From 9976c2900250d997c32be5bc7bca45406b0b4e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 13:42:07 +0000 Subject: [PATCH 1/2] Add Angle Z (pseudo-3D depth) to the iterations grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the existing Angle control to Angle XY (the in-plane spiral/swirl) and add Angle Z, which tilts each cell's offset into depth and projects it orthographically back to 2D. The tool is flat — Figma, Penpot, and SVG have no depth axis — so the math is 3D internally but the output is plain 2D positions, keeping export and round-trip behavior intact. angleZ=0 reproduces the prior pure-XY output exactly, and the field is optional for back-compat with saved configs and library entries. https://claude.ai/code/session_013Gbz4nk3D9f6H16U1jrbMY --- CHANGELOG.md | 5 +++ docs/controls.md | 4 ++- docs/llm-pattern-guide.md | 3 +- library/_schema.json | 8 ++++- src/plugin/engine/angle.ts | 48 +++++++++++++++++++++------ src/plugin/engine/cells.ts | 1 + src/plugin/loop/diff.ts | 6 ++-- src/shared/defaults.ts | 2 ++ src/shared/types.ts | 14 ++++++-- src/ui/library/types.ts | 6 +++- src/ui/sections/IterationsSection.tsx | 11 +++++- src/ui/sections/LibraryOverlay.tsx | 1 + tests/angle.test.ts | 37 +++++++++++++++++++++ 13 files changed, 126 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c10eb..01389b6 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 +- **Angle Z (pseudo-3D depth).** The Iterations section now has a second rotation: the old **Angle** is renamed **Angle XY** (the in-plane spiral/swirl), and **Angle Z** tilts each cell's offset into depth. The tool is flat 2D — Figma, Penpot, and SVG have no depth axis — so the tilt is computed in 3D and orthographically projected back to 2D. A flat spiral leans into a cone or helix; a ring squashes into an ellipse seen at an angle. Output stays plain 2D positions, so it exports and round-trips like everything else. Library entries can declare an optional `angleZ`. + ## [v0.2.0] — 2026-05-22 ### Added diff --git a/docs/controls.md b/docs/controls.md index d1f3d19..d3e66cb 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -22,7 +22,9 @@ 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. -**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". +**Angle XY.** Degrees of per-cell rotation in the screen plane, 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". + +**Angle Z.** Degrees of per-cell tilt into depth, applied after Angle XY. The tool is flat 2D, so there's no real depth axis — instead each clone's offset is tilted toward/away from you and projected back onto the canvas, which foreshortens it. Leave at 0 to stay flat. Dial it up and a flat spiral leans into a cone or helix, a ring squashes into an ellipse seen at an angle. Because it's a projection, the result is still plain 2D positions, so it exports and round-trips like everything else. 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/llm-pattern-guide.md b/docs/llm-pattern-guide.md index 333809d..a19b85e 100644 --- a/docs/llm-pattern-guide.md +++ b/docs/llm-pattern-guide.md @@ -47,7 +47,8 @@ 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. | -| `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. | +| `angle` | optional | Number, -360 to 360. Per-cell rotation in degrees applied to the grid offset *in the screen plane*, *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. | +| `angleZ` | optional | Number, -360 to 360. Per-cell tilt into depth in degrees, applied *after* `angle` and orthographically projected back to 2D (the tool is flat — there is no real depth axis). Cell `i` is tilted by `angleZ * i`, foreshortening its offset; a flat spiral leans into a cone/helix, a ring squashes into an ellipse. Output is still plain 2D positions. Default `0`. | | `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. | diff --git a/library/_schema.json b/library/_schema.json index 25f5adb..d9a9b3e 100644 --- a/library/_schema.json +++ b/library/_schema.json @@ -16,7 +16,13 @@ "type": "number", "minimum": -360, "maximum": 360, - "description": "Per-cell rotation in degrees applied to the grid offset around the source center. Cell i is rotated by `angle * i`. Use with cols ≥ 2 to trace spirals or swirl a 2D grid." + "description": "Per-cell rotation in degrees applied to the grid offset in the screen plane. Cell i is rotated by `angle * i`. Use with cols ≥ 2 to trace spirals or swirl a 2D grid." + }, + "angleZ": { + "type": "number", + "minimum": -360, + "maximum": 360, + "description": "Per-cell tilt into depth in degrees, applied after `angle` and orthographically projected to 2D. Cell i is tilted by `angleZ * i`, turning a flat spiral into a cone/helix. 0 = flat." }, "showFirst": { "type": "boolean", diff --git a/src/plugin/engine/angle.ts b/src/plugin/engine/angle.ts index fa60ab9..2e6d904 100644 --- a/src/plugin/engine/angle.ts +++ b/src/plugin/engine/angle.ts @@ -1,7 +1,21 @@ // src/plugin/engine/angle.ts -// Rotates a grid offset around the source center by `angle * i` degrees, -// where i is the cell's flat index. Lets a straight line of cells curl into -// a spiral, or a rectangular grid swirl, without touching the formula layer. +// Rotates a grid offset around the source center by `angle * i` degrees, then +// optionally tilts it into depth by `angleZ * i` degrees and projects back to +// 2D. Lets a straight line of cells curl into a spiral (XY), or a flat spiral +// foreshorten into a cone/helix (Z), without touching the formula layer. +// +// The math is 3D internally but the output is plain 2D, so it round-trips to +// SVG, Figma, and Penpot — none of which have a real depth axis. +// +// p = (x, y, 0) +// Rz: rotate in the screen plane by angleXY * i (the classic spiral/swirl) +// Rx: tilt about the screen's horizontal axis by angleZ * i (lean into depth) +// project orthographically: keep (x, y), drop the depth component +// +// Rotating about X with z = 0 leaves x untouched and foreshortens y by +// cos(angleZ * i): a flat disc seen edge-on becomes an ellipse, and a per-cell +// tilt turns a spiral into a corkscrew. angleZ = 0 reproduces the pure-XY +// output exactly. const DEG_TO_RAD = Math.PI / 180 @@ -9,13 +23,27 @@ export function applyAngleToOffset( values: { x: number; y: number }, i: number, angleDegrees: number, + angleZDegrees = 0, ): { x: number; y: number } { - if (angleDegrees === 0) return values - const theta = angleDegrees * i * DEG_TO_RAD - const cos = Math.cos(theta) - const sin = Math.sin(theta) - return { - x: values.x * cos - values.y * sin, - y: values.x * sin + values.y * cos, + if (angleDegrees === 0 && angleZDegrees === 0) return values + + let x = values.x + let y = values.y + + if (angleDegrees !== 0) { + const theta = angleDegrees * i * DEG_TO_RAD + const cos = Math.cos(theta) + const sin = Math.sin(theta) + const rx = x * cos - y * sin + const ry = x * sin + y * cos + x = rx + y = ry } + + if (angleZDegrees !== 0) { + const phi = angleZDegrees * i * DEG_TO_RAD + y = y * Math.cos(phi) + } + + return { x, y } } diff --git a/src/plugin/engine/cells.ts b/src/plugin/engine/cells.ts index e53e976..faa4a78 100644 --- a/src/plugin/engine/cells.ts +++ b/src/plugin/engine/cells.ts @@ -55,6 +55,7 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { { x: compiled.x.evaluate(scope, 'x'), y: compiled.y.evaluate(scope, 'y') }, scope.i, config.angle, + config.angleZ ?? 0, ) const baseEased = applyEasing(config.easing, computeInterpFactor(config, scope.tx, scope.ty)) return { diff --git a/src/plugin/loop/diff.ts b/src/plugin/loop/diff.ts index feb26d9..6d62546 100644 --- a/src/plugin/loop/diff.ts +++ b/src/plugin/loop/diff.ts @@ -42,8 +42,10 @@ export function diffConfig( return { mode: 'full', dirty: ALL_PROPS } } - // angle re-rotates every cell's grid offset, so x and y must be re-applied. - const angleChanged = (prev.angle ?? 0) !== (next.angle ?? 0) + // angle / angleZ re-rotate every cell's grid offset, so x and y must be + // re-applied when either changes. + const angleChanged = + (prev.angle ?? 0) !== (next.angle ?? 0) || (prev.angleZ ?? 0) !== (next.angleZ ?? 0) const dirty: DirtyProperty[] = [] if (!eqNumericProperty(prev.x, next.x) || angleChanged) dirty.push('x') diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index e76e34e..1fc2374 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -23,6 +23,7 @@ export const DEFAULT_CONFIG: LoopConfig = { cols: 10, rows: 10, angle: 0, + angleZ: 0, x: num(60), y: num(60), rotation: num(0), @@ -48,6 +49,7 @@ export const RESET_CONFIG: LoopConfig = { cols: 1, rows: 1, angle: 0, + angleZ: 0, x: num(0), y: num(0), rotation: num(0), diff --git a/src/shared/types.ts b/src/shared/types.ts index d4bba87..c08e148 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,10 +41,18 @@ export interface LoopConfig { // Iteration cols: number rows: 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. + // Degrees of per-cell rotation in the screen plane (around the view's Z + // axis), 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. Surfaced in the UI as "Angle XY". angle: number + // Degrees of per-cell tilt into depth (rotation about the screen's horizontal + // X axis), applied after `angle` and orthographically projected back to 2D. + // Cell i is tilted by angleZ * i degrees; the depth component is dropped, so + // a flat spiral foreshortens into a cone/helix. 0 = flat (output identical to + // angle alone). Surfaced in the UI as "Angle Z". Optional for back-compat + // with saved configs and library entries that predate it. + angleZ?: number // Base transforms (per-step) x: NumericProperty diff --git a/src/ui/library/types.ts b/src/ui/library/types.ts index d3dbe57..af230ce 100644 --- a/src/ui/library/types.ts +++ b/src/ui/library/types.ts @@ -9,11 +9,15 @@ export interface LibraryEntry { author?: string cols: number rows: number - /** Optional per-cell rotation around the source center (degrees). + /** Optional per-cell rotation in the screen plane (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 * every x/y formula. Omit (or 0) for unrotated patterns. */ angle?: number + /** Optional per-cell tilt into depth (degrees), applied after `angle` and + * orthographically projected back to 2D. Cell i is tilted by `angleZ * i`, + * turning a flat spiral into a cone/helix. Omit (or 0) for flat patterns. */ + angleZ?: number showFirst?: boolean formulas: Partial> } diff --git a/src/ui/sections/IterationsSection.tsx b/src/ui/sections/IterationsSection.tsx index 9c95627..d433bc3 100644 --- a/src/ui/sections/IterationsSection.tsx +++ b/src/ui/sections/IterationsSection.tsx @@ -75,7 +75,7 @@ export function IterationsSection({ onChange={(v, commit) => update({ ...config, rows: Math.max(1, Math.round(v)) }, commit)} /> update({ ...config, angle: v }, commit)} /> + update({ ...config, angleZ: v }, commit)} + /> ) } diff --git a/src/ui/sections/LibraryOverlay.tsx b/src/ui/sections/LibraryOverlay.tsx index 05c46a0..55759b1 100644 --- a/src/ui/sections/LibraryOverlay.tsx +++ b/src/ui/sections/LibraryOverlay.tsx @@ -35,6 +35,7 @@ function applyEntry(config: LoopConfig, entry: LibraryEntry): LoopConfig { cols: entry.cols, rows: entry.rows, angle: entry.angle ?? 0, + angleZ: entry.angleZ ?? 0, fxMode: true, showFirst: entry.showFirst ?? true, } diff --git a/tests/angle.test.ts b/tests/angle.test.ts index cc2cbca..66faafb 100644 --- a/tests/angle.test.ts +++ b/tests/angle.test.ts @@ -50,4 +50,41 @@ describe('applyAngleToOffset', () => { expect(r.x).toBeCloseTo(3 * s - 4 * s, 6) expect(r.y).toBeCloseTo(3 * s + 4 * s, 6) }) + + describe('angleZ (depth tilt, orthographic projection)', () => { + it('is backward compatible: angleZ=0 matches the pure-XY result', () => { + for (let i = 1; i <= 6; i++) { + const flat = applyAngleToOffset({ x: 30, y: 40 }, i, 17) + const withZero = applyAngleToOffset({ x: 30, y: 40 }, i, 17, 0) + expect(withZero.x).toBeCloseTo(flat.x, 6) + expect(withZero.y).toBeCloseTo(flat.y, 6) + } + }) + + it('leaves x untouched and foreshortens y by cos(angleZ × i)', () => { + // angleZ only (no XY): x is unchanged, y scales by cos(angleZ × i). + const r = applyAngleToOffset({ x: 10, y: 20 }, 1, 0, 60) + expect(r.x).toBeCloseTo(10, 6) + expect(r.y).toBeCloseTo(20 * Math.cos((60 * Math.PI) / 180), 6) // 20 × 0.5 + }) + + it('collapses y to 0 when the tilt reaches edge-on (angleZ × i = 90°)', () => { + const r = applyAngleToOffset({ x: 10, y: 20 }, 1, 0, 90) + expect(r.x).toBeCloseTo(10, 6) + expect(r.y).toBeCloseTo(0, 6) + }) + + it('applies the depth tilt after the in-plane rotation', () => { + // Rz(90°) sends (10,0) -> (0,10); then Rx tilt foreshortens that y by cos(60°). + const r = applyAngleToOffset({ x: 10, y: 0 }, 1, 90, 60) + expect(r.x).toBeCloseTo(0, 6) + expect(r.y).toBeCloseTo(10 * Math.cos((60 * Math.PI) / 180), 6) // 5 + }) + + it('returns the origin unchanged regardless of either angle', () => { + const r = applyAngleToOffset({ x: 0, y: 0 }, 4, 30, 45) + expect(r.x).toBeCloseTo(0, 6) + expect(r.y).toBeCloseTo(0, 6) + }) + }) }) From a99e3a1854a023b9844cead467229e4146022dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 13:52:03 +0000 Subject: [PATCH 2/2] Add depth shading dial for the Angle Z cue Add a Depth control (0-100%) that uses each cell's projected depth from the Angle Z tilt as a near/far cue: cells leaning toward the viewer grow and stay bright, cells leaning away shrink and fade. The angle engine now returns the normalized depth alongside the projected offset, and the shading folds into the per-cell scale/opacity in the shared evaluateCell, so both the browser preview and the Figma/Penpot orchestrator pick it up with no render-path changes. depthShade defaults to 0 (pure projection) and is a no-op without a nonzero angleZ. The diff layer re-applies scale/opacity when shading is active and position or tilt changes. Library entries can declare an optional depthShade. https://claude.ai/code/session_013Gbz4nk3D9f6H16U1jrbMY --- CHANGELOG.md | 1 + docs/controls.md | 2 + docs/llm-pattern-guide.md | 1 + library/_schema.json | 6 +++ src/plugin/engine/angle.ts | 35 ++++++++++++--- src/plugin/engine/cells.ts | 36 ++++++++++++--- src/plugin/loop/diff.ts | 21 ++++++--- src/shared/defaults.ts | 2 + src/shared/types.ts | 5 +++ src/ui/library/types.ts | 4 ++ src/ui/sections/IterationsSection.tsx | 9 ++++ src/ui/sections/LibraryOverlay.tsx | 1 + tests/angle.test.ts | 25 ++++++++++- tests/cells.test.ts | 63 ++++++++++++++++++++++++++- 14 files changed, 192 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01389b6..c35d1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Swift Loop. Versions follow SemVer; the [v0.x.y] anchor l ### Added - **Angle Z (pseudo-3D depth).** The Iterations section now has a second rotation: the old **Angle** is renamed **Angle XY** (the in-plane spiral/swirl), and **Angle Z** tilts each cell's offset into depth. The tool is flat 2D — Figma, Penpot, and SVG have no depth axis — so the tilt is computed in 3D and orthographically projected back to 2D. A flat spiral leans into a cone or helix; a ring squashes into an ellipse seen at an angle. Output stays plain 2D positions, so it exports and round-trips like everything else. Library entries can declare an optional `angleZ`. +- **Depth shading.** A **Depth** dial (0–100%) sells the 3D illusion: cells leaning toward the viewer grow and stay bright, cells leaning away shrink and dim. It scales/fades on top of the Scale and Opacity sliders and does nothing without a nonzero Angle Z (no tilt, no depth to shade). Library entries can declare an optional `depthShade`. ## [v0.2.0] — 2026-05-22 diff --git a/docs/controls.md b/docs/controls.md index d3e66cb..2e4cdd2 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -26,6 +26,8 @@ The first section. This is where you set the count. **Angle Z.** Degrees of per-cell tilt into depth, applied after Angle XY. The tool is flat 2D, so there's no real depth axis — instead each clone's offset is tilted toward/away from you and projected back onto the canvas, which foreshortens it. Leave at 0 to stay flat. Dial it up and a flat spiral leans into a cone or helix, a ring squashes into an ellipse seen at an angle. Because it's a projection, the result is still plain 2D positions, so it exports and round-trips like everything else. +**Depth.** How hard to sell the 3D illusion, 0 to 100%. With Angle Z doing the tilt, this scales and fades each clone by how far it leans toward or away from you: the near side grows and stays bright, the far side shrinks and dims. It does nothing on its own — you need a nonzero Angle Z for there to be any depth to shade. At 0% you get pure projection (shapes keep their size); crank it up and the cone or helix reads as genuinely three-dimensional. Note this rides on top of your Scale and Opacity sliders, so a cell's final size is the slider value times the depth factor. + 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. ## Transform diff --git a/docs/llm-pattern-guide.md b/docs/llm-pattern-guide.md index a19b85e..27a6b21 100644 --- a/docs/llm-pattern-guide.md +++ b/docs/llm-pattern-guide.md @@ -49,6 +49,7 @@ You, the LLM reading this, are helping a designer write a new library pattern. Y | `rows` | yes | Integer 1 to 100. The default row count. Use `1` for linear or radial patterns. | | `angle` | optional | Number, -360 to 360. Per-cell rotation in degrees applied to the grid offset *in the screen plane*, *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. | | `angleZ` | optional | Number, -360 to 360. Per-cell tilt into depth in degrees, applied *after* `angle` and orthographically projected back to 2D (the tool is flat — there is no real depth axis). Cell `i` is tilted by `angleZ * i`, foreshortening its offset; a flat spiral leans into a cone/helix, a ring squashes into an ellipse. Output is still plain 2D positions. Default `0`. | +| `depthShade` | optional | Number, 0 to 100. Depth-cue strength: scales and fades each cell by how far its `angleZ` tilt leans it toward (bigger/brighter) or away from (smaller/dimmer) the viewer. No effect without a nonzero `angleZ`. Rides on top of the scale/opacity values. Default `0` (pure projection). | | `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. | diff --git a/library/_schema.json b/library/_schema.json index d9a9b3e..458b4c6 100644 --- a/library/_schema.json +++ b/library/_schema.json @@ -24,6 +24,12 @@ "maximum": 360, "description": "Per-cell tilt into depth in degrees, applied after `angle` and orthographically projected to 2D. Cell i is tilted by `angleZ * i`, turning a flat spiral into a cone/helix. 0 = flat." }, + "depthShade": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Depth-cue strength (0..100) driven by the `angleZ` tilt: cells leaning toward the viewer grow, cells leaning away shrink and fade. No effect without a nonzero `angleZ`. 0 = off." + }, "showFirst": { "type": "boolean", "description": "If true (default), the preview renders a clone at i=0. Set to false for patterns where i=0 naturally lands away from the origin (e.g. radial bursts) and the source should remain visually centered." diff --git a/src/plugin/engine/angle.ts b/src/plugin/engine/angle.ts index 2e6d904..b7195ee 100644 --- a/src/plugin/engine/angle.ts +++ b/src/plugin/engine/angle.ts @@ -16,17 +16,25 @@ // cos(angleZ * i): a flat disc seen edge-on becomes an ellipse, and a per-cell // tilt turns a spiral into a corkscrew. angleZ = 0 reproduces the pure-XY // output exactly. +// +// `depth` is the discarded component, normalized by the offset's magnitude to +// [-1, 1]: +1 = leaning fully toward the viewer, -1 = fully away, 0 = in the +// screen plane. Consumers use it as a depth cue (near = bigger/brighter). const DEG_TO_RAD = Math.PI / 180 -export function applyAngleToOffset( +export interface RotatedOffset { + x: number + y: number + depth: number // normalized depth toward the viewer in [-1, 1] +} + +export function rotateOffset( values: { x: number; y: number }, i: number, angleDegrees: number, angleZDegrees = 0, -): { x: number; y: number } { - if (angleDegrees === 0 && angleZDegrees === 0) return values - +): RotatedOffset { let x = values.x let y = values.y @@ -40,10 +48,27 @@ export function applyAngleToOffset( y = ry } + let depth = 0 if (angleZDegrees !== 0) { const phi = angleZDegrees * i * DEG_TO_RAD + const z = y * Math.sin(phi) y = y * Math.cos(phi) + // hypot of the pre-rotation offset — magnitude is rotation-invariant, so it + // equals the magnitude of the in-plane (x, y) we just rotated. + const mag = Math.hypot(values.x, values.y) + depth = mag === 0 ? 0 : z / mag } - return { x, y } + return { x, y, depth } +} + +export function applyAngleToOffset( + values: { x: number; y: number }, + i: number, + angleDegrees: number, + angleZDegrees = 0, +): { x: number; y: number } { + if (angleDegrees === 0 && angleZDegrees === 0) return { x: values.x, y: values.y } + const r = rotateOffset(values, i, angleDegrees, angleZDegrees) + return { x: r.x, y: r.y } } diff --git a/src/plugin/engine/cells.ts b/src/plugin/engine/cells.ts index faa4a78..e020c85 100644 --- a/src/plugin/engine/cells.ts +++ b/src/plugin/engine/cells.ts @@ -5,7 +5,7 @@ // Figma node, build an SVG element, etc.). import type { CompiledFormulas, LoopConfig, Scope } from '../../shared/types' -import { applyAngleToOffset } from './angle' +import { rotateOffset } from './angle' import type { CompiledFactors } from './compile' import { applyEasing } from './easing' import { buildScope } from './scope' @@ -18,10 +18,13 @@ export interface CellValues { // Grid offset after angle rotation. x: number y: number + // Normalized depth toward the viewer in [-1, 1] after the Angle Z tilt; 0 + // when angleZ is 0. Surfaced for consumers that want a custom depth cue. + depth: number rotation: number - scaleX: number + scaleX: number // depth shading already folded in (additive px delta to source size) scaleY: number - opacity: number // raw evaluated value (0..100); consumers clamp/normalize + opacity: number // raw evaluated value (0..100), depth shading folded in; consumers clamp/normalize // Per-property lerp factors in [0, 1] (formula-resolved or eased fallback). fillFactor: number strokeFactor: number @@ -51,12 +54,30 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { c, r, ) - const rotated = applyAngleToOffset( + const rotated = rotateOffset( { x: compiled.x.evaluate(scope, 'x'), y: compiled.y.evaluate(scope, 'y') }, scope.i, config.angle, config.angleZ ?? 0, ) + let scaleX = compiled.scaleX.evaluate(scope, 'scaleX') + let scaleY = compiled.scaleY.evaluate(scope, 'scaleY') + let opacity = compiled.opacity.evaluate(scope, 'opacity') + + // Depth shading: use the projected depth as a near/far cue. Cells leaning + // toward the viewer grow; cells leaning away shrink and fade. Strength is a + // 0..100 dial. No-op when there's no tilt (depth = 0) or the dial is off, so + // it never touches flat patterns. + const depthShade = config.depthShade ?? 0 + if (depthShade !== 0 && rotated.depth !== 0) { + const k = depthShade / 100 + const sizeFactor = 1 + k * rotated.depth // [1-k, 1+k] + scaleX = (sourceWidth + scaleX) * sizeFactor - sourceWidth + scaleY = (sourceHeight + scaleY) * sizeFactor - sourceHeight + // Brightening past full opacity is meaningless, so only the far side fades. + if (rotated.depth < 0) opacity *= 1 + k * rotated.depth + } + const baseEased = applyEasing(config.easing, computeInterpFactor(config, scope.tx, scope.ty)) return { i, @@ -65,10 +86,11 @@ export function evaluateCell(i: number, input: EvaluateCellInput): CellValues { scope, x: rotated.x, y: rotated.y, + depth: rotated.depth, rotation: compiled.rotation.evaluate(scope, 'rotation'), - scaleX: compiled.scaleX.evaluate(scope, 'scaleX'), - scaleY: compiled.scaleY.evaluate(scope, 'scaleY'), - opacity: compiled.opacity.evaluate(scope, 'opacity'), + scaleX, + scaleY, + opacity, fillFactor: factors.fill ? factors.fill.evaluate(scope, 'fillFactor') : baseEased, strokeFactor: factors.stroke ? factors.stroke.evaluate(scope, 'strokeFactor') : baseEased, strokeWeightFactor: factors.strokeWeight diff --git a/src/plugin/loop/diff.ts b/src/plugin/loop/diff.ts index 6d62546..076c8f6 100644 --- a/src/plugin/loop/diff.ts +++ b/src/plugin/loop/diff.ts @@ -46,14 +46,25 @@ export function diffConfig( // re-applied when either changes. const angleChanged = (prev.angle ?? 0) !== (next.angle ?? 0) || (prev.angleZ ?? 0) !== (next.angleZ ?? 0) + const xChanged = !eqNumericProperty(prev.x, next.x) + const yChanged = !eqNumericProperty(prev.y, next.y) + + // Depth shading derives scale and opacity from each cell's projected depth, + // which depends on x/y and the angles. So when shading is active, any change + // to those must re-apply scaleX/scaleY/opacity; toggling the dial itself + // always does. + const depthShadeChanged = (prev.depthShade ?? 0) !== (next.depthShade ?? 0) + const depthActive = (prev.depthShade ?? 0) !== 0 || (next.depthShade ?? 0) !== 0 + const depthDirtiesSize = + depthShadeChanged || (depthActive && (angleChanged || xChanged || yChanged)) const dirty: DirtyProperty[] = [] - if (!eqNumericProperty(prev.x, next.x) || angleChanged) dirty.push('x') - if (!eqNumericProperty(prev.y, next.y) || angleChanged) dirty.push('y') + if (xChanged || angleChanged) dirty.push('x') + if (yChanged || angleChanged) dirty.push('y') if (!eqNumericProperty(prev.rotation, next.rotation)) dirty.push('rotation') - if (!eqNumericProperty(prev.scaleX, next.scaleX)) dirty.push('scaleX') - if (!eqNumericProperty(prev.scaleY, next.scaleY)) dirty.push('scaleY') - if (!eqNumericProperty(prev.opacity, next.opacity)) dirty.push('opacity') + if (!eqNumericProperty(prev.scaleX, next.scaleX) || depthDirtiesSize) dirty.push('scaleX') + if (!eqNumericProperty(prev.scaleY, next.scaleY) || depthDirtiesSize) dirty.push('scaleY') + if (!eqNumericProperty(prev.opacity, next.opacity) || depthDirtiesSize) dirty.push('opacity') if (JSON.stringify(prev.fill) !== JSON.stringify(next.fill)) dirty.push('fill') if (JSON.stringify(prev.stroke) !== JSON.stringify(next.stroke)) dirty.push('stroke') if (!eqNumericProperty(prev.strokeWeight, next.strokeWeight)) dirty.push('strokeWeight') diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index 1fc2374..51ca0d5 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -24,6 +24,7 @@ export const DEFAULT_CONFIG: LoopConfig = { rows: 10, angle: 0, angleZ: 0, + depthShade: 0, x: num(60), y: num(60), rotation: num(0), @@ -50,6 +51,7 @@ export const RESET_CONFIG: LoopConfig = { rows: 1, angle: 0, angleZ: 0, + depthShade: 0, x: num(0), y: num(0), rotation: num(0), diff --git a/src/shared/types.ts b/src/shared/types.ts index c08e148..10d9e20 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,11 @@ export interface LoopConfig { // angle alone). Surfaced in the UI as "Angle Z". Optional for back-compat // with saved configs and library entries that predate it. angleZ?: number + // Strength (0..100) of the depth cue driven by the Angle Z tilt: cells + // leaning toward the viewer grow, cells leaning away shrink and fade. 0 = off + // (pure orthographic projection). No effect without a nonzero angleZ. Surfaced + // in the UI as "Depth". Optional for back-compat. + depthShade?: number // Base transforms (per-step) x: NumericProperty diff --git a/src/ui/library/types.ts b/src/ui/library/types.ts index af230ce..15b8db2 100644 --- a/src/ui/library/types.ts +++ b/src/ui/library/types.ts @@ -18,6 +18,10 @@ export interface LibraryEntry { * orthographically projected back to 2D. Cell i is tilted by `angleZ * i`, * turning a flat spiral into a cone/helix. Omit (or 0) for flat patterns. */ angleZ?: number + /** Optional depth-cue strength (0..100) driven by the `angleZ` tilt: cells + * leaning toward the viewer grow, cells leaning away shrink and fade. No + * effect without a nonzero `angleZ`. Omit (or 0) for no depth shading. */ + depthShade?: number showFirst?: boolean formulas: Partial> } diff --git a/src/ui/sections/IterationsSection.tsx b/src/ui/sections/IterationsSection.tsx index d433bc3..1fa7da1 100644 --- a/src/ui/sections/IterationsSection.tsx +++ b/src/ui/sections/IterationsSection.tsx @@ -92,6 +92,15 @@ export function IterationsSection({ unit="°" onChange={(v, commit) => update({ ...config, angleZ: v }, commit)} /> + update({ ...config, depthShade: v }, commit)} + /> ) } diff --git a/src/ui/sections/LibraryOverlay.tsx b/src/ui/sections/LibraryOverlay.tsx index 55759b1..159526b 100644 --- a/src/ui/sections/LibraryOverlay.tsx +++ b/src/ui/sections/LibraryOverlay.tsx @@ -36,6 +36,7 @@ function applyEntry(config: LoopConfig, entry: LibraryEntry): LoopConfig { rows: entry.rows, angle: entry.angle ?? 0, angleZ: entry.angleZ ?? 0, + depthShade: entry.depthShade ?? 0, fxMode: true, showFirst: entry.showFirst ?? true, } diff --git a/tests/angle.test.ts b/tests/angle.test.ts index 66faafb..ef830f2 100644 --- a/tests/angle.test.ts +++ b/tests/angle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { applyAngleToOffset } from '../src/plugin/engine/angle' +import { applyAngleToOffset, rotateOffset } from '../src/plugin/engine/angle' describe('applyAngleToOffset', () => { it('returns the input unchanged when angle is zero', () => { @@ -88,3 +88,26 @@ describe('applyAngleToOffset', () => { }) }) }) + +describe('rotateOffset depth', () => { + it('reports zero depth when there is no Z tilt', () => { + expect(rotateOffset({ x: 30, y: 40 }, 2, 25).depth).toBe(0) + }) + + it('is ±1 when the offset tilts fully toward / away (angleZ × i = ±90°)', () => { + // offset along +y, no XY rotation: the tilt sends it fully into depth. + expect(rotateOffset({ x: 0, y: 40 }, 1, 0, 90).depth).toBeCloseTo(1, 6) + expect(rotateOffset({ x: 0, y: 40 }, 1, 0, -90).depth).toBeCloseTo(-1, 6) + }) + + it('normalizes depth to the offset magnitude (scale-independent)', () => { + const small = rotateOffset({ x: 0, y: 10 }, 1, 0, 30).depth + const big = rotateOffset({ x: 0, y: 1000 }, 1, 0, 30).depth + expect(small).toBeCloseTo(big, 6) + expect(small).toBeCloseTo(Math.sin((30 * Math.PI) / 180), 6) + }) + + it('reports zero depth at the origin (no magnitude to normalize against)', () => { + expect(rotateOffset({ x: 0, y: 0 }, 3, 40, 45).depth).toBe(0) + }) +}) diff --git a/tests/cells.test.ts b/tests/cells.test.ts index 48fc02c..e2c5797 100644 --- a/tests/cells.test.ts +++ b/tests/cells.test.ts @@ -15,7 +15,7 @@ function reference( i: number, sw: number, sh: number, -): Omit { +): Omit { const compiled = compileConfig(config) const factors = compileFactors(config) const c = i % config.cols @@ -88,6 +88,67 @@ describe('evaluateCell', () => { expect(run(DEFAULT_CONFIG, 4).opacity).toBeCloseTo(100, 6) }) + it('depth shading grows toward-viewer cells and keeps their opacity', () => { + // angle 90° sends the +x offset onto +y; angleZ 90° tilts that fully toward + // the viewer (depth +1). depthShade 50% → sizeFactor 1.5 on a 40×30 source. + const config: LoopConfig = { + ...DEFAULT_CONFIG, + cols: 2, + rows: 1, + angle: 90, + angleZ: 90, + depthShade: 50, + x: { ...DEFAULT_CONFIG.x, value: 100 }, + y: { ...DEFAULT_CONFIG.y, value: 0 }, + scaleX: { ...DEFAULT_CONFIG.scaleX, value: 0 }, + scaleY: { ...DEFAULT_CONFIG.scaleY, value: 0 }, + } + const cell = run(config, 1, 40, 30) + expect(cell.depth).toBeCloseTo(1, 6) + expect(cell.scaleX).toBeCloseTo(20, 6) // 40 × 1.5 − 40 + expect(cell.scaleY).toBeCloseTo(15, 6) // 30 × 1.5 − 30 + expect(cell.opacity).toBeCloseTo(100, 6) // near side never over-brightens + }) + + it('depth shading shrinks and fades away-from-viewer cells', () => { + const config: LoopConfig = { + ...DEFAULT_CONFIG, + cols: 2, + rows: 1, + angle: 90, + angleZ: -90, // tilt away → depth −1 + depthShade: 50, + x: { ...DEFAULT_CONFIG.x, value: 100 }, + y: { ...DEFAULT_CONFIG.y, value: 0 }, + scaleX: { ...DEFAULT_CONFIG.scaleX, value: 0 }, + scaleY: { ...DEFAULT_CONFIG.scaleY, value: 0 }, + } + const cell = run(config, 1, 40, 30) + expect(cell.depth).toBeCloseTo(-1, 6) + expect(cell.scaleX).toBeCloseTo(-20, 6) // 40 × 0.5 − 40 + expect(cell.scaleY).toBeCloseTo(-15, 6) // 30 × 0.5 − 30 + expect(cell.opacity).toBeCloseTo(50, 6) // 100 × 0.5 + }) + + it('depth shading is a no-op when depthShade is 0 (pure projection)', () => { + const config: LoopConfig = { + ...DEFAULT_CONFIG, + cols: 2, + rows: 1, + angle: 90, + angleZ: 90, + depthShade: 0, + x: { ...DEFAULT_CONFIG.x, value: 100 }, + y: { ...DEFAULT_CONFIG.y, value: 0 }, + scaleX: { ...DEFAULT_CONFIG.scaleX, value: 7 }, + scaleY: { ...DEFAULT_CONFIG.scaleY, value: 0 }, + } + const cell = run(config, 1, 40, 30) + expect(cell.scaleX).toBeCloseTo(7, 6) + expect(cell.scaleY).toBeCloseTo(0, 6) + expect(cell.opacity).toBeCloseTo(100, 6) + }) + it('falls back to the eased base factor when a ramp has no formula', () => { const config: LoopConfig = { ...DEFAULT_CONFIG, cols: 5, rows: 1, easing: 'linear' } const cell = run(config, 3)