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