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