ParaShape

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

APIReturnsDescription
createRegistry(input)RegistryMerge 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 })ModelLoad 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.nodesOperationDefinition[]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 | undefinedLook up one definition, any lane.
registry.value(method) / registry.entity(method)ValueDefinition | EntityDefinition | undefinedLook 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>
}