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
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`.

View File

@ -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)

View File

@ -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".

View File

@ -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()

View File

@ -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()

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 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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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)