//! The corpus runner behind `cargo xtask corpus [--part NAME] [--no-write]`: build every part of //! the meshing test corpus ([`crate::corpus`]), tessellate it, run each [`Stage`] on it and write //! the results table to docs/meshing/corpus.md. //! //! The table is a fixed part columns block (bodies, faces, the boundary surface and the time to //! build it) followed by one column group per stage. A mesher joins the corpus by implementing //! [`Stage`] over a [`Loaded`] part (its tessellated boundary surfaces are ready in //! `Loaded::surfaces`) and adding itself to [`stages`]. A stage that fails on a part shows the //! error in its first column; the run goes on. use std::fmt::Write as _; use std::path::Path; use std::time::Instant; use crate::corpus::{self, CATEGORIES, Part}; use modelcraft_geometry::{Design, Measure, SurfaceMesh}; use modelcraft_mesh::cdt::{CdtOptions, tetrahedralize}; use modelcraft_mesh::surface::{BoundarySurface, SurfaceOptions}; pub const FILE: &str = "docs/meshing/corpus.md"; /// Chordal tolerance of the boundary surface, relative to the part's bounding-box diagonal. const RELATIVE_TOLERANCE: f64 = 1e-3; /// A built part with its boundary surfaces. pub struct Loaded<'a> { pub part: &'a Part, pub design: Design, /// SolidCraft's measures, one per body in the design's order. pub measures: Vec, /// One per body, in the design's order. pub surfaces: Vec, /// The chordal tolerance the surfaces were made with (mm). pub tolerance: f64, } /// A column group of the results table: a mesher, a check. pub trait Stage { fn headers(&self) -> &'static [&'static str]; /// One cell per header. fn run(&self, part: &Loaded) -> Result, String>; } /// The stages, in column order. Meshers add theirs here. fn stages() -> Vec> { vec![Box::new(SurfaceVolume), Box::new(BoundaryCheck), Box::new(TetCdt)] } /// The boundary surface's enclosed volume against SolidCraft's measured volume and the closed /// form: a surface that is open, inverted or misplaced shows here first. struct SurfaceVolume; impl Stage for SurfaceVolume { fn headers(&self) -> &'static [&'static str] { &["enclosed mm³", "SolidCraft mm³", "rel. diff", "exact mm³"] } fn run(&self, l: &Loaded) -> Result, String> { let enclosed: f64 = l.surfaces.iter().map(SurfaceMesh::enclosed_volume).sum(); let measured: f64 = l.measures.iter().map(|m| m.volume).sum(); let rel = if measured.abs() > 0.0 { (enclosed - measured) / measured } else { f64::NAN }; Ok(vec![format!("{enclosed:.1}"), format!("{measured:.1}"), format!("{rel:.1e}"), l.part.volume.map_or("–".into(), |v| format!("{v:.1}"))]) } } /// Boundary surface preparation (`modelcraft_mesh::surface`): welded, closed, manifold, outward /// and free of self-intersections, or the first defects found, which keep the part from being /// volume meshed. struct BoundaryCheck; impl Stage for BoundaryCheck { fn headers(&self) -> &'static [&'static str] { &["boundary", "genus", "feature edges", "check ms"] } fn run(&self, l: &Loaded) -> Result, String> { let start = Instant::now(); let (mut vertices, mut genus, mut features) = (0, Vec::new(), 0); for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) { let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() }; let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?; vertices += s.points().len(); genus.extend(s.shells().iter().map(|sh| if sh.void { format!("{} (void)", sh.genus) } else { sh.genus.to_string() })); features += s.feature_edges().len(); } let ms = start.elapsed().as_secs_f64() * 1e3; Ok(vec![format!("ok, {vertices} vertices"), genus.join(", "), features.to_string(), format!("{ms:.1}")]) } } /// The constrained Delaunay tetrahedralization (`modelcraft_mesh::cdt`) of each body's boundary: /// tetrahedra, Steiner points on boundary edges, cavities refilled by gift-wrapping, and the /// validity gate on the rounded mesh (every tetrahedron positive, volume equal to the boundary's). struct TetCdt; impl Stage for TetCdt { fn headers(&self) -> &'static [&'static str] { &["CDT tets", "Steiner", "cavities", "tet gate", "CDT ms"] } fn run(&self, l: &Loaded) -> Result, String> { let start = Instant::now(); let (mut tets, mut steiner, mut cavities) = (0, 0, 0); for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) { let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() }; let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?; let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?; m.gate(s.volume()).and_then(|r| r.into_result()).map_err(|e| format!("{}: rounded mesh: {e}", body.name))?; tets += m.tets.len(); steiner += m.steiner.len(); cavities += m.stats.cavities; } let ms = start.elapsed().as_secs_f64() * 1e3; Ok(vec![tets.to_string(), steiner.to_string(), cavities.to_string(), "pass".into(), format!("{ms:.0}")]) } } fn load(part: &Part) -> Result<(Loaded<'_>, f64), String> { let start = Instant::now(); let design = corpus::load(part).map_err(|e| e.to_string())?; if let Some(e) = design.errors.first() { return Err(format!("feature {} failed: {}", e.feature, e.message)); } let measures = design.bodies.iter().map(|b| b.measure().map_err(|e| format!("measure {}: {e}", b.name))).collect::, _>>()?; let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); for m in &measures { for i in 0..3 { lo[i] = lo[i].min(m.bbox_min[i]); hi[i] = hi[i].max(m.bbox_max[i]); } } let diagonal = (0..3).map(|i| (hi[i] - lo[i]).powi(2)).sum::().sqrt(); let tolerance = RELATIVE_TOLERANCE * if diagonal.is_finite() && diagonal > 0.0 { diagonal } else { 1.0 }; let surfaces = design.bodies.iter().map(|b| b.tessellate(tolerance).map_err(|e| format!("tessellate {}: {e}", b.name))).collect::, _>>()?; let ms = start.elapsed().as_secs_f64() * 1e3; Ok((Loaded { part, design, measures, surfaces, tolerance }, ms)) } /// A failed part or stage: the error, then blanks to fill `width` cells. fn error_cells(e: &str, width: usize) -> impl Iterator { std::iter::once(format!("error: {e}")).chain(std::iter::repeat_n(String::new(), width.saturating_sub(1))) } /// Run the corpus; with `write` (and every part) write [`FILE`] under the workspace `root`. pub fn run(root: &Path, only: Option<&str>, write: bool) -> Result<(), String> { let mut parts = corpus::parts().map_err(|e| e.to_string())?; if let Some(name) = only { parts.retain(|p| p.name == name); if parts.is_empty() { return Err(format!("no corpus part named `{name}`")); } } let stages = stages(); const PART_COLUMNS: &[&str] = &["part", "categories", "bodies", "faces", "triangles", "tol. mm", "build ms"]; let mut header = PART_COLUMNS.to_vec(); for s in &stages { header.extend_from_slice(s.headers()); } let mut rows: Vec> = Vec::new(); let mut failures = 0; for part in &parts { eprintln!("corpus: {}", part.name); let mut row = vec![part.name.clone(), part.categories.join(", ")]; match load(part) { Err(e) => { failures += 1; row.extend(error_cells(&e, header.len() - row.len())); } Ok((l, ms)) => { let faces: usize = l.measures.iter().map(|m| m.faces).sum(); let triangles: usize = l.surfaces.iter().map(|s| s.triangles.len()).sum(); row.extend([ l.design.bodies.len().to_string(), faces.to_string(), triangles.to_string(), format!("{:.3}", l.tolerance), format!("{ms:.0}"), ]); for s in &stages { let width = s.headers().len(); match s.run(&l) { Ok(cells) if cells.len() == width => row.extend(cells), Ok(cells) => { failures += 1; row.extend(error_cells(&format!("stage gave {} cells for {width} columns", cells.len()), width)); } Err(e) => { failures += 1; row.extend(error_cells(&e, width)); } } } } } rows.push(row); } let table = markdown_table(&header, &rows); println!("{table}"); if write && only.is_none() { let path = root.join(FILE); std::fs::write(&path, document(&table, &parts)).map_err(|e| format!("{}: {e}", path.display()))?; println!("corpus: wrote {FILE}"); } if failures > 0 { Err(format!("corpus: {failures} part(s) or stage(s) failed")) } else { Ok(()) } } fn markdown_table(header: &[&str], rows: &[Vec]) -> String { let mut s = format!("| {} |\n|{}\n", header.join(" | "), "---|".repeat(header.len())); for r in rows { let _ = writeln!(s, "| {} |", r.iter().map(|c| c.replace('|', "\\|")).collect::>().join(" | ")); } s } fn document(table: &str, parts: &[Part]) -> String { let mut s = String::from( "# Meshing test corpus\n\n\ Generated by `cargo xtask corpus`; do not edit. The parts are our own: SolidCraft recipes in\n\ `tests/corpus//recipe.json` and analytic shapes generated in code\n\ (`crates/testkit/src/corpus.rs`). Each run rebuilds every part, tessellates it at a chordal\n\ tolerance of 1e-3 of its bounding-box diagonal and runs every stage on it. Times are wall\n\ clock on the machine that ran it; every other column is deterministic.\n\n\ ## Results\n\n", ); s.push_str(table); s.push_str("\n## Categories\n\n| category | stresses | parts |\n|---|---|---|\n"); for (c, what) in CATEGORIES { let n = parts.iter().filter(|p| p.categories.iter().any(|x| x == c)).count(); let _ = writeln!(s, "| {c} | {what} | {n} |"); } s.push_str("\n## Parts\n\n"); for p in parts { let _ = writeln!(s, "- `{}`: {}", p.name, p.description); } s } #[cfg(test)] mod tests { use super::*; /// The analytic shapes come out of boundary preparation valid, with their genus. #[test] fn analytic_parts_are_valid_boundaries() { let expected = [("analytic-sphere", "0"), ("analytic-torus", "1"), ("analytic-cylinder", "0"), ("analytic-pipe", "1"), ("analytic-plate-hole", "1")]; let parts = corpus::analytic_parts(); for (name, genus) in expected { let part = parts.iter().find(|p| p.name == name).unwrap(); let (loaded, _) = load(part).unwrap(); let cells = BoundaryCheck.run(&loaded).unwrap(); assert_eq!(cells[1], genus, "{name}: {cells:?}"); } } /// The constrained Delaunay tetrahedralization passes the gate on parts that exercise each /// of its stages: an assembly whose rounding needs a few-ulp repair, cavities refilled by /// gift-wrapping, and a holed plate. #[test] fn cdt_meshes_corpus_parts() { let mut parts = corpus::parts().unwrap(); parts.retain(|p| ["bolted-assembly", "thin-wall-enclosure", "analytic-plate-hole"].contains(&p.name.as_str())); assert_eq!(parts.len(), 3); for part in &parts { let (loaded, _) = load(part).unwrap(); let cells = TetCdt.run(&loaded).unwrap_or_else(|e| panic!("{}: {e}", part.name)); assert_eq!(cells[3], "pass", "{}: {cells:?}", part.name); assert!(cells[0].parse::().unwrap() > 0); } } }