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:
rob 2026-05-31 00:12:48 -03:00
parent 28ca8ee338
commit b284b58229
10 changed files with 162 additions and 16 deletions

View File

@ -179,11 +179,11 @@ TOOLS = {
"code": code('cmd = ws + ["clear"]'), "code": code('cmd = ws + ["clear"]'),
}, },
"wood-feature": { "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": [ "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": "--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": "--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": "--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": "--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": "--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": "--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": "--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( "code": code(
'cmd = ws + ["feature", kind]\n' 'cmd = ws + ["feature", kind]\n'
@ -198,7 +200,7 @@ TOOLS = {
'if face: cmd += ["--face", face]\n' 'if face: cmd += ["--face", face]\n'
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n' 'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n' ' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n'
' ("--rotation", rotation)]:\n' ' ("--rotation", rotation), ("--miter", miter), ("--bevel", bevel)]:\n'
' if val != "": cmd += [flag, str(val)]' ' if val != "": cmd += [flag, str(val)]'
), ),
}, },

View File

@ -175,7 +175,9 @@ def cmd_feature(scene: Scene, args) -> str:
along_in=_optlen(args.along), across_in=_optlen(args.across), along_in=_optlen(args.along), across_in=_optlen(args.across),
width_in=_optlen(args.width), height_in=_optlen(args.height), width_in=_optlen(args.width), height_in=_optlen(args.height),
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter), 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] part = scene.find_feature(feat.id)[0]
return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}." 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), along_in=_optlen(args.along), across_in=_optlen(args.across),
width_in=_optlen(args.width), height_in=_optlen(args.height), width_in=_optlen(args.width), height_in=_optlen(args.height),
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter), 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}." 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("--depth", help="cut depth / tenon protrusion")
parser.add_argument("--diameter", help="hole diameter") parser.add_argument("--diameter", help="hole diameter")
parser.add_argument("--rotation", help="rotate the feature about its face normal (deg)") 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 = 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") 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)") sp.add_argument("--part", default=None, help="Board id/name (default: selection)")
add_dim_flags(sp) add_dim_flags(sp)
sp.set_defaults(func=cmd_feature) sp.set_defaults(func=cmd_feature)

View File

@ -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_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 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 "" 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: if finished and allow > 0:
note = (note + "; " if note else "") + "sand to final" note = (note + "; " if note else "") + "sand to final"
items.append(CutItem( items.append(CutItem(

View File

@ -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 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): def part_solid(part: Part):
from build123d import Box, Pos, Rot from build123d import Box, Pos, Rot
@ -98,6 +122,9 @@ def part_solid(part: Part):
if feat.kind == "chamfer": if feat.kind == "chamfer":
solid = _apply_chamfer(solid, feat, length, width, thickness) solid = _apply_chamfer(solid, feat, length, width, thickness)
continue continue
if feat.kind == "miter":
solid = _apply_miter(solid, feat, length, width, thickness)
continue
fsolid, is_cut = _feature_solid_local(feat, length, width, thickness) fsolid, is_cut = _feature_solid_local(feat, length, width, thickness)
solid = (solid - fsolid) if is_cut else (solid + fsolid) solid = (solid - fsolid) if is_cut else (solid + fsolid)

View File

@ -52,7 +52,7 @@ TOOL_CMD = {
kind=a["kind"], part=_opt(a.get("part")), face=a.get("face") or "end_b", 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")), 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")), 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-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-connect": lambda a: (cli.cmd_connect, SimpleNamespace(anchor=a["anchor"], moving=a["moving"])),
"wood-explode": lambda a: (cli.cmd_explode, SimpleNamespace(distance=a["distance"])), "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, pending.id, face=pending.face, along_in=pending.along_in,
across_in=pending.across_in, width_in=pending.width_in, across_in=pending.across_in, width_in=pending.width_in,
height_in=pending.height_in, depth_in=pending.depth_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._commit() # re-tessellates with the new geometry
self.preview_changed.emit() # clear the ghost self.preview_changed.emit() # clear the ghost

View File

@ -12,7 +12,7 @@ from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
from ..scene import FACES from ..scene import FACES
from .controller import Controller from .controller import Controller
_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer"] _KINDS = ["tenon", "mortise", "hole", "slot", "chamfer", "miter"]
class FeaturePanel(QWidget): class FeaturePanel(QWidget):
@ -54,12 +54,16 @@ class FeaturePanel(QWidget):
("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"), ("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"),
("diameter_in", "Diameter", "Hole diameter (holes only)"), ("diameter_in", "Diameter", "Hole diameter (holes only)"),
("rotation_deg", "Rotate", "Spin the feature about its face normal to line up the cross-section"), ("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 = {} self._spins = {}
for key, label, tip in self._fields: for key, label, tip in self._fields:
sp = QDoubleSpinBox() sp = QDoubleSpinBox()
if key == "rotation_deg": if key.endswith("_deg"):
sp.setRange(-180, 180); sp.setSingleStep(15); sp.setSuffix(" °") lo = -180 if key == "rotation_deg" else -80
sp.setRange(lo, 180 if key == "rotation_deg" else 80)
sp.setSingleStep(5); sp.setSuffix(" °")
else: else:
sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in") sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in")
sp.setToolTip(tip) sp.setToolTip(tip)
@ -222,4 +226,7 @@ _HINTS = {
"Depth = how deep.", "Depth = how deep.",
"chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size " "chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size "
"(the red preview highlights the face).", "(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.",
} }

View File

@ -43,6 +43,11 @@ def build_steps(scene, plan=None) -> list:
joinery = [] joinery = []
for p in scene.parts: for p in scene.parts:
for f in p.features: 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" dims = (f"{f.diameter_in:g}\"" if f.kind == "hole"
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}\"") 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})") joinery.append(f"On {names[p.id]}{f.kind} on the {f.face} face ({dims})")

View File

@ -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.", f"({_fmt_len(along)} along) and rout all {n} mortises {_fmt_len(d)} deep with a guide bushing.",
["template stock (ply/MDF)", "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. # 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)) 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()): for wd, n in sorted(widths.items()):

View File

@ -131,12 +131,14 @@ def list_projects() -> list[str]:
# Feature kinds: ADD fuses material (tenon), CUT subtracts a box/cylinder, EDGE # 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"} ADD_KINDS = {"tenon"}
CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"} CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"}
EDGE_KINDS = {"chamfer"} 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") 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 # Surface treatments, in increasing order of work. Anything past "raw" implies
# the board is sanded (so it gets the sanding allowance + lighter/finished look). # the board is sanded (so it gets the sanding allowance + lighter/finished look).
@ -166,6 +168,8 @@ class Feature:
depth_in: float = 1.0 depth_in: float = 1.0
diameter_in: float = 0.375 diameter_in: float = 0.375
rotation_deg: float = 0.0 # rotation of the feature about its face normal 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 @property
def is_cut(self) -> bool: def is_cut(self) -> bool:
@ -594,6 +598,8 @@ class Scene:
kind = kind.lower().strip() kind = kind.lower().strip()
if kind not in FEATURE_KINDS: if kind not in FEATURE_KINDS:
raise SceneError(f"Unknown feature {kind!r}. Known: {', '.join(sorted(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: if face not in FACES:
raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}") raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}")
self._checkpoint() self._checkpoint()
@ -601,9 +607,11 @@ class Scene:
fid = f"f{self._next_feat}" fid = f"f{self._next_feat}"
self._next_feat += 1 self._next_feat += 1
allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in", 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, 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}) **{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) part.features.append(feat)
self.selection = part.id self.selection = part.id
return feat return feat

70
tests/test_miter.py Normal file
View File

@ -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