1use 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
35fn 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
57fn 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
78fn 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#[derive(Default)]
88pub struct InterferenceWindow {
89 open: bool,
90 report: Option<InterferenceReport>,
91 clearance: f64,
93 stale: bool,
96 hits: HashMap<String, egui::Rect>,
98}
99
100impl InterferenceWindow {
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn open_and_run(&mut self, state: &mut EngineState) {
107 self.run(state);
108 self.open = true;
109 }
110
111 fn run(&mut self, state: &mut EngineState) {
113 self.report = Some(state.interference_check_with(self.clearance));
114 self.stale = false;
115 }
116
117 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 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
126 self.hits.clear();
127 if !self.open {
128 return;
129 }
130 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 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 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 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 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 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 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 #[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 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 #[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
359pub 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];