Iteration

Generative art is mostly loops. Solandra makes the common looping patterns first-class: instead of fiddling with indices and offsets you get positions, sizes and centers handed to you, already in canvas coordinates.

forTiling

The workhorse. Divides the canvas into an n-column grid and calls you for each tile with (point, delta, center, index):

  • point — the top-left corner of the tile
  • delta — the [width, height] of the tile
  • center — the center of the tile
  • index — a sequential counter

Options: margin (space around the edges), type: "square" (force square tiles; the default "proportionate" follows the canvas aspect ratio), and order ("columnFirst" or "rowFirst").

s.background(210, 25, 95)
s.forTiling(
  { n: 8, type: "square", margin: 0.05 },
  ([x, y], [dX], [cX, cY], i) => {
    s.setFillColor(120 + i * 2, 60, 50)
    s.fill(
      new RegularPolygon({
        at: [cX, cY],
        n: 3 + (i % 5),
        r: dX * 0.4,
        a: i * 0.05,
      })
    )
  }
)

forHorizontal and forVertical

Divide the canvas into full-height columns or full-width rows. Same callback signature as forTiling.

s.background(0, 0, 15)
s.forHorizontal({ n: 12, margin: 0.1 }, ([x, y], [dX, dY], _c, i) => {
  const h = 0.3 + 0.6 * Math.abs(Math.sin(i * 0.8))
  s.setFillColor(20 + i * 10, 80, 60)
  s.fill(
    new Rect({ at: [x + dX * 0.15, y + dY * (1 - h)], w: dX * 0.7, h: dY * h })
  )
})

forMargin(margin, callback) is the degenerate but useful case: a single cell inset from the edges.

forGrid

Iterates over integer coordinates, for algorithmic patterns on discrete grids (pair it with the hex, triangle and isometric transforms):

s.background(45, 40, 96)
s.forGrid({ minX: 1, maxX: 9, minY: 1, maxY: 9 }, ([x, y], i) => {
  const on = (x * y) % 3 === 0
  s.setFillColor(on ? 340 : 215, 70, 55)
  s.fill(
    new Circle({
      at: [x * 0.1, y * 0.1],
      r: on ? 0.04 : 0.015,
    })
  )
})

times, downFrom and range

Simple counting loops: times(n, cb) counts up from 0, downFrom(n, cb) counts down from n (great for painter's-algorithm layering), and range({ from, to, n }, cb) walks n (by default inclusive) steps across a numeric interval:

s.background(230, 40, 12)
s.downFrom(9, (n) => {
  s.setFillColor(260 - n * 15, 70, 25 + n * 6)
  s.fill(new Circle({ at: [0.5, 0.5], r: 0.05 * n }))
})
s.setStrokeColor(0, 0, 100, 0.6)
s.lineWidth = 0.002
s.range({ from: 0, to: Math.PI / 3, n: 12, inclusive: false }, (a) => {
  s.draw(new RegularPolygon({ at: [0.5, 0.5], n: 3, r: 0.47, a }))
})

aroundCircle

Places n points evenly around a circle (at defaults to the canvas center, r to 0.25):

s.background(20, 30, 95)
s.aroundCircle({ n: 24, r: 0.35 }, ([x, y], i) => {
  s.setFillColor(i * 15, 70, 55)
  s.fill(new Star({ at: [x, y], n: 5, r: 0.045, a: i * 0.3 }))
})

forRadialTiling

forTiling, but in circles: rings of cells going out from a centre, each ring divided into n sectors. Each cell arrives as a HollowArc, ready to fill or draw, along with the point at the middle of it, its bounds (r, r2, a, a2, plus which ring and sector it is) and a sequential index.

s.background(0, 0, 8)
s.forRadialTiling(
  { n: 20, rings: 5, r: 0.45 },
  (_at, cell, { ring, sector }) => {
    s.setFillColor(
      (sector % 2 === 0 ? 8 : 22) + ring * 4,
      sector % 2 === 0 ? 75 : 45,
      (ring + sector) % 2 === 0 ? 48 : 24
    )
    s.fill(cell)
  }
)

innerRadius leaves a hole in the middle, and from/to cover part of a turn rather than all of it, for a fan. The point handed over is the middle of the cell, and the mid angle of its bounds is the direction out to it, so anything drawn there can be turned to face outwards:

s.background(235, 25, 8)
s.forRadialTiling(
  { n: 12, rings: 3, r: 0.46, innerRadius: 0.09 },
  (at, cell, { ring, a, a2 }) => {
    s.setFillColor(210 + ring * 40, 60, 25 + ring * 8, 0.9)
    s.fill(cell)
 
    s.withTranslation(at, () => {
      s.withRotation((a + a2) / 2, () => {
        s.setFillColor(40 - ring * 10, 80, 60, 0.85)
        s.fill(new Ellipse({ at: [0, 0], w: 0.1 + ring * 0.03, h: 0.035 }))
      })
    })
  }
)

By default it goes round each ring in turn (order: "ringFirst"); "sectorFirst" goes out along each sector instead, which is what you want when the colour or size should build outwards.

forPoissonDiskPoints

Poisson disk sampling gives random points that are never closer than minDist: random but evenly spread, like scattered seeds. Much nicer than uniform random points for organic textures.

s.background(210, 60, 12)
s.forPoissonDiskPoints({ minDist: 0.08 }, ([x, y], i) => {
  s.setFillColor(180 + y * 100, 70, 60)
  s.fill(new Circle({ at: [x, y], r: 0.025 }))
})

alongPath

alongPath walks a path instead of a grid: it visits n points spread evenly by distance along it, and gives you the angle the path is heading in at each one, so things can be laid out following the path rather than merely sitting on it.

It takes a SimplePath, or anything that has one — Line, Rect, RegularPolygon, Star and Spiral all do. Pass inclusive: false for a closed path, where the last point would otherwise repeat the first.

s.background(215, 40, 15)
const star = new Star({ at: s.meta.center, n: 5, r: 0.28, r2: 0.13 })
 
s.alongPath({ path: star, n: 60, inclusive: false }, (at, angle, i) => {
  s.setFillColor(45 + i * 3, 85, 60)
  s.withTranslation(at, () => {
    s.withRotation(angle, () => {
      s.fill(
        new Rect({
          at: [0, 0],
          w: 0.006,
          h: 0.03 + 0.03 * (i % 3),
          align: "center",
        })
      )
    })
  })
})

The underlying measurements are on SimplePath itself (length, pointAt, tangentAt, pointsAlong) if you want the points without drawing; see Paths and Curves.

Higher order iteration: build and withRandomOrder

Every iteration helper can be composed with two higher-order utilities.

build runs an iteration helper but collects the callback's return values into an array, so you can gather data first and draw later:

// collect tile centers, then connect them in a shuffled tour
s.background(0, 0, 96)
const centers = s.build(
  s.forTiling,
  { n: 6, type: "square" },
  (_pt, _d, c) => c
)
s.shuffle(centers)
s.lineWidth = 0.004
s.setStrokeColor(215, 60, 45)
centers.forEach((from, i) => {
  const to = centers[(i + 1) % centers.length]
  s.draw(new Line(from, to))
})

withRandomOrder runs an iteration helper but executes the callbacks in shuffled order; essential when overlapping tiles should layer unpredictably:

s.background(40, 30, 95)
s.withRandomOrder(
  s.forTiling,
  { n: 6, type: "square", margin: 0.1 },
  ([x, y], [dX], _c, i) => {
    s.setFillColor(i * 4, 70, 55, 0.95)
    s.fill(new Square({ at: [x, y], s: dX * 1.4 }))
  }
)

Choosing at random: proportionately and doProportion

Two small helpers bridge iteration and randomness. doProportion(p, cb) runs the callback with probability p. proportionately picks one of several weighted branches (weights need not sum to anything in particular):

s.background(220, 30, 14)
s.forTiling({ n: 10, type: "square" }, ([x, y], [dX], [cX, cY]) => {
  s.proportionately([
    [
      3,
      () => {
        s.setFillColor(45, 90, 60)
        s.fill(new Circle({ at: [cX, cY], r: dX * 0.35 }))
      },
    ],
    [
      2,
      () => {
        s.setFillColor(340, 80, 60)
        s.fill(new Square({ at: [cX, cY], s: dX * 0.6, align: "center" }))
      },
    ],
    [1, () => {}], // sometimes do nothing
  ])
})

Next up: Randomness and Noise, which powers all the sampling used above.

Solandra was made by James Porter.

Check out the GitHub page or install with npm i solandra