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.
13const 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).
16const 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
25impl EngineState {
26    /// Re-bake the overlay from the cached report (empty without an active
27    /// view or while a sketch edit is live).
28    pub fn refresh_pmi_overlay(&mut self) {
29        let Some(active) = self.pmi_active_view.clone() else {
30            self.clear_pmi_overlay();
31            return;
32        };
33        if self.sketch_mode() {
34            self.clear_pmi_overlay();
35            return;
36        }
37        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(&active)).cloned() else {
38            self.clear_pmi_overlay();
39            return;
40        };
41        let text_size = self
42            .pmi_state()
43            .find_view(&active)
44            .map(|view| view.display.text_size_pt)
45            .unwrap_or(12.0);
46        let wpp = self.camera.world_per_pixel();
47        let (_, up, view_dir) = self.camera.basis();
48        let style = PmiLayoutStyle {
49            arrow: ARROW_PX * wpp,
50            text_height: TEXT_PX_AT_12PT * wpp * (text_size / 12.0),
51            view_dir,
52            view_up: up,
53            plane: None,
54        };
55        let mut lines: Vec<f32> = Vec::new();
56        let mut line_colors: Vec<f32> = Vec::new();
57        let mut tris: Vec<f32> = Vec::new();
58        let mut tri_colors: Vec<f32> = Vec::new();
59        for row in &view.annotations {
60            if !row.enabled || row.status != PmiStatus::Ok {
61                continue;
62            }
63            // A picked annotation plane lays the row out in that plane.
64            let row_style = PmiLayoutStyle {
65                plane: row.plane,
66                ..style
67            };
68            let drawn = pmi_present(&row.geometry, row.label_world, &row.text, &row_style);
69            for polyline in &drawn.polylines {
70                for pair in polyline.windows(2) {
71                    for point in pair {
72                        lines.extend(point.iter().map(|c| *c as f32));
73                        line_colors.extend_from_slice(&PMI_RGB);
74                    }
75                }
76            }
77            for arrow in &drawn.arrows {
78                for point in arrow {
79                    tris.extend(point.iter().map(|c| *c as f32));
80                    tri_colors.extend_from_slice(&PMI_RGB);
81                }
82                // Both windings, so the arrowhead reads from either side.
83                for point in [arrow[0], arrow[2], arrow[1]] {
84                    tris.extend(point.iter().map(|c| *c as f32));
85                    tri_colors.extend_from_slice(&PMI_RGB);
86                }
87            }
88        }
89        let group = if lines.is_empty() && tris.is_empty() {
90            serde_json::json!({ "name": PMI_OVERLAY_GROUP })
91        } else {
92            serde_json::json!({
93                "name": PMI_OVERLAY_GROUP,
94                "renderOrder": 10001,
95                "lines": { "positions": lines, "colors": line_colors },
96                "tris": { "positions": tris, "colors": tri_colors },
97            })
98        };
99        let _ = self.set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
100        self.pmi_overlay_key = Some((if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE }, view_dir));
101        self.dirty = true;
102    }
103
104    fn clear_pmi_overlay(&mut self) {
105        if self.pmi_overlay_key.take().is_some() {
106            let _ = self.set_overlay_json(
107                &serde_json::json!({ "groups": [ { "name": PMI_OVERLAY_GROUP } ] }).to_string(),
108            );
109            self.dirty = true;
110        }
111    }
112
113    /// Per-frame upkeep: re-bake when the zoom (>0.5 %) or the viewing
114    /// direction (>0.5°) moved, so arrowheads stay pixel-sized and face the
115    /// camera.
116    pub fn ensure_pmi_overlay_current(&mut self) {
117        let Some((baked_wpp, baked_dir)) = self.pmi_overlay_key else {
118            if self.pmi_active_view.is_some() {
119                self.refresh_pmi_overlay();
120            }
121            return;
122        };
123        if self.sketch_mode() {
124            self.clear_pmi_overlay();
125            return;
126        }
127        let wpp = self.camera.world_per_pixel();
128        let (_, _, view_dir) = self.camera.basis();
129        let dot = baked_dir[0] * view_dir[0] + baked_dir[1] * view_dir[1] + baked_dir[2] * view_dir[2];
130        if super::overlay_wpp_stale(baked_wpp, wpp) || dot < (0.5f64).to_radians().cos() {
131            self.refresh_pmi_overlay();
132        }
133    }
134
135    /// The label feed for the app's chip pass — the active view's
136    /// annotations: `[{id, type, icon, text, status, message, color:[r,g,b],
137    /// world:[x,y,z], enabled, open, textSizePt}]`. Empty without an active
138    /// view.
139    pub fn pmi_labels_json(&self) -> String {
140        let Some(active) = self.pmi_active_view.as_deref() else {
141            return "[]".into();
142        };
143        let Some(view) = self.pmi_report.as_ref().and_then(|report| report.view(active)) else {
144            return "[]".into();
145        };
146        let text_size = self
147            .pmi_state()
148            .find_view(active)
149            .map(|view| view.display.text_size_pt)
150            .unwrap_or(12.0);
151        let rows: Vec<serde_json::Value> = view
152            .annotations
153            .iter()
154            .map(|row| {
155                let color = if row.status == PmiStatus::Error {
156                    ERROR_RGB
157                } else if !row.enabled {
158                    DISABLED_RGB
159                } else {
160                    PMI_RGB
161                };
162                let icon = pmi_type(&row.kind).map(|def| def.icon).unwrap_or("");
163                let text = if row.status == PmiStatus::Error {
164                    format!("{} {}", icon, row.id)
165                } else if row.text.is_empty() {
166                    format!("{} {}", icon, row.id)
167                } else {
168                    row.text.clone()
169                };
170                serde_json::json!({
171                    "id": row.id,
172                    "type": row.kind,
173                    "icon": icon,
174                    "text": text,
175                    "status": match row.status { PmiStatus::Ok => "ok", PmiStatus::Error => "error" },
176                    "message": row.message,
177                    "color": [color[0], color[1], color[2]],
178                    "world": row.label_world,
179                    "enabled": row.enabled,
180                    "open": self.pmi_open_annotation.as_deref() == Some(row.id.as_str()),
181                    "textSizePt": text_size,
182                })
183            })
184            .collect();
185        serde_json::Value::Array(rows).to_string()
186    }
187}