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
28impl EngineState {
29    /// Re-bake the overlay from the cached report (empty without an active
30    /// view or while a sketch edit is live).
31    pub fn refresh_pmi_overlay(&mut self) {
32        let Some(active) = self.pmi_active_view.clone() else {
33            self.clear_pmi_overlay();
34            return;
35        };
36        if self.any_sketch_mode() {
37            self.clear_pmi_overlay();
38            return;
39        }
40        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(&active)).cloned() else {
41            self.clear_pmi_overlay();
42            return;
43        };
44        let text_size = self
45            .pmi_state()
46            .find_view(&active)
47            .map(|view| view.display.text_size_pt)
48            .unwrap_or(12.0);
49        let wpp = self.camera.world_per_pixel();
50        let (_, up, view_dir) = self.camera.basis();
51        let style = PmiLayoutStyle {
52            arrow: ARROW_PX * wpp,
53            text_height: TEXT_PX_AT_12PT * wpp * (text_size / 12.0),
54            view_dir,
55            view_up: up,
56            plane: None,
57        };
58        let mut lines: Vec<f32> = Vec::new();
59        let mut line_colors: Vec<f32> = Vec::new();
60        let mut tris: Vec<f32> = Vec::new();
61        let mut tri_colors: Vec<f32> = Vec::new();
62        for row in &view.annotations {
63            if !row.enabled || row.status != PmiStatus::Ok || self.balloon_target_hidden_live(row) {
64                continue;
65            }
66            // A picked annotation plane lays the row out in that plane.
67            let row_style = PmiLayoutStyle {
68                plane: row.plane,
69                ..style
70            };
71            let drawn = pmi_present(&row.geometry, row.label_world, &row.text, &row_style);
72            for polyline in &drawn.polylines {
73                for pair in polyline.windows(2) {
74                    for point in pair {
75                        lines.extend(point.iter().map(|c| *c as f32));
76                        line_colors.extend_from_slice(&PMI_RGB);
77                    }
78                }
79            }
80            for arrow in &drawn.arrows {
81                for point in arrow {
82                    tris.extend(point.iter().map(|c| *c as f32));
83                    tri_colors.extend_from_slice(&PMI_RGB);
84                }
85                // Both windings, so the arrowhead reads from either side.
86                for point in [arrow[0], arrow[2], arrow[1]] {
87                    tris.extend(point.iter().map(|c| *c as f32));
88                    tri_colors.extend_from_slice(&PMI_RGB);
89                }
90            }
91        }
92        let group = if lines.is_empty() && tris.is_empty() {
93            serde_json::json!({ "name": PMI_OVERLAY_GROUP })
94        } else {
95            serde_json::json!({
96                "name": PMI_OVERLAY_GROUP,
97                "renderOrder": 10001,
98                "lines": { "positions": lines, "colors": line_colors },
99                "tris": { "positions": tris, "colors": tri_colors },
100            })
101        };
102        let _ = self.set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
103        self.pmi_overlay_key = Some((if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE }, view_dir));
104        self.dirty = true;
105    }
106
107    /// Whether a balloon row's target is hidden in the LIVE scene: every
108    /// solid its recipe projects onto is invisible now. The kernel resolves
109    /// against the view's stored hidden list; a hide made in the viewport
110    /// after the run is only known here, so the leader is withheld and the
111    /// chip reads as an error until the view's visibility is updated.
112    pub(crate) fn balloon_target_hidden_live(&self, row: &brep_kernel::PmiAnnotationReport) -> bool {
113        match &row.geometry {
114            brep_kernel::PmiGeometry::Leader { balloon: true, anchor: Some(anchor), .. } => {
115                !anchor.solids.is_empty()
116                    && anchor
117                        .solids
118                        .iter()
119                        .all(|name| self.scene.solid(name).is_none_or(|solid| !solid.visible))
120            }
121            _ => false,
122        }
123    }
124
125    fn clear_pmi_overlay(&mut self) {
126        if self.pmi_overlay_key.take().is_some() {
127            let _ = self.set_overlay_json(
128                &serde_json::json!({ "groups": [ { "name": PMI_OVERLAY_GROUP } ] }).to_string(),
129            );
130            self.dirty = true;
131        }
132    }
133
134    /// Per-frame upkeep: re-bake when the zoom (>0.5 %) or the viewing
135    /// direction (>0.5°) moved, so arrowheads stay pixel-sized and face the
136    /// camera.
137    pub fn ensure_pmi_overlay_current(&mut self) {
138        let Some((baked_wpp, baked_dir)) = self.pmi_overlay_key else {
139            if self.pmi_active_view.is_some() {
140                self.refresh_pmi_overlay();
141            }
142            return;
143        };
144        if self.any_sketch_mode() {
145            self.clear_pmi_overlay();
146            return;
147        }
148        let wpp = self.camera.world_per_pixel();
149        let (_, _, view_dir) = self.camera.basis();
150        let dot = baked_dir[0] * view_dir[0] + baked_dir[1] * view_dir[1] + baked_dir[2] * view_dir[2];
151        if super::overlay_wpp_stale(baked_wpp, wpp) || dot < (0.5f64).to_radians().cos() {
152            self.refresh_pmi_overlay();
153        }
154    }
155
156    /// The label feed for the app's chip pass — the active view's
157    /// annotations: `[{id, type, icon, text, status, message, color:[r,g,b],
158    /// world:[x,y,z], enabled, open, textSizePt}]`. Empty without an active
159    /// view.
160    pub fn pmi_labels_json(&self) -> String {
161        let Some(active) = self.pmi_active_view.as_deref() else {
162            return "[]".into();
163        };
164        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(active)) else {
165            return "[]".into();
166        };
167        let text_size = self
168            .pmi_state()
169            .find_view(active)
170            .map(|view| view.display.text_size_pt)
171            .unwrap_or(12.0);
172        let rows: Vec<serde_json::Value> = view
173            .annotations
174            .iter()
175            .map(|row| {
176                // A balloon whose target was hidden in the viewport AFTER
177                // the run (the view's hidden list not yet updated) is an
178                // error chip with no leader: the kernel's head is on a body
179                // nobody sees.
180                let (status, message) = if row.status == PmiStatus::Ok && self.balloon_target_hidden_live(row) {
181                    (PmiStatus::Error, LIVE_HIDDEN_MESSAGE.to_string())
182                } else {
183                    (row.status, row.message.clone())
184                };
185                let color = if status == PmiStatus::Error {
186                    ERROR_RGB
187                } else if !row.enabled {
188                    DISABLED_RGB
189                } else {
190                    PMI_RGB
191                };
192                let icon = pmi_type(&row.kind).map(|def| def.icon).unwrap_or("");
193                let text = if status == PmiStatus::Error {
194                    format!("{} {}", icon, row.id)
195                } else if row.text.is_empty() {
196                    format!("{} {}", icon, row.id)
197                } else {
198                    row.text.clone()
199                };
200                serde_json::json!({
201                    "id": row.id,
202                    "type": row.kind,
203                    "icon": icon,
204                    "text": text,
205                    "status": match status { PmiStatus::Ok => "ok", PmiStatus::Error => "error" },
206                    "message": message,
207                    "color": [color[0], color[1], color[2]],
208                    "world": row.label_world,
209                    "enabled": row.enabled,
210                    "open": self.pmi_open_annotation.as_deref() == Some(row.id.as_str()),
211                    "selected": self.pmi_selected_annotation.as_deref() == Some(row.id.as_str()),
212                    "textSizePt": text_size,
213                })
214            })
215            .collect();
216        serde_json::Value::Array(rows).to_string()
217    }
218}