From b284b582299036e75971064008672e4e432c5184 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 31 May 2026 00:12:48 -0300 Subject: [PATCH] Add angled end cuts: miter + bevel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new 'miter' feature angles a board's end — miter across the width (frame corners, braces), bevel through the thickness (tilted blade), or both (compound). Slots into the existing Feature system. - scene: Feature.miter_deg/bevel_deg; END_KINDS={"miter"}; add_feature forces an end face and defaults to 45° miter; serialization additive. - geometry._apply_miter: subtracts a large block rotated about the end (Z=miter, Y=bevel) so the viewer/export show the real angled end; guarded. - cut list notes "miter 45° / bevel 15°"; instructions describe the angled end; jigs suggest a miter sled/gauge for ≥3 repeated angle settings. - cli feature --miter/--bevel; controller wood-feature passthrough; gen_wood_tools wood-feature gains --miter/--bevel (re-run it for the voice tools). - GUI Joinery tab: "+ Miter" button + Miter/Bevel angle fields (live edit/apply). - tests: default 45°, end-face forcing, JSON roundtrip, cut-list note, instructions, jig suggestion, and real geometry (volume reduced). 237 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/gen_wood_tools.py | 10 +++-- src/woodshop/cli.py | 14 +++++-- src/woodshop/cutplan.py | 6 +++ src/woodshop/geometry.py | 27 ++++++++++++ src/woodshop/gui/controller.py | 5 ++- src/woodshop/gui/feature_panel.py | 13 ++++-- src/woodshop/instructions.py | 5 +++ src/woodshop/jigs.py | 14 +++++++ src/woodshop/scene.py | 14 +++++-- tests/test_miter.py | 70 +++++++++++++++++++++++++++++++ 10 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 tests/test_miter.py diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index e02e644..63c53e5 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -179,11 +179,11 @@ TOOLS = { "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'.", + "description": "Add a feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, rabbet, chamfer, or miter (angled END cut). Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'miter the end at 45', 'bevel the end'.", "arguments": [ - {"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet"}, + {"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet | chamfer | miter"}, {"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": "--face", "variable": "face", "default": "end_b", "description": "Which face: end_a, end_b, top, bottom, left, right (miter uses an end)"}, {"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'"}, @@ -191,6 +191,8 @@ TOOLS = { {"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'"}, {"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"}, + {"flag": "--miter", "variable": "miter", "default": "", "description": "kind=miter: angle across the width in degrees (45 for a frame corner)"}, + {"flag": "--bevel", "variable": "bevel", "default": "", "description": "kind=miter: angle through the thickness in degrees (tilted blade)"}, ], "code": code( 'cmd = ws + ["feature", kind]\n' @@ -198,7 +200,7 @@ TOOLS = { 'if face: cmd += ["--face", face]\n' 'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n' ' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n' - ' ("--rotation", rotation)]:\n' + ' ("--rotation", rotation), ("--miter", miter), ("--bevel", bevel)]:\n' ' if val != "": cmd += [flag, str(val)]' ), }, diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 7e0876d..23ba91e 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -175,7 +175,9 @@ def cmd_feature(scene: Scene, args) -> str: 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), - rotation_deg=_optdeg(args.rotation)) + rotation_deg=_optdeg(args.rotation), + miter_deg=_optdeg(getattr(args, "miter", None)), + bevel_deg=_optdeg(getattr(args, "bevel", None))) part = scene.find_feature(feat.id)[0] return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}." @@ -186,7 +188,9 @@ def cmd_feature_edit(scene: Scene, args) -> str: 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), - rotation_deg=_optdeg(args.rotation)) + rotation_deg=_optdeg(args.rotation), + miter_deg=_optdeg(getattr(args, "miter", None)), + bevel_deg=_optdeg(getattr(args, "bevel", None))) return f"Updated feature {feat.id}." @@ -386,9 +390,11 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--depth", help="cut depth / tenon protrusion") parser.add_argument("--diameter", help="hole diameter") parser.add_argument("--rotation", help="rotate the feature about its face normal (deg)") + parser.add_argument("--miter", help="miter angle across the width, deg (kind=miter)") + parser.add_argument("--bevel", help="bevel angle through the thickness, deg (kind=miter)") - 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 = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot/miter)") + sp.add_argument("kind", help="tenon | mortise | hole | slot | dado | rabbet | chamfer | miter") sp.add_argument("--part", default=None, help="Board id/name (default: selection)") add_dim_flags(sp) sp.set_defaults(func=cmd_feature) diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index e3b90c5..39d3782 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -172,6 +172,12 @@ def _cut_items(scene, settings: "ShopSettings | None" = None) -> list: rough_len = round(final_len + allow, 3) if finished else final_len rough_wid = round(final_wid + allow, 3) if (finished and sheet) else final_wid note = "incl. tenon" if cut_length(p) > p.length_in + _EPS else "" + for f in p.features: + if f.kind == "miter": + bits = ([f"miter {f.miter_deg:g}°"] if f.miter_deg else []) \ + + ([f"bevel {f.bevel_deg:g}°"] if f.bevel_deg else []) + if bits: + note = (note + "; " if note else "") + " ".join(bits) if finished and allow > 0: note = (note + "; " if note else "") + "sand to final" items.append(CutItem( diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index be0cfe0..ba4df2e 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -87,6 +87,30 @@ def _apply_chamfer(solid, feat: Feature, L: float, w: float, t: float): return solid # over-sized / invalid chamfer: leave the board unbevelled +def _apply_miter(solid, feat: Feature, L: float, w: float, t: float): + """Angle a board's end: rotate a large cutting block about the end and + subtract it. miter_deg tilts the cut across the width (about Z), bevel_deg + through the thickness (about Y). 0/0 = a square end (no-op).""" + from build123d import Box, Pos, Rot + + if feat.miter_deg == 0.0 and feat.bevel_deg == 0.0: + return solid + big = 3 * max(L, w, t) + 10 + block = Box(big, big, big) + if feat.face == "end_a": + cutter = Pos(-big / 2, 0, 0) * block # covers x < 0 + pivot = (0.0, 0.0, 0.0) + else: # end_b (default) + cutter = Pos(L + big / 2, 0, 0) * block # covers x > L + pivot = (L, 0.0, 0.0) + cutter = (Pos(*pivot) * Rot(Z=feat.miter_deg, Y=feat.bevel_deg) + * Pos(*[-c for c in pivot]) * cutter) + try: + return solid - cutter + except Exception: + return solid # invalid angle: leave the end square + + def part_solid(part: Part): from build123d import Box, Pos, Rot @@ -98,6 +122,9 @@ def part_solid(part: Part): if feat.kind == "chamfer": solid = _apply_chamfer(solid, feat, length, width, thickness) continue + if feat.kind == "miter": + solid = _apply_miter(solid, feat, length, width, thickness) + continue fsolid, is_cut = _feature_solid_local(feat, length, width, thickness) solid = (solid - fsolid) if is_cut else (solid + fsolid) diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 42a0f99..4c7e305 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -52,7 +52,7 @@ TOOL_CMD = { 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")), - rotation=_opt(a.get("rotation")))), + rotation=_opt(a.get("rotation")), miter=_opt(a.get("miter")), bevel=_opt(a.get("bevel")))), "wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])), "wood-connect": lambda a: (cli.cmd_connect, SimpleNamespace(anchor=a["anchor"], moving=a["moving"])), "wood-explode": lambda a: (cli.cmd_explode, SimpleNamespace(distance=a["distance"])), @@ -392,7 +392,8 @@ class Controller(QObject): pending.id, face=pending.face, along_in=pending.along_in, across_in=pending.across_in, width_in=pending.width_in, height_in=pending.height_in, depth_in=pending.depth_in, - diameter_in=pending.diameter_in, rotation_deg=pending.rotation_deg) + diameter_in=pending.diameter_in, rotation_deg=pending.rotation_deg, + miter_deg=pending.miter_deg, bevel_deg=pending.bevel_deg) self._commit() # re-tessellates with the new geometry self.preview_changed.emit() # clear the ghost diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index a693d95..d8d4874 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, from ..scene import FACES from .controller import Controller -_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer"] +_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer", "miter"] class FeaturePanel(QWidget): @@ -54,12 +54,16 @@ class FeaturePanel(QWidget): ("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"), ("diameter_in", "Diameter", "Hole diameter (holes only)"), ("rotation_deg", "Rotate", "Spin the feature about its face normal to line up the cross-section"), + ("miter_deg", "Miter", "Miter end cut: angle across the width (0 = square end)"), + ("bevel_deg", "Bevel", "Miter end cut: angle through the thickness (tilted blade)"), ] self._spins = {} for key, label, tip in self._fields: sp = QDoubleSpinBox() - if key == "rotation_deg": - sp.setRange(-180, 180); sp.setSingleStep(15); sp.setSuffix(" °") + if key.endswith("_deg"): + lo = -180 if key == "rotation_deg" else -80 + sp.setRange(lo, 180 if key == "rotation_deg" else 80) + sp.setSingleStep(5); sp.setSuffix(" °") else: sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in") sp.setToolTip(tip) @@ -222,4 +226,7 @@ _HINTS = { "Depth = how deep.", "chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size " "(the red preview highlights the face).", + "miter": "Miter — angles the chosen END. Miter = angle across the width (45° for a " + "frame corner); Bevel = angle through the thickness (tilted blade); set " + "both for a compound cut. Apply to see the real angled end.", } diff --git a/src/woodshop/instructions.py b/src/woodshop/instructions.py index c8d7347..a8fa00b 100644 --- a/src/woodshop/instructions.py +++ b/src/woodshop/instructions.py @@ -43,6 +43,11 @@ def build_steps(scene, plan=None) -> list: joinery = [] for p in scene.parts: for f in p.features: + if f.kind == "miter": + dims = " / ".join(([f"miter {f.miter_deg:g}°"] if f.miter_deg else []) + + ([f"bevel {f.bevel_deg:g}°"] if f.bevel_deg else [])) or "square" + joinery.append(f"On {names[p.id]} — angled cut on the {f.face} end ({dims})") + continue dims = (f"⌀{f.diameter_in:g}\"" if f.kind == "hole" else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}\"") joinery.append(f"On {names[p.id]} — {f.kind} on the {f.face} face ({dims})") diff --git a/src/woodshop/jigs.py b/src/woodshop/jigs.py index 121d094..848e57f 100644 --- a/src/woodshop/jigs.py +++ b/src/woodshop/jigs.py @@ -62,6 +62,20 @@ def suggest_jigs(scene, min_repeats: int = 3) -> list: f"({_fmt_len(along)} along) and rout all {n} mortises {_fmt_len(d)} deep with a guide bushing.", ["template stock (ply/MDF)", "guide bushing"])) + # Repeated angled end cuts -> a miter sled / set the gauge once. + miters = Counter((round(f.miter_deg, 1), round(f.bevel_deg, 1)) + for p in scene.parts for f in p.features if f.kind == "miter" + and (f.miter_deg or f.bevel_deg)) + for (miter, bevel), n in sorted(miters.items()): + if n >= min_repeats: + ang = " / ".join(([f"{miter:g}° miter"] if miter else []) + + ([f"{bevel:g}° bevel"] if bevel else [])) + jigs.append(JigSuggestion( + "miter-sled", f"Miter setup — {n}× {ang} cuts", n, + f"Set the miter gauge/sled (and blade tilt for bevel) to {ang} ONCE and cut all " + f"{n} ends without re-measuring; a stop block gives identical lengths too.", + ["a crosscut sled or miter gauge"])) + # Repeated panel widths -> set the rip fence once. widths = Counter(round(p.section_in[1], 2) for p in scene.parts if is_plywood(p.stock)) for wd, n in sorted(widths.items()): diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 35899b9..2463b20 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -131,12 +131,14 @@ def list_projects() -> list[str]: # Feature kinds: ADD fuses material (tenon), CUT subtracts a box/cylinder, EDGE -# operates on the board's edges (chamfer bevel). +# operates on the board's edges (chamfer bevel), END angles an end (miter/bevel). ADD_KINDS = {"tenon"} CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"} EDGE_KINDS = {"chamfer"} -FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS +END_KINDS = {"miter"} # an angled end cut (miter across width, bevel thru thickness) +FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS | END_KINDS FACES = ("end_a", "end_b", "top", "bottom", "left", "right") +END_FACES = ("end_a", "end_b") # Surface treatments, in increasing order of work. Anything past "raw" implies # the board is sanded (so it gets the sanding allowance + lighter/finished look). @@ -166,6 +168,8 @@ class Feature: depth_in: float = 1.0 diameter_in: float = 0.375 rotation_deg: float = 0.0 # rotation of the feature about its face normal + miter_deg: float = 0.0 # 'miter' end cut: angle across the width (0 = square) + bevel_deg: float = 0.0 # 'miter' end cut: angle through the thickness @property def is_cut(self) -> bool: @@ -594,6 +598,8 @@ class Scene: kind = kind.lower().strip() if kind not in FEATURE_KINDS: raise SceneError(f"Unknown feature {kind!r}. Known: {', '.join(sorted(FEATURE_KINDS))}") + if kind in END_KINDS and face not in END_FACES: + face = "end_b" # a miter only makes sense on an end if face not in FACES: raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}") self._checkpoint() @@ -601,9 +607,11 @@ class Scene: fid = f"f{self._next_feat}" self._next_feat += 1 allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in", - "diameter_in", "rotation_deg"} + "diameter_in", "rotation_deg", "miter_deg", "bevel_deg"} 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}) + if kind == "miter" and feat.miter_deg == 0.0 and feat.bevel_deg == 0.0: + feat.miter_deg = 45.0 # a sensible default a user can edit part.features.append(feat) self.selection = part.id return feat diff --git a/tests/test_miter.py b/tests/test_miter.py new file mode 100644 index 0000000..53f71a5 --- /dev/null +++ b/tests/test_miter.py @@ -0,0 +1,70 @@ +"""End miter / bevel angled cuts.""" +import json + +import pytest + +from woodshop.scene import Scene + + +def test_add_miter_defaults_to_45_on_end(): + s = Scene() + s.place("2x4", 24) + f = s.add_feature("p1", "miter") # no angle given + assert f.kind == "miter" and f.face == "end_b" and f.miter_deg == 45.0 + + +def test_miter_forced_to_end_face(): + s = Scene() + s.place("2x4", 24) + f = s.add_feature("p1", "miter", face="top", miter_deg=30) # top is invalid for miter + assert f.face == "end_b" and f.miter_deg == 30 + + +def test_miter_roundtrips_through_json(): + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "miter", miter_deg=45, bevel_deg=15) + s2 = Scene.from_dict(json.loads(json.dumps(s.to_dict()))) + f = s2.get_part("p1").features[0] + assert f.kind == "miter" and f.miter_deg == 45 and f.bevel_deg == 15 + + +def test_cutlist_notes_the_miter(): + from woodshop.cutplan import build_cut_plan + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "miter", miter_deg=45) + note = build_cut_plan(s).items[0].note + assert "miter 45" in note + + +def test_instructions_describe_miter(): + from woodshop.instructions import build_steps, format_steps + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "miter", miter_deg=45, bevel_deg=10) + text = format_steps(build_steps(s)) + assert "miter 45" in text and "bevel 10" in text + + +def test_jigs_suggest_miter_sled_for_repeats(): + from woodshop.jigs import suggest_jigs + s = Scene() + for _ in range(4): + s.place("2x4", 24) + for pid in ("p1", "p2", "p3", "p4"): + s.add_feature(pid, "miter", miter_deg=45) + kinds = [j.kind for j in suggest_jigs(s)] + assert "miter-sled" in kinds + + +def test_miter_geometry_removes_material(): + pytest.importorskip("build123d") + from woodshop.geometry import part_solid + s = Scene() + s.place("2x4", 24) + square_vol = part_solid(s.get_part("p1")).volume + s.add_feature("p1", "miter", miter_deg=45) + mitered_vol = part_solid(s.get_part("p1")).volume + assert mitered_vol < square_vol # a wedge was cut off +