Skip to main content

brep_render/engine_state/
feature_dims.rs

1use super::*;
2use super::sketch_edit_ops::is_plain_number_literal;
3use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
4
5/// The world-space overlay group carrying the FD leaders + arrowheads.
6const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
7
8/// The role a dimension-gizmo hit region plays. An `Arrow` is a value-drag handle
9/// (a linear leader or the angular arc handle, keyed by its `field_key`); `Origin`
10/// is the shared origin/center sphere (the mode toggle). Both share ONE region
11/// list so the pick + the drawn outline can never drift.
12#[derive(Clone, Debug)]
13enum DimRole {
14    Arrow(String),
15    Origin,
16}
17
18impl EngineState {
19    /// The armed ◎ gizmo mode: `"none"`, `"transform"`, or `"dimension"`. Drives
20    /// the ◎ highlight + the app's dimension-overlay draw / input routing.
21    pub fn gizmo_mode(&self) -> &'static str {
22        match self.transform_gizmo.mode {
23            GizmoMode::None => "none",
24            GizmoMode::Transform => "transform",
25            GizmoMode::Dimension => "dimension",
26        }
27    }
28
29    /// Whether the DIMENSION gizmo is armed for THIS feature (drives the ◎
30    /// dimension-mode highlight).
31    pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
32        matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
33            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
34    }
35
36    /// The dimension-armed feature id (empty unless in dimension mode).
37    pub fn dimension_armed_feature(&self) -> String {
38        if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
39            self.transform_gizmo.feature_id.clone().unwrap_or_default()
40        } else {
41            String::new()
42        }
43    }
44
45    /// Arm the DIMENSION gizmo for `feature_id`: hide the transform widget, show
46    /// the annotation overlay. Re-arming a different feature moves it.
47    pub fn arm_dimension(&mut self, feature_id: &str) {
48        // The widget slot is shared — an armed component Move gizmo yields.
49        self.component_move_reset();
50        self.transform_gizmo.feature_id = Some(feature_id.to_string());
51        self.transform_gizmo.mode = GizmoMode::Dimension;
52        self.transform_gizmo.drag = None;
53        // The transform widget and the dimension overlay are mutually exclusive.
54        let _ = self.widgets.set_transform_json("null");
55        self.refresh_feature_dimension_overlay();
56        self.dirty = true;
57    }
58
59    // --- The orange center-sphere ◎ TOGGLE (dimension ↔ transform) ---------
60    //
61    // A single orange sphere sits at the gizmo center in BOTH modes: the
62    // transform gizmo's `HANDLE_CENTER` sphere and the dimension arrows' shared
63    // origin sphere project to the same point. Clicking it flips the two modes,
64    // mirroring the old app's `CombinedTransformControls` center-handle toggle
65    // (pointer-down on `HANDLE_CENTER` calls
66    // `toggleDisplayMode`). The viewport routes a bare CLICK here; a DRAG on the
67    // center still free-moves via `transform_press` (unchanged).
68
69    /// Whether a screen-px pick in TRANSFORM mode lands on the orange CENTER
70    /// free-move sphere (`HANDLE_CENTER`). The viewport uses this to make a bare
71    /// click on the center TOGGLE to the dimension arrows (via
72    /// [`toggle_to_dimension`](Self::toggle_to_dimension)) instead of swallowing
73    /// it as a generic handle click. False in any other gizmo mode.
74    pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
75        matches!(self.transform_gizmo.mode, GizmoMode::Transform)
76            && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
77    }
78
79    /// Whether a screen-px pick in DIMENSION mode lands on an orange ORIGIN
80    /// sphere of the armed feature's dimension arrows. Each distinct annotation
81    /// draws such a sphere — a LINEAR dim at its `point_a` (a cube's three axis
82    /// dims share one, a cone/pyramid draw two), an ANGULAR dim at its arc
83    /// `center` (the vertex; its sweep-END sphere is the angle DRAG handle, not a
84    /// toggle) — so every one is projected via the camera and hit-tested against
85    /// the screen-constant sphere radius. The viewport uses this to TOGGLE back to
86    /// the transform gizmo (via [`toggle_to_transform`](Self::toggle_to_transform)),
87    /// which is the ONLY way an angular-only feature (a revolve) reaches transform.
88    /// False in any other gizmo mode. The hit radius mirrors the gizmo center's own
89    /// tolerance (`PX_CENTER_RAD + 2.0`, transform.rs) so the two toggle targets match.
90    pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
91        // The origin sphere (LINEAR at `point_a`, ANGULAR at the arc `center`) is an
92        // `Origin`-role region. 2D-test the cursor against the SAME screen-space
93        // regions the outline draws — a point-in-circle test, so what is outlined
94        // is exactly what toggles.
95        let p = [x as f32, y as f32];
96        self.dimension_hit_regions()
97            .into_iter()
98            .any(|(role, shape)| matches!(role, DimRole::Origin) && shape.contains(p))
99    }
100
101    /// Whether a screen-px pick in DIMENSION mode lands on a dimension ARROWHEAD
102    /// (a linear leader's orange cone TIP at `point_b`, or an angular arc's orange
103    /// sweep-END handle sphere). Returns the grabbed annotation's `field_key` — the
104    /// viewport routes a DRAG that starts here to [`feature_dimension_drag`](Self::
105    /// feature_dimension_drag), editing that param live (Fix 4). `None` in any other
106    /// gizmo mode / when no arrowhead is under the pointer. Distinct from
107    /// [`dimension_origin_pick`](Self::dimension_origin_pick): that grabs the SHARED
108    /// origin sphere (a mode toggle), this grabs an arrowHEAD (a value edit). The
109    /// nearest arrowhead within the screen-constant hit radius wins.
110    pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
111        // The NEAREST `Arrow`-role region containing the cursor wins. LINEAR: the
112        // WHOLE leader CAPSULE (`point_a → point_b`) is grabbable, so a cursor
113        // anywhere on the visible shaft grabs it — even when `point_b` crosses
114        // BEHIND the eye (the reported sizeY failure), because in ortho the whole
115        // leader still projects (and in perspective the region is front-clipped to
116        // its visible part). ANGULAR: the arc sweep-END handle CIRCLE. These are
117        // the SAME screen-space regions the outline draws, so what is outlined is
118        // exactly what grabs.
119        let p = [x as f32, y as f32];
120        let mut best: Option<(f32, String)> = None;
121        for (role, shape) in self.dimension_hit_regions() {
122            if let DimRole::Arrow(key) = role {
123                let d = shape.spine_distance(p);
124                if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
125                    best = Some((d, key));
126                }
127            }
128        }
129        best.map(|(_, key)| key)
130    }
131
132    /// The authoritative SCREEN-space (viewport-local px) pickable regions of the
133    /// armed feature's dimension gizmo, each paired with its [`DimRole`]. The
134    /// SINGLE source `dimension_arrow_pick` (its `Arrow` regions), `dimension_origin_pick`
135    /// (its `Origin` regions), and `dimension_hit_areas_json` (draws them ALL) all
136    /// consume — so the grabbable area is exactly the drawn outline. Projection +
137    /// the perspective front-clip happen ONCE in [`brep_gizmos::hit_region`].
138    ///   * LINEAR → a leader CAPSULE (`point_a → point_b`, `ARROW_HANDLE_HIT_RAD_PX`)
139    ///     with role `Arrow` + an origin CIRCLE (`point_a`, `ORIGIN_SPHERE_RAD_PX + 2`)
140    ///     with role `Origin`.
141    ///   * ANGULAR → an arc-handle CIRCLE (`arrow_handle_point`,
142    ///     `ARROW_HANDLE_HIT_RAD_PX`) with role `Arrow` + an arc-center origin
143    ///     CIRCLE (`center`, `ORIGIN_SPHERE_RAD_PX + 2`) with role `Origin`.
144    /// Origin balls are deduped by world position (a torus's linear origin + its
145    /// angular center coincide) so they match the single drawn sphere. `[]` unless
146    /// the DIMENSION gizmo is armed for a feature.
147    fn dimension_hit_regions(&self) -> Vec<(DimRole, HitShape)> {
148        if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
149            return Vec::new();
150        }
151        let feature = self.dimension_armed_feature();
152        if feature.is_empty() {
153            return Vec::new();
154        }
155        let wpp = self.camera.world_per_pixel();
156        let arrow_px = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
157        let origin_px = (crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0) as f32;
158        let cam = &self.camera;
159        let mut out: Vec<(DimRole, HitShape)> = Vec::new();
160        let mut origins: Vec<[f64; 3]> = Vec::new();
161        for ann in self.feature_dimension_annotations(&feature) {
162            match ann.kind {
163                crate::feature_dimensions::FeatureDimKind::Linear => {
164                    if let Some(shape) = segment_region(cam, ann.point_a, ann.point_b, arrow_px) {
165                        out.push((DimRole::Arrow(ann.field_key.clone()), shape));
166                    }
167                    push_origin_region(&mut out, &mut origins, cam, ann.point_a, origin_px);
168                }
169                crate::feature_dimensions::FeatureDimKind::Angular => {
170                    let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
171                    if let Some(shape) = point_region(cam, handle, arrow_px) {
172                        out.push((DimRole::Arrow(ann.field_key.clone()), shape));
173                    }
174                    push_origin_region(&mut out, &mut origins, cam, ann.center, origin_px);
175                }
176            }
177        }
178        out
179    }
180
181    /// DEBUG overlay: the EXACT SCREEN-space (viewport-local px) pickable regions
182    /// of the armed feature's dimension gizmo — the SAME regions
183    /// `dimension_arrow_pick` + `dimension_origin_pick` 2D-test the cursor against
184    /// ([`dimension_hit_regions`](Self::dimension_hit_regions)) — so the red
185    /// outline can NEVER drift from the grabbable area. Each item is a
186    /// `{ kind:"capsule", a:[x,y], b:[x,y], r }` (linear leaders) or
187    /// `{ kind:"circle", c:[x,y], r }` (origin / arc-handle spheres); the app only
188    /// offsets by `rect.min`. `[]` unless the DIMENSION gizmo is armed.
189    pub fn dimension_hit_areas_json(&self) -> String {
190        let regions = self.dimension_hit_regions();
191        super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
192    }
193
194    /// Toggle the armed ◎ gizmo from TRANSFORM to DIMENSION for the currently
195    /// transform-armed feature (the orange center-sphere click). No-op unless a
196    /// feature is transform-armed.
197    pub fn toggle_to_dimension(&mut self) {
198        let feature = self.transform_armed_feature();
199        if !feature.is_empty() && self.feature_dimension_annotations_json(&feature) != "[]" {
200            self.arm_dimension(&feature);
201        }
202    }
203
204    /// Toggle the armed ◎ gizmo from DIMENSION to TRANSFORM for the currently
205    /// dimension-armed feature (the orange origin-sphere click). No-op unless a
206    /// feature is dimension-armed.
207    pub fn toggle_to_transform(&mut self) {
208        let feature = self.dimension_armed_feature();
209        if feature.is_empty() {
210            return;
211        }
212        // A PLANE has NO transform — its placement is fully the orientation +
213        // `offset_distance` the offset dimension gizmo drives — so it never gets a
214        // transform gizmo: the origin-sphere toggle stays in dimension mode. (Other
215        // dim features, e.g. a revolve, still toggle to their transform gizmo.)
216        if let Some(index) = self.history.index_of(&feature) {
217            if self.history.feature_type(index).as_deref() == Some("P") {
218                return;
219            }
220        }
221        self.arm_transform(&feature);
222    }
223
224    /// The linear dimension annotations for `feature_id` (resolving expression
225    /// params against the live history env first). `[]` for a feature type with
226    /// no FD-1 builder / a missing feature.
227    fn feature_dimension_annotations(
228        &self,
229        feature_id: &str,
230    ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
231        let Some(index) = self.history.index_of(feature_id) else {
232            return Vec::new();
233        };
234        let Some(feature_type) = self.history.feature_type(index) else {
235            return Vec::new();
236        };
237        let Some(params) = self.history.feature_params(index) else {
238            return Vec::new();
239        };
240        let resolved = self.resolve_param_expressions(&params);
241        // Resolve any scene references the builder needs (extrude profile plane,
242        // revolve axis line) from the run report's profiles/axes — keyed off the
243        // ORIGINAL params so reference-name strings are read verbatim.
244        let refs = self.feature_dimension_refs(&feature_type, &params);
245        crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
246    }
247
248    /// Resolve the scene references a feature-dimension builder needs beyond its
249    /// pure params: the extrude/revolve profile PLANE (center + normal) and the
250    /// revolve AXIS line. Sourced from the run report the engine already holds —
251    /// `sketch_profiles` (the sketch's world profile, which survives being
252    /// consumed by the extrude/revolve since only solids honor `removed`) and
253    /// `sketch_axes` (a sketch's published axis lines) — with resident-scene
254    /// fallbacks for a profile that is a solid FACE (its display-mesh plane, see
255    /// [`Self::lookup_profile_plane`]) and an axis that is a solid EDGE (its
256    /// polyline). Empty for any other feature type; missing pieces stay `None`
257    /// so the builder degrades to `[]` gracefully.
258    fn feature_dimension_refs(
259        &self,
260        feature_type: &str,
261        params: &serde_json::Value,
262    ) -> crate::feature_dimensions::ResolvedRefs {
263        let mut refs = crate::feature_dimensions::ResolvedRefs::default();
264        match feature_type {
265            "E" => {
266                if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
267                    refs.profile_center = Some(center);
268                    refs.profile_normal = Some(normal);
269                }
270            }
271            "R" => {
272                if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
273                    refs.profile_center = Some(center);
274                    refs.profile_normal = Some(normal);
275                }
276                if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
277                    refs.axis_point = Some(point);
278                    refs.axis_dir = Some(dir);
279                }
280            }
281            "P" => {
282                // The plane feature registers ONE scene frame under its own id; the
283                // offset dim hangs off that resolved plane (origin + z-axis normal).
284                if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
285                    if let Some((_, frame)) =
286                        self.construction_frames.iter().find(|(name, _)| name == id)
287                    {
288                        refs.plane_origin = Some(vec3_to_arr(frame.origin));
289                        refs.plane_normal = Some(fd_normalize3(vec3_to_arr(frame.z_axis)));
290                        // Screen-constant handle stub for the offset ≈ 0 case (~48 px).
291                        refs.plane_dim_length = Some(self.camera.world_per_pixel() * 48.0);
292                    }
293                }
294            }
295            _ => {}
296        }
297        refs
298    }
299
300    /// Resolve a `profile` reference param to the world plane the extrude/revolve
301    /// gizmo hangs off, as `(center, unit normal)`. A SKETCH profile first (its
302    /// outer-loop centroid + `z_axis`), else a resident solid FACE named by the
303    /// reference (the area-weighted centroid + outward normal of its display
304    /// mesh) — the same two sources the kernel's extrude/revolve accept for
305    /// `profile` (`scene.resolve_profile`, then `resolve_face` →
306    /// `face_profile`, whose `z_axis` is likewise the OUTWARD face normal, so the
307    /// arc's `orient_revolve_axis` sign matches the kernel's sweep direction).
308    /// `None` when neither resolves — a face whose owning solid was later
309    /// consumed (a boolean target) is no longer in the scene, and the builder
310    /// then degrades to `[]` as before.
311    fn lookup_profile_plane(
312        &self,
313        profile_param: Option<&serde_json::Value>,
314    ) -> Option<([f64; 3], [f64; 3])> {
315        if let Some(profile) = self.lookup_sketch_profile(profile_param) {
316            return Some((sketch_profile_centroid(profile), vec3_to_arr(profile.z_axis)));
317        }
318        let name = first_reference_name(profile_param?)?;
319        self.scene.face_plane_world(&name)
320    }
321
322    /// Resolve a `profile` reference param to the sketch profile the engine holds
323    /// (exact name, or the `:PROFILE`-suffixed form — mirrors `SceneMap::resolve_profile`).
324    fn lookup_sketch_profile(
325        &self,
326        profile_param: Option<&serde_json::Value>,
327    ) -> Option<&brep_kernel::SketchProfile> {
328        let name = first_reference_name(profile_param?)?;
329        // A committed sketch is surfaced as a render display sheet aliased
330        // `{sketch}:FACE`, and profile consumers may reference the `{sketch}:PROFILE`
331        // form; both alias the base sketch id the run report keys `sketch_profiles`
332        // by. Strip either so the extrude/revolve gizmo resolves the same profile the
333        // kernel does (mirrors `common::normalize_profile_alias` / `resolve_profile`).
334        let base = name
335            .strip_suffix(":FACE")
336            .or_else(|| name.strip_suffix(":PROFILE"))
337            .unwrap_or(&name);
338        self.sketch_profiles
339            .iter()
340            .find(|(id, _)| id == &name || id == base)
341            .map(|(_, profile)| profile)
342    }
343
344    /// Resolve an `axis` reference param to a world line `(point, unit direction)`:
345    /// a published sketch axis first (`sketch_axes`), else a resident solid EDGE's
346    /// polyline endpoints (`scene.edge_polyline_world`). `None` if neither resolves.
347    fn lookup_axis_line(
348        &self,
349        axis_param: Option<&serde_json::Value>,
350    ) -> Option<([f64; 3], [f64; 3])> {
351        let name = first_reference_name(axis_param?)?;
352        if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
353            let dir = fd_normalize3(vec3_to_arr(axis.direction));
354            return Some((vec3_to_arr(axis.point), dir));
355        }
356        // Fallback: a resident edge used as an axis — take its polyline endpoints.
357        let poly = self.scene.edge_polyline_world(&name)?;
358        let first = *poly.first()?;
359        let last = *poly.last()?;
360        let dir = fd_sub3(last, first);
361        if fd_norm3(dir) < 1e-9 {
362            return None;
363        }
364        Some((first, fd_normalize3(dir)))
365    }
366
367    /// A copy of `params` with each top-level STRING field evaluated against the
368    /// history's `expressions` + `configurator` (the kernel `eval_expression`) and
369    /// replaced by its finite numeric result — so an expression-valued param
370    /// (e.g. `sizeX: "a + b"`) places its dimension at the resolved length.
371    /// Non-numeric strings (ids, enum options) fail to eval and stay verbatim.
372    fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
373        let expressions = self.history.expressions();
374        let configurator = self.history.configurator();
375        let mut out = params.clone();
376        if let Some(object) = out.as_object_mut() {
377            for value in object.values_mut() {
378                if let Some(source) = value.as_str() {
379                    if let Ok(number) =
380                        brep_kernel::eval_expression(&expressions, &configurator, source)
381                    {
382                        if number.is_finite() {
383                            *value = serde_json::json!(number);
384                        }
385                    }
386                }
387            }
388        }
389        out
390    }
391
392    /// The dimension annotations for `feature_id` as JSON:
393    /// `[{ fieldKey, pointA, pointB, value, label, mid }]` (world-space points;
394    /// `mid` is the leader midpoint the app anchors the label at). `[]` when the
395    /// feature type has no FD-1 builder.
396    pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
397        use crate::feature_dimensions::FeatureDimKind;
398        let annotations = self.feature_dimension_annotations(feature_id);
399        let wpp = self.camera.world_per_pixel();
400        let out: Vec<serde_json::Value> = annotations
401            .iter()
402            .map(|a| {
403                // The chip anchor: a linear leader's midpoint, or an angular arc's
404                // mid-sweep point at the screen-constant radius (camera-dependent,
405                // so computed here with the live `world_per_pixel`). `kind` lets the
406                // app format the chip (`A 234°` for an angular value in DEGREES).
407                let (kind, mid) = match a.kind {
408                    FeatureDimKind::Linear => ("linear", a.midpoint()),
409                    FeatureDimKind::Angular => {
410                        ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
411                    }
412                };
413                serde_json::json!({
414                    "fieldKey": a.field_key,
415                    "pointA": a.point_a,
416                    "pointB": a.point_b,
417                    "value": a.value,
418                    "label": a.label,
419                    "mid": mid,
420                    "kind": kind,
421                })
422            })
423            .collect();
424        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
425    }
426
427    /// The `{ mode, feature, annotations }` snapshot the headless verifier reads
428    /// (published as `__brepFeatureDim`).
429    pub fn feature_dimension_state_json(&self) -> String {
430        let feature = self.dimension_armed_feature();
431        let annotations: serde_json::Value = if feature.is_empty() {
432            serde_json::json!([])
433        } else {
434            serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
435                .unwrap_or_else(|_| serde_json::json!([]))
436        };
437        serde_json::json!({
438            "mode": self.gizmo_mode(),
439            "feature": feature,
440            "annotations": annotations,
441        })
442        .to_string()
443    }
444
445    /// The `set_overlay` JSON for the `feature-dim-leaders` group — the annotation
446    /// leaders + arrowheads for the dimension-armed feature (empty when not in
447    /// dimension mode, so a stale group is cleared).
448    fn feature_dimension_overlay_json(&self) -> String {
449        let feature = self.dimension_armed_feature();
450        let annotations = if feature.is_empty() {
451            Vec::new()
452        } else {
453            self.feature_dimension_annotations(&feature)
454        };
455        let (positions, colors) = crate::feature_dimensions::leaders_buffers(
456            &annotations,
457            self.camera.world_per_pixel(),
458        );
459        serde_json::json!({
460            "groups": [
461                {
462                    "name": FEATURE_DIM_OVERLAY,
463                    "renderOrder": 10003,
464                    "tris": { "positions": positions, "colors": colors },
465                }
466            ]
467        })
468        .to_string()
469    }
470
471    /// (Re)project the dimension leaders onto the current geometry. Called on arm
472    /// + after every param change (drag / value edit / rerun in dimension mode)
473    /// + on a material ZOOM ([`Self::ensure_feature_dimension_overlay_current`]).
474    /// Remembers the `world_per_pixel` it baked at, which is what lets that
475    /// per-frame ensure fire on change ONLY.
476    pub fn refresh_feature_dimension_overlay(&mut self) {
477        let json = self.feature_dimension_overlay_json();
478        let _ = self.set_overlay_json(&json);
479        let wpp = self.camera.world_per_pixel();
480        self.feature_dim_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
481    }
482
483    /// Clear the dimension overlay (an empty group), e.g. when disarming or
484    /// switching to transform mode.
485    pub(super) fn clear_feature_dimension_overlay(&mut self) {
486        let _ = self.set_overlay_json(&serde_json::json!({
487            "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
488        }).to_string());
489        self.feature_dim_overlay_wpp = 0.0;
490    }
491
492    /// Per-frame upkeep for the DIMENSION gizmo (driven by
493    /// [`Self::ensure_overlays_current`]).
494    ///
495    /// The group is baked into pre-expanded vertices at feed time, and its
496    /// rod/cone/origin-sphere sizing — plus the angular arc's entire world
497    /// RADIUS (`ANGLE_ARC_RAD_PX × world_per_pixel`) — is screen-constant. So a
498    /// zoom that is not followed by a re-bake leaves the handles drawn at the old
499    /// pixel size, and the angular sweep handle drawn at the old world position
500    /// while [`Self::dimension_hit_regions`] (which projects against the LIVE
501    /// camera) grabs at the new one: the outline and the drawn handle drift apart.
502    ///
503    /// Re-bakes ONLY on a material `world_per_pixel` change
504    /// ([`overlay_wpp_stale`](super::overlay_wpp_stale)) — a quiet frame does not
505    /// touch the overlay, so there is no per-frame dirty loop. Nothing armed →
506    /// nothing baked, and the remembered zoom is dropped so re-arming re-bakes.
507    pub(super) fn ensure_feature_dimension_overlay_current(&mut self) {
508        if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
509            || self.dimension_armed_feature().is_empty()
510        {
511            self.feature_dim_overlay_wpp = 0.0;
512            return;
513        }
514        let wpp = self.camera.world_per_pixel();
515        if super::overlay_wpp_stale(self.feature_dim_overlay_wpp, wpp) {
516            self.refresh_feature_dimension_overlay();
517        }
518    }
519
520    /// Drag a dimension handle: project the pointer pixel `(x, y)` onto the
521    /// annotation's world axis (`pointA → pointB`), take the distance along the
522    /// axis from `pointA` as the new value (correcting for any transform scale so
523    /// the PARAM — not the scaled world length — is what changes), set the param,
524    /// and re-run the history live. Degenerate projections (parallel ray / zero
525    /// axis) no-op.
526    pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
527        let annotations = self.feature_dimension_annotations(feature_id);
528        let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
529            return;
530        };
531        if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
532            if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
533                self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
534                // Live-follow: re-bake the world-space leaders onto the rebuilt
535                // geometry so the arc tracks the pointer this frame (Fix 3).
536                self.refresh_feature_dimension_overlay();
537            }
538            return;
539        }
540        let a = annotation.point_a;
541        let b = annotation.point_b;
542        let axis = fd_sub3(b, a);
543        let len = fd_norm3(axis);
544        if len < 1e-9 {
545            return;
546        }
547        let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
548        let ray = self.camera.pick_ray(x, y);
549        let ray_dir = fd_normalize3(ray.dir);
550        let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
551            return;
552        };
553        // World distance → param value: correct for the local axis scale via the
554        // CURRENT ratio (world length / current param). Under unit scale this is
555        // the identity; when the param is ~0 there is no ratio, so use the world
556        // distance directly (unit-scale assumption).
557        let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
558            annotation.value / len
559        } else {
560            1.0
561        };
562        // Preserve SIGN so a linear dim can be dragged through the origin to the
563        // negative side (a directional dim — cube size, height — then extends the
564        // other way; the kernel takes |value| for magnitude dims). A small dead-zone
565        // keeps it off an exact 0 (a degenerate extent the builders reject).
566        let raw = t_world * scale_recip;
567        let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
568        self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
569        // Live-follow: re-bake the world-space leaders onto the rebuilt geometry so
570        // the arrow tracks the pointer this frame (Fix 3).
571        self.refresh_feature_dimension_overlay();
572    }
573
574    /// Map a pointer pixel to a swept angle (DEGREES) for an ANGULAR annotation:
575    /// search the sweep for the degree whose arc-end projects nearest the pointer
576    /// (a coarse 2° pass, then a ±2° refine at 0.25°), snap to 1°, clamp to
577    /// `[-360, 360]`. Ported from the overlay `angle` drag. The magnitude is
578    /// floored off exactly 0 so a torus `arc` drag never lands on 0 — which the
579    /// kernel's `|| 360` falsy fallback would flip to a FULL torus mid-drag.
580    /// `None` if the arc never projects in front of the camera.
581    ///
582    /// `pub(super)`: the assembly-constraint angle-arc drag
583    /// (`assembly_overlay.rs`) maps its pointer through this SAME search so the
584    /// two angle gizmos share one drag feel.
585    pub(super) fn angular_drag_degrees(
586        &self,
587        ann: &crate::feature_dimensions::FeatureDimAnnotation,
588        x: f64,
589        y: f64,
590    ) -> Option<f64> {
591        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
592        if radius <= 1e-9 {
593            return None;
594        }
595        // A sweep of `deg` and `deg - 360` share the SAME arc-end world point, so
596        // the screen-nearest search alone can't tell them apart at the wrap. Break
597        // the tie toward the CURRENT value (angle-unwrap
598        // continuity) with a tiny bias `~1e-6·Δ°²` — decisive only when screen
599        // errors are essentially equal, negligible against any real pointer move.
600        let current = ann.value;
601        let combined = |screen_err: f64, deg: f64| -> f64 {
602            let d = deg - current;
603            screen_err + 1e-6 * d * d
604        };
605        let mut best_deg = current;
606        let mut best_err = f64::INFINITY;
607        // Coarse sweep over the full range.
608        let mut deg = -360.0;
609        while deg <= 360.0 {
610            if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
611                let err = combined(err, deg);
612                if err < best_err {
613                    best_err = err;
614                    best_deg = deg;
615                }
616            }
617            deg += 2.0;
618        }
619        if !best_err.is_finite() {
620            return None;
621        }
622        // Refine around the coarse best.
623        let center = best_deg;
624        let mut deg = center - 2.0;
625        while deg <= center + 2.0 {
626            if (-360.0..=360.0).contains(&deg) {
627                if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
628                    let err = combined(err, deg);
629                    if err < best_err {
630                        best_err = err;
631                        best_deg = deg;
632                    }
633                }
634            }
635            deg += 0.25;
636        }
637        let clamped = best_deg.round().clamp(-360.0, 360.0);
638        let floored = if clamped.abs() < 0.1 {
639            if clamped < 0.0 { -0.1 } else { 0.1 }
640        } else {
641            clamped
642        };
643        Some(floored)
644    }
645
646    /// Squared screen-pixel distance from `(x, y)` to the arc-end at `deg` for an
647    /// angular annotation (`center + rotate(ref_dir, axis, deg) * radius`), or
648    /// `None` when that point is behind the camera.
649    fn angle_arc_end_err(
650        &self,
651        ann: &crate::feature_dimensions::FeatureDimAnnotation,
652        radius: f64,
653        deg: f64,
654        x: f64,
655        y: f64,
656    ) -> Option<f64> {
657        let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
658            ann.ref_dir,
659            ann.axis,
660            deg.to_radians(),
661        ));
662        let p = [
663            ann.center[0] + dir[0] * radius,
664            ann.center[1] + dir[1] * radius,
665            ann.center[2] + dir[2] * radius,
666        ];
667        let (sx, sy, depth) = self.camera.project(p);
668        if depth <= 0.0 {
669            return None;
670        }
671        Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
672    }
673
674    /// Edit a dimension value from a label field: a plain numeric literal sets the
675    /// param to that number; otherwise the input is treated as an EXPRESSION —
676    /// evaluated LIVE against the history's `expressions` + `configurator` (the
677    /// kernel `eval_expression`) and, on success, STORED as the expression string
678    /// (the kernel re-evaluates it via `ctx.number`, so it stays live). A blank /
679    /// bad-expression input no-ops (never corrupts the feature). Re-runs live.
680    pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
681        let trimmed = input.trim();
682        if trimmed.is_empty() {
683            return;
684        }
685        if is_plain_number_literal(trimmed) {
686            let Ok(number) = trimmed.parse::<f64>() else {
687                return;
688            };
689            if !number.is_finite() {
690                return;
691            }
692            self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
693        } else {
694            // Validate the expression before storing it (a bad expression no-ops).
695            let expressions = self.history.expressions();
696            let configurator = self.history.configurator();
697            match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
698                Ok(number) if number.is_finite() => {
699                    self.write_feature_dimension_param(
700                        feature_id,
701                        field_key,
702                        serde_json::Value::String(trimmed.to_string()),
703                    );
704                }
705                _ => {}
706            }
707        }
708    }
709
710    /// Set one `inputParams` field of `feature_id` (a number or an expression
711    /// string) and re-run the history (which re-projects the leaders in dimension
712    /// mode). No-op when the feature is absent.
713    fn write_feature_dimension_param(
714        &mut self,
715        feature_id: &str,
716        field_key: &str,
717        value: serde_json::Value,
718    ) {
719        let Some(index) = self.history.index_of(feature_id) else {
720            return;
721        };
722        let mut params = self
723            .history
724            .feature_params(index)
725            .unwrap_or_else(|| serde_json::json!({}));
726        let Some(object) = params.as_object_mut() else {
727            return;
728        };
729        object.insert(field_key.to_string(), value);
730        let _ = self.update_feature_params(feature_id, &params.to_string());
731    }
732}
733
734// --- FD-1 geometry helpers (self-contained, `fd_` prefixed to avoid clashes) ---
735
736fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
737    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
738}
739
740fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
741    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
742}
743
744fn fd_norm3(v: [f64; 3]) -> f64 {
745    fd_dot3(v, v).sqrt()
746}
747
748fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
749    let n = fd_norm3(v);
750    if n < 1e-12 {
751        [0.0, 0.0, 1.0]
752    } else {
753        [v[0] / n, v[1] / n, v[2] / n]
754    }
755}
756
757/// Push an origin-ball region at world `p` unless a ball already sits there (dedup
758/// by world position, matching `leaders_buffers`' single drawn sphere). The shared
759/// point-region builder projects it to a screen circle; a behind-eye (perspective)
760/// origin is omitted — invisible, so not grabbable — matching the outline.
761fn push_origin_region(
762    out: &mut Vec<(DimRole, HitShape)>,
763    seen: &mut Vec<[f64; 3]>,
764    cam: &crate::view::ViewCamera,
765    p: [f64; 3],
766    px: f32,
767) {
768    if seen.iter().any(|o| fd_norm3(fd_sub3(*o, p)) < 1e-6) {
769        return;
770    }
771    seen.push(p);
772    if let Some(shape) = point_region(cam, p, px) {
773        out.push((DimRole::Origin, shape));
774    }
775}
776
777/// The parameter `t` of the point on the axis line `a + t*dir` (dir UNIT) closest
778/// to the ray `ray_o + s*ray_d` (ray_d UNIT). `None` when the two are parallel
779/// (no well-defined projection). `t` is the signed world distance along `dir`
780/// from `a`. (`pub(super)`: shared with the constraint distance-arrow drag.)
781pub(super) fn closest_t_on_axis(
782    a: [f64; 3],
783    dir: [f64; 3],
784    ray_o: [f64; 3],
785    ray_d: [f64; 3],
786) -> Option<f64> {
787    let w0 = fd_sub3(a, ray_o);
788    let b = fd_dot3(dir, ray_d);
789    let d = fd_dot3(dir, w0);
790    let e = fd_dot3(ray_d, w0);
791    let denom = 1.0 - b * b;
792    if denom.abs() < 1e-9 {
793        return None;
794    }
795    Some((b * e - d) / denom)
796}
797
798fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
799    [v.x, v.y, v.z]
800}
801
802/// The FIRST reference NAME in a `reference_selection` param: a bare string, an
803/// object's `name`, or the first name in an array (port of the kernel
804/// `first_reference_name`). Trims + drops empties.
805fn first_reference_name(value: &serde_json::Value) -> Option<String> {
806    match value {
807        serde_json::Value::String(text) => {
808            let trimmed = text.trim();
809            (!trimmed.is_empty()).then(|| trimmed.to_string())
810        }
811        serde_json::Value::Object(map) => map
812            .get("name")
813            .and_then(|v| v.as_str())
814            .map(|s| s.trim().to_string())
815            .filter(|s| !s.is_empty()),
816        serde_json::Value::Array(items) => items.iter().find_map(first_reference_name),
817        _ => None,
818    }
819}
820
821/// The world CENTROID of a sketch profile — the average of its outer-loop curve
822/// start points (the profile-polygon vertices), approximating the previous
823/// face-average-center computation. Falls back to the sketch plane origin when
824/// no outer loop is available. Used to anchor the extrude/revolve gizmos on the
825/// geometry rather than at a possibly-far sketch-plane origin.
826fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
827    let mut sum = [0.0f64; 3];
828    let mut count = 0usize;
829    if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
830        for curve in &outer.curves {
831            if let Ok(domain) = curve.domain() {
832                if let Ok(point) = curve.evaluate(domain[0]) {
833                    sum[0] += point.x;
834                    sum[1] += point.y;
835                    sum[2] += point.z;
836                    count += 1;
837                }
838            }
839        }
840    }
841    if count > 0 {
842        [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
843    } else {
844        vec3_to_arr(profile.origin)
845    }
846}
847
848#[cfg(test)]
849mod feature_dimension_tests {
850    use super::*;
851
852    /// A one-primitive history request (identity transform) for the given type +
853    /// params, so the engine builds it and the dimension methods can read it.
854    fn primitive_request(feature_type: &str, id: &str, params: serde_json::Value) -> String {
855        let mut input = params.as_object().cloned().unwrap_or_default();
856        input.insert("id".into(), serde_json::json!(id));
857        input.insert(
858            "transform".into(),
859            serde_json::json!({
860                "position": [0.0, 0.0, 0.0],
861                "rotationEuler": [0.0, 0.0, 0.0],
862                "scale": [1.0, 1.0, 1.0]
863            }),
864        );
865        input.insert(
866            "boolean".into(),
867            serde_json::json!({ "targets": [], "operation": "NONE" }),
868        );
869        serde_json::json!({
870            "expressions": "",
871            "configurator": {},
872            "features": [{
873                "type": feature_type,
874                "inputParams": input,
875                "persistentData": {}
876            }]
877        })
878        .to_string()
879    }
880
881    fn cube_engine() -> EngineState {
882        let mut state = EngineState::new();
883        state
884            .set_history_json(&primitive_request(
885                "P.CU",
886                "Box",
887                serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 }),
888            ))
889            .unwrap();
890        state
891    }
892
893    #[test]
894    fn annotations_json_for_a_cube_has_three_linear_dims() {
895        let state = cube_engine();
896        let json: serde_json::Value =
897            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
898        let arr = json.as_array().unwrap();
899        assert_eq!(arr.len(), 3);
900        assert_eq!(arr[0]["fieldKey"], "sizeX");
901        assert_eq!(arr[0]["value"].as_f64().unwrap(), 10.0);
902        assert!(arr[0]["pointA"].is_array() && arr[0]["pointB"].is_array());
903        assert!(arr[0]["mid"].is_array());
904    }
905
906    #[test]
907    fn annotations_json_for_a_cylinder_has_radius_and_height() {
908        let mut state = EngineState::new();
909        state
910            .set_history_json(&primitive_request(
911                "P.CY",
912                "Cyl",
913                serde_json::json!({ "radius": 4.0, "height": 12.0 }),
914            ))
915            .unwrap();
916        let json: serde_json::Value =
917            serde_json::from_str(&state.feature_dimension_annotations_json("Cyl")).unwrap();
918        let arr = json.as_array().unwrap();
919        assert_eq!(arr.len(), 2);
920        assert_eq!(arr[0]["fieldKey"], "radius");
921        assert_eq!(arr[1]["fieldKey"], "height");
922    }
923
924    /// Task C audit — every PRIMITIVE that carries a Transform group also ships a
925    /// dimension builder, so expanding it auto-arms a dimension gizmo (`HistoryPanel`
926    /// guards on `feature_dimension_annotations_json != "[]"`). That gizmo's
927    /// origin/center sphere is the ONLY route to the transform gizmo now that the ◎
928    /// arm-button is gone, so a primitive with no dims would be stranded. Assert all
929    /// six expose a non-empty dimension gizmo AND arm to dimension mode. (Datum /
930    /// helix / pattern / port ALSO carry a Transform group but NO dim builder — they
931    /// have no path to transform post-◎-removal; flagged in the handoff, not fixed.)
932    #[test]
933    fn every_transformable_primitive_auto_arms_a_dimension_gizmo() {
934        let cases: [(&str, serde_json::Value); 6] = [
935            ("P.CU", serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 })),
936            ("P.CY", serde_json::json!({ "radius": 4.0, "height": 12.0 })),
937            ("P.CO", serde_json::json!({ "radiusBottom": 4.0, "radiusTop": 2.0, "height": 10.0 })),
938            ("P.S", serde_json::json!({ "radius": 5.0 })),
939            ("P.PY", serde_json::json!({ "baseSideLength": 6.0, "height": 8.0 })),
940            ("P.T", serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": 120.0 })),
941        ];
942        for (ty, params) in cases {
943            let mut state = EngineState::new();
944            state
945                .set_history_json(&primitive_request(ty, "Feat", params))
946                .unwrap_or_else(|e| panic!("{ty} builds: {e}"));
947            assert_ne!(
948                state.feature_dimension_annotations_json("Feat"),
949                "[]",
950                "{ty} must expose a dimension gizmo so expanding it auto-arms one"
951            );
952            // Arming it (what expand does) lands in dimension mode.
953            state.arm_dimension("Feat");
954            assert_eq!(state.gizmo_mode(), "dimension", "{ty}");
955        }
956    }
957
958    #[test]
959    fn annotations_json_empty_for_unknown_feature() {
960        let state = cube_engine();
961        assert_eq!(state.feature_dimension_annotations_json("nope"), "[]");
962    }
963
964    #[test]
965    fn set_value_updates_the_param_and_reruns() {
966        let mut state = cube_engine();
967        state.arm_dimension("Box");
968        state.feature_dimension_set_value("Box", "sizeX", "25");
969        let index = state.history.index_of("Box").unwrap();
970        let params = state.history.feature_params(index).unwrap();
971        assert_eq!(params["sizeX"].as_f64().unwrap(), 25.0);
972        // The reported annotation picks up the new value.
973        let json: serde_json::Value =
974            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
975        assert_eq!(json[0]["value"].as_f64().unwrap(), 25.0);
976    }
977
978    #[test]
979    fn set_value_stores_expression_string_when_not_a_literal() {
980        let mut state = cube_engine();
981        // Seed a variable in the history expressions.
982        state.set_expressions("w = 7;");
983        state.feature_dimension_set_value("Box", "sizeX", "w * 2");
984        let index = state.history.index_of("Box").unwrap();
985        let params = state.history.feature_params(index).unwrap();
986        // The expression is stored verbatim (the kernel re-evaluates it live).
987        assert_eq!(params["sizeX"].as_str().unwrap(), "w * 2");
988        // …and resolves to 14 in the reported annotation.
989        let json: serde_json::Value =
990            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
991        assert_eq!(json[0]["value"].as_f64().unwrap(), 14.0);
992    }
993
994    #[test]
995    fn set_value_rejects_a_bad_expression() {
996        let mut state = cube_engine();
997        state.feature_dimension_set_value("Box", "sizeX", "this is not valid");
998        let index = state.history.index_of("Box").unwrap();
999        let params = state.history.feature_params(index).unwrap();
1000        // Unchanged — the bad expression never landed.
1001        assert_eq!(params["sizeX"].as_f64().unwrap(), 10.0);
1002    }
1003
1004    #[test]
1005    fn origin_sphere_and_center_sphere_toggle_the_two_modes() {
1006        // The single orange sphere at the gizmo center flips dimension ↔ transform:
1007        // clicking the dimension arrows' ORIGIN sphere arms the transform gizmo, and
1008        // clicking the transform gizmo's CENTER sphere arms the dimension arrows.
1009        let mut state = cube_engine();
1010        state.resize(800.0, 600.0);
1011        state.camera.eye = [0.0, 0.0, 40.0];
1012        state.camera.target = [0.0, 0.0, 0.0];
1013        state.camera.up = [0.0, 1.0, 0.0];
1014        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1015
1016        // Arm the DIMENSION arrows for the cube.
1017        state.arm_dimension("Box");
1018        assert_eq!(state.gizmo_mode(), "dimension");
1019
1020        // The arrows share one ORIGIN sphere at the cube's min corner
1021        // (annotations[0].pointA). Project it the way `transform_gizmo_anchor` does.
1022        let anns: serde_json::Value =
1023            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
1024        let pa = &anns[0]["pointA"];
1025        let origin = [
1026            pa[0].as_f64().unwrap(),
1027            pa[1].as_f64().unwrap(),
1028            pa[2].as_f64().unwrap(),
1029        ];
1030        let (ox, oy, depth) = state.camera.project(origin);
1031        assert!(depth > 0.0, "origin in front of camera");
1032
1033        // A click on the origin sphere toggles DIMENSION → TRANSFORM (mirroring the
1034        // viewport click-chain). The cross-mode pick is inert here.
1035        assert!(state.dimension_origin_pick(ox, oy), "pick hits the origin sphere");
1036        assert!(
1037            !state.transform_center_pick(ox, oy),
1038            "no transform-center pick while in dimension mode"
1039        );
1040        state.toggle_to_transform();
1041        assert_eq!(state.gizmo_mode(), "transform");
1042
1043        // The transform gizmo's orange CENTER sphere sits at the same anchor; a
1044        // click on it toggles TRANSFORM → DIMENSION.
1045        let (cx, cy) = state.transform_gizmo_anchor().expect("transform anchor");
1046        assert!(state.transform_center_pick(cx, cy), "pick hits the center handle");
1047        assert!(
1048            !state.dimension_origin_pick(cx, cy),
1049            "no dimension-origin pick while in transform mode"
1050        );
1051        state.toggle_to_dimension();
1052        assert_eq!(state.gizmo_mode(), "dimension");
1053    }
1054
1055    /// A revolve is ANGULAR-ONLY: its single dimension is the sweep angle, whose
1056    /// mode-toggle target is the arc CENTER (there is no linear origin sphere). So
1057    /// the center-sphere pick is the ONLY way it reaches the transform gizmo. Assert
1058    /// the full round trip dimension → transform → dimension. (This guards the FD-2
1059    /// gap fix — restoring the old `kind != Linear { continue }` skip in
1060    /// `dimension_origin_pick` makes the first pick miss and fails this test.)
1061    #[test]
1062    fn plane_arms_the_offset_dim_and_never_toggles_to_transform() {
1063        // A plane gets the offset DIMENSION gizmo on arm; unlike a revolve, its
1064        // origin-sphere toggle must NOT give it a transform gizmo — a plane has no
1065        // transform (its placement is orientation + offset_distance).
1066        let request = serde_json::json!({
1067            "expressions": "",
1068            "configurator": {},
1069            "features": [{
1070                "type": "P",
1071                "inputParams": { "id": "Pl", "orientation": "XY", "offset_distance": 3.0 },
1072                "persistentData": {}
1073            }]
1074        })
1075        .to_string();
1076        let mut state = EngineState::new();
1077        state.set_history_json(&request).expect("plane builds");
1078
1079        // It emits one linear offset dim (so expand auto-arms dimension).
1080        let anns = state.feature_dimension_annotations("Pl");
1081        assert_eq!(anns.len(), 1, "plane emits its offset dim: {anns:?}");
1082        assert_eq!(anns[0].field_key, "offset_distance");
1083
1084        state.arm_dimension("Pl");
1085        assert_eq!(state.gizmo_mode(), "dimension");
1086        // The origin-sphere toggle is a NO-OP for a plane — stays in dimension mode.
1087        state.toggle_to_transform();
1088        assert_eq!(state.gizmo_mode(), "dimension", "a plane never gets a transform gizmo");
1089    }
1090
1091    #[test]
1092    fn angular_center_sphere_toggles_a_revolve_to_transform_and_back() {
1093        // Sketch "Sk": a radial rectangle (x∈[2,4], y∈[0,3]) profile + a +Y
1094        // construction line "Sk:G20" the revolve uses as its axis. The kernel
1095        // publishes both, and they survive the sketch being consumed by the revolve.
1096        let request = serde_json::json!({
1097            "expressions": "",
1098            "configurator": {},
1099            "features": [
1100                {
1101                    "type": "S",
1102                    "inputParams": { "id": "Sk" },
1103                    "persistentData": {
1104                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1105                        "sketch": {
1106                            "points": [
1107                                {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1108                                {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1109                                {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1110                            ],
1111                            "geometries": [
1112                                {"id":10,"type":"line","points":[1,2]},
1113                                {"id":11,"type":"line","points":[2,3]},
1114                                {"id":12,"type":"line","points":[3,4]},
1115                                {"id":13,"type":"line","points":[4,1]},
1116                                {"id":20,"type":"line","points":[5,6],"construction":true}
1117                            ],
1118                            "constraints": []
1119                        }
1120                    }
1121                },
1122                {
1123                    "type": "R",
1124                    "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
1125                    "persistentData": {}
1126                }
1127            ]
1128        })
1129        .to_string();
1130
1131        let mut state = EngineState::new();
1132        state.set_history_json(&request).expect("revolve builds");
1133        state.resize(800.0, 600.0);
1134        state.camera.eye = [0.0, 0.0, 40.0];
1135        state.camera.target = [0.0, 0.0, 0.0];
1136        state.camera.up = [0.0, 1.0, 0.0];
1137        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1138
1139        // The revolve's ONLY dimension is angular — no linear origin sphere exists.
1140        let anns = state.feature_dimension_annotations("Rev");
1141        assert_eq!(anns.len(), 1, "revolve emits one (angular) dim: {anns:?}");
1142        assert_eq!(anns[0].kind, crate::feature_dimensions::FeatureDimKind::Angular);
1143        let center = anns[0].center;
1144
1145        // Arm the dimension arrows (what expanding the feature does).
1146        state.arm_dimension("Rev");
1147        assert_eq!(state.gizmo_mode(), "dimension");
1148
1149        // Clicking the arc-CENTER orange sphere toggles DIMENSION → TRANSFORM.
1150        let (cx, cy, depth) = state.camera.project(center);
1151        assert!(depth > 0.0, "arc center in front of the camera");
1152        assert!(
1153            state.dimension_origin_pick(cx, cy),
1154            "pick hits the angular center sphere"
1155        );
1156        state.toggle_to_transform();
1157        assert_eq!(state.gizmo_mode(), "transform");
1158
1159        // And the transform gizmo's CENTER sphere toggles back to the arc.
1160        let (tx, ty) = state.transform_gizmo_anchor().expect("transform anchor");
1161        assert!(
1162            state.transform_center_pick(tx, ty),
1163            "pick hits the transform center handle"
1164        );
1165        state.toggle_to_dimension();
1166        assert_eq!(state.gizmo_mode(), "dimension");
1167        assert!(
1168            state.dimension_armed_for("Rev"),
1169            "round trip lands back on the revolve's dimension arrows"
1170        );
1171    }
1172
1173    /// A revolve history: sketch "Sk" (radial rectangle profile x∈[2,4], y∈[0,3]
1174    /// plus a +Y construction line "Sk:G20" used as the axis) revolved `angle`°
1175    /// about that axis. Mirrors the fixture in
1176    /// `angular_center_sphere_toggles_a_revolve_to_transform_and_back` so the
1177    /// revolve's angular dim resolves against a real scene, parameterized by angle.
1178    fn revolve_engine(angle: f64) -> EngineState {
1179        revolve_engine_with_profile("Sk", angle)
1180    }
1181
1182    /// As [`revolve_engine`] but with the revolve's `profile` reference spelled
1183    /// `profile` — so a test can drive the committed-sketch `{sketch}:FACE` display
1184    /// alias through the same live pipeline.
1185    fn revolve_engine_with_profile(profile: &str, angle: f64) -> EngineState {
1186        let request = serde_json::json!({
1187            "expressions": "",
1188            "configurator": {},
1189            "features": [
1190                {
1191                    "type": "S",
1192                    "inputParams": { "id": "Sk" },
1193                    "persistentData": {
1194                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1195                        "sketch": {
1196                            "points": [
1197                                {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1198                                {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1199                                {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1200                            ],
1201                            "geometries": [
1202                                {"id":10,"type":"line","points":[1,2]},
1203                                {"id":11,"type":"line","points":[2,3]},
1204                                {"id":12,"type":"line","points":[3,4]},
1205                                {"id":13,"type":"line","points":[4,1]},
1206                                {"id":20,"type":"line","points":[5,6],"construction":true}
1207                            ],
1208                            "constraints": []
1209                        }
1210                    }
1211                },
1212                {
1213                    "type": "R",
1214                    "inputParams": { "id": "Rev", "profile": profile, "axis": "Sk:G20", "angle": angle },
1215                    "persistentData": {}
1216                }
1217            ]
1218        })
1219        .to_string();
1220
1221        let mut state = EngineState::new();
1222        state.set_history_json(&request).expect("revolve builds");
1223        state.resize(800.0, 600.0);
1224        // Look straight DOWN the revolve axis (the oriented axis is -Y for this
1225        // fixture; center is (0, 1.5, 0)) so the arc's (cosθ, sinθ) maps uniquely
1226        // to screen. An edge-on view would alias θ ↔ -θ and defeat the angular
1227        // drag's nearest-projection search.
1228        state.camera.eye = [0.0, 40.0, 0.0];
1229        state.camera.target = [0.0, 1.5, 0.0];
1230        state.camera.up = [0.0, 0.0, 1.0];
1231        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1232        state
1233    }
1234
1235    /// The revolve's `angle` surfaces as an ANGULAR annotation in the JSON the app
1236    /// reads (so the auto-arm lights it up), and its arc arrowhead is pickable +
1237    /// draggable, writing the swept degrees back to `angle`. Exercises the whole
1238    /// revolve angle-gizmo pipeline end-to-end against a live scene: `axis`
1239    /// reference → oriented arc (`orient_revolve_axis`, the kernel's rule) → pick →
1240    /// angular drag. (The struct-level annotation + center-sphere mode toggle are
1241    /// covered by `angular_center_sphere_toggles_a_revolve_to_transform_and_back`;
1242    /// this locks in the JSON surface + the live pick/drag for the revolve.)
1243    #[test]
1244    fn revolve_angle_json_is_angular_and_drag_writes_the_swept_degrees() {
1245        let mut state = revolve_engine(90.0);
1246
1247        // JSON surface: exactly one angular `angle` entry (what the app + auto-arm
1248        // read — a non-`[]` result is what auto-arms the gizmo on expand).
1249        let json: serde_json::Value =
1250            serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1251        let arr = json.as_array().unwrap();
1252        assert_eq!(arr.len(), 1, "revolve emits one dim: {arr:?}");
1253        assert_eq!(arr[0]["fieldKey"], "angle");
1254        assert_eq!(arr[0]["kind"], "angular");
1255        assert!((arr[0]["value"].as_f64().unwrap() - 90.0).abs() < 1e-9);
1256        assert!(arr[0]["mid"].is_array(), "angular chip anchors on the arc");
1257
1258        // Arm the dimension arrows (what expanding the feature does), then grab the
1259        // revolve's angular dim.
1260        state.arm_dimension("Rev");
1261        let ann = state
1262            .feature_dimension_annotations("Rev")
1263            .into_iter()
1264            .find(|a| a.field_key == "angle")
1265            .expect("angle dim");
1266        assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1267
1268        // PRESS on the arc's CURRENT (90°) arrowhead handle → pick grabs `angle`.
1269        let handle =
1270            crate::feature_dimensions::arrow_handle_point(&ann, state.world_per_pixel());
1271        let (hx, hy, hdepth) = state.camera.project(handle);
1272        assert!(hdepth > 0.0, "handle in front of the camera");
1273        assert_eq!(
1274            state.dimension_arrow_pick(hx, hy).as_deref(),
1275            Some("angle"),
1276            "pick at the arc arrowhead grabs the angle handle"
1277        );
1278
1279        // DRAG toward the arc-end for a larger sweep; the nearest-projection search
1280        // must recover that degree (within the 1° snap).
1281        let target = 200.0_f64;
1282        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1283        let dir = crate::feature_dimensions::rotate_about_axis(
1284            ann.ref_dir,
1285            ann.axis,
1286            target.to_radians(),
1287        );
1288        let world = [
1289            ann.center[0] + dir[0] * radius,
1290            ann.center[1] + dir[1] * radius,
1291            ann.center[2] + dir[2] * radius,
1292        ];
1293        let (tx, ty, tdepth) = state.camera.project(world);
1294        assert!(tdepth > 0.0, "drag target in front of the camera");
1295        state.feature_dimension_drag("Rev", "angle", tx, ty);
1296
1297        let after = state.feature_dimension_annotations("Rev");
1298        let new_angle = after.iter().find(|a| a.field_key == "angle").expect("angle dim");
1299        assert!(
1300            (new_angle.value - target).abs() < 2.0,
1301            "drag should sweep the angle to ~{target}°, got {}",
1302            new_angle.value
1303        );
1304    }
1305
1306    /// Regression: a revolve (or extrude) whose `profile` references the committed
1307    /// sketch by its render display alias `{sketch}:FACE` must still resolve the
1308    /// sketch profile and auto-arm its dimension gizmo. `sketch_profiles` is keyed by
1309    /// the base sketch id (`Sk`), so `lookup_sketch_profile` has to strip the `:FACE`
1310    /// alias — exactly as the kernel's profile consumers do. Before the strip this
1311    /// returned `[]` and the angle arc silently never appeared (the real-world
1312    /// `RevolveSketch.BREP.json` symptom: profile `S4:FACE`).
1313    #[test]
1314    fn revolve_face_alias_profile_still_arms_the_angle_gizmo() {
1315        let state = revolve_engine_with_profile("Sk:FACE", 90.0);
1316        let json: serde_json::Value =
1317            serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1318        let arr = json.as_array().unwrap();
1319        assert_eq!(
1320            arr.len(),
1321            1,
1322            "the `:FACE`-aliased revolve profile must still emit its angular dim: {arr:?}"
1323        );
1324        assert_eq!(arr[0]["fieldKey"], "angle");
1325        assert_eq!(arr[0]["kind"], "angular");
1326        assert_eq!(arr[0]["value"].as_f64().unwrap(), 90.0);
1327    }
1328
1329    /// DEBUG-overlay consistency + draw==hit invariant: `dimension_hit_areas_json`
1330    /// emits the SAME SCREEN-space regions the picks 2D-test the cursor against —
1331    /// three leader CAPSULES (`point_a → point_b`, `ARROW_HANDLE_HIT_RAD_PX`,
1332    /// projected via the camera) + ONE deduped origin CIRCLE (`point_a`,
1333    /// `ORIGIN_SPHERE_RAD_PX + 2`) for a cube — and a cursor inside each grabs the
1334    /// matching handle. `[]` outside dimension mode.
1335    #[test]
1336    fn dimension_hit_areas_are_screen_regions_matching_the_picks() {
1337        let mut state = cube_engine();
1338        state.resize(800.0, 600.0);
1339        // Oblique so the three leaders fan out to distinct screen directions (a
1340        // straight-down view foreshortens the +Z leader onto the shared corner,
1341        // making its midpoint ambiguous with the others).
1342        state.camera.eye = [30.0, 20.0, 40.0];
1343        state.camera.target = [0.0, 0.0, 0.0];
1344        state.camera.up = [0.0, 1.0, 0.0];
1345        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1346
1347        // Only populated in dimension mode.
1348        assert_eq!(state.dimension_hit_areas_json(), "[]");
1349        state.arm_transform("Box");
1350        assert_eq!(state.dimension_hit_areas_json(), "[]");
1351
1352        state.arm_dimension("Box");
1353        let areas: Vec<serde_json::Value> =
1354            serde_json::from_str(&state.dimension_hit_areas_json()).unwrap();
1355        let anns = state.feature_dimension_annotations("Box");
1356
1357        let close2 = |v: &serde_json::Value, want: (f64, f64)| {
1358            (v[0].as_f64().unwrap() - want.0).abs() < 1e-3
1359                && (v[1].as_f64().unwrap() - want.1).abs() < 1e-3
1360        };
1361
1362        // Three leader CAPSULES (screen px), one per linear dim; endpoints = the
1363        // camera-projected point_a/point_b, radius = ARROW_HANDLE_HIT_RAD_PX.
1364        let capsules: Vec<&serde_json::Value> =
1365            areas.iter().filter(|a| a["kind"] == "capsule").collect();
1366        assert_eq!(capsules.len(), 3);
1367        assert_eq!(capsules.len(), anns.len());
1368        for (cap, ann) in capsules.iter().zip(anns.iter()) {
1369            let r = cap["r"].as_f64().unwrap();
1370            assert!(
1371                (r - crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX).abs() < 1e-6,
1372                "r {r} must equal ARROW_HANDLE_HIT_RAD_PX"
1373            );
1374            let (ax, ay, _) = state.camera.project(ann.point_a);
1375            let (bx, by, _) = state.camera.project(ann.point_b);
1376            assert!(close2(&cap["a"], (ax, ay)), "leader a: {:?} vs {ax},{ay}", cap["a"]);
1377            assert!(close2(&cap["b"], (bx, by)), "leader b: {:?} vs {bx},{by}", cap["b"]);
1378        }
1379
1380        // One deduped origin CIRCLE at the shared min corner (all three cube dims
1381        // share `point_a`), radius ORIGIN_SPHERE_RAD_PX + 2.
1382        let circles: Vec<&serde_json::Value> =
1383            areas.iter().filter(|a| a["kind"] == "circle").collect();
1384        assert_eq!(circles.len(), 1, "cube dims share one origin sphere");
1385        let want_r = crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0;
1386        assert!((circles[0]["r"].as_f64().unwrap() - want_r).abs() < 1e-6);
1387        let (ox, oy, _) = state.camera.project(anns[0].point_a);
1388        assert!(close2(&circles[0]["c"], (ox, oy)), "origin circle off point_a");
1389
1390        // Draw==hit: a cursor at each leader MIDPOINT (uniquely owned by that leader
1391        // away from the shared corner) grabs THAT field; a cursor at the origin
1392        // circle center is an origin pick.
1393        for ann in &anns {
1394            let mid = ann.midpoint();
1395            let (mx, my, _) = state.camera.project(mid);
1396            assert_eq!(
1397                state.dimension_arrow_pick(mx, my).as_deref(),
1398                Some(ann.field_key.as_str()),
1399                "cursor on the {} leader midpoint grabs it",
1400                ann.field_key
1401            );
1402        }
1403        assert!(state.dimension_origin_pick(ox, oy), "cursor on the origin circle toggles");
1404    }
1405
1406    #[test]
1407    fn closest_t_on_axis_projects_a_perpendicular_ray() {
1408        // Axis along +X from origin; a ray straight down through (7, 5, 0) hits the
1409        // axis at t = 7.
1410        let t = closest_t_on_axis(
1411            [0.0, 0.0, 0.0],
1412            [1.0, 0.0, 0.0],
1413            [7.0, 5.0, 0.0],
1414            [0.0, -1.0, 0.0],
1415        )
1416        .unwrap();
1417        assert!((t - 7.0).abs() < 1e-9, "t = {t}");
1418    }
1419
1420    #[test]
1421    fn closest_t_on_axis_none_when_parallel() {
1422        assert!(closest_t_on_axis(
1423            [0.0, 0.0, 0.0],
1424            [1.0, 0.0, 0.0],
1425            [0.0, 5.0, 0.0],
1426            [1.0, 0.0, 0.0],
1427        )
1428        .is_none());
1429    }
1430
1431    // --- FD-2 angular: torus arc (a primitive with an angular dim) -------------
1432
1433    fn torus_engine(arc: f64) -> EngineState {
1434        let mut state = EngineState::new();
1435        state
1436            .set_history_json(&primitive_request(
1437                "P.T",
1438                "Tor",
1439                serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": arc }),
1440            ))
1441            .unwrap();
1442        state
1443    }
1444
1445    #[test]
1446    fn annotations_json_for_a_torus_has_two_linear_and_one_angular() {
1447        let state = torus_engine(120.0);
1448        let json: serde_json::Value =
1449            serde_json::from_str(&state.feature_dimension_annotations_json("Tor")).unwrap();
1450        let arr = json.as_array().unwrap();
1451        assert_eq!(arr.len(), 3);
1452        assert_eq!(arr[0]["fieldKey"], "majorRadius");
1453        assert_eq!(arr[0]["kind"], "linear");
1454        assert_eq!(arr[1]["fieldKey"], "tubeRadius");
1455        assert_eq!(arr[1]["kind"], "linear");
1456        // The arc dim is ANGULAR, its value is DEGREES, and its chip anchors on
1457        // the arc (a world point the app projects).
1458        assert_eq!(arr[2]["fieldKey"], "arc");
1459        assert_eq!(arr[2]["kind"], "angular");
1460        assert_eq!(arr[2]["value"].as_f64().unwrap(), 120.0);
1461        assert!(arr[2]["mid"].is_array());
1462    }
1463
1464    #[test]
1465    fn dragging_a_torus_arc_writes_the_swept_degrees() {
1466        let mut state = torus_engine(90.0);
1467        state.arm_dimension("Tor");
1468        let anns = state.feature_dimension_annotations("Tor");
1469        let arc = anns
1470            .iter()
1471            .find(|a| a.field_key == "arc")
1472            .expect("arc dim")
1473            .clone();
1474        assert_eq!(arc.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1475
1476        // Aim the pointer exactly at the arc-end for a target sweep; the drag's
1477        // nearest-projection search must recover that degree (within the 1° snap).
1478        let target = 210.0_f64;
1479        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1480        let dir = crate::feature_dimensions::rotate_about_axis(
1481            arc.ref_dir,
1482            arc.axis,
1483            target.to_radians(),
1484        );
1485        let world = [
1486            arc.center[0] + dir[0] * radius,
1487            arc.center[1] + dir[1] * radius,
1488            arc.center[2] + dir[2] * radius,
1489        ];
1490        let (sx, sy, depth) = state.camera.project(world);
1491        assert!(depth > 0.0, "arc-end must project in front of the camera");
1492
1493        state.feature_dimension_drag("Tor", "arc", sx, sy);
1494
1495        let after = state.feature_dimension_annotations("Tor");
1496        let new_arc = after.iter().find(|a| a.field_key == "arc").expect("arc dim");
1497        assert!(
1498            (new_arc.value - target).abs() < 2.0,
1499            "drag should sweep the arc to ~{target}°, got {}",
1500            new_arc.value
1501        );
1502    }
1503
1504    // --- Fix 4: dimension-arrow pick + drag (a linear cube dim) ----------------
1505
1506    #[test]
1507    fn dimension_arrow_pick_and_drag_edits_the_param_live() {
1508        let mut state = cube_engine();
1509        state.resize(800.0, 600.0);
1510        state.camera.eye = [0.0, 0.0, 40.0]; // look down -Z: +X screen-right, +Y up
1511        state.camera.target = [0.0, 0.0, 0.0];
1512        state.camera.up = [0.0, 1.0, 0.0];
1513        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1514
1515        // A pick is inert until the dimension arrows are armed.
1516        state.arm_transform("Box");
1517        assert!(
1518            state.dimension_arrow_pick(400.0, 300.0).is_none(),
1519            "no arrow pick in transform mode"
1520        );
1521
1522        state.arm_dimension("Box");
1523        assert_eq!(state.gizmo_mode(), "dimension");
1524
1525        // The sizeX dim runs origin → (sizeX,0,0); its orange arrowHEAD is at
1526        // `point_b`. A pick AT the projected arrowhead grabs the `sizeX` field.
1527        let ann = state
1528            .feature_dimension_annotations("Box")
1529            .into_iter()
1530            .find(|a| a.field_key == "sizeX")
1531            .expect("sizeX dim");
1532        let a = ann.point_a;
1533        let b = ann.point_b;
1534        let (bx, by, depth) = state.camera.project(b);
1535        assert!(depth > 0.0, "arrowhead in front of the camera");
1536        assert_eq!(
1537            state.dimension_arrow_pick(bx, by).as_deref(),
1538            Some("sizeX"),
1539            "pick at the arrowhead grabs sizeX"
1540        );
1541        // A far-off pixel grabs nothing.
1542        assert!(state.dimension_arrow_pick(10.0, 10.0).is_none(), "empty space → no arrow");
1543
1544        // DRAG the arrow outward along +X to a target length; the value tracks the
1545        // pointer live (Fix 3 + Fix 4). Aim the pointer at a + dir*target_len.
1546        let len = {
1547            let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1548            (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
1549        };
1550        let dir = [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len];
1551        let target_len = 16.0_f64;
1552        let world = [
1553            a[0] + dir[0] * target_len,
1554            a[1] + dir[1] * target_len,
1555            a[2] + dir[2] * target_len,
1556        ];
1557        let (wx, wy, wdepth) = state.camera.project(world);
1558        assert!(wdepth > 0.0, "drag target in front of the camera");
1559
1560        let before = state
1561            .history
1562            .feature_params(state.history.index_of("Box").unwrap())
1563            .unwrap()["sizeX"]
1564            .as_f64()
1565            .unwrap();
1566        state.feature_dimension_drag("Box", "sizeX", wx, wy);
1567        let after = state
1568            .history
1569            .feature_params(state.history.index_of("Box").unwrap())
1570            .unwrap()["sizeX"]
1571            .as_f64()
1572            .unwrap();
1573
1574        // The param grew toward the drag target. The drag scales the world distance
1575        // by the current (value / world-length) ratio; under unit scale that is 1,
1576        // so the new value ≈ target_len.
1577        let expected = target_len * (ann.value / len);
1578        assert!(after > before, "sizeX grew: {before} → {after}");
1579        assert!(
1580            (after - expected).abs() < 0.5,
1581            "drag set sizeX to ~{expected}, got {after}"
1582        );
1583    }
1584
1585    /// REGRESSION PIN — the exact logged failure (`sizeY tip depth=-3.449`). An
1586    /// armed cube dimension gizmo oriented so the sizeY leader's FAR end
1587    /// (`point_b`) has NEGATIVE view-depth while its shaft is clearly on screen.
1588    /// The old point-only pick + `depth <= 0.0` skip returned None for the whole
1589    /// handle; being ORTHOGRAPHIC, the region builder projects the WHOLE leader in
1590    /// full (no clip), so the capsule covers the shaft AND `point_b` — a cursor AT
1591    /// the projected `point_b` grabs it.
1592    #[test]
1593    fn linear_leader_with_point_b_behind_the_eye_is_grabbable_on_its_visible_shaft() {
1594        let mut state = cube_engine();
1595        state.resize(800.0, 600.0);
1596        state.arm_dimension("Box");
1597
1598        // Locate the sizeY leader, then aim an ORTHO camera whose eye plane falls
1599        // BETWEEN point_a and point_b (a < L/2 perpendicular offset), so point_b is
1600        // behind the eye plane but the shaft's a-side is in front. Being ortho, the
1601        // whole leader still renders — so by the user's rule it must be grabbable.
1602        let anns = state.feature_dimension_annotations("Box");
1603        let ann = anns.iter().find(|a| a.field_key == "sizeY").expect("sizeY dim").clone();
1604        let a = ann.point_a;
1605        let b = ann.point_b;
1606        let dir = fd_normalize3(fd_sub3(b, a));
1607        let l = fd_norm3(fd_sub3(b, a));
1608        // Offset the eye TOWARD the sum of the OTHER leaders' directions (sizeX +
1609        // sizeZ), so `forward = a - eye` points AWAY from them — otherwise the pick
1610        // ray through point_b can coincidentally graze another leader (they all
1611        // share the corner `a`). Keep only the component ⟂ the sizeY direction.
1612        let mut off = [0.0f64; 3];
1613        for other in anns.iter().filter(|a| a.field_key != "sizeY") {
1614            let d = fd_normalize3(fd_sub3(other.point_b, other.point_a));
1615            off = [off[0] + d[0], off[1] + d[1], off[2] + d[2]];
1616        }
1617        let off_dot = fd_dot3(off, dir);
1618        let perp = fd_normalize3([
1619            off[0] - dir[0] * off_dot,
1620            off[1] - dir[1] * off_dot,
1621            off[2] - dir[2] * off_dot,
1622        ]);
1623        let mid = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5];
1624        let d_off = l * 0.25; // < L/2 → point_b lands behind the eye plane
1625        let eye = [
1626            mid[0] + perp[0] * d_off,
1627            mid[1] + perp[1] * d_off,
1628            mid[2] + perp[2] * d_off,
1629        ];
1630        let forward = fd_normalize3(fd_sub3(a, eye)); // look toward the front (a) side
1631        state.camera.eye = eye;
1632        state.camera.target = [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]];
1633        state.camera.up = if forward[2].abs() < 0.9 { [0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0] };
1634        state.camera.projection =
1635            crate::view::Projection::Orthographic { half_height: l.max(1.0) };
1636
1637        // The pin: point_b is behind the eye plane, point_a in front.
1638        assert!(
1639            state.camera.view_depth(b) < 0.0,
1640            "point_b must be behind the eye: {}",
1641            state.camera.view_depth(b)
1642        );
1643        assert!(state.camera.view_depth(a) > 0.0, "point_a in front");
1644
1645        // A cursor AT the (still on-screen, ortho) projected point_b grabs sizeY —
1646        // the VERY pixel the old `depth <= 0.0` skip culled the whole handle at.
1647        let (bx, by, bdepth) = state.camera.project(b);
1648        assert!(bdepth < 0.0, "the projected far-end depth the old skip rejected");
1649        assert_eq!(
1650            state.dimension_arrow_pick(bx, by).as_deref(),
1651            Some("sizeY"),
1652            "the leader is grabbable at its visible far end"
1653        );
1654
1655        // …and on its mid shaft too.
1656        let (mx, my, _) = state.camera.project(mid);
1657        assert_eq!(
1658            state.dimension_arrow_pick(mx, my).as_deref(),
1659            Some("sizeY"),
1660            "the leader is grabbable on its shaft"
1661        );
1662    }
1663
1664    // -----------------------------------------------------------------------
1665    // Zoom → the drawn gizmo follows (the reported "zoom does not update the
1666    // draggable gizmos" bug). The leaders/arrowheads/arc ride the GENERAL
1667    // `set_overlay` channel, which pre-expands its vertices AT FEED TIME — so
1668    // unlike the per-frame widgets (transform gizmo, datums, ViewCube) they do
1669    // NOT re-size against the live camera. Only a re-bake updates them.
1670    // -----------------------------------------------------------------------
1671
1672    /// A quiet frame must not re-bake (no per-frame dirty loop), a material
1673    /// zoom must — the same contract `ensure_current_hides_in_sketch_mode_and_
1674    /// rebakes_on_zoom` holds for the assembly-constraint overlay.
1675    #[test]
1676    fn ensure_overlays_current_rebakes_the_dimension_gizmo_on_zoom_without_looping() {
1677        let mut state = cube_engine();
1678        state.resize(800.0, 600.0);
1679        state.camera.eye = [30.0, 20.0, 40.0];
1680        state.camera.target = [0.0, 0.0, 0.0];
1681        state.camera.up = [0.0, 1.0, 0.0];
1682        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1683        state.arm_dimension("Box");
1684
1685        // Quiet frame: nothing moved, nothing re-bakes.
1686        state.dirty = false;
1687        state.ensure_overlays_current();
1688        assert!(!state.dirty, "a quiet frame must not re-bake (no dirty loop)");
1689
1690        // Zoom out 2x → the screen-constant rod/cone/sphere sizing is stale.
1691        state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1692        state.ensure_overlays_current();
1693        assert!(state.dirty, "a zoom change re-bakes the screen-constant sizing");
1694
1695        // …and it SETTLES: the re-bake recorded the new zoom, so the frames after
1696        // it are quiet again. (A fix that re-baked unconditionally would pin the
1697        // CPU and repaint forever; this is what catches it.)
1698        state.dirty = false;
1699        state.ensure_overlays_current();
1700        state.ensure_overlays_current();
1701        state.ensure_overlays_current();
1702        assert!(!state.dirty, "the re-bake settles — no per-frame re-bake loop");
1703
1704        // Every OTHER zoom path lands on the same world-per-pixel key: a viewport
1705        // resize (camera.height) is a zoom too.
1706        state.resize(800.0, 300.0);
1707        state.dirty = false;
1708        state.ensure_overlays_current();
1709        assert!(state.dirty, "a viewport resize changes world-per-pixel → re-bake");
1710
1711        // Pan + orbit do NOT: they leave the target-plane world-per-pixel alone,
1712        // the baked geometry is world-space (the GPU re-projects it every frame),
1713        // and the hit regions are recomputed live at pick time.
1714        state.dirty = false;
1715        state.camera.eye = [40.0, 20.0, 30.0]; // orbit (same distance)
1716        state.ensure_overlays_current();
1717        assert!(!state.dirty, "orbit needs no re-bake (world-space geometry)");
1718        state.camera.eye = [35.0, 25.0, 45.0];
1719        state.camera.target = [5.0, 5.0, 5.0]; // pan (same distance)
1720        state.ensure_overlays_current();
1721        assert!(!state.dirty, "pan needs no re-bake (world-space geometry)");
1722    }
1723
1724    /// The DRAWN angular arc is the half of this the user sees move: its radius is
1725    /// `ANGLE_ARC_RAD_PX * world_per_pixel`, so a zoom that doubles the
1726    /// world-per-pixel must double the arc in world space to keep it a constant
1727    /// 120 px on screen. Before the per-frame ensure the baked group kept the OLD
1728    /// radius — and because `dimension_hit_regions` sizes the grab circle from the
1729    /// LIVE camera, the drawn sweep handle and the circle that grabs it drifted
1730    /// apart by the whole radius delta.
1731    #[test]
1732    fn zoom_rescales_the_drawn_angular_arc_and_keeps_the_grab_circle_on_it() {
1733        let mut state = revolve_engine(90.0);
1734        state.arm_dimension("Rev");
1735        assert_eq!(
1736            state.widgets.overlay_group_names(),
1737            vec!["feature-dim-leaders"],
1738            "only the dimension gizmo is drawn, so the overlay bbox is its own"
1739        );
1740
1741        let extent = |s: &EngineState| {
1742            let b = s.widgets.overlay_groups_bbox();
1743            let (min, max) = (b.min, b.max);
1744            (max[0] - min[0]).max(max[1] - min[1]).max(max[2] - min[2])
1745        };
1746        let before = extent(&state);
1747        assert!(before > 0.0, "the arc is drawn");
1748
1749        // The grab circle sits ON the drawn sweep handle at the baked zoom.
1750        let hit_center = |s: &EngineState| {
1751            let areas: Vec<serde_json::Value> =
1752                serde_json::from_str(&s.dimension_hit_areas_json()).unwrap();
1753            let c = areas
1754                .iter()
1755                .find(|a| a["kind"] == "circle" && a["r"].as_f64().unwrap() > 10.0)
1756                .expect("the arc sweep-end grab circle")
1757                .clone();
1758            [c["c"][0].as_f64().unwrap(), c["c"][1].as_f64().unwrap()]
1759        };
1760        let wpp_baked = state.camera.world_per_pixel();
1761        let drawn_handle = |s: &EngineState, wpp: f64| {
1762            let ann = s.feature_dimension_annotations("Rev");
1763            let ann = ann.iter().find(|a| a.field_key == "angle").unwrap();
1764            let p = crate::feature_dimensions::arrow_handle_point(ann, wpp);
1765            let (x, y, _) = s.camera.project(p);
1766            [x, y]
1767        };
1768        let at_arm = drawn_handle(&state, wpp_baked);
1769        let hit_at_arm = hit_center(&state);
1770        assert!(
1771            (at_arm[0] - hit_at_arm[0]).hypot(at_arm[1] - hit_at_arm[1]) < 1.0,
1772            "at arm the grab circle sits on the drawn handle"
1773        );
1774
1775        // Zoom out 2x, then run the per-frame upkeep.
1776        state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1777        state.ensure_overlays_current();
1778
1779        // The drawn arc doubled in world size (constant on screen)…
1780        let after = extent(&state);
1781        assert!(
1782            (after / before - 2.0).abs() < 0.05,
1783            "the drawn arc must scale with the zoom: {before} → {after}"
1784        );
1785        // …and the grab circle is still ON the handle the user can see, which is
1786        // now the one at the NEW world-per-pixel.
1787        let wpp_now = state.camera.world_per_pixel();
1788        let drawn_now = drawn_handle(&state, wpp_now);
1789        let hit_now = hit_center(&state);
1790        assert!(
1791            (drawn_now[0] - hit_now[0]).hypot(drawn_now[1] - hit_now[1]) < 1.0,
1792            "after the zoom the grab circle still sits on the drawn handle"
1793        );
1794        // The stale-bake handle is a long way off — proof the assertion has teeth.
1795        let stale = drawn_handle(&state, wpp_baked);
1796        assert!(
1797            (stale[0] - hit_now[0]).hypot(stale[1] - hit_now[1]) > 20.0,
1798            "the OLD-zoom handle is nowhere near the live grab circle"
1799        );
1800    }
1801
1802    /// EVERY zoom path, not just the wheel: they are all the same one key
1803    /// (`camera.world_per_pixel()`), so keying the invalidation off that quantity
1804    /// covers them without a per-path hook. After the upkeep frame the gizmo is
1805    /// baked at the LIVE zoom whichever path moved it.
1806    #[test]
1807    fn every_zoom_path_rebakes_the_dimension_gizmo() {
1808        type Zoom = (&'static str, fn(&mut EngineState));
1809        let paths: Vec<Zoom> = vec![
1810            ("wheel", |s| {
1811                assert!(s.wheel(-240.0, None), "the wheel zooms");
1812            }),
1813            ("zoom-to-fit", |s| s.zoom_to_fit()),
1814            ("standard view (View menu — it zoom-to-fits)", |s| {
1815                assert!(s.standard_view("FRONT"));
1816            }),
1817            ("viewport resize", |s| s.resize(400.0, 200.0)),
1818            ("projection half-height", |s| {
1819                s.camera.projection = crate::view::Projection::Orthographic { half_height: 7.0 };
1820            }),
1821        ];
1822        for (name, apply) in paths {
1823            let mut state = cube_engine();
1824            state.resize(800.0, 600.0);
1825            state.camera.eye = [30.0, 20.0, 40.0];
1826            state.camera.target = [0.0, 0.0, 0.0];
1827            state.camera.up = [0.0, 1.0, 0.0];
1828            state.camera.projection =
1829                crate::view::Projection::Orthographic { half_height: 200.0 };
1830            state.arm_dimension("Box");
1831            let baked = state.feature_dim_overlay_wpp;
1832            assert!(baked > 0.0, "{name}: armed → baked");
1833
1834            apply(&mut state);
1835            let live = state.camera.world_per_pixel();
1836            assert!(
1837                super::overlay_wpp_stale(baked, live),
1838                "{name} must move world-per-pixel ({baked} → {live})"
1839            );
1840            state.ensure_overlays_current();
1841            assert!(
1842                (state.feature_dim_overlay_wpp - live).abs() < live * 1e-9,
1843                "{name}: the gizmo is re-baked at the LIVE zoom"
1844            );
1845        }
1846    }
1847
1848    /// The counter-case that says WHY keying on world-per-pixel is right — the
1849    /// camera moves that must NOT re-bake, and don't:
1850    ///   * pan and orbit hold the eye→target distance;
1851    ///   * the ViewCube (face, corner and navigation arrow alike) is a
1852    ///     fixed-pivot reorient — `apply_look_direction` / `apply_viewcube_arrow`
1853    ///     keep `camera.distance()`, so it is an ORBIT, never a zoom;
1854    ///   * the ortho↔perspective toggle solves for the distance / half-height
1855    ///     that PRESERVES apparent size (`projection_toggle_preserves_apparent_
1856    ///     size` in `view`), so the screen-constant sizing is already right.
1857    /// Nothing needs re-baking through any of them: the baked buffers are
1858    /// world-space, so the GPU re-projects them, and the hit regions are rebuilt
1859    /// from the live camera at pick time.
1860    #[test]
1861    fn pan_orbit_viewcube_and_projection_toggle_need_no_rebake() {
1862        let mut state = cube_engine();
1863        state.resize(800.0, 600.0);
1864        state.camera.eye = [30.0, 20.0, 40.0];
1865        state.camera.target = [0.0, 0.0, 0.0];
1866        state.camera.up = [0.0, 1.0, 0.0];
1867        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1868        state.arm_dimension("Box");
1869        let baked = state.feature_dim_overlay_wpp;
1870
1871        // A ViewCube FACE click reorients at a FIXED pivot distance.
1872        state.apply_look_direction([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]);
1873        // A pan slides eye + target together, so the distance holds too.
1874        state.camera.eye = [35.0, 25.0, 45.0];
1875        state.camera.target = [5.0, 5.0, 5.0];
1876        // …and ortho → perspective keeps the apparent size.
1877        assert_eq!(state.toggle_projection(), "perspective");
1878
1879        let live = state.camera.world_per_pixel();
1880        assert!(
1881            !super::overlay_wpp_stale(baked, live),
1882            "pan/orbit/ViewCube/projection-toggle hold world-per-pixel ({baked} → {live})"
1883        );
1884        state.dirty = false;
1885        state.ensure_overlays_current();
1886        assert!(!state.dirty, "none of these must re-bake");
1887
1888        // …and the gizmo still grabs where it draws, because the hit regions
1889        // re-project against the live camera every pick.
1890        let anns = state.feature_dimension_annotations("Box");
1891        for ann in &anns {
1892            let (mx, my, _) = state.camera.project(ann.midpoint());
1893            assert_eq!(
1894                state.dimension_arrow_pick(mx, my).as_deref(),
1895                Some(ann.field_key.as_str()),
1896                "after pan+orbit+toggle the {} leader still grabs on its shaft",
1897                ann.field_key
1898            );
1899        }
1900    }
1901
1902    /// The revolve's END cap face + one of that cap's boundary edges, read off the
1903    /// built [`revolve_engine`] scene (never hardcoded — the loop key in the cap
1904    /// name is the kernel's). The reported model (R3: `profile: "R2:L0_END"`,
1905    /// `axis: "R2:L0_END|S1:G3_RV[0]"`) has exactly this shape: a feature built
1906    /// off a FACE of a prior revolve and one of that face's edges, no sketch
1907    /// behind either.
1908    fn revolve_end_cap_and_boundary_edge() -> (String, String) {
1909        let state = revolve_engine(90.0);
1910        let solid = state.scene.solid("Rev").expect("the revolve solid is resident");
1911        let cap = solid
1912            .faces
1913            .iter()
1914            .map(|face| face.name.clone())
1915            .find(|name| name.ends_with("_END"))
1916            .expect("the revolve names its END cap");
1917        let edge = solid
1918            .edges
1919            .iter()
1920            .map(|edge| edge.name.clone())
1921            .find(|name| name.split('|').any(|side| side == cap))
1922            .expect("the END cap has a named boundary edge");
1923        (cap, edge)
1924    }
1925
1926    /// The [`revolve_engine`] history (sketch "Sk" + revolve "Rev") with one more
1927    /// feature appended, built into an engine.
1928    fn engine_with_extra_feature(feature: serde_json::Value) -> EngineState {
1929        let request = serde_json::json!({
1930            "expressions": "",
1931            "configurator": {},
1932            "features": [
1933                {
1934                    "type": "S",
1935                    "inputParams": { "id": "Sk" },
1936                    "persistentData": {
1937                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1938                        "sketch": {
1939                            "points": [
1940                                {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1941                                {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1942                                {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1943                            ],
1944                            "geometries": [
1945                                {"id":10,"type":"line","points":[1,2]},
1946                                {"id":11,"type":"line","points":[2,3]},
1947                                {"id":12,"type":"line","points":[3,4]},
1948                                {"id":13,"type":"line","points":[4,1]},
1949                                {"id":20,"type":"line","points":[5,6],"construction":true}
1950                            ],
1951                            "constraints": []
1952                        }
1953                    }
1954                },
1955                {
1956                    "type": "R",
1957                    "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
1958                    "persistentData": {}
1959                },
1960                feature
1961            ]
1962        })
1963        .to_string();
1964        let mut state = EngineState::new();
1965        state.set_history_json(&request).expect("the face-profile feature builds");
1966        state
1967    }
1968
1969    fn fd_cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1970        [
1971            a[1] * b[2] - a[2] * b[1],
1972            a[2] * b[0] - a[0] * b[2],
1973            a[0] * b[1] - a[1] * b[0],
1974        ]
1975    }
1976
1977    /// Regression (the reported model): a revolve whose `profile` is a resident
1978    /// solid FACE and whose `axis` is one of that face's edges — no sketch behind
1979    /// either — gets its angle gizmo like a sketch-profile revolve does. The refs
1980    /// resolver used to know sketch profiles only, so such a revolve emitted `[]`
1981    /// and the app showed no angle gizmo. The arc must be geometrically right too:
1982    /// centered ON the axis edge, zero reference ⟂ the axis, and sweeping the way
1983    /// the kernel swept the solid (toward the cap's OUTWARD normal).
1984    #[test]
1985    fn revolve_off_a_resident_face_profile_emits_its_angle_dim() {
1986        let (cap, edge) = revolve_end_cap_and_boundary_edge();
1987        let state = engine_with_extra_feature(serde_json::json!({
1988            "type": "R",
1989            "inputParams": { "id": "Rev2", "profile": cap, "axis": edge, "angle": 120.0 },
1990            "persistentData": {}
1991        }));
1992        assert!(state.scene.solid("Rev2").is_some(), "the face-profile revolve built a solid");
1993
1994        let anns = state.feature_dimension_annotations("Rev2");
1995        assert_eq!(anns.len(), 1, "a face-profile revolve emits its angle dim: {anns:?}");
1996        let ann = &anns[0];
1997        assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1998        assert_eq!(ann.field_key, "angle");
1999        assert!((ann.value - 120.0).abs() < 1e-9, "value is the angle: {}", ann.value);
2000
2001        // The arc is centered ON the axis edge and sweeps ABOUT it.
2002        let poly = state.scene.edge_polyline_world(&edge).expect("axis edge polyline");
2003        let (a, b) = (poly[0], *poly.last().unwrap());
2004        let edge_dir = fd_normalize3(fd_sub3(b, a));
2005        let along = fd_dot3(fd_sub3(ann.center, a), edge_dir);
2006        let foot = [
2007            a[0] + edge_dir[0] * along,
2008            a[1] + edge_dir[1] * along,
2009            a[2] + edge_dir[2] * along,
2010        ];
2011        assert!(
2012            fd_norm3(fd_sub3(ann.center, foot)) < 1e-6,
2013            "arc center {:?} lies on the axis edge {a:?}→{b:?}",
2014            ann.center
2015        );
2016        assert!(
2017            fd_dot3(ann.axis, edge_dir).abs() > 1.0 - 1e-9,
2018            "arc axis {:?} is the edge direction {edge_dir:?}",
2019            ann.axis
2020        );
2021        assert!(fd_dot3(ann.ref_dir, ann.axis).abs() < 1e-9, "zero reference ⟂ axis");
2022
2023        // Orientation: the arc's initial motion (the tangent at θ=0) carries the
2024        // profile toward the cap's OUTWARD normal — the kernel's own rule — so the
2025        // built solid sits on that side of the axis.
2026        let tangent = fd_cross3(ann.axis, ann.ref_dir);
2027        let (_, cap_normal) = state.scene.face_plane_world(&cap).expect("cap plane");
2028        assert!(
2029            fd_dot3(tangent, cap_normal) > 0.5,
2030            "the arc sweeps toward the cap's outward normal {cap_normal:?}: tangent {tangent:?}"
2031        );
2032        let bbox = &state.scene.solid("Rev2").unwrap().bbox;
2033        let bbox_center = [
2034            (bbox.min[0] + bbox.max[0]) * 0.5,
2035            (bbox.min[1] + bbox.max[1]) * 0.5,
2036            (bbox.min[2] + bbox.max[2]) * 0.5,
2037        ];
2038        assert!(
2039            fd_dot3(tangent, fd_sub3(bbox_center, ann.center)) > 0.5,
2040            "the built solid lies on the arc's sweep side: bbox center {bbox_center:?}"
2041        );
2042
2043        // The JSON surface the app reads (a non-`[]` result is what auto-arms the
2044        // gizmo on expand) carries the one angular entry.
2045        let json: serde_json::Value =
2046            serde_json::from_str(&state.feature_dimension_annotations_json("Rev2")).unwrap();
2047        let arr = json.as_array().unwrap();
2048        assert_eq!(arr.len(), 1, "one angular dim in JSON: {arr:?}");
2049        assert_eq!(arr[0]["fieldKey"], "angle");
2050        assert_eq!(arr[0]["kind"], "angular");
2051    }
2052
2053    /// The same resolver serves the extrude: an extrude whose `profile` is a
2054    /// resident solid FACE gets its distance dims, anchored at the face's centroid
2055    /// and running along the face's OUTWARD normal (the direction the kernel's
2056    /// face profile extrudes).
2057    #[test]
2058    fn extrude_off_a_resident_face_profile_emits_its_distance_dims() {
2059        let (cap, _) = revolve_end_cap_and_boundary_edge();
2060        let state = engine_with_extra_feature(serde_json::json!({
2061            "type": "E",
2062            "inputParams": { "id": "Ext", "profile": cap, "distance": 5.0 },
2063            "persistentData": {}
2064        }));
2065        assert!(state.scene.solid("Ext").is_some(), "the face-profile extrude built a solid");
2066
2067        let anns = state.feature_dimension_annotations("Ext");
2068        assert_eq!(anns.len(), 2, "a face-profile extrude emits distance + distanceBack: {anns:?}");
2069        assert_eq!(anns[0].field_key, "distance");
2070        assert_eq!(anns[1].field_key, "distanceBack");
2071        let (centroid, normal) = state.scene.face_plane_world(&cap).expect("cap plane");
2072        assert!(
2073            fd_norm3(fd_sub3(anns[0].point_a, centroid)) < 1e-9,
2074            "anchored at the face centroid {centroid:?}: {:?}",
2075            anns[0].point_a
2076        );
2077        let leg = fd_sub3(anns[0].point_b, anns[0].point_a);
2078        assert!((fd_norm3(leg) - 5.0).abs() < 1e-9, "the D leg is `distance` long: {leg:?}");
2079        assert!(
2080            fd_dot3(fd_normalize3(leg), normal) > 1.0 - 1e-9,
2081            "the D leg runs along the outward normal {normal:?}: {leg:?}"
2082        );
2083        // The solid really extends that way: its bbox reaches `distance` past the
2084        // cap plane along the outward normal, and not behind it.
2085        let bbox = &state.scene.solid("Ext").unwrap().bbox;
2086        let corners = (0..8).map(|k| {
2087            [
2088                if k & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
2089                if k & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
2090                if k & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
2091            ]
2092        });
2093        let heights: Vec<f64> = corners.map(|c| fd_dot3(fd_sub3(c, centroid), normal)).collect();
2094        let top = heights.iter().cloned().fold(f64::MIN, f64::max);
2095        let bottom = heights.iter().cloned().fold(f64::MAX, f64::min);
2096        assert!((top - 5.0).abs() < 1e-3, "solid reaches distance 5 along the normal: {top}");
2097        assert!(bottom.abs() < 1e-3, "solid starts at the cap plane: {bottom}");
2098    }
2099
2100    /// The scene-side face plane the face-profile fallback reads: for every
2101    /// named face of an axis-aligned cube, the centroid is the face center on the
2102    /// bbox boundary and the normal points OUTWARD (unit); an unknown name is
2103    /// `None`.
2104    #[test]
2105    fn face_plane_world_of_a_cube_face_is_its_center_with_the_outward_normal() {
2106        let state = cube_engine();
2107        let solid = state.scene.solid("Box").expect("cube resident");
2108        let size = [10.0, 20.0, 30.0];
2109        let mid = [5.0, 10.0, 15.0];
2110        let named: Vec<&str> = solid
2111            .faces
2112            .iter()
2113            .map(|face| face.name.as_str())
2114            .filter(|name| !name.is_empty())
2115            .collect();
2116        assert_eq!(named.len(), 6, "six named cube faces: {named:?}");
2117        for name in named {
2118            let (centroid, normal) = state
2119                .scene
2120                .face_plane_world(name)
2121                .unwrap_or_else(|| panic!("face {name} has a plane"));
2122            assert!((fd_norm3(normal) - 1.0).abs() < 1e-9, "{name}: unit normal {normal:?}");
2123            let axis = (0..3)
2124                .find(|&k| normal[k].abs() > 1.0 - 1e-6)
2125                .unwrap_or_else(|| panic!("{name}: axis-aligned normal {normal:?}"));
2126            let expected_axis_coord = if normal[axis] > 0.0 { size[axis] } else { 0.0 };
2127            for k in 0..3 {
2128                let expected = if k == axis { expected_axis_coord } else { mid[k] };
2129                assert!(
2130                    (centroid[k] - expected).abs() < 1e-4,
2131                    "{name}: centroid {centroid:?} is the face center"
2132                );
2133            }
2134            assert!(
2135                fd_dot3(normal, fd_sub3(centroid, mid)) > 0.0,
2136                "{name}: normal {normal:?} points outward from {centroid:?}"
2137            );
2138        }
2139        assert!(state.scene.face_plane_world("Box:NOPE").is_none());
2140    }
2141}