Document structure#
A forma document is a flat list of top-level blocks. Only four kinds may appear there:
param, local,
component and model. Anything else
at the top level is an error.
param wall = 2 // an input
local sizes = [10, 20, 30] // a computed value
component "plate" { // a reusable block
param size = [20, 20]
extrude { height = wall rect { size = size center = true } }
}
model "stack" { // something to render
for i, s in sizes {
translate { offset = [0, 0, i * wall] plate { size = [s, s] } }
}
}
Order does not matter between declarations of different kinds - a model may use a
component declared below it. Order does matter among locals, which are evaluated top to
bottom. See Scope and names.
The pipeline#
source ──▶ lexer ──▶ parser ──▶ evaluator ──▶ geometry nodes ──▶ Manifold (WASM) ──▶ mesh
│ │
params digest cache
Each stage has a distinct job, and knowing which stage raised an error tells you what kind of mistake it was.
- Lexer turns characters into tokens. It rejects unknown characters, unterminated strings and unterminated block comments.
- Parser turns tokens into an abstract syntax tree. It rejects anything that is not
grammatical - a missing brace, a keyword used as a block type, a
paramblock containing nested blocks. - Evaluator resolves params and locals, then walks the models. It rejects unknown names, bad operands, unknown blocks, unknown attributes and dimensionality mismatches. Its output is a tree of geometry nodes - it never calls the kernel itself.
- Kernel solves the node tree into concrete solids, memoized by node digest in an
EvaluationContext.
The separation between steps 3 and 4 is what makes editing cheap. The evaluator is pure and fast; the kernel is where the time goes, and it is asked only about subtrees whose content actually changed.
The one exception is align, which has to measure its child before
it can decide where to put it. It is the only block that reaches into the kernel during
evaluation, and the only one that fails when there is no kernel context.
Nesting is composition#
There is no separate expression language for geometry. A block's children are its operands, written inside its braces:
difference { // subtract…
box { size = [20, 20, 10] } // …this is the first operand
cylinder { radius = 5 height = 30 } // …and this is cut out of it
}
Several children in a row are implicitly unioned. Every operation except the three booleans unions its children before acting on them, so a transform applied to two shapes moves both together:
translate {
offset = [0, 0, 10]
box { size = [4, 4, 4] } // these two are unioned first,
sphere { radius = 3 } // then the union is moved
}
difference is the operation where order matters: the
first child is the body and every later child is cut out of it. union and intersection
receive their children as a list too, but the result does not depend on the order.
An operation with no children is an error; a shape with children is an error.
Dimensionality#
Every piece of geometry is either 2D or 3D, and the language tracks which.
- 2D shapes -
rect,rounded_rect,circle,ellipse,regular_polygon,stadium,polygon- produce cross-sections. - 3D shapes -
box,sphere,cylinder,cone,torus- produce solids. - Operations either fix what they accept (
extrudetakes 2D,refinetakes 3D) or pass their children's dimensionality through (translate,rotate,hull, the booleans).
Three rules follow, and each has its own error message:
| Rule | Error when broken |
|---|---|
| The children of one block must all be the same dimensionality | "union" cannot mix 2D and 3D shapes |
| A block that fixes what it takes must get it | "extrude" takes 2D geometry, got 3D |
| Anything rendered must be 3D | part "body" is 2D - extrude or revolve it before rendering |
To get from 2D to 3D use extrude or
revolve; to go back use
project or
slice.
Units and orientation#
- Lengths are unitless. Every consumer treats them as millimetres - STL has no unit either - but nothing in the language enforces that.
- Angles are always in degrees. Everywhere:
rotate { z = 90 },revolve { angle = 180 },sin(30),param a { type = angle }. There is no radian form in the language. The JavaScriptAngleclass stores radians internally, and that is the only place radians appear. - Z is up.
extrudesweeps along +Z.revolvespins a profile written in the XY plane about the Y axis, mapping the profile's Y to the result's Z, so a profile drawn from[0, 0]upwards produces a solid standing on the XY plane. - 3D rotations apply X, then Y, then Z, matching OpenSCAD, so a model ported from there lands in the same orientation.
Identity is content#
The evaluator does not produce a call graph - it produces a tree of immutable nodes, each carrying a 128-bit digest of its own content and its children's digests. Two structurally identical subtrees have the same digest and therefore are the same node.
That has consequences worth designing around:
- A component used twenty times is evaluated once. Eight spheres in a loop cost one sphere and eight transforms.
- Two shapes written with their attributes in a different order still share a cache entry - object keys are sorted before they are digested.
- The members of a
unionare sorted by subtree size, so two unions written in a different order also share a digest.differenceis never reordered, because its first child means something different from the rest. - Nested transforms collapse into a single matrix, so a deep stack of
translateblocks is one kernel call rather than one per level. - The digest is computed from the IEEE bits of every number, never from a runtime-seeded hash, so it is the same in every browser and every run.
See GeometryNode for the node types themselves, and
EvaluationContext for the cache built on top of them.