Skip to content

Commit

Permalink
WIP 3D export
Browse files Browse the repository at this point in the history
  • Loading branch information
chrysn committed Apr 13, 2024
1 parent 669815b commit ee18039
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 26 deletions.
44 changes: 39 additions & 5 deletions boxes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,38 @@ def __init__(self) -> None:
"--burn", action="store", type=float, default=0.1,
help='burn correction (in mm)(bigger values for tighter fit) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#burn)')

def verify(self, assembly):
"""Run any plausibility checks or other verification that can be run,
and raise on error"""

for ((partname_a, edge_a), (partname_b, edge_b)) in assembly._antiparallel_edges:
part_a = [p for p in self.surface.parts if p.name == partname_a]
part_b = [p for p in self.surface.parts if p.name == partname_b]
if len(part_a) != 1:
raise ValueError(f"Part {partname_a} referenced in assembly but not present and unique (found parts: {part_a})")
if len(part_b) != 1:
raise ValueError(f"Part {partname_b} referenced in assembly but not present and unique (found parts: {part_b})")
debug_label = f"{partname_a} {edge_a} / {partname_b} {edge_b}"

((part_a,), (part_b,)) = (part_a, part_b)
try:
(a_coords, a_length, a_extra_metadata) = part_a.oriented_markers[edge_a]
except KeyError:
raise RuntimeError(f"Part {partname_a} should be attached by its edge {edge_a} to {partname_b}, but only has edges {list(part_a.oriented_markers.keys())}") from None
try:
(b_coords, b_length, b_extra_metadata) = part_b.oriented_markers[edge_b]
except KeyError:
raise RuntimeError(f"Part {partname_b} should be attached by its edge {edge_b} to {partname_a}, but only has edges {list(part_b.oriented_markers.keys())}") from None
if a_length != b_length:
raise ValueError(f"Edges {debug_label} are supposed to be shared, but have different lengths ({a_length} != {b_length})")

if a_extra_metadata['edge_parameters'] != b_extra_metadata['edge_parameters']:
raise ValueError("Different edge parameters were used between {debug_label}: {a_extra_metadata['edge_parameters']} vs {b_extra_metadata['edge_parameters']}. This may be OK depending on the precise metadata, but all considered so far (bed bolt settings) need to match")

# We could do checks for whether the types are opposing, and
# whether the details match, but for the time being those will just
# be in the assembly parts.

@contextmanager
def saved_context(self):
"""
Expand Down Expand Up @@ -2412,7 +2444,9 @@ def rectangularWall(self, x, y, edges="eeee",

edges[i](l,
bedBolts=self.getEntry(bedBolts, i),
bedBoltSettings=self.getEntry(bedBoltSettings, i))
bedBoltSettings=self.getEntry(bedBoltSettings, i),
edge_label=i,
)
self.edgeCorner(e1, e2, 90)

if holesMargin is not None:
Expand Down Expand Up @@ -2583,18 +2617,18 @@ def trapezoidWall(self, w, h0, h1, edges="eeee",

self.moveTo(edges[-1].spacing(), edges[0].margin())
self.cc(callback, 0, y=edges[0].startwidth())
edges[0](w)
edges[0](w, edge_label=0)
self.edgeCorner(edges[0], edges[1], 90)
self.cc(callback, 1, y=edges[1].startwidth())
edges[1](h1)
edges[1](h1, edge_label=1)
self.edgeCorner(edges[1], self.edges["e"], 90)
self.corner(a)
self.cc(callback, 2)
edges[2](l)
edges[2](l, edge_label=2)
self.corner(-a)
self.edgeCorner(self.edges["e"], edges[-1], 90)
self.cc(callback, 3, y=edges[-1].startwidth())
edges[3](h0)
edges[3](h0, edge_label=3)
self.edgeCorner(edges[-1], edges[0], 90)

self.move(overallwidth, overallheight, move, label=label)
Expand Down
115 changes: 115 additions & 0 deletions boxes/assembly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
type Part = str
type Edge = int
type Tree = tuple[(Part, Edge), list[(Edge, Tree)]]

class Assembly:
"""An assembly describes how parts of a generator belong together.
It is populated by calling base and antiparallel_edge methods.
"""
def __init__(self):
self._base = None
self._antiparallel_edges = []

def base(self, part, edge):
self._base = (part, edge)

def antiparallel_edge(self, part_a, edge_a, part_b, edge_b):
"""Note that the indicated edges of parts A and part B are
antiparallel.
Antiparallel edges are the typical case in boxes scripts: Parts usually
all follow the same direction (counterclockwise), and boxes are usually
built so that the same side of the material faces out."""
self._antiparallel_edges.append(((part_a, edge_a), (part_b, edge_b)))

def tree(self) -> Tree:
"""Greedily assemble a tree out of all connected parts starting from
the base. Currently this goes depth-first to make any alignment issues
as visible as possible.
"""

def tree_from(self, start, exclude) -> tuple[(Tree, set[Part])]:
children = []
covered_parts = exclude | {start[0]}
for ((a, ae), (b, be)) in self._antiparallel_edges:
child_tree = None
if a == start[0] and b not in covered_parts:
child_tree, new_covered_parts = tree_from(self, (b, be), covered_parts)
child_tree_startingedge = ae
elif b == start[0] and a not in covered_parts:
child_tree, new_covered_parts = tree_from(self, (a, ae), covered_parts)
child_tree_startingedge = be
if child_tree is not None:
covered_parts |= new_covered_parts
children.append((child_tree_startingedge, child_tree))

return ((start, children), covered_parts)

return tree_from(self, self._base, set())[0]

def explain(self, *, _tree=None, _indent=0):
if _tree is None:
_tree = self.tree()

((part, edge), children) = _tree
print(" "*_indent, f"Painting part {part} starting at its edge {edge}")
for (parentedge, child) in children:
print(" "*_indent, f"at its edge {parentedge}:")
self.explain(_tree=child, _indent=_indent + 1)

def openscad(self, file, *, box, _tree=None, _indent=0):
# FIXME: Given this takes box and assembly, it should move to a method
# of the box, maybe (b/c the assembly may become a property of that)

if _tree is None:
_tree = self.tree()

((part, edge), children) = _tree

i = " " * _indent

(parent_part,) = [p for p in box.surface.parts if p.name == part]

print(f'{i}reverse_{part}_{edge}() {{ part("{part}"); ', file=file);
for (parent_edge, child) in children:
# We could inline the lengths and translations; currently they are
# generated independently and probably that's a good thing because
# it's useful for other interactions with the visualizations as
# well (like for pointing out things in the planarized plot maybe?
# Well, at least during debugging that was useful.) ... and before
# we accessed self.surface.parts for the extra_metadata, this code
# was just a function of the assembly and not the box/surface

(child_part,) = [p for p in box.surface.parts if p.name == child[0][0]]
# 0 and 1 are the coords and length we take for granted as a symbol in openscad
parent_edge_kind = parent_part.oriented_markers[parent_edge][2]['edge_kind']
child_edge_kind = child_part.oriented_markers[child[0][1]][2]['edge_kind']

# This particular check is also done in verify()
print(f'{i} assert(length_{part}_{parent_edge} == length_{child[0][0]}_{child[0][1]});', file=file)

print(f'{i} forward_{part}_{parent_edge}() // Shift coordinates to the correct edge of the parent', file=file)

parent_positive = getattr(parent_edge_kind, "positive", None)
child_positive = getattr(child_edge_kind, "positive", None)
from . import edges
if isinstance(parent_edge_kind, edges.FingerJointEdge) and parent_positive == True and isinstance(child_edge_kind, edges.FingerHoleEdge):
print(f'{i} translate([0, 0, -2*3]) rotate([-fold_angle, 0, 0]) // Fold up', file=file) # FIXME thickness and extract parameters
elif isinstance(parent_edge_kind, edges.FingerJointEdge) and parent_positive == True and isinstance(child_edge_kind, edges.StackableEdge):
print(f'{i} translate([0, 0, -2*3 - 8]) rotate([-fold_angle, 0, 0]) // Fold up', file=file) # FIXME thickness and extract parameters, worse: the 8 are just a guess
elif parent_positive == False and child_positive == True:
print(f'{i} rotate([-fold_angle, 0, 0]) translate([0, 0, 3]) // Fold up', file=file) # FIXME thickness
elif parent_positive == True and child_positive == False:
# It'd be great to autoamte that as inverse-of the other directin
print(f'{i} translate([0, 0, -3]) rotate([-fold_angle, 0, 0]) // Fold up', file=file) # FIXME thickness
else:
print(f"Warning: No rules describe how to assemble a {parent_edge_kind} and a {child_edge_kind}. Coarsely rotating; children are marked in transparent red.")
print(f"{i} #", file=file)
print(f'{i} rotate([-fold_angle, 0, 0])', file=file)

print(f'{i} translate([length_{part}_{parent_edge}, 0, 0]) rotate([0, 0, 180]) // Edge is antiparallel', file=file)


self.openscad(file=file, box=box, _tree=child, _indent=_indent + 1)
print(f"{i}}}", file=file)
30 changes: 30 additions & 0 deletions boxes/drawing.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,24 @@ def extents(self):
return Extents()
return sum([p.extents() for p in self.parts])

def place_oriented_marker(self, coords, label, length, extra_metadata):
self._p.place_oriented_marker(coords, label, length, extra_metadata)


class Part:
def __init__(self, name) -> None:
self.name: str = name
self.pathes: list[Any] = []
self.path: list[Any] = []

self.oriented_markers = {}

def __repr__(self):
return "<%s %s at %#x>" % (type(self).__name__, repr(self.name) if self.name else "(unnamed)", id(self))

def place_oriented_marker(self, coords, label, length, extra_metadata):
self.oriented_markers[label] = (coords, length, extra_metadata)

def extents(self):
if not self.pathes:
return Extents()
Expand All @@ -140,6 +151,11 @@ def transform(self, f, m, invert_y=False):
for p in self.pathes:
p.transform(f, m, invert_y)

new_oriented_markers = {}
for (label, (transform, length, extra_metadata)) in self.oriented_markers.items():
new_oriented_markers[label] = (m * transform, f * length, extra_metadata)
self.oriented_markers = new_oriented_markers

def append(self, *path):
self.path.append(list(path))

Expand Down Expand Up @@ -417,6 +433,20 @@ def flush(self):
def new_part(self, *args, **kwargs):
self._dwg.new_part(*args, **kwargs)

def place_oriented_marker(self, label, length=None, extra_metadata=None):
# The length is well justified because it can be used both to give a
# visualization to the marker and to align two markers in antiparallel
# offset by their length.
#
# The extra_metadata has no justification whatsoever in being part of
# the draw module. It is merely there because due to how move works,
# metadata on which part we're actually active on is unavailable at
# BaseEdge.__call__ time, and is only retroactively populated when the
# part is moved. A `with self.part(part name) as slef:` or similar
# mechanic would allow more explicit control, but as things are, the
# metadata is just passed along through the draw code.
self._renderer.place_oriented_marker(self._m, label, length, extra_metadata)


class SVGSurface(Surface):

Expand Down
16 changes: 12 additions & 4 deletions boxes/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,9 @@ def __getattr__(self, name):
return getattr(self.boxes, name)

@abstractmethod
def __call__(self, length, **kw):
pass
def __call__(self, length, edge_label=None, **kw):
if edge_label is not None:
self.ctx.place_oriented_marker(edge_label, length, {"edge_kind": self, "edge_parameters": kw})

def startwidth(self) -> float:
"""Amount of space the beginning of the edge is set below the inner space of the part """
Expand Down Expand Up @@ -339,6 +340,7 @@ class Edge(BaseEdge):

def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw):
"""Draw edge of length mm"""
super().__call__(length=length, bedBolts=bedBolts, bedBoltSettings=bedBoltSettings, **kw)
if bedBolts:
# distribute the bolts equidistantly
interval_length = length / bedBolts.bolts
Expand Down Expand Up @@ -944,7 +946,7 @@ def draw_finger(self, f, h, style, positive: bool = True, firsthalf: bool = True
self.polyline(0, 90, h, -90, f, -90, h, 90)

def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw):

super().__call__(length=length, bedBolts=bedBolts, bedBoltSettings=bedBoltSettings, **kw)
positive = self.positive
t = self.settings.thickness

Expand Down Expand Up @@ -1019,7 +1021,7 @@ def __init__(self, boxes, settings) -> None:
self.ctx = boxes.ctx
self.settings = settings

def __call__(self, x, y, length, angle=90, bedBolts=None, bedBoltSettings=None):
def __call__(self, x, y, length, angle=90, bedBolts=None, bedBoltSettings=None, edge_label=None):
"""
Draw holes for a matching finger joint edge
Expand All @@ -1030,6 +1032,9 @@ def __call__(self, x, y, length, angle=90, bedBolts=None, bedBoltSettings=None):
:param bedBolts: (Default value = None)
:param bedBoltSettings: (Default value = None)
"""
# not an edge, replicating BasicEdge code
if edge_label is not None:
self.ctx.place_oriented_marker(edge_label, length, {"edge_kind": self, "edge_parameters": dict(bedBolts=bedBolts, bedBoltSettings=bedBoltSettings)})
with self.boxes.saved_context():
self.boxes.moveTo(x, y, angle)
s, f = self.settings.space, self.settings.finger
Expand Down Expand Up @@ -1073,6 +1078,7 @@ def __init__(self, boxes, fingerHoles=None, **kw) -> None:
self.fingerHoles = fingerHoles or boxes.fingerHolesAt

def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw):
super().__call__(length=length, bedBolts=bedBolts, bedBoltSettings=bedBoltSettings, **kw)
dist = self.fingerHoles.settings.edge_width
with self.saved_context():
self.fingerHoles(
Expand Down Expand Up @@ -1180,6 +1186,8 @@ def __init__(self, boxes, settings, fingerjointsettings) -> None:
self.fingerjointsettings = fingerjointsettings

def __call__(self, length, **kw):
super().__call__(length, **kw)

s = self.settings
r = s.height / 2.0 / (1 - math.cos(math.radians(s.angle)))
l = r * math.sin(math.radians(s.angle))
Expand Down
19 changes: 13 additions & 6 deletions boxes/generators/discrack.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,13 @@ def sidewall_holes(self):
r * self.rear_factor,
-r * self.lower_factor - self.thickness/2,
90)
self.fingerHolesAt(0, 0, self.lower_size)
self.fingerHolesAt(0, 0, self.lower_size, edge_label=4)
with self.saved_context():
self.moveTo(
r * self.rear_factor + self.thickness/2,
-r * self.lower_factor,
0)
self.fingerHolesAt(0, 0, self.rear_size)
self.fingerHolesAt(0, 0, self.rear_size, edge_label=5)

if self.debug:
self.circle(0, 0, self.disc_diameter / 2)
Expand Down Expand Up @@ -259,8 +259,15 @@ def render(self):
self.lower_factor = min(self.lower_factor, 0.99)
self.rear_factor = min(self.rear_factor, 0.99)

self.rectangularWall(o, o, "eeee", move="right", callback=[self.sidewall_holes])
self.rectangularWall(o, o, "eeee", move="right mirror", callback=[self.sidewall_holes])
self.rectangularWall(o, o, "eeee", move="right", callback=[self.sidewall_holes], label="left")
self.rectangularWall(o, o, "eeee", move="right mirror", callback=[self.sidewall_holes], label="right")

self.rectangularWall(self.lower_size, sum(self.sx), "fffe", move="right", callback=[self.lower_holes])
self.rectangularWall(self.rear_size, sum(self.sx), "fefh", move="right", callback=[self.rear_holes])
self.rectangularWall(self.lower_size, sum(self.sx), "fffe", move="right", callback=[self.lower_holes], label="bottom")
self.rectangularWall(self.rear_size, sum(self.sx), "fefh", move="right", callback=[self.rear_holes], label="rear")

def assemble(self, assembly):
assembly.base("left", 1)
assembly.antiparallel_edge("left", 4, "bottom", 2)
# assembly.parallel_edge("left", 5, "rear", 2) # no such function yet
assembly.antiparallel_edge("rear", 3, "bottom", 1)
assembly.antiparallel_edge("right", 5, "rear", 0)
Loading

0 comments on commit ee18039

Please sign in to comment.