mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
kernel: a side-by-side runner for truck and SMLib booleans
examples/smlib_booleans runs truck and SMLib on the same cases (named pairs whose results are known by hand, then fuzz::boolean_shapes for N seeds), each as union, intersection and cut, and compares volume, face and edge counts and the shape of the history (how many faces Kept, Modified, Split, Merged, Generated; edges from an operand edge, new, or without origin). It writes a JSON and a Markdown report and never changes the default kernel. SMLib runs in a child process per case, so a crash inside it is reported as that case's result. Its primitives and booleans arrive with the operation shims; until a call exists the case is reported as not run on SMLib. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ```sh
|
||||
//! scripts/fetch-smlib.sh
|
||||
//! cargo run --release -p solidcraft-kernel --features smlib --example smlib_booleans -- \
|
||||
//! [--seeds N] [--out DIR]
|
||||
//! ```
|
||||
//!
|
||||
//! 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`).
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! 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,
|
||||
//! Merged and Generated, and how many edges come from an operand edge, are new where two faces
|
||||
//! meet, or have no origin.
|
||||
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
fn main() {
|
||||
eprintln!("smlib_booleans needs SMLib: run scripts/fetch-smlib.sh, then add --features smlib");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "smlib")]
|
||||
fn main() {
|
||||
if let Err(e) = run::main() {
|
||||
eprintln!("smlib_booleans: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "smlib")]
|
||||
mod run {
|
||||
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_kernel::fuzz::{Shape, boolean_shapes};
|
||||
use solidcraft_kernel::{BoolOp, boolean, measure};
|
||||
use solidcraft_smlib::history::Order;
|
||||
|
||||
const OPS: [BoolOp; 3] = [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut];
|
||||
|
||||
/// Relative volume difference that still counts as the same result: truck measures from a
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Outcome {
|
||||
error: Option<String>,
|
||||
empty: bool,
|
||||
volume: f64,
|
||||
faces: usize,
|
||||
edges: usize,
|
||||
history: Option<HistoryShape>,
|
||||
ms: f64,
|
||||
}
|
||||
|
||||
/// A history's shape: faces Kept, Modified, Split, Merged, Generated; edges from an operand
|
||||
/// edge, new where two faces meet, without origin.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct HistoryShape {
|
||||
faces: [usize; 5],
|
||||
edges: [usize; 3],
|
||||
}
|
||||
|
||||
impl HistoryShape {
|
||||
fn of(h: &History) -> HistoryShape {
|
||||
let mut s = HistoryShape::default();
|
||||
for f in &h.faces {
|
||||
let i = match f.change {
|
||||
Change::Kept => 0,
|
||||
Change::Modified => 1,
|
||||
Change::Split => 2,
|
||||
Change::Merged => 3,
|
||||
Change::Generated => 4,
|
||||
};
|
||||
if let Some(n) = s.faces.get_mut(i) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
for e in &h.edges {
|
||||
let i = if e.origins.iter().any(|o| matches!(o, EdgeOrigin::Edge { .. })) {
|
||||
0
|
||||
} else if e.origins.is_empty() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if let Some(n) = s.edges.get_mut(i) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn text(&self) -> String {
|
||||
let [k, m, s, g, n] = self.faces;
|
||||
let [e, meet, none] = self.edges;
|
||||
format!("K{k} M{m} S{s} J{g} G{n} / e{e} x{meet} -{none}")
|
||||
}
|
||||
}
|
||||
|
||||
fn op_name(op: BoolOp) -> &'static str {
|
||||
match op {
|
||||
BoolOp::Union => "union",
|
||||
BoolOp::Intersect => "intersect",
|
||||
BoolOp::Cut => "cut",
|
||||
}
|
||||
}
|
||||
|
||||
fn truck(a: &Shape, b: &Shape, op: BoolOp) -> Outcome {
|
||||
let t0 = Instant::now();
|
||||
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;
|
||||
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.history = body.history().map(HistoryShape::of);
|
||||
match measure(&body) {
|
||||
Ok(m) => o.volume = m.volume,
|
||||
Err(e) => o.error = Some(format!("measure: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn smlib(a: &Shape, b: &Shape, op: BoolOp) -> Outcome {
|
||||
let t0 = Instant::now();
|
||||
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;
|
||||
match result {
|
||||
Err(e) => o.error = Some(e),
|
||||
Ok(None) => o.empty = true,
|
||||
Ok(Some(r)) => {
|
||||
let counts = r.counts().map_err(|e| e.to_string());
|
||||
let volume = r.volume(1e-6).map_err(|e| e.to_string());
|
||||
let history = r
|
||||
.history()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|raw| raw.history(&[Order::default(), Order::default()], &Order::default()).map_err(|e| e.to_string()));
|
||||
match (counts, volume) {
|
||||
(Ok([f, e, _]), Ok(v)) => {
|
||||
o.faces = f;
|
||||
o.edges = e;
|
||||
o.volume = v;
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => o.error = Some(e),
|
||||
}
|
||||
o.history = history.ok().as_ref().map(HistoryShape::of);
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The SMLib side, through `solidcraft-smlib`. Primitives and booleans arrive with the
|
||||
/// operation shims (smlib_primitives.cpp, smlib_ops.cpp); until a call exists it answers
|
||||
/// "not supported yet" and the report counts the case as not run on SMLib.
|
||||
mod side {
|
||||
use solidcraft_kernel::BoolOp;
|
||||
use solidcraft_kernel::fuzz::Shape;
|
||||
use solidcraft_smlib::Brep;
|
||||
|
||||
fn not_yet(what: &str) -> String {
|
||||
format!("not supported yet: {what} through the SMLib C API")
|
||||
}
|
||||
|
||||
pub fn shape(s: &Shape) -> Result<Brep, String> {
|
||||
match s {
|
||||
Shape::Box { a, b } => Brep::make_box(*a, [b[0] - a[0], b[1] - a[1], b[2] - a[2]]).map_err(|e| e.to_string()),
|
||||
Shape::Cylinder { .. } => Err(not_yet("cylinder")),
|
||||
Shape::Sphere { .. } => Err(not_yet("sphere")),
|
||||
Shape::Torus { .. } => Err(not_yet("torus")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `a op b`; `None` when the result is empty.
|
||||
pub fn boolean(_a: &Brep, _b: &Brep, op: BoolOp) -> Result<Option<Brep>, String> {
|
||||
Err(not_yet(match op {
|
||||
BoolOp::Union => "union",
|
||||
BoolOp::Intersect => "intersection",
|
||||
BoolOp::Cut => "cut",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs whose results are known by hand.
|
||||
fn named() -> Vec<(String, Shape, Shape)> {
|
||||
let bx = |a: [f64; 3], b: [f64; 3]| Shape::Box { a, b };
|
||||
let z = [0.0, 0.0, 1.0];
|
||||
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 }),
|
||||
(
|
||||
"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 },
|
||||
),
|
||||
("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 },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
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 arr = |h: &Value, k: &str| -> Vec<usize> {
|
||||
h.get(k)
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_u64).filter_map(|x| usize::try_from(x).ok()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let history = v
|
||||
.get("history")
|
||||
.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),
|
||||
empty: v.get("empty").and_then(Value::as_bool).unwrap_or(false),
|
||||
volume: f("volume"),
|
||||
faces: n("faces"),
|
||||
edges: n("edges"),
|
||||
history,
|
||||
ms: f("ms"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome_json(o: &Outcome) -> Value {
|
||||
json!({
|
||||
"error": o.error,
|
||||
"empty": o.empty,
|
||||
"volume": o.volume,
|
||||
"faces": o.faces,
|
||||
"edges": o.edges,
|
||||
"history": o.history.map(|h| json!({"faces": h.faces, "edges": h.edges})),
|
||||
"ms": o.ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// How the two compare: "same", what differs, or why it couldn't be compared.
|
||||
fn verdict(t: &Outcome, s: &Outcome) -> (String, &'static str) {
|
||||
if s.error.as_deref().is_some_and(|e| e.starts_with("not supported yet")) {
|
||||
return ("not run on SMLib".into(), "skipped");
|
||||
}
|
||||
match (&t.error, &s.error) {
|
||||
(Some(_), Some(_)) => return ("both failed".into(), "failed"),
|
||||
(Some(_), None) => return ("truck failed, SMLib didn't".into(), "smlib-only"),
|
||||
(None, Some(_)) => return ("SMLib failed, truck didn't".into(), "truck-only"),
|
||||
(None, None) => {}
|
||||
}
|
||||
if t.empty || s.empty {
|
||||
return if t.empty == s.empty { ("both empty".into(), "same") } else { ("one empty".into(), "differs") };
|
||||
}
|
||||
let mut diffs = Vec::new();
|
||||
let scale = t.volume.abs().max(s.volume.abs()).max(1e-9);
|
||||
if (t.volume - s.volume).abs() > VOLUME_REL * scale {
|
||||
diffs.push(format!("volume {:.4} vs {:.4}", t.volume, s.volume));
|
||||
}
|
||||
if t.faces != s.faces {
|
||||
diffs.push(format!("faces {} vs {}", t.faces, s.faces));
|
||||
}
|
||||
if t.edges != s.edges {
|
||||
diffs.push(format!("edges {} vs {}", t.edges, s.edges));
|
||||
}
|
||||
match (t.history, s.history) {
|
||||
(Some(a), Some(b)) if a != b => diffs.push("history".into()),
|
||||
(None, Some(_)) => diffs.push("no truck history".into()),
|
||||
(Some(_), None) => diffs.push("no SMLib history".into()),
|
||||
_ => {}
|
||||
}
|
||||
if diffs.is_empty() { ("same".into(), "same") } else { (diffs.join(", "), "differs") }
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), String> {
|
||||
let mut seeds = 32u64;
|
||||
let mut out = PathBuf::from("target/smlib-booleans");
|
||||
let mut child = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--seeds" => seeds = args.next().and_then(|n| n.parse().ok()).ok_or("--seeds takes a number")?,
|
||||
"--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()?));
|
||||
}
|
||||
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)
|
||||
}));
|
||||
if let Some((index, op)) = child {
|
||||
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(());
|
||||
}
|
||||
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() {
|
||||
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 _ = writeln!(md, "| {name} | {} | {} | {} | {what} |", op_name(op), cell(&t), cell(&s));
|
||||
rows.push(json!({
|
||||
"case": name, "a": a, "b": b, "op": op_name(op),
|
||||
"truck": outcome_json(&t), "smlib": outcome_json(&s),
|
||||
"verdict": class, "detail": what,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let summary: Vec<String> = tally.iter().map(|(k, v)| format!("{k} {v}")).collect();
|
||||
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"
|
||||
);
|
||||
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 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())?;
|
||||
println!("{} cases × 3: {} ({})", cases.len(), summary.join(", "), out.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user