IGES reader checked against Fusion's IGES exports: 75 of 76 reference parts read back exactly; cone tips read whole

`cargo xtask step-corpus --iges` (and `solvecraft-cli step-corpus --iges`) opens every oracle part.igs as File → Open does and compares body count, volume, area and face count with Fusion's measurements, writing docs/iges-import.md. The run found faces closing at an apex (a drill point's cone, a sphere's pole: the seam runs out to the apex and straight back) kept whole and meshed wrongly; they are now split along the iso-line opposite the seam like bands are (18-hole-features and 40-box-sphere-torus pass). The one failure left, 50-sweep-tight-bend, is written by Fusion as five loose, partly degenerate surfaces that Fusion cannot read back as a solid either; documented in the report. Test: a drilled blind hole round-trips through IGES.

Fixes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-10 05:56:53 +00:00
1 parent 72bc6fb010
commit a7fe0429f0
6 files changed
+214 -18

No files matched your search

+31 -13
View File
@@ -23,9 +23,10 @@ commands:
xtask/data/fusion-catalog.tsv from plan/fusion/menu-tree.json)
oracle replay plan/fusion/oracle/*/recipe.json and compare with measure.json;
writes docs/oracle.md
step-corpus [DIR]
step-corpus [--iges] [DIR]
import every DIR/*/part.step (default plan/fusion/oracle) and compare
with measure.json; writes docs/step-import.md
with measure.json; writes docs/step-import.md (--iges: part.igs,
docs/iges-import.md)
book [--no-build]
generate docs/book's command reference and screenshots, then mdbook build
into target/book
@@ -44,7 +45,7 @@ fn main() -> ExitCode {
Some("assets") => assets::run(&root()),
Some("parity") => parity::run(&root(), rest.contains(&"--refresh")),
Some("oracle") => cmd_oracle(),
Some("step-corpus") => cmd_step_corpus(rest.first().copied()),
Some("step-corpus") => cmd_step_corpus(rest.iter().find(|a| !a.starts_with("--")).copied(), rest.contains(&"--iges")),
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
Some("ci") => cmd_ci(),
@@ -216,20 +217,24 @@ fn cmd_wasm() -> Result<(), String> {
/// Import every oracle STEP file (exported by Fusion) and compare with Fusion's measurements;
/// write docs/step-import.md.
fn cmd_step_corpus(dir: Option<&str>) -> Result<(), String> {
fn cmd_step_corpus(dir: Option<&str>, iges: bool) -> Result<(), String> {
let dir = dir.map(PathBuf::from).unwrap_or_else(|| root().join("plan/fusion/oracle"));
let file = if iges { "part.igs" } else { "part.step" };
let mut cases: Vec<PathBuf> = std::fs::read_dir(&dir)
.map_err(|e| format!("{}: {e} (the oracle data is local, in plan/fusion/oracle)", dir.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| p.join("part.step").is_file() && p.join("measure.json").is_file())
.filter(|p| p.join(file).is_file() && p.join("measure.json").is_file())
.collect();
cases.sort();
if cases.is_empty() {
return Err(format!("no cases with part.step and measure.json in {}", dir.display()));
return Err(format!("no cases with {file} and measure.json in {}", dir.display()));
}
let mut cmd = cargo();
cmd.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--", "step-corpus", "--json"]);
if iges {
cmd.arg("--iges");
}
for c in &cases {
cmd.arg(c);
}
@@ -241,8 +246,19 @@ fn cmd_step_corpus(dir: Option<&str>) -> Result<(), String> {
let check =
|r: &serde_json::Value, name: &str| r["checks"].as_array().and_then(|c| c.iter().find(|c| c["check"] == name).cloned()).unwrap_or_default();
let num = |v: &serde_json::Value| v.as_f64().map(|x| format!("{x:.3}")).unwrap_or_else(|| "–".into());
let mut md = format!(
"# STEP import\n\n\
let mut md = if iges {
format!(
"# IGES import (Fusion corpus)\n\nGenerated by `cargo xtask step-corpus --iges`. Each case is a reference part exported from Fusion as IGES 5.3 \
(kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.igs` exactly as File → Open does and compares with Fusion's \
measurements of the part it exported: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. \
How IGES is read: see [STEP import](step-import.md#iges). 50-sweep-tight-bend is written by Fusion as five loose, partly degenerate surfaces \
(seams running into slits, a ring given twice); Fusion cannot read it back as a solid either.\n\n**{pass} / {} files pass.**\n\n\
| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n",
list.len()
)
} else {
format!(
"# STEP import\n\n\
SolveCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its own reader in `crates/kernel/src/step_in` \
(written from the public standards; no third-party STEP code). A STEP file becomes an **Import** base feature on the \
timeline: it holds the file's B-rep (the STEP text is kept in the design, so the design reopens without the file) and \
@@ -270,8 +286,9 @@ units; colours (314 and the predefined numbers); body names (name properties, el
offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as \
a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.\n\n\
## Fusion corpus\n\nGenerated by `cargo xtask step-corpus`. Each case is a part exported from Fusion as STEP (AP214; kept locally in `plan/fusion/oracle/`, never committed). SolveCraft opens `part.step` exactly as File → Open does (an Import base feature) and compares with Fusion's own measurements: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file.\n\n**{pass} / {} files pass.**\n\n| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n",
list.len()
);
list.len()
)
};
for r in &list {
let status = if r["pass"].as_bool().unwrap_or(false) { "pass" } else { "FAIL" };
let (v, a, f) = (check(r, "volume_mm3"), check(r, "area_mm2"), check(r, "faces"));
@@ -294,9 +311,10 @@ a manifold solid B-rep on the same surfaces a STEP export uses, with its name an
notes.join("; ")
);
}
std::fs::write(root().join("docs/step-import.md"), md).map_err(|e| format!("docs/step-import.md: {e}"))?;
println!("step-corpus: {pass}/{} files pass → docs/step-import.md", list.len());
if pass == list.len() { Ok(()) } else { Err(format!("{} STEP file(s) failed", list.len() - pass)) }
let out_md = if iges { "docs/iges-import.md" } else { "docs/step-import.md" };
std::fs::write(root().join(out_md), md).map_err(|e| format!("{out_md}: {e}"))?;
println!("step-corpus: {pass}/{} files pass → {out_md}", list.len());
if pass == list.len() { Ok(()) } else { Err(format!("{} file(s) failed", list.len() - pass)) }
}
fn cmd_ci() -> Result<(), String> {