feat(occt,kernel,cli): deterministic STEP export and opaque STEP import

M5. Round-trip fidelity is exact on the sample part — volume and area at
relative error 0, face count preserved, units MM by construction.

FIRST MILESTONE TO TOUCH build.rs. STEP needs TKDESTEP, TKDE and TKXSBase, none
of which the earlier milestones linked. OCCT 7.9 renamed the data-exchange
toolkits, so the TKSTEP the roadmap named does not exist here; the set above is
what a linked probe found actually resolves. Native libraries already installed,
not new Rust crates — flagged because CLAUDE.md's rule about dependencies exists
for a reason.

DETERMINISM took real work. OCCT's writer varies in two fields. FILE_NAME's
wall-clock timestamp is the obvious one and APIHeaderSection_MakeHeader pins it.
The other is a process-global counter in the PRODUCT entity — 'VernierCAD 1',
'VernierCAD 2' — that no Interface_Static parameter reaches;
write.step.product.name sets the base and not the counter, so pinning it looks
like it worked and does not. The facade normalizes that field out of the written
text, and deliberately not blindly: it counts what it rewrites, requires a pure
numeric suffix, and returns kUnexpected if the shape of the output is not what it
expects, so an OCCT upgrade breaks loudly instead of quietly restoring
non-determinism no test would notice.

An IMPORT IS AN OPAQUE BODY, stated in the API and asserted by what the gate does
NOT claim. A STEP file carries no VernierCAD entity ids and nothing in it is a
stable topological identity across two imports, so an imported shape has no
parametric history and no feature may reference its faces — claiming otherwise
would be invariant #4's exact prohibition.

Also fixed here because STEP surfaced it: OCCT narrates to stdout, and
vernier-cli's JSON report lives on stdout, so an unmuzzled STEP call corrupted
the machine-readable output the whole verification path depends on. Caught by
json_report_file_matches_stdout, which is what that test is for. The guard is
scoped and restoring: global muting would discard diagnostics everywhere else,
and a non-restoring guard would leave the process silent after a throw.

Mutation-verified: disabling the normalization fails m5-step with 'two exports of
one shape differ ... first difference at byte 626'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 06:33:48 +02:00
co-authored by Claude Opus 5
parent 63efedb584
commit e575b30d1f
8 changed files with 483 additions and 0 deletions
+25
View File
@@ -39,6 +39,31 @@ next session's first item. Recorded Phase 5 deviations: a shelled-away
face *continues* as its opening's rim ring (recorded OCCT history, probe-verified — a
fillet on the old top edge stays on the outer top edge); patterns repeat extrude features
only and cap at 64 instances (per-instance naming slots live in a `u8`).
**M5 — STEP import/export — landed 2026-08-11.** `export_step` / `import_step` in the façade,
`OcctKernel::export_step` / `import_step` above them, and the `m5-step` gate. Round-trip
fidelity is exact — the sample part's volume and area come back at relative error 0, face count
preserved, units MM by construction (invariant #8).
**M5 is the first milestone to touch `build.rs`**, and it needs three OCCT toolkits the earlier
milestones did not: `TKDESTEP`, `TKDE`, `TKXSBase`. OCCT 7.9 renamed the data-exchange
libraries, so the older `TKSTEP` name in the roadmap does not exist here — the set above is what
a linked probe found actually resolves. These are native libraries already installed, not new
Rust crates.
**Determinism (invariant #1) needed real work and the fix is load-bearing.** OCCT's STEP writer
is not deterministic: `FILE_NAME` carries a wall-clock timestamp, pinned through
`APIHeaderSection_MakeHeader`, and the `PRODUCT` entity carries a *process-global counter* that
no `Interface_Static` parameter reaches (`write.step.product.name` sets the base, not the
counter). The façade normalizes that one field out of the written text — counting what it
rewrites and returning `kUnexpected` if the pattern is not what it expects, so an OCCT upgrade
must break loudly rather than quietly restoring non-determinism no test would notice. Verified
by mutation: disabling the normalization fails `m5-step` with "two exports of one shape differ".
Recorded M5 deviations: **an imported body is opaque** — a STEP file carries no VernierCAD
entity ids and nothing in it is a stable topological identity across two imports, so an import
has no parametric history and no feature may reference its faces (invariants #4/#5); it is
geometry to look at, measure, tessellate and export again. There is **no timeline feature and no
shell wiring** for either direction yet — both are kernel-level capabilities with a headless
gate, and import in particular has no honest place in the feature DAG until the opacity question
above is answered. And the STEP calls run under a scoped **messenger guard**, because OCCT
narrates to stdout and `vernier-cli`'s JSON report lives there (see MISTAKES.md).
**M4 — loft — kernel half landed 2026-08-10; the document half is below.**
`make_loft` → `evaluate_loft` lofts N ≥ 2 closed sections into a ruled solid, keyed by a new
provenance kind: `ProvenanceKey::Bridged { sources: BTreeSet<EntityId>, sibling }`, because a
+25
View File
@@ -166,3 +166,28 @@ These are documented in advance because they are near-certain and expensive.
the output *differ*, or every test passes whichever side the code reads.
- **The general shape:** ask of any fixture, "what would this test do if the code read the other
one?" If the answer is "pass", the fixture is the bug.
### OCCT's STEP writer is non-deterministic and talks to stdout (2026-08-11)
- **Symptom, one:** exporting the same shape twice produced files of identical length that
differed at byte 626. **Symptom, two:** adding STEP export broke two *unrelated* CLI
integration tests — `json_report_file_matches_stdout` and `selftest_output_is_deterministic`.
- **Cause, one:** two fields vary. `FILE_NAME` carries a wall-clock timestamp, which is the
obvious one and is fixable through `APIHeaderSection_MakeHeader`. The non-obvious one is that
the `PRODUCT` entity carries a **process-global counter** — `'VernierCAD 1'`, `'VernierCAD 2'`
— that increments per transfer and that no `Interface_Static` parameter reaches.
`write.step.product.name` sets the *base* and not the counter, so pinning it looks like it
worked and does not.
- **Cause, two:** `STEPControl_Writer`/`Reader` narrate to OCCT's default messenger, which
prints to **stdout**. `vernier-cli` writes its JSON report to stdout, so a STEP call inside a
selftest check corrupted the machine-readable output the whole verification path depends on.
The fix is a scoped, restoring guard around the STEP calls — global muting would throw away
diagnostics everywhere else, and a non-restoring one would leave the process permanently
silent after a throw.
- **Wrong hypothesis to skip:** "the timestamp is the non-determinism." It is only half, and
fixing it makes the remaining half *harder* to see, because the file then looks pinned. Diff
two outputs byte-for-byte and read the first difference rather than reasoning about which
fields ought to vary.
- **Second-order lesson:** a new kernel capability can break a test that has nothing to do with
it, by writing to a stream another contract owns. When adding a library call, ask what it
prints and where — not only what it returns.
+137
View File
@@ -123,6 +123,10 @@ pub(crate) fn registry() -> Vec<Check> {
name: "m4-sweep",
run: check_m4_sweep,
},
Check {
name: "m5-step",
run: check_m5_step,
},
]
}
@@ -3681,6 +3685,139 @@ fn check_stl_sample() -> Result<Vec<u8>, String> {
Ok(vernier_tess::write_binary_stl(&mesh))
}
/// The M5 gate: STEP export and import, and the determinism the export
/// owes invariant #1.
///
/// # What this proves
///
/// 1. **Byte-identical export.** OCCT's STEP writer is not deterministic:
/// `FILE_NAME` carries a wall-clock timestamp, and the `PRODUCT` entity
/// carries a *process-global counter* that increments per transfer and
/// that no `Interface_Static` parameter reaches (measured against OCCT
/// 7.9.3). The façade pins the header and normalizes the counter, so
/// two exports of one shape must be identical bytes. This check exports
/// the same body twice and compares — and the selftest harness's own
/// double run compares the artifact again on top of that.
/// 2. **Round-trip fidelity, analytically.** The spacer's volume and
/// surface area come back through STEP unchanged. Measured relative
/// error on this geometry is exactly 0, so the assertion is at the
/// file's standard 1e-9 and has nine orders of headroom rather than
/// being tuned to fit.
/// 3. **An import is an opaque body.** It tessellates, it measures, and it
/// carries no parametric identity — a STEP file has no VernierCAD
/// entity ids and nothing in it is a stable identity across two
/// imports, so claiming one would be invariant #4's exact prohibition.
/// The check asserts the topology survives and stops there, deliberately.
/// 4. **Hostile input fails closed** rather than hanging or panicking:
/// a file that is not STEP, and a path that does not exist.
///
/// Artifact: both volumes, both areas, the exported byte length and its
/// digest, and the re-exported bytes.
fn check_m5_step() -> Result<Vec<u8>, String> {
use vernier_kernel::evaluate::evaluate_sample_part;
let mut kernel = vernier_kernel::OcctKernel::new();
let part = evaluate_sample_part(&mut kernel).map_err(|e| e.to_string())?;
let source_volume = part.summary.geometry.volume;
let source_area = part.summary.geometry.area;
// Per-process scratch directory. The selftest's own integration tests
// run several `vernier-cli` processes at once, and a fixed path made
// them race: one process's export landed between another's write and
// read. The pid never reaches the artifact, so the check stays a pure
// function of the build (the harness's purity rule on `Check`).
let dir = std::env::temp_dir().join(format!("vernier-m5-step-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let first = dir.join("part-a.step");
let second = dir.join("part-b.step");
let first_path = first.to_str().ok_or("non-UTF-8 temp path")?;
let second_path = second.to_str().ok_or("non-UTF-8 temp path")?;
kernel
.export_step(part.shape, first_path)
.map_err(|e| format!("first export failed: {e}"))?;
kernel
.export_step(part.shape, second_path)
.map_err(|e| format!("second export failed: {e}"))?;
let a = std::fs::read(&first).map_err(|e| e.to_string())?;
let b = std::fs::read(&second).map_err(|e| e.to_string())?;
if a != b {
// Name the first difference: a bare "they differ" would send the
// next reader hunting through 15 kB.
let at = a.iter().zip(&b).position(|(x, y)| x != y);
return Err(format!(
"two exports of one shape differ (len {} vs {}, first difference at byte {at:?}) \
— invariant #1",
a.len(),
b.len()
));
}
if a.is_empty() {
return Err("export produced no bytes".to_owned());
}
// The determinism normalization must actually have run: OCCT's counted
// product name must not survive into the file.
let text = String::from_utf8_lossy(&a);
if !text.contains("'VernierCAD'") {
return Err("exported file does not carry the pinned product name".to_owned());
}
if !text.contains("1970-01-01T00:00:00") {
return Err("exported file does not carry the pinned timestamp".to_owned());
}
let imported = kernel
.import_step(first_path)
.map_err(|e| format!("import failed: {e}"))?;
let back = imported.summary;
let rel = |got: f64, want: f64| ((got - want) / want).abs();
if rel(back.geometry.volume, source_volume) > 1e-9 {
return Err(format!(
"round-trip volume {} differs from {source_volume}",
back.geometry.volume
));
}
if rel(back.geometry.area, source_area) > 1e-9 {
return Err(format!(
"round-trip area {} differs from {source_area}",
back.geometry.area
));
}
if back.topology.faces != part.summary.topology.faces {
return Err(format!(
"round-trip face count {} differs from {}",
back.topology.faces, part.summary.topology.faces
));
}
// Hostile input fails closed. A hang here is invariant #6, not merely
// a wrong answer, so this doubles as a liveness check.
let junk = dir.join("not-a-step.step");
std::fs::write(&junk, b"this is not a STEP file\n").map_err(|e| e.to_string())?;
let junk_path = junk.to_str().ok_or("non-UTF-8 temp path")?;
if kernel.import_step(junk_path).is_ok() {
return Err("a non-STEP file imported successfully".to_owned());
}
let missing = dir.join("does-not-exist.step");
let missing_path = missing.to_str().ok_or("non-UTF-8 temp path")?;
if kernel.import_step(missing_path).is_ok() {
return Err("a missing file imported successfully".to_owned());
}
let mut bytes = format!(
"source-volume {source_volume}\nsource-area {source_area}\n\
roundtrip-volume {}\nroundtrip-area {}\nfaces {}\nexport-len {}\n",
back.geometry.volume,
back.geometry.area,
back.topology.faces,
a.len()
)
.into_bytes();
bytes.extend_from_slice(&a);
let _ = std::fs::remove_dir_all(&dir);
Ok(bytes)
}
/// A real OCCT operation runs headlessly and summarizes deterministically —
/// the double-run harness byte-compares actual kernel output. Artifact: the
/// box's canonical golden text.
+35
View File
@@ -142,6 +142,41 @@ impl OcctKernel {
Ok(convert(facade::make_box(dx, dy, dz)?))
}
/// Writes an evaluated shape to `path` as AP214 STEP, millimetres.
///
/// The bytes are deterministic: the same shape exported twice, in one
/// process or in two, produces identical files (invariant #1). OCCT's
/// own writer is not — it embeds a wall-clock timestamp and a
/// process-global counter — so the façade pins the first and
/// normalizes the second, failing loudly rather than shipping bytes it
/// does not recognise.
///
/// # Errors
///
/// [`KernelError`] if the shape is unknown, the path is unwritable, or
/// OCCT refuses the transfer.
pub fn export_step(&self, shape: ShapeId, path: &str) -> Result<(), KernelError> {
facade::export_step(shape.raw(), path)?;
Ok(())
}
/// Reads a STEP file and registers the resulting shape.
///
/// The result is an **opaque body**. A STEP file carries no VernierCAD
/// entity ids, and nothing in it is a stable topological identity
/// across two imports, so the shape has no parametric history and no
/// feature may reference its faces (invariants #4/#5). It is geometry
/// to look at, measure, tessellate and export again — not to build a
/// timeline on.
///
/// # Errors
///
/// [`KernelError`] if the file is missing, unreadable, not STEP, or
/// carries no transferable root.
pub fn import_step(&mut self, path: &str) -> Result<PrimitiveResult, KernelError> {
Ok(convert(facade::import_step(path)?))
}
/// Builds a Z-axis cylinder with its base at Z = 0; dimensions in mm.
///
/// # Errors
+6
View File
@@ -34,6 +34,12 @@ fn main() {
"TKMesh",
"TKFillet",
"TKOffset",
// STEP read/write. OCCT 7.9 renamed the data-exchange toolkits:
// this is TKDESTEP, not the TKSTEP older documentation names.
// These three are what M5's probe found actually resolve.
"TKDESTEP",
"TKDE",
"TKXSBase",
] {
println!("cargo:rustc-link-lib=dylib={toolkit}");
}
@@ -242,4 +242,24 @@ SectionData section_curves(std::uint64_t shape, double px, double py, double pz,
// an unknown handle reports status 2.
std::int32_t release_shape(std::uint64_t shape);
// STEP export. Writes `shape` to `path` as AP214, millimetres.
//
// The bytes are deterministic (invariant #1), which OCCT's writer is not
// on its own: FILE_NAME carries a wall-clock timestamp, pinned here via
// APIHeaderSection_MakeHeader, and the PRODUCT entity carries a
// process-global counter that no Interface_Static parameter reaches, which
// is normalized out of the written text. That normalization counts what it
// rewrites and returns status 4 if the pattern is not what it expects, so
// an OCCT upgrade cannot silently restore non-determinism.
std::int32_t export_step(std::uint64_t shape, rust::Str path);
// STEP import. Reads `path` and registers the single resulting shape.
//
// The result is an OPAQUE BODY. A STEP file carries no VernierCAD entity
// ids, and nothing in it survives as a stable topological identity across
// two imports, so an imported shape has no parametric history and no
// feature may reference its faces (invariants #4/#5). It is geometry to
// look at, measure and export again — not to build a timeline on.
ShapeSummary import_step(rust::Str path);
} // namespace vernier
+198
View File
@@ -65,6 +65,17 @@
#include <algorithm>
#include <cmath>
#include <APIHeaderSection_MakeHeader.hxx>
#include <Interface_Static.hxx>
#include <Message.hxx>
#include <Message_Messenger.hxx>
#include <Message_Printer.hxx>
#include <STEPControl_Controller.hxx>
#include <STEPControl_Reader.hxx>
#include <STEPControl_Writer.hxx>
#include <StepData_StepModel.hxx>
#include <TCollection_HAsciiString.hxx>
#include <fstream>
#include <memory>
#include <mutex>
#include <set>
@@ -2338,4 +2349,191 @@ SectionData section_curves(std::uint64_t shape_handle, double px, double py, dou
}
}
namespace {
// The fixed FILE_NAME timestamp. STEP wants an ISO-8601 instant and OCCT
// writes wall-clock by default, which invariant #1 forbids outright: the
// same part exported twice would differ. The epoch is the honest constant
// — it says "this file carries no time information" rather than asserting
// a false one.
// Silences OCCT's default messenger for the lifetime of the guard.
//
// `STEPControl_Writer` and `STEPControl_Reader` narrate to the default
// messenger, which prints to **stdout**. `vernier-cli` writes its JSON
// report to stdout, so an unmuzzled STEP call corrupts the machine-readable
// output the whole verification path depends on — caught by
// `json_report_file_matches_stdout`, which is exactly what that test is
// for. It is also non-deterministic (entity counts, ANSI colour), so it
// breaks invariant #1 twice over.
//
// Scoped and restoring rather than a global mute: OCCT's diagnostics are
// worth having everywhere else, and a throw inside the STEP call must not
// leave the process permanently silent.
class SilencedMessenger {
public:
SilencedMessenger() : saved_(Message::DefaultMessenger()->Printers()) {
Message::DefaultMessenger()->ChangePrinters().Clear();
}
~SilencedMessenger() { Message::DefaultMessenger()->ChangePrinters() = saved_; }
SilencedMessenger(const SilencedMessenger&) = delete;
SilencedMessenger& operator=(const SilencedMessenger&) = delete;
private:
Message_SequenceOfPrinters saved_;
};
constexpr const char* kStepTimestamp = "1970-01-01T00:00:00";
// The product name we ask OCCT to use. It matters that this is ours and
// not OCCT's default, because normalize_step_product below keys on it.
constexpr const char* kStepProduct = "VernierCAD";
// Rewrites the one field OCCT will not let us pin.
//
// `STEPControl_ActorWrite` appends a process-global counter to the product
// name — 'VernierCAD 1', 'VernierCAD 2' — so two exports of the same part
// in one process differ, and there is no `Interface_Static` knob for it
// (probed against OCCT 7.9.3; `write.step.product.name` sets the base and
// not the counter). Every other varying field is pinned properly through
// `APIHeaderSection_MakeHeader`.
//
// This is deliberately not a blind substitution. The counted name occurs a
// known number of times, so the function *counts* and reports failure if
// the shape of the output is not what it expects. An OCCT upgrade that
// changes the pattern must break loudly here rather than quietly restoring
// non-determinism that no test would notice.
bool normalize_step_product(std::string& text, int& replaced) {
const std::string needle = std::string("'") + kStepProduct + " ";
const std::string fixed = std::string("'") + kStepProduct + "'";
replaced = 0;
std::size_t at = 0;
while ((at = text.find(needle, at)) != std::string::npos) {
const std::size_t close = text.find('\'', at + 1);
if (close == std::string::npos) {
return false;
}
// Only a pure counter suffix is ours to rewrite; anything else means
// the format moved and the caller must be told.
for (std::size_t i = at + needle.size(); i < close; ++i) {
if (text[i] < '0' || text[i] > '9') {
return false;
}
}
if (close == at + needle.size()) {
return false;
}
text.replace(at, close - at + 1, fixed);
at += fixed.size();
++replaced;
}
return true;
}
} // namespace
std::int32_t export_step(std::uint64_t shape, rust::Str path) {
const std::string out_path(path);
if (out_path.empty()) {
return kInvalidArgument;
}
try {
const SilencedMessenger quiet;
TopoDS_Shape found;
if (!lookup_shape(shape, found)) {
return kUnknownShape;
}
STEPControl_Controller::Init();
// Millimetres, per invariant #8. OCCT's default happens to be MM, but
// a default is not a guarantee and the file states the unit.
Interface_Static::SetCVal("write.step.unit", "MM");
Interface_Static::SetCVal("write.step.product.name", kStepProduct);
STEPControl_Writer writer;
if (writer.Transfer(found, STEPControl_AsIs) != IFSelect_RetDone) {
return kOperationFailed;
}
// Pin every FILE_NAME field. The timestamp is the one that would
// otherwise vary across runs.
APIHeaderSection_MakeHeader header(writer.Model());
header.SetName(new TCollection_HAsciiString(kStepProduct));
header.SetTimeStamp(new TCollection_HAsciiString(kStepTimestamp));
header.SetAuthorValue(1, new TCollection_HAsciiString(kStepProduct));
header.SetOrganizationValue(1, new TCollection_HAsciiString(""));
header.SetOriginatingSystem(new TCollection_HAsciiString(kStepProduct));
header.SetPreprocessorVersion(new TCollection_HAsciiString(kStepProduct));
header.SetAuthorisation(new TCollection_HAsciiString(""));
const std::string staging = out_path + ".vernier-step-tmp";
if (writer.Write(staging.c_str()) != IFSelect_RetDone) {
std::remove(staging.c_str());
return kOperationFailed;
}
std::string text;
{
std::ifstream in(staging, std::ios::binary);
if (!in) {
std::remove(staging.c_str());
return kOperationFailed;
}
std::ostringstream buffer;
buffer << in.rdbuf();
text = buffer.str();
}
std::remove(staging.c_str());
int replaced = 0;
if (!normalize_step_product(text, replaced) || replaced == 0) {
// Either the counted product name is absent or it does not look the
// way it did when this was written. Failing is correct: shipping the
// bytes would ship non-determinism (invariant #1).
return kUnexpected;
}
std::ofstream final_out(out_path, std::ios::binary | std::ios::trunc);
if (!final_out) {
return kOperationFailed;
}
final_out << text;
final_out.flush();
if (!final_out) {
return kOperationFailed;
}
return kOk;
} catch (const Standard_Failure&) {
return kOcctFailure;
} catch (...) {
return kUnexpected;
}
}
ShapeSummary import_step(rust::Str path) {
const std::string in_path(path);
if (in_path.empty()) {
return empty_summary(kInvalidArgument);
}
try {
const SilencedMessenger quiet;
STEPControl_Controller::Init();
STEPControl_Reader reader;
if (reader.ReadFile(in_path.c_str()) != IFSelect_RetDone) {
return empty_summary(kInvalidArgument);
}
if (reader.TransferRoots() == 0) {
return empty_summary(kOperationFailed);
}
const TopoDS_Shape shape = reader.OneShape();
if (shape.IsNull()) {
return empty_summary(kOperationFailed);
}
ShapeSummary out = empty_summary(kOk);
// Summarize before registering (see make_box): no orphaned handles.
summarize_into(shape, out);
out.shape = register_shape(shape);
return out;
} catch (const Standard_Failure&) {
return empty_summary(kOcctFailure);
} catch (...) {
return empty_summary(kUnexpected);
}
}
} // namespace vernier
+37
View File
@@ -462,6 +462,10 @@ mod ffi {
) -> SectionData;
/// Releases a registered shape.
fn release_shape(shape: u64) -> i32;
/// Writes a shape to `path` as deterministic AP214 STEP, mm.
fn export_step(shape: u64, path: &str) -> i32;
/// Reads a STEP file and registers the resulting shape.
fn import_step(path: &str) -> ShapeSummary;
}
}
@@ -921,3 +925,36 @@ pub fn shape_faces(shape: u64) -> Result<Vec<FaceInfo>, FacadeError> {
pub fn release_shape(shape: u64) -> Result<(), FacadeError> {
check(ffi::release_shape(shape))
}
/// Writes `shape` to `path` as AP214 STEP in millimetres.
///
/// The bytes are deterministic: exporting the same shape twice, in one
/// process or in two, produces identical files (invariant #1). OCCT's
/// writer is not deterministic on its own — see `export_step` in
/// `facade.cpp` for the two fields involved and how each is handled.
///
/// # Errors
///
/// [`FacadeError`] if the handle is unknown, the path is empty or
/// unwritable, OCCT refuses the transfer, or the written file does not
/// have the shape the determinism normalization expects.
pub fn export_step(shape: u64, path: &str) -> Result<(), FacadeError> {
check(ffi::export_step(shape, path))
}
/// Reads a STEP file and registers the resulting shape.
///
/// The result is an **opaque body**: a STEP file carries no VernierCAD
/// entity ids and nothing in it is a stable topological identity across
/// two imports, so the shape has no parametric history and no feature may
/// reference its faces (invariants #4/#5).
///
/// # Errors
///
/// [`FacadeError`] if the file is missing, unreadable, not STEP, or
/// carries no transferable root.
pub fn import_step(path: &str) -> Result<ShapeSummary, FacadeError> {
let summary = ffi::import_step(path);
check(summary.code)?;
Ok(summary)
}