Pipeline map — what happens to one model.json
Drawn from the code as it stands at 3d2b28da (branch main). Every factual sentence carries a file:line that was opened. Where something could not be verified, the text says so. This document DESCRIBES — no recommendations, no proposals, no invented layer names.
Paths are relative to the repo root. A path written Model.ts:473 is packages/model/src/Model.ts:473; short paths inside a section are relative to packages/model/src/.
In-flight note: components.ts, nodes/EntitiesNode.ts, rootFold.ts and packages/types/src/evaluate/entities.ts carried uncommitted edits from another session at the time of this pass, with a block-behaviour change pending in that area. Anchors into those four files were read from git show HEAD:<path>, so the line numbers here are the committed ones.
The shape the code has
A reasonable guess about a document engine is that it is a line: parse, then build, then evaluate, then render, then handle updates. That is not this engine's shape, and the difference matters for anyone reading the code.
What the code actually has is one linear act that terminates, and then a standing structure that is pulled:
model.json
│
│ ONE LINEAR ACT — runs once, throws or finishes, computes nothing
▼
┌────────────────────────────────────────────────────────┐
│ LOAD Model.fromJSON │
│ validate → 15 lanes assigned as RAW JSON │
│ → node trees built for exactly 2 pseudo-lanes │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ A STANDING GRAPH (a structure, not a step) │
│ · one node tree: parameters + objects │
│ · 15 lanes still raw JSON, behind lazy factories │
│ · nothing has been computed │
└────────────────────────────────────────────────────────┘
│
│ ── and from here on, ONE operation, repeated: PULL ──
▼
┌────────────────────────────────────────────────────────┐
│ PULL gate on isDirty → compute → commit │
│ same template at every grain │
└───────────────────────┬────────────────────────────────┘
│
the WHOLE-SCENE pull FANS OUT — one resolve, one cache:
│
┌───────────┬───────┴────────┬──────────────────┐
▼ ▼ ▼ ▼
pull.frame collectGeometry modelCombination modelCombination
RenderFrame FromSlices FromSlices + expandBlocks
Slice[] GeometryNode[] blocks KEPT blocks EXPANDED
(host) (evaluate()) (3D tessellate) (drawing/paper)
│
▼
┌────────────────────────────────────────────────────────┐
│ INVALIDATE — an edit MARKS; it does not recompute. │
│ Two mechanisms, because two kinds of edit exist. │
│ The next pull decides. ──────────► back to PULL │
└────────────────────────────────────────────────────────┘Three specific ways the linear reading is wrong, each with its evidence:
- "Evaluate" and "render frame" are not two steps. One
SceneNode.resolve()builds the fold slices AND the render frame in the same collect —{ slices, frame: frameFromSlices(this.nodes, slices) }(Model.ts:316-317). The type says so in its own doc: "Fold slices + render frame — ONE SceneNode resolve produces both" (packages/types/src/evaluate/frame.ts:54). - There is no render stage in this engine at all. The engine's last act is producing the frame.
diffEntityFrame(renderDiff.ts:108) has no caller in this repo outside its own tests (__tests__/renderFrame.test.ts,__tests__/sceneHooks.test.ts) — it is a helper a HOST calls. See §5. - "Build" is not a stage that processes the document. Load builds node trees for two pseudo-lanes and leaves all fifteen real lanes as raw JSON (
Model.ts:489-503,:521,:527). Every other lane is built on first pull, which means "build" is not a phase — it is something that happens lazily, inside a pull, fifteen different times.
So the sections below are: the one act (§1), the structure it leaves (§2), the one operation (§3), its fan-out (§4), where the engine stops (§5), and how the loop closes (§6).
1. LOAD — the one linear act
Model.fromJSON(json, { registry, assets }) (Model.ts:473-529). A registry must already exist — createRegistry(input) (registry/Registry.ts:247) — because validation's last pass consults it.
This is the only genuinely sequential part of the engine, and it computes nothing: it validates, copies, and constructs.
1a. Validate — throws, never migrates
The first act, before anything is constructed, is validateModelJSON(json, registry) (Model.ts:478). The rule is stated in the comment immediately above the call (Model.ts:475-477):
The engine speaks ONLY the current schema. A document in an older shape is REJECTED here with a message naming the current field — never accommodated, and never quietly rewritten on load.
validateModelJSON (validate.ts:53-110) is an orchestrator over the per-lane files in validate/. It runs three groups of passes and throws on the first group that produces errors; errors inside a group are COLLECTED and reported together, not one at a time (validate.ts:107-109).
Structural (zod). safeParseModel(json) (validate.ts:56, implementation validate/parse.ts:56) runs ModelSchema (schema/model.ts:630-655). The root is .strict() (:655), so an unknown root key is a hard reject. It declares 22 keys: five that are not lanes (id, version, title, flat, settings — :631-636), the two root node lists parameters and objects (:646-647), and the fifteen lane arrays. Before a zod failure is reported, two named-pointer passes get first refusal on the message (validate.ts:54-55, :64-67): staticAssetRecordMessages (validate/parse.ts:65) and rootObjectMessages (validate/parse.ts:93), so a static material/layer/style record — or a root body still written as a fold list — names the field rather than a raw zod issue path. SCHEMA_VERSION is optional on load: "Additive, never required" (schema/model.ts:632-633).
Per-lane semantic passes, all batched at validate.ts:73 plus the two loops below it:
| Lane | Pass | Site | What it checks |
|---|---|---|---|
| (all nodes) | nodeKeyErrors | validate/assets.ts:111 | node key legality across the document |
materials | materialErrors | validate/assets.ts:133 | HARD lane structure only — texture slots. An unknown applyMaterial name is deliberately SOFT (materialReferenceErrors, validate/assets.ts:51, routed to diagnoseModelJSON) so a public doc with missing materials still loads and renders default gray (validate.ts:70-72) |
layers | layerErrors | validate/assets.ts:228 | layer lane structure |
styles | styleErrors | validate/assets.ts:295 | style lane structure |
textures | textureErrors | validate/textures.ts:13 | texture chain structure |
tables | tableErrors | validate/tables.ts:14 | table chain structure |
views | viewErrors | validate/views.ts:56 | view chain structure |
cameras | cameraErrors | validate/cameras.ts:86 | camera records |
animations | animationTrackErrors | validate/animations.ts:49 | track methods, resolved first by trackMethodsOf (validate/animations.ts:18) |
sheets | inline loop | validate.ts:82-86 | a sheet still carrying the retired number field instead of key |
Three named rejections for retired shapes are hard-coded in the same function: sheets[].number (above), settings.language (validate.ts:88-93, i18n removed), and a bare scene() call in ANY expression in the document (validate.ts:98-105) — found by a regex pre-filter (BARE_SCENE_CALL, validate/expressions.ts:10) over every expression collectExpressions (validate/expressions.ts:75) can reach.
Not every pass in validate/ runs on load. validate/components.ts exports three WARNING functions — componentTableWarnings (:15), flatContractWarnings (:40), openingCutWarnings (:104) — and validate/skin.ts:16 exports skinErrors; none of the four appears in validateModelJSON's list. They are re-exported (validate.ts:28-46) for diagnoseModelJSON (validate/diagnose.ts:63), the non-throwing door.
Registry pass, last, and only when a registry was supplied: registryErrors(json, registry) (validate.ts:106, implementation validate/registry.ts:74). Scope stated at validate.ts:50-52 — "method validity + lane placement, per-method arg keys, numeric-constraint placement, reserved/builtin keys". Lane placement carries a per-lane message table, so a step belonging to views / skeletons / animations / tables / textures found in the wrong lane is named with the lane it belongs to (validate/registry.ts:56-64).
Not checked here: expression SYNTAX. validateExpressions (validate/expressions.ts:169) is exported but is not called by validateModelJSON; a bad expression becomes a per-node runtime failure.
1b. Fifteen lanes are copied as raw JSON
The document's lane arrays are assigned verbatim onto model.scope or model (Model.ts:489-503). Which object owns which array is not decided there — it is declared once in the lane descriptor (schema/lanes.ts:98-115, field storage: "scope" | "model") and read back through Model.laneCarrier() (Model.ts:552-557).
LANE_NAMES is the closed list of lanes (schema/lanes.ts:88-93): materials, layers, styles, textStyles, annotationStyles, cameras, skeletons, animations, components, textures, tables, views, sheets. A LIGHT is not among them: a light is an ordinary ENTITY whose geometry is a Light (the lights[] lane was deleted 2026-08-16). Neither is the coordinate-axis display: the helpers[] lane (axes / grid) was deleted the same day and the triad is a VIEWER setting now — it never reached a file format, and it is a habit of the person looking, not authored intent. parameters and scene are deliberately absent from it — they are PSEUDO-lanes, "not arrays of entries, they are the root collection itself" (schema/lanes.ts:82-87).
The descriptor is a Record over a closed union precisely so a cross-cutting consumer cannot fall behind: "add a key, and every Record<LaneName, …> in the repo stops compiling until it is handled" (schema/lanes.ts:18-22). The defect that motivated it is recorded there: Model.convertUnit walked only the live node collection and never converted a components[] body, so a mm→m switch left every component a thousand times too big (schema/lanes.ts:12-16).
1c. Exactly two node trees are built
model.nodes.fromJSON(json.parameters ?? [], registry, "parameters") Model.ts:521
model.nodes.fromJSON(json.objects ?? [], registry, "scene") Model.ts:527Those two calls are the whole of eager construction.
NodeCollection.fromJSON (nodes/NodeCollection.ts:349-368) does two things beyond construction:
- Bulk attach —
_bulkAdd(:358) instead of per-itemadd(), because per-item add rescans every sibling for key dedupe and fires a listener cascade per step, which made loading quadratic (:350-352). - Key backfill — every
EntitiesNodethat arrived without akeygets one minted from the registry'sdefaultKeyOf(:363-367), using ONE minter for the whole document (:360-362). The doc records why this moved here fromModel.fromJSON: it used to run for the ROOToperationsand nothing else, so a keyless step inside asheets[]/components[]body stayed keyless forever — and since the fold stamps a producer's tail with the STEP's key, its entities came out carrying the enclosing container's key, so nothing downstream could trace an entity back to the node that drew it. That is what left every AI-authored sheet undraggable on the sheet canvas (nodes/NodeCollection.ts:331-348).
1d. One listener, one non-lazy lane node
If opts.assets is present, the Store is attached to the scope and one listener is wired (Model.ts:504-515): an "asset" notification carrying a url calls onAssetLoaded(model, url) (:513). See §6d.
The SceneNode is the one lane node NOT built lazily — it is constructed in the Model constructor (Model.ts:313-322), before any document is loaded. See §4.
2. What load leaves standing
Not a step. This is the state the engine is in after §1, and reading it as a structure rather than a phase is what makes the rest of the code legible.
Three things exist, and nothing has been computed:
- one node tree, holding
parameters+objects; - fifteen lane arrays, still raw JSON on
model/model.scope; - a set of lazy
*NodeOffactories, each of which will wrap its lane on first pull and memoize the result in a per-lane Map on the Model.
2a. Four node granularities
The nodes are not one uniform grain. Which grain a lane gets is a property of the lane.
| Grain | Class | Declared | What ONE node is |
|---|---|---|---|
| per OBJECT | ObjectNode | nodes/ObjectNode.ts:134; built by objectNodeOf (lanes/objects/nodes.ts:24) | one objects[] entry — the addressable unit a host mounts and moves one at a time (nodes/ObjectNode.ts:121-133) |
| per STEP | EntitiesNode / ContainerNode | nodes/EntitiesNode.ts, nodes/ContainerNode.ts:128 | one operation in a fold list; a container is one nested fold list |
| per ENTRY | ChainNode | nodes/LaneNode.ts:232 | one lane entry viewed as one cached unit |
| per LANE | SnapshotLaneNode / CollectionNode | nodes/LaneNode.ts:213, nodes/CollectionNode.ts:85 | the whole lane as one value (SnapshotLaneNode), or the lane's ordered KEY LIST only (CollectionNode) |
CollectionNode is a deliberate split by COST, not a variant of the others: it returns membership and nothing else, so "reading the lane can never pull an entry's BODY" — which is what lets a sheet place a table that lists every sheet without closing a loop (nodes/CollectionNode.ts:10-19). It exists only for lanes marked collection: true in the descriptor — COLLECTION_LANES = LANE_NAMES.filter(lane => LANES[lane].collection) (schema/references.ts:33), which at this commit is views and sheets (schema/lanes.ts:116, :118).
ObjectNode, ChainNode, SnapshotLaneNode, CollectionNode and SceneNode all extend LaneNode (nodes/LaneNode.ts:30), which is what puts them under the same dirty-gated pull contract the graph proper uses, so a hidden cross-lane read registers a real _dependents edge instead of an "always recompute" flag (nodes/LaneNode.ts:5-17).
2b. Every lane's factory
Every row is LAZY — constructed on first pull, memoized.
| Lane | Grain | Factory | Node type | |
|---|---|---|---|---|
materials | per LANE | materialsNodeOf (lanes/materials/nodes.ts:11) | `SnapshotLaneNode<(Operation | Container)[], MaterialData[]>` |
layers | per LANE | layersNodeOf (lanes/layers/nodes.ts:8) | `SnapshotLaneNode<(Operation | Container)[], Layer[]>` |
styles | per LANE | stylesNodeOf (lanes/styles/nodes.ts:8) | `SnapshotLaneNode<(Operation | Container)[], Style[]>` |
cameras | per LANE | camerasNodeOf (lanes/cameras/nodes.ts:8) | SnapshotLaneNode<CameraNode[], EvaluatedCamera[]> | |
skeletons | per ENTRY | skeletonNodeOf (lanes/skeletons/nodes.ts:10) | ChainNode<Chain, Skeleton> | |
animations | per ENTRY | animationNodeOf (lanes/animations/nodes.ts:25) | ChainNode<Chain, { clip; tracks }> | |
components | per ENTRY | componentNodeOf (lanes/components/nodes.ts:42) | ChainNode<Component, ResolvedComponentValue> | |
components (rigs) | per ENTRY | componentSkeletonsNodeOf (lanes/skeletons/nodes.ts:59), componentAnimationsNodeOf (lanes/animations/nodes.ts:49) | ChainNode<Component, Skeleton[]>, ChainNode<Component, { clips; tracks }> | |
textures | per ENTRY | textureNodeOf (lanes/textures/nodes.ts:11) | ChainNode<Chain, ImageBuffer> | |
tables | per ENTRY | tableNodeOf (lanes/tables/nodes.ts:9) | ChainNode<TableChain, TableValue> | |
views | per ENTRY | viewNodeOf (lanes/views/nodes.ts:71) | ChainNode<View, ResolvedView> | |
sheets | per ENTRY | sheetNodeOf (lanes/sheets/nodes.ts:66) | ChainNode<Sheet, ResolvedSheet> | |
objects (pseudo) | per OBJECT | objectNodeOf (lanes/objects/nodes.ts:24) | ObjectNode — the one lazy factory over an EAGERLY built node tree |
Two lanes reach their body through a CUT scope rather than the model root: components and textures are functions — .parent cut, entered only through placement args (scope: "isolated", schema/lanes.ts:107-108, rationale at schema/references.ts:40-44). sheets is isolated too, but with its own parameters as a real signature (schema/lanes.ts:113-114, schema/references.ts:45-53).
3. PULL — the one operation
After load there are no more stages. There is one operation, run at every grain: gate on isDirty, compute, commit.
3a. The template
BaseNode.evaluate(listener?) (nodes/BaseNode.ts:155-184):
- Attach the optional
"change"listener (:156). - The gate —
if (!this.isDirty) return this.value(:157). Serving the cache is not an evaluate, and deliberately does not open an observation attempt (:159-162). beginEvaluate(this)opens the attempt (:163). Every exit below settles it exactly once — including the superseded one, which produces no value and no error but still ends.retractReads()(:168, implementation:109-112) drops every reverse read edge, so a node that STOPS reading a target is removed from that target's_dependentsand stops cascading. Fresh edges re-register during the compute.pull(...)(:177-182, implementationcore/maybeAsync.ts:20-57) runswithEvaluating(this, () => this.compute())— pushing this node as "currently evaluating" for the whole duration, so a hidden cross-lane read reached synchronously from inside it (Table.get()from an expression,sampleTexture/resolvePlaced*from an operation) registers a real dependency edge back onto this node (nodes/BaseNode.ts:170-176)._commit(:189-209) runspostCompute(raw)(:133-142: non-finite-number check, thenconvert+validate), clearserror, settles, notifies"change"._fail(:212-227) records the message, setserrorValue()(:146—undefinedby default,[]forContainerNode), and logs ONCE per (node, message) because a broken model re-evaluates on every pass and an unthrottledconsole.errorspams serverless logs per request (:216-222).
Async. pull clears isDirty and captures cell._epoch up front (core/maybeAsync.ts:31-32). A non-thenable commits inline (:40-43). A thenable sets pending and defers; on settle, if (cell._epoch !== epoch) the result is DISCARDED as superseded (:47, :52). An async commit additionally cascades — markDependentsDirty(this) then scheduleRewalk(this.owner) (nodes/BaseNode.ts:208) — and scheduleRewalk coalesces N settles landing in one tick into ONE top-level re-walk via queueMicrotask (core/maybeAsync.ts:81-88).
3b. Three call shapes for the same operation
| Path | Site | Who drives it |
|---|---|---|
BaseNode.evaluate(listener?) | nodes/BaseNode.ts:155 | ordinary nodes — parameters, expressions, arguments, containers |
EntitiesNode.evaluateStep(input, definitions) | nodes/EntitiesNode.ts:303 | the owning fold — FoldRunner.run (nodes/ContainerNode.ts:58) |
LaneNode.pull(compute) / LaneNode.track(compute) | nodes/LaneNode.ts:86 / :58 | a lane node's own resolve() |
EntitiesNode.evaluate() does NOT compute. It registers the "change" listener and returns the cached value (nodes/EntitiesNode.ts:727-730). The reason is stated there: an operation never computes standalone — the owning fold pulls it through evaluateStep, which is what CONSUMES isDirty; the inherited BaseNode.evaluate() would clear the flag around a stub compute() and silently drop the edit signal, so the next fold pass would serve the stale cached step (nodes/EntitiesNode.ts:722-726).
evaluateStep (nodes/EntitiesNode.ts:303-330) has its own gate: !this.isDirty && this._state && this._inputState === input — object identity on the input array (:304). That is why FoldRunner keeps an identity-stable empty seed: a fresh [] literal per run would miss the first step's cache on every recompute and cascade a full re-run down the fold (nodes/ContainerNode.ts:32-35). It opens its attempt AFTER the gate (:312), retracts its own dynamic read edges (:315), and runs _applyStep inside withEvaluating(this, …) (:325).
LaneNode.track (nodes/LaneNode.ts:58-79) is the cycle guard: dependOn(this) first, then THROWS a named "Circular dependency" if THIS instance is already mid-resolve on the current call stack — any number of hops (:60-63). LaneNode.pull (:86-95) adds the single-value cache; a FAILED compute leaves isDirty untouched so the next resolve retries fresh (:83-85).
3c. Object isolation
Each object folds its OWN operations from an EMPTY stream, so no object can see another object's entities. ContainerNode "holds a list of nodes and runs them as its own fold (stream starts empty)" (nodes/ContainerNode.ts:124-126), each container owns its own FoldRunner (:157), and every FoldRunner.run starts from that runner's identity-stable seed: ChainState = [] (:35, used at :48).
objectKeys(model) reports that per-object grain — and returns EMPTY when a loose EntitiesNode sits at the root, because that turns the whole root back into one sequential fold and takes the isolation away (lanes/objects/nodes.ts:8-22).
The fold rule is ONE rule at every level (nodes/ContainerNode.ts:18-23): an operations list is a flat pipeline executed top-down over a stream that starts empty; an EntitiesNode step consumes the stream (producers concat); a ContainerNode child runs its own list by the same rule and its RESULT concats in like a producer, stamped with the child's key (:113-116); value nodes bind only and never touch the stream (:89).
3d. What each lane's pull produces
| Lane | Produces | Via |
|---|---|---|
objects (per object) | ObjectValue = { content: Entity[]; instance } | ObjectNode.resolve (nodes/ObjectNode.ts:141), door evaluateObject (lanes/objects/nodes.ts:40) |
materials | MaterialData[] | materialsNodeOf (lanes/materials/nodes.ts:11) |
layers | Layer[] | layersNodeOf → collectLayers (lanes/layers/layers.ts:146-184) |
styles | Style[] | stylesNodeOf → collectStyleNodes (lanes/styles/styles.ts:141) |
cameras | EvaluatedCamera[] | camerasNodeOf → collectCameras (lanes/cameras/nodes.ts:8) |
| lights | LightEntity[] | NOT a lane — collectLights(frame) scans the settled render frame for light entities (document/light.ts) |
| environment | ResolvedEnvironment | NOT a lane — resolveEnvironment(settings.environment) derives the sun from site + moment (document/environment.ts) |
skeletons | Skeleton per entry | skeletonNodeOf (lanes/skeletons/nodes.ts:10) → evaluateSkeletonChain (nodeMethods/skeleton.ts) |
animations | { clip: AnimationClip; tracks: Track[] } per entry | animationNodeOf (lanes/animations/nodes.ts:25) → evaluateAnimationChain |
components | ResolvedComponentValue per entry | componentNodeOf (lanes/components/nodes.ts:42) |
components (rigs) | Skeleton[] / { clips; tracks } | componentSkeletonsNodeOf (lanes/skeletons/nodes.ts:59), componentAnimationsNodeOf (lanes/animations/nodes.ts:49) |
textures | ImageBuffer per entry | textureNodeOf (lanes/textures/nodes.ts:11) |
tables | TableValue per entry | tableNodeOf → evaluateTableChain (lanes/tables/tables.ts:485) |
views | ResolvedView per entry | viewNodeOf (lanes/views/nodes.ts:71) |
sheets | ResolvedSheet per entry | sheetNodeOf (lanes/sheets/nodes.ts:66) |
| the 3D scene | ScenePull = { slices, frame } | SceneNode.resolve (nodes/LaneNode.ts:165) — see §4 |
tables shows the cross-lane edge concretely: tableNodeOf hands evaluateTableChain a lazy () => model.sceneFrame(...) thunk rather than a bare collectRenderFrame, precisely so the dependency edge is registered and a whole-scene tableSource scan re-resolves after ANY root-fold edit (lanes/tables/nodes.ts:9).
4. The whole-scene pull, and its fan-out
The 3D scene's pull is the one that does not fit the per-lane table, because it is where the engine stops being a graph of independent values and becomes one shared cache with four readers.
4a. One resolve builds both halves
Model._sceneNode is a SceneNode constructed in the Model constructor (Model.ts:313-322). Its collect callback is two lines:
const slices = rootFoldSlices(this.nodes)
return { slices, frame: frameFromSlices(this.nodes, slices) } Model.ts:316-317The comment above it states the intent: "One fold per dirty: slices + frame built together; tessellate/collectGeometry/host collectRenderFrame all read this cache" (Model.ts:309-311). The type carries the same statement: "Fold slices + render frame — ONE SceneNode resolve produces both" (packages/types/src/evaluate/frame.ts:54).
Its second callback is the dependency binding: while collecting, dependOn every root body node (Model.ts:319-321), so an edit ANYWHERE in the root fold dirties this node and every whole-scene consumer downstream of it.
The two halves:
rootFoldSlices(nodes)(rootFold.ts:82-108) →Map<sourceKey, Entity[]> | null. Throws immediately if acycleGateis set — the self-referential whole-scene embed guard (:83-86). Returnsnullwhen the root holds no looseEntitiesNode(:88), which is the normal shape: every root entry is aContainerNodethat evaluates itself. Otherwise it runs a persistentFoldRunnerkept per collection in aWeakMap(:55,:90-95) and slices the flat result by each entity'ssourceKey(:100-106).frameFromSlices(nodes, slices)(rootFold.ts:120-177) →RenderFrameSlice[], pure over already-collected slices and explicitly does NOT re-run the fold (:118-119). A slice is{ key, id, leaves, blocks }(pushed at:174). Instancing survives: aBlockEntitybecomes ablocks[]entry carrying{ leaves, placement, definition }(:161-167), with each definition's baked leaves memoized per collect (:126-139).
4b. Four shapes come off that one cache
scenePull(model, reader?) is the internal single-path source — model._sceneNode.resolve(reader) (core/scene.ts:12-14). Four consumers derive four different shapes of the same scene from it, and the differences are not cosmetic:
| # | Consumer | Function | Shape | What is different |
|---|---|---|---|---|
| 1 | host render | pull.frame, via sceneFrame (Model.ts:455-457) or collectRenderFrame (rootFold.ts:184-190) | RenderFrameSlice[] | block placements kept AS placements, in blocks[] |
| 2 | evaluate().geometry | collectGeometryFromSlices(nodes, pull.slices, diagnostics) (Model.ts:952, implementation rootFold.ts:240) | GeometryNode[] | "Same root combination as modelCombinationFromSlices, in EvaluateResult form (for hosts that consume evaluate() directly)" (rootFold.ts:238-239) |
| 3 | 3D tessellation | sceneTessellation → modelCombinationFromSlices(nodes, slices) (core/scene.ts:19-22) | Entity[] | BlockEntity placements KEPT — "so exporters with instancing can use definition + transform" (rootFold.ts:211-212) |
| 4 | drawing / paper | sceneDrawingEntities → same, { expandBlocks: true } (core/scene.ts:28-31) | Entity[] | placements EXPANDED into tessellated leaves, because "resolveView projects world triangles and has no definitions map of its own" (rootFold.ts:193-195) |
Shapes 3 and 4 are the same function with one option flipped (rootFold.ts:196-234); the branch is at :213, and the expand door is expandAndTessellate (:225). Both re-stamp sourceKey with the top-level object's key rather than the producing operation's, because "object grain wins — producer stamps (operation keys) are fold-internal" (rootFold.ts:216-218).
All four skip a slice whose node's return type is in NON_RENDERABLE_DOMAINS = new Set(["material", "layer"]) (rootFold.ts:27, consulted at :143, :203, :250).
Two re-entrancy facts about this cache, both load-bearing:
collectRenderFrameavoids re-entry while theSceneNodeis mid-collect —if (scene && !scene.resolving)(rootFold.ts:186-188) — because collect builds the frame throughframeFromSlicesdirectly, never through this function.- A nested whole-scene pull DURING a collect runs an ephemeral nested collect and deliberately does NOT write
this.value, since the outer collect still owns the cache (nodes/LaneNode.ts:174-176). A self-referential embed (viewEntity/tableEntity) stampscycleGatefirst, and then that nested resolve throws by name instead (nodes/LaneNode.ts:168-172).
4c. Model.evaluate(params) is a separate door, and does not fold the scene
Model.evaluate(params) (Model.ts:895-968):
applyOverrides(this, params)(:896, implementationcore/scene.ts:114-134) rewrites matchingParameterNodeinputs as JSON literals (core/scene.ts:118, so"red"becomes'"red"'rather than a bare identifier) and dirties EVERY node viatraverse(:129). Overrides are transient — restored in afinally(Model.ts:965-967, restore closure atcore/scene.ts:130-133).this.nodes.evaluate()(Model.ts:898) — a flat loop over top-level elements (nodes/NodeCollection.ts:407-409). This is the header/binding pass; it does not run the scene fold.- Pull the materials and layers lanes, merging
registry.builtinMaterialsunder the document's own by name (Model.ts:901-914). - Install
geometryas a lazy, memoized getter viaObject.defineProperty(Model.ts:936-959) — shape 2 in the table above. The reason is inline (:928-934):.geometryis a full-mesh tessellation pass, and the web viewport reads.nodesand syncs through the separatecollectRenderFramepath, so it never touches.geometryand now never computes it. The bake re-applies the same transient overrides before folding (:949), because the pull runs after thefinallyrestore has already put the document's own inputs back.
5. Where the engine stops
The engine's last act is producing one of the four shapes in §4b. It does not render, and it does not diff.
renderDiff.ts provides the reconcile helpers, and nothing in this repo calls them outside their own tests: diffEntityFrame (renderDiff.ts:108) and fireFrameHooks (:163) appear only in __tests__/renderFrame.test.ts and __tests__/sceneHooks.test.ts. They are the host's half of the contract, shipped alongside the engine.
What they define:
contentKey(entity) (renderDiff.ts:46-86) — two independent FNV-1a 32-bit passes with different offset bases, plus the source length (:26-28). The pattern is stated at :5-16: a host renders by SNAPSHOT, keys every leaf entity by a content hash of its geometry, and diffs against the previous frame — the virtual-DOM pattern. It works because evaluation is deterministic (same params → bit-identical floats → identical hashes), and the failure mode is safe: a hash miss only re-tessellates, it can never render a stale shape. Two exclusions are deliberate (:14-16):
idis excluded — a freshcrypto.randomUUID()on every recompute (nodeMethods/utils.ts:31), so it could never match across frames.- props are excluded (material / layer / name), so "same shape, new paint" is a cheap props update instead of a rebuild.
For a multi-face wrap the key composes FNV over PER-FACE serializations rather than one whole-brep stringify, so editing one face does not re-key the whole body (:41-45, loop at :63-68).
diffEntityFrame(previous, next) (:108-135) — a multiset reconcile, order-independent, O(previous + next) (:102-107), returning four buckets (:134):
| Bucket | Meaning |
|---|---|
added | a next-frame entity whose content key matched no previous cell (:121-124) |
kept | matched, and material / layer / name are all unchanged (:126-127) |
updated | matched by geometry, but a prop differs — the props-only update (:128) |
removed | previous cells that no next entity claimed (:132-133) |
fireFrameHooks(host, diff) (:163-176, added at 3fc55c49) brackets that transition with the scene-tier mount points — added IS a mount, removed IS an unmount, so it only brackets what the diff already computed (:150-153). Three rules hold there: pairing is the law, every before gets exactly one after even when a handler throws (:156-158); a throwing handler may not blank the rest of the frame but is not swallowed either — errors are collected and rethrown as ONE AggregateError after the whole frame has fired (:142-148, :175); and unmounts run before mounts, so a handler counting what is live never sees both at once (:165-166).
5a. The narrower boundary: per-object channels
There is a second, narrower door out of the engine that skips the frame entirely. subscribeObject(model, key, listener) (lanes/objects/nodes.ts:54-73) fires with the channel that actually moved — "content", "transform" or "props" — and is skipped entirely when a re-resolve produces the same object (:44-49, channel chosen at :113-117). flushObjectChannels (:98-120) gates on the node's dirty EPOCH rather than isDirty, because _epoch bumps on every dirty and survives evaluation: a consumer that pulls evaluateObject(key) between the edit and the notification would otherwise swallow the announcement (:102-108).
That is what makes a move a matrix write instead of a re-tessellation. ObjectNode.resolve hands the unchanged content prefix on by IDENTITY, never copied — "an unchanged prefix must stay === across a tail-only edit" (nodes/ObjectNode.ts:149-155) — and tailSteps lifts the longest trailing move / rotate / apply* suffix out of the geometry into one placement (:66-80, composed by compileTail at :99-119). A step qualifies only when it applies UNIFORMLY to the whole stream: a per-entity callback arg is not one matrix and stops the tail, and so does a locked step anywhere in the body, since a lifted placement has no way to exempt part of the content (:57-65, :71).
6. INVALIDATION — how the loop closes
An edit does not re-run anything. It MARKS, and the next pull decides. There are two mechanisms, because there are two kinds of edit, plus two special cases.
6a. Mark
Three functions, all in nodes/BaseNode.ts:
setDirty(node, source?)(:21-32) — setsisDirty, bumps_epoch, no cascade. Every direct dirty write in the engine must go through it (ormarkDirty) so an in-flight async compute is invalidated (:18-20). Both dirty hook points fire here, around the marking, because this is the one place every dirty passes through (:22-27).markDirty(node, visited, source?)(:34-42) —setDirty, then walksnode._dependentsandnode.parenttransitively, guarded by avisitedset.markDependentsDirty(node)(:47-53) — the async-settle variant: dirty what READS this node, not the node itself, because it just computed its fresh value.
CollectionNode deliberately does NOT use the parent walk: its dirtyReaders (nodes/CollectionNode.ts:45-47) walks only _dependents, because LaneNode points parent at the ModelScope and the built-in cascade would dirty the whole model on every membership change (:41-44).
6b. Mechanism 1 — a GRAPH edit cascades on its own
An ArgumentNode.setInput needs nothing special: it cascades precisely through the ordinary _dependents / markDirty chain, "same as ever" (core/scene.ts:42-46).
The next pull then walks the same three call shapes from §3b, and each gate decides whether to recompute:
- an ordinary node:
if (!this.isDirty) return this.value(nodes/BaseNode.ts:157); - a fold step:
!isDirty && _state && _inputState === input(nodes/EntitiesNode.ts:304) — so an edit to step k re-runs only k..N, even across interleaved containers (nodes/ContainerNode.ts:25-28); - a lane node:
if (!this.isDirty) { dependOn(this); return this.value }(nodes/LaneNode.ts:87).
CollectionNode and EntryNode are the exception to "gate then serve": both compute INLINE on every read and compare against the last snapshot, dirtying their readers when it moved (nodes/CollectionNode.ts:95-113, :60-81). The reason is at :115-127: validate-on-read only fires if the reader actually reads, and a table's ChainNode returns its cached value without calling compute at all, so nothing would ever reach the collection again — the same verify step a pull-based incremental system runs before trusting a memo, cheap here because it never touches a body.
6c. Mechanism 2 — a LANE JSON edit has no node to dirty
Editing a lane array in place (tables / textures / components / views / sheets) touches JSON that has no ArgumentNode of its own to dirty (core/scene.ts:33-38). That is what invalidateLaneNodes(model) (core/scene.ts:53-77) exists for — the response to a "change" notification on the root collection.
It blanket-visits every ALREADY-CONSTRUCTED lane node, but the invalidation is not blanket: each node's own hasChanged() (nodes/LaneNode.ts:114-116) does a content diff of that ONE record's or chain's own small JSON against a snapshot taken at its last resolve() (snapshotSource, :101-103), and only the ones that actually moved are dirtied. The comment states the purpose directly: "editing one texture/component/table must not force every OTHER already-resolved one to recompute too" (core/scene.ts:38-42).
What it replaced is recorded in the same comment: a whole-lane JSON.stringify hash — _texturesSignature — that was paid on every resolve of every chain (core/scene.ts:41-42, and again at nodes/LaneNode.ts:109-113). The per-record diff is cheap because it is bounded by one record's own JSON.
Two further details:
- It uses
markDirty, notsetDirty, because a changed record must cascade to whatever registered a dependency on it — a baresetDirtytouches only the ONE node, "which would leave every DOWNSTREAM consumer serving its stale cached value forever" (core/scene.ts:54-58). _sceneNodeis deliberately excluded (core/scene.ts:47-52): a lane-JSON edit never changes the 3D scene on its own, and the one path where it would matter — atableEntity/viewEntity/imageEntity/placeComponent/displaceoperation consuming the edited lane — already cascades to it precisely through that operation's OWN dependency edge plus the fold-adjacency wiring.
ChainNode.resolveWith adds a second layer for its per-call-argument memo: the hot path compares memoEpoch !== _epoch (a plain integer compare) and clears the memo wholesale when they differ, retracting the union of its reads at the same time (nodes/LaneNode.ts:260-269). The content diff is NOT run per call — it runs once per touch event inside invalidateLaneNodes, which bumps _epoch, which this check then picks up (nodes/LaneNode.ts:254-259).
6d. Special case — async supersede
Covered mechanically in §3a: pull captures cell._epoch before computing (core/maybeAsync.ts:32) and, on settle, discards the result when the epoch moved (:47, :52), invoking onSuperseded instead. That callback exists so an observer that opened on entry can still close, even though the attempt produced neither a value nor an error (core/maybeAsync.ts:25-29).
6e. Special case — an asset finished loading
An asset that finishes loading AFTER the first evaluate is an update with no JSON edit at all. The Store listener wired at Model.ts:511-514 calls onAssetLoaded(model, url) (core/scene.ts:88-102), which:
- dirties every
url-methodParameterNodewhose literal input matches the url (core/scene.ts:89-95) — the ordinarymarkDirtycascade then reaches thetextCurve/imageEntity/ pattern-image consumers, so nothing serves stale pixels; - dirties every texture chain and every component chain wholesale (
:96-97), because a component may hold its OWN url-method parameter, invisible to the root scan, and itsresolveWithmemo has no other invalidation edge to late-arriving pixels (core/scene.ts:82-87); - re-resolves material textures via
resolveProcessorTextures(:98); - notifies
"change"on the root collection so host renderers, which subscribe there, learn that pixels arrived without a JSON edit (:99-101).
6f. And back to a pull
Nothing above computed anything. The loop closes when a consumer asks again: the next sceneFrame / evaluateObject / Table.get / evaluateView pull hits a gate that is now open, recomputes only what was marked, and the fan-out in §4b hands the result to whichever of the four shapes that consumer wanted.