diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 6e94d472..09a90321 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -73,7 +73,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").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) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", + "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", ), ]; @@ -1156,6 +1156,16 @@ fn interference(s: &mut Session, p: &Value) -> Result { let (mut hits, mut unchecked) = (Vec::new(), Vec::new()); for (i, j) in pairs { let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue }; + let threshold = 1e-9 * a.body.size().max(1.0).powi(3); + // A pin a hair off the axis of its hole: the crescent the walls share, which the + // boolean can't intersect. + if let Some(o) = solidcraft_kernel::coaxial_overlap(&a.body, &b.body) { + if o.volume > threshold { + hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid, + "bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}})); + } + 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)); @@ -1178,7 +1188,7 @@ fn interference(s: &mut Session, p: &Value) -> Result { continue; } }; - if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) { + if m.volume > threshold { 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}})); } } diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index f0b42b4e..4c5e4a6c 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -1452,3 +1452,31 @@ mod cam; // Plots and interference of motion studies, on the four-bar and slider-crank above. #[path = "motion_analysis_tests.rs"] mod motion_analysis; + +/// A pin through a hole of its own radius whose axis is a hair off the hole's (parts placed from +/// rounded coordinates): the walls are all but tangent along two lines, which the boolean can't +/// intersect. Each offset gets a checked answer: clear on the axis and within the kernel's +/// same-surface tolerance, and otherwise the crescent the pin shares with the plate, against +/// the textbook lens of two circles. The pin is chamfered, so it is not a plain prism. +#[test] +fn interference_of_a_pin_a_hair_off_its_hole() { + let crescent = |r: f64, e: f64| std::f64::consts::PI * r * r - (2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt()); + for offset in [0.0, 1e-6, 5.9e-4, 1e-2] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 5, "corner": [-10, -10, 0], "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 9, "base": [offset, 0, -2], "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.chamfer", json!({"edges": [[offset + 3.0, 0, 7]], "distance": 0.5, "body": "Pin"})); + let i = run(&mut s, "inspect.interference", json!({"bodies": ["Plate", "Pin"]})); + assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{offset}: {i}"); + if offset < 1e-5 { + assert_eq!(i["count"], 0, "{offset}: {i}"); + continue; + } + assert_eq!(i["count"], 1, "{offset}: {i}"); + let want = 5.0 * crescent(3.0, offset); + let got = i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0); + assert!((got - want).abs() < 1e-6 * want, "{offset}: {got} against {want}: {i}"); + assert!((i["interferences"][0]["near_coaxial"]["offset_mm"].as_f64().unwrap_or(0.0) - offset).abs() < 1e-9, "{i}"); + } +} diff --git a/crates/kernel/src/coaxial_overlap.rs b/crates/kernel/src/coaxial_overlap.rs new file mode 100644 index 00000000..378a4fbe --- /dev/null +++ b/crates/kernel/src/coaxial_overlap.rs @@ -0,0 +1,424 @@ +//! The shared volume of a pin in a hole of (nearly) its own size whose axis is a hair off the +//! hole's (parts placed from rounded coordinates). +//! +//! The pin's wall and the hole's wall then meet along two lines where they are all but tangent, +//! which the boolean cannot intersect, and the facets of the two walls pass through each other +//! by less than the boolean's sampled shortcuts can see. So the overlap is worked out in two +//! parts instead. The pin's wall is moved off the hole's by a clear step (`offset_body`) and the +//! bodies are intersected as they then are, which the boolean can do: narrowed, that gives the +//! overlap away from the walls (the core). In the band the move swept, what the two walls +//! share is a crescent in every section, whose area follows from the two circles alone. The +//! band is assumed to hold nothing but the two walls; that is checked by intersecting with the +//! pin widened by half the step and by the whole step, whose volumes must come out as the core +//! plus the crescents those wider circles give. Where they don't (a cross hole through the +//! band, a hole that is not round all the way, a pin end inside the hole), the overlap is not +//! answered here and the caller takes the general boolean. + +use solidcraft_geom::{Aabb3, Vec3}; + +use crate::body::Body; +use crate::{BoolOp, FaceSurface}; + +/// The overlap of two bodies with a pin of one in a hole of the other, worked out as above. +#[derive(Clone, Debug)] +pub struct CoaxialOverlap { + pub volume: f64, + /// Approximate: the core's area plus the crescents' walls and ends. + pub area: f64, + pub centroid: Vec3, + pub bbox: Aabb3, + /// The largest offset between a pin's axis and its hole's, and the length they share. + pub offset: f64, + pub length: f64, +} + +/// Largest axis offset plus radius difference handled, as a share of the radius. +const NEAR: f64 = 0.01; +/// The step the pin's wall is moved by, as a share of its radius. +const STEP: f64 = 0.1; + +/// The faces of one body on one cylinder, the side its material is on and their extent along +/// the axis. +#[derive(Clone, Debug)] +struct Wall { + faces: Vec, + origin: Vec3, + axis: Vec3, + radius: f64, + /// Material inside the cylinder (a pin), not outside it (a hole). + boss: bool, + /// Intervals along `axis` from `origin`, one per face. + spans: Vec<(f64, f64)>, +} + +/// A pin of one body (`pin_in_a`) in a hole of the other, and the length they share. +#[derive(Clone, Debug)] +struct Pair { + pin: Wall, + pin_in_a: bool, + hole: Wall, + /// The hole's axis seen from the pin's, across it, and how far. + toward: Vec3, + offset: f64, + /// Where they share length along the pin's axis. + shared: Vec<(f64, f64)>, + step: f64, +} + +/// The overlap of `a` and `b` when a pin of one sits in a hole of the other on nearly the same +/// cylinder (axes parallel, offset plus radius difference within 1 % of the radius, but not on +/// the same surface), worked out as the module says. `None` when there is no such pin, or the +/// bodies hold more than the pin and the hole where the work assumes they don't: the caller +/// then takes the general boolean. +pub fn coaxial_overlap(a: &Body, b: &Body) -> Option { + if a.is_mesh() || b.is_mesh() { + return None; + } + crate::guard("near-coaxial overlap", || Ok(overlap(a, b))).ok().flatten() +} + +fn overlap(a: &Body, b: &Body) -> Option { + let size = a.size().max(b.size()); + if !(size.is_finite() && size > 0.0) { + return None; + } + // The same surface as the kernel takes it elsewhere: walls that close are one wall. + let same = (size * 1e-7).max(1e-9) * 10.0; + // Meshed only when two cylinders are close enough to be asked about. + let (ca, cb) = (cylinders(a), cylinders(b)); + let close = |x: &Wall, y: &Wall| x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && near(x, y, same).is_some(); + if !ca.iter().any(|x| cb.iter().any(|y| close(x, y))) { + return None; + } + let (wa, wb) = (walls(a, ca, same)?, walls(b, cb, same)?); + let mut pairs = Vec::new(); + for x in &wa { + for y in &wb { + if x.axis.dot(y.axis).abs() <= 1.0 - 1e-9 { + continue; + } + let (pin, hole, pin_in_a) = match (x.boss, y.boss) { + (true, false) => (x, y, true), + (false, true) => (y, x, false), + // Two pins or two holes on nearly one cylinder: not this case. + _ if near(x, y, same).is_some() => return None, + _ => continue, + }; + let Some((toward, offset)) = near(pin, hole, same) else { continue }; + let shared = intersect(&union(&pin.spans), &union(&project(hole, pin))); + if total(&shared) <= same { + continue; + } + let step = (pin.radius * STEP).min(size * 0.02); + pairs.push(Pair { pin: pin.clone(), pin_in_a, hole: hole.clone(), toward, offset, shared, step }); + } + } + if pairs.is_empty() { + return None; + } + // A pin's faces move together: one step for each of them (the pin's walls do not repeat + // across pairs unless one pin is near two holes, which the check below then answers). + let shared_at = |k: f64| -> Option<(f64, Option)> { + let moved = |body: &Body, in_a: bool| -> Option { + let mine: Vec<&Pair> = pairs.iter().filter(|p| p.pin_in_a == in_a).collect(); + if mine.is_empty() { + return Some(body.clone()); + } + let shift = |i: usize, _| mine.iter().find(|p| p.pin.faces.contains(&i)).map(|p| p.step * k).unwrap_or(0.0); + let m = crate::offset::offset_body(body, shift).ok()?; + m.validity().is_empty().then_some(m) + }; + let (ma, mb) = (moved(a, true)?, moved(b, false)?); + let (r, report) = crate::capture_boolean_diagnostics(|| crate::ops::boolean(&ma, &mb, BoolOp::Intersect)); + if report.unverified().is_some() || report.warning().is_some() { + return None; + } + match r.ok()? { + None => Some((0.0, None)), + Some(body) => Some((crate::measure(&body).ok()?.volume, Some(body))), + } + }; + // The crescents' volume with every pin grown by `k` steps. + let crescents = + |k: f64| -> f64 { pairs.iter().map(|p| total(&p.shared) * crescent(p.pin.radius + p.step * k, p.hole.radius, p.offset).area).sum() }; + let (core, core_body) = shared_at(-1.0)?; + for k in [0.5, 1.0] { + let (v, _) = shared_at(k)?; + let gain = crescents(k) - crescents(-1.0); + if !((v - core - gain).abs() <= 0.02 * gain + 1e-4 * v.abs() + 1e-12 * size.powi(3)) { + return None; + } + } + // The band held the two walls only: the overlap is the core plus the crescents. + let mut volume = core; + let mut moment = Vec3::ZERO; + let mut area = 0.0; + let mut bbox = Aabb3::EMPTY; + if let Some(body) = &core_body + && let Ok(m) = crate::measure(body) + { + moment = m.centroid * m.volume; + area = m.area; + bbox = m.bbox; + volume = m.volume; + } + for p in &pairs { + let c = crescent(p.pin.radius, p.hole.radius, p.offset); + let length = total(&p.shared); + // Less what the narrowed pin already reached (nothing, unless the hole is the smaller). + let v = length * c.area - length * crescent(p.pin.radius - p.step, p.hole.radius, p.offset).area; + let mid = p.shared.iter().map(|(s, t)| (s + t) * 0.5 * (t - s)).sum::() / length; + let side = p.toward.cross(p.pin.axis); + moment += (p.pin.origin + p.pin.axis * mid + p.toward * c.centroid) * v; + volume += v; + area += p.shared.len() as f64 * 2.0 * c.area + length * c.perimeter; + for (s, t) in &p.shared { + for q in crescent_outline(p.pin.radius, p.hole.radius, p.offset) { + let at = p.pin.origin + p.toward * q.0 + side * q.1; + bbox.add(at + p.pin.axis * *s); + bbox.add(at + p.pin.axis * *t); + } + } + } + let centroid = if volume > 0.0 { moment / volume } else { bbox.center() }; + let offset = pairs.iter().map(|p| p.offset).fold(0.0, f64::max); + let length = pairs.iter().map(|p| total(&p.shared)).sum(); + Some(CoaxialOverlap { volume: volume.max(0.0), area, centroid, bbox, offset, length }) +} + +/// The pin's offset to the hole, when the two are nearly on one cylinder but not on the same +/// surface: the unit direction from the pin's axis to the hole's (any, for one axis) and the +/// distance. +fn near(pin: &Wall, hole: &Wall, same: f64) -> Option<(Vec3, f64)> { + let d = hole.origin - pin.origin; + let across = d - pin.axis * d.dot(pin.axis); + let offset = across.len(); + let gap = (pin.radius - hole.radius).abs(); + if offset <= same && gap <= same { + return None; + } + if !(offset + gap <= NEAR * pin.radius.min(hole.radius)) { + return None; + } + let toward = across.normalized().unwrap_or_else(|| pin.axis.any_perp()); + Some((toward, offset)) +} + +/// The body's cylindrical faces, each as a wall of its own with no side or extent yet. +fn cylinders(b: &Body) -> Vec { + (0..b.face_count()) + .filter_map(|i| match crate::face_surface(b, i)? { + FaceSurface::Cylinder { origin, axis, radius } if origin.is_finite() && radius.is_finite() && radius > 0.0 => { + Some(Wall { faces: vec![i], origin, axis: axis.normalized()?, radius, boss: false, spans: Vec::new() }) + } + _ => None, + }) + .collect() +} + +/// The cylindrical walls of a body from its `cylinders`: its faces on each cylinder, which side +/// its material is on and where they run along the axis. `None` when the body can't be meshed. +fn walls(b: &Body, cylinders: Vec, same: f64) -> Option> { + let mut out: Vec = Vec::new(); + let mesh = b.tessellate_with((b.size() * 1e-3).max(1e-4), true).ok()?; + for Wall { faces, origin, axis, radius, .. } in cylinders { + let Some(&face) = faces.first() else { continue }; + // Which way the face looks: out of the axis for a pin, toward it for a hole. + let (mut facing, mut lo, mut hi) = (0.0, f64::INFINITY, f64::NEG_INFINITY); + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *f as usize != face { + continue; + } + let Some([p, q, r]) = mesh.tri(t) else { continue }; + let c = (p + q + r) / 3.0; + let radial = (c - origin) - axis * (c - origin).dot(axis); + facing += (q - p).cross(r - p).dot(radial); + for x in [p, q, r] { + let s = (x - origin).dot(axis); + lo = lo.min(s); + hi = hi.max(s); + } + } + if !(lo < hi) || facing == 0.0 { + continue; + } + let boss = facing > 0.0; + let wall = Wall { faces: vec![face], origin, axis, radius, boss, spans: vec![(lo, hi)] }; + match out.iter_mut().find(|w| w.boss == boss && on_one_cylinder(w, &wall, same)) { + Some(w) => { + let s = (origin - w.origin).dot(w.axis) * if axis.dot(w.axis) < 0.0 { -1.0 } else { 1.0 }; + let (a, b) = if axis.dot(w.axis) < 0.0 { (-hi, -lo) } else { (lo, hi) }; + w.faces.push(face); + w.spans.push((a + s, b + s)); + } + None => out.push(wall), + } + } + Some(out) +} + +fn on_one_cylinder(x: &Wall, y: &Wall, same: f64) -> bool { + let d = y.origin - x.origin; + x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < same && (d - x.axis * d.dot(x.axis)).len() < same +} + +/// `wall`'s spans along `onto`'s axis. +fn project(wall: &Wall, onto: &Wall) -> Vec<(f64, f64)> { + let flip = wall.axis.dot(onto.axis) < 0.0; + let s = (wall.origin - onto.origin).dot(onto.axis); + wall.spans.iter().map(|&(lo, hi)| if flip { (s - hi, s - lo) } else { (s + lo, s + hi) }).collect() +} + +/// Sorted, disjoint intervals covering `spans`. +fn union(spans: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut v: Vec<(f64, f64)> = spans.iter().copied().filter(|(a, b)| a < b).collect(); + v.sort_by(|x, y| x.0.total_cmp(&y.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len()); + for (a, b) in v { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +fn intersect(x: &[(f64, f64)], y: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + for &(a, b) in x { + for &(c, d) in y { + let (lo, hi) = (a.max(c), b.min(d)); + if lo < hi { + out.push((lo, hi)); + } + } + } + out +} + +fn total(spans: &[(f64, f64)]) -> f64 { + spans.iter().map(|(a, b)| b - a).sum() +} + +/// The part of a disc of radius `pin` outside a disc of radius `hole` whose centre is `offset` +/// away along +x: its area, its centroid's x (y is 0) and its perimeter. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Crescent { + pub(crate) area: f64, + pub(crate) centroid: f64, + pub(crate) perimeter: f64, +} + +pub(crate) fn crescent(pin: f64, hole: f64, offset: f64) -> Crescent { + use std::f64::consts::PI; + let (r, q, e) = (pin.max(0.0), hole.max(0.0), offset.abs()); + let disc = PI * r * r; + if e >= r + q { + return Crescent { area: disc, centroid: 0.0, perimeter: 2.0 * PI * r }; + } + if e <= (r - q).abs() { + return if q >= r { + Crescent { area: 0.0, centroid: 0.0, perimeter: 0.0 } + } else { + let area = disc - PI * q * q; + Crescent { area, centroid: -PI * q * q * e / area, perimeter: 2.0 * PI * (r + q) } + }; + } + // Half-angles of the lens's two segments, seen from each centre. + let alpha = ((e * e + r * r - q * q) / (2.0 * e * r)).clamp(-1.0, 1.0).acos(); + let beta = ((e * e + q * q - r * r) / (2.0 * e * q)).clamp(-1.0, 1.0).acos(); + let segment = |radius: f64, half: f64| { + let area = radius * radius * (half - half.sin() * half.cos()); + let w = 2.0 * half - (2.0 * half).sin(); + let from_centre = if w > 1e-12 { 4.0 * radius * half.sin().powi(3) / (3.0 * w) } else { radius }; + (area, from_centre) + }; + let (sa, da) = segment(r, alpha); + let (sb, db) = segment(q, beta); + let lens = sa + sb; + let area = (disc - lens).max(0.0); + let moment = -(sa * da + sb * (e - db)); + let centroid = if area > 0.0 { moment / area } else { 0.0 }; + Crescent { area, centroid, perimeter: r * (2.0 * PI - 2.0 * alpha) + q * 2.0 * beta } +} + +/// Points around the crescent's outline (x toward the hole's centre, y across), for its box. +fn crescent_outline(pin: f64, hole: f64, offset: f64) -> Vec<(f64, f64)> { + let (r, q, e) = (pin, hole, offset); + let alpha = if e > 0.0 { ((e * e + r * r - q * q) / (2.0 * e * r)).clamp(-1.0, 1.0).acos() } else { 0.0 }; + let n = 32; + (0..=n) + .map(|i| { + let t = alpha + (2.0 * std::f64::consts::PI - 2.0 * alpha) * f64::from(i) / f64::from(n); + (r * t.cos(), r * t.sin()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The crescent against the textbook lens of two equal circles, and its limits. + #[test] + fn crescent_matches_the_lens_formula() { + let (r, e) = (3.0f64, 0.01f64); + let lens = 2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt(); + let c = crescent(r, r, e); + assert!((c.area - (std::f64::consts::PI * r * r - lens)).abs() < 1e-12, "{c:?}"); + // Thin: twice the radius times the offset, its centroid π/4 of the radius away from + // the hole's centre, its outline nearly two half circles. + assert!((c.area - 2.0 * r * e).abs() < 1e-6, "{c:?}"); + assert!((c.centroid + std::f64::consts::FRAC_PI_4 * r).abs() < 1e-2, "{c:?}"); + assert!((c.perimeter - 2.0 * std::f64::consts::PI * r).abs() < 1e-3, "{c:?}"); + assert_eq!(crescent(r, r, 0.0).area, 0.0); + // A pin with clearance in its hole shares nothing; a pin wider than its hole a ring. + assert_eq!(crescent(2.99, 3.0, 0.005).area, 0.0); + let ring = crescent(3.01, 3.0, 0.0); + assert!((ring.area - std::f64::consts::PI * (3.01f64.powi(2) - 9.0)).abs() < 1e-12, "{ring:?}"); + } + + /// A 20 x 20 x 5 plate with a hole of radius 3 through it, and a pin of radius `pin` through + /// the hole, `offset` off its axis along X. + fn plate_and_pin(pin: f64, offset: f64) -> (Body, Body) { + let block = crate::box_solid(Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 5.0)).unwrap(); + let drill = crate::cylinder(Vec3::new(0.0, 0.0, -2.0), Vec3::Z, 3.0, 9.0).unwrap(); + let plate = crate::boolean(&block, &drill, BoolOp::Cut).unwrap().unwrap(); + (plate, crate::cylinder(Vec3::new(offset, 0.0, -2.0), Vec3::Z, pin, 9.0).unwrap()) + } + + /// The pin shares a crescent with the plate over the plate's thickness, in either order. + #[test] + fn a_pin_off_its_hole_shares_a_crescent() { + let (plate, pin) = plate_and_pin(3.0, 0.01); + let want = 5.0 * crescent(3.0, 3.0, 0.01).area; + for (x, y) in [(&plate, &pin), (&pin, &plate)] { + let o = coaxial_overlap(x, y).unwrap(); + assert!((o.volume - want).abs() < 1e-6 * want, "{o:?} {want}"); + assert!((o.offset - 0.01).abs() < 1e-9 && (o.length - 5.0).abs() < 1e-9, "{o:?}"); + // The crescent is on the pin's side away from the hole's axis (+X). + assert!(o.centroid.x > 2.0 && o.bbox.min.z > -1e-9 && o.bbox.max.z < 5.0 + 1e-9, "{o:?}"); + } + } + + /// A pin with clearance all round, though off the axis, shares nothing. + #[test] + fn a_pin_with_clearance_shares_nothing() { + let (plate, pin) = plate_and_pin(2.995, 0.003); + let o = coaxial_overlap(&plate, &pin).unwrap(); + assert!(o.volume < 1e-9, "{o:?}"); + } + + /// A slot through the hole's wall where the crescent would be: the band holds more than the + /// two walls, the widened pin says so, and the pair is left to the boolean. + #[test] + fn a_slot_through_the_band_is_left_to_the_boolean() { + let (plate, pin) = plate_and_pin(3.0, 0.01); + let slot = crate::box_solid(Vec3::new(2.5, -1.0, -1.0), Vec3::new(4.0, 1.0, 6.0)).unwrap(); + let slotted = crate::boolean(&plate, &slot, BoolOp::Cut).unwrap().unwrap(); + assert!(coaxial_overlap(&slotted, &pin).is_none()); + // Pins on the very axis, or far off it, aren't asked about. + assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.0).1).is_none()); + assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.5).1).is_none()); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 814d935a..7775d2b4 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -28,6 +28,7 @@ mod boundary; mod bridge; mod build; mod clearance; +mod coaxial_overlap; mod continuity; mod coplanar; mod corner_g2; @@ -139,6 +140,7 @@ pub use build::{ PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus, }; +pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap}; pub use continuity::{ ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics, };