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