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 mathComposition 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 nestedgroupEntityis 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?
| Layer | Lives in | Example |
|---|---|---|
| Computation — value → value, transient, no render | expression namespaces (Point.*, Vector.*, Curve.*, Surface.*, Face.*), registered via RegistryInput.namespaces | Curve.length(profile) |
| Scene entities — renderable, flows to Three.js / SketchUp | operations (producers + transforms) returning groups of Curve / Face / Point entities | rectangle, 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.
- 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 OWNoperations[]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 taparray1.lengthwherearray1is 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.operationsIS the root fold: the exact same algorithm as any nested container'soperations[], run directly over the model's top-level operations (shared via oneFoldRunner). There is no longer a degenerate "root generator" concept, no separate root-only chain law — a root-level transform (applyMaterial/applyLayerincluded) is an ordinary fold step, just like inside any container.ModelJSONmirrors the container grammar structurally:parameters?: NodeJSON[](header) +operations?: NodeJSON[](body), no flat interleaved node list, nonodeTypefield 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:
- 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. - Compute (registry-supplied, synchronous). Each node's definition computes: value-lane parameters produce values, entity-lane operations produce/transform entity arrays.
- 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 nestedgroupEntityreferencing a sharedGroupDefinition(a "copy"); geometry ops leave those untouched, so nothing bakes until something actually needs the leaves inside (export, a geometry op after an explicitgroupExplode, 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):
| return | compute 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):
| Helper | Shape |
|---|---|
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:
- Depend on
@parashape/parametric(and@parashape/parametric/computewhen wiring geometry functions). You do not need the@parashape/parametric/nodessubpath — that is ParaShape's own node set, an optional extra never pulled in unless imported; treat its docs as a worked example. - Write your own node set — the contract above:
nodes(definitions withcreate/compute),namespaces(your expression functions), optional inerttypes,builtinParameters. - Compose and run:
createRegistry(yourNodes)→Model.fromJSON(json, { registry })→model.toScene(). - Frontend: the panels in
packages/uiare registry-driven (they renderregistry.nodesand each definition'sattributes), 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.