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 (see
154/// `EngineState::feature_dimension_refs`) and hands them in. All fields are
155/// optional: a builder that can't source what it needs returns `[]` gracefully.
156#[derive(Clone, Debug, Default)]
157pub struct ResolvedRefs {
158    /// Extrude/revolve: the profile CENTER (world centroid of the outer loop) —
159    /// the anchor the distance/angle gizmo hangs off, matching the previous app's
160    /// resolved profile-reference-geometry center.
161    pub profile_center: Option<[f64; 3]>,
162    /// Extrude/revolve: the profile plane NORMAL (unit) — the sketch `+z` basis,
163    /// the authoritative sweep/revolve-orientation direction.
164    pub profile_normal: Option<[f64; 3]>,
165    /// Revolve: a point on the resolved axis LINE (world).
166    pub axis_point: Option<[f64; 3]>,
167    /// Revolve: the resolved axis line DIRECTION (unit, UNORIENTED — the builder
168    /// orients it toward the profile front via the `orient_revolve_axis` port).
169    pub axis_dir: Option<[f64; 3]>,
170    /// Plane (`P`): the resolved plane frame's ORIGIN (world) — the plane AFTER its
171    /// `offset_distance`. The offset dim's un-offset base is `origin − normal·offset`.
172    pub plane_origin: Option<[f64; 3]>,
173    /// Plane (`P`): the resolved plane frame's unit NORMAL (its z-axis) — the axis
174    /// the signed offset dim runs along.
175    pub plane_normal: Option<[f64; 3]>,
176    /// Plane (`P`): a small world length for the offset dim's handle stub when the
177    /// offset is ~0 (a zero-length leader can't be dragged). Screen-constant so the
178    /// handle is a consistent size; ignored once the offset is non-zero.
179    pub plane_dim_length: Option<f64>,
180}
181
182/// Build the linear dimension annotations for a feature `type` from its
183/// `input_params`. Dispatches on the primitive-solid type; returns `[]` for any
184/// type without an FD-1 builder (extrude / revolve / booleans / etc.).
185///
186/// `input_params` should already have its numeric fields resolved to numbers
187/// (the engine resolves expression strings against the history env before
188/// calling this — see `EngineState::feature_dimension_annotations`), but plain
189/// numeric strings are tolerated here too so the pure geometry stays testable.
190pub fn build_annotations(feature_type: &str, input_params: &Value) -> Vec<FeatureDimAnnotation> {
191    build_annotations_with_refs(feature_type, input_params, &ResolvedRefs::default())
192}
193
194/// Build the dimension annotations for a feature, given any resolved scene
195/// references (`ResolvedRefs`) it needs. The primitive-solid + torus builders
196/// ignore `resolved` (pure params + the baked transform); extrude/revolve read
197/// the resolved profile plane / axis line and return `[]` when it is
198/// unavailable (a face profile, an unresolved reference — see the builders).
199pub 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        // The sphere feature type is `P.S` (the dispatch keys on `P.S`).
210        "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
220/// Cube (`P.CU`): minimum corner at the local origin, extending `+sizeX/Y/Z`
221/// (matches the kernel's `make_box_brep((0,0,0), …)`). Three linear dims from the
222/// origin corner along each axis.
223fn 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
238/// Cylinder (`P.CY`): axis is local `+Y`, base at `y=0`, top at `y=height`;
239/// radius along local `+X`. Two dims: radius (base → radial) + height (base → top).
240fn 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
252/// Cone (`P.CO`): base radius at `y=0`, top radius at `y=height` (both along
253/// local `+X`), axis along `+Y`. Three dims: radiusBottom / radiusTop / height.
254fn 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
269/// Sphere (`P.S`): one radial dim from the center along local `+X`.
270fn 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
277/// Pyramid (`P.PY`): centered on its axis — base at `y = -h/2`, apex at `y = h/2`,
278/// base edge spanning `±side/2` in local X (at `z = -side/2`). Two dims: the base
279/// side edge + the axial height.
280fn 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
296/// Torus (`P.T`): centered on its axis (local `+Y`) at the origin; the tube
297/// circle lies in the local X/Y plane centered at `(majorRadius, 0, 0)`, revolved
298/// `arc` DEGREES about `+Y`. Two LINEAR dims — `majorRadius` (center → the tube
299/// centerline along `+X`, `R`) and `tubeRadius` (the centerline → the outer wall,
300/// `r`) — plus the `arc` sweep as an ANGULAR dim about the local `+Y` axis from
301/// the `+X` reference. Ported verbatim from the previous torus-annotation builder.
302fn 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    // The axis / reference are the transformed local +Y / +X (subtract the
311    // transformed origin then normalize, so translation cancels; a non-uniform
312    // scale skews them — normalization handles magnitude).
313    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
323/// Extrude (`E`): a LINEAR distance dim from the profile CENTER along the profile
324/// plane NORMAL by `distance` (`D`), plus the two-sided `distanceBack` leg along
325/// `-normal` (`Db`). Needs the resolved profile center + normal (a sketch profile
326/// — a resident-face profile is not sourced, so those extrudes get no dim). Ported
327/// from the previous extrude-annotation builder.
328fn 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
351/// Plane (`P`): one SIGNED LINEAR offset dim along the plane normal, driving
352/// `offset_distance`. The plane sits on either side of its base orientation/
353/// reference plane, so the dim runs from the un-offset base
354/// (`origin − normal·offset`) to the current plane (`origin`) and is dragged
355/// through zero to flip sign (the drag preserves sign; the kernel accepts a
356/// negative `offset_distance`). At offset ≈ 0 the leader collapses, so a small
357/// `+normal` stub (`plane_dim_length`, screen-constant) keeps it draggable — the
358/// value mapping stays 1:1 there. No resolved plane frame → `[]`.
359fn 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    // The un-offset base plane point — the dim's zero anchor.
365    let base = [
366        origin[0] - normal[0] * offset,
367        origin[1] - normal[1] * offset,
368        origin[2] - normal[2] * offset,
369    ];
370    // Handle at the current offset; at ~0 use a small +normal stub so the leader
371    // has a direction (the drag no-ops on a zero-length axis).
372    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
391/// Revolve (`R`): one ANGULAR dim = `angle` DEGREES swept about the resolved axis
392/// line, oriented toward the profile front (the `orient_revolve_axis` port — so
393/// the arc rotates the SAME way the solid does), centered at the axis point
394/// nearest the profile, zeroed on the radial from that vertex to the profile.
395/// Needs the resolved axis line + profile; returns `[]` otherwise. Ported from
396/// the previous revolve-annotation builder.
397fn 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    // The radial from the vertex to the profile (projected ⟂ axis) is the zero
407    // reference; the `angular` ctor re-projects + falls back if it degenerates.
408    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
413/// The signed revolve axis native Revolve uses: the profile's outward normal
414/// selects between the two directions of an unoriented axis edge. Port of
415/// `resolveOrientedRevolveAxisDirection`.
416pub(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    // radial = (profileCenter - axisPoint) projected ⟂ axis.
431    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
445/// The point on line `(line_point, line_dir)` closest to `point`.
446pub(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
460/// Clamp a degree value to `[-360, 360]`.
461fn clamp_deg(v: f64) -> f64 {
462    v.clamp(-360.0, 360.0)
463}
464
465/// Apply a feature's `inputParams.transform` (TRS, `rotationEuler` in DEGREES,
466/// `M = T·R·S`) to a LOCAL point → WORLD. Mirrors composing that degree-based TRS
467/// matrix and applying it to a point: `world = position + R·(scale ⊙ local)`, with `R` the exact
468/// intrinsic XYZ Euler order matrix the kernel bake uses (`rotate_euler_xyz_f64`).
469pub(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
487/// Read a `[x, y, z]` from a `transform` sub-field (numbers only; a missing /
488/// short array keeps the per-index default).
489fn 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
502/// Read `params[key]` as a finite number: a JSON number, else a plain numeric
503/// string (e.g. `"12.5"`). Non-numeric / expression strings resolve to `0.0`
504/// (the engine pre-resolves expressions before building, so this is only the
505/// pure-geometry fallback). Mirrors the numeric input-param resolution fallback.
506fn 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
514// --- restyled leader geometry (matches the reference dimension-arrows image) --
515//
516// Each annotation draws a thick SILVER rod (a 3D tube) from the shared origin
517// `point_a` out to `point_b`, an ORANGE cone arrowhead at `point_b`, and a
518// single ORANGE origin sphere at the shared start point (deduped across the
519// annotations that share it — a cube's three axis dims share one corner). The
520// geometry is radially symmetric so it needs no camera orientation; it is fed to
521// the `feature-dim-leaders` overlay group as flat triangle buffers. Colors are
522// display sRGB written ~directly by the overlay shader (with a per-face shade for
523// depth), so use hex/255 — no linear conversion.
524
525/// Silver-grey rod shaft color (~0xccced1) — also the angle ARC tube.
526const SHAFT_RGB: [f32; 3] = [0.80, 0.81, 0.82];
527/// Orange cone + origin/handle-sphere color (#F5A623).
528const ORANGE_RGB: [f32; 3] = [0.961, 0.651, 0.137];
529/// Red — the angle gizmo's ZERO-reference (drawn DASHED) radial line.
530const RED_RGB: [f32; 3] = [0.902, 0.157, 0.157];
531/// Green — the angle gizmo's rotation-AXIS line.
532const GREEN_RGB: [f32; 3] = [0.204, 0.808, 0.267];
533
534/// Silver-rod shaft radius, CSS pixels (thick, reads as a 3D rod).
535const SHAFT_RAD_PX: f64 = 2.2;
536/// Arrowhead cone length, CSS pixels.
537const CONE_LEN_PX: f64 = 16.0;
538/// Arrowhead cone base radius, CSS pixels (fuller 3D cone).
539const CONE_RAD_PX: f64 = 6.0;
540/// Origin sphere radius, CSS pixels (medium, screen-constant). Shared with
541/// `EngineState::dimension_origin_pick` so the click hit-radius matches the drawn
542/// sphere exactly.
543pub(crate) const ORIGIN_SPHERE_RAD_PX: f64 = 7.0;
544
545/// The angle gizmo's ARC radius, CSS pixels (screen-constant — matching the previous
546/// `FEATURE_ANGLE_RADIUS_PX`). Shared with the engine so the drawn arc, the chip
547/// anchor (mid-sweep) and the drag hit-search all use the SAME radius.
548pub const ANGLE_ARC_RAD_PX: f64 = 120.0;
549/// The angle gizmo's ref/axis line radius, CSS pixels (thinner than the arc).
550const ANGLE_RAY_RAD_PX: f64 = 1.6;
551/// Degrees of sweep per arc tube segment (tessellation of the arc).
552const ARC_DEG_PER_SEG: f64 = 4.0;
553/// The RED zero-reference line's dash / gap length, CSS pixels.
554const 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
562/// Build the world-space leader geometry for a set of annotations as flat
563/// triangle `(positions, colors)` buffers (9 position + 9 color floats per
564/// triangle), ready to feed the `feature-dim-leaders` overlay group as `tris`.
565/// Normals are omitted — the overlay parser computes a flat face normal per
566/// triangle, and the shader's per-face shade gives the rods/cones/spheres their
567/// 3D read. `world_per_pixel` keeps the rod/cone/sphere screen-constant.
568pub 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    // Draw the shared origin sphere once per distinct start point.
579    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                // The arc VERTEX gets the same orange origin sphere as a linear
591                // dim's `point_a`, so it is a visible mode-toggle target (the arc's
592                // sweep-END orange sphere is the angle DRAG handle, not a toggle).
593                // `add_origin` dedups against a linear origin at the same world
594                // point (a torus's `majorRadius` origin == this center), so no
595                // doubled geometry there.
596                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                // The cone occupies the far end; the rod runs from origin to it.
610                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
620/// Append a PLAIN leader line `a → b` (thin silver rod, NO arrowhead cone / origin
621/// sphere — nothing that reads as grabbable) onto existing `(positions, colors)`
622/// triangle buffers. The NON-dimensional assembly-constraint overlays (coincident /
623/// parallel / …) draw their anchor-to-anchor leaders through this so the line
624/// styling stays in this ONE home (same silver + screen-constant radius as the
625/// dimension rods, slightly thinner because it carries no handle).
626pub 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/// A flat triangle-soup accumulator (positions + per-vertex rgb colors).
643#[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
659/// Push a solid 3D rod (open-ended tube) from `a` to `b` with world `radius`.
660fn 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
687/// Push a filled arrowhead cone: apex at `tip`, base circle of world `radius`
688/// centered at `base` (side facets + a base cap).
689fn 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); // side facet
710        tb.tri(base, cur, prev, rgb); // base cap
711        prev = cur;
712    }
713}
714
715/// Push a filled UV sphere of world `radius` at `center` (flat-shaded facets).
716fn 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
742/// Push the angle gizmo (image-9 target) for an ANGULAR annotation: a light-grey
743/// ARC of screen-constant radius swept from `ref_dir` by `value` degrees about
744/// `axis` at `center`, an ORANGE handle SPHERE at the sweep end with an ORANGE
745/// CONE just past it along the arc tangent, a RED DASHED zero-reference line along
746/// `ref_dir`, and a GREEN line along `axis`. Sizing is screen-constant via
747/// `world_per_pixel` so the gizmo stays a fixed pixel size across zoom.
748fn 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    // A full 360° arc would close on itself; clamp the DRAWN sweep just under it
762    // (matches the overlay's ±359.9 draw clamp) while the chip still shows the
763    // real value.
764    let value = ann.value.clamp(-359.9, 359.9);
765    let value_rad = value.to_radians();
766
767    // The arc: sample from 0 → value and connect consecutive points with grey
768    // tube segments. The point at parameter `t` (radians) is
769    // `center + rotate(start, axis, t) * radius`.
770    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    // The sweep END: the orange handle sphere sits on the arc, the cone points
788    // just past it along the arc tangent (the direction of increasing angle).
789    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    // Tangent = d/dt rotate = axis × dir_end, signed by the sweep direction.
797    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    // RED DASHED zero-reference line from the center out along `ref_dir` to the
808    // arc-start radius (short tube dashes with gaps).
809    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    // GREEN rotation-axis line through the center (a solid tube, both sides).
817    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
831/// Push a DASHED line `a → b` as a series of short solid tube segments (dash then
832/// gap, in screen-constant CSS px). Used for the angle gizmo's red zero-reference.
833fn 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
859/// The world-space chip anchor for an ANGULAR annotation: the arc mid-sweep point
860/// at the screen-constant radius (`center + rotate(ref_dir, axis, value/2) *
861/// radius`) — i.e. `labelAnchor = vertex + bisector * radius`. Camera-dependent
862/// (via `world_per_pixel`), so the engine computes it per frame.
863pub 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
874/// Screen-px hit radius for grabbing a dimension arrowHEAD — the cone base plus a
875/// little slack, so a click near the drawn arrowhead reliably grabs it. Shared by
876/// `EngineState::dimension_arrow_pick`.
877// Grab tolerance around a dimension arrowhead. Generous on purpose: egui only
878// reports a DRAG once the pointer has already moved a few px past the press, so a
879// tight radius makes the arrow feel un-grabbable. This gives a comfortable target.
880pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
881
882/// The world-space arrowHEAD handle point of an annotation — the drag grab target
883/// (`EngineState::dimension_arrow_pick`). LINEAR: the orange cone tip at
884/// `point_b`. ANGULAR: the orange sweep-END handle sphere on the arc (`center +
885/// rotate(ref_dir, axis, value°) * radius`, value clamped to the drawn ±359.9° so
886/// the grab point matches the drawn handle). Camera-dependent for angular (via
887/// `world_per_pixel`), so it is computed per frame.
888pub(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
907// --- small vec3 helpers (self-contained; the leader geometry is pure) --------
908
909fn 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
917/// Normalize `v`, or return `fallback` if `v` is ~zero-length.
918fn 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
927/// A stable unit vector ⟂ `direction` (a port of the previous app's arbitrary-perpendicular helper).
928fn 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
948/// Rotate `v` by `angle` radians about unit `axis` (Rodrigues). Shared with the
949/// engine's angular drag/chip anchoring.
950pub 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
974/// A radially-symmetric perpendicular basis `(u, v)` for a unit `dir`.
975fn 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    // The plane offset dim runs from the un-offset base (origin − normal·offset)
1020    // to the current plane (origin), keyed `offset_distance` with the signed value.
1021    #[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            &params,
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]); // base = origin − n·offset
1033        assert_eq!(anns[0].point_b, [0.0, 0.0, 5.0]); // handle = origin
1034    }
1035
1036    // A NEGATIVE offset places the plane on the −normal side; the dim still runs
1037    // from the base to the current plane (the drag keeps the sign).
1038    #[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            &params,
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    // At offset ≈ 0 the leader would collapse (undraggable); a +normal stub keeps
1052    // it a real, draggable leader while the value stays 0.
1053    #[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            &params,
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    // No resolved plane frame (unresolved / not yet run) → no dim, gracefully.
1067    #[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", &params, &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", &params);
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        // sizeX runs along +X from the origin corner.
1087        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", &params);
1103        // The origin corner is translated; lengths are unchanged.
1104        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", &params);
1121        // Local +X (sizeX) rotates onto world +Y.
1122        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        // Lengths preserved under rotation.
1127        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", &params);
1141        // World length is scaled by 2, but the reported param value is still 10.
1142        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", &params);
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        // Height runs along +Y, radius along +X.
1159        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", &params);
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        // The top radius dim is anchored at y = height.
1177        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", &params);
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", &params);
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        // Centered on the axis: base at y = -h/2, apex at y = +h/2.
1202        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", &params).is_empty());
1210        assert!(build_annotations("BOOLEAN", &params).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", &params);
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        // Triangle soup: 3 position + 3 color floats per vertex, and the count is
1229        // a whole number of triangles.
1230        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        // Both the silver shaft and the orange cone/sphere colors are present.
1234        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        // A cube's three axis dims all start at the same corner: the origin sphere
1245        // is deduped, so exactly one sphere's worth of extra tris appears vs a
1246        // single-annotation build sharing that origin.
1247        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    // --- FD-2: torus / extrude / revolve + the angle gizmo --------------------
1262
1263    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", &params);
1278        assert_eq!(anns.len(), 3);
1279        // majorRadius + tubeRadius are LINEAR; arc is ANGULAR.
1280        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        // tubeRadius is anchored at the tube centerline (majorRadius along +X).
1287        assert!((anns[1].point_a[0] - 5.0).abs() < 1e-9);
1288        // arc: angular about local +Y from the +X reference, value in degrees.
1289        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", &params);
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", &params, &refs);
1316        assert_eq!(anns.len(), 2);
1317        assert_eq!(anns[0].field_key, "distance");
1318        assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1319        // The distance leader runs from the profile center along +normal (+Z) by 10.
1320        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        // The back leg runs along -normal by distanceBack.
1324        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        // Axis = +Z through the origin; profile off +X. Expect one angular dim of
1331        // the given degrees, about +Z, zeroed on the radial (+X) to the profile.
1332        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", &params, &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        // The vertex is the axis point nearest the profile (the origin here).
1349        assert!(norm3(a.center) < 1e-9, "vertex on axis: {:?}", a.center);
1350    }
1351
1352    #[test]
1353    fn revolve_axis_orients_toward_profile_front() {
1354        // Flipping the profile normal flips which of the two axis directions the
1355        // oriented sweep uses (the `orient_revolve_axis` sign test).
1356        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", &params, &base);
1369        let b = build_annotations_with_refs("R", &params, &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", &params);
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        // The angle gizmo contributes all four colors: grey arc + orange
1393        // cone/handle + red dashed reference + green axis.
1394        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        // A 180° arc about +Z from +X: the mid-sweep bisector is +Y, so the chip
1403        // anchors at center + (+Y) * radius (radius = 120px * world_per_pixel).
1404        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}