Skip to main content

brep_render/engine_state/
feature_dims.rs

1use brep_kernel::first_reference_name;
2use super::*;
3use super::sketch_edit_ops::is_plain_number_literal;
4use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
5
6/// The world-space overlay group carrying the FD leaders + arrowheads.
7const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
8
9/// How clearly a dimension handle must win its own point before it is published
10/// as a widget (px, screen). See [`EngineState::dimension_handle_points`].
11const GRAB_MARGIN_PX: f64 = 2.0;
12
13/// The role a dimension-gizmo hit region plays. An `Arrow` is a value-drag handle
14/// (a linear leader or the angular arc handle, keyed by its `field_key`); `Origin`
15/// is the shared origin/center BALL (the mode toggle), keyed by the `field_key` of
16/// the FIRST dim anchored there — several dims share one ball (a cube's three
17/// leaders), so the name is a stable handle for it rather than a claim that it
18/// belongs to that one dim. Both share ONE region list so the pick + the drawn
19/// outline can never drift.
20#[derive(Clone, Debug)]
21enum DimRole {
22    Arrow(String),
23    Origin(String),
24}
25
26impl EngineState {
27    /// The armed ◎ gizmo mode: `"none"`, `"transform"`, or `"dimension"`. Drives
28    /// the ◎ highlight + the app's dimension-overlay draw / input routing.
29    pub fn gizmo_mode(&self) -> &'static str {
30        match self.transform_gizmo.mode {
31            GizmoMode::None => "none",
32            GizmoMode::Transform => "transform",
33            GizmoMode::Dimension => "dimension",
34        }
35    }
36
37    /// Whether the DIMENSION gizmo is armed for THIS feature (drives the ◎
38    /// dimension-mode highlight).
39    pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
40        matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
41            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
42    }
43
44    /// The dimension-armed feature id (empty unless in dimension mode).
45    pub fn dimension_armed_feature(&self) -> String {
46        if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
47            self.transform_gizmo.feature_id.clone().unwrap_or_default()
48        } else {
49            String::new()
50        }
51    }
52
53    /// Arm the DIMENSION gizmo for `feature_id`: hide the transform widget, show
54    /// the annotation overlay. Re-arming a different feature moves it.
55    pub fn arm_dimension(&mut self, feature_id: &str) {
56        // The widget slot is shared — an armed component Move gizmo yields.
57        self.component_move_reset();
58        self.transform_gizmo.feature_id = Some(feature_id.to_string());
59        self.transform_gizmo.mode = GizmoMode::Dimension;
60        self.transform_gizmo.drag = None;
61        // The transform widget and the dimension overlay are mutually exclusive.
62        let _ = self.widgets.set_transform_json("null");
63        self.refresh_feature_dimension_overlay();
64        self.dirty = true;
65    }
66
67    // --- The orange center-sphere ◎ TOGGLE (dimension ↔ transform) ---------
68    //
69    // A single orange sphere sits at the gizmo center in BOTH modes: the
70    // transform gizmo's `HANDLE_CENTER` sphere and the dimension arrows' shared
71    // origin sphere project to the same point. Clicking it flips the two modes,
72    // mirroring the old app's `CombinedTransformControls` center-handle toggle
73    // (pointer-down on `HANDLE_CENTER` calls
74    // `toggleDisplayMode`). The viewport routes a bare CLICK here; a DRAG on the
75    // center still free-moves via `transform_press` (unchanged).
76
77    /// Whether a screen-px pick in TRANSFORM mode lands on the orange CENTER
78    /// free-move sphere (`HANDLE_CENTER`). The viewport uses this to make a bare
79    /// click on the center TOGGLE to the dimension arrows (via
80    /// [`toggle_to_dimension`](Self::toggle_to_dimension)) instead of swallowing
81    /// it as a generic handle click. False in any other gizmo mode.
82    pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
83        matches!(self.transform_gizmo.mode, GizmoMode::Transform)
84            && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
85    }
86
87    /// Whether a screen-px pick in DIMENSION mode lands on an orange ORIGIN
88    /// sphere of the armed feature's dimension arrows. Each distinct annotation
89    /// draws such a sphere — a LINEAR dim at its `point_a` (a cube's three axis
90    /// dims share one, a cone/pyramid draw two), an ANGULAR dim at its arc
91    /// `center` (the vertex; its sweep-END sphere is the angle DRAG handle, not a
92    /// toggle) — so every one is projected via the camera and hit-tested against
93    /// the screen-constant sphere radius. The viewport uses this to TOGGLE back to
94    /// the transform gizmo (via [`toggle_to_transform`](Self::toggle_to_transform)),
95    /// which is the ONLY way an angular-only feature (a revolve) reaches transform.
96    /// False in any other gizmo mode. The hit radius mirrors the gizmo center's own
97    /// tolerance (`PX_CENTER_RAD + 2.0`, transform.rs) so the two toggle targets match.
98    pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
99        // The origin sphere (LINEAR at `point_a`, ANGULAR at the arc `center`) is an
100        // `Origin`-role region. 2D-test the cursor against the SAME screen-space
101        // regions the outline draws — a point-in-circle test, so what is outlined
102        // is exactly what toggles.
103        let p = [x as f32, y as f32];
104        self.dimension_hit_regions()
105            .into_iter()
106            .any(|(role, shape)| matches!(role, DimRole::Origin(_)) && shape.contains(p))
107    }
108
109    /// Whether a screen-px pick in DIMENSION mode lands on a dimension ARROWHEAD
110    /// (a linear leader's orange cone TIP at `point_b`, or an angular arc's orange
111    /// sweep-END handle sphere). Returns the grabbed annotation's `field_key` — the
112    /// viewport routes a DRAG that starts here to [`feature_dimension_drag`](Self::feature_dimension_drag), editing that param live (Fix 4). `None` in any other
113    /// gizmo mode / when no arrowhead is under the pointer. Distinct from
114    /// [`dimension_origin_pick`](Self::dimension_origin_pick): that grabs the SHARED
115    /// origin sphere (a mode toggle), this grabs an arrowHEAD (a value edit). The
116    /// nearest arrowhead within the screen-constant hit radius wins.
117    pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
118        // The NEAREST `Arrow`-role region containing the cursor wins. LINEAR: the
119        // WHOLE leader CAPSULE (`point_a → point_b`) is grabbable, so a cursor
120        // anywhere on the visible shaft grabs it — even when `point_b` crosses
121        // BEHIND the eye (the reported sizeY failure), because in ortho the whole
122        // leader still projects (and in perspective the region is front-clipped to
123        // its visible part). ANGULAR: the arc sweep-END handle CIRCLE. These are
124        // the SAME screen-space regions the outline draws, so what is outlined is
125        // exactly what grabs.
126        let p = [x as f32, y as f32];
127        let mut best: Option<(f32, String)> = None;
128        for (role, shape) in self.dimension_hit_regions() {
129            if let DimRole::Arrow(key) = role {
130                let d = shape.spine_distance(p);
131                if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
132                    best = Some((d, key));
133                }
134            }
135        }
136        best.map(|(_, key)| key)
137    }
138
139    /// The authoritative SCREEN-space (viewport-local px) pickable regions of the
140    /// armed feature's dimension gizmo, each paired with its [`DimRole`]. The
141    /// SINGLE source `dimension_arrow_pick` (its `Arrow` regions), `dimension_origin_pick`
142    /// (its `Origin` regions), and `dimension_hit_areas_json` (draws them ALL) all
143    /// consume — so the grabbable area is exactly the drawn outline. Projection +
144    /// the perspective front-clip happen ONCE in [`brep_gizmos::hit_region`].
145    ///   * LINEAR → a leader CAPSULE (`point_a → point_b`, `ARROW_HANDLE_HIT_RAD_PX`)
146    ///     with role `Arrow` + an origin CIRCLE (`point_a`, `ORIGIN_SPHERE_RAD_PX + 2`)
147    ///     with role `Origin`.
148    ///   * ANGULAR → an arc-handle CIRCLE (`arrow_handle_point`,
149    ///     `ARROW_HANDLE_HIT_RAD_PX`) with role `Arrow` + an arc-center origin
150    ///     CIRCLE (`center`, `ORIGIN_SPHERE_RAD_PX + 2`) with role `Origin`.
151    /// Origin balls are deduped by world position (a torus's linear origin + its
152    /// angular center coincide) so they match the single drawn sphere. `[]` unless
153    /// the DIMENSION gizmo is armed for a feature.
154    fn dimension_hit_regions(&self) -> Vec<(DimRole, HitShape)> {
155        if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
156            return Vec::new();
157        }
158        let feature = self.dimension_armed_feature();
159        if feature.is_empty() {
160            return Vec::new();
161        }
162        let wpp = self.camera.world_per_pixel();
163        let arrow_px = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
164        let origin_px = (crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0) as f32;
165        let cam = &self.camera;
166        let mut out: Vec<(DimRole, HitShape)> = Vec::new();
167        let mut origins: Vec<[f64; 3]> = Vec::new();
168        for ann in self.feature_dimension_annotations(&feature) {
169            match ann.kind {
170                crate::feature_dimensions::FeatureDimKind::Linear => {
171                    if let Some(shape) = segment_region(cam, ann.point_a, ann.point_b, arrow_px) {
172                        out.push((DimRole::Arrow(ann.field_key.clone()), shape));
173                    }
174                    push_origin_region(&mut out, &mut origins, cam, ann.point_a, origin_px, &ann.field_key);
175                }
176                crate::feature_dimensions::FeatureDimKind::Angular => {
177                    let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
178                    if let Some(shape) = point_region(cam, handle, arrow_px) {
179                        out.push((DimRole::Arrow(ann.field_key.clone()), shape));
180                    }
181                    push_origin_region(&mut out, &mut origins, cam, ann.center, origin_px, &ann.field_key);
182                }
183            }
184        }
185        out
186    }
187
188    /// The grabbable ARROWHEAD of every dimension the armed ◎ gizmo draws, as
189    /// `(field_key, (x, y))` in viewport-local px — what a host presses to drag
190    /// that param's value.
191    ///
192    /// The point is the arrowHEAD end of the `Arrow` region
193    /// (`dimension_hit_regions`), not its middle:
194    /// a LINEAR leader's capsule runs `point_a → point_b` and the whole shaft
195    /// grabs, but the cone TIP at `b` is the thing a reader sees and a
196    /// walkthrough should be filmed pulling; an ANGULAR dim's region is already
197    /// the sweep-end handle circle, so its center is the point.
198    ///
199    /// Published ONLY where the gizmo's own pick answers with that field, AND
200    /// still answers it `GRAB_MARGIN_PX` away in each direction — the same
201    /// rule [`transform_handle_points`](Self::transform_handle_points) follows,
202    /// with a margin the transform widget does not need.
203    ///
204    /// The margin is not caution, it is a bug this found. A pick is decided by
205    /// `spine_distance`, nearest wins and a tie goes to the first annotation, so
206    /// where two handles COINCIDE the winner turns on the last bit of an `f32`.
207    /// A full-circle torus is exactly that: `arc`'s sweep-end ball sits at 360°,
208    /// which is where the `majorRadius` leader points, so the two are the same
209    /// screen point. Publishing `dim:arc` there passed an equality test taken in
210    /// `f64` and then lost the press, because a rect goes out through JSON and
211    /// comes back a few ulps away — and the drag silently resized the torus
212    /// instead of sweeping it. Requiring the field to win by a couple of pixels
213    /// drops such a handle rather than publishing one that grabs its neighbour.
214    ///
215    /// Empty in any mode but DIMENSION.
216    pub fn dimension_handle_points(&self) -> Vec<(String, (f64, f64))> {
217        use brep_gizmos::hit_region::HitShape;
218        self.dimension_hit_regions()
219            .into_iter()
220            .filter_map(|(role, shape)| {
221                let DimRole::Arrow(key) = role else { return None };
222                let [x, y] = match shape {
223                    HitShape::Capsule { b, .. } => b,
224                    HitShape::Circle { c, .. } => c,
225                };
226                let point = (f64::from(x), f64::from(y));
227                let grabs = |dx: f64, dy: f64| {
228                    self.dimension_arrow_pick(point.0 + dx, point.1 + dy).as_deref()
229                        == Some(key.as_str())
230                };
231                const M: f64 = GRAB_MARGIN_PX;
232                let robust = grabs(0.0, 0.0)
233                    && grabs(M, 0.0)
234                    && grabs(-M, 0.0)
235                    && grabs(0.0, M)
236                    && grabs(0.0, -M);
237                robust.then_some((key, point))
238            })
239            .collect()
240    }
241
242    /// The ORIGIN BALLS the armed ◎ gizmo draws, as `(field_key, (x, y))` in
243    /// viewport-local px — the orange spheres a CLICK toggles the gizmo between
244    /// its dimension and transform forms at.
245    ///
246    /// A ball is keyed by the first dim anchored on it, because several share
247    /// one: a cube's three leaders all start at its min corner and draw a single
248    /// sphere (`radius`/`sizeX`), while a cone or a pyramid draws two — one at
249    /// the base centre and one at the top (`radiusBottom` and `radiusTop`). The
250    /// name is a stable handle for the ball, not a claim that it belongs to that
251    /// dim alone.
252    ///
253    /// Published under the same rule as the arrowheads: only where the gizmo's
254    /// own [`dimension_origin_pick`](Self::dimension_origin_pick) answers at the
255    /// point, so a ball hidden behind an arrowhead that outranks it is dropped
256    /// rather than published somewhere a click would do something else.
257    ///
258    /// Empty in any mode but DIMENSION.
259    pub fn dimension_origin_points(&self) -> Vec<(String, (f64, f64))> {
260        use brep_gizmos::hit_region::HitShape;
261        self.dimension_hit_regions()
262            .into_iter()
263            .filter_map(|(role, shape)| {
264                let DimRole::Origin(key) = role else { return None };
265                let HitShape::Circle { c, .. } = shape else { return None };
266                let point = (f64::from(c[0]), f64::from(c[1]));
267                self.dimension_origin_pick(point.0, point.1).then_some((key, point))
268            })
269            .collect()
270    }
271
272    /// DEBUG overlay: the EXACT SCREEN-space (viewport-local px) pickable regions
273    /// of the armed feature's dimension gizmo — the SAME regions
274    /// `dimension_arrow_pick` + `dimension_origin_pick` 2D-test the cursor against
275    /// (`dimension_hit_regions`) — so the red
276    /// outline can NEVER drift from the grabbable area. Each item is a
277    /// `{ kind:"capsule", a:[x,y], b:[x,y], r }` (linear leaders) or
278    /// `{ kind:"circle", c:[x,y], r }` (origin / arc-handle spheres); the app only
279    /// offsets by `rect.min`. `[]` unless the DIMENSION gizmo is armed.
280    pub fn dimension_hit_areas_json(&self) -> String {
281        let regions = self.dimension_hit_regions();
282        super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
283    }
284
285    /// Toggle the armed ◎ gizmo from TRANSFORM to DIMENSION for the currently
286    /// transform-armed feature (the orange center-sphere click). No-op unless a
287    /// feature is transform-armed.
288    pub fn toggle_to_dimension(&mut self) {
289        let feature = self.transform_armed_feature();
290        if !feature.is_empty() && self.feature_dimension_annotations_json(&feature) != "[]" {
291            self.arm_dimension(&feature);
292        }
293    }
294
295    /// Toggle the armed ◎ gizmo from DIMENSION to TRANSFORM for the currently
296    /// dimension-armed feature (the orange origin-sphere click). No-op unless a
297    /// feature is dimension-armed.
298    pub fn toggle_to_transform(&mut self) {
299        let feature = self.dimension_armed_feature();
300        if feature.is_empty() {
301            return;
302        }
303        // A PLANE has NO transform — its placement is fully the orientation +
304        // `offset_distance` the offset dimension gizmo drives — so it never gets a
305        // transform gizmo: the origin-sphere toggle stays in dimension mode. (Other
306        // dim features, e.g. a revolve, still toggle to their transform gizmo.)
307        if let Some(index) = self.history.index_of(&feature) {
308            if self.history.feature_type(index).as_deref() == Some("P") {
309                return;
310            }
311        }
312        self.arm_transform(&feature);
313    }
314
315    /// The linear dimension annotations for `feature_id` (resolving expression
316    /// params against the live history env first). `[]` for a feature type with
317    /// no FD-1 builder / a missing feature.
318    fn feature_dimension_annotations(
319        &self,
320        feature_id: &str,
321    ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
322        let Some(index) = self.history.index_of(feature_id) else {
323            return Vec::new();
324        };
325        let Some(feature_type) = self.history.feature_type(index) else {
326            return Vec::new();
327        };
328        let Some(params) = self.history.feature_params(index) else {
329            return Vec::new();
330        };
331        let resolved = self.resolve_param_expressions(&params);
332        // Resolve any scene references the builder needs (extrude profile plane,
333        // revolve axis line) from the run report's profiles/axes — keyed off the
334        // ORIGINAL params so reference-name strings are read verbatim.
335        let refs = self.feature_dimension_refs(&feature_type, &params);
336        crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
337    }
338
339    /// Resolve the scene references a feature-dimension builder needs beyond its
340    /// pure params: the extrude/revolve profile PLANE (center + normal) and the
341    /// revolve AXIS line. Sourced from the run report the engine already holds —
342    /// `sketch_profiles` (the sketch's world profile, which survives being
343    /// consumed by the extrude/revolve since only solids honor `removed`) and
344    /// `sketch_axes` (a sketch's published axis lines) — with resident-scene
345    /// fallbacks for a profile that is a solid FACE (its display-mesh plane, see
346    /// [`Self::lookup_profile_plane`]) and an axis that is a solid EDGE (its
347    /// polyline). Empty for any other feature type; missing pieces stay `None`
348    /// so the builder degrades to `[]` gracefully.
349    fn feature_dimension_refs(
350        &self,
351        feature_type: &str,
352        params: &serde_json::Value,
353    ) -> crate::feature_dimensions::ResolvedRefs {
354        let mut refs = crate::feature_dimensions::ResolvedRefs::default();
355        match feature_type {
356            "E" => {
357                if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
358                    refs.profile_center = Some(center);
359                    refs.profile_normal = Some(normal);
360                }
361                // Screen-constant handle stub for a leg at 0 (~48 px), as for "P".
362                refs.plane_dim_length = Some(self.camera.world_per_pixel() * 48.0);
363            }
364            "R" => {
365                if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
366                    refs.profile_center = Some(center);
367                    refs.profile_normal = Some(normal);
368                }
369                if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
370                    refs.axis_point = Some(point);
371                    refs.axis_dir = Some(dir);
372                }
373            }
374            "P" => {
375                // The plane feature registers ONE scene frame under its own id; the
376                // offset dim hangs off that resolved plane (origin + z-axis normal).
377                if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
378                    if let Some((_, frame)) =
379                        self.construction_frames.iter().find(|(name, _)| name == id)
380                    {
381                        refs.plane_origin = Some(vec3_to_arr(frame.origin));
382                        refs.plane_normal = Some(fd_normalize3(vec3_to_arr(frame.z_axis)));
383                        // Screen-constant handle stub for the offset ≈ 0 case (~48 px).
384                        refs.plane_dim_length = Some(self.camera.world_per_pixel() * 48.0);
385                    }
386                }
387            }
388            _ => {}
389        }
390        refs
391    }
392
393    /// Resolve a `profile` reference param to the world plane the extrude/revolve
394    /// gizmo hangs off, as `(center, unit normal)`. A SKETCH profile first (its
395    /// outer-loop centroid + `z_axis`), else a resident solid FACE named by the
396    /// reference (the area-weighted centroid + outward normal of its display
397    /// mesh) — the same two sources the kernel's extrude/revolve accept for
398    /// `profile` (`scene.resolve_profile`, then `resolve_face` →
399    /// `face_profile`, whose `z_axis` is likewise the OUTWARD face normal, so the
400    /// arc's `orient_revolve_axis` sign matches the kernel's sweep direction).
401    /// `None` when neither resolves — a face whose owning solid was later
402    /// consumed (a boolean target) is no longer in the scene, and the builder
403    /// then degrades to `[]` as before.
404    fn lookup_profile_plane(
405        &self,
406        profile_param: Option<&serde_json::Value>,
407    ) -> Option<([f64; 3], [f64; 3])> {
408        if let Some(profile) = self.lookup_sketch_profile(profile_param) {
409            return Some((sketch_profile_centroid(profile), vec3_to_arr(profile.z_axis)));
410        }
411        let name = first_reference_name(profile_param)?;
412        self.scene.face_plane_world(&name)
413    }
414
415    /// Resolve a `profile` reference param to the sketch profile the engine holds
416    /// (exact name, or the `:PROFILE`-suffixed form — mirrors `SceneMap::resolve_profile`).
417    fn lookup_sketch_profile(
418        &self,
419        profile_param: Option<&serde_json::Value>,
420    ) -> Option<&brep_kernel::SketchProfile> {
421        let name = first_reference_name(profile_param)?;
422        // `{sketch}:FACE` (the display-sheet pick) and `{sketch}:PROFILE` alias the
423        // base sketch id the run report keys `sketch_profiles` by: the kernel's ONE
424        // sketch-name rule, so the extrude/revolve gizmo resolves the profile the
425        // kernel does.
426        let base = brep_kernel::feature_pipeline::canonical_sketch_name(&name);
427        self.sketch_profiles
428            .iter()
429            .find(|(id, _)| id == &name || id == base)
430            .map(|(_, profile)| profile)
431    }
432
433    /// Resolve an `axis` reference param to a world line `(point, unit direction)`:
434    /// a published sketch axis first (`sketch_axes`), else a resident solid EDGE's
435    /// polyline endpoints (`scene.edge_polyline_world`). `None` if neither resolves.
436    fn lookup_axis_line(
437        &self,
438        axis_param: Option<&serde_json::Value>,
439    ) -> Option<([f64; 3], [f64; 3])> {
440        let name = first_reference_name(axis_param)?;
441        if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
442            let dir = fd_normalize3(vec3_to_arr(axis.direction));
443            return Some((vec3_to_arr(axis.point), dir));
444        }
445        // Fallback: a resident edge used as an axis — take its polyline endpoints.
446        let poly = self.scene.edge_polyline_world(&name)?;
447        let first = *poly.first()?;
448        let last = *poly.last()?;
449        let dir = fd_sub3(last, first);
450        if fd_norm3(dir) < 1e-9 {
451            return None;
452        }
453        Some((first, fd_normalize3(dir)))
454    }
455
456    /// A copy of `params` with each top-level STRING field evaluated against the
457    /// history's `expressions` + `configurator` (the kernel `eval_expression`) and
458    /// replaced by its finite numeric result — so an expression-valued param
459    /// (e.g. `sizeX: "a + b"`) places its dimension at the resolved length.
460    /// Non-numeric strings (ids, enum options) fail to eval and stay verbatim.
461    fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
462        let expressions = self.history.expressions();
463        let configurator = self.history.configurator();
464        let mut out = params.clone();
465        if let Some(object) = out.as_object_mut() {
466            for value in object.values_mut() {
467                if let Some(source) = value.as_str() {
468                    if let Ok(number) =
469                        brep_kernel::eval_expression(&expressions, &configurator, source)
470                    {
471                        if number.is_finite() {
472                            *value = serde_json::json!(number);
473                        }
474                    }
475                }
476            }
477        }
478        out
479    }
480
481    /// The dimension annotations for `feature_id` as JSON:
482    /// `[{ fieldKey, pointA, pointB, value, label, mid }]` (world-space points;
483    /// `mid` is the leader midpoint the app anchors the label at). `[]` when the
484    /// feature type has no FD-1 builder.
485    pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
486        use crate::feature_dimensions::FeatureDimKind;
487        let annotations = self.feature_dimension_annotations(feature_id);
488        let wpp = self.camera.world_per_pixel();
489        let out: Vec<serde_json::Value> = annotations
490            .iter()
491            .map(|a| {
492                // The chip anchor: a linear leader's midpoint, or an angular arc's
493                // mid-sweep point at the screen-constant radius (camera-dependent,
494                // so computed here with the live `world_per_pixel`). `kind` lets the
495                // app format the chip (`A 234°` for an angular value in DEGREES).
496                let (kind, mid) = match a.kind {
497                    FeatureDimKind::Linear => ("linear", a.midpoint()),
498                    FeatureDimKind::Angular => {
499                        ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
500                    }
501                };
502                serde_json::json!({
503                    "fieldKey": a.field_key,
504                    "pointA": a.point_a,
505                    "pointB": a.point_b,
506                    "value": a.value,
507                    "label": a.label,
508                    "mid": mid,
509                    "kind": kind,
510                })
511            })
512            .collect();
513        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
514    }
515
516    /// The `{ mode, feature, annotations }` snapshot the headless verifier reads
517    /// (published as `__brepFeatureDim`).
518    pub fn feature_dimension_state_json(&self) -> String {
519        let feature = self.dimension_armed_feature();
520        let annotations: serde_json::Value = if feature.is_empty() {
521            serde_json::json!([])
522        } else {
523            serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
524                .unwrap_or_else(|_| serde_json::json!([]))
525        };
526        serde_json::json!({
527            "mode": self.gizmo_mode(),
528            "feature": feature,
529            "annotations": annotations,
530        })
531        .to_string()
532    }
533
534    /// The `set_overlay` JSON for the `feature-dim-leaders` group — the annotation
535    /// leaders + arrowheads for the dimension-armed feature (empty when not in
536    /// dimension mode, so a stale group is cleared).
537    fn feature_dimension_overlay_json(&self) -> String {
538        let feature = self.dimension_armed_feature();
539        let annotations = if feature.is_empty() {
540            Vec::new()
541        } else {
542            self.feature_dimension_annotations(&feature)
543        };
544        let (positions, colors) = crate::feature_dimensions::leaders_buffers(
545            &annotations,
546            self.camera.world_per_pixel(),
547        );
548        serde_json::json!({
549            "groups": [
550                {
551                    "name": FEATURE_DIM_OVERLAY,
552                    "renderOrder": 10003,
553                    "tris": { "positions": positions, "colors": colors },
554                }
555            ]
556        })
557        .to_string()
558    }
559
560    /// (Re)project the dimension leaders onto the current geometry. Called on arm
561    /// + after every param change (drag / value edit / rerun in dimension mode)
562    /// + on a material ZOOM (`Self::ensure_feature_dimension_overlay_current`).
563    /// Remembers the `world_per_pixel` it baked at, which is what lets that
564    /// per-frame ensure fire on change ONLY.
565    pub fn refresh_feature_dimension_overlay(&mut self) {
566        let json = self.feature_dimension_overlay_json();
567        let _ = self.set_overlay_json(&json);
568        let wpp = self.camera.world_per_pixel();
569        self.feature_dim_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
570    }
571
572    /// Clear the dimension overlay (an empty group), e.g. when disarming or
573    /// switching to transform mode.
574    pub(super) fn clear_feature_dimension_overlay(&mut self) {
575        let _ = self.set_overlay_json(&serde_json::json!({
576            "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
577        }).to_string());
578        self.feature_dim_overlay_wpp = 0.0;
579    }
580
581    /// Per-frame upkeep for the DIMENSION gizmo (driven by
582    /// [`Self::ensure_overlays_current`]).
583    ///
584    /// The group is baked into pre-expanded vertices at feed time, and its
585    /// rod/cone/origin-sphere sizing — plus the angular arc's entire world
586    /// RADIUS (`ANGLE_ARC_RAD_PX × world_per_pixel`) — is screen-constant. So a
587    /// zoom that is not followed by a re-bake leaves the handles drawn at the old
588    /// pixel size, and the angular sweep handle drawn at the old world position
589    /// while [`Self::dimension_hit_regions`] (which projects against the LIVE
590    /// camera) grabs at the new one: the outline and the drawn handle drift apart.
591    ///
592    /// Re-bakes ONLY on a material `world_per_pixel` change
593    /// ([`overlay_wpp_stale`](super::overlay_wpp_stale)) — a quiet frame does not
594    /// touch the overlay, so there is no per-frame dirty loop. Nothing armed →
595    /// nothing baked, and the remembered zoom is dropped so re-arming re-bakes.
596    pub(super) fn ensure_feature_dimension_overlay_current(&mut self) {
597        if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
598            || self.dimension_armed_feature().is_empty()
599        {
600            self.feature_dim_overlay_wpp = 0.0;
601            return;
602        }
603        let wpp = self.camera.world_per_pixel();
604        if super::overlay_wpp_stale(self.feature_dim_overlay_wpp, wpp) {
605            self.refresh_feature_dimension_overlay();
606        }
607    }
608
609    /// Finalize a dimension drag with a fresh run of the committed parameters.
610    /// The final run forces this feature and its consumers to rebuild, rather
611    /// than replaying the cached handle.
612    ///
613    /// It does NOT cancel the preview still in flight. Both background runners
614    /// already keep one run in flight and coalesce the rest to the newest (the
615    /// browser worker parks it, latest wins; the native thread skips a Run
616    /// followed by another), so at most one intermediate of this same feature,
617    /// at a nearby value, finishes ahead of the final run, on a warm cache.
618    /// Cancelling instead terminated the browser worker on nearly every release
619    /// (a preview is almost always pending when the finger lifts): the final
620    /// value then waited for a new worker to instantiate the wasm module,
621    /// receive the parts library and rebuild the WHOLE history cold, which on a
622    /// phone took long enough that users re-opened the feature to see it.
623    /// User-invoked cancel (`cancel_run`) remains the way out of a slow run.
624    pub fn feature_dimension_release(&mut self, feature_id: &str) {
625        if self.history.index_of(feature_id).is_none() {
626            return;
627        }
628        self.rerun_history_forcing(Some(feature_id));
629    }
630
631    /// Drag a dimension handle: project the pointer pixel `(x, y)` onto the
632    /// annotation's world axis (`pointA → pointB`), take the distance along the
633    /// axis from `pointA` as the new value (correcting for any transform scale so
634    /// the PARAM — not the scaled world length — is what changes), set the param,
635    /// and re-run the history live. Degenerate projections (parallel ray / zero
636    /// axis) no-op.
637    pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
638        let annotations = self.feature_dimension_annotations(feature_id);
639        let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
640            return;
641        };
642        if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
643            if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
644                self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
645                // Live-follow: re-bake the world-space leaders onto the rebuilt
646                // geometry so the arc tracks the pointer this frame (Fix 3).
647                self.refresh_feature_dimension_overlay();
648            }
649            return;
650        }
651        let a = annotation.point_a;
652        let b = annotation.point_b;
653        let axis = fd_sub3(b, a);
654        let len = fd_norm3(axis);
655        if len < 1e-9 {
656            return;
657        }
658        let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
659        let ray = self.camera.pick_ray(x, y);
660        let ray_dir = fd_normalize3(ray.dir);
661        let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
662            return;
663        };
664        // World distance → param value: correct for the local axis scale via the
665        // CURRENT ratio (world length / current param). Under unit scale this is
666        // the identity; when the param is ~0 there is no ratio, so use the world
667        // distance directly (unit-scale assumption).
668        let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
669            annotation.value / len
670        } else {
671            1.0
672        };
673        // Preserve SIGN so a linear dim can be dragged through the origin to the
674        // negative side (a directional dim — cube size, height — then extends the
675        // other way; the kernel takes |value| for magnitude dims). A small dead-zone
676        // keeps it off an exact 0 (a degenerate extent the builders reject).
677        let raw = t_world * scale_recip;
678        let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
679        self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
680        // Live-follow: re-bake the world-space leaders onto the rebuilt geometry so
681        // the arrow tracks the pointer this frame (Fix 3).
682        self.refresh_feature_dimension_overlay();
683    }
684
685    /// Map a pointer pixel to a swept angle (DEGREES) for an ANGULAR annotation:
686    /// search the sweep for the degree whose arc-end projects nearest the pointer
687    /// (a coarse 2° pass, then a ±2° refine at 0.25°), snap to 1°, clamp to
688    /// `[-360, 360]`. Ported from the overlay `angle` drag. The magnitude is
689    /// floored off exactly 0 so a torus `arc` drag never lands on 0 — which the
690    /// kernel's `|| 360` falsy fallback would flip to a FULL torus mid-drag.
691    /// `None` if the arc never projects in front of the camera.
692    ///
693    /// `pub(super)`: the assembly-constraint angle-arc drag
694    /// (`assembly_overlay.rs`) maps its pointer through this SAME search so the
695    /// two angle gizmos share one drag feel.
696    pub(super) fn angular_drag_degrees(
697        &self,
698        ann: &crate::feature_dimensions::FeatureDimAnnotation,
699        x: f64,
700        y: f64,
701    ) -> Option<f64> {
702        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
703        if radius <= 1e-9 {
704            return None;
705        }
706        // A sweep of `deg` and `deg - 360` share the SAME arc-end world point, so
707        // the screen-nearest search alone can't tell them apart at the wrap. Break
708        // the tie toward the CURRENT value (angle-unwrap
709        // continuity) with a tiny bias `~1e-6·Δ°²` — decisive only when screen
710        // errors are essentially equal, negligible against any real pointer move.
711        let current = ann.value;
712        let combined = |screen_err: f64, deg: f64| -> f64 {
713            let d = deg - current;
714            screen_err + 1e-6 * d * d
715        };
716        let mut best_deg = current;
717        let mut best_err = f64::INFINITY;
718        // Coarse sweep over the full range.
719        let mut deg = -360.0;
720        while deg <= 360.0 {
721            if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
722                let err = combined(err, deg);
723                if err < best_err {
724                    best_err = err;
725                    best_deg = deg;
726                }
727            }
728            deg += 2.0;
729        }
730        if !best_err.is_finite() {
731            return None;
732        }
733        // Refine around the coarse best.
734        let center = best_deg;
735        let mut deg = center - 2.0;
736        while deg <= center + 2.0 {
737            if (-360.0..=360.0).contains(&deg) {
738                if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
739                    let err = combined(err, deg);
740                    if err < best_err {
741                        best_err = err;
742                        best_deg = deg;
743                    }
744                }
745            }
746            deg += 0.25;
747        }
748        let clamped = best_deg.round().clamp(-360.0, 360.0);
749        let floored = if clamped.abs() < 0.1 {
750            if clamped < 0.0 { -0.1 } else { 0.1 }
751        } else {
752            clamped
753        };
754        Some(floored)
755    }
756
757    /// Squared screen-pixel distance from `(x, y)` to the arc-end at `deg` for an
758    /// angular annotation (`center + rotate(ref_dir, axis, deg) * radius`), or
759    /// `None` when that point is behind the camera.
760    fn angle_arc_end_err(
761        &self,
762        ann: &crate::feature_dimensions::FeatureDimAnnotation,
763        radius: f64,
764        deg: f64,
765        x: f64,
766        y: f64,
767    ) -> Option<f64> {
768        let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
769            ann.ref_dir,
770            ann.axis,
771            deg.to_radians(),
772        ));
773        let p = [
774            ann.center[0] + dir[0] * radius,
775            ann.center[1] + dir[1] * radius,
776            ann.center[2] + dir[2] * radius,
777        ];
778        let (sx, sy, depth) = self.camera.project(p);
779        if depth <= 0.0 {
780            return None;
781        }
782        Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
783    }
784
785    /// Edit a dimension value from a label field: a plain numeric literal sets the
786    /// param to that number; otherwise the input is treated as an EXPRESSION —
787    /// evaluated LIVE against the history's `expressions` + `configurator` (the
788    /// kernel `eval_expression`) and, on success, STORED as the expression string
789    /// (the kernel re-evaluates it via `ctx.number`, so it stays live). A blank /
790    /// bad-expression input no-ops (never corrupts the feature). Re-runs live.
791    pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
792        let trimmed = input.trim();
793        if trimmed.is_empty() {
794            return;
795        }
796        if is_plain_number_literal(trimmed) {
797            let Ok(number) = trimmed.parse::<f64>() else {
798                return;
799            };
800            if !number.is_finite() {
801                return;
802            }
803            self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
804        } else {
805            // Validate the expression before storing it (a bad expression no-ops).
806            let expressions = self.history.expressions();
807            let configurator = self.history.configurator();
808            match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
809                Ok(number) if number.is_finite() => {
810                    self.write_feature_dimension_param(
811                        feature_id,
812                        field_key,
813                        serde_json::Value::String(trimmed.to_string()),
814                    );
815                }
816                _ => {}
817            }
818        }
819    }
820
821    /// Set one `inputParams` field of `feature_id` (a number or an expression
822    /// string) and re-run the history (which re-projects the leaders in dimension
823    /// mode). No-op when the feature is absent.
824    fn write_feature_dimension_param(
825        &mut self,
826        feature_id: &str,
827        field_key: &str,
828        value: serde_json::Value,
829    ) {
830        let Some(index) = self.history.index_of(feature_id) else {
831            return;
832        };
833        let mut params = self
834            .history
835            .feature_params(index)
836            .unwrap_or_else(|| serde_json::json!({}));
837        let Some(object) = params.as_object_mut() else {
838            return;
839        };
840        // A drag frame whose pointer has not moved (egui reports `dragged()`
841        // on every frame the button is held, and the frame loop keeps running
842        // while a run is in flight) resolves to the value already written. No
843        // run for it: each submit is a whole-history rebuild on the runner.
844        if object.get(field_key) == Some(&value) {
845            return;
846        }
847        object.insert(field_key.to_string(), value);
848        let _ = self.update_feature_params(feature_id, &params.to_string());
849    }
850}
851
852// --- FD-1 geometry helpers (self-contained, `fd_` prefixed to avoid clashes) ---
853
854fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
855    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
856}
857
858fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
859    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
860}
861
862fn fd_norm3(v: [f64; 3]) -> f64 {
863    fd_dot3(v, v).sqrt()
864}
865
866fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
867    let n = fd_norm3(v);
868    if n < 1e-12 {
869        [0.0, 0.0, 1.0]
870    } else {
871        [v[0] / n, v[1] / n, v[2] / n]
872    }
873}
874
875/// Push an origin-ball region at world `p` unless a ball already sits there (dedup
876/// by world position, matching `leaders_buffers`' single drawn sphere). The shared
877/// point-region builder projects it to a screen circle; a behind-eye (perspective)
878/// origin is omitted — invisible, so not grabbable — matching the outline.
879fn push_origin_region(
880    out: &mut Vec<(DimRole, HitShape)>,
881    seen: &mut Vec<[f64; 3]>,
882    cam: &crate::view::ViewCamera,
883    p: [f64; 3],
884    px: f32,
885    field_key: &str,
886) {
887    if seen.iter().any(|o| fd_norm3(fd_sub3(*o, p)) < 1e-6) {
888        return;
889    }
890    seen.push(p);
891    if let Some(shape) = point_region(cam, p, px) {
892        out.push((DimRole::Origin(field_key.to_string()), shape));
893    }
894}
895
896/// The parameter `t` of the point on the axis line `a + t*dir` (dir UNIT) closest
897/// to the ray `ray_o + s*ray_d` (ray_d UNIT). `None` when the two are parallel
898/// (no well-defined projection). `t` is the signed world distance along `dir`
899/// from `a`. (`pub(super)`: shared with the constraint distance-arrow drag.)
900pub(super) fn closest_t_on_axis(
901    a: [f64; 3],
902    dir: [f64; 3],
903    ray_o: [f64; 3],
904    ray_d: [f64; 3],
905) -> Option<f64> {
906    let w0 = fd_sub3(a, ray_o);
907    let b = fd_dot3(dir, ray_d);
908    let d = fd_dot3(dir, w0);
909    let e = fd_dot3(ray_d, w0);
910    let denom = 1.0 - b * b;
911    if denom.abs() < 1e-9 {
912        return None;
913    }
914    Some((b * e - d) / denom)
915}
916
917fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
918    [v.x, v.y, v.z]
919}
920
921/// The world CENTROID of a sketch profile — the average of its outer-loop curve
922/// start points (the profile-polygon vertices), approximating the previous
923/// face-average-center computation. Falls back to the sketch plane origin when
924/// no outer loop is available. Used to anchor the extrude/revolve gizmos on the
925/// geometry rather than at a possibly-far sketch-plane origin.
926fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
927    let mut sum = [0.0f64; 3];
928    let mut count = 0usize;
929    if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
930        for curve in &outer.curves {
931            if let Ok(domain) = curve.domain() {
932                if let Ok(point) = curve.evaluate(domain[0]) {
933                    sum[0] += point.x;
934                    sum[1] += point.y;
935                    sum[2] += point.z;
936                    count += 1;
937                }
938            }
939        }
940    }
941    if count > 0 {
942        [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
943    } else {
944        vec3_to_arr(profile.origin)
945    }
946}
947