Clearance report: overlaps, near pairs and nearest bodies (#1900)
Thanks @NOWN-DEV.
This commit is contained in:
1 parent
fd073620ae
commit
186555dbe9
18 files changed
+1428
-17
No files matched your search
@@ -19,3 +19,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Clearance (`inspect.clearance`): in one call, which bodies overlap and by how much, which
|
||||
//! come closer than a clearance and where, and each body's nearest neighbours. Pairs are pruned
|
||||
//! by bounding boxes grown by the clearance, then by the bodies' meshes (which lie within their
|
||||
//! chord tolerance of the faces); distances are measured on the exact faces and edges
|
||||
//! ([`solidcraft_kernel::body_distance`]) and overlaps by intersecting the bodies. The pairs
|
||||
//! that are left run in parallel.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::mesh_distance::box_gap;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
use solidcraft_kernel::{BodyDistance, DistanceShape};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).noundo().params(
|
||||
"bodies?: [names] (default: every solid body, in world placement, but those in `against`); against?: [names] (only pairs with one body in `bodies` and the other here); \
|
||||
min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \
|
||||
nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \
|
||||
\"component\" (pairs of bodies inside one placed component, its subcomponents included) | null; tolerance?: mm (default 1e-6: a gap \
|
||||
below it is contact, 0) → overlaps: [{a, b, volume_mm3, area_mm2, bbox, center}]; near: [{a, b, distance_mm, from, to, overlap_mm3?: \
|
||||
a contact's shared volume below min_volume, exact?: false when measured on the meshes}], nearest by body (the same for pairs that overlap: 0); \
|
||||
nearest: {body: [{body, distance_mm}]}; unchecked: [{a, b, reason}] (pairs whose overlap could not be worked out); pairs_checked; \
|
||||
pairs_pruned (ruled out by their boxes); ms. Distances are measured on the exact faces and edges, the meshes only rule pairs out",
|
||||
)];
|
||||
|
||||
/// Most bodies one call takes.
|
||||
const MAX_BODIES: usize = 20_000;
|
||||
/// Most pairs whose grown boxes meet that one call measures.
|
||||
const MAX_PAIRS: usize = 200_000;
|
||||
/// Most neighbours a body's `nearest` list holds.
|
||||
const MAX_NEAREST: usize = 1000;
|
||||
/// Largest clearance (mm).
|
||||
const MAX_CLEARANCE: f64 = 1e6;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SkipWithin {
|
||||
Nothing,
|
||||
Occurrence,
|
||||
Component,
|
||||
}
|
||||
|
||||
/// What one pair came to.
|
||||
#[derive(Default)]
|
||||
struct PairResult {
|
||||
/// Shared volume, area, box and centre.
|
||||
overlap: Option<(f64, f64, Aabb3, solidcraft_geom::Vec3)>,
|
||||
distance: Option<BodyDistance>,
|
||||
unchecked: Option<String>,
|
||||
}
|
||||
|
||||
fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.clearance";
|
||||
let started = web_time::Instant::now();
|
||||
let param = |k: &str, default: f64, max: f64| -> Result<f64> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(_) => match num(p, k) {
|
||||
Some(v) if (0.0..=max).contains(&v) => Ok(v),
|
||||
_ => Err(bad(cmd, format!("`{k}` must be a number from 0 to {max}"))),
|
||||
},
|
||||
}
|
||||
};
|
||||
let min_volume = param("min_volume", 0.01, f64::MAX)?;
|
||||
let gap = param("clearance", 0.0, MAX_CLEARANCE)?;
|
||||
let tolerance = param("tolerance", 1e-6, 1.0)?;
|
||||
let nearest = param("nearest", 0.0, MAX_NEAREST as f64)?.floor() as usize;
|
||||
let skip = match p.get("skip_within") {
|
||||
None | Some(Value::Null) => SkipWithin::Nothing,
|
||||
Some(_) => match str_(p, "skip_within") {
|
||||
Some("occurrence") => SkipWithin::Occurrence,
|
||||
Some("component") => SkipWithin::Component,
|
||||
_ => return Err(bad(cmd, "`skip_within` must be \"occurrence\", \"component\" or null")),
|
||||
},
|
||||
};
|
||||
|
||||
let st = s.world_state();
|
||||
let solid: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
|
||||
let named = |key: &str| -> Result<Vec<usize>> {
|
||||
let names = string_list(p, key);
|
||||
let mut out = Vec::new();
|
||||
for n in &names {
|
||||
let i = solid.iter().position(|b| &b.name == n).ok_or_else(|| bad(cmd, format!("no solid body `{n}`")))?;
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let against = if p.get("against").is_none_or(Value::is_null) { None } else { Some(named("against")?) };
|
||||
// Without `bodies`: every body (every body not in `against`, when it is given).
|
||||
let subjects = if p.get("bodies").is_none_or(Value::is_null) {
|
||||
(0..solid.len()).filter(|i| against.as_ref().is_none_or(|a| !a.contains(i))).collect()
|
||||
} else {
|
||||
named("bodies")?
|
||||
};
|
||||
if solid.len() > MAX_BODIES {
|
||||
return Err(bad(cmd, format!("at most {MAX_BODIES} bodies")));
|
||||
}
|
||||
let n = solid.len();
|
||||
let mut in_subjects = vec![false; n];
|
||||
for &i in &subjects {
|
||||
if let Some(x) = in_subjects.get_mut(i) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
let mut in_against = vec![false; n];
|
||||
for &i in against.iter().flatten() {
|
||||
if let Some(x) = in_against.get_mut(i) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
let used: Vec<bool> = (0..n).map(|i| in_subjects.get(i).copied().unwrap_or(false) || in_against.get(i).copied().unwrap_or(false)).collect();
|
||||
|
||||
// The occurrence path of each body, for `skip_within`.
|
||||
let paths: HashMap<String, Vec<u64>> = s.doc.body_frames(&s.model.state()).into_iter().map(|(name, _, path, _)| (name, path)).collect();
|
||||
let path_by_body: Vec<&[u64]> = solid.iter().map(|b| paths.get(&b.name).map(Vec::as_slice).unwrap_or(&[])).collect();
|
||||
let path_of = |i: usize| path_by_body.get(i).copied().unwrap_or(&[]);
|
||||
let flag = |v: &[bool], i: usize| v.get(i).copied().unwrap_or(false);
|
||||
let allowed = |i: usize, j: usize| -> bool {
|
||||
if i == j {
|
||||
return false;
|
||||
}
|
||||
let paired = match &against {
|
||||
None => flag(&in_subjects, i) && flag(&in_subjects, j),
|
||||
Some(_) => (flag(&in_subjects, i) && flag(&in_against, j)) || (flag(&in_subjects, j) && flag(&in_against, i)),
|
||||
};
|
||||
if !paired {
|
||||
return false;
|
||||
}
|
||||
let (a, b) = (path_of(i), path_of(j));
|
||||
match skip {
|
||||
SkipWithin::Nothing => true,
|
||||
SkipWithin::Occurrence => a.is_empty() || a != b,
|
||||
SkipWithin::Component => a.is_empty() || a.first() != b.first(),
|
||||
}
|
||||
};
|
||||
|
||||
// Meshes (made once, in parallel) and the distance shapes over them.
|
||||
let idx: Vec<usize> = (0..n).filter(|&i| flag(&used, i)).collect();
|
||||
let meshes: Vec<Option<Arc<Mesh>>> = {
|
||||
let made = solidcraft_kernel::map_with_local_pools(&idx, usize::MAX, |&i| Ok(solid.get(i).map(|b| b.mesh())));
|
||||
let mut out = vec![None; n];
|
||||
for (&i, m) in idx.iter().zip(made) {
|
||||
if let (Some(slot), Ok(m)) = (out.get_mut(i), m) {
|
||||
*slot = m;
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let shapes: Vec<Option<DistanceShape>> = (0..n)
|
||||
.map(|i| match (solid.get(i), meshes.get(i).and_then(Option::as_ref)) {
|
||||
(Some(b), Some(m)) if flag(&used, i) => Some(DistanceShape::new(&b.body, m, b.display_tol())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let shape = |i: usize| shapes.get(i).and_then(Option::as_ref);
|
||||
let boxes: Vec<Aabb3> = (0..n).map(|i| shape(i).map_or(Aabb3::EMPTY, DistanceShape::bounds)).collect();
|
||||
|
||||
// Pairs whose boxes, grown by half the clearance each, meet.
|
||||
let half = solidcraft_geom::Vec3::new(1.0, 1.0, 1.0) * (gap / 2.0);
|
||||
let grown: Vec<Aabb3> = boxes.iter().map(|b| if b.is_empty() { *b } else { Aabb3 { min: b.min - half, max: b.max + half } }).collect();
|
||||
let (close, truncated) = super::joints::overlapping_pairs(&grown, MAX_PAIRS);
|
||||
let close: Vec<(usize, usize)> = close.into_iter().filter(|&(i, j)| allowed(i, j) && shape(i).is_some() && shape(j).is_some()).collect();
|
||||
let mut total = 0usize;
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
if allowed(i, j) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let results = solidcraft_kernel::map_with_local_pools(&close, usize::MAX, |&(i, j)| {
|
||||
let (Some(a), Some(b)) = (shape(i), shape(j)) else { return Ok(PairResult::default()) };
|
||||
let (Some(ba), Some(bb)) = (solid.get(i), solid.get(j)) else { return Ok(PairResult::default()) };
|
||||
Ok(measure_pair(ba, a, bb, b, gap, min_volume, tolerance))
|
||||
});
|
||||
|
||||
let mut known: HashMap<(usize, usize), f64> = HashMap::new();
|
||||
let (mut overlaps, mut near, mut unchecked) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str());
|
||||
for (&(i, j), r) in close.iter().zip(results) {
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = r.unchecked {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason}));
|
||||
continue;
|
||||
}
|
||||
if let Some((volume, area, bbox, center)) = r.overlap
|
||||
&& volume >= min_volume
|
||||
{
|
||||
overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area,
|
||||
"bbox": {"min": bbox.min, "max": bbox.max}, "center": center}));
|
||||
known.insert((i, j), 0.0);
|
||||
continue;
|
||||
}
|
||||
let Some(d) = r.distance else { continue };
|
||||
known.insert((i, j), d.distance);
|
||||
if d.distance < gap {
|
||||
let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to});
|
||||
if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) {
|
||||
e["overlap_mm3"] = json!(v);
|
||||
}
|
||||
if !d.exact {
|
||||
e["exact"] = json!(false);
|
||||
}
|
||||
near.push(e);
|
||||
}
|
||||
}
|
||||
near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0)));
|
||||
|
||||
let mut nearest_out = serde_json::Map::new();
|
||||
if nearest > 0 {
|
||||
let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| {
|
||||
solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| {
|
||||
let (Some(a), Some(b)) = (shape(i), shape(j)) else { return Ok(None) };
|
||||
Ok(solidcraft_kernel::body_distance(a, b, f64::INFINITY)?.map(|d| if d.distance < tolerance { 0.0 } else { d.distance }))
|
||||
})
|
||||
});
|
||||
for (i, list) in lists {
|
||||
let rows: Vec<Value> = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect();
|
||||
nearest_out.insert(name(i).to_string(), Value::Array(rows));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"overlaps": overlaps,
|
||||
"near": near,
|
||||
"nearest": nearest_out,
|
||||
"unchecked": unchecked,
|
||||
"bodies": subjects.len(),
|
||||
"pairs_checked": close.len(),
|
||||
"pairs_pruned": total.saturating_sub(close.len()),
|
||||
"truncated": truncated > 0,
|
||||
"ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
}))
|
||||
}
|
||||
|
||||
/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance
|
||||
/// when it may be closer than `gap`.
|
||||
fn measure_pair(
|
||||
ba: &solidcraft_doc::ModelBody,
|
||||
a: &DistanceShape,
|
||||
bb: &solidcraft_doc::ModelBody,
|
||||
b: &DistanceShape,
|
||||
gap: f64,
|
||||
min_volume: f64,
|
||||
tolerance: f64,
|
||||
) -> PairResult {
|
||||
let mut out = PairResult::default();
|
||||
let slack = 2.0 * (a.chord() + b.chord());
|
||||
let mesh = a.mesh_closest(b, gap + slack);
|
||||
let holds = |outer: &solidcraft_doc::ModelBody, inner: &solidcraft_doc::ModelBody| {
|
||||
let (o, i) = (outer.mesh(), inner.mesh());
|
||||
let (ob, ib) = (o.bounds(), i.bounds());
|
||||
let nested = ob.min.x <= ib.min.x
|
||||
&& ob.min.y <= ib.min.y
|
||||
&& ob.min.z <= ib.min.z
|
||||
&& ib.max.x <= ob.max.x
|
||||
&& ib.max.y <= ob.max.y
|
||||
&& ib.max.z <= ob.max.z;
|
||||
nested && i.positions.first().is_some_and(|p| o.contains(*p))
|
||||
};
|
||||
let touching = mesh.is_some_and(|m| m.distance <= slack);
|
||||
if touching || holds(ba, bb) || holds(bb, ba) {
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let region = shared_region(a, b);
|
||||
let (ca, cb) = (clipped(&ba.body, region), clipped(&bb.body, region));
|
||||
let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| {
|
||||
solidcraft_kernel::boolean(ca.as_ref().unwrap_or(&ba.body), cb.as_ref().unwrap_or(&bb.body), solidcraft_kernel::BoolOp::Intersect)
|
||||
});
|
||||
match shared {
|
||||
Err(e) => {
|
||||
out.unchecked = Some(e.to_string());
|
||||
return out;
|
||||
}
|
||||
Ok(shared) => {
|
||||
if let Some(reason) = report.unverified() {
|
||||
out.unchecked = Some(reason);
|
||||
return out;
|
||||
}
|
||||
if let Some(o) = shared {
|
||||
match measure_near_origin(&o, region.center()) {
|
||||
Ok(m) if !plausible(m.volume, m.centroid, m.bbox) => {
|
||||
out.unchecked = Some(format!(
|
||||
"the shared part came out malformed ({:.4} mm³ with its centre outside its own box), so it is not measured",
|
||||
m.volume
|
||||
));
|
||||
return out;
|
||||
}
|
||||
Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)),
|
||||
Err(e) => {
|
||||
out.unchecked = Some(e.to_string());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.overlap.is_some_and(|o| o.0 >= min_volume) {
|
||||
return out;
|
||||
}
|
||||
if out.overlap.is_some_and(|o| o.0 > 0.0) {
|
||||
// Contact with a sliver of shared volume.
|
||||
let at = out.overlap.map(|o| o.3).unwrap_or_default();
|
||||
out.distance = Some(BodyDistance { distance: 0.0, from: at, to: at, exact: true });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
if mesh.is_none() {
|
||||
return out;
|
||||
}
|
||||
match solidcraft_kernel::body_distance(a, b, gap) {
|
||||
Ok(Some(mut d)) => {
|
||||
if d.distance < tolerance {
|
||||
d.distance = 0.0;
|
||||
}
|
||||
out.distance = Some(d);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => out.unchecked = Some(e.to_string()),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The box where two bodies may share volume: where their boxes meet, grown by a margin so that
|
||||
/// a cut there stays clear of both bodies' faces.
|
||||
fn shared_region(a: &DistanceShape, b: &DistanceShape) -> Aabb3 {
|
||||
let (x, y) = (a.bounds(), b.bounds());
|
||||
let min = x.min.max(y.min);
|
||||
let max = x.max.min(y.max);
|
||||
if x.is_empty() || y.is_empty() || !(min.x <= max.x && min.y <= max.y && min.z <= max.z) {
|
||||
return Aabb3::EMPTY;
|
||||
}
|
||||
let grow = 1.0 + 0.05 * min.dist(max);
|
||||
let g = solidcraft_geom::Vec3::new(grow, grow, grow);
|
||||
Aabb3 { min: min - g, max: max + g }
|
||||
}
|
||||
|
||||
/// `body` cut down to `region` when the body is much larger than it. The boolean works to a
|
||||
/// tolerance that grows with the bodies' size: a 0.01 mm overlap against a plate 1.7 m long
|
||||
/// comes out a quarter too small, and thinner ones not at all. Cut to the region first, the
|
||||
/// body is small and its faces there are the same faces. `None`: use the whole body.
|
||||
fn clipped(body: &solidcraft_kernel::Body, region: Aabb3) -> Option<solidcraft_kernel::Body> {
|
||||
if region.is_empty() || body.size() <= 4.0 * region.diagonal() {
|
||||
return None;
|
||||
}
|
||||
let cut = solidcraft_kernel::box_solid(region.min, region.max).ok()?;
|
||||
let (r, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(body, &cut, solidcraft_kernel::BoolOp::Intersect));
|
||||
match r {
|
||||
Ok(Some(c)) if report.unverified().is_none() => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`solidcraft_kernel::measure`] of `body` moved by `-at` and back: far from the origin, the
|
||||
/// centre of a thin part loses its precision (its moments are large numbers that nearly cancel).
|
||||
fn measure_near_origin(body: &solidcraft_kernel::Body, at: solidcraft_geom::Vec3) -> solidcraft_kernel::Result<solidcraft_kernel::BodyMeasure> {
|
||||
let Ok(moved) = solidcraft_kernel::transform(body, -at, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::Z, 0.0) else {
|
||||
return solidcraft_kernel::measure(body);
|
||||
};
|
||||
let mut m = solidcraft_kernel::measure(&moved)?;
|
||||
m.centroid += at;
|
||||
m.bbox = Aabb3 { min: m.bbox.min + at, max: m.bbox.max + at };
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Is a measured shared part well formed? A part whose shell came out inside out or open can
|
||||
/// carry a volume with its centre outside its own box, or more volume than its box holds.
|
||||
fn plausible(volume: f64, centre: solidcraft_geom::Vec3, bbox: Aabb3) -> bool {
|
||||
let slack = 1e-6 * bbox.diagonal().max(1.0);
|
||||
let (lo, hi) = (bbox.min, bbox.max);
|
||||
let inside = [(centre.x, lo.x, hi.x), (centre.y, lo.y, hi.y), (centre.z, lo.z, hi.z)].iter().all(|&(c, l, h)| c >= l - slack && c <= h + slack);
|
||||
let s = bbox.size();
|
||||
volume.is_finite() && volume >= 0.0 && inside && volume <= s.x * s.y * s.z + slack
|
||||
}
|
||||
|
||||
/// Each subject's `count` nearest bodies, nearest first: pairs are measured in the order of their
|
||||
/// boxes' gaps (a lower bound of their distance), in rounds, until the boxes of the next ones
|
||||
/// are farther apart than the last neighbour kept. `known` holds distances already measured.
|
||||
fn nearest_lists(
|
||||
subjects: &[usize],
|
||||
n: usize,
|
||||
count: usize,
|
||||
boxes: &[Aabb3],
|
||||
allowed: &dyn Fn(usize, usize) -> bool,
|
||||
known: &mut HashMap<(usize, usize), f64>,
|
||||
measure: impl Fn(&[(usize, usize)]) -> Vec<solidcraft_kernel::Result<Option<f64>>>,
|
||||
) -> BTreeMap<usize, Vec<(usize, f64)>> {
|
||||
let key = |i: usize, j: usize| (i.min(j), i.max(j));
|
||||
let gap_of = |i: usize, j: usize| match (boxes.get(i), boxes.get(j)) {
|
||||
(Some(a), Some(b)) => box_gap(a, b),
|
||||
_ => f64::INFINITY,
|
||||
};
|
||||
// Each subject's candidates, by box gap.
|
||||
let mut order: BTreeMap<usize, (Vec<(f64, usize)>, usize)> = BTreeMap::new();
|
||||
for &i in subjects {
|
||||
let mut c: Vec<(f64, usize)> = (0..n).filter(|&j| allowed(i, j)).map(|j| (gap_of(i, j), j)).filter(|(g, _)| g.is_finite()).collect();
|
||||
c.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1)));
|
||||
order.insert(i, (c, 0));
|
||||
}
|
||||
let mut failed: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
|
||||
let measured = |known: &HashMap<(usize, usize), f64>, i: usize, c: &[(f64, usize)]| -> Vec<(usize, f64)> {
|
||||
let mut l: Vec<(usize, f64)> = c.iter().filter_map(|&(_, j)| known.get(&key(i, j)).map(|d| (j, *d))).collect();
|
||||
l.sort_by(|x, y| x.1.total_cmp(&y.1).then(x.0.cmp(&y.0)));
|
||||
l
|
||||
};
|
||||
// Each round measures, for every subject not settled yet, the next `count` candidates.
|
||||
for _ in 0..n.max(1) {
|
||||
let mut todo: Vec<(usize, usize)> = Vec::new();
|
||||
for (&i, (c, next)) in order.iter_mut() {
|
||||
let have = measured(known, i, c);
|
||||
let mut added = 0;
|
||||
while let Some(&(g, j)) = c.get(*next) {
|
||||
let bound = if have.len() >= count { have.get(count - 1).map_or(f64::INFINITY, |x| x.1) } else { f64::INFINITY };
|
||||
if g > bound || added >= count {
|
||||
break;
|
||||
}
|
||||
*next += 1;
|
||||
let k = key(i, j);
|
||||
if !known.contains_key(&k) && !failed.contains(&k) && !todo.contains(&k) {
|
||||
todo.push(k);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if todo.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (k, r) in todo.iter().zip(measure(&todo)) {
|
||||
match r {
|
||||
Ok(Some(d)) => {
|
||||
known.insert(*k, d);
|
||||
}
|
||||
_ => {
|
||||
failed.insert(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
order.iter().map(|(&i, (c, _))| (i, measured(known, i, c).into_iter().take(count).collect())).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clearance_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,253 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn cube(s: &mut Session, name: &str, size: f64, corner: [f64; 3]) {
|
||||
run(s, "solid.box", json!({"length": size, "width": size, "height": size, "corner": corner, "operation": "new", "body_name": name}));
|
||||
}
|
||||
|
||||
fn f(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// The pair `{a, b}` of a list entry, sorted.
|
||||
fn pair(v: &Value) -> [String; 2] {
|
||||
let mut p = [v["a"].as_str().unwrap_or("").to_string(), v["b"].as_str().unwrap_or("").to_string()];
|
||||
p.sort();
|
||||
p
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes sharing a 1 mm corner cube overlap by 1 mm³; a third, far off, is pruned.
|
||||
#[test]
|
||||
fn an_overlap_of_one_cubic_millimetre_is_reported() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
|
||||
cube(&mut s, "B", 10.0, [9.0, 9.0, 9.0]);
|
||||
cube(&mut s, "Far", 10.0, [100.0, 0.0, 0.0]);
|
||||
let r = run(&mut s, "inspect.clearance", json!({}));
|
||||
let overlaps = r["overlaps"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(overlaps.len(), 1, "{r}");
|
||||
assert_eq!(pair(&overlaps[0]), ["A", "B"]);
|
||||
assert!((f(&overlaps[0]["volume_mm3"]) - 1.0).abs() < 1e-6, "{r}");
|
||||
// Three faces of 1 x 1 from each cube: 6 mm².
|
||||
assert!((f(&overlaps[0]["area_mm2"]) - 6.0).abs() < 1e-6, "{r}");
|
||||
assert!((f(&overlaps[0]["center"][0]) - 9.5).abs() < 1e-6, "{r}");
|
||||
assert_eq!(r["pairs_checked"], 1, "{r}");
|
||||
assert_eq!(r["pairs_pruned"], 2, "{r}");
|
||||
assert_eq!(r["unchecked"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
// An overlap under min_volume is contact, not an overlap: with a clearance it is near, at 0.
|
||||
let r = run(&mut s, "inspect.clearance", json!({"min_volume": 2, "clearance": 0.1}));
|
||||
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert_eq!(f(&r["near"][0]["distance_mm"]), 0.0, "{r}");
|
||||
assert!((f(&r["near"][0]["overlap_mm3"]) - 1.0).abs() < 1e-6, "{r}");
|
||||
}
|
||||
|
||||
/// Two cubes 0.5 mm apart are near with a clearance of 1 mm; 2 mm apart they are not.
|
||||
#[test]
|
||||
fn pairs_closer_than_the_clearance_are_near() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
|
||||
cube(&mut s, "B", 10.0, [10.5, 2.0, 3.0]);
|
||||
cube(&mut s, "C", 10.0, [-12.0, 0.0, 0.0]);
|
||||
let r = run(&mut s, "inspect.clearance", json!({"clearance": 1}));
|
||||
let near = r["near"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(near.len(), 1, "{r}");
|
||||
assert_eq!(pair(&near[0]), ["A", "B"]);
|
||||
assert!((f(&near[0]["distance_mm"]) - 0.5).abs() < 1e-9, "{r}");
|
||||
assert!(near[0].get("exact").is_none(), "measured on the exact faces: {r}");
|
||||
// The points lie on the facing faces x = 10 and x = 10.5.
|
||||
let xs = [f(&near[0]["from"][0]), f(&near[0]["to"][0])];
|
||||
assert!(xs.iter().any(|x| (x - 10.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.5).abs() < 1e-9), "{r}");
|
||||
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
// With no clearance nothing is near; with 2.5 mm C (2 mm from A) is near too.
|
||||
let r = run(&mut s, "inspect.clearance", json!({}));
|
||||
assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let r = run(&mut s, "inspect.clearance", json!({"clearance": 2.5}));
|
||||
let near = r["near"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(near.len(), 2, "{r}");
|
||||
assert_eq!(pair(&near[1]), ["A", "C"]);
|
||||
assert!((f(&near[1]["distance_mm"]) - 2.0).abs() < 1e-9, "{r}");
|
||||
}
|
||||
|
||||
/// Distances where the nearest points are on edges, and on curved faces whose meshes are far
|
||||
/// coarser than the answer: cube edge to cube edge, a cylinder's side to a cube's face, and a
|
||||
/// large sphere (mesh chord about 1 mm) to a cube.
|
||||
#[test]
|
||||
fn distances_are_exact_on_edges_and_curved_faces() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
|
||||
// Its vertical edge at (12, 13) faces A's at (10, 10): √(2² + 3²).
|
||||
cube(&mut s, "B", 10.0, [12.0, 13.0, 0.0]);
|
||||
run(&mut s, "solid.cylinder", json!({"base": [-30, 5, 0], "radius": 5, "height": 10, "operation": "new", "body_name": "Pin"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius": 500, "center": [5, 5, 510.7], "operation": "new", "body_name": "Ball"}));
|
||||
let r = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"], "bodies": ["B", "Pin", "Ball"]}));
|
||||
let near = r["near"].as_array().cloned().unwrap_or_default();
|
||||
let d = |other: &str| near.iter().find(|n| pair(n).contains(&other.to_string())).map(|n| (f(&n["distance_mm"]), n.get("exact").is_none()));
|
||||
assert_eq!(near.len(), 3, "{r}");
|
||||
let (edge, exact) = d("B").unwrap_or((f64::NAN, false));
|
||||
assert!((edge - 13f64.sqrt()).abs() < 1e-6 && exact, "{r}");
|
||||
// The pin's side (x = -25) is 25 from A's face x = 0.
|
||||
let (side, exact) = d("Pin").unwrap_or((f64::NAN, false));
|
||||
assert!((side - 25.0).abs() < 1e-6 && exact, "{r}");
|
||||
// The ball's lowest point, (5, 5, 10.7), is 0.7 above A's top.
|
||||
let (ball, exact) = d("Ball").unwrap_or((f64::NAN, false));
|
||||
assert!((ball - 0.7).abs() < 1e-6 && exact, "{r}");
|
||||
// `against` leaves out the pairs among `bodies`, which are by default every other body.
|
||||
assert!(near.iter().all(|n| pair(n).contains(&"A".to_string())), "{r}");
|
||||
let all = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"]}));
|
||||
assert_eq!(all["near"].as_array().map(Vec::len), Some(3), "{all}");
|
||||
}
|
||||
|
||||
/// A pin in a hole 0.2 mm wider all round, in a plate whose mesh is coarser than that gap: the
|
||||
/// plate's flat faces reach over the hole on their surfaces but not on the faces, so the
|
||||
/// distance is the gap to the hole's wall.
|
||||
#[test]
|
||||
fn a_pin_in_a_hole_is_as_far_as_the_holes_wall() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "operation": "new", "body_name": "Plate"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3.2, "height": 4, "base": [10, 10, -1], "operation": "cut", "targets": ["Plate"]}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [10, 10, -5], "operation": "new", "body_name": "Pin"}));
|
||||
let r = run(&mut s, "inspect.clearance", json!({"clearance": 1}));
|
||||
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert!((f(&r["near"][0]["distance_mm"]) - 0.2).abs() < 1e-6, "{r}");
|
||||
let r = run(&mut s, "inspect.clearance", json!({"clearance": 0.15}));
|
||||
assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
}
|
||||
|
||||
/// Three spheres' nearest neighbours against their distances worked out by hand.
|
||||
#[test]
|
||||
fn nearest_distances_of_three_spheres() {
|
||||
let mut s = Session::default();
|
||||
for (name, r, c) in [("S1", 5.0, [0.0, 0.0, 0.0]), ("S2", 3.0, [12.0, 0.0, 0.0]), ("S3", 2.0, [3.0, 20.0, 4.0])] {
|
||||
run(&mut s, "solid.sphere", json!({"radius": r, "center": c, "operation": "new", "body_name": name}));
|
||||
}
|
||||
let r = run(&mut s, "inspect.clearance", json!({"nearest": 2}));
|
||||
let d12 = 12.0 - 8.0;
|
||||
let d13 = (9.0f64 + 400.0 + 16.0).sqrt() - 7.0;
|
||||
let d23 = (81.0f64 + 400.0 + 16.0).sqrt() - 5.0;
|
||||
let want = [("S1", [("S2", d12), ("S3", d13)]), ("S2", [("S1", d12), ("S3", d23)]), ("S3", [("S1", d13), ("S2", d23)])];
|
||||
for (body, list) in want {
|
||||
let got = r["nearest"][body].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(got.len(), 2, "{body}: {r}");
|
||||
for (g, (other, d)) in got.iter().zip(list) {
|
||||
assert_eq!(g["body"], other, "{body}: {r}");
|
||||
assert!((f(&g["distance_mm"]) - d).abs() < 1e-6, "{body} to {other}: {} against {d}", g["distance_mm"]);
|
||||
}
|
||||
}
|
||||
// One neighbour each.
|
||||
let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"]}));
|
||||
assert_eq!(r["nearest"]["S3"].as_array().map(Vec::len), Some(0), "only S3 is in `bodies`, so it has no partner: {r}");
|
||||
let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"], "against": ["S1", "S2"]}));
|
||||
assert_eq!(r["nearest"]["S3"][0]["body"], "S1", "{r}");
|
||||
}
|
||||
|
||||
/// `skip_within` leaves out the pairs inside one component (two bodies made to touch, as the
|
||||
/// parts of a bought component do) and keeps those between components.
|
||||
#[test]
|
||||
fn skip_within_leaves_out_a_components_own_pairs() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Bought"}));
|
||||
cube(&mut s, "Housing", 10.0, [0.0, 0.0, 0.0]);
|
||||
cube(&mut s, "Insert", 4.0, [8.0, 3.0, 3.0]);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bracket"}));
|
||||
cube(&mut s, "Plate", 10.0, [-9.5, 0.0, 0.0]);
|
||||
let all = run(&mut s, "inspect.clearance", json!({}));
|
||||
let pairs: Vec<[String; 2]> = all["overlaps"].as_array().into_iter().flatten().map(pair).collect();
|
||||
assert!(pairs.contains(&["Housing".into(), "Insert".into()]) && pairs.contains(&["Housing".into(), "Plate".into()]), "{all}");
|
||||
for mode in ["component", "occurrence"] {
|
||||
let r = run(&mut s, "inspect.clearance", json!({"skip_within": mode}));
|
||||
let pairs: Vec<[String; 2]> = r["overlaps"].as_array().into_iter().flatten().map(pair).collect();
|
||||
assert_eq!(pairs, vec![["Housing".to_string(), "Plate".to_string()]], "{mode}: {r}");
|
||||
assert!((f(&r["overlaps"][0]["volume_mm3"]) - 50.0).abs() < 1e-6, "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters out of range are refused, not guessed at.
|
||||
#[test]
|
||||
fn bad_parameters_are_refused() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
|
||||
for p in [
|
||||
json!({"clearance": -1}),
|
||||
json!({"clearance": "wide"}),
|
||||
json!({"min_volume": -0.1}),
|
||||
json!({"nearest": 1e9}),
|
||||
json!({"tolerance": 5}),
|
||||
json!({"skip_within": "body"}),
|
||||
json!({"bodies": ["Nope"]}),
|
||||
json!({"against": ["Nope"]}),
|
||||
] {
|
||||
assert!(s.execute("inspect.clearance", &p).is_err(), "{p}");
|
||||
}
|
||||
let r = run(&mut s, "inspect.clearance", json!({"nearest": 3, "skip_within": null}));
|
||||
assert_eq!(r["pairs_checked"], 0, "{r}");
|
||||
assert_eq!(r["nearest"]["A"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
}
|
||||
|
||||
/// A pin sunk 0.01 mm into a plate 1.7 m long: the shared ring holds π (4.43² − 2.5²) × 0.01 mm³
|
||||
/// wherever the plate stands, as on a plate of 100 mm (the boolean of the whole plate gave 0.31).
|
||||
#[test]
|
||||
fn a_thin_overlap_with_a_large_plate_is_measured_in_full() {
|
||||
let ring = std::f64::consts::PI * (4.43f64.powi(2) - 2.5f64.powi(2)) * 0.01;
|
||||
for (length, far) in [(100.0, false), (1700.0, false), (1700.0, true)] {
|
||||
let mut s = Session::default();
|
||||
let corner = [-length / 2.0, -length / 4.0, 0.0];
|
||||
run(&mut s, "solid.box", json!({"length": length, "width": length / 2.0, "height": 5, "corner": corner, "body_name": "Plate"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 2.5, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 7, "base": [0, 0, -1.99], "operation": "new", "body_name": "Pin"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4.43, "height": 2, "base": [0, 0, 4.99], "operation": "join", "targets": ["Pin"]}));
|
||||
let shift = if far { [-326.3, 13.5, 1278.3] } else { [0.0; 3] };
|
||||
if far {
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Plate", "Pin"], "translate": shift}));
|
||||
}
|
||||
let r = run(&mut s, "inspect.clearance", json!({"min_volume": 1e-4}));
|
||||
let o = &r["overlaps"][0];
|
||||
assert!((f(&o["volume_mm3"]) - ring).abs() < 1e-3 * ring, "{length} {far}: {r}");
|
||||
// The ring's centre is on the axis, 0.005 below the plate's face.
|
||||
assert!((f(&o["center"][2]) - (4.995 + shift[2])).abs() < 1e-4, "{length} {far}: {r}");
|
||||
assert!((f(&o["center"][0]) - shift[0]).abs() < 1e-4, "{length} {far}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared part whose centre lies outside its own box, or which holds more than its box, is
|
||||
/// malformed: the pair is reported unchecked rather than with that volume.
|
||||
#[test]
|
||||
fn a_malformed_shared_part_is_not_measured() {
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
let bbox = Aabb3 { min: Vec3::new(0.0, 0.0, 0.0), max: Vec3::new(10.0, 10.0, 1.0) };
|
||||
assert!(super::plausible(50.0, Vec3::new(5.0, 5.0, 0.5), bbox));
|
||||
assert!(!super::plausible(50.0, Vec3::new(5.0, 5.0, 3.0), bbox));
|
||||
assert!(!super::plausible(1239.0, Vec3::new(5.0, 5.0, 0.5), bbox));
|
||||
assert!(!super::plausible(f64::NAN, Vec3::new(5.0, 5.0, 0.5), bbox));
|
||||
}
|
||||
|
||||
/// Two plates 0.2 mm apart, the upper one set 10 mm in from the lower one's corner, and two set
|
||||
/// side by side with a 0.2 mm gap: 0.2, not 0 (a point past a corner, on the line of one of its
|
||||
/// edges, was taken for a point of the face). Gaps under twice the meshes' chord went wrong.
|
||||
#[test]
|
||||
fn a_small_gap_past_a_corner_is_not_contact() {
|
||||
let mut s = Session::default();
|
||||
let plate = |s: &mut Session, name: &str, size: [f64; 3], corner: [f64; 3]| {
|
||||
run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner, "operation": "new", "body_name": name}));
|
||||
};
|
||||
plate(&mut s, "A", [100.0, 100.0, 2.0], [0.0, 0.0, 0.0]);
|
||||
plate(&mut s, "B", [100.0, 100.0, 2.0], [10.0, 10.0, 2.2]);
|
||||
plate(&mut s, "C", [100.0, 100.0, 2.0], [0.0, 0.0, 10.0]);
|
||||
plate(&mut s, "D", [100.0, 100.0, 2.0], [100.2, 0.0, 10.0]);
|
||||
// 3 mm apart in x and 0.2 in z, along their long sides: √(3² + 0.2²).
|
||||
plate(&mut s, "E", [1000.0, 100.0, 2.0], [0.0, 300.0, 0.0]);
|
||||
plate(&mut s, "F", [1000.0, 100.0, 2.0], [1003.0, 300.0, 2.2]);
|
||||
for (a, b, want) in [("A", "B", 0.2), ("C", "D", 0.2), ("E", "F", (9.0f64 + 0.04).sqrt())] {
|
||||
let r = run(&mut s, "inspect.clearance", json!({"bodies": [a], "against": [b], "clearance": 5, "nearest": 4}));
|
||||
assert!((f(&r["near"][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}");
|
||||
assert!((f(&r["nearest"][a][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}");
|
||||
}
|
||||
}
|
||||
@@ -1201,7 +1201,7 @@ 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) {
|
||||
pub(crate) 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),
|
||||
|
||||
@@ -21,6 +21,7 @@ macro_rules! planned {
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod clearance;
|
||||
mod clipboard;
|
||||
mod compare;
|
||||
mod component;
|
||||
@@ -251,6 +252,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(component_link::COMMANDS.iter());
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(clearance::COMMANDS.iter());
|
||||
v.extend(motion::COMMANDS.iter());
|
||||
v.extend(config::COMMANDS.iter());
|
||||
v.extend(parts::COMMANDS.iter());
|
||||
|
||||
Reference in new issue
Block a user