From ccde7a483737db8abae92ed95659bdbbeaf453ae Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 18:39:55 +0000 Subject: [PATCH] Robustness runs and the kernel fixes they found - solvecraft-cli fuzz / fuzz-one: seeded random modelling sessions (boxes, extruded profiles with arcs, revolves; joins and cuts of boxes, cylinders and spheres, fillets, chamfers, shells, holes, patterns) on a grid that makes coincident faces and tangencies common, every step checked (command ok, every body a sound solid via the new Body::validity, volume moving the right way), plus the boolean volume identity on random primitives; one process per seed with a time limit; failures minimised to scripts - shell/offset: corners where a curved face meets another at an angle are refined onto the moved surfaces (Newton); edges that are neither lines nor arcs are rebuilt through their moved points; one shell per piece; faces that would cross say so (not supported yet) instead of leaving a cavity inside out; flat revolved faces count as planes; the unhealed body is tried when the healed one fails - cylinders start their seams at an odd angle (grid-aligned seams broke booleans along them) - partial revolves of profiles touching the axis build no zero-area face on the axis - booleans on bodies in several pieces retry a piece at a time - regression tests for each, and a fixed set of seeds in cargo test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- apps/solvecraft-cli/src/fuzz.rs | 212 ++++++++++ apps/solvecraft-cli/src/main.rs | 4 + crates/engine/src/cmd/sketch_project_tests.rs | 3 +- crates/engine/src/fuzz.rs | 372 ++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/robustness.rs | 91 +++++ crates/kernel/src/body.rs | 25 ++ crates/kernel/src/build.rs | 140 ++++++- crates/kernel/src/fuzz.rs | 160 ++++++++ crates/kernel/src/lib.rs | 1 + crates/kernel/src/meshbody.rs | 2 +- crates/kernel/src/offset.rs | 83 +++- crates/kernel/src/ops.rs | 95 ++++- crates/kernel/src/polyhedron.rs | 23 +- crates/kernel/src/tests.rs | 63 +++ 16 files changed, 1260 insertions(+), 16 deletions(-) create mode 100644 apps/solvecraft-cli/src/fuzz.rs create mode 100644 crates/engine/src/fuzz.rs create mode 100644 crates/engine/src/tests/robustness.rs create mode 100644 crates/kernel/src/fuzz.rs diff --git a/apps/solvecraft-cli/src/fuzz.rs b/apps/solvecraft-cli/src/fuzz.rs new file mode 100644 index 00000000..64c1f488 --- /dev/null +++ b/apps/solvecraft-cli/src/fuzz.rs @@ -0,0 +1,212 @@ +//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md). +//! +//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash +//! is recorded like any other failure. + +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use solvecraft_engine::fuzz; +use solvecraft_engine::fuzz::boolean_case; + +fn flag(args: &[String], name: &str, default: T) -> T { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default) +} + +/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr). +pub fn one(args: &[String]) -> Result<(), String> { + let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one [--steps N] [--minimise]")?; + let steps = flag(args, "--steps", 8usize); + let minimise = args.iter().any(|a| a == "--minimise"); + eprintln!("design {seed}"); + let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default())); + let mut design = json!({ + "outcome": run.outcome, + "steps": run.steps.iter().map(|s| s.kind).collect::>(), + "unsupported": run.unsupported, + "rejected": run.rejected, + }); + if run.outcome.is_bug() { + design["script"] = run.script(); + if minimise { + eprintln!("minimise"); + let m = fuzz::minimise(&run); + design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::>(), "script": m.script()}); + } + } + let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed); + eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default()); + let b = boolean_case(seed); + println!("{}", json!({"seed": seed, "design": design, "boolean": b})); + Ok(()) +} + +struct Job { + seed: u64, + child: std::process::Child, + started: Instant, + err: std::sync::mpsc::Receiver, + last: String, +} + +/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each; +/// write failing scripts to `--out` and a summary to `--report`. +pub fn run(args: &[String]) -> Result<(), String> { + let from = flag(args, "--from", 0u64); + let count = flag(args, "--count", 100u64); + let jobs = flag(args, "--jobs", 4usize).clamp(1, 64); + let timeout = Duration::from_secs(flag(args, "--timeout", 120u64)); + let steps = flag(args, "--steps", 8usize); + let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned(); + let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned(); + let minimise = args.iter().any(|a| a == "--minimise"); + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + if let Some(d) = &out_dir { + std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?; + } + let mut next = from; + let mut running: Vec = Vec::new(); + let mut results: Vec = Vec::new(); + let t0 = Instant::now(); + while next < from + count || !running.is_empty() { + while running.len() < jobs && next < from + count { + let mut cmd = Command::new(&exe); + cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]); + if minimise { + cmd.arg("--minimise"); + } + let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?; + let (tx, rx) = std::sync::mpsc::channel(); + if let Some(err) = child.stderr.take() { + std::thread::spawn(move || { + for line in BufReader::new(err).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + } + running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() }); + next += 1; + } + std::thread::sleep(Duration::from_millis(50)); + let mut i = 0; + while i < running.len() { + let Some(j) = running.get_mut(i) else { break }; + while let Ok(l) = j.err.try_recv() { + if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") { + j.last = l; + } + } + let done = match j.child.try_wait() { + Ok(Some(status)) => { + let mut s = String::new(); + if let Some(mut o) = j.child.stdout.take() { + let _ = o.read_to_string(&mut s); + } + Some(match serde_json::from_str::(s.trim()) { + Ok(v) => v, + Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}), + }) + } + Ok(None) if j.started.elapsed() > timeout => { + let _ = j.child.kill(); + let _ = j.child.wait(); + Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last})) + } + Ok(None) => None, + Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})), + }; + match done { + Some(v) => { + record(&v, out_dir.as_deref()); + results.push(v); + running.swap_remove(i); + } + None => i += 1, + } + } + } + let summary = summarise(&results, t0.elapsed()); + println!("{summary}"); + if let Some(r) = report { + std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?; + } + Ok(()) +} + +/// Print a failure as it comes in and keep its script. +fn record(v: &Value, out_dir: Option<&str>) { + let seed = v["seed"].as_u64().unwrap_or(0); + let d = &v["design"]; + let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume")); + if bug || v.get("timeout").is_some() || v.get("crash").is_some() { + eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") }); + } + let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok"); + if bk != "ok" { + eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]); + } + if let Some(dir) = out_dir { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) { + let _ = writeln!(f, "{v}"); + } + if bug { + let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] }; + let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default()); + } + if bk != "ok" { + let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default()); + } + } +} + +fn summarise(results: &[Value], took: Duration) -> String { + let n = results.len().max(1); + let mut design: BTreeMap = BTreeMap::new(); + let mut boolean: BTreeMap = BTreeMap::new(); + let mut at_step: BTreeMap = BTreeMap::new(); + let mut unsupported: BTreeMap = BTreeMap::new(); + let mut steps = 0; + for v in results { + let k = if v.get("timeout").is_some() { + "timeout".to_string() + } else if v.get("crash").is_some() { + "crash".to_string() + } else { + v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string() + }; + if k != "ok" { + let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?"); + *at_step.entry(format!("{k} at {last}")).or_default() += 1; + } + *design.entry(k).or_default() += 1; + *boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1; + steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0); + for u in v["design"]["unsupported"].as_array().into_iter().flatten() { + *unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1; + } + } + let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64); + let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64()); + for (k, c) in &design { + s += &format!("- {k}: {}\n", pct(*c)); + } + s += "\nFailures by step:\n"; + for (k, c) in &at_step { + s += &format!("- {k}: {c}\n"); + } + s += "\nSteps refused as not supported (undone, the design went on):\n"; + for (k, c) in &unsupported { + s += &format!("- {k}: {c}\n"); + } + s += "\nBoolean identity (union, intersection, cut of two primitives):\n"; + for (k, c) in &boolean { + s += &format!("- {k}: {}\n", pct(*c)); + } + s +} diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index db62942a..74ba8a09 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -16,6 +16,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod fuzz; mod oracle; mod recipe; mod seams; @@ -35,6 +36,7 @@ const USAGE: &str = "usage: solvecraft-cli commands solvecraft-cli recipe solvecraft-cli oracle ... [--json] + solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE] solvecraft-cli step-corpus ... [--json] solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT] solvecraft-cli licences @@ -58,6 +60,8 @@ fn main() -> ExitCode { }), Some("oracle") => oracle::run(&args[1..]), Some("step-corpus") => oracle::step_corpus(&args[1..]), + Some("fuzz") => fuzz::run(&args[1..]), + Some("fuzz-one") => fuzz::one(&args[1..]), Some("mcp") => cmd_mcp(&args[1..]), Some("icon") => cmd_icon(&args[1..]), Some("licences" | "licenses") => { diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 39ad5487..00baada5 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -296,7 +296,8 @@ fn isoparametric_curves_of_a_cylinder() { let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()]; let zs: Vec = w.pts.iter().map(|p| p.z).collect(); assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}"); - assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3)); + // Within the mesh's sag: the cut runs across facets unless a seam happens to lie there. + assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-2 && p.y.abs() < 1e-2), "{:?}", w.pts); } #[test] diff --git a/crates/engine/src/fuzz.rs b/crates/engine/src/fuzz.rs new file mode 100644 index 00000000..a1b20db7 --- /dev/null +++ b/crates/engine/src/fuzz.rs @@ -0,0 +1,372 @@ +//! Random modelling sessions for robustness testing (`solvecraft-cli fuzz`, docs/robustness.md). +//! +//! A seeded generator builds realistic feature sequences: a base (box, extruded sketch profile +//! with an arc, revolved section), then joins and cuts of boxes, cylinders and spheres placed on +//! a coarse grid against the body (so coincident faces and tangencies come up often), fillets +//! and chamfers on random edges, shells, holes and patterns. Every step is checked: the command +//! succeeds, every body is a sound solid, and the volume moves the right way. + +use serde::Serialize; +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; +use solvecraft_kernel::fuzz::Rng; +pub use solvecraft_kernel::fuzz::{BoolCase, boolean_case, boolean_shapes}; + +use crate::{EngineError, Session}; + +/// One step: the commands it runs (a sketch and its feature go together). +#[derive(Clone, Debug, Serialize)] +pub struct Step { + pub kind: &'static str, + pub commands: Vec<(String, Value)>, + /// How the total volume must move. + #[serde(skip)] + expect: Expect, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Expect { + Any, + Grow, + Shrink, +} + +/// What happened at a step. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Outcome { + /// "ok", "unsupported" (a feature the kernel doesn't do yet), "rejected" (the generator's + /// parameters were refused), "failed", "panic" (one that escaped every guard), "invalid" (a + /// body that isn't a sound solid), "volume". + pub kind: String, + pub detail: String, +} + +impl Outcome { + fn ok() -> Outcome { + Outcome { kind: "ok".into(), detail: String::new() } + } + fn new(kind: &str, detail: impl Into) -> Outcome { + Outcome { kind: kind.into(), detail: detail.into() } + } + /// A failure the kernel should not have (not a known limitation or a refused input). + pub fn is_bug(&self) -> bool { + matches!(self.kind.as_str(), "failed" | "panic" | "invalid" | "volume") + } +} + +/// A design's run: its steps, and what happened at the last one run. +#[derive(Clone, Debug, Serialize)] +pub struct Run { + pub seed: u64, + pub steps: Vec, + pub outcome: Outcome, + /// Steps refused as not supported (kind, message): undone, not part of the design. + pub unsupported: Vec<(String, String)>, + /// Steps whose generated parameters a command refused. + pub rejected: usize, +} + +impl Run { + /// The commands as a script for `solvecraft-cli run`. + pub fn script(&self) -> Value { + Value::Array(self.steps.iter().flat_map(|s| s.commands.iter().map(|(c, p)| json!({"command": c, "params": p}))).collect()) + } +} + +fn total_volume(s: &Session) -> f64 { + s.model.state().bodies.iter().filter_map(|b| solvecraft_kernel::measure(&b.body).ok()).map(|m| m.volume).sum() +} + +fn classify(e: &EngineError) -> Outcome { + let msg = e.to_string(); + 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" (coincident faces on + // curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a + // fillet between curved faces) 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), + EngineError::Other(_) if msg.contains("not supported") || msg.contains("only ") => Outcome::new("unsupported", msg), + EngineError::Other(_) => Outcome::new("failed", msg), + } +} + +/// Run one step and check it. +fn apply(s: &mut Session, step: &Step) -> Outcome { + let before = total_volume(s); + for (c, p) in &step.commands { + if let Err(e) = s.execute(c, p) { + return classify(&e); + } + } + let st = s.model.state(); + if let Some(f) = s.model.results.iter().find(|r| r.error.is_some()) { + return Outcome::new("failed", format!("{}: {}", f.name, f.error.clone().unwrap_or_default())); + } + for b in &st.bodies { + let bad = b.body.validity(); + if !bad.is_empty() { + return Outcome::new("invalid", format!("{}: {}", b.name, bad.join("; "))); + } + } + let after = total_volume(s); + let slack = 1e-4 * before.max(after) + 1e-6; + let wrong = match step.expect { + Expect::Any => false, + Expect::Grow => after < before - slack, + Expect::Shrink => after > before + slack, + }; + if wrong || !after.is_finite() { + return Outcome::new("volume", format!("{} moved the volume the wrong way: {before:.4} → {after:.4}", step.kind)); + } + Outcome::ok() +} + +fn step(kind: &'static str, expect: Expect, commands: Vec<(&str, Value)>) -> Step { + Step { kind, expect, commands: commands.into_iter().map(|(c, p)| (c.to_string(), p)).collect() } +} + +/// The bodies' bounds (min, max). +fn bounds(s: &Session) -> Option<(Vec3, Vec3)> { + let st = s.model.state(); + let mut it = st.bodies.iter().map(|b| b.mesh().bounds()); + let first = it.next()?; + Some(it.fold((first.min, first.max), |(lo, hi), b| { + (Vec3::new(lo.x.min(b.min.x), lo.y.min(b.min.y), lo.z.min(b.min.z)), Vec3::new(hi.x.max(b.max.x), hi.y.max(b.max.y), hi.z.max(b.max.z))) + })) +} + +const G: f64 = 2.5; + +fn snap(x: f64) -> f64 { + (x / G).round() * G +} + +/// A coordinate in the bounds: often exactly on a side (coincident faces), else on the grid. +fn coord(r: &mut Rng, lo: f64, hi: f64) -> f64 { + match r.below(4) { + 0 => lo, + 1 => hi, + _ => snap(lo + (hi - lo) * r.unit()), + } +} + +fn base(r: &mut Rng) -> Step { + match r.below(3) { + 0 => { + let (l, w, h) = (r.grid(10.0, 60.0, G), r.grid(10.0, 60.0, G), r.grid(5.0, 40.0, G)); + step("box", Expect::Any, vec![("solid.box", json!({"length": l, "width": w, "height": h}))]) + } + 1 => { + // A profile of lines closed by an arc, extruded. + let (w, h) = (r.grid(15.0, 50.0, G), r.grid(10.0, 40.0, G)); + let bulge = r.grid(2.5, (w / 2.0).min(15.0), G); + let notch = r.chance(0.5); + let mut pts = vec![json!([0, 0]), json!([w, 0]), json!([w, h])]; + if notch { + let nx = snap(w * 0.6); + pts.push(json!([nx, h])); + pts.push(json!([nx, snap(h * 0.5)])); + pts.push(json!([snap(w * 0.3), snap(h * 0.5)])); + pts.push(json!([snap(w * 0.3), h])); + } + pts.push(json!([0, h])); + let d = r.grid(5.0, 40.0, G); + step( + "extruded profile", + Expect::Any, + vec![ + ("sketch.create", json!({"plane": "XY"})), + ("sketch.line", json!({"points": pts})), + ("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})), + ("sketch.finish", json!({})), + ("Extrude", json!({"distance": d})), + ], + ) + } + _ => { + let (r0, r1, h) = (r.grid(0.0, 10.0, G), r.grid(12.5, 30.0, G), r.grid(5.0, 30.0, G)); + let angle = r.pick(&["360 deg", "180 deg", "90 deg", "270 deg"]).unwrap_or("360 deg"); + step( + "revolved section", + Expect::Any, + vec![ + ("sketch.create", json!({"plane": "XZ"})), + ("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})), + ("sketch.finish", json!({})), + ("Revolve", json!({"axis": "Z", "angle": angle})), + ], + ) + } + } +} + +fn random_point(r: &mut Rng, lo: Vec3, hi: Vec3) -> [f64; 3] { + [coord(r, lo.x, hi.x), coord(r, lo.y, hi.y), coord(r, lo.z, hi.z)] +} + +/// The next step for the design as it stands (None: nothing fits, try another). +fn next_step(r: &mut Rng, s: &Session, last_feature: &Option) -> Option { + let (lo, hi) = bounds(s)?; + let st = s.model.state(); + let body = st.bodies.get(r.below(st.bodies.len()))?; + let tol = (body.body.size() * 1e-3).max(1e-3); + Some(match r.below(12) { + 0 | 1 => { + let a = random_point(r, lo, hi); + let size = [r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G)]; + let join = r.chance(0.5); + let (kind, expect, op) = if join { ("join box", Expect::Grow, "join") } else { ("cut box", Expect::Shrink, "cut") }; + step(kind, expect, vec![("solid.box", json!({"corner": a, "length": size[0], "width": size[1], "height": size[2], "operation": op}))]) + } + 2 | 3 => { + let base = random_point(r, lo, hi); + let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]])?; + let join = r.chance(0.4); + let (kind, expect, op) = if join { ("join cylinder", Expect::Grow, "join") } else { ("cut cylinder", Expect::Shrink, "cut") }; + let p = json!({"base": base, "axis": axis, "radius": r.grid(1.25, 10.0, 1.25), "height": r.grid(2.5, 30.0, G), "operation": op}); + step(kind, expect, vec![("solid.cylinder", p)]) + } + 4 => { + let c = random_point(r, lo, hi); + step("cut sphere", Expect::Shrink, vec![("solid.sphere", json!({"center": c, "radius": r.grid(2.5, 10.0, G), "operation": "cut"}))]) + } + 5 | 6 => { + let edges = body.body.edges(tol).ok()?; + let n = 1 + r.below(3); + let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect(); + let rad = r.pick(&[0.5, 1.0, 2.0, 2.5, 3.0])?; + step("fillet", Expect::Any, vec![("solid.fillet", json!({"edges": picks, "radius": rad}))]) + } + 7 => { + let edges = body.body.edges(tol).ok()?; + let n = 1 + r.below(3); + let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect(); + step("chamfer", Expect::Shrink, vec![("solid.chamfer", json!({"edges": picks, "distance": r.pick(&[0.5, 1.0, 2.0])?}))]) + } + 8 => { + let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect(); + let f = faces.get(r.below(faces.len()))?; + let p = [f.centroid.x, f.centroid.y, f.centroid.z]; + step("shell", Expect::Shrink, vec![("solid.shell", json!({"faces": [p], "thickness": r.pick(&[1.0, 1.5, 2.0])?}))]) + } + 9 => { + let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect(); + let f = faces.get(r.below(faces.len()))?; + let p = [f.centroid.x, f.centroid.y, f.centroid.z]; + let mut h = json!({"position": p, "diameter": r.grid(2.0, 8.0, 1.0)}); + if r.chance(0.5) { + h["depth"] = json!(r.grid(2.5, 20.0, G)); + if r.chance(0.5) { + h["type"] = json!("drilled"); + } + } + step("hole", Expect::Shrink, vec![("solid.hole", h)]) + } + 10 => { + let name = last_feature.clone()?; + let dir = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])?; + let p = json!({"features": [name], "dir1": dir, "count1": 2 + r.below(2), "spacing1": r.grid(5.0, 20.0, G)}); + step("rectangular pattern", Expect::Any, vec![("solid.pattern.rectangular", p)]) + } + _ => { + let name = last_feature.clone()?; + let p = json!({"features": [name], "axis": "Z", "count": 2 + r.below(5)}); + step("circular pattern", Expect::Any, vec![("solid.pattern.circular", p)]) + } + }) +} + +/// Generate and run a design of up to `max_steps` steps, stopping at the first problem. Steps +/// refused as unsupported (or with parameters the command rejects) are undone and counted. +pub fn run(seed: u64, max_steps: usize) -> Run { + run_with(seed, max_steps, &mut |_| {}) +} + +/// [`run`], telling `on_step` about each step before it runs (to see where a hang happened). +pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run { + let mut r = Rng::new(seed); + let mut s = Session::default(); + let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 }; + let mut last_feature: Option = None; + let n = 2 + r.below(max_steps.max(2) - 1); + let mut tries = 0; + while run.steps.len() < n && tries < n * 4 { + tries += 1; + let st = if run.steps.is_empty() { Some(base(&mut r)) } else { next_step(&mut r, &s, &last_feature) }; + let Some(st) = st else { continue }; + let before = s.doc.features.len(); + on_step(&st); + let out = apply(&mut s, &st); + if out.kind == "rejected" || out.kind == "unsupported" { + // Not this one: undo what it added and try another. + while s.doc.features.len() > before { + s.doc_mut().features.pop(); + } + s.refresh(); + if out.kind == "rejected" { + run.rejected += 1; + } else { + run.unsupported.push((st.kind.to_string(), out.detail)); + } + continue; + } + let kind = st.kind; + run.steps.push(st); + if out.kind != "ok" { + run.outcome = out; + return run; + } + if matches!(kind, "join box" | "cut box" | "join cylinder" | "cut cylinder" | "cut sphere" | "hole") { + last_feature = s.doc.features.last().map(|f| f.name.clone()); + } + } + run +} + +/// 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 mut s = Session::default(); + for (i, st) in steps.iter().enumerate() { + let out = apply(&mut s, st); + if out.kind != "ok" { + return (i, out); + } + } + (steps.len().saturating_sub(1), Outcome::ok()) +} + +/// The start of a message, to tell failures apart (numbers vary between versions of a case). +fn gist(d: &str) -> String { + d.chars().take_while(|c| !c.is_ascii_digit()).take(60).collect() +} + +/// A failing design cut down: steps are dropped while the last one still fails the same way. +pub fn minimise(run: &Run) -> Run { + let mut steps = run.steps.clone(); + let (kind, g) = (run.outcome.kind.clone(), gist(&run.outcome.detail)); + let same = |st: &[Step]| { + let (i, o) = replay(st); + i + 1 == st.len() && o.kind == kind && gist(&o.detail) == g + }; + let mut changed = true; + while changed && steps.len() > 1 { + changed = false; + let mut i = 0; + while i + 1 < steps.len() { + let mut t = steps.clone(); + t.remove(i); + if same(&t) { + steps = t; + changed = true; + } else { + i += 1; + } + } + } + let (_, outcome) = replay(&steps); + Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index efe4e15c..860c43f0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod cmd; pub mod frames; pub mod legacy_ids; pub mod licences; +pub mod fuzz; pub mod params; pub mod recovery; pub mod sample; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a146668e..c330c035 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -21,6 +21,7 @@ fn rel(a: f64, b: f64) -> f64 { } mod component_frames; +mod robustness; /// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. #[test] diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs new file mode 100644 index 00000000..cd4eb7a6 --- /dev/null +++ b/crates/engine/src/tests/robustness.rs @@ -0,0 +1,91 @@ +//! Cases found by the robustness runs (`solvecraft-cli fuzz`, docs/robustness.md), cut down to +//! the steps that matter, and a fixed set of seeds that must keep running clean. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::fuzz; + +/// Run a script; every command must succeed and every body be a sound solid. +fn script(steps: Value) -> Session { + let mut s = Session::default(); + for st in steps.as_array().into_iter().flatten() { + let (c, p) = (st["command"].as_str().unwrap_or(""), &st["params"]); + if let Err(e) = s.execute(c, p) { + panic!("{c} {p}: {e}"); + } + } + for b in &s.model.state().bodies { + let bad = b.body.validity(); + assert!(bad.is_empty(), "{}: {bad:?}", b.name); + } + s +} + +/// Shell of a notched plate closed by a shallow arc, opening a notch wall: the arc's cylinder +/// meets the plates at an angle, so its corners need more than one linear step to move. +#[test] +fn shell_with_a_shallow_arc_side() { + script(json!([ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [37.5, 0], [37.5, 37.5], [22.5, 37.5], [22.5, 20.0], [12.5, 20.0], [12.5, 37.5], [0, 37.5]]}}, + {"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 37.5], "through": [-5.0, 18.75]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 7.5}}, + {"command": "solid.shell", "params": {"faces": [[12.5, 28.75, 3.75]], "thickness": 2.0}} + ])); +} + +/// Shell of a plate with a hole whose wall meets the plate's arc side: the edge where the two +/// cylinders meet is neither a line nor an arc, and is rebuilt through its moved points. +#[test] +fn shell_with_a_hole_through_a_curved_side() { + script(json!([ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [42.5, 0], [42.5, 20.0], [0, 20.0]]}}, + {"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 20.0], "through": [-10.0, 10.0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 27.5}}, + {"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [-10.000000000000002, 12.5, 12.5], "height": 17.5, "operation": "cut", "radius": 5.0}}, + {"command": "solid.shell", "params": {"faces": [[42.5, 10.0, 13.75]], "thickness": 2.0}} + ])); +} + +/// Shell of a body in two pieces (a cylinder joined clear of a ring): each piece is shelled. +#[test] +fn shell_of_a_body_in_two_pieces() { + script(json!([ + {"command": "sketch.create", "params": {"plane": "XZ"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 0], "p1": [22.5, 17.5]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.revolve", "params": {"angle": "360 deg", "axis": "Z"}}, + {"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0}}, + {"command": "solid.shell", "params": {"faces": [[-22.5, 22.5, 0.0]], "thickness": 2.0}} + ])); +} + +/// A shell thicker than a step under a notch would turn the cavity inside out: a clear "not +/// supported yet" error instead of a failed boolean. +#[test] +fn shell_thicker_than_a_step_says_so() { + let mut s = Session::default(); + for (c, p) in [ + ("solid.box", json!({"height": 7.5, "length": 30.0, "width": 35.0})), + ("solid.box", json!({"corner": [0.0, 30.0, 2.5], "height": 22.5, "length": 22.5, "operation": "cut", "width": 20.0})), + ] { + s.execute(c, &p).unwrap(); + } + let e = s.execute("solid.shell", &json!({"faces": [[18.75, 35.0, 2.5]], "thickness": 1.5})).unwrap_err(); + assert!(e.to_string().contains("not supported yet"), "{e}"); +} + +/// Seeds that run clean: no failure, no invalid body, no volume going the wrong way. +#[test] +fn fixed_seeds_run_clean() { + for seed in FIXED_SEEDS { + let r = fuzz::run(*seed, 8); + assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script()); + } +} + +const FIXED_SEEDS: &[u64] = &[3, 5, 11, 16, 21, 29, 33, 35]; diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 40dfbc00..90e11583 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -616,6 +616,31 @@ impl Body { let mut seen = std::collections::HashSet::new(); self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count() } + /// What is wrong with the body as a solid: shells that aren't closed and consistently + /// oriented, faces that don't mesh, a volume that isn't positive. Empty when it is sound. + pub fn validity(&self) -> Vec { + if let Some(m) = &self.mesh { + return if crate::meshbody::is_closed(m) { Vec::new() } else { vec!["the mesh is not closed".into()] }; + } + let mut out = Vec::new(); + for (i, sh) in self.solid.boundaries().iter().enumerate() { + let c = sh.shell_condition(); + if c != truck_topology::shell::ShellCondition::Closed { + out.push(format!("shell {i} is {c:?}, not closed")); + } + } + let missing = self.unmeshed_faces(); + if missing > 0 { + out.push(format!("{missing} face(s) don't mesh")); + } + match crate::measure(self) { + Ok(m) if m.volume.is_finite() && m.volume > 0.0 => {} + Ok(m) => out.push(format!("volume {}", m.volume)), + Err(e) => out.push(format!("no measure: {e}")), + } + out + } + pub fn shell_count(&self) -> usize { match &self.mesh { Some(_) => 1, diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 5a716b36..955611f6 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -205,6 +205,13 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: let radial = (c - o) - axis * (c - o).dot(axis); let toward = axis.cross(radial).dot(plane.normal()); let angle = if toward < 0.0 { -angle } else { angle }; + // A profile with an edge on the axis: no zero-area face where that edge turns on itself. + if r.holes.is_empty() + && let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)? + { + out.push(b); + continue; + } let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?; out.push(Body::new(solid)?); } @@ -226,6 +233,9 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result { v.pop().ok_or_else(|| KernelError::Failed("box".into())) } +/// Where primitives start their circles (radians from the frame's x axis). +pub(crate) const SEAM_ANGLE: f64 = 0.6131; + /// Cylinder with base centre `base`, axis direction `axis`, radius and height. pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result { let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?; @@ -233,7 +243,9 @@ pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result Option<(Vec2, f64)> { (same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r)) } +/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces, +/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the +/// loop isn't like that. +fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result> { + use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed}; + let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; + let n = lp.segs.len(); + let axis_segs: Vec = + (0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect(); + let [k] = axis_segs[..] else { return Ok(None) }; + // The open chain after the axis segment, back around to it: only its ends on the axis. + let chain: Vec = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect(); + if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) { + return Ok(None); + } + // Built with the axis through the world origin, then moved into place. + let shift = plane.to_world(axis_origin); + let local = Plane { origin: plane.origin - shift, ..*plane }; + let plane = &local; + let axis = plane.dir_to_world(d); + let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle)); + let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5)); + let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate())); + guard("revolve", || { + let fail = |m: &str| KernelError::Failed(format!("revolve: {m}")); + let pts: Vec = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect(); + let last = pts.len() - 1; + let pv: Vec = pts.iter().map(|p| builder::vertex(p3(*p))).collect(); + // The ends stay where they are (on the axis). + let qv: Vec = pts + .iter() + .enumerate() + .map(|(i, p)| { + if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) } + }) + .collect(); + let mut e: Vec = Vec::new(); + let mut er: Vec = Vec::new(); + for (i, sg) in chain.iter().enumerate() { + let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) }; + let edge = match *sg { + Seg2::Line { .. } => builder::line(a, b), + Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))), + Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => { + crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b)) + } + }; + let curve = edge.oriented_curve().transformed(rot); + er.push(mt::Edge::new(qa, qb, curve)); + e.push(edge); + } + // Arcs swept by the vertices off the axis. + let mut arcs: Vec> = vec![None; pts.len()]; + for i in 1..last { + let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue }; + if let Some(slot) = arcs.get_mut(i) { + *slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p)))); + } + } + // Side faces: each edge's swept surface, oriented to agree with its boundary. + let mut faces: Vec = Vec::new(); + for (i, sg) in chain.iter().enumerate() { + let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) }; + let mut ws: Vec = vec![ei.clone()]; + if let Some(Some(c)) = arcs.get(i + 1) { + ws.push(c.clone()); + } + ws.push(eri.inverse()); + if let Some(Some(c)) = arcs.get(i) { + ws.push(c.inverse()); + } + let wire: mt::Wire = ws.into(); + let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9)); + let f = if radial { + builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))? + } else { + let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle)); + let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?; + // The loop's turning (Newell) against the surface normal halfway round. + let mut nw = Vec3::ZERO; + let poly: Vec = wire + .edge_iter() + .flat_map(|ed| { + let c = ed.oriented_curve(); + let (t0, t1) = c.range_tuple(); + (0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::>() + }) + .collect(); + for (j, p) in poly.iter().enumerate() { + if let Some(q) = poly.get((j + 1) % poly.len()) { + nw += p.cross(*q); + } + } + let probe = turn(&half, plane.to_world(sg.mid())); + let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v)); + if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) { + surface = surface.inverse(); + } + mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))? + }; + faces.push(f); + } + // Caps: the profile at the start and at the end, sharing the axis edge. + let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) }; + let axis_edge = builder::line(an, a0); + let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::>().into(); + let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::>().into(); + let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?; + let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?; + let mut last_err = String::new(); + for flip in 0..4 { + let mut all = faces.clone(); + all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() }); + all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() }); + match Solid::try_new(vec![all.into()]) { + Ok(solid) => { + let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; + return Body::new(solid).map(Some); + } + Err(er) => last_err = er.to_string(), + } + } + Err(fail(&last_err)) + }) +} + fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result> { let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; let n = lp.segs.len(); diff --git a/crates/kernel/src/fuzz.rs b/crates/kernel/src/fuzz.rs new file mode 100644 index 00000000..cd03b500 --- /dev/null +++ b/crates/kernel/src/fuzz.rs @@ -0,0 +1,160 @@ +//! Robustness checks on random inputs (`solvecraft-cli fuzz`, docs/robustness.md): a small +//! seeded random generator, and the boolean volume identity on random pairs of primitives +//! placed on a coarse grid, so coincident faces and tangencies come up often. + +use serde::Serialize; +use solvecraft_geom::Vec3; + +use crate::{Body, BoolOp, boolean, box_solid, cylinder, measure, sphere, torus}; + +/// xorshift64*: small, fast, and the same everywhere (wasm included). +#[derive(Clone, Debug)] +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + // Spread nearby seeds apart (splitmix64), never zero. + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + Rng((z ^ (z >> 31)) | 1) + } + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + /// 0…n−1 (0 when n is 0). + pub fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize } + } + /// In [0, 1). + pub fn unit(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + pub fn chance(&mut self, p: f64) -> bool { + self.unit() < p + } + pub fn pick(&mut self, xs: &[T]) -> Option { + xs.get(self.below(xs.len())).copied() + } + /// A multiple of `step` in [lo, hi]. + pub fn grid(&mut self, lo: f64, hi: f64, step: f64) -> f64 { + let n = ((hi - lo) / step).floor().max(0.0) as usize; + lo + step * self.below(n + 1) as f64 + } +} + +/// A primitive used by the boolean checks, as plain data (so a failure can be written down). +#[derive(Clone, Debug, Serialize)] +pub enum Shape { + Box { a: [f64; 3], b: [f64; 3] }, + Cylinder { base: [f64; 3], axis: [f64; 3], radius: f64, height: f64 }, + Sphere { center: [f64; 3], radius: f64 }, + Torus { center: [f64; 3], major: f64, minor: f64 }, +} + +impl Shape { + pub fn body(&self) -> crate::Result { + let v = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]); + match self { + Shape::Box { a, b } => box_solid(v(a), v(b)), + Shape::Cylinder { base, axis, radius, height } => cylinder(v(base), v(axis), *radius, *height), + Shape::Sphere { center, radius } => sphere(v(center), *radius), + Shape::Torus { center, major, minor } => torus(v(center), *major, *minor), + } + } + + fn random(r: &mut Rng) -> Shape { + let g = 2.5; + let mut p = || [r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g)]; + let at = p(); + match r.below(10) { + 0..=4 => { + let size = [r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g)]; + Shape::Box { a: at, b: [at[0] + size[0], at[1] + size[1], at[2] + size[2]] } + } + 5..=7 => { + let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap_or([0.0, 0.0, 1.0]); + Shape::Cylinder { base: at, axis, radius: r.grid(2.5, 10.0, g), height: r.grid(5.0, 30.0, g) } + } + 8 => Shape::Sphere { center: at, radius: r.grid(2.5, 12.5, g) }, + _ => { + let minor = r.grid(2.5, 5.0, g); + Shape::Torus { center: at, major: minor + r.grid(2.5, 10.0, g), minor } + } + } + } +} + +/// One boolean check: the shapes, what went wrong (none when the identity holds). +#[derive(Clone, Debug, Serialize)] +pub struct BoolCase { + pub seed: u64, + pub a: Shape, + pub b: Shape, + /// "ok", or the kind of failure: "error", "invalid", "identity". + pub outcome: String, + pub detail: String, +} + +fn vol(b: &Option) -> crate::Result { + match b { + Some(b) => Ok(measure(b)?.volume), + None => Ok(0.0), + } +} + +/// The two primitives [`boolean_case`] uses for a seed. +pub fn boolean_shapes(seed: u64) -> (Shape, Shape) { + let mut r = Rng::new(seed); + (Shape::random(&mut r), Shape::random(&mut r)) +} + +/// Union, intersection and cut of two random primitives: each result is a sound solid, and +/// V(A∪B) + V(A∩B) = V(A) + V(B) and V(A−B) = V(A) − V(A∩B), within 0.2 %. +pub fn boolean_case(seed: u64) -> BoolCase { + let (a, b) = boolean_shapes(seed); + let mut case = BoolCase { seed, a: a.clone(), b: b.clone(), outcome: "ok".into(), detail: String::new() }; + let fail = |c: &mut BoolCase, kind: &str, d: String| { + c.outcome = kind.into(); + c.detail = d; + }; + let (Ok(ba), Ok(bb)) = (a.body(), b.body()) else { + fail(&mut case, "error", "a primitive could not be built".into()); + return case; + }; + let mut res = Vec::new(); + for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] { + match boolean(&ba, &bb, op) { + Ok(x) => { + if let Some(body) = &x { + let bad = body.validity(); + if !bad.is_empty() { + fail(&mut case, "invalid", format!("{op:?}: {}", bad.join("; "))); + return case; + } + } + res.push(x); + } + Err(e) => { + fail(&mut case, "error", format!("{op:?}: {e}")); + return case; + } + } + } + let v = |b: &Option| vol(b).unwrap_or(f64::NAN); + let (va, vb) = (v(&Some(ba)), v(&Some(bb))); + let [u, i, c] = &res[..] else { return case }; + let (vu, vi, vc) = (v(u), v(i), v(c)); + let tol = 2e-3 * (va + vb); + let e1 = (vu + vi - va - vb).abs(); + let e2 = (vc - (va - vi)).abs(); + if !(e1 <= tol && e2 <= tol) { + fail(&mut case, "identity", format!("A {va:.3}, B {vb:.3}, A∪B {vu:.3}, A∩B {vi:.3}, A−B {vc:.3}: off by {e1:.3} and {e2:.3}")); + } + case +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 4a837f05..388b5668 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -21,6 +21,7 @@ mod coplanar; mod curveblend; mod delete_face; mod freeform; +pub mod fuzz; mod heal; mod helix; mod iges_in; diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index babf4a83..7d2a967d 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -147,7 +147,7 @@ fn unify_winding(m: &mut TriMesh) { } /// Every triangle side is shared by exactly two triangles in opposite directions. -fn is_closed(m: &TriMesh) -> bool { +pub(crate) fn is_closed(m: &TriMesh) -> bool { let mut count: HashMap<(u32, u32), i32> = HashMap::new(); for t in &m.triangles { for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index 8d2968d9..227da9f5 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -64,6 +64,15 @@ impl Surf { } } } + /// The gradient of [`Surf::dist`] (the normal before orientation). + fn grad(&self, p: Vec3) -> Option { + match *self { + Surf::Plane { n, .. } => Some(n), + Surf::Cylinder { convex, .. } | Surf::Sphere { convex, .. } | Surf::Cone { convex, .. } => { + self.normal(p).map(|n| if convex { n } else { -n }) + } + } + } fn moved(&self, s: f64) -> Option { match *self { Surf::Plane { n, d } => Some(Surf::Plane { n, d: d + s }), @@ -222,6 +231,10 @@ pub(crate) fn surf_of(f: &mt::Face, tol: f64) -> Option { return Some(t); } let (p0, n0) = *s.first()?; + // Flat though not built as a plane (a radial line swept round an axis). + if s.iter().all(|(p, n)| n.dot(n0) > 1.0 - 1e-9 && (*p - p0).dot(n0).abs() < tol) { + return Some(Surf::Plane { n: n0, d: n0.dot(p0) }); + } // The normal most square to the first (a half cylinder's two ends are opposite). let far = s.iter().map(|x| x.1).min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs()))?; let Some(a) = n0.cross(far).normalized() else { return sphere_or_cone(&s, tol) }; @@ -354,9 +367,26 @@ fn sphere_or_cone(s: &[(Vec3, Vec3)], tol: f64) -> Option { /// n_i · δ = s_i for each face's normal n_i at p, then checked against the moved surfaces. fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> Option { let ns: Vec = old.iter().map(|sf| sf.normal(p)).collect::>()?; + let mut q = p + span_step(&ns, shifts)?; + // The step is exact for planes; a curved face meeting another at an angle needs a few + // Newton steps onto the moved surfaces. + for _ in 0..30 { + let d: Vec = new.iter().map(|sf| sf.dist(q)).collect(); + if d.iter().all(|x| x.abs() < tol) { + return Some(q); + } + let gs: Vec = new.iter().map(|sf| sf.grad(q)).collect::>()?; + let rhs: Vec = d.iter().map(|x| -x).collect(); + q += span_step(&gs, &rhs)?; + } + new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q) +} + +/// The smallest move `v` (in the span of `ns`) with `n_i · v = s_i` (least squares). +fn span_step(ns: &[Vec3], s: &[f64]) -> Option { // Orthonormal basis of the normals' span (tangent faces share a normal). let mut basis: Vec = Vec::new(); - for n in &ns { + for n in ns { let mut v = *n; for e in &basis { v = v - *e * v.dot(*e); @@ -369,13 +399,13 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> let r = basis.len(); let mut m = [[0.0f64; 3]; 3]; let mut rhs = [0.0f64; 3]; - for (n, s) in ns.iter().zip(shifts) { + for (n, si) in ns.iter().zip(s) { let row: Vec = basis.iter().map(|e| n.dot(*e)).collect(); for i in 0..r { for j in 0..r { m[i][j] += row[i] * row[j]; } - rhs[i] += row[i] * s; + rhs[i] += row[i] * si; } } // Pad unused dimensions with the identity. @@ -383,11 +413,11 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> row[i] = 1.0; } let c = crate::polyhedron::solve3_pub(m, rhs)?; - let mut q = p; + let mut v = Vec3::ZERO; for (k, e) in basis.iter().enumerate() { - q += *e * c[k]; + v += *e * c[k]; } - new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q) + Some(v) } /// A line or circular arc through an edge (by samples): `None` for other curves. @@ -475,6 +505,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul }; newpos.insert(v.id(), q); } + // An edge turned round: faces crossed over (a wall thinner than the move), which would + // leave the body inside out. + for e in b.solid.edge_iter() { + let (f0, f1) = (e.absolute_front(), e.absolute_back()); + let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue }; + let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0); + if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 { + return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)")); + } + } guard("offset", || { let verts: HashMap = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect(); let mut edges: HashMap = HashMap::new(); @@ -486,7 +526,27 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul if from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 { return Err(fail("an edge vanishes (the offset is too large)")); } - let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("edges must be lines or arcs"))?; + let Some(kind) = arc_mid(&e, size * 1e-6) else { + // Another curve (where two cylinders meet): its points moved onto the moved + // faces, through a smooth curve. + use mt::{BoundedCurve, ParametricCurve}; + let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?; + let (o, s, n) = pick(fs); + let cv = e.curve(); + let (t0, t1) = cv.range_tuple(); + let k = 16; + let mut pts = vec![from_p3(a.point())]; + for j in 1..k { + let p = from_p3(cv.subs(t0 + (t1 - t0) * j as f64 / k as f64)); + pts.push( + move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("an edge whose faces don't meet the same way after the move"))?, + ); + } + pts.push(from_p3(c.point())); + let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| fail("edge curve"))?; + edges.insert(e.id(), mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve))); + continue; + }; let ne = match kind { None => builder::line(a, c), Some(m) => { @@ -540,8 +600,13 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } out.push(nf); } - let shell: mt::Shell = out.into(); - let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?; + // One shell per shell of the body (pieces, voids). + let mut shells: Vec = Vec::new(); + let mut it = out.into_iter(); + for sh in b.solid.boundaries() { + shells.push(it.by_ref().take(sh.len()).collect::>().into()); + } + let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?; Body::new(solid) }) } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 98ca7f8d..ec3dc7a6 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; use truck_modeling as mt; -use crate::body::{Body, from_p3, p3, v3}; +use crate::body::{Body, Solid, from_p3, p3, v3}; use crate::{KernelError, Result, guard}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -162,6 +162,25 @@ thread_local! { /// everything or an intersection of disjoint bodies. Results are checked against volume bounds /// and retried with shifted copies and other tolerances when they fail or look wrong. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { + let r = boolean_whole(a, b, op); + // Bodies in several pieces: when the whole failed (or came out empty), a piece at a time. + let retry = match &r { + Err(_) => true, + Ok(None) => op != BoolOp::Intersect, + Ok(Some(_)) => false, + }; + if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) { + let (la, lb) = (a.lumps()?, b.lumps()?); + if (la.len() > 1 || lb.len() > 1) + && let Ok(Some(x)) = by_lumps(la, lb, op) + { + return Ok(Some(x)); + } + } + r +} + +fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; let size = a.size().max(b.size()); @@ -339,6 +358,80 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { Err(KernelError::Failed(format!("boolean {op:?}: {last}"))) } +/// A boolean of bodies given as their pieces; the result's pieces in one body. +fn by_lumps(la: Vec, lb: Vec, op: BoolOp) -> Result> { + let boxes_meet = |x: &Body, y: &Body| -> bool { + let (Ok(mx), Ok(my)) = (x.tessellate(x.size() * 0.05 + 1e-3), y.tessellate(y.size() * 0.05 + 1e-3)) else { return true }; + let (bx, by) = (mx.bounds(), my.bounds()); + let g = (x.size() + y.size()) * 0.01; + bx.min.x <= by.max.x + g + && by.min.x <= bx.max.x + g + && bx.min.y <= by.max.y + g + && by.min.y <= bx.max.y + g + && bx.min.z <= by.max.z + g + && by.min.z <= bx.max.z + g + }; + let mut out: Vec = Vec::new(); + match op { + BoolOp::Cut => { + for x in la { + let mut cur = Some(x); + for y in &lb { + cur = match cur { + Some(c) if boxes_meet(&c, y) => boolean(&c, y, BoolOp::Cut)?, + other => other, + }; + } + out.extend(cur); + } + } + BoolOp::Intersect => { + for x in &la { + for y in &lb { + if boxes_meet(x, y) + && let Some(r) = boolean(x, y, BoolOp::Intersect)? + { + out.push(r); + } + } + } + } + BoolOp::Union => { + out = la; + for y in lb { + let mut cur = y; + let mut rest = Vec::new(); + for x in out { + if boxes_meet(&x, &cur) { + match boolean(&x, &cur, BoolOp::Union)? { + Some(u) if u.solid.boundaries().len() == 1 => cur = u, + _ => rest.push(x), + } + } else { + rest.push(x); + } + } + rest.push(cur); + out = rest; + } + } + } + join_pieces(out) +} + +/// Bodies (apart from each other) as one body of several pieces. +pub(crate) fn join_pieces(pieces: Vec) -> Result> { + match pieces.len() { + 0 => Ok(None), + 1 => Ok(pieces.into_iter().next()), + _ => { + let shells: Vec = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect(); + let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?; + Body::new(solid).map(Some) + } + } +} + /// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections). pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { if m.iter().flatten().any(|x| !x.is_finite()) { diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 5e8f146d..d34d154e 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -365,14 +365,31 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { match crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut) { Ok(Some(r)) => Ok(r), Ok(None) => Err(KernelError::Failed("the shell removed everything".into())), - // Healing can leave faces the boolean dislikes (revolved patches): the body as it was. - Err(e) if healed.face_count() == b.face_count() => { + // Healing can leave faces the boolean dislikes (revolved patches): the body as it was, + // its faces to open found again. + Err(e) => { + let raw = b.tessellate((size * 1e-3).max(1e-3)).map_err(|_| e.clone())?; + let mut opened_raw: Vec = Vec::new(); + for p in open { + let near = raw + .triangles + .iter() + .zip(&raw.tri_face) + .filter_map(|(t, f)| raw.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) + .min_by(|a, c| a.0.total_cmp(&c.0)); + if let Some((d, f)) = near + && d < size * 1e-3 + 1e-6 + && !opened_raw.contains(&f) + { + opened_raw.push(f); + } + } + let opened = opened_raw; let cavity = offset_planar(b, |fi, _| if opened.contains(&fi) { margin } else { -thickness }).map_err(|_| e.clone())?; crate::ops::boolean(b, &cavity, crate::BoolOp::Cut) .map_err(|_| e)? .ok_or_else(|| KernelError::Failed("the shell removed everything".into())) } - Err(e) => Err(e), } } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 2a27c0ee..4a7f5ea1 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1297,3 +1297,66 @@ fn tight_bend_counts() { let m = measure(&b).unwrap().merged; assert_eq!((m.faces, m.edges, m.vertices), (5, 6, 4), "{m:?}"); } + +#[test] +#[ignore] +fn boolean_identity_survey() { + let n: u64 = std::env::var("N").ok().and_then(|x| x.parse().ok()).unwrap_or(100); + let seeds: Vec = match std::env::var("SEEDS") { + Ok(s) => s.split(',').filter_map(|x| x.parse().ok()).collect(), + Err(_) => (0..n).collect(), + }; + let mut bad = 0; + for seed in seeds { + let c = crate::fuzz::boolean_case(seed); + if c.outcome != "ok" { + bad += 1; + println!("{seed}: {} {} | {:?} | {:?}", c.outcome, c.detail, c.a, c.b); + } + } + println!("{bad}/{n} failed"); +} + +/// A quarter turn of a rectangle with an edge on the axis: no zero-area face along the axis, so +/// a cylinder can be cut through the corner (it failed in every boolean attempt before). +#[test] +fn partial_revolve_on_the_axis() { + let sq = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] }; + let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap(); + assert!(pie.validity().is_empty(), "{:?}", pie.validity()); + let v = measure(&pie).unwrap().volume; + assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}"); + assert_eq!(measure(&pie).unwrap().merged.faces, 5); + let cyl = cylinder(Vec3::new(2.5, 0.0, 5.0), Vec3::Z, 3.75, 22.5).unwrap(); + let cut = boolean(&pie, &cyl, BoolOp::Cut).unwrap().unwrap(); + assert!(cut.validity().is_empty()); + let vc = measure(&cut).unwrap().volume; + assert!(vc < v - 1.0 && vc > v - PI * 3.75 * 3.75 * 2.5, "{vc}"); +} + +/// Boolean identities on primitives the robustness runs found failing: cylinders tangent to box +/// faces from inside and out, crossing cylinders touching along a line (their seams used to lie +/// exactly where the other body cut them). +#[test] +fn boolean_identity_on_tangent_primitives() { + for seed in [1, 32, 169] { + let c = crate::fuzz::boolean_case(seed); + assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b); + } +} + +/// A body in two pieces: booleans go a piece at a time. +#[test] +fn boolean_of_a_body_in_pieces() { + let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap(); + let two = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); + assert_eq!(two.shell_count(), 2); + let tool = cylinder(Vec3::new(-5.0, 5.0, 5.0), Vec3::X, 2.0, 40.0).unwrap(); + let cut = boolean(&two, &tool, BoolOp::Cut).unwrap().unwrap(); + assert_eq!(cut.shell_count(), 2); + assert!(cut.validity().is_empty(), "{:?}", cut.validity()); + let v = measure(&cut).unwrap().volume; + assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}"); +}