Appearance
Template Engine — How It Works
How a parametric template becomes drawing primitives: registry →
build()→ composition → IR → WASM placement. The single shipped template is the Level-1sewage-pumping-station; the multi-level L0/L1/L2 walkthrough below is a conceptual illustration of the composition engine's capabilities.
This page traces the composition/execution engine end to end so you can analyze it. For authoring syntax see the Template Language Reference; for the conceptual model see the Overview.
Conceptual illustration (not shipped templates): The sections below use an example multi-level set — four Level-0 blocks (
submersible-pump-unit,wetwell-level-cluster,inline-flowmeter,discharge-manifold), a Level-1pump-station-unitthat composes them, and a Level-2pumping-station-areathat composes two L1 units — to demonstrate how the composition engine resolves a 2-deep L2→L1→L0 tree. These are illustrative fixtures used in the composition-pipeline tests; the only template shipped in the library issewage-pumping-station(Level 1).
Pipeline at a glance
author register execute (build) materialise
─────── ───────── ─────────────── ───────────
Template → registry Map → BuildContextImpl.build() → IR (TemplateOutput)
.define() (slug → tmpl) ├─ place / pipe / route │
├─ compose(slug,…) ◄── recursion (child ctx, same registry)
└─ connect / branch / when / repeat
▼
app/engine/irMapper → place_template(json, ox, oy) → WASM → CRDT canvasThere are two BuildContext implementations in the tree — important to know:
- SDK execution context
flow/services/template-execution/build-context-impl.ts— the one the registry + composition pipeline use; accumulates aTemplateOutputIR viagetOutput(). This is what the composition tests exercise. - Canvas/engine context
app/engine/templateBuildContext.ts+app/engine/irMapper.ts+app/engine/templateExecutor.ts— maps aTemplateIRto the WASMplace_templatepayload for actual on-canvas placement. They are parallel implementations of the samectx.*surface; a template'sbuild()runs against whichever context the calling path supplies.
1. Registry — discovery & storage
flow/templates/registry.ts holds a module-level Map<string, RegisteredTemplate>.
registerAllTemplates()dynamicallyimport()s the./library/level-0|1|2barrels in parallel, walks each module's named exports, and registers anything that passes the type-guard_registered === true && typeof slug === 'string'(that flag is set byTemplate.define).- It is on-demand and async — NOT auto-invoked at module load. It must be called once at app bootstrap before any
ctx.compose()runs (otherwise compose can't resolve slugs).getTemplate(slug)is the lookup the execution context uses;getRegisteredSlugs()feeds the LLM library index. - Tests register templates directly with
registerTemplate(tmpl)(and_clearRegistryForTests()between cases), bypassing bootstrap.
2. ctx.compose(slug, params, { at }) — the heart of multi-level
In build-context-impl.ts:
- Record dependency — pushes
sluginto_composedSlugs(drivesmetadata.composedTemplates). - Resolve —
registry.getTemplate(slug). If the registry is null or the slug is missing → emits a warning and places a placeholder node@template/<slug>(so a missing block is visible, not silently dropped). - Execute inner — merges supplied params over the inner template's defaults, then spins up a child
BuildContextImplsharing the same registry. That shared registry is what enables nesting — the child can itselfcompose(). The child runs the innerbuild(). - Copy + offset — every inner node/pipe/label/note/boundary/group is copied into the parent with an id prefix (
<slug>_c<n>_) and its position/waypoints offset byat. - Return interfaces —
ComposedTemplate.interfacesare the inner template'sinterfaces(params)with eachpositionoffset byat. These offset interface points are what the parent passes toctx.connect(). - Propagate deps upward — the child's
_composedSlugsbubble up, so a Level-2 output'smetadata.composedTemplatestransitively lists the L1 slug and the L0 slugs it pulled in.
⚠ Honest finding — the "max depth 2" rule is not enforced. The spec says nesting is capped at 2 levels, but there is no depth counter or guard in the code. Because the child context shares the registry, an L3→L2→L1→L0 chain would resolve to arbitrary depth without error, and a template that composes itself would infinite-loop / stack-overflow (also unguarded). The shipped templates stay within the 2-deep shape by construction; the engine does not reject deeper or cyclic nesting. (Good candidate for a follow-up: a depth/cycle guard in _executeInnerTemplate.)
3. ctx.connect(from, to) — interface → routed pipe
connect() resolves each ref via resolveInterfacePosition():
- an
InterfacePoint→ its.positiondirectly, - a
{ symbol, port }→ the placed symbol's position plus the port's local offset, - a bare string →
null(unresolved).
It then creates a pipe connect_<n> routed through exactly two waypoints [fromPos, toPos]. Because composed interface positions are already offset (step 2.5 above), connecting two composed interfaces yields a pipe in the parent coordinate system — e.g. the L2 area wiring duty→storage lands at [40,0]→[80,20].
4. IR → WASM place_template
The SDK execution context accumulates private arrays and emits a TemplateOutput IR via getOutput() (node/pipe/label/boundary/group lists + metadata.{composedTemplates,warnings}).
The canvas placement path (separate from the IR-accumulation path) is:
app/engine/irMapper.ts→irToPlaceTemplatePayload(ir)converts aTemplateIR(grid units ×TEMPLATE_GRID_MM, 1 GU = 8 mm) into aPlaceTemplatePayload,app/engine/templateExecutor.ts→executeTemplate()serializes it and calls the WASM entryengine.place_template(json, origin_x, origin_y), where the engine offsets every element by the drop origin, creates a group, batch-adds elements + connections, and syncs the CRDT canvas.
5. Illustrative counts (from the composition engine tests)
The composition-pipeline tests use small inline fixture templates (fixture-pump, fixture-manifold) to verify position offsets, connect waypoints, and dependency propagation — without depending on any shipped library template other than sewage-pumping-station.
To illustrate how node counts compound across levels (conceptually, using the example templates described in the note above): per-block real-node counts would be submersible-pump-unit = 5, wetwell-level-cluster = 5, inline-flowmeter = 4, discharge-manifold = N isolation valves. A composed L1 pump-station-unit at pump_count=3 would total 3×5 + 5 + 3 + 4 = 27 nodes; an L2 pumping-station-area composing two such units (duty=3, storage=2) would total 27 + 21 = 48 nodes with full 2-deep resolution.
The assertions covering these mechanics live in flow/services/template-execution/__tests__/composition-pipeline.test.ts — run it to observe registry resolution, offset propagation, connect waypoints, and dependency tracking.
How to observe it yourself
npx vitest run flow/services/template-execution/__tests__/composition-pipeline.test.ts flow/templates/__tests__/level-1-templates.test.ts— verifies registry resolution, compose offsets, connect waypoints, dependency propagation, and the shipped sewage-pumping-station end-to-end execution.- In the app, the Template Stencil (
useTemplateStencil) lists the single shipped template (sewage-pumping-station); placing it runsbuild()throughplace_templateonto the canvas.
Limitations / follow-ups (observed)
- No depth or cycle guard in
compose()(see §2) — the depth-2 cap is convention, not enforcement. registerAllTemplates()is not auto-called — composition silently degrades to placeholder nodes if bootstrap didn't register the library.- Two parallel BuildContext implementations (SDK vs
app/engine) — a divergence risk; they should be reconciled or one delegated to the other. - The SDK's authoring-facing
compose()is typed to returnunknown, so reading.interfacesoff a compose result requires aComposedTemplatecast (seepump-station-unit.ts/pumping-station-area.ts).
Related
- Template Language Reference · Overview · Pumping Stations
- Engine code:
flow/templates/registry.ts,flow/services/template-execution/build-context-impl.ts,app/engine/irMapper.ts,app/engine/templateExecutor.ts