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    ///
93    /// An ACOMP (assembly component) feature ROUTES to the component Move gizmo
94    /// instead: its `transform` is the component pose (`{translate,
95    /// rotateEulerDeg}`, a different shape), its gizmo attaches at the member
96    /// bbox center, commits on release, and refuses fixed instances — the
97    /// generic position/rotationEuler gizmo must never write its keys into an
98    /// ACOMP's params. This covers the history panel's arm-on-expand path too.
99    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    /// Disarm: hide BOTH gizmos + drop any in-flight drag. Also resets the
118    /// component Move gizmo (the widget slot is shared, so a disarm clears
119    /// whichever controller was feeding it).
120    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    /// The armed feature's current TRS pose (from its `inputParams.transform`),
131    /// or `None` when disarmed / the feature vanished.
132    fn armed_pose(&self) -> Option<TransformPose> {
133        let id = self.transform_gizmo.feature_id.as_deref()?;
134        let index = self.history.index_of(id)?;
135        let params = self.history.feature_params(index)?;
136        let transform = params.get("transform");
137        Some(TransformPose {
138            position: read_pose_vec3(transform, "position", [0.0, 0.0, 0.0]),
139            rotation_deg: read_pose_vec3(transform, "rotationEuler", [0.0, 0.0, 0.0]),
140            scale: read_pose_vec3(transform, "scale", [1.0, 1.0, 1.0]),
141        })
142    }
143
144    /// (Re)feed the widget gizmo at the armed feature's frame: origin =
145    /// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
146    /// the kernel bake). Auto-disarms if the feature vanished. Called every drag
147    /// frame from `transform_drag_to` so the widget tracks the moving pose live
148    /// (Fix 3); the drag delta resolves against the frozen grab frame, so this
149    /// re-sync never feeds back into the drag math.
150    pub fn sync_transform_gizmo(&mut self) {
151        // Only the TRANSFORM mode feeds the move/rotate widget; in dimension mode
152        // the widget stays hidden (the annotations render as an overlay instead).
153        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
154            return;
155        }
156        let Some(pose) = self.armed_pose() else {
157            self.disarm_transform();
158            return;
159        };
160        let _ = self.widgets.set_transform_json(&transform_frame_json(&pose));
161        self.dirty = true;
162    }
163
164    /// Whether feature `feature_id` carries a `transform` param (a Transform
165    /// group), so it CAN show a transform gizmo. The panel's auto-arm-on-expand
166    /// uses this: a feature with NO dimension gizmo but WITH a transform
167    /// (datum/helix/pattern/port) arms the TRANSFORM gizmo directly on expand,
168    /// instead of being left with no gizmo at all now that the ◎ arm button is
169    /// gone. (`armed_pose` alone can't gate this — it defaults to identity for a
170    /// transform-less feature, so a boolean/fillet would show a spurious gizmo.)
171    pub fn feature_has_transform(&self, feature_id: &str) -> bool {
172        self.history
173            .index_of(feature_id)
174            .and_then(|i| self.history.feature_params(i))
175            .map(|params| params.get("transform").is_some())
176            .unwrap_or(false)
177    }
178
179    /// The armed gizmo origin projected to VIEWPORT-LOCAL px (the center handle
180    /// sits here). The history panel publishes it so the headed verifier can
181    /// locate + drag the gizmo. `None` when disarmed / not projectable (the ONE
182    /// [`crate::view::ViewCamera::projectable`] policy — ortho always projects).
183    pub fn transform_gizmo_anchor(&self) -> Option<(f64, f64)> {
184        let pose = self.armed_pose()?;
185        let (sx, sy, _) = self.camera.project(pose.position);
186        self.camera.projectable(pose.position).then_some((sx, sy))
187    }
188
189    /// The transform gizmo's axis-end labels as JSON:
190    /// `[{ text:"XC"|"YC"|"ZC", rgb:[r,g,b], world:[x,y,z] }]`. The app projects
191    /// each `world` point and draws the colored egui label just past the matching
192    /// cone tip (X=red, Y=green, Z=blue). `[]` unless the TRANSFORM gizmo is armed.
193    pub fn transform_axis_labels_json(&self) -> String {
194        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
195            return "[]".to_string();
196        }
197        let Some(pose) = self.armed_pose() else {
198            return "[]".to_string();
199        };
200        let euler = [
201            pose.rotation_deg[0].to_radians(),
202            pose.rotation_deg[1].to_radians(),
203            pose.rotation_deg[2].to_radians(),
204        ];
205        let axes = [
206            normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
207            normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
208            normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
209        ];
210        // Just past the cone tip, screen-constant.
211        let gap_px = 12.0_f64;
212        let dist = (brep_gizmos::transform::PX_AXIS_LEN as f64 + gap_px) * self.camera.world_per_pixel();
213        let labels: [(&str, [f32; 3]); 3] = [
214            ("XC", [0.92, 0.26, 0.28]), // red
215            ("YC", [0.30, 0.78, 0.36]), // green
216            ("ZC", [0.30, 0.52, 0.98]), // blue
217        ];
218        let out: Vec<serde_json::Value> = (0..3)
219            .map(|i| {
220                let o = pose.position;
221                let a = axes[i];
222                serde_json::json!({
223                    "text": labels[i].0,
224                    "rgb": labels[i].1,
225                    "world": [o[0] + a[0] * dist, o[1] + a[1] * dist, o[2] + a[2] * dist],
226                })
227            })
228            .collect();
229        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
230    }
231
232    /// DEBUG overlay: the EXACT SCREEN-space (viewport-local px) pickable regions
233    /// of the transform gizmo — the SAME [`hit_regions`](brep_gizmos::transform::
234    /// TransformGizmo::hit_regions) the hit test 2D-tests the cursor against — so
235    /// the red outline can NEVER drift from the grabbable area. Each item is a
236    /// `{ kind:"capsule", a:[x,y], b:[x,y], r }` (axis arrows) or
237    /// `{ kind:"circle", c:[x,y], r }` (center + rotation grab spheres); the app
238    /// only offsets by `rect.min` to draw. Projection + the perspective front-clip
239    /// already happened in the region builder. The WIDGET FEED is authoritative —
240    /// exactly like [`transform_pick`](Self::transform_pick), which has no mode
241    /// gate — so this emits for EVERY controller of the shared widget gizmo (the
242    /// feature TRANSFORM mode and the assembly component Move gizmo alike) and is
243    /// `[]` precisely when the widget is hidden (nothing is grabbable).
244    pub fn transform_hit_areas_json(&self) -> String {
245        let cam = gizmo_camera(&self.camera);
246        let regions = self.widgets.transform_hit_regions(&cam);
247        super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
248    }
249
250    /// Begin a gizmo drag at viewport px `(x, y)` when the gizmo is armed AND a
251    /// handle is under the pointer. Returns whether a handle was grabbed — the
252    /// viewport routes the drag to the gizmo (not the camera) when `true`; a press
253    /// on empty space returns `false` and still orbits.
254    pub fn transform_press(&mut self, x: f64, y: f64) -> bool {
255        // Only grabbable in TRANSFORM mode — in dimension mode the dimension
256        // handles own the pointer (routed by the app), and disarmed grabs nothing.
257        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
258            return false;
259        }
260        let handle = self.transform_pick(x, y);
261        if handle == 0 {
262            return false;
263        }
264        let Some(pose) = self.armed_pose() else {
265            return false;
266        };
267        self.widgets.set_transform_active(handle);
268        self.transform_gizmo.drag = Some(TransformDrag {
269            handle,
270            sx: x as f32,
271            sy: y as f32,
272            start: pose,
273        });
274        self.dirty = true;
275        true
276    }
277
278    /// Whether a gizmo handle drag is in flight.
279    pub fn transform_dragging(&self) -> bool {
280        self.transform_gizmo.drag.is_some()
281    }
282
283    /// Continue the in-flight gizmo drag to viewport px `(cx, cy)`: resolve the
284    /// world delta from the grab (against the frozen grab-time frame), apply it to
285    /// the grab pose, write it back into the feature's `transform`, and re-run so
286    /// the model follows live. Then re-sync the VISIBLE gizmo to the moved pose so
287    /// the widget tracks the pointer in real time (Fix 3) — the delta stays
288    /// anchored to `drag.start`, so this visual sync never feeds back on itself.
289    pub fn transform_drag_to(&mut self, cx: f64, cy: f64) {
290        let Some(drag) = self.transform_gizmo.drag else {
291            return;
292        };
293        let delta = self.resolve_transform_delta(&drag, cx, cy);
294        if matches!(delta, TransformDelta::None) {
295            return;
296        }
297        let pose = apply_transform_delta(&drag.start, &delta);
298        self.write_armed_pose(&pose);
299        // Live-follow: re-feed the widget frame to the just-written pose. NB
300        // `finish_apply` skips this while a drag is in flight (drag.is_some()), so
301        // the sync happens here. The active-handle gold highlight survives (the
302        // widget only clears it on a `null` feed).
303        self.sync_transform_gizmo();
304    }
305
306    /// End the drag: clear the active-handle highlight + re-sync the gizmo to the
307    /// feature's final (moved) pose (unpin the frame).
308    pub fn transform_release(&mut self) {
309        if self.transform_gizmo.drag.take().is_some() {
310            self.widgets.set_transform_active(0);
311            self.sync_transform_gizmo();
312        }
313    }
314
315    /// Resolve the widget gizmo's frame delta for a drag from the grab to
316    /// `(cx, cy)` into a world-space [`TransformDelta`] — via
317    /// `WidgetRegistry::transform_drag_json_with_frame` + the ported commit mapping.
318    ///
319    /// The delta is resolved against the FROZEN grab-time frame (rebuilt from
320    /// `drag.start`), NOT the live widget gizmo — so `transform_drag_to` can
321    /// re-sync the VISIBLE gizmo to the moving pose every frame (Fix 3 live-follow)
322    /// without the visual sync feeding back into the drag math. Since a param
323    /// change outside a drag always flows through `finish_apply` → `sync`, the grab
324    /// pose is exactly the frame the widget held at grab, so this is behaviorally
325    /// identical to the old pinned-widget-frame math — plus the live visual sync.
326    fn resolve_transform_delta(&self, drag: &TransformDrag, cx: f64, cy: f64) -> TransformDelta {
327        let cam = gizmo_camera(&self.camera);
328        let frame_json = transform_frame_json(&drag.start);
329        let json = self.widgets.transform_drag_json_with_frame(
330            &cam,
331            &frame_json,
332            drag.handle,
333            drag.sx,
334            drag.sy,
335            cx as f32,
336            cy as f32,
337        );
338        let value: serde_json::Value =
339            serde_json::from_str(&json).unwrap_or(serde_json::Value::Null);
340        match value.get("kind").and_then(|k| k.as_str()) {
341            Some("translate") => {
342                TransformDelta::Translate(json_vec3(value.get("world").and_then(|a| a.as_array())))
343            }
344            Some("rotate") => TransformDelta::Rotate {
345                axis: json_vec3(value.get("axisWorld").and_then(|a| a.as_array())),
346                radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
347            },
348            _ => TransformDelta::None,
349        }
350    }
351
352    /// Write `pose` into the armed feature's `inputParams.transform.{position,
353    /// rotationEuler}` (preserving every other field, incl. `scale`) and re-run.
354    fn write_armed_pose(&mut self, pose: &TransformPose) {
355        let Some(id) = self.transform_gizmo.feature_id.clone() else {
356            return;
357        };
358        let Some(index) = self.history.index_of(&id) else {
359            return;
360        };
361        let mut params = self
362            .history
363            .feature_params(index)
364            .unwrap_or_else(|| serde_json::json!({}));
365        // Ensure `transform` is an object, then set the two edited vectors.
366        if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
367            if let Some(object) = params.as_object_mut() {
368                object.insert("transform".into(), serde_json::json!({}));
369            }
370        }
371        if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
372            transform.insert("position".into(), serde_json::json!(pose.position));
373            transform.insert("rotationEuler".into(), serde_json::json!(pose.rotation_deg));
374        }
375        let _ = self.update_feature_params(&id, &params.to_string());
376    }
377}
378
379/// Apply a resolved drag delta to the grab pose → the new pose. PURE (the unit
380/// test drives it directly). Translation adds the world delta to `position`;
381/// rotation pre-multiplies a world-axis quaternion onto the pose's orientation
382/// and re-extracts the intrinsic XYZ Euler order (degrees). `scale` is a documented seam
383/// (no scale handle exists in the gizmo yet), so it is carried through unchanged.
384fn apply_transform_delta(start: &TransformPose, delta: &TransformDelta) -> TransformPose {
385    match delta {
386        TransformDelta::Translate(world) => TransformPose {
387            position: [
388                start.position[0] + world[0],
389                start.position[1] + world[1],
390                start.position[2] + world[2],
391            ],
392            ..*start
393        },
394        TransformDelta::Rotate { axis, radians } => {
395            let q0 = quat_from_euler_xyz_deg(start.rotation_deg);
396            let dq = quat_from_axis_angle(*axis, *radians);
397            let nq = quat_mul(dq, q0);
398            TransformPose {
399                rotation_deg: euler_xyz_deg_from_quat(nq),
400                ..*start
401            }
402        }
403        TransformDelta::None => *start,
404    }
405}
406
407/// Read a `[x, y, z]` from a transform sub-field (numbers only; missing / short
408/// arrays keep the per-index default).
409fn read_pose_vec3(transform: Option<&serde_json::Value>, key: &str, default: [f64; 3]) -> [f64; 3] {
410    let array = transform.and_then(|t| t.get(key)).and_then(|v| v.as_array());
411    let mut out = default;
412    if let Some(array) = array {
413        for (index, slot) in out.iter_mut().enumerate() {
414            if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
415                *slot = number;
416            }
417        }
418    }
419    out
420}
421
422/// A `[f64; 3]` from a JSON number array (zero-filled past the end).
423fn json_vec3(array: Option<&Vec<serde_json::Value>>) -> [f64; 3] {
424    let mut out = [0.0; 3];
425    if let Some(array) = array {
426        for (index, slot) in out.iter_mut().enumerate() {
427            if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
428                *slot = number;
429            }
430        }
431    }
432    out
433}
434
435/// The gizmo frame feed (`set_transform_json` shape) for a pose: origin =
436/// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
437/// the kernel bake), with the center free-move handle shown.
438fn transform_frame_json(pose: &TransformPose) -> String {
439    let euler = [
440        pose.rotation_deg[0].to_radians(),
441        pose.rotation_deg[1].to_radians(),
442        pose.rotation_deg[2].to_radians(),
443    ];
444    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
445    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
446    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
447    serde_json::json!({
448        "origin": pose.position,
449        "x": x,
450        "y": y,
451        "z": z,
452        "showCenter": true,
453    })
454    .to_string()
455}
456
457pub(super) fn normalize3(v: [f64; 3]) -> [f64; 3] {
458    let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
459    if length < 1e-12 {
460        [0.0, 0.0, 1.0]
461    } else {
462        [v[0] / length, v[1] / length, v[2] / length]
463    }
464}
465
466/// Apply an intrinsic XYZ Euler (radians) to a vector — the EXACT matrix the kernel
467/// bake (`transform_bake` / `datum::rotate_euler_xyz`) uses, so the fed gizmo
468/// frame aligns with the baked solid.
469pub(crate) fn rotate_euler_xyz_f64(v: [f64; 3], euler: [f64; 3]) -> [f64; 3] {
470    let (c1, s1) = (euler[0].cos(), euler[0].sin());
471    let (c2, s2) = (euler[1].cos(), euler[1].sin());
472    let (c3, s3) = (euler[2].cos(), euler[2].sin());
473    let m00 = c2 * c3;
474    let m01 = -c2 * s3;
475    let m02 = s2;
476    let m10 = c1 * s3 + c3 * s1 * s2;
477    let m11 = c1 * c3 - s1 * s2 * s3;
478    let m12 = -c2 * s1;
479    let m20 = s1 * s3 - c1 * c3 * s2;
480    let m21 = c3 * s1 + c1 * s2 * s3;
481    let m22 = c1 * c2;
482    [
483        m00 * v[0] + m01 * v[1] + m02 * v[2],
484        m10 * v[0] + m11 * v[1] + m12 * v[2],
485        m20 * v[0] + m21 * v[1] + m22 * v[2],
486    ]
487}
488
489// --- quaternion helpers (ported from CombinedTransformControls) -----
490
491pub(super) type Quat = [f64; 4]; // [x, y, z, w]
492
493pub(super) fn quat_from_axis_angle(axis: [f64; 3], angle: f64) -> Quat {
494    let n = normalize3(axis);
495    let half = angle * 0.5;
496    let s = half.sin();
497    [n[0] * s, n[1] * s, n[2] * s, half.cos()]
498}
499
500/// Quaternion from an intrinsic XYZ Euler (degrees in).
501pub(super) fn quat_from_euler_xyz_deg(deg: [f64; 3]) -> Quat {
502    let (c1, s1) = ((deg[0].to_radians() * 0.5).cos(), (deg[0].to_radians() * 0.5).sin());
503    let (c2, s2) = ((deg[1].to_radians() * 0.5).cos(), (deg[1].to_radians() * 0.5).sin());
504    let (c3, s3) = ((deg[2].to_radians() * 0.5).cos(), (deg[2].to_radians() * 0.5).sin());
505    [
506        s1 * c2 * c3 + c1 * s2 * s3,
507        c1 * s2 * c3 - s1 * c2 * s3,
508        c1 * c2 * s3 + s1 * s2 * c3,
509        c1 * c2 * c3 - s1 * s2 * s3,
510    ]
511}
512
513/// Quaternion product `a * b`.
514pub(super) fn quat_mul(a: Quat, b: Quat) -> Quat {
515    [
516        a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
517        a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
518        a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
519        a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
520    ]
521}
522
523/// Intrinsic XYZ Euler from a quaternion (via the rotation matrix) → the
524/// 'XYZ' Euler in DEGREES. Uses the SAME matrix element naming as
525/// `rotate_euler_xyz_f64`, so the round-trip is consistent with the kernel bake.
526pub(super) fn euler_xyz_deg_from_quat(q: Quat) -> [f64; 3] {
527    let [x, y, z, w] = q;
528    let (x2, y2, z2) = (x + x, y + y, z + z);
529    let (xx, xy, xz) = (x * x2, x * y2, x * z2);
530    let (yy, yz, zz) = (y * y2, y * z2, z * z2);
531    let (wx, wy, wz) = (w * x2, w * y2, w * z2);
532    // Rotation matrix elements (m<row><col> naming).
533    let m11 = 1.0 - (yy + zz);
534    let m12 = xy - wz;
535    let m13 = xz + wy;
536    let m22 = 1.0 - (xx + zz);
537    let m23 = yz - wx;
538    let m32 = yz + wx;
539    let m33 = 1.0 - (xx + yy);
540    let ey = m13.clamp(-1.0, 1.0).asin();
541    let (ex, ez) = if m13.abs() < 0.9999999 {
542        ((-m23).atan2(m33), (-m12).atan2(m11))
543    } else {
544        (m32.atan2(m22), 0.0)
545    };
546    [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
547}
548
549#[cfg(test)]
550mod transform_gizmo_tests {
551    use super::*;
552
553    /// A one-feature history document: a `P.CU` cube `name` at the origin with an
554    /// identity transform (so the gizmo arms at [0,0,0], world XYZ frame).
555    fn cube_request(name: &str, size: f64) -> String {
556        serde_json::json!({
557            "expressions": "",
558            "configurator": {},
559            "features": [{
560                "type": "P.CU",
561                "inputParams": {
562                    "id": name,
563                    "sizeX": size, "sizeY": size, "sizeZ": size,
564                    "transform": {
565                        "position": [0.0, 0.0, 0.0],
566                        "rotationEuler": [0.0, 0.0, 0.0],
567                        "scale": [1.0, 1.0, 1.0]
568                    },
569                    "boolean": { "targets": [], "operation": "NONE" }
570                },
571                "persistentData": {}
572            }]
573        })
574        .to_string()
575    }
576
577    fn ident() -> TransformPose {
578        TransformPose {
579            position: [1.0, 2.0, 3.0],
580            rotation_deg: [0.0, 0.0, 0.0],
581            scale: [1.0, 1.0, 1.0],
582        }
583    }
584
585    #[test]
586    fn translate_delta_adds_world_to_position() {
587        let pose = apply_transform_delta(&ident(), &TransformDelta::Translate([4.0, -1.0, 0.5]));
588        assert_eq!(pose.position, [5.0, 1.0, 3.5]);
589        assert_eq!(pose.rotation_deg, [0.0, 0.0, 0.0]);
590        assert_eq!(pose.scale, [1.0, 1.0, 1.0]);
591    }
592
593    #[test]
594    fn rotate_delta_about_z_yields_z_euler() {
595        // +90° about world +Z from identity orientation → rotationEuler [0,0,90].
596        let pose = apply_transform_delta(
597            &ident(),
598            &TransformDelta::Rotate {
599                axis: [0.0, 0.0, 1.0],
600                radians: std::f64::consts::FRAC_PI_2,
601            },
602        );
603        assert!((pose.rotation_deg[0]).abs() < 1e-6, "{:?}", pose.rotation_deg);
604        assert!((pose.rotation_deg[1]).abs() < 1e-6, "{:?}", pose.rotation_deg);
605        assert!((pose.rotation_deg[2] - 90.0).abs() < 1e-4, "{:?}", pose.rotation_deg);
606        // Rotation pivots about the origin → position holds.
607        assert_eq!(pose.position, [1.0, 2.0, 3.0]);
608    }
609
610    #[test]
611    fn euler_quat_roundtrip_is_identity() {
612        // A non-gimbal compound rotation round-trips euler→quat→euler.
613        let deg = [30.0, 45.0, 60.0];
614        let back = euler_xyz_deg_from_quat(quat_from_euler_xyz_deg(deg));
615        for k in 0..3 {
616            assert!((back[k] - deg[k]).abs() < 1e-4, "axis {k}: {back:?} vs {deg:?}");
617        }
618    }
619
620    #[test]
621    fn rotate_about_local_axis_composes_onto_existing_orientation() {
622        // Start already rotated 90° about Z; add 90° about the (world) Z axis →
623        // 180° about Z. Pre-multiplying the world-Z delta gives a valid euler for
624        // a 180°-about-Z orientation (±180 about Z is equivalent).
625        let start = TransformPose {
626            rotation_deg: [0.0, 0.0, 90.0],
627            ..ident()
628        };
629        let pose = apply_transform_delta(
630            &start,
631            &TransformDelta::Rotate {
632                axis: [0.0, 0.0, 1.0],
633                radians: std::f64::consts::FRAC_PI_2,
634            },
635        );
636        // Compare orientations via the quaternion (euler triples for 180°-Z can
637        // be [0,0,180] or [180,0,-180]…; the quaternion is unambiguous up to sign).
638        let got = quat_from_euler_xyz_deg(pose.rotation_deg);
639        let want = quat_from_euler_xyz_deg([0.0, 0.0, 180.0]);
640        let dot = got[0] * want[0] + got[1] * want[1] + got[2] * want[2] + got[3] * want[3];
641        assert!(dot.abs() > 0.9999, "orientation mismatch: {got:?} vs {want:?}");
642    }
643
644    #[test]
645    fn arm_press_drag_moves_the_feature_then_disarms() {
646        let mut engine = EngineState::new();
647        engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
648        engine.resize(800.0, 600.0);
649        engine.camera.eye = [0.0, 0.0, 40.0];
650        engine.camera.target = [0.0, 0.0, 0.0];
651        engine.camera.up = [0.0, 1.0, 0.0];
652        engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
653
654        assert!(!engine.transform_armed());
655        engine.arm_transform("Pin");
656        assert!(engine.transform_armed() && engine.transform_armed_for("Pin"));
657        assert!(engine.widgets.has_transform(), "arming feeds the widget gizmo");
658
659        // The gizmo origin (= Pin position [0,0,0]) projects to the viewport
660        // center; the center free-move handle sits there.
661        let (ax, ay) = engine.transform_gizmo_anchor().expect("armed anchor");
662        assert!((ax - 400.0).abs() < 1.0 && (ay - 300.0).abs() < 1.0, "anchor {ax},{ay}");
663
664        // The VISIBLE gizmo origin starts at the grab pose (Pin at the world origin).
665        let origin_before = engine.widgets.transform_origin().expect("gizmo shown");
666        assert!(origin_before[0].abs() < 1e-4, "gizmo starts at x=0: {origin_before:?}");
667
668        // Press the handle + drag screen-right → +X world translate.
669        assert!(engine.transform_press(ax, ay), "press grabs a handle");
670        assert!(engine.transform_dragging());
671        engine.transform_drag_to(ax + 60.0, ay);
672
673        let index = engine.history.index_of("Pin").unwrap();
674        let params = engine.history.feature_params(index).unwrap();
675        let moved_x = params["transform"]["position"][0].as_f64().unwrap();
676        assert!(moved_x > 0.0, "Pin moved +X, got {:?}", params["transform"]["position"]);
677
678        // Fix 3 live-follow: MID-DRAG (before release) the VISIBLE gizmo has already
679        // re-synced to the moved pose — its origin tracks the feature's new +X
680        // position, so the widget follows the pointer in real time. And it matches
681        // the just-written param (no drift between widget + feature).
682        let origin_mid = engine.widgets.transform_origin().expect("gizmo still shown");
683        assert!(
684            origin_mid[0] > 0.0 && (origin_mid[0] as f64 - moved_x).abs() < 1e-3,
685            "gizmo should follow to x={moved_x} mid-drag, got {origin_mid:?}"
686        );
687        // The active-handle gold highlight survives the per-frame re-sync.
688        assert!(engine.transform_dragging(), "still dragging after the live re-sync");
689
690        engine.transform_release();
691        assert!(!engine.transform_dragging());
692
693        engine.disarm_transform();
694        assert!(!engine.transform_armed());
695        assert!(!engine.widgets.has_transform(), "disarm hides the gizmo");
696    }
697
698    #[test]
699    fn deleting_the_armed_feature_auto_disarms() {
700        let mut engine = EngineState::new();
701        engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
702        engine.arm_transform("Pin");
703        assert!(engine.transform_armed());
704        // Removing the feature and re-running should drop the gizmo (the sync
705        // hook in `rerun_history` finds no pose and disarms).
706        engine.delete_feature("Pin");
707        assert!(!engine.transform_armed(), "armed feature gone → auto-disarm");
708        assert!(!engine.widgets.has_transform());
709    }
710
711    /// DEBUG-overlay consistency + draw==hit invariant: `transform_hit_areas_json`
712    /// emits the SAME SCREEN-space regions [`TransformGizmo::hit`] tests the cursor
713    /// against — 3 axis-arrow CAPSULES + the center CIRCLE + 3 rotation grab
714    /// CIRCLES (7 items) — with the coords being the axis-seg / grab points
715    /// projected through the SAME gizmo camera, and the SAME radii. `[]` unless in
716    /// transform mode.
717    #[test]
718    fn transform_hit_areas_are_screen_regions_matching_the_hit_test() {
719        let mut engine = EngineState::new();
720        engine.set_history_json(&cube_request("Pin", 10.0)).unwrap();
721        engine.resize(800.0, 600.0);
722        // An oblique view so the three projected axis segments are distinct.
723        engine.camera.eye = [30.0, 20.0, 40.0];
724        engine.camera.target = [0.0, 0.0, 0.0];
725        engine.camera.up = [0.0, 1.0, 0.0];
726        engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
727
728        // Disarmed → nothing to outline.
729        assert_eq!(engine.transform_hit_areas_json(), "[]");
730
731        engine.arm_transform("Pin");
732        let areas: Vec<serde_json::Value> =
733            serde_json::from_str(&engine.transform_hit_areas_json()).unwrap();
734        let cam = gizmo_camera(&engine.camera);
735
736        // 3 axis-arrow CAPSULES (screen px) + 4 CIRCLES (center + 3 rings).
737        let capsules: Vec<&serde_json::Value> =
738            areas.iter().filter(|a| a["kind"] == "capsule").collect();
739        let circles: Vec<&serde_json::Value> =
740            areas.iter().filter(|a| a["kind"] == "circle").collect();
741        assert_eq!(capsules.len(), 3, "one capsule per axis arrow");
742        assert_eq!(circles.len(), 4, "center + 3 ring grab circles");
743
744        let close2 = |v: &serde_json::Value, want: [f32; 2]| {
745            (v[0].as_f64().unwrap() as f32 - want[0]).abs() < 1e-3
746                && (v[1].as_f64().unwrap() as f32 - want[1]).abs() < 1e-3
747        };
748
749        // Each capsule's endpoints = the axis_seg projected through the SAME gizmo
750        // camera, at the AXIS_HIT_THRESH_PX radius.
751        for (i, cap) in capsules.iter().enumerate() {
752            let r = cap["r"].as_f64().unwrap();
753            assert!(
754                (r - brep_gizmos::transform::AXIS_HIT_THRESH_PX as f64).abs() < 1e-6,
755                "axis {i}: r {r} must equal AXIS_HIT_THRESH_PX"
756            );
757            let (a, b) = engine.widgets.transform_axis_seg(&cam, i).unwrap();
758            let sa = cam.world_to_screen(a).unwrap();
759            let sb = cam.world_to_screen(b).unwrap();
760            assert!(close2(&cap["a"], sa), "axis {i} a: {:?} vs {sa:?}", cap["a"]);
761            assert!(close2(&cap["b"], sb), "axis {i} b: {:?} vs {sb:?}", cap["b"]);
762        }
763
764        // The center circle = the projected center ball at PX_CENTER_RAD + 2.
765        let center = engine.widgets.transform_center_grab().unwrap();
766        let sc = cam.world_to_screen(center).unwrap();
767        let center_r = brep_gizmos::transform::PX_CENTER_RAD as f64 + 2.0;
768        assert!(
769            circles
770                .iter()
771                .any(|c| close2(&c["c"], sc) && (c["r"].as_f64().unwrap() - center_r).abs() < 1e-6),
772            "center circle at the projected origin"
773        );
774
775        // The 3 ring circles = the projected grab balls at PX_RING_GRAB_RAD + 3.
776        let ring_r = brep_gizmos::transform::PX_RING_GRAB_RAD as f64 + 3.0;
777        for g in engine.widgets.transform_ring_grabs(&cam).unwrap() {
778            let sg = cam.world_to_screen(g).unwrap();
779            assert!(
780                circles
781                    .iter()
782                    .any(|c| close2(&c["c"], sg) && (c["r"].as_f64().unwrap() - ring_r).abs() < 1e-6),
783                "ring grab circle at {sg:?}"
784            );
785        }
786
787        // Draw==hit invariant: a cursor at each region's own reference point is
788        // picked as SOME handle whose region contains it (regions overlap by
789        // design — the center ball sits at the arrow origins — so the invariant is
790        // "the picked handle's region contains the cursor").
791        let regions = engine.widgets.transform_hit_regions(&cam);
792        for (_, shape) in &regions {
793            let probe = match shape {
794                brep_gizmos::hit_region::HitShape::Circle { c, .. } => *c,
795                brep_gizmos::hit_region::HitShape::Capsule { a, b, .. } => {
796                    [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]
797                }
798            };
799            let picked = engine.transform_pick(probe[0] as f64, probe[1] as f64);
800            assert_ne!(picked, 0, "a handle under its own region at {probe:?}");
801            let picked_shape = regions.iter().find(|(id, _)| *id == picked).unwrap().1;
802            assert!(
803                picked_shape.contains(probe),
804                "picked handle {picked}'s region must contain the cursor {probe:?}"
805            );
806        }
807
808        // Flipping to dimension mode retracts the transform hit areas.
809        engine.arm_dimension("Pin");
810        assert_eq!(engine.transform_hit_areas_json(), "[]");
811    }
812}
813
814// ============================================================================
815// Modeling selection UX — hover highlight + multi-select toggle + the
816// "candidates under the cursor" list. Appended as its OWN `impl` block (purely
817// additive over the existing pick/selection API) so concurrent edits to the
818// primary block + the transform-gizmo block don't conflict.
819//
820// The three are tied together through the SAME filter-respecting engine pick
821// (`pick::pick` / `pick::pick_filtered` with the selection filter's enabled
822// kinds), so hover, a plain/Ctrl click, and the candidate list all agree on
823// what is under the cursor and in what order.
824//
825// Ports the retired viewer's selection methods:
826//   * hover      — `_updateHover` → `SelectionFilter.setHoverRef(primary)` /
827//                  `clearHover()`; `hover_at` sets the top admitted pick HOVERED.
828//   * multi-sel  — the ref-store `toggleRef` (Ctrl/Cmd add) vs `setHoverRef`
829//                  replace; `select_toggle_at` adds/removes without clearing.
830//   * candidates — `_collectSelectionCandidates` builds the ranked pick list and
831//                  its FINAL sort (see the note on `candidates_filtered_at`).
832// ============================================================================
833