From a1f614511508c432f4f1eb98e7c356e7ce856af8 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 10:49:38 -0300 Subject: [PATCH] Connect: choose which board moves + drag the sub-assembly along MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Fit dialog's "Make connection" now has a "Reposition the other board / this board" choice (controller.make_connection(move_self=)), so you pick which side moves to seat the joint. - scene.connect now group-moves: it captures the moving board's pre-seat pose, seats it, then applies the same rigid transform (_drag_group) to every board in its existing sub-assembly (excluding the anchor's group) — so previously connected parts travel with it instead of being left behind. 85 tests pass (sub-assembly stays rigid through a connect; verified by render: a post seated into a rail carried its attached board along). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +++- src/woodshop/gui/controller.py | 9 ++++--- src/woodshop/gui/feature_panel.py | 10 ++++++-- src/woodshop/scene.py | 40 ++++++++++++++++++++++++++++++- tests/test_scene.py | 21 ++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a7cddba..99ef3e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,10 @@ pytest # 25 tests stay separate boards (not fused — cut list & disassembly keep working). Ops: `scene.explode(d)` backs each moving board off along its joint axis, `assemble()` re-seats (reverse), `disconnect(cid/part)` breaks (pieces stay - put). `_seat()` is the shared mate math. CLI `connections/disconnect/explode/ + put). `_seat()` is the shared mate math; `connect()` then `_drag_group()` + applies the same rigid transform to the moving board's existing sub-assembly + (minus the anchor's group) so connected boards travel together. The GUI Fit + dialog lets you pick which board repositions (`make_connection(move_self=)`). CLI `connections/disconnect/explode/ assemble`; voice `wood-explode/assemble/disconnect`. The GUI Parts tab is a QTreeWidget grouping connected boards under an assembly node, with a right-click menu (back off / re-fit / break). Not yet: countersinks, diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index db9215a..8e11999 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -304,13 +304,16 @@ class Controller(QObject): def groups(self) -> list[list[str]]: return self.scene.groups() - def make_connection(self, target_fid: str) -> None: - """Move/orient the target's board so its feature seats into the active one.""" + def make_connection(self, target_fid: str, move_self: bool = False) -> None: + """Seat the two features together. By default the target's board moves; + move_self moves the active feature's board instead. The moving board's + whole sub-assembly travels with it.""" feat = self.active_feature_obj() if not feat: return + anchor, moving = (target_fid, feat.id) if move_self else (feat.id, target_fid) try: - msg = self.scene.connect(feat.id, target_fid) + msg = self.scene.connect(anchor, moving) except SceneError as exc: self.logged.emit("sys", str(exc).strip('"')) return diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index 1321d53..509bff1 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -165,8 +165,14 @@ class FeaturePanel(QWidget): lambda cur, _prev: self.c.highlight_feature(cur.data(Qt.UserRole)) if cur else None) lst.setCurrentRow(0) lay.addWidget(lst) - connect_cb = QCheckBox("Make connection (move && orient the other board to seat the joint)") + connect_cb = QCheckBox("Make connection (seat the joint together)") lay.addWidget(connect_cb) + which = QComboBox() + which.addItems(["Reposition the other board", "Reposition this board"]) + which.setToolTip("Which board moves to seat the joint (its whole assembly moves with it)") + which.setEnabled(False) + connect_cb.toggled.connect(which.setEnabled) + lay.addWidget(which) bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(dlg.accept) bb.rejected.connect(dlg.reject) @@ -176,7 +182,7 @@ class FeaturePanel(QWidget): target = lst.currentItem().data(Qt.UserRole) self.c.fit_feature(target) # size to match if connect_cb.isChecked(): - self.c.make_connection(target) # then assemble + self.c.make_connection(target, move_self=which.currentIndex() == 1) else: self.c.highlight_feature(feat.id) # restore highlight on cancel diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index cf68790..069f0c9 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -37,6 +37,14 @@ def _dot(u, v): return u[0] * v[0] + u[1] * v[1] + u[2] * v[2] +def _transpose(M): + return [[M[j][i] for j in range(3)] for i in range(3)] + + +def _mv(M, x): # 3x3 matrix · 3-vector + return tuple(sum(M[i][j] * x[j] for j in range(3)) for i in range(3)) + + def _rot_x(deg): c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) return [[1, 0, 0], [0, c, -s], [0, s, c]] @@ -567,10 +575,40 @@ class Scene: moving_part.position_in = [pa[i] - moved_fp[i] for i in range(3)] return anchor_part, moving_part, na + def _group_of(self, pid: str) -> set: + for g in self.groups(): + if pid in g: + return set(g) + return {pid} + + def _drag_group(self, lead: Part, old_R, old_p, member_ids) -> None: + """Apply the rigid move that `lead` just underwent to the other members of + its assembly, so connected boards travel together.""" + new_R, new_p = lead.rotation_matrix(), lead.position_in + Rd = _matmul(new_R, _transpose(old_R)) # delta rotation + for pid in member_ids: + if pid == lead.id: + continue + q = self.get_part(pid) + q.yaw_deg, q.tilt_deg, q.roll_deg = matrix_to_ypr(_matmul(Rd, q.rotation_matrix())) + rel = [q.position_in[i] - old_p[i] for i in range(3)] + moved = _mv(Rd, rel) + q.position_in = [new_p[i] + moved[i] for i in range(3)] + def connect(self, anchor_fid: str, moving_fid: str) -> str: - """Seat the moving board into the anchor and record the connection.""" + """Seat the moving board into the anchor and record the connection. If the + moving board is already part of an assembly, that whole sub-assembly moves + with it (rigidly).""" self._checkpoint() + moving_part = self.feature_owner(moving_fid) + anchor_part = self.feature_owner(anchor_fid) + # boards rigidly attached to the moving one (minus the anchor's group) + move_with = self._group_of(moving_part.id) - self._group_of(anchor_part.id) + old_R, old_p = moving_part.rotation_matrix(), list(moving_part.position_in) + anchor_part, moving_part, _ = self._seat(anchor_fid, moving_fid) + self._drag_group(moving_part, old_R, old_p, move_with) + existing = next((c for c in self.connections if c.anchor == anchor_fid and c.moving == moving_fid), None) if existing: diff --git a/tests/test_scene.py b/tests/test_scene.py index bc57e94..d6792e5 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -291,6 +291,27 @@ def test_delete_drops_connections(): assert s.connections == [] +def test_connecting_drags_connected_subassembly(): + 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) # f1 (A) + s.place("2x4", 12) + s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f2 (B end) + s.add_feature("p2", "mortise", face="top", along_in=6, width_in=1.5, height_in=1, depth_in=1) # f3 (B top) + s.place("2x4", 8) + s.add_feature("p3", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f4 (C) + s.connect("f3", "f4") # C seats into B; B stays, C moves + + def dist(a, b): + return math.dist(s.get_part(a).position_in, s.get_part(b).position_in) + + d_bc = dist("p2", "p3") + c_before = list(s.get_part("p3").position_in) + s.connect("f1", "f2") # B seats into A — C should ride along + assert dist("p2", "p3") == pytest.approx(d_bc, abs=1e-6) # B–C kept rigid + assert s.get_part("p3").position_in != c_before # C actually moved + + def test_connect_needs_two_boards(): s = Scene() s.place("2x4", 24)