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)]
157pub struct ResolvedRefs {
158 pub profile_center: Option<[f64; 3]>,
162 pub profile_normal: Option<[f64; 3]>,
165 pub axis_point: Option<[f64; 3]>,
167 pub axis_dir: Option<[f64; 3]>,
170 pub plane_origin: Option<[f64; 3]>,
173 pub plane_normal: Option<[f64; 3]>,
176 pub plane_dim_length: Option<f64>,
180}
181
182pub fn build_annotations(feature_type: &str, input_params: &Value) -> Vec<FeatureDimAnnotation> {
191 build_annotations_with_refs(feature_type, input_params, &ResolvedRefs::default())
192}
193
194pub fn build_annotations_with_refs(
200 feature_type: &str,
201 input_params: &Value,
202 resolved: &ResolvedRefs,
203) -> Vec<FeatureDimAnnotation> {
204 let transform = input_params.get("transform");
205 match feature_type {
206 "P.CU" => build_cube(input_params, transform),
207 "P.CY" => build_cylinder(input_params, transform),
208 "P.CO" => build_cone(input_params, transform),
209 "P.S" | "P.SP" => build_sphere(input_params, transform),
211 "P.PY" => build_pyramid(input_params, transform),
212 "P.T" => build_torus(input_params, transform),
213 "E" => build_extrude(input_params, resolved),
214 "R" => build_revolve(input_params, resolved),
215 "P" => build_plane(input_params, resolved),
216 _ => Vec::new(),
217 }
218}
219
220fn build_cube(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
224 let sx = resolve_number(params, "sizeX");
225 let sy = resolve_number(params, "sizeY");
226 let sz = resolve_number(params, "sizeZ");
227 let p0 = transform_point(transform, [0.0, 0.0, 0.0]);
228 let px = transform_point(transform, [sx, 0.0, 0.0]);
229 let py = transform_point(transform, [0.0, sy, 0.0]);
230 let pz = transform_point(transform, [0.0, 0.0, sz]);
231 vec![
232 FeatureDimAnnotation::linear("sizeX", p0, px, sx, "X"),
233 FeatureDimAnnotation::linear("sizeY", p0, py, sy, "Y"),
234 FeatureDimAnnotation::linear("sizeZ", p0, pz, sz, "Z"),
235 ]
236}
237
238fn build_cylinder(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
241 let radius = resolve_number(params, "radius");
242 let height = resolve_number(params, "height");
243 let base = transform_point(transform, [0.0, 0.0, 0.0]);
244 let top = transform_point(transform, [0.0, height, 0.0]);
245 let radial = transform_point(transform, [radius, 0.0, 0.0]);
246 vec![
247 FeatureDimAnnotation::linear("radius", base, radial, radius, "R"),
248 FeatureDimAnnotation::linear("height", base, top, height, "H"),
249 ]
250}
251
252fn build_cone(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
255 let radius_top = resolve_number(params, "radiusTop");
256 let radius_bottom = resolve_number(params, "radiusBottom");
257 let height = resolve_number(params, "height");
258 let base_center = transform_point(transform, [0.0, 0.0, 0.0]);
259 let top_center = transform_point(transform, [0.0, height, 0.0]);
260 let base_radius = transform_point(transform, [radius_bottom, 0.0, 0.0]);
261 let top_radius = transform_point(transform, [radius_top, height, 0.0]);
262 vec![
263 FeatureDimAnnotation::linear("radiusBottom", base_center, base_radius, radius_bottom, "Rb"),
264 FeatureDimAnnotation::linear("radiusTop", top_center, top_radius, radius_top, "Rt"),
265 FeatureDimAnnotation::linear("height", base_center, top_center, height, "H"),
266 ]
267}
268
269fn build_sphere(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
271 let radius = resolve_number(params, "radius");
272 let center = transform_point(transform, [0.0, 0.0, 0.0]);
273 let radial = transform_point(transform, [radius, 0.0, 0.0]);
274 vec![FeatureDimAnnotation::linear("radius", center, radial, radius, "R")]
275}
276
277fn build_pyramid(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
281 let side = resolve_number(params, "baseSideLength");
282 let height = resolve_number(params, "height");
283 let half_side = side * 0.5;
284 let base_y = -height * 0.5;
285 let apex_y = height * 0.5;
286 let base_start = transform_point(transform, [-half_side, base_y, -half_side]);
287 let base_end = transform_point(transform, [half_side, base_y, -half_side]);
288 let base_center = transform_point(transform, [0.0, base_y, 0.0]);
289 let apex = transform_point(transform, [0.0, apex_y, 0.0]);
290 vec![
291 FeatureDimAnnotation::linear("baseSideLength", base_start, base_end, side, "Side"),
292 FeatureDimAnnotation::linear("height", base_center, apex, height, "H"),
293 ]
294}
295
296fn build_torus(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
303 let major = resolve_number(params, "majorRadius");
304 let tube = resolve_number(params, "tubeRadius");
305 let arc = clamp_deg(resolve_number(params, "arc"));
306
307 let center = transform_point(transform, [0.0, 0.0, 0.0]);
308 let major_point = transform_point(transform, [major, 0.0, 0.0]);
309 let tube_point = transform_point(transform, [major + tube, 0.0, 0.0]);
310 let axis = normalize_or(sub3(transform_point(transform, [0.0, 1.0, 0.0]), center), [0.0, 1.0, 0.0]);
314 let start_dir = sub3(major_point, center);
315
316 vec![
317 FeatureDimAnnotation::linear("majorRadius", center, major_point, major, "R"),
318 FeatureDimAnnotation::linear("tubeRadius", major_point, tube_point, tube, "r"),
319 FeatureDimAnnotation::angular("arc", center, axis, start_dir, arc, "Arc"),
320 ]
321}
322
323fn build_extrude(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
329 let (Some(center), Some(normal)) = (resolved.profile_center, resolved.profile_normal) else {
330 return Vec::new();
331 };
332 let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
333 let distance = resolve_number(params, "distance");
334 let back = resolve_number(params, "distanceBack");
335 let forward = [
336 center[0] + normal[0] * distance,
337 center[1] + normal[1] * distance,
338 center[2] + normal[2] * distance,
339 ];
340 let backward = [
341 center[0] - normal[0] * back,
342 center[1] - normal[1] * back,
343 center[2] - normal[2] * back,
344 ];
345 vec![
346 FeatureDimAnnotation::linear("distance", center, forward, distance, "D"),
347 FeatureDimAnnotation::linear("distanceBack", center, backward, back, "Db"),
348 ]
349}
350
351fn build_plane(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
360 let (Some(origin), Some(normal)) = (resolved.plane_origin, resolved.plane_normal) else {
361 return Vec::new();
362 };
363 let offset = resolve_number(params, "offset_distance");
364 let base = [
366 origin[0] - normal[0] * offset,
367 origin[1] - normal[1] * offset,
368 origin[2] - normal[2] * offset,
369 ];
370 let extent = if offset.abs() > 1e-6 {
373 offset
374 } else {
375 resolved.plane_dim_length.unwrap_or(1.0)
376 };
377 let handle = [
378 base[0] + normal[0] * extent,
379 base[1] + normal[1] * extent,
380 base[2] + normal[2] * extent,
381 ];
382 vec![FeatureDimAnnotation::linear(
383 "offset_distance",
384 base,
385 handle,
386 offset,
387 "Offset",
388 )]
389}
390
391fn build_revolve(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
398 let (Some(axis_point), Some(axis_dir)) = (resolved.axis_point, resolved.axis_dir) else {
399 return Vec::new();
400 };
401 let Some(profile_center) = resolved.profile_center else {
402 return Vec::new();
403 };
404 let axis = orient_revolve_axis(axis_dir, axis_point, profile_center, resolved.profile_normal);
405 let vertex = closest_point_on_line(profile_center, axis_point, axis);
406 let start_dir = sub3(profile_center, vertex);
409 let angle = clamp_deg(resolve_number(params, "angle"));
410 vec![FeatureDimAnnotation::angular("angle", vertex, axis, start_dir, angle, "A")]
411}
412
413pub(crate) fn orient_revolve_axis(
417 axis_dir: [f64; 3],
418 axis_point: [f64; 3],
419 profile_center: [f64; 3],
420 profile_normal: Option<[f64; 3]>,
421) -> [f64; 3] {
422 let axis = normalize_or(axis_dir, [0.0, 1.0, 0.0]);
423 let Some(normal) = profile_normal else {
424 return axis;
425 };
426 if norm3(normal) <= 1e-12 {
427 return axis;
428 }
429 let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
430 let mut radial = sub3(profile_center, axis_point);
432 let d = dot3(radial, axis);
433 radial = [radial[0] - axis[0] * d, radial[1] - axis[1] * d, radial[2] - axis[2] * d];
434 if norm3(radial) <= 1e-12 {
435 return axis;
436 }
437 let c = cross3(axis, radial);
438 if dot3(c, normal) < 0.0 {
439 [-axis[0], -axis[1], -axis[2]]
440 } else {
441 axis
442 }
443}
444
445pub(crate) fn closest_point_on_line(
447 point: [f64; 3],
448 line_point: [f64; 3],
449 line_dir: [f64; 3],
450) -> [f64; 3] {
451 let dir = normalize_or(line_dir, [0.0, 1.0, 0.0]);
452 let t = dot3(sub3(point, line_point), dir);
453 [
454 line_point[0] + dir[0] * t,
455 line_point[1] + dir[1] * t,
456 line_point[2] + dir[2] * t,
457 ]
458}
459
460fn clamp_deg(v: f64) -> f64 {
462 v.clamp(-360.0, 360.0)
463}
464
465pub(crate) fn transform_point(transform: Option<&Value>, local: [f64; 3]) -> [f64; 3] {
470 let position = read_vec3(transform, "position", [0.0, 0.0, 0.0]);
471 let rotation_deg = read_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]);
472 let scale = read_vec3(transform, "scale", [1.0, 1.0, 1.0]);
473 let scaled = [local[0] * scale[0], local[1] * scale[1], local[2] * scale[2]];
474 let euler = [
475 rotation_deg[0].to_radians(),
476 rotation_deg[1].to_radians(),
477 rotation_deg[2].to_radians(),
478 ];
479 let rotated = rotate_euler_xyz_f64(scaled, euler);
480 [
481 rotated[0] + position[0],
482 rotated[1] + position[1],
483 rotated[2] + position[2],
484 ]
485}
486
487fn read_vec3(transform: Option<&Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
490 let array = transform.and_then(|t| t.get(key)).and_then(Value::as_array);
491 let mut out = default;
492 if let Some(array) = array {
493 for (index, slot) in out.iter_mut().enumerate() {
494 if let Some(number) = array.get(index).and_then(Value::as_f64) {
495 *slot = number;
496 }
497 }
498 }
499 out
500}
501
502fn resolve_number(params: &Value, key: &str) -> f64 {
507 match params.get(key) {
508 Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).unwrap_or(0.0),
509 Some(Value::String(s)) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
510 _ => 0.0,
511 }
512}
513
514const SHAFT_RGB: [f32; 3] = [0.80, 0.81, 0.82];
527const ORANGE_RGB: [f32; 3] = [0.961, 0.651, 0.137];
529const RED_RGB: [f32; 3] = [0.902, 0.157, 0.157];
531const GREEN_RGB: [f32; 3] = [0.204, 0.808, 0.267];
533
534const SHAFT_RAD_PX: f64 = 2.2;
536const CONE_LEN_PX: f64 = 16.0;
538const CONE_RAD_PX: f64 = 6.0;
540pub(crate) const ORIGIN_SPHERE_RAD_PX: f64 = 7.0;
544
545pub const ANGLE_ARC_RAD_PX: f64 = 120.0;
549const ANGLE_RAY_RAD_PX: f64 = 1.6;
551const ARC_DEG_PER_SEG: f64 = 4.0;
553const DASH_LEN_PX: f64 = 6.0;
555const DASH_GAP_PX: f64 = 5.0;
556
557const TUBE_SEGMENTS: usize = 8;
558const CONE_SEGMENTS: usize = 16;
559const SPHERE_RINGS: usize = 6;
560const SPHERE_SECTORS: usize = 10;
561
562pub fn leaders_buffers(
569 annotations: &[FeatureDimAnnotation],
570 world_per_pixel: f64,
571) -> (Vec<f32>, Vec<f32>) {
572 let mut tb = TriBuf::default();
573 let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
574 let cone_len = CONE_LEN_PX * world_per_pixel;
575 let cone_rad = CONE_RAD_PX * world_per_pixel;
576 let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
577
578 let mut origins: Vec<[f64; 3]> = Vec::new();
580 let mut add_origin = |tb: &mut TriBuf, a: [f64; 3]| {
581 if !origins.iter().any(|o| norm3(sub3(*o, a)) < 1e-6) {
582 push_sphere(tb, a, sphere_rad, ORANGE_RGB);
583 origins.push(a);
584 }
585 };
586
587 for ann in annotations {
588 match ann.kind {
589 FeatureDimKind::Angular => {
590 add_origin(&mut tb, ann.center);
597 push_angle_gizmo(&mut tb, ann, world_per_pixel);
598 }
599 FeatureDimKind::Linear => {
600 let a = ann.point_a;
601 let b = ann.point_b;
602 let axis = sub3(b, a);
603 let len = norm3(axis);
604 add_origin(&mut tb, a);
605 if len < 1e-9 {
606 continue;
607 }
608 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
609 let cl = cone_len.min(len * 0.9);
611 let shaft_end = [b[0] - dir[0] * cl, b[1] - dir[1] * cl, b[2] - dir[2] * cl];
612 push_tube(&mut tb, a, shaft_end, shaft_rad, SHAFT_RGB);
613 push_cone(&mut tb, shaft_end, b, cone_rad, ORANGE_RGB);
614 }
615 }
616 }
617 (tb.positions, tb.colors)
618}
619
620pub fn append_plain_leader(
627 positions: &mut Vec<f32>,
628 colors: &mut Vec<f32>,
629 a: [f64; 3],
630 b: [f64; 3],
631 world_per_pixel: f64,
632) {
633 let mut tb = TriBuf {
634 positions: std::mem::take(positions),
635 colors: std::mem::take(colors),
636 };
637 push_tube(&mut tb, a, b, ANGLE_RAY_RAD_PX * world_per_pixel, SHAFT_RGB);
638 *positions = tb.positions;
639 *colors = tb.colors;
640}
641
642#[derive(Default)]
644struct TriBuf {
645 positions: Vec<f32>,
646 colors: Vec<f32>,
647}
648
649impl TriBuf {
650 fn tri(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], rgb: [f32; 3]) {
651 for p in [a, b, c] {
652 self.positions
653 .extend_from_slice(&[p[0] as f32, p[1] as f32, p[2] as f32]);
654 self.colors.extend_from_slice(&rgb);
655 }
656 }
657}
658
659fn push_tube(tb: &mut TriBuf, a: [f64; 3], b: [f64; 3], radius: f64, rgb: [f32; 3]) {
661 let axis = sub3(b, a);
662 let len = norm3(axis);
663 if len < 1e-9 || radius <= 0.0 {
664 return;
665 }
666 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
667 let (u, v) = axis_basis(dir);
668 let ring = |center: [f64; 3], k: usize| -> [f64; 3] {
669 let ang = (k as f64 / TUBE_SEGMENTS as f64) * std::f64::consts::TAU;
670 let (c, s) = (ang.cos() * radius, ang.sin() * radius);
671 [
672 center[0] + u[0] * c + v[0] * s,
673 center[1] + u[1] * c + v[1] * s,
674 center[2] + u[2] * c + v[2] * s,
675 ]
676 };
677 for k in 0..TUBE_SEGMENTS {
678 let a0 = ring(a, k);
679 let a1 = ring(a, k + 1);
680 let b0 = ring(b, k);
681 let b1 = ring(b, k + 1);
682 tb.tri(a0, b0, b1, rgb);
683 tb.tri(a0, b1, a1, rgb);
684 }
685}
686
687fn push_cone(tb: &mut TriBuf, base: [f64; 3], tip: [f64; 3], radius: f64, rgb: [f32; 3]) {
690 let axis = sub3(tip, base);
691 let len = norm3(axis);
692 if len < 1e-9 || radius <= 0.0 {
693 return;
694 }
695 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
696 let (u, v) = axis_basis(dir);
697 let ring = |k: usize| -> [f64; 3] {
698 let ang = (k as f64 / CONE_SEGMENTS as f64) * std::f64::consts::TAU;
699 let (c, s) = (ang.cos() * radius, ang.sin() * radius);
700 [
701 base[0] + u[0] * c + v[0] * s,
702 base[1] + u[1] * c + v[1] * s,
703 base[2] + u[2] * c + v[2] * s,
704 ]
705 };
706 let mut prev = ring(0);
707 for k in 1..=CONE_SEGMENTS {
708 let cur = ring(k);
709 tb.tri(tip, prev, cur, rgb); tb.tri(base, cur, prev, rgb); prev = cur;
712 }
713}
714
715fn push_sphere(tb: &mut TriBuf, center: [f64; 3], radius: f64, rgb: [f32; 3]) {
717 if radius <= 0.0 {
718 return;
719 }
720 let point = |ring: usize, sector: usize| -> [f64; 3] {
721 let lat = std::f64::consts::PI * (ring as f64 / SPHERE_RINGS as f64)
722 - std::f64::consts::FRAC_PI_2;
723 let lon = std::f64::consts::TAU * (sector as f64 / SPHERE_SECTORS as f64);
724 [
725 center[0] + lat.cos() * lon.cos() * radius,
726 center[1] + lat.cos() * lon.sin() * radius,
727 center[2] + lat.sin() * radius,
728 ]
729 };
730 for r in 0..SPHERE_RINGS {
731 for sct in 0..SPHERE_SECTORS {
732 let p00 = point(r, sct);
733 let p01 = point(r, sct + 1);
734 let p10 = point(r + 1, sct);
735 let p11 = point(r + 1, sct + 1);
736 tb.tri(p00, p10, p11, rgb);
737 tb.tri(p00, p11, p01, rgb);
738 }
739 }
740}
741
742fn push_angle_gizmo(tb: &mut TriBuf, ann: &FeatureDimAnnotation, world_per_pixel: f64) {
749 let center = ann.center;
750 let axis = ann.axis;
751 let start = ann.ref_dir;
752 let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
753 let ray_rad = ANGLE_RAY_RAD_PX * world_per_pixel;
754 let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
755 let cone_len = CONE_LEN_PX * world_per_pixel;
756 let cone_rad = CONE_RAD_PX * world_per_pixel;
757 let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
758 if radius <= 1e-9 {
759 return;
760 }
761 let value = ann.value.clamp(-359.9, 359.9);
765 let value_rad = value.to_radians();
766
767 let arc_point = |t: f64| -> [f64; 3] {
771 let dir = rotate_about_axis(start, axis, t);
772 [
773 center[0] + dir[0] * radius,
774 center[1] + dir[1] * radius,
775 center[2] + dir[2] * radius,
776 ]
777 };
778 let seg_count = ((value.abs() / ARC_DEG_PER_SEG).ceil() as usize).max(2);
779 let mut prev = arc_point(0.0);
780 for k in 1..=seg_count {
781 let t = value_rad * (k as f64 / seg_count as f64);
782 let cur = arc_point(t);
783 push_tube(tb, prev, cur, shaft_rad, SHAFT_RGB);
784 prev = cur;
785 }
786
787 let dir_end = rotate_about_axis(start, axis, value_rad);
790 let end_pt = [
791 center[0] + dir_end[0] * radius,
792 center[1] + dir_end[1] * radius,
793 center[2] + dir_end[2] * radius,
794 ];
795 push_sphere(tb, end_pt, sphere_rad, ORANGE_RGB);
796 let sweep_sign = if value < 0.0 { -1.0 } else { 1.0 };
798 let tangent = normalize_or(cross3(axis, dir_end), dir_end);
799 let tangent = [tangent[0] * sweep_sign, tangent[1] * sweep_sign, tangent[2] * sweep_sign];
800 let cone_tip = [
801 end_pt[0] + tangent[0] * cone_len,
802 end_pt[1] + tangent[1] * cone_len,
803 end_pt[2] + tangent[2] * cone_len,
804 ];
805 push_cone(tb, end_pt, cone_tip, cone_rad, ORANGE_RGB);
806
807 let ref_end = [
810 center[0] + start[0] * radius,
811 center[1] + start[1] * radius,
812 center[2] + start[2] * radius,
813 ];
814 push_dashed(tb, center, ref_end, ray_rad, RED_RGB, world_per_pixel);
815
816 let axis_len = radius * 0.7;
818 let axis_a = [
819 center[0] - axis[0] * axis_len,
820 center[1] - axis[1] * axis_len,
821 center[2] - axis[2] * axis_len,
822 ];
823 let axis_b = [
824 center[0] + axis[0] * axis_len,
825 center[1] + axis[1] * axis_len,
826 center[2] + axis[2] * axis_len,
827 ];
828 push_tube(tb, axis_a, axis_b, ray_rad, GREEN_RGB);
829}
830
831fn push_dashed(
834 tb: &mut TriBuf,
835 a: [f64; 3],
836 b: [f64; 3],
837 radius: f64,
838 rgb: [f32; 3],
839 world_per_pixel: f64,
840) {
841 let axis = sub3(b, a);
842 let len = norm3(axis);
843 if len < 1e-9 {
844 return;
845 }
846 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
847 let dash = (DASH_LEN_PX * world_per_pixel).max(1e-6);
848 let gap = (DASH_GAP_PX * world_per_pixel).max(1e-6);
849 let mut s = 0.0;
850 while s < len {
851 let e = (s + dash).min(len);
852 let p0 = [a[0] + dir[0] * s, a[1] + dir[1] * s, a[2] + dir[2] * s];
853 let p1 = [a[0] + dir[0] * e, a[1] + dir[1] * e, a[2] + dir[2] * e];
854 push_tube(tb, p0, p1, radius, rgb);
855 s = e + gap;
856 }
857}
858
859pub fn angular_chip_anchor(ann: &FeatureDimAnnotation, world_per_pixel: f64) -> [f64; 3] {
864 let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
865 let value = ann.value.clamp(-359.9, 359.9);
866 let bisector = rotate_about_axis(ann.ref_dir, ann.axis, (value * 0.5).to_radians());
867 [
868 ann.center[0] + bisector[0] * radius,
869 ann.center[1] + bisector[1] * radius,
870 ann.center[2] + bisector[2] * radius,
871 ]
872}
873
874pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
881
882pub(crate) fn arrow_handle_point(
889 ann: &FeatureDimAnnotation,
890 world_per_pixel: f64,
891) -> [f64; 3] {
892 match ann.kind {
893 FeatureDimKind::Linear => ann.point_b,
894 FeatureDimKind::Angular => {
895 let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
896 let value = ann.value.clamp(-359.9, 359.9);
897 let dir = rotate_about_axis(ann.ref_dir, ann.axis, value.to_radians());
898 [
899 ann.center[0] + dir[0] * radius,
900 ann.center[1] + dir[1] * radius,
901 ann.center[2] + dir[2] * radius,
902 ]
903 }
904 }
905}
906
907fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
910 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
911}
912
913fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
914 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
915}
916
917fn normalize_or(v: [f64; 3], fallback: [f64; 3]) -> [f64; 3] {
919 let n = norm3(v);
920 if n < 1e-12 {
921 fallback
922 } else {
923 [v[0] / n, v[1] / n, v[2] / n]
924 }
925}
926
927fn arbitrary_perpendicular(direction: [f64; 3]) -> [f64; 3] {
929 if norm3(direction) <= 1e-12 {
930 return [0.0, 0.0, 1.0];
931 }
932 let seed = if dot3(direction, [0.0, 0.0, 1.0]).abs() < 0.9 {
933 [0.0, 0.0, 1.0]
934 } else {
935 [0.0, 1.0, 0.0]
936 };
937 let mut perp = cross3(direction, seed);
938 if norm3(perp) <= 1e-12 {
939 perp = cross3(direction, [1.0, 0.0, 0.0]);
940 }
941 if norm3(perp) <= 1e-12 {
942 [1.0, 0.0, 0.0]
943 } else {
944 normalize_or(perp, [1.0, 0.0, 0.0])
945 }
946}
947
948pub fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
951 let axis = normalize_or(axis, [0.0, 1.0, 0.0]);
952 let (s, c) = angle.sin_cos();
953 let d = dot3(axis, v);
954 let cr = cross3(axis, v);
955 [
956 v[0] * c + cr[0] * s + axis[0] * d * (1.0 - c),
957 v[1] * c + cr[1] * s + axis[1] * d * (1.0 - c),
958 v[2] * c + cr[2] * s + axis[2] * d * (1.0 - c),
959 ]
960}
961
962fn norm3(v: [f64; 3]) -> f64 {
963 (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
964}
965
966fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
967 [
968 a[1] * b[2] - a[2] * b[1],
969 a[2] * b[0] - a[0] * b[2],
970 a[0] * b[1] - a[1] * b[0],
971 ]
972}
973
974fn axis_basis(dir: [f64; 3]) -> ([f64; 3], [f64; 3]) {
976 let seed = if dir[0].abs() < 0.9 {
977 [1.0, 0.0, 0.0]
978 } else {
979 [0.0, 1.0, 0.0]
980 };
981 let mut u = cross3(dir, seed);
982 let un = norm3(u);
983 if un < 1e-9 {
984 u = [0.0, 1.0, 0.0];
985 } else {
986 u = [u[0] / un, u[1] / un, u[2] / un];
987 }
988 let v = cross3(dir, u);
989 let vn = norm3(v).max(1e-9);
990 (u, [v[0] / vn, v[1] / vn, v[2] / vn])
991}
992
993#[cfg(test)]
994mod tests {
995 use super::*;
996 use serde_json::json;
997
998 fn ident_transform() -> Value {
999 json!({
1000 "position": [0.0, 0.0, 0.0],
1001 "rotationEuler": [0.0, 0.0, 0.0],
1002 "scale": [1.0, 1.0, 1.0],
1003 })
1004 }
1005
1006 fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
1007 norm3(sub3(a, b))
1008 }
1009
1010 fn plane_refs(origin: [f64; 3], normal: [f64; 3], stub: f64) -> ResolvedRefs {
1011 ResolvedRefs {
1012 plane_origin: Some(origin),
1013 plane_normal: Some(normal),
1014 plane_dim_length: Some(stub),
1015 ..Default::default()
1016 }
1017 }
1018
1019 #[test]
1022 fn plane_offset_dim_runs_along_the_normal() {
1023 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": 5.0 });
1024 let anns = build_annotations_with_refs(
1025 "P",
1026 ¶ms,
1027 &plane_refs([0.0, 0.0, 5.0], [0.0, 0.0, 1.0], 1.0),
1028 );
1029 assert_eq!(anns.len(), 1);
1030 assert_eq!(anns[0].field_key, "offset_distance");
1031 assert_eq!(anns[0].value, 5.0);
1032 assert_eq!(anns[0].point_a, [0.0, 0.0, 0.0]); assert_eq!(anns[0].point_b, [0.0, 0.0, 5.0]); }
1035
1036 #[test]
1039 fn plane_offset_dim_supports_negative() {
1040 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": -4.0 });
1041 let anns = build_annotations_with_refs(
1042 "P",
1043 ¶ms,
1044 &plane_refs([0.0, 0.0, -4.0], [0.0, 0.0, 1.0], 1.0),
1045 );
1046 assert_eq!(anns[0].value, -4.0);
1047 assert_eq!(anns[0].point_a, [0.0, 0.0, 0.0]);
1048 assert_eq!(anns[0].point_b, [0.0, 0.0, -4.0]);
1049 }
1050
1051 #[test]
1054 fn plane_offset_dim_at_zero_uses_a_draggable_stub() {
1055 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": 0.0 });
1056 let anns = build_annotations_with_refs(
1057 "P",
1058 ¶ms,
1059 &plane_refs([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 2.0),
1060 );
1061 assert_eq!(anns[0].value, 0.0);
1062 assert_eq!(anns[0].point_b, [0.0, 0.0, 2.0], "stub gives the leader a +normal direction");
1063 assert!(dist(anns[0].point_a, anns[0].point_b) > 1e-6, "non-degenerate → draggable");
1064 }
1065
1066 #[test]
1068 fn plane_without_a_resolved_frame_has_no_dim() {
1069 let params = json!({ "id": "Pl", "orientation": "XY", "offset_distance": 5.0 });
1070 assert!(build_annotations_with_refs("P", ¶ms, &ResolvedRefs::default()).is_empty());
1071 }
1072
1073 #[test]
1074 fn cube_identity_gives_three_axis_dims() {
1075 let params = json!({
1076 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1077 "transform": ident_transform(),
1078 });
1079 let anns = build_annotations("P.CU", ¶ms);
1080 assert_eq!(anns.len(), 3);
1081 let keys: Vec<&str> = anns.iter().map(|a| a.field_key.as_str()).collect();
1082 assert_eq!(keys, ["sizeX", "sizeY", "sizeZ"]);
1083 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1084 assert!((dist(anns[1].point_a, anns[1].point_b) - 20.0).abs() < 1e-9);
1085 assert!((dist(anns[2].point_a, anns[2].point_b) - 30.0).abs() < 1e-9);
1086 assert!((anns[0].point_a[0]).abs() < 1e-9);
1088 assert!((anns[0].point_b[0] - 10.0).abs() < 1e-9);
1089 assert!((anns[0].value - 10.0).abs() < 1e-9);
1090 }
1091
1092 #[test]
1093 fn cube_translation_moves_the_dims() {
1094 let params = json!({
1095 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1096 "transform": {
1097 "position": [5.0, -3.0, 2.0],
1098 "rotationEuler": [0.0, 0.0, 0.0],
1099 "scale": [1.0, 1.0, 1.0],
1100 },
1101 });
1102 let anns = build_annotations("P.CU", ¶ms);
1103 assert!((anns[0].point_a[0] - 5.0).abs() < 1e-9);
1105 assert!((anns[0].point_a[1] + 3.0).abs() < 1e-9);
1106 assert!((anns[0].point_a[2] - 2.0).abs() < 1e-9);
1107 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1108 }
1109
1110 #[test]
1111 fn cube_rotation_90_about_z_maps_x_axis_to_y() {
1112 let params = json!({
1113 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1114 "transform": {
1115 "position": [0.0, 0.0, 0.0],
1116 "rotationEuler": [0.0, 0.0, 90.0],
1117 "scale": [1.0, 1.0, 1.0],
1118 },
1119 });
1120 let anns = build_annotations("P.CU", ¶ms);
1121 let px = anns[0].point_b;
1123 assert!(px[0].abs() < 1e-6, "{px:?}");
1124 assert!((px[1] - 10.0).abs() < 1e-6, "{px:?}");
1125 assert!(px[2].abs() < 1e-6, "{px:?}");
1126 assert!((dist(anns[1].point_a, anns[1].point_b) - 20.0).abs() < 1e-6);
1128 }
1129
1130 #[test]
1131 fn cube_scale_scales_world_length() {
1132 let params = json!({
1133 "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
1134 "transform": {
1135 "position": [0.0, 0.0, 0.0],
1136 "rotationEuler": [0.0, 0.0, 0.0],
1137 "scale": [2.0, 1.0, 1.0],
1138 },
1139 });
1140 let anns = build_annotations("P.CU", ¶ms);
1141 assert!((dist(anns[0].point_a, anns[0].point_b) - 20.0).abs() < 1e-9);
1143 assert!((anns[0].value - 10.0).abs() < 1e-9);
1144 }
1145
1146 #[test]
1147 fn cylinder_gives_radius_and_height() {
1148 let params = json!({
1149 "radius": 4.0, "height": 12.0,
1150 "transform": ident_transform(),
1151 });
1152 let anns = build_annotations("P.CY", ¶ms);
1153 assert_eq!(anns.len(), 2);
1154 assert_eq!(anns[0].field_key, "radius");
1155 assert_eq!(anns[1].field_key, "height");
1156 assert!((dist(anns[0].point_a, anns[0].point_b) - 4.0).abs() < 1e-9);
1157 assert!((dist(anns[1].point_a, anns[1].point_b) - 12.0).abs() < 1e-9);
1158 assert!((anns[0].point_b[0] - 4.0).abs() < 1e-9);
1160 assert!((anns[1].point_b[1] - 12.0).abs() < 1e-9);
1161 }
1162
1163 #[test]
1164 fn cone_gives_three_dims() {
1165 let params = json!({
1166 "radiusBottom": 5.0, "radiusTop": 2.0, "height": 8.0,
1167 "transform": ident_transform(),
1168 });
1169 let anns = build_annotations("P.CO", ¶ms);
1170 assert_eq!(anns.len(), 3);
1171 let keys: Vec<&str> = anns.iter().map(|a| a.field_key.as_str()).collect();
1172 assert_eq!(keys, ["radiusBottom", "radiusTop", "height"]);
1173 assert!((dist(anns[0].point_a, anns[0].point_b) - 5.0).abs() < 1e-9);
1174 assert!((dist(anns[1].point_a, anns[1].point_b) - 2.0).abs() < 1e-9);
1175 assert!((dist(anns[2].point_a, anns[2].point_b) - 8.0).abs() < 1e-9);
1176 assert!((anns[1].point_a[1] - 8.0).abs() < 1e-9);
1178 }
1179
1180 #[test]
1181 fn sphere_gives_one_radius_dim() {
1182 let params = json!({ "radius": 7.5, "transform": ident_transform() });
1183 let anns = build_annotations("P.S", ¶ms);
1184 assert_eq!(anns.len(), 1);
1185 assert_eq!(anns[0].field_key, "radius");
1186 assert!((dist(anns[0].point_a, anns[0].point_b) - 7.5).abs() < 1e-9);
1187 }
1188
1189 #[test]
1190 fn pyramid_gives_side_and_height_centered() {
1191 let params = json!({
1192 "baseSideLength": 6.0, "height": 10.0,
1193 "transform": ident_transform(),
1194 });
1195 let anns = build_annotations("P.PY", ¶ms);
1196 assert_eq!(anns.len(), 2);
1197 assert_eq!(anns[0].field_key, "baseSideLength");
1198 assert_eq!(anns[1].field_key, "height");
1199 assert!((dist(anns[0].point_a, anns[0].point_b) - 6.0).abs() < 1e-9);
1200 assert!((dist(anns[1].point_a, anns[1].point_b) - 10.0).abs() < 1e-9);
1201 assert!((anns[1].point_a[1] + 5.0).abs() < 1e-9);
1203 assert!((anns[1].point_b[1] - 5.0).abs() < 1e-9);
1204 }
1205
1206 #[test]
1207 fn unknown_type_gives_no_dims() {
1208 let params = json!({ "distance": 5.0 });
1209 assert!(build_annotations("EXTRUDE", ¶ms).is_empty());
1210 assert!(build_annotations("BOOLEAN", ¶ms).is_empty());
1211 }
1212
1213 #[test]
1214 fn numeric_string_params_resolve() {
1215 let params = json!({
1216 "sizeX": "10", "sizeY": "20", "sizeZ": "30",
1217 "transform": ident_transform(),
1218 });
1219 let anns = build_annotations("P.CU", ¶ms);
1220 assert!((anns[0].value - 10.0).abs() < 1e-9);
1221 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1222 }
1223
1224 #[test]
1225 fn leaders_buffers_emit_shaft_cone_and_origin_sphere_tris() {
1226 let ann = FeatureDimAnnotation::linear("sizeX", [0.0, 0.0, 0.0], [10.0, 0.0, 0.0], 10.0, "X");
1227 let (pos, col) = leaders_buffers(std::slice::from_ref(&ann), 0.1);
1228 assert!(!pos.is_empty(), "expected triangle geometry");
1231 assert_eq!(pos.len(), col.len(), "one rgb color per xyz position");
1232 assert_eq!(pos.len() % 9, 0, "whole triangles (3 verts * 3 floats)");
1233 let has = |rgb: [f32; 3]| {
1235 col.chunks_exact(3)
1236 .any(|c| (c[0] - rgb[0]).abs() < 1e-3 && (c[1] - rgb[1]).abs() < 1e-3 && (c[2] - rgb[2]).abs() < 1e-3)
1237 };
1238 assert!(has(SHAFT_RGB), "expected silver shaft tris");
1239 assert!(has(ORANGE_RGB), "expected orange cone/sphere tris");
1240 }
1241
1242 #[test]
1243 fn cube_dims_share_one_origin_sphere() {
1244 let anns = build_annotations(
1248 "P.CU",
1249 &json!({ "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0, "transform": ident_transform() }),
1250 );
1251 assert_eq!(anns.len(), 3);
1252 let origins: std::collections::BTreeSet<_> = anns
1253 .iter()
1254 .map(|a| (a.point_a[0] as i64, a.point_a[1] as i64, a.point_a[2] as i64))
1255 .collect();
1256 assert_eq!(origins.len(), 1, "cube dims share one origin corner");
1257 let (pos, _) = leaders_buffers(&anns, 0.1);
1258 assert!(!pos.is_empty());
1259 }
1260
1261 fn color_present(col: &[f32], rgb: [f32; 3]) -> bool {
1264 col.chunks_exact(3).any(|c| {
1265 (c[0] - rgb[0]).abs() < 1e-3
1266 && (c[1] - rgb[1]).abs() < 1e-3
1267 && (c[2] - rgb[2]).abs() < 1e-3
1268 })
1269 }
1270
1271 #[test]
1272 fn torus_emits_two_linear_and_one_angular() {
1273 let params = json!({
1274 "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 90.0,
1275 "transform": ident_transform(),
1276 });
1277 let anns = build_annotations("P.T", ¶ms);
1278 assert_eq!(anns.len(), 3);
1279 assert_eq!(anns[0].field_key, "majorRadius");
1281 assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1282 assert!((dist(anns[0].point_a, anns[0].point_b) - 5.0).abs() < 1e-9);
1283 assert_eq!(anns[1].field_key, "tubeRadius");
1284 assert_eq!(anns[1].kind, FeatureDimKind::Linear);
1285 assert!((dist(anns[1].point_a, anns[1].point_b) - 1.0).abs() < 1e-9);
1286 assert!((anns[1].point_a[0] - 5.0).abs() < 1e-9);
1288 let arc = &anns[2];
1290 assert_eq!(arc.field_key, "arc");
1291 assert_eq!(arc.kind, FeatureDimKind::Angular);
1292 assert!((arc.value - 90.0).abs() < 1e-9);
1293 assert!((arc.axis[1] - 1.0).abs() < 1e-6, "axis ≈ +Y: {:?}", arc.axis);
1294 assert!((arc.ref_dir[0] - 1.0).abs() < 1e-6, "ref ≈ +X: {:?}", arc.ref_dir);
1295 }
1296
1297 #[test]
1298 fn torus_arc_clamped_to_360() {
1299 let params = json!({
1300 "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 500.0,
1301 "transform": ident_transform(),
1302 });
1303 let anns = build_annotations("P.T", ¶ms);
1304 assert!((anns[2].value - 360.0).abs() < 1e-9, "arc clamps to 360");
1305 }
1306
1307 #[test]
1308 fn extrude_emits_linear_distance_along_normal() {
1309 let refs = ResolvedRefs {
1310 profile_center: Some([2.0, 0.0, 0.0]),
1311 profile_normal: Some([0.0, 0.0, 1.0]),
1312 ..Default::default()
1313 };
1314 let params = json!({ "distance": 10.0, "distanceBack": 3.0 });
1315 let anns = build_annotations_with_refs("E", ¶ms, &refs);
1316 assert_eq!(anns.len(), 2);
1317 assert_eq!(anns[0].field_key, "distance");
1318 assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1319 assert_eq!(anns[0].point_a, [2.0, 0.0, 0.0]);
1321 assert!((anns[0].point_b[2] - 10.0).abs() < 1e-9);
1322 assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1323 assert_eq!(anns[1].field_key, "distanceBack");
1325 assert!((anns[1].point_b[2] + 3.0).abs() < 1e-9);
1326 }
1327
1328 #[test]
1329 fn revolve_emits_one_angular_about_axis() {
1330 let refs = ResolvedRefs {
1333 profile_center: Some([5.0, 0.0, 0.0]),
1334 profile_normal: Some([0.0, 1.0, 0.0]),
1335 axis_point: Some([0.0, 0.0, 0.0]),
1336 axis_dir: Some([0.0, 0.0, 1.0]),
1337 ..Default::default()
1338 };
1339 let params = json!({ "angle": 234.0 });
1340 let anns = build_annotations_with_refs("R", ¶ms, &refs);
1341 assert_eq!(anns.len(), 1);
1342 let a = &anns[0];
1343 assert_eq!(a.field_key, "angle");
1344 assert_eq!(a.kind, FeatureDimKind::Angular);
1345 assert!((a.value - 234.0).abs() < 1e-9);
1346 assert!((a.axis[2] - 1.0).abs() < 1e-6, "axis ≈ +Z: {:?}", a.axis);
1347 assert!((a.ref_dir[0] - 1.0).abs() < 1e-6, "ref ≈ +X: {:?}", a.ref_dir);
1348 assert!(norm3(a.center) < 1e-9, "vertex on axis: {:?}", a.center);
1350 }
1351
1352 #[test]
1353 fn revolve_axis_orients_toward_profile_front() {
1354 let base = ResolvedRefs {
1357 profile_center: Some([5.0, 0.0, 0.0]),
1358 profile_normal: Some([0.0, 1.0, 0.0]),
1359 axis_point: Some([0.0, 0.0, 0.0]),
1360 axis_dir: Some([0.0, 0.0, 1.0]),
1361 ..Default::default()
1362 };
1363 let flipped = ResolvedRefs {
1364 profile_normal: Some([0.0, -1.0, 0.0]),
1365 ..base.clone()
1366 };
1367 let params = json!({ "angle": 90.0 });
1368 let a = build_annotations_with_refs("R", ¶ms, &base);
1369 let b = build_annotations_with_refs("R", ¶ms, &flipped);
1370 assert!((a[0].axis[2] - 1.0).abs() < 1e-6);
1371 assert!((b[0].axis[2] + 1.0).abs() < 1e-6, "flipped normal → negated axis");
1372 }
1373
1374 #[test]
1375 fn extrude_and_revolve_empty_without_resolved_refs() {
1376 let refs = ResolvedRefs::default();
1377 assert!(build_annotations_with_refs("E", &json!({ "distance": 5.0 }), &refs).is_empty());
1378 assert!(build_annotations_with_refs("R", &json!({ "angle": 90.0 }), &refs).is_empty());
1379 }
1380
1381 #[test]
1382 fn angle_gizmo_emits_arc_cone_sphere_ref_and_axis_tris() {
1383 let params = json!({
1384 "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 234.0,
1385 "transform": ident_transform(),
1386 });
1387 let anns = build_annotations("P.T", ¶ms);
1388 let (pos, col) = leaders_buffers(&anns, 0.1);
1389 assert!(!pos.is_empty());
1390 assert_eq!(pos.len(), col.len());
1391 assert_eq!(pos.len() % 9, 0);
1392 assert!(color_present(&col, SHAFT_RGB), "grey arc");
1395 assert!(color_present(&col, ORANGE_RGB), "orange cone/handle");
1396 assert!(color_present(&col, RED_RGB), "red dashed reference");
1397 assert!(color_present(&col, GREEN_RGB), "green axis");
1398 }
1399
1400 #[test]
1401 fn angular_chip_anchor_sits_on_the_arc_mid_sweep() {
1402 let ann = FeatureDimAnnotation::angular(
1405 "angle",
1406 [0.0, 0.0, 0.0],
1407 [0.0, 0.0, 1.0],
1408 [1.0, 0.0, 0.0],
1409 180.0,
1410 "A",
1411 );
1412 let wpp = 0.01;
1413 let anchor = angular_chip_anchor(&ann, wpp);
1414 let radius = ANGLE_ARC_RAD_PX * wpp;
1415 assert!((anchor[1] - radius).abs() < 1e-6, "mid-sweep ≈ +Y*radius: {anchor:?}");
1416 assert!(anchor[0].abs() < 1e-6 && anchor[2].abs() < 1e-6);
1417 }
1418}