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:
jyviko
2026-10-22 01:08:38 -04:00
parent f56a94eac6
commit e36f8d7774
10 changed files with 135 additions and 3872 deletions
-307
View File
@@ -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"));
}
}
-218
View File
@@ -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, &region) {
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, &region) {
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(())
}
-109
View File
@@ -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}");
}
-686
View File
@@ -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}");
}
}
}
}
-83
View File
@@ -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}");
}
+1 -6
View File
@@ -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
View File
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");
}
+7
View File
@@ -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)
}
+8
View File
@@ -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)