Parametron

Model compiles a ModelJSON document into a live node graph and pulls it lazily: reading .value on a clean node returns a cache, reading it on a dirty node recomputes and re-caches. This page covers the Model lifecycle, how dirty propagation and the root fold work, sub-model composition (ModelScope), model references, and keyframe animation. For the document format itself see Schema & validation; for the expression language nodes evaluate against, see Expressions; for turning an evaluated model into mesh/curve/point data for a renderer, see Render contract.

Model lifecycle

import { createRegistry, Model } from "@parametron/parametric"
import { nodeRegistry } from "@parametron/parametric/nodes"

const registry = createRegistry(nodeRegistry)
const model = Model.fromJSON(json, { registry })   // validates, builds the node tree

const result = model.evaluate({ width: 300 })       // optional param overrides
result.geometry   // GeometryNode[] — lazy, memoized on first read
result.params     // ParamInfo[] — declared parameters + current values

model.toJSON()                                       // ModelJSON, round-trips
model.toScene({ width: 300 })                         // SceneJSON (evaluate() + format)

Model.fromJSON(json, { registry, assets? }) validates against registry (validateModelJSON), then loads json.parameters and json.operations into one flat nodes: NodeCollection<ChildNode> (header nodes first, body fold list after — ChildNode = OperationNode | ContainerNode | ParameterNode | ExpressionNode | BaseNode). Every operation without an explicit key gets one auto-assigned from the registry's defaultKeyOf, so every step is referenceable. opts.assets is a Store — pre-loaded fonts/images/sub-model docs the graph reads synchronously; see Store.

model.evaluate(params?) applies params as input overrides on matching ParameterNodes (values are JSON-encoded, so a string override becomes a quoted literal, never a bare identifier), runs nodes.evaluate(), and returns an EvaluateResult: { name, geometry, materials, fonts, layers, params, nodes, definitions?, camera? }. .geometry is defined via a lazy getter — a full tessellation pass over every leaf — so a consumer that never reads it (a viewport driven by collectRenderFrame instead) never pays for it.

model.toScene(params?) runs evaluate() then formats the already-computed node values into SceneJSON (mesh/curve/point entities, materials/layers/ fonts referenced by name) — the one-shot render path; see Render contract for the full contract including the incremental collectRenderFrame alternative.

model.bakeValue(value) resolves an EntityJSON[] that may contain nested groupEntity placements (from clone / makeGroup / instancing) into flat leaf entities, using this model's group-definition registry (model.scope.definitions). It delegates to model.scope.bakeValue, so any consumer holding the owning ModelScope (e.g. a node's .model getter for a hover preview) reaches the identical logic.

model.convertUnit(unitType, newUnit) rewrites every unit-carrying literal in the model's parameter inputs and operation args from the current unit to newUnit, with dimensional inference (a reference like "width - 18" converts only the literal 18, not the whole expression). The standalone convertModelUnit(nodes, unit, unitType, currentUnit, newUnit) is the same logic against a bare NodeCollectionModel.convertUnit delegates to it.

seedBuiltinLengths(json) adds any of lengthX/lengthY/lengthZ not already declared as root parameters (method: "length", default "0") — opt-in for a "new model" authoring flow, never called automatically from fromJSON/evaluate (a model legitimately without these keys is meaningful elsewhere in the system).

modelSections(nodes) partitions a flat NodeCollection back into { parameters?, operations? } by lane (ParameterNode and header containers → parameters, everything else → operations) — the one partition rule Model.toJSON and any host serializing a bare collection share.

evaluateModel(json, params?, { registry, assets? }) (from evaluate.ts) is the one-call convenience: Model.fromJSON + model.evaluate(params), with json.camera copied onto the result if present.

The pull graph

Every node — ParameterNode, OperationNode, ContainerNode, ArgumentNode, ExpressionNode, OptionNode — is a BaseNode. It starts isDirty = true; node.evaluate() is a no-op that returns the cached .value when clean, otherwise runs compute(), post-processes and caches the result, and notifies "change" listeners. NodeCollection.evaluate() walks its top-level elements and calls node.evaluate() on each (containers run their own nested fold internally).

Changing an input (ParameterNode.setInput, an argument's setInput, …) dirties the node and cascades: setDirty(node) flags one node (and bumps its _epoch, so an in-flight async compute() gets invalidated); markDirty walks node._dependents (nodes whose expressions reference this one) and node.parent (the owning container/model), dirtying everything downstream transitively. Unrelated nodes are untouched — no over-invalidation.

import { Model } from "@parametron/parametric"

const model = Model.fromJSON({
    title: "chain",
    parameters: [
        { method: "number", key: "a", args: [{ key: "number", input: "10" }] },
        { method: "number", key: "b", args: [{ key: "number", input: "a * 2" }] },
        { method: "number", key: "c", args: [{ key: "number", input: "b + 5" }] },
    ],
} as any, { registry })
model.evaluate()

const [a, b, c] = model.nodes.elements as any[]
a.setInput("100")
// b.isDirty === true, c.isDirty === true — transitive, before the next evaluate()
model.evaluate()
// b.value === 200, c.value === 205

markDependentsDirty(node) is the asynchronous-settle counterpart: it dirties node's dependents and parent chain WITHOUT re-dirtying node itself (the node just produced a fresh value; what needs recomputing is whatever reads it). BaseNode.evaluate()'s async branch calls this after a compute() thenable settles.

Root fold

The model root is itself a container: top-level operations fold exactly like a nested container's list — producers concat their output into the stream, transforms process it, nested containers contribute their own fold result — in declaration order, over a stream that starts empty. A persistent FoldRunner (one per NodeCollection) memoizes per-step results across calls, so an edit that dirties one step doesn't re-run the whole root chain. nodeGeometry(node, definitions?, entitiesOverride?) renders one generator node's current value into { items, instances?, material? } — the same instanced-vs-baked split EvaluateResult.geometry and a host's per-node re-render share — and collectMaterials(nodes, ns?) collects the live material records (scope lane + adopted placeModel placements) reaching a collection. Both are exported for hosts that render outside evaluate(); the fuller render pipeline (collectRenderFrame, diffing) is documented on Render contract.

Sub-models (ModelScope)

ModelScope is the runtime scope every NodeCollection is attached to — model.scope for the top-level model, one per nested sub-model. It carries nodes, unit, parentScope (for cross-model parent.* expression reads), the static asset lanes (materials/layers/fonts), the assets Store, the registry, and definitions — the runtime group-definition map (Map<string, GroupDefinition>) that groupEntity placements reference by id, rebuilt on every evaluate.

A loadModel parameter's compute() builds a live SubModel ({ scope, collection, title }) from its embedded, instanced ModelJSON input and caches it on the node; a placeModel operation adopts one placement of that sub-model into the host scope, evaluating the sub graph and namespacing its assets and group definitions by the placement's key ("door""door:Oak") so two sub-models that each define "Oak" never collide — name is the renderer's sole dispatch key. Model.fromJSON wires scope.buildSubModel and scope.placeSubModel automatically (wireSubModelHooks, idempotent, also re-wired onto every sub scope) — a host authoring nodes directly against a bare ModelScope never needs to call these itself.

ModelScope.evaluate() pulls the root fold fresh via the evaluateRoot hook Model's constructor wires on — a no-op on a scope built standalone (not through new Model(...)).

Model references

ref.ts is a host utility, not a runtime mechanism — a loadModel input already embeds the full instanced ModelJSON, so the engine never resolves a reference during evaluation. parseRef(ref) classifies a reference string an editor is about to resolve (installing a catalog pick, opening a page's root model) into one of three schemes:

import { parseRef, encodeInlineRef } from "@parametron/parametric"

parseRef("abc-123")                        // { scheme: "platform", value: "abc-123", ... }
parseRef("https://example.com/door.json")  // { scheme: "url", value: "https://...", ... }
parseRef("data:application/json;base64,...") // { scheme: "inline", value: "<decoded JSON string>", ... }

encodeInlineRef({ title: "Door", ... })    // → "data:application/json;base64,..."

platform (a bare id) is the canonical case — every saved model uses it, resolved by the host's own DB/R2/extension-dir lookup. url is delegated to the host's fetch. inline is self-contained — value is already the decoded JSON string, resolved with no host call, so it works offline. encodeInlineRef is the reverse direction (used when bundling a sub-model reference into a portable snapshot).

Animation

animation.ts is playback outside the graph: an animated ParameterNode carries animation: { keyframes: [{ time, value }], loop? } (numeric parameters only — length/number/angle); the player just automates the input field, re-evaluating on every tick. The engine and renderers never know animation exists.

import { AnimationPlayer, animatedParameters, animationValueAt } from "@parametron/parametric"

const player = new AnimationPlayer(model)   // { nodes: NodeCollection }
player.hasAnimation                          // true if any parameter carries keyframes

player.seek(0.4)      // deterministic: sets every animated input, then model.nodes.evaluate()
player.toggleAll()    // global Play — every animated parameter, one absolute clock
player.toggleParameters(animatedParameters(model.nodes))  // Interact-tool click, relative clock
player.stop()

animationValueAt(spec, time) is the pure tween: before the first keyframe it holds the first value, after the last it holds the last, eased (easeInOut) per segment in between — deterministic, so scrubbing to any time reproduces the same geometry. animationEnd(spec) is the last keyframe's time. animatedParameters(nodes) collects every animated ParameterNode recursively (header containers are display grouping, not a boundary); animatedParametersFor(nodes, nodeKey) narrows to the parameters referenced by expressions inside one node's subtree — how an Interact-tool click on a door resolves to just that door's openAngle, not every animated parameter in the model.

AnimationPlayer tracks playing and fires onStateChange on every flip (including self-finish at the end of a non-looping run); toggleAll/ toggleParameters reverse direction automatically when every picked track's current value already sits at its last keyframe.

See also

📖 7 min read