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