Skip to main content

viewport_lib/interaction/
gizmo.rs

1//! Custom transform gizmo: translate, rotate, and scale handles rendered over
2//! the selected object.
3//!
4//! # Architecture
5//!
6//! `Gizmo` is a pure CPU state struct that lives in the host application. It stores the
7//! current mode (translate/rotate/scale), which axis is hovered or active, and the
8//! transform snapshot captured at drag start for undo.
9//!
10//! Rendering is handled by `gizmo_pipeline` in `ViewportGpuResources`, which uses
11//! `depth_compare: Always` so the gizmo always appears on top of scene geometry.
12//!
13//! Hit testing uses cylinder-distance approximation via `parry3d`-style math:
14//! we compute the closest-approach distance from the ray to each axis line segment,
15//! then compare against a threshold. This avoids the parry3d dependency in the gizmo
16//! module itself (the gizmo vertices are already in gizmo-local space).
17
18/// Pivot point mode for the gizmo : determines where the transform center is.
19#[derive(Debug, Clone, Copy, PartialEq)]
20#[non_exhaustive]
21pub enum PivotMode {
22    /// Average of all selected objects' positions (default).
23    SelectionCentroid,
24    /// Each object transforms around its own origin.
25    IndividualOrigins,
26    /// Median of selected object positions (alias for SelectionCentroid in single-pivot ops).
27    MedianPoint,
28    /// World origin (0, 0, 0).
29    WorldOrigin,
30    /// Arbitrary 3D cursor position.
31    Cursor3D(glam::Vec3),
32}
33
34impl PivotMode {
35    /// Advance to the next mode in the cycle.
36    ///
37    /// Cycle order: `SelectionCentroid` -> `IndividualOrigins` -> `MedianPoint` -> `WorldOrigin` -> …
38    ///
39    /// `Cursor3D` is excluded from the cycle (it requires an explicit position) and
40    /// falls back to `SelectionCentroid`.
41    pub fn cycle_next(self) -> Self {
42        match self {
43            PivotMode::SelectionCentroid => PivotMode::IndividualOrigins,
44            PivotMode::IndividualOrigins => PivotMode::MedianPoint,
45            PivotMode::MedianPoint => PivotMode::WorldOrigin,
46            PivotMode::WorldOrigin => PivotMode::SelectionCentroid,
47            PivotMode::Cursor3D(_) => PivotMode::SelectionCentroid,
48        }
49    }
50
51    /// Step back to the previous mode in the cycle.
52    pub fn cycle_prev(self) -> Self {
53        match self {
54            PivotMode::SelectionCentroid => PivotMode::WorldOrigin,
55            PivotMode::IndividualOrigins => PivotMode::SelectionCentroid,
56            PivotMode::MedianPoint => PivotMode::IndividualOrigins,
57            PivotMode::WorldOrigin => PivotMode::MedianPoint,
58            PivotMode::Cursor3D(_) => PivotMode::SelectionCentroid,
59        }
60    }
61
62    /// Short human-readable label for HUD display.
63    pub fn label(self) -> &'static str {
64        match self {
65            PivotMode::SelectionCentroid => "Selection Centroid",
66            PivotMode::IndividualOrigins => "Individual Origins",
67            PivotMode::MedianPoint => "Median Point",
68            PivotMode::WorldOrigin => "World Origin",
69            PivotMode::Cursor3D(_) => "3D Cursor",
70        }
71    }
72}
73
74/// Compute the gizmo center based on the given `PivotMode`, selection, and position resolver.
75///
76/// Returns `None` if the selection is empty or positions are unavailable.
77pub fn gizmo_center_for_pivot(
78    pivot: &PivotMode,
79    selection: &crate::interaction::selection::Selection,
80    position_fn: impl Fn(crate::interaction::selection::NodeId) -> Option<glam::Vec3>,
81) -> Option<glam::Vec3> {
82    if selection.is_empty() {
83        return None;
84    }
85    match pivot {
86        PivotMode::SelectionCentroid | PivotMode::MedianPoint => selection.centroid(position_fn),
87        PivotMode::IndividualOrigins => selection.primary().and_then(position_fn),
88        PivotMode::WorldOrigin => Some(glam::Vec3::ZERO),
89        PivotMode::Cursor3D(pos) => Some(*pos),
90    }
91}
92
93/// Gizmo interaction mode.
94#[derive(Debug, Clone, Copy, PartialEq)]
95#[non_exhaustive]
96pub enum GizmoMode {
97    /// Move selected objects along one or two axes.
98    Translate,
99    /// Rotate selected objects around an axis.
100    Rotate,
101    /// Scale selected objects along one or two axes.
102    Scale,
103}
104
105/// Which axis or handle is being hovered or dragged.
106#[derive(Debug, Clone, Copy, PartialEq)]
107#[non_exhaustive]
108pub enum GizmoAxis {
109    /// World or local X axis.
110    X,
111    /// World or local Y axis.
112    Y,
113    /// World or local Z axis.
114    Z,
115    /// XY plane handle (translate/scale in X+Y simultaneously).
116    XY,
117    /// XZ plane handle.
118    XZ,
119    /// YZ plane handle.
120    YZ,
121    /// Screen-space handle (translate in the camera plane).
122    Screen,
123    /// No axis : used when nothing is hovered or active.
124    None,
125}
126
127/// Coordinate space for gizmo axis orientation.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum GizmoSpace {
130    /// Axes aligned to world X/Y/Z.
131    World,
132    /// Axes aligned to the selected object's local orientation.
133    Local,
134}
135
136/// Gizmo CPU state : lives in the host application, not a renderer state struct.
137///
138/// Gizmo state is transient and must NOT be serialized.
139/// Note: the drag-start transform snapshot (for undo) is stored in the
140/// application struct, not here, to avoid coupling with app-specific types.
141pub struct Gizmo {
142    /// Current interaction mode.
143    pub mode: GizmoMode,
144    /// Coordinate space for axis directions.
145    pub space: GizmoSpace,
146    /// Axis under the mouse cursor (updated each hover frame).
147    pub hovered_axis: GizmoAxis,
148    /// Axis currently being dragged (set on drag start, cleared on drag end).
149    pub active_axis: GizmoAxis,
150    /// Mouse position (in viewport-local pixels) at drag start.
151    pub drag_start_mouse: Option<glam::Vec2>,
152    /// Pivot point mode : determines the transform center for multi-selections.
153    pub pivot_mode: PivotMode,
154}
155
156impl Gizmo {
157    /// Create a gizmo with default translate mode in world space.
158    pub fn new() -> Self {
159        Self {
160            mode: GizmoMode::Translate,
161            space: GizmoSpace::World,
162            hovered_axis: GizmoAxis::None,
163            active_axis: GizmoAxis::None,
164            drag_start_mouse: None,
165            pivot_mode: PivotMode::SelectionCentroid,
166        }
167    }
168
169    /// Advance the pivot mode to the next in the cycle.
170    ///
171    /// Call this when [`crate::interaction::input::Action::CyclePivotModeForward`] fires.
172    pub fn cycle_pivot_forward(&mut self) {
173        self.pivot_mode = self.pivot_mode.cycle_next();
174    }
175
176    /// Step the pivot mode back to the previous in the cycle.
177    ///
178    /// Call this when [`crate::interaction::input::Action::CyclePivotModeBackward`] fires.
179    pub fn cycle_pivot_backward(&mut self) {
180        self.pivot_mode = self.pivot_mode.cycle_prev();
181    }
182
183    /// Resolve the three axis directions based on the current space and the
184    /// given object orientation.
185    fn axis_directions(&self, object_orientation: glam::Quat) -> [glam::Vec3; 3] {
186        match self.space {
187            GizmoSpace::World => [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z],
188            GizmoSpace::Local => [
189                object_orientation * glam::Vec3::X,
190                object_orientation * glam::Vec3::Y,
191                object_orientation * glam::Vec3::Z,
192            ],
193        }
194    }
195
196    /// Hit test: given a ray in world space, the gizmo's center position, and
197    /// the selected object's orientation, return which handle is under the cursor.
198    ///
199    /// Uses closest-approach distance from ray to axis line segment for axis
200    /// handles, and ray-plane intersection for plane/screen handles.
201    ///
202    /// # Arguments
203    ///
204    /// * `ray_origin` : world-space origin of the picking ray
205    /// * `ray_dir` : world-space direction of the picking ray (normalized)
206    /// * `gizmo_center` : world-space position of the gizmo (== selected object position)
207    /// * `gizmo_scale` : world-space length of each axis arm
208    pub fn hit_test(
209        &self,
210        ray_origin: glam::Vec3,
211        ray_dir: glam::Vec3,
212        gizmo_center: glam::Vec3,
213        gizmo_scale: f32,
214    ) -> GizmoAxis {
215        self.hit_test_oriented(
216            ray_origin,
217            ray_dir,
218            gizmo_center,
219            gizmo_scale,
220            glam::Quat::IDENTITY,
221        )
222    }
223
224    /// Hit test with an explicit object orientation for local-space gizmo.
225    pub fn hit_test_oriented(
226        &self,
227        ray_origin: glam::Vec3,
228        ray_dir: glam::Vec3,
229        gizmo_center: glam::Vec3,
230        gizmo_scale: f32,
231        object_orientation: glam::Quat,
232    ) -> GizmoAxis {
233        let dirs = self.axis_directions(object_orientation);
234
235        match self.mode {
236            GizmoMode::Rotate => {
237                // Test rotation rings: each ring is a circle in the plane perpendicular
238                // to its axis. We intersect the ray with that plane and check if the
239                // hit point lies on the ring (within a tolerance band).
240                let ring_radius = gizmo_scale * ROTATION_RING_RADIUS;
241                let ring_tolerance = gizmo_scale * 0.15;
242
243                let axis_ids = [GizmoAxis::X, GizmoAxis::Y, GizmoAxis::Z];
244                let mut best: Option<(GizmoAxis, f32)> = None;
245
246                for i in 0..3 {
247                    let normal = dirs[i];
248                    let denom = ray_dir.dot(normal);
249                    if denom.abs() < 1e-6 {
250                        continue;
251                    }
252                    let t = (gizmo_center - ray_origin).dot(normal) / denom;
253                    if t < 0.0 {
254                        continue;
255                    }
256                    let hit_point = ray_origin + ray_dir * t;
257                    let dist_from_center = (hit_point - gizmo_center).length();
258                    if (dist_from_center - ring_radius).abs() < ring_tolerance
259                        && (best.is_none() || t < best.unwrap().1)
260                    {
261                        best = Some((axis_ids[i], t));
262                    }
263                }
264
265                best.map(|(a, _)| a).unwrap_or(GizmoAxis::None)
266            }
267            _ => {
268                // Translate / Scale modes: arrows + plane handles + screen handle.
269                let hit_radius = gizmo_scale * 0.18;
270
271                // --- Screen handle (center square): check first, highest priority ---
272                let screen_size = gizmo_scale * 0.15;
273                let to_center = gizmo_center - ray_origin;
274                let t_center = to_center.dot(ray_dir);
275                if t_center > 0.0 {
276                    let closest = ray_origin + ray_dir * t_center;
277                    let offset = closest - gizmo_center;
278                    if offset.length() < screen_size {
279                        return GizmoAxis::Screen;
280                    }
281                }
282
283                // --- Plane handles (small quads at axis-pair corners) ---
284                let plane_offset = gizmo_scale * 0.25;
285                let plane_size = gizmo_scale * 0.15;
286
287                let plane_handles = [
288                    (GizmoAxis::XY, dirs[0], dirs[1], dirs[2]),
289                    (GizmoAxis::XZ, dirs[0], dirs[2], dirs[1]),
290                    (GizmoAxis::YZ, dirs[1], dirs[2], dirs[0]),
291                ];
292
293                let mut best_plane: Option<(GizmoAxis, f32)> = None;
294                for (axis, dir_a, dir_b, normal) in &plane_handles {
295                    let quad_center = gizmo_center + *dir_a * plane_offset + *dir_b * plane_offset;
296                    let denom = ray_dir.dot(*normal);
297                    if denom.abs() < 1e-6 {
298                        continue;
299                    }
300                    let t = (quad_center - ray_origin).dot(*normal) / denom;
301                    if t < 0.0 {
302                        continue;
303                    }
304                    let hit_point = ray_origin + ray_dir * t;
305                    let local = hit_point - quad_center;
306                    let a_dist = local.dot(*dir_a).abs();
307                    let b_dist = local.dot(*dir_b).abs();
308                    if a_dist < plane_size
309                        && b_dist < plane_size
310                        && (best_plane.is_none() || t < best_plane.unwrap().1)
311                    {
312                        best_plane = Some((*axis, t));
313                    }
314                }
315                if let Some((axis, _)) = best_plane {
316                    return axis;
317                }
318
319                // --- Single-axis handles ---
320                let axis_ids = [GizmoAxis::X, GizmoAxis::Y, GizmoAxis::Z];
321                let mut best: Option<(GizmoAxis, f32)> = None;
322
323                for i in 0..3 {
324                    let arm_end = gizmo_center + dirs[i] * gizmo_scale;
325                    let dist = ray_to_segment_distance(ray_origin, ray_dir, gizmo_center, arm_end);
326                    if dist < hit_radius {
327                        let t = ray_segment_t(ray_origin, ray_dir, gizmo_center, arm_end);
328                        if best.is_none() || t < best.unwrap().1 {
329                            best = Some((axis_ids[i], t));
330                        }
331                    }
332                }
333
334                best.map(|(a, _)| a).unwrap_or(GizmoAxis::None)
335            }
336        }
337    }
338}
339
340impl Default for Gizmo {
341    fn default() -> Self {
342        Self::new()
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Ray / segment distance math
348// ---------------------------------------------------------------------------
349
350/// Compute the shortest distance between a ray and a line segment.
351///
352/// Returns the distance in world units. Uses the standard closest-point-on-ray
353/// to closest-point-on-segment approach.
354fn ray_to_segment_distance(
355    ray_origin: glam::Vec3,
356    ray_dir: glam::Vec3,
357    seg_a: glam::Vec3,
358    seg_b: glam::Vec3,
359) -> f32 {
360    let seg_dir = seg_b - seg_a;
361    let w0 = ray_origin - seg_a;
362
363    let a = ray_dir.dot(ray_dir); // |ray_dir|^2 (== 1 if normalized)
364    let b = ray_dir.dot(seg_dir);
365    let c = seg_dir.dot(seg_dir);
366    let d = ray_dir.dot(w0);
367    let e = seg_dir.dot(w0);
368
369    let denom = a * c - b * b;
370
371    let (t_ray, t_seg) = if denom.abs() > 1e-8 {
372        let t_r = (b * e - c * d) / denom;
373        let t_s = (a * e - b * d) / denom;
374        (t_r.max(0.0), t_s.clamp(0.0, 1.0))
375    } else {
376        // Parallel: closest point on segment start.
377        (0.0, 0.0)
378    };
379
380    let closest_ray = ray_origin + ray_dir * t_ray;
381    let closest_seg = seg_a + seg_dir * t_seg;
382    (closest_ray - closest_seg).length()
383}
384
385/// Return the ray parameter `t` at which the ray is closest to the segment.
386///
387/// Used to pick the nearest axis when multiple axes are within hit radius.
388fn ray_segment_t(
389    ray_origin: glam::Vec3,
390    ray_dir: glam::Vec3,
391    seg_a: glam::Vec3,
392    seg_b: glam::Vec3,
393) -> f32 {
394    let seg_dir = seg_b - seg_a;
395    let w0 = ray_origin - seg_a;
396
397    let a = ray_dir.dot(ray_dir);
398    let b = ray_dir.dot(seg_dir);
399    let c = seg_dir.dot(seg_dir);
400    let d = ray_dir.dot(w0);
401    let e = seg_dir.dot(w0);
402
403    let denom = a * c - b * b;
404    if denom.abs() > 1e-8 {
405        let t_r = (b * e - c * d) / denom;
406        t_r.max(0.0)
407    } else {
408        0.0
409    }
410}
411
412// ---------------------------------------------------------------------------
413// Gizmo mesh generation (mode-aware)
414// ---------------------------------------------------------------------------
415
416/// Vertex type reused from resources module (position, normal, color).
417pub use crate::resources::Vertex;
418
419/// Axis color definitions (per UI-SPEC).
420/// X = red, Y = green, Z = blue; brightened variants for hover.
421const X_COLOR: [f32; 4] = [0.878, 0.322, 0.322, 1.0]; // #e05252
422const Y_COLOR: [f32; 4] = [0.361, 0.722, 0.361, 1.0]; // #5cb85c
423const Z_COLOR: [f32; 4] = [0.290, 0.620, 1.0, 1.0]; // #4a9eff
424
425const X_COLOR_HOV: [f32; 4] = [1.0, 0.518, 0.518, 1.0]; // X * 1.3 clamped
426const Y_COLOR_HOV: [f32; 4] = [0.469, 0.938, 0.469, 1.0]; // Y * 1.3 clamped
427const Z_COLOR_HOV: [f32; 4] = [0.377, 0.806, 1.0, 1.0]; // Z * 1.3 clamped
428
429const SCREEN_COLOR: [f32; 4] = [0.9, 0.9, 0.9, 0.6];
430const SCREEN_COLOR_HOV: [f32; 4] = [1.0, 1.0, 1.0, 0.8];
431const PLANE_ALPHA: f32 = 0.3;
432const PLANE_ALPHA_HOV: f32 = 0.5;
433
434/// Select the base or hover color for an axis based on whether it's hovered.
435fn axis_color(axis: GizmoAxis, hovered: GizmoAxis) -> [f32; 4] {
436    let is_hovered = axis == hovered;
437    match axis {
438        GizmoAxis::X => {
439            if is_hovered {
440                X_COLOR_HOV
441            } else {
442                X_COLOR
443            }
444        }
445        GizmoAxis::Y => {
446            if is_hovered {
447                Y_COLOR_HOV
448            } else {
449                Y_COLOR
450            }
451        }
452        GizmoAxis::Z => {
453            if is_hovered {
454                Z_COLOR_HOV
455            } else {
456                Z_COLOR
457            }
458        }
459        GizmoAxis::Screen => {
460            if is_hovered {
461                SCREEN_COLOR_HOV
462            } else {
463                SCREEN_COLOR
464            }
465        }
466        _ => [1.0; 4],
467    }
468}
469
470/// Get the color for a plane handle, blending the two axis colors.
471/// On hover, RGB is brightened by 1.3× (clamped) in addition to the alpha bump.
472fn plane_color(axis: GizmoAxis, hovered: GizmoAxis) -> [f32; 4] {
473    let is_hovered = axis == hovered;
474    let alpha = if is_hovered {
475        PLANE_ALPHA_HOV
476    } else {
477        PLANE_ALPHA
478    };
479    let brightness = if is_hovered { 1.3 } else { 1.0 };
480    let (c1, c2) = match axis {
481        GizmoAxis::XY => (X_COLOR, Y_COLOR),
482        GizmoAxis::XZ => (X_COLOR, Z_COLOR),
483        GizmoAxis::YZ => (Y_COLOR, Z_COLOR),
484        _ => return [1.0, 1.0, 1.0, alpha],
485    };
486    [
487        ((c1[0] + c2[0]) * 0.5 * brightness).min(1.0),
488        ((c1[1] + c2[1]) * 0.5 * brightness).min(1.0),
489        ((c1[2] + c2[2]) * 0.5 * brightness).min(1.0),
490        alpha,
491    ]
492}
493
494/// Build gizmo mesh for the specified mode.
495///
496/// - **Translate:** arrows + plane quads + center square
497/// - **Rotate:** torus ring segments around each axis
498/// - **Scale:** arrows with cube tips instead of cones
499///
500/// All geometry is in gizmo-local space. The `space_orientation` quaternion
501/// rotates axis geometry for local-space mode (identity for world space).
502///
503/// Returns `(vertices, indices)`.
504pub(crate) fn build_gizmo_mesh(
505    mode: GizmoMode,
506    hovered: GizmoAxis,
507    space_orientation: glam::Quat,
508) -> (Vec<Vertex>, Vec<u32>) {
509    let mut vertices: Vec<Vertex> = Vec::new();
510    let mut indices: Vec<u32> = Vec::new();
511
512    match mode {
513        GizmoMode::Translate => {
514            build_arrows(
515                &mut vertices,
516                &mut indices,
517                hovered,
518                space_orientation,
519                false,
520            );
521            build_plane_quads(&mut vertices, &mut indices, hovered, space_orientation);
522            build_screen_handle(&mut vertices, &mut indices, hovered);
523        }
524        GizmoMode::Rotate => {
525            build_rotation_rings(&mut vertices, &mut indices, hovered, space_orientation);
526        }
527        GizmoMode::Scale => {
528            build_arrows(
529                &mut vertices,
530                &mut indices,
531                hovered,
532                space_orientation,
533                true,
534            );
535        }
536    }
537
538    (vertices, indices)
539}
540
541/// Arrow proportions in gizmo-local units.
542const SHAFT_RADIUS: f32 = 0.035;
543const SHAFT_LENGTH: f32 = 0.70;
544/// Major radius of the rotation rings : used in both mesh generation and hit testing.
545pub const ROTATION_RING_RADIUS: f32 = 0.85;
546const CONE_RADIUS: f32 = 0.09;
547const CONE_LENGTH: f32 = 0.30;
548const CUBE_HALF: f32 = 0.06;
549const SEGMENTS: u32 = 8;
550
551/// Build arrow geometry for all 3 axes. If `cube_tips` is true, use cubes
552/// instead of cones (for Scale mode).
553fn build_arrows(
554    vertices: &mut Vec<Vertex>,
555    indices: &mut Vec<u32>,
556    hovered: GizmoAxis,
557    orientation: glam::Quat,
558    cube_tips: bool,
559) {
560    let base_axes = [
561        (GizmoAxis::X, glam::Vec3::X, glam::Vec3::Y),
562        (GizmoAxis::Y, glam::Vec3::Y, glam::Vec3::X),
563        (GizmoAxis::Z, glam::Vec3::Z, glam::Vec3::Y),
564    ];
565
566    for (axis, raw_dir, raw_up) in &base_axes {
567        let axis_dir = orientation * *raw_dir;
568        let up_hint = orientation * *raw_up;
569        let color = axis_color(*axis, hovered);
570
571        let tangent = if axis_dir.abs().dot(orientation * glam::Vec3::Y) > 0.9 {
572            axis_dir.cross(up_hint).normalize()
573        } else {
574            axis_dir.cross(orientation * glam::Vec3::Y).normalize()
575        };
576        let bitangent = axis_dir.cross(tangent).normalize();
577
578        let base_index = vertices.len() as u32;
579
580        // --- Shaft cylinder ---
581        let shaft_bottom = glam::Vec3::ZERO;
582        let shaft_top = axis_dir * SHAFT_LENGTH;
583
584        for i in 0..SEGMENTS {
585            let angle = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
586            let radial = tangent * angle.cos() + bitangent * angle.sin();
587
588            vertices.push(Vertex {
589                position: (shaft_bottom + radial * SHAFT_RADIUS).to_array(),
590                normal: radial.to_array(),
591                color,
592                uv: [0.0, 0.0],
593                tangent: [0.0, 0.0, 0.0, 1.0],
594            });
595            vertices.push(Vertex {
596                position: (shaft_top + radial * SHAFT_RADIUS).to_array(),
597                normal: radial.to_array(),
598                color,
599                uv: [0.0, 0.0],
600                tangent: [0.0, 0.0, 0.0, 1.0],
601            });
602        }
603
604        // Shaft side indices.
605        for i in 0..SEGMENTS {
606            let next = (i + 1) % SEGMENTS;
607            let b0 = base_index + i * 2;
608            let t0 = base_index + i * 2 + 1;
609            let b1 = base_index + next * 2;
610            let t1 = base_index + next * 2 + 1;
611            indices.extend_from_slice(&[b0, b1, t0, t0, b1, t1]);
612        }
613
614        // Shaft bottom cap.
615        let shaft_bottom_center = vertices.len() as u32;
616        vertices.push(Vertex {
617            position: shaft_bottom.to_array(),
618            normal: (-axis_dir).to_array(),
619            color,
620            uv: [0.0, 0.0],
621            tangent: [0.0, 0.0, 0.0, 1.0],
622        });
623        for i in 0..SEGMENTS {
624            let next = (i + 1) % SEGMENTS;
625            let v0 = base_index + i * 2;
626            let v1 = base_index + next * 2;
627            indices.extend_from_slice(&[shaft_bottom_center, v1, v0]);
628        }
629
630        // --- Tip ---
631        let tip_base = shaft_top;
632        if cube_tips {
633            build_cube_tip(
634                vertices, indices, tip_base, axis_dir, tangent, bitangent, color,
635            );
636        } else {
637            build_cone_tip(
638                vertices, indices, tip_base, axis_dir, tangent, bitangent, color,
639            );
640        }
641    }
642}
643
644/// Build a cone tip for translate arrows.
645fn build_cone_tip(
646    vertices: &mut Vec<Vertex>,
647    indices: &mut Vec<u32>,
648    base_center: glam::Vec3,
649    axis_dir: glam::Vec3,
650    tangent: glam::Vec3,
651    bitangent: glam::Vec3,
652    color: [f32; 4],
653) {
654    let cone_tip = base_center + axis_dir * CONE_LENGTH;
655    let cone_base_start = vertices.len() as u32;
656
657    // Base ring.
658    for i in 0..SEGMENTS {
659        let angle = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
660        let radial = tangent * angle.cos() + bitangent * angle.sin();
661        vertices.push(Vertex {
662            position: (base_center + radial * CONE_RADIUS).to_array(),
663            normal: (-axis_dir).to_array(),
664            color,
665            uv: [0.0, 0.0],
666            tangent: [0.0, 0.0, 0.0, 1.0],
667        });
668    }
669
670    // Base cap center.
671    let base_cap_center = vertices.len() as u32;
672    vertices.push(Vertex {
673        position: base_center.to_array(),
674        normal: (-axis_dir).to_array(),
675        color,
676        uv: [0.0, 0.0],
677        tangent: [0.0, 0.0, 0.0, 1.0],
678    });
679    for i in 0..SEGMENTS {
680        let next = (i + 1) % SEGMENTS;
681        indices.extend_from_slice(&[base_cap_center, cone_base_start + i, cone_base_start + next]);
682    }
683
684    // Tip vertex + side triangles.
685    let tip_idx = vertices.len() as u32;
686    vertices.push(Vertex {
687        position: cone_tip.to_array(),
688        normal: axis_dir.to_array(),
689        color,
690        uv: [0.0, 0.0],
691        tangent: [0.0, 0.0, 0.0, 1.0],
692    });
693    for i in 0..SEGMENTS {
694        let next = (i + 1) % SEGMENTS;
695        indices.extend_from_slice(&[cone_base_start + i, cone_base_start + next, tip_idx]);
696    }
697}
698
699/// Build a cube tip for scale arrows.
700fn build_cube_tip(
701    vertices: &mut Vec<Vertex>,
702    indices: &mut Vec<u32>,
703    center: glam::Vec3,
704    axis_dir: glam::Vec3,
705    tangent: glam::Vec3,
706    bitangent: glam::Vec3,
707    color: [f32; 4],
708) {
709    let cube_center = center + axis_dir * CUBE_HALF;
710    let h = CUBE_HALF;
711
712    // 8 corners of the cube.
713    let corners = [
714        cube_center + axis_dir * (-h) + tangent * (-h) + bitangent * (-h),
715        cube_center + axis_dir * h + tangent * (-h) + bitangent * (-h),
716        cube_center + axis_dir * h + tangent * h + bitangent * (-h),
717        cube_center + axis_dir * (-h) + tangent * h + bitangent * (-h),
718        cube_center + axis_dir * (-h) + tangent * (-h) + bitangent * h,
719        cube_center + axis_dir * h + tangent * (-h) + bitangent * h,
720        cube_center + axis_dir * h + tangent * h + bitangent * h,
721        cube_center + axis_dir * (-h) + tangent * h + bitangent * h,
722    ];
723
724    // 6 faces (each face = 4 vertices with face normal + 2 triangles).
725    let faces: [([usize; 4], glam::Vec3); 6] = [
726        ([1, 2, 6, 5], axis_dir),   // +axis
727        ([0, 4, 7, 3], -axis_dir),  // -axis
728        ([2, 3, 7, 6], tangent),    // +tangent
729        ([0, 1, 5, 4], -tangent),   // -tangent
730        ([4, 5, 6, 7], bitangent),  // +bitangent
731        ([0, 3, 2, 1], -bitangent), // -bitangent
732    ];
733
734    for (corner_ids, normal) in &faces {
735        let base = vertices.len() as u32;
736        for &ci in corner_ids {
737            vertices.push(Vertex {
738                position: corners[ci].to_array(),
739                normal: normal.to_array(),
740                color,
741                uv: [0.0, 0.0],
742                tangent: [0.0, 0.0, 0.0, 1.0],
743            });
744        }
745        indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
746    }
747}
748
749/// Build small transparent plane-handle quads at axis-pair corners.
750fn build_plane_quads(
751    vertices: &mut Vec<Vertex>,
752    indices: &mut Vec<u32>,
753    hovered: GizmoAxis,
754    orientation: glam::Quat,
755) {
756    let plane_offset = 0.25_f32;
757    let plane_size = 0.15_f32;
758
759    let planes = [
760        (GizmoAxis::XY, glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
761        (GizmoAxis::XZ, glam::Vec3::X, glam::Vec3::Z, glam::Vec3::Y),
762        (GizmoAxis::YZ, glam::Vec3::Y, glam::Vec3::Z, glam::Vec3::X),
763    ];
764
765    for (axis, dir_a, dir_b, normal_dir) in &planes {
766        let a = orientation * *dir_a;
767        let b = orientation * *dir_b;
768        let n = orientation * *normal_dir;
769        let center = a * plane_offset + b * plane_offset;
770        let color = plane_color(*axis, hovered);
771
772        let base = vertices.len() as u32;
773        let corners = [
774            center + a * (-plane_size) + b * (-plane_size),
775            center + a * plane_size + b * (-plane_size),
776            center + a * plane_size + b * plane_size,
777            center + a * (-plane_size) + b * plane_size,
778        ];
779        for c in &corners {
780            vertices.push(Vertex {
781                position: c.to_array(),
782                normal: n.to_array(),
783                color,
784                uv: [0.0, 0.0],
785                tangent: [0.0, 0.0, 0.0, 1.0],
786            });
787        }
788        // Two triangles (double-sided via two-face winding).
789        indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
790        indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
791    }
792}
793
794/// Build a small center square for screen-space translate.
795fn build_screen_handle(vertices: &mut Vec<Vertex>, indices: &mut Vec<u32>, hovered: GizmoAxis) {
796    let size = 0.08_f32;
797    let color = axis_color(GizmoAxis::Screen, hovered);
798    let base = vertices.len() as u32;
799
800    // Small quad in XY plane at the origin. The gizmo uniform will orient it
801    // toward the camera via billboard, but for simplicity we emit in XY.
802    let corners = [
803        glam::Vec3::new(-size, -size, 0.0),
804        glam::Vec3::new(size, -size, 0.0),
805        glam::Vec3::new(size, size, 0.0),
806        glam::Vec3::new(-size, size, 0.0),
807    ];
808    for c in &corners {
809        vertices.push(Vertex {
810            position: c.to_array(),
811            normal: [0.0, 0.0, 1.0],
812            color,
813            uv: [0.0, 0.0],
814            tangent: [0.0, 0.0, 0.0, 1.0],
815        });
816    }
817    indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
818    indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
819}
820
821/// Build torus ring segments for rotation mode.
822fn build_rotation_rings(
823    vertices: &mut Vec<Vertex>,
824    indices: &mut Vec<u32>,
825    hovered: GizmoAxis,
826    orientation: glam::Quat,
827) {
828    let ring_radius = ROTATION_RING_RADIUS; // major radius
829    let tube_radius = 0.025_f32; // minor radius
830    let ring_segments = 40_u32;
831    let tube_segments = 8_u32;
832
833    let axis_data = [
834        (GizmoAxis::X, glam::Vec3::X),
835        (GizmoAxis::Y, glam::Vec3::Y),
836        (GizmoAxis::Z, glam::Vec3::Z),
837    ];
838
839    for (axis, raw_dir) in &axis_data {
840        let axis_dir = orientation * *raw_dir;
841        let color = axis_color(*axis, hovered);
842
843        // Build two perpendicular vectors in the plane of the ring.
844        let (ring_u, ring_v) = perpendicular_pair(axis_dir);
845
846        let base = vertices.len() as u32;
847
848        for i in 0..ring_segments {
849            let theta = (i as f32) * std::f32::consts::TAU / (ring_segments as f32);
850            let cos_t = theta.cos();
851            let sin_t = theta.sin();
852            // Point on the ring centerline.
853            let ring_center = (ring_u * cos_t + ring_v * sin_t) * ring_radius;
854            // Outward direction from the torus center.
855            let outward = (ring_u * cos_t + ring_v * sin_t).normalize();
856
857            for j in 0..tube_segments {
858                let phi = (j as f32) * std::f32::consts::TAU / (tube_segments as f32);
859                let cos_p = phi.cos();
860                let sin_p = phi.sin();
861                let normal = outward * cos_p + axis_dir * sin_p;
862                let pos = ring_center + normal * tube_radius;
863
864                vertices.push(Vertex {
865                    position: pos.to_array(),
866                    normal: normal.to_array(),
867                    color,
868                    uv: [0.0, 0.0],
869                    tangent: [0.0, 0.0, 0.0, 1.0],
870                });
871            }
872        }
873
874        // Indices: connect adjacent ring segments.
875        for i in 0..ring_segments {
876            let next_i = (i + 1) % ring_segments;
877            for j in 0..tube_segments {
878                let next_j = (j + 1) % tube_segments;
879                let v00 = base + i * tube_segments + j;
880                let v01 = base + i * tube_segments + next_j;
881                let v10 = base + next_i * tube_segments + j;
882                let v11 = base + next_i * tube_segments + next_j;
883                indices.extend_from_slice(&[v00, v10, v01, v01, v10, v11]);
884            }
885        }
886    }
887}
888
889/// Compute two perpendicular unit vectors to the given axis.
890fn perpendicular_pair(axis: glam::Vec3) -> (glam::Vec3, glam::Vec3) {
891    let hint = if axis.dot(glam::Vec3::Z).abs() > 0.9 {
892        glam::Vec3::X
893    } else {
894        glam::Vec3::Z
895    };
896    let u = axis.cross(hint).normalize();
897    let v = axis.cross(u).normalize();
898    (u, v)
899}
900
901// ---------------------------------------------------------------------------
902// Gizmo scale computation
903// ---------------------------------------------------------------------------
904
905/// Compute the world-space scale for the gizmo so it appears at a consistent
906/// screen size regardless of camera distance.
907///
908/// `gizmo_center_world` : position of the gizmo (selected object)
909/// `camera_eye` : camera eye position
910/// `fov_y` : camera vertical field of view (radians)
911/// `viewport_height` : viewport height in pixels
912/// Target: gizmo should be approximately 100px tall on screen.
913pub fn compute_gizmo_scale(
914    gizmo_center_world: glam::Vec3,
915    camera_eye: glam::Vec3,
916    fov_y: f32,
917    viewport_height: f32,
918) -> f32 {
919    let dist = (gizmo_center_world - camera_eye).length();
920    // world_units_per_pixel at distance = 2 * tan(fov_y/2) * dist / viewport_height
921    let world_per_px = 2.0 * (fov_y * 0.5).tan() * dist / viewport_height;
922    // Target: 100 pixels = gizmo total length.
923    let target_px = 100.0_f32;
924    world_per_px * target_px
925}
926
927// ---------------------------------------------------------------------------
928// GizmoUniform (mirrors the WGSL struct)
929// ---------------------------------------------------------------------------
930
931/// Uniform data for the gizmo shader: model matrix positioning the gizmo.
932#[repr(C)]
933#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
934pub(crate) struct GizmoUniform {
935    /// World-space model matrix (Translation * Scale; no rotation for world-space gizmo).
936    pub(crate) model: [[f32; 4]; 4],
937}
938
939// ---------------------------------------------------------------------------
940// Drag math helpers
941// ---------------------------------------------------------------------------
942
943/// Project mouse delta (in screen pixels) onto a world-space axis direction.
944///
945/// Returns the signed scalar amount to move along the axis.
946///
947/// # Arguments
948/// * `drag_delta` : mouse movement in pixels since drag start (egui drag_delta())
949/// * `axis_world` : world-space axis direction (X, Y, or Z unit vector)
950/// * `view_proj` : camera view-projection matrix
951/// * `gizmo_center` : world-space gizmo center (selected object position)
952/// * `viewport_size` : viewport size in pixels
953pub fn project_drag_onto_axis(
954    drag_delta: glam::Vec2,
955    axis_world: glam::Vec3,
956    view_proj: glam::Mat4,
957    gizmo_center: glam::Vec3,
958    viewport_size: glam::Vec2,
959) -> f32 {
960    // Project the axis tip and base to screen space.
961    let base_ndc = view_proj.project_point3(gizmo_center);
962    let tip_ndc = view_proj.project_point3(gizmo_center + axis_world);
963
964    // Convert NDC to screen pixels.
965    let base_screen = glam::Vec2::new(
966        (base_ndc.x + 1.0) * 0.5 * viewport_size.x,
967        (1.0 - base_ndc.y) * 0.5 * viewport_size.y,
968    );
969    let tip_screen = glam::Vec2::new(
970        (tip_ndc.x + 1.0) * 0.5 * viewport_size.x,
971        (1.0 - tip_ndc.y) * 0.5 * viewport_size.y,
972    );
973
974    let axis_screen = tip_screen - base_screen;
975    let axis_screen_len = axis_screen.length();
976
977    if axis_screen_len < 1e-4 {
978        return 0.0;
979    }
980
981    // Project the mouse drag onto the screen-space axis direction.
982    let axis_screen_norm = axis_screen / axis_screen_len;
983    let drag_along_axis = drag_delta.dot(axis_screen_norm);
984
985    // Convert screen pixels back to world units.
986    // 1 world unit projects to `axis_screen_len` pixels.
987    drag_along_axis / axis_screen_len
988}
989
990/// Project mouse delta onto a rotation axis, returning an angle in radians.
991///
992/// For rotation, we use: move right/up on screen = positive rotation.
993/// The axis being dragged is perpendicular in screen space.
994pub fn project_drag_onto_rotation(
995    drag_delta: glam::Vec2,
996    axis_world: glam::Vec3,
997    view: glam::Mat4,
998) -> f32 {
999    // Project the rotation axis into camera space to find the perpendicular
1000    // screen direction.
1001    let axis_cam = (view * axis_world.extend(0.0))
1002        .truncate()
1003        .normalize_or_zero();
1004
1005    // The perpendicular of the axis in screen space.
1006    let perp = glam::Vec2::new(-axis_cam.y, axis_cam.x);
1007    let perp_len = perp.length();
1008    if perp_len < 1e-4 {
1009        return 0.0;
1010    }
1011
1012    // Project drag delta onto the perpendicular direction.
1013    let perp_norm = perp / perp_len;
1014    let drag_amount = drag_delta.dot(perp_norm);
1015
1016    // Scale: 1 full-screen drag = 2π radians (reasonable sensitivity).
1017    drag_amount * 0.02
1018}
1019
1020/// Project mouse delta onto a world-space plane defined by two axis directions.
1021///
1022/// Returns the 3D world-space displacement vector in the plane.
1023pub fn project_drag_onto_plane(
1024    drag_delta: glam::Vec2,
1025    axis_a: glam::Vec3,
1026    axis_b: glam::Vec3,
1027    view_proj: glam::Mat4,
1028    gizmo_center: glam::Vec3,
1029    viewport_size: glam::Vec2,
1030) -> glam::Vec3 {
1031    let a_amount =
1032        project_drag_onto_axis(drag_delta, axis_a, view_proj, gizmo_center, viewport_size);
1033    let b_amount =
1034        project_drag_onto_axis(drag_delta, axis_b, view_proj, gizmo_center, viewport_size);
1035    axis_a * a_amount + axis_b * b_amount
1036}
1037
1038/// Project mouse delta onto the camera-facing (screen) plane.
1039///
1040/// Returns the 3D world-space displacement vector.
1041pub fn project_drag_onto_screen_plane(
1042    drag_delta: glam::Vec2,
1043    camera_right: glam::Vec3,
1044    camera_up: glam::Vec3,
1045    view_proj: glam::Mat4,
1046    gizmo_center: glam::Vec3,
1047    viewport_size: glam::Vec2,
1048) -> glam::Vec3 {
1049    project_drag_onto_plane(
1050        drag_delta,
1051        camera_right,
1052        camera_up,
1053        view_proj,
1054        gizmo_center,
1055        viewport_size,
1056    )
1057}
1058
1059/// Compute the gizmo center from a multi-selection by averaging positions.
1060///
1061/// Thin wrapper around `Selection::centroid()` for discoverability in gizmo workflows.
1062pub fn gizmo_center_from_selection(
1063    selection: &crate::interaction::selection::Selection,
1064    position_fn: impl Fn(crate::interaction::selection::NodeId) -> Option<glam::Vec3>,
1065) -> Option<glam::Vec3> {
1066    selection.centroid(position_fn)
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    fn gizmo() -> Gizmo {
1074        Gizmo::new()
1075    }
1076
1077    #[test]
1078    fn test_hit_test_x_axis() {
1079        let g = gizmo();
1080        let center = glam::Vec3::ZERO;
1081        let scale = 1.0;
1082        let axis = g.hit_test(
1083            glam::Vec3::new(0.5, 0.5, 0.0),
1084            glam::Vec3::new(0.0, -1.0, 0.0),
1085            center,
1086            scale,
1087        );
1088        assert_eq!(axis, GizmoAxis::X);
1089    }
1090
1091    #[test]
1092    fn test_hit_test_y_axis() {
1093        let g = gizmo();
1094        let center = glam::Vec3::ZERO;
1095        let scale = 1.0;
1096        let axis = g.hit_test(
1097            glam::Vec3::new(0.5, 0.5, 0.0),
1098            glam::Vec3::new(-1.0, 0.0, 0.0),
1099            center,
1100            scale,
1101        );
1102        assert_eq!(axis, GizmoAxis::Y);
1103    }
1104
1105    #[test]
1106    fn test_hit_test_z_axis() {
1107        let g = gizmo();
1108        let center = glam::Vec3::ZERO;
1109        let scale = 1.0;
1110        let axis = g.hit_test(
1111            glam::Vec3::new(0.0, 0.5, 0.5),
1112            glam::Vec3::new(0.0, -1.0, 0.0),
1113            center,
1114            scale,
1115        );
1116        assert_eq!(axis, GizmoAxis::Z);
1117    }
1118
1119    #[test]
1120    fn test_hit_test_miss() {
1121        let g = gizmo();
1122        let center = glam::Vec3::ZERO;
1123        let scale = 1.0;
1124        let axis = g.hit_test(
1125            glam::Vec3::new(10.0, 10.0, 10.0),
1126            glam::Vec3::new(0.0, 0.0, -1.0),
1127            center,
1128            scale,
1129        );
1130        assert_eq!(axis, GizmoAxis::None);
1131    }
1132
1133    #[test]
1134    fn test_hit_test_plane_handle_xy() {
1135        let g = gizmo();
1136        let center = glam::Vec3::ZERO;
1137        let scale = 1.0;
1138        // Ray coming from +Z, hitting the XY plane handle area (at ~0.25, 0.25).
1139        let axis = g.hit_test_oriented(
1140            glam::Vec3::new(0.25, 0.25, 5.0),
1141            glam::Vec3::new(0.0, 0.0, -1.0),
1142            center,
1143            scale,
1144            glam::Quat::IDENTITY,
1145        );
1146        assert_eq!(axis, GizmoAxis::XY, "expected XY plane handle hit");
1147    }
1148
1149    #[test]
1150    fn test_hit_test_local_orientation() {
1151        let mut g = gizmo();
1152        g.space = GizmoSpace::Local;
1153        let center = glam::Vec3::ZERO;
1154        let scale = 1.0;
1155        // Rotate the object 90° around Y: local X -> world -Z, local Z -> world +X.
1156        let rot = glam::Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
1157
1158        // A ray that hits along world -Z direction should hit local X axis.
1159        // Local X arm goes from origin to (0, 0, -1).
1160        let axis = g.hit_test_oriented(
1161            glam::Vec3::new(0.0, 0.5, -0.5),
1162            glam::Vec3::new(0.0, -1.0, 0.0),
1163            center,
1164            scale,
1165            rot,
1166        );
1167        assert_eq!(
1168            axis,
1169            GizmoAxis::X,
1170            "local X axis should be along world -Z after 90° Y rotation"
1171        );
1172    }
1173
1174    #[test]
1175    fn test_project_drag_onto_axis() {
1176        let view = glam::Mat4::look_at_rh(
1177            glam::Vec3::new(0.0, 0.0, 5.0),
1178            glam::Vec3::ZERO,
1179            glam::Vec3::Y,
1180        );
1181        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1182        let vp = proj * view;
1183        let viewport_size = glam::Vec2::new(800.0, 600.0);
1184        let center = glam::Vec3::ZERO;
1185
1186        let result = project_drag_onto_axis(
1187            glam::Vec2::new(100.0, 0.0),
1188            glam::Vec3::X,
1189            vp,
1190            center,
1191            viewport_size,
1192        );
1193        assert!(result > 0.0, "expected positive drag along X, got {result}");
1194    }
1195
1196    #[test]
1197    fn test_project_drag_onto_plane() {
1198        let view = glam::Mat4::look_at_rh(
1199            glam::Vec3::new(0.0, 5.0, 5.0),
1200            glam::Vec3::ZERO,
1201            glam::Vec3::Y,
1202        );
1203        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1204        let vp = proj * view;
1205        let viewport_size = glam::Vec2::new(800.0, 600.0);
1206        let center = glam::Vec3::ZERO;
1207
1208        let result = project_drag_onto_plane(
1209            glam::Vec2::new(100.0, 0.0),
1210            glam::Vec3::X,
1211            glam::Vec3::Z,
1212            vp,
1213            center,
1214            viewport_size,
1215        );
1216        // Should have components along X and Z.
1217        assert!(
1218            result.length() > 0.0,
1219            "plane drag should produce non-zero displacement"
1220        );
1221        assert!(
1222            result.y.abs() < 1e-4,
1223            "XZ plane drag should have no Y component"
1224        );
1225    }
1226
1227    #[test]
1228    fn test_screen_handle_hit() {
1229        let g = gizmo();
1230        let center = glam::Vec3::ZERO;
1231        let scale = 1.0;
1232        // Ray aimed directly at origin from +Z.
1233        let axis = g.hit_test(
1234            glam::Vec3::new(0.0, 0.0, 5.0),
1235            glam::Vec3::new(0.0, 0.0, -1.0),
1236            center,
1237            scale,
1238        );
1239        assert_eq!(
1240            axis,
1241            GizmoAxis::Screen,
1242            "ray at center should hit Screen handle"
1243        );
1244    }
1245
1246    #[test]
1247    fn test_build_mesh_translate_has_plane_quads() {
1248        let (verts, idxs) =
1249            build_gizmo_mesh(GizmoMode::Translate, GizmoAxis::None, glam::Quat::IDENTITY);
1250        // Translate mode has arrows + plane quads + screen handle : substantially more geometry.
1251        assert!(
1252            verts.len() > 80,
1253            "translate mesh should have significant vertex count, got {}",
1254            verts.len()
1255        );
1256        assert!(!idxs.is_empty());
1257    }
1258
1259    #[test]
1260    fn test_build_mesh_rotate_produces_rings() {
1261        let (verts, _) = build_gizmo_mesh(GizmoMode::Rotate, GizmoAxis::None, glam::Quat::IDENTITY);
1262        // 3 rings × 40 ring_segments × 8 tube_segments = 960 vertices.
1263        assert!(
1264            verts.len() >= 960,
1265            "rotate mesh should have ring vertices, got {}",
1266            verts.len()
1267        );
1268    }
1269
1270    #[test]
1271    fn test_build_mesh_scale_has_cubes() {
1272        let (verts_translate, _) =
1273            build_gizmo_mesh(GizmoMode::Translate, GizmoAxis::None, glam::Quat::IDENTITY);
1274        let (verts_scale, _) =
1275            build_gizmo_mesh(GizmoMode::Scale, GizmoAxis::None, glam::Quat::IDENTITY);
1276        // Scale mode has cube tips (6 faces × 4 verts = 24 per axis = 72 for cube tips)
1277        // instead of cone tips (segments + 1 + segments per axis). Different counts expected.
1278        assert!(
1279            verts_scale.len() > 50,
1280            "scale mesh should have geometry, got {}",
1281            verts_scale.len()
1282        );
1283        assert_ne!(
1284            verts_translate.len(),
1285            verts_scale.len(),
1286            "translate and scale should have different vertex counts (cone vs cube tips)"
1287        );
1288    }
1289
1290    #[test]
1291    fn test_compute_gizmo_scale() {
1292        let scale = compute_gizmo_scale(
1293            glam::Vec3::ZERO,
1294            glam::Vec3::new(0.0, 0.0, 10.0),
1295            std::f32::consts::FRAC_PI_4,
1296            600.0,
1297        );
1298        assert!(scale > 0.0, "gizmo scale should be positive");
1299        assert!((scale - 1.381).abs() < 0.1, "unexpected scale: {scale}");
1300    }
1301
1302    #[test]
1303    fn test_gizmo_center_single_selection() {
1304        let mut sel = crate::interaction::selection::Selection::new();
1305        sel.select_one(1);
1306        let center = gizmo_center_from_selection(&sel, |id| match id {
1307            1 => Some(glam::Vec3::new(3.0, 0.0, 0.0)),
1308            _ => None,
1309        });
1310        let c = center.unwrap();
1311        assert!((c.x - 3.0).abs() < 1e-5);
1312    }
1313
1314    #[test]
1315    fn test_gizmo_center_multi_selection() {
1316        let mut sel = crate::interaction::selection::Selection::new();
1317        sel.add(1);
1318        sel.add(2);
1319        let center = gizmo_center_from_selection(&sel, |id| match id {
1320            1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1321            2 => Some(glam::Vec3::new(4.0, 0.0, 0.0)),
1322            _ => None,
1323        });
1324        let c = center.unwrap();
1325        assert!((c.x - 2.0).abs() < 1e-5);
1326    }
1327
1328    // --- PivotMode tests ---
1329
1330    #[test]
1331    fn test_pivot_selection_centroid_matches_centroid() {
1332        let mut sel = crate::interaction::selection::Selection::new();
1333        sel.add(1);
1334        sel.add(2);
1335        let pos_fn = |id: crate::interaction::selection::NodeId| match id {
1336            1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1337            2 => Some(glam::Vec3::new(4.0, 0.0, 0.0)),
1338            _ => None,
1339        };
1340        let centroid = gizmo_center_from_selection(&sel, pos_fn);
1341        let pivot = gizmo_center_for_pivot(&PivotMode::SelectionCentroid, &sel, pos_fn);
1342        assert_eq!(centroid, pivot);
1343    }
1344
1345    #[test]
1346    fn test_pivot_world_origin_returns_zero() {
1347        let mut sel = crate::interaction::selection::Selection::new();
1348        sel.add(1);
1349        let result = gizmo_center_for_pivot(&PivotMode::WorldOrigin, &sel, |_| {
1350            Some(glam::Vec3::new(5.0, 0.0, 0.0))
1351        });
1352        assert_eq!(result, Some(glam::Vec3::ZERO));
1353    }
1354
1355    #[test]
1356    fn test_pivot_world_origin_empty_selection_returns_none() {
1357        let sel = crate::interaction::selection::Selection::new();
1358        let result = gizmo_center_for_pivot(&PivotMode::WorldOrigin, &sel, |_| None);
1359        assert_eq!(result, None);
1360    }
1361
1362    #[test]
1363    fn test_pivot_individual_origins_uses_primary() {
1364        let mut sel = crate::interaction::selection::Selection::new();
1365        sel.add(1);
1366        sel.add(2); // primary = 2
1367        let result = gizmo_center_for_pivot(&PivotMode::IndividualOrigins, &sel, |id| match id {
1368            1 => Some(glam::Vec3::new(1.0, 0.0, 0.0)),
1369            2 => Some(glam::Vec3::new(9.0, 0.0, 0.0)),
1370            _ => None,
1371        });
1372        let c = result.unwrap();
1373        assert!(
1374            (c.x - 9.0).abs() < 1e-5,
1375            "expected primary (node 2) position x=9, got {}",
1376            c.x
1377        );
1378    }
1379
1380    #[test]
1381    fn test_pivot_median_point_same_as_centroid() {
1382        let mut sel = crate::interaction::selection::Selection::new();
1383        sel.add(1);
1384        sel.add(2);
1385        let pos_fn = |id: crate::interaction::selection::NodeId| match id {
1386            1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1387            2 => Some(glam::Vec3::new(6.0, 0.0, 0.0)),
1388            _ => None,
1389        };
1390        let result = gizmo_center_for_pivot(&PivotMode::MedianPoint, &sel, pos_fn);
1391        let c = result.unwrap();
1392        assert!((c.x - 3.0).abs() < 1e-5);
1393    }
1394
1395    #[test]
1396    fn test_pivot_cursor3d_returns_cursor_pos() {
1397        let mut sel = crate::interaction::selection::Selection::new();
1398        sel.add(1);
1399        let cursor = glam::Vec3::new(7.0, 2.0, 3.0);
1400        let result = gizmo_center_for_pivot(&PivotMode::Cursor3D(cursor), &sel, |_| {
1401            Some(glam::Vec3::ZERO)
1402        });
1403        assert_eq!(result, Some(cursor));
1404    }
1405
1406    #[test]
1407    fn test_pivot_cursor3d_empty_selection_returns_none() {
1408        let sel = crate::interaction::selection::Selection::new();
1409        let cursor = glam::Vec3::new(1.0, 2.0, 3.0);
1410        let result = gizmo_center_for_pivot(&PivotMode::Cursor3D(cursor), &sel, |_| None);
1411        assert_eq!(result, None);
1412    }
1413
1414    #[test]
1415    fn test_gizmo_pivot_mode_field_defaults_to_selection_centroid() {
1416        let g = Gizmo::new();
1417        assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1418    }
1419
1420    // --- Pivot cycling tests ---
1421
1422    #[test]
1423    fn test_cycle_next_full_round_trip() {
1424        let start = PivotMode::SelectionCentroid;
1425        let after_one = start.cycle_next();
1426        assert!(matches!(after_one, PivotMode::IndividualOrigins));
1427        let after_two = after_one.cycle_next();
1428        assert!(matches!(after_two, PivotMode::MedianPoint));
1429        let after_three = after_two.cycle_next();
1430        assert!(matches!(after_three, PivotMode::WorldOrigin));
1431        let wrapped = after_three.cycle_next();
1432        assert!(matches!(wrapped, PivotMode::SelectionCentroid));
1433    }
1434
1435    #[test]
1436    fn test_cycle_prev_full_round_trip() {
1437        let start = PivotMode::SelectionCentroid;
1438        let after_one = start.cycle_prev();
1439        assert!(matches!(after_one, PivotMode::WorldOrigin));
1440        let after_two = after_one.cycle_prev();
1441        assert!(matches!(after_two, PivotMode::MedianPoint));
1442        let after_three = after_two.cycle_prev();
1443        assert!(matches!(after_three, PivotMode::IndividualOrigins));
1444        let wrapped = after_three.cycle_prev();
1445        assert!(matches!(wrapped, PivotMode::SelectionCentroid));
1446    }
1447
1448    #[test]
1449    fn test_cycle_next_and_prev_are_inverses() {
1450        for mode in [
1451            PivotMode::SelectionCentroid,
1452            PivotMode::IndividualOrigins,
1453            PivotMode::MedianPoint,
1454            PivotMode::WorldOrigin,
1455        ] {
1456            assert_eq!(mode.cycle_next().cycle_prev(), mode);
1457            assert_eq!(mode.cycle_prev().cycle_next(), mode);
1458        }
1459    }
1460
1461    #[test]
1462    fn test_cursor3d_falls_back_to_selection_centroid_on_cycle() {
1463        let cursor = PivotMode::Cursor3D(glam::Vec3::ONE);
1464        assert!(matches!(cursor.cycle_next(), PivotMode::SelectionCentroid));
1465        assert!(matches!(cursor.cycle_prev(), PivotMode::SelectionCentroid));
1466    }
1467
1468    #[test]
1469    fn test_label_returns_non_empty_str() {
1470        for mode in [
1471            PivotMode::SelectionCentroid,
1472            PivotMode::IndividualOrigins,
1473            PivotMode::MedianPoint,
1474            PivotMode::WorldOrigin,
1475            PivotMode::Cursor3D(glam::Vec3::ZERO),
1476        ] {
1477            assert!(!mode.label().is_empty());
1478        }
1479    }
1480
1481    #[test]
1482    fn test_gizmo_cycle_pivot_forward_and_backward() {
1483        let mut g = Gizmo::new();
1484        assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1485        g.cycle_pivot_forward();
1486        assert!(matches!(g.pivot_mode, PivotMode::IndividualOrigins));
1487        g.cycle_pivot_backward();
1488        assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1489    }
1490}