One Thread Short
2026 · textile
A sett is a promise kept by counting; miscount once and the cloth keeps the error honestly, compounding it row after row until the pattern admits what happened.
a tartan sett woven with one thread miscounted, so the check crowds and slips out of register toward the lower right, one weft band far darker than it should be
painted by Kimi K2.6p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: the sett runs off all four edges but it is only true along the top-left. a lost thread in the
// broad band compounds down and across, so the bands crowd and the cells go rectangular toward the lower
// right, where one weft band came through near-black and a madder guard cuts it at the 533/533 crossing.
// layering: linen washes and a lower-right shade first, then the warp threads, then the weft crossing them,
// then twill hatch in the crowded cells, and last the coal band, the madder guard, and two threads freed.
const PALETTE = [
"#e5ddc7", // ground — bare linen between the bands
"#3c5a4a", // mass — bottle green, the broad band
"#2c4257", // mass — indigo, the narrow band
"#171d1e", // shadow — the overcheck that came through far too dark
"#9a3a2b", // accent — madder guard, two threads wide, rationed
];
const LINEN = PALETTE[0];
const COL = {
green: PALETTE[1],
indigo: PALETTE[2],
coal: PALETTE[3],
madder: PALETTE[4],
};
const U = 21; // one unit of the thread count
// the sett as the pattern book has it. the warp was counted right.
const WARP_SETT = [
[null, 2.4], ["green", 4.0], [null, 0.9], ["madder", 0.32], [null, 0.9],
["indigo", 2.2], [null, 1.6], ["green", 0.8], [null, 2.9], ["indigo", 0.6], [null, 0.4],
];
// the weft was counted from the same tally after it lost a thread in the broad band —
// three units where there should be four. nothing after it can land where it should.
const WEFT_SETT = [
[null, 2.4], ["green", 3.0], [null, 0.9], ["madder", 0.32], [null, 0.9],
["indigo", 2.2], [null, 1.6], ["green", 0.8], [null, 2.9], ["indigo", 0.6], [null, 0.4],
];
const WARP_DRIFT = -1.15; // px the nth band creeps back, times n^1.35: the check crowds
const WEFT_DRIFT = -2.6; // the weft carries the miscount, so it crowds more than twice as hard
const LEAN = 12; // the warp leaning right as it falls
const SAG = 26; // the weft bowing down under the pull toward the right edge
function g(mean, sd, lo, hi) {
return constrain(randomGaussian(mean, sd), lo, hi);
}
// how fully the cloth is present here: dry and thin at the upper left, full at the lower right
function presence(x, y) {
const u = constrain(x / 800, 0, 1);
const v = constrain(y / 800, 0, 1);
const build = 0.32 + 0.34 * u + 0.42 * v + 0.17 * u * v;
return constrain(build + (noise(x * 0.0031, y * 0.0031) - 0.5) * 0.34, 0.12, 1.14);
}
// walk the sett along one axis, band by band, letting the miscount accumulate
function settRuns(sett, start, drift) {
const runs = [];
let cursor = start;
let i = 0;
for (let rep = 0; rep < 5; rep++) {
for (let s = 0; s < sett.length; s++) {
const name = sett[s][0];
const w = sett[s][1] * U;
if (name) {
const a = cursor + drift * Math.pow(i + 1, 1.35);
runs.push({ name, a, b: a + w * g(1, 0.04, 0.88, 1.14), i });
i++;
}
cursor += w;
}
}
return runs.filter((r) => r.b > -30 && r.a < 830);
}
function warpX(x, y) {
return x + LEAN * Math.pow(constrain(y, 0, 800) / 800, 1.8) + (noise(x * 0.011, y * 0.0034) - 0.5) * 8;
}
function weftY(x, y) {
return y + SAG * Math.pow(constrain(x, 0, 800) / 800, 1.9) + (noise(x * 0.0034, y * 0.011) - 0.5) * 8;
}
// one thread's colour: bleached out where the cloth runs thin, and sinking toward
// the coal of the overcheck where the weaving crowds up
function threadColour(name, x, y, dry) {
const p = presence(x, y);
const wash = constrain(g(0.5 - 0.5 * p + dry, 0.07, 0, 0.56), 0, 0.56);
const sink = constrain(g((p - 0.62) * 0.95, 0.06, 0, 0.5), 0, 0.5);
return lerpColor(lerpColor(color(COL[name]), color(LINEN), wash), color(PALETTE[3]), sink);
}
function threadBrush(name) {
if (name === "coal") return random() < 0.22 ? "charcoal" : "2B";
if (name === "madder") return random() < 0.5 ? "cpencil" : "2B";
return random() < 0.28 ? "2B" : "cpencil";
}
function warpThread(x, y0, y1, name, dry, weight) {
brush.noFill();
brush.set(threadBrush(name), threadColour(name, x, (y0 + y1) / 2, dry), weight);
brush.beginShape(0.12);
for (let k = 0; k <= 5; k++) {
const y = lerp(y0, y1, k / 5);
brush.vertex(warpX(x, y) + g(0, 0.9, -3, 3), y);
}
brush.endShape(false);
}
function weftThread(y, x0, x1, name, dry, weight) {
brush.noFill();
brush.set(threadBrush(name), threadColour(name, (x0 + x1) / 2, y, dry), weight);
brush.beginShape(0.12);
for (let k = 0; k <= 5; k++) {
const x = lerp(x0, x1, k / 5);
brush.vertex(x, weftY(x, y) + g(0, 0.9, -3, 3));
}
brush.endShape(false);
}
// a pick that came off the shuttle undyed and crosses the dark band nearly linen
function palePick(y, x0, x1, tint, weight) {
brush.noFill();
brush.set("cpencil", lerpColor(color(LINEN), color(tint), g(0.2, 0.06, 0.05, 0.36)), weight);
brush.beginShape(0.12);
for (let k = 0; k <= 5; k++) {
const x = lerp(x0, x1, k / 5);
brush.vertex(x, weftY(x, y) + g(0, 0.9, -3, 3));
}
brush.endShape(false);
}
function warpBand(run) {
const wide = run.b - run.a;
const dense = presence((run.a + run.b) / 2, 560);
const step = constrain(3.5 - 1.2 * dense + wide * 0.008, 2.3, 4.4);
const n = max(2, floor(wide / step));
// on the open left the weaving has not taken hold: those bands begin late and fray
const held = constrain(map(run.a, 30, 430, 0.04, 1), 0.04, 1);
const top = held > 0.86 ? -26 : g(330 - held * 320, 54, -28, 388);
for (let k = 0; k < n; k++) {
const x = run.a + step * (k + g(0.5, 0.16, 0.08, 0.92));
if (random() > presence(x, 470) + 0.3) continue;
const guard = run.name === "madder"; // the guard lines are rationed: some were never laid
if (guard && random() < 0.4) continue;
if (guard) {
// a guard thread runs the whole length, but it only takes the dye properly
// down where the cloth thickens — above that it is barely a blush
const split = g(432, 46, 340, 520);
warpThread(x, top + g(0, 24, -64, 74), split, "madder", g(0.5, 0.07, 0.34, 0.66), g(0.85, 0.1, 0.6, 1.1));
warpThread(x + g(0, 0.8, -2, 2), split - g(38, 14, 10, 70), 828 + g(0, 15, -34, 34), "madder", g(0.14, 0.06, 0.02, 0.3), g(1.05, 0.14, 0.75, 1.45));
continue;
}
warpThread(x, top + g(0, 24, -64, 74), 828 + g(0, 15, -34, 34), run.name, g(0.02, 0.05, -0.06, 0.16), g(1.34, 0.24, 0.8, 2.1));
}
}
function weftBand(run) {
const wide = run.b - run.a;
const dense = presence(560, (run.a + run.b) / 2);
const step = constrain(3.5 - 1.2 * dense + wide * 0.008, 2.3, 4.4);
const n = max(2, floor(wide / step));
const held = constrain(map(run.a, 60, 470, 0.04, 1), 0.04, 1);
// below the fray the weft carries to the selvedge, though not every pick reaches it
const left = held > 0.86 ? g(-14, 52, -40, 152) : g(400 - held * 390, 66, -28, 452);
for (let k = 0; k < n; k++) {
const y = run.a + step * (k + g(0.5, 0.16, 0.08, 0.92));
if (random() > presence(470, y) + 0.3) continue;
const guard = run.name === "madder";
if (guard && random() < 0.4) continue;
const weight = guard ? g(0.95, 0.12, 0.7, 1.3) : g(1.38, 0.25, 0.8, 2.15);
// the over-dipped band is only black where the loom had got up to speed
const dry = guard ? g(0.24, 0.07, 0.08, 0.42)
: run.name === "coal" ? g(0.2, 0.07, 0.02, 0.4)
: g(0.02, 0.05, -0.06, 0.16);
const stop = guard ? g(700, 90, 420, 834) : 830 + g(0, 15, -34, 34);
weftThread(y, left + g(0, 26, -70, 78), stop, run.name, dry, weight);
}
}
// where the crowding has closed a cell up, the twill diagonal starts to show through
function twillCell(wx, wy) {
const cx = (max(wx.a, -10) + min(wx.b, 810)) / 2;
const cy = (max(wy.a, -10) + min(wy.b, 810)) / 2;
const w = min(wx.b, 810) - max(wx.a, -10);
const h = min(wy.b, 810) - max(wy.a, -10);
brush.noFill();
brush.noStroke();
const tone = random() < 0.3 ? PALETTE[3] : COL[random() < 0.7 ? wy.name : wx.name];
brush.hatchStyle(random() < 0.42 ? "2H" : "cpencil", tone, g(1.1, 0.18, 0.7, 1.6));
brush.hatch(g(4.2, 0.8, 2.6, 6.6), g(26, 8, 6, 48), { rand: 0.32, continuous: false, gradient: 0.55 });
brush.rect(cx, cy + SAG * Math.pow(cx / 800, 1.9), w * 0.94, h * 0.94, "center");
brush.noHatch();
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(LINEN);
// the cloth as a whole: a cool breath through the middle, and the shade it gathers at the lower right
brush.noStroke();
brush.fillBleed(0.62, "out");
brush.fillTexture(0.42, 0.4);
brush.fill(PALETTE[1], 15);
brush.circle(452, 396, 306, true);
brush.fill(PALETTE[2], 12);
brush.circle(268, 512, 244, true);
brush.fillBleed(0.5, "out");
brush.fill(PALETTE[3], 26);
brush.circle(612, 622, 258, true);
brush.fill(PALETTE[3], 20);
brush.circle(722, 742, 196, true);
brush.fill(PALETTE[3], 15);
brush.circle(756, 542, 168, true);
brush.fill(PALETTE[4], 9);
brush.circle(548, 556, 132, true);
// the warp is set so the second guard line falls on the lower-right thirds line
const warp = settRuns(WARP_SETT, 35, WARP_DRIFT);
const weft = settRuns(WEFT_SETT, -186, WEFT_DRIFT);
// one broad weft band was over-dipped and came back nearly black; it lands on the lower thirds line
let coal = -1;
let near = 1e9;
for (let i = 0; i < weft.length; i++) {
const c = (weft[i].a + weft[i].b) / 2;
if (weft[i].name !== "green" || weft[i].b - weft[i].a < 2 * U) continue;
if (abs(c - 528) < near) {
near = abs(c - 528);
coal = i;
}
}
if (coal >= 0) {
weft[coal].name = "coal";
weft[coal].a -= 6;
weft[coal].b += 9;
}
// the warp, hung first and full height
for (let i = 0; i < warp.length; i++) warpBand(warp[i]);
// the weft laid across it — the coal band held back
for (let i = 0; i < weft.length; i++) {
if (weft[i].name !== "coal") weftBand(weft[i]);
}
// the crowded cells, chosen by hand-ish: only where the drift has already closed things up
let cells = 0;
for (let i = 0; i < warp.length && cells < 19; i++) {
for (let j = 0; j < weft.length && cells < 19; j++) {
const cx = (warp[i].a + warp[i].b) / 2;
const cy = (weft[j].a + weft[j].b) / 2;
if (cx < 420 || cy < 430 || cx > 812 || cy > 812) continue;
if (warp[i].name === "madder" || weft[j].name === "madder") continue;
if (random() < 0.42) continue;
twillCell(warp[i], weft[j]);
cells++;
}
}
// the coal band, laid last and laid heavy, running the full width and off both edges
if (coal >= 0) {
const band = weft[coal];
weftBand(band);
// a second pass across the crowded half, where the dye sat longest in the vat
const n = max(4, floor((band.b - band.a) / 3.8));
for (let k = 0; k < n; k++) {
const y = band.a + 3.8 * (k + g(0.5, 0.14, 0.1, 0.9));
weftThread(y, g(298, 128, 50, 528), 834 + g(0, 16, -36, 36), "coal", g(-0.08, 0.05, -0.22, 0.05), g(1.55, 0.26, 0.9, 2.25));
}
// the warp still rides over the top of it — the band is woven, not printed on
for (let i = 0; i < warp.length; i++) {
if (warp[i].b < 130) continue;
const over = max(2, floor((warp[i].b - warp[i].a) / 4.6));
for (let k = 0; k < over; k++) {
if (random() < 0.3) continue;
const x = warp[i].a + 4.6 * (k + g(0.5, 0.18, 0.1, 0.9));
const y0 = weftY(x, band.a) - g(3, 2, -2, 8);
const y1 = weftY(x, band.b) + g(4, 2.5, -2, 11);
brush.noFill();
brush.set(random() < 0.35 ? "2B" : "cpencil", lerpColor(color(COL[warp[i].name]), color(LINEN), g(0.5, 0.11, 0.26, 0.74)), g(1.75, 0.24, 1.1, 2.4));
brush.line(warpX(x, y0), y0, warpX(x, y1) + g(0, 1.1, -3, 3), y1);
}
}
// and two picks came through undyed altogether, laid pale straight across the dark
const mid = (band.a + band.b) / 2;
palePick(mid - 11, g(126, 60, -20, 260), 838, COL.green, 1.75);
palePick(mid + 17, g(336, 70, 180, 500), 814, COL.indigo, 1.5);
// it bled into the linen below, worst where the cloth is heaviest
brush.noStroke();
brush.fillBleed(0.56, "out");
brush.fillTexture(0.5, 0.5);
brush.fill(PALETTE[3], 24);
brush.circle(686, band.b + 30, 48, true);
brush.fill(PALETTE[3], 16);
brush.circle(556, band.b + 22, 34, true);
}
// the madder guard nearest the thirds line, redrawn crisp — and carried over the coal band,
// the one place in the cloth where the accent is allowed to sit on top of the dark
let guard = -1;
near = 1e9;
for (let i = 0; i < warp.length; i++) {
const c = (warp[i].a + warp[i].b) / 2;
if (warp[i].name !== "madder") continue;
if (abs(c - 533) < near) {
near = abs(c - 533);
guard = i;
}
}
if (guard >= 0) {
const c = (warp[guard].a + warp[guard].b) / 2;
brush.noFill();
brush.set("2B", lerpColor(color(COL.madder), color(LINEN), 0.2), 1.5);
brush.beginShape(0.1);
brush.vertex(warpX(c - 1.6, 398), 398);
brush.vertex(warpX(c, 508) + 1.2, 508);
brush.vertex(warpX(c + 1.4, 606), 606);
brush.vertex(warpX(c, 712) - 1.6, 712);
brush.endShape(false);
brush.set("cpencil", lerpColor(color(COL.madder), color(LINEN), 0.34), 1.3);
brush.line(warpX(c + 3.6, 452), 452, warpX(c + 3.2, 604) + 1.8, 604);
brush.set("2B", lerpColor(color(COL.madder), color(LINEN), 0.46), 1.1);
brush.line(warpX(c - 4.4, 344), 344, warpX(c - 4.8, 486), 486);
// and where it crosses the coal it pools, the one bright thing in the cloth
brush.noStroke();
brush.fillBleed(0.34, "out");
brush.fillTexture(0.6, 0.6);
brush.fill(COL.madder, 176);
brush.circle(warpX(c, 536) + 1, 538, 5.4, true);
brush.fill(COL.madder, 132);
brush.circle(warpX(c + 5, 560) - 2, 562, 3.8, true);
}
// two threads pulled free of the selvedge, and two slubs where the yarn thickened
brush.field("hand");
brush.set("2B", lerpColor(color(COL.green), color(LINEN), 0.22), 1.4);
brush.flowLine(724, 744, 112, 18);
brush.set("cpencil", lerpColor(color(COL.coal), color(LINEN), 0.3), 1.5);
brush.flowLine(766, 782, 86, 6);
brush.noField();
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill(PALETTE[3], 128);
brush.circle(596, 556, 3.4, true);
brush.fill(PALETTE[1], 104);
brush.circle(392, 646, 3, true);
}