Skip to main content

brep_render/engine_state/
component_move.rs

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// ============================================================================
8// The COMPONENT Move gizmo (build-spec §8.5, lane H) — free move + re-solve on
9// commit. Reuses the SAME widget transform gizmo (brep-gizmos) as the feature
10// transform controller, but with component semantics:
11//
12//   * ATTACHES at the component's member-bbox CENTER (not the pose origin).
13//   * The Move toggle ARMS/DISARMS (`component_move_toggle`); the armed gizmo
14//     shows EVERY handle set at once — axis arrows + the center free-move ball
15//     + the rotation arcs — so moving and rotating never needs a mode switch.
16//   * FIXED components refuse with a toast and never arm.
17//   * A drag moves ONLY the gizmo (free move) — the pose param is written ONCE
18//     on release (`component_release`), composing the drag delta onto the
19//     ACOMP's `inputParams.transform` `{translate, rotateEulerDeg}` (intrinsic-
20//     XYZ degrees, the kernel `compose_trs_matrix` convention) and re-running.
21//     The constraint tail re-solves on that run and may snap the component back
22//     into compliance — BY DESIGN (free move + re-solve on commit; live
23//     drag-solve is the named follow-up).
24//
25// EXCLUSIVE with the feature transform/dimension gizmos: arming any one of the
26// three resets the others (they share the one widget slot).
27// ============================================================================
28
29/// The component-Move controller state (one per engine, like [`TransformArm`]).
30#[derive(Default)]
31pub struct ComponentMoveArm {
32    /// The ACOMP feature id the gizmo is armed for (`None` = disarmed).
33    pub(super) feature_id: Option<String>,
34    /// The gizmo pivot — the component's member-bbox center, re-synced after
35    /// every applied run so the gizmo follows a re-solved (snapped) component.
36    pub(super) anchor: [f64; 3],
37    /// The in-flight handle drag (grab snapshot + the pending composed pose).
38    pub(super) drag: Option<ComponentMoveDrag>,
39}
40
41/// A grab snapshot: the handle, the grab screen point, the pose AT GRAB (the
42/// frozen frame every drag move resolves against — no error accumulation), and
43/// the PENDING composed pose the release commits.
44#[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/// A component pose as the gizmo tracks it: the ACOMP `transform` pair plus the
54/// gizmo anchor (bbox center) it pivots about.
55#[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/// A resolved world-space gizmo delta (parsed off the widget drag JSON).
63#[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    /// Whether the component Move gizmo is armed (for any component).
71    pub fn component_move_armed(&self) -> bool {
72        self.component_move.feature_id.is_some()
73    }
74
75    /// The armed component feature id (empty when disarmed).
76    pub fn component_move_armed_feature(&self) -> String {
77        self.component_move.feature_id.clone().unwrap_or_default()
78    }
79
80    /// The Move toggle (context bar / tree action): ARMS the full gizmo (all
81    /// handle sets) for `feature_id`, or DISARMS when it is already armed
82    /// (arming fresh replaces any other armed component). A FIXED component
83    /// refuses with a toast and never arms (spec §8.5).
84    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    /// The `arm_transform` ROUTE for ACOMP features (history-panel expand):
104    /// a FIXED component silently stays armless (the explicit Move action is
105    /// the one that toasts).
106    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    /// Drop the component arm STATE only (the caller owns the widget slot) —
114    /// the exclusivity hook `arm_transform` / `arm_dimension` / `disarm_transform`
115    /// call before taking the slot for themselves.
116    pub(super) fn component_move_reset(&mut self) {
117        self.component_move = ComponentMoveArm::default();
118    }
119
120    /// Arm for `feature_id`: claim the shared widget slot (clearing the
121    /// feature gizmo + dimension overlay), pin the anchor at the member-bbox
122    /// center, and feed the full handle set.
123    fn component_move_arm_widget(&mut self, feature_id: &str) {
124        // Claim the shared slot WITHOUT disarm_transform (which would also reset
125        // the component state we are about to set).
126        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    /// (Re)feed the widget gizmo at the armed component's current pose+anchor.
143    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    /// Post-run re-sync (the [`finish_apply`] hook, mirroring
160    /// `sync_transform_gizmo`): re-anchor at the possibly re-solved member bbox
161    /// and re-feed; auto-disarm when the component vanished or became fixed.
162    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    /// Begin a component-gizmo drag at viewport px `(x, y)`; `true` when a
178    /// handle was grabbed (the viewport routes the drag here, not the camera).
179    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    /// Whether a component-gizmo drag is in flight.
207    pub fn component_move_dragging(&self) -> bool {
208        self.component_move.drag.is_some()
209    }
210
211    /// Continue the drag: resolve the world delta against the FROZEN grab frame,
212    /// compose the pending pose, and move ONLY the visible gizmo (free move —
213    /// the mesh follows on release, when the commit re-runs + re-solves).
214    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        // Live-follow the WIDGET at the pending pose; the gold active-handle
234        // highlight survives (only a null feed clears it).
235        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    /// End the drag: COMMIT the pending pose into the ACOMP's
244    /// `inputParams.transform` (one param write → one undo entry → one rerun
245    /// whose constraint tail re-solves; the post-run sync then re-glues the
246    /// gizmo to wherever the solve left the component). A grab that never moved
247    /// commits nothing.
248    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, &params.to_string());
278    }
279
280    /// The armed component gizmo's logical state for the verifier:
281    /// `{armed, feature, anchor}`.
282    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
292/// The widget frame feed for a component pose: origin = the ANCHOR (bbox
293/// center), axes = the pose's rotated basis (intrinsic XYZ, the kernel bake).
294/// EVERY handle set is shown (center free-move ball + axis arrows + rotation
295/// arcs) — move and rotate coexist, no mode switch.
296fn 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
317/// Parse the widget drag JSON (`transform_drag_json_with_frame`) into a world
318/// delta; `None` for `{"kind":"none"}` / degenerate drags.
319fn 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
342/// Compose a world-space gizmo delta onto a component pose. PURE (unit-tested
343/// directly). Translation shifts pose + anchor together; rotation pivots about
344/// the ANCHOR `C` (the gizmo sits at the bbox center, so the component spins in
345/// place about it): `R' = dR·R` and `translate' = C + dR·(translate − C)` —
346/// decomposed back to intrinsic-XYZ degrees exactly like the kernel's
347/// `compose_trs_matrix` convention (shared quaternion helpers with the feature
348/// transform gizmo).
349pub(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
381/// Rotate `v` by quaternion `q`: `v + 2·(q.xyz × (q.xyz × v + w·v))`.
382fn 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    /// The pivot math pin: +90° about world Z through the anchor C=[25,5,5]
417    /// maps the pose origin t=[20,0,0] to C + Rz90·(t−C) = [30,0,0] and the
418    /// orientation to [0,0,90] — the exact intrinsic-XYZ compose the kernel's
419    /// `compose_trs_matrix` bakes, so committing this pose re-poses the solid
420    /// identically to spinning it in place about its bbox center.
421    #[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        // Round-trip: −90° about the same pivot undoes it exactly.
438        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        // Look straight down −Z at ACOMP2's bbox center [25,5,5] so it projects
460        // to the viewport center and screen-right is world +X.
461        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        // FIXED component: toast + never arms.
474        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        // Free component: one toggle arms EVERY handle set at once (the ring
483        // grabs prove the arcs are up alongside the arrows/center).
484        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        // The history panel's expand path calls arm_transform for a dimension-
504        // less transformable — an ACOMP must arm the COMPONENT gizmo instead.
505        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        // A FIXED component silently stays armless on the routed path (only the
511        // explicit Move action toasts).
512        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        // The anchor projects to the viewport center; the center free-move
527        // handle sits there (translate mode shows it).
528        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        // Drag 60 px screen-right (+X world). FREE MOVE: the widget follows,
534        // the params + mesh do NOT (commit-on-release).
535        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        // Release: ONE commit — 60 px at world_per_pixel (2·20/600) = 4 units.
552        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        // The commit is a real user edit → exactly one undo entry.
566        assert!(engine.history.can_undo(), "commit minted an undo entry");
567    }
568
569    /// Draw==hit for the COMPONENT Move gizmo: `transform_hit_areas_json` emits
570    /// the shared widget's screen regions while the component gizmo is armed —
571    /// even though the ◎ mode stays `"none"` (the WIDGET FEED, not the mode, is
572    /// authoritative, exactly like `transform_pick`) — with the full handle set
573    /// (3 axis capsules + the center + 3 ring-grab circles), and a cursor at
574    /// each region's own reference point picks a handle whose region contains
575    /// it. Disarming retracts the outlines to `[]`.
576    #[test]
577    fn component_move_hit_areas_match_the_shared_hit_test() {
578        let mut engine = assembly_engine_with_camera();
579        // An oblique view so the three projected axis segments are distinct.
580        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        // Draw==hit invariant (the feature-gizmo test's probe): the picked
593        // handle's region contains the cursor at each region's reference point
594        // (regions overlap by design, so "same handle" isn't required).
595        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 &regions {
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"); // arcs are up alongside the arrows
622
623        // Grab the Z-rotation ball (ARCS order puts Z first) and drag it +90°
624        // about world Z through the anchor.
625        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        // Target = the grab point rotated +90° about Z through the anchor.
635        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        // translate' = C + Rz90·(t−C): [20,0,0] about [25,5,5] → [30,0,0].
652        assert!(
653            (info.translate[0] - 30.0).abs() < 0.1 && info.translate[1].abs() < 0.1,
654            "pivot compose: {:?}",
655            info.translate
656        );
657        // Spinning a cube about its own bbox center keeps the center in place.
658        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}