use super::*;
use brep_kernel::{pmi_present, pmi_type, PmiLayoutStyle, PmiStatus};
const PMI_OVERLAY_GROUP: &str = "pmi-overlay";
pub(crate) const ARROW_PX: f64 = 12.0;
pub(crate) const TEXT_PX_AT_12PT: f64 = 11.0;
const PMI_RGB: [f32; 3] = [0.98, 0.78, 0.22];
const EXPLODE_ARROW_RGB: [f32; 3] = [0.0, 0.0, 0.0];
const EXPLODE_ARROW_ON_DARK_RGB: [f32; 3] = [0.92, 0.94, 0.96];
const INSERTION_HEAD_PX: f64 = 20.0;
const ERROR_RGB: [f32; 3] = [0.97, 0.32, 0.29];
const DISABLED_RGB: [f32; 3] = [0.55, 0.55, 0.55];
pub(crate) const LIVE_HIDDEN_MESSAGE: &str = "target hidden in the viewport: show it, or update the view's visibility";
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PmiLabel {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub icon: &'static str,
pub text: String,
pub status: &'static str,
pub message: String,
pub color: [f32; 3],
pub world: [f64; 3],
pub enabled: bool,
pub open: bool,
pub selected: bool,
pub text_size_pt: f64,
}
impl EngineState {
pub fn refresh_pmi_overlay(&mut self) {
let Some(active) = self.pmi_active_view.clone() else {
self.clear_pmi_overlay();
return;
};
if self.any_sketch_mode() {
self.clear_pmi_overlay();
return;
}
let camera = self.snapshot_camera();
let ids: Vec<String> = self.pmi_report.as_ref().and_then(|r| r.view(&active))
.map(|v| v.annotations.iter().filter(|row| matches!(&row.geometry,
brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera)))
.map(|row| row.id.clone()).collect()).unwrap_or_default();
let mut missing = Vec::new();
for id in &ids {
if let Some(names) = self.pmi_balloon_head_now(id) {
missing.extend(names);
self.pmi_balloon_stale = Some(id.clone());
}
}
if !missing.is_empty() {
missing.sort();
missing.dedup();
self.request_exact_solids(missing.iter().map(String::as_str));
for id in &ids { let _ = self.pmi_balloon_head_now(id); }
}
let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(&active)) else {
self.clear_pmi_overlay();
return;
};
let text_size = self
.pmi_state_shared()
.find_view(&active)
.map(|view| view.display.text_size_pt)
.unwrap_or(12.0);
let wpp = self.camera.world_per_pixel();
let (_, up, view_dir) = self.camera.basis();
let style = PmiLayoutStyle {
arrow: ARROW_PX * wpp,
text_height: TEXT_PX_AT_12PT * wpp * (text_size / 12.0),
view_dir,
view_up: up,
plane: None,
};
let mut lines: Vec<f32> = Vec::new();
let mut line_colors: Vec<f32> = Vec::new();
let mut tris: Vec<f32> = Vec::new();
let mut tri_colors: Vec<f32> = Vec::new();
let mut explode_step = 0;
let mut arrows: Vec<([f64; 3], [f64; 3])> = Vec::new();
let playback = self.explode_playback_poses();
for row in &view.annotations {
if !row.enabled || row.status != PmiStatus::Ok || self.balloon_target_hidden_live(row) {
continue;
}
let geometry = if matches!(row.geometry, brep_kernel::PmiGeometry::Explode { .. }) {
explode_step += 1;
super::exploded_ops::explode_at(&row.geometry, self.explode_fraction(explode_step - 1))
} else {
playback.as_ref().and_then(|poses| poses.shown_geometry(row)).unwrap_or_else(|| row.geometry.clone())
};
let label = playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world));
let row_style = PmiLayoutStyle {
plane: row.plane,
..style
};
if let brep_kernel::PmiGeometry::Explode { solids, translate, trace, .. } = &geometry {
if *trace {
let mut now = crate::camera::Aabb::empty();
for solid in self.scene.solids() {
if solids.iter().any(|s| solid.name == *s || solid.name.strip_prefix(s.as_str()).is_some_and(|rest| rest.starts_with(':'))) {
now.union(&solid.bbox);
}
}
if !now.is_empty() {
let home = crate::camera::Aabb {
min: std::array::from_fn(|k| now.min[k] - translate[k]),
max: std::array::from_fn(|k| now.max[k] - translate[k]),
};
arrows.extend(self.outline_arrow(&home, *translate));
}
}
continue;
}
let drawn = pmi_present(&geometry, label, &row.text, &row_style);
let rgb = PMI_RGB;
for polyline in &drawn.polylines {
for pair in polyline.windows(2) {
for point in pair {
lines.extend(point.iter().map(|c| *c as f32));
line_colors.extend_from_slice(&rgb);
}
}
}
for arrow in &drawn.arrows {
for point in arrow {
tris.extend(point.iter().map(|c| *c as f32));
tri_colors.extend_from_slice(&rgb);
}
for point in [arrow[0], arrow[2], arrow[1]] {
tris.extend(point.iter().map(|c| *c as f32));
tri_colors.extend_from_slice(&rgb);
}
}
}
arrows.extend(self.instructions_insertion_arrows());
let ground = if self.scene.line_art.is_some() { crate::thumbnail::line_art::BACKGROUND } else { self.settings.background };
let light = 0.2126 * ground[0] + 0.7152 * ground[1] + 0.0722 * ground[2] > 0.5;
let ink = if light { EXPLODE_ARROW_RGB } else { EXPLODE_ARROW_ON_DARK_RGB };
for (tail, tip) in arrows {
let d = [tip[0] - tail[0], tip[1] - tail[1], tip[2] - tail[2]];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if !(length > 1e-9) {
continue;
}
let d = d.map(|v| v / length);
let head = (INSERTION_HEAD_PX * wpp).min(length * 0.6);
let w = crate::geometry3d::cross3(view_dir, d);
let wl = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
let w = if wl > 1e-9 { w.map(|v| v / wl) } else { up };
let base: [f64; 3] = std::array::from_fn(|k| tip[k] - d[k] * head);
for point in [tail, base] {
lines.extend(point.iter().map(|c| *c as f32));
line_colors.extend_from_slice(&ink);
}
let half = head * 0.35;
let corners = [tip, std::array::from_fn(|k| base[k] + w[k] * half), std::array::from_fn(|k| base[k] - w[k] * half)];
for point in [corners[0], corners[1], corners[2], corners[0], corners[2], corners[1]] {
tris.extend(point.iter().map(|c| *c as f32));
tri_colors.extend_from_slice(&ink);
}
}
let group = if lines.is_empty() && tris.is_empty() {
serde_json::json!({ "name": PMI_OVERLAY_GROUP })
} else {
serde_json::json!({
"name": PMI_OVERLAY_GROUP,
"renderOrder": 10001,
"lines": { "positions": lines, "colors": line_colors },
"tris": { "positions": tris, "colors": tri_colors },
})
};
let _ = self.set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
self.pmi_overlay_key = Some((if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE }, view_dir));
self.dirty = true;
}
pub(crate) fn balloon_target_hidden_live(&self, row: &brep_kernel::PmiAnnotationReport) -> bool {
match &row.geometry {
brep_kernel::PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => {
!anchor.solids.is_empty()
&& anchor
.solids
.iter()
.all(|name| self.scene.solid(name).is_none_or(|solid| !solid.visible))
}
_ => false,
}
}
fn clear_pmi_overlay(&mut self) {
if self.pmi_overlay_key.take().is_some() {
let _ = self.set_overlay_json(
&serde_json::json!({ "groups": [ { "name": PMI_OVERLAY_GROUP } ] }).to_string(),
);
self.dirty = true;
}
}
pub fn ensure_pmi_overlay_current(&mut self) {
let Some((baked_wpp, baked_dir)) = self.pmi_overlay_key else {
if self.pmi_active_view.is_some() {
self.refresh_pmi_overlay();
}
return;
};
if self.any_sketch_mode() {
self.clear_pmi_overlay();
return;
}
let wpp = self.camera.world_per_pixel();
let (_, _, view_dir) = self.camera.basis();
let dot = baked_dir[0] * view_dir[0] + baked_dir[1] * view_dir[1] + baked_dir[2] * view_dir[2];
let camera = self.snapshot_camera();
let balloon_camera_changed = self.pmi_report.as_ref()
.and_then(|r| self.pmi_active_view.as_ref().and_then(|id| r.view(id)))
.is_some_and(|v| v.annotations.iter().any(|row| matches!(&row.geometry,
brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera))));
if balloon_camera_changed || super::overlay_wpp_stale(baked_wpp, wpp) || dot < (0.5f64).to_radians().cos() {
self.refresh_pmi_overlay();
}
}
pub fn pmi_labels_json(&self) -> String {
serde_json::to_string(&self.pmi_labels()).unwrap_or_else(|_| "[]".into())
}
pub fn pmi_labels(&self) -> Vec<PmiLabel> {
let Some(active) = self.pmi_active_view.as_deref() else {
return Vec::new();
};
let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(active)) else {
return Vec::new();
};
let explode_chips = matches!(self.settings.workbench.as_str(), "pmi" | "all");
if !explode_chips && view.annotations.iter().all(|row| matches!(row.kind.as_str(), "explode" | "hide")) {
return Vec::new();
}
let text_size = self
.pmi_state_shared()
.find_view(active)
.map(|view| view.display.text_size_pt)
.unwrap_or(12.0);
let playback = self.explode_playback_poses();
view.annotations
.iter()
.filter(|row| !matches!(row.kind.as_str(), "explode" | "hide"))
.map(|row| {
let (status, message) = if row.status == PmiStatus::Ok && self.balloon_target_hidden_live(row) {
(PmiStatus::Error, LIVE_HIDDEN_MESSAGE.to_string())
} else {
(row.status, row.message.clone())
};
let color = if status == PmiStatus::Error {
ERROR_RGB
} else if !row.enabled {
DISABLED_RGB
} else {
PMI_RGB
};
let icon = pmi_type(&row.kind).map(|def| def.icon).unwrap_or("");
let text = if status == PmiStatus::Error || row.text.is_empty() {
format!("{} {}", icon, row.id)
} else {
row.text.clone()
};
PmiLabel {
id: row.id.clone(),
kind: row.kind.clone(),
icon,
text,
status: match status { PmiStatus::Ok => "ok", PmiStatus::Error => "error" },
message,
color,
world: playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world)),
enabled: row.enabled,
open: self.pmi_open_annotation.as_deref() == Some(row.id.as_str()),
selected: self.pmi_selected_annotation.as_deref() == Some(row.id.as_str()),
text_size_pt: text_size,
}
})
.collect()
}
}