Parametron

packages/parametric/src/schema/ is the type layer for ModelJSON — plain TypeScript types plus the Zod schemas that check them structurally. validate.ts builds on top of it with the runtime passes: structural (Zod), registry (method existence, lane placement, arg keys), expression syntax (the engine's own parser), and a headless-friendly diagnosis entry point that never throws. This page covers shape, not authoring semantics — the full authoring guide (expression scope, per-method arg tables, container/fold rules) is Model authoring reference; per-entity fields are Entity API.

ModelJSON — the document root

export type ModelJSON = {
    id?: string
    title: string
    camera?: CameraJSON
    materials?: MaterialJSON[]
    layers?: LayerJSON[]
    fonts?: FontJSON[]
    parameters?: NodeJSON[]
    operations?: NodeJSON[]
    unit: UnitConfig
}

parameters is the header (named VALUE bindings, order display-only); operations is the body (one flat fold, executed top-down). materials/layers/fonts are static named-record lanes, never graph nodes — see Scene entities & assets below. ModelSchema is the Zod counterpart (z.ZodType<ModelJSON>), plus three sibling schemas for the asset lanes:

import { FontAssetSchema, LayerAssetSchema, MaterialAssetSchema, ModelSchema } from "@parametron/parametric"

Each asset schema requires a non-empty name and rejects duplicate names within its own lane (materials, layers, fonts each get their own uniqueness check — a material and a layer may share a name, two materials may not). unit.length defaults to "mm" if omitted; unit.area/unit.volume/unit.mass are optional.

modelNodesOf(json: unknown): NodeJSON[] returns the load-order concat of both node arrays (parameters first, then operations) — works on loose, pre-validation documents. ModelLane = "parameters" | "operations" names which array a node lives in; it drives both lane validation and the diagnostic output below.

CameraSchema/CameraJSON (type?, position, target, zoom?) is the optional saved-view field on the document; it carries no expressions.

NodeJSON — operation vs container

One recursive shape, two forms discriminated by a required field:

export type OperationJSON = {
    key?: string
    label?: string
    enabled?: string       // falsy → this node is bypassed / emits nothing
    method: string
    args?: ArgumentJSON[]
    animation?: AnimationJSON   // numeric parameters only
}

export type ContainerJSON = {
    key?: string
    label?: string
    enabled?: string
    operations: NodeJSON[]
}

export type NodeJSON = OperationJSON | ContainerJSON
import { isContainerJSON } from "@parametron/parametric"

isContainerJSON(node) // true iff node.operations is an array

method is a plain string, not a Zod enum — validity is registry-dependent (MethodSchema = z.string().min(1)), so structural parsing never rejects an unknown method; that's the registry pass's job (below). Whether a given method is a value binding (goes in parameters) or an entity operation (goes in operations) is decided by the registry's return kind for that method, not by the schema.

AnimationJSON (keyframes: AnimationKeyframeJSON[], loop?: boolean) is a keyframe track on a single numeric parameter — time in seconds on the model's shared play clock, value in the parameter's own input space. normalizeAnimation(spec) returns a copy with keyframes sorted by time (applied on load and on every editor commit). The corresponding Zod schemas — NodeSchema, OperationSchema, ContainerSchema, AnimationSchema — are all .strict(): an unrecognized field on a node is a validation error, not silently dropped.

Leaf primitives

schema/primitives.ts has no recursive references — every other schema module builds on it.

import { ArgumentSchema, exprSchema, keySchema, MethodSchema, OptionSchema } from "@parametron/parametric"
  • keySchema (aliased as generatorKeySchema) — a node/parameter key: ^[a-zA-Z_][a-zA-Z0-9_]*$, and not in the reserved-name set (below).
  • exprSchema — any non-empty string; syntax is checked later by validateExpressions, not here.
  • LengthUnitSchema/AreaUnitSchema/VolumeUnitSchema/MassUnitSchema — enums backing UnitConfig (LengthUnit = "mm" | "cm" | "m" | "in" | "ft", etc.).
  • ArgumentSchema (ArgumentJSON) — one { key, input, label?, min?, max?, step?, options? } entry. input is string | Record<string, unknown> — an object is legal only as loadModel's primary arg (an embedded instanced ModelJSON); everywhere else it must be an expression string. min/max/step/options are only meaningful on numeric parameter args (registry pass rejects them elsewhere).
  • OptionSchema (OptionJSON) — one { label, input } preset choice inside args[].options.

Constants

schema/constants.ts has no Zod dependency — raw data other modules key off.

import { isAnimatableMethod, isLoadMethod, KEY_REGEX, PARAM_PRIMARY_ARG, RESERVED_KEY_SET, VALUE_RETURN } from "@parametron/parametric"
  • VALUE_RETURN = "value" — the registry return string that marks a method as a value binding (vs. an entity operation). Lane placement checks compare against this constant, nowhere else.
  • RESERVED_KEY_SETJS_RESERVED (Math, Number, isNaN, …) ∪ JS_KEYWORDS (break, class, null, …); a node key can't shadow any of them. Registry-level reservations (computation namespaces, builtin parameter keys) are a separate, registry-aware check — see Registry & contracts.
  • PARAM_PRIMARY_ARG — the primary arg key for each engine-builtin parameter method (length"length", arrayLength"lengths", loadModel"input", …) — used when serializing a ParameterNode's single scalar arg.
  • LOAD_KIND_BY_METHOD / isLoadMethod(method)loadModel is the only loader parameter left; its primary input is a URL/embedded document, not an expression, so both collectNodeExpressions (validate.ts) and the expression pass skip it.
  • PARAM_METHOD_VALUE_TYPE / ANIMATABLE_VALUE_TYPES / isAnimatableMethod(method) — a numeric parameter (number/length/angle) may carry animation; arrayLength's underlying value type is length[], so it's excluded even though its method name isn't in the animatable set directly.

Scene entities & assets

schema/graph.ts defines the entity JSON shapes and the static asset lanes. An entity is { id, geometry, visible?, layer?, material?, label?, sourceKey? } (EntityBase) with no type tag — kind is sniffed from the shape of geometry:

import { entityKind, isCurve, isFace, isGroup, isPoint } from "@parametron/parametric"

isPoint(e)  // geometry is an array   → PointJSON  (geometry: Point3D = [x,y,z])
isCurve(e)  // geometry has controlPoints → CurveJSON (geometry: NurbsCurveJSON)
isFace(e)   // geometry has loops     → FaceJSON  (geometry: { loops: Point3D[][], uvMatrix?: Transformation })
isGroup(e)  // has a `definition` id, no `geometry` → GroupEntity
entityKind(e) // "pointEntity" | "curveEntity" | "faceEntity" | "groupEntity"

GroupEntity (Instance & { id, definition }) is one placement of a GroupDefinition ({ id, entities: EntityJSON[] }) — SketchUp's component-definition/instance split: the shared child entities live once in the model's runtime definitions map, and each GroupEntity just carries a transformation plus per-instance overrides (material/layer/name/visible). EntityJSON = CurveJSON | FaceJSON | PointJSON | GroupEntity.

Transformation = number[] (a flat 4×4) has two incompatible conventions depending on use — Instance.transformation is column-major (Three.js/SketchUp convention, translation at indices 12/13/14); FaceGeometry.uvMatrix is row-major (translation at 3/7/11). Check which field you're reading before indexing into it.

MaterialJSON/LayerJSON/FontJSON are the asset lane record shapes (name is the sole identity, unique per lane) — MaterialJSON.texture is a URI (https:// or data:), never a separate asset id; FontJSON.url is where the Store pre-loads the .ttf/.otf from. UnitConfig ({ length, area?, volume?, mass? }) is the runtime shape backing ModelJSON.unit. RawModelJSON is the loose loader shape (parameters/operations typed unknown[]) used before ModelSchema has validated a document.

Type catalog

schema/types.ts holds TYPE_REGISTRY, the CORE type catalog — the value/entity kinds the engine's own machinery depends on (scene-entity kinds, primitives, the dimension system). A RegistryInput may add more types on top; those are inert display-only entries that never join the core dimension lattice (see Registry & contracts).

export type TypeDef = {
    name: string
    kind: "sceneEntity" | "namespace" | "value"
    color: ThemeColorFamily
    note?: string
    shape?: string
    coerce?: (value: unknown, context: { store?: Store }) => unknown
    attributes?: Record<string, unknown>
}

kind sorts a type into one of three families: sceneEntity (curveEntity/faceEntity/pointEntity/groupEntity), namespace (entity/material/layer — generic grouping, not a concrete value), or value (number/length/angle/vector/point/plane/axis/curve/face/surface/color/string/function/model/font/fn/…). color is one of THEME_COLOR_FAMILIES (ThemeColorFamily) — a Domphy theme tone, resolved reactively by a UI consumer, never a literal hex. coerce is an optional per-type hook ((value, { store }) => unknown) the arg coercer runs for values of that type — none of the core TYPE_REGISTRY entries set one today; it exists for a RegistryInput's own types extensions (registry-level, inert on the core catalog).

Scene output (SceneJSON)

schema/scene.ts is the return type of model.toScene(): { nodes: SceneNode[] }, a pure geometry tree with no asset bodies (materials/layers referenced by string name, resolved from the Store at render time). SceneEntity = Curve | Mesh | Point — a tessellated variant of the three scene-entity kinds (Mesh.positions/indices/normals/uvs/groups: MeshGroup[] for a face, Curve.points as a polyline, Point.position). This is one of two render contracts the engine exposes; the full one-shot vs. incremental comparison is Render contract.

Validation

validate.ts layers four independently-callable passes on top of the schema. All of them are synchronous.

Structural — plain Zod, no registry needed:

import { parseModel, safeParseModel } from "@parametron/parametric"

const model = parseModel(raw)              // throws z.ZodError on failure
const result = safeParseModel(raw)         // { success: true, data } | { success: false, error }

Structural + registry, combined and thrown as one error:

import { createRegistry, validateModelJSON } from "@parametron/parametric"
import { nodeRegistry } from "@parametron/parametric/nodes"

const registry = createRegistry(nodeRegistry)
validateModelJSON(model, registry) // throws Error("Model validation failed:\n  path: message\n  …")

Omit registry and only the Zod structural pass runs. With a registry, registryErrors(model, registry) also checks: method existence, lane placement (a return: "value" method must sit in parameters, everything else in operations), declared arg keys per method (an args[].key the method doesn't accept is an error — including naming the common LLM mistake of passing args as an object map instead of an array), min/max/step only on numeric-typed args, and reserved/builtin key clashes (a node key can't collide with a computation namespace, a JS global, or another parameter method's builtin key).

Expression syntax — every expression-bearing field (label, enabled, args[].input, min, max, step, options[].input), parsed with the same jsep grammar evaluate() uses at eval time (see Expressions) — a typo fails validation instead of silently rendering an empty node:

import { validateExpressions } from "@parametron/parametric"

const errors = validateExpressions(model) // Array<{ path, expression, message }>

loadModel's primary input (a URL or embedded document) is skipped — it isn't an expression. A parseable-but-unusable expression ([0,,0], a sparse array hole jsep accepts but the AST walker has no case for) is reported too, with a dedicated message.

Diagnosis — the headless-friendly entry point: never throws, folds all three passes above into one flat list, and reports structural problems Zod alone can't localize as cleanly (e.g. a node that is neither an operation nor a container):

import { diagnoseModelJSON, formatModelDiagnostics } from "@parametron/parametric"

const diags = diagnoseModelJSON(raw, registry)
// Array<{ section: "parameters" | "operations" | "", index, key, typeName, errors: string[] }>

console.log(formatModelDiagnostics(raw, registry))
// "operations[0] (NodeJSON):\n  operations[0].args[0].input: bad expression `width +` — ..."
// or "OK (no node errors found)" when clean

diagnoseModelJSON also flags a handful of pre-v21 field names (nodes, generators, modifiers) with a migration message, so a stale saved document points straight at the fix instead of failing structural parse with no context.

See also

  • Model authoring reference — full authoring semantics: expression scope, per-method arg tables, the fold/container rules.
  • Expressions — the parse/evaluate grammar validateExpressions checks syntax against.
  • Model & pull graph — how Model.fromJSON consumes a validated ModelJSON and builds the runtime node graph.
  • Registry & contractsRegistryInput, createRegistry, and how a registry extends TYPE_REGISTRY / decides method lane placement.
  • Entity API — the full per-entity field reference for CurveJSON/FaceJSON/PointJSON/GroupEntity.
  • Render contractSceneJSON/toScene() vs. the incremental collectRenderFrame/diffEntityFrame loop.
📖 8 min read