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/// A flat triangle-soup accumulator (positions + per-vertex rgb colors).
628#[derive(Default)]
629struct TriBuf {
630    positions: Vec<f32>,
631    colors: Vec<f32>,
632}
633
634impl TriBuf {
635    fn tri(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], rgb: [f32; 3]) {
636        for p in [a, b, c] {
637            self.positions
638                .extend_from_slice(&[p[0] as f32, p[1] as f32, p[2] as f32]);
639            self.colors.extend_from_slice(&rgb);
640        }
641    }
642}
643
644/// Push a solid 3D rod (open-ended tube) from `a` to `b` with world `radius`.
645fn push_tube(tb: &mut TriBuf, a: [f64; 3], b: [f64; 3], radius: f64, rgb: [f32; 3]) {
646    let axis = sub3(b, a);
647    let len = norm3(axis);
648    if len < 1e-9 || radius <= 0.0 {
649        return;
650    }
651    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
652    let (u, v) = axis_basis(dir);
653    let ring = |center: [f64; 3], k: usize| -> [f64; 3] {
654        let ang = (k as f64 / TUBE_SEGMENTS as f64) * std::f64::consts::TAU;
655        let (c, s) = (ang.cos() * radius, ang.sin() * radius);
656        [
657            center[0] + u[0] * c + v[0] * s,
658            center[1] + u[1] * c + v[1] * s,
659            center[2] + u[2] * c + v[2] * s,
660        ]
661    };
662    for k in 0..TUBE_SEGMENTS {
663        let a0 = ring(a, k);
664        let a1 = ring(a, k + 1);
665        let b0 = ring(b, k);
666        let b1 = ring(b, k + 1);
667        tb.tri(a0, b0, b1, rgb);
668        tb.tri(a0, b1, a1, rgb);
669    }
670}
671
672/// Push a filled arrowhead cone: apex at `tip`, base circle of world `radius`
673/// centered at `base` (side facets + a base cap).
674fn push_cone(tb: &mut TriBuf, base: [f64; 3], tip: [f64; 3], radius: f64, rgb: [f32; 3]) {
675    let axis = sub3(tip, base);
676    let len = norm3(axis);
677    if len < 1e-9 || radius <= 0.0 {
678        return;
679    }
680    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
681    let (u, v) = axis_basis(dir);
682    let ring = |k: usize| -> [f64; 3] {
683        let ang = (k as f64 / CONE_SEGMENTS as f64) * std::f64::consts::TAU;
684        let (c, s) = (ang.cos() * radius, ang.sin() * radius);
685        [
686            base[0] + u[0] * c + v[0] * s,
687            base[1] + u[1] * c + v[1] * s,
688            base[2] + u[2] * c + v[2] * s,
689        ]
690    };
691    let mut prev = ring(0);
692    for k in 1..=CONE_SEGMENTS {
693        let cur = ring(k);
694        tb.tri(tip, prev, cur, rgb); // side facet
695        tb.tri(base, cur, prev, rgb); // base cap
696        prev = cur;
697    }
698}
699
700/// Push a filled UV sphere of world `radius` at `center` (flat-shaded facets).
701fn push_sphere(tb: &mut TriBuf, center: [f64; 3], radius: f64, rgb: [f32; 3]) {
702    if radius <= 0.0 {
703        return;
704    }
705    let point = |ring: usize, sector: usize| -> [f64; 3] {
706        let lat = std::f64::consts::PI * (ring as f64 / SPHERE_RINGS as f64)
707            - std::f64::consts::FRAC_PI_2;
708        let lon = std::f64::consts::TAU * (sector as f64 / SPHERE_SECTORS as f64);
709        [
710            center[0] + lat.cos() * lon.cos() * radius,
711            center[1] + lat.cos() * lon.sin() * radius,
712            center[2] + lat.sin() * radius,
713        ]
714    };
715    for r in 0..SPHERE_RINGS {
716        for sct in 0..SPHERE_SECTORS {
717            let p00 = point(r, sct);
718            let p01 = point(r, sct + 1);
719            let p10 = point(r + 1, sct);
720            let p11 = point(r + 1, sct + 1);
721            tb.tri(p00, p10, p11, rgb);
722            tb.tri(p00, p11, p01, rgb);
723        }
724    }
725}
726
727/// Push the angle gizmo (image-9 target) for an ANGULAR annotation: a light-grey
728/// ARC of screen-constant radius swept from `ref_dir` by `value` degrees about
729/// `axis` at `center`, an ORANGE handle SPHERE at the sweep end with an ORANGE
730/// CONE just past it along the arc tangent, a RED DASHED zero-reference line along
731/// `ref_dir`, and a GREEN line along `axis`. Sizing is screen-constant via
732/// `world_per_pixel` so the gizmo stays a fixed pixel size across zoom.
733fn push_angle_gizmo(tb: &mut TriBuf, ann: &FeatureDimAnnotation, world_per_pixel: f64) {
734    let center = ann.center;
735    let axis = ann.axis;
736    let start = ann.ref_dir;
737    let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
738    let ray_rad = ANGLE_RAY_RAD_PX * world_per_pixel;
739    let shaft_rad = SHAFT_RAD_PX * world_per_pixel;
740    let cone_len = CONE_LEN_PX * world_per_pixel;
741    let cone_rad = CONE_RAD_PX * world_per_pixel;
742    let sphere_rad = ORIGIN_SPHERE_RAD_PX * world_per_pixel;
743    if radius <= 1e-9 {
744        return;
745    }
746    // A full 360° arc would close on itself; clamp the DRAWN sweep just under it
747    // (matches the overlay's ±359.9 draw clamp) while the chip still shows the
748    // real value.
749    let value = ann.value.clamp(-359.9, 359.9);
750    let value_rad = value.to_radians();
751
752    // The arc: sample from 0 → value and connect consecutive points with grey
753    // tube segments. The point at parameter `t` (radians) is
754    // `center + rotate(start, axis, t) * radius`.
755    let arc_point = |t: f64| -> [f64; 3] {
756        let dir = rotate_about_axis(start, axis, t);
757        [
758            center[0] + dir[0] * radius,
759            center[1] + dir[1] * radius,
760            center[2] + dir[2] * radius,
761        ]
762    };
763    let seg_count = ((value.abs() / ARC_DEG_PER_SEG).ceil() as usize).max(2);
764    let mut prev = arc_point(0.0);
765    for k in 1..=seg_count {
766        let t = value_rad * (k as f64 / seg_count as f64);
767        let cur = arc_point(t);
768        push_tube(tb, prev, cur, shaft_rad, SHAFT_RGB);
769        prev = cur;
770    }
771
772    // The sweep END: the orange handle sphere sits on the arc, the cone points
773    // just past it along the arc tangent (the direction of increasing angle).
774    let dir_end = rotate_about_axis(start, axis, value_rad);
775    let end_pt = [
776        center[0] + dir_end[0] * radius,
777        center[1] + dir_end[1] * radius,
778        center[2] + dir_end[2] * radius,
779    ];
780    push_sphere(tb, end_pt, sphere_rad, ORANGE_RGB);
781    // Tangent = d/dt rotate = axis × dir_end, signed by the sweep direction.
782    let sweep_sign = if value < 0.0 { -1.0 } else { 1.0 };
783    let tangent = normalize_or(cross3(axis, dir_end), dir_end);
784    let tangent = [tangent[0] * sweep_sign, tangent[1] * sweep_sign, tangent[2] * sweep_sign];
785    let cone_tip = [
786        end_pt[0] + tangent[0] * cone_len,
787        end_pt[1] + tangent[1] * cone_len,
788        end_pt[2] + tangent[2] * cone_len,
789    ];
790    push_cone(tb, end_pt, cone_tip, cone_rad, ORANGE_RGB);
791
792    // RED DASHED zero-reference line from the center out along `ref_dir` to the
793    // arc-start radius (short tube dashes with gaps).
794    let ref_end = [
795        center[0] + start[0] * radius,
796        center[1] + start[1] * radius,
797        center[2] + start[2] * radius,
798    ];
799    push_dashed(tb, center, ref_end, ray_rad, RED_RGB, world_per_pixel);
800
801    // GREEN rotation-axis line through the center (a solid tube, both sides).
802    let axis_len = radius * 0.7;
803    let axis_a = [
804        center[0] - axis[0] * axis_len,
805        center[1] - axis[1] * axis_len,
806        center[2] - axis[2] * axis_len,
807    ];
808    let axis_b = [
809        center[0] + axis[0] * axis_len,
810        center[1] + axis[1] * axis_len,
811        center[2] + axis[2] * axis_len,
812    ];
813    push_tube(tb, axis_a, axis_b, ray_rad, GREEN_RGB);
814}
815
816/// Push a DASHED line `a → b` as a series of short solid tube segments (dash then
817/// gap, in screen-constant CSS px). Used for the angle gizmo's red zero-reference.
818fn push_dashed(
819    tb: &mut TriBuf,
820    a: [f64; 3],
821    b: [f64; 3],
822    radius: f64,
823    rgb: [f32; 3],
824    world_per_pixel: f64,
825) {
826    let axis = sub3(b, a);
827    let len = norm3(axis);
828    if len < 1e-9 {
829        return;
830    }
831    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
832    let dash = (DASH_LEN_PX * world_per_pixel).max(1e-6);
833    let gap = (DASH_GAP_PX * world_per_pixel).max(1e-6);
834    let mut s = 0.0;
835    while s < len {
836        let e = (s + dash).min(len);
837        let p0 = [a[0] + dir[0] * s, a[1] + dir[1] * s, a[2] + dir[2] * s];
838        let p1 = [a[0] + dir[0] * e, a[1] + dir[1] * e, a[2] + dir[2] * e];
839        push_tube(tb, p0, p1, radius, rgb);
840        s = e + gap;
841    }
842}
843
844/// The world-space chip anchor for an ANGULAR annotation: the arc mid-sweep point
845/// at the screen-constant radius (`center + rotate(ref_dir, axis, value/2) *
846/// radius`) — i.e. `labelAnchor = vertex + bisector * radius`. Camera-dependent
847/// (via `world_per_pixel`), so the engine computes it per frame.
848pub fn angular_chip_anchor(ann: &FeatureDimAnnotation, world_per_pixel: f64) -> [f64; 3] {
849    let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
850    let value = ann.value.clamp(-359.9, 359.9);
851    let bisector = rotate_about_axis(ann.ref_dir, ann.axis, (value * 0.5).to_radians());
852    [
853        ann.center[0] + bisector[0] * radius,
854        ann.center[1] + bisector[1] * radius,
855        ann.center[2] + bisector[2] * radius,
856    ]
857}
858
859/// Screen-px hit radius for grabbing a dimension arrowHEAD — the cone base plus a
860/// little slack, so a click near the drawn arrowhead reliably grabs it. Shared by
861/// `EngineState::dimension_arrow_pick`.
862// Grab tolerance around a dimension arrowhead. Generous on purpose: egui only
863// reports a DRAG once the pointer has already moved a few px past the press, so a
864// tight radius makes the arrow feel un-grabbable. This gives a comfortable target.
865pub(crate) const ARROW_HANDLE_HIT_RAD_PX: f64 = CONE_RAD_PX + 12.0;
866
867/// The world-space arrowHEAD handle point of an annotation — the drag grab target
868/// (`EngineState::dimension_arrow_pick`). LINEAR: the orange cone tip at
869/// `point_b`. ANGULAR: the orange sweep-END handle sphere on the arc (`center +
870/// rotate(ref_dir, axis, value°) * radius`, value clamped to the drawn ±359.9° so
871/// the grab point matches the drawn handle). Camera-dependent for angular (via
872/// `world_per_pixel`), so it is computed per frame.
873pub(crate) fn arrow_handle_point(
874    ann: &FeatureDimAnnotation,
875    world_per_pixel: f64,
876) -> [f64; 3] {
877    match ann.kind {
878        FeatureDimKind::Linear => ann.point_b,
879        FeatureDimKind::Angular => {
880            let radius = ANGLE_ARC_RAD_PX * world_per_pixel;
881            let value = ann.value.clamp(-359.9, 359.9);
882            let dir = rotate_about_axis(ann.ref_dir, ann.axis, value.to_radians());
883            [
884                ann.center[0] + dir[0] * radius,
885                ann.center[1] + dir[1] * radius,
886                ann.center[2] + dir[2] * radius,
887            ]
888        }
889    }
890}
891
892/// Normalize `v`, or return `fallback` if `v` is ~zero-length.
893fn normalize_or(v: [f64; 3], fallback: [f64; 3]) -> [f64; 3] {
894    let n = norm3(v);
895    if n < 1e-12 {
896        fallback
897    } else {
898        [v[0] / n, v[1] / n, v[2] / n]
899    }
900}
901
902/// A stable unit vector ⟂ `direction` (a port of the previous app's arbitrary-perpendicular helper).
903fn arbitrary_perpendicular(direction: [f64; 3]) -> [f64; 3] {
904    if norm3(direction) <= 1e-12 {
905        return [0.0, 0.0, 1.0];
906    }
907    let seed = if dot3(direction, [0.0, 0.0, 1.0]).abs() < 0.9 {
908        [0.0, 0.0, 1.0]
909    } else {
910        [0.0, 1.0, 0.0]
911    };
912    let mut perp = cross3(direction, seed);
913    if norm3(perp) <= 1e-12 {
914        perp = cross3(direction, [1.0, 0.0, 0.0]);
915    }
916    if norm3(perp) <= 1e-12 {
917        [1.0, 0.0, 0.0]
918    } else {
919        normalize_or(perp, [1.0, 0.0, 0.0])
920    }
921}
922
923/// Rotate `v` by `angle` radians about unit `axis` (Rodrigues). Shared with the
924/// engine's angular drag/chip anchoring.
925pub fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
926    crate::geometry3d::rotate3(v, normalize_or(axis, [0.0, 1.0, 0.0]), angle)
927}
928
929/// A radially-symmetric perpendicular basis `(u, v)` for a unit `dir`.
930fn axis_basis(dir: [f64; 3]) -> ([f64; 3], [f64; 3]) {
931    let seed = if dir[0].abs() < 0.9 {
932        [1.0, 0.0, 0.0]
933    } else {
934        [0.0, 1.0, 0.0]
935    };
936    let mut u = cross3(dir, seed);
937    let un = norm3(u);
938    if un < 1e-9 {
939        u = [0.0, 1.0, 0.0];
940    } else {
941        u = [u[0] / un, u[1] / un, u[2] / un];
942    }
943    let v = cross3(dir, u);
944    let vn = norm3(v).max(1e-9);
945    (u, [v[0] / vn, v[1] / vn, v[2] / vn])
946}
947
948// BREP private tests: a26c339f6f235f3d