Skip to main content

proof_engine/editor/
gizmos.rs

1//! Gizmo renderer — 3D manipulation handles, measurement, annotations,
2//! selection outlines, light/collider/frustum/path/vector-field visualisations.
3
4use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
5
6// ─────────────────────────────────────────────────────────────────────────────
7// Primitive geometry helpers
8// ─────────────────────────────────────────────────────────────────────────────
9
10/// A 3D ray used for hit testing.
11#[derive(Debug, Clone, Copy)]
12pub struct Ray3 {
13    pub origin:    Vec3,
14    pub direction: Vec3,
15}
16
17impl Ray3 {
18    pub fn new(origin: Vec3, direction: Vec3) -> Self {
19        Self { origin, direction: direction.normalize() }
20    }
21
22    pub fn at(&self, t: f32) -> Vec3 {
23        self.origin + self.direction * t
24    }
25
26    /// Closest distance from the ray to a point.
27    pub fn distance_to_point(&self, p: Vec3) -> f32 {
28        let v = p - self.origin;
29        let t = v.dot(self.direction).max(0.0);
30        (self.origin + self.direction * t - p).length()
31    }
32
33    /// Closest t on the ray to a line segment (a, b).
34    pub fn closest_t_to_segment(&self, a: Vec3, b: Vec3) -> f32 {
35        let ab = b - a;
36        let ao = self.origin - a;
37        let dab = self.direction.dot(ab);
38        let dab2 = ab.dot(ab);
39        let cross_len = (self.direction.cross(ab)).length();
40        if cross_len < 1e-6 { return 0.0; } // parallel
41        let t = ao.cross(ab).dot(self.direction.cross(ab)) / (cross_len * cross_len);
42        t.max(0.0)
43    }
44
45    /// Distance from ray to a line segment.
46    pub fn distance_to_segment(&self, a: Vec3, b: Vec3) -> f32 {
47        let t = self.closest_t_to_segment(a, b);
48        let closest_on_ray = self.at(t);
49        let ab = b - a;
50        let t_seg = ((closest_on_ray - a).dot(ab) / ab.dot(ab).max(1e-12)).clamp(0.0, 1.0);
51        let closest_on_seg = a + ab * t_seg;
52        (closest_on_ray - closest_on_seg).length()
53    }
54}
55
56// ─────────────────────────────────────────────────────────────────────────────
57// GizmoMode & GizmoSpace
58// ─────────────────────────────────────────────────────────────────────────────
59
60/// Which manipulation gizmo is active.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
62pub enum GizmoMode {
63    #[default]
64    Translate,
65    Rotate,
66    Scale,
67    Universal,
68    Bounds,
69    Custom,
70}
71
72impl GizmoMode {
73    pub fn label(self) -> &'static str {
74        match self {
75            GizmoMode::Translate => "Translate",
76            GizmoMode::Rotate    => "Rotate",
77            GizmoMode::Scale     => "Scale",
78            GizmoMode::Universal => "Universal",
79            GizmoMode::Bounds    => "Bounds",
80            GizmoMode::Custom    => "Custom",
81        }
82    }
83
84    /// Hotkey that activates this mode.
85    pub fn hotkey(self) -> char {
86        match self {
87            GizmoMode::Translate => 'g',
88            GizmoMode::Rotate    => 'r',
89            GizmoMode::Scale     => 's',
90            GizmoMode::Universal => 'u',
91            GizmoMode::Bounds    => 'b',
92            GizmoMode::Custom    => 'c',
93        }
94    }
95}
96
97/// In which coordinate system the gizmo operates.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
99pub enum GizmoSpace {
100    #[default]
101    World,
102    Local,
103}
104
105// ─────────────────────────────────────────────────────────────────────────────
106// Axis constants
107// ─────────────────────────────────────────────────────────────────────────────
108
109pub const AXIS_X: Vec3 = Vec3::X;
110pub const AXIS_Y: Vec3 = Vec3::Y;
111pub const AXIS_Z: Vec3 = Vec3::Z;
112
113/// RGBA colours for each axis.
114pub fn axis_color(axis: Vec3) -> Vec4 {
115    if (axis - AXIS_X).length() < 1e-4 { Vec4::new(1.0, 0.2, 0.2, 1.0) }
116    else if (axis - AXIS_Y).length() < 1e-4 { Vec4::new(0.2, 1.0, 0.2, 1.0) }
117    else { Vec4::new(0.2, 0.4, 1.0, 1.0) }
118}
119
120pub fn axis_hover_color(axis: Vec3) -> Vec4 {
121    let c = axis_color(axis);
122    Vec4::new(
123        (c.x + 0.4).min(1.0),
124        (c.y + 0.4).min(1.0),
125        (c.z + 0.4).min(1.0),
126        1.0,
127    )
128}
129
130// ─────────────────────────────────────────────────────────────────────────────
131// TranslateHandle
132// ─────────────────────────────────────────────────────────────────────────────
133
134/// A single axis arrow for the translation gizmo.
135#[derive(Debug, Clone)]
136pub struct TranslateHandle {
137    pub axis:       Vec3,
138    pub hovered:    bool,
139    pub active:     bool,
140    pub drag_start: Vec3,  // world-space drag start point
141    pub value:      Vec3,  // accumulated translation
142    pub length:     f32,   // visual length of the arrow
143}
144
145impl TranslateHandle {
146    pub fn new(axis: Vec3) -> Self {
147        Self {
148            axis: axis.normalize(),
149            hovered: false,
150            active: false,
151            drag_start: Vec3::ZERO,
152            value: Vec3::ZERO,
153            length: 1.0,
154        }
155    }
156
157    /// Compute the start and end points of the arrow in world space.
158    pub fn arrow_segment(&self, origin: Vec3, scale: f32) -> (Vec3, Vec3) {
159        let end = origin + self.axis * self.length * scale;
160        (origin, end)
161    }
162
163    /// Hit-test against the mouse ray. Returns true if hit.
164    pub fn hit_test(&self, ray: &Ray3, origin: Vec3, scale: f32, threshold: f32) -> bool {
165        let (a, b) = self.arrow_segment(origin, scale);
166        ray.distance_to_segment(a, b) < threshold
167    }
168
169    pub fn begin_drag(&mut self, world_pos: Vec3) {
170        self.active = true;
171        self.drag_start = world_pos;
172        self.value = Vec3::ZERO;
173    }
174
175    pub fn update_drag(&mut self, world_pos: Vec3) {
176        let delta = world_pos - self.drag_start;
177        self.value = self.axis * self.axis.dot(delta);
178    }
179
180    pub fn end_drag(&mut self) -> Vec3 {
181        self.active = false;
182        std::mem::take(&mut self.value)
183    }
184
185    pub fn color(&self) -> Vec4 {
186        if self.hovered || self.active { axis_hover_color(self.axis) }
187        else { axis_color(self.axis) }
188    }
189
190    /// ASCII representation.
191    pub fn render_ascii(&self, origin: Vec3) -> String {
192        let (a, b) = self.arrow_segment(origin, 1.0);
193        let label = if self.axis == AXIS_X { "X" }
194                    else if self.axis == AXIS_Y { "Y" }
195                    else { "Z" };
196        format!("→[{}] ({:.1},{:.1},{:.1})→({:.1},{:.1},{:.1})",
197            label, a.x, a.y, a.z, b.x, b.y, b.z)
198    }
199}
200
201/// Plane constraint handle (XY, YZ, XZ).
202#[derive(Debug, Clone)]
203pub struct PlaneHandle {
204    pub normal:  Vec3,  // plane normal = the locked axis
205    pub hovered: bool,
206    pub active:  bool,
207    pub value:   Vec3,
208}
209
210impl PlaneHandle {
211    pub fn new(normal: Vec3) -> Self {
212        Self { normal: normal.normalize(), hovered: false, active: false, value: Vec3::ZERO }
213    }
214
215    /// Returns the two tangent axes of this plane.
216    pub fn tangents(&self) -> (Vec3, Vec3) {
217        let u = if self.normal.abs().dot(AXIS_Y) < 0.99 {
218            self.normal.cross(AXIS_Y).normalize()
219        } else {
220            self.normal.cross(AXIS_X).normalize()
221        };
222        let v = self.normal.cross(u).normalize();
223        (u, v)
224    }
225
226    pub fn color(&self) -> Vec4 {
227        let c = axis_color(self.normal);
228        Vec4::new(c.x, c.y, c.z, if self.hovered { 0.5 } else { 0.2 })
229    }
230}
231
232// ─────────────────────────────────────────────────────────────────────────────
233// RotateHandle
234// ─────────────────────────────────────────────────────────────────────────────
235
236/// One arc ring for the rotation gizmo.
237#[derive(Debug, Clone)]
238pub struct RotateHandle {
239    pub axis:        Vec3,
240    pub hovered:     bool,
241    pub active:      bool,
242    pub angle_start: f32,
243    pub angle_delta: f32,
244    pub radius:      f32,
245    pub segments:    usize,
246}
247
248impl RotateHandle {
249    pub fn new(axis: Vec3) -> Self {
250        Self {
251            axis: axis.normalize(),
252            hovered: false,
253            active: false,
254            angle_start: 0.0,
255            angle_delta: 0.0,
256            radius: 1.0,
257            segments: 32,
258        }
259    }
260
261    /// Generate arc points around the axis (full circle).
262    pub fn arc_points(&self, center: Vec3, scale: f32) -> Vec<Vec3> {
263        let r = self.radius * scale;
264        let (u, v) = {
265            let n = self.axis;
266            let u = if n.abs().dot(AXIS_Y) < 0.99 { n.cross(AXIS_Y).normalize() }
267                    else { n.cross(AXIS_X).normalize() };
268            let v = n.cross(u).normalize();
269            (u, v)
270        };
271        let n = self.segments;
272        (0..=n)
273            .map(|i| {
274                let theta = std::f32::consts::TAU * i as f32 / n as f32;
275                center + (u * theta.cos() + v * theta.sin()) * r
276            })
277            .collect()
278    }
279
280    pub fn begin_drag(&mut self, angle: f32) {
281        self.active = true;
282        self.angle_start = angle;
283        self.angle_delta = 0.0;
284    }
285
286    pub fn update_drag(&mut self, angle: f32) {
287        self.angle_delta = angle - self.angle_start;
288    }
289
290    pub fn end_drag(&mut self) -> f32 {
291        self.active = false;
292        let delta = self.angle_delta;
293        self.angle_delta = 0.0;
294        delta
295    }
296
297    pub fn color(&self) -> Vec4 {
298        if self.hovered || self.active { axis_hover_color(self.axis) }
299        else { axis_color(self.axis) }
300    }
301
302    /// Hit-test: distance from ray to arc circle perimeter.
303    pub fn hit_test(&self, ray: &Ray3, center: Vec3, scale: f32, threshold: f32) -> bool {
304        let pts = self.arc_points(center, scale);
305        for i in 0..pts.len().saturating_sub(1) {
306            if ray.distance_to_segment(pts[i], pts[i + 1]) < threshold {
307                return true;
308            }
309        }
310        false
311    }
312}
313
314// ─────────────────────────────────────────────────────────────────────────────
315// ScaleHandle
316// ─────────────────────────────────────────────────────────────────────────────
317
318/// One per-axis scale cube handle.
319#[derive(Debug, Clone)]
320pub struct ScaleHandle {
321    pub axis:        Vec3,
322    pub hovered:     bool,
323    pub active:      bool,
324    pub scale_start: Vec3,
325    pub scale_delta: Vec3,
326    pub cube_size:   f32,
327}
328
329impl ScaleHandle {
330    pub fn new(axis: Vec3) -> Self {
331        Self {
332            axis: axis.normalize(),
333            hovered: false,
334            active: false,
335            scale_start: Vec3::ONE,
336            scale_delta: Vec3::ZERO,
337            cube_size: 0.12,
338        }
339    }
340
341    /// World-space position of the cube endpoint.
342    pub fn cube_center(&self, origin: Vec3, scale: f32) -> Vec3 {
343        origin + self.axis * scale
344    }
345
346    pub fn begin_drag(&mut self, current_scale: Vec3) {
347        self.active = true;
348        self.scale_start = current_scale;
349        self.scale_delta = Vec3::ZERO;
350    }
351
352    pub fn update_drag(&mut self, mouse_delta: f32) {
353        let delta = self.axis * mouse_delta;
354        self.scale_delta = delta;
355    }
356
357    pub fn end_drag(&mut self) -> Vec3 {
358        self.active = false;
359        let delta = self.scale_delta;
360        self.scale_delta = Vec3::ZERO;
361        delta
362    }
363
364    pub fn color(&self) -> Vec4 {
365        if self.hovered || self.active { axis_hover_color(self.axis) }
366        else { axis_color(self.axis) }
367    }
368
369    /// Hit-test: point-sphere approximation for the cube.
370    pub fn hit_test(&self, ray: &Ray3, origin: Vec3, scale: f32, threshold: f32) -> bool {
371        let center = self.cube_center(origin, scale);
372        ray.distance_to_point(center) < threshold
373    }
374}
375
376// ─────────────────────────────────────────────────────────────────────────────
377// GizmoRaycast
378// ─────────────────────────────────────────────────────────────────────────────
379
380/// Result of a gizmo hit test.
381#[derive(Debug, Clone, PartialEq)]
382pub enum GizmoHit {
383    TranslateAxis(usize),      // 0=X, 1=Y, 2=Z
384    TranslatePlane(usize),     // 0=XY, 1=YZ, 2=XZ
385    RotateAxis(usize),
386    ScaleAxis(usize),
387    ScaleUniform,
388    None,
389}
390
391/// Hit tests all gizmo handles against a mouse ray.
392pub struct GizmoRaycast {
393    pub threshold: f32,
394}
395
396impl GizmoRaycast {
397    pub fn new(threshold: f32) -> Self {
398        Self { threshold }
399    }
400
401    pub fn test_translate(
402        &self,
403        ray: &Ray3,
404        handles: &[TranslateHandle; 3],
405        planes: &[PlaneHandle; 3],
406        origin: Vec3,
407        scale: f32,
408    ) -> GizmoHit {
409        for (i, h) in handles.iter().enumerate() {
410            if h.hit_test(ray, origin, scale, self.threshold) {
411                return GizmoHit::TranslateAxis(i);
412            }
413        }
414        for (i, p) in planes.iter().enumerate() {
415            let (u, v) = p.tangents();
416            let s = 0.3 * scale;
417            let corners = [
418                origin + u * s + v * s,
419                origin + u * s - v * s,
420                origin - u * s - v * s,
421                origin - u * s + v * s,
422            ];
423            for j in 0..4 {
424                if ray.distance_to_segment(corners[j], corners[(j + 1) % 4]) < self.threshold {
425                    return GizmoHit::TranslatePlane(i);
426                }
427            }
428        }
429        GizmoHit::None
430    }
431
432    pub fn test_rotate(
433        &self,
434        ray: &Ray3,
435        handles: &[RotateHandle; 3],
436        center: Vec3,
437        scale: f32,
438    ) -> GizmoHit {
439        for (i, h) in handles.iter().enumerate() {
440            if h.hit_test(ray, center, scale, self.threshold) {
441                return GizmoHit::RotateAxis(i);
442            }
443        }
444        GizmoHit::None
445    }
446
447    pub fn test_scale(
448        &self,
449        ray: &Ray3,
450        handles: &[ScaleHandle; 3],
451        origin: Vec3,
452        scale: f32,
453    ) -> GizmoHit {
454        // Uniform scale — hit near origin.
455        if ray.distance_to_point(origin) < self.threshold * 1.5 {
456            return GizmoHit::ScaleUniform;
457        }
458        for (i, h) in handles.iter().enumerate() {
459            if h.hit_test(ray, origin, scale, self.threshold) {
460                return GizmoHit::ScaleAxis(i);
461            }
462        }
463        GizmoHit::None
464    }
465}
466
467// ─────────────────────────────────────────────────────────────────────────────
468// Selection outline
469// ─────────────────────────────────────────────────────────────────────────────
470
471/// An AABB box for selection outline drawing.
472#[derive(Debug, Clone, Copy)]
473pub struct BoundingBox {
474    pub min: Vec3,
475    pub max: Vec3,
476}
477
478impl BoundingBox {
479    pub fn new(min: Vec3, max: Vec3) -> Self {
480        Self { min, max }
481    }
482
483    pub fn from_center_size(center: Vec3, size: Vec3) -> Self {
484        let half = size * 0.5;
485        Self { min: center - half, max: center + half }
486    }
487
488    pub fn center(&self) -> Vec3 {
489        (self.min + self.max) * 0.5
490    }
491
492    pub fn size(&self) -> Vec3 {
493        self.max - self.min
494    }
495
496    /// All 12 edges as (start, end) pairs.
497    pub fn edges(&self) -> [(Vec3, Vec3); 12] {
498        let (mn, mx) = (self.min, self.max);
499        [
500            // Bottom face
501            (Vec3::new(mn.x, mn.y, mn.z), Vec3::new(mx.x, mn.y, mn.z)),
502            (Vec3::new(mx.x, mn.y, mn.z), Vec3::new(mx.x, mn.y, mx.z)),
503            (Vec3::new(mx.x, mn.y, mx.z), Vec3::new(mn.x, mn.y, mx.z)),
504            (Vec3::new(mn.x, mn.y, mx.z), Vec3::new(mn.x, mn.y, mn.z)),
505            // Top face
506            (Vec3::new(mn.x, mx.y, mn.z), Vec3::new(mx.x, mx.y, mn.z)),
507            (Vec3::new(mx.x, mx.y, mn.z), Vec3::new(mx.x, mx.y, mx.z)),
508            (Vec3::new(mx.x, mx.y, mx.z), Vec3::new(mn.x, mx.y, mx.z)),
509            (Vec3::new(mn.x, mx.y, mx.z), Vec3::new(mn.x, mx.y, mn.z)),
510            // Verticals
511            (Vec3::new(mn.x, mn.y, mn.z), Vec3::new(mn.x, mx.y, mn.z)),
512            (Vec3::new(mx.x, mn.y, mn.z), Vec3::new(mx.x, mx.y, mn.z)),
513            (Vec3::new(mx.x, mn.y, mx.z), Vec3::new(mx.x, mx.y, mx.z)),
514            (Vec3::new(mn.x, mn.y, mx.z), Vec3::new(mn.x, mx.y, mx.z)),
515        ]
516    }
517
518    pub fn contains_point(&self, p: Vec3) -> bool {
519        p.x >= self.min.x && p.x <= self.max.x &&
520        p.y >= self.min.y && p.y <= self.max.y &&
521        p.z >= self.min.z && p.z <= self.max.z
522    }
523
524    pub fn expanded(&self, amount: f32) -> Self {
525        Self {
526            min: self.min - Vec3::splat(amount),
527            max: self.max + Vec3::splat(amount),
528        }
529    }
530}
531
532// ─────────────────────────────────────────────────────────────────────────────
533// AnnotationGizmo
534// ─────────────────────────────────────────────────────────────────────────────
535
536/// World-space text label with a leader line.
537#[derive(Debug, Clone)]
538pub struct AnnotationGizmo {
539    pub id:       u32,
540    pub text:     String,
541    pub world_pos: Vec3,
542    pub label_offset: Vec2,  // screen-space offset from projected world_pos
543    pub color:    Vec4,
544    pub visible:  bool,
545    pub line_width: f32,
546}
547
548impl AnnotationGizmo {
549    pub fn new(id: u32, text: impl Into<String>, pos: Vec3) -> Self {
550        Self {
551            id,
552            text: text.into(),
553            world_pos: pos,
554            label_offset: Vec2::new(20.0, -20.0),
555            color: Vec4::new(1.0, 1.0, 0.0, 1.0),
556            visible: true,
557            line_width: 1.0,
558        }
559    }
560
561    pub fn render_ascii(&self) -> String {
562        format!(
563            "[{}] \"{}\" @ ({:.1},{:.1},{:.1})",
564            self.id, self.text, self.world_pos.x, self.world_pos.y, self.world_pos.z
565        )
566    }
567}
568
569// ─────────────────────────────────────────────────────────────────────────────
570// GridGizmo
571// ─────────────────────────────────────────────────────────────────────────────
572
573/// Snap grid dot/line visualisation.
574#[derive(Debug, Clone)]
575pub struct GridGizmo {
576    pub snap_size:    f32,
577    pub color:        Vec4,
578    pub dot_radius:   f32,
579    pub show_lines:   bool,
580    pub half_extent:  f32,
581    pub y_plane:      f32,
582}
583
584impl GridGizmo {
585    pub fn new(snap_size: f32) -> Self {
586        Self {
587            snap_size,
588            color: Vec4::new(0.5, 0.5, 1.0, 0.4),
589            dot_radius: 0.04,
590            show_lines: false,
591            half_extent: 20.0,
592            y_plane: 0.0,
593        }
594    }
595
596    /// Generate dot positions within the visible area.
597    pub fn dot_positions(&self, camera_pos: Vec3) -> Vec<Vec3> {
598        let s = self.snap_size;
599        let cx = (camera_pos.x / s).floor() as i32;
600        let cz = (camera_pos.z / s).floor() as i32;
601        let n  = (self.half_extent / s).ceil() as i32 + 1;
602        let mut pts = Vec::new();
603        for dx in -n..=n {
604            for dz in -n..=n {
605                pts.push(Vec3::new(
606                    (cx + dx) as f32 * s,
607                    self.y_plane,
608                    (cz + dz) as f32 * s,
609                ));
610            }
611        }
612        pts
613    }
614}
615
616// ─────────────────────────────────────────────────────────────────────────────
617// LightGizmo
618// ─────────────────────────────────────────────────────────────────────────────
619
620/// Visualises a light source.
621#[derive(Debug, Clone)]
622pub struct LightGizmo {
623    pub position:  Vec3,
624    pub direction: Vec3,
625    pub range:     f32,
626    pub cone_angle: f32,  // degrees, only for spot lights
627    pub color:     Vec4,
628    pub kind:      LightKind,
629    pub selected:  bool,
630}
631
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633pub enum LightKind { Point, Directional, Spot, Area }
634
635impl LightGizmo {
636    pub fn new_point(pos: Vec3, range: f32) -> Self {
637        Self {
638            position: pos, direction: Vec3::new(0.0, -1.0, 0.0),
639            range, cone_angle: 45.0,
640            color: Vec4::new(1.0, 1.0, 0.8, 1.0),
641            kind: LightKind::Point, selected: false,
642        }
643    }
644
645    /// Sphere edges for a point light.
646    pub fn sphere_lines(&self, segments: usize) -> Vec<(Vec3, Vec3)> {
647        let mut lines = Vec::new();
648        let r = self.range;
649        let n = segments;
650        let tau = std::f32::consts::TAU;
651        // Three great circles.
652        for plane in 0..3_u8 {
653            for i in 0..n {
654                let a = tau * i as f32 / n as f32;
655                let b = tau * (i + 1) as f32 / n as f32;
656                let p = |theta: f32| -> Vec3 {
657                    match plane {
658                        0 => self.position + Vec3::new(r * theta.cos(), r * theta.sin(), 0.0),
659                        1 => self.position + Vec3::new(0.0, r * theta.cos(), r * theta.sin()),
660                        _ => self.position + Vec3::new(r * theta.cos(), 0.0, r * theta.sin()),
661                    }
662                };
663                lines.push((p(a), p(b)));
664            }
665        }
666        lines
667    }
668
669    /// Cone lines for a spot light.
670    pub fn cone_lines(&self, segments: usize) -> Vec<(Vec3, Vec3)> {
671        let mut lines = Vec::new();
672        let half = self.cone_angle.to_radians() * 0.5;
673        let r = self.range * half.tan();
674        let tip = self.position;
675        let fwd = self.direction.normalize();
676        let base_center = tip + fwd * self.range;
677        let right = if fwd.abs().dot(Vec3::Y) < 0.99 { fwd.cross(Vec3::Y).normalize() }
678                    else { fwd.cross(Vec3::X).normalize() };
679        let up = fwd.cross(right).normalize();
680        let tau = std::f32::consts::TAU;
681        let n = segments;
682        let mut prev = base_center + right * r;
683        for i in 1..=n {
684            let theta = tau * i as f32 / n as f32;
685            let curr = base_center + (right * theta.cos() + up * theta.sin()) * r;
686            lines.push((prev, curr));
687            prev = curr;
688        }
689        // Add lines from tip to rim.
690        for i in 0..4 {
691            let theta = tau * i as f32 / 4.0;
692            let rim = base_center + (right * theta.cos() + up * theta.sin()) * r;
693            lines.push((tip, rim));
694        }
695        lines
696    }
697
698    pub fn render_ascii(&self) -> String {
699        format!(
700            "Light[{:?}] pos=({:.1},{:.1},{:.1}) range={:.1}",
701            self.kind, self.position.x, self.position.y, self.position.z, self.range
702        )
703    }
704}
705
706// ─────────────────────────────────────────────────────────────────────────────
707// ColliderGizmo
708// ─────────────────────────────────────────────────────────────────────────────
709
710/// Shows a physics collider shape.
711#[derive(Debug, Clone)]
712pub struct ColliderGizmo {
713    pub position: Vec3,
714    pub rotation: Quat,
715    pub shape:    ColliderShape,
716    pub color:    Vec4,
717    pub selected: bool,
718}
719
720#[derive(Debug, Clone)]
721pub enum ColliderShape {
722    Box   { half_extents: Vec3 },
723    Sphere { radius: f32 },
724    Capsule { half_height: f32, radius: f32 },
725    Cylinder { half_height: f32, radius: f32 },
726    ConvexHull { points: Vec<Vec3> },
727}
728
729impl ColliderGizmo {
730    pub fn new_box(pos: Vec3, half_extents: Vec3) -> Self {
731        Self {
732            position: pos,
733            rotation: Quat::IDENTITY,
734            shape: ColliderShape::Box { half_extents },
735            color: Vec4::new(0.0, 1.0, 0.5, 0.8),
736            selected: false,
737        }
738    }
739
740    pub fn new_sphere(pos: Vec3, radius: f32) -> Self {
741        Self {
742            position: pos,
743            rotation: Quat::IDENTITY,
744            shape: ColliderShape::Sphere { radius },
745            color: Vec4::new(0.0, 1.0, 0.5, 0.8),
746            selected: false,
747        }
748    }
749
750    pub fn wire_lines(&self, segments: usize) -> Vec<(Vec3, Vec3)> {
751        match &self.shape {
752            ColliderShape::Box { half_extents } => {
753                let bb = BoundingBox::new(
754                    self.position - *half_extents,
755                    self.position + *half_extents,
756                );
757                bb.edges().to_vec()
758            }
759            ColliderShape::Sphere { radius } => {
760                let lg = LightGizmo::new_point(self.position, *radius);
761                lg.sphere_lines(segments)
762            }
763            ColliderShape::Capsule { half_height, radius } => {
764                let mut lines = Vec::new();
765                let r = *radius;
766                let h = *half_height;
767                let n = segments;
768                let tau = std::f32::consts::TAU;
769                // Two circles at top and bottom.
770                for cap in [-1.0_f32, 1.0] {
771                    let cy = self.position.y + cap * h;
772                    let mut prev = Vec3::new(self.position.x + r, cy, self.position.z);
773                    for i in 1..=n {
774                        let theta = tau * i as f32 / n as f32;
775                        let curr = Vec3::new(
776                            self.position.x + r * theta.cos(),
777                            cy,
778                            self.position.z + r * theta.sin(),
779                        );
780                        lines.push((prev, curr));
781                        prev = curr;
782                    }
783                }
784                // Side lines.
785                for i in 0..4 {
786                    let theta = tau * i as f32 / 4.0;
787                    let dx = r * theta.cos();
788                    let dz = r * theta.sin();
789                    let bot = Vec3::new(self.position.x + dx, self.position.y - h, self.position.z + dz);
790                    let top = Vec3::new(self.position.x + dx, self.position.y + h, self.position.z + dz);
791                    lines.push((bot, top));
792                }
793                lines
794            }
795            ColliderShape::Cylinder { half_height, radius } => {
796                let mut lines = Vec::new();
797                let r = *radius;
798                let h = *half_height;
799                let n = segments;
800                let tau = std::f32::consts::TAU;
801                for cap in [-1.0_f32, 1.0] {
802                    let cy = self.position.y + cap * h;
803                    let mut prev = Vec3::new(self.position.x + r, cy, self.position.z);
804                    for i in 1..=n {
805                        let theta = tau * i as f32 / n as f32;
806                        let curr = Vec3::new(
807                            self.position.x + r * theta.cos(), cy,
808                            self.position.z + r * theta.sin(),
809                        );
810                        lines.push((prev, curr));
811                        prev = curr;
812                    }
813                }
814                for i in 0..8 {
815                    let theta = tau * i as f32 / 8.0;
816                    lines.push((
817                        Vec3::new(self.position.x + r * theta.cos(), self.position.y - h, self.position.z + r * theta.sin()),
818                        Vec3::new(self.position.x + r * theta.cos(), self.position.y + h, self.position.z + r * theta.sin()),
819                    ));
820                }
821                lines
822            }
823            ColliderShape::ConvexHull { points } => {
824                // Just draw lines between consecutive points as an approximation.
825                let n = points.len();
826                let mut lines = Vec::new();
827                for i in 0..n {
828                    lines.push((self.position + points[i], self.position + points[(i + 1) % n]));
829                }
830                lines
831            }
832        }
833    }
834}
835
836// ─────────────────────────────────────────────────────────────────────────────
837// CameraFrustumGizmo
838// ─────────────────────────────────────────────────────────────────────────────
839
840/// Preview a camera's frustum in the editor viewport.
841#[derive(Debug, Clone)]
842pub struct CameraFrustumGizmo {
843    pub position:    Vec3,
844    pub rotation:    Quat,
845    pub fov_degrees: f32,
846    pub aspect:      f32,
847    pub near:        f32,
848    pub far:         f32,
849    pub color:       Vec4,
850    pub selected:    bool,
851}
852
853impl CameraFrustumGizmo {
854    pub fn new(pos: Vec3, fov: f32, aspect: f32, near: f32, far: f32) -> Self {
855        Self {
856            position: pos, rotation: Quat::IDENTITY,
857            fov_degrees: fov, aspect, near, far,
858            color: Vec4::new(0.8, 0.8, 0.0, 0.9),
859            selected: false,
860        }
861    }
862
863    /// Returns the 8 corners of the frustum in world space.
864    pub fn frustum_corners(&self) -> [Vec3; 8] {
865        let half_fov = (self.fov_degrees * 0.5).to_radians();
866        let near_h = 2.0 * self.near * half_fov.tan();
867        let near_w = near_h * self.aspect;
868        let far_h  = 2.0 * self.far  * half_fov.tan();
869        let far_w  = far_h  * self.aspect;
870
871        let fwd   = self.rotation * Vec3::new(0.0, 0.0, -1.0);
872        let right = self.rotation * Vec3::X;
873        let up    = self.rotation * Vec3::Y;
874
875        let nc = self.position + fwd * self.near;
876        let fc = self.position + fwd * self.far;
877
878        [
879            nc - right * near_w * 0.5 - up * near_h * 0.5,
880            nc + right * near_w * 0.5 - up * near_h * 0.5,
881            nc + right * near_w * 0.5 + up * near_h * 0.5,
882            nc - right * near_w * 0.5 + up * near_h * 0.5,
883            fc - right * far_w * 0.5 - up * far_h * 0.5,
884            fc + right * far_w * 0.5 - up * far_h * 0.5,
885            fc + right * far_w * 0.5 + up * far_h * 0.5,
886            fc - right * far_w * 0.5 + up * far_h * 0.5,
887        ]
888    }
889
890    /// Returns the 12 edges of the frustum.
891    pub fn edges(&self) -> Vec<(Vec3, Vec3)> {
892        let c = self.frustum_corners();
893        vec![
894            // Near face
895            (c[0], c[1]), (c[1], c[2]), (c[2], c[3]), (c[3], c[0]),
896            // Far face
897            (c[4], c[5]), (c[5], c[6]), (c[6], c[7]), (c[7], c[4]),
898            // Connecting edges
899            (c[0], c[4]), (c[1], c[5]), (c[2], c[6]), (c[3], c[7]),
900        ]
901    }
902
903    pub fn render_ascii(&self) -> String {
904        format!(
905            "Camera frustum: pos=({:.1},{:.1},{:.1}) fov={:.0}° near={:.2} far={:.0}",
906            self.position.x, self.position.y, self.position.z,
907            self.fov_degrees, self.near, self.far
908        )
909    }
910}
911
912// ─────────────────────────────────────────────────────────────────────────────
913// PathGizmo
914// ─────────────────────────────────────────────────────────────────────────────
915
916/// Draws a spline path between waypoints.
917#[derive(Debug, Clone)]
918pub struct PathGizmo {
919    pub waypoints:    Vec<Vec3>,
920    pub color:        Vec4,
921    pub closed:       bool,
922    pub show_handles: bool,
923    pub segments_per_span: usize,
924}
925
926impl PathGizmo {
927    pub fn new(waypoints: Vec<Vec3>) -> Self {
928        Self {
929            waypoints,
930            color: Vec4::new(0.2, 0.8, 1.0, 1.0),
931            closed: false,
932            show_handles: true,
933            segments_per_span: 16,
934        }
935    }
936
937    /// Evaluate a Catmull-Rom spline at parameter t in [0, 1] between p1 and p2.
938    fn catmull_rom(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: f32) -> Vec3 {
939        let t2 = t * t;
940        let t3 = t2 * t;
941        0.5 * (
942            p1 * 2.0
943            + (p2 - p0) * t
944            + (p0 * 2.0 - p1 * 5.0 + p2 * 4.0 - p3) * t2
945            + (p1 * 3.0 - p0 - p2 * 3.0 + p3) * t3
946        )
947    }
948
949    /// Generate line segments for the spline path.
950    pub fn spline_lines(&self) -> Vec<(Vec3, Vec3)> {
951        let n = self.waypoints.len();
952        if n < 2 { return Vec::new(); }
953        let mut lines = Vec::new();
954        let count = if self.closed { n } else { n - 1 };
955        for i in 0..count {
956            let p0 = self.waypoints[(i + n - 1) % n];
957            let p1 = self.waypoints[i % n];
958            let p2 = self.waypoints[(i + 1) % n];
959            let p3 = self.waypoints[(i + 2) % n];
960            let segs = self.segments_per_span;
961            let mut prev = p1;
962            for j in 1..=segs {
963                let t = j as f32 / segs as f32;
964                let curr = Self::catmull_rom(p0, p1, p2, p3, t);
965                lines.push((prev, curr));
966                prev = curr;
967            }
968        }
969        lines
970    }
971
972    /// Straight-line segments between consecutive waypoints.
973    pub fn line_segments(&self) -> Vec<(Vec3, Vec3)> {
974        let n = self.waypoints.len();
975        if n < 2 { return Vec::new(); }
976        let count = if self.closed { n } else { n - 1 };
977        (0..count)
978            .map(|i| (self.waypoints[i], self.waypoints[(i + 1) % n]))
979            .collect()
980    }
981
982    pub fn add_waypoint(&mut self, pt: Vec3) {
983        self.waypoints.push(pt);
984    }
985
986    pub fn remove_waypoint(&mut self, index: usize) {
987        if index < self.waypoints.len() {
988            self.waypoints.remove(index);
989        }
990    }
991
992    pub fn total_length(&self) -> f32 {
993        self.line_segments().iter().map(|(a, b)| (*b - *a).length()).sum()
994    }
995}
996
997// ─────────────────────────────────────────────────────────────────────────────
998// VectorFieldGizmo
999// ─────────────────────────────────────────────────────────────────────────────
1000
1001/// Samples a force field and draws vectors as arrows.
1002#[derive(Debug, Clone)]
1003pub struct VectorFieldGizmo {
1004    pub grid_origin:    Vec3,
1005    pub grid_size:      Vec3,
1006    pub cell_count:     [u32; 3],  // samples per axis
1007    pub arrow_scale:    f32,
1008    pub color_min:      Vec4,
1009    pub color_max:      Vec4,
1010    pub max_magnitude:  f32,
1011    pub visible:        bool,
1012}
1013
1014impl VectorFieldGizmo {
1015    pub fn new(origin: Vec3, size: Vec3, cells: [u32; 3]) -> Self {
1016        Self {
1017            grid_origin: origin,
1018            grid_size: size,
1019            cell_count: cells,
1020            arrow_scale: 0.5,
1021            color_min: Vec4::new(0.0, 0.5, 1.0, 0.8),
1022            color_max: Vec4::new(1.0, 0.3, 0.0, 1.0),
1023            max_magnitude: 10.0,
1024            visible: true,
1025        }
1026    }
1027
1028    /// Sample positions for the vector field grid.
1029    pub fn sample_positions(&self) -> Vec<Vec3> {
1030        let [nx, ny, nz] = self.cell_count;
1031        let mut pts = Vec::with_capacity((nx * ny * nz) as usize);
1032        for iz in 0..nz {
1033            for iy in 0..ny {
1034                for ix in 0..nx {
1035                    let t = Vec3::new(
1036                        ix as f32 / nx.max(1) as f32,
1037                        iy as f32 / ny.max(1) as f32,
1038                        iz as f32 / nz.max(1) as f32,
1039                    );
1040                    pts.push(self.grid_origin + t * self.grid_size);
1041                }
1042            }
1043        }
1044        pts
1045    }
1046
1047    /// Given a vector value at a position, produce an arrow (start, end, color).
1048    pub fn make_arrow(&self, pos: Vec3, vector: Vec3) -> (Vec3, Vec3, Vec4) {
1049        let magnitude = vector.length();
1050        let t = (magnitude / self.max_magnitude.max(1e-6)).clamp(0.0, 1.0);
1051        let color = Vec4::lerp(self.color_min, self.color_max, t);
1052        let end = pos + vector.normalize_or_zero() * magnitude.min(self.max_magnitude) * self.arrow_scale;
1053        (pos, end, color)
1054    }
1055
1056    /// Generate arrows for a set of (position, vector) samples.
1057    pub fn arrows_for_samples(
1058        &self,
1059        samples: &[(Vec3, Vec3)],
1060    ) -> Vec<(Vec3, Vec3, Vec4)> {
1061        if !self.visible { return Vec::new(); }
1062        samples.iter()
1063            .map(|(pos, vec)| self.make_arrow(*pos, *vec))
1064            .collect()
1065    }
1066
1067    /// Color interpolated by magnitude.
1068    pub fn color_for_magnitude(&self, magnitude: f32) -> Vec4 {
1069        let t = (magnitude / self.max_magnitude.max(1e-6)).clamp(0.0, 1.0);
1070        Vec4::lerp(self.color_min, self.color_max, t)
1071    }
1072}
1073
1074// ─────────────────────────────────────────────────────────────────────────────
1075// MeasurementTool
1076// ─────────────────────────────────────────────────────────────────────────────
1077
1078/// Shows the distance between two selected points/entities.
1079#[derive(Debug, Clone, Default)]
1080pub struct MeasurementTool {
1081    pub point_a: Option<Vec3>,
1082    pub point_b: Option<Vec3>,
1083    pub visible: bool,
1084}
1085
1086impl MeasurementTool {
1087    pub fn new() -> Self { Self { visible: true, ..Default::default() } }
1088
1089    pub fn set_a(&mut self, p: Vec3) { self.point_a = Some(p); }
1090    pub fn set_b(&mut self, p: Vec3) { self.point_b = Some(p); }
1091    pub fn clear(&mut self) { self.point_a = None; self.point_b = None; }
1092
1093    pub fn distance(&self) -> Option<f32> {
1094        Some((self.point_b? - self.point_a?).length())
1095    }
1096
1097    pub fn midpoint(&self) -> Option<Vec3> {
1098        Some((self.point_a? + self.point_b?) * 0.5)
1099    }
1100
1101    pub fn render_ascii(&self) -> String {
1102        match self.distance() {
1103            Some(d) => format!("Distance: {:.4} units", d),
1104            None => "Measurement: (click two points)".into(),
1105        }
1106    }
1107}
1108
1109// ─────────────────────────────────────────────────────────────────────────────
1110// GizmoRenderer — top-level struct
1111// ─────────────────────────────────────────────────────────────────────────────
1112
1113/// Manages all gizmo state and provides hit-testing and rendering helpers.
1114pub struct GizmoRenderer {
1115    pub mode:         GizmoMode,
1116    pub space:        GizmoSpace,
1117    pub gizmo_scale:  f32,
1118    pub visible:      bool,
1119
1120    // Translation handles: [X, Y, Z]
1121    pub translate:    [TranslateHandle; 3],
1122    pub translate_planes: [PlaneHandle; 3], // [XY, YZ, XZ]
1123
1124    // Rotation handles: [X, Y, Z]
1125    pub rotate:       [RotateHandle; 3],
1126
1127    // Scale handles: [X, Y, Z]
1128    pub scale:        [ScaleHandle; 3],
1129
1130    pub raycast:      GizmoRaycast,
1131    pub annotations:  Vec<AnnotationGizmo>,
1132    pub grid_gizmo:   GridGizmo,
1133    pub measurement:  MeasurementTool,
1134    pub lights:       Vec<LightGizmo>,
1135    pub colliders:    Vec<ColliderGizmo>,
1136    pub cameras:      Vec<CameraFrustumGizmo>,
1137    pub paths:        Vec<PathGizmo>,
1138    pub vector_field: Option<VectorFieldGizmo>,
1139
1140    pub selection_outlines: Vec<(BoundingBox, Vec4)>,
1141
1142    // Axis lock state (X/Y/Z keys).
1143    pub locked_axis: Option<Vec3>,
1144
1145    next_annotation_id: u32,
1146}
1147
1148impl GizmoRenderer {
1149    pub fn new() -> Self {
1150        Self {
1151            mode:        GizmoMode::Translate,
1152            space:       GizmoSpace::World,
1153            gizmo_scale: 1.0,
1154            visible:     true,
1155
1156            translate: [
1157                TranslateHandle::new(AXIS_X),
1158                TranslateHandle::new(AXIS_Y),
1159                TranslateHandle::new(AXIS_Z),
1160            ],
1161            translate_planes: [
1162                PlaneHandle::new(AXIS_Z), // XY plane (Z is normal)
1163                PlaneHandle::new(AXIS_X), // YZ plane
1164                PlaneHandle::new(AXIS_Y), // XZ plane
1165            ],
1166            rotate: [
1167                RotateHandle::new(AXIS_X),
1168                RotateHandle::new(AXIS_Y),
1169                RotateHandle::new(AXIS_Z),
1170            ],
1171            scale: [
1172                ScaleHandle::new(AXIS_X),
1173                ScaleHandle::new(AXIS_Y),
1174                ScaleHandle::new(AXIS_Z),
1175            ],
1176
1177            raycast:    GizmoRaycast::new(0.1),
1178            annotations: Vec::new(),
1179            grid_gizmo: GridGizmo::new(0.5),
1180            measurement: MeasurementTool::new(),
1181            lights:      Vec::new(),
1182            colliders:   Vec::new(),
1183            cameras:     Vec::new(),
1184            paths:       Vec::new(),
1185            vector_field: None,
1186
1187            selection_outlines: Vec::new(),
1188            locked_axis: None,
1189
1190            next_annotation_id: 1,
1191        }
1192    }
1193
1194    // ── Mode switching ────────────────────────────────────────────────────────
1195
1196    pub fn set_mode(&mut self, mode: GizmoMode) {
1197        self.mode = mode;
1198        self.locked_axis = None;
1199    }
1200
1201    pub fn set_space(&mut self, space: GizmoSpace) {
1202        self.space = space;
1203    }
1204
1205    pub fn handle_hotkey(&mut self, key: char) {
1206        match key {
1207            'g' => self.set_mode(GizmoMode::Translate),
1208            'r' => self.set_mode(GizmoMode::Rotate),
1209            's' => self.set_mode(GizmoMode::Scale),
1210            'u' => self.set_mode(GizmoMode::Universal),
1211            'x' => self.locked_axis = Some(AXIS_X),
1212            'y' => self.locked_axis = Some(AXIS_Y),
1213            'z' => self.locked_axis = Some(AXIS_Z),
1214            _ => {}
1215        }
1216    }
1217
1218    // ── Hit testing ───────────────────────────────────────────────────────────
1219
1220    pub fn hit_test(&self, ray: &Ray3, origin: Vec3) -> GizmoHit {
1221        if !self.visible { return GizmoHit::None; }
1222        match self.mode {
1223            GizmoMode::Translate | GizmoMode::Universal => {
1224                self.raycast.test_translate(ray, &self.translate, &self.translate_planes, origin, self.gizmo_scale)
1225            }
1226            GizmoMode::Rotate => {
1227                self.raycast.test_rotate(ray, &self.rotate, origin, self.gizmo_scale)
1228            }
1229            GizmoMode::Scale => {
1230                self.raycast.test_scale(ray, &self.scale, origin, self.gizmo_scale)
1231            }
1232            _ => GizmoHit::None,
1233        }
1234    }
1235
1236    // ── Hover updates ─────────────────────────────────────────────────────────
1237
1238    pub fn update_hover(&mut self, hit: &GizmoHit) {
1239        // Clear all hover states.
1240        for h in self.translate.iter_mut() { h.hovered = false; }
1241        for p in self.translate_planes.iter_mut() { p.hovered = false; }
1242        for r in self.rotate.iter_mut() { r.hovered = false; }
1243        for s in self.scale.iter_mut() { s.hovered = false; }
1244        // Set new hover.
1245        match hit {
1246            GizmoHit::TranslateAxis(i) => { self.translate[*i].hovered = true; }
1247            GizmoHit::TranslatePlane(i) => { self.translate_planes[*i].hovered = true; }
1248            GizmoHit::RotateAxis(i) => { self.rotate[*i].hovered = true; }
1249            GizmoHit::ScaleAxis(i) => { self.scale[*i].hovered = true; }
1250            _ => {}
1251        }
1252    }
1253
1254    // ── Drag ──────────────────────────────────────────────────────────────────
1255
1256    pub fn begin_drag(&mut self, hit: &GizmoHit, world_pos: Vec3, current_scale: Vec3) {
1257        match hit {
1258            GizmoHit::TranslateAxis(i) => self.translate[*i].begin_drag(world_pos),
1259            GizmoHit::RotateAxis(i) => {
1260                let angle = world_pos.dot(self.rotate[*i].axis);
1261                self.rotate[*i].begin_drag(angle);
1262            }
1263            GizmoHit::ScaleAxis(i) => self.scale[*i].begin_drag(current_scale),
1264            _ => {}
1265        }
1266    }
1267
1268    pub fn update_drag(&mut self, hit: &GizmoHit, world_pos: Vec3) {
1269        match hit {
1270            GizmoHit::TranslateAxis(i) => self.translate[*i].update_drag(world_pos),
1271            GizmoHit::RotateAxis(i) => {
1272                let angle = world_pos.dot(self.rotate[*i].axis);
1273                self.rotate[*i].update_drag(angle);
1274            }
1275            _ => {}
1276        }
1277    }
1278
1279    pub fn end_drag(&mut self, hit: &GizmoHit) -> GizmoDelta {
1280        match hit {
1281            GizmoHit::TranslateAxis(i) => {
1282                let delta = self.translate[*i].end_drag();
1283                GizmoDelta::Translation(delta)
1284            }
1285            GizmoHit::RotateAxis(i) => {
1286                let delta = self.rotate[*i].end_drag();
1287                GizmoDelta::Rotation(self.rotate[*i].axis, delta)
1288            }
1289            GizmoHit::ScaleAxis(i) => {
1290                let delta = self.scale[*i].end_drag();
1291                GizmoDelta::Scale(delta)
1292            }
1293            GizmoHit::ScaleUniform => {
1294                GizmoDelta::ScaleUniform(1.0)
1295            }
1296            _ => GizmoDelta::None,
1297        }
1298    }
1299
1300    // ── Selection outlines ────────────────────────────────────────────────────
1301
1302    pub fn add_selection_outline(&mut self, bounds: BoundingBox, color: Vec4) {
1303        self.selection_outlines.push((bounds, color));
1304    }
1305
1306    pub fn clear_selection_outlines(&mut self) {
1307        self.selection_outlines.clear();
1308    }
1309
1310    // ── Annotations ───────────────────────────────────────────────────────────
1311
1312    pub fn add_annotation(&mut self, text: impl Into<String>, pos: Vec3) -> u32 {
1313        let id = self.next_annotation_id;
1314        self.next_annotation_id += 1;
1315        self.annotations.push(AnnotationGizmo::new(id, text, pos));
1316        id
1317    }
1318
1319    pub fn remove_annotation(&mut self, id: u32) {
1320        self.annotations.retain(|a| a.id != id);
1321    }
1322
1323    // ── Lights / colliders / cameras ─────────────────────────────────────────
1324
1325    pub fn add_light(&mut self, gizmo: LightGizmo) { self.lights.push(gizmo); }
1326    pub fn add_collider(&mut self, gizmo: ColliderGizmo) { self.colliders.push(gizmo); }
1327    pub fn add_camera_frustum(&mut self, gizmo: CameraFrustumGizmo) { self.cameras.push(gizmo); }
1328    pub fn add_path(&mut self, gizmo: PathGizmo) { self.paths.push(gizmo); }
1329
1330    pub fn set_vector_field(&mut self, vf: VectorFieldGizmo) {
1331        self.vector_field = Some(vf);
1332    }
1333
1334    // ── Rendering summary ─────────────────────────────────────────────────────
1335
1336    /// Render a debug summary as ASCII text.
1337    pub fn render_ascii(&self, origin: Vec3) -> String {
1338        let mut out = String::new();
1339        out.push_str(&format!(
1340            "=== Gizmo [{}] space={:?} scale={:.2} ===\n",
1341            self.mode.label(), self.space, self.gizmo_scale
1342        ));
1343        match self.mode {
1344            GizmoMode::Translate => {
1345                for h in &self.translate {
1346                    out.push_str(&format!("  {}\n", h.render_ascii(origin)));
1347                }
1348            }
1349            GizmoMode::Rotate => {
1350                for r in &self.rotate {
1351                    let pts = r.arc_points(origin, self.gizmo_scale);
1352                    out.push_str(&format!("  Rotate[{:?}] {} arc pts\n", r.axis, pts.len()));
1353                }
1354            }
1355            GizmoMode::Scale => {
1356                for s in &self.scale {
1357                    out.push_str(&format!(
1358                        "  Scale[{:?}] cube @ {:?}\n",
1359                        s.axis, s.cube_center(origin, self.gizmo_scale)
1360                    ));
1361                }
1362            }
1363            _ => {}
1364        }
1365        if !self.annotations.is_empty() {
1366            out.push_str("Annotations:\n");
1367            for a in &self.annotations {
1368                out.push_str(&format!("  {}\n", a.render_ascii()));
1369            }
1370        }
1371        if !self.selection_outlines.is_empty() {
1372            out.push_str(&format!("{} selection outline(s)\n", self.selection_outlines.len()));
1373        }
1374        if let Some(d) = self.measurement.distance() {
1375            out.push_str(&format!("  {}\n", self.measurement.render_ascii()));
1376            let _ = d;
1377        }
1378        out
1379    }
1380}
1381
1382impl Default for GizmoRenderer {
1383    fn default() -> Self { Self::new() }
1384}
1385
1386/// The result of a completed drag operation.
1387#[derive(Debug, Clone, PartialEq)]
1388pub enum GizmoDelta {
1389    None,
1390    Translation(Vec3),
1391    Rotation(Vec3, f32),  // (axis, radians)
1392    Scale(Vec3),
1393    ScaleUniform(f32),
1394}
1395
1396// ─────────────────────────────────────────────────────────────────────────────
1397// Tests
1398// ─────────────────────────────────────────────────────────────────────────────
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403
1404    #[test]
1405    fn test_translate_handle_hit() {
1406        let h = TranslateHandle::new(AXIS_X);
1407        let ray = Ray3::new(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, -1.0));
1408        // Ray points along -Z from (0,0,1). Arrow is along +X. Should miss.
1409        assert!(!h.hit_test(&ray, Vec3::ZERO, 1.0, 0.1));
1410    }
1411
1412    #[test]
1413    fn test_translate_handle_drag() {
1414        let mut h = TranslateHandle::new(AXIS_X);
1415        h.begin_drag(Vec3::ZERO);
1416        h.update_drag(Vec3::new(2.0, 0.5, 0.3));
1417        // Only X component should be projected.
1418        assert!((h.value.x - 2.0).abs() < 1e-5);
1419        assert!(h.value.y.abs() < 1e-5);
1420    }
1421
1422    #[test]
1423    fn test_rotate_arc_points() {
1424        let r = RotateHandle::new(AXIS_Y);
1425        let pts = r.arc_points(Vec3::ZERO, 1.0);
1426        assert_eq!(pts.len(), r.segments + 1);
1427        for p in &pts {
1428            assert!((p.length() - 1.0).abs() < 0.01);
1429        }
1430    }
1431
1432    #[test]
1433    fn test_scale_handle_hit_uniform() {
1434        let gizmo = GizmoRenderer::new();
1435        let ray = Ray3::new(Vec3::new(0.0, 0.0, 0.1), Vec3::new(0.0, 0.0, -1.0));
1436        let hit = gizmo.raycast.test_scale(&ray, &gizmo.scale, Vec3::ZERO, 1.0);
1437        // A ray aimed almost directly at origin should hit ScaleUniform.
1438        assert_eq!(hit, GizmoHit::ScaleUniform);
1439    }
1440
1441    #[test]
1442    fn test_bounding_box_edges() {
1443        let bb = BoundingBox::new(Vec3::ZERO, Vec3::ONE);
1444        let edges = bb.edges();
1445        assert_eq!(edges.len(), 12);
1446    }
1447
1448    #[test]
1449    fn test_bounding_box_contains() {
1450        let bb = BoundingBox::new(Vec3::ZERO, Vec3::ONE);
1451        assert!(bb.contains_point(Vec3::new(0.5, 0.5, 0.5)));
1452        assert!(!bb.contains_point(Vec3::new(1.5, 0.5, 0.5)));
1453    }
1454
1455    #[test]
1456    fn test_frustum_corners() {
1457        let fg = CameraFrustumGizmo::new(Vec3::ZERO, 60.0, 16.0 / 9.0, 0.1, 100.0);
1458        let corners = fg.frustum_corners();
1459        assert_eq!(corners.len(), 8);
1460        // Near corners should be closer to origin than far corners.
1461        let near_dist = corners[0].length();
1462        let far_dist  = corners[4].length();
1463        assert!(far_dist > near_dist);
1464    }
1465
1466    #[test]
1467    fn test_path_gizmo_length() {
1468        let path = PathGizmo::new(vec![
1469            Vec3::ZERO,
1470            Vec3::new(1.0, 0.0, 0.0),
1471            Vec3::new(2.0, 0.0, 0.0),
1472        ]);
1473        let len = path.total_length();
1474        assert!((len - 2.0).abs() < 1e-5);
1475    }
1476
1477    #[test]
1478    fn test_path_spline_lines() {
1479        let path = PathGizmo::new(vec![
1480            Vec3::ZERO,
1481            Vec3::new(1.0, 0.0, 0.0),
1482            Vec3::new(2.0, 1.0, 0.0),
1483            Vec3::new(3.0, 0.0, 0.0),
1484        ]);
1485        let lines = path.spline_lines();
1486        assert!(!lines.is_empty());
1487    }
1488
1489    #[test]
1490    fn test_vector_field_samples() {
1491        let vf = VectorFieldGizmo::new(Vec3::ZERO, Vec3::ONE * 10.0, [4, 4, 4]);
1492        let pts = vf.sample_positions();
1493        assert_eq!(pts.len(), 64);
1494    }
1495
1496    #[test]
1497    fn test_vector_field_arrow_color() {
1498        let vf = VectorFieldGizmo::new(Vec3::ZERO, Vec3::ONE, [2, 2, 2]);
1499        let c0 = vf.color_for_magnitude(0.0);
1500        let c1 = vf.color_for_magnitude(vf.max_magnitude);
1501        assert!((c0 - vf.color_min).length() < 1e-4);
1502        assert!((c1 - vf.color_max).length() < 1e-4);
1503    }
1504
1505    #[test]
1506    fn test_light_gizmo_sphere_lines() {
1507        let lg = LightGizmo::new_point(Vec3::ZERO, 5.0);
1508        let lines = lg.sphere_lines(16);
1509        assert!(!lines.is_empty());
1510    }
1511
1512    #[test]
1513    fn test_measurement_tool() {
1514        let mut m = MeasurementTool::new();
1515        m.set_a(Vec3::ZERO);
1516        m.set_b(Vec3::new(3.0, 4.0, 0.0));
1517        let d = m.distance().unwrap();
1518        assert!((d - 5.0).abs() < 1e-5);
1519        let mid = m.midpoint().unwrap();
1520        assert!((mid.x - 1.5).abs() < 1e-5);
1521    }
1522
1523    #[test]
1524    fn test_gizmo_mode_hotkey() {
1525        let mut g = GizmoRenderer::new();
1526        g.handle_hotkey('r');
1527        assert_eq!(g.mode, GizmoMode::Rotate);
1528        g.handle_hotkey('g');
1529        assert_eq!(g.mode, GizmoMode::Translate);
1530        g.handle_hotkey('x');
1531        assert_eq!(g.locked_axis, Some(AXIS_X));
1532    }
1533
1534    #[test]
1535    fn test_ray_distance_to_point() {
1536        let ray = Ray3::new(Vec3::ZERO, Vec3::X);
1537        let d = ray.distance_to_point(Vec3::new(0.0, 1.0, 0.0));
1538        assert!((d - 1.0).abs() < 1e-5);
1539    }
1540
1541    #[test]
1542    fn test_annotation_add_remove() {
1543        let mut g = GizmoRenderer::new();
1544        let id = g.add_annotation("label", Vec3::ONE);
1545        assert_eq!(g.annotations.len(), 1);
1546        g.remove_annotation(id);
1547        assert!(g.annotations.is_empty());
1548    }
1549
1550    #[test]
1551    fn test_collider_box_edges() {
1552        let c = ColliderGizmo::new_box(Vec3::ZERO, Vec3::ONE);
1553        let lines = c.wire_lines(16);
1554        assert_eq!(lines.len(), 12);
1555    }
1556}