Chain & entities
One lane
An operations[] fold has exactly one state: a plain EntityJSON[] (ChainState in registry/contract.ts). There is no separate instances lane — an operation's value is the stream after it, a container's value is its own fold result, both verbatim.
export type ChainState = EntityJSON[]A transform's compute signature reflects this directly — it is a producer plus an implicit input (the previous fold state):
compute: (input: ChainState, args: ComputeArgs, context: ComputeContext) => ChainState | Promise<ChainState>A producer's create signature has no input at all — the engine concats its result onto the stream for you:
create: (args: ComputeArgs, context: ComputeContext) => ChainState | Promise<ChainState>
// engine: output = [...input, ...create(args, context)]IMMUTABILITY is contract law: create/compute never mutates input — unchanged entities are shared by reference, changed ones are cloned. The per-step fold cache keys on object identity, so a mutation makes it silently stale.
Copies & instancing
An entity may be a nested groupEntity — one placement of a shared GroupDefinition, stored once in the model's definitions map. This is SketchUp component semantics (definition + instance), and it is the only place instancing lives:
- Array/mirror over a group copies the cheap wrapper — every copy still points at the same
GroupDefinition, so the renderer draws one definition + N placements. - Array/mirror over raw geometry (a curve, face, or point) bakes real transformed copies with fresh ids — there is no shared definition to reuse.
The renderer picks between the two purely from value shape: ≥2 groupEntity sharing one definition → instanced render; anything else → baked items.
groupExplode / looseToGroup / makeGroup are the explicit group/ungroup operations:
| Transform | Direction |
|---|---|
groupExplode | dissolve nested groups in the entity array into raw geometry (SketchUp Explode; nested arg: recursive vs one level) |
looseToGroup | the inverse — collect loose curves/faces into nested groups by connectivity (shared welded vertex); one group per connected component, points and existing groups pass through |
makeGroup | wrap the WHOLE entity array into one nested group, unconditionally — no connectivity clustering, no kind filtering (unlike looseToGroup, which makes one group per connected cluster and passes points/existing groups through untouched) |
The fold is a pipeline
Steps apply in listed order — a plain top-down fold, not reversed matrix composition. There is no rule that a fold must keep one entity kind: curveExtrude (Curve[] → Face[] solid, the same compute as the extrude producer with the fold input playing the profile role) turns rectangle → array → curveExtrude → edgeFillet into one ordinary operations[] list — curves in, faces out partway through.
Geometry ops (anything wired through applyGeometry) partition nested groups OUT before running — a group is a structural wrapper, not geometry, so it passes through untouched; dissolve it explicitly with groupExplode first if the op needs to reach inside. Transforms and props (move/rotate/arrayLinear/mirror/applyMaterial/applyLayer/visible/name, …) instead map over every entity, INCLUDING group wrappers — a transform composes onto a group's placement (cheap), a prop stamps the wrapper itself (per-copy).
Wiring a custom operation
Three wiring shapes cover everything a node registry writes:
| Shape | Used for | How |
|---|---|---|
| producer | any operation with no fold input (adds to the stream) | create returns the entity array directly; the engine concats it |
| transform/prop | move / array / mirror / applyMaterial / … | compute: (input) => input.map(...) over every entity, groups included |
| geometry transform | boolean / extrude / fillet / … | compute: (input) => applyGeometry(input, entityOp(fn, args, types)) — groups auto-excluded |
import type { EntityDefinition, EntityJSON } from "@parashape/parametric"
import { applyGeometry, entityOp, group } from "@parashape/parametric"
import { curve } from "@parashape/parametric/compute"
// 1. producer — chain state IS the returned array (group() is an identity shim).
const star: EntityDefinition = {
method: "star",
defaultKey: "star",
return: "curveEntity",
args: [{ key: "radius", type: "length", default: "100" }],
create: ({ radius }) => group([curve.polygon([0, 0, 0], Number(radius), 5)]),
}
// 2. transform/prop — maps EVERY entity, including group wrappers.
const tint: EntityDefinition = {
method: "tint",
defaultKey: "tint",
return: "entity",
args: [{ key: "color", type: "color", default: "'#ff0000'" }],
compute: (input, { color }) => input.map(entity => ({ ...entity, material: String(color) })),
}
// 3. geometry transform — applyGeometry partitions nested groups out first.
function bulge(entity: EntityJSON, amount: number): EntityJSON {
return entity // real implementation moves control points outward by `amount`
}
const bulgeTransform: EntityDefinition = {
method: "curveBulge",
defaultKey: "bulge",
return: "curveEntity",
args: [{ key: "amount", type: "length", default: "10" }],
compute: (input, { amount }) => applyGeometry(input, entityOp(bulge, [Number(amount)], ["length"])),
}Planarity rules
curveExtrude/extrude need no plane grouping — they run per-curve:
| Input | Result |
|---|---|
| open, planar or 3D | planar walls; caps is ignored |
| closed, planar | solid with a one-piece cap |
| closed, 3D (non-planar) | cap auto-folds into planar triangles (the crease lines are correct geometry, not a bug) |
Extrude direction is the per-curve Newell normal.
2D curve booleans are per-plane: curveSelfUnion partitions its input into coplanar groups and booleans each independently; a binary op (curveUnion/curveSubtract/curveIntersect) only consumes operands coplanar with the subject — a non-coplanar operand is ignored and the subject passes through unchanged.
The internal curve→face coercion (asShapes, used wherever a producer needs a Face from a closed curve) force-planarizes silently — it projects onto the best-fit Newell plane with no error, even for 3D input. This is why extrude/sweep take the profile curve directly instead of a pre-filled face: forcing a fill step first would collapse the open-curve walls case and silently flatten 3D vertices.
enabled
Every node in the operations[] fold — operation or container — carries an optional enabled expression (same shape as label — an ArgumentNode, type binary):
- Falsy on an operation — the step is bypassed:
inputpasses through unchanged, with the same identity, so downstream per-step caches stay valid. - Falsy on a container — it contributes nothing to its parent's fold (as if it were absent).
enabled is parametric like every other arg ("enabled": "hasDoors") — evaluating it registers the same dependency tracking as any expression, so a referenced parameter change re-dirties the node live. Value-lane parameters do not have enabled — disabling one would break every expression that references it.