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