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