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
34pub fn gizmo_center_for_pivot(
38 pivot: &PivotMode,
39 selection: &crate::interaction::selection::Selection,
40 position_fn: impl Fn(crate::interaction::selection::NodeId) -> Option<glam::Vec3>,
41) -> Option<glam::Vec3> {
42 if selection.is_empty() {
43 return None;
44 }
45 match pivot {
46 PivotMode::SelectionCentroid | PivotMode::MedianPoint => selection.centroid(position_fn),
47 PivotMode::IndividualOrigins => selection.primary().and_then(position_fn),
48 PivotMode::WorldOrigin => Some(glam::Vec3::ZERO),
49 PivotMode::Cursor3D(pos) => Some(*pos),
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq)]
55#[non_exhaustive]
56pub enum GizmoMode {
57 Translate,
59 Rotate,
61 Scale,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
67#[non_exhaustive]
68pub enum GizmoAxis {
69 X,
71 Y,
73 Z,
75 XY,
77 XZ,
79 YZ,
81 Screen,
83 None,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum GizmoSpace {
90 World,
92 Local,
94}
95
96pub struct Gizmo {
102 pub mode: GizmoMode,
104 pub space: GizmoSpace,
106 pub hovered_axis: GizmoAxis,
108 pub active_axis: GizmoAxis,
110 pub drag_start_mouse: Option<glam::Vec2>,
112 pub pivot_mode: PivotMode,
114}
115
116impl Gizmo {
117 pub fn new() -> Self {
119 Self {
120 mode: GizmoMode::Translate,
121 space: GizmoSpace::World,
122 hovered_axis: GizmoAxis::None,
123 active_axis: GizmoAxis::None,
124 drag_start_mouse: None,
125 pivot_mode: PivotMode::SelectionCentroid,
126 }
127 }
128
129 fn axis_directions(&self, object_orientation: glam::Quat) -> [glam::Vec3; 3] {
132 match self.space {
133 GizmoSpace::World => [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z],
134 GizmoSpace::Local => [
135 object_orientation * glam::Vec3::X,
136 object_orientation * glam::Vec3::Y,
137 object_orientation * glam::Vec3::Z,
138 ],
139 }
140 }
141
142 pub fn hit_test(
155 &self,
156 ray_origin: glam::Vec3,
157 ray_dir: glam::Vec3,
158 gizmo_center: glam::Vec3,
159 gizmo_scale: f32,
160 ) -> GizmoAxis {
161 self.hit_test_oriented(
162 ray_origin,
163 ray_dir,
164 gizmo_center,
165 gizmo_scale,
166 glam::Quat::IDENTITY,
167 )
168 }
169
170 pub fn hit_test_oriented(
172 &self,
173 ray_origin: glam::Vec3,
174 ray_dir: glam::Vec3,
175 gizmo_center: glam::Vec3,
176 gizmo_scale: f32,
177 object_orientation: glam::Quat,
178 ) -> GizmoAxis {
179 let dirs = self.axis_directions(object_orientation);
180
181 match self.mode {
182 GizmoMode::Rotate => {
183 let ring_radius = gizmo_scale * ROTATION_RING_RADIUS;
187 let ring_tolerance = gizmo_scale * 0.15;
188
189 let axis_ids = [GizmoAxis::X, GizmoAxis::Y, GizmoAxis::Z];
190 let mut best: Option<(GizmoAxis, f32)> = None;
191
192 for i in 0..3 {
193 let normal = dirs[i];
194 let denom = ray_dir.dot(normal);
195 if denom.abs() < 1e-6 {
196 continue;
197 }
198 let t = (gizmo_center - ray_origin).dot(normal) / denom;
199 if t < 0.0 {
200 continue;
201 }
202 let hit_point = ray_origin + ray_dir * t;
203 let dist_from_center = (hit_point - gizmo_center).length();
204 if (dist_from_center - ring_radius).abs() < ring_tolerance
205 && (best.is_none() || t < best.unwrap().1)
206 {
207 best = Some((axis_ids[i], t));
208 }
209 }
210
211 best.map(|(a, _)| a).unwrap_or(GizmoAxis::None)
212 }
213 _ => {
214 let hit_radius = gizmo_scale * 0.18;
216
217 let screen_size = gizmo_scale * 0.15;
219 let to_center = gizmo_center - ray_origin;
220 let t_center = to_center.dot(ray_dir);
221 if t_center > 0.0 {
222 let closest = ray_origin + ray_dir * t_center;
223 let offset = closest - gizmo_center;
224 if offset.length() < screen_size {
225 return GizmoAxis::Screen;
226 }
227 }
228
229 let plane_offset = gizmo_scale * 0.25;
231 let plane_size = gizmo_scale * 0.15;
232
233 let plane_handles = [
234 (GizmoAxis::XY, dirs[0], dirs[1], dirs[2]),
235 (GizmoAxis::XZ, dirs[0], dirs[2], dirs[1]),
236 (GizmoAxis::YZ, dirs[1], dirs[2], dirs[0]),
237 ];
238
239 let mut best_plane: Option<(GizmoAxis, f32)> = None;
240 for (axis, dir_a, dir_b, normal) in &plane_handles {
241 let quad_center = gizmo_center + *dir_a * plane_offset + *dir_b * plane_offset;
242 let denom = ray_dir.dot(*normal);
243 if denom.abs() < 1e-6 {
244 continue;
245 }
246 let t = (quad_center - ray_origin).dot(*normal) / denom;
247 if t < 0.0 {
248 continue;
249 }
250 let hit_point = ray_origin + ray_dir * t;
251 let local = hit_point - quad_center;
252 let a_dist = local.dot(*dir_a).abs();
253 let b_dist = local.dot(*dir_b).abs();
254 if a_dist < plane_size
255 && b_dist < plane_size
256 && (best_plane.is_none() || t < best_plane.unwrap().1)
257 {
258 best_plane = Some((*axis, t));
259 }
260 }
261 if let Some((axis, _)) = best_plane {
262 return axis;
263 }
264
265 let axis_ids = [GizmoAxis::X, GizmoAxis::Y, GizmoAxis::Z];
267 let mut best: Option<(GizmoAxis, f32)> = None;
268
269 for i in 0..3 {
270 let arm_end = gizmo_center + dirs[i] * gizmo_scale;
271 let dist = ray_to_segment_distance(ray_origin, ray_dir, gizmo_center, arm_end);
272 if dist < hit_radius {
273 let t = ray_segment_t(ray_origin, ray_dir, gizmo_center, arm_end);
274 if best.is_none() || t < best.unwrap().1 {
275 best = Some((axis_ids[i], t));
276 }
277 }
278 }
279
280 best.map(|(a, _)| a).unwrap_or(GizmoAxis::None)
281 }
282 }
283 }
284}
285
286impl Default for Gizmo {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292fn ray_to_segment_distance(
301 ray_origin: glam::Vec3,
302 ray_dir: glam::Vec3,
303 seg_a: glam::Vec3,
304 seg_b: glam::Vec3,
305) -> f32 {
306 let seg_dir = seg_b - seg_a;
307 let w0 = ray_origin - seg_a;
308
309 let a = ray_dir.dot(ray_dir); let b = ray_dir.dot(seg_dir);
311 let c = seg_dir.dot(seg_dir);
312 let d = ray_dir.dot(w0);
313 let e = seg_dir.dot(w0);
314
315 let denom = a * c - b * b;
316
317 let (t_ray, t_seg) = if denom.abs() > 1e-8 {
318 let t_r = (b * e - c * d) / denom;
319 let t_s = (a * e - b * d) / denom;
320 (t_r.max(0.0), t_s.clamp(0.0, 1.0))
321 } else {
322 (0.0, 0.0)
324 };
325
326 let closest_ray = ray_origin + ray_dir * t_ray;
327 let closest_seg = seg_a + seg_dir * t_seg;
328 (closest_ray - closest_seg).length()
329}
330
331fn ray_segment_t(
335 ray_origin: glam::Vec3,
336 ray_dir: glam::Vec3,
337 seg_a: glam::Vec3,
338 seg_b: glam::Vec3,
339) -> f32 {
340 let seg_dir = seg_b - seg_a;
341 let w0 = ray_origin - seg_a;
342
343 let a = ray_dir.dot(ray_dir);
344 let b = ray_dir.dot(seg_dir);
345 let c = seg_dir.dot(seg_dir);
346 let d = ray_dir.dot(w0);
347 let e = seg_dir.dot(w0);
348
349 let denom = a * c - b * b;
350 if denom.abs() > 1e-8 {
351 let t_r = (b * e - c * d) / denom;
352 t_r.max(0.0)
353 } else {
354 0.0
355 }
356}
357
358pub use crate::resources::Vertex;
364
365const 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];
376const SCREEN_COLOR_HOV: [f32; 4] = [1.0, 1.0, 1.0, 0.8];
377const PLANE_ALPHA: f32 = 0.3;
378const PLANE_ALPHA_HOV: f32 = 0.5;
379
380fn axis_color(axis: GizmoAxis, hovered: GizmoAxis) -> [f32; 4] {
382 let is_hovered = axis == hovered;
383 match axis {
384 GizmoAxis::X => {
385 if is_hovered {
386 X_COLOR_HOV
387 } else {
388 X_COLOR
389 }
390 }
391 GizmoAxis::Y => {
392 if is_hovered {
393 Y_COLOR_HOV
394 } else {
395 Y_COLOR
396 }
397 }
398 GizmoAxis::Z => {
399 if is_hovered {
400 Z_COLOR_HOV
401 } else {
402 Z_COLOR
403 }
404 }
405 GizmoAxis::Screen => {
406 if is_hovered {
407 SCREEN_COLOR_HOV
408 } else {
409 SCREEN_COLOR
410 }
411 }
412 _ => [1.0; 4],
413 }
414}
415
416fn plane_color(axis: GizmoAxis, hovered: GizmoAxis) -> [f32; 4] {
419 let is_hovered = axis == hovered;
420 let alpha = if is_hovered {
421 PLANE_ALPHA_HOV
422 } else {
423 PLANE_ALPHA
424 };
425 let brightness = if is_hovered { 1.3 } else { 1.0 };
426 let (c1, c2) = match axis {
427 GizmoAxis::XY => (X_COLOR, Y_COLOR),
428 GizmoAxis::XZ => (X_COLOR, Z_COLOR),
429 GizmoAxis::YZ => (Y_COLOR, Z_COLOR),
430 _ => return [1.0, 1.0, 1.0, alpha],
431 };
432 [
433 ((c1[0] + c2[0]) * 0.5 * brightness).min(1.0),
434 ((c1[1] + c2[1]) * 0.5 * brightness).min(1.0),
435 ((c1[2] + c2[2]) * 0.5 * brightness).min(1.0),
436 alpha,
437 ]
438}
439
440pub(crate) fn build_gizmo_mesh(
451 mode: GizmoMode,
452 hovered: GizmoAxis,
453 space_orientation: glam::Quat,
454) -> (Vec<Vertex>, Vec<u32>) {
455 let mut vertices: Vec<Vertex> = Vec::new();
456 let mut indices: Vec<u32> = Vec::new();
457
458 match mode {
459 GizmoMode::Translate => {
460 build_arrows(
461 &mut vertices,
462 &mut indices,
463 hovered,
464 space_orientation,
465 false,
466 );
467 build_plane_quads(&mut vertices, &mut indices, hovered, space_orientation);
468 build_screen_handle(&mut vertices, &mut indices, hovered);
469 }
470 GizmoMode::Rotate => {
471 build_rotation_rings(&mut vertices, &mut indices, hovered, space_orientation);
472 }
473 GizmoMode::Scale => {
474 build_arrows(
475 &mut vertices,
476 &mut indices,
477 hovered,
478 space_orientation,
479 true,
480 );
481 }
482 }
483
484 (vertices, indices)
485}
486
487const SHAFT_RADIUS: f32 = 0.035;
489const SHAFT_LENGTH: f32 = 0.70;
490pub const ROTATION_RING_RADIUS: f32 = 0.85;
492const CONE_RADIUS: f32 = 0.09;
493const CONE_LENGTH: f32 = 0.30;
494const CUBE_HALF: f32 = 0.06;
495const SEGMENTS: u32 = 8;
496
497fn build_arrows(
500 vertices: &mut Vec<Vertex>,
501 indices: &mut Vec<u32>,
502 hovered: GizmoAxis,
503 orientation: glam::Quat,
504 cube_tips: bool,
505) {
506 let base_axes = [
507 (GizmoAxis::X, glam::Vec3::X, glam::Vec3::Y),
508 (GizmoAxis::Y, glam::Vec3::Y, glam::Vec3::X),
509 (GizmoAxis::Z, glam::Vec3::Z, glam::Vec3::Y),
510 ];
511
512 for (axis, raw_dir, raw_up) in &base_axes {
513 let axis_dir = orientation * *raw_dir;
514 let up_hint = orientation * *raw_up;
515 let color = axis_color(*axis, hovered);
516
517 let tangent = if axis_dir.abs().dot(orientation * glam::Vec3::Y) > 0.9 {
518 axis_dir.cross(up_hint).normalize()
519 } else {
520 axis_dir.cross(orientation * glam::Vec3::Y).normalize()
521 };
522 let bitangent = axis_dir.cross(tangent).normalize();
523
524 let base_index = vertices.len() as u32;
525
526 let shaft_bottom = glam::Vec3::ZERO;
528 let shaft_top = axis_dir * SHAFT_LENGTH;
529
530 for i in 0..SEGMENTS {
531 let angle = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
532 let radial = tangent * angle.cos() + bitangent * angle.sin();
533
534 vertices.push(Vertex {
535 position: (shaft_bottom + radial * SHAFT_RADIUS).to_array(),
536 normal: radial.to_array(),
537 color,
538 uv: [0.0, 0.0],
539 tangent: [0.0, 0.0, 0.0, 1.0],
540 });
541 vertices.push(Vertex {
542 position: (shaft_top + radial * SHAFT_RADIUS).to_array(),
543 normal: radial.to_array(),
544 color,
545 uv: [0.0, 0.0],
546 tangent: [0.0, 0.0, 0.0, 1.0],
547 });
548 }
549
550 for i in 0..SEGMENTS {
552 let next = (i + 1) % SEGMENTS;
553 let b0 = base_index + i * 2;
554 let t0 = base_index + i * 2 + 1;
555 let b1 = base_index + next * 2;
556 let t1 = base_index + next * 2 + 1;
557 indices.extend_from_slice(&[b0, b1, t0, t0, b1, t1]);
558 }
559
560 let shaft_bottom_center = vertices.len() as u32;
562 vertices.push(Vertex {
563 position: shaft_bottom.to_array(),
564 normal: (-axis_dir).to_array(),
565 color,
566 uv: [0.0, 0.0],
567 tangent: [0.0, 0.0, 0.0, 1.0],
568 });
569 for i in 0..SEGMENTS {
570 let next = (i + 1) % SEGMENTS;
571 let v0 = base_index + i * 2;
572 let v1 = base_index + next * 2;
573 indices.extend_from_slice(&[shaft_bottom_center, v1, v0]);
574 }
575
576 let tip_base = shaft_top;
578 if cube_tips {
579 build_cube_tip(
580 vertices, indices, tip_base, axis_dir, tangent, bitangent, color,
581 );
582 } else {
583 build_cone_tip(
584 vertices, indices, tip_base, axis_dir, tangent, bitangent, color,
585 );
586 }
587 }
588}
589
590fn build_cone_tip(
592 vertices: &mut Vec<Vertex>,
593 indices: &mut Vec<u32>,
594 base_center: glam::Vec3,
595 axis_dir: glam::Vec3,
596 tangent: glam::Vec3,
597 bitangent: glam::Vec3,
598 color: [f32; 4],
599) {
600 let cone_tip = base_center + axis_dir * CONE_LENGTH;
601 let cone_base_start = vertices.len() as u32;
602
603 for i in 0..SEGMENTS {
605 let angle = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
606 let radial = tangent * angle.cos() + bitangent * angle.sin();
607 vertices.push(Vertex {
608 position: (base_center + radial * CONE_RADIUS).to_array(),
609 normal: (-axis_dir).to_array(),
610 color,
611 uv: [0.0, 0.0],
612 tangent: [0.0, 0.0, 0.0, 1.0],
613 });
614 }
615
616 let base_cap_center = vertices.len() as u32;
618 vertices.push(Vertex {
619 position: base_center.to_array(),
620 normal: (-axis_dir).to_array(),
621 color,
622 uv: [0.0, 0.0],
623 tangent: [0.0, 0.0, 0.0, 1.0],
624 });
625 for i in 0..SEGMENTS {
626 let next = (i + 1) % SEGMENTS;
627 indices.extend_from_slice(&[base_cap_center, cone_base_start + i, cone_base_start + next]);
628 }
629
630 let tip_idx = vertices.len() as u32;
632 vertices.push(Vertex {
633 position: cone_tip.to_array(),
634 normal: axis_dir.to_array(),
635 color,
636 uv: [0.0, 0.0],
637 tangent: [0.0, 0.0, 0.0, 1.0],
638 });
639 for i in 0..SEGMENTS {
640 let next = (i + 1) % SEGMENTS;
641 indices.extend_from_slice(&[cone_base_start + i, cone_base_start + next, tip_idx]);
642 }
643}
644
645fn build_cube_tip(
647 vertices: &mut Vec<Vertex>,
648 indices: &mut Vec<u32>,
649 center: glam::Vec3,
650 axis_dir: glam::Vec3,
651 tangent: glam::Vec3,
652 bitangent: glam::Vec3,
653 color: [f32; 4],
654) {
655 let cube_center = center + axis_dir * CUBE_HALF;
656 let h = CUBE_HALF;
657
658 let corners = [
660 cube_center + axis_dir * (-h) + tangent * (-h) + bitangent * (-h),
661 cube_center + axis_dir * h + tangent * (-h) + bitangent * (-h),
662 cube_center + axis_dir * h + tangent * h + bitangent * (-h),
663 cube_center + axis_dir * (-h) + tangent * h + bitangent * (-h),
664 cube_center + axis_dir * (-h) + tangent * (-h) + bitangent * h,
665 cube_center + axis_dir * h + tangent * (-h) + bitangent * h,
666 cube_center + axis_dir * h + tangent * h + bitangent * h,
667 cube_center + axis_dir * (-h) + tangent * h + bitangent * h,
668 ];
669
670 let faces: [([usize; 4], glam::Vec3); 6] = [
672 ([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), ];
679
680 for (corner_ids, normal) in &faces {
681 let base = vertices.len() as u32;
682 for &ci in corner_ids {
683 vertices.push(Vertex {
684 position: corners[ci].to_array(),
685 normal: normal.to_array(),
686 color,
687 uv: [0.0, 0.0],
688 tangent: [0.0, 0.0, 0.0, 1.0],
689 });
690 }
691 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
692 }
693}
694
695fn build_plane_quads(
697 vertices: &mut Vec<Vertex>,
698 indices: &mut Vec<u32>,
699 hovered: GizmoAxis,
700 orientation: glam::Quat,
701) {
702 let plane_offset = 0.25_f32;
703 let plane_size = 0.15_f32;
704
705 let planes = [
706 (GizmoAxis::XY, glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
707 (GizmoAxis::XZ, glam::Vec3::X, glam::Vec3::Z, glam::Vec3::Y),
708 (GizmoAxis::YZ, glam::Vec3::Y, glam::Vec3::Z, glam::Vec3::X),
709 ];
710
711 for (axis, dir_a, dir_b, normal_dir) in &planes {
712 let a = orientation * *dir_a;
713 let b = orientation * *dir_b;
714 let n = orientation * *normal_dir;
715 let center = a * plane_offset + b * plane_offset;
716 let color = plane_color(*axis, hovered);
717
718 let base = vertices.len() as u32;
719 let corners = [
720 center + a * (-plane_size) + b * (-plane_size),
721 center + a * plane_size + b * (-plane_size),
722 center + a * plane_size + b * plane_size,
723 center + a * (-plane_size) + b * plane_size,
724 ];
725 for c in &corners {
726 vertices.push(Vertex {
727 position: c.to_array(),
728 normal: n.to_array(),
729 color,
730 uv: [0.0, 0.0],
731 tangent: [0.0, 0.0, 0.0, 1.0],
732 });
733 }
734 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
736 indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
737 }
738}
739
740fn build_screen_handle(vertices: &mut Vec<Vertex>, indices: &mut Vec<u32>, hovered: GizmoAxis) {
742 let size = 0.08_f32;
743 let color = axis_color(GizmoAxis::Screen, hovered);
744 let base = vertices.len() as u32;
745
746 let corners = [
749 glam::Vec3::new(-size, -size, 0.0),
750 glam::Vec3::new(size, -size, 0.0),
751 glam::Vec3::new(size, size, 0.0),
752 glam::Vec3::new(-size, size, 0.0),
753 ];
754 for c in &corners {
755 vertices.push(Vertex {
756 position: c.to_array(),
757 normal: [0.0, 0.0, 1.0],
758 color,
759 uv: [0.0, 0.0],
760 tangent: [0.0, 0.0, 0.0, 1.0],
761 });
762 }
763 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
764 indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
765}
766
767fn build_rotation_rings(
769 vertices: &mut Vec<Vertex>,
770 indices: &mut Vec<u32>,
771 hovered: GizmoAxis,
772 orientation: glam::Quat,
773) {
774 let ring_radius = ROTATION_RING_RADIUS; let tube_radius = 0.025_f32; let ring_segments = 40_u32;
777 let tube_segments = 8_u32;
778
779 let axis_data = [
780 (GizmoAxis::X, glam::Vec3::X),
781 (GizmoAxis::Y, glam::Vec3::Y),
782 (GizmoAxis::Z, glam::Vec3::Z),
783 ];
784
785 for (axis, raw_dir) in &axis_data {
786 let axis_dir = orientation * *raw_dir;
787 let color = axis_color(*axis, hovered);
788
789 let (ring_u, ring_v) = perpendicular_pair(axis_dir);
791
792 let base = vertices.len() as u32;
793
794 for i in 0..ring_segments {
795 let theta = (i as f32) * std::f32::consts::TAU / (ring_segments as f32);
796 let cos_t = theta.cos();
797 let sin_t = theta.sin();
798 let ring_center = (ring_u * cos_t + ring_v * sin_t) * ring_radius;
800 let outward = (ring_u * cos_t + ring_v * sin_t).normalize();
802
803 for j in 0..tube_segments {
804 let phi = (j as f32) * std::f32::consts::TAU / (tube_segments as f32);
805 let cos_p = phi.cos();
806 let sin_p = phi.sin();
807 let normal = outward * cos_p + axis_dir * sin_p;
808 let pos = ring_center + normal * tube_radius;
809
810 vertices.push(Vertex {
811 position: pos.to_array(),
812 normal: normal.to_array(),
813 color,
814 uv: [0.0, 0.0],
815 tangent: [0.0, 0.0, 0.0, 1.0],
816 });
817 }
818 }
819
820 for i in 0..ring_segments {
822 let next_i = (i + 1) % ring_segments;
823 for j in 0..tube_segments {
824 let next_j = (j + 1) % tube_segments;
825 let v00 = base + i * tube_segments + j;
826 let v01 = base + i * tube_segments + next_j;
827 let v10 = base + next_i * tube_segments + j;
828 let v11 = base + next_i * tube_segments + next_j;
829 indices.extend_from_slice(&[v00, v10, v01, v01, v10, v11]);
830 }
831 }
832 }
833}
834
835fn perpendicular_pair(axis: glam::Vec3) -> (glam::Vec3, glam::Vec3) {
837 let hint = if axis.dot(glam::Vec3::Y).abs() > 0.9 {
838 glam::Vec3::X
839 } else {
840 glam::Vec3::Y
841 };
842 let u = axis.cross(hint).normalize();
843 let v = axis.cross(u).normalize();
844 (u, v)
845}
846
847pub fn compute_gizmo_scale(
860 gizmo_center_world: glam::Vec3,
861 camera_eye: glam::Vec3,
862 fov_y: f32,
863 viewport_height: f32,
864) -> f32 {
865 let dist = (gizmo_center_world - camera_eye).length();
866 let world_per_px = 2.0 * (fov_y * 0.5).tan() * dist / viewport_height;
868 let target_px = 100.0_f32;
870 world_per_px * target_px
871}
872
873#[repr(C)]
879#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
880pub(crate) struct GizmoUniform {
881 pub(crate) model: [[f32; 4]; 4],
883}
884
885pub fn project_drag_onto_axis(
900 drag_delta: glam::Vec2,
901 axis_world: glam::Vec3,
902 view_proj: glam::Mat4,
903 gizmo_center: glam::Vec3,
904 viewport_size: glam::Vec2,
905) -> f32 {
906 let base_ndc = view_proj.project_point3(gizmo_center);
908 let tip_ndc = view_proj.project_point3(gizmo_center + axis_world);
909
910 let base_screen = glam::Vec2::new(
912 (base_ndc.x + 1.0) * 0.5 * viewport_size.x,
913 (1.0 - base_ndc.y) * 0.5 * viewport_size.y,
914 );
915 let tip_screen = glam::Vec2::new(
916 (tip_ndc.x + 1.0) * 0.5 * viewport_size.x,
917 (1.0 - tip_ndc.y) * 0.5 * viewport_size.y,
918 );
919
920 let axis_screen = tip_screen - base_screen;
921 let axis_screen_len = axis_screen.length();
922
923 if axis_screen_len < 1e-4 {
924 return 0.0;
925 }
926
927 let axis_screen_norm = axis_screen / axis_screen_len;
929 let drag_along_axis = drag_delta.dot(axis_screen_norm);
930
931 drag_along_axis / axis_screen_len
934}
935
936pub fn project_drag_onto_rotation(
941 drag_delta: glam::Vec2,
942 axis_world: glam::Vec3,
943 view: glam::Mat4,
944) -> f32 {
945 let axis_cam = (view * axis_world.extend(0.0))
948 .truncate()
949 .normalize_or_zero();
950
951 let perp = glam::Vec2::new(-axis_cam.y, axis_cam.x);
953 let perp_len = perp.length();
954 if perp_len < 1e-4 {
955 return 0.0;
956 }
957
958 let perp_norm = perp / perp_len;
960 let drag_amount = drag_delta.dot(perp_norm);
961
962 drag_amount * 0.02
964}
965
966pub fn project_drag_onto_plane(
970 drag_delta: glam::Vec2,
971 axis_a: glam::Vec3,
972 axis_b: glam::Vec3,
973 view_proj: glam::Mat4,
974 gizmo_center: glam::Vec3,
975 viewport_size: glam::Vec2,
976) -> glam::Vec3 {
977 let a_amount =
978 project_drag_onto_axis(drag_delta, axis_a, view_proj, gizmo_center, viewport_size);
979 let b_amount =
980 project_drag_onto_axis(drag_delta, axis_b, view_proj, gizmo_center, viewport_size);
981 axis_a * a_amount + axis_b * b_amount
982}
983
984pub fn project_drag_onto_screen_plane(
988 drag_delta: glam::Vec2,
989 camera_right: glam::Vec3,
990 camera_up: glam::Vec3,
991 view_proj: glam::Mat4,
992 gizmo_center: glam::Vec3,
993 viewport_size: glam::Vec2,
994) -> glam::Vec3 {
995 project_drag_onto_plane(
996 drag_delta,
997 camera_right,
998 camera_up,
999 view_proj,
1000 gizmo_center,
1001 viewport_size,
1002 )
1003}
1004
1005pub fn gizmo_center_from_selection(
1009 selection: &crate::interaction::selection::Selection,
1010 position_fn: impl Fn(crate::interaction::selection::NodeId) -> Option<glam::Vec3>,
1011) -> Option<glam::Vec3> {
1012 selection.centroid(position_fn)
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018
1019 fn gizmo() -> Gizmo {
1020 Gizmo::new()
1021 }
1022
1023 #[test]
1024 fn test_hit_test_x_axis() {
1025 let g = gizmo();
1026 let center = glam::Vec3::ZERO;
1027 let scale = 1.0;
1028 let axis = g.hit_test(
1029 glam::Vec3::new(0.5, 0.5, 0.0),
1030 glam::Vec3::new(0.0, -1.0, 0.0),
1031 center,
1032 scale,
1033 );
1034 assert_eq!(axis, GizmoAxis::X);
1035 }
1036
1037 #[test]
1038 fn test_hit_test_y_axis() {
1039 let g = gizmo();
1040 let center = glam::Vec3::ZERO;
1041 let scale = 1.0;
1042 let axis = g.hit_test(
1043 glam::Vec3::new(0.5, 0.5, 0.0),
1044 glam::Vec3::new(-1.0, 0.0, 0.0),
1045 center,
1046 scale,
1047 );
1048 assert_eq!(axis, GizmoAxis::Y);
1049 }
1050
1051 #[test]
1052 fn test_hit_test_z_axis() {
1053 let g = gizmo();
1054 let center = glam::Vec3::ZERO;
1055 let scale = 1.0;
1056 let axis = g.hit_test(
1057 glam::Vec3::new(0.0, 0.5, 0.5),
1058 glam::Vec3::new(0.0, -1.0, 0.0),
1059 center,
1060 scale,
1061 );
1062 assert_eq!(axis, GizmoAxis::Z);
1063 }
1064
1065 #[test]
1066 fn test_hit_test_miss() {
1067 let g = gizmo();
1068 let center = glam::Vec3::ZERO;
1069 let scale = 1.0;
1070 let axis = g.hit_test(
1071 glam::Vec3::new(10.0, 10.0, 10.0),
1072 glam::Vec3::new(0.0, 0.0, -1.0),
1073 center,
1074 scale,
1075 );
1076 assert_eq!(axis, GizmoAxis::None);
1077 }
1078
1079 #[test]
1080 fn test_hit_test_plane_handle_xy() {
1081 let g = gizmo();
1082 let center = glam::Vec3::ZERO;
1083 let scale = 1.0;
1084 let axis = g.hit_test_oriented(
1086 glam::Vec3::new(0.25, 0.25, 5.0),
1087 glam::Vec3::new(0.0, 0.0, -1.0),
1088 center,
1089 scale,
1090 glam::Quat::IDENTITY,
1091 );
1092 assert_eq!(axis, GizmoAxis::XY, "expected XY plane handle hit");
1093 }
1094
1095 #[test]
1096 fn test_hit_test_local_orientation() {
1097 let mut g = gizmo();
1098 g.space = GizmoSpace::Local;
1099 let center = glam::Vec3::ZERO;
1100 let scale = 1.0;
1101 let rot = glam::Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
1103
1104 let axis = g.hit_test_oriented(
1107 glam::Vec3::new(0.0, 0.5, -0.5),
1108 glam::Vec3::new(0.0, -1.0, 0.0),
1109 center,
1110 scale,
1111 rot,
1112 );
1113 assert_eq!(
1114 axis,
1115 GizmoAxis::X,
1116 "local X axis should be along world -Z after 90° Y rotation"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_project_drag_onto_axis() {
1122 let view = glam::Mat4::look_at_rh(
1123 glam::Vec3::new(0.0, 0.0, 5.0),
1124 glam::Vec3::ZERO,
1125 glam::Vec3::Y,
1126 );
1127 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1128 let vp = proj * view;
1129 let viewport_size = glam::Vec2::new(800.0, 600.0);
1130 let center = glam::Vec3::ZERO;
1131
1132 let result = project_drag_onto_axis(
1133 glam::Vec2::new(100.0, 0.0),
1134 glam::Vec3::X,
1135 vp,
1136 center,
1137 viewport_size,
1138 );
1139 assert!(result > 0.0, "expected positive drag along X, got {result}");
1140 }
1141
1142 #[test]
1143 fn test_project_drag_onto_plane() {
1144 let view = glam::Mat4::look_at_rh(
1145 glam::Vec3::new(0.0, 5.0, 5.0),
1146 glam::Vec3::ZERO,
1147 glam::Vec3::Y,
1148 );
1149 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1150 let vp = proj * view;
1151 let viewport_size = glam::Vec2::new(800.0, 600.0);
1152 let center = glam::Vec3::ZERO;
1153
1154 let result = project_drag_onto_plane(
1155 glam::Vec2::new(100.0, 0.0),
1156 glam::Vec3::X,
1157 glam::Vec3::Z,
1158 vp,
1159 center,
1160 viewport_size,
1161 );
1162 assert!(
1164 result.length() > 0.0,
1165 "plane drag should produce non-zero displacement"
1166 );
1167 assert!(
1168 result.y.abs() < 1e-4,
1169 "XZ plane drag should have no Y component"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_screen_handle_hit() {
1175 let g = gizmo();
1176 let center = glam::Vec3::ZERO;
1177 let scale = 1.0;
1178 let axis = g.hit_test(
1180 glam::Vec3::new(0.0, 0.0, 5.0),
1181 glam::Vec3::new(0.0, 0.0, -1.0),
1182 center,
1183 scale,
1184 );
1185 assert_eq!(
1186 axis,
1187 GizmoAxis::Screen,
1188 "ray at center should hit Screen handle"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_build_mesh_translate_has_plane_quads() {
1194 let (verts, idxs) =
1195 build_gizmo_mesh(GizmoMode::Translate, GizmoAxis::None, glam::Quat::IDENTITY);
1196 assert!(
1198 verts.len() > 80,
1199 "translate mesh should have significant vertex count, got {}",
1200 verts.len()
1201 );
1202 assert!(!idxs.is_empty());
1203 }
1204
1205 #[test]
1206 fn test_build_mesh_rotate_produces_rings() {
1207 let (verts, _) = build_gizmo_mesh(GizmoMode::Rotate, GizmoAxis::None, glam::Quat::IDENTITY);
1208 assert!(
1210 verts.len() >= 960,
1211 "rotate mesh should have ring vertices, got {}",
1212 verts.len()
1213 );
1214 }
1215
1216 #[test]
1217 fn test_build_mesh_scale_has_cubes() {
1218 let (verts_translate, _) =
1219 build_gizmo_mesh(GizmoMode::Translate, GizmoAxis::None, glam::Quat::IDENTITY);
1220 let (verts_scale, _) =
1221 build_gizmo_mesh(GizmoMode::Scale, GizmoAxis::None, glam::Quat::IDENTITY);
1222 assert!(
1225 verts_scale.len() > 50,
1226 "scale mesh should have geometry, got {}",
1227 verts_scale.len()
1228 );
1229 assert_ne!(
1230 verts_translate.len(),
1231 verts_scale.len(),
1232 "translate and scale should have different vertex counts (cone vs cube tips)"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_compute_gizmo_scale() {
1238 let scale = compute_gizmo_scale(
1239 glam::Vec3::ZERO,
1240 glam::Vec3::new(0.0, 0.0, 10.0),
1241 std::f32::consts::FRAC_PI_4,
1242 600.0,
1243 );
1244 assert!(scale > 0.0, "gizmo scale should be positive");
1245 assert!((scale - 1.381).abs() < 0.1, "unexpected scale: {scale}");
1246 }
1247
1248 #[test]
1249 fn test_gizmo_center_single_selection() {
1250 let mut sel = crate::interaction::selection::Selection::new();
1251 sel.select_one(1);
1252 let center = gizmo_center_from_selection(&sel, |id| match id {
1253 1 => Some(glam::Vec3::new(3.0, 0.0, 0.0)),
1254 _ => None,
1255 });
1256 let c = center.unwrap();
1257 assert!((c.x - 3.0).abs() < 1e-5);
1258 }
1259
1260 #[test]
1261 fn test_gizmo_center_multi_selection() {
1262 let mut sel = crate::interaction::selection::Selection::new();
1263 sel.add(1);
1264 sel.add(2);
1265 let center = gizmo_center_from_selection(&sel, |id| match id {
1266 1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1267 2 => Some(glam::Vec3::new(4.0, 0.0, 0.0)),
1268 _ => None,
1269 });
1270 let c = center.unwrap();
1271 assert!((c.x - 2.0).abs() < 1e-5);
1272 }
1273
1274 #[test]
1277 fn test_pivot_selection_centroid_matches_centroid() {
1278 let mut sel = crate::interaction::selection::Selection::new();
1279 sel.add(1);
1280 sel.add(2);
1281 let pos_fn = |id: crate::interaction::selection::NodeId| match id {
1282 1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1283 2 => Some(glam::Vec3::new(4.0, 0.0, 0.0)),
1284 _ => None,
1285 };
1286 let centroid = gizmo_center_from_selection(&sel, pos_fn);
1287 let pivot = gizmo_center_for_pivot(&PivotMode::SelectionCentroid, &sel, pos_fn);
1288 assert_eq!(centroid, pivot);
1289 }
1290
1291 #[test]
1292 fn test_pivot_world_origin_returns_zero() {
1293 let mut sel = crate::interaction::selection::Selection::new();
1294 sel.add(1);
1295 let result = gizmo_center_for_pivot(&PivotMode::WorldOrigin, &sel, |_| {
1296 Some(glam::Vec3::new(5.0, 0.0, 0.0))
1297 });
1298 assert_eq!(result, Some(glam::Vec3::ZERO));
1299 }
1300
1301 #[test]
1302 fn test_pivot_world_origin_empty_selection_returns_none() {
1303 let sel = crate::interaction::selection::Selection::new();
1304 let result = gizmo_center_for_pivot(&PivotMode::WorldOrigin, &sel, |_| None);
1305 assert_eq!(result, None);
1306 }
1307
1308 #[test]
1309 fn test_pivot_individual_origins_uses_primary() {
1310 let mut sel = crate::interaction::selection::Selection::new();
1311 sel.add(1);
1312 sel.add(2); let result = gizmo_center_for_pivot(&PivotMode::IndividualOrigins, &sel, |id| match id {
1314 1 => Some(glam::Vec3::new(1.0, 0.0, 0.0)),
1315 2 => Some(glam::Vec3::new(9.0, 0.0, 0.0)),
1316 _ => None,
1317 });
1318 let c = result.unwrap();
1319 assert!(
1320 (c.x - 9.0).abs() < 1e-5,
1321 "expected primary (node 2) position x=9, got {}",
1322 c.x
1323 );
1324 }
1325
1326 #[test]
1327 fn test_pivot_median_point_same_as_centroid() {
1328 let mut sel = crate::interaction::selection::Selection::new();
1329 sel.add(1);
1330 sel.add(2);
1331 let pos_fn = |id: crate::interaction::selection::NodeId| match id {
1332 1 => Some(glam::Vec3::new(0.0, 0.0, 0.0)),
1333 2 => Some(glam::Vec3::new(6.0, 0.0, 0.0)),
1334 _ => None,
1335 };
1336 let result = gizmo_center_for_pivot(&PivotMode::MedianPoint, &sel, pos_fn);
1337 let c = result.unwrap();
1338 assert!((c.x - 3.0).abs() < 1e-5);
1339 }
1340
1341 #[test]
1342 fn test_pivot_cursor3d_returns_cursor_pos() {
1343 let mut sel = crate::interaction::selection::Selection::new();
1344 sel.add(1);
1345 let cursor = glam::Vec3::new(7.0, 2.0, 3.0);
1346 let result = gizmo_center_for_pivot(&PivotMode::Cursor3D(cursor), &sel, |_| {
1347 Some(glam::Vec3::ZERO)
1348 });
1349 assert_eq!(result, Some(cursor));
1350 }
1351
1352 #[test]
1353 fn test_pivot_cursor3d_empty_selection_returns_none() {
1354 let sel = crate::interaction::selection::Selection::new();
1355 let cursor = glam::Vec3::new(1.0, 2.0, 3.0);
1356 let result = gizmo_center_for_pivot(&PivotMode::Cursor3D(cursor), &sel, |_| None);
1357 assert_eq!(result, None);
1358 }
1359
1360 #[test]
1361 fn test_gizmo_pivot_mode_field_defaults_to_selection_centroid() {
1362 let g = Gizmo::new();
1363 assert!(matches!(g.pivot_mode, PivotMode::SelectionCentroid));
1364 }
1365}