1use 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
46const 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
63const 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
75enum 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
102pub struct PmiPanel {
104 hits: HashMap<String, egui::Rect>,
105 layout: ColumnLayout,
106 columns: Vec<ColumnSpec>,
107 collapsed: HashSet<String>,
109 hovered: Option<String>,
110 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 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 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, ¶ms.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 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 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 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 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 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 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 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 *hover = out.hovered_entity;
502 }
503
504 pub fn hits_json(&self) -> String {
506 crate::automation::hit_rects::hits_json(&self.hits)
507 }
508}
509
510pub 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];