From 7f49e65c33af2ed3a47b85452e83de9fad1b37af Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 31 May 2026 09:18:55 -0300 Subject: [PATCH] Miter: full-width cut + per-kind feature inputs (usability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/woodshop/geometry.py | 38 +++++++++++++--------- src/woodshop/gui/feature_panel.py | 46 ++++++++++++++++++++++++--- src/woodshop/scene.py | 2 ++ src/woodshop/viewer.py | 16 +++------- tests/test_feature_panel.py | 53 +++++++++++++++++++++++++++++++ tests/test_miter.py | 26 +++++++++++++++ 6 files changed, 149 insertions(+), 32 deletions(-) create mode 100644 tests/test_feature_panel.py diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index ba4df2e..dbb1a45 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -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 diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index d8d4874..c21cec4 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -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.)", } diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 2463b20..2aa70d4 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -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)) diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index e392a77..7c14ce7 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -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 diff --git a/tests/test_feature_panel.py b/tests/test_feature_panel.py new file mode 100644 index 0000000..d315cd3 --- /dev/null +++ b/tests/test_feature_panel.py @@ -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) diff --git a/tests/test_miter.py b/tests/test_miter.py index 2557250..93aa4e5 100644 --- a/tests/test_miter.py +++ b/tests/test_miter.py @@ -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")