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