Add angled end cuts: miter + bevel
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) <noreply@anthropic.com>
This commit is contained in:
parent
28ca8ee338
commit
b284b58229
|
|
@ -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)]'
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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()):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
Loading…
Reference in New Issue