Report accepted Boolean retries on features and fuzz runs (#249)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
15 files changed
+491
-26
No files matched your search
@@ -331,6 +331,12 @@ or edited values, including the Appearance panel.
|
|||||||
|
|
||||||
## Kernel
|
## Kernel
|
||||||
|
|
||||||
|
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||||
|
feature warnings and structured inspection data. Diagnostics survive cached
|
||||||
|
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||||
|
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||||
|
acceptance checks remain in force.
|
||||||
|
|
||||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
|
|||||||
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
|
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
|
||||||
"unsupported": run.unsupported,
|
"unsupported": run.unsupported,
|
||||||
"rejected": run.rejected,
|
"rejected": run.rejected,
|
||||||
|
"boolean_fallbacks": run.boolean_fallbacks,
|
||||||
|
"boolean_reports_truncated": run.boolean_reports_truncated,
|
||||||
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
|
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
|
||||||
});
|
});
|
||||||
if run.outcome.is_bug() {
|
if run.outcome.is_bug() {
|
||||||
@@ -179,9 +181,23 @@ fn summarise(results: &[Value], took: Duration) -> String {
|
|||||||
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
|
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
|
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
|
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let mut fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
|
||||||
|
let mut truncated = [0usize; 2];
|
||||||
let mut steps = 0;
|
let mut steps = 0;
|
||||||
let (mut dp, mut bp) = (0, 0);
|
let (mut dp, mut bp) = (0, 0);
|
||||||
for v in results {
|
for v in results {
|
||||||
|
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
|
||||||
|
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
|
||||||
|
truncated[i] += 1;
|
||||||
|
}
|
||||||
|
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
|
||||||
|
for (strategy, count) in counts {
|
||||||
|
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
|
||||||
|
*total = total.saturating_add(count.as_u64().unwrap_or(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
|
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
|
||||||
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
|
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
|
||||||
let k = if v.get("timeout").is_some() {
|
let k = if v.get("timeout").is_some() {
|
||||||
@@ -220,5 +236,28 @@ fn summarise(results: &[Value], took: Duration) -> String {
|
|||||||
for (k, c) in &boolean {
|
for (k, c) in &boolean {
|
||||||
s += &format!("- {k}: {}\n", pct(*c));
|
s += &format!("- {k}: {}\n", pct(*c));
|
||||||
}
|
}
|
||||||
|
s += "\nSuccessful Boolean retries (events, including nested operations):\n";
|
||||||
|
for (strategy, [designs, checks]) in &fallbacks {
|
||||||
|
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
|
||||||
|
}
|
||||||
|
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
|
||||||
|
let results = [
|
||||||
|
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
|
||||||
|
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
|
||||||
|
json!({"timeout":90}),
|
||||||
|
];
|
||||||
|
let text = summarise(&results, Duration::ZERO);
|
||||||
|
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
|
||||||
|
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
|
||||||
|
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-4
@@ -352,6 +352,7 @@ pub struct EvalCache {
|
|||||||
struct CachedEval {
|
struct CachedEval {
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
warning: Option<String>,
|
warning: Option<String>,
|
||||||
|
boolean_diagnostics: kernel::BooleanDiagnostics,
|
||||||
ms: f64,
|
ms: f64,
|
||||||
state: Arc<ModelState>,
|
state: Arc<ModelState>,
|
||||||
}
|
}
|
||||||
@@ -395,6 +396,7 @@ pub struct FeatureResult {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub warning: Option<String>,
|
pub warning: Option<String>,
|
||||||
|
pub boolean_diagnostics: kernel::BooleanDiagnostics,
|
||||||
pub ms: f64,
|
pub ms: f64,
|
||||||
pub skipped: bool,
|
pub skipped: bool,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
@@ -635,6 +637,7 @@ impl Model {
|
|||||||
name: f.name.clone(),
|
name: f.name.clone(),
|
||||||
error: None,
|
error: None,
|
||||||
warning: None,
|
warning: None,
|
||||||
|
boolean_diagnostics: Default::default(),
|
||||||
ms: 0.0,
|
ms: 0.0,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -656,6 +659,7 @@ impl Model {
|
|||||||
name: f.name.clone(),
|
name: f.name.clone(),
|
||||||
error: c.error,
|
error: c.error,
|
||||||
warning: c.warning,
|
warning: c.warning,
|
||||||
|
boolean_diagnostics: c.boolean_diagnostics,
|
||||||
ms: c.ms,
|
ms: c.ms,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -699,12 +703,22 @@ impl Model {
|
|||||||
};
|
};
|
||||||
// A parameter with an error fails the features using it, with that error.
|
// A parameter with an error fails the features using it, with that error.
|
||||||
let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}")));
|
let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}")));
|
||||||
let r = match bad_param {
|
let (r, mut boolean_diagnostics) = kernel::capture_boolean_diagnostics(|| match bad_param {
|
||||||
Some(e) => Err(kernel::KernelError::Failed(e)),
|
Some(e) => Err(kernel::KernelError::Failed(e)),
|
||||||
None => kernel::guard(&f.name, || {
|
None => kernel::guard(&f.name, || {
|
||||||
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||||
}),
|
}),
|
||||||
};
|
});
|
||||||
|
if r.is_ok() {
|
||||||
|
if let Some(note) = boolean_diagnostics.warning() {
|
||||||
|
warning = Some(match warning {
|
||||||
|
Some(existing) => format!("{existing}; {note}"),
|
||||||
|
None => note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
boolean_diagnostics = Default::default();
|
||||||
|
}
|
||||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
let error = match r {
|
let error = match r {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -730,9 +744,28 @@ impl Model {
|
|||||||
if !self.no_cache
|
if !self.no_cache
|
||||||
&& let Ok(mut m) = self.cache.lock()
|
&& let Ok(mut m) = self.cache.lock()
|
||||||
{
|
{
|
||||||
m.put(key, CachedEval { error: error.clone(), warning: warning.clone(), ms, state: state.clone() });
|
m.put(
|
||||||
|
key,
|
||||||
|
CachedEval {
|
||||||
|
error: error.clone(),
|
||||||
|
warning: warning.clone(),
|
||||||
|
boolean_diagnostics: boolean_diagnostics.clone(),
|
||||||
|
ms,
|
||||||
|
state: state.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() });
|
out.push(FeatureResult {
|
||||||
|
id: f.id,
|
||||||
|
name: f.name.clone(),
|
||||||
|
error,
|
||||||
|
warning,
|
||||||
|
boolean_diagnostics,
|
||||||
|
ms,
|
||||||
|
skipped: false,
|
||||||
|
fingerprint: fp,
|
||||||
|
state: state.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
self.results = out;
|
self.results = out;
|
||||||
self.last_recomputed = recomputed;
|
self.last_recomputed = recomputed;
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||||
|
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
json!({
|
json!({
|
||||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||||
|
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ pub struct Run {
|
|||||||
pub unsupported: Vec<(String, String)>,
|
pub unsupported: Vec<(String, String)>,
|
||||||
/// Steps whose generated parameters a command refused.
|
/// Steps whose generated parameters a command refused.
|
||||||
pub rejected: usize,
|
pub rejected: usize,
|
||||||
|
pub boolean_fallbacks: std::collections::BTreeMap<solvecraft_kernel::BooleanStrategy, usize>,
|
||||||
|
pub boolean_reports_truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Run {
|
impl Run {
|
||||||
@@ -289,7 +291,15 @@ pub fn run(seed: u64, max_steps: usize) -> Run {
|
|||||||
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
|
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
|
||||||
let mut r = Rng::new(seed);
|
let mut r = Rng::new(seed);
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
|
let mut run = Run {
|
||||||
|
seed,
|
||||||
|
steps: Vec::new(),
|
||||||
|
outcome: Outcome::ok(),
|
||||||
|
unsupported: Vec::new(),
|
||||||
|
rejected: 0,
|
||||||
|
boolean_fallbacks: Default::default(),
|
||||||
|
boolean_reports_truncated: false,
|
||||||
|
};
|
||||||
let mut last_feature: Option<String> = None;
|
let mut last_feature: Option<String> = None;
|
||||||
let n = 2 + r.below(max_steps.max(2) - 1);
|
let n = 2 + r.below(max_steps.max(2) - 1);
|
||||||
let mut tries = 0;
|
let mut tries = 0;
|
||||||
@@ -314,6 +324,12 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = st.kind;
|
let kind = st.kind;
|
||||||
|
for result in s.model.results.iter().skip(before).filter(|result| result.error.is_none()) {
|
||||||
|
run.boolean_reports_truncated |= result.boolean_diagnostics.truncated;
|
||||||
|
for event in &result.boolean_diagnostics.events {
|
||||||
|
*run.boolean_fallbacks.entry(event.strategy).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
run.steps.push(st);
|
run.steps.push(st);
|
||||||
if out.kind != "ok" {
|
if out.kind != "ok" {
|
||||||
run.outcome = out;
|
run.outcome = out;
|
||||||
@@ -329,14 +345,19 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
|
|||||||
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
|
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
|
||||||
/// that isn't ok (or the last step's, ok).
|
/// that isn't ok (or the last step's, ok).
|
||||||
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
|
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
|
||||||
|
let (index, outcome, _) = replay_session(steps);
|
||||||
|
(index, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_session(steps: &[Step]) -> (usize, Outcome, Session) {
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
for (i, st) in steps.iter().enumerate() {
|
for (i, st) in steps.iter().enumerate() {
|
||||||
let out = apply(&mut s, st);
|
let out = apply(&mut s, st);
|
||||||
if out.kind != "ok" {
|
if out.kind != "ok" {
|
||||||
return (i, out);
|
return (i, out, s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(steps.len().saturating_sub(1), Outcome::ok())
|
(steps.len().saturating_sub(1), Outcome::ok(), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
|
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
|
||||||
@@ -367,6 +388,14 @@ pub fn minimise(run: &Run) -> Run {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (_, outcome) = replay(&steps);
|
let (_, outcome, session) = replay_session(&steps);
|
||||||
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
|
let mut boolean_fallbacks = std::collections::BTreeMap::new();
|
||||||
|
let mut boolean_reports_truncated = false;
|
||||||
|
for result in &session.model.results {
|
||||||
|
boolean_reports_truncated |= result.boolean_diagnostics.truncated;
|
||||||
|
for event in &result.boolean_diagnostics.events {
|
||||||
|
*boolean_fallbacks.entry(event.strategy).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0, boolean_fallbacks, boolean_reports_truncated }
|
||||||
}
|
}
|
||||||
@@ -118,6 +118,7 @@ fn join_meeting_nothing_keeps_the_first_body() {
|
|||||||
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
|
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod boolean_diagnostics;
|
||||||
mod component_frames;
|
mod component_frames;
|
||||||
mod mirror_planes;
|
mod mirror_planes;
|
||||||
mod primitive_points;
|
mod primitive_points;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use super::*;
|
||||||
|
use solvecraft_kernel::BooleanStrategy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boolean_retry_warning_and_details_survive_cache_undo_and_reopen() {
|
||||||
|
let slope = 1f64.to_radians().tan();
|
||||||
|
let mut session = Session::default();
|
||||||
|
run(&mut session, "sketch.create", json!({"plane":"XZ","name":"Floor section"}));
|
||||||
|
run(&mut session, "sketch.line", json!({"points":[[0,0],[100,0],[100,-10.-slope*100.],[0,-10.]],"closed":true}));
|
||||||
|
run(&mut session, "sketch.finish", json!({}));
|
||||||
|
run(&mut session, "solid.extrude", json!({"sketch":"Floor section","distance":80,"body_name":"Floor"}));
|
||||||
|
run(&mut session, "solid.cylinder", json!({"base":[50,40,-5],"radius":10,"height":30,"operation":"cut"}));
|
||||||
|
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Ring section"}));
|
||||||
|
run(&mut session, "sketch.circle.center", json!({"center":[50,40],"radius":20}));
|
||||||
|
run(&mut session, "sketch.circle.center", json!({"center":[50,40],"radius":10}));
|
||||||
|
run(&mut session, "sketch.finish", json!({}));
|
||||||
|
run(
|
||||||
|
&mut session,
|
||||||
|
"solid.extrude",
|
||||||
|
json!({"sketch":"Ring section","profiles":[{"point":[65,40]}],"start_offset":-5,"distance":7,"body_name":"Ring"}),
|
||||||
|
);
|
||||||
|
let id = run(&mut session, "solid.combine", json!({"target":"Floor","tools":["Ring"],"operation":"join"}))["feature"].as_u64().unwrap();
|
||||||
|
let check = |session: &Session| {
|
||||||
|
let result = session.model.result(id).unwrap();
|
||||||
|
assert!(result.error.is_none());
|
||||||
|
assert!(result.warning.as_ref().is_some_and(|warning| warning.contains("Boolean retry:")), "{result:?}");
|
||||||
|
assert!(
|
||||||
|
result.boolean_diagnostics.events.iter().any(|event| event.strategy == BooleanStrategy::CommonBore),
|
||||||
|
"{:?}",
|
||||||
|
result.boolean_diagnostics
|
||||||
|
);
|
||||||
|
result.boolean_diagnostics.clone()
|
||||||
|
};
|
||||||
|
let original = check(&session);
|
||||||
|
let expected = (10. + slope * 50.) * (8000. - PI * 100.) + PI * 300. * 5.;
|
||||||
|
assert!(rel(volume(&mut session), expected) < 2e-5);
|
||||||
|
let inspected = run(&mut session, "timeline.inspect", json!({"feature":id}));
|
||||||
|
assert_eq!(inspected["boolean_diagnostics"], serde_json::to_value(&original).unwrap());
|
||||||
|
session.refresh();
|
||||||
|
assert_eq!(check(&session), original);
|
||||||
|
run(&mut session, "edit.undo", json!({}));
|
||||||
|
run(&mut session, "edit.redo", json!({}));
|
||||||
|
assert_eq!(check(&session), original);
|
||||||
|
let reopened_doc = solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap();
|
||||||
|
let reopened = Session::new(reopened_doc);
|
||||||
|
assert_eq!(check(&reopened), original);
|
||||||
|
// A later independent feature must not inherit the earlier retry's warning.
|
||||||
|
run(&mut session, "solid.box", json!({"corner":[200,0,0],"length":5,"width":5,"height":5}));
|
||||||
|
let last = session.model.results.last().unwrap();
|
||||||
|
assert!(last.warning.is_none());
|
||||||
|
assert!(last.boolean_diagnostics.events.is_empty());
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)?;
|
let cut = crate::build::cylinder(at(lo, 0.0, 0.0), c.axis, c.radius, hi - lo)?;
|
||||||
crate::ops::boolean(&joined, &cut, BoolOp::Cut)
|
crate::ops::boolean(&joined, &cut, BoolOp::Cut)
|
||||||
};
|
};
|
||||||
match run() {
|
let (result, diagnostics) = crate::capture_boolean_diagnostics(run);
|
||||||
Ok(Some(r)) if accept(&r) => return Some(r),
|
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,
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ pub struct BoolCase {
|
|||||||
/// "ok", or the kind of failure: "error", "invalid", "identity".
|
/// "ok", or the kind of failure: "error", "invalid", "identity".
|
||||||
pub outcome: String,
|
pub outcome: String,
|
||||||
pub detail: 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> {
|
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 %.
|
/// 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 {
|
pub fn boolean_case(seed: u64) -> BoolCase {
|
||||||
let (a, b) = boolean_shapes(seed);
|
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| {
|
let fail = |c: &mut BoolCase, kind: &str, d: String| {
|
||||||
c.outcome = kind.into();
|
c.outcome = kind.into();
|
||||||
c.detail = d;
|
c.detail = d;
|
||||||
@@ -129,7 +139,12 @@ pub fn boolean_case(seed: u64) -> BoolCase {
|
|||||||
};
|
};
|
||||||
let mut res = Vec::new();
|
let mut res = Vec::new();
|
||||||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
|
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) => {
|
Ok(x) => {
|
||||||
if let Some(body) = &x {
|
if let Some(body) = &x {
|
||||||
let bad = body.validity();
|
let bad = body.validity();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod blend;
|
|||||||
mod blend_corner;
|
mod blend_corner;
|
||||||
mod blend_variable;
|
mod blend_variable;
|
||||||
mod body;
|
mod body;
|
||||||
|
mod boolean_diagnostics;
|
||||||
mod bore_guard;
|
mod bore_guard;
|
||||||
mod build;
|
mod build;
|
||||||
mod coplanar;
|
mod coplanar;
|
||||||
@@ -62,6 +63,7 @@ pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
|
|||||||
pub use blend_corner::fillet_radii;
|
pub use blend_corner::fillet_radii;
|
||||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||||
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
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 build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||||
pub use delete_face::delete_faces;
|
pub use delete_face::delete_faces;
|
||||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||||
|
|||||||
+69
-12
@@ -5,6 +5,7 @@ use solvecraft_geom::Vec3;
|
|||||||
use truck_modeling as mt;
|
use truck_modeling as mt;
|
||||||
|
|
||||||
use crate::body::{Body, Solid, from_p3};
|
use crate::body::{Body, Solid, from_p3};
|
||||||
|
use crate::boolean_diagnostics::{BooleanFallback, BooleanStrategy, capture_boolean_diagnostics, merge, record};
|
||||||
use crate::{KernelError, Result, guard};
|
use crate::{KernelError, Result, guard};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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
|
/// 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.
|
/// 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>> {
|
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.
|
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
|
||||||
let retry = match &r {
|
let retry = match &r {
|
||||||
Err(_) => true,
|
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) {
|
if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) {
|
||||||
let (la, lb) = (a.lumps()?, b.lumps()?);
|
let (la, lb) = (a.lumps()?, b.lumps()?);
|
||||||
if (la.len() > 1 || lb.len() > 1)
|
if la.len() > 1 || lb.len() > 1 {
|
||||||
&& let Ok(Some(x)) = by_lumps(la, lb, op)
|
let (pieces, report) = capture_boolean_diagnostics(|| by_lumps(la, lb, op));
|
||||||
{
|
if let Ok(Some(x)) = pieces {
|
||||||
r = Ok(Some(x));
|
r = Ok(Some(x));
|
||||||
|
diagnostics = report;
|
||||||
|
diagnostics.push(BooleanFallback::new(BooleanStrategy::Piecewise));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if op == BoolOp::Union
|
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
|
// Prefer the exact polygon result; reject an edge-only pinch if it cannot
|
||||||
// form a closed solid without displacing either operand.
|
// form a closed solid without displacing either operand.
|
||||||
r = match crate::polybool::planar_boolean(a, b, op) {
|
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(
|
_ => Err(KernelError::Invalid(
|
||||||
"join cannot preserve the original faces; check for zero-thickness edge contact or add intentional overlap".into(),
|
"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) {
|
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());
|
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
|
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) {
|
if !APART.with(|c| c.get()) && crate::coplanar::has_coincident_faces(a, b) {
|
||||||
APART.with(|c| c.set(true));
|
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));
|
APART.with(|c| c.set(false));
|
||||||
if let Some(Ok(Some(body))) = r {
|
if let Some(Ok(Some(body))) = r {
|
||||||
let (ok, _) = check(&body);
|
let (ok, _) = check(&body);
|
||||||
if ok {
|
if ok {
|
||||||
|
merge(diagnostics);
|
||||||
|
let mut event = BooleanFallback::new(BooleanStrategy::CoplanarSeparation);
|
||||||
|
event.face_shift_mm = size * 0.0229;
|
||||||
|
record(event);
|
||||||
return Ok(Some(body));
|
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).
|
// 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) {
|
if op == BoolOp::Union && !BORES.with(|c| c.get()) && crate::coplanar::has_coincident_cylinders(a, b) {
|
||||||
BORES.with(|c| c.set(true));
|
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));
|
BORES.with(|c| c.set(false));
|
||||||
if let Some(body) = r {
|
if let Some(body) = r {
|
||||||
|
merge(diagnostics);
|
||||||
return Ok(Some(body));
|
return Ok(Some(body));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut empty_votes = 0;
|
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();
|
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||||
for (attempt, j) in JITTER.iter().enumerate() {
|
for (attempt, j) in JITTER.iter().enumerate() {
|
||||||
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
|
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 {
|
match r {
|
||||||
Ok(s) if s.boundaries().is_empty() || s.face_iter().next().is_none() => {
|
Ok(s) if s.boundaries().is_empty() || s.face_iter().next().is_none() => {
|
||||||
empty_votes += 1;
|
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();
|
last = "empty result".into();
|
||||||
}
|
}
|
||||||
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
|
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
let (ok, v) = check(&body);
|
let (ok, v) = check(&body);
|
||||||
if ok {
|
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));
|
return Ok(Some(body));
|
||||||
}
|
}
|
||||||
last = format!("implausible result volume {v:.4}");
|
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 {
|
if empty_votes >= 2 && op != BoolOp::Union {
|
||||||
|
merge(empty_diagnostics);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Coincident planar faces: push them apart (exactly) and try again.
|
// Coincident planar faces: push them apart (exactly) and try again.
|
||||||
if !APART.with(|c| c.get()) {
|
if !APART.with(|c| c.get()) {
|
||||||
APART.with(|c| c.set(true));
|
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));
|
APART.with(|c| c.set(false));
|
||||||
match r {
|
match r {
|
||||||
Some(Ok(Some(body))) => {
|
Some(Ok(Some(body))) => {
|
||||||
let (ok, v) = check(&body);
|
let (ok, v) = check(&body);
|
||||||
if ok {
|
if ok {
|
||||||
|
merge(diagnostics);
|
||||||
|
let mut event = BooleanFallback::new(BooleanStrategy::CoplanarSeparation);
|
||||||
|
event.face_shift_mm = size * 0.0229;
|
||||||
|
record(event);
|
||||||
return Ok(Some(body));
|
return Ok(Some(body));
|
||||||
}
|
}
|
||||||
last = format!("{last}; faces pushed apart gave an implausible volume {v:.4}");
|
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)) => {
|
Ok(Some(body)) => {
|
||||||
let (ok, v) = check(&body);
|
let (ok, v) = check(&body);
|
||||||
if ok {
|
if ok {
|
||||||
|
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||||||
return Ok(Some(body));
|
return Ok(Some(body));
|
||||||
}
|
}
|
||||||
last = format!("planar fallback gave an implausible volume {v:.4}");
|
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}"),
|
Err(e) => last = format!("{last}; {e}"),
|
||||||
}
|
}
|
||||||
// Last resort: nudge the tool by a micron-scale screw motion (turned 2e-6 rad about a
|
// 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()) {
|
if !APART.with(|c| c.get()) {
|
||||||
APART.with(|c| c.set(true));
|
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 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));
|
APART.with(|c| c.set(false));
|
||||||
match r {
|
match r {
|
||||||
Ok(Some(body)) => {
|
Ok(Some(body)) => {
|
||||||
let (ok, v) = check(&body);
|
let (ok, v) = check(&body);
|
||||||
if ok {
|
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));
|
return Ok(Some(body));
|
||||||
}
|
}
|
||||||
last = format!("{last}; the nudged tool gave an implausible volume {v:.4}");
|
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}"),
|
Err(e) => last = format!("{last}; nudged: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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;
|
let v_ring = PI * (400.0 - 100.0) * 5.0;
|
||||||
for (z0, z1) in [(-5.0, 0.0), (-5.0, 2.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;
|
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!(rel(v, v_floor + v_ring) < 2e-5, "ring {z0}..{z1}: {v} vs {}", v_floor + v_ring);
|
||||||
assert!(u.validity().is_empty(), "{:?}", u.validity());
|
assert!(u.validity().is_empty(), "{:?}", u.validity());
|
||||||
|
|||||||
@@ -36,6 +36,30 @@ rejected before any jobs or output directories are created. The maximum seed
|
|||||||
- `cargo test` runs a fixed set of seeds that must stay clean
|
- `cargo test` runs a fixed set of seeds that must stay clean
|
||||||
(`crates/engine/src/tests/robustness.rs`), next to the minimised cases fixed so far.
|
(`crates/engine/src/tests/robustness.rs`), next to the minimised cases fixed so far.
|
||||||
|
|
||||||
|
## Successful Boolean retries
|
||||||
|
|
||||||
|
When an accepted Boolean result needs a numerical retry, the feature shows a
|
||||||
|
warning in the timeline. `timeline.inspect` and `doc.inspect` expose its
|
||||||
|
`boolean_diagnostics`, including the strategy and the applied translation,
|
||||||
|
rotation, face offset and tessellation tolerance in millimetres/radians.
|
||||||
|
|
||||||
|
A common shift of both operands is reversed on the result. Coincident-face
|
||||||
|
separation and bore offsets describe temporary working geometry; they are not
|
||||||
|
claims about the final model's error. A tool nudge changes the relative operand
|
||||||
|
placement and reports both its translation magnitude and rotation angle.
|
||||||
|
Exact planar and piecewise fallbacks report zero displacement. The existing
|
||||||
|
volume and spatial acceptance checks still apply to all results.
|
||||||
|
|
||||||
|
Reports belong to the evaluated feature and survive cached rebuilds and
|
||||||
|
undo/redo. Cold reopening regenerates them from the embedded design. Failed or
|
||||||
|
discarded retry branches do not contribute successful-retry events. Reports
|
||||||
|
are capped at 1024 events per feature and explicitly identify truncation.
|
||||||
|
|
||||||
|
Fuzz JSON and the text report count successful retry events by strategy for
|
||||||
|
designs and Boolean identity checks separately, including nested operations.
|
||||||
|
These are event counts, so a single Boolean or seed can contribute several.
|
||||||
|
Truncated reports are counted separately rather than implying complete totals.
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
|
|
||||||
1000 seeds (0–999), 8 processes on a heavily loaded machine (load average 60–80), 90 s per
|
1000 seeds (0–999), 8 processes on a heavily loaded machine (load average 60–80), 90 s per
|
||||||
|
|||||||
Reference in new issue
Block a user