diff --git a/.gitignore b/.gitignore index 9f614c4..55f9688 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Byte-compiled / optimized / DLL files -__pycache__/ +__pycache__ *.py[codz] *$py.class @@ -125,9 +125,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/bot/control/camera.py b/bot/control/camera.py index 7a7aca6..b185e7d 100644 --- a/bot/control/camera.py +++ b/bot/control/camera.py @@ -48,10 +48,16 @@ def __init__(self, base, scene, settings): self.base.accept("cmd_center", self.recenter) self.base.accept("cmd_zoom", self.handle_zoom) self.base.accept("cmd_align_plane", self.align_to_plane) + self.base.accept("cmd_toggle_marker", self.toggle_marker) # Vitesse de déplacement au clavier self.key_pan_speed = 2.0 + show_marker = bool(settings.get("show_marker_at_start", False)) + self._marker_visible = show_marker + if not show_marker: + self.marker.hide() + # Task pour maintenir le marqueur et le gizmo self.base.taskMgr.add(self.update_task, "CameraUpdateTask") # Configure la camera par rapport au contenu de la scene @@ -110,6 +116,23 @@ def create_marker(self): ls.drawTo(v) return NodePath(ls.create()) + def toggle_marker(self): + """Show or hide the camera pivot marker.""" + self._marker_visible = not getattr(self, "_marker_visible", True) + if self._marker_visible: + self.marker.show() + else: + self.marker.hide() + + def apply_settings(self, settings: dict): + """Apply camera settings from config (speeds, marker visibility).""" + if "show_marker_at_start" in settings: + self._marker_visible = bool(settings["show_marker_at_start"]) + if self._marker_visible: + self.marker.show() + else: + self.marker.hide() + def handle_rotate(self, dx, dy): """ Rotate the camera around the pivot node. @@ -267,19 +290,6 @@ def align_to_plane(self, axis): # On lance et on déverrouille à la fin Sequence(self.transition, Func(self._unlock)).start() - # Parallel( - # self.focal_node.hprInterval(duration, target_hpr, blendType='easeInOut'), - # self.focal_node.posInterval(duration, center, blendType='easeInOut'), - # LerpFunc(lambda s: self.lens.setFilmSize(s), - # fromData=self.lens.getFilmSize().getX(), - # toData=max_dim * 1.5, - # duration=duration, - # blendType='easeInOut'), - # name="AlignAnimation" - # ).start() - - # taskMgr.doMethodLater(duration, self._unlock, "UnlockTask") - def update_task(self, task): """ Per-frame task: keep the pivot marker and gizmo in sync with the camera. diff --git a/bot/control/keyboard.py b/bot/control/keyboard.py deleted file mode 100644 index 9df7b7e..0000000 --- a/bot/control/keyboard.py +++ /dev/null @@ -1,97 +0,0 @@ -from direct.showbase.InputStateGlobal import inputState -import sys - - -class KeyboardHandler: - """ - Handles keyboard input and dispatches camera commands via the Panda3D messenger. - - Arrow keys produce continuous smooth panning through a per-frame task; - shortcut keys (c, x, y, z, f5…) send one-shot command events. - """ - - def __init__(self, base): - """ - Register all key bindings and start the continuous move task. - - Args: - base: Panda3D ShowBase instance. - """ - self.base = base - - # Dictionnaire pour stocker l'état des touches - self.keys = {"arrow_left": 0, "arrow_right": 0, "arrow_up": 0, "arrow_down": 0} - - # On écoute l'appui et le relâchement - for key in self.keys: - self.base.accept(key, self.set_key, [key, 1]) - self.base.accept(key + "-up", self.set_key, [key, 0]) - - # Actions directes (Events) - self.base.accept("escape", sys.exit) - self.base.accept("f5", lambda: base.messenger.send("cmd_hot_reload")) - self.base.accept("c", lambda: base.messenger.send("cmd_center")) - self.base.accept("alt-x", lambda: base.messenger.send("cmd_align_plane", ["x"])) - self.base.accept("alt-y", lambda: base.messenger.send("cmd_align_plane", ["y"])) - self.base.accept("alt-z", lambda: base.messenger.send("cmd_align_plane", ["z"])) - - self.base.accept("x", lambda: base.messenger.send("cmd_axis_constraint", [1])) - self.base.accept("y", lambda: base.messenger.send("cmd_axis_constraint", [2])) - self.base.accept("z", lambda: base.messenger.send("cmd_axis_constraint", [4])) - self.base.accept( - "shift-x", lambda: base.messenger.send("cmd_axis_constraint", [6]) - ) - self.base.accept( - "shift-y", lambda: base.messenger.send("cmd_axis_constraint", [5]) - ) - self.base.accept( - "shift-z", lambda: base.messenger.send("cmd_axis_constraint", [3]) - ) - - # Axis-constraint shortcuts (0..7 mask). - # x=1, y=2, z=4 - for mask in range(8): - self.base.accept( - str(mask), - lambda m=mask: base.messenger.send("cmd_axis_constraint", [m]), - ) - # Flèches directionnelles - # self.base.accept('arrow_left', lambda: base.messenger.send("cmd_pan", [-1, 0])) - # self.base.accept('arrow_right', lambda: base.messenger.send("cmd_pan", [1, 0])) - # self.base.accept('arrow_up', lambda: base.messenger.send("cmd_pan", [0, 1])) - # self.base.accept('arrow_down', lambda: base.messenger.send("cmd_pan", [0, -1])) - - self.base.accept("p", lambda: base.messenger.send("cmd_toggle_marker")) - inputState.watchWithModifiers("up", "arrow_up") - inputState.watchWithModifiers("down", "arrow_down") - inputState.watchWithModifiers("left", "arrow_left") - inputState.watchWithModifiers("right", "arrow_right") - # On ajoute une tâche pour traiter le mouvement fluide - self.base.taskMgr.add(self.move_task, "KeyboardMoveTask") - - def set_key(self, key, value): - """Update the pressed state of *key* (1 = down, 0 = up).""" - self.keys[key] = value - - def move_task(self, task): - """ - Per-frame task: emit ``cmd_pan`` while arrow keys are held down. - - Movement is frame-rate independent (scaled by ``clock.getDt()``). - - Returns: - task.cont to keep the task alive. - """ - # On calcule le vecteur de direction selon les touches pressées - dx = self.keys["arrow_right"] - self.keys["arrow_left"] - dy = self.keys["arrow_up"] - self.keys["arrow_down"] - - if dx != 0 or dy != 0: - # On envoie une petite valeur de déplacement constante - # On multiplie par globalClock.getDt() pour que la vitesse soit - # la même peu importe la puissance du PC (Frame Rate Independent) - dt = self.base.clock.getDt() - speed = 0.5 # Ajustez cette valeur pour la sensibilité clavier - self.base.messenger.send("cmd_pan", [dx * speed * dt, dy * speed * dt]) - - return task.cont diff --git a/bot/control/mouse.py b/bot/control/mouse.py index ddb0050..96c6805 100644 --- a/bot/control/mouse.py +++ b/bot/control/mouse.py @@ -8,21 +8,28 @@ class MouseHandler: """ - Handles mouse input and dispatches camera commands via the Panda3D messenger. + Handles mouse input for domain picking and camera gestures. - - Left-button drag → ``cmd_rotate`` - - Shift + left-button drag → ``cmd_pan`` - - Scroll wheel → ``cmd_zoom`` + Domain (priority): + - Hover → ``ViewEventType.HOVER`` + - Left click on curve → ``ViewEventType.CURVE_SELECTED`` + - Control-point drag → ``CP_PICK_START`` / ``CP_DRAG`` / ``CP_PICK_END`` + + Local (via shortcut ``GestureTracker``): + - Left-button drag → ``cmd_pan`` + - Scroll wheel → ``cmd_zoom`` """ - def __init__(self, base): + def __init__(self, base, gesture_tracker=None): """ - Register mouse-wheel bindings and start the per-frame update task. + Register the per-frame update task. Args: base: Panda3D ShowBase instance. + gesture_tracker: Optional ``GestureTracker`` from the shortcut registry. """ self.base = base + self.gesture_tracker = gesture_tracker self.prev_mouse_pos = None self._left_was_down = False @@ -39,16 +46,11 @@ def __init__(self, base): self.drag_last_valid_world_pos = None self.drag_offset = [0.0, 0.0, 0.0] - # Bindings molette souris - self.base.accept( - "wheel_up", lambda: self.base.messenger.send("cmd_zoom", [0.9]) - ) - self.base.accept( - "wheel_down", lambda: self.base.messenger.send("cmd_zoom", [1.1]) - ) - self.base.taskMgr.add(self.update, "MouseTask") + def set_gesture_tracker(self, gesture_tracker) -> None: + self.gesture_tracker = gesture_tracker + def set_edit_mode(self, enabled: bool, curve_tag=None): self.edit_mode_enabled = bool(enabled) self.active_curve_tag = str(curve_tag) if curve_tag is not None else None @@ -228,19 +230,25 @@ def _handle_curve_click(self, m_pos: Point2, left_down: bool): ): self.base._on_event_cb(ViewEventType.CURVE_SELECTED, metadata["curve_tag"]) - def _handle_drag(self, curr_pos: Point2): - if self.dragging_cp: - self.prev_mouse_pos = Point2(curr_pos) + def _current_modifiers(self) -> frozenset[str]: + mods = set() + if inputState.isSet("shift"): + mods.add("shift") + if inputState.isSet("control"): + mods.add("control") + if inputState.isSet("alt"): + mods.add("alt") + return frozenset(mods) + + def _handle_gestures(self, curr_pos: Point2, left_down: bool): + if self.gesture_tracker is None: return - - if self.base.mouseWatcherNode.isButtonDown(MouseButton.one()): - if self.prev_mouse_pos is not None: - delta = curr_pos - self.prev_mouse_pos - if delta.lengthSquared() > 0: - self.base.messenger.send("cmd_pan", [delta.getX(), delta.getY()]) - self.prev_mouse_pos = Point2(curr_pos) - else: - self.prev_mouse_pos = None + self.gesture_tracker.on_frame( + left_down=left_down, + pos=(curr_pos.getX(), curr_pos.getY()), + modifiers=self._current_modifiers(), + blocked=self.dragging_cp, + ) def update(self, task): if not self.base.mouseWatcherNode.hasMouse(): @@ -248,6 +256,8 @@ def update(self, task): self._finalize_drag(self.drag_last_valid_world_pos) self._left_was_down = False self.prev_mouse_pos = None + if self.gesture_tracker is not None: + self.gesture_tracker.reset() return task.cont m_pos = self.base.mouseWatcherNode.getMouse() @@ -257,10 +267,7 @@ def update(self, task): self._handle_hover(m_pos) self._handle_curve_click(m_pos, left_down) self._handle_cp_interaction(m_pos, left_down) - self._handle_drag(curr_pos) + self._handle_gestures(curr_pos, left_down) self._left_was_down = left_down return task.cont - - def is_shift_down(self) -> bool: - return inputState.isSet("shift") diff --git a/bot/control/shortcuts/__init__.py b/bot/control/shortcuts/__init__.py new file mode 100644 index 0000000..b43bfbc --- /dev/null +++ b/bot/control/shortcuts/__init__.py @@ -0,0 +1,22 @@ +from bot.control.shortcuts.binding import Click, Drag, Hold, Key, Seq, Wheel +from bot.control.shortcuts.engine import ( + Delta, + InputContext, + ShortcutRegistry, + bind, + registry, +) + +__all__ = [ + "Click", + "Delta", + "Drag", + "Hold", + "InputContext", + "Key", + "Seq", + "ShortcutRegistry", + "Wheel", + "bind", + "registry", +] diff --git a/bot/control/shortcuts/binding.py b/bot/control/shortcuts/binding.py new file mode 100644 index 0000000..b9c2d47 --- /dev/null +++ b/bot/control/shortcuts/binding.py @@ -0,0 +1,61 @@ +"""Immutable binding types for the shortcut system.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass(frozen=True) +class Key: + """Single key press (Panda3D event name, e.g. ``\"c\"``, ``\"shift-x\"``).""" + + name: str + + +@dataclass(frozen=True) +class Seq: + """Ordered key sequence; fires only when the full sequence matches.""" + + keys: tuple[str, ...] + timeout: float = 0.4 + + def __init__(self, *keys: str, timeout: float = 0.4): + object.__setattr__(self, "keys", tuple(keys)) + object.__setattr__(self, "timeout", timeout) + + +@dataclass(frozen=True) +class Wheel: + """Mouse wheel tick.""" + + direction: Literal["up", "down"] + + +@dataclass(frozen=True) +class Click: + """Mouse click without significant movement.""" + + button: Literal["left", "right", "middle"] + modifiers: frozenset[str] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class Drag: + """Continuous mouse drag; handler receives ``(ctx, delta)`` each frame.""" + + button: Literal["left", "right", "middle"] + modifiers: frozenset[str] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class Hold: + """Keys held down; handler is called every frame with pressed-state dict.""" + + keys: tuple[str, ...] + + def __init__(self, *keys: str): + object.__setattr__(self, "keys", tuple(keys)) + + +Binding = Key | Seq | Wheel | Click | Drag | Hold diff --git a/bot/control/shortcuts/engine.py b/bot/control/shortcuts/engine.py new file mode 100644 index 0000000..49bb227 --- /dev/null +++ b/bot/control/shortcuts/engine.py @@ -0,0 +1,336 @@ +"""Shortcut registry, sequence buffer, and gesture tracker.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any, Callable, Literal + +from bot.control.shortcuts.binding import ( + Binding, + Click, + Drag, + Hold, + Key, + Seq, + Wheel, +) +from bot.viewer.contracts import ViewEventType + +Scope = Literal["local", "domain"] +Handler = Callable[..., Any] + +_BUTTON_MAP = { + "left": 0, + "middle": 1, + "right": 2, +} +_BUTTON_FROM_INDEX = {v: k for k, v in _BUTTON_MAP.items()} + +# Movement squared threshold (Panda3D normalised coords) to distinguish click vs drag. +_DRAG_THRESHOLD_SQ = 1e-6 + + +@dataclass +class Entry: + binding: Binding + fn: Handler + scope: Scope + + +@dataclass +class InputContext: + """Runtime context passed to every shortcut handler.""" + + base: Any + scene: Any | None + mouse_handler: Any + on_event_cb: Callable[[ViewEventType, Any], None] + camera_controller: Any | None = None + + @property + def messenger(self): + return self.base.messenger + + +@dataclass +class Delta: + """Screen-space mouse delta for continuous drag handlers.""" + + x: float + y: float + + +class SequenceBuffer: + """ + Buffer for key sequences and single keys. + + Exact ``Seq`` match → return that entry. + Buffer is a prefix of some ``Seq`` → wait (return None). + Otherwise → try a single-key ``Key`` match for the last key. + """ + + def __init__(self) -> None: + self._buf: list[str] = [] + self._last_t: float = 0.0 + self._timeout: float = 0.4 + self._seq_entries: list[Entry] = [] + self._key_entries: dict[str, Entry] = {} + + def configure(self, seq_entries: list[Entry], key_entries: list[Entry]) -> None: + self._seq_entries = list(seq_entries) + self._key_entries = { + e.binding.name: e for e in key_entries if isinstance(e.binding, Key) + } + timeouts = [ + e.binding.timeout for e in seq_entries if isinstance(e.binding, Seq) + ] + self._timeout = max(timeouts) if timeouts else 0.4 + + def clear(self) -> None: + self._buf.clear() + + def _is_prefix(self, candidate: tuple[str, ...]) -> bool: + for entry in self._seq_entries: + keys = entry.binding.keys + if len(candidate) < len(keys) and keys[: len(candidate)] == candidate: + return True + return False + + def _matching_timeout(self, candidate: tuple[str, ...]) -> float: + timeouts = [] + for entry in self._seq_entries: + keys = entry.binding.keys + if len(candidate) < len(keys) and keys[: len(candidate)] == candidate: + timeouts.append(entry.binding.timeout) + return max(timeouts) if timeouts else self._timeout + + def push(self, key: str, now: float | None = None) -> Entry | None: + t = time.monotonic() if now is None else now + if self._buf and (t - self._last_t) > self._timeout: + self._buf.clear() + + self._buf.append(key) + self._last_t = t + candidate = tuple(self._buf) + + for entry in self._seq_entries: + if entry.binding.keys == candidate: + self._buf.clear() + return entry + + if self._is_prefix(candidate): + self._timeout = self._matching_timeout(candidate) + return None + + self._buf.clear() + return self._key_entries.get(key) + + +class GestureTracker: + """ + Minimal mouse gesture state machine: idle → pressed → dragging → released. + + Domain interactions (CP drag, curve pick) stay in ``MouseHandler`` and pass + ``blocked=True`` so camera gestures do not fire. + """ + + _IDLE = 0 + _PRESSED = 1 + _DRAGGING = 2 + + def __init__(self) -> None: + self._state = self._IDLE + self._button: str | None = None + self._modifiers: frozenset[str] = frozenset() + self._press_pos: tuple[float, float] | None = None + self._prev_pos: tuple[float, float] | None = None + self._drag_entries: list[Entry] = [] + self._click_entries: list[Entry] = [] + self._invoke: Callable[..., None] | None = None + + def configure( + self, + drag_entries: list[Entry], + click_entries: list[Entry], + invoke: Callable[..., None], + ) -> None: + self._drag_entries = list(drag_entries) + self._click_entries = list(click_entries) + self._invoke = invoke + + def reset(self) -> None: + self._state = self._IDLE + self._button = None + self._modifiers = frozenset() + self._press_pos = None + self._prev_pos = None + + def _find( + self, entries: list[Entry], button: str, modifiers: frozenset[str] + ) -> Entry | None: + for entry in entries: + b = entry.binding + if b.button == button and b.modifiers == modifiers: + return entry + return None + + def on_frame( + self, + *, + left_down: bool, + pos: tuple[float, float] | None, + modifiers: frozenset[str], + blocked: bool, + ) -> None: + if blocked or pos is None: + if self._state != self._IDLE: + self.reset() + return + + button = "left" + if left_down and self._state == self._IDLE: + self._state = self._PRESSED + self._button = button + self._modifiers = frozenset(modifiers) + self._press_pos = pos + self._prev_pos = pos + return + + if left_down and self._state in (self._PRESSED, self._DRAGGING): + assert self._prev_pos is not None and self._press_pos is not None + dx = pos[0] - self._prev_pos[0] + dy = pos[1] - self._prev_pos[1] + if self._state == self._PRESSED: + pdx = pos[0] - self._press_pos[0] + pdy = pos[1] - self._press_pos[1] + if pdx * pdx + pdy * pdy > _DRAG_THRESHOLD_SQ: + self._state = self._DRAGGING + if self._state == self._DRAGGING and (dx != 0.0 or dy != 0.0): + entry = self._find( + self._drag_entries, self._button or button, self._modifiers + ) + if entry is not None and self._invoke is not None: + self._invoke(entry, Delta(dx, dy)) + self._prev_pos = pos + return + + if not left_down and self._state == self._PRESSED: + entry = self._find( + self._click_entries, self._button or button, self._modifiers + ) + if entry is not None and self._invoke is not None: + self._invoke(entry) + self.reset() + return + + if not left_down: + self.reset() + + +class ShortcutRegistry: + """Declarative registry: binding + handler + local/domain scope.""" + + def __init__(self) -> None: + self._entries: list[Entry] = [] + self._ctx: InputContext | None = None + self.sequence_buffer = SequenceBuffer() + self.gesture_tracker = GestureTracker() + self._hold_keys: dict[str, int] = {} + self._hold_entry: Entry | None = None + self._installed = False + + def bind(self, binding: Binding, *, scope: Scope = "local"): + def decorator(fn: Handler) -> Handler: + self._entries.append(Entry(binding, fn, scope)) + return fn + + return decorator + + def entries(self) -> list[Entry]: + return list(self._entries) + + def update_context(self, **kwargs: Any) -> None: + if self._ctx is None: + return + for k, v in kwargs.items(): + setattr(self._ctx, k, v) + + def _invoke(self, entry: Entry, *args: Any) -> None: + if self._ctx is None: + return + result = entry.fn(self._ctx, *args) + if entry.scope == "domain": + if isinstance(result, dict): + payload = dict(result) + payload.setdefault("action", entry.fn.__name__) + else: + payload = {"action": entry.fn.__name__} + self._ctx.on_event_cb(ViewEventType.SHORTCUT, payload) + + def install(self, ctx: InputContext) -> None: + """Wire bindings to Panda3D ``accept`` / tasks / gesture tracker.""" + self._ctx = ctx + base = ctx.base + + key_entries = [e for e in self._entries if isinstance(e.binding, Key)] + seq_entries = [e for e in self._entries if isinstance(e.binding, Seq)] + wheel_entries = [e for e in self._entries if isinstance(e.binding, Wheel)] + hold_entries = [e for e in self._entries if isinstance(e.binding, Hold)] + drag_entries = [e for e in self._entries if isinstance(e.binding, Drag)] + click_entries = [e for e in self._entries if isinstance(e.binding, Click)] + + self.sequence_buffer.configure(seq_entries, key_entries) + self.gesture_tracker.configure(drag_entries, click_entries, self._invoke) + + # Collect all key names that participate in Key or Seq bindings. + watched: set[str] = set() + for e in key_entries: + watched.add(e.binding.name) + for e in seq_entries: + watched.update(e.binding.keys) + + for name in watched: + base.accept(name, self._on_key, [name]) + + for e in wheel_entries: + event = "wheel_up" if e.binding.direction == "up" else "wheel_down" + + def _wheel(entry=e): + self._invoke(entry) + + base.accept(event, _wheel) + + if hold_entries: + # Only one Hold binding is supported (arrow-pan style). + self._hold_entry = hold_entries[0] + self._hold_keys = {k: 0 for k in self._hold_entry.binding.keys} + for key in self._hold_keys: + base.accept(key, self._set_hold_key, [key, 1]) + base.accept(key + "-up", self._set_hold_key, [key, 0]) + base.taskMgr.add(self._hold_task, "ShortcutHoldTask") + + self._installed = True + + def _on_key(self, name: str) -> None: + entry = self.sequence_buffer.push(name) + if entry is not None: + self._invoke(entry) + + def _set_hold_key(self, key: str, value: int) -> None: + self._hold_keys[key] = value + + def _hold_task(self, task): + if self._hold_entry is not None and self._ctx is not None: + if any(self._hold_keys.values()): + self._invoke(self._hold_entry, dict(self._hold_keys)) + return task.cont + + +# Module-level singleton used by ``@bind`` and ``shortcuts_registry``. +registry = ShortcutRegistry() + + +def bind(binding: Binding, *, scope: Scope = "local"): + """Register a shortcut on the default registry.""" + return registry.bind(binding, scope=scope) diff --git a/bot/control/shortcuts_registry.py b/bot/control/shortcuts_registry.py new file mode 100644 index 0000000..25d9c7e --- /dev/null +++ b/bot/control/shortcuts_registry.py @@ -0,0 +1,210 @@ +""" +Declarative shortcut registry — single place to add new commands. + +Usage:: + + @bind(Key("c"), scope="local") + def center_camera(ctx): + ctx.messenger.send("cmd_center") + + @bind(Key("n"), scope="domain") + def new_point(ctx): + return {"action": "new_point"} # emits ViewEventType.SHORTCUT +""" + +from __future__ import annotations + +import sys + +from direct.gui.OnscreenText import OnscreenText +from panda3d.core import TextNode + +from bot.control.shortcuts import Key, Seq, Wheel, Drag, Hold, Click, bind, registry + +# Importing this module registers all handlers on the default ``registry``. +__all__ = ["registry"] + + +@bind(Key("escape"), scope="local") +def quit_view(ctx): + sys.exit(0) + + +@bind(Key("f5"), scope="local") +def hot_reload(ctx): + ctx.messenger.send("cmd_hot_reload") + + +@bind(Key("c"), scope="local") +def center_camera(ctx): + ctx.messenger.send("cmd_center") + + +@bind(Key("alt-x"), scope="local") +def align_plane_x(ctx): + ctx.messenger.send("cmd_align_plane", ["x"]) + + +@bind(Key("alt-y"), scope="local") +def align_plane_y(ctx): + ctx.messenger.send("cmd_align_plane", ["y"]) + + +@bind(Key("alt-z"), scope="local") +def align_plane_z(ctx): + ctx.messenger.send("cmd_align_plane", ["z"]) + + +@bind(Key("x"), scope="local") +def axis_constraint_x(ctx): + ctx.messenger.send("cmd_axis_constraint", [1]) + + +@bind(Key("y"), scope="local") +def axis_constraint_y(ctx): + ctx.messenger.send("cmd_axis_constraint", [2]) + + +@bind(Key("z"), scope="local") +def axis_constraint_z(ctx): + ctx.messenger.send("cmd_axis_constraint", [4]) + + +@bind(Key("shift-x"), scope="local") +def axis_constraint_yz(ctx): + ctx.messenger.send("cmd_axis_constraint", [6]) + + +@bind(Key("shift-y"), scope="local") +def axis_constraint_xz(ctx): + ctx.messenger.send("cmd_axis_constraint", [5]) + + +@bind(Key("shift-z"), scope="local") +def axis_constraint_xy(ctx): + ctx.messenger.send("cmd_axis_constraint", [3]) + + +def _register_axis_mask_keys() -> None: + """Axis-constraint masks 0..7 via digit keys.""" + + def _make(mask: int): + def handler(ctx, m=mask): + ctx.messenger.send("cmd_axis_constraint", [m]) + + handler.__name__ = f"axis_constraint_mask_{mask}" + return handler + + for mask in range(8): + registry.bind(Key(str(mask)), scope="local")(_make(mask)) + + +_register_axis_mask_keys() + + +@bind(Key("p"), scope="local") +def toggle_marker(ctx): + ctx.messenger.send("cmd_toggle_marker") + + +@bind(Hold("arrow_left", "arrow_right", "arrow_up", "arrow_down"), scope="local") +def arrow_pan(ctx, keys: dict): + dx = keys.get("arrow_right", 0) - keys.get("arrow_left", 0) + dy = keys.get("arrow_up", 0) - keys.get("arrow_down", 0) + if dx == 0 and dy == 0: + return + dt = ctx.base.clock.getDt() + speed = 0.5 + ctx.messenger.send("cmd_pan", [dx * speed * dt, dy * speed * dt]) + + +@bind(Wheel("up"), scope="local") +def zoom_in(ctx): + ctx.messenger.send("cmd_zoom", [0.9]) + + +@bind(Wheel("down"), scope="local") +def zoom_out(ctx): + ctx.messenger.send("cmd_zoom", [1.1]) + + +@bind(Drag("left"), scope="local") +def pan_drag(ctx, delta): + if ctx.mouse_handler is not None and getattr( + ctx.mouse_handler, "dragging_cp", False + ): + return + ctx.messenger.send("cmd_pan", [delta.x, delta.y]) + + +def _format_binding(b) -> str: + """Translates a Binding object into a readable short string (English).""" + if isinstance(b, Key): + return b.name.upper() + if isinstance(b, Seq): + return " then ".join(k.upper() for k in b.keys) + if isinstance(b, Drag): + mods = ( + f" + {'+'.join(b.modifiers).upper()}" + if getattr(b, "modifiers", None) + else "" + ) + return f"DRAG {b.button.upper()}{mods}" + if isinstance(b, Wheel): + mods = ( + f" ({'+'.join(b.modifiers).upper()})" + if getattr(b, "modifiers", None) + else "" + ) + return f"SCROLL {b.direction.upper()}{mods}" + if isinstance(b, Hold): + keys_set = set(b.keys) + if keys_set == {"arrow_left", "arrow_right", "arrow_up", "arrow_down"}: + return "ARROW KEYS" + return f"HOLD {', '.join(b.keys).upper()}" + if isinstance(b, Click): + mods = ( + f" + {'+'.join(b.modifiers).upper()}" + if getattr(b, "modifiers", None) + else "" + ) + return f"CLICK {b.button.upper()}{mods}" + return "UNKNOWN" + + +@bind(Key("h"), scope="local") +def toggle_help(ctx): + """Show or hide this help menu.""" + if hasattr(ctx.base, "_help_ui") and ctx.base._help_ui is not None: + ctx.base._help_ui.destroy() + ctx.base._help_ui = None + return + + lines = ["--- SHORTCUTS ---", ""] + + for entry in registry.entries(): + name = entry.fn.__name__ + + if name.startswith("axis_constraint_mask_"): + continue + + desc = entry.fn.__doc__ or name.replace("_", " ").capitalize() + binding_str = _format_binding(entry.binding) + + lines.append(f"{binding_str:<20} : {desc}") + + help_text = "\n".join(lines) + + ctx.base._help_ui = OnscreenText( + text=help_text, + parent=ctx.base.a2dTopLeft, + pos=(0.04, -0.06), + scale=0.04, + fg=(0.9, 0.9, 0.9, 1), + bg=(0.05, 0.05, 0.08, 0.95), + align=TextNode.ALeft, + font=ctx.base.loader.loadFont("cmtt12"), + ) + + # NOTE: FORCE TO TOP LAYER (Above HUDGizmo and 3D grid) + ctx.base._help_ui.setBin("gui-popup", 0) diff --git a/bot/core/spline.py b/bot/core/spline.py index 8b77a88..92685a5 100644 --- a/bot/core/spline.py +++ b/bot/core/spline.py @@ -88,6 +88,7 @@ def move_control_point(self, tag: str, cp_index: int, new_pt: list[float]): ) self._notify_observers() + @staticmethod def preview_evaluate( type: str, degree: int, diff --git a/bot/view/scene.py b/bot/view/scene.py index cd16db6..01d4fad 100644 --- a/bot/view/scene.py +++ b/bot/view/scene.py @@ -30,7 +30,7 @@ class HUDGizmo: def __init__(self, parent: NodePath): self.root = parent.attachNewNode("hud_gizmo_root") self.root.setPos(80, 0, -80) - self.root.setScale(400) + self.root.setScale(200) self._create_axes() def _create_axes(self): diff --git a/bot/view/view.py b/bot/view/view.py index 77561fc..f7e11e8 100644 --- a/bot/view/view.py +++ b/bot/view/view.py @@ -11,7 +11,8 @@ from bot.viewer.serialize import payload_to_geom_data from bot.control.camera import CameraController from bot.control.mouse import MouseHandler -from bot.control.keyboard import KeyboardHandler +from bot.control.shortcuts import InputContext +from bot.control.shortcuts_registry import registry as shortcut_registry from bot.viewer.contracts import ScenePayload, SceneUpdateOp, ViewerCommandType _DEFAULT_SCENE = { @@ -53,13 +54,25 @@ def __init__(self, config_filename: str, cmd_queue: queue.Queue, on_event_cb): self._on_event_cb = on_event_cb self._scene = None self._camera_controller = None + self._config_filename = config_filename self._config = self.__load_config(config_filename) - self.kb_handler = KeyboardHandler(self) self.mouse_handler = MouseHandler(self) self.axis_constraint_mask = 7 self.accept("cmd_axis_constraint", self.__on_axis_constraint_cmd) + self.accept("cmd_hot_reload", self.__on_hot_reload) + + shortcut_registry.install( + InputContext( + base=self, + scene=self._scene, + mouse_handler=self.mouse_handler, + on_event_cb=self._on_event_cb, + camera_controller=self._camera_controller, + ) + ) + self.mouse_handler.set_gesture_tracker(shortcut_registry.gesture_tracker) self.hud = OnscreenText( text="", pos=(-1.3, -0.5), scale=0.06, fg=(1, 1, 1, 1), align=TextNode.ALeft @@ -155,6 +168,15 @@ def __set_axis_constraint(self, mask: int): def __on_axis_constraint_cmd(self, mask: int): self.__set_axis_constraint(mask) + def __on_hot_reload(self): + """Reload TOML config from disk and apply to scene / camera.""" + self._config = self.__load_config(self._config_filename) + if self._scene: + self._scene.apply_settings(self.__scene_cfg()) + if self._camera_controller: + self._camera_controller.apply_settings(self.__camera_cfg()) + self.hud.setText("Config reloaded.") + def __load_scene(self, payload: dict): """Load (or reload) the scene from an add payload or legacy geom_data.""" if self._scene is not None: @@ -167,6 +189,7 @@ def __load_scene(self, payload: dict): self._scene = Scene(self, geom_data, self.__scene_cfg()) self._scene.set_axis_constraint(self.axis_constraint_mask) + shortcut_registry.update_context(scene=self._scene) if self._camera_controller is None: self._camera_controller = CameraController( @@ -175,6 +198,7 @@ def __load_scene(self, payload: dict): else: self._camera_controller.scene = self._scene + shortcut_registry.update_context(camera_controller=self._camera_controller) self._camera_controller.recenter() def __update_scene(self, payload: dict): diff --git a/bot/viewer/contracts.py b/bot/viewer/contracts.py index 5ae6460..2099242 100644 --- a/bot/viewer/contracts.py +++ b/bot/viewer/contracts.py @@ -71,6 +71,9 @@ class ViewEventType(IntEnum): # Legitimate: The parent receives an absolute 3D coordinate to instantiate a new free point. PICK = 105 + # Legitimate: Domain shortcut fired in the child; payload is ``{"action": str, ...}``. + SHORTCUT = 106 + ParentCommand = SceneUpdateOp | ViewerCommandType ParentMessage = tuple[ParentCommand, Any] @@ -171,6 +174,11 @@ class EventPick(TypedDict): world_pos: list[float] +class EventShortcut(TypedDict): + event_type: Literal[ViewEventType.SHORTCUT] + action: str + + ViewEvent = Union[ EventHover, EventCurveSelected, @@ -178,6 +186,7 @@ class EventPick(TypedDict): EventCPDrag, EventCPPickEnd, EventPick, + EventShortcut, ] diff --git a/doc/callbacks.md b/doc/callbacks.md index 6b01146..058c493 100644 --- a/doc/callbacks.md +++ b/doc/callbacks.md @@ -57,3 +57,4 @@ The following table details the most common events you can attach callbacks to: | `CP_PICK_START` | `{"curve_tag": str, "cp_index": int, "world_pos": [...]}` | Freeze global history undo stacks or record structural pre-states. | | `CP_PICK_END` | `{"curve_tag": str, "cp_index": int, "world_pos": [...]}` | Commit the finalized drag movement to the mathematical model kernel. | | `PICK` | `{"world_pos": [x, y, z]}` | Instantiate new custom points, nodes, or primitives at absolute locations. | +| `SHORTCUT` | `{"action": str, ...}` | React to domain shortcuts declared with `@bind(..., scope="domain")`. | diff --git a/doc/technical_reference_v1.md b/doc/technical_reference_v1.md index 79c5b1a..777de9a 100644 --- a/doc/technical_reference_v1.md +++ b/doc/technical_reference_v1.md @@ -298,13 +298,32 @@ See the complete extension walkthrough in [custom_model.md](custom_model.md). ### 4.8 Keyboard shortcuts -From `bot/control/keyboard.py`: +Source of truth: [`bot/control/shortcuts_registry.py`](../bot/control/shortcuts_registry.py). + +All shortcuts are declared with `@bind(...)` on the shared registry. Adding a +command is a three-line change: + +```python +from bot.control.shortcuts import bind, Key + +@bind(Key("n"), scope="domain") # or scope="local" +def new_point(ctx): + return {"action": "new_point"} # domain → ViewEventType.SHORTCUT +``` + +- **local**: runs in the Panda3D child (camera, HUD, scene). +- **domain**: emits `ViewEventType.SHORTCUT` to the parent (payload `{"action": str, ...}`). + +Built-in bindings: - `c`: center camera - `alt-x`, `alt-y`, `alt-z`: align camera plane - `x`, `y`, `z`, `shift-x`, `shift-y`, `shift-z`, `0..7`: axis-constraint masks - Arrow keys: smooth pan -- `f5`: hot reload +- Left drag: pan +- Mouse wheel: zoom +- `f5`: hot reload config from disk +- `p`: toggle camera pivot marker - `escape`: exit ## 5. Development and CI/CD diff --git a/ferrispline b/ferrispline index a90ed8c..adcd3d6 160000 --- a/ferrispline +++ b/ferrispline @@ -1 +1 @@ -Subproject commit a90ed8c787bef18d183e135b482943423eead00d +Subproject commit adcd3d6d694605cb6ae09e11a2ff5c5d5f336bae diff --git a/tests/unit/test_control/test_mouse_drag_session.py b/tests/unit/test_control/test_mouse_drag_session.py index fec3e4e..6eecc8b 100644 --- a/tests/unit/test_control/test_mouse_drag_session.py +++ b/tests/unit/test_control/test_mouse_drag_session.py @@ -79,6 +79,9 @@ def _make_handler(self): """Creates a pre-configured MouseHandler for testing drag sessions.""" handler = MouseHandler.__new__(MouseHandler) handler.base = _FakeBase() + + handler.gesture_tracker = None + handler.constraints = ConstraintManager(handler.base) handler.picker = MagicMock() diff --git a/tests/unit/test_control/test_shortcuts.py b/tests/unit/test_control/test_shortcuts.py new file mode 100644 index 0000000..8a45ebb --- /dev/null +++ b/tests/unit/test_control/test_shortcuts.py @@ -0,0 +1,157 @@ +"""Unit tests for the KISS shortcut SequenceBuffer, domain scope, and gestures.""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from bot.control.shortcuts.binding import Drag, Key, Seq +from bot.control.shortcuts.engine import ( + Delta, + Entry, + GestureTracker, + InputContext, + SequenceBuffer, + ShortcutRegistry, +) +from bot.viewer.contracts import ViewEventType + + +class TestSequenceBuffer(unittest.TestCase): + def setUp(self): + self.buf = SequenceBuffer() + self.calls = [] + + def seq_handler(ctx): + self.calls.append("gg") + + def key_g(ctx): + self.calls.append("g") + + def key_x(ctx): + self.calls.append("x") + + self.seq_entry = Entry(Seq("g", "g", timeout=0.4), seq_handler, "local") + self.key_g = Entry(Key("g"), key_g, "local") + self.key_x = Entry(Key("x"), key_x, "local") + self.buf.configure([self.seq_entry], [self.key_g, self.key_x]) + + def test_seq_exact_match(self): + self.assertIsNone(self.buf.push("g", now=1.0)) + entry = self.buf.push("g", now=1.1) + self.assertIs(entry, self.seq_entry) + + def test_seq_prefix_wait(self): + self.assertIsNone(self.buf.push("g", now=1.0)) + self.assertEqual(self.buf._buf, ["g"]) + + def test_seq_timeout_reset(self): + self.assertIsNone(self.buf.push("g", now=1.0)) + # After timeout, buffer clears then treats next key fresh. + entry = self.buf.push("x", now=2.0) + self.assertIs(entry, self.key_x) + + def test_seq_no_false_prefix(self): + self.assertIsNone(self.buf.push("g", now=1.0)) + entry = self.buf.push("x", now=1.1) + self.assertIs(entry, self.key_x) + self.assertNotIn("gg", self.calls) + + def test_single_key_when_not_prefix(self): + entry = self.buf.push("x", now=1.0) + self.assertIs(entry, self.key_x) + + +class TestDomainScope(unittest.TestCase): + def test_domain_scope_emits_event(self): + events = [] + reg = ShortcutRegistry() + + @reg.bind(Key("n"), scope="domain") + def new_point(ctx): + return {"action": "new_point"} + + ctx = InputContext( + base=MagicMock(), + scene=None, + mouse_handler=MagicMock(), + on_event_cb=lambda et, data: events.append((et, data)), + ) + reg._ctx = ctx + entry = reg.entries()[0] + reg._invoke(entry) + + self.assertEqual(len(events), 1) + self.assertEqual(events[0][0], ViewEventType.SHORTCUT) + self.assertEqual(events[0][1]["action"], "new_point") + + def test_domain_scope_default_action_name(self): + events = [] + reg = ShortcutRegistry() + + @reg.bind(Key("m"), scope="domain") + def my_action(ctx): + return None + + ctx = InputContext( + base=MagicMock(), + scene=None, + mouse_handler=MagicMock(), + on_event_cb=lambda et, data: events.append((et, data)), + ) + reg._ctx = ctx + reg._invoke(reg.entries()[0]) + self.assertEqual(events[0][1]["action"], "my_action") + + +class TestGestureTracker(unittest.TestCase): + def test_drag_blocked_during_cp_drag(self): + invoked = [] + tracker = GestureTracker() + + def pan(ctx, delta): + invoked.append(delta) + + entry = Entry(Drag("left"), pan, "local") + tracker.configure([entry], [], lambda e, *a: invoked.append((e, a))) + + tracker.on_frame( + left_down=True, + pos=(0.0, 0.0), + modifiers=frozenset(), + blocked=False, + ) + tracker.on_frame( + left_down=True, + pos=(0.1, 0.0), + modifiers=frozenset(), + blocked=True, # CP drag active + ) + self.assertEqual(invoked, []) + self.assertEqual(tracker._state, tracker._IDLE) + + def test_drag_fires_when_not_blocked(self): + invoked = [] + tracker = GestureTracker() + + def pan(ctx, delta): + pass + + entry = Entry(Drag("left"), pan, "local") + + def invoke(e, *args): + invoked.append(args) + + tracker.configure([entry], [], invoke) + tracker.on_frame( + left_down=True, pos=(0.0, 0.0), modifiers=frozenset(), blocked=False + ) + tracker.on_frame( + left_down=True, pos=(0.05, 0.0), modifiers=frozenset(), blocked=False + ) + self.assertEqual(len(invoked), 1) + self.assertIsInstance(invoked[0][0], Delta) + + +if __name__ == "__main__": + unittest.main()