kernel-cases: watertight checks the display mesh, welding vertices by distance

Two faults made simple curved solids look open (#818's blind hole: 80 open edges, a stadium
slot: 52):
- It meshed with `refine`, the measuring mesh, which splits triangles across curved faces on
  their own and leaves T-junctions on the edges they share with neighbours. It now checks the
  display mesh, whose faces share each edge's points.
- It matched vertices by rounding to a 1e-6 mm grid, which splits points a rounding error apart
  across a cell boundary. It now joins vertices closer than `tolerance.weld_mm` (default 1e-4 mm).
Both cases now have 0 open edges, also with the weld at 1e-6 mm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-21 07:30:37 -04:00
committed by jyviko
parent e809edd338
commit 8cb976b9b2
2 changed files with 50 additions and 13 deletions
+48 -11
View File
@@ -59,18 +59,55 @@ fn scratch(area: &str, name: &str) -> Result<String, 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();
/// A mesh's edges used by one triangle only (open) and by more than two (over-shared). Vertices
/// closer than `weld` (mm) are one vertex: a mesh repeats a vertex for each face, and faces
/// meeting at an edge may place it a rounding error apart.
fn open_edges(positions: &[solidcraft_engine::geom::Vec3], triangles: &[[u32; 3]], weld: f64) -> (usize, usize) {
use std::collections::HashMap;
let weld = if weld.is_finite() && weld > 0.0 { weld } else { 1e-4 };
let cell = |p: &solidcraft_engine::geom::Vec3| [p.x, p.y, p.z].map(|x| (x / weld).floor() as i64);
// Union-find over positions, joining each vertex with any within `weld` in the 27 cells round it.
let mut parent: Vec<usize> = (0..positions.len()).collect();
fn find(parent: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parent.get(i) {
if p == i {
break;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = gp;
}
i = p;
}
i
}
let mut grid: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
for (i, p) in positions.iter().enumerate() {
let c = cell(p);
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
let Some(near) = grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]) else { continue };
for &j in near {
if positions.get(j).is_some_and(|q| q.dist(*p) <= weld) {
let (a, b) = (find(&mut parent, i), find(&mut parent, j));
if let Some(slot) = parent.get_mut(a) {
*slot = b;
}
}
}
}
}
}
grid.entry(c).or_default().push(i);
}
let mut uses: HashMap<(usize, usize), usize> = 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;
let (a, b) = (find(&mut parent, a as usize), find(&mut parent, b as usize));
if a != b {
*uses.entry((a.min(b), a.max(b))).or_default() += 1;
}
*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())
@@ -192,8 +229,8 @@ fn check_case(dir: &str) -> Value {
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),
let (open, over) = match b.body.tessellate(tol) {
Ok(mesh) => open_edges(&mesh.positions, &mesh.triangles, want["tolerance"]["weld_mm"].as_f64().unwrap_or(1e-4)),
Err(e) => {
c.push("watertight", json!(format!("{}: {e}", b.name)), json!("no open edges"), false);
continue;
+2 -2
View File
@@ -87,9 +87,9 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage
| `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. |
| `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. |
| `watertight` | `true`: each body's display mesh (chord tolerance `tolerance.mesh_mm`, default 0.01 mm) has no open and no over-shared edges, with vertices closer than `tolerance.weld_mm` (default 1e-4 mm) taken as one. |
| `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`. |
| `tolerance` | Optional overrides: `rel`, `abs_mm`, `mesh_mm`, `weld_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