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 @@ + + + + + +Swift Loop — Layers transform tiers + + + +
+

Layers, three tiers of transforms

+

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.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Tier 1 + Step only + extruded stack +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Tier 2 + + rotation + twisted tower +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Tier 3 + + scale + spiral tunnel +
+
+ + \ No newline at end of file diff --git a/library/_schema.json b/library/_schema.json index 25f5adb..1e78e9b 100644 --- a/library/_schema.json +++ b/library/_schema.json @@ -10,8 +10,14 @@ "description": { "type": "string", "maxLength": 200 }, "tags": { "type": "array", "items": { "type": "string" }, "maxItems": 8 }, "author": { "type": "string" }, - "cols": { "type": "integer", "minimum": 1, "maximum": 100 }, - "rows": { "type": "integer", "minimum": 1, "maximum": 100 }, + "cols": { "type": "integer", "minimum": 1, "maximum": 120 }, + "rows": { "type": "integer", "minimum": 1, "maximum": 120 }, + "layers": { + "type": "integer", + "minimum": 1, + "maximum": 120, + "description": "Number of depth layers (Z). The grid becomes a Columns × Rows × Layers lattice; each cell's layer index `l` (plus `layers` and `tz`) is exposed to formulas, which project it to 2D. Default 1 (flat)." + }, "angle": { "type": "number", "minimum": -360, diff --git a/library/concentric-squares.json b/library/concentric-squares.json new file mode 100644 index 0000000..db95ce3 --- /dev/null +++ b/library/concentric-squares.json @@ -0,0 +1,13 @@ +{ + "id": "concentric-squares", + "name": "Concentric Squares", + "description": "Nested square rings — Rows are the rings, Columns the points around each square's perimeter. The squircle map (cos/max(|cos|,|sin|)) traces a true square outline.", + "tags": ["grid", "polar", "tiling"], + "author": "@swiftner", + "cols": 32, + "rows": 5, + "formulas": { + "x": "x = cos(c * TAU / cols) / max(abs(cos(c * TAU / cols)), abs(sin(c * TAU / cols))) * (r + 1) * {x:26}", + "y": "y = sin(c * TAU / cols) / max(abs(cos(c * TAU / cols)), abs(sin(c * TAU / cols))) * (r + 1) * {y:26}" + } +} diff --git a/library/cube.json b/library/cube.json new file mode 100644 index 0000000..3822605 --- /dev/null +++ b/library/cube.json @@ -0,0 +1,17 @@ +{ + "id": "cube", + "name": "Cube", + "description": "A 3D lattice of dots in oblique projection. Layers step back diagonally; nearer ones grow and brighten, farther ones shrink and dim. Drag X/Y to resize the cube, or Layers for its depth.", + "tags": ["3d", "grid"], + "author": "@swiftner", + "cols": 5, + "rows": 5, + "layers": 5, + "formulas": { + "x": "x = (c - (cols - 1) / 2) * {x:64} + (tz - 0.5) * 170", + "y": "y = (r - (rows - 1) / 2) * {y:64} - (tz - 0.5) * 170", + "scaleX": "scaleX = (tz - 0.5) * 24", + "scaleY": "scaleY = (tz - 0.5) * 24", + "opacity": "opacity = 55 + tz * 45" + } +} diff --git a/library/cylinder.json b/library/cylinder.json new file mode 100644 index 0000000..53cfad9 --- /dev/null +++ b/library/cylinder.json @@ -0,0 +1,17 @@ +{ + "id": "cylinder", + "name": "Cylinder", + "description": "A tube of dots — Columns wrap around, Rows climb its height, Layers add inner shells. Tilted so the near wall reads larger and brighter.", + "tags": ["3d", "radial", "grid"], + "author": "@swiftner", + "cols": 22, + "rows": 12, + "layers": 1, + "formulas": { + "x": "x = cos(c * TAU / cols) * {x:130} * (1 - 0.4 * tz)", + "y": "y = (ty - 0.5) * {y:320} - sin(c * TAU / cols) * 24", + "scaleX": "scaleX = sin(c * TAU / cols) * 8", + "scaleY": "scaleY = sin(c * TAU / cols) * 8", + "opacity": "opacity = 74 + sin(c * TAU / cols) * 26" + } +} diff --git a/library/damped-wave.json b/library/damped-wave.json index 0976b37..852b1bb 100644 --- a/library/damped-wave.json +++ b/library/damped-wave.json @@ -7,6 +7,6 @@ "cols": 40, "rows": 1, "formulas": { "x": "x = i * w / 4", - "y": "y = sin(i * 0.5) * exp(-i * 0.08) * 80" + "y": "y = sin(i * 0.5) * exp(-i * 0.08) * {y:80}" } } diff --git a/library/halftone.json b/library/halftone.json index 6961ea0..e70c88f 100644 --- a/library/halftone.json +++ b/library/halftone.json @@ -8,8 +8,8 @@ "formulas": { "x": "x = c * w", "y": "y = r * h", - "scaleX": "scaleX = 1 - sqrt((c - cols / 2)^2 + (r - rows / 2)^2) / 8", - "scaleY": "scaleY = 1 - sqrt((c - cols / 2)^2 + (r - rows / 2)^2) / 8", + "scaleX": "scaleX = -sqrt((c - cols / 2)^2 + (r - rows / 2)^2) * w * 0.12", + "scaleY": "scaleY = -sqrt((c - cols / 2)^2 + (r - rows / 2)^2) * h * 0.12", "opacity": "opacity = 100 - sqrt((c - cols / 2)^2 + (r - rows / 2)^2) * 12" } } diff --git a/library/helix.json b/library/helix.json new file mode 100644 index 0000000..da47f4a --- /dev/null +++ b/library/helix.json @@ -0,0 +1,17 @@ +{ + "id": "helix", + "name": "Helix", + "description": "A coil climbing the canvas, the near side of each loop large and bright. Set Layers to the number of strands: 1 is a spring, 2 a double helix (DNA), more a twisted rope.", + "tags": ["3d", "spiral", "curve"], + "author": "@swiftner", + "cols": 60, + "rows": 1, + "layers": 1, + "formulas": { + "x": "x = cos(tx * TAU * 3 + l * TAU / layers) * {x:120}", + "y": "y = (tx - 0.5) * {y:320}", + "scaleX": "scaleX = sin(tx * TAU * 3 + l * TAU / layers) * 12", + "scaleY": "scaleY = sin(tx * TAU * 3 + l * TAU / layers) * 12", + "opacity": "opacity = 70 + sin(tx * TAU * 3 + l * TAU / layers) * 30" + } +} diff --git a/library/lissajous.json b/library/lissajous.json index b89a2d8..2c0380f 100644 --- a/library/lissajous.json +++ b/library/lissajous.json @@ -6,7 +6,7 @@ "author": "@swiftner", "cols": 80, "rows": 1, "formulas": { - "x": "x = sin(t * TAU * 3) * 150", - "y": "y = sin(t * TAU * 2) * 150" + "x": "x = sin(t * TAU * 3) * {x:150}", + "y": "y = sin(t * TAU * 2) * {y:150}" } } diff --git a/library/phyllotaxis.json b/library/phyllotaxis.json index 867b4f9..3bbf7d3 100644 --- a/library/phyllotaxis.json +++ b/library/phyllotaxis.json @@ -6,7 +6,7 @@ "author": "@swiftner", "cols": 100, "rows": 1, "formulas": { - "x": "x = cos(i * 2.39996) * sqrt(i) * 18", - "y": "y = sin(i * 2.39996) * sqrt(i) * 18" + "x": "x = cos(i * 2.39996) * sqrt(i) * {x:18}", + "y": "y = sin(i * 2.39996) * sqrt(i) * {y:18}" } } diff --git a/library/polar-grid.json b/library/polar-grid.json index 4025759..3ee8e2c 100644 --- a/library/polar-grid.json +++ b/library/polar-grid.json @@ -6,7 +6,7 @@ "author": "@swiftner", "cols": 12, "rows": 5, "formulas": { - "x": "x = cos(c * TAU / cols) * (r + 1) * 20", - "y": "y = sin(c * TAU / cols) * (r + 1) * 20" + "x": "x = cos(c * TAU / cols) * (r + 1) * {x:20}", + "y": "y = sin(c * TAU / cols) * (r + 1) * {y:20}" } } diff --git a/library/ribbon.json b/library/ribbon.json index 832cf64..10b8ccc 100644 --- a/library/ribbon.json +++ b/library/ribbon.json @@ -6,7 +6,7 @@ "author": "@swiftner", "cols": 20, "rows": 6, "formulas": { - "x": "x = c * 25 + sin(ty * TAU + tx * PI) * 30", - "y": "y = r * 30" + "x": "x = c * {x:25} + sin(ty * TAU + tx * PI) * 30", + "y": "y = r * {y:30}" } } diff --git a/library/sphere.json b/library/sphere.json new file mode 100644 index 0000000..0bf73fd --- /dev/null +++ b/library/sphere.json @@ -0,0 +1,17 @@ +{ + "id": "sphere", + "name": "Sphere", + "description": "A ball of dots. Columns wrap around as longitude, Rows as latitude, and Layers nest inward as shells (1 = a hollow shell). The front face reads large and bright, the back recedes.", + "tags": ["3d", "radial", "organic"], + "author": "@swiftner", + "cols": 20, + "rows": 12, + "layers": 1, + "formulas": { + "x": "x = sin(ty * PI) * cos(c * TAU / cols) * {x:150} * (1 - 0.55 * tz)", + "y": "y = cos(ty * PI) * {y:150} * (1 - 0.55 * tz)", + "scaleX": "scaleX = sin(ty * PI) * sin(c * TAU / cols) * 16", + "scaleY": "scaleY = sin(ty * PI) * sin(c * TAU / cols) * 16", + "opacity": "opacity = 70 + sin(ty * PI) * sin(c * TAU / cols) * 30" + } +} diff --git a/library/square-spiral.json b/library/square-spiral.json new file mode 100644 index 0000000..72b3429 --- /dev/null +++ b/library/square-spiral.json @@ -0,0 +1,13 @@ +{ + "id": "square-spiral", + "name": "Square Spiral", + "description": "An expanding square (Archimedean) spiral — the boxy cousin of Spiral. Radius grows with t while the squircle map keeps the turns square.", + "tags": ["spiral", "tiling"], + "author": "@swiftner", + "cols": 120, + "rows": 1, + "formulas": { + "x": "x = cos(t * TAU * 4) / max(abs(cos(t * TAU * 4)), abs(sin(t * TAU * 4))) * t * {x:200}", + "y": "y = sin(t * TAU * 4) / max(abs(cos(t * TAU * 4)), abs(sin(t * TAU * 4))) * t * {y:200}" + } +} diff --git a/library/torus.json b/library/torus.json new file mode 100644 index 0000000..0e26391 --- /dev/null +++ b/library/torus.json @@ -0,0 +1,17 @@ +{ + "id": "torus", + "name": "Torus", + "description": "A donut of dots seen at a tilt — Columns run around the ring, Rows around the tube. The near side of the ring grows and brightens, the far side recedes.", + "tags": ["3d", "polar", "radial"], + "author": "@swiftner", + "cols": 28, + "rows": 10, + "layers": 1, + "formulas": { + "x": "x = ({x:150} + 42 * cos(r * TAU / rows)) * cos(c * TAU / cols)", + "y": "y = ({y:150} + 42 * cos(r * TAU / rows)) * sin(c * TAU / cols) * 0.5 + 42 * sin(r * TAU / rows)", + "scaleX": "scaleX = sin(c * TAU / cols) * 9", + "scaleY": "scaleY = sin(c * TAU / cols) * 9", + "opacity": "opacity = 72 + sin(c * TAU / cols) * 28" + } +} diff --git a/library/truchet.json b/library/truchet.json new file mode 100644 index 0000000..e4329e3 --- /dev/null +++ b/library/truchet.json @@ -0,0 +1,14 @@ +{ + "id": "truchet", + "name": "Truchet", + "description": "A uniform grid where every tile is rotated a random quarter-turn (0/90/180/270°). Reroll the seed for a new arrangement. Classic generative tiling — best with an asymmetric source shape.", + "tags": ["grid", "tiling", "random"], + "author": "@swiftner", + "cols": 10, + "rows": 10, + "formulas": { + "x": "x = c * w", + "y": "y = r * h", + "rotation": "rotation = floor(rand() * 4) * 90" + } +} diff --git a/library/wave.json b/library/wave.json index b749f80..a1a096a 100644 --- a/library/wave.json +++ b/library/wave.json @@ -6,7 +6,7 @@ "author": "@swiftner", "cols": 30, "rows": 1, "formulas": { - "x": "x = c * 20", - "y": "y = sin(tx * TAU * 2) * 60" + "x": "x = c * {x:20}", + "y": "y = sin(tx * TAU * 2) * {y:60}" } } diff --git a/package.json b/package.json index 1b96718..0971139 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:penpot": "bun run build && node scripts/build-penpot.mjs", "build:all": "bun run build:penpot && bun run build:preview", "dev": "bun run build && bun run build:preview && bunx --bun serve -l 4173 .", + "dev:watch": "build-figma-plugin --watch & bun run watch:preview & bunx --bun serve -l 4173 .", "package": "bash scripts/package.sh" }, "dependencies": { diff --git a/scripts/layers-demo.ts b/scripts/layers-demo.ts new file mode 100644 index 0000000..78260ce --- /dev/null +++ b/scripts/layers-demo.ts @@ -0,0 +1,223 @@ +// Throwaway: renders the three per-axis-transform "tiers" for Layers, side by +// side, using the REAL engine (compileConfig + evaluateCell) driven by fx +// formulas that project the layer index `l` into 2D. Run: `bun scripts/layers-demo.ts` +import { writeFileSync } from 'node:fs' +import { cellCount, evaluateCell } from '../src/plugin/engine/cells' +import { compileConfig, compileFactors } from '../src/plugin/engine/compile' +import { DEFAULT_CONFIG } from '../src/shared/defaults' +import type { LoopConfig, NumericProperty } from '../src/shared/types' + +const COLS = 4 +const ROWS = 4 +const LAYERS = 6 +const BASE = 30 // base cell size in px (source w/h) + +const fx = (formula: string): NumericProperty => ({ + value: 0, + end: null, + random: 0, + unlocked: true, + formula, +}) + +// Shared grid term, centered on (0,0): (c - (COLS-1)/2) * pitch +const GX = `(c - ${(COLS - 1) / 2}) * 52` +const GY = `(r - ${(ROWS - 1) / 2}) * 52` + +function makeConfig(over: Partial): LoopConfig { + return { + ...DEFAULT_CONFIG, + cols: COLS, + rows: ROWS, + layers: LAYERS, + angle: 0, + fill: { stops: [] }, + stroke: { stops: [] }, + // front layers brighter — pure depth cue, same in every tier + opacity: fx(`opacity = 42 + l * ${Math.round(58 / (LAYERS - 1))}`), + ...over, + } +} + +// Tier 1 — STACK: grid extruded into depth (oblique offset per layer) +const stack = makeConfig({ + x: fx(`x = ${GX} + l * 28`), + y: fx(`y = ${GY} - l * 24`), + rotation: fx('rotation = 0'), + scaleX: fx('scaleX = 0'), + scaleY: fx('scaleY = 0'), +}) + +// Tier 2 — TWIST: each layer's plane rotated around center + self-rotation +const TW = 'l * 0.26' +const twist = makeConfig({ + x: fx(`x = (${GX}) * cos(${TW}) - (${GY}) * sin(${TW}) + l * 28`), + y: fx(`y = (${GX}) * sin(${TW}) + (${GY}) * cos(${TW}) - l * 24`), + rotation: fx('rotation = l * 15'), + scaleX: fx('scaleX = 0'), + scaleY: fx('scaleY = 0'), +}) + +// Tier 3 — TUNNEL: far layers small + converge to center + spiral +const D = `(${LAYERS - 1} - l)` // depth-from-front +const F = `pow(0.78, ${D})` // compound scale factor +const SP = `${D} * 0.40` // spiral angle +const tunnel = makeConfig({ + x: fx(`x = ((${GX}) * cos(${SP}) - (${GY}) * sin(${SP})) * ${F}`), + y: fx(`y = ((${GX}) * sin(${SP}) + (${GY}) * cos(${SP})) * ${F}`), + rotation: fx(`rotation = ${D} * 22`), + scaleX: fx(`scaleX = ${BASE} * (${F} - 1)`), + scaleY: fx(`scaleY = ${BASE} * (${F} - 1)`), +}) + +interface Rect { + x: number + y: number + w: number + h: number + cx: number + cy: number + rot: number + op: number + l: number +} + +function renderCells(config: LoopConfig): Rect[] { + const compiled = compileConfig(config) + const factors = compileFactors(config) + const n = cellCount(config) + const out: Rect[] = [] + for (let i = 0; i < n; i++) { + const cell = evaluateCell(i, { + config, + compiled, + factors, + sourceWidth: BASE, + sourceHeight: BASE, + }) + const w = Math.max(1, BASE + cell.scaleX) + const h = Math.max(1, BASE + cell.scaleY) + const x = cell.x - cell.scaleX / 2 + const y = cell.y - cell.scaleY / 2 + out.push({ + x, + y, + w, + h, + cx: x + w / 2, + cy: y + h / 2, + rot: cell.rotation, + op: Math.max(0, Math.min(1, cell.opacity / 100)), + l: cell.scope.l, + }) + } + return out +} + +const tiers = [ + { title: 'Step only', sub: 'extruded stack', rects: renderCells(stack) }, + { title: '+ rotation', sub: 'twisted tower', rects: renderCells(twist) }, + { title: '+ scale', sub: 'spiral tunnel', rects: renderCells(tunnel) }, +] + +// Shared bbox across all tiers → identical scale, honest comparison +let minX = Infinity +let minY = Infinity +let maxX = -Infinity +let maxY = -Infinity +for (const t of tiers) { + for (const r of t.rects) { + minX = Math.min(minX, r.x) + minY = Math.min(minY, r.y) + maxX = Math.max(maxX, r.x + r.w) + maxY = Math.max(maxY, r.y + r.h) + } +} +const pad = 24 +const vbX = minX - pad +const vbY = minY - pad +const vbW = maxX - minX + pad * 2 +const vbH = maxY - minY + pad * 2 + +function depthColor(l: number): string { + const t = LAYERS > 1 ? l / (LAYERS - 1) : 0 + const lerp = (a: number, b: number) => Math.round(a + (b - a) * t) + return `rgb(${lerp(74, 150)}, ${lerp(88, 214)}, ${lerp(196, 255)})` +} + +function svgFor(rects: Rect[]): string { + const body = rects + .map((r) => { + const transform = r.rot ? ` transform="rotate(${r.rot.toFixed(2)} ${r.cx.toFixed(2)} ${r.cy.toFixed(2)})"` : '' + return `` + }) + .join('\n') + return `\n${body}\n` +} + +const panels = tiers + .map( + (t, i) => ` +
+
${svgFor(t.rects)}
+
+ Tier ${i + 1} + ${t.title} + ${t.sub} +
+
`, + ) + .join('\n') + +const html = ` + + + + +Swift Loop — Layers transform tiers + + + +
+

Layers, three tiers of transforms

+

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() {
Select a single Vector, Shape, Text, or Group
)}
- setLibraryOpen(true)} - onClearPattern={() => setAppliedName(null)} + sourceSize={sourceSize} + count={config.cols} + onCount={(v, commit) => update({ ...config, cols: v }, commit)} + stepKey="x" + stepLabel="X step" + angle={config.columnAngle ?? 0} + onAngle={(v, commit) => update({ ...config, columnAngle: v }, commit)} + scale={config.columnScale ?? 0} + onScale={(v, commit) => update({ ...config, columnScale: v }, commit)} + fade={config.columnFade ?? 0} + onFade={(v, commit) => update({ ...config, columnFade: v }, commit)} + chip={ + + } + /> + update({ ...config, rows: v }, commit)} + stepKey="y" + stepLabel="Y step" + angle={config.rowAngle ?? 0} + onAngle={(v, commit) => update({ ...config, rowAngle: v }, commit)} + scale={config.rowScale ?? 0} + onScale={(v, commit) => update({ ...config, rowScale: v }, commit)} + fade={config.rowFade ?? 0} + onFade={(v, commit) => update({ ...config, rowFade: v }, commit)} /> - + + - )} - {open &&
{children}
} + {open && ( +
+ {hint ?

{hint}

: null} + {children} +
+ )} ) } diff --git a/src/ui/components/SliderRow.tsx b/src/ui/components/SliderRow.tsx index d79ca1f..8c8422f 100644 --- a/src/ui/components/SliderRow.tsx +++ b/src/ui/components/SliderRow.tsx @@ -13,6 +13,7 @@ interface Props { formula?: string // expandable formula editor content onFormulaChange?: (next: string) => void onChange: (next: number, commit: boolean) => void + disabled?: boolean // dimmed + non-interactive (e.g. an axis with count 1) } export function SliderRow({ @@ -26,6 +27,7 @@ export function SliderRow({ formula, onFormulaChange, onChange, + disabled, }: Props) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(value.toString()) @@ -76,13 +78,17 @@ export function SliderRow({ const hasFormula = formula !== undefined && onFormulaChange !== undefined return ( -
+
{label} {hasFormula ? ( - {appliedName && ( - - )} - - } - > - update({ ...config, cols: Math.max(1, Math.round(v)) }, commit)} - /> - update({ ...config, rows: Math.max(1, Math.round(v)) }, commit)} - /> - update({ ...config, angle: v }, commit)} - /> - - ) -} diff --git a/src/ui/sections/LayerSection.tsx b/src/ui/sections/LayerSection.tsx new file mode 100644 index 0000000..7131287 --- /dev/null +++ b/src/ui/sections/LayerSection.tsx @@ -0,0 +1,124 @@ +import { DEFAULT_DEPTH_DIR } from '../../plugin/engine/cells' +import type { LoopConfig } from '../../shared/types' +import { Section } from '../components/Section' +import { SliderRow } from '../components/SliderRow' +import { MAX_AXIS } from '../config-ops' + +interface Props { + config: LoopConfig + update: (next: LoopConfig, commit?: boolean) => void +} + +// The depth (Z) axis. A peer of Column and Row: it stacks copies of the grid +// and offers the same family of per-axis controls (Count, Step, Twist, Scale, +// Fade, Random), plus depth-only extras — the offset Direction, a colour-by- +// depth sweep, and the stacking order. +export function LayerSection({ config, update }: Props) { + // With a single layer there's no depth, so the depth controls do nothing yet. + const noDepth = (config.layers ?? 1) <= 1 + return ( +
+ update({ ...config, layers: Math.max(1, Math.round(v)) }, commit)} + /> + update({ ...config, layerStep: v }, commit)} + /> + update({ ...config, layerDirection: v }, commit)} + /> + update({ ...config, layerAngle: v }, commit)} + /> + update({ ...config, layerScale: v }, commit)} + /> + update({ ...config, layerFade: v }, commit)} + /> + update({ ...config, layerRandom: v }, commit)} + /> + + +
+ ) +} diff --git a/src/ui/sections/LibraryOverlay.tsx b/src/ui/sections/LibraryOverlay.tsx index 05c46a0..772aa2b 100644 --- a/src/ui/sections/LibraryOverlay.tsx +++ b/src/ui/sections/LibraryOverlay.tsx @@ -34,6 +34,7 @@ function applyEntry(config: LoopConfig, entry: LibraryEntry): LoopConfig { ...config, cols: entry.cols, rows: entry.rows, + layers: entry.layers ?? 1, angle: entry.angle ?? 0, fxMode: true, showFirst: entry.showFirst ?? true, diff --git a/src/ui/sections/ModulationSection.tsx b/src/ui/sections/ModulationSection.tsx index 69a174c..2a4c076 100644 --- a/src/ui/sections/ModulationSection.tsx +++ b/src/ui/sections/ModulationSection.tsx @@ -9,16 +9,29 @@ interface Props { sourceSize: { width: number; height: number } | null } +// Human labels matching the Appearance section (scaleX/scaleY are "Size X/Y"). +const RANDOM_LABELS = { + rotation: 'Rotation', + scaleX: 'Size X', + scaleY: 'Size Y', + opacity: 'Opacity', +} as const + export function ModulationSection({ config, update, sourceSize }: Props) { const rotAmpMax = sinusoidalAmplitudeMaxFor('rotation', sourceSize) const scaleAmpMax = sinusoidalAmplitudeMaxFor('scale', sourceSize) return ( -
+

Random ±

- {(['x', 'y', 'rotation', 'scaleX', 'scaleY', 'opacity'] as const).map((k) => ( + {(['rotation', 'scaleX', 'scaleY', 'opacity'] as const).map((k) => ( { try { const text = await navigator.clipboard.readText() - const parsed = JSON.parse(text) as LoopConfig - if (typeof parsed.cols !== 'number' || typeof parsed.rows !== 'number') return - update(parsed, true) + const next = sanitizePastedConfig(JSON.parse(text)) + if (next) update(next, true) } catch { // ignore invalid clipboard } diff --git a/src/ui/sections/TransformSection.tsx b/src/ui/sections/TransformSection.tsx deleted file mode 100644 index 7de1304..0000000 --- a/src/ui/sections/TransformSection.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { formulaForProperty } from '../../plugin/engine/compile' -import type { FormulaProperty, LoopConfig, NumericProperty } from '../../shared/types' -import { Section } from '../components/Section' -import { SliderRow } from '../components/SliderRow' -import { rewriteTrailingScale } from '../formula-scale' -import { sliderRangeFor } from '../slider-ranges' - -interface Props { - config: LoopConfig - update: (next: LoopConfig, commit?: boolean) => void - sourceSize: { width: number; height: number } | null -} - -// Drives a slider's value into a NumericProperty without destroying an active -// library formula. If the formula has a trailing `* `, rewrite that -// literal; otherwise leave it intact (placeholder-based patterns like spiral -// read `value` directly at compile time). -function computeSliderUpdate(cur: NumericProperty, v: number): NumericProperty { - if (!cur.unlocked) return { ...cur, value: v, unlocked: false, formula: null } - const rewritten = cur.formula ? rewriteTrailingScale(cur.formula, v) : null - if (rewritten) return { ...cur, value: v, formula: rewritten } - return { ...cur, value: v } -} - -const ROWS: { key: FormulaProperty; label: string; unit?: string }[] = [ - { key: 'x', label: 'X step' }, - { key: 'y', label: 'Y step' }, - { key: 'rotation', label: 'Rotation', unit: '°' }, - { key: 'scaleX', label: 'Scale X' }, - { key: 'scaleY', label: 'Scale Y' }, -] - -export function TransformSection({ config, update, sourceSize }: Props) { - return ( -
- {ROWS.map((row) => { - const cur = config[row.key] - const range = sliderRangeFor(row.key, sourceSize) - return ( - { - const trimmed = text.trim() - update( - { - ...config, - [row.key]: - trimmed === '' - ? { ...cur, unlocked: false, formula: null } - : { ...cur, unlocked: true, formula: text }, - }, - false, - ) - }} - onChange={(v, commit) => { - const nextProp = computeSliderUpdate(cur, v) - update({ ...config, [row.key]: nextProp }, commit) - }} - /> - ) - })} -
- ) -} diff --git a/src/ui/styles.css b/src/ui/styles.css index 443a697..03ea632 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -417,6 +417,65 @@ body, outline: none; border-color: var(--accent); } +/* Dimmed when the axis can't act yet (e.g. count 1). */ +.slider-row.is-disabled { + opacity: 0.4; + pointer-events: none; +} + +/* One-line plain-language summary under a section title. */ +.section-hint { + margin: -2px 0 8px; + font-size: 11px; + line-height: 1.4; + color: var(--muted); +} + +/* Checkbox row (e.g. "Colour by depth"), styled to match the controls. */ +.toggle-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 0; + font-size: 11px; + color: var(--fg); + cursor: pointer; +} +.toggle-row input { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 1px solid var(--hairline); + border-radius: 3px; + background: var(--bg); + cursor: pointer; + position: relative; + flex: none; + transition: background 120ms ease, border-color 120ms ease; +} +.toggle-row input:hover { + border-color: var(--fg); +} +.toggle-row input:checked { + background: var(--accent); + border-color: var(--accent); +} +.toggle-row input:checked::after { + content: ""; + position: absolute; + left: 4px; + top: 1px; + width: 3px; + height: 7px; + border: solid var(--bg); + border-width: 0 1.6px 1.6px 0; + transform: rotate(45deg); +} +.toggle-row.is-disabled { + opacity: 0.4; + pointer-events: none; +} /* FormulaRow */ .formula-row { @@ -756,6 +815,14 @@ body, .easing-chip select:focus { outline: none; } +/* The closed control is transparent so the pill shows through, but a + transparent appearance:none