Skip to main content

brep_render/engine_state/
instructions_ops.rs

1//! Manufacturing instructions on the engine: an instructions document places
2//! its target assembly as one grounded component (the WRAPPER) and keeps what
3//! the author writes in its `instructions` block (`brep_instructions::model`).
4//!
5//! What this module adds on top of the crate:
6//! * [`EngineState::instructions_start`]: turn an empty document into
7//!   instructions for an assembly;
8//! * [`EngineState::instructions_level`]: the wrapper's assembly as the
9//!   wizard's occurrence tree, read from its embedded document;
10//! * [`EngineState::instructions_run_wizard`]: propose the step tree and
11//!   give each step its own PMI view: an iso camera fitted to what the step
12//!   shows, the parts not placed yet hidden, and a balloon on each added
13//!   part that has a Find Number (a placement inside the wrapper, so the
14//!   balloon names its occurrence);
15//! * [`EngineState::instructions_restructure`]: an edit that changes which
16//!   step adds what (merge, split, insert, reorder, move a part, delete), with
17//!   the step views brought up to date in the same undo step;
18//! * [`EngineState::instructions_edit`]: every other edit, read-modify-write
19//!   of the block as one undo step;
20//! * [`EngineState::instructions_show_step`]: show a step's view.
21//! * [`EngineState::instructions_fold_retarget`]: a new state of the
22//!   assembly re-targets the steps in the same undo step, waiting for
23//!   [`EngineState::instructions_retarget_approve`].
24//!
25//! The wizard reads the scene, so right after a start it waits for the run
26//! that builds the wrapper ([`EngineState::instructions_run_wizard_when_built`]).
27
28use super::assembly_ops::ComponentInsert;
29use super::EngineState;
30use super::{InstructionsRefField, RefSelectTarget};
31use brep_instructions::model::{Arrow, Instructions, Step, Style, Target, BLOCK_KEY, DOCUMENT_CLASS};
32use brep_instructions::wizard::Level;
33use brep_instructions::{edit, retarget, source, wizard};
34use brep_kernel::{PmiAnnotation, PmiCamera, PmiDisplay, PmiProjection, PmiView};
35use serde_json::{json, Value};
36
37/// The ghost of a part an earlier step placed: neutral grey, see-through.
38const GHOST_RGBA: [f32; 4] = [0.62, 0.64, 0.66, 0.28];
39
40/// How long a step's parts take to come home along their insertion arrows.
41const INSERTION_SECONDS: f64 = 0.8;
42/// The viewport a generated step camera is fitted in (the player data's
43/// default), so the fit does not depend on the window the edit was made in.
44const GENERATED_VIEWPORT: [f64; 2] = [1280.0, 800.0];
45
46/// A step's insertion motion in the in-app player: its arrowed parts start
47/// out along their arrows and move home. Display poses only (the explode
48/// machinery's), never a write to the document.
49#[derive(Debug, Clone, PartialEq)]
50pub struct Insertion {
51    /// The view it plays in; showing any other puts it away.
52    view: String,
53    /// The solids under each arrow and the arrow's start offset.
54    moves: Vec<(Vec<String>, [f64; 3])>,
55    /// 0 = at the arrows' start, 1 = home.
56    fraction: f64,
57    /// Held where it is (automation's still pictures): ticks leave it.
58    held: bool,
59}
60
61/// Where the player goes.
62#[derive(Debug, Clone, PartialEq)]
63pub enum PlayTo {
64    First,
65    Next,
66    Back,
67    /// The shown step again, its insertion from the start.
68    Replay,
69    Step(String),
70}
71
72/// The document key of the document class (`documentClass`).
73const DOCUMENT_CLASS_KEY: &str = "documentClass";
74
75impl EngineState {
76    /// The document's instructions block; `Ok(None)` when it has none.
77    pub fn instructions(&self) -> Result<Option<Instructions>, String> {
78        match self.history.document_block(BLOCK_KEY) {
79            None | Some(Value::Null) => Ok(None),
80            Some(block) => Instructions::from_value(block).map(Some),
81        }
82    }
83
84    /// Whether the document is an instructions document.
85    pub fn is_instructions(&self) -> bool {
86        self.history.document_block(DOCUMENT_CLASS_KEY).and_then(Value::as_str) == Some(DOCUMENT_CLASS)
87            || self.history.document_block(BLOCK_KEY).is_some()
88    }
89
90    pub(super) fn instructions_required(&self) -> Result<Instructions, String> {
91        self.instructions()?.ok_or_else(|| "this document is not manufacturing instructions".to_string())
92    }
93
94    /// Read the block, apply `edit`, validate and write it back as ONE undo
95    /// step (`coalesce` groups a typing run). Nothing is written on a refusal.
96    pub fn instructions_edit<T>(
97        &mut self,
98        coalesce: Option<&str>,
99        edit: impl FnOnce(&mut Instructions) -> Result<T, String>,
100    ) -> Result<T, String> {
101        let mut block = self.instructions_required()?;
102        let out = edit(&mut block)?;
103        block.validate()?;
104        self.history.set_document_block(BLOCK_KEY, Some(block.to_value()), coalesce);
105        Ok(out)
106    }
107
108    /// The wrapper component: `(id, part name)` of the first placement.
109    pub fn instructions_wrapper(&self) -> Result<(String, String), String> {
110        self.history
111            .features()
112            .iter()
113            .find(|f| super::components::is_acomp_feature_type(f["type"].as_str().unwrap_or_default()))
114            .and_then(|f| Some((f["inputParams"]["id"].as_str()?.to_string(), f["inputParams"]["partName"].as_str()?.to_string())))
115            .ok_or_else(|| "the instructions place no assembly".to_string())
116    }
117
118    /// The wrapper's assembly as the wizard's occurrence tree.
119    pub fn instructions_level(&self) -> Result<Level, String> {
120        let (id, part) = self.instructions_wrapper()?;
121        let document = &self.history.parts_library()[part.as_str()]["document"];
122        if !source::has_placements(document) {
123            return Err(format!("'{part}' is not an assembly: instructions need one to describe"));
124        }
125        Ok(source::level_from_document(document, &id, "", &part))
126    }
127
128    /// Make this EMPTY document the instructions for an assembly: place it as
129    /// the grounded wrapper, mark the document class and start a block for
130    /// `target`. One undo step. Returns the wrapper's id.
131    pub fn instructions_start(&mut self, insert: ComponentInsert<'_>, target: Target) -> Result<String, String> {
132        if !self.history.features().is_empty() || self.history.document_block(BLOCK_KEY).is_some() {
133            return Err("instructions start from an empty document".into());
134        }
135        if let ComponentInsert::New { document_json, .. } = &insert {
136            let document: Value = serde_json::from_str(document_json).map_err(|e| format!("the assembly is not a document: {e}"))?;
137            if !source::has_placements(&document) {
138                return Err("instructions need an assembly to describe".into());
139            }
140        }
141        // The first placement is inserted grounded.
142        let id = self.insert_component(insert)?;
143        self.history.set_document_block_no_undo(DOCUMENT_CLASS_KEY, Some(Value::String(DOCUMENT_CLASS.into())));
144        self.history.set_document_block_no_undo(BLOCK_KEY, Some(Instructions::new(target).to_value()));
145        Ok(id)
146    }
147
148    /// Propose the step tree from the wrapper's assembly, replacing the
149    /// block's steps (the target, template and front matter stay), and
150    /// give each step its view. The views the old steps had are removed. One
151    /// undo step. Returns the step count.
152    pub fn instructions_run_wizard(&mut self) -> Result<usize, String> {
153        let mut block = self.instructions_required()?;
154        let level = self.instructions_level()?;
155        let old_views: Vec<String> = block.steps().filter_map(|(_, s)| s.view.clone()).collect();
156        wizard::propose(&mut block, &level);
157        let mut state = self.pmi_state();
158        state.views.retain(|view| !old_views.contains(&view.id));
159        self.instructions_step_views(&mut block, &mut state)?;
160        block.validate()?;
161        let steps = block.step_count();
162        self.history.set_document_block(BLOCK_KEY, Some(block.to_value()), None);
163        let pmi = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
164        self.history.set_pmi_block_no_undo(pmi);
165        self.rerun_history();
166        Ok(steps)
167    }
168
169    /// [`instructions_run_wizard`](Self::instructions_run_wizard) now when
170    /// the scene is current, else once the pending run lands (a background
171    /// runner has not built the wrapper's solids yet right after
172    /// [`instructions_start`](Self::instructions_start)).
173    pub fn instructions_run_wizard_when_built(&mut self) -> Result<(), String> {
174        self.instructions_required()?;
175        if self.run_pending() {
176            self.instructions_wizard_pending = true;
177            return Ok(());
178        }
179        self.instructions_run_wizard().map(|_| ())
180    }
181
182    /// Whether the wizard is waiting for a run to land.
183    pub fn instructions_wizard_pending(&self) -> bool {
184        self.instructions_wizard_pending
185    }
186
187    /// A structural edit (merge, split, insert, reorder, move a part, delete):
188    /// `edit` on the block, then every step's view brought up to date: the
189    /// parts hidden and the balloons follow the steps' parts, each step keeps
190    /// its camera and the author's own annotations, a step without a view
191    /// gets one, and the views of steps that went away are removed. One undo
192    /// step.
193    pub fn instructions_restructure<T>(&mut self, edit: impl FnOnce(&mut Instructions) -> Result<T, String>) -> Result<T, String> {
194        self.instructions_restructure_with_views(|block, _| edit(block))
195    }
196
197    /// [`Self::instructions_restructure`] whose edit also reaches the PMI
198    /// state (a step's view camera).
199    fn instructions_restructure_with_views<T>(
200        &mut self,
201        edit: impl FnOnce(&mut Instructions, &mut brep_kernel::PmiState) -> Result<T, String>,
202    ) -> Result<T, String> {
203        let mut block = self.instructions_required()?;
204        let before: Vec<String> = block.steps().filter_map(|(_, s)| s.view.clone()).collect();
205        let mut state = self.pmi_state();
206        let out = edit(&mut block, &mut state)?;
207        block.validate()?;
208        let kept: Vec<String> = block.steps().filter_map(|(_, s)| s.view.clone()).collect();
209        state.views.retain(|view| !before.contains(&view.id) || kept.contains(&view.id));
210        self.instructions_step_views(&mut block, &mut state)?;
211        self.history.set_document_block(BLOCK_KEY, Some(block.to_value()), None);
212        let pmi = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
213        self.history.set_pmi_block_no_undo(pmi);
214        self.rerun_history();
215        Ok(out)
216    }
217
218    /// Fold a new state of the wrapper's assembly into the steps of the
219    /// `incoming` document before it is adopted (Update components, a linked
220    /// tab's newer state), so the assembly and the re-target are one undo
221    /// step: the wizard reruns on the changed parts only and the change waits
222    /// in the block's `retarget` for the author's approval. An assembly whose
223    /// occurrences did not change only moves the target. Refused while a
224    /// re-target waits. Returns whether the steps changed (their views are
225    /// made once the new revision is built).
226    pub(super) fn instructions_fold_retarget(&self, incoming: &mut Value) -> Result<bool, String> {
227        let Ok((id, part)) = self.instructions_wrapper() else { return Ok(false) };
228        let old_entry = &self.history.parts_library()[part.as_str()];
229        let new_entry = &incoming["partsLibrary"][part.as_str()];
230        if new_entry.is_null() || old_entry["document"] == new_entry["document"] {
231            return Ok(false);
232        }
233        let Some(mut block) = incoming.get(BLOCK_KEY).filter(|b| !b.is_null()).map(Instructions::from_value).transpose()? else {
234            return Ok(false);
235        };
236        if block.retarget.is_some() {
237            return Err("the instructions' re-target waits for approval: approve it in the Instructions pane, then update again".into());
238        }
239        let old = source::level_from_document(&old_entry["document"], &id, "", &part);
240        let new = source::level_from_document(&new_entry["document"], &id, "", &part);
241        let text = |key: &str, or: &str| new_entry[key].as_str().filter(|v| !v.is_empty()).unwrap_or(or).to_string();
242        let to = Target {
243            source_key: text("sourceKey", &block.target.source_key),
244            signature: text("sourceSignature", &block.target.signature),
245            ..block.target.clone()
246        };
247        let diff = retarget::diff(&old, &new);
248        let changed = !diff.is_empty();
249        if changed {
250            let before: Vec<String> = block.steps().filter_map(|(_, s)| s.view.clone()).collect();
251            retarget::apply(&mut block, &new, &diff, to)?;
252            // The views of steps a dropped sub-assembly's group took away.
253            let kept: Vec<String> = block.steps().filter_map(|(_, s)| s.view.clone()).collect();
254            if let Some(views) = incoming["pmi"]["views"].as_array_mut() {
255                views.retain(|view| view["id"].as_str().is_none_or(|id| !before.iter().any(|b| b == id) || kept.iter().any(|k| k == id)));
256            }
257        } else {
258            block.target = to;
259        }
260        block.validate()?;
261        incoming[BLOCK_KEY] = block.to_value();
262        Ok(changed)
263    }
264
265    /// Bring every step's view up to date with the block without an undo step
266    /// of its own (it belongs to the edit that changed the steps): what each
267    /// hides and its balloons follow the steps' parts, and a new step gets a
268    /// view fitted to the solids the run just built.
269    pub(super) fn instructions_refresh_views(&mut self) -> Result<(), String> {
270        let mut block = self.instructions_required()?;
271        let mut state = self.pmi_state();
272        self.instructions_step_views(&mut block, &mut state)?;
273        self.history.set_document_block_no_undo(BLOCK_KEY, Some(block.to_value()));
274        let pmi = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
275        self.history.set_pmi_block_no_undo(pmi);
276        self.rerun_history();
277        Ok(())
278    }
279
280    /// Approve the pending re-target: the new revision becomes the target and
281    /// the steps it left empty go, with their views. One undo step. Returns
282    /// the new target.
283    pub fn instructions_retarget_approve(&mut self) -> Result<Target, String> {
284        self.instructions_restructure(retarget::approve)
285    }
286
287    /// Reject the pending re-target: the ONE undo that takes the new revision
288    /// and its re-target back together (what Undo does), offered as a button
289    /// and a command. Refused when none waits; when the re-target came in with
290    /// the file (nothing in this session takes it back: approve it, or update
291    /// again later); and when a later edit sits on top of that undo step (it
292    /// would go too: undo it first, or approve). The newest step is the
293    /// re-target's when the block as it stood before it has no `retarget`.
294    /// Returns the target the instructions keep describing.
295    pub fn instructions_retarget_reject(&mut self) -> Result<Target, String> {
296        let block = self.instructions_required()?;
297        let from = block.retarget.as_ref().ok_or("no re-target is waiting for approval")?.from.clone();
298        if !self.history.can_undo() {
299            return Err("the re-target came with the file: nothing in this session takes it back; approve it, or update again later".into());
300        }
301        let fold_is_newest = self
302            .history
303            .undo_top_block(BLOCK_KEY)
304            .is_some_and(|before| before.get("retarget").is_none_or(Value::is_null));
305        if !fold_is_newest {
306            return Err("edits followed the re-target: undo them first, or approve it".into());
307        }
308        self.undo();
309        Ok(from)
310    }
311
312    /// Enter reference selection for step `step_id`'s occurrence `field` (an
313    /// arrow's part or a callout's): the field takes ONE solid of the placed
314    /// assembly, picked in the viewport, and Finish derives its occurrence
315    /// (`Self::instructions_commit_ref`). `seed` is the occurrence it holds
316    /// now, lit as the pick's seed through its first solid. Nothing rolls.
317    pub fn begin_ref_select_for_instructions(&mut self, step_id: &str, field: InstructionsRefField, seed: Option<&str>) {
318        let restore_index = self.history.rollback();
319        let filter = vec!["SOLID".to_string()];
320        let label = match field {
321            InstructionsRefField::Arrow(_) => "Part the arrow moves".to_string(),
322            InstructionsRefField::Callout(_) => "Part the callout points at".to_string(),
323        };
324        let path = match field {
325            InstructionsRefField::Arrow(index) => vec!["arrows".to_string(), index.to_string(), "occurrence".to_string()],
326            InstructionsRefField::Callout(index) => vec!["callouts".to_string(), index.to_string(), "occurrence".to_string()],
327        };
328        let names = seed.and_then(|occurrence| self.instructions_occurrence_solid(occurrence)).into_iter().collect();
329        self.selection_filter = crate::engine_state::selection_filter::SelectionFilter::from_ref_filter(&filter);
330        self.ref_select = Some(super::RefSelectState {
331            feature_id: step_id.to_string(),
332            path,
333            label,
334            filter,
335            multiple: false,
336            names,
337            restore_index,
338            target: RefSelectTarget::Instructions { field },
339            picks: 0,
340            claimed: false,
341            finish_requested: false,
342        });
343        self.sync_ref_select_emphasis();
344    }
345
346    /// The occurrence a picked solid belongs to: its name without the member
347    /// (`ACOMP1:ACOMP3:Pin` → `ACOMP1:ACOMP3`), when the placed assembly
348    /// holds that occurrence.
349    pub fn instructions_occurrence_of_solid(&self, solid: &str) -> Result<String, String> {
350        let (occurrence, _) = solid.rsplit_once(':').ok_or_else(|| format!("'{solid}' is not a part of the assembly"))?;
351        let level = self.instructions_level()?;
352        if !wizard::flatten(&level).iter().any(|(_, c)| c.path == occurrence) {
353            return Err(format!("'{solid}' is not a part of the assembly"));
354        }
355        Ok(occurrence.to_string())
356    }
357
358    /// The first drawn solid of `occurrence` (`<occurrence>:<member>`), for
359    /// lighting the occurrence in the viewport.
360    pub fn instructions_occurrence_solid(&self, occurrence: &str) -> Option<String> {
361        let prefix = format!("{occurrence}:");
362        self.scene.solids().iter().map(|solid| solid.name.clone()).find(|name| name.starts_with(&prefix))
363    }
364
365    /// Write a finished occurrence pick into step `step_id`'s `field`: an
366    /// arrow takes a part the step adds (any other is refused, the arrow
367    /// unchanged), a callout any part of the assembly. One undo step, the
368    /// step views following.
369    pub(crate) fn instructions_commit_ref(&mut self, step_id: &str, field: InstructionsRefField, solid: &str) -> Result<(), String> {
370        let occurrence = self.instructions_occurrence_of_solid(solid)?;
371        let step_id = step_id.to_string();
372        self.instructions_edit(None, |block| {
373            let step = block.step_mut(&step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
374            match field {
375                InstructionsRefField::Arrow(index) => {
376                    if !step.adds.iter().any(|add| add.occurrences.contains(&occurrence)) {
377                        return Err(format!("{occurrence} is not a part this step adds: an arrow moves one of the step's own parts"));
378                    }
379                    let arrow = step.arrows.get_mut(index).ok_or_else(|| format!("no arrow {index}"))?;
380                    arrow.occurrence = occurrence;
381                }
382                InstructionsRefField::Callout(index) => {
383                    let callout = step.callouts.get_mut(index).ok_or_else(|| format!("no callout {index}"))?;
384                    callout.occurrence = Some(occurrence);
385                }
386            }
387            Ok(())
388        })?;
389        self.history.break_coalescing();
390        Ok(())
391    }
392
393    /// A new empty step after `after` (or at the end), with its own view.
394    /// Returns its id.
395    pub fn instructions_new_step(&mut self, after: Option<&str>) -> Result<String, String> {
396        self.instructions_restructure(|block| edit::insert_step(block, after))
397    }
398
399    /// A BOM row dragged into a step: add `occurrences` of the wrapper's
400    /// assembly to `step_id` ([`edit::add_parts`]). An occurrence not in the
401    /// assembly, or a step that does not exist, is refused with nothing
402    /// written. The step views follow in the same undo step. Returns what
403    /// was added and what was left where it is.
404    pub fn instructions_add_parts(&mut self, step_id: &str, occurrences: &[String]) -> Result<edit::AddReport, String> {
405        let (wrapper, _) = self.instructions_wrapper()?;
406        let level = self.instructions_level()?;
407        let flat = wizard::flatten(&level);
408        let mut components = Vec::new();
409        for occurrence in occurrences {
410            if *occurrence == wrapper {
411                return Err("that row is the whole assembly: drag a part row into the step".into());
412            }
413            let component = flat
414                .iter()
415                .find(|(_, c)| &c.path == occurrence)
416                .map(|(_, c)| *c)
417                .ok_or_else(|| format!("'{occurrence}' is not an occurrence of the assembly"))?;
418            components.push(component);
419        }
420        if self.instructions_required()?.step(step_id).is_none() {
421            return Err(format!("no step '{step_id}'"));
422        }
423        self.instructions_restructure(|block| edit::add_parts(block, step_id, &components))
424    }
425
426    /// Every occurrence of the part named `part` (its name or its PLM part id) at the assembly's top level
427    /// (what a packed BOM row stands for; a sub-assembly's own parts belong
428    /// to its group) into `step_id`, the ones already in a step reported.
429    /// Refused for a part the top level does not have.
430    pub fn instructions_add_part(&mut self, step_id: &str, part: &str) -> Result<edit::AddReport, String> {
431        let level = self.instructions_level()?;
432        let occurrences: Vec<String> = level.components.iter().filter(|c| c.part == part || c.part_key() == part).map(|c| c.path.clone()).collect();
433        if occurrences.is_empty() {
434            return Err(format!("the assembly has no part '{part}'"));
435        }
436        self.instructions_add_parts(step_id, &occurrences)
437    }
438
439    /// Take a part out of a step ([`edit::remove_add`]): it is not planned
440    /// any more until it is added again. One undo step, views following.
441    pub fn instructions_remove_add(&mut self, step_id: &str, add_index: usize) -> Result<(), String> {
442        self.instructions_restructure(|block| edit::remove_add(block, step_id, add_index).map(drop))
443    }
444
445    /// Move a step under `parent` (`None`: the top level) at `index`, its
446    /// children with it ([`edit::nest_step`]). One undo step, views following
447    /// (a nested step's camera looks from its new parent's direction).
448    pub fn instructions_nest_step(&mut self, step_id: &str, parent: Option<&str>, index: usize) -> Result<(), String> {
449        self.instructions_restructure(|block| edit::nest_step(block, step_id, parent, index))
450    }
451
452    /// The parts of the assembly not yet in any step, as `(occurrence, part)`
453    /// ([`edit::unplanned`]); empty when the document is not instructions.
454    pub fn instructions_unplanned(&self) -> Vec<(String, String)> {
455        let Ok(Some(block)) = self.instructions() else { return Vec::new() };
456        let Ok(level) = self.instructions_level() else { return Vec::new() };
457        edit::unplanned(&block, &level).into_iter().map(|c| (c.path.clone(), c.part.clone())).collect()
458    }
459
460    /// Capture the viewport's camera as `step_id`'s view camera. It is the
461    /// author's from then on: every edit keeps it, until
462    /// [`Self::instructions_reset_view`]. One undo step.
463    pub fn instructions_capture_camera(&mut self, step_id: &str) -> Result<(), String> {
464        let snapshot = self.snapshot_camera();
465        self.instructions_restructure_with_views(|block, state| {
466            let step = block.step_mut(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
467            step.camera_captured = true;
468            if let Some(view) = step.view.as_deref().and_then(|id| state.find_view_mut(id)) {
469                // A section saved before its plane was stored is anchored on
470                // the old camera first, as the PMI workbench's Update camera
471                // does (no step view carries a section today).
472                if let Some(section) = view.display.section.as_mut() {
473                    section.freeze(view.camera.as_ref());
474                }
475                view.camera = Some(snapshot);
476            }
477            Ok(())
478        })
479    }
480
481    /// Drop `step_id`'s captured camera: its view's camera is generated again
482    /// from the step's parts and its parent's direction. One undo step.
483    pub fn instructions_reset_view(&mut self, step_id: &str) -> Result<(), String> {
484        self.instructions_restructure(|block| {
485            block.step_mut(step_id).ok_or_else(|| format!("no step '{step_id}'"))?.camera_captured = false;
486            Ok(())
487        })
488    }
489
490    /// The step whose view `view_id` is, if any.
491    pub(super) fn instructions_step_of_view(&self, view_id: &str) -> Option<String> {
492        let block = self.instructions().ok().flatten()?;
493        let step = block.steps().find(|(_, step)| step.view.as_deref() == Some(view_id)).map(|(_, step)| step.id.clone());
494        step
495    }
496
497    /// Give every step of `block` its view in `state`: the parts not placed
498    /// yet hidden and a balloon on each added part that has a Find Number (a
499    /// placement inside the wrapper, so the balloon names its occurrence).
500    /// A step's existing view keeps its id and the annotations the author
501    /// added (explode poses among them). Its camera is generated from the
502    /// step's parts ([`Self::generated_step_camera`]) unless the author
503    /// captured one, which stays until Reset view. Parents go before their
504    /// children, whose cameras look from the parent's direction. The user's
505    /// camera is put back afterwards.
506    fn instructions_step_views(&mut self, block: &mut Instructions, state: &mut brep_kernel::PmiState) -> Result<(), String> {
507        self.pmi_deactivate_view();
508        let camera = self.camera.clone();
509        let solids: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
510        let boxes: std::collections::HashMap<String, crate::camera::Aabb> =
511            self.scene.solids().iter().map(|s| (s.name.clone(), s.bbox)).collect();
512        fn parents_first(steps: &[brep_instructions::model::Step], out: &mut Vec<String>) {
513            for step in steps {
514                out.push(step.id.clone());
515                parents_first(&step.steps, out);
516            }
517        }
518        let mut ids = Vec::new();
519        parents_first(&block.steps, &mut ids);
520        for step_id in &ids {
521            let presentation = edit::presentation(block, step_id, &solids)?;
522            let number = block.step_number(step_id).unwrap_or(0);
523            let step = block.step(step_id).expect("listed step");
524            let existing = step.view.as_deref().and_then(|id| state.find_view(id)).cloned();
525            let parent_camera = block
526                .parent_id(step_id)
527                .flatten()
528                .and_then(|parent| block.step(parent))
529                .and_then(|parent| parent.view.as_deref())
530                .and_then(|id| state.find_view(id))
531                .and_then(|view| view.camera.clone());
532            // A Line art step whose parts slide in along the derived arrows
533            // frames where they start too.
534            let slides = block.template.style == Style::LineArt && step.arrows.is_empty();
535            let camera_now = match existing.as_ref().and_then(|view| view.camera.clone()).filter(|_| step.camera_captured) {
536                Some(kept) => kept,
537                None => self.generated_step_camera(&presentation, &boxes, parent_camera.as_ref(), slides),
538            };
539            // The author's annotations stay; the step's own balloons (the
540            // ones naming an occurrence) are made again from its parts.
541            let mut annotations: Vec<PmiAnnotation> = existing
542                .as_ref()
543                .map(|view| {
544                    view.annotations
545                        .iter()
546                        .filter(|a| !(a.kind == "balloon" && a.params.get("occurrence").is_some()) && !a.is_baseline_hide())
547                        .cloned()
548                        .collect()
549                })
550                .unwrap_or_default();
551            // The parts not placed yet are the view's baseline hide step,
552            // made again from the step after its balloons (its id kept); the
553            // author's own hide steps stay.
554            let baseline_id = existing
555                .as_ref()
556                .and_then(|view| view.annotations.iter().find(|a| a.is_baseline_hide()))
557                .map(|a| a.id().to_string())
558                .filter(|id| !id.is_empty());
559            let view_id = match &existing {
560                Some(view) => view.id.clone(),
561                None => state.next_id("VIEW"),
562            };
563            for add in step.adds.iter().filter(|a| !a.find_number.is_empty()) {
564                let Some(occurrence) = add.occurrences.first() else { continue };
565                let prefix = format!("{occurrence}:");
566                let Some(member) = solids.iter().find(|name| name.starts_with(&prefix)) else { continue };
567                annotations.push(PmiAnnotation {
568                    plugin_replay: None,
569                    persistent_data: Value::Null,
570                    kind: "balloon".into(),
571                    enabled: true,
572                    params: json!({ "id": state.next_id("BAL"), "target": member, "occurrence": occurrence }),
573                    label_world: None,
574                });
575            }
576            if !presentation.hidden.is_empty() {
577                let id = baseline_id.unwrap_or_else(|| brep_kernel::baseline_hide_id(&view_id));
578                annotations.push(PmiAnnotation::hide_step(&id, &presentation.hidden, true));
579            }
580            let display = existing.as_ref().map(|view| view.display.clone()).unwrap_or_else(|| PmiDisplay::new(12.0, false, None));
581            let view = PmiView {
582                id: view_id.clone(),
583                name: format!("Step {number} · {}", step.title),
584                camera: Some(camera_now),
585                display,
586                annotations,
587            };
588            match state.find_view_mut(&view_id) {
589                Some(slot) => *slot = view,
590                None => state.views.push(view),
591            }
592            block.step_mut(step_id).expect("listed step").view = Some(view_id);
593        }
594        self.camera = camera;
595        Ok(())
596    }
597
598    /// A step's generated camera: it frames everything the step shows, the
599    /// parts it adds (its own and its children's) and the ghosts placed
600    /// before it, so no part runs off the picture. It looks from its parent's
601    /// camera direction, with the parent's up and projection, or from the
602    /// isometric in the app's default (orthographic) projection at the top
603    /// level. The fit is made in a fixed viewport
604    /// ([`GENERATED_VIEWPORT`]), so the same step gives the same camera
605    /// whatever window the edit was made in. Leaves `self.camera` changed.
606    fn generated_step_camera(
607        &mut self,
608        presentation: &edit::Presentation,
609        boxes: &std::collections::HashMap<String, crate::camera::Aabb>,
610        parent: Option<&PmiCamera>,
611        slides: bool,
612    ) -> PmiCamera {
613        let union = |names: &[String]| {
614            let mut bbox = crate::camera::Aabb::empty();
615            for name in names {
616                if let Some(solid) = boxes.get(name) {
617                    bbox.union(solid);
618                }
619            }
620            bbox
621        };
622        let mut shown = union(&presentation.placed);
623        shown.union(&union(&presentation.adding));
624        let frame = shown.clone();
625        match parent {
626            Some(parent) => {
627                let (kind, scale) = match parent.projection {
628                    PmiProjection::Orthographic { half_height } => ("orthographic", half_height),
629                    PmiProjection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
630                };
631                let json = json!({ "kind": kind, "eye": parent.eye, "target": parent.target, "up": parent.up, "scale": scale }).to_string();
632                let _ = self.camera.apply_state_json(&json);
633            }
634            None => {
635                // The app's default projection, whatever the viewport is in,
636                // so the same step gives the same camera.
637                self.camera.projection = crate::view::Projection::Orthographic { half_height: 10.0 };
638                self.camera.standard_view("iso");
639            }
640        }
641        let mut frame = frame;
642        if slides {
643            if let Some(offset) = default_start_offset(presentation, boxes, self.camera.up) {
644                let mut start = union(&presentation.adding);
645                start.min = std::array::from_fn(|k| start.min[k] + offset[k]);
646                start.max = std::array::from_fn(|k| start.max[k] + offset[k]);
647                frame.union(&start);
648            }
649        }
650        self.camera.width = GENERATED_VIEWPORT[0];
651        self.camera.height = GENERATED_VIEWPORT[1];
652        if !frame.is_empty() {
653            self.camera.zoom_to_fit(&frame, 1.25);
654        }
655        // An orthographic fit keeps the eye where it was: put it at a fixed
656        // distance (clear of everything shown) so it depends on the frame
657        // alone, not on the viewport the edit was made in.
658        if matches!(self.camera.projection, crate::view::Projection::Orthographic { .. }) {
659            // An empty step shows nothing: its box is inside out (+inf..-inf),
660            // whose infinite diagonal put the eye at infinity, and the history
661            // document then stored it as null and refused every later run.
662            let size = if shown.is_empty() { [0.0; 3] } else { shown.size() };
663            let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
664            let distance = (4.0 * diagonal).max(10.0);
665            let (eye, target) = (self.camera.eye, self.camera.target);
666            let direction = [eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]];
667            let length = (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]).sqrt();
668            if length > 1e-12 {
669                self.camera.eye = [
670                    target[0] + direction[0] / length * distance,
671                    target[1] + direction[1] / length * distance,
672                    target[2] + direction[2] / length * distance,
673                ];
674            }
675        }
676        self.snapshot_camera()
677    }
678
679    /// Show a step's view (its camera, hidden parts and balloons).
680    pub fn instructions_show_step(&mut self, step_id: &str) -> Result<(), String> {
681        let block = self.instructions_required()?;
682        let step = block.step(step_id).ok_or_else(|| format!("no step '{step_id}'"))?;
683        let view = step.view.clone().ok_or_else(|| format!("step '{step_id}' has no view: run the wizard or pick one"))?;
684        self.pmi_activate_view(&view)
685    }
686
687    /// Draw the shown step as the players do. In a Line art document the
688    /// scene carries the look ([`crate::scene::RenderScene::line_art`]): the
689    /// placed parts stay in it, drawn white with grey lines, the added ones
690    /// flat blue with black lines. Otherwise the parts earlier steps placed
691    /// are ghosted (grey and translucent, in the context layer, which draws
692    /// without depth writes or picks) and the parts the step adds solid. Run
693    /// after a view is applied and after every run while one is shown (the
694    /// copies follow the rebuilt displays); with no step shown, or before a
695    /// view is put away, the ghosts go and their parts show again. Only an
696    /// instructions document's context layer is touched: a Work part keeps
697    /// its context on the part's own engine.
698    pub(crate) fn instructions_refresh_ghosts(&mut self) {
699        if !self.is_instructions() {
700            return;
701        }
702        let shown = self.instructions_shown_presentation();
703        // A Line art step draws its placed parts opaque and white in the
704        // scene itself (`RenderScene::line_art`), not as translucent ghosts:
705        // hidden lines must not show through them.
706        let line_art = shown.is_some() && self.instructions().ok().flatten().is_some_and(|block| block.template.style == Style::LineArt);
707        let all_placed = shown.map(|presentation| presentation.placed).unwrap_or_default();
708        let look = line_art.then(|| crate::scene::LineArtLook { placed: all_placed.iter().cloned().collect() });
709        if self.scene.line_art != look {
710            self.scene.line_art = look;
711            self.dirty = true;
712        }
713        let placed = if line_art { Vec::new() } else { all_placed };
714        let gone: Vec<String> =
715            self.context_scene.solids().iter().map(|ghost| ghost.name.clone()).filter(|name| !placed.contains(name)).collect();
716        for name in gone {
717            self.context_scene.remove_solid(&name);
718            self.scene.set_visible(&name, true);
719            self.dirty = true;
720        }
721        for name in placed {
722            let Some(solid) = self.scene.solid(&name) else { continue };
723            // A hidden part without a ghost is one the view hides; one with a
724            // ghost was ghosted before, and a run may have rebuilt it since.
725            if !solid.visible && self.context_scene.solid(&name).is_none() {
726                continue;
727            }
728            let mut ghost = solid.clone();
729            ghost.visible = true;
730            ghost.source_handle = 0;
731            ghost.vertices.clear();
732            if self.context_scene.solid(&name) != Some(&ghost) {
733                self.context_scene.insert_solid(ghost);
734                self.dirty = true;
735            }
736            if solid.visible {
737                self.scene.set_visible(&name, false);
738                self.dirty = true;
739            }
740        }
741        if !self.context_scene.solids().is_empty() && self.context_scene.tint != Some(GHOST_RGBA) {
742            self.context_scene.tint = Some(GHOST_RGBA);
743            self.dirty = true;
744        }
745    }
746
747    /// The arrows `step_id`'s parts come home along: the step's own; in a
748    /// Line art document, a step without any gets one per part it adds (its
749    /// own and its group's), all travelling down its view's up direction
750    /// from a start clear of the parts already placed
751    /// (`default_start_offset`). Derived each time, never written to the
752    /// document: the author's arrows replace them. The first step of a
753    /// group, which places nothing over anything, slides nothing in.
754    pub fn instructions_step_arrows(&self, block: &Instructions, step_id: &str) -> Vec<Arrow> {
755        let Some(step) = block.step(step_id) else { return Vec::new() };
756        if !step.arrows.is_empty() || block.template.style != Style::LineArt {
757            return step.arrows.clone();
758        }
759        let pmi = self.pmi_state();
760        let Some(up) = step.view.as_deref().and_then(|id| pmi.find_view(id)).and_then(|view| view.camera.as_ref()).map(|camera| camera.up) else {
761            return Vec::new();
762        };
763        let solids: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
764        let Ok(presentation) = edit::presentation(block, step_id, &solids) else { return Vec::new() };
765        let boxes: std::collections::HashMap<String, crate::camera::Aabb> =
766            self.scene.solids().iter().map(|s| (s.name.clone(), s.bbox)).collect();
767        default_arrows(step, &presentation, &boxes, up)
768    }
769
770    /// Play a step in the in-app player: show its view (its camera, ghosts
771    /// and balloons) and start its parts' insertion along their arrows.
772    /// Steps go in reading order (a step's children before it); Next on
773    /// the last step and Back on the first stay put. Returns the step shown.
774    pub fn instructions_play(&mut self, to: PlayTo) -> Result<String, String> {
775        let block = self.instructions_required()?;
776        let order: Vec<String> = block.steps().map(|(_, step)| step.id.clone()).collect();
777        if order.is_empty() {
778            return Err("there are no steps yet: run the wizard first".into());
779        }
780        let shown = self.instructions_shown_step();
781        let at = shown.as_ref().and_then(|id| order.iter().position(|step| step == id));
782        let step = match to {
783            PlayTo::First => order[0].clone(),
784            PlayTo::Next => order[at.map_or(0, |i| (i + 1).min(order.len() - 1))].clone(),
785            PlayTo::Back => order[at.map_or(0, |i| i.saturating_sub(1))].clone(),
786            PlayTo::Replay => shown.unwrap_or_else(|| order[0].clone()),
787            PlayTo::Step(id) => order.iter().find(|step| **step == id).cloned().ok_or_else(|| format!("no step '{id}'"))?,
788        };
789        self.instructions_show_step(&step)?;
790        let view = self.pmi_active_view().map(str::to_string).unwrap_or_default();
791        let solids: Vec<String> = self.scene.solids().iter().map(|solid| solid.name.clone()).collect();
792        let presentation = edit::presentation(&block, &step, &solids)?;
793        let under = |path: &str, occurrence: &str| path == occurrence || (path.starts_with(occurrence) && path[occurrence.len()..].starts_with(':'));
794        let arrows = self.instructions_step_arrows(&block, &step);
795        let moves: Vec<(Vec<String>, [f64; 3])> = block
796            .step(&step)
797            .map(|_| {
798                arrows
799                    .iter()
800                    .map(|arrow| {
801                        // Only the parts this step adds move: a placed part's
802                        // display is a ghost's source and stays where it is.
803                        let names: Vec<String> = presentation.adding.iter().filter(|name| under(name, &arrow.occurrence)).cloned().collect();
804                        (names, arrow.start_offset())
805                    })
806                    .filter(|(names, offset)| !names.is_empty() && offset.iter().any(|v| *v != 0.0))
807                    .collect()
808            })
809            .unwrap_or_default();
810        self.instructions_insertion = (!moves.is_empty()).then(|| Insertion { view, moves, fraction: 0.0, held: false });
811        self.pmi_apply_explode();
812        self.refresh_pmi_overlay();
813        Ok(step)
814    }
815
816    /// Advance the insertion by `dt` seconds; `true` while another frame is
817    /// needed. The app calls it once per frame.
818    pub fn instructions_insertion_tick(&mut self, dt: f64) -> bool {
819        let Some(insertion) = self.instructions_insertion.as_mut() else { return false };
820        if self.pmi_active_view.as_deref() != Some(insertion.view.as_str()) {
821            self.instructions_insertion = None;
822            return false;
823        }
824        if insertion.held {
825            return false;
826        }
827        insertion.fraction = (insertion.fraction + dt.max(0.0) / INSERTION_SECONDS).min(1.0);
828        let done = insertion.fraction >= 1.0;
829        if done {
830            self.instructions_insertion = None;
831        }
832        self.pmi_apply_explode();
833        self.refresh_pmi_overlay();
834        !done
835    }
836
837    /// The insertion's display poses for [`Self::pmi_apply_explode`]:
838    /// `(solids, translate, rotate°, scale, centre)`, empty unless the view
839    /// it plays in is the one shown.
840    pub(crate) fn instructions_insertion_poses(&self) -> Vec<(Vec<String>, [f64; 3], [f64; 3], [f64; 3], [f64; 3])> {
841        let Some(insertion) = &self.instructions_insertion else { return Vec::new() };
842        if self.pmi_active_view.as_deref() != Some(insertion.view.as_str()) {
843            return Vec::new();
844        }
845        let left = 1.0 - insertion.fraction;
846        insertion
847            .moves
848            .iter()
849            .map(|(names, offset)| (names.clone(), offset.map(|v| v * left), [0.0; 3], [1.0; 3], [0.0; 3]))
850            .collect()
851    }
852
853    /// The insertion's arrows while it plays, in world space: for each
854    /// moving group of parts, from the edge of its screen outline where it is
855    /// now (on its centre line, on the side it travels toward) to where that
856    /// point lands at home (its seat), so the arrow never crosses the parts,
857    /// shortens as they come in and is gone once they are seated.
858    pub(crate) fn instructions_insertion_arrows(&self) -> Vec<([f64; 3], [f64; 3])> {
859        let Some(insertion) = &self.instructions_insertion else { return Vec::new() };
860        if self.pmi_active_view.as_deref() != Some(insertion.view.as_str()) {
861            return Vec::new();
862        }
863        let left = 1.0 - insertion.fraction;
864        insertion
865            .moves
866            .iter()
867            .filter_map(|(names, offset)| {
868                let mut bbox = crate::camera::Aabb::empty();
869                for name in names {
870                    let home = self.pmi_explode_originals.get(name).or_else(|| self.scene.solid(name));
871                    if let Some(display) = home {
872                        bbox.union(&display.bbox);
873                    }
874                }
875                self.outline_arrow(&bbox, offset.map(|v| v * left))
876            })
877            .collect()
878    }
879
880    /// Hold the playing insertion at `fraction` (clamped to 0..1) until
881    /// another step is played. Nothing when no part is moving.
882    pub fn instructions_hold_insertion(&mut self, fraction: f64) {
883        let Some(insertion) = self.instructions_insertion.as_mut() else { return };
884        insertion.fraction = if fraction.is_finite() { fraction.clamp(0.0, 1.0) } else { 0.0 };
885        insertion.held = true;
886        self.pmi_apply_explode();
887        self.refresh_pmi_overlay();
888    }
889
890    /// An insertion or explode arrow for parts whose bounds at home are
891    /// `home` and which stand `now` away from home: from the edge of their
892    /// screen outline (on their centre line, on the side they travel toward)
893    /// to where that point lands at home, so it never crosses the parts and
894    /// is exactly the travel left. `None` with nothing left to travel.
895    pub(crate) fn outline_arrow(&self, home: &crate::camera::Aabb, now: [f64; 3]) -> Option<([f64; 3], [f64; 3])> {
896        let length = (now[0] * now[0] + now[1] * now[1] + now[2] * now[2]).sqrt();
897        if home.is_empty() || !(length > 1e-9) {
898            return None;
899        }
900        // The travel direction, toward home.
901        let d = now.map(|v| -v / length);
902        let (c, size) = (home.center(), home.size());
903        let h = 0.5 * (size[0] * d[0].abs() + size[1] * d[1].abs() + size[2] * d[2].abs());
904        let (right, up, _) = self.camera.basis();
905        let screen = |p: [f64; 3]| [crate::geometry3d::dot3(p, right), crate::geometry3d::dot3(p, up)];
906        let sd = screen(d);
907        let sl = (sd[0] * sd[0] + sd[1] * sd[1]).sqrt();
908        let mut lead = h;
909        if sl > 1e-9 {
910            let u = [sd[0] / sl, sd[1] / sl];
911            let along = |p: [f64; 3]| {
912                let s = screen(p);
913                s[0] * u[0] + s[1] * u[1]
914            };
915            let reach = (0..8)
916                .map(|i| {
917                    along([
918                        if i & 1 == 0 { home.min[0] } else { home.max[0] },
919                        if i & 2 == 0 { home.min[1] } else { home.max[1] },
920                        if i & 4 == 0 { home.min[2] } else { home.max[2] },
921                    ])
922                })
923                .fold(f64::NEG_INFINITY, f64::max);
924            lead = ((reach - along(c)) / sl).max(h);
925        }
926        let seat: [f64; 3] = std::array::from_fn(|k| c[k] + d[k] * lead);
927        Some((std::array::from_fn(|k| seat[k] + now[k]), seat))
928    }
929
930    /// How far the shown step's parts have come home (0..1), while they move.
931    pub fn instructions_insertion_fraction(&self) -> Option<f64> {
932        self.instructions_insertion.as_ref().map(|insertion| insertion.fraction)
933    }
934
935    /// The step whose view is shown.
936    pub fn instructions_shown_step(&self) -> Option<String> {
937        let active = self.pmi_active_view()?;
938        let block = self.instructions().ok().flatten()?;
939        let step = block.steps().find(|(_, step)| step.view.as_deref() == Some(active)).map(|(_, step)| step.id.clone());
940        step
941    }
942
943    /// The presentation of the step whose view is shown, over the scene's
944    /// solids.
945    fn instructions_shown_presentation(&self) -> Option<edit::Presentation> {
946        let active = self.pmi_active_view()?;
947        let block = self.instructions().ok().flatten()?;
948        let (_, step) = block.steps().find(|(_, step)| step.view.as_deref() == Some(active))?;
949        let solids: Vec<String> = self.scene.solids().iter().map(|solid| solid.name.clone()).collect();
950        edit::presentation(&block, &step.id, &solids).ok()
951    }
952
953    /// What automation and the panes read: the block, the step being shown,
954    /// the steps left empty and the step count.
955    pub fn instructions_state_json(&self) -> Value {
956        match self.instructions() {
957            Ok(Some(block)) => {
958                let shown = self.pmi_active_view().and_then(|view| {
959                    block.steps().find(|(_, s)| s.view.as_deref() == Some(view)).map(|(_, s)| s.id.clone())
960                });
961                json!({
962                    "instructions": block.to_value(),
963                    "steps": block.step_count(),
964                    "wizardPending": self.instructions_wizard_pending,
965                    "viewsPending": self.instructions_views_pending,
966                    "shownStep": shown,
967                    "ghosted": self.context_scene.solids().iter().map(|ghost| ghost.name.as_str()).collect::<Vec<_>>(),
968                    "insertion": self.instructions_insertion_fraction(),
969                    "emptySteps": edit::empty_steps(&block),
970                    "unplanned": self.instructions_unplanned().into_iter().map(|(occurrence, part)| json!({ "occurrence": occurrence, "part": part })).collect::<Vec<_>>(),
971                    "wrapper": self.instructions_wrapper().ok().map(|(id, part)| json!({ "id": id, "part": part })),
972                })
973            }
974            Ok(None) => json!({ "instructions": null }),
975            Err(error) => json!({ "instructions": null, "error": error }),
976        }
977    }
978}
979
980/// The start offset of a Line art step's derived insertion (see
981/// [`EngineState::instructions_step_arrows`]): straight up the view's `up`
982/// by twice the added parts' height along it, and at least a third of
983/// everything the step shows, so a flat part still travels visibly. `None`
984/// when the step places nothing over anything (its first part just
985/// appears) or adds nothing drawn.
986pub(crate) fn default_start_offset(
987    presentation: &edit::Presentation,
988    boxes: &std::collections::HashMap<String, crate::camera::Aabb>,
989    up: [f64; 3],
990) -> Option<[f64; 3]> {
991    if presentation.placed.is_empty() || presentation.adding.is_empty() {
992        return None;
993    }
994    let length = (up[0] * up[0] + up[1] * up[1] + up[2] * up[2]).sqrt();
995    if !(length > 1e-12) {
996        return None;
997    }
998    let up = up.map(|v| v / length);
999    let union = |names: &[String]| {
1000        let mut bbox = crate::camera::Aabb::empty();
1001        for name in names {
1002            if let Some(solid) = boxes.get(name) {
1003                bbox.union(solid);
1004            }
1005        }
1006        bbox
1007    };
1008    let adding = union(&presentation.adding);
1009    if adding.is_empty() {
1010        return None;
1011    }
1012    let mut shown = union(&presentation.placed);
1013    shown.union(&adding);
1014    let along = |b: &crate::camera::Aabb| {
1015        let size = b.size();
1016        size[0] * up[0].abs() + size[1] * up[1].abs() + size[2] * up[2].abs()
1017    };
1018    let size = shown.size();
1019    let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
1020    let distance = (2.0 * along(&adding)).max(diagonal / 3.0);
1021    (distance.is_finite() && distance > 0.0).then(|| up.map(|v| v * distance))
1022}
1023
1024/// A Line art step's derived arrows: one per occurrence the step and its
1025/// group add, each starting [`default_start_offset`] away.
1026pub(crate) fn default_arrows(
1027    step: &Step,
1028    presentation: &edit::Presentation,
1029    boxes: &std::collections::HashMap<String, crate::camera::Aabb>,
1030    up: [f64; 3],
1031) -> Vec<Arrow> {
1032    let Some(offset) = default_start_offset(presentation, boxes, up) else { return Vec::new() };
1033    let distance = (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2]).sqrt();
1034    let mut occurrences = Vec::new();
1035    step.occurrences_with_descendants(&mut occurrences);
1036    // A sub-assembly's occurrence stands for its parts: one arrow for it.
1037    occurrences
1038        .iter()
1039        .filter(|occurrence| !occurrences.iter().any(|other| other != *occurrence && occurrence.starts_with(&format!("{other}:"))))
1040        .map(|occurrence| Arrow { occurrence: occurrence.to_string(), direction: offset.map(|v| -v / distance), distance })
1041        .collect()
1042}