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