Add multi-select + numberpad control panel

Multi-select:
- Ctrl+click in the 3D view (viewport.picked carries an additive flag) and
  Ctrl/Shift in the parts list (ExtendedSelection) build controller.selected.
- Group ops (move_selected/rotate_selected/stand/lay/sand/delete) apply to every
  selected board in ONE undo step via new scene.batch() context manager.
- Voice "move these 4 inches in +y" works: the selected ids are fed into the
  interpreter prompt, which expands to one call per selected board.

Numberpad panel (gui/numpad.py):
- Buttons laid out like a numpad: 4/6/8/2 move X/Y, +/- move Z, 7/9 yaw, 1/3
  tilt, 0 front, . iso, 5 fit. Configurable move-step and angle-step.
- The physical numpad keys do the same — MainWindow.keyPressEvent forwards
  KeypadModifier keys to the panel (unless typing in the command box).

Scene: batch() coalesces checkpoints so a group action is a single undo.
56 tests passing (added batch, toggle-multiselect, group-move-undo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 12:47:39 -03:00
parent 9d21816542
commit 417bf39d09
11 changed files with 293 additions and 62 deletions

View File

@ -53,11 +53,18 @@ operations + interpreter:
`execute_call`, which **reuses the CLI command functions** (no behavioral `execute_call`, which **reuses the CLI command functions** (no behavioral
drift). Every mutation saves to disk and emits `changed`. drift). Every mutation saves to disk and emits `changed`.
- `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select. - `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select.
- `panels.py` — parts list + selected-part inspector (editable length/yaw/tilt) - `panels.py` — parts list (ExtendedSelection: Ctrl/Shift multi-select) +
+ quick-action buttons. `command_bar.py` — text + push-to-talk + transcript, selected-part inspector (editable length/yaw/tilt) + quick-action buttons.
with slow work (LLM/dictate/TTS) on a `QThreadPool` (`workers.py`). `command_bar.py` — text + push-to-talk + transcript, with slow work
- One visible **selection** (`scene.selection`) is shared by 3D click, the (LLM/dictate/TTS) on a `QThreadPool` (`workers.py`).
list, and voice — so "delete that" is unambiguous. - `numpad.py` — a numberpad control panel (2/4/6/8 move, 1/3/7/9 rotate, +/
raise/lower, 0/. front/iso, 5 fit) that also responds to the **physical
numpad keys** (MainWindow.keyPressEvent forwards them when not typing).
- **Multi-selection**: `controller.selected` is a list driven by 3D Ctrl+click
(`viewport.picked` carries an additive flag) and list multi-select. Group ops
(`move_selected`/`rotate_selected`/stand/lay/sand/delete) apply to all selected
in one undo step via `scene.batch()`. Voice "move these" works because the
selected ids are fed into the interpreter prompt.
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`. Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
Named projects: `~/.local/share/woodshop/projects/<slug>.json`. Named projects: `~/.local/share/woodshop/projects/<slug>.json`.

View File

@ -53,13 +53,16 @@ python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
woodshop # launches the unified desktop app woodshop # launches the unified desktop app
``` ```
One window with the **3D viewport** (click a board to select it), a **parts One window with the **3D viewport** (click a board to select it; Ctrl+click to
panel** (list + selected-part inspector + quick-action buttons), and a select several), a **parts panel** (list + selected-part inspector +
**command bar** at the bottom where you type or push-to-talk (🎤). Mouse, quick-action buttons), a **numberpad control panel** (move/rotate the selection
keyboard, and voice all drive the same scene and the same visible selection, so by clicking or with your keyboard's numpad — 2/4/6/8 move, 1/3/7/9 rotate, +/
"delete that" / the Delete button / saying "delete the front-left leg" are raise/lower, 0/. front/iso, 5 fit), and a **command bar** where you type or
interchangeable. Menus cover New/Open/Save projects, Export STL/STEP, Save push-to-talk (🎤). Mouse, keyboard, and voice all drive the same scene and the
Image, Undo/Redo, camera views, and Build templates. same visible selection — so "move these 4 inches", the numpad 8 key, and the
move button are interchangeable, and act on every selected board at once (one
undo). Menus cover New/Open/Save projects, Export STL/STEP, Save Image,
Undo/Redo, camera views, and Build templates.
### Standalone tools (headless / scripting) ### Standalone tools (headless / scripting)

View File

@ -59,6 +59,8 @@ Rules:
- Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name. - Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name.
- For a board you place earlier in THIS response, refer to it later as $1, $2, ... - For a board you place earlier in THIS response, refer to it later as $1, $2, ...
numbered by the order you place boards in this response (the first wood-place is $1). numbered by the order you place boards in this response (the first wood-place is $1).
- "these" / "them" / "the selected ones" refer to the currently-selected boards
listed under the scene; emit one call per selected board (e.g. wood-move for each).
- Legs and uprights must be stood up: place the board, then wood-stand it. - Legs and uprights must be stood up: place the board, then wood-stand it.
- For wood-join, "part_b" is the board being attached (it gets moved into place); - For wood-join, "part_b" is the board being attached (it gets moved into place);
"to" is the board it attaches to. Anchor is "end" (far end) or "start". "to" is the board it attaches to. Anchor is "end" (far end) or "start".

View File

@ -67,6 +67,7 @@ class Controller(QObject):
self.scene_path = Path(scene_path) if scene_path else default_scene_path() self.scene_path = Path(scene_path) if scene_path else default_scene_path()
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 []
# ----- persistence / notify ---------------------------------------- # ----- persistence / notify ----------------------------------------
def save(self) -> None: def save(self) -> None:
@ -78,55 +79,111 @@ class Controller(QObject):
self.logged.emit("ws", message) self.logged.emit("ws", message)
self.changed.emit() self.changed.emit()
# ----- selection --------------------------------------------------- # ----- selection (single + multi) ----------------------------------
def select(self, ref: str | None) -> None: def _valid(self, ids):
if ref: have = {p.id for p in self.scene.parts}
try: return [i for i in ids if i in have]
self.scene.select(ref)
except SceneError: def set_selected(self, ids) -> None:
return """Replace the whole selection set. Primary = last in the list."""
else: self.selected = self._valid(list(dict.fromkeys(ids)))
self.scene.selection = None self.scene.selection = self.selected[-1] if self.selected else None
self.changed.emit() self.changed.emit()
def select(self, ref: str | None) -> None:
"""Single-select by id/name (e.g. from a 3D click without Ctrl)."""
if not ref:
self.set_selected([])
return
try:
part = self.scene.get_part(ref)
except SceneError:
return
self.set_selected([part.id])
def toggle(self, ref: str | None) -> None:
"""Ctrl+click: add/remove a board from the selection."""
if not ref:
return
try:
pid = self.scene.get_part(ref).id
except SceneError:
return
ids = list(self.selected)
ids.remove(pid) if pid in ids else ids.append(pid)
self.set_selected(ids)
def target_ids(self) -> list[str]:
"""The boards an action applies to: the multi-selection, else the primary."""
if self.selected:
return list(self.selected)
return [self.scene.selection] if self.scene.selection else []
@property @property
def selected_id(self) -> str | None: def selected_id(self) -> str | None:
return self.scene.selection return self.scene.selection
# ----- direct operations (buttons / menus) ------------------------- # ----- direct operations (buttons / menus) -------------------------
def _do(self, fn, *args, **kwargs) -> None: def _do(self, fn) -> None:
try: try:
msg = fn(*args, **kwargs) msg = fn()
except (SceneError, ValueError, KeyError) as exc: except (SceneError, ValueError, KeyError) as exc:
self.logged.emit("sys", str(exc).strip('"')) self.logged.emit("sys", str(exc).strip('"'))
return return
# A single op selects its result (e.g. placing a board selects it).
self.selected = [self.scene.selection] if self.scene.selection else []
self._commit(msg if isinstance(msg, str) else None) self._commit(msg if isinstance(msg, str) else None)
def _do_group(self, op, verb: str) -> None:
"""Apply `op(part_id)` to every selected board as a single undo step."""
ids = self.target_ids()
if not ids:
self.logged.emit("sys", "Nothing selected.")
return
try:
with self.scene.batch():
for pid in ids:
op(pid)
except (SceneError, ValueError, KeyError) as exc:
self.logged.emit("sys", str(exc).strip('"'))
return
self.selected = self._valid(self.selected) # drop any deleted ids
if self.scene.selection not in {p.id for p in self.scene.parts}:
self.scene.selection = self.selected[-1] if self.selected else None
n = len(ids)
self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.")
def place(self, stock: str, length_in: float): def place(self, stock: str, length_in: float):
self._do(lambda: f"Placed {self.scene.place(stock, length_in).id}.") self._do(lambda: f"Placed {self.scene.place(stock, length_in).id}.")
def stand(self, ref=None): self._do(lambda: f"Set {self.scene.stand(ref).id} standing up.") # group-aware (act on the whole selection)
def lay(self, ref=None): self._do(lambda: f"Laid {self.scene.stand(ref, 0.0).id} flat.") def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
def sand(self, ref=None): self._do(lambda: f"Sanded {self.scene.finish(ref).id}.") def lay(self): self._do_group(lambda pid: self.scene.stand(pid, 0.0), "Laid flat")
def delete(self, ref=None): self._do(lambda: self.scene.delete(ref)) def sand(self): self._do_group(lambda pid: self.scene.finish(pid), "Sanded")
def delete(self): self._do_group(lambda pid: self.scene.delete(pid), "Deleted")
def move_selected(self, dx=0.0, dy=0.0, dz=0.0):
self._do_group(lambda pid: self.scene.move(pid, dx, dy, dz), "Moved")
def rotate_selected(self, dyaw=0.0, dtilt=0.0):
def op(pid):
p = self.scene.get_part(pid)
self.scene.orient(pid, yaw=p.yaw_deg + dyaw, tilt=p.tilt_deg + dtilt)
self._do_group(op, "Rotated")
def rotate_90(self): self.rotate_selected(dyaw=90)
# single-part (act on the primary selection)
def undo(self): self._do(self.scene.undo) def undo(self): self._do(self.scene.undo)
def redo(self): self._do(self.scene.redo) def redo(self): self._do(self.scene.redo)
def clear(self): self._do(self.scene.clear) def clear(self): self._do(self.scene.clear)
def duplicate(self, ref=None): self._do(lambda: f"Copied to {self.scene.copy(ref).id}.") def duplicate(self): self._do(lambda: f"Copied to {self.scene.copy(self.scene.selection).id}.")
def rename(self, ref, name): self._do(lambda: f"Named {self.scene.rename(ref, name).id}.") def rename(self, ref, name): self._do(lambda: f"Named {self.scene.rename(ref, name).id}.")
def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.")
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}.")
def rotate_90(self, ref=None):
part = self.scene.resolve(ref) if ref or self.scene.selection else None
if part:
self.rotate(part.id, yaw=(part.yaw_deg + 90) % 360)
def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.")
def move(self, ref=None, dx=0.0, dy=0.0, dz=0.0):
self._do(lambda: f"Moved {self.scene.move(ref, dx, dy, dz).id}.")
# ----- 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):
@ -163,7 +220,9 @@ class Controller(QObject):
"""Interpret a spoken/typed command and apply it. Returns a spoken summary. """Interpret a spoken/typed command and apply it. Returns a spoken summary.
(Slow call from a worker thread.)""" (Slow call from a worker thread.)"""
self.save() # ensure disk reflects current state self.save() # ensure disk reflects current state
scene_text = cli.cmd_status(self.scene, None) sel = ", ".join(self.selected) if self.selected else "none"
scene_text = (cli.cmd_status(self.scene, None)
+ f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}")
calls = driver.interpret(text, self.schemas(), scene_text=scene_text) calls = driver.interpret(text, self.schemas(), scene_text=scene_text)
messages = driver.dispatch(calls, verbose=False, executor=self.execute_call) messages = driver.dispatch(calls, verbose=False, executor=self.execute_call)
self._commit() self._commit()

View File

@ -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) QSplitter, 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 .numpad import NumpadPanel
from .panels import PartsPanel from .panels import PartsPanel
from .viewport import Viewport from .viewport import Viewport
@ -25,11 +26,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.numpad = NumpadPanel(self.controller, self.viewport)
self.command = CommandBar(self.controller, self.pool) self.command = CommandBar(self.controller, self.pool)
right = QWidget()
rlayout = QVBoxLayout(right)
rlayout.setContentsMargins(0, 0, 0, 0)
rlayout.addWidget(self.parts, 1)
rlayout.addWidget(self.numpad)
top = QSplitter(Qt.Horizontal) top = QSplitter(Qt.Horizontal)
top.addWidget(self.viewport) top.addWidget(self.viewport)
top.addWidget(self.parts) top.addWidget(right)
top.setStretchFactor(0, 3) top.setStretchFactor(0, 3)
top.setStretchFactor(1, 1) top.setStretchFactor(1, 1)
@ -40,11 +48,23 @@ class MainWindow(QMainWindow):
split.setStretchFactor(1, 1) split.setStretchFactor(1, 1)
self.setCentralWidget(split) self.setCentralWidget(split)
self.viewport.picked.connect(self.controller.select) self.viewport.picked.connect(self._on_pick)
self.controller.changed.connect(self._on_changed) self.controller.changed.connect(self._on_changed)
self._build_menus() self._build_menus()
self._on_changed() # initial render + status self._on_changed() # initial render + status
def _on_pick(self, pid: str, additive: bool):
self.controller.toggle(pid) if additive else self.controller.select(pid)
def keyPressEvent(self, event):
# Physical numpad drives the move/rotate panel — but only when the user
# isn't typing in the command box.
if (event.modifiers() & Qt.KeypadModifier) and not self.command.input.hasFocus():
if self.numpad.trigger(event.key()):
event.accept()
return
super().keyPressEvent(event)
# ----- menus ------------------------------------------------------- # ----- menus -------------------------------------------------------
def _act(self, menu, text, slot, shortcut=None): def _act(self, menu, text, slot, shortcut=None):
a = QAction(text, self) a = QAction(text, self)
@ -95,12 +115,14 @@ class MainWindow(QMainWindow):
# ----- slots ------------------------------------------------------- # ----- slots -------------------------------------------------------
def _on_changed(self): def _on_changed(self):
self.viewport.render_scene(self.controller.scene)
scene = self.controller.scene scene = self.controller.scene
self.viewport.render_scene(scene, self.controller.selected)
bf = sum(board_feet(p.stock, p.length_in) for p in scene.parts) bf = sum(board_feet(p.stock, p.length_in) for p in scene.parts)
sel = self.controller.selected
sel_txt = (f"{len(sel)} selected" if len(sel) > 1
else (sel[0] if sel else "none"))
self.statusBar().showMessage( self.statusBar().showMessage(
f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · " f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · selection: {sel_txt}")
f"selection: {scene.selection or 'none'}")
def _camera(self, fn): def _camera(self, fn):
fn() fn()

View File

@ -0,0 +1,83 @@
"""Numberpad control panel: move/rotate the selection without talking, by
clicking buttons laid out like a numpad or by pressing the physical numpad
keys (MainWindow forwards them via `action_for`).
Layout (mirrors a keyboard numpad):
7 yaw 8 +Y 9 yaw
4 X 5 Fit 6 +X
1 tilt 2 Y 3 tilt
0 Front . Iso +Z / Z
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QDoubleSpinBox, QGridLayout, QGroupBox, QHBoxLayout,
QLabel, QPushButton, QVBoxLayout)
from .controller import Controller
class NumpadPanel(QGroupBox):
def __init__(self, controller: Controller, viewport, parent=None):
super().__init__("Move / Rotate", parent)
self.c = controller
self.vp = viewport
root = QVBoxLayout(self)
steps = QHBoxLayout()
self.step = QDoubleSpinBox(); self.step.setRange(0.05, 48); self.step.setValue(1.0)
self.step.setSuffix(" in"); self.step.setSingleStep(0.5)
self.angle = QDoubleSpinBox(); self.angle.setRange(1, 180); self.angle.setValue(15)
self.angle.setSuffix(" °")
steps.addWidget(QLabel("Step")); steps.addWidget(self.step)
steps.addWidget(QLabel("Angle")); steps.addWidget(self.angle)
root.addLayout(steps)
# key -> (button label, callable). Buttons and physical keys share this.
self._actions = {
Qt.Key_7: ("7\n⟲ yaw", lambda: self.c.rotate_selected(dyaw=-self._a())),
Qt.Key_8: ("8\n+Y ↑", lambda: self.c.move_selected(dy=self._s())),
Qt.Key_9: ("9\n⟳ yaw", lambda: self.c.rotate_selected(dyaw=self._a())),
Qt.Key_4: ("4\nX ←", lambda: self.c.move_selected(dx=-self._s())),
Qt.Key_5: ("5\nFit", self.vp.fit),
Qt.Key_6: ("6\n+X →", lambda: self.c.move_selected(dx=self._s())),
Qt.Key_1: ("1\n⤓ tilt", lambda: self.c.rotate_selected(dtilt=-self._a())),
Qt.Key_2: ("2\nY ↓", lambda: self.c.move_selected(dy=-self._s())),
Qt.Key_3: ("3\n⤒ tilt", lambda: self.c.rotate_selected(dtilt=self._a())),
Qt.Key_0: ("0\nFront", self.vp.set_front),
Qt.Key_Period: (".\nIso", self.vp.set_iso),
Qt.Key_Plus: ("+\nZ ↑", lambda: self.c.move_selected(dz=self._s())),
Qt.Key_Minus: ("\nZ ↓", lambda: self.c.move_selected(dz=-self._s())),
}
grid = QGridLayout()
positions = {
Qt.Key_7: (0, 0), Qt.Key_8: (0, 1), Qt.Key_9: (0, 2),
Qt.Key_4: (1, 0), Qt.Key_5: (1, 1), Qt.Key_6: (1, 2),
Qt.Key_1: (2, 0), Qt.Key_2: (2, 1), Qt.Key_3: (2, 2),
Qt.Key_0: (3, 0), Qt.Key_Period: (3, 1),
Qt.Key_Plus: (4, 0), Qt.Key_Minus: (4, 1),
}
for key, (label, _) in self._actions.items():
b = QPushButton(label)
b.setMinimumHeight(36)
b.clicked.connect(lambda _=False, k=key: self.trigger(k))
r, col = positions[key]
grid.addWidget(b, r, col)
root.addLayout(grid)
root.addWidget(QLabel("<i>Tip: use the keyboard numpad too.</i>"))
def _s(self) -> float:
return self.step.value()
def _a(self) -> float:
return self.angle.value()
def trigger(self, key) -> bool:
"""Run the action bound to a numpad key. Returns True if handled."""
entry = self._actions.get(key)
if entry:
entry[1]()
return True
return False

View File

@ -4,9 +4,10 @@ that solves "delete that" ambiguity)."""
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QDoubleSpinBox, QFormLayout, QGridLayout, QGroupBox, from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout,
QHBoxLayout, QInputDialog, QLabel, QListWidget, QGridLayout, QGroupBox, QInputDialog, QLabel,
QListWidgetItem, QPushButton, QVBoxLayout, QWidget) QListWidget, QListWidgetItem, QPushButton,
QVBoxLayout, QWidget)
from .controller import Controller from .controller import Controller
@ -21,6 +22,7 @@ class PartsPanel(QWidget):
root.addWidget(QLabel("<b>Parts</b>")) root.addWidget(QLabel("<b>Parts</b>"))
self.list = QListWidget() self.list = QListWidget()
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
self.list.itemSelectionChanged.connect(self._on_row_selected) self.list.itemSelectionChanged.connect(self._on_row_selected)
root.addWidget(self.list, 1) root.addWidget(self.list, 1)
@ -65,16 +67,14 @@ class PartsPanel(QWidget):
def refresh(self) -> None: def refresh(self) -> None:
self._loading = True self._loading = True
self.list.clear() self.list.clear()
sel_row = -1 selected = set(self.c.selected)
for row, p in enumerate(self.c.scene.parts): for p in self.c.scene.parts:
name = f" · {p.name}" if p.name else "" name = f" · {p.name}" if p.name else ""
item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}") item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}")
item.setData(Qt.UserRole, p.id) item.setData(Qt.UserRole, p.id)
self.list.addItem(item) self.list.addItem(item)
if p.id == self.c.selected_id: if p.id in selected:
sel_row = row item.setSelected(True)
if sel_row >= 0:
self.list.setCurrentRow(sel_row)
part = self._selected_part() part = self._selected_part()
if part: if part:
@ -99,9 +99,8 @@ class PartsPanel(QWidget):
def _on_row_selected(self) -> None: def _on_row_selected(self) -> None:
if self._loading: if self._loading:
return return
items = self.list.selectedItems() ids = [it.data(Qt.UserRole) for it in self.list.selectedItems()]
if items: self.c.set_selected(ids)
self.c.select(items[0].data(Qt.UserRole))
def _rename(self) -> None: def _rename(self) -> None:
part = self._selected_part() part = self._selected_part()

View File

@ -2,15 +2,16 @@
selection, and emits a signal when a board is clicked.""" selection, and emits a signal when a board is clicked."""
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton,
QVBoxLayout, QWidget)
from ..scene import Scene from ..scene import Scene
from ..viewer import _PALETTE, _part_mesh, _quiet_vtk from ..viewer import _PALETTE, _part_mesh, _quiet_vtk
class Viewport(QWidget): class Viewport(QWidget):
picked = Signal(str) # part id clicked in the 3D view picked = Signal(str, bool) # (part id, additive?) — additive when Ctrl held
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@ -51,16 +52,18 @@ class Viewport(QWidget):
def _on_pick(self, actor) -> None: def _on_pick(self, actor) -> None:
pid = self._actor_to_pid.get(actor) pid = self._actor_to_pid.get(actor)
if pid: if pid:
self.picked.emit(pid) additive = bool(QApplication.keyboardModifiers() & Qt.ControlModifier)
self.picked.emit(pid, additive)
def render_scene(self, scene: Scene) -> None: def render_scene(self, scene: Scene, selected_ids=None) -> None:
selected_ids = set(selected_ids or ([scene.selection] if scene.selection else []))
cam = None if self._first else self.plotter.camera_position cam = None if self._first else self.plotter.camera_position
self.plotter.clear() self.plotter.clear()
self._actor_to_pid.clear() self._actor_to_pid.clear()
labels, pts = [], [] labels, pts = [], []
for i, part in enumerate(scene.parts): for i, part in enumerate(scene.parts):
selected = part.id == scene.selection selected = part.id in selected_ids
actor = self.plotter.add_mesh( actor = self.plotter.add_mesh(
_part_mesh(part), _part_mesh(part),
color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)], color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)],
@ -88,6 +91,10 @@ 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_front(self): self.plotter.view_xz(); 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 close_viewport(self) -> None: def close_viewport(self) -> None:
try: try:
self.plotter.close() self.plotter.close()

View File

@ -19,6 +19,7 @@ import copy
import json import json
import math import math
import os import os
from contextlib import contextmanager
from dataclasses import dataclass, field, fields, asdict from dataclasses import dataclass, field, fields, asdict
from pathlib import Path from pathlib import Path
@ -172,10 +173,23 @@ class Scene:
# ----- undo / redo -------------------------------------------------- # ----- undo / redo --------------------------------------------------
def _checkpoint(self) -> None: def _checkpoint(self) -> None:
if getattr(self, "_suppress", False): # inside a batch — one undo covers it
return
self._undo.append(json.dumps(self._raw(), sort_keys=True)) self._undo.append(json.dumps(self._raw(), sort_keys=True))
del self._undo[:-50] # keep the last 50 steps del self._undo[:-50] # keep the last 50 steps
self._redo.clear() # a new action invalidates the redo history self._redo.clear() # a new action invalidates the redo history
@contextmanager
def batch(self):
"""Group several operations into a single undo step (e.g. moving a
multi-selection)."""
self._checkpoint()
self._suppress = True
try:
yield
finally:
self._suppress = False
def _restore(self, snapshot: dict) -> None: def _restore(self, snapshot: dict) -> None:
restored = Scene.from_dict(snapshot) restored = Scene.from_dict(snapshot)
restored._undo = self._undo restored._undo = self._undo

View File

@ -55,6 +55,28 @@ def test_select_and_undo_redo(tmp_path):
assert len(c.scene.parts) == 2 assert len(c.scene.parts) == 2
def test_toggle_multiselect(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24)
c.place("2x4", 24)
c.select("p1")
c.toggle("p2")
assert set(c.selected) == {"p1", "p2"}
c.toggle("p1") # ctrl-click again removes it
assert c.selected == ["p2"]
def test_group_move_is_single_undo(tmp_path):
c = _controller(tmp_path)
for _ in range(3):
c.place("2x4", 24)
c.set_selected(["p1", "p2", "p3"])
c.move_selected(dy=4) # "move these 4 inches in +y"
assert all(p.position_in[1] == 4 for p in c.scene.parts)
c.undo() # one undo reverts the whole group
assert all(p.position_in[1] == 0 for p in c.scene.parts)
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()

View File

@ -179,6 +179,19 @@ def test_new_action_clears_redo():
s.redo() s.redo()
def test_batch_is_one_undo():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 24)
with s.batch():
s.move("p1", dx=5)
s.move("p2", dx=5)
assert s.get_part("p1").position_in[0] == 5
s.undo() # single undo reverts both moves
assert s.get_part("p1").position_in[0] == 0
assert s.get_part("p2").position_in[0] == 0
def test_clear(): def test_clear():
s = Scene() s = Scene()
s.place("2x4", 24) s.place("2x4", 24)