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
than centered. The flush corner is fixed (A's +width/+thick side; no per-join
choice of which corner / centered).
2. **Joinery features** (`Feature` on each `Part`) are parametric booleans applied
in `geometry.part_solid`: `tenon` adds a protruding tongue (fuse); `mortise`,
`hole`, `slot`, `dado`, `rabbet` cut into a chosen face. The viewport
tessellates featured boards via build123d (plain boards stay fast pyvista
boxes). CLI: `feature/feature-edit/feature-delete/features`; voice:
`wood-feature`/`wood-feature-delete`. Not yet: **chamfers/bevels** (need edge
selection, not a box/cylinder cut), countersinks, and the **GUI feature panel**
(add/edit features by clicking — currently CLI/voice only).
2. **Joinery features** (`Feature` on each `Part`) are parametric ops applied in
`geometry.part_solid`: `tenon` fuses a protruding tongue; `mortise`/`hole`/
`slot`/`dado`/`rabbet` cut a box/cylinder into a face; `chamfer` bevels the
edges around a face via build123d `chamfer()` (handled specially —
`_apply_chamfer`, with a try/except fallback for over-sized bevels). The
viewport tessellates featured boards (plain boards stay fast pyvista boxes).
Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice
`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).
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
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")
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):
from build123d import Box, Pos, Rot
@ -75,7 +104,10 @@ def part_solid(part: Part):
thickness, width = part.section_in
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)
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._schemas: str | None = None
self.selected: list[str] = [self.scene.selection] if self.scene.selection else []
self.active_feature: str | None = None # feature currently being edited
# ----- persistence / notify ----------------------------------------
def save(self) -> None:
@ -189,6 +190,70 @@ class Controller(QObject):
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}.")
# ----- 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 --------------------------------------------
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=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.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox,
QSplitter, QVBoxLayout, QWidget)
QSplitter, QTabWidget, QVBoxLayout, QWidget)
from ..cutlist import board_feet
from ..scene import list_projects
from .command_bar import CommandBar
from .controller import Controller
from .feature_panel import FeaturePanel
from .numpad import NumpadPanel
from .panels import PartsPanel
from .viewport import Viewport
@ -26,13 +27,18 @@ class MainWindow(QMainWindow):
self.controller = Controller(scene_path)
self.viewport = Viewport()
self.parts = PartsPanel(self.controller)
self.features_panel = FeaturePanel(self.controller)
self.numpad = NumpadPanel(self.controller, self.viewport)
self.command = CommandBar(self.controller, self.pool)
tabs = QTabWidget()
tabs.addTab(self.parts, "Parts")
tabs.addTab(self.features_panel, "Joinery")
right = QWidget()
rlayout = QVBoxLayout(right)
rlayout.setContentsMargins(0, 0, 0, 0)
rlayout.addWidget(self.parts, 1)
rlayout.addWidget(tabs, 1)
rlayout.addWidget(self.numpad)
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 []
# 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"}
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")

View File

@ -31,6 +31,22 @@ def test_tenon_adds_volume():
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():
s = Scene()
s.place("2x4", 12)