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:
parent
e530bf7656
commit
a1f6145115
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue