Heavy Cloth, Let Fall
2026 · study
A fold is a tube, not a stripe: its lit back curves as it falls, and its two sides drop away at different rates, so the edge of the light keeps bending. Folds keep no order either — two of these run together into one on the way down.
a length of heavy cloth held along a rising hem and let fall, three deep folds catching a raking light, one of them heaping off the bottom edge, black and white chalk on toned paper
painted by GPT-5.6p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: heavy cloth held along a hem that climbs to the upper right, let fall, and landed.
// It fills the lower right two thirds; the upper left is bare. Five curving seams, none parallel,
// so fold widths at mid height run from sixty to two hundred and sixty; two converge at three
// quarters depth, pinching a narrow fold out so its neighbours become one broad fold that heaps
// off the bottom edge. The near corner has flopped forward over the fall and casts onto it. The
// top edge and left selvage are lost into the paper; only the near hem is stated, and in pieces.
// layering: pale washes; the tone as ribbons running the whole fall, so every light-to-shade
// boundary curves down its length; grain along the fall; the closings hatched broad and oblique,
// never drawn as a line; the flap and its shade; white chalk on two crests; the hem; cores last.
const PALETTE = [
"#d6c9ae", // paper — the toned sheet, and the value the cloth crosses on every fold
"#8d7e66", // mass — cloth turning away from the light
"#483e31", // shadow — a fold closing
"#1e1a16", // ink — the crease cores, struck last
"#f7f0e2", // light — white chalk heightening
"#a5522f", // sanguine — reflected warmth, rationed
];
const PAPER = PALETTE[0];
const MASS = PALETTE[1];
const DEEP = PALETTE[2];
const INK = PALETTE[3];
const LIGHT = PALETTE[4];
const SANG = PALETTE[5];
const PAPER_VAL = 0.62;
// the seams: a point on the held hem, a control point, a point on the near hem below
const SEAMS = [ { t: [326, 424], c: [251, 508], b: [236, 583] }, // the free selvage at the left
{ t: [432, 392], c: [292, 512], b: [302, 645] },
{ t: [498, 340], c: [415, 524], b: [356, 671], into: 1, m0: 0.5, m1: 0.74 },
{ t: [624, 296], c: [560, 500], b: [556, 745] },
{ t: [812, 194], c: [812, 430], b: [786, 655] }, // this one leaves by the right edge
];
// one fold between each pair of seams. crest is where the light stands, wl and wr how fast it
// falls away on either side — never equal, so the tube reads as a tube. drift swings the crest
// sideways as the fold descends. hang is how far this fold's own hem drops below the others.
const FOLDS = [ { crest: 0.48, wl: 0.2, wr: 0.32, amp: 0.8, drift: 0.12, hang: 0.05 },
{ crest: 0.44, wl: 0.18, wr: 0.26, amp: 0.88, drift: -0.1, hang: 0.02 },
{ crest: 0.4, wl: 0.19, wr: 0.3, amp: 1.0, drift: 0.14, hang: 0.2 }, // heaps off the bottom
{ crest: 0.34, wl: 0.17, wr: 0.29, amp: 0.86, drift: -0.11, hang: 0.06 }, ];
// a crease born partway down, where the cloth has widened enough to take another one
const SUBS = [{ f: 3, u: 0.64, du: -0.13, s0: 0.44, amp: 0.16, wid: 0.055 }];
// the whole length buckling against its own weight, slung across every fold it reaches
const SAGS = [ { x: 1.9, xs: 1.2, s: 0.52, tilt: 0.07, amp: 0.19, wid: 0.08 },
{ x: 3.3, xs: 0.8, s: 0.31, tilt: -0.06, amp: 0.13, wid: 0.065 },
{ x: 0.7, xs: 0.9, s: 0.76, tilt: 0.05, amp: 0.11, wid: 0.055 }, ];
// the near corner, flopped forward over the fall: where its contour runs and how deep it lies
const FLAP = { x0: 0.03, x1: 1.72, a: 0.38, b: 0.32, c: 0.26 };
function cfJitter(base, toward, mean, spread) {
return lerpColor(color(base), color(toward), constrain(randomGaussian(mean, spread), 0, 1));
}
// chalk laid thin, so a stroke mixes with what is under it instead of hiding it
function cfInk(col, a) { const c = color(col);
return color(red(c), green(c), blue(c), constrain(a, 6, 255));
}
function cfSeamRaw(i, s) { const S = SEAMS[i];
const u = constrain(s, 0, 1.14);
const a = (1 - u) * (1 - u);
const b = 2 * u * (1 - u);
const d = u * u;
return [S.t[0] * a + S.c[0] * b + S.b[0] * d, S.t[1] * a + S.c[1] * b + S.b[1] * d];
}
// a seam, after any convergence into its neighbour, and never quite a smooth curve
function cfSeam(i, s) { const p = cfSeamRaw(i, s);
const S = SEAMS[i];
if (S.into !== undefined) { const k = constrain((s - S.m0) / (S.m1 - S.m0), 0, 1);
const w = k * k * (3 - 2 * k);
const q = cfSeamRaw(S.into, s);
p[0] = lerp(p[0], q[0], w);
p[1] = lerp(p[1], q[1], w);
}
const n = (noise(i * 5.7 + 2.3, constrain(s, 0, 1.1) * 2.2) - 0.5) * 26
* (0.3 + 0.7 * Math.sin(constrain(s, 0, 1) * Math.PI));
return [p[0] + n, p[1] + n * 0.24];
}
// how far this fold's own hem falls: deepest under the crest, drawn up at each seam
function cfDepth(f, u) { const uu = constrain(u, 0, 1);
const q = Math.max(0, Math.sin(Math.PI * uu));
return 1 + FOLDS[f].hang * Math.pow(q, 0.7)
+ 0.024 * Math.sin(uu * 9.3 + f * 2.1) + 0.013 * Math.sin(uu * 21 + f);
}
function cfPoint(f, u, s) { const uu = constrain(u, 0, 1);
const ss = Math.min(constrain(s, 0, 1.12), cfDepth(f, uu));
const A = cfSeam(f, ss);
const B = cfSeam(f + 1, ss);
return [lerp(A[0], B[0], uu), lerp(A[1], B[1], uu)];
}
// one coordinate running across the whole cloth: 0 at the selvage, 4 off the right edge
function cfXPoint(X, s) { const f = constrain(Math.floor(X), 0, FOLDS.length - 1);
return cfPoint(f, X - f, s);
}
function cfXDepth(X) { const f = constrain(Math.floor(X), 0, FOLDS.length - 1);
return cfDepth(f, X - f);
}
// where the light stands on this fold at this depth — it wanders, so its edge is never straight
function cfCrest(f, s) { const F = FOLDS[f];
const ss = constrain(s, 0, 1.12);
return constrain(F.crest + F.drift * ss + 0.055 * Math.sin(ss * 4.1 + f * 1.7), 0.1, 0.9);
}
// the value of the cloth: 0 a crease core, 1 white chalk
function cfTone(f, u, s) { const F = FOLDS[f];
const uu = constrain(u, 0, 1);
const ss = constrain(s, 0, 1.12);
const c = cfCrest(f, ss);
const spread = 0.78 + 0.4 * ss; // a fold opens as it falls, so its light broadens
const d = (uu - c) / ((uu < c ? F.wl : F.wr) * spread);
let val = 0.12 + 0.76 * F.amp * Math.exp(-0.5 * d * d);
// heavy cloth shuts on itself over a broad band, not at a line
val -= 0.26 * Math.exp(-0.5 * Math.pow(uu / 0.16, 2));
val -= 0.29 * Math.exp(-0.5 * Math.pow((1 - uu) / 0.15, 2));
// reflected light climbing the far wall of each closing
val += 0.13 * Math.exp(-0.5 * Math.pow((uu - 0.92) / 0.05, 2));
val += 0.06 * Math.exp(-0.5 * Math.pow((uu - 0.06) / 0.04, 2));
val -= 0.032 * f / (FOLDS.length - 1); // the raking light thins as it crosses the cloth
// the two edges the eye is not meant to follow: they come up to the value of the paper
// and the form is lost in them
if (f === 0) val += 0.34 * Math.exp(-0.5 * Math.pow(uu / 0.17, 2));
val += 0.3 * Math.exp(-0.5 * Math.pow(ss / 0.11, 2));
val -= 0.12 * Math.exp(-0.5 * Math.pow((ss - 0.74) / 0.24, 2));
for (const k of SUBS) { if (k.f !== f) continue;
const g = constrain((ss - k.s0) / 0.15, 0, 1);
const cu = k.u + k.du * constrain((ss - k.s0) / 0.8, 0, 1);
val -= k.amp * g * Math.exp(-0.5 * Math.pow((uu - cu) / k.wid, 2));
val += 0.45 * k.amp * g
* Math.exp(-0.5 * Math.pow((uu - cu - k.wid * 2.1) / (k.wid * 1.1), 2));
}
const X = f + uu;
for (const k of SAGS) { const env = Math.exp(-0.5 * Math.pow((X - k.x) / k.xs, 2));
const cs = k.s + k.tilt * (X - k.x);
val -= k.amp * env * Math.exp(-0.5 * Math.pow((ss - cs) / k.wid, 2));
val += 0.5 * k.amp * env
* Math.exp(-0.5 * Math.pow((ss - cs + k.wid * 1.8) / (k.wid * 0.8), 2));
}
// and the shade the flopped corner throws on the cloth immediately above its contour
if (X < FLAP.x1 + 0.1) { const e = cfFlapS(X);
val -= 0.26 * Math.exp(-0.5 * Math.pow((ss - e + 0.045) / 0.055, 2));
}
val += (noise(X * 4.2 + 31.4, ss * 3.6 + 7.1) - 0.5) * 0.07;
return constrain(PAPER_VAL + (val - PAPER_VAL) * (0.82 + 0.26 * ss), 0.02, 1.02);
}
// the value scale in pigment: crease core, shadow, mass, the bare sheet, white chalk
function cfRamp(val) { const s = [[0, INK], [0.19, DEEP], [0.45, MASS], [PAPER_VAL, PAPER], [1, LIGHT]];
const c = constrain(val, 0, 1);
let i = 0;
while (i < s.length - 2 && s[i + 1][0] < c) i++;
const f = (c - s[i][0]) / (s[i + 1][0] - s[i][0]);
return lerpColor(color(s[i][1]), color(s[i + 1][1]), constrain(f, 0, 1));
}
// the contour of the flopped corner, in depth, as it runs across the cloth
function cfFlapS(X) { const q = constrain((X - FLAP.x0) / (FLAP.x1 - FLAP.x0), 0, 1);
return FLAP.a + FLAP.b * q + FLAP.c * q * q + 0.03 * Math.sin(q * 7.5 + 1.2);
}
// a point on the flopped corner: t is 0 at its contour and 1 at the near hem
function cfFlapPt(X, t) { const e = cfFlapS(X);
const d = Math.max(cfXDepth(X), e + 0.07);
return cfXPoint(constrain(X, 0, FOLDS.length - 0.01), e + (d - e) * constrain(t, 0, 1.02));
}
// its own light: a roll at the turn, dark under the roll, the near face coming up again
function cfFlapTone(X, t) { const q = constrain((X - FLAP.x0) / (FLAP.x1 - FLAP.x0), 0, 1);
let v = 0.38;
v += 0.46 * Math.exp(-0.5 * Math.pow((t - 0.12) / 0.15, 2));
v -= 0.19 * Math.exp(-0.5 * Math.pow((t - 0.46) / 0.18, 2));
v += 0.16 * Math.exp(-0.5 * Math.pow((t - 0.85) / 0.18, 2));
v -= 0.15 * q;
v += (noise(X * 3.4 + 61.2, t * 3.1 + 9.4) - 0.5) * 0.09;
return constrain(v, 0.03, 1.0);
}
// one ribbon of tone running down the fall of a fold: its two long edges follow the seams, so
// every boundary it makes with its neighbour is a curve and not a straight cut
function cfRibbon(f, u0, u1, s0, s1, alpha) { const a = [];
const b = [];
for (let k = 0; k <= 5; k++) { a.push(cfPoint(f, u0, lerp(s0, s1, k / 5)));
b.push(cfPoint(f, u1, lerp(s1, s0, k / 5)));
}
if (Math.hypot(a[0][0] - b[5][0], a[0][1] - b[5][1]) < 2.2) return;
const val = cfTone(f, (u0 + u1) / 2, (s0 + s1) / 2) + randomGaussian(0, 0.012);
brush.noStroke();
brush.fillBleed(0.24, "out");
brush.fillTexture(0.2, 0.16);
brush.fill(cfRamp(val), constrain(alpha + randomGaussian(0, 11), 10, 250));
brush.beginShape(0.15);
for (const p of a) brush.vertex(p[0], p[1]);
for (const p of b) brush.vertex(p[0], p[1]);
brush.endShape(true);
}
function cfStrips(f, scale) { const A = cfPoint(f, 0, 0.55);
const B = cfPoint(f, 1, 0.55);
return Math.round(constrain(Math.hypot(A[0] - B[0], A[1] - B[1]) / (11 / scale), 3, 17));
}
// one glaze over the whole cloth, its ribbons overlapping their neighbours on all four sides
function cfPass(ns, scale, uOff, sOff, alpha, skip, only) { for (let f = 0; f < FOLDS.length; f++) {
const nu = cfStrips(f, scale);
const du = 1 / nu;
const ds = 1 / ns;
for (let i = 0; i < nu; i++) { for (let j = 0; j < ns; j++) {
const u0 = constrain((i + uOff) * du - du * 0.45, 0, 1);
const u1 = constrain((i + uOff + 1) * du + du * 0.45, 0, 1);
const s0 = constrain((j + sOff) * ds - ds * 0.4, 0, 1);
const s1 = constrain((j + sOff + 1) * ds + ds * 0.4, 0, 1.02);
if (u1 - u0 < du * 0.5 || s1 - s0 < ds * 0.5) continue;
const val = cfTone(f, (u0 + u1) / 2, (s0 + s1) / 2);
if (only && (val < only[0] || val > only[1])) continue;
if (random() < skip) continue;
cfRibbon(f, u0, u1, s0, s1, alpha);
}
}
}
}
// a band that follows a curve across the cloth instead of a rectangle in one fold
function cfBand(x0, x1, sA, sB, col, alpha) { const n = Math.max(3, Math.round((x1 - x0) * 8));
brush.noStroke();
brush.fillBleed(0.24, "out");
brush.fillTexture(0.26, 0.22);
brush.fill(col, alpha);
brush.beginShape(0.14);
for (let k = 0; k <= n; k++) { const X = lerp(x0, x1, k / n);
const p = cfXPoint(constrain(X, 0, FOLDS.length - 0.01), sA(X));
brush.vertex(p[0], p[1]);
}
for (let k = n; k >= 0; k--) { const X = lerp(x0, x1, k / n);
const p = cfXPoint(constrain(X, 0, FOLDS.length - 0.01), sB(X));
brush.vertex(p[0], p[1]);
}
brush.endShape(true);
}
// one stroke running down the fall of a fold; uAt gives where it sits across the fold at depth s
function cfRun(f, s0, s1, uAt, col, w, name, curv, jit) { const a = constrain(s0, 0, 1.02);
const b = constrain(s1, a + 0.06, 1.12);
const steps = Math.max(3, Math.round((b - a) * 18));
brush.noFill();
brush.set(name, col, Math.max(0.3, w));
brush.beginShape(curv);
for (let k = 0; k <= steps; k++) { const s = a + ((b - a) * k) / steps;
const p = cfPoint(f, uAt(s), s);
brush.vertex(p[0] + randomGaussian(0, jit), p[1] + randomGaussian(0, jit));
}
brush.endShape(false);
}
// no mark on this cloth is one unbroken length: it is laid in pieces, paper showing between
function cfBroken(f, s0, s1, uAt, col, w, name, jit, pieces) { const step = (s1 - s0) / pieces;
let s = s0;
for (let i = 0; i < pieces; i++) { const len = step * constrain(randomGaussian(0.62, 0.18), 0.24, 0.96);
if (s + len > s1) break;
cfRun(f, s, s + len, uAt, col, w * constrain(randomGaussian(1, 0.24), 0.45, 1.6), name, 0.2, jit);
s += len + step * constrain(randomGaussian(0.42, 0.18), 0.06, 0.9);
if (s >= s1) break;
}
}
// chalk worked across a closing: the only way a passage on this paper gets genuinely dark, and
// the reason a closing reads as a broad turning of the cloth and not as a drawn wire
function cfHatch(f, uc, uspan, s0, s1, n, col, w, name, slant, dir) { brush.noFill();
for (let i = 0; i < n; i++) {
const s = constrain(s0 + ((s1 - s0) * (i + randomGaussian(0, 0.42))) / n, 0, 1.06);
const ua = uc + randomGaussian(0, uspan);
// every stroke in a pass runs the same way, out of the closing and toward the light, so
// the hatching lies as one direction of chalk and never crosses itself into a mesh
const len = uspan * constrain(randomGaussian(1.6, 0.5), 0.4, 2.6)
* dir * (random() < 0.16 ? -0.6 : 1);
const sl = slant * constrain(randomGaussian(1, 0.4), 0.15, 1.9);
brush.set(name, col, w * constrain(randomGaussian(1, 0.22), 0.35, 1.8));
brush.beginShape(0.4);
for (let k = 0; k <= 3; k++) { const q = k / 3;
const p = cfPoint(f, ua + len * q, constrain(s + sl * q, 0, 1.06));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
}
}
// a mark lying across the cloth, riding over the crests and sinking into the closings
function cfCrossX(x0, x1, s, col, w, name) { const sag = randomGaussian(0.024, 0.01);
brush.noFill();
brush.set(name, col, Math.max(0.3, w));
brush.beginShape(0.55);
const n = Math.max(4, Math.round((x1 - x0) * 7));
for (let k = 0; k <= n; k++) { const q = k / n;
const p = cfXPoint(constrain(lerp(x0, x1, q), 0, FOLDS.length - 0.01),
s + sag * Math.sin(q * Math.PI) + randomGaussian(0, 0.004));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
}
// a mark following the flopped corner, at a fixed place between its contour and the hem
function cfFlapRun(x0, x1, t, col, w, name, jit) { brush.noFill();
brush.set(name, col, Math.max(0.3, w));
brush.beginShape(0.35);
const n = Math.max(3, Math.round((x1 - x0) * 8));
for (let k = 0; k <= n; k++) { const p = cfFlapPt(lerp(x0, x1, k / n), t + randomGaussian(0, jit));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
}
// a point on the near hem, off pixels below it along the line of the fall
function cfEdgePt(X, off) { const XX = constrain(X, 0, FOLDS.length - 0.01);
const d = cfXDepth(XX);
const p = cfXPoint(XX, d);
const q = cfXPoint(XX, d - 0.07);
const m = Math.hypot(p[0] - q[0], p[1] - q[1]) || 1;
return [p[0] + ((p[0] - q[0]) / m) * off, p[1] + ((p[1] - q[1]) / m) * off];
}
function cfHemRun(x0, x1, off, col, name, w, jit) { brush.noFill();
brush.set(name, col, w);
brush.beginShape(0.4);
const n = Math.max(3, Math.round((x1 - x0) * 9));
for (let k = 0; k <= n; k++) { const p = cfEdgePt(lerp(x0, x1, k / n), off + randomGaussian(0, jit));
brush.vertex(p[0] + randomGaussian(0, 1.4), p[1] + randomGaussian(0, 1.4));
}
brush.endShape(false);
}
// the near hem turned back on itself: a band of cloth whose roll faces up out of the picture
function cfHemBand(x0, x1, o0, o1, col, alpha) { const n = Math.max(3, Math.round((x1 - x0) * 7));
brush.noStroke();
brush.fillBleed(0.22, "out");
brush.fillTexture(0.34, 0.3);
brush.fill(col, alpha);
brush.beginShape(0.2);
for (let k = 0; k <= n; k++) brush.vertex(...cfEdgePt(lerp(x0, x1, k / n), o0));
for (let k = n; k >= 0; k--) brush.vertex(...cfEdgePt(lerp(x0, x1, k / n), o1));
brush.endShape(true);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(1.7);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
const NF = FOLDS.length;
// 1 — the mass of the cloth put in as pale washes before any fold is found
const WASH = [[470, 566, 206, 30], [664, 604, 184, 24], [304, 636, 128, 20],
[706, 306, 132, 22], [566, 772, 152, 18], [792, 520, 138, 16], [408, 452, 96, 12]];
brush.noStroke();
for (const w of WASH) { brush.fillBleed(0.62, "out");
brush.fillTexture(0.24, 0.28);
brush.fill(cfJitter(MASS, PAPER, 0.3, 0.1), w[3]);
brush.circle(w[0] + randomGaussian(0, 6), w[1] + randomGaussian(0, 6),
w[2] * constrain(randomGaussian(1, 0.06), 0.86, 1.14), true);
}
// 2 — the tone as long ribbons down the fall, glazed four times at offsets so it ramps
// instead of stepping, then the closings and the lit backs each pressed again
cfPass(7, 1.0, 0, 0, 205, 0.04);
cfPass(7, 1.0, 0.5, 0.5, 172, 0.06);
cfPass(5, 0.7, 0.28, 0.3, 128, 0.1);
cfPass(9, 1.3, 0.7, 0.62, 96, 0.14);
cfPass(5, 1.5, 0.15, 0.35, 190, 0.04, [0, 0.34]);
cfPass(6, 1.8, 0.6, 0.1, 178, 0.05, [0, 0.2]);
cfPass(4, 2.0, 0.35, 0.6, 165, 0.06, [0, 0.12]);
cfPass(6, 1.6, 0.2, 0.18, 150, 0.06, [0.74, 1.05]);
cfPass(8, 2.2, 0.66, 0.55, 120, 0.1, [0.8, 1.05]);
// 3 — the grain of the weave, taken along the fall so it follows the curve of each fold
for (let k = 0; k < 104; k++) { const f = Math.floor(random(NF));
const u0 = random();
const s0 = constrain(randomGaussian(0.5, 0.28), 0.02, 0.9);
const len = constrain(randomGaussian(0.26, 0.14), 0.09, 0.52);
const dr = randomGaussian(0, 0.09);
const val = cfTone(f, u0, s0 + len * 0.5)
+ randomGaussian(0, 0.05) * (random() < 0.45 ? 1 : -1);
cfRun(f, s0, Math.min(1.06, s0 + len), (s) => u0 + dr * (s - s0),
cfInk(cfRamp(constrain(val, 0.03, 1)), 16),
constrain(randomGaussian(3.4, 1.2), 1.2, 6), "cpencil", 0.2, 0.5);
}
// 4 — the closings worked dark by hatching across them, five passes each reaching a
// different distance out of the closing, so the shade is a broad turning and not a wire
for (let f = 0; f < NF; f++) {
// uc, uspan, count, weight, brush, slant, ink-bias, alpha, direction out of the closing
const HB = [[0.9, 0.07, 80, 1.3, "cpencil", 0.05, 0.34, 32, -1],
[0.96, 0.045, 64, 1.0, "cpencil", 0.04, 0.2, 36, -1], [0.98, 0.03, 46, 0.9, "2B", 0.03, 0.1, 40, -1],
[0.99, 0.018, 28, 0.8, "charcoal", 0.022, 0.05, 42, -1],
[0.79, 0.075, 58, 1.4, "cpencil", 0.055, 0.5, 26, -1],
[0.06, 0.055, 50, 1.1, "cpencil", 0.045, 0.4, 30, 1], [0.02, 0.03, 30, 0.9, "2B", 0.03, 0.24, 34, 1]];
for (const h of HB) { cfHatch(f, h[0], h[1], 0.06, 1.05, h[2],
cfInk(cfJitter(DEEP, INK, h[6], 0.09), h[7]), h[3], h[4], h[5], h[8]);
}
// and touched once at the core, in short pieces only
for (let g = 0; g < 2; g++) { cfBroken(f, constrain(randomGaussian(0.4 + g * 0.12, 0.12), 0.1, 0.68),
constrain(randomGaussian(1.0, 0.06), 0.82, 1.05),
(s) => 0.985 - g * 0.012, cfInk(cfJitter(DEEP, INK, 0.28 + g * 0.2, 0.08), 66),
constrain(randomGaussian(1.6 - g * 0.3, 0.3), 0.6, 2.2), g === 0 ? "2B" : "charcoal", 0.7, 3);
}
}
// the crease born partway down, where the cloth has widened enough to take another
for (const k of SUBS) { const uAt = (s) => k.u + k.du * constrain((s - k.s0) / 0.8, 0, 1);
cfHatch(k.f, k.u - 0.02, 0.055, k.s0 + 0.06, 1.04, 46,
cfInk(cfJitter(DEEP, INK, 0.3, 0.09), 34), 1.3, "cpencil", 0.07, -1);
for (let g = 0; g < 2; g++) { cfBroken(k.f, k.s0 + 0.08 + g * 0.06, 1.03, (s) => uAt(s) + (g - 0.5) * 0.01,
cfInk(cfJitter(DEEP, INK, 0.26 + g * 0.2, 0.07), 62),
constrain(randomGaussian(1.5 - g * 0.3, 0.3), 0.6, 2.1), "2B", 0.7, 3);
}
cfBroken(k.f, k.s0 + 0.12, 1.02, (s) => uAt(s) + k.wid * 2.5, cfInk(cfJitter(LIGHT, PAPER, 0.4, 0.08), 70),
constrain(randomGaussian(2.2, 0.5), 0.9, 3.4), "cpencil", 0.6, 3);
}
// 5 — the buckles, touched once each so the eye reads the cloth as one length
for (const k of SAGS) { cfCrossX(Math.max(0, k.x - k.xs * 0.9), Math.min(NF - 0.01, k.x + k.xs * 0.9),
k.s + randomGaussian(0, 0.01),
cfInk(cfRamp(constrain(cfTone(Math.floor(k.x), 0.5, k.s) - 0.1, 0.05, 0.9)), 36),
constrain(randomGaussian(2.6, 0.7), 1.2, 4), "cpencil");
}
for (let k = 0; k < 9; k++) { const x = constrain(randomGaussian(2.4, 1.0), 0.15, NF - 0.3);
const s = constrain(randomGaussian(0.6, 0.2), 0.16, 1.02);
const sp = constrain(randomGaussian(0.24, 0.09), 0.1, 0.42);
const dk = random() < 0.55;
cfCrossX(x - sp, x + sp, s, cfInk(cfRamp(constrain(cfTone(Math.floor(x), x - Math.floor(x), s)
+ (dk ? -0.14 : 0.2), 0.04, 0.95)), dk ? 66 : 86), constrain(randomGaussian(1.4, 0.4), 0.6, 2.4),
dk ? (random() < 0.5 ? "2B" : "charcoal") : "cpencil");
}
// 6 — the white heightening, rationed: a few short touches on the two crests that matter,
// laid at slightly different places across each so the light sits on a turning surface
const HI = [[2, 0.14, 1.04, 3.4, 96, 5], [3, 0.2, 1.02, 2.6, 80, 4], [1, 0.14, 0.78, 2.0, 62, 3]];
for (const h of HI) { const f = h[0];
for (let g = 0; g < 4; g++) { cfBroken(f, h[1] + g * 0.05, h[2] - g * 0.03,
(s) => cfCrest(f, s) + (g - 1.5) * 0.045,
cfInk(cfJitter(LIGHT, PAPER, 0.16 + Math.abs(g - 1.5) * 0.2, 0.05), h[4] - g * 8),
h[3] * (1 - g * 0.12), "cpencil", 0.6, h[5]);
}
}
// the one passage the sheet is built around: the broad fold at the lower right third
for (let g = 0; g < 3; g++) {
cfBroken(2, 0.34 + g * 0.04, 0.7 - g * 0.04, (s) => cfCrest(2, s) - 0.008 + g * 0.01,
cfInk(cfJitter(LIGHT, PAPER, 0.04, 0.03), 118 + g * 34), 2.6 - g * 0.6, "cpencil", 0.5, 2);
}
// 7 — the held hem at the top, stated twice only and lost in between
for (let i = 0; i < 2; i++) { const a = 0.42 + i * 0.36;
brush.noFill();
brush.set("cpencil", cfInk(cfJitter(LIGHT, PAPER, 0.2 + i * 0.14, 0.05), 130), 1.8 - i * 0.5);
brush.beginShape(0.35);
for (let k = 0; k <= 5; k++) {
const p = cfXPoint(constrain(lerp(a, a + 0.26, k / 5) * NF, 0, NF - 0.01), 0.008);
brush.vertex(p[0] + randomGaussian(0, 2), p[1] - 2 + randomGaussian(0, 2));
}
brush.endShape(false);
}
for (const f of [2, 3]) { const p = cfSeam(f, 0.014);
const q = cfSeam(f, 0.1);
brush.noFill();
brush.set("2B", cfInk(cfJitter(INK, DEEP, 0.34, 0.08), 130), constrain(randomGaussian(1.6, 0.3), 0.7, 2.2));
brush.line(p[0] + randomGaussian(0, 1.4), p[1] - 1, q[0] + randomGaussian(0, 1.8), q[1]);
}
// 8 — the near corner, flopped forward over the fall. Its own light, its own roll, and the
// contour where it leaves the cloth behind it — the one place a real edge is wanted.
for (let g = 0; g < 3; g++) { const nt = 9;
for (let i = 0; i < nt; i++) { const t0 = constrain((i - 0.4 + g * 0.28) / nt, 0, 1);
const t1 = constrain((i + 1.4 + g * 0.28) / nt, 0, 1.02);
if (t1 - t0 < 0.05) continue;
for (let j = 0; j < 3; j++) { const x0 = FLAP.x0 + ((FLAP.x1 - FLAP.x0) * (j - 0.16 + g * 0.1)) / 3;
const x1 = FLAP.x0 + ((FLAP.x1 - FLAP.x0) * (j + 1.16 + g * 0.1)) / 3;
const val = cfFlapTone((x0 + x1) / 2, (t0 + t1) / 2) + randomGaussian(0, 0.014);
cfBand(Math.max(FLAP.x0, x0), Math.min(FLAP.x1, x1),
(X) => cfFlapS(X) + (Math.max(cfXDepth(X), cfFlapS(X) + 0.07) - cfFlapS(X)) * t0,
(X) => cfFlapS(X) + (Math.max(cfXDepth(X), cfFlapS(X) + 0.07) - cfFlapS(X)) * t1,
cfRamp(val), constrain(190 - g * 44 + randomGaussian(0, 10), 20, 240));
}
}
}
// the roll of the turn takes the light; under it the cloth goes to nothing
for (let g = 0; g < 4; g++) { const a = FLAP.x0 + random(0, 0.5);
cfFlapRun(a, Math.min(FLAP.x1, a + random(0.5, 1.1)), 0.09 + (g - 1.5) * 0.035,
cfInk(cfJitter(LIGHT, PAPER, 0.08 + Math.abs(g - 1.5) * 0.2, 0.05), 118 - g * 10),
constrain(randomGaussian(3 - g * 0.4, 0.4), 1, 4), "cpencil", 0.012);
}
for (let g = 0; g < 26; g++) { const a = FLAP.x0 + random(0, FLAP.x1 - FLAP.x0 - 0.2);
cfFlapRun(a, a + random(0.12, 0.34), constrain(randomGaussian(0.44, 0.11), 0.22, 0.68),
cfInk(cfJitter(DEEP, INK, 0.34, 0.1), 46),
constrain(randomGaussian(1.5, 0.4), 0.6, 2.4), g % 3 === 0 ? "2B" : "cpencil", 0.02);
}
// the shade it throws on the cloth above its contour, hatched up into the fall
for (let g = 0; g < 90; g++) { const X = constrain(randomGaussian(1.0, 0.66), FLAP.x0, FLAP.x1);
const e = cfFlapS(X);
const dx = randomGaussian(0, 0.09);
const dep = constrain(randomGaussian(0.07, 0.035), 0.02, 0.16);
brush.noFill();
brush.set(g % 4 === 0 ? "2B" : "cpencil", cfInk(cfJitter(DEEP, INK, 0.34, 0.1), 34),
constrain(randomGaussian(1.3, 0.35), 0.5, 2.2));
brush.beginShape(0.4);
for (let k = 0; k <= 3; k++) { const q = k / 3;
const p = cfXPoint(constrain(X + dx * q, 0, NF - 0.01), constrain(e - 0.01 - dep * q, 0, 1.05));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
}
// the contour itself, in three pieces with the middle one lost
const CT = [[0.26, 0.74], [0.9, 1.3], [1.52, 1.88]];
for (const c of CT) { for (let g = 0; g < 3; g++) { brush.noFill();
brush.set(g === 0 ? "charcoal" : "2B", cfInk(cfJitter(INK, DEEP, 0.14 + g * 0.18, 0.06), 120),
constrain(randomGaussian(1.7 - g * 0.4, 0.3), 0.6, 2.4));
brush.beginShape(0.35);
const n = Math.max(3, Math.round((c[1] - c[0]) * 9));
for (let k = 0; k <= n; k++) { const p = cfFlapPt(lerp(c[0], c[1], k / n), 0.004 + g * 0.006);
brush.vertex(p[0] + randomGaussian(0, 1.2), p[1] + randomGaussian(0, 1.2));
}
brush.endShape(false);
}
}
// 9 — the near hem: turned back on itself, stated in pieces, and the shade it throws down
// onto whatever the cloth has landed on
cfHemBand(0.04, 1.36, -4, 18, cfJitter(MASS, PAPER, 0.34, 0.08), 140);
cfHemBand(2.62, 3.96, -4, 22, cfJitter(MASS, PAPER, 0.28, 0.08), 132);
cfHemBand(0.2, 1.2, 2, 13, cfJitter(MASS, DEEP, 0.58, 0.1), 84);
cfHemBand(2.8, 3.8, 2, 14, cfJitter(MASS, DEEP, 0.54, 0.1), 80);
const HEM = [[0.06, 0.56], [0.7, 1.1], [1.18, 1.38], [2.66, 3.06], [3.2, 3.62], [3.72, 3.96]];
for (let i = 0; i < HEM.length; i++) { const h = HEM[i];
cfHemRun(h[0], h[1], 2 + (i % 2), cfInk(cfJitter(LIGHT, PAPER, 0.14 + (i % 3) * 0.1, 0.06),
166 - (i % 3) * 22), "cpencil", 2.6 - (i % 3) * 0.5, 1.4);
for (let g = 0; g < 2; g++) { const a = lerp(h[0], h[1], random(0, 0.28));
const b = lerp(h[0], h[1], random(0.62, 1));
cfHemRun(a, b, 17 - g * 4 + randomGaussian(0, 1.6), cfInk(cfJitter(INK, DEEP, 0.22 + g * 0.2, 0.07), 128),
g === 0 ? "charcoal" : "2B", constrain(randomGaussian(1.7 - g * 0.5, 0.3), 0.6, 2.3), 1.4);
}
}
for (let k = 0; k < 34; k++) { const X = random() < 0.5 ? constrain(randomGaussian(0.7, 0.5), 0.04, 1.38)
: constrain(randomGaussian(3.3, 0.5), 2.64, NF - 0.05);
const a = cfEdgePt(X, constrain(randomGaussian(24, 6), 16, 38));
const b = cfEdgePt(X, constrain(randomGaussian(46, 10), 28, 68));
brush.noFill();
brush.set("spray", cfInk(cfJitter(DEEP, PAPER, 0.52, 0.12), 50),
constrain(randomGaussian(9, 2.2), 3.5, 14));
brush.line(a[0], a[1], b[0], b[1]);
}
// 10 — the crease cores, near black and last, three short passages only
const CORE = [[2, 0.64, 0.9], [3, 0.34, 0.56]];
for (const c of CORE) { cfHatch(c[0], 0.965, 0.03, c[1], c[2], 30,
cfInk(cfJitter(INK, DEEP, 0.12, 0.06), 52), 1.2, "charcoal", 0.035, -1);
cfBroken(c[0], c[1], c[2], (s) => 0.988,
cfInk(cfJitter(INK, DEEP, 0.08, 0.05), 110), 1.6, "charcoal", 0.6, 2);
}
// 11 — sanguine, four notes of warmth climbing out of the closings
cfCrossX(2.84, 3.02, 0.42, cfInk(cfJitter(SANG, DEEP, 0.3, 0.07), 130), 1.2, "cpencil");
cfCrossX(3.4, 3.56, 0.66, cfInk(cfJitter(SANG, PAPER, 0.24, 0.06), 120), 1.0, "cpencil");
cfBroken(2, 0.72, 0.98, (s) => 0.95, cfInk(cfJitter(SANG, PAPER, 0.36, 0.08), 126), 1.3, "cpencil", 0.6, 2);
cfFlapRun(0.7, 1.05, 0.4, cfInk(cfJitter(SANG, DEEP, 0.34, 0.08), 118), 1.1, "cpencil", 0.01);
}