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