The kernel a .opm is written against
Everything a program may name, lifted out of the --- blocks in lua/*.lua. Run npm run studio:doc after changing anything there; npm run studio:doc:check is what stops this file and the kernel drifting.
A program reaches all of it through one function, and there is no other way in:
local mesh = require_kernel('okonomi-mesh@1')
A file also names the kernel it was written against, in manifest.json:
{ "kernel": "okonomi-mesh@1" }
A loader that does not carry that version refuses the file rather than building it a member short.
okonomi-mesh@1
Source: lua/okonomi-mesh-1.lua
okonomi-mesh@1 — the geometry kernel, in the language the file is written in.
This is a rewrite of src/geometry/mesh.ts and the mesh half of builder.ts, and it lives here rather than in the loader for one reason: a host that is not JavaScript would otherwise have to reimplement it. Four hosts are in scope — the browser, Node, Unreal (C++) and Unity (C#) — and four independent implementations of ear clipping, of inset's bisector mitre and of the chord tolerance would have to agree with one another down to the triangle. They would not. In Lua there is one of it, and a host needs only a VM it already embeds.
Two things about the language that are not stylistic
Lua tables are 1-based and glTF indices are 0-based. Every indices entry here is a glTF vertex index — 0-based — held in a 1-based table. The two numberings sit in the same array and only one of them is Lua's. Mixing them does not raise anything; it renders as a torn cap, which is the failure this whole kernel is careful about.
It is written to the Lua 5.1 subset, because the weakest target VM decides: MoonSharp is about 5.2 and LuaJIT is 5.1. So no integer division, no goto, no bitwise operators and no table.unpack. Geometry needs none of them.
Stated figures
M.ARC_TOLERANCE = 0.0001
How far a chord may sit inside its arc before another segment is worth it.
M.MAX_ARC_SEGMENTS = 12
M.EPSILON = 1e-12
M.ARC_SAGITTA = ARC_SAGITTA
The sagitta of a quarter turn split n ways, as a fraction of the radius: 1 - cos(pi / 4n), for n = 1 to MAX_ARC_SEGMENTS.
##### Why these are written out and not computed
arcSegments turns a float into a count, and a count is topology. It used to be ceil(pi / (4 * acos(1 - tolerance / radius))) — one acos, and if acos differs by a unit in the last place between musl (this VM in a browser), System.Math (MoonSharp under Unity), glibc (a C++ host) and V8, and the value happens to land on an integer boundary, the segment count moves by one. That is not a rounding difference between two builds of one model, it is two different models — a different vertex count, a different index count, a different mesh.
Written out, the decision is a multiply and a compare. Both are exactly specified by IEEE 754, so every host that has doubles gets the same answer by construction rather than by luck.
n is the smallest n whose sagitta is within tolerance, and <= is the strictness that says so: a chord that sags by precisely the tolerance is inside it. With <, the threshold radius itself would take a segment more than it needs, at every one of the twelve boundaries.
The comment beside each is the largest radius it serves at the 0.1 mm tolerance this kernel defaults to.
Functions
Reached through the table require_kernel answers with.
M.newMesh(name, material)
A named part in one slot, as four flat arrays.
name is the node it moves with and material is the slot that dresses it. They are different axes and both are strings: a Sprosse takes the node of the member carrying it and the slot of the timber it is cut from.
M.box(sizeX, sizeY, sizeZ, opts)
An axis-aligned box, given (x, y, z) extents and a centre — x through the wall, y up, z along it.
M.cylinder(radius, height, opts)
A cylinder about the Y axis.
Only the door's Einbohrbänder need one, but a barrel drawn as a box is the kind of detail that makes a whole door look wrong — and the barrel is exactly the step the Falz makes, so it is looked at from the room every time the door is open.
The side normals are radial rather than per-face, so the barrel shades round instead of facetting at the seam. That is the one place this departs from box, which gives every face its own four vertices precisely so that an arris stays sharp.
segments is exposed because a disc that closes a round recess has to have the same facets as the hole it closes. A twelve-sided floor behind a thirty-six-sided wall leaves a sliver of daylight at every second corner, which reads as a torn edge rather than as a coarse polygon.
M.boxBetween(x0, x1, y0, y1, z0, z1, opts)
A box given by its extents rather than its size and centre, which is how most of a window's parts are actually described.
nil for a zero extent: a stile whose panel reaches the edge has zero width, and a zero-extent box is degenerate geometry rather than a small part. The caller's add takes nil as an ordinary answer for exactly this.
M.triangulate(points)
Ear clipping.
The outlines here are small and simple — a rectangle, a window frame ring — so the quadratic cost is irrelevant and the alternative is a dependency that would have to be trusted about winding.
The triples it answers with are Lua indices into points, 1-based. The cap routines subtract one on the way into mesh.indices; see the note at the head of this section.
M.arcSegments(radius, tolerance)
M.roundProfile(depth, radius, tolerance)
The stations of a quarter-round arris of radius at the far end.
A station is { z, inset, curved, tangent }, and the last two are what make it more than a ladder. curved marks the band arriving here as part of a curve, which is what earns it a rolled normal instead of a facet one — a chamfer never sets it, because a chamfer is a real facet and shading it smooth would round an edge a joiner cut flat.
tangent is the profile's own direction as (rise, inset). Without it the tangent is taken from the chord to the next station, and at the two ends of the arc there is no neighbour on the far side to average with — so the answer comes out half a segment off. That is 15 degrees on a 2 mm arris: a crease where the roll meets the flat run and another where it meets the cap, which is exactly the hard edge the rounding was milled away to lose.
M.bevelProfile(depth, bevel)
A chamfer on the arris, as opposed to a lean on the whole member.
taper insets the far cap and leaves the wall to slope the entire depth between them, which is what a bevelled architrave is — one board, one leaning face. A window's members are not that shape. Their faces are square and it is the edge that is worked, so the treatment stops after bevel of depth and lets the rest run straight. Tapering a 14 mm frame plate over its whole depth does not break its arris, it makes a wedge of the frame.
M.mirrorStations(stations, depth)
The same stations, treated at the near cap instead of the far one.
roundProfile and bevelProfile both shape the cap at z = depth. A window is the other case: ring runs room to weather, so the face a standing person sees is the one at z = 0.
The curved flag moves by one station, and that is the whole reason this is a function. addWalls reads it off the second station of each pair, so it marks the band arriving there. Reverse the list without moving it and every rounded band is shaded as a facet — geometrically a round, and visually the hard arris it was supposed to replace.
M.circlePoints(radius, segments, center)
A circle and a rectangle drawn on the same sweep of angles.
They exist as a pair, and only as a pair: extrude's paired opening joins point i of the outline to point i of the hole, so the two loops have to be built by one rule or the join shears. The rule is the angle — both walk the same rays out from their centre, so wherever the circle is, the rectangle is directly outside it.
M.rectanglePoints(width, height, segments, center)
M.extrude(polygon, opts)
A closed profile in the XY plane, extruded from z = 0 to z = depth.
The extrusion is a stack of stations: each one is a depth and how far the outline is inset there. Two stations make the straight-sided prism this started as; a handful following a quarter circle make a rounded arris. Both are the same walk up the same ladder, so there is one wall routine.
taper sugar for the two-station case: the far cap inset by that much. profile the stations outright. roundProfile builds the rounded one. opening a hole through the extrusion — the one shape a single closed outline cannot express and a window frame needs. Two axis-aligned rectangles are the common case and keep their own path; otherwise the two loops are paired point for point and must be the same length. That is what lets a round hole sit in a square face. openingArris whether the hole follows the profile as well. It does by default, and a window depends on that: the arris round a glazed panel has to roll away from the glass, so the hole grows where the outline shrinks. A Schalterprogramm's surround is the other case — the moulding is on the outside edge and the aperture is cut square through it, because a rounded aperture reads as a pane set in a frame rather than as a cover plate in a hole.
A part
What the mesh constructors answer with, before b:add takes it.
Mesh:vertexCount()
Mesh:triangleCount()
Mesh:isEmpty()
Mesh:transform(m)
Right-multiplies every position by a row-major 4x4 and every normal by its upper-left 3x3.
Rigid transforms only. A non-uniform scale would need the inverse transpose, and nothing here scales.
Mesh:translate(dx, dy, dz)
Mesh:merge(other)
Folds another mesh in, shifting its indices.
The shift is by vertex count, not by table length, and the added value is a glTF index — see the header. This is the one place the two numberings meet.
Mesh:bounds()
glTF requires min and max on a POSITION accessor; a viewer uses them to frame the model without reading the buffer.
okonomi-animation@1
Source: lua/okonomi-animation-1.lua
A named motion, as glTF keyframes.
The point of shipping these inside the file is that opening a window is not something a client should have to work out. The hinge axis is buried in joinery — the clear opening plus the Überschlag, offset by the Falzluft — and a client that guesses swings the sash beside its hinge rather than on it.
The two ways a part can turn
A node has one pivot, and the exporter puts it in the node's translation. A motion about that point is a rotation channel and nothing else: two keyframes, exact at every angle, because glTF interpolates rotations spherically.
A motion about a second point on the same node — a sash turns about its hinge stile and tilts about its bottom rail — has no pivot left to use, so it is expressed as a rotation and a compensating translation. For a node resting at p, turning about q:
T(θ) = R(θ)·(p − q) + q
which is p at θ = 0, so the rest pose is still the closed one. That translation traces an arc and glTF interpolates translations straight, so it is sampled.
Stated figures
M.DURATION = 1.0
One second, always.
A client that wants the sash to take three plays the clip at a third speed; a client that wants it 63 degrees open plays it to 0.7. Baking either into the file would put them in the cache key.
M.ARC_TOLERANCE = 0.0001
How far a compensated arc may sag between keyframes.
M.MAX_ARC_SAMPLES = 256
A runaway guard, not a tuning knob. It has to sit well above anything the tolerance actually asks for, or it silently costs accuracy instead of bounding cost.
Functions
Reached through the table require_kernel answers with.
M.quaternion(axis, radians)
glTF stores a quaternion XYZW.
M.rodrigues(vector, axis, radians)
Rotates a vector about an axis through the origin.
Rodrigues, because a 3x3 matrix would be more code for the one thing needed here.
M.sagittaRatio(radians)
1 - cos x, the sagitta of a chord across x as a fraction of the radius — computed from the series above and never from the host's cos.
##### Why arcSamples cannot have arcSegments' table
The mesh kernel writes out twelve thresholds, and it can, because the arc it divides is always a quarter turn: twelve angles, twelve constants, and the decision is a multiply and a compare. A clip's arc is whatever angle a provider wrote — a sash opens 90 degrees, a leaf tilts 12, and the next file may say something else — so the angle to be tested is turn / 2c for a c that is being searched for. There is no finite set of angles to tabulate.
So the transcendental is taken out the other way: it is replaced by a series this file states, and the count is found by walking c upward until the sagitta fits. Every step is a multiply, an add and a compare, all exactly specified by IEEE 754, so the count is the same on every host that has doubles — the same guarantee the table gives, reached differently.
Which makes the specification the series rather than a true cosine, and that is the point: two hosts need not agree about cos, and they cannot disagree about this. The deviation from a true 1 - cos is at most 1.3e-15.
Clamped at pi, where the series' domain ends and where the quantity stops meaning anything anyway: a chord across more than half a turn does not sag by 1 - cos of it, it sags by the whole diameter. Returning the maximum keeps the walk below monotone, which is what lets it stop at the first fit.
M.arcSamples(radians, offset, tolerance)
How many keyframes the arc needs to stay inside the tolerance, rather than a constant that happens to suit the angles in the file today.
A chord across an angle d on a circle of radius r sags by r(1 - cos(d/2)), so c steps fit when r * sagittaRatio(turn / 2c) is within the tolerance. Deriving it is what makes the guarantee hold for a motion nobody has written yet.
The smallest c that fits, found by walking. The old form divided by 2*acos(1 - tolerance/r) and took a ceil of it, which is the same number and is decided by the host's acos — see sagittaRatio for why that is not good enough for something that decides how many keyframes a file carries.
M.rotate(spec)
A turn about the node's own pivot.
Two keyframes is not a shortcut: glTF's LINEAR interpolation of a rotation is spherical, so the in-between angles are exact.
M.translate(spec)
A straight travel, for a leaf that slides rather than swings.
A glTF translation channel replaces the node's own translation, it does not add to it. The writer puts a node's pivot in exactly that field, so a clip whose first keyframe is the bare offset would snap the leaf to the origin the instant it started. Hence rest: the first keyframe restates where the leaf already is, and the second is that plus the travel.
M.rotateAbout(spec)
A turn about some other point, for a node whose pivot is already spent.
okonomi-builder@1
Source: lua/okonomi-builder-1.lua
The accumulator every Bauart is handed, in Lua.
A rewrite of src/geometry/builder.ts. It is handed a generator's declaration and the values a client asked for, and it answers with named parts, the ports the result offers, and its bounds.
The frame is the planner's:
+Z along the wall +X through the wall, toward the weather +Y up, with y = 0 where the type contract's frame.datum says — the window's own underside, the door's finished floor, the Schalterprogramm's centre.
Metres throughout. Everything a provider or a client types is in millimetres, and the conversion happens here at the boundary, once.
Stated figures
M.OPEN_DEGREES = 90
A quarter turn, for everything that swings.
M.TILT_DEGREES = 12
How far a Kipp sash leans in at the head.
Functions
Reached through the table require_kernel answers with.
M.round(value)
1e-6 quantisation, applied to pivots, ports and bounds.
math.floor(v * 1e6 + 0.5) and not a rounding helper, because it has to agree with JavaScript's Math.round to the last digit or the parity fixtures disagree for a reason that has nothing to do with geometry: Math.round breaks a tie toward +Infinity, which is what adding a half and flooring does.
M.newBuilder(definition, values)
definition is the generator's entry from the manifest — it is read for its key and its inputs, and for nothing else. values is what the host cast and validated before we were called; casting is not this side's business.
The builder
The object handed to the function build.lua returns — b by convention.
Builder:key()
Builder:input(key)
An input in its declared type, falling back to the generator's default.
A request that predates an input simply has not got it, and that has to read as the default rather than as arithmetic on nil.
Builder:inputM(key)
The same, in metres. Millimetres in, metres out, converted once.
Builder:inputBool(key)
Builder:declares(key)
Builder:inputMOrZero(key)
Nought for an input this Bauart does not declare, rather than an error.
window_fixed has no sash, so it declares no sash_edge_bevel_mm, and the arris walk asks every member for both edges regardless.
Builder:add(part)
nil is an ordinary answer: a member whose panel reaches the edge has zero width, and a zero-extent box is degenerate geometry rather than a small part.
Builder:warn(message)
Builder:measure(label, value)
A figure the build settled that the request did not state. Labelled rather than keyed, because the label is the generator's own vocabulary.
Builder:pivot(node, point)
Where a moving part turns.
Declared in model space, like everything else here; the exporter moves that node's geometry into its own frame and puts the pivot in the node's translation. Nothing in a generator's arithmetic changes because of it. The order pivots were declared in is kept beside them.
pairs has no order, and Lua 5.4 makes that visible: it seeds its string hash per VM, so the same table walks differently in two processes. That reaches the file — asset.extras carries the pivots and JSON.stringify writes whatever order it is handed — and a .glb whose bytes depend on which process built it is exactly what the content-addressed name must never be. The clips already kept their order for the same reason; this is the other map.
Builder:port(entry)
node names the part the port travels with. A handle moves when the sash swings and the frame does not, so a client that opens the window needs to know which.
Builder:addChannels(clip, channels)
One clip may move several parts, and one part may appear in several clips.
The order clips were first named is kept beside them, because pairs has no order at all and the answer has to be the same twice running — a cache key is over the inputs, but a file that shuffled its own clips would still be two different files for one question.
Builder:turn(clip, options)
A turn, about the node's own pivot unless about names another point.
A node has one pivot, so a second motion on the same node — a sash turns about its hinge stile and tilts about its bottom rail — has to say where.
Builder:slide(clip, options)
A straight travel, for a leaf that runs on a track instead of turning.
The node's rest position is passed through because a glTF translation channel replaces the node's own translation rather than adding to it, and the exporter has already put the pivot there. Without it a sliding leaf jumps to the origin the moment the clip starts.
Builder:box(sizeX, sizeY, sizeZ, opts)
Builder:boxBetween(x0, x1, y0, y1, z0, z1, opts)
Builder:cylinder(radius, height, opts)
A barrel about the Y axis — the door's Einbohrbänder, and nothing else so far.
segments is exposed because a disc that closes a round recess has to have the same facets as the hole it closes. A twelve-sided floor behind a thirty-six-sided wall leaves a sliver of daylight at every second corner, which reads as a torn edge rather than as a coarse polygon.
Builder:clamp(value, low, high)
Builder:call(build)
okonomi-plan@1
Source: lua/okonomi-plan-1.lua
okonomi-plan@1 — what a family gets to draw an elevation with.
Shapes as data, not a pen to draw with
The TypeScript original hands a family a Pen — two callbacks and two coordinate flips — and gets strings of SVG back. That is the right shape for one language and the wrong one for a format: a callback protocol is a thing every host would have to implement identically, and what comes back would be markup that only one of them knows how to check.
So a plan is data. A list of rectangles and polylines, in the building's own millimetres — z along the wall, y up from the datum — and the host turns them into whatever it draws with. The paper stays entirely on the host side: the viewBox, the flip from a building's y to SVG's, the stroke width, the margin and the escaping are none of a provider's business.
Why a drawing rather than a render
A picker needs to show what it is offering, and a parametric article has no photograph. An elevation is what a joiner's drawing already is: a few hundred bytes against a PNG's tens of thousands, crisp at any size, and it cannot go stale because it comes from the same declaration the geometry does.
The symbols are the German convention (DIN 1356): the apex of a Dreh triangle points at the hinge stile, a Kipp triangle's apex points down at the rail it turns about. Seen from the room, which is the side a planner stands on.
Functions
Reached through the table require_kernel answers with.
M.rect(z, y, w, h, class)
A rectangle, given its bottom left corner in the building's frame.
Bottom left and not top left, because that is where a building's coordinates start. SVG's y runs the other way and the host flips it once; writing every rectangle upside down here would put that flip in three files instead of one.
M.polyline(points, class)
M.outline(extent)
The outer outline of the article. Every family draws it, and draws it first.
M.daylight(box)
A hole the outer members leave.
Family-specific for a reason worth stating: a window's is inset on all four sides, a door's runs down to the floor because a door has no cill, and a Schalterprogramm has one per gang rather than one at all. Insetting a door's four ways drew a threshold under every door that has not got one. So the box is passed in; only the drawing of it is shared.
M.turnSymbol(left, right, yLo, yHi, hingeAtLeft)
Dreh: a triangle whose apex sits on the hinge stile.
Shared between the window and the door because it is a convention rather than a fact about either — DIN 1356 draws the same triangle on both, and two copies of it would eventually disagree about which end the apex is.
M.tiltSymbol(left, right, yLo, yHi)
Kipp: a triangle whose apex points down at the rail it turns about.
A door has none — it turns and nothing else — which is why this is offered rather than drawn alongside the Dreh.
M.ring(z, y, radius, class, sides)
A circle, as the polygon a plan can actually carry.
There is no arc primitive and there should not be one: the paper owns two shapes, and an elevation at thumbnail size cannot tell a twelve-sided polygon from a circle. The count is kept low deliberately — a five-gang combination draws five of these, and the whole document has to stay under two kilobytes.