fix(kernel): a small overlap is not taken for bodies apart or inside (#484)
When sample points say two bodies are apart or one is inside the other, the facetings are now checked for surfaces passing through each other; if they do, the boolean is computed instead of shortcut. If it still can't be intersected, the sampled answer is kept as a flagged, unverified result, and Interference lists such pairs as "could not be checked" with the reason. Fixes #442 and #443. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
340ac0313a
commit
3159fc0dd0
8 files changed
+406
-25
No files matched your search
@@ -602,3 +602,26 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
|
||||
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
|
||||
#[test]
|
||||
fn loft_intersect_that_trims_one_edge_changes_the_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"}));
|
||||
for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"}));
|
||||
// r 35 up to z = 9, then a frustum to r 34 at z = 10.
|
||||
let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0);
|
||||
let got = volume(&mut s);
|
||||
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||
let m = measure(&mut s);
|
||||
assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}");
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -531,7 +531,7 @@ fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bodies.len() > 200 {
|
||||
return Err(bad(cmd, "too many bodies (200 at most)"));
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
|
||||
for (i, a) in bodies.iter().enumerate() {
|
||||
for b in bodies.iter().skip(i + 1) {
|
||||
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
@@ -544,14 +544,34 @@ fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if apart {
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(o)) = solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect) else { continue };
|
||||
let Ok(m) = solidcraft_kernel::measure(&o) else { continue };
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()}))
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -151,6 +151,57 @@ fn rigid_cycles_solve_and_conflicts_report() {
|
||||
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
|
||||
}
|
||||
|
||||
/// A 30 x 30 x 3 plate with a through slot whose x = 20 wall carries one rib (0.3 tall, 0.8
|
||||
/// wide), and a peg in the slot with 0.15 clearance all round.
|
||||
fn ribbed_slot_and_peg(s: &mut Session, peg_x1: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3, "body_name": "Plate"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": -1}));
|
||||
let slot = json!([[10, 5], [20, 5], [20, 14.6], [19.7, 14.6], [19.7, 15.4], [20, 15.4], [20, 25], [10, 25]]);
|
||||
run(s, "sketch.line", json!({"points": slot, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 5, "operation": "cut", "targets": ["Plate"]}));
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [10.15, 5.15], "p1": [peg_x1, 24.85]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3, "operation": "new", "body_name": "Peg"}));
|
||||
}
|
||||
|
||||
/// The peg overlaps only the rib: 0.15 x 0.8 x 3 = 0.36 mm³ (#442).
|
||||
#[test]
|
||||
fn interference_finds_a_peg_overlapping_a_small_rib() {
|
||||
let mut s = Session::default();
|
||||
ribbed_slot_and_peg(&mut s, 19.85);
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 0.36).abs() < 1e-6, "{i}");
|
||||
let (lo, hi) = (&i["interferences"][0]["bbox"]["min"], &i["interferences"][0]["bbox"]["max"]);
|
||||
assert!((lo[0].as_f64().unwrap_or(0.0) - 19.7).abs() < 1e-6 && (hi[0].as_f64().unwrap_or(0.0) - 19.85).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// An arc revolved up to a post ends on the post's wall, where the two bodies can't be
|
||||
/// intersected: the pair is listed as unchecked instead of passing as clear.
|
||||
#[test]
|
||||
fn interference_lists_a_pair_it_could_not_check() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "Post"}));
|
||||
run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": [4, 14, 2], "body_name": "Arc"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(unchecked.len(), 1, "{i}");
|
||||
assert!(unchecked[0]["reason"].as_str().is_some_and(|r| r.starts_with("not verified") && !r.contains("displacement")), "{i}");
|
||||
// Bodies that are clear of each other, or overlap plainly, leave the list empty.
|
||||
let mut s = two_cubes();
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_links_and_interference() {
|
||||
let mut s = two_cubes();
|
||||
|
||||
@@ -76,6 +76,40 @@ pub struct Mesh {
|
||||
pub edge_faces: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
fn boxes_meet(a: &Aabb3, b: &Aabb3) -> bool {
|
||||
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z
|
||||
}
|
||||
|
||||
/// Two triangles that pass through each other (see [`Mesh::crosses`]).
|
||||
fn triangles_cross(t: &[Vec3; 3], u: &[Vec3; 3], tol: f64) -> bool {
|
||||
// Signed distances of one triangle's corners from the other's plane, when they lie more
|
||||
// than `tol` to both sides of it.
|
||||
let sides = |tri: &[Vec3; 3], plane: &[Vec3; 3]| -> Option<(Vec3, [f64; 3])> {
|
||||
let n = (plane[1] - plane[0]).cross(plane[2] - plane[0]).normalized()?;
|
||||
let d = [n.dot(tri[0] - plane[0]), n.dot(tri[1] - plane[0]), n.dot(tri[2] - plane[0])];
|
||||
let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
(lo < -tol && hi > tol).then_some((n, d))
|
||||
};
|
||||
let (Some((nu, dt)), Some((nt, du))) = (sides(t, u), sides(u, t)) else { return false };
|
||||
let Some(line) = nt.cross(nu).normalized() else { return false };
|
||||
// Where a triangle's edges pass through the other's plane, measured along the line the
|
||||
// two planes share.
|
||||
let reach = |tri: &[Vec3; 3], d: &[f64; 3]| -> (f64, f64) {
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
let (p, q, dp, dq) = (tri[i], tri[j], d[i], d[j]);
|
||||
if (dp < 0.0) != (dq < 0.0) && dp != dq {
|
||||
let at = line.dot(p + (q - p) * (dp / (dp - dq)));
|
||||
lo = lo.min(at);
|
||||
hi = hi.max(at);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
};
|
||||
let ((t0, t1), (u0, u1)) = (reach(t, &dt), reach(u, &du));
|
||||
t1.min(u1) - t0.max(u0) > tol
|
||||
}
|
||||
|
||||
/// Mass properties of a closed mesh.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MeshMeasure {
|
||||
@@ -281,6 +315,86 @@ impl Mesh {
|
||||
dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2
|
||||
}
|
||||
|
||||
/// Whether the surfaces of two meshes pass through each other by more than `tol`: some
|
||||
/// triangle of one has corners more than `tol` to both sides of a triangle of the other,
|
||||
/// and the reverse, and they share a stretch of the line where their planes meet longer
|
||||
/// than `tol`. Faces that only touch, lie on each other or stop on the other surface don't
|
||||
/// count, nor do two facetings of one curved surface that are within `tol` of it. Bodies
|
||||
/// can still overlap without crossing: one inside the other, or sharing a slab between
|
||||
/// faces that are parallel or in one plane.
|
||||
pub fn crosses(&self, o: &Mesh, tol: f64) -> bool {
|
||||
if !tol.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let tol = tol.max(0.0);
|
||||
let (ba, bb) = (self.bounds(), o.bounds());
|
||||
let region = Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
|
||||
if region.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let near = |m: &Mesh| -> Vec<([Vec3; 3], Aabb3)> {
|
||||
m.triangles
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let t = m.tri(t)?;
|
||||
let mut bx = Aabb3::EMPTY;
|
||||
t.iter().for_each(|p| bx.add(*p));
|
||||
(!bx.is_empty() && boxes_meet(&bx, ®ion)).then_some((t, bx))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (ta, tb) = (near(self), near(o));
|
||||
if ta.is_empty() || tb.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// A grid over the shared box holds the other mesh's triangles.
|
||||
let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 24);
|
||||
let size = region.size();
|
||||
let cell = |v: f64, lo: f64, s: f64| -> usize { if s > 0.0 { (((v - lo) / s * n as f64).floor().max(0.0) as usize).min(n - 1) } else { 0 } };
|
||||
let span = |bx: &Aabb3| -> [(usize, usize); 3] {
|
||||
[
|
||||
(cell(bx.min.x, region.min.x, size.x), cell(bx.max.x, region.min.x, size.x)),
|
||||
(cell(bx.min.y, region.min.y, size.y), cell(bx.max.y, region.min.y, size.y)),
|
||||
(cell(bx.min.z, region.min.z, size.z), cell(bx.max.z, region.min.z, size.z)),
|
||||
]
|
||||
};
|
||||
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); n * n * n];
|
||||
for (i, (_, bx)) in tb.iter().enumerate() {
|
||||
let [x, y, z] = span(bx);
|
||||
for ix in x.0..=x.1 {
|
||||
for iy in y.0..=y.1 {
|
||||
for iz in z.0..=z.1 {
|
||||
if let Some(c) = cells.get_mut((ix * n + iy) * n + iz) {
|
||||
c.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per triangle of `o`: the last triangle of `self` tested against it, plus one.
|
||||
let mut seen = vec![0usize; tb.len()];
|
||||
for (i, (t, bx)) in ta.iter().enumerate() {
|
||||
let [x, y, z] = span(bx);
|
||||
for ix in x.0..=x.1 {
|
||||
for iy in y.0..=y.1 {
|
||||
for iz in z.0..=z.1 {
|
||||
for j in cells.get((ix * n + iy) * n + iz).into_iter().flatten() {
|
||||
let (Some(mark), Some((u, ub))) = (seen.get_mut(*j as usize), tb.get(*j as usize)) else { continue };
|
||||
if *mark == i + 1 {
|
||||
continue;
|
||||
}
|
||||
*mark = i + 1;
|
||||
if boxes_meet(bx, ub) && triangles_cross(t, u, tol) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
|
||||
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
|
||||
@@ -233,3 +233,55 @@ fn split_at_and_closest_param() {
|
||||
assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12);
|
||||
assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side.
|
||||
fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = max - min;
|
||||
let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]);
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side;
|
||||
c[(axis + 1) % 3] = a as f64 / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 / n as f64;
|
||||
at(c)
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshes_cross_only_where_their_surfaces_pass_through_each_other() {
|
||||
let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n);
|
||||
let a = cube(0.0, 0.0, 1);
|
||||
// A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap.
|
||||
for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] {
|
||||
assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// Apart, touching on a face or an edge, and the same body.
|
||||
for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] {
|
||||
assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// One inside the other, standing on a face, and sharing a face plane from inside.
|
||||
let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2);
|
||||
let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2);
|
||||
let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2);
|
||||
assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01));
|
||||
assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01));
|
||||
// A slab overlap between faces that are parallel or in one plane has no crossing.
|
||||
assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01));
|
||||
// Passing through by less than the tolerance doesn't count.
|
||||
assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01));
|
||||
assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN));
|
||||
}
|
||||
@@ -17,6 +17,9 @@ pub enum BooleanStrategy {
|
||||
PlanarFallback,
|
||||
ToolNudge,
|
||||
Piecewise,
|
||||
/// No retry gave a result, and the answer from sample points was kept although the bodies'
|
||||
/// facetings disagree with it.
|
||||
UnverifiedShortcut,
|
||||
}
|
||||
|
||||
/// Numerical adjustments used by a successful retry, in millimetres/radians.
|
||||
@@ -47,6 +50,10 @@ impl BooleanFallback {
|
||||
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
|
||||
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
|
||||
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
|
||||
BooleanStrategy::UnverifiedShortcut => format!(
|
||||
"not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored",
|
||||
self.tolerance_mm
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +73,12 @@ impl BooleanDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the result is not verified, when it ignores a place where the bodies seem to pass
|
||||
/// through each other ([`BooleanStrategy::UnverifiedShortcut`]).
|
||||
pub fn unverified(&self) -> Option<String> {
|
||||
self.events.iter().find(|event| event.strategy == BooleanStrategy::UnverifiedShortcut).map(BooleanFallback::description)
|
||||
}
|
||||
|
||||
pub fn warning(&self) -> Option<String> {
|
||||
if self.events.is_empty() && !self.truncated {
|
||||
return None;
|
||||
|
||||
+115
-16
@@ -89,11 +89,12 @@ fn mismatch(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh, result: &solid
|
||||
|
||||
/// How one body sits relative to another, from sample points: (fraction of `b`'s samples
|
||||
/// inside `a`, fraction of `a`'s samples inside `b`).
|
||||
fn overlap(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> Option<(f64, f64)> {
|
||||
if a.triangles.len() + b.triangles.len() > 80_000 {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib) = (a.inside_index(), b.inside_index());
|
||||
fn overlap(
|
||||
a: &solidcraft_geom::Mesh,
|
||||
b: &solidcraft_geom::Mesh,
|
||||
ia: &solidcraft_geom::InsideIndex,
|
||||
ib: &solidcraft_geom::InsideIndex,
|
||||
) -> Option<(f64, f64)> {
|
||||
let frac = |inner: &solidcraft_geom::Mesh, inner_i: &solidcraft_geom::InsideIndex, outer_i: &solidcraft_geom::InsideIndex| -> Option<f64> {
|
||||
let bx = inner.bounds();
|
||||
let s = bx.size();
|
||||
@@ -110,12 +111,59 @@ fn overlap(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> Option<(f64,
|
||||
}
|
||||
(n >= 50).then(|| hit as f64 / n as f64)
|
||||
};
|
||||
Some((frac(b, &ib, &ia)?, frac(a, &ia, &ib)?))
|
||||
Some((frac(b, ib, ia)?, frac(a, ia, ib)?))
|
||||
}
|
||||
|
||||
/// Whether some facet of `m` has material just under it (`depth` in from its middle) that is
|
||||
/// inside the other body (`inside`) or outside it. With `within`, only facets in that box.
|
||||
fn under_surface(
|
||||
m: &solidcraft_geom::Mesh,
|
||||
own: &solidcraft_geom::InsideIndex,
|
||||
other: &solidcraft_geom::InsideIndex,
|
||||
depth: f64,
|
||||
inside: bool,
|
||||
within: Option<&solidcraft_geom::Aabb3>,
|
||||
) -> bool {
|
||||
m.triangles.iter().filter_map(|t| m.tri(t)).any(|[p, q, r]| {
|
||||
let Some(n) = (q - p).cross(r - p).normalized() else { return false };
|
||||
let c = (p + q + r) * (1.0 / 3.0) - n * depth;
|
||||
let near = within.is_none_or(|b| c.x >= b.min.x && c.x <= b.max.x && c.y >= b.min.y && c.y <= b.max.y && c.z >= b.min.z && c.z <= b.max.z);
|
||||
// A wall thinner than `depth` puts the point outside its own body: no evidence.
|
||||
near && other.contains(c) == inside && own.contains(c)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the bodies' facetings show that the samples' answer (see [`trivial`]) is wrong:
|
||||
/// their surfaces pass through each other, or a facet of one has material under it on the
|
||||
/// wrong side of the other. Overlaps and gaps shallower than `tol` are not seen.
|
||||
fn contradicted(
|
||||
(ma, ia): (&solidcraft_geom::Mesh, &solidcraft_geom::InsideIndex),
|
||||
(mb, ib): (&solidcraft_geom::Mesh, &solidcraft_geom::InsideIndex),
|
||||
(disjoint, b_in_a, a_in_b): (bool, bool, bool),
|
||||
tol: f64,
|
||||
) -> bool {
|
||||
if !(disjoint || b_in_a || a_in_b) {
|
||||
return false;
|
||||
}
|
||||
if ma.crosses(mb, tol) {
|
||||
return true;
|
||||
}
|
||||
if disjoint {
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let shared = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
|
||||
return !shared.is_empty() && (under_surface(ma, ia, ib, tol, true, Some(&shared)) || under_surface(mb, ib, ia, tol, true, Some(&shared)));
|
||||
}
|
||||
(a_in_b && under_surface(ma, ia, ib, tol, false, None)) || (b_in_a && !a_in_b && under_surface(mb, ib, ia, tol, false, None))
|
||||
}
|
||||
|
||||
/// Booleans that need no intersection: one body clear of the other, or inside it. These are
|
||||
/// common with coincident curved faces (a hole drilled where one already is, a body joined
|
||||
/// with itself), which the intersection can't handle. `counts`: the operands' faces and edges.
|
||||
/// `tol`: how far the meshes' surfaces may pass through each other and still count as touching
|
||||
/// (they are facetings of the bodies, so a few times their tolerance).
|
||||
///
|
||||
/// The second value is false when the facetings contradict the samples ([`contradicted`]):
|
||||
/// the answer is then only what to fall back on if the bodies can't be intersected.
|
||||
fn trivial(
|
||||
a: &Body,
|
||||
b: &Body,
|
||||
@@ -123,12 +171,21 @@ fn trivial(
|
||||
ma: &solidcraft_geom::Mesh,
|
||||
mb: &solidcraft_geom::Mesh,
|
||||
counts: &[(usize, usize)],
|
||||
) -> Option<Option<Body>> {
|
||||
let (b_in_a, a_in_b) = overlap(ma, mb)?;
|
||||
tol: f64,
|
||||
) -> Option<(Option<Body>, bool)> {
|
||||
if ma.triangles.len() + mb.triangles.len() > 80_000 {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib) = (ma.inside_index(), mb.inside_index());
|
||||
let (b_in_a, a_in_b) = overlap(ma, mb, &ia, &ib)?;
|
||||
const NONE: f64 = 0.003;
|
||||
const ALL: f64 = 0.997;
|
||||
let disjoint = b_in_a < NONE && a_in_b < NONE;
|
||||
match op {
|
||||
// The samples only say that nearly all of one body is clear of, or inside, the other. A
|
||||
// small part can still be on the wrong side (a peg overlapping a rib, a chamfer taken off
|
||||
// one edge), and then the bodies have to be intersected.
|
||||
let sure = !contradicted((ma, &ia), (mb, &ib), (disjoint, b_in_a > ALL, a_in_b > ALL), tol);
|
||||
let answer = match op {
|
||||
// Sampling only selects this candidate. Validate every shell boundary and the
|
||||
// complete material/void nesting before preserving a disconnected union.
|
||||
BoolOp::Union if disjoint => {
|
||||
@@ -151,7 +208,8 @@ fn trivial(
|
||||
Some(Some(candidate))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
answer.map(|r| (r, sure))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -167,13 +225,40 @@ mod shortcut_tests {
|
||||
let other = crate::sphere(direction * distance, 10.0).unwrap();
|
||||
let other_mesh = other.tessellate(0.03).unwrap();
|
||||
let counts = record::counts(&[&outer, &other]);
|
||||
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts).is_none());
|
||||
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts).is_none());
|
||||
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts, 0.09).is_none());
|
||||
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts, 0.09).is_none());
|
||||
}
|
||||
let protruding = crate::sphere(direction * 9.001, 1.0).unwrap();
|
||||
let protruding_mesh = protruding.tessellate(0.03).unwrap();
|
||||
let counts = record::counts(&[&outer, &protruding]);
|
||||
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts).is_none());
|
||||
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts, 0.09).is_none());
|
||||
}
|
||||
|
||||
/// Bodies sharing less than the samples can see still meet: no shortcut, and the boolean
|
||||
/// gives the shared part.
|
||||
#[test]
|
||||
fn a_small_overlap_is_not_taken_for_apart_or_inside() {
|
||||
let cube = |x: f64, y: f64| crate::box_solid(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0)).unwrap();
|
||||
let a = cube(0.0, 0.0);
|
||||
// A 0.01 slab between parallel faces (0.1 % of each cube), and a 0.02 x 0.02 corner.
|
||||
for (b, want) in [(cube(9.99, 0.0), 1.0), (cube(9.98, 9.98), 0.004)] {
|
||||
let (ma, mb) = (a.tessellate(0.001).unwrap(), b.tessellate(0.001).unwrap());
|
||||
let counts = record::counts(&[&a, &b]);
|
||||
for op in [BoolOp::Intersect, BoolOp::Cut, BoolOp::Union] {
|
||||
assert!(trivial(&a, &b, op, &ma, &mb, &counts, 0.003).is_none_or(|(_, sure)| !sure), "{op:?} {want}");
|
||||
}
|
||||
let shared = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap();
|
||||
assert!((crate::measure(&shared).unwrap().volume - want).abs() < 1e-9, "{want}");
|
||||
let cut = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((crate::measure(&cut).unwrap().volume - (1000.0 - want)).abs() < 1e-9, "{want}");
|
||||
}
|
||||
// Touching on a face, and one cube well inside the other, still take the shortcut.
|
||||
let (touching, inner) = (cube(10.0, 0.0), crate::box_solid(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0)).unwrap());
|
||||
let ma = a.tessellate(0.001).unwrap();
|
||||
for (b, op) in [(&touching, BoolOp::Intersect), (&inner, BoolOp::Intersect), (&inner, BoolOp::Union)] {
|
||||
let counts = record::counts(&[&a, b]);
|
||||
assert!(trivial(&a, b, op, &ma, &b.tessellate(0.001).unwrap(), &counts, 0.003).is_some_and(|(_, sure)| sure), "{op:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,10 +423,16 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
(consistent, v)
|
||||
};
|
||||
// One body clear of or inside the other: no intersection needed.
|
||||
// Kept for the end when the facetings disagree with it: bodies that touch along curved
|
||||
// faces can pass through each other slightly, and often can't be intersected.
|
||||
let mut doubted = None;
|
||||
if let (Some(ma), Some(mb)) = &meshes
|
||||
&& let Some(r) = trivial(a, b, op, ma, mb, &counts)
|
||||
&& let Some((r, sure)) = trivial(a, b, op, ma, mb, &counts, 3.0 * tol_m)
|
||||
{
|
||||
return Ok(r);
|
||||
if sure {
|
||||
return Ok(r);
|
||||
}
|
||||
doubted = Some(r);
|
||||
}
|
||||
let mut last = String::new();
|
||||
// Coincident faces make the intersection fail after many retries; push them apart first.
|
||||
@@ -522,7 +613,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
Err(e) => last = format!("{last}; nudged: {e}"),
|
||||
}
|
||||
}
|
||||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||||
match doubted {
|
||||
Some(r) => {
|
||||
let mut event = BooleanFallback::new(BooleanStrategy::UnverifiedShortcut);
|
||||
event.tolerance_mm = 3.0 * tol_m;
|
||||
record(event);
|
||||
Ok(r)
|
||||
}
|
||||
None => Err(KernelError::Failed(format!("boolean {op:?}: {last}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `r`, a boolean of the target and `nb` (the tool nudged), with its history over the target and
|
||||
|
||||
@@ -635,20 +635,29 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
|
||||
row_label(ui, "Checked");
|
||||
ui.label(format!("{} bodies", r["checked"].as_u64().unwrap_or(0)));
|
||||
ui.end_row();
|
||||
if hits.is_empty() {
|
||||
let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
if hits.is_empty() && unchecked.is_empty() {
|
||||
row_label(ui, "Result");
|
||||
ui.label(RichText::new("no interference").color(t.text_dim));
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
row_label(ui, "Interferes");
|
||||
ui.label(RichText::new("Volume").strong());
|
||||
ui.end_row();
|
||||
if !hits.is_empty() {
|
||||
row_label(ui, "Interferes");
|
||||
ui.label(RichText::new("Volume").strong());
|
||||
ui.end_row();
|
||||
}
|
||||
for h in hits {
|
||||
row_label(ui, &format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or("")));
|
||||
ui.label(RichText::new(crate::prefs::show_mm(app, h["volume_mm3"].as_f64().unwrap_or(0.0), 3)).color(t.error));
|
||||
ui.end_row();
|
||||
}
|
||||
// Pairs the check could not work out: not clear, not known to interfere.
|
||||
for u in unchecked {
|
||||
row_label(ui, &format!("{} · {}", u["a"].as_str().unwrap_or(""), u["b"].as_str().unwrap_or("")));
|
||||
ui.label(RichText::new("could not be checked").color(t.warning)).on_hover_text(u["reason"].as_str().unwrap_or(""));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
Reference in new issue
Block a user