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