Skip to main content

brep_render/engine_state/
pmi_overlay.rs

1//! The active PMI view's annotation graphics: dimension lines, extension
2//! lines, arcs, leaders and arrowheads baked into the `pmi-overlay` group
3//! from the kernel's presentation layout (the SAME layout the STEP file
4//! writes), sized in pixels at bake time and re-baked when the zoom or the
5//! viewing direction moves materially; plus the label feed the app's chip
6//! pass draws.
7
8use super::*;
9use brep_kernel::{pmi_present, pmi_type, PmiLayoutStyle, PmiStatus};
10
11const PMI_OVERLAY_GROUP: &str = "pmi-overlay";
12/// Arrowhead length, in pixels.
13pub(crate) const ARROW_PX: f64 = 12.0;
14/// The reference text cap height at 12 pt, in pixels (frames scale with the
15/// view's text size).
16pub(crate) const TEXT_PX_AT_12PT: f64 = 11.0;
17/// PMI line / arrow colour (amber, distinct from the dimension gizmo's rods
18/// and the constraint status colours).
19const PMI_RGB: [f32; 3] = [0.98, 0.78, 0.22];
20/// An exploded view's trace arrows and the instructions' insertion arrows:
21/// black on a light ground, near-white on the dark viewport.
22const 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];
24/// Their filled head's length on screen, px (the reference frames' ~20 px;
25/// its width is 0.7 of it).
26const INSERTION_HEAD_PX: f64 = 20.0;
27/// An error annotation's chip colour.
28const ERROR_RGB: [f32; 3] = [0.97, 0.32, 0.29];
29/// A disabled annotation's chip colour.
30const DISABLED_RGB: [f32; 3] = [0.55, 0.55, 0.55];
31/// The error a balloon shows while its target is hidden in the viewport and
32/// the view's hidden list has not caught up (no run has happened).
33pub(crate) const LIVE_HIDDEN_MESSAGE: &str = "target hidden in the viewport: show it, or update the view's visibility";
34
35/// One chip of [`EngineState::pmi_labels`]; serialised, the
36/// `__brepPmiLabels` row.
37#[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    /// What the chip reads: the resolved text, or the type's glyph and the
45    /// id for an error or an empty one.
46    pub text: String,
47    /// `ok` or `error`.
48    pub status: &'static str,
49    pub message: String,
50    pub color: [f32; 3],
51    /// The label anchor, world coordinates.
52    pub world: [f64; 3],
53    pub enabled: bool,
54    /// Its form is open.
55    pub open: bool,
56    /// Selected in the PMI tree.
57    pub selected: bool,
58    pub text_size_pt: f64,
59}
60
61impl EngineState {
62    /// Re-bake the overlay from the cached report (empty without an active
63    /// view or while a sketch edit is live).
64    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        // Camera movement changes an automatic leader's closest screen point.
74        // Fetch missing topology in one batch; the topology callback retries
75        // this bake once the last pending balloon can be projected.
76        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        // Borrowed, not cloned: a 500-step view's rows are ~4 ms to copy, every
96        // animation frame. Nothing below writes `self` until the bake is done.
97        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        // Part-way through playback, a balloon rides its moving target.
122        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            // An explode trail follows its step's playback fraction, like its bodies.
128            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            // A picked annotation plane lays the row out in that plane.
136            let row_style = PmiLayoutStyle {
137                plane: row.plane,
138                ..style
139            };
140            // An explode trace is an insertion arrow (`arrows` below), drawn
141            // from the parts where they stand to their seat.
142            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                // Both windings, so the arrowhead reads from either side.
176                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        // The exploded view's traces and the instructions player's insertion
183        // arrows: a thin solid line from each moving part to its seat and a
184        // filled head there (owner 2026-11-07), in ink that reads on the
185        // ground: black on a light one (a Line art step's), near-white on
186        // the dark viewport.
187        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    /// Whether a balloon row's target is hidden in the LIVE scene: every
230    /// solid its recipe projects onto is invisible now. The kernel resolves
231    /// against the view's stored hidden list; a hide made in the viewport
232    /// after the run is only known here, so the leader is withheld and the
233    /// chip reads as an error until the view's visibility is updated.
234    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    /// Per-frame upkeep: re-bake when the zoom (>0.5 %) or the viewing
257    /// direction (>0.5°) moved, so arrowheads stay pixel-sized and face the
258    /// camera.
259    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    /// The label feed for the app's chip pass — the active view's
285    /// annotations: `[{id, type, icon, text, status, message, color:[r,g,b],
286    /// world:[x,y,z], enabled, open, selected, textSizePt}]`. Empty without an
287    /// active view. The automation registry's copy of [`Self::pmi_labels`].
288    pub fn pmi_labels_json(&self) -> String {
289        serde_json::to_string(&self.pmi_labels()).unwrap_or_else(|_| "[]".into())
290    }
291
292    /// The label feed, typed: what the viewport's chip pass reads every
293    /// frame. It reads the shared parse of the PMI block, so a frame with
294    /// nothing changed builds the rows and nothing else.
295    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        // An explode step's chip is an editing handle of the PMI workbench; an
303        // exploded view shown from the Assembly workbench draws its bodies and
304        // trails without them (`exploded_ops`).
305        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        // Part-way through playback a balloon rides its body.
315        let playback = self.explode_playback_poses();
316        // An explode step draws NO label in the 3D view (owner 2026-10-26:
317        // "I don't want to see the exploded labels at all"): its name lives
318        // in the PMI tree and the Assembly workbench's step list, and while
319        // its form is open the transform gizmo stands on its parts. A hide
320        // step draws nothing either: its bodies are not there to label.
321        view.annotations
322            .iter()
323            .filter(|row| !matches!(row.kind.as_str(), "explode" | "hide"))
324            .map(|row| {
325                // A balloon whose target was hidden in the viewport AFTER
326                // the run (the view's hidden list not yet updated) is an
327                // error chip with no leader: the kernel's head is on a body
328                // nobody sees.
329                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}