Files
OneCAD/crates/engine/src/tests/robustness.rs
T
bherbruckandClaude Opus 5.5 add4a18961 fix: fuse bodies that touch back to back over part of a face (#679, #674) (#704)
Two L's extruded up and down from one 2 x 2 grid now join into one solid (the planar rebuild gives each fan of faces around a pinched vertex its own vertex copy), and a pad whose end lies half past the edge of the face it stands on fuses exactly: contact faces are imprinted with each other's outline and the common faces glued away, still held to the retry guard. Face splitting handles a line crossing an edge twice; glue matches differently parametrised curves and keeps several pieces apart. Robustness seed 16 now fuses with the exact volume.

Fixes #679, fixes #674.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:02:44 -04:00

355 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Cases found by the robustness runs (`solidcraft-cli fuzz`, docs/robustness.md), cut down to
//! the steps that matter, and a fixed set of seeds that must keep running clean.
use std::f64::consts::PI;
use serde_json::{Value, json};
use super::{run, volume};
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}");
}
/// Panics inside the kernel are caught natively, but abort the web build (wasm can't unwind).
/// Building the samples must not panic at all (the Plastic Enclosure's bosses used to, 8 times).
#[test]
fn samples_build_without_panics() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let me = std::thread::current().id();
let count = Arc::new(AtomicUsize::new(0));
let c2 = count.clone();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().id() == me {
c2.fetch_add(1, Ordering::SeqCst);
}
let _ = info;
}));
let mut failed = Vec::new();
for sample in crate::sample::samples() {
let mut s = Session::default();
for c in (sample.script)()["commands"].as_array().into_iter().flatten() {
let _ = s.execute(c["command"].as_str().unwrap_or(""), &c["params"]);
}
let n = count.swap(0, Ordering::SeqCst);
if n > 0 {
failed.push(format!("{}: {n} panic(s)", sample.name));
}
}
std::panic::set_hook(prev);
assert!(failed.is_empty(), "{failed:?}");
}
fn total_volume(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(0.0)).sum()
}
/// Volume of a box L×W×H with its vertical edges rounded (R) and its bottom loop filleted
/// (ρ ≤ R): the straight runs lose (1 − π/4)ρ² each, the corners a quarter turn of that
/// (Pappus, about the corner's axis).
fn rounded_box(l: f64, w: f64, h: f64, r: f64, rho: f64) -> f64 {
use std::f64::consts::PI;
let a = (1.0 - PI / 4.0) * rho * rho;
let xbar = rho * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
l * w * h - (4.0 - PI) * r * r * h - a * (2.0 * (l - 2.0 * r) + 2.0 * (w - 2.0 * r)) - 2.0 * PI * (r - xbar) * a
}
/// Box, fillets, shell: a 60×40×30 box with its vertical edges rounded r5 and its bottom loop
/// r3, shelled 2 mm with the top open. The bottom round runs round the corners as spindle tori
/// (the tube wider than its circle), which the offset now takes.
#[test]
fn shell_of_a_filleted_box() {
let s = script(json!([
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 15], [60, 0, 15], [60, 40, 15], [0, 40, 15]], "radius": 5}},
{"command": "solid.fillet", "params": {"edges": [[30, 0, 0], [60, 20, 0], [30, 40, 0], [0, 20, 0]], "radius": 3}},
{"command": "solid.shell", "params": {"faces": [[30, 20, 30]], "thickness": 2}}
]));
let want = rounded_box(60.0, 40.0, 30.0, 5.0, 3.0) - rounded_box(56.0, 36.0, 28.0, 3.0, 1.0);
let v = total_volume(&s);
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
}
/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge
/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp
/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its
/// tangency (every face moves in, the opening is swept out through the top).
#[test]
fn shell_of_the_tutorial_plate() {
let base = json!([
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}}
]);
let s0 = script(base.clone());
let body = total_volume(&s0);
// 2 mm: the cavity is a sharp 56 × 36 box from z = 2 up through the top.
let mut steps = base.as_array().cloned().unwrap_or_default();
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 2}}));
let v = total_volume(&script(Value::Array(steps)));
let want = body - 56.0 * 36.0 * 13.0;
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
// 1.5 mm: a 57 × 37 × 12 cavity with r0.5 rounds, opened by its top face (57 × 36.5)
// swept up through the top (estimated: the rounds' mitre is left out, hence 1e-3).
let mut steps = base.as_array().cloned().unwrap_or_default();
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 1.5}}));
let v = total_volume(&script(Value::Array(steps)));
let a = (1.0 - std::f64::consts::PI / 4.0) * 0.25;
let want = body - (57.0 * 37.0 * 12.0 - a * (57.0 + 12.0)) - 57.0 * 36.5 * 1.5;
assert!((v - want).abs() < 1e-3 * want, "{v} vs {want}");
}
/// Shell after a drilled hole (its tip a cone): the cone moves like the other faces.
#[test]
fn shell_after_a_drilled_hole() {
for open in [[30.0, 0.0, 7.5], [10.0, 10.0, 15.0]] {
script(json!([
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
{"command": "solid.hole", "params": {"position": [30, 20, 15], "diameter": 5, "depth": 8, "type": "drilled"}},
{"command": "solid.shell", "params": {"faces": [open], "thickness": 2}}
]));
}
}
/// Rounding a box's edges one at a time: an edge running into an earlier round (of the same
/// radius or another) takes off about what each round alone would (the corner where they meet
/// is small).
#[test]
fn fillet_into_an_earlier_round() {
let box_ = json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}});
for r2 in [2.0, 3.0] {
let s = script(json!([
box_,
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}},
{"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": r2}}
]));
let lost = |r: f64, len: f64| (1.0 - PI / 4.0) * r * r * len;
let want = 36000.0 - lost(2.0, 60.0) - lost(r2, 15.0);
let v = total_volume(&s);
assert!((v - want).abs() < 2.0, "r2 {r2}: {v} vs {want}");
}
}
/// Clean seeds (seed 16: a partial face contact, fused), and the original seed 5 edge contact,
/// now rejected without distortion.
#[test]
fn fixed_seeds_run_clean() {
for seed in FIXED_SEEDS {
if *seed == 5 {
// Seed 5 originally joined these boxes along only an edge. Accepting
// it required silently shifting the tool; that is now invalid input.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 25, "width": 15, "height": 10}));
let error = s
.execute(
"solid.box",
&json!({
"corner": [25, 2.5, 10], "length": 17.5, "width": 17.5,
"height": 20, "operation": "join"
}),
)
.unwrap_err();
assert!(error.to_string().contains("zero-thickness edge contact"), "{error}");
assert!((total_volume(&s) - 3750.0).abs() < 1e-6);
assert_eq!(s.model.state().bodies.len(), 1);
assert!(s.model.state().bodies.first().unwrap().body.validity().is_empty());
continue;
}
if *seed == 16 {
// Seed 16 patterns a short cylinder around a block: one copy's end lies on a side of
// the block with half of it past the block's bottom edge (#674). The end and the side
// are split along each other's outline and the half disc they share goes: the block,
// half of the first cylinder (the other half is inside the block) and three whole ones.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 25.0, "width": 47.5, "height": 37.5}));
run(
&mut s,
"solid.cylinder",
json!({"axis": [1.0, 0.0, 0.0], "base": [0.0, 7.5, 0.0], "height": 2.5, "operation": "join", "radius": 5.0}),
);
run(&mut s, "solid.pattern.circular", json!({"axis": "Z", "count": 4, "features": ["Cylinder1"]}));
// (Within the measured round sides' mesh error, about 0.03 mm³ a cylinder.)
let want = 25.0 * 47.5 * 37.5 + 3.5 * PI * 25.0 * 2.5;
assert!((total_volume(&s) - want).abs() < 0.2, "{} vs {want}", total_volume(&s));
assert!(s.model.state().bodies.iter().all(|b| b.body.validity().is_empty()));
let r = fuzz::run(*seed, 8);
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
continue;
}
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];
/// A drilled hole as deep as the plate: the drill point breaks through the bottom.
#[test]
fn hole_as_deep_as_the_plate() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 10}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [25, 12.5, 10], "diameter": 6, "depth": 10, "type": "drilled"}));
let v = volume(&mut s);
assert!(v < v0 - PI * 9.0 * 9.0 && v > v0 - PI * 9.0 * 10.0 - 1.0, "{v0} {v}");
// Other sizes and places, the defaults, and a tapped hole.
for (i, p) in [
json!({"position": [10, 10, 10], "diameter": 5, "depth": 10, "type": "drilled"}),
json!({"position": [40, 15, 10], "diameter": 5, "depth": 10}),
json!({"position": [12, 6, 10], "diameter": 8.5, "depth": 10}),
json!({"position": [30, 7, 10], "thread": "M6", "depth": 10}),
json!({"position": [20, 18, 10], "diameter": 4, "depth": 10, "tip_angle": 90}),
]
.into_iter()
.enumerate()
{
let before = volume(&mut s);
let r = s.execute("solid.hole", &p);
assert!(r.is_ok(), "{i} {p}: {r:?}");
assert!(volume(&mut s) < before - 1.0, "{i} {p}");
}
}
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
#[test]
fn lip_after_boss() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "plastic.boss", json!({"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
run(&mut s, "plastic.boss", json!({"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
let v0 = volume(&mut s);
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
let v = volume(&mut s);
assert!(v > v0 + 1.0, "{v0} {v}");
// Bosses up to the rim, with ribs, against a wall; lips, grooves and outside rims.
let bosses = [
json!({"position": [15, 15, 2], "diameter": 8, "height": 28, "hole_diameter": 3}),
// Ribs out to the inner walls (15 − 4 − 9 = 2), and short of them.
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 9}),
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6}),
json!({"position": [5.5, 25, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}),
json!({"position": [40, 25, 2], "diameter": 8, "height": 20, "draft": "1 deg", "fillet": 1}),
];
let lips = [
json!({"face": [1, 25, 30], "width": 1, "height": 2}),
json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove"}),
json!({"face": [1, 25, 30], "width": 1, "height": 2, "side": "outside"}),
];
for (i, b) in bosses.iter().enumerate() {
for (j, l) in lips.iter().enumerate() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let r = s.execute("plastic.boss", b);
assert!(r.is_ok(), "boss {i}: {r:?}");
let v0 = volume(&mut s);
let r = s.execute("plastic.lip", l);
assert!(r.is_ok(), "boss {i} lip {j}: {r:?}");
assert!((volume(&mut s) - v0).abs() > 1.0, "boss {i} lip {j}");
}
}
}
/// A lip, a groove and an outside lip on the rim of a rounded, shelled enclosure (#29: the
/// lip's walls continue the shell's walls, which a boolean join couldn't do).
#[test]
fn lip_on_a_rounded_shelled_rim() {
use std::f64::consts::PI;
let base = json!([
{"command": "solid.box", "params": {"corner": [-40, -30, 0], "length": 80, "width": 60, "height": 30}},
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}}
]);
let v0 = total_volume(&script(base.clone()));
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
let inside = ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0);
for (extra, want) in [
(json!({}), v0 + inside * 1.5),
(json!({"side": "outside"}), v0 + (ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0)) * 1.5),
// (The groove's band comes from the rim's meshed outline: within its sag.)
(json!({"type": "groove"}), v0 - inside * 1.5),
] {
let mut p = json!({"face": [0, 29, 30], "width": 1, "height": 1.5});
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
let mut steps = base.as_array().cloned().unwrap_or_default();
steps.push(json!({"command": "plastic.lip", "params": p}));
let v = total_volume(&script(Value::Array(steps)));
assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}");
}
}