Scripting SDK¶
The @ifc-lite/sdk package is the scriptable BIM API behind ifc-lite eval, ifc-lite run, the viewer's script console, and the extension system. Everything hangs off a single bim object: query entities, read properties and quantities, walk the spatial tree, edit properties, run clashes, and export, all through one typed surface.
The bim API surface¶
The bim object (a BimContext) groups its capabilities into namespaces, plus a set of top-level helpers for the common query paths. The main namespaces:
| Namespace | Purpose |
|---|---|
bim.model |
Loaded-model management (list, active model) |
bim.query() |
Start a fluent entity query chain |
bim.viewer |
Colorize, isolate, hide/show, section, fly-to (drives a running viewer) |
bim.mutate |
Property and attribute edits |
bim.store |
Document-level edits (add/remove entities, positional attributes) |
bim.lens |
Lens visualization presets |
bim.create |
Create IFC elements from scratch |
bim.export |
Export to CSV, glTF, STEP, HBJSON, and more |
bim.clash |
Run clash rules and the discipline matrix |
bim.ids |
IDS validation |
bim.bcf |
BCF topics, comments, viewpoints |
bim.files, bim.schedule, bim.spatial, bim.spaces, bim.drawing, bim.list, bim.bsdd, bim.events, bim.sandbox |
Supporting namespaces (file access, scheduling, spatial ops, space program, 2D drawings, entity tables, bSDD lookups, events, sandboxed sub-scripts) |
On top of the namespaces, BimContext exposes direct helpers for the hot paths, so you rarely reach into internals: bim.query(), bim.entity(ref), bim.properties(ref), bim.quantities(ref), bim.materials(ref), bim.classifications(ref), bim.storeys(), bim.contains(ref), bim.containedIn(ref), bim.storey(ref), bim.path(ref), and more.
// Fluent query
const externalWalls = bim.query()
.byType('IfcWall')
.where('Pset_WallCommon', 'IsExternal', '=', true)
.toArray();
// Direct helpers
const storeys = bim.storeys();
for (const s of storeys) {
console.log(`${s.name}: ${bim.contains(s.ref).length} elements`);
}
Running scripts from the CLI¶
eval — one-liners¶
ifc-lite eval evaluates a single JavaScript expression with bim in scope:
# Count walls
ifc-lite eval model.ifc "bim.query().byType('IfcWall').count()"
# List storey names
ifc-lite eval model.ifc "bim.storeys().map(s => s.name)"
# Per-entity evaluation: --type binds `ref` and `entity` to each match
ifc-lite eval model.ifc "bim.quantity(ref, 'GrossSideArea')" --type IfcWall --limit 3
With --type, the expression runs once per matching entity with ref (the entity reference) and entity in scope; without it, the expression runs once with bim. Add --json for machine-readable output.
run — script files¶
ifc-lite run executes a .js file with bim and console available:
ifc-lite run analysis.js model.ifc
# Stream bim.viewer.* calls to a running `ifc-lite view` instance
ifc-lite run paint.js model.ifc --viewer 3456
const walls = bim.query().byType('IfcWall').toArray();
console.log(`Found ${walls.length} walls`);
for (const wall of walls) {
const psets = bim.properties(wall.ref);
const common = psets.find(p => p.name === 'Pset_WallCommon');
const isExternal = common?.properties.find(p => p.name === 'IsExternal');
console.log(` ${wall.name}: external=${isExternal?.value ?? 'unknown'}`);
}
eval and run are not sandboxed
The CLI eval and run commands execute your code directly in the Node
host (via new Function), with full access to the machine. They are meant
for your own scripts and trusted LLM-generated snippets on your own
files. To run untrusted code, use the sandbox below. Pointing --viewer
at a running viewer streams bim.viewer.* calls to it in real time.
Discovering the API for LLM tooling¶
ifc-lite schema dumps the scriptable API as JSON so an LLM (or you) can discover methods before writing code:
ifc-lite schema # full schema with params and return types
ifc-lite schema --compact # names and descriptions only
The schema covers the scriptable namespaces, model, query, viewer, mutate, store, lens, create, files, schedule, clash, and export, and includes each method's parameter names, return type, and LLM semantic hints (useWhen, task tags). Running ifc-lite schema first is the recommended way to author correct eval/run code. See Using with LLM Terminals for the wider agent workflow.
The sandbox¶
@ifc-lite/sandbox runs scripts in an isolated QuickJS-in-WASM interpreter, the mechanism the viewer and the extension system use to run untrusted code safely. Each sandbox gets a fresh QuickJS context; the bim API is rebuilt inside it, gated by permissions; and TypeScript is transpiled to JavaScript before execution.
import { Sandbox } from '@ifc-lite/sandbox';
const sandbox = new Sandbox(bim, {
permissions: { query: true, mutate: false }, // read-only by default
limits: { memoryBytes: 64 * 1024 * 1024, timeoutMs: 30_000 },
});
await sandbox.init();
const result = await sandbox.eval(`bim.query.create().byType('IfcWall').count()`);
console.log(result.value, result.logs, result.durationMs);
Or reach it through the SDK as bim.sandbox.eval(script, config), which returns the same ScriptResult (value, captured logs, durationMs).
When eval() throws. It rejects with a ScriptError (carrying message, logs and durationMs) for a throw in the script's main body, for a CPU timeout, and — since the fix in #2078 — for a script whose entry point is async and whose returned promise rejects after its first await. That last case previously resolved: the main body had succeeded, so the rejection came back as ordinary data in result.value ({ type: 'rejected', error: … }) and a failed script looked like a clean pass. If you upgrade across that change and were relying on eval() resolving, you will start seeing the error propagate.
One case is still not reported: a rejection in a promise the script never hands back — run(); 'started' rather than return run(). There is no handle to inspect, and quickjs-emscripten exposes no host rejection tracker, so nothing observes it.
When dispose() throws. The same unawaited shape has a second failure mode: if that detached job exhausts the memory limit, it leaves objects orphaned on QuickJS's GC list and upstream JS_FreeRuntime asserts, so teardown comes back as an emscripten abort — a SandboxAbortError from dispose() (#1922). The eval() that caused it resolved normally, so teardown is the only place that run's failure is observable: settle a run's outcome after disposing it, not before, or you will report a clean result for a script that died.
The abort poisons the whole WASM module, not just the one sandbox. The package retires it and builds the next sandbox on a fresh instance — a few milliseconds, no page reload, and nothing to do for the common case where a sandbox is created per run.
A host that keeps one sandbox alive across many runs — an extension runtime, a REPL — does have something to do. Its sandbox may be sitting on a module that some other sandbox's abort retired. It still executes scripts, but emscripten's abort latch is per module and has already fired on it, so its own teardown can no longer report a failure and it will silently leak. Sandbox.moduleRetired is that signal; the contract is to discard, not to reuse:
import { createSandbox, type Sandbox, type SandboxConfig } from '@ifc-lite/sandbox';
async function ensureLiveSandbox(sandbox: Sandbox, config: SandboxConfig): Promise<Sandbox> {
if (!sandbox.moduleRetired) return sandbox;
sandbox.dispose();
return createSandbox(bim, config); // fresh module, no page reload
}
Check it while the sandbox is live: dispose() releases the module reference, after which moduleRetired reads false. The process-wide isSandboxRuntimeAborted() is a diagnostic — latched for the process lifetime — not a health check; a true does not mean scripting is dead.
Permissions gate which namespaces the script can touch (model, query, viewer, mutate, store, lens, export, files). The defaults are read-only: mutate and store are off, everything else on. Limits cap resources, defaulting to 64 MB heap, a 30-second timeout, and a 512 KB stack. A namespace whose permission is disabled is simply not built on the sandboxed bim handle, so a script cannot call what it was not granted.
Sandboxed vs. direct¶
| Path | Runtime | Isolation |
|---|---|---|
ifc-lite eval / ifc-lite run |
Node host, new Function |
None, full host access |
bim.sandbox.eval / @ifc-lite/sandbox |
QuickJS-in-WASM | Permissions + memory/CPU/stack limits |
| Extensions | QuickJS-in-WASM | Capability-gated bundle sandbox |
Use the direct path for your own scripts; use the sandbox when the code comes from somewhere you do not fully trust.
Embedding programmatically¶
Create a BimContext yourself with createBimContext. It needs either a local backend (viewer-embedded) or a transport (connected to a running viewer):
import { createBimContext } from '@ifc-lite/sdk';
// Local mode: drive an in-process backend
const bim = createBimContext({ backend: myLocalBackend });
const wallCount = bim.query().byType('IfcWall').count();
// Remote mode: talk to a running viewer over a transport
const remote = createBimContext({ transport: myBroadcastTransport });
remote.viewer.colorize(refs, '#ff0000');
In remote mode bim.viewer.* calls are forwarded to the connected viewer, which is exactly how ifc-lite run script.js model.ifc --viewer <port> works under the hood. For the full type surface, see the @ifc-lite/sdk README and the TypeScript API reference.