Skip to main content

viewport_lib/interaction/manipulation/
mod.rs

1//! Object manipulation controller: move, rotate, and scale with axis constraints.
2//!
3//! # Quick start
4//!
5//! ```rust,ignore
6//! let mut manip = ManipulationController::new();
7//!
8//! // Each frame:
9//! let result = manip.update(&frame, ManipulationContext { ... });
10//! match result {
11//!     ManipResult::Update(delta) => { /* apply delta to selected objects */ }
12//!     ManipResult::Commit        => { /* finalize / push undo */ }
13//!     ManipResult::Cancel        => { /* restore snapshot */ }
14//!     ManipResult::None          => {}
15//! }
16//!
17//! // Suppress orbit while manipulating:
18//! if manip.is_active() {
19//!     orbit_controller.resolve();
20//! } else {
21//!     orbit_controller.apply_to_camera(&mut camera);
22//! }
23//! ```
24
25mod session;
26pub mod solvers;
27pub mod types;
28
29pub use types::*;
30
31use crate::interaction::gizmo::{Gizmo, GizmoAxis, GizmoMode, GizmoSpace};
32use crate::interaction::input::{Action, ActionFrame};
33use session::{ManipulationSession, update_constraint, update_numeric_state};
34
35/// Manages a single object-manipulation session (G/R/S + axis constraints + gizmo drag).
36///
37/// Owns all session state; the app only supplies per-frame context and applies the
38/// resulting [`TransformDelta`].
39pub struct ManipulationController {
40    session: Option<ManipulationSession>,
41}
42
43impl ManipulationController {
44    /// Create a controller with no active session.
45    pub fn new() -> Self {
46        Self { session: None }
47    }
48
49    /// Drive the controller for one frame.
50    ///
51    /// Priority order:
52    /// 1. Confirm (Enter, or left-click while not a gizmo drag) → [`ManipResult::Commit`]
53    /// 2. Cancel (Escape) → [`ManipResult::Cancel`]
54    /// 3. Gizmo drag release → [`ManipResult::Commit`]
55    /// 4. Update constraints and numeric input
56    /// 5. Compute and return [`ManipResult::Update`]
57    /// 6. Gizmo drag start → begins session, returns [`ManipResult::None`] this frame
58    /// 7. G/R/S keys (when `selection_center` is `Some`) → begins session
59    /// 8. Otherwise → [`ManipResult::None`]
60    pub fn update(&mut self, frame: &ActionFrame, ctx: ManipulationContext) -> ManipResult {
61        if let Some(ref mut session) = self.session {
62            // 1. Confirm: Enter key, or left-click when not a gizmo drag.
63            let click_confirm = ctx.clicked && !session.is_gizmo_drag;
64            if frame.is_active(Action::Confirm) || click_confirm {
65                self.session = None;
66                return ManipResult::Commit;
67            }
68
69            // 2. Cancel: Escape key.
70            if frame.is_active(Action::Cancel) {
71                self.session = None;
72                return ManipResult::Cancel;
73            }
74
75            // 3. Gizmo drag released.
76            if session.is_gizmo_drag && !ctx.dragging {
77                self.session = None;
78                return ManipResult::Commit;
79            }
80
81            // 4. Constraint and numeric updates.
82            let axis_before = session.axis;
83            let exclude_before = session.exclude_axis;
84            update_constraint(
85                session,
86                frame.is_active(Action::ConstrainX),
87                frame.is_active(Action::ConstrainY),
88                frame.is_active(Action::ConstrainZ),
89                frame.is_active(Action::ExcludeX),
90                frame.is_active(Action::ExcludeY),
91                frame.is_active(Action::ExcludeZ),
92            );
93            update_numeric_state(session, frame);
94
95            // If the constraint changed, reset the cursor anchor so the next
96            // frame's delta is computed relative to the current cursor position
97            // with the new constraint — and tell the app to restore its snapshot.
98            if session.axis != axis_before || session.exclude_axis != exclude_before {
99                session.cursor_anchor = ctx.cursor_viewport;
100                session.cursor_last_total = glam::Vec2::ZERO;
101                session.last_scale_factor = 1.0;
102                return ManipResult::ConstraintChanged;
103            }
104
105            // 5. Compute delta.
106            //
107            // Prefer absolute-cursor arithmetic over raw pointer_delta so that
108            // the per-frame increment is stable even if the OS coalesces events.
109            // Falls back to ctx.pointer_delta when cursor_viewport is unavailable.
110            let pointer_delta = if session.numeric.is_some() {
111                glam::Vec2::ZERO
112            } else if let (Some(current), Some(anchor)) =
113                (ctx.cursor_viewport, session.cursor_anchor)
114            {
115                let total = current - anchor;
116                let increment = total - session.cursor_last_total;
117                session.cursor_last_total = total;
118                increment
119            } else {
120                ctx.pointer_delta
121            };
122
123            let mut delta = TransformDelta::default();
124
125            let camera_view = ctx.camera.view_matrix();
126            let view_proj = ctx.camera.proj_matrix() * camera_view;
127
128            match session.kind {
129                ManipulationKind::Move => {
130                    delta.translation = solvers::constrained_translation(
131                        pointer_delta,
132                        session.axis,
133                        session.exclude_axis,
134                        session.gizmo_center,
135                        &ctx.camera,
136                        ctx.viewport_size,
137                    );
138                    // Numeric position override.
139                    if let Some(ref numeric) = session.numeric {
140                        delta.position_override = numeric.parsed_values();
141                    }
142                }
143
144                ManipulationKind::Rotate => {
145                    let rot = if let Some(ax) = session.axis {
146                        if session.exclude_axis {
147                            // Excluded axis: rotate around the dominant of the two remaining axes.
148                            let (ax1, ax2) = solvers::excluded_axes(ax);
149                            let a1 = solvers::drag_onto_rotation(pointer_delta, ax1, camera_view);
150                            let a2 = solvers::drag_onto_rotation(pointer_delta, ax2, camera_view);
151                            let (chosen_axis, angle) =
152                                if a1.abs() >= a2.abs() { (ax1, a1) } else { (ax2, a2) };
153                            glam::Quat::from_axis_angle(chosen_axis, angle)
154                        } else {
155                            // Constrained to a single axis: angular sweep around screen center.
156                            let axis_world = solvers::gizmo_axis_to_vec3(ax);
157                            let angle = solvers::angular_rotation_from_cursor(
158                                ctx.cursor_viewport,
159                                pointer_delta,
160                                session.gizmo_center,
161                                axis_world,
162                                view_proj,
163                                ctx.viewport_size,
164                                camera_view,
165                            );
166                            glam::Quat::from_axis_angle(axis_world, angle)
167                        }
168                    } else {
169                        // Unconstrained: rotate around camera view direction.
170                        let view_dir =
171                            (ctx.camera.center - ctx.camera.eye_position()).normalize();
172                        glam::Quat::from_axis_angle(view_dir, pointer_delta.x * 0.01)
173                    };
174                    delta.rotation = rot;
175                }
176
177                ManipulationKind::Scale => {
178                    // Project the pivot into viewport-pixel space.
179                    let ndc = view_proj.project_point3(session.gizmo_center);
180                    let center_screen = glam::Vec2::new(
181                        (ndc.x + 1.0) * 0.5 * ctx.viewport_size.x,
182                        (1.0 - ndc.y) * 0.5 * ctx.viewport_size.y,
183                    );
184
185                    // Cumulative scale factor = current distance / anchor distance.
186                    // Moving toward the centre shrinks; moving away (or passing through
187                    // and out the other side) grows.
188                    let cumulative = match (ctx.cursor_viewport, session.cursor_anchor) {
189                        (Some(cursor), Some(anchor)) => {
190                            let dist_anchor = (anchor - center_screen).length();
191                            let dist_now    = (cursor - center_screen).length();
192                            if dist_anchor > 2.0 {
193                                (dist_now / dist_anchor).max(0.001)
194                            } else {
195                                1.0
196                            }
197                        }
198                        _ => {
199                            // Fallback when cursor is unavailable: integrate pointer_delta.
200                            (session.last_scale_factor
201                                * (1.0 + pointer_delta.x * 4.0 / ctx.viewport_size.x.max(1.0)))
202                                .max(0.001)
203                        }
204                    };
205
206                    // Convert cumulative → per-frame incremental so the app can keep
207                    // multiplying each frame as before.
208                    let incr = (cumulative / session.last_scale_factor).max(0.001);
209                    session.last_scale_factor = cumulative;
210
211                    delta.scale = match (session.axis, session.exclude_axis) {
212                        (None, _)                          => glam::Vec3::splat(incr),
213                        (Some(GizmoAxis::X), false)        => glam::Vec3::new(incr, 1.0, 1.0),
214                        (Some(GizmoAxis::Y), false)        => glam::Vec3::new(1.0, incr, 1.0),
215                        (Some(_), false)                   => glam::Vec3::new(1.0, 1.0, incr),
216                        (Some(GizmoAxis::X), true)         => glam::Vec3::new(1.0, incr, incr),
217                        (Some(GizmoAxis::Y), true)         => glam::Vec3::new(incr, 1.0, incr),
218                        (Some(_), true)                    => glam::Vec3::new(incr, incr, 1.0),
219                    };
220
221                    // Numeric scale override.
222                    if let Some(ref numeric) = session.numeric {
223                        delta.scale_override = numeric.parsed_values();
224                    }
225                }
226            }
227
228            return ManipResult::Update(delta);
229        }
230
231        // No active session — check for session starts.
232
233        // 6. Gizmo drag start.
234        if ctx.drag_started {
235            if let (Some(gizmo_info), Some(center), Some(cursor)) =
236                (&ctx.gizmo, ctx.selection_center, ctx.cursor_viewport)
237            {
238                let camera_view = ctx.camera.view_matrix();
239                let view_proj = ctx.camera.proj_matrix() * camera_view;
240
241                // Build a ray from the cursor position.
242                let ray_origin = ctx.camera.eye_position();
243                let ray_dir =
244                    unproject_cursor_to_ray(cursor, &ctx.camera, view_proj, ctx.viewport_size);
245
246                let temp_gizmo = Gizmo {
247                    mode: gizmo_info.mode,
248                    space: GizmoSpace::World,
249                    hovered_axis: GizmoAxis::None,
250                    active_axis: GizmoAxis::None,
251                    drag_start_mouse: None,
252                    pivot_mode: crate::interaction::gizmo::PivotMode::SelectionCentroid,
253                };
254                let hit = temp_gizmo.hit_test_oriented(
255                    ray_origin,
256                    ray_dir,
257                    gizmo_info.center,
258                    gizmo_info.scale,
259                    gizmo_info.orientation,
260                );
261
262                if hit != GizmoAxis::None {
263                    let kind = match gizmo_info.mode {
264                        GizmoMode::Translate => ManipulationKind::Move,
265                        GizmoMode::Rotate    => ManipulationKind::Rotate,
266                        GizmoMode::Scale     => ManipulationKind::Scale,
267                    };
268                    self.session = Some(ManipulationSession {
269                        kind,
270                        axis: Some(hit),
271                        exclude_axis: false,
272                        numeric: None,
273                        is_gizmo_drag: true,
274                        gizmo_center: center,
275                        cursor_anchor: ctx.cursor_viewport,
276                        cursor_last_total: glam::Vec2::ZERO,
277                        last_scale_factor: 1.0,
278                    });
279                    return ManipResult::None;
280                }
281            }
282        }
283
284        // 7. G/R/S keyboard shortcuts.
285        if let Some(center) = ctx.selection_center {
286            let kind = if frame.is_active(Action::BeginMove) {
287                Some(ManipulationKind::Move)
288            } else if frame.is_active(Action::BeginRotate) {
289                Some(ManipulationKind::Rotate)
290            } else if frame.is_active(Action::BeginScale) {
291                Some(ManipulationKind::Scale)
292            } else {
293                None
294            };
295
296            if let Some(kind) = kind {
297                self.session = Some(ManipulationSession {
298                    kind,
299                    axis: None,
300                    exclude_axis: false,
301                    numeric: None,
302                    is_gizmo_drag: false,
303                    gizmo_center: center,
304                    cursor_anchor: ctx.cursor_viewport,
305                    cursor_last_total: glam::Vec2::ZERO,
306                    last_scale_factor: 1.0,
307                });
308                return ManipResult::None;
309            }
310        }
311
312        ManipResult::None
313    }
314
315    /// Returns `true` when a manipulation session is in progress.
316    ///
317    /// Use this to suppress camera orbit:
318    /// ```rust,ignore
319    /// if manip.is_active() { orbit.resolve() } else { orbit.apply_to_camera(&mut cam) }
320    /// ```
321    pub fn is_active(&self) -> bool {
322        self.session.is_some()
323    }
324
325    /// Returns an inspectable snapshot of the current session, or `None` when idle.
326    pub fn state(&self) -> Option<ManipulationState> {
327        self.session.as_ref().map(|s| s.to_state())
328    }
329
330    /// Force-begin a manipulation (e.g. from a UI button).
331    ///
332    /// No-op if a session is already active.
333    pub fn begin(&mut self, kind: ManipulationKind, center: glam::Vec3) {
334        if self.session.is_some() {
335            return;
336        }
337        self.session = Some(ManipulationSession {
338            kind,
339            axis: None,
340            exclude_axis: false,
341            numeric: None,
342            is_gizmo_drag: false,
343            gizmo_center: center,
344            cursor_anchor: None,
345            cursor_last_total: glam::Vec2::ZERO,
346            last_scale_factor: 1.0,
347        });
348    }
349
350    /// Force-cancel any active session without emitting [`ManipResult::Cancel`].
351    pub fn reset(&mut self) {
352        self.session = None;
353    }
354}
355
356impl Default for ManipulationController {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362// ---------------------------------------------------------------------------
363// Internal helpers
364// ---------------------------------------------------------------------------
365
366/// Compute a world-space ray direction from a viewport-local cursor position.
367fn unproject_cursor_to_ray(
368    cursor_viewport: glam::Vec2,
369    camera: &crate::camera::camera::Camera,
370    view_proj: glam::Mat4,
371    viewport_size: glam::Vec2,
372) -> glam::Vec3 {
373    // Convert cursor from viewport pixels (Y-down) to NDC.
374    let ndc_x = (cursor_viewport.x / viewport_size.x.max(1.0)) * 2.0 - 1.0;
375    let ndc_y = 1.0 - (cursor_viewport.y / viewport_size.y.max(1.0)) * 2.0;
376
377    let inv_vp = view_proj.inverse();
378
379    let far_world = inv_vp.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
380
381    // Use the camera eye position for accuracy (same as the gizmo hit-test origin).
382    let eye = camera.eye_position();
383    (far_world - eye).normalize_or(glam::Vec3::NEG_Z)
384}
385
386// ---------------------------------------------------------------------------
387// Tests
388// ---------------------------------------------------------------------------
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::interaction::input::ActionFrame;
394    use session::{NumericInputState, update_constraint};
395
396    fn make_camera() -> crate::camera::camera::Camera {
397        crate::camera::camera::Camera::default()
398    }
399
400    fn idle_ctx() -> ManipulationContext {
401        ManipulationContext {
402            camera: make_camera(),
403            viewport_size: glam::Vec2::new(800.0, 600.0),
404            cursor_viewport: None,
405            pointer_delta: glam::Vec2::ZERO,
406            selection_center: None,
407            gizmo: None,
408            drag_started: false,
409            dragging: false,
410            clicked: false,
411        }
412    }
413
414    // -----------------------------------------------------------------------
415    // Constraint transition tests
416    // -----------------------------------------------------------------------
417
418    #[test]
419    fn constraint_transitions_x_y_shift_z() {
420        let mut session = ManipulationSession {
421            kind: ManipulationKind::Move,
422            axis: None,
423            exclude_axis: false,
424            numeric: None,
425            is_gizmo_drag: false,
426            gizmo_center: glam::Vec3::ZERO,
427            cursor_anchor: None,
428            cursor_last_total: glam::Vec2::ZERO,
429            last_scale_factor: 1.0,
430        };
431
432        // X: constrained, not excluded.
433        update_constraint(&mut session, true, false, false, false, false, false);
434        assert_eq!(session.axis, Some(GizmoAxis::X));
435        assert!(!session.exclude_axis);
436
437        // Y: constrained, not excluded.
438        update_constraint(&mut session, false, true, false, false, false, false);
439        assert_eq!(session.axis, Some(GizmoAxis::Y));
440        assert!(!session.exclude_axis);
441
442        // Shift+Z: excluded.
443        update_constraint(&mut session, false, false, false, false, false, true);
444        assert_eq!(session.axis, Some(GizmoAxis::Z));
445        assert!(session.exclude_axis);
446    }
447
448    // -----------------------------------------------------------------------
449    // Numeric parse test (deferred — Action enum lacks NumericDigit/Backspace/Tab)
450    // -----------------------------------------------------------------------
451
452    #[test]
453    #[ignore = "numeric input deferred: Action enum lacks NumericDigit/Backspace/Tab variants"]
454    fn numeric_parse_x_axis() {
455        // When numeric input actions are added, this test should verify:
456        //   NumericInputState with axis=Some(X), after typing "2", ".", "5", "0"
457        //   -> parsed_values() returns [Some(2.5), None, None]
458        let mut state = NumericInputState::new(Some(GizmoAxis::X), false);
459        // Simulated digit pushes (would be driven by Action events):
460        state.axis_inputs[0] = "2.50".to_string();
461        let parsed = state.parsed_values();
462        assert_eq!(parsed[0], Some(2.5));
463        assert_eq!(parsed[1], None);
464        assert_eq!(parsed[2], None);
465    }
466
467    // -----------------------------------------------------------------------
468    // angular_rotation_from_cursor sign tests
469    // -----------------------------------------------------------------------
470
471    fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
472        // Camera at (0, 0, 5) looking at origin.
473        let view = glam::Mat4::look_at_rh(
474            glam::Vec3::new(0.0, 0.0, 5.0),
475            glam::Vec3::ZERO,
476            glam::Vec3::Y,
477        );
478        let proj = glam::Mat4::perspective_rh(
479            std::f32::consts::FRAC_PI_4,
480            800.0 / 600.0,
481            0.1,
482            100.0,
483        );
484        (view, proj * view)
485    }
486
487    #[test]
488    fn angular_rotation_z_toward_camera_cw_is_positive() {
489        // Axis = +Z, camera at +Z => axis points toward camera (axis_z_cam > 0).
490        // CW screen motion (cursor sweeps CW) should produce positive world angle.
491        let (camera_view, view_proj) = make_view_proj_looking_neg_z();
492        let gizmo_center = glam::Vec3::ZERO;
493        let viewport_size = glam::Vec2::new(800.0, 600.0);
494
495        // Place cursor to the right of center, move it upward (CW sweep).
496        let cursor = glam::Vec2::new(500.0, 300.0); // right of screen center
497        let pointer_delta = glam::Vec2::new(0.0, -20.0); // upward = CW for right-side cursor
498
499        let angle = solvers::angular_rotation_from_cursor(
500            Some(cursor),
501            pointer_delta,
502            gizmo_center,
503            glam::Vec3::Z,
504            view_proj,
505            viewport_size,
506            camera_view,
507        );
508        assert!(
509            angle > 0.0,
510            "CW motion with +Z axis (toward camera) should give positive angle, got {angle}"
511        );
512    }
513
514    #[test]
515    fn angular_rotation_neg_z_away_from_camera_cw_is_negative() {
516        // Axis = -Z points away from camera.  Same CW cursor motion should give negative angle.
517        let (camera_view, view_proj) = make_view_proj_looking_neg_z();
518        let gizmo_center = glam::Vec3::ZERO;
519        let viewport_size = glam::Vec2::new(800.0, 600.0);
520
521        let cursor = glam::Vec2::new(500.0, 300.0);
522        let pointer_delta = glam::Vec2::new(0.0, -20.0);
523
524        let angle = solvers::angular_rotation_from_cursor(
525            Some(cursor),
526            pointer_delta,
527            gizmo_center,
528            glam::Vec3::NEG_Z,
529            view_proj,
530            viewport_size,
531            camera_view,
532        );
533        assert!(
534            angle < 0.0,
535            "CW motion with -Z axis (away from camera) should give negative angle, got {angle}"
536        );
537    }
538
539    // -----------------------------------------------------------------------
540    // Controller lifecycle tests
541    // -----------------------------------------------------------------------
542
543    #[test]
544    fn controller_lifecycle_begin_reset() {
545        let mut ctrl = ManipulationController::new();
546        assert!(!ctrl.is_active());
547
548        ctrl.begin(ManipulationKind::Move, glam::Vec3::ZERO);
549        assert!(ctrl.is_active());
550
551        ctrl.reset();
552        assert!(!ctrl.is_active());
553    }
554
555    #[test]
556    fn controller_begin_no_op_when_active() {
557        let mut ctrl = ManipulationController::new();
558        ctrl.begin(ManipulationKind::Move, glam::Vec3::ONE);
559        ctrl.begin(ManipulationKind::Rotate, glam::Vec3::ZERO);
560        // Should still be Move (second begin was no-op).
561        let state = ctrl.state().unwrap();
562        assert_eq!(state.kind, ManipulationKind::Move);
563    }
564
565    #[test]
566    fn controller_idle_returns_none() {
567        let mut ctrl = ManipulationController::new();
568        let frame = ActionFrame::default();
569        let result = ctrl.update(&frame, idle_ctx());
570        assert_eq!(result, ManipResult::None);
571        assert!(!ctrl.is_active());
572    }
573
574    #[test]
575    fn controller_no_session_without_selection_center() {
576        let mut ctrl = ManipulationController::new();
577        // No selection_center → G/R/S should not start a session.
578        let mut frame = ActionFrame::default();
579        frame.actions.insert(
580            crate::interaction::input::Action::BeginMove,
581            crate::interaction::input::ResolvedActionState::Pressed,
582        );
583        let result = ctrl.update(&frame, idle_ctx());
584        assert_eq!(result, ManipResult::None);
585        assert!(!ctrl.is_active());
586    }
587
588    #[test]
589    fn controller_g_key_starts_move_session() {
590        let mut ctrl = ManipulationController::new();
591        let mut frame = ActionFrame::default();
592        frame.actions.insert(
593            crate::interaction::input::Action::BeginMove,
594            crate::interaction::input::ResolvedActionState::Pressed,
595        );
596        let mut ctx = idle_ctx();
597        ctx.selection_center = Some(glam::Vec3::new(1.0, 2.0, 3.0));
598
599        let result = ctrl.update(&frame, ctx);
600        assert_eq!(result, ManipResult::None); // None on first frame
601        assert!(ctrl.is_active());
602        assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
603    }
604}