brep_render/engine_state/
pmi_overlay.rs1use super::*;
9use brep_kernel::{pmi_present, pmi_type, PmiLayoutStyle, PmiStatus};
10
11const PMI_OVERLAY_GROUP: &str = "pmi-overlay";
12pub(crate) const ARROW_PX: f64 = 12.0;
14pub(crate) const TEXT_PX_AT_12PT: f64 = 11.0;
17const PMI_RGB: [f32; 3] = [0.98, 0.78, 0.22];
20const EXPLODE_ARROW_RGB: [f32; 3] = [0.0, 0.0, 0.0];
23const EXPLODE_ARROW_ON_DARK_RGB: [f32; 3] = [0.92, 0.94, 0.96];
24const INSERTION_HEAD_PX: f64 = 20.0;
27const ERROR_RGB: [f32; 3] = [0.97, 0.32, 0.29];
29const DISABLED_RGB: [f32; 3] = [0.55, 0.55, 0.55];
31pub(crate) const LIVE_HIDDEN_MESSAGE: &str = "target hidden in the viewport: show it, or update the view's visibility";
34
35#[derive(Debug, Clone, PartialEq, serde::Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct PmiLabel {
40 pub id: String,
41 #[serde(rename = "type")]
42 pub kind: String,
43 pub icon: &'static str,
44 pub text: String,
47 pub status: &'static str,
49 pub message: String,
50 pub color: [f32; 3],
51 pub world: [f64; 3],
53 pub enabled: bool,
54 pub open: bool,
56 pub selected: bool,
58 pub text_size_pt: f64,
59}
60
61impl EngineState {
62 pub fn refresh_pmi_overlay(&mut self) {
65 let Some(active) = self.pmi_active_view.clone() else {
66 self.clear_pmi_overlay();
67 return;
68 };
69 if self.any_sketch_mode() {
70 self.clear_pmi_overlay();
71 return;
72 }
73 let camera = self.snapshot_camera();
77 let ids: Vec<String> = self.pmi_report.as_ref().and_then(|r| r.view(&active))
78 .map(|v| v.annotations.iter().filter(|row| matches!(&row.geometry,
79 brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
80 if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera)))
81 .map(|row| row.id.clone()).collect()).unwrap_or_default();
82 let mut missing = Vec::new();
83 for id in &ids {
84 if let Some(names) = self.pmi_balloon_head_now(id) {
85 missing.extend(names);
86 self.pmi_balloon_stale = Some(id.clone());
87 }
88 }
89 if !missing.is_empty() {
90 missing.sort();
91 missing.dedup();
92 self.request_exact_solids(missing.iter().map(String::as_str));
93 for id in &ids { let _ = self.pmi_balloon_head_now(id); }
94 }
95 let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(&active)) else {
98 self.clear_pmi_overlay();
99 return;
100 };
101 let text_size = self
102 .pmi_state_shared()
103 .find_view(&active)
104 .map(|view| view.display.text_size_pt)
105 .unwrap_or(12.0);
106 let wpp = self.camera.world_per_pixel();
107 let (_, up, view_dir) = self.camera.basis();
108 let style = PmiLayoutStyle {
109 arrow: ARROW_PX * wpp,
110 text_height: TEXT_PX_AT_12PT * wpp * (text_size / 12.0),
111 view_dir,
112 view_up: up,
113 plane: None,
114 };
115 let mut lines: Vec<f32> = Vec::new();
116 let mut line_colors: Vec<f32> = Vec::new();
117 let mut tris: Vec<f32> = Vec::new();
118 let mut tri_colors: Vec<f32> = Vec::new();
119 let mut explode_step = 0;
120 let mut arrows: Vec<([f64; 3], [f64; 3])> = Vec::new();
121 let playback = self.explode_playback_poses();
123 for row in &view.annotations {
124 if !row.enabled || row.status != PmiStatus::Ok || self.balloon_target_hidden_live(row) {
125 continue;
126 }
127 let geometry = if matches!(row.geometry, brep_kernel::PmiGeometry::Explode { .. }) {
129 explode_step += 1;
130 super::exploded_ops::explode_at(&row.geometry, self.explode_fraction(explode_step - 1))
131 } else {
132 playback.as_ref().and_then(|poses| poses.shown_geometry(row)).unwrap_or_else(|| row.geometry.clone())
133 };
134 let label = playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world));
135 let row_style = PmiLayoutStyle {
137 plane: row.plane,
138 ..style
139 };
140 if let brep_kernel::PmiGeometry::Explode { solids, translate, trace, .. } = &geometry {
143 if *trace {
144 let mut now = crate::camera::Aabb::empty();
145 for solid in self.scene.solids() {
146 if solids.iter().any(|s| solid.name == *s || solid.name.strip_prefix(s.as_str()).is_some_and(|rest| rest.starts_with(':'))) {
147 now.union(&solid.bbox);
148 }
149 }
150 if !now.is_empty() {
151 let home = crate::camera::Aabb {
152 min: std::array::from_fn(|k| now.min[k] - translate[k]),
153 max: std::array::from_fn(|k| now.max[k] - translate[k]),
154 };
155 arrows.extend(self.outline_arrow(&home, *translate));
156 }
157 }
158 continue;
159 }
160 let drawn = pmi_present(&geometry, label, &row.text, &row_style);
161 let rgb = PMI_RGB;
162 for polyline in &drawn.polylines {
163 for pair in polyline.windows(2) {
164 for point in pair {
165 lines.extend(point.iter().map(|c| *c as f32));
166 line_colors.extend_from_slice(&rgb);
167 }
168 }
169 }
170 for arrow in &drawn.arrows {
171 for point in arrow {
172 tris.extend(point.iter().map(|c| *c as f32));
173 tri_colors.extend_from_slice(&rgb);
174 }
175 for point in [arrow[0], arrow[2], arrow[1]] {
177 tris.extend(point.iter().map(|c| *c as f32));
178 tri_colors.extend_from_slice(&rgb);
179 }
180 }
181 }
182 arrows.extend(self.instructions_insertion_arrows());
188 let ground = if self.scene.line_art.is_some() { crate::thumbnail::line_art::BACKGROUND } else { self.settings.background };
189 let light = 0.2126 * ground[0] + 0.7152 * ground[1] + 0.0722 * ground[2] > 0.5;
190 let ink = if light { EXPLODE_ARROW_RGB } else { EXPLODE_ARROW_ON_DARK_RGB };
191 for (tail, tip) in arrows {
192 let d = [tip[0] - tail[0], tip[1] - tail[1], tip[2] - tail[2]];
193 let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
194 if !(length > 1e-9) {
195 continue;
196 }
197 let d = d.map(|v| v / length);
198 let head = (INSERTION_HEAD_PX * wpp).min(length * 0.6);
199 let w = crate::geometry3d::cross3(view_dir, d);
200 let wl = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
201 let w = if wl > 1e-9 { w.map(|v| v / wl) } else { up };
202 let base: [f64; 3] = std::array::from_fn(|k| tip[k] - d[k] * head);
203 for point in [tail, base] {
204 lines.extend(point.iter().map(|c| *c as f32));
205 line_colors.extend_from_slice(&ink);
206 }
207 let half = head * 0.35;
208 let corners = [tip, std::array::from_fn(|k| base[k] + w[k] * half), std::array::from_fn(|k| base[k] - w[k] * half)];
209 for point in [corners[0], corners[1], corners[2], corners[0], corners[2], corners[1]] {
210 tris.extend(point.iter().map(|c| *c as f32));
211 tri_colors.extend_from_slice(&ink);
212 }
213 }
214 let group = if lines.is_empty() && tris.is_empty() {
215 serde_json::json!({ "name": PMI_OVERLAY_GROUP })
216 } else {
217 serde_json::json!({
218 "name": PMI_OVERLAY_GROUP,
219 "renderOrder": 10001,
220 "lines": { "positions": lines, "colors": line_colors },
221 "tris": { "positions": tris, "colors": tri_colors },
222 })
223 };
224 let _ = self.set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
225 self.pmi_overlay_key = Some((if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE }, view_dir));
226 self.dirty = true;
227 }
228
229 pub(crate) fn balloon_target_hidden_live(&self, row: &brep_kernel::PmiAnnotationReport) -> bool {
235 match &row.geometry {
236 brep_kernel::PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => {
237 !anchor.solids.is_empty()
238 && anchor
239 .solids
240 .iter()
241 .all(|name| self.scene.solid(name).is_none_or(|solid| !solid.visible))
242 }
243 _ => false,
244 }
245 }
246
247 fn clear_pmi_overlay(&mut self) {
248 if self.pmi_overlay_key.take().is_some() {
249 let _ = self.set_overlay_json(
250 &serde_json::json!({ "groups": [ { "name": PMI_OVERLAY_GROUP } ] }).to_string(),
251 );
252 self.dirty = true;
253 }
254 }
255
256 pub fn ensure_pmi_overlay_current(&mut self) {
260 let Some((baked_wpp, baked_dir)) = self.pmi_overlay_key else {
261 if self.pmi_active_view.is_some() {
262 self.refresh_pmi_overlay();
263 }
264 return;
265 };
266 if self.any_sketch_mode() {
267 self.clear_pmi_overlay();
268 return;
269 }
270 let wpp = self.camera.world_per_pixel();
271 let (_, _, view_dir) = self.camera.basis();
272 let dot = baked_dir[0] * view_dir[0] + baked_dir[1] * view_dir[1] + baked_dir[2] * view_dir[2];
273 let camera = self.snapshot_camera();
274 let balloon_camera_changed = self.pmi_report.as_ref()
275 .and_then(|r| self.pmi_active_view.as_ref().and_then(|id| r.view(id)))
276 .is_some_and(|v| v.annotations.iter().any(|row| matches!(&row.geometry,
277 brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
278 if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera))));
279 if balloon_camera_changed || super::overlay_wpp_stale(baked_wpp, wpp) || dot < (0.5f64).to_radians().cos() {
280 self.refresh_pmi_overlay();
281 }
282 }
283
284 pub fn pmi_labels_json(&self) -> String {
289 serde_json::to_string(&self.pmi_labels()).unwrap_or_else(|_| "[]".into())
290 }
291
292 pub fn pmi_labels(&self) -> Vec<PmiLabel> {
296 let Some(active) = self.pmi_active_view.as_deref() else {
297 return Vec::new();
298 };
299 let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(active)) else {
300 return Vec::new();
301 };
302 let explode_chips = matches!(self.settings.workbench.as_str(), "pmi" | "all");
306 if !explode_chips && view.annotations.iter().all(|row| matches!(row.kind.as_str(), "explode" | "hide")) {
307 return Vec::new();
308 }
309 let text_size = self
310 .pmi_state_shared()
311 .find_view(active)
312 .map(|view| view.display.text_size_pt)
313 .unwrap_or(12.0);
314 let playback = self.explode_playback_poses();
316 view.annotations
322 .iter()
323 .filter(|row| !matches!(row.kind.as_str(), "explode" | "hide"))
324 .map(|row| {
325 let (status, message) = if row.status == PmiStatus::Ok && self.balloon_target_hidden_live(row) {
330 (PmiStatus::Error, LIVE_HIDDEN_MESSAGE.to_string())
331 } else {
332 (row.status, row.message.clone())
333 };
334 let color = if status == PmiStatus::Error {
335 ERROR_RGB
336 } else if !row.enabled {
337 DISABLED_RGB
338 } else {
339 PMI_RGB
340 };
341 let icon = pmi_type(&row.kind).map(|def| def.icon).unwrap_or("");
342 let text = if status == PmiStatus::Error || row.text.is_empty() {
343 format!("{} {}", icon, row.id)
344 } else {
345 row.text.clone()
346 };
347 PmiLabel {
348 id: row.id.clone(),
349 kind: row.kind.clone(),
350 icon,
351 text,
352 status: match status { PmiStatus::Ok => "ok", PmiStatus::Error => "error" },
353 message,
354 color,
355 world: playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world)),
356 enabled: row.enabled,
357 open: self.pmi_open_annotation.as_deref() == Some(row.id.as_str()),
358 selected: self.pmi_selected_annotation.as_deref() == Some(row.id.as_str()),
359 text_size_pt: text_size,
360 }
361 })
362 .collect()
363 }
364}