- 1. Why a file at all
- 2. The container
- 3. `manifest.json`
- 4. `build.lua`
- 5. `elevation.lua`
- 6. Determinism
- 7. Materials
- 8. Type contracts
- 9. The sandbox
- 10. Versioning
The .opm format, version 1
A .opm file is a parametric product. It carries the rules for building a thing rather than the thing itself: ask it for a window 900 mm wide and it builds one; ask it again at 901 and it builds another. Nothing is stored, and nothing was built in advance.
This document is the specification. It describes what a conforming file contains, what a conforming loader does with it, and what both may rely on. It is normative where it says must and explanatory everywhere else.
The reference implementation is @okonomi-gmbh/opm-loader. The examples on this site are real files, built and byte-compared on every commit against three independent Lua hosts.
1. Why a file at all
The obvious way to serve parametric models is a build service: a client posts its parameters and gets a model back. That works, and it costs a network round trip per change. In a plan where somebody is dragging a wall, the round trip is the product — everything else is milliseconds.
Moving the rules into a file inverts that. A consumer fetches once and then builds locally, synchronously, as often as it likes. There is no request in flight, so there is no loading state, no cache to keep coherent, no token to proxy and no CORS to arrange. A window becomes a function call.
The price is that the rules must travel in a language the consumer can run, and must produce the same answer everywhere. Sections 4 and 6 are about paying it.
2. The container
A .opm file must be a ZIP archive. Media type application/vnd.okonomi.parametric+zip, conventional extension .opm.
It holds four members:
| Member | Required | What it is |
|---|---|---|
manifest.json | yes | What the file builds and what it takes. Section 3. |
build.lua | yes | The program that builds it. Section 4. |
elevation.lua | no | A scale drawing, as data. Section 5. |
materials.json | no | The finishes its slots may take. Section 7. |
A loader must refuse a file with no manifest.json or no build.lua. It must accept one without the other two: a provider who has not drawn an elevation still ships geometry, and a file with no finishes builds a model a host may dress however it likes.
A single directory prefix is tolerated. zip -r window.opm programs/window.opm — the command everybody reaches for first — stores every member under programs/window.opm/. A loader must locate the manifest wherever it is and read the other members from the same prefix. Refusing such a file would be refusing it for a reason nobody could see from the outside.
Two manifests are two products in one file, and a loader must refuse that rather than resolve it by picking the first.
The media type is registered with nobody. A loader must not trust a Content-Type header to tell a .opm from anything else: one static host serves it as application/octet-stream, another as text/plain, and a third guesses from the extension and gets it wrong. Sniff the bytes — a ZIP begins PK\x03\x04.
Determinism of the container
Packing the same members twice should produce the same bytes. That means no timestamps in the local headers and a stated entry order. It is not required of a conforming file — a ZIP is a ZIP — but a producer that ignores it gives up the ability to compare an artefact with the one it replaces.
3. manifest.json
The envelope is described by opm-manifest@1, and the fields inside each generator entry by field-schema@1. The two compose: everywhere the envelope needs to say what a field is, it references the other document rather than restating it.
{
"format": "okonomi-parametric@1",
"kernel": "okonomi-mesh@1",
"vendor": { "name": "acme-joinery", "version": "2.1.0" },
"fieldSchema": "field-schema@1",
"generators": [ … ]
}
format is the first thing a loader reads and the first thing it may refuse on. A file naming a version the loader does not carry must be refused rather than opened hopefully: a half-understood file produces a model a reader cannot tell from a correct one.
kernel names the geometry kernel the program is written against, as a contract rather than as a file list. okonomi-mesh@1 is the only value a loader accepts today. Behind it a loader carries four Lua modules — mesh, animation, builder, plan — and a program reaches all of them through require_kernel. These are two different lists. A kernel that gained a fifth module would not change what a file may declare; a kernel that changed what an existing call means, would.
A file may carry several Bauarten
And that is the ordinary case. A fixed light and a casement are the same three members one rail apart; sharing one file keeps their glazing build, their frame widths and their arris limits one calculation rather than two that drift. A provider sells a Lieferprogramm, not a window.
Each entry states its key, its type, the typeContract it claims, a translated label and description, its field groups, its inputs, its slots, and every port and animation the build attaches. The last two are a promise in both directions: a host must not have to build a Bauart to learn whether it carries a handle, and a build must not attach a port the manifest did not name.
Inputs are one flat list
There is no parameter/option split. When a value is bound, and who is asked, are facts about a host's forms rather than about the product — so each field carries a scope (type or generator) saying which of the two demanded it, and a host that wants the split derives it from the type contract's derives list (section 8).
4. build.lua
The program is a Lua chunk that must evaluate to a single function:
return function(b)
local mesh = require_kernel('okonomi-mesh@1')
b:add(mesh.box(b:inputM('width'), b:inputM('height'), 0.08,
{ name = 'frame', material = 'frame' }))
end
b is the builder. It answers b:key() (which Bauart is being asked for), b:input(key) and b:inputM(key) (an input, raw or converted to metres), b:declares(key), and it takes b:add(part), b:warn(text), b:measure(label, value), b:pivot(...), b:port(...), b:turn(...) and b:slide(...). The full surface is in the kernel reference.
require_kernel is the only way in. There is no require, no dofile, and no filesystem.
Units
Millimetres in, metres out. Every dimensional input is whole millimetres, because that is what a plan is drawn in and what a cache key can be made of. Geometry is metres, because that is what glTF is. b:inputM is the conversion, done once.
UVs are in metres too. A client scales a material by 1 / worldSize, so a UV already measured in metres lands the texture at its real size.
The frame
+X through the wall, +Y up, +Z along it. Where y = 0 sits is not the format's business — it is the type contract's, and the families disagree: a window's own underside, a door's finished floor, a switch combination's centre. See section 8.
5. elevation.lua
Optional, and the same shape: a chunk evaluating to a function of the builder. It returns shapes as data, never markup:
return { extent = { width = 900, height = 1400 }, shapes = { … } }
Rectangles and polylines in the building's own millimetres, through require_kernel('okonomi-plan@1'). The host owns the paper: the viewBox, the flip from a building's y to a screen's, the stroke width, the margin and the escaping are none of a provider's business.
A callback protocol would have been the other design, and it is the wrong one for a format: every host would have to implement it identically, and what came back would be markup only one of them could check.
6. Determinism
The same file, the same inputs, the same bytes — on every host. This is the strongest claim the format makes and the one most easily lost.
A conforming program:
- has no clock and no random source. Neither is offered; determinism falls out rather than being enforced.
- must not turn a transcendental into a count. Two correctly-rounded
acosimplementations may differ in the last place, and if that decides how many facets an arris has, the two hosts have built different models — not the same model rounded differently. Where a count is needed, the kernel compares against written-out thresholds or walks a stated series. A program that needs its own count must do the same. - must state key order at any boundary. A Lua table has no order, and Lua 5.4 seeds its string hash per process — so
pairswalks one table two ways in two runs. Anything crossing out of the VM must be handed over in a stated order or it will differ between processes. - should write float literals as floats. Lua 5.4 has an integer subtype and
1is an integer; negating an integer zero gives zero where negating a float zero gives negative zero. It renders identically and the bytes differ — and Lua 5.1 has no such subtype at all, so the same source answers differently on two target hosts.
Coordinates are held to a few units in the last place; counts and indices are exact. A difference in a coordinate is precision. A difference in a count is a different mesh.
7. Materials
materials.json names, per slot, which finishes it offers, and for each finish its factors — colour, roughness, metalness, opacity — and optionally its map filenames and their worldSize in metres.
Filenames, never URLs. Where images live is a fact about an installation, and a file outlives an installation.
A build names each part's material after a slot key, and a client's dressing pass looks a finish up by that name. A part named anything else silently never gets its finish.
8. Type contracts
A .opm says what it builds. A type contract says what a kind of product owes, across every vendor that builds one — so a host can ask any vendor's window for a width without knowing whose window it is.
Contracts are published here, in two namespaces:
core/— the shared layer. A kind of product: something that fills a hole (core/opening@1), something swept along a route (core/path_run@1), or the root every family stands on (core/base@1). No generator claims one directly.- anything else — a vendor's namespace. An article that is actually sold, narrowing one of the above.
The namespace is what lets two vendors both publish a window@1 without arguing about what the word means. See the contract guide for requires, forbids, derives, frame and slots.
9. The sandbox
A .opm is somebody else's code, and a conforming loader must run it as such. What the reference implementation guarantees:
- Text-mode chunks only. Lua bytecode is a known escape route and is not loaded.
- A curated
_ENV.mathwithoutrandomandrandomseed;stringwithoutfind,match,gmatchandgsub;tablewithconcat,insert,removeandsort; and a handful of base functions. Absent entirely:io,os,debug,package,require,load,dofile,collectgarbage,rawset/rawget/rawlen,coroutine,_G. - No
pcall. A chunk that can catch an error can catch the one raised to stop it, and a runaway loop would then defend itself. Handing a vendor no error handling at all is a real cost, paid because the alternative is a limit that does not hold. - Two limits, one hook. A step count — the reproducible one, so the same program is refused the same way on every machine — and a wall clock as a backstop. Defaults: forty million instructions, two seconds, 64 MB.
- The pattern functions are withdrawn rather than bounded, and this is the one hole a limit of that shape cannot close. Both limits are raised from a count hook, and a count hook fires between Lua instructions — never inside a C function. Lua's matcher backtracks: a couple of dozen lazy quantifiers is a single call that runs for as long as it likes while the step counter stays at zero. Measured, not supposed.
10. Versioning
okonomi-parametric@1 names the container and the manifest envelope — the ZIP, the four member names, the required keys and their meanings.
A @2 would be a change that makes a version-1 loader read a version-2 file wrongly rather than not at all: a member renamed, a required key removed, a coordinate frame turned. Adding an optional member, adding an optional key, or adding a value to an open enumeration is not a version change.
The kernel version (manifest.kernel) moves independently, and for the other half of the file: okonomi-parametric@1 is what a reader must understand, okonomi-mesh@1 is what a program was written against.
Type contracts version independently again, per document, in their own id. Three clocks, because they measure three things.