Clearance report: overlaps, near pairs and nearest bodies (#1900)

Thanks @NOWN-DEV.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-17 04:54:39 -04:00
1 parent fd073620ae
commit 186555dbe9
18 files changed
+1428 -17

No files matched your search

Generated
+1
View File
@@ -4135,6 +4135,7 @@ dependencies = [
"solidcraft-render",
"solidcraft-sketch",
"thiserror 2.0.21",
"web-time",
]
[[package]]
+3
View File
@@ -157,6 +157,7 @@
"Move earlier": "Move earlier",
"Move later": "Move later",
"Name": "Name",
"Nearest": "Nearest",
"New study": "New study",
"No crash reports.": "No crash reports.",
"No matching appearances": "No matching appearances",
@@ -275,6 +276,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.",
"To Offset": "To Offset",
"Tolerance": "Tolerance",
"Too close": "Too close",
"Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.",
"Type": "Type",
"UNTRIM": "UNTRIM",
@@ -408,6 +410,7 @@
"cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_clearance": "Clearance",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_continuity": "Continuity Analysis",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
+3
View File
@@ -157,6 +157,7 @@
"Move earlier": "前へ移動",
"Move later": "後へ移動",
"Name": "名前",
"Nearest": "最も近い",
"New study": "新規スタディ",
"No crash reports.": "クラッシュレポートはありません。",
"No matching appearances": "一致する外観がありません",
@@ -275,6 +276,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。",
"To Offset": "オフセットまで",
"Tolerance": "公差",
"Too close": "近すぎる",
"Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。",
"Type": "タイプ",
"UNTRIM": "トリム解除",
@@ -408,6 +410,7 @@
"cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_clearance": "クリアランス",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_continuity": "連続性解析",
"cmd.inspect_curvature_comb": "曲率コーム解析",
+3
View File
@@ -157,6 +157,7 @@
"Move earlier": "向前移动",
"Move later": "向后移动",
"Name": "名称",
"Nearest": "最近",
"New study": "新建研究",
"No crash reports.": "没有崩溃报告。",
"No matching appearances": "没有匹配的外观",
@@ -275,6 +276,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。",
"To Offset": "偏移",
"Tolerance": "公差",
"Too close": "过近",
"Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。",
"Type": "类型",
"UNTRIM": "取消修剪",
@@ -408,6 +410,7 @@
"cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_clearance": "间隙",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_continuity": "连续性分析",
"cmd.inspect_curvature_comb": "曲率梳分析",
+2 -2
View File
@@ -145,8 +145,8 @@ impl ModelBody {
Some((Arc::new(plan), Arc::new(mesh)))
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
fn display_tol(&self) -> f64 {
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm.
pub fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3)
}
+1
View File
@@ -19,3 +19,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
+456
View File
@@ -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;
+253
View File
@@ -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}");
}
}
+1 -1
View File
@@ -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),
+2
View File
@@ -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());
+47
View File
@@ -145,6 +145,53 @@ impl Bvh {
}
best
}
/// Calls `item(i, j)` for pairs of items, `i` from this hierarchy and `j` from `other`, in
/// leaves whose boxes, and every pair of boxes above them, `enter` accepts. `enter` is asked
/// again for a pair of boxes when it is taken up, so a bound that tightens while items are
/// visited (the nearest pair so far) prunes what is still waiting. Of two children, the pair
/// whose box centres are nearer is taken up first.
pub fn visit_pairs(&self, other: &Bvh, mut enter: impl FnMut(&Aabb3, &Aabb3) -> bool, mut item: impl FnMut(u32, u32)) {
if self.nodes.is_empty() || other.nodes.is_empty() {
return;
}
let mut stack = vec![(0usize, 0usize)];
while let Some((i, j)) = stack.pop() {
let (Some(a), Some(b)) = (self.nodes.get(i), other.nodes.get(j)) else { continue };
if !enter(&a.bounds, &b.bounds) {
continue;
}
match (a.count > 0, b.count > 0) {
(true, true) => {
let (ia, ib) = (a.first as usize, b.first as usize);
let left = self.order.get(ia..ia + a.count as usize).unwrap_or(&[]);
let right = other.order.get(ib..ib + b.count as usize).unwrap_or(&[]);
for &x in left {
for &y in right {
item(x, y);
}
}
}
// Split the node that is not a leaf, or the larger one.
(leaf_a, leaf_b) => {
let split_a = !leaf_a && (leaf_b || a.bounds.diagonal() >= b.bounds.diagonal());
let pairs =
if split_a { [(a.first as usize, j), (a.first as usize + 1, j)] } else { [(i, b.first as usize), (i, b.first as usize + 1)] };
let gap = |(x, y): (usize, usize)| match (self.nodes.get(x), other.nodes.get(y)) {
(Some(x), Some(y)) => x.bounds.center().dist(y.bounds.center()),
_ => f64::INFINITY,
};
let [p, q] = pairs;
// The nearer pair is popped first.
if gap(p) <= gap(q) {
stack.extend([q, p]);
} else {
stack.extend([p, q]);
}
}
}
}
}
}
fn finite(b: &Aabb3) -> bool {
+1
View File
@@ -18,6 +18,7 @@ pub mod env;
pub mod fill;
pub mod fill_general;
mod mesh;
pub mod mesh_distance;
mod plane;
mod profile;
pub mod tess;
+261
View File
@@ -0,0 +1,261 @@
//! Distances between two triangle meshes: the nearest pair of points, and every pair of
//! triangles within a distance. A [`TriangleSet`] keeps a mesh's triangles under a bounding
//! volume hierarchy, so a query costs about the pairs of triangles that come near each other
//! rather than the product of the triangle counts.
use crate::compare::closest_on_triangle;
use crate::{Aabb3, Bvh, Mesh, Vec3};
/// A mesh's triangles with their B-rep faces, indexed for distance queries.
#[derive(Clone, Debug, Default)]
pub struct TriangleSet {
tris: Vec<[Vec3; 3]>,
face: Vec<u32>,
bvh: Bvh,
bounds: Aabb3,
}
/// The nearest points of two triangle sets, and the triangles they lie on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshClosest {
pub distance: f64,
pub a: Vec3,
pub b: Vec3,
pub tri_a: u32,
pub tri_b: u32,
}
impl TriangleSet {
/// The set of `mesh`'s triangles (ones with a vertex that is not finite are left out).
pub fn new(mesh: &Mesh) -> TriangleSet {
let mut tris = Vec::with_capacity(mesh.triangles.len());
let mut face = Vec::with_capacity(mesh.triangles.len());
let mut boxes = Vec::with_capacity(mesh.triangles.len());
let mut bounds = Aabb3::EMPTY;
for (k, t) in mesh.triangles.iter().enumerate() {
let Some(p) = mesh.tri(t) else { continue };
if !p.iter().all(|v| v.is_finite()) {
continue;
}
let mut b = Aabb3::EMPTY;
for v in p {
b.add(v);
}
bounds = bounds.union(&b);
boxes.push(b);
tris.push(p);
face.push(mesh.tri_face.get(k).copied().unwrap_or(0));
}
TriangleSet { bvh: Bvh::new(&boxes), tris, face, bounds }
}
/// The box around every triangle.
pub fn bounds(&self) -> Aabb3 {
self.bounds
}
pub fn len(&self) -> usize {
self.tris.len()
}
pub fn is_empty(&self) -> bool {
self.tris.is_empty()
}
/// Triangle `t`'s corners.
pub fn triangle(&self, t: u32) -> Option<[Vec3; 3]> {
self.tris.get(t as usize).copied()
}
/// The B-rep face triangle `t` came from.
pub fn face(&self, t: u32) -> Option<u32> {
self.face.get(t as usize).copied()
}
/// The nearest points of this set and `other`, if they come within `limit` of each other
/// (`f64::INFINITY`: always). Triangles that cross count as distance 0, at a point where
/// they meet.
pub fn closest(&self, other: &TriangleSet, limit: f64) -> Option<MeshClosest> {
let mut best: Option<MeshClosest> = None;
let bound = std::cell::Cell::new(if limit.is_nan() { f64::INFINITY } else { limit });
let mut found = |i: u32, j: u32| {
let (Some(x), Some(y)) = (self.tris.get(i as usize), other.tris.get(j as usize)) else { return };
let (d, a, b) = triangle_distance(x, y);
if d <= bound.get() && best.is_none_or(|m| d < m.distance) {
best = Some(MeshClosest { distance: d, a, b, tri_a: i, tri_b: j });
bound.set(d);
}
};
self.bvh.visit_pairs(&other.bvh, |x, y| box_gap(x, y) <= bound.get(), &mut found);
best
}
/// Calls `f` with every pair of triangles, one from this set and one from `other`, that come
/// within `d` of each other, with their nearest points.
pub fn within(&self, other: &TriangleSet, d: f64, mut f: impl FnMut(MeshClosest)) {
if !(d >= 0.0) {
return;
}
self.bvh.visit_pairs(
&other.bvh,
|x, y| box_gap(x, y) <= d,
|i, j| {
let (Some(x), Some(y)) = (self.tris.get(i as usize), other.tris.get(j as usize)) else { return };
let (dist, a, b) = triangle_distance(x, y);
if dist <= d {
f(MeshClosest { distance: dist, a, b, tri_a: i, tri_b: j });
}
},
);
}
/// The distance from `p` to the nearest of the triangles `tris` (indices into this set).
pub fn distance_to(&self, p: Vec3, tris: &[u32]) -> f64 {
tris.iter()
.filter_map(|&t| self.tris.get(t as usize))
.map(|[a, b, c]| closest_on_triangle(p, *a, *b, *c).dist(p))
.fold(f64::INFINITY, f64::min)
}
}
/// The gap between two boxes (0 when they meet).
pub fn box_gap(a: &Aabb3, b: &Aabb3) -> f64 {
if a.is_empty() || b.is_empty() {
return f64::INFINITY;
}
let axis = |lo1: f64, hi1: f64, lo2: f64, hi2: f64| (lo2 - hi1).max(lo1 - hi2).max(0.0);
let d = Vec3::new(axis(a.min.x, a.max.x, b.min.x, b.max.x), axis(a.min.y, a.max.y, b.min.y, b.max.y), axis(a.min.z, a.max.z, b.min.z, b.max.z));
d.len()
}
/// The nearest points of segments `p0`–`p1` and `q0`–`q1` (Ericson, Real-Time Collision
/// Detection, 5.1.9).
pub fn closest_on_segments(p0: Vec3, p1: Vec3, q0: Vec3, q1: Vec3) -> (Vec3, Vec3) {
let (d1, d2, r) = (p1 - p0, q1 - q0, p0 - q0);
let (a, e, f) = (d1.len2(), d2.len2(), d2.dot(r));
const TINY: f64 = 1e-300;
let (s, t) = if a <= TINY && e <= TINY {
(0.0, 0.0)
} else if a <= TINY {
(0.0, (f / e).clamp(0.0, 1.0))
} else {
let c = d1.dot(r);
if e <= TINY {
((-c / a).clamp(0.0, 1.0), 0.0)
} else {
let b = d1.dot(d2);
let den = a * e - b * b;
let mut s = if den > TINY * (a * e).max(1.0) { ((b * f - c * e) / den).clamp(0.0, 1.0) } else { 0.0 };
let mut t = (b * s + f) / e;
if t < 0.0 {
t = 0.0;
s = (-c / a).clamp(0.0, 1.0);
} else if t > 1.0 {
t = 1.0;
s = ((b - c) / a).clamp(0.0, 1.0);
}
(s, t)
}
};
(p0 + d1 * s, q0 + d2 * t)
}
/// Where segment `p`–`q` passes through triangle `t` (not when it lies in its plane).
fn segment_through(p: Vec3, q: Vec3, t: &[Vec3; 3]) -> Option<Vec3> {
let [a, b, c] = *t;
let n = (b - a).cross(c - a);
let (dp, dq) = (n.dot(p - a), n.dot(q - a));
if (dp > 0.0 && dq > 0.0) || (dp < 0.0 && dq < 0.0) || dp == dq {
return None;
}
let x = p + (q - p) * (dp / (dp - dq));
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(x - *u).dot(n) >= 0.0);
inside.then_some(x)
}
/// The distance between two triangles and their nearest points (0 where they cross).
pub fn triangle_distance(s: &[Vec3; 3], t: &[Vec3; 3]) -> (f64, Vec3, Vec3) {
let edges = |x: &[Vec3; 3]| [(x[0], x[1]), (x[1], x[2]), (x[2], x[0])];
for (p, q) in edges(s) {
if let Some(x) = segment_through(p, q, t) {
return (0.0, x, x);
}
}
for (p, q) in edges(t) {
if let Some(x) = segment_through(p, q, s) {
return (0.0, x, x);
}
}
let mut best = (f64::INFINITY, s[0], t[0]);
let mut keep = |a: Vec3, b: Vec3| {
let d = a.dist(b);
if d < best.0 {
best = (d, a, b);
}
};
for p in s {
keep(*p, closest_on_triangle(*p, t[0], t[1], t[2]));
}
for q in t {
keep(closest_on_triangle(*q, s[0], s[1], s[2]), *q);
}
for (p0, p1) in edges(s) {
for (q0, q1) in edges(t) {
let (a, b) = closest_on_segments(p0, p1, q0, q1);
keep(a, b);
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
fn tri(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> [Vec3; 3] {
[Vec3::from(a), Vec3::from(b), Vec3::from(c)]
}
/// Distances between triangles worked out by hand: face to vertex, edge to edge, crossing.
#[test]
fn triangle_distances_by_hand() {
let floor = tri([0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]);
// A vertex 3 above the floor's inside.
let (d, a, b) = triangle_distance(&floor, &tri([1.0, 1.0, 3.0], [1.0, 1.0, 9.0], [2.0, 1.0, 9.0]));
assert!((d - 3.0).abs() < 1e-12 && a.dist(Vec3::new(1.0, 1.0, 0.0)) < 1e-12 && b.dist(Vec3::new(1.0, 1.0, 3.0)) < 1e-12);
// Two skew edges: the floor's x axis edge and a line along y at x = 5, z = 2, off the floor at y < 0.
let (d, ..) = triangle_distance(&floor, &tri([5.0, -1.0, 2.0], [5.0, -9.0, 2.0], [5.0, -5.0, 9.0]));
assert!((d - 5f64.sqrt()).abs() < 1e-12, "{d}");
// A triangle through the floor.
let (d, ..) = triangle_distance(&floor, &tri([2.0, 2.0, -1.0], [2.0, 2.0, 1.0], [3.0, 2.0, 1.0]));
assert_eq!(d, 0.0);
// Parallel segments overlapping along their length.
let (p, q) = closest_on_segments(Vec3::ZERO, Vec3::X * 4.0, Vec3::new(2.0, 1.0, 0.0), Vec3::new(6.0, 1.0, 0.0));
assert!((p.dist(q) - 1.0).abs() < 1e-12);
}
fn cube(at: Vec3, size: f64) -> Mesh {
let mut m = Mesh::default();
let c = |x: f64, y: f64, z: f64| at + Vec3::new(x, y, z) * size;
m.positions = vec![c(0., 0., 0.), c(1., 0., 0.), c(1., 1., 0.), c(0., 1., 0.), c(0., 0., 1.), c(1., 0., 1.), c(1., 1., 1.), c(0., 1., 1.)];
m.triangles =
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
m.tri_face = vec![0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5];
m
}
/// Two cubes apart along a diagonal: the nearest points are their facing corners.
#[test]
fn nearest_points_of_two_meshes() {
let a = TriangleSet::new(&cube(Vec3::ZERO, 10.0));
let b = TriangleSet::new(&cube(Vec3::new(13.0, 14.0, 0.0), 10.0));
let c = a.closest(&b, f64::INFINITY);
assert!(c.is_some_and(|c| (c.distance - 5.0).abs() < 1e-12), "{c:?}");
assert!(a.closest(&b, 4.0).is_none(), "nothing within 4");
let mut n = 0;
a.within(&b, 5.5, |_| n += 1);
assert!(n > 0);
let far = TriangleSet::new(&cube(Vec3::new(5.0, 5.0, 5.0), 10.0));
assert_eq!(a.closest(&far, f64::INFINITY).map(|c| c.distance), Some(0.0), "overlapping cubes cross");
}
}
+312
View File
@@ -0,0 +1,312 @@
//! The minimum distance between two bodies, on their exact faces and edges.
//!
//! The display meshes find where the bodies come nearest and prune pairs that are far apart:
//! the meshes lie within their chord tolerance of the faces, so a mesh distance bounds the exact
//! one from both sides. The answer itself comes from the exact geometry. Near the nearest mesh
//! points, each body offers the face there and the edges of that face that pass close by; the
//! points are moved in turn to the nearest point of one feature from the other (alternating
//! projection) until they settle, on analytic surfaces in closed form and on other surfaces and
//! curves through the kernel's point inversion. A point that settles off its face (past the
//! face's boundary) is dropped: the face's edges, offered too, then carry the minimum.
use std::collections::HashMap;
use std::sync::OnceLock;
use solidcraft_geom::mesh_distance::{MeshClosest, TriangleSet};
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::surface_kind::{FaceSurface, surface_of};
use crate::{Result, guard};
/// The minimum distance between two bodies and the points where it is reached.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BodyDistance {
pub distance: f64,
/// On the first body.
pub from: Vec3,
/// On the second body.
pub to: Vec3,
/// Measured on the exact faces and edges (false: on the meshes, for a mesh body or where the
/// exact geometry gave no answer).
pub exact: bool,
}
/// A body made ready for distance queries: its mesh's triangles under a hierarchy, and its faces
/// and edges as they are first needed. Share one between the queries that involve its body.
pub struct DistanceShape<'a> {
body: &'a Body,
mesh: &'a Mesh,
set: TriangleSet,
chord: f64,
faces: Vec<mt::Face>,
edges: Vec<mt::Edge>,
surfaces: Vec<OnceLock<Option<FaceSurface>>>,
face_tris: OnceLock<HashMap<u32, Vec<u32>>>,
}
/// Most face pairs near the nearest mesh points whose exact distance is worked out.
const MAX_FACE_PAIRS: usize = 24;
/// Most projection rounds for one pair of features.
const MAX_ROUNDS: usize = 400;
impl<'a> DistanceShape<'a> {
/// `mesh` is `body`'s mesh, within `chord` (mm) of its faces.
pub fn new(body: &'a Body, mesh: &'a Mesh, chord: f64) -> DistanceShape<'a> {
let exact = body.mesh.is_none();
let faces: Vec<mt::Face> = if exact { body.solid.face_iter().cloned().collect() } else { Vec::new() };
let edges = if exact { Body::unique_edges(&body.solid) } else { Vec::new() };
let surfaces = faces.iter().map(|_| OnceLock::new()).collect();
let chord = if chord.is_finite() && chord > 0.0 { chord } else { 0.0 };
DistanceShape { body, mesh, set: TriangleSet::new(mesh), chord, faces, edges, surfaces, face_tris: OnceLock::new() }
}
/// The box around the body: its mesh's, grown by the chord tolerance.
pub fn bounds(&self) -> Aabb3 {
let b = self.set.bounds();
if b.is_empty() {
return b;
}
let g = Vec3::new(1.0, 1.0, 1.0) * self.chord;
Aabb3 { min: b.min - g, max: b.max + g }
}
/// How far the mesh may lie from the faces (mm).
pub fn chord(&self) -> f64 {
self.chord
}
/// The nearest points of the two meshes, if they come within `limit`.
pub fn mesh_closest(&self, other: &DistanceShape, limit: f64) -> Option<MeshClosest> {
self.set.closest(&other.set, limit)
}
fn surface(&self, face: usize) -> Option<FaceSurface> {
*self.surfaces.get(face)?.get_or_init(|| self.faces.get(face).and_then(|f| surface_of(f, self.body.size())))
}
fn face_triangles(&self, face: u32) -> &[u32] {
let map = self.face_tris.get_or_init(|| {
let mut m: HashMap<u32, Vec<u32>> = HashMap::new();
for t in 0..self.set.len() as u32 {
if let Some(f) = self.set.face(t) {
m.entry(f).or_default().push(t);
}
}
m
});
map.get(&face).map(Vec::as_slice).unwrap_or(&[])
}
/// Does `p`, a point on face `face`'s surface, lie on the face itself? Its mesh rules out
/// points farther than twice the chord. Nearer the face's boundary than that, the exact edge
/// nearest `p` decides: `p` must lie on the side of it where the face's mesh is.
fn on_face(&self, face: u32, p: Vec3) -> bool {
let tris = self.face_triangles(face);
let tiny = 1e-9 * self.body.size().max(1.0);
let reach = 2.0 * self.chord + tiny;
if self.set.distance_to(p, tris) > reach {
return false;
}
// The exact edges of the face near `p`: their nearest points and directions there.
let mut near: Vec<(f64, Vec3, Vec3)> = Vec::new();
for e in self.mesh.face_edges(face) {
let Some(line) = self.mesh.edges.get(e) else { continue };
let rough = match line.as_slice() {
[one] => one.dist(p),
_ => line.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min),
};
if rough > 2.0 * reach {
continue;
}
if let Some((at, dir)) = self.edge_nearest(e, p, &mut Hint::default()) {
near.push((at.dist(p), at, dir));
}
}
let Some(least) = near.iter().map(|n| n.0).reduce(f64::min) else { return true };
if least <= tiny {
return true;
}
// The nearest edge (any of them, where several meet at a vertex) with the face's mesh on
// `p`'s side. An edge with the face on both sides (a seam inside it) says nothing.
let mut decided = false;
for &(_, at, dir) in near.iter().filter(|n| n.0 <= least + tiny) {
let Some(t) = dir.normalized() else { continue };
let across = |x: Vec3| {
let v = x - at;
v - t * v.dot(t)
};
let sides: Vec<Vec3> = tris
.iter()
.filter_map(|&k| self.set.triangle(k))
.filter(|[a, b, c]| solidcraft_geom::compare::closest_on_triangle(at, *a, *b, *c).dist(at) <= reach)
.map(|[a, b, c]| across((a + b + c) / 3.0))
.filter(|w| w.len() > tiny)
.collect();
let Some(inward) = sides.iter().copied().max_by(|x, y| x.len().total_cmp(&y.len())) else { continue };
if sides.iter().any(|w| w.dot(inward) < 0.0) {
continue;
}
// `p` straight along the edge (past its end, on the line through it) says nothing
// about which side it is on: another edge meeting there decides.
let off = across(p);
if off.len() <= tiny {
continue;
}
decided = true;
if off.dot(inward) >= 0.0 {
return true;
}
}
!decided
}
/// The features near mesh point `p` on face `face`: the face, and its edges within twice the chord.
fn features(&self, face: u32, p: Vec3) -> Vec<Feature> {
let mut out = vec![Feature::Face(face as usize)];
let reach = 2.0 * self.chord + 1e-9 * self.body.size().max(1.0);
for e in self.mesh.face_edges(face) {
let Some(line) = self.mesh.edges.get(e) else { continue };
let near = match line.as_slice() {
[one] => one.dist(p),
_ => line.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min),
};
if near <= reach && e < self.edges.len() {
out.push(Feature::Edge(e));
}
}
out
}
/// The point of `f` nearest `p`, with the parameter hint for the next call.
fn project(&self, f: Feature, p: Vec3, hint: &mut Hint) -> Option<Vec3> {
use mt::{ParametricSurface, SearchNearestParameter};
match f {
Feature::Face(i) => {
if let Some(q) = self.surface(i).and_then(|s| s.nearest(p)) {
return Some(q);
}
let s = self.faces.get(i)?.oriented_surface();
let uv = s.search_nearest_parameter(p3(p), hint.uv, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?;
hint.uv = Some(uv);
Some(from_p3(s.subs(uv.0, uv.1))).filter(|q| q.is_finite())
}
Feature::Edge(i) => self.edge_nearest(i, p, hint).map(|(q, _)| q),
}
}
/// The point of edge `i` nearest `p`, with the edge's direction there.
fn edge_nearest(&self, i: usize, p: Vec3, hint: &mut Hint) -> Option<(Vec3, Vec3)> {
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
let c = self.edges.get(i)?.oriented_curve();
let (t0, t1) = c.range_tuple();
let (lo, hi) = (t0.min(t1), t0.max(t1));
let at = |t: f64| from_p3(c.subs(t));
let start = match hint.t {
Some(t) => t,
None => {
// A coarse look along the curve, then the inversion from the best sample.
let n = 32;
(0..=n).map(|k| t0 + (t1 - t0) * f64::from(k) / f64::from(n)).min_by(|a, b| at(*a).dist(p).total_cmp(&at(*b).dist(p)))?
}
};
let mut best = (start.clamp(lo, hi), f64::INFINITY);
best.1 = at(best.0).dist(p);
for t in [c.search_nearest_parameter(p3(p), Some(start), 64).map(|t| t.clamp(lo, hi)), Some(t0), Some(t1)].into_iter().flatten() {
let d = at(t).dist(p);
if d < best.1 {
best = (t, d);
}
}
hint.t = Some(best.0);
let d = c.der(best.0);
let q = at(best.0);
q.is_finite().then_some((q, Vec3::new(d.x, d.y, d.z)))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Feature {
Face(usize),
Edge(usize),
}
#[derive(Clone, Copy, Default)]
struct Hint {
uv: Option<(f64, f64)>,
t: Option<f64>,
}
/// Settle a pair of points on features `fa` of `a` and `fb` of `b`, starting from `pb` on `b`.
fn settle(a: &DistanceShape, fa: Feature, b: &DistanceShape, fb: Feature, pb: Vec3) -> Option<(f64, Vec3, Vec3)> {
let (mut ha, mut hb) = (Hint::default(), Hint::default());
let scale = a.body.size().max(b.body.size()).max(1.0);
let mut q = b.project(fb, pb, &mut hb)?;
let mut p = a.project(fa, q, &mut ha)?;
for _ in 0..MAX_ROUNDS {
let q2 = b.project(fb, p, &mut hb)?;
let p2 = a.project(fa, q2, &mut ha)?;
let moved = p2.dist(p).max(q2.dist(q));
p = p2;
q = q2;
if moved <= 1e-12 * scale {
break;
}
}
let d = p.dist(q);
d.is_finite().then_some((d, p, q))
}
/// The minimum distance between the bodies of `a` and `b`, when it may be within `limit` (mm;
/// `f64::INFINITY` for any): `None` when the meshes show it is not. Overlapping bodies are not
/// told apart from touching ones here (both give 0 or the distance between their surfaces):
/// an intersection says whether they share volume.
pub fn body_distance(a: &DistanceShape, b: &DistanceShape, limit: f64) -> Result<Option<BodyDistance>> {
let slack = 2.0 * (a.chord + b.chord);
let Some(m) = a.mesh_closest(b, if limit.is_finite() { limit + slack } else { f64::INFINITY }) else { return Ok(None) };
let mesh = BodyDistance { distance: m.distance, from: m.a, to: m.b, exact: false };
if a.faces.is_empty() || b.faces.is_empty() {
return Ok(Some(mesh));
}
// The face pairs near the nearest mesh points, each with its nearest triangle pair.
let mut seeds: HashMap<(u32, u32), MeshClosest> = HashMap::new();
a.set.within(&b.set, m.distance + slack, |c| {
let (Some(fa), Some(fb)) = (a.set.face(c.tri_a), b.set.face(c.tri_b)) else { return };
let e = seeds.entry((fa, fb)).or_insert(c);
if c.distance < e.distance {
*e = c;
}
});
let mut seeds: Vec<((u32, u32), MeshClosest)> = seeds.into_iter().collect();
seeds.sort_by(|x, y| x.1.distance.total_cmp(&y.1.distance).then(x.0.cmp(&y.0)));
seeds.truncate(MAX_FACE_PAIRS);
let best = guard("body distance", || {
let mut best: Option<BodyDistance> = None;
let mut tried = std::collections::HashSet::new();
for ((fa, fb), c) in &seeds {
for xa in a.features(*fa, c.a) {
for xb in b.features(*fb, c.b) {
if !tried.insert((xa, xb)) {
continue;
}
let Some((d, p, q)) = settle(a, xa, b, xb, c.b) else { continue };
let holds = |s: &DistanceShape, f: Feature, x: Vec3| match f {
Feature::Face(i) => s.on_face(i as u32, x),
Feature::Edge(_) => true,
};
if holds(a, xa, p) && holds(b, xb, q) && best.is_none_or(|bd| d < bd.distance) {
best = Some(BodyDistance { distance: d, from: p, to: q, exact: true });
}
}
}
}
Ok(best)
})?;
// The exact minimum lies within the chords of the mesh's; an answer outside that went astray.
let chords = a.chord + b.chord + 1e-9 * a.body.size().max(b.body.size()).max(1.0);
Ok(Some(match best {
Some(e) if e.distance <= m.distance + chords && e.distance >= m.distance - chords => e,
_ => mesh,
}))
}
+2
View File
@@ -34,6 +34,7 @@ mod coplanar;
mod corner_g2;
mod curveblend;
mod delete_face;
mod distance;
#[cfg(test)]
mod edge_polyline_tests;
mod emboss;
@@ -149,6 +150,7 @@ pub use continuity::{
};
pub use corner_g2::{corner_g2_patch, seam_error};
pub use delete_face::delete_faces;
pub use distance::{BodyDistance, DistanceShape, body_distance};
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
pub use fair::{
FairCurve, FairNet, FairOptions, FairReport, MAX_FAIR_ITERATIONS, MAX_FAIR_POINTS, fair_bspline_curve, fair_bspline_surface, fair_curve,
+6 -2
View File
@@ -43,8 +43,12 @@ pub enum FaceSurface {
/// The surface of face `face` (in face order), if it is one of the analytic kinds.
pub fn face_surface(b: &Body, face: usize) -> Option<FaceSurface> {
let f = b.solid.face_iter().nth(face)?;
let tol = (b.size() * 1e-7).max(1e-9) * 100.0;
surface_of(b.solid.face_iter().nth(face)?, b.size())
}
/// The analytic surface of face `f` of a body whose box diagonal is `size`.
pub(crate) fn surface_of(f: &mt::Face, size: f64) -> Option<FaceSurface> {
let tol = (size * 1e-7).max(1e-9) * 100.0;
Some(match surf_of(f, tol)? {
Surf::Plane { n, d } => FaceSurface::Plane { normal: n, point: n * d },
Surf::Cylinder { o, a, r, .. } => FaceSurface::Cylinder { origin: o, axis: a, radius: r },
+55 -11
View File
@@ -78,6 +78,8 @@ pub enum Asm {
result: Option<Value>,
overlaps: Vec<Mesh>,
of: Vec<Sel>,
/// Pairs closer than this (mm, as typed) are listed too.
clearance: String,
},
/// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline.
Trace(Box<TraceForm>),
@@ -189,9 +191,10 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
}
"joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
"joint.cam" => crate::dialogs_coupling::cam::start(app),
"inspect.interference" => {
(Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)])
}
"inspect.interference" => (
Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into() },
vec![SelInput::new("Bodies (all if none)", BODIES, true)],
),
_ => return None,
};
Some((Kind::Assembly(asm), inputs))
@@ -691,12 +694,15 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
}
Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs),
Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs),
Asm::Interference { result, overlaps, of } => {
Asm::Interference { result, overlaps, of, clearance } => {
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
row_label(ui, "Clearance");
field(ui, clearance);
ui.end_row();
row_label(ui, "");
let stale = result.is_some() && *of != picked;
if ui.button(if stale { "Compute again" } else { "Compute" }).clicked() {
let (r, o) = interference(app, &picked);
let (r, o) = interference(app, &picked, clearance);
*result = Some(r);
*overlaps = o;
*of = picked;
@@ -716,16 +722,26 @@ fn capital(s: &str) -> String {
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
/// Run the check and find the overlapping volumes (world placement) to show in red.
fn interference(app: &mut SolveApp, picked: &[Sel]) -> (Value, Vec<Mesh>) {
/// Run the check (overlaps, pairs closer than the clearance, each body's nearest neighbour) and
/// find the overlapping volumes (world placement) to show in red.
fn interference(app: &mut SolveApp, picked: &[Sel], clearance: &str) -> (Value, Vec<Mesh>) {
let names: Vec<String> = picked.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let r = match app.run("inspect.interference", json!({ "bodies": names })) {
let (scale, _) = crate::prefs::unit_scale(&app.session.doc.units);
let clearance = match crate::prefs::parse_length_mm(clearance, scale) {
Some(v) if v >= 0.0 => v,
_ => return (json!({ "error": "the clearance must be a length of 0 or more" }), Vec::new()),
};
let mut p = json!({ "clearance": clearance, "nearest": 1 });
if !names.is_empty() {
p["bodies"] = json!(names);
}
let r = match app.run("inspect.clearance", p) {
Ok(v) => v,
Err(e) => return (json!({ "error": e }), Vec::new()),
};
let st = app.session.world_state();
let mut meshes = Vec::new();
for hit in r["interferences"].as_array().into_iter().flatten() {
for hit in r["overlaps"].as_array().into_iter().flatten() {
let (Some(a), Some(b)) = (hit["a"].as_str().and_then(|n| st.body(n)), hit["b"].as_str().and_then(|n| st.body(n))) else { continue };
if let Ok(Some(o)) = solidcraft_engine::kernel::boolean(&a.body, &b.body, solidcraft_engine::kernel::BoolOp::Intersect)
&& let Ok(m) = o.tessellate((o.size() * 2e-3).max(1e-3))
@@ -744,15 +760,43 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
ui.end_row();
return;
}
let hits = r["interferences"].as_array().cloned().unwrap_or_default();
let hits = r["overlaps"].as_array().cloned().unwrap_or_default();
row_label(ui, "Checked");
ui.label(format!("{} bodies", r["checked"].as_u64().unwrap_or(0)));
ui.label(format!("{} bodies", r["bodies"].as_u64().unwrap_or(0)));
ui.end_row();
let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default();
let near = r["near"].as_array().cloned().unwrap_or_default();
let pair = |h: &Value| format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or(""));
if hits.is_empty() && unchecked.is_empty() {
row_label(ui, "Result");
ui.label(RichText::new(crate::i18n::tr_label("no interference")).color(t.text_dim));
ui.end_row();
}
// Pairs closer than the clearance, nearest first.
if !near.is_empty() {
row_label(ui, "Too close");
ui.label(RichText::new(crate::i18n::tr("ui.distance")).strong());
ui.end_row();
}
for h in &near {
row_label(ui, &pair(h));
ui.label(RichText::new(crate::prefs::show_mm(app, h["distance_mm"].as_f64().unwrap_or(0.0), 1)).color(t.warning));
ui.end_row();
}
// Each body's nearest neighbour.
let nearest = r["nearest"].as_object().cloned().unwrap_or_default();
if !nearest.is_empty() {
row_label(ui, "Nearest");
ui.label(RichText::new(crate::i18n::tr("ui.distance")).strong());
ui.end_row();
}
for (body, list) in &nearest {
let Some(n) = list.get(0) else { continue };
row_label(ui, &format!("{body} → {}", n["body"].as_str().unwrap_or("")));
ui.label(crate::prefs::show_mm(app, n["distance_mm"].as_f64().unwrap_or(0.0), 1));
ui.end_row();
}
if hits.is_empty() && unchecked.is_empty() {
return;
}
if !hits.is_empty() {
+19 -1
View File
@@ -26,7 +26,25 @@ couples two joints. Links can be chained (one joint drives a second, which drive
third), and a value is carried through a chain of up to 64 links in whatever order they were
made. Contact sets stop motion where bodies would pass through each other,
**Motion Study** keys joint values along a timeline, and **Interference** shows overlapping
volumes in red.
volumes in red, lists the pairs closer than its **Clearance** and each body's nearest neighbour.
The check behind it, `inspect.clearance`, does all of that in one call for any number of bodies,
which suits scripts and agents checking a whole assembly:
```json
{"command": "inspect.clearance", "params": {"clearance": 1, "nearest": 2, "skip_within": "component"}}
```
It returns `overlaps` (pairs sharing at least `min_volume`, 0.01 mm³ by default, with the shared
volume, area, box and centre), `near` (pairs closer than `clearance`, with the distance and the
two nearest points; a contact sharing less than `min_volume` is listed here at 0 with its
`overlap_mm3`), `nearest` (each body's `nearest` closest bodies) and `unchecked` (pairs whose
overlap could not be worked out, with the reason). `against` limits the check to pairs with one
body in `bodies` and the other in `against`, and `skip_within` leaves out pairs inside one
occurrence (`"occurrence"`) or one placed component, its subcomponents included
(`"component"`): the parts of a bought component that touch by design. Distances are measured on
the exact faces and edges; the meshes only rule out pairs that are too far apart, and the pairs
left are checked in parallel. `inspect.interference` remains for the overlaps alone.
**Gear**, **Rack and Pinion** and **Screw** (on the **ASSEMBLE** panel) are motion links whose
ratio comes from the parts rather than a typed number: