Live red preview + Apply for feature editing
Adjusting a feature's fields was abstract and gave unclear feedback. Now: - Dragging any field (Face/Along/Across/Width/Height/Depth/Diameter) shows a live translucent RED ghost of the pending feature over the committed one — a cheap pyvista box/cylinder (viewer.feature_preview_mesh), no re-tessellation, so it updates instantly. - An Apply button commits the pending edit (controller.set_preview / apply_preview, preview_changed -> viewport.set_preview red overlay). - Per-kind hint text + per-field tooltips explain what each parameter does. 68 tests pass (preview-then-apply, preview-mesh builds). Verified by render: committed mortise + red ghost of a moved/resized pending edit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d0e40cdcbc
commit
70f8e9f0a2
|
|
@ -103,8 +103,13 @@ pytest # 25 tests
|
||||||
viewport tessellates featured boards (plain boards stay fast pyvista boxes).
|
viewport tessellates featured boards (plain boards stay fast pyvista boxes).
|
||||||
Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice
|
Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice
|
||||||
`wood-feature`/`wood-feature-delete`; **GUI Joinery tab** (`feature_panel.py`)
|
`wood-feature`/`wood-feature-delete`; **GUI Joinery tab** (`feature_panel.py`)
|
||||||
— add-with-default then edit fields; `controller.active_feature` is the one
|
— add-with-default, then dragging a field shows a **live red preview ghost**
|
||||||
being edited. Not yet: countersinks, and click-a-face-to-place in the GUI.
|
(`viewer.feature_preview_mesh` — a cheap box/cylinder, no re-tessellation) of
|
||||||
|
the pending change over the committed feature; **Apply** commits it
|
||||||
|
(`controller.set_preview`/`apply_preview`, `preview_changed` signal →
|
||||||
|
`viewport.set_preview`). Per-kind hints + field tooltips explain the
|
||||||
|
parameters. `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.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ mutation saves to disk (keeping the CLI/headless tools interoperable) and emits
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ TOOL_CMD = {
|
||||||
class Controller(QObject):
|
class Controller(QObject):
|
||||||
changed = Signal() # scene or selection changed -> refresh views
|
changed = Signal() # scene or selection changed -> refresh views
|
||||||
logged = Signal(str, str) # (who, text): who in {"you","ws","sys"}
|
logged = Signal(str, str) # (who, text): who in {"you","ws","sys"}
|
||||||
|
preview_changed = Signal() # pending feature preview changed -> redraw red ghost
|
||||||
|
|
||||||
def __init__(self, scene_path: str | None = None):
|
def __init__(self, scene_path: str | None = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -74,6 +76,7 @@ class Controller(QObject):
|
||||||
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
|
self.active_feature: str | None = None # feature currently being edited
|
||||||
|
self.preview = None # (Part, Feature) pending preview, or None
|
||||||
|
|
||||||
# ----- persistence / notify ----------------------------------------
|
# ----- persistence / notify ----------------------------------------
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
|
|
@ -254,6 +257,39 @@ class Controller(QObject):
|
||||||
except SceneError:
|
except SceneError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ----- live preview of a pending feature edit ----------------------
|
||||||
|
def set_preview(self, **fields) -> None:
|
||||||
|
"""Stash a pending edit (does NOT change the model) and redraw the ghost."""
|
||||||
|
feat = self.active_feature_obj()
|
||||||
|
if not feat:
|
||||||
|
self.preview = None
|
||||||
|
else:
|
||||||
|
part = self.scene.find_feature(feat.id)[0]
|
||||||
|
pending = copy.copy(feat)
|
||||||
|
for k, val in fields.items():
|
||||||
|
if val is not None and hasattr(pending, k):
|
||||||
|
setattr(pending, k, val)
|
||||||
|
self.preview = (part, pending)
|
||||||
|
self.preview_changed.emit()
|
||||||
|
|
||||||
|
def clear_preview(self) -> None:
|
||||||
|
self.preview = None
|
||||||
|
self.preview_changed.emit()
|
||||||
|
|
||||||
|
def apply_preview(self) -> None:
|
||||||
|
"""Commit the pending edit to the real feature (then re-render geometry)."""
|
||||||
|
if not self.preview:
|
||||||
|
return
|
||||||
|
_, pending = self.preview
|
||||||
|
self.preview = None
|
||||||
|
self.scene.edit_feature(
|
||||||
|
pending.id, face=pending.face, along_in=pending.along_in,
|
||||||
|
across_in=pending.across_in, width_in=pending.width_in,
|
||||||
|
height_in=pending.height_in, depth_in=pending.depth_in,
|
||||||
|
diameter_in=pending.diameter_in)
|
||||||
|
self._commit() # re-tessellates with the new geometry
|
||||||
|
self.preview_changed.emit() # clear the ghost
|
||||||
|
|
||||||
# ----- 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):
|
||||||
|
|
|
||||||
|
|
@ -34,25 +34,40 @@ class FeaturePanel(QWidget):
|
||||||
self.list.itemSelectionChanged.connect(self._on_row)
|
self.list.itemSelectionChanged.connect(self._on_row)
|
||||||
root.addWidget(self.list, 1)
|
root.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
self.hint = QLabel("")
|
||||||
|
self.hint.setWordWrap(True)
|
||||||
|
self.hint.setStyleSheet("color:#aaaaaa; font-size:11px;")
|
||||||
|
root.addWidget(self.hint)
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
self.face = QComboBox(); self.face.addItems(FACES)
|
self.face = QComboBox(); self.face.addItems(FACES)
|
||||||
self.face.currentIndexChanged.connect(self._apply)
|
self.face.currentIndexChanged.connect(self._preview)
|
||||||
|
# (key, label, tooltip)
|
||||||
|
self._fields = [
|
||||||
|
("along_in", "Along board", "Position along the board's length (or 1st offset on an end)"),
|
||||||
|
("across_in", "Across", "Offset from the centre of the face"),
|
||||||
|
("width_in", "Width", "Feature size across the face (1st dimension)"),
|
||||||
|
("height_in", "Height", "Feature size across the face (2nd dimension)"),
|
||||||
|
("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"),
|
||||||
|
("diameter_in", "Diameter", "Hole diameter (holes only)"),
|
||||||
|
]
|
||||||
self._spins = {}
|
self._spins = {}
|
||||||
for key, label, lo, hi in [
|
for key, label, tip in self._fields:
|
||||||
("along_in", "Along", -48, 96), ("across_in", "Across", -24, 24),
|
sp = QDoubleSpinBox(); sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in")
|
||||||
("width_in", "Width", 0, 24), ("height_in", "Height", 0, 24),
|
sp.setToolTip(tip)
|
||||||
("depth_in", "Depth", 0, 24), ("diameter_in", "Diameter", 0, 12)]:
|
sp.valueChanged.connect(self._preview) # live red ghost as you drag
|
||||||
sp = QDoubleSpinBox(); sp.setRange(lo, hi); sp.setSingleStep(0.25); sp.setSuffix(" in")
|
|
||||||
sp.editingFinished.connect(self._apply)
|
|
||||||
self._spins[key] = sp
|
self._spins[key] = sp
|
||||||
form.addRow("Face", self.face)
|
form.addRow(label, sp)
|
||||||
for key, sp in self._spins.items():
|
form.insertRow(0, "Face", self.face)
|
||||||
form.addRow(key.replace("_in", "").capitalize(), sp)
|
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
||||||
btns = QHBoxLayout()
|
btns = QHBoxLayout()
|
||||||
|
self.apply_btn = QPushButton("Apply")
|
||||||
|
self.apply_btn.setToolTip("Commit the previewed change (cuts/adds the real geometry)")
|
||||||
|
self.apply_btn.clicked.connect(self.c.apply_preview)
|
||||||
self.del_btn = QPushButton("Delete feature")
|
self.del_btn = QPushButton("Delete feature")
|
||||||
self.del_btn.clicked.connect(self.c.delete_active_feature)
|
self.del_btn.clicked.connect(self.c.delete_active_feature)
|
||||||
|
btns.addWidget(self.apply_btn)
|
||||||
btns.addWidget(self.del_btn)
|
btns.addWidget(self.del_btn)
|
||||||
root.addLayout(btns)
|
root.addLayout(btns)
|
||||||
|
|
||||||
|
|
@ -84,13 +99,18 @@ class FeaturePanel(QWidget):
|
||||||
editable = feat is not None
|
editable = feat is not None
|
||||||
self.face.setEnabled(editable)
|
self.face.setEnabled(editable)
|
||||||
self.del_btn.setEnabled(editable)
|
self.del_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)
|
self.face.setCurrentText(feat.face)
|
||||||
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.hint.setText(_HINTS.get(feat.kind, "") if feat else
|
||||||
|
"Add a feature above, then adjust it here.")
|
||||||
self._loading = False
|
self._loading = False
|
||||||
|
if self.c.preview is not None: # committed state -> drop any stale ghost
|
||||||
|
self.c.clear_preview()
|
||||||
|
|
||||||
def _on_row(self) -> None:
|
def _on_row(self) -> None:
|
||||||
if self._loading:
|
if self._loading:
|
||||||
|
|
@ -99,8 +119,23 @@ class FeaturePanel(QWidget):
|
||||||
if items:
|
if items:
|
||||||
self.c.select_feature(items[0].data(Qt.UserRole))
|
self.c.select_feature(items[0].data(Qt.UserRole))
|
||||||
|
|
||||||
def _apply(self) -> None:
|
def _preview(self) -> None:
|
||||||
|
"""Live: show a red ghost of the pending values (no commit until Apply)."""
|
||||||
if self._loading or not self.c.active_feature:
|
if self._loading or not self.c.active_feature:
|
||||||
return
|
return
|
||||||
dims = {key: sp.value() for key, sp in self._spins.items()}
|
dims = {key: sp.value() for key, sp in self._spins.items()}
|
||||||
self.c.edit_active_feature(face=self.face.currentText(), **dims)
|
self.c.set_preview(face=self.face.currentText(), **dims)
|
||||||
|
|
||||||
|
|
||||||
|
_HINTS = {
|
||||||
|
"tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; "
|
||||||
|
"Depth = how far it sticks out; Across = shift it off-centre.",
|
||||||
|
"mortise": "Mortise — a pocket. Along = position down the board; Width × Height = "
|
||||||
|
"the opening on the face; Depth = how deep it goes.",
|
||||||
|
"hole": "Hole — Along = position down the board; Across = off-centre; "
|
||||||
|
"Diameter = size; Depth 0 = drilled all the way through.",
|
||||||
|
"slot": "Slot — a channel. Along = position; Width × Height = the opening; "
|
||||||
|
"Depth = how deep.",
|
||||||
|
"chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size "
|
||||||
|
"(the red preview highlights the face).",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
self.viewport.picked.connect(self._on_pick)
|
self.viewport.picked.connect(self._on_pick)
|
||||||
self.controller.changed.connect(self._on_changed)
|
self.controller.changed.connect(self._on_changed)
|
||||||
|
self.controller.preview_changed.connect(
|
||||||
|
lambda: self.viewport.set_preview(self.controller.preview))
|
||||||
self._build_menus()
|
self._build_menus()
|
||||||
self._on_changed() # initial render + status
|
self._on_changed() # initial render + status
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton,
|
||||||
QVBoxLayout, QWidget)
|
QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from ..scene import Scene
|
from ..scene import Scene
|
||||||
from ..viewer import _PALETTE, _add_feature_edges, _part_mesh, _quiet_vtk
|
from ..viewer import (_PALETTE, _add_feature_edges, _part_mesh, _quiet_vtk,
|
||||||
|
feature_preview_mesh)
|
||||||
|
|
||||||
|
|
||||||
class Viewport(QWidget):
|
class Viewport(QWidget):
|
||||||
|
|
@ -94,6 +95,27 @@ class Viewport(QWidget):
|
||||||
self.plotter.camera_position = cam # keep the user's viewpoint
|
self.plotter.camera_position = cam # keep the user's viewpoint
|
||||||
self.plotter.render()
|
self.plotter.render()
|
||||||
|
|
||||||
|
def set_preview(self, preview) -> None:
|
||||||
|
"""Draw (or clear) the translucent red ghost of a pending feature edit."""
|
||||||
|
for name in ("preview_face", "preview_edges"):
|
||||||
|
try:
|
||||||
|
self.plotter.remove_actor(name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if preview is not None:
|
||||||
|
part, feat = preview
|
||||||
|
try:
|
||||||
|
mesh = feature_preview_mesh(part, feat)
|
||||||
|
self.plotter.add_mesh(mesh, color="red", opacity=0.4, pickable=False,
|
||||||
|
reset_camera=False, name="preview_face")
|
||||||
|
edges = mesh.extract_feature_edges()
|
||||||
|
if edges.n_points:
|
||||||
|
self.plotter.add_mesh(edges, color="red", line_width=3,
|
||||||
|
pickable=False, reset_camera=False, name="preview_edges")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.plotter.render()
|
||||||
|
|
||||||
def set_front(self): self.plotter.view_xz(); self.plotter.render()
|
def set_front(self): self.plotter.view_xz(); self.plotter.render()
|
||||||
def set_iso(self): self.plotter.view_isometric(); self.plotter.render()
|
def set_iso(self): self.plotter.view_isometric(); self.plotter.render()
|
||||||
def fit(self): self.plotter.reset_camera(); self.plotter.render()
|
def fit(self): self.plotter.reset_camera(); self.plotter.render()
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,55 @@ def _part_mesh(part: Part):
|
||||||
return cube
|
return cube
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_extent(axis, L, w, t):
|
||||||
|
if axis == (1, 0, 0):
|
||||||
|
return L
|
||||||
|
if axis in ((0, 1, 0), (0, -1, 0)):
|
||||||
|
return w
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def feature_preview_mesh(part, feat):
|
||||||
|
"""A cheap pyvista box/cylinder showing a feature's footprint (no build123d),
|
||||||
|
for the live red preview while adjusting fields."""
|
||||||
|
import pyvista as pv
|
||||||
|
|
||||||
|
from .geometry import _face_frame
|
||||||
|
L = part.length_in
|
||||||
|
t, w = part.section_in
|
||||||
|
o, n, u, v = _face_frame(feat.face, L, w, t)
|
||||||
|
off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0)
|
||||||
|
fp = tuple(o[i] + off_u * u[i] + feat.across_in * v[i] for i in range(3))
|
||||||
|
|
||||||
|
if feat.kind == "hole":
|
||||||
|
thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1
|
||||||
|
h = feat.depth_in if feat.depth_in > 0 else thru
|
||||||
|
c = tuple(fp[i] - n[i] * h / 2 for i in range(3))
|
||||||
|
mesh = pv.Cylinder(center=c, direction=n, radius=feat.diameter_in / 2, height=h)
|
||||||
|
elif feat.kind == "chamfer": # can't cheaply preview the bevel — highlight the face
|
||||||
|
ue, ve, thin = _axis_extent(u, L, w, t), _axis_extent(v, L, w, t), 0.08
|
||||||
|
dims = tuple(ue * abs(u[i]) + ve * abs(v[i]) + thin * abs(n[i]) for i in range(3))
|
||||||
|
c = fp
|
||||||
|
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
|
||||||
|
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
|
||||||
|
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
|
||||||
|
else: # tenon (out) / mortise/slot/dado/rabbet (in)
|
||||||
|
d = feat.depth_in
|
||||||
|
dims = tuple(feat.width_in * abs(u[i]) + feat.height_in * abs(v[i]) + d * abs(n[i])
|
||||||
|
for i in range(3))
|
||||||
|
sign = 1 if feat.kind == "tenon" else -1
|
||||||
|
c = tuple(fp[i] + sign * n[i] * d / 2 for i in range(3))
|
||||||
|
mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2,
|
||||||
|
c[1] - dims[1] / 2, c[1] + dims[1] / 2,
|
||||||
|
c[2] - dims[2] / 2, c[2] + dims[2] / 2))
|
||||||
|
|
||||||
|
mesh.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
|
||||||
|
mesh.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
|
||||||
|
mesh.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
|
||||||
|
mesh.translate(part.position_in, inplace=True)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
def _add_feature_edges(plotter, mesh, selected: bool) -> None:
|
def _add_feature_edges(plotter, mesh, selected: bool) -> None:
|
||||||
"""Overlay a tessellated solid's real edges (corners/holes/chamfers) so it
|
"""Overlay a tessellated solid's real edges (corners/holes/chamfers) so it
|
||||||
reads as crisply as a plain board, without the triangle-mesh noise."""
|
reads as crisply as a plain board, without the triangle-mesh noise."""
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,28 @@ def test_group_move_is_single_undo(tmp_path):
|
||||||
assert all(p.position_in[1] == 0 for p in c.scene.parts)
|
assert all(p.position_in[1] == 0 for p in c.scene.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_preview_then_apply(tmp_path):
|
||||||
|
c = _controller(tmp_path)
|
||||||
|
c.place("2x4", 12)
|
||||||
|
c.add_feature("mortise") # active feature with defaults
|
||||||
|
orig = c.active_feature_obj().depth_in
|
||||||
|
c.set_preview(depth_in=orig + 0.5) # preview only — model unchanged
|
||||||
|
assert c.preview is not None
|
||||||
|
assert c.active_feature_obj().depth_in == orig
|
||||||
|
c.apply_preview() # commit
|
||||||
|
assert c.preview is None
|
||||||
|
assert c.active_feature_obj().depth_in == orig + 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_preview_mesh_builds():
|
||||||
|
pytest.importorskip("pyvista")
|
||||||
|
from woodshop.scene import Scene
|
||||||
|
from woodshop.viewer import feature_preview_mesh
|
||||||
|
s = Scene(); s.place("2x4", 12)
|
||||||
|
feat = s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5)
|
||||||
|
assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_tool_is_safe(tmp_path):
|
def test_unknown_tool_is_safe(tmp_path):
|
||||||
c = _controller(tmp_path)
|
c = _controller(tmp_path)
|
||||||
assert "unknown" in c.execute_call("wood-bogus", {}).lower()
|
assert "unknown" in c.execute_call("wood-bogus", {}).lower()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue