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