@okonomi-gmbh/opm-loader
v0.1.1
Published
Build parametric windows, doors and switch ranges from a .opm file: a sandboxed Lua VM and a geometry kernel, in the page or in Node.
Maintainers
Readme
@okonomi-gmbh/opm-loader
A .opm is a parametric model: a manifest saying what it builds and which
inputs it takes, a Lua program that builds it, and optionally a drawing and a
set of finishes. This package opens one, runs its program in a sandboxed Lua VM,
and hands back named parts as flat arrays.
npm install @okonomi-gmbh/opm-loaderimport { fetchOpm, toBuildResult } from '@okonomi-gmbh/opm-loader'
import wasmUri from '@okonomi-gmbh/opm-loader/glue.wasm?url' // Vite; see below
const opm = await fetchOpm('/programs/window.opm', { wasmUri })
const built = toBuildResult(opm.build('window_casement', { width: 900, height: 1400 }))
for (const part of built.parts) {
part.name // the movable node it belongs to — 'frame', 'sash', …
part.material // the slot to dress it in
part.positions // number[], metres
part.normals // number[]
part.uvs // number[], metres — set repeat = 1 / worldSize
part.indices // number[], triangles
}Also on the result: bounds, ports (where a handle goes), pivots,
animations (how it opens) and warnings.
That is the whole of the core. No renderer, no glTF, no scene graph — the only dependency on that path is the Lua VM. What you do with the arrays is your business, and there is more than one right answer.
Two things you cannot skip
1. Say where the WebAssembly is
wasmUri is the one option with no sensible default. Left unset, the Lua VM
decides it is in a browser and fetches its glue.wasm from unpkg.com — a
third-party dependency your page acquires on its first build, and on a page with
a content policy, a build that simply does not happen.
The file ships with this package. Three spellings, because no one of them works everywhere:
// Vite, Rollup
import wasmUri from '@okonomi-gmbh/opm-loader/glue.wasm?url'
// Webpack 5, Rspack — asset modules
const wasmUri = new URL('@okonomi-gmbh/opm-loader/glue.wasm', import.meta.url).href
// Anything else: copy the file into your public directory at build time and
// point at it. It is at `dist/glue.wasm` inside the package.It is shipped rather than pointed at because
node_modules/wasmoon/dist/glue.wasm is not a stable path: whether it is
hoisted depends on your package manager, and under pnpm it is not there at all.
The copy is pinned to the exact VM version this package depends on — the glue
and the wasm are one build and cannot be mixed.
2. Let your page instantiate WebAssembly
A Content-Security-Policy with an explicit script-src blocks
WebAssembly.instantiate unless it says so. This is the most likely reason a
build that works in development does nothing in production, and the error names
neither this package nor the VM.
# Rails, config/initializers/content_security_policy.rb
policy.script_src :self, :wasm_unsafe_evalContent-Security-Policy: script-src 'self' 'wasm-unsafe-eval'wasm-unsafe-eval permits WebAssembly compilation and nothing else — it does
not re-enable eval for JavaScript.
Where a .opm comes from
fetchOpm takes either form, and tells them apart by looking at the bytes
rather than by trusting a content type — .opm is registered with nobody, so
one host serves it as application/octet-stream, another as text/plain.
await fetchOpm('/programs/window.opm') // the artefact: one archive, one request
await fetchOpm('/programs/window.opm/') // the authoring form: four files beside each otherIf you already have the bytes — a drop zone, an <input type=file>, a file read
in Node — hand them over directly:
import { openOpmArchiveInBrowser } from '@okonomi-gmbh/opm-loader'
const opm = await openOpmArchiveInBrowser(bytes, 'window.opm', { wasmUri })That is also why there is no Node entry point. Nothing here touches a
filesystem: the entry uses fetch, TextEncoder and CompressionStream, all
of which Node 22 has. Read the file yourself and pass the bytes.
If you fetch from a generator service, note it will need your origin in its CORS allowlist — an empty allowlist sends no headers at all.
What is in a file, without building anything
fetchOpm gives back a LoadedOpm, and everything descriptive on it is
readable at once — it is the manifest, read when the file was opened. No build,
no Lua, no waiting. That is the difference from a build service, where you had to
build something before you could learn what could be built.
const opm = await fetchOpm('/programs/window.opm', { wasmUri })
opm.manifest // the envelope
opm.generators // every Bauart the file offers
opm.generator('window_casement') // one of them, or a throwOnly three calls start the Lua build at all: build, plan and slots.
Everything else is reading.
opm.manifest — who, and what
{
format: 'okonomi-parametric@1', // the container's version
kernel: 'okonomi-mesh@1', // which kernel version it was written against
vendor: { name: 'okonomi-archviz-generator', version: '6.0.0' },
fieldSchema: 'field-schema@1', // which field format the inputs speak
generators: [ … ] // the same as opm.generators
}opm.generators — one Bauart
A file is a Lieferprogramm and may carry several. window.opm carries four:
window_fixed, window_casement, window_casement_double, window_transom.
{
key: 'window_casement',
type: 'window', // the product family
typeContract: 'okonomi/window@1', // the id only — see below
label: { default: 'Window, single casement',
de: 'Fenster, einflügelig Dreh-Kipp' },
description: { default: '…', de: '…' },
groups: [ { id: 'size', order: 1, label: { default: 'Size', de: 'Maße' } }, … ],
inputs: [ … ], // see below
slots: [ { key: 'frame', label: {…} }, // no default
{ key: 'glass', label: {…}, defaultMaterial: 'glass' }, … ],
ports: [ … ], // where a handle goes
animations: [ … ] // how it opens
}ports and animations are here rather than only in a build result. A host
therefore knows before the first build whether a Bauart carries a handle and
whether it opens at all — against a service, that answer cost a build.
slots here is the declaration, not the choice. It says which parts can be
dressed. What a particular build chose is opm.slots(key, inputs).
defaultMaterial is optional and often absent — on window_casement only
glass and cladding carry one. Where it is missing, the finish is not
defaulted but asked for: there is a material-role input (frame_material,
sash_material) whose own default decides. Do not read a missing
defaultMaterial as "undressed".
inputs — every field the Bauart takes
{
key: 'width',
dataType: 'integer', // integer decimal boolean enum string list object
unit: 'mm',
default: 900,
min: 300, max: 2000, // what this Bauart offers
limitMin: 300, limitMax: 2000, // what the generator could build at all
step: 1,
label: { default: 'Width', de: 'Breite' },
description: { default: '…', de: '…' }, // where there is one
scope: 'type', // type | generator
role: 'dimension', // dimension | material | configuration
group: 'size', // one of the groups declared above
widget: 'slider', // slider number switch select segmented path
visibleIf: { bar_type: ['internal', 'applied'] }, // where there is one
options: [ … ] // on an enum
}Every locale is always there. label and description are
{ default, de, … } — default is English, every other key a locale tag. There
is no language negotiation anywhere: all of it arrives, the client picks.
The three axes that are easy to confuse
| | |
|---|---|
| scope | Who asked for the field. type means the type contract demands it of every vendor in that family. generator means this Bauart chose it. On window_casement only width and height are type; the other thirty are not. |
| role | What the field is for. dimension is a measurement, material picks a finish for a slot, configuration is everything else. A count is not a dimension: bars_vertical decides geometry but is not a size. |
| group | Where it is rendered. Presentation only, with an order and a translated heading in generator.groups. |
scope and role are different questions and both are needed — group cannot
do the job, because a group like frame holds both the frame depth and the
finish.
min/max against limitMin/limitMax
The first is what this Bauart offers; the second what the generator could
build at all. In a .opm they are usually the same — there is no provider
narrowing a range — but both are emitted, because a form and a slider read
different ones.
Outside the range is refused, not clamped — but you have to ask for it.
opm.build() does not validate. It takes what it is given and hands it to
the program, which is what keeps the one hot call cheap. The rule lives in
resolveInputs, and there are two ways to get it:
import { fetchOpm, resolveInputs, OutOfRange } from '@okonomi-gmbh/opm-loader'
const entry = opm.generator('window_casement')
try {
const inputs = resolveInputs(entry, { width: 99999 }) // throws OutOfRange
const built = opm.build(entry.key, inputs)
} catch (error) {
if (error instanceof OutOfRange) { /* the message names the range */ }
}createClient(...).loadBuild() from the ./client entry does this for you.
resolveInputs also fills in defaults, rounds millimetres to whole numbers and
lower-cases enums, so 900 and 900.4 are one model rather than two. It is the
same file the generator service uses, which is what stops a page accepting a
size its supplier would refuse. Clamping and warning is still the client's job —
this only tells you that you must.
options on an enum
{ value: 'oak', // an English code; this is stored data
label: { default: 'Oak', de: 'Eiche' },
color: '#b08b5a', // only where the value names a material
thumbnail: 'https://…/oak.png' } // likewise, and absolutevalue is a code and is never translated — it goes into the cache key. Only
what it is called is translated. color and thumbnail show it and are
therefore not translated either; both are absent from an ordinary enum, because
there is no colour for DIN links.
visibleIf
Show a field only while another holds one of these values. It hides, it does not remove: a build takes every declared field whether or not the form showed it, because a declaration may not change shape with a value.
list and object
On dataType: 'list', items says what one element is (without key, group
and label — an element is not something a form lists), together with
minItems/maxItems. On object, fields holds the same shape as inputs,
one level down. That is what makes a skirting along a drawn wall run possible;
widget: 'path' is the hint that this is not something anybody types.
typeContract is an id, not the document
The file names only 'okonomi/window@1'. What that type demands of every
vendor lives in the registry:
https://registry.opm.okonomi.cloud/contracts/okonomi/window@1
https://registry.opm.okonomi.cloud/contracts/okonomi/window@1/sourceThe first is resolved — what it inherits from core/opening@1 is already
folded in. The second is the document as written, which is what somebody writing
a contract of their own needs.
What is worth reading there: requires (what every vendor must declare),
forbids (what it may not declare — a window forbids depth, because the
frame's depth is the product's), frame.datum (where y = 0 sits: a window's
own underside, a door's finished floor, a switch combination's centre) and
derives.keys (which values the scene fills in rather than asking for).
The whole specification is at registry.opm.okonomi.cloud.
And what a build costs
opm.build(key, inputs) // geometry, bounds, ports, pivots, clips, warnings
opm.plan(key, inputs) // the elevation as data — or null if the file carries none
opm.slots(key, inputs) // this build's slots, each with the finish it was given
opm.output() // whatever the program printed; for a workbench only
opm.close() // release the VM, once per fileplan returns null where the file carries no elevation.lua, and that is
deliberate: a drawing is how a picker shows what it is offering, and a provider
who has not written one should still be able to ship geometry. The host draws
nothing rather than refusing the file.
On opm.slots, an undressed slot omits material rather than nulling it —
which is what the client-side dressing pass tests for. A file with no
materials.json answers with the slots and no finishes at all: geometry still
builds, and a picker shows it grey.
The three.js entry, if you want it
./client turns a build into a three.js scene with its animation clips, and can
give you the same .glb bytes a generator service would have answered with
— so an existing dressing pass keyed on glTF material names keeps working
unchanged.
import { createClient } from '@okonomi-gmbh/opm-loader/client'
const client = createClient(opm, { textureBase: '/textures/', via: 'scene' })
const { built, gltf } = await client.loadBuild('window_casement', { width: 900 })
// gltf.scene, gltf.animations — as GLTFLoader hands them overvia: 'scene' builds the three objects directly; via: 'glb' goes through the
exporter and back and gives you the bytes as well. It is one assembly, two
routes — a second implementation would be a second opinion about where a
sash's hinge is.
three is an optional peer dependency. Install it only if you use this
entry.
Notes that will save you an afternoon
moduleResolutionmust readexports—bundler,node16ornodenext. Classicnoderesolution ignores the map and finds nothing.- One VM per file. A
LoadedOpmowns its sandbox for its whole life; two providers sharing one would share an_ENV, and the tables in it are writable. Callclose()when you are done with a file, not after each build. - Building is synchronous and fast. A casement is about 3 ms. There is nothing in flight, so no loading state and no cache is needed for correctness — memoise if you like, not because you must.
- Refusals are ordinary, and
resolveInputsis what raises them. A size outside a Bauart's declared range throwsOutOfRangerather than being clamped, and the message names the range. That is the supplier's contract, not a bug.opm.build()on its own does not check — see above. cacheKeyis stable, but not across major versions of this package. The key is derived from the canonical inputs and a build version compiled into the loader, so a major release can change it. If you store keys, treat a major bump as an invalidation.
The sandbox
A .opm is somebody else's code, and it is run as such. The VM gets a curated
_ENV: math without random, string without the pattern functions, table
and a handful of base functions. Absent entirely: io, os, debug,
package, require, load, pcall. Chunks are loaded in text mode only —
Lua bytecode is an escape route.
Two limits, raised from one hook: a step count (reproducible; the same program is refused the same way on every machine) and a wall clock as a backstop. The pattern functions are withdrawn rather than bounded, because Lua's matcher backtracks inside a single C call where no count hook can reach it.
There is no clock and no random source inside, which is what makes a build deterministic rather than merely usually the same.
Licence
MIT.
