Skip to main content

brep_app/panels/
interference.rs

1//! Interference results window — the floating window behind the Assembly
2//! workbench's `∩` toolbar button.
3//!
4//! Follows the pinned Info-window idiom: a movable + resizable
5//! [`egui::Window`] drawn at ctx level, owning its own state (the last
6//! [`InterferenceReport`]). The check itself lives in the ENGINE
7//! ([`EngineState::interference_check`] — main-side, non-destructive,
8//! bbox-prefiltered); this panel only renders the report:
9//!
10//! * a Clearance field (mm, 0 = off): the near-miss threshold for the run;
11//! * a row per INTERFERING pair — `ACOMP1 × ACOMP3 — 12.4 mm³` — clicking it
12//!   selects/highlights BOTH components (emphasis over their member solids)
13//!   and frames them; a hidden participant is noted on its row (it still
14//!   participated — interference is a physical question);
15//! * a row per NEAR MISS — `ACOMP1 × ACOMP2 — 1.5 mm gap` — same click;
16//! * the interference volumes (red) and near-miss gaps (amber) drawn in the
17//!   viewport while the window is open and the report is current;
18//! * a green all-clear line when nothing interferes (a PASS state, shown
19//!   positively);
20//! * explicit `unverified` (boolean refused) and `skipped` (budget / no
21//!   geometry) sections — nothing is ever dropped silently;
22//! * a Re-run button.
23//!
24//! Colors come from the ONE assembly status map
25//! ([`brep_render::assembly_status`]) and volumes from the ONE measurement
26//! formatter (`super::info_windows::num`) — no re-rolled styling.
27
28use crate::automation::hit_keys::HitKeyDoc;
29use brep_render::assembly_status;
30use brep_render::engine_state::{ClearancePair, EngineState, InterferencePair, InterferenceReport};
31use eframe::egui;
32use serde_json::Value;
33use std::collections::HashMap;
34
35/// The row/line label of one interfering pair: `A × B — 12.4 mm³`, with a
36/// `(hidden: …)` note when a participant is currently invisible.
37fn pair_label(pair: &InterferencePair) -> String {
38    let mut label = format!(
39        "{} \u{00d7} {} \u{2014} {} mm\u{00b3}",
40        pair.a,
41        pair.b,
42        super::info_windows::num(pair.volume)
43    );
44    let hidden: Vec<&str> = [
45        pair.a_hidden.then_some(pair.a.as_str()),
46        pair.b_hidden.then_some(pair.b.as_str()),
47    ]
48    .into_iter()
49    .flatten()
50    .collect();
51    if !hidden.is_empty() {
52        label.push_str(&format!(" (hidden: {})", hidden.join(", ")));
53    }
54    label
55}
56
57/// The near-miss row label: `A × B — 1.5 mm gap`, plus the hidden note.
58fn near_label(pair: &ClearancePair) -> String {
59    let mut label = format!(
60        "{} \u{00d7} {} \u{2014} {} mm gap",
61        pair.a,
62        pair.b,
63        super::info_windows::num(pair.distance)
64    );
65    let hidden: Vec<&str> = [
66        pair.a_hidden.then_some(pair.a.as_str()),
67        pair.b_hidden.then_some(pair.b.as_str()),
68    ]
69    .into_iter()
70    .flatten()
71    .collect();
72    if !hidden.is_empty() {
73        label.push_str(&format!(" (hidden: {})", hidden.join(", ")));
74    }
75    label
76}
77
78/// An [`egui::Color32`] off the shared status palette.
79fn status_color(status: &str) -> egui::Color32 {
80    let [r, g, b] = assembly_status::status_color_rgb(status);
81    egui::Color32::from_rgb(r, g, b)
82}
83
84/// The shell-owned interference results window. Opened (and run) by the
85/// Assembly workbench's toolbar button; re-run from its own button. Owns the
86/// last report — the engine holds no window state.
87#[derive(Default)]
88pub struct InterferenceWindow {
89    open: bool,
90    report: Option<InterferenceReport>,
91    /// The near-miss threshold (mm) the next run uses; 0 = off.
92    clearance: f64,
93    /// The model moved since the report was taken: its overlay is cleared
94    /// and the window asks for a re-run.
95    stale: bool,
96    /// Per-frame interactive-widget screen rects for the headed verifier.
97    hits: HashMap<String, egui::Rect>,
98}
99
100impl InterferenceWindow {
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// The toolbar entry point: run the check NOW and show the window.
106    pub fn open_and_run(&mut self, state: &mut EngineState) {
107        self.run(state);
108        self.open = true;
109    }
110
111    /// Run the check at the window's clearance (draws the overlay).
112    fn run(&mut self, state: &mut EngineState) {
113        self.report = Some(state.interference_check_with(self.clearance));
114        self.stale = false;
115    }
116
117    /// Select both participants of a result row and frame them.
118    fn focus_pair(state: &mut EngineState, a: &str, b: &str) {
119        let ids = [a.to_string(), b.to_string()];
120        state.select_components(&ids);
121        state.zoom_to_components(&ids);
122    }
123
124    /// Draw the window (if open) at ctx level, like the Info windows.
125    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
126        self.hits.clear();
127        if !self.open {
128            return;
129        }
130        // A rebuild since the run: the drawn volumes no longer match the
131        // model — take them down and ask for a re-run.
132        if !self.stale
133            && self
134                .report
135                .as_ref()
136                .is_some_and(|report| report.generation != state.applied_generation())
137        {
138            self.stale = true;
139            state.clear_interference_overlay();
140        }
141        let mut open = true;
142        egui::Window::new("Interference")
143            .id(egui::Id::new("brep-interference-window"))
144            .open(&mut open)
145            .movable(true)
146            .resizable(true)
147            .default_size([360.0, 320.0])
148            .default_pos(super::below_chrome(ctx, 860.0, 0.0))
149            .show(ctx, |ui| {
150                egui::ScrollArea::vertical()
151                    .auto_shrink([false, false])
152                    .show(ui, |ui| self.body(ui, state));
153            });
154        if !open {
155            self.close(state);
156        }
157    }
158
159    /// Shut the window and take its overlay down (the title-bar ✕).
160    pub fn close(&mut self, state: &mut EngineState) {
161        self.open = false;
162        state.clear_interference_overlay();
163    }
164
165    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
166        // Header: the summary + Re-run.
167        ui.horizontal(|ui| {
168            let rerun = ui.button("Re-run");
169            self.hits.insert("interference:rerun".into(), rerun.rect);
170            if rerun.clicked() {
171                self.run(state);
172            }
173            ui.label("Clearance");
174            let clearance = ui.add(
175                egui::DragValue::new(&mut self.clearance)
176                    .range(0.0..=1.0e4)
177                    .speed(0.1)
178                    .suffix(" mm"),
179            );
180            self.hits.insert("interference:clearance".into(), clearance.rect);
181            if let Some(report) = &self.report {
182                ui.weak(format!(
183                    "{} components \u{00b7} {} pairs \u{00b7} {} boolean{}",
184                    report.component_count,
185                    report.pair_total,
186                    report.booleans_run,
187                    if report.booleans_run == 1 { "" } else { "s" }
188                ));
189            }
190        });
191        ui.separator();
192
193        let Some(report) = self.report.clone() else {
194            ui.weak("Run the check from the toolbar.");
195            return;
196        };
197        if report.component_count < 2 {
198            ui.weak("Needs at least two components.");
199            return;
200        }
201        if self.stale {
202            ui.colored_label(
203                status_color("unsupported-selection"),
204                "The model changed \u{2014} Re-run to refresh.",
205            );
206        }
207
208        if report.pairs.is_empty() {
209            // The PASS state, shown positively (green — the shared palette's
210            // `satisfied`). Refusals/skips below still temper it.
211            ui.colored_label(
212                status_color("satisfied"),
213                format!(
214                    "\u{2713} No interference \u{2014} {} pair{} checked",
215                    report.pair_total,
216                    if report.pair_total == 1 { "" } else { "s" }
217                ),
218            );
219        } else {
220            for pair in &report.pairs {
221                // A clickable row: selecting it highlights BOTH participants
222                // (emphasis over the union of their member solids).
223                let row = ui.selectable_label(
224                    false,
225                    egui::RichText::new(pair_label(pair)).color(status_color("error")),
226                );
227                self.hits
228                    .insert(format!("interference:pair:{}x{}", pair.a, pair.b), row.rect);
229                if row.clicked() {
230                    Self::focus_pair(state, &pair.a, &pair.b);
231                }
232            }
233        }
234
235        // Near misses (a clearance was set): amber rows, smallest gap first.
236        if report.clearance > 0.0 {
237            ui.add_space(4.0);
238            if report.near_misses.is_empty() {
239                ui.weak(format!(
240                    "No gaps under {} mm",
241                    super::info_windows::num(report.clearance)
242                ));
243            } else {
244                ui.label(format!(
245                    "Clearance under {} mm (gap measured on the display mesh):",
246                    super::info_windows::num(report.clearance)
247                ));
248            }
249            for pair in &report.near_misses {
250                let row = ui.selectable_label(
251                    false,
252                    egui::RichText::new(near_label(pair))
253                        .color(status_color("unsupported-selection")),
254                );
255                self.hits
256                    .insert(format!("interference:near:{}x{}", pair.a, pair.b), row.rect);
257                if row.clicked() {
258                    Self::focus_pair(state, &pair.a, &pair.b);
259                }
260            }
261        }
262
263        // Never-silent sections: boolean refusals and skipped work.
264        if !report.unverified.is_empty() {
265            ui.add_space(4.0);
266            ui.label("Not verified (boolean refused):");
267            for line in &report.unverified {
268                ui.colored_label(status_color("unsupported-selection"), line);
269            }
270        }
271        if !report.skipped.is_empty() {
272            ui.add_space(4.0);
273            ui.label("Not checked:");
274            for line in &report.skipped {
275                ui.weak(line);
276            }
277        }
278    }
279
280    /// The window's logical state for the headed verifier
281    /// (`__brepInterference`): `{open, report: {…} | null}`.
282    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
283    pub fn state_json(&self) -> String {
284        self.state_value().to_string()
285    }
286
287    /// [`Self::state_json`] plus `overlayShown`: whether `state` (the active
288    /// document's engine) currently draws the interference overlay — the
289    /// published blob, so a script can see the overlay follow the window.
290    pub fn state_json_in(&self, state: &EngineState) -> String {
291        let mut value = self.state_value();
292        value["overlayShown"] = Value::Bool(
293            state
294                .widgets
295                .overlay_group_names()
296                .contains(&brep_render::engine_state::INTERFERENCE_OVERLAY_GROUP),
297        );
298        value.to_string()
299    }
300
301    fn state_value(&self) -> Value {
302        let report = self.report.as_ref().map(|report| {
303            serde_json::json!({
304                "componentCount": report.component_count,
305                "pairTotal": report.pair_total,
306                "booleansRun": report.booleans_run,
307                "pairs": report
308                    .pairs
309                    .iter()
310                    .map(|pair| {
311                        serde_json::json!({
312                            "a": pair.a,
313                            "b": pair.b,
314                            "volume": pair.volume,
315                            "aHidden": pair.a_hidden,
316                            "bHidden": pair.b_hidden,
317                            "label": pair_label(pair),
318                        })
319                    })
320                    .collect::<Vec<_>>(),
321                "clearance": report.clearance,
322                "nearMisses": report
323                    .near_misses
324                    .iter()
325                    .map(|pair| {
326                        serde_json::json!({
327                            "a": pair.a,
328                            "b": pair.b,
329                            "distance": pair.distance,
330                            "witness": pair.witness,
331                            "aHidden": pair.a_hidden,
332                            "bHidden": pair.b_hidden,
333                            "label": near_label(pair),
334                        })
335                    })
336                    .collect::<Vec<_>>(),
337                "skipped": report.skipped,
338                "unverified": report.unverified,
339            })
340        });
341        serde_json::json!({
342            "open": self.open,
343            "clearance": self.clearance,
344            "stale": self.stale,
345            "report": report.unwrap_or(Value::Null),
346        })
347    }
348
349    /// Per-frame widget rects (`interference:rerun`, `interference:clearance`,
350    /// `interference:pair:AxB`, `interference:near:AxB`)
351    /// for the headed verifier.
352    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
353    pub fn hits_json(&self) -> String {
354        crate::automation::hit_rects::hits_json(&self.hits)
355    }
356}
357
358
359/// The hit keys this panel publishes (see `automation::hit_keys`).
360pub static HIT_KEYS: &[HitKeyDoc] = &[
361    HitKeyDoc { panel: "interference", prefix: "interference:rerun", meaning: "rerun the interference check", command: Some("interference_check") },
362    HitKeyDoc { panel: "interference", prefix: "interference:", meaning: "an interference result row", command: None },
363];