The Ridge That Sags
2026 · architecture
A roofscape is an argument about repetition: every course the same, no two the same, and the whole thing held together by the one ridge that has begun to lose the argument.
tiled roof ridges seen from above, stacking diagonally up from the lower left, tiles catching a low sun unevenly, one ridge in the upper right sagging under its own weight
painted by Kimi K3p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: seven tiled roofs stack up the anti-diagonal from the lower left; the sagging ridge lands upper-right of centre, with the top-left and lower-right corners left as open paper
// layering: three pale washes along the band first, then each roof back-to-front — lit far slope, slate near slope, uneven light patches, tile courses
// last: the ink eave-shadow under the sag, and a rationed rust on a few ridge caps
const PALETTE = [
"#ece6da", // ground — warm paper, the gutters between roofs
"#8c99a0", // mass — slate pantile, the broad near slopes
"#5b6a71", // shadow — the cool side of every course
"#2c363b", // ink — eaves, ridge caps, the sag
"#bd8f55", // light — low sun on the far slopes
"#bf4f2c", // accent — rust, a handful of tiles only
];
// x0,y0 -> x1,y1 is the ridge line. near/far are the slope depths either side of it.
// sag bows the ridge toward the viewer; the eave barely follows, so the slope narrows at the dip.
const ROOFS = [
{ x0: 646, y0: 126, x1: 814, y1: 70, near: 54, far: 20, sag: 0, steps: 12, mass: 72, lit: 54, keep: 0.55, patches: 2 },
{ x0: 388, y0: 322, x1: 678, y1: 208, near: 126, far: 50, sag: 26, steps: 26, mass: 118, lit: 108, keep: 0.9, patches: 4 },
{ x0: 268, y0: 470, x1: 560, y1: 386, near: 98, far: 38, sag: 7, steps: 24, mass: 102, lit: 80, keep: 0.78, patches: 3 },
{ x0: 556, y0: 434, x1: 708, y1: 390, near: 64, far: 24, sag: -6, steps: 14, mass: 86, lit: 62, keep: 0.68, patches: 2 },
{ x0: 58, y0: 604, x1: 322, y1: 470, near: 92, far: 34, sag: 9, steps: 22, mass: 106, lit: 72, keep: 0.76, patches: 3 },
{ x0: -34, y0: 744, x1: 268, y1: 630, near: 114, far: 40, sag: 13, steps: 24, mass: 112, lit: 76, keep: 0.72, patches: 3 },
{ x0: 236, y0: 692, x1: 428, y1: 624, near: 74, far: 28, sag: -9, steps: 16, mass: 96, lit: 64, keep: 0.7, patches: 2 },
];
function axes(r) {
const dx = r.x1 - r.x0;
const dy = r.y1 - r.y0;
const len = Math.hypot(dx, dy);
// n points down-slope, toward the viewer
return { ux: dx / len, uy: dy / len, nx: -dy / len, ny: dx / len, len };
}
// the ridge as a straight spine plus a separate sag profile, so each edge can follow the sag by its own amount
function spine(r) {
const a = axes(r);
const base = [];
const sag = [];
for (let i = 0; i <= r.steps; i++) {
const t = i / r.steps;
base.push([r.x0 + a.ux * a.len * t, r.y0 + a.uy * a.len * t]);
sag.push(r.sag * Math.sin(Math.PI * t));
}
return { base, sag, a };
}
function row(g, depth, follow, wobble) {
const out = [];
for (let i = 0; i < g.base.length; i++) {
const d = depth + g.sag[i] * follow;
out.push([
g.base[i][0] + g.a.nx * d + randomGaussian(0, wobble),
g.base[i][1] + g.a.ny * d + randomGaussian(0, wobble),
]);
}
return out;
}
function between(p, q, t) {
return [lerp(p[0], q[0], t), lerp(p[1], q[1], t)];
}
// a flat interlocking facePlane: down one edge, back along the other.
// the outline is walked in long strides — a roof edge wants six knuckles, not thirty
function facePlane(top, bottom, col, alpha, bleed, tex) {
const stride = Math.max(1, Math.floor((top.length - 1) / 6));
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(tex, 0.5);
brush.fill(col, alpha);
brush.beginShape(0.1);
for (let i = 0; i < top.length; i += stride) brush.vertex(top[i][0], top[i][1]);
brush.vertex(top[top.length - 1][0], top[top.length - 1][1]);
for (let i = bottom.length - 1; i >= 0; i -= stride) brush.vertex(bottom[i][0], bottom[i][1]);
brush.vertex(bottom[0][0], bottom[0][1]);
brush.endShape(true);
}
// a band of tiles catching the light differently from its neighbours
function patch(ridge, eave, i0, i1, t0, t1, col, alpha) {
const top = [];
const bottom = [];
for (let i = i0; i <= i1; i++) {
top.push(between(ridge[i], eave[i], t0));
bottom.push(between(ridge[i], eave[i], t1));
}
facePlane(top, bottom, col, alpha, 0.44, 0.55);
}
function scatterPatches(ridge, eave, n, warm) {
const last = ridge.length - 1;
for (let k = 0; k < n; k++) {
const w = Math.max(2, Math.floor(randomGaussian(4.4, 1.6)));
const i0 = constrain(Math.floor(random(0, last - w)), 0, last - w);
const t0 = constrain(randomGaussian(0.34, 0.24), 0.02, 0.72);
const t1 = constrain(t0 + randomGaussian(0.3, 0.11), t0 + 0.1, 0.99);
const hot = random() < warm;
patch(ridge, eave, i0, i0 + w, t0, t1,
hot ? "#bd8f55" : "#5b6a71",
hot ? 62 + random(-14, 26) : 74 + random(-16, 30));
}
}
// the pantile flutes: one stroke per course, ridge to eave, most of them broken short
function courses(ridge, eave, keep, weight) {
brush.noFill();
for (let i = 0; i < ridge.length; i++) {
if (random() > keep) continue;
const top = between(ridge[i], eave[i], random(0.02, 0.12));
const cut = random() < 0.34 ? random(0.5, 0.82) : random(0.88, 1.0);
const end = between(ridge[i], eave[i], cut);
const dark = random() < 0.26;
brush.set(random() < 0.5 ? "2H" : "HB",
dark ? "#2c363b" : "#5b6a71",
weight * (0.72 + random(0, 0.7)));
brush.line(top[0], top[1], end[0], end[1]);
}
}
// two or three tile rows crossing the courses, always broken
function rows(ridge, eave, n) {
const last = ridge.length - 1;
brush.noFill();
for (let k = 0; k < n; k++) {
const t = constrain(randomGaussian(0.42 + k * 0.2, 0.07), 0.14, 0.9);
const i0 = Math.floor(random(0, last * 0.4));
const i1 = Math.floor(random(i0 + 3, last + 1));
brush.set("2H", "#5b6a71", 0.5 + random(0, 0.4));
brush.beginShape(0.3);
for (let i = i0; i <= i1; i += 2) {
const p = between(ridge[i], eave[i], t + randomGaussian(0, 0.012));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
}
}
function paintRoof(r) {
const g = spine(r);
const ridge = row(g, 0, 1, 1.1);
const eave = row(g, r.near, 0.14, 2.3);
const back = row(g, -r.far, 0, 1.5);
// the far slope, tipped to the low sun, is the one warm band per roof
facePlane(ridge, back, "#bd8f55", r.lit, 0.28, 0.45);
// the near slope: one broad slate pass, then bands that catch the light unevenly
facePlane(ridge, eave, "#8c99a0", r.mass, 0.32, 0.55);
scatterPatches(ridge, eave, r.patches, 0.42);
courses(ridge, eave, r.keep, 0.95);
rows(ridge, eave, random() < 0.5 ? 2 : 3);
// eave line under the near slope, ridge cap over the top
brush.noFill();
brush.set("charcoal", "#2c363b", 1.5 + random(0, 0.5));
brush.beginShape(0.25);
for (let i = 0; i < eave.length; i += 2) brush.vertex(eave[i][0], eave[i][1]);
brush.endShape(false);
brush.set("pen", "#2c363b", 1.9 + random(0, 0.6));
brush.beginShape(0.25);
for (let i = 0; i < ridge.length; i += 2) brush.vertex(ridge[i][0], ridge[i][1]);
brush.endShape(false);
return { ridge, eave, back };
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#ece6da");
// three washes laid along the band so the paper is never bare white under the tiles
brush.noStroke();
brush.fillBleed(0.4, "out");
brush.fillTexture(0.3, 0.4);
brush.fill("#8c99a0", 22);
brush.circle(250, 570, 148, true);
brush.fill("#8c99a0", 17);
brush.circle(506, 330, 132, true);
brush.fill("#bd8f55", 15);
brush.circle(664, 218, 104, true);
const roof = [];
for (let i = 0; i < ROOFS.length; i++) roof.push(paintRoof(ROOFS[i]));
const focal = roof[1];
const mid = Math.floor(focal.ridge.length / 2);
// the sag, argued twice: a deep pool of shadow gathering in the dip
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.6, 0.6);
patch(focal.ridge, focal.eave, mid - 6, mid + 5, 0.04, 0.46, "#2c363b", 96);
patch(focal.ridge, focal.eave, mid - 3, mid + 3, 0.06, 0.3, "#2c363b", 118);
patch(focal.ridge, focal.eave, mid - 9, mid - 4, 0.5, 0.94, "#bd8f55", 84);
patch(focal.ridge, focal.eave, mid + 4, mid + 10, 0.44, 0.88, "#bd8f55", 66);
// and the eave beneath it, thrown down onto the gutter — the darkest mark on the sheet
brush.noFill();
brush.set("charcoal", "#2c363b", 3.4);
brush.beginShape(0.4);
brush.vertex(focal.eave[mid - 8][0] + 3, focal.eave[mid - 8][1] + 5);
brush.vertex(focal.eave[mid - 3][0] + 5, focal.eave[mid - 3][1] + 9);
brush.vertex(focal.eave[mid + 2][0] + 6, focal.eave[mid + 2][1] + 10);
brush.vertex(focal.eave[mid + 7][0] + 4, focal.eave[mid + 7][1] + 6);
brush.endShape(false);
brush.set("charcoal", "#2c363b", 2.6);
brush.beginShape(0.4);
brush.vertex(focal.ridge[mid - 7][0] - 2, focal.ridge[mid - 7][1] + 2);
brush.vertex(focal.ridge[mid][0] - 1, focal.ridge[mid][1] + 3);
brush.vertex(focal.ridge[mid + 6][0] + 1, focal.ridge[mid + 6][1] + 2);
brush.endShape(false);
// three broken tiles at the low point of the sag
brush.set("2B", "#2c363b", 1.6);
brush.line(focal.ridge[mid - 2][0] + 4, focal.ridge[mid - 2][1] + 12, focal.ridge[mid - 2][0] + 18, focal.ridge[mid - 2][1] + 20);
brush.line(focal.ridge[mid + 1][0] - 2, focal.ridge[mid + 1][1] + 9, focal.ridge[mid + 1][0] + 12, focal.ridge[mid + 1][1] + 24);
brush.line(focal.ridge[mid + 3][0] + 6, focal.ridge[mid + 3][1] + 14, focal.ridge[mid + 3][0] + 14, focal.ridge[mid + 3][1] + 30);
// rust: a few capping tiles, nowhere else
brush.set("marker", "#bf4f2c", 1.7);
brush.line(focal.ridge[6][0], focal.ridge[6][1], focal.ridge[9][0], focal.ridge[9][1]);
brush.line(focal.ridge[21][0], focal.ridge[21][1], focal.ridge[23][0], focal.ridge[23][1]);
brush.set("marker", "#bf4f2c", 1.4);
brush.line(roof[4].ridge[6][0], roof[4].ridge[6][1], roof[4].ridge[9][0], roof[4].ridge[9][1]);
brush.line(roof[5].ridge[19][0], roof[5].ridge[19][1], roof[5].ridge[22][0], roof[5].ridge[22][1]);
brush.noStroke();
brush.fillBleed(0.4, "out");
brush.fill("#bf4f2c", 96);
brush.circle(focal.eave[mid + 9][0] - 12, focal.eave[mid + 9][1] - 16, 7, true);
brush.fill("#bf4f2c", 74);
brush.circle(roof[2].eave[7][0] - 8, roof[2].eave[7][1] - 14, 5.5, true);
// one last cold line where the lowest roof runs off the bottom edge
brush.noFill();
brush.set("pen", "#2c363b", 1.2);
brush.line(roof[6].eave[3][0], roof[6].eave[3][1], roof[6].eave[3][0] + 26, roof[6].eave[3][1] + 34);
}