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