//.hxx"]
A3 --> A5["build/bindings////.d.ts.json"]
end
subgraph "Stage 2 — compile (em++)"
A4 --> B1[em++ -c per class]
B1 --> B2["build/bindings/.../.o (cached)"]
end
subgraph "Stage 3 — link (wasm-ld)"
B2 --> C1[wasm-ld + emcc glue]
C1 --> C2[dist/opencascade_single.wasm]
C1 --> C3[dist/opencascade_single.js]
A5 --> C4["dist/opencascade_single.d.ts (build intermediate)"]
end
subgraph "Stage 4 — package assembly"
C4 --> D1[libcascade assemble]
D1 --> D2[dist/types.d.ts + dist/variant.d.ts]
end
`
```
## Stage 1 — bindgen [#stage-1--bindgen]
The in-image `bindings` stage invokes the Python bindgen which:
1. Walks every header reachable from the rendered yml's `bindings:` list via libclang.
2. Builds an AST per class, applies the `bindgen-filters.yaml` exclusions, and
classifies each member as constructor / static method / instance method /
property.
3. Resolves typedefs (including the OCCT `occ::handle<>` family) against a
shared cache.
4. Emits one `.hxx` per class with `EMSCRIPTEN_BINDINGS(...)` registrations and
one `.d.ts.json` shard per class describing the TypeScript shape.
The bindgen is deterministic: same headers + same filter YAML = identical
output bytes.
## Stage 2 — compile [#stage-2--compile]
`em++` compiles each `.hxx` to a `.o`. The cache key is the `.hxx` content
hash plus the compile-time flag fingerprint. Cached `.o` files survive across
consumer builds — that's the speedup that makes a custom-trimmed build take
60 seconds instead of 30 minutes.
## Stage 3 — link [#stage-3--link]
`emcc` links the selected `.o` files — the ones your config's `bindings` array
resolved to — against the pre-compiled OCCT library archives in `dist/libs/`. The
output:
* `.wasm` — the wasm binary.
* `.js` — the emscripten loader glue.
* `.d.ts` — the merged TypeScript declarations (from `.d.ts.json` shards
filtered to the bound symbol set).
* `.build-manifest.json` — symbol coverage report (requested vs compiled,
wasm bytes, validation flags).
`.d.ts` is a build intermediate. `libcascade assemble` verifies that the
variant declarations agree, then publishes the shared surface as `types.d.ts`
and `variant.d.ts`; npm packages do not ship per-variant declaration files.
## How custom C++ enters the pipeline [#how-custom-c-enters-the-pipeline]
A `customBindings` entry with the default `scope: 'all'` is concatenated into
one translation unit which flows through a smaller variant of stages 1+2 —
bindgen discovers Handle/NCollection references, `em++` compiles, and the
result links into the final wasm alongside the auto-generated bindings.
`scope: 'main'` skips bindgen entirely. Those `.cpp` contents compile as raw
Embind registrations and may include their helper implementations. See
[Extend with C++](/docs/toolchain/guides/extend-with-cpp).
## When the pipeline fails [#when-the-pipeline-fails]
| Symptom | Stage | Fix |
| ------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `libclang: cannot find ` | 1 | OCCT headers not mounted into the Docker image |
| `undefined symbol: _ZN10TopoDS_...` | 3 | Remove a `bindings` entry whose `.o` no longer exists, or the class transitively needs another class you trimmed |
| `BindingError: invalid type` at runtime | 3 | Duplicate `EMSCRIPTEN_BINDINGS()` group across a `scope: 'main'` file and a generated `.hxx` |
| Codegen emits `any` for a known type | 1 | The link step always prints a triage summary to stderr when this happens. The build still proceeds by default; set `OCJS_STRICT_TYPES=1` in CI to fail the build instead of shipping a poisoned `.d.ts`. File an issue with the printed triage summary. |
| Codegen emits `unknown` / surfaces an unbound reference | 1 | Same gate as above. Warning printed by default; `OCJS_STRICT_TYPES=1` escalates to a hard failure for CI consumers. |
## What the pipeline produces — JS-side contract [#what-the-pipeline-produces--js-side-contract]
The artifacts above are an implementation detail. The contract those artifacts
expose to JS consumers — overload-dispatched calls, in-place class outputs,
`returnValue` envelopes, Handle elision — lives in two consumer-facing
concept pages:
* [Calling OCCT from JS](/docs/package/concepts/calling-occt-from-js) — overload dispatch, enums,
defaults, and the `TopoDS` downcast bridge.
* [Return shapes](/docs/package/concepts/return-shapes) — class outputs, envelopes, `returnValue`,
and Handle elision.
## Docker stage mapping [#docker-stage-mapping]
The pipeline stages map directly to the published Docker stages described in
[Docker image](/docs/toolchain/reference/docker-image#image-stages):
| Pipeline stage | Docker stage | Published tag |
| ---------------------- | -------------------------------------------- | ------------------------------------- |
| 1 — discover | `bindgen-base` | `:bindgen-base` |
| 2 — emit (TUs + .d.ts) | `bindgen-base` | `:bindgen-base` |
| 3 — compile + link | `compiled-{threading}` + `final-{threading}` | `:single-threaded`, `:multi-threaded` |
`:bindgen-base` is both a build stage and a published image — it carries the
patched OCCT tree, the PCH, and the `.d.ts.json` index but not the
pre-compiled `.o` files. Custom-bindings consumers pull `:bindgen-base`,
re-run `generate` against their own YAML, and compile from there.
## Related [#related]
* [Extend with C++](/docs/toolchain/guides/extend-with-cpp) — how to inject custom C++ into the pipeline.
* [Trim symbols](/docs/toolchain/guides/trim-symbols) — controlling which classes survive into stage 3.
* [YAML schema](/docs/toolchain/reference/yaml-schema) — every YAML key the pipeline consumes.
---
# Two-channel config model
URL: /docs/toolchain/concepts/two-channel-config-model
**Maintainer track.** Skip this page if you consume the published npm
package — every shipped build already pins the right channel-1 flags. The
channels matter when you rebuild libcascade from source; a custom build
configures channel 2 only.
libcascade exposes two configuration channels with different lifecycles and
different scope. Treat them as orthogonal — confusion between the two is the
most common cause of build errors.
## Channel 1 — compile-time `OCJS_*` env vars [#channel-1--compile-time-ocjs_-env-vars]
Set at the **bindgen + C++ compile** stage. Bake into every `.o` file the
final wasm is linked from.
| Variable | Effect |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `OCJS_EXCEPTIONS=1` | Compile with `-fwasm-exceptions` everywhere |
| `OCJS_SIMD=1` | Compile with `-msimd128` |
| `OCJS_RELAXED_SIMD=1` | Additionally emit `-mrelaxed-simd` (Chrome/Firefox only) |
| `OCJS_STRICT_TYPES=1` | Escalate missing-typedef warning to a build failure (default `0`: warn-only — the triage summary is always printed to stderr) |
[^lto]: `OCJS_LTO` exists but is intentionally off in every shipped preset.
The workspace benchmarks showed LTO increasing the wasm size for libcascade's
object distribution, so it stays disabled. Flip it on locally only if
you're benchmarking a specific bindings trim.
These flags pin into a **build-flags manifest** alongside each cached `.o`.
Mixing builds with mismatched flags fails-loud at link time — never silent
ABI breakage.
## Channel 2 — link-time settings and flags [#channel-2--link-time-settings-and-flags]
Set per-consumer-build in `libcascade.config.ts`. Apply only to the final
`emcc` link step.
```typescript notypecheck
settings: {
MODULARIZE: true,
EXPORT_ES6: true,
ALLOW_MEMORY_GROWTH: true,
WASM_BIGINT: true,
EVAL_CTORS: 2,
},
compilerFlags: { optimize: 'O3' },
```
These flags control loader shape (ESM vs CommonJS), memory limits, and
environment detection. They cannot retroactively change the exception model
or SIMD configuration the `.o` files were compiled with.
## Why the split? [#why-the-split]
The bindgen produces one `.o` file per generated binding in the maintainer Docker pipeline. Caching
them across consumer builds turns a 30-minute full build into a 60-second link.
The cache key must be deterministic — that's what the compile-time channel
fingerprint guards.
If consumers could change compile-time flags from their own config, the cache invariant
would break: a downstream `-fexceptions` request would silently rebuild every
class, defeating the cache. Splitting the channels makes the contract
explicit.
## When you actually need to change channel 1 [#when-you-actually-need-to-change-channel-1]
Almost never as a consumer. The published build is the named
`single-threaded` preset from `build-configs/configurations.json`, which pins:
* `OCJS_EXCEPTIONS=1` + `OCJS_EH_MODE=wasm` (native wasm exceptions)
* `OCJS_SIMD=1` (baseline SIMD, Safari-compatible)
* `OCJS_CLOSURE=true` + `OCJS_CONVERGE=true`
* `THREADING=single-threaded`
The matching config carries `WASM_BIGINT: true` and `EVAL_CTORS: 2` at link
time. The threaded variant retains BigInt but removes eval-ctors with
`EVAL_CTORS: null`.
Presets are addressed by name, not by raw env vars — see
[Named compile-time configurations](/docs/toolchain/reference/configurations) for the full
list (`single-threaded`, `single-threaded-smallest`, `multi-threaded`,
`debug`).
The combinations not yet pre-built are exotic — `OCJS_RELAXED_SIMD=1` for
Chrome-only deploys, custom allocator pairings, non-default WASM-opt budgets.
To get one, fork the Docker image build pipeline and rebuild from source.
## Diagnostic checklist [#diagnostic-checklist]
If a build acts strangely:
1. Confirm channel-1 fingerprint is what you expect. The full flag set is
recorded in the in-repo build manifest (regenerated by every `bindings`
stage):
```bash
cat build/build-flags.json
```
For the **published** tarball, the consumer-facing equivalent is
`dist/opencascade_single.provenance.json`, which captures the active preset,
compile flags, and commit SHA the wasm was built from.
2. Confirm channel-2 settings actually made it into the wasm
(`libcascade build --render-only` prints the rendered flag list first):
```bash
strings dist/my-build.wasm | grep -E 'STACK_SIZE|MAXIMUM_MEMORY'
```
3. Recompare against a known-good cached build — drift here points at
channel-1 cache poisoning (rebuild the deps layer).
---
# Variants and assemble
URL: /docs/toolchain/concepts/variants-and-assemble
A custom build usually ships more than one binary from the same symbol set: a
single-threaded one that loads anywhere, and a pthread-enabled one for hosts
that are cross-origin isolated. `variants` expresses that in the config, and
`libcascade assemble` turns the resulting artifacts into a package surface.
## Variants share one bindings list [#variants-share-one-bindings-list]
```typescript notypecheck
variants: [
{ name: 'single', settings: { EVAL_CTORS: 2 } },
{
name: 'multi',
compilerFlags: { threads: true },
settings: { PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency', SHARED_MEMORY: true },
},
],
```
Two binaries, one `bindings` array, a handful of flags of difference. Nothing
templates one config into two.
Note what the multi variant does *not* declare. `compilerFlags: { threads: true }`
renders `-pthread`, and the `requires: ['threads']` that drives image selection
and the generated capability probe is **inferred** from exactly that — a build
against shared memory cannot run without `SharedArrayBuffer`, so the capability
is a consequence of the flags rather than something you restate. Declare
`requires` yourself only to add a capability the flags do not imply.
That sharing is load-bearing. When variants can drift in their symbol lists,
their generated `.d.ts` files drift too, and a consumer holding a
`TopoDS_Shape` from one variant cannot pass it to a function typed against the
other — the two `OpenCascadeInstance` types are structurally incomparable
despite describing the same classes. Sharing the list makes one shared surface
possible.
A per-variant `bindings` extension is allowed but discouraged for exactly that
reason.
## A capability is both a build and a runtime fact [#a-capability-is-both-a-build-and-a-runtime-fact]
A variant's `threads` capability does two things:
* **At build time** it selects the multi-threaded container image for that
variant.
* **At load time** it becomes the capability the generated selector probes:
`SharedArrayBuffer` present ∧ (`globalThis.crossOriginIsolated` ?? running
under Node).
Capabilities are inferred from the variant's effective build flags —
`compilerFlags.threads`, a `-pthread` raw flag, or a `SHARED_MEMORY` /
`USE_PTHREADS` setting all imply `threads`. An explicit `requires` array is
unioned with what was inferred, so it can add a capability but never remove
one: a build that genuinely needs shared memory cannot be configured to skip
its own probe.
A variant with no capabilities is loadable everywhere.
## What `assemble` writes [#what-assemble-writes]
```bash
npx libcascade assemble
```
| File | Role |
| ------------------------- | --------------------------------------------------------------------------------------------------- |
| `types.d.ts` | One d.ts unioning every variant's surface. Symbols only some variants bind are typed optional. |
| `init.js` / `init.d.ts` | The `./init` subpath — `createInstance`, the selector, and the override symbol. |
| `init..js` | The `.//init` subpath — the same entry pinned to one variant. Multi-variant packages only. |
| `index.js` / `index.d.ts` | The root entry, in `eager` or `factory` mode. |
| `variant.d.ts` | Types for the raw per-variant glue subpaths (`./single`, `./multi`, …). |
| `exports.json` | The `exports` fragment to merge into `package.json`. |
The shared d.ts and the eager barrel are rendered from one symbol list computed
once, so the declaration and the runtime export cannot disagree.
## `createInstance` [#createinstance]
`./init` is the entry every consumer can use, in every mode:
```typescript notypecheck
import { createInstance } from 'my-occt-package/init';
const oc = await createInstance(); // most capable variant
const single = await createInstance({ variant: 'single' });
const capped = await createInstance({ variant: 'multi', threadCount: 4 });
```
| Option | Effect |
| ------------- | ------------------------------------------------------------------------------------------------------- |
| `variant` | Load a named variant instead of the selected one. |
| `threadCount` | Size OCCT's default thread pool and raise its launch cap to match. Omitted, OCCT sizes the pool itself. |
| `locateFile` | Emscripten's WASM URL resolver, for bundlers that relocate the binary. |
| `wasmBinary` | Pre-fetched bytes, instead of a network fetch. |
| `wasmMemory` | A `WebAssembly.Memory` you own. |
It owns the plumbing every consumer used to reimplement:
* **Glue self-reference.** Emscripten spawns pthread workers from the glue's
own URL; `createInstance` passes it as `mainScriptUrlOrBlob` so you do not
have to know that name.
* **Node `file:` URL → path conversion.** Node's `Worker` takes a path, and a
`file:` URL's pathname keeps a leading slash before a Windows drive letter
that Node rejects.
* **OCCT thread-pool sizing.** For a `threads` variant it calls
`OSD_ThreadPool.DefaultPool(…)` and `SetNbDefaultThreadsToLaunch(…)` so the
lazily-created default pool does not stay smaller than the worker count baked
into the binary.
* **An actionable error** when you ask for a variant this host cannot run,
naming the unmet capability and how to provide it.
## `.//init` — one variant, one glue [#variantinit--one-variant-one-glue]
A multi-variant package also generates a **pinned** entry per variant:
```typescript notypecheck
import { createInstance } from 'my-occt-package/single/init';
const oc = await createInstance(); // always the single variant
```
It exports the same `createInstance` and owns the same plumbing. The one thing
it does differently is name exactly one glue file.
That matters because of how bundlers treat asset URLs. The shared `./init` must
be able to *reach* every variant — the choice is made at runtime — so it
contains one `new URL('./.js', import.meta.url)` per variant. Vite's
`vite:asset-import-meta-url` transform emits an asset for every one of those at
transform time, **before** tree-shaking, and no branch is ever statically dead.
An app that only ever loads the single variant therefore still ships the multi
variant's glue JS — 60–80 KB it never fetches. The pinned entry contains one
such URL, so nothing else is emitted.
The `.wasm` is unaffected either way. The glue import is deliberately opaque to
bundlers, so only the binary a consumer references itself — via
`.//wasm` or `locateFile` — enters a build. That property holds for
both entries and is pinned by a Vite host-app test in the toolchain suite.
| | `./init` | `.//init` |
| ------------------------------------- | ------------------------ | -------------------------------------------------- |
| Variant | selected at load time | fixed at import time |
| Glue assets in the bundle | one per declared variant | one |
| Capability probe + actionable error | yes | yes |
| `Symbol.for('.select')` override | picks the variant | must name this entry's variant, or the call throws |
Pick `./init` when the app should adapt to the host (a library, or a page that
may or may not be cross-origin isolated). Pick `.//init` when the
answer is already known — a Node CLI that always wants threads, a page that
deliberately serves the single-threaded build — and the bytes matter.
Single-variant packages get no pinned entry: their `./init` already resolves
exactly one glue.
A pinned entry knows about one variant and nothing else, so both
`createInstance({ variant: 'other' })` and a `Symbol.for('.select')`
override naming another variant fail with
`Unknown variant "other". Declared: single.` — loudly, rather than by quietly
handing back a variant you did not ask for.
## Root modes [#root-modes]
### `factory` [#factory]
```typescript notypecheck
assemble: { exports: 'factory' }
```
The root re-exports `createInstance`. Nothing is instantiated until you ask.
The factory entries re-export the shared surface with `export type *`, so
`import type { TopoDS_Shape } from 'my-occt-package'` keeps working in type
position without promising a runtime value the root does not export.
Pick this when your package is a library whose consumers decide when — and
whether — the WASM module comes up.
### `eager` [#eager]
```typescript notypecheck
assemble: { exports: 'eager' }
```
The root probes capabilities, selects a variant, initialises it with a
top-level `await`, and re-exports every bound symbol as a named value:
```typescript notypecheck
import oc, { BRepPrimAPI_MakeBox } from 'libcascade';
using box = new BRepPrimAPI_MakeBox(10, 10, 10);
using also = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
```
That is the mode the `libcascade` package itself ships. Named value exports are
what make `gp_Pnt` usable as a value in an external editor instead of failing
with "cannot be used as a value".
Importing `./init` never evaluates the eager root, so a consumer who wants the
factory never pays for an instantiation they did not ask for.
## Selection and the override symbol [#selection-and-the-override-symbol]
The selector picks the **most capable** variant whose `requires` all probe
true; configuration order breaks ties. To force a choice, set the override
before importing the root or calling `createInstance`:
```javascript notypecheck
globalThis[Symbol.for('libcascade.select')] = 'single';
```
The symbol key is `.select`. It is a pre-import override:
set it in a module that runs first, not after the root has already evaluated.
## The exports fragment [#the-exports-fragment]
```bash
npx libcascade assemble --write-exports
```
```json title="package.json (generated subpaths)"
{
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./init": { "types": "./dist/init.d.ts", "default": "./dist/init.js" },
"./single": { "types": "./dist/variant.d.ts", "default": "./dist/myapp_single.js" },
"./single/init": { "types": "./dist/init.d.ts", "default": "./dist/init.single.js" },
"./single/wasm": "./dist/myapp_single.wasm",
"./multi": { "types": "./dist/variant.d.ts", "default": "./dist/myapp_multi.js" },
"./multi/init": { "types": "./dist/init.d.ts", "default": "./dist/init.multi.js" },
"./multi/wasm": "./dist/myapp_multi.wasm"
}
}
```
The pinned entries reuse `init.d.ts` — they export the same surface, so there is
no reason to generate N copies of the same declarations.
The merge keeps every subpath you declared yourself — hand-written aliases such
as `./wasm` or `./api-reference.json` keep resolving. It does not touch
`files`; add the generated files to that list once.
## What `dist/` holds, and what ships [#what-dist-holds-and-what-ships]
`dist/` is not a publish manifest. It holds three classes of file, and **`files`
in `package.json` is the authoritative statement of which ones ship**:
| Class | Files | Ships |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Shipped surface | `types.d.ts`, `init.js` / `init.d.ts`, `init..js`, `index.js` / `index.d.ts`, `variant.d.ts`, `.js`, `.wasm` | Yes — this is what `exports` points at |
| Durable records | `.build-manifest.json`, `.provenance.json`, `.js.symbols` | Your call — `libcascade` ships all three so consumers can audit the build |
| In-repo build products | `.d.ts` (one per variant), `exports.json` | No |
The per-variant `.d.ts` files look like leftovers next to the
generated `types.d.ts`, and they are not. They are the container `dts` step's
artifact, with two real consumers:
* **`assemble` parses every one of them** to build the shared `types.d.ts`.
Delete them and `assemble` cannot run.
* **The bindgen's own type tests import them directly.** In this repository, 30
files under `tests/` do `import type { … } from '../dist/opencascade_single'`,
because the per-variant d.ts *is* the artifact under test. They cannot be
repointed at `types.d.ts`, which is a different thing — a cross-variant union.
So they are build products consumed in-repo that do not publish. Leave
them in `dist/` and leave them out of `files`; `exports.json` is likewise a
review aid for `--write-exports` and never ships.
## Consumer prerequisites for a threads variant [#consumer-prerequisites-for-a-threads-variant]
### Cross-origin isolation [#cross-origin-isolation]
Browsers gate `SharedArrayBuffer` behind cross-origin isolation. Every page
that loads the threaded binary must be served with:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
Without them the capability probe fails, the selector falls back to a variant
without `requires`, and an explicit `createInstance({ variant: 'multi' })`
throws with that remediation in the message. Node exposes `SharedArrayBuffer`
unconditionally — no headers needed.
### Vite needs ES-module workers [#vite-needs-es-module-workers]
Emscripten spawns its pthread workers as ES modules with top-level await.
Vite's default worker format is `iife`, which cannot emit them:
```typescript title="vite.config.ts" notypecheck
export default {
worker: { format: 'es' },
};
```
### Bundlers and the glue [#bundlers-and-the-glue]
The generated `init` entries reference each glue with `new URL(…,
import.meta.url)`, so bundlers emit it as a first-class asset, but load it
through an import that is deliberately opaque to them
(`import(/* webpackIgnore: true */ /* @vite-ignore */ href)`). Cutting that
graph edge is what keeps the `.wasm` out: a statically analysable import makes
a bundler follow the glue, and a pthread build contains
`new Worker(new URL(, import.meta.url))`, so the glue gets re-bundled as
a worker entry and every variant's binary comes with it. No configuration is
needed either way. The glue resolves its `.wasm` sibling relative to its own
URL; pass `locateFile` only when your deployment moves the binary away from the
glue. See
[Bundler and locateFile](/docs/package/guides/bundler-locatefile) for
per-bundler recipes.
## Related [#related]
* [Config reference](/docs/toolchain/reference/config) — the `variants` and `assemble` fields.
* [CLI reference](/docs/toolchain/reference/cli) — `assemble` flags.
* [Custom multi-threaded build](/docs/toolchain/guides/multi-threading) — the threads variant end to end.
---
# Migrate from a yml build
URL: /docs/toolchain/getting-started/migrate-from-yaml
If your package builds a custom OCCT WASM today, it probably owns some
combination of: a hand-written build yml, a templating layer to express
variants, a `docker run … && mv …` script per variant, one `.d.ts` per variant,
and a hand-maintained image tag. All five collapse into one config file and two
commands.
This guide follows the shape of the largest real migration —
`replicad-opencascadejs`, which had every one of those pieces.
## Before [#before]
```json title="package.json (before)"
{
"scripts": {
"build": "pnpm run generateConfig && pnpm run buildSingle && pnpm run buildMulti",
"generateConfig": "ytt -f build-source/ --output-files build-config",
"buildSingle": "cd build-config && docker run --rm -v \"$(pwd):/src\" -u \"$(id -u):$(id -g)\" ghcr.io/taucad/opencascade.js:canary-ebd263f1-single-threaded link custom_build_single.yml && mkdir -p ../dist && mv replicad_single.js replicad_single.wasm replicad_single.d.ts ../dist/ && cd -",
"buildMulti": "cd build-config && docker run --rm … multi-threaded link custom_build_multi.yml && mv … && cd -"
}
}
```
```text
build-source/defaults.yml # bindings + buildFlags, ~300 lines
build-source/custom_build_single.yml # ytt template: name + EVAL_CTORS
build-source/custom_build_multi.yml # ytt template: name + pthread flags
build-config/custom_build_single.yml # generated, committed
build-config/custom_build_multi.yml # generated, committed
dist/replicad_single.d.ts # ~260k lines
dist/replicad_multi.d.ts # ~260k near-identical lines
```
## After [#after]
```json title="package.json (after)"
{
"scripts": {
"build": "pnpm run buildSingle && pnpm run buildMulti && pnpm run assemble",
"buildSingle": "libcascade build --variant single",
"buildMulti": "libcascade build --variant multi",
"assemble": "libcascade assemble --write-exports"
},
"devDependencies": {
"@libcascade/toolchain": "3.0.0"
}
}
```
```text
libcascade.config.ts # the only build input
build-config/wrappers/*.cpp # unchanged, referenced in place
dist/types.d.ts # one shared surface
dist/init.js, dist/index.js # generated entries
```
## Step 1 — install the toolchain [#step-1--install-the-toolchain]
```bash
npm install --save-dev @libcascade/toolchain
```
## Step 2 — run the migrator [#step-2--run-the-migrator]
```bash
npx libcascade migrate build-config/custom_build_single.yml \
build-config/custom_build_multi.yml \
--out libcascade.config.ts
```
Pass every variant's yml to the one invocation. Two ymls that differ only in a
name and a handful of flags are one config with two variants, so that is what
comes out: the flags they share become the base, the rest become each variant's
delta. The command writes nothing over an existing file without `--force`, and
prints what a human has to check to stderr.
The rest of this step is the mapping it applied — read it to review the result,
or to do the translation by hand if your build is far enough from the reference
shape that `migrate` refuses it.
### The mapping [#the-mapping]
The container-side yml has six concepts. Each maps to one `defineBuild` field.
| yml | `defineBuild` |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mainBuild.name: replicad_single.js` | `name: 'replicad'` + `variants: [{ name: 'single' }]` — the artifact base name is derived, and the `.js` suffix disappears. Keep an exact legacy filename with `variants[].outputName`. |
| `mainBuild.bindings[].symbol` | `bindings: [...]` — flat strings instead of a list of one-key maps. |
| `mainBuild.emccFlags: -sNAME=value` | `settings: { NAME: value }` — typed, one entry per `-s` flag. |
| `mainBuild.emccFlags: -O3 / -msimd128 / -fwasm-exceptions / --no-entry / -flto` | `compilerFlags: { optimize, simd, exceptions, noEntry, lto }`. |
| any other `emccFlags` entry (`-pthread`, `-Wl,--allow-undefined`, `--emit-symbol-map`) | `rawFlags: [...]`. |
| `mainBuild.additionalBindFiles` | `customBindings: [{ file, symbols, scope: 'main' }]`. |
| `additionalCppFiles` (top level) | `customBindings: [{ file, symbols }]` — `scope` defaults to `'all'`. |
| `extraBuilds[]` | `variants[]`. |
| `generateTypescriptDefinitions` | same name, same meaning. |
Value translations worth calling out:
| yml flag | `settings` entry |
| ------------------------------------------------ | ------------------------------------------------ |
| `-sALLOW_MEMORY_GROWTH=1` | `ALLOW_MEMORY_GROWTH: true` |
| `-sINITIAL_MEMORY=100MB` | `INITIAL_MEMORY: '100MB'` |
| `-sSTACK_SIZE=8388608` | `STACK_SIZE: 8_388_608` |
| `-sEXPORTED_RUNTIME_METHODS=["FS","wasmMemory"]` | `EXPORTED_RUNTIME_METHODS: ['FS', 'wasmMemory']` |
| `-sENVIRONMENT=web,worker,node` | `ENVIRONMENT: ['web', 'worker', 'node']` |
| `-sERROR_ON_UNDEFINED_SYMBOLS=0` | `ERROR_ON_UNDEFINED_SYMBOLS: false` |
| `-sWASM_BIGINT` (bare) | `WASM_BIGINT: true` |
Any `emccFlags` entry none of those rows claims is kept in `rawFlags` verbatim
and listed in the generated header — `migrate` never drops a flag. An unknown
*key* is the other way round: it stops the command, because the yml schema has
no pass-through bucket for one.
The ytt layer has no counterpart because it has nothing left to do: the shared
list is the base config, and the templates were only ever adding a variant name
and a handful of flags.
For replicad's two ymls, the emitted config reads:
```typescript title="libcascade.config.ts"
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'replicad',
bindings: [/* the flat contents of defaults.yml */],
customBindings: [
{ file: 'build-config/wrappers/shape-hasher.cpp', symbols: ['OCJS_ShapeHasher'] },
{ file: 'build-config/wrappers/mesh-extractor.cpp', symbols: ['ReplicadMeshData', 'ReplicadMeshExtractor'] },
// …one entry per wrapper file
],
settings: {
EXPORT_ES6: true,
MODULARIZE: true,
ALLOW_MEMORY_GROWTH: true,
INITIAL_MEMORY: '100MB',
MAXIMUM_MEMORY: '4GB',
EXPORTED_RUNTIME_METHODS: [
'FS',
'wasmMemory',
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
ENVIRONMENT: ['web', 'worker', 'node'],
ERROR_ON_UNDEFINED_SYMBOLS: false,
STACK_SIZE: 8_388_608,
WASM_BIGINT: true,
EVAL_CTORS: 2,
},
compilerFlags: { exceptions: 'wasm', noEntry: true, simd: true, optimize: 'O3' },
variants: [
{ name: 'single' },
{
name: 'multi',
compilerFlags: { threads: true },
settings: {
EVAL_CTORS: null,
PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency',
SHARED_MEMORY: true,
},
},
],
assemble: { exports: 'factory' },
});
```
The base holds what both ymls agreed on; the `multi` variant holds the three
lines they differed by. `EVAL_CTORS: null` is how a variant **removes** an
inherited setting — the single-threaded yml had it and the multi-threaded one
did not, because constructor evaluation order is non-deterministic under pthread
workers. `requires: ['threads']` is absent on purpose: `compilerFlags.threads`
implies it.
### Review 1 — `customBindings` needs a symbol list your yml never declared [#review-1--custombindings-needs-a-symbol-list-your-yml-never-declared]
The yml listed wrapper file paths and, separately, the symbols those files
provide — in the same `bindings:` array as the OCCT ones, with nothing tying
the two together. `customBindings` makes the link explicit: each file declares
what it provides, which is what lets `bindings` stay type-checked (custom names
are accepted precisely because a wrapper declared them) and what keeps
[`detect` and `check`](/docs/toolchain/guides/detect-and-check) from reporting your own
classes as unknown.
`migrate` reconstructs the link by reading each `.cpp` for the classes it
registers — top-level `class` / `struct` definitions and Embind
`class_("Name")` calls. That is a derivation, not a declaration, so it is the
first thing to check. Where it finds nothing it writes a
`TODO(libcascade migrate)` marker with the candidate names instead of guessing;
`libcascade build` refuses the config until the marker is resolved.
Each `Replicad*`-style name stays in `bindings` too — that is what requests it.
### Review 2 — the exception-helpers delta [#review-2--the-exception-helpers-delta]
emsdk 6.0.5 removed `-sEXPORT_EXCEPTION_HANDLING_HELPERS`. If your yml carried
it, `migrate` deletes it and exports the three helpers directly — because with
`-fwasm-exceptions` the link pipeline **hard-fails** without them:
```diff
- -sEXPORT_EXCEPTION_HANDLING_HELPERS
- -sEXPORTED_RUNTIME_METHODS=["FS","wasmMemory"]
+ EXPORTED_RUNTIME_METHODS: [
+ 'FS',
+ 'wasmMemory',
+ 'getExceptionMessage',
+ 'incrementExceptionRefcount',
+ 'decrementExceptionRefcount',
+ ],
```
This is the same delta the upstream `full.yml` received. The other rewrite
`migrate` applies is `-sUSE_PTHREADS=1` → `compilerFlags: { threads: true }`:
emcc keeps that setting name only as a deprecated legacy alias of the `-pthread`
your yml already carried beside it, so the config states the request once. Both
rewrites are commented at their site in the generated file.
### Review 3 — the rest of the generated header [#review-3--the-rest-of-the-generated-header]
The header block `migrate` writes names everything else it decided for you:
| Item | What to check |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `assemble.exports: 'factory'` | The yml has no equivalent concept. `'factory'` exposes only the variant-selecting `createInstance`; switch to `'eager'` if consumers import OCCT names off the package root. |
| Variant names | Split from the artifact names (`replicad_single` → `single`). A name that does not fit `_` keeps its exact published filename through `variants[].outputName`. |
| `rawFlags` | Every flag no typed member models, listed. Each is passed to emcc unchanged; the question is whether you still want it. |
## Step 3 — replace the build scripts [#step-3--replace-the-build-scripts]
```bash
npx libcascade build --render-only
```
Renders the yml the container will actually receive, without needing an engine.
Diff it against your committed `build-config/*.yml` — flag order differs (the
renderer emits a canonical order; emcc is order-insensitive for distinct
flags), but the flag *set* should match. That diff is the migration's first
gate.
Then build for real:
```bash
npx libcascade build
```
The `mv` chains are gone: the driver mounts a scratch output directory, points
`OCJS_OUTPUT_DIR` at it, and moves artifacts into `dist/` only after the run
exits 0. `cd build-config && … && cd -` is gone too — paths resolve relative to
the config file.
The image tag is gone as well. The digest for your toolchain version is
embedded in the package and verified after the pull, so the tag-drift problem —
a tag string in a shell script that had to be kept in sync by hand with the
`libcascade` version you depend on — no longer exists.
## Step 4 — assemble the package surface [#step-4--assemble-the-package-surface]
```bash
npx libcascade assemble --write-exports
```
This is where the duplicated `.d.ts` files die. One `types.d.ts` describes both
variants, so a shape produced by the single-threaded instance is assignable to a
function typed against the multi-threaded one. Downstream `as unknown as`
erasure written to work around that incomparability can be deleted.
`--write-exports` merges the generated subpaths (`.`, `./init`, `./single`,
`./single/wasm`, `./multi`, `./multi/wasm`) into your `package.json` while
preserving hand-written ones such as `./wasm`. Add the generated files to
`files` once.
## Step 5 — move consumers onto `createInstance` [#step-5--move-consumers-onto-createinstance]
Whatever your package taught before — a hand-written `initOCSingle.js`, tests
passing `mainScriptUrlOrBlob` themselves, per-variant loader modules in a
downstream app — collapses into the generated `./init` entry:
```typescript notypecheck
import { createInstance } from 'replicad-opencascadejs/init';
const oc = await createInstance(); // best available
const mt = await createInstance({ variant: 'multi' }); // explicit
```
See [Variants and assemble](/docs/toolchain/concepts/variants-and-assemble) for the selector,
the override symbol, and the cross-origin-isolation requirement.
## Step 6 — delete [#step-6--delete]
* `build-source/` and every ytt template.
* The generated `build-config/*.yml`.
* `ytt` from your tooling — the dependency and any CI install step.
* The `generateConfig` script.
* The `docker run` / `mkdir -p` / `mv` / `cd -` script bodies.
* The per-variant `.d.ts` files from `dist/` and from `files`.
* Any hard-coded `ghcr.io/taucad/opencascade.js:` string.
Keep your `wrappers/*.cpp` exactly where they are — `customBindings` references
them in place.
Add `.libcascade/` to `.gitignore`.
## Step 7 — prove parity [#step-7--prove-parity]
1. `build-manifest.json` deltas — requested, compiled, and alias-resolved
counts — match the pre-migration build for each variant.
2. Your package's own test suite passes against the regenerated artifacts.
3. Downstream consumers build and run with the loaders replaced.
Then wire the drift guard in, so the class of failure custom builds actually
suffer from cannot come back silently:
```bash
npx libcascade check src
```
## Related [#related]
* [Config reference](/docs/toolchain/reference/config) — every field and its type story.
* [CLI reference](/docs/toolchain/reference/cli) — `build`, `assemble`, `detect`, `check`.
* [Container yml contract](/docs/toolchain/reference/yaml-schema) — what the renderer emits, for when you diff it.
---
# Quickstart — custom build
URL: /docs/toolchain/getting-started/quick-start
Most applications should install the prebuilt [`libcascade`](/docs/package/getting-started/quick-start-npm)
package and stop there. Reach for the toolchain when you need a WASM binary the
prebuilt one cannot be: a trimmed symbol set, your own C++ wrappers, or
different Emscripten link settings.
**Pre-release.** `@libcascade/toolchain` ships lockstep-versioned with
`libcascade` — the toolchain that built the binary you depend on is the one
whose types describe it. Both are at `3.0.0-beta.0` until the v3 release.
## Prerequisites [#prerequisites]
* Node 22+.
* A container engine: Docker Desktop, [colima](https://github.com/abiosoft/colima),
Rancher Desktop, OrbStack, or Podman. The toolchain drives the published image
for you — you never write a `docker` command.
* A working directory under your home folder on macOS/Windows. Docker Desktop
does not share `/tmp` or `/opt` into its VM by default, and outputs vanish
silently.
GitHub-hosted macOS runners ship no container engine at all. Run toolchain
builds on a Linux runner.
## 1. Install [#1-install]
```bash
npm install --save-dev @libcascade/toolchain
```
It is a `devDependency` on purpose: it shells out to a container engine and
must never appear in a production dependency graph. It has no `postinstall`
hook and does nothing until you invoke it.
## 2. Write the config [#2-write-the-config]
```typescript title="libcascade.config.ts"
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'myapp',
bindings: [
'BRepPrimAPI_MakeBox',
'BRepAlgoAPI_Cut',
'BRepMesh_IncrementalMesh',
'STEPControl_Reader',
'STEPControl_Writer',
'TopExp_Explorer',
'TopoDS_Shape',
'gp_Pnt',
],
settings: {
MODULARIZE: true,
EXPORT_ES6: true,
ALLOW_MEMORY_GROWTH: true,
INITIAL_MEMORY: '100MB',
MAXIMUM_MEMORY: '4GB',
// Required with `exceptions: 'wasm'` on emsdk 6.0.5 — the link fails
// without these three. See the exception-helpers note below.
EXPORTED_RUNTIME_METHODS: [
'FS',
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
ENVIRONMENT: ['web', 'worker', 'node'],
// OCCT's OSD_MemInfo references mallinfo, which Emscripten does not provide.
ERROR_ON_UNDEFINED_SYMBOLS: false,
},
compilerFlags: { optimize: 'O3', simd: true, exceptions: 'wasm', noEntry: true },
variants: [{ name: 'single' }],
assemble: { exports: 'factory' },
});
```
Everything in that file is compile-checked. A typo in `bindings` is a
TypeScript error with a "did you mean" suggestion, an unknown `-s` name in
`settings` does not exist on the type, and `INITIAL_MEMORY: '100mb'` — which
emcc silently misparses — does not match the `MemorySize` template literal.
See [Config reference](/docs/toolchain/reference/config) for every field.
Do not have a bindings list yet? `npx libcascade detect src` scans your source
and prints one to paste in. Read
[detect and check](/docs/toolchain/guides/detect-and-check) first — it is an onboarding
tool, not a size optimizer.
## 3. Build [#3-build]
```bash
npx libcascade build
```
For each variant the CLI renders the container-side yml into `.libcascade/`,
pulls the digest-pinned image, runs the link with the artifact directory
mounted, and moves the results into `dist/` only after the run exits 0 — a
failed build never leaves half an artifact behind.
```text
dist/
myapp_single.js # Emscripten glue
myapp_single.wasm # the binary
myapp_single.d.ts # generated TypeScript declarations
myapp_single.js.symbols # symbol map
myapp_single.build-manifest.json # requested / compiled / missing symbols
myapp_single.provenance.json # toolchain + source commits
```
Add `.libcascade/` to your `.gitignore`. The rendered ymls and the raw
container output stay there for inspection after a failure.
Useful flags:
```bash
npx libcascade build --variant single # one variant instead of all
npx libcascade build --render-only # render the yml(s) and stop — no engine needed
npx libcascade build --config path/to/libcascade.config.ts
```
`build` fails loudly when the container's own `build-manifest.json` reports
`validation_passed: false`, printing the missing symbols. That matters because
a **missing binding links successfully and fails at runtime** with a
`BindingError` — the link step alone proves nothing.
## 4. Assemble the package surface [#4-assemble-the-package-surface]
```bash
npx libcascade assemble
```
`assemble` never touches a container. It reads the per-variant `.d.ts` and
build manifests from `dist/` and writes the npm-publishable surface next to
them: one shared `types.d.ts`, an `./init` entry exposing `createInstance`, a
root entry, and an `exports.json` fragment. Add `--write-exports` to merge that
fragment straight into your `package.json`.
See [Variants and assemble](/docs/toolchain/concepts/variants-and-assemble) for what each
generated file does and when to pick `exports: 'eager'` over `'factory'`.
## 5. Consume the artifacts [#5-consume-the-artifacts]
With `assemble: { exports: 'factory' }`, the root entry re-exports the factory
and nothing is instantiated until you ask:
```typescript title="src/oc.ts" notypecheck
import { createInstance } from 'my-occt-package';
export const oc = await createInstance();
```
Inside the package that produced `dist/`, import the generated entry directly:
```typescript notypecheck
import { createInstance } from './dist/init.js';
const oc = await createInstance();
using box = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
const shape = box.Shape();
```
`createInstance` owns the plumbing consumers used to copy by hand: variant
selection, the glue self-reference pthread workers spawn from, Node `file:`
URL → path conversion, and OCCT thread-pool sizing.
## Exception helpers on emsdk 6.0.5 [#exception-helpers-on-emsdk-605]
The image's Emscripten removed `-sEXPORT_EXCEPTION_HANDLING_HELPERS`. With
`compilerFlags: { exceptions: 'wasm' }` the link pipeline **hard-fails** unless
`EXPORTED_RUNTIME_METHODS` exports the three helpers directly:
```typescript notypecheck
EXPORTED_RUNTIME_METHODS: [
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
```
Add whatever else your app needs (`'FS'`, `'wasmMemory'`) to the same array.
## Keep it honest in CI [#keep-it-honest-in-ci]
```bash
npx libcascade check src
```
`check` recomputes the symbols your source references and exits non-zero when
any of them is missing from `bindings`. It converts the runtime-`BindingError`
failure class into a build-time one. Put it next to your typecheck.
## Next steps [#next-steps]
Every `defineBuild` field, its type, and what the generated unions check.
One bindings list, N binaries, one `types.d.ts`, and the generated entries.
Field-by-field mapping from `defaults.yml` + ytt + `docker run` scripts.
Declare your own wrapper files and the symbols they provide.
---
# Emscripten settings and flags
URL: /docs/toolchain/guides/custom-emcc-flags
Once the `bindings` list is right, the second knob is how the binary is linked.
Three fields cover it:
| Field | Holds |
| --------------- | --------------------------------------------------------------------------------------------------------------- |
| `settings` | Emscripten `-s` settings, typed against the image's own emsdk. |
| `compilerFlags` | The closed set of non-`-s` flags worth typing (`-O3`, `-msimd128`, `-fwasm-exceptions`, `-flto`, `--no-entry`). |
| `rawFlags` | Everything else, passed through verbatim after the typed flags. |
## Recommended baseline [#recommended-baseline]
```typescript title="libcascade.config.ts" notypecheck
settings: {
MODULARIZE: true, // init() returns Promise
EXPORT_ES6: true, // ESM output
ALLOW_MEMORY_GROWTH: true, // the 16 MB initial heap is not enough
INITIAL_MEMORY: '100MB',
MAXIMUM_MEMORY: '4GB', // the wasm32 ceiling
STACK_SIZE: 8_388_608,
WASM_BIGINT: true, // no i64 legalisation shim
EXPORTED_RUNTIME_METHODS: [
'FS',
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
ENVIRONMENT: ['web', 'worker', 'node'],
ERROR_ON_UNDEFINED_SYMBOLS: false, // OSD_MemInfo references mallinfo
EVAL_CTORS: 2, // static-init evaluation at build time
},
compilerFlags: { optimize: 'O3', simd: true, exceptions: 'wasm', noEntry: true },
```
## Setting-by-setting rationale [#setting-by-setting-rationale]
| Setting | Why |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `optimize: 'O3'` | Production LLVM optimisation. `'Os'` is a size-tuned alternative — benchmark it against your workload. `'O0'` is for debugging only. |
| `exceptions: 'wasm'` | Native Wasm exception instructions instead of JavaScript `invoke_*` trampolines. |
| `simd: true` | Baseline WebAssembly SIMD, supported across the package's browser matrix. |
| `WASM_BIGINT: true` | Removes the i64↔i32-pair legalisation shim. |
| `EVAL_CTORS: 2` | Runs static initialisers at build time. Smaller payload, faster startup. Requires `O2` or better. |
| `MODULARIZE` + `EXPORT_ES6` | Required for the ESM glue the generated entries import. |
| `ENVIRONMENT` | Strips dead environment detection. Without it the runtime probes for `process` / `window` / `importScripts`. |
| `ALLOW_MEMORY_GROWTH` | Required for any non-trivial geometry. |
| `MAXIMUM_MEMORY: '4GB'` | The wasm32 hard ceiling (2³² bytes). |
| `ERROR_ON_UNDEFINED_SYMBOLS: false` | OCCT's `OSD_MemInfo` references `mallinfo`, which Emscripten does not provide. |
Every setting name carries its upstream emsdk documentation as JSDoc, so
hovering it in your editor shows what emcc says about it. There are 312 of
them; the ones above are the ones a custom build normally touches.
## The exception helpers are not optional [#the-exception-helpers-are-not-optional]
emsdk 6.0.5 removed `-sEXPORT_EXCEPTION_HANDLING_HELPERS`. With
`exceptions: 'wasm'` the link pipeline **hard-fails** unless these three are in
`EXPORTED_RUNTIME_METHODS`:
```typescript notypecheck
EXPORTED_RUNTIME_METHODS: [
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
```
Add whatever else you need (`'FS'`, `'wasmMemory'`) to the same array.
## When to reach for `rawFlags` [#when-to-reach-for-rawflags]
`settings` has no index signature — an unknown `-s` name is a compile error,
not a silently-ignored flag. Anything the typed surface cannot express goes
here, verbatim, after every typed flag:
```typescript notypecheck
rawFlags: ['-Wl,--allow-undefined', '--emit-symbol-map'],
variants: [
{ name: 'debug', rawFlags: ['-gsource-map'] },
],
```
Variant `rawFlags` are appended after the base ones.
Reach for `compilerFlags` first. `-pthread`, `-msimd128`, `-O3`,
`-fwasm-exceptions`, `-flto`, and `--no-entry` are all typed there, on the base
config or per variant — a flag that lands in `rawFlags` today and turns out to
be common is a candidate for `compilerFlags` tomorrow, not a permanent resident.
## Trade-offs [#trade-offs]
### Wasm exceptions vs JavaScript exceptions [#wasm-exceptions-vs-javascript-exceptions]
`-fwasm-exceptions` requires that **all** object files and the linker use the
flag consistently. Mixed builds surface `__cpp_exception` as an unresolved
import at link time. The published images compile everything with wasm
exceptions; only override if you target a wasm engine without `try_table`
support.
### SIMD: baseline vs relaxed [#simd-baseline-vs-relaxed]
Baseline `simd: true` (`-msimd128`) is universal across the supported matrix.
Relaxed SIMD is not: Safari 26.x refuses to parse the relaxed opcodes and the
module fails to instantiate. If you ship it, ship it as an extra variant
alongside a baseline one:
```typescript notypecheck
variants: [
{ name: 'single' },
{ name: 'relaxed', rawFlags: ['-mrelaxed-simd'] },
],
```
and select between them yourself — the generated capability probes cover
`threads`, not SIMD flavours.
### `EVAL_CTORS` levels [#eval_ctors-levels]
| Level | Behaviour |
| ----- | --------------------------------------------------------- |
| `0` | Off — every static initialiser runs at startup |
| `1` | Evaluates constructors with safe side effects |
| `2` | Recommended — full constructor evaluation, requires `O2`+ |
Drop it in a threads variant. Constructor evaluation order is non-deterministic
under pthread workers, which is exactly what `settings: { EVAL_CTORS: null }`
on the variant is for — `null` removes an inherited base setting.
## What you cannot change at link time [#what-you-cannot-change-at-link-time]
The wasm bitwidth (`wasm32` vs `wasm64`), the C++ standard-library version, the
OCCT commit pin, and the libclang version are all baked into the published
image during the bindgen pipeline. Those live on the other side of the
[two-channel split](/docs/toolchain/concepts/two-channel-config-model); changing them means
forking the image build.
## Related [#related]
* [Config reference](/docs/toolchain/reference/config) — the full `settings` type story.
* [Custom multi-threaded build](/docs/toolchain/guides/multi-threading) — the pthread settings set.
* [Two-channel config model](/docs/toolchain/concepts/two-channel-config-model) — compile-time vs link-time.
---
# Derive a C++ class in JavaScript
URL: /docs/toolchain/guides/derive-cpp-class-in-js
OCCT progress callbacks require a derived `Message_ProgressIndicator` class.
That needs Embind's `allow_subclass`, so it is a `customBindings` entry with
`scope: 'main'` — raw Embind — rather than a generated binding.
## 1. Add one self-contained binding file [#1-add-one-self-contained-binding-file]
Create `bindings/progress-indicator.cpp` with the bridge class, wrapper, and
registration in the same file:
```cpp
#include
#include
using namespace emscripten;
struct Message_ProgressIndicator_JS : public Message_ProgressIndicator {
using Message_ProgressIndicator::Show;
using Message_ProgressIndicator::UserBreak;
using Message_ProgressIndicator::Reset;
};
struct Message_ProgressIndicator_JSWrapper
: public wrapper {
EMSCRIPTEN_WRAPPER(Message_ProgressIndicator_JSWrapper);
void Show(const Message_ProgressScope&, bool isForce) {
call("Show", GetPosition(), isForce);
}
bool UserBreak() { return call("UserBreak"); }
void Reset() { call("Reset"); }
};
EMSCRIPTEN_BINDINGS(progress_indicator_js) {
class_>(
"Message_ProgressIndicator_JS")
.function("Show", &Message_ProgressIndicator_JS::Show, pure_virtual())
.function("UserBreak", optional_override([](Message_ProgressIndicator_JS& self) {
return self.Message_ProgressIndicator_JS::UserBreak();
}))
.function("Reset", optional_override([](Message_ProgressIndicator_JS& self) {
self.Message_ProgressIndicator_JS::Reset();
}))
.allow_subclass(
"Message_ProgressIndicator_JSWrapper");
}
```
The repository's executable Docker fixture contains the complete production
registration, including inherited methods needed by its runtime test.
## 2. Declare the file [#2-declare-the-file]
```typescript title="libcascade.config.ts"
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'progress-callback',
bindings: [
'BRepAlgoAPI_Fuse',
'BRepPrimAPI_MakeBox',
'Message_ProgressIndicator',
'Message_ProgressRange',
'Message_ProgressScope',
'Message_ProgressIndicator_JS',
'gp_Pnt',
],
customBindings: [
{
file: 'bindings/progress-indicator.cpp',
symbols: ['Message_ProgressIndicator_JS'],
scope: 'main',
},
],
variants: [{ name: 'single' }],
});
```
The registration name goes in `symbols`, which is what admits it to `bindings`
without it being an `OcctSymbol`. Build with `npx libcascade build` — see the
[Quickstart](/docs/toolchain/getting-started/quick-start). Raw Embind files skip the
bindgen, so add a project-local TypeScript declaration for
`Message_ProgressIndicator_JS` if the consumer is TypeScript.
## 3. Derive in JavaScript [#3-derive-in-javascript]
```javascript notypecheck
import { createInstance } from './dist/init.js';
const oc = await createInstance();
const Progress = oc.Message_ProgressIndicator_JS.extend('Progress', {
Show(position) {
console.log('progress', position);
},
UserBreak() {
return false;
},
});
using progress = new Progress();
// Pass progress.Start() to a long-running OCCT algorithm.
```
See [Extend with C++](/docs/toolchain/guides/extend-with-cpp) for the distinction between generated
and raw bindings.
---
# detect and check
URL: /docs/toolchain/guides/detect-and-check
Two commands answer one question — *which OCCT symbols does this code actually
reference?* — in the two directions that matter.
```bash
npx libcascade detect src # onboarding: seed a bindings list
npx libcascade check src # CI: fail when a referenced symbol is not bound
```
**Neither command is a size tool, and neither ever removes anything.** Read
the caveats below before acting on either output. Both are marked
experimental.
## Why they exist: the failure asymmetry [#why-they-exist-the-failure-asymmetry]
A `bindings` list that is missing a symbol **links successfully**. The wasm
builds, the manifest can pass, the package publishes — and the first time your
code touches the missing class it throws a `BindingError` at runtime.
`libcascade build` cannot catch that. Only running the code path can.
`check` converts that failure class into a build-time error. That is the whole
value proposition, and it is a correctness one, not a size one.
## Why they are not size tools [#why-they-are-not-size-tools]
The measurement: dropping **14% of the symbols bought 0.9% of brotli size**.
The \~5,400 embind registrations are GC roots, so unbound symbols free glue, not
kernel code. (`--gufa`, the obvious next lever, is a measured size *regression*
on top of that.)
Trimming a binding set is still worth doing for the reasons in
[Trim symbols](/docs/toolchain/guides/trim-symbols) — startup work, honest dependency surface — but
do not expect `detect` to hand you megabytes.
## `detect` — the first bindings list [#detect--the-first-bindings-list]
Writing the initial `bindings` array is the scariest step of custom-build
onboarding. `detect` scans your source for symbol references, closes over the
symbol catalog, and prints a paste-ready fragment with per-symbol provenance:
```bash
npx libcascade detect src lib
```
```text
bindings: [
'BRepBuilderAPI_MakeShape', // closure: base of BRepPrimAPI_MakeBox
'BRepPrimAPI_MakeBox', // seed: src/shapes.ts:41
'gp_XYZ', // closure: member type of gp_Pnt
],
```
`--json` emits the same result machine-readably.
The output is a **starting set, not a minimal one**. Symbols your source does
not reference today include roadmap-reserved capacity and anything your own C++
wrapper files call. Review the list. Never diff it against an existing config
and delete the difference.
## `check` — the drift guard [#check--the-drift-guard]
```bash
npx libcascade check src
```
`check` recomputes the referenced set from your source and fails when any of it
is missing from `bindings ∪ customBindings[].symbols`, naming each symbol, the
first `file:line` that references it, and the fix:
```text
libcascade check: 1 referenced symbol is not bound by libcascade.config.ts.
ChFi2d_FilletAPI
first referenced at src/fillet.ts:13
```
It exits non-zero, so it belongs in CI next to your typecheck:
```yaml title=".github/workflows/ci.yml"
- run: npx tsc --noEmit
- run: npx libcascade check src
```
Symbols bound under an OCCT typedef alias (`TColgp_Array1OfPnt` for
`NCollection_Array1_gp_Pnt`) count as bound. Names that are not in the catalog
at all — your `customBindings` symbols, Emscripten runtime members such as
`oc.FS`, plain typos — are never failures. `--verbose` lists them as ignored
along with the scan's caveats.
## How the scan works [#how-the-scan-works]
| Rule | Behaviour |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Strong signal | `oc.Symbol` (also `this.oc.Symbol`) |
| Weak signal | any bare identifier that exactly matches a catalog name **and contains an underscore** — this is what catches type-only imports such as `import type { TopoDS_Shape }` |
| Single-word names | `Draft`, `Expr`, `BRepTools` and friends are excluded from bare matching because they collide with ordinary identifiers. Write them as `oc.BRepTools` to be seen. |
| Overload suffixes | `Geom2d_Line_1` resolves to `Geom2d_Line`, but only when the full name is not itself a symbol |
| Excluded paths | `.d.ts`, `node_modules`, `dist`, `build`, `out`, `coverage` |
| Comments | blanked before matching, so a comment naming a deliberately-omitted class is not a reference |
| Strings | scanned — which is what makes `oc['gp_Pnt']` visible |
| Builtins | `OCJS`, `TopoDS` and friends are registered unconditionally, so they are never detected or demanded |
`.d.ts` files are excluded for a specific reason: an OCCT `.d.ts` declares every
symbol, which would make the scan vacuous.
## Limits you must not forget [#limits-you-must-not-forget]
The scanner is **regex and token based, not AST based** — the toolchain's
runtime dependencies stay at two small packages. It therefore cannot see:
* dynamic access — `oc[name]` where `name` is a variable
* names built by concatenation
* symbols only your C++ wrapper files call
* anything reached through a dependency's compiled code
So:
* **`check` passing is not a proof.** It proves no symbol is missing from the
*written* references it can see. An unexercised code path can still hit a
runtime `BindingError`.
* **`detect` output is a seed.** It is not an audit of what you could drop.
* **Neither command edits your config.** They print; you decide.
## Related [#related]
* [CLI reference](/docs/toolchain/reference/cli) — flags and exit behaviour.
* [Trim symbols](/docs/toolchain/guides/trim-symbols) — the bindings list as a design signal.
* [Config reference](/docs/toolchain/reference/config) — where the list lives.
---
# Extend with C++
URL: /docs/toolchain/guides/extend-with-cpp
A custom build can compile your own C++ alongside OCCT. You declare each file
and the symbols it provides in `customBindings`:
```typescript title="libcascade.config.ts" notypecheck
customBindings: [
{ file: 'wrappers/fair-curve.cpp', symbols: ['FairCurve'] },
{ file: 'bindings/helpers.cpp', symbols: ['addReals'], scope: 'main' },
],
```
Paths resolve relative to the config file's directory and are checked for
existence when the config loads, so a moved or misspelled file fails
immediately with the resolved absolute path — not twenty minutes into a build.
## Why the symbols are declared [#why-the-symbols-are-declared]
`bindings` is type-checked against the generated `OcctSymbol` union. Your
classes are not in it. Declaring them in `customBindings[].symbols` is what
admits them:
```typescript notypecheck
bindings: ['gp_Pnt', 'FairCurve'],
customBindings: [{ file: 'wrappers/fair-curve.cpp', symbols: ['FairCurve'] }],
```
The custom-symbol union is inferred **only** from `customBindings`, never from
`bindings`. A typo'd OCCT name therefore cannot widen the union and type itself
as valid.
Declaring them also keeps [`detect` and `check`](/docs/toolchain/guides/detect-and-check) from
reporting your own classes as unknown symbols.
## `scope: 'all'` — generated bindings [#scope-all--generated-bindings]
The default. The file is inspected by the bindgen, which generates JavaScript
and TypeScript bindings for the classes it finds.
```cpp title="wrappers/fair-curve.cpp"
#include
class FairCurve {
public:
TopoDS_Shape Build() const { /* ... */ }
};
```
```typescript title="libcascade.config.ts" notypecheck
bindings: ['FairCurve'],
customBindings: [{ file: 'wrappers/fair-curve.cpp', symbols: ['FairCurve'] }],
```
The generator discovers classes, constructors, methods, enums, referenced OCCT
types, and `Handle` / NCollection aliases from these files, and their
declarations become part of the build's `.d.ts` — and therefore of the shared
`types.d.ts` that `libcascade assemble` writes.
## `scope: 'main'` — raw Embind [#scope-main--raw-embind]
Use `scope: 'main'` when you need Embind constructs the generator does not
emit: free functions, `value_object`, collection registrations, or
`allow_subclass`.
```cpp title="bindings/helpers.cpp"
#include
#include
Standard_Real addReals(Standard_Real a, Standard_Real b) { return a + b; }
EMSCRIPTEN_BINDINGS(custom_helpers) {
emscripten::function("addReals", &addReals);
}
```
```typescript title="libcascade.config.ts" notypecheck
customBindings: [{ file: 'bindings/helpers.cpp', symbols: ['addReals'], scope: 'main' }],
```
A `scope: 'main'` file may contain both its helper implementation and its
`EMSCRIPTEN_BINDINGS(...)` block. It compiles directly and skips the bindgen,
so you own any TypeScript declaration for what it registers:
```typescript notypecheck
import { createInstance } from './dist/init.js';
import type { OpenCascadeInstance } from './dist/types.js';
type CustomInstance = OpenCascadeInstance & {
addReals(a: number, b: number): number;
};
const oc = (await createInstance()) as CustomInstance;
console.log(oc.addReals(1.5, 2.25));
```
## Which one? [#which-one]
* Prefer `scope: 'all'` for ordinary classes and generated types.
* Use `scope: 'main'` only for a binding construct the bindgen cannot emit.
* Do not declare the same file twice.
Both scopes record the file path and its SHA-256 digest in the build manifest
and the provenance sidecar, so a change to your C++ is part of the artifact's
identity.
The two scopes map onto the container yml's `additionalCppFiles` (top level,
shared by every output) and `mainBuild.additionalBindFiles` (per output). You
never write that yml — see [Container yml contract](/docs/toolchain/reference/yaml-schema)
if you want to read what gets rendered.
## Build [#build]
```bash
npx libcascade build --render-only # check the rendered yml first
npx libcascade build
```
## Related [#related]
* [Config reference](/docs/toolchain/reference/config) — the `customBindings` field.
* [Derive a C++ class in JavaScript](/docs/toolchain/guides/derive-cpp-class-in-js) — an
`allow_subclass` example under `scope: 'main'`.
* [Variants and assemble](/docs/toolchain/concepts/variants-and-assemble) — how your
generated declarations reach the shared `types.d.ts`.
---
# Custom multi-threaded build
URL: /docs/toolchain/guides/multi-threading
The npm package already ships a pre-built multi-threaded variant — see
[Package — Multi-threaded build](/docs/package/guides/multi-threading) for the
import recipe, COOP/COEP headers, and OCCT activation calls. Build your own
only when you need:
* A trimmed symbol list.
* Different link settings (alternative `STACK_SIZE`, `INITIAL_MEMORY`, a capped
`PTHREAD_POOL_SIZE`).
* A browser-only variant with growable array buffers (see below).
## Declare the variant [#declare-the-variant]
A threaded build is not a separate config. It is a variant of the same one:
```typescript title="libcascade.config.ts"
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'occt',
bindings: ['BRepMesh_IncrementalMesh', 'TopoDS_Shape', 'gp_Pnt'],
settings: {
MODULARIZE: true,
EXPORT_ES6: true,
ALLOW_MEMORY_GROWTH: true,
MAXIMUM_MEMORY: '4GB',
STACK_SIZE: 8_388_608,
WASM_BIGINT: true,
ENVIRONMENT: ['web', 'worker', 'node'],
EXPORTED_RUNTIME_METHODS: [
'FS',
'wasmMemory',
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
ERROR_ON_UNDEFINED_SYMBOLS: false,
EVAL_CTORS: 2,
},
compilerFlags: { optimize: 'O3', simd: true, exceptions: 'wasm', noEntry: true },
variants: [
{ name: 'single' },
{
name: 'multi',
compilerFlags: { threads: true },
settings: {
// Constructor evaluation order is non-deterministic under pthread
// workers, so the base EVAL_CTORS is removed for this variant.
EVAL_CTORS: null,
SHARED_MEMORY: true,
PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency',
},
},
],
});
```
`compilerFlags: { threads: true }` renders `-pthread`, and it is the line that
matters twice over. From it the toolchain infers the variant's `threads`
capability, which selects the multi-threaded container image at build time and
becomes the capability the generated `createInstance` probes before handing you
an instance at load time.
You can still write `requires: ['threads']` explicitly, but you do not need to:
a build against shared memory cannot instantiate without `SharedArrayBuffer`, so
the capability follows from the flags. An explicit `requires` is *added to* what
is inferred — declaring `requires: []` does not opt a threaded build out of its
own probe, by design.
```bash
npx libcascade build --variant multi
npx libcascade assemble
```
## Load it [#load-it]
```typescript notypecheck
import { createInstance } from 'my-occt-package/init';
const oc = await createInstance(); // best variant this host supports
const mt = await createInstance({ variant: 'multi' }); // explicit
const capped = await createInstance({ variant: 'multi', threadCount: 4 });
```
There is no `mainScriptUrlOrBlob` to pass, no `file:` URL to convert for Node
workers, and no thread-pool call to remember: `createInstance` does all three.
Asking for `'multi'` on a host without cross-origin isolation throws with the
remediation in the message rather than failing deep inside module
instantiation.
Browsers gate `SharedArrayBuffer` behind cross-origin isolation, so every page
loading the threaded binary needs:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
Node exposes `SharedArrayBuffer` unconditionally. Under Vite, add
`worker: { format: 'es' }` — Emscripten's workers are ES modules with top-level
await and the default `iife` format cannot emit them. Both are covered in
[Variants and assemble](/docs/toolchain/concepts/variants-and-assemble).
## Sizing the pool [#sizing-the-pool]
`PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency'` is evaluated by the
generated glue at module-instantiation time, so one binary adapts to whatever
hardware loads it: an M2 Pro browser gets 12 workers, a mobile device 4–6, a
Node server whatever `os.cpus().length` reports.
Each worker costs `STACK_SIZE` (8 MB above) at module init, so a 12-core box
reserves \~96 MB of stack on top of `INITIAL_MEMORY`. To cap it, either wrap the
expression in the setting or pass `threadCount` at load:
```typescript notypecheck
const oc = await createInstance({ variant: 'multi', threadCount: 8 });
```
`threadCount` sizes OCCT's default pool and raises its launch cap to match. It
does not shrink the pre-spawned worker pool baked into the binary — that is a
build-time number.
> ### Why not a small fixed pool size? [#why-not-a-small-fixed-pool-size]
>
> A round number like `PTHREAD_POOL_SIZE: 4` caps concurrency on larger
> machines and oversubscribes smaller ones.
> `'navigator.hardwareConcurrency'` follows the host without a separate build.
> Add a cap only after benchmarking your workload and target devices; the
> published benchmark measures 12 workers and does not claim a universal
> optimum.
## Browser-only MT build [#browser-only-mt-build]
If you can guarantee recent browsers (Chrome / Edge ≥ 144, Firefox ≥ 154,
Safari ≥ 26.2 — August 2026 baseline) and do **not** need the binary under
Node.js, Bun, or Deno, add a third variant so typed-array views track memory
growth:
```typescript notypecheck
{
name: 'multi-browser',
compilerFlags: { threads: true },
settings: {
EVAL_CTORS: null,
SHARED_MEMORY: true,
PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency',
GROWABLE_ARRAYBUFFERS: true,
ENVIRONMENT: ['web', 'worker'],
},
}
```
Emscripten then wraps the pthread `SharedArrayBuffer` in a
[resizable `ArrayBuffer` view](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer/grow)
via `WebAssembly.Memory.prototype.toResizableBuffer()`. Typed arrays created
from `oc.wasmMemory.buffer` stay valid across memory growth, and the
`-Wpthreads-mem-growth` advisory disappears.
> ### Runtime support matrix — `toResizableBuffer()` (August 2026) [#runtime-support-matrix--toresizablebuffer-august-2026]
>
> **Supported** — ship browser-variant artefacts to:
>
> * Chrome / Edge ≥ 144
> * Firefox ≥ 154
> * Safari ≥ 26.2 (desktop + iOS)
>
> **NOT supported** — fall back to the plain threads variant for:
>
> * Node.js (all current LTS) — throws `TypeError: wasmMemory.toResizableBuffer is not a function` on module init.
> * Bun and Deno (all current versions).
> * Samsung Internet and other Chromium derivatives that lag mainline.
> * Firefox ≤ 153 — WebAssembly worker transfer can hang even when the API exists; [the engine fix ships in Firefox 154](https://bugzilla.mozilla.org/show_bug.cgi?id=2021136).
>
> Because Node.js is the dominant test-harness runtime, the shipped
> multi-threaded build keeps `GROWABLE_ARRAYBUFFERS` off so it stays loadable
> under `vitest`, `jest`, and other Node-based runners. Opt in only for
> production payloads served directly to browser clients.
The selector picks the most capable variant whose requirements probe true and
breaks ties by configuration order, so a browser-only variant needs an explicit
`createInstance({ variant: 'multi-browser' })` — or the pre-import override
symbol — rather than automatic selection.
> ### Relaxed SIMD — advanced, non-default [#relaxed-simd--advanced-non-default]
>
> Safari 26.x does not support relaxed SIMD. If profiling justifies it for
> Chromium, Firefox, or Node, add a sibling variant with
> `rawFlags: ['-mrelaxed-simd']`, retain the baseline binary, and select it
> yourself whenever relaxed-SIMD validation fails.
## After building [#after-building]
Activation calls, parallel-aware OCCT APIs, and benchmarks are the same as for
the shipped binary — see
[Package — Multi-threaded build](/docs/package/guides/multi-threading).
## Related [#related]
* [Variants and assemble](/docs/toolchain/concepts/variants-and-assemble) — selection, overrides, and the generated entries.
* [Emscripten settings and flags](/docs/toolchain/guides/custom-emcc-flags) — the settings baseline.
* [Trim symbols](/docs/toolchain/guides/trim-symbols) — shrink the symbol set before building.
---
# Reproducible CI
URL: /docs/toolchain/guides/reproducible-ci
A reproducible custom build means: same inputs, same wasm bytes, every time.
The toolchain closes most of that loop for you — what is left is choosing where
to run it and what to assert afterwards.
## 1. Pin the toolchain, not a tag [#1-pin-the-toolchain-not-a-tag]
```json title="package.json"
{
"devDependencies": {
"@libcascade/toolchain": "3.0.0",
"libcascade": "3.0.0"
}
}
```
Commit your lockfile. The toolchain package ships `images.json`, in which each
image tag was resolved to an immutable `ghcr.io/taucad/opencascade.js@sha256:…`
digest at publish time. `@libcascade/toolchain@3.0.0` therefore names one
reproducible build environment forever, and `npm ls` answers "which toolchain
built this artifact".
The driver runs `repository@digest` — never a tag — and verifies the local repo
digest after pulling. A mismatch is an error naming both digests, not a silent
roll-forward.
This replaces the pattern it supersedes: an image tag pasted into a shell
script that had to be kept in sync by hand with the `libcascade` version the
application depended on.
Because the toolchain and `libcascade` are lockstep-versioned, keeping the two
dependencies on the same version is the whole compatibility story.
## 2. Run it where a container engine exists [#2-run-it-where-a-container-engine-exists]
```yaml title=".github/workflows/wasm-build.yml"
jobs:
build-wasm:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx libcascade check src
- run: npx libcascade build
- run: npx libcascade assemble
- name: Assert wasm hash
run: |
EXPECTED=$(cat .wasm-hash)
ACTUAL=$(sha256sum dist/myapp_single.wasm | cut -d' ' -f1)
[ "$EXPECTED" = "$ACTUAL" ] || { echo "wasm hash drift"; exit 1; }
```
GitHub-hosted **macOS runners ship no container engine at all** — run toolchain
builds on a Linux runner. The driver's engine probe order is
`$LIBCASCADE_CONTAINER_CMD`, then `docker`, then `podman`; if none responds it
fails with install options rather than a cryptic `ENOENT`.
Three gates are doing work in that job:
* `check` fails when your source references a symbol the config does not bind —
the failure that otherwise reaches production as a runtime `BindingError`.
* `build` fails when the container's `build-manifest.json` reports
`validation_passed: false`, printing the unsatisfied symbols.
* The hash assertion catches everything else. If `EXPECTED` and `ACTUAL`
diverge, something changed — either intentionally (bump `.wasm-hash`) or by
accident (investigate).
## 3. Keep the provenance sidecars [#3-keep-the-provenance-sidecars]
Every variant build emits `.provenance.json` next to the binary,
recording the active compile preset, flags, and the source commit the wasm was
built from. Diff those across builds to detect surprise cache turnover, and
publish them with your package.
## 4. Verify the image supply chain (optional) [#4-verify-the-image-supply-chain-optional]
The driver already proves the image is the digest the toolchain pinned. If your
threat model wants the signature too, verify it before the build step. Every
published image is signed with [cosign](https://github.com/sigstore/cosign) via
OIDC keyless signing, with the signature on the manifest-list digest — one
signature verifies regardless of which architecture pulls it.
```bash
IMAGES=node_modules/@libcascade/toolchain/generated/images.json
REF="$(jq -r '.repository + "@" + .singleThreaded.digest' "$IMAGES")"
cosign verify "$REF" \
--certificate-identity-regexp 'https://github.com/taucad/opencascade\.js/\.github/workflows/docker\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
A successful verification confirms the image was built by the
`taucad/opencascade.js` GitHub Actions `docker.yml` workflow and has not been
tampered with since publication.
The image also ships a SLSA provenance attestation and an SBOM:
```bash
cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp 'https://github\.com/taucad/opencascade\.js/\.github/workflows/docker\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$REF"
docker buildx imagetools inspect --format '{{ json .SBOM }}' "$REF"
```
The SBOM lists every apt package and pinned commit (OCCT, freetype, rapidjson)
in the image. Diff it against the previous digest's SBOM to flag unexpected
dependency bumps.
## 5. Never opt out in CI [#5-never-opt-out-in-ci]
`$LIBCASCADE_IMAGE` points the driver at any locally reachable image and skips
digest verification, printing a provenance warning when it does. That is a dev
loop, not a CI setting. Leave it unset in every automated build.
## Upstream reproducibility [#upstream-reproducibility]
The repository runs `.github/workflows/reproducibility.yml` weekly and on
demand: two isolated Linux/amd64 cold builds in parallel, runtime smoke for
each, and an exact artifact-ledger comparison. Stable npm publication invokes
that same exact-commit gate; canary and beta builds keep the single-candidate
path and rely on scheduled cold coverage.
## Related [#related]
* [Driver environment](/docs/toolchain/reference/env-vars) — engine probing, overrides, and what each one forfeits.
* [CLI reference](/docs/toolchain/reference/cli) — the commands this workflow runs.
* [Docker image](/docs/toolchain/reference/docker-image) — tags, labels, and the stage layout behind the digests.
---
# Trim symbols
URL: /docs/toolchain/guides/trim-symbols
`opencascade_single.wasm` binds OCCT classes and weighs roughly
40 MB. Most applications need a fraction of that surface — a STEP round-tripper
might touch \~120 classes, a glTF mesher fewer. The `bindings` array in
`libcascade.config.ts` is where you say so.
## Prerequisites [#prerequisites]
* The [Quickstart](/docs/toolchain/getting-started/quick-start) run once, so you know your
container engine works.
* A working directory under your home folder on macOS/Windows. Docker Desktop
does not share `/tmp` or `/opt` into its VM by default and outputs vanish
silently.
## Size budget [#size-budget]
Each bound class contributes roughly 15–25 KB to the linked wasm. A reasonable
target by use case:
| Use case | Symbols | Approximate wasm size |
| ----------------------------------------------- | ----------------: | --------------------: |
| Single-format viewer (read STEP → mesh) | 80–150 | 2–4 MB |
| Round-trip pipeline (STEP/IGES edit + write) | 200–400 | 5–10 MB |
| Full code-CAD tool (booleans + fillets + sweep) | 600–1,200 | 12–20 MB |
| Reference / kitchen sink | | \~40 MB |
Numbers are after `optimize: 'O3'`, `simd: true`, `WASM_BIGINT: true`, and
`EVAL_CTORS: 2`.
Two things that budget does **not** buy you. First, the returns are
front-loaded: below a few hundred symbols the embind registration glue, not the
kernel, is what remains — dropping 14% of a trimmed list measured 0.9% of
brotli size. Second, a missing symbol is not a build error. It links fine and
throws a `BindingError` at runtime.
## 1. Seed the list [#1-seed-the-list]
```bash
npx libcascade detect src
```
`detect` scans your source, closes over the symbol catalog, and prints a
paste-ready fragment with per-symbol provenance. It is an onboarding tool with
real limits — read [detect and check](/docs/toolchain/guides/detect-and-check) before you trust the
output. In particular it is a *starting set*, never a minimal one, and it never
proposes a removal.
## 2. Put it in the config [#2-put-it-in-the-config]
```typescript title="libcascade.config.ts"
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'step_roundtrip',
bindings: [
'IFSelect_ReturnStatus',
'Interface_Static',
'Message_ProgressRange',
'STEPControl_Reader',
'STEPControl_StepModelType',
'STEPControl_Writer',
'Standard_Failure',
'TCollection_AsciiString',
'TCollection_ExtendedString',
'TopExp',
'TopExp_Explorer',
'TopoDS',
'TopoDS_Compound',
'TopoDS_Edge',
'TopoDS_Face',
'TopoDS_Shape',
'TopoDS_Shell',
'TopoDS_Solid',
'TopoDS_Vertex',
'TopoDS_Wire',
'gp_Ax2',
'gp_Dir',
'gp_Pnt',
'gp_Vec',
],
settings: {
MODULARIZE: true,
EXPORT_ES6: true,
ALLOW_MEMORY_GROWTH: true,
WASM_BIGINT: true,
EXPORTED_RUNTIME_METHODS: [
'FS',
'getExceptionMessage',
'incrementExceptionRefcount',
'decrementExceptionRefcount',
],
ENVIRONMENT: ['web', 'worker', 'node'],
ERROR_ON_UNDEFINED_SYMBOLS: false,
},
compilerFlags: { optimize: 'O3', simd: true, exceptions: 'wasm', noEntry: true },
variants: [{ name: 'single', settings: { EVAL_CTORS: 2 } }],
});
```
You list classes you instantiate or pass around — not their transitive closure.
Base classes and the `NCollection_*` members of a bound class are
auto-discovered at codegen time.
Every name is checked against the generated `OcctSymbol` union as you type, so
the class of mistake that used to surface as a link failure ten minutes into a
build is now a red squiggle.
## 3. Build [#3-build]
```bash
npx libcascade build
```
The link reuses the precompiled object files baked into the image for every
still-bound class, so a trim that strips most of the full surface typically
completes in 60–180 seconds on a warm image.
Artifacts land in `dist/`:
```text
dist/step_roundtrip_single.wasm # the trimmed binary
dist/step_roundtrip_single.js # Emscripten glue
dist/step_roundtrip_single.d.ts # generated declarations
dist/step_roundtrip_single.build-manifest.json # symbol coverage
```
The build fails if the manifest reports `validation_passed: false`, printing
the symbols the container could not satisfy. Add them back and re-run.
## 4. Guard the list [#4-guard-the-list]
```bash
npx libcascade check src
```
Run it in CI. A symbol you start referencing but forget to bind is otherwise a
runtime crash for your users, and a build that succeeds proves nothing about it.
## Iterating [#iterating]
Repeated trims benefit from the engine's own image cache — the image is pulled
once and verified by digest thereafter. To render and inspect the
container-side yml without running anything:
```bash
npx libcascade build --render-only
```
To iterate against a locally built image instead of the pinned one, see the
[dev loop](/docs/toolchain/reference/env-vars#local-image-dev-loop).
## When trimming feeds back into your design [#when-trimming-feeds-back-into-your-design]
A trimmed binary makes your application's OCCT dependency graph explicit.
Adding a class to recover from a `check` failure is a signal: either the class
is genuinely part of your call graph, or your code reaches into an OCCT
subsystem it does not need. The second case is worth investigating.
## Related [#related]
* [Quickstart](/docs/toolchain/getting-started/quick-start) — end-to-end first custom build.
* [detect and check](/docs/toolchain/guides/detect-and-check) — seeding and guarding the list.
* [Config reference](/docs/toolchain/reference/config) — every field, including `bindings`.
* [Bindgen pipeline](/docs/toolchain/concepts/bindgen-pipeline) — why the trim works without
computing a transitive closure.
---
# build-wasm.sh (internals)
URL: /docs/toolchain/reference/cli-build-wasm
**Internals.** Custom builds are driven by the `libcascade` CLI — see
[CLI reference](/docs/toolchain/reference/cli). `build-wasm.sh` is the orchestrator **inside** the
container image and in a from-source checkout; it is what the container's
entrypoint dispatches to. Read this when you are building the image itself.
`build-wasm.sh` is the orchestration entry point for the libcascade build pipeline.
Each subcommand maps to a stage of the bindgen → compile → link sequence.
## Synopsis [#synopsis]
```bash
./build-wasm.sh [options] []
```
## Subcommands [#subcommands]
### `validate` [#validate]
```bash
./build-wasm.sh validate build-configs/my-config.yml
```
Parses the YAML against `src/customBuildSchema.py` and fails non-zero on any
malformed entry, unknown key, duplicated symbol, or missing
`additionalCppFiles` path. Does **not** run the C++ pipeline.
### `bindings` [#bindings]
```bash
./build-wasm.sh bindings
```
Runs the libclang-driven bindgen against the OCCT headers, emitting one
`.hxx` per class under `build/bindings////` plus
sidecar `.d.ts.json` shards.
Cache: re-uses prior output when header contents and bindgen-filter
fingerprint match. Force regeneration with `--force`.
### `pch` [#pch]
```bash
./build-wasm.sh pch
```
Builds the precompiled header used by every bindings TU. Always passes
`-Xclang -fno-pch-timestamp` so Nx/Docker cache restores don't invalidate the
PCH on disk-mtime drift.
### `link` [#link]
```bash
./build-wasm.sh link build-configs/my-config.yml
```
Compiles every `.hxx` referenced by the YAML's `bindings:` list (cached `.o`
files reused), links them against the precompiled OCCT objects under
`dist/libs/`, and emits the final wasm + JS + `.d.ts` + manifest sibling to
the YAML.
### `clean` [#clean]
```bash
./build-wasm.sh clean # rm build/, cache/, dist/
./build-wasm.sh clean --cache # rm cache/ only
./build-wasm.sh clean --dist # rm dist/ only
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ------------------------------------------------------- |
| 0 | Success |
| 1 | YAML validation failure |
| 2 | bindgen failure (libclang error) |
| 3 | Compile failure |
| 4 | Link failure (undefined symbol or unresolved reference) |
| 5 | Missing input file |
| 64 | Invalid usage (unknown subcommand or flag) |
## Common flags [#common-flags]
| Flag | Effect |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| `--config ` | Override `OCJS_CONFIG` for this run — selects a named preset from `build-configs/configurations.json`. |
| `--force` | Bypass cache; rebuild from scratch |
| `--verbose` | Equivalent to `OCJS_VERBOSE=1` |
| `--jobs N` | Set `OCJS_PARALLEL_JOBS=N` |
## Typical CI invocation [#typical-ci-invocation]
```bash
./build-wasm.sh validate build-configs/my-config.yml
./build-wasm.sh link --jobs 8 build-configs/my-config.yml
sha256sum dist/my-config.wasm
```
## Related [#related]
* [CLI reference](/docs/toolchain/reference/cli) — the `libcascade` bin, which is what custom builds use.
* [Bindgen pipeline](/docs/toolchain/concepts/bindgen-pipeline) — stage diagram and artifact layout.
* [Driver environment](/docs/toolchain/reference/env-vars) — the `OCJS_*` catalogue this script reads.
* [Container yml contract](/docs/toolchain/reference/yaml-schema) — the yml this script consumes.
---
# libcascade CLI
URL: /docs/toolchain/reference/cli
`@libcascade/toolchain` installs one bin, `libcascade`. Run it with `npx` (or
your package manager's equivalent) from the directory holding
`libcascade.config.ts`.
```text
libcascade build [--variant ] [--config ] [--render-only]
libcascade assemble [--config ] [--write-exports]
libcascade detect [--json]
libcascade check [--config ] [--verbose]
libcascade migrate [--out ] [--force]
```
`libcascade --help` prints the same synopsis plus the environment variables.
## `build` [#build]
Links one WASM binary per variant through the container.
```bash
npx libcascade build
npx libcascade build --variant multi
npx libcascade build --render-only
```
What one invocation does, per variant:
1. Load and validate the config (see [Config reference](/docs/toolchain/reference/config)).
2. Render the container-side yml into `.libcascade/`.
3. Resolve the image — the digest pinned for this toolchain version, or your
override — and verify the local repo digest after pulling.
4. Run the engine with the config directory mounted at `/src` and a scratch
directory mounted at `/out`, with `OCJS_OUTPUT_DIR` pointed at it.
5. On exit 0, move the artifacts into `dist/`.
6. Read `.build-manifest.json` and fail if `validation_passed` is
not `true`, printing the missing symbols and the binding-report deltas.
Step 6 is the reason to run this instead of the engine directly: **a missing
binding links successfully and fails at runtime** with a `BindingError`.
Artifacts per variant, in `dist/`:
| File | Contents |
| ---------------------------------- | ------------------------------------------------------- |
| `.js` | Emscripten glue |
| `.wasm` | The binary |
| `.d.ts` | Generated TypeScript declarations |
| `.js.symbols` | Symbol map |
| `.build-manifest.json` | Requested / compiled / alias-resolved / missing symbols |
| `.provenance.json` | Toolchain and source commits |
| Flag | Effect |
| ------------------ | ----------------------------------------------------------------------------- |
| `--variant ` | Build one variant. Default: every variant in the config. |
| `--config ` | Config file path. Default: `./libcascade.config.{ts,js,mjs}`. |
| `--render-only` | Render the yml(s), print their paths, and stop. No container engine required. |
Failures leave `.libcascade/` in place — the rendered yml and the container's
raw output directory are what you inspect. Add it to `.gitignore`.
## `assemble` [#assemble]
Generates the npm packaging surface from the artifacts `build` produced. Pure
Node; it never touches a container.
```bash
npx libcascade assemble
npx libcascade assemble --write-exports
```
Reads `.d.ts` and `.build-manifest.json` for every
declared variant and writes, next to them:
| File | Contents |
| ------------------------- | ----------------------------------------------------------------------------------------------------- |
| `types.d.ts` | One d.ts unioning every variant's surface. Symbols only some variants bind are typed optional. |
| `init.js` / `init.d.ts` | The `./init` subpath: `createInstance({ variant, threadCount, wasmBinary, wasmMemory, locateFile })`. |
| `index.js` / `index.d.ts` | The root entry, per `assemble.exports`. |
| `variant.d.ts` | Types for the raw per-variant glue subpaths (`./single`, `./multi`, …). |
| `exports.json` | The generated `exports` fragment. |
`--write-exports` merges that fragment into the package's own `package.json`:
generated subpaths win, every other subpath you declared is preserved in place.
The `files` list is not touched — add the generated files to it once.
`assemble` fails with a pointer at `libcascade build --variant ` when a
variant's `.d.ts` is missing. It packages artifacts; it never builds them.
See [Variants and assemble](/docs/toolchain/concepts/variants-and-assemble) for the
generated entries in detail.
## `detect` [#detect]
Scans your source for OCCT symbol references, closes over the catalog, and
prints a paste-ready `bindings` fragment with per-symbol provenance.
```bash
npx libcascade detect src
npx libcascade detect src lib --json
```
```text
bindings: [
'BRepBuilderAPI_MakeShape', // closure: base of BRepPrimAPI_MakeBox
'BRepPrimAPI_MakeBox', // seed: src/shapes.ts:41
'gp_XYZ', // closure: member type of gp_Pnt
],
```
The output is a **starting set, not a minimal one**, and `detect` never removes
anything. Read [detect and check](/docs/toolchain/guides/detect-and-check) before acting on
it.
## `check` [#check]
The inverse direction, for CI: recompute the referenced set and fail when any
of it is missing from `bindings ∪ customBindings[].symbols`.
```bash
npx libcascade check src
npx libcascade check src --verbose
```
```text
libcascade check: 1 referenced symbol is not bound by libcascade.config.ts.
ChFi2d_FilletAPI
first referenced at src/fillet.ts:13
```
Exits non-zero on a miss. Symbols bound under an OCCT typedef alias count as
bound. Names not in the catalog at all — your custom symbols, `oc.FS`, typos —
are never failures; `--verbose` lists them as ignored, along with the scan's
caveats.
## `migrate` [#migrate]
Converts v2-style container ymls into a typed `libcascade.config.ts`. Pure
Node, one shot, run once per package — it is an onboarding tool, not a sync.
```bash
npx libcascade migrate build-config/custom_build_single.yml \
build-config/custom_build_multi.yml \
--out libcascade.config.ts
```
Pass **every** variant's yml to one invocation. Sibling ymls that differ only in
flags and artifact name are one config with one variant each, and that is what
`migrate` emits: the values they all agree on become the base, each yml's
differences become its variant. Ymls that disagree on `bindings`,
`additionalCppFiles`, or `additionalBindFiles` are not variants of one build —
the config has no per-variant form for those — so `migrate` names the
disagreement and refuses. Migrate those separately.
| Flag | Effect |
| -------------- | -------------------------------------------------------------------------------------------------------- |
| `--out ` | Write the config here. Default: stdout. |
| `--force` | Overwrite an existing `--out` file. Without it an existing file is an error, never a silent replacement. |
Findings go to stderr, the config to stdout — so `> libcascade.config.ts` works
too, and the findings still reach you.
### Where each flag lands [#where-each-flag-lands]
| `emccFlags` entry | Config |
| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `-sNAME=VALUE`, or a bare `-sNAME` (which emcc reads as `=1`) | `settings: { NAME: … }`, the value deserialized with the generated grammar — memory sizes, bracketed lists, the `ENVIRONMENT` comma list, and the 0/1 integers that mean booleans |
| `-O0`…`-O3`, `-Os`, `-Oz` | `compilerFlags.optimize` |
| `-msimd128`, `-flto`, `--no-entry`, `-pthread` | `compilerFlags.simd` / `.lto` / `.noEntry` / `.threads` |
| `-fwasm-exceptions`, `-fexceptions` | `compilerFlags.exceptions` |
| Anything else — and any `-sNAME=VALUE` whose value the typed grammar cannot express | `rawFlags`, verbatim |
That last row is what makes the output trustworthy: a flag nobody modelled is
passed to emcc unchanged **and** listed in the emitted header, so it can never
go missing between the yml and the build. An unknown *yml key* is the opposite
case — the schema has no verbatim bucket for one, so it is an error.
### Two rewrites it applies [#two-rewrites-it-applies]
Both are required by the pinned emsdk, and each leaves a comment at its site in
the emitted config:
| yml | Config | Why |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `-pthread` + `-sUSE_PTHREADS=1` | `compilerFlags: { threads: true }` | `USE_PTHREADS` survives only as emcc's deprecated legacy alias of `-pthread`; the pair is one request written twice |
| `-sEXPORT_EXCEPTION_HANDLING_HELPERS` | `getExceptionMessage`, `incrementExceptionRefcount`, `decrementExceptionRefcount` added to `EXPORTED_RUNTIME_METHODS` | emsdk 6.0.5 removed the setting and **hard-fails** a `-fwasm-exceptions` link without the three helpers |
`requires: ['threads']` is not emitted at all: `threads` is inferred from the
flags that cause it. See [Config reference](/docs/toolchain/reference/config).
### What it cannot know [#what-it-cannot-know]
Two things the yml format never recorded, both named in the emitted header as
your review list:
* **`customBindings[].symbols`.** The yml lists wrapper file *paths*; which
symbols each provides is what the typed config needs. `migrate` reads them out
of the `.cpp` — top-level `class` / `struct` definitions and Embind
`class_("Name")` registrations — and where it finds none it emits a
`TODO(libcascade migrate)` marker plus the candidate names (the `bindings`
entries that are not OCCT symbols and that no wrapper claims). It never
guesses, and the config will not build until you fill the marker in.
* **`assemble.exports`.** No yml equivalent exists. It defaults to `'factory'`;
switch to `'eager'` if consumers import OCCT names off the package root.
The full walkthrough, with the hand-mapping tables and the review checklist, is
[Migrate from a yml build](/docs/toolchain/getting-started/migrate-from-yaml).
## Environment [#environment]
`LIBCASCADE_CONTAINER_CMD`, `LIBCASCADE_IMAGE`, and `LIBCASCADE_PLATFORM` are
documented in [Driver environment](/docs/toolchain/reference/env-vars).
## Programmatic use [#programmatic-use]
The container driver is exported for orchestration that needs to sequence runs
itself:
```typescript notypecheck
import { createContainerDriver } from '@libcascade/toolchain/driver';
```
That is an escape hatch, not the supported path. Prefer the CLI.
---
# Config reference
URL: /docs/toolchain/reference/config
`libcascade.config.ts` is the single source of truth for a custom build. The
CLI looks for `libcascade.config.ts`, `libcascade.config.js`, or
`libcascade.config.mjs` in the working directory, or the path you pass to
`--config`. It is loaded with [jiti](https://github.com/unjs/jiti), so a
TypeScript config needs no build step.
```typescript
import { defineBuild } from '@libcascade/toolchain';
export default defineBuild({
name: 'myapp',
bindings: ['gp_Pnt', 'TopoDS_Shape'],
variants: [{ name: 'single' }],
});
```
`defineBuild` is an identity function — it returns its argument unchanged. Its
entire job is to attach types.
## Fields [#fields]
| Field | Type | Notes |
| ------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `name` | `string` | **Required.** Artifact base name. With no `outputName`, variant `v` produces `_.*`. |
| `bindings` | `(OcctSymbol \| CustomSymbol)[]` | **Required.** Symbols to expose to JavaScript. Rendered verbatim into the container's `bindings:` list. |
| `customBindings` | `{ file, symbols, scope? }[]` | Your own C++ files and the symbols each provides. |
| `settings` | `EmccSettings` | Emscripten `-s` settings. Generated type — see below. |
| `compilerFlags` | `{ optimize?, simd?, exceptions?, lto?, noEntry? }` | The closed set of non-`-s` flags worth typing. |
| `rawFlags` | `string[]` | Escape hatch. Appended verbatim after every typed flag. |
| `variants` | `{ name, outputName?, requires?, settings?, rawFlags? }[]` | **Required, non-empty.** One binary per entry. |
| `assemble` | `{ exports: 'eager' \| 'factory' }` | Packaging mode for `libcascade assemble`. Defaults to `'factory'`. |
| `image` | `string` | Container image override. `$LIBCASCADE_IMAGE` wins over it; both skip digest verification. |
| `generateTypescriptDefinitions` | `boolean` | Defaults to `true`. Set `false` only for fast throwaway iteration — `assemble` needs the `.d.ts`. |
### `bindings` [#bindings]
The element type is the generated `OcctSymbol` union — **6,257** string
literals covering every OCCT class and enum in the release, every OCCT
`typedef` alias the bindgen resolves (`TColgp_Array1OfPnt` →
`NCollection_Array1_gp_Pnt`), and the Embind builtins — **plus** exactly the
symbols this config's own `customBindings` declare.
```typescript notypecheck
bindings: ['BRepPrimAPI_MakeBoox'],
// ^ Type '"BRepPrimAPI_MakeBoox"' is not assignable…
// Did you mean '"BRepPrimAPI_MakeBox"'?
```
Base classes and `NCollection_*` members of a bound class are auto-discovered
at codegen time, so the list holds classes you instantiate or pass around, not
their transitive closure.
The custom-symbol union is inferred **only** from `customBindings[].symbols`.
That is deliberate: a typo'd OCCT name cannot silently widen the union and type
itself as valid.
### `customBindings` [#custombindings]
```typescript notypecheck
customBindings: [
{ file: 'wrappers/mesh-extractor.cpp', symbols: ['MyMeshData', 'MyMeshExtractor'] },
{ file: 'build-configs/free-functions.cpp', symbols: ['TopoDS_Cast'], scope: 'main' },
],
```
| Key | Meaning |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `file` | Path to the `.cpp`, resolved relative to the config file's directory. Checked for existence at config load. |
| `symbols` | The symbols this file provides. These are the only names `bindings` may carry that are not `OcctSymbol`. |
| `scope` | `'all'` (default) renders the file into the container yml's top-level `additionalCppFiles`; `'main'` renders it into `mainBuild.additionalBindFiles`. |
See [Extend with C++](/docs/toolchain/guides/extend-with-cpp) for which scope to pick.
### `settings` [#settings]
`EmccSettings` is generated from the **image's own** emsdk `settings.js`
(6.0.5) plus emcc's legacy/deprecated tables — 312 settings, each carrying its
upstream documentation as JSDoc, so hovering a setting in your editor shows
what emcc says about it. Legacy names such as `USE_PTHREADS` are typed and
marked `@deprecated`.
Values whose grammar emcc constrains are typed structurally rather than as
`string`:
| Setting family | Type | Why |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `INITIAL_MEMORY`, `MAXIMUM_MEMORY`, `STACK_SIZE`, `TOTAL_MEMORY`, … | `MemorySize` = number \| `${number}KB` \| `${number}MB` \| `${number}GB` | `'100mb'` and `'100 MB'` are silent misparses in a yml. Here they do not compile. |
| `ENVIRONMENT` | `readonly EmccEnvironment[]` | An array of literals cannot express a typo or a duplicate; the renderer owns the comma joining. |
| `EXPORTED_RUNTIME_METHODS`, `EXPORTED_FUNCTIONS`, and 19 more | `readonly string[]` | The renderer emits emcc's bracketed-list syntax, so the yml-quoting fragility disappears. |
| `PTHREAD_POOL_SIZE` | `number \| 'navigator.hardwareConcurrency'` | The JS-expression form is a documented emcc idiom; the literal keeps it discoverable without admitting arbitrary strings. |
| flags emcc declares as 0/1 ints | `boolean` (rendered `1`/`0`) | Boolean semantics, int encoding. |
Serialization at render time:
| Value | Rendered |
| -------------------------------- | -------------------------- |
| `true` / `false` | `-sNAME=1` / `-sNAME=0` |
| `number`, `string` | `-sNAME=` verbatim |
| `string[]` | `-sNAME=["a","b"]` |
| `ENVIRONMENT: ['web', 'worker']` | `-sENVIRONMENT=web,worker` |
There is no index signature on `EmccSettings`. That is what makes an unknown
`-s` name a compile error — and why anything the typed surface cannot express
belongs in `rawFlags`.
### `compilerFlags` [#compilerflags]
Only the non-`-s` flags the reference builds actually use are modelled.
| Key | Emits |
| -------------------------------------------------------- | ------------------------------------ |
| `optimize: 'O0' \| 'O1' \| 'O2' \| 'O3' \| 'Os' \| 'Oz'` | `-O3` … |
| `simd: true` | `-msimd128` |
| `exceptions: 'wasm' \| 'emscripten'` | `-fwasm-exceptions` / `-fexceptions` |
| `lto: true` | `-flto` |
| `noEntry: true` | `--no-entry` |
Anything else — `-mrelaxed-simd`, `-Wl,--allow-undefined`, `--emit-symbol-map`,
`-pthread` — goes in `rawFlags`.
The rendered flag order is fixed: exceptions → `-s` settings → `-flto` →
`--no-entry` → `-msimd128` → optimisation → base `rawFlags` → variant
`rawFlags`. emcc treats distinct flags as order-insensitive.
### `variants` [#variants]
```typescript notypecheck
variants: [
{ name: 'single', settings: { EVAL_CTORS: 2 } },
{
name: 'multi',
compilerFlags: { threads: true },
settings: { SHARED_MEMORY: true, PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency' },
},
],
```
| Key | Meaning |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Unique within the config. Names the rendered yml, the artifact, and the `--variant` selector. |
| `outputName` | Overrides the default `_` artifact base name. |
| `compilerFlags` | Merged key by key over the base `compilerFlags`, variant wins. `threads: true` renders `-pthread`. |
| `requires` | Capabilities this variant needs from the host. Normally **inferred** from the build flags — a threaded build implies `['threads']` — so most configs never declare it. Declaring it *adds* to what is inferred; it cannot subtract. |
| `settings` | Merged over the base `settings`. A value of `null` **removes** an inherited key. |
| `rawFlags` | Appended after the base `rawFlags`. |
A variant's capabilities select its container image at build time and become the
probes the generated `createInstance` runs before returning an instance.
Base keys keep their declaration order when a variant overrides them in place;
variant-only keys are appended. `null` is how the multi-threaded variant drops
the base `EVAL_CTORS` — constructor evaluation order is non-deterministic under
pthread workers. Unsetting a key the base never declared is a configuration
error, caught at load.
A single-variant config is first class: `variants: [{ name: 'single' }]` emits
no selector machinery at assemble time.
Variants share one `bindings` list on purpose. That is what lets `assemble`
emit one `types.d.ts` for all of them — see
[Variants and assemble](/docs/toolchain/concepts/variants-and-assemble).
## Load-time validation [#load-time-validation]
Types cannot see the filesystem. `libcascade build`, `assemble`, and `check`
all validate the loaded config first and report every problem at once, with
resolved absolute paths:
* empty `name`, empty `bindings`, empty `variants`
* duplicate variant names
* a variant `null`-unsetting a setting the base never declared
* a `customBindings.file` that does not exist on disk
* a `customBindings` entry declaring no symbols
## Worked references [#worked-references]
Two real configs ship in the open:
* [`libcascade.config.ts`](https://github.com/taucad/opencascade.js/blob/main/libcascade.config.ts) —
the full build itself: every OCCT symbol, one `scope: 'main'` wrapper file,
single + multi variants, `assemble: { exports: 'eager' }`.
* [`replicad-opencascadejs`](https://github.com/sgenoud/replicad) — a trimmed
custom build: 11 wrapper files declaring 17 custom symbols, single + multi,
`assemble: { exports: 'factory' }`.
## Related [#related]
* [CLI reference](/docs/toolchain/reference/cli) — the commands that consume this file.
* [Driver environment](/docs/toolchain/reference/env-vars) — engine discovery, image override, digest pinning.
* [Quickstart](/docs/toolchain/getting-started/quick-start) — from install to `dist/`.
---
# Named compile-time configurations
URL: /docs/toolchain/reference/configurations
**Maintainer track.** These presets are compiled into the published images.
A custom build selects between them implicitly — a variant declaring
`requires: ['threads']` gets the multi-threaded image, everything else the
single-threaded one — and configures the link step through
[`libcascade.config.ts`](/docs/toolchain/reference/config).
Compile-time presets live in [`build-configs/configurations.json`](https://github.com/taucad/opencascade.js/blob/main/build-configs/configurations.json). Each entry is a flat map of `OCJS_*` environment-variable names to values; the build CLI loads the entry and exports each key before driving `emcc` and `wasm-opt`.
All shipped presets use native WASM exceptions (`OCJS_EXCEPTIONS=1`,
`OCJS_EH_MODE=wasm`) and the Closure Compiler. Link-only features such as
BigInt and eval-ctors belong to each build YAML's `emccFlags`, not these
compile-time presets.
## Shipped presets [#shipped-presets]
| Preset | Use case | Differentiating flags |
| -------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `single-threaded` | Default custom-build preset when a variant does not require threads. | `OCJS_OPT=-O3`, `OCJS_WASM_OPT_LEVEL=-O4`, `THREADING=single-threaded` |
| `single-threaded-smallest` | Size-tuned variant — benchmark it against your workload before shipping. | `OCJS_OPT=-Os`, `OCJS_WASM_OPT_LEVEL=-O3`, `THREADING=single-threaded` |
| `multi-threaded` | Published `libcascade/multi` build. SAB/COOP+COEP-isolated deployments. | `OCJS_OPT=-O3`, `OCJS_WASM_OPT_LEVEL=-O4`, `THREADING=multi-threaded` |
| `debug` | Fastest build for local iteration. Not for production — no SIMD, no converge, no BigInt. | `OCJS_OPT=-O0`, `OCJS_WASM_OPT_LEVEL=-O0`, `OCJS_SIMD=0`, `OCJS_CONVERGE=false` |
For the full `OCJS_*` matrix that each preset sets — including the flags they share — see [BUILD\_SYSTEM.md](https://github.com/taucad/opencascade.js/blob/main/BUILD_SYSTEM.md#configurationsjson).
## Selecting a preset [#selecting-a-preset]
`build-wasm.sh` reads the `OCJS_CONFIG` env var or the `--config ` CLI flag (the CLI flag wins if both are set). When neither is set, the script falls back to `single-threaded`:
```bash
OCJS_CONFIG=debug ./build-wasm.sh link build-configs/my-config.yml
```
Or via the CLI flag:
```bash
./build-wasm.sh --config debug link build-configs/my-config.yml
```
## Adding a custom preset [#adding-a-custom-preset]
Append a new entry to `build-configs/configurations.json`. The shape is a flat `OCJS_*` env-var map — same keys the shipped presets use:
```json
{
"single-threaded-no-simd": {
"OCJS_OPT": "-Os",
"OCJS_LTO": "0",
"OCJS_EXCEPTIONS": "1",
"OCJS_EH_MODE": "wasm",
"OCJS_SIMD": "0",
"THREADING": "single-threaded",
"OCJS_DEFINES": "OCCT_NO_DUMP",
"OCJS_UNDEFINES": "OCC_CONVERT_SIGNALS",
"OCJS_WASM_OPT_LEVEL": "-O3",
"OCJS_CLOSURE": "true",
"OCJS_CONVERGE": "true",
"OCJS_MALLOC": "mimalloc",
"BINARYEN_EXTRA_PASSES": ""
}
}
```
Custom presets share the same compile-`.o` cache as shipped ones — the cache is keyed by the compile-flag fingerprint, not by the preset name. Two presets with identical compile-time flags share cache entries automatically.
## Cache invalidation [#cache-invalidation]
Changing any compile flag invalidates the cached `.o` files that depended on it. The build manifest records the active fingerprint:
```bash
cat build/build-flags.json
```
The published npm tarball ships `dist/opencascade_single.provenance.json` and
`dist/opencascade_multi.provenance.json` — same preset / flag information
for each variant, with the commit SHA the wasm was built from. CI scripts should
diff these files across builds to detect surprise cache turnover.
## Related [#related]
* [Two-channel config model](/docs/toolchain/concepts/two-channel-config-model) — why compile-time and link-time config are separate channels.
* [Emscripten settings and flags](/docs/toolchain/guides/custom-emcc-flags) — link-time `settings` rationale.
* [Driver environment](/docs/toolchain/reference/env-vars) — driver variables and the `OCJS_*` catalogue.
---
# Docker image
URL: /docs/toolchain/reference/docker-image
The maintainer-distributed Docker image lets consumers run custom-trimmed
wasm builds without setting up emsdk, libclang, and Python locally.
**You do not pull or run this image by hand.** `libcascade build` resolves
the digest its toolchain version pinned, pulls it, verifies the digest, and
runs it with the right mounts and UID mapping for your platform. This page
documents the image itself — its tags, labels, and stages — for auditing and
for building it from source. See [CLI reference](/docs/toolchain/reference/cli) and
[Driver environment](/docs/toolchain/reference/env-vars) for the consumer path.
## Pulling [#pulling]
```bash
docker pull ghcr.io/taucad/opencascade.js:single-threaded
```
## Tags [#tags]
| Tag | Points to |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `:single-threaded` | Latest release, single-threaded warm cache (default for browser CAD UIs) |
| `:multi-threaded` | Latest release, multi-threaded warm cache (requires COOP/COEP on consumer pages) |
| `:bindgen-base` | Latest release, post-PCH/generate but pre-compile (custom-bindings starting point) |
| `:{{version}}-single-threaded`
`:{{version}}-multi-threaded` | Pinned release (e.g. `:3.0.0-single-threaded`); manifest list of `linux/amd64+arm64` |
| `:{{version}}-bindgen-base` | Pinned release, bindgen-base |
| `:{{version}}-` when `{{version}}` is a canary | Immutable manually dispatched canary (e.g. `:3.0.0-canary.a1b2c3d4-single-threaded`), retained for seven days |
| `:branch-main[-]` | Current or immutable `main`, single-threaded |
| `:multi-threaded-branch-main[-]` | Current or immutable `main`, multi-threaded |
| `:bindgen-base-branch-main[-]` | Current or immutable `main`, bindgen-base |
| `@sha256:` | Immutable pin — use in CI |
**Pin by digest in production.** The bare-name tags (`:single-threaded`,
`:multi-threaded`, `:bindgen-base`) are mutable and roll forward with every
release. The toolchain does this for you: each published version embeds the
resolved digests in `generated/images.json` and verifies them after pulling.
See [Reproducible CI](/docs/toolchain/guides/reproducible-ci).
The legacy `:beta`, `:rolling`, and `:latest` tags are **not published** by
this project. Use a version-pinned tag (e.g. `:3.0.0-single-threaded`) or the
manifest-list digest for explicit version control.
## Entrypoint [#entrypoint]
The driver invokes the image as ` run … link `, with the
config directory mounted at `/src` and a scratch directory mounted at `/out`
via `OCJS_OUTPUT_DIR`. The invocation below is what it constructs — reproduce
it by hand only when debugging the image itself:
```bash
docker run --rm \
-v "$(pwd):/src" \
-v "$(pwd)/out:/out" \
-e OCJS_OUTPUT_DIR=/out \
ghcr.io/taucad/opencascade.js@sha256: \
link mybuild.yml
```
The entrypoint dispatches subcommands through `npx nx run ocjs:` so
runs benefit from Nx's content-addressed cache:
| Subcommand | What it does |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `link ` | End-to-end build. Nx caches the canonical `link-core`, then always materializes its exact inventory into the requested output directory before validation and provenance. |
| `compile-bindings`, `compile-sources`, `pch`, … | Run an individual Nx target |
| `validate ` | Validate YAML without building |
| `nx ` | Pass-through to `npx nx` (escape hatch) |
Outputs land in `/src` next to your YAML (`OCJS_OUTPUT_DIR=/src` default).
### Override the entrypoint [#override-the-entrypoint]
```bash
docker run --rm -it -v "$(pwd):/src" --entrypoint bash \
ghcr.io/taucad/opencascade.js:single-threaded
```
…drops you into a shell with `emsdk`, libclang, and Python on the PATH.
## Multi-arch matrix [#multi-arch-matrix]
| Event | Result | Built on |
| ----------------------------- | ------------------------------- | ----------------------------------------------------------------------------------- |
| Pull request targeting `main` | Three-stage validation, no tags | `ubuntu-latest` (amd64) |
| `main`, release, or dispatch | `linux/amd64` + `linux/arm64` | `ubuntu-latest` (amd64) + `ubuntu-24.04-arm` (arm64), GitHub Actions native runners |
`main`, release, and manually dispatched canary runs ship full manifest lists
so Apple Silicon and ARM Linux hosts pull the native architecture
transparently. CI links and smokes each image natively and requires every
native stage to pass before promotion. The amd64 outputs are the canonical npm
package inputs; host-specific compiler output is not compared byte-for-byte
across architectures.
## OCI labels [#oci-labels]
Inspect via `docker inspect ghcr.io/taucad/opencascade.js:single-threaded`:
| Label | Purpose |
| -------------------------------------- | ------------------------------------------------------------------------------------ |
| `org.opencontainers.image.title` | Stage-specific title (single-threaded, multi-threaded, …) |
| `org.opencontainers.image.description` | Threading model and consumer prerequisites |
| `org.opencontainers.image.source` | [https://github.com/taucad/opencascade.js](https://github.com/taucad/opencascade.js) |
| `org.opencontainers.image.url` | Same as source |
| `org.opencontainers.image.revision` | Git commit the image was built from |
| `org.opencontainers.image.version` | Semver tag |
| `org.opencontainers.image.licenses` | LGPL-2.1-only |
| `org.opencontainers.image.vendor` | taucad |
## Cosign signatures [#cosign-signatures]
Every published image is signed with [cosign](https://github.com/sigstore/cosign)
via OIDC keyless signing — no rotating private keys, signatures published to
the Sigstore Rekor transparency log. Release tags carry **one signature on
the manifest-list digest** that verifies regardless of which arch the
consumer pulls.
```bash
cosign verify ghcr.io/taucad/opencascade.js:single-threaded \
--certificate-identity-regexp 'https://github.com/taucad/opencascade\.js/\.github/workflows/docker\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
A successful verification confirms the image was built by the
`taucad/opencascade.js` GitHub Actions `docker.yml` workflow and has not
been tampered with since publication.
## Provenance [#provenance]
```bash
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity-regexp 'https://github\.com/taucad/opencascade\.js/\.github/workflows/docker\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/taucad/opencascade.js:single-threaded
```
The attestation records the source commit, the workflow, and the runner
that produced the image.
## SBOM [#sbom]
```bash
docker buildx imagetools inspect \
--format '{{ json .SBOM }}' \
ghcr.io/taucad/opencascade.js:single-threaded
```
Diff the SBOM across image digests in CI to flag unexpected dep bumps.
## Image stages [#image-stages]
The [Dockerfile](https://github.com/taucad/opencascade.js/blob/main/Dockerfile)
is multi-stage with five logical stages, three of which are published:
| Stage | Published as | Contents |
| -------------------------- | ------------------ | ------------------------------------------------------------------------------- |
| `deps-base` | *(not published)* | emsdk + apt + Node 24 + uv + Python + OCCT/rapidjson/freetype + LLVM 17 headers |
| `bindgen-base` | `:bindgen-base` | deps + npm ci + patches + PCH + `.d.ts.json` index |
| `compiled-single-threaded` | *(not published)* | bindgen + compiled `.o` files + OCCT `.a` (single-threaded) |
| `compiled-multi-threaded` | *(not published)* | bindgen + compiled `.o` files + OCCT `.a` (multi-threaded) |
| `final-single` | `:single-threaded` | compiled-single + OCI labels + entrypoint |
| `final-multi` | `:multi-threaded` | compiled-multi + OCI labels + entrypoint |
Each stage is independently rebuildable via `docker buildx build --target `. See [Bindgen pipeline](/docs/toolchain/concepts/bindgen-pipeline) for how the
stages compose with the build pipeline.
---
# Driver environment
URL: /docs/toolchain/reference/env-vars
The typed config is the only configuration channel for a build. The
environment variables below control the **driver** — which engine runs, which
image it runs, and on which platform.
## Driver variables [#driver-variables]
| Variable | Effect |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `LIBCASCADE_CONTAINER_CMD` | Container engine to probe first. Default probe order: `docker`, then `podman`. |
| `LIBCASCADE_IMAGE` | Image reference override. Wins over a config-level `image:`. Skips digest verification and prints a provenance warning. |
| `LIBCASCADE_PLATFORM` | Passed to the engine as `--platform`. Unset by default; the published images are multi-arch. |
### Engine discovery [#engine-discovery]
The driver probes each candidate with ` version` and takes the first that
exits 0. With none available it fails with install options for Docker Desktop,
colima, and Podman — and the reminder that GitHub-hosted macOS runners ship no
container engine at all.
Point `LIBCASCADE_CONTAINER_CMD` at any binary that speaks the same CLI:
```bash
LIBCASCADE_CONTAINER_CMD=podman npx libcascade build
```
### UID mapping and mounts [#uid-mapping-and-mounts]
`-u uid:gid` is emitted **only on Linux native engines**. On Docker Desktop for
macOS and Windows the VM maps ownership itself and an explicit `-u` breaks the
build, so the driver omits it. Mount paths are resolved absolute from the
config file's directory.
You do not configure any of this. It is the platform-edge handling that
consumer `docker run` strings used to copy-paste, subtly differently, each
time.
### Platform [#platform]
The images are published as multi-arch manifest lists (`linux/amd64` +
`linux/arm64`), so Apple Silicon and ARM Linux hosts pull the native
architecture automatically. `LIBCASCADE_PLATFORM` exists as a narrow override
for debugging cross-architecture issues:
```bash
LIBCASCADE_PLATFORM=linux/amd64 npx libcascade build
```
## Digest pinning [#digest-pinning]
The toolchain package ships `generated/images.json`, in which each image tag was
resolved to an immutable digest at publish time. The driver runs
`ghcr.io/taucad/opencascade.js@sha256:…` — never a tag — and after pulling it
inspects the local repo digests to prove the image really is that one. A
mismatch is an error naming both the expected and the local digest.
Which of the two pinned images a variant gets is decided by its config:
`requires: ['threads']` selects the multi-threaded image, everything else the
single-threaded one.
Consequence worth stating plainly: `@libcascade/toolchain@X` names one
reproducible build environment forever. Pinning the toolchain version in your
lockfile is the entire pinning story — there is no tag to keep in sync by hand.
## Local-image dev loop [#local-image-dev-loop]
Contributors who build the container image locally need the driver to run
*that* image. `LIBCASCADE_IMAGE` accepts any reference the local engine can
resolve:
```bash
# Build the image locally, then point the CLI at it.
docker buildx build --target final-single -t ocjs-local:single-threaded .
LIBCASCADE_IMAGE=ocjs-local:single-threaded npx libcascade build --variant single
```
While an override is active the driver:
* skips digest verification — a locally built image has no repo digest to
verify against;
* prints a one-line provenance warning naming the override, because the
artifacts it produces carry no reproducible toolchain provenance.
A config-level `image:` behaves the same way; the environment variable wins
over it.
Never set either in CI. See [Reproducible CI](/docs/toolchain/guides/reproducible-ci).
## Inside the image [#inside-the-image]
The variables below are read by the build system **inside** the container. The
driver sets `OCJS_OUTPUT_DIR` itself; the rest are maintainer-facing, relevant
when you build the image from source rather than consume it.
**Maintainer track.** Nothing here is part of the custom-build path. Your
build is configured by `libcascade.config.ts`.
### Compile-time flags [#compile-time-flags]
| Variable | Default | Effect |
| ------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OCJS_EXCEPTIONS` | `1` | Compile every translation unit with `-fwasm-exceptions`. Mixed builds fail at link. |
| `OCJS_SIMD` | `1` | Compile every translation unit with `-msimd128` (baseline SIMD). |
| `OCJS_RELAXED_SIMD` | `0` | Additionally emit `-mrelaxed-simd`. Safari 26.x lacks support; keep a baseline build for Safari. |
| `OCJS_LTO` | `0` | Enable LLVM LTO. The measured full build grew 21%, so shipped presets keep it off. |
| `OCJS_STRICT_TYPES` | `0` (warn-only) | The link-time `.d.ts` post-processor always prints a triage summary to stderr when it rewrites signatures to `unknown`. Set `=1` to escalate that condition to a build failure. |
### Build orchestration [#build-orchestration]
| Variable | Default | Effect |
| ---------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OCJS_CONFIG` | `single-threaded` | Named preset from `configurations.json`. |
| `OCJS_COMPILE_WORKERS` | Up to 8 | Max parallel binding-object compile workers. Changes execution only, not cache identity. |
| `BUILD_DIR` | `./build` | Canonical intermediate root. Custom values are rejected because Nx owns the fixed output graph. |
| `OCJS_OUTPUT_DIR` | `/src` in the container | Destination for the uncached materialization step. `libcascade build` sets this to a mounted scratch directory and moves the artifacts into `dist/` afterwards. |
### Image internals [#image-internals]
| Variable | Default | Effect |
| ---------------------- | ---------------- | ------------------------------------------------------------------- |
| `OCJS_DEPS_VERSION` | from `DEPS.json` | Override the dependency pinning (OCCT, freetype, rapidjson). |
| `OCJS_EMSDK_DIR` | `/deps/emsdk` | Location of the Emscripten SDK inside the image. |
| `OCJS_PYTHON` | `python3` | Python interpreter for the bindgen. |
| `OCJS_VERBOSE` | `0` | Print every compile / link command. |
| `OCJS_DUMP_CACHE_KEYS` | `0` | Print cache-key contents on a miss, to debug spurious invalidation. |
## Related [#related]
* [CLI reference](/docs/toolchain/reference/cli) — the commands these variables affect.
* [Config reference](/docs/toolchain/reference/config) — the only channel for build configuration.
* [Named compile-time configurations](/docs/toolchain/reference/configurations) — the `OCJS_*` preset list.
* [Two-channel config model](/docs/toolchain/concepts/two-channel-config-model) — why compile-time and link-time config are separate.
---
# Container yml contract
URL: /docs/toolchain/reference/yaml-schema
**Internals.** You do not write this file. `libcascade build` renders one yml
per variant from [`libcascade.config.ts`](/docs/toolchain/reference/config) into `.libcascade/` and
hands it to the container. This page documents the contract between the two,
for reading a rendered yml or debugging a build. The user-facing surface is
the [config reference](/docs/toolchain/reference/config); `libcascade build --render-only` shows you
what it produces.
The yml controls which OCCT classes get bound, which C++ wrapper code is
injected, and which Emscripten linker flags drive the final wasm.
## Top-level shape [#top-level-shape]
```yaml
mainBuild:
name:
bindings:
- symbol:
emccFlags:
-
additionalBindFiles:
- bindings/custom.cpp
extraBuilds:
- name:
bindings: [...]
emccFlags: [...]
additionalBindFiles:
- bindings/variant.cpp
additionalCppFiles:
- path/to/extra.cpp
generateTypescriptDefinitions: true
```
The canonical Cerberus definition lives in `src/customBuildSchema.py`; the renderer that emits this shape lives in the toolchain package.
## `mainBuild` [#mainbuild]
The primary wasm artifact produced by the YAML.
### `mainBuild.name` [#mainbuildname]
Output filename without extension. `name: my-occt` produces `my-occt.wasm`,
`my-occt.js`, `my-occt.d.ts`, and `my-occt.build-manifest.json`.
### `mainBuild.bindings` [#mainbuildbindings]
Allowlist of OCCT classes to expose to JS via embind. Only classes listed
here (and their transitive base classes) are accessible at runtime.
```yaml
bindings:
- symbol: BRepPrimAPI_MakeBox
- symbol: TopoDS_Shape
- symbol: gp_Pnt
```
The symbol name must match exactly the C++ class name in the generated
binding `.cpp` files under `build/bindings/`. Base classes are auto-included
if missing from the list.
### `mainBuild.emccFlags` [#mainbuildemccflags]
Emscripten linker flags. The renderer emits these from `settings`,
`compilerFlags`, and `rawFlags` in a canonical order. See
[Emscripten settings and flags](/docs/toolchain/guides/custom-emcc-flags) for the
recommended baseline and per-flag rationale.
### `mainBuild.additionalBindFiles` [#mainbuildadditionalbindfiles]
Per-build `.cpp` files containing raw `EMSCRIPTEN_BINDINGS(...)`
registrations. The files compile directly, may include their own helper
implementation, and skip generated TypeScript bindings. See
[Extend with C++](/docs/toolchain/guides/extend-with-cpp).
## Symbol resolution classes [#symbol-resolution-classes]
Every YAML-requested symbol becomes a linked binding through exactly one of four mechanisms. The post-link `build-manifest.json` (schema `build-manifest-v3`) buckets each requested symbol into one of these categories under `symbols`:
1. **Direct compilation.** `bindings: - symbol: gp_Pnt` causes the generator to emit `build/bindings/gp_Pnt.cpp`, which `compileBindings.py` compiles into `build/compiled-bindings/gp_Pnt.cpp.o`. Detected by `ocjs_bindgen.link.manifest_registry.collect_compiled_symbols`. Reported as `satisfied_by_compiled` (count surfaces as `symbols.compiled`).
2. **NCollection typedef alias.** `bindings: - symbol: TColgp_Array1OfPnt` resolves via the canonical mangled spelling `NCollection_Array1_gp_Pnt`; the linker substitutes the typedef at link time. Mapping lives in `build/ncollection-manifest.json`. Detected by `manifest_registry.load_ncollection_alias_index`. Reported under `symbols.alias_resolved` as `{alias, canonical}` entries.
3. **Embind builtin.** libcascade's built-in binding source (`OCJS`, `TopoDS`, `TColStd_IndexedDataMapOfStringString`) registers Embind class wrappers with no generated binding object of their own. Detected by `manifest_registry.builtin_binding_symbols` reading `build/additional-bind-symbols.json`.
4. **Consumer `additionalBindFiles`.** YAML's own `mainBuild.additionalBindFiles` undergoes the same Embind pathway. Each output compiles the built-in source plus its ordered consumer files as one translation unit; the AST producer records their registration-name union in `additional-bind-symbols.json`. Reported under `symbols.builtin` (no separate bucket).
Anything that survives all four lookups lands in `symbols.missing` and triggers `validation_passed=false`. The link step also raises immediately via `yaml_build.verifyBindings` (no env-var gate) so a YAML asking for a symbol the toolchain cannot provide fails the link, not just the post-link audit.
Auto-discovered NCollection canonicals (entries the YAML never named directly, but that became reachable from the YAML's scope) are tracked separately in `.provenance.json::nCollectionManifest.{linked, total, dropped}` (schema `wasm-build-provenance-v2`). They never appear in `symbols.requested` because they're produced by the discovery pass, not requested by the operator.
## Producer-side manifest contract [#producer-side-manifest-contract]
Every mechanism above has exactly one **producer** — a pipeline stage with the semantic knowledge to compute it — that writes a JSON manifest in `build/` or the dist sidecar. Every downstream **consumer** (link-time `verifyBindings`, post-link `validate-build.py`, `generate-api-reference.mjs`, `docker-e2e-validate.sh`) reads the manifest through the corresponding `manifest_registry` loader. No consumer re-parses C++, runs regex against source, or re-derives set-difference math.
| Manifest | Producer | Consumer loader |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `build/ncollection-manifest.json` | `ocjs_bindgen.discover` | `manifest_registry.load_ncollection_alias_index` |
| `build/additional-bind-symbols.json` | `runBuild::getAdditionalBindFilesO()` (libclang AST via `ocjs_bindgen.ast.parse_binding_source` + `ocjs_bindgen.ast.walker.extract_class_registrations`) | `manifest_registry.builtin_binding_symbols` |
| `build/compiled-bindings/*.cpp.o` | `compileBindings.py` | `manifest_registry.collect_compiled_symbols` |
| `build/compiled-bindings/binding-report.json` | `compileBindings.py` | `validate-build.py::validate_binding_report` |
| `.provenance.json::nCollectionManifest` | `yaml_build.main` via `provenance.add_linking(ncollection_linked=, ncollection_total=, ncollection_dropped=)` | `generate-api-reference.mjs`, `scripts/docker-e2e-validate.sh` |
| `build/any-type-report.json` | `generate.py` | `validate-build.py::merge_any_reasons` |
When a manifest is missing, consumers fail loudly with a pointer at `pnpm nx run ocjs:build`. Stale artifacts are stale by definition; rendering them with degraded math produces docs whose numbers contradict the build that produced them.
## `extraBuilds` [#extrabuilds]
Same schema as `mainBuild`. Each entry produces a sibling wasm artifact from
one yml pass.
The toolchain does not use this key: it renders **one yml per variant**, each
with a single `mainBuild`, so a variant failure is isolated and `--variant`
can select one. `extraBuilds` remains part of the container contract for
hand-written ymls.
## `additionalCppFiles` [#additionalcppfiles]
Top-level list of `.cpp` files inspected by bindgen before generated custom
bindings are compiled.
```yaml
additionalCppFiles:
- wrappers/fair-curve.cpp
- wrappers/shape-cast.cpp
```
* Paths resolve relative to the YAML file's directory; absolute paths are accepted.
* File contents are concatenated in declaration order and read as UTF-8.
* Missing, unreadable, or non-file paths fail validation.
* Normalized paths and SHA-256 digests are recorded in manifests and provenance.
`additionalBindFiles` follows the same path, ordering, validation, and identity
rules, but belongs inside each build block.
## `generateTypescriptDefinitions` [#generatetypescriptdefinitions]
Default `true`. Set to `false` to skip `.d.ts` generation (rare — useful only
for ultra-fast iteration builds).
## Inspecting the rendered yml [#inspecting-the-rendered-yml]
```bash
npx libcascade build --render-only
```
Renders one yml per variant into `.libcascade/` and prints their paths without
needing a container engine. That is the supported way to read this contract for
your own build — and the first gate when migrating an existing hand-written
yml, since the flag *set* should match even though the renderer emits a
canonical flag order.
The config's own invariants — wrapper files existing on disk, unique variant
names, `null` unsets with a base key to unset — are checked when the config
loads, before anything is rendered.
## Related [#related]
* [Config reference](/docs/toolchain/reference/config) — the file you actually write.
* [Migrate from a yml build](/docs/toolchain/getting-started/migrate-from-yaml) — the field-by-field mapping.
* [Extend with C++](/docs/toolchain/guides/extend-with-cpp) — which `customBindings` scope renders into which key.
---
# API reference data
URL: /docs/package/reference/libcascade-api/api-reference-data
Every published package includes `libcascade/api-reference.json`. The feed is the
portable build-time source for the bound OCCT hierarchy, declarations,
provenance, and exact input hashes used by this site.
```ts
import reference from 'libcascade/api-reference.json' with { type: 'json' };
console.log(reference.package.version);
console.log(reference.source.commit);
console.log(reference.modules);
```
The schema identifier is `ocjs-api-reference-v1`. Consumers should reject
unknown schema identifiers and verify `package.name`, `package.version`, and
the full 40-character source commit before generating derived data.
The feed deliberately excludes site concerns such as route slugs, anchors,
quick links, and search indexes. Those are derived locally. In this repository,
`npm exec nx -- run ocjs:docs-sync` reads the installed package by default, or
accepts an already-downloaded package directory or tarball:
```bash
npm exec nx -- run ocjs:docs-sync -- --from ./libcascade-3.0.0-canary.5bf5e36c.tgz
```
For a complete local docs build from a downloaded CI or npm tarball, keep that
source available to every automatic pre-script:
```bash
OCJS_API_REFERENCE_SOURCE=../libcascade-3.0.0-canary.5bf5e36c.tgz \
pnpm --dir docs-site build
```
The command is offline after the package is available, replaces stale output
atomically, removes deleted symbols, and is byte-idempotent for an unchanged
feed.
---
# Exception classes
URL: /docs/package/reference/libcascade-api/exception-classes
OCCT throws subclasses of `Standard_Failure`. Native WASM exception handling
surfaces them as `WebAssembly.Exception` values and the initialized runtime
decodes their C++ type and message.
```typescript
import oc from 'libcascade';
try {
riskyOcctCall();
} catch (error: unknown) {
if (error instanceof WebAssembly.Exception) {
const [type, message] = oc.getExceptionMessage(error);
console.error(`${type}: ${message}`);
} else {
throw error;
}
}
```
`WebAssembly.Exception.message` does not contain the C++ failure text. Use the
helper while the exception belongs to the current runtime. The related
`incrementExceptionRefcount` and `decrementExceptionRefcount` instance helpers
exist only for code that retains an exception beyond its catch scope.
## Common hierarchy [#common-hierarchy]
```text
Standard_Failure
├── Standard_OutOfRange
├── Standard_NullObject
├── Standard_NoSuchObject
├── Standard_TypeMismatch
├── Standard_DomainError
├── Standard_DivideByZero
└── Standard_ProgramError
└── Standard_NotImplemented
```
See [Debugging WASM exceptions](/docs/package/guides/debugging-wasm-exceptions) for
failure patterns and debug builds.
---
# Entry points
URL: /docs/package/reference/libcascade-api/init-function
`libcascade` exposes three entry points. Which one you want depends on whether
you need to control *when* the WASM module is instantiated.
| Entry | Import | Gives you |
| ------- | --------------------------------------- | --------------------------------------------------------------------------------- |
| Root | `libcascade` | An already-initialised instance, plus every bound symbol as a named value export. |
| Factory | `libcascade/init` | `createInstance(options)` — nothing is instantiated until you call it. |
| Variant | `libcascade/single`, `libcascade/multi` | The raw Emscripten glue for one binary. Advanced. |
## Root — the initialised instance [#root--the-initialised-instance]
The root entry probes the host, picks the most capable variant it supports,
and initialises it with a top-level `await`:
```typescript
import oc from 'libcascade';
using box = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
const shape = box.Shape();
```
Every bound symbol is also a named value export, so you can import the classes
you use directly:
```typescript
import { BRepPrimAPI_MakeBox } from 'libcascade';
using box = new BRepPrimAPI_MakeBox(10, 10, 10);
```
Types are exported from the same specifier:
```typescript
import oc, { type OpenCascadeInstance, type TopoDS_Shape } from 'libcascade';
```
Because the module instantiates at import time, there is nothing to memoise —
ES module evaluation happens once per realm.
## `libcascade/init` — the factory [#libcascadeinit--the-factory]
Use this entry when you need options, a specific variant, or control over when
the WASM comes up. Importing it never evaluates the eager root.
```typescript
import { createInstance } from 'libcascade/init';
const oc = await createInstance();
const mt = await createInstance({ variant: 'multi' });
const capped = await createInstance({ variant: 'multi', threadCount: 4 });
```
### Options [#options]
**`CreateInstanceOptions`** — Options accepted by `createInstance` from the `./init` subpath.
- **`locateFile`** (`((path: string, scriptDirectory: string) => string) | undefined`, optional)
- **`wasmBinary`** (`ArrayBuffer | Uint8Array | undefined`, optional)
- **`wasmMemory`** (`WebAssembly.Memory | undefined`, optional)
- **`print`** (`((text: string) => void) | undefined`, optional)
- **`printErr`** (`((text: string) => void) | undefined`, optional)
- **`variant`** (`LibcascadeVariant | undefined`, optional) — Variant to load. Omitted, the most capable variant the host supports is selected (see the `./init` entry for the capability probes).
- **`threadCount`** (`number | undefined`, optional) — Size of OCCT's default thread pool for a threads variant. Omitted, OCCT sizes the pool itself and the launch cap is raised to match it.
`createInstance` owns the plumbing that is otherwise the consumer's problem:
the glue self-reference Emscripten's pthread workers spawn from, Node `file:`
URL → path conversion, and OCCT thread-pool sizing for a threads variant. It
throws with an actionable message when you request a variant this host cannot
run.
### Memoised singleton pattern [#memoised-singleton-pattern]
WASM instantiation is expensive. Memoise the Promise when several call sites
share one runtime and you are not using the eager root:
```typescript
import { createInstance } from 'libcascade/init';
let ocPromise: ReturnType | undefined;
export const getOc = () => (ocPromise ??= createInstance());
```
### Variant selection [#variant-selection]
Selection picks the most capable variant whose requirements the host meets;
`multi` requires `SharedArrayBuffer` and cross-origin isolation (Node always
qualifies). To force a choice before anything is imported:
```typescript
globalThis[Symbol.for('libcascade.select')] = 'single';
```
## Return type [#return-type]
The resolved object exposes every bound class and namespace as instance
properties (`oc.BRepPrimAPI_MakeBox`, `oc.TopoDS`) together with `oc.FS`,
`oc.wasmMemory`, and exception helpers such as `oc.getExceptionMessage`.
One `OpenCascadeInstance` type describes every variant, so a shape produced by
the single-threaded instance is assignable wherever the multi-threaded one is
expected.
## Variant subpaths — advanced [#variant-subpaths--advanced]
`libcascade/single` and `libcascade/multi` resolve to the raw Emscripten glue.
Their default export is the module factory:
```typescript
import init from 'libcascade/multi';
const oc = await init();
```
This bypasses `createInstance`, so **you** own the pthread plumbing —
including the `mainScriptUrlOrBlob` self-reference workers need. Prefer
`createInstance({ variant: 'multi' })` unless you have a reason not to. The
matching binaries are exported as `libcascade/single/wasm` and
`libcascade/multi/wasm`.
Browser deployments of the threaded variant also need cross-origin isolation.
See [Multi-threaded build](/docs/package/guides/multi-threading).
---
# Module shape
URL: /docs/package/reference/libcascade-api/module-shape
The root entry's default export is an initialised instance. All OCCT runtime
values live on it, and each one is also a named value export of the same
module.
```typescript
import oc, { type TopoDS_Shape } from 'libcascade';
using box = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
const shape: TopoDS_Shape = box.Shape();
```
Named imports resolve to real values as well as types, so
`import { BRepPrimAPI_MakeBox } from 'libcascade'` and `oc.BRepPrimAPI_MakeBox`
are the same binding.
## Bound classes and namespaces [#bound-classes-and-namespaces]
Classes are direct instance properties. OCCT namespaces such as `TopoDS`,
`Interface_Static`, and `XCAFDoc_DocumentTool` are static-method objects:
```typescript
oc.Interface_Static.SetIVal('write.step.schema', 5);
const edge = oc.TopoDS.Edge(genericShape);
```
## Filesystem (`oc.FS`) [#filesystem-ocfs]
Use the Emscripten virtual filesystem for OCCT file readers and writers:
```typescript
const path = '/result.step';
writer.Write(path);
const bytes = oc.FS.readFile(path) as Uint8Array;
oc.FS.unlink(path);
return bytes;
```
`FS.readFile()` returns an owned `Uint8Array`; its bytes remain valid after
the file is unlinked. Copy only when your own API needs an independent buffer.
| Method | Purpose |
| --------------------------- | --------------------- |
| `FS.readFile(path)` | Read owned bytes |
| `FS.writeFile(path, bytes)` | Write an input file |
| `FS.unlink(path)` | Remove a virtual path |
| `FS.mkdir(path)` | Create a directory |
| `FS.readdir(path)` | List a directory |
## Linear memory (`oc.wasmMemory`) [#linear-memory-ocwasmmemory]
Advanced pointer interop uses typed arrays created from the live
`WebAssembly.Memory` buffer:
```typescript
const bytes = new Uint8Array(oc.wasmMemory.buffer);
const values = new Float64Array(oc.wasmMemory.buffer);
```
A call that allocates may grow memory and detach an existing view. Create a
fresh typed array from `oc.wasmMemory.buffer` after such calls. Most consumers
never need direct memory access.
## Exception helpers [#exception-helpers]
Exception helpers are also instance properties. See
[Exception classes](/docs/package/reference/libcascade-api/exception-classes) and
[Debugging WASM exceptions](/docs/package/guides/debugging-wasm-exceptions).
## Module re-init [#module-re-init]
Calling `createInstance()` from `libcascade/init` creates another WASM instance with a separate C++ heap.
Memoise it when an application should share one runtime.
---
# API Reference
The full bound OCCT API (5,348 classes) is exposed as one synthesised Markdown page per package at `/docs/package/api///.mdx`. Use the search endpoint at `/api/search?query=…` to discover entries.