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 rot = if let Some(ax) = session.axis {
146 if session.exclude_axis {
147 let (ax1, ax2) = solvers::excluded_axes(ax);
149 let a1 = solvers::drag_onto_rotation(pointer_delta, ax1, camera_view);
150 let a2 = solvers::drag_onto_rotation(pointer_delta, ax2, camera_view);
151 let (chosen_axis, angle) =
152 if a1.abs() >= a2.abs() { (ax1, a1) } else { (ax2, a2) };
153 glam::Quat::from_axis_angle(chosen_axis, angle)
154 } else {
155 let axis_world = solvers::gizmo_axis_to_vec3(ax);
157 let angle = solvers::angular_rotation_from_cursor(
158 ctx.cursor_viewport,
159 pointer_delta,
160 session.gizmo_center,
161 axis_world,
162 view_proj,
163 ctx.viewport_size,
164 camera_view,
165 );
166 glam::Quat::from_axis_angle(axis_world, angle)
167 }
168 } else {
169 let view_dir =
171 (ctx.camera.center - ctx.camera.eye_position()).normalize();
172 glam::Quat::from_axis_angle(view_dir, pointer_delta.x * 0.01)
173 };
174 delta.rotation = rot;
175 }
176
177 ManipulationKind::Scale => {
178 let ndc = view_proj.project_point3(session.gizmo_center);
180 let center_screen = glam::Vec2::new(
181 (ndc.x + 1.0) * 0.5 * ctx.viewport_size.x,
182 (1.0 - ndc.y) * 0.5 * ctx.viewport_size.y,
183 );
184
185 let cumulative = match (ctx.cursor_viewport, session.cursor_anchor) {
189 (Some(cursor), Some(anchor)) => {
190 let dist_anchor = (anchor - center_screen).length();
191 let dist_now = (cursor - center_screen).length();
192 if dist_anchor > 2.0 {
193 (dist_now / dist_anchor).max(0.001)
194 } else {
195 1.0
196 }
197 }
198 _ => {
199 (session.last_scale_factor
201 * (1.0 + pointer_delta.x * 4.0 / ctx.viewport_size.x.max(1.0)))
202 .max(0.001)
203 }
204 };
205
206 let incr = (cumulative / session.last_scale_factor).max(0.001);
209 session.last_scale_factor = cumulative;
210
211 delta.scale = match (session.axis, session.exclude_axis) {
212 (None, _) => glam::Vec3::splat(incr),
213 (Some(GizmoAxis::X), false) => glam::Vec3::new(incr, 1.0, 1.0),
214 (Some(GizmoAxis::Y), false) => glam::Vec3::new(1.0, incr, 1.0),
215 (Some(_), false) => glam::Vec3::new(1.0, 1.0, incr),
216 (Some(GizmoAxis::X), true) => glam::Vec3::new(1.0, incr, incr),
217 (Some(GizmoAxis::Y), true) => glam::Vec3::new(incr, 1.0, incr),
218 (Some(_), true) => glam::Vec3::new(incr, incr, 1.0),
219 };
220
221 if let Some(ref numeric) = session.numeric {
223 delta.scale_override = numeric.parsed_values();
224 }
225 }
226 }
227
228 return ManipResult::Update(delta);
229 }
230
231 if ctx.drag_started {
235 if let (Some(gizmo_info), Some(center), Some(cursor)) =
236 (&ctx.gizmo, ctx.selection_center, ctx.cursor_viewport)
237 {
238 let camera_view = ctx.camera.view_matrix();
239 let view_proj = ctx.camera.proj_matrix() * camera_view;
240
241 let ray_origin = ctx.camera.eye_position();
243 let ray_dir =
244 unproject_cursor_to_ray(cursor, &ctx.camera, view_proj, ctx.viewport_size);
245
246 let temp_gizmo = Gizmo {
247 mode: gizmo_info.mode,
248 space: GizmoSpace::World,
249 hovered_axis: GizmoAxis::None,
250 active_axis: GizmoAxis::None,
251 drag_start_mouse: None,
252 pivot_mode: crate::interaction::gizmo::PivotMode::SelectionCentroid,
253 };
254 let hit = temp_gizmo.hit_test_oriented(
255 ray_origin,
256 ray_dir,
257 gizmo_info.center,
258 gizmo_info.scale,
259 gizmo_info.orientation,
260 );
261
262 if hit != GizmoAxis::None {
263 let kind = match gizmo_info.mode {
264 GizmoMode::Translate => ManipulationKind::Move,
265 GizmoMode::Rotate => ManipulationKind::Rotate,
266 GizmoMode::Scale => ManipulationKind::Scale,
267 };
268 self.session = Some(ManipulationSession {
269 kind,
270 axis: Some(hit),
271 exclude_axis: false,
272 numeric: None,
273 is_gizmo_drag: true,
274 gizmo_center: center,
275 cursor_anchor: ctx.cursor_viewport,
276 cursor_last_total: glam::Vec2::ZERO,
277 last_scale_factor: 1.0,
278 });
279 return ManipResult::None;
280 }
281 }
282 }
283
284 if let Some(center) = ctx.selection_center {
286 let kind = if frame.is_active(Action::BeginMove) {
287 Some(ManipulationKind::Move)
288 } else if frame.is_active(Action::BeginRotate) {
289 Some(ManipulationKind::Rotate)
290 } else if frame.is_active(Action::BeginScale) {
291 Some(ManipulationKind::Scale)
292 } else {
293 None
294 };
295
296 if let Some(kind) = kind {
297 self.session = Some(ManipulationSession {
298 kind,
299 axis: None,
300 exclude_axis: false,
301 numeric: None,
302 is_gizmo_drag: false,
303 gizmo_center: center,
304 cursor_anchor: ctx.cursor_viewport,
305 cursor_last_total: glam::Vec2::ZERO,
306 last_scale_factor: 1.0,
307 });
308 return ManipResult::None;
309 }
310 }
311
312 ManipResult::None
313 }
314
315 pub fn is_active(&self) -> bool {
322 self.session.is_some()
323 }
324
325 pub fn state(&self) -> Option<ManipulationState> {
327 self.session.as_ref().map(|s| s.to_state())
328 }
329
330 pub fn begin(&mut self, kind: ManipulationKind, center: glam::Vec3) {
334 if self.session.is_some() {
335 return;
336 }
337 self.session = Some(ManipulationSession {
338 kind,
339 axis: None,
340 exclude_axis: false,
341 numeric: None,
342 is_gizmo_drag: false,
343 gizmo_center: center,
344 cursor_anchor: None,
345 cursor_last_total: glam::Vec2::ZERO,
346 last_scale_factor: 1.0,
347 });
348 }
349
350 pub fn reset(&mut self) {
352 self.session = None;
353 }
354}
355
356impl Default for ManipulationController {
357 fn default() -> Self {
358 Self::new()
359 }
360}
361
362fn unproject_cursor_to_ray(
368 cursor_viewport: glam::Vec2,
369 camera: &crate::camera::camera::Camera,
370 view_proj: glam::Mat4,
371 viewport_size: glam::Vec2,
372) -> glam::Vec3 {
373 let ndc_x = (cursor_viewport.x / viewport_size.x.max(1.0)) * 2.0 - 1.0;
375 let ndc_y = 1.0 - (cursor_viewport.y / viewport_size.y.max(1.0)) * 2.0;
376
377 let inv_vp = view_proj.inverse();
378
379 let far_world = inv_vp.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
380
381 let eye = camera.eye_position();
383 (far_world - eye).normalize_or(glam::Vec3::NEG_Z)
384}
385
386#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::interaction::input::ActionFrame;
394 use session::{NumericInputState, update_constraint};
395
396 fn make_camera() -> crate::camera::camera::Camera {
397 crate::camera::camera::Camera::default()
398 }
399
400 fn idle_ctx() -> ManipulationContext {
401 ManipulationContext {
402 camera: make_camera(),
403 viewport_size: glam::Vec2::new(800.0, 600.0),
404 cursor_viewport: None,
405 pointer_delta: glam::Vec2::ZERO,
406 selection_center: None,
407 gizmo: None,
408 drag_started: false,
409 dragging: false,
410 clicked: false,
411 }
412 }
413
414 #[test]
419 fn constraint_transitions_x_y_shift_z() {
420 let mut session = ManipulationSession {
421 kind: ManipulationKind::Move,
422 axis: None,
423 exclude_axis: false,
424 numeric: None,
425 is_gizmo_drag: false,
426 gizmo_center: glam::Vec3::ZERO,
427 cursor_anchor: None,
428 cursor_last_total: glam::Vec2::ZERO,
429 last_scale_factor: 1.0,
430 };
431
432 update_constraint(&mut session, true, false, false, false, false, false);
434 assert_eq!(session.axis, Some(GizmoAxis::X));
435 assert!(!session.exclude_axis);
436
437 update_constraint(&mut session, false, true, false, false, false, false);
439 assert_eq!(session.axis, Some(GizmoAxis::Y));
440 assert!(!session.exclude_axis);
441
442 update_constraint(&mut session, false, false, false, false, false, true);
444 assert_eq!(session.axis, Some(GizmoAxis::Z));
445 assert!(session.exclude_axis);
446 }
447
448 #[test]
453 #[ignore = "numeric input deferred: Action enum lacks NumericDigit/Backspace/Tab variants"]
454 fn numeric_parse_x_axis() {
455 let mut state = NumericInputState::new(Some(GizmoAxis::X), false);
459 state.axis_inputs[0] = "2.50".to_string();
461 let parsed = state.parsed_values();
462 assert_eq!(parsed[0], Some(2.5));
463 assert_eq!(parsed[1], None);
464 assert_eq!(parsed[2], None);
465 }
466
467 fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
472 let view = glam::Mat4::look_at_rh(
474 glam::Vec3::new(0.0, 0.0, 5.0),
475 glam::Vec3::ZERO,
476 glam::Vec3::Y,
477 );
478 let proj = glam::Mat4::perspective_rh(
479 std::f32::consts::FRAC_PI_4,
480 800.0 / 600.0,
481 0.1,
482 100.0,
483 );
484 (view, proj * view)
485 }
486
487 #[test]
488 fn angular_rotation_z_toward_camera_cw_is_positive() {
489 let (camera_view, view_proj) = make_view_proj_looking_neg_z();
492 let gizmo_center = glam::Vec3::ZERO;
493 let viewport_size = glam::Vec2::new(800.0, 600.0);
494
495 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(
500 Some(cursor),
501 pointer_delta,
502 gizmo_center,
503 glam::Vec3::Z,
504 view_proj,
505 viewport_size,
506 camera_view,
507 );
508 assert!(
509 angle > 0.0,
510 "CW motion with +Z axis (toward camera) should give positive angle, got {angle}"
511 );
512 }
513
514 #[test]
515 fn angular_rotation_neg_z_away_from_camera_cw_is_negative() {
516 let (camera_view, view_proj) = make_view_proj_looking_neg_z();
518 let gizmo_center = glam::Vec3::ZERO;
519 let viewport_size = glam::Vec2::new(800.0, 600.0);
520
521 let cursor = glam::Vec2::new(500.0, 300.0);
522 let pointer_delta = glam::Vec2::new(0.0, -20.0);
523
524 let angle = solvers::angular_rotation_from_cursor(
525 Some(cursor),
526 pointer_delta,
527 gizmo_center,
528 glam::Vec3::NEG_Z,
529 view_proj,
530 viewport_size,
531 camera_view,
532 );
533 assert!(
534 angle < 0.0,
535 "CW motion with -Z axis (away from camera) should give negative angle, got {angle}"
536 );
537 }
538
539 #[test]
544 fn controller_lifecycle_begin_reset() {
545 let mut ctrl = ManipulationController::new();
546 assert!(!ctrl.is_active());
547
548 ctrl.begin(ManipulationKind::Move, glam::Vec3::ZERO);
549 assert!(ctrl.is_active());
550
551 ctrl.reset();
552 assert!(!ctrl.is_active());
553 }
554
555 #[test]
556 fn controller_begin_no_op_when_active() {
557 let mut ctrl = ManipulationController::new();
558 ctrl.begin(ManipulationKind::Move, glam::Vec3::ONE);
559 ctrl.begin(ManipulationKind::Rotate, glam::Vec3::ZERO);
560 let state = ctrl.state().unwrap();
562 assert_eq!(state.kind, ManipulationKind::Move);
563 }
564
565 #[test]
566 fn controller_idle_returns_none() {
567 let mut ctrl = ManipulationController::new();
568 let frame = ActionFrame::default();
569 let result = ctrl.update(&frame, idle_ctx());
570 assert_eq!(result, ManipResult::None);
571 assert!(!ctrl.is_active());
572 }
573
574 #[test]
575 fn controller_no_session_without_selection_center() {
576 let mut ctrl = ManipulationController::new();
577 let mut frame = ActionFrame::default();
579 frame.actions.insert(
580 crate::interaction::input::Action::BeginMove,
581 crate::interaction::input::ResolvedActionState::Pressed,
582 );
583 let result = ctrl.update(&frame, idle_ctx());
584 assert_eq!(result, ManipResult::None);
585 assert!(!ctrl.is_active());
586 }
587
588 #[test]
589 fn controller_g_key_starts_move_session() {
590 let mut ctrl = ManipulationController::new();
591 let mut frame = ActionFrame::default();
592 frame.actions.insert(
593 crate::interaction::input::Action::BeginMove,
594 crate::interaction::input::ResolvedActionState::Pressed,
595 );
596 let mut ctx = idle_ctx();
597 ctx.selection_center = Some(glam::Vec3::new(1.0, 2.0, 3.0));
598
599 let result = ctrl.update(&frame, ctx);
600 assert_eq!(result, ManipResult::None); assert!(ctrl.is_active());
602 assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
603 }
604}