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