Track connections as assemblies (back-off / break / re-fit)

connect() now RECORDS a Connection (anchor feature, moving feature) instead of
just moving a board, so connected parts form an assembly:
- scene.groups(): connected-component part groups via the connection graph.
- explode(distance): back each moving board off along its joint axis (exploded
  view); assemble(): re-seat all (reverse); disconnect(cid/part): break a
  connection — pieces stay in place but become independent.
- _seat() extracted from connect() so re-fit re-runs the mate math.
- delete() drops connections referencing the removed part; clear() resets them;
  connections persist in scene.json.

Parts stay SEPARATE boards (not fused) so the cut list and disassembly keep
working — the assembly is a group, not a merge.

CLI: connections / disconnect / explode / assemble; voice: wood-connect/explode/
assemble/disconnect (25 tools). Fit dialog shows part names. 83 tests pass
(records+groups, explode/assemble roundtrip, disconnect keeps position).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 09:35:34 -03:00
parent e35020382d
commit fad56f4fc3
6 changed files with 219 additions and 8 deletions

View File

@ -195,6 +195,25 @@ TOOLS = {
], ],
"code": code('cmd = [ws, "connect", anchor, moving]'), "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": { "wood-feature-delete": {
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.", "description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
"arguments": [ "arguments": [

View File

@ -177,6 +177,35 @@ def cmd_connect(scene: Scene, args) -> str:
return scene.connect(args.anchor, args.moving) 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: def cmd_feature_delete(scene: Scene, args) -> str:
return scene.delete_feature(args.fid) 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.add_argument("moving", help="Feature id whose board moves to mate")
sp.set_defaults(func=cmd_connect) 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 = 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.add_argument("name", help="Project name, e.g. 'coffee table'")
sp.set_defaults(func=cmd_save) 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) print(str(exc).strip('"'), file=sys.stderr)
return 1 return 1
if args.command not in ("status", "export", "cutlist", "render", "save", if args.command not in ("status", "export", "cutlist", "render", "save",
"projects", "features"): "projects", "features", "connections"):
scene.save(args.scene) scene.save(args.scene)
print(message) print(message)
return 0 return 0

View File

@ -53,6 +53,9 @@ TOOL_CMD = {
rotation=_opt(a.get("rotation")))), rotation=_opt(a.get("rotation")))),
"wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])), "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-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-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-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])), "wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),

View File

@ -155,7 +155,8 @@ class FeaturePanel(QWidget):
lay.addWidget(QLabel(f"Select the {mate} to mate with:")) lay.addWidget(QLabel(f"Select the {mate} to mate with:"))
lst = QListWidget() lst = QListWidget()
for part, f in cands: 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 = QListWidgetItem(label)
item.setData(Qt.UserRole, f.id) item.setData(Qt.UserRole, f.id)
lst.addItem(item) lst.addItem(item)

View File

@ -236,6 +236,16 @@ class Joint:
anchor: str = "end_a" # measure offset from "end_a" (start) or "end_b" (far end) 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): class SceneError(Exception):
"""Raised for invalid operations (bad references, unknown stock, ...).""" """Raised for invalid operations (bad references, unknown stock, ...)."""
@ -246,10 +256,12 @@ class Scene:
units: str = "inch" units: str = "inch"
parts: list[Part] = field(default_factory=list) parts: list[Part] = field(default_factory=list)
joints: list[Joint] = field(default_factory=list) joints: list[Joint] = field(default_factory=list)
connections: list[Connection] = field(default_factory=list)
selection: str | None = None selection: str | None = None
_next_part: int = 1 _next_part: int = 1
_next_joint: int = 1 _next_joint: int = 1
_next_feat: int = 1 _next_feat: int = 1
_next_conn: int = 1
_undo: list[str] = field(default_factory=list, repr=False) _undo: list[str] = field(default_factory=list, repr=False)
_redo: 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._checkpoint()
self.parts = [] self.parts = []
self.joints = [] self.joints = []
self.connections = []
self.selection = None self.selection = None
self._next_part = 1 self._next_part = 1
self._next_joint = 1 self._next_joint = 1
self._next_feat = 1 self._next_feat = 1
self._next_conn = 1
return "Cleared the scene." return "Cleared the scene."
# ----- joinery features -------------------------------------------- # ----- joinery features --------------------------------------------
@ -517,16 +531,18 @@ class Scene:
self.selection = part.id self.selection = part.id
return f"Deleted feature {fid} from {part.id}." return f"Deleted feature {fid} from {part.id}."
def connect(self, anchor_fid: str, moving_fid: str) -> str: def feature_owner(self, fid: str) -> Part:
"""Move/orient the board owning ``moving_fid`` so its feature mates with return self.find_feature(fid)[0]
``anchor_fid`` (tenon seated into mortise: faces meet, axes aligned)."""
anchor_part, _ = self.find_feature(anchor_fid) 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) moving_part, moving_feat = self.find_feature(moving_fid)
if anchor_part is moving_part: if anchor_part is moving_part:
raise SceneError("Connect features on two different boards.") 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. # moving feature's LOCAL frame (in its own board), with its rotation.
t, w = moving_part.section_in 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)) 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)] 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 self.selection = moving_part.id
return f"Connected {moving_part.id} to {anchor_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: def delete(self, ref: str | None) -> str:
self._checkpoint() self._checkpoint()
part = self.resolve(ref) 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.parts = [p for p in self.parts if p.id != part.id]
self.joints = [j for j in self.joints self.joints = [j for j in self.joints
if part.id not in (j.part_a, j.part_b)] 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: if self.selection == part.id:
self.selection = self.parts[-1].id if self.parts else None self.selection = self.parts[-1].id if self.parts else None
return f"Deleted {part.id}." return f"Deleted {part.id}."
@ -587,15 +686,20 @@ class Scene:
for f in p.get("features", [])] for f in p.get("features", [])]
parts.append(Part(**{k: v for k, v in p.items() if k in valid})) parts.append(Part(**{k: v for k, v in p.items() if k in valid}))
joints = [Joint(**j) for j in data.get("joints", [])] 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( return cls(
version=data.get("version", SCENE_VERSION), version=data.get("version", SCENE_VERSION),
units=data.get("units", "inch"), units=data.get("units", "inch"),
parts=parts, parts=parts,
joints=joints, joints=joints,
connections=connections,
selection=data.get("selection"), selection=data.get("selection"),
_next_part=data.get("_next_part", len(parts) + 1), _next_part=data.get("_next_part", len(parts) + 1),
_next_joint=data.get("_next_joint", len(joints) + 1), _next_joint=data.get("_next_joint", len(joints) + 1),
_next_feat=data.get("_next_feat", 1), _next_feat=data.get("_next_feat", 1),
_next_conn=data.get("_next_conn", len(connections) + 1),
_undo=data.get("_undo", []), _undo=data.get("_undo", []),
_redo=data.get("_redo", []), _redo=data.get("_redo", []),
) )

View File

@ -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 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(): def test_connect_needs_two_boards():
s = Scene() s = Scene()
s.place("2x4", 24) s.place("2x4", 24)