From 9cbff4ec782566cd9038e5f3bb98fd731bd88a57 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 13:54:04 -0300 Subject: [PATCH] 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) --- CLAUDE.md | 18 ++--- src/woodshop/geometry.py | 34 +++++++++- src/woodshop/gui/controller.py | 65 ++++++++++++++++++ src/woodshop/gui/feature_panel.py | 106 ++++++++++++++++++++++++++++++ src/woodshop/gui/main_window.py | 10 ++- src/woodshop/scene.py | 6 +- tests/test_geometry.py | 16 +++++ 7 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 src/woodshop/gui/feature_panel.py diff --git a/CLAUDE.md b/CLAUDE.md index a2252c6..a880147 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** ~7–13s 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. diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index 039417f..6e870cd 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -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) diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 9f3407a..bacfb60 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -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): diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py new file mode 100644 index 0000000..9c93a77 --- /dev/null +++ b/src/woodshop/gui/feature_panel.py @@ -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("Joinery on the selected board")) + + 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) diff --git a/src/woodshop/gui/main_window.py b/src/woodshop/gui/main_window.py index 9b54043..2d4db5a 100644 --- a/src/woodshop/gui/main_window.py +++ b/src/woodshop/gui/main_window.py @@ -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) diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 19adbec..09797e5 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -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") diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 28bb1b2..c206394 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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)