Skip to main content

brep_render/
feature_dimensions.rs

1//! Feature-dimension annotations (FD-1) — the engine-native port of the original
2//! `FeatureDimensionAnnotationBuilder`.
3//!
4//! When a primitive-solid feature is selected and its ◎ is in DIMENSION mode,
5//! its key numeric params render as draggable dimension annotations: a leader
6//! from world `point_a` → `point_b`, editing the param `field_key`. FD-1 covers
7//! the LINEAR builders only (cube / cylinder / cone / sphere / pyramid); the
8//! angular + add-material builders (torus arc, revolve/extrude, pattern, port)
9//! are FD-2.
10//!
11//! Each local-space point convention is copied verbatim from the previous app's builder so
12//! the dims land on the SAME edges it drew — and, because the kernel bakes
13//! `inputParams.transform` with the exact intrinsic XYZ Euler order used here, on the
14//! rebuilt solid's edges too. See the per-builder notes below for the local
15//! corner conventions (e.g. the cube's minimum corner is the origin; the pyramid
16//! is centered on its axis with the base at `y = -h/2`).
17
18use serde_json::Value;
19
20use crate::engine_state::rotate_euler_xyz_f64;
21
22/// A dimension annotation's kind. FD-1 was all [`FeatureDimKind::Linear`]; FD-2
23/// adds [`FeatureDimKind::Angular`] (torus `arc`, revolve `angle`).
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum FeatureDimKind {
26    /// A linear distance from `point_a` → `point_b`.
27    Linear,
28    /// An angular sweep of `value` DEGREES about `axis`, measured in the plane
29    /// ⟂ `axis` from the zero reference `ref_dir`, centered at `center`. Ported
30    /// from the previous app's angle-annotation builder.
31    Angular,
32}
33
34/// One editable dimension annotation.
35///
36/// LINEAR: a leader from world `point_a` → `point_b` whose length is the
37/// (resolved) value of param `field_key`; dragging projects the pointer onto the
38/// `a → b` axis.
39///
40/// ANGULAR: a screen-constant-radius ARC swept `value` degrees about `axis`
41/// (unit), starting from `ref_dir` (unit, in the plane ⟂ `axis`), centered at
42/// `center`; dragging maps the pointer to a swept angle. The `point_a`/`point_b`
43/// / `midpoint()` linear surface is unused for an angular annotation (both are
44/// set to `center`); the app anchors an angular chip at the arc's mid-sweep,
45/// which is camera-dependent and computed engine-side.
46#[derive(Clone, Debug)]
47pub struct FeatureDimAnnotation {
48    /// The `inputParams` key this annotation edits (e.g. `sizeX`, `radius`, `arc`).
49    pub field_key: String,
50    /// LINEAR: the dimension's start point in WORLD space (the leader anchor /
51    /// drag base). ANGULAR: `center`.
52    pub point_a: [f64; 3],
53    /// LINEAR: the dimension's end point in WORLD space (the value-1 handle).
54    /// ANGULAR: `center`.
55    pub point_b: [f64; 3],
56    /// The current (resolved) numeric value of `field_key` (a length for LINEAR,
57    /// DEGREES for ANGULAR).
58    pub value: f64,
59    /// The short display prefix (`X`, `R`, `H`, `A`, `Arc`, …).
60    pub label: String,
61    /// The annotation kind.
62    pub kind: FeatureDimKind,
63    /// ANGULAR only: the arc center (the axis-plane vertex) in WORLD space.
64    /// `[0;3]` for a linear annotation.
65    pub center: [f64; 3],
66    /// ANGULAR: the rotation AXIS (unit) the arc sweeps about. LINEAR: `[0;3]`
67    /// for feature dims; the assembly DISTANCE overlays stash the BASE-FACE
68    /// outward unit normal here (the signed drag axis — see
69    /// [`crate::constraint_overlays`]'s perpendicular-foot construction).
70    /// `leaders_buffers` ignores it for linear annotations either way.
71    pub axis: [f64; 3],
72    /// ANGULAR only: the sweep's ZERO reference (unit, in the plane ⟂ `axis`).
73    /// `[0;3]` for a linear annotation.
74    pub ref_dir: [f64; 3],
75}
76
77impl FeatureDimAnnotation {
78    /// `pub(crate)`: the assembly-constraint overlay builder
79    /// ([`crate::constraint_overlays`]) constructs the SAME annotation shape for
80    /// distance constraints so `leaders_buffers` renders constraint arrows with
81    /// byte-identical styling (UI-consistency directive — one arrow look).
82    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    /// An angular annotation: `value` DEGREES swept about `axis` from `ref_dir`,
97    /// centered at `center`. `axis` is normalized and `ref_dir` is projected into
98    /// the plane ⟂ `axis` then normalized (mirrors the overlay `#createAngle`
99    /// pre-processing). A degenerate axis/ref falls back to an arbitrary basis so
100    /// the annotation is always renderable.
101    ///
102    /// `pub(crate)`: also constructed by [`crate::constraint_overlays`] for angle
103    /// constraints so the constraint arc reuses this exact gizmo styling.
104    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        // Project the reference into the plane ⟂ axis, then normalize.
114        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    /// The world-space midpoint of the leader — where the app anchors a LINEAR
139    /// label. (Angular chips anchor at the arc mid-sweep, computed engine-side
140    /// with the camera's `world_per_pixel`.)
141    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/// Resolved scene geometry an annotation builder needs beyond the pure
151/// `inputParams` — the profile plane (extrude/revolve) and the axis line
152/// (revolve). These are NOT pure params (they resolve scene references), so the
153/// engine resolves them from the run report's sketch profiles / axes, or from
154/// the resident scene when the reference names a solid face / edge (see
155/// `EngineState::feature_dimension_refs`), and hands them in. All fields are
156/// optional: a builder that can't source what it needs returns `[]` gracefully.
157#[derive(Clone, Debug, Default)]
158pub struct ResolvedRefs {
159    /// Extrude/revolve: the profile CENTER (world centroid of the outer loop) —
160    /// the anchor the distance/angle gizmo hangs off, matching the previous app's
161    /// resolved profile-reference-geometry center.
162    pub profile_center: Option<[f64; 3]>,
163    /// Extrude/revolve: the profile plane NORMAL (unit) — the sketch `+z` basis,
164    /// or a face profile's OUTWARD normal (what the kernel's `face_profile` sets
165    /// as `z_axis`) — the authoritative sweep/revolve-orientation direction.
166    pub profile_normal: Option<[f64; 3]>,
167    /// Revolve: a point on the resolved axis LINE (world).
168    pub axis_point: Option<[f64; 3]>,
169    /// Revolve: the resolved axis line DIRECTION (unit, UNORIENTED — the builder
170    /// orients it toward the profile front via the `orient_revolve_axis` port).
171    pub axis_dir: Option<[f64; 3]>,
172    /// Plane (`P`): the resolved plane frame's ORIGIN (world) — the plane AFTER its
173    /// `offset_distance`. The offset dim's un-offset base is `origin − normal·offset`.
174    pub plane_origin: Option<[f64; 3]>,
175    /// Plane (`P`): the resolved plane frame's unit NORMAL (its z-axis) — the axis
176    /// the signed offset dim runs along.
177    pub plane_normal: Option<[f64; 3]>,
178    /// Plane (`P`): a small world length for the offset dim's handle stub when the
179    /// offset is ~0 (a zero-length leader can't be dragged). Screen-constant so the
180    /// handle is a consistent size; ignored once the offset is non-zero.
181    pub plane_dim_length: Option<f64>,
182}
183
184/// Build the linear dimension annotations for a feature `type` from its
185/// `input_params`. Dispatches on the primitive-solid type; returns `[]` for any
186/// type without an FD-1 builder (extrude / revolve / booleans / etc.).
187///
188/// `input_params` should already have its numeric fields resolved to numbers
189/// (the engine resolves expression strings against the history env before
190/// calling this — see `EngineState::feature_dimension_annotations`), but plain
191/// numeric strings are tolerated here too so the pure geometry stays testable.
192pub fn build_annotations(feature_type: &str, input_params: &Value) -> Vec<FeatureDimAnnotation> {
193    build_annotations_with_refs(feature_type, input_params, &ResolvedRefs::default())
194}
195
196/// Build the dimension annotations for a feature, given any resolved scene
197/// references (`ResolvedRefs`) it needs. The primitive-solid + torus builders
198/// ignore `resolved` (pure params + the baked transform); extrude/revolve read
199/// the resolved profile plane / axis line and return `[]` when it is
200/// unavailable (an unresolved reference — see the builders). The engine resolves
201/// the profile plane from a sketch profile OR a resident solid face.
202pub 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        // The sphere feature type is `P.S` (the dispatch keys on `P.S`).
213        "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
223/// Cube (`P.CU`): minimum corner at the local origin, extending `+sizeX/Y/Z`
224/// (matches the kernel's `make_box_brep((0,0,0), …)`). Three linear dims from the
225/// origin corner along each axis.
226fn 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
241/// Cylinder (`P.CY`): axis is local `+Y`, base at `y=0`, top at `y=height`;
242/// radius along local `+X`. Two dims: radius (base → radial) + height (base → top).
243fn 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
255/// Cone (`P.CO`): base radius at `y=0`, top radius at `y=height` (both along
256/// local `+X`), axis along `+Y`. Three dims: radiusBottom / radiusTop / height.
257fn 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
272/// Sphere (`P.S`): one radial dim from the center along local `+X`.
273fn 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
280/// Pyramid (`P.PY`): centered on its axis — base at `y = -h/2`, apex at `y = h/2`,
281/// base edge spanning `±side/2` in local X (at `z = -side/2`). Two dims: the base
282/// side edge + the axial height.
283fn 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
299/// Torus (`P.T`): centered on its axis (local `+Y`) at the origin; the tube
300/// circle lies in the local X/Y plane centered at `(majorRadius, 0, 0)`, revolved
301/// `arc` DEGREES about `+Y`. Two LINEAR dims — `majorRadius` (center → the tube
302/// centerline along `+X`, `R`) and `tubeRadius` (the centerline → the outer wall,
303/// `r`) — plus the `arc` sweep as an ANGULAR dim about the local `+Y` axis from
304/// the `+X` reference. Ported verbatim from the previous torus-annotation builder.
305fn 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    // The axis / reference are the transformed local +Y / +X (subtract the
314    // transformed origin then normalize, so translation cancels; a non-uniform
315    // scale skews them — normalization handles magnitude).
316    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
326/// Extrude (`E`): a LINEAR distance dim from the profile CENTER along the profile
327/// plane NORMAL by `distance` (`D`), plus the two-sided `distanceBack` leg along
328/// `-normal` (`Db`). Needs the resolved profile center + normal (a sketch
329/// profile's, or a resident solid face's — the engine sources both). Ported from
330/// the previous extrude-annotation builder.
331fn 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
354/// Plane (`P`): one SIGNED LINEAR offset dim along the plane normal, driving
355/// `offset_distance`. The plane sits on either side of its base orientation/
356/// reference plane, so the dim runs from the un-offset base
357/// (`origin − normal·offset`) to the current plane (`origin`) and is dragged
358/// through zero to flip sign (the drag preserves sign; the kernel accepts a
359/// negative `offset_distance`). At offset ≈ 0 the leader collapses, so a small
360/// `+normal` stub (`plane_dim_length`, screen-constant) keeps it draggable — the
361/// value mapping stays 1:1 there. No resolved plane frame → `[]`.
362fn 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    // The un-offset base plane point — the dim's zero anchor.
368    let base = [
369        origin[0] - normal[0] * offset,
370        origin[1] - normal[1] * offset,
371        origin[2] - normal[2] * offset,
372    ];
373    // Handle at the current offset; at ~0 use a small +normal stub so the leader
374    // has a direction (the drag no-ops on a zero-length axis).
375    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
394/// Revolve (`R`): one ANGULAR dim = `angle` DEGREES swept about the resolved axis
395/// line, oriented toward the profile front (the `orient_revolve_axis` port — so
396/// the arc rotates the SAME way the solid does), centered at the axis point
397/// nearest the profile, zeroed on the radial from that vertex to the profile.
398/// Needs the resolved axis line + profile; returns `[]` otherwise. Ported from
399/// the previous revolve-annotation builder.
400fn 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    // The radial from the vertex to the profile (projected ⟂ axis) is the zero
410    // reference; the `angular` ctor re-projects + falls back if it degenerates.
411    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
416/// The signed revolve axis native Revolve uses: the profile's outward normal
417/// selects between the two directions of an unoriented axis edge. Port of
418/// `resolveOrientedRevolveAxisDirection`.
419pub(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    // radial = (profileCenter - axisPoint) projected ⟂ axis.
434    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
448/// The point on line `(line_point, line_dir)` closest to `point`.
449pub(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
463/// Clamp a degree value to `[-360, 360]`.
464fn clamp_deg(v: f64) -> f64 {
465    v.clamp(-360.0, 360.0)
466}
467
468/// Apply a feature's `inputParams.transform` (TRS, `rotationEuler` in DEGREES,
469/// `M = T·R·S`) to a LOCAL point → WORLD. Mirrors composing that degree-based TRS
470/// matrix and applying it to a point: `world = position + R·(scale ⊙ local)`, with `R` the exact
471/// intrinsic XYZ Euler order matrix the kernel bake uses (`rotate_euler_xyz_f64`).
472pub(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
490/// Read a `[x, y, z]` from a `transform` sub-field (numbers only; a missing /
491/// short array keeps the per-index default).
492fn 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
505/// Read `params[key]` as a finite number: a JSON number, else a plain numeric
506/// string (e.g. `"12.5"`). Non-numeric / expression strings resolve to `0.0`
507/// (the engine pre-resolves expressions before building, so this is only the
508/// pure-geometry fallback). Mirrors the numeric input-param resolution fallback.
509fn 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
517// --- restyled leader geometry (matches the reference dimension-arrows image) --
518//
519// Each annotation draws a thick SILVER rod (a 3D tube) from the shared origin
520// `point_a` out to `point_b`, an ORANGE cone arrowhead at `point_b`, and a
521// single ORANGE origin sphere at the shared start point (deduped across the
522// annotations that share it — a cube's three axis dims share one corner). The
523// geometry is radially symmetric so it needs no camera orientation; it is fed to
524// the `feature-dim-leaders` overlay group as flat triangle buffers. Colors are
525// display sRGB written ~directly by the overlay shader (with a per-face shade for
526// depth), so use hex/255 — no linear conversion.
527
528/// Silver-grey rod shaft color (~0xccced1) — also the angle ARC tube.
529const SHAFT_RGB: [f32; 3] = [0.80, 0.81, 0.82];
530/// Orange cone + origin/handle-sphere color (#F5A623).
531const ORANGE_RGB: [f32; 3] = [0.961, 0.651, 0.137];
532/// Red — the angle gizmo's ZERO-reference (drawn DASHED) radial line.
533const RED_RGB: [f32; 3] = [0.902, 0.157, 0.157];
534/// Green — the angle gizmo's rotation-AXIS line.
535const GREEN_RGB: [f32; 3] = [0.204, 0.808, 0.267];
536
537/// Silver-rod shaft radius, CSS pixels (thick, reads as a 3D rod).
538const SHAFT_RAD_PX: f64 = 2.2;
539/// Arrowhead cone length, CSS pixels.
540const CONE_LEN_PX: f64 = 16.0;
541/// Arrowhead cone base radius, CSS pixels (fuller 3D cone).
542const CONE_RAD_PX: f64 = 6.0;
543/// Origin sphere radius, CSS pixels (medium, screen-constant). Shared with
544/// `EngineState::dimension_origin_pick` so the click hit-radius matches the drawn
545/// sphere exactly.
546pub(crate) const ORIGIN_SPHERE_RAD_PX: f64 = 7.0;
547
548/// The angle gizmo's ARC radius, CSS pixels (screen-constant — matching the previous
549/// `FEATURE_ANGLE_RADIUS_PX`). Shared with the engine so the drawn arc, the chip
550/// anchor (mid-sweep) and the drag hit-search all use the SAME radius.
551pub const ANGLE_ARC_RAD_PX: f64 = 120.0;
552/// The angle gizmo's ref/axis line radius, CSS pixels (thinner than the arc).
553const ANGLE_RAY_RAD_PX: f64 = 1.6;
554/// Degrees of sweep per arc tube segment (tessellation of the arc).
555const ARC_DEG_PER_SEG: f64 = 4.0;
556/// The RED zero-reference line's dash / gap length, CSS pixels.
557const 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
565/// Build the world-space leader geometry for a set of annotations as flat
566/// triangle `(positions, colors)` buffers (9 position + 9 color floats per
567/// triangle), ready to feed the `feature-dim-leaders` overlay group as `tris`.
568/// Normals are omitted — the overlay parser computes a flat face normal per
569/// triangle, and the shader's per-face shade gives the rods/cones/spheres their
570/// 3D read. `world_per_pixel` keeps the rod/cone/sphere screen-constant.
571pub 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    // Draw the shared origin sphere once per distinct start point.
582    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                // The arc VERTEX gets the same orange origin sphere as a linear
594                // dim's `point_a`, so it is a visible mode-toggle target (the arc's
595                // sweep-END orange sphere is the angle DRAG handle, not a toggle).
596                // `add_origin` dedups against a linear origin at the same world
597                // point (a torus's `majorRadius` origin == this center), so no
598                // doubled geometry there.
599                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                // The cone occupies the far end; the rod runs from origin to it.
613                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
623/// Append a PLAIN leader line `a → b` (thin silver rod, NO arrowhead cone / origin
624/// sphere — nothing that reads as grabbable) onto existing `(positions, colors)`
625/// triangle buffers. The NON-dimensional assembly-constraint overlays (coincident /
626/// parallel / …) draw their anchor-to-anchor leaders through this so the line
627/// styling stays in this ONE home (same silver + screen-constant radius as the
628/// dimension rods, slightly thinner because it carries no handle).
629pub 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/// A flat triangle-soup accumulator (positions + per-vertex rgb colors).
646#[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
662/// Push a solid 3D rod (open-ended tube) from `a` to `b` with world `radius`.
663fn 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
690/// Push a filled arrowhead cone: apex at `tip`, base circle of world `radius`
691/// centered at `base` (side facets + a base cap).
692fn 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); // side facet
713        tb.tri(base, cur, prev, rgb); // base cap
714        prev = cur;
715    }
716}
717
718/// Push a filled UV sphere of world `radius` at `center` (flat-shaded facets).
719fn 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
745/// Push the angle gizmo (image-9 target) for an ANGULAR annotation: a light-grey
746/// ARC of screen-constant radius swept from `ref_dir` by `value` degrees about
747/// `axis` at `center`, an ORANGE handle SPHERE at the sweep end with an ORANGE
748/// CONE just past it along the arc tangent, a RED DASHED zero-reference line along
749/// `ref_dir`, and a GREEN line along `axis`. Sizing is screen-constant via
750/// `world_per_pixel` so the gizmo stays a fixed pixel size across zoom.
751fn 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    // A full 360° arc would close on itself; clamp the DRAWN sweep just under it
765    // (matches the overlay's ±359.9 draw clamp) while the chip still shows the
766    // real value.
767    let value = ann.value.clamp(-359.9, 359.9);
768    let value_rad = value.to_radians();
769
770    // The arc: sample from 0 → value and connect consecutive points with grey
771    // tube segments. The point at parameter `t` (radians) is
772    // `center + rotate(start, axis, t) * radius`.
773    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    // The sweep END: the orange handle sphere sits on the arc, the cone points
791    // just past it along the arc tangent (the direction of increasing angle).
792    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    // Tangent = d/dt rotate = axis × dir_end, signed by the sweep direction.
800    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    // RED DASHED zero-reference line from the center out along `ref_dir` to the
811    // arc-start radius (short tube dashes with gaps).
812    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    // GREEN rotation-axis line through the center (a solid tube, both sides).
820    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
834/// Push a DASHED line `a → b` as a series of short solid tube segments (dash then
835/// gap, in screen-constant CSS px). Used for the angle gizmo's red zero-reference.
836fn 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
862/// The world-space chip anchor for an ANGULAR annotation: the arc mid-sweep point
863/// at the screen-constant radius (`center + rotate(ref_dir, axis, value/2) *
864/// radius`) — i.e. `labelAnchor = vertex + bisector * radius`. Camera-dependent
865/// (via `world_per_pixel`), so the engine computes it per frame.
866pub 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
877/// Screen-px hit radius for grabbing a dimension arrowHEAD — the cone base plus a
878/// little slack, so a click near the drawn arrowhead reliably grabs it. Shared by
879/// `EngineState::dimension_arrow_pick`.
880// Grab tolerance around a dimension arrowhead. Generous on purpose: egui only
881// reports a DRAG once the pointer has already moved a few px past the press, so a
882// tight radius makes the arrow feel un-grabbable. This gives a comfortable target.
883pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
884
885/// The world-space arrowHEAD handle point of an annotation — the drag grab target
886/// (`EngineState::dimension_arrow_pick`). LINEAR: the orange cone tip at
887/// `point_b`. ANGULAR: the orange sweep-END handle sphere on the arc (`center +
888/// rotate(ref_dir, axis, value°) * radius`, value clamped to the drawn ±359.9° so
889/// the grab point matches the drawn handle). Camera-dependent for angular (via
890/// `world_per_pixel`), so it is computed per frame.
891pub(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
910// --- small vec3 helpers (self-contained; the leader geometry is pure) --------
911
912fn 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
920/// Normalize `v`, or return `fallback` if `v` is ~zero-length.
921fn 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
930/// A stable unit vector ⟂ `direction` (a port of the previous app's arbitrary-perpendicular helper).
931fn 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
951/// Rotate `v` by `angle` radians about unit `axis` (Rodrigues). Shared with the
952/// engine's angular drag/chip anchoring.
953pub 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
977/// A radially-symmetric perpendicular basis `(u, v)` for a unit `dir`.
978fn 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    // The plane offset dim runs from the un-offset base (origin − normal·offset)
1023    // to the current plane (origin), keyed `offset_distance` with the signed value.
1024    #[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            &params,
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]); // base = origin − n·offset
1036        assert_eq!(anns[0].point_b, [0.0, 0.0, 5.0]); // handle = origin
1037    }
1038
1039    // A NEGATIVE offset places the plane on the −normal side; the dim still runs
1040    // from the base to the current plane (the drag keeps the sign).
1041    #[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            &params,
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    // At offset ≈ 0 the leader would collapse (undraggable); a +normal stub keeps
1055    // it a real, draggable leader while the value stays 0.
1056    #[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            &params,
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    // No resolved plane frame (unresolved / not yet run) → no dim, gracefully.
1070    #[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", &params, &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", &params);
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        // sizeX runs along +X from the origin corner.
1090        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", &params);
1106        // The origin corner is translated; lengths are unchanged.
1107        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", &params);
1124        // Local +X (sizeX) rotates onto world +Y.
1125        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        // Lengths preserved under rotation.
1130        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", &params);
1144        // World length is scaled by 2, but the reported param value is still 10.
1145        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", &params);
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        // Height runs along +Y, radius along +X.
1162        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", &params);
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        // The top radius dim is anchored at y = height.
1180        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", &params);
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", &params);
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        // Centered on the axis: base at y = -h/2, apex at y = +h/2.
1205        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", &params).is_empty());
1213        assert!(build_annotations("BOOLEAN", &params).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", &params);
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        // Triangle soup: 3 position + 3 color floats per vertex, and the count is
1232        // a whole number of triangles.
1233        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        // Both the silver shaft and the orange cone/sphere colors are present.
1237        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        // A cube's three axis dims all start at the same corner: the origin sphere
1248        // is deduped, so exactly one sphere's worth of extra tris appears vs a
1249        // single-annotation build sharing that origin.
1250        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    // --- FD-2: torus / extrude / revolve + the angle gizmo --------------------
1265
1266    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", &params);
1281        assert_eq!(anns.len(), 3);
1282        // majorRadius + tubeRadius are LINEAR; arc is ANGULAR.
1283        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        // tubeRadius is anchored at the tube centerline (majorRadius along +X).
1290        assert!((anns[1].point_a[0] - 5.0).abs() < 1e-9);
1291        // arc: angular about local +Y from the +X reference, value in degrees.
1292        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", &params);
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", &params, &refs);
1319        assert_eq!(anns.len(), 2);
1320        assert_eq!(anns[0].field_key, "distance");
1321        assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1322        // The distance leader runs from the profile center along +normal (+Z) by 10.
1323        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        // The back leg runs along -normal by distanceBack.
1327        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        // Axis = +Z through the origin; profile off +X. Expect one angular dim of
1334        // the given degrees, about +Z, zeroed on the radial (+X) to the profile.
1335        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", &params, &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        // The vertex is the axis point nearest the profile (the origin here).
1352        assert!(norm3(a.center) < 1e-9, "vertex on axis: {:?}", a.center);
1353    }
1354
1355    #[test]
1356    fn revolve_axis_orients_toward_profile_front() {
1357        // Flipping the profile normal flips which of the two axis directions the
1358        // oriented sweep uses (the `orient_revolve_axis` sign test).
1359        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", &params, &base);
1372        let b = build_annotations_with_refs("R", &params, &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", &params);
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        // The angle gizmo contributes all four colors: grey arc + orange
1396        // cone/handle + red dashed reference + green axis.
1397        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        // A 180° arc about +Z from +X: the mid-sweep bisector is +Y, so the chip
1406        // anchors at center + (+Y) * radius (radius = 120px * world_per_pixel).
1407        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}