Miter: full-width cut + per-kind feature inputs (usability)

Two issues Rob hit:

1. Miter pivoted at the END CENTRE, so 45° only notched a corner — you couldn't
   cut edge-to-edge. miter_cutter() now pivots about an EDGE of the end, so a
   45° miter is a true full-width corner cut; the angle's sign picks which edge
   stays long. Factored into geometry.miter_cutter so the wedge preview uses the
   exact same cut. edit_feature keeps a miter on an end face.

2. The Joinery panel showed every input for every feature, so most knobs did
   nothing for a miter (only miter/bevel) or chamfer (only width), and the face
   dropdown offered faces a miter can't use. The panel now shows only the
   relevant rows per kind (KIND_FIELDS) and limits faces per kind (KIND_FACES:
   tenon/miter = ends only).

tests: 45° miter wedge spans the full width; miter face stays an end; panel
shows angle-only for miter, width-only for chamfer, box fields for mortise.
243 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-31 09:18:55 -03:00
parent 12e4bbab88
commit 7f49e65c33
6 changed files with 149 additions and 32 deletions

View File

@ -87,26 +87,34 @@ 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)."""
def miter_cutter(feat: Feature, L: float, w: float, t: float):
"""The block whose subtraction angles a board's end. It pivots about an EDGE
of the end (not the centre) so a 45° miter is a full corner-to-corner cut, not
a centred notch. miter_deg tilts across the width (about Z), bevel_deg through
the thickness (about Y); the angle's sign picks which edge stays full length."""
from build123d import Box, Pos, Rot
big = 3 * max(L, w, t) + 10
if feat.face == "end_a":
block = Pos(-big / 2, 0, 0) * Box(big, big, big) # covers x < 0
endx = 0.0
else: # end_b (default)
block = Pos(L + big / 2, 0, 0) * Box(big, big, big) # covers x > L
endx = L
# pivot at the edge that stays full length (opposite the angle's sign)
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)
* Pos(*[-c for c in pivot]) * block)
def _apply_miter(solid, feat: Feature, L: float, w: float, t: float):
"""Angle a board's end (0/0 = a square end, no-op)."""
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
return solid - miter_cutter(feat, L, w, t)
except Exception:
return solid # invalid angle: leave the end square

View File

@ -13,6 +13,21 @@ from ..scene import FACES
from .controller import Controller
_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer", "miter"]
_ENDS = ["end_a", "end_b"]
# Which faces each feature can live on (a miter/tenon only make sense on an end).
KIND_FACES = {"tenon": _ENDS, "miter": _ENDS} # others: all faces
# Which input rows are relevant per kind — everything else is hidden so the
# panel only shows knobs that actually do something.
_BOX = ["along_in", "across_in", "width_in", "height_in", "depth_in", "rotation_deg"]
KIND_FIELDS = {
"tenon": ["across_in", "width_in", "height_in", "depth_in", "rotation_deg"],
"mortise": _BOX, "slot": _BOX, "dado": _BOX, "rabbet": _BOX,
"hole": ["along_in", "across_in", "diameter_in", "depth_in"],
"chamfer": ["width_in"],
"miter": ["miter_deg", "bevel_deg"],
}
class FeaturePanel(QWidget):
@ -43,6 +58,7 @@ class FeaturePanel(QWidget):
root.addWidget(self.hint)
form = QFormLayout()
self.form = form
self.face = QComboBox(); self.face.addItems(FACES)
self.face.currentIndexChanged.connect(self._preview)
# (key, label, tooltip)
@ -95,6 +111,15 @@ class FeaturePanel(QWidget):
pid = self.c.selected_id
return next((p for p in self.c.scene.parts if p.id == pid), None) if pid else None
def _set_row_visible(self, sp, vis: bool) -> None:
try:
self.form.setRowVisible(sp, vis) # Qt 6.4+
except (AttributeError, TypeError):
sp.setVisible(vis)
lab = self.form.labelForField(sp)
if lab is not None:
lab.setVisible(vis)
def refresh(self) -> None:
self._loading = True
part = self._part()
@ -123,15 +148,25 @@ class FeaturePanel(QWidget):
feat = self.c.active_feature_obj()
editable = feat is not None
self.face.setEnabled(editable)
self.del_btn.setEnabled(editable)
self.apply_btn.setEnabled(editable)
for sp in self._spins.values():
sp.setEnabled(editable)
if feat:
self.face.setCurrentText(feat.face)
faces = KIND_FACES.get(feat.kind, list(FACES))
self.face.blockSignals(True)
self.face.clear(); self.face.addItems(faces)
self.face.setCurrentText(feat.face if feat.face in faces else faces[0])
self.face.blockSignals(False)
self.face.setEnabled(len(faces) > 1)
show = set(KIND_FIELDS.get(feat.kind, [k for k, _, _ in self._fields]))
for key, sp in self._spins.items():
sp.setValue(getattr(feat, key))
self._set_row_visible(sp, key in show)
else:
self.face.setEnabled(False)
for sp in self._spins.values():
self._set_row_visible(sp, True)
self.hint.setText(_HINTS.get(feat.kind, "") if feat else
"Add a feature above, then adjust it here.")
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
@ -226,7 +261,8 @@ _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.",
"miter": "Miter — angles the chosen END across the FULL width. Miter = angle "
"across the width (45° = frame corner); Bevel = angle through the "
"thickness (tilted blade); both = compound. The angle's sign flips which "
"way it leans. (Other fields don't apply to a miter.)",
}

View File

@ -630,6 +630,8 @@ class Scene:
if v is None:
continue
if k == "face":
if feat.kind in END_KINDS and v not in END_FACES:
continue # a miter only lives on an end
feat.face = v
elif hasattr(feat, k):
setattr(feat, k, float(v))

View File

@ -56,21 +56,13 @@ def _miter_wedge_mesh(part: Part, feat):
"""The piece a miter cuts OFF (board ∩ cutter), placed in world space — so the
preview/highlight shows the angled cut, not a tenon-like box. None if no cut."""
try:
from build123d import Box, Pos, Rot
from build123d import Box, Pos
from .geometry import miter_cutter
L = part.length_in
t, w = part.section_in
board = Pos(L / 2, 0, 0) * Box(L, w, t)
big = 3 * max(L, w, t) + 10
block = Box(big, big, big)
if feat.face == "end_a":
cutter = Pos(-big / 2, 0, 0) * block
pivot = (0.0, 0.0, 0.0)
else:
cutter = Pos(L + big / 2, 0, 0) * block
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)
wedge = board & cutter # the removed piece
wedge = board & miter_cutter(feat, L, w, t) # the removed piece (same cutter)
mesh = _solid_to_polydata(wedge)
if mesh.n_points == 0:
return None

View File

@ -0,0 +1,53 @@
"""Offscreen test: the Joinery panel shows only the inputs each feature uses."""
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402
from woodshop.gui.controller import Controller # noqa: E402
from woodshop.gui.feature_panel import FeaturePanel # noqa: E402
_app = QApplication.instance() or QApplication([])
def _panel(tmp_path):
c = Controller(str(tmp_path / "s.json"))
return c, FeaturePanel(c)
def test_miter_shows_only_angle_fields(tmp_path):
c, panel = _panel(tmp_path)
c.place("2x4", 24)
c.add_feature("miter")
panel.refresh()
assert panel._spins["miter_deg"].isVisibleTo(panel)
assert panel._spins["bevel_deg"].isVisibleTo(panel)
assert not panel._spins["width_in"].isVisibleTo(panel) # irrelevant -> hidden
assert not panel._spins["depth_in"].isVisibleTo(panel)
# face dropdown limited to ends
faces = [panel.face.itemText(i) for i in range(panel.face.count())]
assert faces == ["end_a", "end_b"]
def test_chamfer_shows_only_width(tmp_path):
c, panel = _panel(tmp_path)
c.place("2x4", 24)
c.add_feature("chamfer")
panel.refresh()
assert panel._spins["width_in"].isVisibleTo(panel)
assert not panel._spins["depth_in"].isVisibleTo(panel)
assert not panel._spins["miter_deg"].isVisibleTo(panel)
def test_mortise_shows_box_fields(tmp_path):
c, panel = _panel(tmp_path)
c.place("2x4", 24)
c.add_feature("mortise")
panel.refresh()
for k in ("along_in", "width_in", "height_in", "depth_in"):
assert panel._spins[k].isVisibleTo(panel)
assert not panel._spins["miter_deg"].isVisibleTo(panel)

View File

@ -82,3 +82,29 @@ def test_miter_preview_is_a_wedge_not_a_box():
# the wedge sits at the mitered end (x≈24), not a 1" box at the end centre
xmax = mesh.bounds[1]
assert xmax > 20 # spans out to the board end, like the real cut
def test_miter_cuts_full_width_not_a_corner():
"""A 45° miter on a 2x4 should span the FULL width (edge-to-edge), not just
notch a corner the removed wedge's width ≈ the board width."""
pytest.importorskip("build123d")
from woodshop.geometry import miter_cutter
from build123d import Box, Pos
s = Scene()
s.place("2x4", 24)
p = s.get_part("p1")
t, w = p.section_in
feat = s.add_feature("p1", "miter", miter_deg=45)
board = Pos(24 / 2, 0, 0) * Box(24, w, t)
wedge = board & miter_cutter(feat, 24, w, t)
b = wedge.bounding_box()
y_extent = b.max.Y - b.min.Y
assert y_extent > 0.9 * w # spans (nearly) the full 3.5" width
def test_edit_feature_keeps_miter_on_an_end():
s = Scene()
s.place("2x4", 24)
f = s.add_feature("p1", "miter", miter_deg=45)
s.edit_feature(f.id, face="top") # invalid for a miter — should be ignored
assert f.face in ("end_a", "end_b")