Five Turns Up the Well
2026 · architecture
From underneath, a stair is nothing but soffit and rail — five turns of it, each smaller than the last, closing on the one square of sky that made the shaft worth building.
a rectangular stairwell from directly below, flights turning away to one small bright opening
painted by Kimi K3p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: the bottom of a rectangular stairwell, looking straight up the well. One plan
// rectangle is drawn storey after storey, each a similarity of the last about the shaft axis, so
// the turns nest: four of them winding in on a small bright opening. The axis sits in the upper
// right of the near well, on the thirds crossing, which throws every larger storey down and left —
// that is where the dark collects, and the two sides of each turn facing the light are left as
// bare paper, stated by line alone. Each flight is a raked plane between two flat landings: its
// edges are staircases of tread and riser, its underside is laddered with nosings that catch the
// light, and a balustrade of vertical posts under one smooth rail climbs the well beside it.
// layering: haze and lamp washes, the flights from the farthest storey down to the nearest so the
// near ones bury the far, the corner darks, and the opening struck last.
const PALETTE = [
"#e9e3d5", // paper — the opening, and the lit sides left unworked
"#8d9299", // haze — air in the shaft, and the light caught on every nosing
"#4e545a", // graphite — the working mass of the soffits
"#191c1f", // ink — near flights, well edges, silhouettes
"#c98b32", // lamp — accent, only in the opening and its first spill
];
const SW_VP = [533, 267]; // the shaft axis: the upper-right thirds crossing
const SW_AX = 520, SW_AY = 455; // plan half-extents to the shaft wall — not a square shaft
const SW_WELLF = 0.6; // the open well, as a fraction of the wall rectangle
const SW_S = 0.575; // one storey recedes by this much
const SW_ROT = 14; // the plan is not square to the sheet
const SW_RAIL = 0.115; // handrail height, in storeys
const SW_DRY = 0.4; // below this a flight is left to line alone, and the paper shows through
const SW_LAND = 0.11; // the flat landing at each end of a flight, as a share of the run
const SW_TOP = 18, SW_APU = 4.75; // the last flight drawn, and where the shaft opens to the sky
const SW_CR = Math.cos((SW_ROT * Math.PI) / 180);
const SW_SR = Math.sin((SW_ROT * Math.PI) / 180);
// the axis must fall inside the near well, pushed toward its upper-right corner
const SW_VL = [0.6 * SW_AX * SW_WELLF, -0.6 * SW_AY * SW_WELLF];
const SW_D = [-(SW_VL[0] * SW_CR - SW_VL[1] * SW_SR), -(SW_VL[0] * SW_SR + SW_VL[1] * SW_CR)];
// no two storeys were built quite square over one another
const SW_WOB = [[16, -11], [-13, 18], [9, 14], [-18, -9], [20, 7], [-7, -20], [11, -16]];
// how dark each of the four sides is: the two facing down-left take the whole weight,
// and how the storeys fade going up, until the last closes dark around the light
const SW_SIDE = [0.98, 0.85, 0.19, 0.34];
const SW_TURN = [1.0, 0.88, 0.76, 0.64, 0.95];
function swWob(u, i) {
const j = Math.floor(u);
const a = SW_WOB[((j % 7) + 7) % 7][i];
return a + (SW_WOB[(((j + 1) % 7) + 7) % 7][i] - a) * (u - j);
}
// rad 0 is the well edge, rad 1 the shaft wall
function swHalf(rad) {
const g = SW_WELLF + (1 - SW_WELLF) * rad;
return [SW_AX * g, SW_AY * g];
}
function swCornerLocal(k, h) {
const q = ((k % 4) + 4) % 4;
return [(q === 0 || q === 3 ? 1 : -1) * h[0], (q === 0 || q === 1 ? 1 : -1) * h[1]];
}
// a flight is flat over its landings and rakes evenly between them
function swU(m, s) {
return (m + constrain((s - SW_LAND) / (1 - 2 * SW_LAND), 0, 1)) / 4;
}
// flight m runs along side m mod 4; s walks it, rad crosses it, u is the height in storeys
function swPt(m, s, rad, u) {
const h = swHalf(rad);
const a = swCornerLocal(m, h), b = swCornerLocal(m + 1, h);
const px = a[0] + (b[0] - a[0]) * s, py = a[1] + (b[1] - a[1]) * s;
const rx = px * SW_CR - py * SW_SR + swWob(u, 0);
const ry = px * SW_SR + py * SW_CR + swWob(u, 1);
const f = Math.pow(SW_S, u);
return [SW_VP[0] + f * (SW_D[0] + rx), SW_VP[1] + f * (SW_D[1] + ry)];
}
// one edge of a flight walked as treads and risers: out along the tread at one level, then up
// the riser. ex carries the nosing's overhang, which is what makes the serration read at all.
function swStepEdge(m, rad, n, ex) {
const pts = [];
const e = ex === undefined ? 0.9 : ex;
for (let i = 0; i < n; i++) {
const s0 = i / n, s1 = (i + 1) / n;
const u0 = swU(m, s0);
pts.push(swPt(m, s0, rad, u0));
pts.push(swPt(m, s1, rad, u0 - (swU(m, s1) - u0) * e));
}
pts.push(swPt(m, 1, rad, swU(m, 1)));
return pts;
}
function swTreads(m) {
const a = swPt(m, 0, 0.5, swU(m, 0));
const b = swPt(m, 1, 0.5, swU(m, 1));
return constrain(Math.round(Math.hypot(b[0] - a[0], b[1] - a[1]) / 38), 3, 12);
}
// the direction a nosing lies in, taken across the middle of the flight
function swCrossAngle(m, rIn, rOut) {
const u = swU(m, 0.5);
const a = swPt(m, 0.5, rIn, u);
const b = swPt(m, 0.5, rOut, u);
return (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI;
}
function swBand(inner, outer, jit) {
const poly = [];
for (let i = 0; i < inner.length; i++) {
poly.push([inner[i][0] + randomGaussian(0, jit), inner[i][1] + randomGaussian(0, jit)]);
}
for (let i = outer.length - 1; i >= 0; i--) {
poly.push([outer[i][0] + randomGaussian(0, jit * 1.2), outer[i][1] + randomGaussian(0, jit * 1.2)]);
}
return poly;
}
function swInk(d) {
return d > 0.68 ? PALETTE[3] : d > 0.38 ? PALETTE[2] : PALETTE[1];
}
function swPencil(d) {
return d > 0.72 ? "charcoal" : d > 0.44 ? "2B" : d > 0.24 ? "HB" : "2H";
}
// the ladder of nosings: on a worked soffit each one is a dark seam with the light from the
// opening riding along its edge — the single mark that says stair rather than plane
function swNosings(m, d, rIn, rOut, n) {
brush.noFill();
const dry = d < SW_DRY;
for (let i = 1; i < n; i++) {
const s = i / n + randomGaussian(0, 0.006);
const u = swU(m, s);
const a = swPt(m, s, rIn, u);
const b = swPt(m, s, dry ? Math.min(rOut, rIn + 0.3) : Math.min(rOut, 0.94), u);
const e0 = constrain(randomGaussian(0.03, 0.04), -0.05, 0.2);
const e1 = constrain(randomGaussian(0.9, 0.09), 0.5, 1.04);
const x0 = lerp(a[0], b[0], e0), y0 = lerp(a[1], b[1], e0);
const x1 = lerp(a[0], b[0], e1), y1 = lerp(a[1], b[1], e1);
const ru = swPt(m, s, rIn, u + 0.03);
const ox = (ru[0] - a[0]) * 0.5, oy = (ru[1] - a[1]) * 0.5;
if (d > 0.5) {
brush.set(random() < 0.5 ? "2B" : "charcoal", PALETTE[3], max(0.5, 0.6 + 0.8 * d)); brush.line(x0 - ox, y0 - oy, x1 - ox, y1 - oy);
brush.set(random() < 0.55 ? "2H" : "HB", random() < 0.22 ? PALETTE[0] : PALETTE[1],
max(0.42, 0.68 + 0.4 * d + randomGaussian(0, 0.1)));
brush.line(x0 + ox * 0.6, y0 + oy * 0.6, x1 + ox * 0.6, y1 + oy * 0.6);
} else if (!dry) {
brush.set(random() < 0.5 ? "HB" : "2B", swInk(d * 0.95), max(0.4, 0.5 + 0.8 * d)); brush.line(x0, y0, x1, y1);
} else {
// on the lit sides the ladder is only started, a tick at the well edge and no more
if (random() < 0.42) continue;
brush.set(random() < 0.65 ? "2H" : "HB", random() < 0.3 ? PALETTE[2] : PALETTE[1],
max(0.3, randomGaussian(0.52, 0.11)));
const g = constrain(randomGaussian(0.4, 0.13), 0.16, 0.7);
brush.line(x0 + randomGaussian(0, 1.4), y0 + randomGaussian(0, 1.4),
lerp(x0, x1, g), lerp(y0, y1, g));
}
}
}
// the underside of one flight: tone laid in, then hatched along the treads so the density
// itself steps, then the ladder, then the two edges. A lit flight gets line only.
function swFlight(m, d, o) {
const rIn = o.rIn === undefined ? 0 : o.rIn;
const rOut = o.rOut === undefined ? 1 : o.rOut;
const n = o.n, dry = d < SW_DRY;
const inner = swStepEdge(m, rIn, n, rIn < 0.02 ? 0.95 : 0.5);
const outer = swStepEdge(m, rOut, n, 0.34);
const cross = swCrossAngle(m, rIn, rOut);
if (!dry) {
const poly = swBand(inner, outer, o.jit === undefined ? 1.2 : o.jit);
const gap = 2.0 + 11 * Math.pow(1 - d, 1.7);
brush.noStroke();
brush.noHatch();
brush.fillBleed(0.16 + 0.16 * d, "out"); brush.fillTexture(0.46 + 0.12 * d, 0.22 + 0.16 * d);
brush.fill(d > 0.55 ? PALETTE[2] : PALETTE[1], 24 + 96 * d + random(-10, 16));
brush.polygon(poly);
if (d > 0.7) {
brush.fillBleed(0.28, "in"); brush.fillTexture(0.56, 0.34);
brush.fill(PALETTE[3], 30 + 56 * d + random(-10, 14));
brush.polygon(poly);
}
brush.noFill();
brush.hatchStyle(swPencil(d), swInk(d), max(0.42, 0.5 + 1.1 * d + randomGaussian(0, 0.07)));
brush.hatch(gap, cross + randomGaussian(0, 3.5), { rand: 0.16, continuous: false });
brush.polygon(poly);
if (d > 0.56) {
brush.hatchStyle(swPencil(d * 0.86), swInk(d * 0.94), max(0.4, 0.46 + 0.9 * d));
brush.hatch(gap * 1.8, cross + 64 + randomGaussian(0, 7), { rand: 0.22, continuous: false });
brush.polygon(poly);
}
if (d > 0.8) {
brush.hatchStyle("charcoal", PALETTE[3], 1.2);
brush.hatch(gap * 0.9, cross - 52 + randomGaussian(0, 7), { rand: 0.26, continuous: false });
brush.polygon(poly);
}
// the soffit is lit off the well and starved toward the wall, so its outer half is taken
// down again — that gradient is what gives each flight a surface to lie on
if (d > 0.46) {
const mid = swStepEdge(m, rIn + (rOut - rIn) * (0.42 + random(0, 0.1)), n, 0.3);
const deep = swBand(mid, outer, 1.5);
brush.hatchStyle(d > 0.7 ? "charcoal" : "2B", PALETTE[3], max(0.46, 0.62 + 0.6 * d));
brush.hatch(gap * 1.2, cross + 28 + randomGaussian(0, 6), { rand: 0.26, continuous: false });
brush.polygon(deep);
}
brush.noHatch();
} else if (rIn < 0.02) {
// a lit flight is left as paper; only a narrow shadow gathers under its well edge
const strip = swBand(inner, swStepEdge(m, rIn + 0.2, n, 0.3), 1.4);
brush.noFill();
brush.hatchStyle(random() < 0.5 ? "2H" : "HB", random() < 0.4 ? PALETTE[2] : PALETTE[1],
max(0.3, randomGaussian(0.52, 0.1)));
brush.hatch(6.5 + 8 * (SW_DRY - d), cross + randomGaussian(0, 5), { rand: 0.3, continuous: false });
brush.polygon(strip);
brush.noHatch();
}
swNosings(m, d, rIn, rOut, n);
// the outer arris, where the soffit dies into the shaft wall — broken on a lit flight
brush.noFill();
brush.set(d > 0.6 ? "2B" : "2H", swInk(d * 0.8), max(0.32, 0.38 + 0.5 * d));
const a0 = dry ? Math.floor(outer.length * 0.42) : 0;
const a1 = dry ? Math.floor(outer.length * 0.78) : outer.length;
if (a1 - a0 > 2 && !(dry && o.wide)) {
brush.beginShape(0.02);
for (let i = a0; i < a1; i++) {
brush.vertex(outer[i][0] + randomGaussian(0, 1.6), outer[i][1] + randomGaussian(0, 1.6));
}
brush.endShape(false);
}
// and the stepped edge along the well — the crispest line on any flight
if (rIn < 0.02) {
brush.noFill();
brush.set(d > 0.5 ? "2B" : "HB", d > 0.3 ? PALETTE[3] : PALETTE[2], max(0.52, 0.72 + 0.85 * d));
brush.beginShape(0.0);
for (let i = 0; i < inner.length; i++) {
brush.vertex(inner[i][0] + randomGaussian(0, 0.9), inner[i][1] + randomGaussian(0, 0.9));
}
brush.endShape(false);
}
}
// the balustrade: a band standing on the well edge, filled with vertical posts that all point
// at the axis because they are vertical, and closed by one smooth rail over their heads
function swBalustrade(m, d, n) {
const rail = [];
for (let i = 0; i <= n; i++) rail.push(swPt(m, i / n, -0.05, swU(m, i / n) + SW_RAIL));
if (rail.length < 3) return;
const steps = swStepEdge(m, 0, n, 0.95);
const poly = [];
for (let i = 0; i < steps.length; i++) poly.push(steps[i]);
for (let i = rail.length - 1; i >= 0; i--) poly.push(rail[i]);
// the band catches light off the well, so it stays paler than the soffit behind it — but
// never white: it takes a grey of its own so the spiral reads as tone, not ribbon
if (d >= SW_DRY) {
brush.noStroke();
brush.noHatch();
brush.fillBleed(0.2, "out"); brush.fillTexture(0.42, 0.24);
brush.fill(PALETTE[1], 40 + 88 * d + random(-8, 12));
brush.polygon(poly);
const ra = swCrossAngle(m, 0, 1);
brush.noFill();
brush.hatchStyle(d > 0.66 ? "HB" : "2H", PALETTE[2], max(0.36, 0.46 + 0.44 * d));
brush.hatch(3.0 + 4 * (1 - d), ra + randomGaussian(0, 4), { rand: 0.22, continuous: false });
brush.polygon(poly);
brush.noHatch();
}
brush.noFill();
const np = d >= SW_DRY ? n * 2 : n; // the posts, two to a tread where the drawing is worked
for (let i = 0; i < np; i++) {
const s = (i + 0.5) / np;
const foot = swPt(m, s, 0, swU(m, s));
const head = swPt(m, s, -0.05, swU(m, s) + SW_RAIL);
brush.set(random() < 0.35 ? "2B" : "HB", d > 0.42 ? PALETTE[3] : PALETTE[2],
max(0.3, 0.4 + 0.42 * d + randomGaussian(0, 0.09)));
brush.line(foot[0] + randomGaussian(0, 0.8), foot[1] + randomGaussian(0, 0.8),
lerp(foot[0], head[0], randomGaussian(0.95, 0.05)) + randomGaussian(0, 0.8),
lerp(foot[1], head[1], randomGaussian(0.95, 0.05)) + randomGaussian(0, 0.8));
}
// the rail, smooth where the stair under it is stepped — and on a lit flight only as much
// of its length as the eye needed
const r0 = d >= SW_DRY ? 0 : Math.floor(rail.length * 0.22);
const r1 = d >= SW_DRY ? rail.length : max(r0 + 3, Math.floor(rail.length * 0.8));
if (r1 - r0 >= 3) {
brush.noFill();
brush.set(d > 0.45 ? "2B" : "HB", d > 0.3 ? PALETTE[3] : PALETTE[2], max(0.44, 0.62 + 0.7 * d));
brush.beginShape(0.06);
for (let i = r0; i < r1; i++) {
brush.vertex(rail[i][0] + randomGaussian(0, 1.1), rail[i][1] + randomGaussian(0, 1.1));
}
brush.endShape(false);
}
}
function swWash(x, y, r, col, a, bleed) {
brush.noStroke();
brush.noHatch();
brush.fillBleed(bleed, "out"); brush.fillTexture(0.38, 0.3);
brush.fill(col, a); brush.circle(x + randomGaussian(0, 5), y + randomGaussian(0, 5), r, true);
}
// the opening: warm air behind it, clean paper in it, and the last flight's stepped edge
// struck black across its near lip so the light is plainly behind the stair
function swAperture() {
const q = [];
for (let k = 0; k < 4; k++) {
const p = swPt(k, 0, 0, SW_APU);
q.push([p[0] + randomGaussian(0, 1.4), p[1] + randomGaussian(0, 1.4)]);
}
brush.noStroke();
brush.noHatch();
brush.fillBleed(0.5, "out"); brush.fillTexture(0.34, 0.2);
brush.fill(PALETTE[4], 30); brush.circle(SW_VP[0] - 4, SW_VP[1] + 10, 60, true);
brush.fill(PALETTE[4], 18); brush.circle(SW_VP[0] + 26, SW_VP[1] - 16, 38, true);
brush.fillBleed(0.28, "out"); brush.fillTexture(0.6, 0.42);
brush.fill(PALETTE[3], 170); brush.circle(q[0][0] + 8, q[0][1] + 12, 16, true);
brush.fill(PALETTE[3], 130); brush.circle(q[1][0] - 11, q[1][1] + 12, 12, true);
brush.fillBleed(0.07, "in"); brush.fillTexture(0.2, 0.1);
brush.fill(PALETTE[0], 252);
brush.polygon(q); brush.polygon(q); // struck twice, so the sky is the cleanest paper on the sheet
brush.fillBleed(0.4, "out");
brush.fill(PALETTE[4], 24); brush.circle(SW_VP[0] - 14, SW_VP[1] + 14, 16, true);
brush.noFill();
brush.set("pen", PALETTE[3], 1.1);
brush.beginShape(0.02);
for (let i = 0; i < q.length; i++) brush.vertex(q[i][0], q[i][1]);
brush.endShape(true);
brush.set("charcoal", PALETTE[3], 1.7); brush.line(q[0][0] + 7, q[0][1] + 10, q[1][0] - 6, q[1][1] + 8);
brush.set("rotring", PALETTE[3], 0.7); brush.line(q[0][0] + 15, q[0][1] + 17, q[1][0] - 10, q[1][1] + 14);
brush.set("2H", PALETTE[4], 0.5); brush.line(q[2][0] - 2, q[2][1] - 3, lerp(q[2][0], q[3][0], 0.62), lerp(q[2][1], q[3][1], 0.62));
brush.set("2H", PALETTE[1], 0.6); brush.line(q[1][0] - 13, q[1][1] + 11, q[1][0] - 2, q[1][1] + 3);
// the last three treads, in silhouette on the near lip
brush.noFill();
for (let i = 0; i < 3; i++) {
const t = 0.2 + i * 0.26, r = 5.5 + i * 1.4;
const bx = lerp(q[0][0], q[1][0], t), by = lerp(q[0][1], q[1][1], t);
brush.set(i === 0 ? "charcoal" : "2B", PALETTE[3], max(0.6, randomGaussian(1.0, 0.16))); brush.line(bx + randomGaussian(0, 0.7), by + r, bx + r * 0.9, by + r * 0.35);
}
// and the warmth the opening throws down the shaft, the only colour in the drawing
brush.set("spray", PALETTE[4], 4.4); brush.line(q[0][0] + 2, q[0][1] + 16, q[1][0] - 5, q[1][1] + 14);
brush.set("spray", PALETTE[4], 6.5); brush.line(q[1][0] - 22, q[1][1] + 34, q[0][0] + 16, q[0][1] + 40);
brush.set("spray", PALETTE[4], 3.0); brush.line(q[3][0] + 12, q[3][1] + 5, q[3][0] + 34, q[3][1] + 16);
brush.set("2H", PALETTE[4], 0.6); brush.line(q[1][0] - 30, q[1][1] + 50, q[1][0] - 8, q[1][1] + 44);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(1.9);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PALETTE[0]);
// 1 — the air in the shaft: heavy and low on the left, thinning as it climbs to the light
swWash(118, 706, 300, PALETTE[1], 30, 0.62);
swWash(298, 566, 244, PALETTE[1], 20, 0.6);
swWash(38, 812, 210, PALETTE[2], 15, 0.68);
swWash(226, 352, 166, PALETTE[1], 10, 0.58);
swWash(566, 240, 152, PALETTE[4], 13, 0.6);
swWash(628, 178, 96, PALETTE[4], 8, 0.55);
// 2 — the flights, laid from the farthest storey down to the nearest so that each turn
// buries the outer reach of the one above it, as it would from below
for (let m = SW_TOP; m >= 0; m--) {
const j = Math.floor(m / 4);
let d = constrain(0.09 + 0.86 * SW_SIDE[m % 4] * SW_TURN[Math.min(j, 4)], 0.07, 0.97);
if (j >= 4) d = max(d, 0.72);
const n = swTreads(m);
const o = { n: n };
if (j === 0) {
// the storey overhead runs off the sheet; only its inner reach is worked
o.rOut = 0.62;
o.jit = 1.8;
o.wide = true;
}
swFlight(m, d, o);
if (j < 4) swBalustrade(m, d, n);
// the outer reach of the near dark flights, brushed loose and left to fall off the edge
if (j === 0 && d > 0.4) swFlight(m, d * 0.8, { n: max(3, n - 3), rIn: 0.6, rOut: 1.0, jit: 2.6 });
}
// 3 — last darks: the corner the light never reaches, drawn rather than pooled, because
// pigment this deep will only go down as strokes
brush.noFill();
for (let i = 0; i < 26; i++) {
const t = i / 25;
const x = lerp(-40, 214, t) + randomGaussian(0, 14), y = lerp(704, 838, t) + randomGaussian(0, 26);
brush.set(random() < 0.5 ? "charcoal" : "2B", random() < 0.8 ? PALETTE[3] : PALETTE[2],
max(0.8, randomGaussian(2.1, 0.5)));
const a = 40 + randomGaussian(0, 18);
const r = constrain(randomGaussian(48, 16), 16, 92);
brush.line(x, y, x + Math.cos((a * Math.PI) / 180) * r, y + Math.sin((a * Math.PI) / 180) * r);
}
brush.hatchStyle("charcoal", PALETTE[3], 1.4);
brush.hatch(2.6, 34 + randomGaussian(0, 5), { rand: 0.28, continuous: false });
brush.polygon([[-60, 640], [96, 742], [188, 860], [-60, 860]]);
brush.hatchStyle("2B", PALETTE[3], 1.0);
brush.hatch(4.0, -38 + randomGaussian(0, 6), { rand: 0.3, continuous: false });
brush.polygon([[-60, 668], [118, 782], [206, 860], [-60, 860]]);
brush.noHatch();
brush.noFill();
brush.set("spray", PALETTE[3], 4.4); brush.line(-28, 762, 168, 828);
brush.set("spray", PALETTE[2], 2.8); brush.line(74, 640, 218, 808);
// 4 — the opening, struck last so nothing crosses it
swAperture();
}