The runtime node classes are the built graph itself — what Model.fromJSON produces from ModelJSON and what evaluate()/collectRenderFrame walk. Every class here (BaseNode, ParameterNode, OperationNode, ContainerNode, ExpressionNode, ArgumentNode, OptionNode, NodeCollection) lives in src/nodes/ and is exported from the package's main entry point, @parametron/parametric — not from the /nodes subpath. @parametron/parametric/nodes is a different thing: ParaShape's stock method catalog (nodeRegistry, an input to createRegistry), covered on Registry & contracts. This page is about the node instances the engine builds and pulls, regardless of which registry backs them. Per-entity method docs live on Model authoring reference and Entity API; the fold they participate in is described on Model & pull graph.
BaseNode — shared pull/dirty lifecycle
BaseNode (extends BaseObject) is the base every other class here derives from. It owns the dirty-flag pull machinery so no subclass reimplements it:
value: unknown— last computed value;error?: string— last error message (""when never evaluated,undefinedafter a clean compute).isDirty/pending— needs-recompute and async-in-flight flags.compute(): unknown— override point; the raw value beforepostCompute's sanitizer pass.evaluate(listener?)calls it (if dirty), caches the result, and notifies"change"listeners.postCompute(raw)— post-processes acompute()result through the sanitizer'sconvert/validateforvalueType; subclasses override it for a non-scalar value (ContainerNodereturns the entity array as-is,ArgumentNoderoutes through domain coercion instead).display(listener?)/isValid(listener?)/isPending(listener?)/getError(listener?)/setError(message)— the UI-facing read surface; every one of these accepts an optional change listener so a panel can subscribe and read in one call.symbol— unit suffix for the node'svalueType, resolved againstmodel.unit("°"for angle,"[mm]"-style wrapping for array types).copy()— duplicate this node in its collection, inserted right after the original, with a freshid.
Dirtying goes through two free functions exported alongside the class, never a bare node.isDirty = true write:
import { markDirty, setDirty } from "@parametron/parametric"setDirty(node) flags one node and bumps its _epoch (invalidates any in-flight async compute). markDirty(node) cascades: it dirties the node, then walks _dependents and parent recursively. Every setInput/ setEnabled on the node classes below calls one of these directly; setKey doesn't dirty the renamed node itself — it drives NodeCollection.renameReferences, which calls setInput (hence markDirty) on every expression that referenced the old key, then re-evaluates once. Either way, a UI never needs to dirty a node manually.
ParameterNode — header value binding
Runtime class for a parameters-lane node (registry return: "value" — length, number, boolean, arrayLength, loadModel, …):
const param = model.nodes.elements.find(n => (n as ParameterNode).key === "width")
param.setInput("lengthX * 2") // stores the new expression string, marks dirty (parsed lazily on next evaluate)
param.setKey("panelWidth") // renames + rewrites every referencing expressionkey: string— the identifier other expressions reference;method: string— the registry value method.input: ExpressionNode | string | Record<string, unknown>— anExpressionNodefor a scalar parameter; a raw string/object for aloadModelloader (never an expression — the embedded instancedModelJSON).min/max/step: ExpressionNode | null— numeric constraint fields (number/length/currency/angle only).options: NodeCollection<OptionNode>— select-style enum choices.args: ArgumentNode[]— static extra args beyond the primary input (e.g.loadModel'ssourceprovenance).animation: AnimationJSON | null— keyframe data; playback lives outside the graph.getInput(listener?)/setInput(value)— read/write the raw expression (or raw value, for a loader).setKey(key)— throws on a reserved/duplicate key; on success renames the key everywhere it's referenced (NodeCollection.renameReferences) and re-labels an untouched default label.newOptionDraft()— engine-owned factory for the options editor's trailing "add row" (so@parametron/uinever constructs engine nodes directly).
OperationNode — one fold step
The entity node: every operations-lane step (producer or transform) is an OperationNode. A producer (registry create) concats [...input, ...created]; a transform (registry compute) processes the whole stream.
const rect = OperationNode.fromRegistry(registry, "rectangle", {
point1: "[0,0,0]",
point2: "[100,50,0]",
}, "profile")method: string— registry method name;key: string— names the stream after this step;args: ArgumentNode[]— the parametrizing arguments.label/enabled: ArgumentNode | null— optional display label and enable gate; falsyenabledbypasses the step (input passes through unchanged, same array identity).definition: EntityDefinition | null— the registry entry backing this node.isProducer— true forclone,placeModel, or any definition withcreate.static fromRegistry(registry, method, argsMap?, key?, enabled?, label?)— build a node from a registry method + a{ argName: expression }map.setLabel(expr)/setEnabled(expr)/setKey(key)— mutate and notify"change"(setKeyalso rewrites references and rejects a reserved/duplicate key).evaluateStep(input, definitions?)— run this step over the previous step'sChainState; per-step cached on(own-args dirty, input identity). This is what the owning fold calls —OperationNodedoes not compute standalone (its inheritedevaluate()/compute()are no-ops that just return the cached value; the fold'sevaluateStepis what consumesisDirty).referenceValue()— the baked entity array an expression sees when it references this node'skey(pulls the owning fold first).
ContainerNode + FoldRunner — the container form and its fold
ContainerNode is the container half of the grammar ({ key?, label?,
enabled?, operations: [...] }) — a nested fold that runs its own children from an empty stream and concats its result into the parent like a producer. The model root follows the same fold semantics (Model.ts's rootFoldSlices), but model.nodes is a NodeCollection, not literally a ContainerNode instance — the root fold runs through its own persistent FoldRunner, not ContainerNode.compute(). Fold execution for an actual container is FoldRunner (one instance per container), which ContainerNode.compute() delegates to — it's not constructed directly by consumers.
const container = model.nodes.elements.find(n => n instanceof ContainerNode && n.key === "profile")
container.value // this container's own fold result (EntityJSON[])
container.operations // OperationNode | ContainerNode children, in ordervalue: EntityJSON[]— cached fold result;key,label,enabled— same shape asOperationNode.lane: ModelLane— which model section this container was declared in.children: NodeCollection— the child collection;operationsis a getter that filters it toOperationNode | ContainerNode.setLabel(expr)/setEnabled(expr)/setKey(key)/copy()— same contract asOperationNode's.computeUntil(stopBefore)— the fold state up to (not including) a given child, baked to concrete geometry; used for filter-arg hover previews.- Disabled (
enabledfalsy) → contributes nothing (value = []); the parent's fold simply skips a disabled child.
ExpressionNode — one evaluated expression string
Backs every input/label/min/max/step field across the other node types. Holds a raw expression string and evaluates it against the model's flat key namespace.
expression: string— the raw source;getInput(listener?)/setInput(expr)— read/write (write marks dirty).buildContext(): EvalContext— the sanctioned way to inspect the evaluation scope this expression sees (UI must not reach into private engine internals for this).ownerNodes— the owningNodeCollection, orundefinedif not yet mounted.requestEvaluate()— re-evaluate the whole owning graph; the UI calls this after committing a field edit (blur / row edit) so dependents and geometry refresh.
compute() parses expression with jsep and evaluates it against every model key (parameters, operation/container keys reachable at any nesting depth, plus a parent.* scope for a loadModel sub-model's host-parameter overrides) — each key read through a getter that lazily registers the reading expression as a dependent. A single-cardinality entity reference auto-unwraps (Entity[] of length 1 → Entity); a return: "entity" node (mixed kinds — every container, plus generic operations) never unwraps.
ArgumentNode — one operation argument
ArgumentNode extends ExpressionNode, adding the registry argName this value is bound to plus author constraints (min/max/step/options) for a constrained arg slot:
const point1 = rect.args.find(a => a.argName === "point1")
point1.expression // "[0,0,0]"
point1.evaluate() // [0, 0, 0]argName: string— the registry arg key.min/max/step: ExpressionNode | null,options: NodeCollection<OptionNode>— same per-arg constraint shape a parameter's primary arg carries.applyConstraints(json)— restore constraint fields from a persistedArgumentJSON.toInput(): ArgumentJSON— serialize as{ key, input, ...constraints }.newOptionDraft()— same engine-owned draft factory asParameterNode's.
postCompute is overridden: an argument's raw value routes through the domain coercion pipeline (domains/parameter.ts converters + entity/asset resolution — string → font/material lookup, group flattening, atom-kind matching for a curveEntity[] | faceEntity[]-style union arg) instead of the base sanitizer convert/validate pipeline, because arg values are JSON shapes (bare arrays, entity objects), not sanitizer-typed scalars.
OptionNode — one select choice
OptionNode extends ExpressionNode, adding a plain label: string display string next to the expression. Used for ParameterNode.options and ArgumentNode.options (enum-style value/arg slots). valueType reads through to the parent's valueType.
NodeCollection — build, mutate, and walk the graph
NodeCollection<T extends BaseNode = BaseNode> extends BaseCollection<T> is the ordered child collection every container-shaped scope holds (model.nodes is the root one; ContainerNode.children is a nested one). It's both the JSON→node loader and the mutation API a UI drives.
import { Model } from "@parametron/parametric"
const model = Model.fromJSON(modelJson, { registry })
const rect = model.nodes.addOperation("rectangle") // auto-keyed "rectangle2" if "rectangle1" exists
const group = model.nodes.addContainer()
const child = group.children.addOperation("rectangle") // key still model-wide unique
const width = model.nodes.addEntry("number") // ParameterNode, auto-keyed "number1"registry: Registry | undefined— resolved through the owner chain.createFromJSON(json, registry?, lane?)— deserialize oneNodeJSON(operation or container form) into the matching typed node; dispatches onisContainerJSON→ContainerNode, elseregistry.value(method)→ParameterNode, elseOperationNode.fromRegistry.fromJSON(items, registry?, lane?)— bulkcreateFromJSON+add.addOperation(method, argsMap?, key?)/addContainer(key?)/addEntry(method, key?)— construct + add + auto-key; the mutation surface a UI's "add node" actions call.rootNodes()— the model's root collection, walked up viaowner.parent— key operations always check uniqueness against this, not just the local subtree.keyExists(key, except?)/generateKey(baseName)— model-wide key uniqueness check and"base1","base2", … generation.renameReferences(oldKey, newKey)— rewrite every expression in the model that referencesoldKeyas an identifier, then re-evaluate once; called by everysetKey()above.getMapKey()—{ key: node }map of this collection's direct elements (used to build anExpressionNode's evaluation context).evaluate()— evaluate every direct element.traverse(fn)— walk every node reachable from this collection, including nested container children and each parameter/argument'smin/max/step/optionssub-nodes.
Where this fits
These classes are what Model.fromJSON builds and what evaluate() / collectRenderFrame pull — see Model & pull graph for the fold and dirty-propagation contract, Registry & contracts for the RegistryInput/NodeDefinition shapes OperationNode.fromRegistry and ParameterNode read, and Schema & validation for the ModelJSON/NodeJSON document shape these nodes are built from. @parametron/ui's panels are the reference consumer of this API — see UI overview & chrome.