From 186555dbe937dd9df4391123b6fb073d5bdfd293 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:54:39 +0200 Subject: [PATCH] Clearance report: overlaps, near pairs and nearest bodies (#1900) Thanks @NOWN-DEV. --- Cargo.lock | 1 + assets/locales/en.json | 3 + assets/locales/ja.json | 3 + assets/locales/zh.json | 3 + crates/doc/src/eval.rs | 4 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/clearance.rs | 456 +++++++++++++++++++++++ crates/engine/src/cmd/clearance_tests.rs | 253 +++++++++++++ crates/engine/src/cmd/joints.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/geom/src/bvh.rs | 47 +++ crates/geom/src/lib.rs | 1 + crates/geom/src/mesh_distance.rs | 261 +++++++++++++ crates/kernel/src/distance.rs | 312 ++++++++++++++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/surface_kind.rs | 8 +- crates/ui-egui/src/dialogs_assembly.rs | 66 +++- docs/book/src/guide/assemblies.md | 20 +- 18 files changed, 1428 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/cmd/clearance.rs create mode 100644 crates/engine/src/cmd/clearance_tests.rs create mode 100644 crates/geom/src/mesh_distance.rs create mode 100644 crates/kernel/src/distance.rs diff --git a/Cargo.lock b/Cargo.lock index 7aca27e2..82a4f865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4135,6 +4135,7 @@ dependencies = [ "solidcraft-render", "solidcraft-sketch", "thiserror 2.0.21", + "web-time", ] [[package]] diff --git a/assets/locales/en.json b/assets/locales/en.json index f29d6140..de3d68aa 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 823200df..8a6f8a57 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "曲率コーム解析", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 1dac15f9..7cbf9d21 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "曲率梳分析", diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e626f98b..649f6b60 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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) } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 89d54bfd..58ab39c2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,3 +19,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +web-time = { workspace = true } diff --git a/crates/engine/src/cmd/clearance.rs b/crates/engine/src/cmd/clearance.rs new file mode 100644 index 00000000..add1b24f --- /dev/null +++ b/crates/engine/src/cmd/clearance.rs @@ -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, + unchecked: Option, +} + +fn clearance(s: &mut Session, p: &Value) -> Result { + let cmd = "inspect.clearance"; + let started = web_time::Instant::now(); + let param = |k: &str, default: f64, max: f64| -> Result { + 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> { + 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 = (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> = 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 = (0..n).filter(|&i| flag(&used, i)).collect(); + let meshes: Vec>> = { + 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> = (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 = (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 = 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 = 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 { + 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 { + 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>>, +) -> BTreeMap> { + 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)> = 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; diff --git a/crates/engine/src/cmd/clearance_tests.rs b/crates/engine/src/cmd/clearance_tests.rs new file mode 100644 index 00000000..5f1876e7 --- /dev/null +++ b/crates/engine/src/cmd/clearance_tests.rs @@ -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}"); + } +} diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 09a90321..962af8f8 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -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 = (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), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7380d9d..1a78ddae 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/geom/src/bvh.rs b/crates/geom/src/bvh.rs index 24c5dc80..816fa5ad 100644 --- a/crates/geom/src/bvh.rs +++ b/crates/geom/src/bvh.rs @@ -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 { diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 2ad4f3d8..ab75d895 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/mesh_distance.rs b/crates/geom/src/mesh_distance.rs new file mode 100644 index 00000000..31653917 --- /dev/null +++ b/crates/geom/src/mesh_distance.rs @@ -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, + 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 { + 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 { + let mut best: Option = 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 { + 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"); + } +} diff --git a/crates/kernel/src/distance.rs b/crates/kernel/src/distance.rs new file mode 100644 index 00000000..29cd5920 --- /dev/null +++ b/crates/kernel/src/distance.rs @@ -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, + edges: Vec, + surfaces: Vec>>, + face_tris: OnceLock>>, +} + +/// 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 = 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 { + self.set.closest(&other.set, limit) + } + + fn surface(&self, face: usize) -> Option { + *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> = 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 = 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 { + 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 { + 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, +} + +/// 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> { + 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 = 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, + })) +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b2be9b2f..69c79cdf 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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, diff --git a/crates/kernel/src/surface_kind.rs b/crates/kernel/src/surface_kind.rs index 2982fb59..5f5cb47f 100644 --- a/crates/kernel/src/surface_kind.rs +++ b/crates/kernel/src/surface_kind.rs @@ -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 { - 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 { + 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 }, diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 90c7659f..75136aec 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -78,6 +78,8 @@ pub enum Asm { result: Option, overlaps: Vec, of: Vec, + /// 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), @@ -189,9 +191,10 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { } "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 = 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::() + 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) { +/// 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) { let names: Vec = 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() { diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 311545ff..6672bbf1 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -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: