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