use crate::automation::hit_keys::HitKeyDoc;
use crate::column_tree::{self, CellKind, ColumnLayout, ColumnSpec, ColumnTreeSpec, RowAction, RowNode};
use crate::form_view::{form_view, FormViewSpec};
use brep_render::brep_kernel::{pmi_schema_catalogue, pmi_type, PmiReport, PmiState, PmiStatus, PMI_TYPES};
use brep_render::engine_state::{EngineState, PmiViewPatch};
use brep_render::features::form_fields_from_schema;
use eframe::egui;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
const DIALOG_HOVER_OWNER: &str = "pmi";
const NAME: &str = "name";
const KIND: &str = "kind";
const VALUE: &str = "value";
const STATUS: &str = "status";
const ON: &str = "on";
const ACTIONS: &str = "actions";
const OK_COLOR: &str = "#3fb950";
const ERROR_COLOR: &str = "#f85149";
const ACTIVE_COLOR: &str = "#58a6ff";
const MUTED_COLOR: &str = "#8b949e";
const ACT_ACTIVATE: &str = "activate";
const ACT_DEACTIVATE: &str = "deactivate";
const ACT_UPDATE_CAMERA: &str = "update-camera";
const ACT_UPDATE_VISIBILITY: &str = "update-visibility";
const ACT_WIREFRAME: &str = "wireframe";
const ACT_DELETE_VIEW: &str = "delete-view";
const ACT_EDIT: &str = "edit";
const ACT_UP: &str = "move-up";
const ACT_DOWN: &str = "move-down";
const ACT_DELETE: &str = "delete";
enum Action {
Capture,
Add(String),
TextSize(String, f64),
Rename(String, String),
Activate(String),
Deactivate,
UpdateCamera(String),
UpdateVisibility(String),
Wireframe(String, bool),
DeleteView(String),
SetEnabled(String, bool),
Open(Option<String>),
Move(String, usize),
Delete(String),
UpdateParams(String, Value),
BeginRefSelect {
id: String,
path: Vec<String>,
label: String,
filter: Vec<String>,
multiple: bool,
seed: Vec<String>,
},
}
pub struct PmiPanel {
hits: HashMap<String, egui::Rect>,
layout: ColumnLayout,
columns: Vec<ColumnSpec>,
collapsed: HashSet<String>,
hovered: Option<String>,
pending_hover: Option<Option<String>>,
}
impl Default for PmiPanel {
fn default() -> Self {
Self::new()
}
}
impl PmiPanel {
pub fn new() -> Self {
Self {
hits: HashMap::new(),
layout: ColumnLayout::default(),
columns: vec![
ColumnSpec::new(NAME, "View / annotation", CellKind::Text).width(150.0),
ColumnSpec::new(KIND, "", CellKind::Badges).width(28.0),
ColumnSpec::new(VALUE, "Value", CellKind::ReadOnly).width(150.0),
ColumnSpec::new(STATUS, "", CellKind::Badges).width(28.0),
ColumnSpec::new(ON, "On", CellKind::Toggle).width(30.0),
ColumnSpec::new(ACTIONS, "", CellKind::Actions { label: "\u{22EF}".into() }).width(30.0),
],
collapsed: HashSet::new(),
hovered: None,
pending_hover: None,
}
}
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
self.hits.clear();
self.hits.insert("pmi:panel:clip".into(), ui.clip_rect());
let pmi = state.pmi_state();
let report = state.pmi_report().cloned().unwrap_or_default();
let active = state.pmi_active_view().map(String::from);
let open = state.pmi_open_annotation().map(String::from);
let mut action: Option<Action> = None;
let mut close = false;
let mut hover: Option<String> = None;
match open.as_deref().and_then(|id| pmi.find_annotation(id).map(|(view, annotation)| (view.id.clone(), annotation.clone()))) {
Some((_, annotation)) => self.show_form(ui, &annotation, &report, &mut action, &mut close, &mut hover),
None => self.show_tree(ui, &pmi, &report, active.as_deref(), &mut action),
}
let result: Result<(), String> = match action {
Some(Action::Capture) => {
state.pmi_capture_view(None);
Ok(())
}
Some(Action::Add(type_id)) => state.pmi_add_annotation(None, &type_id, "{}").map(|_| ()),
Some(Action::TextSize(id, size)) => state.pmi_set_view_display(&id, &PmiViewPatch { text_size_pt: Some(size), ..Default::default() }),
Some(Action::Rename(id, name)) => state.pmi_rename_view(&id, &name),
Some(Action::Activate(id)) => state.pmi_activate_view(&id),
Some(Action::Deactivate) => {
state.pmi_deactivate_view();
Ok(())
}
Some(Action::UpdateCamera(id)) => state.pmi_update_view_camera(&id),
Some(Action::UpdateVisibility(id)) => state.pmi_update_view_visibility(&id),
Some(Action::Wireframe(id, on)) => state.pmi_set_view_display(&id, &PmiViewPatch { wireframe: Some(on), ..Default::default() }),
Some(Action::DeleteView(id)) => state.pmi_delete_view(&id),
Some(Action::SetEnabled(id, on)) => state.pmi_set_annotation_enabled(&id, on),
Some(Action::Open(id)) => {
state.pmi_set_annotation_open(id.as_deref());
Ok(())
}
Some(Action::Move(id, index)) => state.pmi_move_annotation(&id, index),
Some(Action::Delete(id)) => state.pmi_remove_annotation(&id),
Some(Action::UpdateParams(id, params)) => state.pmi_update_annotation(&id, ¶ms.to_string()),
Some(Action::BeginRefSelect { id, path, label, filter, multiple, seed }) => {
state.begin_ref_select_for_pmi(&id, path, label, filter, multiple, seed);
Ok(())
}
None => Ok(()),
};
if let Err(error) = result {
state.push_notice(format!("PMI: {error}"));
}
if close {
state.pmi_set_annotation_open(None);
}
if let Some(hover) = self.pending_hover.take() {
match hover {
Some(id) => state.pmi_hover(&id),
None => state.pmi_hover_end(),
}
}
let hover_changed = match &hover {
Some(name) => state.hover_entity_by_name(DIALOG_HOVER_OWNER, name),
None => state.dialog_hover_end(DIALOG_HOVER_OWNER),
};
if hover_changed {
ui.ctx().request_repaint();
}
}
#[allow(clippy::too_many_arguments)]
fn show_tree(
&mut self,
ui: &mut egui::Ui,
pmi: &PmiState,
report: &PmiReport,
active: Option<&str>,
action: &mut Option<Action>,
) {
ui.horizontal_wrapped(|ui| {
let capture = ui
.add(crate::icon_text::icon_button(ui, "\u{1F5CE} Capture view"))
.on_hover_text("Snapshot the current camera and visibility into a new view and activate it");
self.hits.insert("pmi:capture".into(), capture.rect);
if capture.clicked() {
*action = Some(Action::Capture);
}
let can_add = active.is_some();
let mut add_type: Option<String> = None;
ui.add_enabled_ui(can_add, |ui| {
let combo = egui::ComboBox::from_id_salt("pmi-add")
.selected_text("+ Add annotation")
.show_ui(ui, |ui| {
for def in PMI_TYPES.iter() {
let item = crate::icon_text::selectable_icon_label(ui, false, def.long_name);
self.hits.insert(format!("pmi:add:{}", def.type_id), item.rect);
if item.clicked() {
add_type = Some(def.type_id.to_string());
}
}
});
let response = if can_add {
combo.response
} else {
combo.response.on_disabled_hover_text("Capture or activate a view first")
};
self.hits.insert("pmi:add".into(), response.rect);
});
if let Some(type_id) = add_type {
*action = Some(Action::Add(type_id));
}
if let Some(view) = active.and_then(|id| pmi.find_view(id)) {
let mut size = view.display.text_size_pt;
let drag = ui
.add(egui::DragValue::new(&mut size).range(1.0..=288.0).speed(0.5).suffix(" pt"))
.on_hover_text("Label text size for the active view (1–288 pt)");
self.hits.insert("pmi:textsize".into(), drag.rect);
if drag.changed() {
*action = Some(Action::TextSize(view.id.clone(), size));
}
}
});
let annotation_count: usize = pmi.views.iter().map(|view| view.annotations.len()).sum();
ui.label(
egui::RichText::new(match active.and_then(|id| pmi.find_view(id)) {
Some(view) => format!(
"{} view{} | {annotation_count} annotation{} | active: {}",
pmi.views.len(),
if pmi.views.len() == 1 { "" } else { "s" },
if annotation_count == 1 { "" } else { "s" },
view.name
),
None => format!(
"{} view{} | {annotation_count} annotation{} | no active view",
pmi.views.len(),
if pmi.views.len() == 1 { "" } else { "s" },
if annotation_count == 1 { "" } else { "s" },
),
})
.weak(),
);
if active.is_none() {
ui.label(egui::RichText::new("Capture a view to start annotating").weak().italics());
}
ui.add_space(2.0);
let rows: Vec<RowNode> = pmi
.views
.iter()
.map(|view| {
let is_active = active == Some(view.id.as_str());
let view_report = report.view(&view.id);
let count = view.annotations.len();
let mut row = RowNode::new(&view.id)
.cell(NAME, Value::String(view.name.clone()))
.cell(KIND, serde_json::json!([{ "glyph": "\u{1F441}", "color": if is_active { ACTIVE_COLOR } else { MUTED_COLOR }, "tooltip": "PMI view" }]))
.cell(
VALUE,
Value::String(format!(
"{} · {count} annotation{}",
match view.camera.as_ref().map(|c| &c.projection) {
Some(brep_render::brep_kernel::PmiProjection::Orthographic { .. }) => "orthographic",
Some(brep_render::brep_kernel::PmiProjection::Perspective { .. }) => "perspective",
None => "no camera",
},
if count == 1 { "" } else { "s" }
)),
)
.cell(
STATUS,
if is_active {
serde_json::json!([{ "glyph": "\u{25CF}", "color": ACTIVE_COLOR, "tooltip": "active view" }])
} else {
serde_json::json!([])
},
)
.cell(ON, Value::Bool(is_active))
.actions(vec![
if is_active {
RowAction::new(ACT_DEACTIVATE, "Deactivate view").tooltip("Restore the modeling camera and visibility")
} else {
RowAction::new(ACT_ACTIVATE, "Activate view").tooltip("Apply this view's camera, visibility and wireframe")
},
RowAction::new(ACT_UPDATE_CAMERA, "Update camera").tooltip("Re-capture the camera from the current viewpoint"),
RowAction::new(ACT_UPDATE_VISIBILITY, "Update visibility").tooltip("Re-capture which objects are hidden"),
RowAction::new(ACT_WIREFRAME, if view.display.wireframe { "Wireframe off" } else { "Wireframe on" }),
RowAction::new(ACT_DELETE_VIEW, "Delete view").tooltip("Delete the view and its annotations").separator_above().destructive(),
]);
row.expanded = !self.collapsed.contains(&view.id);
row.selected = is_active;
row.children = view
.annotations
.iter()
.enumerate()
.map(|(index, annotation)| {
let id = annotation.id().to_string();
let resolved = view_report.and_then(|v| v.annotations.iter().find(|r| r.id == id));
let def = pmi_type(&annotation.kind);
let (status_glyph, status_color, tooltip, text) = match resolved {
Some(row) if row.status == PmiStatus::Ok => ("\u{2713}", OK_COLOR, "resolved".to_string(), row.text.replace('\n', " / ")),
Some(row) => ("\u{2715}", ERROR_COLOR, row.message.clone(), row.message.clone()),
None => ("\u{2013}", MUTED_COLOR, "not resolved yet".to_string(), String::new()),
};
let mut child = RowNode::new(&id)
.cell(NAME, Value::String(id.clone()))
.cell(
KIND,
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()) }]),
)
.cell(VALUE, Value::String(text))
.cell(STATUS, serde_json::json!([{ "glyph": status_glyph, "color": status_color, "tooltip": tooltip }]))
.cell(ON, Value::Bool(annotation.enabled))
.actions(vec![
RowAction::new(ACT_EDIT, "Edit annotation").tooltip("Open the annotation's dialog"),
RowAction::new(ACT_UP, "Move up").tooltip("Move before the previous annotation"),
RowAction::new(ACT_DOWN, "Move down").tooltip("Move after the next annotation"),
RowAction::new(ACT_DELETE, "Delete annotation").separator_above().destructive(),
]);
child.selected = false;
let _ = index;
child
})
.collect();
row
})
.collect();
let spec = ColumnTreeSpec {
id: "pmi-views",
columns: &self.columns,
root_label: Some("PMI Views"),
root_cells: None,
empty_hint: Some("(no views — Capture view to snapshot the camera and start annotating)"),
hits_prefix: "pmi:",
};
let out = column_tree::column_tree(ui, &spec, &mut self.layout, &rows, Some(&mut self.hits));
if out.hovered != self.hovered {
self.hovered = out.hovered.clone();
self.pending_hover = Some(out.hovered.clone().filter(|id| pmi.find_annotation(id).is_some()));
}
if let Some(id) = &out.toggled {
if pmi.find_view(id).is_some() {
if !self.collapsed.remove(id) {
self.collapsed.insert(id.clone());
}
}
}
if let Some(click) = out.actions.first() {
let id = click.row_id.clone();
*action = match click.action.as_str() {
ACT_ACTIVATE => Some(Action::Activate(id)),
ACT_DEACTIVATE => Some(Action::Deactivate),
ACT_UPDATE_CAMERA => Some(Action::UpdateCamera(id)),
ACT_UPDATE_VISIBILITY => Some(Action::UpdateVisibility(id)),
ACT_WIREFRAME => pmi.find_view(&id).map(|view| Action::Wireframe(id.clone(), !view.display.wireframe)),
ACT_DELETE_VIEW => Some(Action::DeleteView(id)),
ACT_EDIT => Some(Action::Open(Some(id))),
ACT_UP => pmi.locate_annotation(&id).map(|(_, index)| Action::Move(id.clone(), index.saturating_sub(1))),
ACT_DOWN => pmi.locate_annotation(&id).map(|(_, index)| Action::Move(id.clone(), index + 1)),
ACT_DELETE => Some(Action::Delete(id)),
_ => None,
};
return;
}
if let Some(edit) = out.edits.first() {
let id = edit.row_id.clone();
match edit.column.as_str() {
NAME if pmi.find_view(&id).is_some() => {
*action = Some(Action::Rename(id, edit.value.as_str().unwrap_or("").to_string()));
}
ON if pmi.find_view(&id).is_some() => {
*action = Some(if edit.value.as_bool().unwrap_or(false) { Action::Activate(id) } else { Action::Deactivate });
}
ON => {
*action = Some(Action::SetEnabled(id, edit.value.as_bool().unwrap_or(true)));
}
_ => {}
}
return;
}
if let Some(id) = &out.clicked {
if pmi.find_annotation(id).is_some() {
*action = Some(Action::Open(Some(id.clone())));
} else if pmi.find_view(id).is_some() && active != Some(id.as_str()) {
*action = Some(Action::Activate(id.clone()));
}
}
}
fn show_form(
&mut self,
ui: &mut egui::Ui,
annotation: &brep_render::brep_kernel::PmiAnnotation,
report: &PmiReport,
action: &mut Option<Action>,
close: &mut bool,
hover: &mut Option<String>,
) {
let catalogue = pmi_schema_catalogue();
let Some(schema) = catalogue
.as_array()
.and_then(|entries| entries.iter().find(|entry| entry.get("type").and_then(Value::as_str) == Some(annotation.kind.as_str())))
.cloned()
else {
*close = true;
return;
};
let fields = form_fields_from_schema(&schema);
let mut params = annotation.params.clone();
let id = annotation.id().to_string();
let def = pmi_type(&annotation.kind);
let title = format!("{} {}", def.map(|d| d.label).unwrap_or(&annotation.kind), id);
let (banner_text, banner_color) = match report.annotation(&id) {
Some(row) if row.status == PmiStatus::Ok => (row.text.replace('\n', " / "), egui::Color32::from_rgb(0x3f, 0xb9, 0x50)),
Some(row) => (row.message.clone(), egui::Color32::from_rgb(0xf8, 0x51, 0x49)),
None => ("not resolved yet".to_string(), egui::Color32::GRAY),
};
let spec = FormViewSpec {
title: &title,
subtitle: None,
fields: &fields,
banner: Some((banner_text.as_str(), banner_color)),
trailing: None,
exit_label: "Return to tree",
extra: None,
rollback: false,
hits_prefix: "pmi:",
};
let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
let anchor = self.hits.get("pmi:form:feature").map(|rect| rect.min).unwrap_or(egui::Pos2::ZERO);
self.hits.insert(format!("pmi:form:annotation:{id}"), egui::Rect::from_min_size(anchor, egui::Vec2::ZERO));
if let Some(activate) = out.ref_activate {
*action = Some(Action::BeginRefSelect {
id: id.clone(),
path: activate.path,
label: activate.label,
filter: activate.filter,
multiple: activate.multiple,
seed: activate.seed,
});
}
if out.changed {
*action = Some(Action::UpdateParams(id.clone(), params));
}
if out.exit_clicked {
*close = true;
}
*hover = out.hovered_entity;
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "pmi", prefix: "pmi:capture", meaning: "capture the current camera as a PMI view", command: Some("pmi_capture_view") },
HitKeyDoc { panel: "pmi", prefix: "pmi:add", meaning: "open the add-annotation menu", command: None },
HitKeyDoc { panel: "pmi", prefix: "pmi:add:", meaning: "add an annotation of that type", command: Some("pmi_add_annotation") },
HitKeyDoc { panel: "pmi", prefix: "pmi:textsize", meaning: "the text size control", command: Some("pmi_set_view_display") },
HitKeyDoc { panel: "pmi", prefix: "pmi:row:", meaning: "select a view or annotation row (pmi:row:id)", command: None },
HitKeyDoc { panel: "pmi", prefix: "pmi:cell:", meaning: "a row cell (pmi:cell:id:column)", command: None },
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") },
HitKeyDoc { panel: "pmi", prefix: "pmi:panel:clip", meaning: "the visible region of the pane", command: None },
];