use super::*;
use crate::camera::Aabb;
const MAX_BOOLEAN_PAIRS: usize = 64;
const VOLUME_EPSILON: f64 = 1e-6;
#[derive(Debug, Clone, PartialEq)]
pub struct InterferencePair {
pub a: String,
pub b: String,
pub volume: f64,
pub a_hidden: bool,
pub b_hidden: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct InterferenceReport {
pub component_count: usize,
pub pair_total: usize,
pub booleans_run: usize,
pub pairs: Vec<InterferencePair>,
pub skipped: Vec<String>,
pub unverified: Vec<String>,
}
#[cfg(test)]
thread_local! {
static BOOLEAN_LANE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn note_boolean_call() {
#[cfg(test)]
BOOLEAN_LANE_CALLS.with(|calls| calls.set(calls.get() + 1));
}
#[cfg(test)]
pub(crate) fn boolean_lane_calls() -> usize {
BOOLEAN_LANE_CALLS.with(|calls| calls.get())
}
#[cfg(test)]
pub(crate) fn reset_boolean_lane_calls() {
BOOLEAN_LANE_CALLS.with(|calls| calls.set(0));
}
fn intersect_volume(a: u32, b: u32) -> Result<f64, String> {
note_boolean_call();
let result = brep_kernel::boolean_handle_native(
a,
b,
brep_kernel::BooleanOperation::Intersect,
&brep_kernel::BooleanOptions::default(),
)?;
let volume = brep_kernel::mass_properties_handle_native(result, 1.0).map(|p| p.volume);
brep_kernel::free_solid(result);
volume
}
pub(crate) struct PlanComponent {
pub id: String,
pub bbox: Aabb,
pub hidden: bool,
}
pub(crate) struct PairPlan {
pub boolean_pairs: Vec<(usize, usize)>,
pub skipped: Vec<String>,
pub pair_total: usize,
}
fn inflated(bbox: &Aabb) -> Aabb {
if bbox.is_empty() {
return *bbox;
}
let size = bbox.size();
let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
let margin = (diagonal * 0.01).max(1e-6);
let mut out = *bbox;
for axis in 0..3 {
out.min[axis] -= margin;
out.max[axis] += margin;
}
out
}
fn overlaps(a: &Aabb, b: &Aabb) -> bool {
if a.is_empty() || b.is_empty() {
return false;
}
(0..3).all(|axis| a.min[axis] <= b.max[axis] && b.min[axis] <= a.max[axis])
}
pub(crate) fn plan_pairs(components: &[PlanComponent], cap: usize) -> PairPlan {
let mut plan = PairPlan {
boolean_pairs: Vec::new(),
skipped: Vec::new(),
pair_total: components.len().saturating_sub(1) * components.len() / 2,
};
for component in components {
if component.bbox.is_empty() {
plan.skipped
.push(format!("{} — no resident geometry, not checked", component.id));
}
}
let boxes: Vec<Aabb> = components.iter().map(|c| inflated(&c.bbox)).collect();
for i in 0..components.len() {
for j in (i + 1)..components.len() {
if !overlaps(&boxes[i], &boxes[j]) {
continue; }
if plan.boolean_pairs.len() >= cap {
plan.skipped.push(format!(
"{} × {} — skipped (boolean budget of {cap} pairs reached)",
components[i].id, components[j].id
));
continue;
}
plan.boolean_pairs.push((i, j));
}
}
plan
}
impl EngineState {
pub fn interference_check(&mut self) -> InterferenceReport {
self.ensure_assembly_synced();
let ids = self.component_ids();
let mut members: Vec<Vec<String>> = Vec::with_capacity(ids.len());
let mut plan_input: Vec<PlanComponent> = Vec::with_capacity(ids.len());
for id in &ids {
let info = self.component_info(id);
let solids = info.map(|info| info.members).unwrap_or_default();
let mut bbox = Aabb::empty();
let mut hidden = false;
for name in &solids {
if let Some(solid) = self.scene.solid(name) {
bbox.union(&solid.bbox);
hidden |= !solid.visible;
}
}
plan_input.push(PlanComponent {
id: id.clone(),
bbox,
hidden,
});
members.push(solids);
}
let plan = plan_pairs(&plan_input, MAX_BOOLEAN_PAIRS);
let mut report = InterferenceReport {
component_count: ids.len(),
pair_total: plan.pair_total,
booleans_run: 0,
pairs: Vec::new(),
skipped: plan.skipped,
unverified: Vec::new(),
};
if plan.boolean_pairs.is_empty() {
return report;
}
let handles = self.resident_solid_handles();
for (i, j) in plan.boolean_pairs {
report.booleans_run += 1;
let mut volume = 0.0;
let mut refusal: Option<String> = None;
for solid_a in &members[i] {
for solid_b in &members[j] {
let (Some(&ha), Some(&hb)) = (handles.get(solid_a), handles.get(solid_b))
else {
continue; };
let (Some(a), Some(b)) =
(self.scene.solid(solid_a), self.scene.solid(solid_b))
else {
continue;
};
if !overlaps(&inflated(&a.bbox), &inflated(&b.bbox)) {
continue;
}
match intersect_volume(ha, hb) {
Ok(v) => volume += v,
Err(error) => {
refusal.get_or_insert(error);
}
}
}
}
let (a, b) = (&plan_input[i], &plan_input[j]);
if let Some(error) = refusal {
report
.unverified
.push(format!("{} × {} — boolean refused: {error}", a.id, b.id));
}
if volume > VOLUME_EPSILON {
report.pairs.push(InterferencePair {
a: a.id.clone(),
b: b.id.clone(),
volume,
a_hidden: a.hidden,
b_hidden: b.hidden,
});
}
}
report
.pairs
.sort_by(|x, y| y.volume.total_cmp(&x.volume));
report
}
}
#[cfg(test)]
mod tests {
use super::super::components::component_fixtures::two_instance_assembly_json;
use super::*;
fn two_cube_engine(second_translate: [f64; 3]) -> EngineState {
brep_kernel::clear_history_cache();
reset_boolean_lane_calls();
let mut doc: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
doc["features"][1]["inputParams"]["transform"]["translate"] =
serde_json::json!(second_translate);
let mut engine = EngineState::new();
engine.set_history_json(&doc.to_string()).expect("fixture loads");
engine
}
#[test]
fn overlapping_cubes_report_the_pair_with_the_known_volume() {
let mut engine = two_cube_engine([5.0, 0.0, 0.0]);
let report = engine.interference_check();
assert_eq!(report.component_count, 2);
assert_eq!(report.pair_total, 1);
assert_eq!(report.booleans_run, 1);
assert_eq!(report.pairs.len(), 1, "{report:?}");
let pair = &report.pairs[0];
assert_eq!((pair.a.as_str(), pair.b.as_str()), ("ACOMP1", "ACOMP2"));
assert!(
(pair.volume - 500.0).abs() < 1e-6,
"5×10×10 overlap: {}",
pair.volume
);
assert!(!pair.a_hidden && !pair.b_hidden);
assert!(report.skipped.is_empty() && report.unverified.is_empty());
}
#[test]
fn separated_cubes_are_all_clear_without_any_boolean() {
let mut engine = two_cube_engine([20.0, 0.0, 0.0]);
let report = engine.interference_check();
assert_eq!(report.pair_total, 1);
assert!(report.pairs.is_empty(), "{report:?}");
assert_eq!(report.booleans_run, 0, "prefilter proves the pair clear");
assert_eq!(boolean_lane_calls(), 0, "the boolean lane was not invoked");
assert!(report.skipped.is_empty() && report.unverified.is_empty());
}
#[test]
fn three_components_check_three_pairs() {
brep_kernel::clear_history_cache();
reset_boolean_lane_calls();
let mut doc: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
doc["features"][1]["inputParams"]["transform"]["translate"] =
serde_json::json!([5.0, 0.0, 0.0]);
let mut third = doc["features"][1].clone();
third["inputParams"]["id"] = serde_json::json!("ACOMP3");
third["inputParams"]["transform"]["translate"] = serde_json::json!([2.0, 0.0, 0.0]);
doc["features"].as_array_mut().unwrap().push(third);
let mut engine = EngineState::new();
engine.set_history_json(&doc.to_string()).expect("fixture loads");
let report = engine.interference_check();
assert_eq!(report.component_count, 3);
assert_eq!(report.pair_total, 3, "N(N−1)/2");
assert_eq!(report.booleans_run, 3);
assert_eq!(report.pairs.len(), 3, "{report:?}");
let key = |p: &InterferencePair| (p.a.clone(), p.b.clone(), p.volume);
let got: Vec<_> = report.pairs.iter().map(key).collect();
assert_eq!(got[0].0, "ACOMP1");
assert_eq!(got[0].1, "ACOMP3");
assert!((got[0].2 - 800.0).abs() < 1e-6, "{got:?}");
assert_eq!(got[1].0, "ACOMP2");
assert_eq!(got[1].1, "ACOMP3");
assert!((got[1].2 - 700.0).abs() < 1e-6, "{got:?}");
assert_eq!(got[2].0, "ACOMP1");
assert_eq!(got[2].1, "ACOMP2");
assert!((got[2].2 - 500.0).abs() < 1e-6, "{got:?}");
}
#[test]
fn hidden_participant_still_checked_and_flagged() {
let mut engine = two_cube_engine([5.0, 0.0, 0.0]);
assert!(engine.scene.set_visible("ACOMP2:Part", false));
let report = engine.interference_check();
assert_eq!(report.pairs.len(), 1, "hidden ACOMP2 still participates");
let pair = &report.pairs[0];
assert!((pair.volume - 500.0).abs() < 1e-6);
assert!(!pair.a_hidden, "ACOMP1 is visible");
assert!(pair.b_hidden, "ACOMP2 is hidden and flagged");
}
#[test]
fn exact_face_contact_is_never_false_interference() {
let mut engine = two_cube_engine([10.0, 0.0, 0.0]);
let report = engine.interference_check();
assert_eq!(report.booleans_run, 1, "inflated boxes overlap at contact");
assert!(
report.pairs.is_empty(),
"face contact must not read as interference: {report:?}"
);
assert!(report.skipped.is_empty());
assert_eq!(report.unverified.len(), 1, "{report:?}");
assert!(
report.unverified[0].starts_with("ACOMP1 × ACOMP2 — boolean refused:"),
"{:?}",
report.unverified
);
}
#[test]
fn planner_prefilters_caps_and_notes() {
let cube = |origin: [f64; 3]| Aabb {
min: origin,
max: [origin[0] + 1.0, origin[1] + 1.0, origin[2] + 1.0],
};
let component = |id: &str, bbox: Aabb| PlanComponent {
id: id.to_string(),
bbox,
hidden: false,
};
let plan = plan_pairs(
&[
component("ACOMP1", cube([0.0; 3])),
component("ACOMP2", cube([10.0, 0.0, 0.0])),
],
8,
);
assert_eq!(plan.pair_total, 1);
assert!(plan.boolean_pairs.is_empty());
assert!(plan.skipped.is_empty());
let coincident: Vec<PlanComponent> = (1..=4)
.map(|n| component(&format!("ACOMP{n}"), cube([0.0; 3])))
.collect();
let plan = plan_pairs(&coincident, 2);
assert_eq!(plan.pair_total, 6);
assert_eq!(plan.boolean_pairs, vec![(0, 1), (0, 2)]);
assert_eq!(plan.skipped.len(), 4);
assert!(
plan.skipped
.iter()
.all(|note| note.contains("boolean budget of 2")),
"{:?}",
plan.skipped
);
let plan = plan_pairs(
&[
component("ACOMP1", cube([0.0; 3])),
component("ACOMP2", Aabb::empty()),
],
8,
);
assert!(plan.boolean_pairs.is_empty());
assert_eq!(plan.skipped.len(), 1);
assert!(plan.skipped[0].contains("no resident geometry"));
}
}