Skip to main content

brep_render/
feature_dimensions.rs

1//! Draggable linear and angular annotations for selected feature parameters.
2//!
3//! Builders place local-space leaders on the feature's geometry and apply the
4//! same intrinsic XYZ transform as the kernel, keeping annotations aligned with
5//! the rebuilt solid. Each annotation identifies the parameter it edits.
6
7use crate::geometry3d::{cross3, dot3, len3 as norm3, sub3};
8
9use serde_json::Value;
10
11use crate::engine_state::rotate_euler_xyz_f64;
12
13/// A dimension annotation's kind. FD-1 was all [`FeatureDimKind::Linear`]; FD-2
14/// adds [`FeatureDimKind::Angular`] (torus `arc`, revolve `angle`).
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum FeatureDimKind {
17    /// A linear distance from `point_a` → `point_b`.
18    Linear,
19    /// An angular sweep of `value` DEGREES about `axis`, measured in the plane
20    /// ⟂ `axis` from the zero reference `ref_dir`, centered at `center`. Ported
21    /// from the previous app's angle-annotation builder.
22    Angular,
23}
24
25/// One editable dimension annotation.
26///
27/// LINEAR: a leader from world `point_a` → `point_b` whose length is the
28/// (resolved) value of param `field_key`; dragging projects the pointer onto the
29/// `a → b` axis.
30///
31/// ANGULAR: a screen-constant-radius ARC swept `value` degrees about `axis`
32/// (unit), starting from `ref_dir` (unit, in the plane ⟂ `axis`), centered at
33/// `center`; dragging maps the pointer to a swept angle. The `point_a`/`point_b`
34/// / `midpoint()` linear surface is unused for an angular annotation (both are
35/// set to `center`); the app anchors an angular chip at the arc's mid-sweep,
36/// which is camera-dependent and computed engine-side.
37#[derive(Clone, Debug)]
38pub struct FeatureDimAnnotation {
39    /// The `inputParams` key this annotation edits (e.g. `sizeX`, `radius`, `arc`).
40    pub field_key: String,
41    /// LINEAR: the dimension's start point in WORLD space (the leader anchor /
42    /// drag base). ANGULAR: `center`.
43    pub point_a: [f64; 3],
44    /// LINEAR: the dimension's end point in WORLD space (the value-1 handle).
45    /// ANGULAR: `center`.
46    pub point_b: [f64; 3],
47    /// The current (resolved) numeric value of `field_key` (a length for LINEAR,
48    /// DEGREES for ANGULAR).
49    pub value: f64,
50    /// The short display prefix (`X`, `R`, `H`, `A`, `Arc`, …).
51    pub label: String,
52    /// The annotation kind.
53    pub kind: FeatureDimKind,
54    /// ANGULAR only: the arc center (the axis-plane vertex) in WORLD space.
55    /// `[0;3]` for a linear annotation.
56    pub center: [f64; 3],
57    /// ANGULAR: the rotation AXIS (unit) the arc sweeps about. LINEAR: `[0;3]`
58    /// for feature dims; the assembly DISTANCE overlays stash the BASE-FACE
59    /// outward unit normal here (the signed drag axis — see
60    /// [`crate::constraint_overlays`]'s perpendicular-foot construction).
61    /// `leaders_buffers` ignores it for linear annotations either way.
62    pub axis: [f64; 3],
63    /// ANGULAR only: the sweep's ZERO reference (unit, in the plane ⟂ `axis`).
64    /// `[0;3]` for a linear annotation.
65    pub ref_dir: [f64; 3],
66}
67
68impl FeatureDimAnnotation {
69    /// `pub(crate)`: the assembly-constraint overlay builder
70    /// ([`crate::constraint_overlays`]) constructs the SAME annotation shape for
71    /// distance constraints so `leaders_buffers` renders constraint arrows with
72    /// byte-identical styling (UI-consistency directive — one arrow look).
73    pub(crate) fn linear(field_key: &str, a: [f64; 3], b: [f64; 3], value: f64, label: &str) -> Self {
74        Self {
75            field_key: field_key.to_string(),
76            point_a: a,
77            point_b: b,
78            value,
79            label: label.to_string(),
80            kind: FeatureDimKind::Linear,
81            center: [0.0; 3],
82            axis: [0.0; 3],
83            ref_dir: [0.0; 3],
84        }
85    }
86
87    /// An angular annotation: `value` DEGREES swept about `axis` from `ref_dir`,
88    /// centered at `center`. `axis` is normalized and `ref_dir` is projected into
89    /// the plane ⟂ `axis` then normalized (mirrors the overlay `#createAngle`
90    /// pre-processing). A degenerate axis/ref falls back to an arbitrary basis so
91    /// the annotation is always renderable.
92    ///
93    /// `pub(crate)`: also constructed by [`crate::constraint_overlays`] for angle
94    /// constraints so the constraint arc reuses this exact gizmo styling.
95    pub(crate) fn angular(
96        field_key: &str,
97        center: [f64; 3],
98        axis: [f64; 3],
99        ref_dir: [f64; 3],
100        value: f64,
101        label: &str,
102    ) -> Self {
103        let axis = normalize_or(axis, [0.0, 1.0, 0.0]);
104        // Project the reference into the plane ⟂ axis, then normalize.
105        let d = dot3(ref_dir, axis);
106        let planar = [
107            ref_dir[0] - axis[0] * d,
108            ref_dir[1] - axis[1] * d,
109            ref_dir[2] - axis[2] * d,
110        ];
111        let ref_dir = if norm3(planar) <= 1e-9 {
112            arbitrary_perpendicular(axis)
113        } else {
114            normalize_or(planar, arbitrary_perpendicular(axis))
115        };
116        Self {
117            field_key: field_key.to_string(),
118            point_a: center,
119            point_b: center,
120            value,
121            label: label.to_string(),
122            kind: FeatureDimKind::Angular,
123            center,
124            axis,
125            ref_dir,
126        }
127    }
128
129    /// The world-space midpoint of the leader — where the app anchors a LINEAR
130    /// label. (Angular chips anchor at the arc mid-sweep, computed engine-side
131    /// with the camera's `world_per_pixel`.)
132    pub fn midpoint(&self) -> [f64; 3] {
133        [
134            (self.point_a[0] + self.point_b[0]) * 0.5,
135            (self.point_a[1] + self.point_b[1]) * 0.5,
136            (self.point_a[2] + self.point_b[2]) * 0.5,
137        ]
138    }
139}
140
141/// Resolved scene geometry an annotation builder needs beyond the pure
142/// `inputParams` — the profile plane (extrude/revolve) and the axis line
143/// (revolve). These are NOT pure params (they resolve scene references), so the
144/// engine resolves them from the run report's sketch profiles / axes, or from
145/// the resident scene when the reference names a solid face / edge (see
146/// `EngineState::feature_dimension_refs`), and hands them in. All fields are
147/// optional: a builder that can't source what it needs returns `[]` gracefully.
148#[derive(Clone, Debug, Default)]
149pub struct ResolvedRefs {
150    /// Extrude/revolve: the profile CENTER (world centroid of the outer loop) —
151    /// the anchor the distance/angle gizmo hangs off, matching the previous app's
152    /// resolved profile-reference-geometry center.
153    pub profile_center: Option<[f64; 3]>,
154    /// Extrude/revolve: the profile plane NORMAL (unit) — the sketch `+z` basis,
155    /// or a face profile's OUTWARD normal (what the kernel's `face_profile` sets
156    /// as `z_axis`) — the authoritative sweep/revolve-orientation direction.
157    pub profile_normal: Option<[f64; 3]>,
158    /// Revolve: a point on the resolved axis LINE (world).
159    pub axis_point: Option<[f64; 3]>,
160    /// Revolve: the resolved axis line DIRECTION (unit, UNORIENTED — the builder
161    /// orients it toward the profile front via the `orient_revolve_axis` port).
162    pub axis_dir: Option<[f64; 3]>,
163    /// Plane (`P`): the resolved plane frame's ORIGIN (world) — the plane AFTER its
164    /// `offset_distance`. The offset dim's un-offset base is `origin − normal·offset`.
165    pub plane_origin: Option<[f64; 3]>,
166    /// Plane (`P`): the resolved plane frame's unit NORMAL (its z-axis) — the axis
167    /// the signed offset dim runs along.
168    pub plane_normal: Option<[f64; 3]>,
169    /// Plane (`P`) and Extrude (`E`): a small world length for a linear dim's
170    /// handle stub when its value is ~0 (a zero-length leader can't be dragged).
171    /// Screen-constant so the handle is a consistent size; ignored once the value
172    /// is non-zero.
173    pub plane_dim_length: Option<f64>,
174}
175
176/// Build the linear dimension annotations for a feature `type` from its
177/// `input_params`. Dispatches on the primitive-solid type; returns `[]` for any
178/// type without an FD-1 builder (extrude / revolve / booleans / etc.).
179///
180/// `input_params` should already have its numeric fields resolved to numbers
181/// (the engine resolves expression strings against the history env before
182/// calling this — see `EngineState::feature_dimension_annotations`), but plain
183/// numeric strings are tolerated here too so the pure geometry stays testable.
184pub fn build_annotations(feature_type: &str, input_params: &Value) -> Vec<FeatureDimAnnotation> {
185    build_annotations_with_refs(feature_type, input_params, &ResolvedRefs::default())
186}
187
188/// Build the dimension annotations for a feature, given any resolved scene
189/// references (`ResolvedRefs`) it needs. The primitive-solid + torus builders
190/// ignore `resolved` (pure params + the baked transform); extrude/revolve read
191/// the resolved profile plane / axis line and return `[]` when it is
192/// unavailable (an unresolved reference — see the builders). The engine resolves
193/// the profile plane from a sketch profile OR a resident solid face.
194pub fn build_annotations_with_refs(
195    feature_type: &str,
196    input_params: &Value,
197    resolved: &ResolvedRefs,
198) -> Vec<FeatureDimAnnotation> {
199    let transform = input_params.get("transform");
200    match feature_type {
201        "P.CU" => build_cube(input_params, transform),
202        "P.CY" => build_cylinder(input_params, transform),
203        "P.CO" => build_cone(input_params, transform),
204        // The sphere feature type is `P.S` (the dispatch keys on `P.S`).
205        "P.S" | "P.SP" => build_sphere(input_params, transform),
206        "P.PY" => build_pyramid(input_params, transform),
207        "P.T" => build_torus(input_params, transform),
208        "E" => build_extrude(input_params, resolved),
209        "R" => build_revolve(input_params, resolved),
210        "P" => build_plane(input_params, resolved),
211        _ => Vec::new(),
212    }
213}
214
215/// Cube (`P.CU`): minimum corner at the local origin, extending `+sizeX/Y/Z`
216/// (matches the kernel's `make_box_brep((0,0,0), …)`). Three linear dims from the
217/// origin corner along each axis.
218fn build_cube(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
219    let sx = resolve_number(params, "sizeX");
220    let sy = resolve_number(params, "sizeY");
221    let sz = resolve_number(params, "sizeZ");
222    let p0 = transform_point(transform, [0.0, 0.0, 0.0]);
223    let px = transform_point(transform, [sx, 0.0, 0.0]);
224    let py = transform_point(transform, [0.0, sy, 0.0]);
225    let pz = transform_point(transform, [0.0, 0.0, sz]);
226    vec![
227        FeatureDimAnnotation::linear("sizeX", p0, px, sx, "X"),
228        FeatureDimAnnotation::linear("sizeY", p0, py, sy, "Y"),
229        FeatureDimAnnotation::linear("sizeZ", p0, pz, sz, "Z"),
230    ]
231}
232
233/// Cylinder (`P.CY`): axis is local `+Y`, base at `y=0`, top at `y=height`;
234/// radius along local `+X`. Two dims: radius (base → radial) + height (base → top).
235fn build_cylinder(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
236    let radius = resolve_number(params, "radius");
237    let height = resolve_number(params, "height");
238    let base = transform_point(transform, [0.0, 0.0, 0.0]);
239    let top = transform_point(transform, [0.0, height, 0.0]);
240    let radial = transform_point(transform, [radius, 0.0, 0.0]);
241    vec![
242        FeatureDimAnnotation::linear("radius", base, radial, radius, "R"),
243        FeatureDimAnnotation::linear("height", base, top, height, "H"),
244    ]
245}
246
247/// Cone (`P.CO`): base radius at `y=0`, top radius at `y=height` (both along
248/// local `+X`), axis along `+Y`. Three dims: radiusBottom / radiusTop / height.
249fn build_cone(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
250    let radius_top = resolve_number(params, "radiusTop");
251    let radius_bottom = resolve_number(params, "radiusBottom");
252    let height = resolve_number(params, "height");
253    let base_center = transform_point(transform, [0.0, 0.0, 0.0]);
254    let top_center = transform_point(transform, [0.0, height, 0.0]);
255    let base_radius = transform_point(transform, [radius_bottom, 0.0, 0.0]);
256    let top_radius = transform_point(transform, [radius_top, height, 0.0]);
257    vec![
258        FeatureDimAnnotation::linear("radiusBottom", base_center, base_radius, radius_bottom, "Rb"),
259        FeatureDimAnnotation::linear("radiusTop", top_center, top_radius, radius_top, "Rt"),
260        FeatureDimAnnotation::linear("height", base_center, top_center, height, "H"),
261    ]
262}
263
264/// Sphere (`P.S`): one radial dim from the center along local `+X`.
265fn build_sphere(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
266    let radius = resolve_number(params, "radius");
267    let center = transform_point(transform, [0.0, 0.0, 0.0]);
268    let radial = transform_point(transform, [radius, 0.0, 0.0]);
269    vec![FeatureDimAnnotation::linear("radius", center, radial, radius, "R")]
270}
271
272/// Pyramid (`P.PY`): centered on its axis — base at `y = -h/2`, apex at `y = h/2`,
273/// base edge spanning `±side/2` in local X (at `z = -side/2`). Two dims: the base
274/// side edge + the axial height.
275fn build_pyramid(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
276    let side = resolve_number(params, "baseSideLength");
277    let height = resolve_number(params, "height");
278    let half_side = side * 0.5;
279    let base_y = -height * 0.5;
280    let apex_y = height * 0.5;
281    let base_start = transform_point(transform, [-half_side, base_y, -half_side]);
282    let base_end = transform_point(transform, [half_side, base_y, -half_side]);
283    let base_center = transform_point(transform, [0.0, base_y, 0.0]);
284    let apex = transform_point(transform, [0.0, apex_y, 0.0]);
285    vec![
286        FeatureDimAnnotation::linear("baseSideLength", base_start, base_end, side, "Side"),
287        FeatureDimAnnotation::linear("height", base_center, apex, height, "H"),
288    ]
289}
290
291/// Torus (`P.T`): centered on its axis (local `+Y`) at the origin; the tube
292/// circle lies in the local X/Y plane centered at `(majorRadius, 0, 0)`, revolved
293/// `arc` DEGREES about `+Y`. Two LINEAR dims — `majorRadius` (center → the tube
294/// centerline along `+X`, `R`) and `tubeRadius` (the centerline → the outer wall,
295/// `r`) — plus the `arc` sweep as an ANGULAR dim about the local `+Y` axis from
296/// the `+X` reference. Ported verbatim from the previous torus-annotation builder.
297fn build_torus(params: &Value, transform: Option<&Value>) -> Vec<FeatureDimAnnotation> {
298    let major = resolve_number(params, "majorRadius");
299    let tube = resolve_number(params, "tubeRadius");
300    let arc = clamp_deg(resolve_number(params, "arc"));
301
302    let center = transform_point(transform, [0.0, 0.0, 0.0]);
303    let major_point = transform_point(transform, [major, 0.0, 0.0]);
304    let tube_point = transform_point(transform, [major + tube, 0.0, 0.0]);
305    // The axis / reference are the transformed local +Y / +X (subtract the
306    // transformed origin then normalize, so translation cancels; a non-uniform
307    // scale skews them — normalization handles magnitude).
308    let axis = normalize_or(sub3(transform_point(transform, [0.0, 1.0, 0.0]), center), [0.0, 1.0, 0.0]);
309    let start_dir = sub3(major_point, center);
310
311    vec![
312        FeatureDimAnnotation::linear("majorRadius", center, major_point, major, "R"),
313        FeatureDimAnnotation::linear("tubeRadius", major_point, tube_point, tube, "r"),
314        FeatureDimAnnotation::angular("arc", center, axis, start_dir, arc, "Arc"),
315    ]
316}
317
318/// Extrude (`E`): a LINEAR distance dim from the profile CENTER along the profile
319/// plane NORMAL by `distance` (`D`), plus the two-sided `distanceBack` leg along
320/// `-normal` (`Db`). Needs the resolved profile center + normal (a sketch
321/// profile's, or a resident solid face's — the engine sources both). Ported from
322/// the previous extrude-annotation builder.
323fn build_extrude(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
324    let (Some(center), Some(normal)) = (resolved.profile_center, resolved.profile_normal) else {
325        return Vec::new();
326    };
327    let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
328    let distance = resolve_number(params, "distance");
329    let back = resolve_number(params, "distanceBack");
330    // A leg at ~0 (a one-sided extrude's other side) would collapse to a
331    // zero-length leader, which the drag ignores: its arrow could not be pulled
332    // out. Give it the Plane dim's screen-constant stub along its own direction
333    // instead; the value (and its label) stays 0 and the drag maps 1:1 there.
334    let leg = |value: f64| if value.abs() > 1e-6 { value } else { resolved.plane_dim_length.unwrap_or(0.0) };
335    let (forward_len, back_len) = (leg(distance), leg(back));
336    let forward = [
337        center[0] + normal[0] * forward_len,
338        center[1] + normal[1] * forward_len,
339        center[2] + normal[2] * forward_len,
340    ];
341    let backward = [
342        center[0] - normal[0] * back_len,
343        center[1] - normal[1] * back_len,
344        center[2] - normal[2] * back_len,
345    ];
346    vec![
347        FeatureDimAnnotation::linear("distance", center, forward, distance, "D"),
348        FeatureDimAnnotation::linear("distanceBack", center, backward, back, "Db"),
349    ]
350}
351
352/// Plane (`P`): one SIGNED LINEAR offset dim along the plane normal, driving
353/// `offset_distance`. The plane sits on either side of its base orientation/
354/// reference plane, so the dim runs from the un-offset base
355/// (`origin − normal·offset`) to the current plane (`origin`) and is dragged
356/// through zero to flip sign (the drag preserves sign; the kernel accepts a
357/// negative `offset_distance`). At offset ≈ 0 the leader collapses, so a small
358/// `+normal` stub (`plane_dim_length`, screen-constant) keeps it draggable — the
359/// value mapping stays 1:1 there. No resolved plane frame → `[]`.
360fn build_plane(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
361    let (Some(origin), Some(normal)) = (resolved.plane_origin, resolved.plane_normal) else {
362        return Vec::new();
363    };
364    let offset = resolve_number(params, "offset_distance");
365    // The un-offset base plane point — the dim's zero anchor.
366    let base = [
367        origin[0] - normal[0] * offset,
368        origin[1] - normal[1] * offset,
369        origin[2] - normal[2] * offset,
370    ];
371    // Handle at the current offset; at ~0 use a small +normal stub so the leader
372    // has a direction (the drag no-ops on a zero-length axis).
373    let extent = if offset.abs() > 1e-6 {
374        offset
375    } else {
376        resolved.plane_dim_length.unwrap_or(1.0)
377    };
378    let handle = [
379        base[0] + normal[0] * extent,
380        base[1] + normal[1] * extent,
381        base[2] + normal[2] * extent,
382    ];
383    vec![FeatureDimAnnotation::linear(
384        "offset_distance",
385        base,
386        handle,
387        offset,
388        "Offset",
389    )]
390}
391
392/// Revolve (`R`): one ANGULAR dim = `angle` DEGREES swept about the resolved axis
393/// line, oriented toward the profile front (the `orient_revolve_axis` port — so
394/// the arc rotates the SAME way the solid does), centered at the axis point
395/// nearest the profile, zeroed on the radial from that vertex to the profile.
396/// Needs the resolved axis line + profile; returns `[]` otherwise. Ported from
397/// the previous revolve-annotation builder.
398fn build_revolve(params: &Value, resolved: &ResolvedRefs) -> Vec<FeatureDimAnnotation> {
399    let (Some(axis_point), Some(axis_dir)) = (resolved.axis_point, resolved.axis_dir) else {
400        return Vec::new();
401    };
402    let Some(profile_center) = resolved.profile_center else {
403        return Vec::new();
404    };
405    let axis = orient_revolve_axis(axis_dir, axis_point, profile_center, resolved.profile_normal);
406    let vertex = closest_point_on_line(profile_center, axis_point, axis);
407    // The radial from the vertex to the profile (projected ⟂ axis) is the zero
408    // reference; the `angular` ctor re-projects + falls back if it degenerates.
409    let start_dir = sub3(profile_center, vertex);
410    let angle = clamp_deg(resolve_number(params, "angle"));
411    vec![FeatureDimAnnotation::angular("angle", vertex, axis, start_dir, angle, "A")]
412}
413
414/// The signed revolve axis native Revolve uses: the profile's outward normal
415/// selects between the two directions of an unoriented axis edge. Port of
416/// `resolveOrientedRevolveAxisDirection`.
417pub(crate) fn orient_revolve_axis(
418    axis_dir: [f64; 3],
419    axis_point: [f64; 3],
420    profile_center: [f64; 3],
421    profile_normal: Option<[f64; 3]>,
422) -> [f64; 3] {
423    let axis = normalize_or(axis_dir, [0.0, 1.0, 0.0]);
424    let Some(normal) = profile_normal else {
425        return axis;
426    };
427    if norm3(normal) <= 1e-12 {
428        return axis;
429    }
430    let normal = normalize_or(normal, [0.0, 0.0, 1.0]);
431    // radial = (profileCenter - axisPoint) projected ⟂ axis.
432    let mut radial = sub3(profile_center, axis_point);
433    let d = dot3(radial, axis);
434    radial = [radial[0] - axis[0] * d, radial[1] - axis[1] * d, radial[2] - axis[2] * d];
435    if norm3(radial) <= 1e-12 {
436        return axis;
437    }
438    let c = cross3(axis, radial);
439    if dot3(c, normal) < 0.0 {
440        [-axis[0], -axis[1], -axis[2]]
441    } else {
442        axis
443    }
444}
445
446/// The point on line `(line_point, line_dir)` closest to `point`.
447pub(crate) fn closest_point_on_line(
448    point: [f64; 3],
449    line_point: [f64; 3],
450    line_dir: [f64; 3],
451) -> [f64; 3] {
452    let dir = normalize_or(line_dir, [0.0, 1.0, 0.0]);
453    let t = dot3(sub3(point, line_point), dir);
454    [
455        line_point[0] + dir[0] * t,
456        line_point[1] + dir[1] * t,
457        line_point[2] + dir[2] * t,
458    ]
459}
460
461/// Clamp a degree value to `[-360, 360]`.
462fn clamp_deg(v: f64) -> f64 {
463    v.clamp(-360.0, 360.0)
464}
465
466/// Apply a feature's `inputParams.transform` (TRS, `rotationEuler` in DEGREES,
467/// `M = T·R·S`) to a LOCAL point → WORLD. Mirrors composing that degree-based TRS
468/// matrix and applying it to a point: `world = position + R·(scale ⊙ local)`, with `R` the exact
469/// intrinsic XYZ Euler order matrix the kernel bake uses (`rotate_euler_xyz_f64`).
470pub(crate) fn transform_point(transform: Option<&Value>, local: [f64; 3]) -> [f64; 3] {
471    let position = read_vec3(transform, "position", [0.0, 0.0, 0.0]);
472    let rotation_deg = read_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]);
473    let scale = read_vec3(transform, "scale", [1.0, 1.0, 1.0]);
474    let scaled = [local[0] * scale[0], local[1] * scale[1], local[2] * scale[2]];
475    let euler = [
476        rotation_deg[0].to_radians(),
477        rotation_deg[1].to_radians(),
478        rotation_deg[2].to_radians(),
479    ];
480    let rotated = rotate_euler_xyz_f64(scaled, euler);
481    [
482        rotated[0] + position[0],
483        rotated[1] + position[1],
484        rotated[2] + position[2],
485    ]
486}
487
488/// Read a `[x, y, z]` from a `transform` sub-field (numbers only; a missing /
489/// short array keeps the per-index default).
490fn read_vec3(transform: Option<&Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
491    crate::json_support::vec3_or(transform.and_then(|t| t.get(key)), default)
492}
493
494/// Read `params[key]` as a finite number: a JSON number, else a plain numeric
495/// string (e.g. `"12.5"`). Non-numeric / expression strings resolve to `0.0`
496/// (the engine pre-resolves expressions before building, so this is only the
497/// pure-geometry fallback). Mirrors the numeric input-param resolution fallback.
498fn resolve_number(params: &Value, key: &str) -> f64 {
499    match params.get(key) {
500        Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).unwrap_or(0.0),
501        Some(Value::String(s)) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
502        _ => 0.0,
503    }
504}
505
506// --- restyled leader geometry (matches the reference dimension-arrows image) --
507//
508// Each annotation draws a thick SILVER rod (a 3D tube) from the shared origin
509// `point_a` out to `point_b`, an ORANGE cone arrowhead at `point_b`, and a
510// single ORANGE origin sphere at the shared start point (deduped across the
511// annotations that share it — a cube's three axis dims share one corner). The
512// geometry is radially symmetric so it needs no camera orientation; it is fed to
513// the `feature-dim-leaders` overlay group as flat triangle buffers. Colors are
514// display sRGB written ~directly by the overlay shader (with a per-face shade for
515// depth), so use hex/255 — no linear conversion.
516
517/// Silver-grey rod shaft color (~0xccced1) — also the angle ARC tube.
518const SHAFT_RGB: [f32; 3] = [0.80, 0.81, 0.82];
519/// Orange cone + origin/handle-sphere color (#F5A623).
520const ORANGE_RGB: [f32; 3] = [0.961, 0.651, 0.137];
521/// Red — the angle gizmo's ZERO-reference (drawn DASHED) radial line.
522const RED_RGB: [f32; 3] = [0.902, 0.157, 0.157];
523/// Green — the angle gizmo's rotation-AXIS line.
524const GREEN_RGB: [f32; 3] = [0.204, 0.808, 0.267];
525
526/// Silver-rod shaft radius, CSS pixels (thick, reads as a 3D rod).
527const SHAFT_RAD_PX: f64 = 2.2;
528/// Arrowhead cone length, CSS pixels.
529const CONE_LEN_PX: f64 = 16.0;
530/// Arrowhead cone base radius, CSS pixels (fuller 3D cone).
531const CONE_RAD_PX: f64 = 6.0;
532/// Origin sphere radius, CSS pixels (medium, screen-constant). Shared with
533/// `EngineState::dimension_origin_pick` so the click hit-radius matches the drawn
534/// sphere exactly.
535pub(crate) const ORIGIN_SPHERE_RAD_PX: f64 = 7.0;
536
537/// The angle gizmo's ARC radius, CSS pixels (screen-constant — matching the previous
538/// `FEATURE_ANGLE_RADIUS_PX`). Shared with the engine so the drawn arc, the chip
539/// anchor (mid-sweep) and the drag hit-search all use the SAME radius.
540pub const ANGLE_ARC_RAD_PX: f64 = 120.0;
541/// The angle gizmo's ref/axis line radius, CSS pixels (thinner than the arc).
542const ANGLE_RAY_RAD_PX: f64 = 1.6;
543/// Degrees of sweep per arc tube segment (tessellation of the arc).
544const ARC_DEG_PER_SEG: f64 = 4.0;
545/// The RED zero-reference line's dash / gap length, CSS pixels.
546const DASH_LEN_PX: f64 = 6.0;
547const DASH_GAP_PX: f64 = 5.0;
548
549const TUBE_SEGMENTS: usize = 8;
550const CONE_SEGMENTS: usize = 16;
551const SPHERE_RINGS: usize = 6;
552const SPHERE_SECTORS: usize = 10;
553
554/// Build the world-space leader geometry for a set of annotations as flat
555/// triangle `(positions, colors)` buffers (9 position + 9 color floats per
556/// triangle), ready to feed the `feature-dim-leaders` overlay group as `tris`.
557/// Normals are omitted — the overlay parser computes a flat face normal per
558/// triangle, and the shader's per-face shade gives the rods/cones/spheres their
559/// 3D read. `world_per_pixel` keeps the rod/cone/sphere screen-constant.
560pub fn leaders_buffers(
561    annotations: &[FeatureDimAnnotation],
562    world_per_pixel: f64,
563) -> (Vec<f32>, Vec<f32>) {
564    let mut tb = TriBuf::default();
565    let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
566    let cone_len = CONE_LEN_PX * world_per_pixel;
567    let cone_rad = CONE_RAD_PX * world_per_pixel;
568    let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
569
570    // Draw the shared origin sphere once per distinct start point.
571    let mut origins: Vec<[f64; 3]> = Vec::new();
572    let mut add_origin = |tb: &mut TriBuf, a: [f64; 3]| {
573        if !origins.iter().any(|o| norm3(sub3(*o, a)) < 1e-6) {
574            push_sphere(tb, a, sphere_rad, ORANGE_RGB);
575            origins.push(a);
576        }
577    };
578
579    for ann in annotations {
580        match ann.kind {
581            FeatureDimKind::Angular => {
582                // The arc VERTEX gets the same orange origin sphere as a linear
583                // dim's `point_a`, so it is a visible mode-toggle target (the arc's
584                // sweep-END orange sphere is the angle DRAG handle, not a toggle).
585                // `add_origin` dedups against a linear origin at the same world
586                // point (a torus's `majorRadius` origin == this center), so no
587                // doubled geometry there.
588                add_origin(&mut tb, ann.center);
589                push_angle_gizmo(&mut tb, ann, world_per_pixel);
590            }
591            FeatureDimKind::Linear => {
592                let a = ann.point_a;
593                let b = ann.point_b;
594                let axis = sub3(b, a);
595                let len = norm3(axis);
596                add_origin(&mut tb, a);
597                if len < 1e-9 {
598                    continue;
599                }
600                let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
601                // The cone occupies the far end; the rod runs from origin to it.
602                let cl = cone_len.min(len * 0.9);
603                let shaft_end = [b[0] - dir[0] * cl, b[1] - dir[1] * cl, b[2] - dir[2] * cl];
604                push_tube(&mut tb, a, shaft_end, shaft_rad, SHAFT_RGB);
605                push_cone(&mut tb, shaft_end, b, cone_rad, ORANGE_RGB);
606            }
607        }
608    }
609    (tb.positions, tb.colors)
610}
611
612/// Append a PLAIN leader line `a → b` (thin silver rod, NO arrowhead cone / origin
613/// sphere — nothing that reads as grabbable) onto existing `(positions, colors)`
614/// triangle buffers. The NON-dimensional assembly-constraint overlays (coincident /
615/// parallel / …) draw their anchor-to-anchor leaders through this so the line
616/// styling stays in this ONE home (same silver + screen-constant radius as the
617/// dimension rods, slightly thinner because it carries no handle).
618pub fn append_plain_leader(
619    positions: &mut Vec<f32>,
620    colors: &mut Vec<f32>,
621    a: [f64; 3],
622    b: [f64; 3],
623    world_per_pixel: f64,
624) {
625    let mut tb = TriBuf {
626        positions: std::mem::take(positions),
627        colors: std::mem::take(colors),
628    };
629    push_tube(&mut tb, a, b, ANGLE_RAY_RAD_PX * world_per_pixel, SHAFT_RGB);
630    *positions = tb.positions;
631    *colors = tb.colors;
632}
633
634/// The free-motion glyph at a selected assembly component (what its mates
635/// leave it): a double-headed arrow through `center` per free slide, and per
636/// free rotation a rod along the axis line (through its point nearest
637/// `center`) with a curled arrow turning about it. Screen-constant sizes,
638/// world-space triangles in the overlay `tris` shape.
639pub fn freedom_glyph_buffers(
640    center: [f64; 3],
641    slides: &[[f64; 3]],
642    axes: &[([f64; 3], [f64; 3])],
643    world_per_pixel: f64,
644) -> (Vec<f32>, Vec<f32>) {
645    let mut tb = TriBuf::default();
646    let rod = SHAFT_RAD_PX * world_per_pixel;
647    let cone_len = CONE_LEN_PX * world_per_pixel;
648    let cone_rad = CONE_RAD_PX * world_per_pixel;
649    let half = GLYPH_SLIDE_HALF_PX * world_per_pixel;
650    for slide in slides {
651        let d = normalize_or(*slide, [1.0, 0.0, 0.0]);
652        let tip = |k: f64| [center[0] + d[0] * k, center[1] + d[1] * k, center[2] + d[2] * k];
653        push_tube(&mut tb, tip(-(half - cone_len)), tip(half - cone_len), rod, SLIDE_RGB);
654        push_cone(&mut tb, tip(half - cone_len), tip(half), cone_rad, SLIDE_RGB);
655        push_cone(&mut tb, tip(-(half - cone_len)), tip(-half), cone_rad, SLIDE_RGB);
656    }
657    for (point, direction) in axes {
658        let d = normalize_or(*direction, [0.0, 0.0, 1.0]);
659        // The axis line where it passes the component.
660        let foot = closest_point_on_line(center, *point, d);
661        let along = |k: f64| [foot[0] + d[0] * k, foot[1] + d[1] * k, foot[2] + d[2] * k];
662        let reach = GLYPH_AXIS_HALF_PX * world_per_pixel;
663        push_tube(&mut tb, along(-reach), along(reach), ANGLE_RAY_RAD_PX * world_per_pixel, TURN_RGB);
664        // A 270° curl about the axis, ending in an arrowhead.
665        let (u, v) = axis_basis(d);
666        let radius = GLYPH_CURL_RAD_PX * world_per_pixel;
667        let at = |angle: f64| {
668            let (c, s) = (angle.cos() * radius, angle.sin() * radius);
669            [foot[0] + u[0] * c + v[0] * s, foot[1] + u[1] * c + v[1] * s, foot[2] + u[2] * c + v[2] * s]
670        };
671        let sweep = 1.5 * std::f64::consts::PI;
672        let steps = 18;
673        for i in 0..steps {
674            let a0 = sweep * i as f64 / steps as f64;
675            let a1 = sweep * (i + 1) as f64 / steps as f64;
676            push_tube(&mut tb, at(a0), at(a1), rod, TURN_RGB);
677        }
678        let end = at(sweep);
679        let tangent = normalize_or(sub3(at(sweep), at(sweep - 0.05)), u);
680        let head = [end[0] + tangent[0] * cone_len, end[1] + tangent[1] * cone_len, end[2] + tangent[2] * cone_len];
681        push_cone(&mut tb, end, head, cone_rad, TURN_RGB);
682    }
683    (tb.positions, tb.colors)
684}
685
686/// Free-motion glyph colors and sizes (screen px).
687const SLIDE_RGB: [f32; 3] = [0.255, 0.624, 0.949];
688const TURN_RGB: [f32; 3] = [0.804, 0.380, 0.886];
689const GLYPH_SLIDE_HALF_PX: f64 = 48.0;
690const GLYPH_AXIS_HALF_PX: f64 = 60.0;
691const GLYPH_CURL_RAD_PX: f64 = 20.0;
692
693/// A flat triangle-soup accumulator (positions + per-vertex rgb colors).
694#[derive(Default)]
695struct TriBuf {
696    positions: Vec<f32>,
697    colors: Vec<f32>,
698}
699
700impl TriBuf {
701    fn tri(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], rgb: [f32; 3]) {
702        for p in [a, b, c] {
703            self.positions
704                .extend_from_slice(&[p[0] as f32, p[1] as f32, p[2] as f32]);
705            self.colors.extend_from_slice(&rgb);
706        }
707    }
708}
709
710/// Push a solid 3D rod (open-ended tube) from `a` to `b` with world `radius`.
711fn push_tube(tb: &mut TriBuf, a: [f64; 3], b: [f64; 3], radius: f64, rgb: [f32; 3]) {
712    let axis = sub3(b, a);
713    let len = norm3(axis);
714    if len < 1e-9 || radius <= 0.0 {
715        return;
716    }
717    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
718    let (u, v) = axis_basis(dir);
719    let ring = |center: [f64; 3], k: usize| -> [f64; 3] {
720        let ang = (k as f64 / TUBE_SEGMENTS as f64) * std::f64::consts::TAU;
721        let (c, s) = (ang.cos() * radius, ang.sin() * radius);
722        [
723            center[0] + u[0] * c + v[0] * s,
724            center[1] + u[1] * c + v[1] * s,
725            center[2] + u[2] * c + v[2] * s,
726        ]
727    };
728    for k in 0..TUBE_SEGMENTS {
729        let a0 = ring(a, k);
730        let a1 = ring(a, k + 1);
731        let b0 = ring(b, k);
732        let b1 = ring(b, k + 1);
733        tb.tri(a0, b0, b1, rgb);
734        tb.tri(a0, b1, a1, rgb);
735    }
736}
737
738/// Push a filled arrowhead cone: apex at `tip`, base circle of world `radius`
739/// centered at `base` (side facets + a base cap).
740fn push_cone(tb: &mut TriBuf, base: [f64; 3], tip: [f64; 3], radius: f64, rgb: [f32; 3]) {
741    let axis = sub3(tip, base);
742    let len = norm3(axis);
743    if len < 1e-9 || radius <= 0.0 {
744        return;
745    }
746    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
747    let (u, v) = axis_basis(dir);
748    let ring = |k: usize| -> [f64; 3] {
749        let ang = (k as f64 / CONE_SEGMENTS as f64) * std::f64::consts::TAU;
750        let (c, s) = (ang.cos() * radius, ang.sin() * radius);
751        [
752            base[0] + u[0] * c + v[0] * s,
753            base[1] + u[1] * c + v[1] * s,
754            base[2] + u[2] * c + v[2] * s,
755        ]
756    };
757    let mut prev = ring(0);
758    for k in 1..=CONE_SEGMENTS {
759        let cur = ring(k);
760        tb.tri(tip, prev, cur, rgb); // side facet
761        tb.tri(base, cur, prev, rgb); // base cap
762        prev = cur;
763    }
764}
765
766/// Push a filled UV sphere of world `radius` at `center` (flat-shaded facets).
767fn push_sphere(tb: &mut TriBuf, center: [f64; 3], radius: f64, rgb: [f32; 3]) {
768    if radius <= 0.0 {
769        return;
770    }
771    let point = |ring: usize, sector: usize| -> [f64; 3] {
772        let lat = std::f64::consts::PI * (ring as f64 / SPHERE_RINGS as f64)
773            - std::f64::consts::FRAC_PI_2;
774        let lon = std::f64::consts::TAU * (sector as f64 / SPHERE_SECTORS as f64);
775        [
776            center[0] + lat.cos() * lon.cos() * radius,
777            center[1] + lat.cos() * lon.sin() * radius,
778            center[2] + lat.sin() * radius,
779        ]
780    };
781    for r in 0..SPHERE_RINGS {
782        for sct in 0..SPHERE_SECTORS {
783            let p00 = point(r, sct);
784            let p01 = point(r, sct + 1);
785            let p10 = point(r + 1, sct);
786            let p11 = point(r + 1, sct + 1);
787            tb.tri(p00, p10, p11, rgb);
788            tb.tri(p00, p11, p01, rgb);
789        }
790    }
791}
792
793/// Push the angle gizmo (image-9 target) for an ANGULAR annotation: a light-grey
794/// ARC of screen-constant radius swept from `ref_dir` by `value` degrees about
795/// `axis` at `center`, an ORANGE handle SPHERE at the sweep end with an ORANGE
796/// CONE just past it along the arc tangent, a RED DASHED zero-reference line along
797/// `ref_dir`, and a GREEN line along `axis`. Sizing is screen-constant via
798/// `world_per_pixel` so the gizmo stays a fixed pixel size across zoom.
799fn push_angle_gizmo(tb: &mut TriBuf, ann: &FeatureDimAnnotation, world_per_pixel: f64) {
800    let center = ann.center;
801    let axis = ann.axis;
802    let start = ann.ref_dir;
803    let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
804    let ray_rad = ANGLE_RAY_RAD_PX * world_per_pixel;
805    let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
806    let cone_len = CONE_LEN_PX * world_per_pixel;
807    let cone_rad = CONE_RAD_PX * world_per_pixel;
808    let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
809    if radius <= 1e-9 {
810        return;
811    }
812    // A full 360° arc would close on itself; clamp the DRAWN sweep just under it
813    // (matches the overlay's ±359.9 draw clamp) while the chip still shows the
814    // real value.
815    let value = ann.value.clamp(-359.9, 359.9);
816    let value_rad = value.to_radians();
817
818    // The arc: sample from 0 → value and connect consecutive points with grey
819    // tube segments. The point at parameter `t` (radians) is
820    // `center + rotate(start, axis, t) * radius`.
821    let arc_point = |t: f64| -> [f64; 3] {
822        let dir = rotate_about_axis(start, axis, t);
823        [
824            center[0] + dir[0] * radius,
825            center[1] + dir[1] * radius,
826            center[2] + dir[2] * radius,
827        ]
828    };
829    let seg_count = ((value.abs() / ARC_DEG_PER_SEG).ceil() as usize).max(2);
830    let mut prev = arc_point(0.0);
831    for k in 1..=seg_count {
832        let t = value_rad * (k as f64 / seg_count as f64);
833        let cur = arc_point(t);
834        push_tube(tb, prev, cur, shaft_rad, SHAFT_RGB);
835        prev = cur;
836    }
837
838    // The sweep END: the orange handle sphere sits on the arc, the cone points
839    // just past it along the arc tangent (the direction of increasing angle).
840    let dir_end = rotate_about_axis(start, axis, value_rad);
841    let end_pt = [
842        center[0] + dir_end[0] * radius,
843        center[1] + dir_end[1] * radius,
844        center[2] + dir_end[2] * radius,
845    ];
846    push_sphere(tb, end_pt, sphere_rad, ORANGE_RGB);
847    // Tangent = d/dt rotate = axis × dir_end, signed by the sweep direction.
848    let sweep_sign = if value < 0.0 { -1.0 } else { 1.0 };
849    let tangent = normalize_or(cross3(axis, dir_end), dir_end);
850    let tangent = [tangent[0] * sweep_sign, tangent[1] * sweep_sign, tangent[2] * sweep_sign];
851    let cone_tip = [
852        end_pt[0] + tangent[0] * cone_len,
853        end_pt[1] + tangent[1] * cone_len,
854        end_pt[2] + tangent[2] * cone_len,
855    ];
856    push_cone(tb, end_pt, cone_tip, cone_rad, ORANGE_RGB);
857
858    // RED DASHED zero-reference line from the center out along `ref_dir` to the
859    // arc-start radius (short tube dashes with gaps).
860    let ref_end = [
861        center[0] + start[0] * radius,
862        center[1] + start[1] * radius,
863        center[2] + start[2] * radius,
864    ];
865    push_dashed(tb, center, ref_end, ray_rad, RED_RGB, world_per_pixel);
866
867    // GREEN rotation-axis line through the center (a solid tube, both sides).
868    let axis_len = radius * 0.7;
869    let axis_a = [
870        center[0] - axis[0] * axis_len,
871        center[1] - axis[1] * axis_len,
872        center[2] - axis[2] * axis_len,
873    ];
874    let axis_b = [
875        center[0] + axis[0] * axis_len,
876        center[1] + axis[1] * axis_len,
877        center[2] + axis[2] * axis_len,
878    ];
879    push_tube(tb, axis_a, axis_b, ray_rad, GREEN_RGB);
880}
881
882/// Push a DASHED line `a → b` as a series of short solid tube segments (dash then
883/// gap, in screen-constant CSS px). Used for the angle gizmo's red zero-reference.
884fn push_dashed(
885    tb: &mut TriBuf,
886    a: [f64; 3],
887    b: [f64; 3],
888    radius: f64,
889    rgb: [f32; 3],
890    world_per_pixel: f64,
891) {
892    let axis = sub3(b, a);
893    let len = norm3(axis);
894    if len < 1e-9 {
895        return;
896    }
897    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
898    let dash = (DASH_LEN_PX * world_per_pixel).max(1e-6);
899    let gap = (DASH_GAP_PX * world_per_pixel).max(1e-6);
900    let mut s = 0.0;
901    while s < len {
902        let e = (s + dash).min(len);
903        let p0 = [a[0] + dir[0] * s, a[1] + dir[1] * s, a[2] + dir[2] * s];
904        let p1 = [a[0] + dir[0] * e, a[1] + dir[1] * e, a[2] + dir[2] * e];
905        push_tube(tb, p0, p1, radius, rgb);
906        s = e + gap;
907    }
908}
909
910/// The world-space chip anchor for an ANGULAR annotation: the arc mid-sweep point
911/// at the screen-constant radius (`center + rotate(ref_dir, axis, value/2) *
912/// radius`) — i.e. `labelAnchor = vertex + bisector * radius`. Camera-dependent
913/// (via `world_per_pixel`), so the engine computes it per frame.
914pub fn angular_chip_anchor(ann: &FeatureDimAnnotation, world_per_pixel: f64) -> [f64; 3] {
915    let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
916    let value = ann.value.clamp(-359.9, 359.9);
917    let bisector = rotate_about_axis(ann.ref_dir, ann.axis, (value * 0.5).to_radians());
918    [
919        ann.center[0] + bisector[0] * radius,
920        ann.center[1] + bisector[1] * radius,
921        ann.center[2] + bisector[2] * radius,
922    ]
923}
924
925/// Screen-px hit radius for grabbing a dimension arrowHEAD — the cone base plus a
926/// little slack, so a click near the drawn arrowhead reliably grabs it. Shared by
927/// `EngineState::dimension_arrow_pick`.
928// Grab tolerance around a dimension arrowhead. Generous on purpose: egui only
929// reports a DRAG once the pointer has already moved a few px past the press, so a
930// tight radius makes the arrow feel un-grabbable. This gives a comfortable target.
931pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
932
933/// The world-space arrowHEAD handle point of an annotation — the drag grab target
934/// (`EngineState::dimension_arrow_pick`). LINEAR: the orange cone tip at
935/// `point_b`. ANGULAR: the orange sweep-END handle sphere on the arc (`center +
936/// rotate(ref_dir, axis, value°) * radius`, value clamped to the drawn ±359.9° so
937/// the grab point matches the drawn handle). Camera-dependent for angular (via
938/// `world_per_pixel`), so it is computed per frame.
939pub(crate) fn arrow_handle_point(
940    ann: &FeatureDimAnnotation,
941    world_per_pixel: f64,
942) -> [f64; 3] {
943    match ann.kind {
944        FeatureDimKind::Linear => ann.point_b,
945        FeatureDimKind::Angular => {
946            let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
947            let value = ann.value.clamp(-359.9, 359.9);
948            let dir = rotate_about_axis(ann.ref_dir, ann.axis, value.to_radians());
949            [
950                ann.center[0] + dir[0] * radius,
951                ann.center[1] + dir[1] * radius,
952                ann.center[2] + dir[2] * radius,
953            ]
954        }
955    }
956}
957
958/// Normalize `v`, or return `fallback` if `v` is ~zero-length.
959fn normalize_or(v: [f64; 3], fallback: [f64; 3]) -> [f64; 3] {
960    let n = norm3(v);
961    if n < 1e-12 {
962        fallback
963    } else {
964        [v[0] / n, v[1] / n, v[2] / n]
965    }
966}
967
968/// A stable unit vector ⟂ `direction` (a port of the previous app's arbitrary-perpendicular helper).
969fn arbitrary_perpendicular(direction: [f64; 3]) -> [f64; 3] {
970    if norm3(direction) <= 1e-12 {
971        return [0.0, 0.0, 1.0];
972    }
973    let seed = if dot3(direction, [0.0, 0.0, 1.0]).abs() < 0.9 {
974        [0.0, 0.0, 1.0]
975    } else {
976        [0.0, 1.0, 0.0]
977    };
978    let mut perp = cross3(direction, seed);
979    if norm3(perp) <= 1e-12 {
980        perp = cross3(direction, [1.0, 0.0, 0.0]);
981    }
982    if norm3(perp) <= 1e-12 {
983        [1.0, 0.0, 0.0]
984    } else {
985        normalize_or(perp, [1.0, 0.0, 0.0])
986    }
987}
988
989/// Rotate `v` by `angle` radians about unit `axis` (Rodrigues). Shared with the
990/// engine's angular drag/chip anchoring.
991pub fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
992    crate::geometry3d::rotate3(v, normalize_or(axis, [0.0, 1.0, 0.0]), angle)
993}
994
995/// A radially-symmetric perpendicular basis `(u, v)` for a unit `dir`.
996fn axis_basis(dir: [f64; 3]) -> ([f64; 3], [f64; 3]) {
997    let seed = if dir[0].abs() < 0.9 {
998        [1.0, 0.0, 0.0]
999    } else {
1000        [0.0, 1.0, 0.0]
1001    };
1002    let mut u = cross3(dir, seed);
1003    let un = norm3(u);
1004    if un < 1e-9 {
1005        u = [0.0, 1.0, 0.0];
1006    } else {
1007        u = [u[0] / un, u[1] / un, u[2] / un];
1008    }
1009    let v = cross3(dir, u);
1010    let vn = norm3(v).max(1e-9);
1011    (u, [v[0] / vn, v[1] / vn, v[2] / vn])
1012}
1013