1use serde_json::Value;
19
20use crate::engine_state::rotate_euler_xyz_f64;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum FeatureDimKind {
26 Linear,
28 Angular,
32}
33
34#[derive(Clone, Debug)]
47pub struct FeatureDimAnnotation {
48 pub field_key: String,
50 pub point_a: [f64; 3],
53 pub point_b: [f64; 3],
56 pub value: f64,
59 pub label: String,
61 pub kind: FeatureDimKind,
63 pub center: [f64; 3],
66 pub axis: [f64; 3],
72 pub ref_dir: [f64; 3],
75}
76
77impl FeatureDimAnnotation {
78 pub(crate) fn linear(field_key: &str, a: [f64; 3], b: [f64; 3], value: f64, label: &str) -> Self {
83 Self {
84 field_key: field_key.to_string(),
85 point_a: a,
86 point_b: b,
87 value,
88 label: label.to_string(),
89 kind: FeatureDimKind::Linear,
90 center: [0.0; 3],
91 axis: [0.0; 3],
92 ref_dir: [0.0; 3],
93 }
94 }
95
96 pub(crate) fn angular(
105 field_key: &str,
106 center: [f64; 3],
107 axis: [f64; 3],
108 ref_dir: [f64; 3],
109 value: f64,
110 label: &str,
111 ) -> Self {
112 let axis = normalize_or(axis, [0.0, 1.0, 0.0]);
113 let d = dot3(ref_dir, axis);
115 let planar = [
116 ref_dir[0] - axis[0] * d,
117 ref_dir[1] - axis[1] * d,
118 ref_dir[2] - axis[2] * d,
119 ];
120 let ref_dir = if norm3(planar) <= 1e-9 {
121 arbitrary_perpendicular(axis)
122 } else {
123 normalize_or(planar, arbitrary_perpendicular(axis))
124 };
125 Self {
126 field_key: field_key.to_string(),
127 point_a: center,
128 point_b: center,
129 value,
130 label: label.to_string(),
131 kind: FeatureDimKind::Angular,
132 center,
133 axis,
134 ref_dir,
135 }
136 }
137
138 pub fn midpoint(&self) -> [f64; 3] {
142 [
143 (self.point_a[0] + self.point_b[0]) * 0.5,
144 (self.point_a[1] + self.point_b[1]) * 0.5,
145 (self.point_a[2] + self.point_b[2]) * 0.5,
146 ]
147 }
148}
149
150#[derive(Clone, Debug, Default)]
158pub struct ResolvedRefs {
159 pub profile_center: Option<[f64; 3]>,
163 pub profile_normal: Option<[f64; 3]>,
167 pub axis_point: Option<[f64; 3]>,
169 pub axis_dir: Option<[f64; 3]>,
172 pub plane_origin: Option<[f64; 3]>,
175 pub plane_normal: Option<[f64; 3]>,
178 pub plane_dim_length: Option<f64>,
182}
183
184pub fn build_annotations(feature_type: &str, input_params: &Value) -> Vec<FeatureDimAnnotation> {
193 build_annotations_with_refs(feature_type, input_params, &ResolvedRefs::default())
194}
195
196pub fn build_annotations_with_refs(
203 feature_type: &str,
204 input_params: &Value,
205 resolved: &ResolvedRefs,
206) -> Vec<FeatureDimAnnotation> {
207 let transform = input_params.get("transform");
208 match feature_type {
209 "P.CU" => build_cube(input_params, transform),
210 "P.CY" => build_cylinder(input_params, transform),
211 "P.CO" => build_cone(input_params, transform),
212 "P.S" | "P.SP" => build_sphere(input_params, transform),
214 "P.PY" => build_pyramid(input_params, transform),
215 "P.T" => build_torus(input_params, transform),
216 "E" => build_extrude(input_params, resolved),
217 "R" => build_revolve(input_params, resolved),
218 "P" => build_plane(input_params, resolved),
219 _ => Vec::new(),
220 }
221}
222
223fn build_cube(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
227 let sx = resolve_number(params, "sizeX");
228 let sy = resolve_number(params, "sizeY");
229 let sz = resolve_number(params, "sizeZ");
230 let p0 = transform_point(transform, [0.0, 0.0, 0.0]);
231 let px = transform_point(transform, [sx, 0.0, 0.0]);
232 let py = transform_point(transform, [0.0, sy, 0.0]);
233 let pz = transform_point(transform, [0.0, 0.0, sz]);
234 vec![
235 FeatureDimAnnotation::linear("sizeX", p0, px, sx, "X"),
236 FeatureDimAnnotation::linear("sizeY", p0, py, sy, "Y"),
237 FeatureDimAnnotation::linear("sizeZ", p0, pz, sz, "Z"),
238 ]
239}
240
241fn build_cylinder(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
244 let radius = resolve_number(params, "radius");
245 let height = resolve_number(params, "height");
246 let base = transform_point(transform, [0.0, 0.0, 0.0]);
247 let top = transform_point(transform, [0.0, height, 0.0]);
248 let radial = transform_point(transform, [radius, 0.0, 0.0]);
249 vec![
250 FeatureDimAnnotation::linear("radius", base, radial, radius, "R"),
251 FeatureDimAnnotation::linear("height", base, top, height, "H"),
252 ]
253}
254
255fn build_cone(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
258 let radius_top = resolve_number(params, "radiusTop");
259 let radius_bottom = resolve_number(params, "radiusBottom");
260 let height = resolve_number(params, "height");
261 let base_center = transform_point(transform, [0.0, 0.0, 0.0]);
262 let top_center = transform_point(transform, [0.0, height, 0.0]);
263 let base_radius = transform_point(transform, [radius_bottom, 0.0, 0.0]);
264 let top_radius = transform_point(transform, [radius_top, height, 0.0]);
265 vec![
266 FeatureDimAnnotation::linear("radiusBottom", base_center, base_radius, radius_bottom, "Rb"),
267 FeatureDimAnnotation::linear("radiusTop", top_center, top_radius, radius_top, "Rt"),
268 FeatureDimAnnotation::linear("height", base_center, top_center, height, "H"),
269 ]
270}
271
272fn build_sphere(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
274 let radius = resolve_number(params, "radius");
275 let center = transform_point(transform, [0.0, 0.0, 0.0]);
276 let radial = transform_point(transform, [radius, 0.0, 0.0]);
277 vec![FeatureDimAnnotation::linear("radius", center, radial, radius, "R")]
278}
279
280fn build_pyramid(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
284 let side = resolve_number(params, "baseSideLength");
285 let height = resolve_number(params, "height");
286 let half_side = side * 0.5;
287 let base_y = -height * 0.5;
288 let apex_y = height * 0.5;
289 let base_start = transform_point(transform, [-half_side, base_y, -half_side]);
290 let base_end = transform_point(transform, [half_side, base_y, -half_side]);
291 let base_center = transform_point(transform, [0.0, base_y, 0.0]);
292 let apex = transform_point(transform, [0.0, apex_y, 0.0]);
293 vec![
294 FeatureDimAnnotation::linear("baseSideLength", base_start, base_end, side, "Side"),
295 FeatureDimAnnotation::linear("height", base_center, apex, height, "H"),
296 ]
297}
298
299fn build_torus(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
306 let major = resolve_number(params, "majorRadius");
307 let tube = resolve_number(params, "tubeRadius");
308 let arc = clamp_deg(resolve_number(params, "arc"));
309
310 let center = transform_point(transform, [0.0, 0.0, 0.0]);
311 let major_point = transform_point(transform, [major, 0.0, 0.0]);
312 let tube_point = transform_point(transform, [major + tube, 0.0, 0.0]);
313 let axis = normalize_or(sub3(transform_point(transform, [0.0, 1.0, 0.0]), center), [0.0, 1.0, 0.0]);
317 let start_dir = sub3(major_point, center);
318
319 vec![
320 FeatureDimAnnotation::linear("majorRadius", center, major_point, major, "R"),
321 FeatureDimAnnotation::linear("tubeRadius", major_point, tube_point, tube, "r"),
322 FeatureDimAnnotation::angular("arc", center, axis, start_dir, arc, "Arc"),
323 ]
324}
325
326fn build_extrude(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
332 let (Some(center), Some(normal)) = (resolved.profile_center, resolved.profile_normal) else {
333 return Vec::new();
334 };
335 let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
336 let distance = resolve_number(params, "distance");
337 let back = resolve_number(params, "distanceBack");
338 let forward = [
339 center[0] + normal[0] * distance,
340 center[1] + normal[1] * distance,
341 center[2] + normal[2] * distance,
342 ];
343 let backward = [
344 center[0] - normal[0] * back,
345 center[1] - normal[1] * back,
346 center[2] - normal[2] * back,
347 ];
348 vec![
349 FeatureDimAnnotation::linear("distance", center, forward, distance, "D"),
350 FeatureDimAnnotation::linear("distanceBack", center, backward, back, "Db"),
351 ]
352}
353
354fn build_plane(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
363 let (Some(origin), Some(normal)) = (resolved.plane_origin, resolved.plane_normal) else {
364 return Vec::new();
365 };
366 let offset = resolve_number(params, "offset_distance");
367 let base = [
369 origin[0] - normal[0] * offset,
370 origin[1] - normal[1] * offset,
371 origin[2] - normal[2] * offset,
372 ];
373 let extent = if offset.abs() > 1e-6 {
376 offset
377 } else {
378 resolved.plane_dim_length.unwrap_or(1.0)
379 };
380 let handle = [
381 base[0] + normal[0] * extent,
382 base[1] + normal[1] * extent,
383 base[2] + normal[2] * extent,
384 ];
385 vec![FeatureDimAnnotation::linear(
386 "offset_distance",
387 base,
388 handle,
389 offset,
390 "Offset",
391 )]
392}
393
394fn build_revolve(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
401 let (Some(axis_point), Some(axis_dir)) = (resolved.axis_point, resolved.axis_dir) else {
402 return Vec::new();
403 };
404 let Some(profile_center) = resolved.profile_center else {
405 return Vec::new();
406 };
407 let axis = orient_revolve_axis(axis_dir, axis_point, profile_center, resolved.profile_normal);
408 let vertex = closest_point_on_line(profile_center, axis_point, axis);
409 let start_dir = sub3(profile_center, vertex);
412 let angle = clamp_deg(resolve_number(params, "angle"));
413 vec![FeatureDimAnnotation::angular("angle", vertex, axis, start_dir, angle, "A")]
414}
415
416pub(crate) fn orient_revolve_axis(
420 axis_dir: [f64; 3],
421 axis_point: [f64; 3],
422 profile_center: [f64; 3],
423 profile_normal: Option<[f64; 3]>,
424) -> [f64; 3] {
425 let axis = normalize_or(axis_dir, [0.0, 1.0, 0.0]);
426 let Some(normal) = profile_normal else {
427 return axis;
428 };
429 if norm3(normal) <= 1e-12 {
430 return axis;
431 }
432 let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
433 let mut radial = sub3(profile_center, axis_point);
435 let d = dot3(radial, axis);
436 radial = [radial[0] - axis[0] * d, radial[1] - axis[1] * d, radial[2] - axis[2] * d];
437 if norm3(radial) <= 1e-12 {
438 return axis;
439 }
440 let c = cross3(axis, radial);
441 if dot3(c, normal) < 0.0 {
442 [-axis[0], -axis[1], -axis[2]]
443 } else {
444 axis
445 }
446}
447
448pub(crate) fn closest_point_on_line(
450 point: [f64; 3],
451 line_point: [f64; 3],
452 line_dir: [f64; 3],
453) -> [f64; 3] {
454 let dir = normalize_or(line_dir, [0.0, 1.0, 0.0]);
455 let t = dot3(sub3(point, line_point), dir);
456 [
457 line_point[0] + dir[0] * t,
458 line_point[1] + dir[1] * t,
459 line_point[2] + dir[2] * t,
460 ]
461}
462
463fn clamp_deg(v: f64) -> f64 {
465 v.clamp(-360.0, 360.0)
466}
467
468pub(crate) fn transform_point(transform: Option<&Value>, local: [f64; 3]) -> [f64; 3] {
473 let position = read_vec3(transform, "position", [0.0, 0.0, 0.0]);
474 let rotation_deg = read_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]);
475 let scale = read_vec3(transform, "scale", [1.0, 1.0, 1.0]);
476 let scaled = [local[0] * scale[0], local[1] * scale[1], local[2] * scale[2]];
477 let euler = [
478 rotation_deg[0].to_radians(),
479 rotation_deg[1].to_radians(),
480 rotation_deg[2].to_radians(),
481 ];
482 let rotated = rotate_euler_xyz_f64(scaled, euler);
483 [
484 rotated[0] + position[0],
485 rotated[1] + position[1],
486 rotated[2] + position[2],
487 ]
488}
489
490fn read_vec3(transform: Option<&Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
493 let array = transform.and_then(|t| t.get(key)).and_then(Value::as_array);
494 let mut out = default;
495 if let Some(array) = array {
496 for (index, slot) in out.iter_mut().enumerate() {
497 if let Some(number) = array.get(index).and_then(Value::as_f64) {
498 *slot = number;
499 }
500 }
501 }
502 out
503}
504
505fn resolve_number(params: &Value, key: &str) -> f64 {
510 match params.get(key) {
511 Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).unwrap_or(0.0),
512 Some(Value::String(s)) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
513 _ => 0.0,
514 }
515}
516
517const SHAFT_RGB: [f32; 3] = [0.80, 0.81, 0.82];
530const ORANGE_RGB: [f32; 3] = [0.961, 0.651, 0.137];
532const RED_RGB: [f32; 3] = [0.902, 0.157, 0.157];
534const GREEN_RGB: [f32; 3] = [0.204, 0.808, 0.267];
536
537const SHAFT_RAD_PX: f64 = 2.2;
539const CONE_LEN_PX: f64 = 16.0;
541const CONE_RAD_PX: f64 = 6.0;
543pub(crate) const ORIGIN_SPHERE_RAD_PX: f64 = 7.0;
547
548pub const ANGLE_ARC_RAD_PX: f64 = 120.0;
552const ANGLE_RAY_RAD_PX: f64 = 1.6;
554const ARC_DEG_PER_SEG: f64 = 4.0;
556const DASH_LEN_PX: f64 = 6.0;
558const DASH_GAP_PX: f64 = 5.0;
559
560const TUBE_SEGMENTS: usize = 8;
561const CONE_SEGMENTS: usize = 16;
562const SPHERE_RINGS: usize = 6;
563const SPHERE_SECTORS: usize = 10;
564
565pub fn leaders_buffers(
572 annotations: &[FeatureDimAnnotation],
573 world_per_pixel: f64,
574) -> (Vec<f32>, Vec<f32>) {
575 let mut tb = TriBuf::default();
576 let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
577 let cone_len = CONE_LEN_PX * world_per_pixel;
578 let cone_rad = CONE_RAD_PX * world_per_pixel;
579 let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
580
581 let mut origins: Vec<[f64; 3]> = Vec::new();
583 let mut add_origin = |tb: &mut TriBuf, a: [f64; 3]| {
584 if !origins.iter().any(|o| norm3(sub3(*o, a)) < 1e-6) {
585 push_sphere(tb, a, sphere_rad, ORANGE_RGB);
586 origins.push(a);
587 }
588 };
589
590 for ann in annotations {
591 match ann.kind {
592 FeatureDimKind::Angular => {
593 add_origin(&mut tb, ann.center);
600 push_angle_gizmo(&mut tb, ann, world_per_pixel);
601 }
602 FeatureDimKind::Linear => {
603 let a = ann.point_a;
604 let b = ann.point_b;
605 let axis = sub3(b, a);
606 let len = norm3(axis);
607 add_origin(&mut tb, a);
608 if len < 1e-9 {
609 continue;
610 }
611 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
612 let cl = cone_len.min(len * 0.9);
614 let shaft_end = [b[0] - dir[0] * cl, b[1] - dir[1] * cl, b[2] - dir[2] * cl];
615 push_tube(&mut tb, a, shaft_end, shaft_rad, SHAFT_RGB);
616 push_cone(&mut tb, shaft_end, b, cone_rad, ORANGE_RGB);
617 }
618 }
619 }
620 (tb.positions, tb.colors)
621}
622
623pub fn append_plain_leader(
630 positions: &mut Vec<f32>,
631 colors: &mut Vec<f32>,
632 a: [f64; 3],
633 b: [f64; 3],
634 world_per_pixel: f64,
635) {
636 let mut tb = TriBuf {
637 positions: std::mem::take(positions),
638 colors: std::mem::take(colors),
639 };
640 push_tube(&mut tb, a, b, ANGLE_RAY_RAD_PX * world_per_pixel, SHAFT_RGB);
641 *positions = tb.positions;
642 *colors = tb.colors;
643}
644
645#[derive(Default)]
647struct TriBuf {
648 positions: Vec<f32>,
649 colors: Vec<f32>,
650}
651
652impl TriBuf {
653 fn tri(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], rgb: [f32; 3]) {
654 for p in [a, b, c] {
655 self.positions
656 .extend_from_slice(&[p[0] as f32, p[1] as f32, p[2] as f32]);
657 self.colors.extend_from_slice(&rgb);
658 }
659 }
660}
661
662fn push_tube(tb: &mut TriBuf, a: [f64; 3], b: [f64; 3], radius: f64, rgb: [f32; 3]) {
664 let axis = sub3(b, a);
665 let len = norm3(axis);
666 if len < 1e-9 || radius <= 0.0 {
667 return;
668 }
669 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
670 let (u, v) = axis_basis(dir);
671 let ring = |center: [f64; 3], k: usize| -> [f64; 3] {
672 let ang = (k as f64 / TUBE_SEGMENTS as f64) * std::f64::consts::TAU;
673 let (c, s) = (ang.cos() * radius, ang.sin() * radius);
674 [
675 center[0] + u[0] * c + v[0] * s,
676 center[1] + u[1] * c + v[1] * s,
677 center[2] + u[2] * c + v[2] * s,
678 ]
679 };
680 for k in 0..TUBE_SEGMENTS {
681 let a0 = ring(a, k);
682 let a1 = ring(a, k + 1);
683 let b0 = ring(b, k);
684 let b1 = ring(b, k + 1);
685 tb.tri(a0, b0, b1, rgb);
686 tb.tri(a0, b1, a1, rgb);
687 }
688}
689
690fn push_cone(tb: &mut TriBuf, base: [f64; 3], tip: [f64; 3], radius: f64, rgb: [f32; 3]) {
693 let axis = sub3(tip, base);
694 let len = norm3(axis);
695 if len < 1e-9 || radius <= 0.0 {
696 return;
697 }
698 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
699 let (u, v) = axis_basis(dir);
700 let ring = |k: usize| -> [f64; 3] {
701 let ang = (k as f64 / CONE_SEGMENTS as f64) * std::f64::consts::TAU;
702 let (c, s) = (ang.cos() * radius, ang.sin() * radius);
703 [
704 base[0] + u[0] * c + v[0] * s,
705 base[1] + u[1] * c + v[1] * s,
706 base[2] + u[2] * c + v[2] * s,
707 ]
708 };
709 let mut prev = ring(0);
710 for k in 1..=CONE_SEGMENTS {
711 let cur = ring(k);
712 tb.tri(tip, prev, cur, rgb); tb.tri(base, cur, prev, rgb); prev = cur;
715 }
716}
717
718fn push_sphere(tb: &mut TriBuf, center: [f64; 3], radius: f64, rgb: [f32; 3]) {
720 if radius <= 0.0 {
721 return;
722 }
723 let point = |ring: usize, sector: usize| -> [f64; 3] {
724 let lat = std::f64::consts::PI * (ring as f64 / SPHERE_RINGS as f64)
725 - std::f64::consts::FRAC_PI_2;
726 let lon = std::f64::consts::TAU * (sector as f64 / SPHERE_SECTORS as f64);
727 [
728 center[0] + lat.cos() * lon.cos() * radius,
729 center[1] + lat.cos() * lon.sin() * radius,
730 center[2] + lat.sin() * radius,
731 ]
732 };
733 for r in 0..SPHERE_RINGS {
734 for sct in 0..SPHERE_SECTORS {
735 let p00 = point(r, sct);
736 let p01 = point(r, sct + 1);
737 let p10 = point(r + 1, sct);
738 let p11 = point(r + 1, sct + 1);
739 tb.tri(p00, p10, p11, rgb);
740 tb.tri(p00, p11, p01, rgb);
741 }
742 }
743}
744
745fn push_angle_gizmo(tb: &mut TriBuf, ann: &FeatureDimAnnotation, world_per_pixel: f64) {
752 let center = ann.center;
753 let axis = ann.axis;
754 let start = ann.ref_dir;
755 let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
756 let ray_rad = ANGLE_RAY_RAD_PX * world_per_pixel;
757 let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
758 let cone_len = CONE_LEN_PX * world_per_pixel;
759 let cone_rad = CONE_RAD_PX * world_per_pixel;
760 let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
761 if radius <= 1e-9 {
762 return;
763 }
764 let value = ann.value.clamp(-359.9, 359.9);
768 let value_rad = value.to_radians();
769
770 let arc_point = |t: f64| -> [f64; 3] {
774 let dir = rotate_about_axis(start, axis, t);
775 [
776 center[0] + dir[0] * radius,
777 center[1] + dir[1] * radius,
778 center[2] + dir[2] * radius,
779 ]
780 };
781 let seg_count = ((value.abs() / ARC_DEG_PER_SEG).ceil() as usize).max(2);
782 let mut prev = arc_point(0.0);
783 for k in 1..=seg_count {
784 let t = value_rad * (k as f64 / seg_count as f64);
785 let cur = arc_point(t);
786 push_tube(tb, prev, cur, shaft_rad, SHAFT_RGB);
787 prev = cur;
788 }
789
790 let dir_end = rotate_about_axis(start, axis, value_rad);
793 let end_pt = [
794 center[0] + dir_end[0] * radius,
795 center[1] + dir_end[1] * radius,
796 center[2] + dir_end[2] * radius,
797 ];
798 push_sphere(tb, end_pt, sphere_rad, ORANGE_RGB);
799 let sweep_sign = if value < 0.0 { -1.0 } else { 1.0 };
801 let tangent = normalize_or(cross3(axis, dir_end), dir_end);
802 let tangent = [tangent[0] * sweep_sign, tangent[1] * sweep_sign, tangent[2] * sweep_sign];
803 let cone_tip = [
804 end_pt[0] + tangent[0] * cone_len,
805 end_pt[1] + tangent[1] * cone_len,
806 end_pt[2] + tangent[2] * cone_len,
807 ];
808 push_cone(tb, end_pt, cone_tip, cone_rad, ORANGE_RGB);
809
810 let ref_end = [
813 center[0] + start[0] * radius,
814 center[1] + start[1] * radius,
815 center[2] + start[2] * radius,
816 ];
817 push_dashed(tb, center, ref_end, ray_rad, RED_RGB, world_per_pixel);
818
819 let axis_len = radius * 0.7;
821 let axis_a = [
822 center[0] - axis[0] * axis_len,
823 center[1] - axis[1] * axis_len,
824 center[2] - axis[2] * axis_len,
825 ];
826 let axis_b = [
827 center[0] + axis[0] * axis_len,
828 center[1] + axis[1] * axis_len,
829 center[2] + axis[2] * axis_len,
830 ];
831 push_tube(tb, axis_a, axis_b, ray_rad, GREEN_RGB);
832}
833
834fn push_dashed(
837 tb: &mut TriBuf,
838 a: [f64; 3],
839 b: [f64; 3],
840 radius: f64,
841 rgb: [f32; 3],
842 world_per_pixel: f64,
843) {
844 let axis = sub3(b, a);
845 let len = norm3(axis);
846 if len < 1e-9 {
847 return;
848 }
849 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
850 let dash = (DASH_LEN_PX * world_per_pixel).max(1e-6);
851 let gap = (DASH_GAP_PX * world_per_pixel).max(1e-6);
852 let mut s = 0.0;
853 while s < len {
854 let e = (s + dash).min(len);
855 let p0 = [a[0] + dir[0] * s, a[1] + dir[1] * s, a[2] + dir[2] * s];
856 let p1 = [a[0] + dir[0] * e, a[1] + dir[1] * e, a[2] + dir[2] * e];
857 push_tube(tb, p0, p1, radius, rgb);
858 s = e + gap;
859 }
860}
861
862pub fn angular_chip_anchor(ann: &FeatureDimAnnotation, world_per_pixel: f64) -> [f64; 3] {
867 let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
868 let value = ann.value.clamp(-359.9, 359.9);
869 let bisector = rotate_about_axis(ann.ref_dir, ann.axis, (value * 0.5).to_radians());
870 [
871 ann.center[0] + bisector[0] * radius,
872 ann.center[1] + bisector[1] * radius,
873 ann.center[2] + bisector[2] * radius,
874 ]
875}
876
877pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
884
885pub(crate) fn arrow_handle_point(
892 ann: &FeatureDimAnnotation,
893 world_per_pixel: f64,
894) -> [f64; 3] {
895 match ann.kind {
896 FeatureDimKind::Linear => ann.point_b,
897 FeatureDimKind::Angular => {
898 let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
899 let value = ann.value.clamp(-359.9, 359.9);
900 let dir = rotate_about_axis(ann.ref_dir, ann.axis, value.to_radians());
901 [
902 ann.center[0] + dir[0] * radius,
903 ann.center[1] + dir[1] * radius,
904 ann.center[2] + dir[2] * radius,
905 ]
906 }
907 }
908}
909
910fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
913 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
914}
915
916fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
917 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
918}
919
920fn normalize_or(v: [f64; 3], fallback: [f64; 3]) -> [f64; 3] {
922 let n = norm3(v);
923 if n < 1e-12 {
924 fallback
925 } else {
926 [v[0] / n, v[1] / n, v[2] / n]
927 }
928}
929
930fn arbitrary_perpendicular(direction: [f64; 3]) -> [f64; 3] {
932 if norm3(direction) <= 1e-12 {
933 return [0.0, 0.0, 1.0];
934 }
935 let seed = if dot3(direction, [0.0, 0.0, 1.0]).abs() < 0.9 {
936 [0.0, 0.0, 1.0]
937 } else {
938 [0.0, 1.0, 0.0]
939 };
940 let mut perp = cross3(direction, seed);
941 if norm3(perp) <= 1e-12 {
942 perp = cross3(direction, [1.0, 0.0, 0.0]);
943 }
944 if norm3(perp) <= 1e-12 {
945 [1.0, 0.0, 0.0]
946 } else {
947 normalize_or(perp, [1.0, 0.0, 0.0])
948 }
949}
950
951pub fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
954 let axis = normalize_or(axis, [0.0, 1.0, 0.0]);
955 let (s, c) = angle.sin_cos();
956 let d = dot3(axis, v);
957 let cr = cross3(axis, v);
958 [
959 v[0] * c + cr[0] * s + axis[0] * d * (1.0 - c),
960 v[1] * c + cr[1] * s + axis[1] * d * (1.0 - c),
961 v[2] * c + cr[2] * s + axis[2] * d * (1.0 - c),
962 ]
963}
964
965fn norm3(v: [f64; 3]) -> f64 {
966 (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
967}
968
969fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
970 [
971 a[1] * b[2] - a[2] * b[1],
972 a[2] * b[0] - a[0] * b[2],
973 a[0] * b[1] - a[1] * b[0],
974 ]
975}
976
977fn axis_basis(dir: [f64; 3]) -> ([f64; 3], [f64; 3]) {
979 let seed = if dir[0].abs() < 0.9 {
980 [1.0, 0.0, 0.0]
981 } else {
982 [0.0, 1.0, 0.0]
983 };
984 let mut u = cross3(dir, seed);
985 let un = norm3(u);
986 if un < 1e-9 {
987 u = [0.0, 1.0, 0.0];
988 } else {
989 u = [u[0] / un, u[1] / un, u[2] / un];
990 }
991 let v = cross3(dir, u);
992 let vn = norm3(v).max(1e-9);
993 (u, [v[0] / vn, v[1] / vn, v[2] / vn])
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use serde_json::json;
1000
1001 fn ident_transform() -> Value {
1002 json!({
1003 "position": [0.0, 0.0, 0.0],
1004 "rotationEuler": [0.0, 0.0, 0.0],
1005 "scale": [1.0, 1.0, 1.0],
1006 })
1007 }
1008
1009 fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
1010 norm3(sub3(a, b))
1011 }
1012
1013 fn plane_refs(origin: [f64; 3], normal: [f64; 3], stub: f64) -> ResolvedRefs {
1014 ResolvedRefs {
1015 plane_origin: Some(origin),
1016 plane_normal: Some(normal),
1017 plane_dim_length: Some(stub),
1018 ..Default::default()
1019 }
1020 }
1021
1022 #[test]
1025 fn plane_offset_dim_runs_along_the_normal() {
1026 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": 5.0 });
1027 let anns = build_annotations_with_refs(
1028 "P",
1029 ¶ms,
1030 &plane_refs([0.0, 0.0, 5.0], [0.0, 0.0, 1.0], 1.0),
1031 );
1032 assert_eq!(anns.len(), 1);
1033 assert_eq!(anns[0].field_key, "offset_distance");
1034 assert_eq!(anns[0].value, 5.0);
1035 assert_eq!(anns[0].point_a, [0.0, 0.0, 0.0]); assert_eq!(anns[0].point_b, [0.0, 0.0, 5.0]); }
1038
1039 #[test]
1042 fn plane_offset_dim_supports_negative() {
1043 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": -4.0 });
1044 let anns = build_annotations_with_refs(
1045 "P",
1046 ¶ms,
1047 &plane_refs([0.0, 0.0, -4.0], [0.0, 0.0, 1.0], 1.0),
1048 );
1049 assert_eq!(anns[0].value, -4.0);
1050 assert_eq!(anns[0].point_a, [0.0, 0.0, 0.0]);
1051 assert_eq!(anns[0].point_b, [0.0, 0.0, -4.0]);
1052 }
1053
1054 #[test]
1057 fn plane_offset_dim_at_zero_uses_a_draggable_stub() {
1058 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": 0.0 });
1059 let anns = build_annotations_with_refs(
1060 "P",
1061 ¶ms,
1062 &plane_refs([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 2.0),
1063 );
1064 assert_eq!(anns[0].value, 0.0);
1065 assert_eq!(anns[0].point_b, [0.0, 0.0, 2.0], "stub gives the leader a +normal direction");
1066 assert!(dist(anns[0].point_a, anns[0].point_b) > 1e-6, "non-degenerate → draggable");
1067 }
1068
1069 #[test]
1071 fn plane_without_a_resolved_frame_has_no_dim() {
1072 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": 5.0 });
1073 assert!(build_annotations_with_refs("P", ¶ms, &ResolvedRefs::default()).is_empty());
1074 }
1075
1076 #[test]
1077 fn cube_identity_gives_three_axis_dims() {
1078 let params = json!({
1079 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1080 "transform": ident_transform(),
1081 });
1082 let anns = build_annotations("P.CU", ¶ms);
1083 assert_eq!(anns.len(), 3);
1084 let keys: Vec<&str> = anns.iter().map(|a| a.field_key.as_str()).collect();
1085 assert_eq!(keys, ["sizeX", "sizeY", "sizeZ"]);
1086 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1087 assert!((dist(anns[1].point_a, anns[1].point_b) - 20.0).abs() < 1e-9);
1088 assert!((dist(anns[2].point_a, anns[2].point_b) - 30.0).abs() < 1e-9);
1089 assert!((anns[0].point_a[0]).abs() < 1e-9);
1091 assert!((anns[0].point_b[0] - 10.0).abs() < 1e-9);
1092 assert!((anns[0].value - 10.0).abs() < 1e-9);
1093 }
1094
1095 #[test]
1096 fn cube_translation_moves_the_dims() {
1097 let params = json!({
1098 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1099 "transform": {
1100 "position": [5.0, -3.0, 2.0],
1101 "rotationEuler": [0.0, 0.0, 0.0],
1102 "scale": [1.0, 1.0, 1.0],
1103 },
1104 });
1105 let anns = build_annotations("P.CU", ¶ms);
1106 assert!((anns[0].point_a[0] - 5.0).abs() < 1e-9);
1108 assert!((anns[0].point_a[1] + 3.0).abs() < 1e-9);
1109 assert!((anns[0].point_a[2] - 2.0).abs() < 1e-9);
1110 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1111 }
1112
1113 #[test]
1114 fn cube_rotation_90_about_z_maps_x_axis_to_y() {
1115 let params = json!({
1116 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1117 "transform": {
1118 "position": [0.0, 0.0, 0.0],
1119 "rotationEuler": [0.0, 0.0, 90.0],
1120 "scale": [1.0, 1.0, 1.0],
1121 },
1122 });
1123 let anns = build_annotations("P.CU", ¶ms);
1124 let px = anns[0].point_b;
1126 assert!(px[0].abs() < 1e-6, "{px:?}");
1127 assert!((px[1] - 10.0).abs() < 1e-6, "{px:?}");
1128 assert!(px[2].abs() < 1e-6, "{px:?}");
1129 assert!((dist(anns[1].point_a, anns[1].point_b) - 20.0).abs() < 1e-6);
1131 }
1132
1133 #[test]
1134 fn cube_scale_scales_world_length() {
1135 let params = json!({
1136 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1137 "transform": {
1138 "position": [0.0, 0.0, 0.0],
1139 "rotationEuler": [0.0, 0.0, 0.0],
1140 "scale": [2.0, 1.0, 1.0],
1141 },
1142 });
1143 let anns = build_annotations("P.CU", ¶ms);
1144 assert!((dist(anns[0].point_a, anns[0].point_b) - 20.0).abs() < 1e-9);
1146 assert!((anns[0].value - 10.0).abs() < 1e-9);
1147 }
1148
1149 #[test]
1150 fn cylinder_gives_radius_and_height() {
1151 let params = json!({
1152 "radius": 4.0, "height": 12.0,
1153 "transform": ident_transform(),
1154 });
1155 let anns = build_annotations("P.CY", ¶ms);
1156 assert_eq!(anns.len(), 2);
1157 assert_eq!(anns[0].field_key, "radius");
1158 assert_eq!(anns[1].field_key, "height");
1159 assert!((dist(anns[0].point_a, anns[0].point_b) - 4.0).abs() < 1e-9);
1160 assert!((dist(anns[1].point_a, anns[1].point_b) - 12.0).abs() < 1e-9);
1161 assert!((anns[0].point_b[0] - 4.0).abs() < 1e-9);
1163 assert!((anns[1].point_b[1] - 12.0).abs() < 1e-9);
1164 }
1165
1166 #[test]
1167 fn cone_gives_three_dims() {
1168 let params = json!({
1169 "radiusBottom": 5.0, "radiusTop": 2.0, "height": 8.0,
1170 "transform": ident_transform(),
1171 });
1172 let anns = build_annotations("P.CO", ¶ms);
1173 assert_eq!(anns.len(), 3);
1174 let keys: Vec<&str> = anns.iter().map(|a| a.field_key.as_str()).collect();
1175 assert_eq!(keys, ["radiusBottom", "radiusTop", "height"]);
1176 assert!((dist(anns[0].point_a, anns[0].point_b) - 5.0).abs() < 1e-9);
1177 assert!((dist(anns[1].point_a, anns[1].point_b) - 2.0).abs() < 1e-9);
1178 assert!((dist(anns[2].point_a, anns[2].point_b) - 8.0).abs() < 1e-9);
1179 assert!((anns[1].point_a[1] - 8.0).abs() < 1e-9);
1181 }
1182
1183 #[test]
1184 fn sphere_gives_one_radius_dim() {
1185 let params = json!({ "radius": 7.5, "transform": ident_transform() });
1186 let anns = build_annotations("P.S", ¶ms);
1187 assert_eq!(anns.len(), 1);
1188 assert_eq!(anns[0].field_key, "radius");
1189 assert!((dist(anns[0].point_a, anns[0].point_b) - 7.5).abs() < 1e-9);
1190 }
1191
1192 #[test]
1193 fn pyramid_gives_side_and_height_centered() {
1194 let params = json!({
1195 "baseSideLength": 6.0, "height": 10.0,
1196 "transform": ident_transform(),
1197 });
1198 let anns = build_annotations("P.PY", ¶ms);
1199 assert_eq!(anns.len(), 2);
1200 assert_eq!(anns[0].field_key, "baseSideLength");
1201 assert_eq!(anns[1].field_key, "height");
1202 assert!((dist(anns[0].point_a, anns[0].point_b) - 6.0).abs() < 1e-9);
1203 assert!((dist(anns[1].point_a, anns[1].point_b) - 10.0).abs() < 1e-9);
1204 assert!((anns[1].point_a[1] + 5.0).abs() < 1e-9);
1206 assert!((anns[1].point_b[1] - 5.0).abs() < 1e-9);
1207 }
1208
1209 #[test]
1210 fn unknown_type_gives_no_dims() {
1211 let params = json!({ "distance": 5.0 });
1212 assert!(build_annotations("EXTRUDE", ¶ms).is_empty());
1213 assert!(build_annotations("BOOLEAN", ¶ms).is_empty());
1214 }
1215
1216 #[test]
1217 fn numeric_string_params_resolve() {
1218 let params = json!({
1219 "sizeX": "10", "sizeY": "20", "sizeZ": "30",
1220 "transform": ident_transform(),
1221 });
1222 let anns = build_annotations("P.CU", ¶ms);
1223 assert!((anns[0].value - 10.0).abs() < 1e-9);
1224 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1225 }
1226
1227 #[test]
1228 fn leaders_buffers_emit_shaft_cone_and_origin_sphere_tris() {
1229 let ann = FeatureDimAnnotation::linear("sizeX", [0.0, 0.0, 0.0], [10.0, 0.0, 0.0], 10.0, "X");
1230 let (pos, col) = leaders_buffers(std::slice::from_ref(&ann), 0.1);
1231 assert!(!pos.is_empty(), "expected triangle geometry");
1234 assert_eq!(pos.len(), col.len(), "one rgb color per xyz position");
1235 assert_eq!(pos.len() % 9, 0, "whole triangles (3 verts * 3 floats)");
1236 let has = |rgb: [f32; 3]| {
1238 col.chunks_exact(3)
1239 .any(|c| (c[0] - rgb[0]).abs() < 1e-3 && (c[1] - rgb[1]).abs() < 1e-3 && (c[2] - rgb[2]).abs() < 1e-3)
1240 };
1241 assert!(has(SHAFT_RGB), "expected silver shaft tris");
1242 assert!(has(ORANGE_RGB), "expected orange cone/sphere tris");
1243 }
1244
1245 #[test]
1246 fn cube_dims_share_one_origin_sphere() {
1247 let anns = build_annotations(
1251 "P.CU",
1252 &json!({ "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0, "transform": ident_transform() }),
1253 );
1254 assert_eq!(anns.len(), 3);
1255 let origins: std::collections::BTreeSet<_> = anns
1256 .iter()
1257 .map(|a| (a.point_a[0] as i64, a.point_a[1] as i64, a.point_a[2] as i64))
1258 .collect();
1259 assert_eq!(origins.len(), 1, "cube dims share one origin corner");
1260 let (pos, _) = leaders_buffers(&anns, 0.1);
1261 assert!(!pos.is_empty());
1262 }
1263
1264 fn color_present(col: &[f32], rgb: [f32; 3]) -> bool {
1267 col.chunks_exact(3).any(|c| {
1268 (c[0] - rgb[0]).abs() < 1e-3
1269 && (c[1] - rgb[1]).abs() < 1e-3
1270 && (c[2] - rgb[2]).abs() < 1e-3
1271 })
1272 }
1273
1274 #[test]
1275 fn torus_emits_two_linear_and_one_angular() {
1276 let params = json!({
1277 "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 90.0,
1278 "transform": ident_transform(),
1279 });
1280 let anns = build_annotations("P.T", ¶ms);
1281 assert_eq!(anns.len(), 3);
1282 assert_eq!(anns[0].field_key, "majorRadius");
1284 assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1285 assert!((dist(anns[0].point_a, anns[0].point_b) - 5.0).abs() < 1e-9);
1286 assert_eq!(anns[1].field_key, "tubeRadius");
1287 assert_eq!(anns[1].kind, FeatureDimKind::Linear);
1288 assert!((dist(anns[1].point_a, anns[1].point_b) - 1.0).abs() < 1e-9);
1289 assert!((anns[1].point_a[0] - 5.0).abs() < 1e-9);
1291 let arc = &anns[2];
1293 assert_eq!(arc.field_key, "arc");
1294 assert_eq!(arc.kind, FeatureDimKind::Angular);
1295 assert!((arc.value - 90.0).abs() < 1e-9);
1296 assert!((arc.axis[1] - 1.0).abs() < 1e-6, "axis ≈ +Y: {:?}", arc.axis);
1297 assert!((arc.ref_dir[0] - 1.0).abs() < 1e-6, "ref ≈ +X: {:?}", arc.ref_dir);
1298 }
1299
1300 #[test]
1301 fn torus_arc_clamped_to_360() {
1302 let params = json!({
1303 "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 500.0,
1304 "transform": ident_transform(),
1305 });
1306 let anns = build_annotations("P.T", ¶ms);
1307 assert!((anns[2].value - 360.0).abs() < 1e-9, "arc clamps to 360");
1308 }
1309
1310 #[test]
1311 fn extrude_emits_linear_distance_along_normal() {
1312 let refs = ResolvedRefs {
1313 profile_center: Some([2.0, 0.0, 0.0]),
1314 profile_normal: Some([0.0, 0.0, 1.0]),
1315 ..Default::default()
1316 };
1317 let params = json!({ "distance": 10.0, "distanceBack": 3.0 });
1318 let anns = build_annotations_with_refs("E", ¶ms, &refs);
1319 assert_eq!(anns.len(), 2);
1320 assert_eq!(anns[0].field_key, "distance");
1321 assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1322 assert_eq!(anns[0].point_a, [2.0, 0.0, 0.0]);
1324 assert!((anns[0].point_b[2] - 10.0).abs() < 1e-9);
1325 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1326 assert_eq!(anns[1].field_key, "distanceBack");
1328 assert!((anns[1].point_b[2] + 3.0).abs() < 1e-9);
1329 }
1330
1331 #[test]
1332 fn revolve_emits_one_angular_about_axis() {
1333 let refs = ResolvedRefs {
1336 profile_center: Some([5.0, 0.0, 0.0]),
1337 profile_normal: Some([0.0, 1.0, 0.0]),
1338 axis_point: Some([0.0, 0.0, 0.0]),
1339 axis_dir: Some([0.0, 0.0, 1.0]),
1340 ..Default::default()
1341 };
1342 let params = json!({ "angle": 234.0 });
1343 let anns = build_annotations_with_refs("R", ¶ms, &refs);
1344 assert_eq!(anns.len(), 1);
1345 let a = &anns[0];
1346 assert_eq!(a.field_key, "angle");
1347 assert_eq!(a.kind, FeatureDimKind::Angular);
1348 assert!((a.value - 234.0).abs() < 1e-9);
1349 assert!((a.axis[2] - 1.0).abs() < 1e-6, "axis ≈ +Z: {:?}", a.axis);
1350 assert!((a.ref_dir[0] - 1.0).abs() < 1e-6, "ref ≈ +X: {:?}", a.ref_dir);
1351 assert!(norm3(a.center) < 1e-9, "vertex on axis: {:?}", a.center);
1353 }
1354
1355 #[test]
1356 fn revolve_axis_orients_toward_profile_front() {
1357 let base = ResolvedRefs {
1360 profile_center: Some([5.0, 0.0, 0.0]),
1361 profile_normal: Some([0.0, 1.0, 0.0]),
1362 axis_point: Some([0.0, 0.0, 0.0]),
1363 axis_dir: Some([0.0, 0.0, 1.0]),
1364 ..Default::default()
1365 };
1366 let flipped = ResolvedRefs {
1367 profile_normal: Some([0.0, -1.0, 0.0]),
1368 ..base.clone()
1369 };
1370 let params = json!({ "angle": 90.0 });
1371 let a = build_annotations_with_refs("R", ¶ms, &base);
1372 let b = build_annotations_with_refs("R", ¶ms, &flipped);
1373 assert!((a[0].axis[2] - 1.0).abs() < 1e-6);
1374 assert!((b[0].axis[2] + 1.0).abs() < 1e-6, "flipped normal → negated axis");
1375 }
1376
1377 #[test]
1378 fn extrude_and_revolve_empty_without_resolved_refs() {
1379 let refs = ResolvedRefs::default();
1380 assert!(build_annotations_with_refs("E", &json!({ "distance": 5.0 }), &refs).is_empty());
1381 assert!(build_annotations_with_refs("R", &json!({ "angle": 90.0 }), &refs).is_empty());
1382 }
1383
1384 #[test]
1385 fn angle_gizmo_emits_arc_cone_sphere_ref_and_axis_tris() {
1386 let params = json!({
1387 "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 234.0,
1388 "transform": ident_transform(),
1389 });
1390 let anns = build_annotations("P.T", ¶ms);
1391 let (pos, col) = leaders_buffers(&anns, 0.1);
1392 assert!(!pos.is_empty());
1393 assert_eq!(pos.len(), col.len());
1394 assert_eq!(pos.len() % 9, 0);
1395 assert!(color_present(&col, SHAFT_RGB), "grey arc");
1398 assert!(color_present(&col, ORANGE_RGB), "orange cone/handle");
1399 assert!(color_present(&col, RED_RGB), "red dashed reference");
1400 assert!(color_present(&col, GREEN_RGB), "green axis");
1401 }
1402
1403 #[test]
1404 fn angular_chip_anchor_sits_on_the_arc_mid_sweep() {
1405 let ann = FeatureDimAnnotation::angular(
1408 "angle",
1409 [0.0, 0.0, 0.0],
1410 [0.0, 0.0, 1.0],
1411 [1.0, 0.0, 0.0],
1412 180.0,
1413 "A",
1414 );
1415 let wpp = 0.01;
1416 let anchor = angular_chip_anchor(&ann, wpp);
1417 let radius = ANGLE_ARC_RAD_PX * wpp;
1418 assert!((anchor[1] - radius).abs() < 1e-6, "mid-sweep ≈ +Y*radius: {anchor:?}");
1419 assert!(anchor[0].abs() < 1e-6 && anchor[2].abs() < 1e-6);
1420 }
1421}