forked from 1b-agent/fieldcraft-upstream
testkit: run the meshing skill on the corpus and samples (cargo xtask skill-results)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
bb3def1cf5
commit
cb4141d0f0
7 files changed
+619
-1
No files matched your search
@@ -19,6 +19,7 @@ pub use datum::edit_params as datum_edit_params;
|
||||
pub use datum::preview as datum_preview;
|
||||
pub use mesh::analytic_surfaces;
|
||||
pub use nodes::merge_preview as node_merge_preview;
|
||||
pub use plan::untagged_faces;
|
||||
pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
|
||||
mod mirror;
|
||||
mod model;
|
||||
|
||||
@@ -1079,7 +1079,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The faces of `bodies` (as the design has them) that no element face of the mesh is tagged
|
||||
/// with: a set, load or relaxation by such a face finds nothing. Only a face narrower than the
|
||||
/// elements beside it should be one.
|
||||
fn untagged_faces(s: &Session, bodies: &[String]) -> Vec<Value> {
|
||||
pub fn untagged_faces(s: &Session, bodies: &[String]) -> Vec<Value> {
|
||||
let c = s.model.classification();
|
||||
let mut tagged: BTreeSet<u32> = BTreeSet::new();
|
||||
for t in c.elements().values() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! `skill [--part NAME] [--no-write] --work DIR`: see [`fieldcraft_testkit::skill`]; run it as
|
||||
//! `cargo xtask skill-results`, which also draws the section pictures.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
const USAGE: &str = "usage: skill --work DIR [--part NAME] [--no-write]";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let (mut only, mut work, mut write) = (None, None, true);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--part" => only = it.next().map(String::as_str),
|
||||
"--work" => work = it.next().map(String::as_str),
|
||||
"--no-write" => write = false,
|
||||
other => {
|
||||
eprintln!("error: unknown argument `{other}`\n{USAGE}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(work) = work else {
|
||||
eprintln!("error: --work is needed\n{USAGE}");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
match fieldcraft_testkit::skill::run(&fieldcraft_testkit::corpus::workspace_root(), Path::new(work), only, write) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! [`skill`] (the `skill` binary, `cargo xtask skill-results`) meshes every corpus part and
|
||||
//! sample design by following the meshing skill and writes docs/meshing/skill-results.md.
|
||||
//!
|
||||
//! [`oracle`] (the `oracle` binary, `cargo xtask oracle`) runs the verification and validation
|
||||
//! cases of `tests/oracle` against their references and fixtures and writes docs/oracle.md.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -13,3 +16,4 @@
|
||||
pub mod corpus;
|
||||
pub mod oracle;
|
||||
pub mod runner;
|
||||
pub mod skill;
|
||||
@@ -0,0 +1,533 @@
|
||||
//! The meshing skill's test (#482), behind `cargo xtask skill-results`: every corpus part and
|
||||
//! every sample with a design, meshed by following `skills/fieldcraft-meshing/SKILL.md` step by
|
||||
//! step with today's commands, through [`Session::execute`] as an agent over MCP would. The
|
||||
//! results go to docs/meshing/skill-results.md, one row per part.
|
||||
//!
|
||||
//! The steps, per part: look (`geometry.bodies`, `geometry.features`, `geometry.check` at the
|
||||
//! planned size); plan (`mesh.plan`) and apply it (`mesh.plan.apply`, which uses the body's mirror
|
||||
//! symmetry itself); where the plan fails, the skill's fallbacks in order (`mesh.hex.generate`,
|
||||
//! then `mesh.tet.generate` tet10), each failure kept as the reason; `mesh.quality`;
|
||||
//! `mesh.quality.mark` at the skill's limits; `mesh.node.relax` round each marked element; mark
|
||||
//! again; `mesh.check.duplicates`. The commands that built the final mesh are written as a
|
||||
//! script per part, so `fieldcraft-cli snapshot --section` can draw its section picture.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::corpus;
|
||||
|
||||
pub const FILE: &str = "docs/meshing/skill-results.md";
|
||||
/// The section pictures, one per part, relative to the workspace root.
|
||||
pub const PICTURES: &str = "docs/meshing/skill";
|
||||
/// The skill's acceptance limits (§6).
|
||||
const MIN_SCALED_JACOBIAN: f64 = 0.3;
|
||||
const MAX_EDGE_RATIO: f64 = 10.0;
|
||||
/// The mesh's volume against the geometry's (§6.1): a larger difference is a missing cell or a
|
||||
/// mesh too coarse for the curvature.
|
||||
const MAX_VOLUME_ERROR: f64 = 0.02;
|
||||
/// The most marked elements relaxed one by one; more than this is a clustered failure (the
|
||||
/// blocks are wrong, §6.3), which relaxing does not fix.
|
||||
const MAX_RELAXED: usize = 20;
|
||||
|
||||
/// The Raspberry Pi 5 enclosure analysis design (base, lid, heat sink, board, chips, air).
|
||||
const PI5: &str = include_str!("../../engine/tests/designs/pi5-enclosure.json");
|
||||
/// The cup of the projection mesher's tests (#418): a Ø40 × 30 cylinder with a 4 mm floor and
|
||||
/// wall, and a box handle joined to its side.
|
||||
const CUP: &str = r#"[
|
||||
{"command": "solid.cylinder", "params": {"radius": 20, "height": 30, "body_name": "Cup"}},
|
||||
{"command": "solid.cylinder", "params": {"base": [0, 0, 4], "radius": 16, "height": 27, "operation": "cut", "targets": ["Cup"]}},
|
||||
{"command": "solid.box", "params": {"length": 11, "width": 6, "height": 16, "corner": [19, -3, 8], "operation": "new", "body_name": "Handle"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 8, "corner": [18, -4, 12], "operation": "cut", "targets": ["Handle"]}},
|
||||
{"command": "solid.combine", "params": {"target": "Cup", "tools": ["Handle"], "operation": "join"}}
|
||||
]"#;
|
||||
const PLAIN_CUP: &str = r#"[
|
||||
{"command": "solid.cylinder", "params": {"radius": 20, "height": 30, "body_name": "Cup"}},
|
||||
{"command": "solid.cylinder", "params": {"base": [0, 0, 4], "radius": 16, "height": 27, "operation": "cut", "targets": ["Cup"]}}
|
||||
]"#;
|
||||
|
||||
/// Why a part fails the skill's limits today, read from its numbers and its section picture.
|
||||
/// Shown only while the part fails.
|
||||
const CAUSES: &[(&str, &str)] = &[
|
||||
(
|
||||
"bent-tube",
|
||||
"the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.",
|
||||
),
|
||||
(
|
||||
"filleted-bracket",
|
||||
"no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).",
|
||||
),
|
||||
(
|
||||
"loft-transition",
|
||||
"the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).",
|
||||
),
|
||||
(
|
||||
"pinhole-block",
|
||||
"the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.",
|
||||
),
|
||||
(
|
||||
"sharp-wedge",
|
||||
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.",
|
||||
),
|
||||
("sphere-on-plate", "no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked)."),
|
||||
(
|
||||
"analytic-sphere",
|
||||
"the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).",
|
||||
),
|
||||
(
|
||||
"analytic-torus",
|
||||
"the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.",
|
||||
),
|
||||
(
|
||||
"crank-arm",
|
||||
"the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.",
|
||||
),
|
||||
(
|
||||
"cross-cylinders",
|
||||
"the boundary tessellation of the joined cylinders is invalid (inconsistent orientation at the two singular points), so every mesher refuses the body.",
|
||||
),
|
||||
];
|
||||
|
||||
/// A part the skill is run on.
|
||||
pub struct Case {
|
||||
pub name: String,
|
||||
/// Where it comes from and what it is.
|
||||
pub about: String,
|
||||
/// Its `.solidcraft` bytes.
|
||||
pub design: Vec<u8>,
|
||||
/// The bodies meshed (none: every body of the design).
|
||||
pub bodies: Vec<String>,
|
||||
/// The bodies are cells of one part (split along shared faces), joined by merging nodes.
|
||||
pub cells: bool,
|
||||
}
|
||||
|
||||
fn script(commands: &str) -> Result<Vec<u8>, String> {
|
||||
let wrapped = if commands.trim_start().starts_with('[') { format!("{{\"commands\": {commands}}}") } else { commands.to_string() };
|
||||
fieldcraft_geometry::script_design(&wrapped).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The corpus parts, then the samples' designs that are not corpus parts.
|
||||
pub fn cases() -> Result<Vec<Case>, String> {
|
||||
let mut out = Vec::new();
|
||||
for part in corpus::parts().map_err(|e| e.to_string())? {
|
||||
let design = corpus::design_bytes(&part).map_err(|e| e.to_string())?;
|
||||
let cells = part.categories.iter().any(|c| c == "partitioned");
|
||||
out.push(Case { name: part.name.clone(), about: format!("corpus: {}", part.description), design, bodies: Vec::new(), cells });
|
||||
}
|
||||
use fieldcraft_engine::sample;
|
||||
let samples: [(&str, &str, &str, &[&str]); 6] = [
|
||||
("crank-arm", "sample `crank-arm`: the 170 mm bicycle crank, mirror symmetric across y = 0", sample::CRANK_ARM, &[]),
|
||||
("upright-bracket", "samples `drop-test`, `flow-past`: the upright L-bracket", sample::UPRIGHT_BRACKET, &[]),
|
||||
("pi5-base", "the Raspberry Pi 5 enclosure's base: a shelled box with fillets and standoffs", PI5, &["Base"]),
|
||||
("pi5-lid", "the Raspberry Pi 5 enclosure's lid", PI5, &["Lid"]),
|
||||
("cup", "the cup with a handle of #418", CUP, &[]),
|
||||
("plain-cup", "the cup without its handle (#418)", PLAIN_CUP, &[]),
|
||||
];
|
||||
for (name, about, commands, bodies) in samples {
|
||||
out.push(Case {
|
||||
name: name.into(),
|
||||
about: about.into(),
|
||||
design: script(commands)?,
|
||||
bodies: bodies.iter().map(|b| b.to_string()).collect(),
|
||||
cells: false,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One part's result.
|
||||
#[derive(Default)]
|
||||
pub struct Outcome {
|
||||
pub method: String,
|
||||
/// Why each earlier method failed, in order.
|
||||
pub failures: Vec<String>,
|
||||
pub symmetry: String,
|
||||
pub geometry_findings: String,
|
||||
pub elements: usize,
|
||||
pub kinds: String,
|
||||
pub min_sj: Option<f64>,
|
||||
pub mean_sj: Option<f64>,
|
||||
pub max_edge_ratio: Option<f64>,
|
||||
pub volume_error: Option<f64>,
|
||||
pub untagged: usize,
|
||||
/// Marked before relaxing, then after.
|
||||
pub marked: (usize, usize),
|
||||
/// The minimum scaled Jacobian and the largest edge ratio before relaxing.
|
||||
pub before: (Option<f64>, Option<f64>),
|
||||
pub relaxed: usize,
|
||||
/// Relaxations undone because they left their region worse.
|
||||
pub undone: usize,
|
||||
pub cracks: String,
|
||||
/// Coincident nodes merged between cells.
|
||||
pub merged: usize,
|
||||
pub seconds: f64,
|
||||
/// The commands that built the final mesh (for the section picture).
|
||||
pub replay: Vec<Value>,
|
||||
/// The section: axis (0..3) and its coordinate.
|
||||
pub section: (usize, f64),
|
||||
}
|
||||
|
||||
/// Run a command, keeping it in `replay` when it succeeds.
|
||||
fn exec(s: &mut Session, replay: &mut Vec<Value>, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
replay.push(json!({"command": id, "params": params}));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn short(e: &str) -> String {
|
||||
let one = e.replace(['\n', '|'], " ");
|
||||
if one.chars().count() > 240 { format!("{}…", one.chars().take(240).collect::<String>()) } else { one }
|
||||
}
|
||||
|
||||
/// Follow the skill on one part, its design written to `path`.
|
||||
pub fn run_case(case: &Case, path: &Path) -> Result<Outcome, String> {
|
||||
std::fs::write(path, &case.design).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let start = Instant::now();
|
||||
let mut s = Session::default();
|
||||
let mut o = Outcome::default();
|
||||
let mut replay = Vec::new();
|
||||
exec(&mut s, &mut replay, "geometry.open", json!({"path": path.to_string_lossy()}))?;
|
||||
|
||||
// §1 Look.
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).map_err(|e| e.to_string())?;
|
||||
let all: Vec<&Value> = bodies.as_array().map(|a| a.iter().collect()).unwrap_or_default();
|
||||
let targets: Vec<&Value> =
|
||||
all.into_iter().filter(|b| case.bodies.is_empty() || case.bodies.iter().any(|n| b["name"].as_str() == Some(n))).collect();
|
||||
if targets.is_empty() {
|
||||
return Err("no body to mesh".into());
|
||||
}
|
||||
let names: Vec<String> = targets.iter().filter_map(|b| b["name"].as_str().map(String::from)).collect();
|
||||
let volume: f64 = targets.iter().filter_map(|b| b["volume"].as_f64()).sum();
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for b in &targets {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(b["bbox"][0][k].as_f64().unwrap_or(f64::INFINITY));
|
||||
hi[k] = hi[k].max(b["bbox"][1][k].as_f64().unwrap_or(f64::NEG_INFINITY));
|
||||
}
|
||||
}
|
||||
// The section across the longest side, through the middle: it cuts the primary features.
|
||||
let axis = (0..3).max_by(|&a, &b| (hi[a] - lo[a]).total_cmp(&(hi[b] - lo[b]))).unwrap_or(0);
|
||||
o.section = (axis, 0.5 * (lo[axis] + hi[axis]));
|
||||
|
||||
let mut findings = Vec::new();
|
||||
let mut methods = Vec::new();
|
||||
let mut symmetry = Vec::new();
|
||||
for body in &names {
|
||||
let _ = s.execute("geometry.features", &json!({"body": body}));
|
||||
// §3 Plan: the size it picks is the size the geometry is checked at (§1.2).
|
||||
let plan = exec(&mut s, &mut replay, "mesh.plan", json!({"body": body}));
|
||||
let size = plan.as_ref().ok().and_then(|p| p["size"].as_f64());
|
||||
if let Ok(c) = s.execute("geometry.check", &json!({"body": body, "size": size})) {
|
||||
let counts: Vec<String> = c["counts"]
|
||||
.as_object()
|
||||
.map(|m| m.iter().filter(|(_, n)| n.as_u64().unwrap_or(0) > 0).map(|(k, n)| format!("{n} {k}")).collect())
|
||||
.unwrap_or_default();
|
||||
if !counts.is_empty() {
|
||||
findings.push(if names.len() > 1 { format!("{body}: {}", counts.join(", ")) } else { counts.join(", ") });
|
||||
}
|
||||
}
|
||||
let mut method = None;
|
||||
match plan {
|
||||
Ok(p) => match exec(
|
||||
&mut s,
|
||||
&mut replay,
|
||||
"mesh.plan.apply",
|
||||
json!({"plan": p["name"].as_str().map_or_else(|| format!("Plan-{body}"), String::from)}),
|
||||
) {
|
||||
Ok(r) => {
|
||||
let skipped = r["skipped_cuts"].as_array().map_or(0, Vec::len);
|
||||
let mesher = r["mesher"].as_str().or_else(|| p["mesher"].as_str()).unwrap_or("?");
|
||||
method = Some(if skipped > 0 { format!("plan ({mesher}, {skipped} cuts skipped)") } else { format!("plan ({mesher})") });
|
||||
if let Some(planes) = r["symmetry"].as_array().filter(|a| !a.is_empty()) {
|
||||
symmetry.push(format!("{} plane(s)", planes.len()));
|
||||
}
|
||||
}
|
||||
Err(e) => o.failures.push(format!("{body}: mesh.plan.apply: {}", short(&e))),
|
||||
},
|
||||
Err(e) => o.failures.push(format!("{body}: mesh.plan: {}", short(&e))),
|
||||
}
|
||||
// §3.4 The plan does not fit: the structured meshers, then tet10 for what is not worth
|
||||
// blocking.
|
||||
if method.is_none() {
|
||||
match exec(&mut s, &mut replay, "mesh.hex.generate", json!({"body": body, "size": size})) {
|
||||
Ok(r) => method = Some(format!("mesh.hex.generate ({})", r["method"].as_str().unwrap_or("auto"))),
|
||||
Err(e) => o.failures.push(format!("{body}: mesh.hex.generate: {}", short(&e))),
|
||||
}
|
||||
}
|
||||
if method.is_none() {
|
||||
match exec(&mut s, &mut replay, "mesh.hex.project", json!({"body": body, "size": size, "template": "sphere"})) {
|
||||
Ok(_) => method = Some("mesh.hex.project (sphere)".into()),
|
||||
Err(e) => o.failures.push(format!("{body}: mesh.hex.project (sphere): {}", short(&e))),
|
||||
}
|
||||
}
|
||||
if method.is_none() {
|
||||
match exec(&mut s, &mut replay, "mesh.tet.generate", json!({"body": body, "size": size, "order": "quadratic"})) {
|
||||
Ok(_) => method = Some("tet10".into()),
|
||||
Err(e) => o.failures.push(format!("{body}: mesh.tet.generate: {}", short(&e))),
|
||||
}
|
||||
}
|
||||
methods.push(method.unwrap_or_else(|| "none".into()));
|
||||
}
|
||||
methods.dedup();
|
||||
o.method = methods.join(", ");
|
||||
o.symmetry = if symmetry.is_empty() { "–".into() } else { symmetry.join(", ") };
|
||||
o.geometry_findings = if findings.is_empty() { "–".into() } else { findings.join("; ") };
|
||||
if s.model.elements().is_empty() {
|
||||
o.seconds = start.elapsed().as_secs_f64();
|
||||
return Ok(o);
|
||||
}
|
||||
|
||||
// §6.5 Cells of one part meshed one by one join by merging their coincident nodes (separate
|
||||
// parts are tied instead, never merged).
|
||||
if case.cells {
|
||||
let r = exec(&mut s, &mut replay, "mesh.node.merge", json!({}))?;
|
||||
o.merged = r["merged"].as_u64().unwrap_or(0) as usize;
|
||||
}
|
||||
|
||||
// §6 Quality, mark, relax.
|
||||
let before = s.execute("mesh.quality", &json!({})).map_err(|e| e.to_string())?;
|
||||
o.before = (before["metrics"]["scaled_jacobian"]["min"].as_f64(), worst_ratio(&before));
|
||||
let mark_params = json!({"limits": {"scaled_jacobian": MIN_SCALED_JACOBIAN, "edge_ratio": MAX_EDGE_RATIO}});
|
||||
let marked = exec(&mut s, &mut replay, "mesh.quality.mark", mark_params.clone())?;
|
||||
let labels: Vec<u64> = marked["elements"].as_array().map(|a| a.iter().filter_map(|e| e["label"].as_u64()).collect()).unwrap_or_default();
|
||||
o.marked.0 = marked["marked"].as_u64().unwrap_or(0) as usize;
|
||||
if o.marked.0 > 0 && o.marked.0 <= MAX_RELAXED {
|
||||
for label in labels.iter().take(MAX_RELAXED) {
|
||||
let params = json!({"around_element": label, "reapply": true});
|
||||
let Ok(r) = s.execute("mesh.node.relax", ¶ms) else { continue };
|
||||
// §6.3: a relaxation that leaves its region worse is undone.
|
||||
let worse = |k: &str, higher_is_better: bool| match (r["before"][k].as_f64(), r["after"][k].as_f64()) {
|
||||
(Some(b), Some(a)) => {
|
||||
if higher_is_better {
|
||||
a < b - 1e-9
|
||||
} else {
|
||||
a > b.max(MAX_EDGE_RATIO) + 1e-9
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if worse("min_scaled_jacobian", true) || worse("max_edge_ratio", false) {
|
||||
s.execute("edit.undo", &json!({})).map_err(|e| e.to_string())?;
|
||||
o.undone += 1;
|
||||
} else {
|
||||
replay.push(json!({"command": "mesh.node.relax", "params": params}));
|
||||
o.relaxed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let marked = if o.relaxed + o.undone > 0 { exec(&mut s, &mut replay, "mesh.quality.mark", mark_params)? } else { marked };
|
||||
o.marked.1 = marked["marked"].as_u64().unwrap_or(0) as usize;
|
||||
|
||||
let q = s.execute("mesh.quality", &json!({})).map_err(|e| e.to_string())?;
|
||||
let metric = |m: &str, k: &str| q["metrics"][m][k].as_f64();
|
||||
o.min_sj = metric("scaled_jacobian", "min");
|
||||
o.mean_sj = metric("scaled_jacobian", "mean");
|
||||
o.max_edge_ratio = worst_ratio(&q);
|
||||
o.volume_error = q["volume"].as_f64().filter(|_| volume > 0.0).map(|v| (v - volume) / volume);
|
||||
o.elements = s.model.elements().len();
|
||||
let mut kinds = std::collections::BTreeMap::<String, usize>::new();
|
||||
for e in s.model.elements().values() {
|
||||
*kinds.entry(format!("{:?}", e.kind).to_lowercase()).or_default() += 1;
|
||||
}
|
||||
o.kinds = kinds.iter().map(|(k, n)| format!("{n} {k}")).collect::<Vec<_>>().join(", ");
|
||||
o.untagged = fieldcraft_engine::cmd::untagged_faces(&s, &names).len();
|
||||
o.cracks = match s.execute("mesh.check.duplicates", &json!({})) {
|
||||
Ok(d) => {
|
||||
let n = |k: &str| d[k].as_array().map_or(0, Vec::len);
|
||||
let (coincident, cracks) = (n("coincident"), n("cracks"));
|
||||
if coincident + cracks == 0 { "none".into() } else { format!("{coincident} coincident, {cracks} cracks") }
|
||||
}
|
||||
Err(e) => short(&e.to_string()),
|
||||
};
|
||||
o.seconds = start.elapsed().as_secs_f64();
|
||||
o.replay = replay;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// The largest edge ratio of a quality reply (hex, wedge), else the largest aspect ratio (tet,
|
||||
/// which has no edge ratio).
|
||||
fn worst_ratio(q: &Value) -> Option<f64> {
|
||||
q["metrics"]["edge_ratio"]["max"].as_f64().or_else(|| q["metrics"]["aspect_ratio"]["max"].as_f64())
|
||||
}
|
||||
|
||||
fn num(v: Option<f64>, digits: usize) -> String {
|
||||
v.map_or("–".into(), |x| format!("{x:.digits$}"))
|
||||
}
|
||||
|
||||
/// Whether the part meets the skill's limits.
|
||||
fn verdict(o: &Outcome) -> String {
|
||||
let (Some(sj), Some(ratio)) = (o.min_sj, o.max_edge_ratio) else { return "no mesh".into() };
|
||||
let mut failed = Vec::new();
|
||||
if sj < MIN_SCALED_JACOBIAN {
|
||||
failed.push("SJ");
|
||||
}
|
||||
if ratio > MAX_EDGE_RATIO {
|
||||
failed.push("ratio");
|
||||
}
|
||||
if o.volume_error.is_some_and(|v| v.abs() > MAX_VOLUME_ERROR) {
|
||||
failed.push("volume");
|
||||
}
|
||||
match (failed.is_empty(), o.method.starts_with("plan")) {
|
||||
(false, _) => format!("fail ({})", failed.join(", ")),
|
||||
(true, true) => "pass".into(),
|
||||
(true, false) => "pass (fallback)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the skill on every case (or the one named `only`), write the replay scripts and the
|
||||
/// picture list to `work`, and the results to [`FILE`] unless `only` is given or `write` is
|
||||
/// false. The picture list (`pictures.json`) is what `cargo xtask skill-results` draws with
|
||||
/// `fieldcraft-cli snapshot`.
|
||||
pub fn run(root: &Path, work: &Path, only: Option<&str>, write: bool) -> Result<(), String> {
|
||||
std::fs::create_dir_all(work).map_err(|e| format!("{}: {e}", work.display()))?;
|
||||
let work = &std::fs::canonicalize(work).map_err(|e| format!("{}: {e}", work.display()))?;
|
||||
let mut cases = cases()?;
|
||||
if let Some(name) = only {
|
||||
cases.retain(|c| c.name == name);
|
||||
if cases.is_empty() {
|
||||
return Err(format!("no part named `{name}`"));
|
||||
}
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
let mut pictures = Vec::new();
|
||||
for case in &cases {
|
||||
eprintln!("skill: {}", case.name);
|
||||
let design: PathBuf = work.join(format!("{}.solidcraft", case.name));
|
||||
let o = match run_case(case, &design) {
|
||||
Ok(o) => o,
|
||||
Err(e) => Outcome { method: "none".into(), failures: vec![short(&e)], ..Outcome::default() },
|
||||
};
|
||||
for f in &o.failures {
|
||||
eprintln!("skill: {}: {f}", case.name);
|
||||
}
|
||||
let verdict = verdict(&o);
|
||||
let picture = if o.replay.is_empty() {
|
||||
"–".to_string()
|
||||
} else {
|
||||
let replay = work.join(format!("{}.json", case.name));
|
||||
std::fs::write(&replay, Value::Array(o.replay.clone()).to_string()).map_err(|e| format!("{}: {e}", replay.display()))?;
|
||||
let png = format!("{PICTURES}/{}.png", case.name);
|
||||
let section = format!("{}={:.3}", ["x", "y", "z"][o.section.0], o.section.1);
|
||||
pictures.push(json!({"script": replay.to_string_lossy(), "out": png, "section": section}));
|
||||
format!("[{section}](skill/{}.png)", case.name)
|
||||
};
|
||||
rows.push(vec![
|
||||
case.name.clone(),
|
||||
o.method.clone(),
|
||||
o.symmetry.clone(),
|
||||
if o.elements > 0 { format!("{} ({})", o.elements, o.kinds) } else { "–".into() },
|
||||
num(o.min_sj, 3),
|
||||
num(o.mean_sj, 3),
|
||||
num(o.max_edge_ratio, 1),
|
||||
o.volume_error.map_or("–".into(), |v| format!("{v:.1e}")),
|
||||
if o.elements > 0 { o.untagged.to_string() } else { "–".into() },
|
||||
if o.elements == 0 {
|
||||
"–".into()
|
||||
} else if o.relaxed == 0 {
|
||||
let why = if o.undone > 0 { format!("{} relaxations undone: worse", o.undone) } else { "not relaxed".into() };
|
||||
format!("{} ({why})", o.marked.0)
|
||||
} else {
|
||||
format!(
|
||||
"{} → {} ({} relaxed, {} undone; min SJ {} → {}, max ratio {} → {})",
|
||||
o.marked.0,
|
||||
o.marked.1,
|
||||
o.relaxed,
|
||||
o.undone,
|
||||
num(o.before.0, 3),
|
||||
num(o.min_sj, 3),
|
||||
num(o.before.1, 1),
|
||||
num(o.max_edge_ratio, 1)
|
||||
)
|
||||
},
|
||||
match (o.cracks.is_empty(), o.merged) {
|
||||
(true, _) => "–".into(),
|
||||
(false, 0) => o.cracks.clone(),
|
||||
(false, m) => format!("{} (after merging {m} nodes)", o.cracks),
|
||||
},
|
||||
o.geometry_findings.clone(),
|
||||
format!("{:.1}", o.seconds),
|
||||
verdict.clone(),
|
||||
picture,
|
||||
]);
|
||||
let cause = CAUSES.iter().find(|(name, _)| *name == case.name).map(|(_, c)| *c).filter(|_| !verdict.starts_with("pass"));
|
||||
if !o.failures.is_empty() || cause.is_some() {
|
||||
let mut note = format!("- `{}` ({}):", case.name, case.about);
|
||||
for f in &o.failures {
|
||||
let _ = write!(note, "\n - {f}");
|
||||
}
|
||||
if let Some(c) = cause {
|
||||
let _ = write!(note, "\n - **{verdict}**: {c}");
|
||||
}
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
let header = [
|
||||
"part",
|
||||
"method",
|
||||
"symmetry",
|
||||
"elements",
|
||||
"min SJ",
|
||||
"mean SJ",
|
||||
"max edge ratio¹",
|
||||
"volume error",
|
||||
"untagged faces",
|
||||
"marked → after relax",
|
||||
"duplicates",
|
||||
"geometry.check",
|
||||
"time s",
|
||||
"verdict",
|
||||
"section",
|
||||
];
|
||||
let mut table = format!("| {} |\n|{}\n", header.join(" | "), "---|".repeat(header.len()));
|
||||
for r in &rows {
|
||||
let _ = writeln!(table, "| {} |", r.join(" | "));
|
||||
}
|
||||
println!("{table}");
|
||||
let list = work.join("pictures.json");
|
||||
std::fs::write(&list, Value::Array(pictures).to_string()).map_err(|e| format!("{}: {e}", list.display()))?;
|
||||
if write && only.is_none() {
|
||||
let path = root.join(FILE);
|
||||
std::fs::write(&path, document(&table, ¬es, &cases)).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!("skill: wrote {FILE}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn document(table: &str, notes: &[String], cases: &[Case]) -> String {
|
||||
let mut d = String::from(
|
||||
"# The meshing skill on the corpus and the samples\n\n\
|
||||
Generated by `cargo xtask skill-results`; do not edit. Each part is meshed by following\n\
|
||||
[the meshing skill](../../skills/fieldcraft-meshing/SKILL.md) with today's commands, headless, as\n\
|
||||
an agent over MCP would (`crates/testkit/src/skill.rs`): `geometry.bodies`, `geometry.features`,\n\
|
||||
`mesh.plan` and `geometry.check` at the plan's size, `mesh.plan.apply` (which meshes one cell of a\n\
|
||||
mirror-symmetric body and mirrors it), and where the plan fails the skill's fallbacks in order,\n\
|
||||
`mesh.hex.generate`, `mesh.hex.project` (sphere template) and then `mesh.tet.generate` (tet10);\n\
|
||||
the cells of a split part are joined with `mesh.node.merge`; then `mesh.quality.mark` at the\n\
|
||||
skill's limits (scaled Jacobian ≥ 0.3, edge ratio ≤ 10), `mesh.node.relax` round each marked\n\
|
||||
element when at most 20 are marked (more is a clustered failure: the blocks are wrong, and relaxing\n\
|
||||
is not the fix), `mesh.quality.mark` again, `mesh.quality` and `mesh.check.duplicates`. Sizes are\n\
|
||||
the plan's (1/25 of the body's diagonal, 2 layers through a shell).\n\n\
|
||||
Columns: `method` is what meshed the part (`plan (project|cells|sweep)` is the skill's target,\n\
|
||||
anything else a fallback); `symmetry` the mirror planes `mesh.plan.apply` used; `volume error` the\n\
|
||||
mesh's volume against the geometry's; `untagged faces` B-rep faces no element face is tagged\n\
|
||||
with (a set on them finds nothing); `marked` the elements failing the limits before and after\n\
|
||||
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio\n\
|
||||
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;\n\
|
||||
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and\n\
|
||||
the volume is within 2 % (`pass (fallback)` when a fallback meshed it).\n\
|
||||
The section pictures cut each part through the middle of its longest side, marked elements red.\n\
|
||||
Times are wall clock on the machine that ran it.\n\n## Results\n\n",
|
||||
);
|
||||
d.push_str(table);
|
||||
d.push_str("\n## Why a part fails\n\nEach earlier method's error, in the order the skill tries them, and for a part that fails the\nlimits the cause read from its numbers and section picture.\n\n");
|
||||
d.push_str(¬es.join("\n"));
|
||||
d.push_str("\n\n## Parts\n\n");
|
||||
for c in cases {
|
||||
let _ = writeln!(d, "- `{}`: {}", c.name, c.about);
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod assets;
|
||||
mod icons;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod skill;
|
||||
mod test;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -38,6 +39,10 @@ commands:
|
||||
build every meshing test corpus part (tests/corpus, analytic shapes), tessellate
|
||||
and mesh it, and write the results to docs/meshing/corpus.md (release build);
|
||||
--part NAME --design PATH writes that part's .solidcraft design instead
|
||||
skill-results [--part NAME] [--no-write]
|
||||
mesh every corpus part and sample by following the meshing skill
|
||||
(skills/fieldcraft-meshing) and write docs/meshing/skill-results.md and each
|
||||
part's section picture (docs/meshing/skill/*.png; release build)
|
||||
oracle [--case NAME]... [--bless NAME|all]... [--quick] [--no-write]
|
||||
run the verification and validation cases (tests/oracle, docs/verification.md)
|
||||
against their references and fixtures; write docs/oracle.md after a full run;
|
||||
@@ -90,6 +95,7 @@ fn main() -> ExitCode {
|
||||
c.args(["run", "--release", "-p", "fieldcraft-testkit", "--bin", "corpus", "--"]).args(&rest);
|
||||
run(c, "corpus")
|
||||
}
|
||||
Some("skill-results") => skill::run(&root(), &rest),
|
||||
Some("oracle") => {
|
||||
// A binary of the testkit, which has the engine and SolidCraft scripts for geometry.
|
||||
let mut c = cargo();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! `cargo xtask skill-results [--part NAME] [--no-write]`: the meshing skill on every corpus part
|
||||
//! and sample (the testkit's `skill` binary, which writes docs/meshing/skill-results.md), then
|
||||
//! each part's section picture drawn by `fieldcraft-cli snapshot` from the commands that meshed
|
||||
//! it (docs/meshing/skill/<part>.png).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root.join("target"), Into::into);
|
||||
let work = target.join("skill-results");
|
||||
let work_arg = work.to_string_lossy().into_owned();
|
||||
let mut c = crate::cargo();
|
||||
c.args(["run", "--release", "-p", "fieldcraft-testkit", "--bin", "skill", "--", "--work", &work_arg]).args(rest);
|
||||
crate::run(c, "skill")?;
|
||||
if rest.contains(&"--no-write") {
|
||||
return Ok(());
|
||||
}
|
||||
let mut c = crate::cargo();
|
||||
c.args(["build", "--release", "-p", "fieldcraft-cli"]);
|
||||
crate::run(c, "build fieldcraft-cli")?;
|
||||
let cli = target.join("release").join(if cfg!(windows) { "fieldcraft-cli.exe" } else { "fieldcraft-cli" });
|
||||
let list = work.join("pictures.json");
|
||||
let text = std::fs::read_to_string(&list).map_err(|e| format!("{}: {e}", list.display()))?;
|
||||
let pictures: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", list.display()))?;
|
||||
let dir = root.join("docs/meshing/skill");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for p in &pictures {
|
||||
let (Some(script), Some(out), Some(section)) = (p["script"].as_str(), p["out"].as_str(), p["section"].as_str()) else {
|
||||
return Err(format!("{}: bad entry {p}", list.display()));
|
||||
};
|
||||
let out = root.join(out);
|
||||
let mut c = std::process::Command::new(&cli);
|
||||
c.args(["snapshot", script, "--size", "960x600", "--section", section, "--out"]).arg(&out);
|
||||
crate::run(c, &format!("snapshot {}", out.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user