viewport-lib 0.4.0

3D viewport rendering library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
//! Object manipulation controller: move, rotate, and scale with axis constraints.
//!
//! # Quick start
//!
//! ```rust,ignore
//! let mut manip = ManipulationController::new();
//!
//! // Each frame:
//! let result = manip.update(&frame, ManipulationContext { ... });
//! match result {
//!     ManipResult::Update(delta) => { /* apply delta to selected objects */ }
//!     ManipResult::Commit        => { /* finalize / push undo */ }
//!     ManipResult::Cancel        => { /* restore snapshot */ }
//!     ManipResult::None          => {}
//! }
//!
//! // Suppress orbit while manipulating:
//! if manip.is_active() {
//!     orbit_controller.resolve();
//! } else {
//!     orbit_controller.apply_to_camera(&mut camera);
//! }
//! ```

mod session;
pub mod solvers;
pub mod types;

pub use types::*;

use crate::interaction::gizmo::{Gizmo, GizmoAxis, GizmoMode, GizmoSpace};
use crate::interaction::input::{Action, ActionFrame};
use session::{ManipulationSession, update_constraint, update_numeric_state};

/// Manages a single object-manipulation session (G/R/S + axis constraints + gizmo drag).
///
/// Owns all session state; the app only supplies per-frame context and applies the
/// resulting [`TransformDelta`].
pub struct ManipulationController {
    session: Option<ManipulationSession>,
}

impl ManipulationController {
    /// Create a controller with no active session.
    pub fn new() -> Self {
        Self { session: None }
    }

    /// Drive the controller for one frame.
    ///
    /// Priority order:
    /// 1. Confirm (Enter, or left-click while not a gizmo drag) → [`ManipResult::Commit`]
    /// 2. Cancel (Escape) → [`ManipResult::Cancel`]
    /// 3. Gizmo drag release → [`ManipResult::Commit`]
    /// 4. Update constraints and numeric input
    /// 5. Compute and return [`ManipResult::Update`]
    /// 6. Gizmo drag start → begins session, returns [`ManipResult::None`] this frame
    /// 7. G/R/S keys (when `selection_center` is `Some`) → begins session
    /// 8. Otherwise → [`ManipResult::None`]
    pub fn update(&mut self, frame: &ActionFrame, ctx: ManipulationContext) -> ManipResult {
        if let Some(ref mut session) = self.session {
            // 1. Confirm: Enter key, or left-click when not a gizmo drag.
            let click_confirm = ctx.clicked && !session.is_gizmo_drag;
            if frame.is_active(Action::Confirm) || click_confirm {
                self.session = None;
                return ManipResult::Commit;
            }

            // 2. Cancel: Escape key.
            if frame.is_active(Action::Cancel) {
                self.session = None;
                return ManipResult::Cancel;
            }

            // 3. Gizmo drag released.
            if session.is_gizmo_drag && !ctx.dragging {
                self.session = None;
                return ManipResult::Commit;
            }

            // 4. Constraint and numeric updates.
            let axis_before = session.axis;
            let exclude_before = session.exclude_axis;
            update_constraint(
                session,
                frame.is_active(Action::ConstrainX),
                frame.is_active(Action::ConstrainY),
                frame.is_active(Action::ConstrainZ),
                frame.is_active(Action::ExcludeX),
                frame.is_active(Action::ExcludeY),
                frame.is_active(Action::ExcludeZ),
            );
            update_numeric_state(session, frame);

            // If the constraint changed, reset the cursor anchor so the next
            // frame's delta is computed relative to the current cursor position
            // with the new constraint — and tell the app to restore its snapshot.
            if session.axis != axis_before || session.exclude_axis != exclude_before {
                session.cursor_anchor = ctx.cursor_viewport;
                session.cursor_last_total = glam::Vec2::ZERO;
                session.last_scale_factor = 1.0;
                return ManipResult::ConstraintChanged;
            }

            // 5. Compute delta.
            //
            // Prefer absolute-cursor arithmetic over raw pointer_delta so that
            // the per-frame increment is stable even if the OS coalesces events.
            // Falls back to ctx.pointer_delta when cursor_viewport is unavailable.
            let pointer_delta = if session.numeric.is_some() {
                glam::Vec2::ZERO
            } else if let (Some(current), Some(anchor)) =
                (ctx.cursor_viewport, session.cursor_anchor)
            {
                let total = current - anchor;
                let increment = total - session.cursor_last_total;
                session.cursor_last_total = total;
                increment
            } else {
                ctx.pointer_delta
            };

            let mut delta = TransformDelta::default();

            let camera_view = ctx.camera.view_matrix();
            let view_proj = ctx.camera.proj_matrix() * camera_view;

            match session.kind {
                ManipulationKind::Move => {
                    delta.translation = solvers::constrained_translation(
                        pointer_delta,
                        session.axis,
                        session.exclude_axis,
                        session.gizmo_center,
                        &ctx.camera,
                        ctx.viewport_size,
                    );
                    // Numeric position override.
                    if let Some(ref numeric) = session.numeric {
                        delta.position_override = numeric.parsed_values();
                    }
                }

                ManipulationKind::Rotate => {
                    let rot = if let Some(ax) = session.axis {
                        if session.exclude_axis {
                            // Excluded axis: rotate around the dominant of the two remaining axes.
                            let (ax1, ax2) = solvers::excluded_axes(ax);
                            let a1 = solvers::drag_onto_rotation(pointer_delta, ax1, camera_view);
                            let a2 = solvers::drag_onto_rotation(pointer_delta, ax2, camera_view);
                            let (chosen_axis, angle) =
                                if a1.abs() >= a2.abs() { (ax1, a1) } else { (ax2, a2) };
                            glam::Quat::from_axis_angle(chosen_axis, angle)
                        } else {
                            // Constrained to a single axis: angular sweep around screen center.
                            let axis_world = solvers::gizmo_axis_to_vec3(ax);
                            let angle = solvers::angular_rotation_from_cursor(
                                ctx.cursor_viewport,
                                pointer_delta,
                                session.gizmo_center,
                                axis_world,
                                view_proj,
                                ctx.viewport_size,
                                camera_view,
                            );
                            glam::Quat::from_axis_angle(axis_world, angle)
                        }
                    } else {
                        // Unconstrained: rotate around camera view direction.
                        let view_dir =
                            (ctx.camera.center - ctx.camera.eye_position()).normalize();
                        glam::Quat::from_axis_angle(view_dir, pointer_delta.x * 0.01)
                    };
                    delta.rotation = rot;
                }

                ManipulationKind::Scale => {
                    // Project the pivot into viewport-pixel space.
                    let ndc = view_proj.project_point3(session.gizmo_center);
                    let center_screen = glam::Vec2::new(
                        (ndc.x + 1.0) * 0.5 * ctx.viewport_size.x,
                        (1.0 - ndc.y) * 0.5 * ctx.viewport_size.y,
                    );

                    // Cumulative scale factor = current distance / anchor distance.
                    // Moving toward the centre shrinks; moving away (or passing through
                    // and out the other side) grows.
                    let cumulative = match (ctx.cursor_viewport, session.cursor_anchor) {
                        (Some(cursor), Some(anchor)) => {
                            let dist_anchor = (anchor - center_screen).length();
                            let dist_now    = (cursor - center_screen).length();
                            if dist_anchor > 2.0 {
                                (dist_now / dist_anchor).max(0.001)
                            } else {
                                1.0
                            }
                        }
                        _ => {
                            // Fallback when cursor is unavailable: integrate pointer_delta.
                            (session.last_scale_factor
                                * (1.0 + pointer_delta.x * 4.0 / ctx.viewport_size.x.max(1.0)))
                                .max(0.001)
                        }
                    };

                    // Convert cumulative → per-frame incremental so the app can keep
                    // multiplying each frame as before.
                    let incr = (cumulative / session.last_scale_factor).max(0.001);
                    session.last_scale_factor = cumulative;

                    delta.scale = match (session.axis, session.exclude_axis) {
                        (None, _)                          => glam::Vec3::splat(incr),
                        (Some(GizmoAxis::X), false)        => glam::Vec3::new(incr, 1.0, 1.0),
                        (Some(GizmoAxis::Y), false)        => glam::Vec3::new(1.0, incr, 1.0),
                        (Some(_), false)                   => glam::Vec3::new(1.0, 1.0, incr),
                        (Some(GizmoAxis::X), true)         => glam::Vec3::new(1.0, incr, incr),
                        (Some(GizmoAxis::Y), true)         => glam::Vec3::new(incr, 1.0, incr),
                        (Some(_), true)                    => glam::Vec3::new(incr, incr, 1.0),
                    };

                    // Numeric scale override.
                    if let Some(ref numeric) = session.numeric {
                        delta.scale_override = numeric.parsed_values();
                    }
                }
            }

            return ManipResult::Update(delta);
        }

        // No active session — check for session starts.

        // 6. Gizmo drag start.
        if ctx.drag_started {
            if let (Some(gizmo_info), Some(center), Some(cursor)) =
                (&ctx.gizmo, ctx.selection_center, ctx.cursor_viewport)
            {
                let camera_view = ctx.camera.view_matrix();
                let view_proj = ctx.camera.proj_matrix() * camera_view;

                // Build a ray from the cursor position.
                let ray_origin = ctx.camera.eye_position();
                let ray_dir =
                    unproject_cursor_to_ray(cursor, &ctx.camera, view_proj, ctx.viewport_size);

                let temp_gizmo = Gizmo {
                    mode: gizmo_info.mode,
                    space: GizmoSpace::World,
                    hovered_axis: GizmoAxis::None,
                    active_axis: GizmoAxis::None,
                    drag_start_mouse: None,
                    pivot_mode: crate::interaction::gizmo::PivotMode::SelectionCentroid,
                };
                let hit = temp_gizmo.hit_test_oriented(
                    ray_origin,
                    ray_dir,
                    gizmo_info.center,
                    gizmo_info.scale,
                    gizmo_info.orientation,
                );

                if hit != GizmoAxis::None {
                    let kind = match gizmo_info.mode {
                        GizmoMode::Translate => ManipulationKind::Move,
                        GizmoMode::Rotate    => ManipulationKind::Rotate,
                        GizmoMode::Scale     => ManipulationKind::Scale,
                    };
                    self.session = Some(ManipulationSession {
                        kind,
                        axis: Some(hit),
                        exclude_axis: false,
                        numeric: None,
                        is_gizmo_drag: true,
                        gizmo_center: center,
                        cursor_anchor: ctx.cursor_viewport,
                        cursor_last_total: glam::Vec2::ZERO,
                        last_scale_factor: 1.0,
                    });
                    return ManipResult::None;
                }
            }
        }

        // 7. G/R/S keyboard shortcuts.
        if let Some(center) = ctx.selection_center {
            let kind = if frame.is_active(Action::BeginMove) {
                Some(ManipulationKind::Move)
            } else if frame.is_active(Action::BeginRotate) {
                Some(ManipulationKind::Rotate)
            } else if frame.is_active(Action::BeginScale) {
                Some(ManipulationKind::Scale)
            } else {
                None
            };

            if let Some(kind) = kind {
                self.session = Some(ManipulationSession {
                    kind,
                    axis: None,
                    exclude_axis: false,
                    numeric: None,
                    is_gizmo_drag: false,
                    gizmo_center: center,
                    cursor_anchor: ctx.cursor_viewport,
                    cursor_last_total: glam::Vec2::ZERO,
                    last_scale_factor: 1.0,
                });
                return ManipResult::None;
            }
        }

        ManipResult::None
    }

    /// Returns `true` when a manipulation session is in progress.
    ///
    /// Use this to suppress camera orbit:
    /// ```rust,ignore
    /// if manip.is_active() { orbit.resolve() } else { orbit.apply_to_camera(&mut cam) }
    /// ```
    pub fn is_active(&self) -> bool {
        self.session.is_some()
    }

    /// Returns an inspectable snapshot of the current session, or `None` when idle.
    pub fn state(&self) -> Option<ManipulationState> {
        self.session.as_ref().map(|s| s.to_state())
    }

    /// Force-begin a manipulation (e.g. from a UI button).
    ///
    /// No-op if a session is already active.
    pub fn begin(&mut self, kind: ManipulationKind, center: glam::Vec3) {
        if self.session.is_some() {
            return;
        }
        self.session = Some(ManipulationSession {
            kind,
            axis: None,
            exclude_axis: false,
            numeric: None,
            is_gizmo_drag: false,
            gizmo_center: center,
            cursor_anchor: None,
            cursor_last_total: glam::Vec2::ZERO,
            last_scale_factor: 1.0,
        });
    }

    /// Force-cancel any active session without emitting [`ManipResult::Cancel`].
    pub fn reset(&mut self) {
        self.session = None;
    }
}

impl Default for ManipulationController {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/// Compute a world-space ray direction from a viewport-local cursor position.
fn unproject_cursor_to_ray(
    cursor_viewport: glam::Vec2,
    camera: &crate::camera::camera::Camera,
    view_proj: glam::Mat4,
    viewport_size: glam::Vec2,
) -> glam::Vec3 {
    // Convert cursor from viewport pixels (Y-down) to NDC.
    let ndc_x = (cursor_viewport.x / viewport_size.x.max(1.0)) * 2.0 - 1.0;
    let ndc_y = 1.0 - (cursor_viewport.y / viewport_size.y.max(1.0)) * 2.0;

    let inv_vp = view_proj.inverse();

    let far_world = inv_vp.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));

    // Use the camera eye position for accuracy (same as the gizmo hit-test origin).
    let eye = camera.eye_position();
    (far_world - eye).normalize_or(glam::Vec3::NEG_Z)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::interaction::input::ActionFrame;
    use session::{NumericInputState, update_constraint};

    fn make_camera() -> crate::camera::camera::Camera {
        crate::camera::camera::Camera::default()
    }

    fn idle_ctx() -> ManipulationContext {
        ManipulationContext {
            camera: make_camera(),
            viewport_size: glam::Vec2::new(800.0, 600.0),
            cursor_viewport: None,
            pointer_delta: glam::Vec2::ZERO,
            selection_center: None,
            gizmo: None,
            drag_started: false,
            dragging: false,
            clicked: false,
        }
    }

    // -----------------------------------------------------------------------
    // Constraint transition tests
    // -----------------------------------------------------------------------

    #[test]
    fn constraint_transitions_x_y_shift_z() {
        let mut session = ManipulationSession {
            kind: ManipulationKind::Move,
            axis: None,
            exclude_axis: false,
            numeric: None,
            is_gizmo_drag: false,
            gizmo_center: glam::Vec3::ZERO,
            cursor_anchor: None,
            cursor_last_total: glam::Vec2::ZERO,
            last_scale_factor: 1.0,
        };

        // X: constrained, not excluded.
        update_constraint(&mut session, true, false, false, false, false, false);
        assert_eq!(session.axis, Some(GizmoAxis::X));
        assert!(!session.exclude_axis);

        // Y: constrained, not excluded.
        update_constraint(&mut session, false, true, false, false, false, false);
        assert_eq!(session.axis, Some(GizmoAxis::Y));
        assert!(!session.exclude_axis);

        // Shift+Z: excluded.
        update_constraint(&mut session, false, false, false, false, false, true);
        assert_eq!(session.axis, Some(GizmoAxis::Z));
        assert!(session.exclude_axis);
    }

    // -----------------------------------------------------------------------
    // Numeric parse test (deferred — Action enum lacks NumericDigit/Backspace/Tab)
    // -----------------------------------------------------------------------

    #[test]
    #[ignore = "numeric input deferred: Action enum lacks NumericDigit/Backspace/Tab variants"]
    fn numeric_parse_x_axis() {
        // When numeric input actions are added, this test should verify:
        //   NumericInputState with axis=Some(X), after typing "2", ".", "5", "0"
        //   -> parsed_values() returns [Some(2.5), None, None]
        let mut state = NumericInputState::new(Some(GizmoAxis::X), false);
        // Simulated digit pushes (would be driven by Action events):
        state.axis_inputs[0] = "2.50".to_string();
        let parsed = state.parsed_values();
        assert_eq!(parsed[0], Some(2.5));
        assert_eq!(parsed[1], None);
        assert_eq!(parsed[2], None);
    }

    // -----------------------------------------------------------------------
    // angular_rotation_from_cursor sign tests
    // -----------------------------------------------------------------------

    fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
        // Camera at (0, 0, 5) looking at origin.
        let view = glam::Mat4::look_at_rh(
            glam::Vec3::new(0.0, 0.0, 5.0),
            glam::Vec3::ZERO,
            glam::Vec3::Y,
        );
        let proj = glam::Mat4::perspective_rh(
            std::f32::consts::FRAC_PI_4,
            800.0 / 600.0,
            0.1,
            100.0,
        );
        (view, proj * view)
    }

    #[test]
    fn angular_rotation_z_toward_camera_cw_is_positive() {
        // Axis = +Z, camera at +Z => axis points toward camera (axis_z_cam > 0).
        // CW screen motion (cursor sweeps CW) should produce positive world angle.
        let (camera_view, view_proj) = make_view_proj_looking_neg_z();
        let gizmo_center = glam::Vec3::ZERO;
        let viewport_size = glam::Vec2::new(800.0, 600.0);

        // Place cursor to the right of center, move it upward (CW sweep).
        let cursor = glam::Vec2::new(500.0, 300.0); // right of screen center
        let pointer_delta = glam::Vec2::new(0.0, -20.0); // upward = CW for right-side cursor

        let angle = solvers::angular_rotation_from_cursor(
            Some(cursor),
            pointer_delta,
            gizmo_center,
            glam::Vec3::Z,
            view_proj,
            viewport_size,
            camera_view,
        );
        assert!(
            angle > 0.0,
            "CW motion with +Z axis (toward camera) should give positive angle, got {angle}"
        );
    }

    #[test]
    fn angular_rotation_neg_z_away_from_camera_cw_is_negative() {
        // Axis = -Z points away from camera.  Same CW cursor motion should give negative angle.
        let (camera_view, view_proj) = make_view_proj_looking_neg_z();
        let gizmo_center = glam::Vec3::ZERO;
        let viewport_size = glam::Vec2::new(800.0, 600.0);

        let cursor = glam::Vec2::new(500.0, 300.0);
        let pointer_delta = glam::Vec2::new(0.0, -20.0);

        let angle = solvers::angular_rotation_from_cursor(
            Some(cursor),
            pointer_delta,
            gizmo_center,
            glam::Vec3::NEG_Z,
            view_proj,
            viewport_size,
            camera_view,
        );
        assert!(
            angle < 0.0,
            "CW motion with -Z axis (away from camera) should give negative angle, got {angle}"
        );
    }

    // -----------------------------------------------------------------------
    // Controller lifecycle tests
    // -----------------------------------------------------------------------

    #[test]
    fn controller_lifecycle_begin_reset() {
        let mut ctrl = ManipulationController::new();
        assert!(!ctrl.is_active());

        ctrl.begin(ManipulationKind::Move, glam::Vec3::ZERO);
        assert!(ctrl.is_active());

        ctrl.reset();
        assert!(!ctrl.is_active());
    }

    #[test]
    fn controller_begin_no_op_when_active() {
        let mut ctrl = ManipulationController::new();
        ctrl.begin(ManipulationKind::Move, glam::Vec3::ONE);
        ctrl.begin(ManipulationKind::Rotate, glam::Vec3::ZERO);
        // Should still be Move (second begin was no-op).
        let state = ctrl.state().unwrap();
        assert_eq!(state.kind, ManipulationKind::Move);
    }

    #[test]
    fn controller_idle_returns_none() {
        let mut ctrl = ManipulationController::new();
        let frame = ActionFrame::default();
        let result = ctrl.update(&frame, idle_ctx());
        assert_eq!(result, ManipResult::None);
        assert!(!ctrl.is_active());
    }

    #[test]
    fn controller_no_session_without_selection_center() {
        let mut ctrl = ManipulationController::new();
        // No selection_center → G/R/S should not start a session.
        let mut frame = ActionFrame::default();
        frame.actions.insert(
            crate::interaction::input::Action::BeginMove,
            crate::interaction::input::ResolvedActionState::Pressed,
        );
        let result = ctrl.update(&frame, idle_ctx());
        assert_eq!(result, ManipResult::None);
        assert!(!ctrl.is_active());
    }

    #[test]
    fn controller_g_key_starts_move_session() {
        let mut ctrl = ManipulationController::new();
        let mut frame = ActionFrame::default();
        frame.actions.insert(
            crate::interaction::input::Action::BeginMove,
            crate::interaction::input::ResolvedActionState::Pressed,
        );
        let mut ctx = idle_ctx();
        ctx.selection_center = Some(glam::Vec3::new(1.0, 2.0, 3.0));

        let result = ctrl.update(&frame, ctx);
        assert_eq!(result, ManipResult::None); // None on first frame
        assert!(ctrl.is_active());
        assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
    }
}