Building a Sol LeWitt Drawing Generator

#react#web#art

Instruction-driven generative wall drawings in the browser. A tribute to Sol LeWitt, built with Next.js and Canvas 2D.

Live at: intervolz.com/sollewitt

HN Post

Wall Drawing #11 — generated in the browser


The Idea

Sol LeWitt wrote instructions. Other people drew them. The drawings were never the same twice — the instructions defined constraints, and the execution was an interpretation within those constraints.

HE IS THE ORIGINAL PROMPT ENGINEER!!

That's exactly what a generative system does. So I built one.

Each "wall drawing" is defined as a data structure with title, description, and an ordered list of steps. The engine reads the steps, rolls a seed, and produces strokes. Press Create and it draws itself. Re-roll and you get a different valid interpretation of the same instruction.

The whole thing runs as a static Next.js site. No server, no database. Just instructions and math.


Tech Stack

TechRole
Next.js 15App framework, static export
Canvas 2DAll rendering
TypeScriptType safety

That's it. No WebGL, no heavy rendering libraries. Canvas 2D is the right tool for 2D generative art, there's no render loop to manage, and the imperative API maps naturally to stroke-by-stroke drawing.


The Instruction Model

Every drawing is a DrawingInstruction. Here's Wall Drawing #11:

{
  id: 'wall-drawing-11',
  title: 'Wall Drawing #11',
  description:
    'A wall divided horizontally and vertically into four equal parts. '
    + 'Within each part, three of the four kinds of lines are superimposed.',
  year: 1969,
  medium: 'Black pencil',
  steps: [
    { type: 'divide', params: { rows: 2, cols: 2, drawGrid: true } },
    {
      type: 'lines',
      params: {
        lineKinds: ['horizontal', 'vertical', 'diagonal-left', 'diagonal-right'],
        pickPerCell: 3,
        density: { min: 30, max: 60 },
      },
    },
  ],
}
  1. divide → draws the grid
  2. Then reads lines → for each cell
  3. We shuffle the four line kinds, pick three, and fill the cell with dense parallel hatching
    • The density range means each seed produces a slightly different feel

Adding a new drawing means writing a new instruction file and registering it. The engine handles the rest.


The Diagonal Problem

First pass at diagonal lines was wrong. I was parameterizing start and end points on opposing edges and interpolating — which produces fan lines that converge, not parallel hatching.

Sol LeWitt's diagonals are straight parallel lines at 45°. Every line has the same slope, just offset. The fix was a sweep across the cell:

case 'diagonal-right': {
  // ly = lx + k, sweep k from -w to h
  const k = -w + (i + 0.5) / count * (w + h)
  const lx0 = Math.max(0, -k)
  const ly0 = Math.max(0, k)
  const lx1 = Math.min(w, h - k)
  const ly1 = Math.min(h, w + k)
  if (lx0 < lx1) {
    lines.push([[x + lx0, y + ly0], [x + lx1, y + ly1]])
  }
  break
}

Each line satisfies ly = lx + k in cell-local coordinates. k sweeps from -w to h, evenly spaced. The Math.max/Math.min calls clip each line to the cell bounds. Every line is parallel. Every line is at 45°.

Correct: parallel hatching at 45°


The Offscreen Buffer

Drawing hundreds of strokes every frame is wasteful. Most frames during playback only add a few new strokes. So the renderer uses a double-buffer strategy:

  1. An offscreen canvas accumulates drawn strokes. New strokes get drawn onto it incrementally.
  2. Each frame, the offscreen buffer is blitted to the display canvas in a single drawImage call.
  3. If progress goes backward (seek or re-roll), the buffer clears and redraws from scratch up to the target.
frame N:   strokes 0-140 already on buffer → draw 141-145 → blit
frame N+1: strokes 0-145 already on buffer → draw 146-150 → blit
seek to 0: clear buffer → blit empty

The animation loop runs via requestAnimationFrame and reads a shared progressRef — a mutable ref that the playback timer writes to. No React state, no re-renders. The rAF loop, the progress ref, and the canvas context are the entire rendering pipeline.

Both the display canvas and offscreen buffer are scaled by devicePixelRatio for crisp lines on Retina displays.


Playback

Each wall section is a full-viewport (100dvh) container. You see the drawing's instructions centered on screen with a Create button. Click it and the instructions fade out, then the drawing animates over 5 seconds. Once done, re-roll and share/download buttons appear. Re-roll takes you back to the instruction view with a new seed.

The progress ref is shared between three systems:

  • The rAF timer in WallSection writes progressRef.current every frame
  • The canvas paint loop in DrawingCanvas reads it every frame and draws incrementally
  • The timeline bar gets its width set via direct DOM manipulation (bar.style.width)

Zero setState calls during playback. React only re-renders on state transitions — the Create/complete/reroll moments.


Drawings

Nineteen wall drawings implemented so far. Each one required a different step handler in the engine.

Wall Drawing #11 (1969)

Four equal squares, three of four line kinds per cell. Dense parallel hatching.

Wall Drawing #11

Wall Drawing #17 (1969)

Four parts, a different line direction in each. Uses the unique flag to assign one kind per cell without repeats.

Wall Drawing #17

Wall Drawing #46 (1970)

Vertical lines, not straight, not touching. The wobbly-lines handler generates per-segment random displacement around a vertical center.

Wall Drawing #46

Wall Drawing #38 (1970)

Colored tissue paper squares in a pegboard grid. The pegboard handler fills a grid with randomly colored squares — closed paths detected and filled instead of stroked.

Added an optional background color for this guy.

Wall Drawing #38

Wall Drawing #46 (1970)

Vertical lines, not straight, not touching, covering the wall evenly.

Wall Drawing #46

Wall Drawing #47 (1970)

A wall divided into fifteen equal parts, each with a different line direction, and all combinations.

Wall Drawing #47


Project Structure

app/
  layout.tsx            Root layout
  page.tsx              Entry → Gallery

components/
  Gallery.tsx           Seed management, renders WallSections
  WallSection.tsx       100dvh container, playback timer, controls
  DrawingCanvas.tsx     Canvas 2D renderer with offscreen buffer
  Controls.tsx          Re-roll / Download
  Timeline.tsx          Seekable progress bar

lib/
  engine.ts             Seeded PRNG, stroke generation, step handlers
  drawings/             One file per instruction

types/
  drawings.ts           DrawingInstruction, StrokeElement, LineKind

Engine Functions

The engine.ts file contains handler functions for each step type. Each handler interprets its parameters and generates StrokeElement[] arrays.

HandlerStep TypeDescriptionUsed By
handleDivideAndLinesdivide + linesGrid of cells with parallel line hatching. Supports unique (one direction per cell) and progressive (cumulative directions) modes. Optional colors per direction.#11, #17, #19, #47, #56, #87
handleBandsbandsRandomly placed bands of parallel lines in specified directions.#16
handlePegboardpegboardGrid of colored squares (closed paths that get filled).#38
handleWobblyLineswobbly-linesVertical lines with per-segment random displacement for hand-drawn effect.#46
handleCombinatorialcombinatorialAll combinations of line directions arranged in rows (4 singles, 6 pairs, 4 triples, 1 quad = 15 cells).#85
handleScatteredLinesscattered-linesThousands of short lines at random positions and angles, covering wall evenly.#86
handleGridWobblygrid-wobblyGrid of cells where each cell has wobbly lines in one random direction. Supports fillViewport for edge-to-edge cells.#88
handleCombinatorialWobblycombinatorial-wobbly15 vertical columns for all color combinations, with wobbly vertical lines.#95
handleGridAndArcsgrid-and-arcsGrid overlay with concentric quarter-circle arcs emanating from all four corners.#130
handleMidpointArcsmidpoint-arcsArcs drawn from midpoints of each wall side.#138
handleProgressiveWobblyGridprogressive-wobbly-gridGrid with increasing count of wobbly lines per column (vertical) and per row (horizontal). Supports fillViewport.#142
handleSquareAndLinesquare-and-lineRandom black square with one or more colored lines crossing through.#154, #159, #160

Utility functions:

  • seededRandom(seed) — deterministic PRNG for reproducible outputs
  • shuffle(arr, rand) — Fisher-Yates shuffle using seeded random
  • generateLinesForCell(kind, x, y, w, h, count) — parallel lines at 0°, 90°, or 45° within bounds
  • getCombinations(arr, k) — all k-combinations of array elements
  • pushStroke(strokes, path, style) — append a stroke with auto-incrementing draw order

What I Learned

Canvas 2D is underrated. For 2D generative art, it's the right tool.

Offscreen buffers make incremental drawing trivial. The double-buffer pattern is old graphics programming wisdom. It works just as well in a browser <canvas>.

Refs over state for animation. React state triggers re-renders. Mutable refs don't. For anything running at 60fps, refs are the only sane option. Share a ref between the timer and the renderer, skip React entirely for the hot path.

Sol LeWitt's instructions are surprisingly precise. "Three of the four kinds of lines" is an exact combinatorial specification. The generative system just formalizes what was already algorithmic.


More drawings soon!

Thank you for checking out my project.