1use brep_render::assembly_status;
25use brep_render::engine_state::{EngineState, InterferencePair, InterferenceReport};
26use eframe::egui;
27use serde_json::Value;
28use std::collections::HashMap;
29
30fn 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
52fn 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#[derive(Default)]
62pub struct InterferenceWindow {
63 open: bool,
64 report: Option<InterferenceReport>,
65 hits: HashMap<String, egui::Rect>,
67}
68
69impl InterferenceWindow {
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn open_and_run(&mut self, state: &mut EngineState) {
76 self.report = Some(state.interference_check());
77 self.open = true;
78 }
79
80 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 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 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 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 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 #[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 #[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 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 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", ¶ms.to_string())
275 .expect("move ACOMP2");
276 state
277 }
278
279 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; window.show(ctx, state);
297 });
298 }
299
300 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 #[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 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 #[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 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 #[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}