1use super::transform_gizmo::{
2 euler_xyz_deg_from_quat, normalize3, quat_from_axis_angle, quat_from_euler_xyz_deg, quat_mul,
3 Quat,
4};
5use super::*;
6
7#[derive(Default)]
31pub struct ComponentMoveArm {
32 pub(super) feature_id: Option<String>,
34 pub(super) anchor: [f64; 3],
37 pub(super) drag: Option<ComponentMoveDrag>,
39}
40
41#[derive(Clone)]
45pub(super) struct ComponentMoveDrag {
46 handle: u32,
47 sx: f32,
48 sy: f32,
49 start: ComponentPose,
50 pending: Option<ComponentPose>,
51}
52
53#[derive(Clone, Copy, PartialEq, Debug)]
56pub(super) struct ComponentPose {
57 pub translate: [f64; 3],
58 pub rotate_deg: [f64; 3],
59 pub anchor: [f64; 3],
60}
61
62#[derive(Clone, Copy, PartialEq, Debug)]
64pub(super) enum ComponentDelta {
65 Translate([f64; 3]),
66 Rotate { axis: [f64; 3], radians: f64 },
67}
68
69impl EngineState {
70 pub fn component_move_armed(&self) -> bool {
72 self.component_move.feature_id.is_some()
73 }
74
75 pub fn component_move_armed_feature(&self) -> String {
77 self.component_move.feature_id.clone().unwrap_or_default()
78 }
79
80 pub fn component_move_toggle(&mut self, feature_id: &str) {
85 let Some(info) = self.component_info(feature_id) else {
86 self.push_notice(format!("'{feature_id}' is not an assembly component"));
87 return;
88 };
89 if info.fixed {
90 self.push_notice(format!(
91 "{} ({feature_id}) is fixed — unfix it to move",
92 info.part_name
93 ));
94 return;
95 }
96 if self.component_move.feature_id.as_deref() == Some(feature_id) {
97 self.disarm_transform();
98 } else {
99 self.component_move_arm_widget(feature_id);
100 }
101 }
102
103 pub(super) fn component_move_arm(&mut self, feature_id: &str) {
107 match self.component_info(feature_id) {
108 Some(info) if !info.fixed => self.component_move_arm_widget(feature_id),
109 _ => {}
110 }
111 }
112
113 pub(super) fn component_move_reset(&mut self) {
117 self.component_move = ComponentMoveArm::default();
118 }
119
120 fn component_move_arm_widget(&mut self, feature_id: &str) {
124 self.transform_gizmo.feature_id = None;
127 self.transform_gizmo.mode = GizmoMode::None;
128 self.transform_gizmo.drag = None;
129 self.clear_feature_dimension_overlay();
130
131 let anchor = self
132 .component_bbox_center(feature_id)
133 .or_else(|| self.component_info(feature_id).map(|info| info.translate))
134 .unwrap_or([0.0; 3]);
135 self.component_move.feature_id = Some(feature_id.to_string());
136 self.component_move.anchor = anchor;
137 self.component_move.drag = None;
138 self.feed_component_widget();
139 self.dirty = true;
140 }
141
142 fn feed_component_widget(&mut self) {
144 let Some(id) = self.component_move.feature_id.clone() else {
145 return;
146 };
147 let Some(info) = self.component_info(&id) else {
148 return;
149 };
150 let pose = ComponentPose {
151 translate: info.translate,
152 rotate_deg: info.rotate_deg,
153 anchor: self.component_move.anchor,
154 };
155 let json = component_frame_json(&pose);
156 let _ = self.widgets.set_transform_json(&json);
157 }
158
159 pub(super) fn component_move_sync(&mut self) {
163 let Some(id) = self.component_move.feature_id.clone() else {
164 return;
165 };
166 match self.component_info(&id) {
167 Some(info) if !info.fixed => {
168 self.component_move.anchor =
169 self.component_bbox_center(&id).unwrap_or(info.translate);
170 self.feed_component_widget();
171 self.dirty = true;
172 }
173 _ => self.disarm_transform(),
174 }
175 }
176
177 pub fn component_press(&mut self, x: f64, y: f64) -> bool {
180 let Some(id) = self.component_move.feature_id.clone() else {
181 return false;
182 };
183 let handle = self.transform_pick(x, y);
184 if handle == 0 {
185 return false;
186 }
187 let Some(info) = self.component_info(&id) else {
188 return false;
189 };
190 self.widgets.set_transform_active(handle);
191 self.component_move.drag = Some(ComponentMoveDrag {
192 handle,
193 sx: x as f32,
194 sy: y as f32,
195 start: ComponentPose {
196 translate: info.translate,
197 rotate_deg: info.rotate_deg,
198 anchor: self.component_move.anchor,
199 },
200 pending: None,
201 });
202 self.dirty = true;
203 true
204 }
205
206 pub fn component_move_dragging(&self) -> bool {
208 self.component_move.drag.is_some()
209 }
210
211 pub fn component_drag_to(&mut self, cx: f64, cy: f64) {
215 let Some(drag) = self.component_move.drag.clone() else {
216 return;
217 };
218 let cam = gizmo_camera(&self.camera);
219 let frame = component_frame_json(&drag.start);
220 let json = self.widgets.transform_drag_json_with_frame(
221 &cam,
222 &frame,
223 drag.handle,
224 drag.sx,
225 drag.sy,
226 cx as f32,
227 cy as f32,
228 );
229 let Some(delta) = parse_drag_delta(&json) else {
230 return;
231 };
232 let pending = compose_component_delta(&drag.start, &delta);
233 let json = component_frame_json(&pending);
236 let _ = self.widgets.set_transform_json(&json);
237 if let Some(live) = self.component_move.drag.as_mut() {
238 live.pending = Some(pending);
239 }
240 self.dirty = true;
241 }
242
243 pub fn component_release(&mut self) {
249 let Some(drag) = self.component_move.drag.take() else {
250 return;
251 };
252 self.widgets.set_transform_active(0);
253 self.dirty = true;
254 let Some(pending) = drag.pending else {
255 return;
256 };
257 let Some(id) = self.component_move.feature_id.clone() else {
258 return;
259 };
260 self.component_move.anchor = pending.anchor;
261 let Some(index) = self.history.index_of(&id) else {
262 return;
263 };
264 let mut params = self
265 .history
266 .feature_params(index)
267 .unwrap_or_else(|| serde_json::json!({}));
268 if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
269 if let Some(object) = params.as_object_mut() {
270 object.insert("transform".into(), serde_json::json!({}));
271 }
272 }
273 if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
274 transform.insert("translate".into(), serde_json::json!(pending.translate));
275 transform.insert("rotateEulerDeg".into(), serde_json::json!(pending.rotate_deg));
276 }
277 let _ = self.update_feature_params(&id, ¶ms.to_string());
278 }
279
280 pub fn component_move_json(&self) -> String {
283 serde_json::json!({
284 "armed": self.component_move_armed(),
285 "feature": self.component_move_armed_feature(),
286 "anchor": self.component_move.anchor,
287 })
288 .to_string()
289 }
290}
291
292fn component_frame_json(pose: &ComponentPose) -> String {
297 let euler = [
298 pose.rotate_deg[0].to_radians(),
299 pose.rotate_deg[1].to_radians(),
300 pose.rotate_deg[2].to_radians(),
301 ];
302 let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
303 let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
304 let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
305 serde_json::json!({
306 "origin": pose.anchor,
307 "x": x,
308 "y": y,
309 "z": z,
310 "showCenter": true,
311 "showAxes": true,
312 "showRings": true,
313 })
314 .to_string()
315}
316
317fn parse_drag_delta(json: &str) -> Option<ComponentDelta> {
320 let value: serde_json::Value = serde_json::from_str(json).ok()?;
321 let vec3 = |key: &str| -> [f64; 3] {
322 let mut out = [0.0; 3];
323 if let Some(array) = value.get(key).and_then(|v| v.as_array()) {
324 for (index, slot) in out.iter_mut().enumerate() {
325 if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
326 *slot = number;
327 }
328 }
329 }
330 out
331 };
332 match value.get("kind").and_then(|k| k.as_str()) {
333 Some("translate") => Some(ComponentDelta::Translate(vec3("world"))),
334 Some("rotate") => Some(ComponentDelta::Rotate {
335 axis: vec3("axisWorld"),
336 radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
337 }),
338 _ => None,
339 }
340}
341
342pub(super) fn compose_component_delta(
350 start: &ComponentPose,
351 delta: &ComponentDelta,
352) -> ComponentPose {
353 match delta {
354 ComponentDelta::Translate(d) => ComponentPose {
355 translate: add3(start.translate, *d),
356 rotate_deg: start.rotate_deg,
357 anchor: add3(start.anchor, *d),
358 },
359 ComponentDelta::Rotate { axis, radians } => {
360 let dq = quat_from_axis_angle(*axis, *radians);
361 let q0 = quat_from_euler_xyz_deg(start.rotate_deg);
362 let rotate_deg = euler_xyz_deg_from_quat(quat_mul(dq, q0));
363 let offset = sub3(start.translate, start.anchor);
364 ComponentPose {
365 translate: add3(start.anchor, quat_rotate(dq, offset)),
366 rotate_deg,
367 anchor: start.anchor,
368 }
369 }
370 }
371}
372
373fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
374 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
375}
376
377fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
378 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
379}
380
381fn quat_rotate(q: Quat, v: [f64; 3]) -> [f64; 3] {
383 let u = [q[0], q[1], q[2]];
384 let w = q[3];
385 let cross = |a: [f64; 3], b: [f64; 3]| -> [f64; 3] {
386 [
387 a[1] * b[2] - a[2] * b[1],
388 a[2] * b[0] - a[0] * b[2],
389 a[0] * b[1] - a[1] * b[0],
390 ]
391 };
392 let t = cross(u, add3(cross(u, v), [w * v[0], w * v[1], w * v[2]]));
393 [v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
394}
395
396#[cfg(test)]
397mod component_move_tests {
398 use super::super::component_fixtures::two_instance_assembly_json;
399 use super::*;
400
401 fn pose(translate: [f64; 3], rotate_deg: [f64; 3], anchor: [f64; 3]) -> ComponentPose {
402 ComponentPose { translate, rotate_deg, anchor }
403 }
404
405 #[test]
406 fn compose_translate_shifts_pose_and_anchor_together() {
407 let out = compose_component_delta(
408 &pose([20.0, 0.0, 0.0], [0.0, 0.0, 0.0], [25.0, 5.0, 5.0]),
409 &ComponentDelta::Translate([4.0, -1.0, 0.5]),
410 );
411 assert_eq!(out.translate, [24.0, -1.0, 0.5]);
412 assert_eq!(out.anchor, [29.0, 4.0, 5.5]);
413 assert_eq!(out.rotate_deg, [0.0, 0.0, 0.0]);
414 }
415
416 #[test]
422 fn compose_rotate_pivots_about_the_anchor() {
423 let out = compose_component_delta(
424 &pose([20.0, 0.0, 0.0], [0.0, 0.0, 0.0], [25.0, 5.0, 5.0]),
425 &ComponentDelta::Rotate {
426 axis: [0.0, 0.0, 1.0],
427 radians: std::f64::consts::FRAC_PI_2,
428 },
429 );
430 for (got, want) in out.translate.iter().zip([30.0, 0.0, 0.0]) {
431 assert!((got - want).abs() < 1e-9, "translate {:?}", out.translate);
432 }
433 assert!((out.rotate_deg[2] - 90.0).abs() < 1e-6, "{:?}", out.rotate_deg);
434 assert!(out.rotate_deg[0].abs() < 1e-6 && out.rotate_deg[1].abs() < 1e-6);
435 assert_eq!(out.anchor, [25.0, 5.0, 5.0], "rotation never moves the pivot");
436
437 let back = compose_component_delta(
439 &out,
440 &ComponentDelta::Rotate {
441 axis: [0.0, 0.0, 1.0],
442 radians: -std::f64::consts::FRAC_PI_2,
443 },
444 );
445 for (got, want) in back.translate.iter().zip([20.0, 0.0, 0.0]) {
446 assert!((got - want).abs() < 1e-9, "round-trip {:?}", back.translate);
447 }
448 for angle in back.rotate_deg {
449 assert!(angle.abs() < 1e-6, "round-trip {:?}", back.rotate_deg);
450 }
451 }
452
453 fn assembly_engine_with_camera() -> EngineState {
454 let mut engine = EngineState::new();
455 engine
456 .set_history_json(&two_instance_assembly_json())
457 .unwrap();
458 engine.resize(800.0, 600.0);
459 engine.camera.eye = [25.0, 5.0, 45.0];
462 engine.camera.target = [25.0, 5.0, 5.0];
463 engine.camera.up = [0.0, 1.0, 0.0];
464 engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
465 engine
466 }
467
468 #[test]
469 fn toggle_arms_the_full_gizmo_then_disarms_and_fixed_refuses() {
470 let mut engine = assembly_engine_with_camera();
471 assert!(!engine.component_move_armed());
472
473 engine.component_move_toggle("ACOMP1");
475 assert!(!engine.component_move_armed());
476 let notices = engine.take_notices();
477 assert!(
478 notices.iter().any(|n| n.contains("fixed")),
479 "fixed refusal toast: {notices:?}"
480 );
481
482 engine.component_move_toggle("ACOMP2");
485 assert!(engine.component_move_armed());
486 assert!(engine.widgets.has_transform(), "arming feeds the widget");
487 let cam = gizmo_camera(&engine.camera);
488 assert!(
489 engine.widgets.transform_ring_grabs(&cam).is_some(),
490 "rotation arcs are shown together with the move handles"
491 );
492 assert!(!engine.transform_armed(), "the FEATURE gizmo stays disarmed");
493
494 engine.component_move_toggle("ACOMP2");
495 assert!(!engine.component_move_armed(), "second toggle disarms");
496 assert!(!engine.widgets.has_transform(), "widget cleared");
497 assert!(engine.take_notices().is_empty(), "no spurious toasts");
498 }
499
500 #[test]
501 fn arm_transform_routes_acomp_to_the_component_gizmo() {
502 let mut engine = assembly_engine_with_camera();
503 engine.arm_transform("ACOMP2");
506 assert!(engine.component_move_armed());
507 assert_eq!(engine.component_move_armed_feature(), "ACOMP2");
508 assert!(!engine.transform_armed(), "generic gizmo must not arm for ACOMP");
509
510 engine.disarm_transform();
513 engine.arm_transform("ACOMP1");
514 assert!(!engine.component_move_armed());
515 assert!(!engine.transform_armed());
516 assert!(engine.take_notices().is_empty(), "routed refusal is silent");
517 }
518
519 #[test]
520 fn translate_drag_is_free_move_and_commits_on_release() {
521 let mut engine = assembly_engine_with_camera();
522 engine.component_move_toggle("ACOMP2");
523 let anchor = engine.component_move.anchor;
524 assert!((anchor[0] - 25.0).abs() < 1e-6, "bbox-center anchor: {anchor:?}");
525
526 let (sx, sy, depth) = engine.camera.project(anchor);
529 assert!(depth > 0.0);
530 assert!(engine.component_press(sx, sy), "center handle grabbed");
531 assert!(engine.component_move_dragging());
532
533 engine.component_drag_to(sx + 60.0, sy);
536 let info = engine.component_info("ACOMP2").unwrap();
537 assert_eq!(info.translate, [20.0, 0.0, 0.0], "no param write mid-drag");
538 let solid_x = engine
539 .scene
540 .solid("ACOMP2:Part")
541 .expect("member solid")
542 .bbox
543 .min[0];
544 assert!((solid_x - 20.0).abs() < 1e-6, "mesh stays put mid-drag");
545 let widget_origin = engine.widgets.transform_origin().expect("widget shown");
546 assert!(
547 widget_origin[0] as f64 > anchor[0] + 2.0,
548 "the gizmo follows the pointer: {widget_origin:?}"
549 );
550
551 engine.component_release();
553 assert!(!engine.component_move_dragging());
554 let info = engine.component_info("ACOMP2").unwrap();
555 assert!(
556 (info.translate[0] - 24.0).abs() < 0.2,
557 "committed translate: {:?}",
558 info.translate
559 );
560 let solid_x = engine.scene.solid("ACOMP2:Part").unwrap().bbox.min[0];
561 assert!(
562 (solid_x - info.translate[0]).abs() < 1e-6,
563 "the rerun re-posed the member to the committed pose"
564 );
565 assert!(engine.history.can_undo(), "commit minted an undo entry");
567 }
568
569 #[test]
577 fn component_move_hit_areas_match_the_shared_hit_test() {
578 let mut engine = assembly_engine_with_camera();
579 engine.camera.eye = [55.0, 25.0, 45.0];
581 assert_eq!(engine.transform_hit_areas_json(), "[]", "nothing armed → no outlines");
582
583 engine.component_move_toggle("ACOMP2");
584 assert_eq!(engine.gizmo_mode(), "none", "component arm keeps the ◎ mode none");
585 let areas: Vec<serde_json::Value> =
586 serde_json::from_str(&engine.transform_hit_areas_json()).unwrap();
587 let capsules = areas.iter().filter(|a| a["kind"] == "capsule").count();
588 let circles = areas.iter().filter(|a| a["kind"] == "circle").count();
589 assert_eq!(capsules, 3, "one capsule per axis arrow");
590 assert_eq!(circles, 4, "center + 3 ring grab circles");
591
592 let cam = gizmo_camera(&engine.camera);
596 let regions = engine.widgets.transform_hit_regions(&cam);
597 assert_eq!(regions.len(), areas.len(), "outline set == hit-region set");
598 for (_, shape) in ®ions {
599 let probe = match shape {
600 brep_gizmos::hit_region::HitShape::Circle { c, .. } => *c,
601 brep_gizmos::hit_region::HitShape::Capsule { a, b, .. } => {
602 [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]
603 }
604 };
605 let picked = engine.transform_pick(probe[0] as f64, probe[1] as f64);
606 assert_ne!(picked, 0, "a handle under its own region at {probe:?}");
607 let picked_shape = regions.iter().find(|(id, _)| *id == picked).unwrap().1;
608 assert!(
609 picked_shape.contains(probe),
610 "picked handle {picked}'s region must contain the cursor {probe:?}"
611 );
612 }
613
614 engine.component_move_toggle("ACOMP2");
615 assert_eq!(engine.transform_hit_areas_json(), "[]", "disarm retracts the outlines");
616 }
617
618 #[test]
619 fn rotate_drag_commits_the_anchor_pivot_compose() {
620 let mut engine = assembly_engine_with_camera();
621 engine.component_move_toggle("ACOMP2"); let cam = gizmo_camera(&engine.camera);
626 let grabs = engine.widgets.transform_ring_grabs(&cam).expect("gizmo shown");
627 let grab = grabs[0];
628 let anchor = engine.component_move.anchor;
629 let start = cam
630 .world_to_screen(grab)
631 .expect("grab projects");
632 assert!(engine.component_press(start[0] as f64, start[1] as f64), "ring grabbed");
633
634 let offset = [grab.x as f64 - anchor[0], grab.y as f64 - anchor[1]];
636 let target_world = [
637 anchor[0] - offset[1],
638 anchor[1] + offset[0],
639 grab.z as f64,
640 ];
641 let (tx, ty, _) = engine.camera.project(target_world);
642 engine.component_drag_to(tx, ty);
643 engine.component_release();
644
645 let info = engine.component_info("ACOMP2").unwrap();
646 assert!(
647 (info.rotate_deg[2] - 90.0).abs() < 0.5,
648 "committed +90° about Z: {:?}",
649 info.rotate_deg
650 );
651 assert!(
653 (info.translate[0] - 30.0).abs() < 0.1 && info.translate[1].abs() < 0.1,
654 "pivot compose: {:?}",
655 info.translate
656 );
657 let center = engine.component_bbox_center("ACOMP2").unwrap();
659 assert!(
660 (center[0] - 25.0).abs() < 0.1 && (center[1] - 5.0).abs() < 0.1,
661 "bbox center invariant under the pivot rotation: {center:?}"
662 );
663 }
664}