diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c10eb..c35d1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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`. +- **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 ### Added diff --git a/docs/controls.md b/docs/controls.md index d1f3d19..2e4cdd2 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -22,7 +22,11 @@ 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. + +**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. diff --git a/docs/llm-pattern-guide.md b/docs/llm-pattern-guide.md index 333809d..27a6b21 100644 --- a/docs/llm-pattern-guide.md +++ b/docs/llm-pattern-guide.md @@ -47,7 +47,9 @@ 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`. | +| `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 25f5adb..458b4c6 100644 --- a/library/_schema.json +++ b/library/_schema.json @@ -16,7 +16,19 @@ "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." + }, + "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", diff --git a/src/plugin/engine/angle.ts b/src/plugin/engine/angle.ts index fa60ab9..b7195ee 100644 --- a/src/plugin/engine/angle.ts +++ b/src/plugin/engine/angle.ts @@ -1,21 +1,74 @@ // 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. +// +// `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 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, +): RotatedOffset { + 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 + } + + 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, depth } +} + 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 { 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 e53e976..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,11 +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, @@ -64,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 feb26d9..076c8f6 100644 --- a/src/plugin/loop/diff.ts +++ b/src/plugin/loop/diff.ts @@ -42,16 +42,29 @@ 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 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 e76e34e..51ca0d5 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -23,6 +23,8 @@ export const DEFAULT_CONFIG: LoopConfig = { cols: 10, rows: 10, angle: 0, + angleZ: 0, + depthShade: 0, x: num(60), y: num(60), rotation: num(0), @@ -48,6 +50,8 @@ export const RESET_CONFIG: LoopConfig = { cols: 1, 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 d4bba87..10d9e20 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,10 +41,23 @@ 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 + // 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 d3dbe57..15b8db2 100644 --- a/src/ui/library/types.ts +++ b/src/ui/library/types.ts @@ -9,11 +9,19 @@ 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 + /** 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 9c95627..1fa7da1 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)} + /> + update({ ...config, depthShade: v }, commit)} + /> ) } diff --git a/src/ui/sections/LibraryOverlay.tsx b/src/ui/sections/LibraryOverlay.tsx index 05c46a0..159526b 100644 --- a/src/ui/sections/LibraryOverlay.tsx +++ b/src/ui/sections/LibraryOverlay.tsx @@ -35,6 +35,8 @@ function applyEntry(config: LoopConfig, entry: LibraryEntry): LoopConfig { cols: entry.cols, 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 cc2cbca..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', () => { @@ -50,4 +50,64 @@ 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) + }) + }) +}) + +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)