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