Skip to main content

brep_render/engine_state/
exploded_ops.rs

1//! Assembly exploded views, built on the PMI explode rows (one mechanism, not
2//! two): an exploded view IS a PMI view whose `explode` annotations are its
3//! ordered STEPS. It is saved with the assembly in the `pmi` block, a drawing
4//! sheet places it like any PMI view, and the PDF 3D export carries it.
5//!
6//! What this module adds on top:
7//! * [`EngineState::exploded_views`]: the views that hold explode steps, for the
8//!   Assembly workbench's list;
9//! * [`EngineState::assembly_auto_explode`]: a new view with one step per
10//!   top-level component, each moved straight away from the base component
11//!   (the first fixed one, else the largest) by a distance proportional to the
12//!   assembly's size, the outermost first, every step drawing its trail;
13//! * playback: a PROGRESS from 0 (collapsed) to the number of steps (fully
14//!   exploded) poses step `k` at `clamp(progress - k, 0, 1)` of its delta, on
15//!   the displays and on the trails alike, and [`EngineState::exploded_tick`]
16//!   moves it toward a target over time (Explode / Collapse). Progress is
17//!   display state, never saved: a view activated afresh shows fully exploded.
18use super::EngineState;
19use brep_kernel::{PmiAnnotation, PmiAnnotationReport, PmiGeometry, PmiPose};
20use serde_json::{json, Value};
21
22/// The animation state of the active view's steps.
23#[derive(Debug, Clone, Default, PartialEq)]
24pub struct ExplodePlayback {
25    /// Steps shown, `0 ..= steps`; `None` = every step at full (the default).
26    pub progress: Option<f64>,
27    /// Where an animation is heading, and its speed in steps per second.
28    pub target: Option<(f64, f64)>,
29}
30
31/// One step of an exploded view (an `explode` row of a PMI view).
32#[derive(Debug, Clone, PartialEq)]
33pub struct ExplodeStep {
34    pub id: String,
35    pub targets: Vec<String>,
36    /// What a person reads for each target: a component's part name and id
37    /// (`sub-a (ACOMP2)`), a solid's own name.
38    pub labels: Vec<String>,
39    pub translate: [f64; 3],
40    pub trace: bool,
41    pub enabled: bool,
42}
43
44/// One hide step of an exploded view (a `hide` row of a PMI view): the
45/// components / solids it keeps out of the view.
46#[derive(Debug, Clone, PartialEq)]
47pub struct HideStep {
48    pub id: String,
49    pub targets: Vec<String>,
50    /// What a person reads for each target, as [`ExplodeStep::labels`].
51    pub labels: Vec<String>,
52    pub enabled: bool,
53}
54
55/// A PMI view that holds explode steps.
56#[derive(Debug, Clone, PartialEq)]
57pub struct ExplodedView {
58    pub id: String,
59    pub name: String,
60    pub steps: Vec<ExplodeStep>,
61    /// Its hide steps, in row order (not steps of the playback).
62    pub hides: Vec<HideStep>,
63}
64
65/// `geometry` with an explode delta taken `fraction` of the way: the
66/// translation and rotation scale linearly, the scale toward 1. Other rows
67/// pass through.
68pub(crate) fn explode_at(geometry: &PmiGeometry, fraction: f64) -> PmiGeometry {
69    match geometry {
70        PmiGeometry::Explode { solids, translate, rotate_deg, scale, center, trace } => PmiGeometry::Explode {
71            solids: solids.clone(),
72            translate: translate.map(|v| v * fraction),
73            rotate_deg: rotate_deg.map(|v| v * fraction),
74            scale: scale.map(|v| 1.0 + (v - 1.0) * fraction),
75            center: *center,
76            trace: *trace,
77        },
78        other => other.clone(),
79    }
80}
81
82/// Where the active view's playback shows what the full explode puts at a
83/// point: the run lays every annotation out on the fully exploded bodies,
84/// and a body part-way through its steps carries its annotations with it,
85/// `shown ∘ full⁻¹` through that body's stages. Borrowed from the report and
86/// asked per row, so a frame builds nothing for rows that do not move.
87pub(crate) struct PlaybackPoses<'a> {
88    progress: f64,
89    /// The posed explode rows, in step order.
90    steps: Vec<&'a PmiAnnotationReport>,
91    /// Each posed explode row's step index.
92    index: std::collections::HashMap<&'a str, usize>,
93}
94
95fn pose_of(geometry: &PmiGeometry) -> Option<PmiPose> {
96    match geometry {
97        PmiGeometry::Explode { translate, rotate_deg, scale, center, .. } => {
98            Some(PmiPose { center: *center, translate: *translate, rotate_deg: *rotate_deg, scale: *scale })
99        }
100        _ => None,
101    }
102}
103
104impl PlaybackPoses<'_> {
105    /// Step `step`'s pose fully exploded and as shown now.
106    fn stage(&self, step: usize) -> Option<(PmiPose, PmiPose)> {
107        let geometry = &self.steps[step].geometry;
108        let fraction = (self.progress - step as f64).clamp(0.0, 1.0);
109        Some((pose_of(geometry)?, pose_of(&explode_at(geometry, fraction))?))
110    }
111
112    /// The stages that pose `solid`, in display order.
113    fn stages_of(&self, solid: &str) -> Vec<(PmiPose, PmiPose)> {
114        (0..self.steps.len())
115            .filter(|&step| matches!(&self.steps[step].geometry, PmiGeometry::Explode { solids, .. } if solids.iter().any(|s| s == solid)))
116            .filter_map(|step| self.stage(step))
117            .collect()
118    }
119
120    /// The stages a row's points ride: an explode step's chip and gizmo its
121    /// own step only (the run lays them at the step's own delta from its
122    /// modeling centre), a balloon every stage of its target. Other rows are
123    /// laid out on the modeling geometry and do not move.
124    fn rider(&self, row: &PmiAnnotationReport) -> Vec<(PmiPose, PmiPose)> {
125        match &row.geometry {
126            PmiGeometry::Explode { .. } => self.index.get(row.id.as_str()).and_then(|&step| self.stage(step)).into_iter().collect(),
127            PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => {
128                // An edge head is on its own solid. A surface head is on
129                // whichever member is nearest; members one step moves apart
130                // from another are rare, and the first posed one stands in.
131                let solid = match &anchor.mode {
132                    brep_kernel::BalloonAnchorMode::Edge { solid, .. } => Some(solid.as_str()),
133                    _ => anchor.solids.iter().map(String::as_str).find(|solid| !self.stages_of(solid).is_empty()),
134                };
135                solid.map(|solid| self.stages_of(solid)).unwrap_or_default()
136            }
137            _ => Vec::new(),
138        }
139    }
140
141    /// Where `point`, laid out by the run for `row`, is shown now.
142    pub(crate) fn shown(&self, row: &PmiAnnotationReport, point: [f64; 3]) -> [f64; 3] {
143        let stages = self.rider(row);
144        let model = stages.iter().rev().fold(point, |p, (full, _)| full.unapply(p));
145        stages.iter().fold(model, |p, (_, shown)| shown.apply(p))
146    }
147
148    /// The inverse of [`Self::shown`]: a point placed on screen now, in the
149    /// frame the run lays `row` out in (where a dragged label is stored).
150    pub(crate) fn full(&self, row: &PmiAnnotationReport, point: [f64; 3]) -> [f64; 3] {
151        let stages = self.rider(row);
152        let model = stages.iter().rev().fold(point, |p, (_, shown)| shown.unapply(p));
153        stages.iter().fold(model, |p, (full, _)| full.apply(p))
154    }
155
156    /// `row`'s geometry as shown now: a balloon's head rides its target.
157    /// (An explode row's own trail follows [`explode_at`].)
158    pub(crate) fn shown_geometry(&self, row: &PmiAnnotationReport) -> Option<PmiGeometry> {
159        match &row.geometry {
160            PmiGeometry::Leader { targets, dot, balloon: true, anchor } => Some(PmiGeometry::Leader {
161                targets: targets.iter().map(|target| self.shown(row, *target)).collect(),
162                dot: *dot,
163                balloon: true,
164                anchor: anchor.clone(),
165            }),
166            _ => None,
167        }
168    }
169}
170
171fn steps_of(annotations: &[PmiAnnotation], parts: &std::collections::HashMap<String, String>) -> Vec<ExplodeStep> {
172    annotations
173        .iter()
174        .filter(|a| a.kind == "explode")
175        .map(|a| {
176            let position = &a.params["transform"]["position"];
177            let translate = std::array::from_fn(|i| position[i].as_f64().unwrap_or(0.0));
178            let targets = a.references("targets");
179            let labels = targets
180                .iter()
181                .map(|t| parts.get(t).map_or_else(|| t.clone(), |part| format!("{part} ({t})")))
182                .collect();
183            ExplodeStep {
184                id: a.params["id"].as_str().unwrap_or_default().to_string(),
185                targets,
186                labels,
187                translate,
188                trace: a.params["showTraceLine"].as_bool().unwrap_or(true),
189                enabled: a.enabled,
190            }
191        })
192        .collect()
193}
194
195fn hides_of(annotations: &[PmiAnnotation], parts: &std::collections::HashMap<String, String>) -> Vec<HideStep> {
196    annotations
197        .iter()
198        .filter(|a| a.kind == "hide")
199        .map(|a| {
200            let targets = a.references("targets");
201            let labels = targets
202                .iter()
203                .map(|t| parts.get(t).map_or_else(|| t.clone(), |part| format!("{part} ({t})")))
204                .collect();
205            HideStep { id: a.params["id"].as_str().unwrap_or_default().to_string(), targets, labels, enabled: a.enabled }
206        })
207        .collect()
208}
209
210impl EngineState {
211    /// Every PMI view with at least one explode step, in view order.
212    pub fn exploded_views(&self) -> Vec<ExplodedView> {
213        let state = self.pmi_state();
214        if !state.views.iter().any(|view| view.annotations.iter().any(|a| a.kind == "explode")) {
215            return Vec::new();
216        }
217        // Each top-level component's part name, for the step labels (read per
218        // feature: the panel asks every frame, so no whole-document copy).
219        let mut parts: std::collections::HashMap<String, String> = (0..self.history.len())
220            .filter(|&i| self.history.feature_type(i).as_deref() == Some("ACOMP"))
221            .filter_map(|i| self.history.feature_params(i))
222            .filter_map(|p| Some((p["id"].as_str()?.to_string(), p["partName"].as_str()?.to_string())))
223            .collect();
224        // A step on a placement inside a sub-assembly names that placement's part.
225        for view in &state.views {
226            for target in view.annotations.iter().filter(|a| a.kind == "explode" || a.kind == "hide").flat_map(|a| a.references("targets")) {
227                if target.contains(':') && !parts.contains_key(&target) {
228                    if let Some(part) = self.part_at_path(&target) {
229                        parts.insert(target, part);
230                    }
231                }
232            }
233        }
234        state
235            .views
236            .iter()
237            .map(|view| ExplodedView {
238                id: view.id.clone(),
239                name: view.name.clone(),
240                steps: steps_of(&view.annotations, &parts),
241                hides: hides_of(&view.annotations, &parts),
242            })
243            .filter(|view| !view.steps.is_empty())
244            .collect()
245    }
246
247    /// Whether view `id` holds explode steps (cheap: no labels).
248    pub fn is_exploded_view(&self, id: &str) -> bool {
249        self.pmi_state().find_view(id).is_some_and(|view| view.annotations.iter().any(|a| a.kind == "explode"))
250    }
251
252    /// The number of explode steps the active view poses (enabled rows the
253    /// last run resolved), the unit of [`ExplodePlayback::progress`].
254    pub fn exploded_step_count(&self) -> usize {
255        let Some(active) = self.pmi_active_view.as_deref() else { return 0 };
256        self.pmi_report
257            .as_ref()
258            .and_then(|report| report.view(active))
259            .map_or(0, |view| {
260                view.annotations
261                    .iter()
262                    .filter(|row| row.enabled && row.status == brep_kernel::PmiStatus::Ok)
263                    .filter(|row| matches!(row.geometry, PmiGeometry::Explode { .. }))
264                    .count()
265            })
266    }
267
268    /// How far step `index` (among the active view's posed explode rows) is
269    /// shown, `0 ..= 1`.
270    pub(crate) fn explode_fraction(&self, index: usize) -> f64 {
271        match self.explode_playback.progress {
272            None => 1.0,
273            Some(progress) => (progress - index as f64).clamp(0.0, 1.0),
274        }
275    }
276
277    /// The active view's playback poses, while it shows anything other than
278    /// every step at full (`None` then: the run's layout is what is shown).
279    pub(crate) fn explode_playback_poses(&self) -> Option<PlaybackPoses<'_>> {
280        let progress = self.explode_playback.progress?;
281        let active = self.pmi_active_view.as_deref()?;
282        let view = self.pmi_report.as_ref()?.view(active)?;
283        let steps: Vec<&PmiAnnotationReport> = view
284            .annotations
285            .iter()
286            .filter(|row| row.enabled && row.status == brep_kernel::PmiStatus::Ok)
287            .filter(|row| matches!(row.geometry, PmiGeometry::Explode { .. }))
288            .collect();
289        if progress >= steps.len() as f64 {
290            return None;
291        }
292        let index = steps.iter().enumerate().map(|(step, row)| (row.id.as_str(), step)).collect();
293        Some(PlaybackPoses { progress, steps, index })
294    }
295
296    /// The step index of explode row `id` among the active view's posed rows.
297    pub(crate) fn explode_step_of(&self, id: &str) -> Option<usize> {
298        let active = self.pmi_active_view.as_deref()?;
299        self.pmi_report
300            .as_ref()?
301            .view(active)?
302            .annotations
303            .iter()
304            .filter(|row| row.enabled && row.status == brep_kernel::PmiStatus::Ok)
305            .filter(|row| matches!(row.geometry, PmiGeometry::Explode { .. }))
306            .position(|row| row.id == id)
307    }
308
309    /// The playback state, for the panel and automation.
310    /// The shown view's progress, `0 ..= steps` (the slider's value).
311    pub fn exploded_progress(&self) -> f64 {
312        self.explode_playback.progress.unwrap_or(self.exploded_step_count() as f64)
313    }
314
315    pub fn exploded_state_json(&self) -> Value {
316        let steps = self.exploded_step_count();
317        json!({
318            "activeView": self.pmi_active_view,
319            "steps": steps,
320            "progress": self.explode_playback.progress.unwrap_or(steps as f64),
321            "animating": self.explode_playback.target.is_some(),
322            "views": self.exploded_views().iter().map(|view| json!({
323                "id": view.id,
324                "name": view.name,
325                "steps": view.steps.iter().map(|step| json!({
326                    "id": step.id, "targets": step.targets, "labels": step.labels, "translate": step.translate,
327                    "trace": step.trace, "enabled": step.enabled,
328                })).collect::<Vec<_>>(),
329                "hides": view.hides.iter().map(|step| json!({
330                    "id": step.id, "targets": step.targets, "labels": step.labels, "enabled": step.enabled,
331                })).collect::<Vec<_>>(),
332            })).collect::<Vec<_>>(),
333        })
334    }
335
336    /// Show the active view's steps up to `progress` (clamped to
337    /// `0 ..= steps`), stopping any animation.
338    pub fn exploded_set_progress(&mut self, progress: f64) {
339        self.explode_playback.target = None;
340        self.set_explode_progress(progress);
341    }
342
343    fn set_explode_progress(&mut self, progress: f64) {
344        let steps = self.exploded_step_count() as f64;
345        self.explode_playback.progress = Some(progress.clamp(0.0, steps));
346        if self.pmi_active_view.is_some() {
347            self.pmi_apply_explode();
348            self.refresh_pmi_overlay();
349            // A step's gizmo rides its step.
350            self.sync_transform_gizmo();
351        }
352    }
353
354    /// Animate the active view: `explode` plays its steps out from where it
355    /// stands to fully exploded, else back to collapsed, at `steps_per_second`.
356    pub fn exploded_animate(&mut self, explode: bool, steps_per_second: f64) -> Result<(), String> {
357        if self.pmi_active_view.is_none() {
358            return Err("activate an exploded view first".into());
359        }
360        let steps = self.exploded_step_count() as f64;
361        let to = if explode { steps } else { 0.0 };
362        if self.explode_playback.progress.is_none() {
363            // Fully exploded now: an Explode replays from collapsed.
364            self.set_explode_progress(if explode { 0.0 } else { steps });
365        }
366        self.explode_playback.target = Some((to, steps_per_second.max(1e-3)));
367        Ok(())
368    }
369
370    /// Advance an animation by `dt` seconds; `true` while another frame is
371    /// needed. The app calls it once per frame.
372    pub fn exploded_tick(&mut self, dt: f64) -> bool {
373        let Some((to, speed)) = self.explode_playback.target else { return false };
374        if self.pmi_active_view.is_none() {
375            self.explode_playback.target = None;
376            return false;
377        }
378        let from = self.explode_playback.progress.unwrap_or(to);
379        let step = speed * dt.max(0.0);
380        let next = if to > from { (from + step).min(to) } else { (from - step).max(to) };
381        self.set_explode_progress(next);
382        if (next - to).abs() < 1e-12 {
383            self.explode_playback.target = None;
384            return false;
385        }
386        true
387    }
388
389    /// The placements the selection covers, as occurrence paths, in selection
390    /// order: for each selected solid, the shallowest placement above it whose
391    /// every solid is selected. A whole sub-assembly picked in the viewport is
392    /// `ACOMP1`; the pin inside it picked from the BOM is `ACOMP1:ACOMP2`. A
393    /// body of the assembly's own (no placement) is not one.
394    pub fn selected_occurrences(&self) -> Vec<String> {
395        let selected: std::collections::HashSet<&str> = self.emphasis.selected_solids.iter().map(String::as_str).collect();
396        let mut out: Vec<String> = Vec::new();
397        for name in &self.emphasis.selected_solids {
398            let mut path = String::new();
399            for (at, _) in name.match_indices(':') {
400                path = name[..at].to_string();
401                let prefix = format!("{path}:");
402                if self.scene.solids().iter().filter(|s| s.name.starts_with(&prefix)).all(|s| selected.contains(s.name.as_str())) {
403                    break;
404                }
405            }
406            if !path.is_empty() && !out.contains(&path) {
407                out.push(path);
408            }
409        }
410        out
411    }
412
413    /// Add a step to the shown exploded view that moves `targets` (the
414    /// selection's placements when empty) straight up by their own size, and
415    /// select it, so its gizmo is armed to drag it where it belongs. Returns
416    /// the step's id.
417    pub fn exploded_add_step(&mut self, targets: &[String]) -> Result<String, String> {
418        let view = self
419            .pmi_active_view
420            .clone()
421            .filter(|id| self.is_exploded_view(id))
422            .ok_or("show an exploded view first")?;
423        let targets: Vec<String> = if targets.is_empty() { self.selected_occurrences() } else { targets.to_vec() };
424        if targets.is_empty() {
425            return Err("select the components to move".into());
426        }
427        let mut low = [f64::INFINITY; 3];
428        let mut high = [f64::NEG_INFINITY; 3];
429        for target in &targets {
430            let prefix = format!("{target}:");
431            for solid in self.scene.solids().iter().filter(|s| s.name.starts_with(&prefix)) {
432                for k in 0..3 {
433                    low[k] = low[k].min(solid.bbox.min[k]);
434                    high[k] = high[k].max(solid.bbox.max[k]);
435                }
436            }
437        }
438        if !low[0].is_finite() {
439            return Err(format!("'{}' has no solids", targets.join(", ")));
440        }
441        let size = (0..3).map(|k| (high[k] - low[k]).powi(2)).sum::<f64>().sqrt().max(1.0);
442        let mut state = self.pmi_state();
443        let step = state.next_id("EXP");
444        state.find_view_mut(&view).ok_or("the exploded view vanished")?.annotations.push(PmiAnnotation {
445            plugin_replay: None,
446            persistent_data: Value::Null,
447            kind: "explode".into(),
448            enabled: true,
449            params: json!({
450                "id": step,
451                "targets": targets,
452                "transform": { "position": [0.0, 0.0, size], "rotationEuler": [0, 0, 0], "scale": [1, 1, 1] },
453                "showTraceLine": true,
454            }),
455            label_world: None,
456        });
457        self.write_pmi_state(state);
458        self.explode_playback = ExplodePlayback::default();
459        self.pmi_set_annotation_open(Some(&step));
460        Ok(step)
461    }
462
463    /// A new exploded view of the assembly, one step per top-level component.
464    /// The base component stays: the first fixed one, else the largest. Every
465    /// other moves straight away from the base's centre (along +Z when the
466    /// two centres coincide) by `spread` times half the assembly's diagonal,
467    /// the outermost first. Returns the view's id; the view is active.
468    pub fn assembly_auto_explode(&mut self, name: Option<&str>, spread: f64) -> Result<String, String> {
469        self.assembly_auto_explode_deep(name, spread, 1)
470    }
471
472    /// The placements directly under `path` (the assembly's own for `None`),
473    /// as `(occurrence path, isFixed)` in document order.
474    fn placements_under(&self, path: Option<&str>) -> Vec<(String, bool)> {
475        let of = |features: &[Value], prefix: &str| -> Vec<(String, bool)> {
476            features
477                .iter()
478                .filter(|f| f["type"] == "ACOMP")
479                .filter_map(|f| {
480                    let id = f["inputParams"]["id"].as_str()?;
481                    Some((format!("{prefix}{id}"), f["inputParams"]["isFixed"].as_bool().unwrap_or(false)))
482                })
483                .collect()
484        };
485        let Some(path) = path else {
486            return of(self.history.features(), "");
487        };
488        let mut segments = path.split(':');
489        let Some(top) = segments.next() else { return Vec::new() };
490        let Some(params) = self.history.index_of(top).and_then(|i| self.history.feature_params(i)) else { return Vec::new() };
491        let Some(mut part) = params["partName"].as_str().map(str::to_string) else { return Vec::new() };
492        let mut library = self.history.parts_library();
493        let mut document = &library[&part]["document"];
494        for id in segments {
495            let Some(next) = document["features"]
496                .as_array()
497                .and_then(|features| features.iter().find(|f| f["inputParams"]["id"].as_str() == Some(id)))
498                .and_then(|f| f["inputParams"]["partName"].as_str())
499            else {
500                return Vec::new();
501            };
502            part = next.to_string();
503            library = &document["partsLibrary"];
504            document = &library[&part]["document"];
505        }
506        of(document["features"].as_array().map(Vec::as_slice).unwrap_or(&[]), &format!("{path}:"))
507    }
508
509    /// One group's steps: each placement but the base (the first fixed one,
510    /// else the largest) moved straight away from the base's centre (+Z when
511    /// the centres coincide) by `spread` × half the group's diagonal, the
512    /// outermost first. `None` when fewer than two of them show a solid.
513    fn explode_group(&self, placements: &[(String, bool)], spread: f64) -> Option<Vec<(String, f64, [f64; 3])>> {
514        let boxes: Vec<(&str, bool, [f64; 3], [f64; 3])> = placements
515            .iter()
516            .filter_map(|(path, fixed)| {
517                let prefix = format!("{path}:");
518                let mut low = [f64::INFINITY; 3];
519                let mut high = [f64::NEG_INFINITY; 3];
520                for solid in self.scene.solids().iter().filter(|s| s.name.starts_with(&prefix)) {
521                    for k in 0..3 {
522                        low[k] = low[k].min(solid.bbox.min[k]);
523                        high[k] = high[k].max(solid.bbox.max[k]);
524                    }
525                }
526                low[0].is_finite().then_some((path.as_str(), *fixed, low, high))
527            })
528            .collect();
529        if boxes.len() < 2 {
530            return None;
531        }
532        let volume = |b: &(&str, bool, [f64; 3], [f64; 3])| (0..3).map(|k| (b.3[k] - b.2[k]).max(0.0)).product::<f64>();
533        let base = boxes
534            .iter()
535            .position(|b| b.1)
536            .unwrap_or_else(|| (0..boxes.len()).max_by(|&a, &b| volume(&boxes[a]).total_cmp(&volume(&boxes[b]))).unwrap_or(0));
537        let centre = |b: &(&str, bool, [f64; 3], [f64; 3])| -> [f64; 3] { std::array::from_fn(|k| (b.2[k] + b.3[k]) / 2.0) };
538        let origin = centre(&boxes[base]);
539        let low: [f64; 3] = std::array::from_fn(|k| boxes.iter().map(|b| b.2[k]).fold(f64::INFINITY, f64::min));
540        let high: [f64; 3] = std::array::from_fn(|k| boxes.iter().map(|b| b.3[k]).fold(f64::NEG_INFINITY, f64::max));
541        let diagonal = (0..3).map(|k| (high[k] - low[k]).powi(2)).sum::<f64>().sqrt().max(1e-9);
542        let distance = spread * diagonal / 2.0;
543        let mut moves: Vec<(String, f64, [f64; 3])> = boxes
544            .iter()
545            .enumerate()
546            .filter(|(i, _)| *i != base)
547            .map(|(_, b)| {
548                let c = centre(b);
549                let d: [f64; 3] = std::array::from_fn(|k| c[k] - origin[k]);
550                let length = d.iter().map(|v| v * v).sum::<f64>().sqrt();
551                let direction = if length > 1e-6 * diagonal { d.map(|v| v / length) } else { [0.0, 0.0, 1.0] };
552                (b.0.to_string(), length, direction.map(|v| v * distance))
553            })
554            .collect();
555        // The outermost comes off first; a collapse brings it home last.
556        moves.sort_by(|a, b| b.1.total_cmp(&a.1));
557        Some(moves)
558    }
559
560    /// [`Self::assembly_auto_explode`] `depth` levels down: level 1 is the
561    /// assembly's own placements; at each further level every sub-assembly
562    /// placement (the base included) opens, its placements moving away from
563    /// its own base by `spread` × its own size. A nested step moves on top of
564    /// the steps of the placements above it. Steps go level by level, so
565    /// Explode takes the big pieces apart first.
566    pub fn assembly_auto_explode_deep(&mut self, name: Option<&str>, spread: f64, depth: usize) -> Result<String, String> {
567        if !(spread.is_finite() && spread > 0.0) {
568            return Err("the spread must be a positive number".into());
569        }
570        // Measure the assembly as placed: an active view's poses are put back first.
571        self.pmi_deactivate_view();
572        let mut moves: Vec<(String, f64, [f64; 3])> = Vec::new();
573        let mut level: Vec<Option<String>> = vec![None];
574        for _ in 0..depth.max(1) {
575            let mut next = Vec::new();
576            for parent in &level {
577                let placements = self.placements_under(parent.as_deref());
578                if let Some(group) = self.explode_group(&placements, spread) {
579                    moves.extend(group);
580                }
581                next.extend(placements.into_iter().map(|(path, _)| Some(path)));
582            }
583            level = next;
584        }
585        if moves.is_empty() {
586            return Err("an exploded view needs at least two placed components".into());
587        }
588        let name = name
589            .map(str::trim)
590            .filter(|n| !n.is_empty())
591            .map(String::from)
592            .unwrap_or_else(|| format!("Exploded view {}", self.exploded_views().len() + 1));
593        // The view is framed on the EXPLODED layout: captured on the assembly
594        // as it stands, the parts thrown furthest left the window (and so did
595        // the gizmo that drags their steps). Each box at its exploded place,
596        // the base where it is; the user's own camera is put back after the
597        // capture (activating the view shows the view's).
598        // Each solid at its exploded place: shifted by every step above it.
599        let mut exploded = crate::camera::Aabb::empty();
600        for solid in self.scene.solids().iter().filter(|s| s.visible) {
601            let mut shift = [0.0; 3];
602            for (path, _, translate) in &moves {
603                if solid.name.starts_with(path.as_str()) && solid.name[path.len()..].starts_with(':') {
604                    for k in 0..3 {
605                        shift[k] += translate[k];
606                    }
607                }
608            }
609            exploded.expand(std::array::from_fn(|k| solid.bbox.min[k] + shift[k]));
610            exploded.expand(std::array::from_fn(|k| solid.bbox.max[k] + shift[k]));
611        }
612        let camera = self.camera.clone();
613        self.camera.zoom_to_fit(&exploded, 1.15);
614        let id = self.pmi_capture_view(Some(&name));
615        self.camera = camera;
616        let mut state = self.pmi_state();
617        let mut rows = Vec::new();
618        for (component, _, translate) in moves {
619            let step = state.next_id("EXP");
620            rows.push(PmiAnnotation {
621                plugin_replay: None,
622                persistent_data: Value::Null,
623                kind: "explode".into(),
624                enabled: true,
625                params: json!({
626                    "id": step,
627                    "targets": [component],
628                    "transform": { "position": translate, "rotationEuler": [0, 0, 0], "scale": [1, 1, 1] },
629                    "showTraceLine": true,
630                }),
631                label_world: None,
632            });
633        }
634        state.find_view_mut(&id).ok_or("the new view vanished")?.annotations.extend(rows);
635        self.write_pmi_state(state);
636        self.explode_playback = ExplodePlayback::default();
637        let _ = self.pmi_activate_view(&id);
638        Ok(id)
639    }
640}
641