Report accepted Boolean retries on features and fuzz runs (#249)

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 10:38:04 -04:00
1 parent 611422e4df
commit f0dda667bb
15 files changed
+491 -26

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
//! 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::Serialize;
const MAX_EVENTS: usize = 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BooleanStrategy {
RefinedTolerance,
ShiftedOperands,
CoplanarSeparation,
CommonBore,
PlanarFallback,
ToolNudge,
Piecewise,
}
/// 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)]
pub struct BooleanFallback {
pub strategy: BooleanStrategy,
pub translation_mm: f64,
pub rotation_rad: f64,
pub face_shift_mm: f64,
pub tolerance_mm: f64,
}
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::PlanarFallback => "exact planar fallback (no displacement)".into(),
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct BooleanDiagnostics {
pub events: Vec<BooleanFallback>,
pub truncated: bool,
}
impl BooleanDiagnostics {
pub(crate) fn push(&mut self, event: BooleanFallback) {
if self.events.len() < MAX_EVENTS {
self.events.push(event);
} else {
self.truncated = true;
}
}
pub fn warning(&self) -> Option<String> {
if self.events.is_empty() && !self.truncated {
return None;
}
let mut descriptions: Vec<String> = self.events.iter().take(4).map(BooleanFallback::description).collect();
if self.events.len() > 4 {
descriptions.push(format!("{} additional retries", self.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);
}
});
}
pub(crate) fn merge(report: BooleanDiagnostics) {
for event in report.events {
record(event);
}
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());
}
#[test]
fn exact_operations_and_failed_calls_have_no_retry_report() {
use solvecraft_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"));
}
}
+9 -2
View File
@@ -557,8 +557,15 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
let cut = crate::build::cylinder(at(lo, 0.0, 0.0), c.axis, c.radius, hi - lo)?;
crate::ops::boolean(&joined, &cut, BoolOp::Cut)
};
match run() {
Ok(Some(r)) if accept(&r) => return Some(r),
let (result, diagnostics) = crate::capture_boolean_diagnostics(run);
match result {
Ok(Some(r)) if accept(&r) => {
crate::boolean_diagnostics::merge(diagnostics);
let mut event = crate::BooleanFallback::new(crate::BooleanStrategy::CommonBore);
event.face_shift_mm = d;
crate::boolean_diagnostics::record(event);
return Some(r);
}
_ => continue,
}
}
+17 -2
View File
@@ -99,6 +99,8 @@ pub struct BoolCase {
/// "ok", or the kind of failure: "error", "invalid", "identity".
pub outcome: String,
pub detail: String,
pub boolean_fallbacks: std::collections::BTreeMap<crate::BooleanStrategy, usize>,
pub boolean_reports_truncated: bool,
}
fn vol(b: &Option<Body>) -> crate::Result<f64> {
@@ -118,7 +120,15 @@ pub fn boolean_shapes(seed: u64) -> (Shape, Shape) {
/// V(A∪B) + V(A∩B) = V(A) + V(B) and V(A−B) = V(A) − V(A∩B), within 0.2 %.
pub fn boolean_case(seed: u64) -> BoolCase {
let (a, b) = boolean_shapes(seed);
let mut case = BoolCase { seed, a: a.clone(), b: b.clone(), outcome: "ok".into(), detail: String::new() };
let mut case = BoolCase {
seed,
a: a.clone(),
b: b.clone(),
outcome: "ok".into(),
detail: String::new(),
boolean_fallbacks: Default::default(),
boolean_reports_truncated: false,
};
let fail = |c: &mut BoolCase, kind: &str, d: String| {
c.outcome = kind.into();
c.detail = d;
@@ -129,7 +139,12 @@ pub fn boolean_case(seed: u64) -> BoolCase {
};
let mut res = Vec::new();
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
match boolean(&ba, &bb, op) {
let (result, diagnostics) = crate::capture_boolean_diagnostics(|| boolean(&ba, &bb, op));
case.boolean_reports_truncated |= diagnostics.truncated;
for event in diagnostics.events {
*case.boolean_fallbacks.entry(event.strategy).or_default() += 1;
}
match result {
Ok(x) => {
if let Some(body) = &x {
let bad = body.validity();
+2
View File
@@ -16,6 +16,7 @@ mod blend;
mod blend_corner;
mod blend_variable;
mod body;
mod boolean_diagnostics;
mod bore_guard;
mod build;
mod coplanar;
@@ -62,6 +63,7 @@ pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use blend_corner::fillet_radii;
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
+69 -12
View File
@@ -5,6 +5,7 @@ use solvecraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, Solid, from_p3};
use crate::boolean_diagnostics::{BooleanFallback, BooleanStrategy, capture_boolean_diagnostics, merge, record};
use crate::{KernelError, Result, guard};
#[cfg(test)]
@@ -192,7 +193,7 @@ pub(crate) fn narrowing_bores() -> bool {
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
/// and retried with shifted copies and other tolerances when they fail or look wrong.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let mut r = boolean_whole(a, b, op);
let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| boolean_whole(a, b, op));
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
let retry = match &r {
Err(_) => true,
@@ -201,10 +202,13 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
};
if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) {
let (la, lb) = (a.lumps()?, b.lumps()?);
if (la.len() > 1 || lb.len() > 1)
&& let Ok(Some(x)) = by_lumps(la, lb, op)
{
r = Ok(Some(x));
if la.len() > 1 || lb.len() > 1 {
let (pieces, report) = capture_boolean_diagnostics(|| by_lumps(la, lb, op));
if let Ok(Some(x)) = pieces {
r = Ok(Some(x));
diagnostics = report;
diagnostics.push(BooleanFallback::new(BooleanStrategy::Piecewise));
}
}
}
if op == BoolOp::Union
@@ -215,7 +219,11 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Prefer the exact polygon result; reject an edge-only pinch if it cannot
// form a closed solid without displacing either operand.
r = match crate::polybool::planar_boolean(a, b, op) {
Ok(Some(exact)) if preserves_planar_surfaces(a, b, &exact) => Ok(Some(exact)),
Ok(Some(exact)) if preserves_planar_surfaces(a, b, &exact) => {
diagnostics = Default::default();
diagnostics.events.push(BooleanFallback::new(BooleanStrategy::PlanarFallback));
Ok(Some(exact))
}
_ => Err(KernelError::Invalid(
"join cannot preserve the original faces; check for zero-thickness edge contact or add intentional overlap".into(),
)),
@@ -225,6 +233,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
}
if r.is_ok() {
merge(diagnostics);
}
r
}
@@ -353,11 +364,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
if !APART.with(|c| c.get()) && crate::coplanar::has_coincident_faces(a, b) {
APART.with(|c| c.set(true));
let r = crate::coplanar::boolean_apart(a, b, op);
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::boolean_apart(a, b, op));
APART.with(|c| c.set(false));
if let Some(Ok(Some(body))) = r {
let (ok, _) = check(&body);
if ok {
merge(diagnostics);
let mut event = BooleanFallback::new(BooleanStrategy::CoplanarSeparation);
event.face_shift_mm = size * 0.0229;
record(event);
return Ok(Some(body));
}
}
@@ -365,13 +380,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Bores on a common cylinder (a pad's bore around the hole it is joined over).
if op == BoolOp::Union && !BORES.with(|c| c.get()) && crate::coplanar::has_coincident_cylinders(a, b) {
BORES.with(|c| c.set(true));
let r = crate::coplanar::bore_union(a, b, &|body| check(body).0);
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::bore_union(a, b, &|body| check(body).0));
BORES.with(|c| c.set(false));
if let Some(body) = r {
merge(diagnostics);
return Ok(Some(body));
}
}
let mut empty_votes = 0;
let mut empty_diagnostics = crate::BooleanDiagnostics::default();
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
for (attempt, j) in JITTER.iter().enumerate() {
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
@@ -397,12 +414,29 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
match r {
Ok(s) if s.boundaries().is_empty() || s.face_iter().next().is_none() => {
empty_votes += 1;
if attempt > 0 || k != 2e-3 {
let mut event =
BooleanFallback::new(if attempt > 0 { BooleanStrategy::ShiftedOperands } else { BooleanStrategy::RefinedTolerance });
event.translation_mm = Vec3::new(j[0], j[1], j[2]).len() * (size * 0.01);
event.tolerance_mm = tol;
empty_diagnostics.push(event);
}
last = "empty result".into();
}
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
Ok(body) => {
let (ok, v) = check(&body);
if ok {
if attempt > 0 || k != 2e-3 {
let mut event = BooleanFallback::new(if attempt > 0 {
BooleanStrategy::ShiftedOperands
} else {
BooleanStrategy::RefinedTolerance
});
event.translation_mm = Vec3::new(j[0], j[1], j[2]).len() * (size * 0.01);
event.tolerance_mm = tol;
record(event);
}
return Ok(Some(body));
}
last = format!("implausible result volume {v:.4}");
@@ -413,18 +447,23 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
}
if empty_votes >= 2 && op != BoolOp::Union {
merge(empty_diagnostics);
return Ok(None);
}
}
// Coincident planar faces: push them apart (exactly) and try again.
if !APART.with(|c| c.get()) {
APART.with(|c| c.set(true));
let r = crate::coplanar::boolean_apart(a, b, op);
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::boolean_apart(a, b, op));
APART.with(|c| c.set(false));
match r {
Some(Ok(Some(body))) => {
let (ok, v) = check(&body);
if ok {
merge(diagnostics);
let mut event = BooleanFallback::new(BooleanStrategy::CoplanarSeparation);
event.face_shift_mm = size * 0.0229;
record(event);
return Ok(Some(body));
}
last = format!("{last}; faces pushed apart gave an implausible volume {v:.4}");
@@ -440,11 +479,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Ok(Some(body)) => {
let (ok, v) = check(&body);
if ok {
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
return Ok(Some(body));
}
last = format!("planar fallback gave an implausible volume {v:.4}");
}
Ok(None) => return Ok(None),
Ok(None) => {
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
return Ok(None);
}
Err(e) => last = format!("{last}; {e}"),
}
// Last resort: nudge the tool by a micron-scale screw motion (turned 2e-6 rad about a
@@ -453,17 +496,31 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
if !APART.with(|c| c.get()) {
APART.with(|c| c.set(true));
let c = b.solid.vertex_iter().fold(Vec3::ZERO, |acc, v| acc + from_p3(v.point())) * (1.0 / b.solid.vertex_iter().count().max(1) as f64);
let r = transform(b, Vec3::new(0.61, -0.37, 0.71) * (size * 2e-6), c, Vec3::new(0.27, 0.83, -0.49), 2e-6).and_then(|nb| boolean(a, &nb, op));
let translation = Vec3::new(0.61, -0.37, 0.71) * (size * 2e-6);
let (r, diagnostics) =
capture_boolean_diagnostics(|| transform(b, translation, c, Vec3::new(0.27, 0.83, -0.49), 2e-6).and_then(|nb| boolean(a, &nb, op)));
APART.with(|c| c.set(false));
match r {
Ok(Some(body)) => {
let (ok, v) = check(&body);
if ok {
merge(diagnostics);
let mut event = BooleanFallback::new(BooleanStrategy::ToolNudge);
event.translation_mm = translation.len();
event.rotation_rad = 2e-6;
record(event);
return Ok(Some(body));
}
last = format!("{last}; the nudged tool gave an implausible volume {v:.4}");
}
Ok(None) => return Ok(None),
Ok(None) => {
merge(diagnostics);
let mut event = BooleanFallback::new(BooleanStrategy::ToolNudge);
event.translation_mm = translation.len();
event.rotation_rad = 2e-6;
record(event);
return Ok(None);
}
Err(e) => last = format!("{last}; nudged: {e}"),
}
}
+4 -1
View File
@@ -324,7 +324,10 @@ fn drain_pads_flush_under_a_floor_with_a_hole() {
// The ring's part inside the floor adds nothing: 5 mm of ring below it either way.
let v_ring = PI * (400.0 - 100.0) * 5.0;
for (z0, z1) in [(-5.0, 0.0), (-5.0, 2.0)] {
let u = boolean(&floor, &ring(z0, z1), BoolOp::Union).unwrap().unwrap();
let (result, diagnostics) = capture_boolean_diagnostics(|| boolean(&floor, &ring(z0, z1), BoolOp::Union));
let u = result.unwrap().unwrap();
assert!(diagnostics.events.iter().any(|event| event.strategy == BooleanStrategy::CommonBore), "{diagnostics:?}");
assert!(diagnostics.events.iter().all(|event| event.translation_mm.is_finite() && event.face_shift_mm.is_finite()));
let v = measure(&u).unwrap().volume;
assert!(rel(v, v_floor + v_ring) < 2e-5, "ring {z0}..{z1}: {v} vs {}", v_floor + v_ring);
assert!(u.validity().is_empty(), "{:?}", u.validity());