Files
bherbruck 126e91f95b STEP: one reader: topology-first reading in solidcore-step, step-brep's import deleted (RFC 0003, 1.6) (#14038)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: the moved reader as a solidcore workspace crate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the tube for a torus face winding around its axis

A torus face bounded by two circles about its axis (a half torus between its rims, the fillet
round a bore) was refused: "a face winding around a torus ... without a seam edge". It now gets
a seam as a band on a cylinder or sphere does, a tube circle at a vertex's angle, running from
the loop the face lies above up the tube to the other loop, so on the face's side whichever half
of the tube that is. On the bake-off 9 bodies that were open shells close: torus_ring (4934.802,
its reference), turbine, nist_ctc_05 (both editions), nist_ftc_06 rd, nist_ftc_11 (both),
nist_stc_06, nist_stc_10; every body closed goes 135 -> 145, reference volumes 23 -> 24 of 24.

bench/step/run.py takes a probe-shaped reader (LABEL=probe:COMMAND, one JSON line) for a
library without SolidCraft's CLI, and runs every reader on a file together so they meet the same
load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: short entity names, POLY_LOOP, FACETED_BREP and plain FACE

What solidcore's step-import read and this reader refused:
- Part 21 short names (CRTPNT, FCSRF, PLYLP, ...) read as their long names, in the record's
  parser (step-import's table, #2582). STEP Tools' clip and socks are written that way.
- A POLY_LOOP bound is a closed polygon: one vertex per point and one line edge per side, an edge
  shared by every loop with the same two points.
- A FACETED_BREP is a manifold solid of such faces (calo, cylcub).
- A plain FACE, with no surface, lies on the plane of its loops (Newell's normal, refused where
  the loop leaves the plane by more than the tolerance).

Tests: a faceted tetrahedron closes with 6 shared edges; short names parse to long ones; the
torus band seam from the commit before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: read topology outside representations, lines by their vertices, inner loops by area

Topology-first reading, as solidcore's step-import and step-brep read files (#2583):
- Topology no representation lists (bare geometry files): crates/kernel reads the file's
  unlisted solids, then its unlisted shells and faces (each face a one-face sheet), through
  solidcore_step::topology::roots, in the file's one representation context. A representation's
  own shell and face items stay out (a presentation's are not bodies).
- A LINE edge whose vertices lie off its LINE (a direction or magnitude written wrong) is the
  segment between its vertices, which the faces it bounds share; other curves still refuse a far
  vertex.
- An inner loop written the way its outer loop runs, on a face whose loops close in (u, v), is
  run the other way.
- More Part 21 short names (product structure, representations, units, FACETED_BREP).

Bake-off (319 files, 220 opened by some reader): opened 195 -> 212, every body closed 135 ->
148, reference volumes 24 of 24 (core 22); median time unchanged (0.123 s -> 0.119 s).
kernel-cases/import 35/35; the kernel's step_in, IGES and lump tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a sphere face bounded by vertex loops at its poles is the whole sphere

A face of a sphere whose bounds are vertex loops alone, at its poles (cadmpeg's
ap242_vertex_loop), was refused as "a face without an edge loop". It is the whole sphere: its
loop becomes a meridian from the south pole to the north and back, which the slit completion
keeps as its seam, as a ball bounded by its seam alone already is. Any other face without an
edge loop is still refused. Test: a ball bounded by vertex loops at its poles closes with one
seam edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a face SMLib refuses is named and left out; a face used twice is one piece

Opening a file never fails over faces that cannot be made: the face is left out, the rest come in
as an open body, and the import's warnings name each face left out with why.
- SMLib's refusal of a record face by its index ("flat arrays: face 90: a NURBS form that is not
  its analytic surface") now names the file's face (face #1202), so it is left out like a face
  the reader refuses. NIST CTC 02 (AP203) opens: 480 of its 487 faces as an open body, the 4
  faces 622 mm off their surfaces and the one SMLib refuses named in the warnings.
- A face the solid's shells use more than once (cadmpeg br02: one face as the outer shell and two
  voids) is read once, and its further uses are named as left out.
- One face read alone is the sheet itself (nothing to stitch).

bench/step/run.py counts the files opened with faces left out, from the import warnings.
Test: a face SMLib refuses by its index is named as the file's face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: a solid whose only face cannot be made says which face, why and how far

A file whose one face cannot be made (cadmpeg's pc01, pc03 and tp06 non-coplanar fixtures:
edges 1 to 10 mm off the face's surface) has nothing consistent to open. Its error now reads
"the solid's only face, #30, cannot be made: an edge lies 1 mm off the face's surface" in place of
the count of faces whose pcurves failed. Test: a disk whose circle lies 1.5 mm off its plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: an unclamped B-spline curve is read clamped to its domain

A closed curve written as an unclamped B-spline (uniform knots past its ends, its first points
repeated at its end: cadmpeg's N04 recipes, periodic writers) had no closed-form pcurve on a
plane, so its faces were left out: "no exact pcurve of a B-spline on a plane". It is now read
as the same curve over [knots[p], knots[n]], clamped there by exact knot insertion
(eval::pcurve::segment), as every evaluator and pcurve takes a spline. recipe N04a and N04b open
as 3 closed solids each (5000 and 6000 mm³, as solidcore's step-brep reads them).

Test: a disk bounded by an unclamped periodic quadratic reads, its edge clamped to [0, 4].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move step-brep's export mapping into step-export, its fixtures into solidcore-step (pure git mv)

brep_to_model (a kernel body as step-import's model, for the writer) moves to step-export, which
RFC 0003 item 1.7 measures against SolidCraft's writer; the two STEP fixtures move to
solidcore-step's tests. The move alone, so their history follows them; the next commit deletes
step-brep's import side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete step-brep's STEP import: one STEP reader, solidcore-step

solidcore had two STEP readers; solidcore-step (SolidCraft's, moved in #13979) now reads what
step-brep's topology-first build read, but the faces it would have to build inconsistently
(#14038's gate). step-brep's import side goes: its build, constructor and UV trims (lib.rs,
constructor.rs, pcurves.rs) and their tests. What it kept for the writer, brep_to_model, is
step-export's now (RFC 0003 item 1.7); its round-trip cases that read back through the deleted
importer go with it. The panic-site list drops step-brep's rows and moves brep_to_model's.

Ported, with their names: the Discord fillet operand reads as one closed solid, and a face no
shell lists is a one-face sheet (crates/kernel step_in tests); rod_doubled_edge names the face
of its folded rim (solidcore-step). The fixtures moved with their history. The reading cases of
step-brep's tests, written by Brennen Herbruck (topology_first, issue_2580_winding_faces,
issue_2581_faceted_brep, issue_2583_topology_first, discord_fillet), are covered by
solidcore-step's tests of seams, tori, faceted solids, unlisted topology and the bake-off.

step-import stays for the goldens and step-export until #14123. Core's code for the 7
jack_in_the_box bodies still open (#14130) is at 2a96a046ca.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: port step-brep's import panic fuzz onto solidcore-step

step-brep's tests/panic_fuzz.rs (#2490, by Brennen Herbruck) went with its import. Its cases
come back with their names:
- panic_fuzz_step_import (solidcore-step): the same mutations (truncation, flipped bytes,
  hostile numbers, broken and cyclic references, dropped and repeated lines, swapped entity
  names, deep nesting) of the Discord fillet and tetra fixtures and of primitives this crate's
  writer writes. Each is parsed, read into records, checked as the kernel takes them, made into
  kernel bodies and written again. Also entity_edit_lands_on_an_entity_name, and a check that
  the written bases read back as solids, so the mutations reach the whole path.
- panic_fuzz_step_flat_decode (step-import, whose flat arrays it decodes).

Run: 3 seeds of 20 000 import cases and 5 000 flat cases, 0 findings, 0 hangs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: rustfmt the panic fuzz harness's doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* panic fuzz harness: keep solidcore's formatting where solidcore-step includes it

solidcore-step formats with SolidCraft's rustfmt.toml, which reached the harness through its
#[path] include; the include is #[rustfmt::skip] now, and the harness is solidcore's as on main
but its doc naming the fuzzes that share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP and pcurves: topology short names; a use's interval refused when empty or not finite

From review:
- The record's parser maps the AP203 short names of the topology too (ADVFC, EDGCRV, EDGLP,
  FCBND, MNSLBR, ORNEDG, VRTPNT): a file written with them had its faces refused.
- solidcore::eval::pcurve refuses a use whose interval is empty, reversed or not finite before
  anything else: a given pcurve was checked against the interval, which a NaN end passed, so
  exact_pcurves returned it as a pcurve. Tests: those intervals are Invalid with and without a
  given pcurve; VRTPNT and FCBND read as their long names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore-record: short names checked against ISO's short-names file

Every pair of the table is as http://standards.iso.org/iso/10303/tech/short_names/short-names.txt
gives it, the source cited beside it. One was wrong, from step-import's table: CMCRSG is
COMPOSITE_CURVE_SEGMENT, not COMPOSITE_CURVE (CMPCRV, added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: solidcraft-step is now solidcore-step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pcurve docs: a pcurve without a closed form is refused, not fitted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP import: SMLib's first refusal names the file's face, on a turned sheet too

A solid SMLib refused by a face's record index ("flat arrays: face 12") had that index named as a file face only on the reads that left faces out, not the first, so where every face read alone nothing was left out and the faces were stitched instead. solid_part names it, with the face order of the record SMLib was given (a sheet's turned faces reorder it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench/step: a reader or corpus file that cannot be run is a crashed row, not a stopped run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:21:45 -05:00

197 lines
9.9 KiB
Python
Vendored

#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""STEP reader bake-off: run one or more readers over the corpus fetch.sh built.
Each reader is LABEL=COMMAND, run as `COMMAND eval FILE --json-out OUT` and read as
`solidcraft-cli eval` writes it (`bodies`: objects with `volume_mm3`, `faces` and `closed`; older
builds name `closed` `solid`, which is read in its place), or
LABEL=probe:COMMAND, run as `COMMAND FILE` and printing one JSON object with `status`
("opened"/"refused"), `bodies`, `solids`, `faces` and `volume` (a reader without SolidCraft's
CLI, such as a probe over another library). A `.p21` file is passed as a `.step` copy.
bench/step/run.py --corpus bench/step/corpus --out /tmp/bake \\
--reader base=/path/to/old/solidcraft-cli --reader new=/path/to/new/solidcraft-cli
Writes OUT/per-file.tsv (one row per file, one column group per reader) and OUT/summary.md:
files opened (at least one body), refused, timed out, every body closed, reference volumes matched
(references.tsv, within 0.1%), and wall time per opened file (median, 90th percentile, slowest).
Wall time includes process start, so treat it beside the counts, not as a profile.
"""
import argparse
import concurrent.futures
import json
import os
import shlex
import shutil
import statistics
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
def run_probe(command, local, tmp, timeout):
start = time.monotonic()
try:
p = subprocess.run(command + [local], capture_output=True, timeout=timeout, cwd=tmp)
except subprocess.TimeoutExpired:
return {"status": "timeout", "wall_s": timeout}
except OSError as e:
return {"status": "crashed", "wall_s": time.monotonic() - start, "error": str(e)[:200]}
wall = time.monotonic() - start
try:
data = json.loads(p.stdout.decode("utf-8", "replace").strip().splitlines()[-1])
except (ValueError, IndexError):
return {"status": "crashed", "wall_s": wall, "error": p.stderr.decode("utf-8", "replace")[-200:]}
try:
if data.get("status") != "opened" or not data.get("bodies"):
return {"status": "refused", "wall_s": wall, "error": str(data.get("error", "no bodies"))[:200]}
counts = {"bodies": int(data["bodies"]), "closed": int(data.get("solids") or 0), "faces": int(data.get("faces") or 0)}
volume = float(data.get("volume") or 0.0)
except (TypeError, ValueError, AttributeError):
return {"status": "refused", "wall_s": wall, "error": "bad json"}
return {"status": "opened", **counts, "volume": volume, "wall_s": wall}
def run_one(label, command, path, timeout):
with tempfile.TemporaryDirectory() as tmp:
name = os.path.basename(path)
if name.lower().endswith(".p21"):
name = name[:-4] + ".step"
local = os.path.join(tmp, name)
try:
shutil.copyfile(path, local)
except OSError as e:
return {"status": "crashed", "wall_s": 0.0, "error": f"copy: {e}"[:200]}
if command and command[0].startswith("probe:"):
return run_probe([command[0][len("probe:"):]] + command[1:], local, tmp, timeout)
out = os.path.join(tmp, "out.json")
start = time.monotonic()
try:
p = subprocess.run(command + ["eval", local, "--json-out", out], capture_output=True, timeout=timeout, cwd=tmp)
except subprocess.TimeoutExpired:
return {"status": "timeout", "wall_s": timeout}
except OSError as e:
return {"status": "crashed", "wall_s": time.monotonic() - start, "error": str(e)[:200]}
wall = time.monotonic() - start
try:
with open(out) as f:
data = json.load(f)
except (OSError, ValueError):
err = (p.stderr.decode("utf-8", "replace").strip().splitlines() or ["no output"])[-1]
return {"status": "crashed" if p.returncode < 0 else "refused", "wall_s": wall, "error": err[:200]}
bodies = data.get("bodies") or []
if not bodies:
return {"status": "refused", "wall_s": wall, "error": "no bodies"}
try:
vol = sum(float(b.get("volume_mm3") or 0.0) for b in bodies)
faces = sum(int(b.get("faces") or 0) for b in bodies)
closed = sum(1 for b in bodies if b.get("closed", b.get("solid")) is True)
# Faces the import left out, as its warnings name them ("left out: face #…").
warnings = " ".join(str(t.get("warning") or "") for t in data.get("timeline") or [])
except (TypeError, ValueError, AttributeError):
return {"status": "refused", "wall_s": wall, "error": "bad json"}
left_out = warnings.count("left out:")
return {"status": "opened", "bodies": len(bodies), "closed": closed, "faces": faces, "volume": vol, "wall_s": wall, "left_out": left_out}
def references():
refs = {}
with open(os.path.join(HERE, "references.tsv")) as f:
for line in f:
if line.startswith("#") or not line.strip():
continue
sha, vol, _ = line.rstrip("\n").split("\t", 2)
refs[sha] = float(vol)
return refs
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--corpus", default=os.path.join(HERE, "corpus"))
ap.add_argument("--out", required=True)
ap.add_argument("--reader", action="append", required=True, help="LABEL=COMMAND")
ap.add_argument("--timeout", type=float, default=120.0)
ap.add_argument("--jobs", type=int, default=4)
a = ap.parse_args()
readers = []
for r in a.reader:
label, sep, cmd = r.partition("=")
if not sep or not label or not cmd.strip():
ap.error(f"--reader needs LABEL=COMMAND, got {r!r}")
readers.append((label, shlex.split(cmd)))
with open(os.path.join(a.corpus, "manifest.tsv")) as f:
files = [line.rstrip("\n").split("\t") for line in f.readlines()[1:]]
refs = references()
results = {}
with concurrent.futures.ThreadPoolExecutor(a.jobs) as pool:
# File by file, every reader on a file together, so that each meets the same load.
futs = {pool.submit(run_one, label, cmd, os.path.join(a.corpus, rel), a.timeout): (label, rel) for _, _, rel, _ in files for label, cmd in readers}
for fut in concurrent.futures.as_completed(futs):
results[futs[fut]] = fut.result()
os.makedirs(a.out, exist_ok=True)
cols = ["status", "bodies", "closed", "faces", "volume", "wall_s", "left_out", "error"]
with open(os.path.join(a.out, "per-file.tsv"), "w") as f:
f.write("\t".join(["file", "section", "ref_volume"] + [f"{l}:{c}" for l, _ in readers for c in cols]) + "\n")
for sha, section, rel, _ in files:
row = [rel, section, str(refs.get(sha, ""))]
for label, _ in readers:
r = results[(label, rel)]
row += [str(r.get(c, "")) for c in cols]
f.write("\t".join(row) + "\n")
# Files any reader opens: the rest hold no importable solid for anyone.
live = [(sha, rel) for sha, _, rel, _ in files if any(results[(l, rel)]["status"] == "opened" for l, _ in readers)]
lines = [f"# STEP reader bake-off\n", f"{len(files)} files; {len(live)} opened by at least one reader; timeout {a.timeout:g} s, {a.jobs} at once.\n",
"| | " + " | ".join(l for l, _ in readers) + " |", "|---|" + "---|" * len(readers)]
def row(name, fn):
lines.append(f"| {name} | " + " | ".join(str(fn(l)) for l, _ in readers) + " |")
def of(l, st):
return [rel for _, rel in live if results[(l, rel)]["status"] == st]
row("opened", lambda l: len(of(l, "opened")))
row("refused", lambda l: len(of(l, "refused")))
row("timed out", lambda l: len(of(l, "timeout")))
row("crashed", lambda l: len(of(l, "crashed")))
row("opened with faces left out (named in its warnings)", lambda l: sum(1 for rel in of(l, "opened") if results[(l, rel)].get("left_out")))
row("opened, every body closed", lambda l: sum(1 for rel in of(l, "opened") if results[(l, rel)]["closed"] == results[(l, rel)]["bodies"]))
ref_live = [(sha, rel) for sha, rel in live if sha in refs]
def matched(l):
n = 0
for sha, rel in ref_live:
r = results[(l, rel)]
if r["status"] == "opened" and abs(r["volume"] - refs[sha]) <= 1e-3 * abs(refs[sha]):
n += 1
return f"{n} of {len(ref_live)}"
row("reference volume within 0.1%", matched)
def times(l):
return sorted(results[(l, rel)]["wall_s"] for rel in of(l, "opened"))
row("median wall time (s)", lambda l: f"{statistics.median(times(l)):.3f}" if times(l) else "-")
row("90th percentile (s)", lambda l: f"{times(l)[int(0.9 * (len(times(l)) - 1))]:.2f}" if times(l) else "-")
row("slowest (s)", lambda l: f"{times(l)[-1]:.2f}" if times(l) else "-")
if len(readers) == 2:
(la, _), (lb, _) = readers
lines.append("")
for name, x, y in ((f"Opened by {la} only", la, lb), (f"Opened by {lb} only", lb, la)):
only = [rel for _, rel in live if results[(x, rel)]["status"] == "opened" and results[(y, rel)]["status"] != "opened"]
lines.append(f"{name} ({len(only)}): " + (", ".join(f"`{r}`" for r in only) or "none"))
lines.append("")
both = [rel for _, rel in live if results[(la, rel)]["status"] == results[(lb, rel)]["status"] == "opened"]
diff = [rel for rel in both if abs(results[(la, rel)]["volume"] - results[(lb, rel)]["volume"]) > 1e-3 * max(abs(results[(la, rel)]["volume"]), 1e-9)]
lines.append(f"Opened by both with volumes more than 0.1% apart ({len(diff)} of {len(both)}): " + (", ".join(f"`{r}`" for r in diff) or "none"))
with open(os.path.join(a.out, "summary.md"), "w") as f:
f.write("\n".join(lines) + "\n")
print("\n".join(lines))
if __name__ == "__main__":
sys.exit(main())