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 only: the rotation AXIS (unit) the arc sweeps about. `[0;3]` for
67    /// a linear annotation.
68    pub axis: [f64; 3],
69    /// ANGULAR only: the sweep's ZERO reference (unit, in the plane ⟂ `axis`).
70    /// `[0;3]` for a linear annotation.
71    pub ref_dir: [f64; 3],
72}
73
74impl FeatureDimAnnotation {
75    fn linear(field_key: &str, a: [f64; 3], b: [f64; 3], value: f64, label: &str) -> Self {
76        Self {
77            field_key: field_key.to_string(),
78            point_a: a,
79            point_b: b,
80            value,
81            label: label.to_string(),
82            kind: FeatureDimKind::Linear,
83            center: [0.0; 3],
84            axis: [0.0; 3],
85            ref_dir: [0.0; 3],
86        }
87    }
88
89    /// An angular annotation: `value` DEGREES swept about `axis` from `ref_dir`,
90    /// centered at `center`. `axis` is normalized and `ref_dir` is projected into
91    /// the plane ⟂ `axis` then normalized (mirrors the overlay `#createAngle`
92    /// pre-processing). A degenerate axis/ref falls back to an arbitrary basis so
93    /// the annotation is always renderable.
94    fn angular(
95        field_key: &str,
96        center: [f64; 3],
97        axis: [f64; 3],
98        ref_dir: [f64; 3],
99        value: f64,
100        label: &str,
101    ) -> Self {
102        let axis = normalize_or(axis, [0.0, 1.0, 0.0]);
103        // Project the reference into the plane ⟂ axis, then normalize.
104        let d = dot3(ref_dir, axis);
105        let planar = [
106            ref_dir[0] - axis[0] * d,
107            ref_dir[1] - axis[1] * d,
108            ref_dir[2] - axis[2] * d,
109        ];
110        let ref_dir = if norm3(planar) <= 1e-9 {
111            arbitrary_perpendicular(axis)
112        } else {
113            normalize_or(planar, arbitrary_perpendicular(axis))
114        };
115        Self {
116            field_key: field_key.to_string(),
117            point_a: center,
118            point_b: center,
119            value,
120            label: label.to_string(),
121            kind: FeatureDimKind::Angular,
122            center,
123            axis,
124            ref_dir,
125        }
126    }
127
128    /// The world-space midpoint of the leader — where the app anchors a LINEAR
129    /// label. (Angular chips anchor at the arc mid-sweep, computed engine-side
130    /// with the camera's `world_per_pixel`.)
131    pub fn midpoint(&self) -> [f64; 3] {
132        [
133            (self.point_a[0] + self.point_b[0]) * 0.5,
134            (self.point_a[1] + self.point_b[1]) * 0.5,
135            (self.point_a[2] + self.point_b[2]) * 0.5,
136        ]
137    }
138}
139
140/// Resolved scene geometry an annotation builder needs beyond the pure
141/// `inputParams` — the profile plane (extrude/revolve) and the axis line
142/// (revolve). These are NOT pure params (they resolve scene references), so the
143/// engine resolves them from the run report's sketch profiles / axes (see
144/// `EngineState::feature_dimension_refs`) and hands them in. All fields are
145/// optional: a builder that can't source what it needs returns `[]` gracefully.
146#[derive(Clone, Debug, Default)]
147pub struct ResolvedRefs {
148    /// Extrude/revolve: the profile CENTER (world centroid of the outer loop) —
149    /// the anchor the distance/angle gizmo hangs off, matching the previous app's
150    /// resolved profile-reference-geometry center.
151    pub profile_center: Option<[f64; 3]>,
152    /// Extrude/revolve: the profile plane NORMAL (unit) — the sketch `+z` basis,
153    /// the authoritative sweep/revolve-orientation direction.
154    pub profile_normal: Option<[f64; 3]>,
155    /// Revolve: a point on the resolved axis LINE (world).
156    pub axis_point: Option<[f64; 3]>,
157    /// Revolve: the resolved axis line DIRECTION (unit, UNORIENTED — the builder
158    /// orients it toward the profile front via the `orient_revolve_axis` port).
159    pub axis_dir: Option<[f64; 3]>,
160}
161
162/// Build the linear dimension annotations for a feature `type` from its
163/// `input_params`. Dispatches on the primitive-solid type; returns `[]` for any
164/// type without an FD-1 builder (extrude / revolve / booleans / etc.).
165///
166/// `input_params` should already have its numeric fields resolved to numbers
167/// (the engine resolves expression strings against the history env before
168/// calling this — see `EngineState::feature_dimension_annotations`), but plain
169/// numeric strings are tolerated here too so the pure geometry stays testable.
170pub fn build_annotations(feature_type: &str, input_params: &Value) -> Vec<FeatureDimAnnotation> {
171    build_annotations_with_refs(feature_type, input_params, &ResolvedRefs::default())
172}
173
174/// Build the dimension annotations for a feature, given any resolved scene
175/// references (`ResolvedRefs`) it needs. The primitive-solid + torus builders
176/// ignore `resolved` (pure params + the baked transform); extrude/revolve read
177/// the resolved profile plane / axis line and return `[]` when it is
178/// unavailable (a face profile, an unresolved reference — see the builders).
179pub fn build_annotations_with_refs(
180    feature_type: &str,
181    input_params: &Value,
182    resolved: &ResolvedRefs,
183) -> Vec<FeatureDimAnnotation> {
184    let transform = input_params.get("transform");
185    match feature_type {
186        "P.CU" => build_cube(input_params, transform),
187        "P.CY" => build_cylinder(input_params, transform),
188        "P.CO" => build_cone(input_params, transform),
189        // The sphere feature type is `P.S` (the dispatch keys on `P.S`).
190        "P.S" | "P.SP" => build_sphere(input_params, transform),
191        "P.PY" => build_pyramid(input_params, transform),
192        "P.T" => build_torus(input_params, transform),
193        "E" => build_extrude(input_params, resolved),
194        "R" => build_revolve(input_params, resolved),
195        _ => Vec::new(),
196    }
197}
198
199/// Cube (`P.CU`): minimum corner at the local origin, extending `+sizeX/Y/Z`
200/// (matches the kernel's `make_box_brep((0,0,0), …)`). Three linear dims from the
201/// origin corner along each axis.
202fn build_cube(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
203    let sx = resolve_number(params, "sizeX");
204    let sy = resolve_number(params, "sizeY");
205    let sz = resolve_number(params, "sizeZ");
206    let p0 = transform_point(transform, [0.0, 0.0, 0.0]);
207    let px = transform_point(transform, [sx, 0.0, 0.0]);
208    let py = transform_point(transform, [0.0, sy, 0.0]);
209    let pz = transform_point(transform, [0.0, 0.0, sz]);
210    vec![
211        FeatureDimAnnotation::linear("sizeX", p0, px, sx, "X"),
212        FeatureDimAnnotation::linear("sizeY", p0, py, sy, "Y"),
213        FeatureDimAnnotation::linear("sizeZ", p0, pz, sz, "Z"),
214    ]
215}
216
217/// Cylinder (`P.CY`): axis is local `+Y`, base at `y=0`, top at `y=height`;
218/// radius along local `+X`. Two dims: radius (base → radial) + height (base → top).
219fn build_cylinder(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
220    let radius = resolve_number(params, "radius");
221    let height = resolve_number(params, "height");
222    let base = transform_point(transform, [0.0, 0.0, 0.0]);
223    let top = transform_point(transform, [0.0, height, 0.0]);
224    let radial = transform_point(transform, [radius, 0.0, 0.0]);
225    vec![
226        FeatureDimAnnotation::linear("radius", base, radial, radius, "R"),
227        FeatureDimAnnotation::linear("height", base, top, height, "H"),
228    ]
229}
230
231/// Cone (`P.CO`): base radius at `y=0`, top radius at `y=height` (both along
232/// local `+X`), axis along `+Y`. Three dims: radiusBottom / radiusTop / height.
233fn build_cone(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
234    let radius_top = resolve_number(params, "radiusTop");
235    let radius_bottom = resolve_number(params, "radiusBottom");
236    let height = resolve_number(params, "height");
237    let base_center = transform_point(transform, [0.0, 0.0, 0.0]);
238    let top_center = transform_point(transform, [0.0, height, 0.0]);
239    let base_radius = transform_point(transform, [radius_bottom, 0.0, 0.0]);
240    let top_radius = transform_point(transform, [radius_top, height, 0.0]);
241    vec![
242        FeatureDimAnnotation::linear("radiusBottom", base_center, base_radius, radius_bottom, "Rb"),
243        FeatureDimAnnotation::linear("radiusTop", top_center, top_radius, radius_top, "Rt"),
244        FeatureDimAnnotation::linear("height", base_center, top_center, height, "H"),
245    ]
246}
247
248/// Sphere (`P.S`): one radial dim from the center along local `+X`.
249fn build_sphere(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
250    let radius = resolve_number(params, "radius");
251    let center = transform_point(transform, [0.0, 0.0, 0.0]);
252    let radial = transform_point(transform, [radius, 0.0, 0.0]);
253    vec![FeatureDimAnnotation::linear("radius", center, radial, radius, "R")]
254}
255
256/// Pyramid (`P.PY`): centered on its axis — base at `y = -h/2`, apex at `y = h/2`,
257/// base edge spanning `±side/2` in local X (at `z = -side/2`). Two dims: the base
258/// side edge + the axial height.
259fn build_pyramid(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
260    let side = resolve_number(params, "baseSideLength");
261    let height = resolve_number(params, "height");
262    let half_side = side * 0.5;
263    let base_y = -height * 0.5;
264    let apex_y = height * 0.5;
265    let base_start = transform_point(transform, [-half_side, base_y, -half_side]);
266    let base_end = transform_point(transform, [half_side, base_y, -half_side]);
267    let base_center = transform_point(transform, [0.0, base_y, 0.0]);
268    let apex = transform_point(transform, [0.0, apex_y, 0.0]);
269    vec![
270        FeatureDimAnnotation::linear("baseSideLength", base_start, base_end, side, "Side"),
271        FeatureDimAnnotation::linear("height", base_center, apex, height, "H"),
272    ]
273}
274
275/// Torus (`P.T`): centered on its axis (local `+Y`) at the origin; the tube
276/// circle lies in the local X/Y plane centered at `(majorRadius, 0, 0)`, revolved
277/// `arc` DEGREES about `+Y`. Two LINEAR dims — `majorRadius` (center → the tube
278/// centerline along `+X`, `R`) and `tubeRadius` (the centerline → the outer wall,
279/// `r`) — plus the `arc` sweep as an ANGULAR dim about the local `+Y` axis from
280/// the `+X` reference. Ported verbatim from the previous torus-annotation builder.
281fn build_torus(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
282    let major = resolve_number(params, "majorRadius");
283    let tube = resolve_number(params, "tubeRadius");
284    let arc = clamp_deg(resolve_number(params, "arc"));
285
286    let center = transform_point(transform, [0.0, 0.0, 0.0]);
287    let major_point = transform_point(transform, [major, 0.0, 0.0]);
288    let tube_point = transform_point(transform, [major + tube, 0.0, 0.0]);
289    // The axis / reference are the transformed local +Y / +X (subtract the
290    // transformed origin then normalize, so translation cancels; a non-uniform
291    // scale skews them — normalization handles magnitude).
292    let axis = normalize_or(sub3(transform_point(transform, [0.0, 1.0, 0.0]), center), [0.0, 1.0, 0.0]);
293    let start_dir = sub3(major_point, center);
294
295    vec![
296        FeatureDimAnnotation::linear("majorRadius", center, major_point, major, "R"),
297        FeatureDimAnnotation::linear("tubeRadius", major_point, tube_point, tube, "r"),
298        FeatureDimAnnotation::angular("arc", center, axis, start_dir, arc, "Arc"),
299    ]
300}
301
302/// Extrude (`E`): a LINEAR distance dim from the profile CENTER along the profile
303/// plane NORMAL by `distance` (`D`), plus the two-sided `distanceBack` leg along
304/// `-normal` (`Db`). Needs the resolved profile center + normal (a sketch profile
305/// — a resident-face profile is not sourced, so those extrudes get no dim). Ported
306/// from the previous extrude-annotation builder.
307fn build_extrude(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
308    let (Some(center), Some(normal)) = (resolved.profile_center, resolved.profile_normal) else {
309        return Vec::new();
310    };
311    let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
312    let distance = resolve_number(params, "distance");
313    let back = resolve_number(params, "distanceBack");
314    let forward = [
315        center[0] + normal[0] * distance,
316        center[1] + normal[1] * distance,
317        center[2] + normal[2] * distance,
318    ];
319    let backward = [
320        center[0] - normal[0] * back,
321        center[1] - normal[1] * back,
322        center[2] - normal[2] * back,
323    ];
324    vec![
325        FeatureDimAnnotation::linear("distance", center, forward, distance, "D"),
326        FeatureDimAnnotation::linear("distanceBack", center, backward, back, "Db"),
327    ]
328}
329
330/// Revolve (`R`): one ANGULAR dim = `angle` DEGREES swept about the resolved axis
331/// line, oriented toward the profile front (the `orient_revolve_axis` port — so
332/// the arc rotates the SAME way the solid does), centered at the axis point
333/// nearest the profile, zeroed on the radial from that vertex to the profile.
334/// Needs the resolved axis line + profile; returns `[]` otherwise. Ported from
335/// the previous revolve-annotation builder.
336fn build_revolve(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
337    let (Some(axis_point), Some(axis_dir)) = (resolved.axis_point, resolved.axis_dir) else {
338        return Vec::new();
339    };
340    let Some(profile_center) = resolved.profile_center else {
341        return Vec::new();
342    };
343    let axis = orient_revolve_axis(axis_dir, axis_point, profile_center, resolved.profile_normal);
344    let vertex = closest_point_on_line(profile_center, axis_point, axis);
345    // The radial from the vertex to the profile (projected ⟂ axis) is the zero
346    // reference; the `angular` ctor re-projects + falls back if it degenerates.
347    let start_dir = sub3(profile_center, vertex);
348    let angle = clamp_deg(resolve_number(params, "angle"));
349    vec![FeatureDimAnnotation::angular("angle", vertex, axis, start_dir, angle, "A")]
350}
351
352/// The signed revolve axis native Revolve uses: the profile's outward normal
353/// selects between the two directions of an unoriented axis edge. Port of
354/// `resolveOrientedRevolveAxisDirection`.
355pub(crate) fn orient_revolve_axis(
356    axis_dir: [f64; 3],
357    axis_point: [f64; 3],
358    profile_center: [f64; 3],
359    profile_normal: Option<[f64; 3]>,
360) -> [f64; 3] {
361    let axis = normalize_or(axis_dir, [0.0, 1.0, 0.0]);
362    let Some(normal) = profile_normal else {
363        return axis;
364    };
365    if norm3(normal) <= 1e-12 {
366        return axis;
367    }
368    let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
369    // radial = (profileCenter - axisPoint) projected ⟂ axis.
370    let mut radial = sub3(profile_center, axis_point);
371    let d = dot3(radial, axis);
372    radial = [radial[0] - axis[0] * d, radial[1] - axis[1] * d, radial[2] - axis[2] * d];
373    if norm3(radial) <= 1e-12 {
374        return axis;
375    }
376    let c = cross3(axis, radial);
377    if dot3(c, normal) < 0.0 {
378        [-axis[0], -axis[1], -axis[2]]
379    } else {
380        axis
381    }
382}
383
384/// The point on line `(line_point, line_dir)` closest to `point`.
385pub(crate) fn closest_point_on_line(
386    point: [f64; 3],
387    line_point: [f64; 3],
388    line_dir: [f64; 3],
389) -> [f64; 3] {
390    let dir = normalize_or(line_dir, [0.0, 1.0, 0.0]);
391    let t = dot3(sub3(point, line_point), dir);
392    [
393        line_point[0] + dir[0] * t,
394        line_point[1] + dir[1] * t,
395        line_point[2] + dir[2] * t,
396    ]
397}
398
399/// Clamp a degree value to `[-360, 360]`.
400fn clamp_deg(v: f64) -> f64 {
401    v.clamp(-360.0, 360.0)
402}
403
404/// Apply a feature's `inputParams.transform` (TRS, `rotationEuler` in DEGREES,
405/// `M = T·R·S`) to a LOCAL point → WORLD. Mirrors composing that degree-based TRS
406/// matrix and applying it to a point: `world = position + R·(scale ⊙ local)`, with `R` the exact
407/// intrinsic XYZ Euler order matrix the kernel bake uses (`rotate_euler_xyz_f64`).
408pub(crate) fn transform_point(transform: Option<&Value>, local: [f64; 3]) -> [f64; 3] {
409    let position = read_vec3(transform, "position", [0.0, 0.0, 0.0]);
410    let rotation_deg = read_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]);
411    let scale = read_vec3(transform, "scale", [1.0, 1.0, 1.0]);
412    let scaled = [local[0] * scale[0], local[1] * scale[1], local[2] * scale[2]];
413    let euler = [
414        rotation_deg[0].to_radians(),
415        rotation_deg[1].to_radians(),
416        rotation_deg[2].to_radians(),
417    ];
418    let rotated = rotate_euler_xyz_f64(scaled, euler);
419    [
420        rotated[0] + position[0],
421        rotated[1] + position[1],
422        rotated[2] + position[2],
423    ]
424}
425
426/// Read a `[x, y, z]` from a `transform` sub-field (numbers only; a missing /
427/// short array keeps the per-index default).
428fn read_vec3(transform: Option<&Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
429    let array = transform.and_then(|t| t.get(key)).and_then(Value::as_array);
430    let mut out = default;
431    if let Some(array) = array {
432        for (index, slot) in out.iter_mut().enumerate() {
433            if let Some(number) = array.get(index).and_then(Value::as_f64) {
434                *slot = number;
435            }
436        }
437    }
438    out
439}
440
441/// Read `params[key]` as a finite number: a JSON number, else a plain numeric
442/// string (e.g. `"12.5"`). Non-numeric / expression strings resolve to `0.0`
443/// (the engine pre-resolves expressions before building, so this is only the
444/// pure-geometry fallback). Mirrors the numeric input-param resolution fallback.
445fn resolve_number(params: &Value, key: &str) -> f64 {
446    match params.get(key) {
447        Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).unwrap_or(0.0),
448        Some(Value::String(s)) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
449        _ => 0.0,
450    }
451}
452
453// --- restyled leader geometry (matches the reference dimension-arrows image) --
454//
455// Each annotation draws a thick SILVER rod (a 3D tube) from the shared origin
456// `point_a` out to `point_b`, an ORANGE cone arrowhead at `point_b`, and a
457// single ORANGE origin sphere at the shared start point (deduped across the
458// annotations that share it — a cube's three axis dims share one corner). The
459// geometry is radially symmetric so it needs no camera orientation; it is fed to
460// the `feature-dim-leaders` overlay group as flat triangle buffers. Colors are
461// display sRGB written ~directly by the overlay shader (with a per-face shade for
462// depth), so use hex/255 — no linear conversion.
463
464/// Silver-grey rod shaft color (~0xccced1) — also the angle ARC tube.
465const SHAFT_RGB: [f32; 3] = [0.80, 0.81, 0.82];
466/// Orange cone + origin/handle-sphere color (#F5A623).
467const ORANGE_RGB: [f32; 3] = [0.961, 0.651, 0.137];
468/// Red — the angle gizmo's ZERO-reference (drawn DASHED) radial line.
469const RED_RGB: [f32; 3] = [0.902, 0.157, 0.157];
470/// Green — the angle gizmo's rotation-AXIS line.
471const GREEN_RGB: [f32; 3] = [0.204, 0.808, 0.267];
472
473/// Silver-rod shaft radius, CSS pixels (thick, reads as a 3D rod).
474const SHAFT_RAD_PX: f64 = 2.2;
475/// Arrowhead cone length, CSS pixels.
476const CONE_LEN_PX: f64 = 16.0;
477/// Arrowhead cone base radius, CSS pixels (fuller 3D cone).
478const CONE_RAD_PX: f64 = 6.0;
479/// Origin sphere radius, CSS pixels (medium, screen-constant). Shared with
480/// `EngineState::dimension_origin_pick` so the click hit-radius matches the drawn
481/// sphere exactly.
482pub(crate) const ORIGIN_SPHERE_RAD_PX: f64 = 7.0;
483
484/// The angle gizmo's ARC radius, CSS pixels (screen-constant — matching the previous
485/// `FEATURE_ANGLE_RADIUS_PX`). Shared with the engine so the drawn arc, the chip
486/// anchor (mid-sweep) and the drag hit-search all use the SAME radius.
487pub const ANGLE_ARC_RAD_PX: f64 = 120.0;
488/// The angle gizmo's ref/axis line radius, CSS pixels (thinner than the arc).
489const ANGLE_RAY_RAD_PX: f64 = 1.6;
490/// Degrees of sweep per arc tube segment (tessellation of the arc).
491const ARC_DEG_PER_SEG: f64 = 4.0;
492/// The RED zero-reference line's dash / gap length, CSS pixels.
493const DASH_LEN_PX: f64 = 6.0;
494const DASH_GAP_PX: f64 = 5.0;
495
496const TUBE_SEGMENTS: usize = 8;
497const CONE_SEGMENTS: usize = 16;
498const SPHERE_RINGS: usize = 6;
499const SPHERE_SECTORS: usize = 10;
500
501/// Build the world-space leader geometry for a set of annotations as flat
502/// triangle `(positions, colors)` buffers (9 position + 9 color floats per
503/// triangle), ready to feed the `feature-dim-leaders` overlay group as `tris`.
504/// Normals are omitted — the overlay parser computes a flat face normal per
505/// triangle, and the shader's per-face shade gives the rods/cones/spheres their
506/// 3D read. `world_per_pixel` keeps the rod/cone/sphere screen-constant.
507pub fn leaders_buffers(
508    annotations: &[FeatureDimAnnotation],
509    world_per_pixel: f64,
510) -> (Vec<f32>, Vec<f32>) {
511    let mut tb = TriBuf::default();
512    let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
513    let cone_len = CONE_LEN_PX * world_per_pixel;
514    let cone_rad = CONE_RAD_PX * world_per_pixel;
515    let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
516
517    // Draw the shared origin sphere once per distinct start point.
518    let mut origins: Vec<[f64; 3]> = Vec::new();
519    let mut add_origin = |tb: &mut TriBuf, a: [f64; 3]| {
520        if !origins.iter().any(|o| norm3(sub3(*o, a)) < 1e-6) {
521            push_sphere(tb, a, sphere_rad, ORANGE_RGB);
522            origins.push(a);
523        }
524    };
525
526    for ann in annotations {
527        match ann.kind {
528            FeatureDimKind::Angular => {
529                // The arc VERTEX gets the same orange origin sphere as a linear
530                // dim's `point_a`, so it is a visible mode-toggle target (the arc's
531                // sweep-END orange sphere is the angle DRAG handle, not a toggle).
532                // `add_origin` dedups against a linear origin at the same world
533                // point (a torus's `majorRadius` origin == this center), so no
534                // doubled geometry there.
535                add_origin(&mut tb, ann.center);
536                push_angle_gizmo(&mut tb, ann, world_per_pixel);
537            }
538            FeatureDimKind::Linear => {
539                let a = ann.point_a;
540                let b = ann.point_b;
541                let axis = sub3(b, a);
542                let len = norm3(axis);
543                add_origin(&mut tb, a);
544                if len < 1e-9 {
545                    continue;
546                }
547                let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
548                // The cone occupies the far end; the rod runs from origin to it.
549                let cl = cone_len.min(len * 0.9);
550                let shaft_end = [b[0] - dir[0] * cl, b[1] - dir[1] * cl, b[2] - dir[2] * cl];
551                push_tube(&mut tb, a, shaft_end, shaft_rad, SHAFT_RGB);
552                push_cone(&mut tb, shaft_end, b, cone_rad, ORANGE_RGB);
553            }
554        }
555    }
556    (tb.positions, tb.colors)
557}
558
559/// A flat triangle-soup accumulator (positions + per-vertex rgb colors).
560#[derive(Default)]
561struct TriBuf {
562    positions: Vec<f32>,
563    colors: Vec<f32>,
564}
565
566impl TriBuf {
567    fn tri(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], rgb: [f32; 3]) {
568        for p in [a, b, c] {
569            self.positions
570                .extend_from_slice(&[p[0] as f32, p[1] as f32, p[2] as f32]);
571            self.colors.extend_from_slice(&rgb);
572        }
573    }
574}
575
576/// Push a solid 3D rod (open-ended tube) from `a` to `b` with world `radius`.
577fn push_tube(tb: &mut TriBuf, a: [f64; 3], b: [f64; 3], radius: f64, rgb: [f32; 3]) {
578    let axis = sub3(b, a);
579    let len = norm3(axis);
580    if len < 1e-9 || radius <= 0.0 {
581        return;
582    }
583    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
584    let (u, v) = axis_basis(dir);
585    let ring = |center: [f64; 3], k: usize| -> [f64; 3] {
586        let ang = (k as f64 / TUBE_SEGMENTS as f64) * std::f64::consts::TAU;
587        let (c, s) = (ang.cos() * radius, ang.sin() * radius);
588        [
589            center[0] + u[0] * c + v[0] * s,
590            center[1] + u[1] * c + v[1] * s,
591            center[2] + u[2] * c + v[2] * s,
592        ]
593    };
594    for k in 0..TUBE_SEGMENTS {
595        let a0 = ring(a, k);
596        let a1 = ring(a, k + 1);
597        let b0 = ring(b, k);
598        let b1 = ring(b, k + 1);
599        tb.tri(a0, b0, b1, rgb);
600        tb.tri(a0, b1, a1, rgb);
601    }
602}
603
604/// Push a filled arrowhead cone: apex at `tip`, base circle of world `radius`
605/// centered at `base` (side facets + a base cap).
606fn push_cone(tb: &mut TriBuf, base: [f64; 3], tip: [f64; 3], radius: f64, rgb: [f32; 3]) {
607    let axis = sub3(tip, base);
608    let len = norm3(axis);
609    if len < 1e-9 || radius <= 0.0 {
610        return;
611    }
612    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
613    let (u, v) = axis_basis(dir);
614    let ring = |k: usize| -> [f64; 3] {
615        let ang = (k as f64 / CONE_SEGMENTS as f64) * std::f64::consts::TAU;
616        let (c, s) = (ang.cos() * radius, ang.sin() * radius);
617        [
618            base[0] + u[0] * c + v[0] * s,
619            base[1] + u[1] * c + v[1] * s,
620            base[2] + u[2] * c + v[2] * s,
621        ]
622    };
623    let mut prev = ring(0);
624    for k in 1..=CONE_SEGMENTS {
625        let cur = ring(k);
626        tb.tri(tip, prev, cur, rgb); // side facet
627        tb.tri(base, cur, prev, rgb); // base cap
628        prev = cur;
629    }
630}
631
632/// Push a filled UV sphere of world `radius` at `center` (flat-shaded facets).
633fn push_sphere(tb: &mut TriBuf, center: [f64; 3], radius: f64, rgb: [f32; 3]) {
634    if radius <= 0.0 {
635        return;
636    }
637    let point = |ring: usize, sector: usize| -> [f64; 3] {
638        let lat = std::f64::consts::PI * (ring as f64 / SPHERE_RINGS as f64)
639            - std::f64::consts::FRAC_PI_2;
640        let lon = std::f64::consts::TAU * (sector as f64 / SPHERE_SECTORS as f64);
641        [
642            center[0] + lat.cos() * lon.cos() * radius,
643            center[1] + lat.cos() * lon.sin() * radius,
644            center[2] + lat.sin() * radius,
645        ]
646    };
647    for r in 0..SPHERE_RINGS {
648        for sct in 0..SPHERE_SECTORS {
649            let p00 = point(r, sct);
650            let p01 = point(r, sct + 1);
651            let p10 = point(r + 1, sct);
652            let p11 = point(r + 1, sct + 1);
653            tb.tri(p00, p10, p11, rgb);
654            tb.tri(p00, p11, p01, rgb);
655        }
656    }
657}
658
659/// Push the angle gizmo (image-9 target) for an ANGULAR annotation: a light-grey
660/// ARC of screen-constant radius swept from `ref_dir` by `value` degrees about
661/// `axis` at `center`, an ORANGE handle SPHERE at the sweep end with an ORANGE
662/// CONE just past it along the arc tangent, a RED DASHED zero-reference line along
663/// `ref_dir`, and a GREEN line along `axis`. Sizing is screen-constant via
664/// `world_per_pixel` so the gizmo stays a fixed pixel size across zoom.
665fn push_angle_gizmo(tb: &mut TriBuf, ann: &FeatureDimAnnotation, world_per_pixel: f64) {
666    let center = ann.center;
667    let axis = ann.axis;
668    let start = ann.ref_dir;
669    let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
670    let ray_rad = ANGLE_RAY_RAD_PX * world_per_pixel;
671    let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
672    let cone_len = CONE_LEN_PX * world_per_pixel;
673    let cone_rad = CONE_RAD_PX * world_per_pixel;
674    let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
675    if radius <= 1e-9 {
676        return;
677    }
678    // A full 360° arc would close on itself; clamp the DRAWN sweep just under it
679    // (matches the overlay's ±359.9 draw clamp) while the chip still shows the
680    // real value.
681    let value = ann.value.clamp(-359.9, 359.9);
682    let value_rad = value.to_radians();
683
684    // The arc: sample from 0 → value and connect consecutive points with grey
685    // tube segments. The point at parameter `t` (radians) is
686    // `center + rotate(start, axis, t) * radius`.
687    let arc_point = |t: f64| -> [f64; 3] {
688        let dir = rotate_about_axis(start, axis, t);
689        [
690            center[0] + dir[0] * radius,
691            center[1] + dir[1] * radius,
692            center[2] + dir[2] * radius,
693        ]
694    };
695    let seg_count = ((value.abs() / ARC_DEG_PER_SEG).ceil() as usize).max(2);
696    let mut prev = arc_point(0.0);
697    for k in 1..=seg_count {
698        let t = value_rad * (k as f64 / seg_count as f64);
699        let cur = arc_point(t);
700        push_tube(tb, prev, cur, shaft_rad, SHAFT_RGB);
701        prev = cur;
702    }
703
704    // The sweep END: the orange handle sphere sits on the arc, the cone points
705    // just past it along the arc tangent (the direction of increasing angle).
706    let dir_end = rotate_about_axis(start, axis, value_rad);
707    let end_pt = [
708        center[0] + dir_end[0] * radius,
709        center[1] + dir_end[1] * radius,
710        center[2] + dir_end[2] * radius,
711    ];
712    push_sphere(tb, end_pt, sphere_rad, ORANGE_RGB);
713    // Tangent = d/dt rotate = axis × dir_end, signed by the sweep direction.
714    let sweep_sign = if value < 0.0 { -1.0 } else { 1.0 };
715    let tangent = normalize_or(cross3(axis, dir_end), dir_end);
716    let tangent = [tangent[0] * sweep_sign, tangent[1] * sweep_sign, tangent[2] * sweep_sign];
717    let cone_tip = [
718        end_pt[0] + tangent[0] * cone_len,
719        end_pt[1] + tangent[1] * cone_len,
720        end_pt[2] + tangent[2] * cone_len,
721    ];
722    push_cone(tb, end_pt, cone_tip, cone_rad, ORANGE_RGB);
723
724    // RED DASHED zero-reference line from the center out along `ref_dir` to the
725    // arc-start radius (short tube dashes with gaps).
726    let ref_end = [
727        center[0] + start[0] * radius,
728        center[1] + start[1] * radius,
729        center[2] + start[2] * radius,
730    ];
731    push_dashed(tb, center, ref_end, ray_rad, RED_RGB, world_per_pixel);
732
733    // GREEN rotation-axis line through the center (a solid tube, both sides).
734    let axis_len = radius * 0.7;
735    let axis_a = [
736        center[0] - axis[0] * axis_len,
737        center[1] - axis[1] * axis_len,
738        center[2] - axis[2] * axis_len,
739    ];
740    let axis_b = [
741        center[0] + axis[0] * axis_len,
742        center[1] + axis[1] * axis_len,
743        center[2] + axis[2] * axis_len,
744    ];
745    push_tube(tb, axis_a, axis_b, ray_rad, GREEN_RGB);
746}
747
748/// Push a DASHED line `a → b` as a series of short solid tube segments (dash then
749/// gap, in screen-constant CSS px). Used for the angle gizmo's red zero-reference.
750fn push_dashed(
751    tb: &mut TriBuf,
752    a: [f64; 3],
753    b: [f64; 3],
754    radius: f64,
755    rgb: [f32; 3],
756    world_per_pixel: f64,
757) {
758    let axis = sub3(b, a);
759    let len = norm3(axis);
760    if len < 1e-9 {
761        return;
762    }
763    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
764    let dash = (DASH_LEN_PX * world_per_pixel).max(1e-6);
765    let gap = (DASH_GAP_PX * world_per_pixel).max(1e-6);
766    let mut s = 0.0;
767    while s < len {
768        let e = (s + dash).min(len);
769        let p0 = [a[0] + dir[0] * s, a[1] + dir[1] * s, a[2] + dir[2] * s];
770        let p1 = [a[0] + dir[0] * e, a[1] + dir[1] * e, a[2] + dir[2] * e];
771        push_tube(tb, p0, p1, radius, rgb);
772        s = e + gap;
773    }
774}
775
776/// The world-space chip anchor for an ANGULAR annotation: the arc mid-sweep point
777/// at the screen-constant radius (`center + rotate(ref_dir, axis, value/2) *
778/// radius`) — i.e. `labelAnchor = vertex + bisector * radius`. Camera-dependent
779/// (via `world_per_pixel`), so the engine computes it per frame.
780pub fn angular_chip_anchor(ann: &FeatureDimAnnotation, world_per_pixel: f64) -> [f64; 3] {
781    let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
782    let value = ann.value.clamp(-359.9, 359.9);
783    let bisector = rotate_about_axis(ann.ref_dir, ann.axis, (value * 0.5).to_radians());
784    [
785        ann.center[0] + bisector[0] * radius,
786        ann.center[1] + bisector[1] * radius,
787        ann.center[2] + bisector[2] * radius,
788    ]
789}
790
791/// Screen-px hit radius for grabbing a dimension arrowHEAD — the cone base plus a
792/// little slack, so a click near the drawn arrowhead reliably grabs it. Shared by
793/// `EngineState::dimension_arrow_pick`.
794// Grab tolerance around a dimension arrowhead. Generous on purpose: egui only
795// reports a DRAG once the pointer has already moved a few px past the press, so a
796// tight radius makes the arrow feel un-grabbable. This gives a comfortable target.
797pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
798
799/// The world-space arrowHEAD handle point of an annotation — the drag grab target
800/// (`EngineState::dimension_arrow_pick`). LINEAR: the orange cone tip at
801/// `point_b`. ANGULAR: the orange sweep-END handle sphere on the arc (`center +
802/// rotate(ref_dir, axis, value°) * radius`, value clamped to the drawn ±359.9° so
803/// the grab point matches the drawn handle). Camera-dependent for angular (via
804/// `world_per_pixel`), so it is computed per frame.
805pub(crate) fn arrow_handle_point(
806    ann: &FeatureDimAnnotation,
807    world_per_pixel: f64,
808) -> [f64; 3] {
809    match ann.kind {
810        FeatureDimKind::Linear => ann.point_b,
811        FeatureDimKind::Angular => {
812            let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
813            let value = ann.value.clamp(-359.9, 359.9);
814            let dir = rotate_about_axis(ann.ref_dir, ann.axis, value.to_radians());
815            [
816                ann.center[0] + dir[0] * radius,
817                ann.center[1] + dir[1] * radius,
818                ann.center[2] + dir[2] * radius,
819            ]
820        }
821    }
822}
823
824// --- small vec3 helpers (self-contained; the leader geometry is pure) --------
825
826fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
827    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
828}
829
830fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
831    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
832}
833
834/// Normalize `v`, or return `fallback` if `v` is ~zero-length.
835fn normalize_or(v: [f64; 3], fallback: [f64; 3]) -> [f64; 3] {
836    let n = norm3(v);
837    if n < 1e-12 {
838        fallback
839    } else {
840        [v[0] / n, v[1] / n, v[2] / n]
841    }
842}
843
844/// A stable unit vector ⟂ `direction` (a port of the previous app's arbitrary-perpendicular helper).
845fn arbitrary_perpendicular(direction: [f64; 3]) -> [f64; 3] {
846    if norm3(direction) <= 1e-12 {
847        return [0.0, 0.0, 1.0];
848    }
849    let seed = if dot3(direction, [0.0, 0.0, 1.0]).abs() < 0.9 {
850        [0.0, 0.0, 1.0]
851    } else {
852        [0.0, 1.0, 0.0]
853    };
854    let mut perp = cross3(direction, seed);
855    if norm3(perp) <= 1e-12 {
856        perp = cross3(direction, [1.0, 0.0, 0.0]);
857    }
858    if norm3(perp) <= 1e-12 {
859        [1.0, 0.0, 0.0]
860    } else {
861        normalize_or(perp, [1.0, 0.0, 0.0])
862    }
863}
864
865/// Rotate `v` by `angle` radians about unit `axis` (Rodrigues). Shared with the
866/// engine's angular drag/chip anchoring.
867pub fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
868    let axis = normalize_or(axis, [0.0, 1.0, 0.0]);
869    let (s, c) = angle.sin_cos();
870    let d = dot3(axis, v);
871    let cr = cross3(axis, v);
872    [
873        v[0] * c + cr[0] * s + axis[0] * d * (1.0 - c),
874        v[1] * c + cr[1] * s + axis[1] * d * (1.0 - c),
875        v[2] * c + cr[2] * s + axis[2] * d * (1.0 - c),
876    ]
877}
878
879fn norm3(v: [f64; 3]) -> f64 {
880    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
881}
882
883fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
884    [
885        a[1] * b[2] - a[2] * b[1],
886        a[2] * b[0] - a[0] * b[2],
887        a[0] * b[1] - a[1] * b[0],
888    ]
889}
890
891/// A radially-symmetric perpendicular basis `(u, v)` for a unit `dir`.
892fn axis_basis(dir: [f64; 3]) -> ([f64; 3], [f64; 3]) {
893    let seed = if dir[0].abs() < 0.9 {
894        [1.0, 0.0, 0.0]
895    } else {
896        [0.0, 1.0, 0.0]
897    };
898    let mut u = cross3(dir, seed);
899    let un = norm3(u);
900    if un < 1e-9 {
901        u = [0.0, 1.0, 0.0];
902    } else {
903        u = [u[0] / un, u[1] / un, u[2] / un];
904    }
905    let v = cross3(dir, u);
906    let vn = norm3(v).max(1e-9);
907    (u, [v[0] / vn, v[1] / vn, v[2] / vn])
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913    use serde_json::json;
914
915    fn ident_transform() -> Value {
916        json!({
917            "position": [0.0, 0.0, 0.0],
918            "rotationEuler": [0.0, 0.0, 0.0],
919            "scale": [1.0, 1.0, 1.0],
920        })
921    }
922
923    fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
924        norm3(sub3(a, b))
925    }
926
927    #[test]
928    fn cube_identity_gives_three_axis_dims() {
929        let params = json!({
930            "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
931            "transform": ident_transform(),
932        });
933        let anns = build_annotations("P.CU", &params);
934        assert_eq!(anns.len(), 3);
935        let keys: Vec<&str> = anns.iter().map(|a| a.field_key.as_str()).collect();
936        assert_eq!(keys, ["sizeX", "sizeY", "sizeZ"]);
937        assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
938        assert!((dist(anns[1].point_a, anns[1].point_b) - 20.0).abs() < 1e-9);
939        assert!((dist(anns[2].point_a, anns[2].point_b) - 30.0).abs() < 1e-9);
940        // sizeX runs along +X from the origin corner.
941        assert!((anns[0].point_a[0]).abs() < 1e-9);
942        assert!((anns[0].point_b[0] - 10.0).abs() < 1e-9);
943        assert!((anns[0].value - 10.0).abs() < 1e-9);
944    }
945
946    #[test]
947    fn cube_translation_moves_the_dims() {
948        let params = json!({
949            "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
950            "transform": {
951                "position": [5.0, -3.0, 2.0],
952                "rotationEuler": [0.0, 0.0, 0.0],
953                "scale": [1.0, 1.0, 1.0],
954            },
955        });
956        let anns = build_annotations("P.CU", &params);
957        // The origin corner is translated; lengths are unchanged.
958        assert!((anns[0].point_a[0] - 5.0).abs() < 1e-9);
959        assert!((anns[0].point_a[1] + 3.0).abs() < 1e-9);
960        assert!((anns[0].point_a[2] - 2.0).abs() < 1e-9);
961        assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
962    }
963
964    #[test]
965    fn cube_rotation_90_about_z_maps_x_axis_to_y() {
966        let params = json!({
967            "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
968            "transform": {
969                "position": [0.0, 0.0, 0.0],
970                "rotationEuler": [0.0, 0.0, 90.0],
971                "scale": [1.0, 1.0, 1.0],
972            },
973        });
974        let anns = build_annotations("P.CU", &params);
975        // Local +X (sizeX) rotates onto world +Y.
976        let px = anns[0].point_b;
977        assert!(px[0].abs() < 1e-6, "{px:?}");
978        assert!((px[1] - 10.0).abs() < 1e-6, "{px:?}");
979        assert!(px[2].abs() < 1e-6, "{px:?}");
980        // Lengths preserved under rotation.
981        assert!((dist(anns[1].point_a, anns[1].point_b) - 20.0).abs() < 1e-6);
982    }
983
984    #[test]
985    fn cube_scale_scales_world_length() {
986        let params = json!({
987            "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0,
988            "transform": {
989                "position": [0.0, 0.0, 0.0],
990                "rotationEuler": [0.0, 0.0, 0.0],
991                "scale": [2.0, 1.0, 1.0],
992            },
993        });
994        let anns = build_annotations("P.CU", &params);
995        // World length is scaled by 2, but the reported param value is still 10.
996        assert!((dist(anns[0].point_a, anns[0].point_b) - 20.0).abs() < 1e-9);
997        assert!((anns[0].value - 10.0).abs() < 1e-9);
998    }
999
1000    #[test]
1001    fn cylinder_gives_radius_and_height() {
1002        let params = json!({
1003            "radius": 4.0, "height": 12.0,
1004            "transform": ident_transform(),
1005        });
1006        let anns = build_annotations("P.CY", &params);
1007        assert_eq!(anns.len(), 2);
1008        assert_eq!(anns[0].field_key, "radius");
1009        assert_eq!(anns[1].field_key, "height");
1010        assert!((dist(anns[0].point_a, anns[0].point_b) - 4.0).abs() < 1e-9);
1011        assert!((dist(anns[1].point_a, anns[1].point_b) - 12.0).abs() < 1e-9);
1012        // Height runs along +Y, radius along +X.
1013        assert!((anns[0].point_b[0] - 4.0).abs() < 1e-9);
1014        assert!((anns[1].point_b[1] - 12.0).abs() < 1e-9);
1015    }
1016
1017    #[test]
1018    fn cone_gives_three_dims() {
1019        let params = json!({
1020            "radiusBottom": 5.0, "radiusTop": 2.0, "height": 8.0,
1021            "transform": ident_transform(),
1022        });
1023        let anns = build_annotations("P.CO", &params);
1024        assert_eq!(anns.len(), 3);
1025        let keys: Vec<&str> = anns.iter().map(|a| a.field_key.as_str()).collect();
1026        assert_eq!(keys, ["radiusBottom", "radiusTop", "height"]);
1027        assert!((dist(anns[0].point_a, anns[0].point_b) - 5.0).abs() < 1e-9);
1028        assert!((dist(anns[1].point_a, anns[1].point_b) - 2.0).abs() < 1e-9);
1029        assert!((dist(anns[2].point_a, anns[2].point_b) - 8.0).abs() < 1e-9);
1030        // The top radius dim is anchored at y = height.
1031        assert!((anns[1].point_a[1] - 8.0).abs() < 1e-9);
1032    }
1033
1034    #[test]
1035    fn sphere_gives_one_radius_dim() {
1036        let params = json!({ "radius": 7.5, "transform": ident_transform() });
1037        let anns = build_annotations("P.S", &params);
1038        assert_eq!(anns.len(), 1);
1039        assert_eq!(anns[0].field_key, "radius");
1040        assert!((dist(anns[0].point_a, anns[0].point_b) - 7.5).abs() < 1e-9);
1041    }
1042
1043    #[test]
1044    fn pyramid_gives_side_and_height_centered() {
1045        let params = json!({
1046            "baseSideLength": 6.0, "height": 10.0,
1047            "transform": ident_transform(),
1048        });
1049        let anns = build_annotations("P.PY", &params);
1050        assert_eq!(anns.len(), 2);
1051        assert_eq!(anns[0].field_key, "baseSideLength");
1052        assert_eq!(anns[1].field_key, "height");
1053        assert!((dist(anns[0].point_a, anns[0].point_b) - 6.0).abs() < 1e-9);
1054        assert!((dist(anns[1].point_a, anns[1].point_b) - 10.0).abs() < 1e-9);
1055        // Centered on the axis: base at y = -h/2, apex at y = +h/2.
1056        assert!((anns[1].point_a[1] + 5.0).abs() < 1e-9);
1057        assert!((anns[1].point_b[1] - 5.0).abs() < 1e-9);
1058    }
1059
1060    #[test]
1061    fn unknown_type_gives_no_dims() {
1062        let params = json!({ "distance": 5.0 });
1063        assert!(build_annotations("EXTRUDE", &params).is_empty());
1064        assert!(build_annotations("BOOLEAN", &params).is_empty());
1065    }
1066
1067    #[test]
1068    fn numeric_string_params_resolve() {
1069        let params = json!({
1070            "sizeX": "10", "sizeY": "20", "sizeZ": "30",
1071            "transform": ident_transform(),
1072        });
1073        let anns = build_annotations("P.CU", &params);
1074        assert!((anns[0].value - 10.0).abs() < 1e-9);
1075        assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1076    }
1077
1078    #[test]
1079    fn leaders_buffers_emit_shaft_cone_and_origin_sphere_tris() {
1080        let ann = FeatureDimAnnotation::linear("sizeX", [0.0, 0.0, 0.0], [10.0, 0.0, 0.0], 10.0, "X");
1081        let (pos, col) = leaders_buffers(std::slice::from_ref(&ann), 0.1);
1082        // Triangle soup: 3 position + 3 color floats per vertex, and the count is
1083        // a whole number of triangles.
1084        assert!(!pos.is_empty(), "expected triangle geometry");
1085        assert_eq!(pos.len(), col.len(), "one rgb color per xyz position");
1086        assert_eq!(pos.len() % 9, 0, "whole triangles (3 verts * 3 floats)");
1087        // Both the silver shaft and the orange cone/sphere colors are present.
1088        let has = |rgb: [f32; 3]| {
1089            col.chunks_exact(3)
1090                .any(|c| (c[0] - rgb[0]).abs() < 1e-3 && (c[1] - rgb[1]).abs() < 1e-3 && (c[2] - rgb[2]).abs() < 1e-3)
1091        };
1092        assert!(has(SHAFT_RGB), "expected silver shaft tris");
1093        assert!(has(ORANGE_RGB), "expected orange cone/sphere tris");
1094    }
1095
1096    #[test]
1097    fn cube_dims_share_one_origin_sphere() {
1098        // A cube's three axis dims all start at the same corner: the origin sphere
1099        // is deduped, so exactly one sphere's worth of extra tris appears vs a
1100        // single-annotation build sharing that origin.
1101        let anns = build_annotations(
1102            "P.CU",
1103            &json!({ "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0, "transform": ident_transform() }),
1104        );
1105        assert_eq!(anns.len(), 3);
1106        let origins: std::collections::BTreeSet<_> = anns
1107            .iter()
1108            .map(|a| (a.point_a[0] as i64, a.point_a[1] as i64, a.point_a[2] as i64))
1109            .collect();
1110        assert_eq!(origins.len(), 1, "cube dims share one origin corner");
1111        let (pos, _) = leaders_buffers(&anns, 0.1);
1112        assert!(!pos.is_empty());
1113    }
1114
1115    // --- FD-2: torus / extrude / revolve + the angle gizmo --------------------
1116
1117    fn color_present(col: &[f32], rgb: [f32; 3]) -> bool {
1118        col.chunks_exact(3).any(|c| {
1119            (c[0] - rgb[0]).abs() < 1e-3
1120                && (c[1] - rgb[1]).abs() < 1e-3
1121                && (c[2] - rgb[2]).abs() < 1e-3
1122        })
1123    }
1124
1125    #[test]
1126    fn torus_emits_two_linear_and_one_angular() {
1127        let params = json!({
1128            "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 90.0,
1129            "transform": ident_transform(),
1130        });
1131        let anns = build_annotations("P.T", &params);
1132        assert_eq!(anns.len(), 3);
1133        // majorRadius + tubeRadius are LINEAR; arc is ANGULAR.
1134        assert_eq!(anns[0].field_key, "majorRadius");
1135        assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1136        assert!((dist(anns[0].point_a, anns[0].point_b) - 5.0).abs() < 1e-9);
1137        assert_eq!(anns[1].field_key, "tubeRadius");
1138        assert_eq!(anns[1].kind, FeatureDimKind::Linear);
1139        assert!((dist(anns[1].point_a, anns[1].point_b) - 1.0).abs() < 1e-9);
1140        // tubeRadius is anchored at the tube centerline (majorRadius along +X).
1141        assert!((anns[1].point_a[0] - 5.0).abs() < 1e-9);
1142        // arc: angular about local +Y from the +X reference, value in degrees.
1143        let arc = &anns[2];
1144        assert_eq!(arc.field_key, "arc");
1145        assert_eq!(arc.kind, FeatureDimKind::Angular);
1146        assert!((arc.value - 90.0).abs() < 1e-9);
1147        assert!((arc.axis[1] - 1.0).abs() < 1e-6, "axis ≈ +Y: {:?}", arc.axis);
1148        assert!((arc.ref_dir[0] - 1.0).abs() < 1e-6, "ref ≈ +X: {:?}", arc.ref_dir);
1149    }
1150
1151    #[test]
1152    fn torus_arc_clamped_to_360() {
1153        let params = json!({
1154            "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 500.0,
1155            "transform": ident_transform(),
1156        });
1157        let anns = build_annotations("P.T", &params);
1158        assert!((anns[2].value - 360.0).abs() < 1e-9, "arc clamps to 360");
1159    }
1160
1161    #[test]
1162    fn extrude_emits_linear_distance_along_normal() {
1163        let refs = ResolvedRefs {
1164            profile_center: Some([2.0, 0.0, 0.0]),
1165            profile_normal: Some([0.0, 0.0, 1.0]),
1166            ..Default::default()
1167        };
1168        let params = json!({ "distance": 10.0, "distanceBack": 3.0 });
1169        let anns = build_annotations_with_refs("E", &params, &refs);
1170        assert_eq!(anns.len(), 2);
1171        assert_eq!(anns[0].field_key, "distance");
1172        assert_eq!(anns[0].kind, FeatureDimKind::Linear);
1173        // The distance leader runs from the profile center along +normal (+Z) by 10.
1174        assert_eq!(anns[0].point_a, [2.0, 0.0, 0.0]);
1175        assert!((anns[0].point_b[2] - 10.0).abs() < 1e-9);
1176        assert!((dist(anns[0].point_a, anns[0].point_b) - 10.0).abs() < 1e-9);
1177        // The back leg runs along -normal by distanceBack.
1178        assert_eq!(anns[1].field_key, "distanceBack");
1179        assert!((anns[1].point_b[2] + 3.0).abs() < 1e-9);
1180    }
1181
1182    #[test]
1183    fn revolve_emits_one_angular_about_axis() {
1184        // Axis = +Z through the origin; profile off +X. Expect one angular dim of
1185        // the given degrees, about +Z, zeroed on the radial (+X) to the profile.
1186        let refs = ResolvedRefs {
1187            profile_center: Some([5.0, 0.0, 0.0]),
1188            profile_normal: Some([0.0, 1.0, 0.0]),
1189            axis_point: Some([0.0, 0.0, 0.0]),
1190            axis_dir: Some([0.0, 0.0, 1.0]),
1191        };
1192        let params = json!({ "angle": 234.0 });
1193        let anns = build_annotations_with_refs("R", &params, &refs);
1194        assert_eq!(anns.len(), 1);
1195        let a = &anns[0];
1196        assert_eq!(a.field_key, "angle");
1197        assert_eq!(a.kind, FeatureDimKind::Angular);
1198        assert!((a.value - 234.0).abs() < 1e-9);
1199        assert!((a.axis[2] - 1.0).abs() < 1e-6, "axis ≈ +Z: {:?}", a.axis);
1200        assert!((a.ref_dir[0] - 1.0).abs() < 1e-6, "ref ≈ +X: {:?}", a.ref_dir);
1201        // The vertex is the axis point nearest the profile (the origin here).
1202        assert!(norm3(a.center) < 1e-9, "vertex on axis: {:?}", a.center);
1203    }
1204
1205    #[test]
1206    fn revolve_axis_orients_toward_profile_front() {
1207        // Flipping the profile normal flips which of the two axis directions the
1208        // oriented sweep uses (the `orient_revolve_axis` sign test).
1209        let base = ResolvedRefs {
1210            profile_center: Some([5.0, 0.0, 0.0]),
1211            profile_normal: Some([0.0, 1.0, 0.0]),
1212            axis_point: Some([0.0, 0.0, 0.0]),
1213            axis_dir: Some([0.0, 0.0, 1.0]),
1214        };
1215        let flipped = ResolvedRefs {
1216            profile_normal: Some([0.0, -1.0, 0.0]),
1217            ..base.clone()
1218        };
1219        let params = json!({ "angle": 90.0 });
1220        let a = build_annotations_with_refs("R", &params, &base);
1221        let b = build_annotations_with_refs("R", &params, &flipped);
1222        assert!((a[0].axis[2] - 1.0).abs() < 1e-6);
1223        assert!((b[0].axis[2] + 1.0).abs() < 1e-6, "flipped normal → negated axis");
1224    }
1225
1226    #[test]
1227    fn extrude_and_revolve_empty_without_resolved_refs() {
1228        let refs = ResolvedRefs::default();
1229        assert!(build_annotations_with_refs("E", &json!({ "distance": 5.0 }), &refs).is_empty());
1230        assert!(build_annotations_with_refs("R", &json!({ "angle": 90.0 }), &refs).is_empty());
1231    }
1232
1233    #[test]
1234    fn angle_gizmo_emits_arc_cone_sphere_ref_and_axis_tris() {
1235        let params = json!({
1236            "majorRadius": 5.0, "tubeRadius": 1.0, "arc": 234.0,
1237            "transform": ident_transform(),
1238        });
1239        let anns = build_annotations("P.T", &params);
1240        let (pos, col) = leaders_buffers(&anns, 0.1);
1241        assert!(!pos.is_empty());
1242        assert_eq!(pos.len(), col.len());
1243        assert_eq!(pos.len() % 9, 0);
1244        // The angle gizmo contributes all four colors: grey arc + orange
1245        // cone/handle + red dashed reference + green axis.
1246        assert!(color_present(&col, SHAFT_RGB), "grey arc");
1247        assert!(color_present(&col, ORANGE_RGB), "orange cone/handle");
1248        assert!(color_present(&col, RED_RGB), "red dashed reference");
1249        assert!(color_present(&col, GREEN_RGB), "green axis");
1250    }
1251
1252    #[test]
1253    fn angular_chip_anchor_sits_on_the_arc_mid_sweep() {
1254        // A 180° arc about +Z from +X: the mid-sweep bisector is +Y, so the chip
1255        // anchors at center + (+Y) * radius (radius = 120px * world_per_pixel).
1256        let ann = FeatureDimAnnotation::angular(
1257            "angle",
1258            [0.0, 0.0, 0.0],
1259            [0.0, 0.0, 1.0],
1260            [1.0, 0.0, 0.0],
1261            180.0,
1262            "A",
1263        );
1264        let wpp = 0.01;
1265        let anchor = angular_chip_anchor(&ann, wpp);
1266        let radius = ANGLE_ARC_RAD_PX * wpp;
1267        assert!((anchor[1] - radius).abs() < 1e-6, "mid-sweep ≈ +Y*radius: {anchor:?}");
1268        assert!(anchor[0].abs() < 1e-6 && anchor[2].abs() < 1e-6);
1269    }
1270}