Four Kisses, One Edge
2026 · geometric
A tangent is the politest possible disagreement: the circle keeps its curve, the line keeps its straightness, and they consent to share exactly one point.
wide arcs entering from the top and right, coming down to touch a single ruled edge that crosses the lower-left third, drawn with a compass that wobbles
painted by GPT-5.6p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: one ruled edge runs from the left edge down to the bottom, crossing the lower-left third;
// three wide arcs enter from the top and right and come down to kiss it, all weight gathering at (259, 527).
// layering: pale slate blooms, hatched annular bands, ghost compass arcs, the arcs themselves, then the ruled edge and the dark kisses last.
const PALETTE = [
"#e7e2d5", // ground — warm grey paper
"#5b6d87", // mass — slate blue, the compass arcs
"#1f2731", // shadow — near-black ink, the ruled edge and the kisses
"#a1997f", // dust — grey ochre, construction lines and ghosts
"#c07f2c", // accent — ochre, rationed to the far tangency
];
const [PAPER, MASS, INK, DUST, OCHRE] = PALETTE;
// the ruled edge: anchored off the left edge, running down at thirty degrees
const LX = -40;
const LY = 355;
const UX = 0.8660;
const UY = 0.5;
// its upward normal, and the fixed angle every tangent radius makes with it
const NX = 0.5;
const NY = -0.8660;
const A0 = Math.atan2(-NY, -NX); // 2.0944 rad — the same kiss angle for every arc
function edgePoint(t) {
return [LX + UX * t, LY + UY * t];
}
function centreFor(t, r) {
const p = edgePoint(t);
return [p[0] + NX * r, p[1] + NY * r];
}
function tone(base, toward, mean, spread) {
return lerpColor(color(base), color(toward), constrain(randomGaussian(mean, spread), 0, 1));
}
// a compass that will not hold its radius: each pass breathes in and out along the sweep
function compassArc(cx, cy, r, a1, a2, name, base, toward, weight, passes, wob) {
const span = a2 - a1;
const steps = constrain(floor((abs(span) * r) / 11), 14, 260);
for (let p = 0; p < passes; p++) {
const slip = randomGaussian(0, wob * 0.7);
const s1 = a1 + randomGaussian(0, 0.06);
const s2 = a2 + randomGaussian(0, 0.06);
const ph = p * 6.7 + r * 0.013;
brush.noFill();
brush.set(name, tone(base, toward, 0.24, 0.16), weight * (1 + randomGaussian(0, 0.11)));
brush.beginShape(0.6);
for (let i = 0; i <= steps; i++) {
const a = lerp(s1, s2, i / steps);
const rr = r + slip + (noise(a * 1.35 + ph, ph * 0.4) - 0.5) * wob * 3.2 + randomGaussian(0, wob * 0.16);
const pr = constrain(1.18 - abs(a - A0) * 0.55, 0.34, 1.18);
brush.vertex(cx + rr * Math.cos(a), cy + rr * Math.sin(a), pr);
}
brush.endShape(false);
}
}
// the annulus between two radii, hatched — the compass sweeping a mass rather than a line
function hatchedBand(cx, cy, rOut, rIn, a1, a2, gap, ang, col) {
brush.noStroke();
brush.noFill();
brush.hatchStyle(random() < 0.5 ? "cpencil" : "HB", col, 1.35);
brush.hatch(gap, ang + randomGaussian(0, 2.2), { rand: 0.1, continuous: true, gradient: 0 });
const n = 16;
brush.beginShape(0.5);
for (let i = 0; i <= n; i++) {
const a = lerp(a1, a2, i / n);
const rr = rOut + randomGaussian(0, 3.2);
brush.vertex(cx + rr * Math.cos(a), cy + rr * Math.sin(a));
}
for (let i = n; i >= 0; i--) {
const a = lerp(a1, a2, i / n);
const rr = rIn + randomGaussian(0, 3.6);
brush.vertex(cx + rr * Math.cos(a), cy + rr * Math.sin(a));
}
brush.endShape(true);
brush.noHatch();
}
// the sliver of shadow trapped between a tangent arc and the edge — pinched to nothing at the kiss
function edgeCap(t, r, da, db, base, toward, alpha) {
const c = centreFor(t, r);
const n = 22;
const pts = [];
for (let i = 0; i <= n; i++) {
const a = lerp(A0 - da, A0 + db, i / n);
const rr = r + randomGaussian(0, 2.4);
pts.push([c[0] + rr * Math.cos(a), c[1] + rr * Math.sin(a)]);
}
brush.noStroke();
brush.fillBleed(0.32 + random(0, 0.1), "out");
brush.fillTexture(0.62, 0.4);
brush.fill(tone(base, toward, 0.42, 0.16), alpha + random(-8, 10));
brush.beginShape(0.15);
for (let i = 0; i <= n; i++) brush.vertex(pts[i][0], pts[i][1]);
const tEnd = (pts[n][0] - LX) * UX + (pts[n][1] - LY) * UY;
const tBeg = (pts[0][0] - LX) * UX + (pts[0][1] - LY) * UY;
for (let i = 0; i <= 6; i++) {
const q = edgePoint(lerp(tEnd, tBeg, i / 6));
brush.vertex(q[0] + randomGaussian(0, 1.4), q[1] + randomGaussian(0, 1.4));
}
brush.endShape(true);
}
function bloom(x, y, r, base, toward, alpha) {
brush.noStroke();
brush.fillBleed(0.5 + random(0, 0.14), "out");
brush.fillTexture(0.5, 0.28);
brush.fill(tone(base, toward, 0.3, 0.2), alpha + random(-5, 7));
brush.circle(x, y, r * (1 + randomGaussian(0, 0.06)), false);
}
// the moment of contact: a short dark segment of the same circle, laid over the arc
function kiss(t, r, half, weight) {
const c = centreFor(t, r);
compassArc(c[0], c[1], r, A0 - half, A0 + half, "2B", INK, MASS, weight, 2, 1.1);
}
// the radius that proves the tangency, drawn from pin to contact, and the pin itself
function radiusLine(t, r, base, weight) {
const c = centreFor(t, r);
const p = edgePoint(t);
const mx = (c[0] + p[0]) / 2 + randomGaussian(0, 2.2);
const my = (c[1] + p[1]) / 2 + randomGaussian(0, 2.2);
brush.noFill();
brush.set("2H", tone(base, PAPER, 0.3, 0.12), weight);
brush.line(c[0], c[1], mx, my);
brush.line(mx, my, p[0], p[1]);
brush.set("2H", tone(base, PAPER, 0.16, 0.08), weight * 1.2);
brush.line(c[0] - 8, c[1] - 7, c[0] + 9, c[1] + 8);
brush.line(c[0] - 8, c[1] + 8, c[0] + 8, c[1] - 8);
}
// the ruled edge itself — straight, but drawn by a hand
function ruledEdge(t1, t2, weight, name, base, off) {
const a = edgePoint(t1);
const b = edgePoint(t2);
const mx = (a[0] + b[0]) / 2 + NX * off + randomGaussian(0, 1.1);
const my = (a[1] + b[1]) / 2 + NY * off + randomGaussian(0, 1.1);
brush.noFill();
brush.set(name, base, weight);
brush.line(a[0] + NX * off, a[1] + NY * off, mx, my);
brush.line(mx, my, b[0] + NX * off, b[1] + NY * off);
}
function prick(x, y, r, base, alpha) {
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill(base, alpha);
brush.circle(x, y, r, true);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// --- pale air first: a broad slate breath where the arcs crowd ---
bloom(272, 398, 108, MASS, PAPER, 60);
bloom(190, 344, 92, MASS, PAPER, 52);
bloom(360, 452, 96, MASS, DUST, 46);
bloom(108, 282, 78, DUST, MASS, 50);
bloom(462, 512, 80, MASS, PAPER, 38);
bloom(322, 254, 104, MASS, PAPER, 26);
bloom(560, 574, 66, MASS, DUST, 28);
bloom(632, 610, 52, DUST, OCHRE, 36);
bloom(664, 156, 92, MASS, PAPER, 18);
// the crescents of shadow each arc traps against the edge — the argument's real mass
edgeCap(345, 400, 0.58, 0.44, MASS, INK, 86);
edgeCap(250, 300, 0.54, 0.42, MASS, INK, 70);
edgeCap(560, 560, 0.34, 0.30, MASS, INK, 52);
edgeCap(170, 215, 0.52, 0.50, MASS, INK, 58);
edgeCap(345, 400, 0.24, 0.20, INK, MASS, 62);
edgeCap(705, 140, 0.40, 0.34, DUST, OCHRE, 40);
// --- hatched crescents: the compass used as a wide flat pencil, close above the edge ---
hatchedBand(459, 181, 400, 340, 1.42, 2.32, 5.6, 17, tone(INK, MASS, 0.34, 0.1));
hatchedBand(326, 220, 300, 250, 1.62, 2.52, 5.0, 29, tone(MASS, INK, 0.4, 0.12));
hatchedBand(725, 150, 560, 508, 1.62, 2.10, 6.2, 17, tone(MASS, INK, 0.26, 0.1));
hatchedBand(214, 254, 215, 176, 1.66, 2.30, 4.4, 23, tone(INK, DUST, 0.34, 0.12));
// --- ghost compass work: false starts, a lifted pencil, and the radii that prove the kiss ---
compassArc(527, 313, 320, 1.02, 3.08, "2H", DUST, PAPER, 1.0, 2, 2.6);
compassArc(527, 313, 338, 1.44, 2.62, "2H", DUST, PAPER, 0.8, 1, 3.0);
compassArc(459, 181, 418, 1.10, 2.86, "2H", DUST, PAPER, 0.9, 1, 3.4);
compassArc(326, 220, 284, 0.70, 2.90, "2H", DUST, PAPER, 0.85, 1, 3.2);
radiusLine(345, 400, DUST, 0.9);
radiusLine(170, 215, DUST, 0.8);
radiusLine(560, 560, INK, 1.0);
// --- the arcs, back to front; each enters at the top or the right and comes down to the edge ---
compassArc(214, 254, 215, 0.30, 3.30, "cpencil", MASS, DUST, 1.7, 3, 2.2);
compassArc(725, 150, 560, 1.32, 3.52, "cpencil", MASS, DUST, 1.9, 3, 2.5);
compassArc(725, 150, 560, 1.52, 3.20, "2B", MASS, INK, 1.3, 2, 1.8);
compassArc(326, 220, 300, -0.55, 3.95, "cpencil", MASS, DUST, 2.0, 3, 2.6);
compassArc(326, 220, 300, 0.60, 3.10, "2B", MASS, INK, 1.4, 2, 1.7);
compassArc(459, 181, 400, 0.42, 3.75, "cpencil", MASS, INK, 2.4, 4, 2.4);
compassArc(459, 181, 400, 0.86, 3.30, "2B", MASS, INK, 1.6, 2, 1.6);
// two small circles knotted at the focus, where the compass was set short
compassArc(283, 432, 95, 0.10, 4.30, "2B", INK, MASS, 2.0, 3, 2.0);
compassArc(328, 507, 52, 0.40, 4.60, "2B", INK, MASS, 1.5, 2, 1.6);
// one small arc in ochre, alone above the edge in the open lower right
compassArc(641, 587, 140, 0.88, 3.50, "2B", OCHRE, INK, 2.1, 3, 1.9);
compassArc(641, 587, 154, 1.30, 2.80, "2H", OCHRE, PAPER, 0.9, 1, 2.4);
compassArc(533, 642, 38, 0.60, 4.40, "2B", OCHRE, DUST, 1.3, 2, 1.5);
// --- the ruled edge, dark and next to last ---
ruledEdge(-24, 934, 2.6, "rotring", tone(INK, MASS, 0.12, 0.06), 0);
ruledEdge(-10, 926, 1.5, "2B", tone(INK, MASS, 0.24, 0.08), 1.6);
ruledEdge(64, 902, 1.1, "2B", tone(INK, MASS, 0.3, 0.1), -1.4);
ruledEdge(210, 640, 0.9, "2H", tone(INK, DUST, 0.4, 0.1), 3.6);
// --- the darks: where circle meets line, and nowhere else ---
kiss(345, 400, 0.30, 2.7);
kiss(250, 300, 0.26, 2.3);
kiss(560, 560, 0.20, 2.2);
kiss(170, 215, 0.32, 2.1);
kiss(318, 95, 0.44, 1.8);
kiss(705, 140, 0.26, 1.5);
// compass pricks at the contacts, the nearest one ochre
const f = edgePoint(345);
prick(f[0] + 2, f[1] - 5, 6.5, OCHRE, 215);
prick(f[0] - 7, f[1] - 14, 3.6, INK, 210);
compassArc(459, 181, 400, A0 - 0.07, A0 + 0.05, "2B", OCHRE, INK, 1.9, 2, 0.8);
const g = edgePoint(560);
prick(g[0], g[1] - 5, 4.4, INK, 180);
const h = edgePoint(170);
prick(h[0] + 2, h[1] - 5, 3.8, INK, 170);
const k = edgePoint(705);
prick(k[0] - 1, k[1] - 5, 3.2, OCHRE, 180);
// one straight companion falling through the open right: a line is an argument, not a decoration
brush.noFill();
brush.set("rotring", tone(INK, MASS, 0.2, 0.08), 1.7);
brush.line(604, 18, 686, 268);
brush.set("2B", tone(INK, DUST, 0.3, 0.1), 1.1);
brush.line(628, 20, 700, 240);
// three ticks of dust in the open paper below the edge, so it breathes rather than empties
brush.set("2H", tone(DUST, PAPER, 0.42, 0.12), 0.9);
brush.line(212, 742, 234, 728);
brush.line(452, 786, 470, 774);
prick(338, 704, 2.8, DUST, 130);
}