Skip to main content

brep_render/engine_state/
component_move.rs

1use crate::geometry3d::{add3, cross3 as cross, sub3};
2use super::transform_gizmo::{
3    euler_xyz_deg_from_quat, normalize3, quat_from_axis_angle, quat_from_euler_xyz_deg, quat_mul,
4    parse_drag_delta, Quat, TransformDelta,
5};
6use super::*;
7
8// Component movement uses the shared gizmo at the member-bbox center. Fixed
9// components cannot arm it. A drag is solved LIVE: every drag frame asks the
10// kernel's dry-run drag solve (`assembly_drag_solve_impl`) where the mates put
11// the dragged members, and poses the member displays there display-only (the
12// document is untouched until release). A frame whose solve fails holds the
13// last good pose; a solve slower than the frame budget skips the next frames
14// in proportion, so a large assembly still follows the pointer. Release runs
15// one full solve at the final pointer pose and writes the solved poses of
16// every moved component in one batch (one undo step); the rerun then finds
17// them already solved.
18// Arming a component resets feature-transform and dimension gizmos because they
19// share one widget slot. All translation and rotation handles are shown together.
20// A GROUP (several components moved together) is one rigid body: one gizmo at
21// the union member-bbox center on world axes, the same delta composed onto
22// every member about that shared pivot, and one batched commit (one undo step).
23
24/// The component-Move controller state (one per engine, like [`TransformArm`]).
25pub struct ComponentMoveArm {
26    /// The ACOMP feature id the gizmo is armed for (`None` = disarmed) — the
27    /// FIRST member of a group.
28    pub(super) feature_id: Option<String>,
29    /// Every component the gizmo moves, `feature_id` first. One entry for the
30    /// single-component Move; two or more for a group move.
31    pub(super) group: Vec<String>,
32    /// Members that are FIXED and armed by "Unfix and Move": they stay fixed
33    /// until a drag commits, and the release writes `isFixed: false` in the
34    /// same batch as the new pose (one undo step). Disarming without a drag
35    /// changes nothing.
36    pub(super) unfix: Vec<String>,
37    /// Move Copy selects its members once the background scene rebuild lands.
38    select_after_run: bool,
39    /// The gizmo pivot — the component's (group's union) member-bbox center, re-synced after
40    /// every applied run so the gizmo follows a re-solved (snapped) component.
41    pub(super) anchor: [f64; 3],
42    /// The in-flight handle drag (grab snapshot + the pending composed pose).
43    pub(super) drag: Option<ComponentMoveDrag>,
44    /// Pre-drag copies of every display the live solve re-posed, keyed by
45    /// solid name — put back before a run's reconcile, on disarm, and when a
46    /// release writes nothing.
47    pub(super) live_originals: std::collections::HashMap<String, crate::scene::SolidDisplay>,
48    /// The pose each live-posed component's displays show now, by component:
49    /// a frame whose solve leaves it there re-poses nothing. (The drag solve
50    /// answers for every component it solved; on l500 three unmoved 100-part
51    /// modules were re-posed and re-uploaded every frame beside the dragged
52    /// one.)
53    pub(super) live_applied: std::collections::HashMap<String, ([f64; 3], [f64; 3])>,
54    /// The per-frame drag solve budget in milliseconds.
55    pub(super) budget_ms: f64,
56    /// Test hook: the cost every drag solve is charged instead of its
57    /// measured wall time (deterministic over-budget behaviour).
58    pub(super) solve_cost_override_ms: Option<f64>,
59    /// What the last typed move did (the panel's status line).
60    pub(super) typed: Option<TypedMove>,
61}
62
63/// The outcome of one typed move ([`EngineState::component_move_typed`]):
64/// the field, the value typed, how much of it the mates let through along
65/// that axis, and the constraints that stopped the rest.
66#[derive(Clone, Debug, PartialEq)]
67pub struct TypedMove {
68    /// `x`, `y`, `z` (mm along the gizmo's axes) or `rx`, `ry`, `rz` (degrees
69    /// about them).
70    pub field: String,
71    pub asked: f64,
72    pub applied: f64,
73    /// Constraint ids that refused the rest of the move (empty when it all
74    /// went through, or when none could be named).
75    pub blocking: Vec<String>,
76    /// Whether anything was written (one undo step).
77    pub written: bool,
78}
79
80impl TypedMove {
81    /// Whether the mates let the whole typed value through.
82    pub fn complete(&self) -> bool {
83        (self.applied - self.asked).abs() <= TYPED_TOLERANCE * self.asked.abs().max(1.0)
84    }
85
86    /// The panel's status line: what moved, of what was asked, and why not all.
87    pub fn summary(&self) -> String {
88        let (axis, unit, space) = typed_axis(&self.field).map(|(axis, rotate)| {
89            (["X", "Y", "Z"][axis], if rotate { "\u{00b0}" } else { " mm" }, if rotate { "about" } else { "along" })
90        }).unwrap_or(("?", "", "along"));
91        let shown = |value: f64| crate::formatting::compact_decimal(value, 4);
92        let limited = if self.blocking.is_empty() {
93            String::new()
94        } else {
95            format!(", limited by {}", self.blocking.join(", "))
96        };
97        if !self.written {
98            return match self.blocking.as_slice() {
99                [] => format!("Not moved {space} {axis}: the mates hold it here."),
100                held => format!("Not moved {space} {axis}: held by {}.", held.join(", ")),
101            };
102        }
103        if self.complete() {
104            return format!("Moved {}{unit} {space} {axis}.", shown(self.applied));
105        }
106        format!("Moved {} of {}{unit} {space} {axis}{limited}.", shown(self.applied), shown(self.asked))
107    }
108}
109
110/// A typed move counts as complete within this fraction of what was typed.
111const TYPED_TOLERANCE: f64 = 1e-6;
112
113/// `x|y|z|rx|ry|rz` → (axis index, is a rotation).
114fn typed_axis(field: &str) -> Option<(usize, bool)> {
115    match field {
116        "x" => Some((0, false)),
117        "y" => Some((1, false)),
118        "z" => Some((2, false)),
119        "rx" => Some((0, true)),
120        "ry" => Some((1, true)),
121        "rz" => Some((2, true)),
122        _ => None,
123    }
124}
125
126/// The per-frame drag solve budget: a solve slower than this skips the next
127/// frames in proportion (the display holds the last good pose meanwhile).
128pub(super) const DRAG_SOLVE_BUDGET_MS: f64 = 30.0;
129/// Never skip more than this many frames after one slow solve.
130const DRAG_SOLVE_MAX_SKIP: u32 = 10;
131
132impl Default for ComponentMoveArm {
133    fn default() -> Self {
134        Self {
135            feature_id: None,
136            group: Vec::new(),
137            unfix: Vec::new(),
138            select_after_run: false,
139            anchor: [0.0; 3],
140            drag: None,
141            live_originals: std::collections::HashMap::new(),
142            live_applied: std::collections::HashMap::new(),
143            budget_ms: DRAG_SOLVE_BUDGET_MS,
144            solve_cost_override_ms: None,
145            typed: None,
146        }
147    }
148}
149
150/// The live solve's view of one drag: the last good solved poses, whether
151/// the latest frame's solve failed, and the frames still to skip.
152#[derive(Clone, Default, Debug)]
153pub(super) struct LiveSolve {
154    /// Component id → solved `(translate, rotate_deg)` from the last solve
155    /// that held (every component the solve moved, not only the dragged).
156    pub good: Option<std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>>,
157    /// The latest attempted solve failed (the display holds `good`).
158    pub held: bool,
159    /// Frames to skip before the next solve (over-budget back-off).
160    pub skip: u32,
161    /// Solves run and frames skipped this drag (for the verifier).
162    pub solves: u32,
163    pub skipped: u32,
164}
165
166/// A grab snapshot: the handle, the grab screen point, the gizmo frame AT GRAB
167/// (the frozen frame every drag move resolves against — no error
168/// accumulation), every member's pose at grab (pivoting about the shared
169/// anchor), and the PENDING delta + frame the release commits.
170#[derive(Clone)]
171pub(super) struct ComponentMoveDrag {
172    handle: u32,
173    sx: f32,
174    sy: f32,
175    start: ComponentPose,
176    members: Vec<(String, ComponentPose)>,
177    pending: Option<(TransformDelta, ComponentPose)>,
178    live: LiveSolve,
179}
180
181/// A component pose as the gizmo tracks it: the ACOMP `transform` pair plus the
182/// gizmo anchor (bbox center) it pivots about.
183#[derive(Clone, Copy, PartialEq, Debug)]
184pub(super) struct ComponentPose {
185    pub translate: [f64; 3],
186    pub rotate_deg: [f64; 3],
187    pub anchor: [f64; 3],
188}
189
190impl EngineState {
191    /// Whether the component Move gizmo is armed (for any component).
192    pub fn component_move_armed(&self) -> bool {
193        self.component_move.feature_id.is_some()
194    }
195
196    /// The armed component feature id (empty when disarmed).
197    pub fn component_move_armed_feature(&self) -> String {
198        self.component_move.feature_id.clone().unwrap_or_default()
199    }
200
201    /// Every component the armed gizmo moves (empty when disarmed).
202    pub fn component_move_group(&self) -> Vec<String> {
203        self.component_move.group.clone()
204    }
205
206    /// The Move action for SEVERAL components (a multi-selection, or faces of
207    /// several occurrences): one gizmo that moves them together as a rigid
208    /// group. Duplicates count once. FIXED members are left out with one toast
209    /// naming them; the rest still arm. One movable member falls back to the
210    /// single-component [`Self::component_move_toggle`]; pressing Move again on
211    /// the armed group disarms it.
212    pub fn component_move_group_toggle(&mut self, ids: &[String]) {
213        let mut unique: Vec<String> = Vec::new();
214        for id in ids {
215            if !unique.contains(id) {
216                unique.push(id.clone());
217            }
218        }
219        if unique.len() <= 1 {
220            if let Some(id) = unique.first() {
221                self.component_move_toggle(id);
222            }
223            return;
224        }
225        let mut movable = Vec::new();
226        let mut fixed = Vec::new();
227        for id in unique {
228            match self.component_info(&id) {
229                None => self.push_notice(format!("'{id}' is not an assembly component")),
230                Some(info) if info.fixed => fixed.push(format!("{} ({id})", info.part_name)),
231                Some(_) => movable.push(id),
232            }
233        }
234        if !fixed.is_empty() {
235            let verb = if fixed.len() == 1 { "is" } else { "are" };
236            self.push_notice(format!(
237                "{} {verb} fixed — not moved (unfix to move)",
238                fixed.join(", ")
239            ));
240        }
241        match movable.len() {
242            0 => {}
243            1 => self.component_move_toggle(&movable[0]),
244            // The same set, in any order, toggles off.
245            _ if self.component_move.group.len() == movable.len()
246                && movable.iter().all(|id| self.component_move.group.contains(id)) =>
247            {
248                self.disarm_transform()
249            }
250            _ => self.component_move_arm_group(movable),
251        }
252    }
253
254    /// "Unfix and Move": like [`Self::component_move_group_toggle`], but FIXED
255    /// components join the move instead of being skipped. Nothing is unfixed
256    /// until a drag commits; the release then writes each fixed member's
257    /// `isFixed: false` together with its new pose, as one undo step. Pressing
258    /// it again on the same armed set disarms.
259    pub fn component_move_unfix_toggle(&mut self, ids: &[String]) {
260        let mut members: Vec<String> = Vec::new();
261        let mut unfix = Vec::new();
262        for id in ids {
263            if members.contains(id) {
264                continue;
265            }
266            match self.component_info(id) {
267                None => self.push_notice(format!("'{id}' is not an assembly component")),
268                Some(info) => {
269                    if info.fixed {
270                        unfix.push(id.clone());
271                    }
272                    members.push(id.clone());
273                }
274            }
275        }
276        if members.is_empty() {
277            return;
278        }
279        let same = self.component_move.group.len() == members.len()
280            && members.iter().all(|id| self.component_move.group.contains(id))
281            && self.component_move.unfix.len() == unfix.len();
282        if same {
283            self.disarm_transform();
284            return;
285        }
286        self.component_move_arm_group(members);
287        self.component_move.unfix = unfix;
288    }
289
290    /// The Move toggle (context bar / tree action): ARMS the full gizmo (all
291    /// handle sets) for `feature_id`, or DISARMS when it is already armed
292    /// (arming fresh replaces any other armed component). A FIXED component
293    /// refuses with a toast and never arms.
294    pub fn component_move_toggle(&mut self, feature_id: &str) {
295        let Some(info) = self.component_info(feature_id) else {
296            self.push_notice(format!("'{feature_id}' is not an assembly component"));
297            return;
298        };
299        if info.fixed {
300            self.push_notice(format!(
301                "{} ({feature_id}) is fixed — unfix it to move",
302                info.part_name
303            ));
304            return;
305        }
306        // Armed for exactly this component (not as one member of a group).
307        if self.component_move.group == [feature_id] {
308            self.disarm_transform();
309        } else {
310            self.component_move_arm_widget(feature_id);
311        }
312    }
313
314    /// The Move Copy action (context bar / tree action): append a SECOND
315    /// instance of `source_id`'s part — its `inputParams` cloned (same
316    /// `partName`, same pose, any other placement keys riding along) under a
317    /// fresh `ACOMP<n>` id and an explicit `isFixed: false` — then select it
318    /// and arm the full Move gizmo on it, so the user drags the copy off the
319    /// original. No new part is made: both instances reference one
320    /// parts-library entry. A FIXED source copies fine (the copy is free).
321    /// Returns the new feature id.
322    pub fn component_move_copy(&mut self, source_id: &str) -> Result<String, String> {
323        let Some(index) = self.history.index_of(source_id).filter(|&index| {
324            matches!(
325                self.history.feature_type(index).as_deref(),
326                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
327            )
328        }) else {
329            let error = format!("'{source_id}' is not an assembly component");
330            self.push_notice(error.clone());
331            return Err(error);
332        };
333        let mut params = self
334            .history
335            .feature_params(index)
336            .unwrap_or_else(|| serde_json::json!({}));
337        let id = self.history.next_feature_id("ACOMP");
338        let Some(map) = params.as_object_mut() else {
339            return Err(format!("component '{source_id}': malformed inputParams"));
340        };
341        map.insert("id".into(), serde_json::Value::String(id.clone()));
342        map.insert("isFixed".into(), serde_json::Value::Bool(false));
343        let feature = serde_json::json!({
344            "type": "ACOMP",
345            "inputParams": params,
346            "persistentData": {}
347        });
348        self.add_feature(&feature.to_string())?;
349        // Select the copy so the context bar's actions (Move again, Delete, …)
350        // target the instance the gizmo sits on, not the original.
351        self.component_move_arm_widget(&id);
352        if self.run_pending() {
353            self.component_move.select_after_run = true;
354        } else {
355            self.select_component(&id);
356        }
357        Ok(id)
358    }
359
360    /// The `arm_transform` ROUTE for ACOMP features (history-panel expand):
361    /// a FIXED component silently stays armless (the explicit Move action is
362    /// the one that toasts).
363    pub(super) fn component_move_arm(&mut self, feature_id: &str) {
364        match self.component_info(feature_id) {
365            Some(info) if !info.fixed => self.component_move_arm_widget(feature_id),
366            _ => {}
367        }
368    }
369
370    /// Drop the component arm STATE only (the caller owns the widget slot) —
371    /// the exclusivity hook `arm_transform` / `arm_dimension` / `disarm_transform`
372    /// call before taking the slot for themselves.
373    pub(super) fn component_move_reset(&mut self) {
374        self.component_live_restore();
375        let budget_ms = self.component_move.budget_ms;
376        let solve_cost_override_ms = self.component_move.solve_cost_override_ms;
377        self.component_move =
378            ComponentMoveArm { budget_ms, solve_cost_override_ms, ..ComponentMoveArm::default() };
379    }
380
381    /// Arm for `feature_id`: claim the shared widget slot (clearing the
382    /// feature gizmo + dimension overlay), pin the anchor at the member-bbox
383    /// center, and feed the full handle set.
384    fn component_move_arm_widget(&mut self, feature_id: &str) {
385        self.component_move_arm_group(vec![feature_id.to_string()]);
386    }
387
388    /// Arm for every id of `group` (callers pass at least one, none fixed).
389    fn component_move_arm_group(&mut self, group: Vec<String>) {
390        // Claim the shared slot WITHOUT disarm_transform (which would also reset
391        // the component state we are about to set).
392        self.transform_gizmo.feature_id = None;
393        self.transform_gizmo.mode = GizmoMode::None;
394        self.transform_gizmo.drag = None;
395        self.clear_feature_dimension_overlay();
396
397        self.component_live_restore();
398        let anchor = self.component_group_anchor(&group);
399        self.component_move.feature_id = group.first().cloned();
400        self.component_move.group = group;
401        self.component_move.unfix.clear();
402        self.component_move.select_after_run = false;
403        self.component_move.anchor = anchor;
404        self.component_move.drag = None;
405        self.component_move.typed = None;
406        self.feed_component_widget();
407        self.dirty = true;
408    }
409
410    /// The pivot of `group`: the union member-bbox center (a lone component
411    /// without resident members falls back to its translate, as before).
412    fn component_group_anchor(&self, group: &[String]) -> [f64; 3] {
413        let mut bbox = crate::camera::Aabb::empty();
414        for id in group {
415            for solid in self.scene.solids() {
416                if super::components::owned_by(&solid.name, id) {
417                    bbox.union(&solid.bbox);
418                }
419            }
420        }
421        if !bbox.is_empty() {
422            return bbox.center();
423        }
424        group
425            .first()
426            .and_then(|id| self.component_info(id))
427            .map(|info| info.translate)
428            .unwrap_or([0.0; 3])
429    }
430
431    /// The gizmo frame at the armed pose: a single component's own rotated
432    /// basis; a group's WORLD axes (a group has no single orientation).
433    fn component_move_frame(&self) -> Option<ComponentPose> {
434        let anchor = self.component_move.anchor;
435        if self.component_move.group.len() > 1 {
436            return Some(ComponentPose { translate: anchor, rotate_deg: [0.0; 3], anchor });
437        }
438        let info = self.component_info(self.component_move.feature_id.as_deref()?)?;
439        Some(ComponentPose { translate: info.translate, rotate_deg: info.rotate_deg, anchor })
440    }
441
442    /// (Re)feed the widget gizmo at the armed component's current pose+anchor.
443    fn feed_component_widget(&mut self) {
444        let Some(pose) = self.component_move_frame() else {
445            return;
446        };
447        let json = component_frame_json(&pose);
448        let _ = self.widgets.set_transform_json(&json);
449    }
450
451    /// Post-run re-sync (the [`finish_apply`] hook, mirroring
452    /// `sync_transform_gizmo`): re-anchor at the possibly re-solved member bbox
453    /// and re-feed; auto-disarm when the component vanished or became fixed.
454    pub(super) fn component_move_sync(&mut self) {
455        if self.component_move.feature_id.is_none() {
456            return;
457        }
458        // A member that vanished or became fixed drops out; the gizmo
459        // disarms only when nothing movable is left.
460        let unfix = &self.component_move.unfix;
461        let group: Vec<String> = self
462            .component_move
463            .group
464            .iter()
465            .filter(|id| {
466                self.component_info(id)
467                    .is_some_and(|info| !info.fixed || unfix.contains(id))
468            })
469            .cloned()
470            .collect();
471        let Some(id) = group.first().cloned() else {
472            self.disarm_transform();
473            return;
474        };
475        // Earlier replies may arrive while a newer run is pending.
476        // Consume this once, only after the current scene is available.
477        if self.component_move.select_after_run && !self.run_pending() {
478            self.component_move.select_after_run = false;
479            self.select_component(&id);
480        }
481        self.component_move.anchor = self.component_group_anchor(&group);
482        self.component_move.feature_id = Some(id);
483        self.component_move.unfix.retain(|id| group.contains(id));
484        self.component_move.group = group;
485        self.feed_component_widget();
486        self.dirty = true;
487    }
488
489    /// Begin a component-gizmo drag at viewport px `(x, y)`; `true` when a
490    /// handle was grabbed (the viewport routes the drag here, not the camera).
491    pub fn component_press(&mut self, x: f64, y: f64) -> bool {
492        let Some(start) = self.component_move_frame() else {
493            return false;
494        };
495        let handle = self.transform_pick(x, y);
496        if handle == 0 {
497            return false;
498        }
499        let Some(members) = self.component_move_members() else {
500            return false;
501        };
502        self.widgets.set_transform_active(handle);
503        self.component_move.live_applied.clear();
504        self.component_move.drag = Some(ComponentMoveDrag {
505            handle,
506            sx: x as f32,
507            sy: y as f32,
508            start,
509            members,
510            pending: None,
511            live: LiveSolve::default(),
512        });
513        // A drag supersedes the last typed move's status line.
514        self.component_move.typed = None;
515        // The dry-run solve needs the main-side session (once per applied run).
516        if self.history_has_assembly() {
517            self.ensure_assembly_session();
518        }
519        self.dirty = true;
520        true
521    }
522
523    /// Every armed member at its current pose, pivoting about the shared
524    /// anchor (`None` when a member is gone).
525    fn component_move_members(&self) -> Option<Vec<(String, ComponentPose)>> {
526        self.component_move
527            .group
528            .iter()
529            .map(|id| {
530                let info = self.component_info(id)?;
531                let pose = ComponentPose {
532                    translate: info.translate,
533                    rotate_deg: info.rotate_deg,
534                    anchor: self.component_move.anchor,
535                };
536                Some((id.clone(), pose))
537            })
538            .collect()
539    }
540
541    /// A TYPED move of the armed component (or group): `value` mm along, or
542    /// degrees about, one of the gizmo's axes (`field` = `x|y|z|rx|ry|rz`) —
543    /// the same motion as dragging that arrow or ring by exactly `value`. It is
544    /// solved against the mates once and written through the drag release's
545    /// commit (one undo step, unfix, mechanical references). When the mates
546    /// allow only part of it, that part is applied and the report says how
547    /// much and which constraints stopped the rest; when they allow none, a
548    /// notice names them and nothing is written.
549    pub fn component_move_typed(&mut self, field: &str, value: f64) -> Result<TypedMove, String> {
550        let (axis, rotate) = typed_axis(field).ok_or_else(|| format!("unknown move field {field:?}"))?;
551        if !value.is_finite() {
552            return Err(format!("{value} is not a number"));
553        }
554        if self.component_move.drag.is_some() {
555            return Err("a drag is in progress".into());
556        }
557        let frame = self.component_move_frame().ok_or("nothing is armed to move")?;
558        let members = self.component_move_members().ok_or("an armed component is gone")?;
559        let mut unit = [0.0; 3];
560        unit[axis] = 1.0;
561        let direction = normalize3(quat_rotate(quat_from_euler_xyz_deg(frame.rotate_deg), unit));
562        let delta = if rotate {
563            TransformDelta::Rotate { axis: direction, radians: value.to_radians() }
564        } else {
565            TransformDelta::Translate([direction[0] * value, direction[1] * value, direction[2] * value])
566        };
567        if self.history_has_assembly() {
568            self.ensure_assembly_session();
569        }
570        let (solved, blocking) = match self.component_solve_explained(&members, &delta, None, true) {
571            Ok(result) => result,
572            Err(error) => {
573                let report = TypedMove { field: field.into(), asked: value, applied: 0.0, blocking: Vec::new(), written: false };
574                self.push_notice(format!("Move: the mates cannot hold any pose for this move ({error})."));
575                self.component_move.typed = Some(report.clone());
576                return Ok(report);
577            }
578        };
579        let (primary, start) = members.first().cloned().ok_or("nothing is armed to move")?;
580        let landed = solved.get(&primary).copied().unwrap_or((start.translate, start.rotate_deg));
581        let applied = if rotate {
582            let q_start = quat_from_euler_xyz_deg(start.rotate_deg);
583            let q = quat_mul(quat_from_euler_xyz_deg(landed.1), [-q_start[0], -q_start[1], -q_start[2], q_start[3]]);
584            let along = q[0] * direction[0] + q[1] * direction[1] + q[2] * direction[2];
585            let mut degrees = (2.0 * along.atan2(q[3])).to_degrees();
586            if degrees > 180.0 {
587                degrees -= 360.0;
588            } else if degrees < -180.0 {
589                degrees += 360.0;
590            }
591            degrees
592        } else {
593            let moved = sub3(rigid_delta_point(&start, landed, start.anchor), start.anchor);
594            moved[0] * direction[0] + moved[1] * direction[1] + moved[2] * direction[2]
595        };
596        let stuck = applied.abs() <= TYPED_TOLERANCE * value.abs().max(1.0);
597        let written = !stuck && self.component_commit_solved(&members, &solved, frame.anchor);
598        if !written {
599            self.feed_component_widget();
600        }
601        let report = TypedMove { field: field.into(), asked: value, applied: if written { applied } else { 0.0 }, blocking, written };
602        if !written {
603            self.push_notice(format!("Move: {}", report.summary()));
604        }
605        self.component_move.typed = Some(report.clone());
606        self.dirty = true;
607        Ok(report)
608    }
609
610    /// The last typed move's report (the panel's status line).
611    pub fn component_move_typed_report(&self) -> Option<TypedMove> {
612        self.component_move.typed.clone()
613    }
614
615    /// Whether a component-gizmo drag is in flight.
616    pub fn component_move_dragging(&self) -> bool {
617        self.component_move.drag.is_some()
618    }
619
620    /// Continue the drag: resolve the world delta against the FROZEN grab frame,
621    /// compose the pending pose, solve it live (budget permitting) and pose the
622    /// member displays and the gizmo where the mates put them.
623    pub fn component_drag_to(&mut self, cx: f64, cy: f64) {
624        let Some(drag) = self.component_move.drag.clone() else {
625            return;
626        };
627        let cam = gizmo_camera(&self.camera);
628        let frame = component_frame_json(&drag.start);
629        let json = self.widgets.transform_drag_json_with_frame(
630            &cam,
631            &frame,
632            drag.handle,
633            drag.sx,
634            drag.sy,
635            cx as f32,
636            cy as f32,
637        );
638        let Some(delta) = parse_drag_delta(&json) else {
639            return;
640        };
641        let pending = compose_component_delta(&drag.start, &delta);
642        if let Some(live) = self.component_move.drag.as_mut() {
643            live.pending = Some((delta, pending));
644        }
645        let mut live = drag.live.clone();
646        if live.skip > 0 {
647            live.skip -= 1;
648            live.skipped += 1;
649        } else {
650            let started = now_ms();
651            let solved = self.component_drag_solve(&drag.members, &delta, live.good.as_ref());
652            let cost = self.component_move.solve_cost_override_ms.unwrap_or(now_ms() - started);
653            live.solves += 1;
654            match solved {
655                Ok(poses) => {
656                    live.good = Some(poses);
657                    live.held = false;
658                }
659                Err(_) => live.held = true,
660            }
661            let budget = self.component_move.budget_ms.max(1e-3);
662            if cost > budget {
663                live.skip = ((cost / budget).ceil() as u32).saturating_sub(1).min(DRAG_SOLVE_MAX_SKIP);
664            }
665        }
666        // The gizmo rides the primary member's live pose (the pending pose
667        // until a solve has held); the gold active-handle highlight survives
668        // (only a null feed clears it).
669        let frame = self.component_live_frame(&drag, &pending, &live);
670        let _ = self.widgets.set_transform_json(&component_frame_json(&frame));
671        self.component_live_pose_displays(&drag.members, &live);
672        if let Some(state) = self.component_move.drag.as_mut() {
673            state.live = live;
674        }
675        self.dirty = true;
676    }
677
678    /// One dry-run solve of the dragged members at `delta`: component id →
679    /// solved `(translate, rotate_deg)` for everything the mates moved. `good`
680    /// (the last good live poses) is where mechanical mates re-base. A
681    /// document without assembly state moves the members freely.
682    fn component_drag_solve(
683        &mut self,
684        members: &[(String, ComponentPose)],
685        delta: &TransformDelta,
686        good: Option<&std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>>,
687    ) -> Result<std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>, String> {
688        self.component_solve_explained(members, delta, good, false).map(|(poses, _)| poses)
689    }
690
691    /// [`Self::component_drag_solve`], and with `explain` the constraints that
692    /// refused the dragged members' target pose (the kernel's conflict search:
693    /// one solve per constraint, so only a typed move asks).
694    fn component_solve_explained(
695        &mut self,
696        members: &[(String, ComponentPose)],
697        delta: &TransformDelta,
698        good: Option<&std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>>,
699        explain: bool,
700    ) -> Result<(std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>, Vec<String>), String> {
701        let free: std::collections::BTreeMap<String, ([f64; 3], [f64; 3])> = members
702            .iter()
703            .map(|(id, start)| {
704                let pose = compose_component_delta(start, delta);
705                (id.clone(), (pose.translate, pose.rotate_deg))
706            })
707            .collect();
708        if !self.history_has_assembly() {
709            return Ok((free, Vec::new()));
710        }
711        let poses: serde_json::Map<String, serde_json::Value> = free
712            .iter()
713            .map(|(id, (translate, rotate_deg))| {
714                (id.clone(), serde_json::json!({ "translate": translate, "rotateEulerDeg": rotate_deg }))
715            })
716            .collect();
717        // The last good live poses: mechanical mates re-base there, so each
718        // frame couples only the motion since then.
719        let reference: serde_json::Map<String, serde_json::Value> = good
720            .into_iter()
721            .flatten()
722            .map(|(id, (translate, rotate_deg))| {
723                (id.clone(), serde_json::json!({ "translate": translate, "rotateEulerDeg": rotate_deg }))
724            })
725            .collect();
726        let request = serde_json::json!({
727            "poses": poses,
728            "unfix": self.component_move.unfix,
729            "reference": reference,
730            "explain": explain,
731        });
732        let reply = brep_kernel::assembly_drag_solve_impl(&request.to_string())?;
733        let reply: serde_json::Value = serde_json::from_str(&reply).map_err(|error| error.to_string())?;
734        let mut solved = std::collections::BTreeMap::new();
735        for (id, pose) in reply.get("poses").and_then(|poses| poses.as_object()).into_iter().flatten() {
736            let triple = |key: &str| -> Option<[f64; 3]> {
737                let values = pose.get(key)?.as_array()?;
738                Some([values.first()?.as_f64()?, values.get(1)?.as_f64()?, values.get(2)?.as_f64()?])
739            };
740            if let (Some(translate), Some(rotate_deg)) = (triple("translate"), triple("rotateEulerDeg")) {
741                solved.insert(id.clone(), (translate, rotate_deg));
742            }
743        }
744        let blocking = reply
745            .get("blocking")
746            .and_then(|ids| ids.as_array())
747            .into_iter()
748            .flatten()
749            .filter_map(|id| id.as_str().map(str::to_string))
750            .collect();
751        Ok((solved, blocking))
752    }
753
754    /// The gizmo frame for this drag frame: a group keeps its world-axis frame
755    /// at the anchor carried by the primary member's live delta; a single
756    /// component sits at its live pose.
757    fn component_live_frame(
758        &self,
759        drag: &ComponentMoveDrag,
760        pending: &ComponentPose,
761        live: &LiveSolve,
762    ) -> ComponentPose {
763        let Some((id, start)) = drag.members.first() else {
764            return *pending;
765        };
766        let Some(&(translate, rotate_deg)) = live.good.as_ref().and_then(|good| good.get(id)) else {
767            return *pending;
768        };
769        let anchor = rigid_delta_point(start, (translate, rotate_deg), start.anchor);
770        if drag.members.len() > 1 {
771            return ComponentPose { translate: anchor, rotate_deg: [0.0; 3], anchor };
772        }
773        ComponentPose { translate, rotate_deg, anchor }
774    }
775
776    /// Pose every display of every live-solved component at its solved pose
777    /// (display-only; the pre-drag copies are kept for the restore).
778    fn component_live_pose_displays(&mut self, members: &[(String, ComponentPose)], live: &LiveSolve) {
779        let Some(good) = live.good.as_ref() else {
780            return;
781        };
782        for (id, &solved) in good {
783            // The grab pose of a dragged member; the document pose otherwise.
784            let start = match members.iter().find(|(member, _)| member == id) {
785                Some((_, start)) => *start,
786                None => match self.component_info(id).map(|info| (info.translate, info.rotate_deg)).or_else(|| self.occurrence_world_pose(id)) {
787                    Some((translate, rotate_deg)) => ComponentPose { translate, rotate_deg, anchor: [0.0; 3] },
788                    None => continue,
789                },
790            };
791            // Already showing this pose (posed to it last frame, or never
792            // moved from where it started): nothing to re-pose or re-upload.
793            let shown = self
794                .component_move
795                .live_applied
796                .get(id)
797                .copied()
798                .unwrap_or((start.translate, start.rotate_deg));
799            if same_pose(shown, solved) {
800                continue;
801            }
802            self.component_move.live_applied.insert(id.clone(), solved);
803            // A flexible occurrence (`ACOMP1:ACOMP2`) poses its own solids;
804            // they come after their instance in the map, so its pose wins.
805            let names = if id.contains(':') {
806                let prefix = format!("{id}:");
807                self.scene.solids().iter().filter(|solid| solid.name.starts_with(&prefix)).map(|solid| solid.name.clone()).collect()
808            } else {
809                self.member_solid_names(id)
810            };
811            for name in names {
812                let Some(display) = self.scene.solid(&name) else { continue };
813                let original = self
814                    .component_move
815                    .live_originals
816                    .entry(name.clone())
817                    .or_insert_with(|| display.clone())
818                    .clone();
819                if let Some(display) = self.scene.solid_mut(&name) {
820                    *display = original;
821                    pose_display_rigid(display, &start, solved);
822                }
823            }
824        }
825        self.dirty = true;
826    }
827
828    /// A nested occurrence's world pose as `(translate, rotate_deg)`: its top
829    /// component's pose composed with the occurrence's SOLVED pose in it
830    /// (`nested_poses`). What a flexible occurrence's live drag pose starts
831    /// from. (The live display skipped occurrences, so a flexible
832    /// sub-assembly's parts moved rigidly with it during the drag and jumped
833    /// to their solved poses at release.)
834    fn occurrence_world_pose(&self, path: &str) -> Option<([f64; 3], [f64; 3])> {
835        let (top, rest) = path.split_once(':')?;
836        let record = self.assembly_components().iter().find(|record| record.id == top)?;
837        let local = record.nested_poses.get(rest)?;
838        let (a, b) = (&record.transform, local);
839        let world: [f64; 16] = std::array::from_fn(|k| (0..4).map(|m| a[(k / 4) * 4 + m] * b[m * 4 + k % 4]).sum());
840        let pose = brep_kernel::transform_to_pose_params(&brep_kernel::AffineTransform::new(world).ok()?);
841        let triple = |key: &str| -> Option<[f64; 3]> {
842            let values = pose.get(key)?.as_array()?;
843            Some([values.first()?.as_f64()?, values.get(1)?.as_f64()?, values.get(2)?.as_f64()?])
844        };
845        Some((triple("translate")?, triple("rotateEulerDeg")?))
846    }
847
848    /// Put every live-posed display back exactly (pre-drag pose).
849    pub(super) fn component_live_restore(&mut self) {
850        self.component_move.live_applied.clear();
851        let originals = std::mem::take(&mut self.component_move.live_originals);
852        if originals.is_empty() {
853            return;
854        }
855        for (name, original) in originals {
856            if let Some(display) = self.scene.solid_mut(&name) {
857                *display = original;
858            }
859        }
860        self.dirty = true;
861    }
862
863    /// End the drag: COMMIT the pending pose into each moved ACOMP's
864    /// `inputParams.transform` (one param write — or one batched write for a
865    /// group → one undo entry → one rerun whose constraint tail re-solves; the
866    /// post-run sync then re-glues the gizmo to wherever the solve left the
867    /// components). A grab that never moved commits nothing.
868    pub fn component_release(&mut self) {
869        let Some(drag) = self.component_move.drag.take() else {
870            return;
871        };
872        self.widgets.set_transform_active(0);
873        self.dirty = true;
874        let Some((delta, frame)) = drag.pending else {
875            self.component_live_restore();
876            return;
877        };
878        // The true solve at the final pointer pose; a failure keeps the last
879        // good live pose, and a drag that never held writes nothing (the
880        // pre-drag pose stands).
881        let solved = match self.component_drag_solve(&drag.members, &delta, drag.live.good.as_ref()) {
882            Ok(poses) => poses,
883            Err(_) => match drag.live.good.clone() {
884                Some(poses) => poses,
885                None => {
886                    self.component_live_restore();
887                    self.component_move.unfix.clear();
888                    self.feed_component_widget();
889                    return;
890                }
891            },
892        };
893        let solved = self.snap_dragged_poses(solved);
894        self.component_commit_solved(&drag.members, &solved, frame.anchor);
895    }
896
897    /// The released poses on the 0.001 drag grid, each number judged against
898    /// what the document stores, so a partner the mates left alone stays
899    /// unmoved. Only a drag comes through here; a typed move stays exact.
900    fn snap_dragged_poses(
901        &self,
902        solved: std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>,
903    ) -> std::collections::BTreeMap<String, ([f64; 3], [f64; 3])> {
904        use crate::formatting::snap_drag_moved;
905        solved
906            .into_iter()
907            .map(|(id, (translate, rotate_deg))| {
908                let snapped = match self.component_info(&id) {
909                    Some(info) => (
910                        std::array::from_fn(|i| snap_drag_moved(info.translate[i], translate[i])),
911                        std::array::from_fn(|i| snap_drag_moved(info.rotate_deg[i], rotate_deg[i])),
912                    ),
913                    None => (translate, rotate_deg),
914                };
915                (id, snapped)
916            })
917            .collect()
918    }
919
920    /// Write the solved poses of a drag or a typed move: every component that
921    /// moved, a pending unfix, and the mechanical mates' references, in ONE
922    /// undo step (then one rerun). `fallback_anchor` places the gizmo when the
923    /// primary member has no solved pose. Returns whether anything was written.
924    fn component_commit_solved(
925        &mut self,
926        members: &[(String, ComponentPose)],
927        solved: &std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>,
928        fallback_anchor: [f64; 3],
929    ) -> bool {
930        self.component_move.anchor = match members.first() {
931            Some((id, start)) => match solved.get(id) {
932                Some(&pose) => rigid_delta_point(start, pose, start.anchor),
933                None => fallback_anchor,
934            },
935            None => fallback_anchor,
936        };
937        let mut edits = Vec::new();
938        for (id, &(translate, rotate_deg)) in solved {
939            let Some(index) = self.history.index_of(id) else {
940                continue;
941            };
942            // Only what moved is written (a drag the mates fully undid, or a
943            // partner they left alone, changes nothing). A pending unfix
944            // always writes: the release frees the member.
945            let unfix = self.component_move.unfix.contains(id);
946            match self.component_info(id) {
947                Some(info) if !unfix && !pose_moved((info.translate, info.rotate_deg), (translate, rotate_deg)) => continue,
948                None => continue,
949                _ => {}
950            }
951            let pose = ComponentPose { translate, rotate_deg, anchor: [0.0; 3] };
952            let mut params = self
953                .history
954                .feature_params(index)
955                .unwrap_or_else(|| serde_json::json!({}));
956            if !params.get("transform").map(|t| t.is_object()).unwrap_or(false) {
957                if let Some(object) = params.as_object_mut() {
958                    object.insert("transform".into(), serde_json::json!({}));
959                }
960            }
961            if let Some(transform) = params.get_mut("transform").and_then(|t| t.as_object_mut()) {
962                transform.insert("translate".into(), serde_json::json!(pose.translate));
963                transform.insert("rotateEulerDeg".into(), serde_json::json!(pose.rotate_deg));
964            }
965            if self.component_move.unfix.contains(id) {
966                if let Some(object) = params.as_object_mut() {
967                    object.insert("isFixed".into(), serde_json::Value::Bool(false));
968                }
969            }
970            edits.push((id.clone(), params));
971        }
972        // The pending unfix is spent: from here the members are free.
973        self.component_move.unfix.clear();
974        if edits.is_empty() {
975            self.component_live_restore();
976            return false;
977        }
978        // Gears, racks and screws keep their phase across runs (their stored
979        // references): re-capture them at the released poses and write them in
980        // the SAME undo step as the poses, or the next run would read a
981        // multi-turn drag against the pre-drag references and wrap a spin.
982        // ONE UNDO STEP PER COMMIT. A param write coalesces on `param:{index}`,
983        // so two drags or typed values of the same component in a row would
984        // otherwise be one entry, and one undo would take back both (the
985        // feature transform gizmo ends its run on release for the same reason).
986        self.history.break_coalescing();
987        if let Some(block) = self.mechanical_block_at(solved) {
988            let resolved: Vec<(usize, serde_json::Value)> = edits
989                .iter()
990                .filter_map(|(id, params)| Some((self.history.index_of(id)?, params.clone())))
991                .collect();
992            // The checkpoint here snapshots the pre-drag document (poses AND
993            // references); the block follows without one.
994            self.history.set_many_feature_params(&resolved);
995            self.history.set_assembly_block(block, false);
996            self.history.break_coalescing();
997            let _ = self.rerun_history();
998            return true;
999        }
1000        match edits.as_slice() {
1001            [] => {}
1002            [(id, params)] => {
1003                let _ = self.update_feature_params(id, &params.to_string());
1004            }
1005            _ => {
1006                let _ = self.update_many_feature_params(&edits);
1007            }
1008        }
1009        self.history.break_coalescing();
1010        true
1011    }
1012
1013    /// The assembly block with every mechanical mate's references re-captured
1014    /// at `solved` (the released poses), or `None` when the document has no
1015    /// mechanical mate (nothing to refresh).
1016    fn mechanical_block_at(
1017        &mut self,
1018        solved: &std::collections::BTreeMap<String, ([f64; 3], [f64; 3])>,
1019    ) -> Option<serde_json::Value> {
1020        if !self.history_has_assembly() {
1021            return None;
1022        }
1023        let poses: serde_json::Map<String, serde_json::Value> = solved
1024            .iter()
1025            .map(|(id, (translate, rotate_deg))| {
1026                (id.clone(), serde_json::json!({ "translate": translate, "rotateEulerDeg": rotate_deg }))
1027            })
1028            .collect();
1029        let reply = brep_kernel::assembly_rebase_mechanical_impl(&serde_json::json!({ "poses": poses }).to_string()).ok()?;
1030        serde_json::from_str::<serde_json::Value>(&reply).ok().filter(|block| !block.is_null())
1031    }
1032
1033    /// The armed component gizmo's logical state for the verifier:
1034    /// `{armed, feature, group, unfix, anchor}`.
1035    pub fn component_move_json(&self) -> String {
1036        serde_json::json!({
1037            "armed": self.component_move_armed(),
1038            "feature": self.component_move_armed_feature(),
1039            "group": self.component_move.group,
1040            "unfix": self.component_move.unfix,
1041            "anchor": self.component_move.anchor,
1042            "typed": self.component_move.typed.as_ref().map(|typed| serde_json::json!({
1043                "field": typed.field,
1044                "asked": typed.asked,
1045                "applied": typed.applied,
1046                "blocking": typed.blocking,
1047                "written": typed.written,
1048                "summary": typed.summary(),
1049            })),
1050            "live": self.component_move.drag.as_ref().map(|drag| {
1051                let poses: serde_json::Map<String, serde_json::Value> = drag
1052                    .live
1053                    .good
1054                    .iter()
1055                    .flatten()
1056                    .map(|(id, (translate, rotate_deg))| {
1057                        (id.clone(), serde_json::json!({ "translate": translate, "rotateEulerDeg": rotate_deg }))
1058                    })
1059                    .collect();
1060                serde_json::json!({
1061                    "poses": poses,
1062                    "held": drag.live.held,
1063                    "solves": drag.live.solves,
1064                    "skipped": drag.live.skipped,
1065                })
1066            }),
1067        })
1068        .to_string()
1069    }
1070}
1071
1072/// The widget frame feed for a component pose: origin = the ANCHOR (bbox
1073/// center), axes = the pose's rotated basis (intrinsic XYZ, the kernel bake).
1074/// EVERY handle set is shown (center free-move ball + axis arrows + rotation
1075/// arcs) — move and rotate coexist, no mode switch.
1076fn component_frame_json(pose: &ComponentPose) -> String {
1077    let euler = [
1078        pose.rotate_deg[0].to_radians(),
1079        pose.rotate_deg[1].to_radians(),
1080        pose.rotate_deg[2].to_radians(),
1081    ];
1082    let x = normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler));
1083    let y = normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler));
1084    let z = normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler));
1085    serde_json::json!({
1086        "origin": pose.anchor,
1087        "x": x,
1088        "y": y,
1089        "z": z,
1090        "showCenter": true,
1091        "showAxes": true,
1092        "showRings": true,
1093    })
1094    .to_string()
1095}
1096
1097/// Translation shifts pose and anchor together. Rotation pivots about anchor C:
1098/// `R' = dR·R`, `translate' = C + dR·(translate − C)`.
1099pub(super) fn compose_component_delta(
1100    start: &ComponentPose,
1101    delta: &TransformDelta,
1102) -> ComponentPose {
1103    match delta {
1104        TransformDelta::Translate(d) => ComponentPose {
1105            translate: add3(start.translate, *d),
1106            rotate_deg: start.rotate_deg,
1107            anchor: add3(start.anchor, *d),
1108        },
1109        TransformDelta::Rotate { axis, radians } => {
1110            let dq = quat_from_axis_angle(*axis, *radians);
1111            let q0 = quat_from_euler_xyz_deg(start.rotate_deg);
1112            let rotate_deg = euler_xyz_deg_from_quat(quat_mul(dq, q0));
1113            let offset = sub3(start.translate, start.anchor);
1114            ComponentPose {
1115                translate: add3(start.anchor, quat_rotate(dq, offset)),
1116                rotate_deg,
1117                anchor: start.anchor,
1118            }
1119        }
1120    }
1121}
1122
1123/// Where a world point riding a component at `start` lands when the component
1124/// is at `to`: `p' = R_to·R_startᵀ·(p − t_start) + t_to`.
1125fn rigid_delta_point(start: &ComponentPose, to: ([f64; 3], [f64; 3]), point: [f64; 3]) -> [f64; 3] {
1126    let q_start = quat_from_euler_xyz_deg(start.rotate_deg);
1127    let q_to = quat_from_euler_xyz_deg(to.1);
1128    let q_delta = quat_mul(q_to, [-q_start[0], -q_start[1], -q_start[2], q_start[3]]);
1129    add3(to.0, quat_rotate(q_delta, sub3(point, start.translate)))
1130}
1131
1132/// Whether two `(translate, rotate_deg)` poses differ beyond solver noise: a
1133/// partner the drag did not move comes back from the solve within ~1e-7 mm
1134/// (measured on as1-ug-214), and writing that would rerun it for nothing.
1135fn pose_moved(a: ([f64; 3], [f64; 3]), b: ([f64; 3], [f64; 3])) -> bool {
1136    (0..3).any(|i| (a.0[i] - b.0[i]).abs() > 1e-6 || (a.1[i] - b.1[i]).abs() > 1e-6)
1137}
1138
1139/// Re-pose a display rigidly from the component pose `start` to `to`: the
1140/// mesh, edge polylines and vertices move, normals rotate, the bbox follows.
1141/// Two `(translate, rotate_deg)` poses within roundoff of each other.
1142fn same_pose(a: ([f64; 3], [f64; 3]), b: ([f64; 3], [f64; 3])) -> bool {
1143    (0..3).all(|k| (a.0[k] - b.0[k]).abs() <= 1e-9 && (a.1[k] - b.1[k]).abs() <= 1e-9)
1144}
1145
1146fn pose_display_rigid(display: &mut crate::scene::SolidDisplay, start: &ComponentPose, to: ([f64; 3], [f64; 3])) {
1147    // A rigid move keeps the shape: the renderer rewrites positions and
1148    // normals in the buffers it holds (`sync_scene`'s pose path) instead of
1149    // rebuilding the solid every drag frame.
1150    if display.shape_revision == 0 {
1151        display.shape_revision = display.revision;
1152    }
1153    // Moved by rewriting its positions, not through a pose matrix.
1154    display.pose = None;
1155    display.revision = crate::scene::next_revision();
1156    let q_start = quat_from_euler_xyz_deg(start.rotate_deg);
1157    let q_delta = quat_mul(quat_from_euler_xyz_deg(to.1), [-q_start[0], -q_start[1], -q_start[2], q_start[3]]);
1158    let pose = |p: [f64; 3]| add3(to.0, quat_rotate(q_delta, sub3(p, start.translate)));
1159    let mut bbox = crate::camera::Aabb::empty();
1160    for position in &mut display.mesh.positions {
1161        let posed = pose(position.map(f64::from));
1162        *position = posed.map(|v| v as f32);
1163        bbox.expand(posed);
1164    }
1165    for normal in &mut display.mesh.normals {
1166        *normal = quat_rotate(q_delta, normal.map(f64::from)).map(|v| v as f32);
1167    }
1168    for edge in &mut display.edges {
1169        for point in &mut edge.polyline {
1170            let posed = pose(point.map(f64::from));
1171            *point = posed.map(|v| v as f32);
1172            bbox.expand(posed);
1173        }
1174    }
1175    for vertex in &mut display.vertices {
1176        vertex.position = pose(vertex.position);
1177        bbox.expand(vertex.position);
1178    }
1179    if !bbox.is_empty() {
1180        display.bbox = bbox;
1181    }
1182}
1183
1184/// Milliseconds on a monotonic-enough clock (the browser has no `Instant`).
1185/// Shared with the motion-study bake (`simulation_play`), which slices its
1186/// solving under the same kind of per-frame budget.
1187#[cfg(not(target_arch = "wasm32"))]
1188pub(super) fn now_ms() -> f64 {
1189    static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
1190    ORIGIN.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0
1191}
1192
1193#[cfg(target_arch = "wasm32")]
1194pub(super) fn now_ms() -> f64 {
1195    js_sys::Date::now()
1196}
1197
1198/// Rotate `v` by quaternion `q`: `v + 2·(q.xyz × (q.xyz × v + w·v))`.
1199pub(super) fn quat_rotate(q: Quat, v: [f64; 3]) -> [f64; 3] {
1200    let u = [q[0], q[1], q[2]];
1201    let w = q[3];
1202    let t = cross(u, add3(cross(u, v), [w * v[0], w * v[1], w * v[2]]));
1203    [v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
1204}
1205