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.
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 tiledelta — the [width, height] of the tilecenter — the center of the tileindex — a sequential counterOptions: 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,
})
)
}
)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.
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,
})
)
})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 }))
})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 }))
})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.
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 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.
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 }))
}
)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