Parametron

The engine has no built-in CAD catalog — every method a model can call (rectangle, curveExtrude, length, …) comes from a Registry you build and pass in. This page documents RegistryInput, the Registry class, the chain helpers a node's compute calls, and how to register your own node types. For the stock catalog itself see Node catalog; for the per-entity API surface see Entity API.

The registry is the engine's input

import { createRegistry, Model } from "@parametron/parametric"
import { nodeRegistry } from "@parametron/parametric/nodes" // ParaShape's stock catalog

const registry = createRegistry(nodeRegistry)
const model = Model.fromJSON(json, { registry })

createRegistry(input) merges the engine's own builtins (CORE_NODES) with one flat RegistryInput into a validated Registry instance. There is no module-global mutable registry — build one per composition; two registries can coexist in the same process. createRegistry() with no argument still works (builtins only: scalar parameters, clone/entities/placeModel, groupExplode/looseToGroup/makeGroup).

RegistryInput — what you supply

type RegistryInput = {
    nodes?: OperationDefinition[]
    namespaces?: FunctionNamespaces        // PascalCase expression namespaces (Point.*, Curve.*, …)
    types?: TypeDef[]                      // extra value types (display + coerce hook only)
    builtinParameters?: string[]           // reserved parameter keys auto-seeded per model
    builtinParameterMethods?: Record<string, string>
}
  • nodes — your OperationDefinitions, appended after CORE_NODES. List order is display/catalog order — there is deliberately no order field.
  • namespaces — bare PascalCase functions usable inside expressions (Curve.length(profile)); names become reserved (no node key may shadow one).
  • types — extra value types for ArgDef.type, inert: display metadata plus an optional coerce hook — they never join the core dimension lattice (unit inference stays fixed to the built-in types in typeRegistry, also exported from the package root).
  • builtinParameters / builtinParameterMethods — e.g. ParaShape's own registry reserves lengthX/lengthY/lengthZ and requires the length value method to claim them. A builtin key absent from builtinParameterMethods is reserved outright — no node may ever take it.

OperationDefinition — value vs entity

One definition kind, discriminated by return:

type OperationDefinitionBase = {
    method: string        // dispatch + uniqueness key, globally unique across the WHOLE registry
    defaultKey: string     // camelCase base for auto-generated node keys ("arc" → arc1, arc2…)
    args?: ArgDef[]
    attributes?: Record<string, unknown>  // opaque frontend metadata — engine never reads it
}

type ValueDefinition = OperationDefinitionBase & {
    return: "value"
    compute?: (args: ComputeArgs, context: ComputeContext) => unknown | Promise<unknown>
}

type EntityDefinition = OperationDefinitionBase & {
    return: "curveEntity" | "faceEntity" | "pointEntity" | "entity"
    create?: (args: ComputeArgs, context: ComputeContext) => ChainState | Promise<ChainState>
    compute?: (input: ChainState, args: ComputeArgs, context: ComputeContext) => ChainState | Promise<ChainState>
}

type OperationDefinition = ValueDefinition | EntityDefinition

A ValueDefinition binds a key to a value and lives in the model's parameters header (scalar parameters — length, number, … — omit compute; their value is just the coerced primary arg). An EntityDefinition is one step in the operations fold; return names the entity kind it produces or passes through ("entity" = generic/mixed).

ArgDef (one per argument): { key, type, default?, min?, max?, step?, attributes? }type is "length", "faceEntity[]", "point[] | curveEntity[]", "fn", … ; default is an expression string used when a model omits the arg; attributes is opaque UI metadata (label, tooltip, options…).

ComputeArgs is Record<string, unknown> — evaluated + coerced arg values keyed by ArgDef.key. ComputeContext carries store (the session asset registry — fonts/materials/images/models) and definitions (the model's group-definition map, for nested-group resolution).

create vs compute — producer vs transform

An EntityDefinition declares exactly one of the two — the Registry constructor throws if both or neither are present:

  • create — a producer. The engine concats [...input, ...create(args, ctx)]; the created part is cached on the node's own args (an upstream input change never re-runs it).
  • compute — a transform. Receives the whole stream (ChainState = EntityJSON[]) and returns the next one — map (1→1), multiply (n→m>n), or reduce (n→m<n).

Both may return a value directly or a Promise of one — the async machinery (pending flag, cancellation epochs, dependent cascade) is exercised end to end in src/__tests__/asyncCompute.test.ts, which builds a local test-only RegistryInput with deferred producer/transform functions — a useful template if your create/compute does real I/O.

Immutability is a contract rule: create/compute must never mutate their input — the per-step chain cache keys on object identity, and mutation makes it silently stale.

Chain helpers

Exported from the package root (registry/helpers.ts) for use inside a transform's compute:

function explodeGroups(state: ChainState, nested: boolean, definitions?: DefinitionMap): ChainState
function applyGeometry(state: ChainState, op: (entities: EntityJSON[]) => unknown[]): ChainState
function entityOp(fn: (...a: unknown[]) => unknown, argValues: unknown[], argTypes: string[]):
    (entities: EntityJSON[]) => unknown[]

applyGeometry deep-clones the plain (non-group) entities, runs op over them, re-stamps sourceKey, and passes nested groupEntity placements through untouched (dissolve a group first with groupExplode to reach the geometry inside). entityOp adapts a domain function (from @parametron/parametric/compute) plus ordered arg values into the (entities) => unknown[] shape applyGeometry expects — per-entity by default, or once over the whole array when flagged wholeArray; function-valued args resolve as (entity, index) => value callbacks, except args declared "function"-typed (predicates), which pass through untouched.

Builtins (CORE_NODES)

Every Registry carries the engine's own CORE_NODES (also exported from the package root), regardless of the caller's RegistryInput: scalar value methods (length, number, angle, boolean, color, text, arrayLength, function), the loadModel async loader, the structural producers clone / entities / placeModel, and the structural transforms groupExplode / looseToGroup / makeGroup. Reusing one of these method names in your own RegistryInput.nodes throws a duplicate-registration error.

The Registry class

class Registry {
    readonly nodes: OperationDefinition[]              // builtins first, then your nodes — registration order
    readonly namespaces: FunctionNamespaces
    readonly types: TypeDef[]
    readonly reservedNames: Set<string>
    readonly builtinParameters: Set<string>
    readonly builtinParameterMethods: Map<string, string>

    operation(method?: string): OperationDefinition | undefined
    value(method?: string): ValueDefinition | undefined     // value-lane only
    entity(method?: string): EntityDefinition | undefined   // entity-lane only
    has(method: string): boolean
    methods(lane?: "value" | "entities"): string[]
    defaultKeyOf(method?: string): string | null
    argDefs(method: string): ArgDef[]
    argKeys(method: string): string[]
    argTypes(method: string): Record<string, string>
    typeOf(name: string): TypeDef | undefined
    isReservedKey(key: string): boolean
}

registry.nodes is itself the serializable catalog view a docs generator or UI menu renders straight off (create/compute are just unused function properties for a consumer that only reads attributes/args). Construction validates eagerly and throws on the first problem: a missing method/return, a non-camelCase defaultKey, a duplicate method, a duplicate arg key within one definition, non-JSON-serializable attributes, an EntityDefinition declaring both or neither of create/compute, or an arg type referencing an unknown type name.

Type registry

RegistryInput.types extends the merged type table (registry.types / registry.typeOf(name)), built from the package's own TypeDef[] (typeRegistry, plus the typeColors name→theme-family map — both exported from the package root). Each TypeDef is { name, kind, color, note?, shape?, coerce?, attributes? }: kind is "sceneEntity" | "namespace" | "value", color is one of the 10 Domphy theme families (resolved by a UI, never a literal hex), and the optional coerce(value, { store }) hook lets a type read from the Store while coercing an arg value. An extra type is display + coercion metadata only — it never joins the core dimension lattice.

The reference node registry (@parametron/parametric/nodes)

ParaShape's own default catalog ships as an optional subpath, never pulled in unless imported:

import {
    nodeRegistry,          // RegistryInput — createRegistry(nodeRegistry)
    NODE_REGISTRY,          // raw display catalog (docs/llms generation)
    FUNCTION_REGISTRY,      // Point.*/Curve.*/… namespace function catalog
    nodeNamespaces,         // the wired FunctionNamespaces object
} from "@parametron/parametric/nodes"

nodeRegistry is built by wiring NODE_REGISTRY's display entries onto the standalone functions in @parametron/parametric/compute — the same no-engine-coupling functions your own create/compute can reuse. See Node catalog for the method list and Compute for the pure-geometry functions.

Registering a custom node type

A minimal producer (shape follows the create-only producers already in the engine's own CORE_NODES catalog — src/registry/builtins.ts — and the test-only registry in src/__tests__/asyncCompute.test.ts; a transform follows the compute signature documented above):

import { createRegistry, type RegistryInput } from "@parametron/parametric"

const myRegistry: RegistryInput = {
    nodes: [
        {
            method: "myPoint",
            defaultKey: "point",
            return: "pointEntity",
            args: [{ key: "x", type: "number", default: "0" }],
            create: (args) => [{ id: crypto.randomUUID(), geometry: [args.x as number, 0, 0] }],
            attributes: { category: "Custom", label: "My Point" },
        },
    ],
}

const registry = createRegistry(myRegistry)

To merge with the stock catalog instead of replacing it, concatenate nodeRegistry.nodes with your own rather than passing myRegistry alone. Every method must stay globally unique across the merged set — the Registry constructor throws on a collision, catching a name clash at composition time rather than at first evaluation.

See also

  • Node catalog — the stock method list this registry produces.
  • Compute — the pure-geometry functions a create/compute typically wires to.
  • Model authoring reference — how a ModelJSON document references registry methods by name.
  • Store — the async pre-load layer (ComputeContext.store) registry computes read from synchronously.
📖 6 min read