Highlight the selected feature in the 3D scene (cyan)
Clicking a feature in the Joinery tab now highlights it in the viewport with a
cyan ghost so you can see which mortise/tenon it is; browsing candidates in the
"Fit to…" dialog highlights each one as you select it (restored to the active
feature on cancel). Reuses the overlay mechanism with a kind ("edit"=red pending
vs "highlight"=cyan); controller.highlight_feature drives it.
84 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
774ddd3480
commit
20327ee9d3
|
|
@ -81,7 +81,8 @@ 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
|
self.preview = None # (Part, Feature) shown as an overlay, or None
|
||||||
|
self.preview_kind = "edit" # "edit" (red pending) | "highlight" (cyan)
|
||||||
|
|
||||||
# ----- persistence / notify ----------------------------------------
|
# ----- persistence / notify ----------------------------------------
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
|
|
@ -328,6 +329,20 @@ class Controller(QObject):
|
||||||
if val is not None and hasattr(pending, k):
|
if val is not None and hasattr(pending, k):
|
||||||
setattr(pending, k, val)
|
setattr(pending, k, val)
|
||||||
self.preview = (part, pending)
|
self.preview = (part, pending)
|
||||||
|
self.preview_kind = "edit"
|
||||||
|
self.preview_changed.emit()
|
||||||
|
|
||||||
|
def highlight_feature(self, fid: str | None) -> None:
|
||||||
|
"""Show a cyan highlight of a feature in the scene (no edit)."""
|
||||||
|
if not fid:
|
||||||
|
self.preview = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
part, feat = self.scene.find_feature(fid)
|
||||||
|
self.preview = (part, copy.copy(feat))
|
||||||
|
self.preview_kind = "highlight"
|
||||||
|
except SceneError:
|
||||||
|
self.preview = None
|
||||||
self.preview_changed.emit()
|
self.preview_changed.emit()
|
||||||
|
|
||||||
def clear_preview(self) -> None:
|
def clear_preview(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -123,8 +123,8 @@ class FeaturePanel(QWidget):
|
||||||
self.fit_btn.setEnabled(bool(mate))
|
self.fit_btn.setEnabled(bool(mate))
|
||||||
self.fit_btn.setText(f"Fit to {mate}…" if mate else "Fit to mate…")
|
self.fit_btn.setText(f"Fit to {mate}…" if mate else "Fit to mate…")
|
||||||
self._loading = False
|
self._loading = False
|
||||||
if self.c.preview is not None: # committed state -> drop any stale ghost
|
# Highlight the selected feature in the scene (cyan), or clear if none.
|
||||||
self.c.clear_preview()
|
self.c.highlight_feature(feat.id if feat else None)
|
||||||
|
|
||||||
def _on_row(self) -> None:
|
def _on_row(self) -> None:
|
||||||
if self._loading:
|
if self._loading:
|
||||||
|
|
@ -160,6 +160,9 @@ class FeaturePanel(QWidget):
|
||||||
item = QListWidgetItem(label)
|
item = QListWidgetItem(label)
|
||||||
item.setData(Qt.UserRole, f.id)
|
item.setData(Qt.UserRole, f.id)
|
||||||
lst.addItem(item)
|
lst.addItem(item)
|
||||||
|
# Highlight whichever candidate is selected so the user sees it in 3D.
|
||||||
|
lst.currentItemChanged.connect(
|
||||||
|
lambda cur, _prev: self.c.highlight_feature(cur.data(Qt.UserRole)) if cur else None)
|
||||||
lst.setCurrentRow(0)
|
lst.setCurrentRow(0)
|
||||||
lay.addWidget(lst)
|
lay.addWidget(lst)
|
||||||
connect_cb = QCheckBox("Make connection (move && orient the other board to seat the joint)")
|
connect_cb = QCheckBox("Make connection (move && orient the other board to seat the joint)")
|
||||||
|
|
@ -168,11 +171,14 @@ class FeaturePanel(QWidget):
|
||||||
bb.accepted.connect(dlg.accept)
|
bb.accepted.connect(dlg.accept)
|
||||||
bb.rejected.connect(dlg.reject)
|
bb.rejected.connect(dlg.reject)
|
||||||
lay.addWidget(bb)
|
lay.addWidget(bb)
|
||||||
if dlg.exec() and lst.currentItem():
|
accepted = dlg.exec()
|
||||||
|
if accepted and lst.currentItem():
|
||||||
target = lst.currentItem().data(Qt.UserRole)
|
target = lst.currentItem().data(Qt.UserRole)
|
||||||
self.c.fit_feature(target) # size to match
|
self.c.fit_feature(target) # size to match
|
||||||
if connect_cb.isChecked():
|
if connect_cb.isChecked():
|
||||||
self.c.make_connection(target) # then assemble
|
self.c.make_connection(target) # then assemble
|
||||||
|
else:
|
||||||
|
self.c.highlight_feature(feat.id) # restore highlight on cancel
|
||||||
|
|
||||||
|
|
||||||
_HINTS = {
|
_HINTS = {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,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(
|
self.controller.preview_changed.connect(
|
||||||
lambda: self.viewport.set_preview(self.controller.preview))
|
lambda: self.viewport.set_preview(self.controller.preview,
|
||||||
|
self.controller.preview_kind))
|
||||||
self._build_menus()
|
self._build_menus()
|
||||||
self._on_changed() # initial render + status
|
self._on_changed() # initial render + status
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,9 @@ 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:
|
def set_preview(self, preview, kind="edit") -> None:
|
||||||
"""Draw (or clear) the translucent red ghost of a pending feature edit."""
|
"""Draw (or clear) a feature overlay: red for a pending edit, cyan to
|
||||||
|
highlight the selected feature."""
|
||||||
for name in ("preview_face", "preview_edges"):
|
for name in ("preview_face", "preview_edges"):
|
||||||
try:
|
try:
|
||||||
self.plotter.remove_actor(name)
|
self.plotter.remove_actor(name)
|
||||||
|
|
@ -104,13 +105,14 @@ class Viewport(QWidget):
|
||||||
pass
|
pass
|
||||||
if preview is not None:
|
if preview is not None:
|
||||||
part, feat = preview
|
part, feat = preview
|
||||||
|
color = "#33ccff" if kind == "highlight" else "red"
|
||||||
try:
|
try:
|
||||||
mesh = feature_preview_mesh(part, feat)
|
mesh = feature_preview_mesh(part, feat)
|
||||||
self.plotter.add_mesh(mesh, color="red", opacity=0.4, pickable=False,
|
self.plotter.add_mesh(mesh, color=color, opacity=0.4, pickable=False,
|
||||||
reset_camera=False, name="preview_face")
|
reset_camera=False, name="preview_face")
|
||||||
edges = mesh.extract_feature_edges()
|
edges = mesh.extract_feature_edges()
|
||||||
if edges.n_points:
|
if edges.n_points:
|
||||||
self.plotter.add_mesh(edges, color="red", line_width=3,
|
self.plotter.add_mesh(edges, color=color, line_width=3,
|
||||||
pickable=False, reset_camera=False, name="preview_edges")
|
pickable=False, reset_camera=False, name="preview_edges")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,17 @@ def test_fit_mortise_to_tenon(tmp_path):
|
||||||
assert m.depth_in == 1.5 + 1 / 32
|
assert m.depth_in == 1.5 + 1 / 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_highlight_feature(tmp_path):
|
||||||
|
c = _controller(tmp_path)
|
||||||
|
c.place("2x4", 12)
|
||||||
|
c.add_feature("mortise") # f1
|
||||||
|
c.highlight_feature("f1")
|
||||||
|
assert c.preview is not None and c.preview_kind == "highlight"
|
||||||
|
assert c.preview[1].id == "f1"
|
||||||
|
c.highlight_feature(None)
|
||||||
|
assert c.preview is None
|
||||||
|
|
||||||
|
|
||||||
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