use brep_render::assembly_status;
use brep_render::engine_state::{EngineState, InterferencePair, InterferenceReport};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
fn pair_label(pair: &InterferencePair) -> String {
let mut label = format!(
"{} \u{00d7} {} \u{2014} {} mm\u{00b3}",
pair.a,
pair.b,
super::info_windows::num(pair.volume)
);
let hidden: Vec<&str> = [
pair.a_hidden.then_some(pair.a.as_str()),
pair.b_hidden.then_some(pair.b.as_str()),
]
.into_iter()
.flatten()
.collect();
if !hidden.is_empty() {
label.push_str(&format!(" (hidden: {})", hidden.join(", ")));
}
label
}
fn status_color(status: &str) -> egui::Color32 {
let [r, g, b] = assembly_status::status_color_rgb(status);
egui::Color32::from_rgb(r, g, b)
}
#[derive(Default)]
pub struct InterferenceWindow {
open: bool,
report: Option<InterferenceReport>,
hits: HashMap<String, egui::Rect>,
}
impl InterferenceWindow {
pub fn new() -> Self {
Self::default()
}
pub fn open_and_run(&mut self, state: &mut EngineState) {
self.report = Some(state.interference_check());
self.open = true;
}
pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
self.hits.clear();
if !self.open {
return;
}
let mut open = true;
egui::Window::new("Interference")
.id(egui::Id::new("brep-interference-window"))
.open(&mut open)
.movable(true)
.resizable(true)
.default_size([360.0, 320.0])
.default_pos([860.0, 80.0])
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| self.body(ui, state));
});
self.open = open;
}
fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
ui.horizontal(|ui| {
let rerun = ui.button("Re-run");
self.hits.insert("interference:rerun".into(), rerun.rect);
if rerun.clicked() {
self.report = Some(state.interference_check());
}
if let Some(report) = &self.report {
ui.weak(format!(
"{} components \u{00b7} {} pairs \u{00b7} {} boolean{}",
report.component_count,
report.pair_total,
report.booleans_run,
if report.booleans_run == 1 { "" } else { "s" }
));
}
});
ui.separator();
let Some(report) = self.report.clone() else {
ui.weak("Run the check from the toolbar.");
return;
};
if report.component_count < 2 {
ui.weak("Needs at least two components.");
return;
}
if report.pairs.is_empty() {
ui.colored_label(
status_color("satisfied"),
format!(
"\u{2713} No interference \u{2014} {} pair{} checked",
report.pair_total,
if report.pair_total == 1 { "" } else { "s" }
),
);
} else {
for pair in &report.pairs {
let row = ui.selectable_label(
false,
egui::RichText::new(pair_label(pair)).color(status_color("error")),
);
self.hits
.insert(format!("interference:pair:{}x{}", pair.a, pair.b), row.rect);
if row.clicked() {
state.select_components(&[pair.a.clone(), pair.b.clone()]);
}
}
}
if !report.unverified.is_empty() {
ui.add_space(4.0);
ui.label("Not verified (boolean refused):");
for line in &report.unverified {
ui.colored_label(status_color("unsupported-selection"), line);
}
}
if !report.skipped.is_empty() {
ui.add_space(4.0);
ui.label("Not checked:");
for line in &report.skipped {
ui.weak(line);
}
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn state_json(&self) -> String {
let report = self.report.as_ref().map(|report| {
serde_json::json!({
"componentCount": report.component_count,
"pairTotal": report.pair_total,
"booleansRun": report.booleans_run,
"pairs": report
.pairs
.iter()
.map(|pair| {
serde_json::json!({
"a": pair.a,
"b": pair.b,
"volume": pair.volume,
"aHidden": pair.a_hidden,
"bHidden": pair.b_hidden,
"label": pair_label(pair),
})
})
.collect::<Vec<_>>(),
"skipped": report.skipped,
"unverified": report.unverified,
})
});
serde_json::json!({
"open": self.open,
"report": report.unwrap_or(Value::Null),
})
.to_string()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn hits_json(&self) -> String {
let mut map = serde_json::Map::new();
for (key, rect) in &self.hits {
map.insert(
key.clone(),
serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
);
}
Value::Object(map).to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use brep_render::engine_state::ComponentInsert;
fn part_document() -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Part",
"sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
})
.to_string()
}
fn two_cube_state(translate: [f64; 3]) -> EngineState {
brep_render::brep_kernel::clear_history_cache();
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "cube",
source_key: "cube",
source_signature: "sig-1",
document_json: &part_document(),
})
.expect("insert 1");
state
.insert_component(ComponentInsert::Existing { part_name: "cube" })
.expect("insert 2");
let mut params = state
.history
.feature_params(state.history.index_of("ACOMP2").unwrap())
.unwrap();
params["transform"]["translate"] = serde_json::json!(translate);
state
.update_feature_params("ACOMP2", ¶ms.to_string())
.expect("move ACOMP2");
state
}
fn run_frame(
ctx: &egui::Context,
window: &mut InterferenceWindow,
state: &mut EngineState,
events: Vec<egui::Event>,
) {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(1280.0, 800.0),
)),
events,
..Default::default()
};
let _ = ctx.run_ui(raw, |ui| {
let _ = ui; window.show(ctx, state);
});
}
fn click_at(
ctx: &egui::Context,
window: &mut InterferenceWindow,
state: &mut EngineState,
pos: egui::Pos2,
) {
run_frame(
ctx,
window,
state,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
run_frame(
ctx,
window,
state,
vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
);
}
#[test]
fn labels_read_pair_volume_and_hidden_note() {
let pair = |hidden: (bool, bool)| InterferencePair {
a: "ACOMP1".into(),
b: "ACOMP3".into(),
volume: 12.4,
a_hidden: hidden.0,
b_hidden: hidden.1,
};
assert_eq!(
pair_label(&pair((false, false))),
"ACOMP1 \u{00d7} ACOMP3 \u{2014} 12.4 mm\u{00b3}"
);
assert_eq!(
pair_label(&pair((false, true))),
"ACOMP1 \u{00d7} ACOMP3 \u{2014} 12.4 mm\u{00b3} (hidden: ACOMP3)"
);
assert_eq!(
pair_label(&pair((true, true))),
"ACOMP1 \u{00d7} ACOMP3 \u{2014} 12.4 mm\u{00b3} (hidden: ACOMP1, ACOMP3)"
);
}
#[test]
fn open_and_run_publishes_the_report() {
let mut state = two_cube_state([5.0, 0.0, 0.0]);
let mut window = InterferenceWindow::new();
assert_eq!(
serde_json::from_str::<Value>(&window.state_json()).unwrap()["report"],
Value::Null,
"no report before the first run"
);
window.open_and_run(&mut state);
let published: Value = serde_json::from_str(&window.state_json()).unwrap();
assert_eq!(published["open"], true);
let report = &published["report"];
assert_eq!(report["componentCount"], 2);
assert_eq!(report["pairTotal"], 1);
assert_eq!(report["pairs"][0]["a"], "ACOMP1");
assert_eq!(report["pairs"][0]["b"], "ACOMP2");
assert!((report["pairs"][0]["volume"].as_f64().unwrap() - 500.0).abs() < 1e-6);
assert_eq!(
report["pairs"][0]["label"],
"ACOMP1 \u{00d7} ACOMP2 \u{2014} 500 mm\u{00b3}"
);
let mut state = two_cube_state([40.0, 0.0, 0.0]);
let mut window = InterferenceWindow::new();
window.open_and_run(&mut state);
let published: Value = serde_json::from_str(&window.state_json()).unwrap();
assert_eq!(published["report"]["pairs"], serde_json::json!([]));
assert_eq!(published["report"]["booleansRun"], 0, "prefiltered");
}
#[test]
fn pair_row_click_selects_both_components() {
let ctx = egui::Context::default();
let mut state = two_cube_state([5.0, 0.0, 0.0]);
let mut window = InterferenceWindow::new();
window.open_and_run(&mut state);
run_frame(&ctx, &mut window, &mut state, vec![]);
run_frame(&ctx, &mut window, &mut state, vec![]);
let row = *window
.hits
.get("interference:pair:ACOMP1xACOMP2")
.expect("pair row rect");
click_at(&ctx, &mut window, &mut state, row.center());
assert!(
state.emphasis.selected_solids.contains("ACOMP1:Part")
&& state.emphasis.selected_solids.contains("ACOMP2:Part"),
"row click highlights BOTH participants"
);
}
#[test]
fn rerun_picks_up_hidden_participants() {
let ctx = egui::Context::default();
let mut state = two_cube_state([5.0, 0.0, 0.0]);
let mut window = InterferenceWindow::new();
window.open_and_run(&mut state);
assert!(state.scene.set_visible("ACOMP2:Part", false));
run_frame(&ctx, &mut window, &mut state, vec![]);
run_frame(&ctx, &mut window, &mut state, vec![]);
let rerun = *window.hits.get("interference:rerun").expect("rerun rect");
click_at(&ctx, &mut window, &mut state, rerun.center());
let published: Value = serde_json::from_str(&window.state_json()).unwrap();
let pair = &published["report"]["pairs"][0];
assert_eq!(pair["bHidden"], true, "hidden participant flagged: {pair}");
assert!(
pair["label"].as_str().unwrap().contains("(hidden: ACOMP2)"),
"{pair}"
);
}
}