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:
parent
9d21816542
commit
417bf39d09
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -53,11 +53,18 @@ operations + interpreter:
|
|||
`execute_call`, which **reuses the CLI command functions** (no behavioral
|
||||
drift). Every mutation saves to disk and emits `changed`.
|
||||
- `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select.
|
||||
- `panels.py` — parts list + selected-part inspector (editable length/yaw/tilt)
|
||||
+ quick-action buttons. `command_bar.py` — text + push-to-talk + transcript,
|
||||
with slow work (LLM/dictate/TTS) on a `QThreadPool` (`workers.py`).
|
||||
- One visible **selection** (`scene.selection`) is shared by 3D click, the
|
||||
list, and voice — so "delete that" is unambiguous.
|
||||
- `panels.py` — parts list (ExtendedSelection: Ctrl/Shift multi-select) +
|
||||
selected-part inspector (editable length/yaw/tilt) + quick-action buttons.
|
||||
`command_bar.py` — text + push-to-talk + transcript, with slow work
|
||||
(LLM/dictate/TTS) on a `QThreadPool` (`workers.py`).
|
||||
- `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`.
|
||||
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -53,13 +53,16 @@ python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
|
|||
woodshop # launches the unified desktop app
|
||||
```
|
||||
|
||||
One window with the **3D viewport** (click a board to select it), a **parts
|
||||
panel** (list + selected-part inspector + quick-action buttons), and a
|
||||
**command bar** at the bottom where you type or push-to-talk (🎤). Mouse,
|
||||
keyboard, and voice all drive the same scene and the same visible selection, so
|
||||
"delete that" / the Delete button / saying "delete the front-left leg" are
|
||||
interchangeable. Menus cover New/Open/Save projects, Export STL/STEP, Save
|
||||
Image, Undo/Redo, camera views, and Build templates.
|
||||
One window with the **3D viewport** (click a board to select it; Ctrl+click to
|
||||
select several), a **parts panel** (list + selected-part inspector +
|
||||
quick-action buttons), a **numberpad control panel** (move/rotate the selection
|
||||
by clicking or with your keyboard's numpad — 2/4/6/8 move, 1/3/7/9 rotate, +/−
|
||||
raise/lower, 0/. front/iso, 5 fit), and a **command bar** where you type or
|
||||
push-to-talk (🎤). Mouse, keyboard, and voice all drive the same scene and the
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ Rules:
|
|||
- 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, ...
|
||||
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.
|
||||
- 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".
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class Controller(QObject):
|
|||
self.scene_path = Path(scene_path) if scene_path else default_scene_path()
|
||||
self.scene = Scene.load(self.scene_path)
|
||||
self._schemas: str | None = None
|
||||
self.selected: list[str] = [self.scene.selection] if self.scene.selection else []
|
||||
|
||||
# ----- persistence / notify ----------------------------------------
|
||||
def save(self) -> None:
|
||||
|
|
@ -78,55 +79,111 @@ class Controller(QObject):
|
|||
self.logged.emit("ws", message)
|
||||
self.changed.emit()
|
||||
|
||||
# ----- selection ---------------------------------------------------
|
||||
def select(self, ref: str | None) -> None:
|
||||
if ref:
|
||||
try:
|
||||
self.scene.select(ref)
|
||||
except SceneError:
|
||||
return
|
||||
else:
|
||||
self.scene.selection = None
|
||||
# ----- selection (single + multi) ----------------------------------
|
||||
def _valid(self, ids):
|
||||
have = {p.id for p in self.scene.parts}
|
||||
return [i for i in ids if i in have]
|
||||
|
||||
def set_selected(self, ids) -> None:
|
||||
"""Replace the whole selection set. Primary = last in the list."""
|
||||
self.selected = self._valid(list(dict.fromkeys(ids)))
|
||||
self.scene.selection = self.selected[-1] if self.selected else None
|
||||
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
|
||||
def selected_id(self) -> str | None:
|
||||
return self.scene.selection
|
||||
|
||||
# ----- direct operations (buttons / menus) -------------------------
|
||||
def _do(self, fn, *args, **kwargs) -> None:
|
||||
def _do(self, fn) -> None:
|
||||
try:
|
||||
msg = fn(*args, **kwargs)
|
||||
msg = fn()
|
||||
except (SceneError, ValueError, KeyError) as exc:
|
||||
self.logged.emit("sys", str(exc).strip('"'))
|
||||
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)
|
||||
|
||||
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):
|
||||
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.")
|
||||
def lay(self, ref=None): self._do(lambda: f"Laid {self.scene.stand(ref, 0.0).id} flat.")
|
||||
def sand(self, ref=None): self._do(lambda: f"Sanded {self.scene.finish(ref).id}.")
|
||||
def delete(self, ref=None): self._do(lambda: self.scene.delete(ref))
|
||||
# group-aware (act on the whole selection)
|
||||
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
|
||||
def lay(self): self._do_group(lambda pid: self.scene.stand(pid, 0.0), "Laid flat")
|
||||
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 redo(self): self._do(self.scene.redo)
|
||||
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 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):
|
||||
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 --------------------------------------------
|
||||
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=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.
|
||||
(Slow — call from a worker thread.)"""
|
||||
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)
|
||||
messages = driver.dispatch(calls, verbose=False, executor=self.execute_call)
|
||||
self._commit()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
QSplitter, QVBoxLayout, QWidget)
|
||||
|
||||
from ..cutlist import board_feet
|
||||
from ..scene import list_projects
|
||||
from .command_bar import CommandBar
|
||||
from .controller import Controller
|
||||
from .numpad import NumpadPanel
|
||||
from .panels import PartsPanel
|
||||
from .viewport import Viewport
|
||||
|
||||
|
|
@ -25,11 +26,18 @@ class MainWindow(QMainWindow):
|
|||
self.controller = Controller(scene_path)
|
||||
self.viewport = Viewport()
|
||||
self.parts = PartsPanel(self.controller)
|
||||
self.numpad = NumpadPanel(self.controller, self.viewport)
|
||||
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.addWidget(self.viewport)
|
||||
top.addWidget(self.parts)
|
||||
top.addWidget(right)
|
||||
top.setStretchFactor(0, 3)
|
||||
top.setStretchFactor(1, 1)
|
||||
|
||||
|
|
@ -40,11 +48,23 @@ class MainWindow(QMainWindow):
|
|||
split.setStretchFactor(1, 1)
|
||||
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._build_menus()
|
||||
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 -------------------------------------------------------
|
||||
def _act(self, menu, text, slot, shortcut=None):
|
||||
a = QAction(text, self)
|
||||
|
|
@ -95,12 +115,14 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# ----- slots -------------------------------------------------------
|
||||
def _on_changed(self):
|
||||
self.viewport.render_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)
|
||||
sel = self.controller.selected
|
||||
sel_txt = (f"{len(sel)} selected" if len(sel) > 1
|
||||
else (sel[0] if sel else "none"))
|
||||
self.statusBar().showMessage(
|
||||
f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · "
|
||||
f"selection: {scene.selection or 'none'}")
|
||||
f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · selection: {sel_txt}")
|
||||
|
||||
def _camera(self, fn):
|
||||
fn()
|
||||
|
|
|
|||
|
|
@ -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\n−X ←", 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\n−Y ↓", 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
|
||||
|
|
@ -4,9 +4,10 @@ that solves "delete that" ambiguity)."""
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (QDoubleSpinBox, QFormLayout, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QInputDialog, QLabel, QListWidget,
|
||||
QListWidgetItem, QPushButton, QVBoxLayout, QWidget)
|
||||
from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout,
|
||||
QGridLayout, QGroupBox, QInputDialog, QLabel,
|
||||
QListWidget, QListWidgetItem, QPushButton,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from .controller import Controller
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ class PartsPanel(QWidget):
|
|||
root.addWidget(QLabel("<b>Parts</b>"))
|
||||
|
||||
self.list = QListWidget()
|
||||
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
|
||||
self.list.itemSelectionChanged.connect(self._on_row_selected)
|
||||
root.addWidget(self.list, 1)
|
||||
|
||||
|
|
@ -65,16 +67,14 @@ class PartsPanel(QWidget):
|
|||
def refresh(self) -> None:
|
||||
self._loading = True
|
||||
self.list.clear()
|
||||
sel_row = -1
|
||||
for row, p in enumerate(self.c.scene.parts):
|
||||
selected = set(self.c.selected)
|
||||
for p in self.c.scene.parts:
|
||||
name = f" · {p.name}" if p.name else ""
|
||||
item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}")
|
||||
item.setData(Qt.UserRole, p.id)
|
||||
self.list.addItem(item)
|
||||
if p.id == self.c.selected_id:
|
||||
sel_row = row
|
||||
if sel_row >= 0:
|
||||
self.list.setCurrentRow(sel_row)
|
||||
if p.id in selected:
|
||||
item.setSelected(True)
|
||||
|
||||
part = self._selected_part()
|
||||
if part:
|
||||
|
|
@ -99,9 +99,8 @@ class PartsPanel(QWidget):
|
|||
def _on_row_selected(self) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
items = self.list.selectedItems()
|
||||
if items:
|
||||
self.c.select(items[0].data(Qt.UserRole))
|
||||
ids = [it.data(Qt.UserRole) for it in self.list.selectedItems()]
|
||||
self.c.set_selected(ids)
|
||||
|
||||
def _rename(self) -> None:
|
||||
part = self._selected_part()
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
selection, and emits a signal when a board is clicked."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from ..scene import Scene
|
||||
from ..viewer import _PALETTE, _part_mesh, _quiet_vtk
|
||||
|
||||
|
||||
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):
|
||||
super().__init__(parent)
|
||||
|
|
@ -51,16 +52,18 @@ class Viewport(QWidget):
|
|||
def _on_pick(self, actor) -> None:
|
||||
pid = self._actor_to_pid.get(actor)
|
||||
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
|
||||
self.plotter.clear()
|
||||
self._actor_to_pid.clear()
|
||||
|
||||
labels, pts = [], []
|
||||
for i, part in enumerate(scene.parts):
|
||||
selected = part.id == scene.selection
|
||||
selected = part.id in selected_ids
|
||||
actor = self.plotter.add_mesh(
|
||||
_part_mesh(part),
|
||||
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.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:
|
||||
try:
|
||||
self.plotter.close()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import copy
|
|||
import json
|
||||
import math
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field, fields, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -172,10 +173,23 @@ class Scene:
|
|||
|
||||
# ----- undo / redo --------------------------------------------------
|
||||
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))
|
||||
del self._undo[:-50] # keep the last 50 steps
|
||||
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:
|
||||
restored = Scene.from_dict(snapshot)
|
||||
restored._undo = self._undo
|
||||
|
|
|
|||
|
|
@ -55,6 +55,28 @@ def test_select_and_undo_redo(tmp_path):
|
|||
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):
|
||||
c = _controller(tmp_path)
|
||||
assert "unknown" in c.execute_call("wood-bogus", {}).lower()
|
||||
|
|
|
|||
|
|
@ -179,6 +179,19 @@ def test_new_action_clears_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():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
|
|
|
|||
Loading…
Reference in New Issue