//! Transform gizmo math: hit testing, drag projection, and snap rounding.
//!
//! Stage 7 piece 6 (a): rays in, transforms out. The viewport piece
//! renders the handles and feeds rays into [`hit_test`] and
//! [`apply_drag`]; this module owns the geometry so all of it can be
//! unit-tested without a window.
//!
//! Three modes ([`GizmoMode`]) each expose a small set of [`GizmoHandle`]s:
//!
//! - **Translate** — one axis arrow per world axis, plus three "plane
//!   quads" (XY/XZ/YZ) that drag along two axes at once.
//! - **Rotate** — one circle per world axis, dragged around its normal.
//! - **Scale** — one axis cube per world axis (non-uniform along that
//!   axis) plus one center handle for uniform scale.
//!
//! Holding the snap modifier rounds the drag result to a configurable
//! step ([`SnapSettings`]): grid distance for translate, angle for
//! rotate, factor step for scale. Snap is applied to the *delta* from
//! the drag's starting transform, never to the starting transform
//! itself, so the result lines up with a fresh selection that already
//! sits between grid points.
//!
//! The gizmo lives at the entity's translation (its rotation and scale
//! do not transform the handles — they always point along world axes).
//! The shipped viewport renders this "world-space" gizmo; a future
//! "local-space" toggle would orient the handles by the entity rotation
//! before hit testing, which is a small change in [`world_axis`] /
//! [`world_plane`].

use oxide_engine::math::{Plane, Quat, Ray, Transform, Vec3};

/// Which transform tool the gizmo is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GizmoMode {
    /// Axis arrows + plane quads. Hotkey **W**.
    Translate,
    /// Axis circles. Hotkey **E**.
    Rotate,
    /// Axis cubes + center uniform. Hotkey **R**.
    Scale,
}

impl GizmoMode {
    /// The label shown in the status bar / toolbar.
    pub fn label(self) -> &'static str {
        match self {
            GizmoMode::Translate => "Translate",
            GizmoMode::Rotate => "Rotate",
            GizmoMode::Scale => "Scale",
        }
    }
}

/// One of the three world axes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis3 {
    X,
    Y,
    Z,
}

impl Axis3 {
    /// All three axes in stable order.
    pub const ALL: [Axis3; 3] = [Axis3::X, Axis3::Y, Axis3::Z];

    /// Unit vector along this axis.
    pub fn unit(self) -> Vec3 {
        match self {
            Axis3::X => Vec3::X,
            Axis3::Y => Vec3::Y,
            Axis3::Z => Vec3::Z,
        }
    }

    /// Zero-based index for indexing into per-component arrays.
    pub fn index(self) -> usize {
        match self {
            Axis3::X => 0,
            Axis3::Y => 1,
            Axis3::Z => 2,
        }
    }
}

/// One of the three world-aligned planes (XY = plane whose normal is Z, …).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaneAxis {
    XY,
    XZ,
    YZ,
}

impl PlaneAxis {
    /// All three planes in stable order.
    pub const ALL: [PlaneAxis; 3] = [PlaneAxis::XY, PlaneAxis::XZ, PlaneAxis::YZ];

    /// Unit normal to the plane.
    pub fn normal(self) -> Vec3 {
        match self {
            PlaneAxis::XY => Vec3::Z,
            PlaneAxis::XZ => Vec3::Y,
            PlaneAxis::YZ => Vec3::X,
        }
    }

    /// The two axes that lie in this plane (in stable order).
    pub fn axes(self) -> (Vec3, Vec3) {
        match self {
            PlaneAxis::XY => (Vec3::X, Vec3::Y),
            PlaneAxis::XZ => (Vec3::X, Vec3::Z),
            PlaneAxis::YZ => (Vec3::Y, Vec3::Z),
        }
    }
}

/// One interactive gizmo handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GizmoHandle {
    TranslateAxis(Axis3),
    TranslatePlane(PlaneAxis),
    RotateAxis(Axis3),
    ScaleAxis(Axis3),
    /// The center "uniform scale" cube.
    ScaleUniform,
}

impl GizmoHandle {
    /// The mode this handle belongs to.
    pub fn mode(self) -> GizmoMode {
        match self {
            GizmoHandle::TranslateAxis(_) | GizmoHandle::TranslatePlane(_) => GizmoMode::Translate,
            GizmoHandle::RotateAxis(_) => GizmoMode::Rotate,
            GizmoHandle::ScaleAxis(_) | GizmoHandle::ScaleUniform => GizmoMode::Scale,
        }
    }
}

/// Snap step sizes applied during a drag while the snap modifier is held.
///
/// Each step is applied to the **delta** the drag has accumulated — never
/// to the starting transform — so a selection that already sits between
/// grid points keeps its starting offset.
#[derive(Debug, Clone, Copy)]
pub struct SnapSettings {
    /// Translation grid in world units (default `0.25`).
    pub distance: f32,
    /// Rotation step in degrees (default `15`).
    pub angle_deg: f32,
    /// Scale step (default `0.1` — factors round to the nearest `0.1`).
    pub scale: f32,
}

impl Default for SnapSettings {
    fn default() -> Self {
        Self {
            distance: 0.25,
            angle_deg: 15.0,
            scale: 0.1,
        }
    }
}

/// One in-progress gizmo drag.
///
/// Created by the viewport when the user clicks a handle, kept alive while
/// the button is held, and dropped on release. Each frame the viewport
/// calls [`apply_drag`] with the new pointer ray to compute the new
/// transform.
#[derive(Debug, Clone, Copy)]
pub struct GizmoDrag {
    /// The handle the user grabbed.
    pub handle: GizmoHandle,
    /// The entity's transform when the drag started — never mutated; the
    /// drag computes a delta from this and applies it fresh each frame.
    pub start_transform: Transform,
    /// The world-space point where the drag began. For an axis handle
    /// this is the closest point on the axis to the click ray; for a
    /// plane handle, the ray-plane intersection; for a circle handle,
    /// the projection of the ray hit onto the rotation plane.
    pub start_anchor: Vec3,
    /// Handle-specific reference scalar set at drag start. For
    /// [`GizmoHandle::ScaleUniform`] it is the world-space distance that
    /// corresponds to one *factor* of change — the gizmo size — so a
    /// drag away from the entity by that much grows the scale by ~1.0.
    /// Unused (set to `1.0`) for every other handle.
    pub reference: f32,
}

// =====================================================================
// Hit testing
// =====================================================================

/// Tries every handle the given mode exposes and returns the one closest
/// to `ray`, or `None` if none are within `pixel_tolerance_world` of any
/// handle. `gizmo_size` is the per-axis world length of the arrow / cube
/// handles; both inputs are computed by the viewport based on the
/// camera's distance to the gizmo origin (so the gizmo stays the same
/// pixel size at any zoom).
pub fn hit_test(
    ray: &Ray,
    transform: &Transform,
    mode: GizmoMode,
    gizmo_size: f32,
    pixel_tolerance_world: f32,
) -> Option<GizmoHandle> {
    let origin = transform.translation;
    let mut best: Option<(f32, GizmoHandle)> = None;
    let mut consider = |dist_sq: f32, handle: GizmoHandle| {
        if dist_sq.is_finite() && dist_sq < pixel_tolerance_world * pixel_tolerance_world {
            match best {
                Some((b, _)) if b <= dist_sq => {}
                _ => best = Some((dist_sq, handle)),
            }
        }
    };

    match mode {
        GizmoMode::Translate => {
            for axis in Axis3::ALL {
                let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
                consider(d, GizmoHandle::TranslateAxis(axis));
            }
            for plane in PlaneAxis::ALL {
                if let Some(d) = plane_quad_distance_sq(origin, plane, gizmo_size, ray) {
                    consider(d, GizmoHandle::TranslatePlane(plane));
                }
            }
        }
        GizmoMode::Rotate => {
            for axis in Axis3::ALL {
                if let Some(d) = circle_distance_sq(origin, axis.unit(), gizmo_size, ray) {
                    consider(d, GizmoHandle::RotateAxis(axis));
                }
            }
        }
        GizmoMode::Scale => {
            for axis in Axis3::ALL {
                let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
                consider(d, GizmoHandle::ScaleAxis(axis));
            }
            // Uniform handle: the center cube.
            let d = ray.distance_to_point(origin).powi(2);
            consider(d, GizmoHandle::ScaleUniform);
        }
    }

    best.map(|(_, h)| h)
}

/// Squared distance from `ray` to the segment from `origin + axis * inner`
/// to `origin + axis * length`, with the closest point clamped to the
/// segment. Used for axis arrows.
///
/// The leading `inner` offset (~20% of length) keeps the segment clear of
/// the central cube area, so a ray that pierces the gizmo's center is
/// claimed by the uniform / center handle rather than by every axis at
/// once.
fn axis_segment_distance_sq(origin: Vec3, axis: Vec3, length: f32, ray: &Ray) -> f32 {
    let inner = length * 0.2;
    let pt_on_axis = closest_point_on_line(origin, axis, ray);
    let along = (pt_on_axis - origin).dot(axis).clamp(inner, length);
    let clamped = origin + axis * along;
    ray.distance_to_point(clamped).powi(2)
}

/// Distance² from `ray` to a square plane quad at `origin` (size × size),
/// or `None` when the ray is parallel to the plane. Used for translate
/// plane handles.
fn plane_quad_distance_sq(origin: Vec3, plane: PlaneAxis, size: f32, ray: &Ray) -> Option<f32> {
    let p = Plane::from_point_normal(origin, plane.normal());
    let t = p.ray_intersection(ray)?;
    let hit = ray.at(t);
    let (a, b) = plane.axes();
    // The plane quad spans roughly the *outer* part of the gizmo: from
    // ~0.3*size to ~0.7*size on each axis, away from the central cube
    // and clear of the axis arrows.
    let inner = size * 0.3;
    let outer = size * 0.7;
    let da = (hit - origin).dot(a);
    let db = (hit - origin).dot(b);
    if da >= inner && da <= outer && db >= inner && db <= outer {
        // Inside the quad — perfect hit, no distance penalty.
        Some(0.0)
    } else {
        // Outside — penalize by distance from the nearest edge so handle
        // priority degrades smoothly with miss distance.
        let clamped = origin + a * da.clamp(inner, outer) + b * db.clamp(inner, outer);
        Some(ray.distance_to_point(clamped).powi(2))
    }
}

/// Distance² from `ray` to the circle of radius `r` lying in the plane
/// through `origin` with the given `axis` as normal, or `None` when the
/// ray is parallel to the plane. Used for rotate circles.
fn circle_distance_sq(origin: Vec3, axis: Vec3, r: f32, ray: &Ray) -> Option<f32> {
    let p = Plane::from_point_normal(origin, axis);
    let t = p.ray_intersection(ray)?;
    let hit = ray.at(t);
    // Project onto the plane and find the closest circle point.
    let v = hit - origin;
    let in_plane = v - axis * v.dot(axis);
    let len = in_plane.length();
    if len < 1e-6 {
        // Right at the center — distance to circle is `r` itself.
        return Some(r * r);
    }
    let on_circle = origin + in_plane * (r / len);
    Some(ray.distance_to_point(on_circle).powi(2))
}

/// Closest point on the line through `origin` along the unit `dir`
/// vector to `ray`. Result is unconstrained — clamping to a segment is
/// the caller's job.
pub fn closest_point_on_line(origin: Vec3, dir: Vec3, ray: &Ray) -> Vec3 {
    let r = ray.direction;
    let w = origin - ray.origin;
    let d = dir.dot(r);
    let denom = 1.0 - d * d;
    if denom.abs() < 1e-6 {
        // Ray parallel to line — closest point on the line is the origin.
        return origin;
    }
    let s = (dir.dot(-w) - r.dot(-w) * d) / denom;
    origin + dir * s
}

// =====================================================================
// Drag application
// =====================================================================

/// Applies the in-progress `drag` to its starting transform using the
/// pointer's current ray, returning the new transform. Pure: same inputs
/// always yield the same output.
///
/// When `snap` is `Some`, the per-mode delta is rounded to the appropriate
/// step before being applied (so the snap modifier can be toggled mid-
/// drag and the result lines up to the grid regardless of how the user
/// got there).
pub fn apply_drag(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
    match drag.handle {
        GizmoHandle::TranslateAxis(axis) => translate_along_axis(drag, current_ray, axis, snap),
        GizmoHandle::TranslatePlane(plane) => translate_in_plane(drag, current_ray, plane, snap),
        GizmoHandle::RotateAxis(axis) => rotate_around_axis(drag, current_ray, axis, snap),
        GizmoHandle::ScaleAxis(axis) => scale_along_axis(drag, current_ray, axis, snap),
        GizmoHandle::ScaleUniform => scale_uniform(drag, current_ray, snap),
    }
}

/// Rounds `value` to the nearest integer multiple of `step`. Returns
/// `value` unchanged when `step` is non-positive.
pub fn snap_round(value: f32, step: f32) -> f32 {
    if step <= 0.0 {
        return value;
    }
    (value / step).round() * step
}

fn translate_along_axis(
    drag: &GizmoDrag,
    current_ray: &Ray,
    axis: Axis3,
    snap: Option<&SnapSettings>,
) -> Transform {
    let dir = axis.unit();
    let now = closest_point_on_line(drag.start_transform.translation, dir, current_ray);
    let mut delta = (now - drag.start_anchor).dot(dir);
    if let Some(s) = snap {
        delta = snap_round(delta, s.distance);
    }
    let mut t = drag.start_transform;
    t.translation += dir * delta;
    t
}

fn translate_in_plane(
    drag: &GizmoDrag,
    current_ray: &Ray,
    plane: PlaneAxis,
    snap: Option<&SnapSettings>,
) -> Transform {
    let p = Plane::from_point_normal(drag.start_transform.translation, plane.normal());
    let Some(t) = p.ray_intersection(current_ray) else {
        return drag.start_transform;
    };
    let now = current_ray.at(t);
    let (a, b) = plane.axes();
    let mut da = (now - drag.start_anchor).dot(a);
    let mut db = (now - drag.start_anchor).dot(b);
    if let Some(s) = snap {
        da = snap_round(da, s.distance);
        db = snap_round(db, s.distance);
    }
    let mut out = drag.start_transform;
    out.translation += a * da + b * db;
    out
}

fn rotate_around_axis(
    drag: &GizmoDrag,
    current_ray: &Ray,
    axis: Axis3,
    snap: Option<&SnapSettings>,
) -> Transform {
    let axis_dir = axis.unit();
    let origin = drag.start_transform.translation;
    let plane = Plane::from_point_normal(origin, axis_dir);
    let Some(t) = plane.ray_intersection(current_ray) else {
        return drag.start_transform;
    };
    let now = current_ray.at(t);
    // Vectors from origin to start / current points, both already lying
    // in the rotation plane.
    let from = (drag.start_anchor - origin).normalize_or_zero();
    let to = (now - origin).normalize_or_zero();
    if from.length_squared() < 1e-6 || to.length_squared() < 1e-6 {
        return drag.start_transform;
    }
    // Signed angle around `axis_dir`.
    let cross = from.cross(to);
    let sin = cross.dot(axis_dir);
    let cos = from.dot(to).clamp(-1.0, 1.0);
    let mut angle = sin.atan2(cos);
    if let Some(s) = snap {
        let step = s.angle_deg.to_radians();
        angle = snap_round(angle, step);
    }
    let rotation = Quat::from_axis_angle(axis_dir, angle);
    let mut out = drag.start_transform;
    out.rotation = rotation * drag.start_transform.rotation;
    out
}

fn scale_along_axis(
    drag: &GizmoDrag,
    current_ray: &Ray,
    axis: Axis3,
    snap: Option<&SnapSettings>,
) -> Transform {
    let dir = axis.unit();
    let origin = drag.start_transform.translation;
    let now = closest_point_on_line(origin, dir, current_ray);
    let start_along = (drag.start_anchor - origin).dot(dir);
    if start_along.abs() < 1e-4 {
        return drag.start_transform;
    }
    let now_along = (now - origin).dot(dir);
    let mut factor = now_along / start_along;
    if let Some(s) = snap {
        factor = snap_round(factor, s.scale);
    }
    // Clamp to a small positive floor so a runaway drag can't flip scale
    // to zero / negative (which crashes inverse-transform math elsewhere).
    factor = factor.max(0.001);
    let mut out = drag.start_transform;
    let mut s = drag.start_transform.scale.to_array();
    s[axis.index()] *= factor;
    out.scale = Vec3::from_array(s);
    out
}

fn scale_uniform(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
    let origin = drag.start_transform.translation;
    // Perpendicular distance from the current ray to the entity, in world
    // units. The *delta* from the click's perpendicular distance, divided
    // by `drag.reference` (the gizmo size), is the additive change in
    // scale factor. Avoids the previous `now_dist / start_dist` formula's
    // blow-up when the click landed near the gizmo center (start_dist
    // ≈ 0) and the divide spiked the factor.
    let start_perp = (drag.start_anchor - origin).length();
    let now_perp = current_ray.distance_to_point(origin);
    let reference = drag.reference.max(1e-4);
    let mut factor = 1.0 + (now_perp - start_perp) / reference;
    if let Some(s) = snap {
        factor = snap_round(factor, s.scale);
    }
    // Floor at a small positive value so a runaway drag past the origin
    // can't flip scale negative (which crashes inverse-transform math).
    factor = factor.max(0.001);
    let mut out = drag.start_transform;
    out.scale = drag.start_transform.scale * factor;
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxide_engine::math::Vec3;
    use std::f32::consts::FRAC_PI_2;

    fn id_transform_at(p: Vec3) -> Transform {
        Transform {
            translation: p,
            rotation: Quat::IDENTITY,
            scale: Vec3::ONE,
        }
    }

    // --- Hit testing ----------------------------------------------------

    #[test]
    fn hit_test_picks_translate_axis_under_cursor() {
        // Camera looking straight down -Z at origin.
        let ray = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let hit = hit_test(
            &ray,
            &id_transform_at(Vec3::ZERO),
            GizmoMode::Translate,
            1.0,
            0.1,
        );
        assert_eq!(hit, Some(GizmoHandle::TranslateAxis(Axis3::X)));
    }

    #[test]
    fn hit_test_picks_translate_plane_inside_quad() {
        let ray = Ray::new(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let hit = hit_test(
            &ray,
            &id_transform_at(Vec3::ZERO),
            GizmoMode::Translate,
            1.0,
            0.1,
        );
        assert_eq!(hit, Some(GizmoHandle::TranslatePlane(PlaneAxis::XY)));
    }

    #[test]
    fn hit_test_picks_rotate_circle_on_radius() {
        // Camera looking down +X, so the rotate-X circle is in YZ plane.
        // Aim at a point on that circle of radius 1.
        let ray = Ray::new(Vec3::new(5.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
        let hit = hit_test(
            &ray,
            &id_transform_at(Vec3::ZERO),
            GizmoMode::Rotate,
            1.0,
            0.1,
        );
        assert_eq!(hit, Some(GizmoHandle::RotateAxis(Axis3::X)));
    }

    #[test]
    fn hit_test_misses_when_ray_far_from_handles() {
        let ray = Ray::new(Vec3::new(50.0, 50.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let hit = hit_test(
            &ray,
            &id_transform_at(Vec3::ZERO),
            GizmoMode::Translate,
            1.0,
            0.1,
        );
        assert!(hit.is_none());
    }

    #[test]
    fn hit_test_picks_scale_uniform_at_center() {
        let ray = Ray::new(Vec3::ZERO + Vec3::Z * 5.0, -Vec3::Z);
        let hit = hit_test(
            &ray,
            &id_transform_at(Vec3::ZERO),
            GizmoMode::Scale,
            1.0,
            0.1,
        );
        assert_eq!(hit, Some(GizmoHandle::ScaleUniform));
    }

    // --- Translate drag -------------------------------------------------

    #[test]
    fn translate_axis_drag_moves_along_axis_only() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::TranslateAxis(Axis3::X),
            start_transform: start,
            start_anchor: Vec3::ZERO,
            reference: 1.0,
        };
        // Ray that closest-approaches X at x = 3.
        let cur = Ray::new(Vec3::new(3.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let out = apply_drag(&drag, &cur, None);
        assert!((out.translation.x - 3.0).abs() < 1e-4);
        assert!(out.translation.y.abs() < 1e-4);
        assert!(out.translation.z.abs() < 1e-4);
    }

    #[test]
    fn translate_axis_snap_rounds_to_distance_step() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::TranslateAxis(Axis3::X),
            start_transform: start,
            start_anchor: Vec3::ZERO,
            reference: 1.0,
        };
        let cur = Ray::new(Vec3::new(0.74, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let snap = SnapSettings {
            distance: 0.25,
            ..SnapSettings::default()
        };
        let out = apply_drag(&drag, &cur, Some(&snap));
        // 0.74 rounds to 0.75.
        assert!((out.translation.x - 0.75).abs() < 1e-4);
    }

    #[test]
    fn translate_plane_drag_moves_in_both_axes() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::TranslatePlane(PlaneAxis::XY),
            start_transform: start,
            start_anchor: Vec3::ZERO,
            reference: 1.0,
        };
        let cur = Ray::new(Vec3::new(2.0, 3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let out = apply_drag(&drag, &cur, None);
        assert!((out.translation.x - 2.0).abs() < 1e-4);
        assert!((out.translation.y - 3.0).abs() < 1e-4);
        assert!(out.translation.z.abs() < 1e-4);
    }

    // --- Rotate drag ----------------------------------------------------

    #[test]
    fn rotate_around_x_axis_produces_quarter_turn() {
        let start = id_transform_at(Vec3::ZERO);
        // Click at the +Y point on the YZ circle.
        let drag = GizmoDrag {
            handle: GizmoHandle::RotateAxis(Axis3::X),
            start_transform: start,
            start_anchor: Vec3::new(0.0, 1.0, 0.0),
            reference: 1.0,
        };
        // Drag to the +Z point — 90° around +X (right-hand rule from +Y → +Z).
        let cur = Ray::new(Vec3::new(5.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, 0.0));
        let out = apply_drag(&drag, &cur, None);
        // Apply the rotation to Y and confirm it lands on Z.
        let rotated = out.rotation * Vec3::Y;
        assert!((rotated - Vec3::Z).length() < 1e-4);
    }

    #[test]
    fn rotate_snap_rounds_to_angle_step() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::RotateAxis(Axis3::X),
            start_transform: start,
            start_anchor: Vec3::new(0.0, 1.0, 0.0),
            reference: 1.0,
        };
        // Drag to ~89°: should snap to 90° with a 15° step.
        let cur = Ray::new(Vec3::new(5.0, 0.0175, 0.9998), Vec3::new(-1.0, 0.0, 0.0));
        let snap = SnapSettings {
            angle_deg: 15.0,
            ..SnapSettings::default()
        };
        let out = apply_drag(&drag, &cur, Some(&snap));
        let rotated = out.rotation * Vec3::Y;
        // A 90° rotation around X maps Y → Z exactly.
        assert!(
            (rotated - Vec3::Z).length() < 1e-3,
            "expected snap to 90°, got {rotated:?}"
        );
    }

    #[test]
    fn rotate_no_movement_returns_start_transform() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::RotateAxis(Axis3::Y),
            start_transform: start,
            start_anchor: Vec3::new(1.0, 0.0, 0.0),
            reference: 1.0,
        };
        // Ray pointing back at the anchor (no rotation).
        let cur = Ray::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
        let out = apply_drag(&drag, &cur, None);
        // Quaternion should be ~identity.
        let rotated = out.rotation * Vec3::Z;
        assert!((rotated - Vec3::Z).length() < 1e-3);
    }

    // --- Scale drag -----------------------------------------------------

    #[test]
    fn scale_axis_doubles_when_pointer_moves_to_2x_anchor() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::ScaleAxis(Axis3::X),
            start_transform: start,
            start_anchor: Vec3::new(1.0, 0.0, 0.0),
            reference: 1.0,
        };
        let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let out = apply_drag(&drag, &cur, None);
        assert!((out.scale.x - 2.0).abs() < 1e-4);
        assert!((out.scale.y - 1.0).abs() < 1e-4);
        assert!((out.scale.z - 1.0).abs() < 1e-4);
    }

    #[test]
    fn scale_axis_floors_at_small_positive_value() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::ScaleAxis(Axis3::Y),
            start_transform: start,
            start_anchor: Vec3::new(0.0, 1.0, 0.0),
            reference: 1.0,
        };
        // Drag well past the origin — would naively give factor = -3.
        let cur = Ray::new(Vec3::new(0.0, -3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let out = apply_drag(&drag, &cur, None);
        // Clamped to a small positive floor — never negative.
        assert!(out.scale.y > 0.0);
        assert!(out.scale.y < 0.01);
    }

    #[test]
    fn scale_uniform_doubles_along_every_axis() {
        let mut start = id_transform_at(Vec3::ZERO);
        start.scale = Vec3::new(1.0, 2.0, 3.0);
        // With reference = 1.0, dragging the perpendicular distance from
        // 1.0 (the start anchor) to 2.0 grows the factor by exactly 1.0.
        let drag = GizmoDrag {
            handle: GizmoHandle::ScaleUniform,
            start_transform: start,
            start_anchor: Vec3::new(1.0, 0.0, 0.0),
            reference: 1.0,
        };
        let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let out = apply_drag(&drag, &cur, None);
        assert!((out.scale.x - 2.0).abs() < 1e-4);
        assert!((out.scale.y - 4.0).abs() < 1e-4);
        assert!((out.scale.z - 6.0).abs() < 1e-4);
    }

    #[test]
    fn scale_uniform_is_not_supersensitive_when_click_lands_near_center() {
        // The old `now_dist / start_dist` formula blew up when a click
        // landed near the gizmo center (start_dist ≈ 0). The new formula
        // is additive in the perpendicular delta, so a tiny start_dist
        // does not amplify the factor.
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::ScaleUniform,
            start_transform: start,
            // Click landed near the center (perp distance 0.05).
            start_anchor: Vec3::new(0.05, 0.0, 0.0),
            reference: 1.0,
        };
        // Drag the pointer to a new perp distance of 0.5 (so delta = 0.45).
        let cur = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let out = apply_drag(&drag, &cur, None);
        // factor = 1.0 + 0.45 / 1.0 = 1.45 — gentle. The old formula would
        // give 0.5 / 0.05 = 10.0, which is what the maintainer reported.
        assert!(
            (out.scale.x - 1.45).abs() < 1e-3,
            "expected gentle factor 1.45, got scale {:?}",
            out.scale
        );
    }

    #[test]
    fn scale_uniform_snap_rounds_factor() {
        // Reported by the maintainer: uniform-scale snap did nothing. The
        // old formula's runaway factor swamped the snap step; the new
        // additive formula puts the factor in a sane range so snap_round
        // can hit a sensible step.
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::ScaleUniform,
            start_transform: start,
            start_anchor: Vec3::new(0.5, 0.0, 0.0),
            reference: 1.0,
        };
        // Pointer at perp distance ~1.32 → factor 1 + (1.32 - 0.5) = 1.82
        // → snaps to 1.8 (step 0.1).
        let cur = Ray::new(Vec3::new(1.32, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let snap = SnapSettings {
            scale: 0.1,
            ..SnapSettings::default()
        };
        let out = apply_drag(&drag, &cur, Some(&snap));
        assert!(
            (out.scale.x - 1.8).abs() < 1e-3,
            "uniform-scale snap should round 1.82 to 1.8, got {:?}",
            out.scale
        );
    }

    #[test]
    fn scale_snap_rounds_factor_to_step() {
        let start = id_transform_at(Vec3::ZERO);
        let drag = GizmoDrag {
            handle: GizmoHandle::ScaleAxis(Axis3::X),
            start_transform: start,
            start_anchor: Vec3::new(1.0, 0.0, 0.0),
            reference: 1.0,
        };
        // Pointer at 1.83 → factor 1.83 → snaps to 1.8 (step 0.1).
        let cur = Ray::new(Vec3::new(1.83, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let snap = SnapSettings {
            scale: 0.1,
            ..SnapSettings::default()
        };
        let out = apply_drag(&drag, &cur, Some(&snap));
        assert!((out.scale.x - 1.8).abs() < 1e-4);
    }

    // --- Helpers --------------------------------------------------------

    #[test]
    fn closest_point_on_axis_recovers_perpendicular_drop() {
        let ray = Ray::new(Vec3::new(3.0, 4.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
        let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
        // Drop a perpendicular onto the X axis — should land at (3, 0, 0).
        assert!((pt - Vec3::new(3.0, 0.0, 0.0)).length() < 1e-4);
    }

    #[test]
    fn closest_point_on_axis_handles_parallel_ray() {
        // Ray along X overlaps the X axis exactly — returns the axis origin.
        let ray = Ray::new(Vec3::new(0.0, 2.0, 0.0), Vec3::X);
        let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
        assert_eq!(pt, Vec3::ZERO);
    }

    #[test]
    fn snap_round_to_step() {
        assert_eq!(snap_round(0.74, 0.25), 0.75);
        assert_eq!(snap_round(0.12, 0.25), 0.0);
        assert_eq!(snap_round(-0.74, 0.25), -0.75);
        // Zero / negative step disables snapping.
        assert_eq!(snap_round(0.74, 0.0), 0.74);
        assert_eq!(snap_round(0.74, -0.5), 0.74);
    }

    #[test]
    fn gizmo_handle_maps_to_mode() {
        assert_eq!(
            GizmoHandle::TranslateAxis(Axis3::X).mode(),
            GizmoMode::Translate
        );
        assert_eq!(
            GizmoHandle::TranslatePlane(PlaneAxis::XY).mode(),
            GizmoMode::Translate
        );
        assert_eq!(GizmoHandle::RotateAxis(Axis3::Z).mode(), GizmoMode::Rotate);
        assert_eq!(GizmoHandle::ScaleAxis(Axis3::Y).mode(), GizmoMode::Scale);
        assert_eq!(GizmoHandle::ScaleUniform.mode(), GizmoMode::Scale);
    }

    #[test]
    fn axis3_unit_and_index_align() {
        for axis in Axis3::ALL {
            let unit = axis.unit();
            let idx = axis.index();
            let mut expected = [0.0; 3];
            expected[idx] = 1.0;
            assert_eq!(unit.to_array(), expected);
        }
    }

    #[test]
    fn plane_axis_normal_is_orthogonal_to_its_axes() {
        for plane in PlaneAxis::ALL {
            let n = plane.normal();
            let (a, b) = plane.axes();
            assert!(n.dot(a).abs() < 1e-6);
            assert!(n.dot(b).abs() < 1e-6);
            // The two axes within the plane are also orthogonal.
            assert!(a.dot(b).abs() < 1e-6);
        }
    }

    // The rotation around X used FRAC_PI_2 indirectly via 90° axis drag.
    // This second test just confirms a clean 90° around Y matches the
    // expected matrix-applied direction.
    #[test]
    fn rotate_y_90_maps_x_to_minus_z() {
        // Manually construct a 90° Y rotation and confirm orientation.
        let q = Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
        let v = q * Vec3::X;
        assert!((v - Vec3::new(0.0, 0.0, -1.0)).length() < 1e-4);
    }
}
