A zero-build, browser-based viewer for STEP (ISO 10303) CAD models — rendered with
three.js and parsed with
occt-import-js (OpenCascade compiled to
WebAssembly). No bundler, no npm install: it's plain ES modules loaded from a CDN, so
you serve the folder and it runs.
- Loads a bundled sample gallery of STEP (and an IGES) model with one click.
- Open your own
.step/.stp/.iges/.igs/.brep/.brpfile via the picker or by dragging it anywhere onto the window — the loader routes each format to the matching occt-import-js reader (STEP / IGES / BREP). - Parses the B-rep with occt-import-js (WASM) and renders the resulting meshes with
three.js — orbit / pan / zoom via
OrbitControls. - Viewer polish: camera auto-fits to the model, a wireframe toggle (button or
Wkey), a loading spinner while parsing, and a non-blocking error toast on bad input. - Keyboard-navigable camera — Tab to the 3D view and drive orbit / zoom / pan entirely from the keyboard (see below), so the viewer is usable without a mouse.
Most shortcuts work anywhere on the page. The camera keys are scoped to the 3D
view — Tab to it first (it shows a focus ring), then:
| Key | Action |
|---|---|
← → ↑ ↓ |
Orbit the camera (~5° per press) — Left/Right azimuth, Up/Down polar |
+ − |
Zoom (dolly) the camera in / out, clamped to the fit bounds |
Shift + arrows |
Pan the view target |
Home |
Re-fit / reset the view |
Global shortcuts (work regardless of focus): 1–5 load the gallery samples,
W toggles wireframe, F / R fit the view, ? opens the shortcuts help.
https://aatchison.github.io/step-viewer-demo/
The bundled Gear sample (2,696 tris, 52.0 × 52.0 × 20.0) loaded at a 1440×900 desktop
viewport. Pick another model from the gallery strip, drag in your own STEP / IGES / BREP
file, and orbit / pan / zoom with the mouse.
It's a static site — serve the folder with any web server:
python3 -m http.server 8000
# then open http://localhost:8000(occt-import-js loads its WASM over the network, so a plain file:// open won't work —
use a server.)
Want to add a sample, bump a dependency, or fix something? See
CONTRIBUTING.md — it covers the local-dev flow, the
deliberate zero-build ground rules (ES modules + CDN importmap, no bundler,
no node_modules at runtime), how to add a sample, how to bump the pinned
three / occt versions, the pre-PR manual test checklist, and the PR conventions
(keep it zero-build, preserve a11y and the err.kind error taxonomy).
The site stays zero-build — index.html loads three.js and occt-import-js from a
CDN via an importmap, and nothing here is bundled or shipped. Everything under
package.json / node_modules is dev-only: it exists so the sample-parse tests can
run the exact same occt-import-js engine (pinned to the CDN version) in Node.
npm ci # install the dev-only test + typecheck dependencies
npm test # run the node:test sample-parse regression suite (no browser)
npm run typecheck # tsc --noEmit: JSDoc + checkJs across the split modules (no output)npm run typecheck is dev-only static type-checking (issue #111): the ES
modules under src/ are annotated with JSDoc @param/@returns/@throws and
topped with // @ts-check, and tsc --noEmit (config in tsconfig.json) catches
parameter/shape mismatches across the split files. It emits no JavaScript — the
shipped index.html / src/*.js are byte-for-byte unchanged and the site stays
zero-build. (three@0.160.0 doesn't bundle its own .d.ts, so three's types come
from the pinned @types/three@0.160.0 devDependency — the same version as the CDN
runtime engine.)
A GitHub Actions workflow runs npm ci, then npm test and npm run typecheck, on
every push / PR to main (Node 20, ubuntu-latest, read-only GITHUB_TOKEN via
permissions: contents: read).
Note: this commit only proposes the workflow. The automation token that opened the
PR lacks GitHub's workflow scope — and GitHub refuses any push that touches
.github/workflows/ without it — so the ready-to-run file ships at
.github/workflows-proposed/ci.yml. A maintainer
with workflow scope enables it by moving it into place:
git mv .github/workflows-proposed/ci.yml .github/workflows/ci.yml
git commit -m "ci: enable workflow" && git pushocct-import-js bundles readers for three CAD formats, and the viewer dispatches on the file extension to the matching one:
- STEP —
.step/.stp(ISO 10303, AP203 / AP214 / AP242). - IGES —
.iges/.igs. - BREP —
.brep/.brp(OpenCascade native).
All six extensions are accepted by the picker and drag-and-drop; an unrecognized extension is rejected up front with a hint rather than a failed parse. Other CAD formats (STL, OBJ, …) are out of scope for this demo.
The viewer is a zero-build static site: index.html declares an
importmap
that points three and three/addons/ at the jsDelivr
CDN (pinned to three@0.160.0 — the addons load from the same version's
examples/jsm/ path), then loads src/main.js as an ES module. There is no
bundler, no transpile step, and nothing under node_modules ships — you serve the
folder and the browser resolves every import over the network.
index.html also carries the whole UI shell: all page markup, the entire inline
<style> (design tokens, layout, the light/dark theme, gizmo/HUD chrome), and the
importmap + entry <script>. The behaviour lives in a small set of ES modules
under src/, split so the pure geometry logic can be type-checked and unit-tested
outside a browser:
| File | Role |
|---|---|
index.html |
Markup, inline <style>, importmap, and the <script type="module" src="./src/main.js"> entry point. |
src/main.js |
The app orchestrator: builds the three.js scene, lighting/IBL (RoomEnvironment), OrbitControls, the orientation gizmo (ViewHelper) + model-info HUD, the sample gallery, the loader/spinner/status UI, keyboard shortcuts, the help/About dialogs, and all error routing (engine panel vs. toast). |
src/step.js |
Browser-only CAD loader orchestrator: owns the occt engine lifecycle — a Web Worker (src/step.worker.js) with a transparent main-thread fallback — the file-extension→reader dispatch, initOcct() / resetOcct(), and loadCadFromArrayBuffer(). |
src/step.worker.js |
Classic Web Worker that importScripts() the occt UMD build from the CDN and runs the parse off the main thread, so a dense part never freezes orbit/UI. |
src/step-core.js |
The pure, node-importable half: buildGroupFromOcctResult() (per-mesh typed arrays → THREE.Group), repackResultMesh(), countTriangles(), and the SAMPLES gallery manifest. Imports only three; touches no window/document/CDN. |
src/scene.js |
Pure geometry helpers — finiteness guards, part counting, GPU teardown (disposeGroup), and the aspect-aware fit-distance math shared by Fit / view presets. |
src/ui.js |
Pure text/number formatters, the accepted-extension gate (CAD_EXT_RE), and describeError() — the cause-worded error copy keyed off err.kind. |
src/i18n.js |
The localization table and t() / applyStaticI18n(). |
Note (docs vs. code): earlier rounds shipped as a literal two files (
index.html+src/step.js). The loader has since been split into the modules above (pure core extracted for headless tests in #108, off-thread parse in the worker) — this section documents the code as it stands today.
The path from raw CAD bytes to shaded triangles, end to end:
- Engine bootstrap. occt-import-js ships as a UMD/global factory, not an ES
module.
initOcct()(insrc/step.js) loads it — off the main thread it lives insrc/step.worker.js, whichimportScripts()the CDN build; if the worker can't init or crashes,step.jstransparently falls back to injecting the same occt<script>on the main thread. Either way the factory is called withlocateFilepointed at the CDNdist/dir so the sibling.wasmresolves next to the.js. The resulting engine promise is cached — the ~10–15 s first-load cost is paid once — and is resettable viaresetOcct()so the Retry button can start a genuinely fresh attempt after a stall. - Read. The file extension is mapped to the matching occt reader
(
ReadStepFile/ReadIgesFile/ReadBrepFile); all three return the same{ success, meshes, root }shape, so everything downstream is format-agnostic.result.meshes[]is the flat list of per-solid meshes. - Repack + transfer.
repackResultMesh()(insrc/step-core.js) flattens each raw mesh's nestedattributes.position.array/attributes.normal.array/index.array/colorinto plainFloat32Array/Uint32Array— the exact structured-clone-safe shape the worker zero-copy-transfers back to the main thread. Both engines handbuildGroupFromOcctResult()the identical input, so the built group is byte-for-byte the same no matter which engine parsed. - Build the group.
buildGroupFromOcctResult()makes oneTHREE.Meshper result mesh (== one solid — preserving assembly identity for the Parts panel). For each:position/normalmap onto aTHREE.BufferGeometry's attributes,index.arraysets the index, andcolor(RGB 0..1, defaulting to the accent blue) drives aMeshStandardMaterial. Normals are computed withgeometry.computeVertexNormals()only when the engine supplied none. - Deferred feature edges. Crisp mechanical edges come from
EdgesGeometry(geometry, 30)— a 30° crease threshold keeps real feature edges while smooth fillets stay clean. BecauseEdgesGeometrywalks every triangle, it's the most expensive app-side step, so it's deferred to an idle slot (requestIdleCallback, falling back tosetTimeout) after the shaded mesh is already on screen; the edges pop in a beat later. A liveness guard skips the work if the model was swapped out first, and meshes above a triangle ceiling skip the overlay entirely.
Every loader error is tagged with a cause on err.kind at its throw site, so
the UI can word and route it by why it failed rather than blaming "parse" for an
engine or network problem:
err.kind |
Cause | UI routing |
|---|---|---|
init |
occt/WASM engine or CDN download failure (worker or main thread), or a first-load stall | Persistent inline engine panel with a Retry button (Retry calls resetOcct() then re-runs the last attempt). |
http |
A fetch/HTTP status failure pulling the file bytes |
Transient toast (auto-dismisses). |
parse |
The engine ran but the bytes weren't valid CAD for the reader | Transient toast. |
(Two finer tags also exist — empty for a parse that yields no renderable
geometry and degenerate for non-finite/NaN vertices — both routed to the toast.)
The dispatch is intentionally simple: err.kind === 'init' gets the persistent
panel, everything else gets a self-dismissing toast (see src/main.js). The
cause-worded copy lives in describeError() in src/ui.js. Preserve this
taxonomy when adding a code path that can fail — tag the throw with the right
kind so it lands on the correct surface.
Add a bundled sample. Drop a .step (or any occt-readable) file under
samples/, then add one entry to the SAMPLES array in src/step-core.js:
{ file: 'widget.step', labelKey: 'sampleWidget', reader: 'ReadStepFile' },SAMPLES is the single source of truth — the gallery, the deep-link handler, and
the parse regression test all read it, so there's nothing else to wire up. Add the
matching labelKey string to src/i18n.js. Note the number-key shortcuts map
by position: 1–5 load the first five gallery entries in order, so a new
sample is reachable via its slot without extra code.
Add another OCCT format. occt-import-js already bundles readers beyond STEP, so supporting, say, IGES-only builds or a new occt reader is mostly wiring, not parsing. To surface a format the demo didn't expose, you'd update three spots that gate on the extension:
READER_BY_EXTinsrc/step.js— map the new extension to itsRead*Filemethod (IGES is alreadyReadIgesFile).CAD_EXT_REinsrc/ui.js— the regex the drag-drop guard and file-picker reject path both test against.- The
accept="…"filter on the#file-inputelement inindex.html.
The mesh-build path (repackResultMesh → buildGroupFromOcctResult) is already
format-agnostic — every reader returns the same { success, meshes, root } shape —
so no conversion code changes. (IGES and BREP are in fact already wired through
all three spots; they're the worked example.)
Out of scope, honestly. Formats OCCT doesn't read (STL, OBJ, glTF import, …)
are deliberately not supported — adding them would mean a second parser and a
second mesh-build path, which is more than this demo aims to be. Export to .glb
exists (via three's GLTFExporter); import from other ecosystems does not.
| # | Feature | Status |
|---|---|---|
| 1 | Project scaffold — three.js scene, IBL lighting, OrbitControls | ✅ |
| 2 | CAD loader core — occt-import-js WASM → three.js meshes (STEP/IGES/BREP) | ✅ |
| 3 | File input — drag-and-drop / picker across all supported extensions | ✅ |
| 4 | Sample gallery — bundled models, one-click / number-key load | ✅ |
| 5 | Viewer polish — auto-fit camera, wireframe, gizmo + HUD, material presets | ✅ |
| 6 | Robustness — off-thread parse worker, engine-error panel + Retry, error toasts | ✅ |
| 7 | A11y & i18n — keyboard-navigable camera, shortcuts/help, localized UI | ✅ |
| 8 | Dev tooling — node parse tests + JSDoc typecheck (zero-build preserved) | ✅ |
| 9 | Docs — usage, architecture, STEP→mesh pipeline, extension guide, credits | ✅ |
See Architecture, How STEP becomes a mesh, and Extending it above for how these fit together.
This demo stands on two open-source projects:
- three.js — WebGL 3D rendering (source, MIT License).
- occt-import-js — STEP/IGES import via OpenCascade compiled to WebAssembly (MIT License). It wraps Open CASCADE Technology, which is licensed under the LGPL-2.1 with an additional exception.
Both libraries are loaded at runtime from the jsDelivr CDN.
MIT — see LICENSE.
