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:
parent
e35020382d
commit
fad56f4fc3
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", []),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue