Miter from-center (picket points) + real chamfer preview
- Miter gains a from_center option: pivot at the end CENTRE so it notches a corner instead of cutting full width. Add a +angle and a −angle from centre on the same end to bring it to a point (picket fence). Feature.from_center; geometry.miter_cutter honours it; Joinery panel checkbox (miter only); CLI feature --miter-pivot edge|center; wood-feature tool arg; serialization. - Chamfer preview now shows the actual bevel slivers it removes (board − chamfered board), like the miter wedge — instead of a flat face slab. _chamfer_removed_mesh, with the face-highlight as fallback. - tests: center miter notches a corner, two center miters make a point, from_center roundtrip, chamfer preview is real geometry. 247 pass. Re-run gen_wood_tools.py for the voice tool to expose --miter-pivot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f49e65c33
commit
f1eb7e8c29
|
|
@ -193,6 +193,7 @@ TOOLS = {
|
||||||
{"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": "--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)"},
|
{"flag": "--bevel", "variable": "bevel", "default": "", "description": "kind=miter: angle through the thickness in degrees (tilted blade)"},
|
||||||
|
{"flag": "--miter-pivot", "variable": "miter_pivot", "default": "", "description": "kind=miter: 'edge' (full-width, default) or 'center' (corner notch — add a +angle and a -angle from center to bring an end to a point, e.g. a picket)"},
|
||||||
],
|
],
|
||||||
"code": code(
|
"code": code(
|
||||||
'cmd = ws + ["feature", kind]\n'
|
'cmd = ws + ["feature", kind]\n'
|
||||||
|
|
@ -200,7 +201,8 @@ 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), ("--miter", miter), ("--bevel", bevel)]:\n'
|
' ("--rotation", rotation), ("--miter", miter), ("--bevel", bevel),\n'
|
||||||
|
' ("--miter-pivot", miter_pivot)]:\n'
|
||||||
' if val != "": cmd += [flag, str(val)]'
|
' if val != "": cmd += [flag, str(val)]'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -177,11 +177,17 @@ def cmd_feature(scene: Scene, args) -> str:
|
||||||
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)),
|
miter_deg=_optdeg(getattr(args, "miter", None)),
|
||||||
bevel_deg=_optdeg(getattr(args, "bevel", None)))
|
bevel_deg=_optdeg(getattr(args, "bevel", None)),
|
||||||
|
from_center=_miter_pivot(getattr(args, "miter_pivot", 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}."
|
||||||
|
|
||||||
|
|
||||||
|
def _miter_pivot(value):
|
||||||
|
"""'center'/'edge' -> bool from_center, or None to leave unchanged."""
|
||||||
|
return None if not value else (str(value).lower().startswith("c"))
|
||||||
|
|
||||||
|
|
||||||
def cmd_feature_edit(scene: Scene, args) -> str:
|
def cmd_feature_edit(scene: Scene, args) -> str:
|
||||||
feat = scene.edit_feature(
|
feat = scene.edit_feature(
|
||||||
args.fid, face=args.face,
|
args.fid, face=args.face,
|
||||||
|
|
@ -190,7 +196,8 @@ def cmd_feature_edit(scene: Scene, args) -> str:
|
||||||
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)),
|
miter_deg=_optdeg(getattr(args, "miter", None)),
|
||||||
bevel_deg=_optdeg(getattr(args, "bevel", None)))
|
bevel_deg=_optdeg(getattr(args, "bevel", None)),
|
||||||
|
from_center=_miter_pivot(getattr(args, "miter_pivot", None)))
|
||||||
return f"Updated feature {feat.id}."
|
return f"Updated feature {feat.id}."
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -392,6 +399,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
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("--miter", help="miter angle across the width, deg (kind=miter)")
|
||||||
parser.add_argument("--bevel", help="bevel angle through the thickness, deg (kind=miter)")
|
parser.add_argument("--bevel", help="bevel angle through the thickness, deg (kind=miter)")
|
||||||
|
parser.add_argument("--miter-pivot", dest="miter_pivot", choices=["edge", "center"],
|
||||||
|
help="miter pivot: 'edge' (full-width cut, default) or 'center' "
|
||||||
|
"(corner notch — two make a point)")
|
||||||
|
|
||||||
sp = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot/miter)")
|
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("kind", help="tenon | mortise | hole | slot | dado | rabbet | chamfer | miter")
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,12 @@ def miter_cutter(feat: Feature, L: float, w: float, t: float):
|
||||||
else: # end_b (default)
|
else: # end_b (default)
|
||||||
block = Pos(L + big / 2, 0, 0) * Box(big, big, big) # covers x > L
|
block = Pos(L + big / 2, 0, 0) * Box(big, big, big) # covers x > L
|
||||||
endx = L
|
endx = L
|
||||||
# pivot at the edge that stays full length (opposite the angle's sign)
|
if getattr(feat, "from_center", False):
|
||||||
py = (-w / 2 if feat.miter_deg >= 0 else w / 2) if feat.miter_deg else 0.0
|
pivot = (endx, 0.0, 0.0) # pivot at the end CENTRE -> a corner notch
|
||||||
pz = (-t / 2 if feat.bevel_deg >= 0 else t / 2) if feat.bevel_deg else 0.0
|
else: # pivot at the edge that stays full length
|
||||||
pivot = (endx, py, pz)
|
py = (-w / 2 if feat.miter_deg >= 0 else w / 2) if feat.miter_deg else 0.0
|
||||||
|
pz = (-t / 2 if feat.bevel_deg >= 0 else t / 2) if feat.bevel_deg else 0.0
|
||||||
|
pivot = (endx, py, pz)
|
||||||
return (Pos(*pivot) * Rot(Z=feat.miter_deg, Y=feat.bevel_deg)
|
return (Pos(*pivot) * Rot(Z=feat.miter_deg, Y=feat.bevel_deg)
|
||||||
* Pos(*[-c for c in pivot]) * block)
|
* Pos(*[-c for c in pivot]) * block)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ 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")), miter=_opt(a.get("miter")), bevel=_opt(a.get("bevel")))),
|
rotation=_opt(a.get("rotation")), miter=_opt(a.get("miter")), bevel=_opt(a.get("bevel")),
|
||||||
|
miter_pivot=_opt(a.get("miter_pivot")))),
|
||||||
"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"])),
|
||||||
|
|
@ -393,7 +394,8 @@ class Controller(QObject):
|
||||||
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)
|
miter_deg=pending.miter_deg, bevel_deg=pending.bevel_deg,
|
||||||
|
from_center=pending.from_center)
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ class FeaturePanel(QWidget):
|
||||||
self._spins[key] = sp
|
self._spins[key] = sp
|
||||||
form.addRow(label, sp)
|
form.addRow(label, sp)
|
||||||
form.insertRow(0, "Face", self.face)
|
form.insertRow(0, "Face", self.face)
|
||||||
|
self.center_cb = QCheckBox("From centre (corner notch — two make a point)")
|
||||||
|
self.center_cb.setToolTip("Pivot the miter at the end's centre so it notches a corner; "
|
||||||
|
"add a +angle and a −angle to bring the end to a point (picket)")
|
||||||
|
self.center_cb.toggled.connect(self._preview)
|
||||||
|
form.addRow("", self.center_cb)
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
||||||
self.fit_btn = QPushButton("Fit to mate…")
|
self.fit_btn = QPushButton("Fit to mate…")
|
||||||
|
|
@ -163,10 +168,13 @@ class FeaturePanel(QWidget):
|
||||||
for key, sp in self._spins.items():
|
for key, sp in self._spins.items():
|
||||||
sp.setValue(getattr(feat, key))
|
sp.setValue(getattr(feat, key))
|
||||||
self._set_row_visible(sp, key in show)
|
self._set_row_visible(sp, key in show)
|
||||||
|
self.center_cb.setChecked(getattr(feat, "from_center", False))
|
||||||
|
self._set_row_visible(self.center_cb, feat.kind == "miter")
|
||||||
else:
|
else:
|
||||||
self.face.setEnabled(False)
|
self.face.setEnabled(False)
|
||||||
for sp in self._spins.values():
|
for sp in self._spins.values():
|
||||||
self._set_row_visible(sp, True)
|
self._set_row_visible(sp, True)
|
||||||
|
self._set_row_visible(self.center_cb, False)
|
||||||
self.hint.setText(_HINTS.get(feat.kind, "") if feat else
|
self.hint.setText(_HINTS.get(feat.kind, "") if feat else
|
||||||
"Add a feature above, then adjust it here.")
|
"Add a feature above, then adjust it here.")
|
||||||
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
|
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
|
||||||
|
|
@ -201,6 +209,7 @@ class FeaturePanel(QWidget):
|
||||||
if self._loading or not self.c.active_feature:
|
if self._loading or not self.c.active_feature:
|
||||||
return
|
return
|
||||||
dims = {key: sp.value() for key, sp in self._spins.items()}
|
dims = {key: sp.value() for key, sp in self._spins.items()}
|
||||||
|
dims["from_center"] = self.center_cb.isChecked()
|
||||||
self.c.set_preview(face=self.face.currentText(), **dims)
|
self.c.set_preview(face=self.face.currentText(), **dims)
|
||||||
|
|
||||||
def _fit(self) -> None:
|
def _fit(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ class Feature:
|
||||||
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)
|
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
|
bevel_deg: float = 0.0 # 'miter' end cut: angle through the thickness
|
||||||
|
from_center: bool = False # miter pivots at the end CENTRE (corner notch) vs an edge
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cut(self) -> bool:
|
def is_cut(self) -> bool:
|
||||||
|
|
@ -610,6 +611,8 @@ class Scene:
|
||||||
"diameter_in", "rotation_deg", "miter_deg", "bevel_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 dims.get("from_center") is not None:
|
||||||
|
feat.from_center = bool(dims["from_center"])
|
||||||
if kind == "miter" and feat.miter_deg == 0.0 and feat.bevel_deg == 0.0:
|
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
|
feat.miter_deg = 45.0 # a sensible default a user can edit
|
||||||
part.features.append(feat)
|
part.features.append(feat)
|
||||||
|
|
@ -633,6 +636,8 @@ class Scene:
|
||||||
if feat.kind in END_KINDS and v not in END_FACES:
|
if feat.kind in END_KINDS and v not in END_FACES:
|
||||||
continue # a miter only lives on an end
|
continue # a miter only lives on an end
|
||||||
feat.face = v
|
feat.face = v
|
||||||
|
elif k == "from_center":
|
||||||
|
feat.from_center = bool(v)
|
||||||
elif hasattr(feat, k):
|
elif hasattr(feat, k):
|
||||||
setattr(feat, k, float(v))
|
setattr(feat, k, float(v))
|
||||||
self.selection = part.id
|
self.selection = part.id
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,10 @@ def feature_preview_mesh(part, feat):
|
||||||
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
|
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
|
||||||
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
|
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
|
||||||
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
|
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
|
||||||
elif feat.kind == "chamfer": # can't cheaply preview the bevel — highlight the face
|
elif feat.kind == "chamfer": # show the bevel slivers it removes; else the face
|
||||||
|
removed = _chamfer_removed_mesh(part, feat)
|
||||||
|
if removed is not None:
|
||||||
|
return removed
|
||||||
ue, ve, thin = _axis_extent(u, L, w, t), _axis_extent(v, L, w, t), 0.08
|
ue, ve, thin = _axis_extent(u, L, w, t), _axis_extent(v, L, w, t), 0.08
|
||||||
dims = tuple(ue * abs(u[i]) + ve * abs(v[i]) + thin * abs(n[i]) for i in range(3))
|
dims = tuple(ue * abs(u[i]) + ve * abs(v[i]) + thin * abs(n[i]) for i in range(3))
|
||||||
c = fp
|
c = fp
|
||||||
|
|
@ -156,6 +159,30 @@ def feature_preview_mesh(part, feat):
|
||||||
return mesh
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def _chamfer_removed_mesh(part: Part, feat):
|
||||||
|
"""The bevel slivers a chamfer removes (board − chamfered board), placed in
|
||||||
|
world space — so the preview/highlight shows the real bevel, not a face slab.
|
||||||
|
None if the chamfer is a no-op (e.g. over-sized and skipped)."""
|
||||||
|
try:
|
||||||
|
from build123d import Box, Pos
|
||||||
|
|
||||||
|
from .geometry import _apply_chamfer
|
||||||
|
L = part.length_in
|
||||||
|
t, w = part.section_in
|
||||||
|
box = Pos(L / 2, 0, 0) * Box(L, w, t)
|
||||||
|
removed = box - _apply_chamfer(box, feat, L, w, t)
|
||||||
|
mesh = _solid_to_polydata(removed)
|
||||||
|
if mesh.n_points == 0:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
mesh.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
|
||||||
|
mesh.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
|
||||||
|
mesh.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
|
||||||
|
mesh.translate(part.position_in, inplace=True)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
def _add_feature_edges(plotter, mesh, selected: bool) -> None:
|
def _add_feature_edges(plotter, mesh, selected: bool) -> None:
|
||||||
"""Overlay a tessellated solid's real edges (corners/holes/chamfers) so it
|
"""Overlay a tessellated solid's real edges (corners/holes/chamfers) so it
|
||||||
reads as crisply as a plain board, without the triangle-mesh noise."""
|
reads as crisply as a plain board, without the triangle-mesh noise."""
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,52 @@ def test_edit_feature_keeps_miter_on_an_end():
|
||||||
f = s.add_feature("p1", "miter", miter_deg=45)
|
f = s.add_feature("p1", "miter", miter_deg=45)
|
||||||
s.edit_feature(f.id, face="top") # invalid for a miter — should be ignored
|
s.edit_feature(f.id, face="top") # invalid for a miter — should be ignored
|
||||||
assert f.face in ("end_a", "end_b")
|
assert f.face in ("end_a", "end_b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_center_miter_notches_a_corner():
|
||||||
|
"""A from-center 45° miter removes a corner triangle (~half the width),
|
||||||
|
unlike the full-width edge cut."""
|
||||||
|
pytest.importorskip("build123d")
|
||||||
|
from woodshop.geometry import miter_cutter
|
||||||
|
from build123d import Box, Pos
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
t, w = s.get_part("p1").section_in
|
||||||
|
f = s.add_feature("p1", "miter", miter_deg=45, from_center=True)
|
||||||
|
assert f.from_center is True
|
||||||
|
board = Pos(12, 0, 0) * Box(24, w, t)
|
||||||
|
wedge = board & miter_cutter(f, 24, w, t)
|
||||||
|
b = wedge.bounding_box()
|
||||||
|
assert (b.max.Y - b.min.Y) < 0.75 * w # only a corner, not full width
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_center_miters_make_a_point():
|
||||||
|
"""+45 and −45 from centre on the same end bring it to a point (picket)."""
|
||||||
|
pytest.importorskip("build123d")
|
||||||
|
from woodshop.geometry import part_solid
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
full = part_solid(s.get_part("p1")).volume
|
||||||
|
s.add_feature("p1", "miter", miter_deg=45, from_center=True)
|
||||||
|
s.add_feature("p1", "miter", miter_deg=-45, from_center=True)
|
||||||
|
pointed = part_solid(s.get_part("p1")).volume
|
||||||
|
assert pointed < full # both corners gone -> a point
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_center_roundtrips():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.add_feature("p1", "miter", miter_deg=45, from_center=True)
|
||||||
|
s2 = Scene.from_dict(json.loads(json.dumps(s.to_dict())))
|
||||||
|
assert s2.get_part("p1").features[0].from_center is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_chamfer_preview_shows_removed_bevel():
|
||||||
|
pytest.importorskip("pyvista")
|
||||||
|
pytest.importorskip("build123d")
|
||||||
|
from woodshop.viewer import feature_preview_mesh
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
f = s.add_feature("p1", "chamfer", face="top", width_in=0.25)
|
||||||
|
mesh = feature_preview_mesh(s.get_part("p1"), f)
|
||||||
|
assert mesh is not None and mesh.n_points > 0 # the bevel slivers, not a slab
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue