1use super::*;
2
3#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
10pub enum GizmoMode {
11 #[default]
13 None,
14 Transform,
16 Dimension,
18}
19
20#[derive(Default)]
22pub struct TransformArm {
23 pub(super) feature_id: Option<String>,
26 pub(super) mode: GizmoMode,
29 pub(super) drag: Option<TransformDrag>,
31}
32
33#[derive(Clone, Copy)]
38pub(super) struct TransformDrag {
39 handle: u32,
40 sx: f32,
41 sy: f32,
42 start: TransformPose,
43}
44
45#[derive(Clone, Copy, PartialEq, Debug)]
49struct TransformPose {
50 position: [f64; 3],
51 rotation_deg: [f64; 3],
52 scale: [f64; 3],
53}
54
55#[derive(Clone, Copy, PartialEq, Debug)]
57enum TransformDelta {
58 Translate([f64; 3]),
60 Rotate { axis: [f64; 3], radians: f64 },
63 None,
65}
66
67impl EngineState {
68 pub fn transform_armed(&self) -> bool {
72 matches!(self.transform_gizmo.mode, GizmoMode::Transform)
73 }
74
75 pub fn transform_armed_feature(&self) -> String {
77 if self.transform_armed() {
78 self.transform_gizmo.feature_id.clone().unwrap_or_default()
79 } else {
80 String::new()
81 }
82 }
83
84 pub fn transform_armed_for(&self, feature_id: &str) -> bool {
86 self.transform_armed() && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
87 }
88
89 pub fn arm_transform(&mut self, feature_id: &str) {
100 let is_acomp = self
101 .history
102 .index_of(feature_id)
103 .and_then(|index| self.history.feature_type(index))
104 .is_some_and(|ty| super::components::is_acomp_feature_type(&ty));
105 if is_acomp {
106 self.component_move_arm(feature_id);
107 return;
108 }
109 self.component_move_reset();
110 self.transform_gizmo.feature_id = Some(feature_id.to_string());
111 self.transform_gizmo.mode = GizmoMode::Transform;
112 self.transform_gizmo.drag = None;
113 self.clear_feature_dimension_overlay();
114 self.sync_transform_gizmo();
115 }
116
117 pub fn disarm_transform(&mut self) {
121 self.component_move_reset();
122 self.transform_gizmo.feature_id = None;
123 self.transform_gizmo.mode = GizmoMode::None;
124 self.transform_gizmo.drag = None;
125 let _ = self.widgets.set_transform_json("null");
126 self.clear_feature_dimension_overlay();
127 self.dirty = true;
128 }
129
130 fn armed_pose(&self) -> Option<TransformPose> {
133 let id = self.transform_gizmo.feature_id.as_deref()?;
134 let index = self.history.index_of(id)?;
135 let params = self.history.feature_params(index)?;
136 let transform = params.get("transform");
137 Some(TransformPose {
138 position: read_pose_vec3(transform, "position", [0.0, 0.0, 0.0]),
139 rotation_deg: read_pose_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]),
140 scale: read_pose_vec3(transform, "scale", [1.0, 1.0, 1.0]),
141 })
142 }
143
144 pub fn sync_transform_gizmo(&mut self) {
151 if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
154 return;
155 }
156 let Some(pose) = self.armed_pose() else {
157 self.disarm_transform();
158 return;
159 };
160 let _ = self.widgets.set_transform_json(&transform_frame_json(&pose));
161 self.dirty = true;
162 }
163
164 pub fn feature_has_transform(&self, feature_id: &str) -> bool {
172 self.history
173 .index_of(feature_id)
174 .and_then(|i| self.history.feature_params(i))
175 .map(|params| params.get("transform").is_some())
176 .unwrap_or(false)
177 }
178
179 pub fn transform_gizmo_anchor(&self) -> Option<(f64, f64)> {
184 let pose = self.armed_pose()?;
185 let (sx, sy, _) = self.camera.project(pose.position);
186 self.camera.projectable(pose.position).then_some((sx, sy))
187 }
188
189 pub fn transform_axis_labels_json(&self) -> String {
194 if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
195 return "[]".to_string();
196 }
197 let Some(pose) = self.armed_pose() else {
198 return "[]".to_string();
199 };
200 let euler = [
201 pose.rotation_deg[0].to_radians(),
202 pose.rotation_deg[1].to_radians(),
203 pose.rotation_deg[2].to_radians(),
204 ];
205 let axes = [
206 normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
207 normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
208 normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
209 ];
210 let gap_px = 12.0_f64;
212 let dist = (brep_gizmos::transform::PX_AXIS_LEN as f64 + gap_px) * self.camera.world_per_pixel();
213 let labels: [(&str, [f32; 3]); 3] = [
214 ("XC", [0.92, 0.26, 0.28]), ("YC", [0.30, 0.78, 0.36]), ("ZC", [0.30, 0.52, 0.98]), ];
218 let out: Vec<serde_json::Value> = (0..3)
219 .map(|i| {
220 let o = pose.position;
221 let a = axes[i];
222 serde_json::json!({
223 "text": labels[i].0,
224 "rgb": labels[i].1,
225 "world": [o[0] + a[0] * dist, o[1] + a[1] * dist, o[2] + a[2] * dist],
226 })
227 })
228 .collect();
229 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
230 }
231
232 pub fn transform_hit_areas_json(&self) -> String {
245 let cam = gizmo_camera(&self.camera);
246 let regions = self.widgets.transform_hit_regions(&cam);
247 super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
248 }
249
250 pub fn transform_press(&mut self, x: f64, y: f64) -> bool {
255 if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
258 return false;
259 }
260 let handle = self.transform_pick(x, y);
261 if handle == 0 {
262 return false;
263 }
264 let Some(pose) = self.armed_pose() else {
265 return false;
266 };
267 self.widgets.set_transform_active(handle);
268 self.transform_gizmo.drag = Some(TransformDrag {
269 handle,
270 sx: x as f32,
271 sy: y as f32,
272 start: pose,
273 });
274 self.dirty = true;
275 true
276 }
277
278 pub fn transform_dragging(&self) -> bool {
280 self.transform_gizmo.drag.is_some()
281 }
282
283 pub fn transform_drag_to(&mut self, cx: f64, cy: f64) {
290 let Some(drag) = self.transform_gizmo.drag else {
291 return;
292 };
293 let delta = self.resolve_transform_delta(&drag, cx, cy);
294 if matches!(delta, TransformDelta::None) {
295 return;
296 }
297 let pose = apply_transform_delta(&drag.start, &delta);
298 self.write_armed_pose(&pose);
299 self.sync_transform_gizmo();
304 }
305
306 pub fn transform_release(&mut self) {
309 if self.transform_gizmo.drag.take().is_some() {
310 self.widgets.set_transform_active(0);
311 self.sync_transform_gizmo();
312 }
313 }
314
315 fn resolve_transform_delta(&self, drag: &TransformDrag, cx: f64, cy: f64) -> TransformDelta {
327 let cam = gizmo_camera(&self.camera);
328 let frame_json = transform_frame_json(&drag.start);
329 let json = self.widgets.transform_drag_json_with_frame(
330 &cam,
331 &frame_json,
332 drag.handle,
333 drag.sx,
334 drag.sy,
335 cx as f32,
336 cy as f32,
337 );
338 let value: serde_json::Value =
339 serde_json::from_str(&json).unwrap_or(serde_json::Value::Null);
340 match value.get("kind").and_then(|k| k.as_str()) {
341 Some("translate") => {
342 TransformDelta::Translate(json_vec3(value.get("world").and_then(|a| a.as_array())))
343 }
344 Some("rotate") => TransformDelta::Rotate {
345 axis: json_vec3(value.get("axisWorld").and_then(|a| a.as_array())),
346 radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
347 },
348 _ => TransformDelta::None,
349 }
350 }
351
352 fn write_armed_pose(&mut self, pose: &TransformPose) {
355 let Some(id) = self.transform_gizmo.feature_id.clone() else {
356 return;
357 };
358 let Some(index) = self.history.index_of(&id) else {
359 return;
360 };
361 let mut params = self
362 .history
363 .feature_params(index)
364 .unwrap_or_else(|| serde_json::json!({}));
365 if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
367 if let Some(object) = params.as_object_mut() {
368 object.insert("transform".into(), serde_json::json!({}));
369 }
370 }
371 if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
372 transform.insert("position".into(), serde_json::json!(pose.position));
373 transform.insert("rotationEuler".into(), serde_json::json!(pose.rotation_deg));
374 }
375 let _ = self.update_feature_params(&id, ¶ms.to_string());
376 }
377}
378
379fn apply_transform_delta(start: &TransformPose, delta: &TransformDelta) -> TransformPose {
385 match delta {
386 TransformDelta::Translate(world) => TransformPose {
387 position: [
388 start.position[0] + world[0],
389 start.position[1] + world[1],
390 start.position[2] + world[2],
391 ],
392 ..*start
393 },
394 TransformDelta::Rotate { axis, radians } => {
395 let q0 = quat_from_euler_xyz_deg(start.rotation_deg);
396 let dq = quat_from_axis_angle(*axis, *radians);
397 let nq = quat_mul(dq, q0);
398 TransformPose {
399 rotation_deg: euler_xyz_deg_from_quat(nq),
400 ..*start
401 }
402 }
403 TransformDelta::None => *start,
404 }
405}
406
407fn read_pose_vec3(transform: Option<&serde_json::Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
410 let array = transform.and_then(|t| t.get(key)).and_then(|v| v.as_array());
411 let mut out = default;
412 if let Some(array) = array {
413 for (index, slot) in out.iter_mut().enumerate() {
414 if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
415 *slot = number;
416 }
417 }
418 }
419 out
420}
421
422fn json_vec3(array: Option<&Vec<serde_json::Value>>) -> [f64; 3] {
424 let mut out = [0.0; 3];
425 if let Some(array) = array {
426 for (index, slot) in out.iter_mut().enumerate() {
427 if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
428 *slot = number;
429 }
430 }
431 }
432 out
433}
434
435fn transform_frame_json(pose: &TransformPose) -> String {
439 let euler = [
440 pose.rotation_deg[0].to_radians(),
441 pose.rotation_deg[1].to_radians(),
442 pose.rotation_deg[2].to_radians(),
443 ];
444 let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
445 let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
446 let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
447 serde_json::json!({
448 "origin": pose.position,
449 "x": x,
450 "y": y,
451 "z": z,
452 "showCenter": true,
453 })
454 .to_string()
455}
456
457pub(super) fn normalize3(v: [f64; 3]) -> [f64; 3] {
458 let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
459 if length < 1e-12 {
460 [0.0, 0.0, 1.0]
461 } else {
462 [v[0] / length, v[1] / length, v[2] / length]
463 }
464}
465
466pub(crate) fn rotate_euler_xyz_f64(v: [f64; 3], euler: [f64; 3]) -> [f64; 3] {
470 let (c1, s1) = (euler[0].cos(), euler[0].sin());
471 let (c2, s2) = (euler[1].cos(), euler[1].sin());
472 let (c3, s3) = (euler[2].cos(), euler[2].sin());
473 let m00 = c2 * c3;
474 let m01 = -c2 * s3;
475 let m02 = s2;
476 let m10 = c1 * s3 + c3 * s1 * s2;
477 let m11 = c1 * c3 - s1 * s2 * s3;
478 let m12 = -c2 * s1;
479 let m20 = s1 * s3 - c1 * c3 * s2;
480 let m21 = c3 * s1 + c1 * s2 * s3;
481 let m22 = c1 * c2;
482 [
483 m00 * v[0] + m01 * v[1] + m02 * v[2],
484 m10 * v[0] + m11 * v[1] + m12 * v[2],
485 m20 * v[0] + m21 * v[1] + m22 * v[2],
486 ]
487}
488
489pub(super) type Quat = [f64; 4]; pub(super) fn quat_from_axis_angle(axis: [f64; 3], angle: f64) -> Quat {
494 let n = normalize3(axis);
495 let half = angle * 0.5;
496 let s = half.sin();
497 [n[0] * s, n[1] * s, n[2] * s, half.cos()]
498}
499
500pub(super) fn quat_from_euler_xyz_deg(deg: [f64; 3]) -> Quat {
502 let (c1, s1) = ((deg[0].to_radians() * 0.5).cos(), (deg[0].to_radians() * 0.5).sin());
503 let (c2, s2) = ((deg[1].to_radians() * 0.5).cos(), (deg[1].to_radians() * 0.5).sin());
504 let (c3, s3) = ((deg[2].to_radians() * 0.5).cos(), (deg[2].to_radians() * 0.5).sin());
505 [
506 s1 * c2 * c3 + c1 * s2 * s3,
507 c1 * s2 * c3 - s1 * c2 * s3,
508 c1 * c2 * s3 + s1 * s2 * c3,
509 c1 * c2 * c3 - s1 * s2 * s3,
510 ]
511}
512
513pub(super) fn quat_mul(a: Quat, b: Quat) -> Quat {
515 [
516 a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
517 a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
518 a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
519 a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
520 ]
521}
522
523pub(super) fn euler_xyz_deg_from_quat(q: Quat) -> [f64; 3] {
527 let [x, y, z, w] = q;
528 let (x2, y2, z2) = (x + x, y + y, z + z);
529 let (xx, xy, xz) = (x * x2, x * y2, x * z2);
530 let (yy, yz, zz) = (y * y2, y * z2, z * z2);
531 let (wx, wy, wz) = (w * x2, w * y2, w * z2);
532 let m11 = 1.0 - (yy + zz);
534 let m12 = xy - wz;
535 let m13 = xz + wy;
536 let m22 = 1.0 - (xx + zz);
537 let m23 = yz - wx;
538 let m32 = yz + wx;
539 let m33 = 1.0 - (xx + yy);
540 let ey = m13.clamp(-1.0, 1.0).asin();
541 let (ex, ez) = if m13.abs() < 0.9999999 {
542 ((-m23).atan2(m33), (-m12).atan2(m11))
543 } else {
544 (m32.atan2(m22), 0.0)
545 };
546 [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
547}
548
549#[cfg(test)]
550mod transform_gizmo_tests {
551 use super::*;
552
553 fn cube_request(name: &str, size: f64) -> String {
556 serde_json::json!({
557 "expressions": "",
558 "configurator": {},
559 "features": [{
560 "type": "P.CU",
561 "inputParams": {
562 "id": name,
563 "sizeX": size, "sizeY": size, "sizeZ": size,
564 "transform": {
565 "position": [0.0, 0.0, 0.0],
566 "rotationEuler": [0.0, 0.0, 0.0],
567 "scale": [1.0, 1.0, 1.0]
568 },
569 "boolean": { "targets": [], "operation": "NONE" }
570 },
571 "persistentData": {}
572 }]
573 })
574 .to_string()
575 }
576
577 fn ident() -> TransformPose {
578 TransformPose {
579 position: [1.0, 2.0, 3.0],
580 rotation_deg: [0.0, 0.0, 0.0],
581 scale: [1.0, 1.0, 1.0],
582 }
583 }
584
585 #[test]
586 fn translate_delta_adds_world_to_position() {
587 let pose = apply_transform_delta(&ident(), &TransformDelta::Translate([4.0, -1.0, 0.5]));
588 assert_eq!(pose.position, [5.0, 1.0, 3.5]);
589 assert_eq!(pose.rotation_deg, [0.0, 0.0, 0.0]);
590 assert_eq!(pose.scale, [1.0, 1.0, 1.0]);
591 }
592
593 #[test]
594 fn rotate_delta_about_z_yields_z_euler() {
595 let pose = apply_transform_delta(
597 &ident(),
598 &TransformDelta::Rotate {
599 axis: [0.0, 0.0, 1.0],
600 radians: std::f64::consts::FRAC_PI_2,
601 },
602 );
603 assert!((pose.rotation_deg[0]).abs() < 1e-6, "{:?}", pose.rotation_deg);
604 assert!((pose.rotation_deg[1]).abs() < 1e-6, "{:?}", pose.rotation_deg);
605 assert!((pose.rotation_deg[2] - 90.0).abs() < 1e-4, "{:?}", pose.rotation_deg);
606 assert_eq!(pose.position, [1.0, 2.0, 3.0]);
608 }
609
610 #[test]
611 fn euler_quat_roundtrip_is_identity() {
612 let deg = [30.0, 45.0, 60.0];
614 let back = euler_xyz_deg_from_quat(quat_from_euler_xyz_deg(deg));
615 for k in 0..3 {
616 assert!((back[k] - deg[k]).abs() < 1e-4, "axis {k}: {back:?} vs {deg:?}");
617 }
618 }
619
620 #[test]
621 fn rotate_about_local_axis_composes_onto_existing_orientation() {
622 let start = TransformPose {
626 rotation_deg: [0.0, 0.0, 90.0],
627 ..ident()
628 };
629 let pose = apply_transform_delta(
630 &start,
631 &TransformDelta::Rotate {
632 axis: [0.0, 0.0, 1.0],
633 radians: std::f64::consts::FRAC_PI_2,
634 },
635 );
636 let got = quat_from_euler_xyz_deg(pose.rotation_deg);
639 let want = quat_from_euler_xyz_deg([0.0, 0.0, 180.0]);
640 let dot = got[0] * want[0] + got[1] * want[1] + got[2] * want[2] + got[3] * want[3];
641 assert!(dot.abs() > 0.9999, "orientation mismatch: {got:?} vs {want:?}");
642 }
643
644 #[test]
645 fn arm_press_drag_moves_the_feature_then_disarms() {
646 let mut engine = EngineState::new();
647 engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
648 engine.resize(800.0, 600.0);
649 engine.camera.eye = [0.0, 0.0, 40.0];
650 engine.camera.target = [0.0, 0.0, 0.0];
651 engine.camera.up = [0.0, 1.0, 0.0];
652 engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
653
654 assert!(!engine.transform_armed());
655 engine.arm_transform("Pin");
656 assert!(engine.transform_armed() && engine.transform_armed_for("Pin"));
657 assert!(engine.widgets.has_transform(), "arming feeds the widget gizmo");
658
659 let (ax, ay) = engine.transform_gizmo_anchor().expect("armed anchor");
662 assert!((ax - 400.0).abs() < 1.0 && (ay - 300.0).abs() < 1.0, "anchor {ax},{ay}");
663
664 let origin_before = engine.widgets.transform_origin().expect("gizmo shown");
666 assert!(origin_before[0].abs() < 1e-4, "gizmo starts at x=0: {origin_before:?}");
667
668 assert!(engine.transform_press(ax, ay), "press grabs a handle");
670 assert!(engine.transform_dragging());
671 engine.transform_drag_to(ax + 60.0, ay);
672
673 let index = engine.history.index_of("Pin").unwrap();
674 let params = engine.history.feature_params(index).unwrap();
675 let moved_x = params["transform"]["position"][0].as_f64().unwrap();
676 assert!(moved_x > 0.0, "Pin moved +X, got {:?}", params["transform"]["position"]);
677
678 let origin_mid = engine.widgets.transform_origin().expect("gizmo still shown");
683 assert!(
684 origin_mid[0] > 0.0 && (origin_mid[0] as f64 - moved_x).abs() < 1e-3,
685 "gizmo should follow to x={moved_x} mid-drag, got {origin_mid:?}"
686 );
687 assert!(engine.transform_dragging(), "still dragging after the live re-sync");
689
690 engine.transform_release();
691 assert!(!engine.transform_dragging());
692
693 engine.disarm_transform();
694 assert!(!engine.transform_armed());
695 assert!(!engine.widgets.has_transform(), "disarm hides the gizmo");
696 }
697
698 #[test]
699 fn deleting_the_armed_feature_auto_disarms() {
700 let mut engine = EngineState::new();
701 engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
702 engine.arm_transform("Pin");
703 assert!(engine.transform_armed());
704 engine.delete_feature("Pin");
707 assert!(!engine.transform_armed(), "armed feature gone → auto-disarm");
708 assert!(!engine.widgets.has_transform());
709 }
710
711 #[test]
718 fn transform_hit_areas_are_screen_regions_matching_the_hit_test() {
719 let mut engine = EngineState::new();
720 engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
721 engine.resize(800.0, 600.0);
722 engine.camera.eye = [30.0, 20.0, 40.0];
724 engine.camera.target = [0.0, 0.0, 0.0];
725 engine.camera.up = [0.0, 1.0, 0.0];
726 engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
727
728 assert_eq!(engine.transform_hit_areas_json(), "[]");
730
731 engine.arm_transform("Pin");
732 let areas: Vec<serde_json::Value> =
733 serde_json::from_str(&engine.transform_hit_areas_json()).unwrap();
734 let cam = gizmo_camera(&engine.camera);
735
736 let capsules: Vec<&serde_json::Value> =
738 areas.iter().filter(|a| a["kind"] == "capsule").collect();
739 let circles: Vec<&serde_json::Value> =
740 areas.iter().filter(|a| a["kind"] == "circle").collect();
741 assert_eq!(capsules.len(), 3, "one capsule per axis arrow");
742 assert_eq!(circles.len(), 4, "center + 3 ring grab circles");
743
744 let close2 = |v: &serde_json::Value, want: [f32; 2]| {
745 (v[0].as_f64().unwrap() as f32 - want[0]).abs() < 1e-3
746 && (v[1].as_f64().unwrap() as f32 - want[1]).abs() < 1e-3
747 };
748
749 for (i, cap) in capsules.iter().enumerate() {
752 let r = cap["r"].as_f64().unwrap();
753 assert!(
754 (r - brep_gizmos::transform::AXIS_HIT_THRESH_PX as f64).abs() < 1e-6,
755 "axis {i}: r {r} must equal AXIS_HIT_THRESH_PX"
756 );
757 let (a, b) = engine.widgets.transform_axis_seg(&cam, i).unwrap();
758 let sa = cam.world_to_screen(a).unwrap();
759 let sb = cam.world_to_screen(b).unwrap();
760 assert!(close2(&cap["a"], sa), "axis {i} a: {:?} vs {sa:?}", cap["a"]);
761 assert!(close2(&cap["b"], sb), "axis {i} b: {:?} vs {sb:?}", cap["b"]);
762 }
763
764 let center = engine.widgets.transform_center_grab().unwrap();
766 let sc = cam.world_to_screen(center).unwrap();
767 let center_r = brep_gizmos::transform::PX_CENTER_RAD as f64 + 2.0;
768 assert!(
769 circles
770 .iter()
771 .any(|c| close2(&c["c"], sc) && (c["r"].as_f64().unwrap() - center_r).abs() < 1e-6),
772 "center circle at the projected origin"
773 );
774
775 let ring_r = brep_gizmos::transform::PX_RING_GRAB_RAD as f64 + 3.0;
777 for g in engine.widgets.transform_ring_grabs(&cam).unwrap() {
778 let sg = cam.world_to_screen(g).unwrap();
779 assert!(
780 circles
781 .iter()
782 .any(|c| close2(&c["c"], sg) && (c["r"].as_f64().unwrap() - ring_r).abs() < 1e-6),
783 "ring grab circle at {sg:?}"
784 );
785 }
786
787 let regions = engine.widgets.transform_hit_regions(&cam);
792 for (_, shape) in ®ions {
793 let probe = match shape {
794 brep_gizmos::hit_region::HitShape::Circle { c, .. } => *c,
795 brep_gizmos::hit_region::HitShape::Capsule { a, b, .. } => {
796 [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]
797 }
798 };
799 let picked = engine.transform_pick(probe[0] as f64, probe[1] as f64);
800 assert_ne!(picked, 0, "a handle under its own region at {probe:?}");
801 let picked_shape = regions.iter().find(|(id, _)| *id == picked).unwrap().1;
802 assert!(
803 picked_shape.contains(probe),
804 "picked handle {picked}'s region must contain the cursor {probe:?}"
805 );
806 }
807
808 engine.arm_dimension("Pin");
810 assert_eq!(engine.transform_hit_areas_json(), "[]");
811 }
812}
813
814