1use 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
42const CONSTRAINT_OVERLAY_GROUP: &str = "assembly-constraint-overlay";
46
47impl EngineState {
48 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 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 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 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 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 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 pub fn constraint_overlays(&self) -> &[ConstraintOverlay] {
148 &self.constraint_overlays
149 }
150
151 pub fn constraint_labels_json(&self) -> String {
159 let wpp = self.camera.world_per_pixel();
160 let rows: Vec<serde_json::Value> = self
161 .constraint_overlays
162 .iter()
163 .filter_map(|overlay| {
164 let world = overlay.label_anchor(wpp)?;
165 let color = status_color(&overlay.status);
166 Some(serde_json::json!({
167 "id": overlay.id,
168 "type": overlay.constraint_type,
169 "icon": overlay.icon,
170 "text": overlay.label_text(),
171 "status": overlay.status,
172 "message": overlay.message,
173 "color": [color[0], color[1], color[2]],
174 "world": world,
175 "draggable": overlay.draggable,
176 "selected": self.selected_constraint.as_deref() == Some(overlay.id.as_str()),
177 }))
178 })
179 .collect();
180 serde_json::Value::Array(rows).to_string()
181 }
182
183 pub fn constraint_hover(&mut self, id: &str) {
195 self.constraint_label_hover_active = true;
196 if self.constraint_hovered.as_deref() == Some(id) {
197 return;
198 }
199 let elements = match self.constraint_overlays.iter().find(|o| o.id == id) {
200 Some(overlay) => overlay.elements.clone(),
201 None => Vec::new(),
202 };
203 let mut solids: Vec<String> = Vec::new();
206 let mut faces: Vec<String> = Vec::new();
207 let mut edges: Vec<String> = Vec::new();
208 for element in &elements {
209 if let Some(at) = element.find('@') {
210 solids.push(element[..at].to_string());
212 continue;
213 }
214 if self.scene_has_face(element) {
215 faces.push(element.clone());
216 } else if self.scene_has_edge(element) {
217 edges.push(element.clone());
218 } else if self.scene.solids().iter().any(|s| s.name == *element) {
219 solids.push(element.clone());
220 } else {
221 let prefix = format!("{element}:");
224 solids.extend(
225 self.scene
226 .solids()
227 .iter()
228 .filter(|s| s.name.starts_with(&prefix))
229 .map(|s| s.name.clone()),
230 );
231 }
232 }
233 self.clear_hover();
234 self.emphasis.hovered_solids.extend(solids);
235 self.emphasis.hovered_faces.extend(faces);
236 self.emphasis.hovered_edges.extend(edges);
237 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
238 self.constraint_hovered = Some(id.to_string());
239 self.dirty = true;
240 }
241
242 pub fn constraint_hover_end(&mut self) {
245 if self.constraint_hovered.take().is_some() {
246 self.clear_hover();
247 }
248 }
249
250 pub fn take_constraint_label_hover(&mut self) -> bool {
255 std::mem::take(&mut self.constraint_label_hover_active)
256 }
257
258 pub fn constraint_label_clicked(&mut self, id: &str) {
266 if !live_constraint_exists(id) {
267 return;
268 }
269 self.selected_constraint = Some(id.to_string());
270 let _ = self.assembly_set_constraint_open(id, true);
271 self.dirty = true;
272 }
273
274 pub fn selected_constraint(&self) -> Option<String> {
278 let id = self.selected_constraint.as_deref()?;
279 live_constraint_exists(id).then(|| id.to_string())
280 }
281
282 pub fn constraint_deselect(&mut self) {
285 if self.selected_constraint.take().is_some() {
286 self.dirty = true;
287 }
288 }
289
290 fn scene_has_face(&self, name: &str) -> bool {
291 self.scene
292 .solids()
293 .iter()
294 .any(|solid| solid.faces.iter().any(|face| face.name == name))
295 }
296
297 fn scene_has_edge(&self, name: &str) -> bool {
298 self.scene
299 .solids()
300 .iter()
301 .any(|solid| solid.edges.iter().any(|edge| edge.name == name))
302 }
303
304 fn constraint_hit_regions(&self) -> Vec<(String, HitShape)> {
316 if !self.settings.show_constraint_graphics || self.sketch_mode() {
317 return Vec::new();
318 }
319 let wpp = self.camera.world_per_pixel();
320 let radius = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
321 let cam = &self.camera;
322 let mut out = Vec::new();
323 for overlay in &self.constraint_overlays {
324 if !overlay.draggable {
325 continue;
326 }
327 let Some(annotation) = &overlay.annotation else {
328 continue;
329 };
330 let shape = match overlay.kind {
331 ConstraintOverlayKind::Distance => {
332 segment_region(cam, annotation.point_a, annotation.point_b, radius)
333 }
334 ConstraintOverlayKind::Angle => point_region(
335 cam,
336 crate::feature_dimensions::arrow_handle_point(annotation, wpp),
337 radius,
338 ),
339 ConstraintOverlayKind::Leader => None,
340 };
341 if let Some(shape) = shape {
342 out.push((overlay.id.clone(), shape));
343 }
344 }
345 out
346 }
347
348 pub fn constraint_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
353 let p = [x as f32, y as f32];
354 let mut best: Option<(f32, String)> = None;
355 for (id, shape) in self.constraint_hit_regions() {
356 let d = shape.spine_distance(p);
357 if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
358 best = Some((d, id));
359 }
360 }
361 best.map(|(_, id)| id)
362 }
363
364 pub fn constraint_drag_begin(&mut self, x: f64, y: f64) -> bool {
368 let Some(id) = self.constraint_arrow_pick(x, y) else {
369 return false;
370 };
371 let Some(overlay) = self.constraint_overlays.iter().find(|o| o.id == id) else {
372 return false;
373 };
374 let Some(field) = overlay.field_key() else {
375 return false;
376 };
377 let preview = overlay
378 .annotation
379 .as_ref()
380 .map(|a| a.value)
381 .or(overlay.value)
382 .unwrap_or(0.0);
383 self.constraint_drag = Some(ConstraintDrag {
384 id,
385 field,
386 params: overlay.input_params.clone(),
387 preview,
388 });
389 true
390 }
391
392 pub fn constraint_drag_to(&mut self, x: f64, y: f64) {
399 let Some(id) = self.constraint_drag.as_ref().map(|d| d.id.clone()) else {
400 return;
401 };
402 let Some(overlay) = self.constraint_overlays.iter().find(|o| o.id == id) else {
403 return;
404 };
405 let Some(annotation) = overlay.annotation.clone() else {
406 return;
407 };
408 let kind = overlay.kind;
409 let new_value = match kind {
410 ConstraintOverlayKind::Distance => {
411 let a = annotation.point_a;
412 let ray = self.camera.pick_ray(x, y);
413 let rd = ray.dir;
414 let rn = (rd[0] * rd[0] + rd[1] * rd[1] + rd[2] * rd[2]).sqrt();
415 if rn < 1e-12 {
416 return;
417 }
418 let ray_dir = [rd[0] / rn, rd[1] / rn, rd[2] / rn];
419 let n = annotation.axis;
420 let raw = if n[0] * n[0] + n[1] * n[1] + n[2] * n[2] > 0.5 {
421 let Some(t) = closest_t_on_axis(a, n, ray.origin, ray_dir) else {
431 return;
432 };
433 t
434 } else {
435 let b = annotation.point_b;
441 let axis = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
442 let len =
443 (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
444 if len < 1e-9 {
445 return;
446 }
447 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
448 let Some(t) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
449 return;
450 };
451 let ratio = if annotation.value.abs() > 1e-9 {
452 annotation.value / len
453 } else {
454 1.0
455 };
456 (t * ratio).max(0.0)
457 };
458 (raw * 1e4).round() / 1e4
459 }
460 ConstraintOverlayKind::Angle => {
461 let Some(degrees) = self.angular_drag_degrees(&annotation, x, y) else {
462 return;
463 };
464 interior_degrees(degrees)
465 }
466 ConstraintOverlayKind::Leader => return,
467 };
468 if let Some(drag) = self.constraint_drag.as_mut() {
469 drag.preview = new_value;
470 }
471 self.apply_constraint_drag_preview();
472 self.bake_constraint_overlay();
473 }
474
475 fn apply_constraint_drag_preview(&mut self) {
481 let Some(drag) = self.constraint_drag.as_ref() else {
482 return;
483 };
484 let preview = drag.preview;
485 let id = drag.id.clone();
486 let Some(overlay) = self.constraint_overlays.iter_mut().find(|o| o.id == id) else {
487 return;
488 };
489 let kind = overlay.kind;
490 overlay.value = Some(preview);
491 if let Some(annotation) = overlay.annotation.as_mut() {
492 match kind {
493 ConstraintOverlayKind::Distance => {
494 let a = annotation.point_a;
495 let n = annotation.axis;
496 if n[0] * n[0] + n[1] * n[1] + n[2] * n[2] > 0.5 {
497 annotation.point_b = [
503 a[0] + n[0] * preview,
504 a[1] + n[1] * preview,
505 a[2] + n[2] * preview,
506 ];
507 } else {
508 let b = annotation.point_b;
509 let axis = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
510 let len = (axis[0] * axis[0]
511 + axis[1] * axis[1]
512 + axis[2] * axis[2])
513 .sqrt();
514 if len > 1e-9 && annotation.value.abs() > 1e-9 {
515 let world_len = preview * (len / annotation.value);
518 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
519 annotation.point_b = [
520 a[0] + dir[0] * world_len,
521 a[1] + dir[1] * world_len,
522 a[2] + dir[2] * world_len,
523 ];
524 }
525 }
526 annotation.value = preview;
527 }
528 _ => {
529 annotation.value = preview;
530 }
531 }
532 }
533 }
534
535 pub fn constraint_drag_commit_payload(&self) -> Option<(String, String)> {
546 let drag = self.constraint_drag.as_ref()?;
547 let mut params = drag.params.clone();
548 if !params.is_object() {
549 params = serde_json::json!({});
550 }
551 let exterior = drag.field == "angle"
552 && params
553 .get("exteriorAngle")
554 .and_then(|v| v.as_bool())
555 .unwrap_or(false);
556 let committed = if exterior {
557 180.0 - drag.preview
558 } else {
559 drag.preview
560 };
561 let object = params.as_object_mut().expect("object ensured above");
562 object.insert(drag.field.to_string(), serde_json::json!(committed));
563 object.insert("id".to_string(), serde_json::json!(drag.id));
564 Some((drag.id.clone(), params.to_string()))
565 }
566
567 pub fn constraint_drag_release(&mut self) {
576 let Some((id, params_json)) = self.constraint_drag_commit_payload() else {
577 self.constraint_drag = None;
578 return;
579 };
580 self.constraint_drag = None;
581 if !live_constraint_exists(&id) {
582 self.notices
583 .push(format!("Constraint {id}: no live assembly session to update"));
584 self.refresh_constraint_overlay();
585 return;
586 }
587 match brep_kernel::assembly_update_constraint_json(&id, ¶ms_json) {
588 Ok(report) => {
589 self.consume_assembly_moved_solids(&report);
590 self.adopt_assembly_fold();
591 }
592 Err(error) => {
595 let message = error
596 .as_string()
597 .unwrap_or_else(|| "constraint update failed".to_string());
598 self.notices.push(format!("Constraint {id}: {message}"));
599 }
600 }
601 self.refresh_constraint_overlay();
602 self.dirty = true;
603 }
604
605 pub fn consume_assembly_moved_solids(&mut self, report_json: &str) {
616 let Ok(report) = serde_json::from_str::<serde_json::Value>(report_json) else {
617 return;
618 };
619 let Some(moved) = report.get("movedSolids").and_then(|v| v.as_array()) else {
620 return;
621 };
622 let lod = if self.settings.lod_factor.is_finite() && self.settings.lod_factor > 0.0 {
623 self.settings.lod_factor
624 } else {
625 1.0
626 };
627 let mut changed = false;
628 for entry in moved {
629 let Some(name) = entry.get("name").and_then(|v| v.as_str()) else {
630 continue;
631 };
632 let Some(handle) = entry
633 .get("handle")
634 .and_then(|v| v.as_u64())
635 .map(|h| h as u32)
636 else {
637 continue;
638 };
639 match brep_kernel::display_payload_handle_native(handle, lod) {
640 Ok(payload) => {
641 let mut display = crate::scene::solid_display_from_payload(name, payload);
642 display.source_handle = handle;
643 if let Some(old) = self.scene.solids().iter().find(|s| s.name == name) {
644 display.visible = old.visible;
645 }
646 self.scene.insert_solid(display);
647 changed = true;
648 }
649 Err(error) => self
650 .notices
651 .push(format!("re-tessellate moved solid {name}: {error}")),
652 }
653 }
654 if changed {
655 self.sync_colors_from_metadata();
660 self.dirty = true;
661 }
662 }
663
664 fn adopt_assembly_fold(&mut self) {
673 let document = self.history.request_json();
674 match brep_kernel::assembly_apply_document_json(&document) {
675 Ok(folded) => {
676 if let Err(error) = self.history.adopt_folded_request(&folded) {
677 self.notices.push(format!("assembly fold: {error}"));
678 }
679 }
680 Err(error) => {
682 let message = error
683 .as_string()
684 .unwrap_or_else(|| "assembly document fold failed".to_string());
685 self.notices.push(format!("assembly fold: {message}"));
686 }
687 }
688 }
689}
690
691fn live_constraint_exists(id: &str) -> bool {
695 serde_json::from_str::<serde_json::Value>(&brep_kernel::assembly_state_json())
696 .ok()
697 .and_then(|state| {
698 state.get("constraints").and_then(|list| {
699 list.as_array().map(|entries| {
700 entries.iter().any(|entry| {
701 entry
702 .get("inputParams")
703 .and_then(|p| p.get("id"))
704 .and_then(|v| v.as_str())
705 == Some(id)
706 })
707 })
708 })
709 })
710 .unwrap_or(false)
711}
712
713fn interior_degrees(degrees: f64) -> f64 {
718 let folded = degrees.abs() % 360.0;
719 let interior = if folded > 180.0 { 360.0 - folded } else { folded };
720 if interior < 0.5 {
721 0.0
722 } else {
723 interior
724 }
725}
726
727#[cfg(test)]
728mod constraint_overlay_tests {
729 use super::*;
730
731 fn ortho_engine(target: [f64; 3]) -> EngineState {
734 let mut state = EngineState::new();
735 state.resize(800.0, 600.0);
736 state.camera.eye = [target[0], target[1], target[2] + 40.0];
737 state.camera.target = target;
738 state.camera.up = [0.0, 1.0, 0.0];
739 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
740 state
741 }
742
743 fn distance_rows() -> String {
744 serde_json::json!([{
745 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
746 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
747 "directions": [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]],
748 "value": 8.0, "unit": "mm",
749 }])
750 .to_string()
751 }
752
753 fn state_json(params: serde_json::Value) -> String {
754 serde_json::json!({
755 "constraints": [{ "type": "distance", "inputParams": params,
756 "persistentData": {}, "enabled": true, "open": false }],
757 "idCounter": 1
758 })
759 .to_string()
760 }
761
762 #[test]
763 fn canned_distance_pick_drag_preview_and_commit_payload() {
764 let mut state = ortho_engine([4.0, 0.0, 0.0]);
765 state.refresh_constraint_overlay_from(
766 &distance_rows(),
767 &state_json(serde_json::json!({
768 "id": "DIST1", "elements": ["A_PX", "B_NX"], "distance": 8.0
769 })),
770 );
771 assert_eq!(state.constraint_overlays().len(), 1);
772 let ann = state.constraint_overlays()[0]
773 .annotation
774 .clone()
775 .expect("linear annotation");
776
777 let mid = ann.midpoint();
779 let (mx, my, depth) = state.camera.project(mid);
780 assert!(depth > 0.0);
781 assert_eq!(state.constraint_arrow_pick(mx, my).as_deref(), Some("DIST1"));
782 assert!(state.constraint_arrow_pick(5.0, 5.0).is_none(), "empty corner grabs nothing");
783
784 assert!(state.constraint_drag_begin(mx, my));
787 let target_world = [
788 ann.point_a[0] + 5.0,
789 ann.point_a[1],
790 ann.point_a[2],
791 ];
792 let (tx, ty, tdepth) = state.camera.project(target_world);
793 assert!(tdepth > 0.0);
794 state.constraint_drag_to(tx, ty);
795 let drag = state.constraint_drag.clone().expect("drag live");
796 assert!((drag.preview - 5.0).abs() < 0.05, "preview ≈ 5, got {}", drag.preview);
797 assert!(
799 (state.constraint_overlays()[0].value.unwrap() - drag.preview).abs() < 1e-9,
800 "label value tracks the preview"
801 );
802 let previewed = state.constraint_overlays()[0].annotation.as_ref().unwrap();
803 assert!(
804 (previewed.point_b[0] - 5.0).abs() < 0.05,
805 "arrow tip tracks the preview: {:?}",
806 previewed.point_b
807 );
808
809 let (id, params_json) = state.constraint_drag_commit_payload().expect("payload");
811 assert_eq!(id, "DIST1");
812 let params: serde_json::Value = serde_json::from_str(¶ms_json).unwrap();
813 assert!((params["distance"].as_f64().unwrap() - drag.preview).abs() < 1e-9);
814 assert_eq!(params["elements"][0], "A_PX", "untouched params ride along");
815 assert_eq!(params["id"], "DIST1");
816
817 state.constraint_drag_release();
821 assert!(state.constraint_drag.is_none());
822 let notices = state.take_notices();
823 assert!(
824 notices.iter().any(|n| n.contains("DIST1")),
825 "refused commit surfaces a notice: {notices:?}"
826 );
827 }
828
829 #[test]
830 fn plane_based_distance_drag_is_signed_and_hit_matches_draw() {
831 let mut state = ortho_engine([3.0, 0.0, 0.0]);
837 let r = 1.0 / 2.0_f64.sqrt();
838 let s = 8.0 / 2.0_f64.sqrt(); let rows = serde_json::json!([{
840 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
841 "anchors": [[0.0, 0.0, 0.0], [7.0, 1.0, 0.0]],
842 "directions": [[r, r, 0.0], null],
843 "geoms": ["plane", "point"],
844 "value": s, "unit": "mm",
845 }])
846 .to_string();
847 state.refresh_constraint_overlay_from(
848 &rows,
849 &state_json(serde_json::json!({
850 "id": "DIST1", "elements": ["A_TILT", "B_PT"], "distance": s
851 })),
852 );
853 let ann = state.constraint_overlays()[0]
854 .annotation
855 .clone()
856 .expect("linear annotation");
857 let foot = [3.0, -3.0, 0.0];
859 for i in 0..3 {
860 assert!((ann.point_a[i] - foot[i]).abs() < 1e-9, "foot: {:?}", ann.point_a);
861 assert!((ann.axis[i] - [r, r, 0.0][i]).abs() < 1e-9, "axis: {:?}", ann.axis);
862 }
863 assert_eq!(ann.point_b, [7.0, 1.0, 0.0], "tip at the other anchor");
864
865 let regions = state.constraint_hit_regions();
868 assert_eq!(regions.len(), 1);
869 assert_eq!(regions[0].0, "DIST1");
870 let HitShape::Capsule { a, b, r: rad } = regions[0].1 else {
871 panic!("distance handle is a capsule: {:?}", regions[0].1);
872 };
873 let (ax, ay, _) = state.camera.project(ann.point_a);
874 let (bx, by, _) = state.camera.project(ann.point_b);
875 assert!((a[0] as f64 - ax).abs() < 1e-3 && (a[1] as f64 - ay).abs() < 1e-3);
876 assert!((b[0] as f64 - bx).abs() < 1e-3 && (b[1] as f64 - by).abs() < 1e-3);
877 assert!(rad as f64 >= crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX - 1e-6);
878 let (nx, ny, _) = state.camera.project([3.5, 0.5, 0.0]);
882 assert!(state.constraint_arrow_pick(nx, ny).is_none(), "stale chord must miss");
883
884 let (mx, my, depth) = state.camera.project(ann.midpoint());
888 assert!(depth > 0.0);
889 assert!(state.constraint_drag_begin(mx, my));
890 let target = [foot[0] - r * 3.0, foot[1] - r * 3.0, 0.0];
891 let (tx, ty, tdepth) = state.camera.project(target);
892 assert!(tdepth > 0.0);
893 state.constraint_drag_to(tx, ty);
894 let preview = state.constraint_drag.as_ref().unwrap().preview;
895 assert!((preview + 3.0).abs() < 0.01, "signed preview ≈ −3, got {preview}");
896 let previewed = state.constraint_overlays()[0].annotation.as_ref().unwrap();
897 for i in 0..3 {
898 assert!(
899 (previewed.point_b[i] - target[i]).abs() < 0.01,
900 "tip on the far side of the base face: {:?}",
901 previewed.point_b
902 );
903 }
904 assert!((previewed.value - preview).abs() < 1e-9);
905
906 let (id, params_json) = state.constraint_drag_commit_payload().expect("payload");
908 assert_eq!(id, "DIST1");
909 let params: serde_json::Value = serde_json::from_str(¶ms_json).unwrap();
910 assert!((params["distance"].as_f64().unwrap() - preview).abs() < 1e-9);
911 assert!(params["distance"].as_f64().unwrap() < 0.0);
912 }
913
914 #[test]
915 fn expression_valued_distance_param_is_not_grabbable() {
916 let mut state = ortho_engine([4.0, 0.0, 0.0]);
917 state.refresh_constraint_overlay_from(
918 &distance_rows(),
919 &state_json(serde_json::json!({
920 "id": "DIST1", "elements": [], "distance": "gap * 2"
921 })),
922 );
923 assert_eq!(state.constraint_overlays().len(), 1);
924 assert!(!state.constraint_overlays()[0].draggable);
925 let ann = state.constraint_overlays()[0].annotation.clone().unwrap();
927 let (mx, my, _) = state.camera.project(ann.midpoint());
928 assert!(state.constraint_arrow_pick(mx, my).is_none());
929 assert!(!state.constraint_drag_begin(mx, my));
930 }
931
932 #[test]
933 fn angle_arc_drag_maps_to_interior_degrees() {
934 let mut state = ortho_engine([0.0, 0.0, 0.0]);
937 let rows = serde_json::json!([{
938 "id": "ANGL1", "type": "angle", "status": "adjusted", "message": "",
939 "anchors": [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
940 "directions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
941 "value": 90.0, "unit": "deg",
942 }])
943 .to_string();
944 let constraints = serde_json::json!({
945 "constraints": [{ "type": "angle",
946 "inputParams": { "id": "ANGL1", "elements": [], "angle": 90.0 },
947 "persistentData": {}, "enabled": true, "open": false }],
948 "idCounter": 1
949 })
950 .to_string();
951 state.refresh_constraint_overlay_from(&rows, &constraints);
952 let ann = state.constraint_overlays()[0]
953 .annotation
954 .clone()
955 .expect("angular annotation");
956 let wpp = state.world_per_pixel();
957
958 let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
960 let (hx, hy, hdepth) = state.camera.project(handle);
961 assert!(hdepth > 0.0);
962 assert_eq!(state.constraint_arrow_pick(hx, hy).as_deref(), Some("ANGL1"));
963 assert!(state.constraint_drag_begin(hx, hy));
964
965 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * wpp;
968 let dir = crate::feature_dimensions::rotate_about_axis(
969 ann.ref_dir,
970 ann.axis,
971 135.0_f64.to_radians(),
972 );
973 let world = [
974 ann.center[0] + dir[0] * radius,
975 ann.center[1] + dir[1] * radius,
976 ann.center[2] + dir[2] * radius,
977 ];
978 let (sx, sy, sdepth) = state.camera.project(world);
979 assert!(sdepth > 0.0);
980 state.constraint_drag_to(sx, sy);
981 let preview = state.constraint_drag.as_ref().unwrap().preview;
982 assert!((preview - 135.0).abs() < 2.0, "interior sweep ≈ 135, got {preview}");
983 let (_, params_json) = state.constraint_drag_commit_payload().expect("payload");
984 let params: serde_json::Value = serde_json::from_str(¶ms_json).unwrap();
985 assert!((params["angle"].as_f64().unwrap() - preview).abs() < 1e-9);
986
987 let dir = crate::feature_dimensions::rotate_about_axis(
990 ann.ref_dir,
991 ann.axis,
992 (-30.0_f64).to_radians(),
993 );
994 let world = [
995 ann.center[0] + dir[0] * radius,
996 ann.center[1] + dir[1] * radius,
997 ann.center[2] + dir[2] * radius,
998 ];
999 let (sx, sy, _) = state.camera.project(world);
1000 state.constraint_drag_to(sx, sy);
1001 let preview = state.constraint_drag.as_ref().unwrap().preview;
1002 assert!((preview - 30.0).abs() < 2.0, "folded interior ≈ 30, got {preview}");
1003 }
1004
1005 #[test]
1006 fn exterior_angle_commit_remaps_the_display_value() {
1007 let mut state = ortho_engine([0.0, 0.0, 0.0]);
1011 state.constraint_drag = Some(ConstraintDrag {
1012 id: "ANGL1".to_string(),
1013 field: "angle",
1014 params: serde_json::json!({
1015 "id": "ANGL1", "elements": [], "angle": 120.0, "exteriorAngle": true
1016 }),
1017 preview: 30.0,
1018 });
1019 let (_, params_json) = state.constraint_drag_commit_payload().expect("payload");
1020 let params: serde_json::Value = serde_json::from_str(¶ms_json).unwrap();
1021 assert!((params["angle"].as_f64().unwrap() - 150.0).abs() < 1e-9);
1022 state.constraint_drag.as_mut().unwrap().params["exteriorAngle"] =
1025 serde_json::json!(false);
1026 let (_, params_json) = state.constraint_drag_commit_payload().unwrap();
1027 let params: serde_json::Value = serde_json::from_str(¶ms_json).unwrap();
1028 assert!((params["angle"].as_f64().unwrap() - 30.0).abs() < 1e-9);
1029 state.constraint_drag = Some(ConstraintDrag {
1030 id: "DIST1".to_string(),
1031 field: "distance",
1032 params: serde_json::json!({ "id": "DIST1", "exteriorAngle": true }),
1033 preview: 4.0,
1034 });
1035 let (_, params_json) = state.constraint_drag_commit_payload().unwrap();
1036 let params: serde_json::Value = serde_json::from_str(¶ms_json).unwrap();
1037 assert!((params["distance"].as_f64().unwrap() - 4.0).abs() < 1e-9);
1038 }
1039
1040 #[test]
1041 fn interior_degrees_folds_the_sweep_domain() {
1042 assert_eq!(interior_degrees(0.0), 0.0);
1043 assert_eq!(interior_degrees(0.3), 0.0, "sub-snap residue lands on exact 0");
1044 assert!((interior_degrees(90.0) - 90.0).abs() < 1e-12);
1045 assert!((interior_degrees(-90.0) - 90.0).abs() < 1e-12);
1046 assert!((interior_degrees(180.0) - 180.0).abs() < 1e-12);
1047 assert!((interior_degrees(210.0) - 150.0).abs() < 1e-12);
1048 assert!((interior_degrees(-359.0) - 1.0).abs() < 1e-12);
1049 }
1050
1051 #[test]
1052 fn ensure_current_hides_in_sketch_mode_and_rebakes_on_zoom() {
1053 let mut state = ortho_engine([4.0, 0.0, 0.0]);
1054 state.refresh_constraint_overlay_from(
1055 &distance_rows(),
1056 &state_json(serde_json::json!({ "id": "DIST1", "elements": [], "distance": 8.0 })),
1057 );
1058 assert_eq!(state.constraint_overlays().len(), 1);
1059
1060 state.settings.show_constraint_graphics = false;
1062 state.ensure_constraint_overlay_current();
1063 assert!(state.constraint_overlays().is_empty(), "hidden while toggled off");
1064
1065 state.settings.show_constraint_graphics = true;
1068 state.ensure_constraint_overlay_current();
1069 assert!(state.constraint_overlays().is_empty(), "no live session to restore from");
1070
1071 state.refresh_constraint_overlay_from(
1074 &distance_rows(),
1075 &state_json(serde_json::json!({ "id": "DIST1", "elements": [], "distance": 8.0 })),
1076 );
1077 state.dirty = false;
1078 state.ensure_constraint_overlay_current();
1079 assert!(!state.dirty, "a quiet frame must not re-bake (no dirty loop)");
1080 state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1081 state.ensure_constraint_overlay_current();
1082 assert!(state.dirty, "a zoom change re-bakes the screen-constant sizing");
1083 }
1084
1085 fn assembly_engine() -> EngineState {
1096 let part_doc = serde_json::json!({
1097 "expressions": "", "configurator": {},
1098 "features": [
1099 { "type": "P.CU", "inputParams": { "id": "Part", "sizeX": 2.0, "sizeY": 2.0, "sizeZ": 2.0 } }
1100 ]
1101 });
1102 let request = serde_json::json!({
1103 "expressions": "", "configurator": {},
1104 "partsLibrary": {
1105 "lane-g-part": {
1106 "sourceKey": "lane-g-part",
1107 "sourceSignature": "lane-g-sig",
1108 "document": part_doc,
1109 }
1110 },
1111 "features": [
1112 { "type": "ACOMP", "inputParams": {
1113 "id": "ACOMP1", "partName": "lane-g-part",
1114 "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
1115 "isFixed": true }, "persistentData": {} },
1116 { "type": "ACOMP", "inputParams": {
1117 "id": "ACOMP2", "partName": "lane-g-part",
1118 "transform": { "translate": [10, 0, 0], "rotateEulerDeg": [0, 0, 0] } },
1119 "persistentData": {} }
1120 ],
1121 "assembly": {
1122 "constraints": [{
1123 "type": "distance",
1124 "inputParams": {
1125 "id": "DIST1",
1126 "elements": ["ACOMP1:Part_PX", "ACOMP2:Part_NX"],
1127 "distance": 8.0
1128 },
1129 "persistentData": {}, "enabled": true, "open": false
1130 }],
1131 "idCounter": 1
1132 }
1133 });
1134 let mut state = EngineState::new();
1135 state
1136 .set_history_json(&request.to_string())
1137 .expect("assembly history builds");
1138 state.resize(800.0, 600.0);
1139 state.camera.eye = [5.0, 1.0, 40.0];
1140 state.camera.target = [5.0, 1.0, 0.0];
1141 state.camera.up = [0.0, 1.0, 0.0];
1142 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1143 state.refresh_constraint_overlay();
1145 state
1146 }
1147
1148 fn display_bbox_lo_x(state: &EngineState, name: &str) -> f64 {
1149 let solid = state
1150 .scene
1151 .solids()
1152 .iter()
1153 .find(|s| s.name == name)
1154 .unwrap_or_else(|| panic!("{name} displayed"));
1155 solid.bbox.min[0]
1156 }
1157
1158 #[test]
1163 fn label_click_selects_and_accordion_keeps_one_row_open() {
1164 let mut state = assembly_engine();
1165 let second = state
1167 .assembly_add_constraint(
1168 "parallel",
1169 &serde_json::json!({"elements": ["ACOMP1:Part_PX", "ACOMP2:Part_NX"]})
1170 .to_string(),
1171 )
1172 .expect("second constraint adds");
1173 state.assembly_set_constraint_open(&second, true).unwrap();
1174
1175 state.constraint_label_clicked("DIST1");
1177 assert_eq!(state.selected_constraint().as_deref(), Some("DIST1"));
1178 let session: serde_json::Value =
1179 serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1180 let open_ids: Vec<&str> = session["constraints"]
1181 .as_array()
1182 .unwrap()
1183 .iter()
1184 .filter(|c| c["open"] == true)
1185 .map(|c| c["inputParams"]["id"].as_str().unwrap())
1186 .collect();
1187 assert_eq!(open_ids, ["DIST1"], "accordion: exactly the clicked row is open");
1188
1189 let labels: serde_json::Value =
1191 serde_json::from_str(&state.constraint_labels_json()).unwrap();
1192 assert_eq!(labels[0]["id"], "DIST1");
1193 assert_eq!(labels[0]["selected"], true);
1194
1195 assert!(state.clear_selection(), "constraint-only clear reports a change");
1197 assert_eq!(state.selected_constraint(), None);
1198 let labels: serde_json::Value =
1199 serde_json::from_str(&state.constraint_labels_json()).unwrap();
1200 assert_eq!(labels[0]["selected"], false);
1201
1202 state.constraint_label_clicked("DIST1");
1204 state.assembly_remove_constraint("DIST1").unwrap();
1205 assert_eq!(state.selected_constraint(), None, "deleted → no lingering selection");
1206 }
1207
1208 #[test]
1209 fn live_distance_drag_commits_solves_retessellates_and_folds() {
1210 let mut state = assembly_engine();
1211
1212 let overlays = state.constraint_overlays();
1214 assert_eq!(overlays.len(), 1, "one distance overlay: {overlays:?}");
1215 let overlay = &overlays[0];
1216 assert_eq!(overlay.id, "DIST1");
1217 assert_eq!(overlay.kind, ConstraintOverlayKind::Distance);
1218 assert!(overlay.draggable);
1219 assert_eq!(overlay.status, "satisfied", "{}", overlay.message);
1220 let ann = overlay.annotation.clone().expect("linear annotation");
1221 assert!((ann.value - 8.0).abs() < 1e-6, "measured separation 8: {}", ann.value);
1222
1223 let labels: serde_json::Value =
1225 serde_json::from_str(&state.constraint_labels_json()).unwrap();
1226 assert_eq!(labels[0]["id"], "DIST1");
1227 assert_eq!(labels[0]["draggable"], true);
1228 assert_eq!(labels[0]["text"], "\u{27FA} 8 mm");
1229 assert_eq!(labels[0]["icon"], "\u{27FA}");
1230 assert_eq!(labels[0]["type"], "distance");
1231 let green = crate::constraint_overlays::status_color("satisfied");
1232 assert!((labels[0]["color"][1].as_f64().unwrap() - green[1] as f64).abs() < 1e-6);
1233
1234 state.constraint_hover("DIST1");
1237 assert!(state.emphasis.hovered_faces.contains("ACOMP1:Part_PX"));
1238 assert!(state.emphasis.hovered_faces.contains("ACOMP2:Part_NX"));
1239 assert!(state.take_constraint_label_hover());
1240 assert!(!state.take_constraint_label_hover(), "one-frame flag");
1241 state.constraint_hover_end();
1242 assert!(state.emphasis.hovered_faces.is_empty());
1243
1244 state.constraint_label_clicked("DIST1");
1246 let session: serde_json::Value =
1247 serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1248 assert_eq!(session["constraints"][0]["open"], true);
1249
1250 assert!((display_bbox_lo_x(&state, "ACOMP2:Part") - 10.0).abs() < 1e-6);
1252
1253 let (mx, my, depth) = state.camera.project(ann.midpoint());
1255 assert!(depth > 0.0);
1256 assert!(state.constraint_drag_begin(mx, my), "leader grab");
1257 let dir = {
1258 let d = [
1259 ann.point_b[0] - ann.point_a[0],
1260 ann.point_b[1] - ann.point_a[1],
1261 ann.point_b[2] - ann.point_a[2],
1262 ];
1263 let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1264 [d[0] / n, d[1] / n, d[2] / n]
1265 };
1266 let target = [
1267 ann.point_a[0] + dir[0] * 5.0,
1268 ann.point_a[1] + dir[1] * 5.0,
1269 ann.point_a[2] + dir[2] * 5.0,
1270 ];
1271 let (tx, ty, tdepth) = state.camera.project(target);
1272 assert!(tdepth > 0.0);
1273 state.constraint_drag_to(tx, ty);
1274 let preview = state.constraint_drag.as_ref().unwrap().preview;
1275 assert!((preview - 5.0).abs() < 0.05, "preview ≈ 5, got {preview}");
1276
1277 state.constraint_drag_release();
1279 assert!(state.constraint_drag.is_none());
1280
1281 let session: serde_json::Value =
1283 serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1284 let committed = session["constraints"][0]["inputParams"]["distance"]
1285 .as_f64()
1286 .expect("numeric target");
1287 assert!((committed - preview).abs() < 1e-9, "committed {committed}");
1288
1289 assert!(
1292 (display_bbox_lo_x(&state, "ACOMP2:Part") - 7.0).abs() < 1e-6,
1293 "ACOMP2:Part re-meshed at its solved pose (lo.x = {})",
1294 display_bbox_lo_x(&state, "ACOMP2:Part")
1295 );
1296
1297 let request: serde_json::Value =
1300 serde_json::from_str(&state.history_request_json()).unwrap();
1301 let folded = &request["features"][1]["inputParams"]["transform"]["translate"];
1302 assert!(
1303 (folded[0].as_f64().unwrap() - 7.0).abs() < 1e-6,
1304 "folded ACOMP2 pose: {folded}"
1305 );
1306 assert_eq!(
1307 request["assembly"]["constraints"][0]["persistentData"]["status"],
1308 "satisfied"
1309 );
1310
1311 let overlay = &state.constraint_overlays()[0];
1313 assert!(
1314 (overlay.value.unwrap() - preview).abs() < 0.01,
1315 "overlay value follows the solve: {:?}",
1316 overlay.value
1317 );
1318
1319 let report = state.roll_to(1);
1321 assert!(!report.contains("\"error\""), "clean rerun: {report}");
1322 assert!((display_bbox_lo_x(&state, "ACOMP2:Part") - 7.0).abs() < 1e-6);
1323 assert_eq!(state.constraint_overlays().len(), 1);
1324
1325 let ann = state.constraint_overlays()[0]
1332 .annotation
1333 .clone()
1334 .expect("refreshed annotation");
1335 assert!((ann.point_a[0] - 2.0).abs() < 1e-6, "foot on the base plane: {:?}", ann.point_a);
1336 assert!((ann.point_b[0] - 7.0).abs() < 1e-6, "tip at the other anchor: {:?}", ann.point_b);
1337 assert!((ann.axis[0] - 1.0).abs() < 1e-9, "base normal +X: {:?}", ann.axis);
1338 let (mx, my, depth) = state.camera.project(ann.midpoint());
1339 assert!(depth > 0.0);
1340 assert!(state.constraint_drag_begin(mx, my), "leader re-grab");
1341 let (tx, ty, tdepth) = state.camera.project([-1.0, 1.0, 1.0]);
1342 assert!(tdepth > 0.0);
1343 state.constraint_drag_to(tx, ty);
1344 let preview = state.constraint_drag.as_ref().unwrap().preview;
1345 assert!((preview + 3.0).abs() < 0.05, "signed preview ≈ −3, got {preview}");
1346 state.constraint_drag_release();
1347 let session: serde_json::Value =
1348 serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap();
1349 let committed = session["constraints"][0]["inputParams"]["distance"]
1350 .as_f64()
1351 .expect("numeric target");
1352 assert!((committed - preview).abs() < 1e-9, "committed {committed}");
1353 assert!(
1354 (display_bbox_lo_x(&state, "ACOMP2:Part") + 1.0).abs() < 1e-6,
1355 "ACOMP2:Part re-meshed BEHIND the base face (lo.x = {})",
1356 display_bbox_lo_x(&state, "ACOMP2:Part")
1357 );
1358 let request: serde_json::Value =
1359 serde_json::from_str(&state.history_request_json()).unwrap();
1360 let folded = &request["features"][1]["inputParams"]["transform"]["translate"];
1361 assert!(
1362 (folded[0].as_f64().unwrap() + 1.0).abs() < 1e-6,
1363 "folded negative-side pose: {folded}"
1364 );
1365 assert_eq!(
1366 state.constraint_overlays()[0].label_text(),
1367 "\u{27FA} -3 mm",
1368 "signed label after the negative solve"
1369 );
1370 }
1371}