1mod 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
35pub struct ManipulationController {
40 session: Option<ManipulationSession>,
41}
42
43impl ManipulationController {
44 pub fn new() -> Self {
46 Self { session: None }
47 }
48
49 pub fn update(&mut self, frame: &ActionFrame, ctx: ManipulationContext) -> ManipResult {
61 if let Some(ref mut session) = self.session {
62 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 if frame.is_active(Action::Cancel) {
71 self.session = None;
72 return ManipResult::Cancel;
73 }
74
75 if session.is_gizmo_drag && !ctx.dragging {
77 self.session = None;
78 return ManipResult::Commit;
79 }
80
81 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 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 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 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 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 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 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 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 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 (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 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 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 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 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 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 pub fn is_active(&self) -> bool {
325 self.session.is_some()
326 }
327
328 pub fn state(&self) -> Option<ManipulationState> {
330 self.session.as_ref().map(|s| s.to_state())
331 }
332
333 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 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
365fn 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 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 let eye = camera.eye_position();
386 (far_world - eye).normalize_or(glam::Vec3::NEG_Z)
387}
388
389#[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 #[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 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 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 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 #[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 let mut frame = ActionFrame::default();
474 frame.typed_chars.push('2');
475 let mut ctx = idle_ctx();
476 ctx.dragging = false; let result = ctrl.update(&frame, ctx);
478 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 let mut frame = ActionFrame::default();
494 frame.typed_chars.extend(['2', '5']);
495 ctrl.update(&frame, idle_ctx());
496
497 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 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 fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
523 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 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 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(
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 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 #[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 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 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); assert!(ctrl.is_active());
649 assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
650 }
651}