From a0072e6271d12bfff09bd769ecfd9bb68575913b Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 13:27:57 -0300 Subject: [PATCH] Add joinery features (parametric boolean tenon/mortise/hole/slot) Features as re-editable objects attached to a board, each a boolean op: - scene.py: Feature dataclass (kind/face/position/size/depth), Part.features, add_feature/edit_feature/delete_feature/find_feature, serialization + counter. - geometry.py: part_solid now builds the local board then fuses tenons / cuts mortise/hole/slot/dado/rabbet via build123d booleans, then places it. _face_frame maps each board face; holes are oriented cylinders, others oriented boxes. - viewer.py: featured boards render the tessellated true solid (edges off to avoid triangle noise); plain boards keep the fast pyvista box. - cli.py: feature / feature-edit / feature-delete / features commands; status shows feature kinds. gui/controller: wood-feature(-delete) dispatch. - 21 wood-* tools (added wood-feature, wood-feature-delete). 64 tests pass (feature model + build123d volume/tessellation checks). Verified with a render: tenon + mortise + through-hole on one board, and STEP/STL export. Phase A (model + geometry + CLI/voice). Next: GUI feature panel; chamfers. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 15 ++++-- scripts/gen_wood_tools.py | 29 +++++++++++ src/woodshop/cli.py | 74 +++++++++++++++++++++++++++- src/woodshop/geometry.py | 76 +++++++++++++++++++++++++---- src/woodshop/gui/controller.py | 5 ++ src/woodshop/gui/viewport.py | 4 +- src/woodshop/scene.py | 88 ++++++++++++++++++++++++++++++++++ src/woodshop/viewer.py | 21 +++++++- tests/test_geometry.py | 39 +++++++++++++++ tests/test_scene.py | 34 +++++++++++++ 10 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 tests/test_geometry.py diff --git a/CLAUDE.md b/CLAUDE.md index d98a9b0..a2252c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,11 +93,16 @@ pytest # 25 tests 1. **Joins are flush butt joints**: B's end sits flush against A's surface, and B is aligned to A's reference corner (top faces level + one side flush) rather - than centered — so mixed-size boards line up cleanly. The flush step snaps - B's +faces to A's +faces on A's cross-section axes, skipping the axis B - extends along (so the butt contact is preserved). Not yet modeled: joinery - *cuts* (mortise/tenon, lap, pocket holes), and the flush corner is fixed - (A's +width/+thick side; no per-join choice of which corner / centered). + than centered. The flush corner is fixed (A's +width/+thick side; no per-join + choice of which corner / centered). +2. **Joinery features** (`Feature` on each `Part`) are parametric booleans applied + in `geometry.part_solid`: `tenon` adds a protruding tongue (fuse); `mortise`, + `hole`, `slot`, `dado`, `rabbet` cut into a chosen face. The viewport + tessellates featured boards via build123d (plain boards stay fast pyvista + boxes). CLI: `feature/feature-edit/feature-delete/features`; voice: + `wood-feature`/`wood-feature-delete`. Not yet: **chamfers/bevels** (need edge + selection, not a box/cylinder cut), countersinks, and the **GUI feature panel** + (add/edit features by clicking — currently CLI/voice only). 2. **Latency** ~7–13s per utterance (one `claude -p` call). 3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against failures but the mic path isn't exercised in the unit tests. diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 7481998..221bc0d 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -163,6 +163,35 @@ TOOLS = { "arguments": [], "code": code('cmd = [ws, "clear"]'), }, + "wood-feature": { + "description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.", + "arguments": [ + {"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet"}, + {"flag": "--part", "variable": "part", "default": "", "description": "Board id/name (default: most recent)"}, + {"flag": "--face", "variable": "face", "default": "end_b", "description": "Which face: end_a, end_b, top, bottom, left, right"}, + {"flag": "--along", "variable": "along", "default": "", "description": "Position along the board (e.g. '3 in'), or 1st offset on an end"}, + {"flag": "--across", "variable": "across", "default": "", "description": "Offset across the face from centre"}, + {"flag": "--width", "variable": "width", "default": "", "description": "Feature width, e.g. '1.5 in'"}, + {"flag": "--height", "variable": "height", "default": "", "description": "Feature height/thickness"}, + {"flag": "--depth", "variable": "depth", "default": "", "description": "Cut depth, or tenon protrusion length"}, + {"flag": "--diameter", "variable": "diameter", "default": "", "description": "Hole diameter, e.g. '0.5 in'"}, + ], + "code": code( + 'cmd = [ws, "feature", kind]\n' + 'if part: cmd += ["--part", part]\n' + 'if face: cmd += ["--face", face]\n' + 'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n' + ' ("--height", height), ("--depth", depth), ("--diameter", diameter)]:\n' + ' if val != "": cmd += [flag, str(val)]' + ), + }, + "wood-feature-delete": { + "description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.", + "arguments": [ + {"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"}, + ], + "code": code('cmd = [ws, "feature-delete", fid]'), + }, "wood-cutlist": { "description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.", "arguments": [], diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index a6450d5..200b321 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -144,6 +144,46 @@ def cmd_projects(scene: Scene, args) -> str: return "Saved projects: " + (", ".join(names) if names else "none yet") +def _optlen(v, unit="inch"): + return to_inches(v, default_unit=unit) if v not in (None, "") else None + + +def cmd_feature(scene: Scene, args) -> str: + feat = scene.add_feature( + args.part, args.kind, face=args.face, + along_in=_optlen(args.along), across_in=_optlen(args.across), + width_in=_optlen(args.width), height_in=_optlen(args.height), + depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter)) + part = scene.find_feature(feat.id)[0] + return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}." + + +def cmd_feature_edit(scene: Scene, args) -> str: + feat = scene.edit_feature( + args.fid, face=args.face, + along_in=_optlen(args.along), across_in=_optlen(args.across), + width_in=_optlen(args.width), height_in=_optlen(args.height), + depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter)) + return f"Updated feature {feat.id}." + + +def cmd_feature_delete(scene: Scene, args) -> str: + return scene.delete_feature(args.fid) + + +def cmd_feature_list(scene: Scene, args) -> str: + rows = [(p, f) for p in scene.parts for f in p.features + if not args.part or p.id == args.part or p.name == args.part] + if not rows: + return "No features." + lines = [] + for p, f in rows: + dims = (f"⌀{f.diameter_in:g}" if f.kind == "hole" + else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}") + lines.append(f" {f.id}: {f.kind} on {p.id} {f.face} ({dims})") + return "\n".join(lines) + + def cmd_export(scene: Scene, args) -> str: from .geometry import export # lazy: keeps build123d out of the core path path = export(scene, args.path) @@ -175,6 +215,8 @@ def _describe_part(p) -> str: bits.append(f"yaw {p.yaw_deg:g}°") if p.finishes: bits.append(f"[{', '.join(p.finishes)}]") + if p.features: + bits.append(f"{{{', '.join(f.kind for f in p.features)}}}") return f" {p.id}: " + ", ".join(bits) @@ -261,6 +303,35 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--part", default=None) sp.set_defaults(func=cmd_rename) + def add_dim_flags(parser, face_default="end_b"): + parser.add_argument("--face", default=face_default, + help="end_a|end_b|top|bottom|left|right") + parser.add_argument("--along", help="position along the board / 1st offset") + parser.add_argument("--across", help="offset across the face / 2nd offset") + parser.add_argument("--width", help="feature width") + parser.add_argument("--height", help="feature height/thickness") + parser.add_argument("--depth", help="cut depth / tenon protrusion") + parser.add_argument("--diameter", help="hole diameter") + + sp = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot)") + sp.add_argument("kind", help="tenon | mortise | hole | slot | dado | rabbet") + sp.add_argument("--part", default=None, help="Board id/name (default: selection)") + add_dim_flags(sp) + sp.set_defaults(func=cmd_feature) + + sp = sub.add_parser("feature-edit", help="Adjust an existing feature") + sp.add_argument("fid", help="Feature id, e.g. f1") + add_dim_flags(sp, face_default=None) + sp.set_defaults(func=cmd_feature_edit) + + sp = sub.add_parser("feature-delete", help="Remove a feature") + sp.add_argument("fid") + sp.set_defaults(func=cmd_feature_delete) + + sp = sub.add_parser("features", help="List joinery features") + sp.add_argument("--part", default=None) + sp.set_defaults(func=cmd_feature_list) + 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) @@ -302,7 +373,8 @@ def main(argv: list[str] | None = None) -> int: except (SceneError, ValueError, KeyError) as exc: print(str(exc).strip('"'), file=sys.stderr) return 1 - if args.command not in ("status", "export", "cutlist", "render", "save", "projects"): + if args.command not in ("status", "export", "cutlist", "render", "save", + "projects", "features"): scene.save(args.scene) print(message) return 0 diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index a2c5cb7..039417f 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -12,7 +12,60 @@ from __future__ import annotations from pathlib import Path -from .scene import Part, Scene +from .scene import Feature, Part, Scene + + +# Each face of the LOCAL board (X in [0,L], Y in [-w/2,w/2], Z in [-t/2,t/2]): +# (origin on the face, outward normal, in-plane u axis, in-plane v axis). +def _face_frame(face: str, L: float, w: float, t: float): + return { + "top": ((L / 2, 0, t / 2), (0, 0, 1), (1, 0, 0), (0, 1, 0)), + "bottom": ((L / 2, 0, -t / 2), (0, 0, -1), (1, 0, 0), (0, 1, 0)), + "right": ((L / 2, w / 2, 0), (0, 1, 0), (1, 0, 0), (0, 0, 1)), + "left": ((L / 2, -w / 2, 0), (0, -1, 0), (1, 0, 0), (0, 0, 1)), + "end_b": ((L, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)), + "end_a": ((0, 0, 0), (-1, 0, 0), (0, 1, 0), (0, 0, 1)), + }[face] + + +def _orient_z_to(solid, n): + """Rotate a Z-axis primitive (Cylinder) so its axis points along n.""" + from build123d import Rot + if abs(n[2]) > 0.9: + return solid + if abs(n[1]) > 0.9: + return Rot(X=90) * solid + return Rot(Y=90) * solid + + +def _feature_solid_local(feat: Feature, L: float, w: float, t: float): + """Return (solid, is_cut) for one feature, in the board's local frame.""" + from build123d import Box, Cylinder, Pos + + o, n, u, v = _face_frame(feat.face, L, w, t) + # Position on the face. When u is the length axis, `along_in` is measured + # from end_a; otherwise both offsets are from the face centre. + off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0) + off_v = feat.across_in + fp = tuple(o[i] + off_u * u[i] + off_v * v[i] for i in range(3)) + depth = feat.depth_in + + if feat.kind == "hole": + r = feat.diameter_in / 2 + thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1 + h = depth if depth > 0 else thru + cyl = _orient_z_to(Cylinder(radius=r, height=h), n) + c = tuple(fp[i] - n[i] * h / 2 for i in range(3)) # extend into the board + return Pos(*c) * cyl, True + + # Box-shaped feature: cross-section width(u) × height(v), depth along normal. + sx = feat.width_in * abs(u[0]) + feat.height_in * abs(v[0]) + depth * abs(n[0]) + sy = feat.width_in * abs(u[1]) + feat.height_in * abs(v[1]) + depth * abs(n[1]) + sz = feat.width_in * abs(u[2]) + feat.height_in * abs(v[2]) + depth * abs(n[2]) + box = Box(sx, sy, sz) + sign = 1 if feat.kind == "tenon" else -1 # tenon protrudes; others cut inward + c = tuple(fp[i] + sign * n[i] * depth / 2 for i in range(3)) + return Pos(*c) * box, (feat.kind != "tenon") def part_solid(part: Part): @@ -20,14 +73,18 @@ def part_solid(part: Part): 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 - # roll about its own axis (X), tilt up toward Z (about Y), then heading (Z). - box = Rot(X=part.roll_deg) * box - box = Rot(Y=-part.tilt_deg) * box - box = Rot(Z=part.yaw_deg) * box - box = Pos(*part.position_in) * box # place in the scene - return box + solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin + + for feat in part.features: # apply joinery as booleans + fsolid, is_cut = _feature_solid_local(feat, length, width, thickness) + solid = (solid - fsolid) if is_cut else (solid + fsolid) + + # place: roll about its own axis (X), tilt up toward Z (about Y), heading (Z). + solid = Rot(X=part.roll_deg) * solid + solid = Rot(Y=-part.tilt_deg) * solid + solid = Rot(Z=part.yaw_deg) * solid + solid = Pos(*part.position_in) * solid + return solid def scene_compound(scene: Scene): @@ -44,6 +101,7 @@ def export(scene: Scene, path: str | Path) -> Path: from build123d import export_step, export_stl path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) compound = scene_compound(scene) if compound is None: raise ValueError("Nothing to export: the scene is empty.") diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index a7ae740..9f3407a 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -45,6 +45,11 @@ TOOL_CMD = { "wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace( part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")), "wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))), + "wood-feature": lambda a: (cli.cmd_feature, SimpleNamespace( + kind=a["kind"], part=_opt(a.get("part")), face=a.get("face") or "end_b", + along=_opt(a.get("along")), across=_opt(a.get("across")), width=_opt(a.get("width")), + height=_opt(a.get("height")), depth=_opt(a.get("depth")), diameter=_opt(a.get("diameter")))), + "wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])), "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"])), diff --git a/src/woodshop/gui/viewport.py b/src/woodshop/gui/viewport.py index bc4d553..ed3904a 100644 --- a/src/woodshop/gui/viewport.py +++ b/src/woodshop/gui/viewport.py @@ -67,8 +67,8 @@ class Viewport(QWidget): actor = self.plotter.add_mesh( _part_mesh(part), color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)], - show_edges=True, line_width=3 if selected else 1, edge_color="black", - reset_camera=False, pickable=True, + show_edges=not part.features, line_width=3 if selected else 1, + edge_color="black", reset_camera=False, pickable=True, ) self._actor_to_pid[actor] = part.id mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)] diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 57ef3a6..19adbec 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -84,6 +84,41 @@ def list_projects() -> list[str]: return sorted(p.stem for p in d.glob("*.json")) if d.exists() else [] +# Feature kinds and whether they ADD material (tenon) or CUT it (everything else). +ADD_KINDS = {"tenon"} +CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"} +FEATURE_KINDS = ADD_KINDS | CUT_KINDS +FACES = ("end_a", "end_b", "top", "bottom", "left", "right") + + +@dataclass +class Feature: + """A parametric joinery feature attached to a board — a boolean op (add for a + tenon, cut for mortise/slot/hole) on a chosen face, re-editable and movable. + + Placement on the face uses two offsets: for face=top/bottom/left/right, + ``along_in`` is the position along the board length (from end_a) and + ``across_in`` is offset from the face centre; for face=end_a/end_b the two + offsets are the lateral (width) and vertical (thickness) positions from + centre. ``width_in`` × ``height_in`` is the feature's cross-section on the + face and ``depth_in`` is how deep it cuts (or how far a tenon protrudes). + ``diameter_in`` is used for round holes instead of width/height. + """ + id: str + kind: str + face: str = "end_b" + along_in: float = 0.0 + across_in: float = 0.0 + width_in: float = 1.0 + height_in: float = 1.0 + depth_in: float = 1.0 + diameter_in: float = 0.375 + + @property + def is_cut(self) -> bool: + return self.kind in CUT_KINDS + + @dataclass class Part: id: str @@ -96,6 +131,7 @@ class Part: roll_deg: float = 0.0 # rotation about the board's own length axis name: str = "" # optional human alias, e.g. "front-left leg" finishes: list[str] = field(default_factory=list) + features: list[Feature] = field(default_factory=list) def local_frame(self) -> tuple[tuple, tuple, tuple]: """The board's (length, width, thickness) unit axes in world space. @@ -152,6 +188,7 @@ class Scene: selection: str | None = None _next_part: int = 1 _next_joint: int = 1 + _next_feat: int = 1 _undo: list[str] = field(default_factory=list, repr=False) _redo: list[str] = field(default_factory=list, repr=False) @@ -369,8 +406,55 @@ class Scene: self.selection = None self._next_part = 1 self._next_joint = 1 + self._next_feat = 1 return "Cleared the scene." + # ----- joinery features -------------------------------------------- + def add_feature(self, ref: str | None, kind: str, face: str = "end_b", + **dims) -> Feature: + kind = kind.lower().strip() + if kind not in FEATURE_KINDS: + raise SceneError(f"Unknown feature {kind!r}. Known: {', '.join(sorted(FEATURE_KINDS))}") + if face not in FACES: + raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}") + self._checkpoint() + part = self.resolve(ref) + fid = f"f{self._next_feat}" + self._next_feat += 1 + allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in", "diameter_in"} + feat = Feature(id=fid, kind=kind, face=face, + **{k: float(v) for k, v in dims.items() if k in allowed and v is not None}) + part.features.append(feat) + self.selection = part.id + return feat + + def find_feature(self, fid: str) -> tuple[Part, Feature]: + for p in self.parts: + for f in p.features: + if f.id == fid: + return p, f + raise SceneError(f"No feature {fid!r}.") + + def edit_feature(self, fid: str, **dims) -> Feature: + self._checkpoint() + part, feat = self.find_feature(fid) + for k, v in dims.items(): + if v is None: + continue + if k == "face": + feat.face = v + elif hasattr(feat, k): + setattr(feat, k, float(v)) + self.selection = part.id + return feat + + def delete_feature(self, fid: str) -> str: + self._checkpoint() + part, feat = self.find_feature(fid) + part.features.remove(feat) + self.selection = part.id + return f"Deleted feature {fid} from {part.id}." + def delete(self, ref: str | None) -> str: self._checkpoint() part = self.resolve(ref) @@ -396,11 +480,14 @@ class Scene: def from_dict(cls, data: dict) -> "Scene": parts = [] valid = {f.name for f in fields(Part)} + feat_fields = {f.name for f in fields(Feature)} for p in data.get("parts", []): p = dict(p) if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes p["yaw_deg"] = p.pop("rotation_deg") p["section_in"] = tuple(p["section_in"]) + p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields}) + 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", [])] return cls( @@ -411,6 +498,7 @@ class Scene: 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), _undo=data.get("_undo", []), _redo=data.get("_redo", []), ) diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index 3c9921d..1549959 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -21,9 +21,28 @@ from .scene import Part, Scene, default_scene_path _PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"] +def _featured_mesh(part: Part): + """Tessellate the true build123d solid (with joinery booleans) for display.""" + import pyvista as pv + + from .geometry import part_solid + verts, tris = part_solid(part).tessellate(0.02) + points = [(v.X, v.Y, v.Z) for v in verts] + faces = [] + for tri in tris: + faces += [3, tri[0], tri[1], tri[2]] + return pv.PolyData(points, faces) + + def _part_mesh(part: Part): import pyvista as pv + if part.features: # show real joinery (slower; only featured boards) + try: + return _featured_mesh(part) + except Exception: + pass # fall back to the plain box if booleans fail + length = part.length_in thickness, width = part.section_in cube = pv.Cube(center=(length / 2, 0, 0), @@ -55,7 +74,7 @@ def _render(plotter, scene: Scene) -> None: plotter.add_mesh( _part_mesh(part), color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)], - show_edges=True, + show_edges=not part.features, # triangle mesh would look noisy with edges line_width=3 if edge else 1, edge_color="black", smooth_shading=False, diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..28bb1b2 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,39 @@ +"""Geometry tests that exercise the build123d boolean features.""" +import pytest + +pytest.importorskip("build123d") + +from woodshop.geometry import part_solid # noqa: E402 +from woodshop.scene import Scene # noqa: E402 + + +def test_hole_reduces_volume(): + s = Scene() + s.place("2x4", 12) + base = part_solid(s.get_part("p1")).volume + s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=1.0, depth_in=0) # through + assert part_solid(s.get_part("p1")).volume < base + + +def test_mortise_reduces_volume(): + s = Scene() + s.place("2x4", 12) + base = part_solid(s.get_part("p1")).volume + s.add_feature("p1", "mortise", face="top", along_in=6, width_in=1, height_in=2, depth_in=0.75) + assert part_solid(s.get_part("p1")).volume < base + + +def test_tenon_adds_volume(): + s = Scene() + s.place("2x4", 12) + base = part_solid(s.get_part("p1")).volume + s.add_feature("p1", "tenon", face="end_b", width_in=1.5, height_in=0.75, depth_in=1.5) + assert part_solid(s.get_part("p1")).volume > base + + +def test_featured_part_tessellates(): + s = Scene() + s.place("2x4", 12) + s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5, depth_in=0) + verts, tris = part_solid(s.get_part("p1")).tessellate(0.05) + assert len(verts) > 8 and len(tris) > 12 diff --git a/tests/test_scene.py b/tests/test_scene.py index cb0dba0..628aa67 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -192,6 +192,40 @@ def test_batch_is_one_undo(): assert s.get_part("p2").position_in[0] == 0 +def test_add_edit_delete_feature(): + s = Scene() + s.place("2x4", 12) + f = s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5) + assert f.id == "f1" and f.is_cut + assert s.get_part("p1").features[0].kind == "mortise" + s.edit_feature("f1", depth_in=0.75) + assert s.find_feature("f1")[1].depth_in == 0.75 + s.delete_feature("f1") + assert s.get_part("p1").features == [] + + +def test_tenon_is_additive(): + s = Scene() + s.place("2x4", 12) + assert not s.add_feature("p1", "tenon", face="end_b", depth_in=1).is_cut + + +def test_unknown_feature_kind_errors(): + s = Scene() + s.place("2x4", 12) + with pytest.raises(SceneError, match="Unknown feature"): + s.add_feature("p1", "dovetailzzz") + + +def test_feature_roundtrip(tmp_path): + s = Scene() + s.place("2x4", 12) + s.add_feature("p1", "hole", face="top", along_in=3, diameter_in=0.5) + loaded = Scene.load(s.save(tmp_path / "s.json")) + feat = loaded.get_part("p1").features[0] + assert feat.kind == "hole" and feat.diameter_in == 0.5 + + def test_clear(): s = Scene() s.place("2x4", 24)