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-render",
"solidcraft-sketch", "solidcraft-sketch",
"thiserror 2.0.21", "thiserror 2.0.21",
"web-time",
] ]
[[package]] [[package]]
+3
View File
@@ -157,6 +157,7 @@
"Move earlier": "Move earlier", "Move earlier": "Move earlier",
"Move later": "Move later", "Move later": "Move later",
"Name": "Name", "Name": "Name",
"Nearest": "Nearest",
"New study": "New study", "New study": "New study",
"No crash reports.": "No crash reports.", "No crash reports.": "No crash reports.",
"No matching appearances": "No matching appearances", "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.", "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", "To Offset": "To Offset",
"Tolerance": "Tolerance", "Tolerance": "Tolerance",
"Too close": "Too close",
"Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.", "Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.",
"Type": "Type", "Type": "Type",
"UNTRIM": "UNTRIM", "UNTRIM": "UNTRIM",
@@ -408,6 +410,7 @@
"cmd.file_slicers": "Installed Slicers", "cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis", "cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass", "cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_clearance": "Clearance",
"cmd.inspect_compare": "Compare Bodies", "cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_continuity": "Continuity Analysis", "cmd.inspect_continuity": "Continuity Analysis",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis", "cmd.inspect_curvature_comb": "Curvature Comb Analysis",
+3
View File
@@ -157,6 +157,7 @@
"Move earlier": "前へ移動", "Move earlier": "前へ移動",
"Move later": "後へ移動", "Move later": "後へ移動",
"Name": "名前", "Name": "名前",
"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": "近すぎる",
"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": "インストール済みのスライサー", "cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析", "cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心", "cmd.inspect_center_of_mass": "重心",
"cmd.inspect_clearance": "クリアランス",
"cmd.inspect_compare": "ボディを比較", "cmd.inspect_compare": "ボディを比較",
"cmd.inspect_continuity": "連続性解析", "cmd.inspect_continuity": "連続性解析",
"cmd.inspect_curvature_comb": "曲率コーム解析", "cmd.inspect_curvature_comb": "曲率コーム解析",
+3
View File
@@ -157,6 +157,7 @@
"Move earlier": "向前移动", "Move earlier": "向前移动",
"Move later": "向后移动", "Move later": "向后移动",
"Name": "名称", "Name": "名称",
"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": "过近",
"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": "已安装的切片软件", "cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析", "cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心", "cmd.inspect_center_of_mass": "质心",
"cmd.inspect_clearance": "间隙",
"cmd.inspect_compare": "比较实体", "cmd.inspect_compare": "比较实体",
"cmd.inspect_continuity": "连续性分析", "cmd.inspect_continuity": "连续性分析",
"cmd.inspect_curvature_comb": "曲率梳分析", "cmd.inspect_curvature_comb": "曲率梳分析",
+2 -2
View File
@@ -145,8 +145,8 @@ impl ModelBody {
Some((Arc::new(plan), Arc::new(mesh))) Some((Arc::new(plan), Arc::new(mesh)))
} }
/// The display mesh's chord tolerance: 1/1000 of the body's size. /// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm.
fn display_tol(&self) -> f64 { pub fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3) (self.body.size() * 1e-3).max(1e-3)
} }
+1
View File
@@ -19,3 +19,4 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { 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 /// 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. /// `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(); let mut order: Vec<usize> = (0..boxes.len()).collect();
order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) { order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) {
(Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x), (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 browser;
mod clearance;
mod clipboard; mod clipboard;
mod compare; mod compare;
mod component; mod component;
@@ -251,6 +252,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component::COMMANDS.iter()); v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter()); v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter()); v.extend(joints::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter()); v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter()); v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter()); v.extend(parts::COMMANDS.iter());
+47
View File
@@ -145,6 +145,53 @@ impl Bvh {
} }
best 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 { fn finite(b: &Aabb3) -> bool {
+1
View File
@@ -18,6 +18,7 @@ pub mod env;
pub mod fill; pub mod fill;
pub mod fill_general; pub mod fill_general;
mod mesh; mod mesh;
pub mod mesh_distance;
mod plane; mod plane;
mod profile; mod profile;
pub mod tess; 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 corner_g2;
mod curveblend; mod curveblend;
mod delete_face; mod delete_face;
mod distance;
#[cfg(test)] #[cfg(test)]
mod edge_polyline_tests; mod edge_polyline_tests;
mod emboss; mod emboss;
@@ -149,6 +150,7 @@ pub use continuity::{
}; };
pub use corner_g2::{corner_g2_patch, seam_error}; pub use corner_g2::{corner_g2_patch, seam_error};
pub use delete_face::delete_faces; 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 emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
pub use fair::{ pub use fair::{
FairCurve, FairNet, FairOptions, FairReport, MAX_FAIR_ITERATIONS, MAX_FAIR_POINTS, fair_bspline_curve, fair_bspline_surface, fair_curve, 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. /// 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> { pub fn face_surface(b: &Body, face: usize) -> Option<FaceSurface> {
let f = b.solid.face_iter().nth(face)?; surface_of(b.solid.face_iter().nth(face)?, b.size())
let tol = (b.size() * 1e-7).max(1e-9) * 100.0; }
/// 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)? { Some(match surf_of(f, tol)? {
Surf::Plane { n, d } => FaceSurface::Plane { normal: n, point: n * d }, Surf::Plane { n, d } => FaceSurface::Plane { normal: n, point: n * d },
Surf::Cylinder { o, a, r, .. } => FaceSurface::Cylinder { origin: o, axis: a, radius: r }, 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>, result: Option<Value>,
overlaps: Vec<Mesh>, overlaps: Vec<Mesh>,
of: Vec<Sel>, 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. /// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline.
Trace(Box<TraceForm>), 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.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
"joint.cam" => crate::dialogs_coupling::cam::start(app), "joint.cam" => crate::dialogs_coupling::cam::start(app),
"inspect.interference" => { "inspect.interference" => (
(Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]) Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into() },
} vec![SelInput::new("Bodies (all if none)", BODIES, true)],
),
_ => return None, _ => return None,
}; };
Some((Kind::Assembly(asm), inputs)) 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::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::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(); 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, ""); row_label(ui, "");
let stale = result.is_some() && *of != picked; let stale = result.is_some() && *of != picked;
if ui.button(if stale { "Compute again" } else { "Compute" }).clicked() { 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); *result = Some(r);
*overlaps = o; *overlaps = o;
*of = picked; *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() 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. /// Run the check (overlaps, pairs closer than the clearance, each body's nearest neighbour) and
fn interference(app: &mut SolveApp, picked: &[Sel]) -> (Value, Vec<Mesh>) { /// 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 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, Ok(v) => v,
Err(e) => return (json!({ "error": e }), Vec::new()), Err(e) => return (json!({ "error": e }), Vec::new()),
}; };
let st = app.session.world_state(); let st = app.session.world_state();
let mut meshes = Vec::new(); 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 }; 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) 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)) && 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(); ui.end_row();
return; 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"); 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(); ui.end_row();
let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default(); 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() { if hits.is_empty() && unchecked.is_empty() {
row_label(ui, "Result"); row_label(ui, "Result");
ui.label(RichText::new(crate::i18n::tr_label("no interference")).color(t.text_dim)); ui.label(RichText::new(crate::i18n::tr_label("no interference")).color(t.text_dim));
ui.end_row(); 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; return;
} }
if !hits.is_empty() { 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 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, made. Contact sets stop motion where bodies would pass through each other,
**Motion Study** keys joint values along a timeline, and **Interference** shows overlapping **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 **Gear**, **Rack and Pinion** and **Screw** (on the **ASSEMBLE** panel) are motion links whose
ratio comes from the parts rather than a typed number: ratio comes from the parts rather than a typed number: