Add PoC core: scene model, operations, geometry, viewer

Voice-driven woodworking modeler core (the woodshop-specific half;
voice/AI plumbing will reuse existing CmdForge tools).

- scene.py: Part/Joint/Scene model, place/join/sand/delete/undo, JSON
  persistence (atomic), selection + undo stack
- lumber.py: nominal->actual dimensional lumber table
- units.py: parse "6 ft" / "3 ft 6 in" / "10 inches" to inches
- cli.py: `woodshop` CLI (place/join/sand/delete/undo/export/status)
- geometry.py: build123d solids + STL/STEP export
- viewer.py: live pyvista viewport watching scene.json
- tests: 20 passing, including the canonical example sentence
- pyproject: woodshop + woodshop-view entry points, [viewer] extra

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 01:15:01 -03:00
parent 70591ad6fe
commit a688623caf
10 changed files with 707 additions and 0 deletions

View File

@ -10,7 +10,16 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
woodshop = "woodshop.cli:main"
woodshop-view = "woodshop.viewer:main"
[project.optional-dependencies]
# Heavy 3D stack (OpenCASCADE etc.) — only needed to run the live viewport.
viewer = [
"build123d>=0.6",
"pyvista>=0.43",
]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",

11
src/woodshop/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""WoodShop - voice-driven conversational 3D woodworking & furniture modeler.
Architecture (see CLAUDE.md):
- The *scene* (parts + joints) is the single source of truth, persisted as JSON.
- Voice/AI/agent-loop plumbing is reused from existing CmdForge tools
(`dictate`, `read-aloud`, `pa-load-tools`, `pa-reason-core`, `pa-tool-loop`).
- This package owns only what is genuinely woodshop-specific: the scene model,
the woodworking operations, and a live 3D viewport.
"""
__version__ = "0.1.0"

3
src/woodshop/__main__.py Normal file
View File

@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())

127
src/woodshop/cli.py Normal file
View File

@ -0,0 +1,127 @@
"""WoodShop command-line interface.
Each subcommand loads the active scene, applies one operation, saves, and prints
a short human-readable confirmation (which the driver speaks back via TTS). The
CmdForge `wood-*` tools are thin wrappers around these subcommands, so the
operation logic lives here once.
"""
from __future__ import annotations
import argparse
import sys
from .scene import Scene, SceneError
from .units import to_inches
def _fmt_len(inches: float) -> str:
feet, rem = divmod(round(inches, 2), 12)
if feet and rem:
return f"{int(feet)} ft {rem:g} in"
if feet:
return f"{int(feet)} ft"
return f"{rem:g} in"
def cmd_place(scene: Scene, args) -> str:
length = to_inches(args.length, default_unit=args.unit)
part = scene.place(args.stock, length)
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}."
def cmd_join(scene: Scene, args) -> str:
offset = to_inches(args.offset, default_unit=args.unit) if args.offset else 0.0
joint = scene.join(args.part_a, args.part_b, angle_deg=args.angle,
offset_in=offset, anchor=args.anchor)
where = f" {_fmt_len(offset)} from {'the start' if args.anchor == 'end_a' else 'the end'}" if offset else ""
return f"Joined {joint.part_b} to {joint.part_a} at {args.angle:g} degrees{where}."
def cmd_sand(scene: Scene, args) -> str:
part = scene.finish(args.part, kind="sanded")
return f"Sanded {part.id}."
def cmd_delete(scene: Scene, args) -> str:
return scene.delete(args.part)
def cmd_undo(scene: Scene, args) -> str:
return scene.undo()
def cmd_reset(scene: Scene, args) -> str:
scene.__dict__.update(Scene().__dict__)
return "Cleared the scene."
def cmd_export(scene: Scene, args) -> str:
from .geometry import export # lazy: keeps build123d out of the core path
path = export(scene, args.path)
return f"Exported {len(scene.parts)} part(s) to {path}."
def cmd_status(scene: Scene, args) -> str:
lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); "
f"selection: {scene.selection or 'none'}"]
for p in scene.parts:
fin = f" [{', '.join(p.finishes)}]" if p.finishes else ""
lines.append(f" {p.id}: {_fmt_len(p.length_in)} {p.stock}{fin}")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="woodshop", description="Voice/CLI woodworking operations.")
p.add_argument("--scene", help="Path to scene.json (default: $WOODSHOP_SCENE or XDG data dir)")
sub = p.add_subparsers(dest="command", required=True)
sp = sub.add_parser("place", help="Place a new board")
sp.add_argument("stock", help="Nominal stock, e.g. 2x4")
sp.add_argument("length", help="Length, e.g. '6 ft' or '72'")
sp.add_argument("--unit", default="inch", help="Default unit for bare numbers (inch|foot)")
sp.set_defaults(func=cmd_place)
sp = sub.add_parser("join", help="Join one board to another")
sp.add_argument("part_b", help="Board being attached, e.g. p2")
sp.add_argument("--to", dest="part_a", default=None, help="Board to attach to (default: selection)")
sp.add_argument("--angle", type=float, default=90.0, help="Angle in degrees")
sp.add_argument("--offset", default=None, help="Distance from anchor, e.g. '10 in'")
sp.add_argument("--anchor", choices=["end_a", "end_b"], default="end_b",
help="Measure offset from start (end_a) or far end (end_b)")
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_join)
sp = sub.add_parser("sand", help="Sand a board")
sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
sp.set_defaults(func=cmd_sand)
sp = sub.add_parser("delete", help="Delete a board")
sp.add_argument("part", nargs="?", default=None)
sp.set_defaults(func=cmd_delete)
sp = sub.add_parser("export", help="Export the scene to STL or STEP")
sp.add_argument("path", help="Output file, e.g. table.stl or table.step")
sp.set_defaults(func=cmd_export)
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
sub.add_parser("reset", help="Clear the scene").set_defaults(func=cmd_reset)
sub.add_parser("status", help="Show the scene").set_defaults(func=cmd_status)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
scene = Scene.load(args.scene)
try:
message = args.func(scene, args)
except (SceneError, ValueError, KeyError) as exc:
print(str(exc).strip('"'), file=sys.stderr)
return 1
if args.command not in ("status", "export"):
scene.save(args.scene)
print(message)
return 0
if __name__ == "__main__":
raise SystemExit(main())

53
src/woodshop/geometry.py Normal file
View File

@ -0,0 +1,53 @@
"""Turn a scene into real solids with build123d (accurate, exportable geometry).
This is the buildable side of the house: it produces watertight solids that can
be written to STL (3D printing) or STEP (CAD / CNC). The live viewer renders
lightweight boxes for speed; this module is the source of truth for export.
Coordinate convention matches scene.py: a board is length(X) x width(Y) x
thickness(Z), centered on its length axis, with end_a (the start) at the part's
``position_in`` before rotation about Z.
"""
from __future__ import annotations
from pathlib import Path
from .scene import Part, Scene
def part_solid(part: Part):
from build123d import Box, Pos, Rot
length = part.length_in
thickness, width = part.section_in
box = Box(length, width, thickness) # X=length, Y=width, Z=thickness
box = Pos(length / 2, 0, 0) * box # move start (end_a) to origin
box = Rot(0, 0, part.rotation_deg) * box # orient in the XY plane
box = Pos(*part.position_in) * box # place in the scene
return box
def scene_compound(scene: Scene):
from build123d import Compound
solids = [part_solid(p) for p in scene.parts]
if not solids:
return None
return Compound(children=solids)
def export(scene: Scene, path: str | Path) -> Path:
"""Export the whole scene to STL or STEP based on the file extension."""
from build123d import export_step, export_stl
path = Path(path)
compound = scene_compound(scene)
if compound is None:
raise ValueError("Nothing to export: the scene is empty.")
if path.suffix.lower() == ".step":
export_step(compound, str(path))
elif path.suffix.lower() == ".stl":
export_stl(compound, str(path))
else:
raise ValueError(f"Unsupported export format: {path.suffix} (use .stl or .step)")
return path

48
src/woodshop/lumber.py Normal file
View File

@ -0,0 +1,48 @@
"""Nominal -> actual dimensional lumber sizing.
A "2x4" is nominally 2"x4" but actually 1.5"x3.5" once surfaced. Getting this
right is what makes the models buildable rather than decorative, so the table
lives in one place and is shared by both the operations and the viewport.
All values are in inches. Section is (thickness, width) of the board's
cross-section; length is supplied per-part by the user.
"""
from __future__ import annotations
# (thickness, width) in inches for surfaced softwood dimensional lumber.
NOMINAL_TO_ACTUAL: dict[str, tuple[float, float]] = {
"1x2": (0.75, 1.5),
"1x3": (0.75, 2.5),
"1x4": (0.75, 3.5),
"1x6": (0.75, 5.5),
"1x8": (0.75, 7.25),
"1x10": (0.75, 9.25),
"1x12": (0.75, 11.25),
"2x2": (1.5, 1.5),
"2x3": (1.5, 2.5),
"2x4": (1.5, 3.5),
"2x6": (1.5, 5.5),
"2x8": (1.5, 7.25),
"2x10": (1.5, 9.25),
"2x12": (1.5, 11.25),
"4x4": (3.5, 3.5),
"4x6": (3.5, 5.5),
"6x6": (5.5, 5.5),
}
def normalize_stock(stock: str) -> str:
"""Canonicalize a spoken/typed stock name, e.g. '2 x 4' or '2X4' -> '2x4'."""
return stock.strip().lower().replace(" ", "").replace("by", "x")
def actual_section(stock: str) -> tuple[float, float]:
"""Return the (thickness, width) in inches for a nominal stock name.
Raises KeyError with the list of known stock if unknown.
"""
key = normalize_stock(stock)
if key not in NOMINAL_TO_ACTUAL:
known = ", ".join(sorted(NOMINAL_TO_ACTUAL))
raise KeyError(f"Unknown stock {stock!r}. Known stock: {known}")
return NOMINAL_TO_ACTUAL[key]

215
src/woodshop/scene.py Normal file
View File

@ -0,0 +1,215 @@
"""The WoodShop scene: the single source of truth for a model.
A scene is a list of *parts* (boards) and *joints* (how they attach), plus a
*selection* (the last-touched part, so commands like "sand it" resolve) and an
*undo stack*. It is persisted as plain JSON so that:
* stateless CmdForge operation tools can read -> mutate -> write it, and
* the long-lived viewport process can watch the file and re-render.
Geometry convention (all inches): a part is a box of length x width x thickness.
Unplaced, it runs along +X starting at ``position``; ``width`` along Y,
``thickness`` along Z. ``rotation_deg`` is a rotation about the Z axis applied
about ``position``. Joints compute the attached part's position/rotation so the
viewport stays a dumb renderer (no constraint solver needed for the PoC).
"""
from __future__ import annotations
import copy
import json
import math
import os
from dataclasses import dataclass, field, asdict
from pathlib import Path
from .lumber import actual_section, normalize_stock
SCENE_VERSION = 1
def default_scene_path() -> Path:
"""Where the active scene lives (override with $WOODSHOP_SCENE)."""
env = os.environ.get("WOODSHOP_SCENE")
if env:
return Path(env).expanduser()
base = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser()
return base / "woodshop" / "scene.json"
@dataclass
class Part:
id: str
stock: str # canonical nominal name, e.g. "2x4"
length_in: float
section_in: tuple[float, float] # (thickness, width)
position_in: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.0])
rotation_deg: float = 0.0 # about Z, in the XY plane
finishes: list[str] = field(default_factory=list)
def axis_unit(self) -> tuple[float, float]:
a = math.radians(self.rotation_deg)
return (math.cos(a), math.sin(a))
def end_point(self) -> list[float]:
"""The far end (end_b) of the board in world space."""
ux, uy = self.axis_unit()
return [
self.position_in[0] + ux * self.length_in,
self.position_in[1] + uy * self.length_in,
self.position_in[2],
]
@dataclass
class Joint:
id: str
part_a: str
part_b: str
angle_deg: float = 90.0
offset_in: float = 0.0
anchor: str = "end_a" # measure offset from "end_a" (start) or "end_b" (far end)
class SceneError(Exception):
"""Raised for invalid operations (bad references, unknown stock, ...)."""
@dataclass
class Scene:
version: int = SCENE_VERSION
units: str = "inch"
parts: list[Part] = field(default_factory=list)
joints: list[Joint] = field(default_factory=list)
selection: str | None = None
_next_part: int = 1
_next_joint: int = 1
_undo: list[str] = field(default_factory=list, repr=False)
# ----- lookup -------------------------------------------------------
def get_part(self, ref: str) -> Part:
for p in self.parts:
if p.id == ref:
return p
raise SceneError(f"No part {ref!r}. Parts: {[p.id for p in self.parts] or 'none'}")
def resolve(self, ref: str | None) -> Part:
"""Resolve a part reference, defaulting to the current selection."""
if ref in (None, "", "it", "selection", "current"):
if not self.selection:
raise SceneError("No part is selected; say which board.")
return self.get_part(self.selection)
return self.get_part(ref)
# ----- undo ---------------------------------------------------------
def _checkpoint(self) -> None:
self._undo.append(json.dumps(self._raw(), sort_keys=True))
del self._undo[:-50] # keep the last 50 steps
def undo(self) -> str:
if not self._undo:
raise SceneError("Nothing to undo.")
snapshot = json.loads(self._undo.pop())
restored = Scene.from_dict(snapshot)
restored._undo = self._undo
self.__dict__.update(restored.__dict__)
return "Undid last operation."
# ----- operations ---------------------------------------------------
def place(self, stock: str, length_in: float) -> Part:
self._checkpoint()
stock = normalize_stock(stock)
section = actual_section(stock)
pid = f"p{self._next_part}"
self._next_part += 1
part = Part(id=pid, stock=stock, length_in=float(length_in), section_in=section)
self.parts.append(part)
self.selection = pid
return part
def finish(self, ref: str | None, kind: str = "sanded") -> Part:
self._checkpoint()
part = self.resolve(ref)
if kind not in part.finishes:
part.finishes.append(kind)
self.selection = part.id
return part
def join(
self,
part_a: str | None,
part_b: str,
angle_deg: float = 90.0,
offset_in: float = 0.0,
anchor: str = "end_a",
) -> Joint:
self._checkpoint()
a = self.resolve(part_a)
b = self.get_part(part_b)
# Distance measured along A from its start.
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
ux, uy = a.axis_unit()
attach = [a.position_in[0] + ux * along,
a.position_in[1] + uy * along,
a.position_in[2]]
b.position_in = attach
b.rotation_deg = a.rotation_deg + angle_deg
jid = f"j{self._next_joint}"
self._next_joint += 1
joint = Joint(id=jid, part_a=a.id, part_b=b.id,
angle_deg=float(angle_deg), offset_in=float(offset_in), anchor=anchor)
self.joints.append(joint)
self.selection = b.id
return joint
def delete(self, ref: str | None) -> str:
self._checkpoint()
part = self.resolve(ref)
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)]
if self.selection == part.id:
self.selection = self.parts[-1].id if self.parts else None
return f"Deleted {part.id}."
# ----- persistence --------------------------------------------------
def _raw(self) -> dict:
d = asdict(self)
d.pop("_undo", None)
return d
def to_dict(self) -> dict:
d = asdict(self)
return d
@classmethod
def from_dict(cls, data: dict) -> "Scene":
parts = [Part(**{**p, "section_in": tuple(p["section_in"])})
for p in data.get("parts", [])]
joints = [Joint(**j) for j in data.get("joints", [])]
return cls(
version=data.get("version", SCENE_VERSION),
units=data.get("units", "inch"),
parts=parts,
joints=joints,
selection=data.get("selection"),
_next_part=data.get("_next_part", len(parts) + 1),
_next_joint=data.get("_next_joint", len(joints) + 1),
_undo=data.get("_undo", []),
)
def save(self, path: Path | None = None) -> Path:
path = Path(path) if path else default_scene_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self.to_dict(), indent=2))
tmp.replace(path) # atomic so the viewport never reads a half-written file
return path
@classmethod
def load(cls, path: Path | None = None) -> "Scene":
path = Path(path) if path else default_scene_path()
if not path.exists():
return cls()
return cls.from_dict(json.loads(path.read_text()))

43
src/woodshop/units.py Normal file
View File

@ -0,0 +1,43 @@
"""Parse spoken/typed lengths into inches.
The AI interpreter is expected to pass reasonably structured values, but people
say "6 foot", "2'", '10 inches', '3 ft 6 in', or bare numbers. Everything is
normalized to inches (the scene's internal unit).
"""
from __future__ import annotations
import re
_FEET = r"(?:feet|foot|ft|')"
_INCH = r"(?:inches|inch|in|\")"
# e.g. "3 ft 6 in", "6 foot", "10 inches", "2'", "72"
_COMBINED = re.compile(
rf"^\s*(?:(?P<ft>[\d.]+)\s*{_FEET})?\s*(?:(?P<inch>[\d.]+)\s*{_INCH})?\s*$",
re.IGNORECASE,
)
_BARE = re.compile(r"^\s*(?P<n>[\d.]+)\s*$")
def to_inches(value: str | float | int, default_unit: str = "inch") -> float:
"""Convert a length expression to inches.
Bare numbers use ``default_unit`` ('inch' or 'foot'). Raises ValueError on
anything unparseable.
"""
if isinstance(value, (int, float)):
return float(value) * (12.0 if default_unit.startswith("f") else 1.0)
text = str(value).strip()
bare = _BARE.match(text)
if bare:
n = float(bare.group("n"))
return n * (12.0 if default_unit.startswith("f") else 1.0)
m = _COMBINED.match(text)
if m and (m.group("ft") or m.group("inch")):
ft = float(m.group("ft") or 0)
inch = float(m.group("inch") or 0)
return ft * 12.0 + inch
raise ValueError(f"Could not parse length: {value!r}")

90
src/woodshop/viewer.py Normal file
View File

@ -0,0 +1,90 @@
"""Live 3D viewport: watches scene.json and re-renders on every change.
Run it alongside the voice driver:
woodshop-view # or: python -m woodshop.viewer
It polls the scene file's mtime and rebuilds the view whenever an operation
tool writes a change, so saying "place a 6 foot 2x4" makes a board appear. Uses
lightweight pyvista boxes for instant updates (build123d/geometry.py is used for
accurate export, not for the live view).
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
from .scene import Part, Scene, default_scene_path
# Distinct colors so adjacent boards read as separate pieces.
_PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"]
def _part_mesh(part: Part):
import pyvista as pv
length = part.length_in
thickness, width = part.section_in
cube = pv.Cube(center=(length / 2, 0, 0),
x_length=length, y_length=width, z_length=thickness)
cube.rotate_z(part.rotation_deg, point=(0, 0, 0), inplace=True)
cube.translate(part.position_in, inplace=True)
return cube
def _render(plotter, scene: Scene) -> None:
plotter.clear()
for i, part in enumerate(scene.parts):
edge = part.id == scene.selection
plotter.add_mesh(
_part_mesh(part),
color=_PALETTE[i % len(_PALETTE)],
show_edges=True,
line_width=3 if edge else 1,
edge_color="yellow" if edge else "black",
)
n = len(scene.parts)
plotter.add_text(f"{n} part(s) | selection: {scene.selection or '-'}",
font_size=10, name="hud")
if n:
plotter.add_axes()
def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None:
import pyvista as pv
scene_path = Path(scene_path) if scene_path else default_scene_path()
plotter = pv.Plotter(title="WoodShop")
plotter.set_background("#2b2b2b")
last_mtime = -1.0
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
_render(plotter, scene)
plotter.show(interactive_update=True, auto_close=False)
while True:
try:
mtime = scene_path.stat().st_mtime if scene_path.exists() else 0.0
if mtime != last_mtime:
last_mtime = mtime
scene = Scene.load(scene_path) if scene_path.exists() else Scene()
_render(plotter, scene)
plotter.update()
time.sleep(poll_s)
except KeyboardInterrupt:
break
except Exception: # window closed
break
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="woodshop-view", description="Live WoodShop 3D viewport.")
ap.add_argument("--scene", help="Path to scene.json")
args = ap.parse_args(argv)
run(args.scene)
return 0
if __name__ == "__main__":
raise SystemExit(main())

108
tests/test_scene.py Normal file
View File

@ -0,0 +1,108 @@
"""Tests for the scene model and operations (no heavy 3D deps required)."""
import math
import pytest
from woodshop.lumber import actual_section, normalize_stock
from woodshop.scene import Scene, SceneError
from woodshop.units import to_inches
# ----- lumber ----------------------------------------------------------
def test_nominal_to_actual():
assert actual_section("2x4") == (1.5, 3.5)
assert actual_section("4x4") == (3.5, 3.5)
@pytest.mark.parametrize("raw,expected", [("2 x 4", "2x4"), ("2X4", "2x4"), ("2by4", "2x4")])
def test_normalize_stock(raw, expected):
assert normalize_stock(raw) == expected
def test_unknown_stock_lists_options():
with pytest.raises(KeyError, match="Known stock"):
actual_section("9x9")
# ----- units -----------------------------------------------------------
@pytest.mark.parametrize("value,unit,inches", [
("6 ft", "inch", 72), ("6 foot", "inch", 72), ("10 inches", "inch", 10),
("3 ft 6 in", "inch", 42), ("2'", "inch", 24), ("72", "inch", 72),
("6", "foot", 72), (6, "foot", 72),
])
def test_to_inches(value, unit, inches):
assert to_inches(value, default_unit=unit) == inches
def test_to_inches_bad():
with pytest.raises(ValueError):
to_inches("a bunch")
# ----- operations ------------------------------------------------------
def test_place_sets_section_and_selection():
s = Scene()
p = s.place("2x4", 72)
assert p.id == "p1"
assert p.section_in == (1.5, 3.5)
assert s.selection == "p1"
def test_the_example_sentence():
"""'place a 6 foot 2x4, sand it, attach a 2 foot 2x4 at 90 deg, 10 in from end.'"""
s = Scene()
s.place("2x4", to_inches("6 ft")) # p1
s.finish("it") # sand the selection
s.place("2x4", to_inches("2 ft")) # p2 (now selected)
s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b")
p1, p2 = s.get_part("p1"), s.get_part("p2")
assert "sanded" in p1.finishes
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
assert p2.position_in[0] == pytest.approx(62.0)
assert p2.rotation_deg == pytest.approx(90.0)
# p2 now runs along +Y
ux, uy = p2.axis_unit()
assert ux == pytest.approx(0.0, abs=1e-9)
assert uy == pytest.approx(1.0)
assert len(s.joints) == 1
def test_resolve_it_without_selection_errors():
s = Scene()
with pytest.raises(SceneError, match="selected"):
s.finish("it")
def test_undo_restores_previous_state():
s = Scene()
s.place("2x4", 72)
s.place("2x4", 24)
assert len(s.parts) == 2
s.undo()
assert len(s.parts) == 1
assert s.selection == "p1"
def test_delete_reassigns_selection_and_drops_joints():
s = Scene()
s.place("2x4", 72)
s.place("2x4", 24)
s.join("p1", "p2")
s.delete("p2")
assert [p.id for p in s.parts] == ["p1"]
assert s.joints == []
assert s.selection == "p1"
def test_roundtrip_serialization(tmp_path):
s = Scene()
s.place("2x4", 72)
s.place("2x4", 24)
s.join("p1", "p2", angle_deg=90, offset_in=10)
path = s.save(tmp_path / "scene.json")
loaded = Scene.load(path)
assert [p.id for p in loaded.parts] == ["p1", "p2"]
assert loaded.parts[0].section_in == (1.5, 3.5)
assert loaded.joints[0].angle_deg == 90
assert loaded.selection == "p2"