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— yourOperationDefinitions, appended afterCORE_NODES. List order is display/catalog order — there is deliberately noorderfield.namespaces— bare PascalCase functions usable inside expressions (Curve.length(profile)); names become reserved (no nodekeymay shadow one).types— extra value types forArgDef.type, inert: display metadata plus an optionalcoercehook — they never join the core dimension lattice (unit inference stays fixed to the built-in types intypeRegistry, also exported from the package root).builtinParameters/builtinParameterMethods— e.g. ParaShape's own registry reserveslengthX/lengthY/lengthZand requires thelengthvalue method to claim them. A builtin key absent frombuiltinParameterMethodsis 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 | EntityDefinitionA 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/computetypically wires to. - Model authoring reference — how a
ModelJSONdocument references registry methods by name. - Store — the async pre-load layer (
ComputeContext.store) registry computes read from synchronously.