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    /// When the armed subject is ONE SPLINE ANCHOR rather than the feature's
32    /// `transform` param: the anchor index into `persistentData.spline.points`
33    /// of `feature_id` (an SP feature). The same gizmo, press / drag / release
34    /// and live-follow apply; only the pose read and write-back differ (see
35    /// `spline_edit.rs`).
36    pub(super) anchor: Option<usize>,
37}
38
39/// A grab snapshot for an in-flight transform-gizmo drag: the grabbed handle,
40/// the grab screen point, and the feature's pose AT GRAB. Every drag move
41/// resolves an absolute delta from this grab (against the pinned grab-time
42/// frame) and re-applies it to `start`, so the drag never accumulates error.
43#[derive(Clone, Copy)]
44pub(super) struct TransformDrag {
45    handle: u32,
46    sx: f32,
47    sy: f32,
48    start: TransformPose,
49}
50
51/// A TRS pose read from / written to a feature's `inputParams.transform`
52/// (`rotation` in DEGREES, intrinsic XYZ Euler order — the kernel `transform_bake`
53/// convention, `M = T·R·S`).
54#[derive(Clone, Copy, PartialEq, Debug)]
55struct TransformPose {
56    position: [f64; 3],
57    rotation_deg: [f64; 3],
58    scale: [f64; 3],
59}
60
61/// A world-space delta shared by feature and component gizmo controllers.
62#[derive(Clone, Copy, PartialEq, Debug)]
63pub(super) enum TransformDelta {
64    /// World-space translation, added to `position`.
65    Translate([f64; 3]),
66    /// World-axis rotation, pre-multiplied onto the pose's orientation.
67    Rotate { axis: [f64; 3], radians: f64 },
68}
69
70/// Decode widget drag JSON; unknown kinds and invalid JSON produce no delta.
71pub(super) fn parse_drag_delta(json: &str) -> Option<TransformDelta> {
72    let value: serde_json::Value = serde_json::from_str(json).ok()?;
73    match value.get("kind").and_then(|k| k.as_str()) {
74        Some("translate") => Some(TransformDelta::Translate(
75            crate::json_support::vec3_or(value.get("world"), [0.0; 3]),
76        )),
77        Some("rotate") => Some(TransformDelta::Rotate {
78            axis: crate::json_support::vec3_or(value.get("axisWorld"), [0.0; 3]),
79            radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
80        }),
81        _ => None,
82    }
83}
84
85impl EngineState {
86    /// Whether the TRANSFORM gizmo (move/rotate) is armed for ANY feature. False
87    /// in dimension mode — the two ◎ modes are exclusive, so the transform gizmo
88    /// arms/handles never render while dimensions are shown.
89    pub fn transform_armed(&self) -> bool {
90        matches!(self.transform_gizmo.mode, GizmoMode::Transform)
91    }
92
93    /// The transform-gizmo-armed feature id (empty unless in transform mode).
94    pub fn transform_armed_feature(&self) -> String {
95        if self.transform_armed() {
96            self.transform_gizmo.feature_id.clone().unwrap_or_default()
97        } else {
98            String::new()
99        }
100    }
101
102    /// The gizmo as the headed verifier sees it: the mode, the armed feature,
103    /// the spline anchor index when the gizmo sits on one, and `origin` — the
104    /// DRAWN move/rotate widget's frame origin (`null` when no widget is
105    /// drawn), which is the proof a handle is on screen, unlike any
106    /// params-derived pose.
107    pub fn gizmo_state_json(&self) -> String {
108        let mode = match self.transform_gizmo.mode {
109            GizmoMode::None => "none",
110            GizmoMode::Transform => "transform",
111            GizmoMode::Dimension => "dimension",
112        };
113        serde_json::json!({
114            "mode": mode,
115            "armed": self.transform_armed(),
116            "feature": self.transform_gizmo.feature_id,
117            "anchor": self.transform_gizmo.anchor,
118            "origin": self.widgets.transform_origin(),
119        })
120        .to_string()
121    }
122
123    /// Whether the TRANSFORM gizmo is armed for THIS feature.
124    pub fn transform_armed_for(&self, feature_id: &str) -> bool {
125        self.transform_armed() && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
126    }
127
128    /// Arm the TRANSFORM gizmo for `feature_id` and feed it at the feature's
129    /// transform frame. Re-arming a different feature moves the gizmo to it.
130    /// Clears any dimension overlay (the modes are exclusive).
131    ///
132    /// An ACOMP (assembly component) feature ROUTES to the component Move gizmo
133    /// instead: its `transform` is the component pose (`{translate,
134    /// rotateEulerDeg}`, a different shape), its gizmo attaches at the member
135    /// bbox center, commits on release, and refuses fixed instances — the
136    /// generic position/rotationEuler gizmo must never write its keys into an
137    /// ACOMP's params. This covers the history panel's arm-on-expand path too.
138    pub fn arm_transform(&mut self, feature_id: &str) {
139        let is_acomp = self
140            .history
141            .index_of(feature_id)
142            .and_then(|index| self.history.feature_type(index))
143            .is_some_and(|ty| super::components::is_acomp_feature_type(&ty));
144        if is_acomp {
145            self.component_move_arm(feature_id);
146            return;
147        }
148        self.component_move_reset();
149        self.transform_gizmo.feature_id = Some(feature_id.to_string());
150        self.transform_gizmo.mode = GizmoMode::Transform;
151        self.transform_gizmo.drag = None;
152        self.transform_gizmo.anchor = None;
153        self.clear_feature_dimension_overlay();
154        self.sync_transform_gizmo();
155    }
156
157    /// Disarm: hide BOTH gizmos + drop any in-flight drag. Also resets the
158    /// component Move gizmo (the widget slot is shared, so a disarm clears
159    /// whichever controller was feeding it).
160    pub fn disarm_transform(&mut self) {
161        self.component_move_reset();
162        self.transform_gizmo.feature_id = None;
163        self.transform_gizmo.mode = GizmoMode::None;
164        self.transform_gizmo.drag = None;
165        self.transform_gizmo.anchor = None;
166        let _ = self.widgets.set_transform_json("null");
167        self.clear_feature_dimension_overlay();
168        self.dirty = true;
169    }
170
171    /// The armed feature's current TRS pose (from its `inputParams.transform`),
172    /// or `None` when disarmed / the feature vanished.
173    fn armed_pose(&self) -> Option<TransformPose> {
174        let id = self.transform_gizmo.feature_id.as_deref()?;
175        let index = self.history.index_of(id)?;
176        if let Some(anchor) = self.transform_gizmo.anchor {
177            // A spline anchor: its position + the intrinsic-XYZ Euler of its
178            // stored axis triad (see `spline_edit.rs`).
179            let (position, rotation_deg) = self.spline_anchor_pose(index, anchor)?;
180            return Some(TransformPose {
181                position,
182                rotation_deg,
183                scale: [1.0, 1.0, 1.0],
184            });
185        }
186        let params = self.history.feature_params(index)?;
187        let transform = params.get("transform");
188        let pivot = if self.is_xform_feature(index) {
189            self.xform_pivot(index, &params)
190        } else {
191            [0.0; 3]
192        };
193        // Build the history's expression environment ONCE for the three vectors:
194        // a transform component may be an expression string.
195        let env = brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator())
196            .ok();
197        let env = env.as_ref();
198        let position = read_pose_vec3(env, transform, "position", [0.0; 3]);
199        Some(TransformPose {
200            position: std::array::from_fn(|axis| position[axis] + pivot[axis]),
201            rotation_deg: read_pose_vec3(env, transform, "rotationEuler", [0.0, 0.0, 0.0]),
202            scale: read_pose_vec3(env, transform, "scale", [1.0, 1.0, 1.0]),
203        })
204    }
205
206    fn is_xform_feature(&self, index: usize) -> bool {
207        self.history.feature_type(index).is_some_and(|ty| {
208            ty.eq_ignore_ascii_case("XFORM") || ty.eq_ignore_ascii_case("TRANSFORM")
209        })
210    }
211
212    /// XFORM rotates each selected solid about its own source vertex-bbox center.
213    /// Anchor the shared controls to the first resolved solid's pivot; the same
214    /// translation/rotation delta still applies to every selected solid.
215    fn xform_pivot(&self, index: usize, params: &serde_json::Value) -> [f64; 3] {
216        if !params["pivot"]
217            .as_str()
218            .is_some_and(|p| p.eq_ignore_ascii_case("BBOX_CENTER"))
219        {
220            return [0.0; 3];
221        }
222        // Replay the warm current prefix, then fold only results BEFORE XFORM.
223        // Using the transformed scene bbox would make the pivot drift on rotation.
224        let Ok(request) = serde_json::from_value::<HistoryRequest>(self.history.prefix_request())
225        else {
226            return [0.0; 3];
227        };
228        let result = brep_kernel::execute_history(&request);
229        let mut handles = HashMap::new();
230        for feature in result.results.iter().take(index) {
231            for name in &feature.removed {
232                handles.remove(name);
233            }
234            for solid in &feature.added {
235                handles.insert(solid.name.clone(), solid.handle);
236            }
237        }
238        let Some(refs) = params["solids"].as_array() else {
239            return [0.0; 3];
240        };
241        for reference in refs {
242            let Some(name) = reference.as_str().or_else(|| reference["name"].as_str()) else {
243                continue;
244            };
245            let Some(handle) = handles.get(name.trim()) else {
246                continue;
247            };
248            if let Ok(center) = brep_kernel::transform_pivot_native(*handle) {
249                return center;
250            }
251        }
252        [0.0; 3]
253    }
254
255    /// (Re)feed the widget gizmo at the armed feature's frame: origin =
256    /// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
257    /// the kernel bake). Auto-disarms if the feature vanished. Called every drag
258    /// frame from `transform_drag_to` so the widget tracks the moving pose live
259    /// (Fix 3); the drag delta resolves against the frozen grab frame, so this
260    /// re-sync never feeds back into the drag math.
261    pub fn sync_transform_gizmo(&mut self) {
262        // Only the TRANSFORM mode feeds the move/rotate widget; in dimension mode
263        // the widget stays hidden (the annotations render as an overlay instead).
264        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
265            return;
266        }
267        let Some(pose) = self.armed_pose() else {
268            self.disarm_transform();
269            return;
270        };
271        let _ = self
272            .widgets
273            .set_transform_json(&transform_frame_json(&pose));
274        self.dirty = true;
275    }
276
277    /// Whether the feature schema exposes the shared Transform controls.
278    /// Capability belongs to the schema, not to which default-valued fields
279    /// happen to have been serialized in the model.
280    pub fn feature_has_transform(&self, feature_id: &str) -> bool {
281        self.history
282            .index_of(feature_id)
283            .and_then(|i| self.history.feature_type(i))
284            .and_then(|ty| crate::features::feature_schema(&ty))
285            .is_some_and(|schema| schema["inputParamsSchema"]["transform"]["type"] == "transform")
286    }
287
288    /// The armed gizmo origin projected to VIEWPORT-LOCAL px (the center handle
289    /// sits here). The history panel publishes it so the headed verifier can
290    /// locate + drag the gizmo. `None` when disarmed / not projectable (the ONE
291    /// [`crate::view::ViewCamera::projectable`] policy — ortho always projects).
292    pub fn transform_gizmo_anchor(&self) -> Option<(f64, f64)> {
293        let pose = self.armed_pose()?;
294        let (sx, sy, _) = self.camera.project(pose.position);
295        self.camera.projectable(pose.position).then_some((sx, sy))
296    }
297
298    /// The transform gizmo's axis-end labels as JSON:
299    /// `[{ text:"XC"|"YC"|"ZC", rgb:[r,g,b], world:[x,y,z] }]`. The app projects
300    /// each `world` point and draws the colored egui label just past the matching
301    /// cone tip (X=red, Y=green, Z=blue). `[]` unless the TRANSFORM gizmo is armed.
302    pub fn transform_axis_labels_json(&self) -> String {
303        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
304            return "[]".to_string();
305        }
306        let Some(pose) = self.armed_pose() else {
307            return "[]".to_string();
308        };
309        let euler = [
310            pose.rotation_deg[0].to_radians(),
311            pose.rotation_deg[1].to_radians(),
312            pose.rotation_deg[2].to_radians(),
313        ];
314        let axes = [
315            normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
316            normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
317            normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
318        ];
319        // Just past the cone tip, screen-constant.
320        let gap_px = 12.0_f64;
321        let dist =
322            (brep_gizmos::transform::PX_AXIS_LEN as f64 + gap_px) * self.camera.world_per_pixel();
323        let labels: [(&str, [f32; 3]); 3] = [
324            ("XC", [0.92, 0.26, 0.28]), // red
325            ("YC", [0.30, 0.78, 0.36]), // green
326            ("ZC", [0.30, 0.52, 0.98]), // blue
327        ];
328        let out: Vec<serde_json::Value> = (0..3)
329            .map(|i| {
330                let o = pose.position;
331                let a = axes[i];
332                serde_json::json!({
333                    "text": labels[i].0,
334                    "rgb": labels[i].1,
335                    "world": [o[0] + a[0] * dist, o[1] + a[1] * dist, o[2] + a[2] * dist],
336                })
337            })
338            .collect();
339        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
340    }
341
342    /// Viewport-local pick regions for the debug overlay, shared by feature and
343    /// component controllers. The widget feed determines visibility; hidden
344    /// widgets return `[]`. The app offsets these circles/capsules by `rect.min`.
345    pub fn transform_hit_areas_json(&self) -> String {
346        let cam = gizmo_camera(&self.camera);
347        let regions = self.widgets.transform_hit_regions(&cam);
348        super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
349    }
350
351    /// Begin a gizmo drag at viewport px `(x, y)` when the gizmo is armed AND a
352    /// handle is under the pointer. Returns whether a handle was grabbed — the
353    /// viewport routes the drag to the gizmo (not the camera) when `true`; a press
354    /// on empty space returns `false` and still orbits.
355    pub fn transform_press(&mut self, x: f64, y: f64) -> bool {
356        // Only grabbable in TRANSFORM mode — in dimension mode the dimension
357        // handles own the pointer (routed by the app), and disarmed grabs nothing.
358        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
359            return false;
360        }
361        let handle = self.transform_pick(x, y);
362        if handle == 0 {
363            return false;
364        }
365        let Some(pose) = self.armed_pose() else {
366            return false;
367        };
368        self.widgets.set_transform_active(handle);
369        self.transform_gizmo.drag = Some(TransformDrag {
370            handle,
371            sx: x as f32,
372            sy: y as f32,
373            start: pose,
374        });
375        self.dirty = true;
376        true
377    }
378
379    /// Whether a gizmo handle drag is in flight.
380    pub fn transform_dragging(&self) -> bool {
381        self.transform_gizmo.drag.is_some()
382    }
383
384    /// Continue the in-flight gizmo drag to viewport px `(cx, cy)`: resolve the
385    /// world delta from the grab (against the frozen grab-time frame), apply it to
386    /// the grab pose, write it back into the feature's `transform`, and re-run so
387    /// the model follows live. Then re-sync the VISIBLE gizmo to the moved pose so
388    /// the widget tracks the pointer in real time (Fix 3) — the delta stays
389    /// anchored to `drag.start`, so this visual sync never feeds back on itself.
390    pub fn transform_drag_to(&mut self, cx: f64, cy: f64) {
391        let Some(drag) = self.transform_gizmo.drag else {
392            return;
393        };
394        let Some(delta) = self.resolve_transform_delta(&drag, cx, cy) else {
395            return;
396        };
397        let pose = apply_transform_delta(&drag.start, &delta);
398        self.write_armed_pose(&pose);
399        // Live-follow: re-feed the widget frame to the just-written pose. NB
400        // `finish_apply` skips this while a drag is in flight (drag.is_some()), so
401        // the sync happens here. The active-handle gold highlight survives (the
402        // widget only clears it on a `null` feed).
403        self.sync_transform_gizmo();
404    }
405
406    /// End the drag: clear the active-handle highlight + re-sync the gizmo to the
407    /// feature's final (moved) pose (unpin the frame).
408    pub fn transform_release(&mut self) {
409        if self.transform_gizmo.drag.take().is_some() {
410            self.widgets.set_transform_active(0);
411            self.sync_transform_gizmo();
412        }
413    }
414
415    /// Resolve against the grab-time frame so live widget updates cannot
416    /// feed back into the drag calculation.
417    fn resolve_transform_delta(
418        &self,
419        drag: &TransformDrag,
420        cx: f64,
421        cy: f64,
422    ) -> Option<TransformDelta> {
423        let cam = gizmo_camera(&self.camera);
424        let frame_json = transform_frame_json(&drag.start);
425        let json = self.widgets.transform_drag_json_with_frame(
426            &cam,
427            &frame_json,
428            drag.handle,
429            drag.sx,
430            drag.sy,
431            cx as f32,
432            cy as f32,
433        );
434        parse_drag_delta(&json)
435    }
436
437    /// Write `pose` into the armed feature's `inputParams.transform.{position,
438    /// rotationEuler}` (preserving every other field, incl. `scale`) and re-run.
439    fn write_armed_pose(&mut self, pose: &TransformPose) {
440        let Some(id) = self.transform_gizmo.feature_id.clone() else {
441            return;
442        };
443        let Some(index) = self.history.index_of(&id) else {
444            return;
445        };
446        if let Some(anchor) = self.transform_gizmo.anchor {
447            self.write_spline_anchor_pose(&id, anchor, pose.position, pose.rotation_deg);
448            return;
449        }
450        let mut params = self
451            .history
452            .feature_params(index)
453            .unwrap_or_else(|| serde_json::json!({}));
454        let pivot = if self.is_xform_feature(index) {
455            self.xform_pivot(index, &params)
456        } else {
457            [0.0; 3]
458        };
459        let position: [f64; 3] = std::array::from_fn(|axis| pose.position[axis] - pivot[axis]);
460        // Ensure `transform` is an object, then set the two edited vectors.
461        if !params
462            .get("transform")
463            .map(|t| t.is_object())
464            .unwrap_or(false)
465        {
466            if let Some(object) = params.as_object_mut() {
467                object.insert("transform".into(), serde_json::json!({}));
468            }
469        }
470        if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
471            transform.insert("position".into(), serde_json::json!(position));
472            transform.insert("rotationEuler".into(), serde_json::json!(pose.rotation_deg));
473        }
474        let _ = self.update_feature_params(&id, &params.to_string());
475    }
476}
477
478/// Apply translation or world-axis rotation to the grab pose, retaining scale.
479fn apply_transform_delta(start: &TransformPose, delta: &TransformDelta) -> TransformPose {
480    match delta {
481        TransformDelta::Translate(world) => TransformPose {
482            position: [
483                start.position[0] + world[0],
484                start.position[1] + world[1],
485                start.position[2] + world[2],
486            ],
487            ..*start
488        },
489        TransformDelta::Rotate { axis, radians } => {
490            let q0 = quat_from_euler_xyz_deg(start.rotation_deg);
491            let dq = quat_from_axis_angle(*axis, *radians);
492            let nq = quat_mul(dq, q0);
493            TransformPose {
494                rotation_deg: euler_xyz_deg_from_quat(nq),
495                ..*start
496            }
497        }
498    }
499}
500
501/// Read a `[x, y, z]` from a transform sub-field (numbers only; missing / short
502/// arrays keep the per-index default).
503fn read_pose_vec3(
504    env: Option<&brep_kernel::Env>,
505    transform: Option<&serde_json::Value>,
506    key: &str,
507    default: [f64; 3],
508) -> [f64; 3] {
509    let array = transform
510        .and_then(|t| t.get(key))
511        .and_then(|v| v.as_array());
512    let mut out = default;
513    if let Some(array) = array {
514        for (index, slot) in out.iter_mut().enumerate() {
515            match array.get(index) {
516                Some(serde_json::Value::Number(number)) => {
517                    if let Some(number) = number.as_f64() {
518                        *slot = number;
519                    }
520                }
521                // A component may be an EXPRESSION (`"boxW/2"`) — the kernel
522                // evaluates it when it builds, so the gizmo must arm at the same
523                // place. Reading it as 0 parked the handles at the world origin
524                // while the solid sat elsewhere, and the first drag wrote that
525                // wrong origin back as a literal, teleporting the solid.
526                Some(serde_json::Value::String(source)) => {
527                    if let Some(number) = env
528                        .and_then(|env| env.eval(source).ok())
529                        .filter(|number| number.is_finite())
530                    {
531                        *slot = number;
532                    }
533                }
534                _ => {}
535            }
536        }
537    }
538    out
539}
540
541/// The gizmo frame feed (`set_transform_json` shape) for a pose: origin =
542/// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
543/// the kernel bake), with the center free-move handle shown.
544fn transform_frame_json(pose: &TransformPose) -> String {
545    let euler = [
546        pose.rotation_deg[0].to_radians(),
547        pose.rotation_deg[1].to_radians(),
548        pose.rotation_deg[2].to_radians(),
549    ];
550    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
551    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
552    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
553    serde_json::json!({
554        "origin": pose.position,
555        "x": x,
556        "y": y,
557        "z": z,
558        "showCenter": true,
559    })
560    .to_string()
561}
562
563pub(super) fn normalize3(v: [f64; 3]) -> [f64; 3] {
564    let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
565    if length < 1e-12 {
566        [0.0, 0.0, 1.0]
567    } else {
568        [v[0] / length, v[1] / length, v[2] / length]
569    }
570}
571
572/// Apply an intrinsic XYZ Euler (radians) to a vector — the EXACT matrix the kernel
573/// bake (`transform_bake` / `datum::rotate_euler_xyz`) uses, so the fed gizmo
574/// frame aligns with the baked solid.
575pub(crate) fn rotate_euler_xyz_f64(v: [f64; 3], euler: [f64; 3]) -> [f64; 3] {
576    let (c1, s1) = (euler[0].cos(), euler[0].sin());
577    let (c2, s2) = (euler[1].cos(), euler[1].sin());
578    let (c3, s3) = (euler[2].cos(), euler[2].sin());
579    let m00 = c2 * c3;
580    let m01 = -c2 * s3;
581    let m02 = s2;
582    let m10 = c1 * s3 + c3 * s1 * s2;
583    let m11 = c1 * c3 - s1 * s2 * s3;
584    let m12 = -c2 * s1;
585    let m20 = s1 * s3 - c1 * c3 * s2;
586    let m21 = c3 * s1 + c1 * s2 * s3;
587    let m22 = c1 * c2;
588    [
589        m00 * v[0] + m01 * v[1] + m02 * v[2],
590        m10 * v[0] + m11 * v[1] + m12 * v[2],
591        m20 * v[0] + m21 * v[1] + m22 * v[2],
592    ]
593}
594
595// --- quaternion helpers (ported from CombinedTransformControls) -----
596
597pub(super) type Quat = [f64; 4]; // [x, y, z, w]
598
599pub(super) fn quat_from_axis_angle(axis: [f64; 3], angle: f64) -> Quat {
600    let n = normalize3(axis);
601    let half = angle * 0.5;
602    let s = half.sin();
603    [n[0] * s, n[1] * s, n[2] * s, half.cos()]
604}
605
606/// Quaternion from an intrinsic XYZ Euler (degrees in).
607pub(super) fn quat_from_euler_xyz_deg(deg: [f64; 3]) -> Quat {
608    let (c1, s1) = (
609        (deg[0].to_radians() * 0.5).cos(),
610        (deg[0].to_radians() * 0.5).sin(),
611    );
612    let (c2, s2) = (
613        (deg[1].to_radians() * 0.5).cos(),
614        (deg[1].to_radians() * 0.5).sin(),
615    );
616    let (c3, s3) = (
617        (deg[2].to_radians() * 0.5).cos(),
618        (deg[2].to_radians() * 0.5).sin(),
619    );
620    [
621        s1 * c2 * c3 + c1 * s2 * s3,
622        c1 * s2 * c3 - s1 * c2 * s3,
623        c1 * c2 * s3 + s1 * s2 * c3,
624        c1 * c2 * c3 - s1 * s2 * s3,
625    ]
626}
627
628/// Quaternion product `a * b`.
629pub(super) fn quat_mul(a: Quat, b: Quat) -> Quat {
630    [
631        a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
632        a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
633        a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
634        a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
635    ]
636}
637
638/// Intrinsic XYZ Euler from a quaternion (via the rotation matrix) → the
639/// 'XYZ' Euler in DEGREES. Uses the SAME matrix element naming as
640/// `rotate_euler_xyz_f64`, so the round-trip is consistent with the kernel bake.
641pub(super) fn euler_xyz_deg_from_quat(q: Quat) -> [f64; 3] {
642    let [x, y, z, w] = q;
643    let (x2, y2, z2) = (x + x, y + y, z + z);
644    let (xx, xy, xz) = (x * x2, x * y2, x * z2);
645    let (yy, yz, zz) = (y * y2, y * z2, z * z2);
646    let (wx, wy, wz) = (w * x2, w * y2, w * z2);
647    // Rotation matrix elements (m<row><col> naming).
648    let m11 = 1.0 - (yy + zz);
649    let m12 = xy - wz;
650    let m13 = xz + wy;
651    let m22 = 1.0 - (xx + zz);
652    let m23 = yz - wx;
653    let m32 = yz + wx;
654    let m33 = 1.0 - (xx + yy);
655    let ey = m13.clamp(-1.0, 1.0).asin();
656    let (ex, ez) = if m13.abs() < 0.9999999 {
657        ((-m23).atan2(m33), (-m12).atan2(m11))
658    } else {
659        (m32.atan2(m22), 0.0)
660    };
661    [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
662}
663
664// BREP private tests: a433bd9d8292ab45