kernel: boolean runner times both kernels and runs the cut sequence

The side-by-side runner now reports how long each boolean takes on truck and
on SMLib (the call alone), and apart from that the SMLib side's conversions:
the operands' truck→SMLib import and the result's SMLib→truck export, which
the app pays while truck still holds the bodies. The report gains a times
summary (median, worst, total), solid counts, the analytic volume of each named
case's union, intersection and cut, and how many results land on it.

It also runs the "each cut gets slower" case from TASK_kernel-smlib-booleans as
a named sequence: a 63.6 × 31.7 × 9.5 block, a Ø20 × 10 boss joined on top, a
slot front to back, a Ø6 cross hole through the boss and a slot that splits
the block, each step on the previous result. SMLib keeps its own results from
step to step (only the tools come from truck), in a child process like the
cases, and every step's volume is checked against its closed form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 05:04:10 -04:00
parent 2196129f16
commit ac6ddbdbec
+523 -75
View File
@@ -1,6 +1,6 @@
//! Side-by-side booleans: truck (today's kernel) and SMLib on the same cases, compared on
//! volume, face and edge counts and history. It reports; it never changes which kernel the app
//! uses.
//! volume, face and edge counts, history and time. It reports; it never changes which kernel the
//! app uses.
//!
//! ```sh
//! scripts/fetch-smlib.sh
@@ -11,11 +11,19 @@
//! Cases: named pairs of primitives whose results are known by hand (the ones the kernel's
//! boolean and history tests build), then `fuzz::boolean_shapes(seed)` for seeds 0..N (the
//! random pairs of `solidcraft-cli fuzz` and the robustness runs). Each case runs union,
//! intersection and cut. Writes `smlib-booleans.json` and `smlib-booleans.md` into DIR (default
//! `target/smlib-booleans`).
//! intersection and cut. Then one named sequence, each operation on the previous result: a
//! block, a boss joined on top, three through-cuts from the side faces, the last splitting the
//! body (the "each cut gets slower" case of TASK_kernel-smlib-booleans). Writes
//! `smlib-booleans.json` and `smlib-booleans.md` into DIR (default `target/smlib-booleans`).
//!
//! SMLib runs in a child process per case (this same program, with `--smlib-case`), so a crash
//! inside it is reported as that case's result instead of ending the run.
//! SMLib runs in a child process per case (this same program, with `--smlib-case`, and the
//! sequence with `--smlib-sequence`), so a crash inside it is reported as that case's result
//! instead of ending the run.
//!
//! Times are wall times of the boolean call alone (`boolean` on truck, `Brep::boolean` on
//! SMLib). The SMLib side's conversions are timed apart: the operands' truck→SMLib import
//! (`smlib_body`) and the result's SMLib→truck export (`body_from_smlib`), which the app pays
//! for as long as truck holds the bodies.
//!
//! History is compared by its shape, since the two kernels number faces differently until the
//! SMLib export renumbers into canonical order: how many result faces are Kept, Modified, Split,
@@ -38,14 +46,16 @@ fn main() {
#[cfg(feature = "smlib")]
mod run {
use std::f64::consts::{FRAC_PI_2, FRAC_PI_6, PI};
use std::fmt::Write as _;
use std::path::PathBuf;
use std::time::Instant;
use serde_json::{Value, json};
use solidcraft_geom::brep::history::{Change, EdgeOrigin, History};
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_kernel::fuzz::{Shape, boolean_shapes};
use solidcraft_kernel::{BoolOp, boolean, measure};
use solidcraft_kernel::{Body, BoolOp, boolean, extrude, measure};
use solidcraft_smlib::MEASURE_ACCURACY;
use solidcraft_smlib::history::Order;
@@ -55,8 +65,8 @@ mod run {
/// fine tessellation (`measure`), SMLib integrates exactly.
const VOLUME_REL: f64 = 1e-3;
/// What one kernel made of one case: its volume, face and edge counts, history shape and
/// time, or why it made nothing.
/// What one kernel made of one case: its volume, face, edge and solid counts, history shape
/// and times, or why it made nothing.
#[derive(Clone, Debug, Default)]
struct Outcome {
error: Option<String>,
@@ -64,8 +74,15 @@ mod run {
volume: f64,
faces: usize,
edges: usize,
solids: usize,
history: Option<HistoryShape>,
/// The boolean call alone.
ms: f64,
/// SMLib only: the operands' truck→SMLib import.
import_ms: f64,
/// SMLib only: the result's SMLib→truck export, and why it failed.
export_ms: f64,
export_error: Option<String>,
}
/// A history's shape: faces Kept, Modified, Split, Merged, Generated; edges from an operand
@@ -121,36 +138,53 @@ mod run {
}
}
fn truck(a: &Shape, b: &Shape, op: BoolOp) -> Outcome {
let t0 = Instant::now();
fn ms_since(t0: Instant) -> f64 {
t0.elapsed().as_secs_f64() * 1e3
}
/// `a op b` on truck, timing the boolean alone.
fn truck_bodies(a: &Body, b: &Body, op: BoolOp) -> (Outcome, Option<Body>) {
let mut o = Outcome::default();
let result = a.body().and_then(|ba| b.body().map(|bb| (ba, bb))).and_then(|(ba, bb)| boolean(&ba, &bb, op));
o.ms = t0.elapsed().as_secs_f64() * 1e3;
let t0 = Instant::now();
let result = boolean(a, b, op);
o.ms = ms_since(t0);
match result {
Err(e) => o.error = Some(e.to_string()),
Ok(None) => o.empty = true,
Ok(Some(body)) => {
o.faces = body.face_count();
o.edges = body.edge_count();
o.solids = body.shell_count();
o.history = body.history().map(HistoryShape::of);
match measure(&body) {
Ok(m) => o.volume = m.volume,
Err(e) => o.error = Some(format!("measure: {e}")),
}
return (o, Some(body));
}
}
o
(o, None)
}
fn smlib(a: &Shape, b: &Shape, op: BoolOp) -> Outcome {
let t0 = Instant::now();
fn truck(a: &Shape, b: &Shape, op: BoolOp) -> Outcome {
match a.body().and_then(|ba| b.body().map(|bb| (ba, bb))) {
Err(e) => Outcome { error: Some(format!("truck primitive: {e}")), ..Outcome::default() },
Ok((ba, bb)) => truck_bodies(&ba, &bb, op).0,
}
}
/// `a op b` on SMLib, operands already in SMLib: tagged as operands 0 and 1, the boolean
/// timed alone, then measured exactly and exported back to truck (timed apart).
fn smlib_breps(a: &solidcraft_smlib::Brep, b: &solidcraft_smlib::Brep, op: BoolOp) -> (Outcome, Option<solidcraft_smlib::Brep>) {
let mut o = Outcome::default();
let result = (|| -> Result<Option<solidcraft_smlib::Brep>, String> {
let ta = side::shape(a)?.tagged(0).map_err(|e| e.to_string())?;
let tb = side::shape(b)?.tagged(1).map_err(|e| e.to_string())?;
side::boolean(&ta, &tb, op)
})();
o.ms = t0.elapsed().as_secs_f64() * 1e3;
let tagged = a.tagged(0).and_then(|ta| b.tagged(1).map(|tb| (ta, tb)));
let (ta, tb) = match tagged {
Ok(t) => t,
Err(e) => return (Outcome { error: Some(format!("tag: {e}")), ..o }, None),
};
let t0 = Instant::now();
let result = side::boolean(&ta, &tb, op);
o.ms = ms_since(t0);
match result {
Err(e) => o.error = Some(e),
Ok(None) => o.empty = true,
@@ -169,10 +203,30 @@ mod run {
}
(Err(e), _) | (_, Err(e)) => o.error = Some(e),
}
o.solids = r.solid_count().unwrap_or(0);
o.history = history.ok().as_ref().map(HistoryShape::of);
let t0 = Instant::now();
let back = solidcraft_kernel::body_from_smlib(&r);
o.export_ms = ms_since(t0);
o.export_error = back.err().map(|e| e.to_string());
return (o, Some(r));
}
}
(o, None)
}
fn smlib(a: &Shape, b: &Shape, op: BoolOp) -> Outcome {
let t0 = Instant::now();
let operands = side::shape(a).and_then(|sa| side::shape(b).map(|sb| (sa, sb)));
let import_ms = ms_since(t0);
match operands {
Err(e) => Outcome { error: Some(e), import_ms, ..Outcome::default() },
Ok(((sa, ia), (sb, ib))) => {
let (mut o, _) = smlib_breps(&sa, &sb, op);
o.import_ms = if ia || ib { import_ms } else { 0.0 };
o
}
}
o
}
/// The SMLib side, through `solidcraft-smlib`. Boxes and cylinders are the very B-reps truck
@@ -185,14 +239,17 @@ mod run {
use solidcraft_kernel::fuzz::Shape;
use solidcraft_smlib::{BooleanOp, Brep};
pub fn shape(s: &Shape) -> Result<Brep, String> {
/// The shape in SMLib, and whether it was imported from truck (rather than made there).
pub fn shape(s: &Shape) -> Result<(Brep, bool), String> {
match s {
Shape::Sphere { center, radius } => return Brep::make_sphere(*center, *radius).map_err(|e| e.to_string()),
Shape::Torus { center, major, minor } => return Brep::make_torus(*center, *major, *minor).map_err(|e| e.to_string()),
Shape::Sphere { center, radius } => return Brep::make_sphere(*center, *radius).map(|b| (b, false)).map_err(|e| e.to_string()),
Shape::Torus { center, major, minor } => {
return Brep::make_torus(*center, *major, *minor).map(|b| (b, false)).map_err(|e| e.to_string());
}
Shape::Box { .. } | Shape::Cylinder { .. } => {}
}
let body = s.body().map_err(|e| format!("truck primitive: {e}"))?;
solidcraft_kernel::smlib_body(&body).map_err(|e| format!("import: {e}"))
solidcraft_kernel::smlib_body(&body).map(|b| (b, true)).map_err(|e| format!("import: {e}"))
}
/// `a op b`; `None` when the result is empty.
@@ -206,34 +263,273 @@ mod run {
}
}
/// Pairs whose results are known by hand.
fn named() -> Vec<(String, Shape, Shape)> {
/// ∫ over the disc y² + z² ≤ r² of the chord 2√(R² − y²): the volume a cylinder of radius r
/// shares with a perpendicular one of radius R ≥ r through its axis, both long enough.
/// Simpson in y = r sin θ, where the integrand is smooth.
fn crossing_cylinders(r: f64, big: f64) -> f64 {
simpson(-FRAC_PI_2, FRAC_PI_2, |t| {
let (s, c) = t.sin_cos();
4.0 * r * r * c * c * (big * big - r * r * s * s).max(0.0).sqrt()
})
}
fn simpson(a: f64, b: f64, f: impl Fn(f64) -> f64) -> f64 {
let n = 4096;
let h = (b - a) / n as f64;
let mut sum = f(a) + f(b);
for i in 1..n {
sum += f(a + h * i as f64) * if i % 2 == 1 { 4.0 } else { 2.0 };
}
sum * h / 3.0
}
/// Union, intersection and cut volumes of bodies of volume `a` and `b` sharing `common`.
fn by_op(a: f64, b: f64, common: f64) -> [f64; 3] {
[a + b - common, common, a - common]
}
/// Pairs whose results are known by hand, with their union, intersection and cut volumes
/// (0 is empty).
fn named() -> Vec<(String, Shape, Shape, Option<[f64; 3]>)> {
let bx = |a: [f64; 3], b: [f64; 3]| Shape::Box { a, b };
let z = [0.0, 0.0, 1.0];
let ball = 4.0 / 3.0 * PI * 125.0;
// The torus's tube (centre radius 4, radius 2) inside the post's radius 3: the tube's
// section disc for ρ ≤ 3, swept round, with ρ − 4 = 2 sin θ.
let ring_in_post = simpson(-FRAC_PI_2, -FRAC_PI_6, |t| {
let (s, c) = t.sin_cos();
2.0 * PI * (4.0 + 2.0 * s) * 8.0 * c * c
});
vec![
("overlapping boxes".into(), bx([0.0; 3], [10.0; 3]), bx([5.0; 3], [15.0; 3])),
("boxes sharing faces".into(), bx([0.0; 3], [10.0; 3]), bx([5.0, 0.0, 0.0], [15.0, 10.0, 10.0])),
("boxes apart".into(), bx([0.0; 3], [10.0; 3]), bx([20.0, 0.0, 0.0], [30.0, 10.0, 10.0])),
("slot through a bar".into(), bx([0.0; 3], [30.0, 10.0, 10.0]), bx([10.0, -1.0, -1.0], [20.0, 11.0, 11.0])),
("pocket".into(), bx([0.0; 3], [10.0; 3]), bx([3.0; 3], [7.0, 7.0, 23.0])),
("hole through a box".into(), bx([0.0; 3], [10.0; 3]), Shape::Cylinder { base: [5.0, 5.0, -5.0], axis: z, radius: 2.0, height: 20.0 }),
("overlapping boxes".into(), bx([0.0; 3], [10.0; 3]), bx([5.0; 3], [15.0; 3]), Some(by_op(1000.0, 1000.0, 125.0))),
("boxes sharing faces".into(), bx([0.0; 3], [10.0; 3]), bx([5.0, 0.0, 0.0], [15.0, 10.0, 10.0]), Some(by_op(1000.0, 1000.0, 500.0))),
("boxes apart".into(), bx([0.0; 3], [10.0; 3]), bx([20.0, 0.0, 0.0], [30.0, 10.0, 10.0]), Some(by_op(1000.0, 1000.0, 0.0))),
(
"slot through a bar".into(),
bx([0.0; 3], [30.0, 10.0, 10.0]),
bx([10.0, -1.0, -1.0], [20.0, 11.0, 11.0]),
Some(by_op(3000.0, 1440.0, 1000.0)),
),
("pocket".into(), bx([0.0; 3], [10.0; 3]), bx([3.0; 3], [7.0, 7.0, 23.0]), Some(by_op(1000.0, 320.0, 112.0))),
(
"hole through a box".into(),
bx([0.0; 3], [10.0; 3]),
Shape::Cylinder { base: [5.0, 5.0, -5.0], axis: z, radius: 2.0, height: 20.0 },
Some(by_op(1000.0, 80.0 * PI, 40.0 * PI)),
),
(
"crossing cylinders".into(),
Shape::Cylinder { base: [0.0, 0.0, -10.0], axis: z, radius: 4.0, height: 20.0 },
Shape::Cylinder { base: [-10.0, 0.0, 0.0], axis: [1.0, 0.0, 0.0], radius: 3.0, height: 20.0 },
Some(by_op(320.0 * PI, 180.0 * PI, crossing_cylinders(3.0, 4.0))),
),
(
"sphere into a box corner".into(),
bx([0.0; 3], [10.0; 3]),
Shape::Sphere { center: [10.0; 3], radius: 5.0 },
Some(by_op(1000.0, ball, ball / 8.0)),
),
("sphere into a box corner".into(), bx([0.0; 3], [10.0; 3]), Shape::Sphere { center: [10.0; 3], radius: 5.0 }),
(
"torus around a post".into(),
Shape::Cylinder { base: [0.0; 3], axis: z, radius: 3.0, height: 10.0 },
Shape::Torus { center: [0.0, 0.0, 5.0], major: 4.0, minor: 2.0 },
Some(by_op(90.0 * PI, 32.0 * PI * PI, ring_in_post)),
),
]
}
/// One step of the cut sequence: a profile extruded into a tool, combined with the body so
/// far. The first step (no operation) is the starting body itself.
struct Step {
name: &'static str,
op: Option<BoolOp>,
plane: Plane,
region: Region2,
start: f64,
end: f64,
/// The body's volume after this step.
volume: f64,
solids: usize,
}
impl Step {
fn tool(&self) -> Result<Body, String> {
let mut bodies = extrude(&self.plane, std::slice::from_ref(&self.region), self.start, self.end).map_err(|e| e.to_string())?;
match (bodies.pop(), bodies.is_empty()) {
(Some(b), true) => Ok(b),
_ => Err("the profile extruded into other than one body".into()),
}
}
}
fn region(outer: Loop2) -> Region2 {
Region2 { outer: outer.ccw(), holes: vec![] }
}
/// A slot between the centres `a` and `b` of its two end arcs, `r` wide either side.
fn slot(a: Vec2, b: Vec2, r: f64) -> Loop2 {
let d = b - a;
let side = Vec2::new(-d.y, d.x) * (r / d.x.hypot(d.y));
let along = d.y.atan2(d.x);
Loop2 {
segs: vec![
Seg2::Line { a: a - side, b: b - side },
Seg2::Arc { center: b, radius: r, start: along - FRAC_PI_2, sweep: PI },
Seg2::Line { a: b + side, b: a + side },
Seg2::Arc { center: a, radius: r, start: along + FRAC_PI_2, sweep: PI },
],
}
}
/// Block 63.6 × 31.7 × 9.5; a Ø20 × 10 boss joined on its top; a slot through it front to
/// back; a Ø6 cross hole through the boss from the right side; a slot front to back through
/// the whole height that splits the block in two. Tools run 1 mm past the body each side.
fn sequence() -> Vec<Step> {
let (w, d, h) = (63.6, 31.7, 9.5);
let block = w * d * h;
let boss = block + 1000.0 * PI;
// Front plane: sketch x = +X, y = −Z, normal +Y.
let front = Plane { origin: Vec3::new(0.0, -1.0, 0.0), ..Plane::XZ };
// Left plane: sketch x = +Y, y = +Z, normal +X.
let left = Plane { origin: Vec3::new(-1.0, 0.0, 0.0), ..Plane::YZ };
let slot1 = boss - (10.0 * 5.0 + PI * 2.5 * 2.5) * d;
let cross = slot1 - crossing_cylinders(3.0, 10.0);
let split = cross - 4.0 * h * d;
vec![
Step {
name: "block",
op: None,
plane: Plane::XY,
region: region(Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, d), Vec2::new(0.0, d)])),
start: 0.0,
end: h,
volume: block,
solids: 1,
},
Step {
name: "join Ø20 boss",
op: Some(BoolOp::Union),
plane: Plane { origin: Vec3::new(0.0, 0.0, h), ..Plane::XY },
region: region(Loop2::circle(Vec2::new(w / 2.0, d / 2.0), 10.0)),
start: 0.0,
end: 10.0,
volume: boss,
solids: 1,
},
Step {
name: "cut slot front to back",
op: Some(BoolOp::Cut),
plane: front,
region: region(slot(Vec2::new(8.0, -h / 2.0), Vec2::new(18.0, -h / 2.0), 2.5)),
start: 0.0,
end: d + 2.0,
volume: slot1,
solids: 1,
},
Step {
name: "cut Ø6 cross hole in the boss",
op: Some(BoolOp::Cut),
plane: left,
region: region(Loop2::circle(Vec2::new(d / 2.0, h + 5.0), 3.0)),
start: 0.0,
end: w + 2.0,
volume: cross,
solids: 1,
},
Step {
name: "cut splitting slot",
op: Some(BoolOp::Cut),
plane: front,
region: region(slot(Vec2::new(52.0, 3.0), Vec2::new(52.0, -13.0), 2.0)),
start: 0.0,
end: d + 2.0,
volume: split,
solids: 2,
},
]
}
/// The sequence on truck, each step on the previous step's result; stops at the first
/// failure.
fn truck_sequence(steps: &[Step]) -> Vec<Outcome> {
let mut out = Vec::new();
let mut body: Option<Body> = None;
for s in steps {
let tool = match s.tool() {
Ok(t) => t,
Err(e) => {
out.push(Outcome { error: Some(format!("tool: {e}")), ..Outcome::default() });
break;
}
};
let (o, next) = match (&body, s.op) {
(Some(b), Some(op)) => truck_bodies(b, &tool, op),
_ => {
let o = Outcome {
faces: tool.face_count(),
edges: tool.edge_count(),
solids: tool.shell_count(),
volume: measure(&tool).map(|m| m.volume).unwrap_or(f64::NAN),
..Outcome::default()
};
(o, Some(tool))
}
};
let failed = o.error.is_some() || next.is_none();
out.push(o);
body = next;
if failed {
break;
}
}
out
}
/// The sequence on SMLib, each step on SMLib's own previous result (only the tools come from
/// truck); stops at the first failure.
fn smlib_sequence(steps: &[Step]) -> Vec<Outcome> {
let mut out = Vec::new();
let mut body: Option<solidcraft_smlib::Brep> = None;
for s in steps {
let t0 = Instant::now();
let tool = s.tool().and_then(|t| solidcraft_kernel::smlib_body(&t).map_err(|e| format!("import: {e}")));
let import_ms = ms_since(t0);
let tool = match tool {
Ok(t) => t,
Err(e) => {
out.push(Outcome { error: Some(format!("tool: {e}")), import_ms, ..Outcome::default() });
break;
}
};
let (mut o, next) = match (&body, s.op) {
(Some(b), Some(op)) => smlib_breps(b, &tool, op),
_ => {
let [faces, edges, _] = tool.counts().unwrap_or_default();
let o = Outcome {
faces,
edges,
solids: tool.solid_count().unwrap_or(0),
volume: tool.mass_properties(MEASURE_ACCURACY).map(|m| m.volume).unwrap_or(f64::NAN),
..Outcome::default()
};
(o, Some(tool))
}
};
o.import_ms = import_ms;
let failed = o.error.is_some() || next.is_none();
out.push(o);
body = next;
if failed {
break;
}
}
out
}
fn outcome_from_json(v: &Value) -> Outcome {
let n = |k: &str| v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()).unwrap_or(0);
let f = |k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(f64::NAN);
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
let arr = |h: &Value, k: &str| -> Vec<usize> {
h.get(k)
.and_then(Value::as_array)
@@ -245,33 +541,49 @@ mod run {
.filter(|h| !h.is_null())
.and_then(|h| Some(HistoryShape { faces: arr(h, "faces").try_into().ok()?, edges: arr(h, "edges").try_into().ok()? }));
Outcome {
error: v.get("error").and_then(Value::as_str).map(str::to_string),
error: s("error"),
empty: v.get("empty").and_then(Value::as_bool).unwrap_or(false),
volume: f("volume"),
faces: n("faces"),
edges: n("edges"),
solids: n("solids"),
history,
ms: f("ms"),
import_ms: f("import_ms"),
export_ms: f("export_ms"),
export_error: s("export_error"),
}
}
/// This program again with `args`, its standard output as JSON; a crash is an error.
fn child(args: &[String]) -> Result<Value, String> {
let exe = std::env::current_exe().map_err(|e| format!("cannot start a child: {e}"))?;
let out = std::process::Command::new(exe)
.args(args)
.stderr(std::process::Stdio::null())
.output()
.map_err(|e| format!("cannot start a child: {e}"))?;
if !out.status.success() {
return Err(format!("crashed ({})", out.status));
}
serde_json::from_slice::<Value>(&out.stdout).map_err(|e| format!("unreadable child output: {e}"))
}
/// [`smlib`] for case `index` and operation `op`, in a child process.
fn smlib_isolated(index: usize, op: usize, seeds: u64) -> Outcome {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return Outcome { error: Some(format!("cannot start a child: {e}")), ..Outcome::default() },
};
let run = std::process::Command::new(exe)
.args(["--seeds", &seeds.to_string(), "--smlib-case", &index.to_string(), &op.to_string()])
.stderr(std::process::Stdio::null())
.output();
match run {
Err(e) => Outcome { error: Some(format!("cannot start a child: {e}")), ..Outcome::default() },
Ok(out) if !out.status.success() => Outcome { error: Some(format!("crashed ({})", out.status)), ..Outcome::default() },
Ok(out) => match serde_json::from_slice::<Value>(&out.stdout) {
Ok(v) => outcome_from_json(&v),
Err(e) => Outcome { error: Some(format!("unreadable child output: {e}")), ..Outcome::default() },
},
let args = ["--seeds".to_string(), seeds.to_string(), "--smlib-case".into(), index.to_string(), op.to_string()];
match child(&args) {
Ok(v) => outcome_from_json(&v),
Err(e) => Outcome { error: Some(e), ..Outcome::default() },
}
}
/// [`smlib_sequence`] in a child process.
fn smlib_sequence_isolated() -> Vec<Outcome> {
match child(&["--smlib-sequence".to_string()]) {
Ok(Value::Array(steps)) => steps.iter().map(outcome_from_json).collect(),
Ok(_) => vec![Outcome { error: Some("unreadable child output".into()), ..Outcome::default() }],
Err(e) => vec![Outcome { error: Some(e), ..Outcome::default() }],
}
}
@@ -282,8 +594,12 @@ mod run {
"volume": o.volume,
"faces": o.faces,
"edges": o.edges,
"solids": o.solids,
"history": o.history.map(|h| json!({"faces": h.faces, "edges": h.edges})),
"ms": o.ms,
"import_ms": o.import_ms,
"export_ms": o.export_ms,
"export_error": o.export_error,
})
}
@@ -312,6 +628,9 @@ mod run {
if t.edges != s.edges {
diffs.push(format!("edges {} vs {}", t.edges, s.edges));
}
if t.solids != s.solids {
diffs.push(format!("solids {} vs {}", t.solids, s.solids));
}
match (t.history, s.history) {
(Some(a), Some(b)) if a != b => diffs.push("history".into()),
(None, Some(_)) => diffs.push("no truck history".into()),
@@ -321,10 +640,42 @@ mod run {
if diffs.is_empty() { ("same".into(), "same") } else { (diffs.join(", "), "differs") }
}
fn cell(o: &Outcome) -> String {
match (&o.error, o.empty) {
(Some(e), _) => format!("error: {}", e.replace('|', "/")),
(None, true) => "empty".into(),
(None, false) => {
format!("{:.3} · {}f {}e {}s · {}", o.volume, o.faces, o.edges, o.solids, o.history.map_or("no history".into(), |h| h.text()))
}
}
}
/// How far a measured volume lies from the analytic one, relative; `None` when it made none.
fn off(o: &Outcome, analytic: f64) -> Option<f64> {
if o.error.is_some() {
return None;
}
let v = if o.empty { 0.0 } else { o.volume };
Some(if analytic == 0.0 { v.abs() } else { (v / analytic - 1.0).abs() })
}
/// Median, worst and total of `xs` (ms), or dashes when empty.
fn stats(xs: &mut Vec<f64>) -> String {
xs.retain(|x| x.is_finite());
if xs.is_empty() {
return "– | – | –".into();
}
xs.sort_by(f64::total_cmp);
let median = xs.get(xs.len() / 2).copied().unwrap_or(f64::NAN);
let worst = xs.last().copied().unwrap_or(f64::NAN);
format!("{median:.2} | {worst:.1} | {:.0}", xs.iter().sum::<f64>())
}
pub fn main() -> Result<(), String> {
let mut seeds = 32u64;
let mut out = PathBuf::from("target/smlib-booleans");
let mut child = None;
let mut child_case = None;
let mut child_sequence = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
@@ -332,68 +683,165 @@ mod run {
"--out" => out = args.next().map(PathBuf::from).ok_or("--out takes a directory")?,
"--smlib-case" => {
let mut n = || args.next().and_then(|n| n.parse::<usize>().ok()).ok_or("--smlib-case takes a case and an operation");
child = Some((n()?, n()?));
child_case = Some((n()?, n()?));
}
"--smlib-sequence" => child_sequence = true,
other => return Err(format!("unknown argument {other}")),
}
}
let mut cases = named();
cases.extend((0..seeds).map(|s| {
let (a, b) = boolean_shapes(s);
(format!("seed {s}"), a, b)
(format!("seed {s}"), a, b, None)
}));
if let Some((index, op)) = child {
let ((_, a, b), op) = cases.get(index).zip(OPS.get(op)).ok_or("no such case")?;
if let Some((index, op)) = child_case {
let ((_, a, b, _), op) = cases.get(index).zip(OPS.get(op)).ok_or("no such case")?;
solidcraft_smlib::init().map_err(|e| e.to_string())?;
println!("{}", outcome_json(&smlib(a, b, *op)));
return Ok(());
}
let steps = sequence();
if child_sequence {
solidcraft_smlib::init().map_err(|e| e.to_string())?;
println!("{}", Value::Array(smlib_sequence(&steps).iter().map(outcome_json).collect()));
return Ok(());
}
solidcraft_smlib::init().map_err(|e| e.to_string())?;
let mut rows = Vec::new();
let mut md = String::new();
let mut tally = std::collections::BTreeMap::<&str, usize>::new();
for (index, (name, a, b)) in cases.iter().enumerate() {
let (mut truck_ms, mut smlib_ms, mut import_ms, mut export_ms) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let (mut truck_fail, mut smlib_fail, mut export_fail) = (0usize, 0usize, 0usize);
let (mut analytic_ops, mut truck_exact, mut smlib_exact) = (0usize, 0usize, 0usize);
for (index, (name, a, b, analytic)) in cases.iter().enumerate() {
for (k, op) in OPS.into_iter().enumerate() {
let t = truck(a, b, op);
let s = smlib_isolated(index, k, seeds);
let (what, class) = verdict(&t, &s);
*tally.entry(class).or_default() += 1;
let cell = |o: &Outcome| match (&o.error, o.empty) {
(Some(e), _) => format!("error: {}", e.replace('|', "/")),
(None, true) => "empty".into(),
(None, false) => format!(
"{:.3} · {}f {}e · {} · {:.0} ms",
o.volume,
o.faces,
o.edges,
o.history.map_or("no history".into(), |h| h.text()),
o.ms
),
let want = analytic.and_then(|v| v.get(k).copied());
if let Some(w) = want {
analytic_ops += 1;
truck_exact += usize::from(off(&t, w).is_some_and(|d| d <= VOLUME_REL));
smlib_exact += usize::from(off(&s, w).is_some_and(|d| d <= 1e-6));
}
truck_fail += usize::from(t.error.is_some());
smlib_fail += usize::from(s.error.is_some());
export_fail += usize::from(s.export_error.is_some());
if t.error.is_none() {
truck_ms.push(t.ms);
}
if s.error.is_none() {
smlib_ms.push(s.ms);
import_ms.push(s.import_ms);
if !s.empty {
export_ms.push(s.export_ms);
}
}
let want_text = want.map_or("–".into(), |w| if w == 0.0 { "empty".into() } else { format!("{w:.3}") });
let back = match (&s.export_error, s.error.is_some() || s.empty) {
(_, true) => "–".into(),
(Some(e), false) => format!("{:.1} (failed: {})", s.export_ms, e.replace('|', "/")),
(None, false) => format!("{:.1}", s.export_ms),
};
let _ = writeln!(md, "| {name} | {} | {} | {} | {what} |", op_name(op), cell(&t), cell(&s));
let _ = writeln!(
md,
"| {name} | {} | {want_text} | {} | {} | {what} | {:.1} | {:.1} | {:.1} | {back} |",
op_name(op),
cell(&t),
cell(&s),
t.ms,
s.ms,
s.import_ms,
);
rows.push(json!({
"case": name, "a": a, "b": b, "op": op_name(op),
"case": name, "a": a, "b": b, "op": op_name(op), "analytic": want,
"truck": outcome_json(&t), "smlib": outcome_json(&s),
"verdict": class, "detail": what,
}));
}
}
let truck_steps = truck_sequence(&steps);
let smlib_steps = smlib_sequence_isolated();
let mut seq_md = String::new();
let mut seq_json = Vec::new();
for (i, s) in steps.iter().enumerate() {
let (t, m) = (truck_steps.get(i), smlib_steps.get(i));
let ms = |o: Option<&Outcome>| o.filter(|_| s.op.is_some()).map_or("–".into(), |o| format!("{:.1}", o.ms));
let vol = |o: Option<&Outcome>| match o {
None => "not run".into(),
Some(o) => match &o.error {
Some(e) => format!("error: {}", e.replace('|', "/")),
None => format!("{:.3} ({}f {}e {}s)", o.volume, o.faces, o.edges, o.solids),
},
};
let export = m.filter(|o| s.op.is_some() && o.error.is_none()).map_or("–".into(), |o| match &o.export_error {
Some(e) => format!("{:.1} (failed: {})", o.export_ms, e.replace('|', "/")),
None => format!("{:.1}", o.export_ms),
});
let _ = writeln!(
seq_md,
"| {} | {:.3} ({}s) | {} | {} | {} | {} | {} | {export} |",
s.name,
s.volume,
s.solids,
vol(t),
vol(m),
ms(t),
ms(m),
m.map_or("–".into(), |o| format!("{:.1}", o.import_ms)),
);
seq_json.push(json!({
"step": s.name, "op": s.op.map(op_name), "analytic": s.volume, "analytic_solids": s.solids,
"truck": t.map(outcome_json), "smlib": m.map(outcome_json),
}));
}
let summary: Vec<String> = tally.iter().map(|(k, v)| format!("{k} {v}")).collect();
let ops = cases.len() * OPS.len();
let mut report = String::new();
let _ = writeln!(report, "# Booleans: truck and SMLib side by side\n");
let _ = writeln!(report, "{} cases × 3 operations: {}.\n", cases.len(), summary.join(", "));
let _ = writeln!(
report,
"History shape: faces **K**ept, **M**odified, **S**plit, **J**oined (merged), **G**enerated; \
edges from an operand **e**dge, new where faces meet (**x**), without origin (**-**).\n"
"Failed: truck {truck_fail}/{ops}, SMLib {smlib_fail}/{ops}; SMLib results that don't export back to truck: \
{export_fail}. Against the {analytic_ops} analytic volumes: truck within {VOLUME_REL:e} on {truck_exact}, \
SMLib within 1e-6 on {smlib_exact}.\n"
);
let _ = writeln!(report, "## Times (ms, operations that didn't fail)\n");
let _ = writeln!(report, "| | median | worst | total |\n|---|---|---|---|");
let _ = writeln!(report, "| truck boolean | {} |", stats(&mut truck_ms));
let _ = writeln!(report, "| SMLib boolean | {} |", stats(&mut smlib_ms));
let _ = writeln!(report, "| SMLib import (truck→SMLib, both operands) | {} |", stats(&mut import_ms));
let _ = writeln!(report, "| SMLib export (SMLib→truck, result) | {} |\n", stats(&mut export_ms));
let _ = writeln!(
report,
"## Cut sequence\n\nEach step on the previous step's result, each kernel on its own (SMLib's results stay in SMLib; \
only the tools come from truck). Volumes in mm³ (faces, edges, solids); times in ms.\n"
);
let _ = writeln!(
report,
"| step | analytic | truck | SMLib | truck ms | SMLib ms | SMLib import ms | SMLib export ms |\n|---|---|---|---|---|---|---|---|"
);
report.push_str(&seq_md);
let _ = writeln!(report, "\n## Cases\n");
let _ = writeln!(
report,
"Volume · faces, edges, solids · history shape: faces **K**ept, **M**odified, **S**plit, **J**oined (merged), \
**G**enerated; edges from an operand **e**dge, new where faces meet (**x**), without origin (**-**). \
Times in ms: the boolean on each kernel, then SMLib's import of the operands and export of the result.\n"
);
let _ = writeln!(
report,
"| case | op | analytic | truck | SMLib | verdict | truck ms | SMLib ms | import ms | export ms |\n\
|---|---|---|---|---|---|---|---|---|---|"
);
let _ = writeln!(report, "| case | op | truck | SMLib | verdict |\n|---|---|---|---|---|");
report.push_str(&md);
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
let json = json!({ "cases": cases.len(), "tally": tally, "rows": rows });
let json = json!({ "cases": cases.len(), "tally": tally, "rows": rows, "sequence": seq_json });
let text = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
std::fs::write(out.join("smlib-booleans.json"), text).map_err(|e| e.to_string())?;
std::fs::write(out.join("smlib-booleans.md"), &report).map_err(|e| e.to_string())?;