Tide Lines in a Dish
2026 · abstract
Water keeps no record of itself, only of where it stopped; I painted the stopping and let the mistakes dry in.
evaporation rings crowding the low corner of a shallow dish, each dried edge a different mineral
painted by Claude Fable 5p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: four families of nested tide lines, each family a puddle that shrank and drifted; they crowd and overlap in the lower left, thinning to bare plaster upper right
// layering: stained dish floor and sediment first, mineral shallows and hatched crust second, tide lines from widest to last-and-darkest, then manganese rims, scratches, grit and one iron-oxide edge
// the open paper runs as a wide diagonal band across the upper right — that side of the dish dried first and was never wet again
const PALETTE = [
"#e6dfcd", // ground — dry plaster
"#a89263", // mass — ochre tide line
"#68735c", // mineral — copper green
"#7a6247", // sediment — umber
"#33302a", // shadow — manganese
"#c05a2b", // accent — iron oxide, rationed
];
const GROUND = PALETTE[0];
const OCHRE = PALETTE[1];
const COPPER = PALETTE[2];
const UMBER = PALETTE[3];
const MANGANESE = PALETTE[4];
const OXIDE = PALETTE[5];
function blendMix(a, b, t) {
return lerpColor(color(a), color(b), constrain(t, 0, 1));
}
// every tide line is one of three minerals, darkened as the puddle concentrates,
// so no two rings carry quite the same pigment
function mineral(dark, greenBias) {
const r = random();
const base = r < greenBias ? COPPER : r < greenBias + 0.44 ? OCHRE : UMBER;
return blendMix(base, MANGANESE, randomGaussian(dark, 0.12));
}
// the dish never dried in a true circle: noise pushes each radius in and out
function ringRadius(cx, cy, r, a, wob) {
const n = noise(cx * 0.0031 + Math.cos(a) * 0.85, cy * 0.0031 + Math.sin(a) * 0.85);
return r * (1 + (n - 0.5) * wob);
}
function tideArc(cx, cy, r, a0, span, col, w, name, wob) {
const steps = constrain(floor(abs(span) / 6), 8, 90);
brush.noFill();
brush.set(name, col, w);
brush.beginShape(0.5);
for (let i = 0; i <= steps; i++) {
const a = ((a0 + (span * i) / steps) * Math.PI) / 180;
const rr = ringRadius(cx, cy, r, a, wob);
brush.vertex(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr);
}
brush.endShape(false);
}
// a tide line is a crust, not a contour: several offset passes, each a slightly
// different pigment, so the edge thickens and thins along its length
function tideLine(cx, cy, r, a0, span, weight, dark, greenBias, passes) {
for (let p = 0; p < passes; p++) {
const rr = r + randomGaussian(0, r * 0.012) + p * randomGaussian(1.7, 2.2);
const s0 = a0 + randomGaussian(0, 7);
const sp = span * constrain(randomGaussian(0.88, 0.13), 0.5, 1);
const name = p === 0 ? "cpencil" : random() < 0.4 ? "2B" : "charcoal";
const w = constrain(randomGaussian(weight, weight * 0.28), 0.5, 4.4);
tideArc(cx, cy, rr, s0, sp, mineral(dark, greenBias), w, name, 0.09 + random(0, 0.07));
}
}
// the water that stood inside a tide line, laid down as a plane of pigment
function shallow(cx, cy, r, a0, span, col, op) {
brush.noStroke();
brush.fillBleed(0.4, "out");
brush.fillTexture(0.72, 0.4);
brush.fill(col, op);
brush.beginShape(0.35);
for (let i = 0; i <= 16; i++) {
const a = ((a0 + (span * i) / 16) * Math.PI) / 180;
const rr = ringRadius(cx, cy, r, a, 0.11) * (0.82 + random(0, 0.14));
brush.vertex(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr);
}
brush.vertex(cx + random(-20, 20), cy + random(-20, 20));
brush.endShape(true);
}
// pigment pooling on the low side of a tide line, where the water sat longest
function pool(cx, cy, r, a0, span, n, dark, op) {
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fillTexture(0.7, 0.55);
for (let i = 0; i < n; i++) {
const a = ((a0 + (span * (i + random(-0.34, 0.34))) / n) * Math.PI) / 180;
const rr = ringRadius(cx, cy, r, a, 0.1);
brush.fill(mineral(dark, 0.3), constrain(op + randomGaussian(0, 18), 50, 210));
brush.circle(
cx + Math.cos(a) * rr,
cy + Math.sin(a) * rr,
max(11, randomGaussian(r * 0.16, r * 0.04)),
true,
);
}
}
// a dried scab of mineral, laid in as hatching rather than as a flat shape
function crust(x, y, rad, ang, gap, col, weight) {
brush.noFill();
brush.noStroke();
brush.hatchStyle(random() < 0.5 ? "cpencil" : "2B", col, weight);
brush.hatch(gap, ang, { rand: 0.34, continuous: false });
brush.circle(x, y, rad, true);
brush.noHatch();
}
// each family is one puddle: as it lost water its centre slid downhill and its
// tide lines drew in, each new line more concentrated than the last
// rings: [radius, startAngle, span, weight] hand-set, gaps deliberately uneven
const FAMILIES = [
{
x0: 258, y0: 548, x1: 188, y1: 634, green: 0.2, ink: 0.62,
rings: [[254, 92, 272, 1.8], [219, 126, 226, 1.6], [183, 58, 214, 1.7], [161, 148, 196, 2.3], [130, 112, 244, 2.9]],
},
{
x0: 336, y0: 444, x1: 358, y1: 492, green: 0.42, ink: 0.5,
rings: [[198, 6, 318, 1.6], [163, 74, 226, 1.4], [126, 12, 250, 1.5], [98, 96, 206, 2.0], [74, 40, 288, 2.2]],
},
{
x0: 406, y0: 618, x1: 366, y1: 656, green: 0.3, ink: 0.58,
rings: [[142, 134, 292, 1.5], [111, 176, 218, 1.4], [84, 120, 246, 1.8], [61, 190, 264, 2.1], [40, 146, 300, 1.7]],
},
{
x0: 108, y0: 414, x1: 74, y1: 466, green: 0.16, ink: 0.44,
rings: [[170, -58, 244, 1.5], [133, -18, 196, 1.4], [102, -70, 218, 1.7], [72, 24, 168, 1.6]],
},
{
x0: 596, y0: 262, x1: 610, y1: 282, green: 0.46, ink: 0.3,
rings: [[46, -34, 258, 0.8], [30, 84, 214, 0.75], [18, 6, 176, 0.7]],
},
{
x0: 502, y0: 536, x1: 514, y1: 556, green: 0.36, ink: 0.36,
rings: [[58, -12, 286, 1.0], [38, 96, 208, 0.9], [22, 24, 232, 0.85]],
},
];
// loners: single tide lines with no family, out in the thinning paper
const LONERS = [
[252, 320, 122, 44, 282, 1.2, 0.2, 0.26, 2],
[388, 296, 54, 66, 240, 0.9, 0.16, 0.5, 2],
[470, 208, 30, 88, 222, 0.7, 0.12, 0.46, 1],
[688, 178, 17, 118, 194, 0.65, 0.1, 0.4, 1],
[176, 262, 36, -40, 204, 0.8, 0.14, 0.24, 1],
];
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(GROUND);
// ------------------------------------------- the stained floor of the dish
brush.noStroke();
brush.fillBleed(0.32, "out");
brush.fillTexture(0.66, 0.4);
for (let i = 0; i < 11; i++) {
const x = constrain(randomGaussian(226, 158), -60, 620);
const y = constrain(randomGaussian(590, 150), 150, 850);
brush.fill(mineral(0.14, 0.24), constrain(randomGaussian(82, 22), 38, 136));
brush.circle(x, y, max(58, randomGaussian(108, 26)), true);
}
// sediment settling out of the water, thickest where the families overlap
brush.fillBleed(0.24, "out");
brush.fillTexture(0.74, 0.55);
for (let i = 0; i < 28; i++) {
const x = constrain(randomGaussian(244, 128), -30, 600);
const y = constrain(randomGaussian(596, 132), 180, 830);
brush.fill(mineral(0.3, 0.28), constrain(randomGaussian(92, 26), 40, 158));
brush.circle(x, y, max(15, randomGaussian(42, 15)), true);
}
// ------------------------------------------- shallows: the standing water
shallow(214, 596, 226, 104, 250, blendMix(OCHRE, UMBER, 0.44), 82);
shallow(348, 476, 158, 24, 296, blendMix(UMBER, COPPER, 0.34), 70);
shallow(378, 640, 116, 146, 274, blendMix(COPPER, UMBER, 0.46), 78);
shallow(88, 448, 138, -44, 226, blendMix(OCHRE, MANGANESE, 0.22), 62);
shallow(190, 630, 122, 118, 236, blendMix(UMBER, MANGANESE, 0.28), 88);
shallow(366, 654, 56, 150, 292, blendMix(COPPER, MANGANESE, 0.3), 96);
// ------------------------------------------- crust: hatched mineral scabs
crust(224, 588, 96, 22, 5.2, blendMix(OCHRE, MANGANESE, 0.44), 1.05);
crust(316, 512, 74, 114, 5.6, blendMix(UMBER, MANGANESE, 0.38), 0.95);
crust(150, 662, 62, 70, 4.4, blendMix(COPPER, MANGANESE, 0.46), 1.1);
crust(384, 636, 52, 152, 5.4, blendMix(OCHRE, UMBER, 0.56), 0.9);
crust(86, 470, 54, 4, 6.4, blendMix(UMBER, MANGANESE, 0.32), 0.85);
crust(268, 690, 42, 128, 5.0, blendMix(OCHRE, MANGANESE, 0.5), 0.9);
crust(430, 546, 32, 88, 6.2, blendMix(COPPER, UMBER, 0.48), 0.65);
// pigment pooling against the widest tide lines
pool(258, 548, 250, 118, 190, 6, 0.34, 132);
pool(336, 444, 194, 44, 206, 4, 0.28, 112);
pool(108, 414, 166, -30, 154, 4, 0.26, 104);
pool(388, 628, 134, 158, 198, 5, 0.36, 126);
pool(196, 622, 156, 130, 176, 5, 0.4, 118);
pool(366, 654, 60, 132, 240, 4, 0.44, 130);
// ------------------------------------- the tide lines, widest and palest first
for (let f = 0; f < FAMILIES.length; f++) {
const fam = FAMILIES[f];
const n = fam.rings.length;
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
const cx = lerp(fam.x0, fam.x1, t) + randomGaussian(0, 3);
const cy = lerp(fam.y0, fam.y1, t) + randomGaussian(0, 3);
const g = fam.rings[i];
const dark = 0.14 + fam.ink * t;
tideLine(cx, cy, g[0], g[1], g[2], g[3], dark, fam.green, t > 0.6 ? 3 : 2);
}
}
for (let i = 0; i < LONERS.length; i++) {
const g = LONERS[i];
tideLine(g[0], g[1], g[2], g[3], g[4], g[5], g[6], g[7], g[8]);
}
// ------------------------------------------- heavy bands where they crowd
tideLine(196, 622, 158, 128, 112, 3.2, 0.66, 0.16, 4);
tideLine(188, 634, 130, 108, 96, 3.0, 0.7, 0.2, 4);
tideLine(366, 654, 60, 156, 128, 2.4, 0.62, 0.26, 3);
tideLine(376, 646, 84, 176, 86, 2.6, 0.6, 0.3, 3);
tideLine(258, 548, 252, 136, 104, 2.8, 0.64, 0.18, 3);
// ------------------------------------------- darks land last
brush.noFill();
const ink = () => blendMix(MANGANESE, UMBER, random(0, 0.3));
tideArc(258, 548, 253, 146, 88, ink(), 3.3, "charcoal", 0.1);
tideArc(188, 634, 131, 116, 82, ink(), 3.0, "charcoal", 0.11);
tideArc(196, 622, 159, 138, 72, ink(), 2.7, "charcoal", 0.1);
tideArc(366, 654, 41, 160, 122, ink(), 2.0, "2B", 0.13);
tideArc(376, 646, 62, 190, 78, ink(), 2.3, "charcoal", 0.12);
tideArc(74, 466, 73, 30, 92, ink(), 1.9, "2B", 0.12);
tideArc(358, 492, 75, 52, 96, ink(), 1.8, "cpencil", 0.14);
tideArc(108, 414, 171, -6, 56, ink(), 2.0, "2B", 0.1);
// the deepest hollow in the crowd, almost solid
brush.noStroke();
brush.fillBleed(0.22, "out");
brush.fillTexture(0.78, 0.6);
brush.fill(MANGANESE, 178);
brush.circle(228, 638, 36, true);
brush.fill(MANGANESE, 134);
brush.circle(262, 664, 25, true);
brush.fill(MANGANESE, 106);
brush.circle(198, 608, 20, true);
brush.fill(UMBER, 142);
brush.circle(288, 624, 17, true);
brush.fill(MANGANESE, 92);
brush.circle(172, 672, 15, true);
brush.fill(MANGANESE, 74);
brush.circle(320, 668, 11, true);
// scratches kept, not corrected — the surface was disturbed while it dried
brush.set("charcoal", blendMix(MANGANESE, UMBER, 0.16), 1.7);
brush.beginShape(0.6);
brush.vertex(152, 566);
brush.vertex(198, 598);
brush.vertex(232, 634);
brush.endShape(false);
brush.beginShape(0.6);
brush.vertex(246, 700);
brush.vertex(288, 680);
brush.vertex(322, 654);
brush.endShape(false);
brush.set("2B", blendMix(MANGANESE, COPPER, 0.3), 1.2);
brush.beginShape(0.6);
brush.vertex(96, 654);
brush.vertex(128, 678);
brush.vertex(152, 700);
brush.endShape(false);
brush.beginShape(0.6);
brush.vertex(330, 546);
brush.vertex(360, 566);
brush.vertex(388, 590);
brush.endShape(false);
brush.set("rotring", blendMix(MANGANESE, UMBER, 0.34), 0.8);
brush.line(186, 424, 214, 456);
brush.line(410, 686, 438, 668);
// grit drifting out of the crowd, thinning upward and to the right
brush.field("seabed");
brush.set("spray", blendMix(UMBER, MANGANESE, 0.46), 1.4);
for (let i = 0; i < 90; i++) {
const x = constrain(randomGaussian(240, 150), 8, 660);
const y = constrain(randomGaussian(608, 150), 90, 792);
if (x + (800 - y) > 1000 && random() < 0.85) continue;
brush.flowLine(x, y, constrain(randomGaussian(16, 8), 5, 36), randomGaussian(24, 46));
}
brush.noField();
// one iron-oxide tide line, the only warm mineral left in the dish
tideArc(348, 476, 160, 168, 82, blendMix(OXIDE, UMBER, 0.18), 2.7, "cpencil", 0.1);
tideArc(348, 476, 165, 182, 46, color(OXIDE), 1.8, "2B", 0.12);
tideArc(366, 654, 59, 216, 54, blendMix(OXIDE, MANGANESE, 0.24), 1.9, "2B", 0.13);
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill(OXIDE, 168);
brush.circle(172, 540, 10, true);
brush.fill(OXIDE, 116);
brush.circle(424, 714, 6.5, true);
brush.fill(OXIDE, 80);
brush.circle(586, 288, 4.5, true);
}