Connect: choose which board moves + drag the sub-assembly along

- 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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 10:49:38 -03:00
parent e530bf7656
commit a1f6145115
5 changed files with 78 additions and 7 deletions

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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) # BC 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)