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"], "default_value": "View" },
60            "sectionOffset": { "type": "number", "label": "Section offset (mm)", "default_value": 0.0 },
61            "sectionFlip": { "type": "boolean", "label": "Reverse section", "default_value": false },
62            "updateCamera": { "type": "button", "label": "Update camera from the viewport" },
63            "updateVisibility": { "type": "button", "label": "Update visibility from the scene" }
64        }
65    })
66}
67
68/// A view's current values in [`pmi_view_schema`]'s shape — the form's seed.
69pub fn pmi_view_params(view: &brep_kernel::PmiView) -> serde_json::Value {
70    serde_json::json!({
71        "name": view.name,
72        "textSizePt": view.display.text_size_pt,
73        "wireframe": view.display.wireframe,
74        "sectionEnabled": view.display.section.is_some(),
75        "sectionAxis": view.display.section.as_ref().map(|s| s.axis.as_str()).unwrap_or("View"),
76        "sectionOffset": view.display.section.as_ref().map(|s| s.offset).unwrap_or(0.),
77        "sectionFlip": view.display.section.as_ref().is_some_and(|s| s.flip),
78    })
79}
80
81/// `{solid}@x,y,z` in WORLD coordinates (the PMI vertex-ref convention).
82pub fn world_vertex_ref(solid: &str, position: [f64; 3]) -> String {
83    let trim = |v: f64| {
84        let rounded = (v * 1e9).round() / 1e9;
85        let trimmed = crate::formatting::compact_decimal(rounded, 9);
86        if trimmed == "-0" || trimmed.is_empty() { "0".to_string() } else { trimmed }
87    };
88    format!("{solid}@{},{},{}", trim(position[0]), trim(position[1]), trim(position[2]))
89}
90
91impl EngineState {
92    // --- read surface ------------------------------------------------------
93
94    /// The document's `pmi` block as typed state (the default — no views —
95    /// when the document carries none).
96    pub fn pmi_state(&self) -> PmiState {
97        self.history
98            .pmi_block()
99            .and_then(|block| serde_json::from_value(block.clone()).ok())
100            .unwrap_or_default()
101    }
102
103    /// The PMI report of the last APPLIED run.
104    pub fn pmi_report(&self) -> Option<&PmiReport> {
105        self.pmi_report.as_ref()
106    }
107
108    pub fn pmi_active_view(&self) -> Option<&str> {
109        self.pmi_active_view.as_deref()
110    }
111
112    pub fn pmi_open_annotation(&self) -> Option<&str> {
113        self.pmi_open_annotation.as_deref()
114    }
115
116    /// The view whose dialog the PMI pane shows, if one is open.
117    pub fn pmi_open_view(&self) -> Option<&str> {
118        self.pmi_open_view.as_deref()
119    }
120
121    /// How many PMI dialogs — annotation or view — this engine has OPENED. The
122    /// app's dialog door reads it beside the open subject so a re-open of the
123    /// dialog already open is still an open (the id alone does not move).
124    /// Monotonic and never reset; a CLOSE does not count.
125    pub fn pmi_dialog_opens(&self) -> u64 {
126        self.pmi_dialog_opens
127    }
128
129    /// **The PMI half of the dialog door's counter.** Open `id`'s form and
130    /// count the open. EVERY path that opens an annotation form goes through
131    /// here — the panel's `+`, its row Edit and double-click, the viewport
132    /// label double click, the automation add — so the door has one signal to watch
133    /// and a re-open of the same annotation still reads as an open. Assigning
134    /// `pmi_open_annotation = Some(..)` anywhere else is the bug this exists to
135    /// prevent: the form would open with its pane still behind another tab.
136    ///
137    /// An open annotation is the selected one too — the dialog is about it —
138    /// and it replaces an open VIEW dialog, since the pane shows one dialog.
139    fn open_pmi_annotation(&mut self, id: String) {
140        self.pmi_open_view = None;
141        self.pmi_selected_view = None;
142        self.pmi_selected_annotation = Some(id.clone());
143        self.pmi_open_annotation = Some(id);
144        self.pmi_dialog_opens = self.pmi_dialog_opens.wrapping_add(1);
145    }
146
147    /// The view half of [`Self::open_pmi_annotation`]: open view `id`'s dialog,
148    /// closing an open annotation's, select the view and count the open.
149    fn open_pmi_view(&mut self, id: String) {
150        self.pmi_open_annotation = None;
151        if self.pmi_selected_annotation.take().is_some() {
152            self.refresh_pmi_overlay();
153        }
154        self.pmi_selected_view = Some(id.clone());
155        self.pmi_open_view = Some(id);
156        self.pmi_dialog_opens = self.pmi_dialog_opens.wrapping_add(1);
157    }
158
159    /// The annotation selected in the PMI tree (or opened), while it exists.
160    pub fn pmi_selected_annotation(&self) -> Option<&str> {
161        let id = self.pmi_selected_annotation.as_deref()?;
162        self.pmi_state().find_annotation(id).is_some().then_some(id)
163    }
164
165    /// The view selected in the PMI tree (or opened), while it exists.
166    pub fn pmi_selected_view(&self) -> Option<&str> {
167        let id = self.pmi_selected_view.as_deref()?;
168        self.pmi_state().find_view(id).is_some().then_some(id)
169    }
170
171    /// Whether the PMI workbench remembered a modeling state (it is "entered").
172    pub fn pmi_workbench_entered(&self) -> bool {
173        self.pmi_modeling.is_some()
174    }
175
176    /// The `__brepPmi` verifier global: the block, the report, the active
177    /// view, the open annotation and the datum letters, as one object.
178    pub fn pmi_state_json(&self) -> String {
179        let state = self.pmi_state();
180        serde_json::json!({
181            "views": state.views,
182            "idCounter": state.id_counter,
183            "activeView": self.pmi_active_view,
184            "openAnnotation": self.pmi_open_annotation,
185            "openView": self.pmi_open_view,
186            "selectedAnnotation": self.pmi_selected_annotation(),
187            "selectedView": self.pmi_selected_view(),
188            "entered": self.pmi_modeling.is_some(),
189            "datums": state.datum_letters(),
190            "report": self.pmi_report,
191        })
192        .to_string()
193    }
194
195    // --- block writes --------------------------------------------------------
196
197    /// Write `state` as the document's block (checkpointed) and re-run the
198    /// history so the tail resolves it. An empty block is removed so a part
199    /// that never had PMI saves byte-identically.
200    fn write_pmi_state(&mut self, state: PmiState) -> String {
201        let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
202        self.history.set_pmi_block(block, None);
203        self.rerun_history()
204    }
205
206    // --- views ---------------------------------------------------------------
207
208    /// The current camera as a snapshot.
209    fn snapshot_camera(&self) -> PmiCamera {
210        let camera = &self.camera;
211        PmiCamera {
212            eye: camera.eye,
213            target: camera.target,
214            up: camera.up,
215            projection: match camera.projection {
216                Projection::Orthographic { half_height } => PmiProjection::Orthographic { half_height },
217                Projection::Perspective { fov_y_deg } => PmiProjection::Perspective { fov_y_deg },
218            },
219            viewport: [camera.width, camera.height],
220        }
221    }
222
223    /// The names of the solids currently hidden in the scene.
224    fn hidden_solid_names(&self) -> Vec<String> {
225        self.scene
226            .solids()
227            .iter()
228            .filter(|solid| !solid.visible)
229            .map(|solid| solid.name.clone())
230            .collect()
231    }
232
233    /// Capture a view: the current camera, hidden objects and wireframe
234    /// setting, named `name` (or `View N`). The new view becomes active.
235    pub fn pmi_capture_view(&mut self, name: Option<&str>) -> String {
236        let mut state = self.pmi_state();
237        let id = state.next_id("VIEW");
238        let name = name
239            .map(str::trim)
240            .filter(|n| !n.is_empty())
241            .map(String::from)
242            .unwrap_or_else(|| format!("View {}", state.views.len() + 1));
243        // The capture reads the MODELING state: if another view is active its
244        // applied state is what is on screen, so deactivate first (restores)
245        // — a view captures what the user set up, not another view's snapshot.
246        let was_active = self.pmi_active_view.is_some();
247        if was_active {
248            self.pmi_deactivate_view();
249        }
250        self.pmi_remember_modeling();
251        state.views.push(PmiView {
252            id: id.clone(),
253            name,
254            camera: Some(self.snapshot_camera()),
255            display: PmiDisplay {
256                text_size_pt: 12.0,
257                wireframe: self.settings.wireframe,
258                hidden: self.hidden_solid_names(),
259                section: None,
260            },
261            annotations: Vec::new(),
262        });
263        self.write_pmi_state(state);
264        let _ = self.pmi_activate_view(&id);
265        id
266    }
267
268    pub fn pmi_rename_view(&mut self, id: &str, name: &str) -> Result<(), String> {
269        let mut state = self.pmi_state();
270        let view = state.find_view_mut(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
271        let name = name.trim();
272        if name.is_empty() {
273            return Err("a view needs a name".into());
274        }
275        view.name = name.to_string();
276        self.write_pmi_state(state);
277        Ok(())
278    }
279
280    /// Delete a view (and its annotations). An active view deactivates first.
281    pub fn pmi_delete_view(&mut self, id: &str) -> Result<(), String> {
282        let mut state = self.pmi_state();
283        let before = state.views.len();
284        if self.pmi_active_view.as_deref() == Some(id) {
285            self.pmi_deactivate_view();
286        }
287        state.views.retain(|view| view.id != id);
288        if state.views.len() == before {
289            return Err(format!("no PMI view '{id}'"));
290        }
291        if let Some(open) = &self.pmi_open_annotation {
292            if state.find_annotation(open).is_none() {
293                self.pmi_open_annotation = None;
294            }
295        }
296        if self.pmi_open_view.as_deref() == Some(id) {
297            self.pmi_open_view = None;
298        }
299        self.write_pmi_state(state);
300        Ok(())
301    }
302
303    /// Re-capture a view's camera from the current camera (the explicit
304    /// Update Camera action — orbiting never rewrites a snapshot silently).
305    pub fn pmi_update_view_camera(&mut self, id: &str) -> Result<(), String> {
306        let mut state = self.pmi_state();
307        let camera = self.snapshot_camera();
308        let view = state.find_view_mut(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
309        view.camera = Some(camera);
310        self.write_pmi_state(state);
311        Ok(())
312    }
313
314    /// Re-capture a view's hidden set from the scene (the explicit Update
315    /// Visibility action).
316    pub fn pmi_update_view_visibility(&mut self, id: &str) -> Result<(), String> {
317        let hidden = self.hidden_solid_names();
318        self.pmi_set_view_display(id, &PmiViewPatch { hidden: Some(hidden), ..Default::default() })
319    }
320
321    /// Patch a view's name / display state. The active view re-applies.
322    pub fn pmi_set_view_display(&mut self, id: &str, patch: &PmiViewPatch) -> Result<(), String> {
323        self.patch_pmi_view(id, patch, None)
324    }
325
326    /// Apply an edited **PMI view** form ([`pmi_view_schema`]'s params). Only
327    /// the fields that differ from the view are written, and a run of edits to
328    /// one view is ONE undo step — the form writes on every keystroke of the
329    /// name, the way an annotation's form does.
330    pub fn pmi_update_view(&mut self, id: &str, params_json: &str) -> Result<(), String> {
331        let params: serde_json::Value =
332            serde_json::from_str(params_json).map_err(|error| format!("view params: {error}"))?;
333        let state = self.pmi_state();
334        let view = state.find_view(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
335        let name = params
336            .get("name")
337            .and_then(serde_json::Value::as_str)
338            .filter(|name| *name != view.name)
339            .map(String::from);
340        let text_size_pt = params
341            .get("textSizePt")
342            .and_then(serde_json::Value::as_f64)
343            .filter(|size| *size != view.display.text_size_pt);
344        let wireframe = params
345            .get("wireframe")
346            .and_then(serde_json::Value::as_bool)
347            .filter(|on| *on != view.display.wireframe);
348        let section = if params.get("sectionEnabled").is_some() {
349            let section = if params["sectionEnabled"].as_bool().unwrap_or(false) {
350                let axis = params["sectionAxis"].as_str().unwrap_or("View");
351                let offset = params["sectionOffset"].as_f64().unwrap_or(0.);
352                if !["View", "X", "Y", "Z"].contains(&axis) || !offset.is_finite() { return Err("invalid section plane".into()); }
353                Some(brep_kernel::feature_pipeline::pmi::PmiSection { axis: axis.into(), offset, flip: params["sectionFlip"].as_bool().unwrap_or(false) })
354            } else { None };
355            (section != view.display.section).then_some(section)
356        } else { None };
357        if name.is_none() && text_size_pt.is_none() && wireframe.is_none() && section.is_none() {
358            return Ok(());
359        }
360        let patch = PmiViewPatch { name, text_size_pt, wireframe, hidden: None, section };
361        self.patch_pmi_view(id, &patch, Some(&format!("pmi:view:{id}")))
362    }
363
364    /// Open view `id`'s dialog (`None` closes it). Opening activates the view,
365    /// as opening one of its annotations does: the dialog describes what is on
366    /// screen. An id that names no view opens nothing and counts nothing.
367    pub fn pmi_set_view_open(&mut self, id: Option<&str>) {
368        match id {
369            Some(id) => {
370                if self.pmi_state().find_view(id).is_none() {
371                    return;
372                }
373                if self.pmi_active_view.as_deref() != Some(id) {
374                    let _ = self.pmi_activate_view(id);
375                }
376                self.open_pmi_view(id.to_string());
377            }
378            None => self.pmi_open_view = None,
379        }
380        self.refresh_pmi_overlay();
381    }
382
383    fn patch_pmi_view(&mut self, id: &str, patch: &PmiViewPatch, coalesce_key: Option<&str>) -> Result<(), String> {
384        let mut state = self.pmi_state();
385        let view = state.find_view_mut(id).ok_or_else(|| format!("no PMI view '{id}'"))?;
386        if let Some(name) = &patch.name {
387            let name = name.trim();
388            if name.is_empty() {
389                return Err("a view needs a name".into());
390            }
391            view.name = name.to_string();
392        }
393        if let Some(size) = patch.text_size_pt {
394            view.display.text_size_pt = brep_kernel::clamp_text_size(size);
395        }
396        if let Some(wireframe) = patch.wireframe {
397            view.display.wireframe = wireframe;
398        }
399        if let Some(hidden) = &patch.hidden {
400            view.display.hidden = hidden.clone();
401        }
402        if let Some(section) = &patch.section { view.display.section = section.clone(); }
403        let display = view.display.clone();
404        let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
405        self.history.set_pmi_block(block, coalesce_key);
406        self.rerun_history();
407        if self.pmi_active_view.as_deref() == Some(id) {
408            self.apply_view_display(&display);
409        }
410        Ok(())
411    }
412
413    /// Remember the modeling state: what is on screen while NO view is
414    /// active is the modeling state (hiding a body in the workbench before
415    /// capturing is a modeling change, not a view's), so it is re-snapshotted
416    /// whenever an activation starts from no active view; while a view is
417    /// active the snapshot is kept (switching views restores to it).
418    fn pmi_remember_modeling(&mut self) {
419        if self.pmi_active_view.is_none() || self.pmi_modeling.is_none() {
420            self.pmi_modeling = Some(PmiModelingSnapshot {
421                camera_json: self.camera_state_json(),
422                hidden: self.hidden_solid_names(),
423                wireframe: self.settings.wireframe,
424            });
425        }
426    }
427
428    /// Entering the PMI workbench: remember the modeling camera, visibility
429    /// and wireframe so a view activation can be undone exactly.
430    pub fn pmi_enter_workbench(&mut self) {
431        self.pmi_remember_modeling();
432    }
433
434    /// Leaving the PMI workbench: deactivate the view (restoring the modeling
435    /// state), close its dialogs, and forget the snapshot. An open SHEET is
436    /// not this workbench's to close — sheets are the Drawing workbench's, and
437    /// the shell closes the paper when a workbench hides the Sheets pane.
438    pub fn pmi_leave_workbench(&mut self) {
439        self.pmi_deactivate_view();
440        self.pmi_modeling = None;
441        self.pmi_open_annotation = None;
442        self.pmi_open_view = None;
443        self.refresh_pmi_overlay();
444    }
445
446    fn apply_view_display(&mut self, display: &PmiDisplay) {
447        let names: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
448        for name in names {
449            let visible = !display.hidden.contains(&name);
450            self.scene.set_visible(&name, visible);
451        }
452        if self.settings.wireframe != display.wireframe {
453            self.settings.wireframe = display.wireframe;
454            self.settings_generation = self.settings_generation.wrapping_add(1);
455        }
456        self.dirty = true;
457    }
458
459    /// Activate a view: apply its camera (refit to the live viewport), hidden
460    /// names, wireframe and explode poses; its annotations become the drawn
461    /// and editable set.
462    pub fn pmi_activate_view(&mut self, id: &str) -> Result<(), String> {
463        let state = self.pmi_state();
464        let view = state.find_view(id).ok_or_else(|| format!("no PMI view '{id}'"))?.clone();
465        if self.pmi_active_view.as_deref() != Some(id) {
466            // Switching views keeps the snapshot: deactivating restores the
467            // modeling state, and the new view starts from it.
468            let keep = self.pmi_active_view.is_some();
469            self.pmi_deactivate_view();
470            if !keep {
471                self.pmi_remember_modeling();
472            }
473        }
474        if self.pmi_modeling.is_none() {
475            self.pmi_remember_modeling();
476        }
477        self.pmi_active_view = Some(id.to_string());
478        if let Some(camera) = &view.camera {
479            let (kind, scale) = match camera.projection {
480                PmiProjection::Orthographic { half_height } => ("orthographic", half_height),
481                PmiProjection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
482            };
483            let json = serde_json::json!({
484                "kind": kind, "eye": camera.eye, "target": camera.target, "up": camera.up, "scale": scale,
485            })
486            .to_string();
487            let _ = self.apply_camera_state_json(&json);
488            self.controls_sync_after_camera_apply();
489        }
490        self.apply_view_display(&view.display);
491        self.pmi_apply_explode();
492        if let Some(open) = &self.pmi_open_annotation {
493            if !view.annotations.iter().any(|a| a.id() == open) {
494                self.pmi_open_annotation = None;
495            }
496        }
497        self.refresh_pmi_overlay();
498        Ok(())
499    }
500
501    /// Deactivate the active view: restore the explode poses, the modeling
502    /// visibility, wireframe and camera. A no-op without an active view.
503    pub fn pmi_deactivate_view(&mut self) {
504        if self.pmi_active_view.take().is_none() {
505            return;
506        }
507        if self.pmi_state().find_annotation(&self.transform_armed_feature()).is_some() { self.disarm_transform(); }
508        self.pmi_restore_explode();
509        if let Some(snapshot) = self.pmi_modeling.clone() {
510            let names: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
511            for name in names {
512                let visible = !snapshot.hidden.contains(&name);
513                self.scene.set_visible(&name, visible);
514            }
515            if self.settings.wireframe != snapshot.wireframe {
516                self.settings.wireframe = snapshot.wireframe;
517                self.settings_generation = self.settings_generation.wrapping_add(1);
518            }
519            let _ = self.apply_camera_state_json(&snapshot.camera_json);
520            self.controls_sync_after_camera_apply();
521        }
522        self.pmi_open_annotation = None;
523        self.dirty = true;
524        self.refresh_pmi_overlay();
525    }
526
527    /// Keep the arcball controls in step with a camera written wholesale.
528    fn controls_sync_after_camera_apply(&mut self) {
529        // The controls read the camera each frame (orbit deltas are applied
530        // onto `self.camera`), so writing the camera is enough; a fresh depth
531        // fit happens on the next render. Nothing else to sync.
532        self.dirty = true;
533    }
534
535    // --- explode (display-only poses) -----------------------------------------
536
537    /// Pose the active view's explode targets on the CURRENT displays,
538    /// keeping the un-posed copies for the restore.
539    pub(crate) fn pmi_apply_explode(&mut self) {
540        self.pmi_restore_explode();
541        let Some(active) = self.pmi_active_view.clone() else {
542            return;
543        };
544        let Some(report) = self.pmi_report.as_ref() else {
545            return;
546        };
547        let Some(view) = report.view(&active) else {
548            return;
549        };
550        let poses: Vec<(Vec<String>, [f64; 3], [f64; 3], [f64; 3], [f64; 3])> = view
551            .annotations
552            .iter()
553            .filter(|row| row.enabled && row.status == PmiStatus::Ok)
554            .filter_map(|row| match &row.geometry {
555                PmiGeometry::Explode { solids, translate, rotate_deg, scale, center, .. } => {
556                    Some((solids.clone(), *translate, *rotate_deg, *scale, *center))
557                }
558                _ => None,
559            })
560            .collect();
561        for (solids, translate, rotate_deg, scale, center) in poses {
562            for name in solids {
563                let Some(display) = self.scene.solid(&name) else { continue };
564                self.pmi_explode_originals
565                    .entry(name.clone())
566                    .or_insert_with(|| display.clone());
567                if let Some(display) = self.scene.solid_mut(&name) {
568                    transform_display(display, center, translate, rotate_deg, scale);
569                }
570            }
571        }
572        if let Some((point, normal)) = self.pmi_state().find_view(&active).and_then(|v| v.section_plane()) {
573            let names: Vec<String> = self.scene.solids().iter().map(|s| s.name.clone()).collect();
574            for name in names {
575                if let Some(display) = self.scene.solid(&name) { self.pmi_explode_originals.entry(name.clone()).or_insert_with(|| display.clone()); }
576                if let Some(display) = self.scene.solid_mut(&name) { super::pmi_section::clip_display(display, point, normal); }
577            }
578        }
579        if !self.pmi_explode_originals.is_empty() {
580            self.dirty = true;
581        }
582    }
583
584    /// Put every exploded display back exactly.
585    pub(crate) fn pmi_restore_explode(&mut self) {
586        let originals = std::mem::take(&mut self.pmi_explode_originals);
587        for (name, original) in originals {
588            if let Some(display) = self.scene.solid_mut(&name) {
589                *display = original;
590            }
591        }
592        self.dirty = true;
593    }
594
595    /// Post-apply tail (`finish_apply`): the displays are fresh, so re-pose
596    /// the active view's explode targets and re-bake the overlay.
597    pub(crate) fn pmi_after_apply(&mut self) {
598        // The fresh report resolved every balloon's head from its stored bubble.
599        self.pmi_balloon_stale = None;
600        // Normally already un-posed by `apply_run_output` (before the scene
601        // reconcile); the parse-error branch of a rerun reaches here with the
602        // poses still applied, so restore rather than forget them.
603        self.pmi_restore_explode();
604        if let Some(active) = self.pmi_active_view.clone() {
605            // A view that vanished (undo of its capture) deactivates.
606            let exists = self.pmi_state().find_view(&active).is_some();
607            if !exists {
608                self.pmi_active_view = None;
609                self.pmi_open_annotation = None;
610            } else {
611                self.pmi_apply_explode();
612            }
613        }
614        if let Some(open) = &self.pmi_open_annotation {
615            if self.pmi_state().find_annotation(open).is_none() {
616                self.pmi_open_annotation = None;
617            }
618        }
619        // A view dialog whose view went away (undo of its capture) closes.
620        if let Some(open) = &self.pmi_open_view {
621            if self.pmi_state().find_view(open).is_none() {
622                self.pmi_open_view = None;
623            }
624        }
625        self.refresh_pmi_overlay();
626    }
627
628    // --- annotations -----------------------------------------------------------
629
630    /// Add an annotation of `type_id` to view `view_id` (the active view when
631    /// `None`) with `params_json` (the schema params; `id` is minted). A datum
632    /// with no letter gets the next unused one. Returns the id; the new
633    /// annotation's form opens.
634    pub fn pmi_add_annotation(
635        &mut self,
636        view_id: Option<&str>,
637        type_id: &str,
638        params_json: &str,
639    ) -> Result<String, String> {
640        let def = brep_kernel::pmi_type(type_id).ok_or_else(|| format!("unknown PMI annotation type '{type_id}'"))?;
641        let mut state = self.pmi_state();
642        let view_id = view_id
643            .map(String::from)
644            .or_else(|| self.pmi_active_view.clone())
645            .ok_or_else(|| "no active PMI view — capture or activate a view first".to_string())?;
646        let id = state.next_id(def.short_name);
647        let mut params: serde_json::Value = serde_json::from_str(params_json).unwrap_or_else(|_| serde_json::json!({}));
648        if !params.is_object() {
649            params = serde_json::json!({});
650        }
651        // Schema defaults under the given params.
652        let schema = (def.schema)();
653        if let (Some(fields), Some(object)) = (
654            schema.get("inputParamsSchema").and_then(serde_json::Value::as_object),
655            params.as_object_mut(),
656        ) {
657            for (key, spec) in fields {
658                if !object.contains_key(key) {
659                    if let Some(default) = spec.get("default_value") {
660                        if !default.is_null() {
661                            object.insert(key.clone(), default.clone());
662                        }
663                    }
664                }
665            }
666            object.insert("id".into(), serde_json::Value::String(id.clone()));
667            if type_id == "datum" {
668                let letter = object.get("letter").and_then(serde_json::Value::as_str).unwrap_or("").trim().to_string();
669                if letter.is_empty() {
670                    if let Some(next) = state.next_datum_letter() {
671                        object.insert("letter".into(), serde_json::Value::String(next));
672                    }
673                }
674            }
675        }
676        let view = state.find_view_mut(&view_id).ok_or_else(|| format!("no PMI view '{view_id}'"))?;
677        view.annotations.push(PmiAnnotation {
678            plugin_replay: None,
679            persistent_data: serde_json::Value::Null,
680            kind: type_id.to_string(),
681            enabled: true,
682            params,
683            label_world: None,
684        });
685        self.open_pmi_annotation(id.clone());
686        self.write_pmi_state(state);
687        Ok(id)
688    }
689
690    /// Replace an annotation's params (a form edit). Re-runs.
691    pub fn pmi_update_annotation(&mut self, id: &str, params_json: &str) -> Result<(), String> {
692        if self.pmi_state().find_annotation(id).is_some_and(|(_, a)| a.kind.contains('/')) {
693            let params = serde_json::from_str(params_json).map_err(|e| format!("annotation params: {e}"))?;
694            return self.plugin_update_annotation(id, params).map(|_| ());
695        }
696        self.pmi_update_annotation_no_rerun(id, params_json)?;
697        self.rerun_history();
698        Ok(())
699    }
700
701    /// The fold half of [`Self::pmi_update_annotation`] (checkpointed, no
702    /// re-run) — the reference picker's commit uses it before its shared
703    /// end tail re-runs.
704    pub(crate) fn pmi_update_annotation_no_rerun(&mut self, id: &str, params_json: &str) -> Result<(), String> {
705        let mut state = self.pmi_state();
706        let annotation = state.find_annotation_mut(id).ok_or_else(|| format!("no PMI annotation '{id}'"))?;
707        if annotation.kind.contains('/') {
708            return Err("plugin annotation edits require the worker transaction path".into());
709        }
710        let mut params: serde_json::Value =
711            serde_json::from_str(params_json).map_err(|error| format!("annotation params: {error}"))?;
712        if let Some(object) = params.as_object_mut() {
713            object.insert("id".into(), serde_json::Value::String(id.to_string()));
714        }
715        annotation.params = params;
716        let block = serde_json::to_value(&state).ok();
717        self.history.set_pmi_block(block, Some(&format!("pmi:params:{id}")));
718        Ok(())
719    }
720
721    pub fn pmi_remove_annotation(&mut self, id: &str) -> Result<(), String> {
722        if self.pmi_state().find_annotation(id).is_some_and(|(_, a)| a.kind.contains('/')) {
723            return self.plugin_delete_annotation(id).map(|_| ());
724        }
725        let mut state = self.pmi_state();
726        let Some((view_index, index)) = state.locate_annotation(id) else {
727            return Err(format!("no PMI annotation '{id}'"));
728        };
729        state.views[view_index].annotations.remove(index);
730        if self.pmi_open_annotation.as_deref() == Some(id) {
731            self.pmi_open_annotation = None;
732        }
733        self.write_pmi_state(state);
734        Ok(())
735    }
736
737    pub fn pmi_set_annotation_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
738        if self.pmi_state().find_annotation(id).is_some_and(|(_, a)| a.kind.contains('/')) {
739            return self.plugin_set_annotation_enabled(id, enabled).map(|_| ());
740        }
741        let mut state = self.pmi_state();
742        let annotation = state.find_annotation_mut(id).ok_or_else(|| format!("no PMI annotation '{id}'"))?;
743        if annotation.enabled == enabled {
744            return Ok(());
745        }
746        annotation.enabled = enabled;
747        self.write_pmi_state(state);
748        Ok(())
749    }
750
751    /// Move an annotation to `index` within its view.
752    pub fn pmi_move_annotation(&mut self, id: &str, index: usize) -> Result<(), String> {
753        let mut state = self.pmi_state();
754        let Some((view_index, from)) = state.locate_annotation(id) else {
755            return Err(format!("no PMI annotation '{id}'"));
756        };
757        let annotations = &mut state.views[view_index].annotations;
758        let annotation = annotations.remove(from);
759        let to = index.min(annotations.len());
760        annotations.insert(to, annotation);
761        self.write_pmi_state(state);
762        Ok(())
763    }
764
765    /// Move an annotation to another view (append).
766    pub fn pmi_move_annotation_to_view(&mut self, id: &str, view_id: &str) -> Result<(), String> {
767        let mut state = self.pmi_state();
768        let Some((view_index, from)) = state.locate_annotation(id) else {
769            return Err(format!("no PMI annotation '{id}'"));
770        };
771        if state.find_view(view_id).is_none() {
772            return Err(format!("no PMI view '{view_id}'"));
773        }
774        let annotation = state.views[view_index].annotations.remove(from);
775        state.find_view_mut(view_id).expect("checked").annotations.push(annotation);
776        self.write_pmi_state(state);
777        Ok(())
778    }
779
780    /// Open one annotation's form (engine memory; `None` closes). Opening an
781    /// annotation of another view activates that view. An open COUNTS
782    /// ([`Self::open_pmi_annotation`]) — including a re-open of the annotation
783    /// already open, which is a viewport label double click on the form the user
784    /// cannot see; an id that resolves to no annotation opens nothing and
785    /// counts nothing.
786    pub fn pmi_set_annotation_open(&mut self, id: Option<&str>) {
787        match id {
788            Some(id) => {
789                let state = self.pmi_state();
790                if let Some((view, _)) = state.find_annotation(id) {
791                    let view_id = view.id.clone();
792                    if self.pmi_active_view.as_deref() != Some(view_id.as_str()) {
793                        let _ = self.pmi_activate_view(&view_id);
794                    }
795                    self.open_pmi_annotation(id.to_string());
796                }
797            }
798            None => {
799                if self.pmi_open_annotation.as_deref() == Some(self.transform_armed_feature().as_str()) { self.disarm_transform(); }
800                self.pmi_open_annotation = None;
801            },
802        }
803        self.refresh_pmi_overlay();
804    }
805
806    // --- labels -----------------------------------------------------------------
807
808    /// Move an annotation's label (world). Coalesced per annotation into ONE
809    /// undo step, and NEVER a re-run: the cached report is patched in place
810    /// and the overlay re-baked. A balloon's arrow head is re-derived from
811    /// the moved bubble ([`Self::pmi_reproject_balloon_head`]).
812    pub fn pmi_set_label_world(&mut self, id: &str, world: [f64; 3]) -> Result<(), String> {
813        let mut state = self.pmi_state();
814        let annotation = state.find_annotation_mut(id).ok_or_else(|| format!("no PMI annotation '{id}'"))?;
815        annotation.label_world = Some(world);
816        let block = serde_json::to_value(&state).ok();
817        self.history.set_pmi_block(block, Some(&format!("pmi:label:{id}")));
818        if let Some(report) = self.pmi_report.as_mut() {
819            if let Some(row) = report.annotation_mut(id) {
820                row.label_world = world;
821                if let PmiGeometry::Note { position } = &mut row.geometry {
822                    *position = world;
823                }
824            }
825        }
826        self.pmi_reproject_balloon_head(id);
827        self.refresh_pmi_overlay();
828        Ok(())
829    }
830
831    /// Re-derive a balloon's arrow head from its row's bubble through the
832    /// recipe the kernel put on its leader (the same projection the resolver
833    /// ran, on the exact-solid clones the scene holds), and patch the cached
834    /// report. The head is derived, never stored, so no document edit and no
835    /// run: the next run resolves the same head from the stored bubble. A
836    /// solid the scene does not hold yet is asked of the runner; the head is
837    /// re-derived when the reply lands ([`Self::pmi_refresh_stale_balloon`]),
838    /// which for the Inline runner is before this returns. Any other
839    /// annotation is left alone.
840    pub(crate) fn pmi_reproject_balloon_head(&mut self, id: &str) {
841        if let Some(missing) = self.pmi_balloon_head_now(id) {
842            self.pmi_balloon_stale = Some(id.to_string());
843            self.request_exact_solids(missing.iter().map(String::as_str));
844            self.pmi_refresh_stale_balloon();
845        }
846    }
847
848    /// Retry the balloon whose re-projection waited on a topology reply.
849    pub(crate) fn pmi_refresh_stale_balloon(&mut self) {
850        let Some(id) = self.pmi_balloon_stale.take() else {
851            return;
852        };
853        if self.pmi_balloon_head_now(&id).is_some() {
854            // Still waiting (a background runner's reply is on its way).
855            self.pmi_balloon_stale = Some(id);
856        } else {
857            self.refresh_pmi_overlay();
858        }
859    }
860
861    /// One re-projection attempt: `None` when done (or not a balloon), else
862    /// the occurrence solids the scene does not hold.
863    fn pmi_balloon_head_now(&mut self, id: &str) -> Option<Vec<String>> {
864        let (anchor, bubble) = self
865            .pmi_report
866            .as_ref()
867            .and_then(|report| report.annotation(id))
868            .and_then(|row| match &row.geometry {
869                PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => Some((anchor.clone(), row.label_world)),
870                _ => None,
871            })?;
872        let mut missing = Vec::new();
873        let head = brep_kernel::pmi_balloon_head(&anchor, bubble, |name| self.scene.exact_solid(name), &mut missing);
874        if !missing.is_empty() {
875            return Some(missing);
876        }
877        // A fixed (vertex) anchor answers `None`: the head never moves. A
878        // projection error keeps the kernel's head rather than a wrong one.
879        if let Ok(Some(head)) = head {
880            if let Some(row) = self.pmi_report.as_mut().and_then(|report| report.annotation_mut(id)) {
881                if let PmiGeometry::Leader { targets, .. } = &mut row.geometry {
882                    if let Some(first) = targets.first_mut() {
883                        *first = head;
884                    }
885                }
886            }
887        }
888        None
889    }
890
891    /// Drag an annotation's label to the pointer: the new position is where
892    /// the pick ray crosses the plane through the current label perpendicular
893    /// to the viewing direction (so a drag never changes the label's depth).
894    pub fn pmi_label_drag_to(&mut self, id: &str, x: f64, y: f64) {
895        let Some((current, plane)) = self
896            .pmi_report
897            .as_ref()
898            .and_then(|r| r.annotation(id))
899            .map(|r| (r.label_world, r.plane))
900        else {
901            return;
902        };
903        let ray = self.camera.pick_ray(x, y);
904        // In a picked annotation plane the label stays ON that plane; a
905        // view-aligned label moves in the view-parallel plane through its
906        // current position.
907        let plane = plane.unwrap_or_else(|| {
908            let (_, _, view) = self.camera.basis();
909            brep_kernel::PmiPlane {
910                origin: current,
911                normal: view,
912                x_axis: [0.0; 3],
913            }
914        });
915        let Some(world) = plane.hit(ray.origin, ray.dir) else {
916            return;
917        };
918        let _ = self.pmi_set_label_world(id, world);
919    }
920
921    /// A label drag ended: the next drag is a fresh undo step.
922    pub fn pmi_label_drag_end(&mut self) {
923        self.history.break_coalescing();
924    }
925
926    /// Hover a label: highlight the annotation's referenced geometry.
927    pub fn pmi_hover(&mut self, id: &str) {
928        self.pmi_label_hover_active = true;
929        if self.pmi_hovered.as_deref() == Some(id) {
930            return;
931        }
932        let references: Vec<String> = self
933            .pmi_report
934            .as_ref()
935            .and_then(|report| report.annotation(id))
936            .map(|row| row.references.clone())
937            .unwrap_or_default();
938        let mut solids: Vec<String> = Vec::new();
939        let mut faces: Vec<String> = Vec::new();
940        let mut edges: Vec<String> = Vec::new();
941        for reference in &references {
942            if let Some(at) = reference.find('@') {
943                solids.push(reference[..at].to_string());
944            } else if self.scene_has_face(reference) {
945                faces.push(reference.clone());
946            } else if self.scene_has_edge(reference) {
947                edges.push(reference.clone());
948            } else if self.scene.solid(reference).is_some() {
949                solids.push(reference.clone());
950            } else {
951                let prefix = format!("{reference}:");
952                solids.extend(self.scene.solids().iter().filter(|s| s.name.starts_with(&prefix)).map(|s| s.name.clone()));
953            }
954        }
955        self.clear_hover();
956        self.emphasis.hovered_solids.extend(solids);
957        self.emphasis.hovered_faces.extend(faces);
958        self.emphasis.hovered_edges.extend(edges);
959        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
960        self.pmi_hovered = Some(id.to_string());
961        self.dirty = true;
962    }
963
964    pub fn pmi_hover_end(&mut self) {
965        if self.pmi_hovered.take().is_some() {
966            self.clear_hover();
967            self.dirty = true;
968        }
969    }
970
971    /// Consume the one-frame "a PMI label is hovering elements" flag (the
972    /// viewport's scene-hover pass yields while set).
973    pub fn take_pmi_label_hover(&mut self) -> bool {
974        std::mem::take(&mut self.pmi_label_hover_active)
975    }
976
977    /// A label was clicked: SELECT that annotation, as its tree row's single
978    /// click does — the label carries the accent and nothing opens.
979    pub fn pmi_label_clicked(&mut self, id: &str) {
980        self.pmi_select_annotation(id);
981    }
982
983    /// A label was double-clicked: open that annotation's form, as its tree
984    /// row's double click does.
985    pub fn pmi_label_double_clicked(&mut self, id: &str) {
986        self.pmi_set_annotation_open(Some(id));
987    }
988
989    /// SELECT an annotation without opening it — the PMI tree's single click.
990    /// Its view activates when it is not the active one, as opening does, so
991    /// the selected label is on screen to carry the selection accent. Returns
992    /// false, changing nothing, for an id that names no annotation.
993    pub fn pmi_select_annotation(&mut self, id: &str) -> bool {
994        let state = self.pmi_state();
995        let Some((view, _)) = state.find_annotation(id) else {
996            return false;
997        };
998        if self.pmi_active_view.as_deref() != Some(view.id.as_str()) {
999            let _ = self.pmi_activate_view(&view.id);
1000        }
1001        self.pmi_selected_view = None;
1002        self.pmi_selected_annotation = Some(id.to_string());
1003        self.refresh_pmi_overlay();
1004        true
1005    }
1006
1007    /// SELECT a view without activating it — the PMI tree's single click on a
1008    /// view row. The camera, visibility and wireframe stay as they are: a
1009    /// double click (which opens the view's dialog) or the row's On toggle is
1010    /// what activates. A selected annotation gives up the selection. Returns
1011    /// false, changing nothing, for an id that names no view.
1012    pub fn pmi_select_view(&mut self, id: &str) -> bool {
1013        if self.pmi_state().find_view(id).is_none() {
1014            return false;
1015        }
1016        if self.pmi_selected_annotation.take().is_some() {
1017            self.refresh_pmi_overlay();
1018        }
1019        self.pmi_selected_view = Some(id.to_string());
1020        self.dirty = true;
1021        true
1022    }
1023
1024    // --- reference picker ---------------------------------------------------------
1025
1026    /// Enter reference-selection mode for annotation `id`'s field at `path`.
1027    pub fn begin_ref_select_for_pmi(
1028        &mut self,
1029        id: &str,
1030        path: Vec<String>,
1031        label: String,
1032        filter: Vec<String>,
1033        multiple: bool,
1034        seed_names: Vec<String>,
1035    ) {
1036        let restore_index = self.history.rollback();
1037        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
1038        self.ref_select = Some(RefSelectState {
1039            feature_id: id.to_string(),
1040            path,
1041            label,
1042            filter,
1043            multiple,
1044            names: seed_names,
1045            restore_index,
1046            target: RefSelectTarget::Pmi,
1047        });
1048        self.sync_ref_select_emphasis();
1049    }
1050
1051    /// Commit a finished PMI ref-select into the annotation's params (fold
1052    /// only — the caller's shared tail re-runs).
1053    pub(crate) fn pmi_commit_refs(&mut self, id: &str, path: &[String], names: &[String], multiple: bool) {
1054        let state = self.pmi_state();
1055        let Some((_, annotation)) = state.find_annotation(id) else {
1056            self.push_notice(format!("unknown PMI annotation '{id}'"));
1057            return;
1058        };
1059        let mut params = annotation.params.clone();
1060        let value = if multiple {
1061            serde_json::Value::Array(names.iter().cloned().map(serde_json::Value::String).collect())
1062        } else {
1063            serde_json::Value::String(names.first().cloned().unwrap_or_default())
1064        };
1065        super::selection_ux::set_json_at(&mut params, path, value);
1066        if let Err(error) = self.pmi_update_annotation_no_rerun(id, &params.to_string()) {
1067            self.push_notice(format!("PMI update failed: {error}"));
1068        }
1069        // A pick is a discrete change (it ends with Finish): the next edit of
1070        // the same annotation's params is its own undo step, so picking an
1071        // anchor and then clearing it in the form undo one at a time.
1072        self.history.break_coalescing();
1073    }
1074
1075    // --- import -------------------------------------------------------------------
1076
1077    /// Merge a file's lifted PMI (`read_step_pmi`) into the document beside
1078    /// the import that added its geometry: views append with fresh ids, the
1079    /// import's undo checkpoint covers both (no second checkpoint).
1080    pub(crate) fn pmi_merge_imported(&mut self, lifted: PmiState) {
1081        let mut state = self.pmi_state();
1082        for view in lifted.views {
1083            let id = state.next_id("VIEW");
1084            let mut annotations = Vec::with_capacity(view.annotations.len());
1085            for mut annotation in view.annotations {
1086                let prefix = brep_kernel::pmi_type(&annotation.kind).map(|def| def.short_name).unwrap_or("PMI");
1087                let fresh = state.next_id(prefix);
1088                if let Some(object) = annotation.params.as_object_mut() {
1089                    object.insert("id".into(), serde_json::Value::String(fresh));
1090                }
1091                annotations.push(annotation);
1092            }
1093            state.views.push(PmiView {
1094                id,
1095                name: view.name,
1096                camera: view.camera,
1097                display: view.display,
1098                annotations,
1099            });
1100        }
1101        let block = serde_json::to_value(&state).ok();
1102        self.history.set_pmi_block_no_undo(block);
1103        self.rerun_history();
1104    }
1105}
1106
1107/// Pose a display in place: `p' = R((p − c) ∘ s) + c + t` on the mesh, the
1108/// edge polylines and the vertices; normals rotate; the bbox is rebuilt.
1109pub(super) fn transform_display(
1110    display: &mut crate::scene::SolidDisplay,
1111    center: [f64; 3],
1112    translate: [f64; 3],
1113    rotate_deg: [f64; 3],
1114    scale: [f64; 3],
1115) {
1116    display.revision = crate::scene::next_revision();
1117    let rotate = |v: [f64; 3]| super::rotate_euler_xyz_f64(v, rotate_deg);
1118    let pose = |p: [f64; 3]| -> [f64; 3] {
1119        let local = [(p[0] - center[0]) * scale[0], (p[1] - center[1]) * scale[1], (p[2] - center[2]) * scale[2]];
1120        let rotated = rotate(local);
1121        [rotated[0] + center[0] + translate[0], rotated[1] + center[1] + translate[1], rotated[2] + center[2] + translate[2]]
1122    };
1123    let mut bbox = crate::camera::Aabb::empty();
1124    for position in &mut display.mesh.positions {
1125        let posed = pose([position[0] as f64, position[1] as f64, position[2] as f64]);
1126        *position = [posed[0] as f32, posed[1] as f32, posed[2] as f32];
1127        bbox.expand(posed);
1128    }
1129    for normal in &mut display.mesh.normals {
1130        let rotated = rotate(std::array::from_fn(|i| normal[i] as f64 / scale[i]));
1131        let length = rotated.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-20);
1132        *normal = rotated.map(|v| (v / length) as f32);
1133    }
1134    for edge in &mut display.edges {
1135        for point in &mut edge.polyline {
1136            let posed = pose([point[0] as f64, point[1] as f64, point[2] as f64]);
1137            *point = [posed[0] as f32, posed[1] as f32, posed[2] as f32];
1138            bbox.expand(posed);
1139        }
1140    }
1141    for vertex in &mut display.vertices {
1142        vertex.position = pose(vertex.position);
1143        bbox.expand(vertex.position);
1144    }
1145    if !bbox.is_empty() {
1146        display.bbox = bbox;
1147    }
1148}
1149