From c0f0f8f31da849fb1dabe5bcb8eae3a9082c139a Mon Sep 17 00:00:00 2001 From: Mario Michelli Date: Sat, 23 May 2026 17:32:48 +0200 Subject: [PATCH 01/11] Add a Layers axis (3D point lattice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Iterations grid becomes Columns × Rows × Layers. The engine just emits the cells and exposes each cell's 3D address (`l`, `layers`, `tz`) to the formula scope — projection to 2D lives in formulas / library presets, not a built-in projection. Layers defaults to 1, so existing patterns are byte-identical. - engine: 3D flat-index → (l, r, c) mapping; buildScope gains l/layers/tz (l defaults to 0 and layers is optional, so 2D callers are untouched); n and the orchestrator/render loops span the whole cube; diff treats layers like cols/rows (full rebuild). - ui: a Layers slider in Iterations; the count chip reads cols × rows × layers. - library: optional `layers` field; new Cube preset (oblique projection driven by c/r/l) so designers get 3D without writing math. - docs: fx variable list, controls, formulas, LLM pattern guide, CHANGELOG. - tests: 3D scope (l/layers/tz, back-compat) and flat-index → 3D-address mapping. --- CHANGELOG.md | 5 +++++ README.md | 2 +- docs/controls.md | 2 ++ docs/formulas.md | 6 ++++-- docs/llm-pattern-guide.md | 8 ++++++-- library/_schema.json | 6 ++++++ library/cube.json | 17 +++++++++++++++++ src/plugin/engine/cells.ts | 12 ++++++++++-- src/plugin/engine/evaluate.ts | 3 +++ src/plugin/engine/scope.ts | 13 ++++++++++--- src/plugin/loop/diff.ts | 6 +++++- src/plugin/loop/orchestrator.ts | 4 ++-- src/preview/render-loop.ts | 2 +- src/shared/defaults.ts | 1 + src/shared/types.ts | 10 ++++++++++ src/ui/library/types.ts | 4 ++++ src/ui/sections/IterationsSection.tsx | 17 ++++++++++++++++- src/ui/sections/LibraryOverlay.tsx | 1 + tests/cells.test.ts | 11 +++++++++++ tests/grid.test.ts | 26 ++++++++++++++++++++++++++ 20 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 library/cube.json 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/library/_schema.json b/library/_schema.json index 25f5adb..0b6f914 100644 --- a/library/_schema.json +++ b/library/_schema.json @@ -12,6 +12,12 @@ "author": { "type": "string" }, "cols": { "type": "integer", "minimum": 1, "maximum": 100 }, "rows": { "type": "integer", "minimum": 1, "maximum": 100 }, + "layers": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "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/cube.json b/library/cube.json new file mode 100644 index 0000000..8e16aa2 --- /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} + (l - (layers - 1) / 2) * 34", + "y": "y = (r - (rows - 1) / 2) * {y:64} - (l - (layers - 1) / 2) * 34", + "scaleX": "scaleX = (l - (layers - 1) / 2) * 4 - 8", + "scaleY": "scaleY = (l - (layers - 1) / 2) * 4 - 8", + "opacity": "opacity = 100 - (layers - 1 - l) * 11" + } +} diff --git a/src/plugin/engine/cells.ts b/src/plugin/engine/cells.ts index e53e976..6c4e21e 100644 --- a/src/plugin/engine/cells.ts +++ b/src/plugin/engine/cells.ts @@ -38,18 +38,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') }, 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/loop/diff.ts b/src/plugin/loop/diff.ts index feb26d9..b9401d3 100644 --- a/src/plugin/loop/diff.ts +++ b/src/plugin/loop/diff.ts @@ -38,7 +38,11 @@ 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) + ) { return { mode: 'full', dirty: ALL_PROPS } } diff --git a/src/plugin/loop/orchestrator.ts b/src/plugin/loop/orchestrator.ts index 51e152e..fb15a3a 100644 --- a/src/plugin/loop/orchestrator.ts +++ b/src/plugin/loop/orchestrator.ts @@ -91,7 +91,7 @@ async function fullRegen( const factors = compileFactors(config) if (!parentId) return - const n = config.cols * config.rows + const n = config.cols * config.rows * (config.layers ?? 1) const cloneIds: string[] = [] for (let i = 1; i < n; i++) { @@ -170,7 +170,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 = config.cols * config.rows * (config.layers ?? 1) 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..e74dfa1 100644 --- a/src/preview/render-loop.ts +++ b/src/preview/render-loop.ts @@ -33,7 +33,7 @@ export function renderLoop(opts: RenderLoopOptions): SVGGElement { const compiled = compileConfig(config) const factors = compileFactors(config) - const n = Math.max(1, config.cols * config.rows) + const n = Math.max(1, config.cols * config.rows * (config.layers ?? 1)) const start = config.showFirst === false ? 1 : 0 for (let i = start; i < n; i++) { const cell = evaluateCell(i, { 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..3e036fd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,13 @@ 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. @@ -88,11 +95,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/library/types.ts b/src/ui/library/types.ts index d3dbe57..4df553e 100644 --- a/src/ui/library/types.ts +++ b/src/ui/library/types.ts @@ -9,6 +9,10 @@ export interface LibraryEntry { author?: string cols: number rows: number + /** Optional number of depth layers (Z). The grid becomes a Columns × Rows × + * Layers lattice; the cell's layer index `l` (and `layers`, `tz`) is exposed + * to formulas, which project to 2D. Omit (or 1) for a flat 2D pattern. */ + layers?: number /** Optional per-cell rotation around the source center (degrees). * Cell i's grid offset is rotated by `angle * i` post-formula. Lets a * pattern declare a spiral or swirl without folding the rotation into diff --git a/src/ui/sections/IterationsSection.tsx b/src/ui/sections/IterationsSection.tsx index 9c95627..cf4b173 100644 --- a/src/ui/sections/IterationsSection.tsx +++ b/src/ui/sections/IterationsSection.tsx @@ -18,16 +18,23 @@ export function IterationsSection({ onOpenLibrary, onClearPattern, }: Props) { + const layers = config.layers ?? 1 return (
+ {config.cols} × {config.rows} + {layers > 1 && ( + <> + × + {layers} + + )} · + } + /> + update({ ...config, rows: v }, commit)} + stepKey="y" + stepLabel="Y step" + angle={config.rowAngle ?? 0} + onAngle={(v, commit) => update({ ...config, rowAngle: v }, commit)} + fade={config.rowFade ?? 0} + onFade={(v, commit) => update({ ...config, rowFade: v }, commit)} /> - - + } > + {/* Layer count + per-layer depth transforms */} + update({ ...config, layers: Math.max(1, Math.round(v)) }, commit)} + /> + update({ ...config, layerStep: v }, commit)} + /> + update({ ...config, layerAngle: v }, commit)} + /> + update({ ...config, layerFade: v }, commit)} + /> + + {/* Opacity */} void + sourceSize: { width: number; height: number } | null + // count + count: number + onCount: (v: number, commit: boolean) => void + // step (a NumericProperty, so it keeps its fx + modulation) + stepKey: FormulaProperty + stepLabel: string + // angle (clone rotation per index) and fade (opacity falloff), plain numbers + angle: number + onAngle: (v: number, commit: boolean) => void + fade: number + onFade: (v: number, commit: boolean) => void + chip?: ComponentChildren +} + +// Drives a slider value into a NumericProperty without destroying an active +// library formula (mirrors TransformSection). +function computeStepUpdate(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 } +} + +// One spatial axis (Column or Row): how many, how far apart (step), how much +// each one rotates the clone (angle), and how much it fades (appearance). +export function AxisSection({ + id, + title, + config, + update, + sourceSize, + count, + onCount, + stepKey, + stepLabel, + angle, + onAngle, + fade, + onFade, + chip, +}: Props) { + const step = config[stepKey] as NumericProperty + const range = sliderRangeFor(stepKey, sourceSize) + return ( +
+ onCount(Math.max(1, Math.round(v)), commit)} + /> + { + const trimmed = text.trim() + update( + { + ...config, + [stepKey]: + trimmed === '' + ? { ...step, unlocked: false, formula: null } + : { ...step, unlocked: true, formula: text }, + }, + false, + ) + }} + onChange={(v, commit) => + update({ ...config, [stepKey]: computeStepUpdate(step, v) }, commit) + } + /> + + +
+ ) +} diff --git a/src/ui/sections/IterationsSection.tsx b/src/ui/sections/IterationsSection.tsx deleted file mode 100644 index cf4b173..0000000 --- a/src/ui/sections/IterationsSection.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import type { LoopConfig } from '../../shared/types' -import { Section } from '../components/Section' -import { SliderRow } from '../components/SliderRow' -import { clearPattern } from '../config-ops' - -interface Props { - config: LoopConfig - update: (next: LoopConfig, commit?: boolean) => void - appliedName?: string | null - onOpenLibrary?: () => void - onClearPattern?: () => void -} - -export function IterationsSection({ - config, - update, - appliedName, - onOpenLibrary, - onClearPattern, -}: Props) { - const layers = config.layers ?? 1 - return ( -
- {config.cols} - × - {config.rows} - {layers > 1 && ( - <> - × - {layers} - - )} - · - - {appliedName && ( - - )} - - } - > - update({ ...config, cols: Math.max(1, Math.round(v)) }, commit)} - /> - update({ ...config, rows: Math.max(1, Math.round(v)) }, commit)} - /> - update({ ...config, layers: Math.max(1, Math.round(v)) }, commit)} - /> - update({ ...config, angle: v }, commit)} - /> -
- ) -} 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/tests/cells.test.ts b/tests/cells.test.ts index dd7beb5..e1b4dd2 100644 --- a/tests/cells.test.ts +++ b/tests/cells.test.ts @@ -127,3 +127,35 @@ describe('cellCount', () => { expect(cellCount({ cols: 10, rows: 10, layers: 3 })).toBe(300) }) }) + +describe('per-axis transforms', () => { + it('adds each axis angle to rotation, offsets layers, and fades by axis', () => { + const config: LoopConfig = { + ...DEFAULT_CONFIG, + cols: 3, + rows: 3, + layers: 2, + columnAngle: 10, + rowAngle: 5, + layerAngle: 20, + layerStep: 50, + columnFade: 50, + } + const cell = run(config, 14) // i=14 → l=1, r=1, c=2 + expect(cell.scope).toMatchObject({ c: 2, r: 1, l: 1 }) + // rotation = base(0) + c*10 + r*5 + l*20 + expect(cell.rotation).toBeCloseTo(45, 6) + // x = base column step (c*60) + oblique layer step (l*50*0.82) + expect(cell.x).toBeCloseTo(2 * 60 + 1 * 50 * 0.82, 6) + // opacity = base(100) - tx*columnFade, tx = c/(cols-1) = 1 + expect(cell.opacity).toBeCloseTo(50, 6) + }) + + it('is a no-op when all per-axis fields are absent (back-compat)', () => { + const a = run(DEFAULT_CONFIG, 23) + const b = run({ ...DEFAULT_CONFIG, columnAngle: 0, layerStep: 0, layerFade: 0 }, 23) + expect(b.rotation).toBeCloseTo(a.rotation, 6) + expect(b.x).toBeCloseTo(a.x, 6) + expect(b.opacity).toBeCloseTo(a.opacity, 6) + }) +}) From aa8cb2dae7d34ba825f11c2c847adce99657e01e Mon Sep 17 00:00:00 2001 From: Mario Michelli Date: Sat, 23 May 2026 23:12:59 +0200 Subject: [PATCH 07/11] Move position random into the axis sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each axis carries its own Random (jitter): Column jitters X, Row jitters Y (their step's existing per-property random), and Layer gets a new seeded layerRandom that scatters cells obliquely. Modulation's "Random ±" keeps the non-positional randoms (rotation, scale, opacity) and the sinusoidals. --- src/plugin/engine/cells.ts | 6 ++++++ src/plugin/loop/diff.ts | 3 ++- src/shared/types.ts | 1 + src/ui/sections/AppearanceSection.tsx | 9 +++++++++ src/ui/sections/AxisSection.tsx | 10 +++++++++- src/ui/sections/ModulationSection.tsx | 2 +- 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/plugin/engine/cells.ts b/src/plugin/engine/cells.ts index 7595901..01eba54 100644 --- a/src/plugin/engine/cells.ts +++ b/src/plugin/engine/cells.ts @@ -8,6 +8,7 @@ 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 @@ -87,6 +88,11 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { x += l * layerStep * 0.82 // oblique offset, up-and-to-the-right y -= l * layerStep * 0.57 } + 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) + diff --git a/src/plugin/loop/diff.ts b/src/plugin/loop/diff.ts index 312e4d9..de9a9f3 100644 --- a/src/plugin/loop/diff.ts +++ b/src/plugin/loop/diff.ts @@ -49,7 +49,8 @@ export function diffConfig( (prev.columnFade ?? 0) !== (next.columnFade ?? 0) || (prev.rowFade ?? 0) !== (next.rowFade ?? 0) || (prev.layerFade ?? 0) !== (next.layerFade ?? 0) || - (prev.layerColour ?? false) !== (next.layerColour ?? false) + (prev.layerColour ?? false) !== (next.layerColour ?? false) || + (prev.layerRandom ?? 0) !== (next.layerRandom ?? 0) ) { return { mode: 'full', dirty: ALL_PROPS } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 0d19a0b..f0e83dd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -66,6 +66,7 @@ export interface LoopConfig { rowFade?: number // × ty layerFade?: number // × tz (back-to-front) 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 diff --git a/src/ui/sections/AppearanceSection.tsx b/src/ui/sections/AppearanceSection.tsx index a1f9c15..e2ce28f 100644 --- a/src/ui/sections/AppearanceSection.tsx +++ b/src/ui/sections/AppearanceSection.tsx @@ -76,6 +76,15 @@ export function AppearanceSection({ config, update }: Props) { unit="%" onChange={(v, commit) => update({ ...config, layerFade: v }, commit)} /> + update({ ...config, layerRandom: v }, commit)} + />
) } diff --git a/src/ui/sections/ModulationSection.tsx b/src/ui/sections/ModulationSection.tsx index 69a174c..d7e2858 100644 --- a/src/ui/sections/ModulationSection.tsx +++ b/src/ui/sections/ModulationSection.tsx @@ -15,7 +15,7 @@ export function ModulationSection({ config, update, sourceSize }: Props) { return (

Random ±

- {(['x', 'y', 'rotation', 'scaleX', 'scaleY', 'opacity'] as const).map((k) => ( + {(['rotation', 'scaleX', 'scaleY', 'opacity'] as const).map((k) => ( Date: Sun, 24 May 2026 01:28:24 +0200 Subject: [PATCH 08/11] Reorganize panel into a clear axis/appearance model; fix per-axis Scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the controls around one mental model: Column/Row/Layer say WHERE clones go (and how each axis ramps them), Appearance says what each clone looks like, Modulation oscillates. Concretely: - Split the old conflated "Layer" section in two. Layer is now depth-only (Count, Step, Direction, Twist, Scale, Fade, Random, Colour-by-depth, stack order). A new Appearance section surfaces the base per-clone transforms that had lost their UI home — Rotation and Size X/Y (scaleX/scaleY), each formula-capable — alongside Opacity, Fill, Stroke, Stroke width and Easing. - Add per-axis Scale on all three axes and a depth Direction control; rename the per-clone rotation from "Angle" to "Twist" so it stops colliding with the global grid angle. - Fix the depth z-order: a shared paintOrder() helper paints far layers first so the near layer lands on top, with a "Far layers in front" toggle to flip it. Fix the per-axis Scale: it was multiplying the additive size *delta* (0 by default), so it did nothing. Fold the multiplier through the source size so it scales the rendered size and works even at the default zero delta; at mul=1 it collapses back to the plain delta, leaving existing configs untouched. Engine semantics are otherwise unchanged, so all 33 library presets render identically. New tests cover the depth direction vectors, paint order, and the scale multiplier against rendered size. --- src/plugin/engine/cells.ts | 50 ++++++++++- src/plugin/loop/diff.ts | 5 ++ src/plugin/loop/orchestrator.ts | 20 ++++- src/preview/render-loop.ts | 8 +- src/shared/types.ts | 10 ++- src/ui/App.tsx | 8 +- src/ui/sections/AppearanceSection.tsx | 119 +++++++++++++------------- src/ui/sections/AxisSection.tsx | 18 +++- src/ui/sections/LayerSection.tsx | 108 +++++++++++++++++++++++ tests/cells.test.ts | 60 ++++++++++++- 10 files changed, 329 insertions(+), 77 deletions(-) create mode 100644 src/ui/sections/LayerSection.tsx diff --git a/src/plugin/engine/cells.ts b/src/plugin/engine/cells.ts index 01eba54..5615cbb 100644 --- a/src/plugin/engine/cells.ts +++ b/src/plugin/engine/cells.ts @@ -21,6 +21,31 @@ export function cellCount(config: { cols: number; rows: number; layers?: 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 @@ -85,8 +110,9 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { let y = rotated.y const layerStep = config.layerStep ?? 0 if (layerStep !== 0) { - x += l * layerStep * 0.82 // oblique offset, up-and-to-the-right - y -= l * layerStep * 0.57 + 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) { @@ -105,6 +131,22 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { 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, @@ -113,8 +155,8 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { x, y, rotation, - scaleX: compiled.scaleX.evaluate(scope, 'scaleX'), - scaleY: compiled.scaleY.evaluate(scope, 'scaleY'), + 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, diff --git a/src/plugin/loop/diff.ts b/src/plugin/loop/diff.ts index de9a9f3..064438d 100644 --- a/src/plugin/loop/diff.ts +++ b/src/plugin/loop/diff.ts @@ -45,10 +45,15 @@ export function diffConfig( (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) ) { diff --git a/src/plugin/loop/orchestrator.ts b/src/plugin/loop/orchestrator.ts index fff3c13..f0c9cda 100644 --- a/src/plugin/loop/orchestrator.ts +++ b/src/plugin/loop/orchestrator.ts @@ -92,9 +92,21 @@ async function fullRegen( if (!parentId) return const n = cellCount(config) - const cloneIds: string[] = [] - 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', diff --git a/src/preview/render-loop.ts b/src/preview/render-loop.ts index f2a98af..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 { cellCount, 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, cellCount(config)) - 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/types.ts b/src/shared/types.ts index f0e83dd..1d2d1a7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -60,11 +60,19 @@ export interface LoopConfig { // fill/stroke ramp by depth. columnAngle?: number // deg of clone rotation added per column (× c) rowAngle?: number // × r - layerStep?: number // px oblique depth offset per layer (× l) + 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 diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e3800fe..6ea77c1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import { resetKeepingPattern } from './config-ops' import { useLooperConfig } from './hooks/useLooperConfig' import { AppearanceSection } from './sections/AppearanceSection' 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' @@ -191,6 +192,8 @@ export function App() { 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={ @@ -219,10 +222,13 @@ export function App() { 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)} /> - + + void + sourceSize: { width: number; height: number } | null } const EASINGS: EasingKind[] = ['linear', 'ease', 'easeIn', 'easeOut'] -export function AppearanceSection({ config, update }: Props) { +// The per-clone look: how big each clone is (Size), how it's turned (Rotation), +// how see-through it is (Opacity), and its Fill / Stroke. These ride underneath +// the per-axis ramps in Column / Row / Layer. Each numeric row accepts a +// formula; Easing sets the curve the start→end ramps follow. +const BASE_ROWS: { key: FormulaProperty; label: string; unit?: string }[] = [ + { key: 'rotation', label: 'Rotation', unit: '°' }, + { key: 'scaleX', label: 'Size X', unit: 'px' }, + { key: 'scaleY', label: 'Size Y', unit: 'px' }, +] + +// Drives a slider value into a NumericProperty without destroying an active +// library formula (mirrors the axis sections). +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 } +} + +export function AppearanceSection({ config, update, sourceSize }: Props) { const opacityFormulaActive = !!config.opacity.unlocked const fillFormulaActive = !!config.fill.unlocked const strokeFormulaActive = !!config.stroke.unlocked @@ -30,8 +52,8 @@ export function AppearanceSection({ config, update }: Props) { return (
} > - {/* Layer count + per-layer depth transforms */} - update({ ...config, layers: Math.max(1, Math.round(v)) }, commit)} - /> - update({ ...config, layerStep: v }, commit)} - /> - update({ ...config, layerAngle: v }, commit)} - /> - update({ ...config, layerFade: v }, commit)} - /> - update({ ...config, layerRandom: v }, commit)} - /> - + {/* Base transforms (each formula-capable) */} + {BASE_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) => + update({ ...config, [row.key]: computeSliderUpdate(cur, v) }, commit) + } + /> + ) + })} {/* Opacity */} void + scale: number + onScale: (v: number, commit: boolean) => void fade: number onFade: (v: number, commit: boolean) => void chip?: ComponentChildren @@ -49,6 +52,8 @@ export function AxisSection({ stepLabel, angle, onAngle, + scale, + onScale, fade, onFade, chip, @@ -91,7 +96,7 @@ export function AxisSection({ } /> + 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) { + 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/tests/cells.test.ts b/tests/cells.test.ts index e1b4dd2..50ec069 100644 --- a/tests/cells.test.ts +++ b/tests/cells.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { applyAngleToOffset } from '../src/plugin/engine/angle' import { type CellValues, + DEFAULT_DEPTH_DIR, MAX_CELLS, cellCount, computeInterpFactor, evaluateCell, + paintOrder, } from '../src/plugin/engine/cells' import { compileConfig, compileFactors } from '../src/plugin/engine/compile' import { applyEasing } from '../src/plugin/engine/easing' @@ -128,6 +130,27 @@ describe('cellCount', () => { }) }) +describe('paintOrder', () => { + const grid = { cols: 2, rows: 1, layers: 3 } // perLayer 2 → layer0:[0,1] layer1:[2,3] layer2:[4,5] + + it('is natural order for a single layer', () => { + expect(paintOrder({ cols: 3, rows: 2 })).toEqual([0, 1, 2, 3, 4, 5]) + }) + + it('near-top paints far layers first (so near layers end on top)', () => { + expect(paintOrder({ ...grid, stackOrder: 'near-top' })).toEqual([4, 5, 2, 3, 0, 1]) + }) + + it('far-top keeps natural order (deep layers in front)', () => { + expect(paintOrder({ ...grid, stackOrder: 'far-top' })).toEqual([0, 1, 2, 3, 4, 5]) + }) + + it('covers exactly the indices [0, cellCount) as a permutation', () => { + const order = paintOrder(grid) + expect([...order].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5]) + }) +}) + describe('per-axis transforms', () => { it('adds each axis angle to rotation, offsets layers, and fades by axis', () => { const config: LoopConfig = { @@ -145,15 +168,46 @@ describe('per-axis transforms', () => { expect(cell.scope).toMatchObject({ c: 2, r: 1, l: 1 }) // rotation = base(0) + c*10 + r*5 + l*20 expect(cell.rotation).toBeCloseTo(45, 6) - // x = base column step (c*60) + oblique layer step (l*50*0.82) - expect(cell.x).toBeCloseTo(2 * 60 + 1 * 50 * 0.82, 6) + // x = base column step (c*60) + depth step along the default 35° direction + expect(cell.x).toBeCloseTo(2 * 60 + 1 * 50 * Math.cos((DEFAULT_DEPTH_DIR * Math.PI) / 180), 6) // opacity = base(100) - tx*columnFade, tx = c/(cols-1) = 1 expect(cell.opacity).toBeCloseTo(50, 6) }) + it('pushes layers along layerDirection (0° = +x, 90° = up)', () => { + const base: LoopConfig = { ...DEFAULT_CONFIG, cols: 1, rows: 1, layers: 2, layerStep: 40 } + // l=1 cell. At 0° the offset is pure +x; at 90° it is pure -y (screen up). + const right = run({ ...base, layerDirection: 0 }, 1) + const up = run({ ...base, layerDirection: 90 }, 1) + expect(right.x - run(base, 0).x).toBeCloseTo(40, 6) + expect(right.y - run(base, 0).y).toBeCloseTo(0, 6) + expect(up.x - run({ ...base, layerDirection: 90 }, 0).x).toBeCloseTo(0, 6) + expect(up.y - run({ ...base, layerDirection: 90 }, 0).y).toBeCloseTo(-40, 6) + }) + + it('ramps scale along an axis: the far end renders at scaleMul × source size', () => { + const sw = 40 + const sh = 30 + const base: LoopConfig = { ...DEFAULT_CONFIG, cols: 3, rows: 1 } + // c=2 sits at tx=1, so columnScale -50 halves the *rendered* size + // (renderedW = sw + scaleX), even though the base size delta is 0. + const far = run({ ...base, columnScale: -50 }, 2, sw, sh) + expect(sw + far.scaleX).toBeCloseTo((sw + run(base, 2, sw, sh).scaleX) * 0.5, 6) + expect(sh + far.scaleY).toBeCloseTo((sh + run(base, 2, sw, sh).scaleY) * 0.5, 6) + // c=0 (tx=0) is untouched. + expect(run({ ...base, columnScale: -50 }, 0, sw, sh).scaleX).toBeCloseTo( + run(base, 0, sw, sh).scaleX, + 6, + ) + }) + it('is a no-op when all per-axis fields are absent (back-compat)', () => { const a = run(DEFAULT_CONFIG, 23) - const b = run({ ...DEFAULT_CONFIG, columnAngle: 0, layerStep: 0, layerFade: 0 }, 23) + const b = run( + { ...DEFAULT_CONFIG, columnAngle: 0, layerStep: 0, layerFade: 0, columnScale: 0 }, + 23, + ) + expect(b.scaleX).toBeCloseTo(a.scaleX, 6) expect(b.rotation).toBeCloseTo(a.rotation, 6) expect(b.x).toBeCloseTo(a.x, 6) expect(b.opacity).toBeCloseTo(a.opacity, 6) From 7c5cefd1653161ccd501706be905aa7f98b2b8d4 Mon Sep 17 00:00:00 2001 From: Mario Michelli Date: Sun, 24 May 2026 07:03:11 +0200 Subject: [PATCH 09/11] Polish the panel: hints, dimmed dead controls, styled toggles, dev:watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a one-line plain-language hint under each section title (e.g. Column "Repeats and ramps across columns.", Appearance "Each clone's base look"). - Dim and disable an axis's Step/Twist/Scale/Fade/Random while its Count is 1, so the controls visibly wait on Count (Layer starts this way at 1 layer). - Style the Layer checkboxes (Colour by depth / Far layers in front) as proper accent toggles instead of raw browser checkboxes. - Use human labels in Modulation's Random ± (Rotation / Size X / Size Y / Opacity) to match Appearance, and rename Layer "Step" to "Z step". - Collapse Layer by default (new users) since it's the advanced axis. - Add a dev:watch script that rebuilds ui + preview bundles on change. --- package.json | 1 + src/ui/App.tsx | 2 + src/ui/components/Section.tsx | 10 ++++- src/ui/components/SliderRow.tsx | 10 ++++- src/ui/sections/AppearanceSection.tsx | 1 + src/ui/sections/AxisSection.tsx | 21 +++++++++- src/ui/sections/LayerSection.tsx | 27 +++++++++--- src/ui/sections/ModulationSection.tsx | 17 +++++++- src/ui/styles.css | 59 +++++++++++++++++++++++++++ 9 files changed, 136 insertions(+), 12 deletions(-) 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/src/ui/App.tsx b/src/ui/App.tsx index 6ea77c1..df7f094 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -183,6 +183,7 @@ export function App() { )} - {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 ? (