diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73c10eb..83d6b89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
All notable changes to Swift Loop. Versions follow SemVer; the [v0.x.y] anchor links to the GitHub release.
+## [Unreleased]
+
+### Added
+- **Layers (3D lattice).** The Iterations section gains a **Layers** count: the grid becomes a Columns × Rows × Layers cube of clones. Each cell's layer index `l` (plus `layers` and `tz`) is exposed to formulas, which own the projection to 2D — the 3D look lives in formulas and library presets, not a built-in projection. New **Cube** library preset shows it off (oblique projection, near layers larger/brighter). Defaults to 1, so existing patterns are byte-identical.
+
## [v0.2.0] — 2026-05-22
### Added
diff --git a/README.md b/README.md
index 62c8cba..3973fd3 100644
--- a/README.md
+++ b/README.md
@@ -114,7 +114,7 @@ rotation = t * 360
scale = 0.4 + 0.6 * sin(t * PI)
```
-You can use: `i` (index), `n` (total), `c` (column), `r` (row), `cols`, `rows`, `t` (0 to 1), `tx`, `ty`, `w`, `h`, `seed`.
+You can use: `i` (index), `n` (total), `c` (column), `r` (row), `l` (layer), `cols`, `rows`, `layers`, `t` (0 to 1), `tx`, `ty`, `tz` (0 to 1 across layers), `w`, `h`, `seed`.
Functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `sqrt`, `pow`, `exp`, `log`, `abs`, `min`, `max`, `floor`, `ceil`, `round`, `mod`, `rand()`.
diff --git a/docs/controls.md b/docs/controls.md
index d1f3d19..96ce17a 100644
--- a/docs/controls.md
+++ b/docs/controls.md
@@ -22,6 +22,8 @@ The first section. This is where you set the count.
**Rows.** How many rows. 1 to 100. Leave at 1 for linear and radial patterns.
+**Layers.** Depth layers (Z), 1 to 50. The grid becomes a Columns × Rows × Layers cube of clones. On its own it just stacks more copies in place — the 3D look comes from a formula (or a library preset like **Cube**) that reads the layer index `l`. Leave at 1 for a flat 2D pattern.
+
**Angle.** Degrees of per-cell rotation, applied to each clone's grid offset around the source center. Leave at 0 for straight lines and rectangular grids. Bump it 5 to 30 degrees and a line curls into a spiral, a grid swirls. Crank it past 90 to wrap the pattern back around on itself. Think "how much do successive cells lean".
If you've applied a library pattern, you'll also see a little pill showing its name. Click it to jump back to the library and pick something else.
diff --git a/docs/formulas.md b/docs/formulas.md
index 5173327..0666105 100644
--- a/docs/formulas.md
+++ b/docs/formulas.md
@@ -46,11 +46,13 @@ In every formula, these variables are defined for you:
`r` is the clone's row index.
-`cols` and `rows` are the grid dimensions.
+`l` is the clone's layer index — the depth (Z) axis. It's 0 unless you set Layers above 1, which turns the grid into a Columns × Rows × Layers cube. Use it to write your own 3D projection, or start from the **Cube** library preset.
+
+`cols`, `rows`, and `layers` are the grid dimensions (layers is the depth axis).
`t` is the most useful one. It's `i / (n - 1)`, so it goes from 0 to 1 across the whole loop. If you want anything to happen "smoothly across the loop", multiply or scale by `t`.
-`tx` and `ty` are the same thing but for columns and rows. Smooth 0-to-1 horizontally and vertically.
+`tx` and `ty` are the same thing but for columns and rows. Smooth 0-to-1 horizontally and vertically. `tz` is the same across layers — 0 at the back, 1 at the front.
`w` and `h` are the source shape's width and height in pixels. Use these for tight tiling.
diff --git a/docs/llm-pattern-guide.md b/docs/llm-pattern-guide.md
index 333809d..11805b1 100644
--- a/docs/llm-pattern-guide.md
+++ b/docs/llm-pattern-guide.md
@@ -47,13 +47,14 @@ You, the LLM reading this, are helping a designer write a new library pattern. Y
| `author` | recommended | `@handle` form. |
| `cols` | yes | Integer 1 to 100. The default column count when the pattern loads. |
| `rows` | yes | Integer 1 to 100. The default row count. Use `1` for linear or radial patterns. |
+| `layers` | optional | Integer 1 to 100. Depth layers (Z) — turns the grid into a Columns × Rows × Layers lattice. Each cell's `l`/`layers`/`tz` are exposed to formulas, which project it to 2D (there is no built-in projection). Default `1` (flat). See the `Cube` preset. |
| `angle` | optional | Number, -360 to 360. Per-cell rotation in degrees applied to the grid offset around the source center, *after* the formulas compute `x` and `y`. Cell `i` is rotated by `angle * i`. Lets a pattern declare a spiral or swirl without folding the rotation into every formula. Default `0`. See "Using `angle`" below. |
| `showFirst` | optional | Defaults to `true`. Set to `false` only for radial or spiral patterns where the `i=0` clone naturally lands away from the origin, and you want the source shape to stay visually centered. See "showFirst" below. |
| `formulas` | yes | Object. Any subset of `x`, `y`, `rotation`, `scaleX`, `scaleY`, `opacity`. Omit properties that should stay at their default. |
### Existing tags (please reuse)
-`radial`, `grid`, `wave`, `curve`, `linear`, `random`, `chaos`, `spiral`, `polar`, `rotation`, `scale`, `tiling`, `organic`, `arc`, `physics`.
+`radial`, `grid`, `wave`, `curve`, `linear`, `random`, `chaos`, `spiral`, `polar`, `rotation`, `scale`, `tiling`, `organic`, `arc`, `physics`, `3d`.
Only invent a new tag when nothing existing fits.
@@ -68,14 +69,17 @@ Every formula has access to these:
| Var | Meaning | Range |
|---|---|---|
| `i` | Linear clone index | `0` to `n-1` |
-| `n` | Total clones | `cols * rows` |
+| `n` | Total clones | `cols * rows * layers` |
| `c` | Column index | `0` to `cols-1` |
| `r` | Row index | `0` to `rows-1` |
+| `l` | Layer index (depth/Z) | `0` to `layers-1` |
| `cols` | Column count | from config |
| `rows` | Row count | from config |
+| `layers` | Layer count | from config |
| `t` | Normalized index | `i / (n-1)`, so `0` to `1` across the whole loop |
| `tx` | Normalized column | `c / (cols-1)`, so `0` to `1` across columns |
| `ty` | Normalized row | `r / (rows-1)`, so `0` to `1` across rows |
+| `tz` | Normalized layer | `l / (layers-1)`, so `0` to `1` from back to front |
| `w` | Source shape width | px |
| `h` | Source shape height | px |
| `seed` | Random seed | integer, user-controllable |
diff --git a/layers-demo.html b/layers-demo.html
new file mode 100644
index 0000000..91b44e8
--- /dev/null
+++ b/layers-demo.html
@@ -0,0 +1,364 @@
+
+
+
Same 4×4×6 lattice, rendered by the real Swift Loop engine. Each tier adds one transform that reads the layer index l. Cells drawn as squares (not the default ellipse) so orientation is visible. Brightness = depth.
Same 4×4×6 lattice, rendered by the real Swift Loop engine. Each tier adds one transform that reads the layer index l. Cells drawn as squares (not the default ellipse) so orientation is visible. Brightness = depth.
+
+
${panels}
+
+`
+
+const outPath = 'layers-demo.html'
+writeFileSync(outPath, html)
+console.log(`wrote ${outPath} — ${tiers.map((t) => `${t.title}:${t.rects.length}`).join(', ')}`)
+console.log(`viewBox ${vbX.toFixed(1)} ${vbY.toFixed(1)} ${vbW.toFixed(1)} ${vbH.toFixed(1)}`)
diff --git a/src/plugin/engine/cells.ts b/src/plugin/engine/cells.ts
index e53e976..5615cbb 100644
--- a/src/plugin/engine/cells.ts
+++ b/src/plugin/engine/cells.ts
@@ -8,8 +8,44 @@ import type { CompiledFormulas, LoopConfig, Scope } from '../../shared/types'
import { applyAngleToOffset } from './angle'
import type { CompiledFactors } from './compile'
import { applyEasing } from './easing'
+import { rand } from './prng'
import { buildScope } from './scope'
+// Hard cap on cells rendered/cloned in one loop. cols×rows alone tops out at
+// 2500 (50×50), but the Layers axis multiplies that — an unguarded 50×50×50
+// would be 125k nodes and freeze the host. We clamp the iteration count, so a
+// runaway config renders a (truncated) result instead of hanging.
+export const MAX_CELLS = 10000
+
+export function cellCount(config: { cols: number; rows: number; layers?: number }): number {
+ return Math.min(MAX_CELLS, config.cols * config.rows * (config.layers ?? 1))
+}
+
+// Default depth direction (degrees): up and to the right, the classic
+// isometric-ish reading. cos/sin of 35° ≈ (0.82, 0.57).
+export const DEFAULT_DEPTH_DIR = 35
+
+// Cell indices [0, cellCount) in back-to-front paint order — the order a
+// painter's-algorithm consumer (the SVG preview) should append them so the
+// near layer ends up on top. With one layer this is just natural order.
+// 'near-top' (default) paints far layers (high l) first; 'far-top' keeps
+// natural order so deep layers land in front.
+export function paintOrder(config: {
+ cols: number
+ rows: number
+ layers?: number
+ stackOrder?: 'near-top' | 'far-top'
+}): number[] {
+ const n = cellCount(config)
+ const order = Array.from({ length: n }, (_, i) => i)
+ const layers = config.layers ?? 1
+ if (layers <= 1 || (config.stackOrder ?? 'near-top') === 'far-top') return order
+ const perLayer = config.cols * config.rows
+ // stable sort by descending layer → far layers come first (painted at back)
+ order.sort((a, b) => Math.floor(b / perLayer) - Math.floor(a / perLayer))
+ return order
+}
+
export interface CellValues {
i: number
c: number
@@ -38,18 +74,26 @@ export interface EvaluateCellInput {
export function evaluateCell(i: number, input: EvaluateCellInput): CellValues {
const { config, compiled, factors, sourceWidth, sourceHeight } = input
- const c = i % config.cols
- const r = Math.floor(i / config.cols)
+ // Flat index → 3D address. Cells fill a layer (cols × rows) before advancing
+ // to the next layer, so `i` 0..(cols*rows-1) is layer 0, and so on.
+ const layers = config.layers ?? 1
+ const perLayer = config.cols * config.rows
+ const l = Math.floor(i / perLayer)
+ const within = i % perLayer
+ const c = within % config.cols
+ const r = Math.floor(within / config.cols)
const scope = buildScope(
{
cols: config.cols,
rows: config.rows,
+ layers,
seed: config.seed,
sourceWidth,
sourceHeight,
},
c,
r,
+ l,
)
const rotated = applyAngleToOffset(
{ x: compiled.x.evaluate(scope, 'x'), y: compiled.y.evaluate(scope, 'y') },
@@ -57,19 +101,65 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues {
config.angle,
)
const baseEased = applyEasing(config.easing, computeInterpFactor(config, scope.tx, scope.ty))
+
+ // Per-axis transforms, applied on top of the evaluated cell. Column/Row/Layer
+ // each add a clone rotation; Layer adds an oblique depth offset; each axis can
+ // fade opacity along its run; Layer can sweep the colour ramp by depth (tz).
+ // All default to 0/false, so a config without them is untouched.
+ let x = rotated.x
+ let y = rotated.y
+ const layerStep = config.layerStep ?? 0
+ if (layerStep !== 0) {
+ const dir = ((config.layerDirection ?? DEFAULT_DEPTH_DIR) * Math.PI) / 180
+ x += l * layerStep * Math.cos(dir)
+ y -= l * layerStep * Math.sin(dir) // screen y is down, so subtract to go up
+ }
+ const layerRandom = config.layerRandom ?? 0
+ if (layerRandom !== 0) {
+ x += (rand(config.seed, scope.i, 'layerRandomX') - 0.5) * 2 * layerRandom
+ y += (rand(config.seed, scope.i, 'layerRandomY') - 0.5) * 2 * layerRandom
+ }
+ const rotation =
+ compiled.rotation.evaluate(scope, 'rotation') +
+ c * (config.columnAngle ?? 0) +
+ r * (config.rowAngle ?? 0) +
+ l * (config.layerAngle ?? 0)
+ const opacity =
+ compiled.opacity.evaluate(scope, 'opacity') -
+ scope.tx * (config.columnFade ?? 0) -
+ scope.ty * (config.rowFade ?? 0) -
+ scope.tz * (config.layerFade ?? 0)
+ const colourFactor = config.layerColour ? scope.tz : baseEased
+
+ // Per-axis Scale: a uniform size change ramping along each axis (like Fade,
+ // but for scale). Factors combine multiplicatively. Clamped at 0 so the far
+ // end can vanish but not flip inside-out.
+ const scaleMul = Math.max(
+ 0,
+ (1 + scope.tx * ((config.columnScale ?? 0) / 100)) *
+ (1 + scope.ty * ((config.rowScale ?? 0) / 100)) *
+ (1 + scope.tz * ((config.layerScale ?? 0) / 100)),
+ )
+ // scaleX/scaleY are size *deltas* added to the source size downstream
+ // (renderedW = sourceWidth + scaleX). So fold the per-axis multiplier through
+ // the whole rendered size — this works even when the base delta is 0, and at
+ // mul=1 it collapses back to exactly the base delta (no change to old configs).
+ const baseScaleX = compiled.scaleX.evaluate(scope, 'scaleX')
+ const baseScaleY = compiled.scaleY.evaluate(scope, 'scaleY')
+
return {
i,
c,
r,
scope,
- x: rotated.x,
- y: rotated.y,
- rotation: compiled.rotation.evaluate(scope, 'rotation'),
- scaleX: compiled.scaleX.evaluate(scope, 'scaleX'),
- scaleY: compiled.scaleY.evaluate(scope, 'scaleY'),
- opacity: compiled.opacity.evaluate(scope, 'opacity'),
- fillFactor: factors.fill ? factors.fill.evaluate(scope, 'fillFactor') : baseEased,
- strokeFactor: factors.stroke ? factors.stroke.evaluate(scope, 'strokeFactor') : baseEased,
+ x,
+ y,
+ rotation,
+ scaleX: (sourceWidth + baseScaleX) * scaleMul - sourceWidth,
+ scaleY: (sourceHeight + baseScaleY) * scaleMul - sourceHeight,
+ opacity,
+ fillFactor: factors.fill ? factors.fill.evaluate(scope, 'fillFactor') : colourFactor,
+ strokeFactor: factors.stroke ? factors.stroke.evaluate(scope, 'strokeFactor') : colourFactor,
strokeWeightFactor: factors.strokeWeight
? factors.strokeWeight.evaluate(scope, 'strokeWeightFactor')
: baseEased,
diff --git a/src/plugin/engine/evaluate.ts b/src/plugin/engine/evaluate.ts
index 3d667f7..bfd96b7 100644
--- a/src/plugin/engine/evaluate.ts
+++ b/src/plugin/engine/evaluate.ts
@@ -14,11 +14,14 @@ export function compileFormula(source: string, _propertyKey: string): CompiledFo
n: scope.n,
c: scope.c,
r: scope.r,
+ l: scope.l,
cols: scope.cols,
rows: scope.rows,
+ layers: scope.layers,
t: scope.t,
tx: scope.tx,
ty: scope.ty,
+ tz: scope.tz,
w: scope.w,
h: scope.h,
seed: scope.seed,
diff --git a/src/plugin/engine/scope.ts b/src/plugin/engine/scope.ts
index ac57438..6ee2147 100644
--- a/src/plugin/engine/scope.ts
+++ b/src/plugin/engine/scope.ts
@@ -4,24 +4,31 @@ import type { Scope } from '../../shared/types'
export interface ScopeInput {
cols: number
rows: number
+ layers?: number // depth layers (Z); defaults to 1 (a flat grid)
seed: number
sourceWidth: number
sourceHeight: number
}
-export function buildScope(input: ScopeInput, c: number, r: number): Scope {
- const i = r * input.cols + c
- const n = input.cols * input.rows
+// `l` (layer index) defaults to 0 so 2D callers can keep passing just (c, r).
+export function buildScope(input: ScopeInput, c: number, r: number, l = 0): Scope {
+ const layers = input.layers ?? 1
+ const perLayer = input.cols * input.rows
+ const i = l * perLayer + r * input.cols + c
+ const n = perLayer * layers
return {
i,
n,
c,
r,
+ l,
cols: input.cols,
rows: input.rows,
+ layers,
t: n > 1 ? i / (n - 1) : 0,
tx: input.cols > 1 ? c / (input.cols - 1) : 0,
ty: input.rows > 1 ? r / (input.rows - 1) : 0,
+ tz: layers > 1 ? l / (layers - 1) : 0,
w: input.sourceWidth,
h: input.sourceHeight,
seed: input.seed,
diff --git a/src/plugin/host-loop.ts b/src/plugin/host-loop.ts
index 83db7a8..04861d9 100644
--- a/src/plugin/host-loop.ts
+++ b/src/plugin/host-loop.ts
@@ -33,6 +33,10 @@ export async function startHostLoop(adapter: HostAdapter, bridge: HostBridge): P
bridge.on('loop:update', async (payload) => {
const { config, commit } = payload as { config: LoopConfig; commit: boolean }
+ // Hosts that can't repaint a full regenerate per drag frame (Penpot) act on
+ // commits (mouseup) only; uncommitted live-drag frames are dropped, so the
+ // sliders stay smooth and the canvas catches up on release.
+ if (!commit && !adapter.liveUpdates) return
const source = adapter.getSelectedNode()
if (!source) return
try {
diff --git a/src/plugin/hosts/figma/adapter.ts b/src/plugin/hosts/figma/adapter.ts
index 793dbc5..09dc29f 100644
--- a/src/plugin/hosts/figma/adapter.ts
+++ b/src/plugin/hosts/figma/adapter.ts
@@ -24,6 +24,10 @@ const SUPPORTED_SELECTION_TYPES = new Set([
])
export class FigmaAdapter implements HostAdapter {
+ // Figma repaints a full regenerate per drag frame comfortably.
+ readonly liveUpdates = true
+ readonly maxCells = 10_000
+
// dynamic-page documentAccess requires pages to be loaded before traversal.
// Cache the promise so concurrent first callers share one await.
private _pagesLoaded: Promise | null = null
diff --git a/src/plugin/hosts/host.ts b/src/plugin/hosts/host.ts
index 3144b1c..5e73fe9 100644
--- a/src/plugin/hosts/host.ts
+++ b/src/plugin/hosts/host.ts
@@ -38,6 +38,16 @@ export interface SvgExportResult {
}
export interface HostAdapter {
+ // --- capabilities ---
+ // Can the host absorb a full regenerate on every drag frame? Figma can;
+ // Penpot's reactive document + WASM renderer can't — hundreds of node
+ // mutations per frame trip React #185 ("max update depth") and freeze it —
+ // so it regenerates on commit (mouseup) only and drops live-drag frames.
+ readonly liveUpdates: boolean
+ // Hard ceiling on generated nodes for this host. Figma copes with ~10k;
+ // Penpot's renderer degrades far sooner, so it caps much lower.
+ readonly maxCells: number
+
// --- selection ---
getSelectedNode(): NodeSnapshot | null
onSelectionChange(cb: () => void): () => void
diff --git a/src/plugin/hosts/penpot/adapter.ts b/src/plugin/hosts/penpot/adapter.ts
index ca14397..e3f370a 100644
--- a/src/plugin/hosts/penpot/adapter.ts
+++ b/src/plugin/hosts/penpot/adapter.ts
@@ -48,6 +48,13 @@ function rgbToHex(c: ColorRGB): string {
}
export class PenpotAdapter implements HostAdapter {
+ // Penpot's reactive document + WASM renderer can't take a full regenerate per
+ // drag frame, so regenerate on commit only (see host-loop). The cell ceiling
+ // is a crash guard for the render engine — conservative; tune against real
+ // Penpot.
+ readonly liveUpdates = false
+ readonly maxCells = 1000
+
// undoBlockBegin returns the wrapper `Symbol` type; capture it via ReturnType
// so we don't hand-write the banned identifier.
private undoBlock: ReturnType | null = null
diff --git a/src/plugin/loop/diff.ts b/src/plugin/loop/diff.ts
index feb26d9..064438d 100644
--- a/src/plugin/loop/diff.ts
+++ b/src/plugin/loop/diff.ts
@@ -38,7 +38,25 @@ export function diffConfig(
if (ctx && ctx.previousSourceId !== ctx.currentSourceId) {
return { mode: 'full', dirty: ALL_PROPS }
}
- if (prev.cols !== next.cols || prev.rows !== next.rows) {
+ if (
+ prev.cols !== next.cols ||
+ prev.rows !== next.rows ||
+ (prev.layers ?? 1) !== (next.layers ?? 1) ||
+ (prev.columnAngle ?? 0) !== (next.columnAngle ?? 0) ||
+ (prev.rowAngle ?? 0) !== (next.rowAngle ?? 0) ||
+ (prev.layerStep ?? 0) !== (next.layerStep ?? 0) ||
+ (prev.layerDirection ?? 0) !== (next.layerDirection ?? 0) ||
+ (prev.stackOrder ?? 'near-top') !== (next.stackOrder ?? 'near-top') ||
+ (prev.layerAngle ?? 0) !== (next.layerAngle ?? 0) ||
+ (prev.columnFade ?? 0) !== (next.columnFade ?? 0) ||
+ (prev.rowFade ?? 0) !== (next.rowFade ?? 0) ||
+ (prev.layerFade ?? 0) !== (next.layerFade ?? 0) ||
+ (prev.columnScale ?? 0) !== (next.columnScale ?? 0) ||
+ (prev.rowScale ?? 0) !== (next.rowScale ?? 0) ||
+ (prev.layerScale ?? 0) !== (next.layerScale ?? 0) ||
+ (prev.layerColour ?? false) !== (next.layerColour ?? false) ||
+ (prev.layerRandom ?? 0) !== (next.layerRandom ?? 0)
+ ) {
return { mode: 'full', dirty: ALL_PROPS }
}
diff --git a/src/plugin/loop/orchestrator.ts b/src/plugin/loop/orchestrator.ts
index 51e152e..745f090 100644
--- a/src/plugin/loop/orchestrator.ts
+++ b/src/plugin/loop/orchestrator.ts
@@ -1,6 +1,6 @@
// src/plugin/loop/orchestrator.ts
import type { LoopConfig } from '../../shared/types'
-import { evaluateCell } from '../engine/cells'
+import { cellCount, evaluateCell } from '../engine/cells'
import { compileConfig, compileFactors } from '../engine/compile'
import type { HostAdapter, NodeSnapshot } from '../hosts/host'
import { applyToClone } from './apply'
@@ -91,10 +91,22 @@ async function fullRegen(
const factors = compileFactors(config)
if (!parentId) return
- const n = config.cols * config.rows
- const cloneIds: string[] = []
+ const n = Math.min(adapter.maxCells, cellCount(config))
- for (let i = 1; i < n; i++) {
+ // Clone creation order sets z-order: each insertChild(0) shoves earlier clones
+ // toward the front. Default near-top = natural order (front layer ends on top).
+ // far-top creates deep layers first so they finish in front.
+ const order: number[] = []
+ for (let i = 1; i < n; i++) order.push(i)
+ if ((config.layers ?? 1) > 1 && (config.stackOrder ?? 'near-top') === 'far-top') {
+ const perLayer = config.cols * config.rows
+ order.sort((a, b) => Math.floor(b / perLayer) - Math.floor(a / perLayer))
+ }
+
+ // Indexed by cell i so in-place updates (which address cloneIds[i-1]) stay
+ // correct no matter what order we created the clones in.
+ const cloneById: string[] = new Array(n)
+ for (const i of order) {
const cloneId = await adapter.cloneNode(source.id, {
parentId,
index: 0,
@@ -139,9 +151,11 @@ async function fullRegen(
]),
})
- cloneIds.push(cloneId)
+ cloneById[i] = cloneId
}
+ const cloneIds = cloneById.slice(1)
+
const groupId = await adapter.groupNodes([source.id, ...cloneIds], {
parentId,
name: 'SwiftLoopGroup',
@@ -170,7 +184,7 @@ async function inPlaceMutation(
const compiled = compileConfig(config)
const factors = compileFactors(config)
const dirty = new Set(diff.dirty as DirtyProperty[])
- const n = config.cols * config.rows
+ const n = Math.min(adapter.maxCells, cellCount(config))
for (let i = 1; i < n; i++) {
const cloneId = prev.cloneIds[i - 1]
diff --git a/src/preview/render-loop.ts b/src/preview/render-loop.ts
index 7217661..d9ab3e2 100644
--- a/src/preview/render-loop.ts
+++ b/src/preview/render-loop.ts
@@ -2,7 +2,7 @@
// transform — callers (scene host) wrap it in another to position,
// rotate, and scale the whole loop on the canvas.
-import { evaluateCell } from '../plugin/engine/cells'
+import { evaluateCell, paintOrder } from '../plugin/engine/cells'
import { compileConfig, compileFactors } from '../plugin/engine/compile'
import { sampleRamp } from '../shared/color'
import type { Color, ColorRamp, LoopConfig } from '../shared/types'
@@ -33,9 +33,9 @@ export function renderLoop(opts: RenderLoopOptions): SVGGElement {
const compiled = compileConfig(config)
const factors = compileFactors(config)
- const n = Math.max(1, config.cols * config.rows)
- const start = config.showFirst === false ? 1 : 0
- for (let i = start; i < n; i++) {
+ // Append in back-to-front depth order so the near layer lands on top.
+ for (const i of paintOrder(config)) {
+ if (i === 0 && config.showFirst === false) continue
const cell = evaluateCell(i, {
config,
compiled,
diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts
index e76e34e..e5a39fe 100644
--- a/src/shared/defaults.ts
+++ b/src/shared/defaults.ts
@@ -22,6 +22,7 @@ const scalar = (value: number): ScalarProperty => ({
export const DEFAULT_CONFIG: LoopConfig = {
cols: 10,
rows: 10,
+ layers: 1,
angle: 0,
x: num(60),
y: num(60),
diff --git a/src/shared/types.ts b/src/shared/types.ts
index d4bba87..1d2d1a7 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -41,11 +41,41 @@ export interface LoopConfig {
// Iteration
cols: number
rows: number
+ // Number of depth layers (Z axis): the grid becomes a Columns × Rows × Layers
+ // lattice. The engine only emits the cells and exposes each cell's layer index
+ // (`l`) to the formula scope — projection to 2D is done in formulas / library
+ // presets, not by a built-in projection. Defaults to 1 (a flat grid), so
+ // existing patterns are byte-identical. Optional for back-compat with saved
+ // configs that predate it.
+ layers?: number
// Degrees of per-cell rotation around the source center, applied to the
// grid offset (values.x, values.y) post-formula. Cell i is rotated by
// angle * i degrees, so a 1-row line + nonzero angle traces a spiral.
angle: number
+ // Per-axis transforms (additive post-process; 0/false = no effect, so configs
+ // that predate these render identically). Column step is `x` and Row step is
+ // `y` (the NumericProperties below); Layer has its own oblique step. Each axis
+ // adds a clone rotation and an opacity falloff, and Layer can sweep the
+ // fill/stroke ramp by depth.
+ columnAngle?: number // deg of clone rotation added per column (× c)
+ rowAngle?: number // × r
+ layerStep?: number // px depth offset per layer (× l), along layerDirection
+ layerDirection?: number // deg, direction of the depth offset (default 35 = up-right)
+ layerAngle?: number // deg of clone rotation per layer (× l)
+ // Z-order of overlapping depth layers. 'near-top' (default) keeps the front
+ // layer (l=0) on top — the natural reading of a receding stack. 'far-top'
+ // flips it so deep layers sit in front.
+ stackOrder?: 'near-top' | 'far-top'
+ columnFade?: number // % opacity lost across columns (× tx)
+ rowFade?: number // × ty
+ layerFade?: number // × tz (back-to-front)
+ columnScale?: number // % size change toward the last column (× tx); -100 = vanishes
+ rowScale?: number // × ty
+ layerScale?: number // × tz; negative = perspective falloff (far layers smaller)
+ layerColour?: boolean // sweep the fill/stroke ramp by depth (factor = tz)
+ layerRandom?: number // px of seeded random position jitter per cell
+
// Base transforms (per-step)
x: NumericProperty
y: NumericProperty
@@ -88,11 +118,14 @@ export interface Scope {
n: number
c: number
r: number
+ l: number // layer index (Z), 0-based
cols: number
rows: number
+ layers: number
t: number
tx: number
ty: number
+ tz: number // normalized layer position in [0, 1] (0 when layers === 1)
w: number
h: number
seed: number
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index cba16de..df7f094 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -7,12 +7,12 @@ import { ResizeHandle } from './components/ResizeHandle'
import { resetKeepingPattern } from './config-ops'
import { useLooperConfig } from './hooks/useLooperConfig'
import { AppearanceSection } from './sections/AppearanceSection'
-import { IterationsSection } from './sections/IterationsSection'
+import { AxisSection } from './sections/AxisSection'
+import { LayerSection } from './sections/LayerSection'
import { LibraryOverlay } from './sections/LibraryOverlay'
import { ModulationSection } from './sections/ModulationSection'
import { PresetsSection } from './sections/PresetsSection'
import { SnapshotsBar } from './sections/SnapshotsBar'
-import { TransformSection } from './sections/TransformSection'
const SNAPSHOTS_KEY = 'swift-loop:snapshots'
@@ -180,16 +180,58 @@ export function App() {