Parametron

Every args value in a model is an expression string — a JS-like formula evaluated against the model's parameters and node keys. packages/parametric/src/expression.ts is a small, self-contained interpreter: parse with jsep plus three plugins, evaluate a typed AST directly (no eval, no codegen), and stringify back to source for AST-safe rewrites. The engine never runs untrusted JS — the interpreter only understands the node types below.

Parsing

import { parse } from "@parametron/parametric"

const ast = parse("width - 2 * thickness")

parse is jsep itself, re-exported after registering three plugins at module load:

The Expression type (re-exported from jsep) is the loose AST shape ({ type: string, [k: string]: any }); expression.ts also carries narrower internal views per node type (Identifier, BinaryExpression, CallExpression, …) but those aren't exported — treat Expression as opaque and pass it straight to evaluate/stringify.

Evaluating

import { evaluate, parse, type EvalContext } from "@parametron/parametric"
import { nodeNamespaces } from "@parametron/parametric/nodes"

const ast = parse("Point.distance([0,0,0], [width, 0, 0])")
const result = evaluate(ast, { width: 300 }, nodeNamespaces)
export function evaluate(
    node: Expression,
    context: EvalContext = {},
    namespaces?: Record<string, unknown>
): unknown

context is a plain Record<string, unknown> (EvalContext) — usually parameter/node values keyed by key. namespaces is the registry's PascalCase computation namespaces (Point, Vector, Curve, …, see Node catalog) for the owning model; it's a function argument, not module state, so two models on different registries evaluate independently in the same process.

Scope resolution, low to high priority: JS builtins → namespacescontext. Builtins are fixed: Math, Number, String, Boolean, Array, Object, isNaN, parseFloat, parseInt — the same list the schema calls out as reserved names (a node key can't shadow one). context properties may be lazy getters (Object.defineProperty) — evaluate copies property descriptors, not values, so a getter isn't invoked unless the expression actually reads it. This is how Model wires dependency tracking: an ExpressionNode's context getter registers itself as a dependent the moment an expression touches a given key.

Supported syntax

The evaluator is a switch over jsep's node types — every case below is implemented; anything else throws Unsupported node type.

Node typeExampleNotes
Literal42, 'Oak', true, nullstrings are single-quoted in source
Identifierwidthlooked up in context/namespaces/builtins; unresolved → ReferenceError
BinaryExpressiona + b, a ** b, a && b+ - * / % **, comparisons (< <= > >=), == === != !==, || &&
UnaryExpression-x, !flag, ~n+ - ! ~
ConditionalExpressionflag ? 10 : 20ternary plugin
MemberExpressionfoo.bar, foo[i]dotted or computed
CallExpressionMath.max(a, b), arr.map(f)this is preserved for method calls (str.padStart(...), arr.map(...)) so instance methods work; a bare/namespaced call ignores it
ArrayExpression[0, 0, height / 2]
ObjectExpression{ a: 1, b: 2 }object plugin
ArrowFunctionExpression(p, i) => i % 2 === 0arrow plugin — used for filter/lambda args (vertexFilter, arrayLinear items, …)
Compounda; bsequence, returns the last value

Notably absent because the model schema doesn't need them: assignment, loops, var/let/const, new, typeof/instanceof, try/catch, function declarations, regex literals, tagged templates.

Stringify

import { parse, stringify } from "@parametron/parametric"

stringify(parse("a - (b - c)"))   // "a - (b - c)"   — parens preserved (right operand of `-`)
stringify(parse("(a ** b) ** c")) // "(a ** b) ** c" — parens preserved (left operand of `**`)
stringify(parse("(a * b) + c"))   // "a * b + c"     — redundant parens dropped

stringify(node, parentPrec = 0) walks the same AST back to source, re-parenthesizing only where precedence/associativity requires it — ** is right-associative, everything else left-associative, and array/object/identifier/literal nodes bind as tightly as member access so [1,2].map(...) never grows a defensive wrapper. Round-tripping through parse → mutate AST → stringify is how the engine rewrites expressions programmatically without a text/regex pass.

Renaming references

import { renameIdentifier } from "@parametron/parametric"

renameIdentifier("thickness * 2", "thickness", "wallThickness")
// "wallThickness * 2"

renameIdentifier("foo.length", "length", "size")
// "foo.length"  — unchanged: a non-computed member property is a label, not a reference

renameIdentifier("[1, 2, 3].map((length) => length * 2)", "length", "size")
// unchanged — the arrow's own `length` param shadows the outer name
export function renameIdentifier(expr: string, oldName: string, newName: string): string

When a node key is renamed, every other expression in the model that references it needs the same rewrite — renameIdentifier does that as an AST-safe transform, not a string replace, so it never touches a name used as a label: a non-computed member property (foo.length), an ObjectExpression key ({ length: 3 }), or an arrow function's own parameter. It also respects local shadowing — a reference inside an arrow body is skipped when one of that arrow's own params shadows oldName. Returns expr unchanged (never throws) if the string doesn't contain oldName, doesn't parse, or fails to re-stringify — safe to call speculatively during a rename, including on a mid-edit/invalid expression.

Errors

The evaluator throws plain JS errors — the caller (ExpressionNode.compute(), see Model & pull graph) is responsible for catching and surfacing them:

  • ReferenceError: Undefined variable: <name> — an Identifier not found in context/namespaces/builtins.
  • TypeError: Not a function — a CallExpression callee that isn't callable.
  • Error: Unsupported operator: <op> / Unsupported unary: <op> — a jsep-parsed operator with no evaluator case (shouldn't occur with the plugin set above).
  • Error: Unsupported node type: <type> — same, for a whole AST node kind.
  • parse() itself throws jsep's own syntax errors on malformed source.

See also

  • Model & pull graph — how ExpressionNode builds the per-expression scope (parameter/node keys, dependency tracking, circular-dependency detection) and where namespaces comes from at evaluation time.
  • Node catalog — the Point.* / Vector.* / Curve.* / Surface.* / Face.* computation namespaces available inside expressions.
  • Model authoring reference — the full expression scope/literal/lambda authoring guide, and the expression type in the model schema.
  • Registry & contractsRegistryInput.namespaces, the mechanism a host uses to register its own PascalCase functions.
📖 5 min read