mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
Reference in New Issue
Block a user