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 let pivot = if self.is_xform_feature(index) {
138 self.xform_pivot(index, ¶ms)
139 } else {
140 [0.0; 3]
141 };
142 let position = read_pose_vec3(transform, "position", [0.0; 3]);
143 Some(TransformPose {
144 position: std::array::from_fn(|axis| position[axis] + pivot[axis]),
145 rotation_deg: read_pose_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]),
146 scale: read_pose_vec3(transform, "scale", [1.0, 1.0, 1.0]),
147 })
148 }
149
150 fn is_xform_feature(&self, index: usize) -> bool {
151 self.history.feature_type(index).is_some_and(|ty| {
152 ty.eq_ignore_ascii_case("XFORM") || ty.eq_ignore_ascii_case("TRANSFORM")
153 })
154 }
155
156 fn xform_pivot(&self, index: usize, params: &serde_json::Value) -> [f64; 3] {
160 if !params["pivot"]
161 .as_str()
162 .is_some_and(|p| p.eq_ignore_ascii_case("BBOX_CENTER"))
163 {
164 return [0.0; 3];
165 }
166 let Ok(request) = serde_json::from_value::<HistoryRequest>(self.history.prefix_request())
169 else {
170 return [0.0; 3];
171 };
172 let result = brep_kernel::execute_history(&request);
173 let mut handles = HashMap::new();
174 for feature in result.results.iter().take(index) {
175 for name in &feature.removed {
176 handles.remove(name);
177 }
178 for solid in &feature.added {
179 handles.insert(solid.name.clone(), solid.handle);
180 }
181 }
182 let Some(refs) = params["solids"].as_array() else {
183 return [0.0; 3];
184 };
185 for reference in refs {
186 let Some(name) = reference.as_str().or_else(|| reference["name"].as_str()) else {
187 continue;
188 };
189 let Some(handle) = handles.get(name.trim()) else {
190 continue;
191 };
192 if let Ok(center) = brep_kernel::transform_pivot_native(*handle) {
193 return center;
194 }
195 }
196 [0.0; 3]
197 }
198
199 pub fn sync_transform_gizmo(&mut self) {
206 if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
209 return;
210 }
211 let Some(pose) = self.armed_pose() else {
212 self.disarm_transform();
213 return;
214 };
215 let _ = self
216 .widgets
217 .set_transform_json(&transform_frame_json(&pose));
218 self.dirty = true;
219 }
220
221 pub fn feature_has_transform(&self, feature_id: &str) -> bool {
225 self.history
226 .index_of(feature_id)
227 .and_then(|i| self.history.feature_type(i))
228 .and_then(|ty| crate::features::feature_schema(&ty))
229 .is_some_and(|schema| schema["inputParamsSchema"]["transform"]["type"] == "transform")
230 }
231
232 pub fn transform_gizmo_anchor(&self) -> Option<(f64, f64)> {
237 let pose = self.armed_pose()?;
238 let (sx, sy, _) = self.camera.project(pose.position);
239 self.camera.projectable(pose.position).then_some((sx, sy))
240 }
241
242 pub fn transform_axis_labels_json(&self) -> String {
247 if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
248 return "[]".to_string();
249 }
250 let Some(pose) = self.armed_pose() else {
251 return "[]".to_string();
252 };
253 let euler = [
254 pose.rotation_deg[0].to_radians(),
255 pose.rotation_deg[1].to_radians(),
256 pose.rotation_deg[2].to_radians(),
257 ];
258 let axes = [
259 normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
260 normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
261 normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
262 ];
263 let gap_px = 12.0_f64;
265 let dist =
266 (brep_gizmos::transform::PX_AXIS_LEN as f64 + gap_px) * self.camera.world_per_pixel();
267 let labels: [(&str, [f32; 3]); 3] = [
268 ("XC", [0.92, 0.26, 0.28]), ("YC", [0.30, 0.78, 0.36]), ("ZC", [0.30, 0.52, 0.98]), ];
272 let out: Vec<serde_json::Value> = (0..3)
273 .map(|i| {
274 let o = pose.position;
275 let a = axes[i];
276 serde_json::json!({
277 "text": labels[i].0,
278 "rgb": labels[i].1,
279 "world": [o[0] + a[0] * dist, o[1] + a[1] * dist, o[2] + a[2] * dist],
280 })
281 })
282 .collect();
283 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
284 }
285
286 pub fn transform_hit_areas_json(&self) -> String {
299 let cam = gizmo_camera(&self.camera);
300 let regions = self.widgets.transform_hit_regions(&cam);
301 super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
302 }
303
304 pub fn transform_press(&mut self, x: f64, y: f64) -> bool {
309 if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
312 return false;
313 }
314 let handle = self.transform_pick(x, y);
315 if handle == 0 {
316 return false;
317 }
318 let Some(pose) = self.armed_pose() else {
319 return false;
320 };
321 self.widgets.set_transform_active(handle);
322 self.transform_gizmo.drag = Some(TransformDrag {
323 handle,
324 sx: x as f32,
325 sy: y as f32,
326 start: pose,
327 });
328 self.dirty = true;
329 true
330 }
331
332 pub fn transform_dragging(&self) -> bool {
334 self.transform_gizmo.drag.is_some()
335 }
336
337 pub fn transform_drag_to(&mut self, cx: f64, cy: f64) {
344 let Some(drag) = self.transform_gizmo.drag else {
345 return;
346 };
347 let delta = self.resolve_transform_delta(&drag, cx, cy);
348 if matches!(delta, TransformDelta::None) {
349 return;
350 }
351 let pose = apply_transform_delta(&drag.start, &delta);
352 self.write_armed_pose(&pose);
353 self.sync_transform_gizmo();
358 }
359
360 pub fn transform_release(&mut self) {
363 if self.transform_gizmo.drag.take().is_some() {
364 self.widgets.set_transform_active(0);
365 self.sync_transform_gizmo();
366 }
367 }
368
369 fn resolve_transform_delta(&self, drag: &TransformDrag, cx: f64, cy: f64) -> TransformDelta {
381 let cam = gizmo_camera(&self.camera);
382 let frame_json = transform_frame_json(&drag.start);
383 let json = self.widgets.transform_drag_json_with_frame(
384 &cam,
385 &frame_json,
386 drag.handle,
387 drag.sx,
388 drag.sy,
389 cx as f32,
390 cy as f32,
391 );
392 let value: serde_json::Value =
393 serde_json::from_str(&json).unwrap_or(serde_json::Value::Null);
394 match value.get("kind").and_then(|k| k.as_str()) {
395 Some("translate") => {
396 TransformDelta::Translate(json_vec3(value.get("world").and_then(|a| a.as_array())))
397 }
398 Some("rotate") => TransformDelta::Rotate {
399 axis: json_vec3(value.get("axisWorld").and_then(|a| a.as_array())),
400 radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
401 },
402 _ => TransformDelta::None,
403 }
404 }
405
406 fn write_armed_pose(&mut self, pose: &TransformPose) {
409 let Some(id) = self.transform_gizmo.feature_id.clone() else {
410 return;
411 };
412 let Some(index) = self.history.index_of(&id) else {
413 return;
414 };
415 let mut params = self
416 .history
417 .feature_params(index)
418 .unwrap_or_else(|| serde_json::json!({}));
419 let pivot = if self.is_xform_feature(index) {
420 self.xform_pivot(index, ¶ms)
421 } else {
422 [0.0; 3]
423 };
424 let position: [f64; 3] = std::array::from_fn(|axis| pose.position[axis] - pivot[axis]);
425 if !params
427 .get("transform")
428 .map(|t| t.is_object())
429 .unwrap_or(false)
430 {
431 if let Some(object) = params.as_object_mut() {
432 object.insert("transform".into(), serde_json::json!({}));
433 }
434 }
435 if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
436 transform.insert("position".into(), serde_json::json!(position));
437 transform.insert("rotationEuler".into(), serde_json::json!(pose.rotation_deg));
438 }
439 let _ = self.update_feature_params(&id, ¶ms.to_string());
440 }
441}
442
443fn apply_transform_delta(start: &TransformPose, delta: &TransformDelta) -> TransformPose {
449 match delta {
450 TransformDelta::Translate(world) => TransformPose {
451 position: [
452 start.position[0] + world[0],
453 start.position[1] + world[1],
454 start.position[2] + world[2],
455 ],
456 ..*start
457 },
458 TransformDelta::Rotate { axis, radians } => {
459 let q0 = quat_from_euler_xyz_deg(start.rotation_deg);
460 let dq = quat_from_axis_angle(*axis, *radians);
461 let nq = quat_mul(dq, q0);
462 TransformPose {
463 rotation_deg: euler_xyz_deg_from_quat(nq),
464 ..*start
465 }
466 }
467 TransformDelta::None => *start,
468 }
469}
470
471fn read_pose_vec3(transform: Option<&serde_json::Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
474 let array = transform
475 .and_then(|t| t.get(key))
476 .and_then(|v| v.as_array());
477 let mut out = default;
478 if let Some(array) = array {
479 for (index, slot) in out.iter_mut().enumerate() {
480 if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
481 *slot = number;
482 }
483 }
484 }
485 out
486}
487
488fn json_vec3(array: Option<&Vec<serde_json::Value>>) -> [f64; 3] {
490 let mut out = [0.0; 3];
491 if let Some(array) = array {
492 for (index, slot) in out.iter_mut().enumerate() {
493 if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
494 *slot = number;
495 }
496 }
497 }
498 out
499}
500
501fn transform_frame_json(pose: &TransformPose) -> String {
505 let euler = [
506 pose.rotation_deg[0].to_radians(),
507 pose.rotation_deg[1].to_radians(),
508 pose.rotation_deg[2].to_radians(),
509 ];
510 let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
511 let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
512 let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
513 serde_json::json!({
514 "origin": pose.position,
515 "x": x,
516 "y": y,
517 "z": z,
518 "showCenter": true,
519 })
520 .to_string()
521}
522
523pub(super) fn normalize3(v: [f64; 3]) -> [f64; 3] {
524 let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
525 if length < 1e-12 {
526 [0.0, 0.0, 1.0]
527 } else {
528 [v[0] / length, v[1] / length, v[2] / length]
529 }
530}
531
532pub(crate) fn rotate_euler_xyz_f64(v: [f64; 3], euler: [f64; 3]) -> [f64; 3] {
536 let (c1, s1) = (euler[0].cos(), euler[0].sin());
537 let (c2, s2) = (euler[1].cos(), euler[1].sin());
538 let (c3, s3) = (euler[2].cos(), euler[2].sin());
539 let m00 = c2 * c3;
540 let m01 = -c2 * s3;
541 let m02 = s2;
542 let m10 = c1 * s3 + c3 * s1 * s2;
543 let m11 = c1 * c3 - s1 * s2 * s3;
544 let m12 = -c2 * s1;
545 let m20 = s1 * s3 - c1 * c3 * s2;
546 let m21 = c3 * s1 + c1 * s2 * s3;
547 let m22 = c1 * c2;
548 [
549 m00 * v[0] + m01 * v[1] + m02 * v[2],
550 m10 * v[0] + m11 * v[1] + m12 * v[2],
551 m20 * v[0] + m21 * v[1] + m22 * v[2],
552 ]
553}
554
555pub(super) type Quat = [f64; 4]; pub(super) fn quat_from_axis_angle(axis: [f64; 3], angle: f64) -> Quat {
560 let n = normalize3(axis);
561 let half = angle * 0.5;
562 let s = half.sin();
563 [n[0] * s, n[1] * s, n[2] * s, half.cos()]
564}
565
566pub(super) fn quat_from_euler_xyz_deg(deg: [f64; 3]) -> Quat {
568 let (c1, s1) = (
569 (deg[0].to_radians() * 0.5).cos(),
570 (deg[0].to_radians() * 0.5).sin(),
571 );
572 let (c2, s2) = (
573 (deg[1].to_radians() * 0.5).cos(),
574 (deg[1].to_radians() * 0.5).sin(),
575 );
576 let (c3, s3) = (
577 (deg[2].to_radians() * 0.5).cos(),
578 (deg[2].to_radians() * 0.5).sin(),
579 );
580 [
581 s1 * c2 * c3 + c1 * s2 * s3,
582 c1 * s2 * c3 - s1 * c2 * s3,
583 c1 * c2 * s3 + s1 * s2 * c3,
584 c1 * c2 * c3 - s1 * s2 * s3,
585 ]
586}
587
588pub(super) fn quat_mul(a: Quat, b: Quat) -> Quat {
590 [
591 a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
592 a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
593 a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
594 a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
595 ]
596}
597
598pub(super) fn euler_xyz_deg_from_quat(q: Quat) -> [f64; 3] {
602 let [x, y, z, w] = q;
603 let (x2, y2, z2) = (x + x, y + y, z + z);
604 let (xx, xy, xz) = (x * x2, x * y2, x * z2);
605 let (yy, yz, zz) = (y * y2, y * z2, z * z2);
606 let (wx, wy, wz) = (w * x2, w * y2, w * z2);
607 let m11 = 1.0 - (yy + zz);
609 let m12 = xy - wz;
610 let m13 = xz + wy;
611 let m22 = 1.0 - (xx + zz);
612 let m23 = yz - wx;
613 let m32 = yz + wx;
614 let m33 = 1.0 - (xx + yy);
615 let ey = m13.clamp(-1.0, 1.0).asin();
616 let (ex, ez) = if m13.abs() < 0.9999999 {
617 ((-m23).atan2(m33), (-m12).atan2(m11))
618 } else {
619 (m32.atan2(m22), 0.0)
620 };
621 [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
622}
623
624#[cfg(test)]
625mod transform_gizmo_tests {
626 use super::*;
627
628 fn cube_request(name: &str, size: f64) -> String {
631 serde_json::json!({
632 "expressions": "",
633 "configurator": {},
634 "features": [{
635 "type": "P.CU",
636 "inputParams": {
637 "id": name,
638 "sizeX": size, "sizeY": size, "sizeZ": size,
639 "transform": {
640 "position": [0.0, 0.0, 0.0],
641 "rotationEuler": [0.0, 0.0, 0.0],
642 "scale": [1.0, 1.0, 1.0]
643 },
644 "boolean": { "targets": [], "operation": "NONE" }
645 },
646 "persistentData": {}
647 }]
648 })
649 .to_string()
650 }
651
652 fn ident() -> TransformPose {
653 TransformPose {
654 position: [1.0, 2.0, 3.0],
655 rotation_deg: [0.0, 0.0, 0.0],
656 scale: [1.0, 1.0, 1.0],
657 }
658 }
659
660 #[test]
661 fn xform_uses_shared_controls_and_updates_geometry_for_both_pivots() {
662 for pivot in ["ORIGIN", "BBOX_CENTER"] {
663 for copy in [false, true] {
664 let mut request: serde_json::Value =
665 serde_json::from_str(&cube_request("Source", 10.0)).unwrap();
666 let mut params = crate::features::feature_default_params("XFORM");
667 params["id"] = serde_json::json!("Move");
668 params["solids"] = serde_json::json!([{"name": "Source"}]);
669 params["pivot"] = serde_json::json!(pivot);
670 params["copy"] = serde_json::json!(copy);
671 request["features"]
672 .as_array_mut()
673 .unwrap()
674 .push(serde_json::json!({"type": "XFORM", "inputParams": params}));
675 let mut engine = EngineState::new();
676 engine.set_history_json(&request.to_string()).unwrap();
677 engine.resize(800.0, 600.0);
678 engine.camera.eye = [0.0, 0.0, 40.0];
679 engine.camera.target = [0.0; 3];
680 engine.camera.up = [0.0, 1.0, 0.0];
681 engine.camera.projection =
682 crate::view::Projection::Orthographic { half_height: 20.0 };
683 assert!(engine.feature_has_transform("Move"));
684 engine.arm_transform("Move");
685 assert!(engine.widgets.has_transform());
686 let original = engine.armed_pose().unwrap();
687 let center = if pivot == "BBOX_CENTER" {
688 [5.0; 3]
689 } else {
690 [0.0; 3]
691 };
692 assert_eq!(original.position, center);
693 engine.toggle_to_dimension();
694 assert!(
695 engine.transform_armed_for("Move"),
696 "no dimensions to toggle to"
697 );
698 let (x, y) = engine.transform_gizmo_anchor().unwrap();
699 assert!(engine.transform_press(x, y));
700 engine.transform_drag_to(x + 60.0, y);
701 engine.transform_release();
702 let params = engine.history.feature_params(1).unwrap();
703 let dx = params["transform"]["position"][0].as_f64().unwrap();
704 assert!(dx > 0.0);
705 assert!(params.get("translate").is_none());
706 assert_eq!(params["transform"]["scale"], serde_json::json!([1, 1, 1]));
707 let moved_name = if copy { "Source_Move" } else { "Source" };
708 let bbox = engine
709 .scene
710 .solids()
711 .iter()
712 .find(|s| s.name == moved_name)
713 .unwrap()
714 .bbox;
715 assert!(
716 (bbox.min[0] - dx).abs() < 1e-4,
717 "drag changes real geometry"
718 );
719 let translated = engine.armed_pose().unwrap();
720 let rotated = apply_transform_delta(
721 &translated,
722 &TransformDelta::Rotate {
723 axis: [0.0, 0.0, 1.0],
724 radians: std::f64::consts::FRAC_PI_2,
725 },
726 );
727 engine.write_armed_pose(&rotated);
728 let after = engine.armed_pose().unwrap();
729 assert_eq!(
730 after.position, translated.position,
731 "rotation must not shift the pivot"
732 );
733 assert!((after.rotation_deg[2] - 90.0).abs() < 1e-8);
734 let bbox = engine
735 .scene
736 .solids()
737 .iter()
738 .find(|s| s.name == moved_name)
739 .unwrap()
740 .bbox;
741 let expected_min = dx + if pivot == "ORIGIN" { -10.0 } else { 0.0 };
742 assert!(
743 (bbox.min[0] - expected_min).abs() < 1e-4,
744 "rotation changes real geometry at {pivot}"
745 );
746 if copy {
747 let source = engine
748 .scene
749 .solids()
750 .iter()
751 .find(|s| s.name == "Source")
752 .unwrap();
753 assert_eq!(source.bbox.min, [0.0; 3]);
754 }
755 engine.disarm_transform();
756 assert!(!engine.widgets.has_transform());
757 }
758 }
759 }
760
761 #[test]
762 fn translate_delta_adds_world_to_position() {
763 let pose = apply_transform_delta(&ident(), &TransformDelta::Translate([4.0, -1.0, 0.5]));
764 assert_eq!(pose.position, [5.0, 1.0, 3.5]);
765 assert_eq!(pose.rotation_deg, [0.0, 0.0, 0.0]);
766 assert_eq!(pose.scale, [1.0, 1.0, 1.0]);
767 }
768
769 #[test]
770 fn rotate_delta_about_z_yields_z_euler() {
771 let pose = apply_transform_delta(
773 &ident(),
774 &TransformDelta::Rotate {
775 axis: [0.0, 0.0, 1.0],
776 radians: std::f64::consts::FRAC_PI_2,
777 },
778 );
779 assert!(
780 (pose.rotation_deg[0]).abs() < 1e-6,
781 "{:?}",
782 pose.rotation_deg
783 );
784 assert!(
785 (pose.rotation_deg[1]).abs() < 1e-6,
786 "{:?}",
787 pose.rotation_deg
788 );
789 assert!(
790 (pose.rotation_deg[2] - 90.0).abs() < 1e-4,
791 "{:?}",
792 pose.rotation_deg
793 );
794 assert_eq!(pose.position, [1.0, 2.0, 3.0]);
796 }
797
798 #[test]
799 fn euler_quat_roundtrip_is_identity() {
800 let deg = [30.0, 45.0, 60.0];
802 let back = euler_xyz_deg_from_quat(quat_from_euler_xyz_deg(deg));
803 for k in 0..3 {
804 assert!(
805 (back[k] - deg[k]).abs() < 1e-4,
806 "axis {k}: {back:?} vs {deg:?}"
807 );
808 }
809 }
810
811 #[test]
812 fn rotate_about_local_axis_composes_onto_existing_orientation() {
813 let start = TransformPose {
817 rotation_deg: [0.0, 0.0, 90.0],
818 ..ident()
819 };
820 let pose = apply_transform_delta(
821 &start,
822 &TransformDelta::Rotate {
823 axis: [0.0, 0.0, 1.0],
824 radians: std::f64::consts::FRAC_PI_2,
825 },
826 );
827 let got = quat_from_euler_xyz_deg(pose.rotation_deg);
830 let want = quat_from_euler_xyz_deg([0.0, 0.0, 180.0]);
831 let dot = got[0] * want[0] + got[1] * want[1] + got[2] * want[2] + got[3] * want[3];
832 assert!(
833 dot.abs() > 0.9999,
834 "orientation mismatch: {got:?} vs {want:?}"
835 );
836 }
837
838 #[test]
839 fn arm_press_drag_moves_the_feature_then_disarms() {
840 let mut engine = EngineState::new();
841 engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
842 engine.resize(800.0, 600.0);
843 engine.camera.eye = [0.0, 0.0, 40.0];
844 engine.camera.target = [0.0, 0.0, 0.0];
845 engine.camera.up = [0.0, 1.0, 0.0];
846 engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
847
848 assert!(!engine.transform_armed());
849 engine.arm_transform("Pin");
850 assert!(engine.transform_armed() && engine.transform_armed_for("Pin"));
851 assert!(
852 engine.widgets.has_transform(),
853 "arming feeds the widget gizmo"
854 );
855
856 let (ax, ay) = engine.transform_gizmo_anchor().expect("armed anchor");
859 assert!(
860 (ax - 400.0).abs() < 1.0 && (ay - 300.0).abs() < 1.0,
861 "anchor {ax},{ay}"
862 );
863
864 let origin_before = engine.widgets.transform_origin().expect("gizmo shown");
866 assert!(
867 origin_before[0].abs() < 1e-4,
868 "gizmo starts at x=0: {origin_before:?}"
869 );
870
871 assert!(engine.transform_press(ax, ay), "press grabs a handle");
873 assert!(engine.transform_dragging());
874 engine.transform_drag_to(ax + 60.0, ay);
875
876 let index = engine.history.index_of("Pin").unwrap();
877 let params = engine.history.feature_params(index).unwrap();
878 let moved_x = params["transform"]["position"][0].as_f64().unwrap();
879 assert!(
880 moved_x > 0.0,
881 "Pin moved +X, got {:?}",
882 params["transform"]["position"]
883 );
884
885 let origin_mid = engine
890 .widgets
891 .transform_origin()
892 .expect("gizmo still shown");
893 assert!(
894 origin_mid[0] > 0.0 && (origin_mid[0] as f64 - moved_x).abs() < 1e-3,
895 "gizmo should follow to x={moved_x} mid-drag, got {origin_mid:?}"
896 );
897 assert!(
899 engine.transform_dragging(),
900 "still dragging after the live re-sync"
901 );
902
903 engine.transform_release();
904 assert!(!engine.transform_dragging());
905
906 engine.disarm_transform();
907 assert!(!engine.transform_armed());
908 assert!(!engine.widgets.has_transform(), "disarm hides the gizmo");
909 }
910
911 #[test]
912 fn deleting_the_armed_feature_auto_disarms() {
913 let mut engine = EngineState::new();
914 engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
915 engine.arm_transform("Pin");
916 assert!(engine.transform_armed());
917 engine.delete_feature("Pin");
920 assert!(
921 !engine.transform_armed(),
922 "armed feature gone → auto-disarm"
923 );
924 assert!(!engine.widgets.has_transform());
925 }
926
927 #[test]
934 fn transform_hit_areas_are_screen_regions_matching_the_hit_test() {
935 let mut engine = EngineState::new();
936 engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
937 engine.resize(800.0, 600.0);
938 engine.camera.eye = [30.0, 20.0, 40.0];
940 engine.camera.target = [0.0, 0.0, 0.0];
941 engine.camera.up = [0.0, 1.0, 0.0];
942 engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
943
944 assert_eq!(engine.transform_hit_areas_json(), "[]");
946
947 engine.arm_transform("Pin");
948 let areas: Vec<serde_json::Value> =
949 serde_json::from_str(&engine.transform_hit_areas_json()).unwrap();
950 let cam = gizmo_camera(&engine.camera);
951
952 let capsules: Vec<&serde_json::Value> =
954 areas.iter().filter(|a| a["kind"] == "capsule").collect();
955 let circles: Vec<&serde_json::Value> =
956 areas.iter().filter(|a| a["kind"] == "circle").collect();
957 assert_eq!(capsules.len(), 3, "one capsule per axis arrow");
958 assert_eq!(circles.len(), 4, "center + 3 ring grab circles");
959
960 let close2 = |v: &serde_json::Value, want: [f32; 2]| {
961 (v[0].as_f64().unwrap() as f32 - want[0]).abs() < 1e-3
962 && (v[1].as_f64().unwrap() as f32 - want[1]).abs() < 1e-3
963 };
964
965 for (i, cap) in capsules.iter().enumerate() {
968 let r = cap["r"].as_f64().unwrap();
969 assert!(
970 (r - brep_gizmos::transform::AXIS_HIT_THRESH_PX as f64).abs() < 1e-6,
971 "axis {i}: r {r} must equal AXIS_HIT_THRESH_PX"
972 );
973 let (a, b) = engine.widgets.transform_axis_seg(&cam, i).unwrap();
974 let sa = cam.world_to_screen(a).unwrap();
975 let sb = cam.world_to_screen(b).unwrap();
976 assert!(
977 close2(&cap["a"], sa),
978 "axis {i} a: {:?} vs {sa:?}",
979 cap["a"]
980 );
981 assert!(
982 close2(&cap["b"], sb),
983 "axis {i} b: {:?} vs {sb:?}",
984 cap["b"]
985 );
986 }
987
988 let center = engine.widgets.transform_center_grab().unwrap();
990 let sc = cam.world_to_screen(center).unwrap();
991 let center_r = brep_gizmos::transform::PX_CENTER_RAD as f64 + 2.0;
992 assert!(
993 circles
994 .iter()
995 .any(|c| close2(&c["c"], sc) && (c["r"].as_f64().unwrap() - center_r).abs() < 1e-6),
996 "center circle at the projected origin"
997 );
998
999 let ring_r = brep_gizmos::transform::PX_RING_GRAB_RAD as f64 + 3.0;
1001 for g in engine.widgets.transform_ring_grabs(&cam).unwrap() {
1002 let sg = cam.world_to_screen(g).unwrap();
1003 assert!(
1004 circles.iter().any(
1005 |c| close2(&c["c"], sg) && (c["r"].as_f64().unwrap() - ring_r).abs() < 1e-6
1006 ),
1007 "ring grab circle at {sg:?}"
1008 );
1009 }
1010
1011 let regions = engine.widgets.transform_hit_regions(&cam);
1016 for (_, shape) in ®ions {
1017 let probe = match shape {
1018 brep_gizmos::hit_region::HitShape::Circle { c, .. } => *c,
1019 brep_gizmos::hit_region::HitShape::Capsule { a, b, .. } => {
1020 [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]
1021 }
1022 };
1023 let picked = engine.transform_pick(probe[0] as f64, probe[1] as f64);
1024 assert_ne!(picked, 0, "a handle under its own region at {probe:?}");
1025 let picked_shape = regions.iter().find(|(id, _)| *id == picked).unwrap().1;
1026 assert!(
1027 picked_shape.contains(probe),
1028 "picked handle {picked}'s region must contain the cursor {probe:?}"
1029 );
1030 }
1031
1032 engine.arm_dimension("Pin");
1034 assert_eq!(engine.transform_hit_areas_json(), "[]");
1035 }
1036}
1037
1038