Skip to main content

brep_render/engine_state/
feature_dims.rs

1use super::*;
2use super::sketch_edit_ops::is_plain_number_literal;
3
4/// The world-space overlay group carrying the FD leaders + arrowheads.
5const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
6
7impl EngineState {
8    /// The armed ◎ gizmo mode: `"none"`, `"transform"`, or `"dimension"`. Drives
9    /// the ◎ highlight + the app's dimension-overlay draw / input routing.
10    pub fn gizmo_mode(&self) -> &'static str {
11        match self.transform_gizmo.mode {
12            GizmoMode::None => "none",
13            GizmoMode::Transform => "transform",
14            GizmoMode::Dimension => "dimension",
15        }
16    }
17
18    /// Whether the DIMENSION gizmo is armed for THIS feature (drives the ◎
19    /// dimension-mode highlight).
20    pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
21        matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
22            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
23    }
24
25    /// The dimension-armed feature id (empty unless in dimension mode).
26    pub fn dimension_armed_feature(&self) -> String {
27        if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
28            self.transform_gizmo.feature_id.clone().unwrap_or_default()
29        } else {
30            String::new()
31        }
32    }
33
34    /// Arm the DIMENSION gizmo for `feature_id`: hide the transform widget, show
35    /// the annotation overlay. Re-arming a different feature moves it.
36    pub fn arm_dimension(&mut self, feature_id: &str) {
37        self.transform_gizmo.feature_id = Some(feature_id.to_string());
38        self.transform_gizmo.mode = GizmoMode::Dimension;
39        self.transform_gizmo.drag = None;
40        // The transform widget and the dimension overlay are mutually exclusive.
41        let _ = self.widgets.set_transform_json("null");
42        self.refresh_feature_dimension_overlay();
43        self.dirty = true;
44    }
45
46    // --- The orange center-sphere ◎ TOGGLE (dimension ↔ transform) ---------
47    //
48    // A single orange sphere sits at the gizmo center in BOTH modes: the
49    // transform gizmo's `HANDLE_CENTER` sphere and the dimension arrows' shared
50    // origin sphere project to the same point. Clicking it flips the two modes,
51    // mirroring the old app's `CombinedTransformControls` center-handle toggle
52    // (pointer-down on `HANDLE_CENTER` calls
53    // `toggleDisplayMode`). The viewport routes a bare CLICK here; a DRAG on the
54    // center still free-moves via `transform_press` (unchanged).
55
56    /// Whether a screen-px pick in TRANSFORM mode lands on the orange CENTER
57    /// free-move sphere (`HANDLE_CENTER`). The viewport uses this to make a bare
58    /// click on the center TOGGLE to the dimension arrows (via
59    /// [`toggle_to_dimension`](Self::toggle_to_dimension)) instead of swallowing
60    /// it as a generic handle click. False in any other gizmo mode.
61    pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
62        matches!(self.transform_gizmo.mode, GizmoMode::Transform)
63            && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
64    }
65
66    /// Whether a screen-px pick in DIMENSION mode lands on an orange ORIGIN
67    /// sphere of the armed feature's dimension arrows. Each distinct annotation
68    /// draws such a sphere — a LINEAR dim at its `point_a` (a cube's three axis
69    /// dims share one, a cone/pyramid draw two), an ANGULAR dim at its arc
70    /// `center` (the vertex; its sweep-END sphere is the angle DRAG handle, not a
71    /// toggle) — so every one is projected via the camera and hit-tested against
72    /// the screen-constant sphere radius. The viewport uses this to TOGGLE back to
73    /// the transform gizmo (via [`toggle_to_transform`](Self::toggle_to_transform)),
74    /// which is the ONLY way an angular-only feature (a revolve) reaches transform.
75    /// False in any other gizmo mode. The hit radius mirrors the gizmo center's own
76    /// tolerance (`PX_CENTER_RAD + 2.0`, transform.rs) so the two toggle targets match.
77    pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
78        if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
79            return false;
80        }
81        let feature = self.dimension_armed_feature();
82        if feature.is_empty() {
83            return false;
84        }
85        let hit_r = crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0;
86        let hit_r2 = hit_r * hit_r;
87        for ann in self.feature_dimension_annotations(&feature) {
88            // A LINEAR dim's orange origin sphere sits at `point_a`; an ANGULAR
89            // dim's sits at `center` (the arc vertex). Project whichever this
90            // annotation uses as its mode-toggle target.
91            let target = match ann.kind {
92                crate::feature_dimensions::FeatureDimKind::Linear => ann.point_a,
93                crate::feature_dimensions::FeatureDimKind::Angular => ann.center,
94            };
95            let (sx, sy, depth) = self.camera.project(target);
96            if depth <= 0.0 {
97                continue; // origin behind the camera → no sphere on screen
98            }
99            let (dx, dy) = (sx - x, sy - y);
100            if dx * dx + dy * dy <= hit_r2 {
101                return true;
102            }
103        }
104        false
105    }
106
107    /// Whether a screen-px pick in DIMENSION mode lands on a dimension ARROWHEAD
108    /// (a linear leader's orange cone TIP at `point_b`, or an angular arc's orange
109    /// sweep-END handle sphere). Returns the grabbed annotation's `field_key` — the
110    /// viewport routes a DRAG that starts here to [`feature_dimension_drag`](Self::
111    /// feature_dimension_drag), editing that param live (Fix 4). `None` in any other
112    /// gizmo mode / when no arrowhead is under the pointer. Distinct from
113    /// [`dimension_origin_pick`](Self::dimension_origin_pick): that grabs the SHARED
114    /// origin sphere (a mode toggle), this grabs an arrowHEAD (a value edit). The
115    /// nearest arrowhead within the screen-constant hit radius wins.
116    pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
117        if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
118            return None;
119        }
120        let feature = self.dimension_armed_feature();
121        if feature.is_empty() {
122            return None;
123        }
124        let wpp = self.camera.world_per_pixel();
125        let hit_r = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX;
126        let hit_r2 = hit_r * hit_r;
127        let mut best: Option<(f64, String)> = None;
128        for ann in self.feature_dimension_annotations(&feature) {
129            let tip = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
130            let (sx, sy, depth) = self.camera.project(tip);
131            if depth <= 0.0 {
132                continue; // arrowhead behind the camera
133            }
134            let (dx, dy) = (sx - x, sy - y);
135            let d2 = dx * dx + dy * dy;
136            if d2 <= hit_r2 && best.as_ref().map(|(bd, _)| d2 < *bd).unwrap_or(true) {
137                best = Some((d2, ann.field_key.clone()));
138            }
139        }
140        best.map(|(_, key)| key)
141    }
142
143    /// Toggle the armed ◎ gizmo from TRANSFORM to DIMENSION for the currently
144    /// transform-armed feature (the orange center-sphere click). No-op unless a
145    /// feature is transform-armed.
146    pub fn toggle_to_dimension(&mut self) {
147        let feature = self.transform_armed_feature();
148        if !feature.is_empty() {
149            self.arm_dimension(&feature);
150        }
151    }
152
153    /// Toggle the armed ◎ gizmo from DIMENSION to TRANSFORM for the currently
154    /// dimension-armed feature (the orange origin-sphere click). No-op unless a
155    /// feature is dimension-armed.
156    pub fn toggle_to_transform(&mut self) {
157        let feature = self.dimension_armed_feature();
158        if !feature.is_empty() {
159            self.arm_transform(&feature);
160        }
161    }
162
163    /// The linear dimension annotations for `feature_id` (resolving expression
164    /// params against the live history env first). `[]` for a feature type with
165    /// no FD-1 builder / a missing feature.
166    fn feature_dimension_annotations(
167        &self,
168        feature_id: &str,
169    ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
170        let Some(index) = self.history.index_of(feature_id) else {
171            return Vec::new();
172        };
173        let Some(feature_type) = self.history.feature_type(index) else {
174            return Vec::new();
175        };
176        let Some(params) = self.history.feature_params(index) else {
177            return Vec::new();
178        };
179        let resolved = self.resolve_param_expressions(&params);
180        // Resolve any scene references the builder needs (extrude profile plane,
181        // revolve axis line) from the run report's profiles/axes — keyed off the
182        // ORIGINAL params so reference-name strings are read verbatim.
183        let refs = self.feature_dimension_refs(&feature_type, &params);
184        crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
185    }
186
187    /// Resolve the scene references a feature-dimension builder needs beyond its
188    /// pure params: the extrude/revolve profile PLANE (center + normal) and the
189    /// revolve AXIS line. Sourced from the run report the engine already holds —
190    /// `sketch_profiles` (the sketch's world profile, which survives being
191    /// consumed by the extrude/revolve since only solids honor `removed`) and
192    /// `sketch_axes` (a sketch's published axis lines), with a resident-edge
193    /// polyline fallback for the axis. Empty for any other feature type; missing
194    /// pieces stay `None` so the builder degrades to `[]` gracefully.
195    fn feature_dimension_refs(
196        &self,
197        feature_type: &str,
198        params: &serde_json::Value,
199    ) -> crate::feature_dimensions::ResolvedRefs {
200        let mut refs = crate::feature_dimensions::ResolvedRefs::default();
201        match feature_type {
202            "E" => {
203                if let Some(profile) = self.lookup_sketch_profile(params.get("profile")) {
204                    refs.profile_center = Some(sketch_profile_centroid(profile));
205                    refs.profile_normal = Some(vec3_to_arr(profile.z_axis));
206                }
207            }
208            "R" => {
209                if let Some(profile) = self.lookup_sketch_profile(params.get("profile")) {
210                    refs.profile_center = Some(sketch_profile_centroid(profile));
211                    refs.profile_normal = Some(vec3_to_arr(profile.z_axis));
212                }
213                if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
214                    refs.axis_point = Some(point);
215                    refs.axis_dir = Some(dir);
216                }
217            }
218            _ => {}
219        }
220        refs
221    }
222
223    /// Resolve a `profile` reference param to the sketch profile the engine holds
224    /// (exact name, or the `:PROFILE`-suffixed form — mirrors `SceneMap::resolve_profile`).
225    fn lookup_sketch_profile(
226        &self,
227        profile_param: Option<&serde_json::Value>,
228    ) -> Option<&brep_kernel::SketchProfile> {
229        let name = first_reference_name(profile_param?)?;
230        // A committed sketch is surfaced as a render display sheet aliased
231        // `{sketch}:FACE`, and profile consumers may reference the `{sketch}:PROFILE`
232        // form; both alias the base sketch id the run report keys `sketch_profiles`
233        // by. Strip either so the extrude/revolve gizmo resolves the same profile the
234        // kernel does (mirrors `common::normalize_profile_alias` / `resolve_profile`).
235        let base = name
236            .strip_suffix(":FACE")
237            .or_else(|| name.strip_suffix(":PROFILE"))
238            .unwrap_or(&name);
239        self.sketch_profiles
240            .iter()
241            .find(|(id, _)| id == &name || id == base)
242            .map(|(_, profile)| profile)
243    }
244
245    /// Resolve an `axis` reference param to a world line `(point, unit direction)`:
246    /// a published sketch axis first (`sketch_axes`), else a resident solid EDGE's
247    /// polyline endpoints (`scene.edge_polyline_world`). `None` if neither resolves.
248    fn lookup_axis_line(
249        &self,
250        axis_param: Option<&serde_json::Value>,
251    ) -> Option<([f64; 3], [f64; 3])> {
252        let name = first_reference_name(axis_param?)?;
253        if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
254            let dir = fd_normalize3(vec3_to_arr(axis.direction));
255            return Some((vec3_to_arr(axis.point), dir));
256        }
257        // Fallback: a resident edge used as an axis — take its polyline endpoints.
258        let poly = self.scene.edge_polyline_world(&name)?;
259        let first = *poly.first()?;
260        let last = *poly.last()?;
261        let dir = fd_sub3(last, first);
262        if fd_norm3(dir) < 1e-9 {
263            return None;
264        }
265        Some((first, fd_normalize3(dir)))
266    }
267
268    /// A copy of `params` with each top-level STRING field evaluated against the
269    /// history's `expressions` + `configurator` (the kernel `eval_expression`) and
270    /// replaced by its finite numeric result — so an expression-valued param
271    /// (e.g. `sizeX: "a + b"`) places its dimension at the resolved length.
272    /// Non-numeric strings (ids, enum options) fail to eval and stay verbatim.
273    fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
274        let expressions = self.history.expressions();
275        let configurator = self.history.configurator();
276        let mut out = params.clone();
277        if let Some(object) = out.as_object_mut() {
278            for value in object.values_mut() {
279                if let Some(source) = value.as_str() {
280                    if let Ok(number) =
281                        brep_kernel::eval_expression(&expressions, &configurator, source)
282                    {
283                        if number.is_finite() {
284                            *value = serde_json::json!(number);
285                        }
286                    }
287                }
288            }
289        }
290        out
291    }
292
293    /// The dimension annotations for `feature_id` as JSON:
294    /// `[{ fieldKey, pointA, pointB, value, label, mid }]` (world-space points;
295    /// `mid` is the leader midpoint the app anchors the label at). `[]` when the
296    /// feature type has no FD-1 builder.
297    pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
298        use crate::feature_dimensions::FeatureDimKind;
299        let annotations = self.feature_dimension_annotations(feature_id);
300        let wpp = self.camera.world_per_pixel();
301        let out: Vec<serde_json::Value> = annotations
302            .iter()
303            .map(|a| {
304                // The chip anchor: a linear leader's midpoint, or an angular arc's
305                // mid-sweep point at the screen-constant radius (camera-dependent,
306                // so computed here with the live `world_per_pixel`). `kind` lets the
307                // app format the chip (`A 234°` for an angular value in DEGREES).
308                let (kind, mid) = match a.kind {
309                    FeatureDimKind::Linear => ("linear", a.midpoint()),
310                    FeatureDimKind::Angular => {
311                        ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
312                    }
313                };
314                serde_json::json!({
315                    "fieldKey": a.field_key,
316                    "pointA": a.point_a,
317                    "pointB": a.point_b,
318                    "value": a.value,
319                    "label": a.label,
320                    "mid": mid,
321                    "kind": kind,
322                })
323            })
324            .collect();
325        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
326    }
327
328    /// The `{ mode, feature, annotations }` snapshot the headless verifier reads
329    /// (published as `__brepFeatureDim`).
330    pub fn feature_dimension_state_json(&self) -> String {
331        let feature = self.dimension_armed_feature();
332        let annotations: serde_json::Value = if feature.is_empty() {
333            serde_json::json!([])
334        } else {
335            serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
336                .unwrap_or_else(|_| serde_json::json!([]))
337        };
338        serde_json::json!({
339            "mode": self.gizmo_mode(),
340            "feature": feature,
341            "annotations": annotations,
342        })
343        .to_string()
344    }
345
346    /// The `set_overlay` JSON for the `feature-dim-leaders` group — the annotation
347    /// leaders + arrowheads for the dimension-armed feature (empty when not in
348    /// dimension mode, so a stale group is cleared).
349    fn feature_dimension_overlay_json(&self) -> String {
350        let feature = self.dimension_armed_feature();
351        let annotations = if feature.is_empty() {
352            Vec::new()
353        } else {
354            self.feature_dimension_annotations(&feature)
355        };
356        let (positions, colors) = crate::feature_dimensions::leaders_buffers(
357            &annotations,
358            self.camera.world_per_pixel(),
359        );
360        serde_json::json!({
361            "groups": [
362                {
363                    "name": FEATURE_DIM_OVERLAY,
364                    "renderOrder": 10003,
365                    "tris": { "positions": positions, "colors": colors },
366                }
367            ]
368        })
369        .to_string()
370    }
371
372    /// (Re)project the dimension leaders onto the current geometry. Called on arm
373    /// + after every param change (drag / value edit / rerun in dimension mode).
374    pub fn refresh_feature_dimension_overlay(&mut self) {
375        let json = self.feature_dimension_overlay_json();
376        let _ = self.set_overlay_json(&json);
377    }
378
379    /// Clear the dimension overlay (an empty group), e.g. when disarming or
380    /// switching to transform mode.
381    pub(super) fn clear_feature_dimension_overlay(&mut self) {
382        let _ = self.set_overlay_json(&serde_json::json!({
383            "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
384        }).to_string());
385    }
386
387    /// Drag a dimension handle: project the pointer pixel `(x, y)` onto the
388    /// annotation's world axis (`pointA → pointB`), take the distance along the
389    /// axis from `pointA` as the new value (correcting for any transform scale so
390    /// the PARAM — not the scaled world length — is what changes), set the param,
391    /// and re-run the history live. Degenerate projections (parallel ray / zero
392    /// axis) no-op.
393    pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
394        let annotations = self.feature_dimension_annotations(feature_id);
395        let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
396            return;
397        };
398        if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
399            if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
400                self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
401                // Live-follow: re-bake the world-space leaders onto the rebuilt
402                // geometry so the arc tracks the pointer this frame (Fix 3).
403                self.refresh_feature_dimension_overlay();
404            }
405            return;
406        }
407        let a = annotation.point_a;
408        let b = annotation.point_b;
409        let axis = fd_sub3(b, a);
410        let len = fd_norm3(axis);
411        if len < 1e-9 {
412            return;
413        }
414        let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
415        let ray = self.camera.pick_ray(x, y);
416        let ray_dir = fd_normalize3(ray.dir);
417        let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
418            return;
419        };
420        // World distance → param value: correct for the local axis scale via the
421        // CURRENT ratio (world length / current param). Under unit scale this is
422        // the identity; when the param is ~0 there is no ratio, so use the world
423        // distance directly (unit-scale assumption).
424        let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
425            annotation.value / len
426        } else {
427            1.0
428        };
429        // Preserve SIGN so a linear dim can be dragged through the origin to the
430        // negative side (a directional dim — cube size, height — then extends the
431        // other way; the kernel takes |value| for magnitude dims). A small dead-zone
432        // keeps it off an exact 0 (a degenerate extent the builders reject).
433        let raw = t_world * scale_recip;
434        let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
435        self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
436        // Live-follow: re-bake the world-space leaders onto the rebuilt geometry so
437        // the arrow tracks the pointer this frame (Fix 3).
438        self.refresh_feature_dimension_overlay();
439    }
440
441    /// Map a pointer pixel to a swept angle (DEGREES) for an ANGULAR annotation:
442    /// search the sweep for the degree whose arc-end projects nearest the pointer
443    /// (a coarse 2° pass, then a ±2° refine at 0.25°), snap to 1°, clamp to
444    /// `[-360, 360]`. Ported from the overlay `angle` drag. The magnitude is
445    /// floored off exactly 0 so a torus `arc` drag never lands on 0 — which the
446    /// kernel's `|| 360` falsy fallback would flip to a FULL torus mid-drag.
447    /// `None` if the arc never projects in front of the camera.
448    fn angular_drag_degrees(
449        &self,
450        ann: &crate::feature_dimensions::FeatureDimAnnotation,
451        x: f64,
452        y: f64,
453    ) -> Option<f64> {
454        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
455        if radius <= 1e-9 {
456            return None;
457        }
458        // A sweep of `deg` and `deg - 360` share the SAME arc-end world point, so
459        // the screen-nearest search alone can't tell them apart at the wrap. Break
460        // the tie toward the CURRENT value (angle-unwrap
461        // continuity) with a tiny bias `~1e-6·Δ°²` — decisive only when screen
462        // errors are essentially equal, negligible against any real pointer move.
463        let current = ann.value;
464        let combined = |screen_err: f64, deg: f64| -> f64 {
465            let d = deg - current;
466            screen_err + 1e-6 * d * d
467        };
468        let mut best_deg = current;
469        let mut best_err = f64::INFINITY;
470        // Coarse sweep over the full range.
471        let mut deg = -360.0;
472        while deg <= 360.0 {
473            if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
474                let err = combined(err, deg);
475                if err < best_err {
476                    best_err = err;
477                    best_deg = deg;
478                }
479            }
480            deg += 2.0;
481        }
482        if !best_err.is_finite() {
483            return None;
484        }
485        // Refine around the coarse best.
486        let center = best_deg;
487        let mut deg = center - 2.0;
488        while deg <= center + 2.0 {
489            if (-360.0..=360.0).contains(&deg) {
490                if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
491                    let err = combined(err, deg);
492                    if err < best_err {
493                        best_err = err;
494                        best_deg = deg;
495                    }
496                }
497            }
498            deg += 0.25;
499        }
500        let clamped = best_deg.round().clamp(-360.0, 360.0);
501        let floored = if clamped.abs() < 0.1 {
502            if clamped < 0.0 { -0.1 } else { 0.1 }
503        } else {
504            clamped
505        };
506        Some(floored)
507    }
508
509    /// Squared screen-pixel distance from `(x, y)` to the arc-end at `deg` for an
510    /// angular annotation (`center + rotate(ref_dir, axis, deg) * radius`), or
511    /// `None` when that point is behind the camera.
512    fn angle_arc_end_err(
513        &self,
514        ann: &crate::feature_dimensions::FeatureDimAnnotation,
515        radius: f64,
516        deg: f64,
517        x: f64,
518        y: f64,
519    ) -> Option<f64> {
520        let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
521            ann.ref_dir,
522            ann.axis,
523            deg.to_radians(),
524        ));
525        let p = [
526            ann.center[0] + dir[0] * radius,
527            ann.center[1] + dir[1] * radius,
528            ann.center[2] + dir[2] * radius,
529        ];
530        let (sx, sy, depth) = self.camera.project(p);
531        if depth <= 0.0 {
532            return None;
533        }
534        Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
535    }
536
537    /// Edit a dimension value from a label field: a plain numeric literal sets the
538    /// param to that number; otherwise the input is treated as an EXPRESSION —
539    /// evaluated LIVE against the history's `expressions` + `configurator` (the
540    /// kernel `eval_expression`) and, on success, STORED as the expression string
541    /// (the kernel re-evaluates it via `ctx.number`, so it stays live). A blank /
542    /// bad-expression input no-ops (never corrupts the feature). Re-runs live.
543    pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
544        let trimmed = input.trim();
545        if trimmed.is_empty() {
546            return;
547        }
548        if is_plain_number_literal(trimmed) {
549            let Ok(number) = trimmed.parse::<f64>() else {
550                return;
551            };
552            if !number.is_finite() {
553                return;
554            }
555            self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
556        } else {
557            // Validate the expression before storing it (a bad expression no-ops).
558            let expressions = self.history.expressions();
559            let configurator = self.history.configurator();
560            match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
561                Ok(number) if number.is_finite() => {
562                    self.write_feature_dimension_param(
563                        feature_id,
564                        field_key,
565                        serde_json::Value::String(trimmed.to_string()),
566                    );
567                }
568                _ => {}
569            }
570        }
571    }
572
573    /// Set one `inputParams` field of `feature_id` (a number or an expression
574    /// string) and re-run the history (which re-projects the leaders in dimension
575    /// mode). No-op when the feature is absent.
576    fn write_feature_dimension_param(
577        &mut self,
578        feature_id: &str,
579        field_key: &str,
580        value: serde_json::Value,
581    ) {
582        let Some(index) = self.history.index_of(feature_id) else {
583            return;
584        };
585        let mut params = self
586            .history
587            .feature_params(index)
588            .unwrap_or_else(|| serde_json::json!({}));
589        let Some(object) = params.as_object_mut() else {
590            return;
591        };
592        object.insert(field_key.to_string(), value);
593        let _ = self.update_feature_params(feature_id, &params.to_string());
594    }
595}
596
597// --- FD-1 geometry helpers (self-contained, `fd_` prefixed to avoid clashes) ---
598
599fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
600    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
601}
602
603fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
604    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
605}
606
607fn fd_norm3(v: [f64; 3]) -> f64 {
608    fd_dot3(v, v).sqrt()
609}
610
611fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
612    let n = fd_norm3(v);
613    if n < 1e-12 {
614        [0.0, 0.0, 1.0]
615    } else {
616        [v[0] / n, v[1] / n, v[2] / n]
617    }
618}
619
620/// The parameter `t` of the point on the axis line `a + t*dir` (dir UNIT) closest
621/// to the ray `ray_o + s*ray_d` (ray_d UNIT). `None` when the two are parallel
622/// (no well-defined projection). `t` is the signed world distance along `dir`
623/// from `a`.
624fn closest_t_on_axis(
625    a: [f64; 3],
626    dir: [f64; 3],
627    ray_o: [f64; 3],
628    ray_d: [f64; 3],
629) -> Option<f64> {
630    let w0 = fd_sub3(a, ray_o);
631    let b = fd_dot3(dir, ray_d);
632    let d = fd_dot3(dir, w0);
633    let e = fd_dot3(ray_d, w0);
634    let denom = 1.0 - b * b;
635    if denom.abs() < 1e-9 {
636        return None;
637    }
638    Some((b * e - d) / denom)
639}
640
641fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
642    [v.x, v.y, v.z]
643}
644
645/// The FIRST reference NAME in a `reference_selection` param: a bare string, an
646/// object's `name`, or the first name in an array (port of the kernel
647/// `first_reference_name`). Trims + drops empties.
648fn first_reference_name(value: &serde_json::Value) -> Option<String> {
649    match value {
650        serde_json::Value::String(text) => {
651            let trimmed = text.trim();
652            (!trimmed.is_empty()).then(|| trimmed.to_string())
653        }
654        serde_json::Value::Object(map) => map
655            .get("name")
656            .and_then(|v| v.as_str())
657            .map(|s| s.trim().to_string())
658            .filter(|s| !s.is_empty()),
659        serde_json::Value::Array(items) => items.iter().find_map(first_reference_name),
660        _ => None,
661    }
662}
663
664/// The world CENTROID of a sketch profile — the average of its outer-loop curve
665/// start points (the profile-polygon vertices), approximating the previous
666/// face-average-center computation. Falls back to the sketch plane origin when
667/// no outer loop is available. Used to anchor the extrude/revolve gizmos on the
668/// geometry rather than at a possibly-far sketch-plane origin.
669fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
670    let mut sum = [0.0f64; 3];
671    let mut count = 0usize;
672    if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
673        for curve in &outer.curves {
674            if let Ok(domain) = curve.domain() {
675                if let Ok(point) = curve.evaluate(domain[0]) {
676                    sum[0] += point.x;
677                    sum[1] += point.y;
678                    sum[2] += point.z;
679                    count += 1;
680                }
681            }
682        }
683    }
684    if count > 0 {
685        [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
686    } else {
687        vec3_to_arr(profile.origin)
688    }
689}
690
691#[cfg(test)]
692mod feature_dimension_tests {
693    use super::*;
694
695    /// A one-primitive history request (identity transform) for the given type +
696    /// params, so the engine builds it and the dimension methods can read it.
697    fn primitive_request(feature_type: &str, id: &str, params: serde_json::Value) -> String {
698        let mut input = params.as_object().cloned().unwrap_or_default();
699        input.insert("id".into(), serde_json::json!(id));
700        input.insert(
701            "transform".into(),
702            serde_json::json!({
703                "position": [0.0, 0.0, 0.0],
704                "rotationEuler": [0.0, 0.0, 0.0],
705                "scale": [1.0, 1.0, 1.0]
706            }),
707        );
708        input.insert(
709            "boolean".into(),
710            serde_json::json!({ "targets": [], "operation": "NONE" }),
711        );
712        serde_json::json!({
713            "expressions": "",
714            "configurator": {},
715            "features": [{
716                "type": feature_type,
717                "inputParams": input,
718                "persistentData": {}
719            }]
720        })
721        .to_string()
722    }
723
724    fn cube_engine() -> EngineState {
725        let mut state = EngineState::new();
726        state
727            .set_history_json(&primitive_request(
728                "P.CU",
729                "Box",
730                serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 }),
731            ))
732            .unwrap();
733        state
734    }
735
736    #[test]
737    fn annotations_json_for_a_cube_has_three_linear_dims() {
738        let state = cube_engine();
739        let json: serde_json::Value =
740            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
741        let arr = json.as_array().unwrap();
742        assert_eq!(arr.len(), 3);
743        assert_eq!(arr[0]["fieldKey"], "sizeX");
744        assert_eq!(arr[0]["value"].as_f64().unwrap(), 10.0);
745        assert!(arr[0]["pointA"].is_array() && arr[0]["pointB"].is_array());
746        assert!(arr[0]["mid"].is_array());
747    }
748
749    #[test]
750    fn annotations_json_for_a_cylinder_has_radius_and_height() {
751        let mut state = EngineState::new();
752        state
753            .set_history_json(&primitive_request(
754                "P.CY",
755                "Cyl",
756                serde_json::json!({ "radius": 4.0, "height": 12.0 }),
757            ))
758            .unwrap();
759        let json: serde_json::Value =
760            serde_json::from_str(&state.feature_dimension_annotations_json("Cyl")).unwrap();
761        let arr = json.as_array().unwrap();
762        assert_eq!(arr.len(), 2);
763        assert_eq!(arr[0]["fieldKey"], "radius");
764        assert_eq!(arr[1]["fieldKey"], "height");
765    }
766
767    /// Task C audit — every PRIMITIVE that carries a Transform group also ships a
768    /// dimension builder, so expanding it auto-arms a dimension gizmo (`HistoryPanel`
769    /// guards on `feature_dimension_annotations_json != "[]"`). That gizmo's
770    /// origin/center sphere is the ONLY route to the transform gizmo now that the ◎
771    /// arm-button is gone, so a primitive with no dims would be stranded. Assert all
772    /// six expose a non-empty dimension gizmo AND arm to dimension mode. (Datum /
773    /// helix / pattern / port ALSO carry a Transform group but NO dim builder — they
774    /// have no path to transform post-◎-removal; flagged in the handoff, not fixed.)
775    #[test]
776    fn every_transformable_primitive_auto_arms_a_dimension_gizmo() {
777        let cases: [(&str, serde_json::Value); 6] = [
778            ("P.CU", serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 })),
779            ("P.CY", serde_json::json!({ "radius": 4.0, "height": 12.0 })),
780            ("P.CO", serde_json::json!({ "radiusBottom": 4.0, "radiusTop": 2.0, "height": 10.0 })),
781            ("P.S", serde_json::json!({ "radius": 5.0 })),
782            ("P.PY", serde_json::json!({ "baseSideLength": 6.0, "height": 8.0 })),
783            ("P.T", serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": 120.0 })),
784        ];
785        for (ty, params) in cases {
786            let mut state = EngineState::new();
787            state
788                .set_history_json(&primitive_request(ty, "Feat", params))
789                .unwrap_or_else(|e| panic!("{ty} builds: {e}"));
790            assert_ne!(
791                state.feature_dimension_annotations_json("Feat"),
792                "[]",
793                "{ty} must expose a dimension gizmo so expanding it auto-arms one"
794            );
795            // Arming it (what expand does) lands in dimension mode.
796            state.arm_dimension("Feat");
797            assert_eq!(state.gizmo_mode(), "dimension", "{ty}");
798        }
799    }
800
801    #[test]
802    fn annotations_json_empty_for_unknown_feature() {
803        let state = cube_engine();
804        assert_eq!(state.feature_dimension_annotations_json("nope"), "[]");
805    }
806
807    #[test]
808    fn set_value_updates_the_param_and_reruns() {
809        let mut state = cube_engine();
810        state.arm_dimension("Box");
811        state.feature_dimension_set_value("Box", "sizeX", "25");
812        let index = state.history.index_of("Box").unwrap();
813        let params = state.history.feature_params(index).unwrap();
814        assert_eq!(params["sizeX"].as_f64().unwrap(), 25.0);
815        // The reported annotation picks up the new value.
816        let json: serde_json::Value =
817            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
818        assert_eq!(json[0]["value"].as_f64().unwrap(), 25.0);
819    }
820
821    #[test]
822    fn set_value_stores_expression_string_when_not_a_literal() {
823        let mut state = cube_engine();
824        // Seed a variable in the history expressions.
825        state.set_expressions("w = 7;");
826        state.feature_dimension_set_value("Box", "sizeX", "w * 2");
827        let index = state.history.index_of("Box").unwrap();
828        let params = state.history.feature_params(index).unwrap();
829        // The expression is stored verbatim (the kernel re-evaluates it live).
830        assert_eq!(params["sizeX"].as_str().unwrap(), "w * 2");
831        // …and resolves to 14 in the reported annotation.
832        let json: serde_json::Value =
833            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
834        assert_eq!(json[0]["value"].as_f64().unwrap(), 14.0);
835    }
836
837    #[test]
838    fn set_value_rejects_a_bad_expression() {
839        let mut state = cube_engine();
840        state.feature_dimension_set_value("Box", "sizeX", "this is not valid");
841        let index = state.history.index_of("Box").unwrap();
842        let params = state.history.feature_params(index).unwrap();
843        // Unchanged — the bad expression never landed.
844        assert_eq!(params["sizeX"].as_f64().unwrap(), 10.0);
845    }
846
847    #[test]
848    fn origin_sphere_and_center_sphere_toggle_the_two_modes() {
849        // The single orange sphere at the gizmo center flips dimension ↔ transform:
850        // clicking the dimension arrows' ORIGIN sphere arms the transform gizmo, and
851        // clicking the transform gizmo's CENTER sphere arms the dimension arrows.
852        let mut state = cube_engine();
853        state.resize(800.0, 600.0);
854        state.camera.eye = [0.0, 0.0, 40.0];
855        state.camera.target = [0.0, 0.0, 0.0];
856        state.camera.up = [0.0, 1.0, 0.0];
857        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
858
859        // Arm the DIMENSION arrows for the cube.
860        state.arm_dimension("Box");
861        assert_eq!(state.gizmo_mode(), "dimension");
862
863        // The arrows share one ORIGIN sphere at the cube's min corner
864        // (annotations[0].pointA). Project it the way `transform_gizmo_anchor` does.
865        let anns: serde_json::Value =
866            serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
867        let pa = &anns[0]["pointA"];
868        let origin = [
869            pa[0].as_f64().unwrap(),
870            pa[1].as_f64().unwrap(),
871            pa[2].as_f64().unwrap(),
872        ];
873        let (ox, oy, depth) = state.camera.project(origin);
874        assert!(depth > 0.0, "origin in front of camera");
875
876        // A click on the origin sphere toggles DIMENSION → TRANSFORM (mirroring the
877        // viewport click-chain). The cross-mode pick is inert here.
878        assert!(state.dimension_origin_pick(ox, oy), "pick hits the origin sphere");
879        assert!(
880            !state.transform_center_pick(ox, oy),
881            "no transform-center pick while in dimension mode"
882        );
883        state.toggle_to_transform();
884        assert_eq!(state.gizmo_mode(), "transform");
885
886        // The transform gizmo's orange CENTER sphere sits at the same anchor; a
887        // click on it toggles TRANSFORM → DIMENSION.
888        let (cx, cy) = state.transform_gizmo_anchor().expect("transform anchor");
889        assert!(state.transform_center_pick(cx, cy), "pick hits the center handle");
890        assert!(
891            !state.dimension_origin_pick(cx, cy),
892            "no dimension-origin pick while in transform mode"
893        );
894        state.toggle_to_dimension();
895        assert_eq!(state.gizmo_mode(), "dimension");
896    }
897
898    /// A revolve is ANGULAR-ONLY: its single dimension is the sweep angle, whose
899    /// mode-toggle target is the arc CENTER (there is no linear origin sphere). So
900    /// the center-sphere pick is the ONLY way it reaches the transform gizmo. Assert
901    /// the full round trip dimension → transform → dimension. (This guards the FD-2
902    /// gap fix — restoring the old `kind != Linear { continue }` skip in
903    /// `dimension_origin_pick` makes the first pick miss and fails this test.)
904    #[test]
905    fn angular_center_sphere_toggles_a_revolve_to_transform_and_back() {
906        // Sketch "Sk": a radial rectangle (x∈[2,4], y∈[0,3]) profile + a +Y
907        // construction line "Sk:G20" the revolve uses as its axis. The kernel
908        // publishes both, and they survive the sketch being consumed by the revolve.
909        let request = serde_json::json!({
910            "expressions": "",
911            "configurator": {},
912            "features": [
913                {
914                    "type": "S",
915                    "inputParams": { "id": "Sk" },
916                    "persistentData": {
917                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
918                        "sketch": {
919                            "points": [
920                                {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
921                                {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
922                                {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
923                            ],
924                            "geometries": [
925                                {"id":10,"type":"line","points":[1,2]},
926                                {"id":11,"type":"line","points":[2,3]},
927                                {"id":12,"type":"line","points":[3,4]},
928                                {"id":13,"type":"line","points":[4,1]},
929                                {"id":20,"type":"line","points":[5,6],"construction":true}
930                            ],
931                            "constraints": []
932                        }
933                    }
934                },
935                {
936                    "type": "R",
937                    "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
938                    "persistentData": {}
939                }
940            ]
941        })
942        .to_string();
943
944        let mut state = EngineState::new();
945        state.set_history_json(&request).expect("revolve builds");
946        state.resize(800.0, 600.0);
947        state.camera.eye = [0.0, 0.0, 40.0];
948        state.camera.target = [0.0, 0.0, 0.0];
949        state.camera.up = [0.0, 1.0, 0.0];
950        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
951
952        // The revolve's ONLY dimension is angular — no linear origin sphere exists.
953        let anns = state.feature_dimension_annotations("Rev");
954        assert_eq!(anns.len(), 1, "revolve emits one (angular) dim: {anns:?}");
955        assert_eq!(anns[0].kind, crate::feature_dimensions::FeatureDimKind::Angular);
956        let center = anns[0].center;
957
958        // Arm the dimension arrows (what expanding the feature does).
959        state.arm_dimension("Rev");
960        assert_eq!(state.gizmo_mode(), "dimension");
961
962        // Clicking the arc-CENTER orange sphere toggles DIMENSION → TRANSFORM.
963        let (cx, cy, depth) = state.camera.project(center);
964        assert!(depth > 0.0, "arc center in front of the camera");
965        assert!(
966            state.dimension_origin_pick(cx, cy),
967            "pick hits the angular center sphere"
968        );
969        state.toggle_to_transform();
970        assert_eq!(state.gizmo_mode(), "transform");
971
972        // And the transform gizmo's CENTER sphere toggles back to the arc.
973        let (tx, ty) = state.transform_gizmo_anchor().expect("transform anchor");
974        assert!(
975            state.transform_center_pick(tx, ty),
976            "pick hits the transform center handle"
977        );
978        state.toggle_to_dimension();
979        assert_eq!(state.gizmo_mode(), "dimension");
980        assert!(
981            state.dimension_armed_for("Rev"),
982            "round trip lands back on the revolve's dimension arrows"
983        );
984    }
985
986    /// A revolve history: sketch "Sk" (radial rectangle profile x∈[2,4], y∈[0,3]
987    /// plus a +Y construction line "Sk:G20" used as the axis) revolved `angle`°
988    /// about that axis. Mirrors the fixture in
989    /// `angular_center_sphere_toggles_a_revolve_to_transform_and_back` so the
990    /// revolve's angular dim resolves against a real scene, parameterized by angle.
991    fn revolve_engine(angle: f64) -> EngineState {
992        revolve_engine_with_profile("Sk", angle)
993    }
994
995    /// As [`revolve_engine`] but with the revolve's `profile` reference spelled
996    /// `profile` — so a test can drive the committed-sketch `{sketch}:FACE` display
997    /// alias through the same live pipeline.
998    fn revolve_engine_with_profile(profile: &str, angle: f64) -> EngineState {
999        let request = serde_json::json!({
1000            "expressions": "",
1001            "configurator": {},
1002            "features": [
1003                {
1004                    "type": "S",
1005                    "inputParams": { "id": "Sk" },
1006                    "persistentData": {
1007                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1008                        "sketch": {
1009                            "points": [
1010                                {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1011                                {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1012                                {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1013                            ],
1014                            "geometries": [
1015                                {"id":10,"type":"line","points":[1,2]},
1016                                {"id":11,"type":"line","points":[2,3]},
1017                                {"id":12,"type":"line","points":[3,4]},
1018                                {"id":13,"type":"line","points":[4,1]},
1019                                {"id":20,"type":"line","points":[5,6],"construction":true}
1020                            ],
1021                            "constraints": []
1022                        }
1023                    }
1024                },
1025                {
1026                    "type": "R",
1027                    "inputParams": { "id": "Rev", "profile": profile, "axis": "Sk:G20", "angle": angle },
1028                    "persistentData": {}
1029                }
1030            ]
1031        })
1032        .to_string();
1033
1034        let mut state = EngineState::new();
1035        state.set_history_json(&request).expect("revolve builds");
1036        state.resize(800.0, 600.0);
1037        // Look straight DOWN the revolve axis (the oriented axis is -Y for this
1038        // fixture; center is (0, 1.5, 0)) so the arc's (cosθ, sinθ) maps uniquely
1039        // to screen. An edge-on view would alias θ ↔ -θ and defeat the angular
1040        // drag's nearest-projection search.
1041        state.camera.eye = [0.0, 40.0, 0.0];
1042        state.camera.target = [0.0, 1.5, 0.0];
1043        state.camera.up = [0.0, 0.0, 1.0];
1044        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1045        state
1046    }
1047
1048    /// The revolve's `angle` surfaces as an ANGULAR annotation in the JSON the app
1049    /// reads (so the auto-arm lights it up), and its arc arrowhead is pickable +
1050    /// draggable, writing the swept degrees back to `angle`. Exercises the whole
1051    /// revolve angle-gizmo pipeline end-to-end against a live scene: `axis`
1052    /// reference → oriented arc (`orient_revolve_axis`, the kernel's rule) → pick →
1053    /// angular drag. (The struct-level annotation + center-sphere mode toggle are
1054    /// covered by `angular_center_sphere_toggles_a_revolve_to_transform_and_back`;
1055    /// this locks in the JSON surface + the live pick/drag for the revolve.)
1056    #[test]
1057    fn revolve_angle_json_is_angular_and_drag_writes_the_swept_degrees() {
1058        let mut state = revolve_engine(90.0);
1059
1060        // JSON surface: exactly one angular `angle` entry (what the app + auto-arm
1061        // read — a non-`[]` result is what auto-arms the gizmo on expand).
1062        let json: serde_json::Value =
1063            serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1064        let arr = json.as_array().unwrap();
1065        assert_eq!(arr.len(), 1, "revolve emits one dim: {arr:?}");
1066        assert_eq!(arr[0]["fieldKey"], "angle");
1067        assert_eq!(arr[0]["kind"], "angular");
1068        assert!((arr[0]["value"].as_f64().unwrap() - 90.0).abs() < 1e-9);
1069        assert!(arr[0]["mid"].is_array(), "angular chip anchors on the arc");
1070
1071        // Arm the dimension arrows (what expanding the feature does), then grab the
1072        // revolve's angular dim.
1073        state.arm_dimension("Rev");
1074        let ann = state
1075            .feature_dimension_annotations("Rev")
1076            .into_iter()
1077            .find(|a| a.field_key == "angle")
1078            .expect("angle dim");
1079        assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1080
1081        // PRESS on the arc's CURRENT (90°) arrowhead handle → pick grabs `angle`.
1082        let handle =
1083            crate::feature_dimensions::arrow_handle_point(&ann, state.world_per_pixel());
1084        let (hx, hy, hdepth) = state.camera.project(handle);
1085        assert!(hdepth > 0.0, "handle in front of the camera");
1086        assert_eq!(
1087            state.dimension_arrow_pick(hx, hy).as_deref(),
1088            Some("angle"),
1089            "pick at the arc arrowhead grabs the angle handle"
1090        );
1091
1092        // DRAG toward the arc-end for a larger sweep; the nearest-projection search
1093        // must recover that degree (within the 1° snap).
1094        let target = 200.0_f64;
1095        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1096        let dir = crate::feature_dimensions::rotate_about_axis(
1097            ann.ref_dir,
1098            ann.axis,
1099            target.to_radians(),
1100        );
1101        let world = [
1102            ann.center[0] + dir[0] * radius,
1103            ann.center[1] + dir[1] * radius,
1104            ann.center[2] + dir[2] * radius,
1105        ];
1106        let (tx, ty, tdepth) = state.camera.project(world);
1107        assert!(tdepth > 0.0, "drag target in front of the camera");
1108        state.feature_dimension_drag("Rev", "angle", tx, ty);
1109
1110        let after = state.feature_dimension_annotations("Rev");
1111        let new_angle = after.iter().find(|a| a.field_key == "angle").expect("angle dim");
1112        assert!(
1113            (new_angle.value - target).abs() < 2.0,
1114            "drag should sweep the angle to ~{target}°, got {}",
1115            new_angle.value
1116        );
1117    }
1118
1119    /// Regression: a revolve (or extrude) whose `profile` references the committed
1120    /// sketch by its render display alias `{sketch}:FACE` must still resolve the
1121    /// sketch profile and auto-arm its dimension gizmo. `sketch_profiles` is keyed by
1122    /// the base sketch id (`Sk`), so `lookup_sketch_profile` has to strip the `:FACE`
1123    /// alias — exactly as the kernel's profile consumers do. Before the strip this
1124    /// returned `[]` and the angle arc silently never appeared (the real-world
1125    /// `RevolveSketch.BREP.json` symptom: profile `S4:FACE`).
1126    #[test]
1127    fn revolve_face_alias_profile_still_arms_the_angle_gizmo() {
1128        let state = revolve_engine_with_profile("Sk:FACE", 90.0);
1129        let json: serde_json::Value =
1130            serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1131        let arr = json.as_array().unwrap();
1132        assert_eq!(
1133            arr.len(),
1134            1,
1135            "the `:FACE`-aliased revolve profile must still emit its angular dim: {arr:?}"
1136        );
1137        assert_eq!(arr[0]["fieldKey"], "angle");
1138        assert_eq!(arr[0]["kind"], "angular");
1139        assert_eq!(arr[0]["value"].as_f64().unwrap(), 90.0);
1140    }
1141
1142    #[test]
1143    fn closest_t_on_axis_projects_a_perpendicular_ray() {
1144        // Axis along +X from origin; a ray straight down through (7, 5, 0) hits the
1145        // axis at t = 7.
1146        let t = closest_t_on_axis(
1147            [0.0, 0.0, 0.0],
1148            [1.0, 0.0, 0.0],
1149            [7.0, 5.0, 0.0],
1150            [0.0, -1.0, 0.0],
1151        )
1152        .unwrap();
1153        assert!((t - 7.0).abs() < 1e-9, "t = {t}");
1154    }
1155
1156    #[test]
1157    fn closest_t_on_axis_none_when_parallel() {
1158        assert!(closest_t_on_axis(
1159            [0.0, 0.0, 0.0],
1160            [1.0, 0.0, 0.0],
1161            [0.0, 5.0, 0.0],
1162            [1.0, 0.0, 0.0],
1163        )
1164        .is_none());
1165    }
1166
1167    // --- FD-2 angular: torus arc (a primitive with an angular dim) -------------
1168
1169    fn torus_engine(arc: f64) -> EngineState {
1170        let mut state = EngineState::new();
1171        state
1172            .set_history_json(&primitive_request(
1173                "P.T",
1174                "Tor",
1175                serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": arc }),
1176            ))
1177            .unwrap();
1178        state
1179    }
1180
1181    #[test]
1182    fn annotations_json_for_a_torus_has_two_linear_and_one_angular() {
1183        let state = torus_engine(120.0);
1184        let json: serde_json::Value =
1185            serde_json::from_str(&state.feature_dimension_annotations_json("Tor")).unwrap();
1186        let arr = json.as_array().unwrap();
1187        assert_eq!(arr.len(), 3);
1188        assert_eq!(arr[0]["fieldKey"], "majorRadius");
1189        assert_eq!(arr[0]["kind"], "linear");
1190        assert_eq!(arr[1]["fieldKey"], "tubeRadius");
1191        assert_eq!(arr[1]["kind"], "linear");
1192        // The arc dim is ANGULAR, its value is DEGREES, and its chip anchors on
1193        // the arc (a world point the app projects).
1194        assert_eq!(arr[2]["fieldKey"], "arc");
1195        assert_eq!(arr[2]["kind"], "angular");
1196        assert_eq!(arr[2]["value"].as_f64().unwrap(), 120.0);
1197        assert!(arr[2]["mid"].is_array());
1198    }
1199
1200    #[test]
1201    fn dragging_a_torus_arc_writes_the_swept_degrees() {
1202        let mut state = torus_engine(90.0);
1203        state.arm_dimension("Tor");
1204        let anns = state.feature_dimension_annotations("Tor");
1205        let arc = anns
1206            .iter()
1207            .find(|a| a.field_key == "arc")
1208            .expect("arc dim")
1209            .clone();
1210        assert_eq!(arc.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1211
1212        // Aim the pointer exactly at the arc-end for a target sweep; the drag's
1213        // nearest-projection search must recover that degree (within the 1° snap).
1214        let target = 210.0_f64;
1215        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1216        let dir = crate::feature_dimensions::rotate_about_axis(
1217            arc.ref_dir,
1218            arc.axis,
1219            target.to_radians(),
1220        );
1221        let world = [
1222            arc.center[0] + dir[0] * radius,
1223            arc.center[1] + dir[1] * radius,
1224            arc.center[2] + dir[2] * radius,
1225        ];
1226        let (sx, sy, depth) = state.camera.project(world);
1227        assert!(depth > 0.0, "arc-end must project in front of the camera");
1228
1229        state.feature_dimension_drag("Tor", "arc", sx, sy);
1230
1231        let after = state.feature_dimension_annotations("Tor");
1232        let new_arc = after.iter().find(|a| a.field_key == "arc").expect("arc dim");
1233        assert!(
1234            (new_arc.value - target).abs() < 2.0,
1235            "drag should sweep the arc to ~{target}°, got {}",
1236            new_arc.value
1237        );
1238    }
1239
1240    // --- Fix 4: dimension-arrow pick + drag (a linear cube dim) ----------------
1241
1242    #[test]
1243    fn dimension_arrow_pick_and_drag_edits_the_param_live() {
1244        let mut state = cube_engine();
1245        state.resize(800.0, 600.0);
1246        state.camera.eye = [0.0, 0.0, 40.0]; // look down -Z: +X screen-right, +Y up
1247        state.camera.target = [0.0, 0.0, 0.0];
1248        state.camera.up = [0.0, 1.0, 0.0];
1249        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1250
1251        // A pick is inert until the dimension arrows are armed.
1252        state.arm_transform("Box");
1253        assert!(
1254            state.dimension_arrow_pick(400.0, 300.0).is_none(),
1255            "no arrow pick in transform mode"
1256        );
1257
1258        state.arm_dimension("Box");
1259        assert_eq!(state.gizmo_mode(), "dimension");
1260
1261        // The sizeX dim runs origin → (sizeX,0,0); its orange arrowHEAD is at
1262        // `point_b`. A pick AT the projected arrowhead grabs the `sizeX` field.
1263        let ann = state
1264            .feature_dimension_annotations("Box")
1265            .into_iter()
1266            .find(|a| a.field_key == "sizeX")
1267            .expect("sizeX dim");
1268        let a = ann.point_a;
1269        let b = ann.point_b;
1270        let (bx, by, depth) = state.camera.project(b);
1271        assert!(depth > 0.0, "arrowhead in front of the camera");
1272        assert_eq!(
1273            state.dimension_arrow_pick(bx, by).as_deref(),
1274            Some("sizeX"),
1275            "pick at the arrowhead grabs sizeX"
1276        );
1277        // A far-off pixel grabs nothing.
1278        assert!(state.dimension_arrow_pick(10.0, 10.0).is_none(), "empty space → no arrow");
1279
1280        // DRAG the arrow outward along +X to a target length; the value tracks the
1281        // pointer live (Fix 3 + Fix 4). Aim the pointer at a + dir*target_len.
1282        let len = {
1283            let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1284            (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
1285        };
1286        let dir = [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len];
1287        let target_len = 16.0_f64;
1288        let world = [
1289            a[0] + dir[0] * target_len,
1290            a[1] + dir[1] * target_len,
1291            a[2] + dir[2] * target_len,
1292        ];
1293        let (wx, wy, wdepth) = state.camera.project(world);
1294        assert!(wdepth > 0.0, "drag target in front of the camera");
1295
1296        let before = state
1297            .history
1298            .feature_params(state.history.index_of("Box").unwrap())
1299            .unwrap()["sizeX"]
1300            .as_f64()
1301            .unwrap();
1302        state.feature_dimension_drag("Box", "sizeX", wx, wy);
1303        let after = state
1304            .history
1305            .feature_params(state.history.index_of("Box").unwrap())
1306            .unwrap()["sizeX"]
1307            .as_f64()
1308            .unwrap();
1309
1310        // The param grew toward the drag target. The drag scales the world distance
1311        // by the current (value / world-length) ratio; under unit scale that is 1,
1312        // so the new value ≈ target_len.
1313        let expected = target_len * (ann.value / len);
1314        assert!(after > before, "sizeX grew: {before} → {after}");
1315        assert!(
1316            (after - expected).abs() < 0.5,
1317            "drag set sizeX to ~{expected}, got {after}"
1318        );
1319    }
1320}
1321