Node registry — create your own nodes
A node is one entry in a RegistryInput's nodes[] list: a definition (method, args, attributes, return) plus EXACTLY ONE of create (a producer — adds to the stream) or compute (a transform — processes the stream). Pass it to createRegistry() and every frontend — builder menus, validation, docs — picks it up automatically. No engine code changes.
A complete node registry: producer + transform
import { createRegistry, Model, group, applyGeometry, entityOp } from "@parashape/parametric"
import type { RegistryInput } from "@parashape/parametric"
import { curve } from "@parashape/parametric/compute" // 136 ready-made functions: /dev/compute
export const myNodes: RegistryInput = {
nodes: [
// 1 · A PRODUCER — adds geometry to the stream from args (engine
// concats: output = [...input, ...create(args)])
{
method: "star", // globally unique method name
defaultKey: "star", // auto-key base: star1, star2, …
return: "curveEntity",
args: [
{ key: "radius", type: "length", default: "100",
attributes: { label: "Radius" } },
{ key: "points", type: "number", default: "5",
attributes: { label: "Points" } },
],
attributes: { category: "CurveEntity", label: "Star" }, // UI menu placement
create: ({ radius, points }) =>
group([curve.polygon(Number(radius), Number(points))]),
},
// 2 · A TRANSFORM — same node kind, plus an implicit input (the fold
// state so far) — processes the stream instead of adding to it
{
method: "starJitter",
defaultKey: "jitter",
return: "entity",
args: [{ key: "amount", type: "length", default: "5" }],
attributes: { category: "CurveEntity", label: "Jitter" },
compute: (input, { amount }, ctx) =>
applyGeometry(input, entityOp(myJitterFn, [amount], ["length"])),
},
],
// 3 · Optional: expression namespaces (Star.area(...)), inert types, builtin params
namespaces: {},
}
// 4 · Compose and run — the model JSON can now use "star" like any built-in method
const registry = createRegistry(myNodes)
const model = Model.fromJSON(json, { registry })
const scene = model.toScene()create/compute always return a plain EntityJSON[] (ChainState) — the engine concats a producer's result onto the fold, a transform replaces it wholesale. A transform is a producer plus an implicit input — the fold state so far. Never mutate the input (the per-step cache keys on identity). The chain helpers (applyGeometry / entityOp / explodeGroups) cover the common shapes — see Architecture.
Registry API
| API | Returns | Description |
|---|---|---|
createRegistry(input) | Registry | Merge the engine's own builtins + your RegistryInput (nodes/namespaces/types/builtinParameters); validates method uniqueness, arg types, serializable attributes. Throws early on conflicts. |
Model.fromJSON(json, { registry }) | Model | Load a model against the registry (per-model — no globals). |
store.load(bundle) | Promise<void> | Async phase: scan raw JSON for loader parameters (loadModel/loadFont/loadImage) and pre-register assets. |
validateModelJSON(json, registry) | void (throws) | Structural (Zod) + registry pass: unknown methods, wrong arg keys, lane placement (value methods in parameters, entity methods in operations), reserved keys. |
registry.nodes | OperationDefinition[] | Every definition in registration order — feeds UI menus, docs tables, llms.txt (attributes/args are what a display consumer reads; create/compute are simply ignored). |
registry.methods(lane?) | string[] | All method names, optionally filtered by lane ("value" | "entities"). |
registry.operation(method) | OperationDefinition | undefined | Look up one definition, any lane. |
registry.value(method) / registry.entity(method) | ValueDefinition | EntityDefinition | undefined | Look up one definition, narrowed to its own lane (undefined if `method` is the other lane). |
The full contract (source of truth)
packages/parametric/src/registry/contract.ts verbatim, one section per type — every type a node author needs (RegistryInput, NodeDefinition, ArgDef, ChainGroup, ComputeContext), with the layer rules in the comments:
Overview
/**
* Node contract — the public authoring surface for EXTERNAL node sets. The
* engine (this package) owns graph semantics and takes a flat RegistryInput
* (operation definitions + expression namespaces + extra value types) that
* gets merged with the engine's own builtins into a validated Registry.
*
* v21 — ONE definition kind: the OPERATION. Every method is a node in the one
* recursive NodeJSON grammar; `return` names its lane + kind:
*
* return: "value" — binds key → value; lives in
* the model's `parameters` header. Optional `compute(args, ctx)` for
* resource declarations (createMaterial/createLayer); scalar parameters
* omit it (their value is the evaluated primary arg); async asset loading
* (loadModel/loadFont/loadImage) is the Store's own pre-graph phase.
*
* return: "curveEntity" | "faceEntity" | "pointEntity" | "entity"
* — an entity operation in the `operations` fold. EXACTLY ONE of:
* create(args, ctx) — PRODUCER: the engine concats
* [...input, ...create(args, ctx)] and caches
* the created part on the node's own args
* (input changes never re-run it).
* compute(input, args, ctx) — TRANSFORM: processes the stream (the old
* modifier contract, unchanged).
*
* The CONTAINER form ({ operations: [...] }) is grammar, engine-owned — it has
* no method and never appears in a registry.
*
* Three layers, strictly separated:
* 1. ASYNC (before the graph runs) — the Store scans RAW model JSON for
* the engine loader parameters and pre-loads every asset.
* 2. COMPUTE (caller-supplied) — MAYBE-ASYNC: `create`/`compute` may
* return a value directly or a Promise of it (core/maybeAsync.ts).
* 3. NODE VALUE (engine-owned) — an operation's value = the stream
* after it; a container's value = its own fold result; a value node's
* value = its evaluated binding. All entity values are EntityJSON[].
*
* IMMUTABILITY IS A CONTRACT RULE: compute/create must never mutate their
* input; unchanged parts should share references. The per-step chain cache
* keys on object identity — mutation makes it silently stale.
*
* `attributes` is an OPAQUE bag the engine never reads — it is the contract
* between node authoring and a frontend (category, label, tooltip, icon…).
* Anything that affects how the graph runs is a named structural field instead.
*/Uniform compute currency
export type ChainState = EntityJSON[]
/** Evaluated + coerced arg values, keyed by ArgDef.key. */
export type ComputeArgs = Record<string, unknown>
/**
* - `store` — session asset registry (fonts/materials/images/models), immutable during eval.
* - `definitions` — model definition registry (id → shared geometry) for resolving nested groups.
*/
export type ComputeContext = {
store?: Store
definitions?: DefinitionMap
}Arg definition
/**
* - `type` — value type from the merged type registry (core ∪ node registry). Supports the
* compound forms the coercer understands: `"length"`, `"faceEntity[]"`,
* `"point[] | curveEntity[]"`, `"fn"`.
* - `default` — default expression string used when a model omits the arg.
* - `min`/`max`/`step` — author constraints, persisted per-instance in ModelJSON
* (ArgumentJSON), so they are structural, not display metadata.
* - `attributes` — opaque frontend metadata (label, tooltip, options,
* control hints…); the engine only carries it onto the runtime ArgumentNode.
*/
export type ArgDef = {
key: string
type: string
default?: string
min?: string
max?: string
step?: string
attributes?: Record<string, unknown>
}
/**
* Template for a node's dynamically-added extra arguments — beyond its static
* `args[]`, a node INSTANCE may grow additional args of this one shape at
* runtime (e.g. loadModel's `replace*` overrides, one per promoted sub-model
* parameter). Same fields as `ArgDef` minus `key`: an added arg's key is
* auto-generated from `baseKey` + a model-wide-unique increment ("replace" →
* replace1, replace2…), the same convention `defaultKey` uses for node keys.
*/
export type DynamicArgDef = Omit<ArgDef, "key"> & {
baseKey: string
}Operation definition
/** Entity kinds an operation may return; "entity" = generic/mixed (a chain
* step's kind is free to change — this is documentation + no-unwrap marker). */
export type EntityReturn = "curveEntity" | "faceEntity" | "pointEntity" | "entity"
/**
* - `method` — dispatch + uniqueness key, globally unique across the WHOLE registry.
* - `defaultKey` — friendly base for auto-generated node keys (`"arc"` → arc1, arc2…).
* - `return` — the method's lane + kind (see module doc).
* - `dynamicArg` — when present, an instance of this method may grow extra
* args of this shape at runtime (see `DynamicArgDef`).
* - `attributes` — opaque frontend metadata; never read by the engine.
*/
type OperationDefinitionBase = {
method: string
defaultKey: string
args?: ArgDef[]
dynamicArg?: DynamicArgDef
attributes?: Record<string, unknown>
}
export type ValueDefinition = OperationDefinitionBase & {
return: "value"
/** Value compute for resource declarations (createMaterial/createLayer).
* Scalar parameters omit it — their value is the evaluated primary arg. */
compute?: (args: ComputeArgs, context: ComputeContext) => unknown | Promise<unknown>
}
export type EntityDefinition = OperationDefinitionBase & {
return: EntityReturn
/** PRODUCER form — the engine concats [...input, ...create(args, ctx)]. */
create?: (args: ComputeArgs, context: ComputeContext) => ChainState | Promise<ChainState>
/** TRANSFORM form — processes the stream (exactly one of create/compute). */
compute?: (input: ChainState, args: ComputeArgs, context: ComputeContext) => ChainState | Promise<ChainState>
}
export type OperationDefinition = ValueDefinition | EntityDefinition
export function isValueDefinition(def: OperationDefinition): def is ValueDefinition {
return def.return === "value"
}Registry input
export type FunctionNamespaces = Record<string, Record<string, (...args: unknown[]) => unknown>>
/**
* The flat input `createRegistry(input)` merges onto the engine's own
* builtins. List order = display/catalog order (there is deliberately no
* `order` metadata).
*
* - `namespaces` — PascalCase expression namespaces (Point.*, Vector.*, …).
* Names become reserved (node keys cannot shadow them).
* - `types` — extra value types. These are INERT: display + coerce hook
* only — they never join the core dimension lattice (unit inference).
* - `builtinParameters` — reserved parameter keys auto-seeded per model
* (e.g. lengthX/lengthY/lengthZ).
* - `builtinParameterMethods` — for each `builtinParameters` key, the
* value method required to legitimately claim it (e.g.
* `{ lengthX: "length", material: "createMaterial" }`). A builtin key
* absent here is reserved outright: no node may ever take it.
*/
export type RegistryInput = {
nodes?: OperationDefinition[]
namespaces?: FunctionNamespaces
types?: TypeDef[]
builtinParameters?: string[]
builtinParameterMethods?: Record<string, string>
}