//.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 without a pre-built image 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` / `init..d.ts` | The `.//init` subpath — a fixed-variant runtime and type contract. Multi-variant packages only. |
| `index.js` / `index.d.ts` | The eager root — one selected, initialized instance with self-located WASM and named OCCT values. |
| `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 would otherwise 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 `createInstance` and owns the same loading plumbing, but it has no
selector surface: the import path fixes the variant. It rejects a `variant`
option, ignores the shared override symbol, and names exactly one glue file.
Its declarations follow the same contract: the single-threaded entry omits
both `variant` and `threadCount`; the threaded entry accepts `threadCount` but
still omits `variant`.
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 |
| `variant` option | accepted | rejected |
| `Symbol.for('.select')` override | picks the variant | ignored |
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. Passing any `variant`
option is a type error and a runtime error. The shared override symbol has no
effect because the module contains no selector.
## The root is always eager [#the-root-is-always-eager]
The root selects one capability-compatible variant, self-locates that
variant's WASM asset, initialises it with 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);
```
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
lazy API 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.single.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.multi.d.ts", "default": "./dist/init.multi.js" },
"./multi/wasm": "./dist/myapp_multi.wasm"
}
}
```
Each pinned entry has a small declaration file that removes selectors and
options the fixed variant cannot accept.
The merge keeps every subpath you declared yourself — hand-written aliases such
as `./wasm` or `./api-reference.json` keep resolving. It also replaces
generated entries in `files` with the exact current surface while preserving
hand-maintained files.
## 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` / `init..d.ts`, `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) — variants and their build settings.
* [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, 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": "{{version}}"
}
}
```
```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'],
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,
},
},
],
});
```
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 |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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/init`, `./single/wasm`, `./multi`, `./multi/init`,
`./multi/wasm`) into your `package.json` while preserving hand-written ones
such as `./wasm`. It also replaces generated `files` entries with the exact
current surface while preserving hand-maintained files.
## 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 lazy entries:
```typescript notypecheck
import { createInstance as createBest } from 'replicad-opencascadejs/init';
import { createInstance as createMulti } from 'replicad-opencascadejs/multi/init';
const oc = await createBest(); // best available
const mt = await createMulti(); // fixed by the import path
```
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.
`@libcascade/toolchain` ships lockstep-versioned with `libcascade` — the
toolchain that built the binary you depend on is the one whose types describe
it.
## Prerequisites [#prerequisites]
* Node 22+.
* A container engine installed **and running**: 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'],
},
compilerFlags: { optimize: 'O3', simd: true, exceptions: 'wasm', noEntry: true },
variants: [{ name: 'single' }],
});
```
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 which entry to import.
## 5. Consume the artifacts [#5-consume-the-artifacts]
The package root is the ready-to-use path: it selects a variant, self-locates
its WASM, and initializes at import time.
```typescript title="src/oc.ts" notypecheck
import oc, { BRepPrimAPI_MakeBox } from 'my-occt-package';
using box = new BRepPrimAPI_MakeBox(10, 10, 10);
using also = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
```
Import `./init` when your code must control when initialization happens:
```typescript title="src/oc-lazy.ts" notypecheck
import { createInstance } from 'my-occt-package/init';
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 a consumer would otherwise hand-copy: 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'],
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). |
| `STACK_SIZE: 8_388_608` | Deep OCCT recursion (offsets, booleans, healing) overflows emcc's 64 KB default, and an overflow corrupts static data before anything traps. The link step fills in 8 MB when you leave it unset, so set it only to change it. |
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: ['-mrelaxed-simd', '--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` 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**. The scan cannot see symbols
reserved for external callers or referenced only by custom C++ files. Review
every consumer. Never delete the difference from an existing config
automatically.
## `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',
],
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 as createBest } from 'my-occt-package/init';
import { createInstance as createMulti } from 'my-occt-package/multi/init';
const oc = await createBest(); // best variant this host supports
const mt = await createMulti(); // fixed by the import path
const capped = await createMulti({ 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.
Importing `multi/init` 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 createMulti({ 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": "{{version}}",
"libcascade": "{{version}}"
}
}
```
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@{{version}}` 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'],
},
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
a mistake that would otherwise surface as a link failure ten minutes into a
build is a red squiggle instead.
## 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 })`. |
| `init..js` / `init..d.ts` | A fixed-variant initializer with no selector or `variant` option. Multi-variant packages only. |
| `index.js` / `index.d.ts` | The eager root: a selected, initialized instance with self-located WASM and named OCCT values. |
| `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.
It also replaces generated `files` entries with the exact current surface while
preserving hand-maintained files.
`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]
One thing the yml format never recorded is 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.
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?, threads? }` | The closed set of non-`-s` flags worth typing. |
| `rawFlags` | `string[]` | Escape hatch. Appended verbatim after every typed flag. |
| `variants` | `{ name, outputName?, requires?, settings?, compilerFlags?, rawFlags? }[]` | **Required, non-empty.** One binary per entry. |
| `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` |
| `threads: true` | `-pthread` |
Anything else — `-mrelaxed-simd`, `--emit-symbol-map`,
and other unmodelled flags — goes in `rawFlags`.
The rendered flag order is fixed: exceptions → `-s` settings → `-flto` →
`--no-entry` → `-pthread` → `-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,
and single + multi variants.
* [`replicad-opencascadejs`](https://github.com/sgenoud/replicad) — a trimmed
custom build: 11 wrapper files declaring 17 custom symbols and single +
multi variants.
## 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 (for example `:{{version}}-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. `:{{version}}-canary.-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 (for example `:{{version}}-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. Each consumer's `docker run` string carries
slightly different platform-edge handling.
### 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.
---
# Calling OCCT from JavaScript
URL: /docs/package/concepts/calling-occt-from-js
The libcascade bindings present OCCT to JavaScript through a handful of conventions
that hide most of the C++ idioms. This page covers the call-site rules that
apply to every API surface — constructors, methods, statics, and downcasts. For
the shape of what comes back, see [Return shapes](/docs/package/concepts/return-shapes).
## A worked example, top to bottom [#a-worked-example-top-to-bottom]
Most of the rules below show up in this 8-line construction of an arc-edge:
```typescript
using p1 = new oc.gp_Pnt(0, 0, 0);
using p2 = new oc.gp_Pnt(5, 0, 5);
using p3 = new oc.gp_Pnt(10, 0, 0);
using arcMaker = new oc.GC_MakeArcOfCircle(p1, p2, p3);
using curve = arcMaker.Value(); // Geom_TrimmedCurve smart pointer
using edge = new oc.BRepBuilderAPI_MakeEdge(curve);
using shape = edge.Edge(); // TopoDS_Edge
using wire = new oc.BRepBuilderAPI_MakeWire(shape);
```
No `_2` / `_3` suffixes. No `Handle_*` wrappers. No `.get()` unwraps. No
manual `.delete()`. The rest of the page explains why each line works.
## No more `_N` overload suffixes [#no-more-_n-overload-suffixes]
libcascade V3 removed the legacy `_N` overload suffixes. You always call the
suffix-free constructor or method; the runtime picks the right overload based
on arity and argument shape.
```typescript
using p = new oc.gp_Pnt(1, 2, 3); // not gp_Pnt_3
using box1 = new oc.BRepPrimAPI_MakeBox(10, 20, 30);
using origin = new oc.gp_Pnt(1, 2, 3);
using box2 = new oc.BRepPrimAPI_MakeBox(origin, 10, 20, 30);
using corner = new oc.gp_Pnt(5, 10, 15);
using box3 = new oc.BRepPrimAPI_MakeBox(origin, corner);
```
`gp_Pnt`, `BRepPrimAPI_MakeBox`, `BRepBuilderAPI_MakeEdge` — every multi-arity
constructor surface works this way.
## Overload dispatch is by argument shape [#overload-dispatch-is-by-argument-shape]
Same-arity overloads are picked at call time by a small JS-side dispatcher.
You don't pick the overload — you pass arguments of the right shape and the
binding routes the call.
Three kinds of dispatch you'll see most often:
* **By class type** — `BRepBuilderAPI_MakeEdge(gp_Pnt, gp_Pnt)` and
`BRepBuilderAPI_MakeEdge(TopoDS_Vertex, TopoDS_Vertex)` both have arity 2;
the dispatcher routes by `instanceof`.
* **By integer vs float** — `gp_XY.SetCoord(1, 5.0)` routes to the indexed
overload (`Number.isInteger(1) === true`). Float-only forms use the
coordinate overload.
* **By enum value** — passing `oc.IntSurf_TypeTrans.IntSurf_In` selects the
enum-tagged constructor variant.
Where C++ has parallel `int` and `size_t` overloads the bindgen collapses them
to a single JS signature. `list.FindKey(1)` works without disambiguation.
## Methods returning values use `()` [#methods-returning-values-use-]
Every value-returning accessor is a method call, not a property. The most
common gotcha is reading a `gp_Pnt` as if its coordinates were fields:
```typescript
// Right — method calls.
using p = new oc.gp_Pnt(1, 2, 3);
console.log(p.X(), p.Y(), p.Z()); // 1 2 3
// Wrong — these are the bound functions, not numbers.
console.log(p.X, p.Y, p.Z); // [Function: X] [Function: Y] [Function: Z]
```
The same rule applies to `face.IsNull()`, `list.Size()`, `curve.FirstParameter()`,
and every other "looks like a getter" surface in the binding.
## Enums are string-valued object members [#enums-are-string-valued-object-members]
Every C++ enum is exposed as a plain JS object whose members are strings whose
value equals the member name:
```typescript
oc.TopAbs_ShapeEnum.TopAbs_EDGE === 'TopAbs_EDGE'; // true
oc.TopAbs_Orientation.TopAbs_FORWARD === 'TopAbs_FORWARD';
```
Always reach for the object-member spelling — it's the form IntelliSense
surfaces, the bindings emit `.d.ts` types for, and the smoke tests use:
```typescript
using box = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
using shape = box.Shape();
using explorer = new oc.TopExp_Explorer(
shape,
oc.TopAbs_ShapeEnum.TopAbs_FACE,
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
);
```
Enum-typed return values are strings too:
```typescript
const transition = aline.TransitionOnS1(); // 'IntSurf_In'
```
There is no `.value` accessor — the member already *is* the value.
## Trailing default args fill themselves [#trailing-default-args-fill-themselves]
C++ default arguments work the way you'd expect: omit trailing parameters and
the binding fills the C++ defaults.
```typescript
// BRepMesh_IncrementalMesh(shape, linearDeflection, isRelative=false,
// angDeflection=0.5, isInParallel=false)
using mesh = new oc.BRepMesh_IncrementalMesh(shape, 0.1);
// BRepAlgoAPI_Fuse(S1, S2, ProgressRange=...)
using fuse = new oc.BRepAlgoAPI_Fuse(s1, s2);
```
Non-trailing positions still need an argument — if you want to override
`angDeflection` you must also pass `isRelative`.
## `TopoDS` is the downcast bridge [#topods-is-the-downcast-bridge]
`oc.TopoDS` is the namespace bridge for casting a `TopoDS_Shape` down to its
concrete subtype. Each member is a free function that takes a generic shape
and returns the typed wrapper.
```typescript
using explorer = new oc.TopExp_Explorer(
shape,
oc.TopAbs_ShapeEnum.TopAbs_EDGE,
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
);
using current = explorer.Current();
using edge = oc.TopoDS.Edge(current); // typed TopoDS_Edge
```
The same shape exists for `Face`, `Wire`, `Vertex`, `Shell`, `Solid`,
`CompSolid`, and `Compound`. Each is a function, not a constructor — no `new`.
## Errors throw `WebAssembly.Exception` [#errors-throw-webassemblyexception]
When OCCT raises a C++ exception, it surfaces in JS as a `WebAssembly.Exception`
carrying the typed C++ tag. Decoding the tag gives you the actual OCCT error
(e.g. `Standard_DomainError`, `Standard_NullObject`). See the dedicated
[Debugging WASM exceptions](/docs/package/guides/debugging-wasm-exceptions) guide for the
matching pattern; this concepts page intentionally stays out of the
runtime-decoding weeds.
## Related [#related]
* [Return shapes](/docs/package/concepts/return-shapes) — what comes back from a call: native values,
in-place class outputs, envelopes, and Handle elision.
* [Handles and collections](/docs/package/concepts/handles-and-collections) — smart-pointer surfaces
(`isNull` / `nullify`) and the `NCollection_*` containers.
* [Memory and disposables](/docs/package/concepts/memory-and-disposables) — the `using` rule and the
`DisposableStack` patterns for ownership transfer.
---
# Handles and collections
URL: /docs/package/concepts/handles-and-collections
OCCT carries two long-running C++ idioms that V3's JS surface unifies under
JS-native shapes: the `Handle` smart pointer family and the `NCollection_*`
templated container family. This page covers both and the everyday patterns
they bring to your call sites.
## Smart pointers are unified [#smart-pointers-are-unified]
In OCCT C++, a `Handle` is an intrusive refcount wrapper around a
heap-allocated `Geom_Curve`. In the V3 JS bindings, the wrapper and the
pointee are the same JS object — every `Standard_Transient` subclass *is* the
Handle.
```typescript
using pnt = new oc.gp_Pnt(0, 0, 0);
using dir = new oc.gp_Dir(0, 0, 1);
using ax2 = new oc.gp_Ax2(pnt, dir);
using circle = new oc.Geom_Circle(ax2, 5);
circle.isNull(); // false
```
You never reach for a `Handle_` wrapper class — they don't exist on the
binding object:
```typescript
oc['Handle_Geom_Curve']; // undefined
```
You never call `.get()` to unwrap a Handle. Functions that expect a
`Handle` accept the `Geom_Line` directly:
```typescript
using line = new oc.Geom_Line(ax1);
using edge = new oc.BRepBuilderAPI_MakeEdge(line);
```
## `isNull()` vs `nullify()` [#isnull-vs-nullify]
Two methods carry the Handle nullability semantics into JS:
* `isNull()` is a read — `true` if the smart pointer holds no object.
* `nullify()` clears the smart pointer. **Method calls on a nullified Handle
throw.**
```typescript
using circle = new oc.Geom_Circle(ax2, 5);
circle.isNull(); // false
circle.nullify();
circle.isNull(); // true
circle.Radius(); // throws — decode via WebAssembly.Exception
```
The throw surfaces as a `WebAssembly.Exception` carrying the C++ tag; see
[Debugging WASM exceptions](/docs/package/guides/debugging-wasm-exceptions).
Plain primitive value types (`gp_Pnt`, `gp_Dir`, `gp_Vec`) do **not** have
`isNull` / `nullify` — they're not smart pointers, they're directly-owned
value objects.
## The `NCollection_*` container family [#the-ncollection_-container-family]
OCCT predates `std::vector`, so it ships its own container family. The
bindgen auto-discovers `NCollection_*` types referenced by surviving bound
classes and emits one JS class per template instantiation. The JS class name
is the mangled C++ name — `NCollection_List` becomes
`NCollection_List_TopoDS_Shape`, the legacy `TopTools_ListOfShape` typedef
does **not** appear on the binding object.
Auto-discovery is driven by C++ `using` typedef declarations in the
generated bindings — not by the JS `using` declaration keyword. These two
keywords share a name but solve different problems.
### List — `NCollection_List_TopoDS_Shape` [#list--ncollection_list_topods_shape]
The list family is sequence-ordered, supports head/tail access and reversal,
and is the workhorse for accumulating shapes during a topological walk.
```typescript
using box1 = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
using box2 = new oc.BRepPrimAPI_MakeBox(20, 20, 20);
using shape1 = box1.Shape();
using shape2 = box2.Shape();
using list = new oc.NCollection_List_TopoDS_Shape();
list.Size(); // 0
using appended1 = list.Append(shape1); // see note below
using appended2 = list.Append(shape2);
appended1; appended2; // suppress unused-var lint
list.Size(); // 2
using first = list.First();
using last = list.Last();
list.Reverse();
list.RemoveFirst();
```
**Critical:** `list.Append(shape)` returns a disposable iterator handle.
Capture it with `using` — leaving it unbound leaks the handle. The
`require-using-on-disposable` lint rule catches this automatically.
### Sequence — `NCollection_Sequence_TDF_Label` [#sequence--ncollection_sequence_tdf_label]
Sequence is the same conceptual shape as List but a different OCCT template;
it shows up most often in XDE / TDocStd workflows. **Its `Append` does not
return a disposable** — call it as a statement:
```typescript
using seq = new oc.NCollection_Sequence_TDF_Label();
using label = new oc.TDF_Label();
seq.Append(label); // void; no `using`
seq.Size(); // 1
```
The general rule across the `NCollection_*` family: `Append` semantics differ
per template. Trust the `.d.ts` types — they're the source of truth — and
check the smoke tests when uncertain.
### Indexed map — `NCollection_IndexedMap_TopoDS_Shape_TopTools_ShapeMapHasher` [#indexed-map--ncollection_indexedmap_topods_shape_toptools_shapemaphasher]
Indexed maps combine set semantics with insertion-order indexing. The classic
use is dedup-then-iterate over the faces of a shape:
```typescript
using box = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
using shape = box.Shape();
using explorer = new oc.TopExp_Explorer(
shape,
oc.TopAbs_ShapeEnum.TopAbs_FACE,
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
);
using map = new oc.NCollection_IndexedMap_TopoDS_Shape_TopTools_ShapeMapHasher();
while (explorer.More()) {
using current = explorer.Current();
map.Add(current);
explorer.Next();
}
map.Size(); // 6 (cube faces)
using face1 = map.FindKey(1); // 1-based
map.Contains(face1); // true
map.FindIndex(face1); // 1
```
`FindKey` is **1-based**, matching OCCT's index convention.
### Fixed-size array — `NCollection_Array1_gp_Pnt` [#fixed-size-array--ncollection_array1_gp_pnt]
`Array1` is OCCT's fixed-bounds array. The constructor takes lower and upper
bounds; the array is 1-indexed by default.
```typescript
using arr = new oc.NCollection_Array1_gp_Pnt(1, 5);
arr.Length(); // 5
arr.Lower(); // 1
arr.Upper(); // 5
const pts = [
new oc.gp_Pnt(1, 0, 0),
new oc.gp_Pnt(2, 0, 0),
new oc.gp_Pnt(3, 0, 0),
new oc.gp_Pnt(4, 0, 0),
new oc.gp_Pnt(5, 0, 0),
];
for (let i = 0; i < pts.length; i++) arr.SetValue(i + 1, pts[i]);
using third = arr.Value(3); // gp_Pnt at index 3
using first = arr.First();
using last = arr.Last();
```
Expect 1-based indexing everywhere in the `NCollection_*` family — `Value(0)`
throws `Standard_OutOfRange`.
## Lifetimes at a glance [#lifetimes-at-a-glance]
| Holder | Owns the heap allocation? |
| --------------------------------------------------------------- | ---------------------------------------------------- |
| Smart-pointer subclass (`Geom_Circle`, `Poly_Triangulation`, …) | Yes — refcounted; copying bumps the count. |
| Value class (`gp_Pnt`, `gp_Dir`, `gp_Vec`) | Yes — directly owned; dispose to free. |
| `T&` parameter | No — borrowed for the duration of the call. |
| `NCollection_*` | Yes — disposing the container disposes its contents. |
In JS terms: every wrapper class — handles, value types, containers — exposes
`[Symbol.dispose]`. Bind them with `using` so the wasm heap releases at scope
exit. See [Memory and disposables](/docs/package/concepts/memory-and-disposables) for the full
ruleset and the `DisposableStack` patterns for ownership transfer.
## Related [#related]
* [Memory and disposables](/docs/package/concepts/memory-and-disposables) — when `using` is
required, and the `DisposableStack` patterns.
* [Return shapes](/docs/package/concepts/return-shapes) — `NCollection_*` instances showing up
inside envelope fields.
* [Debugging WASM exceptions](/docs/package/guides/debugging-wasm-exceptions) — decoding
the throw from a nullified-Handle method call.
---
# Memory and disposables
URL: /docs/package/concepts/memory-and-disposables
Every OCCT object you allocate in JS — `gp_Pnt`, `TopoDS_Shape`,
`BRepPrimAPI_MakeBox`, every handle, every container — owns memory inside the
WebAssembly linear memory. Failing to release it leaks the wasm heap, which
grows up to the 4 GB wasm32 ceiling and then crashes the runtime.
## The `using` rule [#the-using-rule]
Declare every disposable OCCT object with `using` so the runtime invokes
`[Symbol.dispose]()` when control leaves the scope.
```typescript
{
using box = new oc.BRepPrimAPI_MakeBox(10, 10, 10);
using shape = box.Shape(); // also disposable
// ... use box / shape ...
} // ← both disposed here, in reverse declaration order
```
The libcascade source repository includes an oxlint rule, `ocjs-lint/require-using-on-disposable`,
that flags any plain `const`/`let` declaration of a disposable as an error.
It's wired into the in-repo `eslint.config.mjs` for the smoke-test suite but
is **not** auto-installed by `pnpm add libcascade` — the published npm tarball ships
the wasm, JS loader, `.d.ts`, API-reference feed, manifests, provenance, and
changelogs. If you want the rule in your own project, copy it from the libcascade
repo into your local lint setup.
## Primitive-only result objects [#primitive-only-result-objects]
Result objects whose fields are all primitives (no nested OCCT objects) do
**not** emit `[Symbol.dispose]`. You can use them without `using`:
```typescript
const bounds = surface.Bounds(0, 0, 0, 0);
console.log(bounds.U1, bounds.U2, bounds.V1, bounds.V2);
```
An OCCT class is still disposable even when it represents a small value. For
example, `gp_Pnt` owns a wasm-side C++ object, so both constructed and returned
points need `using`:
```typescript
using point = p0.Transformed(transform);
console.log(point.X(), point.Y(), point.Z());
```
Coordinate accessors like `X` / `Y` / `Z` are methods, not properties — see
[Calling OCCT from JS](/docs/package/concepts/calling-occt-from-js#methods-returning-values-use-)
for the pattern.
Containers with at least one disposable child auto-emit a `[Symbol.dispose]`
that walks the children and disposes each. The lint rule above handles the
classification automatically.
## When `using` is not required [#when-using-is-not-required]
`using` is required for anything that owns wasm memory and surfaces a
`[Symbol.dispose]`. Four common shapes do **not** need a `using` on the call
site — see [Return shapes](/docs/package/concepts/return-shapes#when-do-i-need-using) for the full
decision table.
* **`void` methods with class-only outputs** — `curve.D0(u, pt)` returns
`void`; the `using` lives on the input `pt`, not the call result.
* **Primitive / enum-only envelopes** — `surface.Bounds(0,0,0,0)` returns a
plain `{ U1, U2, V1, V2 }` object with no disposer; bind it with `const`.
* **Native primitive returns** — `pnt.X()`, `list.Size()`, `face.IsNull()`
return numbers / booleans, never disposables.
* **Forwarding ownership out of a helper** — when a function passes ownership
to its caller, the helper returns the disposable without `using` so the
caller binds it. The `DisposableStack.move()` pattern below is the
canonical OCCT idiom.
## Composing lifetimes with `DisposableStack` [#composing-lifetimes-with-disposablestack]
The TC39 explicit-resource-management proposal also ships a built-in
`DisposableStack` — a LIFO container that adopts disposables and disposes
each one when the stack itself is disposed. Two OCCT-specific patterns
recur often enough to keep in your toolbox.
### `stack.use(...)` — adopt a multi-handle return value as one resource [#stackuse--adopt-a-multi-handle-return-value-as-one-resource]
Some BRep\_Tool overloads return an RBV envelope that owns several embind
handles (e.g. `PolygonOnTriangulation` in its 2-arg form returns a
`{ P, T }` pair). Binding the whole envelope through `using` is the
easy path, but if you only want to forward it once and free everything in
one shot — `stack.use()` adopts the envelope and lets the parent stack
cascade through its `[Symbol.dispose]`.
```typescript
using stack = new DisposableStack();
const r = stack.use(oc.BRep_Tool.PolygonOnTriangulation(edge, loc));
// ... read r.P / r.T / r.L ...
// stack disposes here, which disposes r, which disposes P/T/L in turn.
```
### `stack.move()` — transfer ownership out of a search loop [#stackmove--transfer-ownership-out-of-a-search-loop]
The hardest OCCT lifetime puzzle is "iterate through a topology looking
for a match; on hit, hand the owning handles to the caller; on miss, free
everything before the next iteration". `using` alone can't express this
because a `using` binding always disposes at scope exit. `DisposableStack`
solves it: each iteration creates a fresh stack, adopts every interim
handle, and on success `move()`s the stack into the return value (which
empties the iteration-scoped stack so its scope-exit dispose is a no-op).
```typescript
import type {
Poly_Triangulation,
TopLoc_Location,
TopoDS_Edge,
TopoDS_Shape,
} from 'libcascade';
function findFirstTriangulatedEdge(shape: TopoDS_Shape):
DisposableStack & { edge: TopoDS_Edge; tri: Poly_Triangulation; loc: TopLoc_Location } {
const oc = getOC();
using explorer = new oc.TopExp_Explorer(shape, oc.TopAbs_ShapeEnum.TopAbs_EDGE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE);
while (explorer.More()) {
using iterStack = new DisposableStack();
using current = explorer.Current();
const edge = iterStack.use(oc.TopoDS.Edge(current));
const loc = iterStack.use(new oc.TopLoc_Location());
// ... walk faces, look up triangulation ...
const tri = iterStack.use(oc.BRep_Tool.Triangulation(face, loc, 0));
if (!tri.isNull()) {
// Match: transfer ownership. `iterStack` is now empty, its scope-exit
// dispose is a no-op, and the returned stack owns edge/tri/loc.
return Object.assign(iterStack.move(), { edge, tri, loc });
}
explorer.Next();
// No match: `iterStack` disposes here, freeing edge/loc/tri this round.
}
throw new Error('no triangulated edge');
}
// Caller side — one `using` collects three handles:
using ctx = findFirstTriangulatedEdge(shape);
// The 3-arg PolygonOnTriangulation overload returns a native Handle smart
// pointer (disposable). For the elision overload that returns the {P, T, L}
// envelope, see the `stack.use()` example above.
using polygon = oc.BRep_Tool.PolygonOnTriangulation(ctx.edge, ctx.tri, ctx.loc);
```
`DisposableStack` is part of the JS standard library wherever
`[Symbol.dispose]` is — no libcascade-specific runtime is involved. See
[MDN: DisposableStack](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DisposableStack)
for the full surface (`defer`, `adopt`, `disposed`, etc.).
## Pool pattern for hot loops [#pool-pattern-for-hot-loops]
In a render loop you may allocate the same OCCT object hundreds of times per
second. Allocate once, reuse, dispose once:
```typescript
class TransformPool {
private readonly transforms = new Map();
get(key: string): typeof oc.gp_Trsf {
let trsf = this.transforms.get(key);
if (!trsf) {
trsf = new oc.gp_Trsf();
this.transforms.set(key, trsf);
}
return trsf;
}
[Symbol.dispose]() {
for (const trsf of this.transforms.values()) trsf[Symbol.dispose]();
this.transforms.clear();
}
}
using pool = new TransformPool();
for (let i = 0; i < 1000; i++) {
const trsf = pool.get(`rot-${i % 8}`);
trsf.SetRotation(axis, i * 0.01);
// ...
}
```
## Diagnosing leaks [#diagnosing-leaks]
Wasm heap grows monotonically — to detect a leak, measure the live memory
buffer before and after a representative workload.
```typescript
const before = oc.wasmMemory.buffer.byteLength;
runWorkload();
const after = oc.wasmMemory.buffer.byteLength;
console.log(`heap delta: ${(after - before) / 1024} KB`);
```
For deep audits, build with `-sASSERTIONS=2 -fsanitize=leak` (debug builds
only) and the runtime prints a leak summary on `process.exit()`.
## What `[Symbol.dispose]` actually does [#what-symboldispose-actually-does]
Calls the wrapped object's C++ destructor through the embind glue. For
handles, this decrements the refcount; for value types, it frees the C++
allocation. The JS wrapper object becomes invalid — subsequent method calls
throw an embind error (text varies, typically along the lines of
`BindingError: instance already deleted`).
## Pitfalls [#pitfalls]
* **`using` inside an `if (cond)` branch** disposes when control exits the
branch, not when the outer block ends. Move the declaration up if you need
it across the branch.
* **Returning a `using` value** disposes it before the caller sees it. Use a
plain `const` for return values and disposal at the call site.
* **Passing a disposed object as a parameter** throws on every method.
Disciplined `using` placement is the cheapest defence.
## Related [#related]
* [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) — when a call returns an envelope, a
native value, or an in-place class output, and which of those need `using`.
* [Handles and collections](/docs/package/concepts/handles-and-collections) — `isNull` / `nullify`
on smart pointers and the `NCollection_*` container family.
---
# Return shapes
URL: /docs/package/concepts/return-shapes
A single OCCT method can have multiple output channels: its native C++ return,
plus any number of `T&` output parameters. The bindings collapse these into a
small set of JS-shaped return forms. This page covers each shape, how to
recognise it from a signature, and whether the result needs `using`.
For the rules on *how to call* a method (overload dispatch, enums, defaults),
see [Calling OCCT from JS](/docs/package/concepts/calling-occt-from-js).
## TL;DR [#tldr]
| What the C++ method outputs | What JS sees |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Class output param (`Bnd_Box&`, `gp_Pnt&`, …) | The class you passed in is mutated in place. Read it after the call. |
| Primitive / enum output param (`double&`, `Standard_Real&`) | An envelope is returned; **keep passing placeholder values** at the slot. |
| Non-const `Handle&` output param | The slot is **elided** from the JS signature; the Handle appears as an envelope field. |
| Native C++ return alongside any output(s) | Surfaced on the envelope as `returnValue`. |
## Decision tree [#decision-tree]
Walk this table top-to-bottom for any signature you're reading off the `.d.ts`:
| C++ return | C++ output params | Resulting JS shape |
| ---------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Non-`void` | None | Native return (no envelope). Smart pointers count as native — see Handles below. |
| Non-`void` | Class only (mutated in place) | Native return. Read mutated classes from your input variables; they are not echoed in the return. |
| `void` | Class only | `void`. Read mutated classes from your input variables. |
| Non-`void` | Primitives / enums / elided Handles (± class outputs) | Envelope with `returnValue` for the C++ return + one named field per non-class, non-elided output. Class outputs are NOT echoed. |
| `void` | Primitives / enums / elided Handles (± class outputs) | Same envelope shape, minus `returnValue`. |
The four sections below walk each row with worked examples.
## Class outputs mutate in place [#class-outputs-mutate-in-place]
When a method takes a non-const class reference (`gp_Pnt&`, `Bnd_Box&`,
`GProp_GProps&`, `TopoDS_Shape&`), the binding passes the JS wrapper's
underlying C++ pointer straight through. The C++ method mutates the object you
allocated; you read the result by querying your own variable after the call.
```typescript
using curve = makeSomeCurve();
using inStartPt = new oc.gp_Pnt2d(0, 0);
using inEndPt = new oc.gp_Pnt2d(0, 0);
curve.D0(curve.FirstParameter(), inStartPt);
curve.D0(curve.LastParameter(), inEndPt);
console.log(inStartPt.X(), inStartPt.Y()); // mutated by D0
console.log(inEndPt.X(), inEndPt.Y());
```
Same pattern for whole-shape evaluators:
```typescript
using props = new oc.GProp_GProps();
oc.BRepGProp.VolumeProperties(shape, props, false, false, false);
const volume = props.Mass();
```
The call itself returns `void` or a native value — never an envelope echoing
the class output. The `using` declaration that matters is the one on your
input class; the call site doesn't need its own `using`.
## Primitive / enum envelopes — placeholder inputs are required [#primitive--enum-envelopes--placeholder-inputs-are-required]
When the method has primitive or enum output parameters, the binding wraps the
return in an envelope object whose fields mirror those outputs. The C++
signature is preserved at the call site — you keep passing values for those
slots, but the values are placeholders that C++ overwrites.
```typescript
using surface = new oc.Geom_SphericalSurface(ax, 10);
const bounds = surface.Bounds(0, 0, 0, 0); // placeholders
console.log(bounds.U1, bounds.U2, bounds.V1, bounds.V2);
```
The four `0`s are **not optional**. The binding uses input-passthrough
return-by-value: it preserves the C++ argument list so overload resolution
still works, then reads back the mutated values into the envelope. Calling
`surface.Bounds()` with zero arguments either fails dispatch or picks the
wrong overload.
Primitive-only envelopes (every field is a number, boolean, or string) **do
not carry `[Symbol.dispose]`**. Plain `const` works; you don't need `using` on
the result. This is enforced by the type-level contract in
`tests/disposable-containers.test-d.ts`; the generated declarations are the
canonical truth.
## `returnValue`, not `result` [#returnvalue-not-result]
When a method has a non-`void` C++ return *and* primitive/enum/Handle
outputs, the envelope grows a `returnValue` field carrying the native return.
The v2 name `result` is gone in V3.
```typescript
using batten = new oc.FairCurve_Batten(p1, p2, /* height */ 0.5);
const r = batten.Compute(
oc.FairCurve_AnalysisCode.FairCurve_OK, // input-passthrough enum
/* nbIters */ 50,
/* tolerance */ 1e-3,
);
console.log(r.returnValue); // boolean, the C++ return
console.log(r.Code); // enum output mirror
```
`BRep_Tool.Curve` is another common case: the primitive-only outputs (`First`,
`Last`) and the native Handle return appear together on a `{ returnValue,
First, Last }` envelope.
## Handle output elision [#handle-output-elision]
Non-const `Handle&` output parameters are the one case where the JS
signature is *smaller* than the C++ signature. The binding drops those
positions from the JS argument list entirely; the freshly-assigned Handle
surfaces as an envelope field instead.
```typescript
using r = oc.BRep_Tool.PolygonOnTriangulation(edge, loc); // 2 args, not 4
console.log(r.P, r.T); // Handle outputs
// `loc` (a class output) is mutated in place; not echoed in r.
```
Compare to the same method called with a triangulation in its overload slot —
note that this is a **different overload**, picked by argument shape, that
returns a native Handle instead of an envelope:
```typescript
using handle = oc.BRep_Tool.PolygonOnTriangulation(edge, tri, loc); // 3 args
// handle is a Handle, not an envelope.
```
This is the asymmetry to internalise:
* Primitive / enum output slots → **stay** in the JS arg list as placeholders.
* `Handle&` output slots → **disappear** from the JS arg list.
Other elided signatures: `GeomInt_IntSS.BuildPCurves`,
`ShapeAnalysis_Edge.TreatRLine`, the `ShapeConstruct.New*` family,
`HelixGeom_BuilderApproxCurve3d.ApprHelix`.
Handle-bearing envelopes do carry `[Symbol.dispose]` — they own embind
wrappers that must be released. Always bind them with `using`.
## When do I need `using`? [#when-do-i-need-using]
| Return shape | `using` on the call site? |
| ----------------------------------------------------------------------- | ------------------------------------------------------------ |
| `void` with class-only outputs (e.g. `curve.D0(u, pt)`) | No — `using` goes on the input class. |
| Native primitive return (`pnt.X()`, `list.Size()`) | No — plain values. |
| Native Handle return (`arcMaker.Value() → Geom_TrimmedCurve`) | Yes — Handle is disposable. |
| Primitive / enum-only envelope (`surface.Bounds(0,0,0,0)`) | No — envelope has no disposer. |
| Handle-bearing envelope (`BRep_Tool.PolygonOnTriangulation(edge, loc)`) | Yes — `[Symbol.dispose]` cascades through the Handle fields. |
The canonical type-level contract lives in
`tests/disposable-containers.test-d.ts` and `tests/output-params.test-d.ts`
in the libcascade source repository — when in doubt, the type definitions are the source of
truth.
## Disposer idempotency [#disposer-idempotency]
Manual `r[Symbol.dispose]()` followed by a `using` scope-exit re-dispose is
safe — the second call is a no-op rather than a `BindingError`. This is
intentional so try/finally migration paths can co-exist with `using`:
```typescript
using r = oc.BRep_Tool.PolygonOnTriangulation(edge, loc);
r[Symbol.dispose](); // no-op for the scope-exit dispose
```
## Related [#related]
* [Calling OCCT from JS](/docs/package/concepts/calling-occt-from-js) — overload dispatch,
defaults, enums, and the `TopoDS` downcast bridge.
* [Handles and collections](/docs/package/concepts/handles-and-collections) — the smart-pointer
surface (`isNull` / `nullify`) and the `NCollection_*` containers that show
up in envelope fields.
* [Memory and disposables](/docs/package/concepts/memory-and-disposables) — when `using` is
required vs. optional, and the `DisposableStack` patterns.
---
# Boolean logo
URL: /docs/package/examples/boolean-logo
A worked example of boolean operations — start with a sphere, cut it four
times with translated and scaled copies, fuse a rotated duplicate, and
visualise the result.
```typescript title="examples/boolean-logo.ts"
import type { TopoDS_Shape } from 'libcascade';
import { getOc } from './libcascade-init';
export const buildLogo = async (): Promise => {
const oc = await getOc();
using sphere = new oc.BRepPrimAPI_MakeSphere(1);
const makeCut = (shape: TopoDS_Shape, translation: readonly [number, number, number], scale: number) => {
using tf = new oc.gp_Trsf();
tf.SetTranslation(new oc.gp_Vec(translation[0], translation[1], translation[2]));
tf.SetScaleFactor(scale);
using loc = new oc.TopLoc_Location(tf);
using progress = new oc.Message_ProgressRange();
using cut = new oc.BRepAlgoAPI_Cut(shape, sphere.Shape().Moved(loc, false), progress);
cut.Build(progress);
return cut.Shape();
};
const cut1 = makeCut(sphere.Shape(), [0, 0, 0.7], 1);
const cut2 = makeCut(cut1, [0, 0, -0.7], 1);
const cut3 = makeCut(cut2, [0, 0.25, 1.75], 1.825);
const cut4 = makeCut(cut3, [4.8, 0, 0], 5);
const makeRotation = (rotation: number) => {
const tf = new oc.gp_Trsf();
tf.SetRotation(new oc.gp_Ax1(new oc.gp_Pnt(), new oc.gp_Dir(0, 0, 1)), rotation);
return new oc.TopLoc_Location(tf);
};
using progress = new oc.Message_ProgressRange();
using fuse = new oc.BRepAlgoAPI_Fuse(cut4, cut4.Moved(makeRotation(Math.PI), false), progress);
fuse.Build(progress);
const result = fuse.Shape().Moved(makeRotation(-30 * Math.PI / 180), false);
// XCAF — per-subset PBR materials (brass + gray zones)
using doc = new oc.TDocStd_Document(new oc.TCollection_ExtendedString_1());
const shapeTool = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()).get();
using it1 = new oc.TopoDS_Iterator(result, true, true);
for (; it1.More(); it1.Next()) {
let i = 0;
using it2 = new oc.TopoDS_Iterator(it1.Value(), true, true);
for (; it2.More(); it2.Next()) {
const newShape = shapeTool.NewShape();
shapeTool.SetShape(newShape, it2.Value());
const vmtool = oc.XCAFDoc_DocumentTool.VisMaterialTool(newShape).get();
using visMat = new oc.XCAFDoc_VisMaterial();
const matLabel = vmtool.AddMaterial(
new oc.Handle_XCAFDoc_VisMaterial(visMat),
new oc.TCollection_AsciiString(`logoMat${i}`),
);
vmtool.SetShapeMaterial(newShape, matLabel);
using visMatPbr = new oc.XCAFDoc_VisMaterialPBR();
if (i === 3) {
visMatPbr.BaseColor = new oc.Quantity_ColorRGBA(0.6, 0.5, 0, 1);
} else {
visMatPbr.BaseColor = new oc.Quantity_ColorRGBA(0.3, 0.3, 0.3, 1);
}
visMat.SetPbrMaterial(visMatPbr);
i++;
}
}
return result;
};
```
## What's happening [#whats-happening]
1. `BRepPrimAPI_MakeSphere(1)` builds a unit sphere.
2. `makeCut` constructs a transform (translate + scale), wraps it in a
`TopLoc_Location`, moves a copy of the sphere, and subtracts it via
`BRepAlgoAPI_Cut`.
3. Four sequential cuts produce one half of the ring glyph.
4. `BRepAlgoAPI_Fuse` welds the half to a 180°-rotated copy of itself.
5. A final 30° rotation tilts the ring.
The takeaway: boolean operations allow you to create highly complex shapes
that would be difficult or impossible with classical polygon-based modelling.
## Render it [#render-it]
```typescript
const logoShape = await buildLogo();
const glb = await shapeToGlb(logoShape);
renderGlb(canvas, glb);
```
See [Render with three.js](/docs/package/guides/render-with-three-js) for the GLB → three.js wiring.
See also [Visualize shape helper](/docs/package/guides/visualize-shape-helper).
---
# Classic bottle
URL: /docs/package/examples/classic-bottle
The [OpenCASCADE bottle tutorial](https://dev.opencascade.org/doc/overview/html/occt__tutorial.html)
ported to V3 TypeScript with `using` syntax and suffix-free API.
The example demonstrates **every major OCCT capability** in one script: 2D
profile construction, mirroring, extrusion, fillets, cylinder primitives,
boolean fuse, hollow-solid generation, threading via `ThruSections`, and
final compound assembly.
```typescript title="examples/classic-bottle.ts"
import type { TopoDS_Shape } from 'libcascade';
import { getOc } from './libcascade-init';
export type BottleParams = {
width: number; // 20–100, default 50
height: number; // 50–120, default 70
thickness: number; // 15–50, default 30
};
export const buildBottle = async (
{ width, height, thickness }: BottleParams = { width: 50, height: 70, thickness: 30 },
): Promise => {
const oc = await getOc();
// Profile — define support points
const aPnt1 = new oc.gp_Pnt(-width / 2, 0, 0);
const aPnt2 = new oc.gp_Pnt(-width / 2, -thickness / 4, 0);
const aPnt3 = new oc.gp_Pnt(0, -thickness / 2, 0);
const aPnt4 = new oc.gp_Pnt(width / 2, -thickness / 4, 0);
const aPnt5 = new oc.gp_Pnt(width / 2, 0, 0);
// Profile — define the geometry
using arc = new oc.GC_MakeArcOfCircle(aPnt2, aPnt3, aPnt4);
using seg1 = new oc.GC_MakeSegment(aPnt1, aPnt2);
using seg2 = new oc.GC_MakeSegment(aPnt4, aPnt5);
// Profile — define the topology
using edge1 = new oc.BRepBuilderAPI_MakeEdge(seg1.Value());
using edge2 = new oc.BRepBuilderAPI_MakeEdge(arc.Value());
using edge3 = new oc.BRepBuilderAPI_MakeEdge(seg2.Value());
using wire = new oc.BRepBuilderAPI_MakeWire(edge1.Edge(), edge2.Edge(), edge3.Edge());
// Mirror the wire across the X axis
const xAxis = oc.gp.OX();
using trsf = new oc.gp_Trsf();
trsf.SetMirror(xAxis);
using mirroredBuilder = new oc.BRepBuilderAPI_Transform(wire.Wire(), trsf, false);
const mirroredShape = mirroredBuilder.Shape();
using fullProfile = new oc.BRepBuilderAPI_MakeWire();
fullProfile.Add(wire.Wire());
fullProfile.Add(oc.TopoDS.Wire(mirroredShape));
// Body — extrude the profile
using faceProfile = new oc.BRepBuilderAPI_MakeFace(fullProfile.Wire(), false);
using prismVec = new oc.gp_Vec(0, 0, height);
using body = new oc.BRepPrimAPI_MakePrism(faceProfile.Face(), prismVec, false, true);
let workingBody = body.Shape();
// Body — apply edge fillets
using fillet = new oc.BRepFilletAPI_MakeFillet(workingBody, oc.ChFi3d_FilletShape.ChFi3d_Rational);
using edgeExp = new oc.TopExp_Explorer(workingBody, oc.TopAbs_ShapeEnum.TopAbs_EDGE);
while (edgeExp.More()) {
fillet.Add(thickness / 12, oc.TopoDS.Edge(edgeExp.Current()));
edgeExp.Next();
}
workingBody = fillet.Shape();
// Body — add the neck
using neckLocation = new oc.gp_Pnt(0, 0, height);
const neckAxis = oc.gp.DZ();
using neckAx2 = new oc.gp_Ax2(neckLocation, neckAxis);
const neckRadius = 5;
const neckHeight = 5;
using cyl = new oc.BRepPrimAPI_MakeCylinder(neckAx2, neckRadius, neckHeight);
using progress = new oc.Message_ProgressRange();
using fuse = new oc.BRepAlgoAPI_Fuse(workingBody, cyl.Shape(), progress);
workingBody = fuse.Shape();
// Body — hollow the solid (remove the top face of the neck)
let faceToRemove: ReturnType | undefined;
let zMax = -1;
using faceExp = new oc.TopExp_Explorer(workingBody, oc.TopAbs_ShapeEnum.TopAbs_FACE);
for (; faceExp.More(); faceExp.Next()) {
const aFace = oc.TopoDS.Face(faceExp.Current());
const aSurface = oc.BRep_Tool.Surface(aFace);
if (aSurface.get().$$.ptrType.name === 'Geom_Plane*') {
const aPlane = new oc.Handle_Geom_Plane(aSurface.get()).get();
const aPnt = aPlane.Location();
if (aPnt.Z() > zMax) {
zMax = aPnt.Z();
using topFaceExp = new oc.TopExp_Explorer(aFace, oc.TopAbs_ShapeEnum.TopAbs_FACE);
faceToRemove = oc.TopoDS.Face(topFaceExp.Current());
}
}
}
using facesToRemove = new oc.TopTools_ListOfShape();
if (faceToRemove) facesToRemove.Append(faceToRemove);
using thickSolid = new oc.BRepOffsetAPI_MakeThickSolid();
thickSolid.MakeThickSolidByJoin(
workingBody,
facesToRemove,
-thickness / 50,
1e-3,
oc.BRepOffset_Mode.BRepOffset_Skin,
false,
false,
oc.GeomAbs_JoinType.GeomAbs_Arc,
false,
progress,
);
workingBody = thickSolid.Shape();
// Threading — cylindrical surfaces + elliptical 2D curves
using aCyl1 = new oc.Geom_CylindricalSurface(new oc.gp_Ax3(neckAx2), neckRadius * 0.99);
using aCyl2 = new oc.Geom_CylindricalSurface(new oc.gp_Ax3(neckAx2), neckRadius * 1.05);
const aPnt2d = new oc.gp_Pnt2d(2 * Math.PI, neckHeight / 2);
const aDir2d = new oc.gp_Dir2d(2 * Math.PI, neckHeight / 4);
using anAx2d = new oc.gp_Ax2d(aPnt2d, aDir2d);
const aMajor = 2 * Math.PI;
const aMinor = neckHeight / 10;
using anEllipse1 = new oc.Geom2d_Ellipse(anAx2d, aMajor, aMinor, true);
using anEllipse2 = new oc.Geom2d_Ellipse(anAx2d, aMajor, aMinor / 4, true);
using anArc1 = new oc.Geom2d_TrimmedCurve(new oc.Handle_Geom2d_Curve(anEllipse1), 0, Math.PI, true, true);
using anArc2 = new oc.Geom2d_TrimmedCurve(new oc.Handle_Geom2d_Curve(anEllipse2), 0, Math.PI, true, true);
const tmp1 = anEllipse1.Value(0);
const anEllipsePnt1 = new oc.gp_Pnt2d(tmp1.X(), tmp1.Y());
const tmp2 = anEllipse1.Value(Math.PI);
const anEllipsePnt2 = new oc.gp_Pnt2d(tmp2.X(), tmp2.Y());
using aSegment = new oc.GCE2d_MakeSegment(anEllipsePnt1, anEllipsePnt2);
using anEdge1OnSurf1 = new oc.BRepBuilderAPI_MakeEdge(
new oc.Handle_Geom2d_Curve(anArc1),
new oc.Handle_Geom_Surface(aCyl1),
);
using anEdge2OnSurf1 = new oc.BRepBuilderAPI_MakeEdge(
new oc.Handle_Geom2d_Curve(aSegment.Value()),
new oc.Handle_Geom_Surface(aCyl1),
);
using anEdge1OnSurf2 = new oc.BRepBuilderAPI_MakeEdge(
new oc.Handle_Geom2d_Curve(anArc2),
new oc.Handle_Geom_Surface(aCyl2),
);
using anEdge2OnSurf2 = new oc.BRepBuilderAPI_MakeEdge(
new oc.Handle_Geom2d_Curve(aSegment.Value()),
new oc.Handle_Geom_Surface(aCyl2),
);
using threadingWire1 = new oc.BRepBuilderAPI_MakeWire(
anEdge1OnSurf1.Edge(),
anEdge2OnSurf1.Edge(),
);
using threadingWire2 = new oc.BRepBuilderAPI_MakeWire(
anEdge1OnSurf2.Edge(),
anEdge2OnSurf2.Edge(),
);
oc.BRepLib.BuildCurves3d(threadingWire1.Wire());
oc.BRepLib.BuildCurves3d(threadingWire2.Wire());
using aTool = new oc.BRepOffsetAPI_ThruSections(true, false, 1e-6);
aTool.AddWire(threadingWire1.Wire());
aTool.AddWire(threadingWire2.Wire());
aTool.CheckCompatibility(false);
const myThreading = aTool.Shape();
// Compound assembly + final rotation
using aRes = new oc.TopoDS_Compound();
using aBuilder = new oc.BRep_Builder();
aBuilder.MakeCompound(aRes);
aBuilder.Add(aRes, workingBody);
aBuilder.Add(aRes, myThreading);
using rotTrsf = new oc.gp_Trsf();
rotTrsf.SetRotation(new oc.gp_Ax1(new oc.gp_Pnt(), new oc.gp_Dir(1, 0, 0)), -Math.PI / 2);
using rotLoc = new oc.TopLoc_Location(rotTrsf);
return aRes.Moved(rotLoc, false);
};
```
Every OCCT API used above has a direct V3 binding under `oc.` with no
`_N` suffix. See the [OpenCASCADE tutorial](https://dev.opencascade.org/doc/overview/html/occt__tutorial.html)
for the step-by-step walkthrough of the underlying geometry.
## Migration from v2 [#migration-from-v2]
The v2 example used `gp_Pnt_3`, `GC_MakeArcOfCircle_4`, `BRepBuilderAPI_MakeEdge_24`,
`gp_Trsf_1`, etc. V3 drops every `_N` suffix — overload dispatch happens in
C++ via the unified RBV pipeline. Pass arguments by type and the right overload
runs automatically.
## Render [#render]
See [Render with three.js](/docs/package/guides/render-with-three-js) for the GLB → three.js wiring.
See also [Visualize shape helper](/docs/package/guides/visualize-shape-helper).
---
# Polygon extrusion
URL: /docs/package/examples/polygon-extrusion
The minimum useful OCCT example: a four-point polygon extruded along Z.
```typescript title="examples/polygon-extrusion.ts"
import type { TopoDS_Shape } from 'libcascade';
import { getOc } from './libcascade-init';
export const buildExtrudedPolygon = async (): Promise => {
const oc = await getOc();
using polygon = new oc.BRepBuilderAPI_MakePolygon();
polygon.Add(new oc.gp_Pnt(-50, -50, 0));
polygon.Add(new oc.gp_Pnt(50, -50, 0));
polygon.Add(new oc.gp_Pnt(50, 50, 0));
polygon.Add(new oc.gp_Pnt(-50, 50, 0));
polygon.Close();
using face = new oc.BRepBuilderAPI_MakeFace(polygon.Wire(), false);
using prismVec = new oc.gp_Vec(0, 0, 40);
using prism = new oc.BRepPrimAPI_MakePrism(face.Face(), prismVec, false, true);
return prism.Shape();
};
```
## Why this matters [#why-this-matters]
* `BRepBuilderAPI_MakePolygon` accepts an arbitrary number of points — three
for a triangle, hundreds for a complex contour.
* `Close()` adds the implicit closing edge.
* `BRepBuilderAPI_MakeFace(wire, false)` faces the wire; `false` means "this
is the only wire of the face" (set `true` if you're adding holes later).
* `BRepPrimAPI_MakePrism` extrudes along an arbitrary direction vector rather
than only Z.
Swap `gp_Vec(0, 0, 40)` for `gp_Vec(0.5, 0.5, 1)` and you get a slanted
prism. Swap the polygon for a `BRepBuilderAPI_MakeWire` with curved edges
and you get an extruded curved profile.
## Next steps [#next-steps]
* Combine multiple extrusions with `BRepAlgoAPI_Fuse` for additive construction.
* Subtract holes with `BRepAlgoAPI_Cut`.
* Fillet sharp edges with `BRepFilletAPI_MakeFillet`.
See [First shape tutorial](/docs/package/getting-started/first-shape-tutorial) for the fillet pattern.
---
# FAQ
URL: /docs/package/getting-started/faq
## What is the project lineage? [#what-is-the-project-lineage]
libcascade was originally created by
[Sebastian Alff (`donalffons`)](https://github.com/donalffons) as `opencascade.js`,
a port of [OpenCASCADE Technology (OCCT)](https://dev.opencascade.org/) to
WebAssembly via Emscripten. libcascade succeeds it: development, releases, and
support run from [`taucad/opencascade.js`](https://github.com/taucad/opencascade.js),
and changes are not merged back to the original repository.
libcascade does **not** modify the OCCT C++ source beyond a set of
patches applied at build time. The pipeline downloads a tagged OCCT commit,
compiles with Emscripten, auto-generates embind bindings from libclang, and
ships the wasm + TypeScript surface as an npm package.
## Who maintains it? [#who-maintains-it]
The maintainers and contributors at
[`taucad/opencascade.js`](https://github.com/taucad/opencascade.js) own
development, documentation, the GHCR image, and the `libcascade` npm package.
Sebastian Alff is credited as the original author.
## How can I contribute? [#how-can-i-contribute]
Contributions are welcome:
1. Open issues or PRs at [`taucad/opencascade.js`](https://github.com/taucad/opencascade.js).
2. Follow the existing code style and test conventions in the repo.
3. Add focused tests for behavior changes and keep generated bindings generic
at the C++ type-system level.
For larger architectural questions, start a discussion before opening a PR.
---
# First Shape Tutorial
URL: /docs/package/getting-started/first-shape-tutorial
This tutorial extends the npm quickstart with explanations of every OCCT type
involved — useful if you've never touched OCCT before.
## Step 1 — Choose a primitive [#step-1--choose-a-primitive]
`BRepPrimAPI_MakeBox` constructs an axis-aligned box from three lengths.
```typescript
using box = new oc.BRepPrimAPI_MakeBox(60, 40, 20);
const shape = box.Shape();
```
`shape` is a `TopoDS_Shape` — the universal OCCT geometry handle. All booleans,
fillets, and exports take and return `TopoDS_Shape` instances.
## Step 2 — Walk topology [#step-2--walk-topology]
OCCT shapes are hierarchical: `SOLID → SHELL → FACE → WIRE → EDGE → VERTEX`.
`TopExp_Explorer` iterates entries of a given kind in declaration order.
```typescript
using explorer = new oc.TopExp_Explorer(shape, oc.TopAbs_ShapeEnum.TopAbs_EDGE);
const edges: typeof oc.TopoDS_Edge[] = [];
while (explorer.More()) {
edges.push(oc.TopoDS.Edge(explorer.Current()));
explorer.Next();
}
```
A box has 12 edges — three groups of 4 parallel edges along each axis.
## Step 3 — Apply a fillet [#step-3--apply-a-fillet]
`BRepFilletAPI_MakeFillet` takes the shape and a per-edge radius. Calling
`Add(radius, edge)` once per edge marks them for filleting; `Shape()` runs the
algorithm and returns the result.
```typescript
using fillet = new oc.BRepFilletAPI_MakeFillet(shape);
for (const edge of edges) fillet.Add(3, edge);
const filletedShape = fillet.Shape();
```
## Step 4 — Tessellate and export [#step-4--tessellate-and-export]
Until you call `BRepMesh_IncrementalMesh`, the shape has no triangulation —
it's still analytic. The mesher caches per-shape triangulation; pass
`decreate=true` on the third argument to force regeneration when you change
tolerance.
```typescript
using _mesh = new oc.BRepMesh_IncrementalMesh(filletedShape, 0.1, false, 0.5, false);
```
The two tolerances are **linear** (mm) and **angular** (radians). 0.1 mm linear
is fine for a 60 mm box; tighten it on small features.
Export via the XCAF path documented in [Export glTF / GLB](/docs/package/guides/export-gltf)
to get a GLB your three.js viewer can load.
## Step 5 — Render [#step-5--render]
The npm quickstart shows the full three.js wiring. The minimal version:
```typescript
const loader = new GLTFLoader();
loader.parse(glb.buffer, '', (gltf) => scene.add(gltf.scene));
```
## What went wrong if… [#what-went-wrong-if]
| Symptom | Likely cause |
| ---------------------------------------- | ------------------------------------------------------------------------------------- |
| `BindingError: ptr<-1>` | You called a method on a disposed object — `using` exited scope earlier than expected |
| Black canvas, no errors | Camera is inside the shape — set `camera.position.set(100, 100, 100)` |
| Fillet `Shape()` throws | One of your edges is degenerate; check `edge.Orientation()` |
| `RWGltf_CafWriter.Perform` returns false | The XCAF doc has no shape attached — verify `ShapeTool.AddShape` succeeded |
## Where to next [#where-to-next]
* [Memory and disposables](/docs/package/concepts/memory-and-disposables) for the OCCT memory model in depth.
* [Two-channel config model](/docs/toolchain/concepts/two-channel-config-model) for compile-time vs link-time config.
* [BRepPrimAPI](/docs/package/api/modeling-algorithms/tk-prim/b-rep-prim-api) for the primitive surface.
---
# Projects using libcascade
URL: /docs/package/getting-started/projects-using-libcascade
libcascade powers browser-native CAD across several production apps and
reference repositories. Tau is listed as a peer entry alongside the original
gallery — not promoted to headline status.
## Applications [#applications]
* [ArchiYou](https://archiyou.com/) — library, code-CAD design tool, community hub.
* [BitByBit](https://bitbybit.dev/) — code- and node-based CAD design tool.
* [CascadeStudio](https://github.com/zalo/CascadeStudio) — library and code-CAD design tool.
* [Polygonjs](https://polygonjs.com) — procedural design and animation tool for WebGL.
* [Replicad](https://replicad.xyz/) — library and code-CAD design tool.
* [Tau](https://tau.new) — AI-native CAD platform for the web.
## Reference repositories [#reference-repositories]
* [opencascade.js-examples](https://github.com/donalffons/opencascade.js-examples) —
general examples on how to use the library.
---
# Quickstart — npm
URL: /docs/package/getting-started/quick-start-npm
Target time: **4 minutes** from an empty directory to an orbit-controlled box
in your browser.
## Prerequisites [#prerequisites]
* Node 22+ and pnpm 9+ (npm and yarn also work).
* A bundler that supports wasm imports — Vite 6+, Next 15+, or Bun.
## 1. Install [#1-install]
```bash
pnpm add libcascade three
pnpm add -D @types/three typescript
```
## 2. Import the instance [#2-import-the-instance]
```typescript title="src/build-shape.ts"
import oc from 'libcascade';
```
The root entry probes the host, picks the variant it supports, and initialises
the WASM module with a top-level `await`. Module evaluation happens once per
realm, so there is nothing to memoise — every importer shares one C++ heap.
Need to control *when* that happens, or pass `locateFile` for a relocated
binary? Import `createInstance` from `libcascade/init` instead; see
[Entry points](/docs/package/reference/libcascade-api/init-function).
## 3. Build a shape [#3-build-a-shape]
```typescript title="src/build-shape.ts"
import oc from 'libcascade';
export const buildFilletedBox = async () => {
using box = new oc.BRepPrimAPI_MakeBox(60, 40, 20);
using fillet = new oc.BRepFilletAPI_MakeFillet(box.Shape());
using explorer = new oc.TopExp_Explorer(box.Shape(), oc.TopAbs_ShapeEnum.TopAbs_EDGE);
while (explorer.More()) {
fillet.Add(3, oc.TopoDS.Edge(explorer.Current()));
explorer.Next();
}
return fillet.Shape();
};
```
Every OCCT object is disposable — `using` invokes `Symbol.dispose()` at scope
exit so the C++ heap doesn't leak.
## 4. Export to GLB [#4-export-to-glb]
```typescript title="src/shape-to-glb.ts"
import oc, { type TopoDS_Shape } from 'libcascade';
export const shapeToGlb = async (shape: TopoDS_Shape): Promise => {
using docName = new oc.TCollection_ExtendedString('doc', true);
const doc = new oc.TDocStd_Document(docName);
oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()).get().AddShape(shape, false, false);
using _mesh = new oc.BRepMesh_IncrementalMesh(shape, 0.1, false, 0.5, false);
const path = `/out_${Date.now()}.glb`;
using asciiPath = new oc.TCollection_AsciiString(path);
using writer = new oc.RWGltf_CafWriter(asciiPath, true);
using metadata = new oc.TColStd_IndexedDataMapOfStringString();
using progress = new oc.Message_ProgressRange();
writer.Perform(doc, metadata, progress);
const bytes = oc.FS.readFile(path) as Uint8Array;
oc.FS.unlink(path);
return bytes;
};
```
## 5. Render in three.js [#5-render-in-threejs]
```typescript title="src/main.ts"
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { buildFilletedBox } from './build-shape';
import { shapeToGlb } from './shape-to-glb';
const canvas = document.querySelector('#viewer')!;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 5000);
camera.position.set(100, 100, 100);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const dir = new THREE.DirectionalLight(0xffffff, 1);
dir.position.set(1, 1, 1);
scene.add(dir);
new OrbitControls(camera, renderer.domElement);
const shape = await buildFilletedBox();
const glb = await shapeToGlb(shape);
new GLTFLoader().parse(glb.buffer, '', (gltf) => scene.add(gltf.scene));
renderer.setAnimationLoop(() => renderer.render(scene, camera));
```
Visit `localhost:5173` and you'll see a filleted gray box you can orbit.
> **Want threading?** For batch meshing and boolean workloads, import `createInstance` from `libcascade/multi/init`. Browser deployments require COOP/COEP headers — see the [Multi-threaded build guide](/docs/package/guides/multi-threading) and [Bundler & locateFile — Multi-threaded variant](/docs/package/guides/bundler-locatefile#multi-threaded-variant).
## Next steps [#next-steps]
* Need a different bundler? See [Bundler & locateFile](/docs/package/guides/bundler-locatefile).
* Want a smaller wasm? See [Trim symbols](/docs/toolchain/guides/trim-symbols).
* Looking for STEP instead of GLB? See [Export STEP](/docs/package/guides/export-step).
---
# What is libcascade
URL: /docs/package/getting-started/what-is-libcascade
libcascade brings the OpenCASCADE Technology kernel — a 30-year-old
production-grade BRep CAD library — into the WebAssembly runtime. Every public
OCCT symbol the package configures becomes a TypeScript class with overloaded
constructors, typed methods, and full `.d.ts` coverage.
The project is developed at
[`taucad/opencascade.js`](https://github.com/taucad/opencascade.js) and
published on npm as [`libcascade`](https://www.npmjs.com/package/libcascade). It
was originally created by Sebastian Alff (`donalffons`) and is not an official
Open CASCADE Technology distribution.
## Why a CAD kernel in WebAssembly [#why-a-cad-kernel-in-webassembly]
Browser-native CAD historically meant polygon-only mesh editing. A BRep kernel
gives you:
* **Exact geometry** — curves and surfaces are analytic, not polygonal. Booleans,
fillets, chamfers, threads all run on parametric primitives.
* **Standards-grade interchange** — read and write STEP (`AP214` / `AP242`), IGES,
STL, and GLB out of the box; no external tooling required.
* **Full feature parity with desktop CAD** — `BRepPrimAPI_*` primitives,
`BRepAlgoAPI_*` booleans, `BRepFilletAPI_*` fillets, `XCAF*` for assemblies
and materials.
## When to use libcascade [#when-to-use-libcascade]
| Use case | Good fit | Bad fit |
| ---------------------------------------- | --------------------- | -------------------------------------- |
| Parametric CAD app | Yes | — |
| STEP / IGES interchange | Yes | — |
| Procedural mesh generation | Yes | three.js or manifold-3d may be lighter |
| Real-time interactive booleans (>60 fps) | Maybe — measure first | Reach for manifold-3d |
| Cloud STEP-to-GLB conversion | Yes | — |
## When NOT to use it [#when-not-to-use-it]
* You only need polygon meshes — `three.js` + `manifold-3d` is smaller and faster.
* You need sub-megabyte wasm — full OCCT is \~40 MB; even trimmed it sits at \~12 MB
for a typical "box + boolean + export" surface.
* You need synchronous-only call sites — every `init` returns a Promise.
## Where to next [#where-to-next]
* [Quickstart — npm](/docs/package/getting-started/quick-start-npm) — render a box in 20 lines of TypeScript.
* [Trim symbols](/docs/toolchain/guides/trim-symbols) — cut the wasm from 40 MB down to what your app uses.
* [API Reference](/docs/package/api) — search every bound class and method.
---
# Bundler & locateFile
URL: /docs/package/guides/bundler-locatefile
Start with `import oc from 'libcascade'`. The package resolves its adjacent
WASM asset without configuration in Node and compatible bundlers. Reach for
`createInstance` from `libcascade/single/init` or `libcascade/multi/init` only
when a bundler or deployment copies a fixed variant's binary to another URL.
The package exposes the binaries through the `libcascade/single/wasm` and
`libcascade/multi/wasm` subpath exports (`libcascade/wasm` remains the
single-threaded alias). Every relocation recipe on this page resolves the WASM
through its matching variant specifier; resolving it any other way (deep `node_modules` paths, direct
`dist/*` imports) bypasses the package's `exports` map and breaks under
strict bundler resolution.
## Vite 6+ [#vite-6]
Vite's `?url` suffix turns any asset import into a content-hashed URL string.
Use it when you want Vite to emit a content-hashed asset URL:
```typescript
import { createInstance } from 'libcascade/single/init';
import wasmUrl from 'libcascade/single/wasm?url';
const oc = await createInstance({ locateFile: () => wasmUrl });
```
Add `libcascade` to `optimizeDeps.exclude` in `vite.config.ts` so
Vite skips its dep-optimizer for the binary module:
```typescript title="vite.config.ts"
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: { exclude: ['libcascade'] },
});
```
## Next.js 15 (App Router) [#nextjs-15-app-router]
Next's App Router lacks a first-class `?url` wasm import. The reliable pattern
is to copy the wasm into `public/` at install time via a postinstall script
that resolves the subpath:
```javascript title="scripts/copy-wasm.mjs"
import { copyFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const src = fileURLToPath(import.meta.resolve('libcascade/single/wasm'));
await mkdir('public', { recursive: true });
await copyFile(src, 'public/opencascade_single.wasm');
```
```json title="package.json"
{
"scripts": {
"postinstall": "node scripts/copy-wasm.mjs"
}
}
```
Then reference the public path:
```typescript title="lib/libcascade-init.ts"
'use client';
import { createInstance } from 'libcascade/single/init';
let ocPromise: ReturnType | undefined;
export const getOc = () =>
(ocPromise ??= createInstance({ locateFile: () => '/opencascade_single.wasm' }));
```
Mark `libcascade` as a server external package if you only call it
client-side:
```typescript title="next.config.ts"
import type { NextConfig } from 'next';
const config: NextConfig = {
serverExternalPackages: ['libcascade'],
};
export default config;
```
## Bun [#bun]
Bun resolves wasm imports natively. The `?url` pattern works identically to
Vite. No extra config required.
## Node (ESM) [#node-esm]
Use `import.meta.resolve` to find the wasm sibling to the loader:
```typescript
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createInstance } from 'libcascade/single/init';
const WASM_DIR = dirname(
fileURLToPath(import.meta.resolve('libcascade/single/wasm')),
);
const oc = await createInstance({ locateFile: (file: string) => join(WASM_DIR, file) });
```
## Deno [#deno]
Deno exposes the same `import.meta.resolve` API as Node 22+. The Node snippet
above works unchanged.
## Webpack 5 [#webpack-5]
Webpack 5 handles wasm via `asset/resource` (preferred) or the legacy
`file-loader`. Wire `locateFile` to the emitted URL:
```typescript title="src/libcascade-init.ts"
import { createInstance } from 'libcascade/single/init';
import wasmUrl from 'libcascade/single/wasm';
const oc = await createInstance({
locateFile: (file) => (file.endsWith('.wasm') ? wasmUrl : file),
});
```
```javascript title="webpack.config.js"
module.exports = {
module: {
rules: [
{
test: /\.wasm$/,
type: 'asset/resource',
},
],
},
resolve: {
fallback: {
fs: false,
perf_hooks: false,
os: false,
path: false,
worker_threads: false,
crypto: false,
stream: false,
},
},
};
```
Mark `libcascade` as an external or exclude it from aggressive
bundle inlining — the 12+ MB wasm must stay a separate fetch.
## Legacy bundlers [#legacy-bundlers]
Create-React-App, `react-app-rewired`, and Webpack 4 are **not supported** in
V3 docs. Use Vite, current Next.js, Bun, Node, Deno, or Webpack 5 instead.
## Common pitfalls [#common-pitfalls]
* **Prefer zero configuration**. Add `locateFile` only when the deployed WASM
URL differs from the package-adjacent default.
* **Don't bundle the wasm inline**. Bundling the 12+ MB binary as base64 explodes
your JS payload and prevents the browser's wasm streaming compiler.
* **Cache the `createInstance` Promise**. Each call instantiates another wasm
module with its own C++ heap — memoize behind a singleton. The eager root
entry (`import oc from 'libcascade'`) needs no memoisation: ES module
evaluation already happens once per realm.
## Multi-threaded variant [#multi-threaded-variant]
The pthread-enabled build is a variant of the same package: import its fixed
initializer from `libcascade/multi/init`. That entry also owns the worker
plumbing the raw `libcascade/multi` glue leaves to you. Its wasm is exported at
`libcascade/multi/wasm`.
**Browser prerequisite:** every page that loads the threaded wasm must send cross-origin isolation headers:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
Without these headers, browsers refuse to expose `SharedArrayBuffer` and the wasm fails to instantiate. See the [multi-threaded build guide](/docs/package/guides/multi-threading) for benchmarks and when not to ship threaded.
**Vite prerequisite:** Emscripten spawns its pthread workers as ES modules with
top-level await, which Vite's default `iife` worker format cannot emit:
```typescript title="vite.config.ts"
import { defineConfig } from 'vite';
export default defineConfig({
worker: { format: 'es' },
optimizeDeps: { exclude: ['libcascade'] },
});
```
Run the following **once** after `await createInstance(...)` in every recipe below — it matches the benchmark harness and is required for full speedup on mesh/boolean workloads:
```typescript
oc.BOPAlgo_Options.SetParallelMode(true);
oc.BRepMesh_IncrementalMesh.SetParallelDefault(true);
```
### Vite 6+ [#vite-6-1]
```typescript
import { createInstance } from 'libcascade/multi/init';
import wasmUrl from 'libcascade/multi/wasm?url';
const oc = await createInstance({ locateFile: () => wasmUrl });
oc.BOPAlgo_Options.SetParallelMode(true);
oc.BRepMesh_IncrementalMesh.SetParallelDefault(true);
```
### Next.js 15 (App Router) [#nextjs-15-app-router-1]
Copy the MT wasm into `public/` at install time:
```javascript title="scripts/copy-wasm-multi.mjs"
import { copyFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const src = fileURLToPath(import.meta.resolve('libcascade/multi/wasm'));
await mkdir('public', { recursive: true });
await copyFile(src, 'public/opencascade_multi.wasm');
```
```typescript title="lib/libcascade-init-multi.ts"
'use client';
import { createInstance } from 'libcascade/multi/init';
let ocPromise: ReturnType | undefined;
export const getOcMulti = () =>
(ocPromise ??= createInstance({
locateFile: () => '/opencascade_multi.wasm',
}).then((oc) => {
oc.BOPAlgo_Options.SetParallelMode(true);
oc.BRepMesh_IncrementalMesh.SetParallelDefault(true);
return oc;
}));
```
### Node (ESM) [#node-esm-1]
```typescript
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createInstance } from 'libcascade/multi/init';
const WASM_DIR = dirname(
fileURLToPath(import.meta.resolve('libcascade/multi/wasm')),
);
const oc = await createInstance({
locateFile: (file: string) => join(WASM_DIR, file),
});
oc.BOPAlgo_Options.SetParallelMode(true);
oc.BRepMesh_IncrementalMesh.SetParallelDefault(true);
```
Per-call overrides (`SetRunParallel(true)`, `BRepMesh_IncrementalMesh(..., isInParallel=true)`) remain available for granular opt-in. See [Multi-threaded build — Per-call activation](/docs/package/guides/multi-threading#per-call-activation--granular).
---
# Debugging wasm exceptions
URL: /docs/package/guides/debugging-wasm-exceptions
OCCT failures compiled with native WASM exception handling reach JavaScript as
`WebAssembly.Exception` values. Decode them through the initialized runtime:
```typescript
import oc from 'libcascade';
try {
using fillet = new oc.BRepFilletAPI_MakeFillet(badShape);
fillet.Shape();
} catch (error: unknown) {
if (error instanceof WebAssembly.Exception) {
const [type, message] = oc.getExceptionMessage(error);
console.error(`${type}: ${message}`);
} else {
throw error;
}
}
```
`error.message` is not the C++ failure text. The helper returns a
`[type, message]` tuple such as
`['Standard_Failure', 'BRepFilletAPI_MakeFillet: Empty argument']`.
## Common failures [#common-failures]
| Type or message | Likely cause |
| -------------------------- | ------------------------------------------ |
| `Standard_OutOfRange` | Index past an OCCT collection boundary |
| `Standard_NullObject` | Method called through a null `Handle` |
| `Standard_TypeMismatch` | Downcast to the wrong `TopoDS_*` subtype |
| `BRepAlgoAPI_*` | Invalid or self-intersecting boolean input |
| `BRepFilletAPI_MakeFillet` | Radius too large or a degenerate edge |
## Debug builds [#debug-builds]
For source-level debugging, build with `-g -fwasm-exceptions -O1` and use
[Chrome's WASM DWARF debugger](https://developer.chrome.com/blog/wasm-debugging-2020).
---
# Export glTF / GLB
URL: /docs/package/guides/export-gltf
GLB is the binary single-file flavour of glTF and the format three.js, Babylon,
Filament, and most realtime renderers consume natively. OCCT writes it via the
`RWGltf_CafWriter` against an XCAF document.
## Minimal single-shape GLB [#minimal-single-shape-glb]
```typescript
import type { TopoDS_Shape } from 'libcascade';
import { getOc } from './libcascade-init';
export const shapeToGlb = async (shape: TopoDS_Shape): Promise => {
const oc = await getOc();
using docName = new oc.TCollection_ExtendedString('doc', true);
const doc = new oc.TDocStd_Document(docName);
const shapeTool = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()).get();
shapeTool.AddShape(shape, false, false);
using _mesh = new oc.BRepMesh_IncrementalMesh(shape, 0.1, false, 0.5, false);
const path = `/out_${Date.now()}.glb`;
using asciiPath = new oc.TCollection_AsciiString(path);
using writer = new oc.RWGltf_CafWriter(asciiPath, true); // true = binary GLB
using metadata = new oc.TColStd_IndexedDataMapOfStringString();
using progress = new oc.Message_ProgressRange();
writer.Perform(doc, metadata, progress);
const bytes = oc.FS.readFile(path) as Uint8Array;
oc.FS.unlink(path);
return bytes;
};
```
## Per-shape PBR material [#per-shape-pbr-material]
Attach a `XCAFDoc_VisMaterial` to a shape label and the GLB writer emits a glTF
`materials[]` entry with PBR base color, metalness, and roughness.
```typescript
const shapeLabel = shapeTool.AddShape(shape, false, false);
using matName = new oc.TCollection_AsciiString('Brass');
const vmTool = oc.XCAFDoc_DocumentTool.VisMaterialTool(doc.Main()).get();
using visMat = new oc.XCAFDoc_VisMaterial();
using pbr = new oc.XCAFDoc_VisMaterialPBR();
pbr.BaseColor = new oc.Quantity_ColorRGBA(0.85, 0.65, 0.13, 1);
pbr.Metallic = 0.9;
pbr.Roughness = 0.25;
visMat.SetPbrMaterial(pbr);
const matLabel = vmTool.AddMaterial(visMat, matName);
vmTool.SetShapeMaterial(shapeLabel, matLabel);
```
## Tessellation tolerances [#tessellation-tolerances]
`BRepMesh_IncrementalMesh(shape, linearDeflection, isRelative, angularDeflection, inParallel)`
| Param | Typical value | Effect |
| ----------------- | ------------- | ---------------------------------------- |
| linearDeflection | 0.05 – 0.5 mm | Max chord–surface distance |
| isRelative | `false` | When `true`, deflection scales with bbox |
| angularDeflection | 0.5 rad | Max angle between facets along a curve |
| inParallel | `false` | Single-threaded in browser builds |
Tighten linear deflection for small features (M3 threads need \~0.01 mm).
OCCT caches triangulation on the shape; pass `decreate=true` as the third
positional argument to force regeneration when you change tolerances.
## Coordinate system [#coordinate-system]
GLB is **Y-up** by convention; OCCT is **Z-up**. `RWGltf_CafWriter` emits Z-up
data and lets the consumer interpret it. Most three.js workflows pre-rotate
the scene with `-Math.PI/2` around the X axis to align with glTF Y-up
expectations.
## Validating the output [#validating-the-output]
* [glTF Viewer (Don McCurdy)](https://gltf-viewer.donmccurdy.com/) — drag-drop validation.
* [gltf-validator](https://github.com/KhronosGroup/glTF-Validator) — Node CLI for CI.
* Open in Blender — full PBR roundtrip with materials.
---
# Export STEP
URL: /docs/package/guides/export-step
STEP is the de-facto interchange format for parametric CAD. OCCT exposes two
writers depending on whether you need an assembly tree.
## Single shape — STEPControl\_Writer [#single-shape--stepcontrol_writer]
```typescript
import type { TopoDS_Shape } from 'libcascade';
import { getOc } from './libcascade-init';
export const shapeToStep = async (shape: TopoDS_Shape): Promise => {
const oc = await getOc();
using writer = new oc.STEPControl_Writer();
using progress = new oc.Message_ProgressRange();
oc.Interface_Static.SetIVal('write.step.schema', 5); // AP214
const transferStatus = writer.Transfer(
shape,
oc.STEPControl_StepModelType.STEPControl_AsIs,
true,
progress,
);
if (transferStatus !== oc.IFSelect_ReturnStatus.IFSelect_RetDone) {
throw new Error('STEP Transfer failed');
}
const path = `/out_${Date.now()}.step`;
const writeStatus = writer.Write(path);
if (writeStatus !== oc.IFSelect_ReturnStatus.IFSelect_RetDone) {
throw new Error('STEP Write failed');
}
const bytes = oc.FS.readFile(path) as Uint8Array;
oc.FS.unlink(path);
return bytes;
};
```
Two return-status checks are mandatory — OCCT silently produces a zero-byte
file otherwise.
## Multi-shape assembly — STEPCAFControl\_Writer [#multi-shape-assembly--stepcafcontrol_writer]
For assemblies with named parts, per-shape colors, or PBR materials, write the
shape into an XCAF document first, then use `STEPCAFControl_Writer.Perform`.
**Never** use the empty-filename `Transfer` overload — it silently activates
multi-file mode and emits an empty STEP body.
```typescript
import { getOc } from './libcascade-init';
export const assemblyToStep = async (shapes: Array<{ name: string; shape: TopoDS_Shape }>) => {
const oc = await getOc();
using docName = new oc.TCollection_ExtendedString('doc', true);
const doc = new oc.TDocStd_Document(docName);
const shapeTool = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()).get();
for (const { name, shape } of shapes) {
const label = shapeTool.AddShape(shape, false, false);
using nameStr = new oc.TCollection_ExtendedString(name, true);
oc.TDataStd_Name.Set(label, nameStr);
}
using writer = new oc.STEPCAFControl_Writer();
using progress = new oc.Message_ProgressRange();
oc.Interface_Static.SetIVal('write.step.schema', 5);
const path = `/asm_${Date.now()}.step`;
const ok = writer.Perform(doc, path, progress);
if (!ok) throw new Error('STEP assembly write failed');
const bytes = oc.FS.readFile(path) as Uint8Array;
oc.FS.unlink(path);
return bytes;
};
```
## Schema selection [#schema-selection]
| Code | Schema | Use when |
| ---- | --------------------------- | ----------------------------------- |
| 1 | AP203 (config control) | Legacy mech CAD; avoid for new work |
| 4 | AP214 (auto industry) | Default — broad tool support |
| 5 | AP214 (alt revision) | Recommended for new projects |
| 6 | AP242 (managed model-based) | If you need PMI / PBR materials |
AP242 emits the same geometry as AP214 plus product manufacturing information
attached to the model. Most consumers (Fusion 360, SolidWorks, FreeCAD)
auto-detect schema and treat 5 and 6 interchangeably for pure geometry.
## Bytes out, file in [#bytes-out-file-in]
* **Browser**: `URL.createObjectURL(new Blob([bytes], { type: 'application/step' }))`
and trigger a download via a hidden `` tag.
* **Node**: `fs.writeFileSync('out.step', bytes)`.
* **Cloud**: pipe directly to S3 / GCS as `application/octet-stream`.
## Common pitfalls [#common-pitfalls]
* Check `IFSelect_RetDone` after **both** `Transfer` and `Write` — partial
failures are silent otherwise.
* Always `unlink` the MEMFS path after `FS.readFile` to free the wasm heap.
* `FS.readFile` returns owned bytes that remain valid after `unlink`.
---
# Multi-threaded build
URL: /docs/package/guides/multi-threading
The npm package ships **two** pre-built variants of one binding set:
| Variant | Binary | When to use |
| -------- | ------------------------- | -------------------------------------------------------------------- |
| `single` | `opencascade_single.wasm` | Embeddable widgets, one-op-per-click UX, no COOP/COEP |
| `multi` | `opencascade_multi.wasm` | Batch mesh/boolean, STEP→glTF pipelines, COOP/COEP-isolated surfaces |
Ask for the threaded one by name:
```typescript
import { createInstance } from 'libcascade/multi/init';
const oc = await createInstance();
```
`createInstance` owns the worker plumbing — the glue self-reference Emscripten
spawns pthread workers from, Node `file:` URL → path conversion, and OCCT
thread-pool sizing. The import path fixes the variant and excludes the
single-threaded glue from the bundle. Pass `threadCount` to cap OCCT's default
pool.
Both variants share one `OpenCascadeInstance` type, so a shape from either is
assignable wherever the other is expected. See
[Bundler & locateFile](/docs/package/guides/bundler-locatefile#multi-threaded-variant) for Vite,
Next.js, and Node recipes, and [Entry points](/docs/package/reference/libcascade-api/init-function)
for the full options contract.
OCCT can drive multiple worker threads for mesh and boolean kernels once the pthread-enabled wasm is loaded. That requires hard browser prerequisites (`SharedArrayBuffer` + cross-origin isolation) in the browser; Node 22+ exposes `SharedArrayBuffer` without extra headers.
## When to consider it [#when-to-consider-it]
| Measured workload | Speedup with 12 workers | Worth the COOP/COEP cost? |
| ------------------------------ | ----------------------: | -------------------------------------- |
| STEP import + incremental mesh | 1.33× | Yes, for visualisation pipelines |
| Boolean cut grid | 1.81× | Yes, for batch CAD operations |
| Incremental mesh | 1.06× | Only after profiling the real assembly |
| Tiny two-shape boolean fuse | 0.44× | No — pool overhead dominates |
| Complete 11-sample suite | 1.24× | Yes, for sustained mixed workloads |
If your app does one boolean and one mesh per user interaction, single-threaded
is fine. If you batch hundreds of operations, threading pays off.
See [BENCHMARKS.md](https://github.com/taucad/opencascade.js/blob/main/BENCHMARKS.md) for ST vs MT numbers on a representative workload mix.
## Browser prerequisites [#browser-prerequisites]
Pthread builds use `SharedArrayBuffer`, which browsers gate behind
**cross-origin isolation**. Your server must send:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
…on every page that imports the threaded wasm. Without these headers, browsers
refuse to expose `SharedArrayBuffer` and the wasm fails to instantiate.
Node 22+ exposes `SharedArrayBuffer` unconditionally — no headers needed.
Under Vite, also set `worker: { format: 'es' }`: Emscripten's pthread workers
are ES modules with top-level await, which the default `iife` worker format
cannot emit.
## Triggering OCCT parallelism [#triggering-occt-parallelism]
OCCT respects its own `OSD_Parallel` switches once threads exist. Two
levels of activation are available — **per-call** (granular, opt-in at
each site) and **global** (sets a process-wide default).
### Global activation — call once at startup [#global-activation--call-once-at-startup]
The cleanest option: flip the OCCT-wide defaults once, then any subsequent
call to a parallel-aware API inherits the toggle without an extra argument.
```typescript
// Run once after `await createInstance()` from `libcascade/multi/init`.
oc.BOPAlgo_Options.SetParallelMode(true); // booleans
oc.BRepMesh_IncrementalMesh.SetParallelDefault(true); // meshing
// Optional: inspect the lazily-created pool. -1 means "use all logical
// processors" and is already the default.
const pool = oc.OSD_ThreadPool.DefaultPool(-1);
console.log(pool.NbThreads());
```
After the two global toggles every `new BRepMesh_IncrementalMesh(shape, lin)` and
every `BRepAlgoAPI_*` automatically fans out across the pool. **No
per-call argument required.**
OCCT's default pool already uses `NbLogicalProcessors`, and its default
per-launch fan-out is the full pool. Only call `Init` or
`SetNbDefaultThreadsToLaunch` when deliberately capping concurrency, and do
so before the pool's first job.
### Per-call activation — granular [#per-call-activation--granular]
If you want to keep the global default off and opt in selectively:
```typescript
using mesh = new oc.BRepMesh_IncrementalMesh(
shape,
0.1, // linear deflection
false, // not relative
0.5, // angular deflection
true, // inParallel — flips multi-threading on
);
```
```typescript
using bop = new oc.BRepAlgoAPI_BuilderAlgo();
bop.SetRunParallel(true);
// ... AddArgument / AddTool / Build()
```
`SetRunParallel(false)` is the default for per-instance APIs. libcascade does
not auto-enable it unless you flip the global default above.
### Other parallel-aware APIs [#other-parallel-aware-apis]
Beyond mesh and boolean, the following APIs accept a parallel flag and
benefit when the pool is sized correctly:
| API | Activation |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `BRepExtrema_DistShapeShape` | `.SetMultiThread(true)` |
| `BRepCheck_Analyzer` | ctor `(shape, /*isParallel*/ true)` |
| `RWGltf_CafReader` | `.SetParallel(true)` (glTF **read**) |
| `RWGltf_CafWriter` | `.SetParallel(true)` (glTF **write**) |
| `BVH_Builder` | `.SetParallel(true)` |
| `BRepLib_ValidateEdge`, `BRepLib_CheckCurveOnSurface`, `GeomLib_CheckCurveOnSurface` | `.SetParallel(true)` — used transitively by `BRepCheck_Analyzer` |
| `BRepFill_AdvancedEvolved` | already parallel by default — call `SetParallelMode(false)` to disable |
**STEP/IGES readers (`STEPControl_Reader`, `IGESControl_Reader`) and
`BRepBuilderAPI_Sewing` are sequential** in current OCCT — there is no
parallel flag to flip.
## Costs [#costs]
* **COOP/COEP headers** lock you out of third-party iframes that don't opt in
(e.g. embedded Stripe checkout, some auth providers). Subdomain-isolate
your CAD surface if that matters.
* **Thread spawn time** added 188 ms in the 12-worker benchmark, scaling with pool size.
Memoise the `createInstance()` Promise.
* **Memory** scales with `PTHREAD_POOL_SIZE × STACK_SIZE`. With the
shipped `navigator.hardwareConcurrency` sizing, a 12-core box
reserves \~96 MB of stack on top of `INITIAL_MEMORY`.
* **Debuggability** drops — `console.log` from worker threads doesn't always
reach the main thread.
## Performance notes [#performance-notes]
Pthread + `-sALLOW_MEMORY_GROWTH=1` carries a documented Emscripten
performance penalty. A typed-array view can become stale after
`memory.grow`, so advanced pointer interop must create fresh views from
`oc.wasmMemory.buffer` after calls that may allocate. The shipped binaries
already mitigate the worst of this:
* **mimalloc** is wired into the WASM allocator so per-thread allocation
bypasses the global malloc contention point. libcascade supplies OCCT's
optional `mallinfo` compatibility shim, so custom builds retain strict
undefined-symbol checking with either mimalloc or Emscripten's allocators.
* **Create one typed-array view per allocation-free batch**. Do not retain it
across a call that may grow memory:
```typescript
const heap = new Uint8Array(oc.wasmMemory.buffer);
for (let i = 0; i < n; i++) {
heap[buf + i] = bytes[i];
}
```
### `toResizableBuffer()` support matrix (August 2026) [#toresizablebuffer-support-matrix-august-2026]
The newer [`WebAssembly.Memory.prototype.toResizableBuffer()`](https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface/Memory)
API lets a pthread-enabled binary keep typed-array views valid across growth
entirely by returning a `SharedArrayBuffer` that automatically tracks
`memory.grow`. The shipped `libcascade/multi` binary keeps
`-sGROWABLE_ARRAYBUFFERS=0` (the default) because runtime support is uneven:
| Runtime | Supports `toResizableBuffer()` |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Chrome 144+ / Edge 144+ | Yes |
| Firefox 154+ | Yes |
| Firefox ≤153 | **No** — [WebAssembly worker transfer can hang](https://bugzilla.mozilla.org/show_bug.cgi?id=2021136) despite exposing the API |
| Safari 26.2+ (desktop + iOS) | Yes |
| Node.js 22 / 24 | **No** — `TypeError: wasmMemory.toResizableBuffer is not a function` |
| Bun (all current) | **No** |
| Deno (all current) | **No** |
| Samsung Internet | **No** (as of May 2026) |
The shipped `multi` variant therefore keeps `GROWABLE_ARRAYBUFFERS` off so the
binary stays compatible with Node-based test runners, Bun, Deno, and the legacy
mobile browser. A custom build can add a
[browser-only variant](/docs/toolchain/guides/multi-threading#browser-only-mt-build)
for deployments that have audited their runtime matrix and want the
resizable-buffer fast path.
For most consumers the cost is invisible — OCCT's per-call cost on
mesh/boolean dominates the typed-view refresh cost by 2-3 orders of magnitude.
Reach for the browser-only variant only when you've profiled and the
JS↔WASM boundary is on the hot path.
## When NOT to ship threaded [#when-not-to-ship-threaded]
* You can't (or won't) set COOP/COEP headers — most CDNs/marketing sites can't.
* Your workload is one shape per second — Amdahl's law eats the speedup.
* Your target users include Safari ≤16 — older Safari refused
`SharedArrayBuffer` in cross-origin-isolated contexts.
For most consumers the single-threaded variant wins on simplicity. Import its
fixed initializer:
```typescript
import { createInstance } from 'libcascade/single/init';
const oc = await createInstance();
```
## Custom builds [#custom-builds]
Need a smaller threaded binary (trimmed symbol list) or different Emscripten settings? See [Toolchain — Custom multi-threaded build](/docs/toolchain/guides/multi-threading) for the config recipe and `PTHREAD_POOL_SIZE` rationale.
---
# Render with three.js
URL: /docs/package/guides/render-with-three-js
libcascade produces GLB bytes through OCCT's XCAF + `RWGltf_CafWriter`
pipeline. three.js consumes GLB via `GLTFLoader`. The whole flow is in-browser —
no server round-trip.
## End-to-end snippet [#end-to-end-snippet]
```typescript title="src/render.ts"
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export const renderGlb = (canvas: HTMLCanvasElement, glb: Uint8Array): (() => void) => {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setPixelRatio(globalThis.devicePixelRatio);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111);
const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 5000);
camera.position.set(100, 100, 100);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const dir = new THREE.DirectionalLight(0xffffff, 1);
dir.position.set(1, 1, 1);
scene.add(dir);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
new GLTFLoader().parse(glb.buffer, '', (gltf) => scene.add(gltf.scene));
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
return () => renderer.dispose();
};
```
## Why XCAF (and not raw triangle arrays) [#why-xcaf-and-not-raw-triangle-arrays]
OCCT can hand you raw `Poly_Triangulation` arrays per face, but the XCAF + GLB
path:
* preserves per-face colors and PBR materials,
* writes valid glTF metadata (asset version, extensions used),
* handles multi-shape assemblies as separate primitives,
* emits indexed buffers so three.js doesn't need to dedupe vertices.
See [Export glTF / GLB](/docs/package/guides/export-gltf) for the full pipeline including PBR materials.
## Common rendering issues [#common-rendering-issues]
| Symptom | Likely cause | Fix |
| ---------------------------------- | ------------------------------ | ----------------------------------------------------------------------- |
| Faceted curves (no smooth shading) | Mesh deflection too coarse | Tighten `BRepMesh_IncrementalMesh` linear deflection (e.g. 0.05) |
| Black model | No lights | Add ambient + directional light; check `dir.position` is non-zero |
| Model offset from origin | Shape has a transform baked in | Verify `TopLoc_Location` or apply `shape.Moved(loc, false)` |
| GLB loads but invisible | Camera inside model | `camera.position.set(100, 100, 100)` and `controls.target.set(0, 0, 0)` |
## Helper libraries [#helper-libraries]
For batch shape conversion (multi-shape assemblies, edges-as-fat-lines,
hover-highlight), [`replicad-threejs-helper`](https://github.com/sgenoud/replicad)
wraps the common patterns and works with `libcascade` directly.
---
# Visualize shape helper
URL: /docs/package/guides/visualize-shape-helper
Every example in this docs site can inline the XCAF → mesh → GLB → blob URL
pipeline. This page documents the canonical helper so you can import it once
instead of copying the boilerplate into every script.
## `visualizeDoc` [#visualizedoc]
Build a GLB blob URL from an XCAF document that already contains tessellated shapes:
```typescript title="lib/visualize.ts"
import type { OpenCascadeInstance } from 'libcascade';
export const visualizeDoc = async (
oc: OpenCascadeInstance,
doc: InstanceType,
): Promise => {
using writer = new oc.RWGltf_CafWriter(new oc.TCollection_AsciiString_2('out.glb'), true);
writer.Perform(doc, new oc.TColStd_IndexedDataMapOfStringString(), new oc.Message_ProgressRange());
const fileName = 'out.glb';
const buffer = oc.FS.readFile(fileName, { encoding: 'binary' });
oc.FS.unlink(fileName);
const blob = new Blob([buffer], { type: 'model/gltf-binary' });
return URL.createObjectURL(blob);
};
```
## `visualizeShapes` [#visualizeshapes]
Convenience wrapper — accepts one or more `TopoDS_Shape` values, builds the XCAF
document, meshes, and returns a GLB blob URL:
```typescript title="lib/visualize.ts"
import type { OpenCascadeInstance, TopoDS_Shape } from 'libcascade';
export const visualizeShapes = async (
oc: OpenCascadeInstance,
...shapes: TopoDS_Shape[]
): Promise => {
using doc = new oc.TDocStd_Document(new oc.TCollection_ExtendedString_1());
const shapeTool = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main()).get();
for (const shape of shapes) {
using mesh = new oc.BRepMesh_IncrementalMesh(shape, 0.1, false, 0.1, false);
const newShape = shapeTool.NewShape();
shapeTool.SetShape(newShape, shape);
}
return visualizeDoc(oc, doc);
};
```
## When to inline vs import [#when-to-inline-vs-import]
* **First read**: keep the pipeline inline in tutorials so every step is visible.
* **Production code**: import from a single `lib/visualize.ts` module.
* **Multi-material assemblies**: build the XCAF document yourself (see
[Boolean logo](/docs/package/examples/boolean-logo) for per-subset PBR assignment).
See also: [Render with three.js](/docs/package/guides/render-with-three-js).
---
# 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:
Replace `{{version}}` with the package version in the downloaded filename.
```bash
npm exec nx -- run ocjs:docs-sync -- --from ./libcascade-{{version}}.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-{{version}}.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 four entry shapes. Choose by lifecycle first, then by
whether the host or the import path selects the variant.
| Entry | Import | Gives you |
| ------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------- |
| Root | `libcascade` | An already-initialised instance, plus every bound symbol as a named value export. |
| Shared initializer | `libcascade/init` | Lazy `createInstance(options)` with runtime variant selection. |
| Fixed initializer | `libcascade/single/init`, `libcascade/multi/init` | Lazy `createInstance(options)` for exactly the imported variant. |
| Raw glue | `libcascade/single`, `libcascade/multi` | The Emscripten module factory 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` — shared lazy selection [#libcascadeinit--shared-lazy-selection]
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';
```
## Fixed-variant initializers [#fixed-variant-initializers]
When the variant is already a product decision, put it in the import path:
```typescript
import { createInstance as createSingle } from 'libcascade/single/init';
import { createInstance as createMulti } from 'libcascade/multi/init';
const single = await createSingle();
const multi = await createMulti({ threadCount: 4 });
```
These modules export no selector and ignore the shared override symbol. Their
types reject `variant`; the single-threaded entry also rejects `threadCount`.
Passing `variant` from untyped JavaScript fails before any glue is loaded. Each
entry names one glue asset, so bundlers can exclude the other variant.
## 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()` from `libcascade/multi/init` 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.