Parametron

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, undefined after a clean compute).
  • isDirty / pending — needs-recompute and async-in-flight flags.
  • compute(): unknown — override point; the raw value before postCompute's sanitizer pass. evaluate(listener?) calls it (if dirty), caches the result, and notifies "change" listeners.
  • postCompute(raw) — post-processes a compute() result through the sanitizer's convert/validate for valueType; subclasses override it for a non-scalar value (ContainerNode returns the entity array as-is, ArgumentNode routes 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's valueType, resolved against model.unit ("°" for angle, "[mm]"-style wrapping for array types).
  • copy() — duplicate this node in its collection, inserted right after the original, with a fresh id.

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 expression
  • key: string — the identifier other expressions reference; method: string — the registry value method.
  • input: ExpressionNode | string | Record<string, unknown> — an ExpressionNode for a scalar parameter; a raw string/object for a loadModel loader (never an expression — the embedded instanced ModelJSON).
  • 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's source provenance).
  • 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/ui never 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; falsy enabled bypasses the step (input passes through unchanged, same array identity).
  • definition: EntityDefinition | null — the registry entry backing this node.
  • isProducer — true for clone, placeModel, or any definition with create.
  • 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" (setKey also rewrites references and rejects a reserved/duplicate key).
  • evaluateStep(input, definitions?) — run this step over the previous step's ChainState; per-step cached on (own-args dirty, input identity). This is what the owning fold calls — OperationNode does not compute standalone (its inherited evaluate()/compute() are no-ops that just return the cached value; the fold's evaluateStep is what consumes isDirty).
  • referenceValue() — the baked entity array an expression sees when it references this node's key (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 order
  • value: EntityJSON[] — cached fold result; key, label, enabled — same shape as OperationNode.
  • lane: ModelLane — which model section this container was declared in.
  • children: NodeCollection — the child collection; operations is a getter that filters it to OperationNode | ContainerNode.
  • setLabel(expr) / setEnabled(expr) / setKey(key) / copy() — same contract as OperationNode's.
  • computeUntil(stopBefore) — the fold state up to (not including) a given child, baked to concrete geometry; used for filter-arg hover previews.
  • Disabled (enabled falsy) → 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 owning NodeCollection, or undefined if 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 persisted ArgumentJSON.
  • toInput(): ArgumentJSON — serialize as { key, input, ...constraints }.
  • newOptionDraft() — same engine-owned draft factory as ParameterNode'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 one NodeJSON (operation or container form) into the matching typed node; dispatches on isContainerJSONContainerNode, else registry.value(method)ParameterNode, else OperationNode.fromRegistry.
  • fromJSON(items, registry?, lane?) — bulk createFromJSON + 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 via owner.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 references oldKey as an identifier, then re-evaluate once; called by every setKey() above.
  • getMapKey(){ key: node } map of this collection's direct elements (used to build an ExpressionNode'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's min/max/step/ options sub-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.

📖 9 min read