1#[derive(Debug, Clone, Copy, PartialEq)]
20#[non_exhaustive]
21pub enum PivotMode {
22 SelectionCentroid,
24 IndividualOrigins,
26 MedianPoint,
28 WorldOrigin,
30 Cursor3D(glam::Vec3),
32}
33
34impl PivotMode {
35 pub fn cycle_next(self) -> Self {
42 match self {
43 PivotMode::SelectionCentroid => PivotMode::IndividualOrigins,
44 PivotMode::IndividualOrigins => PivotMode::MedianPoint,
45 PivotMode::MedianPoint => PivotMode::WorldOrigin,
46 PivotMode::WorldOrigin => PivotMode::SelectionCentroid,
47 PivotMode::Cursor3D(_) => PivotMode::SelectionCentroid,
48 }
49 }
50
51 pub fn cycle_prev(self) -> Self {
53 match self {
54 PivotMode::SelectionCentroid => PivotMode::WorldOrigin,
55 PivotMode::IndividualOrigins => PivotMode::SelectionCentroid,
56 PivotMode::MedianPoint => PivotMode::IndividualOrigins,
57 PivotMode::WorldOrigin => PivotMode::MedianPoint,
58 PivotMode::Cursor3D(_) => PivotMode::SelectionCentroid,
59 }
60 }
61
62 pub fn label(self) -> &'static str {
64 match self {
65 PivotMode::SelectionCentroid => "Selection Centroid",
66 PivotMode::IndividualOrigins => "Individual Origins",
67 PivotMode::MedianPoint => "Median Point",
68 PivotMode::WorldOrigin => "World Origin",
69 PivotMode::Cursor3D(_) => "3D Cursor",
70 }
71 }
72}
73
74pub fn gizmo_center_for_pivot(
78 pivot: &PivotMode,
79 selection: &crate::interaction::selection::Selection,
80 position_fn: impl Fn(crate::interaction::selection::NodeId) -> Option<glam::Vec3>,
81) -> Option<glam::Vec3> {
82 if selection.is_empty() {
83 return None;
84 }
85 match pivot {
86 PivotMode::SelectionCentroid | PivotMode::MedianPoint => selection.centroid(position_fn),
87 PivotMode::IndividualOrigins => selection.primary().and_then(position_fn),
88 PivotMode::WorldOrigin => Some(glam::Vec3::ZERO),
89 PivotMode::Cursor3D(pos) => Some(*pos),
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq)]
95#[non_exhaustive]
96pub enum GizmoMode {
97 Translate,
99 Rotate,
101 Scale,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq)]
107#[non_exhaustive]
108pub enum GizmoAxis {
109 X,
111 Y,
113 Z,
115 XY,
117 XZ,
119 YZ,
121 Screen,
123 None,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum GizmoSpace {
130 World,
132 Local,
134}
135
136pub struct Gizmo {
142 pub mode: GizmoMode,
144 pub space: GizmoSpace,
146 pub hovered_axis: GizmoAxis,
148 pub active_axis: GizmoAxis,
150 pub drag_start_mouse: Option<glam::Vec2>,
152 pub pivot_mode: PivotMode,
154}
155
156impl Gizmo {
157 pub fn new() -> Self {
159 Self {
160 mode: GizmoMode::Translate,
161 space: GizmoSpace::World,
162 hovered_axis: GizmoAxis::None,
163 active_axis: GizmoAxis::None,
164 drag_start_mouse: None,
165 pivot_mode: PivotMode::SelectionCentroid,
166 }
167 }
168
169 pub fn cycle_pivot_forward(&mut self) {
173 self.pivot_mode = self.pivot_mode.cycle_next();
174 }
175
176 pub fn cycle_pivot_backward(&mut self) {
180 self.pivot_mode = self.pivot_mode.cycle_prev();
181 }
182
183 fn axis_directions(&self, object_orientation: glam::Quat) -> [glam::Vec3; 3] {
186 match self.space {
187 GizmoSpace::World => [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z],
188 GizmoSpace::Local => [
189 object_orientation * glam::Vec3::X,
190 object_orientation * glam::Vec3::Y,
191 object_orientation * glam::Vec3::Z,
192 ],
193 }
194 }
195
196 pub fn hit_test(
209 &self,
210 ray_origin: glam::Vec3,
211 ray_dir: glam::Vec3,
212 gizmo_center: glam::Vec3,
213 gizmo_scale: f32,
214 ) -> GizmoAxis {
215 self.hit_test_oriented(
216 ray_origin,
217 ray_dir,
218 gizmo_center,
219 gizmo_scale,
220 glam::Quat::IDENTITY,
221 )
222 }
223
224 pub fn hit_test_oriented(
226 &self,
227 ray_origin: glam::Vec3,
228 ray_dir: glam::Vec3,
229 gizmo_center: glam::Vec3,
230 gizmo_scale: f32,
231 object_orientation: glam::Quat,
232 ) -> GizmoAxis {
233 let dirs = self.axis_directions(object_orientation);
234
235 match self.mode {
236 GizmoMode::Rotate => {
237 let ring_radius = gizmo_scale * ROTATION_RING_RADIUS;
241 let ring_tolerance = gizmo_scale * 0.15;
242
243 let axis_ids = [GizmoAxis::X, GizmoAxis::Y, GizmoAxis::Z];
244 let mut best: Option<(GizmoAxis, f32)> = None;
245
246 for i in 0..3 {
247 let normal = dirs[i];
248 let denom = ray_dir.dot(normal);
249 if denom.abs() < 1e-6 {
250 continue;
251 }
252 let t = (gizmo_center - ray_origin).dot(normal) / denom;
253 if t < 0.0 {
254 continue;
255 }
256 let hit_point = ray_origin + ray_dir * t;
257 let dist_from_center = (hit_point - gizmo_center).length();
258 if (dist_from_center - ring_radius).abs() < ring_tolerance
259 && (best.is_none() || t < best.unwrap().1)
260 {
261 best = Some((axis_ids[i], t));
262 }
263 }
264
265 best.map(|(a, _)| a).unwrap_or(GizmoAxis::None)
266 }
267 _ => {
268 let hit_radius = gizmo_scale * 0.18;
270
271 let screen_size = gizmo_scale * 0.15;
273 let to_center = gizmo_center - ray_origin;
274 let t_center = to_center.dot(ray_dir);
275 if t_center > 0.0 {
276 let closest = ray_origin + ray_dir * t_center;
277 let offset = closest - gizmo_center;
278 if offset.length() < screen_size {
279 return GizmoAxis::Screen;
280 }
281 }
282
283 let plane_offset = gizmo_scale * 0.25;
285 let plane_size = gizmo_scale * 0.15;
286
287 let plane_handles = [
288 (GizmoAxis::XY, dirs[0], dirs[1], dirs[2]),
289 (GizmoAxis::XZ, dirs[0], dirs[2], dirs[1]),
290 (GizmoAxis::YZ, dirs[1], dirs[2], dirs[0]),
291 ];
292
293 let mut best_plane: Option<(GizmoAxis, f32)> = None;
294 for (axis, dir_a, dir_b, normal) in &plane_handles {
295 let quad_center = gizmo_center + *dir_a * plane_offset + *dir_b * plane_offset;
296 let denom = ray_dir.dot(*normal);
297 if denom.abs() < 1e-6 {
298 continue;
299 }
300 let t = (quad_center - ray_origin).dot(*normal) / denom;
301 if t < 0.0 {
302 continue;
303 }
304 let hit_point = ray_origin + ray_dir * t;
305 let local = hit_point - quad_center;
306 let a_dist = local.dot(*dir_a).abs();
307 let b_dist = local.dot(*dir_b).abs();
308 if a_dist < plane_size
309 && b_dist < plane_size
310 && (best_plane.is_none() || t < best_plane.unwrap().1)
311 {
312 best_plane = Some((*axis, t));
313 }
314 }
315 if let Some((axis, _)) = best_plane {
316 return axis;
317 }
318
319 let axis_ids = [GizmoAxis::X, GizmoAxis::Y, GizmoAxis::Z];
321 let mut best: Option<(GizmoAxis, f32)> = None;
322
323 for i in 0..3 {
324 let arm_end = gizmo_center + dirs[i] * gizmo_scale;
325 let dist = ray_to_segment_distance(ray_origin, ray_dir, gizmo_center, arm_end);
326 if dist < hit_radius {
327 let t = ray_segment_t(ray_origin, ray_dir, gizmo_center, arm_end);
328 if best.is_none() || t < best.unwrap().1 {
329 best = Some((axis_ids[i], t));
330 }
331 }
332 }
333
334 best.map(|(a, _)| a).unwrap_or(GizmoAxis::None)
335 }
336 }
337 }
338}
339
340impl Default for Gizmo {
341 fn default() -> Self {
342 Self::new()
343 }
344}
345
346fn ray_to_segment_distance(
355 ray_origin: glam::Vec3,
356 ray_dir: glam::Vec3,
357 seg_a: glam::Vec3,
358 seg_b: glam::Vec3,
359) -> f32 {
360 let seg_dir = seg_b - seg_a;
361 let w0 = ray_origin - seg_a;
362
363 let a = ray_dir.dot(ray_dir); let b = ray_dir.dot(seg_dir);
365 let c = seg_dir.dot(seg_dir);
366 let d = ray_dir.dot(w0);
367 let e = seg_dir.dot(w0);
368
369 let denom = a * c - b * b;
370
371 let (t_ray, t_seg) = if denom.abs() > 1e-8 {
372 let t_r = (b * e - c * d) / denom;
373 let t_s = (a * e - b * d) / denom;
374 (t_r.max(0.0), t_s.clamp(0.0, 1.0))
375 } else {
376 (0.0, 0.0)
378 };
379
380 let closest_ray = ray_origin + ray_dir * t_ray;
381 let closest_seg = seg_a + seg_dir * t_seg;
382 (closest_ray - closest_seg).length()
383}
384
385fn ray_segment_t(
389 ray_origin: glam::Vec3,
390 ray_dir: glam::Vec3,
391 seg_a: glam::Vec3,
392 seg_b: glam::Vec3,
393) -> f32 {
394 let seg_dir = seg_b - seg_a;
395 let w0 = ray_origin - seg_a;
396
397 let a = ray_dir.dot(ray_dir);
398 let b = ray_dir.dot(seg_dir);
399 let c = seg_dir.dot(seg_dir);
400 let d = ray_dir.dot(w0);
401 let e = seg_dir.dot(w0);
402
403 let denom = a * c - b * b;
404 if denom.abs() > 1e-8 {
405 let t_r = (b * e - c * d) / denom;
406 t_r.max(0.0)
407 } else {
408 0.0
409 }
410}
411
412pub use crate::resources::Vertex;
418
419const X_COLOR: [f32; 4] = [0.878, 0.322, 0.322, 1.0]; const Y_COLOR: [f32; 4] = [0.361, 0.722, 0.361, 1.0]; const Z_COLOR: [f32; 4] = [0.290, 0.620, 1.0, 1.0]; const X_COLOR_HOV: [f32; 4] = [1.0, 0.518, 0.518, 1.0]; const Y_COLOR_HOV: [f32; 4] = [0.469, 0.938, 0.469, 1.0]; const Z_COLOR_HOV: [f32; 4] = [0.377, 0.806, 1.0, 1.0]; const SCREEN_COLOR: [f32; 4] = [0.9, 0.9, 0.9, 0.6];
430const SCREEN_COLOR_HOV: [f32; 4] = [1.0, 1.0, 1.0, 0.8];
431const PLANE_ALPHA: f32 = 0.3;
432const PLANE_ALPHA_HOV: f32 = 0.5;
433
434fn axis_color(axis: GizmoAxis, hovered: GizmoAxis) -> [f32; 4] {
436 let is_hovered = axis == hovered;
437 match axis {
438 GizmoAxis::X => {
439 if is_hovered {
440 X_COLOR_HOV
441 } else {
442 X_COLOR
443 }
444 }
445 GizmoAxis::Y => {
446 if is_hovered {
447 Y_COLOR_HOV
448 } else {
449 Y_COLOR
450 }
451 }
452 GizmoAxis::Z => {
453 if is_hovered {
454 Z_COLOR_HOV
455 } else {
456 Z_COLOR
457 }
458 }
459 GizmoAxis::Screen => {
460 if is_hovered {
461 SCREEN_COLOR_HOV
462 } else {
463 SCREEN_COLOR
464 }
465 }
466 _ => [1.0; 4],
467 }
468}
469
470fn plane_color(axis: GizmoAxis, hovered: GizmoAxis) -> [f32; 4] {
473 let is_hovered = axis == hovered;
474 let alpha = if is_hovered {
475 PLANE_ALPHA_HOV
476 } else {
477 PLANE_ALPHA
478 };
479 let brightness = if is_hovered { 1.3 } else { 1.0 };
480 let (c1, c2) = match axis {
481 GizmoAxis::XY => (X_COLOR, Y_COLOR),
482 GizmoAxis::XZ => (X_COLOR, Z_COLOR),
483 GizmoAxis::YZ => (Y_COLOR, Z_COLOR),
484 _ => return [1.0, 1.0, 1.0, alpha],
485 };
486 [
487 ((c1[0] + c2[0]) * 0.5 * brightness).min(1.0),
488 ((c1[1] + c2[1]) * 0.5 * brightness).min(1.0),
489 ((c1[2] + c2[2]) * 0.5 * brightness).min(1.0),
490 alpha,
491 ]
492}
493
494pub(crate) fn build_gizmo_mesh(
505 mode: GizmoMode,
506 hovered: GizmoAxis,
507 space_orientation: glam::Quat,
508) -> (Vec<Vertex>, Vec<u32>) {
509 let mut vertices: Vec<Vertex> = Vec::new();
510 let mut indices: Vec<u32> = Vec::new();
511
512 match mode {
513 GizmoMode::Translate => {
514 build_arrows(
515 &mut vertices,
516 &mut indices,
517 hovered,
518 space_orientation,
519 false,
520 );
521 build_plane_quads(&mut vertices, &mut indices, hovered, space_orientation);
522 build_screen_handle(&mut vertices, &mut indices, hovered);
523 }
524 GizmoMode::Rotate => {
525 build_rotation_rings(&mut vertices, &mut indices, hovered, space_orientation);
526 }
527 GizmoMode::Scale => {
528 build_arrows(
529 &mut vertices,
530 &mut indices,
531 hovered,
532 space_orientation,
533 true,
534 );
535 }
536 }
537
538 (vertices, indices)
539}
540
541const SHAFT_RADIUS: f32 = 0.035;
543const SHAFT_LENGTH: f32 = 0.70;
544pub const ROTATION_RING_RADIUS: f32 = 0.85;
546const CONE_RADIUS: f32 = 0.09;
547const CONE_LENGTH: f32 = 0.30;
548const CUBE_HALF: f32 = 0.06;
549const SEGMENTS: u32 = 8;
550
551fn build_arrows(
554 vertices: &mut Vec<Vertex>,
555 indices: &mut Vec<u32>,
556 hovered: GizmoAxis,
557 orientation: glam::Quat,
558 cube_tips: bool,
559) {
560 let base_axes = [
561 (GizmoAxis::X, glam::Vec3::X, glam::Vec3::Y),
562 (GizmoAxis::Y, glam::Vec3::Y, glam::Vec3::X),
563 (GizmoAxis::Z, glam::Vec3::Z, glam::Vec3::Y),
564 ];
565
566 for (axis, raw_dir, raw_up) in &base_axes {
567 let axis_dir = orientation * *raw_dir;
568 let up_hint = orientation * *raw_up;
569 let color = axis_color(*axis, hovered);
570
571 let tangent = if axis_dir.abs().dot(orientation * glam::Vec3::Y) > 0.9 {
572 axis_dir.cross(up_hint).normalize()
573 } else {
574 axis_dir.cross(orientation * glam::Vec3::Y).normalize()
575 };
576 let bitangent = axis_dir.cross(tangent).normalize();
577
578 let base_index = vertices.len() as u32;
579
580 let shaft_bottom = glam::Vec3::ZERO;
582 let shaft_top = axis_dir * SHAFT_LENGTH;
583
584 for i in 0..SEGMENTS {
585 let angle = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
586 let radial = tangent * angle.cos() + bitangent * angle.sin();
587
588 vertices.push(Vertex {
589 position: (shaft_bottom + radial * SHAFT_RADIUS).to_array(),
590 normal: radial.to_array(),
591 color,
592 uv: [0.0, 0.0],
593 tangent: [0.0, 0.0, 0.0, 1.0],
594 });
595 vertices.push(Vertex {
596 position: (shaft_top + radial * SHAFT_RADIUS).to_array(),
597 normal: radial.to_array(),
598 color,
599 uv: [0.0, 0.0],
600 tangent: [0.0, 0.0, 0.0, 1.0],
601 });
602 }
603
604 for i in 0..SEGMENTS {
606 let next = (i + 1) % SEGMENTS;
607 let b0 = base_index + i * 2;
608 let t0 = base_index + i * 2 + 1;
609 let b1 = base_index + next * 2;
610 let t1 = base_index + next * 2 + 1;
611 indices.extend_from_slice(&[b0, b1, t0, t0, b1, t1]);
612 }
613
614 let shaft_bottom_center = vertices.len() as u32;
616 vertices.push(Vertex {
617 position: shaft_bottom.to_array(),
618 normal: (-axis_dir).to_array(),
619 color,
620 uv: [0.0, 0.0],
621 tangent: [0.0, 0.0, 0.0, 1.0],
622 });
623 for i in 0..SEGMENTS {
624 let next = (i + 1) % SEGMENTS;
625 let v0 = base_index + i * 2;
626 let v1 = base_index + next * 2;
627 indices.extend_from_slice(&[shaft_bottom_center, v1, v0]);
628 }
629
630 let tip_base = shaft_top;
632 if cube_tips {
633 build_cube_tip(
634 vertices, indices, tip_base, axis_dir, tangent, bitangent, color,
635 );
636 } else {
637 build_cone_tip(
638 vertices, indices, tip_base, axis_dir, tangent, bitangent, color,
639 );
640 }
641 }
642}
643
644fn build_cone_tip(
646 vertices: &mut Vec<Vertex>,
647 indices: &mut Vec<u32>,
648 base_center: glam::Vec3,
649 axis_dir: glam::Vec3,
650 tangent: glam::Vec3,
651 bitangent: glam::Vec3,
652 color: [f32; 4],
653) {
654 let cone_tip = base_center + axis_dir * CONE_LENGTH;
655 let cone_base_start = vertices.len() as u32;
656
657 for i in 0..SEGMENTS {
659 let angle = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
660 let radial = tangent * angle.cos() + bitangent * angle.sin();
661 vertices.push(Vertex {
662 position: (base_center + radial * CONE_RADIUS).to_array(),
663 normal: (-axis_dir).to_array(),
664 color,
665 uv: [0.0, 0.0],
666 tangent: [0.0, 0.0, 0.0, 1.0],
667 });
668 }
669
670 let base_cap_center = vertices.len() as u32;
672 vertices.push(Vertex {
673 position: base_center.to_array(),
674 normal: (-axis_dir).to_array(),
675 color,
676 uv: [0.0, 0.0],
677 tangent: [0.0, 0.0, 0.0, 1.0],
678 });
679 for i in 0..SEGMENTS {
680 let next = (i + 1) % SEGMENTS;
681 indices.extend_from_slice(&[base_cap_center, cone_base_start + i, cone_base_start + next]);
682 }
683
684 let tip_idx = vertices.len() as u32;
686 vertices.push(Vertex {
687 position: cone_tip.to_array(),
688 normal: axis_dir.to_array(),
689 color,
690 uv: [0.0, 0.0],
691 tangent: [0.0, 0.0, 0.0, 1.0],
692 });
693 for i in 0..SEGMENTS {
694 let next = (i + 1) % SEGMENTS;
695 indices.extend_from_slice(&[cone_base_start + i, cone_base_start + next, tip_idx]);
696 }
697}
698
699fn build_cube_tip(
701 vertices: &mut Vec<Vertex>,
702 indices: &mut Vec<u32>,
703 center: glam::Vec3,
704 axis_dir: glam::Vec3,
705 tangent: glam::Vec3,
706 bitangent: glam::Vec3,
707 color: [f32; 4],
708) {
709 let cube_center = center + axis_dir * CUBE_HALF;
710 let h = CUBE_HALF;
711
712 let corners = [
714 cube_center + axis_dir * (-h) + tangent * (-h) + bitangent * (-h),
715 cube_center + axis_dir * h + tangent * (-h) + bitangent * (-h),
716 cube_center + axis_dir * h + tangent * h + bitangent * (-h),
717 cube_center + axis_dir * (-h) + tangent * h + bitangent * (-h),
718 cube_center + axis_dir * (-h) + tangent * (-h) + bitangent * h,
719 cube_center + axis_dir * h + tangent * (-h) + bitangent * h,
720 cube_center + axis_dir * h + tangent * h + bitangent * h,
721 cube_center + axis_dir * (-h) + tangent * h + bitangent * h,
722 ];
723
724 let faces: [([usize; 4], glam::Vec3); 6] = [
726 ([1, 2, 6, 5], axis_dir), ([0, 4, 7, 3], -axis_dir), ([2, 3, 7, 6], tangent), ([0, 1, 5, 4], -tangent), ([4, 5, 6, 7], bitangent), ([0, 3, 2, 1], -bitangent), ];
733
734 for (corner_ids, normal) in &faces {
735 let base = vertices.len() as u32;
736 for &ci in corner_ids {
737 vertices.push(Vertex {
738 position: corners[ci].to_array(),
739 normal: normal.to_array(),
740 color,
741 uv: [0.0, 0.0],
742 tangent: [0.0, 0.0, 0.0, 1.0],
743 });
744 }
745 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
746 }
747}
748
749fn build_plane_quads(
751 vertices: &mut Vec<Vertex>,
752 indices: &mut Vec<u32>,
753 hovered: GizmoAxis,
754 orientation: glam::Quat,
755) {
756 let plane_offset = 0.25_f32;
757 let plane_size = 0.15_f32;
758
759 let planes = [
760 (GizmoAxis::XY, glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
761 (GizmoAxis::XZ, glam::Vec3::X, glam::Vec3::Z, glam::Vec3::Y),
762 (GizmoAxis::YZ, glam::Vec3::Y, glam::Vec3::Z, glam::Vec3::X),
763 ];
764
765 for (axis, dir_a, dir_b, normal_dir) in &planes {
766 let a = orientation * *dir_a;
767 let b = orientation * *dir_b;
768 let n = orientation * *normal_dir;
769 let center = a * plane_offset + b * plane_offset;
770 let color = plane_color(*axis, hovered);
771
772 let base = vertices.len() as u32;
773 let corners = [
774 center + a * (-plane_size) + b * (-plane_size),
775 center + a * plane_size + b * (-plane_size),
776 center + a * plane_size + b * plane_size,
777 center + a * (-plane_size) + b * plane_size,
778 ];
779 for c in &corners {
780 vertices.push(Vertex {
781 position: c.to_array(),
782 normal: n.to_array(),
783 color,
784 uv: [0.0, 0.0],
785 tangent: [0.0, 0.0, 0.0, 1.0],
786 });
787 }
788 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
790 indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
791 }
792}
793
794fn build_screen_handle(vertices: &mut Vec<Vertex>, indices: &mut Vec<u32>, hovered: GizmoAxis) {
796 let size = 0.08_f32;
797 let color = axis_color(GizmoAxis::Screen, hovered);
798 let base = vertices.len() as u32;
799
800 let corners = [
803 glam::Vec3::new(-size, -size, 0.0),
804 glam::Vec3::new(size, -size, 0.0),
805 glam::Vec3::new(size, size, 0.0),
806 glam::Vec3::new(-size, size, 0.0),
807 ];
808 for c in &corners {
809 vertices.push(Vertex {
810 position: c.to_array(),
811 normal: [0.0, 0.0, 1.0],
812 color,
813 uv: [0.0, 0.0],
814 tangent: [0.0, 0.0, 0.0, 1.0],
815 });
816 }
817 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
818 indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
819}
820
821fn build_rotation_rings(
823 vertices: &mut Vec<Vertex>,
824 indices: &mut Vec<u32>,
825 hovered: GizmoAxis,
826 orientation: glam::Quat,
827) {
828 let ring_radius = ROTATION_RING_RADIUS; let tube_radius = 0.025_f32; let ring_segments = 40_u32;
831 let tube_segments = 8_u32;
832
833 let axis_data = [
834 (GizmoAxis::X, glam::Vec3::X),
835 (GizmoAxis::Y, glam::Vec3::Y),
836 (GizmoAxis::Z, glam::Vec3::Z),
837 ];
838
839 for (axis, raw_dir) in &axis_data {
840 let axis_dir = orientation * *raw_dir;
841 let color = axis_color(*axis, hovered);
842
843 let (ring_u, ring_v) = perpendicular_pair(axis_dir);
845
846 let base = vertices.len() as u32;
847
848 for i in 0..ring_segments {
849 let theta = (i as f32) * std::f32::consts::TAU / (ring_segments as f32);
850 let cos_t = theta.cos();
851 let sin_t = theta.sin();
852 let ring_center = (ring_u * cos_t + ring_v * sin_t) * ring_radius;
854 let outward = (ring_u * cos_t + ring_v * sin_t).normalize();
856
857 for j in 0..tube_segments {
858 let phi = (j as f32) * std::f32::consts::TAU / (tube_segments as f32);
859 let cos_p = phi.cos();
860 let sin_p = phi.sin();
861 let normal = outward * cos_p + axis_dir * sin_p;
862 let pos = ring_center + normal * tube_radius;
863
864 vertices.push(Vertex {
865 position: pos.to_array(),
866 normal: normal.to_array(),
867 color,
868 uv: [0.0, 0.0],
869 tangent: [0.0, 0.0, 0.0, 1.0],
870 });
871 }
872 }
873
874 for i in 0..ring_segments {
876 let next_i = (i + 1) % ring_segments;
877 for j in 0..tube_segments {
878 let next_j = (j + 1) % tube_segments;
879 let v00 = base + i * tube_segments + j;
880 let v01 = base + i * tube_segments + next_j;
881 let v10 = base + next_i * tube_segments + j;
882 let v11 = base + next_i * tube_segments + next_j;
883 indices.extend_from_slice(&[v00, v10, v01, v01, v10, v11]);
884 }
885 }
886 }
887}
888
889fn perpendicular_pair(axis: glam::Vec3) -> (glam::Vec3, glam::Vec3) {
891 let hint = if axis.dot(glam::Vec3::Z).abs() > 0.9 {
892 glam::Vec3::X
893 } else {
894 glam::Vec3::Z
895 };
896 let u = axis.cross(hint).normalize();
897 let v = axis.cross(u).normalize();
898 (u, v)
899}
900
901pub fn compute_gizmo_scale(
914 gizmo_center_world: glam::Vec3,
915 camera_eye: glam::Vec3,
916 fov_y: f32,
917 viewport_height: f32,
918) -> f32 {
919 let dist = (gizmo_center_world - camera_eye).length();
920 let world_per_px = 2.0 * (fov_y * 0.5).tan() * dist / viewport_height;
922 let target_px = 100.0_f32;
924 world_per_px * target_px
925}
926
927#[repr(C)]
933#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
934pub(crate) struct GizmoUniform {
935 pub(crate) model: [[f32; 4]; 4],
937}
938
939pub fn project_drag_onto_axis(
954 drag_delta: glam::Vec2,
955 axis_world: glam::Vec3,
956 view_proj: glam::Mat4,
957 gizmo_center: glam::Vec3,
958 viewport_size: glam::Vec2,
959) -> f32 {
960 let base_ndc = view_proj.project_point3(gizmo_center);
962 let tip_ndc = view_proj.project_point3(gizmo_center + axis_world);
963
964 let base_screen = glam::Vec2::new(
966 (base_ndc.x + 1.0) * 0.5 * viewport_size.x,
967 (1.0 - base_ndc.y) * 0.5 * viewport_size.y,
968 );
969 let tip_screen = glam::Vec2::new(
970 (tip_ndc.x + 1.0) * 0.5 * viewport_size.x,
971 (1.0 - tip_ndc.y) * 0.5 * viewport_size.y,
972 );
973
974 let axis_screen = tip_screen - base_screen;
975 let axis_screen_len = axis_screen.length();
976
977 if axis_screen_len < 1e-4 {
978 return 0.0;
979 }
980
981 let axis_screen_norm = axis_screen / axis_screen_len;
983 let drag_along_axis = drag_delta.dot(axis_screen_norm);
984
985 drag_along_axis / axis_screen_len
988}
989
990pub fn project_drag_onto_rotation(
995 drag_delta: glam::Vec2,
996 axis_world: glam::Vec3,
997 view: glam::Mat4,
998) -> f32 {
999 let axis_cam = (view * axis_world.extend(0.0))
1002 .truncate()
1003 .normalize_or_zero();
1004
1005 let perp = glam::Vec2::new(-axis_cam.y, axis_cam.x);
1007 let perp_len = perp.length();
1008 if perp_len < 1e-4 {
1009 return 0.0;
1010 }
1011
1012 let perp_norm = perp / perp_len;
1014 let drag_amount = drag_delta.dot(perp_norm);
1015
1016 drag_amount * 0.02
1018}
1019
1020pub fn project_drag_onto_plane(
1024 drag_delta: glam::Vec2,
1025 axis_a: glam::Vec3,
1026 axis_b: glam::Vec3,
1027 view_proj: glam::Mat4,
1028 gizmo_center: glam::Vec3,
1029 viewport_size: glam::Vec2,
1030) -> glam::Vec3 {
1031 let a_amount =
1032 project_drag_onto_axis(drag_delta, axis_a, view_proj, gizmo_center, viewport_size);
1033 let b_amount =
1034 project_drag_onto_axis(drag_delta, axis_b, view_proj, gizmo_center, viewport_size);
1035 axis_a * a_amount + axis_b * b_amount
1036}
1037
1038pub fn project_drag_onto_screen_plane(
1042 drag_delta: glam::Vec2,
1043 camera_right: glam::Vec3,
1044 camera_up: glam::Vec3,
1045 view_proj: glam::Mat4,
1046 gizmo_center: glam::Vec3,
1047 viewport_size: glam::Vec2,
1048) -> glam::Vec3 {
1049 project_drag_onto_plane(
1050 drag_delta,
1051 camera_right,
1052 camera_up,
1053 view_proj,
1054 gizmo_center,
1055 viewport_size,
1056 )
1057}
1058
1059pub fn gizmo_center_from_selection(
1063 selection: &crate::interaction::selection::Selection,
1064 position_fn: impl Fn(crate::interaction::selection::NodeId) -> Option<glam::Vec3>,
1065) -> Option<glam::Vec3> {
1066 selection.centroid(position_fn)
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 fn gizmo() -> Gizmo {
1074 Gizmo::new()
1075 }
1076
1077 #[test]
1078 fn test_hit_test_x_axis() {
1079 let g = gizmo();
1080 let center = glam::Vec3::ZERO;
1081 let scale = 1.0;
1082 let axis = g.hit_test(
1083 glam::Vec3::new(0.5, 0.5, 0.0),
1084 glam::Vec3::new(0.0, -1.0, 0.0),
1085 center,
1086 scale,
1087 );
1088 assert_eq!(axis, GizmoAxis::X);
1089 }
1090
1091 #[test]
1092 fn test_hit_test_y_axis() {
1093 let g = gizmo();
1094 let center = glam::Vec3::ZERO;
1095 let scale = 1.0;
1096 let axis = g.hit_test(
1097 glam::Vec3::new(0.5, 0.5, 0.0),
1098 glam::Vec3::new(-1.0, 0.0, 0.0),
1099 center,
1100 scale,
1101 );
1102 assert_eq!(axis, GizmoAxis::Y);
1103 }
1104
1105 #[test]
1106 fn test_hit_test_z_axis() {
1107 let g = gizmo();
1108 let center = glam::Vec3::ZERO;
1109 let scale = 1.0;
1110 let axis = g.hit_test(
1111 glam::Vec3::new(0.0, 0.5, 0.5),
1112 glam::Vec3::new(0.0, -1.0, 0.0),
1113 center,
1114 scale,
1115 );
1116 assert_eq!(axis, GizmoAxis::Z);
1117 }
1118
1119 #[test]
1120 fn test_hit_test_miss() {
1121 let g = gizmo();
1122 let center = glam::Vec3::ZERO;
1123 let scale = 1.0;
1124 let axis = g.hit_test(
1125 glam::Vec3::new(10.0, 10.0, 10.0),
1126 glam::Vec3::new(0.0, 0.0, -1.0),
1127 center,
1128 scale,
1129 );
1130 assert_eq!(axis, GizmoAxis::None);
1131 }
1132
1133 #[test]
1134 fn test_hit_test_plane_handle_xy() {
1135 let g = gizmo();
1136 let center = glam::Vec3::ZERO;
1137 let scale = 1.0;
1138 let axis = g.hit_test_oriented(
1140 glam::Vec3::new(0.25, 0.25, 5.0),
1141 glam::Vec3::new(0.0, 0.0, -1.0),
1142 center,
1143 scale,
1144 glam::Quat::IDENTITY,
1145 );
1146 assert_eq!(axis, GizmoAxis::XY, "expected XY plane handle hit");
1147 }
1148
1149 #[test]
1150 fn test_hit_test_local_orientation() {
1151 let mut g = gizmo();
1152 g.space = GizmoSpace::Local;
1153 let center = glam::Vec3::ZERO;
1154 let scale = 1.0;
1155 let rot = glam::Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
1157
1158 let axis = g.hit_test_oriented(
1161 glam::Vec3::new(0.0, 0.5, -0.5),
1162 glam::Vec3::new(0.0, -1.0, 0.0),
1163 center,
1164 scale,
1165 rot,
1166 );
1167 assert_eq!(
1168 axis,
1169 GizmoAxis::X,
1170 "local X axis should be along world -Z after 90° Y rotation"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_project_drag_onto_axis() {
1176 let view = glam::Mat4::look_at_rh(
1177 glam::Vec3::new(0.0, 0.0, 5.0),
1178 glam::Vec3::ZERO,
1179 glam::Vec3::Y,
1180 );
1181 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1182 let vp = proj * view;
1183 let viewport_size = glam::Vec2::new(800.0, 600.0);
1184 let center = glam::Vec3::ZERO;
1185
1186 let result = project_drag_onto_axis(
1187 glam::Vec2::new(100.0, 0.0),
1188 glam::Vec3::X,
1189 vp,
1190 center,
1191 viewport_size,
1192 );
1193 assert!(result > 0.0, "expected positive drag along X, got {result}");
1194 }
1195
1196 #[test]
1197 fn test_project_drag_onto_plane() {
1198 let view = glam::Mat4::look_at_rh(
1199 glam::Vec3::new(0.0, 5.0, 5.0),
1200 glam::Vec3::ZERO,
1201 glam::Vec3::Y,
1202 );
1203 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1204 let vp = proj * view;
1205 let viewport_size = glam::Vec2::new(800.0, 600.0);
1206 let center = glam::Vec3::ZERO;
1207
1208 let result = project_drag_onto_plane(
1209 glam::Vec2::new(100.0, 0.0),
1210 glam::Vec3::X,
1211 glam::Vec3::Z,
1212 vp,
1213 center,
1214 viewport_size,
1215 );
1216 assert!(
1218 result.length() > 0.0,
1219 "plane drag should produce non-zero displacement"
1220 );
1221 assert!(
1222 result.y.abs() < 1e-4,
1223 "XZ plane drag should have no Y component"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_screen_handle_hit() {
1229 let g = gizmo();
1230 let center = glam::Vec3::ZERO;
1231 let scale = 1.0;
1232 let axis = g.hit_test(
1234 glam::Vec3::new(0.0, 0.0, 5.0),
1235 glam::Vec3::new(0.0, 0.0, -1.0),
1236 center,
1237 scale,
1238 );
1239 assert_eq!(
1240 axis,
1241 GizmoAxis::Screen,
1242 "ray at center should hit Screen handle"
1243 );
1244 }
1245
1246 #[test]
1247 fn test_build_mesh_translate_has_plane_quads() {
1248 let (verts, idxs) =
1249 build_gizmo_mesh(GizmoMode::Translate, GizmoAxis::None, glam::Quat::IDENTITY);
1250 assert!(
1252 verts.len() > 80,
1253 "translate mesh should have significant vertex count, got {}",
1254 verts.len()
1255 );
1256 assert!(!idxs.is_empty());
1257 }
1258
1259 #[test]
1260 fn test_build_mesh_rotate_produces_rings() {
1261 let (verts, _) = build_gizmo_mesh(GizmoMode::Rotate, GizmoAxis::None, glam::Quat::IDENTITY);
1262 assert!(
1264 verts.len() >= 960,
1265 "rotate mesh should have ring vertices, got {}",
1266 verts.len()
1267 );
1268 }
1269
1270 #[test]
1271 fn test_build_mesh_scale_has_cubes() {
1272 let (verts_translate, _) =
1273 build_gizmo_mesh(GizmoMode::Translate, GizmoAxis::None, glam::Quat::IDENTITY);
1274 let (verts_scale, _) =
1275 build_gizmo_mesh(GizmoMode::Scale, GizmoAxis::None, glam::Quat::IDENTITY);
1276 assert!(
1279 verts_scale.len() > 50,
1280 "scale mesh should have geometry, got {}",
1281 verts_scale.len()
1282 );
1283 assert_ne!(
1284 verts_translate.len(),
1285 verts_scale.len(),
1286 "translate and scale should have different vertex counts (cone vs cube tips)"
1287 );
1288 }
1289
1290 #[test]
1291 fn test_compute_gizmo_scale() {
1292 let scale = compute_gizmo_scale(
1293 glam::Vec3::ZERO,
1294 glam::Vec3::new(0.0, 0.0, 10.0),
1295 std::f32::consts::FRAC_PI_4,
1296 600.0,
1297 );
1298 assert!(scale > 0.0, "gizmo scale should be positive");
1299 assert!((scale - 1.381).abs() < 0.1, "unexpected scale: {scale}");
1300 }
1301
1302 #[test]
1303 fn test_gizmo_center_single_selection() {
1304 let mut sel = crate::interaction::selection::Selection::new();
1305 sel.select_one(1);
1306 let center = gizmo_center_from_selection(&sel, |id| match id {
1307 1 => Some(glam::Vec3::new(3.0, 0.0, 0.0)),
1308 _ => None,
1309 });
1310 let c = center.unwrap();
1311 assert!((c.x - 3.0).abs() < 1e-5);
1312 }
1313
1314 #[test]
1315 fn test_gizmo_center_multi_selection() {
1316 let mut sel = crate::interaction::selection::Selection::new();
1317 sel.add(1);
1318 sel.add(2);
1319 let center = gizmo_center_from_selection(&sel, |id| match id {
1320 1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1321 2 => Some(glam::Vec3::new(4.0, 0.0, 0.0)),
1322 _ => None,
1323 });
1324 let c = center.unwrap();
1325 assert!((c.x - 2.0).abs() < 1e-5);
1326 }
1327
1328 #[test]
1331 fn test_pivot_selection_centroid_matches_centroid() {
1332 let mut sel = crate::interaction::selection::Selection::new();
1333 sel.add(1);
1334 sel.add(2);
1335 let pos_fn = |id: crate::interaction::selection::NodeId| match id {
1336 1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1337 2 => Some(glam::Vec3::new(4.0, 0.0, 0.0)),
1338 _ => None,
1339 };
1340 let centroid = gizmo_center_from_selection(&sel, pos_fn);
1341 let pivot = gizmo_center_for_pivot(&PivotMode::SelectionCentroid, &sel, pos_fn);
1342 assert_eq!(centroid, pivot);
1343 }
1344
1345 #[test]
1346 fn test_pivot_world_origin_returns_zero() {
1347 let mut sel = crate::interaction::selection::Selection::new();
1348 sel.add(1);
1349 let result = gizmo_center_for_pivot(&PivotMode::WorldOrigin, &sel, |_| {
1350 Some(glam::Vec3::new(5.0, 0.0, 0.0))
1351 });
1352 assert_eq!(result, Some(glam::Vec3::ZERO));
1353 }
1354
1355 #[test]
1356 fn test_pivot_world_origin_empty_selection_returns_none() {
1357 let sel = crate::interaction::selection::Selection::new();
1358 let result = gizmo_center_for_pivot(&PivotMode::WorldOrigin, &sel, |_| None);
1359 assert_eq!(result, None);
1360 }
1361
1362 #[test]
1363 fn test_pivot_individual_origins_uses_primary() {
1364 let mut sel = crate::interaction::selection::Selection::new();
1365 sel.add(1);
1366 sel.add(2); let result = gizmo_center_for_pivot(&PivotMode::IndividualOrigins, &sel, |id| match id {
1368 1 => Some(glam::Vec3::new(1.0, 0.0, 0.0)),
1369 2 => Some(glam::Vec3::new(9.0, 0.0, 0.0)),
1370 _ => None,
1371 });
1372 let c = result.unwrap();
1373 assert!(
1374 (c.x - 9.0).abs() < 1e-5,
1375 "expected primary (node 2) position x=9, got {}",
1376 c.x
1377 );
1378 }
1379
1380 #[test]
1381 fn test_pivot_median_point_same_as_centroid() {
1382 let mut sel = crate::interaction::selection::Selection::new();
1383 sel.add(1);
1384 sel.add(2);
1385 let pos_fn = |id: crate::interaction::selection::NodeId| match id {
1386 1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1387 2 => Some(glam::Vec3::new(6.0, 0.0, 0.0)),
1388 _ => None,
1389 };
1390 let result = gizmo_center_for_pivot(&PivotMode::MedianPoint, &sel, pos_fn);
1391 let c = result.unwrap();
1392 assert!((c.x - 3.0).abs() < 1e-5);
1393 }
1394
1395 #[test]
1396 fn test_pivot_cursor3d_returns_cursor_pos() {
1397 let mut sel = crate::interaction::selection::Selection::new();
1398 sel.add(1);
1399 let cursor = glam::Vec3::new(7.0, 2.0, 3.0);
1400 let result = gizmo_center_for_pivot(&PivotMode::Cursor3D(cursor), &sel, |_| {
1401 Some(glam::Vec3::ZERO)
1402 });
1403 assert_eq!(result, Some(cursor));
1404 }
1405
1406 #[test]
1407 fn test_pivot_cursor3d_empty_selection_returns_none() {
1408 let sel = crate::interaction::selection::Selection::new();
1409 let cursor = glam::Vec3::new(1.0, 2.0, 3.0);
1410 let result = gizmo_center_for_pivot(&PivotMode::Cursor3D(cursor), &sel, |_| None);
1411 assert_eq!(result, None);
1412 }
1413
1414 #[test]
1415 fn test_gizmo_pivot_mode_field_defaults_to_selection_centroid() {
1416 let g = Gizmo::new();
1417 assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1418 }
1419
1420 #[test]
1423 fn test_cycle_next_full_round_trip() {
1424 let start = PivotMode::SelectionCentroid;
1425 let after_one = start.cycle_next();
1426 assert!(matches!(after_one, PivotMode::IndividualOrigins));
1427 let after_two = after_one.cycle_next();
1428 assert!(matches!(after_two, PivotMode::MedianPoint));
1429 let after_three = after_two.cycle_next();
1430 assert!(matches!(after_three, PivotMode::WorldOrigin));
1431 let wrapped = after_three.cycle_next();
1432 assert!(matches!(wrapped, PivotMode::SelectionCentroid));
1433 }
1434
1435 #[test]
1436 fn test_cycle_prev_full_round_trip() {
1437 let start = PivotMode::SelectionCentroid;
1438 let after_one = start.cycle_prev();
1439 assert!(matches!(after_one, PivotMode::WorldOrigin));
1440 let after_two = after_one.cycle_prev();
1441 assert!(matches!(after_two, PivotMode::MedianPoint));
1442 let after_three = after_two.cycle_prev();
1443 assert!(matches!(after_three, PivotMode::IndividualOrigins));
1444 let wrapped = after_three.cycle_prev();
1445 assert!(matches!(wrapped, PivotMode::SelectionCentroid));
1446 }
1447
1448 #[test]
1449 fn test_cycle_next_and_prev_are_inverses() {
1450 for mode in [
1451 PivotMode::SelectionCentroid,
1452 PivotMode::IndividualOrigins,
1453 PivotMode::MedianPoint,
1454 PivotMode::WorldOrigin,
1455 ] {
1456 assert_eq!(mode.cycle_next().cycle_prev(), mode);
1457 assert_eq!(mode.cycle_prev().cycle_next(), mode);
1458 }
1459 }
1460
1461 #[test]
1462 fn test_cursor3d_falls_back_to_selection_centroid_on_cycle() {
1463 let cursor = PivotMode::Cursor3D(glam::Vec3::ONE);
1464 assert!(matches!(cursor.cycle_next(), PivotMode::SelectionCentroid));
1465 assert!(matches!(cursor.cycle_prev(), PivotMode::SelectionCentroid));
1466 }
1467
1468 #[test]
1469 fn test_label_returns_non_empty_str() {
1470 for mode in [
1471 PivotMode::SelectionCentroid,
1472 PivotMode::IndividualOrigins,
1473 PivotMode::MedianPoint,
1474 PivotMode::WorldOrigin,
1475 PivotMode::Cursor3D(glam::Vec3::ZERO),
1476 ] {
1477 assert!(!mode.label().is_empty());
1478 }
1479 }
1480
1481 #[test]
1482 fn test_gizmo_cycle_pivot_forward_and_backward() {
1483 let mut g = Gizmo::new();
1484 assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1485 g.cycle_pivot_forward();
1486 assert!(matches!(g.pivot_mode, PivotMode::IndividualOrigins));
1487 g.cycle_pivot_backward();
1488 assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1489 }
1490}