Re-land the robustness batch on main (#6): fillets running into an earlier round go to blend_corner (any radius) instead of the same-radius undo; the lip and drilled-hole regression tests move into tests/robustness.rs with the new command ids; measuring 5000 faces is checked for its counts, its time in an ignored perf test run alone

Part of #6

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-10 00:04:37 +00:00
1 parent 0e9ddef4d8
commit 80f5a58806
5 files changed
+108 -79

No files matched your search

+1 -1
View File
@@ -9,9 +9,9 @@
pub mod cmd;
pub mod frames;
pub mod fuzz;
pub mod legacy_ids;
pub mod licences;
pub mod fuzz;
pub mod params;
pub mod recovery;
pub mod sample;
+85 -13
View File
@@ -1,8 +1,11 @@
//! 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 std::f64::consts::PI;
use serde_json::{Value, json};
use super::{run, volume};
use crate::Session;
use crate::fuzz;
@@ -180,22 +183,23 @@ fn shell_after_a_drilled_hole() {
}
}
/// Rounding an edge that runs into an earlier round of the same radius (filleting a box's
/// edges one at a time): the same body as picking both at once. Another radius says so.
/// 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}});
let both = script(json!([box_, {"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}}]));
let one_by_one = script(json!([
box_,
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}},
{"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": 2}}
]));
let (a, b) = (total_volume(&both), total_volume(&one_by_one));
assert!((a - b).abs() < 1e-6 * a, "{a} vs {b}");
let mut s = script(json!([box_, {"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}}]));
let e = s.execute("solid.fillet", &json!({"edges": [[60, 0, 7]], "radius": 3})).unwrap_err();
assert!(e.to_string().contains("not supported yet: an edge running into an earlier round of another radius"), "{e}");
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}");
}
}
/// Seeds that run clean: no failure, no invalid body, no volume going the wrong way.
@@ -208,3 +212,71 @@ fn fixed_seeds_run_clean() {
}
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}");
}
}
}
-57
View File
@@ -361,15 +361,6 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
let attempt = match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
Ok(o) => Ok((o, vec![j])),
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
// A straight edge running into an earlier round of the same radius: that round
// undone and both edges rounded together (they meet in a mitre, as when picked
// at once).
Err(e) if !curved_case(&solid, &edge) && e.to_string().contains("edge ends must be planar") && shape == Shape::Round => {
unround_and_blend(&cur, &edge, r, what).map(|b| ((*b.solid).clone(), vec![j])).map_err(|e2| match &e2 {
KernelError::Failed(m) if m.starts_with("not supported yet: an edge running into") => e2,
_ => KernelError::Failed(format!("{e} ({e2})")),
})
}
Err(e) if !curved_case(&solid, &edge) => Err(e),
Err(e) => match smooth_chain(&solid, &edge) {
Some(ids) => {
@@ -808,54 +799,6 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
Ok(cur)
}
thread_local! {
static UNROUNDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Round `edge` of `cur` where an end runs into an earlier round of radius `r`: the round is
/// deleted (its faces healed back to the sharp edge) and both edges rounded in one go.
fn unround_and_blend(cur: &Body, edge: &mt::Edge, r: f64, what: &str) -> Result<Body> {
if UNROUNDING.with(|c| c.get()) {
return Err(unsupported("rounds meeting rounds"));
}
let size = cur.size();
let mesh = cur.tessellate((size * 1e-3).max(1e-3))?;
let info = cur.faces((size * 1e-3).max(1e-3))?;
let curved = |f: usize| info.get(f).is_some_and(|x| x.plane_normal.is_none());
let ends = [from_p3(edge.absolute_front().point()), from_p3(edge.absolute_back().point())];
let mut round: Option<(usize, Vec3)> = None;
for q in ends {
let near = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| curved(**f as usize))
.filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (q.dist((x + y + z) / 3.0), *f as usize, (x + y + z) / 3.0)))
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((d, f, c)) = near
&& d < r * 2.0
{
round = Some((f, c));
break;
}
}
let (g, on_g) = round.ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
let cyl = crate::topo::cylinder_face_at(cur, on_g).ok_or_else(|| unsupported("only rounds (cylinders) at the edge ends"))?;
if (cyl.radius - r).abs() > 1e-4 * r.max(1.0) || cyl.internal {
return Err(unsupported("an edge running into an earlier round of another radius (round both with one radius, or pick them together)"));
}
let faces = crate::offset::with_same_surface(cur, &[g]);
let sharp = crate::delete_face::delete_faces(cur, &faces)?;
let mid = (ends[0] + ends[1]) * 0.5;
// The restored edge: nearest the middle of the round (its centroid), not its ends (where
// the new edge is near too).
let old = info.get(g).map(|x| x.centroid).unwrap_or(on_g);
UNROUNDING.with(|c| c.set(true));
let r2 = blend(&sharp, &[old, mid], r, Shape::Round, what);
UNROUNDING.with(|c| c.set(false));
r2
}
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> {
chamfer_tool_sides(cur, p, s, None, k)
}
+19 -5
View File
@@ -1361,8 +1361,8 @@ fn boolean_of_a_body_in_pieces() {
assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}");
}
/// Measuring a body of 5000 faces (a modelled thread is like that) stays fast and counts
/// right: 0.8 s here, it took 13 s (a per-face recomputation of the body's size).
/// Measuring a body of 5000 faces (a modelled thread is like that) counts right (it took 13 s,
/// a per-face recomputation of the body's size; now under a second).
#[test]
fn measure_of_a_body_with_many_faces() {
let n = 5000;
@@ -1373,9 +1373,23 @@ fn measure_of_a_body_with_many_faces() {
})
.collect();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
let t = std::time::Instant::now();
let m = measure(&b).unwrap();
// (A loaded build machine runs slower; the bound is loose.)
assert!(t.elapsed().as_secs_f64() < 5.0, "{:?}", t.elapsed());
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (n + 2, 3 * n, 2 * n), "{:?}", m.merged);
}
/// The time it takes (run alone, the suite's other tests share the machine):
/// `cargo test --release -p solvecraft-kernel perf_measure_5000_faces -- --ignored`.
#[test]
#[ignore]
fn perf_measure_5000_faces() {
let pts: Vec<Vec2> = (0..5000)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / 5000.0;
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
})
.collect();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
let t = std::time::Instant::now();
measure(&b).unwrap();
assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed());
}
+3 -3
View File
@@ -94,9 +94,9 @@ Found by QA on new users' first steps, each with an engine test against the anal
- **Shell thinner than a round tangent to the open face** (1.5 mm): every face moves in
(tangencies keep) and the opening is the open face's copy swept out through the body.
- **Shell after a drilled hole** (its tip a cone).
- **Filleting a box's edges one at a time**: an edge that runs into an earlier round of the
same radius undoes that round and rounds both together, the same body as picking both at once.
Another radius says what to do; Fusion's corner patch for it is still missing.
- **Filleting a box's edges one at a time**: an edge that runs into an earlier round takes
that round off and rounds both together, with the corner where they meet (`blend_corner.rs`,
any radii).
### Speed