Skip to main content

brep_render/engine_state/
transform_gizmo.rs

1use super::*;
2
3/// Which of the two gizmos is currently armed for the feature. `Transform` shows
4/// the move/rotate gizmo; `Dimension` shows the on-canvas draggable dimension
5/// annotations (feature-dimensions FD-1). Expanding a feature arms `Dimension`;
6/// the viewport sphere/center toggle flips to `Transform` and back. The two modes
7/// are EXCLUSIVE — the transform gizmo never renders in `Dimension` mode and
8/// vice-versa.
9#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
10pub enum GizmoMode {
11    /// Neither gizmo armed (`feature_id` is `None`).
12    #[default]
13    None,
14    /// The move/rotate transform gizmo is armed.
15    Transform,
16    /// The dimension-annotation gizmo is armed (FD-1).
17    Dimension,
18}
19
20/// The transform-controls gizmo controller state (see the impl block below).
21#[derive(Default)]
22pub struct TransformArm {
23    /// The feature id whose gizmo is armed (for EITHER mode), or `None`
24    /// (disarmed → `mode == None`).
25    pub(super) feature_id: Option<String>,
26    /// Which gizmo is armed for `feature_id` (transform vs dimension). `None`
27    /// exactly when `feature_id` is `None`.
28    pub(super) mode: GizmoMode,
29    /// The live handle drag, captured on pointer-down over a gizmo handle.
30    pub(super) drag: Option<TransformDrag>,
31}
32
33/// A grab snapshot for an in-flight transform-gizmo drag: the grabbed handle,
34/// the grab screen point, and the feature's pose AT GRAB. Every drag move
35/// resolves an absolute delta from this grab (against the pinned grab-time
36/// frame) and re-applies it to `start`, so the drag never accumulates error.
37#[derive(Clone, Copy)]
38pub(super) struct TransformDrag {
39    handle: u32,
40    sx: f32,
41    sy: f32,
42    start: TransformPose,
43}
44
45/// A TRS pose read from / written to a feature's `inputParams.transform`
46/// (`rotation` in DEGREES, intrinsic XYZ Euler order — the kernel `transform_bake`
47/// convention, `M = T·R·S`).
48#[derive(Clone, Copy, PartialEq, Debug)]
49struct TransformPose {
50    position: [f64; 3],
51    rotation_deg: [f64; 3],
52    scale: [f64; 3],
53}
54
55/// A resolved gizmo drag delta in WORLD space (the ported drag-delta mapping).
56#[derive(Clone, Copy, PartialEq, Debug)]
57enum TransformDelta {
58    /// World-space translation, added to `position`.
59    Translate([f64; 3]),
60    /// Rotation about a WORLD axis by `radians`, pre-multiplied onto the pose's
61    /// orientation (so it spins about the feature's own axis).
62    Rotate { axis: [f64; 3], radians: f64 },
63    /// No usable delta (unknown handle / degenerate drag).
64    None,
65}
66
67impl EngineState {
68    /// Whether the TRANSFORM gizmo (move/rotate) is armed for ANY feature. False
69    /// in dimension mode — the two ◎ modes are exclusive, so the transform gizmo
70    /// arms/handles never render while dimensions are shown.
71    pub fn transform_armed(&self) -> bool {
72        matches!(self.transform_gizmo.mode, GizmoMode::Transform)
73    }
74
75    /// The transform-gizmo-armed feature id (empty unless in transform mode).
76    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    /// Whether the TRANSFORM gizmo is armed for THIS feature.
85    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    /// Arm the TRANSFORM gizmo for `feature_id` and feed it at the feature's
90    /// transform frame. Re-arming a different feature moves the gizmo to it.
91    /// Clears any dimension overlay (the modes are exclusive).
92    pub fn arm_transform(&mut self, feature_id: &str) {
93        self.transform_gizmo.feature_id = Some(feature_id.to_string());
94        self.transform_gizmo.mode = GizmoMode::Transform;
95        self.transform_gizmo.drag = None;
96        self.clear_feature_dimension_overlay();
97        self.sync_transform_gizmo();
98    }
99
100    /// Disarm: hide BOTH gizmos + drop any in-flight drag.
101    pub fn disarm_transform(&mut self) {
102        self.transform_gizmo.feature_id = None;
103        self.transform_gizmo.mode = GizmoMode::None;
104        self.transform_gizmo.drag = None;
105        let _ = self.widgets.set_transform_json("null");
106        self.clear_feature_dimension_overlay();
107        self.dirty = true;
108    }
109
110    /// The armed feature's current TRS pose (from its `inputParams.transform`),
111    /// or `None` when disarmed / the feature vanished.
112    fn armed_pose(&self) -> Option<TransformPose> {
113        let id = self.transform_gizmo.feature_id.as_deref()?;
114        let index = self.history.index_of(id)?;
115        let params = self.history.feature_params(index)?;
116        let transform = params.get("transform");
117        Some(TransformPose {
118            position: read_pose_vec3(transform, "position", [0.0, 0.0, 0.0]),
119            rotation_deg: read_pose_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]),
120            scale: read_pose_vec3(transform, "scale", [1.0, 1.0, 1.0]),
121        })
122    }
123
124    /// (Re)feed the widget gizmo at the armed feature's frame: origin =
125    /// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
126    /// the kernel bake). Auto-disarms if the feature vanished. Called every drag
127    /// frame from `transform_drag_to` so the widget tracks the moving pose live
128    /// (Fix 3); the drag delta resolves against the frozen grab frame, so this
129    /// re-sync never feeds back into the drag math.
130    pub fn sync_transform_gizmo(&mut self) {
131        // Only the TRANSFORM mode feeds the move/rotate widget; in dimension mode
132        // the widget stays hidden (the annotations render as an overlay instead).
133        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
134            return;
135        }
136        let Some(pose) = self.armed_pose() else {
137            self.disarm_transform();
138            return;
139        };
140        let _ = self.widgets.set_transform_json(&transform_frame_json(&pose));
141        self.dirty = true;
142    }
143
144    /// Whether feature `feature_id` carries a `transform` param (a Transform
145    /// group), so it CAN show a transform gizmo. The panel's auto-arm-on-expand
146    /// uses this: a feature with NO dimension gizmo but WITH a transform
147    /// (datum/helix/pattern/port) arms the TRANSFORM gizmo directly on expand,
148    /// instead of being left with no gizmo at all now that the ◎ arm button is
149    /// gone. (`armed_pose` alone can't gate this — it defaults to identity for a
150    /// transform-less feature, so a boolean/fillet would show a spurious gizmo.)
151    pub fn feature_has_transform(&self, feature_id: &str) -> bool {
152        self.history
153            .index_of(feature_id)
154            .and_then(|i| self.history.feature_params(i))
155            .map(|params| params.get("transform").is_some())
156            .unwrap_or(false)
157    }
158
159    /// The armed gizmo origin projected to VIEWPORT-LOCAL px (the center handle
160    /// sits here). The history panel publishes it so the headed verifier can
161    /// locate + drag the gizmo. `None` when disarmed / behind the camera.
162    pub fn transform_gizmo_anchor(&self) -> Option<(f64, f64)> {
163        let pose = self.armed_pose()?;
164        let (sx, sy, depth) = self.camera.project(pose.position);
165        (depth > 0.0).then_some((sx, sy))
166    }
167
168    /// The transform gizmo's axis-end labels as JSON:
169    /// `[{ text:"XC"|"YC"|"ZC", rgb:[r,g,b], world:[x,y,z] }]`. The app projects
170    /// each `world` point and draws the colored egui label just past the matching
171    /// cone tip (X=red, Y=green, Z=blue). `[]` unless the TRANSFORM gizmo is armed.
172    pub fn transform_axis_labels_json(&self) -> String {
173        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
174            return "[]".to_string();
175        }
176        let Some(pose) = self.armed_pose() else {
177            return "[]".to_string();
178        };
179        let euler = [
180            pose.rotation_deg[0].to_radians(),
181            pose.rotation_deg[1].to_radians(),
182            pose.rotation_deg[2].to_radians(),
183        ];
184        let axes = [
185            normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
186            normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
187            normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
188        ];
189        // Just past the cone tip, screen-constant.
190        let gap_px = 12.0_f64;
191        let dist = (brep_gizmos::transform::PX_AXIS_LEN as f64 + gap_px) * self.camera.world_per_pixel();
192        let labels: [(&str, [f32; 3]); 3] = [
193            ("XC", [0.92, 0.26, 0.28]), // red
194            ("YC", [0.30, 0.78, 0.36]), // green
195            ("ZC", [0.30, 0.52, 0.98]), // blue
196        ];
197        let out: Vec<serde_json::Value> = (0..3)
198            .map(|i| {
199                let o = pose.position;
200                let a = axes[i];
201                serde_json::json!({
202                    "text": labels[i].0,
203                    "rgb": labels[i].1,
204                    "world": [o[0] + a[0] * dist, o[1] + a[1] * dist, o[2] + a[2] * dist],
205                })
206            })
207            .collect();
208        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
209    }
210
211    /// Begin a gizmo drag at viewport px `(x, y)` when the gizmo is armed AND a
212    /// handle is under the pointer. Returns whether a handle was grabbed — the
213    /// viewport routes the drag to the gizmo (not the camera) when `true`; a press
214    /// on empty space returns `false` and still orbits.
215    pub fn transform_press(&mut self, x: f64, y: f64) -> bool {
216        // Only grabbable in TRANSFORM mode — in dimension mode the dimension
217        // handles own the pointer (routed by the app), and disarmed grabs nothing.
218        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
219            return false;
220        }
221        let handle = self.transform_pick(x, y);
222        if handle == 0 {
223            return false;
224        }
225        let Some(pose) = self.armed_pose() else {
226            return false;
227        };
228        self.widgets.set_transform_active(handle);
229        self.transform_gizmo.drag = Some(TransformDrag {
230            handle,
231            sx: x as f32,
232            sy: y as f32,
233            start: pose,
234        });
235        self.dirty = true;
236        true
237    }
238
239    /// Whether a gizmo handle drag is in flight.
240    pub fn transform_dragging(&self) -> bool {
241        self.transform_gizmo.drag.is_some()
242    }
243
244    /// Continue the in-flight gizmo drag to viewport px `(cx, cy)`: resolve the
245    /// world delta from the grab (against the frozen grab-time frame), apply it to
246    /// the grab pose, write it back into the feature's `transform`, and re-run so
247    /// the model follows live. Then re-sync the VISIBLE gizmo to the moved pose so
248    /// the widget tracks the pointer in real time (Fix 3) — the delta stays
249    /// anchored to `drag.start`, so this visual sync never feeds back on itself.
250    pub fn transform_drag_to(&mut self, cx: f64, cy: f64) {
251        let Some(drag) = self.transform_gizmo.drag else {
252            return;
253        };
254        let delta = self.resolve_transform_delta(&drag, cx, cy);
255        if matches!(delta, TransformDelta::None) {
256            return;
257        }
258        let pose = apply_transform_delta(&drag.start, &delta);
259        self.write_armed_pose(&pose);
260        // Live-follow: re-feed the widget frame to the just-written pose. NB
261        // `finish_apply` skips this while a drag is in flight (drag.is_some()), so
262        // the sync happens here. The active-handle gold highlight survives (the
263        // widget only clears it on a `null` feed).
264        self.sync_transform_gizmo();
265    }
266
267    /// End the drag: clear the active-handle highlight + re-sync the gizmo to the
268    /// feature's final (moved) pose (unpin the frame).
269    pub fn transform_release(&mut self) {
270        if self.transform_gizmo.drag.take().is_some() {
271            self.widgets.set_transform_active(0);
272            self.sync_transform_gizmo();
273        }
274    }
275
276    /// Resolve the widget gizmo's frame delta for a drag from the grab to
277    /// `(cx, cy)` into a world-space [`TransformDelta`] — via
278    /// `WidgetRegistry::transform_drag_json_with_frame` + the ported commit mapping.
279    ///
280    /// The delta is resolved against the FROZEN grab-time frame (rebuilt from
281    /// `drag.start`), NOT the live widget gizmo — so `transform_drag_to` can
282    /// re-sync the VISIBLE gizmo to the moving pose every frame (Fix 3 live-follow)
283    /// without the visual sync feeding back into the drag math. Since a param
284    /// change outside a drag always flows through `finish_apply` → `sync`, the grab
285    /// pose is exactly the frame the widget held at grab, so this is behaviorally
286    /// identical to the old pinned-widget-frame math — plus the live visual sync.
287    fn resolve_transform_delta(&self, drag: &TransformDrag, cx: f64, cy: f64) -> TransformDelta {
288        let cam = gizmo_camera(&self.camera);
289        let frame_json = transform_frame_json(&drag.start);
290        let json = self.widgets.transform_drag_json_with_frame(
291            &cam,
292            &frame_json,
293            drag.handle,
294            drag.sx,
295            drag.sy,
296            cx as f32,
297            cy as f32,
298        );
299        let value: serde_json::Value =
300            serde_json::from_str(&json).unwrap_or(serde_json::Value::Null);
301        match value.get("kind").and_then(|k| k.as_str()) {
302            Some("translate") => {
303                TransformDelta::Translate(json_vec3(value.get("world").and_then(|a| a.as_array())))
304            }
305            Some("rotate") => TransformDelta::Rotate {
306                axis: json_vec3(value.get("axisWorld").and_then(|a| a.as_array())),
307                radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
308            },
309            _ => TransformDelta::None,
310        }
311    }
312
313    /// Write `pose` into the armed feature's `inputParams.transform.{position,
314    /// rotationEuler}` (preserving every other field, incl. `scale`) and re-run.
315    fn write_armed_pose(&mut self, pose: &TransformPose) {
316        let Some(id) = self.transform_gizmo.feature_id.clone() else {
317            return;
318        };
319        let Some(index) = self.history.index_of(&id) else {
320            return;
321        };
322        let mut params = self
323            .history
324            .feature_params(index)
325            .unwrap_or_else(|| serde_json::json!({}));
326        // Ensure `transform` is an object, then set the two edited vectors.
327        if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
328            if let Some(object) = params.as_object_mut() {
329                object.insert("transform".into(), serde_json::json!({}));
330            }
331        }
332        if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
333            transform.insert("position".into(), serde_json::json!(pose.position));
334            transform.insert("rotationEuler".into(), serde_json::json!(pose.rotation_deg));
335        }
336        let _ = self.update_feature_params(&id, &params.to_string());
337    }
338}
339
340/// Apply a resolved drag delta to the grab pose → the new pose. PURE (the unit
341/// test drives it directly). Translation adds the world delta to `position`;
342/// rotation pre-multiplies a world-axis quaternion onto the pose's orientation
343/// and re-extracts the intrinsic XYZ Euler order (degrees). `scale` is a documented seam
344/// (no scale handle exists in the gizmo yet), so it is carried through unchanged.
345fn apply_transform_delta(start: &TransformPose, delta: &TransformDelta) -> TransformPose {
346    match delta {
347        TransformDelta::Translate(world) => TransformPose {
348            position: [
349                start.position[0] + world[0],
350                start.position[1] + world[1],
351                start.position[2] + world[2],
352            ],
353            ..*start
354        },
355        TransformDelta::Rotate { axis, radians } => {
356            let q0 = quat_from_euler_xyz_deg(start.rotation_deg);
357            let dq = quat_from_axis_angle(*axis, *radians);
358            let nq = quat_mul(dq, q0);
359            TransformPose {
360                rotation_deg: euler_xyz_deg_from_quat(nq),
361                ..*start
362            }
363        }
364        TransformDelta::None => *start,
365    }
366}
367
368/// Read a `[x, y, z]` from a transform sub-field (numbers only; missing / short
369/// arrays keep the per-index default).
370fn read_pose_vec3(transform: Option<&serde_json::Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
371    let array = transform.and_then(|t| t.get(key)).and_then(|v| v.as_array());
372    let mut out = default;
373    if let Some(array) = array {
374        for (index, slot) in out.iter_mut().enumerate() {
375            if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
376                *slot = number;
377            }
378        }
379    }
380    out
381}
382
383/// A `[f64; 3]` from a JSON number array (zero-filled past the end).
384fn json_vec3(array: Option<&Vec<serde_json::Value>>) -> [f64; 3] {
385    let mut out = [0.0; 3];
386    if let Some(array) = array {
387        for (index, slot) in out.iter_mut().enumerate() {
388            if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
389                *slot = number;
390            }
391        }
392    }
393    out
394}
395
396/// The gizmo frame feed (`set_transform_json` shape) for a pose: origin =
397/// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
398/// the kernel bake), with the center free-move handle shown.
399fn transform_frame_json(pose: &TransformPose) -> String {
400    let euler = [
401        pose.rotation_deg[0].to_radians(),
402        pose.rotation_deg[1].to_radians(),
403        pose.rotation_deg[2].to_radians(),
404    ];
405    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
406    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
407    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
408    serde_json::json!({
409        "origin": pose.position,
410        "x": x,
411        "y": y,
412        "z": z,
413        "showCenter": true,
414    })
415    .to_string()
416}
417
418fn normalize3(v: [f64; 3]) -> [f64; 3] {
419    let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
420    if length < 1e-12 {
421        [0.0, 0.0, 1.0]
422    } else {
423        [v[0] / length, v[1] / length, v[2] / length]
424    }
425}
426
427/// Apply an intrinsic XYZ Euler (radians) to a vector — the EXACT matrix the kernel
428/// bake (`transform_bake` / `datum::rotate_euler_xyz`) uses, so the fed gizmo
429/// frame aligns with the baked solid.
430pub(crate) fn rotate_euler_xyz_f64(v: [f64; 3], euler: [f64; 3]) -> [f64; 3] {
431    let (c1, s1) = (euler[0].cos(), euler[0].sin());
432    let (c2, s2) = (euler[1].cos(), euler[1].sin());
433    let (c3, s3) = (euler[2].cos(), euler[2].sin());
434    let m00 = c2 * c3;
435    let m01 = -c2 * s3;
436    let m02 = s2;
437    let m10 = c1 * s3 + c3 * s1 * s2;
438    let m11 = c1 * c3 - s1 * s2 * s3;
439    let m12 = -c2 * s1;
440    let m20 = s1 * s3 - c1 * c3 * s2;
441    let m21 = c3 * s1 + c1 * s2 * s3;
442    let m22 = c1 * c2;
443    [
444        m00 * v[0] + m01 * v[1] + m02 * v[2],
445        m10 * v[0] + m11 * v[1] + m12 * v[2],
446        m20 * v[0] + m21 * v[1] + m22 * v[2],
447    ]
448}
449
450// --- quaternion helpers (ported from CombinedTransformControls) -----
451
452type Quat = [f64; 4]; // [x, y, z, w]
453
454fn quat_from_axis_angle(axis: [f64; 3], angle: f64) -> Quat {
455    let n = normalize3(axis);
456    let half = angle * 0.5;
457    let s = half.sin();
458    [n[0] * s, n[1] * s, n[2] * s, half.cos()]
459}
460
461/// Quaternion from an intrinsic XYZ Euler (degrees in).
462fn quat_from_euler_xyz_deg(deg: [f64; 3]) -> Quat {
463    let (c1, s1) = ((deg[0].to_radians() * 0.5).cos(), (deg[0].to_radians() * 0.5).sin());
464    let (c2, s2) = ((deg[1].to_radians() * 0.5).cos(), (deg[1].to_radians() * 0.5).sin());
465    let (c3, s3) = ((deg[2].to_radians() * 0.5).cos(), (deg[2].to_radians() * 0.5).sin());
466    [
467        s1 * c2 * c3 + c1 * s2 * s3,
468        c1 * s2 * c3 - s1 * c2 * s3,
469        c1 * c2 * s3 + s1 * s2 * c3,
470        c1 * c2 * c3 - s1 * s2 * s3,
471    ]
472}
473
474/// Quaternion product `a * b`.
475fn quat_mul(a: Quat, b: Quat) -> Quat {
476    [
477        a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
478        a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
479        a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
480        a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
481    ]
482}
483
484/// Intrinsic XYZ Euler from a quaternion (via the rotation matrix) → the
485/// 'XYZ' Euler in DEGREES. Uses the SAME matrix element naming as
486/// `rotate_euler_xyz_f64`, so the round-trip is consistent with the kernel bake.
487fn euler_xyz_deg_from_quat(q: Quat) -> [f64; 3] {
488    let [x, y, z, w] = q;
489    let (x2, y2, z2) = (x + x, y + y, z + z);
490    let (xx, xy, xz) = (x * x2, x * y2, x * z2);
491    let (yy, yz, zz) = (y * y2, y * z2, z * z2);
492    let (wx, wy, wz) = (w * x2, w * y2, w * z2);
493    // Rotation matrix elements (m<row><col> naming).
494    let m11 = 1.0 - (yy + zz);
495    let m12 = xy - wz;
496    let m13 = xz + wy;
497    let m22 = 1.0 - (xx + zz);
498    let m23 = yz - wx;
499    let m32 = yz + wx;
500    let m33 = 1.0 - (xx + yy);
501    let ey = m13.clamp(-1.0, 1.0).asin();
502    let (ex, ez) = if m13.abs() < 0.9999999 {
503        ((-m23).atan2(m33), (-m12).atan2(m11))
504    } else {
505        (m32.atan2(m22), 0.0)
506    };
507    [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
508}
509
510#[cfg(test)]
511mod transform_gizmo_tests {
512    use super::*;
513
514    /// A one-feature history document: a `P.CU` cube `name` at the origin with an
515    /// identity transform (so the gizmo arms at [0,0,0], world XYZ frame).
516    fn cube_request(name: &str, size: f64) -> String {
517        serde_json::json!({
518            "expressions": "",
519            "configurator": {},
520            "features": [{
521                "type": "P.CU",
522                "inputParams": {
523                    "id": name,
524                    "sizeX": size, "sizeY": size, "sizeZ": size,
525                    "transform": {
526                        "position": [0.0, 0.0, 0.0],
527                        "rotationEuler": [0.0, 0.0, 0.0],
528                        "scale": [1.0, 1.0, 1.0]
529                    },
530                    "boolean": { "targets": [], "operation": "NONE" }
531                },
532                "persistentData": {}
533            }]
534        })
535        .to_string()
536    }
537
538    fn ident() -> TransformPose {
539        TransformPose {
540            position: [1.0, 2.0, 3.0],
541            rotation_deg: [0.0, 0.0, 0.0],
542            scale: [1.0, 1.0, 1.0],
543        }
544    }
545
546    #[test]
547    fn translate_delta_adds_world_to_position() {
548        let pose = apply_transform_delta(&ident(), &TransformDelta::Translate([4.0, -1.0, 0.5]));
549        assert_eq!(pose.position, [5.0, 1.0, 3.5]);
550        assert_eq!(pose.rotation_deg, [0.0, 0.0, 0.0]);
551        assert_eq!(pose.scale, [1.0, 1.0, 1.0]);
552    }
553
554    #[test]
555    fn rotate_delta_about_z_yields_z_euler() {
556        // +90° about world +Z from identity orientation → rotationEuler [0,0,90].
557        let pose = apply_transform_delta(
558            &ident(),
559            &TransformDelta::Rotate {
560                axis: [0.0, 0.0, 1.0],
561                radians: std::f64::consts::FRAC_PI_2,
562            },
563        );
564        assert!((pose.rotation_deg[0]).abs() < 1e-6, "{:?}", pose.rotation_deg);
565        assert!((pose.rotation_deg[1]).abs() < 1e-6, "{:?}", pose.rotation_deg);
566        assert!((pose.rotation_deg[2] - 90.0).abs() < 1e-4, "{:?}", pose.rotation_deg);
567        // Rotation pivots about the origin → position holds.
568        assert_eq!(pose.position, [1.0, 2.0, 3.0]);
569    }
570
571    #[test]
572    fn euler_quat_roundtrip_is_identity() {
573        // A non-gimbal compound rotation round-trips euler→quat→euler.
574        let deg = [30.0, 45.0, 60.0];
575        let back = euler_xyz_deg_from_quat(quat_from_euler_xyz_deg(deg));
576        for k in 0..3 {
577            assert!((back[k] - deg[k]).abs() < 1e-4, "axis {k}: {back:?} vs {deg:?}");
578        }
579    }
580
581    #[test]
582    fn rotate_about_local_axis_composes_onto_existing_orientation() {
583        // Start already rotated 90° about Z; add 90° about the (world) Z axis →
584        // 180° about Z. Pre-multiplying the world-Z delta gives a valid euler for
585        // a 180°-about-Z orientation (±180 about Z is equivalent).
586        let start = TransformPose {
587            rotation_deg: [0.0, 0.0, 90.0],
588            ..ident()
589        };
590        let pose = apply_transform_delta(
591            &start,
592            &TransformDelta::Rotate {
593                axis: [0.0, 0.0, 1.0],
594                radians: std::f64::consts::FRAC_PI_2,
595            },
596        );
597        // Compare orientations via the quaternion (euler triples for 180°-Z can
598        // be [0,0,180] or [180,0,-180]…; the quaternion is unambiguous up to sign).
599        let got = quat_from_euler_xyz_deg(pose.rotation_deg);
600        let want = quat_from_euler_xyz_deg([0.0, 0.0, 180.0]);
601        let dot = got[0] * want[0] + got[1] * want[1] + got[2] * want[2] + got[3] * want[3];
602        assert!(dot.abs() > 0.9999, "orientation mismatch: {got:?} vs {want:?}");
603    }
604
605    #[test]
606    fn arm_press_drag_moves_the_feature_then_disarms() {
607        let mut engine = EngineState::new();
608        engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
609        engine.resize(800.0, 600.0);
610        engine.camera.eye = [0.0, 0.0, 40.0];
611        engine.camera.target = [0.0, 0.0, 0.0];
612        engine.camera.up = [0.0, 1.0, 0.0];
613        engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
614
615        assert!(!engine.transform_armed());
616        engine.arm_transform("Pin");
617        assert!(engine.transform_armed() && engine.transform_armed_for("Pin"));
618        assert!(engine.widgets.has_transform(), "arming feeds the widget gizmo");
619
620        // The gizmo origin (= Pin position [0,0,0]) projects to the viewport
621        // center; the center free-move handle sits there.
622        let (ax, ay) = engine.transform_gizmo_anchor().expect("armed anchor");
623        assert!((ax - 400.0).abs() < 1.0 && (ay - 300.0).abs() < 1.0, "anchor {ax},{ay}");
624
625        // The VISIBLE gizmo origin starts at the grab pose (Pin at the world origin).
626        let origin_before = engine.widgets.transform_origin().expect("gizmo shown");
627        assert!(origin_before[0].abs() < 1e-4, "gizmo starts at x=0: {origin_before:?}");
628
629        // Press the handle + drag screen-right → +X world translate.
630        assert!(engine.transform_press(ax, ay), "press grabs a handle");
631        assert!(engine.transform_dragging());
632        engine.transform_drag_to(ax + 60.0, ay);
633
634        let index = engine.history.index_of("Pin").unwrap();
635        let params = engine.history.feature_params(index).unwrap();
636        let moved_x = params["transform"]["position"][0].as_f64().unwrap();
637        assert!(moved_x > 0.0, "Pin moved +X, got {:?}", params["transform"]["position"]);
638
639        // Fix 3 live-follow: MID-DRAG (before release) the VISIBLE gizmo has already
640        // re-synced to the moved pose — its origin tracks the feature's new +X
641        // position, so the widget follows the pointer in real time. And it matches
642        // the just-written param (no drift between widget + feature).
643        let origin_mid = engine.widgets.transform_origin().expect("gizmo still shown");
644        assert!(
645            origin_mid[0] > 0.0 && (origin_mid[0] as f64 - moved_x).abs() < 1e-3,
646            "gizmo should follow to x={moved_x} mid-drag, got {origin_mid:?}"
647        );
648        // The active-handle gold highlight survives the per-frame re-sync.
649        assert!(engine.transform_dragging(), "still dragging after the live re-sync");
650
651        engine.transform_release();
652        assert!(!engine.transform_dragging());
653
654        engine.disarm_transform();
655        assert!(!engine.transform_armed());
656        assert!(!engine.widgets.has_transform(), "disarm hides the gizmo");
657    }
658
659    #[test]
660    fn deleting_the_armed_feature_auto_disarms() {
661        let mut engine = EngineState::new();
662        engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
663        engine.arm_transform("Pin");
664        assert!(engine.transform_armed());
665        // Removing the feature and re-running should drop the gizmo (the sync
666        // hook in `rerun_history` finds no pose and disarms).
667        engine.delete_feature("Pin");
668        assert!(!engine.transform_armed(), "armed feature gone → auto-disarm");
669        assert!(!engine.widgets.has_transform());
670    }
671}
672
673// ============================================================================
674// Modeling selection UX — hover highlight + multi-select toggle + the
675// "candidates under the cursor" list. Appended as its OWN `impl` block (purely
676// additive over the existing pick/selection API) so concurrent edits to the
677// primary block + the transform-gizmo block don't conflict.
678//
679// The three are tied together through the SAME filter-respecting engine pick
680// (`pick::pick` / `pick::pick_filtered` with the selection filter's enabled
681// kinds), so hover, a plain/Ctrl click, and the candidate list all agree on
682// what is under the cursor and in what order.
683//
684// Ports the retired viewer's selection methods:
685//   * hover      — `_updateHover` → `SelectionFilter.setHoverRef(primary)` /
686//                  `clearHover()`; `hover_at` sets the top admitted pick HOVERED.
687//   * multi-sel  — the ref-store `toggleRef` (Ctrl/Cmd add) vs `setHoverRef`
688//                  replace; `select_toggle_at` adds/removes without clearing.
689//   * candidates — `_collectSelectionCandidates` builds the ranked pick list and
690//                  its FINAL sort (see the note on `candidates_filtered_at`).
691// ============================================================================
692