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 {
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 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 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 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 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 pub fn constraint_hover_end(&mut self) {
242 if self.constraint_hovered.take().is_some() {
243 self.clear_hover();
244 }
245 }
246
247 pub fn take_constraint_label_hover(&mut self) -> bool {
252 std::mem::take(&mut self.constraint_label_hover_active)
253 }
254
255 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 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 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 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 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 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 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 let Some(t) = closest_t_on_axis(a, n, ray.origin, ray_dir) else {
428 return;
429 };
430 t
431 } else {
432 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 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 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 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 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 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, ¶ms_json) {
585 Ok(report) => {
586 self.consume_assembly_moved_solids(&report);
587 self.adopt_assembly_fold();
588 }
589 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 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 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 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
684fn 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
706fn 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 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 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 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 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 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(¶ms_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 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 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(); 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 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 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 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 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 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(¶ms_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 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 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 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 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(¶ms_json).unwrap();
978 assert!((params["angle"].as_f64().unwrap() - preview).abs() < 1e-9);
979
980 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 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(¶ms_json).unwrap();
1014 assert!((params["angle"].as_f64().unwrap() - 150.0).abs() < 1e-9);
1015 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(¶ms_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(¶ms_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 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 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 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 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 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 #[test]
1156 fn label_click_selects_and_accordion_keeps_one_row_open() {
1157 let mut state = assembly_engine();
1158 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 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 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 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 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 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 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 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 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 assert!((display_bbox_lo_x(&state, "ACOMP2:Part") - 10.0).abs() < 1e-6);
1243
1244 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 state.constraint_drag_release();
1270 assert!(state.constraint_drag.is_none());
1271
1272 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 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 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 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 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 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}