Source code for simvx.editor.gizmo_controller

"""Editor gizmo controller — wires mouse input to the Gizmo math layer.

Translates mouse hover / click / drag events into gizmo interactions
and applies the resulting deltas to the selected node's transform.
Supports undo/redo via the UndoStack.

Usage (inside an editor panel's process loop):

    controller = GizmoController(state.gizmo, state.selection, state.undo_stack)
    controller.update(dt)  # call each frame
    render_data = controller.get_render_data()  # pass to GizmoPass
"""


from __future__ import annotations

import logging
import math
from typing import Any

import numpy as np

from simvx.core import (
    Gizmo,
    GizmoAxis,
    GizmoMode,
    Input,
    PropertyCommand,
    Selection,
    UndoStack,
    Vec3,
    screen_to_ray,
)

__all__ = ["GizmoController"]

log = logging.getLogger(__name__)

# Map GizmoAxis enum to integer index for GizmoRenderData
_AXIS_TO_INT: dict[GizmoAxis | None, int] = {
    None: -1,
    GizmoAxis.X: 0,
    GizmoAxis.Y: 1,
    GizmoAxis.Z: 2,
    GizmoAxis.XY: 3,
    GizmoAxis.XZ: 4,
    GizmoAxis.YZ: 5,
    GizmoAxis.ALL: 6,
}

# Map GizmoMode enum to integer index for GizmoRenderData
_MODE_TO_INT: dict[GizmoMode, int] = {
    GizmoMode.TRANSLATE: 0,
    GizmoMode.ROTATE: 1,
    GizmoMode.SCALE: 2,
}


[docs] class GizmoController: """Connects mouse input to the core Gizmo and applies results to nodes. Parameters ---------- gizmo: The core ``Gizmo`` instance that performs the picking/dragging math. selection: Editor selection — the primary selected node is the gizmo target. undo_stack: Editor undo stack for recording transform changes. """ def __init__( self, gizmo: Gizmo, selection: Selection, undo_stack: UndoStack, ) -> None: self.gizmo = gizmo self.selection = selection self.undo_stack = undo_stack # Camera matrices — must be set externally each frame self.view_matrix: np.ndarray = np.eye(4, dtype=np.float32) self.proj_matrix: np.ndarray = np.eye(4, dtype=np.float32) self.screen_size: tuple[float, float] = (800.0, 600.0) # State for undo snapshot self._drag_start_position: Vec3 | None = None self._drag_start_rotation: Vec3 | float | None = None self._drag_start_scale: Vec3 | None = None # -- public API ---------------------------------------------------------
[docs] def update(self, dt: float) -> None: """Process mouse input and update gizmo state. Call once per frame.""" node = self.selection.primary if node is None or not hasattr(node, "position"): self.gizmo.active = False return self.gizmo.active = True self.gizmo.position = Vec3(node.position.x, node.position.y, node.position.z) mouse = Input.get_mouse_position() ray_origin, ray_dir = screen_to_ray( (mouse.x, mouse.y), self.screen_size, self.view_matrix, self.proj_matrix, ) ray_origin = Vec3(ray_origin[0], ray_origin[1], ray_origin[2]) ray_dir = Vec3(ray_dir[0], ray_dir[1], ray_dir[2]) if self.gizmo.dragging: self._handle_drag(ray_origin, ray_dir, node) else: self._handle_hover(ray_origin, ray_dir, node)
[docs] def set_mode(self, mode: GizmoMode) -> None: """Change the gizmo interaction mode (translate / rotate / scale).""" self.gizmo.mode = mode
[docs] def get_render_data(self) -> Any: """Build a GizmoRenderData for the current frame.""" # Avoid circular import at module level from simvx.graphics.renderer.gizmo_pass import GizmoRenderData node = self.selection.primary if node is None or not self.gizmo.active: return None return GizmoRenderData( position=np.array( [self.gizmo.position.x, self.gizmo.position.y, self.gizmo.position.z], dtype=np.float32, ), mode=_MODE_TO_INT[self.gizmo.mode], hover_axis=_AXIS_TO_INT[self.gizmo.hover_axis], active_axis=_AXIS_TO_INT.get(self.gizmo._drag_axis, -1), view_matrix=self.view_matrix, proj_matrix=self.proj_matrix, axis_length=self.gizmo.axis_length, )
# -- private helpers ---------------------------------------------------- def _handle_hover(self, ray_origin: Vec3, ray_dir: Vec3, node: Any) -> None: """Update hover state and start drag on mouse press.""" self.gizmo.hover_axis = self.gizmo.pick_axis(ray_origin, ray_dir) # Check for left mouse button press to start drag if Input.is_action_just_pressed("mouse_left") and self.gizmo.hover_axis is not None: self._snapshot_transform(node) self.gizmo.begin_drag(self.gizmo.hover_axis, ray_origin, ray_dir) def _handle_drag(self, ray_origin: Vec3, ray_dir: Vec3, node: Any) -> None: """Process ongoing drag and apply deltas.""" # Check for mouse release if Input.is_action_just_released("mouse_left"): self.gizmo.end_drag() self._push_undo(node) return delta = self.gizmo.update_drag(ray_origin, ray_dir) if self.gizmo.mode is GizmoMode.TRANSLATE: self._apply_translate(node, delta) elif self.gizmo.mode is GizmoMode.ROTATE: self._apply_rotate(node, delta) else: self._apply_scale(node, delta) def _apply_translate(self, node: Any, delta: Vec3) -> None: """Move the selected node by *delta* in world space.""" node.position = Vec3( node.position.x + delta.x, node.position.y + delta.y, node.position.z + delta.z, ) def _apply_rotate(self, node: Any, delta: Vec3) -> None: """Rotate the selected node by *delta* (radians) per axis.""" if hasattr(node, "rotation"): cur = node.rotation if isinstance(cur, int | float): # Node2D — single float (radians), use Z component node.rotation = cur + delta.z else: # Node3D — Quat: apply incremental euler rotation in radians from simvx.core import Quat euler = cur.euler_angles() node.rotation = Quat.from_euler(euler.x + delta.x, euler.y + delta.y, euler.z + delta.z) def _apply_scale(self, node: Any, delta: Vec3) -> None: """Scale the selected node by additive *delta* per axis.""" if hasattr(node, "scale"): node.scale = Vec3( node.scale.x + delta.x, node.scale.y + delta.y, node.scale.z + delta.z, ) def _snapshot_transform(self, node: Any) -> None: """Capture pre-drag transform for undo.""" self._drag_start_position = Vec3(node.position.x, node.position.y, node.position.z) if hasattr(node, "rotation"): cur = node.rotation if isinstance(cur, int | float): self._drag_start_rotation = cur else: from simvx.core import Quat self._drag_start_rotation = Quat(cur) else: self._drag_start_rotation = None self._drag_start_scale = Vec3(node.scale.x, node.scale.y, node.scale.z) if hasattr(node, "scale") else None def _push_undo(self, node: Any) -> None: """Push undo commands for the completed drag operation.""" mode = self.gizmo.mode if mode is GizmoMode.TRANSLATE and self._drag_start_position is not None: old = self._drag_start_position new = Vec3(node.position.x, node.position.y, node.position.z) if old.x != new.x or old.y != new.y or old.z != new.z: cmd = PropertyCommand(node, "position", old, new, description="Move node") # Push without executing — transform already applied self.undo_stack._undo.append(cmd) self.undo_stack._redo.clear() self.undo_stack.changed.emit() elif mode is GizmoMode.ROTATE and self._drag_start_rotation is not None: old = self._drag_start_rotation new = node.rotation if isinstance(old, int | float): changed = old != new else: from simvx.core import Quat new = Quat(new) changed = old.x != new.x or old.y != new.y or old.z != new.z or old.w != new.w if changed: cmd = PropertyCommand(node, "rotation", old, new, description="Rotate node") self.undo_stack._undo.append(cmd) self.undo_stack._redo.clear() self.undo_stack.changed.emit() elif mode is GizmoMode.SCALE and self._drag_start_scale is not None: old = self._drag_start_scale new = Vec3(node.scale.x, node.scale.y, node.scale.z) if old.x != new.x or old.y != new.y or old.z != new.z: cmd = PropertyCommand(node, "scale", old, new, description="Scale node") self.undo_stack._undo.append(cmd) self.undo_stack._redo.clear() self.undo_stack.changed.emit() self._drag_start_position = None self._drag_start_rotation = None self._drag_start_scale = None