Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion docs/llm-pattern-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
14 changes: 13 additions & 1 deletion library/_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 64 additions & 11 deletions src/plugin/engine/angle.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
37 changes: 30 additions & 7 deletions src/plugin/engine/cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
27 changes: 20 additions & 7 deletions src/plugin/loop/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 4 additions & 0 deletions src/shared/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
19 changes: 16 additions & 3 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/ui/library/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<FormulaProperty, string>>
}
20 changes: 19 additions & 1 deletion src/ui/sections/IterationsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,32 @@ export function IterationsSection({
onChange={(v, commit) => update({ ...config, rows: Math.max(1, Math.round(v)) }, commit)}
/>
<SliderRow
label="Angle"
label="Angle XY"
value={config.angle}
min={-180}
max={180}
step={0.1}
unit="°"
onChange={(v, commit) => update({ ...config, angle: v }, commit)}
/>
<SliderRow
label="Angle Z"
value={config.angleZ ?? 0}
min={-180}
max={180}
step={0.1}
unit="°"
onChange={(v, commit) => update({ ...config, angleZ: v }, commit)}
/>
<SliderRow
label="Depth"
value={config.depthShade ?? 0}
min={0}
max={100}
step={1}
unit="%"
onChange={(v, commit) => update({ ...config, depthShade: v }, commit)}
/>
</Section>
)
}
2 changes: 2 additions & 0 deletions src/ui/sections/LibraryOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Loading
Loading