Pulled Three Ways
2026 · study
A knot is an argument the rope has with itself. Slacken it and the argument is visible; pull it tight and only the outcome is.
one stiff hemp rope tied in the same overhand knot three times at three tensions, climbing a loose diagonal from a slack knot at lower left to a choked one above, the crossings drawn so the cord is seen passing through itself
painted by GPT-6 Astrap5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: one stiff rope, the same overhand knot studied three times on a loose diagonal —
// a broad slack one at lower-left whose top crossing sits on the left third line, a middling one
// drawn tighter above it, a small choked one at the head of the climb. upper right is only paper.
// layering: hemp washes and cast tone first, the measure taken second, then the cord itself with
// every under-strand laid before every over-strand so the crossings are true, and the darks last.
const PALETTE = [
"#e9e2d2", // paper
"#c9ae7b", // light
"#8a6a3e", // hemp
"#4a3624", // umber
"#211c16", // ink
"#a03f28", // accent
];
const PAPER = PALETTE[0];
const LIT = PALETTE[1];
const HEMP = PALETTE[2];
const UMBER = PALETTE[3];
const IRON = PALETTE[4];
const RUST = PALETTE[5];
// the rope is a trefoil with the lower lobe cut away, so both ends hang free
const TA = 220;
const TB = 500;
const LGX = -0.62; // light from the upper left
const LGY = -0.78;
const KNOTS = [
// choked hard: the eyes have closed to slits, the legs pull nearly straight
{ cx: 402, cy: 268, s: 20, a: 1.85, sx: 1.12, sy: 0.76, rot: 62, w: 19, lay: 0.44, force: 1.05,
tails: [{ n: 5, step: 13, t0: -7, t1: -3, jit: 1.1, fray: 1 },
{ n: 5, step: 12, t0: 12, t1: 6, jit: 1.1, fray: 1 }] },
// drawn in, still open enough to see through
{ cx: 318, cy: 443, s: 27, a: 1.95, sx: 1.06, sy: 0.84, rot: 150, w: 23, lay: 0.72, force: 1.15,
tails: [{ n: 6, step: 16, t0: -8, t1: -3, jit: 1.8, fray: 1 },
{ n: 6, step: 15, t0: 8, t1: 4, jit: 1.8, fray: 1 }] },
// slack, the widest eyes, one leg sweeping off across the foot of the sheet
{ cx: 180, cy: 640, s: 40, a: 2.05, sx: 1.00, sy: 1.00, rot: -54, w: 26, lay: 1.0, force: 1.6,
tails: [{ n: 14, step: 26, t0: -8, t1: -2, jit: 2.2, fray: 1 },
{ n: 6, step: 22, t0: 16, t1: 8, jit: 2.2, fray: 0 }] },
];
function knotAt(K, t) {
const r = t * Math.PI / 180;
const lx = Math.sin(r) + K.a * Math.sin(2 * r);
const ly = K.a * Math.cos(2 * r) - Math.cos(r);
const px = lx * K.s * K.sx;
const py = ly * K.s * K.sy;
const cr = Math.cos(K.rot * Math.PI / 180);
const sr = Math.sin(K.rot * Math.PI / 180);
return [K.cx + px * cr - py * sr, K.cy + px * sr + py * cr];
}
// the six parameters at which the cord meets itself; even are under, odd are over
function crossAt(K) {
const tA = Math.acos(-1 / (2 * K.a)) * 180 / Math.PI;
const tB = 360 - tA;
return [tA + 120, tB, tA + 240, tB + 120, tA + 360, tB + 240];
}
function tailFrom(K, atEnd, spec) {
const t = atEnd ? TB : TA;
const p = knotAt(K, t);
const q = knotAt(K, t + (atEnd ? -2.2 : 2.2));
let ang = Math.atan2(p[1] - q[1], p[0] - q[0]) * 180 / Math.PI;
const pts = [];
let x = p[0];
let y = p[1];
for (let i = 0; i < spec.n; i++) {
const u = spec.n < 2 ? 0 : i / (spec.n - 1);
ang += spec.t0 + (spec.t1 - spec.t0) * u + constrain(randomGaussian(0, spec.jit), -5, 5);
const st = spec.step * constrain(randomGaussian(1, 0.07), 0.8, 1.2);
x += Math.cos(ang * Math.PI / 180) * st;
y += Math.sin(ang * Math.PI / 180) * st;
pts.push([x, y]);
}
return { pts: pts, ang: ang };
}
function offsetRun(pts, nx, ny, i0, i1, k, jit) {
const out = [];
for (let i = i0; i <= i1; i++) {
const j = jit ? constrain(randomGaussian(1, jit), 1 - jit * 3, 1 + jit * 3) : 1;
out.push([pts[i][0] + nx[i] * k * j, pts[i][1] + ny[i] * k * j]);
}
return out;
}
function bandShape(a, b, curv) {
brush.beginShape(curv);
for (let i = 0; i < a.length; i++) brush.vertex(a[i][0], a[i][1]);
for (let i = b.length - 1; i >= 0; i--) brush.vertex(b[i][0], b[i][1]);
brush.endShape(true);
}
// one observed passage of cord: two edges, a lit flank, a sunk flank, and the lay of the fibre
function cordDraw(pts, w, lay, force) {
const n = pts.length;
const nx = [];
const ny = [];
for (let i = 0; i < n; i++) {
const p = pts[max(0, i - 1)];
const q = pts[min(n - 1, i + 1)];
const m = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1;
nx.push((p[1] - q[1]) / m);
ny.push((q[0] - p[0]) / m);
}
const h = w * 0.5;
const eL = offsetRun(pts, nx, ny, 0, n - 1, h, 0.05);
const eR = offsetRun(pts, nx, ny, 0, n - 1, -h, 0.05);
const iL = offsetRun(pts, nx, ny, 0, n - 1, h * 0.88, 0);
const iR = offsetRun(pts, nx, ny, 0, n - 1, -h * 0.88, 0);
// the cord owns its own paper, so whatever it passes over is genuinely hidden
brush.noStroke();
brush.noHatch();
brush.fillBleed(0.05, "out");
brush.fillTexture(0.05, 0.08);
brush.fill(PAPER, 255);
bandShape(iL, iR, 0.4);
brush.fillBleed(0.15, "out");
brush.fillTexture(0.46, 0.4);
brush.fill(random() < 0.24 ? LIT : HEMP, 200 + random(-22, 34));
bandShape(eL, eR, 0.4);
// the shadow half of the cylinder, laid as solid pigment in runs — the side the light
// has left swaps wherever the rope turns back on itself, and the run breaks there
let r0 = 0;
while (r0 < n - 1) {
const s0 = (nx[r0] * LGX + ny[r0] * LGY) >= 0 ? 1 : -1;
let r1 = r0;
while (r1 < n - 1 && (((nx[r1 + 1] * LGX + ny[r1 + 1] * LGY) >= 0 ? 1 : -1) === s0)) r1++;
if (r1 - r0 >= 2) {
const inner = offsetRun(pts, nx, ny, r0, r1, -s0 * h * 0.1, 0);
const mid = offsetRun(pts, nx, ny, r0, r1, -s0 * h * 0.56, 0);
const outer = offsetRun(pts, nx, ny, r0, r1, -s0 * h * 0.99, 0);
brush.fillBleed(0.13, "out");
brush.fillTexture(0.5, 0.45);
brush.fill(UMBER, 124 + random(-14, 20));
bandShape(inner, outer, 0.4);
brush.fillBleed(0.09, "out");
brush.fill(UMBER, 108 + random(-14, 20));
bandShape(mid, outer, 0.4);
// and the dark laid in strokes, the only way pigment gets truly deep on this paper
brush.noFill();
for (let g = 0; g < 4; g++) {
const o = h * (0.5 + g * 0.16);
brush.set(g === 0 ? "charcoal" : g < 3 ? "2B" : "cpencil", g < 3 ? IRON : UMBER,
constrain(randomGaussian((1.15 - g * 0.19) * (0.42 + 0.56 * force), 0.13), 0.2, 1.7));
brush.beginShape(0.4);
for (let i = r0; i <= r1; i++) {
brush.vertex(pts[i][0] - nx[i] * s0 * o + randomGaussian(0, 0.7),
pts[i][1] - ny[i] * s0 * o + randomGaussian(0, 0.7));
}
brush.endShape(false);
}
brush.noStroke();
}
r0 = r1 + 1;
}
// a lit crown along the other flank, and the fibre that catches the light on top of it
const CROWN = [[UMBER, 0.24, -0.34, 1], [LIT, 0.2, 0.5, 0.9], [PAPER, 0.08, 0.24, 0.5]];
for (let i = 0; i < n - 2; i += 2) {
const sgn = (nx[i] * LGX + ny[i] * LGY) >= 0 ? 1 : -1;
const k = i + 2;
for (let g = 0; g < CROWN.length; g++) {
if (random() > CROWN[g][3]) continue;
const o = sgn * h * CROWN[g][2];
brush.set("spray", CROWN[g][0], w * CROWN[g][1]);
brush.line(pts[i][0] + nx[i] * o, pts[i][1] + ny[i] * o,
pts[k][0] + nx[k] * o, pts[k][1] + ny[k] * o);
}
}
// the lay: oblique strands crossing the cord, tighter where the rope is squeezed
let acc = 1e9;
for (let i = 1; i < n; i++) {
acc += dist(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]);
if (acc < w * 0.31 * lay * constrain(randomGaussian(1, 0.22), 0.55, 1.6)) continue;
acc = 0;
const sgn = (nx[i] * LGX + ny[i] * LGY) >= 0 ? 1 : -1;
const ta = Math.atan2(-nx[i], ny[i]);
const a = ta + 1.02 + constrain(randomGaussian(0, 0.18), -0.4, 0.4);
const len = h * constrain(randomGaussian(1.5, 0.2), 1.0, 1.95);
const off = h * 0.2 * sgn + constrain(randomGaussian(0, 1.4), -3.2, 3.2);
const ox = pts[i][0] + nx[i] * off;
const oy = pts[i][1] + ny[i] * off;
const dark = random() < 0.3;
brush.set(dark ? "pen" : "cpencil", dark ? IRON : UMBER,
constrain(randomGaussian(dark ? 0.62 : 0.7, 0.18), 0.4, 1.5));
brush.line(ox - Math.cos(a) * len * 0.5, oy - Math.sin(a) * len * 0.5,
ox + Math.cos(a) * len * 0.5, oy + Math.sin(a) * len * 0.5);
}
// Three fine helical fibres turn around the cylindrical cord between crossings.
brush.noFill();
for (let strand = 0; strand < 3; strand++) {
brush.set("cpencil", strand === 1 ? PAPER : HEMP, 0.5);
brush.beginShape(0.35);
let along = 0;
for (let i = 0; i < n; i++) {
if (i) along += dist(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]);
const off = Math.sin(along / (w * 0.65) + strand * 2.094) * h * 0.65;
brush.vertex(pts[i][0] + nx[i] * off, pts[i][1] + ny[i] * off);
}
brush.endShape(false);
}
// the two edges, weighted by which one the light has left
brush.noFill();
let i0 = 0;
while (i0 < n - 2) {
const i1 = min(n - 1, i0 + max(3, Math.round(constrain(randomGaussian(6, 2), 3, 11))));
if (i1 - i0 < 2) break;
const mid = Math.floor((i0 + i1) / 2);
for (let s = 0; s < 2; s++) {
const side = s === 0 ? 1 : -1;
const facing = (nx[mid] * LGX + ny[mid] * LGY) * side;
const dark = facing < 0.08;
brush.set(dark ? "pen" : "2B", dark ? IRON : UMBER,
dark ? constrain(randomGaussian(0.8 * force, 0.15), 0.6, 2.4)
: constrain(randomGaussian(0.6, 0.12), 0.4, 1.4));
const arr = side === 1 ? eL : eR;
brush.beginShape(0.45);
for (let k = i0; k <= i1; k++) brush.vertex(arr[k][0], arr[k][1]);
brush.endShape(false);
}
i0 = i1;
}
}
// the unit tangent of the cord where it passes through a given parameter
function unitAt(K, t) {
const a = knotAt(K, t - 2);
const b = knotAt(K, t + 2);
const m = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
return [(b[0] - a[0]) / m, (b[1] - a[1]) / m];
}
// where one passage buries another: dark packed against the over-strand's two edges
function crossDark(K, tU, tO, w, force) {
const X = knotAt(K, tU);
const u = unitAt(K, tU);
const o = unitAt(K, tO);
const ux = u[0];
const uy = u[1];
const sn = max(0.34, Math.abs(ux * o[1] - uy * o[0]));
const reach = (w * 0.5) / sn;
const px = -uy;
const py = ux;
for (let s = 0; s < 2; s++) {
const sgn = s === 0 ? 1 : -1;
const ex = X[0] + ux * reach * sgn;
const ey = X[1] + uy * reach * sgn;
// the shadow pooled on the buried strand, deepest against the edge above it
brush.noFill();
for (let k = 0; k < 7; k++) {
const sp = w * (0.45 - k * 0.032);
const cx0 = ex + ux * k * w * 0.085 * sgn;
const cy0 = ey + uy * k * w * 0.085 * sgn;
brush.set(k < 4 ? "cpencil" : "2H", k < 4 ? IRON : UMBER,
constrain(randomGaussian(force * (1.2 - k * 0.14), 0.12), 0.2, 1.7));
brush.line(cx0 + px * sp + randomGaussian(0, 0.6), cy0 + py * sp + randomGaussian(0, 0.6),
cx0 - px * sp + randomGaussian(0, 0.6), cy0 - py * sp + randomGaussian(0, 0.6));
}
// and the edge of the passage lying over it, struck once and firmly
brush.set("pen", IRON, constrain(randomGaussian(1.35 * force, 0.14), 0.6, 2.1));
brush.line(ex + px * w * 0.46 + randomGaussian(0, 0.7), ey + py * w * 0.46 + randomGaussian(0, 0.7),
ex - px * w * 0.46 + randomGaussian(0, 0.7), ey - py * w * 0.46 + randomGaussian(0, 0.7));
}
}
function frayAt(p, ang, w, count) {
const pu = [-Math.sin(ang * Math.PI / 180), Math.cos(ang * Math.PI / 180)];
for (let i = 0; i < count; i++) {
const off = map(i, 0, max(1, count - 1), -w * 0.38, w * 0.38) + randomGaussian(0, 1.1);
const a = (ang + constrain(randomGaussian(0, 13), -26, 26)) * Math.PI / 180;
const r = constrain(randomGaussian(w * 0.4, w * 0.1), w * 0.18, w * 0.66);
brush.set(random() < 0.35 ? "2H" : "cpencil", random() < 0.3 ? LIT : UMBER,
constrain(randomGaussian(0.5, 0.12), 0.24, 0.85));
brush.line(p[0] + pu[0] * off, p[1] + pu[1] * off,
p[0] + pu[0] * off + Math.cos(a) * r, p[1] + pu[1] * off + Math.sin(a) * r);
}
}
function whipAt(pts, i, w) {
const p = pts[i];
const ang = Math.atan2(pts[i + 1][1] - pts[i - 1][1], pts[i + 1][0] - pts[i - 1][0]);
const pu = [-Math.sin(ang), Math.cos(ang)];
for (let k = 0; k < 6; k++) {
const off = (k - 2.5) * w * 0.23 + randomGaussian(0, 1.1);
brush.set(k % 3 === 0 ? "2B" : "cpencil", RUST,
constrain(randomGaussian(1.5, 0.3), 0.7, 2.5));
brush.line(p[0] + Math.cos(ang) * off - pu[0] * w * 0.48,
p[1] + Math.sin(ang) * off - pu[1] * w * 0.48,
p[0] + Math.cos(ang) * off + pu[0] * w * 0.48,
p[1] + Math.sin(ang) * off + pu[1] * w * 0.48);
}
}
function washBlob(x, y, r, col, a, bleed) {
brush.noStroke();
brush.noHatch();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.32, 0.36);
brush.fill(col, a);
brush.circle(x + randomGaussian(0, 5), y + randomGaussian(0, 5),
r * constrain(randomGaussian(1, 0.07), 0.85, 1.15), true);
}
// the terminator: a continuous dark run just inside whichever edge the light has left
function deepenKnot(K, weight) {
brush.noFill();
for (let t = TA; t < TB - 5; t += 5) {
const p = knotAt(K, t);
const q = knotAt(K, t + 5);
const m = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1;
const vx = (p[1] - q[1]) / m;
const vy = (q[0] - p[0]) / m;
const sgn = (vx * LGX + vy * LGY) >= 0 ? -1 : 1;
const o = K.w * (0.36 + random(0, 0.06));
brush.set(random() < 0.28 ? "charcoal" : "2B", IRON,
constrain(randomGaussian(weight, 0.16), 0.24, 1.5));
brush.line(p[0] + vx * sgn * o, p[1] + vy * sgn * o,
q[0] + vx * sgn * o, q[1] + vy * sgn * o);
}
}
function drawKnot(K) {
const c = crossAt(K);
K.tIn = tailFrom(K, false, K.tails[0]);
K.tOut = tailFrom(K, true, K.tails[1]);
// the shadow the whole rope throws before any of it is drawn
brush.noFill();
for (let pass = 0; pass < 2; pass++) {
brush.set("spray", pass === 0 ? UMBER : IRON, K.w * (pass === 0 ? 0.5 : 0.26));
for (let t = TA; t < TB - 6; t += 7) {
const p = knotAt(K, t);
const q = knotAt(K, t + 7);
brush.line(p[0] + 5, p[1] + 6.5, q[0] + 5, q[1] + 6.5);
}
}
for (let pass = 0; pass < 2; pass++) {
for (let k = 0; k < 6; k++) {
if ((k % 2 === 1) !== (pass === 1)) continue;
const lo = k === 0 ? TA : (c[k - 1] + c[k]) / 2 - 2;
const hi = k === 5 ? TB : (c[k] + c[k + 1]) / 2 + 2;
const pts = [];
if (k === 0) for (let i = K.tIn.pts.length - 1; i >= 0; i--) pts.push(K.tIn.pts[i]);
const n = max(6, Math.ceil((hi - lo) / 2.4));
for (let i = 0; i <= n; i++) pts.push(knotAt(K, lo + (hi - lo) * i / n));
if (k === 5) for (let i = 0; i < K.tOut.pts.length; i++) pts.push(K.tOut.pts[i]);
cordDraw(pts, K.w, K.lay, K.force);
}
}
crossDark(K, c[0], c[3], K.w, K.force);
crossDark(K, c[2], c[5], K.w, K.force);
crossDark(K, c[4], c[1], K.w, K.force);
if (K.tails[0].fray) frayAt(K.tIn.pts[K.tIn.pts.length - 1], K.tIn.ang, K.w, 7);
if (K.tails[1].fray) frayAt(K.tOut.pts[K.tOut.pts.length - 1], K.tOut.ang, K.w, 7);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// 1 — pale hemp air, pooled where the studies are dense and running off the lower edges
washBlob(120, 692, 218, LIT, 30, 0.62);
washBlob(26, 780, 168, HEMP, 19, 0.7);
washBlob(298, 466, 106, LIT, 9, 0.55);
washBlob(258, 800, 150, HEMP, 12, 0.66);
washBlob(408, 292, 78, LIT, 12, 0.6);
// 2 — the tone each knot throws down-right onto the paper
washBlob(196, 672, 78, UMBER, 26, 0.42);
washBlob(330, 468, 50, UMBER, 20, 0.42);
washBlob(416, 284, 30, UMBER, 14, 0.42);
brush.noFill();
brush.set("spray", UMBER, 6.5);
brush.line(112, 714, 268, 748);
// 3 — the measure taken twice before any of it was worked up
brush.set("2H", UMBER, 0.34);
brush.line(268, 556, 314, 528);
brush.line(392, 358, 428, 336);
// 4 — the three studies, tightest first, so the slack knot's darks land last
for (let i = 0; i < KNOTS.length; i++) drawKnot(KNOTS[i]);
// 5 — the rust whipping that keeps the working end from unlaying
const A = KNOTS[2];
brush.noFill();
whipAt(A.tIn.pts, 3, A.w);
brush.set("pen", RUST, 0.9);
brush.line(304, 448, 318, 458);
brush.noStroke();
brush.fillBleed(0.28, "out");
brush.fill(RUST, 118);
brush.circle(168, 706, 4.2, true);
// 6 — last darks: the slack knot's crossings struck once more
brush.noFill();
deepenKnot(A, 0.58);
deepenKnot(KNOTS[1], 0.34);
const c = crossAt(A);
crossDark(A, c[0], c[3], A.w, 1.25);
crossDark(A, c[2], c[5], A.w, 1.0);
crossDark(A, c[4], c[1], A.w, 0.78);
// the tone the sweeping leg lays down beside itself, following it rather than pooling
const leg = A.tIn.pts;
for (let i = 0; i < leg.length - 1; i++) {
brush.set("spray", random() < 0.7 ? UMBER : HEMP, A.w * (0.3 + random(0, 0.16)));
brush.line(leg[i][0] + 7, leg[i][1] + 11, leg[i + 1][0] + 7, leg[i + 1][1] + 11);
}
brush.set("spray", UMBER, 5.2);
brush.line(112, 736, 276, 772);
brush.set("2H", HEMP, 0.42);
brush.line(268, 552, 296, 540);
brush.line(392, 344, 412, 334);
brush.line(140, 512, 118, 522);
}