1pub mod clip_plane;
27pub 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
39pub struct ManipulationController {
44 session: Option<ManipulationSession>,
45}
46
47impl ManipulationController {
48 pub fn new() -> Self {
50 Self { session: None }
51 }
52
53 pub fn update(&mut self, frame: &ActionFrame, ctx: ManipulationContext) -> ManipResult {
65 if let Some(ref mut session) = self.session {
66 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 if frame.is_active(Action::Cancel) {
75 self.session = None;
76 return ManipResult::Cancel;
77 }
78
79 if session.is_gizmo_drag && !ctx.dragging {
81 self.session = None;
82 return ManipResult::Commit;
83 }
84
85 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 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 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 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 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 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 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 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 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 (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 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 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 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 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 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 pub fn is_active(&self) -> bool {
330 self.session.is_some()
331 }
332
333 pub fn state(&self) -> Option<ManipulationState> {
335 self.session.as_ref().map(|s| s.to_state())
336 }
337
338 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 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
370fn 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 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 let eye = camera.eye_position();
391 (far_world - eye).normalize_or(glam::Vec3::NEG_Z)
392}
393
394#[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 #[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 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 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 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 #[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 let mut frame = ActionFrame::default();
479 frame.typed_chars.push('2');
480 let mut ctx = idle_ctx();
481 ctx.dragging = false; let result = ctrl.update(&frame, ctx);
483 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 let mut frame = ActionFrame::default();
499 frame.typed_chars.extend(['2', '5']);
500 ctrl.update(&frame, idle_ctx());
501
502 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 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 fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
528 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 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 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(
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 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 #[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 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 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); assert!(ctrl.is_active());
654 assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
655 }
656}