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