Skip to content

Add a Layers axis (3D point lattice)#4

Merged
mmichelli merged 11 commits into
masterfrom
claude/3d-lattice-layers
May 24, 2026
Merged

Add a Layers axis (3D point lattice)#4
mmichelli merged 11 commits into
masterfrom
claude/3d-lattice-layers

Conversation

@mmichelli
Copy link
Copy Markdown
Contributor

@mmichelli mmichelli commented May 23, 2026

Replaces the abandoned tilt approach in #3 with a true 3D lattice, and expands the library to use it.

Layers (3D lattice)

The Iterations grid becomes Columns × Rows × Layers — a cube of dots. The engine only emits the cells; it does not project. Each cell's 3D address (l, layers, tz) is exposed to the formula scope, and projection to 2D lives in formulas and library presets, not a built-in projection mode. Layers defaults to 1, so existing patterns are byte-identical. i/n now span the whole cube.

Library

New 3D presets (exercise the Layers axis): Cube, Sphere (longitude × latitude × shells), Helix (Layers = strands → double helix), Torus (tilted donut), Cylinder (tube; Layers = shells).

New 2D presets: Truchet (random quarter-turns), Concentric Squares, Square Spiral (squircle map → true square outlines).

Fixes / polish:

  • Halftone scale was an additive ~1px no-op; it now visibly shrinks dots with distance.
  • Tunability pass: wave, lissajous, damped-wave, phyllotaxis, polar-grid, ribbon expose amplitude/spacing via {x:..}/{y:..} so the X/Y sliders drive them.

Why not the tilt approach (#3)

#3's Tilt multiplied the depth angle by the cell index (angleZ * i), which scatters grids instead of tilting them. Superseded; #3 is closed.

Verification

  • 328 tests pass (3D scope + flat-index mapping; every library formula evaluates without throwing), lint clean, build:all green.
  • Verified live: Cube renders a 5×5×5 lattice; Sphere renders a round 300×300 silhouette with depth-varied dot sizes (32–64px). Helix/Torus/Cylinder/Truchet/Concentric-Squares/Square-Spiral are formula-validated but not yet eyeballed.

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.
mmichelli added 10 commits May 23, 2026 18:50
…Truchet

- Halftone: scale now visibly shrinks dots with distance (was an additive
  ~1px no-op); opacity falloff unchanged.
- Tunability: wave, lissajous, damped-wave, phyllotaxis, polar-grid, ribbon
  expose amplitude/spacing via {x:..}/{y:..} placeholders so the X/Y sliders
  drive them (matching Spiral/Heart/etc.).
- New 3D presets (exercise the Layers axis): Sphere (longitude × latitude ×
  shells), Helix (coil; Layers = strands → double helix), Torus (tilted donut).
- New 2D preset: Truchet (random quarter-turn rotations, rerolls with seed).
- Concentric Squares + Square Spiral: squircle map (cos/max(|cos|,|sin|))
  traces true square outlines — nested rings and a boxy Archimedean spiral.
- Cylinder: a 3D tube (Columns wrap, Rows climb, Layers = shells), tilted and
  depth-shaded so the near wall reads larger/brighter.
Addresses code-review findings on PR #4:
- Thumbnail now iterates layers (was flattening every 3D preset to a single
  layer-0 slice — Cube's library card showed a flat grid instead of a cube).
- Cube formulas use the normalized tz (0..1) instead of raw l, so its
  offset/scale/opacity stay bounded as the Layers slider grows. Previously the
  geometry exploded (~±830px at layers=50) and back-layer opacity underflowed
  to invisible past ~10 layers.
Code-review follow-ups (#3, #6):
- Add cellCount() with a MAX_CELLS (10000) cap, used by the orchestrator and
  preview render loop, so a 50×50×50 config renders a truncated result instead
  of cloning 125k host nodes and freezing.
- Library "every formula evaluates" test now iterates layers (passing l), so 3D
  presets like Cube are exercised at every depth, not just layer 0.
Reorganize the panel into Column, Row, Layer sections (plus Modulation). Each
axis carries its count, step, angle (clone rotation), and a fade. Layer also
gains an oblique depth step, a twist, an opacity fade, and a colour-by-depth
toggle, and absorbs the fill/stroke ramps (the former Appearance section).

- engine: per-axis transforms applied as an additive post-process in
  evaluateCell — rotation += c·columnAngle + r·rowAngle + l·layerAngle; oblique
  layer offset; per-axis opacity fade; layer colour sweeps the ramp by tz. All
  default to 0/false, so existing configs render identically; fx + modulation
  are untouched.
- ui: new generic AxisSection (Column/Row); Layer folded into the former
  Appearance section; the standalone Iterations + Transform sections removed.
- tests: per-axis transform math + back-compat no-op.
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.
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.
- 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.
…rder

- Reconcile the three-way count mismatch (sliders capped at 50, schema at 100,
  presets up to 120). Introduce a single MAX_AXIS = 120 used by the Count
  sliders and paste-clamping, and raise the library schema to match — so
  high-count presets (rose, square-spiral) load without the slider clamping
  them away.
- Harden config paste: sanitizePastedConfig fills missing fields from the
  defaults (a partial JSON can no longer crash the compiler) and clamps
  cols/rows/layers to [1, MAX_AXIS]. Replaces the old "cols/rows are numbers"
  check that pushed raw JSON straight into the engine.
- Fix library thumbnail depth order: paint far layers first so 3D presets show
  the near layer on top, matching the live preview.

Left two cosmetic review nits as-is: the fx affordance differs because sliders
and ramp strips are genuinely different controls, and Direction is now
contextualized by the Layer hint + dimming.
Real-Penpot testing showed the plugin is unusable while dragging sliders:
Penpot's reactive document + WASM render engine can't absorb a full
regenerate on every drag frame, so it falls into a React #185 ("max update
depth") loop and, at higher counts, a hard Internal Error.

Add two HostAdapter capabilities:
- liveUpdates — Figma true, Penpot false. host-loop now drops uncommitted
  live-drag frames on non-live hosts, so on Penpot the sliders stay smooth
  and the canvas regenerates once, on release (commit). Figma keeps its live
  preview unchanged.
- maxCells — Figma 10_000, Penpot 1_000 (conservative crash guard for the
  render engine; tune against real Penpot). The orchestrator clamps each
  generate to it.

Tests cover both: a commit-only host drops live-drag updates and regenerates
on commit, and a generate is capped at maxCells.
@mmichelli mmichelli merged commit 16a9c72 into master May 24, 2026
1 check passed
@mmichelli mmichelli deleted the claude/3d-lattice-layers branch May 25, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant