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};
pub struct ManipulationController {
session: Option<ManipulationSession>,
}
impl ManipulationController {
pub fn new() -> Self {
Self { session: None }
}
pub fn update(&mut self, frame: &ActionFrame, ctx: ManipulationContext) -> ManipResult {
if let Some(ref mut session) = self.session {
let click_confirm = ctx.clicked && !session.is_gizmo_drag;
if frame.is_active(Action::Confirm) || click_confirm {
self.session = None;
return ManipResult::Commit;
}
if frame.is_active(Action::Cancel) {
self.session = None;
return ManipResult::Cancel;
}
if session.is_gizmo_drag && !ctx.dragging {
self.session = None;
return ManipResult::Commit;
}
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 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;
}
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,
);
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 {
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 {
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 {
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 => {
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,
);
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
}
}
_ => {
(session.last_scale_factor
* (1.0 + pointer_delta.x * 4.0 / ctx.viewport_size.x.max(1.0)))
.max(0.001)
}
};
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),
};
if let Some(ref numeric) = session.numeric {
delta.scale_override = numeric.parsed_values();
}
}
}
return ManipResult::Update(delta);
}
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;
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;
}
}
}
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
}
pub fn is_active(&self) -> bool {
self.session.is_some()
}
pub fn state(&self) -> Option<ManipulationState> {
self.session.as_ref().map(|s| s.to_state())
}
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,
});
}
pub fn reset(&mut self) {
self.session = None;
}
}
impl Default for ManipulationController {
fn default() -> Self {
Self::new()
}
}
fn unproject_cursor_to_ray(
cursor_viewport: glam::Vec2,
camera: &crate::camera::camera::Camera,
view_proj: glam::Mat4,
viewport_size: glam::Vec2,
) -> glam::Vec3 {
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));
let eye = camera.eye_position();
(far_world - eye).normalize_or(glam::Vec3::NEG_Z)
}
#[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,
}
}
#[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,
};
update_constraint(&mut session, true, false, false, false, false, false);
assert_eq!(session.axis, Some(GizmoAxis::X));
assert!(!session.exclude_axis);
update_constraint(&mut session, false, true, false, false, false, false);
assert_eq!(session.axis, Some(GizmoAxis::Y));
assert!(!session.exclude_axis);
update_constraint(&mut session, false, false, false, false, false, true);
assert_eq!(session.axis, Some(GizmoAxis::Z));
assert!(session.exclude_axis);
}
#[test]
#[ignore = "numeric input deferred: Action enum lacks NumericDigit/Backspace/Tab variants"]
fn numeric_parse_x_axis() {
let mut state = NumericInputState::new(Some(GizmoAxis::X), false);
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);
}
fn make_view_proj_looking_neg_z() -> (glam::Mat4, glam::Mat4) {
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() {
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::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() {
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}"
);
}
#[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);
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();
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); assert!(ctrl.is_active());
assert_eq!(ctrl.state().unwrap().kind, ManipulationKind::Move);
}
}