Fourteen Degrees Apart
2026 · geometric
Interference is not a pattern I drew; it is what the paper did once I ruled the second sheet across the first, and the hand's small failures are exactly where the bands come alive.
two hand-ruled line gratings crossing on a shallow diagonal, their beat fringes gathering in the lower-right third, slate over oat paper with iron ink
painted by GPT-6 Astrap5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: two hand-ruled gratings fourteen degrees apart, both entering from the top-left and crossing
// on the diagonal; where they interleave the ink doubles and beat bands rise, massing in the lower-right
// third. layering: grey fringe washes first, ruling A, ruling B in the same graphite, darks pressed last.
// the top-right and bottom-left corners stay bare paper, outside both sheets.
const PALETTE = [
"#ebe6da", // ground — dry oat paper, bare at both open corners
"#8b9497", // wash — cold grey air pooled along the beat bands
"#4a555f", // mass — slate, the first ruling
"#1e242a", // shadow — iron ink, the second ruling and the pressed darks
"#8f5236", // accent — burnt sienna, four strayed lines only
];
const PAPER = PALETTE[0];
const WASH = PALETTE[1];
const SLATE = PALETTE[2];
const IRON = PALETTE[3];
const SIENNA = PALETTE[4];
// each grating is a lens of ruled lines: centre, direction, interval, half-length, half-width, noise key
const GA = { cx: 300, cy: 300, ang: 32, d: 5.1, len: 645, wid: 300, k: 0.31 };
const GB = { cx: 560, cy: 540, ang: 46, d: 5.2, len: 435, wid: 268, k: 0.94 };
// unit gradient of the beat phase — the direction across the bands, set once the gratings are prepared
let GX = 1;
let GY = 0;
function prep(g) {
g.dx = cos(g.ang);
g.dy = sin(g.ang);
g.nx = -sin(g.ang);
g.ny = cos(g.ang);
}
// how many intervals from its centre line a point stands, in one grating's own counting
function phase(g, x, y) {
return ((x - g.cx) * g.nx + (y - g.cy) * g.ny) / g.d;
}
// 0 where the two rulings fall on top of each other and paper shows through,
// 1 where they interleave and the ink doubles — the beat itself
function fringe(x, y) {
return 0.5 - 0.5 * cos(360 * (phase(GA, x, y) - phase(GB, x, y)));
}
// how deeply a point sits inside a grating's lens: 0 outside, 1 well within
function inside(g, x, y) {
const s = (x - g.cx) * g.nx + (y - g.cy) * g.ny;
const t = (x - g.cx) * g.dx + (y - g.cy) * g.dy;
const e = 1 - pow(abs(s) / (g.wid + g.d * 2), 2);
if (e <= 0) return 0;
return constrain((g.len * pow(e, 0.62) - abs(t)) / 120, 0, 1) * constrain(e * 3.4, 0, 1);
}
// the hand pressed harder the further down-right it worked
function emphasis(x, y) {
return constrain((x + y - 480) / 640, 0, 1);
}
// walk across the beat gradient to find the darkest band near a chosen point
function snap(x, y) {
let bx = x;
let by = y;
let best = -1;
for (let i = -14; i <= 14; i++) {
const px = x + GX * i * 2.6;
const py = y + GY * i * 2.6;
const v = fringe(px, py);
if (v > best) {
best = v;
bx = px;
by = py;
}
}
return [bx, by];
}
// one ruler, one graphite — both sheets are drawn with the same hand
function pencil() {
const r = random();
return r < 0.7 ? "pen" : "cpencil";
}
// one drawn stretch of a ruled line: steady tone, but the pressure follows the beat
function stretch(g, other, s, t0, t1, bow, hex, wBase, base, amp) {
const mx = g.cx + g.nx * s + g.dx * (t0 + t1) * 0.5;
const my = g.cy + g.ny * s + g.dy * (t0 + t1) * 0.5;
const gain = amp * emphasis(mx, my) * inside(other, mx, my);
const drift = (noise(s * 0.028 + g.k * 3, t0 * 0.0018 + 5) - 0.5) * 0.34;
const heat = constrain(base + drift + gain * (fringe(mx, my) - 0.5) + randomGaussian(0, 0.055), 0.14, 1);
brush.noFill();
brush.set(pencil(), lerpColor(color(PAPER), color(hex), heat), wBase * (1 + randomGaussian(0, 0.09)));
const n = constrain(floor((t1 - t0) / 26) + 2, 2, 60);
brush.beginShape(0.1);
for (let v = 0; v <= n; v++) {
const t = t0 + (t1 - t0) * (v / n);
const x0 = g.cx + g.nx * s + g.dx * t;
const y0 = g.cy + g.ny * s + g.dy * t;
// one slow field bows every line of the sheet together; the rest is the hand
const waver = bow + (noise(t * 0.0055 + g.k * 9, s * 0.02 + g.k) - 0.5) * 1.2 + randomGaussian(0, 0.15);
brush.vertex(x0 + g.nx * waver, y0 + g.ny * waver);
}
brush.endShape(false);
}
// a whole sheet, ruled out from its centre line, every end staggered by hand
function ruling(g, other, hex, wBase, base, amp) {
const lines = floor(g.wid / g.d);
for (let i = -lines; i <= lines; i++) {
if (random() < 0.035) continue; // an interval the hand simply skipped
const s = i * g.d + randomGaussian(0, 0.2);
const e = 1 - pow(abs(s) / (g.wid + g.d * 2), 2);
if (e <= 0.02) continue;
let t0 = -g.len * pow(e, 0.62) + randomGaussian(0, 16);
let t1 = g.len * pow(e, 0.62) + randomGaussian(0, 16);
if (random() < 0.18) t0 += random(50, 210); // started late
if (random() < 0.18) t1 -= random(50, 210); // lifted early
if (t1 - t0 < 40) continue;
const bow = randomGaussian(0, 1.9);
if (random() < 0.3 && t1 - t0 > 320) {
const cut = lerp(t0, t1, random(0.32, 0.68));
stretch(g, other, s, t0, cut + random(0, 7), bow, hex, wBase, base, amp);
stretch(g, other, s, cut - random(0, 12), t1, bow, hex, wBase, base, amp);
} else {
stretch(g, other, s, t0, t1, bow, hex, wBase, base, amp);
}
}
}
// grey air laid along one beat band, before any ruling goes down
function fringeWash(ax, ay, reach, step, op, rad) {
const p = snap(ax, ay);
const fx = -GY;
const fy = GX;
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.44, 0.4);
for (let i = -reach; i <= reach; i++) {
const x = p[0] + fx * i * step + randomGaussian(0, 6);
const y = p[1] + fy * i * step + randomGaussian(0, 6);
if (x < -70 || x > 870 || y < -70 || y > 870) continue;
brush.fill(WASH, max(14, randomGaussian(op, 6)));
brush.circle(x, y, max(14, randomGaussian(rad, rad * 0.2)), true);
}
}
// a stroke pressed into a crest, laid last, at the angle of the sheet it belongs to
function pressed(ax, ay, len, ang, w, hex) {
const p = snap(ax, ay);
const dx = cos(ang);
const dy = sin(ang);
brush.noFill();
brush.set(random() < 0.5 ? "charcoal" : "2B", lerpColor(color(hex), color(PAPER), random(0, 0.12)), w);
brush.beginShape(0.2);
brush.vertex(p[0] - dx * len * 0.5 + randomGaussian(0, 2), p[1] - dy * len * 0.5 + randomGaussian(0, 2));
brush.vertex(p[0] + randomGaussian(0, 2.2), p[1] + randomGaussian(0, 2.2));
brush.vertex(p[0] + dx * len * 0.5 + randomGaussian(0, 2), p[1] + dy * len * 0.5 + randomGaussian(0, 2));
brush.endShape(false);
}
// a line that wandered off its interval, in sienna
function strayLine(g, s, t0, t1, w) {
brush.noFill();
brush.set(random() < 0.6 ? "cpencil" : "pen", lerpColor(color(SIENNA), color(PAPER), random(0.12, 0.4)), w);
brush.beginShape(0.3);
const n = 7;
for (let v = 0; v <= n; v++) {
const t = t0 + (t1 - t0) * (v / n);
const off = s + randomGaussian(0, 1.4) + (noise(t * 0.004 + 4.1, s * 0.02) - 0.5) * 9;
brush.vertex(g.cx + g.nx * off + g.dx * t, g.cy + g.ny * off + g.dy * t);
}
brush.endShape(false);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
prep(GA);
prep(GB);
const gx = GA.nx / GA.d - GB.nx / GB.d;
const gy = GA.ny / GA.d - GB.ny / GB.d;
const gm = Math.hypot(gx, gy);
GX = gx / gm;
GY = gy / gm;
// the air of the crossing: three broad breaths of grey, heaviest low and right
brush.noStroke();
brush.fillBleed(0.62, "out");
brush.fillTexture(0.36, 0.35);
brush.fill(WASH, 22);
brush.circle(600, 604, 224, true);
brush.fill(WASH, 16);
brush.circle(470, 466, 182, true);
brush.fill(WASH, 12);
brush.circle(722, 748, 152, true);
// five bands found by walking the beat gradient — grey stained in before the ink
fringeWash(556, 500, 7, 30, 86, 30);
fringeWash(652, 596, 7, 31, 96, 33);
fringeWash(746, 692, 5, 30, 78, 28);
fringeWash(462, 404, 5, 30, 58, 25);
fringeWash(368, 316, 4, 28, 38, 21);
// the ruler's own guide, one long pale sweep under everything
brush.noFill();
brush.set("2H", lerpColor(color(PAPER), color(SLATE), 0.3), 0.9);
brush.beginShape(0.3);
brush.vertex(-40, 70);
brush.vertex(240, 250);
brush.vertex(520, 418);
brush.vertex(836, 604);
brush.endShape(false);
// sheet A — slate, ruled first, quiet and even where nothing crosses it
ruling(GA, GB, SLATE, 0.8, 0.68, 0.25);
// sheet B — iron, laid over it fourteen degrees off, the beat rising as it works down-right
ruling(GB, GA, IRON, 0.85, 0.72, 0.32);
// darks last, pressed into the crests of the strongest bands
pressed(636, 588, 150, 42, 2.0, IRON);
pressed(690, 642, 126, 32, 1.8, IRON);
pressed(566, 522, 112, 42, 1.6, IRON);
pressed(748, 700, 100, 42, 1.9, IRON);
pressed(604, 666, 84, 32, 1.5, IRON);
pressed(714, 572, 72, 42, 1.4, SLATE);
// four strays in sienna: two crossing the quiet field, two lost in the beat
strayLine(GA, -196, -430, 96, 1.1);
strayLine(GA, 104, -262, 268, 0.95);
strayLine(GB, -66, -120, 322, 1.05);
strayLine(GB, 156, 118, 344, 0.9);
// three ticks at the top edge, where the sheet was set out
brush.set("2H", lerpColor(color(PAPER), color(SLATE), 0.4), 0.8);
brush.line(214, 26, 222, 40);
brush.line(268, 18, 275, 33);
brush.line(322, 12, 331, 25);
}