diff --git a/pyproject.toml b/pyproject.toml index 03ac8f7..990a80e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/woodshop/__init__.py b/src/woodshop/__init__.py new file mode 100644 index 0000000..9f7f98b --- /dev/null +++ b/src/woodshop/__init__.py @@ -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" diff --git a/src/woodshop/__main__.py b/src/woodshop/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/woodshop/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py new file mode 100644 index 0000000..a8858a8 --- /dev/null +++ b/src/woodshop/cli.py @@ -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()) diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py new file mode 100644 index 0000000..2c59cb9 --- /dev/null +++ b/src/woodshop/geometry.py @@ -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 diff --git a/src/woodshop/lumber.py b/src/woodshop/lumber.py new file mode 100644 index 0000000..83a7a47 --- /dev/null +++ b/src/woodshop/lumber.py @@ -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] diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py new file mode 100644 index 0000000..a38bd92 --- /dev/null +++ b/src/woodshop/scene.py @@ -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())) diff --git a/src/woodshop/units.py b/src/woodshop/units.py new file mode 100644 index 0000000..d449144 --- /dev/null +++ b/src/woodshop/units.py @@ -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[\d.]+)\s*{_FEET})?\s*(?:(?P[\d.]+)\s*{_INCH})?\s*$", + re.IGNORECASE, +) +_BARE = re.compile(r"^\s*(?P[\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}") diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py new file mode 100644 index 0000000..efb47e1 --- /dev/null +++ b/src/woodshop/viewer.py @@ -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()) diff --git a/tests/test_scene.py b/tests/test_scene.py new file mode 100644 index 0000000..55f1638 --- /dev/null +++ b/tests/test_scene.py @@ -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"