1use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
5
6#[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 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 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; } let t = ao.cross(ab).dot(self.direction.cross(ab)) / (cross_len * cross_len);
42 t.max(0.0)
43 }
44
45 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
99pub enum GizmoSpace {
100 #[default]
101 World,
102 Local,
103}
104
105pub const AXIS_X: Vec3 = Vec3::X;
110pub const AXIS_Y: Vec3 = Vec3::Y;
111pub const AXIS_Z: Vec3 = Vec3::Z;
112
113pub 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#[derive(Debug, Clone)]
136pub struct TranslateHandle {
137 pub axis: Vec3,
138 pub hovered: bool,
139 pub active: bool,
140 pub drag_start: Vec3, pub value: Vec3, pub length: f32, }
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 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 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 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#[derive(Debug, Clone)]
203pub struct PlaneHandle {
204 pub normal: Vec3, 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 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#[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 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 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#[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 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 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#[derive(Debug, Clone, PartialEq)]
382pub enum GizmoHit {
383 TranslateAxis(usize), TranslatePlane(usize), RotateAxis(usize),
386 ScaleAxis(usize),
387 ScaleUniform,
388 None,
389}
390
391pub 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 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#[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 pub fn edges(&self) -> [(Vec3, Vec3); 12] {
498 let (mn, mx) = (self.min, self.max);
499 [
500 (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 (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 (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#[derive(Debug, Clone)]
538pub struct AnnotationGizmo {
539 pub id: u32,
540 pub text: String,
541 pub world_pos: Vec3,
542 pub label_offset: Vec2, 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#[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 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#[derive(Debug, Clone)]
622pub struct LightGizmo {
623 pub position: Vec3,
624 pub direction: Vec3,
625 pub range: f32,
626 pub cone_angle: f32, 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 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 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 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 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#[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 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 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 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#[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 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 pub fn edges(&self) -> Vec<(Vec3, Vec3)> {
892 let c = self.frustum_corners();
893 vec![
894 (c[0], c[1]), (c[1], c[2]), (c[2], c[3]), (c[3], c[0]),
896 (c[4], c[5]), (c[5], c[6]), (c[6], c[7]), (c[7], c[4]),
898 (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#[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 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 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 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#[derive(Debug, Clone)]
1003pub struct VectorFieldGizmo {
1004 pub grid_origin: Vec3,
1005 pub grid_size: Vec3,
1006 pub cell_count: [u32; 3], 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 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 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 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 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#[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
1109pub struct GizmoRenderer {
1115 pub mode: GizmoMode,
1116 pub space: GizmoSpace,
1117 pub gizmo_scale: f32,
1118 pub visible: bool,
1119
1120 pub translate: [TranslateHandle; 3],
1122 pub translate_planes: [PlaneHandle; 3], pub rotate: [RotateHandle; 3],
1126
1127 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 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), PlaneHandle::new(AXIS_X), PlaneHandle::new(AXIS_Y), ],
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 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 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 pub fn update_hover(&mut self, hit: &GizmoHit) {
1239 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
1388pub enum GizmoDelta {
1389 None,
1390 Translation(Vec3),
1391 Rotation(Vec3, f32), Scale(Vec3),
1393 ScaleUniform(f32),
1394}
1395
1396#[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 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 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 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 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}