diff --git a/crates/kernel/examples/smlib_booleans.rs b/crates/kernel/examples/smlib_booleans.rs index c44e7bf99a..d1cb4f9626 100644 --- a/crates/kernel/examples/smlib_booleans.rs +++ b/crates/kernel/examples/smlib_booleans.rs @@ -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, @@ -64,8 +74,15 @@ mod run { volume: f64, faces: usize, edges: usize, + solids: usize, history: Option, + /// 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, } /// 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) { 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) { let mut o = Outcome::default(); - let result = (|| -> Result, 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 { + /// 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, + 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 { + 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 { + 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 { + let mut out = Vec::new(); + let mut body: Option = 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 { + let mut out = Vec::new(); + let mut body: Option = 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 { 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 { + 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::(&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::(&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 { + 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 { + 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) -> 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::()) + } + 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::().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 = 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())?;