fix: inspect.interference prunes by bounding-box sweep instead of refusing more than 200 bodies (#536)
Fixes #527. Bodies are paired by a sort-and-sweep over their bounding boxes, and the cap moves from 200 bodies to 20,000 overlapping pairs per call (reported as truncated / skipped_pairs).
This commit is contained in:
1 parent
88e9661457
commit
1a6ff0954e
2 files changed
+113
-40
No files matched your search
@@ -6,7 +6,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason",
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -523,55 +523,79 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.interference";
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
if bodies.len() > 200 {
|
||||
return Err(bad(cmd, "too many bodies (200 at most)"));
|
||||
}
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
|
||||
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
|
||||
for (i, a) in bodies.iter().enumerate() {
|
||||
for b in bodies.iter().skip(i + 1) {
|
||||
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
let apart = ba.max.x < bb.min.x
|
||||
|| bb.max.x < ba.min.x
|
||||
|| ba.max.y < bb.min.y
|
||||
|| bb.max.y < ba.min.y
|
||||
|| ba.max.z < bb.min.z
|
||||
|| bb.max.z < ba.min.z;
|
||||
if apart {
|
||||
for (i, j) in pairs {
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
};
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped}))
|
||||
}
|
||||
|
||||
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
|
||||
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
/// `limit` of them, plus how many more there were.
|
||||
fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
|
||||
let mut order: Vec<usize> = (0..boxes.len()).collect();
|
||||
order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) {
|
||||
(Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
});
|
||||
let (mut pairs, mut skipped) = (Vec::new(), 0);
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
let Some(a) = boxes.get(i) else { continue };
|
||||
for &j in order.iter().skip(k + 1) {
|
||||
let Some(b) = boxes.get(j) else { continue };
|
||||
// Sorted by min.x: once one starts past `a`, all later ones do.
|
||||
if b.min.x > a.max.x {
|
||||
break;
|
||||
}
|
||||
if b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z {
|
||||
continue;
|
||||
}
|
||||
if pairs.len() < limit {
|
||||
pairs.push((i.min(j), i.max(j)));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked}))
|
||||
pairs.sort_unstable();
|
||||
(pairs, skipped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -246,3 +246,52 @@ fn faces_mate_by_default() {
|
||||
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
|
||||
}
|
||||
|
||||
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
|
||||
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
|
||||
/// into the first: exactly that pair overlaps, by 0.5 mm³.
|
||||
#[test]
|
||||
fn interference_checks_designs_with_more_than_200_bodies() {
|
||||
let mut s = Session::default();
|
||||
for i in 0..201 {
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [2 * i, 0, 0], "operation": "new"}));
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0.5, 0, 0], "operation": "new", "body_name": "Clash"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 202);
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["checked"], 202, "{i}");
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
let hit = &i["interferences"][0];
|
||||
let mut pair = [hit["a"].as_str().unwrap_or(""), hit["b"].as_str().unwrap_or("")];
|
||||
pair.sort_unstable();
|
||||
assert_eq!(pair, ["Body1", "Clash"], "{i}");
|
||||
assert!((hit["volume_mm3"].as_f64().unwrap_or(0.0) - 0.5).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// The bounding-box sweep behind `inspect.interference` finds exactly the pairs a check of every
|
||||
/// pair finds (touching boxes count), and reports how many it left out past its limit.
|
||||
#[test]
|
||||
fn interference_box_sweep_matches_every_pair_check() {
|
||||
use solidcraft_geom::Aabb3;
|
||||
let cube = |x: f64, y: f64, z: f64, e: f64| Aabb3 { min: Vec3::new(x, y, z), max: Vec3::new(x + e, y + e, z + e) };
|
||||
// A deterministic scatter: overlapping, touching, and apart in only one axis.
|
||||
let boxes: Vec<Aabb3> = (0..60)
|
||||
.map(f64::from)
|
||||
.map(|i| cube((i * 7.3) % 20.0, (i * 3.1) % 9.0, (i * 5.7) % 4.0, 1.0 + (i % 3.0)))
|
||||
.chain([cube(30.0, 0.0, 0.0, 1.0), cube(31.0, 0.0, 0.0, 1.0)])
|
||||
.collect();
|
||||
let mut every = Vec::new();
|
||||
for (i, a) in boxes.iter().enumerate() {
|
||||
for (j, b) in boxes.iter().enumerate().skip(i + 1) {
|
||||
let apart = a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z;
|
||||
if !apart {
|
||||
every.push((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(every.contains(&(60, 61)), "touching boxes are a candidate pair");
|
||||
let (pairs, skipped) = super::overlapping_pairs(&boxes, usize::MAX);
|
||||
assert_eq!((pairs, skipped), (every.clone(), 0));
|
||||
let (pairs, skipped) = super::overlapping_pairs(&boxes, 10);
|
||||
assert_eq!((pairs.len(), skipped), (10, every.len() - 10));
|
||||
}
|
||||
Reference in new issue
Block a user