Standing Water
2026 · architecture
Nobody designed this to be looked at, which is exactly why it holds still so well when you do.
a rooftop water tank on splayed legs, staves banded with steel, recorded flat against an empty sky
painted by Claude Opus 5p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: the drum's mass plants itself on the lower-left thirds crossing, legs splayed onto a
// parapet that runs off both edges; the upper two thirds stay pale, empty sky
// layering: paper, faint sky and deck washes, then the drum in translucent passes, the shaded flank
// and the staves, and last the graphite hoops, bracing, ladder and one rust run
// breathing: the whole right half above the parapet, and the top-left corner, stay unpainted
const PALETTE = [
"#e7e4dd", // paper
"#c3c7c7", // sky
"#8d8377", // stave
"#6d716f", // slate
"#34383a", // ink
"#a3573a", // rust
];
const CX = 262; // the tank's axis, sitting on the left third
const HW = 96; // half the drum's width
const TANK_TOP = 372; // where the staves meet the cone
const TANK_BOT = 588; // where the staves meet the underside
const BOW = 9; // how far a horizontal hoop bows up, read from below
const DECK = 706; // the parapet the legs stand on
// a hoop seen from slightly below: the near point of the circle rides highest
function ringX(t) {
return CX + HW * Math.sin(t);
}
function ringY(t, base) {
return base - BOW * Math.cos(t);
}
// how dark the wall is at horizontal fraction f (-1 left edge, +1 right edge).
// light comes from the upper right, so the tone falls away to the left and the
// far right rim turns back into shadow — that turn is what makes it read round.
function drumShade(f) {
return constrain(0.55 - 0.75 * f, 0.06, 1) + 0.45 * Math.max(0, (f - 0.8) / 0.2);
}
// pale air, pooled low where the roofs begin and thinning upward
function skyWash() {
brush.noStroke();
brush.fillBleed(0.32, "out");
brush.fillTexture(0.45, 0.45);
for (let i = 0; i < 12; i++) {
// kept on the paper: a bloom whose whole disc falls outside the canvas has
// no area left to pool in, its fill collapses to a single point, and
// p5.brush's centroid has no polygon to measure
const x = constrain(randomGaussian(300, 210), 40, 764);
const y = constrain(randomGaussian(590, 140), 230, 690);
const r = constrain(randomGaussian(104, 22), 56, 138);
const a = constrain(randomGaussian(42, 11), 16, 66) * (1 - (y - 200) / 1400);
brush.fill(PALETTE[1], a);
brush.circle(x, y, r, true);
}
// softer blooms behind the tank, so the silhouette has something to sit against
brush.fill(PALETTE[1], 62);
brush.circle(300, 408, 124, true);
brush.fill(PALETTE[1], 48);
brush.circle(172, 476, 100, true);
brush.fill(PALETTE[1], 30);
brush.circle(410, 566, 108, true);
// and one long breath of air lying along the far parapet, so the right is not dead paper
brush.fill(PALETTE[1], 42);
brush.circle(524, 636, 122, true);
brush.fill(PALETTE[1], 34);
brush.circle(636, 662, 116, true);
brush.fill(PALETTE[1], 24);
brush.circle(756, 676, 98, true);
}
// the roof plane: pale to the right, closing up under hatching toward the left corner
function deckPlane() {
brush.noStroke();
brush.fillBleed(0.4, "out");
brush.fillTexture(0.5, 0.5);
for (let i = 0; i < 8; i++) {
const x = constrain(randomGaussian(300, 220), 24, 776);
const y = constrain(randomGaussian(752, 32), DECK + 8, 796);
brush.fill(PALETTE[1], constrain(randomGaussian(36, 9), 12, 54));
brush.circle(x, y, constrain(randomGaussian(62, 14), 36, 88), true);
}
// the roof laid in by hand rather than hatched: a hatch angle this close to
// the plane's own long edges runs its lines straight off the paper
brush.noFill();
for (let i = 0; i < 15; i++) {
const y = 736 + i * 4.6 + randomGaussian(0, 1.6);
const x1 = constrain(randomGaussian(-16, 26), -34, 60);
const x2 = constrain(randomGaussian(196, 54), 90, 268);
brush.set(random() < 0.55 ? "cpencil" : "2H", PALETTE[3], constrain(randomGaussian(0.82, 0.2), 0.42, 1.25));
brush.line(x1, y, x2, y - 5 + randomGaussian(0, 2.2));
}
for (let i = 0; i < 7; i++) {
const y = 738 + i * 7.4 + randomGaussian(0, 2);
brush.set("2H", PALETTE[3], constrain(randomGaussian(0.5, 0.12), 0.3, 0.75));
brush.line(constrain(randomGaussian(250, 24), 214, 292), y, constrain(randomGaussian(430, 46), 340, 502), y - 6 + randomGaussian(0, 2));
}
}
// the parapet the legs stand on, thick under the tank and thinning as it leaves the frame
function parapetLine() {
brush.noFill();
// heaviest where the legs stand, then handed off to lighter strokes and let go
brush.set("charcoal", PALETTE[4], 1.9);
brush.beginShape(0.2);
brush.vertex(112, 710);
brush.vertex(238, 707);
brush.vertex(392, 704);
brush.endShape(false);
brush.set("2B", PALETTE[4], 1.3);
brush.beginShape(0.2);
brush.vertex(-24, 715);
brush.vertex(84, 711);
brush.vertex(146, 709);
brush.endShape(false);
brush.set("2B", PALETTE[3], 1.0);
brush.beginShape(0.2);
brush.vertex(378, 704);
brush.vertex(524, 702);
brush.vertex(668, 700);
brush.endShape(false);
brush.set("2H", PALETTE[3], 0.7);
brush.line(660, 700, 824, 698);
brush.set("2H", PALETTE[3], 0.85);
brush.line(-18, 723, 196, 718);
brush.line(244, 717, 430, 713);
// two neighbouring parapets far off to the right, recorded without comment
brush.set("2H", PALETTE[3], 0.8);
brush.line(566, 688, 806, 684);
brush.set("2H", PALETTE[3], 0.6);
brush.line(628, 676, 802, 673);
brush.line(704, 668, 796, 666);
}
// one translucent pass over the drum; called several times, never twice the same
function drumPass(col, alpha, jit, curl) {
brush.noStroke();
brush.fillBleed(0.26, "out");
brush.fillTexture(0.62, 0.55);
brush.fill(col, alpha);
brush.beginShape(curl);
for (let i = 0; i <= 8; i++) {
const t = -1.52 + (3.04 * i) / 8;
brush.vertex(ringX(t) + randomGaussian(0, jit), ringY(t, TANK_TOP) + randomGaussian(0, jit));
}
for (let i = 8; i >= 0; i--) {
const t = -1.52 + (3.04 * i) / 8;
brush.vertex(ringX(t) + randomGaussian(0, jit), ringY(t, TANK_BOT) + randomGaussian(0, jit));
}
brush.endShape(true);
}
// a vertical band of the wall, between two horizontal fractions — the tool the
// modelling is done with: the shaded flank, the lit band, the turning rim
function drumBand(f0, f1, col, alpha, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.58, 0.6);
brush.fill(col, alpha);
// held a little inside the drawn silhouette, so the bleed pools against the
// outline instead of escaping past it
brush.beginShape(0.1);
for (let i = 0; i <= 5; i++) {
const f = f0 + ((f1 - f0) * i) / 5;
const t = Math.asin(f);
brush.vertex(CX + (ringX(t) - CX) * 0.965 + randomGaussian(0, 1.2), ringY(t, TANK_TOP) + 5 + randomGaussian(0, 1.6));
}
for (let i = 5; i >= 0; i--) {
const f = f0 + ((f1 - f0) * i) / 5;
const t = Math.asin(f);
brush.vertex(CX + (ringX(t) - CX) * 0.965 + randomGaussian(0, 1.2), ringY(t, TANK_BOT) - 5 + randomGaussian(0, 1.6));
}
brush.endShape(true);
}
// the underside crescent: the far rim of the base circle drops where the near rim rises
function drumUnder() {
brush.noStroke();
brush.fillBleed(0.22, "out");
brush.fillTexture(0.55, 0.6);
brush.fill(PALETTE[4], 205);
brush.beginShape(0.35);
for (let i = 0; i <= 8; i++) {
const t = -1.5 + (3.0 * i) / 8;
brush.vertex(ringX(t), ringY(t, TANK_BOT) + randomGaussian(0, 1.6));
}
for (let i = 8; i >= 0; i--) {
const t = -1.5 + (3.0 * i) / 8;
brush.vertex(CX + (ringX(t) - CX) * 0.96, TANK_BOT + 19 * Math.cos(t) + randomGaussian(0, 1.4));
}
brush.endShape(true);
brush.fill(PALETTE[4], 170);
brush.circle(CX + 4, TANK_BOT + 12, 30, true);
// the crescent closed up stroke by stroke: the one place the paper goes
// properly dark, and the weight the whole tank hangs from
brush.noFill();
for (let i = 0; i <= 26; i++) {
const t = -1.4 + (2.8 * i) / 26;
const x = ringX(t) * 0.985 + CX * 0.015;
const y0 = ringY(t, TANK_BOT) + 1;
const drop = 20 * Math.cos(t) - 2;
if (drop < 2) continue;
brush.set(random() < 0.5 ? "cpencil" : "2B", PALETTE[4], constrain(randomGaussian(1.15, 0.22), 0.6, 1.7));
brush.line(x + randomGaussian(0, 1.1), y0, x + randomGaussian(0, 1.6), y0 + drop + randomGaussian(0, 1.4));
}
// the far rim, where the underside finally stops
brush.set("2B", PALETTE[4], 1.5);
brush.beginShape(0.35);
for (let i = 0; i <= 5; i++) {
const t = -1.24 + (2.48 * i) / 5;
brush.vertex(ringX(t) * 0.97 + CX * 0.03, TANK_BOT + 20 * Math.cos(t) + randomGaussian(0, 1.2));
}
brush.endShape(false);
// the near rim struck hard, so the shadow has a lip to hang from
brush.set("charcoal", PALETTE[4], 2.3);
brush.beginShape(0.3);
for (let i = 0; i <= 5; i++) {
const t = -1.44 + (2.88 * i) / 5;
brush.vertex(ringX(t) + randomGaussian(0, 1.0), ringY(t, TANK_BOT) + randomGaussian(0, 1.0));
}
brush.endShape(false);
}
// vertical staves, crowding toward the edges the way a cylinder makes them,
// each weighted by how much light reaches it
function staveSet() {
for (let i = 0; i <= 18; i++) {
const t = -1.46 + (2.92 * i) / 18;
const f = Math.sin(t);
const c = Math.cos(t);
const dark = drumShade(f);
const x = ringX(t) + randomGaussian(0, 1.1);
const col = dark > 0.72 ? (random() < 0.55 ? PALETTE[4] : PALETTE[3]) : dark > 0.4 ? PALETTE[3] : PALETTE[2];
brush.noFill();
brush.set(random() < 0.45 ? "cpencil" : "2H", col, constrain(randomGaussian(0.42 + dark * 1.05, 0.14), 0.4, 1.6));
brush.beginShape(0.15);
brush.vertex(x + randomGaussian(0, 1.4), ringY(t, TANK_TOP) + 3 + random(0, 6));
brush.vertex(x + randomGaussian(0, 2.2), (TANK_TOP + TANK_BOT) / 2 - BOW * c);
brush.vertex(x + randomGaussian(0, 1.4), ringY(t, TANK_BOT) - 2 - random(0, 5));
brush.endShape(false);
}
// the shaded left flank closes up under vertical hatching
brush.noStroke();
brush.noFill();
brush.hatchStyle("cpencil", PALETTE[3], 1.15);
brush.hatch(4.6, 90, { rand: 0.26, continuous: false, gradient: 0.4 });
brush.polygon([
[CX - HW + 1, TANK_TOP + 4],
[CX - 34, TANK_TOP - 4],
[CX - 30, TANK_BOT - 8],
[CX - HW + 2, TANK_BOT - 2],
]);
brush.noHatch();
brush.hatchStyle("cpencil", PALETTE[4], 0.95);
brush.hatch(3.4, 90, { rand: 0.2, continuous: false, gradient: 0.5 });
brush.polygon([
[CX - HW, TANK_TOP + 6],
[CX - 66, TANK_TOP + 1],
[CX - 62, TANK_BOT - 6],
[CX - HW + 1, TANK_BOT - 3],
]);
brush.noHatch();
// and the right rim, where the wall turns away again
brush.hatchStyle("2H", PALETTE[3], 0.8);
brush.hatch(4.4, 90, { rand: 0.2, continuous: false, gradient: 0.5 });
brush.polygon([
[CX + 74, TANK_TOP + 2],
[CX + HW, TANK_TOP + 6],
[CX + HW - 1, TANK_BOT - 3],
[CX + 72, TANK_BOT - 7],
]);
brush.noHatch();
}
// a steel hoop: one graphite arc, a lighter one shadowing it, an occasional bright edge
function hoopBand(base, weight, dark) {
brush.noFill();
brush.set(dark ? "charcoal" : "2B", PALETTE[4], weight);
brush.beginShape(0.4);
for (let i = 0; i <= 6; i++) {
const t = -1.53 + (3.06 * i) / 6;
brush.vertex(ringX(t) + randomGaussian(0, 1.0), ringY(t, base) + randomGaussian(0, 1.1));
}
brush.endShape(false);
brush.set("2H", PALETTE[3], weight * 0.5);
brush.beginShape(0.4);
for (let i = 0; i <= 5; i++) {
const t = -1.4 + (2.8 * i) / 5;
brush.vertex(ringX(t) + random(-1, 1), ringY(t, base + 3.6) + random(-1, 1));
}
brush.endShape(false);
}
// the shallow cone, plus the vent stub that gives the silhouette its one point
function capCone() {
const ax = CX + 22;
const ay = TANK_TOP - 44;
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fillTexture(0.6, 0.5);
brush.fill(PALETTE[3], 122);
brush.beginShape(0.12);
brush.vertex(CX - HW - 9, TANK_TOP + 3);
brush.vertex(CX - 56, ay + 18);
brush.vertex(ax, ay);
brush.vertex(CX + 60, ay + 16);
brush.vertex(CX + HW + 8, TANK_TOP + 1);
brush.vertex(CX + 30, TANK_TOP - BOW - 1);
brush.vertex(CX - 36, TANK_TOP - BOW + 2);
brush.endShape(true);
brush.fill(PALETTE[4], 104);
brush.beginShape(0.14);
brush.vertex(CX - HW - 7, TANK_TOP + 2);
brush.vertex(CX - 54, ay + 20);
brush.vertex(ax - 10, ay + 3);
brush.vertex(CX - 30, TANK_TOP - BOW + 3);
brush.endShape(true);
brush.noFill();
brush.set("charcoal", PALETTE[4], 2.1);
brush.beginShape(0.2);
brush.vertex(CX - HW - 10, TANK_TOP + 4);
brush.vertex(CX - 54, ay + 17);
brush.vertex(ax, ay - 1);
brush.endShape(false);
brush.set("charcoal", PALETTE[4], 1.6);
brush.beginShape(0.2);
brush.vertex(ax + 1, ay);
brush.vertex(CX + 62, ay + 15);
brush.vertex(CX + HW + 9, TANK_TOP + 2);
brush.endShape(false);
// the eave, where the cone oversails the wall
brush.set("2B", PALETTE[4], 1.5);
brush.beginShape(0.3);
brush.vertex(CX - HW - 8, TANK_TOP + 3);
brush.vertex(CX - 40, TANK_TOP - BOW + 2);
brush.vertex(CX + 34, TANK_TOP - BOW);
brush.vertex(CX + HW + 7, TANK_TOP + 2);
brush.endShape(false);
// vent stub
brush.set("2B", PALETTE[4], 2.2);
brush.line(ax - 3, ay + 1, ax - 4, ay - 26);
brush.line(ax + 5, ay - 1, ax + 6, ay - 21);
brush.line(ax - 8, ay - 27, ax + 11, ay - 29);
}
// one splayed leg: a tapered post with the light edge left open
function legPost(xt, yt, xb, w, col, alpha) {
brush.noStroke();
brush.fillBleed(0.18, "out");
brush.fillTexture(0.6, 0.6);
brush.fill(col, alpha);
brush.beginShape(0.05);
brush.vertex(xt - w, yt);
brush.vertex(xt + w, yt + random(-2, 2));
brush.vertex(xb + w * 0.72, DECK - 1);
brush.vertex(xb - w * 0.8, DECK + 1);
brush.endShape(true);
brush.noFill();
brush.set("2B", PALETTE[4], constrain(randomGaussian(1.3, 0.2), 0.8, 1.9));
brush.line(xt - w + 0.5, yt + 2, xb - w * 0.8, DECK);
brush.set("2H", PALETTE[3], 0.75);
brush.line(xt + w - 1, yt + 4, xb + w * 0.66, DECK - 2);
}
// cross-bracing between the front legs, hand-placed so no two members repeat
function braceSet() {
brush.noFill();
brush.set("2B", PALETTE[4], 1.15);
brush.line(188, 618, 344, 658);
brush.set("2B", PALETTE[4], 1.0);
brush.line(342, 616, 184, 661);
brush.set("2H", PALETTE[4], 0.95);
brush.line(177, 667, 350, 663);
brush.set("2B", PALETTE[4], 1.05);
brush.line(172, 676, 358, 700);
brush.set("2B", PALETTE[3], 0.9);
brush.line(356, 674, 168, 701);
brush.set("2H", PALETTE[3], 0.8);
brush.line(210, 622, 214, 692);
brush.line(320, 620, 316, 694);
}
// the service ladder, rungs stepping unevenly the way a welder leaves them
function ladderRun() {
brush.noFill();
brush.set("2B", PALETTE[4], 1.05);
brush.beginShape(0.1);
brush.vertex(372, DECK - 2);
brush.vertex(368, 546);
brush.vertex(366, TANK_TOP + 4);
brush.endShape(false);
brush.set("2H", PALETTE[4], 0.85);
brush.beginShape(0.1);
brush.vertex(390, DECK - 3);
brush.vertex(386, 544);
brush.vertex(383, TANK_TOP + 6);
brush.endShape(false);
for (let i = 0; i < 13; i++) {
const y = DECK - 10 - i * 25 + randomGaussian(0, 2.8);
const t = (DECK - y) / (DECK - TANK_TOP);
brush.set(random() < 0.3 ? "2H" : "2B", PALETTE[4], constrain(randomGaussian(0.8, 0.16), 0.5, 1.2));
brush.line(372 - 4 * t + random(-1, 1), y, 390 - 6 * t + random(-1, 1), y - 1 + random(-1, 1));
}
}
// the downcomer, and the wire that leaves for the next roof
function pipeRun() {
brush.noFill();
brush.set("2B", PALETTE[4], 1.7);
brush.beginShape(0.2);
brush.vertex(274, TANK_BOT + 16);
brush.vertex(280, 652);
brush.vertex(278, DECK + 2);
brush.endShape(false);
brush.set("2H", PALETTE[3], 0.7);
brush.line(286, TANK_BOT + 20, 290, 700);
brush.set("rotring", PALETTE[3], 0.55);
brush.beginShape(0.5);
brush.vertex(CX + 10, TANK_TOP - 72);
brush.vertex(448, 358);
brush.vertex(626, 344);
brush.vertex(824, 318);
brush.endShape(false);
}
// rust: the one warm note, rationed to two runs and a stain
function rustNotes() {
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.45, 0.4);
brush.fill(PALETTE[5], 142);
brush.circle(318, 540, 8.5, true);
brush.fill(PALETTE[5], 104);
brush.circle(322, 566, 6, true);
brush.fill(PALETTE[5], 96);
brush.circle(204, 494, 7, true);
brush.noFill();
brush.set("2B", PALETTE[5], 1.1);
brush.line(318, 530, 323, 584);
brush.set("2H", PALETTE[5], 0.8);
brush.line(324, 548, 327, 578);
brush.set("2H", PALETTE[5], 0.95);
brush.line(206, 486, 202, 528);
brush.set("2H", PALETTE[5], 0.7);
brush.line(348, 428, 352, 452);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES); // hatch angles below are read in degrees
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PALETTE[0]);
skyWash();
deckPlane();
parapetLine();
// the drum, built up in translucent passes rather than one flat wall
drumPass(PALETTE[2], 92, 2.6, 0.12);
drumPass(PALETTE[1], 70, 1.9, 0.09);
drumPass(PALETTE[3], 52, 1.3, 0.06);
drumPass(PALETTE[3], 34, 3.0, 0.14);
// modelling: the flank in shadow, the lit band left almost bare, the far rim turning back
drumBand(-1.0, -0.3, PALETTE[3], 150, 0.12);
drumBand(-1.0, -0.7, PALETTE[4], 128, 0.1);
drumBand(-0.62, 0.04, PALETTE[3], 58, 0.2); // feathers the shadow's inboard edge
drumBand(0.87, 1.0, PALETTE[3], 96, 0.1);
drumBand(0.14, 0.74, PALETTE[1], 40, 0.3);
// back legs first, then the front pair, so the structure has depth
legPost(220, TANK_BOT - 6, 186, 5.0, PALETTE[3], 96);
legPost(306, TANK_BOT - 7, 342, 4.6, PALETTE[3], 88);
legPost(192, TANK_BOT - 2, 146, 6.4, PALETTE[3], 138);
legPost(332, TANK_BOT - 3, 380, 6.0, PALETTE[3], 126);
// the underside comes after the legs: the tank's own shadow falls across them
drumUnder();
staveSet();
capCone();
// the hoops land last on the wall, the one on the thirds crossing heaviest
hoopBand(TANK_TOP + 20, 1.5, false);
hoopBand(428, 1.25, false);
hoopBand(480, 1.1, false);
hoopBand(534, 2.4, true);
hoopBand(TANK_BOT - 10, 2.1, true);
// the drum's silhouette, drawn over everything so the edge stays hard
brush.noFill();
brush.set("charcoal", PALETTE[4], 2.0);
brush.beginShape(0.08);
brush.vertex(CX - HW - 1, TANK_TOP + 6);
brush.vertex(CX - HW - 2, 486);
brush.vertex(CX - HW + 1, TANK_BOT - 4);
brush.endShape(false);
brush.set("charcoal", PALETTE[4], 1.5);
brush.beginShape(0.08);
brush.vertex(CX + HW + 1, TANK_TOP + 4);
brush.vertex(CX + HW + 2, 492);
brush.vertex(CX + HW - 1, TANK_BOT - 5);
brush.endShape(false);
braceSet();
ladderRun();
pipeRun();
rustNotes();
// a last dark under the near feet, so the thing is standing on something
brush.noStroke();
brush.fillBleed(0.4, "out");
brush.fill(PALETTE[4], 132);
brush.circle(147, DECK + 4, 8, true);
brush.fill(PALETTE[4], 120);
brush.circle(380, DECK + 3, 7, true);
brush.fill(PALETTE[4], 76);
brush.circle(186, DECK + 2, 5.5, true);
brush.fill(PALETTE[4], 68);
brush.circle(343, DECK + 2, 5, true);
}