Skip to main content

viewport_lib/runtime/systems/
manipulation.rs

1//! Built-in manipulation system for ViewportRuntime.
2
3use std::collections::HashMap;
4
5use crate::interaction::manipulation::GizmoInfo;
6use crate::interaction::manipulation::gizmo::{self, Gizmo, GizmoAxis, GizmoMode};
7use crate::interaction::manipulation::{
8    ManipResult, ManipulationContext, ManipulationController, TransformDelta,
9};
10use crate::interaction::select::selection::{NodeId, Selection};
11use crate::runtime::context::RuntimeFrameContext;
12use crate::runtime::output::{RuntimeOutput, TransformWriteback};
13use crate::scene::scene::Scene;
14
15/// Built-in manipulation system for [`super::super::ViewportRuntime`].
16///
17/// Wraps [`ManipulationController`] and drives it each frame from
18/// [`RuntimeFrameContext`] inputs. Handles G/R/S key sessions and gizmo drag.
19/// Writes transform changes via [`TransformWriteback`]; does not mutate the scene
20/// directly.
21///
22/// To suppress orbit while a session is active, check
23/// [`ViewportRuntime::is_manipulating`](super::super::ViewportRuntime::is_manipulating)
24/// before calling [`OrbitCameraController::apply_to_camera`](crate::camera::controllers::orbit::OrbitCameraController::apply_to_camera).
25pub struct ManipulationSystem {
26    controller: ManipulationController,
27    transforms_snapshot: HashMap<NodeId, glam::Mat4>,
28    pub(crate) gizmo: Gizmo,
29    pub(crate) gizmo_center: Option<glam::Vec3>,
30    pub(crate) gizmo_scale: f32,
31}
32
33impl Default for ManipulationSystem {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl ManipulationSystem {
40    /// Create a new ManipulationSystem with default settings.
41    pub fn new() -> Self {
42        Self {
43            controller: ManipulationController::new(),
44            transforms_snapshot: HashMap::new(),
45            gizmo: Gizmo::default(),
46            gizmo_center: None,
47            gizmo_scale: 1.0,
48        }
49    }
50
51    /// True while a G/R/S session or gizmo drag is in progress.
52    pub fn is_active(&self) -> bool {
53        self.controller.is_active()
54    }
55
56    /// World-space center of the current selection, or None if selection is empty.
57    pub fn gizmo_center(&self) -> Option<glam::Vec3> {
58        self.gizmo_center
59    }
60
61    /// Screen-size scale factor for the gizmo arms.
62    pub fn gizmo_scale(&self) -> f32 {
63        self.gizmo_scale
64    }
65
66    /// Current gizmo mode (Translate / Rotate / Scale).
67    pub fn gizmo_mode(&self) -> GizmoMode {
68        self.gizmo.mode
69    }
70
71    /// Currently hovered gizmo axis.
72    pub fn gizmo_hovered(&self) -> GizmoAxis {
73        self.gizmo.hovered_axis
74    }
75
76    /// Build a GizmoInfo from current state, suitable for InteractionFrame.
77    pub fn gizmo_info(&self) -> Option<GizmoInfo> {
78        self.gizmo_center.map(|center| GizmoInfo {
79            center,
80            scale: self.gizmo_scale,
81            orientation: glam::Quat::IDENTITY,
82            mode: self.gizmo.mode,
83        })
84    }
85
86    pub(crate) fn step(
87        &mut self,
88        frame: &RuntimeFrameContext,
89        scene: &Scene,
90        selection: &Selection,
91        writeback: &mut TransformWriteback,
92        _output: &mut RuntimeOutput,
93    ) {
94        // Compute gizmo center from selection.
95        self.gizmo_center = gizmo::gizmo_center_from_selection(selection, |id| {
96            scene.node(id).map(|n| {
97                let t = n.world_transform();
98                glam::Vec3::new(t.w_axis.x, t.w_axis.y, t.w_axis.z)
99            })
100        });
101
102        // Compute gizmo scale.
103        if let Some(center) = self.gizmo_center {
104            self.gizmo_scale = gizmo::compute_gizmo_scale(
105                center,
106                frame.camera.eye_position(),
107                frame.camera.fov_y,
108                frame.viewport_size.y,
109            );
110        }
111
112        // Gizmo hover hit-test (only when no session is active).
113        if !self.controller.is_active() {
114            if let (Some(center), Some(cursor)) = (self.gizmo_center, frame.cursor_viewport) {
115                let w = frame.viewport_size.x.max(1.0);
116                let h = frame.viewport_size.y.max(1.0);
117                let view_proj = frame.camera.proj_matrix() * frame.camera.view_matrix();
118                let inv_vp = view_proj.inverse();
119                let ndc_x = (cursor.x / w) * 2.0 - 1.0;
120                let ndc_y = 1.0 - (cursor.y / h) * 2.0;
121                let far = inv_vp.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
122                let ray_origin = frame.camera.eye_position();
123                let ray_dir = (far - ray_origin).normalize_or_zero();
124                self.gizmo.hovered_axis = self.gizmo.hit_test_oriented(
125                    ray_origin,
126                    ray_dir,
127                    center,
128                    self.gizmo_scale,
129                    glam::Quat::IDENTITY,
130                );
131            } else {
132                self.gizmo.hovered_axis = GizmoAxis::None;
133            }
134        }
135
136        // Build ManipulationContext.
137        let gizmo_info = self.gizmo_center.map(|center| GizmoInfo {
138            center,
139            scale: self.gizmo_scale,
140            orientation: glam::Quat::IDENTITY,
141            mode: self.gizmo.mode,
142        });
143        let manip_ctx = ManipulationContext {
144            camera: frame.camera.clone(),
145            viewport_size: frame.viewport_size,
146            cursor_viewport: frame.cursor_viewport,
147            pointer_delta: frame.pointer_delta,
148            selection_center: self.gizmo_center,
149            gizmo: gizmo_info,
150            drag_started: frame.drag_started,
151            dragging: frame.dragging,
152            clicked: frame.clicked,
153        };
154
155        let result = self.controller.update(&frame.input, manip_ctx);
156
157        match result {
158            ManipResult::Update(delta) => {
159                self.apply_delta(&delta, scene, selection, writeback);
160            }
161            ManipResult::Commit => {
162                self.save_snapshots(scene, selection);
163            }
164            ManipResult::Cancel | ManipResult::ConstraintChanged => {
165                self.restore_snapshots(scene, writeback);
166            }
167            ManipResult::None => {
168                if !self.controller.is_active() {
169                    self.save_snapshots(scene, selection);
170                }
171            }
172        }
173    }
174
175    fn save_snapshots(&mut self, scene: &Scene, selection: &Selection) {
176        self.transforms_snapshot.clear();
177        for &id in selection.iter() {
178            if let Some(node) = scene.node(id) {
179                // Snapshot the WORLD transform: apply_delta operates in
180                // world space (the gizmo pivot is a world point) and
181                // converts the result back to local for writeback. Storing
182                // world here keeps that math consistent across drag and
183                // cancel.
184                self.transforms_snapshot.insert(id, node.world_transform());
185            }
186        }
187    }
188
189    fn restore_snapshots(&self, scene: &Scene, writeback: &mut TransformWriteback) {
190        for (&id, &world_snap) in &self.transforms_snapshot {
191            let parent_world = parent_world_of(scene, id);
192            let local = parent_world.inverse() * world_snap;
193            writeback.set(id, glam::Affine3A::from_mat4(local));
194        }
195    }
196
197    fn apply_delta(
198        &self,
199        delta: &TransformDelta,
200        scene: &Scene,
201        selection: &Selection,
202        writeback: &mut TransformWriteback,
203    ) {
204        let Some(center) = self.gizmo_center else {
205            return;
206        };
207
208        let has_pos_override = delta.position_override.iter().any(|v| v.is_some());
209        let has_scale_override = delta.scale_override.iter().any(|v| v.is_some());
210
211        let rot_mat = glam::Mat4::from_quat(delta.rotation);
212        let scale_mat = glam::Mat4::from_scale(delta.scale);
213        let translate_mat = glam::Mat4::from_translation(delta.translation);
214        let to_pivot = glam::Mat4::from_translation(-center);
215        let from_pivot = glam::Mat4::from_translation(center);
216
217        for &id in selection.iter() {
218            // Operate in WORLD space so the pivot-centred rotation lands
219            // on the right point regardless of how deep in the scene
220            // hierarchy this node sits. Snapshots already hold the
221            // world transform for numeric-override sessions.
222            let world_base = if has_pos_override || has_scale_override {
223                match self.transforms_snapshot.get(&id) {
224                    Some(&snap) => snap,
225                    None => continue,
226                }
227            } else {
228                match scene.node(id) {
229                    Some(n) => n.world_transform(),
230                    None => continue,
231                }
232            };
233
234            let mut new_world =
235                translate_mat * from_pivot * rot_mat * scale_mat * to_pivot * world_base;
236
237            // Apply per-axis position overrides (numeric input, e.g. G X 2).
238            for (i, &ov) in delta.position_override.iter().enumerate() {
239                if let Some(v) = ov {
240                    new_world.col_mut(3)[i] = v;
241                }
242            }
243
244            // Convert back to parent-local for the scene writeback. Scene
245            // graph propagation re-applies the parent's world transform
246            // on the next frame so the visible world result matches
247            // `new_world` exactly. For root-parented nodes this is
248            // identity * new_world == new_world, so the prior behaviour
249            // is preserved.
250            let parent_world = parent_world_of(scene, id);
251            let new_local = parent_world.inverse() * new_world;
252
253            writeback.set(id, glam::Affine3A::from_mat4(new_local));
254        }
255    }
256}
257
258/// World transform of a node's parent, or identity for root-parented nodes
259/// and missing parents (defensive: a broken parent ref shouldn't crash
260/// manipulation, just collapse to the historical "treat-as-world" branch).
261fn parent_world_of(scene: &Scene, id: NodeId) -> glam::Mat4 {
262    let Some(parent_id) = scene.node(id).and_then(|n| n.parent()) else {
263        return glam::Mat4::IDENTITY;
264    };
265    scene
266        .node(parent_id)
267        .map(|n| n.world_transform())
268        .unwrap_or(glam::Mat4::IDENTITY)
269}