diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index a7292f4..6469eee 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -195,6 +195,25 @@ TOOLS = { ], "code": code('cmd = [ws, "connect", anchor, moving]'), }, + "wood-explode": { + "description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.", + "arguments": [ + {"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"}, + ], + "code": code('cmd = [ws, "explode", distance]'), + }, + "wood-assemble": { + "description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.", + "arguments": [], + "code": code('cmd = [ws, "assemble"]'), + }, + "wood-disconnect": { + "description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.", + "arguments": [ + {"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"}, + ], + "code": code('cmd = [ws, "disconnect", connection]'), + }, "wood-feature-delete": { "description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.", "arguments": [ diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 90252dc..64bacc3 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -177,6 +177,35 @@ def cmd_connect(scene: Scene, args) -> str: return scene.connect(args.anchor, args.moving) +def cmd_connections(scene: Scene, args) -> str: + if not scene.connections: + return "No connections." + lines = [] + for c in scene.connections: + if not scene._conn_valid(c): + lines.append(f" {c.id}: (stale)") + continue + ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving) + off = f" (backed off {c.backed_off_in:g}\")" if c.backed_off_in else "" + lines.append(f" {c.id}: {mp.id}.{c.moving} → {ap.id}.{c.anchor}{off}") + groups = [g for g in scene.groups() if len(g) > 1] + if groups: + lines.append("Assemblies: " + "; ".join("+".join(g) for g in groups)) + return "\n".join(lines) + + +def cmd_disconnect(scene: Scene, args) -> str: + return scene.disconnect(cid=args.connection) + + +def cmd_explode(scene: Scene, args) -> str: + return scene.explode(to_inches(args.distance)) + + +def cmd_assemble(scene: Scene, args) -> str: + return scene.assemble() + + def cmd_feature_delete(scene: Scene, args) -> str: return scene.delete_feature(args.fid) @@ -348,6 +377,18 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("moving", help="Feature id whose board moves to mate") sp.set_defaults(func=cmd_connect) + sub.add_parser("connections", help="List connections / assemblies").set_defaults(func=cmd_connections) + + sp = sub.add_parser("disconnect", help="Break a connection (pieces stay in place)") + sp.add_argument("connection", help="Connection id, e.g. c1") + sp.set_defaults(func=cmd_disconnect) + + sp = sub.add_parser("explode", help="Back connections off along their joint axes") + sp.add_argument("distance", help="Distance, e.g. '3 in'") + sp.set_defaults(func=cmd_explode) + + sub.add_parser("assemble", help="Re-fit all connections (seat the joints)").set_defaults(func=cmd_assemble) + sp = sub.add_parser("save", help="Save the current scene as a named project") sp.add_argument("name", help="Project name, e.g. 'coffee table'") sp.set_defaults(func=cmd_save) @@ -390,7 +431,7 @@ def main(argv: list[str] | None = None) -> int: print(str(exc).strip('"'), file=sys.stderr) return 1 if args.command not in ("status", "export", "cutlist", "render", "save", - "projects", "features"): + "projects", "features", "connections"): scene.save(args.scene) print(message) return 0 diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 3652419..7839fb4 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -53,6 +53,9 @@ TOOL_CMD = { rotation=_opt(a.get("rotation")))), "wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])), "wood-connect": lambda a: (cli.cmd_connect, SimpleNamespace(anchor=a["anchor"], moving=a["moving"])), + "wood-explode": lambda a: (cli.cmd_explode, SimpleNamespace(distance=a["distance"])), + "wood-assemble": lambda a: (cli.cmd_assemble, SimpleNamespace()), + "wood-disconnect": lambda a: (cli.cmd_disconnect, SimpleNamespace(connection=a["connection"])), "wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))), "wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))), "wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])), diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index ceb3c56..07344ae 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -155,7 +155,8 @@ class FeaturePanel(QWidget): lay.addWidget(QLabel(f"Select the {mate} to mate with:")) lst = QListWidget() for part, f in cands: - label = f"{part.id} · {f.id}: {f.kind} {f.width_in:g}×{f.height_in:g}×{f.depth_in:g}" + who = f"{part.id} ({part.name})" if part.name else part.id + label = f"{who} · {f.id}: {f.kind} {f.width_in:g}×{f.height_in:g}×{f.depth_in:g}" item = QListWidgetItem(label) item.setData(Qt.UserRole, f.id) lst.addItem(item) diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 6630db9..cf68790 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -236,6 +236,16 @@ class Joint: anchor: str = "end_a" # measure offset from "end_a" (start) or "end_b" (far end) +@dataclass +class Connection: + """A recorded mate between two features (anchor stays, moving board seats into + it). Tracking it lets us group, explode (back off), break, and re-fit.""" + id: str + anchor: str # anchor feature id + moving: str # moving feature id (its board was repositioned) + backed_off_in: float = 0.0 # current explode offset along the joint axis + + class SceneError(Exception): """Raised for invalid operations (bad references, unknown stock, ...).""" @@ -246,10 +256,12 @@ class Scene: units: str = "inch" parts: list[Part] = field(default_factory=list) joints: list[Joint] = field(default_factory=list) + connections: list[Connection] = field(default_factory=list) selection: str | None = None _next_part: int = 1 _next_joint: int = 1 _next_feat: int = 1 + _next_conn: int = 1 _undo: list[str] = field(default_factory=list, repr=False) _redo: list[str] = field(default_factory=list, repr=False) @@ -464,10 +476,12 @@ class Scene: self._checkpoint() self.parts = [] self.joints = [] + self.connections = [] self.selection = None self._next_part = 1 self._next_joint = 1 self._next_feat = 1 + self._next_conn = 1 return "Cleared the scene." # ----- joinery features -------------------------------------------- @@ -517,16 +531,18 @@ class Scene: self.selection = part.id return f"Deleted feature {fid} from {part.id}." - def connect(self, anchor_fid: str, moving_fid: str) -> str: - """Move/orient the board owning ``moving_fid`` so its feature mates with - ``anchor_fid`` (tenon seated into mortise: faces meet, axes aligned).""" - anchor_part, _ = self.find_feature(anchor_fid) + def feature_owner(self, fid: str) -> Part: + return self.find_feature(fid)[0] + + def _seat(self, anchor_fid: str, moving_fid: str): + """Position/orient the moving board so its feature mates with the anchor. + Returns (anchor_part, moving_part, anchor_normal). No checkpoint/record.""" + anchor_part, anchor_feat = self.find_feature(anchor_fid) moving_part, moving_feat = self.find_feature(moving_fid) if anchor_part is moving_part: raise SceneError("Connect features on two different boards.") - self._checkpoint() - pa, na, ua, va = anchor_part.feature_world_frame(self.find_feature(anchor_fid)[1]) + pa, na, ua, va = anchor_part.feature_world_frame(anchor_feat) # moving feature's LOCAL frame (in its own board), with its rotation. t, w = moving_part.section_in @@ -549,15 +565,98 @@ class Scene: moved_fp = tuple(sum(R[i][j] * fp_l[j] for j in range(3)) for i in range(3)) moving_part.position_in = [pa[i] - moved_fp[i] for i in range(3)] + return anchor_part, moving_part, na + + def connect(self, anchor_fid: str, moving_fid: str) -> str: + """Seat the moving board into the anchor and record the connection.""" + self._checkpoint() + anchor_part, moving_part, _ = self._seat(anchor_fid, moving_fid) + existing = next((c for c in self.connections + if c.anchor == anchor_fid and c.moving == moving_fid), None) + if existing: + existing.backed_off_in = 0.0 + else: + self.connections.append(Connection(id=f"c{self._next_conn}", + anchor=anchor_fid, moving=moving_fid)) + self._next_conn += 1 self.selection = moving_part.id return f"Connected {moving_part.id} to {anchor_part.id}." + def _conn_valid(self, c: Connection) -> bool: + try: + self.find_feature(c.anchor) + self.find_feature(c.moving) + return True + except SceneError: + return False + + def assemble(self) -> str: + """Re-fit every connection (seat the moving boards back into place).""" + self._checkpoint() + n = 0 + for c in self.connections: + if self._conn_valid(c): + self._seat(c.anchor, c.moving) + c.backed_off_in = 0.0 + n += 1 + return f"Re-fitted {n} connection(s)." + + def explode(self, distance: float) -> str: + """Back off each moving board along its joint axis (exploded view).""" + self._checkpoint() + n = 0 + for c in self.connections: + if not self._conn_valid(c): + continue + _, mp, na = self._seat(c.anchor, c.moving) + mp.position_in = [mp.position_in[i] + na[i] * distance for i in range(3)] + c.backed_off_in = distance + n += 1 + return f"Backed off {n} connection(s) by {distance:g} in." + + def disconnect(self, cid: str | None = None, part: str | None = None) -> str: + """Break connection(s): pieces stay in place but become independent.""" + self._checkpoint() + before = len(self.connections) + if cid: + self.connections = [c for c in self.connections if c.id != cid] + elif part is not None: + pid = self.resolve(part).id + self.connections = [c for c in self.connections + if not (self._conn_valid(c) + and pid in (self.feature_owner(c.anchor).id, + self.feature_owner(c.moving).id))] + else: + self.connections = [] + return f"Broke {before - len(self.connections)} connection(s)." + + def groups(self) -> list[list[str]]: + """Connected-component part groups (assemblies) via the connection graph.""" + parent = {p.id: p.id for p in self.parts} + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + for c in self.connections: + if self._conn_valid(c): + parent[find(self.feature_owner(c.anchor).id)] = find(self.feature_owner(c.moving).id) + groups: dict[str, list[str]] = {} + for p in self.parts: + groups.setdefault(find(p.id), []).append(p.id) + return list(groups.values()) + def delete(self, ref: str | None) -> str: self._checkpoint() part = self.resolve(ref) + dead_features = {f.id for f in part.features} self.parts = [p for p in self.parts if p.id != part.id] self.joints = [j for j in self.joints if part.id not in (j.part_a, j.part_b)] + self.connections = [c for c in self.connections + if not (dead_features & {c.anchor, c.moving})] if self.selection == part.id: self.selection = self.parts[-1].id if self.parts else None return f"Deleted {part.id}." @@ -587,15 +686,20 @@ class Scene: for f in p.get("features", [])] parts.append(Part(**{k: v for k, v in p.items() if k in valid})) joints = [Joint(**j) for j in data.get("joints", [])] + conn_fields = {f.name for f in fields(Connection)} + connections = [Connection(**{k: v for k, v in c.items() if k in conn_fields}) + for c in data.get("connections", [])] return cls( version=data.get("version", SCENE_VERSION), units=data.get("units", "inch"), parts=parts, joints=joints, + connections=connections, selection=data.get("selection"), _next_part=data.get("_next_part", len(parts) + 1), _next_joint=data.get("_next_joint", len(joints) + 1), _next_feat=data.get("_next_feat", 1), + _next_conn=data.get("_next_conn", len(connections) + 1), _undo=data.get("_undo", []), _redo=data.get("_redo", []), ) diff --git a/tests/test_scene.py b/tests/test_scene.py index 890512e..bc57e94 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -248,6 +248,49 @@ def test_connect_seats_tenon_in_mortise(): assert nb == pytest.approx(tuple(-x for x in na), abs=1e-6) # tenon points into mortise +def _two_connected(): + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1) + s.place("2x4", 12) + s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) + s.connect("f1", "f2") + return s + + +def test_connect_records_and_groups(): + s = _two_connected() + assert len(s.connections) == 1 + groups = [g for g in s.groups() if len(g) > 1] + assert groups and set(groups[0]) == {"p1", "p2"} + + +def test_explode_then_assemble_roundtrip(): + s = _two_connected() + seated = list(s.get_part("p2").position_in) + s.explode(5) + assert s.get_part("p2").position_in != seated + assert s.connections[0].backed_off_in == 5 + s.assemble() + assert s.get_part("p2").position_in == pytest.approx(seated) + assert s.connections[0].backed_off_in == 0 + + +def test_disconnect_keeps_position_and_ungroups(): + s = _two_connected() + pos = list(s.get_part("p2").position_in) + s.disconnect(cid="c1") + assert s.connections == [] + assert s.get_part("p2").position_in == pos # pieces stay put + assert all(len(g) == 1 for g in s.groups()) # no longer one assembly + + +def test_delete_drops_connections(): + s = _two_connected() + s.delete("p2") + assert s.connections == [] + + def test_connect_needs_two_boards(): s = Scene() s.place("2x4", 24)