Skip to main content

brep_app/panels/
interference.rs

1//! Interference results window (assemblies build-spec §9) — the floating
2//! window behind the Assembly 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 row per INTERFERING pair — `ACOMP1 × ACOMP3 — 12.4 mm³` — clicking it
11//!   selects/highlights BOTH components (emphasis over their member solids);
12//!   a hidden participant is noted on its row (it still participated —
13//!   interference is a physical question);
14//! * a green all-clear line when nothing interferes (a PASS state, shown
15//!   positively);
16//! * explicit `unverified` (boolean refused) and `skipped` (budget / no
17//!   geometry) sections — nothing is ever dropped silently;
18//! * a Re-run button.
19//!
20//! Colors come from the ONE assembly status map
21//! ([`brep_render::assembly_status`]) and volumes from the ONE measurement
22//! formatter ([`super::info_windows::num`]) — no re-rolled styling.
23
24use crate::automation::hit_keys::HitKeyDoc;
25use brep_render::assembly_status;
26use brep_render::engine_state::{EngineState, InterferencePair, InterferenceReport};
27use eframe::egui;
28use serde_json::Value;
29use std::collections::HashMap;
30
31/// The row/line label of one interfering pair: `A × B — 12.4 mm³`, with a
32/// `(hidden: …)` note when a participant is currently invisible.
33fn pair_label(pair: &InterferencePair) -> String {
34    let mut label = format!(
35        "{} \u{00d7} {} \u{2014} {} mm\u{00b3}",
36        pair.a,
37        pair.b,
38        super::info_windows::num(pair.volume)
39    );
40    let hidden: Vec<&str> = [
41        pair.a_hidden.then_some(pair.a.as_str()),
42        pair.b_hidden.then_some(pair.b.as_str()),
43    ]
44    .into_iter()
45    .flatten()
46    .collect();
47    if !hidden.is_empty() {
48        label.push_str(&format!(" (hidden: {})", hidden.join(", ")));
49    }
50    label
51}
52
53/// An [`egui::Color32`] off the shared status palette.
54fn status_color(status: &str) -> egui::Color32 {
55    let [r, g, b] = assembly_status::status_color_rgb(status);
56    egui::Color32::from_rgb(r, g, b)
57}
58
59/// The shell-owned interference results window. Opened (and run) by the
60/// Assembly workbench's toolbar button; re-run from its own button. Owns the
61/// last report — the engine holds no window state.
62#[derive(Default)]
63pub struct InterferenceWindow {
64    open: bool,
65    report: Option<InterferenceReport>,
66    /// Per-frame interactive-widget screen rects for the headed verifier.
67    hits: HashMap<String, egui::Rect>,
68}
69
70impl InterferenceWindow {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// The toolbar entry point: run the check NOW and show the window.
76    pub fn open_and_run(&mut self, state: &mut EngineState) {
77        self.report = Some(state.interference_check());
78        self.open = true;
79    }
80
81    /// Draw the window (if open) at ctx level, like the Info windows.
82    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
83        self.hits.clear();
84        if !self.open {
85            return;
86        }
87        let mut open = true;
88        egui::Window::new("Interference")
89            .id(egui::Id::new("brep-interference-window"))
90            .open(&mut open)
91            .movable(true)
92            .resizable(true)
93            .default_size([360.0, 320.0])
94            .default_pos([860.0, 80.0])
95            .show(ctx, |ui| {
96                egui::ScrollArea::vertical()
97                    .auto_shrink([false, false])
98                    .show(ui, |ui| self.body(ui, state));
99            });
100        self.open = open;
101    }
102
103    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
104        // Header: the summary + Re-run.
105        ui.horizontal(|ui| {
106            let rerun = ui.button("Re-run");
107            self.hits.insert("interference:rerun".into(), rerun.rect);
108            if rerun.clicked() {
109                self.report = Some(state.interference_check());
110            }
111            if let Some(report) = &self.report {
112                ui.weak(format!(
113                    "{} components \u{00b7} {} pairs \u{00b7} {} boolean{}",
114                    report.component_count,
115                    report.pair_total,
116                    report.booleans_run,
117                    if report.booleans_run == 1 { "" } else { "s" }
118                ));
119            }
120        });
121        ui.separator();
122
123        let Some(report) = self.report.clone() else {
124            ui.weak("Run the check from the toolbar.");
125            return;
126        };
127        if report.component_count < 2 {
128            ui.weak("Needs at least two components.");
129            return;
130        }
131
132        if report.pairs.is_empty() {
133            // The PASS state, shown positively (green — the shared palette's
134            // `satisfied`). Refusals/skips below still temper it.
135            ui.colored_label(
136                status_color("satisfied"),
137                format!(
138                    "\u{2713} No interference \u{2014} {} pair{} checked",
139                    report.pair_total,
140                    if report.pair_total == 1 { "" } else { "s" }
141                ),
142            );
143        } else {
144            for pair in &report.pairs {
145                // A clickable row: selecting it highlights BOTH participants
146                // (emphasis over the union of their member solids).
147                let row = ui.selectable_label(
148                    false,
149                    egui::RichText::new(pair_label(pair)).color(status_color("error")),
150                );
151                self.hits
152                    .insert(format!("interference:pair:{}x{}", pair.a, pair.b), row.rect);
153                if row.clicked() {
154                    state.select_components(&[pair.a.clone(), pair.b.clone()]);
155                }
156            }
157        }
158
159        // Never-silent sections: boolean refusals and skipped work.
160        if !report.unverified.is_empty() {
161            ui.add_space(4.0);
162            ui.label("Not verified (boolean refused):");
163            for line in &report.unverified {
164                ui.colored_label(status_color("unsupported-selection"), line);
165            }
166        }
167        if !report.skipped.is_empty() {
168            ui.add_space(4.0);
169            ui.label("Not checked:");
170            for line in &report.skipped {
171                ui.weak(line);
172            }
173        }
174    }
175
176    /// The window's logical state for the headed verifier
177    /// (`__brepInterference`): `{open, report: {…} | null}`.
178    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
179    pub fn state_json(&self) -> String {
180        let report = self.report.as_ref().map(|report| {
181            serde_json::json!({
182                "componentCount": report.component_count,
183                "pairTotal": report.pair_total,
184                "booleansRun": report.booleans_run,
185                "pairs": report
186                    .pairs
187                    .iter()
188                    .map(|pair| {
189                        serde_json::json!({
190                            "a": pair.a,
191                            "b": pair.b,
192                            "volume": pair.volume,
193                            "aHidden": pair.a_hidden,
194                            "bHidden": pair.b_hidden,
195                            "label": pair_label(pair),
196                        })
197                    })
198                    .collect::<Vec<_>>(),
199                "skipped": report.skipped,
200                "unverified": report.unverified,
201            })
202        });
203        serde_json::json!({
204            "open": self.open,
205            "report": report.unwrap_or(Value::Null),
206        })
207        .to_string()
208    }
209
210    /// Per-frame widget rects (`interference:rerun`, `interference:pair:AxB`)
211    /// for the headed verifier.
212    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
213    pub fn hits_json(&self) -> String {
214        crate::automation::hit_rects::hits_json(&self.hits)
215    }
216}
217
218// BREP private tests: 43c18ea8e8ccb7a6
219
220/// The hit keys this panel publishes (see `automation::hit_keys`).
221pub static HIT_KEYS: &[HitKeyDoc] = &[
222    HitKeyDoc { panel: "interference", prefix: "interference:rerun", meaning: "rerun the interference check", command: Some("interference_check") },
223    HitKeyDoc { panel: "interference", prefix: "interference:", meaning: "an interference result row", command: None },
224];