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