Skip to main content

brep_render/engine_state/
pmi_ops.rs

1//! PMI — the engine half of the PMI workbench: the document's `pmi` block
2//! (checkpointed edits + re-run, like `wire_harness_ops`), the ACTIVE view
3//! (applied camera / visibility / wireframe / explode poses, restored
4//! exactly on deactivate), annotation CRUD, the coalesced label drag, and the
5//! reference picker's PMI flavour.
6//!
7//! Persisted vs engine memory: the `pmi` block (views, annotations, label
8//! positions) lives in the document and rides its undo stack; WHICH view is
9//! active and WHICH annotation's form is open are engine memory — a mode,
10//! not model state — so a rerun, a save or an undo never churns on them.
11//!
12//! A label drag is the one high-frequency PMI edit: it writes the block with
13//! a coalesced checkpoint (`pmi:label:{id}` — one undo step per drag) and
14//! NEVER re-runs the history: the cached report's label position is patched
15//! locally and the overlay re-baked. Every other mutation (a view capture,
16//! an annotation add / edit / remove) is its own undo step followed by a
17//! re-run, whose tail re-resolves the annotations.
18
19use super::*;
20use brep_kernel::{
21    PmiAnnotation, PmiCamera, PmiDisplay, PmiGeometry, PmiProjection, PmiReport, PmiState,
22    PmiStatus, PmiView,
23};
24use crate::view::Projection;
25
26/// The modeling state remembered while the PMI workbench is active.
27#[derive(Debug, Clone)]
28pub struct PmiModelingSnapshot {
29    pub camera_json: String,
30    pub hidden: Vec<String>,
31    pub wireframe: bool,
32}
33
34/// A patch the panel applies to one view's name / display state.
35#[derive(Debug, Default, Clone, PartialEq)]
36pub struct PmiViewPatch {
37    pub name: Option<String>,
38    pub text_size_pt: Option<f64>,
39    pub wireframe: Option<bool>,
40    pub hidden: Option<Vec<String>>,
41    pub section: Option<Option<brep_kernel::feature_pipeline::pmi::PmiSection>>,
42}
43
44/// The **PMI view** dialog's schema — what a view's own form edits: its name,
45/// the text size its labels draw at and whether it shows the model in
46/// wireframe, plus the two re-capture actions its row menu also offers. Shaped
47/// like every other object schema so the shared form view draws it;
48/// [`EngineState::pmi_update_view`] applies what it edits.
49pub fn pmi_view_schema() -> serde_json::Value {
50    serde_json::json!({
51        "type": "pmiView",
52        "shortName": "VIEW",
53        "longName": "PMI view",
54        "inputParamsSchema": {
55            "name": { "type": "string", "label": "Name", "default_value": "View 1" },
56            "textSizePt": { "type": "number", "label": "Text size (pt)", "default_value": brep_kernel::PmiDisplay::default().text_size_pt },
57            "wireframe": { "type": "boolean", "label": "Wireframe", "default_value": false },
58            "sectionEnabled": { "type": "boolean", "label": "Section view", "default_value": false },
59            "sectionAxis": { "type": "options", "label": "Section normal", "options": ["View", "X", "Y", "Z", "Plane"], "default_value": "View" },
60            "sectionPlane": {
61                "type": "reference_selection", "label": "Section plane", "selectionFilter": ["FACE", "PLANE"], "multiple": false,
62                "minSelections": 0, "maxSelections": 1, "default_value": null,
63                "hint": "A planar face, datum plane or sketch plane to cut on (Section normal: Plane); the offset moves the cut along its normal"
64            },
65            "sectionOffset": { "type": "number", "label": "Section offset (mm)", "default_value": 0.0 },
66            "sectionFlip": { "type": "boolean", "label": "Reverse section", "default_value": false },
67            "halfAxis": {
68                "type": "options", "label": "Half section", "options": ["None", "View", "X", "Y", "Z"], "default_value": "None",
69                "hint": "A second plane through the section's origin: material is removed only where both planes remove it (a quarter cut away)"
70            },
71            "halfOffset": { "type": "number", "label": "Half section offset (mm)", "default_value": 0.0 },
72            "halfFlip": { "type": "boolean", "label": "Reverse half section", "default_value": false },
73            "sectionExclude": {
74                "type": "reference_selection", "label": "Not cut", "selectionFilter": ["SOLID", "COMPONENT"], "multiple": true,
75                "minSelections": 0, "maxSelections": 64, "default_value": [],
76                "hint": "Solids or components the section leaves whole, such as shafts and fasteners"
77            },
78            "updateCamera": { "type": "button", "label": "Update camera from the viewport" },
79            "hideSelected": { "type": "button", "label": "Hide the selected bodies in this view" }
80        }
81    })
82}
83
84/// The further planes a view's section edit asks for: a whole `sectionPlanes`
85/// list replaces them, then the form's `half*` rows set (or, `None`, drop)
86/// the first; what neither names is kept. A frozen `View` normal is kept for
87/// a plane that stays `View`.
88fn section_planes_from(
89    params: &serde_json::Value,
90    prior: Option<&brep_kernel::feature_pipeline::pmi::PmiSection>,
91) -> Result<Vec<brep_kernel::feature_pipeline::pmi::PmiSectionPlane>, String> {
92    use brep_kernel::feature_pipeline::pmi::PmiSectionPlane;
93    let prior_planes = prior.map(|s| s.planes.clone()).unwrap_or_default();
94    let valid = |plane: &PmiSectionPlane| ["View", "X", "Y", "Z"].contains(&plane.axis.as_str()) && plane.offset.is_finite();
95    let mut planes = match params.get("sectionPlanes") {
96        Some(serde_json::Value::Null) => Vec::new(),
97        Some(list) => {
98            let planes: Vec<PmiSectionPlane> = serde_json::from_value(list.clone()).map_err(|e| format!("invalid section planes: {e}"))?;
99            if !planes.iter().all(valid) {
100                return Err("invalid section plane".into());
101            }
102            planes
103        }
104        None => prior_planes,
105    };
106    if let Some(axis) = params.get("halfAxis").and_then(serde_json::Value::as_str) {
107        if axis == "None" {
108            if !planes.is_empty() {
109                planes.remove(0);
110            }
111        } else {
112            let before = planes.first();
113            let plane = PmiSectionPlane {
114                axis: axis.to_string(),
115                offset: params.get("halfOffset").and_then(serde_json::Value::as_f64).or(before.map(|p| p.offset)).unwrap_or(0.),
116                flip: params.get("halfFlip").and_then(serde_json::Value::as_bool).or(before.map(|p| p.flip)).unwrap_or(false),
117                normal: before.filter(|p| p.axis == axis).and_then(|p| p.normal),
118            };
119            if !valid(&plane) {
120                return Err("invalid section plane".into());
121            }
122            match planes.first_mut() {
123                Some(first) => *first = plane,
124                None => planes.push(plane),
125            }
126        }
127    }
128    // A View plane with no normal takes the captured direction in `freeze`.
129    Ok(planes)
130}
131
132/// A view's current values in [`pmi_view_schema`]'s shape — the form's seed.
133pub fn pmi_view_params(view: &brep_kernel::PmiView) -> serde_json::Value {
134    serde_json::json!({
135        "name": view.name,
136        "textSizePt": view.display.text_size_pt,
137        "wireframe": view.display.wireframe,
138        "sectionEnabled": view.display.section.is_some(),
139        "sectionAxis": view.display.section.as_ref().map(|s| s.axis.as_str()).unwrap_or("View"),
140        "sectionOffset": view.display.section.as_ref().map(|s| s.offset).unwrap_or(0.),
141        "sectionFlip": view.display.section.as_ref().is_some_and(|s| s.flip),
142        "sectionPlane": view.display.section.as_ref().and_then(|s| s.plane.as_deref()).unwrap_or(""),
143        "sectionExclude": view.display.section.as_ref().map(|s| s.exclude.clone()).unwrap_or_default(),
144        "halfAxis": view.display.section.as_ref().and_then(|s| s.planes.first()).map(|p| p.axis.as_str()).unwrap_or("None"),
145        "halfOffset": view.display.section.as_ref().and_then(|s| s.planes.first()).map(|p| p.offset).unwrap_or(0.),
146        "halfFlip": view.display.section.as_ref().and_then(|s| s.planes.first()).is_some_and(|p| p.flip),
147    })
148}
149
150/// `{solid}@x,y,z` in WORLD coordinates (the PMI vertex-ref convention).
151pub fn world_vertex_ref(solid: &str, position: [f64; 3]) -> String {
152    let trim = |v: f64| {
153        let rounded = (v * 1e9).round() / 1e9;
154        let trimmed = crate::formatting::compact_decimal(rounded, 9);
155        if trimmed == "-0" || trimmed.is_empty() { "0".to_string() } else { trimmed }
156    };
157    format!("{solid}@{},{},{}", trim(position[0]), trim(position[1]), trim(position[2]))
158}
159
160impl EngineState {
161    // --- read surface ------------------------------------------------------
162
163    /// The document's `pmi` block as typed state (the default — no views —
164    /// when the document carries none).
165    pub fn pmi_state(&self) -> PmiState {
166        self.history
167            .pmi_block()
168            .and_then(|block| serde_json::from_value(block.clone()).ok())
169            .unwrap_or_default()
170    }
171
172    /// [`Self::pmi_state`] parsed once per history revision and shared: what
173    /// a frame reads (an animation frame's section check and overlay), where
174    /// parsing the whole block each time cost ~14 ms a parse on a 500-step
175    /// view.
176    pub fn pmi_state_shared(&self) -> std::rc::Rc<PmiState> {
177        let revision = self.history.revision();
178        if let Some((at, state)) = self.pmi_state_cache.borrow().as_ref() {
179            if *at == revision {
180                return state.clone();
181            }
182        }
183        let state = std::rc::Rc::new(self.pmi_state());
184        *self.pmi_state_cache.borrow_mut() = Some((revision, state.clone()));
185        state
186    }
187
188    /// The PMI report of the last APPLIED run.
189    pub fn pmi_report(&self) -> Option<&PmiReport> {
190        self.pmi_report.as_ref()
191    }
192
193    pub fn pmi_active_view(&self) -> Option<&str> {
194        self.pmi_active_view.as_deref()
195    }
196
197    pub fn pmi_open_annotation(&self) -> Option<&str> {
198        self.pmi_open_annotation.as_deref()
199    }
200
201    /// The view whose dialog the PMI pane shows, if one is open.
202    pub fn pmi_open_view(&self) -> Option<&str> {
203        self.pmi_open_view.as_deref()
204    }
205
206    /// How many PMI dialogs — annotation or view — this engine has OPENED. The
207    /// app's dialog door reads it beside the open subject so a re-open of the
208    /// dialog already open is still an open (the id alone does not move).
209    /// Monotonic and never reset; a CLOSE does not count.
210    pub fn pmi_dialog_opens(&self) -> u64 {
211        self.pmi_dialog_opens
212    }
213
214    /// **The PMI half of the dialog door's counter.** Open `id`'s form and
215    /// count the open. EVERY path that opens an annotation form goes through
216    /// here — the panel's `+`, its row Edit and double-click, the viewport
217    /// label double click, the automation add — so the door has one signal to watch
218    /// and a re-open of the same annotation still reads as an open. Assigning
219    /// `pmi_open_annotation = Some(..)` anywhere else is the bug this exists to
220    /// prevent: the form would open with its pane still behind another tab.
221    ///
222    /// An open annotation is the selected one too — the dialog is about it —
223    /// and it replaces an open VIEW dialog, since the pane shows one dialog.
224    fn open_pmi_annotation(&mut self, id: String) {
225        self.pmi_open_view = None;
226        self.pmi_selected_view = None;
227        self.pmi_selected_annotation = Some(id.clone());
228        self.pmi_open_annotation = Some(id);
229        self.pmi_dialog_opens = self.pmi_dialog_opens.wrapping_add(1);
230    }
231
232    /// The view half of [`Self::open_pmi_annotation`]: open view `id`'s dialog,
233    /// closing an open annotation's, select the view and count the open.
234    fn open_pmi_view(&mut self, id: String) {
235        self.pmi_open_annotation = None;
236        if self.pmi_selected_annotation.take().is_some() {
237            self.refresh_pmi_overlay();
238        }
239        self.pmi_selected_view = Some(id.clone());
240        self.pmi_open_view = Some(id);
241        self.pmi_dialog_opens = self.pmi_dialog_opens.wrapping_add(1);
242    }
243
244    /// The annotation selected in the PMI tree (or opened), while it exists.
245    /// Asked every frame (the tree's highlight), so it reads the shared parse.
246    pub fn pmi_selected_annotation(&self) -> Option<&str> {
247        let id = self.pmi_selected_annotation.as_deref()?;
248        self.pmi_state_shared().find_annotation(id).is_some().then_some(id)
249    }
250
251    /// The view selected in the PMI tree (or opened), while it exists.
252    pub fn pmi_selected_view(&self) -> Option<&str> {
253        let id = self.pmi_selected_view.as_deref()?;
254        self.pmi_state_shared().find_view(id).is_some().then_some(id)
255    }
256
257    /// Whether the PMI workbench remembered a modeling state (it is "entered").
258    pub fn pmi_workbench_entered(&self) -> bool {
259        self.pmi_modeling.is_some()
260    }
261
262    /// The `__brepPmi` verifier global: the block, the report, the active
263    /// view, the open annotation and the datum letters, as one object.
264    pub fn pmi_state_json(&self) -> String {
265        self.pmi_state_value().to_string()
266    }
267
268    /// [`Self::pmi_state_json`] as a JSON value.
269    pub fn pmi_state_value(&self) -> serde_json::Value {
270        let state = self.pmi_state();
271        serde_json::json!({
272            "views": state.views,
273            "idCounter": state.id_counter,
274            "activeView": self.pmi_active_view,
275            "openAnnotation": self.pmi_open_annotation,
276            "openView": self.pmi_open_view,
277            "selectedAnnotation": self.pmi_selected_annotation(),
278            "selectedView": self.pmi_selected_view(),
279            "entered": self.pmi_modeling.is_some(),
280            "datums": state.datum_letters(),
281            "report": self.pmi_report,
282        })
283    }
284
285    // --- block writes --------------------------------------------------------
286
287    /// Write `state` as the document's block (checkpointed) and re-run the
288    /// history so the tail resolves it. An empty block is removed so a part
289    /// that never had PMI saves byte-identically.
290    pub(super) fn write_pmi_state(&mut self, state: PmiState) -> String {
291        let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
292        self.history.set_pmi_block(block, None);
293        self.rerun_history()
294    }
295
296    // --- views ---------------------------------------------------------------
297
298    /// The current camera as a snapshot.
299    pub(super) fn snapshot_camera(&self) -> PmiCamera {
300        let camera = &self.camera;
301        PmiCamera {
302            eye: camera.eye,
303            target: camera.target,
304            up: camera.up,
305            projection: match camera.projection {
306                Projection::Orthographic { half_height } => PmiProjection::Orthographic { half_height },
307                Projection::Perspective { fov_y_deg } => PmiProjection::Perspective { fov_y_deg },
308            },
309            viewport: [camera.width, camera.height],
310        }
311    }
312
313    /// The names of the solids currently hidden in the scene.
314    fn hidden_solid_names(&self) -> Vec<String> {
315        // A committed sketch's visibility is its own (`hidden_sketches`),
316        // never a view's: a sketch name is no solid a hide step resolves.
317        self.scene
318            .solids()
319            .iter()
320            .filter(|solid| !solid.visible && !solid.is_sketch)
321            .map(|solid| solid.name.clone())
322            .collect()
323    }
324
325    /// Capture a view: the current camera, hidden objects (its baseline hide
326    /// step) and wireframe setting, named `name` (or `View N`). The new view
327    /// becomes active.
328    pub fn pmi_capture_view(&mut self, name: Option<&str>) -> String {
329        let mut state = self.pmi_state();
330        let id = state.next_id("VIEW");
331        let name = name
332            .map(str::trim)
333            .filter(|n| !n.is_empty())
334            .map(String::from)
335            .unwrap_or_else(|| format!("View {}", state.views.len() + 1));
336        // The capture reads the MODELING state: if another view is active its
337        // applied state is what is on screen, so deactivate first (restores)
338        // — a view captures what the user set up, not another view's snapshot.
339        let was_active = self.pmi_active_view.is_some();
340        if was_active {
341            self.pmi_deactivate_view();
342        }
343        self.pmi_remember_modeling();
344        state.views.push(PmiView {
345            id: id.clone(),
346            name,
347            camera: Some(self.snapshot_camera()),
348            display: PmiDisplay::new(12.0, self.settings.wireframe, None),
349            annotations: Vec::new(),
350        });
351        state.set_view_baseline(&id, &self.hidden_solid_names());
352        self.write_pmi_state(state);
353        let _ = self.pmi_activate_view(&id);
354        id
355    }
356
357    pub fn pmi_rename_view(&mut self, id: &str, name: &str) -> Result<(), String> {
358        let mut state = self.pmi_state();
359        let view = state.find_view_mut(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
360        let name = name.trim();
361        if name.is_empty() {
362            return Err("a view needs a name".into());
363        }
364        view.name = name.to_string();
365        self.write_pmi_state(state);
366        Ok(())
367    }
368
369    /// Delete a view (and its annotations). An active view deactivates first.
370    pub fn pmi_delete_view(&mut self, id: &str) -> Result<(), String> {
371        let mut state = self.pmi_state();
372        let before = state.views.len();
373        if self.pmi_active_view.as_deref() == Some(id) {
374            self.pmi_deactivate_view();
375        }
376        state.views.retain(|view| view.id != id);
377        if state.views.len() == before {
378            return Err(format!("no PMI view '{id}'"));
379        }
380        if let Some(open) = &self.pmi_open_annotation {
381            if state.find_annotation(open).is_none() {
382                self.pmi_open_annotation = None;
383            }
384        }
385        if self.pmi_open_view.as_deref() == Some(id) {
386            self.pmi_open_view = None;
387        }
388        self.write_pmi_state(state);
389        Ok(())
390    }
391
392    /// Re-capture a view's camera from the current camera (the explicit
393    /// Update Camera action — orbiting never rewrites a snapshot silently).
394    pub fn pmi_update_view_camera(&mut self, id: &str) -> Result<(), String> {
395        // A step's view: the capture is the step's author camera, kept
396        // through the step edits that regenerate a generated one. The step
397        // stays shown as the user had it (the capture rebuilds the views,
398        // which puts every view away).
399        if let Some(step) = self.instructions_step_of_view(id) {
400            let shown = self.instructions_shown_step().as_deref() == Some(step.as_str());
401            self.instructions_capture_camera(&step)?;
402            if shown {
403                self.instructions_show_step(&step)?;
404            }
405            return Ok(());
406        }
407        let mut state = self.pmi_state();
408        let camera = self.snapshot_camera();
409        let view = state.find_view_mut(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
410        // A section saved before its plane was stored is anchored on the OLD
411        // camera first, so the cut stays where it was.
412        if let Some(section) = view.display.section.as_mut() {
413            section.freeze(view.camera.as_ref());
414        }
415        view.camera = Some(camera);
416        self.write_pmi_state(state);
417        Ok(())
418    }
419
420    /// Hide the selection in view `view_id` (the active view when `None`) as
421    /// a new **hide step** — a `hide` annotation naming the selected
422    /// components (whole placements, a part inside a sub-assembly by its
423    /// occurrence path, as an explode step names them) and any other
424    /// selected solids — tracked, listed, edited and undone like an explode
425    /// step. The step's form opens. Refused with nothing selected.
426    pub fn pmi_hide_selected(&mut self, view_id: Option<&str>) -> Result<String, String> {
427        let mut targets = self.selected_occurrences();
428        for solid in &self.emphasis.selected_solids {
429            if !targets.iter().any(|target| brep_kernel::entry_names_solid(target, solid)) {
430                targets.push(solid.clone());
431            }
432        }
433        if targets.is_empty() {
434            return Err("select the solids or components to hide".into());
435        }
436        let params = serde_json::json!({ "targets": targets }).to_string();
437        self.pmi_add_annotation(view_id, "hide", &params)
438    }
439
440    /// Patch a view's name / display state. The active view re-applies.
441    pub fn pmi_set_view_display(&mut self, id: &str, patch: &PmiViewPatch) -> Result<(), String> {
442        self.patch_pmi_view(id, patch, None)
443    }
444
445    /// Apply an edited **PMI view** form ([`pmi_view_schema`]'s params). Only
446    /// the fields that differ from the view are written, and a run of edits to
447    /// one view is ONE undo step — the form writes on every keystroke of the
448    /// name, the way an annotation's form does.
449    pub fn pmi_update_view(&mut self, id: &str, params_json: &str) -> Result<(), String> {
450        let params: serde_json::Value =
451            serde_json::from_str(params_json).map_err(|error| format!("view params: {error}"))?;
452        let state = self.pmi_state();
453        let view = state.find_view(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
454        let name = params
455            .get("name")
456            .and_then(serde_json::Value::as_str)
457            .filter(|name| *name != view.name)
458            .map(String::from);
459        let text_size_pt = params
460            .get("textSizePt")
461            .and_then(serde_json::Value::as_f64)
462            .filter(|size| *size != view.display.text_size_pt);
463        let wireframe = params
464            .get("wireframe")
465            .and_then(serde_json::Value::as_bool)
466            .filter(|on| *on != view.display.wireframe);
467        let section = if params.get("sectionEnabled").is_some() {
468            let section = if params["sectionEnabled"].as_bool().unwrap_or(false) {
469                let axis = params["sectionAxis"].as_str().unwrap_or("View");
470                let offset = params["sectionOffset"].as_f64().unwrap_or(0.);
471                if !["View", "X", "Y", "Z", "Plane"].contains(&axis) || !offset.is_finite() { return Err("invalid section plane".into()); }
472                // The plane keeps the anchor it was set on: a re-captured
473                // camera must not move the cut. A newly chosen View axis takes
474                // the captured direction.
475                let prior = view.display.section.as_ref();
476                let mut section = brep_kernel::feature_pipeline::pmi::PmiSection {
477                    axis: axis.into(),
478                    offset,
479                    flip: params["sectionFlip"].as_bool().unwrap_or(false),
480                    origin: prior.and_then(|s| s.origin),
481                    normal: prior.filter(|s| s.axis == axis).and_then(|s| s.normal),
482                    // The picked face or plane (`Plane`): the run resolves it.
483                    plane: match params.get("sectionPlane") {
484                        Some(serde_json::Value::String(name)) => Some(name.trim().to_string()).filter(|name| !name.is_empty()),
485                        Some(serde_json::Value::Null) => None,
486                        _ => prior.and_then(|s| s.plane.clone()),
487                    },
488                    exclude: match params.get("sectionExclude") {
489                        Some(serde_json::Value::Array(names)) => names
490                            .iter()
491                            .filter_map(serde_json::Value::as_str)
492                            .map(|name| name.trim().to_string())
493                            .filter(|name| !name.is_empty())
494                            .collect(),
495                        Some(serde_json::Value::Null) => Vec::new(),
496                        _ => prior.map(|s| s.exclude.clone()).unwrap_or_default(),
497                    },
498                    planes: section_planes_from(&params, prior)?,
499                };
500                section.freeze(view.camera.as_ref());
501                Some(section)
502            } else { None };
503            (section != view.display.section).then_some(section)
504        } else { None };
505        if name.is_none() && text_size_pt.is_none() && wireframe.is_none() && section.is_none() {
506            return Ok(());
507        }
508        let patch = PmiViewPatch { name, text_size_pt, wireframe, hidden: None, section };
509        self.patch_pmi_view(id, &patch, Some(&format!("pmi:view:{id}")))
510    }
511
512    /// Set view `id`'s section the way its form's section rows do, as ONE
513    /// undo step: `enabled` turns it on or off, and each field left `None`
514    /// keeps what the section has (or the form's default: `View`, offset 0,
515    /// not reversed, no plane, nothing left whole, no further planes).
516    /// `planes` replaces the further planes (a half or corner cut away;
517    /// empty: none). The plane is anchored as the form anchors it
518    /// ([`Self::pmi_update_view`]).
519    #[allow(clippy::too_many_arguments)]
520    pub fn pmi_set_view_section(
521        &mut self,
522        id: &str,
523        enabled: bool,
524        axis: Option<&str>,
525        offset: Option<f64>,
526        flip: Option<bool>,
527        plane: Option<&str>,
528        exclude: Option<Vec<String>>,
529        planes: Option<Vec<brep_kernel::feature_pipeline::pmi::PmiSectionPlane>>,
530    ) -> Result<(), String> {
531        let state = self.pmi_state();
532        let view = state.find_view(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
533        let prior = view.display.section.as_ref();
534        // A plane named with no axis cuts on that plane, as picking one in
535        // the form does.
536        let axis = axis.or_else(|| plane.filter(|plane| !plane.trim().is_empty()).map(|_| "Plane"));
537        let mut params = serde_json::json!({
538            "sectionEnabled": enabled,
539            "sectionAxis": axis.map(str::to_string).or_else(|| prior.map(|s| s.axis.clone())).unwrap_or_else(|| "View".into()),
540            "sectionOffset": offset.or_else(|| prior.map(|s| s.offset)).unwrap_or(0.),
541            "sectionFlip": flip.or_else(|| prior.map(|s| s.flip)).unwrap_or(false),
542        });
543        if let Some(plane) = plane {
544            params["sectionPlane"] = plane.into();
545        }
546        if let Some(exclude) = exclude {
547            params["sectionExclude"] = exclude.into();
548        }
549        if let Some(planes) = planes {
550            params["sectionPlanes"] = serde_json::to_value(planes).map_err(|e| e.to_string())?;
551        }
552        self.history.break_coalescing();
553        let result = self.pmi_update_view(id, &params.to_string());
554        self.history.break_coalescing();
555        result
556    }
557
558    /// Open view `id`'s dialog (`None` closes it). Opening activates the view,
559    /// as opening one of its annotations does: the dialog describes what is on
560    /// screen. An id that names no view opens nothing and counts nothing.
561    pub fn pmi_set_view_open(&mut self, id: Option<&str>) {
562        match id {
563            Some(id) => {
564                if self.pmi_state().find_view(id).is_none() {
565                    return;
566                }
567                if self.pmi_active_view.as_deref() != Some(id) {
568                    let _ = self.pmi_activate_view(id);
569                }
570                self.open_pmi_view(id.to_string());
571            }
572            None => self.pmi_open_view = None,
573        }
574        self.refresh_pmi_overlay();
575    }
576
577    fn patch_pmi_view(&mut self, id: &str, patch: &PmiViewPatch, coalesce_key: Option<&str>) -> Result<(), String> {
578        let mut state = self.pmi_state();
579        let view = state.find_view_mut(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
580        if let Some(name) = &patch.name {
581            let name = name.trim();
582            if name.is_empty() {
583                return Err("a view needs a name".into());
584            }
585            view.name = name.to_string();
586        }
587        if let Some(size) = patch.text_size_pt {
588            view.display.text_size_pt = brep_kernel::clamp_text_size(size);
589        }
590        if let Some(wireframe) = patch.wireframe {
591            view.display.wireframe = wireframe;
592        }
593        if let Some(section) = &patch.section { view.display.section = section.clone(); }
594        if let Some(hidden) = &patch.hidden {
595            let solids = self.view_hideable_names();
596            state.set_view_hidden(id, hidden, &solids);
597        }
598        let view = state.find_view(id).expect("patched above").clone();
599        let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
600        self.history.set_pmi_block(block, coalesce_key);
601        self.rerun_history();
602        if self.pmi_active_view.as_deref() == Some(id) {
603            self.apply_view_display(&view);
604        }
605        Ok(())
606    }
607
608    /// The scene's solid names a view's hide step can name (committed
609    /// sketches excluded: their visibility is `hidden_sketches`).
610    fn view_hideable_names(&self) -> Vec<String> {
611        self.scene.solids().iter().filter(|solid| !solid.is_sketch).map(|solid| solid.name.clone()).collect()
612    }
613
614    /// **A viewport hide or show while a view is active is an edit of that
615    /// view** (one undo step): hiding adds a hide step naming what was not
616    /// hidden yet, showing drops the names from its hide steps (a hidden
617    /// component keeps its other solids hidden). The view then re-applies,
618    /// so leaving and re-entering it shows the same thing, and its sheets,
619    /// 3D PDF and report agree. Names that are no scene solid are ignored.
620    /// False when nothing changed.
621    pub(crate) fn pmi_record_visibility(&mut self, view_id: &str, names: &[String], visible: bool) -> bool {
622        let solids = self.view_hideable_names();
623        let names: Vec<String> = names.iter().filter(|name| solids.contains(name)).cloned().collect();
624        if names.is_empty() {
625            return false;
626        }
627        let mut state = self.pmi_state();
628        let changed = if visible {
629            state.show_in_view(view_id, &names, &solids)
630        } else {
631            state.hide_in_view(view_id, &names).is_some()
632        };
633        if !changed {
634            return false;
635        }
636        let Some(view) = state.find_view(view_id).cloned() else { return false };
637        self.write_pmi_state(state);
638        if self.pmi_active_view.as_deref() == Some(view_id) {
639            self.apply_view_display(&view);
640            self.refresh_pmi_overlay();
641        }
642        true
643    }
644
645    /// Remember the modeling state: what is on screen while NO view is
646    /// active is the modeling state (hiding a body in the workbench before
647    /// capturing is a modeling change, not a view's), so it is re-snapshotted
648    /// whenever an activation starts from no active view; while a view is
649    /// active the snapshot is kept (switching views restores to it).
650    fn pmi_remember_modeling(&mut self) {
651        if self.pmi_active_view.is_none() || self.pmi_modeling.is_none() {
652            self.pmi_modeling = Some(PmiModelingSnapshot {
653                camera_json: self.camera_state_json(),
654                hidden: self.hidden_solid_names(),
655                wireframe: self.settings.wireframe,
656            });
657        }
658    }
659
660    /// Entering the PMI workbench: remember the modeling camera, visibility
661    /// and wireframe so a view activation can be undone exactly.
662    pub fn pmi_enter_workbench(&mut self) {
663        self.pmi_remember_modeling();
664    }
665
666    /// Leaving the PMI workbench: deactivate the view (restoring the modeling
667    /// state), close its dialogs, and forget the snapshot. An open SHEET is
668    /// not this workbench's to close — sheets are the Drawing workbench's, and
669    /// the shell closes the paper when a workbench hides the Sheets pane.
670    pub fn pmi_leave_workbench(&mut self) {
671        self.pmi_deactivate_view();
672        self.pmi_modeling = None;
673        self.pmi_open_annotation = None;
674        self.pmi_open_view = None;
675        self.refresh_pmi_overlay();
676    }
677
678    /// Apply a view's visibility and wireframe: a solid is hidden when the
679    /// view `hides` it — its captured baseline or one of its hide steps
680    /// (`PmiView::hides`, by name, so a step just added, edited, removed or
681    /// undone takes effect without waiting on a run).
682    fn apply_view_display(&mut self, view: &PmiView) {
683        let display = &view.display;
684        let names: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
685        for name in names {
686            let hidden = view.hides(&name);
687            if let Some(original) = self.pmi_explode_originals.get_mut(&name).filter(|original| original.visible == hidden) {
688                original.visible = !hidden;
689                self.pmi_unposed_visibility += 1;
690            }
691            if hidden {
692                // Hidden by the view: restoring the section must not show it.
693                self.pmi_section_hidden.retain(|hidden| *hidden != name);
694                self.scene.set_visible(&name, false);
695            } else if !self.pmi_section_hidden.contains(&name) {
696                // A solid the section removes whole stays hidden.
697                self.scene.set_visible(&name, true);
698            }
699        }
700        if self.settings.wireframe != display.wireframe {
701            self.settings.wireframe = display.wireframe;
702            self.settings_generation = self.settings_generation.wrapping_add(1);
703        }
704        self.dirty = true;
705    }
706
707    /// Activate a view: apply its camera (refit to the live viewport), hidden
708    /// names, wireframe and explode poses; its annotations become the drawn
709    /// and editable set.
710    pub fn pmi_activate_view(&mut self, id: &str) -> Result<(), String> {
711        let state = self.pmi_state();
712        let view = state.find_view(id).ok_or_else(|| format!("no PMI view '{id}'"))?.clone();
713        if self.pmi_active_view.as_deref() != Some(id) {
714            // Switching views keeps the snapshot: deactivating restores the
715            // modeling state, and the new view starts from it.
716            let keep = self.pmi_active_view.is_some();
717            self.pmi_deactivate_view();
718            if !keep {
719                self.pmi_remember_modeling();
720            }
721        }
722        if self.pmi_modeling.is_none() {
723            self.pmi_remember_modeling();
724        }
725        if self.pmi_active_view.as_deref() != Some(id) {
726            // A view activated afresh shows every explode step at full.
727            self.explode_playback = Default::default();
728        }
729        self.pmi_active_view = Some(id.to_string());
730        if let Some(camera) = &view.camera {
731            let (kind, scale) = match camera.projection {
732                PmiProjection::Orthographic { half_height } => ("orthographic", half_height),
733                PmiProjection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
734            };
735            let json = serde_json::json!({
736                "kind": kind, "eye": camera.eye, "target": camera.target, "up": camera.up, "scale": scale,
737            })
738            .to_string();
739            let _ = self.apply_camera_state_json(&json);
740            self.controls_sync_after_camera_apply();
741        }
742        self.apply_view_display(&view);
743        self.pmi_apply_explode();
744        self.instructions_refresh_ghosts();
745        if let Some(open) = &self.pmi_open_annotation {
746            if !view.annotations.iter().any(|a| a.id() == open) {
747                self.pmi_open_annotation = None;
748            }
749        }
750        self.refresh_pmi_overlay();
751        Ok(())
752    }
753
754    /// Deactivate the active view: restore the explode poses, the modeling
755    /// visibility, wireframe and camera. A no-op without an active view.
756    pub fn pmi_deactivate_view(&mut self) {
757        if self.pmi_active_view.take().is_none() {
758            return;
759        }
760        // Instructions' ghosts go before the modeling visibility comes back.
761        self.instructions_refresh_ghosts();
762        if self.pmi_state().find_annotation(&self.transform_armed_feature()).is_some() { self.disarm_transform(); }
763        self.pmi_restore_explode();
764        if let Some(snapshot) = self.pmi_modeling.clone() {
765            let names: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
766            for name in names {
767                let visible = !snapshot.hidden.contains(&name);
768                self.scene.set_visible(&name, visible);
769            }
770            if self.settings.wireframe != snapshot.wireframe {
771                self.settings.wireframe = snapshot.wireframe;
772                self.settings_generation = self.settings_generation.wrapping_add(1);
773            }
774            let _ = self.apply_camera_state_json(&snapshot.camera_json);
775            self.controls_sync_after_camera_apply();
776        }
777        self.pmi_open_annotation = None;
778        self.dirty = true;
779        self.refresh_pmi_overlay();
780    }
781
782    /// Keep the arcball controls in step with a camera written wholesale.
783    pub(super) fn controls_sync_after_camera_apply(&mut self) {
784        // The controls read the camera each frame (orbit deltas are applied
785        // onto `self.camera`), so writing the camera is enough; a fresh depth
786        // fit happens on the next render. Nothing else to sync.
787        self.dirty = true;
788    }
789
790    // --- explode (display-only poses) -----------------------------------------
791
792    /// Pose the active view's explode targets on the CURRENT displays,
793    /// keeping the un-posed copies for the restore.
794    pub(crate) fn pmi_apply_explode(&mut self) {
795        let Some(active) = self.pmi_active_view.clone() else {
796            self.pmi_restore_explode();
797            return;
798        };
799        let report = self.pmi_report.as_ref().and_then(|report| report.view(&active));
800        let state = self.pmi_state_shared();
801        let view = state.find_view(&active);
802        // Every plane of the section: material goes only where all remove it.
803        let planes = view.map(|v| v.section_planes_in(report)).unwrap_or_default();
804        let section = (!planes.is_empty()).then_some(());
805        let exclude = view.and_then(|v| v.display.section.clone());
806        let poses: Vec<(Vec<String>, [f64; 3], [f64; 3], [f64; 3], [f64; 3])> = report
807            .map(|view| {
808                view.annotations
809                    .iter()
810                    .filter(|row| row.enabled && row.status == PmiStatus::Ok)
811                    .filter(|row| matches!(row.geometry, PmiGeometry::Explode { .. }))
812                    .enumerate()
813                    // Each step at its playback fraction (`exploded_ops`; 1 unless animating).
814                    .filter_map(|(step, row)| match super::exploded_ops::explode_at(&row.geometry, self.explode_fraction(step)) {
815                        PmiGeometry::Explode { solids, translate, rotate_deg, scale, center, .. } => {
816                            Some((solids, translate, rotate_deg, scale, center))
817                        }
818                        _ => None,
819                    })
820                    .collect()
821            })
822            .unwrap_or_default();
823        // An instructions step's insertion: its arrowed parts on their way home.
824        let mut poses = poses;
825        poses.extend(self.instructions_insertion_poses());
826        if section.is_none() && self.pmi_section_hidden.is_empty() && !self.pmi_explode_originals.is_empty() {
827            // Posed again (an animation frame): a display already posed is
828            // reset from its original's geometry, copied into the buffers it
829            // holds, rather than restored and cloned whole again (every frame,
830            // every moving solid: ~500 on a deep l500 view). A section changes
831            // the meshes themselves, so a sectioned view takes the full way.
832            let wanted: std::collections::HashSet<&String> = poses.iter().flat_map(|pose| pose.0.iter()).collect();
833            let stale: Vec<String> = self.pmi_explode_originals.keys().filter(|name| !wanted.contains(name)).cloned().collect();
834            for name in stale {
835                if let (Some(original), Some(display)) = (self.pmi_explode_originals.remove(&name), self.scene.solid_mut(&name)) {
836                    *display = original;
837                }
838            }
839            for name in wanted {
840                match self.pmi_explode_originals.get(name) {
841                    Some(original) => {
842                        if let Some(display) = self.scene.solid_mut(name) {
843                            reset_posed_geometry(display, original);
844                        }
845                    }
846                    None => {
847                        if let Some(display) = self.scene.solid(name) {
848                            self.pmi_explode_originals.insert(name.clone(), display.clone());
849                        }
850                    }
851                }
852            }
853        } else {
854            self.pmi_restore_explode();
855            for (solids, ..) in &poses {
856                for name in solids {
857                    let Some(display) = self.scene.solid(name) else { continue };
858                    self.pmi_explode_originals.entry(name.clone()).or_insert_with(|| display.clone());
859                }
860            }
861        }
862        for (solids, translate, rotate_deg, scale, center) in poses {
863            for name in solids {
864                if let Some(display) = self.scene.solid_mut(&name) {
865                    transform_display(display, center, translate, rotate_deg, scale);
866                }
867            }
868        }
869        if section.is_some() {
870            use super::pmi_section::{side_of_all, Side};
871            // A solid wholly on the kept side is left exactly as it is (no
872            // copy, no new revision: the GPU keeps its buffers); one wholly
873            // removed is hidden, which keeps its revision too, and its
874            // un-hidden original is kept so sheets and the 3D PDF still draw
875            // it. Only what the plane crosses is clipped, capped and
876            // re-uploaded.
877            // A solid the section excludes (a shaft, a fastener) stays whole.
878            let names: Vec<(String, Side)> = self
879                .scene
880                .solids()
881                .iter()
882                .map(|s| (s.name.clone(), if exclude.as_ref().is_some_and(|cut| cut.excludes(&s.name)) { Side::Kept } else { side_of_all(s, &planes) }))
883                .collect();
884            for (name, side) in names {
885                match side {
886                    Side::Kept => {}
887                    Side::Removed => {
888                        if let Some(display) = self.scene.solid(&name).filter(|display| display.visible) {
889                            self.pmi_explode_originals.entry(name.clone()).or_insert_with(|| display.clone());
890                            self.scene.set_visible(&name, false);
891                            self.pmi_section_hidden.push(name);
892                        }
893                    }
894                    Side::Cut => {
895                        if let Some(display) = self.scene.solid(&name) { self.pmi_explode_originals.entry(name.clone()).or_insert_with(|| display.clone()); }
896                        if let Some(display) = self.scene.solid_mut(&name) { super::pmi_section::clip_display_all(display, &planes); }
897                    }
898                }
899            }
900        }
901        if !self.pmi_explode_originals.is_empty() || !self.pmi_section_hidden.is_empty() {
902            self.dirty = true;
903        }
904    }
905
906    /// Put every exploded display back exactly.
907    pub(crate) fn pmi_restore_explode(&mut self) {
908        for name in std::mem::take(&mut self.pmi_section_hidden) {
909            self.scene.set_visible(&name, true);
910        }
911        let originals = std::mem::take(&mut self.pmi_explode_originals);
912        for (name, original) in originals {
913            if let Some(display) = self.scene.solid_mut(&name) {
914                *display = original;
915            }
916        }
917        self.dirty = true;
918    }
919
920    /// Post-apply tail (`finish_apply`): the displays are fresh, so re-pose
921    /// the active view's explode targets and re-bake the overlay.
922    pub(crate) fn pmi_after_apply(&mut self) {
923        // The fresh report resolved every balloon's head from its stored bubble.
924        self.pmi_balloon_stale = None;
925        // Normally already un-posed by `apply_run_output` (before the scene
926        // reconcile); the parse-error branch of a rerun reaches here with the
927        // poses still applied, so restore rather than forget them.
928        self.pmi_restore_explode();
929        if let Some(active) = self.pmi_active_view.clone() {
930            // A view that vanished (undo of its capture) deactivates.
931            let view = self.pmi_state().find_view(&active).cloned();
932            match view {
933                None => {
934                    self.pmi_active_view = None;
935                    self.pmi_open_annotation = None;
936                }
937                Some(view) => {
938                    // The view's hide steps may have changed with this run
939                    // (added, edited, removed, suppressed, undone): the
940                    // displays take the view's visibility again, then its poses.
941                    self.apply_view_display(&view);
942                    self.pmi_apply_explode();
943                }
944            }
945        }
946        self.instructions_refresh_ghosts();
947        if let Some(open) = &self.pmi_open_annotation {
948            if self.pmi_state().find_annotation(open).is_none() {
949                self.pmi_open_annotation = None;
950            }
951        }
952        // A view dialog whose view went away (undo of its capture) closes.
953        if let Some(open) = &self.pmi_open_view {
954            if self.pmi_state().find_view(open).is_none() {
955                self.pmi_open_view = None;
956            }
957        }
958        self.refresh_pmi_overlay();
959    }
960
961    // --- annotations -----------------------------------------------------------
962
963    /// Add an annotation of `type_id` to view `view_id` (the active view when
964    /// `None`) with `params_json` (the schema params; `id` is minted). A datum
965    /// with no letter gets the next unused one. Returns the id; the new
966    /// annotation's form opens.
967    pub fn pmi_add_annotation(
968        &mut self,
969        view_id: Option<&str>,
970        type_id: &str,
971        params_json: &str,
972    ) -> Result<String, String> {
973        let def = brep_kernel::pmi_type(type_id).ok_or_else(|| format!("unknown PMI annotation type '{type_id}'"))?;
974        let mut state = self.pmi_state();
975        let view_id = view_id
976            .map(String::from)
977            .or_else(|| self.pmi_active_view.clone())
978            .ok_or_else(|| "no active PMI view — capture or activate a view first".to_string())?;
979        let id = state.next_id(def.short_name);
980        let mut params: serde_json::Value = serde_json::from_str(params_json).unwrap_or_else(|_| serde_json::json!({}));
981        if !params.is_object() {
982            params = serde_json::json!({});
983        }
984        // Schema defaults under the given params.
985        let schema = (def.schema)();
986        if let (Some(fields), Some(object)) = (
987            schema.get("inputParamsSchema").and_then(serde_json::Value::as_object),
988            params.as_object_mut(),
989        ) {
990            for (key, spec) in fields {
991                if !object.contains_key(key) {
992                    if let Some(default) = spec.get("default_value") {
993                        if !default.is_null() {
994                            object.insert(key.clone(), default.clone());
995                        }
996                    }
997                }
998            }
999            object.insert("id".into(), serde_json::Value::String(id.clone()));
1000            if type_id == "datum" {
1001                let letter = object.get("letter").and_then(serde_json::Value::as_str).unwrap_or("").trim().to_string();
1002                if letter.is_empty() {
1003                    if let Some(next) = state.next_datum_letter() {
1004                        object.insert("letter".into(), serde_json::Value::String(next));
1005                    }
1006                }
1007            }
1008        }
1009        let view = state.find_view_mut(&view_id).ok_or_else(|| format!("no PMI view '{view_id}'"))?;
1010        view.annotations.push(PmiAnnotation {
1011            plugin_replay: None,
1012            persistent_data: serde_json::Value::Null,
1013            kind: type_id.to_string(),
1014            enabled: true,
1015            params,
1016            label_world: None,
1017        });
1018        self.open_pmi_annotation(id.clone());
1019        self.write_pmi_state(state);
1020        Ok(id)
1021    }
1022
1023    /// Replace an annotation's params (a form edit). Re-runs.
1024    pub fn pmi_update_annotation(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1025        if self.pmi_state().find_annotation(id).is_some_and(|(_, a)| a.kind.contains('/')) {
1026            let params = serde_json::from_str(params_json).map_err(|e| format!("annotation params: {e}"))?;
1027            return self.plugin_update_annotation(id, params).map(|_| ());
1028        }
1029        self.pmi_update_annotation_no_rerun(id, params_json)?;
1030        self.rerun_history();
1031        Ok(())
1032    }
1033
1034    /// The fold half of [`Self::pmi_update_annotation`] (checkpointed, no
1035    /// re-run) — the reference picker's commit uses it before its shared
1036    /// end tail re-runs.
1037    pub(crate) fn pmi_update_annotation_no_rerun(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1038        let mut state = self.pmi_state();
1039        let annotation = state.find_annotation_mut(id).ok_or_else(|| format!("no PMI annotation '{id}'"))?;
1040        if annotation.kind.contains('/') {
1041            return Err("plugin annotation edits require the worker transaction path".into());
1042        }
1043        let mut params: serde_json::Value =
1044            serde_json::from_str(params_json).map_err(|error| format!("annotation params: {error}"))?;
1045        if let Some(object) = params.as_object_mut() {
1046            object.insert("id".into(), serde_json::Value::String(id.to_string()));
1047        }
1048        annotation.params = params;
1049        let block = serde_json::to_value(&state).ok();
1050        self.history.set_pmi_block(block, Some(&format!("pmi:params:{id}")));
1051        Ok(())
1052    }
1053
1054    pub fn pmi_remove_annotation(&mut self, id: &str) -> Result<(), String> {
1055        if self.pmi_state().find_annotation(id).is_some_and(|(_, a)| a.kind.contains('/')) {
1056            return self.plugin_delete_annotation(id).map(|_| ());
1057        }
1058        let mut state = self.pmi_state();
1059        let Some((view_index, index)) = state.locate_annotation(id) else {
1060            return Err(format!("no PMI annotation '{id}'"));
1061        };
1062        state.views[view_index].annotations.remove(index);
1063        if self.pmi_open_annotation.as_deref() == Some(id) {
1064            self.pmi_open_annotation = None;
1065        }
1066        self.write_pmi_state(state);
1067        Ok(())
1068    }
1069
1070    pub fn pmi_set_annotation_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
1071        if self.pmi_state().find_annotation(id).is_some_and(|(_, a)| a.kind.contains('/')) {
1072            return self.plugin_set_annotation_enabled(id, enabled).map(|_| ());
1073        }
1074        let mut state = self.pmi_state();
1075        let annotation = state.find_annotation_mut(id).ok_or_else(|| format!("no PMI annotation '{id}'"))?;
1076        if annotation.enabled == enabled {
1077            return Ok(());
1078        }
1079        annotation.enabled = enabled;
1080        self.write_pmi_state(state);
1081        Ok(())
1082    }
1083
1084    /// Move an annotation to `index` within its view.
1085    pub fn pmi_move_annotation(&mut self, id: &str, index: usize) -> Result<(), String> {
1086        let mut state = self.pmi_state();
1087        let Some((view_index, from)) = state.locate_annotation(id) else {
1088            return Err(format!("no PMI annotation '{id}'"));
1089        };
1090        let annotations = &mut state.views[view_index].annotations;
1091        let annotation = annotations.remove(from);
1092        let to = index.min(annotations.len());
1093        annotations.insert(to, annotation);
1094        self.write_pmi_state(state);
1095        Ok(())
1096    }
1097
1098    /// Move an annotation to another view (append).
1099    pub fn pmi_move_annotation_to_view(&mut self, id: &str, view_id: &str) -> Result<(), String> {
1100        let mut state = self.pmi_state();
1101        let Some((view_index, from)) = state.locate_annotation(id) else {
1102            return Err(format!("no PMI annotation '{id}'"));
1103        };
1104        if state.find_view(view_id).is_none() {
1105            return Err(format!("no PMI view '{view_id}'"));
1106        }
1107        let annotation = state.views[view_index].annotations.remove(from);
1108        state.find_view_mut(view_id).expect("checked").annotations.push(annotation);
1109        self.write_pmi_state(state);
1110        Ok(())
1111    }
1112
1113    /// Open one annotation's form (engine memory; `None` closes). Opening an
1114    /// annotation of another view activates that view. An open COUNTS
1115    /// (`Self::open_pmi_annotation`) — including a re-open of the annotation
1116    /// already open, which is a viewport label double click on the form the user
1117    /// cannot see; an id that resolves to no annotation opens nothing and
1118    /// counts nothing.
1119    pub fn pmi_set_annotation_open(&mut self, id: Option<&str>) {
1120        match id {
1121            Some(id) => {
1122                let state = self.pmi_state();
1123                if let Some((view, _)) = state.find_annotation(id) {
1124                    let view_id = view.id.clone();
1125                    if self.pmi_active_view.as_deref() != Some(view_id.as_str()) {
1126                        let _ = self.pmi_activate_view(&view_id);
1127                    }
1128                    self.open_pmi_annotation(id.to_string());
1129                }
1130            }
1131            None => {
1132                if self.pmi_open_annotation.as_deref() == Some(self.transform_armed_feature().as_str()) { self.disarm_transform(); }
1133                self.pmi_open_annotation = None;
1134            },
1135        }
1136        self.refresh_pmi_overlay();
1137    }
1138
1139    // --- labels -----------------------------------------------------------------
1140
1141    /// Move an annotation's label (world). Coalesced per annotation into ONE
1142    /// undo step, and NEVER a re-run: the cached report is patched in place
1143    /// and the overlay re-baked. A balloon's arrow head is re-derived from
1144    /// the moved bubble (`Self::pmi_reproject_balloon_head`).
1145    pub fn pmi_set_label_world(&mut self, id: &str, world: [f64; 3]) -> Result<(), String> {
1146        let mut state = self.pmi_state();
1147        let annotation = state.find_annotation_mut(id).ok_or_else(|| format!("no PMI annotation '{id}'"))?;
1148        annotation.label_world = Some(world);
1149        let block = serde_json::to_value(&state).ok();
1150        self.history.set_pmi_block(block, Some(&format!("pmi:label:{id}")));
1151        if let Some(report) = self.pmi_report.as_mut() {
1152            if let Some(row) = report.annotation_mut(id) {
1153                row.label_world = world;
1154                if let PmiGeometry::Note { position } = &mut row.geometry {
1155                    *position = world;
1156                }
1157            }
1158        }
1159        self.pmi_reproject_balloon_head(id);
1160        self.refresh_pmi_overlay();
1161        Ok(())
1162    }
1163
1164    /// Re-derive a balloon's arrow head from its row's bubble through the
1165    /// recipe the kernel put on its leader (the same projection the resolver
1166    /// ran, on the exact-solid clones the scene holds), and patch the cached
1167    /// report. The head is derived, never stored, so no document edit and no
1168    /// run: the next run resolves the same head from the stored bubble. A
1169    /// solid the scene does not hold yet is asked of the runner; the head is
1170    /// re-derived when the reply lands ([`Self::pmi_refresh_stale_balloon`]),
1171    /// which for the Inline runner is before this returns. Any other
1172    /// annotation is left alone.
1173    pub(crate) fn pmi_reproject_balloon_head(&mut self, id: &str) {
1174        if let Some(missing) = self.pmi_balloon_head_now(id) {
1175            self.pmi_balloon_stale = Some(id.to_string());
1176            self.request_exact_solids(missing.iter().map(String::as_str));
1177            self.pmi_refresh_stale_balloon();
1178        }
1179    }
1180
1181    /// Retry the balloon whose re-projection waited on a topology reply.
1182    pub(crate) fn pmi_refresh_stale_balloon(&mut self) {
1183        let Some(id) = self.pmi_balloon_stale.take() else {
1184            return;
1185        };
1186        if self.pmi_balloon_head_now(&id).is_some() {
1187            // Still waiting (a background runner's reply is on its way).
1188            self.pmi_balloon_stale = Some(id);
1189        } else {
1190            self.refresh_pmi_overlay();
1191        }
1192    }
1193
1194    /// One re-projection attempt: `None` when done (or not a balloon), else
1195    /// the occurrence solids the scene does not hold.
1196    pub(super) fn pmi_balloon_head_now(&mut self, id: &str) -> Option<Vec<String>> {
1197        let (mut anchor, bubble) = self
1198            .pmi_report
1199            .as_ref()
1200            .and_then(|report| report.annotation(id))
1201            .and_then(|row| match &row.geometry {
1202                PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => Some((anchor.clone(), row.label_world)),
1203                _ => None,
1204            })?;
1205        anchor.camera = Some(self.snapshot_camera());
1206        let mut missing = Vec::new();
1207        let head = brep_kernel::pmi_balloon_head(&anchor, bubble, |name| self.scene.exact_solid(name), &mut missing);
1208        if !missing.is_empty() {
1209            return Some(missing);
1210        }
1211        // A fixed (vertex) anchor answers `None`: the head never moves. A
1212        // projection error keeps the kernel's head rather than a wrong one.
1213        if let Ok(Some(head)) = head {
1214            if let Some(row) = self.pmi_report.as_mut().and_then(|report| report.annotation_mut(id)) {
1215                if let PmiGeometry::Leader { targets, anchor: recipe, .. } = &mut row.geometry {
1216                    *recipe = Some(anchor);
1217                    if let Some(first) = targets.first_mut() {
1218                        *first = head;
1219                    }
1220                }
1221            }
1222        }
1223        None
1224    }
1225
1226    /// Drag an annotation's label to the pointer: the new position is where
1227    /// the pick ray crosses the plane through the current label perpendicular
1228    /// to the viewing direction (so a drag never changes the label's depth).
1229    pub fn pmi_label_drag_to(&mut self, id: &str, x: f64, y: f64) {
1230        // Part-way through explode playback the chip is drawn where its body
1231        // is now: the drag runs there, and is stored back in the run's frame.
1232        let playback = self.explode_playback_poses();
1233        let Some((current, plane)) = self
1234            .pmi_report
1235            .as_ref()
1236            .and_then(|r| r.annotation(id))
1237            .map(|r| (playback.as_ref().map_or(r.label_world, |poses| poses.shown(r, r.label_world)), r.plane))
1238        else {
1239            return;
1240        };
1241        let ray = self.camera.pick_ray(x, y);
1242        // In a picked annotation plane the label stays ON that plane; a
1243        // view-aligned label moves in the view-parallel plane through its
1244        // current position.
1245        let plane = plane.unwrap_or_else(|| {
1246            let (_, _, view) = self.camera.basis();
1247            brep_kernel::PmiPlane {
1248                origin: current,
1249                normal: view,
1250                x_axis: [0.0; 3],
1251            }
1252        });
1253        let Some(world) = plane.hit(ray.origin, ray.dir) else {
1254            return;
1255        };
1256        let world = match (&playback, self.pmi_report.as_ref().and_then(|r| r.annotation(id))) {
1257            (Some(poses), Some(row)) => poses.full(row, world),
1258            _ => world,
1259        };
1260        let _ = self.pmi_set_label_world(id, world);
1261    }
1262
1263    /// A label drag ended: the next drag is a fresh undo step.
1264    pub fn pmi_label_drag_end(&mut self) {
1265        self.history.break_coalescing();
1266    }
1267
1268    /// Hover a label: highlight the annotation's referenced geometry.
1269    pub fn pmi_hover(&mut self, id: &str) {
1270        self.pmi_label_hover_active = true;
1271        if self.pmi_hovered.as_deref() == Some(id) {
1272            return;
1273        }
1274        let references: Vec<String> = self
1275            .pmi_report
1276            .as_ref()
1277            .and_then(|report| report.annotation(id))
1278            .map(|row| row.references.clone())
1279            .unwrap_or_default();
1280        let mut solids: Vec<String> = Vec::new();
1281        let mut faces: Vec<String> = Vec::new();
1282        let mut edges: Vec<String> = Vec::new();
1283        for reference in &references {
1284            if let Some(at) = reference.find('@') {
1285                solids.push(reference[..at].to_string());
1286            } else if self.scene_has_face(reference) {
1287                faces.push(reference.clone());
1288            } else if self.scene_has_edge(reference) {
1289                edges.push(reference.clone());
1290            } else if self.scene.solid(reference).is_some() {
1291                solids.push(reference.clone());
1292            } else {
1293                let prefix = format!("{reference}:");
1294                solids.extend(self.scene.solids().iter().filter(|s| s.name.starts_with(&prefix)).map(|s| s.name.clone()));
1295            }
1296        }
1297        self.clear_hover();
1298        self.emphasis.hovered_solids.extend(solids);
1299        self.emphasis.hovered_faces.extend(faces);
1300        self.emphasis.hovered_edges.extend(edges);
1301        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
1302        self.pmi_hovered = Some(id.to_string());
1303        self.dirty = true;
1304    }
1305
1306    pub fn pmi_hover_end(&mut self) {
1307        if self.pmi_hovered.take().is_some() {
1308            self.clear_hover();
1309            self.dirty = true;
1310        }
1311    }
1312
1313    /// Consume the one-frame "a PMI label is hovering elements" flag (the
1314    /// viewport's scene-hover pass yields while set).
1315    pub fn take_pmi_label_hover(&mut self) -> bool {
1316        std::mem::take(&mut self.pmi_label_hover_active)
1317    }
1318
1319    /// A label was clicked: SELECT that annotation, as its tree row's single
1320    /// click does — the label carries the accent and nothing opens.
1321    pub fn pmi_label_clicked(&mut self, id: &str) {
1322        self.pmi_select_annotation(id);
1323    }
1324
1325    /// A label was double-clicked: open that annotation's form, as its tree
1326    /// row's double click does.
1327    pub fn pmi_label_double_clicked(&mut self, id: &str) {
1328        self.pmi_set_annotation_open(Some(id));
1329    }
1330
1331    /// SELECT an annotation without opening it — the PMI tree's single click.
1332    /// Its view activates when it is not the active one, as opening does, so
1333    /// the selected label is on screen to carry the selection accent. Returns
1334    /// false, changing nothing, for an id that names no annotation.
1335    pub fn pmi_select_annotation(&mut self, id: &str) -> bool {
1336        let state = self.pmi_state();
1337        let Some((view, _)) = state.find_annotation(id) else {
1338            return false;
1339        };
1340        if self.pmi_active_view.as_deref() != Some(view.id.as_str()) {
1341            let _ = self.pmi_activate_view(&view.id);
1342        }
1343        self.pmi_selected_view = None;
1344        self.pmi_selected_annotation = Some(id.to_string());
1345        self.refresh_pmi_overlay();
1346        true
1347    }
1348
1349    /// SELECT a view without activating it — the PMI tree's single click on a
1350    /// view row. The camera, visibility and wireframe stay as they are: a
1351    /// double click (which opens the view's dialog) or the row's On toggle is
1352    /// what activates. A selected annotation gives up the selection. Returns
1353    /// false, changing nothing, for an id that names no view.
1354    pub fn pmi_select_view(&mut self, id: &str) -> bool {
1355        if self.pmi_state().find_view(id).is_none() {
1356            return false;
1357        }
1358        if self.pmi_selected_annotation.take().is_some() {
1359            self.refresh_pmi_overlay();
1360        }
1361        self.pmi_selected_view = Some(id.to_string());
1362        self.dirty = true;
1363        true
1364    }
1365
1366    // --- reference picker ---------------------------------------------------------
1367
1368    /// Enter reference-selection mode for annotation `id`'s field at `path`.
1369    pub fn begin_ref_select_for_pmi(
1370        &mut self,
1371        id: &str,
1372        path: Vec<String>,
1373        label: String,
1374        filter: Vec<String>,
1375        multiple: bool,
1376        seed_names: Vec<String>,
1377    ) {
1378        let restore_index = self.history.rollback();
1379        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
1380        self.ref_select = Some(RefSelectState {
1381            feature_id: id.to_string(),
1382            path,
1383            label,
1384            filter,
1385            multiple,
1386            names: seed_names,
1387            restore_index,
1388            target: RefSelectTarget::Pmi,
1389            picks: 0,
1390            claimed: false,
1391            finish_requested: false,
1392        });
1393        self.sync_ref_select_emphasis();
1394    }
1395
1396    // --- explode editing before the pick commits ------------------------------
1397
1398    /// The names an open explode step is about to move: the reference pick
1399    /// running on its `targets` field while one runs (the picker holds the
1400    /// part until Done commits it), else the step's committed targets.
1401    fn explode_target_names(&self, id: &str, annotation: &PmiAnnotation) -> Vec<String> {
1402        if let Some(field) = self.ref_select_field() {
1403            if field.target == RefSelectTarget::Pmi && field.subject == id && field.path == ["targets"] {
1404                return field.names;
1405            }
1406        }
1407        annotation.references("targets")
1408    }
1409
1410    /// The world bbox centre of the scene solids `names` name: a solid by its
1411    /// name, a component by every solid inside it (`ACOMP2` → `ACOMP2:…`).
1412    /// `None` when none of them is in the scene.
1413    fn scene_names_center(&self, names: &[String]) -> Option<[f64; 3]> {
1414        let mut low = [f64::INFINITY; 3];
1415        let mut high = [f64::NEG_INFINITY; 3];
1416        let mut any = false;
1417        for name in names {
1418            let prefix = format!("{name}:");
1419            for solid in self.scene.solids() {
1420                if solid.name == *name || solid.name.starts_with(&prefix) {
1421                    for axis in 0..3 {
1422                        low[axis] = low[axis].min(solid.bbox.min[axis]);
1423                        high[axis] = high[axis].max(solid.bbox.max[axis]);
1424                    }
1425                    any = true;
1426                }
1427            }
1428        }
1429        any.then(|| std::array::from_fn(|axis| (low[axis] + high[axis]) * 0.5))
1430    }
1431
1432    /// Where an open explode step's gizmo stands BEFORE the kernel has laid
1433    /// the step out — the owner (2026-10-26) picked a part for a new explode
1434    /// and saw no transform controls: a reference pick commits at Done, and
1435    /// until then the step had nothing to stand on. This is the centre of
1436    /// what the step is about to move (the running pick's names, else its
1437    /// committed targets), the same bbox centre the kernel's layout uses.
1438    /// `None` with nothing to move yet, or when the step is not in the
1439    /// active view. Once the run lays the step out, the report's centre is
1440    /// the one the gizmo and a drag use.
1441    pub(super) fn pmi_explode_live_center(&self, id: &str) -> Option<[f64; 3]> {
1442        let state = self.pmi_state_shared();
1443        let (view, annotation) = state.find_annotation(id)?;
1444        if annotation.kind != "explode" || self.pmi_active_view.as_deref() != Some(view.id.as_str()) {
1445            return None;
1446        }
1447        let names = self.explode_target_names(id, annotation);
1448        self.scene_names_center(&names)
1449    }
1450
1451    /// Commit a finished PMI ref-select into the annotation's params (fold
1452    /// only — the caller's shared tail re-runs).
1453    pub(crate) fn pmi_commit_refs(&mut self, id: &str, path: &[String], names: &[String], multiple: bool) {
1454        if self.pmi_state().find_view(id).is_some() {
1455            match path.first().map(String::as_str) {
1456                Some("sectionExclude") => self.pmi_commit_section_exclude(id, names),
1457                _ => self.pmi_commit_section_plane(id, names.first().map(String::as_str).unwrap_or("")),
1458            }
1459            self.history.break_coalescing();
1460            return;
1461        }
1462        let state = self.pmi_state();
1463        let Some((_, annotation)) = state.find_annotation(id) else {
1464            self.push_notice(format!("unknown PMI annotation '{id}'"));
1465            return;
1466        };
1467        let mut params = annotation.params.clone();
1468        let value = if multiple {
1469            serde_json::Value::Array(names.iter().cloned().map(serde_json::Value::String).collect())
1470        } else {
1471            serde_json::Value::String(names.first().cloned().unwrap_or_default())
1472        };
1473        super::selection_ux::set_json_at(&mut params, path, value);
1474        if let Err(error) = self.pmi_update_annotation_no_rerun(id, &params.to_string()) {
1475            self.push_notice(format!("PMI update failed: {error}"));
1476        }
1477        // A pick is a discrete change (it ends with Finish): the next edit of
1478        // the same annotation's params is its own undo step, so picking an
1479        // anchor and then clearing it in the form undo one at a time.
1480        self.history.break_coalescing();
1481    }
1482
1483    /// Commit a view form's **Section plane** pick (fold only, like
1484    /// [`Self::pmi_commit_refs`]): picking a face or plane makes the section
1485    /// cut on it — the section turns on with the `Plane` normal.
1486    fn pmi_commit_section_plane(&mut self, id: &str, name: &str) {
1487        let mut state = self.pmi_state();
1488        let Some(view) = state.find_view_mut(id) else { return };
1489        let section = view.display.section.get_or_insert_with(|| brep_kernel::feature_pipeline::pmi::PmiSection {
1490            axis: "Plane".into(),
1491            offset: 0.,
1492            flip: false,
1493            origin: None,
1494            normal: None,
1495            plane: None,
1496            exclude: Vec::new(),
1497            planes: Vec::new(),
1498        });
1499        section.axis = "Plane".into();
1500        section.plane = Some(name.trim().to_string()).filter(|name| !name.is_empty());
1501        section.freeze(None);
1502        let block = serde_json::to_value(&state).ok();
1503        // Its own undo step, not folded into the form's edits before it.
1504        self.history.break_coalescing();
1505        self.history.set_pmi_block(block, Some(&format!("pmi:view:{id}")));
1506    }
1507
1508    /// Commit a view form's **Not cut** picks (fold only, like
1509    /// [`Self::pmi_commit_section_plane`]): the solids and components the
1510    /// section leaves whole. Nothing to commit on a view without a section.
1511    fn pmi_commit_section_exclude(&mut self, id: &str, names: &[String]) {
1512        let mut state = self.pmi_state();
1513        let Some(section) = state.find_view_mut(id).and_then(|view| view.display.section.as_mut()) else { return };
1514        section.exclude = names.iter().map(|name| name.trim().to_string()).filter(|name| !name.is_empty()).collect();
1515        let block = serde_json::to_value(&state).ok();
1516        self.history.break_coalescing();
1517        self.history.set_pmi_block(block, Some(&format!("pmi:view:{id}")));
1518    }
1519
1520    // --- import -------------------------------------------------------------------
1521
1522    /// Merge a file's lifted PMI (`read_step_pmi`) into the document beside
1523    /// the import that added its geometry: views append with fresh ids, the
1524    /// import's undo checkpoint covers both (no second checkpoint).
1525    pub(crate) fn pmi_merge_imported(&mut self, lifted: PmiState) {
1526        let mut state = self.pmi_state();
1527        for view in lifted.views {
1528            let id = state.next_id("VIEW");
1529            let mut annotations = Vec::with_capacity(view.annotations.len());
1530            for mut annotation in view.annotations {
1531                let prefix = brep_kernel::pmi_type(&annotation.kind).map(|def| def.short_name).unwrap_or("PMI");
1532                let fresh = state.next_id(prefix);
1533                if let Some(object) = annotation.params.as_object_mut() {
1534                    object.insert("id".into(), serde_json::Value::String(fresh));
1535                }
1536                annotations.push(annotation);
1537            }
1538            state.views.push(PmiView {
1539                id,
1540                name: view.name,
1541                camera: view.camera,
1542                display: view.display,
1543                annotations,
1544            });
1545        }
1546        let block = serde_json::to_value(&state).ok();
1547        self.history.set_pmi_block_no_undo(block);
1548        self.rerun_history();
1549    }
1550}
1551
1552/// Pose a display in place: `p' = R((p − c) ∘ s) + c + t` on the mesh, the
1553/// edge polylines and the vertices; normals rotate; the bbox is rebuilt.
1554/// `p ↦ R((p − c) ∘ s) + c + t` as a column-major 4×4, its rotation read off
1555/// the very rotation the vertices are turned by.
1556fn pose_matrix(center: [f64; 3], translate: [f64; 3], rotate: &dyn Fn([f64; 3]) -> [f64; 3], scale: [f64; 3]) -> [f32; 16] {
1557    let columns = [rotate([1.0, 0.0, 0.0]), rotate([0.0, 1.0, 0.0]), rotate([0.0, 0.0, 1.0])];
1558    let linear: [[f64; 3]; 3] = std::array::from_fn(|j| columns[j].map(|v| v * scale[j]));
1559    let shifted: [f64; 3] = std::array::from_fn(|i| center[i] + translate[i] - (0..3).map(|j| linear[j][i] * center[j]).sum::<f64>());
1560    let mut m = [0.0f32; 16];
1561    for j in 0..3 {
1562        for i in 0..3 {
1563            m[j * 4 + i] = linear[j][i] as f32;
1564        }
1565    }
1566    m[12] = shifted[0] as f32;
1567    m[13] = shifted[1] as f32;
1568    m[14] = shifted[2] as f32;
1569    m[15] = 1.0;
1570    m
1571}
1572
1573/// `a · b`, column-major 4×4.
1574use crate::view::mat4_mul_column_major as mat4_mul;
1575
1576/// Put a posed display's geometry back to its original's, copying into the
1577/// buffers it already holds: what a pose moves (positions, normals, edge
1578/// polylines, vertices, the box) and the revisions it set.
1579fn reset_posed_geometry(display: &mut crate::scene::SolidDisplay, original: &crate::scene::SolidDisplay) {
1580    display.mesh.positions.clone_from(&original.mesh.positions);
1581    display.mesh.normals.clone_from(&original.mesh.normals);
1582    if display.edges.len() == original.edges.len() {
1583        for (edge, from) in display.edges.iter_mut().zip(&original.edges) {
1584            edge.polyline.clone_from(&from.polyline);
1585        }
1586    } else {
1587        display.edges.clone_from(&original.edges);
1588    }
1589    display.vertices.clone_from(&original.vertices);
1590    display.bbox = original.bbox;
1591    display.revision = original.revision;
1592    display.shape_revision = original.shape_revision;
1593    display.pose = original.pose;
1594}
1595
1596pub(super) fn transform_display(
1597    display: &mut crate::scene::SolidDisplay,
1598    center: [f64; 3],
1599    translate: [f64; 3],
1600    rotate_deg: [f64; 3],
1601    scale: [f64; 3],
1602) {
1603    // The same mesh posed: the renderer rewrites its positions in the buffers
1604    // it holds (`sync_scene`'s pose path) instead of rebuilding the solid on
1605    // every frame an exploded view animates (l500: p95 81 ms a frame). A
1606    // section clip, which changes the mesh, resets this itself.
1607    // The kernel's explode rotation is in degrees (as every `rotationEuler`;
1608    // its balloon anchors convert the same way); the rotation takes radians.
1609    let rotate_rad = rotate_deg.map(f64::to_radians);
1610    let rotate = |v: [f64; 3]| super::rotate_euler_xyz_f64(v, rotate_rad);
1611    // The pose as a matrix over the shape's own geometry: this one, composed
1612    // with any pose already on it. A display moved some other way (a drag)
1613    // has no shape geometry to compose over, and keeps none.
1614    let step = pose_matrix(center, translate, &rotate, scale);
1615    display.pose = if display.shape_revision == 0 {
1616        Some(step)
1617    } else {
1618        display.pose.map(|before| mat4_mul(&step, &before))
1619    };
1620    if display.shape_revision == 0 {
1621        display.shape_revision = display.revision;
1622    }
1623    display.revision = crate::scene::next_revision();
1624    let pose = |p: [f64; 3]| -> [f64; 3] {
1625        let local = [(p[0] - center[0]) * scale[0], (p[1] - center[1]) * scale[1], (p[2] - center[2]) * scale[2]];
1626        let rotated = rotate(local);
1627        [rotated[0] + center[0] + translate[0], rotated[1] + center[1] + translate[1], rotated[2] + center[2] + translate[2]]
1628    };
1629    let mut bbox = crate::camera::Aabb::empty();
1630    for position in &mut display.mesh.positions {
1631        let posed = pose([position[0] as f64, position[1] as f64, position[2] as f64]);
1632        *position = [posed[0] as f32, posed[1] as f32, posed[2] as f32];
1633        bbox.expand(posed);
1634    }
1635    for normal in &mut display.mesh.normals {
1636        let rotated = rotate(std::array::from_fn(|i| normal[i] as f64 / scale[i]));
1637        let length = rotated.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-20);
1638        *normal = rotated.map(|v| (v / length) as f32);
1639    }
1640    for edge in &mut display.edges {
1641        for point in &mut edge.polyline {
1642            let posed = pose([point[0] as f64, point[1] as f64, point[2] as f64]);
1643            *point = [posed[0] as f32, posed[1] as f32, posed[2] as f32];
1644            bbox.expand(posed);
1645        }
1646    }
1647    for vertex in &mut display.vertices {
1648        vertex.position = pose(vertex.position);
1649        bbox.expand(vertex.position);
1650    }
1651    if !bbox.is_empty() {
1652        display.bbox = bbox;
1653    }
1654}
1655