mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 02:55:54 +00:00
kernel: booleans, placements and splits on SMLib only, with no retries
A boolean is SMLib's boolean, its history SMLib's tags: the jittered retries, tool nudges, prism and coaxial routes, the retry result guard and their diagnostics are gone. A rigid placement moves the record; any other matrix goes through SMLib's transform (new Brep::transformed over smlib_brep_transform). Bodies apart join as their records side by side (brep part::compound), with each piece's faces, sources and history; apart means their exact boxes do not meet. A split is the intersection with a half-space box, with no faces pushed off the plane. (not built: base in progress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
@@ -1,307 +0,0 @@
|
||||
//! Scoped reports for successful Boolean retries. Discarded attempts never enter
|
||||
//! their caller's report, and independent evaluations cannot inherit old events.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAX_EVENTS: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BooleanStrategy {
|
||||
RefinedTolerance,
|
||||
ShiftedOperands,
|
||||
CoplanarSeparation,
|
||||
CommonBore,
|
||||
/// A boss in the other body's bore, grown into the material around it (nothing recut).
|
||||
GrownBoss,
|
||||
PlanarFallback,
|
||||
ToolNudge,
|
||||
/// A rotationally symmetric tool turned about its own axis; its volume is unchanged.
|
||||
ToolReseam,
|
||||
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.
|
||||
/// Operand shifts are reversed on the result; face shifts are temporary working
|
||||
/// offsets. A tool nudge, in contrast, changes the relative operand placement.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BooleanFallback {
|
||||
pub strategy: BooleanStrategy,
|
||||
pub translation_mm: f64,
|
||||
pub rotation_rad: f64,
|
||||
pub face_shift_mm: f64,
|
||||
pub tolerance_mm: f64,
|
||||
}
|
||||
|
||||
impl BooleanStrategy {
|
||||
/// Whether a result made this way can differ from the exact one: the tool was moved
|
||||
/// against the other body, or part of where they meet was ignored. The other retries work
|
||||
/// on temporary geometry or tolerances only, and their results are the exact ones.
|
||||
pub fn alters_result(self) -> bool {
|
||||
matches!(self, BooleanStrategy::ToolNudge | BooleanStrategy::UnverifiedShortcut)
|
||||
}
|
||||
}
|
||||
|
||||
impl BooleanFallback {
|
||||
pub(crate) fn new(strategy: BooleanStrategy) -> Self {
|
||||
Self { strategy, translation_mm: 0., rotation_rad: 0., face_shift_mm: 0., tolerance_mm: 0. }
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
match self.strategy {
|
||||
BooleanStrategy::RefinedTolerance => format!("refined tolerance {:.6e} mm", self.tolerance_mm),
|
||||
BooleanStrategy::ShiftedOperands => {
|
||||
format!("shifted operands {:.6e} mm (result shifted back), tolerance {:.6e} mm", self.translation_mm, self.tolerance_mm)
|
||||
}
|
||||
BooleanStrategy::CoplanarSeparation => format!("temporary coincident-face separation up to {:.6e} mm per face", self.face_shift_mm),
|
||||
BooleanStrategy::CommonBore => format!("temporary bore offset {:.6e} mm, followed by recutting the original bore", self.face_shift_mm),
|
||||
BooleanStrategy::GrownBoss => format!("boss grown {:.6e} mm into the material around the bore it fills", self.face_shift_mm),
|
||||
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::ToolReseam => format!("axisymmetric tool reseamed by {:.6e} rad (no displacement)", 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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BooleanDiagnostics {
|
||||
pub events: Vec<BooleanFallback>,
|
||||
pub truncated: bool,
|
||||
/// Why candidates of the retries that led to the result were discarded before they were
|
||||
/// measured (#3020: a coarse candidate whose edges run far outside the operands, which
|
||||
/// meshing never finished). Left out of the report when there are none.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub rejected: Vec<String>,
|
||||
}
|
||||
|
||||
impl BooleanDiagnostics {
|
||||
pub(crate) fn push(&mut self, event: BooleanFallback) {
|
||||
if self.events.len() < MAX_EVENTS {
|
||||
self.events.push(event);
|
||||
} else {
|
||||
self.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rejected(&mut self, reason: String) {
|
||||
// A candidate checked twice (picked, then taken) is noted once.
|
||||
if self.rejected.contains(&reason) {
|
||||
return;
|
||||
}
|
||||
if self.rejected.len() < MAX_EVENTS {
|
||||
self.rejected.push(reason);
|
||||
} else {
|
||||
self.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Every retry the result took, for a message about the boolean (one that failed its
|
||||
/// checks lists what was tried).
|
||||
pub fn summary(&self) -> Option<String> {
|
||||
self.described(&self.events.iter().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// The note a feature carries: the retries that can make its result differ from the exact
|
||||
/// one ([`BooleanStrategy::alters_result`]). Exact retries are in `events` all the same
|
||||
/// (`timeline.inspect`), but they leave the result as it should be: no reason to warn.
|
||||
pub fn warning(&self) -> Option<String> {
|
||||
self.described(&self.events.iter().filter(|e| e.strategy.alters_result()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn described(&self, events: &[&BooleanFallback]) -> Option<String> {
|
||||
if events.is_empty() && !self.truncated {
|
||||
return None;
|
||||
}
|
||||
let mut descriptions: Vec<String> = events.iter().take(4).map(|e| e.description()).collect();
|
||||
if events.len() > 4 {
|
||||
descriptions.push(format!("{} additional retries", events.len() - 4));
|
||||
}
|
||||
if self.truncated {
|
||||
descriptions.push(format!("report capped at {MAX_EVENTS} retries"));
|
||||
}
|
||||
Some(format!("Boolean retry: {}", descriptions.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static REPORT: RefCell<Option<BooleanDiagnostics>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Scope {
|
||||
previous: Option<Option<BooleanDiagnostics>>,
|
||||
}
|
||||
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.previous.take() {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut() {
|
||||
*report = previous;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture only the events produced by this call. Nested callers explicitly merge
|
||||
/// an accepted attempt; dropping a scope restores its parent even during unwinding.
|
||||
pub fn capture_boolean_diagnostics<T>(run: impl FnOnce() -> T) -> (T, BooleanDiagnostics) {
|
||||
let previous = REPORT.with(|cell| cell.try_borrow_mut().ok().map(|mut report| report.replace(BooleanDiagnostics::default())));
|
||||
let scope = Scope { previous };
|
||||
let result = run();
|
||||
let report = REPORT.with(|cell| cell.try_borrow_mut().ok().and_then(|mut report| report.take())).unwrap_or_default();
|
||||
drop(scope);
|
||||
(result, report)
|
||||
}
|
||||
|
||||
pub(crate) fn record(event: BooleanFallback) {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut()
|
||||
&& let Some(report) = report.as_mut()
|
||||
{
|
||||
report.push(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Note a candidate discarded before it was measured, and why.
|
||||
pub(crate) fn reject(reason: String) {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut()
|
||||
&& let Some(report) = report.as_mut()
|
||||
{
|
||||
report.push_rejected(reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn merge(report: BooleanDiagnostics) {
|
||||
for event in report.events {
|
||||
record(event);
|
||||
}
|
||||
for reason in report.rejected {
|
||||
reject(reason);
|
||||
}
|
||||
if report.truncated {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut()
|
||||
&& let Some(report) = report.as_mut()
|
||||
{
|
||||
report.truncated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nested_attempts_are_committed_explicitly_and_do_not_leak() {
|
||||
let (_, report) = capture_boolean_diagnostics(|| {
|
||||
let (_, discarded) = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge)));
|
||||
assert_eq!(discarded.events.len(), 1);
|
||||
let (_, accepted) = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::PlanarFallback)));
|
||||
merge(accepted);
|
||||
});
|
||||
assert_eq!(report.events.len(), 1);
|
||||
assert_eq!(report.events[0].strategy, BooleanStrategy::PlanarFallback);
|
||||
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
|
||||
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
|
||||
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
|
||||
}
|
||||
|
||||
/// Candidates discarded unmeasured (#3020) are reported with the attempt that led to the
|
||||
/// result, once each, and not at all when there are none.
|
||||
#[test]
|
||||
fn rejected_candidates_follow_the_accepted_attempt() {
|
||||
let (_, report) = capture_boolean_diagnostics(|| {
|
||||
let (_, discarded) = capture_boolean_diagnostics(|| reject("from a discarded attempt".into()));
|
||||
assert_eq!(discarded.rejected.len(), 1);
|
||||
let (_, accepted) = capture_boolean_diagnostics(|| {
|
||||
reject("runaway edge".into());
|
||||
reject("runaway edge".into());
|
||||
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
|
||||
});
|
||||
merge(accepted);
|
||||
});
|
||||
assert_eq!(report.rejected, vec!["runaway edge".to_string()]);
|
||||
assert!(serde_json::to_string(&report).unwrap().contains("\"rejected\":[\"runaway edge\"]"));
|
||||
let plain = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge))).1;
|
||||
assert!(!serde_json::to_string(&plain).unwrap().contains("rejected"));
|
||||
}
|
||||
|
||||
/// Only retries whose result can differ from the exact one make a warning; the summary
|
||||
/// lists every retry.
|
||||
#[test]
|
||||
fn only_retries_that_alter_the_result_warn() {
|
||||
let report = |strategies: &[BooleanStrategy]| {
|
||||
capture_boolean_diagnostics(|| {
|
||||
for s in strategies {
|
||||
record(BooleanFallback::new(*s));
|
||||
}
|
||||
})
|
||||
.1
|
||||
};
|
||||
let exact = report(&[BooleanStrategy::ShiftedOperands, BooleanStrategy::GrownBoss, BooleanStrategy::CoplanarSeparation]);
|
||||
assert!(exact.warning().is_none(), "{exact:?}");
|
||||
assert!(exact.summary().is_some_and(|s| s.contains("shifted operands") && s.contains("boss grown")));
|
||||
let nudged = report(&[BooleanStrategy::GrownBoss, BooleanStrategy::ToolNudge]);
|
||||
let warning = nudged.warning().unwrap();
|
||||
assert!(warning.contains("tool displacement") && !warning.contains("boss grown"), "{warning}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_operations_and_failed_calls_have_no_retry_report() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
|
||||
let other = crate::box_solid(Vec3::new(5., 0., 0.), Vec3::new(15., 10., 10.)).unwrap();
|
||||
let (result, diagnostics) = capture_boolean_diagnostics(|| crate::boolean(&body, &other, crate::BoolOp::Union));
|
||||
assert!(result.unwrap().is_some());
|
||||
assert!(diagnostics.warning().is_none(), "{diagnostics:?}");
|
||||
let mesh = crate::mesh_body(&[Vec3::ZERO, Vec3::X, Vec3::Y], &[[0, 1, 2]]).unwrap();
|
||||
let (result, diagnostics) = capture_boolean_diagnostics(|| crate::boolean(&body, &mesh, crate::BoolOp::Cut));
|
||||
assert!(result.is_err());
|
||||
assert!(diagnostics.warning().is_none(), "{diagnostics:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwinding_restores_parent_and_reports_are_thread_local_and_bounded() {
|
||||
let (_, report) = capture_boolean_diagnostics(|| {
|
||||
let failed = crate::guard("diagnostic test", || {
|
||||
capture_boolean_diagnostics(|| -> crate::Result<()> {
|
||||
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
|
||||
panic!("discard this attempt");
|
||||
})
|
||||
.0
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let separate = std::thread::spawn(|| capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge))).1);
|
||||
assert_eq!(separate.join().unwrap().events.len(), 1);
|
||||
for _ in 0..MAX_EVENTS + 1 {
|
||||
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||||
}
|
||||
});
|
||||
assert_eq!(report.events.len(), MAX_EVENTS);
|
||||
assert!(report.truncated);
|
||||
assert!(report.events.iter().all(|event| event.strategy == BooleanStrategy::PlanarFallback));
|
||||
assert!(report.warning().unwrap().contains("capped"));
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
//! The check every Boolean retry's result must pass before it is returned (issue #616).
|
||||
//!
|
||||
//! A retry moves things about (the tool nudged, faces pushed apart, pieces cut off and glued
|
||||
//! back), so its result is held to what a join or a cut can do at all:
|
||||
//! - it is closed;
|
||||
//! - a join never loses volume and a cut never gains any;
|
||||
//! - outside the tool's box nothing changes: there, the result's surface is the target's and
|
||||
//! the target's surface is the result's. A join or a cut can only change the target where the
|
||||
//! tool is, so geometry anywhere else (a slit through a wall, a face moved) is wrong.
|
||||
//!
|
||||
//! A result that fails is refused with an error: a wrong result that looks right is worse.
|
||||
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{BoolOp, KernelError, Result};
|
||||
|
||||
/// Most triangles sampled from each surface (evenly spaced through the mesh).
|
||||
const MAX_SAMPLES: usize = 20_000;
|
||||
|
||||
/// Distance from `p` to the triangle `abc`.
|
||||
pub(crate) fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else {
|
||||
return [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min);
|
||||
};
|
||||
let h = (p - a).dot(nn);
|
||||
let q = p - nn * h;
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
|
||||
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
|
||||
}
|
||||
|
||||
/// The triangles of a mesh in a uniform grid of cells, each listing the triangles within `reach`
|
||||
/// of it, to answer "is this point within `reach` of the surface?".
|
||||
struct Near<'a> {
|
||||
mesh: &'a Mesh,
|
||||
min: Vec3,
|
||||
cell: f64,
|
||||
dims: [usize; 3],
|
||||
cells: Vec<Vec<u32>>,
|
||||
reach: f64,
|
||||
}
|
||||
|
||||
impl<'a> Near<'a> {
|
||||
fn new(mesh: &'a Mesh, reach: f64) -> Option<Self> {
|
||||
let bounds = mesh.bounds();
|
||||
if bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let min = bounds.min - Vec3::new(reach, reach, reach);
|
||||
let size = bounds.size() + Vec3::new(2.0 * reach, 2.0 * reach, 2.0 * reach);
|
||||
// About a few triangles a cell, at most 64^3 cells.
|
||||
let n = (mesh.triangles.len().max(1) as f64).cbrt().clamp(1.0, 64.0);
|
||||
let cell = (size.x.max(size.y).max(size.z) / n).max(reach * 2.0).max(1e-9);
|
||||
let dims = [size.x, size.y, size.z].map(|s| ((s / cell).ceil() as usize).clamp(1, 256));
|
||||
let mut cells = vec![Vec::new(); dims[0] * dims[1] * dims[2]];
|
||||
for (i, t) in mesh.triangles.iter().enumerate() {
|
||||
let Some(tri) = mesh.tri(t) else { continue };
|
||||
let mut bx = Aabb3::EMPTY;
|
||||
for p in tri {
|
||||
bx.add(p);
|
||||
}
|
||||
let lo = Self::index(min, cell, dims, bx.min - Vec3::new(reach, reach, reach));
|
||||
let hi = Self::index(min, cell, dims, bx.max + Vec3::new(reach, reach, reach));
|
||||
for x in lo[0]..=hi[0] {
|
||||
for y in lo[1]..=hi[1] {
|
||||
for z in lo[2]..=hi[2] {
|
||||
if let Some(c) = cells.get_mut((x * dims[1] + y) * dims[2] + z) {
|
||||
c.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Near { mesh, min, cell, dims, cells, reach })
|
||||
}
|
||||
|
||||
fn index(min: Vec3, cell: f64, dims: [usize; 3], p: Vec3) -> [usize; 3] {
|
||||
let d = p - min;
|
||||
let f = |v: f64, n: usize| ((v / cell).floor().max(0.0) as usize).min(n - 1);
|
||||
[f(d.x, dims[0]), f(d.y, dims[1]), f(d.z, dims[2])]
|
||||
}
|
||||
|
||||
fn close(&self, p: Vec3) -> bool {
|
||||
let d = p - self.min;
|
||||
let s = [d.x, d.y, d.z];
|
||||
if s.iter().zip(self.dims).any(|(v, n)| *v < 0.0 || *v > self.cell * n as f64) {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = Self::index(self.min, self.cell, self.dims, p);
|
||||
let Some(list) = self.cells.get((x * self.dims[1] + y) * self.dims[2] + z) else { return false };
|
||||
list.iter().any(|&t| {
|
||||
self.mesh.triangles.get(t as usize).and_then(|t| self.mesh.tri(t)).is_some_and(|[a, b, c]| point_triangle_dist(p, a, b, c) <= self.reach)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of `from`'s surface outside `region` that is not on `onto`'s surface (within
|
||||
/// `reach`), if there is one.
|
||||
fn stray_point(from: &Mesh, onto: &Near, region: &Aabb3) -> Option<Vec3> {
|
||||
let step = from.triangles.len().div_ceil(MAX_SAMPLES).max(1);
|
||||
from.triangles.iter().step_by(step).filter_map(|t| from.tri(t)).find_map(|[a, b, c]| {
|
||||
let p = (a + b + c) / 3.0;
|
||||
let outside =
|
||||
p.x < region.min.x || p.y < region.min.y || p.z < region.min.z || p.x > region.max.x || p.y > region.max.y || p.z > region.max.z;
|
||||
(outside && !onto.close(p)).then_some(p)
|
||||
})
|
||||
}
|
||||
|
||||
/// The smallest radius of a body's cylinders, spheres and tori (their tubes).
|
||||
fn smallest_radius(body: &Body) -> Option<f64> {
|
||||
use crate::surface_kind::{FaceSurface, face_surface};
|
||||
(0..body.face_count())
|
||||
.filter_map(|i| match face_surface(body, i)? {
|
||||
FaceSurface::Cylinder { radius, .. } | FaceSurface::Sphere { radius, .. } => Some(radius),
|
||||
FaceSurface::Torus { minor, .. } => Some(minor),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|r| r.is_finite() && *r > 0.0)
|
||||
.min_by(f64::total_cmp)
|
||||
}
|
||||
|
||||
fn bad(op: BoolOp, what: String) -> KernelError {
|
||||
let verb = match op {
|
||||
BoolOp::Union => "join",
|
||||
BoolOp::Cut => "cut",
|
||||
BoolOp::Intersect => "intersect",
|
||||
};
|
||||
KernelError::Failed(format!(
|
||||
"the {verb} could not be computed reliably: {what}. The result was refused rather than kept wrong; \
|
||||
where the bodies only touch (faces flush with each other), overlapping them slightly usually helps"
|
||||
))
|
||||
}
|
||||
|
||||
/// Check the result of a Boolean that needed a retry (see the module docs). `result` is `None`
|
||||
/// for an empty result.
|
||||
pub(crate) fn check_retry(a: &Body, b: &Body, op: BoolOp, result: Option<&Body>) -> Result<()> {
|
||||
if op == BoolOp::Intersect || a.is_mesh() || b.is_mesh() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(r) = result else {
|
||||
// Nothing left: only a cut whose tool covers the whole target (to within the meshes'
|
||||
// chords: a tool touching a round side of the target).
|
||||
let chord = a.size().max(b.size()) * 1e-3;
|
||||
let (Ok(ma), Ok(mb)) = (a.mesh(&crate::MeshTolerance::chord(chord)), b.mesh(&crate::MeshTolerance::chord(chord))) else { return Ok(()) };
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let tol = 2.0 * chord;
|
||||
let within = ba.min.x >= bb.min.x - tol
|
||||
&& ba.min.y >= bb.min.y - tol
|
||||
&& ba.min.z >= bb.min.z - tol
|
||||
&& ba.max.x <= bb.max.x + tol
|
||||
&& ba.max.y <= bb.max.y + tol
|
||||
&& ba.max.z <= bb.max.z + tol;
|
||||
return if op == BoolOp::Cut && within { Ok(()) } else { Err(bad(op, "it came out empty".into())) };
|
||||
};
|
||||
if r.solid.boundaries().iter().any(|s| s.shell_condition() != truck_modeling::ShellCondition::Closed) {
|
||||
return Err(bad(op, "the result is not closed".into()));
|
||||
}
|
||||
let size = a.size().max(b.size());
|
||||
let t = (size * 2e-4).max(1e-5);
|
||||
let (Ok(ma), Ok(mb), Ok(mr)) = (a.mesh(&crate::MeshTolerance::chord(t)), b.mesh(&crate::MeshTolerance::chord(t)), r.mesh(&crate::MeshTolerance::chord(t))) else {
|
||||
return Err(bad(op, "the result could not be checked".into()));
|
||||
};
|
||||
// A mesh as coarse as `t` on a round smaller than it has no inside to speak of (a through
|
||||
// hole 0.13 mm across in a 900 mm slab measured −30272 mm³, #1255): the volumes are measured
|
||||
// at a tenth of the bodies' smallest radius, where that is finer. All three at the same
|
||||
// tolerance, so the chords' error on the faces they share cancels. `scale` refines it all.
|
||||
let finest = [a, b, r].iter().filter_map(|x| smallest_radius(x)).fold(f64::INFINITY, f64::min);
|
||||
let fine = if finest * 0.1 < t { (finest * 0.1).max(t * 1e-2) } else { t };
|
||||
let volume = |body: &Body, mesh: &Mesh, scale: f64| -> Result<f64> {
|
||||
if fine == t && scale == 1.0 {
|
||||
return Ok(mesh.measure().volume);
|
||||
}
|
||||
body.mesh(&crate::MeshTolerance::chord(fine * scale)).map(|m| m.measure().volume).map_err(|_| bad(op, "the result could not be checked".into()))
|
||||
};
|
||||
let judge = |scale: f64| -> Result<(bool, f64, f64, f64)> {
|
||||
let (va, vb, vr) = (volume(a, &ma, scale)?, volume(b, &mb, scale)?, volume(r, &mr, scale)?);
|
||||
let slack = 2e-4 * (va.abs() + vb.abs()) + 1e-9;
|
||||
let ok = vr.is_finite()
|
||||
&& match op {
|
||||
BoolOp::Union => vr >= va.max(vb) - slack && vr <= va + vb + slack,
|
||||
BoolOp::Cut => vr <= va + slack && vr >= va - vb - slack,
|
||||
BoolOp::Intersect => true,
|
||||
};
|
||||
Ok((ok, va, vb, vr))
|
||||
};
|
||||
// A volume out of bounds is measured again ten times finer before it refuses the result:
|
||||
// the coarse meshes of a part with many rounds can be off by more than the slack (a 0.8 mm
|
||||
// hole through a plastic part's wall measured 6.8 mm³ more than the part, #1255).
|
||||
let (mut volume_ok, mut va, mut vb, mut vr) = judge(1.0)?;
|
||||
if !volume_ok {
|
||||
(volume_ok, va, vb, vr) = judge(0.1)?;
|
||||
}
|
||||
if !volume_ok {
|
||||
let what = match op {
|
||||
BoolOp::Union => format!("the joined body's volume would be {vr:.4} mm³ (the target is {va:.4} mm³, the tool {vb:.4} mm³)"),
|
||||
_ => format!("the cut body's volume would be {vr:.4} mm³ (the target is {va:.4} mm³, the tool {vb:.4} mm³)"),
|
||||
};
|
||||
return Err(bad(op, what));
|
||||
}
|
||||
// Outside the tool's box (with room for the meshes' chords and a nudge) the result's
|
||||
// surface and the target's must be the same.
|
||||
let reach = 3.0 * t + size * 1e-5;
|
||||
let mut region = mb.bounds();
|
||||
region.min -= Vec3::new(reach, reach, reach) * 2.0;
|
||||
region.max += Vec3::new(reach, reach, reach) * 2.0;
|
||||
let (Some(near_a), Some(near_r)) = (Near::new(&ma, reach), Near::new(&mr, reach)) else {
|
||||
return Err(bad(op, "the result could not be checked".into()));
|
||||
};
|
||||
let at = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
|
||||
if let Some(p) = stray_point(&mr, &near_a, ®ion) {
|
||||
return Err(bad(op, format!("it would change the body away from the tool, at {} (outside the tool's region)", at(p))));
|
||||
}
|
||||
if let Some(p) = stray_point(&ma, &near_r, ®ion) {
|
||||
return Err(bad(op, format!("it would remove part of the body away from the tool, at {} (outside the tool's region)", at(p))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use crate::*;
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
fn fixture() -> (Body, Body, Vec3, f64) {
|
||||
let slope = 1f64.to_radians().tan();
|
||||
let hs = |normal: Vec3, point: Vec3| {
|
||||
let n = normal.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(point) }
|
||||
};
|
||||
let stock = convex_polyhedron(&[
|
||||
hs(Vec3::X, Vec3::new(40., 0., 0.)),
|
||||
hs(-Vec3::X, Vec3::ZERO),
|
||||
hs(Vec3::Y, Vec3::new(0., 30., 0.)),
|
||||
hs(-Vec3::Y, Vec3::ZERO),
|
||||
hs(-Vec3::Z, Vec3::ZERO),
|
||||
hs(Vec3::new(-slope, 0., 1.), Vec3::new(0., 0., 20.)),
|
||||
])
|
||||
.unwrap();
|
||||
let cutter = cylinder(Vec3::new(20., 15., -5.), Vec3::Z, 5., 40.).unwrap();
|
||||
let bored = boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap();
|
||||
let web = box_solid(Vec3::new(14., 14., 16.05), Vec3::new(26., 16., 16.07)).unwrap();
|
||||
let target = boolean(&bored, &web, BoolOp::Union).unwrap().unwrap();
|
||||
let region = Region2 { outer: Loop2::circle(Vec2::new(20., 15.), 8.), holes: vec![Loop2::circle(Vec2::new(20., 15.), 5.).reversed()] };
|
||||
let ring = extrude(&Plane::XY, &[region], 14., 24.).unwrap().remove(0);
|
||||
let web_volume = 0.02 * 2. * (24f64.sqrt() + 25. * 0.2f64.asin());
|
||||
let fine_target = target.mesh(&crate::MeshTolerance::chord(1e-4)).unwrap();
|
||||
let added = fine_target.measure().volume - bored.mesh(&crate::MeshTolerance::chord(1e-4)).unwrap().measure().volume;
|
||||
assert!((added - web_volume).abs() < 0.005, "actual connected web adds {added}, expected {web_volume}");
|
||||
for x in [16., 20., 24.] {
|
||||
for y in [14.2, 15., 15.8] {
|
||||
assert!(fine_target.contains(Vec3::new(x, y, 16.06)), "source web must exist at {x},{y}");
|
||||
}
|
||||
}
|
||||
assert!(!fine_target.contains(Vec3::new(20., 12., 16.06)));
|
||||
let floor_volume = (20. + slope * 20.) * (1200. - 25. * std::f64::consts::PI);
|
||||
let ring_added = std::f64::consts::PI * (64. - 25.) * (24. - (20. + slope * 20.));
|
||||
(target, ring, Vec3::new(20., 15., 16.06), floor_volume + web_volume + ring_added)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_connected_web_between_bore_stations_survives_union() {
|
||||
let (target, ring, probe, expected) = fixture();
|
||||
let target_before = step_export(&[&target], "source").unwrap();
|
||||
let ring_before = step_export(&[&ring], "ring").unwrap();
|
||||
let size = target.size().max(ring.size());
|
||||
let tol = (size * 5e-4).max(size * 1e-5);
|
||||
let ma = target.mesh(&crate::MeshTolerance::chord(tol)).unwrap();
|
||||
let mb = ring.mesh(&crate::MeshTolerance::chord(tol)).unwrap();
|
||||
assert!(ma.contains(probe), "fixture must contain the real connected web");
|
||||
assert!(!mb.contains(probe));
|
||||
let va = ma.measure().volume;
|
||||
let vb = mb.measure().volume;
|
||||
let slack = 2e-3 * (va + vb) + 1e-9;
|
||||
let accept = |body: &Body| {
|
||||
let mr = body.mesh(&crate::MeshTolerance::chord(tol)).unwrap();
|
||||
let v = mr.measure().volume;
|
||||
let mismatch = super::mismatch(&ma, &mb, &mr, BoolOp::Union);
|
||||
eprintln!("fallback candidate V={v}, expected={expected}, web={}, mismatch={mismatch:?}", mr.contains(probe));
|
||||
v > 0. && v >= va.max(vb) - slack && v <= va + vb + slack && mismatch.is_none_or(|f| f < 0.004)
|
||||
};
|
||||
let direct = crate::coplanar::bore_union(&target, &ring, &accept);
|
||||
eprintln!("direct fallback result={}", direct.is_some());
|
||||
let public = boolean(&target, &ring, BoolOp::Union);
|
||||
if let Ok(dir) = solidcraft_geom::env::var("BORE_AUDIT_DIR").ok_or(()) {
|
||||
let dir = std::path::Path::new(&dir);
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("target-with-web.step"), step_export(&[&target], "target with web").unwrap()).unwrap();
|
||||
if let Some(result) = &direct {
|
||||
std::fs::write(dir.join("direct-union.step"), step_export(&[result], "direct union").unwrap()).unwrap();
|
||||
}
|
||||
if let Ok(Some(result)) = &public {
|
||||
std::fs::write(dir.join("public-union.step"), step_export(&[result], "public union").unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
let direct_keeps_web = direct.as_ref().is_none_or(|result| result.mesh(&crate::MeshTolerance::chord(1e-3)).unwrap().contains(probe));
|
||||
let public_keeps_web = match &public {
|
||||
Ok(Some(result)) => {
|
||||
let keep = result.mesh(&crate::MeshTolerance::chord(1e-3)).unwrap().contains(probe);
|
||||
eprintln!("public Union result web={keep}");
|
||||
keep
|
||||
}
|
||||
result => {
|
||||
eprintln!("public Union honestly refused: {result:?}");
|
||||
true
|
||||
}
|
||||
};
|
||||
assert!(direct_keeps_web, "accepted fallback removed real web material");
|
||||
assert!(public_keeps_web, "public Union removed real web material");
|
||||
assert_eq!(step_export(&[&target], "source").unwrap(), target_before);
|
||||
assert_eq!(step_export(&[&ring], "ring").unwrap(), ring_before);
|
||||
}
|
||||
|
||||
/// Posts sunk into one floor have their tops in a common plane but apart: they don't
|
||||
/// coincide, so joining another post doesn't go through pushing faces apart (#261).
|
||||
#[test]
|
||||
fn coplanar_faces_apart_from_each_other_do_not_coincide() {
|
||||
let floor = box_solid(Vec3::new(0., 0., 0.), Vec3::new(80., 60., 2.)).unwrap();
|
||||
let post = |x: f64| cylinder(Vec3::new(x, 30., 1.), Vec3::Z, 3., 6.).unwrap();
|
||||
let with_one = boolean(&floor, &post(10.), BoolOp::Union).unwrap().unwrap();
|
||||
assert!(crate::coplanar::coincident_planes(&with_one, &post(60.)).is_empty());
|
||||
assert!(crate::coplanar::boolean_apart(&with_one, &post(60.), BoolOp::Union).is_none());
|
||||
// A post over the first one's top does meet it there.
|
||||
assert!(!crate::coplanar::coincident_planes(&with_one, &cylinder(Vec3::new(12., 30., 7.), Vec3::Z, 3., 6.).unwrap()).is_empty());
|
||||
let joined = boolean(&with_one, &post(60.), BoolOp::Union).unwrap().unwrap();
|
||||
let v = joined.mesh(&crate::MeshTolerance::chord(1e-3)).unwrap().measure().volume;
|
||||
let expected = 80. * 60. * 2. + 2. * std::f64::consts::PI * 9. * 5.;
|
||||
assert!((v - expected).abs() < 0.5, "volume {v}, expected {expected}");
|
||||
}
|
||||
@@ -1,686 +0,0 @@
|
||||
//! 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), or on it: a pin seated line to line.
|
||||
//!
|
||||
//! The pin's wall and the hole's wall then meet along two lines where they are all but tangent,
|
||||
//! or lie on one another, 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`; where a
|
||||
//! blend running into the pin's wall keeps it from moving, the hole's wall is moved instead) 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, UnitVec3, 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 tries each intersection here gets: with a wall moved clear it takes one or two, and a
|
||||
/// few more where a flush face is pulled back or the tool is nudged by a hair; one that needs
|
||||
/// more than that meets something this work does not cover.
|
||||
const ATTEMPTS: usize = 10;
|
||||
|
||||
/// 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<usize>,
|
||||
origin: Vec3,
|
||||
axis: UnitVec3,
|
||||
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, none for a
|
||||
/// pin seated line to line), 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<CoaxialOverlap> {
|
||||
if a.is_mesh() || b.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
// The boolean that takes the core can fail in one order of the bodies and not the other.
|
||||
crate::guard("near-coaxial overlap", || Ok(overlap(a, b).or_else(|| overlap(b, a)))).ok().flatten()
|
||||
}
|
||||
|
||||
/// Whether a cylinder of `a` and one of `b` are on one cylinder or nearly (what
|
||||
/// [`coaxial_overlap`] asks first, from the faces' surfaces alone): a cheap test for whether
|
||||
/// it is worth preparing the bodies for it. `Some(true)` when every such pair is on one cylinder
|
||||
/// within the kernel's same-surface tolerance, which the boolean takes as one wall; `Some(false)`
|
||||
/// when one is a hair off, which it cannot intersect and may even pass as clear.
|
||||
pub fn coaxial_candidate(a: &Body, b: &Body) -> Option<bool> {
|
||||
if a.is_mesh() || b.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
crate::guard("near-coaxial candidate", || {
|
||||
let same = same_surface(a.size().max(b.size()));
|
||||
let (ca, cb) = (cylinders(a), cylinders(b));
|
||||
let mut found = None;
|
||||
for x in &ca {
|
||||
for y in &cb {
|
||||
if x.axis.dot(y.axis).abs() <= 1.0 - 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, offset)) = near(x, y) {
|
||||
let one = offset + (x.radius - y.radius).abs() <= same;
|
||||
found = Some(found.unwrap_or(true) && one);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The distance within which the kernel takes two surfaces as one, for bodies of `size`.
|
||||
fn same_surface(size: f64) -> f64 {
|
||||
(size * 1e-7).max(1e-9) * 10.0
|
||||
}
|
||||
|
||||
fn overlap(a: &Body, b: &Body) -> Option<CoaxialOverlap> {
|
||||
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 = same_surface(size);
|
||||
// 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).is_some();
|
||||
if !ca.iter().any(|x| cb.iter().any(|y| close(x, y))) {
|
||||
return None;
|
||||
}
|
||||
// A wall that runs once round along a seam edge (as STEP files have them) is cut in two
|
||||
// first: the boolean cannot always intersect it whole.
|
||||
let (sa, sb) = (crate::step_in::split::split_seams(a), crate::step_in::split::split_seams(b));
|
||||
let (ca, cb) = match (&sa, &sb) {
|
||||
(None, None) => (ca, cb),
|
||||
_ => (sa.as_ref().map_or(ca, cylinders), sb.as_ref().map_or(cb, cylinders)),
|
||||
};
|
||||
let (a, b) = (sa.as_ref().unwrap_or(a), sb.as_ref().unwrap_or(b));
|
||||
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, side by side along it: not this
|
||||
// case. Apart along the axis (a screw's head beside a bush of its radius), they
|
||||
// don't meet.
|
||||
_ if near(x, y).is_some() && total(&intersect(&union(&x.spans), &union(&project(y, x)))) > same => return None,
|
||||
_ => continue,
|
||||
};
|
||||
let Some((toward, offset)) = near(pin, hole) 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);
|
||||
// Within the kernel's same-surface tolerance the two walls are one: no crescent.
|
||||
let mut hole = hole.clone();
|
||||
let offset = if offset + (pin.radius - hole.radius).abs() <= same {
|
||||
hole.radius = pin.radius;
|
||||
0.0
|
||||
} else {
|
||||
offset
|
||||
};
|
||||
pairs.push(Pair { pin: pin.clone(), pin_in_a, hole, toward, offset, shared, step });
|
||||
}
|
||||
}
|
||||
if pairs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// A pin's face flush against the other body (a rivet's head on the sheet) touches it over
|
||||
// an area, which the boolean cannot always take: it is pulled back off it by a little.
|
||||
// Only material outside the other body goes, so the overlap is the same.
|
||||
let back = pairs.iter().map(|p| p.step).fold(f64::INFINITY, f64::min) * 0.05;
|
||||
let flat = (flush(a, b, same, back), flush(b, a, same, back), back);
|
||||
// The pin's wall is moved where its neighbours let it; where they don't (a blend running
|
||||
// into it), the hole's wall is moved away from the pin instead.
|
||||
let stuck = std::cell::Cell::new(false);
|
||||
answer(a, b, &pairs, size, &flat, false, &stuck).or_else(|| if stuck.get() { answer(a, b, &pairs, size, &flat, true, &stuck) } else { None })
|
||||
}
|
||||
|
||||
/// The overlap of the `pairs` worked out with each pin's wall moved by steps, or each hole's
|
||||
/// (`move_hole`); `stuck` set when a body could not be moved. `flat` holds the faces of `a`
|
||||
/// and of `b` that touch the other flat, and how far they are pulled back off it.
|
||||
fn answer(
|
||||
a: &Body,
|
||||
b: &Body,
|
||||
pairs: &[Pair],
|
||||
size: f64,
|
||||
flat: &(Vec<usize>, Vec<usize>, f64),
|
||||
move_hole: bool,
|
||||
stuck: &std::cell::Cell<bool>,
|
||||
) -> Option<CoaxialOverlap> {
|
||||
let (fa, fb, back) = (&flat.0, &flat.1, flat.2);
|
||||
// The pin's and the hole's radius with the moved wall `k` steps toward the other.
|
||||
let radii = |p: &Pair, k: f64| if move_hole { (p.pin.radius, p.hole.radius - p.step * k) } else { (p.pin.radius + p.step * k, p.hole.radius) };
|
||||
// A wall's faces move together: one step for each of them (a wall does 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<Body>)> {
|
||||
let moved = |body: &Body, in_a: bool| -> Option<Body> {
|
||||
let mine: Vec<(&Wall, f64)> = pairs
|
||||
.iter()
|
||||
.filter_map(|p| match (move_hole, p.pin_in_a == in_a) {
|
||||
(false, true) => Some((&p.pin, p.step)),
|
||||
(true, false) => Some((&p.hole, p.step)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if mine.is_empty() {
|
||||
return Some(body.clone());
|
||||
}
|
||||
let flush = if in_a { fa } else { fb };
|
||||
let shift = |i: usize, _| match mine.iter().find(|(w, _)| w.faces.contains(&i)) {
|
||||
Some((_, step)) => step * k,
|
||||
None if flush.contains(&i) => -back,
|
||||
None => 0.0,
|
||||
};
|
||||
let m = crate::offset::offset_body(body, shift).ok().filter(|m| m.validity().is_empty());
|
||||
if m.is_none() {
|
||||
stuck.set(true);
|
||||
}
|
||||
m
|
||||
};
|
||||
let (ma, mb) = (moved(a, true)?, moved(b, false)?);
|
||||
let (r, report) =
|
||||
crate::capture_boolean_diagnostics(|| crate::ops::with_attempt_budget(ATTEMPTS, || crate::ops::boolean(&ma, &mb, BoolOp::Intersect)));
|
||||
// The core is the answer and must be exact. The wider pins only check the band, to
|
||||
// 2 % of what it gains: a tool nudged by a hair of the step there is as good.
|
||||
let nudge = if k > 0.0 { pairs.iter().map(|p| p.step).fold(f64::INFINITY, f64::min) * 1e-2 } else { 0.0 };
|
||||
if report.unverified().is_some() || !altered_by_at_most(&report, nudge, size) {
|
||||
return None;
|
||||
}
|
||||
match r.ok()? {
|
||||
None => Some((0.0, None)),
|
||||
Some(body) => Some((crate::measure(&body).ok()?.volume, Some(body))),
|
||||
}
|
||||
};
|
||||
// The crescents' area along each pair with the moved walls `k` steps toward each other.
|
||||
let crescent_at = |p: &Pair, k: f64| {
|
||||
let (r, h) = radii(p, k);
|
||||
crescent(r, h, p.offset).area
|
||||
};
|
||||
let crescents = |k: f64| -> f64 { pairs.iter().map(|p| total(&p.shared) * crescent_at(p, k)).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.002 * 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 moved walls already shared (nothing, unless the hole is the smaller).
|
||||
let v = length * (c.area - crescent_at(p, -1.0));
|
||||
let mid = p.shared.iter().map(|(s, t)| (s + t) * 0.5 * (t - s)).sum::<f64>() / 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 })
|
||||
}
|
||||
|
||||
/// Whether every retry of a boolean that can change its result moved the tool by at most
|
||||
/// `limit` (a rotation counted at the bodies' `size` from its pivot): none for a `limit` of 0.
|
||||
fn altered_by_at_most(report: &crate::BooleanDiagnostics, limit: f64, size: f64) -> bool {
|
||||
!report.truncated
|
||||
&& report
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.strategy.alters_result())
|
||||
.all(|e| e.strategy == crate::BooleanStrategy::ToolNudge && limit > 0.0 && e.translation_mm + e.rotation_rad * size <= limit)
|
||||
}
|
||||
|
||||
/// The pin's offset to the hole, when the two are on one cylinder or nearly: 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) -> 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 + gap <= NEAR * pin.radius.min(hole.radius)) {
|
||||
return None;
|
||||
}
|
||||
let toward = across.normalized().unwrap_or_else(|| pin.axis.any_perp());
|
||||
Some((toward, offset))
|
||||
}
|
||||
|
||||
/// The planar faces of `x` that lie on a face of `y`'s facing the other way, where the two touch
|
||||
/// flat: on a plane of `y`'s, with `y` just beyond the face and not just behind it (within
|
||||
/// `depth`, at every sampled facet of it). A face on such a plane elsewhere (a collar's
|
||||
/// underside inside the other body, level with a pocket's floor) is not one.
|
||||
fn flush(x: &Body, y: &Body, same: f64, depth: f64) -> Vec<usize> {
|
||||
let planes = |b: &Body| -> Vec<(usize, Vec3, Vec3)> {
|
||||
(0..b.face_count())
|
||||
.filter_map(|i| match crate::face_surface(b, i)? {
|
||||
FaceSurface::Plane { normal, point } => Some((i, normal.vec(), point)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let theirs = planes(y);
|
||||
let level: Vec<(usize, Vec3)> = planes(x)
|
||||
.into_iter()
|
||||
.filter(|(_, n, p)| theirs.iter().any(|(_, m, q)| n.dot(*m) < -1.0 + 1e-9 && (*q - *p).dot(*n).abs() <= same))
|
||||
.map(|(i, n, _)| (i, n))
|
||||
.collect();
|
||||
if level.is_empty() || !(depth.is_finite() && depth > 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let tol = (x.size().max(y.size()) * 2e-3).max(1e-4);
|
||||
let (Ok(mx), Ok(my)) = (x.mesh(&crate::MeshTolerance::chord(tol)), y.mesh(&crate::MeshTolerance::chord(tol))) else { return Vec::new() };
|
||||
// At most this many facets of a face are sampled.
|
||||
const SAMPLES: usize = 64;
|
||||
level
|
||||
.into_iter()
|
||||
.filter(|&(i, n)| {
|
||||
let centres: Vec<Vec3> = mx
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mx.tri_face)
|
||||
.filter(|(_, f)| **f as usize == i)
|
||||
.filter_map(|(t, _)| mx.tri(t))
|
||||
.filter(|[a, b, c]| (*b - *a).cross(*c - *a).len() > 0.0)
|
||||
.map(|[a, b, c]| (a + b + c) / 3.0)
|
||||
.collect();
|
||||
let stride = centres.len().div_ceil(SAMPLES).max(1);
|
||||
!centres.is_empty() && centres.iter().step_by(stride).all(|c| my.contains(*c + n * depth) && !my.contains(*c - n * depth))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The body's cylindrical faces, each as a wall of its own with no side or extent yet.
|
||||
fn cylinders(b: &Body) -> Vec<Wall> {
|
||||
(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, 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<Wall>, same: f64) -> Option<Vec<Wall>> {
|
||||
let mut out: Vec<Wall> = Vec::new();
|
||||
let mesh = b.mesh(&crate::MeshTolerance::chord((b.size() * 1e-3).max(1e-4))).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) => {
|
||||
// Where the face's origin lies along the wall's axis; a face whose axis runs the
|
||||
// other way measures its span backwards from there.
|
||||
let s = (origin - w.origin).dot(w.axis);
|
||||
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());
|
||||
// So it is for a pin on the very axis; one far off it isn't asked about.
|
||||
assert!(coaxial_overlap(&slotted, &plate_and_pin(3.0, 0.0).1).is_none());
|
||||
assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.5).1).is_none());
|
||||
}
|
||||
|
||||
/// A pin seated line to line, on the hole's very axis: the walls share nothing, and a head
|
||||
/// sunk 0.2 into the plate's face shares the ring under it.
|
||||
#[test]
|
||||
fn a_pin_seated_line_to_line_shares_only_what_its_head_sinks() {
|
||||
let (plate, pin) = plate_and_pin(3.0, 0.0);
|
||||
for (x, y) in [(&plate, &pin), (&pin, &plate)] {
|
||||
let o = coaxial_overlap(x, y).unwrap();
|
||||
assert!(o.volume < 1e-9 && o.offset < 1e-9 && (o.length - 5.0).abs() < 1e-9, "{o:?}");
|
||||
}
|
||||
let head = crate::cylinder(Vec3::new(0.0, 0.0, 4.8), Vec3::Z, 5.0, 2.0).unwrap();
|
||||
let headed = crate::boolean(&pin, &head, BoolOp::Union).unwrap().unwrap();
|
||||
let want = std::f64::consts::PI * (5.0f64.powi(2) - 3.0f64.powi(2)) * 0.2;
|
||||
let o = coaxial_overlap(&plate, &headed).unwrap();
|
||||
// Measured off the core's mesh, as a boolean's result is.
|
||||
assert!((o.volume - want).abs() < 1e-4 * want, "{o:?} {want}");
|
||||
assert!((o.centroid.z - 4.9).abs() < 1e-6, "{o:?}");
|
||||
}
|
||||
|
||||
/// A bush and a headed screw whose shank ends inside the bore, both written to STEP and
|
||||
/// read back: their cylinders come back as one face each that runs once round along a seam
|
||||
/// edge, which the boolean cannot intersect where the shank's end meets the bore. The
|
||||
/// walls are cut in two first and the answer is the one of the bodies as built.
|
||||
#[test]
|
||||
fn a_screw_in_a_bush_after_a_step_round_trip() {
|
||||
let e = 0.01;
|
||||
let outer = crate::cylinder(Vec3::ZERO, Vec3::Z, 5.0, 10.0).unwrap();
|
||||
let bore = crate::cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 3.0, 12.0).unwrap();
|
||||
let bush = crate::boolean(&outer, &bore, BoolOp::Cut).unwrap().unwrap();
|
||||
let shank = crate::cylinder(Vec3::new(e, 0.0, -2.0), Vec3::Z, 3.0, 8.0).unwrap();
|
||||
let head = crate::cylinder(Vec3::new(e, 0.0, -5.0), Vec3::Z, 5.25, 3.0).unwrap();
|
||||
let screw = crate::boolean(&shank, &head, BoolOp::Union).unwrap().unwrap();
|
||||
let back = |b: &Body| crate::step_import(&crate::step_export(&[b], "round trip").unwrap()).unwrap().bodies.remove(0).body;
|
||||
let (bush, screw) = (back(&bush), back(&screw));
|
||||
// The shank shares a crescent with the bush over the 6 mm it reaches into the bore.
|
||||
let want = 6.0 * crescent(3.0, 3.0, e).area;
|
||||
for (x, y) in [(&bush, &screw), (&screw, &bush)] {
|
||||
let o = coaxial_overlap(x, y).unwrap();
|
||||
assert!((o.volume - want).abs() < 1e-6 * want, "{o:?} {want}");
|
||||
assert!((o.length - 6.0).abs() < 1e-6, "{o:?}");
|
||||
}
|
||||
// Cut in two, each wall is a face more and the body keeps its volume.
|
||||
let split = crate::step_in::split::split_seams(&bush).unwrap();
|
||||
assert_eq!(split.face_count(), bush.face_count() + 2);
|
||||
let (v0, v1) = (crate::measure(&bush).unwrap().volume, crate::measure(&split).unwrap().volume);
|
||||
assert!((v0 - v1).abs() < 1e-6 * v0, "{v0} {v1}");
|
||||
assert!(crate::step_in::split::split_seams(&outer).is_none());
|
||||
}
|
||||
|
||||
/// A pin with a Ø7 collar embedded in a block (the collar's underside at z = 0, inside the
|
||||
/// block's material), a hair off its hole, and a pocket elsewhere in the block whose floor
|
||||
/// is at z = 0 too, facing the collar's underside: the two are level but don't touch, so the
|
||||
/// collar is not pulled back, and all of it inside the block counts.
|
||||
#[test]
|
||||
fn a_collar_level_with_a_pocket_floor_elsewhere_is_not_pulled_back() {
|
||||
let e = 0.01;
|
||||
let mut block = crate::box_solid(Vec3::new(-10.0, -10.0, -5.0), Vec3::new(20.0, 10.0, 3.0)).unwrap();
|
||||
let hole = crate::cylinder(Vec3::new(0.0, 0.0, -6.0), Vec3::Z, 2.0, 10.0).unwrap();
|
||||
let pocket = crate::box_solid(Vec3::new(10.0, -3.0, 0.0), Vec3::new(14.0, 3.0, 4.0)).unwrap();
|
||||
for cut in [&hole, &pocket] {
|
||||
block = crate::boolean(&block, cut, BoolOp::Cut).unwrap().unwrap();
|
||||
}
|
||||
let shank = crate::cylinder(Vec3::new(e, 0.0, -7.0), Vec3::Z, 2.0, 8.0).unwrap();
|
||||
let collar = crate::cylinder(Vec3::new(e, 0.0, 0.0), Vec3::Z, 3.5, 1.0).unwrap();
|
||||
let pin = crate::boolean(&shank, &collar, BoolOp::Union).unwrap().unwrap();
|
||||
// The collar less the hole, plus the shank's crescent over the 5 mm below the collar.
|
||||
let want = std::f64::consts::PI * (3.5 * 3.5 - 2.0 * 2.0) + 5.0 * crescent(2.0, 2.0, e).area;
|
||||
for (x, y) in [(&block, &pin), (&pin, &block)] {
|
||||
let o = coaxial_overlap(x, y).unwrap();
|
||||
assert!((o.volume - want).abs() < 1e-4 * want, "{o:?} {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two rivets with their heads flush on both faces of a sheet, each a hair off its hole,
|
||||
/// read back from STEP: the heads touch the sheet flat, which the boolean cannot always
|
||||
/// take, so they are pulled back off it first. Each rivet shares its own crescent.
|
||||
#[test]
|
||||
fn two_rivets_flush_on_a_sheet_after_a_step_round_trip() {
|
||||
let rivet = |x: f64| {
|
||||
let shank = crate::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::Z, 2.0, 3.5).unwrap();
|
||||
let under = crate::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::Z, 3.5, 1.0).unwrap();
|
||||
let over = crate::cylinder(Vec3::new(x, 0.0, 1.5), Vec3::Z, 3.5, 1.0).unwrap();
|
||||
let r = crate::boolean(&shank, &under, BoolOp::Union).unwrap().unwrap();
|
||||
crate::boolean(&r, &over, BoolOp::Union).unwrap().unwrap()
|
||||
};
|
||||
let mut sheet = crate::box_solid(Vec3::new(-20.0, -10.0, 0.0), Vec3::new(20.0, 10.0, 1.5)).unwrap();
|
||||
for x in [-4.0, 4.0] {
|
||||
let drill = crate::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::Z, 2.0, 4.0).unwrap();
|
||||
sheet = crate::boolean(&sheet, &drill, BoolOp::Cut).unwrap().unwrap();
|
||||
}
|
||||
let back = |b: &Body| crate::step_import(&crate::step_export(&[b], "round trip").unwrap()).unwrap().bodies.remove(0).body;
|
||||
let sheet = back(&sheet);
|
||||
for (x, e) in [(-4.0, 0.004), (4.0, 0.006)] {
|
||||
let r = back(&rivet(x + e));
|
||||
let want = 1.5 * crescent(2.0, 2.0, e).area;
|
||||
for (p, q) in [(&sheet, &r), (&r, &sheet)] {
|
||||
let o = coaxial_overlap(p, q).unwrap();
|
||||
assert!((o.volume - want).abs() < 1e-6 * want, "{o:?} {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//! A shallow circular cut after a pocket (#1765): repeated empty numerical answers must not
|
||||
//! stop the later geometric strategies. Inject the numerical failure on a real small part;
|
||||
//! the exact rounding and ray classification of the original design are not available.
|
||||
|
||||
use super::*;
|
||||
use crate::{box_solid, cylinder, fillet, measure, sphere, step_export, step_import};
|
||||
use std::cell::Cell;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn shallow_cut_after_a_pocket_recovers_from_empty_numerical_attempts() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
|
||||
let plate = fillet(&plate, &[Vec3::new(20.0, 0.0, 10.0)], 2.0).unwrap();
|
||||
let pocket = box_solid(Vec3::new(5.0, 15.0, 9.25), Vec3::new(35.0, 25.0, 10.25)).unwrap();
|
||||
let target = boolean(&plate, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let tool = cylinder(Vec3::new(12.0, 15.0, 8.5), Vec3::Z, 5.0, 1.75).unwrap();
|
||||
let attempts = Cell::new(0);
|
||||
let empty = |_: &Body, _: &Body, _: BoolOp, _: Option<mt::Vector3>, _: f64| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
Ok((Solid::new_unchecked(Vec::new()), None))
|
||||
};
|
||||
let (result, diagnostics) =
|
||||
capture_boolean_diagnostics(|| boolean_via(&target, &tool, BoolOp::Cut, &|a, b, op| boolean_whole_with(a, b, op, false, &empty)));
|
||||
assert!(attempts.get() >= 2, "the first two numerical tries must really have failed");
|
||||
let cut = result.unwrap().expect("a shallow cut leaves the plate");
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
assert_eq!(cut.lumps().unwrap().len(), 1);
|
||||
// Half of the circle overlaps the existing 0.75 mm pocket.
|
||||
let expected = 40.0 * 30.0 * 10.0 - (4.0 - PI) * 40.0 - 30.0 * 10.0 * 0.75 - PI * 25.0 * (1.5 - 0.75 / 2.0);
|
||||
let actual = measure(&cut).unwrap().volume;
|
||||
assert!((actual - expected).abs() < 0.02, "{actual} vs {expected}");
|
||||
assert!(diagnostics.events.iter().all(|e| e.strategy != BooleanStrategy::RefinedTolerance));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_seam_cut_recovers_from_empty_numerical_attempts() {
|
||||
let pin = cylinder(Vec3::new(-5.0, 0.0, 0.0), Vec3::X, 4.5, 10.0).unwrap();
|
||||
let target = step_import(&step_export(&[&pin], "seam regression").unwrap()).unwrap().bodies.remove(0).body;
|
||||
let tool = sphere(Vec3::new(-5.0, 0.0, 0.0), 6.0).unwrap();
|
||||
assert!(crate::seam_faces::needed(&target, &tool));
|
||||
let attempts = Cell::new(0);
|
||||
let shifted_attempts = Cell::new(0);
|
||||
let empty = |_: &Body, _: &Body, _: BoolOp, shift: Option<mt::Vector3>, _: f64| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
if shift.is_some() {
|
||||
shifted_attempts.set(shifted_attempts.get() + 1);
|
||||
}
|
||||
Ok((Solid::new_unchecked(Vec::new()), None))
|
||||
};
|
||||
let (result, diagnostics) =
|
||||
capture_boolean_diagnostics(|| boolean_via(&target, &tool, BoolOp::Cut, &|a, b, op| boolean_whole_with(a, b, op, false, &empty)));
|
||||
assert!(attempts.get() >= 2, "the initial numerical tries must really have failed");
|
||||
assert_eq!(shifted_attempts.get(), 0, "seam recovery must precede displaced retries");
|
||||
let cut = result.unwrap().expect("the spherical cut leaves the end of the pin");
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
assert_eq!(cut.lumps().unwrap().len(), 1);
|
||||
assert!(cut.history().is_some());
|
||||
// Along the common axis, integrate min(pi*4.5^2, pi*(6^2-x^2)) from 0 to 6.
|
||||
let full_radius_end = (6.0_f64.powi(2) - 4.5_f64.powi(2)).sqrt();
|
||||
let removed = PI * (4.5_f64.powi(2) * full_radius_end + 36.0 * (6.0 - full_radius_end) - (216.0 - full_radius_end.powi(3)) / 3.0);
|
||||
let expected = PI * 4.5_f64.powi(2) * 10.0 - removed;
|
||||
let actual = measure(&cut).unwrap().volume;
|
||||
assert!((actual - expected).abs() < expected * 1e-3, "{actual} vs {expected}");
|
||||
assert!(diagnostics.warning().is_none(), "{:?}", diagnostics.warning());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecovered_empty_attempts_keep_refusal_without_displacing_tool() {
|
||||
// These spheres keep fine_tols empty, isolating retries after the exact local strategies.
|
||||
let target = sphere(Vec3::ZERO, 10.0).unwrap();
|
||||
let tool = sphere(Vec3::new(8.0, 0.0, 0.0), 5.0).unwrap();
|
||||
let shifted_attempts = Cell::new(0);
|
||||
let empty = |_: &Body, _: &Body, _: BoolOp, shift: Option<mt::Vector3>, _: f64| {
|
||||
if shift.is_some() {
|
||||
shifted_attempts.set(shifted_attempts.get() + 1);
|
||||
}
|
||||
Ok((Solid::new_unchecked(Vec::new()), None))
|
||||
};
|
||||
let result = boolean_via(&target, &tool, BoolOp::Cut, &|a, b, op| boolean_whole_with(a, b, op, false, &empty));
|
||||
assert_eq!(shifted_attempts.get(), 0, "unrecovered empty attempts must not enable displaced retries");
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("it came out empty"), "{error}");
|
||||
}
|
||||
@@ -22,8 +22,6 @@ mod blend_variable;
|
||||
#[cfg(test)]
|
||||
mod blend_wrong_way_tests;
|
||||
mod body;
|
||||
mod boolean_diagnostics;
|
||||
mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod boundary;
|
||||
mod boundary_network;
|
||||
@@ -32,7 +30,6 @@ mod build;
|
||||
#[cfg(test)]
|
||||
mod chamfer_sides_curved_tests;
|
||||
mod clearance;
|
||||
mod coaxial_overlap;
|
||||
mod continuity;
|
||||
mod coplanar;
|
||||
mod corner_g2;
|
||||
@@ -163,7 +160,6 @@ pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
@@ -172,7 +168,6 @@ pub use build::{
|
||||
loft_with_rails, path_polyline, planar_face_record, revolve, revolve_whole, sphere, sweep, sweep_with, torus,
|
||||
};
|
||||
pub use clearance::{Clearance, Occupancy};
|
||||
pub use coaxial_overlap::{CoaxialOverlap, coaxial_candidate, coaxial_overlap};
|
||||
pub use continuity::{
|
||||
ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics,
|
||||
};
|
||||
@@ -200,7 +195,7 @@ pub use native::{brep_record, decode_native_body, encode_native_body, native_pre
|
||||
pub use offset::with_same_surface;
|
||||
pub use ops::{
|
||||
BoolOp, boolean, can_join_all_touching_faces, can_join_touching_faces, compound_disjoint_extrusions, half_space, is_planar, join_apart,
|
||||
place_matrix, split_by_plane, transform, transform_matrix, union_apart, with_attempt_budget,
|
||||
place_matrix, split_by_plane, transform, transform_matrix, union_apart,
|
||||
};
|
||||
pub use point_path::{PathPiece, PointPath, SpaceBezier, curve_path, edge_path, sketch_record_curve, sweep_point_path_with};
|
||||
pub use polybool::faceted_boolean;
|
||||
|
||||
+119
-2404
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
//! The prism fast path: `boolean_whole` asks the exact 2D prism route before it meshes both
|
||||
//! operands, so a plate and the tool through it are decided without a mesh of either.
|
||||
use super::*;
|
||||
use crate::{BoolOp, boolean, box_solid, cylinder, measure};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// A cylindrical tool through a plate: the exact prism route answers the pair, and the material
|
||||
/// it removes is the tool's own.
|
||||
#[test]
|
||||
fn a_hole_through_a_plate_removes_the_tool_volume() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).expect("plate");
|
||||
let tool = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).expect("tool");
|
||||
let cut = boolean(&plate, &tool, BoolOp::Cut).expect("cut").expect("a hole leaves material");
|
||||
let m = measure(&cut).expect("measure");
|
||||
let want = 40.0 * 30.0 * 10.0 - PI * 25.0 * 10.0;
|
||||
assert!((m.volume - want).abs() < want * 5e-3, "{} vs {want}", m.volume);
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
}
|
||||
|
||||
/// Boxes that stay apart: the union keeps both, whichever route decides it.
|
||||
#[test]
|
||||
fn bodies_that_stay_apart_join_into_one_body_with_both_lumps() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).expect("a");
|
||||
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).expect("b");
|
||||
let joined = boolean(&a, &b, BoolOp::Union).expect("union").expect("two boxes");
|
||||
assert!((measure(&joined).expect("measure").volume - 2000.0).abs() < 1e-6);
|
||||
assert_eq!(joined.lumps().expect("lumps").len(), 2);
|
||||
}
|
||||
|
||||
/// A tool wholly inside its target leaves a closed void, not a through cut.
|
||||
#[test]
|
||||
fn a_tool_wholly_inside_leaves_a_closed_void() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 40.0)).expect("block");
|
||||
let inner = box_solid(Vec3::new(15.0, 15.0, 15.0), Vec3::new(25.0, 25.0, 25.0)).expect("inner");
|
||||
let cut = boolean(&block, &inner, BoolOp::Cut).expect("cut").expect("a void leaves material");
|
||||
assert!((measure(&cut).expect("measure").volume - 63000.0).abs() < 1e-6);
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
}
|
||||
|
||||
/// A tool that misses its target, in section or along the extrusion, leaves it as it is: the
|
||||
/// sampled shortcut answers that by reusing the operand itself, and the 2D route must not
|
||||
/// rebuild it into a look-alike solid on the way past - an untouched body in a timeline has to
|
||||
/// stay the very same body.
|
||||
#[test]
|
||||
fn a_cut_that_changes_nothing_keeps_the_original_body() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).expect("plate");
|
||||
let past_the_end = box_solid(Vec3::new(200.0, 200.0, -5.0), Vec3::new(210.0, 210.0, 20.0)).expect("tool");
|
||||
assert!(
|
||||
boolean(&plate, &past_the_end, BoolOp::Cut).expect("cut").expect("material stays").same(&plate),
|
||||
"a tool clear of the plate must leave the very same body"
|
||||
);
|
||||
let inside_the_height = box_solid(Vec3::new(200.0, 200.0, 5.0), Vec3::new(210.0, 210.0, 20.0)).expect("tool");
|
||||
assert!(
|
||||
boolean(&plate, &inside_the_height, BoolOp::Cut).expect("cut").expect("material stays").same(&plate),
|
||||
"a tool that only overlaps the plate's height must leave the very same body"
|
||||
);
|
||||
let covers_it = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(50.0, 40.0, 20.0)).expect("tool");
|
||||
assert!(boolean(&plate, &covers_it, BoolOp::Cut).expect("cut").is_none(), "a tool over the whole plate removes it");
|
||||
}
|
||||
@@ -35,6 +35,7 @@ unsafe extern "C" {
|
||||
pub fn smlib_brep_write(brep: *const RawBrep, path: *const c_char, ascii: i32) -> i32;
|
||||
pub fn smlib_brep_free(brep: *mut RawBrep);
|
||||
pub fn smlib_brep_copy(brep: *const RawBrep, out: *mut *mut RawBrep) -> i32;
|
||||
pub fn smlib_brep_transform(brep: *const RawBrep, matrix: *const [f64; 4], out: *mut *mut RawBrep) -> i32;
|
||||
pub fn smlib_make_box(origin: *const f64, size: *const f64, out: *mut *mut RawBrep) -> i32;
|
||||
pub fn smlib_brep_volume(brep: *const RawBrep, relative_accuracy: f64, out: *mut f64) -> i32;
|
||||
pub fn smlib_brep_counts(brep: *const RawBrep, out: *mut i64) -> i32;
|
||||
@@ -119,6 +120,9 @@ mod calls {
|
||||
pub fn copy(b: &Handle) -> Result<Handle, i32> {
|
||||
new_brep(|out| unsafe { smlib_brep_copy(b.raw(), out) })
|
||||
}
|
||||
pub fn transform(b: &Handle, matrix: &[[f64; 4]; 4]) -> Result<Handle, i32> {
|
||||
new_brep(|out| unsafe { smlib_brep_transform(b.raw(), matrix.as_ptr(), out) })
|
||||
}
|
||||
pub fn make_box(origin: &[f64; 3], size: &[f64; 3]) -> Result<Handle, i32> {
|
||||
new_brep(|out| unsafe { smlib_make_box(origin.as_ptr(), size.as_ptr(), out) })
|
||||
}
|
||||
@@ -166,6 +170,9 @@ mod calls {
|
||||
pub fn copy(_: &Handle) -> Result<Handle, i32> {
|
||||
Err(NOT_BUILT)
|
||||
}
|
||||
pub fn transform(_: &Handle, _: &[[f64; 4]; 4]) -> Result<Handle, i32> {
|
||||
Err(NOT_BUILT)
|
||||
}
|
||||
pub fn make_box(_: &[f64; 3], _: &[f64; 3]) -> Result<Handle, i32> {
|
||||
Err(NOT_BUILT)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,14 @@ impl Brep {
|
||||
ffi::copy(&self.0).map(Brep).map_err(error)
|
||||
}
|
||||
|
||||
/// This body moved by the affine `matrix` (column-major: columns 0–2 the images of x, y and
|
||||
/// z, column 3 the translation), as SMLib's `SmBrep::Transform` takes a matrix: reflections
|
||||
/// mirror it, a non-uniform scale or a shear turns its geometry into NURBS, tolerances scale
|
||||
/// with it.
|
||||
pub fn transformed(&self, matrix: &[[f64; 4]; 4]) -> Result<Brep> {
|
||||
ffi::transform(&self.0, matrix).map(Brep).map_err(error)
|
||||
}
|
||||
|
||||
/// The enclosed volume (mm³) at `relative_accuracy` (SMLib clamps it to [1e-8, 1e-1]).
|
||||
pub fn volume(&self, relative_accuracy: f64) -> Result<f64> {
|
||||
ffi::volume(&self.0, relative_accuracy).map_err(error)
|
||||
|
||||
Reference in New Issue
Block a user