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 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
5d69452db3
commit
ccde7a4837
16 files changed
+1260
-16
No files matched your search
@@ -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<f64> = 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]
|
||||
|
||||
@@ -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<String>) -> 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<Step>,
|
||||
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<String>) -> Option<Step> {
|
||||
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<String> = 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 }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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];
|
||||
Reference in new issue
Block a user