ParaShape

Architecture — the layers

ParaShape is built as a stack of layers with one-way contracts. The engine has no built-in node catalog: it takes a node registry as input, so a different product (e.g. a geometry-teaching app) can reuse the whole engine with its own node set.

frontends       builder UI · Three.js / SketchUp renderers   reads: registry catalog + SceneJSON
node registries @parashape/parametric/nodes · your own       catalog + compute wiring
engine          @parashape/parametric                        graph, registry, fold, Store, schema
compute         @parashape/parametric/compute                pure geometry/value functions — full list: /dev/compute
kernel          @huukhanhnguyen/shapemetry                    NURBS / mesh math

Composition happens in the host app:

import { createRegistry, Model, store } from "@parashape/parametric"
import { nodeRegistry } from "@parashape/parametric/nodes"

const registry = createRegistry(nodeRegistry)      // the engine's own builtins are always merged in first
await store.load(bundle)                          // async phase: Store pre-loads assets from raw JSON
const model = Model.fromJSON(json, { registry })  // per-model instance — no global mutation
const scene = model.toScene()

One-way rules that keep the layers separate:

  • The engine never reads attributes — that bag is the node-registry ↔ frontend contract (category, label, tooltip, icon…). Anything that affects how the graph runs is a named structural field (method, defaultKey, args, return).
  • A node registry never redefines node-value semantics — an operation's value is the stream AFTER it, a container's value is its OWN fold result, both plain EntityJSON[], an engine invariant. A nested groupEntity is one entity KIND inside that array (a placement of shared geometry), not the whole value — see Chain & entities.
  • Frontends are registry-driven, not registry-owning — menus, docs tables, and llms.txt all render from registry.nodes (every definition, in registration order).

Two authoring layers: values vs renderables

The model splits by one criterion — does it produce a value or a renderable entity?

LayerLives inExample
Computation — value → value, transient, no renderexpression namespaces (Point.*, Vector.*, Curve.*, Surface.*, Face.*), registered via RegistryInput.namespacesCurve.length(profile)
Scene entities — renderable, flows to Three.js / SketchUpoperations (producers + transforms) returning groups of Curve / Face / Point entitiesrectangle, extrude, arrayLinear

The scene-entity set equals the renderer primitive set (wire / filled region / point marker); a solid is Face[]. See Entities and Functions. The registry's per-method return names which lane a method belongs to: "value" lives in the model's parameters header; "curveEntity"/"faceEntity"/"pointEntity"/"entity" live in the operations body.

The model node graph

The whole ModelJSON desugars to one recursive node shape — a plain one-directional input → output dataflow graph, nothing beyond standard node-graph theory. Subgraphs are the normal abstraction (Grasshopper clusters, Houdini subnets, Blender node groups); the one deliberate deviation is named below.

--- config: flowchart: {} layout: dagre title: Model Node Graph --- flowchart TB subgraph ContainerSubgraph["ContainerNode — recursive: a child container is this same shape"] subgraph FoldList["operations[] — ONE flat fold, executed top-down, stream starts empty"] Op1["OperationNode #1 — producer: concat([...state, ...create(args)]) OR transform: compute(state, args)"] Op2["OperationNode #2"] ChildContainer["ChildContainer = ContainerSubgraph (its OWN fold result concats in, like a producer)"] end EmptyStream(("[] start")) --> Op1 --> Op2 --> ChildContainer --> OwnFoldResult["own fold result = ContainerNode.value"] end Op1 -.->|"key tap: array1 — every fold step is a public port (v9, unchanged)"| ArgumentExpression Argument --> OperationArguments --> Op1 & Op2 Parameter --> ArgumentExpression --> Argument OtherNodes --> ParameterExpression --> Parameter OtherNodes --> ArgumentExpression LoaderParameter["LoaderParameter — raw URI input, no expression"] --> ArgumentExpression OwnFoldResult --> ParentFold["parent ContainerNode's own fold (concatenated in, like a producer)"] ParentFold --> ModelRoot ModelRoot["Model.operations — the ROOT is a ContainerNode literal: same fold rule at every depth, no special-cased root machinery"] -->|entities| SceneJSON
  • Every wire is an expression reference. There are no hand-drawn wires: an argument is a collapsed expression subgraph whose incoming edges are references to parameters and operation/container keys (the Houdini parameter-expression style, taken to 100%). Loader parameters are the one exception — their input is a raw URI, not an expression.
  • operations[] is ONE flat fold, executed top-down over a stream that starts empty. A producer operation (polyline, rectangle, extrude, clone, placeModel, …) concats: output = [...input, ...create(args)]. A transform operation (move, curveSelfUnion, curveExtrude, applyMaterial, …) processes the stream: output = compute(input, args). A container ({ operations: [...] }, no method — it is grammar, not a registry entry) runs its OWN operations[] from an empty stream and its result concats into the parent stream, exactly like a producer. One nesting meaning only — no separate "creation vs combination" step inside a leaf, no separate concept for a container's own compute (a container never has one; only its children's fold matters).
  • The boundary is deliberately porous. Every node's key (v9, unchanged) makes it a public port — an outside expression can tap array1.length where array1 is the key of an operation mid-fold. Standard node groups forbid this (you must promote a port); ParaShape auto-promotes every step. An operation's key names the stream AFTER it; a container's key names its OWN fold result.
  • The root is an ordinary container — no special root machinery. model.operations IS the root fold: the exact same algorithm as any nested container's operations[], run directly over the model's top-level operations (shared via one FoldRunner). There is no longer a degenerate "root generator" concept, no separate root-only chain law — a root-level transform (applyMaterial/applyLayer included) is an ordinary fold step, just like inside any container. ModelJSON mirrors the container grammar structurally: parameters?: NodeJSON[] (header) + operations?: NodeJSON[] (body), no flat interleaved node list, no nodeType field anywhere.

The runtime mirrors the graph closely: OperationNode is the leaf (producer or transform, per-step cached, fail-soft); ContainerNode folds its children's operations[] the same way, its value = its own fold result. The runtime keeps the WHOLE model in one flat NodeCollection rather than instantiating a literal nested ContainerNode for the root — the root fold is conceptually a container (same rule, same FoldRunner), applied directly over the top-level nodes for simplicity.

Three runtime layers: async → compute → value

The whole workflow — assets load async into the Store (from the raw JSON and from UI edits); the model itself is a fully-sync pull graph reading the Store:

--- config: flowchart: {} layout: elk title: Workflow --- flowchart TB subgraph Model["Model Sync Graph"] Computations["Computations(Arguments)"] Parameters["Parameters (header lane — value bindings)"] Operations["Operations (body lane — ONE flat fold: producers concat, transforms process)"] end ModelJSON["ModelJSON"] -- Async Load Assets --> Store["Store"] Parameters --> Computations Computations --> Operations Model -- UI Async Load Assets --> Store Store --> Model
  1. Async (before the graph runs). store.load(bundle) scans the RAW model JSON for the engine loader parameters (loadModel / loadFont / loadImage) and registers their assets in the Store — the Store's own job, not a registry hook. The graph itself never awaits — a missing asset is a Store error, not a mid-eval fetch.
  2. Compute (registry-supplied, synchronous). Each node's definition computes: value-lane parameters produce values, entity-lane operations produce/transform entity arrays.
  3. Node value (engine-owned). An operation's value is the stream after it; a container's value is its own fold result — both a plain EntityJSON[]. An entity may be a nested groupEntity referencing a shared GroupDefinition (a "copy"); geometry ops leave those untouched, so nothing bakes until something actually needs the leaves inside (export, a geometry op after an explicit groupExplode, or the renderer).

The node contract (write your own node registry)

Full type reference + step-by-step: Node registry. A node set is plain data + functions:

import type { RegistryInput } from "@parashape/parametric"
import { applyGeometry, entityOp, group } from "@parashape/parametric"
import { curve } from "@parashape/parametric/compute"  // full catalog: /dev/compute

export const myNodes: RegistryInput = {
    namespaces: { /* PascalCase expression functions */ },
    nodes: [
        {
            method: "star",
            defaultKey: "star",
            return: "curveEntity",
            args: [{ key: "radius", type: "length", default: "100" }],
            attributes: { category: "CurveEntity", label: "Star" },
            create: ({ radius }) => group([curve.polygon([0, 0, 0], Number(radius), 5)]),
        },
    ],
}

The uniform compute contract — every operation is a pull-graph node input → node → output, returning a plain entity array (ChainState = EntityJSON[], no separate instances lane):

returncompute signature
"value"(args, ctx) => unknown — optional; scalar parameters omit it
entity kind ("curveEntity" / "faceEntity" / "pointEntity" / "entity")EXACTLY ONE of create(args, ctx) => ChainState (producer — the engine concats [...input, ...create()]) or compute(input, args, ctx) => ChainState (transform — a producer plus an implicit input: the previous fold state)

Whether a transform touches geometry, moves entities, or stamps props is its own business — the engine's chain helpers cover the geometry shape (a transform/prop operation is a plain .map(), no dedicated helper needed — see Chain & entities for the full pattern and a worked example):

HelperShape
group(entities)identity compat shim — a producer returns the entity array directly
applyGeometry(input, entityOp(fn, args, types))partition nested groups OUT (untouched) → clone the rest → run a geometry op (boolean / extrude / fillet…)
explodeGroups(input, nested, definitions)dissolve nested groups into raw geometry

Two hard rules: immutability (create/compute never mutates its input — the per-step cache keys on object identity) and serializable attributes (they feed registry.nodes, the docs/UI view).

Validation is registry-aware: validateModelJSON(json, registry) checks method validity, arg keys, numeric constraints, reserved keys, and lane placement (value-return methods only in parameters, entity-return methods only in operations) on top of the structural (Zod) pass. The persisted ModelJSON format itself is registry-independent and unchanged — see Schema.

Building another product on the engine

A different product (say a geometry-teaching app) takes the engine and swaps the node set:

  1. Depend on @parashape/parametric (and @parashape/parametric/compute when wiring geometry functions). You do not need the @parashape/parametric/nodes subpath — that is ParaShape's own node set, an optional extra never pulled in unless imported; treat its docs as a worked example.
  2. Write your own node set — the contract above: nodes (definitions with create/compute), namespaces (your expression functions), optional inert types, builtinParameters.
  3. Compose and run: createRegistry(yourNodes)Model.fromJSON(json, { registry })model.toScene().
  4. Frontend: the panels in packages/ui are registry-driven (they render registry.nodes and each definition's attributes), so they work with any node set — or render the same data yourself. Any host renderer that consumes SceneJSON works.

Everything a developer needs lives under /dev; the user docs cover the app and ParaShape's own node set.

Why the fold looks different from a canvas tool, but isn't

ParaShape is a pull-based graph like Grasshopper/Dynamo: every node clones input → output. In those tools each intermediate step is a visible node you must wire and hide. Here, an operations[] list is the same kind of pull chain — its wiring is just implicit (each step's input is the previous step's output), making the whole fold an invisible linear subgraph instead of a canvas full of nodes. That is why every step has its own key (an expression can reference the intermediate result, e.g. array1.length), its own cached state (editing step k re-runs only k..N), and fail-soft errors traced to the exact step — without cluttering the canvas with intermediate nodes.