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 brep_render::assembly_status;
25use brep_render::engine_state::{EngineState, InterferencePair, InterferenceReport};
26use eframe::egui;
27use serde_json::Value;
28use std::collections::HashMap;
29
30/// The row/line label of one interfering pair: `A × B — 12.4 mm³`, with a
31/// `(hidden: …)` note when a participant is currently invisible.
32fn pair_label(pair: &InterferencePair) -> String {
33    let mut label = format!(
34        "{} \u{00d7} {} \u{2014} {} mm\u{00b3}",
35        pair.a,
36        pair.b,
37        super::info_windows::num(pair.volume)
38    );
39    let hidden: Vec<&str> = [
40        pair.a_hidden.then_some(pair.a.as_str()),
41        pair.b_hidden.then_some(pair.b.as_str()),
42    ]
43    .into_iter()
44    .flatten()
45    .collect();
46    if !hidden.is_empty() {
47        label.push_str(&format!(" (hidden: {})", hidden.join(", ")));
48    }
49    label
50}
51
52/// An [`egui::Color32`] off the shared status palette.
53fn status_color(status: &str) -> egui::Color32 {
54    let [r, g, b] = assembly_status::status_color_rgb(status);
55    egui::Color32::from_rgb(r, g, b)
56}
57
58/// The shell-owned interference results window. Opened (and run) by the
59/// Assembly workbench's toolbar button; re-run from its own button. Owns the
60/// last report — the engine holds no window state.
61#[derive(Default)]
62pub struct InterferenceWindow {
63    open: bool,
64    report: Option<InterferenceReport>,
65    /// Per-frame interactive-widget screen rects for the headed verifier.
66    hits: HashMap<String, egui::Rect>,
67}
68
69impl InterferenceWindow {
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// The toolbar entry point: run the check NOW and show the window.
75    pub fn open_and_run(&mut self, state: &mut EngineState) {
76        self.report = Some(state.interference_check());
77        self.open = true;
78    }
79
80    /// Draw the window (if open) at ctx level, like the Info windows.
81    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
82        self.hits.clear();
83        if !self.open {
84            return;
85        }
86        let mut open = true;
87        egui::Window::new("Interference")
88            .id(egui::Id::new("brep-interference-window"))
89            .open(&mut open)
90            .movable(true)
91            .resizable(true)
92            .default_size([360.0, 320.0])
93            .default_pos([860.0, 80.0])
94            .show(ctx, |ui| {
95                egui::ScrollArea::vertical()
96                    .auto_shrink([false, false])
97                    .show(ui, |ui| self.body(ui, state));
98            });
99        self.open = open;
100    }
101
102    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
103        // Header: the summary + Re-run.
104        ui.horizontal(|ui| {
105            let rerun = ui.button("Re-run");
106            self.hits.insert("interference:rerun".into(), rerun.rect);
107            if rerun.clicked() {
108                self.report = Some(state.interference_check());
109            }
110            if let Some(report) = &self.report {
111                ui.weak(format!(
112                    "{} components \u{00b7} {} pairs \u{00b7} {} boolean{}",
113                    report.component_count,
114                    report.pair_total,
115                    report.booleans_run,
116                    if report.booleans_run == 1 { "" } else { "s" }
117                ));
118            }
119        });
120        ui.separator();
121
122        let Some(report) = self.report.clone() else {
123            ui.weak("Run the check from the toolbar.");
124            return;
125        };
126        if report.component_count < 2 {
127            ui.weak("Needs at least two components.");
128            return;
129        }
130
131        if report.pairs.is_empty() {
132            // The PASS state, shown positively (green — the shared palette's
133            // `satisfied`). Refusals/skips below still temper it.
134            ui.colored_label(
135                status_color("satisfied"),
136                format!(
137                    "\u{2713} No interference \u{2014} {} pair{} checked",
138                    report.pair_total,
139                    if report.pair_total == 1 { "" } else { "s" }
140                ),
141            );
142        } else {
143            for pair in &report.pairs {
144                // A clickable row: selecting it highlights BOTH participants
145                // (emphasis over the union of their member solids).
146                let row = ui.selectable_label(
147                    false,
148                    egui::RichText::new(pair_label(pair)).color(status_color("error")),
149                );
150                self.hits
151                    .insert(format!("interference:pair:{}x{}", pair.a, pair.b), row.rect);
152                if row.clicked() {
153                    state.select_components(&[pair.a.clone(), pair.b.clone()]);
154                }
155            }
156        }
157
158        // Never-silent sections: boolean refusals and skipped work.
159        if !report.unverified.is_empty() {
160            ui.add_space(4.0);
161            ui.label("Not verified (boolean refused):");
162            for line in &report.unverified {
163                ui.colored_label(status_color("unsupported-selection"), line);
164            }
165        }
166        if !report.skipped.is_empty() {
167            ui.add_space(4.0);
168            ui.label("Not checked:");
169            for line in &report.skipped {
170                ui.weak(line);
171            }
172        }
173    }
174
175    /// The window's logical state for the headed verifier
176    /// (`__brepInterference`): `{open, report: {…} | null}`.
177    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
178    pub fn state_json(&self) -> String {
179        let report = self.report.as_ref().map(|report| {
180            serde_json::json!({
181                "componentCount": report.component_count,
182                "pairTotal": report.pair_total,
183                "booleansRun": report.booleans_run,
184                "pairs": report
185                    .pairs
186                    .iter()
187                    .map(|pair| {
188                        serde_json::json!({
189                            "a": pair.a,
190                            "b": pair.b,
191                            "volume": pair.volume,
192                            "aHidden": pair.a_hidden,
193                            "bHidden": pair.b_hidden,
194                            "label": pair_label(pair),
195                        })
196                    })
197                    .collect::<Vec<_>>(),
198                "skipped": report.skipped,
199                "unverified": report.unverified,
200            })
201        });
202        serde_json::json!({
203            "open": self.open,
204            "report": report.unwrap_or(Value::Null),
205        })
206        .to_string()
207    }
208
209    /// Per-frame widget rects (`interference:rerun`, `interference:pair:AxB`)
210    /// for the headed verifier.
211    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
212    pub fn hits_json(&self) -> String {
213        let mut map = serde_json::Map::new();
214        for (key, rect) in &self.hits {
215            map.insert(
216                key.clone(),
217                serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
218            );
219        }
220        Value::Object(map).to_string()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use brep_render::engine_state::ComponentInsert;
228
229    /// A one-cube part document (10 mm cube `Part` — the insert-flow payload).
230    fn part_document() -> String {
231        serde_json::json!({
232            "expressions": "",
233            "configurator": {},
234            "features": [{
235                "type": "P.CU",
236                "inputParams": {
237                    "id": "Part",
238                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
239                    "transform": {
240                        "position": [0.0, 0.0, 0.0],
241                        "rotationEuler": [0.0, 0.0, 0.0],
242                        "scale": [1.0, 1.0, 1.0]
243                    },
244                    "boolean": { "targets": [], "operation": "NONE" }
245                },
246                "persistentData": {}
247            }]
248        })
249        .to_string()
250    }
251
252    /// Two 10 mm cube instances via the REAL insert flow, the second moved to
253    /// `translate` (the assembly_ops test idiom).
254    fn two_cube_state(translate: [f64; 3]) -> EngineState {
255        brep_render::brep_kernel::clear_history_cache();
256        let mut state = EngineState::new();
257        state
258            .insert_component(ComponentInsert::New {
259                name: "cube",
260                source_key: "cube",
261                source_signature: "sig-1",
262                document_json: &part_document(),
263            })
264            .expect("insert 1");
265        state
266            .insert_component(ComponentInsert::Existing { part_name: "cube" })
267            .expect("insert 2");
268        let mut params = state
269            .history
270            .feature_params(state.history.index_of("ACOMP2").unwrap())
271            .unwrap();
272        params["transform"]["translate"] = serde_json::json!(translate);
273        state
274            .update_feature_params("ACOMP2", &params.to_string())
275            .expect("move ACOMP2");
276        state
277    }
278
279    /// One headless egui frame of the window (the panel test idiom).
280    fn run_frame(
281        ctx: &egui::Context,
282        window: &mut InterferenceWindow,
283        state: &mut EngineState,
284        events: Vec<egui::Event>,
285    ) {
286        let raw = egui::RawInput {
287            screen_rect: Some(egui::Rect::from_min_size(
288                egui::pos2(0.0, 0.0),
289                egui::vec2(1280.0, 800.0),
290            )),
291            events,
292            ..Default::default()
293        };
294        let _ = ctx.run_ui(raw, |ui| {
295            let _ = ui; // the window draws at ctx level
296            window.show(ctx, state);
297        });
298    }
299
300    /// Left-click at `pos`: hover frame + press frame, then a release frame
301    /// (the assembly-structure `click_at` idiom).
302    fn click_at(
303        ctx: &egui::Context,
304        window: &mut InterferenceWindow,
305        state: &mut EngineState,
306        pos: egui::Pos2,
307    ) {
308        run_frame(
309            ctx,
310            window,
311            state,
312            vec![
313                egui::Event::PointerMoved(pos),
314                egui::Event::PointerButton {
315                    pos,
316                    button: egui::PointerButton::Primary,
317                    pressed: true,
318                    modifiers: egui::Modifiers::default(),
319                },
320            ],
321        );
322        run_frame(
323            ctx,
324            window,
325            state,
326            vec![egui::Event::PointerButton {
327                pos,
328                button: egui::PointerButton::Primary,
329                pressed: false,
330                modifiers: egui::Modifiers::default(),
331            }],
332        );
333    }
334
335    #[test]
336    fn labels_read_pair_volume_and_hidden_note() {
337        let pair = |hidden: (bool, bool)| InterferencePair {
338            a: "ACOMP1".into(),
339            b: "ACOMP3".into(),
340            volume: 12.4,
341            a_hidden: hidden.0,
342            b_hidden: hidden.1,
343        };
344        assert_eq!(
345            pair_label(&pair((false, false))),
346            "ACOMP1 \u{00d7} ACOMP3 \u{2014} 12.4 mm\u{00b3}"
347        );
348        assert_eq!(
349            pair_label(&pair((false, true))),
350            "ACOMP1 \u{00d7} ACOMP3 \u{2014} 12.4 mm\u{00b3} (hidden: ACOMP3)"
351        );
352        assert_eq!(
353            pair_label(&pair((true, true))),
354            "ACOMP1 \u{00d7} ACOMP3 \u{2014} 12.4 mm\u{00b3} (hidden: ACOMP1, ACOMP3)"
355        );
356    }
357
358    /// The toolbar entry point runs the ENGINE check and the published state
359    /// carries the interfering pair with its exact volume; the all-clear case
360    /// publishes an empty pair list over the same report shape.
361    #[test]
362    fn open_and_run_publishes_the_report() {
363        let mut state = two_cube_state([5.0, 0.0, 0.0]);
364        let mut window = InterferenceWindow::new();
365        assert_eq!(
366            serde_json::from_str::<Value>(&window.state_json()).unwrap()["report"],
367            Value::Null,
368            "no report before the first run"
369        );
370        window.open_and_run(&mut state);
371        let published: Value = serde_json::from_str(&window.state_json()).unwrap();
372        assert_eq!(published["open"], true);
373        let report = &published["report"];
374        assert_eq!(report["componentCount"], 2);
375        assert_eq!(report["pairTotal"], 1);
376        assert_eq!(report["pairs"][0]["a"], "ACOMP1");
377        assert_eq!(report["pairs"][0]["b"], "ACOMP2");
378        assert!((report["pairs"][0]["volume"].as_f64().unwrap() - 500.0).abs() < 1e-6);
379        assert_eq!(
380            report["pairs"][0]["label"],
381            "ACOMP1 \u{00d7} ACOMP2 \u{2014} 500 mm\u{00b3}"
382        );
383
384        // All-clear: separated cubes → empty pairs, pass state.
385        let mut state = two_cube_state([40.0, 0.0, 0.0]);
386        let mut window = InterferenceWindow::new();
387        window.open_and_run(&mut state);
388        let published: Value = serde_json::from_str(&window.state_json()).unwrap();
389        assert_eq!(published["report"]["pairs"], serde_json::json!([]));
390        assert_eq!(published["report"]["booleansRun"], 0, "prefiltered");
391    }
392
393    /// Clicking a pair row selects BOTH participants (emphasis over the union
394    /// of their member solids) through a real egui click.
395    #[test]
396    fn pair_row_click_selects_both_components() {
397        let ctx = egui::Context::default();
398        let mut state = two_cube_state([5.0, 0.0, 0.0]);
399        let mut window = InterferenceWindow::new();
400        window.open_and_run(&mut state);
401
402        // Two settle frames: the window's first-frame placement may still be
403        // constrained/sized; read the rects only once the layout is stable.
404        run_frame(&ctx, &mut window, &mut state, vec![]);
405        run_frame(&ctx, &mut window, &mut state, vec![]);
406        let row = *window
407            .hits
408            .get("interference:pair:ACOMP1xACOMP2")
409            .expect("pair row rect");
410        click_at(&ctx, &mut window, &mut state, row.center());
411        assert!(
412            state.emphasis.selected_solids.contains("ACOMP1:Part")
413                && state.emphasis.selected_solids.contains("ACOMP2:Part"),
414            "row click highlights BOTH participants"
415        );
416    }
417
418    /// The Re-run button reflects engine changes: hide a participant, re-run,
419    /// and the pair is flagged hidden in the published state.
420    #[test]
421    fn rerun_picks_up_hidden_participants() {
422        let ctx = egui::Context::default();
423        let mut state = two_cube_state([5.0, 0.0, 0.0]);
424        let mut window = InterferenceWindow::new();
425        window.open_and_run(&mut state);
426        assert!(state.scene.set_visible("ACOMP2:Part", false));
427
428        run_frame(&ctx, &mut window, &mut state, vec![]);
429        run_frame(&ctx, &mut window, &mut state, vec![]);
430        let rerun = *window.hits.get("interference:rerun").expect("rerun rect");
431        click_at(&ctx, &mut window, &mut state, rerun.center());
432        let published: Value = serde_json::from_str(&window.state_json()).unwrap();
433        let pair = &published["report"]["pairs"][0];
434        assert_eq!(pair["bHidden"], true, "hidden participant flagged: {pair}");
435        assert!(
436            pair["label"].as_str().unwrap().contains("(hidden: ACOMP2)"),
437            "{pair}"
438        );
439    }
440}