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    /// When the armed subject is ONE DECLARED CONNECTION POINT: its part-local
38    /// address (`J1.VCC`). Exclusive with `feature_id` — a connection point is
39    /// data, not a feature, so there is no history row to hold — and checked
40    /// FIRST everywhere the pose is read or written, before any lookup that
41    /// goes through the history. See the section at the foot of this file.
42    pub(super) port: Option<String>,
43    /// When the armed subject is a NURBS Solid's CONTROL CAGE: the selected
44    /// points of `feature_id` and the drag's grab snapshot. The gizmo sits at
45    /// the selection's centroid; a drag moves (and turns) every selected
46    /// point. See `cage_edit.rs`.
47    pub(super) cage: Option<super::cage_edit::CageArm>,
48}
49
50/// A grab snapshot for an in-flight transform-gizmo drag: the grabbed handle,
51/// the grab screen point, and the feature's pose AT GRAB. Every drag move
52/// resolves an absolute delta from this grab (against the pinned grab-time
53/// frame) and re-applies it to `start`, so the drag never accumulates error.
54#[derive(Clone, Copy)]
55pub(super) struct TransformDrag {
56    handle: u32,
57    sx: f32,
58    sy: f32,
59    start: TransformPose,
60    /// The delta this drag has resolved SO FAR, as the handle resolved it —
61    /// the readout's source. Taken from the drag rather than differenced out of
62    /// the pose, because the pose is the only thing the two are agreed on: a
63    /// rotation composes onto a quaternion and comes back as an Euler triple
64    /// that a subtraction cannot turn into "42.5 degrees about Y" again. `None`
65    /// until the pointer has actually moved off the grab point.
66    last: Option<TransformDelta>,
67}
68
69/// A TRS pose read from / written to a feature's `inputParams.transform`
70/// (`rotation` in DEGREES, intrinsic XYZ Euler order — the kernel `transform_bake`
71/// convention, `M = T·R·S`).
72#[derive(Clone, Copy, PartialEq, Debug)]
73pub(super) struct TransformPose {
74    pub(super) position: [f64; 3],
75    pub(super) rotation_deg: [f64; 3],
76    pub(super) scale: [f64; 3],
77}
78
79/// A world-space delta shared by feature and component gizmo controllers.
80#[derive(Clone, Copy, PartialEq, Debug)]
81pub(super) enum TransformDelta {
82    /// World-space translation, added to `position`.
83    Translate([f64; 3]),
84    /// World-axis rotation, pre-multiplied onto the pose's orientation.
85    Rotate { axis: [f64; 3], radians: f64 },
86}
87
88/// Decode widget drag JSON; unknown kinds and invalid JSON produce no delta.
89pub(super) fn parse_drag_delta(json: &str) -> Option<TransformDelta> {
90    let value: serde_json::Value = serde_json::from_str(json).ok()?;
91    match value.get("kind").and_then(|k| k.as_str()) {
92        Some("translate") => Some(TransformDelta::Translate(
93            crate::json_support::vec3_or(value.get("world"), [0.0; 3]),
94        )),
95        Some("rotate") => Some(TransformDelta::Rotate {
96            axis: crate::json_support::vec3_or(value.get("axisWorld"), [0.0; 3]),
97            radians: value.get("radians").and_then(|n| n.as_f64()).unwrap_or(0.0),
98        }),
99        _ => None,
100    }
101}
102
103impl EngineState {
104    /// Whether the TRANSFORM gizmo (move/rotate) is armed for ANY feature. False
105    /// in dimension mode — the two ◎ modes are exclusive, so the transform gizmo
106    /// arms/handles never render while dimensions are shown.
107    pub fn transform_armed(&self) -> bool {
108        matches!(self.transform_gizmo.mode, GizmoMode::Transform)
109    }
110
111    /// The transform-gizmo-armed feature id (empty unless in transform mode).
112    pub fn transform_armed_feature(&self) -> String {
113        if self.transform_armed() {
114            self.transform_gizmo.feature_id.clone().unwrap_or_default()
115        } else {
116            String::new()
117        }
118    }
119
120    /// The gizmo as the headed verifier sees it: the mode, the armed feature,
121    /// the spline anchor index when the gizmo sits on one, and `origin` — the
122    /// DRAWN move/rotate widget's frame origin (`null` when no widget is
123    /// drawn), which is the proof a handle is on screen, unlike any
124    /// params-derived pose.
125    pub fn gizmo_state_json(&self) -> String {
126        let mode = match self.transform_gizmo.mode {
127            GizmoMode::None => "none",
128            GizmoMode::Transform => "transform",
129            GizmoMode::Dimension => "dimension",
130        };
131        serde_json::json!({
132            "mode": mode,
133            "armed": self.transform_armed(),
134            "feature": self.transform_gizmo.feature_id,
135            "anchor": self.transform_gizmo.anchor,
136            "portPoint": self.transform_gizmo.port,
137            "cage": self.transform_gizmo.cage.as_ref().map(|cage| cage.selected.clone()),
138            "origin": self.widgets.transform_origin(),
139            // A Transform Face turns about its stored `pivot`, and the gizmo
140            // sits at `pivot + position` — so the two coincide only while the
141            // motion is zero. The moment the user drags, the point the rotation
142            // actually happens about is no longer under the handles and was
143            // nowhere on screen at all. Published so the viewport can mark it.
144            "pivot": self.armed_face_transform_pivot(),
145        })
146        .to_string()
147    }
148
149    /// The armed feature's Transform Face pivot in WORLD space, or `None` when
150    /// the armed feature is not a Transform Face (every other transform feature
151    /// turns about its own origin, which the gizmo is already on).
152    pub fn armed_face_transform_pivot(&self) -> Option<[f64; 3]> {
153        let id = self.transform_gizmo.feature_id.as_deref()?;
154        let index = self.history.index_of(id)?;
155        if !self.is_face_transform_feature(index) {
156            return None;
157        }
158        let params = self.history.feature_params(index)?;
159        let env = brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator()).ok();
160        self.face_transform_pivot(index, &params, env.as_ref())
161    }
162
163    /// What the gizmo drag is WORTH, for the chip the viewport draws on it —
164    /// and, when the motion refused, why it refused, said about the user's
165    /// model rather than about the kernel's internals.
166    ///
167    /// A gizmo drag used to be the one edit in the app that reported nothing:
168    /// the model followed the pointer, the number went into `transform`, the
169    /// form's `Transform` section is collapsed by default, and so the distance
170    /// the user had just dragged appeared NOWHERE on screen. The Extrude
171    /// distance arrow carries its value in a chip on the geometry; this is the
172    /// same answer for the gizmo.
173    ///
174    /// The readout is live in two states, and silent otherwise:
175    ///
176    /// * **while a handle is being dragged** — the distance or angle so far.
177    /// * **while the armed feature is REFUSING** — because that is the state
178    ///   the user is left in after letting go of a refused drag, and it is the
179    ///   state that most needs saying. The gizmo tracks the pointer while the
180    ///   geometry stays at its last good answer, so on a refusal the handles
181    ///   end up somewhere the model is not — measured at 15.4 mm INSIDE the
182    ///   solid — and without this the only signal was a red banner in a dock on
183    ///   the far side of the window from the pointer.
184    ///
185    /// `origin` is where to draw it: the gizmo's own frame origin, which is
186    /// where the pointer is.
187    pub fn transform_readout_json(&self) -> String {
188        let quiet = serde_json::json!({ "active": false }).to_string();
189        if !self.transform_armed() {
190            return quiet;
191        }
192        let Some(origin) = self.widgets.transform_origin() else {
193            return quiet;
194        };
195
196        // The refusal, if this feature is refusing — the armed feature's entry
197        // in the last run's report, re-said around the faces it names.
198        let refusal = self.armed_face_transform_refusal();
199        let drag = self.transform_gizmo.drag.as_ref();
200        let delta = drag.and_then(|d| d.last);
201
202        // Silent unless something is happening: no drag in flight and no
203        // refusal on the board means the model is simply sitting there.
204        if delta.is_none() && refusal.is_none() {
205            return quiet;
206        }
207
208        // What the handle resolved, on the same 0.001 grid the drag stores, so
209        // the chip and the form agree digit for digit.
210        let (kind, label, value, unit, text) = match delta {
211            Some(TransformDelta::Translate(world)) => {
212                let distance = crate::formatting::snap_drag_value(
213                    (world[0] * world[0] + world[1] * world[1] + world[2] * world[2]).sqrt(),
214                );
215                let axis = dominant_axis_label(&world);
216                let shown = crate::formatting::compact_decimal(distance, 3);
217                (
218                    "translate",
219                    axis.to_string(),
220                    distance,
221                    "mm",
222                    format!("{axis}  {shown} mm"),
223                )
224            }
225            Some(TransformDelta::Rotate { axis, radians }) => {
226                let degrees = crate::formatting::snap_drag_value(radians.to_degrees());
227                let label = dominant_axis_label(&axis);
228                let shown = crate::formatting::compact_decimal(degrees, 3);
229                (
230                    "rotate",
231                    label.to_string(),
232                    degrees,
233                    "deg",
234                    format!("{label}  {shown}\u{00b0}"),
235                )
236            }
237            // Refusing, but not mid-drag (the user has let go, or typed the
238            // value): the chip carries the refusal alone.
239            None => ("none", String::new(), 0.0, "", String::new()),
240        };
241
242        let mut out = serde_json::json!({
243            "active": true,
244            "kind": kind,
245            "axis": label,
246            "value": value,
247            "unit": unit,
248            "text": text,
249            "origin": origin,
250            "dragging": drag.is_some(),
251            "refused": refusal.is_some(),
252        });
253        if let Some(refusal) = refusal {
254            out["motion"] = serde_json::json!(refusal.motion);
255            out["reason"] = serde_json::json!(refusal.reason);
256            out["hint"] = serde_json::json!(refusal.hint);
257            out["kernel"] = serde_json::json!(refusal.kernel);
258        }
259        out.to_string()
260    }
261
262    /// The armed feature's refusal, re-said about the model — `None` when it is
263    /// not a Transform Face, or is not refusing. The face names come from the
264    /// feature's own `params.faces`, which is the thing the kernel's message
265    /// does not carry and the app does.
266    fn armed_face_transform_refusal(&self) -> Option<crate::face_transform_help::FaceTransformRefusal> {
267        let id = self.transform_gizmo.feature_id.as_deref()?;
268        let index = self.history.index_of(id)?;
269        if !self.is_face_transform_feature(index) {
270            return None;
271        }
272        let report: serde_json::Value = serde_json::from_str(&self.history_report).ok()?;
273        // The kernel records each failure as `"<feature id>: <message>"`; the
274        // delimiter after the exact id is what stops a shorter id matching a
275        // longer one (`TF1` vs `TF10`).
276        let prefix = format!("{id}: ");
277        let message = report
278            .get("featureErrors")?
279            .as_array()?
280            .iter()
281            .filter_map(serde_json::Value::as_str)
282            .find(|entry| entry.starts_with(&prefix))
283            .map(|entry| &entry[prefix.len()..])?;
284        let params = self.history.feature_params(index)?;
285        let faces = brep_kernel::reference_names(params.get("faces"));
286        let facts = crate::face_transform_help::RefusalFacts::from_report(&report, id);
287        Some(crate::face_transform_help::explain_refusal(message, facts, &faces))
288    }
289
290    /// Whether the TRANSFORM gizmo is armed for THIS feature.
291    pub fn transform_armed_for(&self, feature_id: &str) -> bool {
292        self.transform_armed() && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
293    }
294
295    /// Arm the TRANSFORM gizmo for `feature_id` and feed it at the feature's
296    /// transform frame. Re-arming a different feature moves the gizmo to it.
297    /// Clears any dimension overlay (the modes are exclusive).
298    ///
299    /// An ACOMP (assembly component) feature ROUTES to the component Move gizmo
300    /// instead: its `transform` is the component pose (`{translate,
301    /// rotateEulerDeg}`, a different shape), its gizmo attaches at the member
302    /// bbox center, commits on release, and refuses fixed instances — the
303    /// generic position/rotationEuler gizmo must never write its keys into an
304    /// ACOMP's params. This covers the history panel's arm-on-expand path too.
305    pub fn arm_transform(&mut self, feature_id: &str) {
306        let is_acomp = self
307            .history
308            .index_of(feature_id)
309            .and_then(|index| self.history.feature_type(index))
310            .is_some_and(|ty| super::components::is_acomp_feature_type(&ty));
311        if is_acomp {
312            self.component_move_arm(feature_id);
313            return;
314        }
315        self.component_move_reset();
316        self.transform_gizmo.feature_id = Some(feature_id.to_string());
317        self.transform_gizmo.mode = GizmoMode::Transform;
318        self.transform_gizmo.drag = None;
319        self.transform_gizmo.anchor = None;
320        self.transform_gizmo.port = None;
321        self.transform_gizmo.cage = None;
322        self.clear_feature_dimension_overlay();
323        self.sync_transform_gizmo();
324    }
325
326    /// Disarm: hide BOTH gizmos + drop any in-flight drag. Also resets the
327    /// component Move gizmo (the widget slot is shared, so a disarm clears
328    /// whichever controller was feeding it).
329    pub fn disarm_transform(&mut self) {
330        self.component_move_reset();
331        self.transform_gizmo.feature_id = None;
332        self.transform_gizmo.mode = GizmoMode::None;
333        self.transform_gizmo.drag = None;
334        self.transform_gizmo.anchor = None;
335        self.transform_gizmo.port = None;
336        self.transform_gizmo.cage = None;
337        let _ = self.widgets.set_transform_json("null");
338        self.clear_feature_dimension_overlay();
339        self.dirty = true;
340    }
341
342    /// The armed feature's current TRS pose (from its `inputParams.transform`),
343    /// or `None` when disarmed / the feature vanished.
344    fn armed_pose(&self) -> Option<TransformPose> {
345        // A connection point has no history row, so this must come before
346        // every lookup that needs one.
347        if let Some(address) = self.transform_gizmo.port.clone() {
348            return self.port_point_pose(&address);
349        }
350        let id = self.transform_gizmo.feature_id.as_deref()?;
351        let pmi_annotation = {
352            let state = self.pmi_state_shared();
353            state
354                .find_annotation(id)
355                .map(|(view, annotation)| (view.id.clone(), annotation.kind.clone(), annotation.params.clone()))
356        };
357        if let Some((view_id, kind, params)) = pmi_annotation {
358            if self.pmi_active_view.as_deref() != Some(view_id.as_str()) {
359                return None;
360            }
361            if let Some(row) = self.pmi_report.as_ref().and_then(|r| r.annotation(id)) {
362                // Part-way through playback the step's gizmo sits where its step
363                // has brought its bodies.
364                let fraction = self.explode_step_of(id).map_or(1.0, |step| self.explode_fraction(step));
365                if let brep_kernel::PmiGeometry::Explode {
366                    center,
367                    translate,
368                    rotate_deg,
369                    scale,
370                    ..
371                } = &super::exploded_ops::explode_at(&row.geometry, fraction)
372                {
373                    return Some(TransformPose {
374                        position: std::array::from_fn(|i| center[i] + translate[i]),
375                        rotation_deg: *rotate_deg,
376                        scale: *scale,
377                    });
378                }
379            }
380            // An explode step the kernel has not laid out (its targets are
381            // still in the picker, or the run has not landed): the gizmo
382            // stands on what the step is about to move, so the controls are
383            // there from the first pick, before Done commits it.
384            if kind == "explode" {
385                let center = self.pmi_explode_live_center(id)?;
386                let transform = params.get("transform");
387                let position = read_pose_vec3(None, transform, "position", [0.0; 3]);
388                return Some(TransformPose {
389                    position: std::array::from_fn(|axis| center[axis] + position[axis]),
390                    rotation_deg: read_pose_vec3(None, transform, "rotationEuler", [0.0; 3]),
391                    scale: read_pose_vec3(None, transform, "scale", [1.0; 3]),
392                });
393            }
394            return None;
395        }
396        let index = self.history.index_of(id)?;
397        if self.transform_gizmo.cage.is_some() {
398            // A control cage: the selection's centroid, or the live drag pose
399            // (see `cage_edit.rs`).
400            return self.cage_pose();
401        }
402        if let Some(anchor) = self.transform_gizmo.anchor {
403            // A spline anchor: its position + the intrinsic-XYZ Euler of its
404            // stored axis triad (see `spline_edit.rs`).
405            let (position, rotation_deg) = self.spline_anchor_pose(index, anchor)?;
406            return Some(TransformPose {
407                position,
408                rotation_deg,
409                scale: [1.0, 1.0, 1.0],
410            });
411        }
412        let params = self.history.feature_params(index)?;
413        let transform = params.get("transform");
414        // Build the history's expression environment ONCE for the vectors: a
415        // transform or pivot component may be an expression string.
416        let env =
417            brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator()).ok();
418        let env = env.as_ref();
419        let pivot = if self.is_xform_feature(index) {
420            self.xform_pivot(index, &params)
421        } else if self.is_face_transform_feature(index) {
422            // No faces, no pivot, no gizmo: there is nothing to put it on yet.
423            self.face_transform_pivot(index, &params, env)?
424        } else {
425            [0.0; 3]
426        };
427        let position = read_pose_vec3(env, transform, "position", [0.0; 3]);
428        Some(TransformPose {
429            position: std::array::from_fn(|axis| position[axis] + pivot[axis]),
430            rotation_deg: read_pose_vec3(env, transform, "rotationEuler", [0.0, 0.0, 0.0]),
431            scale: read_pose_vec3(env, transform, "scale", [1.0, 1.0, 1.0]),
432        })
433    }
434
435    fn is_xform_feature(&self, index: usize) -> bool {
436        self.history.feature_type(index).is_some_and(|ty| {
437            ty.eq_ignore_ascii_case("XFORM") || ty.eq_ignore_ascii_case("TRANSFORM")
438        })
439    }
440
441    /// Whether the feature at `index` is a Transform Face — the feature whose
442    /// motion turns about a STORED `pivot` param.
443    pub fn is_face_transform_feature(&self, index: usize) -> bool {
444        self.history
445            .feature_type(index)
446            .is_some_and(|ty| is_face_transform_type(&ty))
447    }
448
449    /// The point a Transform Face turns about: its stored `pivot`, or — while
450    /// that is still null — the kernel's default, the selection's centre over
451    /// the scene the feature runs against. `None` when neither exists (no face
452    /// resolves), which is what keeps the gizmo off an empty selection.
453    fn face_transform_pivot(
454        &self,
455        index: usize,
456        params: &serde_json::Value,
457        env: Option<&brep_kernel::Env>,
458    ) -> Option<[f64; 3]> {
459        match params.get("pivot") {
460            Some(pivot) if !pivot.is_null() => Some(read_pose_vec3(env, Some(params), "pivot", [0.0; 3])),
461            _ => self.face_transform_default_pivot(index, params),
462        }
463    }
464
465    /// Whether the Transform Face `feature_id` has a pivot to put its gizmo on —
466    /// a stored one, or faces that resolve to a default. The history panel arms
467    /// the gizmo on the frame this first holds.
468    pub fn face_transform_has_pivot(&self, feature_id: &str) -> bool {
469        let Some(index) = self.history.index_of(feature_id) else {
470            return false;
471        };
472        let Some(params) = self.history.feature_params(index) else {
473            return false;
474        };
475        self.is_face_transform_feature(index) && self.face_transform_pivot(index, &params, None).is_some()
476    }
477
478    /// Transform Face's DEFAULT pivot for `params.faces`: the prefix BEFORE the
479    /// feature is replayed (warm, like [`Self::xform_pivot`]'s) and handed to
480    /// [`brep_kernel::face_transform_pivot`] — the same resolution and centre the
481    /// feature itself falls back to, so the stored value and the kernel agree by
482    /// construction.
483    fn face_transform_default_pivot(
484        &self,
485        index: usize,
486        params: &serde_json::Value,
487    ) -> Option<[f64; 3]> {
488        let names = brep_kernel::reference_names(params.get("faces"));
489        if names.is_empty() {
490            return None;
491        }
492        let request: HistoryRequest =
493            serde_json::from_value(self.history.request_before(index)?).ok()?;
494        let _trace = crate::run_trace::span("face_transform_pivot");
495        let result = self.execute_plugin_history(&request);
496        brep_kernel::face_transform_pivot(&result.results, &names)
497    }
498
499    /// STORE a Transform Face's pivot into the params about to be written for
500    /// the feature at `index`: when this write changes the face selection
501    /// without also setting a pivot, or leaves the pivot null, `pivot` becomes the
502    /// new selection's centre (null again when nothing resolves). A pivot the
503    /// write sets itself is kept. Stored rather than derived per run, so the
504    /// rotation centre is a number in the document: a re-run, a gizmo drag, or an
505    /// upstream edit never moves it. Every lane that writes feature params calls
506    /// this before the write (`update_feature_params`, the batch lane, the
507    /// reference picker's Finish).
508    pub(crate) fn stamp_face_transform_pivot(&self, index: usize, params: &mut serde_json::Value) {
509        if !self.is_face_transform_feature(index) {
510            return;
511        }
512        let before = self.history.feature_params(index).unwrap_or(serde_json::Value::Null);
513        let faces_changed = brep_kernel::reference_names(before.get("faces"))
514            != brep_kernel::reference_names(params.get("faces"));
515        let pivot_set_here = before.get("pivot") != params.get("pivot");
516        let unset = params.get("pivot").is_none_or(serde_json::Value::is_null);
517        if !(unset || (faces_changed && !pivot_set_here)) {
518            return;
519        }
520        let pivot = self.face_transform_default_pivot(index, params);
521        if let Some(object) = params.as_object_mut() {
522            object.insert(
523                "pivot".into(),
524                pivot.map_or(serde_json::Value::Null, |pivot| serde_json::json!(pivot)),
525            );
526        }
527    }
528
529    /// XFORM rotates each selected solid about its own source vertex-bbox center.
530    /// Anchor the shared controls to the first resolved solid's pivot; the same
531    /// translation/rotation delta still applies to every selected solid.
532    fn xform_pivot(&self, index: usize, params: &serde_json::Value) -> [f64; 3] {
533        if !params["pivot"]
534            .as_str()
535            .is_some_and(|p| p.eq_ignore_ascii_case("BBOX_CENTER"))
536        {
537            return [0.0; 3];
538        }
539        // Replay the warm current prefix, then fold only results BEFORE XFORM.
540        // Using the transformed scene bbox would make the pivot drift on rotation.
541        let Ok(request) = serde_json::from_value::<HistoryRequest>(self.run_request_value())
542        else {
543            return [0.0; 3];
544        };
545        let _trace = crate::run_trace::span("xform_pivot");
546        let result = self.execute_plugin_history(&request);
547        let mut handles = HashMap::new();
548        for feature in result.results.iter().take(index) {
549            for name in &feature.removed {
550                handles.remove(name);
551            }
552            for solid in &feature.added {
553                handles.insert(solid.name.clone(), solid.handle);
554            }
555        }
556        let Some(refs) = params["solids"].as_array() else {
557            return [0.0; 3];
558        };
559        for reference in refs {
560            let Some(name) = reference.as_str().or_else(|| reference["name"].as_str()) else {
561                continue;
562            };
563            let Some(handle) = handles.get(name.trim()) else {
564                continue;
565            };
566            if let Ok(center) = brep_kernel::transform_pivot_native(*handle) {
567                return center;
568            }
569        }
570        [0.0; 3]
571    }
572
573    /// (Re)feed the widget gizmo at the armed feature's frame: origin =
574    /// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
575    /// the kernel bake). Auto-disarms if the feature vanished. Called every drag
576    /// frame from `transform_drag_to` so the widget tracks the moving pose live
577    /// (Fix 3); the drag delta resolves against the frozen grab frame, so this
578    /// re-sync never feeds back into the drag math.
579    pub fn sync_transform_gizmo(&mut self) {
580        // Only the TRANSFORM mode feeds the move/rotate widget; in dimension mode
581        // the widget stays hidden (the annotations render as an overlay instead).
582        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
583            return;
584        }
585        let Some(pose) = self.armed_pose() else {
586            self.disarm_transform();
587            return;
588        };
589        // A cage with ONE point selected has nothing to turn about: its
590        // gizmo draws no rotation rings.
591        let show_rings = self.cage_shows_rings().unwrap_or(true);
592        let _ = self
593            .widgets
594            .set_transform_json(&transform_frame_json_with(&pose, show_rings));
595        self.dirty = true;
596    }
597
598    /// Whether the feature schema exposes the shared Transform controls.
599    /// Capability belongs to the schema, not to which default-valued fields
600    /// happen to have been serialized in the model.
601    pub fn feature_has_transform(&self, feature_id: &str) -> bool {
602        self.history
603            .index_of(feature_id)
604            .and_then(|i| self.history.feature_type(i))
605            .and_then(|ty| self.feature_schema(&ty))
606            .is_some_and(|schema| schema["inputParamsSchema"]["transform"]["type"] == "transform")
607    }
608
609    /// The armed gizmo origin projected to VIEWPORT-LOCAL px (the center handle
610    /// sits here). The history panel publishes it so the headed verifier can
611    /// locate + drag the gizmo. `None` when disarmed / not projectable (the ONE
612    /// [`crate::view::ViewCamera::projectable`] policy — ortho always projects).
613    pub fn transform_gizmo_anchor(&self) -> Option<(f64, f64)> {
614        let pose = self.armed_pose()?;
615        let (sx, sy, _) = self.camera.project(pose.position);
616        self.camera.projectable(pose.position).then_some((sx, sy))
617    }
618
619    /// The transform gizmo's axis-end labels as JSON:
620    /// `[{ text:"XC"|"YC"|"ZC", rgb:[r,g,b], world:[x,y,z] }]`. The app projects
621    /// each `world` point and draws the colored egui label just past the matching
622    /// cone tip (X=red, Y=green, Z=blue). `[]` unless the TRANSFORM gizmo is armed.
623    pub fn transform_axis_labels_json(&self) -> String {
624        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
625            return "[]".to_string();
626        }
627        let Some(pose) = self.armed_pose() else {
628            return "[]".to_string();
629        };
630        let euler = [
631            pose.rotation_deg[0].to_radians(),
632            pose.rotation_deg[1].to_radians(),
633            pose.rotation_deg[2].to_radians(),
634        ];
635        let axes = [
636            normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
637            normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
638            normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
639        ];
640        // Just past the cone tip, screen-constant.
641        let gap_px = 12.0_f64;
642        let dist =
643            (brep_gizmos::transform::PX_AXIS_LEN as f64 + gap_px) * self.camera.world_per_pixel();
644        let labels: [(&str, [f32; 3]); 3] = [
645            ("XC", [0.92, 0.26, 0.28]), // red
646            ("YC", [0.30, 0.78, 0.36]), // green
647            ("ZC", [0.30, 0.52, 0.98]), // blue
648        ];
649        let out: Vec<serde_json::Value> = (0..3)
650            .map(|i| {
651                let o = pose.position;
652                let a = axes[i];
653                serde_json::json!({
654                    "text": labels[i].0,
655                    "rgb": labels[i].1,
656                    "world": [o[0] + a[0] * dist, o[1] + a[1] * dist, o[2] + a[2] * dist],
657                })
658            })
659            .collect();
660        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
661    }
662
663    /// A press point for every AXIS ARROW and ROTATION GRAB of the drawn
664    /// transform gizmo, in VIEWPORT-LOCAL px, keyed `axis:x|y|z` and
665    /// `ring:x|y|z` (a ring is named by the frame axis it turns about). Read off
666    /// the SAME regions [`Self::transform_pick`] tests — three quarters of the way
667    /// along an arrow's capsule spine, a grab ball's centre — and kept only where
668    /// a press there picks THAT handle, so a published key can never grab a
669    /// neighbour (an arrow foreshortened onto a ring ball, say). Empty while no
670    /// transform widget is drawn. The centre handle is `gizmo/anchor`'s.
671    pub fn transform_handle_points(&self) -> Vec<(&'static str, (f64, f64))> {
672        use brep_gizmos::hit_region::HitShape;
673        use brep_gizmos::transform as gizmo;
674        let cam = gizmo_camera(&self.camera);
675        self.widgets
676            .transform_hit_regions(&cam)
677            .into_iter()
678            .filter_map(|(handle, shape)| {
679                let key = match handle {
680                    gizmo::HANDLE_AXIS_X => "axis:x",
681                    gizmo::HANDLE_AXIS_Y => "axis:y",
682                    gizmo::HANDLE_AXIS_Z => "axis:z",
683                    gizmo::HANDLE_RING_X => "ring:x",
684                    gizmo::HANDLE_RING_Y => "ring:y",
685                    gizmo::HANDLE_RING_Z => "ring:z",
686                    _ => return None,
687                };
688                let [x, y] = match shape {
689                    HitShape::Capsule { a, b, .. } => {
690                        [a[0] + 0.75 * (b[0] - a[0]), a[1] + 0.75 * (b[1] - a[1])]
691                    }
692                    HitShape::Circle { c, .. } => c,
693                };
694                let point = (f64::from(x), f64::from(y));
695                (self.transform_pick(point.0, point.1) == handle).then_some((key, point))
696            })
697            .collect()
698    }
699
700    /// Viewport-local pick regions for the debug overlay, shared by feature and
701    /// component controllers. The widget feed determines visibility; hidden
702    /// widgets return `[]`. The app offsets these circles/capsules by `rect.min`.
703    pub fn transform_hit_areas_json(&self) -> String {
704        let cam = gizmo_camera(&self.camera);
705        let regions = self.widgets.transform_hit_regions(&cam);
706        super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
707    }
708
709    /// Begin a gizmo drag at viewport px `(x, y)` when the gizmo is armed AND a
710    /// handle is under the pointer. Returns whether a handle was grabbed — the
711    /// viewport routes the drag to the gizmo (not the camera) when `true`; a press
712    /// on empty space returns `false` and still orbits.
713    pub fn transform_press(&mut self, x: f64, y: f64) -> bool {
714        // Only grabbable in TRANSFORM mode — in dimension mode the dimension
715        // handles own the pointer (routed by the app), and disarmed grabs nothing.
716        if !matches!(self.transform_gizmo.mode, GizmoMode::Transform) {
717            return false;
718        }
719        let handle = self.transform_pick(x, y);
720        if handle == 0 {
721            return false;
722        }
723        let Some(pose) = self.armed_pose() else {
724            return false;
725        };
726        self.widgets.set_transform_active(handle);
727        self.transform_gizmo.drag = Some(TransformDrag {
728            handle,
729            sx: x as f32,
730            sy: y as f32,
731            start: pose,
732            last: None,
733        });
734        // A cage drag re-derives every selected point from the grab.
735        self.cage_grab_begin();
736        self.dirty = true;
737        true
738    }
739
740    /// Whether a gizmo handle drag is in flight.
741    pub fn transform_dragging(&self) -> bool {
742        self.transform_gizmo.drag.is_some()
743    }
744
745    /// Continue the in-flight gizmo drag to viewport px `(cx, cy)`: resolve the
746    /// world delta from the grab (against the frozen grab-time frame), apply it to
747    /// the grab pose, write it back into the feature's `transform`, and re-run so
748    /// the model follows live. Then re-sync the VISIBLE gizmo to the moved pose so
749    /// the widget tracks the pointer in real time (Fix 3) — the delta stays
750    /// anchored to `drag.start`, so this visual sync never feeds back on itself.
751    pub fn transform_drag_to(&mut self, cx: f64, cy: f64) {
752        let Some(drag) = self.transform_gizmo.drag else {
753            return;
754        };
755        let Some(delta) = self.resolve_transform_delta(&drag, cx, cy) else {
756            return;
757        };
758        let pose = apply_transform_delta(&drag.start, &delta);
759        // Remember what the handle resolved, so the readout can say it. The
760        // drag is re-resolved from `drag.start` every frame, so this is the
761        // WHOLE motion since the grab, not an increment to accumulate.
762        if let Some(live) = self.transform_gizmo.drag.as_mut() {
763            live.last = Some(delta);
764        }
765        self.write_armed_pose(&pose);
766        // Live-follow: re-feed the widget frame to the just-written pose. NB
767        // `finish_apply` skips this while a drag is in flight (drag.is_some()), so
768        // the sync happens here. The active-handle gold highlight survives (the
769        // widget only clears it on a `null` feed).
770        self.sync_transform_gizmo();
771    }
772
773    /// End the drag: clear the active-handle highlight + re-sync the gizmo to the
774    /// feature's final (moved) pose (unpin the frame).
775    pub fn transform_release(&mut self) {
776        if self.transform_gizmo.drag.take().is_some() {
777            self.widgets.set_transform_active(0);
778            // The re-sync below marks the frame only in Transform mode.
779            self.dirty = true;
780            // ONE UNDO STEP PER DRAG. `set_feature_params` coalesces on
781            // `param:{index}` so that the hundred writes a single drag makes do
782            // not become a hundred undo entries — but nothing ended the run, so
783            // the coalescing went on across drags too: two drags of the same
784            // feature were ONE entry, and a single undo after a refused drag
785            // threw away the good drag before it as well (measured
786            // 2026-09-21 — 0.9254 then 1.8508, one undo, straight to zero).
787            // Ending the run here is the same thing the sheet label drag does
788            // on release, and it is what makes "undo the drag that refused,
789            // keep the one before it" work.
790            self.history.break_coalescing();
791            self.cage_grab_end();
792            self.sync_transform_gizmo();
793        }
794    }
795
796    /// Resolve against the grab-time frame so live widget updates cannot
797    /// feed back into the drag calculation.
798    fn resolve_transform_delta(
799        &self,
800        drag: &TransformDrag,
801        cx: f64,
802        cy: f64,
803    ) -> Option<TransformDelta> {
804        let cam = gizmo_camera(&self.camera);
805        let frame_json = transform_frame_json(&drag.start);
806        let json = self.widgets.transform_drag_json_with_frame(
807            &cam,
808            &frame_json,
809            drag.handle,
810            drag.sx,
811            drag.sy,
812            cx as f32,
813            cy as f32,
814        );
815        parse_drag_delta(&json)
816    }
817
818    /// Write `pose` into the armed feature's `inputParams.transform.{position,
819    /// rotationEuler}` (preserving every other field, incl. `scale`) and re-run.
820    fn write_armed_pose(&mut self, pose: &TransformPose) {
821        // Each number is snapped as it is STORED, after any pivot or centre is
822        // taken off: a world pose on the grid minus an off-grid pivot is not.
823        let start = self.transform_gizmo.drag.map(|drag| drag.start);
824        if let Some(address) = self.transform_gizmo.port.clone() {
825            self.write_port_point_pose(&address, pose);
826            return;
827        }
828        let Some(id) = self.transform_gizmo.feature_id.clone() else {
829            return;
830        };
831        let pmi = self.pmi_state();
832        if let Some((_, annotation)) = pmi.find_annotation(&id) {
833            if annotation.kind == "explode" {
834                let laid_out = self
835                    .pmi_report
836                    .as_ref()
837                    .and_then(|r| r.annotation(&id))
838                    .and_then(|r| match &r.geometry {
839                        brep_kernel::PmiGeometry::Explode { center, .. } => Some(*center),
840                        _ => None,
841                    });
842                // Before the kernel lays the step out (a pick still running)
843                // the gizmo stands on the live centre, and a drag there
844                // already writes the step's position.
845                let center = laid_out.or_else(|| self.pmi_explode_live_center(&id));
846                if let Some(center) = center {
847                    // The drag started from where playback showed the step and
848                    // is stored as its full delta: show the step at full (the
849                    // steps before it are already), so its bodies stay under
850                    // the gizmo.
851                    if let (true, Some(progress), Some(step)) = (laid_out.is_some(), self.explode_playback.progress, self.explode_step_of(&id)) {
852                        self.explode_playback.progress = Some(progress.max(step as f64 + 1.0));
853                    }
854                    let mut params = annotation.params.clone();
855                    let position: [f64; 3] = std::array::from_fn(|i| {
856                        drag_stored(start.map(|s| s.position[i] - center[i]), pose.position[i] - center[i])
857                    });
858                    let rotation = drag_stored_rotation(start.as_ref(), pose);
859                    params["transform"] = serde_json::json!({ "position": position, "rotationEuler": rotation, "scale": pose.scale });
860                    let _ = self.pmi_update_annotation(&id, &params.to_string());
861                }
862                return;
863            }
864        }
865        let Some(index) = self.history.index_of(&id) else {
866            return;
867        };
868        if self.transform_gizmo.cage.is_some() {
869            self.write_cage_pose(pose);
870            return;
871        }
872        if let Some(anchor) = self.transform_gizmo.anchor {
873            let position = std::array::from_fn(|i| drag_stored(start.map(|s| s.position[i]), pose.position[i]));
874            let rotation = drag_stored_rotation(start.as_ref(), pose);
875            self.write_spline_anchor_pose(&id, anchor, position, rotation);
876            return;
877        }
878        let mut params = self
879            .history
880            .feature_params(index)
881            .unwrap_or_else(|| serde_json::json!({}));
882        let pivot = if self.is_xform_feature(index) {
883            self.xform_pivot(index, &params)
884        } else if self.is_face_transform_feature(index) {
885            let env =
886                brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator())
887                    .ok();
888            let Some(pivot) = self.face_transform_pivot(index, &params, env.as_ref()) else {
889                return;
890            };
891            pivot
892        } else {
893            [0.0; 3]
894        };
895        let position: [f64; 3] = std::array::from_fn(|axis| {
896            drag_stored(start.map(|s| s.position[axis] - pivot[axis]), pose.position[axis] - pivot[axis])
897        });
898        let rotation = drag_stored_rotation(start.as_ref(), pose);
899        // Ensure `transform` is an object, then set the two edited vectors.
900        if !params
901            .get("transform")
902            .map(|t| t.is_object())
903            .unwrap_or(false)
904        {
905            if let Some(object) = params.as_object_mut() {
906                object.insert("transform".into(), serde_json::json!({}));
907            }
908        }
909        if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
910            transform.insert("position".into(), serde_json::json!(position));
911            transform.insert("rotationEuler".into(), serde_json::json!(rotation));
912        }
913        let _ = self.update_feature_params(&id, &params.to_string());
914    }
915}
916
917/// The number to store for one pose value: on the drag grid during a drag
918/// (`before` is its value at the grab), untouched for any other write.
919fn drag_stored(before: Option<f64>, after: f64) -> f64 {
920    match before {
921        Some(before) => crate::formatting::snap_drag_moved(before, after),
922        None => after,
923    }
924}
925
926/// [`drag_stored`] for a pose's Euler triple.
927fn drag_stored_rotation(start: Option<&TransformPose>, pose: &TransformPose) -> [f64; 3] {
928    std::array::from_fn(|i| drag_stored(start.map(|s| s.rotation_deg[i]), pose.rotation_deg[i]))
929}
930
931/// Name the axis a world vector lies along — `X`, `Y` or `Z` when it lies along
932/// one to within a thousandth of its length, and the neutral `\u{0394}` when it
933/// does not.
934///
935/// The neutral case is not a fallback, it is the honest answer: the X ARROW OF
936/// A TURNED FRAME points along the turned X, which has two non-zero world
937/// components, and calling that motion "X" would name a world axis the face is
938/// not moving along. A zero-length vector is `\u{0394}` too — there is no axis
939/// to name yet.
940fn dominant_axis_label(v: &[f64; 3]) -> &'static str {
941    let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
942    if length < 1e-12 {
943        return "\u{0394}";
944    }
945    const NAMES: [&str; 3] = ["X", "Y", "Z"];
946    for axis in 0..3 {
947        if v[axis].abs() >= length * 0.999 {
948            return NAMES[axis];
949        }
950    }
951    "\u{0394}"
952}
953
954/// The type strings a Transform Face answers to — the kernel dispatch's alias
955/// set, case-insensitively.
956pub fn is_face_transform_type(feature_type: &str) -> bool {
957    matches!(
958        feature_type.trim().to_ascii_uppercase().as_str(),
959        "TF" | "TRANSFORM FACE" | "TRANSFORMFACE"
960    )
961}
962
963/// Apply translation or world-axis rotation to the grab pose, retaining scale.
964fn apply_transform_delta(start: &TransformPose, delta: &TransformDelta) -> TransformPose {
965    match delta {
966        TransformDelta::Translate(world) => TransformPose {
967            position: [
968                start.position[0] + world[0],
969                start.position[1] + world[1],
970                start.position[2] + world[2],
971            ],
972            ..*start
973        },
974        TransformDelta::Rotate { axis, radians } => {
975            let q0 = quat_from_euler_xyz_deg(start.rotation_deg);
976            let dq = quat_from_axis_angle(*axis, *radians);
977            let nq = quat_mul(dq, q0);
978            TransformPose {
979                rotation_deg: euler_xyz_deg_from_quat(nq),
980                ..*start
981            }
982        }
983    }
984}
985
986/// Read a `[x, y, z]` from a transform sub-field (numbers only; missing / short
987/// arrays keep the per-index default).
988fn read_pose_vec3(
989    env: Option<&brep_kernel::Env>,
990    transform: Option<&serde_json::Value>,
991    key: &str,
992    default: [f64; 3],
993) -> [f64; 3] {
994    let array = transform
995        .and_then(|t| t.get(key))
996        .and_then(|v| v.as_array());
997    let mut out = default;
998    if let Some(array) = array {
999        for (index, slot) in out.iter_mut().enumerate() {
1000            match array.get(index) {
1001                Some(serde_json::Value::Number(number)) => {
1002                    if let Some(number) = number.as_f64() {
1003                        *slot = number;
1004                    }
1005                }
1006                // A component may be an EXPRESSION (`"boxW/2"`) — the kernel
1007                // evaluates it when it builds, so the gizmo must arm at the same
1008                // place. Reading it as 0 parked the handles at the world origin
1009                // while the solid sat elsewhere, and the first drag wrote that
1010                // wrong origin back as a literal, teleporting the solid.
1011                Some(serde_json::Value::String(source)) => {
1012                    if let Some(number) = env
1013                        .and_then(|env| env.eval(source).ok())
1014                        .filter(|number| number.is_finite())
1015                    {
1016                        *slot = number;
1017                    }
1018                }
1019                _ => {}
1020            }
1021        }
1022    }
1023    out
1024}
1025
1026/// The gizmo frame feed (`set_transform_json` shape) for a pose: origin =
1027/// `position`, axes = the feature's rotated basis (intrinsic XYZ Euler order, matching
1028/// the kernel bake), with the center free-move handle shown.
1029fn transform_frame_json(pose: &TransformPose) -> String {
1030    transform_frame_json_with(pose, true)
1031}
1032
1033/// [`transform_frame_json`] with the rotation rings shown or hidden.
1034fn transform_frame_json_with(pose: &TransformPose, show_rings: bool) -> String {
1035    let euler = [
1036        pose.rotation_deg[0].to_radians(),
1037        pose.rotation_deg[1].to_radians(),
1038        pose.rotation_deg[2].to_radians(),
1039    ];
1040    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
1041    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
1042    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
1043    serde_json::json!({
1044        "origin": pose.position,
1045        "x": x,
1046        "y": y,
1047        "z": z,
1048        "showCenter": true,
1049        "showAxes": true,
1050        "showRings": show_rings,
1051    })
1052    .to_string()
1053}
1054
1055pub(super) fn normalize3(v: [f64; 3]) -> [f64; 3] {
1056    let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1057    if length < 1e-12 {
1058        [0.0, 0.0, 1.0]
1059    } else {
1060        [v[0] / length, v[1] / length, v[2] / length]
1061    }
1062}
1063
1064/// Apply an intrinsic XYZ Euler (radians) to a vector — the EXACT matrix the kernel
1065/// bake (`transform_bake` / `datum::rotate_euler_xyz`) uses, so the fed gizmo
1066/// frame aligns with the baked solid.
1067pub(crate) fn rotate_euler_xyz_f64(v: [f64; 3], euler: [f64; 3]) -> [f64; 3] {
1068    let (c1, s1) = (euler[0].cos(), euler[0].sin());
1069    let (c2, s2) = (euler[1].cos(), euler[1].sin());
1070    let (c3, s3) = (euler[2].cos(), euler[2].sin());
1071    let m00 = c2 * c3;
1072    let m01 = -c2 * s3;
1073    let m02 = s2;
1074    let m10 = c1 * s3 + c3 * s1 * s2;
1075    let m11 = c1 * c3 - s1 * s2 * s3;
1076    let m12 = -c2 * s1;
1077    let m20 = s1 * s3 - c1 * c3 * s2;
1078    let m21 = c3 * s1 + c1 * s2 * s3;
1079    let m22 = c1 * c2;
1080    [
1081        m00 * v[0] + m01 * v[1] + m02 * v[2],
1082        m10 * v[0] + m11 * v[1] + m12 * v[2],
1083        m20 * v[0] + m21 * v[1] + m22 * v[2],
1084    ]
1085}
1086
1087// --- quaternion helpers (ported from CombinedTransformControls) -----
1088
1089pub(super) type Quat = [f64; 4]; // [x, y, z, w]
1090
1091pub(super) fn quat_from_axis_angle(axis: [f64; 3], angle: f64) -> Quat {
1092    let n = normalize3(axis);
1093    let half = angle * 0.5;
1094    let s = half.sin();
1095    [n[0] * s, n[1] * s, n[2] * s, half.cos()]
1096}
1097
1098/// Quaternion from an intrinsic XYZ Euler (degrees in).
1099pub(super) fn quat_from_euler_xyz_deg(deg: [f64; 3]) -> Quat {
1100    let (c1, s1) = (
1101        (deg[0].to_radians() * 0.5).cos(),
1102        (deg[0].to_radians() * 0.5).sin(),
1103    );
1104    let (c2, s2) = (
1105        (deg[1].to_radians() * 0.5).cos(),
1106        (deg[1].to_radians() * 0.5).sin(),
1107    );
1108    let (c3, s3) = (
1109        (deg[2].to_radians() * 0.5).cos(),
1110        (deg[2].to_radians() * 0.5).sin(),
1111    );
1112    [
1113        s1 * c2 * c3 + c1 * s2 * s3,
1114        c1 * s2 * c3 - s1 * c2 * s3,
1115        c1 * c2 * s3 + s1 * s2 * c3,
1116        c1 * c2 * c3 - s1 * s2 * s3,
1117    ]
1118}
1119
1120/// Quaternion product `a * b`.
1121pub(super) fn quat_mul(a: Quat, b: Quat) -> Quat {
1122    [
1123        a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
1124        a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
1125        a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
1126        a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
1127    ]
1128}
1129
1130/// Intrinsic XYZ Euler from a quaternion (via the rotation matrix) → the
1131/// 'XYZ' Euler in DEGREES. Uses the SAME matrix element naming as
1132/// `rotate_euler_xyz_f64`, so the round-trip is consistent with the kernel bake.
1133pub(super) fn euler_xyz_deg_from_quat(q: Quat) -> [f64; 3] {
1134    let [x, y, z, w] = q;
1135    let (x2, y2, z2) = (x + x, y + y, z + z);
1136    let (xx, xy, xz) = (x * x2, x * y2, x * z2);
1137    let (yy, yz, zz) = (y * y2, y * z2, z * z2);
1138    let (wx, wy, wz) = (w * x2, w * y2, w * z2);
1139    // Rotation matrix elements (m<row><col> naming).
1140    let m11 = 1.0 - (yy + zz);
1141    let m12 = xy - wz;
1142    let m13 = xz + wy;
1143    let m22 = 1.0 - (xx + zz);
1144    let m23 = yz - wx;
1145    let m32 = yz + wx;
1146    let m33 = 1.0 - (xx + yy);
1147    let ey = m13.clamp(-1.0, 1.0).asin();
1148    let (ex, ez) = if m13.abs() < 0.9999999 {
1149        ((-m23).atan2(m33), (-m12).atan2(m11))
1150    } else {
1151        (m32.atan2(m22), 0.0)
1152    };
1153    [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
1154}
1155
1156// ===========================================================================
1157// The gizmo on a CONNECTION POINT
1158// ===========================================================================
1159//
1160// A connection point is not a feature: it has no history row and no id, so
1161// every read and write above that goes through `history.index_of` misses it.
1162// What it has instead is a SEAT — the frame its declared `transform` is an
1163// offset in (`brep_kernel::ports`) — and the gizmo lane is exactly that seat
1164// applied and un-applied:
1165//
1166//   pose  = seat  o  declaration      (what the handles are drawn at)
1167//   write = seat⁻¹ o  dragged pose    (what the block is given back)
1168//
1169// The seat comes from the LAST APPLIED RUN's report and the declaration from
1170// the block as it stands NOW. That split is deliberate and it is what keeps a
1171// drag smooth: `set_ports_block` re-runs on the NEXT frame, so a pose read
1172// from the run's resolved position would lag the pointer by a frame and
1173// oscillate. The seat does not move while the offset is dragged — only the
1174// reference geometry moves it — so reading it from the last run is exact.
1175
1176impl EngineState {
1177    /// Whether the transform gizmo is armed on a connection point, and which.
1178    pub fn transform_armed_port_point(&self) -> Option<&str> {
1179        self.transform_armed()
1180            .then(|| self.transform_gizmo.port.as_deref())
1181            .flatten()
1182    }
1183
1184    /// Arm the TRANSFORM gizmo on the connection point at `address`, or refuse
1185    /// and say why ([`Self::port_point_gizmo_refusal`]).
1186    pub fn arm_transform_for_port_point(&mut self, address: &str) -> bool {
1187        if let Some(reason) = self.port_point_gizmo_refusal(address) {
1188            self.push_notice(format!("no handles on {address}: {reason}"));
1189            return false;
1190        }
1191        self.component_move_reset();
1192        self.transform_gizmo.feature_id = None;
1193        self.transform_gizmo.anchor = None;
1194        self.transform_gizmo.cage = None;
1195        self.transform_gizmo.port = Some(address.to_string());
1196        self.transform_gizmo.mode = GizmoMode::Transform;
1197        self.transform_gizmo.drag = None;
1198        self.clear_feature_dimension_overlay();
1199        self.sync_transform_gizmo();
1200        true
1201    }
1202
1203    /// Why the gizmo will not arm on this point, in words, or `None` when it
1204    /// will. There are two reasons, and both are cases where the handles would
1205    /// LIE — draw somewhere a drag cannot write to:
1206    ///
1207    /// * the point **maps down**. Its placement is its target's: the kernel's
1208    ///   `resolve_point` returns on `mapsTo` before it ever reads the
1209    ///   transform, so handles would sit on the descendant's geometry and a
1210    ///   drag would write numbers nothing reads, leaving the point where it
1211    ///   was while the handles walked away from it;
1212    /// * an **expression** places it. The gizmo writes numbers, and replacing
1213    ///   `width/2` with `12.5` because a user dragged something is a loss the
1214    ///   document cannot show them. Typing in the panel still works, which is
1215    ///   where an expression belongs.
1216    ///
1217    /// Public because the panel must say the same thing the arm decides rather
1218    /// than inferring a reason from the gizmo not being armed: it is drawn a
1219    /// moment BEFORE the arm runs, so an inference would flash a refusal on
1220    /// the frame a perfectly armable point is selected.
1221    pub fn port_point_gizmo_refusal(&self, address: &str) -> Option<String> {
1222        let point = self.declared_point_value(address)?;
1223        if let Some(target) = point
1224            .get("mapsTo")
1225            .and_then(serde_json::Value::as_str)
1226            .map(str::trim)
1227            .filter(|target| !target.is_empty())
1228        {
1229            return Some(format!(
1230                "it maps to '{target}', and that point's placement is this one's"
1231            ));
1232        }
1233        let transform = point.get("transform")?;
1234        for key in ["position", "rotationEuler"] {
1235            let Some(components) = transform.get(key).and_then(serde_json::Value::as_array) else {
1236                continue;
1237            };
1238            for component in components {
1239                if let Some(expression) = component.as_str() {
1240                    return Some(format!(
1241                        "'{expression}' places it, and a drag writes numbers"
1242                    ));
1243                }
1244            }
1245        }
1246        None
1247    }
1248
1249    /// The seat a connection point's placement is an offset in: the last
1250    /// applied run's, or the WORLD seat for a point no run has resolved yet
1251    /// (one just added, which must still be draggable).
1252    fn port_point_seat(&self, address: &str) -> brep_kernel::Frame {
1253        self.port_point_row(address)
1254            .map(|row| row.seat.to_frame())
1255            .unwrap_or_else(brep_kernel::world_seat)
1256    }
1257
1258    /// The armed connection point's pose in WORLD space — the seat applied to
1259    /// the declaration, per this section's opening note.
1260    pub(super) fn port_point_pose(&self, address: &str) -> Option<TransformPose> {
1261        let point = self.declared_point_value(address)?;
1262        let transform = point.get("transform");
1263        let local_position = read_pose_vec3(None, transform, "position", [0.0; 3]);
1264        let local_rotation = read_pose_vec3(None, transform, "rotationEuler", [0.0; 3]);
1265        let seat = self.port_point_seat(address);
1266        let world_axes = super::spline_edit::axes_from_euler_deg(local_rotation)
1267            .map(|axis| seat_axis(&seat, axis));
1268        Some(TransformPose {
1269            position: array3(brep_kernel::seat_point(&seat, vec3(local_position))),
1270            rotation_deg: super::spline_edit::euler_xyz_deg_from_axes(
1271                world_axes[0],
1272                world_axes[1],
1273                world_axes[2],
1274            ),
1275            scale: [1.0, 1.0, 1.0],
1276        })
1277    }
1278
1279    /// Write a dragged WORLD pose back as the point's offset in its seat.
1280    /// Coalesced per address, so a whole drag is one undo step.
1281    pub(super) fn write_port_point_pose(&mut self, address: &str, pose: &TransformPose) {
1282        let seat = self.port_point_seat(address);
1283        let local = |pose: &TransformPose| {
1284            let local_axes = super::spline_edit::axes_from_euler_deg(pose.rotation_deg)
1285                .map(|axis| unseat_axis(&seat, axis));
1286            (
1287                array3(brep_kernel::unseat_point(&seat, vec3(pose.position))),
1288                super::spline_edit::euler_xyz_deg_from_axes(local_axes[0], local_axes[1], local_axes[2]),
1289            )
1290        };
1291        let (position, rotation) = local(pose);
1292        let before = self.transform_gizmo.drag.map(|drag| local(&drag.start));
1293        let position: [f64; 3] = std::array::from_fn(|i| drag_stored(before.map(|b| b.0[i]), position[i]));
1294        let rotation: [f64; 3] = std::array::from_fn(|i| drag_stored(before.map(|b| b.1[i]), rotation[i]));
1295        let key = format!("port-point-pose:{address}");
1296        self.edit_port_point(address, Some(&key), |point| {
1297            point.insert(
1298                "transform".into(),
1299                serde_json::json!({ "position": position, "rotationEuler": rotation }),
1300            );
1301        });
1302    }
1303}
1304
1305fn vec3(a: [f64; 3]) -> brep_kernel::Vec3 {
1306    brep_kernel::Vec3::new(a[0], a[1], a[2])
1307}
1308
1309fn array3(v: brep_kernel::Vec3) -> [f64; 3] {
1310    [v.x, v.y, v.z]
1311}
1312
1313/// A local axis carried out through the seat's basis.
1314fn seat_axis(seat: &brep_kernel::Frame, axis: [f64; 3]) -> [f64; 3] {
1315    array3(brep_kernel::seat_vector(seat, vec3(axis)))
1316}
1317
1318/// The same axis read back in the seat's basis.
1319fn unseat_axis(seat: &brep_kernel::Frame, axis: [f64; 3]) -> [f64; 3] {
1320    array3(brep_kernel::unseat_vector(seat, vec3(axis)))
1321}
1322