From ee18039f2d50c3ecf115d013766978dab8800130 Mon Sep 17 00:00:00 2001 From: chrysn Date: Sat, 13 Apr 2024 20:31:47 +0200 Subject: [PATCH] WIP 3D export --- boxes/__init__.py | 44 ++++++++++-- boxes/assembly.py | 115 +++++++++++++++++++++++++++++++ boxes/drawing.py | 30 ++++++++ boxes/edges.py | 16 +++-- boxes/generators/discrack.py | 19 +++-- boxes/generators/notesholder.py | 33 ++++++--- boxes/generators/slantedtray.py | 19 ++++- boxes/generators/universalbox.py | 9 +++ scripts/boxes | 24 +++++++ 9 files changed, 283 insertions(+), 26 deletions(-) create mode 100644 boxes/assembly.py diff --git a/boxes/__init__.py b/boxes/__init__.py index fd6e4fb65..3c5e57e2d 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -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): """ @@ -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: @@ -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) diff --git a/boxes/assembly.py b/boxes/assembly.py new file mode 100644 index 000000000..1d39b6d0a --- /dev/null +++ b/boxes/assembly.py @@ -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) diff --git a/boxes/drawing.py b/boxes/drawing.py index 3523c6501..cd0b417e8 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -123,6 +123,9 @@ 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: @@ -130,6 +133,14 @@ def __init__(self, name) -> None: 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() @@ -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)) @@ -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): diff --git a/boxes/edges.py b/boxes/edges.py index f2eb92ede..0456b6c16 100644 --- a/boxes/edges.py +++ b/boxes/edges.py @@ -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 """ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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( @@ -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)) diff --git a/boxes/generators/discrack.py b/boxes/generators/discrack.py index e7d6206ba..dbff9acb3 100644 --- a/boxes/generators/discrack.py +++ b/boxes/generators/discrack.py @@ -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) @@ -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) diff --git a/boxes/generators/notesholder.py b/boxes/generators/notesholder.py index 744d26899..35b380fa5 100644 --- a/boxes/generators/notesholder.py +++ b/boxes/generators/notesholder.py @@ -114,18 +114,21 @@ def render(self): b4.startwidth = lambda: b3.startwidth() - for side in range(2): + for (side, (sidename, frontname)) in enumerate([("left", "front"), ("right", "back")]): with self.saved_context(): self.rectangularWall(y, h, [b, "F", "e", "F"], - ignore_widths=[1, 6], move="right") + ignore_widths=[1, 6], move="right", + label=sidename) # front walls if self.opening == 0.0 or (side and not self.back_openings): self.rectangularWall(x, h, [b, "f", "e", "f"], - ignore_widths=[1, 6], move="right") + ignore_widths=[1, 6], move="right", + label=frontname) else: self.rectangularWall(sx[0] * (1-o/100) / 2, h, [b2, "e", "e", "f"], - ignore_widths=[1, 6], move="right") + ignore_widths=[1, 6], move="right", + label=f"{frontname}left") for ix in range(len(sx)-1): left = sx[ix] * (1-o/100) / 2 right = sx[ix+1] * (1-o/100) / 2 @@ -136,15 +139,20 @@ def render(self): left+right+t, h, [bottom_edge, "e", "e", "e"], callback=[lambda: self.fingerHolesAt(left+t/2, 0, h, 90)], - move="right") + move="right", + label=f"{frontname}sep{ix}", + ) self.rectangularWall(sx[-1] * (1-o/100) / 2, h, [b2, "e", "e", "f"], ignore_widths=[1, 6], - move="right mirror") + move="right mirror", + label=f"{frontname}right", + ) self.rectangularWall(x, h, [b, "F", "e", "F"], - ignore_widths=[1, 6], move="up only") + ignore_widths=[1, 6], move="up only", + label=sidename) # hack to have it reversed in second go and then back to normal sx = list(reversed(sx)) @@ -169,9 +177,16 @@ def render(self): x, y, [front_edge, outer_edge, back_edge, outer_edge], callback=[self.fingerHoleCB(sx, y)], - move="up") + move="up", + label="bottom") # innner walls for i in range(len(sx)-1): self.rectangularWall( y, h, ("e" if self.bottom_edge=="e" else "f") + "fef", - move="right") + move="right", + label=f"separator{i}") + + def assemble(self, assembly): + assembly.base("bottom", 1) + assembly.antiparallel_edge("bottom", 1, "right", 0) + assembly.antiparallel_edge("bottom", 3, "left", 0) diff --git a/boxes/generators/slantedtray.py b/boxes/generators/slantedtray.py index df5807fd1..00dea9371 100644 --- a/boxes/generators/slantedtray.py +++ b/boxes/generators/slantedtray.py @@ -61,6 +61,7 @@ def render(self): "eFfF", move="up", callback=[partial(self.finger_holes_CB, sx, h)], + label="back", ) self.rectangularWall( @@ -69,6 +70,7 @@ def render(self): "FFfF", move="up", callback=[partial(self.finger_holes_CB, sx, y)], + label="bottom", ) self.rectangularWall( @@ -79,7 +81,20 @@ def render(self): callback=[ partial(self.finger_holes_CB, sx, front_height) ], + label="front", ) - for _ in range(len(sx) + 1): - self.trapezoidWall(y, h, front_height, "ffef", move="right") + for i in range(len(sx) + 1): + self.trapezoidWall(y, h, front_height, "ffef", move="right", label=f"side{i}") + + def assemble(self, assembly): + left = "side0" + right = f"side{len(self.sx)}" + assembly.base("bottom", 1) + assembly.antiparallel_edge("bottom", 2, "front", 0) + assembly.antiparallel_edge("bottom", 0, "back", 2) + assembly.antiparallel_edge("bottom", 3, left, 0) + + # not rendering right side: that only has parallel, no antiparallel + # edges. (Relatedly, its outer side would not be an inner side; + # granted, there is no right choice for the inner parts anyway). diff --git a/boxes/generators/universalbox.py b/boxes/generators/universalbox.py index b3f136af1..d1204747c 100644 --- a/boxes/generators/universalbox.py +++ b/boxes/generators/universalbox.py @@ -103,3 +103,12 @@ def render(self): bedBolts=[d3], move="up", label="right") + + def assemble(self, assembly): + assembly.base("bottom", 1) + assembly.antiparallel_edge("front", 1, "right", 3) + assembly.antiparallel_edge("front", 3, "left", 1) + assembly.antiparallel_edge("back", 1, "left", 3) # is there any harm in doing this the other way round? IOW, does A/B make a difference? + assembly.antiparallel_edge("back", 3, "right", 1) + + assembly.antiparallel_edge("bottom", 0, "right", 0) # not sure which is really right diff --git a/scripts/boxes b/scripts/boxes index 1496b1012..be02725cd 100755 --- a/scripts/boxes +++ b/scripts/boxes @@ -26,6 +26,7 @@ except ImportError: import boxes import boxes.generators +import boxes.assembly def print_usage(): @@ -62,6 +63,29 @@ def run_generator(name, args): box.open() box.render() data = box.close() + if hasattr(box, "assemble"): + assembly = boxes.assembly.Assembly() + # given how it's later used in verify, should this just become a + # generated property of the box? + box.assemble(assembly) + box.verify(assembly) + assembly.explain() + with open("assembly.scad", "w") as assemblyfile: + for i, part in enumerate(box.surface.parts): + for (label, (coords, length, extra_metadata)) in part.oriented_markers.items(): + a, b, c, d, e, f, g, h, i = coords + matrix3d = [[a, b, 0, c], [d, e, 0, f], [0, 0, 1, 0], [g, h, 0, i]] + import numpy + matrix3dinv = numpy.linalg.inv(matrix3d) + # converting to list-of-lists form is just the easiest way to get a repr that works for OpenSCAD + matrix3dinv = [[i for i in row] for row in matrix3dinv] + part_name = part.name.replace(" ", "_") + print(f"module forward_{part_name}_{label}() {{ multmatrix({matrix3d}) children(); }}", file=assemblyfile) + print(f"module reverse_{part_name}_{label}() {{ multmatrix({matrix3dinv}) children(); }}", file=assemblyfile) + print(f"length_{part_name}_{label} = {length};", file=assemblyfile); + + assembly.openscad(assemblyfile, box=box) + assembly = boxes.assembly.Assembly() with os.fdopen(sys.stdout.fileno(), "wb", closefd=False) if box.output == "-" else open(box.output, 'wb') as f: f.write(data.getvalue()) else: