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 error annotation's chip colour.
21const ERROR_RGB: [f32; 3] = [0.97, 0.32, 0.29];
22/// A disabled annotation's chip colour.
23const DISABLED_RGB: [f32; 3] = [0.55, 0.55, 0.55];
24/// The error a balloon shows while its target is hidden in the viewport and
25/// the view's hidden list has not caught up (no run has happened).
26pub(crate) const LIVE_HIDDEN_MESSAGE: &str = "target hidden in the viewport: show it, or update the view's visibility";
27
28/// One chip of [`EngineState::pmi_labels`]; serialised, the
29/// `__brepPmiLabels` row.
30#[derive(Debug, Clone, PartialEq, serde::Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct PmiLabel {
33    pub id: String,
34    #[serde(rename = "type")]
35    pub kind: String,
36    pub icon: &'static str,
37    /// What the chip reads: the resolved text, or the type's glyph and the
38    /// id for an error or an empty one.
39    pub text: String,
40    /// `ok` or `error`.
41    pub status: &'static str,
42    pub message: String,
43    pub color: [f32; 3],
44    /// The label anchor, world coordinates.
45    pub world: [f64; 3],
46    pub enabled: bool,
47    /// Its form is open.
48    pub open: bool,
49    /// Selected in the PMI tree.
50    pub selected: bool,
51    pub text_size_pt: f64,
52}
53
54impl EngineState {
55    /// Re-bake the overlay from the cached report (empty without an active
56    /// view or while a sketch edit is live).
57    pub fn refresh_pmi_overlay(&mut self) {
58        let Some(active) = self.pmi_active_view.clone() else {
59            self.clear_pmi_overlay();
60            return;
61        };
62        if self.any_sketch_mode() {
63            self.clear_pmi_overlay();
64            return;
65        }
66        // Camera movement changes an automatic leader's closest screen point.
67        // Fetch missing topology in one batch; the topology callback retries
68        // this bake once the last pending balloon can be projected.
69        let camera = self.snapshot_camera();
70        let ids: Vec<String> = self.pmi_report.as_ref().and_then(|r| r.view(&active))
71            .map(|v| v.annotations.iter().filter(|row| matches!(&row.geometry,
72                brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
73                if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera)))
74                .map(|row| row.id.clone()).collect()).unwrap_or_default();
75        let mut missing = Vec::new();
76        for id in &ids {
77            if let Some(names) = self.pmi_balloon_head_now(id) {
78                missing.extend(names);
79                self.pmi_balloon_stale = Some(id.clone());
80            }
81        }
82        if !missing.is_empty() {
83            missing.sort();
84            missing.dedup();
85            self.request_exact_solids(missing.iter().map(String::as_str));
86            for id in &ids { let _ = self.pmi_balloon_head_now(id); }
87        }
88        // Borrowed, not cloned: a 500-step view's rows are ~4 ms to copy, every
89        // animation frame. Nothing below writes `self` until the bake is done.
90        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(&active)) else {
91            self.clear_pmi_overlay();
92            return;
93        };
94        let text_size = self
95            .pmi_state_shared()
96            .find_view(&active)
97            .map(|view| view.display.text_size_pt)
98            .unwrap_or(12.0);
99        let wpp = self.camera.world_per_pixel();
100        let (_, up, view_dir) = self.camera.basis();
101        let style = PmiLayoutStyle {
102            arrow: ARROW_PX * wpp,
103            text_height: TEXT_PX_AT_12PT * wpp * (text_size / 12.0),
104            view_dir,
105            view_up: up,
106            plane: None,
107        };
108        let mut lines: Vec<f32> = Vec::new();
109        let mut line_colors: Vec<f32> = Vec::new();
110        let mut tris: Vec<f32> = Vec::new();
111        let mut tri_colors: Vec<f32> = Vec::new();
112        let mut explode_step = 0;
113        // Part-way through playback, a balloon rides its moving target.
114        let playback = self.explode_playback_poses();
115        for row in &view.annotations {
116            if !row.enabled || row.status != PmiStatus::Ok || self.balloon_target_hidden_live(row) {
117                continue;
118            }
119            // An explode trail follows its step's playback fraction, like its bodies.
120            let geometry = if matches!(row.geometry, brep_kernel::PmiGeometry::Explode { .. }) {
121                explode_step += 1;
122                super::exploded_ops::explode_at(&row.geometry, self.explode_fraction(explode_step - 1))
123            } else {
124                playback.as_ref().and_then(|poses| poses.shown_geometry(row)).unwrap_or_else(|| row.geometry.clone())
125            };
126            let label = playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world));
127            // A picked annotation plane lays the row out in that plane.
128            let row_style = PmiLayoutStyle {
129                plane: row.plane,
130                ..style
131            };
132            let drawn = pmi_present(&geometry, label, &row.text, &row_style);
133            for polyline in &drawn.polylines {
134                for pair in polyline.windows(2) {
135                    for point in pair {
136                        lines.extend(point.iter().map(|c| *c as f32));
137                        line_colors.extend_from_slice(&PMI_RGB);
138                    }
139                }
140            }
141            for arrow in &drawn.arrows {
142                for point in arrow {
143                    tris.extend(point.iter().map(|c| *c as f32));
144                    tri_colors.extend_from_slice(&PMI_RGB);
145                }
146                // Both windings, so the arrowhead reads from either side.
147                for point in [arrow[0], arrow[2], arrow[1]] {
148                    tris.extend(point.iter().map(|c| *c as f32));
149                    tri_colors.extend_from_slice(&PMI_RGB);
150                }
151            }
152        }
153        let group = if lines.is_empty() && tris.is_empty() {
154            serde_json::json!({ "name": PMI_OVERLAY_GROUP })
155        } else {
156            serde_json::json!({
157                "name": PMI_OVERLAY_GROUP,
158                "renderOrder": 10001,
159                "lines": { "positions": lines, "colors": line_colors },
160                "tris": { "positions": tris, "colors": tri_colors },
161            })
162        };
163        let _ = self.set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
164        self.pmi_overlay_key = Some((if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE }, view_dir));
165        self.dirty = true;
166    }
167
168    /// Whether a balloon row's target is hidden in the LIVE scene: every
169    /// solid its recipe projects onto is invisible now. The kernel resolves
170    /// against the view's stored hidden list; a hide made in the viewport
171    /// after the run is only known here, so the leader is withheld and the
172    /// chip reads as an error until the view's visibility is updated.
173    pub(crate) fn balloon_target_hidden_live(&self, row: &brep_kernel::PmiAnnotationReport) -> bool {
174        match &row.geometry {
175            brep_kernel::PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => {
176                !anchor.solids.is_empty()
177                    && anchor
178                        .solids
179                        .iter()
180                        .all(|name| self.scene.solid(name).is_none_or(|solid| !solid.visible))
181            }
182            _ => false,
183        }
184    }
185
186    fn clear_pmi_overlay(&mut self) {
187        if self.pmi_overlay_key.take().is_some() {
188            let _ = self.set_overlay_json(
189                &serde_json::json!({ "groups": [ { "name": PMI_OVERLAY_GROUP } ] }).to_string(),
190            );
191            self.dirty = true;
192        }
193    }
194
195    /// Per-frame upkeep: re-bake when the zoom (>0.5 %) or the viewing
196    /// direction (>0.5°) moved, so arrowheads stay pixel-sized and face the
197    /// camera.
198    pub fn ensure_pmi_overlay_current(&mut self) {
199        let Some((baked_wpp, baked_dir)) = self.pmi_overlay_key else {
200            if self.pmi_active_view.is_some() {
201                self.refresh_pmi_overlay();
202            }
203            return;
204        };
205        if self.any_sketch_mode() {
206            self.clear_pmi_overlay();
207            return;
208        }
209        let wpp = self.camera.world_per_pixel();
210        let (_, _, view_dir) = self.camera.basis();
211        let dot = baked_dir[0] * view_dir[0] + baked_dir[1] * view_dir[1] + baked_dir[2] * view_dir[2];
212        let camera = self.snapshot_camera();
213        let balloon_camera_changed = self.pmi_report.as_ref()
214            .and_then(|r| self.pmi_active_view.as_ref().and_then(|id| r.view(id)))
215            .is_some_and(|v| v.annotations.iter().any(|row| matches!(&row.geometry,
216                brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
217                if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera))));
218        if balloon_camera_changed || super::overlay_wpp_stale(baked_wpp, wpp) || dot < (0.5f64).to_radians().cos() {
219            self.refresh_pmi_overlay();
220        }
221    }
222
223    /// The label feed for the app's chip pass — the active view's
224    /// annotations: `[{id, type, icon, text, status, message, color:[r,g,b],
225    /// world:[x,y,z], enabled, open, selected, textSizePt}]`. Empty without an
226    /// active view. The automation registry's copy of [`Self::pmi_labels`].
227    pub fn pmi_labels_json(&self) -> String {
228        serde_json::to_string(&self.pmi_labels()).unwrap_or_else(|_| "[]".into())
229    }
230
231    /// The label feed, typed: what the viewport's chip pass reads every
232    /// frame. It reads the shared parse of the PMI block, so a frame with
233    /// nothing changed builds the rows and nothing else.
234    pub fn pmi_labels(&self) -> Vec<PmiLabel> {
235        let Some(active) = self.pmi_active_view.as_deref() else {
236            return Vec::new();
237        };
238        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(active)) else {
239            return Vec::new();
240        };
241        // An explode step's chip is an editing handle of the PMI workbench; an
242        // exploded view shown from the Assembly workbench draws its bodies and
243        // trails without them (`exploded_ops`).
244        let explode_chips = matches!(self.settings.workbench.as_str(), "pmi" | "all");
245        if !explode_chips && view.annotations.iter().all(|row| matches!(row.kind.as_str(), "explode" | "hide")) {
246            return Vec::new();
247        }
248        let text_size = self
249            .pmi_state_shared()
250            .find_view(active)
251            .map(|view| view.display.text_size_pt)
252            .unwrap_or(12.0);
253        // Part-way through playback a balloon rides its body.
254        let playback = self.explode_playback_poses();
255        // An explode step draws NO label in the 3D view (owner 2026-10-26:
256        // "I don't want to see the exploded labels at all"): its name lives
257        // in the PMI tree and the Assembly workbench's step list, and while
258        // its form is open the transform gizmo stands on its parts. A hide
259        // step draws nothing either: its bodies are not there to label.
260        view.annotations
261            .iter()
262            .filter(|row| !matches!(row.kind.as_str(), "explode" | "hide"))
263            .map(|row| {
264                // A balloon whose target was hidden in the viewport AFTER
265                // the run (the view's hidden list not yet updated) is an
266                // error chip with no leader: the kernel's head is on a body
267                // nobody sees.
268                let (status, message) = if row.status == PmiStatus::Ok && self.balloon_target_hidden_live(row) {
269                    (PmiStatus::Error, LIVE_HIDDEN_MESSAGE.to_string())
270                } else {
271                    (row.status, row.message.clone())
272                };
273                let color = if status == PmiStatus::Error {
274                    ERROR_RGB
275                } else if !row.enabled {
276                    DISABLED_RGB
277                } else {
278                    PMI_RGB
279                };
280                let icon = pmi_type(&row.kind).map(|def| def.icon).unwrap_or("");
281                let text = if status == PmiStatus::Error || row.text.is_empty() {
282                    format!("{} {}", icon, row.id)
283                } else {
284                    row.text.clone()
285                };
286                PmiLabel {
287                    id: row.id.clone(),
288                    kind: row.kind.clone(),
289                    icon,
290                    text,
291                    status: match status { PmiStatus::Ok => "ok", PmiStatus::Error => "error" },
292                    message,
293                    color,
294                    world: playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world)),
295                    enabled: row.enabled,
296                    open: self.pmi_open_annotation.as_deref() == Some(row.id.as_str()),
297                    selected: self.pmi_selected_annotation.as_deref() == Some(row.id.as_str()),
298                    text_size_pt: text_size,
299                }
300            })
301            .collect()
302    }
303}