235 lines
10 KiB
Python
235 lines
10 KiB
Python
"""Controller: the single in-memory Scene and every way to mutate it.
|
|
|
|
Buttons/menus call the typed methods directly (instant); voice/typed commands
|
|
go through the Claude interpreter and are applied via `execute_call`, which
|
|
reuses the CLI's command functions so GUI and CLI behave identically. Every
|
|
mutation saves to disk (keeping the CLI/headless tools interoperable) and emits
|
|
`changed` so the views refresh.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from PySide6.QtCore import QObject, Signal
|
|
|
|
from .. import cli, driver
|
|
from ..scene import Scene, SceneError, default_scene_path
|
|
|
|
|
|
def _f(v):
|
|
"""Parse an optional float arg ('' / None -> None)."""
|
|
return float(v) if v not in (None, "") else None
|
|
|
|
|
|
def _opt(v):
|
|
"""Normalize an optional string arg ('' -> None)."""
|
|
return v if v not in (None, "") else None
|
|
|
|
|
|
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
|
|
# command functions means voice and CLI share one implementation.
|
|
TOOL_CMD = {
|
|
"wood-place": lambda a: (cli.cmd_place, SimpleNamespace(stock=a["stock"], length=a["length"], unit="inch")),
|
|
"wood-join": lambda a: (cli.cmd_join, SimpleNamespace(
|
|
part_b=a["part_b"], part_a=_opt(a.get("to")), angle=float(a.get("angle") or 90),
|
|
offset=_opt(a.get("offset")), anchor=a.get("anchor") or "end_b", unit="inch")),
|
|
"wood-stand": lambda a: (cli.cmd_stand, SimpleNamespace(part=_opt(a.get("part")), tilt=float(a.get("tilt") or 90))),
|
|
"wood-lay": lambda a: (cli.cmd_lay, SimpleNamespace(part=_opt(a.get("part")))),
|
|
"wood-rotate": lambda a: (cli.cmd_rotate, SimpleNamespace(
|
|
part=_opt(a.get("part")), yaw=_f(a.get("yaw")), tilt=_f(a.get("tilt")), roll=_f(a.get("roll")))),
|
|
"wood-move": lambda a: (cli.cmd_move, SimpleNamespace(
|
|
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")),
|
|
absolute=False, unit="inch")),
|
|
"wood-trim": lambda a: (cli.cmd_trim, SimpleNamespace(length=a["length"], part=_opt(a.get("part")), unit="inch")),
|
|
"wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace(
|
|
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")),
|
|
"wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))),
|
|
"wood-feature": lambda a: (cli.cmd_feature, SimpleNamespace(
|
|
kind=a["kind"], part=_opt(a.get("part")), face=a.get("face") or "end_b",
|
|
along=_opt(a.get("along")), across=_opt(a.get("across")), width=_opt(a.get("width")),
|
|
height=_opt(a.get("height")), depth=_opt(a.get("depth")), diameter=_opt(a.get("diameter")))),
|
|
"wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])),
|
|
"wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))),
|
|
"wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
|
|
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
|
|
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
|
|
"wood-redo": lambda a: (cli.cmd_redo, SimpleNamespace()),
|
|
"wood-clear": lambda a: (cli.cmd_clear, SimpleNamespace()),
|
|
"wood-cutlist": lambda a: (cli.cmd_cutlist, SimpleNamespace()),
|
|
"wood-save": lambda a: (cli.cmd_save, SimpleNamespace(name=a["name"])),
|
|
"wood-open": lambda a: (cli.cmd_open, SimpleNamespace(name=a["name"])),
|
|
"wood-projects": lambda a: (cli.cmd_projects, SimpleNamespace()),
|
|
}
|
|
|
|
|
|
class Controller(QObject):
|
|
changed = Signal() # scene or selection changed -> refresh views
|
|
logged = Signal(str, str) # (who, text): who in {"you","ws","sys"}
|
|
|
|
def __init__(self, scene_path: str | None = None):
|
|
super().__init__()
|
|
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:
|
|
self.scene.save(self.scene_path)
|
|
|
|
def _commit(self, message: str | None = None) -> None:
|
|
self.save()
|
|
if message:
|
|
self.logged.emit("ws", message)
|
|
self.changed.emit()
|
|
|
|
# ----- 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) -> None:
|
|
try:
|
|
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}.")
|
|
|
|
# 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): 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}.")
|
|
|
|
# ----- project / export --------------------------------------------
|
|
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name)))
|
|
def save_project(self, name):
|
|
from ..scene import project_path
|
|
self.scene.save(project_path(name))
|
|
self.logged.emit("ws", f"Saved project '{name}'.")
|
|
|
|
def export(self, path: str):
|
|
from ..geometry import export
|
|
self.logged.emit("ws", f"Exported to {export(self.scene, path)}.")
|
|
|
|
def cutlist_text(self) -> str:
|
|
from ..cutlist import format_cutlist
|
|
return format_cutlist(self.scene)
|
|
|
|
# ----- voice / typed commands --------------------------------------
|
|
def schemas(self) -> str:
|
|
if self._schemas is None:
|
|
self._schemas = driver.load_schemas()
|
|
return self._schemas
|
|
|
|
def execute_call(self, tool: str, args: dict) -> str:
|
|
"""In-process executor for driver.dispatch (mutates the live scene)."""
|
|
entry = TOOL_CMD.get(tool)
|
|
if entry is None:
|
|
return f"(unknown tool {tool})"
|
|
func, ns = entry(args)
|
|
try:
|
|
return func(self.scene, ns)
|
|
except (SceneError, ValueError, KeyError) as exc:
|
|
return str(exc).strip('"')
|
|
|
|
def run_command(self, text: str) -> str:
|
|
"""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
|
|
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()
|
|
return driver.summarize(calls, messages)
|