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