Parametron

@parametron/parametric

Parametric engine — node graph, expression evaluation, and model system for ParaShape.

Architecture

Model JSON
     │
     ▼
┌──────────┐     ┌───────────┐     ┌───────────────┐
│  Schema  │────▶│   Model    │────▶│ NodeCollection │
│ (parse)  │     │ (fromJSON) │     │  (build graph) │
└──────────┘     └───────────┘     └───────────────┘
                       │
                       ▼
               ┌──────────────┐
               │   evaluate() │
               │  (pull-based) │
               └──────┬───────┘
                      │
          ┌───────────┴───────────┐
          ▼                       ▼
    parameters (header)     operations (body)
    key → VALUE bindings    ONE fold, top-down:
    (resolve values)        producers concat, transforms
          │                 process, containers nest
          └───────────┬───────────┘
                      ▼
              EvaluateResult
              { geometry, materials, params }

Node Graph (v21 — operations model, v22 — static asset lanes)

ONE recursive node shape, two forms discriminated by a required field:

operation  { key?, label?, enabled?, method, args? }          ← does something to the stream
container  { key?, label?, enabled?, operations: NodeJSON[] } ← holds a list of nodes

ModelJSON = { id?, title, unit, camera?, materials?: MaterialJSON[], layers?: LayerJSON[], fonts?: FontJSON[], parameters?: NodeJSON[], operations?: NodeJSON[] }parameters is the header (named VALUE bindings, order display-only), operations is the body: one flat fold executed top-down over a stream that starts empty. The root IS a container. materials/layers/fonts are static named records, not nodes — see Assets below.

The fold

container "cabinet"
 ├── operation rectangle          ← producer: concats its result into the stream
 ├── operation extrude            ← producer (a solid = Face[])
 ├── operation edgeFillet         ← transform: processes the stream (map)
 ├── operation arrayLinear        ← transform (multiply, n → m)
 └── container "doors"            ← nested fold: runs its own list from an
      ├── operation rectangle        empty stream, result concats into the
      └── operation curveExtrude     parent stream like a producer
  • A producer (registry def with create) adds [...input, ...created] — never replaces.
  • A transform (registry def with compute) processes the whole stream — map / multiply (n→m>n) / reduce (n→m<n) / structural shapes as before.
  • An operation's key names the stream AFTER that step; a container's key names its own fold result — both referenceable from any expression.
  • enabled falsy: operation → bypassed (input passes through), container → contributes nothing.

Summary

formlaneDescription
operation, return: "value" methodparametersNamed value binding (length/number/angle/boolean/color/text/arrayLength/function/loadModel). Runtime class ParameterNode.
operation, entity-return methodoperationsOne fold step (producer or transform). Runtime class OperationNode.
containereitherIn operations: its own fold (runtime ContainerNode + FoldRunner). In parameters: display grouping of value nodes.

Materials/layers/fonts are static named records on the document (ModelJSON.materials/layers/fonts), not nodes — plain values, never expressions. name is unique per lane and is how an operation references the record (applyMaterial/applyLayer's name arg, textCurve's font arg). There are no createMaterial/createLayer/loadFont/loadImage methods.

Schema types: OperationJSON | ContainerJSON = NodeJSON — there is no nodeType field; the two forms are discriminated by method vs operations, and the value-vs-entity lane by the method's registry return.

Method naming

Every method is globally unique across the whole registry — a domain prefix convention (curve* / face* / edge* / group* / solid*) keeps the names collision-free (curveUnion vs solidUnion), so the engine dispatches by the bare method alone. Transform families:

  • Whole-stream transforms: move, rotate, resize, mirror, arrayLinear, arrayRadial, arrayCurve, arrayDistances, props applyMaterial / applyLayer / visible / name
  • Curve: curveReverse, curveSplit, curveFillet, curveChamfer, curveOffset, curveThicken, curveExtrude (kind-changing: Curve[] → Face[] solid), curveUnion / curveSubtract / curveIntersect (2D boolean, binary — needs curve), curveSelfUnion (no-operand reduce)
  • Face/solid: faceReverse, solidUnion / solidSubtract / solidIntersect (3D CSG, binary — needs solid), solidSelfUnion, faceExtrude, faceOffset, edgeFillet, edgeChamfer, faceLattice
  • Structural: groupExplode (dissolve nested groups), looseToGroup (cluster by connectivity), makeGroup (wrap the whole stream into one group)

Expression System

All args values are expression strings evaluated at runtime:

"1200"              → literal number
"lengthX - thickness" → references parameters by key
"flag ? 10 : 20"   → ternary
"'Oak'"             → literal text (single-quoted)
"[0,0,lengthZ]"      → array/vector

Powered by jsep with ternary, arrow, and object plugins.

Evaluation Flow

1. Model.fromJSON(json)
   ├── Parse ModelJSON (schema.ts)
   ├── Build NodeCollection (flat → tree)
   └── Wire expression dependencies

2. model.evaluate(params, resolveDep?)
   ├── Resolve parameters (with overrides from params)
   │   └── Expression evaluation (jsep parse → resolve refs → compute)
   ├── Run the body fold (operations, top-down)
   │   └── producers create geometry, transforms process the stream
   └── Collect results → EvaluateResult
       ├── geometry: GeometryNode[] (tree of entities)
       ├── materials: MaterialData[] (PBR materials)
       └── params: ParamInfo[] (exposed parameters)

Scene Entities

Exactly 3 scene entities, each an entity return kind. Every body node's value is an array of these (a single result = an array of 1). A solid is Face[] — a box is 6 Faces; there is no Block entity.

An entity is { id, geometry, visible?, layer?, material?, label?, sourceKey? } with no type tag — its kind is sniffed from the geometry shape (isPoint/isCurve/isFace/entityKind).

Point   (geometry [x,y,z], namespace pointEntity)
            position markers for visualizing locations (render-only)
            producers: pointEntity

Curve   (geometry NurbsCurveJSON, namespace curveEntity)
            1D wires AND closed loops (a closed loop is still a Curve)
            producers: polyline, spline, rectangle, polygon,
                        arcCenterStartEnd, arcThreePoints, arcTangentEnd,
                        circle, ellipseArc, cubicBezier, quadraticBezier,
                        nurbsCurve, textCurve

Face    (geometry { loops, uvMatrix }, namespace faceEntity)
            flat 3-D polygon region (holes via extra loops); a solid = Face[]
            producers: extrude, sweep, loft, revolve,
                        patchFromCurve, patchFromCorners

The generic entity-namespace producers are clone (references another node, adds an independent deep copy with new ids), addMesh (bakes a literal faceEntity[] array into a fresh, independently-transformable copy), and placeModel (places a loadModel value node's sub-model, load-once/place-many). Grouping is the container FORM ({ operations: [...] }), not a method. Materials/layers are static document lanes (see Summary above), not producers.

Curved construction (loft / revolve / sweep / patchFromCurve / patchFromCorners) builds a NURBS surface inside the shapemetry kernel, then tessellates to Face[] for the graph — NURBS is a kernel concept, not a scene entity. Solid topology ops (solidUnion/solidSubtract/solidIntersect booleans, faceExtrude/edgeFillet/edgeChamfer/faceLattice transforms) build a kernel Mesh, then decompose back to Face[].

Rule: the graph only ever holds Curves, Faces, and Points; exact NURBS/mesh math is an internal kernel side-path that always resolves back to these three entities.

Entity Mapping

ParaShape entityThree.jsSketchUp
CurveLineEdges
FaceMesh (filled)Face
PointPoints (markers)ConstructionPoint

TS JSON types: CurveJSON, FaceJSON, PointJSON.

Available Nodes

The engine has NO built-in node catalog — it takes a flat RegistryInput (node definitions + expression namespaces + extra value types) as INPUT (createRegistry(input)Model.fromJSON(json, { registry })). The engine's own CORE_NODES contribute the builtins (scalar parameters, loadModel, container/clone/addMesh/placeModel, groupExplode/looseToGroup/makeGroup); ParaShape's own node set ships as the optional @parametron/parametric/nodes subpath (src/nodesRegistry/catalog.ts is the catalog SSOT, src/nodesRegistry/wiring.ts wires each entry's compute to the standalone functions from @parametron/parametric/compute — the no-engine-coupling reuse surface, namespaced per domain module). This subpath is never pulled in unless a consumer explicitly imports it, so an external app can reuse the bare engine with its own node set. Computation namespaces (Point.*, Vector.*, Curve.*, Surface.*, Face.*) are registered via RegistryInput.namespaces — never at module load. registry.nodes (NodeDefinition[]) IS the serializable view docs/UI menus render from — no separate catalog type.

Source Structure

src/
├── schema.ts          # TypeScript types for JSON format
├── schema/types.ts    # Core value/entity type catalog (TypeDef; a RegistryInput can add inert types)
├── registry/          # Node contract + registry (the engine's INPUT)
│   ├── contract.ts    # RegistryInput / NodeDefinition / ChainGroup / ArgDef
│   ├── Registry.ts    # createRegistry(input) — merge + validate, per-Model instance
│   ├── builtins.ts    # CORE_NODES (engine builtin node definitions + preload hooks)
│   └── helpers.ts     # chain helpers (group/applyGeometry/multiplyInstances/mapInstances/…)
├── validate.ts        # Model/node validation (Zod structure + registry pass)
├── Model.ts           # Model class (fromJSON({registry}), evaluate)
├── evaluate.ts        # evaluateModel(json, params, { registry, assets }) — sync
├── domains/           # Producer/transform implementations per domain
│   ├── curve.ts       # curveEntity producers
│   ├── face.ts        # faceEntity producers
│   ├── solid.ts       # solid/face transforms (solidUnion/solidSubtract/solidIntersect/solidSelfUnion, faceExtrude/edgeFillet/edgeChamfer…)
│   ├── surface.ts     # curved producers (loft/revolve/sweep/patchFromCurve/patchFromCorners → Face[])
│   ├── point.ts       # pointEntity producers
│   ├── entity.ts      # generic per-entity transforms (reverse/resize/applyMaterial/applyLayer/…)
│   ├── entities.ts    # whole-stream ops (move/rotate/array*/mirror/clone/…)
│   ├── font.ts        # font lookup/fallback (fonts[] static lane, not a parameter)
│   └── …            # (namespaces are registered per RegistryInput — see nodesRegistry/)
├── nodesRegistry/     # ParaShape's own node set (the `/nodes` subpath — optional, never
│   ├── catalog.ts     #   pulled in unless imported)
│   ├── functionRegistry.ts
│   ├── namespaces.ts
│   └── wiring.ts       # catalog entry → OperationDefinition, wired to domains/* compute
├── core/              # BaseObject, collections, ModelScope (model), sanitizer
├── nodes/             # Runtime node classes
│   ├── BaseNode.ts
│   ├── ParameterNode.ts
│   ├── OperationNode.ts # one fold step (producer/transform, per-step cache)
│   ├── ContainerNode.ts # container form + FoldRunner
│   ├── ExpressionNode.ts
│   ├── ArgumentNode.ts
│   ├── OptionNode.ts
│   └── NodeCollection.ts
└── utils/
    ├── expression.ts  # Expression parser/evaluator
    ├── convertExpression.ts # Literal-only unit conversion (convertLiteral)
    └── unitConvert.ts # Unit conversion
📖 5 min read