Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

35 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

step2glb

Proof of concept. I needed to convert a pile of STEP files to glTF and couldn't find a tool that handled all of them, so this is an experiment in how far AI assistance could take a small, from-scratch CLI for the job. It was written with the help of misc AI tools. Treat it as such — it works on the models I threw at it, but it is not a hardened production converter.

Tessellate STEP (ISO 10303-21 / .step / .stp) files into binary glTF (.glb) and inspect the assembly hierarchy, with low memory usage.

▶ Live in-browser demo: https://vegarringdal.github.io/step2glb/ — converts STEP → GLB entirely client-side (wasm + OPFS), nothing uploaded to a server.

No geometry kernel dependency (no OpenCASCADE): the parser, math, surfaces, tessellation, CSG and GLB writer are all hand-written. The engine lives in one library crate (step2glb-core) on top of three small dependencies — md5 (mesh dedup keys), tess2-rust (pure-Rust libtess2 port for polygon triangulation with holes — vendored as a fail-soft fork), and the optional meshopt (meshoptimizer pass) — with thin front-ends layered on top (a CLI, a C ABI, and a WebAssembly build). See Crates for the layout.

What it does

  • Reads the product structure: PRODUCT / PRODUCT_DEFINITION / NEXT_ASSEMBLY_USAGE_OCCURRENCE graph, with per-instance transforms from CONTEXT_DEPENDENT_SHAPE_REPRESENTATION + ITEM_DEFINED_TRANSFORMATION (both simple and complex-instance forms).
  • Tessellates B-rep geometry: planes, the quadrics (cylinder / cone / sphere / torus), surfaces of linear extrusion and revolution, offset surfaces (OFFSET_SURFACE — basis + distance along the normal), and (rational) B-spline / NURBS surfaces. A face whose boundary is a rectangle in parameter space (a full patch or a rectangular sub-patch) is meshed as a structured grid over its (u,v) domain — the standard, fold-free way to tessellate a parametric surface, since every cell maps to one small patch and the mesh cannot invert. Genuinely-trimmed faces (inner holes, non-rectangular boundaries) are triangulated in UV space by tess2 (odd winding) instead — analytic surfaces invert UV in closed form, swept and B-spline surfaces via seeded Newton projection. Either way the result is refined by midpoint edge subdivision until both the parametric step limits and a perpendicular chord-sag bound are met, Delaunay-flipped in metric UV to remove slivers, and mapped back to 3D with surface normals. Swept surfaces reduce to the equivalent quadric where possible (revolved line ∥ axis -> cylinder, slanted -> cone, revolved circle -> sphere/torus, extruded line -> plane). Faces that wrap fully around a periodic direction are cut at a seam and rebuilt as band polygons; boundary loops that encircle a sphere pole or cone apex are closed with a sampled polar cap; boundary loops that pass through a pole/apex (half-cones with the tip on the rim, domes split through the poles) walk the cap line between the adjacent meridians at the singularity, where u is otherwise undefined; a single boundary loop that winds once around the seam but is not a clean iso-v circle is unwrapped and closed along its far v edge — folded into the tip where the surface has one (a cone apex or sphere pole adds no area, so a lune or half-cone closes cleanly), or at the loop's own v-extreme for an open band (cylinder / torus), guarded so a degenerate iso-v rim or a full-period wrap is not mis-closed; a face bounded only by a seam "slit" (an edge walked out and back, enclosing no UV area — how some exporters write a full sphere as a single face) is recognized and tessellated as the whole closed surface. Angle parameters (a cone's half-angle, …) are read in the file's PLANE_ANGLE unit — radians by default, or degrees / grads when the unit context assigns a CONVERSION_BASED_UNIT — so a cone declared in degrees is not mistaken for radians (which would flatten a 45° cone into a near-planar disk). A quadric face whose boundary points do not actually lie on its declared surface (a malformed export — a bounding circle off the axis, a vertex at the wrong radius) is salvaged loop-by-loop: only the poisoned loops are dropped and the face keeps its valid boundary; a face whose entire boundary is off the surface is rejected rather than meshed into a spurious disk or spike. Edges whose 3D curve is omitted ($) or unsupported fall back to the file's 2D p-curves evaluated through the surface (PCURVE / SURFACE_CURVE associated geometry) before resorting to a straight chord.
  • Meshes procedural solids: EXTRUDED_AREA_SOLID / REVOLVED_AREA_SOLID (a planar CURVE_BOUNDED_SURFACE profile swept along a direction or around an axis) and their *_FACE_SOLID siblings — caps through the planar face pipeline, walls as exact quad strips; revolutions honour the file's plane-angle unit and skip caps on full turns.
  • Reads AP242 tessellated geometry (TRIANGULATED_FACE_SET, TESSELLATED_SOLID, …) verbatim, and resolves MAPPED_ITEM instancing.
  • Reads colors: STYLED_ITEM / OVER_RIDING_STYLED_ITEM presentation chains (both COLOUR_RGB and named pre-defined colours, plus SURFACE_STYLE_TRANSPARENT — also inside SURFACE_STYLE_RENDERING_WITH_PROPERTIES — folded into the material alpha) are resolved per solid/shell/face and become per-color glTF primitives with their own PBR materials (deduplicated across the file).
  • Deduplicates meshes two ways: per PRODUCT_DEFINITION (one mesh shared by all instances of a part) and by md5 over the geometry bytes (catches identical geometry exported under different ids).
  • Optimizes every mesh with meshoptimizer: vertex weld → degenerate triangle removal → vertex-cache → vertex-fetch.
  • Writes a single .glb: full node hierarchy with instance matrices, shared meshes — geometry is carried in f64 through the whole pipeline and each part is re-centred on its bounding box before the final f32 cast (the offset rides on the node transform, in merged mode on the bucket nodes), so models sited far from the origin keep their precision and don't jitter in viewers; re-centring also lets translated duplicates dedup into one instanced mesh — POSITION (plus NORMAL only with --normals — off by default) + 32-bit indices, and a root transform node converting the file's LENGTH_UNIT (mm, cm, m, inch, …) to meters and the Z-up engineering convention to glTF's Y-up (STEP has no up-axis field to read; pass --up-axis y if a model is already Y-up). Each representation's geometry and the origins of its assembly placement transforms are normalized by its own context unit, so an Autodesk file that mixes a mm assembly context with metre part contexts isn't silently shrunk 1000× (a part would otherwise collapse to a dot, or — once sized right — be flung away from the assembly by an unscaled placement origin).
  • Merged mode (--merged): the rvm_parser_glb output layout instead — one node/mesh/material per color with everything baked to world space, and per-part drawcall ranges + the id hierarchy in the scene extras (see below).

Output of all test models validates clean against the Khronos glTF validator (0 errors / 0 warnings / 0 infos).

Crates

The repository is a Cargo workspace. The engine is one library; everything else is a thin shell over it:

Crate / dir Kind What it is
crates/corestep2glb-core library (import name step2glb) the whole engine: Part-21 reader, geometry/NURBS, tessellation, CSG, the GLB writer, the three sync I/O handle traits and the one-call convert(). No CLI dependency. Cargo features: optimize (meshoptimizer, default) and mmap (memory-map input, default) — both off under --no-default-features.
crates/clistep2glb-cli binary step2glb the command-line converter: clap, memory-mapped input, on-disk spill (--memory-threshold).
crates/capistep2glb-capi cdylib + staticlib a C ABI (step2glb_convert / step2glb_free) for embedding in C/C++/Python/…
crates/wasmstep2glb-wasm cdylib the WebAssembly build (convert_step_to_glb), core compiled --no-default-features. Opt-in optimize feature compiles the C++ meshoptimizer into the wasm bundle (needs clang, no WASI SDK — see wasm-demo/README).
wasm-demo/ Vite app an in-browser STEP → GLB demo: a Web Worker + OPFS synchronous access handles around the wasm core, rendered with <model-viewer>.

Plain cargo build / cargo test operate on the native crates (core, cli, capi — the workspace default-members); the wasm crate is built for its own target (below). The three sync handles (InputHandle / OutputHandle / TempHandle in core::io) are how the same core talks to a Vec, a memory map, a temp file, or the browser's OPFS without ever becoming async.

Build & use

CLI — step2glb-cli

cargo build --release            # binary at target/release/step2glb
./target/release/step2glb model.step

meshopt compiles the bundled meshoptimizer C++ sources, so a C++ toolchain is required for the default build (drop it with --no-default-features on step2glb-core); its bindings need stable Rust 1.82+. Everything else is pure Rust. See Usage for the flags.

Library — step2glb-core

use step2glb::convert::{convert, ConvertOptions};
use step2glb::io::{MemSink, MemTemp};

let step = std::fs::read("model.step")?;
let (mut out, mut tmp) = (MemSink::default(), MemTemp::default());
let report = convert(&step, &mut out, &mut tmp, &ConvertOptions::default())?;
std::fs::write("model.glb", &out.0)?;          // the GLB bytes
println!("{}", report.to_json());               // faces ok/skipped, issues, defaults used

convert reads through an InputHandle, streams the binary chunk through a TempHandle and writes the container to an OutputHandle. For files larger than RAM, parse with StepFile::open(path) (memory-map) and back tmp with an on-disk TempHandle so the geometry never lands on the heap. The lower-level pipeline (step, hierarchy, tessellate, merge, glb) is public too if you need hierarchical output or per-entity control — that is what the CLI drives.

C ABI — step2glb-capi

cargo build -p step2glb-capi --release   # target/release/libstep2glb_capi.{so,a,dylib}
uint8_t *glb = NULL; size_t glb_len = 0;
int rc = step2glb_convert(step_ptr, step_len, &glb, &glb_len);   // 0 = ok
if (rc == 0) { /* use glb[0..glb_len] */ step2glb_free(glb, glb_len); }

WebAssembly — step2glb-wasm + wasm-demo

Hosted demo: https://vegarringdal.github.io/step2glb/ (deployed from main via .github/workflows/pages.yml). Run locally:

rustup target add wasm32-unknown-unknown
cargo build -p step2glb-wasm --target wasm32-unknown-unknown   # compile check

cd wasm-demo && npm install && npm run dev   # wasm-pack build + Vite dev server

The wasm exports convert_step_to_glb(bytes) -> { glb, info } (info is the JSON diagnostics report). The default browser build drops meshopt/mmap; npm run build:wasm:opt (needs clang, no WASI SDK) compiles the C++ meshoptimizer into the bundle for byte-parity with native. The demo writes the upload to OPFS on the main thread, hands the worker a path, and the worker does all sync-handle I/O — see wasm-demo/README.md.

Usage

Every run first prints the effective settings (resolved defaults included — output path, deflection, threads, unit handling, normals, optimize/cleanup, filter) to stderr, so it's always clear what configuration produced a file.

# convert; writes model.glb next to the input
step2glb model.step

# choose output and tessellation quality (deflection is in mm, converted into
# each representation's own unit, so it means the same sag even in files that
# mix units across parts)
step2glb model.step -o out.glb --deflection 0.05 --max-angle 15

# NOTE: the tighter of the two bounds wins per feature. A curved face with
# radius r is governed by --max-angle whenever r < deflection / (1 - cos(a/2));
# at --deflection 0.5 that is every radius under ~58 mm (a=15°) / ~15 mm
# (a=30°). So to get a genuinely coarse mesh, raise --max-angle along with
# --deflection — e.g. --deflection 0.5 --max-angle 30

# one mesh per color + draw-range metadata (rvm_parser_glb layout)
step2glb model.step --merged

# normals are OFF by default (smaller files, harder position welding, viewers
# flat-shade); pass --normals to keep the tessellator's exact analytic normals
step2glb model.step --normals
# full rvm-style cleanup (position weld + meshopt simplify, always drops
# normals) — works with and without --merged
step2glb model.step --cleanup-position

# just print the assembly tree
step2glb model.step --tree

# isolate one element + its whole subtree (debug a missing/wrong part): match
# a product name (case-insensitive substring) or a PRODUCT_DEFINITION id (#<n>).
# Combine with --tree to see which nodes carry [geometry], or convert just that
# subtree to a small GLB.
step2glb model.step --filter "Housing" --tree
step2glb model.step --filter "#584388" -o part.glb
# extract that element + the transitive closure of everything it references to a
# new standalone STEP file (re-runnable) — also pulls geometry linked one
# relationship hop away or on a sibling definition, so an indirectly-attached
# brep still shows up. Small enough to share for debugging.
step2glb model.step --filter "#584388" --extract-step part.step

# explode each part's geometry into separate named nodes, to find a bad piece in
# a viewer by toggling its visibility. Each node is named <ENTITY_TYPE>#<id>, so
# the id of the broken one feeds straight back into --filter "#<id>". Levels:
# solid (one node per solid), shell (per CLOSED_SHELL), face (per ADVANCED_FACE).
step2glb model.step --split shell -o debug.glb   # 4 shells of a figure as nodes
step2glb model.step --split face  -o debug.glb   # finest: one node per face

# isolate a single geometry entity by the id --split printed (a face / shell /
# solid, not just a product): tessellate just it, or extract just its geometry.
step2glb model.step --filter "#4902148" -o face.glb            # one-face GLB
step2glb model.step --filter "#4902148" --extract-step face.step  # shareable fragment
# ...or pull the whole part the entity belongs to (correct units + placement):
step2glb model.step --filter "#4902148" --with-parent -o part.glb

# entity statistics (top types by count) + conversion
step2glb model.step --stats

# keep raw file units instead of scaling to meters
step2glb model.step --no-unit-scale

# input that is already Y-up: skip the default Z-up -> Y-up rotation
step2glb model.step --up-axis y

# skip the meshoptimizer pass
step2glb model.step --no-optimize

# tessellation threads (default: auto = CPU cores, capped at 4);
# output is byte-identical regardless of thread count
step2glb model.step -t 8

# cap RAM by spilling to an on-disk temp file (accepts 300mb / 1gb / raw
# bytes; 0 = all in memory). In the default hierarchical mode this spills each
# mesh's geometry as it is tessellated, so peak RAM is one mesh, not the whole
# model; with --merged only the output chunk spills (merged holds all geometry
# in RAM). Spill is <input>_tmp_cache next to the input; output is identical.
step2glb huge.step --memory-threshold 500mb

# diagnose skipped faces: dump a minimal, shareable STEP reproduction of the
# first failing face of each surface type to model.debug.txt
step2glb model.step --debug-print

--tree output looks like:

AS1_PE_ASM  [geometry]
├─ PLATE  [geometry]
├─ L_BRACKET_ASSEMBLY_ASM  [geometry]
│  ├─ L-BRACKET  [geometry]
│  ├─ NUT_BOLT_ASSEMBLY_ASM  [geometry]
│  │  ├─ BOLT  [geometry]
│  │  └─ NUT  [geometry]
...

Merged mode (--merged)

Produces the same GLB layout as rvm_parser_glb, so the same viewer code (e.g. three.js BatchedMesh selection + a treeview) works for both RVM and STEP input:

  • One node + one mesh + one material per distinct color. All instances are expanded and baked to world space (meters, Y-up via the same (x, y, z) → (x, z, −y) rotation rvm_parser_glb applies — --up-axis y skips it for already-Y-up input), so nodes carry no transforms and are named node0, node1, … with node N referencing mesh N / material N. Normals are off by default (positions-only, like rvm_parser_glb); --normals adds the tessellator's exact analytic NORMALs.
  • Drawcall metadata in scenes[0].extras — per-part index ranges into each merged mesh, plus the full instance tree:
"scenes": [{
  "nodes": [0, 1],
  "extras": {
    // Record<PART_ID, [FIRST_INDEX, INDEX_COUNT]> per color mesh;
    // offsets are elements into that mesh's index accessor
    "draw_ranges_node0": { "2": [0, 2112], "6": [2112, 720] },
    "draw_ranges_node1": { "4": [0, 1572] },
    // Record<ID, [NAME, PARENT_ID]>, "*" marks a root
    "id_hierarchy": {
      "1": ["AS1_PE_ASM", "*"],
      "2": ["PLATE", "1"],
      "3": ["L_BRACKET_ASSEMBLY_ASM", "1"],
      "4": ["L-BRACKET", "3"]
    }
  }
}]

Ids are a 1-based counter assigned depth-first over the expanded assembly (not STEP entity ids): the first node is 1 and they increment, so a part instanced five times gets five ids and five draw ranges. There is one id per draw call: a part's first color reuses the part's id, and each further color of the same part is added to id_hierarchy as its own numbered child node (same name, so the tree will show repeated names for multi-color parts). Each id therefore appears in exactly one draw_ranges_node<N> — never shared across color meshes — so selecting a draw call is a single id lookup. Within each merged mesh the ranges are contiguous and tile the index buffer exactly, so a raycast hit maps back to an id by range lookup, and selection/recolor is a [start, count] group per id. asset.extras carries "web3dversion": 2 like rvm_parser_glb.

Per-part mesh optimization still runs before merging (ranges stay valid; merged meshes are never reordered afterwards), and unit scaling to meters applies unless --no-unit-scale is given.

Position cleanup (--cleanup-position)

Mirrors rvm_parser_glb's cleanup pipeline. With --merged it runs per part instance before merging; without it, it runs once per unique (instanced) mesh of the hierarchical output: positions are welded on a quantized grid, the part is simplified with meshopt_simplify (border locked, so seams between parts stay closed), degenerate triangles (repeated index / coincident positions / near-zero area) are dropped, and the vertex pool is compacted. Draw ranges are recorded after this pass, so they always match the final index buffer.

Like in rvm_parser_glb this produces positions-only primitives — a vertex welded across faces has no single valid normal, so NORMAL is dropped and the viewer flat-shades or computes its own. Skip the flag to keep the tessellator's exact analytic normals instead.

step2glb model.step --merged --cleanup-position

# rvm_parser_glb-equivalent knobs (same defaults)
#   --cleanup-precision 3       weld grid decimals, in file units
#   --meshopt-threshold 0.75    simplify target = threshold * index count
#   --meshopt-target-error 0.0  allowed simplification error
step2glb model.step --merged --cleanup-position \
  --meshopt-threshold 0.3 --meshopt-target-error 0.05

With --meshopt-target-error 0 (the default, like rvm_parser_glb) only zero-error collapses happen regardless of the threshold; give it a small error budget to actually decimate toward the threshold.

Both meshopt knobs also work on their own, with or without --merged: passing either one runs a simplify-only pass that keeps normals (and the hierarchical layout, if not merging):

step2glb model.step --meshopt-threshold 0.3 --meshopt-target-error 0.05

Skipped faces are reported on stderr by surface type, so you always know what a model needed that isn't supported yet — and, separately, which supported surfaces failed trimming/tessellation (Newton non-convergence, multi-winding periodic loops, degenerate bounds, …):

tessellated 1 unique meshes (112 faces ok, 193 skipped) in 34.9ms
unsupported surface types (faces skipped):
     163  B_SPLINE_SURFACE_WITH_KNOTS
      26  SURFACE_OF_LINEAR_EXTRUSION
       4  SURFACE_OF_REVOLUTION
faces skipped on supported surfaces (trimming/tessellation failed):
      12  TOROIDAL_SURFACE

Diagnosing skipped faces (--debug-print)

When a supported surface still fails on some model, --debug-print writes a minimal, self-contained reproduction to <input>.debug.txt: the first failing face of each surface type, each emitted as its ADVANCED_FACE plus the transitive closure of every entity it references (loops, edges, curves, points, the surface), the file's HEADER (schema + originating system), a synthetic shell/solid root, and a comment naming the stage that failed:

step2glb vendor_part.step --debug-print   # -> vendor_part.debug.txt
/* ===== CONICAL_SURFACE (face #271594) -- failure stage: periodic-band
   (wrap-around) tessellation failed (multi-winding loop or seam ...) ===== */
#...=CONICAL_SURFACE('',#...,...);
...

It is geometry only — no part names, assembly structure or metadata leave the file — so it is safe to share from a confidential model, and it is valid Part-21: rename it to .step and feed it back in to reproduce the skip in isolation (or hand it over as a bug report / new test fixture).

How it stays low-memory

The file is held once as a byte buffer. A single string/comment-aware pass builds a compact index of #id → (interned type id, parameter byte range) (16 bytes per entity) plus a per-type id list. Entity parameters are parsed lazily, only for entities the pipeline actually touches, and dropped right after use — no DOM of the file is ever materialized. A 12.6 MB / 195 000 entity file indexes in ~80 ms; a 15 MB assembly converts end-to-end in ~0.7 s with a ~90 MB peak RSS (geometry output dominates, not parsing).

For models whose geometry won't fit, --memory-threshold spills the tessellated geometry to an on-disk temp file as the hierarchical walk runs (holding only one mesh plus accessor metadata), and streams the GLB back out of it — so peak RAM is bounded regardless of model size. Merged mode is the exception: it accumulates one buffer per color in RAM and does not stream (use the default hierarchical mode under a memory ceiling).

Module map

step2glb-core (crates/core/src/):

step.rs        Part-21 indexer + lazy parameter parser (incl. complex instances)
                 + the Source backing (owned buffer / memory map)
io.rs          the three sync I/O handle traits + in-memory impls
geom.rs        V3 / M4 / frames, analytic surfaces, B-spline curve eval
model.rs       typed entity accessors, edge-curve discretization, per-context units
tessellate.rs  B-rep traversal, UV tessellation, seam handling, refinement
csg.rs         CSG primitives + BSP-tree mesh boolean (CSG_SOLID evaluation)
hierarchy.rs   product graph, NAUO edges, instance transforms
styles.rs      STYLED_ITEM color chains, named pre-defined colours
merge.rs       --merged: world-space bake, per-color merge, draw ranges
mesh.rs        TriMesh / MeshSet (per-color buckets), md5 hashing, meshopt
glb.rs         dependency-free binary glTF writer (streams via the I/O handles)
convert.rs     one-call convert() + ConvertReport (the embeddable API)

The other crates are thin shells: crates/cli/src/main.rs (CLI driver), crates/capi/src/lib.rs (C ABI), crates/wasm/src/lib.rs (wasm-bindgen).

Because the engine is a library, the pipeline embeds directly:

let sf = step2glb::step::StepFile::parse(std::fs::read("a.step")?)?;
let asm = step2glb::hierarchy::build(&sf);

Supported entities (geometry)

Kind Supported
Solids MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS, FACETED_BREP, SHELL_BASED_SURFACE_MODEL, FACE_BASED_SURFACE_MODEL, EXTRUDED_AREA_SOLID / REVOLVED_AREA_SOLID / EXTRUDED_FACE_SOLID / REVOLVED_FACE_SOLID (planar profiles)
CSG CSG_SOLID / BOOLEAN_RESULT (union / intersection / difference) over the primitives BLOCK, RIGHT_CIRCULAR_CYLINDER, RIGHT_CIRCULAR_CONE, SPHERE, TORUS, RIGHT_ANGULAR_WEDGE — evaluated by a BSP-tree mesh boolean; HALF_SPACE_SOLID / BOXED_HALF_SPACE operands (planar base) are materialized as a block bounded by the other operand (B-rep operands not yet meshed)
Surfaces PLANE, CYLINDRICAL_SURFACE, CONICAL_SURFACE, SPHERICAL_SURFACE, TOROIDAL_SURFACE, SURFACE_OF_LINEAR_EXTRUSION, SURFACE_OF_REVOLUTION, B_SPLINE_SURFACE_WITH_KNOTS incl. the rational complex-instance form, UNIFORM_SURFACE / QUASI_UNIFORM_SURFACE / BEZIER_SURFACE (implied knots synthesized), RECTANGULAR_TRIMMED_SURFACE (resolved to its basis surface; when a face carries no boundary loops the u/v window itself is synthesized into the trim), OFFSET_SURFACE (+ near-planar fallback via Newell plane fit)
Curves LINE, CIRCLE, ELLIPSE, HYPERBOLA, PARABOLA, B_SPLINE_CURVE_WITH_KNOTS (incl. rational complex form), UNIFORM_CURVE / QUASI_UNIFORM_CURVE / BEZIER_CURVE (implied knots synthesized), POLYLINE, TRIMMED_CURVE, COMPOSITE_CURVE (+ COMPOSITE_CURVE_SEGMENT), SURFACE_CURVE/SEAM_CURVE (via 3D curve), PCURVE (2D parameter curve evaluated through its surface — also the fallback for a SURFACE_CURVE whose 3D curve is $); an edge that resolves to none of these falls back to a straight segment between the edge vertices (and is tallied in the report)
Tessellated TRIANGULATED_FACE_SET, TRIANGULATED_SURFACE_SET, TESSELLATED_SOLID, TESSELLATED_SHELL, and the AP242-ed2 TRIANGULATED_FACE / COMPLEX_TRIANGULATED_FACE (the geometric_link slot is auto-detected; triangle_strips / triangle_fans decoded with GL winding)
Wireframe GEOMETRIC_CURVE_SET / GEOMETRIC_SET (datum / reference curves) emitted as glTF LINE primitives; hierarchical output only
Instancing MAPPED_ITEM / REPRESENTATION_MAP, NAUO assembly instances
Presentation STYLED_ITEM, OVER_RIDING_STYLED_ITEM -> COLOUR_RGB / DRAUGHTING_PRE_DEFINED_COLOUR

Tests

cargo test
  • Unit tests (in each module, 66): Part-21 lexing/param parsing edge cases (escaped quotes, comments, complex instances, typed params), entity-source reconstruction + reference-closure round-trip (the --debug-print machinery), frame/matrix math, closed-form surface UV round-trips, Newton inversion round-trips on B-spline / extrusion / revolution surfaces (cold and hint-seeded), (rational) B-spline curve and surface evaluation against known closed forms, pole-cap reporting, Curve3 evaluation/periods, STYLED_ITEM color-chain resolution, mesh welding/degenerate removal/hashing, GLB container layout, materials and JSON content, merged draw-range/id-hierarchy extras.
  • Property tests (tests/proptest_math.rs, via proptest): the geometry kernel is hand-rolled, so thousands of random surfaces, curves and query points are thrown at the public entry points to prove invariants no single example can — NURBS curve/surface evaluation, seeded-Newton and analytic (u,v) inversion (including adversarial continuity hints), and closed-form round-trips all stay finite (never NaN/∞), never panic, and terminate (every loop already caps its iteration count). The parser clamps overflowing literals (1E9990) so a malformed file cannot seed a non-finite coordinate in the first place.
  • Integration tests (tests/integration.rs) over STEP fixtures in tests/fixtures/:
    • triangle.step — minimal planar ADVANCED_FACE: exact area, normal direction and winding consistency.
    • cylinder_band.step — a full 360° cylindrical face bounded by two circles (the classic periodic-seam case): area within 1 %, all points on the cylinder, normals radial.
    • assembly.step — two products + NAUO + ITEM_DEFINED_TRANSFORMATION: asserts the tree shape and the (100, 0, 0) instance translation.
    • as1_pe_203.stp — the canonical real-world AS1 assembly: root name, 4 top-level children, 18 leaf instances, 5 unique deduplicated meshes, zero failed faces.
    • bspline_patch.step — a trimmed B_SPLINE_SURFACE_WITH_KNOTS: exact area through Newton UV trimming, plus a rational complex-form parse test.
    • extrusion_face.step — a SURFACE_OF_LINEAR_EXTRUSION over a B-spline directrix (not reducible): exact lateral area, all points on the walls.
    • revolution_cylinder.stepSURFACE_OF_REVOLUTION of a line parallel to the axis: asserts reduction to an analytic cylinder and band area.
    • sphere_cap.step — a spherical face bounded by a single circle: the polar-cap path; area within 1.5 % of 2πrh and the pole present.
    • half_cone_apex.step — a 180° cone face whose boundary passes through the apex (the parameterization singularity): exact lateral area.
    • hemisphere_poles.step — a half sphere bounded by a great circle through both poles: area within 2 %, all points on the correct half.
    • sphere_slit.step — a full sphere as one face bounded by a seam slit (one meridian edge walked out and back): full 4πr² area.
    • cone_complex_curve.step — a cone sliver bounded by a rational B-spline conic in complex-instance form: all points on the cone.
    • null_curve_edge.step — a face whose boundary has an edge with a null ($) 3D curve: the edge becomes a straight segment instead of dropping the whole face (regression for a real exporter quirk).
    • bspline_unbounded.step — a B-spline patch whose only bound is degenerate (a seam slit / VERTEX_LOOP): tessellated over its full knot domain instead of being skipped; exact planar area.
    • thin_arc_band.step — a thin planar crescent between two near-concentric arcs: at a coarse deflection the discretized arcs self-intersect and tess2 fails, so the face is re-tessellated finer and recovered.
    • inscribed_hole.step — a planar face whose square hole is inscribed in its circular rim (corners on the rim): the corners poke through the rim's chords, so the hole is nudged inward to let tess2 recover the face.
    • two_edge_arc_sliver.step — a planar sliver bounded by just a chord and a shallow arc (vendor-model excerpt): arcs always keep at least 2 segments, or the closed loop collapses to 2 points and the face is dropped.
    • cylinder_offset_seam_rims.step — a cylinder band whose rims are closed B-spline edges with the vertex half-way around the basis curve's seam (vendor-model excerpt): the rim polylines are re-seamed at the vertex instead of snapping the curve endpoints across the cylinder.
    • bspline_cone_pole.step — a rational B-spline closed in u whose top control row collapses to one point, a NURBS cone/dome apex (vendor-model excerpt): the degenerate pole is detected and the capped surface gridded over its full domain instead of being skipped by the periodic-band path.
    • degenerate_sliver_face.step — a planar face bounded by a single edge whose start and end vertex coincide (real-model excerpt): a zero-area sliver, counted as a degenerate face and skipped quietly, not flagged as a failure.
    • degenerate_plane_slit.step — a planar face whose many-edge boundary is an out-and-back slit of spokes enclosing zero area (real-model excerpt): detected by Newell area, counted degenerate rather than reported as a trimming failure.
    • sphere_lune_through_poles.step — a spherical lune whose single boundary winds once through both poles (real-model excerpt): unwrapped and closed along the meridian at each pole, area on the correct lune.
    • cone_winding_to_apex.step — a conical face whose single winding loop is closed toward the apex (real-model excerpt): the finite tip adds no area, exact lateral area.
    • cylinder_winding_notch.step — a cylindrical band whose single winding loop is closed at its own v-extreme, no singular tip (real-model excerpt): all points on the cylinder.
    • torus_winding_ring.step — a toroidal band closed at its v-extreme, the full-period-wrap guard preventing a mis-close (real-model excerpt): all points on the torus.
    • malformed_offsurface_boundary.step — a cylindrical face whose bounding circle lies off the surface (a malformed real-model excerpt): rejected as malformed instead of meshed into a giant spurious disk.
    • csg_block_minus_cylinder.step — a CSG_SOLID drilling a cylinder out of a block via BOOLEAN_RESULT(.DIFFERENCE.): the BSP mesh boolean must remove the hole, checked by enclosed volume (block 1000 − cylinder ≈ 717) with every vertex inside the block bounds. Plus crates/core/src/csg.rs unit tests asserting exact primitive volumes and set-volume identities for union/intersection/difference.
    • colored.step — a STYLED_ITEM chain: color map -> mesh bucket -> GLB material assertions.
    • merged mode: draw ranges tile every color mesh's index buffer exactly and all ids resolve in id_hierarchy (as1), one id per draw call (a multi-color part splits into numbered child nodes), color buckets and the fallback part (colored.step), the Z-up -> Y-up bake (cylinder_band.step), and --cleanup-position output (positions-only primitives, ranges still tiling the simplified index buffers, output never larger).
    • debugging/units: --filter name/id resolution and subtree dedup; --extract-step's subtree closure reaches the brep and is deterministic; per-representation length units are read from each context (so a mixed mm/metre file keeps each part's true size); a cone's semi_angle is scaled by the file's PLANE_ANGLE unit, so a degree-context cone is read in degrees, not radians.

Known limitations / TODO

  • Tessellation density on pathological B-splines: refinement now runs under a per-face deflection budget (~4·area/deflection² triangles — features below the deflection scale aren't representable anyway), so near-cusp parameterizations (swept tubes with path kinks, helical springs) stop at a sane density instead of exploding to the hard cap. The sag bound may go locally unmet right at a cusp.
  • Folded closed (periodic) B-spline coils: a coil/spring modelled as one closed-in-u B-spline tube (per_u = 2π, v along a long multi-turn helix) has a boundary that winds many times around the closed direction. The single-winding seam detector reports w = 0 (the |w| > 1 limitation below), so it fell to tess2 and folded ≈50% into triangle soup. Detected structurally — a periodic B-spline whose boundary spans (nearly) the full period in the closed direction and the full extent in the other is the whole closed surface — and gridded over the full domain (gridding a closed surface's full period covers it exactly once, fold-free). Checked before the seam-complement path so a stray short end-loop can't mis-route it.
  • Closed B-spline cones/domes with a degenerate apex (parametric pole): a NURBS cone/dome is a tensor-product patch closed in one direction whose opposite control row is coalesced to a single point — the standard way to build a sphere/cone tip (the collapsed row is a parametric pole). Such a face is cut open by a radial seam, so its lone boundary loop winds once (w = ±1) and entered the periodic-band path, which expects a clean iso-v circle to pair against a polar cap line — and a B-spline has no analytic v_caps() for it to synthesize one from, so the face was skipped. Now a collapsed control row is detected as a pole (bspline_has_v_pole); a single full-domain loop on such a closed surface is gridded over the whole domain (the apex row yields zero-area triangles that cleanup drops), fold-free. Gated on a single loop (no interior hole to over-fill) and on an actual pole, so genuine uncapped bands still take the periodic-band path.
  • Degenerate zero-area "sliver" faces reported as failures: CAD kernels emit faces bounded by a single edge whose start and end vertex coincide (a LINE cannot close on itself with any area) from boolean operations. These carry no surface, but were logged as "trimming/tessellation failed", framing a non-problem as a bug. A face whose boundary discretizes to fewer than three distinct points — or that has three or more points but still encloses (near-)zero area by Newell's formula, an out-and-back slit of spokes — is now counted as degenerate_faces and skipped with a soft note, distinct from a boundary that produced nothing (an unsupported curve), which still reports a real failure.
  • Exploding / folded high-aspect wound B-spline strips: a structural part's wound body is a degree-3 NURBS with an extreme parameter aspect ratio (u≈1, v≈16000). It is the whole parametric patch, but its boundary projects (via Newton) to a self-intersecting UV polygon that tess2 folds (≈50% inverted) and explodes (≈480k triangles per face). Detected structurally — the boundary spans the whole knot domain and self-crosses (no tuned threshold) — and gridded over the full domain instead: fold-free, and there is no trim to over-fill. A clean (simple-polygon) trim is left to tess2, so genuine triangular/curved trims are never flattened. Cut one part from 1.1M→0.53M triangles and its worst faces from 50%→0% folded.
  • Folded B-spline strips: ruled / doubly-curved lofted strips used to come out of tess2's unstructured triangulation folded — inverted, overlapping triangles that left real coverage gaps (a shredded surface, e.g. a figure's shoulder). Rectangular parametric patches are now meshed as a structured (u,v) grid, which follows the surface and cannot invert, so these faces tessellate cleanly without a fold-detection heuristic.
  • Planar faces with an inconsistent declared surface: a common faceted export quirk is a FACE_SURFACE with an explicit POLY_LOOP polygon but a PLANE whose normal doesn't match the polygon (sometimes orthogonal to it) — projected onto the declared plane the boundary collapses to a zero-area line and was skipped as a "slit". Likewise a flat degree-1 B_SPLINE_SURFACE_WITH_KNOTS patch whose many-edge boundary self- intersects under Newton UV inversion. The boundary is the authoritative geometry, so when the declared surface is geometrically planar (an explicit PLANE, or a B-spline with a coplanar control net — convex-hull property) and tessellation fails, the plane is re-fit to the loop points (Newell) and the polygon tessellated there. Recovered all 25 skipped faces on a 79k- entity test part. Only runs on already-failed faces, so curved surfaces are never flattened.
  • PCURVE support: an edge whose 3D curve is $ or unsupported now evaluates its 2D parameter curve through the basis surface (bounded 2D curves only — an unbounded LINE p-curve would need a parameter inverse to trim). ISO parameter conventions are converted (plane-angle units; the cone's ISO v runs along the slant, ours along the axis). Trimming of edges that do carry 3D curves still projects those via Newton.
  • Multi-winding periodic loops (|w| > 1) and polar caps on general surfaces of revolution are skipped.
  • Unsupported entity types (found by auditing our reader against the AP203/214/242 EXPRESS schemas; each is now counted and reported on the console when a file actually uses it — see TessStats). Prioritised:
    • COMPOSITE_CURVE (+ COMPOSITE_CURVE_SEGMENT) as edge geometry: each segment's parent_curve (a bounded curve, WR1) is discretized over its own endpoints, oriented by the segment's same_sense, stitched on the shared join and run end-to-end along the edge. Tested against a bent 2-segment edge whose chord fallback would have collapsed the face.
    • HYPERBOLA, PARABOLA edge curves: parameterized per ISO 10303-42 (verified against OpenCascade Geom_Hyperbola/Geom_Parabola) — hyperbola C + a·cosh u·x + b·sinh u·y, parabola C + f·u²·x + 2f·u·y — and adaptively sampled to the deflection. Tested against the analytic enclosed areas (8/3; sinh1·cosh1−1).
    • RECTANGULAR_TRIMMED_SURFACE: resolved to its basis_surface (slot 2) and tessellated on it. A bounded face's explicit boundary loops lie on the basis within the u1/u2/v1/v2 window and are the authoritative trim, so the window is redundant and usense/vsense (which only flip the trimmed surface's parameterization, never used) are irrelevant. When a face carries no boundary loops the window is the trim and its boundary loop is synthesized (ISO parameter conventions converted). Curved bases (cylinder, sphere, B-spline) are no longer wrongly flattened by the plane fallback.
    • AP242-ed2 tessellated geometry: TRIANGULATED_FACE / COMPLEX_TRIANGULATED_FACE. The ed2 *_FACE forms add a geometric_link slot between normals and pnindex (vs the ed1 *_SET we already read); it's detected structurally — pnindex is always a list, geometric_link a ref/$ — so one reader handles both. COMPLEX_TRIANGULATED_FACE's triangle_strips (alternating winding) and triangle_fans (shared first vertex) are expanded to triangles with the standard GL conventions.
    • GEOMETRIC_CURVE_SET / GEOMETRIC_SET: wireframe (datum / reference curves, e.g. structural part JLDATUM/PLDATUM lines) is emitted as glTF LINE primitives (mode 1) — each bounded curve element discretized into a position-only line polyline, kept in its own bucket so the triangle pipeline (meshopt / simplify) never touches it. Hierarchical output only; merged mode skips it.
    • Uniform / quasi-uniform / Bézier B_SPLINE_* forms (no explicit knots): UNIFORM_CURVE / QUASI_UNIFORM_CURVE / BEZIER_CURVE and the *_SURFACE equivalents carry no knot vector — it is implied by the type (ISO 10303-42). The implied knots are synthesized from the degree and control-point count — uniform = all multiplicity 1 (not clamped), quasi-uniform = ends clamped (mult d+1) with interior uniform, Bézier = ends clamped with interior breakpoints of multiplicity d (piecewise) — and the result flows through the existing B-spline eval/tessellation unchanged. Knot count is always n + d + 1; an invalid Bézier net (not a whole number of degree-d segments) is rejected.
    • CSG_SOLID / BOOLEAN_RESULT (constructive solid geometry): STEP stores CSG as a recipe — primitives combined by set operations — not a mesh. The primitives (BLOCK, RIGHT_CIRCULAR_CYLINDER, RIGHT_CIRCULAR_CONE, SPHERE, TORUS) are meshed into closed, outward-oriented polygon soups, then the boolean tree (union/intersection/difference) is evaluated by a BSP-tree mesh boolean (the Thurston / csg.js algorithm — partition each operand by the other's face planes, keep/drop/flip the right fragments, stitch). The on-plane tolerance is relative to the smaller operand's extent (scale-independent, and a thin feature subtracted from a large body is not swallowed by a tolerance sized to the body). Robust for primitive trees; the known soft spot is exactly-coplanar coincident faces between operands. RIGHT_ANGULAR_WEDGE is meshed as a primitive; HALF_SPACE_SOLID / BOXED_HALF_SPACE operands (planar base surface) are materialized at the boolean as a block bounded by the other operand. B-rep solid operands are not yet meshed (reported, not silent). See crates/core/src/csg.rs.
    • OFFSET_SURFACE: a Surface::Offset variant — point = basis + d·n̂, UV inverse via a fixed-point step on the basis inverse, all periodic/cap/step queries delegated to the basis.
    • SWEPT_AREA_SOLID (EXTRUDED_AREA_SOLID / REVOLVED_AREA_SOLID) and SWEPT_FACE_SOLID siblings: planar profiles (CURVE_BOUNDED_SURFACE or a FACE_SURFACE) swept along a direction or around an axis — caps via the planar face pipeline, walls as exact quad strips; revolutions honour the file's plane-angle unit.
    • Lower priority (rare in exchange, surface as skipped-face/approximated warnings, not silent): CURVE_BOUNDED_SURFACE as face geometry (it is handled as a swept-solid profile), non-planar swept profiles, SURFACE_CURVE_SWEPT_SURFACE / FIXED_REFERENCE_SWEPT_SURFACE, OFFSET_CURVE_2D/3D, HYPERBOLA/PARABOLA as swept-surface profiles (they work as edge curves; an unbounded conic profile has no principled finite parameter window for the swept-surface UV inversion).
  • Seam-straddling "long way around" faces on a closed surface: a face on a periodic surface (e.g. a spherical ball-joint) whose outer boundary does not net-wind but straddles the u seam, with the face interior covering more than half the period (the complement of the unwrapped polygon). The tessellator used to fill the polygon interior (the short, seam side), so an inner hole on the long side was not cut and the face rendered wrong. Now detected when an inner loop does not nest inside the outer one (impossible for a real hole), and tessellated as one full u-period band with every loop cut out — the bite and the real holes alike — leaving the wrap-around interior. Found via --split face on a figure's shoulder/elbow joints (spherical faces #4902148 / #4902755).
  • Transparency (SURFACE_STYLE_TRANSPARENT): the styled-item walk reads the transparency factor as a sibling of the fill-area colour and folds it into the material alpha (alpha = 1 − transparency, ISO 10303-46); the writer already emits alphaMode: BLEND for alpha < 1.
  • Per-vertex colors. STEP B-rep colour is per-face / per-item (handled); only AP242 tessellated sets can carry vertex colours, and the writer has no COLOR_0 attribute yet.
  • Optional EXT_mesh_gpu_instancing instead of node-per-instance for huge assemblies, and meshopt simplification LODs (--simplify).
  • Output geometry spill (--memory-threshold): in the hierarchical walk each mesh's geometry is serialized to a temp handle the moment it is tessellated, keeping only accessor/material metadata + the running offset in RAM — so peak memory is one mesh, not the whole model. 0 keeps the all-in-RAM path (byte-identical). Merged mode is the exception: it accumulates one buffer per color and does not stream.
  • WASM build with streaming to/from the browser's OPFS: the worker drives OPFS sync access handles as the core's InputHandle / OutputHandle / TempHandle. The input is read by range on demand inside Rust (the offset index is built with a sliding window, entity bytes are pulled per range — the whole file is never materialized), and the geometry spill above flows through the temp handle — so a large model streams in-tab with bounded wasm memory. The convert_streaming (hierarchical) path holds only the offset index + one entity + one mesh; the convert_step_to_glb (in-RAM) path, used for small files / merged, intentionally loads the whole file.
  • Remaining streaming gaps: the offset index itself is resident (∝ entity count — the irreducible floor), and the geometry spill is not yet wired into merged mode (per-color temp segments + a concatenation pass).
  • Parallel tessellation: -t/--threads fans faces out over scoped std threads (no new dependency), default auto = CPU cores capped at 4. Results merge in face order, so output is byte-identical to serial.
  • --filter/--subtree to export only part of the hierarchy: --filter <name|#id> isolates a matching element plus its whole subtree (substring on product name, or exact PRODUCT_DEFINITION id) — handy for debugging why a part is missing or misplaced. --filter X --extract-step <path> writes a re-runnable standalone STEP file of that element and the forward closure of everything it references — including geometry one relationship hop away or on a sibling product-definition, so an indirectly-attached brep still appears. Runs also warn about leaf parts in the tree that carry no geometry — the usual sign of an unfollowed link.

Note on the vendored tess2-rust fork

crates/tess2-rust is a vendored copy of tess2-rust 1.1.8 (MIT), wired in via [patch.crates-io] in the workspace Cargo.toml. The crate is workspace-excluded (so cargo fmt/test --all leave upstream's formatting and tests untouched) and carries #![allow(warnings)].

Why: the wasm build is panic=abort with no unwinding (stable Rust can't build panic=unwind for wasm). Upstream tess2 panics on some degenerate contours — region().unwrap() on a freed/None region slot during the sweep. On native that panic is caught by catch_unwind, so the one bad face is skipped and conversion continues; on wasm there is no unwinding, so a single sliver face takes down the whole module — the entire conversion fails instead of dropping one face.

What we changed (only the failure path): the Tessellator gains an aborted: Cell<bool> flag and a default dummy_region; the region accessors set aborted and hand back the dummy instead of unwrap()-ing a None slot, and the sweep loop bails to a clean false (= "tessellation failed" → face skipped, exactly the native behavior) on the next event. Core also sanitizes contours (sanitize_contour drops zero-length edges before tess2) to remove the most common trigger up front. The patched sites are marked STEP2GLB PATCH: in crates/tess2-rust/src/tess/mod.rs; the introducing commit is fcc1231.

This is intentionally incremental — a different degenerate face could trip a different unwrap() deeper in the sweep; patch each new site the same way.

Upstream: the fail-soft change is small and self-contained. If this POC keeps proving out, it's worth offering back to upstream tess2-rust as a PR — a panic=abort-safe degenerate-contour path benefits any wasm consumer, not just us.

Note on the bundled Cargo.lock

None is committed; cargo build resolves fresh. The meshopt functions used (generate_vertex_remap, remap_*, optimize_vertex_cache, optimize_vertex_fetch) have identical signatures across 0.5/0.6, so minor resolver differences are harmless.

License

MIT — see LICENSE. Copyright (c) 2026 Vegar Ringdal. Free to use, modify and distribute, including commercially.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages