kernel-cases: count ranges, a watertight mesh check, and per-case timeouts

For the history areas' cases (threads, tessellation):
- A count may be an inclusive range `[min, max]`, where the geometry only bounds it (a modelled
  thread's faces).
- `"watertight": true` meshes each body (chord tolerance `tolerance.mesh_mm`, default 0.01 mm)
  and requires no open and no over-shared edges, with vertices matched by position.
- `"timeout_s"` gives a case longer than the run's timeout, at most 30 min.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-21 07:19:17 -04:00
committed by jyviko
parent ae8809a4b4
commit 58ce14f45f
3 changed files with 50 additions and 4 deletions
+42 -1
View File
@@ -56,6 +56,26 @@ fn scratch(area: &str, name: &str) -> Result<String, String> {
Ok(d.to_string_lossy().to_string())
}
/// Chord tolerance of the mesh a `watertight` case is checked on (mm).
const MESH_TOL: f64 = 0.01;
/// A mesh's edges used by one triangle only (open) and by more than two (over-shared), with
/// vertices matched by position (a mesh may repeat a vertex for each face).
fn open_edges(positions: &[solidcraft_engine::geom::Vec3], triangles: &[[u32; 3]]) -> (usize, usize) {
let key = |i: u32| positions.get(i as usize).map(|p| [p.x, p.y, p.z].map(|x| (x * 1e6).round() as i64));
let mut uses: std::collections::HashMap<([i64; 3], [i64; 3]), usize> = std::collections::HashMap::new();
for t in triangles {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let (Some(a), Some(b)) = (key(a), key(b)) else { continue };
if a == b {
continue;
}
*uses.entry(if a < b { (a, b) } else { (b, a) }).or_default() += 1;
}
}
(uses.values().filter(|&&n| n == 1).count(), uses.values().filter(|&&n| n > 2).count())
}
/// An error that means "this kernel can't do it yet", not a wrong answer.
fn unsupported(e: &str) -> bool {
e.contains("not supported yet")
@@ -170,6 +190,22 @@ fn check_case(dir: &str) -> Value {
"faces": m.merged.faces, "edges": m.merged.edges, "vertices": m.merged.vertices,
}));
shells += m.shells as u64;
if want["watertight"].as_bool() == Some(true) {
let tol = want["tolerance"]["mesh_mm"].as_f64().unwrap_or(MESH_TOL);
let (open, over) = match b.body.tessellate_with(tol, true) {
Ok(mesh) => open_edges(&mesh.positions, &mesh.triangles),
Err(e) => {
c.push("watertight", json!(format!("{}: {e}", b.name)), json!("no open edges"), false);
continue;
}
};
c.push(
"watertight",
json!(format!("{}: {open} open, {over} over-shared mesh edges", b.name)),
json!("no open edges"),
open == 0 && over == 0,
);
}
if want_valid {
// Closed shells, and every validity check this build has.
if want_shells.is_none() {
@@ -201,9 +237,14 @@ fn check_case(dir: &str) -> Value {
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
// A count is exact, or an inclusive range `[min, max]` where the geometry only bounds it.
let count = |c: &mut Checks, k: &str, g: u64| {
if has(k) {
c.push(k, json!(g), want[k].clone(), want[k].as_u64() == Some(g));
let ok = match want[k].as_array() {
Some(r) => r.first().and_then(Value::as_u64).is_some_and(|lo| g >= lo) && r.get(1).and_then(Value::as_u64).is_some_and(|hi| g <= hi),
None => want[k].as_u64() == Some(g),
};
c.push(k, json!(g), want[k].clone(), ok);
}
};
count(&mut c, "body_count", got.body_count as u64);
+4 -1
View File
@@ -86,7 +86,10 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage
| `shells` | The total number of shells, where it isn't one per body: a body with a sealed void (2), several lumps kept in one body. |
| `body_count`, `faces`, `edges`, `vertices` | Exact counts, where the geometry fixes them. Faces and edges count whole analytic surfaces and curves once (a full cylinder side is one face). |
| `volume_mm3`, `area_mm2`, `center_of_mass`, `bbox` | As in `tests/oracle`: totals over every body, relative tolerance 1e-3 for volume and area, 1e-3 of the box diagonal for positions. |
| `tolerance` | Optional overrides: `rel`, `abs_mm`. |
| `faces`, `edges`, `vertices`, `shells`, `body_count` as `[min, max]` | An inclusive range, where the geometry only bounds a count (a modelled thread's faces). |
| `watertight` | `true`: each body's mesh (chord tolerance `tolerance.mesh_mm`, default 0.01 mm) has no open and no over-shared edges. |
| `timeout_s` | A longer timeout for this case than the run's (at most 1800 s), for cases that are slow by nature. A case that only needs it on a loaded machine shouldn't set it. |
| `tolerance` | Optional overrides: `rel`, `abs_mm`, `mesh_mm`. |
| `missing` | A capability SolidCraft has no command or parameter for yet (`"solid.thread has no start count"`): the case is reported **not supported yet** without running, so the gap stays visible. |
## The complexity ladder
+4 -2
View File
@@ -100,14 +100,16 @@ fn run_case(o: &Options, kernel: &str, case: &Path) -> Value {
Ok(c) => c,
Err(e) => return base("fail", format!("solidcraft-cli: {e}")),
};
// A case may need longer than the run's timeout (`timeout_s`, at most 30 min).
let timeout = read(&case.join("expected.json"))["timeout_s"].as_u64().map_or(o.timeout, |s| Duration::from_secs(s.min(1800)).max(o.timeout));
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() > o.timeout => {
Ok(None) if start.elapsed() > timeout => {
let _ = child.kill();
let _ = child.wait();
return base("hang", format!("no result after {} s", o.timeout.as_secs()));
return base("hang", format!("no result after {} s", timeout.as_secs()));
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(e) => return base("fail", format!("wait: {e}")),