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 twist = frame.navigation.twist;
146                    let rot = if let Some(ax) = session.axis {
147                        if session.exclude_axis {
148                            // Excluded axis: rotate around the dominant of the two remaining axes.
149                            let (ax1, ax2) = solvers::excluded_axes(ax);
150                            let a1 = solvers::drag_onto_rotation(pointer_delta, ax1, camera_view);
151                            let a2 = solvers::drag_onto_rotation(pointer_delta, ax2, camera_view);
152                            let (chosen_axis, drag_angle) = if a1.abs() >= a2.abs() {
153                                (ax1, a1)
154                            } else {
155                                (ax2, a2)
156                            };
157                            glam::Quat::from_axis_angle(chosen_axis, drag_angle + twist)
158                        } else {
159                            // Constrained to a single axis: angular sweep around screen center.
160                            let axis_world = solvers::gizmo_axis_to_vec3(ax);
161                            let angle = solvers::angular_rotation_from_cursor(
162                                ctx.cursor_viewport,
163                                pointer_delta,
164                                session.gizmo_center,
165                                axis_world,
166                                view_proj,
167                                ctx.viewport_size,
168                                camera_view,
169                            ) + twist;
170                            glam::Quat::from_axis_angle(axis_world, angle)
171                        }
172                    } else {
173                        // Unconstrained: rotate around camera view direction.
174                        let view_dir = (ctx.camera.center - ctx.camera.eye_position()).normalize();
175                        glam::Quat::from_axis_angle(view_dir, pointer_delta.x * 0.01 + twist)
176                    };
177                    delta.rotation = rot;
178                }
179
180                ManipulationKind::Scale => {
181                    // Project the pivot into viewport-pixel space.
182                    let ndc = view_proj.project_point3(session.gizmo_center);
183                    let center_screen = glam::Vec2::new(
184                        (ndc.x + 1.0) * 0.5 * ctx.viewport_size.x,
185                        (1.0 - ndc.y) * 0.5 * ctx.viewport_size.y,
186                    );
187
188                    // Cumulative scale factor = current distance / anchor distance.
189                    // Moving toward the centre shrinks; moving away (or passing through
190                    // and out the other side) grows.
191                    let cumulative = match (ctx.cursor_viewport, session.cursor_anchor) {
192                        (Some(cursor), Some(anchor)) => {
193                            let dist_anchor = (anchor - center_screen).length();
194                            let dist_now = (cursor - center_screen).length();
195                            if dist_anchor > 2.0 {
196                                (dist_now / dist_anchor).max(0.001)
197                            } else {
198                                1.0
199                            }
200                        }
201                        _ => {
202                            // Fallback when cursor is unavailable: integrate pointer_delta.
203                            (session.last_scale_factor
204                                * (1.0 + pointer_delta.x * 4.0 / ctx.viewport_size.x.max(1.0)))
205                            .max(0.001)
206                        }
207                    };
208
209                    // Convert cumulative -> per-frame incremental so the app can keep
210                    // multiplying each frame as before.
211                    let incr = (cumulative / session.last_scale_factor).max(0.001);
212                    session.last_scale_factor = cumulative;
213
214                    delta.scale = match (session.axis, session.exclude_axis) {
215                        (None, _) => glam::Vec3::splat(incr),
216                        (Some(GizmoAxis::X), false) => glam::Vec3::new(incr, 1.0, 1.0),
217                        (Some(GizmoAxis::Y), false) => glam::Vec3::new(1.0, incr, 1.0),
218                        (Some(_), false) => glam::Vec3::new(1.0, 1.0, incr),
219                        (Some(GizmoAxis::X), true) => glam::Vec3::new(1.0, incr, incr),
220                        (Some(GizmoAxis::Y), true) => glam::Vec3::new(incr, 1.0, incr),
221                        (Some(_), true) => glam::Vec3::new(incr, incr, 1.0),
222                    };
223
224                    // Numeric scale override.
225                    if let Some(ref numeric) = session.numeric {
226                        delta.scale_override = numeric.parsed_values();
227                    }
228                }
229            }
230
231            return ManipResult::Update(delta);
232        }
233
234        // No active session : check for session starts.
235
236        // 6. Gizmo drag start.
237        if ctx.drag_started {
238            if let (Some(gizmo_info), Some(center), Some(cursor)) =
239                (&ctx.gizmo, ctx.selection_center, ctx.cursor_viewport)
240            {
241                let camera_view = ctx.camera.view_matrix();
242                let view_proj = ctx.camera.proj_matrix() * camera_view;
243
244                // Build a ray from the cursor position.
245                let ray_origin = ctx.camera.eye_position();
246                let ray_dir =
247                    unproject_cursor_to_ray(cursor, &ctx.camera, view_proj, ctx.viewport_size);
248
249                let temp_gizmo = Gizmo {
250                    mode: gizmo_info.mode,
251                    space: GizmoSpace::World,
252                    hovered_axis: GizmoAxis::None,
253                    active_axis: GizmoAxis::None,
254                    drag_start_mouse: None,
255                    pivot_mode: crate::interaction::gizmo::PivotMode::SelectionCentroid,
256                };
257                let hit = temp_gizmo.hit_test_oriented(
258                    ray_origin,
259                    ray_dir,
260                    gizmo_info.center,
261                    gizmo_info.scale,
262                    gizmo_info.orientation,
263                );
264
265                if hit != GizmoAxis::None {
266                    let kind = match gizmo_info.mode {
267                        GizmoMode::Translate => ManipulationKind::Move,
268                        GizmoMode::Rotate => ManipulationKind::Rotate,
269                        GizmoMode::Scale => ManipulationKind::Scale,
270                    };
271                    self.session = Some(ManipulationSession {
272                        kind,
273                        axis: Some(hit),
274                        exclude_axis: false,
275                        numeric: None,
276                        is_gizmo_drag: true,
277                        gizmo_center: center,
278                        cursor_anchor: ctx.cursor_viewport,
279                        cursor_last_total: glam::Vec2::ZERO,
280                        last_scale_factor: 1.0,
281                    });
282                    return ManipResult::None;
283                }
284            }
285        }
286
287        // 7. G/R/S keyboard shortcuts.
288        if let Some(center) = ctx.selection_center {
289            let kind = if frame.is_active(Action::BeginMove) {
290                Some(ManipulationKind::Move)
291            } else if frame.is_active(Action::BeginRotate) {
292                Some(ManipulationKind::Rotate)
293            } else if frame.is_active(Action::BeginScale) {
294                Some(ManipulationKind::Scale)
295            } else {
296                None
297            };
298
299            if let Some(kind) = kind {
300                self.session = Some(ManipulationSession {
301                    kind,
302                    axis: None,
303                    exclude_axis: false,
304                    numeric: None,
305                    is_gizmo_drag: false,
306                    gizmo_center: center,
307                    cursor_anchor: ctx.cursor_viewport,
308                    cursor_last_total: glam::Vec2::ZERO,
309                    last_scale_factor: 1.0,
310                });
311                return ManipResult::None;
312            }
313        }
314
315        ManipResult::None
316    }
317
318    /// Returns `true` when a manipulation session is in progress.
319    ///
320    /// Use this to suppress camera orbit:
321    /// ```rust,ignore
322    /// if manip.is_active() { orbit.resolve() } else { orbit.apply_to_camera(&mut cam) }
323    /// ```
324    pub fn is_active(&self) -> bool {
325        self.session.is_some()
326    }
327
328    /// Returns an inspectable snapshot of the current session, or `None` when idle.
329    pub fn state(&self) -> Option<ManipulationState> {
330        self.session.as_ref().map(|s| s.to_state())
331    }
332
333    /// Force-begin a manipulation (e.g. from a UI button).
334    ///
335    /// No-op if a session is already active.
336    pub fn begin(&mut self, kind: ManipulationKind, center: glam::Vec3) {
337        if self.session.is_some() {
338            return;
339        }
340        self.session = Some(ManipulationSession {
341            kind,
342            axis: None,
343            exclude_axis: false,
344            numeric: None,
345            is_gizmo_drag: false,
346            gizmo_center: center,
347            cursor_anchor: None,
348            cursor_last_total: glam::Vec2::ZERO,
349            last_scale_factor: 1.0,
350        });
351    }
352
353    /// Force-cancel any active session without emitting [`ManipResult::Cancel`].
354    pub fn reset(&mut self) {
355        self.session = None;
356    }
357}
358
359impl Default for ManipulationController {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365// ---------------------------------------------------------------------------
366// Internal helpers
367// ---------------------------------------------------------------------------
368
369/// Compute a world-space ray direction from a viewport-local cursor position.
370fn unproject_cursor_to_ray(
371    cursor_viewport: glam::Vec2,
372    camera: &crate::camera::camera::Camera,
373    view_proj: glam::Mat4,
374    viewport_size: glam::Vec2,
375) -> glam::Vec3 {
376    // Convert cursor from viewport pixels (Y-down) to NDC.
377    let ndc_x = (cursor_viewport.x / viewport_size.x.max(1.0)) * 2.0 - 1.0;
378    let ndc_y = 1.0 - (cursor_viewport.y / viewport_size.y.max(1.0)) * 2.0;
379
380    let inv_vp = view_proj.inverse();
381
382    let far_world = inv_vp.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
383
384    // Use the camera eye position for accuracy (same as the gizmo hit-test origin).
385    let eye = camera.eye_position();
386    (far_world - eye).normalize_or(glam::Vec3::NEG_Z)
387}
388
389// ---------------------------------------------------------------------------
390// Tests
391// ---------------------------------------------------------------------------
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::interaction::input::ActionFrame;
397    use session::{NumericInputState, update_constraint};
398
399    fn make_camera() -> crate::camera::camera::Camera {
400        crate::camera::camera::Camera::default()
401    }
402
403    fn idle_ctx() -> ManipulationContext {
404        ManipulationContext {
405            camera: make_camera(),
406            viewport_size: glam::Vec2::new(800.0, 600.0),
407            cursor_viewport: None,
408            pointer_delta: glam::Vec2::ZERO,
409            selection_center: None,
410            gizmo: None,
411            drag_started: false,
412            dragging: false,
413            clicked: false,
414        }
415    }
416
417    // -----------------------------------------------------------------------
418    // Constraint transition tests
419    // -----------------------------------------------------------------------
420
421    #[test]
422    fn constraint_transitions_x_y_shift_z() {
423        let mut session = ManipulationSession {
424            kind: ManipulationKind::Move,
425            axis: None,
426            exclude_axis: false,
427            numeric: None,
428            is_gizmo_drag: false,
429            gizmo_center: glam::Vec3::ZERO,
430            cursor_anchor: None,
431            cursor_last_total: glam::Vec2::ZERO,
432            last_scale_factor: 1.0,
433        };
434
435        // X: constrained, not excluded.
436        update_constraint(&mut session, true, false, false, false, false, false);
437        assert_eq!(session.axis, Some(GizmoAxis::X));
438        assert!(!session.exclude_axis);
439
440        // Y: constrained, not excluded.
441        update_constraint(&mut session, false, true, false, false, false, false);
442        assert_eq!(session.axis, Some(GizmoAxis::Y));
443        assert!(!session.exclude_axis);
444
445        // Shift+Z: excluded.
446        update_constraint(&mut session, false, false, false, false, false, true);
447        assert_eq!(session.axis, Some(GizmoAxis::Z));
448        assert!(session.exclude_axis);
449    }
450
451    // -----------------------------------------------------------------------
452    // Numeric parse test (deferred : Action enum lacks NumericDigit/Backspace/Tab)
453    // -----------------------------------------------------------------------
454
455    #[test]
456    fn numeric_parse_x_axis() {
457        let mut state = NumericInputState::new(Some(GizmoAxis::X), false);
458        state.axis_inputs[0] = "2.50".to_string();
459        let parsed = state.parsed_values();
460        assert_eq!(parsed[0], Some(2.5));
461        assert_eq!(parsed[1], None);
462        assert_eq!(parsed[2], None);
463    }
464
465    #[test]
466    fn numeric_input_bootstraps_on_first_digit() {
467        let mut ctrl = ManipulationController::new();
468        let center = glam::Vec3::new(1.0, 0.0, 0.0);
469        ctrl.begin(ManipulationKind::Move, center);
470        assert!(ctrl.is_active());
471
472        // First digit: bootstrap numeric state.
473        let mut frame = ActionFrame::default();
474        frame.typed_chars.push('2');
475        let mut ctx = idle_ctx();
476        ctx.dragging = false; // not a mouse drag
477        let result = ctrl.update(&frame, ctx);
478        // Should get an Update with a zero translation (numeric override pending parse).
479        assert!(matches!(result, ManipResult::Update(_)));
480        let state = ctrl.state().unwrap();
481        assert!(
482            state.numeric_display.is_some(),
483            "numeric display should be set after first digit"
484        );
485    }
486
487    #[test]
488    fn numeric_backspace_removes_last_digit() {
489        let mut ctrl = ManipulationController::new();
490        ctrl.begin(ManipulationKind::Move, glam::Vec3::ZERO);
491
492        // Type "25".
493        let mut frame = ActionFrame::default();
494        frame.typed_chars.extend(['2', '5']);
495        ctrl.update(&frame, idle_ctx());
496
497        // Backspace once.
498        let mut frame2 = ActionFrame::default();
499        frame2.actions.insert(
500            crate::interaction::input::Action::NumericBackspace,
501            crate::interaction::input::ResolvedActionState::Pressed,
502        );
503        ctrl.update(&frame2, idle_ctx());
504
505        let state = ctrl.state().unwrap();
506        // Should now show "2" only.
507        let display = state.numeric_display.unwrap();
508        assert!(
509            display.contains('2'),
510            "display should contain '2': {display}"
511        );
512        assert!(
513            !display.contains('5'),
514            "display should not contain '5' after backspace: {display}"
515        );
516    }
517
518    // -----------------------------------------------------------------------
519    // angular_rotation_from_cursor sign tests
520    // -----------------------------------------------------------------------
521
522    fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
523        // Camera at (0, 0, 5) looking at origin.
524        let view = glam::Mat4::look_at_rh(
525            glam::Vec3::new(0.0, 0.0, 5.0),
526            glam::Vec3::ZERO,
527            glam::Vec3::Y,
528        );
529        let proj =
530            glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 800.0 / 600.0, 0.1, 100.0);
531        (view, proj * view)
532    }
533
534    #[test]
535    fn angular_rotation_z_toward_camera_cw_is_positive() {
536        // Axis = +Z, camera at +Z => axis points toward camera (axis_z_cam > 0).
537        // CW screen motion (cursor sweeps CW) should produce positive world angle.
538        let (camera_view, view_proj) = make_view_proj_looking_neg_z();
539        let gizmo_center = glam::Vec3::ZERO;
540        let viewport_size = glam::Vec2::new(800.0, 600.0);
541
542        // Place cursor to the right of center, move it upward (CW sweep).
543        let cursor = glam::Vec2::new(500.0, 300.0); // right of screen center
544        let pointer_delta = glam::Vec2::new(0.0, -20.0); // upward = CW for right-side cursor
545
546        let angle = solvers::angular_rotation_from_cursor(
547            Some(cursor),
548            pointer_delta,
549            gizmo_center,
550            glam::Vec3::Z,
551            view_proj,
552            viewport_size,
553            camera_view,
554        );
555        assert!(
556            angle > 0.0,
557            "CW motion with +Z axis (toward camera) should give positive angle, got {angle}"
558        );
559    }
560
561    #[test]
562    fn angular_rotation_neg_z_away_from_camera_cw_is_negative() {
563        // Axis = -Z points away from camera.  Same CW cursor motion should give negative angle.
564        let (camera_view, view_proj) = make_view_proj_looking_neg_z();
565        let gizmo_center = glam::Vec3::ZERO;
566        let viewport_size = glam::Vec2::new(800.0, 600.0);
567
568        let cursor = glam::Vec2::new(500.0, 300.0);
569        let pointer_delta = glam::Vec2::new(0.0, -20.0);
570
571        let angle = solvers::angular_rotation_from_cursor(
572            Some(cursor),
573            pointer_delta,
574            gizmo_center,
575            glam::Vec3::NEG_Z,
576            view_proj,
577            viewport_size,
578            camera_view,
579        );
580        assert!(
581            angle < 0.0,
582            "CW motion with -Z axis (away from camera) should give negative angle, got {angle}"
583        );
584    }
585
586    // -----------------------------------------------------------------------
587    // Controller lifecycle tests
588    // -----------------------------------------------------------------------
589
590    #[test]
591    fn controller_lifecycle_begin_reset() {
592        let mut ctrl = ManipulationController::new();
593        assert!(!ctrl.is_active());
594
595        ctrl.begin(ManipulationKind::Move, glam::Vec3::ZERO);
596        assert!(ctrl.is_active());
597
598        ctrl.reset();
599        assert!(!ctrl.is_active());
600    }
601
602    #[test]
603    fn controller_begin_no_op_when_active() {
604        let mut ctrl = ManipulationController::new();
605        ctrl.begin(ManipulationKind::Move, glam::Vec3::ONE);
606        ctrl.begin(ManipulationKind::Rotate, glam::Vec3::ZERO);
607        // Should still be Move (second begin was no-op).
608        let state = ctrl.state().unwrap();
609        assert_eq!(state.kind, ManipulationKind::Move);
610    }
611
612    #[test]
613    fn controller_idle_returns_none() {
614        let mut ctrl = ManipulationController::new();
615        let frame = ActionFrame::default();
616        let result = ctrl.update(&frame, idle_ctx());
617        assert_eq!(result, ManipResult::None);
618        assert!(!ctrl.is_active());
619    }
620
621    #[test]
622    fn controller_no_session_without_selection_center() {
623        let mut ctrl = ManipulationController::new();
624        // No selection_center -> G/R/S should not start a session.
625        let mut frame = ActionFrame::default();
626        frame.actions.insert(
627            crate::interaction::input::Action::BeginMove,
628            crate::interaction::input::ResolvedActionState::Pressed,
629        );
630        let result = ctrl.update(&frame, idle_ctx());
631        assert_eq!(result, ManipResult::None);
632        assert!(!ctrl.is_active());
633    }
634
635    #[test]
636    fn controller_g_key_starts_move_session() {
637        let mut ctrl = ManipulationController::new();
638        let mut frame = ActionFrame::default();
639        frame.actions.insert(
640            crate::interaction::input::Action::BeginMove,
641            crate::interaction::input::ResolvedActionState::Pressed,
642        );
643        let mut ctx = idle_ctx();
644        ctx.selection_center = Some(glam::Vec3::new(1.0, 2.0, 3.0));
645
646        let result = ctrl.update(&frame, ctx);
647        assert_eq!(result, ManipResult::None); // None on first frame
648        assert!(ctrl.is_active());
649        assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
650    }
651}