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 NodeCollection — Model.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 === 205markDependentsDirty(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
- Schema & validation — the
ModelJSONdocument shapeModel.fromJSONparses. - Registry & contracts — the
RegistryInput/RegistryaModelruns on. - Store — where
assets(fonts, images, sub-model docs) are pre-loaded before evaluation. - Render contract —
collectRenderFrame,SceneJSON, and the diff renderer input. - Model authoring reference — the full authoring guide for expressions and node args.