engine: no boolean retries to report; overlaps are SMLib's exact intersection

The kernel has no boolean retries now, nor their diagnostics
(capture_boolean_diagnostics, BooleanDiagnostics, BooleanStrategy), nor the near-coaxial
crescent (coaxial_overlap, coaxial_candidate, with_attempt_budget). What read them goes:

- doc: FeatureResult and the evaluation cache carry no boolean diagnostics, and no "Boolean
  retry" warning; the saved model no longer writes them (an older file's are ignored);
- engine: timeline.inspect / feature inspection drop `boolean_diagnostics`; fuzz runs and
  the CLI fuzz summary drop their retry counts;
- inspect.interference: each pair is the bodies' Intersect, measured; the near-coaxial
  crescent, the by-piece intersection of bodies in several pieces and the unverified notes
  go (the command's doc no longer lists near_coaxial);
- inspect.clearance: an overlap is the bodies' Intersect, measured near its region; the pin
  tries, the cut-down copies of large bodies (clipped, narrowed), the union cross-check
  (union_disagrees, contradicted) and the malformed-part check (plausible) go, and the
  measure near the origin passes its move's error on.

Tests: the retry test and the unit tests of the deleted checks are removed; the barrel join
test (tests/boolean_diagnostics.rs, now tests/barrel_join.rs) keeps its geometry checks;
the pin-in-bushing test expects the filleted case measured; assertions on empty diagnostics
and on near_coaxial are dropped; the corpus fixtures lose the diagnostics key and the one
retry warning.

(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 07:35:35 -04:00
parent 337b8096f9
commit 712caf8bf4
11 changed files with 82 additions and 372 deletions
+3 -26
View File
@@ -40,8 +40,6 @@ pub fn one(args: &[String]) -> Result<(), String> {
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"boolean_fallbacks": run.boolean_fallbacks,
"boolean_reports_truncated": run.boolean_reports_truncated,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
@@ -191,23 +189,9 @@ fn summarise(results: &[Value], took: Duration) -> String {
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: 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 dp, mut bp) = (0, 0);
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);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
@@ -246,11 +230,6 @@ fn summarise(results: &[Value], took: Duration) -> String {
for (k, c) in &boolean {
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
}
@@ -259,15 +238,13 @@ mod tests {
use super::*;
#[test]
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
fn report_counts_outcomes_and_timeouts() {
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!({"design":{"outcome":{"kind":"ok"}},"boolean":{"outcome":"ok"}}),
json!({"design":{"outcome":{"kind":"ok"}},"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}");
}
}
@@ -131,7 +131,7 @@
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": "Boolean retry: exact planar fallback (no displacement)"
"warning": null
},
{
"error": null,
+12 -168
View File
@@ -47,9 +47,6 @@ enum SkipWithin {
/// Most pairs [`PairCache`] keeps; it starts over when full.
const MAX_CACHED: usize = 2 * MAX_PAIRS;
/// The kernel's tries at intersecting a pin with its hole on one cylinder (each about a second
/// on real parts; a plain pin on its axis needs one or two).
const PIN_ATTEMPTS: usize = 4;
/// Pairs measured before, by the two bodies' geometry where they stood
/// ([`solidcraft_doc::ModelBody::geometry_key`]) and the settings that shape the result
@@ -357,78 +354,19 @@ fn measure_pair(
let touching = mesh.is_some_and(|m| m.distance <= slack);
if touching || holds(ba, bb) || holds(bb, ba) {
// A pair whose shared part can't be worked out is reported, not passed as clear.
let region = shared_region(a, b);
let (ca, cb) = (clipped(ka, region), clipped(kb, region));
// A pin of one in a hole of the other of its own size. On its very axis the boolean takes
// the two walls as one and answers plain ones at once, but with blends next to them it
// tries for minutes and gives up; a hair off the axis it cannot intersect them at all
// (and may pass the pin as clear). So a pin on its axis gets a few tries of the boolean
// first, and what the walls share is otherwise worked out apart, with a few tries of the
// boolean left for when that declines. The body left whole is cut down to where the
// other's piece is first, so that both meet only the faces there.
let candidate = solidcraft_kernel::coaxial_candidate(ca.as_ref().unwrap_or(ka), cb.as_ref().unwrap_or(kb));
let pin = candidate.is_some();
let (ca, cb) = match (ca, cb) {
(Some(x), None) if pin => {
let y = narrowed(kb, &x);
(Some(x), y)
}
(None, Some(y)) if pin => (narrowed(ka, &y), Some(y)),
other => other,
};
// A body left whole is the copy that keeps its meshes: meeting several others at one
// size, it is meshed once for the booleans of all of them.
let (pa, pb) = (ca.as_ref().unwrap_or(ka), cb.as_ref().unwrap_or(kb));
let intersect = || {
solidcraft_kernel::capture_boolean_diagnostics(|| {
let run = || solidcraft_kernel::boolean(pa, pb, solidcraft_kernel::BoolOp::Intersect);
if pin { solidcraft_kernel::with_attempt_budget(PIN_ATTEMPTS, run) } else { run() }
})
};
let first = (candidate != Some(false)).then(intersect);
let answered = first.as_ref().is_some_and(|(r, report)| r.is_ok() && report.unverified().is_none());
let coaxial = if pin && !answered { solidcraft_kernel::coaxial_overlap(pa, pb) } else { None };
if let Some(o) = coaxial {
if o.volume > 0.0 {
out.overlap = Some((o.volume, o.area, o.bbox, o.centroid));
}
} else {
let (shared, report) = first.unwrap_or_else(intersect);
let failed =
|why: String| if pin { format!("a pin on one cylinder with its hole, whose walls could not be intersected ({why})") } else { why };
let shared = match shared {
Err(e) => {
out.unchecked = Some(failed(e.to_string()));
return out;
}
Ok(shared) => shared,
};
if let Some(reason) = report.unverified() {
out.unchecked = Some(failed(reason));
match solidcraft_kernel::boolean(ka, kb, solidcraft_kernel::BoolOp::Intersect) {
Err(e) => {
out.unchecked = Some(e.to_string());
return out;
}
if let Some(o) = shared {
match measure_near_origin(&o, region.center()) {
Ok(m) if !plausible(m.volume, m.centroid, m.bbox) => {
out.unchecked = Some(format!(
"the shared part came out malformed ({:.4} mm³ with its centre outside its own box), so it is not measured",
m.volume
));
return out;
}
Ok(m) => {
if let Some(reason) = union_disagrees(pa, pb, m.volume, region.center()) {
out.unchecked = Some(reason);
return out;
}
out.overlap = Some((m.volume, m.area, m.bbox, m.centroid));
}
Err(e) => {
out.unchecked = Some(e.to_string());
return out;
}
Ok(Some(o)) => match measure_near_origin(&o, shared_region(a, b).center()) {
Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)),
Err(e) => {
out.unchecked = Some(e.to_string());
return out;
}
}
},
Ok(None) => {}
}
if out.overlap.is_some_and(|o| o.0 >= min_volume) {
return out;
@@ -456,8 +394,7 @@ fn measure_pair(
out
}
/// The box where two bodies may share volume: where their boxes meet, grown by a margin so that
/// a cut there stays clear of both bodies' faces.
/// The box where two bodies may share volume: where their boxes meet, grown by a margin.
fn shared_region(a: &DistanceShape, b: &DistanceShape) -> Aabb3 {
let (x, y) = (a.bounds(), b.bounds());
let min = x.min.max(y.min);
@@ -470,109 +407,16 @@ fn shared_region(a: &DistanceShape, b: &DistanceShape) -> Aabb3 {
Aabb3 { min: min - g, max: max + g }
}
/// `body` cut down to `region` when the body is much larger than it. The boolean works to a
/// tolerance that grows with the bodies' size: a 0.01 mm overlap against a plate 1.7 m long
/// comes out a quarter too small, and thinner ones not at all. Cut to the region first, the
/// body is small and its faces there are the same faces. `None`: use the whole body.
fn clipped(body: &solidcraft_kernel::Body, region: Aabb3) -> Option<solidcraft_kernel::Body> {
if region.is_empty() || body.size() <= 4.0 * region.diagonal() {
return None;
}
let cut = solidcraft_kernel::box_solid(region.min, region.max).ok()?;
let (r, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(body, &cut, solidcraft_kernel::BoolOp::Intersect));
match r {
Ok(Some(c)) if report.unverified().is_none() => Some(c),
_ => None,
}
}
/// `body` cut down to where it may meet `piece` (their boxes' common part, grown as
/// [`shared_region`] grows it), when that leaves part of it out. `None`: use the whole body.
fn narrowed(body: &solidcraft_kernel::Body, piece: &solidcraft_kernel::Body) -> Option<solidcraft_kernel::Body> {
let bounds = |b: &solidcraft_kernel::Body| b.mesh(&solidcraft_kernel::MeshTolerance::chord((b.size() * 1e-3).max(1e-4))).ok().map(|m| m.bounds());
let (x, y) = (bounds(body)?, bounds(piece)?);
let (min, max) = (x.min.max(y.min), x.max.min(y.max));
if x.is_empty() || y.is_empty() || !(min.x <= max.x && min.y <= max.y && min.z <= max.z) {
return None;
}
let grow = 1.0 + 0.05 * min.dist(max);
let g = solidcraft_geom::Vec3::new(grow, grow, grow);
let region = Aabb3 { min: min - g, max: max + g };
if region.min.x <= x.min.x
&& region.min.y <= x.min.y
&& region.min.z <= x.min.z
&& x.max.x <= region.max.x
&& x.max.y <= region.max.y
&& x.max.z <= region.max.z
{
return None;
}
let cut = solidcraft_kernel::box_solid(region.min, region.max).ok()?;
let (r, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(body, &cut, solidcraft_kernel::BoolOp::Intersect));
match r {
Ok(Some(c)) if report.unverified().is_none() => Some(c),
_ => None,
}
}
/// [`solidcraft_kernel::measure`] of `body` moved by `-at` and back: far from the origin, the
/// centre of a thin part loses its precision (its moments are large numbers that nearly cancel).
fn measure_near_origin(body: &solidcraft_kernel::Body, at: solidcraft_geom::Vec3) -> solidcraft_kernel::Result<solidcraft_kernel::BodyMeasure> {
let Ok(moved) = solidcraft_kernel::transform(body, -at, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::Z, 0.0) else {
return solidcraft_kernel::measure(body);
};
let moved = solidcraft_kernel::transform(body, -at, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::Z, 0.0)?;
let mut m = solidcraft_kernel::measure(&moved)?;
m.centroid += at;
m.bbox = Aabb3 { min: m.bbox.min + at, max: m.bbox.max + at };
Ok(m)
}
/// Does the shared volume of `a` and `b` disagree with the one their union implies,
/// vol(a) + vol(b) − vol(a ∪ b)? The intersection can come out well formed and still wrong (a
/// mirrored pair read three times its volume). `None` when they agree, or when the union gives no
/// answer to compare with.
fn union_disagrees(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body, shared: f64, at: solidcraft_geom::Vec3) -> Option<String> {
let (union, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Union));
let union = union.ok().flatten().filter(|_| report.unverified().is_none())?;
let volume = |x: &solidcraft_kernel::Body| measure_near_origin(x, at).ok().map(|m| m.volume).filter(|v| v.is_finite());
contradicted(shared, volume(a)?, volume(b)?, volume(&union)?)
}
/// How far vol(a) + vol(b) − vol(a ∪ b) can stray from the shared volume by measuring alone,
/// as a share of vol(a) + vol(b) + vol(a ∪ b). [`solidcraft_kernel::measure`] facets a curved
/// face with chords of up to 3e-4 of its radius where the radius sets them, which leaves the
/// volume it encloses short by up to (4/3) × 3e-4 = 4e-4. That shortfall is the same for a face
/// measured on its own body and in the union, and cancels in the difference, except where the
/// chord changes with the body measured (it also follows the body's size). Measured, that
/// change stays near 2e-5 of the three volumes: 2.1e-5 for a 40 mm roller pressed 0.2 mm into a
/// 2 m plate (`a_roller_pressed_into_a_large_plate_is_reported`), 2e-5 and 1e-6 in the examples
/// of `the_union_check_allows_for_the_bodies_measuring_error`. This allows five times that.
const VOLUME_NOISE: f64 = 1e-4;
/// [`union_disagrees`] on measured volumes: `shared` against vol(a) + vol(b) − vol(a ∪ b).
/// That difference of large numbers carries the measuring error of all three, so a small overlap
/// on large bodies can come out near zero or below it: then the union cannot tell, and a shared
/// part small next to both bodies stands. A shared part the union clearly contradicts (412 mm³
/// where the bodies of a few thousand and their union imply 141) does not.
fn contradicted(shared: f64, va: f64, vb: f64, vu: f64) -> Option<String> {
let implied = va + vb - vu;
let noise = VOLUME_NOISE * (va + vb + vu) + 1e-6;
let allowed = 0.01 * shared.max(implied.abs()) + noise;
let unresolved = implied <= noise && shared <= 0.1 * va.min(vb);
((implied - shared).abs() > allowed && !unresolved)
.then(|| format!("the shared part measures {shared:.4} mm³ but the bodies and their union imply {implied:.4} mm³, so neither is reported"))
}
/// Is a measured shared part well formed? A part whose shell came out inside out or open can
/// carry a volume with its centre outside its own box, or more volume than its box holds.
fn plausible(volume: f64, centre: solidcraft_geom::Vec3, bbox: Aabb3) -> bool {
let slack = 1e-6 * bbox.diagonal().max(1.0);
let (lo, hi) = (bbox.min, bbox.max);
let inside = [(centre.x, lo.x, hi.x), (centre.y, lo.y, hi.y), (centre.z, lo.z, hi.z)].iter().all(|&(c, l, h)| c >= l - slack && c <= h + slack);
let s = bbox.size();
volume.is_finite() && volume >= 0.0 && inside && volume <= s.x * s.y * s.z + slack
}
/// Each subject's `count` nearest bodies, nearest first: pairs are measured in the order of their
/// boxes' gaps (a lower bound of their distance), in rounds, until the boxes of the next ones
/// are farther apart than the last neighbour kept. `known` holds distances already measured.
+7 -56
View File
@@ -217,18 +217,6 @@ fn a_thin_overlap_with_a_large_plate_is_measured_in_full() {
}
}
/// A shared part whose centre lies outside its own box, or which holds more than its box, is
/// malformed: the pair is reported unchecked rather than with that volume.
#[test]
fn a_malformed_shared_part_is_not_measured() {
use solidcraft_geom::{Aabb3, Vec3};
let bbox = Aabb3 { min: Vec3::new(0.0, 0.0, 0.0), max: Vec3::new(10.0, 10.0, 1.0) };
assert!(super::plausible(50.0, Vec3::new(5.0, 5.0, 0.5), bbox));
assert!(!super::plausible(50.0, Vec3::new(5.0, 5.0, 3.0), bbox));
assert!(!super::plausible(1239.0, Vec3::new(5.0, 5.0, 0.5), bbox));
assert!(!super::plausible(f64::NAN, Vec3::new(5.0, 5.0, 0.5), bbox));
}
/// Two plates 0.2 mm apart, the upper one set 10 mm in from the lower one's corner, and two set
/// side by side with a 0.2 mm gap: 0.2, not 0 (a point past a corner, on the line of one of its
/// edges, was taken for a point of the face). Gaps under twice the meshes' chord went wrong.
@@ -348,13 +336,8 @@ fn a_body_that_changed_shape_is_measured_again() {
/// A flanged bushing and a headed pin seated in it line to line, on its very axis, the head on
/// the flange. Plain, they touch and share nothing; sunk 0.2 into the flange, the head shares the
/// ring under it. With a fillet under the head against the bore's sharp edge, the fillet's ring
/// is in the flange (about 0.49 mm³), next to walls that lie on one another: the boolean tried
/// for minutes there and gave up; now it is reported as such within seconds.
///
/// The clock is not read: what is under test is the result, and the boolean that gives up is
/// reported as such instead of hanging the test. The wall that lies on the other one is what
/// makes the pair unchecked, and that is what the assertions below check (#2999 dropped the clock
/// from the sibling tests the same way).
/// is in the flange (about 0.49 mm³), next to walls that lie on one another: an overlap. The clock
/// is not read: what is under test is the result (#2999 dropped it from the sibling tests).
#[test]
fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
let ring = std::f64::consts::PI * (7.0f64.powi(2) - 4.0f64.powi(2)) * 0.2;
@@ -371,11 +354,11 @@ fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
run(&mut s, "solid.fillet", json!({"edges": [[107, 0, z + 1.5]], "radius": 0.5, "body": "Pin"}));
}
let r = run(&mut s, "inspect.clearance", json!({"bodies": ["Pin"], "against": ["Bush"], "min_volume": 0.01, "clearance": 1e-6}));
assert_eq!(r["unchecked"], json!([]), "sunk {sunk}: {r}");
if fillet {
assert!(r["unchecked"][0]["reason"].as_str().is_some_and(|w| w.contains("pin on one cylinder")), "{r}");
assert!(f(&r["overlaps"][0]["volume_mm3"]) > 0.01, "{r}");
continue;
}
assert_eq!(r["unchecked"], json!([]), "sunk {sunk}: {r}");
if sunk == 0.0 {
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(f(&r["near"][0]["distance_mm"]), 0.0, "{r}");
@@ -386,9 +369,9 @@ fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
}
}
/// A chamfered pin through a hole of its own radius, its axis a hair off the hole's: the boolean
/// cannot intersect walls all but tangent along two lines and once passed the pair as clear.
/// The crescent the pin shares with the plate is reported, against the lens of two circles.
/// A chamfered pin through a hole of its own radius, its axis a hair off the hole's, the walls
/// all but tangent along two lines: the crescent the pin shares with the plate is reported,
/// against the lens of two circles.
#[test]
fn a_pin_a_hair_off_its_hole_shares_its_crescent() {
let crescent = |r: f64, e: f64| std::f64::consts::PI * r * r - (2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt());
@@ -406,38 +389,6 @@ fn a_pin_a_hair_off_its_hole_shares_its_crescent() {
}
}
/// The shared volume is checked against the bodies and their union: two 10 mm cubes sharing a
/// 3 mm corner cube agree at 27 mm³, and a shared part read as three times that (as a mirrored
/// pair once was) does not.
#[test]
fn a_shared_volume_the_union_contradicts_is_caught() {
use solidcraft_geom::Vec3;
let a = solidcraft_kernel::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap_or_else(|e| panic!("{e}"));
let b = solidcraft_kernel::box_solid(Vec3::new(7.0, 7.0, 7.0), Vec3::new(17.0, 17.0, 17.0)).unwrap_or_else(|e| panic!("{e}"));
let at = Vec3::new(8.5, 8.5, 8.5);
assert_eq!(super::union_disagrees(&a, &b, 27.0, at), None);
assert!(super::union_disagrees(&a, &b, 81.0, at).is_some());
}
/// The check works on measured volumes, and vol(a) + vol(b) − vol(a ∪ b) carries the error of
/// all three: a small overlap on large bodies can come out near zero, or below it. A union that
/// cannot resolve the shared part leaves it standing; one that clearly contradicts it does not.
#[test]
fn the_union_check_allows_for_the_bodies_measuring_error() {
use super::contradicted;
// 0.96 mm³ shared where the union of 40 000 and 300 mm³ implies -0.75: noise, not a contradiction.
assert_eq!(contradicted(0.96, 40_000.0, 300.0, 40_300.75), None);
// 5.92 mm³ of a small body in a large sheet, with the union 0.4 mm³ off the bodies' sum.
assert_eq!(contradicted(5.92, 250_000.0, 80.0, 250_074.48), None);
// Past the noise, but the union implies nothing at all while the shared part is small next
// to both bodies: the union cannot tell, and the shared part stands.
assert_eq!(contradicted(6.0, 400.0, 200.0, 600.5), None);
// 412 mm³ shared where bodies of a few thousand and their union imply 141: caught.
assert!(contradicted(411.98, 3_200.0, 3_150.0, 6_350.0 - 141.18).is_some());
// A large shared part the union says is not there at all: caught too.
assert!(contradicted(900.0, 3_200.0, 3_150.0, 6_350.0).is_some());
}
/// A small box pressed 0.6 mm into a 2000 × 1000 × 1 mm plate shares 3 × 3 × 0.6 = 5.4 mm³: an
/// overlap, not an unchecked pair.
#[test]
+1 -1
View File
@@ -762,7 +762,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
let removed: Vec<&str> = before.bodies.iter().map(|b| b.name.as_str()).filter(|n| !now.contains(n)).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
// A warning (a Boolean that needed a retry, a reference found again elsewhere) is part of
// A warning (a reference found again elsewhere, a reduced radius) is part of
// the reply: a script or an agent sees it without asking the timeline (#616).
let warning = r.as_ref().and_then(|r| r.warning.clone());
let owned = |names: Vec<&str>| names.into_iter().map(str::to_string).collect();
+16 -83
View File
@@ -56,8 +56,6 @@ enum Outcome {
/// One pair's answer and where its time went.
struct Done {
outcome: Outcome,
/// Unverified boolean results are listed as unchecked and still reported as a hit.
note: Option<String>,
ms: f64,
kernel: KernelTimes,
}
@@ -171,9 +169,6 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
json!({"a": a, "b": b, "ms": round_ms(d.ms), "boolean_ms": round_ms(d.kernel.boolean_ms),
"tessellate_ms": round_ms(d.kernel.tessellate_ms)}),
));
if let Some(reason) = d.note {
unchecked.push(json!({"a": a, "b": b, "reason": reason}));
}
match d.outcome {
Outcome::Hit(v) => hits.push(v),
Outcome::Clear => {}
@@ -263,92 +258,31 @@ fn any_inside(points: &[Vec3], outer: &Mesh) -> bool {
}
}
/// The exact check of one pair: a pin a hair off the axis of its hole by the walls' crescent,
/// otherwise the intersection measured.
/// The exact check of one pair: the bodies' intersection, measured.
fn check(job: &Job) -> Done {
let started = web_time::Instant::now();
let ((outcome, note), kernel) = solidcraft_kernel::profile::capture(|| check_pair(&job.a, &job.b, &job.names));
Done { outcome, note, ms: started.elapsed().as_secs_f64() * 1000.0, kernel }
let (outcome, kernel) = solidcraft_kernel::profile::capture(|| check_pair(&job.a, &job.b, &job.names));
Done { outcome, ms: started.elapsed().as_secs_f64() * 1000.0, kernel }
}
fn check_pair(a: &Body, b: &Body, (na, nb): &(String, String)) -> (Outcome, Option<String>) {
fn check_pair(a: &Body, b: &Body, (na, nb): &(String, String)) -> Outcome {
let threshold = 1e-9 * a.size().max(1.0).powi(3);
// A pin a hair off the axis of its hole: the crescent the walls share, which the boolean
// can't intersect.
if let Some(o) = solidcraft_kernel::coaxial_overlap(a, b) {
if o.volume > threshold {
let hit = json!({"a": na, "b": nb, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid,
"bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}});
return (Outcome::Hit(hit), None);
}
return (Outcome::Clear, None);
}
// A pair whose shared part can't be worked out is reported, not passed as clear.
let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Intersect));
let shared = match shared {
Ok(o) => o,
Err(e) => return (Outcome::Unchecked(e.to_string()), None),
let shared = match solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Intersect) {
Ok(Some(o)) => o,
Ok(None) => return Outcome::Clear,
Err(e) => return Outcome::Unchecked(e.to_string()),
};
let mut note = report.unverified();
let pieces = match shared {
Some(o) => vec![o],
// A body in several pieces, one of them wholly inside the other body: the whole bodies'
// boolean can come out empty, so the pieces are intersected one by one.
None if a.shell_count() > 1 || b.shell_count() > 1 => match by_pieces(a, b) {
Ok((pieces, unverified)) => {
note = note.or(unverified);
pieces
}
Err(e) => return (Outcome::Unchecked(e), note),
},
None => return (Outcome::Clear, note),
let m = match solidcraft_kernel::measure_mass(&shared) {
Ok(m) => m,
Err(e) => return Outcome::Unchecked(e.to_string()),
};
// The shared parts measured together: volumes and areas add, the centre is the volumes'.
let mut measured = Vec::new();
for o in &pieces {
match solidcraft_kernel::measure_mass(o) {
Ok(m) => measured.push(m),
Err(e) => return (Outcome::Unchecked(e.to_string()), note),
}
if m.volume > threshold {
let hit = json!({"a": na, "b": nb, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid,
"bbox": {"min": m.bbox.min, "max": m.bbox.max}});
return Outcome::Hit(hit);
}
let volume: f64 = measured.iter().map(|m| m.volume).sum();
if volume > threshold {
let area: f64 = measured.iter().map(|m| m.area).sum();
let bbox = measured.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bbox));
let center = match measured.as_slice() {
[m] => m.centroid,
_ => measured.iter().fold(Vec3::ZERO, |c, m| c + m.centroid * m.volume) / volume,
};
let hit = json!({"a": na, "b": nb, "volume_mm3": volume, "area_mm2": area, "center": center, "bbox": {"min": bbox.min, "max": bbox.max}});
return (Outcome::Hit(hit), note);
}
(Outcome::Clear, note)
}
/// Most pairs of pieces [`by_pieces`] intersects.
const MAX_PIECE_PAIRS: usize = 256;
/// The shared parts of two bodies, a piece of each at a time, and why any of them is unverified.
fn by_pieces(a: &Body, b: &Body) -> std::result::Result<(Vec<Body>, Option<String>), String> {
let (la, lb) = (a.lumps().map_err(|e| e.to_string())?, b.lumps().map_err(|e| e.to_string())?);
if la.len() < 2 && lb.len() < 2 {
return Ok((Vec::new(), None));
}
if la.len().saturating_mul(lb.len()) > MAX_PIECE_PAIRS {
return Err(format!("not checked: {} by {} pieces are more than {MAX_PIECE_PAIRS} pairs of pieces", la.len(), lb.len()));
}
let (mut out, mut note) = (Vec::new(), None);
for x in &la {
for y in &lb {
let (r, report) =
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(x, y, solidcraft_kernel::BoolOp::Intersect));
note = note.or(report.unverified());
if let Some(o) = r.map_err(|e| e.to_string())? {
out.push(o);
}
}
}
Ok((out, note))
Outcome::Clear
}
/// Each job's answer (in pair order, with the pair's names; `None`: not finished within
@@ -391,7 +325,6 @@ fn run_ordered(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<Option<Done>
let Some(job) = jobs.get(k) else { break };
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(job))).unwrap_or_else(|_| Done {
outcome: Outcome::Unchecked("internal error while checking this pair".into()),
note: None,
ms: 0.0,
kernel: KernelTimes::default(),
});
+1 -1
View File
@@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", super::interference::interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: [{a, b, reason}], the pairs that could not be checked, with the reason, and each mesh body (STL, 3MF or OBJ import: not intersected) as {a: name, b: null, reason: \"mesh body: not checked\"}, so count: 0 is not read as no overlap (checked counts the solid bodies). Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms",
"bodies?: [names] (default: all, in world placement); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: [{a, b, reason}], the pairs that could not be checked, with the reason, and each mesh body (STL, 3MF or OBJ import: not intersected) as {a: name, b: null, reason: \"mesh body: not checked\"}, so count: 0 is not read as no overlap (checked counts the solid bodies). Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms",
),
];
+3 -6
View File
@@ -1826,10 +1826,9 @@ mod motion_analysis;
mod motion_sweep;
/// A pin through a hole of its own radius whose axis is a hair off the hole's (parts placed from
/// rounded coordinates): the walls are all but tangent along two lines, which the boolean can't
/// intersect. Each offset gets a checked answer: clear on the axis and within the kernel's
/// same-surface tolerance, and otherwise the crescent the pin shares with the plate, against
/// the textbook lens of two circles. The pin is chamfered, so it is not a plain prism.
/// rounded coordinates): the walls are all but tangent along two lines. Each offset gets a
/// checked answer: clear on the axis and within the kernel's same-surface tolerance, and
/// otherwise the crescent the pin shares with the plate, against the textbook lens of two circles. The pin is chamfered, so it is not a plain prism.
#[test]
fn interference_of_a_pin_a_hair_off_its_hole() {
let crescent = |r: f64, e: f64| std::f64::consts::PI * r * r - (2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt());
@@ -1849,7 +1848,6 @@ fn interference_of_a_pin_a_hair_off_its_hole() {
let want = 5.0 * crescent(3.0, offset);
let got = i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0);
assert!((got - want).abs() < 1e-6 * want, "{offset}: {got} against {want}: {i}");
assert!((i["interferences"][0]["near_coaxial"]["offset_mm"].as_f64().unwrap_or(0.0) - offset).abs() < 1e-9, "{i}");
}
}
@@ -1870,7 +1868,6 @@ fn interference_of_a_headed_pin_a_hair_off_a_bush_of_its_heads_radius() {
let want = 5.0 * crescent(3.0, offset);
let got = i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0);
assert!((got - want).abs() < 1e-6 * want, "{got} against {want}: {i}");
assert!(i["interferences"][0]["near_coaxial"].is_object(), "{i}");
}
/// A headed pin made before the plate it sits in, a hair off the plate's hole: the crescent
+6 -30
View File
@@ -64,8 +64,6 @@ pub struct Run {
pub unsupported: Vec<(String, String)>,
/// Steps whose generated parameters a command refused.
pub rejected: usize,
pub boolean_fallbacks: std::collections::BTreeMap<solidcraft_kernel::BooleanStrategy, usize>,
pub boolean_reports_truncated: bool,
}
impl Run {
@@ -84,9 +82,8 @@ fn classify(e: &EngineError) -> Outcome {
match e {
EngineError::Internal(..) => Outcome::new("panic", msg),
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
// A failed boolean is a failure even when it says "not supported" (the planar fallback on
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
// fillet between curved faces) are limitations.
// A failed boolean is a failure even when it says "not supported": those are the cases
// to fix. Features the kernel doesn't do yet are limitations.
// Picks the generator got wrong (a centroid off its face).
EngineError::Other(_) if msg.starts_with("invalid input") => Outcome::new("rejected", msg),
EngineError::Other(_) if msg.contains("boolean") => Outcome::new("failed", msg),
@@ -340,8 +337,6 @@ fn run_session(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> (
outcome: Outcome::ok(),
unsupported: Vec::new(),
rejected: 0,
boolean_fallbacks: Default::default(),
boolean_reports_truncated: false,
};
let mut last_feature: Option<String> = None;
let n = 2 + r.below(max_steps.max(2) - 1);
@@ -367,12 +362,6 @@ fn run_session(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> (
continue;
}
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);
if out.kind != "ok" {
run.outcome = out;
@@ -388,19 +377,14 @@ fn run_session(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
/// that isn't ok (or the last step's, ok).
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();
for (i, st) in steps.iter().enumerate() {
let out = apply(&mut s, st);
if out.kind != "ok" {
return (i, out, s);
return (i, out);
}
}
(steps.len().saturating_sub(1), Outcome::ok(), s)
(steps.len().saturating_sub(1), Outcome::ok())
}
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
@@ -431,16 +415,8 @@ pub fn minimise(run: &Run) -> Run {
}
}
}
let (_, outcome, session) = replay_session(&steps);
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 }
let (_, outcome) = replay(&steps);
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
}
#[cfg(test)]
+1
View File
@@ -323,6 +323,7 @@ fn join_of_a_lobed_disc_pattern_is_not_refused() {
assert!(rel(got, want) < 1e-3, "{got} vs {want}");
}
mod barrel_join;
mod blend_fit;
mod component_frames;
mod curved_seam_round;
+31
View File
@@ -0,0 +1,31 @@
use super::*;
/// A barrel joined under a plate's back edge, its ends flush with the sides of the leaf it hangs
/// from (#827 without its fillets): the join is exact, so the command's reply carries no warning,
/// and the barrel's ends stay in the leaf's sides.
#[test]
fn a_barrel_joined_between_the_sides_of_a_leaf_is_exact() {
let mut session = Session::default();
run(&mut session, "solid.box", json!({"length": 40, "width": 20, "height": 6, "corner": [0, 0, 5], "body_name": "Plate"}));
run(&mut session, "solid.box", json!({"length": 14, "width": 10, "height": 6, "corner": [10, 10, 0], "operation": "join", "targets": ["Plate"]}));
let reply = run(
&mut session,
"solid.cylinder",
json!({"radius": 5, "height": 14, "axis": "X", "base": [10, 20, 0], "operation": "join", "targets": ["Plate"]}),
);
assert!(reply["warning"].is_null(), "{reply}");
let state = session.model.state();
let plate = &state.body("Plate").unwrap().body;
assert_eq!(plate.shell_count(), 1);
assert!(plate.validity().is_empty(), "{:?}", plate.validity());
// Every edge ends on a level along x of the plate, the leaf or the barrel.
for edge in plate.edges(1e-3).unwrap() {
for p in [edge.points.first(), edge.points.last()].into_iter().flatten() {
assert!([0., 10., 24., 40.].iter().any(|x| (p.x - x).abs() < 1e-9), "an edge ends at {p:?}");
}
}
// A quarter of the barrel lies in the leaf.
let expected = 5500. + 0.75 * PI * 25. * 14.;
let actual = plate.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-4)).unwrap().measure().volume;
assert!(rel(actual, expected) < 1e-5, "{actual} vs {expected}");
}