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 | ContainerJSONimport { isContainerJSON } from "@parametron/parametric"
isContainerJSON(node) // true iff node.operations is an arraymethod 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 asgeneratorKeySchema) — a node/parameterkey:^[a-zA-Z_][a-zA-Z0-9_]*$, and not in the reserved-name set (below).exprSchema— any non-empty string; syntax is checked later byvalidateExpressions, not here.LengthUnitSchema/AreaUnitSchema/VolumeUnitSchema/MassUnitSchema— enums backingUnitConfig(LengthUnit = "mm" | "cm" | "m" | "in" | "ft", etc.).ArgumentSchema(ArgumentJSON) — one{ key, input, label?, min?, max?, step?, options? }entry.inputisstring | Record<string, unknown>— an object is legal only asloadModel's primary arg (an embedded instancedModelJSON); everywhere else it must be an expression string.min/max/step/optionsare only meaningful on numeric parameter args (registry pass rejects them elsewhere).OptionSchema(OptionJSON) — one{ label, input }preset choice insideargs[].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 registryreturnstring that marks a method as a value binding (vs. an entity operation). Lane placement checks compare against this constant, nowhere else.RESERVED_KEY_SET—JS_RESERVED(Math,Number,isNaN, …) ∪JS_KEYWORDS(break,class,null, …); a nodekeycan'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 aParameterNode's single scalar arg.LOAD_KIND_BY_METHOD/isLoadMethod(method)—loadModelis the only loader parameter left; its primary input is a URL/embedded document, not an expression, so bothcollectNodeExpressions(validate.ts) and the expression pass skip it.PARAM_METHOD_VALUE_TYPE/ANIMATABLE_VALUE_TYPES/isAnimatableMethod(method)— a numeric parameter (number/length/angle) may carryanimation;arrayLength's underlying value type islength[], 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 cleandiagnoseModelJSON 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/evaluategrammarvalidateExpressionschecks syntax against. - Model & pull graph — how
Model.fromJSONconsumes a validatedModelJSONand builds the runtime node graph. - Registry & contracts —
RegistryInput,createRegistry, and how a registry extendsTYPE_REGISTRY/ decides method lane placement. - Entity API — the full per-entity field reference for
CurveJSON/FaceJSON/PointJSON/GroupEntity. - Render contract —
SceneJSON/toScene()vs. the incrementalcollectRenderFrame/diffEntityFrameloop.