Add GUI Joinery panel (Phase B) + chamfers

GUI feature panel (gui/feature_panel.py), a "Joinery" tab beside Parts:
- Add buttons (Tenon/Mortise/Hole/Slot/Chamfer) drop a sensibly-sized feature
  on the selected board (add-with-default), then edit fields (face, along,
  across, width, height, depth, diameter) live; a feature list to pick which to
  edit; Delete. controller.active_feature tracks the one being edited, with
  size defaults derived from the board (controller._feature_defaults).

Chamfers (edge bevels):
- New EDGE_KINDS={"chamfer"}; geometry._apply_chamfer selects the edges around a
  face and bevels them with build123d chamfer(), clamped + try/except so an
  over-sized bevel can't crash the build. Verified: end + top-edge chamfers
  render and reduce volume.

66 tests pass (added chamfer volume + oversize-fallback). Verified GUI imports;
live window still needs a real display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 13:54:04 -03:00
parent a0072e6271
commit 9cbff4ec78
7 changed files with 242 additions and 13 deletions

View File

@ -95,14 +95,16 @@ pytest # 25 tests
B is aligned to A's reference corner (top faces level + one side flush) rather B is aligned to A's reference corner (top faces level + one side flush) rather
than centered. The flush corner is fixed (A's +width/+thick side; no per-join than centered. The flush corner is fixed (A's +width/+thick side; no per-join
choice of which corner / centered). choice of which corner / centered).
2. **Joinery features** (`Feature` on each `Part`) are parametric booleans applied 2. **Joinery features** (`Feature` on each `Part`) are parametric ops applied in
in `geometry.part_solid`: `tenon` adds a protruding tongue (fuse); `mortise`, `geometry.part_solid`: `tenon` fuses a protruding tongue; `mortise`/`hole`/
`hole`, `slot`, `dado`, `rabbet` cut into a chosen face. The viewport `slot`/`dado`/`rabbet` cut a box/cylinder into a face; `chamfer` bevels the
tessellates featured boards via build123d (plain boards stay fast pyvista edges around a face via build123d `chamfer()` (handled specially —
boxes). CLI: `feature/feature-edit/feature-delete/features`; voice: `_apply_chamfer`, with a try/except fallback for over-sized bevels). The
`wood-feature`/`wood-feature-delete`. Not yet: **chamfers/bevels** (need edge viewport tessellates featured boards (plain boards stay fast pyvista boxes).
selection, not a box/cylinder cut), countersinks, and the **GUI feature panel** Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice
(add/edit features by clicking — currently CLI/voice only). `wood-feature`/`wood-feature-delete`; **GUI Joinery tab** (`feature_panel.py`)
— add-with-default then edit fields; `controller.active_feature` is the one
being edited. Not yet: countersinks, and click-a-face-to-place in the GUI.
2. **Latency** ~713s per utterance (one `claude -p` call). 2. **Latency** ~713s per utterance (one `claude -p` call).
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against 3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
failures but the mic path isn't exercised in the unit tests. failures but the mic path isn't exercised in the unit tests.

View File

@ -68,6 +68,35 @@ def _feature_solid_local(feat: Feature, L: float, w: float, t: float):
return Pos(*c) * box, (feat.kind != "tenon") return Pos(*c) * box, (feat.kind != "tenon")
def _face_plane(face: str, L: float, w: float, t: float):
"""(axis index, coordinate) of a face's plane, for selecting its edges."""
return {
"top": (2, t / 2), "bottom": (2, -t / 2),
"right": (1, w / 2), "left": (1, -w / 2),
"end_b": (0, L), "end_a": (0, 0.0),
}[face]
def _apply_chamfer(solid, feat: Feature, L: float, w: float, t: float):
"""Bevel the edges around the feature's face by width_in."""
from build123d import chamfer
axis, value = _face_plane(feat.face, L, w, t)
size = feat.width_in or min(t, w) / 3
def coord(e):
c = e.center()
return (c.X, c.Y, c.Z)[axis]
edges = [e for e in solid.edges() if abs(coord(e) - value) < 1e-3]
if not edges:
return solid
try:
return chamfer(edges, min(size, min(t, w) / 2 - 1e-3))
except Exception:
return solid # over-sized / invalid chamfer: leave the board unbevelled
def part_solid(part: Part): def part_solid(part: Part):
from build123d import Box, Pos, Rot from build123d import Box, Pos, Rot
@ -75,7 +104,10 @@ def part_solid(part: Part):
thickness, width = part.section_in thickness, width = part.section_in
solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin
for feat in part.features: # apply joinery as booleans for feat in part.features: # apply joinery
if feat.kind == "chamfer":
solid = _apply_chamfer(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

@ -73,6 +73,7 @@ class Controller(QObject):
self.scene = Scene.load(self.scene_path) self.scene = Scene.load(self.scene_path)
self._schemas: str | None = None self._schemas: str | None = None
self.selected: list[str] = [self.scene.selection] if self.scene.selection else [] self.selected: list[str] = [self.scene.selection] if self.scene.selection else []
self.active_feature: str | None = None # feature currently being edited
# ----- persistence / notify ---------------------------------------- # ----- persistence / notify ----------------------------------------
def save(self) -> None: def save(self) -> None:
@ -189,6 +190,70 @@ class Controller(QObject):
def rotate(self, ref=None, yaw=None, tilt=None, roll=None): def rotate(self, ref=None, yaw=None, tilt=None, roll=None):
self._do(lambda: f"Oriented {self.scene.orient(ref, yaw=yaw, tilt=tilt, roll=roll).id}.") self._do(lambda: f"Oriented {self.scene.orient(ref, yaw=yaw, tilt=tilt, roll=roll).id}.")
# ----- joinery features --------------------------------------------
def _feature_defaults(self, kind: str, part) -> dict:
"""Sensible starting dimensions derived from the board's size."""
t, w = part.section_in
L = part.length_in
if kind == "tenon":
return dict(face="end_b", width_in=round(w / 2, 3), height_in=round(t / 2, 3), depth_in=1.0)
if kind == "mortise":
return dict(face="top", along_in=round(L / 2, 3), width_in=1.5,
height_in=round(w / 2, 3), depth_in=round(t / 2, 3))
if kind == "hole":
return dict(face="top", along_in=round(L / 2, 3), diameter_in=0.375, depth_in=0.0)
if kind == "slot":
return dict(face="top", along_in=round(L / 2, 3), width_in=2.0,
height_in=0.5, depth_in=round(t / 2, 3))
if kind == "chamfer":
return dict(face="end_b", width_in=round(min(t, w) / 3, 3), depth_in=0.0)
return dict(face="top")
def add_feature(self, kind: str) -> None:
if not self.scene.selection:
self.logged.emit("sys", "Select a board first.")
return
part = self.scene.get_part(self.scene.selection)
try:
feat = self.scene.add_feature(part.id, kind, **self._feature_defaults(kind, part))
except (SceneError, ValueError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self.active_feature = feat.id
self._commit(f"Added {kind} ({feat.id}) to {part.id}.")
def select_feature(self, fid: str | None) -> None:
self.active_feature = fid
self.changed.emit()
def edit_active_feature(self, **dims) -> None:
if not self.active_feature:
return
try:
self.scene.edit_feature(self.active_feature, **dims)
except (SceneError, ValueError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self._commit()
def delete_active_feature(self) -> None:
if not self.active_feature:
return
try:
msg = self.scene.delete_feature(self.active_feature)
except SceneError:
return
self.active_feature = None
self._commit(msg)
def active_feature_obj(self):
if not self.active_feature:
return None
try:
return self.scene.find_feature(self.active_feature)[1]
except SceneError:
return None
# ----- project / export -------------------------------------------- # ----- project / export --------------------------------------------
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name))) def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name)))
def save_project(self, name): def save_project(self, name):

View File

@ -0,0 +1,106 @@
"""Joinery panel: add tenon/mortise/hole/slot/chamfer features to the selected
board, then tweak the active feature's fields. Add-with-default-then-edit:
clicking a kind drops a sensibly-sized feature you can immediately adjust."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QComboBox, QDoubleSpinBox, QFormLayout, QGridLayout,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout, QWidget)
from ..scene import FACES
from .controller import Controller
_KINDS = ["tenon", "mortise", "hole", "slot", "chamfer"]
class FeaturePanel(QWidget):
def __init__(self, controller: Controller, parent=None):
super().__init__(parent)
self.c = controller
self._loading = False
root = QVBoxLayout(self)
root.addWidget(QLabel("<b>Joinery on the selected board</b>"))
add = QGridLayout()
for i, kind in enumerate(_KINDS):
b = QPushButton("+ " + kind.capitalize())
b.clicked.connect(lambda _=False, k=kind: self.c.add_feature(k))
add.addWidget(b, i // 2, i % 2)
root.addLayout(add)
self.list = QListWidget()
self.list.itemSelectionChanged.connect(self._on_row)
root.addWidget(self.list, 1)
form = QFormLayout()
self.face = QComboBox(); self.face.addItems(FACES)
self.face.currentIndexChanged.connect(self._apply)
self._spins = {}
for key, label, lo, hi in [
("along_in", "Along", -48, 96), ("across_in", "Across", -24, 24),
("width_in", "Width", 0, 24), ("height_in", "Height", 0, 24),
("depth_in", "Depth", 0, 24), ("diameter_in", "Diameter", 0, 12)]:
sp = QDoubleSpinBox(); sp.setRange(lo, hi); sp.setSingleStep(0.25); sp.setSuffix(" in")
sp.editingFinished.connect(self._apply)
self._spins[key] = sp
form.addRow("Face", self.face)
for key, sp in self._spins.items():
form.addRow(key.replace("_in", "").capitalize(), sp)
root.addLayout(form)
btns = QHBoxLayout()
self.del_btn = QPushButton("Delete feature")
self.del_btn.clicked.connect(self.c.delete_active_feature)
btns.addWidget(self.del_btn)
root.addLayout(btns)
self.c.changed.connect(self.refresh)
self.refresh()
def _part(self):
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 refresh(self) -> None:
self._loading = True
part = self._part()
self.list.clear()
feats = part.features if part else []
# keep the active feature pointing at something on this board
ids = [f.id for f in feats]
if self.c.active_feature not in ids:
self.c.active_feature = ids[0] if ids else None
for f in feats:
label = (f"{f.id}: {f.kind} · {f.face}")
item = QListWidgetItem(label)
item.setData(Qt.UserRole, f.id)
self.list.addItem(item)
if f.id == self.c.active_feature:
item.setSelected(True)
feat = self.c.active_feature_obj()
editable = feat is not None
self.face.setEnabled(editable)
self.del_btn.setEnabled(editable)
for sp in self._spins.values():
sp.setEnabled(editable)
if feat:
self.face.setCurrentText(feat.face)
for key, sp in self._spins.items():
sp.setValue(getattr(feat, key))
self._loading = False
def _on_row(self) -> None:
if self._loading:
return
items = self.list.selectedItems()
if items:
self.c.select_feature(items[0].data(Qt.UserRole))
def _apply(self) -> None:
if self._loading or not self.c.active_feature:
return
dims = {key: sp.value() for key, sp in self._spins.items()}
self.c.edit_active_feature(face=self.face.currentText(), **dims)

View File

@ -5,12 +5,13 @@ from __future__ import annotations
from PySide6.QtCore import Qt, QThreadPool from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QAction, QKeySequence from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox, from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox,
QSplitter, QVBoxLayout, QWidget) QSplitter, QTabWidget, QVBoxLayout, QWidget)
from ..cutlist import board_feet from ..cutlist import board_feet
from ..scene import list_projects from ..scene import list_projects
from .command_bar import CommandBar from .command_bar import CommandBar
from .controller import Controller from .controller import Controller
from .feature_panel import FeaturePanel
from .numpad import NumpadPanel from .numpad import NumpadPanel
from .panels import PartsPanel from .panels import PartsPanel
from .viewport import Viewport from .viewport import Viewport
@ -26,13 +27,18 @@ class MainWindow(QMainWindow):
self.controller = Controller(scene_path) self.controller = Controller(scene_path)
self.viewport = Viewport() self.viewport = Viewport()
self.parts = PartsPanel(self.controller) self.parts = PartsPanel(self.controller)
self.features_panel = FeaturePanel(self.controller)
self.numpad = NumpadPanel(self.controller, self.viewport) self.numpad = NumpadPanel(self.controller, self.viewport)
self.command = CommandBar(self.controller, self.pool) self.command = CommandBar(self.controller, self.pool)
tabs = QTabWidget()
tabs.addTab(self.parts, "Parts")
tabs.addTab(self.features_panel, "Joinery")
right = QWidget() right = QWidget()
rlayout = QVBoxLayout(right) rlayout = QVBoxLayout(right)
rlayout.setContentsMargins(0, 0, 0, 0) rlayout.setContentsMargins(0, 0, 0, 0)
rlayout.addWidget(self.parts, 1) rlayout.addWidget(tabs, 1)
rlayout.addWidget(self.numpad) rlayout.addWidget(self.numpad)
top = QSplitter(Qt.Horizontal) top = QSplitter(Qt.Horizontal)

View File

@ -84,10 +84,12 @@ def list_projects() -> list[str]:
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else [] return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
# Feature kinds and whether they ADD material (tenon) or CUT it (everything else). # Feature kinds: ADD fuses material (tenon), CUT subtracts a box/cylinder, EDGE
# operates on the board's edges (chamfer bevel).
ADD_KINDS = {"tenon"} ADD_KINDS = {"tenon"}
CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"} CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"}
FEATURE_KINDS = ADD_KINDS | CUT_KINDS EDGE_KINDS = {"chamfer"}
FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS
FACES = ("end_a", "end_b", "top", "bottom", "left", "right") FACES = ("end_a", "end_b", "top", "bottom", "left", "right")

View File

@ -31,6 +31,22 @@ def test_tenon_adds_volume():
assert part_solid(s.get_part("p1")).volume > base assert part_solid(s.get_part("p1")).volume > base
def test_chamfer_reduces_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "chamfer", face="end_b", width_in=0.5)
assert part_solid(s.get_part("p1")).volume < base
def test_oversized_chamfer_falls_back():
s = Scene()
s.place("2x4", 12)
# absurd chamfer size: clamped/caught, board stays valid (no crash)
s.add_feature("p1", "chamfer", face="end_b", width_in=99)
assert part_solid(s.get_part("p1")).volume > 0
def test_featured_part_tessellates(): def test_featured_part_tessellates():
s = Scene() s = Scene()
s.place("2x4", 12) s.place("2x4", 12)