Skip to main content

brep_app/panels/
pmi.rs

1//! The PMI panel — the view TREE on the shared [`crate::column_tree`]
2//! widget: one row per PMI view, its annotations as children, and — when an
3//! annotation is open — that annotation's dialog through the SHARED
4//! [`crate::form_view`] (the same function the feature and constraint
5//! dialogs use; the schema comes from the kernel's `pmi_schema_catalogue`).
6//!
7//! # Tree mode
8//!
9//! Header: **Capture view** (snapshot the camera + visibility into a new,
10//! active view), **+ Add annotation** (the nine types, enabled while a view
11//! is active — creation is gated on an active view), the active view's
12//! **Text size** (points) and a summary.
13//!
14//! Rows: a VIEW row shows its name (editable — rename in place), whether its
15//! camera is captured, its annotation count and an **active** toggle
16//! (activating applies the camera / visibility / wireframe / explode poses;
17//! deactivating restores the modeling state); its menu offers Update camera,
18//! Update visibility, Wireframe on/off, Delete. An ANNOTATION row
19//! shows the type icon, its id, its resolved text (the value + tolerance, the
20//! note, the callout …) or its error, a status badge and an **enabled**
21//! toggle; a click opens its form; its menu offers Edit, Move up / down and
22//! Delete. Hovering an annotation row highlights the geometry it references.
23//!
24//! # Form mode
25//!
26//! Which annotation is open is the ENGINE's `pmi_open_annotation` (a mode,
27//! not model state): the viewport's label click and the context bar's
28//! add-from-selection open one without going through here, so the mode is
29//! read, never owned. Editing is LIVE (every change re-resolves); reference
30//! fields use the engine's modal picker in its PMI flavour (vertex refs in
31//! world coordinates); **Return to tree** closes the form.
32//!
33//! Everything the panel knows comes from the engine (`pmi_state`,
34//! `pmi_report`); this struct holds the widget's transient state only.
35
36use crate::automation::hit_keys::HitKeyDoc;
37use crate::column_tree::{self, CellKind, ColumnLayout, ColumnSpec, ColumnTreeSpec, RowAction, RowNode};
38use crate::form_view::{form_view, FormViewSpec};
39use brep_render::brep_kernel::{pmi_schema_catalogue, pmi_type, PmiReport, PmiState, PmiStatus, PMI_TYPES};
40use brep_render::engine_state::{EngineState, PmiViewPatch};
41use brep_render::features::form_fields_from_schema;
42use eframe::egui;
43use serde_json::Value;
44use std::collections::{HashMap, HashSet};
45
46/// Who this panel is when it drives the viewport's dialog-row hover
47/// (`EngineState::hover_entity_by_name`) — the owner tag that keeps its
48/// highlight independent of the history form's and the Scene tree's.
49const DIALOG_HOVER_OWNER: &str = "pmi";
50
51const NAME: &str = "name";
52const KIND: &str = "kind";
53const VALUE: &str = "value";
54const STATUS: &str = "status";
55const ON: &str = "on";
56const ACTIONS: &str = "actions";
57
58const OK_COLOR: &str = "#3fb950";
59const ERROR_COLOR: &str = "#f85149";
60const ACTIVE_COLOR: &str = "#58a6ff";
61const MUTED_COLOR: &str = "#8b949e";
62
63/// Row-menu action ids.
64const ACT_ACTIVATE: &str = "activate";
65const ACT_DEACTIVATE: &str = "deactivate";
66const ACT_UPDATE_CAMERA: &str = "update-camera";
67const ACT_UPDATE_VISIBILITY: &str = "update-visibility";
68const ACT_WIREFRAME: &str = "wireframe";
69const ACT_DELETE_VIEW: &str = "delete-view";
70const ACT_EDIT: &str = "edit";
71const ACT_UP: &str = "move-up";
72const ACT_DOWN: &str = "move-down";
73const ACT_DELETE: &str = "delete";
74
75/// A deferred engine mutation (one per frame).
76enum Action {
77    Capture,
78    Add(String),
79    TextSize(String, f64),
80    Rename(String, String),
81    Activate(String),
82    Deactivate,
83    UpdateCamera(String),
84    UpdateVisibility(String),
85    Wireframe(String, bool),
86    DeleteView(String),
87    SetEnabled(String, bool),
88    Open(Option<String>),
89    Move(String, usize),
90    Delete(String),
91    UpdateParams(String, Value),
92    BeginRefSelect {
93        id: String,
94        path: Vec<String>,
95        label: String,
96        filter: Vec<String>,
97        multiple: bool,
98        seed: Vec<String>,
99    },
100}
101
102/// The panel's transient UI state.
103pub struct PmiPanel {
104    hits: HashMap<String, egui::Rect>,
105    layout: ColumnLayout,
106    columns: Vec<ColumnSpec>,
107    /// Views collapsed in the tree (every view starts expanded).
108    collapsed: HashSet<String>,
109    hovered: Option<String>,
110    /// A hover change the tree draw recorded, applied to the engine after
111    /// the draw (`Some(None)` = the pointer left the annotation rows).
112    pending_hover: Option<Option<String>>,
113}
114
115impl Default for PmiPanel {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl PmiPanel {
122    pub fn new() -> Self {
123        Self {
124            hits: HashMap::new(),
125            layout: ColumnLayout::default(),
126            columns: vec![
127                ColumnSpec::new(NAME, "View / annotation", CellKind::Text).width(150.0),
128                ColumnSpec::new(KIND, "", CellKind::Badges).width(28.0),
129                ColumnSpec::new(VALUE, "Value", CellKind::ReadOnly).width(150.0),
130                ColumnSpec::new(STATUS, "", CellKind::Badges).width(28.0),
131                ColumnSpec::new(ON, "On", CellKind::Toggle).width(30.0),
132                ColumnSpec::new(ACTIONS, "", CellKind::Actions { label: "\u{22EF}".into() }).width(30.0),
133            ],
134            collapsed: HashSet::new(),
135            hovered: None,
136            pending_hover: None,
137        }
138    }
139
140    /// Draw the panel: the open annotation's form, else the view tree.
141    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
142        self.hits.clear();
143        self.hits.insert("pmi:panel:clip".into(), ui.clip_rect());
144        let pmi = state.pmi_state();
145        let report = state.pmi_report().cloned().unwrap_or_default();
146        let active = state.pmi_active_view().map(String::from);
147        let open = state.pmi_open_annotation().map(String::from);
148
149        let mut action: Option<Action> = None;
150        let mut close = false;
151        // The entity a hovered reference LINE names, kept out of `action` (which
152        // carries the ONE deferred mutation) — hovering coincides with anything.
153        let mut hover: Option<String> = None;
154        match open.as_deref().and_then(|id| pmi.find_annotation(id).map(|(view, annotation)| (view.id.clone(), annotation.clone()))) {
155            Some((_, annotation)) => self.show_form(ui, &annotation, &report, &mut action, &mut close, &mut hover),
156            None => self.show_tree(ui, &pmi, &report, active.as_deref(), &mut action),
157        }
158
159        let result: Result<(), String> = match action {
160            Some(Action::Capture) => {
161                state.pmi_capture_view(None);
162                Ok(())
163            }
164            Some(Action::Add(type_id)) => state.pmi_add_annotation(None, &type_id, "{}").map(|_| ()),
165            Some(Action::TextSize(id, size)) => state.pmi_set_view_display(&id, &PmiViewPatch { text_size_pt: Some(size), ..Default::default() }),
166            Some(Action::Rename(id, name)) => state.pmi_rename_view(&id, &name),
167            Some(Action::Activate(id)) => state.pmi_activate_view(&id),
168            Some(Action::Deactivate) => {
169                state.pmi_deactivate_view();
170                Ok(())
171            }
172            Some(Action::UpdateCamera(id)) => state.pmi_update_view_camera(&id),
173            Some(Action::UpdateVisibility(id)) => state.pmi_update_view_visibility(&id),
174            Some(Action::Wireframe(id, on)) => state.pmi_set_view_display(&id, &PmiViewPatch { wireframe: Some(on), ..Default::default() }),
175            Some(Action::DeleteView(id)) => state.pmi_delete_view(&id),
176            Some(Action::SetEnabled(id, on)) => state.pmi_set_annotation_enabled(&id, on),
177            Some(Action::Open(id)) => {
178                state.pmi_set_annotation_open(id.as_deref());
179                Ok(())
180            }
181            Some(Action::Move(id, index)) => state.pmi_move_annotation(&id, index),
182            Some(Action::Delete(id)) => state.pmi_remove_annotation(&id),
183            Some(Action::UpdateParams(id, params)) => state.pmi_update_annotation(&id, &params.to_string()),
184            Some(Action::BeginRefSelect { id, path, label, filter, multiple, seed }) => {
185                state.begin_ref_select_for_pmi(&id, path, label, filter, multiple, seed);
186                Ok(())
187            }
188            None => Ok(()),
189        };
190        if let Err(error) = result {
191            state.push_notice(format!("PMI: {error}"));
192        }
193        if close {
194            state.pmi_set_annotation_open(None);
195        }
196        if let Some(hover) = self.pending_hover.take() {
197            match hover {
198                Some(id) => state.pmi_hover(&id),
199                None => state.pmi_hover_end(),
200            }
201        }
202        // A hovered reference line lights the ENTITY it names in the 3D view (the
203        // annotation-hover above lights the ANNOTATION — different things, own
204        // slots). Applied every frame, including from the tree branch where it
205        // ends what the form had lit.
206        let hover_changed = match &hover {
207            Some(name) => state.hover_entity_by_name(DIALOG_HOVER_OWNER, name),
208            None => state.dialog_hover_end(DIALOG_HOVER_OWNER),
209        };
210        if hover_changed {
211            // The viewport tile may have drawn BEFORE this pane in the dock.
212            ui.ctx().request_repaint();
213        }
214    }
215
216    #[allow(clippy::too_many_arguments)]
217    fn show_tree(
218        &mut self,
219        ui: &mut egui::Ui,
220        pmi: &PmiState,
221        report: &PmiReport,
222        active: Option<&str>,
223        action: &mut Option<Action>,
224    ) {
225        // --- header -------------------------------------------------------------
226        ui.horizontal_wrapped(|ui| {
227            let capture = ui
228                .add(crate::icon_text::icon_button(ui, "\u{1F5CE} Capture view"))
229                .on_hover_text("Snapshot the current camera and visibility into a new view and activate it");
230            self.hits.insert("pmi:capture".into(), capture.rect);
231            if capture.clicked() {
232                *action = Some(Action::Capture);
233            }
234            let can_add = active.is_some();
235            let mut add_type: Option<String> = None;
236            ui.add_enabled_ui(can_add, |ui| {
237                let combo = egui::ComboBox::from_id_salt("pmi-add")
238                    .selected_text("+ Add annotation")
239                    .show_ui(ui, |ui| {
240                        for def in PMI_TYPES.iter() {
241                            let item = crate::icon_text::selectable_icon_label(ui, false, def.long_name);
242                            self.hits.insert(format!("pmi:add:{}", def.type_id), item.rect);
243                            if item.clicked() {
244                                add_type = Some(def.type_id.to_string());
245                            }
246                        }
247                    });
248                let response = if can_add {
249                    combo.response
250                } else {
251                    combo.response.on_disabled_hover_text("Capture or activate a view first")
252                };
253                self.hits.insert("pmi:add".into(), response.rect);
254            });
255            if let Some(type_id) = add_type {
256                *action = Some(Action::Add(type_id));
257            }
258            if let Some(view) = active.and_then(|id| pmi.find_view(id)) {
259                let mut size = view.display.text_size_pt;
260                let drag = ui
261                    .add(egui::DragValue::new(&mut size).range(1.0..=288.0).speed(0.5).suffix(" pt"))
262                    .on_hover_text("Label text size for the active view (1–288 pt)");
263                self.hits.insert("pmi:textsize".into(), drag.rect);
264                if drag.changed() {
265                    *action = Some(Action::TextSize(view.id.clone(), size));
266                }
267            }
268        });
269        let annotation_count: usize = pmi.views.iter().map(|view| view.annotations.len()).sum();
270        ui.label(
271            egui::RichText::new(match active.and_then(|id| pmi.find_view(id)) {
272                Some(view) => format!(
273                    "{} view{} | {annotation_count} annotation{} | active: {}",
274                    pmi.views.len(),
275                    if pmi.views.len() == 1 { "" } else { "s" },
276                    if annotation_count == 1 { "" } else { "s" },
277                    view.name
278                ),
279                None => format!(
280                    "{} view{} | {annotation_count} annotation{} | no active view",
281                    pmi.views.len(),
282                    if pmi.views.len() == 1 { "" } else { "s" },
283                    if annotation_count == 1 { "" } else { "s" },
284                ),
285            })
286            .weak(),
287        );
288        if active.is_none() {
289            ui.label(egui::RichText::new("Capture a view to start annotating").weak().italics());
290        }
291        ui.add_space(2.0);
292
293        // --- the tree ---------------------------------------------------------------
294        let rows: Vec<RowNode> = pmi
295            .views
296            .iter()
297            .map(|view| {
298                let is_active = active == Some(view.id.as_str());
299                let view_report = report.view(&view.id);
300                let count = view.annotations.len();
301                let mut row = RowNode::new(&view.id)
302                    .cell(NAME, Value::String(view.name.clone()))
303                    .cell(KIND, serde_json::json!([{ "glyph": "\u{1F441}", "color": if is_active { ACTIVE_COLOR } else { MUTED_COLOR }, "tooltip": "PMI view" }]))
304                    .cell(
305                        VALUE,
306                        Value::String(format!(
307                            "{} · {count} annotation{}",
308                            match view.camera.as_ref().map(|c| &c.projection) {
309                                Some(brep_render::brep_kernel::PmiProjection::Orthographic { .. }) => "orthographic",
310                                Some(brep_render::brep_kernel::PmiProjection::Perspective { .. }) => "perspective",
311                                None => "no camera",
312                            },
313                            if count == 1 { "" } else { "s" }
314                        )),
315                    )
316                    .cell(
317                        STATUS,
318                        if is_active {
319                            serde_json::json!([{ "glyph": "\u{25CF}", "color": ACTIVE_COLOR, "tooltip": "active view" }])
320                        } else {
321                            serde_json::json!([])
322                        },
323                    )
324                    .cell(ON, Value::Bool(is_active))
325                    .actions(vec![
326                        if is_active {
327                            RowAction::new(ACT_DEACTIVATE, "Deactivate view").tooltip("Restore the modeling camera and visibility")
328                        } else {
329                            RowAction::new(ACT_ACTIVATE, "Activate view").tooltip("Apply this view's camera, visibility and wireframe")
330                        },
331                        RowAction::new(ACT_UPDATE_CAMERA, "Update camera").tooltip("Re-capture the camera from the current viewpoint"),
332                        RowAction::new(ACT_UPDATE_VISIBILITY, "Update visibility").tooltip("Re-capture which objects are hidden"),
333                        RowAction::new(ACT_WIREFRAME, if view.display.wireframe { "Wireframe off" } else { "Wireframe on" }),
334                        RowAction::new(ACT_DELETE_VIEW, "Delete view").tooltip("Delete the view and its annotations").separator_above().destructive(),
335                    ]);
336                row.expanded = !self.collapsed.contains(&view.id);
337                row.selected = is_active;
338                row.children = view
339                    .annotations
340                    .iter()
341                    .enumerate()
342                    .map(|(index, annotation)| {
343                        let id = annotation.id().to_string();
344                        let resolved = view_report.and_then(|v| v.annotations.iter().find(|r| r.id == id));
345                        let def = pmi_type(&annotation.kind);
346                        let (status_glyph, status_color, tooltip, text) = match resolved {
347                            Some(row) if row.status == PmiStatus::Ok => ("\u{2713}", OK_COLOR, "resolved".to_string(), row.text.replace('\n', " / ")),
348                            Some(row) => ("\u{2715}", ERROR_COLOR, row.message.clone(), row.message.clone()),
349                            None => ("\u{2013}", MUTED_COLOR, "not resolved yet".to_string(), String::new()),
350                        };
351                        let mut child = RowNode::new(&id)
352                            .cell(NAME, Value::String(id.clone()))
353                            .cell(
354                                KIND,
355                                serde_json::json!([{ "glyph": def.map(|d| d.icon).unwrap_or("?"), "color": if annotation.enabled { ACTIVE_COLOR } else { MUTED_COLOR }, "tooltip": def.map(|d| d.label).unwrap_or(annotation.kind.as_str()) }]),
356                            )
357                            .cell(VALUE, Value::String(text))
358                            .cell(STATUS, serde_json::json!([{ "glyph": status_glyph, "color": status_color, "tooltip": tooltip }]))
359                            .cell(ON, Value::Bool(annotation.enabled))
360                            .actions(vec![
361                                RowAction::new(ACT_EDIT, "Edit annotation").tooltip("Open the annotation's dialog"),
362                                RowAction::new(ACT_UP, "Move up").tooltip("Move before the previous annotation"),
363                                RowAction::new(ACT_DOWN, "Move down").tooltip("Move after the next annotation"),
364                                RowAction::new(ACT_DELETE, "Delete annotation").separator_above().destructive(),
365                            ]);
366                        child.selected = false;
367                        let _ = index;
368                        child
369                    })
370                    .collect();
371                row
372            })
373            .collect();
374        let spec = ColumnTreeSpec {
375            id: "pmi-views",
376            columns: &self.columns,
377            root_label: Some("PMI Views"),
378            root_cells: None,
379            empty_hint: Some("(no views — Capture view to snapshot the camera and start annotating)"),
380            hits_prefix: "pmi:",
381        };
382        let out = column_tree::column_tree(ui, &spec, &mut self.layout, &rows, Some(&mut self.hits));
383
384        // --- hover → viewport highlight (applied by `show` after the draw) ---------
385        if out.hovered != self.hovered {
386            self.hovered = out.hovered.clone();
387            self.pending_hover = Some(out.hovered.clone().filter(|id| pmi.find_annotation(id).is_some()));
388        }
389
390        // --- act on what the widget reported (at most one mutation) ------------------
391        if let Some(id) = &out.toggled {
392            if pmi.find_view(id).is_some() {
393                if !self.collapsed.remove(id) {
394                    self.collapsed.insert(id.clone());
395                }
396            }
397        }
398        if let Some(click) = out.actions.first() {
399            let id = click.row_id.clone();
400            *action = match click.action.as_str() {
401                ACT_ACTIVATE => Some(Action::Activate(id)),
402                ACT_DEACTIVATE => Some(Action::Deactivate),
403                ACT_UPDATE_CAMERA => Some(Action::UpdateCamera(id)),
404                ACT_UPDATE_VISIBILITY => Some(Action::UpdateVisibility(id)),
405                ACT_WIREFRAME => pmi.find_view(&id).map(|view| Action::Wireframe(id.clone(), !view.display.wireframe)),
406                ACT_DELETE_VIEW => Some(Action::DeleteView(id)),
407                ACT_EDIT => Some(Action::Open(Some(id))),
408                ACT_UP => pmi.locate_annotation(&id).map(|(_, index)| Action::Move(id.clone(), index.saturating_sub(1))),
409                ACT_DOWN => pmi.locate_annotation(&id).map(|(_, index)| Action::Move(id.clone(), index + 1)),
410                ACT_DELETE => Some(Action::Delete(id)),
411                _ => None,
412            };
413            return;
414        }
415        if let Some(edit) = out.edits.first() {
416            let id = edit.row_id.clone();
417            match edit.column.as_str() {
418                NAME if pmi.find_view(&id).is_some() => {
419                    *action = Some(Action::Rename(id, edit.value.as_str().unwrap_or("").to_string()));
420                }
421                ON if pmi.find_view(&id).is_some() => {
422                    *action = Some(if edit.value.as_bool().unwrap_or(false) { Action::Activate(id) } else { Action::Deactivate });
423                }
424                ON => {
425                    *action = Some(Action::SetEnabled(id, edit.value.as_bool().unwrap_or(true)));
426                }
427                _ => {}
428            }
429            return;
430        }
431        if let Some(id) = &out.clicked {
432            if pmi.find_annotation(id).is_some() {
433                *action = Some(Action::Open(Some(id.clone())));
434            } else if pmi.find_view(id).is_some() && active != Some(id.as_str()) {
435                *action = Some(Action::Activate(id.clone()));
436            }
437        }
438    }
439
440    /// Draw ONE annotation's dialog through the shared form view.
441    fn show_form(
442        &mut self,
443        ui: &mut egui::Ui,
444        annotation: &brep_render::brep_kernel::PmiAnnotation,
445        report: &PmiReport,
446        action: &mut Option<Action>,
447        close: &mut bool,
448        hover: &mut Option<String>,
449    ) {
450        let catalogue = pmi_schema_catalogue();
451        let Some(schema) = catalogue
452            .as_array()
453            .and_then(|entries| entries.iter().find(|entry| entry.get("type").and_then(Value::as_str) == Some(annotation.kind.as_str())))
454            .cloned()
455        else {
456            *close = true;
457            return;
458        };
459        let fields = form_fields_from_schema(&schema);
460        let mut params = annotation.params.clone();
461        let id = annotation.id().to_string();
462        let def = pmi_type(&annotation.kind);
463        let title = format!("{} {}", def.map(|d| d.label).unwrap_or(&annotation.kind), id);
464        let (banner_text, banner_color) = match report.annotation(&id) {
465            Some(row) if row.status == PmiStatus::Ok => (row.text.replace('\n', " / "), egui::Color32::from_rgb(0x3f, 0xb9, 0x50)),
466            Some(row) => (row.message.clone(), egui::Color32::from_rgb(0xf8, 0x51, 0x49)),
467            None => ("not resolved yet".to_string(), egui::Color32::GRAY),
468        };
469        let spec = FormViewSpec {
470            title: &title,
471            subtitle: None,
472            fields: &fields,
473            banner: Some((banner_text.as_str(), banner_color)),
474            trailing: None,
475            exit_label: "Return to tree",
476            extra: None,
477            rollback: false,
478            hits_prefix: "pmi:",
479        };
480        let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
481        let anchor = self.hits.get("pmi:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
482        self.hits.insert(format!("pmi:form:annotation:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
483        if let Some(activate) = out.ref_activate {
484            *action = Some(Action::BeginRefSelect {
485                id: id.clone(),
486                path: activate.path,
487                label: activate.label,
488                filter: activate.filter,
489                multiple: activate.multiple,
490                seed: activate.seed,
491            });
492        }
493        if out.changed {
494            *action = Some(Action::UpdateParams(id.clone(), params));
495        }
496        if out.exit_clicked {
497            *close = true;
498        }
499        // The entity a hovered reference line names — applied by `show`, which
500        // holds the engine.
501        *hover = out.hovered_entity;
502    }
503
504    /// The per-frame widget rects for the headed verifier.
505    pub fn hits_json(&self) -> String {
506        crate::automation::hit_rects::hits_json(&self.hits)
507    }
508}
509
510// BREP private tests: d21fd52241c90427
511
512/// The hit keys this panel publishes (see `automation::hit_keys`).
513pub static HIT_KEYS: &[HitKeyDoc] = &[
514    HitKeyDoc { panel: "pmi", prefix: "pmi:capture", meaning: "capture the current camera as a PMI view", command: Some("pmi_capture_view") },
515    HitKeyDoc { panel: "pmi", prefix: "pmi:add", meaning: "open the add-annotation menu", command: None },
516    HitKeyDoc { panel: "pmi", prefix: "pmi:add:", meaning: "add an annotation of that type", command: Some("pmi_add_annotation") },
517    HitKeyDoc { panel: "pmi", prefix: "pmi:textsize", meaning: "the text size control", command: Some("pmi_set_view_display") },
518    HitKeyDoc { panel: "pmi", prefix: "pmi:row:", meaning: "select a view or annotation row (pmi:row:id)", command: None },
519    HitKeyDoc { panel: "pmi", prefix: "pmi:cell:", meaning: "a row cell (pmi:cell:id:column)", command: None },
520    HitKeyDoc { panel: "pmi", prefix: "pmi:form:", meaning: "the open annotation form (pmi:form:annotation:id, pmi:form:feature, pmi:form:return)", command: Some("pmi_update_annotation") },
521    HitKeyDoc { panel: "pmi", prefix: "pmi:panel:clip", meaning: "the visible region of the pane", command: None },
522];