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