One Unbroken Peel
2026 · still life
The whole picture is a wager that one cut can be carried further than the eye expects, and the dark is only there to hold the length of it.
a lemon high on the left, its rind taken off in a single spiral that falls across the table edge and out of the picture, the rest of the room unlit
painted by Kimi K2.6p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: the lemon sits on the upper-left thirds crossing; its peel unwinds in one unbroken helix
// down across the table's front edge and off the bottom of the canvas. The right half stays unlit room.
// layering: faint ochre glow, the table plane cut off hard at its front edge, cast shadow, back-facing
// curls, the lit front curls, one rationed warm bounce, then the darks and the cut pith edge struck last —
// that edge is one line, the only thing in the picture that never breaks.
const PALETTE = [
"#191510", // ground — the unlit room
"#2f2719", // table, the warmer dark it rests on
"#8a6524", // rind in shadow, ochre
"#e0bc4e", // lemon, the lit mass
"#f4ecd2", // pith and highlight, the lightest note
"#9e3b1e", // accent — one warm bounce under the curls, rationed
];
const GROUND = PALETTE[0];
const TABLE = PALETTE[1];
const OCHRE = PALETTE[2];
const RIND = PALETTE[3];
const PITH = PALETTE[4];
const BOUNCE = PALETTE[5];
// nudge a colour towards another by a small gaussian amount, so no two marks match
function toneShift(base, towards, mean, spread) {
return lerpColor(color(base), color(towards), constrain(randomGaussian(mean, spread), 0, 1));
}
// the table's front edge — tilted, because a dead level line reads as a ruler and not as furniture
function edgeY(x) {
return 549 - 0.036 * x;
}
// the plane, as x, y, radius, how far towards the ground the tone is mixed, and opacity
const TABLE_WASH = [
[112, 400, 62, 0.52, 92],
[172, 426, 68, 0.5, 116],
[230, 390, 66, 0.48, 126],
[256, 450, 70, 0.52, 130],
[188, 484, 66, 0.56, 120],
[122, 468, 62, 0.6, 96],
[64, 438, 58, 0.66, 74],
[310, 416, 68, 0.56, 112],
[330, 474, 66, 0.6, 104],
[376, 428, 62, 0.62, 92],
[398, 488, 64, 0.66, 84],
[452, 450, 62, 0.7, 72],
[470, 500, 60, 0.74, 64],
[520, 466, 60, 0.78, 54],
[562, 498, 58, 0.82, 44],
[608, 480, 56, 0.86, 34],
];
// the peel's centreline. the turns are tight at the fruit and open out as the strip falls,
// the cone widens and leans right, and a phase wobble keeps the loops off a regular spring.
// t is clamped at zero — the tangent below samples t - PEEL_EPS, and pow() of a negative is NaN.
function peelAt(t) {
const u = t < 0 ? 0 : t;
const ph = 1.0 + 24.5 * pow(u, 0.82) + 1.9 * Math.sin(2.4 * u + 0.5);
const a = (24 + 88 * pow(u, 0.62)) * (0.86 + 0.3 * noise(u * 3.1 + 4.2));
return {
x: 232 + a * Math.sin(ph) + 150 * u + 11 * Math.sin(5.1 * u + 1.7),
y: 300 + 300 * u + 244 * u * u,
ph: ph,
};
}
const PEEL_EPS = 0.005;
// sample the centreline into ribbon points: position, unit normal, half-width, and which face is
// towards us. a peel cut in one pass has a near-constant width — what varies is the twist, so the
// half-width is |cos(phase)| and only creeps up with distance towards the viewer.
function buildPeel(steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * 1.1;
const p = peelAt(t);
const b = peelAt(t + PEEL_EPS);
const a = peelAt(t - PEEL_EPS);
let tx = b.x - a.x;
let ty = b.y - a.y;
let m = Math.hypot(tx, ty);
// a degenerate or non-finite tangent falls back to straight down, never to NaN
if (!(m > 1e-6)) {
tx = 0;
ty = 1;
m = 1;
}
const c = Math.cos(p.ph);
pts.push({
x: p.x,
y: p.y,
nx: -ty / m,
ny: tx / m,
c: c,
hw: (17 + 14 * t) * pow(abs(c), 0.6) * (0.88 + 0.24 * noise(t * 5.2)) + 3.2,
t: t,
});
}
return pts;
}
// split the ribbon into lobes — runs that face us (front) and runs that show their pith side.
// consecutive runs share their boundary point, so no lobe is orphaned and no seam opens up.
function buildLobes(pts) {
const lobes = [];
let cur = null;
for (let i = 0; i < pts.length; i++) {
const front = pts[i].c > 0;
if (!cur || cur.front !== front) {
const prev = cur;
cur = { front: front, pts: [] };
if (prev && prev.pts.length > 0) cur.pts.push(prev.pts[prev.pts.length - 1]);
lobes.push(cur);
}
cur.pts.push(pts[i]);
}
return lobes;
}
// one wash pass over a lobe: forward along the outer edge, back along the inner one
function paintLobe(lobe, col, op, outer, inner, jitter) {
const pts = lobe.pts;
if (pts.length < 3) return;
brush.fill(col, op);
brush.beginShape(0.6);
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
brush.vertex(
p.x + p.nx * p.hw * outer + random(-jitter, jitter),
p.y + p.ny * p.hw * outer + random(-jitter, jitter)
);
}
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
brush.vertex(
p.x - p.nx * p.hw * inner + random(-jitter, jitter),
p.y - p.ny * p.hw * inner + random(-jitter, jitter)
);
}
brush.endShape(true);
}
// a run of strokes laid along one offset of a lobe — used for the pith lip, the warm bounce
// under the curls, and the darks that separate one curl from the one behind it
function lobeEdge(lobe, off, bru, col, w, from, to, step, jitter) {
const pts = lobe.pts;
const i0 = max(0, floor(pts.length * from));
const i1 = min(pts.length - 1, ceil(pts.length * to));
if (i1 - i0 < 2) return;
brush.noFill();
brush.set(bru, col, w);
brush.beginShape(0.55);
for (let i = i0; i <= i1; i += step) {
const p = pts[i];
brush.vertex(
p.x + p.nx * p.hw * off + random(-jitter, jitter),
p.y + p.ny * p.hw * off + random(-jitter, jitter)
);
}
brush.endShape(false);
}
// short strokes across a lit lobe — rind pores, following the curl
function rindTexture(lobe) {
const pts = lobe.pts;
brush.noFill();
for (let i = 1; i < pts.length - 1; i += 3) {
if (random() < 0.32) continue;
const p = pts[i];
const w = p.hw * (0.5 + random(0, 0.5));
brush.set("2H", toneShift(OCHRE, GROUND, 0.26, 0.14), 0.6 + random(0, 0.5));
brush.line(
p.x - p.nx * w + random(-1.4, 1.4),
p.y - p.ny * w + random(-1.4, 1.4),
p.x + p.nx * w * (0.7 + random(0, 0.5)),
p.y + p.ny * w * (0.7 + random(0, 0.5))
);
}
}
// the fruit: rind still on almost all of it, and one crescent of bared pith along the lower-left
// rim — the same corner the strip departs from, so the two agree about where the knife went.
// the fruit is the yellowest, most solid thing in the room.
function drawLemon(cx, cy, r) {
brush.noStroke();
// no halo here: a pale wash any wider than the rind bleeds into a second ghost disc beside the
// fruit. the warmth around it is the table's own glow, laid down in step 1.
// the rind. low bleed, so the silhouette stays round, and it stays yellow — every pale mark
// laid on it after this is small, because the fruit has to be the yellowest thing in the room.
brush.fillBleed(0.12, "out");
brush.fillTexture(0.22, 0.38);
brush.fill(RIND, 255);
brush.circle(cx, cy, r * 0.97, true);
brush.fill(toneShift(RIND, OCHRE, 0.26, 0.06), 220);
brush.circle(cx + r * 0.12, cy + r * 0.16, r * 0.78, true);
brush.fill(toneShift(RIND, PITH, 0.07, 0.04), 142);
brush.circle(cx - r * 0.26, cy - r * 0.22, r * 0.5, true);
// the form turning away, lower right — this is what makes it a sphere and not a disc
brush.fillBleed(0.24, "out");
brush.fill(toneShift(OCHRE, GROUND, 0.32, 0.06), 182);
brush.circle(cx + r * 0.5, cy + r * 0.44, r * 0.38, true);
brush.fill(toneShift(OCHRE, GROUND, 0.52, 0.08), 142);
brush.circle(cx + r * 0.64, cy + r * 0.58, r * 0.25, true);
// the bared crescent of pith where the knife came round and the strip left the fruit.
// it is kept well inside the rim — bulging past it is what turns a lemon into a lump.
brush.fillBleed(0.16, "out");
brush.fill(toneShift(PITH, RIND, 0.36, 0.07), 178);
brush.beginShape(0.7);
brush.vertex(cx - r * 0.5, cy + r * 0.44);
brush.vertex(cx - r * 0.16, cy + r * 0.7);
brush.vertex(cx + r * 0.26, cy + r * 0.64);
brush.vertex(cx + r * 0.02, cy + r * 0.5);
brush.vertex(cx - r * 0.32, cy + r * 0.32);
brush.endShape(true);
brush.fillBleed(0.2, "out");
brush.fill(toneShift(OCHRE, PITH, 0.4, 0.08), 118);
brush.circle(cx - r * 0.04, cy + r * 0.58, r * 0.12, true);
// the lit quadrant restated in warm yellow, over the top of everything laid down so far —
// this is what keeps the mottling of the washes from settling as a bruise on the lit side
// their centres are deliberately scattered — stacked concentrically they ring the fruit
// like an ammonite, which is exactly what the eye finds on a focal point.
brush.fillBleed(0.16, "out");
brush.fillTexture(0.2, 0.36);
brush.fill(toneShift(RIND, PITH, 0.15, 0.05), 196);
brush.circle(cx - r * 0.2, cy - r * 0.34, r * 0.5, true);
brush.fill(toneShift(RIND, PITH, 0.26, 0.06), 142);
brush.circle(cx - r * 0.48, cy - r * 0.1, r * 0.32, true);
// the one true highlight, upper-left, where the light comes from — small, and the only white
brush.fillBleed(0.26, "out");
brush.fill(PITH, 208);
brush.circle(cx - r * 0.38, cy - r * 0.48, r * 0.13, true);
brush.fill(PITH, 92);
brush.circle(cx - r * 0.14, cy - r * 0.6, r * 0.075, true);
// a warm rim along the upper-left silhouette, so the fruit reads round against the dark room
brush.noFill();
brush.set("2H", toneShift(PITH, RIND, 0.42, 0.08), 1.1);
brush.beginShape(0.5);
brush.vertex(cx - r * 0.9, cy + r * 0.16);
brush.vertex(cx - r * 0.82, cy - r * 0.38);
brush.vertex(cx - r * 0.46, cy - r * 0.78);
brush.vertex(cx - r * 0.04, cy - r * 0.92);
brush.endShape(false);
// the cut line where the rind ends — it runs along the top of the bared crescent
brush.noFill();
brush.set("2B", toneShift(OCHRE, GROUND, 0.36, 0.08), 1.3);
brush.beginShape(0.55);
brush.vertex(cx - r * 0.7, cy + r * 0.46);
brush.vertex(cx - r * 0.4, cy + r * 0.34);
brush.vertex(cx - r * 0.02, cy + r * 0.6);
brush.vertex(cx + r * 0.3, cy + r * 0.76);
brush.endShape(false);
// the stem dimple, kept inside the rim — sat on the silhouette it read as a stray dash
brush.noStroke();
brush.fillBleed(0.18, "out");
brush.fill(toneShift(OCHRE, GROUND, 0.28, 0.06), 208);
brush.circle(cx + r * 0.16, cy - r * 0.72, r * 0.08, true);
brush.fill(toneShift(OCHRE, GROUND, 0.5, 0.08), 150);
brush.circle(cx + r * 0.26, cy - r * 0.64, r * 0.05, true);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(GROUND);
const LX = 262;
const LY = 256;
const LR = 82;
brush.noStroke();
// 1. the pool of light — enormous and barely there, all of it upper-left. very high bleed and
// single-digit opacity, so it stays air and never hardens into a disc beside the fruit.
brush.fillBleed(0.78, "out");
brush.fillTexture(0.18, 0.36);
brush.fill(OCHRE, 14);
brush.circle(266, 322, 226, true);
brush.fill(OCHRE, 8);
brush.circle(140, 158, 150, true);
// 2. the table plane — many small overlapping washes rather than a few big discs, so their
// union averages into a surface with an irregular far edge instead of one scalloped arc.
// density falls away to the right, into the unlit room. the lower washes deliberately
// overrun the front edge; step 3 cuts them off there.
brush.fillBleed(0.34, "out");
brush.fillTexture(0.3, 0.5);
for (let i = 0; i < TABLE_WASH.length; i++) {
const w = TABLE_WASH[i];
brush.fill(toneShift(OCHRE, GROUND, w[3], 0.05), w[4] + randomGaussian(0, 7));
brush.circle(w[0] + randomGaussian(0, 3), w[1] + randomGaussian(0, 4), w[2], true);
}
// the fruit's cast shadow, thrown right and down along the plane, away from the light.
// high bleed — a crisp edge here reads as a hole cut in the table, not as shadow.
brush.fillBleed(0.46, "out");
brush.fill(GROUND, 226);
brush.circle(306, 342, 36, true);
brush.fill(GROUND, 208);
brush.circle(350, 354, 48, true);
brush.fill(GROUND, 182);
brush.circle(400, 372, 42, true);
brush.fill(GROUND, 146);
brush.circle(446, 390, 34, true);
brush.fill(GROUND, 104);
brush.circle(490, 404, 26, true);
// 3. the void below the table's front edge — cutting the washes off hard here is what turns
// a cloud of warm tone into a plane you could set a fruit down on
brush.fillBleed(0.06, "out");
brush.fillTexture(0.14, 0.3);
brush.fill(GROUND, 252);
brush.beginShape(0);
brush.vertex(-40, edgeY(-40) + 4);
brush.vertex(400, edgeY(400) + 4);
brush.vertex(840, edgeY(840) + 4);
brush.vertex(840, 860);
brush.vertex(-40, 860);
brush.endShape(true);
// 4. the front edge itself — one run of light that starts strong at the left of the fruit and
// gives out towards the unlit right. it sits exactly on the cut, so tone meets void there.
brush.noFill();
brush.set("charcoal", toneShift(OCHRE, PITH, 0.26, 0.07), 1.3);
brush.beginShape(0.12);
brush.vertex(178, edgeY(178) - 1);
brush.vertex(240, edgeY(240));
brush.vertex(300, edgeY(300) + 1);
brush.endShape(false);
brush.set("charcoal", toneShift(OCHRE, GROUND, 0.36, 0.07), 1.1);
brush.beginShape(0.12);
brush.vertex(320, edgeY(320));
brush.vertex(384, edgeY(384) + 1);
brush.vertex(444, edgeY(444));
brush.endShape(false);
brush.set("2H", toneShift(OCHRE, GROUND, 0.34, 0.1), 1.0);
brush.line(112, edgeY(112) - 1, 168, edgeY(168));
brush.set("2H", toneShift(OCHRE, GROUND, 0.5, 0.1), 0.9);
brush.line(62, edgeY(62) - 1, 104, edgeY(104));
brush.line(462, edgeY(462), 534, edgeY(534) + 1);
brush.set("2H", toneShift(OCHRE, GROUND, 0.72, 0.1), 0.8);
brush.line(566, edgeY(566), 626, edgeY(626));
// the front face of the table, dropping into shadow just under its own lit edge
brush.set("cpencil", toneShift(GROUND, TABLE, 0.44, 0.1), 1.3);
brush.line(146, edgeY(146) + 8, 288, edgeY(288) + 8);
brush.line(330, edgeY(330) + 7, 434, edgeY(434) + 7);
// the plane just behind the edge — a few soft drags, slightly converging, so it reads as a
// surface. sprayed and short: a long crisp one floats off as a wire in this much dark.
brush.set("spray", toneShift(OCHRE, GROUND, 0.58, 0.07), 1.2);
brush.line(126, edgeY(126) - 13, 264, edgeY(264) - 16);
brush.line(310, edgeY(310) - 11, 404, edgeY(404) - 14);
brush.set("spray", toneShift(OCHRE, GROUND, 0.7, 0.08), 1.0);
brush.line(164, edgeY(164) - 31, 300, edgeY(300) - 36);
brush.line(206, edgeY(206) - 52, 296, edgeY(296) - 57);
const pts = buildPeel(72);
const lobes = buildLobes(pts);
// 5. the peel — back-facing curls first, they sit behind the front ones and stay in shadow
brush.noStroke();
brush.fillBleed(0.22, "out");
brush.fillTexture(0.34, 0.5);
for (let i = 0; i < lobes.length; i++) {
if (lobes[i].front) continue;
paintLobe(lobes[i], toneShift(OCHRE, GROUND, 0.34, 0.07), 226 + random(-12, 12), 1.0, 0.5, 1.8);
paintLobe(lobes[i], toneShift(OCHRE, GROUND, 0.5, 0.08), 150 + random(-16, 16), 0.72, 0.3, 1.5);
}
// 6. the lit face of the peel — the brightest yellow in the room after the fruit itself
brush.fillBleed(0.2, "out");
brush.fillTexture(0.38, 0.55);
for (let i = 0; i < lobes.length; i++) {
if (!lobes[i].front) continue;
paintLobe(lobes[i], toneShift(RIND, OCHRE, 0.2, 0.07), 252, 1.02, 0.6, 2.0);
paintLobe(lobes[i], toneShift(RIND, OCHRE, 0.42, 0.08), 190 + random(-14, 18), 0.86, 0.36, 1.6);
}
// 7. rind texture on the lit curls
for (let i = 0; i < lobes.length; i++) {
if (!lobes[i].front) continue;
rindTexture(lobes[i]);
}
// 8. the accent, rationed: warm light bouncing off the table into the underside of two curls,
// both of them up near the fruit where the eye already is
for (let i = 3; i <= 5; i += 2) {
lobeEdge(lobes[i], -0.8, "marker", toneShift(BOUNCE, OCHRE, 0.22, 0.06), 2.0, 0.24, 0.8, 2, 1.4);
lobeEdge(lobes[i], -0.56, "spray", toneShift(BOUNCE, OCHRE, 0.46, 0.08), 1.3, 0.34, 0.7, 2, 1.6);
}
brush.noStroke();
brush.fillBleed(0.36, "out");
brush.fill(toneShift(BOUNCE, GROUND, 0.5, 0.08), 84);
brush.circle(296, 462, 17, true);
// 9. the darks, struck last — soft warm shadow seated under a few curls where one passes in
// front of the one behind. sprayed, never drawn: a hard line here would outline the ribbon.
for (let i = 2; i < lobes.length - 1; i += 2) {
lobeEdge(lobes[i], 0.86, "spray", toneShift(GROUND, OCHRE, 0.2, 0.06), 2.0, 0.16, 0.84, 2, 2.2);
}
for (let i = 3; i < lobes.length - 1; i += 4) {
lobeEdge(lobes[i], -0.98, "spray", toneShift(GROUND, OCHRE, 0.14, 0.05), 1.7, 0.24, 0.78, 2, 2.0);
}
// where the strip drapes over the front edge, the table takes its light away
brush.noStroke();
brush.fillBleed(0.3, "out");
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
if ((a.y - edgeY(a.x)) * (b.y - edgeY(b.x)) > 0) continue;
brush.fill(toneShift(GROUND, OCHRE, 0.16, 0.06), 176);
brush.circle(b.x + randomGaussian(8, 4), b.y - 13 + randomGaussian(0, 3), 15, true);
brush.fill(toneShift(GROUND, OCHRE, 0.08, 0.04), 132);
brush.circle(b.x + randomGaussian(24, 6), b.y - 9 + randomGaussian(0, 3), 10, true);
}
// 10. the pith lip — the strip is cut on both long edges, but only the outer one gets a drawn
// line (step 11). here the inner edge only *catches* light, in short broken passages, on
// the lit curls alone. anything more and the ribbon reads as an outline instead of a form.
for (let i = 1; i < lobes.length; i += 2) {
lobeEdge(lobes[i], -0.72, "pen", toneShift(PITH, RIND, 0.34, 0.08), 1.3, 0.2, 0.62, 2, 1.2);
}
// 11. the cut edge — ONE line, unbroken, from behind the fruit to off the bottom of the canvas
brush.noFill();
brush.set("pen", toneShift(PITH, OCHRE, 0.28, 0.06), 1.4);
brush.beginShape(0.55);
for (let i = 0; i < pts.length; i += 2) {
const p = pts[i];
brush.vertex(p.x + p.nx * p.hw * 1.04 + random(-1, 1), p.y + p.ny * p.hw * 1.04 + random(-1, 1));
}
brush.endShape(false);
brush.set("2B", toneShift(OCHRE, GROUND, 0.46, 0.1), 0.9);
brush.beginShape(0.55);
for (let i = 1; i < pts.length; i += 3) {
const p = pts[i];
brush.vertex(p.x + p.nx * p.hw * 1.18 + random(-1.4, 1.4), p.y + p.ny * p.hw * 1.18 + random(-1.4, 1.4));
}
brush.endShape(false);
// 12. the fruit last, so it holds the top of the picture
drawLemon(LX, LY, LR);
// and one dust mote adrift in the empty right, to say the room keeps going
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fill(toneShift(OCHRE, PITH, 0.4, 0.1), 60);
brush.circle(686, 286, 5, true);
}