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:
parent
a0072e6271
commit
9cbff4ec78
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -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** ~7–13s per utterance (one `claude -p` call).
|
2. **Latency** ~7–13s 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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue