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:
parent
12e4bbab88
commit
7f49e65c33
|
|
@ -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
|
return solid # over-sized / invalid chamfer: leave the board unbevelled
|
||||||
|
|
||||||
|
|
||||||
def _apply_miter(solid, feat: Feature, L: float, w: float, t: float):
|
def miter_cutter(feat: Feature, L: float, w: float, t: float):
|
||||||
"""Angle a board's end: rotate a large cutting block about the end and
|
"""The block whose subtraction angles a board's end. It pivots about an EDGE
|
||||||
subtract it. miter_deg tilts the cut across the width (about Z), bevel_deg
|
of the end (not the centre) so a 45° miter is a full corner-to-corner cut, not
|
||||||
through the thickness (about Y). 0/0 = a square end (no-op)."""
|
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
|
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:
|
if feat.miter_deg == 0.0 and feat.bevel_deg == 0.0:
|
||||||
return solid
|
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:
|
try:
|
||||||
return solid - cutter
|
return solid - miter_cutter(feat, L, w, t)
|
||||||
except Exception:
|
except Exception:
|
||||||
return solid # invalid angle: leave the end square
|
return solid # invalid angle: leave the end square
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,21 @@ from ..scene import FACES
|
||||||
from .controller import Controller
|
from .controller import Controller
|
||||||
|
|
||||||
_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer", "miter"]
|
_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):
|
class FeaturePanel(QWidget):
|
||||||
|
|
@ -43,6 +58,7 @@ class FeaturePanel(QWidget):
|
||||||
root.addWidget(self.hint)
|
root.addWidget(self.hint)
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
|
self.form = form
|
||||||
self.face = QComboBox(); self.face.addItems(FACES)
|
self.face = QComboBox(); self.face.addItems(FACES)
|
||||||
self.face.currentIndexChanged.connect(self._preview)
|
self.face.currentIndexChanged.connect(self._preview)
|
||||||
# (key, label, tooltip)
|
# (key, label, tooltip)
|
||||||
|
|
@ -95,6 +111,15 @@ class FeaturePanel(QWidget):
|
||||||
pid = self.c.selected_id
|
pid = self.c.selected_id
|
||||||
return next((p for p in self.c.scene.parts if p.id == pid), None) if pid else None
|
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:
|
def refresh(self) -> None:
|
||||||
self._loading = True
|
self._loading = True
|
||||||
part = self._part()
|
part = self._part()
|
||||||
|
|
@ -123,15 +148,25 @@ class FeaturePanel(QWidget):
|
||||||
|
|
||||||
feat = self.c.active_feature_obj()
|
feat = self.c.active_feature_obj()
|
||||||
editable = feat is not None
|
editable = feat is not None
|
||||||
self.face.setEnabled(editable)
|
|
||||||
self.del_btn.setEnabled(editable)
|
self.del_btn.setEnabled(editable)
|
||||||
self.apply_btn.setEnabled(editable)
|
self.apply_btn.setEnabled(editable)
|
||||||
for sp in self._spins.values():
|
for sp in self._spins.values():
|
||||||
sp.setEnabled(editable)
|
sp.setEnabled(editable)
|
||||||
if feat:
|
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():
|
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)
|
||||||
|
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
|
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 "")
|
||||||
|
|
@ -226,7 +261,8 @@ _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 "
|
"miter": "Miter — angles the chosen END across the FULL width. Miter = angle "
|
||||||
"frame corner); Bevel = angle through the thickness (tilted blade); set "
|
"across the width (45° = frame corner); Bevel = angle through the "
|
||||||
"both for a compound cut. Apply to see the real angled end.",
|
"thickness (tilted blade); both = compound. The angle's sign flips which "
|
||||||
|
"way it leans. (Other fields don't apply to a miter.)",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,8 @@ class Scene:
|
||||||
if v is None:
|
if v is None:
|
||||||
continue
|
continue
|
||||||
if k == "face":
|
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
|
feat.face = v
|
||||||
elif hasattr(feat, k):
|
elif hasattr(feat, k):
|
||||||
setattr(feat, k, float(v))
|
setattr(feat, k, float(v))
|
||||||
|
|
|
||||||
|
|
@ -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
|
"""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."""
|
preview/highlight shows the angled cut, not a tenon-like box. None if no cut."""
|
||||||
try:
|
try:
|
||||||
from build123d import Box, Pos, Rot
|
from build123d import Box, Pos
|
||||||
|
|
||||||
|
from .geometry import miter_cutter
|
||||||
L = part.length_in
|
L = part.length_in
|
||||||
t, w = part.section_in
|
t, w = part.section_in
|
||||||
board = Pos(L / 2, 0, 0) * Box(L, w, t)
|
board = Pos(L / 2, 0, 0) * Box(L, w, t)
|
||||||
big = 3 * max(L, w, t) + 10
|
wedge = board & miter_cutter(feat, L, w, t) # the removed piece (same cutter)
|
||||||
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
|
|
||||||
mesh = _solid_to_polydata(wedge)
|
mesh = _solid_to_polydata(wedge)
|
||||||
if mesh.n_points == 0:
|
if mesh.n_points == 0:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
# the wedge sits at the mitered end (x≈24), not a 1" box at the end centre
|
||||||
xmax = mesh.bounds[1]
|
xmax = mesh.bounds[1]
|
||||||
assert xmax > 20 # spans out to the board end, like the real cut
|
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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue