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        let mut center_marks: Vec<(String, Vec<[[f64; 3]; 2]>)> = Vec::new();
122        // Part-way through playback, a balloon rides its moving target.
123        let playback = self.explode_playback_poses();
124        for row in &view.annotations {
125            if !row.enabled || row.status != PmiStatus::Ok || self.balloon_target_hidden_live(row) {
126                continue;
127            }
128            // An explode trail follows its step's playback fraction, like its bodies.
129            let geometry = if matches!(row.geometry, brep_kernel::PmiGeometry::Explode { .. }) {
130                explode_step += 1;
131                super::exploded_ops::explode_at(&row.geometry, self.explode_fraction(explode_step - 1))
132            } else {
133                playback.as_ref().and_then(|poses| poses.shown_geometry(row)).unwrap_or_else(|| row.geometry.clone())
134            };
135            let label = playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world));
136            // A picked annotation plane lays the row out in that plane.
137            let row_style = PmiLayoutStyle {
138                plane: row.plane,
139                ..style
140            };
141            // An explode trace is an insertion arrow (`arrows` below), drawn
142            // from the parts where they stand to their seat.
143            if let brep_kernel::PmiGeometry::Explode { solids, translate, trace, .. } = &geometry {
144                if *trace {
145                    let mut now = crate::camera::Aabb::empty();
146                    for solid in self.scene.solids() {
147                        if solids.iter().any(|s| solid.name == *s || solid.name.strip_prefix(s.as_str()).is_some_and(|rest| rest.starts_with(':'))) {
148                            now.union(&solid.bbox);
149                        }
150                    }
151                    if !now.is_empty() {
152                        let home = crate::camera::Aabb {
153                            min: std::array::from_fn(|k| now.min[k] - translate[k]),
154                            max: std::array::from_fn(|k| now.max[k] - translate[k]),
155                        };
156                        arrows.extend(self.outline_arrow(&home, *translate));
157                    }
158                }
159                continue;
160            }
161            let drawn = pmi_present(&geometry, label, &row.text, &row_style);
162            if let brep_kernel::PmiGeometry::CenterMark { marks, .. } = &geometry {
163                // Each mark is drawn as six polylines, its two short cross
164                // arms first.
165                let arm = |line: &Vec<[f64; 3]>| -> [f64; 3] {
166                    let d: [f64; 3] = std::array::from_fn(|k| line[1][k] - line[0][k]);
167                    let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
168                    if length > 0.0 { d.map(|c| c / length) } else { [0.0; 3] }
169                };
170                let arms = (0..marks.len())
171                    .filter_map(|k| Some([arm(drawn.polylines.get(6 * k)?), arm(drawn.polylines.get(6 * k + 1)?)]))
172                    .collect();
173                center_marks.push((row.id.clone(), arms));
174            }
175            let rgb = PMI_RGB;
176            for polyline in &drawn.polylines {
177                for pair in polyline.windows(2) {
178                    for point in pair {
179                        lines.extend(point.iter().map(|c| *c as f32));
180                        line_colors.extend_from_slice(&rgb);
181                    }
182                }
183            }
184            for arrow in &drawn.arrows {
185                for point in arrow {
186                    tris.extend(point.iter().map(|c| *c as f32));
187                    tri_colors.extend_from_slice(&rgb);
188                }
189                // Both windings, so the arrowhead reads from either side.
190                for point in [arrow[0], arrow[2], arrow[1]] {
191                    tris.extend(point.iter().map(|c| *c as f32));
192                    tri_colors.extend_from_slice(&rgb);
193                }
194            }
195        }
196        // The exploded view's traces and the instructions player's insertion
197        // arrows: a thin solid line from each moving part to its seat and a
198        // filled head there (owner 2026-11-07), in ink that reads on the
199        // ground: black on a light one (a Line art step's), near-white on
200        // the dark viewport.
201        arrows.extend(self.instructions_insertion_arrows());
202        let ground = if self.scene.line_art.is_some() { crate::thumbnail::line_art::BACKGROUND } else { self.settings.background };
203        let light = 0.2126 * ground[0] + 0.7152 * ground[1] + 0.0722 * ground[2] > 0.5;
204        let ink = if light { EXPLODE_ARROW_RGB } else { EXPLODE_ARROW_ON_DARK_RGB };
205        for (tail, tip) in arrows {
206            let d = [tip[0] - tail[0], tip[1] - tail[1], tip[2] - tail[2]];
207            let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
208            if !(length > 1e-9) {
209                continue;
210            }
211            let d = d.map(|v| v / length);
212            let head = (INSERTION_HEAD_PX * wpp).min(length * 0.6);
213            let w = crate::geometry3d::cross3(view_dir, d);
214            let wl = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
215            let w = if wl > 1e-9 { w.map(|v| v / wl) } else { up };
216            let base: [f64; 3] = std::array::from_fn(|k| tip[k] - d[k] * head);
217            for point in [tail, base] {
218                lines.extend(point.iter().map(|c| *c as f32));
219                line_colors.extend_from_slice(&ink);
220            }
221            let half = head * 0.35;
222            let corners = [tip, std::array::from_fn(|k| base[k] + w[k] * half), std::array::from_fn(|k| base[k] - w[k] * half)];
223            for point in [corners[0], corners[1], corners[2], corners[0], corners[2], corners[1]] {
224                tris.extend(point.iter().map(|c| *c as f32));
225                tri_colors.extend_from_slice(&ink);
226            }
227        }
228        let group = if lines.is_empty() && tris.is_empty() {
229            serde_json::json!({ "name": PMI_OVERLAY_GROUP })
230        } else {
231            serde_json::json!({
232                "name": PMI_OVERLAY_GROUP,
233                "renderOrder": 10001,
234                "lines": { "positions": lines, "colors": line_colors },
235                "tris": { "positions": tris, "colors": tri_colors },
236            })
237        };
238        let _ = self.set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
239        self.pmi_overlay_key = Some((if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE }, view_dir));
240        self.pmi_center_marks_drawn = (center_marks, view_dir);
241        self.dirty = true;
242    }
243
244    /// The center marks the PMI overlay last drew, for automation:
245    /// `{viewDir, marks: [{id, arms: [[u, v], …]}]}` — per mark the cross's
246    /// two arms as unit world directions (f64, before the overlay's f32
247    /// buffers) and the view direction of that bake, so a check can see the
248    /// overlay re-baked while the arms held still.
249    pub fn pmi_center_marks_json(&self) -> String {
250        let (marks, view_dir) = &self.pmi_center_marks_drawn;
251        serde_json::json!({
252            "viewDir": view_dir,
253            "marks": marks.iter().map(|(id, arms)| serde_json::json!({ "id": id, "arms": arms })).collect::<Vec<_>>(),
254        })
255        .to_string()
256    }
257
258    /// Whether a balloon row's target is hidden in the LIVE scene: every
259    /// solid its recipe projects onto is invisible now. The kernel resolves
260    /// against the view's stored hidden list; a hide made in the viewport
261    /// after the run is only known here, so the leader is withheld and the
262    /// chip reads as an error until the view's visibility is updated.
263    pub(crate) fn balloon_target_hidden_live(&self, row: &brep_kernel::PmiAnnotationReport) -> bool {
264        match &row.geometry {
265            brep_kernel::PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => {
266                !anchor.solids.is_empty()
267                    && anchor
268                        .solids
269                        .iter()
270                        .all(|name| self.scene.solid(name).is_none_or(|solid| !solid.visible))
271            }
272            _ => false,
273        }
274    }
275
276    fn clear_pmi_overlay(&mut self) {
277        self.pmi_center_marks_drawn.0.clear();
278        if self.pmi_overlay_key.take().is_some() {
279            let _ = self.set_overlay_json(
280                &serde_json::json!({ "groups": [ { "name": PMI_OVERLAY_GROUP } ] }).to_string(),
281            );
282            self.dirty = true;
283        }
284    }
285
286    /// Per-frame upkeep: re-bake when the zoom (>0.5 %) or the viewing
287    /// direction (>0.5°) moved, so arrowheads stay pixel-sized and face the
288    /// camera.
289    pub fn ensure_pmi_overlay_current(&mut self) {
290        let Some((baked_wpp, baked_dir)) = self.pmi_overlay_key else {
291            if self.pmi_active_view.is_some() {
292                self.refresh_pmi_overlay();
293            }
294            return;
295        };
296        if self.any_sketch_mode() {
297            self.clear_pmi_overlay();
298            return;
299        }
300        let wpp = self.camera.world_per_pixel();
301        let (_, _, view_dir) = self.camera.basis();
302        let dot = baked_dir[0] * view_dir[0] + baked_dir[1] * view_dir[1] + baked_dir[2] * view_dir[2];
303        let camera = self.snapshot_camera();
304        let balloon_camera_changed = self.pmi_report.as_ref()
305            .and_then(|r| self.pmi_active_view.as_ref().and_then(|id| r.view(id)))
306            .is_some_and(|v| v.annotations.iter().any(|row| matches!(&row.geometry,
307                brep_kernel::PmiGeometry::Leader { anchor: Some(a), balloon: true, .. }
308                if matches!(a.mode, brep_kernel::BalloonAnchorMode::Surface) && a.camera.as_ref() != Some(&camera))));
309        if balloon_camera_changed || super::overlay_wpp_stale(baked_wpp, wpp) || dot < (0.5f64).to_radians().cos() {
310            self.refresh_pmi_overlay();
311        }
312    }
313
314    /// The label feed for the app's chip pass — the active view's
315    /// annotations: `[{id, type, icon, text, status, message, color:[r,g,b],
316    /// world:[x,y,z], enabled, open, selected, textSizePt}]`. Empty without an
317    /// active view. The automation registry's copy of [`Self::pmi_labels`].
318    pub fn pmi_labels_json(&self) -> String {
319        serde_json::to_string(&self.pmi_labels()).unwrap_or_else(|_| "[]".into())
320    }
321
322    /// The label feed, typed: what the viewport's chip pass reads every
323    /// frame. It reads the shared parse of the PMI block, so a frame with
324    /// nothing changed builds the rows and nothing else.
325    pub fn pmi_labels(&self) -> Vec<PmiLabel> {
326        let Some(active) = self.pmi_active_view.as_deref() else {
327            return Vec::new();
328        };
329        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(active)) else {
330            return Vec::new();
331        };
332        // An explode step's chip is an editing handle of the PMI workbench; an
333        // exploded view shown from the Assembly workbench draws its bodies and
334        // trails without them (`exploded_ops`).
335        let explode_chips = matches!(self.settings.workbench.as_str(), "pmi" | "all");
336        if !explode_chips && view.annotations.iter().all(|row| matches!(row.kind.as_str(), "explode" | "hide")) {
337            return Vec::new();
338        }
339        let text_size = self
340            .pmi_state_shared()
341            .find_view(active)
342            .map(|view| view.display.text_size_pt)
343            .unwrap_or(12.0);
344        // Part-way through playback a balloon rides its body.
345        let playback = self.explode_playback_poses();
346        // An explode step draws NO label in the 3D view (owner 2026-10-26:
347        // "I don't want to see the exploded labels at all"): its name lives
348        // in the PMI tree and the Assembly workbench's step list, and while
349        // its form is open the transform gizmo stands on its parts. A hide
350        // step draws nothing either: its bodies are not there to label. A
351        // center mark or centerline carries no text and draws no chip:
352        // its name lives in the PMI tree; only an
353        // unresolved one keeps its red chip, the one place its error shows
354        // in the view.
355        view.annotations
356            .iter()
357            .filter(|row| !matches!(row.kind.as_str(), "explode" | "hide"))
358            .filter(|row| !(row.kind == "centerline" && row.status == PmiStatus::Ok))
359            .map(|row| {
360                // A balloon whose target was hidden in the viewport AFTER
361                // the run (the view's hidden list not yet updated) is an
362                // error chip with no leader: the kernel's head is on a body
363                // nobody sees.
364                let (status, message) = if row.status == PmiStatus::Ok && self.balloon_target_hidden_live(row) {
365                    (PmiStatus::Error, LIVE_HIDDEN_MESSAGE.to_string())
366                } else {
367                    (row.status, row.message.clone())
368                };
369                let color = if status == PmiStatus::Error {
370                    ERROR_RGB
371                } else if !row.enabled {
372                    DISABLED_RGB
373                } else {
374                    PMI_RGB
375                };
376                let icon = pmi_type(&row.kind).map(|def| def.icon).unwrap_or("");
377                let text = if status == PmiStatus::Error || row.text.is_empty() {
378                    format!("{} {}", icon, row.id)
379                } else {
380                    row.text.clone()
381                };
382                PmiLabel {
383                    id: row.id.clone(),
384                    kind: row.kind.clone(),
385                    icon,
386                    text,
387                    status: match status { PmiStatus::Ok => "ok", PmiStatus::Error => "error" },
388                    message,
389                    color,
390                    world: playback.as_ref().map_or(row.label_world, |poses| poses.shown(row, row.label_world)),
391                    enabled: row.enabled,
392                    open: self.pmi_open_annotation.as_deref() == Some(row.id.as_str()),
393                    selected: self.pmi_selected_annotation.as_deref() == Some(row.id.as_str()),
394                    text_size_pt: text_size,
395                }
396            })
397            .collect()
398    }
399}