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