Skip to main content

brep_render/engine_state/
assembly_overlay.rs

1//! Assembly-constraint viewport overlays + GRABBABLE distance/angle handles
2//! (build-spec §8.4) — the engine-state half of [`crate::constraint_overlays`].
3//!
4//! Lifecycle: [`EngineState::refresh_constraint_overlay`] pulls the kernel
5//! session's `assembly_overlay_json` + `assembly_state_json`, folds them into
6//! the cached [`ConstraintOverlay`] records, and bakes the leader/arrow/arc
7//! triangle group. It runs after EVERY history apply (`finish_apply`) and after
8//! every constraint mutation this module commits;
9//! [`EngineState::ensure_constraint_overlay_current`] (called once per app
10//! frame) re-bakes on a material camera-zoom change (the arc/rod sizing is
11//! screen-constant) and clears/restores the group when the Show Constraint
12//! Graphics setting or sketch mode toggles.
13//!
14//! Dragging: a press on a DISTANCE leader (capsule region) or an ANGLE arc
15//! sweep-end handle (circle region) begins a drag that PREVIEWS the value
16//! locally (annotation + label track the pointer; no kernel call per move).
17//! A PLANE-BASED distance drag measures the SIGNED offset along the base-face
18//! normal the overlay builder stashed in the annotation (negative = through
19//! the face to its far side — the kernel mapper's `d = (P − base)·n̂` sign
20//! convention, so drag and solver always agree);
21//! release COMMITS `assembly_update_constraint_json(id, {…, distance|angle})`,
22//! which auto-solves. The reply's `movedSolids` display seam is consumed
23//! (re-posed resident solids re-tessellate in place) and the solved state is
24//! folded back into the engine's history document (`assembly_apply_document_json`
25//! — the pose-authority contract, wired here for the DRAG path; the per-run fold
26//! is lane F/H's runner-altitude wiring).
27//!
28//! NATIVE-SAFETY RULE: the kernel's fallible `assembly_*` exports return
29//! `Result<_, JsValue>`, and constructing a `JsValue` ABORTS off-wasm — so every
30//! call is guarded (the constraint must exist in the LIVE session state, checked
31//! through the infallible `assembly_state_json`) and the `Err` arm is reached
32//! only on wasm, where `JsValue` works.
33
34use super::feature_dims::closest_t_on_axis;
35use super::*;
36use crate::constraint_overlays::{
37    build_constraint_overlays, constraint_overlay_buffers, status_color, ConstraintOverlay,
38    ConstraintOverlayKind,
39};
40use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
41
42/// The world-space overlay group carrying the constraint leaders + handles.
43/// Sits just under the feature-dim gizmo group (10003) so an armed feature
44/// gizmo draws over constraint graphics.
45const CONSTRAINT_OVERLAY_GROUP: &str = "assembly-constraint-overlay";
46
47impl EngineState {
48    // -----------------------------------------------------------------------
49    // Refresh / bake
50    // -----------------------------------------------------------------------
51
52    /// Rebuild the constraint-overlay cache from the LIVE kernel session and
53    /// re-bake the drawn group. Cleared (empty group) while the Show Constraint
54    /// Graphics setting is off or a sketch edit is active. A live handle drag's
55    /// preview survives the rebuild (re-applied onto the fresh cache).
56    pub fn refresh_constraint_overlay(&mut self) {
57        if !self.settings.show_constraint_graphics || self.sketch_mode() {
58            self.clear_constraint_overlay();
59            return;
60        }
61        let overlay_json = brep_kernel::assembly_overlay_json();
62        let state_json = brep_kernel::assembly_state_json();
63        self.refresh_constraint_overlay_from(&overlay_json, &state_json);
64    }
65
66    /// [`Self::refresh_constraint_overlay`] with explicit payloads (the parsed
67    /// `assembly_overlay_json` array + `assembly_state_json` object) — the
68    /// testable seam: canned payloads exercise the whole cache/pick/drag path
69    /// without a kernel session.
70    pub fn refresh_constraint_overlay_from(&mut self, overlay_json: &str, state_json: &str) {
71        let rows: serde_json::Value =
72            serde_json::from_str(overlay_json).unwrap_or(serde_json::Value::Null);
73        let state: serde_json::Value =
74            serde_json::from_str(state_json).unwrap_or(serde_json::Value::Null);
75        let constraints = state
76            .get("constraints")
77            .cloned()
78            .unwrap_or(serde_json::Value::Null);
79        self.constraint_overlays = build_constraint_overlays(&rows, &constraints);
80        self.apply_constraint_drag_preview();
81        self.bake_constraint_overlay();
82    }
83
84    /// Clear the cache + the drawn group (setting off / sketch mode / no session).
85    fn clear_constraint_overlay(&mut self) {
86        let had = !self.constraint_overlays.is_empty() || self.constraint_overlay_wpp != 0.0;
87        self.constraint_overlays.clear();
88        self.constraint_overlay_wpp = 0.0;
89        if had {
90            let _ = self.set_overlay_json(
91                &serde_json::json!({ "groups": [ { "name": CONSTRAINT_OVERLAY_GROUP } ] })
92                    .to_string(),
93            );
94        }
95    }
96
97    /// Bake the cached overlays into the drawn triangle group at the CURRENT
98    /// camera zoom (screen-constant sizing) and remember the baked
99    /// `world_per_pixel` for [`Self::ensure_constraint_overlay_current`].
100    fn bake_constraint_overlay(&mut self) {
101        let wpp = self.camera.world_per_pixel();
102        let (positions, colors) = constraint_overlay_buffers(&self.constraint_overlays, wpp);
103        let _ = self.set_overlay_json(
104            &serde_json::json!({
105                "groups": [
106                    {
107                        "name": CONSTRAINT_OVERLAY_GROUP,
108                        "renderOrder": 10002,
109                        "tris": { "positions": positions, "colors": colors },
110                    }
111                ]
112            })
113            .to_string(),
114        );
115        self.constraint_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
116    }
117
118    /// Per-frame upkeep (the app viewport calls this once per frame): hide the
119    /// group while the setting is off / sketch mode is active, restore it when
120    /// they flip back, and re-bake when the camera zoom moved the
121    /// `world_per_pixel` materially (>0.5%) so the screen-constant arc/rod
122    /// sizing stays pixel-true. Re-bakes only on actual change, so a quiet
123    /// frame stays quiet (no dirty loop).
124    pub fn ensure_constraint_overlay_current(&mut self) {
125        let want = self.settings.show_constraint_graphics && !self.sketch_mode();
126        if !want {
127            self.clear_constraint_overlay();
128            return;
129        }
130        if self.constraint_overlay_wpp == 0.0 {
131            // Hidden → shown transition (toggle flipped back on / sketch exited):
132            // pull fresh session state.
133            self.refresh_constraint_overlay();
134            return;
135        }
136        if self.constraint_overlays.is_empty() {
137            return;
138        }
139        let wpp = self.camera.world_per_pixel();
140        if super::overlay_wpp_stale(self.constraint_overlay_wpp, wpp) {
141            self.bake_constraint_overlay();
142        }
143    }
144
145    /// The cached overlay records (read-only view for the app's label pass +
146    /// tests).
147    pub fn constraint_overlays(&self) -> &[ConstraintOverlay] {
148        &self.constraint_overlays
149    }
150
151    /// The label feed for the app's chip pass:
152    /// `[{id, type, icon, text, status, message, color:[r,g,b], world:[x,y,z],
153    /// draggable, selected}]`. `text` leads with `icon` (the type's glyph), so
154    /// the chip is a picture plus the measure; `id` is for the hover tooltip. One row per cached overlay with a resolvable label anchor
155    /// (the leader midpoint / arc mid-sweep / anchor midpoint). `selected`
156    /// marks the label-click-selected constraint (thicker chip border). Empty
157    /// while hidden.
158    pub fn constraint_labels_json(&self) -> String {
159        let wpp = self.camera.world_per_pixel();
160        let rows: Vec<serde_json::Value> = self
161            .constraint_overlays
162            .iter()
163            .filter_map(|overlay| {
164                let world = overlay.label_anchor(wpp)?;
165                let color = status_color(&overlay.status);
166                Some(serde_json::json!({
167                    "id": overlay.id,
168                    "type": overlay.constraint_type,
169                    "icon": overlay.icon,
170                    "text": overlay.label_text(),
171                    "status": overlay.status,
172                    "message": overlay.message,
173                    "color": [color[0], color[1], color[2]],
174                    "world": world,
175                    "draggable": overlay.draggable,
176                    "selected": self.selected_constraint.as_deref() == Some(overlay.id.as_str()),
177                }))
178            })
179            .collect();
180        serde_json::Value::Array(rows).to_string()
181    }
182
183    // -----------------------------------------------------------------------
184    // Label hover (highlight referenced geometry) + click (expand in panel)
185    // -----------------------------------------------------------------------
186
187    /// Hover a constraint LABEL: highlight the referenced elements
188    /// (`inputParams.elements`) through the existing emphasis machinery —
189    /// faces/edges by kernel name, a `{solid}@x,y,z` vertex ref or a bare
190    /// component id by its owning solid(s). Deduped by constraint id so a held
191    /// hover never re-bumps the emphasis generation. Sets the one-frame
192    /// [`Self::take_constraint_label_hover`] flag either way so the viewport's
193    /// scene-hover pass yields.
194    pub fn constraint_hover(&mut self, id: &str) {
195        self.constraint_label_hover_active = true;
196        if self.constraint_hovered.as_deref() == Some(id) {
197            return;
198        }
199        let elements = match self.constraint_overlays.iter().find(|o| o.id == id) {
200            Some(overlay) => overlay.elements.clone(),
201            None => Vec::new(),
202        };
203        // Classify each element against the display scene FIRST (immutable
204        // reads), then write the emphasis in one go.
205        let mut solids: Vec<String> = Vec::new();
206        let mut faces: Vec<String> = Vec::new();
207        let mut edges: Vec<String> = Vec::new();
208        for element in &elements {
209            if let Some(at) = element.find('@') {
210                // Vertex ref "{solid}@x,y,z" → highlight the owning solid.
211                solids.push(element[..at].to_string());
212                continue;
213            }
214            if self.scene_has_face(element) {
215                faces.push(element.clone());
216            } else if self.scene_has_edge(element) {
217                edges.push(element.clone());
218            } else if self.scene.solids().iter().any(|s| s.name == *element) {
219                solids.push(element.clone());
220            } else {
221                // A bare component ref (`ACOMP2`) → its member solids
222                // (`ACOMP2:…`, the namespaced-prefix convention).
223                let prefix = format!("{element}:");
224                solids.extend(
225                    self.scene
226                        .solids()
227                        .iter()
228                        .filter(|s| s.name.starts_with(&prefix))
229                        .map(|s| s.name.clone()),
230                );
231            }
232        }
233        self.clear_hover();
234        self.emphasis.hovered_solids.extend(solids);
235        self.emphasis.hovered_faces.extend(faces);
236        self.emphasis.hovered_edges.extend(edges);
237        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
238        self.constraint_hovered = Some(id.to_string());
239        self.dirty = true;
240    }
241
242    /// The pointer left the constraint labels: drop the element highlight (only
243    /// if a label hover set one — never clobbers an unrelated scene hover).
244    pub fn constraint_hover_end(&mut self) {
245        if self.constraint_hovered.take().is_some() {
246            self.clear_hover();
247        }
248    }
249
250    /// Consume the one-frame "a constraint label is hovering elements" flag —
251    /// the viewport's modeling hover branch skips its scene re-hover while set
252    /// (mirrors [`Self::take_sketch_list_hover`]) so the label-driven highlight
253    /// survives the frame without a per-frame clobber/re-apply dirty loop.
254    pub fn take_constraint_label_hover(&mut self) -> bool {
255        std::mem::take(&mut self.constraint_label_hover_active)
256    }
257
258    /// A constraint LABEL was clicked: SELECT that constraint (the chip gains
259    /// its selected accent, the context bar offers Delete constraint) and OPEN
260    /// it through the ACCORDION open ([`Self::assembly_set_constraint_open`] —
261    /// every other constraint closes, so the clicked one is the ONE open
262    /// constraint, which is what puts the panel into its dialog). Guarded on the
263    /// id existing in the live session (the fallible export's error path would
264    /// abort off-wasm).
265    pub fn constraint_label_clicked(&mut self, id: &str) {
266        if !live_constraint_exists(id) {
267            return;
268        }
269        self.selected_constraint = Some(id.to_string());
270        let _ = self.assembly_set_constraint_open(id, true);
271        self.dirty = true;
272    }
273
274    /// The constraint SELECTED via its viewport label (or `None`), pruned
275    /// against the live session — a deleted / undone constraint never lingers
276    /// as selected.
277    pub fn selected_constraint(&self) -> Option<String> {
278        let id = self.selected_constraint.as_deref()?;
279        live_constraint_exists(id).then(|| id.to_string())
280    }
281
282    /// Drop the constraint selection (the Clear action, Esc, and the context
283    /// bar's delete all route here — directly or via `clear_selection`).
284    pub fn constraint_deselect(&mut self) {
285        if self.selected_constraint.take().is_some() {
286            self.dirty = true;
287        }
288    }
289
290    fn scene_has_face(&self, name: &str) -> bool {
291        self.scene
292            .solids()
293            .iter()
294            .any(|solid| solid.faces.iter().any(|face| face.name == name))
295    }
296
297    fn scene_has_edge(&self, name: &str) -> bool {
298        self.scene
299            .solids()
300            .iter()
301            .any(|solid| solid.edges.iter().any(|edge| edge.name == name))
302    }
303
304    // -----------------------------------------------------------------------
305    // Grabbable handles: pick + drag preview + commit
306    // -----------------------------------------------------------------------
307
308    /// The SCREEN-space pickable regions of the DRAGGABLE constraint handles,
309    /// each paired with its constraint id: a distance leader's whole CAPSULE
310    /// (`point_a → point_b`), an angle arc's sweep-end handle CIRCLE — the same
311    /// region family (and the same generous `ARROW_HANDLE_HIT_RAD_PX`) as the
312    /// feature-dimension arrows, built by the shared `brep_gizmos::hit_region`
313    /// projectors. Non-draggable overlays (expression-valued params, leader-only
314    /// types) contribute nothing.
315    fn constraint_hit_regions(&self) -> Vec<(String, HitShape)> {
316        if !self.settings.show_constraint_graphics || self.sketch_mode() {
317            return Vec::new();
318        }
319        let wpp = self.camera.world_per_pixel();
320        let radius = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
321        let cam = &self.camera;
322        let mut out = Vec::new();
323        for overlay in &self.constraint_overlays {
324            if !overlay.draggable {
325                continue;
326            }
327            let Some(annotation) = &overlay.annotation else {
328                continue;
329            };
330            let shape = match overlay.kind {
331                ConstraintOverlayKind::Distance => {
332                    segment_region(cam, annotation.point_a, annotation.point_b, radius)
333                }
334                ConstraintOverlayKind::Angle => point_region(
335                    cam,
336                    crate::feature_dimensions::arrow_handle_point(annotation, wpp),
337                    radius,
338                ),
339                ConstraintOverlayKind::Leader => None,
340            };
341            if let Some(shape) = shape {
342                out.push((overlay.id.clone(), shape));
343            }
344        }
345        out
346    }
347
348    /// Whether a screen-px pick lands on a draggable constraint handle; returns
349    /// the grabbed constraint's id (the NEAREST containing region wins). The
350    /// viewport routes a drag that starts here into the constraint drag, and
351    /// swallows a bare click so the solid behind the arrow isn't selected.
352    pub fn constraint_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
353        let p = [x as f32, y as f32];
354        let mut best: Option<(f32, String)> = None;
355        for (id, shape) in self.constraint_hit_regions() {
356            let d = shape.spine_distance(p);
357            if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
358                best = Some((d, id));
359            }
360        }
361        best.map(|(_, id)| id)
362    }
363
364    /// Begin a constraint-handle drag at screen `(x, y)`. Returns whether a
365    /// draggable handle was grabbed (the viewport then routes the drag here
366    /// instead of orbiting the camera).
367    pub fn constraint_drag_begin(&mut self, x: f64, y: f64) -> bool {
368        let Some(id) = self.constraint_arrow_pick(x, y) else {
369            return false;
370        };
371        let Some(overlay) = self.constraint_overlays.iter().find(|o| o.id == id) else {
372            return false;
373        };
374        let Some(field) = overlay.field_key() else {
375            return false;
376        };
377        let preview = overlay
378            .annotation
379            .as_ref()
380            .map(|a| a.value)
381            .or(overlay.value)
382            .unwrap_or(0.0);
383        self.constraint_drag = Some(ConstraintDrag {
384            id,
385            field,
386            params: overlay.input_params.clone(),
387            preview,
388        });
389        true
390    }
391
392    /// Drag a grabbed constraint handle to screen `(x, y)`: map the pointer to a
393    /// new value (distance: signed offset along the base-face normal for
394    /// plane-based rows, magnitude along the leader otherwise; angle: the
395    /// shared arc nearest-projection search, folded to the interior 0–180°),
396    /// update the PREVIEW (annotation + label track live), and re-bake. No
397    /// kernel call — the commit happens on [`Self::constraint_drag_release`].
398    pub fn constraint_drag_to(&mut self, x: f64, y: f64) {
399        let Some(id) = self.constraint_drag.as_ref().map(|d| d.id.clone()) else {
400            return;
401        };
402        let Some(overlay) = self.constraint_overlays.iter().find(|o| o.id == id) else {
403            return;
404        };
405        let Some(annotation) = overlay.annotation.clone() else {
406            return;
407        };
408        let kind = overlay.kind;
409        let new_value = match kind {
410            ConstraintOverlayKind::Distance => {
411                let a = annotation.point_a;
412                let ray = self.camera.pick_ray(x, y);
413                let rd = ray.dir;
414                let rn = (rd[0] * rd[0] + rd[1] * rd[1] + rd[2] * rd[2]).sqrt();
415                if rn < 1e-12 {
416                    return;
417                }
418                let ray_dir = [rd[0] / rn, rd[1] / rn, rd[2] / rn];
419                let n = annotation.axis;
420                let raw = if n[0] * n[0] + n[1] * n[1] + n[2] * n[2] > 0.5 {
421                    // PLANE-BASED row (the builder stashed the base-face
422                    // outward unit normal in `axis`, and drew the arrow from
423                    // the perpendicular foot in TRUE world scale): the new
424                    // value is simply the pointer's SIGNED offset along that
425                    // fixed normal from the foot — dragging through the base
426                    // face crosses zero into negatives, the same
427                    // `d = (P − base)·n̂` definition the kernel solves.
428                    // Deliberately no length guard: a zero-distance arrow
429                    // (foot == tip, just the origin sphere) still drags.
430                    let Some(t) = closest_t_on_axis(a, n, ray.origin, ray_dir) else {
431                        return;
432                    };
433                    t
434                } else {
435                    // Plane-less row (line/point pairing — no side to be on):
436                    // project onto the anchor-to-anchor leader, map world
437                    // distance → param via the measured-value / world-length
438                    // ratio, and clamp to the MAGNITUDE domain. `value ≈ 0`
439                    // (touching pair) has no ratio: use the world distance.
440                    let b = annotation.point_b;
441                    let axis = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
442                    let len =
443                        (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
444                    if len < 1e-9 {
445                        return;
446                    }
447                    let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
448                    let Some(t) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
449                        return;
450                    };
451                    let ratio = if annotation.value.abs() > 1e-9 {
452                        annotation.value / len
453                    } else {
454                        1.0
455                    };
456                    (t * ratio).max(0.0)
457                };
458                (raw * 1e4).round() / 1e4
459            }
460            ConstraintOverlayKind::Angle => {
461                let Some(degrees) = self.angular_drag_degrees(&annotation, x, y) else {
462                    return;
463                };
464                interior_degrees(degrees)
465            }
466            ConstraintOverlayKind::Leader => return,
467        };
468        if let Some(drag) = self.constraint_drag.as_mut() {
469            drag.preview = new_value;
470        }
471        self.apply_constraint_drag_preview();
472        self.bake_constraint_overlay();
473    }
474
475    /// Patch the cached overlay with the live drag's preview value so the drawn
476    /// annotation + label track the pointer: a plane-based distance arrow
477    /// re-plants its tip at `foot + n̂·preview` (flipping through the base face
478    /// for a negative preview), a plane-less leader stretches `point_b` along
479    /// its fixed direction, and an angle arc re-sweeps.
480    fn apply_constraint_drag_preview(&mut self) {
481        let Some(drag) = self.constraint_drag.as_ref() else {
482            return;
483        };
484        let preview = drag.preview;
485        let id = drag.id.clone();
486        let Some(overlay) = self.constraint_overlays.iter_mut().find(|o| o.id == id) else {
487            return;
488        };
489        let kind = overlay.kind;
490        overlay.value = Some(preview);
491        if let Some(annotation) = overlay.annotation.as_mut() {
492            match kind {
493                ConstraintOverlayKind::Distance => {
494                    let a = annotation.point_a;
495                    let n = annotation.axis;
496                    if n[0] * n[0] + n[1] * n[1] + n[2] * n[2] > 0.5 {
497                        // Plane-based (base normal in `axis`): the arrow is
498                        // drawn in TRUE world scale, so the tip is EXACTLY
499                        // `foot + n̂·value` — a negative preview lands it on
500                        // the far side of the base face, exactly where the
501                        // solve will put the constrained element.
502                        annotation.point_b = [
503                            a[0] + n[0] * preview,
504                            a[1] + n[1] * preview,
505                            a[2] + n[2] * preview,
506                        ];
507                    } else {
508                        let b = annotation.point_b;
509                        let axis = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
510                        let len = (axis[0] * axis[0]
511                            + axis[1] * axis[1]
512                            + axis[2] * axis[2])
513                            .sqrt();
514                        if len > 1e-9 && annotation.value.abs() > 1e-9 {
515                            // Keep the world-per-value scale the leader had, so
516                            // the arrow tip lands where the face will end up.
517                            let world_len = preview * (len / annotation.value);
518                            let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
519                            annotation.point_b = [
520                                a[0] + dir[0] * world_len,
521                                a[1] + dir[1] * world_len,
522                                a[2] + dir[2] * world_len,
523                            ];
524                        }
525                    }
526                    annotation.value = preview;
527                }
528                _ => {
529                    annotation.value = preview;
530                }
531            }
532        }
533    }
534
535    /// The pending commit `(constraint id, inputParams JSON)` a release would
536    /// send — the drag's params snapshot with the dragged field set to the
537    /// preview value. `None` when no drag is live. (The pure half of
538    /// [`Self::constraint_drag_release`], separated for tests.)
539    ///
540    /// Angle display convention: the drag preview is the INTERIOR arc sweep
541    /// (what the arc draws and the kernel's overlay `value` measures), but the
542    /// stored `inputParams.angle` is the DISPLAY angle — exterior-remapped when
543    /// the constraint's `exteriorAngle` toggle is on (`map_angle`'s contract) —
544    /// so the commit remaps `interior → 180 − interior` for those.
545    pub fn constraint_drag_commit_payload(&self) -> Option<(String, String)> {
546        let drag = self.constraint_drag.as_ref()?;
547        let mut params = drag.params.clone();
548        if !params.is_object() {
549            params = serde_json::json!({});
550        }
551        let exterior = drag.field == "angle"
552            && params
553                .get("exteriorAngle")
554                .and_then(|v| v.as_bool())
555                .unwrap_or(false);
556        let committed = if exterior {
557            180.0 - drag.preview
558        } else {
559            drag.preview
560        };
561        let object = params.as_object_mut().expect("object ensured above");
562        object.insert(drag.field.to_string(), serde_json::json!(committed));
563        object.insert("id".to_string(), serde_json::json!(drag.id));
564        Some((drag.id.clone(), params.to_string()))
565    }
566
567    /// Release the constraint-handle drag: COMMIT the previewed value via
568    /// `assembly_update_constraint_json` (auto-solves), consume the reply's
569    /// `movedSolids` display seam (re-posed resident solids re-tessellate in
570    /// place), fold the solved poses back into the engine's history document
571    /// (pose-authority contract, drag path), and refresh the overlay from the
572    /// post-solve session. A commit against a session that no longer has the
573    /// constraint (or no session at all — canned-payload tests, the native
574    /// thread-runner seam) drops the preview with a notice instead.
575    pub fn constraint_drag_release(&mut self) {
576        let Some((id, params_json)) = self.constraint_drag_commit_payload() else {
577            self.constraint_drag = None;
578            return;
579        };
580        self.constraint_drag = None;
581        if !live_constraint_exists(&id) {
582            self.notices
583                .push(format!("Constraint {id}: no live assembly session to update"));
584            self.refresh_constraint_overlay();
585            return;
586        }
587        match brep_kernel::assembly_update_constraint_json(&id, &params_json) {
588            Ok(report) => {
589                self.consume_assembly_moved_solids(&report);
590                self.adopt_assembly_fold();
591            }
592            // Reachable only on wasm (the guard above covers the native abort
593            // hazard of constructing the error JsValue off-wasm).
594            Err(error) => {
595                let message = error
596                    .as_string()
597                    .unwrap_or_else(|| "constraint update failed".to_string());
598                self.notices.push(format!("Constraint {id}: {message}"));
599            }
600        }
601        self.refresh_constraint_overlay();
602        self.dirty = true;
603    }
604
605    // -----------------------------------------------------------------------
606    // Solve write-backs: movedSolids display seam + the document fold
607    // -----------------------------------------------------------------------
608
609    /// Consume a solve report's `movedSolids: [{name, handle}]` display seam:
610    /// those resident solids were RE-POSED IN PLACE by the solver (their
611    /// producing feature replayed `reused`, so the run delta kept the stale
612    /// mesh) — re-tessellate each from its live resident handle and replace its
613    /// display, preserving per-solid view state (visibility, color override).
614    /// The key is absent on zero-mate reports; absence is tolerated.
615    pub fn consume_assembly_moved_solids(&mut self, report_json: &str) {
616        let Ok(report) = serde_json::from_str::<serde_json::Value>(report_json) else {
617            return;
618        };
619        let Some(moved) = report.get("movedSolids").and_then(|v| v.as_array()) else {
620            return;
621        };
622        let lod = if self.settings.lod_factor.is_finite() && self.settings.lod_factor > 0.0 {
623            self.settings.lod_factor
624        } else {
625            1.0
626        };
627        let mut changed = false;
628        for entry in moved {
629            let Some(name) = entry.get("name").and_then(|v| v.as_str()) else {
630                continue;
631            };
632            let Some(handle) = entry
633                .get("handle")
634                .and_then(|v| v.as_u64())
635                .map(|h| h as u32)
636            else {
637                continue;
638            };
639            match brep_kernel::display_payload_handle_native(handle, lod) {
640                Ok(payload) => {
641                    let mut display = crate::scene::solid_display_from_payload(name, payload);
642                    display.source_handle = handle;
643                    if let Some(old) = self.scene.solids().iter().find(|s| s.name == name) {
644                        display.visible = old.visible;
645                    }
646                    self.scene.insert_solid(display);
647                    changed = true;
648                }
649                Err(error) => self
650                    .notices
651                    .push(format!("re-tessellate moved solid {name}: {error}")),
652            }
653        }
654        if changed {
655            // Colours are NOT hand-copied off the replaced display: this path
656            // builds a fresh one per moved solid, whose per-face colours a copy
657            // could not carry. Re-derive the whole scene from the metadata store
658            // instead — the same seam every other path colours through.
659            self.sync_colors_from_metadata();
660            self.dirty = true;
661        }
662    }
663
664    /// Fold the kernel session's solved state into the engine's history
665    /// document (`assembly_apply_document_json`: the `assembly` block + solved
666    /// poses/isFixed onto the owning features by `inputParams.id`) and ADOPT the
667    /// result — so persisting or re-running uses the solved poses
668    /// (pose-authority contract). Wired for the DRAG-COMMIT path here; callers
669    /// must ensure a live session exists (this runs right after a successful
670    /// mutation). No history re-run: the scene was already refreshed through
671    /// the movedSolids seam.
672    fn adopt_assembly_fold(&mut self) {
673        let document = self.history.request_json();
674        match brep_kernel::assembly_apply_document_json(&document) {
675            Ok(folded) => {
676                if let Err(error) = self.history.adopt_folded_request(&folded) {
677                    self.notices.push(format!("assembly fold: {error}"));
678                }
679            }
680            // Reachable only on wasm (see the native-safety rule above).
681            Err(error) => {
682                let message = error
683                    .as_string()
684                    .unwrap_or_else(|| "assembly document fold failed".to_string());
685                self.notices.push(format!("assembly fold: {message}"));
686            }
687        }
688    }
689}
690
691/// Whether the LIVE kernel session currently holds a constraint with `id` —
692/// checked through the infallible `assembly_state_json` export so the guard
693/// itself can never hit a fallible export's off-wasm-aborting error path.
694fn live_constraint_exists(id: &str) -> bool {
695    serde_json::from_str::<serde_json::Value>(&brep_kernel::assembly_state_json())
696        .ok()
697        .and_then(|state| {
698            state.get("constraints").and_then(|list| {
699                list.as_array().map(|entries| {
700                    entries.iter().any(|entry| {
701                        entry
702                            .get("inputParams")
703                            .and_then(|p| p.get("id"))
704                            .and_then(|v| v.as_str())
705                            == Some(id)
706                    })
707                })
708            })
709        })
710        .unwrap_or(false)
711}
712
713/// Fold an arc sweep (the shared angular drag search's [-360°, 360°] output)
714/// onto the INTERIOR angle domain the constraint mate targets (0–180°,
715/// unsigned — `angle_between_deg` symmetry), snapping the sub-0.5° residue of
716/// the search's zero-floor to an exact 0 (parallel is a legitimate target).
717fn interior_degrees(degrees: f64) -> f64 {
718    let folded = degrees.abs() % 360.0;
719    let interior = if folded > 180.0 { 360.0 - folded } else { folded };
720    if interior < 0.5 {
721        0.0
722    } else {
723        interior
724    }
725}
726
727#[cfg(test)]
728mod constraint_overlay_tests {
729    use super::*;
730
731    /// An 800×600 ortho engine looking down -Z at `target` (the canned-payload
732    /// harness — mirrors the feature-dim test cameras).
733    fn ortho_engine(target: [f64; 3]) -> EngineState {
734        let mut state = EngineState::new();
735        state.resize(800.0, 600.0);
736        state.camera.eye = [target[0], target[1], target[2] + 40.0];
737        state.camera.target = target;
738        state.camera.up = [0.0, 1.0, 0.0];
739        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
740        state
741    }
742
743    fn distance_rows() -> String {
744        serde_json::json!([{
745            "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
746            "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
747            "directions": [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]],
748            "value": 8.0, "unit": "mm",
749        }])
750        .to_string()
751    }
752
753    fn state_json(params: serde_json::Value) -> String {
754        serde_json::json!({
755            "constraints": [{ "type": "distance", "inputParams": params,
756                              "persistentData": {}, "enabled": true, "open": false }],
757            "idCounter": 1
758        })
759        .to_string()
760    }
761
762    #[test]
763    fn canned_distance_pick_drag_preview_and_commit_payload() {
764        let mut state = ortho_engine([4.0, 0.0, 0.0]);
765        state.refresh_constraint_overlay_from(
766            &distance_rows(),
767            &state_json(serde_json::json!({
768                "id": "DIST1", "elements": ["A_PX", "B_NX"], "distance": 8.0
769            })),
770        );
771        assert_eq!(state.constraint_overlays().len(), 1);
772        let ann = state.constraint_overlays()[0]
773            .annotation
774            .clone()
775            .expect("linear annotation");
776
777        // The WHOLE leader capsule is grabbable — pick at the projected midpoint.
778        let mid = ann.midpoint();
779        let (mx, my, depth) = state.camera.project(mid);
780        assert!(depth > 0.0);
781        assert_eq!(state.constraint_arrow_pick(mx, my).as_deref(), Some("DIST1"));
782        assert!(state.constraint_arrow_pick(5.0, 5.0).is_none(), "empty corner grabs nothing");
783
784        // Grab + drag the arrow to the world point 5 units along the leader:
785        // preview tracks the pointer (ratio value/len = 1 here).
786        assert!(state.constraint_drag_begin(mx, my));
787        let target_world = [
788            ann.point_a[0] + 5.0,
789            ann.point_a[1],
790            ann.point_a[2],
791        ];
792        let (tx, ty, tdepth) = state.camera.project(target_world);
793        assert!(tdepth > 0.0);
794        state.constraint_drag_to(tx, ty);
795        let drag = state.constraint_drag.clone().expect("drag live");
796        assert!((drag.preview - 5.0).abs() < 0.05, "preview ≈ 5, got {}", drag.preview);
797        // The preview is reflected in the cached overlay (label + arrow follow).
798        assert!(
799            (state.constraint_overlays()[0].value.unwrap() - drag.preview).abs() < 1e-9,
800            "label value tracks the preview"
801        );
802        let previewed = state.constraint_overlays()[0].annotation.as_ref().unwrap();
803        assert!(
804            (previewed.point_b[0] - 5.0).abs() < 0.05,
805            "arrow tip tracks the preview: {:?}",
806            previewed.point_b
807        );
808
809        // The commit payload: the original params with the dragged field set.
810        let (id, params_json) = state.constraint_drag_commit_payload().expect("payload");
811        assert_eq!(id, "DIST1");
812        let params: serde_json::Value = serde_json::from_str(&params_json).unwrap();
813        assert!((params["distance"].as_f64().unwrap() - drag.preview).abs() < 1e-9);
814        assert_eq!(params["elements"][0], "A_PX", "untouched params ride along");
815        assert_eq!(params["id"], "DIST1");
816
817        // Release without a live kernel session: the commit is refused with a
818        // notice (never the off-wasm-aborting error path), the drag ends, and
819        // the overlay re-reads the (empty) live session.
820        state.constraint_drag_release();
821        assert!(state.constraint_drag.is_none());
822        let notices = state.take_notices();
823        assert!(
824            notices.iter().any(|n| n.contains("DIST1")),
825            "refused commit surfaces a notice: {notices:?}"
826        );
827    }
828
829    #[test]
830    fn plane_based_distance_drag_is_signed_and_hit_matches_draw() {
831        // A base face tilted in the view plane (n̂ = [1,1,0]/√2 — NOT axis
832        // aligned) at Q with the other anchor P off the perpendicular: the
833        // arrow must be the foot→P perpendicular segment, the HIT capsule must
834        // be built from those SAME endpoints (draw == hit), and dragging the
835        // tip THROUGH the base face must preview a NEGATIVE value.
836        let mut state = ortho_engine([3.0, 0.0, 0.0]);
837        let r = 1.0 / 2.0_f64.sqrt();
838        let s = 8.0 / 2.0_f64.sqrt(); // (P − Q)·n̂ = (7 + 1)/√2
839        let rows = serde_json::json!([{
840            "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
841            "anchors": [[0.0, 0.0, 0.0], [7.0, 1.0, 0.0]],
842            "directions": [[r, r, 0.0], null],
843            "geoms": ["plane", "point"],
844            "value": s, "unit": "mm",
845        }])
846        .to_string();
847        state.refresh_constraint_overlay_from(
848            &rows,
849            &state_json(serde_json::json!({
850                "id": "DIST1", "elements": ["A_TILT", "B_PT"], "distance": s
851            })),
852        );
853        let ann = state.constraint_overlays()[0]
854            .annotation
855            .clone()
856            .expect("linear annotation");
857        // Perpendicular-foot construction: foot = P − s·n̂ = (3, −3, 0).
858        let foot = [3.0, -3.0, 0.0];
859        for i in 0..3 {
860            assert!((ann.point_a[i] - foot[i]).abs() < 1e-9, "foot: {:?}", ann.point_a);
861            assert!((ann.axis[i] - [r, r, 0.0][i]).abs() < 1e-9, "axis: {:?}", ann.axis);
862        }
863        assert_eq!(ann.point_b, [7.0, 1.0, 0.0], "tip at the other anchor");
864
865        // Draw == hit: the capsule region is built from the SAME endpoints the
866        // arrow is drawn with (the red debug outline stays the pickable area).
867        let regions = state.constraint_hit_regions();
868        assert_eq!(regions.len(), 1);
869        assert_eq!(regions[0].0, "DIST1");
870        let HitShape::Capsule { a, b, r: rad } = regions[0].1 else {
871            panic!("distance handle is a capsule: {:?}", regions[0].1);
872        };
873        let (ax, ay, _) = state.camera.project(ann.point_a);
874        let (bx, by, _) = state.camera.project(ann.point_b);
875        assert!((a[0] as f64 - ax).abs() < 1e-3 && (a[1] as f64 - ay).abs() < 1e-3);
876        assert!((b[0] as f64 - bx).abs() < 1e-3 && (b[1] as f64 - by).abs() < 1e-3);
877        assert!(rad as f64 >= crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX - 1e-6);
878        // …and the OLD naive anchor-to-anchor chord is NOT the pickable area:
879        // its midpoint (2.1 world units off the perpendicular ≈ 32 px, beyond
880        // the 18 px capsule radius) grabs nothing.
881        let (nx, ny, _) = state.camera.project([3.5, 0.5, 0.0]);
882        assert!(state.constraint_arrow_pick(nx, ny).is_none(), "stale chord must miss");
883
884        // Grab the arrow and drag the pointer to foot + n̂·(−3), THROUGH the
885        // base face: the preview is the SIGNED offset −3 and the tip flips to
886        // the far side (exactly the pointer's world target).
887        let (mx, my, depth) = state.camera.project(ann.midpoint());
888        assert!(depth > 0.0);
889        assert!(state.constraint_drag_begin(mx, my));
890        let target = [foot[0] - r * 3.0, foot[1] - r * 3.0, 0.0];
891        let (tx, ty, tdepth) = state.camera.project(target);
892        assert!(tdepth > 0.0);
893        state.constraint_drag_to(tx, ty);
894        let preview = state.constraint_drag.as_ref().unwrap().preview;
895        assert!((preview + 3.0).abs() < 0.01, "signed preview ≈ −3, got {preview}");
896        let previewed = state.constraint_overlays()[0].annotation.as_ref().unwrap();
897        for i in 0..3 {
898            assert!(
899                (previewed.point_b[i] - target[i]).abs() < 0.01,
900                "tip on the far side of the base face: {:?}",
901                previewed.point_b
902            );
903        }
904        assert!((previewed.value - preview).abs() < 1e-9);
905
906        // The commit payload carries the NEGATIVE distance verbatim.
907        let (id, params_json) = state.constraint_drag_commit_payload().expect("payload");
908        assert_eq!(id, "DIST1");
909        let params: serde_json::Value = serde_json::from_str(&params_json).unwrap();
910        assert!((params["distance"].as_f64().unwrap() - preview).abs() < 1e-9);
911        assert!(params["distance"].as_f64().unwrap() < 0.0);
912    }
913
914    #[test]
915    fn expression_valued_distance_param_is_not_grabbable() {
916        let mut state = ortho_engine([4.0, 0.0, 0.0]);
917        state.refresh_constraint_overlay_from(
918            &distance_rows(),
919            &state_json(serde_json::json!({
920                "id": "DIST1", "elements": [], "distance": "gap * 2"
921            })),
922        );
923        assert_eq!(state.constraint_overlays().len(), 1);
924        assert!(!state.constraint_overlays()[0].draggable);
925        // The graphics exist, but no hit region is built and no drag begins.
926        let ann = state.constraint_overlays()[0].annotation.clone().unwrap();
927        let (mx, my, _) = state.camera.project(ann.midpoint());
928        assert!(state.constraint_arrow_pick(mx, my).is_none());
929        assert!(!state.constraint_drag_begin(mx, my));
930    }
931
932    #[test]
933    fn angle_arc_drag_maps_to_interior_degrees() {
934        // Carriers +X / +Y meeting at the origin, measured 90° — the arc sweeps
935        // about +Z, so look straight down +Z (the arc plane faces the camera).
936        let mut state = ortho_engine([0.0, 0.0, 0.0]);
937        let rows = serde_json::json!([{
938            "id": "ANGL1", "type": "angle", "status": "adjusted", "message": "",
939            "anchors": [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
940            "directions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
941            "value": 90.0, "unit": "deg",
942        }])
943        .to_string();
944        let constraints = serde_json::json!({
945            "constraints": [{ "type": "angle",
946                "inputParams": { "id": "ANGL1", "elements": [], "angle": 90.0 },
947                "persistentData": {}, "enabled": true, "open": false }],
948            "idCounter": 1
949        })
950        .to_string();
951        state.refresh_constraint_overlay_from(&rows, &constraints);
952        let ann = state.constraint_overlays()[0]
953            .annotation
954            .clone()
955            .expect("angular annotation");
956        let wpp = state.world_per_pixel();
957
958        // Grab the arc's sweep-end handle sphere.
959        let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
960        let (hx, hy, hdepth) = state.camera.project(handle);
961        assert!(hdepth > 0.0);
962        assert_eq!(state.constraint_arrow_pick(hx, hy).as_deref(), Some("ANGL1"));
963        assert!(state.constraint_drag_begin(hx, hy));
964
965        // Drag to the arc point at 135°: the shared angular search recovers the
966        // swept degrees (1° snap) and the constraint folds them to the interior.
967        let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * wpp;
968        let dir = crate::feature_dimensions::rotate_about_axis(
969            ann.ref_dir,
970            ann.axis,
971            135.0_f64.to_radians(),
972        );
973        let world = [
974            ann.center[0] + dir[0] * radius,
975            ann.center[1] + dir[1] * radius,
976            ann.center[2] + dir[2] * radius,
977        ];
978        let (sx, sy, sdepth) = state.camera.project(world);
979        assert!(sdepth > 0.0);
980        state.constraint_drag_to(sx, sy);
981        let preview = state.constraint_drag.as_ref().unwrap().preview;
982        assert!((preview - 135.0).abs() < 2.0, "interior sweep ≈ 135, got {preview}");
983        let (_, params_json) = state.constraint_drag_commit_payload().expect("payload");
984        let params: serde_json::Value = serde_json::from_str(&params_json).unwrap();
985        assert!((params["angle"].as_f64().unwrap() - preview).abs() < 1e-9);
986
987        // A drag to the OTHER side of the zero reference (-30°) folds to the
988        // interior 30° — the mate's angle domain is unsigned.
989        let dir = crate::feature_dimensions::rotate_about_axis(
990            ann.ref_dir,
991            ann.axis,
992            (-30.0_f64).to_radians(),
993        );
994        let world = [
995            ann.center[0] + dir[0] * radius,
996            ann.center[1] + dir[1] * radius,
997            ann.center[2] + dir[2] * radius,
998        ];
999        let (sx, sy, _) = state.camera.project(world);
1000        state.constraint_drag_to(sx, sy);
1001        let preview = state.constraint_drag.as_ref().unwrap().preview;
1002        assert!((preview - 30.0).abs() < 2.0, "folded interior ≈ 30, got {preview}");
1003    }
1004
1005    #[test]
1006    fn exterior_angle_commit_remaps_the_display_value() {
1007        // An angle constraint with `exteriorAngle: true` STORES the exterior
1008        // display angle (`map_angle` contract) while the arc previews the
1009        // interior sweep — the commit payload must remap `interior → 180 − x`.
1010        let mut state = ortho_engine([0.0, 0.0, 0.0]);
1011        state.constraint_drag = Some(ConstraintDrag {
1012            id: "ANGL1".to_string(),
1013            field: "angle",
1014            params: serde_json::json!({
1015                "id": "ANGL1", "elements": [], "angle": 120.0, "exteriorAngle": true
1016            }),
1017            preview: 30.0,
1018        });
1019        let (_, params_json) = state.constraint_drag_commit_payload().expect("payload");
1020        let params: serde_json::Value = serde_json::from_str(&params_json).unwrap();
1021        assert!((params["angle"].as_f64().unwrap() - 150.0).abs() < 1e-9);
1022        // Without the toggle the interior sweep commits verbatim; a distance
1023        // drag never remaps regardless of stray flags.
1024        state.constraint_drag.as_mut().unwrap().params["exteriorAngle"] =
1025            serde_json::json!(false);
1026        let (_, params_json) = state.constraint_drag_commit_payload().unwrap();
1027        let params: serde_json::Value = serde_json::from_str(&params_json).unwrap();
1028        assert!((params["angle"].as_f64().unwrap() - 30.0).abs() < 1e-9);
1029        state.constraint_drag = Some(ConstraintDrag {
1030            id: "DIST1".to_string(),
1031            field: "distance",
1032            params: serde_json::json!({ "id": "DIST1", "exteriorAngle": true }),
1033            preview: 4.0,
1034        });
1035        let (_, params_json) = state.constraint_drag_commit_payload().unwrap();
1036        let params: serde_json::Value = serde_json::from_str(&params_json).unwrap();
1037        assert!((params["distance"].as_f64().unwrap() - 4.0).abs() < 1e-9);
1038    }
1039
1040    #[test]
1041    fn interior_degrees_folds_the_sweep_domain() {
1042        assert_eq!(interior_degrees(0.0), 0.0);
1043        assert_eq!(interior_degrees(0.3), 0.0, "sub-snap residue lands on exact 0");
1044        assert!((interior_degrees(90.0) - 90.0).abs() < 1e-12);
1045        assert!((interior_degrees(-90.0) - 90.0).abs() < 1e-12);
1046        assert!((interior_degrees(180.0) - 180.0).abs() < 1e-12);
1047        assert!((interior_degrees(210.0) - 150.0).abs() < 1e-12);
1048        assert!((interior_degrees(-359.0) - 1.0).abs() < 1e-12);
1049    }
1050
1051    #[test]
1052    fn ensure_current_hides_in_sketch_mode_and_rebakes_on_zoom() {
1053        let mut state = ortho_engine([4.0, 0.0, 0.0]);
1054        state.refresh_constraint_overlay_from(
1055            &distance_rows(),
1056            &state_json(serde_json::json!({ "id": "DIST1", "elements": [], "distance": 8.0 })),
1057        );
1058        assert_eq!(state.constraint_overlays().len(), 1);
1059
1060        // Toggle the setting off → the cache + group clear on the next ensure.
1061        state.settings.show_constraint_graphics = false;
1062        state.ensure_constraint_overlay_current();
1063        assert!(state.constraint_overlays().is_empty(), "hidden while toggled off");
1064
1065        // Toggle back on → ensure re-pulls the LIVE session (none in this test →
1066        // stays empty, but the baked flag flips so it would restore).
1067        state.settings.show_constraint_graphics = true;
1068        state.ensure_constraint_overlay_current();
1069        assert!(state.constraint_overlays().is_empty(), "no live session to restore from");
1070
1071        // Re-seed via the canned payload, then zoom the camera: a material
1072        // world-per-pixel change re-bakes (dirty), a quiet frame does not.
1073        state.refresh_constraint_overlay_from(
1074            &distance_rows(),
1075            &state_json(serde_json::json!({ "id": "DIST1", "elements": [], "distance": 8.0 })),
1076        );
1077        state.dirty = false;
1078        state.ensure_constraint_overlay_current();
1079        assert!(!state.dirty, "a quiet frame must not re-bake (no dirty loop)");
1080        state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1081        state.ensure_constraint_overlay_current();
1082        assert!(state.dirty, "a zoom change re-bakes the screen-constant sizing");
1083    }
1084
1085    // -----------------------------------------------------------------------
1086    // End-to-end against a LIVE kernel assembly session
1087    // -----------------------------------------------------------------------
1088
1089    /// Two ACOMP cube instances (one fixed) + a face-face distance constraint,
1090    /// executed through the engine's own history lane so the kernel session is
1091    /// installed on THIS thread (Inline runner). The document CARRIES its
1092    /// `partsLibrary` block (the load path re-seeds the resident library, which
1093    /// `set_history_json`'s cache clear resets); the entry ships `document`
1094    /// only, so the first run exercises the self-heal snapshot lane.
1095    fn assembly_engine() -> EngineState {
1096        let part_doc = serde_json::json!({
1097            "expressions": "", "configurator": {},
1098            "features": [
1099                { "type": "P.CU", "inputParams": { "id": "Part", "sizeX": 2.0, "sizeY": 2.0, "sizeZ": 2.0 } }
1100            ]
1101        });
1102        let request = serde_json::json!({
1103            "expressions": "", "configurator": {},
1104            "partsLibrary": {
1105                "lane-g-part": {
1106                    "sourceKey": "lane-g-part",
1107                    "sourceSignature": "lane-g-sig",
1108                    "document": part_doc,
1109                }
1110            },
1111            "features": [
1112                { "type": "ACOMP", "inputParams": {
1113                    "id": "ACOMP1", "partName": "lane-g-part",
1114                    "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
1115                    "isFixed": true }, "persistentData": {} },
1116                { "type": "ACOMP", "inputParams": {
1117                    "id": "ACOMP2", "partName": "lane-g-part",
1118                    "transform": { "translate": [10, 0, 0], "rotateEulerDeg": [0, 0, 0] } },
1119                  "persistentData": {} }
1120            ],
1121            "assembly": {
1122                "constraints": [{
1123                    "type": "distance",
1124                    "inputParams": {
1125                        "id": "DIST1",
1126                        "elements": ["ACOMP1:Part_PX", "ACOMP2:Part_NX"],
1127                        "distance": 8.0
1128                    },
1129                    "persistentData": {}, "enabled": true, "open": false
1130                }],
1131                "idCounter": 1
1132            }
1133        });
1134        let mut state = EngineState::new();
1135        state
1136            .set_history_json(&request.to_string())
1137            .expect("assembly history builds");
1138        state.resize(800.0, 600.0);
1139        state.camera.eye = [5.0, 1.0, 40.0];
1140        state.camera.target = [5.0, 1.0, 0.0];
1141        state.camera.up = [0.0, 1.0, 0.0];
1142        state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1143        // Re-bake at the test camera (the run-tail refresh used the boot camera).
1144        state.refresh_constraint_overlay();
1145        state
1146    }
1147
1148    fn display_bbox_lo_x(state: &EngineState, name: &str) -> f64 {
1149        let solid = state
1150            .scene
1151            .solids()
1152            .iter()
1153            .find(|s| s.name == name)
1154            .unwrap_or_else(|| panic!("{name} displayed"));
1155        solid.bbox.min[0]
1156    }
1157
1158    /// Label-click selection + the ACCORDION: clicking a chip SELECTS the
1159    /// constraint (pruned read + the chip's `selected` flag) and expands it as
1160    /// the ONE open row — any other open row collapses. Clear and deletion both
1161    /// drop the selection (the context bar's Clear / ✕ Delete paths).
1162    #[test]
1163    fn label_click_selects_and_accordion_keeps_one_row_open() {
1164        let mut state = assembly_engine();
1165        // A second constraint so the accordion has something to collapse.
1166        let second = state
1167            .assembly_add_constraint(
1168                "parallel",
1169                &serde_json::json!({"elements": ["ACOMP1:Part_PX", "ACOMP2:Part_NX"]})
1170                    .to_string(),
1171            )
1172            .expect("second constraint adds");
1173        state.assembly_set_constraint_open(&second, true).unwrap();
1174
1175        // Clicking DIST1's label selects it AND makes it the one open row.
1176        state.constraint_label_clicked("DIST1");
1177        assert_eq!(state.selected_constraint().as_deref(), Some("DIST1"));
1178        let session: serde_json::Value =
1179            serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1180        let open_ids: Vec<&str> = session["constraints"]
1181            .as_array()
1182            .unwrap()
1183            .iter()
1184            .filter(|c| c["open"] == true)
1185            .map(|c| c["inputParams"]["id"].as_str().unwrap())
1186            .collect();
1187        assert_eq!(open_ids, ["DIST1"], "accordion: exactly the clicked row is open");
1188
1189        // The label feed marks the selected chip (thicker border in the app).
1190        let labels: serde_json::Value =
1191            serde_json::from_str(&state.constraint_labels_json()).unwrap();
1192        assert_eq!(labels[0]["id"], "DIST1");
1193        assert_eq!(labels[0]["selected"], true);
1194
1195        // Clear drops the constraint selection (the context bar's Clear / Esc).
1196        assert!(state.clear_selection(), "constraint-only clear reports a change");
1197        assert_eq!(state.selected_constraint(), None);
1198        let labels: serde_json::Value =
1199            serde_json::from_str(&state.constraint_labels_json()).unwrap();
1200        assert_eq!(labels[0]["selected"], false);
1201
1202        // Re-select, then DELETE: the pruned read never yields a dead id.
1203        state.constraint_label_clicked("DIST1");
1204        state.assembly_remove_constraint("DIST1").unwrap();
1205        assert_eq!(state.selected_constraint(), None, "deleted → no lingering selection");
1206    }
1207
1208    #[test]
1209    fn live_distance_drag_commits_solves_retessellates_and_folds() {
1210        let mut state = assembly_engine();
1211
1212        // The run tail installed the session; the overlay cache reflects it.
1213        let overlays = state.constraint_overlays();
1214        assert_eq!(overlays.len(), 1, "one distance overlay: {overlays:?}");
1215        let overlay = &overlays[0];
1216        assert_eq!(overlay.id, "DIST1");
1217        assert_eq!(overlay.kind, ConstraintOverlayKind::Distance);
1218        assert!(overlay.draggable);
1219        assert_eq!(overlay.status, "satisfied", "{}", overlay.message);
1220        let ann = overlay.annotation.clone().expect("linear annotation");
1221        assert!((ann.value - 8.0).abs() < 1e-6, "measured separation 8: {}", ann.value);
1222
1223        // Labels: one status-colored row at the leader midpoint.
1224        let labels: serde_json::Value =
1225            serde_json::from_str(&state.constraint_labels_json()).unwrap();
1226        assert_eq!(labels[0]["id"], "DIST1");
1227        assert_eq!(labels[0]["draggable"], true);
1228        assert_eq!(labels[0]["text"], "\u{27FA} 8 mm");
1229        assert_eq!(labels[0]["icon"], "\u{27FA}");
1230        assert_eq!(labels[0]["type"], "distance");
1231        let green = crate::constraint_overlays::status_color("satisfied");
1232        assert!((labels[0]["color"][1].as_f64().unwrap() - green[1] as f64).abs() < 1e-6);
1233
1234        // Label hover highlights the referenced faces through the emphasis lane;
1235        // hover-end clears; the one-frame viewport-yield flag is set.
1236        state.constraint_hover("DIST1");
1237        assert!(state.emphasis.hovered_faces.contains("ACOMP1:Part_PX"));
1238        assert!(state.emphasis.hovered_faces.contains("ACOMP2:Part_NX"));
1239        assert!(state.take_constraint_label_hover());
1240        assert!(!state.take_constraint_label_hover(), "one-frame flag");
1241        state.constraint_hover_end();
1242        assert!(state.emphasis.hovered_faces.is_empty());
1243
1244        // Label click persists the panel-expansion flag in the session.
1245        state.constraint_label_clicked("DIST1");
1246        let session: serde_json::Value =
1247            serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1248        assert_eq!(session["constraints"][0]["open"], true);
1249
1250        // The free instance sits at x ∈ [10, 12] before the drag.
1251        assert!((display_bbox_lo_x(&state, "ACOMP2:Part") - 10.0).abs() < 1e-6);
1252
1253        // GRAB the leader and DRAG the arrow to 5 units: preview follows.
1254        let (mx, my, depth) = state.camera.project(ann.midpoint());
1255        assert!(depth > 0.0);
1256        assert!(state.constraint_drag_begin(mx, my), "leader grab");
1257        let dir = {
1258            let d = [
1259                ann.point_b[0] - ann.point_a[0],
1260                ann.point_b[1] - ann.point_a[1],
1261                ann.point_b[2] - ann.point_a[2],
1262            ];
1263            let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1264            [d[0] / n, d[1] / n, d[2] / n]
1265        };
1266        let target = [
1267            ann.point_a[0] + dir[0] * 5.0,
1268            ann.point_a[1] + dir[1] * 5.0,
1269            ann.point_a[2] + dir[2] * 5.0,
1270        ];
1271        let (tx, ty, tdepth) = state.camera.project(target);
1272        assert!(tdepth > 0.0);
1273        state.constraint_drag_to(tx, ty);
1274        let preview = state.constraint_drag.as_ref().unwrap().preview;
1275        assert!((preview - 5.0).abs() < 0.05, "preview ≈ 5, got {preview}");
1276
1277        // RELEASE: commits assembly_update_constraint_json → auto-solve.
1278        state.constraint_drag_release();
1279        assert!(state.constraint_drag.is_none());
1280
1281        // 1) The kernel session's target followed the drag.
1282        let session: serde_json::Value =
1283            serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1284        let committed = session["constraints"][0]["inputParams"]["distance"]
1285            .as_f64()
1286            .expect("numeric target");
1287        assert!((committed - preview).abs() < 1e-9, "committed {committed}");
1288
1289        // 2) The movedSolids display seam re-tessellated the re-posed instance
1290        //    IN PLACE: the display mesh actually moved (bbox, not just revision).
1291        assert!(
1292            (display_bbox_lo_x(&state, "ACOMP2:Part") - 7.0).abs() < 1e-6,
1293            "ACOMP2:Part re-meshed at its solved pose (lo.x = {})",
1294            display_bbox_lo_x(&state, "ACOMP2:Part")
1295        );
1296
1297        // 3) The pose-authority fold landed the solved pose + assembly block on
1298        //    the engine's history document.
1299        let request: serde_json::Value =
1300            serde_json::from_str(&state.history_request_json()).unwrap();
1301        let folded = &request["features"][1]["inputParams"]["transform"]["translate"];
1302        assert!(
1303            (folded[0].as_f64().unwrap() - 7.0).abs() < 1e-6,
1304            "folded ACOMP2 pose: {folded}"
1305        );
1306        assert_eq!(
1307            request["assembly"]["constraints"][0]["persistentData"]["status"],
1308            "satisfied"
1309        );
1310
1311        // 4) The overlay refreshed from the post-solve session.
1312        let overlay = &state.constraint_overlays()[0];
1313        assert!(
1314            (overlay.value.unwrap() - preview).abs() < 0.01,
1315            "overlay value follows the solve: {:?}",
1316            overlay.value
1317        );
1318
1319        // 5) Re-running the folded document is stable: no motion, overlay intact.
1320        let report = state.roll_to(1);
1321        assert!(!report.contains("\"error\""), "clean rerun: {report}");
1322        assert!((display_bbox_lo_x(&state, "ACOMP2:Part") - 7.0).abs() < 1e-6);
1323        assert_eq!(state.constraint_overlays().len(), 1);
1324
1325        // 6) NEGATIVE distance, end to end: the refreshed arrow is the
1326        //    perpendicular foot→anchor segment (foot on ACOMP1's PX plane at
1327        //    x=2, tip on ACOMP2's NX anchor at x=7); drag the tip THROUGH the
1328        //    base face to −3 and release — the drag's sign convention and the
1329        //    solver's DistancePlanePlane residual are the SAME signed offset,
1330        //    so the component lands on the opposite side (NX plane at x = −1).
1331        let ann = state.constraint_overlays()[0]
1332            .annotation
1333            .clone()
1334            .expect("refreshed annotation");
1335        assert!((ann.point_a[0] - 2.0).abs() < 1e-6, "foot on the base plane: {:?}", ann.point_a);
1336        assert!((ann.point_b[0] - 7.0).abs() < 1e-6, "tip at the other anchor: {:?}", ann.point_b);
1337        assert!((ann.axis[0] - 1.0).abs() < 1e-9, "base normal +X: {:?}", ann.axis);
1338        let (mx, my, depth) = state.camera.project(ann.midpoint());
1339        assert!(depth > 0.0);
1340        assert!(state.constraint_drag_begin(mx, my), "leader re-grab");
1341        let (tx, ty, tdepth) = state.camera.project([-1.0, 1.0, 1.0]);
1342        assert!(tdepth > 0.0);
1343        state.constraint_drag_to(tx, ty);
1344        let preview = state.constraint_drag.as_ref().unwrap().preview;
1345        assert!((preview + 3.0).abs() < 0.05, "signed preview ≈ −3, got {preview}");
1346        state.constraint_drag_release();
1347        let session: serde_json::Value =
1348            serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1349        let committed = session["constraints"][0]["inputParams"]["distance"]
1350            .as_f64()
1351            .expect("numeric target");
1352        assert!((committed - preview).abs() < 1e-9, "committed {committed}");
1353        assert!(
1354            (display_bbox_lo_x(&state, "ACOMP2:Part") + 1.0).abs() < 1e-6,
1355            "ACOMP2:Part re-meshed BEHIND the base face (lo.x = {})",
1356            display_bbox_lo_x(&state, "ACOMP2:Part")
1357        );
1358        let request: serde_json::Value =
1359            serde_json::from_str(&state.history_request_json()).unwrap();
1360        let folded = &request["features"][1]["inputParams"]["transform"]["translate"];
1361        assert!(
1362            (folded[0].as_f64().unwrap() + 1.0).abs() < 1e-6,
1363            "folded negative-side pose: {folded}"
1364        );
1365        assert_eq!(
1366            state.constraint_overlays()[0].label_text(),
1367            "\u{27FA} -3 mm",
1368            "signed label after the negative solve"
1369        );
1370    }
1371}