forked from 1b-agent/fieldcraft-upstream
Meshing test corpus built with SolidCraft; assembly placement in open_design (#11)
* geometry: build designs from SolidCraft command scripts (feature scripts) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * geometry: place component bodies through their occurrences when opening a design An assembly's instances were missing and moved components sat at their own origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Meshing test corpus: 16 SolidCraft recipes, analytic shapes, loader and runner modelcraft-testkit holds the corpus (tests/corpus/<name>/recipe.json run through SolidCraft's engine, then opened as a design) and the runner binary behind cargo xtask corpus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: corpus results, test corpus architecture and the scripts feature; licences Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * corpus: measure each body once, check stage widths, simpler loader and runner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
49 files changed
+1770
-84
No files matched your search
@@ -0,0 +1,30 @@
|
||||
//! `corpus [--part NAME] [--no-write]`: see [`modelcraft_testkit::runner`]; run it as
|
||||
//! `cargo xtask corpus`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut only = None;
|
||||
let mut write = true;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--part" => only = it.next().map(String::as_str),
|
||||
"--no-write" => write = false,
|
||||
other => {
|
||||
eprintln!("error: unknown argument `{other}`\nusage: corpus [--part NAME] [--no-write]");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
match modelcraft_testkit::runner::run(&modelcraft_testkit::corpus::workspace_root(), only, write) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! The meshing test corpus.
|
||||
//!
|
||||
//! Two kinds of part, both regenerated on every load (no geometry is stored):
|
||||
//! - **Recipes**: `tests/corpus/<name>/recipe.json`, a SolidCraft command script (the file
|
||||
//! `solidcraft-cli run` takes) with a `corpus` object beside its `commands` giving the
|
||||
//! categories it stresses and, when it has one, its closed-form volume; `README.md` beside it is
|
||||
//! one line on what it stresses. The script runs in SolidCraft's engine, the design is saved and
|
||||
//! opened again through [`modelcraft_geometry::open_design`], the path a person's design takes.
|
||||
//! - **Analytic shapes** ([`Analytic`]): sphere, torus, cylinder, thick-walled pipe and a plate
|
||||
//! with a hole, built in code with their exact volume, the classic solver verification
|
||||
//! geometries.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use modelcraft_geometry::{Design, open_design, sample, script_design};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Io(String),
|
||||
#[error("{part}: {message}")]
|
||||
Part { part: String, message: String },
|
||||
#[error("{part}: {source}")]
|
||||
Geometry { part: String, source: modelcraft_geometry::Error },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// What a part stresses in a mesher. A recipe names one or more.
|
||||
pub const CATEGORIES: &[(&str, &str)] = &[
|
||||
("thin-wall", "walls thin against the part's size"),
|
||||
("small-feature", "features much smaller than the part"),
|
||||
("fillet", "blends between faces"),
|
||||
("genus", "through holes (genus > 0)"),
|
||||
("sharp", "sharp edges and acute dihedral angles"),
|
||||
("near-tangent", "faces meeting at a shallow angle"),
|
||||
("boolean", "intersection curves from booleans"),
|
||||
("sweepable", "extrusions and sweeps (one-to-one sweep meshing)"),
|
||||
("blocky", "axis-aligned blocks (polycube-friendly)"),
|
||||
("organic", "free-form, lofted or revolved spline surfaces"),
|
||||
("assembly", "several bodies placed by component transforms"),
|
||||
("analytic", "closed-form geometry for solver verification"),
|
||||
];
|
||||
|
||||
/// Largest recipe or README read.
|
||||
const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
/// Most parts in the corpus directory.
|
||||
const MAX_PARTS: usize = 10_000;
|
||||
|
||||
/// A part of the corpus.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Part {
|
||||
/// The recipe's directory name, or the analytic shape's name (`analytic-…`).
|
||||
pub name: String,
|
||||
/// One line on what it stresses.
|
||||
pub description: String,
|
||||
/// Entries of [`CATEGORIES`].
|
||||
pub categories: Vec<String>,
|
||||
/// Closed-form volume (mm³), when the part has one.
|
||||
pub volume: Option<f64>,
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Source {
|
||||
/// The script, read from `recipe.json`.
|
||||
Recipe(String),
|
||||
Analytic(Analytic),
|
||||
}
|
||||
|
||||
/// Closed-form shapes (mm).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Analytic {
|
||||
Sphere {
|
||||
radius: f64,
|
||||
},
|
||||
Torus {
|
||||
major: f64,
|
||||
minor: f64,
|
||||
},
|
||||
Cylinder {
|
||||
radius: f64,
|
||||
height: f64,
|
||||
},
|
||||
/// A thick-walled cylinder (Lamé).
|
||||
Pipe {
|
||||
inner: f64,
|
||||
outer: f64,
|
||||
length: f64,
|
||||
},
|
||||
/// A plate with a round through hole (Kirsch), built as a parametric design in code.
|
||||
PlateWithHole(sample::Plate),
|
||||
}
|
||||
|
||||
impl Analytic {
|
||||
/// The exact volume.
|
||||
pub fn volume(&self) -> f64 {
|
||||
use std::f64::consts::PI;
|
||||
match *self {
|
||||
Analytic::Sphere { radius } => 4.0 / 3.0 * PI * radius.powi(3),
|
||||
Analytic::Torus { major, minor } => 2.0 * PI * PI * major * minor * minor,
|
||||
Analytic::Cylinder { radius, height } => PI * radius * radius * height,
|
||||
Analytic::Pipe { inner, outer, length } => PI * (outer * outer - inner * inner) * length,
|
||||
Analytic::PlateWithHole(p) => (p.width * p.depth - PI * p.hole_diameter * p.hole_diameter / 4.0) * p.thickness,
|
||||
}
|
||||
}
|
||||
|
||||
/// The design as `.solidcraft` bytes.
|
||||
pub fn design(&self) -> modelcraft_geometry::Result<Vec<u8>> {
|
||||
let script = match *self {
|
||||
Analytic::Sphere { radius } => json!([{"command": "solid.sphere", "params": {"radius": radius, "body_name": "Sphere"}}]),
|
||||
Analytic::Torus { major, minor } => json!([{"command": "solid.torus", "params": {"major": major, "minor": minor, "body_name": "Torus"}}]),
|
||||
Analytic::Cylinder { radius, height } => {
|
||||
json!([{"command": "solid.cylinder", "params": {"radius": radius, "height": height, "body_name": "Cylinder"}}])
|
||||
}
|
||||
Analytic::Pipe { inner, outer, length } => json!([
|
||||
{"command": "solid.cylinder", "params": {"radius": outer, "height": length, "body_name": "Pipe"}},
|
||||
{"command": "solid.cylinder", "params": {"radius": inner, "height": length, "operation": "cut", "targets": ["Pipe"]}}
|
||||
]),
|
||||
Analytic::PlateWithHole(p) => return sample::plate_design(&p),
|
||||
};
|
||||
script_design(&script.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The analytic shapes of the corpus.
|
||||
pub fn analytic_parts() -> Vec<Part> {
|
||||
let plate = sample::Plate { width: 200.0, depth: 100.0, thickness: 5.0, hole_diameter: 20.0 };
|
||||
let shapes: [(&str, &str, &[&str], Analytic); 5] = [
|
||||
("analytic-sphere", "Sphere r 20: curvature everywhere, no edges.", &[], Analytic::Sphere { radius: 20.0 }),
|
||||
("analytic-torus", "Torus 30/8: genus 1 with doubly curved faces and no edges.", &["genus"], Analytic::Torus { major: 30.0, minor: 8.0 }),
|
||||
(
|
||||
"analytic-cylinder",
|
||||
"Cylinder r 10, h 40: the round bar of beam and torsion checks.",
|
||||
&["sweepable"],
|
||||
Analytic::Cylinder { radius: 10.0, height: 40.0 },
|
||||
),
|
||||
(
|
||||
"analytic-pipe",
|
||||
"Thick-walled pipe 20/30, length 60: the Lamé cylinder.",
|
||||
&["genus", "sweepable"],
|
||||
Analytic::Pipe { inner: 20.0, outer: 30.0, length: 60.0 },
|
||||
),
|
||||
("analytic-plate-hole", "Plate 200 x 100 x 5 with a 20 mm hole: the Kirsch plate.", &["genus", "sweepable"], Analytic::PlateWithHole(plate)),
|
||||
];
|
||||
shapes
|
||||
.into_iter()
|
||||
.map(|(name, description, more, shape)| Part {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
categories: std::iter::once("analytic").chain(more.iter().copied()).map(str::to_string).collect(),
|
||||
volume: Some(shape.volume()),
|
||||
source: Source::Analytic(shape),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The workspace root (this crate is `crates/testkit` in it).
|
||||
pub fn workspace_root() -> PathBuf {
|
||||
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
here.ancestors().nth(2).unwrap_or(here).to_path_buf()
|
||||
}
|
||||
|
||||
/// The workspace's corpus directory, `tests/corpus`.
|
||||
pub fn corpus_dir() -> PathBuf {
|
||||
workspace_root().join("tests").join("corpus")
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<String> {
|
||||
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display()));
|
||||
let len = std::fs::metadata(path).map_err(io)?.len();
|
||||
if len > MAX_FILE_BYTES {
|
||||
return Err(Error::Io(format!("{}: larger than {MAX_FILE_BYTES} bytes", path.display())));
|
||||
}
|
||||
std::fs::read_to_string(path).map_err(io)
|
||||
}
|
||||
|
||||
/// The recipe in `dir` (named after the directory).
|
||||
pub fn read_recipe(dir: &Path) -> Result<Part> {
|
||||
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let bad = |message: String| Error::Part { part: name.clone(), message };
|
||||
let script = read(&dir.join("recipe.json"))?;
|
||||
let value: Value = serde_json::from_str(&script).map_err(|e| bad(format!("recipe.json is not JSON: {e}")))?;
|
||||
let meta = value.get("corpus").ok_or_else(|| bad("recipe.json has no `corpus` object".into()))?;
|
||||
let categories: Vec<String> = meta
|
||||
.get("categories")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
if categories.is_empty() {
|
||||
return Err(bad("`corpus.categories` names none".into()));
|
||||
}
|
||||
if let Some(c) = categories.iter().find(|c| !CATEGORIES.iter().any(|(k, _)| k == c)) {
|
||||
return Err(bad(format!("unknown category `{c}`")));
|
||||
}
|
||||
let volume = meta
|
||||
.get("volume_mm3")
|
||||
.map(|v| v.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("`corpus.volume_mm3` must be a positive number".into())))
|
||||
.transpose()?;
|
||||
let readme = read(&dir.join("README.md"))?;
|
||||
let description = readme.lines().map(|l| l.trim_start_matches('#').trim()).find(|l| !l.is_empty()).unwrap_or_default().to_string();
|
||||
if description.is_empty() {
|
||||
return Err(bad("README.md is empty".into()));
|
||||
}
|
||||
Ok(Part { name, description, categories, volume, source: Source::Recipe(script) })
|
||||
}
|
||||
|
||||
/// Every recipe under `dir`, by name.
|
||||
pub fn read_recipes(dir: &Path) -> Result<Vec<Part>> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
|
||||
let mut dirs: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.join("recipe.json").is_file()).take(MAX_PARTS + 1).collect();
|
||||
if dirs.len() > MAX_PARTS {
|
||||
return Err(Error::Io(format!("{}: more than {MAX_PARTS} parts", dir.display())));
|
||||
}
|
||||
dirs.sort();
|
||||
dirs.iter().map(|d| read_recipe(d)).collect()
|
||||
}
|
||||
|
||||
/// The whole corpus: the recipes in [`corpus_dir`], then the analytic shapes.
|
||||
pub fn parts() -> Result<Vec<Part>> {
|
||||
let mut parts = read_recipes(&corpus_dir())?;
|
||||
parts.extend(analytic_parts());
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
/// Build a part: run its recipe (or generate the shape) and open the design.
|
||||
pub fn load(part: &Part) -> Result<Design> {
|
||||
let geometry = |source| Error::Geometry { part: part.name.clone(), source };
|
||||
let bytes = match &part.source {
|
||||
Source::Recipe(script) => script_design(script),
|
||||
Source::Analytic(shape) => shape.design(),
|
||||
}
|
||||
.map_err(geometry)?;
|
||||
open_design(&bytes).map_err(geometry)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn analytic_shapes_have_their_closed_form_volume() {
|
||||
for part in analytic_parts() {
|
||||
let design = load(&part).unwrap();
|
||||
assert!(design.errors.is_empty(), "{}: {:?}", part.name, design.errors);
|
||||
let expect = part.volume.unwrap();
|
||||
let got: f64 = design.bodies.iter().map(|b| b.measure().unwrap().volume).sum();
|
||||
assert!((got - expect).abs() < 1e-3 * expect, "{}: {got} vs {expect}", part.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every committed recipe builds without a failed feature, and its volume matches the closed
|
||||
/// form where it states one.
|
||||
#[test]
|
||||
fn every_recipe_builds() {
|
||||
let parts = read_recipes(&corpus_dir()).unwrap();
|
||||
assert!(parts.len() >= 15, "{}", parts.len());
|
||||
for part in &parts {
|
||||
let design = load(part).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(design.errors.is_empty(), "{}: {:?}", part.name, design.errors);
|
||||
assert!(!design.bodies.is_empty(), "{}: no bodies", part.name);
|
||||
if let Some(expect) = part.volume {
|
||||
let got: f64 = design.bodies.iter().map(|b| b.measure().unwrap().volume).sum();
|
||||
assert!((got - expect).abs() < 1e-3 * expect, "{}: {got} vs {expect}", part.name);
|
||||
}
|
||||
}
|
||||
// Every category is covered.
|
||||
for (c, _) in CATEGORIES {
|
||||
assert!(parts.iter().chain(&analytic_parts()).any(|p| p.categories.iter().any(|x| x == c)), "no part covers {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The assembly's clamps sit where their occurrences put them: one moved, one instance moved
|
||||
/// and turned 90 degrees about Z.
|
||||
#[test]
|
||||
fn assembly_bodies_are_placed() {
|
||||
let part = read_recipe(&corpus_dir().join("bolted-assembly")).unwrap();
|
||||
let design = load(&part).unwrap();
|
||||
let mut boxes: Vec<([f64; 3], [f64; 3])> = design
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = b.measure().unwrap();
|
||||
(m.bbox_min, m.bbox_max)
|
||||
})
|
||||
.collect();
|
||||
boxes.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]).then(a.1[0].total_cmp(&b.1[0])));
|
||||
let expect = [([0.0, 0.0, 0.0], [100.0, 60.0, 5.0]), ([10.0, 10.0, 5.0], [30.0, 20.0, 13.0]), ([65.0, 5.0, 5.0], [75.0, 25.0, 13.0])];
|
||||
assert_eq!(boxes.len(), expect.len());
|
||||
for (got, want) in boxes.iter().zip(&expect) {
|
||||
let close = |a: &[f64; 3], b: &[f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6);
|
||||
assert!(close(&got.0, &want.0) && close(&got.1, &want.1), "{got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_recipes_are_an_error() {
|
||||
let dir = std::env::temp_dir().join(format!("modelcraft-corpus-test-{}", std::process::id()));
|
||||
let part = dir.join("bad");
|
||||
std::fs::create_dir_all(&part).unwrap();
|
||||
std::fs::write(part.join("README.md"), "A bad part.\n").unwrap();
|
||||
for recipe in [
|
||||
"",
|
||||
"{",
|
||||
r#"{"commands": []}"#,
|
||||
r#"{"corpus": {"categories": []}, "commands": []}"#,
|
||||
r#"{"corpus": {"categories": ["nope"]}, "commands": []}"#,
|
||||
r#"{"corpus": {"categories": ["blocky"], "volume_mm3": -1}, "commands": []}"#,
|
||||
] {
|
||||
std::fs::write(part.join("recipe.json"), recipe).unwrap();
|
||||
assert!(read_recipe(&part).is_err(), "{recipe}");
|
||||
}
|
||||
std::fs::write(part.join("recipe.json"), r#"{"corpus": {"categories": ["blocky"]}, "commands": [{"command": "no.such"}]}"#).unwrap();
|
||||
assert!(load(&read_recipe(&part).unwrap()).is_err());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Test tooling (a dev-dependency only, `cargo xtask layers`).
|
||||
//!
|
||||
//! [`corpus`] is the meshing test corpus: parts built with SolidCraft from recipes committed in
|
||||
//! `tests/corpus/<name>/recipe.json`, and analytic shapes generated in code with closed-form
|
||||
//! volumes. Meshing tests load their geometry from here; [`runner`] (the `corpus` binary,
|
||||
//! `cargo xtask corpus`) builds and meshes every part and writes docs/meshing/corpus.md.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod corpus;
|
||||
pub mod runner;
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 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};
|
||||
|
||||
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<Measure>,
|
||||
/// One per body, in the design's order.
|
||||
pub surfaces: Vec<SurfaceMesh>,
|
||||
/// 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<Vec<String>, String>;
|
||||
}
|
||||
|
||||
/// The stages, in column order. Meshers add theirs here.
|
||||
fn stages() -> Vec<Box<dyn Stage>> {
|
||||
vec![Box::new(SurfaceVolume)]
|
||||
}
|
||||
|
||||
/// 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<Vec<String>, 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}"))])
|
||||
}
|
||||
}
|
||||
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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::<f64>().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::<Result<Vec<_>, _>>()?;
|
||||
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<Item = String> {
|
||||
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<String>> = 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>]) -> 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::<Vec<_>>().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/<name>/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
|
||||
}
|
||||
Reference in new issue
Block a user