Skip to main content

brep_render/engine_state/
assembly_overlay.rs

1//! Assembly-constraint leaders and draggable distance/angle annotations.
2//!
3//! Overlays refresh after history application and constraint mutations. Camera
4//! zoom changes rebake their screen-sized geometry; settings and sketch mode
5//! control visibility. Drags preview locally, then commit and solve on release.
6//! Plane-based distances use the signed offset along the base-face normal.
7//! Solved poses update both resident display solids and the history document.
8//!
9//! Live-session guards are required before fallible assembly ABI calls:
10//! constructing a `JsValue` error can abort on native targets.
11
12use super::feature_dims::closest_t_on_axis;
13use super::*;
14use crate::constraint_overlays::{
15    build_constraint_overlays, constraint_overlay_buffers, status_color, ConstraintOverlay,
16    ConstraintOverlayKind,
17};
18use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
19
20/// The world-space overlay group carrying the constraint leaders + handles.
21/// Sits just under the feature-dim gizmo group (10003) so an armed feature
22/// gizmo draws over constraint graphics.
23const CONSTRAINT_OVERLAY_GROUP: &str = "assembly-constraint-overlay";
24/// The free-motion glyph at the selected component (its own group, so the
25/// constraint overlay's bake never clears it).
26const FREEDOM_GLYPH_GROUP: &str = "assembly-freedom-glyph";
27
28impl EngineState {
29    // -----------------------------------------------------------------------
30    // Refresh / bake
31    // -----------------------------------------------------------------------
32
33    /// Rebuild the constraint-overlay cache from the LIVE kernel session and
34    /// re-bake the drawn group. Cleared (empty group) while the Show Constraint
35    /// Graphics setting is off or a sketch edit is active. A live handle drag's
36    /// preview survives the rebuild (re-applied onto the fresh cache).
37    pub fn refresh_constraint_overlay(&mut self) {
38        if !self.settings.show_constraint_graphics || self.any_sketch_mode() {
39            self.clear_constraint_overlay();
40            return;
41        }
42        let rows = self.assembly_overlay_value();
43        let constraints = self
44            .assembly_state_value()
45            .get("constraints")
46            .cloned()
47            .unwrap_or(serde_json::Value::Null);
48        self.refresh_constraint_overlay_values(&rows, &constraints);
49    }
50
51    /// [`Self::refresh_constraint_overlay`] with explicit payloads (the parsed
52    /// `assembly_overlay_json` array + `assembly_state_json` object) — the
53    /// testable seam: canned payloads exercise the whole cache/pick/drag path
54    /// without a kernel session.
55    pub fn refresh_constraint_overlay_from(&mut self, overlay_json: &str, state_json: &str) {
56        let rows: serde_json::Value =
57            serde_json::from_str(overlay_json).unwrap_or(serde_json::Value::Null);
58        let state: serde_json::Value =
59            serde_json::from_str(state_json).unwrap_or(serde_json::Value::Null);
60        let constraints = state
61            .get("constraints")
62            .cloned()
63            .unwrap_or(serde_json::Value::Null);
64        self.refresh_constraint_overlay_values(&rows, &constraints);
65    }
66
67    /// [`Self::refresh_constraint_overlay_from`] on already-parsed values — what
68    /// the run's [`crate::pipeline::AssemblySync`] carries.
69    fn refresh_constraint_overlay_values(
70        &mut self,
71        rows: &serde_json::Value,
72        constraints: &serde_json::Value,
73    ) {
74        self.constraint_overlays = build_constraint_overlays(rows, constraints);
75        self.apply_constraint_drag_preview();
76        self.bake_constraint_overlay();
77    }
78
79    /// Clear the cache + the drawn group (setting off / sketch mode / no session).
80    fn clear_constraint_overlay(&mut self) {
81        let had = !self.constraint_overlays.is_empty() || self.constraint_overlay_wpp != 0.0;
82        self.constraint_overlays.clear();
83        self.constraint_overlay_wpp = 0.0;
84        if had {
85            let _ = self.set_overlay_json(
86                &serde_json::json!({ "groups": [ { "name": CONSTRAINT_OVERLAY_GROUP } ] })
87                    .to_string(),
88            );
89        }
90    }
91
92    /// Bake the cached overlays into the drawn triangle group at the CURRENT
93    /// camera zoom (screen-constant sizing) and remember the baked
94    /// `world_per_pixel` for [`Self::ensure_constraint_overlay_current`].
95    fn bake_constraint_overlay(&mut self) {
96        let wpp = self.camera.world_per_pixel();
97        let (positions, colors) = constraint_overlay_buffers(&self.constraint_overlays, wpp);
98        let _ = self.set_overlay_json(
99            &serde_json::json!({
100                "groups": [
101                    {
102                        "name": CONSTRAINT_OVERLAY_GROUP,
103                        "renderOrder": 10002,
104                        "tris": { "positions": positions, "colors": colors },
105                    }
106                ]
107            })
108            .to_string(),
109        );
110        self.constraint_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
111    }
112
113    /// Per-frame upkeep (the app viewport calls this once per frame): hide the
114    /// group while the setting is off / sketch mode is active, restore it when
115    /// they flip back, and re-bake when the camera zoom moved the
116    /// `world_per_pixel` materially (>0.5%) so the screen-constant arc/rod
117    /// sizing stays pixel-true. Re-bakes only on actual change, so a quiet
118    /// frame stays quiet (no dirty loop).
119    pub fn ensure_constraint_overlay_current(&mut self) {
120        let want = self.settings.show_constraint_graphics && !self.any_sketch_mode();
121        self.ensure_freedom_glyph(want);
122        if !want {
123            self.clear_constraint_overlay();
124            return;
125        }
126        if self.constraint_overlay_wpp == 0.0 {
127            // Hidden → shown transition (toggle flipped back on / sketch exited):
128            // pull fresh session state.
129            self.refresh_constraint_overlay();
130            return;
131        }
132        if self.constraint_overlays.is_empty() {
133            return;
134        }
135        let wpp = self.camera.world_per_pixel();
136        if super::overlay_wpp_stale(self.constraint_overlay_wpp, wpp) {
137            self.bake_constraint_overlay();
138        }
139    }
140
141    /// The component whose free motion the glyph shows: the selection is
142    /// exactly one top-level component's solids (a component pick, from the
143    /// viewport or the tree), and nothing else.
144    pub fn freedom_glyph_component(&self) -> Option<String> {
145        let emphasis = &self.emphasis;
146        if emphasis.selected_solids.is_empty()
147            || !emphasis.selected_faces.is_empty()
148            || !emphasis.selected_edges.is_empty()
149            || !emphasis.selected_vertices.is_empty()
150        {
151            return None;
152        }
153        let record = self.assembly_components.iter().find(|record| {
154            record.solids.len() == emphasis.selected_solids.len()
155                && record.solids.iter().all(|solid| emphasis.selected_solids.contains(solid))
156        })?;
157        Some(record.id.clone())
158    }
159
160    /// What the free-motion glyph draws now: `{component, center, slides,
161    /// axes: [{point, direction}]}` from the last solve's `bodyFreedom`, or
162    /// `null` (no single component selected, a fixed component, or one the
163    /// solve did not analyse).
164    pub fn freedom_glyph_value(&self) -> serde_json::Value {
165        let Some(id) = self.freedom_glyph_component() else { return serde_json::Value::Null };
166        // The one row, never a clone of the whole report: this runs per frame.
167        let freedom = match &self.assembly_sync {
168            Some(sync) => sync.dof["bodyFreedom"][id.as_str()].clone(),
169            None => return serde_json::Value::Null,
170        };
171        let Some(center) = self.component_bbox_center(&id) else { return serde_json::Value::Null };
172        if !freedom.is_object() {
173            return serde_json::Value::Null;
174        }
175        serde_json::json!({
176            "component": id,
177            "center": center,
178            "slides": freedom["slides"],
179            "axes": freedom["axes"],
180        })
181    }
182
183    /// Keep the glyph group in step with the selection, the solve and the
184    /// zoom; re-baked only when one of them actually changed.
185    fn ensure_freedom_glyph(&mut self, want: bool) {
186        let glyph = if want { self.freedom_glyph_value() } else { serde_json::Value::Null };
187        if glyph.is_null() {
188            if self.freedom_glyph_key.take().is_some() {
189                let _ = self.set_overlay_json(
190                    &serde_json::json!({ "groups": [ { "name": FREEDOM_GLYPH_GROUP } ] }).to_string(),
191                );
192            }
193            return;
194        }
195        let key = glyph.to_string();
196        let wpp = self.camera.world_per_pixel();
197        if self.freedom_glyph_key.as_deref() == Some(key.as_str())
198            && !super::overlay_wpp_stale(self.freedom_glyph_wpp, wpp)
199        {
200            return;
201        }
202        let point = |value: &serde_json::Value| -> Option<[f64; 3]> {
203            Some([value[0].as_f64()?, value[1].as_f64()?, value[2].as_f64()?])
204        };
205        let Some(center) = point(&glyph["center"]) else { return };
206        let slides: Vec<[f64; 3]> = glyph["slides"].as_array().into_iter().flatten().filter_map(point).collect();
207        let axes: Vec<([f64; 3], [f64; 3])> = glyph["axes"]
208            .as_array()
209            .into_iter()
210            .flatten()
211            .filter_map(|axis| Some((point(&axis["point"])?, point(&axis["direction"])?)))
212            .collect();
213        let (positions, colors) = crate::feature_dimensions::freedom_glyph_buffers(center, &slides, &axes, wpp);
214        let _ = self.set_overlay_json(
215            &serde_json::json!({
216                "groups": [
217                    {
218                        "name": FREEDOM_GLYPH_GROUP,
219                        "renderOrder": 10003,
220                        "tris": { "positions": positions, "colors": colors },
221                    }
222                ]
223            })
224            .to_string(),
225        );
226        self.freedom_glyph_key = Some(key);
227        self.freedom_glyph_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
228    }
229
230    /// The cached overlay records (read-only view for the app's label pass +
231    /// tests).
232    pub fn constraint_overlays(&self) -> &[ConstraintOverlay] {
233        &self.constraint_overlays
234    }
235
236    /// The label feed for the app's chip pass:
237    /// `[{id, type, icon, text, status, message, color:[r,g,b], world:[x,y,z],
238    /// draggable, selected}]`. `text` leads with `icon` (the type's glyph), so
239    /// the chip is a picture plus the measure; `id` is for the hover tooltip. One row per cached overlay with a resolvable label anchor
240    /// (the leader midpoint / arc mid-sweep / anchor midpoint). `selected`
241    /// marks the label-click-selected constraint (thicker chip border). Empty
242    /// while hidden.
243    pub fn constraint_labels_json(&self) -> String {
244        let wpp = self.camera.world_per_pixel();
245        let rows: Vec<serde_json::Value> = self
246            .constraint_overlays
247            .iter()
248            .filter_map(|overlay| {
249                let world = overlay.label_anchor(wpp)?;
250                let color = status_color(&overlay.status);
251                let mut row = serde_json::json!({
252                    "id": overlay.id,
253                    "type": overlay.constraint_type,
254                    "icon": overlay.icon,
255                    "text": overlay.label_text(),
256                    "status": overlay.status,
257                    "message": overlay.message,
258                    "color": [color[0], color[1], color[2]],
259                    "world": world,
260                    "draggable": overlay.draggable,
261                    "selected": self.selected_constraint.as_deref() == Some(overlay.id.as_str()),
262                });
263                // The angle gizmo's own geometry — the arc VERTEX (which must
264                // sit on the hinge of the two measured elements) and the
265                // rotation axis — so an automation client can measure the
266                // gizmo's placement, not just where its chip landed.
267                if let (ConstraintOverlayKind::Angle, Some(annotation)) =
268                    (overlay.kind, overlay.annotation.as_ref())
269                {
270                    row["center"] = serde_json::json!(annotation.center);
271                    row["axis"] = serde_json::json!(annotation.axis);
272                }
273                Some(row)
274            })
275            .collect();
276        serde_json::Value::Array(rows).to_string()
277    }
278
279    // -----------------------------------------------------------------------
280    // Label hover (highlight referenced geometry) + click (expand in panel)
281    // -----------------------------------------------------------------------
282
283    /// Hover a constraint LABEL: highlight the referenced elements
284    /// (`inputParams.elements`) through the existing emphasis machinery —
285    /// faces/edges by kernel name, a `{solid}@x,y,z` vertex ref or a bare
286    /// component id by its owning solid(s). Deduped by constraint id so a held
287    /// hover never re-bumps the emphasis generation. Sets the one-frame
288    /// [`Self::take_constraint_label_hover`] flag either way so the viewport's
289    /// scene-hover pass yields.
290    pub fn constraint_hover(&mut self, id: &str) {
291        self.constraint_label_hover_active = true;
292        if self.constraint_hovered.as_deref() == Some(id) {
293            return;
294        }
295        let elements = match self.constraint_overlays.iter().find(|o| o.id == id) {
296            Some(overlay) => overlay.elements.clone(),
297            None => Vec::new(),
298        };
299        // Classify each element against the display scene FIRST (immutable
300        // reads), then write the emphasis in one go.
301        let mut solids: Vec<String> = Vec::new();
302        let mut faces: Vec<String> = Vec::new();
303        let mut edges: Vec<String> = Vec::new();
304        for element in &elements {
305            if let Some(at) = element.find('@') {
306                // Vertex ref "{solid}@x,y,z" → highlight the owning solid.
307                solids.push(element[..at].to_string());
308                continue;
309            }
310            if self.scene_has_face(element) {
311                faces.push(element.clone());
312            } else if self.scene_has_edge(element) {
313                edges.push(element.clone());
314            } else if self.scene.solids().iter().any(|s| s.name == *element) {
315                solids.push(element.clone());
316            } else {
317                // A bare component ref (`ACOMP2`) → its member solids
318                // (`ACOMP2:…`, the namespaced-prefix convention).
319                let prefix = format!("{element}:");
320                solids.extend(
321                    self.scene
322                        .solids()
323                        .iter()
324                        .filter(|s| s.name.starts_with(&prefix))
325                        .map(|s| s.name.clone()),
326                );
327            }
328        }
329        self.clear_hover();
330        self.emphasis.hovered_solids.extend(solids);
331        self.emphasis.hovered_faces.extend(faces);
332        self.emphasis.hovered_edges.extend(edges);
333        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
334        self.constraint_hovered = Some(id.to_string());
335        self.dirty = true;
336    }
337
338    /// The pointer left the constraint labels: drop the element highlight (only
339    /// if a label hover set one — never clobbers an unrelated scene hover).
340    pub fn constraint_hover_end(&mut self) {
341        if self.constraint_hovered.take().is_some() {
342            self.clear_hover();
343        }
344    }
345
346    /// Consume the one-frame "a constraint label is hovering elements" flag —
347    /// the viewport's modeling hover branch skips its scene re-hover while set
348    /// (mirrors [`Self::take_sketch_list_hover`]) so the label-driven highlight
349    /// survives the frame without a per-frame clobber/re-apply dirty loop.
350    pub fn take_constraint_label_hover(&mut self) -> bool {
351        std::mem::take(&mut self.constraint_label_hover_active)
352    }
353
354    /// A constraint LABEL was clicked: SELECT that constraint (the chip gains
355    /// its selected accent, the context bar offers Delete constraint) and OPEN
356    /// it through the ACCORDION open ([`Self::assembly_set_constraint_open`] —
357    /// every other constraint closes, so the clicked one is the ONE open
358    /// constraint, which is what puts the panel into its dialog). Guarded on the
359    /// id existing in the live session (the fallible export's error path would
360    /// abort off-wasm).
361    pub fn constraint_label_clicked(&mut self, id: &str) {
362        if !self.constraint_select(id) {
363            return;
364        }
365        let _ = self.assembly_set_constraint_open(id, true);
366    }
367
368    /// SELECT a constraint without opening it — the Constraints tree's single
369    /// click. The same selection a label click makes (the chip's selected
370    /// accent, the context bar's Delete constraint), minus the dialog. Returns
371    /// false, changing nothing, for an id the live session does not hold.
372    pub fn constraint_select(&mut self, id: &str) -> bool {
373        if !live_constraint_exists(id) {
374            return false;
375        }
376        self.selected_constraint = Some(id.to_string());
377        self.dirty = true;
378        true
379    }
380
381    /// The constraint SELECTED via its viewport label (or `None`), pruned
382    /// against the live session — a deleted / undone constraint never lingers
383    /// as selected.
384    pub fn selected_constraint(&self) -> Option<String> {
385        let id = self.selected_constraint.as_deref()?;
386        live_constraint_exists(id).then(|| id.to_string())
387    }
388
389    /// Drop the constraint selection (the Clear action, Esc, and the context
390    /// bar's delete all route here — directly or via `clear_selection`).
391    pub fn constraint_deselect(&mut self) {
392        if self.selected_constraint.take().is_some() {
393            self.dirty = true;
394        }
395    }
396
397    pub(crate) fn scene_has_face(&self, name: &str) -> bool {
398        self.scene
399            .solids()
400            .iter()
401            .any(|solid| solid.faces.iter().any(|face| face.name == name))
402    }
403
404    pub(crate) fn scene_has_edge(&self, name: &str) -> bool {
405        self.scene
406            .solids()
407            .iter()
408            .any(|solid| solid.edges.iter().any(|edge| edge.name == name))
409    }
410
411    // -----------------------------------------------------------------------
412    // Grabbable handles: pick + drag preview + commit
413    // -----------------------------------------------------------------------
414
415    /// The SCREEN-space pickable regions of the DRAGGABLE constraint handles,
416    /// each paired with its constraint id: a distance leader's whole CAPSULE
417    /// (`point_a → point_b`), an angle arc's sweep-end handle CIRCLE — the same
418    /// region family (and the same generous `ARROW_HANDLE_HIT_RAD_PX`) as the
419    /// feature-dimension arrows, built by the shared `brep_gizmos::hit_region`
420    /// projectors. Non-draggable overlays (expression-valued params, leader-only
421    /// types) contribute nothing.
422    fn constraint_hit_regions(&self) -> Vec<(String, HitShape)> {
423        if !self.settings.show_constraint_graphics || self.any_sketch_mode() {
424            return Vec::new();
425        }
426        let wpp = self.camera.world_per_pixel();
427        let radius = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
428        let cam = &self.camera;
429        let mut out = Vec::new();
430        for overlay in &self.constraint_overlays {
431            if !overlay.draggable {
432                continue;
433            }
434            let Some(annotation) = &overlay.annotation else {
435                continue;
436            };
437            let shape = match overlay.kind {
438                ConstraintOverlayKind::Distance => {
439                    segment_region(cam, annotation.point_a, annotation.point_b, radius)
440                }
441                ConstraintOverlayKind::Angle => point_region(
442                    cam,
443                    crate::feature_dimensions::arrow_handle_point(annotation, wpp),
444                    radius,
445                ),
446                ConstraintOverlayKind::Leader => None,
447            };
448            if let Some(shape) = shape {
449                out.push((overlay.id.clone(), shape));
450            }
451        }
452        out
453    }
454
455    /// Whether a screen-px pick lands on a draggable constraint handle; returns
456    /// the grabbed constraint's id (the NEAREST containing region wins). The
457    /// viewport routes a drag that starts here into the constraint drag, and
458    /// swallows a bare click so the solid behind the arrow isn't selected.
459    pub fn constraint_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
460        let p = [x as f32, y as f32];
461        let mut best: Option<(f32, String)> = None;
462        for (id, shape) in self.constraint_hit_regions() {
463            let d = shape.spine_distance(p);
464            if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
465                best = Some((d, id));
466            }
467        }
468        best.map(|(_, id)| id)
469    }
470
471    /// The GRAB POINT of every draggable constraint handle, as
472    /// `{constraint id: [x, y]}` in VIEWPORT-LOCAL px — the point a host has to
473    /// press to start the drag [`Self::constraint_drag_begin`] claims, published
474    /// so the arrow is addressable BY NAME instead of by projection arithmetic.
475    ///
476    /// The point is `crate::feature_dimensions::arrow_handle_point` — a
477    /// distance arrow's TIP, an angle arc's sweep-end handle — projected through
478    /// the same [`point_region`] builder the hit regions come from, so the
479    /// published point and the pickable region can never drift: a press here is
480    /// at distance ZERO from the handle's own region.
481    ///
482    /// Empty while the constraint graphics are hidden (the setting is off, or a
483    /// sketch edit is active) and for every overlay that has no draggable
484    /// handle — a leader-only type, or a dimensional one whose value is an
485    /// expression. The app publishes it as the `constraint` hit panel, in SCREEN
486    /// points.
487    pub fn constraint_handle_points_json(&self) -> String {
488        if !self.settings.show_constraint_graphics || self.any_sketch_mode() {
489            return "{}".to_string();
490        }
491        let wpp = self.camera.world_per_pixel();
492        let radius = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
493        let cam = &self.camera;
494        let mut out = serde_json::Map::new();
495        for overlay in &self.constraint_overlays {
496            if !overlay.draggable || matches!(overlay.kind, ConstraintOverlayKind::Leader) {
497                continue;
498            }
499            let Some(annotation) = &overlay.annotation else {
500                continue;
501            };
502            let point = crate::feature_dimensions::arrow_handle_point(annotation, wpp);
503            if let Some(HitShape::Circle { c, .. }) = point_region(cam, point, radius) {
504                out.insert(
505                    overlay.id.clone(),
506                    serde_json::json!([c[0] as f64, c[1] as f64]),
507                );
508            }
509        }
510        serde_json::Value::Object(out).to_string()
511    }
512
513    /// Begin a constraint-handle drag at screen `(x, y)`. Returns whether a
514    /// draggable handle was grabbed (the viewport then routes the drag here
515    /// instead of orbiting the camera).
516    pub fn constraint_drag_begin(&mut self, x: f64, y: f64) -> bool {
517        let Some(id) = self.constraint_arrow_pick(x, y) else {
518            return false;
519        };
520        // The release commits straight into the live kernel session (no rerun —
521        // the movedSolids seam refreshes the scene), so the session has to exist
522        // on THIS thread before the drag starts.
523        self.ensure_assembly_session();
524        let Some(overlay) = self.constraint_overlays.iter().find(|o| o.id == id) else {
525            return false;
526        };
527        let Some(field) = overlay.field_key() else {
528            return false;
529        };
530        let preview = overlay
531            .annotation
532            .as_ref()
533            .map(|a| a.value)
534            .or(overlay.value)
535            .unwrap_or(0.0);
536        self.constraint_drag = Some(ConstraintDrag {
537            id,
538            field,
539            params: overlay.input_params.clone(),
540            preview,
541        });
542        true
543    }
544
545    /// Drag a grabbed constraint handle to screen `(x, y)`: map the pointer to a
546    /// new value (distance: signed offset along the base-face normal for
547    /// plane-based rows, magnitude along the leader otherwise; angle: the
548    /// shared arc nearest-projection search, preserving signed sweeps through 360°),
549    /// update the PREVIEW (annotation + label track live), and re-bake. No
550    /// kernel call — the commit happens on [`Self::constraint_drag_release`].
551    pub fn constraint_drag_to(&mut self, x: f64, y: f64) {
552        let Some(id) = self.constraint_drag.as_ref().map(|d| d.id.clone()) else {
553            return;
554        };
555        let Some(overlay) = self.constraint_overlays.iter().find(|o| o.id == id) else {
556            return;
557        };
558        let Some(annotation) = overlay.annotation.clone() else {
559            return;
560        };
561        let kind = overlay.kind;
562        let new_value = match kind {
563            ConstraintOverlayKind::Distance => {
564                let a = annotation.point_a;
565                let ray = self.camera.pick_ray(x, y);
566                let rd = ray.dir;
567                let rn = (rd[0] * rd[0] + rd[1] * rd[1] + rd[2] * rd[2]).sqrt();
568                if rn < 1e-12 {
569                    return;
570                }
571                let ray_dir = [rd[0] / rn, rd[1] / rn, rd[2] / rn];
572                let n = annotation.axis;
573                let raw = if n[0] * n[0] + n[1] * n[1] + n[2] * n[2] > 0.5 {
574                    // PLANE-BASED row (the builder stashed the base-face
575                    // outward unit normal in `axis`, and drew the arrow from
576                    // the perpendicular foot in TRUE world scale): the new
577                    // value is simply the pointer's SIGNED offset along that
578                    // fixed normal from the foot — dragging through the base
579                    // face crosses zero into negatives, the same
580                    // `d = (P − base)·n̂` definition the kernel solves.
581                    // Deliberately no length guard: a zero-distance arrow
582                    // (foot == tip, just the origin sphere) still drags.
583                    let Some(t) = closest_t_on_axis(a, n, ray.origin, ray_dir) else {
584                        return;
585                    };
586                    t
587                } else {
588                    // Plane-less row (line/point pairing — no side to be on):
589                    // project onto the anchor-to-anchor leader, map world
590                    // distance → param via the measured-value / world-length
591                    // ratio, and clamp to the MAGNITUDE domain. `value ≈ 0`
592                    // (touching pair) has no ratio: use the world distance.
593                    let b = annotation.point_b;
594                    let axis = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
595                    let len =
596                        (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
597                    if len < 1e-9 {
598                        return;
599                    }
600                    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
601                    let Some(t) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
602                        return;
603                    };
604                    let ratio = if annotation.value.abs() > 1e-9 {
605                        annotation.value / len
606                    } else {
607                        1.0
608                    };
609                    (t * ratio).max(0.0)
610                };
611                crate::formatting::snap_drag_value(raw)
612            }
613            ConstraintOverlayKind::Angle => {
614                let Some(degrees) = self.angular_drag_degrees(&annotation, x, y) else {
615                    return;
616                };
617                constraint_degrees(degrees)
618            }
619            ConstraintOverlayKind::Leader => return,
620        };
621        if let Some(drag) = self.constraint_drag.as_mut() {
622            drag.preview = new_value;
623        }
624        self.apply_constraint_drag_preview();
625        self.bake_constraint_overlay();
626    }
627
628    /// Patch the cached overlay with the live drag's preview value so the drawn
629    /// annotation + label track the pointer: a plane-based distance arrow
630    /// re-plants its tip at `foot + n̂·preview` (flipping through the base face
631    /// for a negative preview), a plane-less leader stretches `point_b` along
632    /// its fixed direction, and an angle arc re-sweeps.
633    fn apply_constraint_drag_preview(&mut self) {
634        let Some(drag) = self.constraint_drag.as_ref() else {
635            return;
636        };
637        let preview = drag.preview;
638        let id = drag.id.clone();
639        let Some(overlay) = self.constraint_overlays.iter_mut().find(|o| o.id == id) else {
640            return;
641        };
642        let kind = overlay.kind;
643        overlay.value = Some(preview);
644        if let Some(annotation) = overlay.annotation.as_mut() {
645            match kind {
646                ConstraintOverlayKind::Distance => {
647                    let a = annotation.point_a;
648                    let n = annotation.axis;
649                    if n[0] * n[0] + n[1] * n[1] + n[2] * n[2] > 0.5 {
650                        // Plane-based (base normal in `axis`): the arrow is
651                        // drawn in TRUE world scale, so the tip is EXACTLY
652                        // `foot + n̂·value` — a negative preview lands it on
653                        // the far side of the base face, exactly where the
654                        // solve will put the constrained element.
655                        annotation.point_b = [
656                            a[0] + n[0] * preview,
657                            a[1] + n[1] * preview,
658                            a[2] + n[2] * preview,
659                        ];
660                    } else {
661                        let b = annotation.point_b;
662                        let axis = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
663                        let len = (axis[0] * axis[0]
664                            + axis[1] * axis[1]
665                            + axis[2] * axis[2])
666                            .sqrt();
667                        if len > 1e-9 && annotation.value.abs() > 1e-9 {
668                            // Keep the world-per-value scale the leader had, so
669                            // the arrow tip lands where the face will end up.
670                            let world_len = preview * (len / annotation.value);
671                            let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
672                            annotation.point_b = [
673                                a[0] + dir[0] * world_len,
674                                a[1] + dir[1] * world_len,
675                                a[2] + dir[2] * world_len,
676                            ];
677                        }
678                    }
679                    annotation.value = preview;
680                }
681                _ => {
682                    annotation.value = preview;
683                }
684            }
685        }
686    }
687
688    /// The pending commit `(constraint id, inputParams JSON)` a release would
689    /// send — the drag's params snapshot with the dragged field set to the
690    /// preview value. `None` when no drag is live. (The pure half of
691    /// [`Self::constraint_drag_release`], separated for tests.)
692    ///
693    /// Angle display convention: the drag preview is the INTERIOR arc sweep
694    /// (what the arc draws and the kernel's overlay `value` measures), but the
695    /// stored `inputParams.angle` is the DISPLAY angle — exterior-remapped when
696    /// the constraint's `exteriorAngle` toggle is on (`map_angle`'s contract) —
697    /// so the commit remaps `interior → 180 − interior` for those.
698    pub fn constraint_drag_commit_payload(&self) -> Option<(String, String)> {
699        let drag = self.constraint_drag.as_ref()?;
700        let mut params = drag.params.clone();
701        if !params.is_object() {
702            params = serde_json::json!({});
703        }
704        let exterior = drag.field == "angle"
705            && params
706                .get("exteriorAngle")
707                .and_then(|v| v.as_bool())
708                .unwrap_or(false);
709        let committed = if exterior {
710            180.0 - drag.preview
711        } else {
712            drag.preview
713        };
714        let object = params.as_object_mut().expect("object ensured above");
715        object.insert(drag.field.to_string(), serde_json::json!(committed));
716        object.insert("id".to_string(), serde_json::json!(drag.id));
717        Some((drag.id.clone(), params.to_string()))
718    }
719
720    /// Release the constraint-handle drag: COMMIT the previewed value via
721    /// `assembly_update_constraint_json` (auto-solves), consume the reply's
722    /// `movedSolids` display seam (re-posed resident solids re-tessellate in
723    /// place so the release shows this frame), and then run the SAME tail as
724    /// every panel-side constraint edit (`Self::after_constraint_mutation`):
725    /// a checkpointed fold — the drag is one undo entry — and the rerun, which
726    /// re-keys the moved instance's cache entry to the pose it now has. A
727    /// commit against a session that no longer has the constraint (or no
728    /// session at all — canned-payload tests, the native thread-runner seam)
729    /// drops the preview with a notice instead.
730    pub fn constraint_drag_release(&mut self) {
731        let Some((id, params_json)) = self.constraint_drag_commit_payload() else {
732            self.constraint_drag = None;
733            return;
734        };
735        self.constraint_drag = None;
736        if !live_constraint_exists(&id) {
737            self.notices
738                .push(format!("Constraint {id}: no live assembly session to update"));
739            self.refresh_constraint_overlay();
740            return;
741        }
742        match brep_kernel::assembly_update_constraint_impl(&id, &params_json) {
743            Ok(report) => {
744                // The display seam first, so the release lands on screen in THIS
745                // frame rather than when the rerun's reply does…
746                self.consume_assembly_moved_solids(&report);
747                // …and then the same tail every other constraint mutation runs:
748                // a CHECKPOINTED fold (the drag is one undo entry, like a panel
749                // edit) plus the rerun. Both are load-bearing. Without the
750                // checkpoint, undo jumps past the drag to whatever edit came
751                // before it — deleting the constraint the user had only resized.
752                // Without the rerun, the solve's IN-PLACE re-pose leaves the
753                // moved instance's geometry under its OLD fingerprint in the
754                // feature cache, so a later replay of that pose (an undo, most
755                // obviously) hands back the geometry at the dragged pose and the
756                // document and the viewport disagree.
757                self.after_constraint_mutation();
758            }
759            // The native door returns its error as text, so a refused update is
760            // a notice on every target, not an abort off wasm.
761            Err(message) => self.notices.push(format!("Constraint {id}: {message}")),
762        }
763        self.refresh_constraint_overlay();
764        self.dirty = true;
765    }
766
767    // -----------------------------------------------------------------------
768    // Solve write-backs: movedSolids display seam + the document fold
769    // -----------------------------------------------------------------------
770
771    /// Consume a solve report's `movedSolids: [{name, handle}]` display seam:
772    /// those resident solids were RE-POSED IN PLACE by the solver (their
773    /// producing feature replayed `reused`, so the run delta kept the stale
774    /// mesh) — re-tessellate each from its live resident handle and replace its
775    /// display, preserving per-solid view state (visibility, color override).
776    /// The key is absent on zero-mate reports; absence is tolerated.
777    pub fn consume_assembly_moved_solids(&mut self, report_json: &str) {
778        let Ok(report) = serde_json::from_str::<serde_json::Value>(report_json) else {
779            return;
780        };
781        let Some(moved) = report.get("movedSolids").and_then(|v| v.as_array()) else {
782            return;
783        };
784        let lod = if self.settings.lod_factor.is_finite() && self.settings.lod_factor > 0.0 {
785            self.settings.lod_factor
786        } else {
787            1.0
788        };
789        let mut changed = false;
790        for entry in moved {
791            let Some(name) = entry.get("name").and_then(|v| v.as_str()) else {
792                continue;
793            };
794            let Some(handle) = entry
795                .get("handle")
796                .and_then(|v| v.as_u64())
797                .map(|h| h as u32)
798            else {
799                continue;
800            };
801            match brep_kernel::display_payload_handle_native(handle, lod) {
802                Ok(payload) => {
803                    let mut display = crate::scene::solid_display_from_payload(name, payload);
804                    display.source_handle = handle;
805                    if let Some(old) = self.scene.solids().iter().find(|s| s.name == name) {
806                        display.visible = old.visible;
807                    }
808                    self.scene.insert_solid(display);
809                    // Re-posed under the SAME handle: a drawing sheet holding
810                    // this solid's topology holds the pre-solve one.
811                    self.scene.drop_exact_solid(name);
812                    self.topology_asked.remove(&handle);
813                    changed = true;
814                }
815                Err(error) => self
816                    .notices
817                    .push(format!("re-tessellate moved solid {name}: {error}")),
818            }
819        }
820        if changed {
821            // Colours are NOT hand-copied off the replaced display: this path
822            // builds a fresh one per moved solid, whose per-face colours a copy
823            // could not carry. Re-derive the whole scene from the metadata store
824            // instead — the same seam every other path colours through.
825            self.sync_colors_from_metadata();
826            self.dirty = true;
827        }
828    }
829}
830
831/// Whether the LIVE kernel session currently holds a constraint with `id` —
832/// checked through the infallible `assembly_state_json` export so the guard
833/// itself can never hit a fallible export's off-wasm-aborting error path.
834fn live_constraint_exists(id: &str) -> bool {
835    serde_json::from_str::<serde_json::Value>(&brep_kernel::assembly_state_json())
836        .ok()
837        .and_then(|state| {
838            state.get("constraints").and_then(|list| {
839                list.as_array().map(|entries| {
840                    entries.iter().any(|entry| {
841                        entry
842                            .get("inputParams")
843                            .and_then(|p| p.get("id"))
844                            .and_then(|v| v.as_str())
845                            == Some(id)
846                    })
847                })
848            })
849        })
850        .unwrap_or(false)
851}
852
853/// Keep signed/full-circle sweeps; remove only the shared torus drag's zero floor.
854fn constraint_degrees(degrees: f64) -> f64 {
855    if degrees.abs() < 0.5 {
856        0.0
857    } else {
858        degrees
859    }
860}
861