From 5fbdfba4f170d8b42ea0bde44b39f93edc5ceffe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 12:44:39 +0000 Subject: [PATCH 01/16] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC=2031=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk From f27f05ebac15f6d347f49c322f29369477694f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:14:16 +0000 Subject: [PATCH 02/16] =?UTF-8?q?31=ED=8C=90:=20Gaussian=20Moat=20?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/gaussian-moat.js | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 incoming/pattern-study/patterns/gaussian-moat.js diff --git a/incoming/pattern-study/patterns/gaussian-moat.js b/incoming/pattern-study/patterns/gaussian-moat.js new file mode 100644 index 000000000..3a4a2e3b4 --- /dev/null +++ b/incoming/pattern-study/patterns/gaussian-moat.js @@ -0,0 +1,449 @@ +// ===== Patternflow pattern ===== +// Title: Gaussian Moat +// Family: number +// Idea: Every LED is a Gaussian integer with the origin at the bottom-left corner, the Gaussian primes glow as faint dots, and a breadth-first flood from 1+i hops from prime to prime in steps no longer than the square root of k, growing a branching coral coloured by its depth until it meets a moat, where its shore pulses and k rises to the next possible step so the flood can cross. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the Gaussian moat problem (Basil Gordon, 1962), pattern round 31 +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Step=1.5..4.5, Reach=0..1 +// +// Knob 1: Hue. Turns the flood's colours round the wheel; at 0 the coral runs from gold at 1 + i through green to cyan and back over every 72 steps of depth, across pale blue prime dots. +// Knob 2: Speed. At 2 the flood reaches 56 primes a second near 1 + i, faster as its frontier widens, and each moat pulses for 0.7 s; at 0.2 everything runs ten times slower. +// Knob 3: Step. The first step length: at 1.5 the flood starts with k = 2 and meets moats at k = 2, 4, 5, 8, 9 and 13 before the last primes in the window join at 16; at 4.5 it starts at k = 20 and fans across the window in one go. +// Knob 4: Reach. Moves the window out along the diagonal: the corner is 0 + 0i up to 0.1, then from 8 + 8i to 4800 + 4800i at 1, where primes are half as dense and the corner prime stays trapped until k = 20. Each restart moves the corner a further 24, then 140, before coming back. +// Buttons: K1 tap raises k to the next step at once, so the flood jumps the moat it is working along. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, abs, sin, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The squared lengths a step between two Gaussian integers can have: the +// sums of two squares, 1 left out since no two odd primes sit 1 apart. +const KS = []; +for (let n = 2; n <= 50; n++) { + let ok = false; + for (let a = 0; a * a <= n && !ok; a++) { + const b = Math.round(sqrt(n - a * a)); + if (a * a + b * b === n) ok = true; + } + if (ok) KS.push(n); +} +const KMAX = 50; + +// Every offset with dx^2 + dy^2 <= 50, nearest first. A flood with step k +// scans the first NK[k] of them; the moat line reads the first R49. +const OD = []; +for (let dy = -7; dy <= 7; dy++) + for (let dx = -7; dx <= 7; dx++) { + const d = dx * dx + dy * dy; + if (d <= KMAX) OD.push([d, dx, dy]); + } +OD.sort((p, q) => p[0] - q[0] || p[2] - q[2] || p[1] - q[1]); +const ODX = new Int8Array(OD.length), ODY = new Int8Array(OD.length), ODD = new Uint8Array(OD.length); +OD.forEach((o, i) => { + ODD[i] = o[0]; + ODX[i] = o[1]; + ODY[i] = o[2]; +}); +const NK = new Uint16Array(KMAX + 1); +for (let k = 0; k <= KMAX; k++) { + let n = 0; + while (n < OD.length && ODD[n] <= k) n++; + NK[k] = n; +} +let R49 = 0; +while (R49 < OD.length && ODD[R49] <= 49) R49++; + +// Rational primes up to 7300 by a sieve, enough to test norms up to +// 2 * 5072^2 by trial division, the far corner at Reach 1 plus the cycle. +const SP = []; +{ + const M = 7300, c = new Uint8Array(M + 1); + for (let i = 2; i <= M; i++) { + if (c[i]) continue; + SP.push(i); + for (let j = i * i; j <= M; j += i) c[j] = 1; + } +} +function isPrime(n) { + if (n < 2) return false; + for (let i = 0; i < SP.length; i++) { + const p = SP[i]; + if (p * p > n) return true; + if (n % p === 0) return n === p; + } + return true; +} +// a + bi with a, b >= 0 is a Gaussian prime when its norm is a prime, or when +// it lies on an axis and is a prime of the form 4n + 3. +function gaussPrime(a, b) { + if (a === 0) return b % 4 === 3 && isPrime(b); + if (b === 0) return a % 4 === 3 && isPrime(a); + return isPrime(a * a + b * b); +} + +function hsv(h, s, v, out, o) { + const f = (n) => { + const k = (n + (h - floor(h)) * 6) % 6; + return v - v * s * max(0, min(k, 4 - k, 1)); + }; + out[o] = f(5) * 255; + out[o + 1] = f(3) * 255; + out[o + 2] = f(1) * 255; +} + +// Reach: the corner stays at the origin up to 0.1, then moves out along the +// diagonal from 8 + 8i to 4800 + 4800i on a log scale. Each restart adds the +// next of CYCLE, so the window wanders out where primes thin and comes back. +const reachOf = (u) => (u <= 0.1 ? 0 : Math.round(8 * 600 ** ((u - 0.1) / 0.9))); +const CYCLE = [0, 24, 140]; +// The first k for a Step: the largest step length squared that fits. +function firstK(step) { + let k = KS[0]; + for (const q of KS) if (q <= step * step + 1e-9) k = q; + return k; +} +const nextK = (k) => { + for (const q of KS) if (q > k) return q; + return KMAX + 1; +}; + +const DMAX = 1024; + +export function setup(params) { + params.s = { + pr: new Uint8Array(N), dep: new Int16Array(N), par: new Int32Array(N), q: new Int32Array(N), born: new Float32Array(N), + ea: new Float32Array(N), ed: new Uint16Array(N), wa: new Float32Array(N), wash: new Float32Array(3), moat: new Float32Array(N), shore: [], grow: [], tg: 0.3, sp: 2, hueK: 0, startT: -1, + nPrime: 0, qlen: 0, lastQ: 0, plen: 1, head: 0, scan: 0, k: 2, k0: 2, off: -1, base: -1, stage: 0, + mode: 0, timer: 0, acc: 0, T: 0, fade: 1, moatA: 0, + hue: -1, pal: new Float32Array(DMAX * 3), dot: new Float32Array(3), buf: new Float32Array(N * 3), + }; +} + +// Lays out the window with its corner at off + off*i and seeds the flood at +// the prime nearest the corner: 1 + i when the corner is the origin. +function build(S, off) { + S.off = off; + S.nPrime = 0; + let seed = -1, best = 1e9; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, b = H - 1 - y, g = gaussPrime(x + off, b + off) ? 1 : 0; + S.pr[p] = g; + if (g) { + S.nPrime++; + const d = x * x + b * b; + if (d < best) (best = d), (seed = p); + } + } + S.dep.fill(-1); + S.born.fill(-10); + S.ea.fill(0); + S.wa.fill(0); + S.moat.fill(0); + S.grow.length = 0; + S.shore = []; + S.qlen = 0; + S.head = 0; + S.scan = 0; + S.k = S.k0; + S.mode = 0; + S.timer = 0; + S.acc = 0; + S.moatA = 0; + S.lastQ = 0; + // A restart fades in over 0.6 s; the first window is there at once. + S.startT = S.T > 0 ? S.T : -1; + if (seed >= 0) { + S.dep[seed] = 0; + S.par[seed] = seed; + S.born[seed] = S.T; + S.q[S.qlen++] = seed; + soak(S, seed); + } +} + +// The next prime the flood reaches: breadth first over the cluster, each +// prime scanning its neighbours nearest first out to distance sqrt(k). +// Only primes inside the window count, so a moat here is a moat of the +// window: a prime just outside it could bridge one. +function pull(S) { + const n = NK[min(S.k, KMAX)], pr = S.pr, dep = S.dep; + while (S.head < S.qlen) { + const p = S.q[S.head], x = p & 63, y = p >> 6; + while (S.scan < n) { + const i = S.scan++, X = x + ODX[i], Y = y + ODY[i]; + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const c = Y * W + X; + if (pr[c] && dep[c] < 0) { + dep[c] = min(DMAX - 1, dep[p] + 1); + S.par[c] = p; + S.born[c] = S.T; + S.q[S.qlen++] = c; + return c; + } + } + S.head++; + S.scan = 0; + } + return -1; +} + +// A segment a LED wide, soft at its sides, into the edge layer: each LED keeps +// the strongest cover and the depth of the edge that gave it. +function stamp(S, x0, y0, x1, y1, d, ea, ed) { + const vx = x1 - x0, vy = y1 - y0, L2 = vx * vx + vy * vy || 1; + const xa = max(0, floor(min(x0, x1)) - 1), xb = min(W - 1, Math.ceil(max(x0, x1)) + 1); + const ya = max(0, floor(min(y0, y1)) - 1), yb = min(H - 1, Math.ceil(max(y0, y1)) + 1); + for (let y = ya; y <= yb; y++) + for (let x = xa; x <= xb; x++) { + const t = cl(((x - x0) * vx + (y - y0) * vy) / L2, 0, 1), ex = x0 + vx * t - x, ey = y0 + vy * t - y; + const a = 1 - sqrt(ex * ex + ey * ey) * 1.1; + if (a <= 0) continue; + const p = y * W + x; + if (a > ea[p]) { + ea[p] = a; + if (ed) ed[p] = d; + } + } +} + +// The ground the flood holds: a faint wash out to 2 LEDs round each reached +// prime, so a moat shows as the dark band between the wash and the dots. +function soak(S, p) { + const x0 = p & 63, y0 = p >> 6, wa = S.wa; + for (let dy = -2; dy <= 2; dy++) + for (let dx = -2; dx <= 2; dx++) { + const x = x0 + dx, y = y0 + dy; + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const a = cl(1.15 - sqrt(dx * dx + dy * dy) * 0.38, 0, 1), q = y * W + x; + if (a > wa[q]) wa[q] = a; + } +} + +// The flood has run out. The shore is every reached prime with an unreached +// one within the next step; the moat line runs where the nearest reached +// prime and the nearest unreached one are equally far. +function meetMoat(S) { + const kn = min(nextK(S.k), KMAX), nk = NK[kn], pr = S.pr, dep = S.dep; + S.shore = []; + for (let h = 0; h < S.qlen; h++) { + const p = S.q[h], x = p & 63, y = p >> 6; + for (let i = 1; i < nk; i++) { + const X = x + ODX[i], Y = y + ODY[i]; + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const c = Y * W + X; + if (pr[c] && dep[c] < 0) { + S.shore.push(p); + break; + } + } + } + const m = S.moat; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let dr = -1, du = -1; + for (let i = 0; i < R49 && (dr < 0 || du < 0); i++) { + const X = x + ODX[i], Y = y + ODY[i]; + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const c = Y * W + X; + if (!pr[c]) continue; + if (dep[c] >= 0) { + if (dr < 0) dr = ODD[i]; + } else if (du < 0) du = ODD[i]; + } + m[y * W + x] = dr > 0 && du > 0 ? max(0, 1 - abs(sqrt(dr) - sqrt(du)) * 0.8) : 0; + } + // A step that crossed nothing pulses for a third as long, so a prime + // trapped in a corner runs through its empty steps quickly. + S.plen = S.qlen > S.lastQ ? 1 : 0.35; + S.lastQ = S.qlen; +} + +function raise(S) { + S.k = nextK(S.k); + S.head = 0; + S.scan = 0; + S.mode = S.k > KMAX ? 2 : 0; + S.timer = 0; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, kv = input.knobValues || [0, 2, 1.5, 0.06], d = cl(dt || 0, 0, 0.1); + const sp = cl(kv[1], 0.2, 3), k0 = firstK(cl(kv[2], 1.5, 4.5)), base = reachOf(cl(kv[3], 0, 1)); + S.T += d; + S.hueK = kv[0] || 0; + if (base !== S.base) { + S.base = base; + S.stage = 0; + S.k0 = k0; + build(S, base); + } else if (k0 !== S.k0) { + // A longer first step carries on from here; a shorter one starts over. + const up = k0 > S.k0; + S.k0 = k0; + if (!up) build(S, S.off); + else if (S.mode < 2 && k0 > S.k) { + S.k = k0; + S.head = 0; + S.scan = 0; + S.mode = 0; + } + } + const b = input.btnPressed || []; + if (b[0] && S.mode < 2) raise(S); + + // Each new edge grows from its parent for 0.3 s at Speed 2. + const tg = 0.6 / sp; + for (let i = S.grow.length - 1; i >= 0; i--) { + const c = S.grow[i]; + if (S.T - S.born[c] >= tg) { + const p = S.par[c]; + stamp(S, p & 63, p >> 6, c & 63, c >> 6, S.dep[c], S.ea, S.ed); + soak(S, c); + S.grow[i] = S.grow[S.grow.length - 1]; + S.grow.pop(); + } + } + S.tg = tg; + + if (S.mode === 0) { + // A few dozen primes a second, more as the frontier (the primes queued + // but not yet scanned) widens: 56 a second at Speed 2 near 1 + i. + S.acc += d * sp * min(90, 28 + (S.qlen - S.head)); + while (S.acc >= 1) { + S.acc -= 1; + const c = pull(S); + if (c >= 0) { + S.grow.push(c); + continue; + } + S.acc = 0; + if (S.qlen >= S.nPrime) { + S.mode = 2; + S.timer = 0; + } else { + meetMoat(S); + S.mode = 1; + S.timer = 0; + } + break; + } + } else if (S.mode === 1) { + // The moat: 0.7 s at Speed 2 of the shore pulsing, then k goes up. + S.timer += (d * sp * 0.72) / S.plen; + if (S.timer >= 1) raise(S); + } else { + // Filled: the coral holds for 3 s, fades for 1.5 s, and the next window + // in the cycle starts. + S.timer += d * sp * 0.5; + if (S.timer >= 4.5) { + S.stage = (S.stage + 1) % CYCLE.length; + build(S, S.base + CYCLE[S.stage]); + } + } + S.fade = S.mode === 2 ? cl((4.5 - S.timer) / 1.5, 0, 1) : cl((S.T - S.startT) / 0.6, 0, 1); + S.moatA = S.mode === 1 ? sin(PI * S.timer) : max(0, S.moatA - d * sp * 1.2); + S.sp = sp; +} + +// Colours by depth: the hue runs out and back along a band of the wheel +// every 72 steps of depth, so each breadth-first layer reads as a ring. +function palette(S) { + const h0 = S.hueK + 0.13; + for (let i = 0; i < DMAX; i++) { + let u = (i % 72) / 36; + if (u > 1) u = 2 - u; + hsv(h0 + 0.42 * u * u * (3 - 2 * u), 0.78, 1, S.pal, i * 3); + } + hsv(h0 + 0.5, 0.6, 0.3, S.dot, 0); + hsv(h0 + 0.47, 0.9, 0.26, S.wash, 0); + S.hue = S.hueK; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + if (S.hue !== S.hueK) palette(S); + const buf = S.buf, pal = S.pal, dot = S.dot, pr = S.pr, dep = S.dep, ea = S.ea, ed = S.ed, wa = S.wa, wash = S.wash, born = S.born, T = S.T; + for (let p = 0, o = 0; p < N; p++, o += 3) { + const w = wa[p]; + let r = wash[0] * w, g = wash[1] * w, b = wash[2] * w; + const a = ea[p]; + if (a > 0) { + const c = ed[p] * 3, v = a * 0.62; + r = max(r, pal[c] * v); + g = max(g, pal[c + 1] * v); + b = max(b, pal[c + 2] * v); + } + if (pr[p]) { + const dd = dep[p]; + if (dd < 0) { + r = max(r, dot[0]); + g = max(g, dot[1]); + b = max(b, dot[2]); + } else { + // A new node flashes white and settles into its colour. + const c = dd * 3, fl = cl(1 - (T - born[p]) * 2.5, 0, 1); + r = pal[c] + (255 - pal[c]) * fl; + g = pal[c + 1] + (255 - pal[c + 1]) * fl; + b = pal[c + 2] + (255 - pal[c + 2]) * fl; + } + } + buf[o] = r; + buf[o + 1] = g; + buf[o + 2] = b; + } + // Edges still growing, from the parent toward the new prime. + if (S.grow.length) { + const tmp = S.tmp || (S.tmp = new Float32Array(N)); + for (const c of S.grow) { + const p = S.par[c], t = cl((T - born[c]) / S.tg, 0, 1), x0 = p & 63, y0 = p >> 6, x1 = c & 63, y1 = c >> 6; + const xe = x0 + (x1 - x0) * t, ye = y0 + (y1 - y0) * t; + stamp(S, x0, y0, xe, ye, 0, tmp, null); + const xa = max(0, min(x0, x1) - 1), xb = min(W - 1, max(x0, x1) + 1), ya = max(0, min(y0, y1) - 1), yb = min(H - 1, max(y0, y1) + 1); + const cc = dep[c] * 3; + for (let y = ya; y <= yb; y++) + for (let x = xa; x <= xb; x++) { + const q = y * W + x, v = tmp[q] * 0.8; + if (v <= 0) continue; + tmp[q] = 0; + const o = q * 3; + buf[o] = max(buf[o], pal[cc] * v + 60 * v); + buf[o + 1] = max(buf[o + 1], pal[cc + 1] * v + 60 * v); + buf[o + 2] = max(buf[o + 2], pal[cc + 2] * v + 60 * v); + } + } + } + // The moat: its middle line glows pale and the shore primes pulse white. + if (S.moatA > 0.01) { + const m = S.moat, A = S.moatA; + for (let p = 0, o = 0; p < N; p++, o += 3) { + const v = m[p] * A * 0.9; + if (v <= 0) continue; + buf[o] += (170 - buf[o]) * v; + buf[o + 1] += (215 - buf[o + 1]) * v; + buf[o + 2] += (255 - buf[o + 2]) * v; + } + const pulse = 0.5 + 0.5 * sin(T * S.sp * 9); + for (const p of S.shore) { + const x = p & 63, y = p >> 6; + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + const X = x + dx, Y = y + dy; + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const o = (Y * W + X) * 3, v = A * (dx || dy ? 0.35 * pulse : 0.5 + 0.5 * pulse); + buf[o] += (255 - buf[o]) * v; + buf[o + 1] += (255 - buf[o + 1]) * v; + buf[o + 2] += (255 - buf[o + 2]) * v; + } + } + } + const f = S.fade; + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, buf[p] * f, buf[p + 1] * f, buf[p + 2] * f); +} From 1c7bd641a3bd99ac7922f08db725bd27cb77ccd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:20:47 +0000 Subject: [PATCH 03/16] =?UTF-8?q?31=ED=8C=90:=20Cat=20Map=20=EB=A5=BC=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- incoming/pattern-study/patterns/cat-map.js | 242 ++++++++++++++++++ .../pattern-study/patterns/gaussian-moat.js | 2 +- 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 incoming/pattern-study/patterns/cat-map.js diff --git a/incoming/pattern-study/patterns/cat-map.js b/incoming/pattern-study/patterns/cat-map.js new file mode 100644 index 000000000..768ca719a --- /dev/null +++ b/incoming/pattern-study/patterns/cat-map.js @@ -0,0 +1,242 @@ +// ===== Patternflow pattern ===== +// Title: Cat Map +// Family: noise +// Idea: The panel is a torus and a bold target of rings is carried by Arnold's cat map, built from two shears that slide the rows and then the columns, so the picture stretches into diagonal stripes, dissolves into what looks like pure static, passes a ghost of itself halfway, and after the map's period comes back exactly. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Arnold's cat map (Vladimir Arnold, 1967), pattern round 31 +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Twist=1..4, Picture=0..3 +// +// Knob 1: Hue. Turns the picture's colours round the wheel; at 0 the rings are cyan and rose round a cream disc. +// Knob 2: Speed. At 2 the picture holds whole for a second and a half, a step through static takes about a quarter of a second, the last step into the return about three, and the whole period at Twist 1 takes 30 s; at 0.2 everything takes ten times as long. +// Knob 3: Twist. The shears' size a = b = 1, 2, 3 or 4, where row y slides by a times y and column x by 2b times x; the period is 64, 32, 64 and 16 steps. +// Knob 4: Picture. The image the map carries: rings crossed by a diagonal band, nested diamonds, four quarters round a disc, or twelve rays. +// Buttons: K1 tap jumps to four steps before the return, so the picture can be watched re-forming out of the static. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, abs, sqrt, atan2, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mod = (v, m) => ((v % m) + m) % m; + +// One full step of the map on a permutation: perm[p] is the source LED whose +// colour now sits at p. The first shear moves what sits at (x, y) to +// (x + a y, y), the second moves (x, y) to (x, y + 2b x), so together +// (x, y) -> (x + a y, 2b x + (1 + 2ab) y), determinant 1, exact on Z64 x Z128. +function shearRows(a, from, to) { + for (let y = 0; y < H; y++) { + const s = mod(a * y, W), o = y * W; + for (let x = 0; x < W; x++) to[o + x] = from[o + ((x - s + W) & 63)]; + } +} +function shearCols(b, from, to) { + for (let x = 0; x < W; x++) { + const s = mod(2 * b * x, H); + for (let y = 0; y < H; y++) to[y * W + x] = from[((y - s + H) & 127) * W + x]; + } +} + +// The four pictures as region numbers: 0 and 1 are the two main colours, 2 +// the light one. Each fills the whole panel. +const REG = []; +{ + for (let k = 0; k < 4; k++) REG.push(new Uint8Array(N)); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, dx = x - 31.5, dy = y - 63.5, r = sqrt(dx * dx + dy * dy); + // Rings 7 LEDs wide round a disc, and a band corner to corner where + // the two ring colours trade places. + let g = r < 9 ? 2 : floor((r - 9) / 7) & 1; + if (g < 2 && abs(2 * dx + dy) / sqrt(5) < 6) g = 1 - g; + REG[0][p] = g; + // Nested diamonds, 8 LEDs a band down the long axis, a light core. + const dd = abs(dx) + abs(dy) / 2; + REG[1][p] = dd < 7 ? 2 : floor((dd - 7) / 6) & 1; + // Four quarters round a disc that swaps them, with a light rim. + const q = (dx < 0) !== (dy < 0) ? 1 : 0; + REG[2][p] = r < 21 ? 1 - q : r < 25 ? 2 : q; + // Twelve rays round a light disc and a light ring. + const ray = floor(((atan2(dy, dx) + PI) / (2 * PI)) * 12) & 1; + REG[3][p] = r < 8 || (r > 30 && r < 34) ? 2 : ray; + } +} + +// How much of the picture survives after n steps of each twist, from the +// rings picture: 0 for the whole picture, about 0.5 for static. It slows the +// steps and holds the frames where something can be seen. +function survey(t) { + let perm = new Int32Array(N), mid = new Int32Array(N), nxt = new Int32Array(N); + for (let i = 0; i < N; i++) perm[i] = i; + const g = REG[0], out = [0]; + for (let n = 1; n <= 200; n++) { + shearRows(t, perm, mid); + shearCols(t, mid, nxt); + [perm, nxt] = [nxt, perm]; + let d = 0, id = true; + for (let p = 0; p < N; p++) { + const v = g[perm[p]] & 1; + if ((p & 63) < 63) d += v !== (g[perm[p + 1]] & 1) ? 1 : 0; + if (p < N - W) d += v !== (g[perm[p + W]] & 1) ? 1 : 0; + if (perm[p] !== p) id = false; + } + if (id) break; + out.push(d / (2 * N)); + } + // out has one entry per state 0..P-1. + return out.map((v) => 1 - cl((v - 0.1) / 0.3, 0, 1)); +} +const SURVEY = [null, null, null, null, null]; + +function hsv(h, s, v, out, o) { + const f = (n) => { + const k = (n + (h - floor(h)) * 6) % 6; + return v - v * s * max(0, min(k, 4 - k, 1)); + }; + out[o] = f(5) * 255; + out[o + 1] = f(3) * 255; + out[o + 2] = f(1) * 255; +} + +export function setup(params) { + const I = () => new Int32Array(N); + params.s = { + perm: I(), mid: I(), nxt: I(), twist: 0, P: 64, st: null, n: 0, mode: 0, t: 0, first: true, + hue: -1, pic: -1, pal: new Float32Array(9), src: new Float32Array(N * 3), sh1: new Int32Array(H), sh2: new Int32Array(W), sp: 2, + }; +} + +function reset(S) { + for (let i = 0; i < N; i++) S.perm[i] = i; + S.n = 0; + S.mode = 0; + S.t = 0; +} + +// Seconds for the step out of state n, and for the hold at state n. +function stepTime(S, n) { + const st = S.st, P = S.P, base = 1.2 / S.sp; + const s = max(st[n], st[(n + 1) % P]); + return base * (0.45 + 1.6 * s) * (1 + 1.5 * Math.exp(-(P - 1 - n) / 1.5)); +} +function holdTime(S, n) { + const base = 1.2 / S.sp, s = S.st[n]; + if (n === 0) return base * (S.first ? 2.3 : 4); + // The stretched stripes after one step and the ghost halfway hold for + // about half a second at Speed 2; frames of static do not hold at all. + return base * 3 * s * sqrt(s); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 1, 0.06], d = cl(dt || 0, 0, 0.1); + S.sp = cl(k[1], 0.2, 3); + S.hueK = k[0] || 0; + S.picK = cl(round(k[3]), 0, 3); + const tw = cl(round(k[2]), 1, 4); + if (tw !== S.twist) { + // A new twist is a new map: the picture comes back whole and starts over. + S.twist = tw; + S.st = SURVEY[tw] || (SURVEY[tw] = survey(tw)); + S.P = S.st.length; + for (let y = 0; y < H; y++) S.sh1[y] = tw * (y - 64); + for (let x = 0; x < W; x++) S.sh2[x] = mod(2 * tw * x + 64, H) - 64; + reset(S); + } + const b = input.btnPressed || []; + if (b[0]) { + // Four steps short of the return, by running the map from the picture. + reset(S); + for (let n = 0; n < S.P - 4; n++) { + shearRows(tw, S.perm, S.mid); + shearCols(tw, S.mid, S.perm); + } + S.n = S.P - 4; + S.first = false; + } + S.t += d; + if (S.mode === 0) { + if (S.t >= holdTime(S, S.n)) { + S.t = 0; + S.mode = 1; + shearRows(tw, S.perm, S.mid); + } + } else if (S.t >= stepTime(S, S.n)) { + shearCols(tw, S.mid, S.nxt); + [S.perm, S.nxt] = [S.nxt, S.perm]; + S.n = (S.n + 1) % S.P; + if (S.n === 0) { + reset(S); + S.first = false; + } + S.t = 0; + S.mode = 0; + } +} + +// Three colours: two strong ones half a turn apart and a light cream, the +// strong pair kept close in brightness so the static stays soft. +function paint(S) { + const h = S.hueK + 0.5, pal = S.pal; + hsv(h, 0.66, 1, pal, 0); + // A dark orange or yellow reads brown or olive, so the second colour + // lifts its light where its hue passes through them. + let h2 = h + 0.4; + h2 -= floor(h2); + hsv(h2, 0.6, 0.72 + 0.26 * max(0, 1 - abs(h2 - 0.13) / 0.11), pal, 3); + hsv(h + 0.62, 0.2, 1, pal, 6); + const g = REG[S.picK], src = S.src; + for (let p = 0; p < N; p++) { + const c = g[p] * 3; + src[p * 3] = pal[c]; + src[p * 3 + 1] = pal[c + 1]; + src[p * 3 + 2] = pal[c + 2]; + } + S.hue = S.hueK; + S.pic = S.picK; +} + +const ease = (u) => u * u * (3 - 2 * u); + +export function draw(display, params) { + const S = params.s; + if (!S || !S.st) return; + if (S.hue !== S.hueK || S.pic !== S.picK) paint(S); + const src = S.src; + if (S.mode === 0) { + const perm = S.perm; + for (let y = 0, p = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + const o = perm[p] * 3; + display.setPixel(x, y, src[o], src[o + 1], src[o + 2]); + } + return; + } + const u = S.t / stepTime(S, S.n); + if (u < 0.5) { + // The rows slide: row y by a (y - 64), pivoting on the middle row, so the + // picture leans; each LED reads between the two it lies between. + const s = ease(u * 2), perm = S.perm; + for (let y = 0; y < H; y++) { + const sh = s * S.sh1[y], o = y * W; + for (let x = 0; x < W; x++) { + const pos = x - sh, i0 = floor(pos), f = pos - i0; + const A = perm[o + (i0 & 63)] * 3, B = perm[o + ((i0 + 1) & 63)] * 3; + display.setPixel(x, y, src[A] + (src[B] - src[A]) * f, src[A + 1] + (src[B + 1] - src[A + 1]) * f, src[A + 2] + (src[B + 2] - src[A + 2]) * f); + } + } + } else { + // The columns slide: column x by 2b x, wrapped to the nearer way round. + const s = ease(u * 2 - 1), mid = S.mid; + for (let x = 0; x < W; x++) { + const sh = s * S.sh2[x]; + for (let y = 0; y < H; y++) { + const pos = y - sh, i0 = floor(pos), f = pos - i0; + const A = mid[(i0 & 127) * W + x] * 3, B = mid[((i0 + 1) & 127) * W + x] * 3; + display.setPixel(x, y, src[A] + (src[B] - src[A]) * f, src[A + 1] + (src[B + 1] - src[A + 1]) * f, src[A + 2] + (src[B + 2] - src[A + 2]) * f); + } + } + } +} diff --git a/incoming/pattern-study/patterns/gaussian-moat.js b/incoming/pattern-study/patterns/gaussian-moat.js index 3a4a2e3b4..acb63f085 100644 --- a/incoming/pattern-study/patterns/gaussian-moat.js +++ b/incoming/pattern-study/patterns/gaussian-moat.js @@ -1,7 +1,7 @@ // ===== Patternflow pattern ===== // Title: Gaussian Moat // Family: number -// Idea: Every LED is a Gaussian integer with the origin at the bottom-left corner, the Gaussian primes glow as faint dots, and a breadth-first flood from 1+i hops from prime to prime in steps no longer than the square root of k, growing a branching coral coloured by its depth until it meets a moat, where its shore pulses and k rises to the next possible step so the flood can cross. +// Idea: Every LED is a Gaussian integer with the origin at the bottom-left corner, the Gaussian primes glow as faint dots, and a breadth-first flood from 1 + i hops from prime to prime in steps no longer than the square root of k, growing a branching coral coloured by its depth until it meets a moat, where its shore pulses and k rises to the next possible step so the flood can cross. // Author: patternflow-lab // Date: 2026-10-24 // SPDX-License-Identifier: CC-BY-SA-4.0 From 85ed5d67ad2ab3edd390ea89dcca0f9ce88045b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:20:57 +0000 Subject: [PATCH 04/16] =?UTF-8?q?31=ED=8C=90:=20KdV=20Solitons=20=EB=A5=BC?= =?UTF-8?q?=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/kdv-solitons.js | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 incoming/pattern-study/patterns/kdv-solitons.js diff --git a/incoming/pattern-study/patterns/kdv-solitons.js b/incoming/pattern-study/patterns/kdv-solitons.js new file mode 100644 index 000000000..b9542768e --- /dev/null +++ b/incoming/pattern-study/patterns/kdv-solitons.js @@ -0,0 +1,331 @@ +// ===== Patternflow pattern ===== +// Title: KdV Solitons +// Family: wave +// Idea: Zabusky and Kruskal's numerical experiment as a space-time diagram falling down the panel: the top row is the wave now, a cosine that steepens, nearly breaks and splits into a train of solitons, each a bright ridge whose slant is its speed, the tall fast ones passing through the small slow ones with a sideways jog, until the whole train pinches back together into the cosine and fans out again. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: solitons of the Korteweg-de Vries equation (Norman Zabusky and Martin Kruskal, 1965), pattern round 31 +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Dispersion=0..1, Start=0..1 +// +// Knob 1: Hue. Turns the crest colour round the wheel; at 0 the crests are cyan over indigo troughs and the tallest go pale. +// Knob 2: Speed. How fast the equation runs and the diagram falls: at 2 the cosine breaks after half a second, the panel holds the last five seconds and the train gathers back into the cosine after sixteen; at 0.2 everything is ten times slower. +// Knob 3: Dispersion. The delta of the equation, 0.022 at 0 as in the 1965 paper, giving eight thin solitons; turned up to 0.06 at 1 the cosine breaks into three wide ones and gathers back almost three times as often. +// Knob 4: Start. The wave each run starts from: the cosine of the paper, two humps of unequal height, a flat plateau whose front edge breaks into a ranked train, or a random sum of five cosines drawn fresh each run. +// Buttons: K1 tap drops a tall sech-squared soliton into the lowest trough of the wave, a pale ridge that races through the whole train. + +const W = 64, H = 128; +const { min, max, floor, abs, sqrt, cos, sin, exp, tanh, cosh, atan2, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The equation u_t + u u_x + delta^2 u_xxx = 0 on a ring of length 2, held on +// 256 points, four to an LED column. +const NX = 256, MK = NX - 1, DX = 2 / NX, SUB = NX / W; +// Rows of the diagram per unit of time, and history rows kept. +const RPT = 40, HR = H + 2; +// Robert-Asselin filter on the leapfrog's middle level: it damps the +// odd-even mode that otherwise grows to overflow after about five time units, +// and takes under one percent off a soliton over a whole run. +const NU = 0.001; +// Colour table over the height above the troughs, from UL to UH. +const NL = 512, UL = -0.5, UH = 5; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = (i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p) * 255; + out[o + 1] = (i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p) * 255; + out[o + 2] = (i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p) * 255; +} + +// Dark troughs, the crest hue at the top of a soliton, pale at the very top. +// Below the crest the hue leans away round the wheel, so the small solitons +// and the ripples between read as deeper water rather than a dim copy of the +// crests. A dim orange or yellow reads brown or olive, so for crests from +// pink round to yellow the lean runs the other way, through violet, +// magenta and red, and only the brightest tones reach the crest hue; the two +// leans meet at a yellow green, where the middle tones change side. +const LH = [0, 0.08, 0.17, 0.27, 0.27, 0.33, 0.5, 0.67, 0.83, 0.92, 1]; +const LL = [-0.3, -0.36, -0.42, -0.47, 0.53, 0.15, 0.12, 0.12, 0, -0.2, -0.3]; +function lean(h) { + h -= floor(h); + for (let i = 1; i < LH.length; i++) if (h <= LH[i] && LH[i] > LH[i - 1]) return LL[i - 1] + ((LL[i] - LL[i - 1]) * (h - LH[i - 1])) / (LH[i] - LH[i - 1]); + return LL[0]; +} +function palette(P) { + const t = P.lut, h0 = P.hue + 0.5, L = lean(h0); + for (let i = 0; i < NL; i++) { + const e = UL + ((UH - UL) * i) / (NL - 1); + const v = cl(e / 2.5, 0, 1), w = cl((e - 2.1) / 1.4, 0, 1), q = cl((e - 0.3) / 1.9, 0, 1); + hsv(h0 + L * (1 - q) ** 0.6, 0.9 - 0.8 * w * w, 0.04 + 0.96 * v ** 1.15, t, i * 3); + } + P.lutHue = P.hue; +} + +// du/dt at point i in the scheme's own differences. +function rhs(u, i, d2) { + const m2 = u[(i - 2) & MK], m1 = u[(i - 1) & MK], z = u[i], p1 = u[(i + 1) & MK], p2 = u[(i + 2) & MK]; + return -((p1 + z + m1) * (p1 - m1)) / (6 * DX) - (d2 * (p2 - 2 * p1 + 2 * m1 - m2)) / (2 * DX * DX * DX); +} + +// Leapfrog is stable while dt times the largest rate of the linear scheme +// stays under 1: |u|/dx for the u u_x term and 2.6 delta^2/dx^3 for the +// third derivative (its worst wave is three grid cells long). A new dt, or a +// new wave, starts the leapfrog again with one step back by Euler. +function restartLeap(P) { + const d2 = P.delta * P.delta; + P.dt = 0.85 / (P.umax / DX + (2.6 * d2) / (DX * DX * DX)); + const a = P.ua, b = P.ub; + for (let i = 0; i < NX; i++) a[i] = b[i] - P.dt * rhs(b, i, d2); +} + +// A starting wave into out, mean zero. Places and heights for the humps, +// the plateau and the random sum are drawn fresh each run. +function makeWave(P, kind, phase, out) { + const R = P.rnd; + if (kind) { + R[0] = Math.random() * 2; + R[1] = R[0] + 0.7 + Math.random() * 0.6; + R[2] = 1.05 + Math.random() * 0.3; + R[3] = 0.6 + Math.random() * 0.3; + for (let k = 1; k <= 5; k++) (R[3 + k] = (0.3 + Math.random()) / k), (R[8 + k] = Math.random() * 2 * PI); + } + for (let i = 0; i < NX; i++) { + const x = i * DX; + let v = 0; + if (kind === 0) v = cos(PI * (x - phase)); + else if (kind === 1) { + // Two humps, about 1.2 and 0.75 high. + const d1 = ((x - R[0] + 3) % 2) - 1, d2 = ((x - R[1] + 3) % 2) - 1; + v = R[2] * exp(-((d1 / 0.3) ** 2)) + R[3] * exp(-((d2 / 0.22) ** 2)); + } else if (kind === 2) { + // A plateau 1.1 high and 0.8 wide with soft edges. + const d = ((x - R[0] + 3) % 2) - 1; + v = 0.55 * (tanh((d + 0.4) / 0.05) - tanh((d - 0.4) / 0.05)); + } else for (let k = 1; k <= 5; k++) v += R[3 + k] * cos(PI * k * x + R[8 + k]); + out[i] = v; + } + let s = 0, mx = 0; + for (let i = 0; i < NX; i++) s += out[i]; + s /= NX; + for (let i = 0; i < NX; i++) mx = max(mx, abs((out[i] -= s))); + // The random sum peaks at 1, like the cosine. + if (kind === 3) for (let i = 0; i < NX; i++) out[i] /= mx; +} + +function begin(P, kind) { + P.ub.set(P.tg); + P.kind = kind; + P.tau = 0; + P.rec = 0; + P.drops = 0; + P.umax = 3; + restartLeap(P); +} + +// The wave into one row of 64 columns as height above the troughs: a +// little more than half the brightest of a column's four points and the +// rest their mean, so a thin crest keeps its peak. The trough level follows +// the lowest point of the wave over about a quarter time unit; it is -1 for +// the cosine and sinks to about -0.7 once the solitons have taken their mass. +function sample(P, out, o) { + const u = P.ub, bg = P.bg; + for (let c = 0, i = 0; c < W; c++) { + let mx = -9, s = 0; + for (let k = 0; k < SUB; k++, i++) { + const v = u[i]; + s += v; + if (v > mx) mx = v; + } + out[o + c] = 0.55 * mx + (0.45 * s) / SUB - bg; + } +} + +// The share of the wave's energy in the first cosine mode, 1 for the cosine +// itself, and where that mode's crest stands. +function mode1(P) { + const u = P.ub; + let c1 = 0, s1 = 0, e = 0; + for (let i = 0; i < NX; i++) { + const a = PI * i * DX; + c1 += u[i] * cos(a); + s1 += u[i] * sin(a); + e += u[i] * u[i]; + } + P.phase = atan2(s1, c1) / PI; + return (2 * (c1 * c1 + s1 * s1)) / (NX * e + 1e-9); +} + +// A soliton 4.4 high over the trough, in the lowest stretch of the wave. Its +// mass is taken evenly off the rest so the mean stays zero, and the older +// leapfrog level gets it one step back along its path. +function drop(P) { + const u = P.ub, a = P.ua; + let best = 99, at = 0; + for (let i = 0; i < NX; i += 4) { + let s = 0; + for (let k = -12; k <= 12; k++) s += u[(i + k) & MK]; + if (s < best) (best = s), (at = i); + } + const A = 4.4, w = P.delta * sqrt(12 / A), c = best / 25 + A / 3; + for (let pass = 0; pass < 2; pass++) { + const tgt = pass ? a : u, back = pass ? c * P.dt : 0; + let m = 0; + for (let i = 0; i < NX; i++) { + const d = ((((i - at) * DX + back + 3) % 2) - 1) / w; + m += P.uc[i] = A / cosh(d) ** 2; + } + for (let i = 0; i < NX; i++) tgt[i] += P.uc[i] - m / NX; + if (!pass) P.bg -= m / NX; + } +} + +function step(P, n) { + const d2 = P.delta * P.delta, k1 = P.dt / (3 * DX), k2 = (d2 * P.dt) / (DX * DX * DX); + for (let s = 0; s < n; s++) { + const a = P.ua, b = P.ub, c = P.uc; + for (let i = 0; i < NX; i++) { + const m2 = b[(i - 2) & MK], m1 = b[(i - 1) & MK], z = b[i], p1 = b[(i + 1) & MK], p2 = b[(i + 2) & MK]; + c[i] = a[i] - k1 * (p1 + z + m1) * (p1 - m1) - k2 * (p2 - 2 * p1 + 2 * m1 - m2); + } + for (let i = 0; i < NX; i++) b[i] += NU * (a[i] - 2 * b[i] + c[i]); + P.ua = b; + P.ub = c; + P.uc = a; + } +} + +export function setup(params) { + params.s = { + ua: new Float64Array(NX), ub: new Float64Array(NX), uc: new Float64Array(NX), tg: new Float64Array(NX), + hist: new Float32Array(HR * W), now: new Float32Array(W), head: 0, frac: 0, + lut: new Float32Array(NL * 3), lutHue: -1, hue: 0, speed: 2, delta: 0.022, + dt: 1e-4, umax: 3, tau: 0, kind: -1, want: 0, wantT: 0, rnd: new Float64Array(14), + f1: 0, rising: 0, rec: 0, phase: 1, reform: -1, toKind: 0, rem: 1, rlen: 1, bg: -1, drops: 0, + }; +} + +// One row of time has passed: store it, keep dt safe, and watch for the +// moment to start a new run. +function row(P) { + const u = P.ub; + let um = 0, lo = 9, bad = false; + for (let i = 0; i < NX; i++) { + const v = u[i]; + if (!(v === v) || abs(v) > 40) bad = true; + else if (abs(v) > um) um = abs(v); + if (v < lo) lo = v; + } + if (bad) return makeWave(P, P.kind, 1, P.tg), begin(P, P.kind); + P.bg += (lo - P.bg) * 0.1; + P.head = (P.head + HR - 1) % HR; + sample(P, P.hist, P.head * W); + // A taller wave than dt was set for: a smaller dt, from here. + if (um > 0.9 * P.umax) (P.umax = um * 1.4), restartLeap(P); + const f = mode1(P), Tr = 0.21 / P.delta, rowT = 1 / RPT; + // Each time the train gathers back, the energy flows into the first mode + // and out again; a peak of that share over 0.6 is one return. + if (f > P.f1) P.rising = 1; + else if (P.rising && P.f1 > 0.6 && P.tau > 0.5 * Tr) (P.rising = 0), P.rec++; + else P.rising = 0; + P.f1 = f; + if (P.reform < 0) { + // The cosine runs through one return and is drawn back onto an exact + // cosine at the second, where the returning one stands; other starts, + // and runs a dropped soliton keeps from returning, start a fresh wave + // after about as long. + if (P.kind === 0 ? P.rec >= 1 && P.rising && f > 0.55 : P.tau > 1.2 * Tr) newRun(P, P.kind, P.kind ? 1 : 0.4); + else if (P.tau > 2.6 * Tr) newRun(P, P.kind, 1); + } + if (P.reform >= 0) { + // Both leapfrog levels are drawn over to the new wave, the cosine target + // following the returning crest, so that what is left of the old wave + // falls along a smoothstep; then the run starts from the new one exactly. + const r = (P.reform = min(1, P.reform + rowT / P.rlen)), rem = 1 - r * r * (3 - 2 * r), g = P.rem > 0 ? 1 - rem / P.rem : 1; + P.rem = rem; + if (P.toKind === 0) for (let i = 0; i < NX; i++) P.tg[i] = cos(PI * (i * DX - P.phase)); + for (let i = 0; i < NX; i++) (u[i] += (P.tg[i] - u[i]) * g), (P.ua[i] += (P.tg[i] - P.ua[i]) * g); + if (r >= 1) (P.reform = -1), begin(P, P.toKind); + } +} + +// A new run toward a wave of this kind, drawn over in len time units: 0.4 +// for the cosine coming back to itself, a whole unit for a different wave. +function newRun(P, kind, len) { + P.reform = 0; + P.rem = 1; + P.rlen = len; + P.toKind = kind; + makeWave(P, kind, P.phase, P.tg); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = cl(k[0], 0, 1); + P.speed = cl(k[1], 0.2, 3); + // Delta from 0.022 to 0.06 on a log scale. + const delta = 0.022 * (0.06 / 0.022) ** cl(k[2], 0, 1); + const kind = min(3, floor(cl(k[3], 0, 1) * 4)); + if (abs(delta - P.delta) > 1e-6) (P.delta = delta), restartLeap(P); + if (P.kind < 0) { + // Before the first frame the panel shows the starting wave as if it had + // been held still, so the diagram falls out of it. + makeWave(P, kind, 1, P.tg); + begin(P, kind); + P.bg = 9; + for (let i = 0; i < NX; i++) P.bg = min(P.bg, P.ub[i]); + sample(P, P.hist, 0); + for (let r = 1; r < HR; r++) P.hist.copyWithin(r * W, 0, W); + } + // A new Start is taken once the knob has rested on it a quarter second, + // and the wave is drawn over into it like any new run. + if (kind === (P.reform >= 0 ? P.toKind : P.kind)) P.wantT = 0; + else { + if (kind !== P.want) (P.want = kind), (P.wantT = 0); + if ((P.wantT += d) > 0.25) newRun(P, kind, 1); + } + const b = input.btnPressed || []; + // Four drops a run at most; past that the train is lost in white. + if (b[0] && P.drops < 4) P.drops++, drop(P); + + // 0.6 time units a second at Speed 2; the cosine breaks at 1/pi. + let left = d * P.speed * 0.3; + const rowT = 1 / RPT; + while (left > 1e-12) { + const run = min(left, (1 - P.frac) * rowT); + step(P, min(4000, max(1, round(run / P.dt)))); + P.tau += run; + left -= run; + P.frac += run / rowT; + if (P.frac >= 1 - 1e-9) (P.frac = 0), row(P); + } +} + +export function draw(display, params) { + const P = params.s; + if (!P || P.kind < 0) return; + if (P.lutHue !== P.hue) palette(P); + const now = P.now, hist = P.hist, lut = P.lut, f = P.frac, sc = (NL - 1) / (UH - UL); + sample(P, now, 0); + for (let y = 0; y < H; y++) { + // Row y is the wave y rows of time ago. The newest stored row is f rows + // old; the top row is the wave now. + let ra = hist, ia, ib, w; + if (y <= f) (ra = now), (ia = 0), (ib = P.head * W), (w = f > 0 ? y / f : 0); + else { + const kk = floor(y - f); + ia = ((P.head + kk) % HR) * W; + ib = ((P.head + kk + 1) % HR) * W; + w = y - f - kk; + } + for (let x = 0; x < W; x++) { + const u = ra[ia + x] + (hist[ib + x] - ra[ia + x]) * w; + const o = cl(round((u - UL) * sc), 0, NL - 1) * 3; + display.setPixel(x, y, lut[o], lut[o + 1], lut[o + 2]); + } + } +} From 0997380670aa597c4f9a6cdf77cffe853cc43bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:22:14 +0000 Subject: [PATCH 05/16] =?UTF-8?q?31=ED=8C=90:=20Lip=20Gloss=20=EB=A5=BC=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- incoming/pattern-study/patterns/lip-gloss.js | 399 +++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 incoming/pattern-study/patterns/lip-gloss.js diff --git a/incoming/pattern-study/patterns/lip-gloss.js b/incoming/pattern-study/patterns/lip-gloss.js new file mode 100644 index 000000000..78a5324e6 --- /dev/null +++ b/incoming/pattern-study/patterns/lip-gloss.js @@ -0,0 +1,399 @@ +// ===== Patternflow pattern ===== +// Title: Lip Gloss +// Family: cosmetic +// Idea: A band of thick clear gloss is poured along the top of a pale wall and slides down it, its front swelling into a rim that breaks into drips of different lengths, each tipped with a round bead that holds a point of light, while a soft light glides down the film and the drips run off the bottom before a fresh pour. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 31, after clear lip gloss sliding down a tester wall +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Pour=0..1, Shimmer=0..1 +// +// Knob 1: Hue. The tint of the gloss, all the way round the colour wheel from rose at 0; the wall stays pale and takes only a breath of it. +// Knob 2: Speed. How fast the gloss runs: at 2 the longest drip reaches the bottom about five seconds after the pour starts and a fresh pour comes every eleven seconds; at 0.2 it all runs ten times slower. +// Knob 3: Pour. How much gloss each pour carries, from a thin band that breaks into three or four long drips at 0 to a heavy curtain at 1 that comes down the whole width with eight or nine short scallops on its front. +// Knob 4: Shimmer. Fine flecks of mica in the gloss that ride down with it and flash as they turn, from a clear gloss with a few dozen at 0 to a shimmer gloss packed with them at 1, its film turned pearly. +// Buttons: K1 tap pours a fresh band along the top now. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, round, sqrt, sin, cos, exp, abs, pow, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// The opening tint: rose, not red. +const H0 = 0.935; +// The film moves under gravity with a flux C*h^3 (a viscous film on a wall, +// speed going as thickness squared), so a film one unit thick runs C rows a +// second at its front at Speed 2. A pour lasts POUR seconds, one starts +// every CYCLE seconds. +const C = 30, POUR = 3, CYCLE = 11; +// Flecks of mica for the Shimmer knob, at most MAXF at once. +const MAXF = 700; +// Beads hanging from the bottom edge, at most MAXD; one lets go once it holds +// DROP units of gloss. +const MAXD = 24, DROP = 5; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + let r, g, b; + if (i === 0) (r = v), (g = t), (b = p); + else if (i === 1) (r = q), (g = v), (b = p); + else if (i === 2) (r = p), (g = v), (b = t); + else if (i === 3) (r = p), (g = q), (b = v); + else if (i === 4) (r = t), (g = p), (b = v); + else (r = v), (g = p), (b = q); + out[o] = r; + out[o + 1] = g; + out[o + 2] = b; +} + +export function setup(params) { + const F = (n) => new Float32Array(n); + params.s = { + h: F(N), hr: F(N), q: F(H), m: F(W), front: new Int16Array(W), hf: F(W), + T: 0, since: 0, pourLeft: 0, flow: 0, out: F(W), + dx: F(MAXD), dv: F(MAXD), df: F(MAXD), dvy: F(MAXD), nd: 0, + bx: F(MAXD * 3), by: F(MAXD * 3), br: F(MAXD * 3), nb: 0, + fx: F(MAXF), fy: F(MAXF), fp: F(MAXF), fw: F(MAXF), nf: 0, cover: 0, + hue: 0, rate: 1, amount: 0, shim: 0.06, started: false, + tint: F(3), absb: F(3), wall: F(3), pear: F(3), gain: 1, buf: F(N * 3), + }; +} + +// A fresh pour: how much gloss comes down each column. A film running down +// a wall breaks its front into fingers (Huppert 1982), the columns that get +// ahead taking more of the flow. Here the fingers are laid into the pour: a +// thin sheet across the whole width with bumps on it of different heights, +// and since the front runs at a speed that goes as thickness squared, each +// bump runs ahead as a drip of its own length. +function newPour(S) { + const P = S.amount, m = S.m, nf = round(3 + 5 * P + rnd() * 0.8); + const base = 0.1 + 0.95 * P; + for (let x = 0; x < W; x++) m[x] = base * (0.9 + 0.2 * rnd()); + for (let k = 0; k < nf; k++) { + const c = ((k + 0.5 + (rnd() - 0.5) * 0.7) / nf) * W, w = (1.6 + 1.3 * rnd()) * (1.3 - 0.4 * P); + const a = (0.6 + 1.0 * rnd()) * (1 - 0.55 * P); + for (let x = 0; x < W; x++) m[x] += a * exp(-(((x + 0.5 - c) / w) ** 2)); + } + S.pourLeft = POUR * (1 - 0.35 * P); + S.since = 0; + S.flow = C * (0.85 + 0.6 * P); +} + +function flecks(S, st) { + // Mica hangs all through the gloss: new flecks turn up wherever the film is + // thick enough to hold them, as many as Shimmer asks for the film there is, + // and each rides down at the surface speed of the film under it, half as + // fast again as the film's mean. + const h = S.h, want = min(MAXF, S.shim * 1000 * S.cover); + for (let tries = 0; tries < 60 && S.nf < want; tries++) { + const x = rnd() * W, y = rnd() * H; + if (h[floor(y) * W + floor(x)] < 0.15) continue; + const i = S.nf++; + S.fx[i] = x; + S.fy[i] = y; + S.fp[i] = rnd() * 2 * PI; + S.fw[i] = (1.5 + 2.5 * rnd()) * (rnd() < 0.5 ? -1 : 1); + } + for (let i = S.nf - 1; i >= 0; i--) { + const hh = h[floor(S.fy[i]) * W + floor(S.fx[i])]; + S.fy[i] += 1.5 * C * hh * hh * st; + S.fp[i] += S.fw[i] * st; + if (S.fy[i] >= H || hh < 0.06 || S.nf > want + 20) { + const j = --S.nf; + S.fx[i] = S.fx[j]; + S.fy[i] = S.fy[j]; + S.fp[i] = S.fp[j]; + S.fw[i] = S.fw[j]; + } + } +} + +function step(S, st) { + S.T += st; + S.since += st; + if (S.since >= CYCLE) newPour(S); + const h = S.h, q = S.q, m = S.m; + // The pour feeds the top row; as it ends it tapers off over half a second. + if (S.pourLeft > 0) { + const f = S.flow * min(1, S.pourLeft * 2) * st; + for (let x = 0; x < W; x++) h[x] += m[x] * f; + S.pourLeft -= st; + } + // Each column runs down with flux C*h^3, worked upwind in steps short + // enough for the fastest wave, which runs at 3*C*h^2. + let hm = 0; + for (let p = 0; p < N; p++) if (h[p] > hm) hm = h[p]; + const n = Math.ceil((st * 3 * C * hm * hm) / 0.8) || 1, dt = st / n; + for (let k = 0; k < n; k++) { + for (let x = 0; x < W; x++) { + for (let y = 0, p = x; y < H; y++, p += W) { + const v = h[p]; + q[y] = C * v * v * v; + } + h[x] -= dt * q[0]; + S.out[x] += dt * q[H - 1]; + for (let y = 1, p = x + W; y < H; y++, p += W) h[p] += dt * (q[y - 1] - q[y]); + } + } + // Surface tension pulls a little gloss sideways, so the drips stay round. + const D = min(0.2, 0.7 * st); + for (let y = 0; y < H; y++) { + const o = y * W; + let prev = h[o]; + for (let x = 0; x < W; x++) { + const c = h[o + x], r = x < W - 1 ? h[o + x + 1] : c; + h[o + x] = c + D * (prev + r - 2 * c); + prev = c; + } + } + // A film thinner than 0.45 slowly soaks away, the thinner the faster, so + // the wall clears from the top, where the draining film is thinnest, while + // the drips stay. + const fade = 0.075 * st; + let cover = 0; + for (let p = 0; p < N; p++) { + const v = h[p]; + if (v < 0.45) h[p] = max(0, v - fade * (1 - v / 0.45)); + if (v > 0.15) cover++; + } + S.cover = cover / N; + flecks(S, st); + drops(S, st); +} + +// What runs off the bottom row gathers into a bead hanging from the edge +// under its drip; a full bead lets go and falls away. +function drops(S, st) { + const out = S.out; + for (let x = 0; x < W; x++) { + const v = out[x]; + if (v <= 0) continue; + out[x] = 0; + let b = -1, bd = 4.5; + for (let i = 0; i < S.nd; i++) { + const d = abs(S.dx[i] - x - 0.5); + if (S.df[i] < 0 && d < bd) (bd = d), (b = i); + } + if (b < 0) { + if (S.nd >= MAXD) continue; + b = S.nd++; + S.dx[b] = x + 0.5; + S.dv[b] = 0; + S.df[b] = -1; + } + const nv = S.dv[b] + v; + S.dx[b] = (S.dx[b] * S.dv[b] + (x + 0.5) * v) / nv; + S.dv[b] = nv; + } + for (let i = S.nd - 1; i >= 0; i--) { + if (S.df[i] < 0) { + // A bead nothing feeds any more slowly soaks away like the film. + S.dv[i] -= 0.12 * st; + if (S.dv[i] > DROP) (S.df[i] = 0), (S.dvy[i] = 20); + else if (S.dv[i] < 0.05) S.df[i] = 99; + continue; + } + S.dvy[i] += 500 * st; + S.df[i] += S.dvy[i] * st; + if (S.df[i] > 14) { + const j = --S.nd; + S.dx[i] = S.dx[j]; + S.dv[i] = S.dv[j]; + S.df[i] = S.df[j]; + S.dvy[i] = S.dvy[j]; + } + } +} + +// Adds a round bead of gloss, radius r and height A, centred at cx, cy. +function dome(S, cx, cy, r, A) { + const { hr, h } = S; + if (S.nb < MAXD * 3) { + S.bx[S.nb] = cx; + S.by[S.nb] = cy; + S.br[S.nb++] = r; + } + for (let y = max(0, floor(cy - r)); y <= min(H - 1, floor(cy + r)); y++) + for (let X = max(0, floor(cx - r)); X <= min(W - 1, floor(cx + r)); X++) { + const dx = (X + 0.5 - cx) / r, dy = (y + 0.5 - cy) / r, d2 = dx * dx + dy * dy; + if (d2 < 1) { + const p = y * W + X; + hr[p] = max(hr[p], h[p] + A * sqrt(1 - d2)); + } + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06]; + const d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.rate = cl(k[1], 0.2, 3) / 2; + S.amount = cl(k[2], 0, 1); + S.shim = cl(k[3], 0, 1); + if (!S.started) { + S.started = true; + newPour(S); + // Open a second and a half into the first pour. + for (let i = 0; i < 45; i++) step(S, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0]) newPour(S); + step(S, d * S.rate); +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const { h, hr, front, hf, buf, tint, absb, wall } = S; + const hue = H0 + S.hue; + // How the gloss tints the light that goes through it: each channel is cut + // by exp(-a*h), a taken from the tint so thick gloss is the deep colour. + hsv(hue, 0.8, 1, tint, 0); + for (let c = 0; c < 3; c++) absb[c] = -Math.log(max(0.2, tint[c])) * 0.95; + // Orange and yellow tints darkened read brown and olive on LEDs; the wall + // is bright enough that they stay clear, and they get a touch more light. + const hw = (hue - floor(hue) + 1) % 1, am = max(0, 1 - abs(hw - 0.11) / 0.1); + S.gain = 1 + 0.08 * am; + // The wall: a pale warm white with a breath of the tint. + hsv(hue + 0.04, 0.07, 1, wall, 0); + // Each column's front and the rim behind it: walking up from the bottom, + // every step where the film thickens adds to a ridge that fades over three + // rows, the capillary ridge a running film piles up at its front. + const kr = exp(-1 / 2.6); + for (let x = 0; x < W; x++) { + let r = 0, f = -1, fh = 0; + for (let y = H - 1; y >= 0; y--) { + const p = y * W + x, below = y < H - 1 ? h[p + W] : 0, jump = h[p] - below; + r *= kr; + if (jump > 0.02) r += jump * 0.9; + if (f < 0 && h[p] > 0.1) (f = y), (fh = h[p]); + hr[p] = h[p] + r; + } + front[x] = f; + hf[x] = fh; + } + S.nb = 0; + // Beads: where a column's front runs further than its neighbours', the + // gloss gathers into a round bead at the tip of the drip. + for (let x = 0; x < W; x++) { + const f = front[x]; + if (f < 6 || f > H - 5) continue; + let tip = true, lo = 1e9; + for (let d = -3; d <= 3 && tip; d++) { + if (!d) continue; + const xx = x + d; + if (xx < 0 || xx >= W) continue; + if (front[xx] > f || (front[xx] === f && d < 0)) tip = false; + lo = min(lo, front[xx]); + } + if (!tip || f - lo < 2) continue; + const r = 2 + 1.8 * min(1.2, hf[x]); + dome(S, x + 0.5, f + 0.6 - r * 0.55, r, 0.5 + 0.7 * min(1.2, hf[x])); + } + for (let i = 0; i < S.nd; i++) { + const r = 1.3 + 1.2 * sqrt(S.dv[i]); + dome(S, S.dx[i], H - 0.3 - r + max(0, S.df[i]), r, 0.6 + 0.15 * r); + } + // The light: a soft bar above the wall that glides down it, seen in the + // gloss as a mirror image moved by the slope of the surface, and a small + // lamp to one side that puts a point of light on every bead. + const T = S.T, ys = ((T * 9) % (H + 60)) - 30, lxa = 0.25 * sin(T * 0.37), lya = -0.32 + 0.08 * sin(T * 0.23); + let hx = lxa, hy = lya, hz = 1; + const hl = sqrt(hx * hx + hy * hy + hz * hz); + hx /= hl; + hy /= hl; + hz /= hl; + const sh = S.shim, pear = S.pear; + hsv(hue - 0.04, 0.3, 245, pear, 0); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, t0 = hr[p], t = t0 / (1 + 0.25 * t0); + const wb = (0.8 + 0.07 * exp(-(((x - 32) / 40) ** 2 + ((y - ys * 0.5 - 30) / 90) ** 2))) * 255; + const film = sm(0.02, 0.14, t); + let R = wall[0] * wb, G = wall[1] * wb, B = wall[2] * wb; + if (film > 0) { + const g = S.gain; + R *= exp(-absb[0] * t) * g; + G *= exp(-absb[1] * t) * g; + B *= exp(-absb[2] * t) * g; + // Mica scatters light back out of the gloss, a pearly haze of the + // tint over the film that grows with Shimmer. + const pe = 0.42 * sh * film; + R += (pear[0] - R) * pe; + G += (pear[1] - G) * pe; + B += (pear[2] - B) * pe; + const gx = (hr[x < W - 1 ? p + 1 : p] - hr[x > 0 ? p - 1 : p]) * 0.5; + const gy = (hr[y < H - 1 ? p + W : p] - hr[y > 0 ? p - W : p]) * 0.5; + let nx = -gx * 3, ny = -gy * 3, nz = 1; + const nl = 1 / sqrt(nx * nx + ny * ny + 1); + nx *= nl; + ny *= nl; + nz *= nl; + // The bar seen in the gloss. + const yr = y - ny * 22 + (x - 32) * 0.15, bar = exp(-(((yr - ys) / 5) ** 2)) * 0.26 * film; + // The lamp: a sharp highlight where the surface faces half way + // between it and the eye. + const nd = max(0, nx * hx + ny * hy + nz * hz), spot = pow(nd, 200) * film; + const s = (bar + spot * 1.3 * sm(0.08, 0.35, t)) * 255; + R += s; + G += s; + B += s; + } + const q = p * 3; + buf[q] = R; + buf[q + 1] = G; + buf[q + 2] = B; + } + // Every bead holds a point of light on the side towards the lamp, and the + // light it gathers lands as a glow of the tint low on the far side. + const pl = sqrt(hx * hx + hy * hy) || 1, ux = hx / pl, uy = hy / pl; + for (let i = 0; i < S.nb; i++) { + const r = S.br[i], cx = S.bx[i], cy = S.by[i]; + glow(buf, cx + ux * r * 0.42, cy + uy * r * 0.42, 0.55 + 0.04 * r, 235, 235, 235); + glow(buf, cx - ux * r * 0.35, cy - uy * r * 0.35 + r * 0.15, r * 0.38, 110 * tint[0], 110 * tint[1], 110 * tint[2]); + } + // Mica flecks: a pale glint of the tint, flashing white as they turn to + // the lamp, a bright flash spilling onto the four LEDs round it. + for (let i = 0; i < S.nf; i++) { + const x = floor(S.fx[i]), y = floor(S.fy[i]); + const f = exp((cos(S.fp[i]) - 1) / 0.08), q = (y * W + x) * 3; + for (let c = 0; c < 3; c++) buf[q + c] += 70 * (0.4 + 0.6 * tint[c]) + 200 * f; + if (f > 0.6) + for (let k = 0; k < 4; k++) { + const X = x + (k === 0 ? 1 : k === 1 ? -1 : 0), Y = y + (k === 2 ? 1 : k === 3 ? -1 : 0); + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const o = (Y * W + X) * 3; + buf[o] += 90 * f; + buf[o + 1] += 90 * f; + buf[o + 2] += 90 * f; + } + } + for (let p = 0; p < N; p++) { + const q = p * 3; + display.setPixel(p % W, (p / W) | 0, knee(buf[q]), knee(buf[q + 1]), knee(buf[q + 2])); + } +} + +// A soft round spot of light, sigma s LEDs, added at x, y. +function glow(buf, x, y, s, r, g, b) { + const e = s * 2.5, k = 1 / (2 * s * s); + for (let Y = max(0, floor(y - e)); Y <= min(H - 1, floor(y + e)); Y++) + for (let X = max(0, floor(x - e)); X <= min(W - 1, floor(x + e)); X++) { + const w = exp(-((X + 0.5 - x) ** 2 + (Y + 0.5 - y) ** 2) * k), q = (Y * W + X) * 3; + buf[q] += r * w; + buf[q + 1] += g * w; + buf[q + 2] += b * w; + } +} + +function knee(v) { + return v < 200 ? v : 255 - 55 * exp(-(v - 200) / 55); +} From f4e165ae36c4fca8453fbdff79594aed53417d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:33:18 +0000 Subject: [PATCH 06/16] =?UTF-8?q?31=ED=8C=90:=20Foam=20Lather=20=EB=A5=BC?= =?UTF-8?q?=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/foam-lather.js | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 incoming/pattern-study/patterns/foam-lather.js diff --git a/incoming/pattern-study/patterns/foam-lather.js b/incoming/pattern-study/patterns/foam-lather.js new file mode 100644 index 000000000..db04f9632 --- /dev/null +++ b/incoming/pattern-study/patterns/foam-lather.js @@ -0,0 +1,455 @@ +// ===== Patternflow pattern ===== +// Title: Foam Lather +// Family: cosmetic +// Idea: Fresh lather fills the panel with hundreds of small tinted bubbles, each a soft dome with a thin bright wall and a point of light, and it coarsens as it rises: bubbles with few neighbours shrink away, walls burst with a flash and merge two bubbles into one, the top turns to a dry honeycomb while the bottom stays wet and round, until new lather is whipped in from below. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 31, after cleansing foam whipped into lather +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Size=0..1, Wetness=0..1 +// +// Knob 1: Hue. The tint of the foam, all the way round the colour wheel from sky blue at 0; the walls and the points of light stay nearly white. +// Knob 2: Speed. How fast the foam lives: at 2 it rises about a row a second, a small bubble with four neighbours is gone in about four seconds and a fresh batch is whipped in every fifteen; at 0.2 it all runs ten times slower. +// Knob 3: Size. The bubbles of fresh lather, from fine ones four LEDs across at 0 to coarse ones ten across at 1; they about double on the way up. +// Knob 4: Wetness. How much liquid the foam holds: near 0 only the bottom sixth is wet and round and the rest dries to a honeycomb of thin walls; at 1 every bubble is a round dome sitting in milky liquid. +// Buttons: K1 tap whips a fresh batch of fine lather in from the bottom. + +const W = 64, H = 128; +const { min, max, floor, sqrt, exp, abs, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// The opening tint: sky blue. +const H0 = 0.57; +// The foam is worked out over a margin M past every edge of the panel, so +// bubbles at the edge have all their neighbours and the edge is no wall. +const M = 6, EW = W + 2 * M, EH = H + 2 * M, EN = EW * EH; +// At most NB bubbles, each with up to NN neighbours on record. +const NB = 1100, NN = 16; +// Gas through the walls: a bubble's area changes by K*(n - 6) LED^2 a second +// at Speed 2, n its number of neighbours (von Neumann's law for 2D foam). The +// 6 is the mean count of the bubbles on the panel, which comes out at 5.9 to +// 6.1, and the dry foam at the top runs 1.2 times as fast. +const K = 1.6; +// The foam rises RISE rows a second; a batch of lather comes every BATCH +// seconds and pushes it up BOOST rows a second faster for two seconds. +const RISE = 1.2, BATCH = 15, BOOST = 14; +// Packing grid for the bubbles, CS LEDs a cell. +const CS = 6, GX = Math.ceil(EW / CS) + 1, GY = Math.ceil(EH / CS) + 1; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + let r, g, b; + if (i === 0) (r = v), (g = t), (b = p); + else if (i === 1) (r = q), (g = v), (b = p); + else if (i === 2) (r = p), (g = v), (b = t); + else if (i === 3) (r = p), (g = q), (b = v); + else if (i === 4) (r = t), (g = p), (b = v); + else (r = v), (g = p), (b = q); + out[o] = r * 255; + out[o + 1] = g * 255; + out[o + 2] = b * 255; +} + +export function setup(params) { + const F = (n) => new Float32Array(n); + params.s = { + x: F(NB), y: F(NB), r: F(NB), a: F(NB), tone: F(NB), n: 0, dead: new Uint8Array(NB), + nb: new Int16Array(NB * NN), nc: new Uint8Array(NB), edge: new Uint8Array(NB), fresh: true, + l1: new Int16Array(EN), l2: new Int16Array(EN), p1: F(EN), p2: F(EN), + head: new Int16Array(GX * GY), next: new Int16Array(NB), + fl: F(32 * 8), nfl: 0, dr: F(96 * 5), ndr: 0, + T: 0, since: 0, boost: 0, acc: 0, rate: 1, size: 0, wet: 0.06, hue: 0, started: false, + col: F(18), buf: F(W * H * 3), + }; +} + +// The radius of fresh lather. +const fresh = (S) => 2.1 + 3.2 * S.size; + +function add(S, x, y, r) { + if (S.n >= NB) return; + const i = S.n++; + S.x[i] = x; + S.y[i] = y; + S.r[i] = r; + S.a[i] = PI * r * r; + S.tone[i] = rnd(); + S.dead[i] = 0; + S.nc[i] = 0; + S.edge[i] = 1; +} + +// Liquid fraction at height y: it drains, so the foam is wettest at the +// bottom; Wetness raises it everywhere. +function liquid(S, y) { + const b = cl(y / H, 0, 1), dr = b ** 4; + return cl(0.02 + 0.3 * S.wet + (0.2 + 0.1 * S.wet) * dr, 0, 0.4); +} + +// The panel filled with lather that has already risen and coarsened: fine +// bubbles at the bottom, two and a half times as big at the top. +function fill(S) { + S.n = 0; + const f = fresh(S); + let y = EH - M - f; + while (y > -M - 2) { + const rr = f * (1 + 1.5 * cl((H - y) / H, 0, 1) ** 1.2); + for (let x = -M + rnd() * rr; x < W + M; ) { + const r = rr * (0.65 + 0.7 * rnd()); + add(S, x, y + (rnd() - 0.5) * rr * 0.6, r); + x += r * 1.75 + rr * 0.15; + } + y -= rr * 1.6; + } + for (let k = 0; k < 60; k++) pack(S, 0.6); +} + +// Bubbles push apart wherever their discs overlap, the small ones giving +// way more, so the foam stays packed and its cells polygonal. +function pack(S, k) { + const { x, y, r, a, head, next } = S; + head.fill(-1); + let rmax = 0; + for (let i = 0; i < S.n; i++) { + const gx = cl(floor((x[i] + M) / CS), 0, GX - 1), gy = cl(floor((y[i] + M) / CS), 0, GY - 1), c = gy * GX + gx; + next[i] = head[c]; + head[c] = i; + if (r[i] > rmax) rmax = r[i]; + } + for (let i = 0; i < S.n; i++) { + const xi = x[i], yi = y[i], ri = r[i], reach = Math.ceil((ri + rmax) / CS); + const gx = cl(floor((xi + M) / CS), 0, GX - 1), gy = cl(floor((yi + M) / CS), 0, GY - 1); + for (let cy = max(0, gy - reach); cy <= min(GY - 1, gy + reach); cy++) + for (let cx = max(0, gx - reach); cx <= min(GX - 1, gx + reach); cx++) + for (let j = head[cy * GX + cx]; j >= 0; j = next[j]) { + if (j <= i) continue; + const dx = x[j] - xi, dy = y[j] - yi, want = (ri + r[j]) * 0.96, d2 = dx * dx + dy * dy; + if (d2 >= want * want) continue; + const d = sqrt(d2) + 1e-4, push = ((want - d) / d) * k, wi = a[j] / (a[i] + a[j]); + x[i] -= dx * push * wi; + y[i] -= dy * push * wi; + x[j] += dx * push * (1 - wi); + y[j] += dy * push * (1 - wi); + } + } + // Soft walls just past the margin at the sides and the bottom. + for (let i = 0; i < S.n; i++) { + const lo = -M + r[i] * 0.4, hi = W + M - r[i] * 0.4, bot = H + M - r[i] * 0.6; + if (x[i] < lo) x[i] += (lo - x[i]) * 0.5; + if (x[i] > hi) x[i] += (hi - x[i]) * 0.5; + if (y[i] > bot) y[i] += (bot - y[i]) * 0.5; + } +} + +// Every LED of the margined grid takes the bubble whose power distance +// d^2 - r^2 is least (a Laguerre cell, which is how a 2D foam of mixed +// sizes divides the plane), and remembers the runner-up for the wall. +function label(S) { + const { x, y, r, l1, l2, p1, p2 } = S; + l1.fill(-1); + l2.fill(-1); + p1.fill(1e9); + p2.fill(1e9); + for (let i = 0; i < S.n; i++) { + const R = max(r[i] * 1.4 + 1.3, r[i] * 1.8), w = r[i] * r[i], cx = x[i] + M, cy = y[i] + M; + const x0 = max(0, floor(cx - R)), x1 = min(EW - 1, floor(cx + R)), y0 = max(0, floor(cy - R)), y1 = min(EH - 1, floor(cy + R)); + for (let Y = y0; Y <= y1; Y++) { + const dy = Y + 0.5 - cy, dy2 = dy * dy - w; + for (let X = x0, p = Y * EW + x0; X <= x1; X++, p++) { + const dx = X + 0.5 - cx, pd = dx * dx + dy2; + if (pd < p1[p]) { + p2[p] = p1[p]; + l2[p] = l1[p]; + p1[p] = pd; + l1[p] = i; + } else if (pd < p2[p]) { + p2[p] = pd; + l2[p] = i; + } + } + } + } + // Who touches whom: two cells are neighbours where their LEDs meet. + const { nb, nc, edge } = S; + nc.fill(0, 0, S.n); + for (let i = 0; i < S.n; i++) edge[i] = x[i] - r[i] * 1.6 < -M + 1 || x[i] + r[i] * 1.6 > W + M - 1 || y[i] - r[i] * 1.6 < -M + 1 || y[i] + r[i] * 1.6 > H + M - 1 ? 1 : 0; + for (let Y = 0; Y < EH - 1; Y++) + for (let X = 0, p = Y * EW; X < EW - 1; X++, p++) { + const a = l1[p]; + if (a < 0) continue; + const b = l1[p + 1], c = l1[p + EW]; + if (b >= 0 && b !== a) link(nb, nc, a, b); + if (c >= 0 && c !== a && c !== b) link(nb, nc, a, c); + } + S.fresh = true; +} + +function link(nb, nc, a, b) { + const oa = a * NN; + for (let k = 0; k < nc[a]; k++) if (nb[oa + k] === b) return; + if (nc[a] < NN) nb[oa + nc[a]++] = b; + const ob = b * NN; + for (let k = 0; k < nc[b]; k++) if (nb[ob + k] === a) return; + if (nc[b] < NN) nb[ob + nc[b]++] = a; +} + +// A wall bursts: a flash along it and a few droplets thrown off, and the +// two bubbles become one with both their areas. +function burst(S, i, j) { + const { x, y, r, a } = S; + const dx = x[j] - x[i], dy = y[j] - y[i], d = sqrt(dx * dx + dy * dy) + 1e-4; + const t = (d * d + r[i] * r[i] - r[j] * r[j]) / (2 * d * d), mx = x[i] + dx * t, my = y[i] + dy * t; + const half = min(r[i], r[j]) * 0.75, ux = -dy / d, uy = dx / d; + for (let k = 0; k < 3 && S.ndr < 96; k++) { + const o = S.ndr++ * 5, s = (rnd() - 0.5) * 2, sp = 10 + 14 * rnd(), side = rnd() < 0.5 ? -1 : 1; + S.dr[o] = mx + ux * half * s; + S.dr[o + 1] = my + uy * half * s; + S.dr[o + 2] = (dx / d) * sp * side + ux * 4 * s; + S.dr[o + 3] = (dy / d) * sp * side + uy * 4 * s - 6; + S.dr[o + 4] = 1; + } + const A = a[i] + a[j]; + x[i] = (x[i] * a[i] + x[j] * a[j]) / A; + y[i] = (y[i] * a[i] + y[j] * a[j]) / A; + a[i] = A; + r[i] = sqrt(A / PI); + S.dead[j] = 1; + if (S.nfl < 32) { + const o = S.nfl++ * 8, f = S.fl; + f[o] = mx - ux * half; + f[o + 1] = my - uy * half; + f[o + 2] = mx + ux * half; + f[o + 3] = my + uy * half; + f[o + 4] = 1; + f[o + 5] = r[i]; + f[o + 6] = x[i]; + f[o + 7] = y[i]; + } +} + +function step(S, st) { + S.T += st; + S.since += st; + if (S.since >= BATCH) (S.since = 0), (S.boost = 2); + const { x, y, r, a, nb, nc, edge, dead } = S; + const n = S.n; + if (S.fresh) { + S.fresh = false; + // Gas runs from bubbles with fewer than six sides to those with more. + // The mean is taken over the bubbles away from the margin's edge, and + // those at the edge keep their size. + let sum = 0, cnt = 0; + for (let i = 0; i < n; i++) if (!edge[i]) (sum += nc[i]), cnt++; + const mean = cnt ? sum / cnt : 6; + for (let i = 0; i < n; i++) { + if (edge[i] || dead[i]) continue; + // A dry foam coarsens faster: its walls are thinner. + const dry = 1.3 - 2.5 * liquid(S, y[i]); + a[i] += K * (nc[i] - mean) * dry * st; + if (a[i] < 2.5) { + dead[i] = 1; + continue; + } + r[i] = sqrt(a[i] / PI); + } + // Walls burst more often the drier and bigger the bubbles. + for (let i = 0; i < n; i++) { + if (dead[i] || edge[i] || !nc[i]) continue; + const dry = cl(1 - liquid(S, y[i]) / 0.2, 0, 1); + if (rnd() > st * 0.014 * dry * (r[i] / 3) ** 2) continue; + const j = nb[i * NN + floor(rnd() * nc[i])]; + if (j >= 0 && j < n && !dead[j] && !edge[j]) burst(S, i, j); + } + } + // The foam rises; whatever leaves the top is gone. + const up = (RISE + (S.boost > 0 ? BOOST * sm(0, 0.4, S.boost) : 0)) * st; + S.boost = max(0, S.boost - st); + for (let i = 0; i < n; i++) { + y[i] -= up; + if (y[i] + r[i] < -M) dead[i] = 1; + } + let k = 0; + for (let i = 0; i < n; i++) { + if (dead[i]) continue; + if (k !== i) { + x[k] = x[i]; + y[k] = y[i]; + r[k] = r[i]; + a[k] = a[i]; + S.tone[k] = S.tone[i]; + } + dead[k] = 0; + k++; + } + S.n = k; + // New lather comes in under the bottom edge as fast as the foam rises, a + // fresh batch finer still. + const f = fresh(S) * (S.boost > 0 ? 0.85 : 1); + S.acc += up * EW; + while (S.acc > 0 && S.n < NB) { + const rr = f * (0.7 + 0.6 * rnd()); + S.acc -= PI * rr * rr * 0.95; + add(S, -M + rnd() * EW, H + M - rr * (0.6 + rnd()), rr); + } + pack(S, 0.7); + // Flashes fade in a third of a second; droplets fly and fall. + for (let i = S.nfl - 1; i >= 0; i--) { + const o = i * 8; + S.fl[o + 4] -= st * 3; + if (S.fl[o + 4] <= 0) { + const l = --S.nfl * 8; + for (let c = 0; c < 8; c++) S.fl[o + c] = S.fl[l + c]; + } + } + for (let i = S.ndr - 1; i >= 0; i--) { + const o = i * 5; + S.dr[o] += S.dr[o + 2] * st; + S.dr[o + 1] += S.dr[o + 3] * st; + S.dr[o + 3] += 60 * st; + S.dr[o + 4] -= st * 3; + if (S.dr[o + 4] <= 0) { + const l = --S.ndr * 5; + for (let c = 0; c < 5; c++) S.dr[o + c] = S.dr[l + c]; + } + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06]; + const d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.rate = cl(k[1], 0.2, 3) / 2; + S.size = cl(k[2], 0, 1); + S.wet = cl(k[3], 0, 1); + if (!S.started) { + S.started = true; + fill(S); + for (let i = 0; i < 30; i++) { + label(S); + step(S, 1 / 30); + } + S.since = 6; + } + if (input.btnPressed && input.btnPressed[0]) (S.boost = 2), (S.since = 0); + step(S, d * S.rate); +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + label(S); + const { x, y, r, l1, l2, p1, p2, col, buf, tone } = S; + const hue = H0 + S.hue; + // Inside a bubble, its rim, the wall, the liquid between bubbles. Orange + // and yellow kept dim read brown and olive on LEDs, so near those hues + // the inside of a bubble is lit brighter. + const hw = (hue - floor(hue) + 1) % 1, am = max(0, 1 - abs(hw - 0.17) / 0.17); + hsv(hue, 0.5 - 0.12 * am, 0.52 + 0.34 * am, col, 0); + hsv(hue - 0.02, 0.4 - 0.05 * am, 0.85 + 0.13 * am, col, 3); + hsv(hue + 0.03, 0.12, 0.98, col, 6); + hsv(hue, 0.3, 0.72 + 0.16 * am, col, 9); + for (let Y = 0; Y < H; Y++) { + const phi = liquid(S, Y), sd = 0.88 + 0.9 * (1 - min(1, phi / 0.36)) ** 2; + for (let X = 0; X < W; X++) { + const p = (Y + M) * EW + X + M, i = l1[p], q = (Y * W + X) * 3; + if (i < 0) { + buf[q] = col[9]; + buf[q + 1] = col[10]; + buf[q + 2] = col[11]; + continue; + } + const px = X + 0.5, py = Y + 0.5, dx = px - x[i], dy = py - y[i], d = sqrt(dx * dx + dy * dy); + const j = l2[p]; + let dw = 9; + if (j >= 0) { + const ex = x[j] - x[i], ey = y[j] - y[i]; + dw = (p2[p] - p1[p]) / (2 * sqrt(ex * ex + ey * ey) + 1e-4); + } + // A bubble is its cell clipped to a disc: in a dry foam the disc + // covers the cell and the bubble is a polygon; in a wet one it is + // round, sitting in liquid that fills the corners between bubbles. + const ed = r[i] * sd - d, e = min(dw, ed); + let R, G, B; + if (e < 0) { + // Liquid: milky, brightest against the bubbles it wets. + const k = exp(e * 0.9) * 0.35; + R = col[9] + (col[6] - col[9]) * k; + G = col[10] + (col[7] - col[10]) * k; + B = col[11] + (col[8] - col[11]) * k; + } else { + // The dome: dim through its middle, brighter round its rim as the + // film turns edge on and low on the right where the light that came + // in through it gathers, a thin bright wall, and a point of light up + // and to the left. + const u = d / (d + e + 1e-4), rr = r[i], gx = dx - rr * 0.3, gy = dy - rr * 0.34; + const g2 = 1 - (gx * gx + gy * gy) / (1.6 * rr * rr), lens = g2 > 0 ? g2 * g2 * 0.45 : 0, rim = max(u * u * u, lens); + const v = 0.85 + 0.3 * tone[i]; + R = (col[0] * (1 - rim) + col[3] * rim) * v; + G = (col[1] * (1 - rim) + col[4] * rim) * v; + B = (col[2] * (1 - rim) + col[5] * rim) * v; + const s = 0.32 + 0.1 * rr, hx = dx + rr * 0.36, hy = dy + rr * 0.4, h2 = (hx * hx + hy * hy) / (2 * s * s); + const h5 = 1 - h2 * 0.2, hl = h5 > 0 ? h5 * h5 * h5 * h5 * h5 * 255 : 0; + // The wall, about 1.3 LEDs across: (1 - (e/1.3)^2)^2. + const w2 = 1 - e * e * 0.6, wl = w2 > 0 ? w2 * w2 * (ed < dw ? 0.75 : 1) : 0; + R += (col[6] - R) * wl + hl; + G += (col[7] - G) * wl + hl; + B += (col[8] - B) * wl + hl; + } + buf[q] = R; + buf[q + 1] = G; + buf[q + 2] = B; + } + } + // A burst wall flashes white along its length, and for a moment the new + // bubble it leaves is lit through. + for (let f = 0; f < S.nfl; f++) { + const o = f * 8, fl = S.fl, life = fl[o + 4], len = Math.hypot(fl[o + 2] - fl[o], fl[o + 3] - fl[o + 1]); + const steps = max(2, Math.ceil(len * 2)), lf = life * life; + for (let k = 0; k <= steps; k++) { + const t = k / steps; + spot(buf, fl[o] + (fl[o + 2] - fl[o]) * t, fl[o + 1] + (fl[o + 3] - fl[o + 1]) * t, lf * 200 * (1 - 0.6 * abs(t - 0.5))); + } + const rm = fl[o + 5] * 0.85, cx = fl[o + 6], cy = fl[o + 7]; + for (let Y = max(0, floor(cy - rm)); Y <= min(H - 1, floor(cy + rm)); Y++) + for (let X = max(0, floor(cx - rm)); X <= min(W - 1, floor(cx + rm)); X++) { + const d2 = ((X + 0.5 - cx) ** 2 + (Y + 0.5 - cy) ** 2) / (rm * rm); + if (d2 >= 1) continue; + const w = (1 - d2) * lf * 0.7, q = (Y * W + X) * 3; + buf[q] += (col[6] - buf[q] * 0.5) * w; + buf[q + 1] += (col[7] - buf[q + 1] * 0.5) * w; + buf[q + 2] += (col[8] - buf[q + 2] * 0.5) * w; + } + } + for (let k = 0; k < S.ndr; k++) { + const o = k * 5; + spot(buf, S.dr[o], S.dr[o + 1], S.dr[o + 4] * 200); + } + for (let p = 0, Y = 0; Y < H; Y++) for (let X = 0; X < W; X++, p += 3) display.setPixel(X, Y, knee(buf[p]), knee(buf[p + 1]), knee(buf[p + 2])); +} + +// White light v shared over the four LEDs round x, y. +function spot(buf, x, y, v) { + const X = floor(x - 0.5), Y = floor(y - 0.5), fx = x - 0.5 - X, fy = y - 0.5 - Y; + for (let k = 0; k < 4; k++) { + const xx = X + (k & 1), yy = Y + (k >> 1); + if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue; + const w = (k & 1 ? fx : 1 - fx) * (k >> 1 ? fy : 1 - fy) * v, q = (yy * W + xx) * 3; + buf[q] += w; + buf[q + 1] += w; + buf[q + 2] += w; + } +} + +function knee(v) { + return v < 200 ? v : 255 - 55 * exp(-(v - 200) / 55); +} From 11543a7a7daad11e8885131de5e77655bf52bab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:39:00 +0000 Subject: [PATCH 07/16] =?UTF-8?q?31=ED=8C=90:=20Indra's=20Pearls=20?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/indras-pearls.js | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 incoming/pattern-study/patterns/indras-pearls.js diff --git a/incoming/pattern-study/patterns/indras-pearls.js b/incoming/pattern-study/patterns/indras-pearls.js new file mode 100644 index 000000000..438a417bb --- /dev/null +++ b/incoming/pattern-study/patterns/indras-pearls.js @@ -0,0 +1,260 @@ +// ===== Patternflow pattern ===== +// Title: Indra's Pearls +// Family: geometry +// Idea: The limit set of a Maskit Kleinian group stood on end: the strip it lives in runs down the panel between two white lines, a necklace of tangent discs fills it with chains of ever smaller pearls nested in the gaps, the whole necklace slides down by the group's own translation z -> z + 2, and the parameter mu drifts along the edge of the Maskit slice so the chain pinches and opens into different double-cusp packings. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: limit set of a Kleinian group (Henri Poincaré, 1883), the Maskit slice as drawn in Indra's Pearls (David Mumford, Caroline Series and David Wright, 2002), pattern round 31 +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Shape=0..1, Zoom=0..1 +// +// Knob 1: Hue. Turns every colour round the wheel together; at 0 the big discs are deep blue and the pearls teal. +// Knob 2: Speed. At 2 the necklace slides five LEDs a second, the chain pinches and opens again every ten seconds and mu swings along the edge of the slice and back in seventeen; at 0.2 all of it goes ten times slower. +// Knob 3: Shape. Where on the edge of the Maskit slice mu swings: at 0 through the cusp mu = 2i, where the big discs stack straight down the middle, out to the cusp 1/10 on either side, where the necks between them wind into double spirals; turned up, the swing narrows and moves past the cusps 1/8, 1/6, 1/5, 1/4 and 1/3 to 1/2 at mu = -1 + 1.73i, where the discs zigzag from wall to wall. +// Knob 4: Zoom. Closes in, up to four times, on a fixed point of a, where the chain necks and the small pearls spiral in: near 0 the whole strip fits the 64 columns with both walls showing; at 1 the view is a quarter of the strip wide and the spirals fill it as they slide past. +// Buttons: K1 tap lights the pearls one generation after another, from the big ones on the walls inward to the smallest. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, exp, abs, sqrt, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// Cusps of the Maskit slice, mu = Re + i Im, from the cusp 0/1 at 2i to 1/2. +// Each is the root of tr W_p/q(mu) = 2 with the largest imaginary part, +// found by the Farey recursion on traces (tr a = -i mu, tr b = 2, +// tr ab = -i mu - 2i). Between two of them the slice's edge dips well below +// the chord, so mu on the chord, or a little above, stays inside the slice. +const CUSP = [ + [0, 2], [-0.0216, 1.9374], [-0.0363, 1.9134], [-0.0674, 1.8745], [-0.1427, 1.8103], + [-0.2201, 1.7667], [-0.3522, 1.7214], [-0.5812, 1.6939], [-1, 1.7321], +]; +const NC = CUSP.length - 1; + +// The strip 0 < Im z < Im mu spans this many of the 64 columns at Zoom 0, +// which leaves three LEDs on each side for the dark half-planes. Im mu runs +// from 1.69 to 2.06, so that is 28 to 34 LEDs per unit of the plane. +const SPAN = 58; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +export function setup(params) { + params.s = { + scroll: 0, ph: 0, sw: 0, hue: 0, shape: 0, zoom: 0.06, wave: -1, + mr: 0, mi: 2, uz: SPAN / 2, cx: 0, cy: 1, buf: new Float32Array(N * 3), pal: new Float32Array(64 * 3), palHue: -1, + }; +} + +// mu for a place c on the cusp chain (0..NC, and negative for its mirror, +// Re mu -> -Re mu, which the slice shares) lifted by l into the slice. +function muAt(c, l, P) { + const m = c < 0 ? -1 : 1; + c = min(abs(c), NC); + const i = min(floor(c), NC - 1), f = c - i; + P.mr = m * (CUSP[i][0] + (CUSP[i + 1][0] - CUSP[i][0]) * f); + P.mi = CUSP[i][1] + (CUSP[i + 1][1] - CUSP[i][1]) * f + l; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + const sp = cl(k[1], 0.2, 3); + P.hue = k[0]; + P.shape = cl(k[2], 0, 1); + P.zoom = cl(k[3], 0, 1); + P.ph += d * sp * 0.05; + P.sw += d * sp * 0.03; + // The lift above the cusp chain breathes between 0, where the chain + // pinches into tangent discs, and 0.06, where the invariant component + // opens into one winding band, every ten seconds at Speed 2. Over about + // seventeen seconds mu also swings along the chain and back: at Shape 0 + // from the cusp 1/10 through 2i to its mirror image, so the spirals at the + // necks wind up one way, unwind and wind up the other way. + const lift = 0.06 * (0.5 - 0.5 * Math.cos(P.ph * 2 * PI)) ** 1.5; + const c = P.shape * NC + Math.sin(P.sw * 2 * PI) * (1.9 - 1.1 * P.shape); + muAt(c, lift, P); + // LEDs per unit of the plane, and the slide: five LEDs a second at Speed 2 + // at any zoom. The picture repeats every 2 along Re z. + P.uz = (SPAN / P.mi) * 4 ** P.zoom; + // The zoom closes in on a fixed point of a, (mu - sqrt(mu^2 + 4)) / 2, the + // one in the lower half of the strip. It lies on the limit set at a neck + // of the chain, where the small pearls spiral in; the slide carries it + // through the middle of the view once a period. + const ar = P.mr * P.mr - P.mi * P.mi + 4, ai = 2 * P.mr * P.mi, am = sqrt(ar * ar + ai * ai); + let sr = sqrt(max(0, (am + ar) / 2)), si = sqrt(max(0, (am - ar) / 2)); + if (ai < 0) si = -si; + if (si < 0) { + sr = -sr; + si = -si; + } + const zs = (1 - 4 ** -P.zoom) / 0.75; + P.cx = ((P.mr - sr) / 2) * zs; + P.cy = P.mi / 2 + ((P.mi - si) / 2 - P.mi / 2) * zs; + P.scroll = (P.scroll + (d * sp * 2.5) / P.uz) % 2; + const b = input.btnPressed || []; + if (b[0]) P.wave = 0; + if (P.wave >= 0) P.wave = P.wave > 14 ? -1 : P.wave + d * 2.2; +} + +// One point z = x + iy through Jos Leys's escape algorithm for the Maskit +// group a(z) = mu + 1/z, b(z) = z + 2. Each step wraps z into the strip of b +// (sheared along mu), turns it half round about mu/2 if it lies above the +// separation line (that half turn maps the limit set onto itself and swaps a +// with its inverse), then applies a. It stops when z leaves the strip +// 0 < Im z < Im mu (it was in a pearl, a disc that some word of the group +// sends onto a half-plane), or falls into a 2-cycle (it is in the invariant +// component, or in one of the discs it pinches into at a cusp). OUT holds +// the kind, the step count and a distance to the limit set in plane units. +const OUT = new Float64Array(3); +// 1 - e^-v in steps of 1/32 up to v = 12, for the separation line. +const SEP = new Float64Array(385); +for (let j = 0; j < 385; j++) SEP[j] = 1 - exp(-j / 32); +function trace(x, y, mr, t, maxIt) { + if (y < 0 || y > t) { + OUT[0] = 0; + OUT[1] = 0; + OUT[2] = y < 0 ? -y : y - t; + return; + } + const sh = mr / t, f = mr > 0 ? -1 : mr < 0 ? 1 : 0, lean = (f * (2 * t - 1.95)) / 4, kk = 7.2 - (1.95 - t) * 15, hm = mr / 2, ht = t / 2, al = abs(lean); + let lx = x + 9, ly = y, llx = x - 9, lly = y, der = 1; + for (let i = 0; i < maxIt; i++) { + const s = x - sh * y; + if (s >= 1 || s < -1) x -= 2 * floor((s + 1) * 0.5); + const u = x - hm; + let up = y >= ht + al; + if (!up && y >= ht - al) { + const v = kk * (u > 0 ? u : -u) * 32; + let sep = 1; + if (v < 383) { + const j = v | 0; + sep = SEP[j] + (SEP[j + 1] - SEP[j]) * (v - j); + } + up = y >= ht + (u > 0 ? lean : -lean) * sep; + } + if (up) { + x = mr - x; + y = t - y; + } + const r = 1 / (x * x + y * y); + der *= r; + x = mr + x * r; + y = t - y * r; + if (y < 0 || y > t) { + OUT[0] = 1; + OUT[1] = i + 1; + OUT[2] = (y < 0 ? -y : y - t) / der; + return; + } + const ex = x - llx, ey = y - lly; + if (ex * ex + ey * ey < 1e-8) { + // How far the cycling point lies from the nearest limit set close to + // the fundamental domain: the two walls and the pearls a^-1(H-) and + // a^2(H-) that touch them at 0 and mu. Divided by the stretch of the + // word that brought it there, that is the distance back where it + // started, to first order. + let s2 = x - sh * y; + s2 -= 2 * floor((s2 + 1) / 2); + x = s2 + sh * y; + const rr = 0.5 / t, dt2 = t - rr; + let dd = min(y, t - y); + dd = min(dd, sqrt(x * x + (y - rr) * (y - rr)) - rr, sqrt((x - mr) * (x - mr) + (y - dt2) * (y - dt2)) - rr); + OUT[0] = 2; + OUT[1] = i + 1; + OUT[2] = max(0, dd) / der; + return; + } + llx = lx; + lly = ly; + lx = x; + ly = y; + } + OUT[0] = 3; + OUT[1] = maxIt; + OUT[2] = 0; +} + +// Colours: 0..31 the invariant component by step count, 32..63 the pearls +// by generation. +function palette(P) { + const pal = P.pal, h = P.hue + 0.6; + for (let i = 0; i < 32; i++) { + const u = i / 31; + // A dark orange or olive reads as brown, so the discs lift their light + // where the hue passes through them and turn gold instead. + let hh = h + 0.07 * u * u; + hh -= floor(hh); + const lift = max(0, 1 - abs(hh - 0.1) / 0.16); + hsv(hh, 0.85 - 0.3 * u * u, 0.2 + 0.5 * u * u + 0.5 * lift * (1 - u * u), pal, i * 3); + // Generation 1 teal, then cyan, sky, lavender and pink, paler as they + // shrink. + const g = min(i, 6) / 6; + hsv(h - 0.12 + 0.36 * g, 0.72 - 0.32 * g, 0.72 + 0.24 * g, pal, (32 + i) * 3); + } + P.palHue = P.hue; +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + if (P.palHue !== P.hue) palette(P); + const buf = P.buf, pal = P.pal, mr = P.mr, t = P.mi; + const uz = P.uz, su = 1 / uz, maxIt = 40 + ((40 * P.zoom) | 0); + const y0 = P.cy - (W / 2) * su, x0 = P.cx - P.scroll - (H / 2) * su; + for (let py = 0, p = 0; py < H; py++) { + const x = x0 + (py + 0.5) * su; + for (let px = 0; px < W; px++, p += 3) { + trace(x, y0 + (px + 0.5) * su, mr, t, maxIt); + const kind = OUT[0], n = OUT[1], de = OUT[2] * uz; + let r, g, b; + if (kind === 0) { + r = 0.02; + g = 0.02; + b = 0.05; + } else if (kind === 1) { + const o = (32 + min(n - 1, 31)) * 3; + // A pearl is a little darker at its rim, so neighbours part. + const v = 0.55 + 0.45 * min(1, de / 2.5); + r = pal[o] * v; + g = pal[o + 1] * v; + b = pal[o + 2] * v; + if (P.wave >= 0) { + const dw = P.wave - n, fl = exp(-dw * dw * 2) * 0.8; + r += (1 - r) * fl; + g += (1 - g) * fl; + b += (1 - b) * fl; + } + } else if (kind === 2) { + // The invariant component glows in from the limit set, about five + // LEDs deep, over a dark core. + const o = min(31, (31 * exp(-de / 5)) | 0) * 3; + r = pal[o]; + g = pal[o + 1]; + b = pal[o + 2]; + } else { + r = 0.7; + g = 0.72; + b = 0.8; + } + // The limit set: a white line a LED or so wide wherever the distance + // to it is under a LED. + if (kind !== 3) { + const w = 0.92 * exp(-de * de * 2.4); + r += (0.92 - r) * w; + g += (0.94 - g) * w; + b += (1 - b) * w; + } + buf[p] = r * 255; + buf[p + 1] = g * 255; + buf[p + 2] = b * 255; + } + } + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, buf[p], buf[p + 1], buf[p + 2]); +} From 859303eaa8ce9e2514d0d6e04e92a097f96eeba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:39:00 +0000 Subject: [PATCH 08/16] =?UTF-8?q?31=ED=8C=90:=20Littlewood=20Roots=20?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../patterns/littlewood-roots.js | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 incoming/pattern-study/patterns/littlewood-roots.js diff --git a/incoming/pattern-study/patterns/littlewood-roots.js b/incoming/pattern-study/patterns/littlewood-roots.js new file mode 100644 index 000000000..67486a6ce --- /dev/null +++ b/incoming/pattern-study/patterns/littlewood-roots.js @@ -0,0 +1,297 @@ +// ===== Patternflow pattern ===== +// Title: Littlewood Roots +// Family: fractal +// Idea: Every root of every polynomial of one degree whose coefficients are all +1 or -1 glows as a soft lace round the unit circle, with holes at the roots of unity, knotted fringes at its edges and a line of real roots, while the polynomials are walked one sign flip at a time and the roots of the current one hop over the lace as sparks that relight it where they land. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: roots of Littlewood polynomials (John Edensor Littlewood, 1966), pattern round 31 +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Degree=13..16, Glow=0..1 +// +// Knob 1: Hue. Turns the lace and the sparks round the colour wheel together; at 0 the lace is sea green, paler where it is dense, and the sparks nearly white. +// Knob 2: Speed. How many sign flips a second: eight at 2, twelve at 3 and under one at 0.2, where the light piles up under sparks that hardly move; each flip moves a spark a few LEDs. +// Knob 3: Degree. The degree n of the polynomials, 13 to 16: at 13 the lace holds the 106,496 roots of all 8192 polynomials with leading coefficient 1, at 16 nearly ten times as many, and the band grows narrower and smoother. Each change computes the lace afresh, and it draws itself in, in a fifth of a second at 13 and about two seconds at 16. +// Knob 4: Glow. How long a place stays lit after a spark lands on it, from just over a second near 0 to ten seconds at 1; the sparks and the patches they light grow wider with it, until at 1 most of the band is lit. +// Buttons: K1 tap flashes the unit circle the lace hugs. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, exp, abs, sqrt, cos, sin, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// 44 LEDs per unit: Re z from 0 to 1.45 across, Im z from 1.45 down to +// -1.45, so the unit circle runs from row 20 to row 108. The set is the same +// under z -> -z and z -> conj z, so the left half folds onto the right; all +// roots lie in 1/2 < |z| < 2, and those beyond 1.45 are few. +const S = 44; +// Widths in LEDs of the fine blur, the wide blur the lace is compared with, +// the sparks and the light they leave. +const FINE = 0.6, WIDE = 2.5, SPARK = 1.8, DEP = 1.4; +const px = (re) => abs(re) * S - 0.5, py = (im) => H / 2 - 0.5 - im * S; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// Normalised Gaussian kernels, three widths each side. +function kernel(sg) { + const R = Math.ceil(sg * 3), k = new Float32Array(2 * R + 1); + let s = 0; + for (let i = -R; i <= R; i++) s += k[i + R] = exp((-i * i) / (2 * sg * sg)); + for (let i = 0; i <= 2 * R; i++) k[i] /= s; + return k; +} +const KF = kernel(FINE), KW = kernel(WIDE); + +export function setup(params) { + params.s = { + n: 0, M: 0, i: 0, c: null, re: null, im: null, pr: null, pi: null, built: 0, + dens: new Float32Array(N), lace: new Float32Array(N), tmp: new Float32Array(N), wide: new Float32Array(N), glow: new Float32Array(N), + step: 0, hue: 0, sp: 2, tau: 1, ring: 0, glw: 0.06, col: new Float32Array(12), colHue: -1, + }; +} + +// The polynomial z^n + z^(n-1) + c(n-2) z^(n-2) + ... + c0 with every c = +-1. +// Fixing the top two coefficients loses nothing: -p(-z) has the other sign +// of z^(n-1), and its roots are -z, which fold onto the conjugates of the +// roots of p. The walk starts at all +1, whose roots are the (n+1)th roots +// of unity other than 1. +function start(P, n) { + P.n = n; + P.M = 1 << (n - 1); + P.i = 0; + P.built = 0; + P.c = new Float64Array(n + 1).fill(1); + P.re = new Float64Array(n); + P.im = new Float64Array(n); + P.pr = new Float64Array(n); + P.pi = new Float64Array(n); + for (let k = 0; k < n; k++) { + const a = (2 * PI * (k + 1)) / (n + 1); + P.re[k] = P.pr[k] = cos(a); + P.im[k] = P.pi[k] = sin(a); + } + P.dens.fill(0); + P.lace.fill(0); +} + +// Durand-Kerner (Weierstrass) iteration: every root at once moves by +// p(z_r) / prod (z_r - z_s). Started from the roots of the polynomial one +// sign flip back, it settles in about eight rounds. Returns false if it has +// not settled. +function solve(P, rounds) { + const n = P.n, c = P.c, re = P.re, im = P.im; + for (let it = 0; it < rounds; it++) { + let mx = 0; + for (let r = 0; r < n; r++) { + const x = re[r], y = im[r]; + let pr = 1, pi = 0; + for (let k = n - 1; k >= 0; k--) { + const t = pr * x - pi * y + c[k]; + pi = pr * y + pi * x; + pr = t; + } + let dr = 1, di = 0; + for (let s = 0; s < n; s++) { + if (s === r) continue; + const ux = x - re[s], uy = y - im[s], t = dr * ux - di * uy; + di = dr * uy + di * ux; + dr = t; + } + let dd = dr * dr + di * di; + if (!(dd > 1e-30)) dd = 1e-30; + const qr = (pr * dr + pi * di) / dd, qi = (pi * dr - pr * di) / dd; + re[r] = x - qr; + im[r] = y - qi; + const m = qr * qr + qi * qi; + if (m > mx) mx = m; + } + if (mx < 1e-13) return true; + } + return false; +} + +// One step along the Gray code: flip the coefficient its index says, then +// find the new roots. If the warm start fails (two roots nearly meeting), +// start again from a circle. +function advance(P) { + P.i = (P.i + 1) % P.M; + let b = P.n - 2; + if (P.i) for (b = 0; !((P.i >> b) & 1); b++); + P.c[b] = -P.c[b]; + if (solve(P, 24)) return; + for (let k = 0; k < P.n; k++) { + P.re[k] = 1.1 * cos((2 * PI * k) / P.n + 0.4); + P.im[k] = 1.1 * sin((2 * PI * k) / P.n + 0.4); + } + solve(P, 80); +} + +// Bilinear splat of the folded roots into the density. The fold puts the +// mirror line Re z = 0 half a LED left of column 0, so weight that falls +// into column -1 belongs to column 0. +function splat(P) { + const re = P.re, im = P.im, d = P.dens; + for (let r = 0; r < P.n; r++) { + const X = px(re[r]), Y = py(im[r]); + const x0 = floor(X), y0 = floor(Y), fx = X - x0, fy = Y - y0; + if (!(x0 >= -1 && x0 < W && y0 >= 0 && y0 < H - 1)) continue; + const xa = x0 < 0 ? 0 : x0, xb = x0 + 1; + d[y0 * W + xa] += (1 - fx) * (1 - fy); + d[(y0 + 1) * W + xa] += (1 - fx) * fy; + if (xb < W) { + d[y0 * W + xb] += fx * (1 - fy); + d[(y0 + 1) * W + xb] += fx * fy; + } + } +} + +// Separable blur of src into out with kernel k; the column left of 0 is the +// mirror of column 0, as the fold makes it. +function blur(src, out, tmp, k) { + const R = (k.length - 1) / 2; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let a = 0; + for (let i = -R; i <= R; i++) { + let xx = x + i; + if (xx < 0) xx = -1 - xx; + else if (xx >= W) continue; + a += src[y * W + xx] * k[i + R]; + } + tmp[y * W + x] = a; + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let a = 0; + for (let i = -R; i <= R; i++) { + const yy = y + i; + if (yy >= 0 && yy < H) a += tmp[yy * W + x] * k[i + R]; + } + out[y * W + x] = a; + } +} + +// The lace level, 0..1: the finely blurred density, normalised by the number +// of roots expected in all so the lace fades in while it builds, and +// sharpened against its own wide blur, so the holes round the roots of unity +// go dark and the knots of the fringe stand out of the band. +function tone(P) { + const L = P.lace, Wd = P.wide; + blur(P.dens, L, P.tmp, KF); + blur(P.dens, Wd, P.tmp, KW); + // About 3600 LEDs of band at this scale, so this makes its mean density 1. + const norm = 3600 / (P.M * P.n); + for (let p = 0; p < N; p++) { + const a = L[p], q = a / (Wd[p] + 1e-9); + L[p] = min(1, min(1, (a * norm) / 2.5) ** 1.2 * min(1.6, q * q)); + } +} + +function blob(G, X, Y, sg, amp) { + const R = Math.ceil(sg * 2.6), k = 1 / (2 * sg * sg); + const xa = max(0, floor(X - R)), xb = min(W - 1, floor(X + R) + 1), ya = max(0, floor(Y - R)), yb = min(H - 1, floor(Y + R) + 1); + for (let y = ya; y <= yb; y++) + for (let x = xa; x <= xb; x++) { + const dx = x - X, dy = y - Y; + // The mirror image across Re z = 0 for blobs near the left edge. + const dm = x + 1 + X; + G[y * W + x] += amp * (exp(-(dx * dx + dy * dy) * k) + (X < R ? exp(-(dm * dm + dy * dy) * k) : 0)); + } +} + +function colours(P) { + const h = P.hue + 0.42, c = P.col; + // The lace deep where it is thin and paler where it is dense, the light + // the sparks leave a little bluer, and the sparks nearly white. + hsv(h, 0.85, 1, c, 0); + hsv(h + 0.03, 0.4, 1, c, 9); + hsv(h + 0.07, 0.5, 1, c, 3); + hsv(h + 0.03, 0.18, 1, c, 6); + P.colHue = P.hue; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 13, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.sp = cl(k[1], 0.2, 3); + // About a second at Glow 0, eight at 1. + P.glw = cl(k[3], 0, 1); + P.tau = 1.2 * 8 ** P.glw; + const n = round(cl(k[2], 13, 16)); + if (n !== P.n) start(P, n); + if (P.built < P.M) { + // Build the lace: about 1.3 million root updates a frame, so degree 13 + // takes six frames and degree 16 about two seconds. + const per = max(64, floor(1.3e6 / (9 * n * n))); + for (let j = 0; j < per && P.built < P.M; j++, P.built++) { + advance(P); + splat(P); + } + tone(P); + if (P.built >= P.M) { + P.step = 0; + P.pr.set(P.re); + P.pi.set(P.im); + } + } else { + // The walk: 4 x Speed flips a second. A spark glides from where it was + // to its new root over the first half of the step and lights the lace + // where it lands. + P.step += d * 4 * P.sp; + while (P.step >= 1) { + P.step -= 1; + for (let r = 0; r < P.n; r++) blob(P.glow, px(P.re[r]), py(P.im[r]), DEP + 2.2 * P.glw, 0.55); + P.pr.set(P.re); + P.pi.set(P.im); + advance(P); + } + } + const g = P.glow, f = exp(-d / P.tau); + for (let p = 0; p < N; p++) g[p] *= f; + const b = input.btnPressed || []; + if (b[0]) P.ring = 1; + P.ring = max(0, P.ring - d * 0.6); +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + if (P.colHue !== P.hue) colours(P); + const L = P.lace, G = P.glow, T = P.tmp, c = P.col; + // The sparks into the scratch buffer, eased between their old and new + // roots. + T.fill(0); + if (P.built >= P.M) { + let e = cl(P.step * 2, 0, 1); + e = e * e * (3 - 2 * e); + for (let r = 0; r < P.n; r++) { + const x = P.pr[r] + (P.re[r] - P.pr[r]) * e, y = P.pi[r] + (P.im[r] - P.pi[r]) * e; + blob(T, px(x), py(y), SPARK + 1.4 * P.glw, 0.36); + } + } + const ring = P.ring * P.ring; + for (let y = 0, p = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + const l = L[p], lace = l > 0.09 ? (0.6 * (l - 0.09)) / 0.91 : 0, sp0 = T[p], m = 0.5 * l * l; + // The glow saturates softly and only lights the lace, so a place lit + // again and again shows the lace's own grain. + const gl = 0.4 * l * (1 - exp(-G[p] * 1.2)); + let sp = sp0; + if (ring > 0) { + const re = (x + 0.5) / S, im = (H / 2 - 0.5 - y) / S, dr = (sqrt(re * re + im * im) - 1) * S; + sp += 0.45 * ring * exp(-dr * dr * 0.18); + } + const v1 = lace, v2 = gl, v3 = min(0.5, sp); + const lr = c[0] + (c[9] - c[0]) * m, lg = c[1] + (c[10] - c[1]) * m, lb = c[2] + (c[11] - c[2]) * m; + const r = lr * v1 + c[3] * v2 + c[6] * v3, g = lg * v1 + c[4] * v2 + c[7] * v3, b = lb * v1 + c[5] * v2 + c[8] * v3; + display.setPixel(x, y, min(255, r * 255), min(255, g * 255), min(255, b * 255)); + } +} From 42f3ecbf81d59b1888af8cfa281b71b1a901715d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:39:18 +0000 Subject: [PATCH 09/16] =?UTF-8?q?31=ED=8C=90:=20Boy's=20Surface=20?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/boys-surface.js | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 incoming/pattern-study/patterns/boys-surface.js diff --git a/incoming/pattern-study/patterns/boys-surface.js b/incoming/pattern-study/patterns/boys-surface.js new file mode 100644 index 000000000..e3f22d684 --- /dev/null +++ b/incoming/pattern-study/patterns/boys-surface.js @@ -0,0 +1,357 @@ +// ===== Patternflow pattern ===== +// Title: Boy's Surface +// Family: topology +// Idea: Boy's immersion of the projective plane turns slowly about its three-fold axis in the top square while a level plane sweeps up and down through it, and the bottom square looks down on the cut: one closed curve that changes shape with the height and crosses itself three ways at the triple point, over a surface coloured warm on one face and cool on the other so the seam where the two meet shows it has only one side. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Boy's surface (Werner Boy, 1901), in the parametrisation of Robert Bryant and Rob Kusner, pattern round 31 +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Tilt=0..1, Bands=0..1 +// +// Knob 1: Hue. Turns both faces round the wheel together; at 0 the front of the disk is pink and the back is cyan. +// Knob 2: Speed. At 2 the surface turns a third of the way round, which brings it back to the same shape, in about nine seconds and the plane sweeps up and down once in twenty; at 0.2 both go ten times slower. +// Knob 3: Tilt. The height of the eye: at 0 it looks across the surface from 22 degrees above the level, at 1 straight down the three-fold axis as the bottom square does, through the glass onto the cut. +// Knob 4: Bands. Circles of the parameter disk painted on the surface as dark stripes, from two broad ones at 0 to nine at 1, counted in from the seam, which always stays light. +// Buttons: K1 tap brings the plane to the height of the triple point in under a second and holds it there five seconds, where the cut is three loops through one point. + +const W = 64, S = 64, N = W * 128; +const { min, max, floor, sqrt, sin, cos, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); + +// The disk is sampled on NR rings of NA points; ring 0 is the centre. +const NR = 64, NA = 192, NP = (NR + 1) * NA, NC = NR * NA; +// The surface lies within 1.571 of (0, 0, ZC), so 19.4 LEDs a unit keeps +// it inside its square at every turn and tilt. The triple point is at the +// origin, the top of the lobes at z = 0.443 and the pole at z = -2. +const ZC = -0.779, SC = 19.4, CX = 31.5, CY = 31.5, PY = 64 + 31.5; +const Z0 = -1.45, Z1 = 0.3; + +// Bryant and Kusner's formula for one point w = re + i im of the closed +// unit disk, written out in real arithmetic. +const S5 = sqrt(5); +function boy(re, im, out, o) { + const r2 = re * re - im * im, i2 = 2 * re * im; + const r3 = r2 * re - i2 * im, i3 = r2 * im + i2 * re; + const r4 = r2 * r2 - i2 * i2, i4 = 2 * r2 * i2; + const r6 = r3 * r3 - i3 * i3, i6 = 2 * r3 * i3; + const dr = r6 + S5 * r3 - 1, di = i6 + S5 * i3, dd = dr * dr + di * di; + // w (1 - w^4), w (1 + w^4) and 1 + w^6, each over the denominator. + const ar = re * (1 - r4) + im * i4, ai = im * (1 - r4) - re * i4; + const br = re * (1 + r4) - im * i4, bi = im * (1 + r4) + re * i4; + const g1 = (-1.5 * (ai * dr - ar * di)) / dd; + const g2 = (-1.5 * (br * dr + bi * di)) / dd; + const g3 = (i6 * dr - (1 + r6) * di) / dd - 0.5; + const g = g1 * g1 + g2 * g2 + g3 * g3; + out[o] = g1 / g; + out[o + 1] = g2 / g; + out[o + 2] = g3 / g; +} + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// Points, normals and cells, once. The normal is +// d/dRe x d/dIm of the map, the orientation of the disk: across the seam, +// where antipodal points of the boundary circle meet, it points the other +// way, and the colours follow it. +function build(G) { + const P = G.p, Nn = G.n, T = new Float64Array(12); + for (let i = 0; i <= NR; i++) { + const r = i / NR; + for (let j = 0; j < NA; j++) { + const a = (TAU * j) / NA, re = r * cos(a), im = r * sin(a), k = i * NA + j, h = 1e-4; + boy(re, im, P, k * 3); + boy(re + h, im, T, 0); + boy(re - h, im, T, 3); + boy(re, im + h, T, 6); + boy(re, im - h, T, 9); + const ux = T[0] - T[3], uy = T[1] - T[4], uz = T[2] - T[5], vx = T[6] - T[9], vy = T[7] - T[10], vz = T[8] - T[11]; + let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + const l = 1 / (sqrt(nx * nx + ny * ny + nz * nz) + 1e-30); + Nn[k * 3] = nx * l; + Nn[k * 3 + 1] = ny * l; + Nn[k * 3 + 2] = nz * l; + } + } + // The corners of each grid cell, and its mean height, which decides + // whether it is under the plane. + for (let i = 0; i < NR; i++) + for (let j = 0; j < NA; j++) { + const c = i * NA + j, o = c * 4, q = [c, c + NA, (i + 1) * NA + ((j + 1) % NA), i * NA + ((j + 1) % NA)]; + for (let e = 0; e < 4; e++) G.cc[o + e] = q[e]; + G.cz[c] = (P[q[0] * 3 + 2] + P[q[1] * 3 + 2] + P[q[2] * 3 + 2] + P[q[3] * 3 + 2]) / 4; + } + // Cells of the grid by height: bin b lists every cell whose corners span + // a height in it, so the cut only looks at the cells near the plane. + const cnt = new Int32Array(NB + 1), lo = new Int32Array(NR * NA), hi = new Int32Array(NR * NA); + for (let i = 0; i < NR; i++) + for (let j = 0; j < NA; j++) { + const c = i * NA + j; + let a = 9, b = -9; + for (let e = 0; e < 4; e++) (a = min(a, P[G.cc[c * 4 + e] * 3 + 2])), (b = max(b, P[G.cc[c * 4 + e] * 3 + 2])); + lo[c] = bin(a); + hi[c] = bin(b); + for (let k = lo[c]; k <= hi[c]; k++) cnt[k + 1]++; + } + for (let k = 0; k < NB; k++) cnt[k + 1] += cnt[k]; + G.binAt = cnt.slice(); + G.cells = new Int32Array(cnt[NB]); + for (let c = 0; c < NR * NA; c++) for (let k = lo[c]; k <= hi[c]; k++) G.cells[cnt[k]++] = c; +} + +// Height bins for the cut, from the pole at -2 to the top at 0.45. +const NB = 96; +const bin = (z) => cl(floor(((z + 2.01) / 2.47) * NB), 0, NB - 1); + +export function setup(params) { + const A = (n) => new Float32Array(n), I = (n) => new Int32Array(n); + params.s = { + p: A(NP * 3), n: A(NP * 3), cc: I(NC * 4), cz: A(NC), binAt: null, cells: null, + // Each point's place in the side view, its depth there, and its row in the plan. + vx: A(NP), vy: A(NP), vd: A(NP), vp: A(NP), + // Depth and point per LED: the side view under the plane and over it, + // and the plan, which looks straight down on what is under it. + za: A(W * S), ia: I(W * S), zo: A(W * S), io: I(W * S), zp: A(W * S), ip: I(W * S), + line: A(N), col: A(N * 3), pal: A(12), c3: A(3), seg: A(4096 * 2), band: A(NR + 1), + L: [0, 1, 2].map(() => ({ a: A(W * S), b: A(W * S), c: A(W * S), f: new Int8Array(W * S), g: new Int8Array(W * S) })), + az: 0.4, sw: PI / 2, hold: 0, hw: 0, hue: 0, tilt: 0, bands: 0.06, zc: -0.5, palHue: -1, built: false, + }; +} +export function update(dt, input, params) { + if (!params.s) setup(params); + const G = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + if (!G.built) build(G), (G.built = true); + G.hue = cl(k[0], 0, 1); + const sp = cl(k[1], 0.2, 3); + G.tilt = cl(k[2], 0, 1); + G.bands = cl(k[3], 0, 1); + // A third of a turn in 8.7 s and one sweep of the plane in 20 s at Speed 2. + G.az += d * sp * 0.12; + G.sw += (d * sp * TAU) / 40; + const b = input.btnPressed || []; + if (b[0]) G.hold = 6.6; + G.hold = max(0, G.hold - d); + // The plane eases over to the triple point, at z = 0, in 0.8 s, and back + // to its sweep in 0.8 s once the hold is over. + G.hw = cl(G.hw + (G.hold > 0.8 ? d : -d) / 0.8, 0, 1); + const zs = Z0 + (Z1 - Z0) * (0.5 - 0.5 * cos(G.sw)); + G.zc = zs * (1 - sm(G.hw)); +} + +// Two colours for each face of the disk, one in the light and one in +// shadow. A dim orange or yellow reads brown, so a face whose hue lies +// between red and yellow turns crimson in shadow, and one between yellow +// and green turns green; other faces lean a little toward violet. +function palette(G) { + const t = G.pal; + for (let f = 0; f < 2; f++) { + let h = G.hue + (f ? 0.52 : 0.92); + h -= floor(h); + const sh = h < 0.17 ? 0.94 : h < 0.33 ? 0.33 : h + cl(0.7 - h, -0.04, 0.04); + hsv(h, f ? 0.8 : 0.7, 1, t, f * 3); + hsv(sh, 0.85, 1, t, 6 + f * 3); + } + G.palHue = G.hue; +} + +// A soft line one LED wide into the light buffer: points every half LED, +// each shared between its four nearest LEDs. +function soft(buf, x0, y0, x1, y1, w, ylo, yhi) { + const n = max(1, Math.ceil(sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) * 2)), ww = (w * sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)) / n; + for (let i = 0; i < n; i++) { + const t = (i + 0.5) / n, x = x0 + (x1 - x0) * t - 0.5, y = y0 + (y1 - y0) * t - 0.5; + const ix = floor(x), iy = floor(y), fx = x - ix, fy = y - iy; + for (let c = 0; c < 4; c++) { + const xx = ix + (c & 1), yy = iy + (c >> 1); + if (xx < 0 || xx >= W || yy < ylo || yy >= yhi) continue; + buf[yy * W + xx] += ww * (c & 1 ? fx : 1 - fx) * (c >> 1 ? fy : 1 - fy); + } + } +} + +// The colour of point k on face f in a view whose right, up and +// toward-the-eye axes carry the normal to (a, b, c), into G.c3. The light is +// two-sided Lambert from upper left in front, with a highlight, and the +// disk's circles stripe it. +function shade(G, k, a, b, c, gain, f) { + const pal = G.pal, sg = f ? -1 : 1, o = f * 3, so = 6 + o; + const ld = max(0, sg * (a * LX + b * LY + c * LZ)), h2 = max(0, sg * (a * HX + b * HY + c * HZ)) ** 2, h8 = (h2 * h2) ** 2; + // The shadow colour gives way to the lit one with the square of the light, + // and later still in the dimmed glass and plan. + const lit = (0.24 + 0.76 * ld) * G.band[(k / NA) | 0] * gain, sp = h8 * h8 * h8 * 0.55 * gain, m = ld * ld * gain; + G.c3[0] = ((pal[so] + (pal[o] - pal[so]) * m) * lit + sp) * 255; + G.c3[1] = ((pal[so + 1] + (pal[o + 1] - pal[so + 1]) * m) * lit + sp) * 255; + G.c3[2] = ((pal[so + 2] + (pal[o + 2] - pal[so + 2]) * m) * lit + sp) * 255; +} +const LX = -0.45, LY = 0.62, LZ = 0.64, HL = 1 / sqrt(LX * LX + LY * LY + (LZ + 1) ** 2), HX = LX * HL, HY = LY * HL, HZ = (LZ + 1) * HL; + +// One grid cell into a depth buffer: every LED whose centre lies in the +// box round its four corners. The boxes of neighbouring cells overlap, so +// the turning surface has no holes. +function fill(zb, ib, X, Y, cc, o, D, k) { + const a = cc[o], b = cc[o + 1], c = cc[o + 2], d = cc[o + 3]; + let xa = X[a], xb = X[b], ya = Y[a], yb = Y[b]; + if (xa > xb) (xb = xa), (xa = X[b]); + if (ya > yb) (yb = ya), (ya = Y[b]); + let t = X[c]; + if (t < xa) xa = t; + else if (t > xb) xb = t; + t = X[d]; + if (t < xa) xa = t; + else if (t > xb) xb = t; + t = Y[c]; + if (t < ya) ya = t; + else if (t > yb) yb = t; + t = Y[d]; + if (t < ya) ya = t; + else if (t > yb) yb = t; + // LED centres sit at whole numbers plus a half. + let x0 = (xa - 0.5) | 0, y0 = (ya - 0.5) | 0, x1 = (xb + 0.5) | 0, y1 = (yb + 0.5) | 0; + if (xa - 0.5 > x0) x0++; + if (ya - 0.5 > y0) y0++; + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > W) x1 = W; + if (y1 > S) y1 = S; + for (let yy = y0; yy < y1; yy++) for (let q = yy * W + x0, qe = yy * W + x1; q < qe; q++) if (D > zb[q]) (zb[q] = D), (ib[q] = k); +} + +// Every cell into the side view's solid or glass layer by its mean height, +// at the mean depth of its corners, and the solid ones into the plan too. +function cellsIn(G, zc) { + const { cc, cz, vx, vy, vd, vp, za, ia, zo, io, zp, ip } = G; + for (let c = 0, o = 0; c < NC; c++, o += 4) { + const k = cc[o], D = (vd[k] + vd[cc[o + 1]] + vd[cc[o + 2]] + vd[cc[o + 3]]) * 0.25; + if (cz[c] <= zc) fill(za, ia, vx, vy, cc, o, D, k), fill(zp, ip, vx, vp, cc, o, cz[c], k); + else fill(zo, io, vx, vy, cc, o, D, k); + } +} + +// The normal of each LED's point in the eye's frame (right, up, toward the +// eye; the plan looks down the axis) and the face of the disk it shows. A +// lone LED showing the other face from every lit neighbour is a cell's box +// poking past a fold, and takes their face. +function normals(G, ids, ca, sa, ce, se, plan, L) { + const n = G.n, { a, b, c, f, g } = L; + for (let q = 0; q < W * S; q++) { + const k = ids[q]; + if (k < 0) { + f[q] = -1; + continue; + } + const nx = n[k * 3], ny = n[k * 3 + 1], nz = n[k * 3 + 2], nyr = nx * sa + ny * ca; + a[q] = nx * ca - ny * sa; + b[q] = plan ? nyr : nyr * se + nz * ce; + c[q] = plan ? nz : nz * se - nyr * ce; + f[q] = c[q] >= 0 ? 0 : 1; + } + for (let q = 0; q < W * S; q++) { + const fq = (g[q] = f[q]); + if (fq < 0) continue; + const x = q & (W - 1), l = x > 0 ? f[q - 1] : -1, r = x < W - 1 ? f[q + 1] : -1, u = q >= W ? f[q - W] : -1, d = q < W * S - W ? f[q + W] : -1; + if (l !== fq && r !== fq && u !== fq && d !== fq && (l >= 0) + (r >= 0) + (u >= 0) + (d >= 0) >= 2) g[q] = 1 - fq; + } +} + +export function draw(display, params) { + const G = params.s; + if (!G || !G.built) return; + if (G.palHue !== G.hue) palette(G); + const { p, n, cc, cz, vx, vy, vd, vp, za, ia, zo, io, zp, ip, line, col, seg, c3 } = G; + const ca = cos(G.az), sa = sin(G.az), el = (22 + 68 * G.tilt) * (PI / 180), ce = cos(el), se = sin(el), zc = G.zc; + // The disk's circles: each ring of the grid is lit by its band, from 1.2 + // on the light stripes to 0.35 in the middle of the dark ones. + const nb = 2 + 7 * G.bands; + for (let i = 0; i <= NR; i++) G.band[i] = 1.2 - 0.85 * (0.5 - 0.5 * cos((TAU * nb * (NR - i)) / NR)); + za.fill(-1e9); + zo.fill(-1e9); + zp.fill(-1e9); + ia.fill(-1); + io.fill(-1); + ip.fill(-1); + // The side view in two layers: what is under the plane is solid, what is + // over it is glass. The plan takes what is under the plane, nearest the + // eye above first. + for (let k = 0, o = 0; k < NP; k++, o += 3) { + const x = p[o], y = p[o + 1], z = p[o + 2], xr = x * ca - y * sa, yr = x * sa + y * ca; + vx[k] = CX + SC * xr; + vy[k] = CY - SC * (yr * se + (z - ZC) * ce); + vd[k] = z * se - yr * ce; + vp[k] = CY - SC * yr; + } + cellsIn(G, zc); + normals(G, ia, ca, sa, ce, se, false, G.L[0]); + normals(G, io, ca, sa, ce, se, false, G.L[1]); + normals(G, ip, ca, sa, ce, se, true, G.L[2]); + const [A, O, Pl] = G.L; + for (let q = 0; q < W * S; q++) { + const o = q * 3, oo = (W * S + q) * 3; + let r = 0, g = 0, b = 0, k = ia[q]; + if (k >= 0) shade(G, k, A.a[q], A.b[q], A.c[q], 1, A.g[q]), (r = c3[0]), (g = c3[1]), (b = c3[2]); + k = io[q]; + if (k >= 0 && zo[q] > za[q]) { + // Glass: 60 percent of its own light over 55 of what is behind. + shade(G, k, O.a[q], O.b[q], O.c[q], 0.6, O.g[q]); + (r = r * 0.55 + c3[0]), (g = g * 0.55 + c3[1]), (b = b * 0.55 + c3[2]); + } + (col[o] = r), (col[o + 1] = g), (col[o + 2] = b); + k = ip[q]; + if (k >= 0) shade(G, k, Pl.a[q], Pl.b[q], Pl.c[q], 0.5, Pl.g[q]), (col[oo] = c3[0]), (col[oo + 1] = c3[1]), (col[oo + 2] = c3[2]); + else col[oo] = col[oo + 1] = col[oo + 2] = 0; + } + // The cut: in each grid cell the plane crosses, the points where it + // crosses the cell's edges, two to a piece of the curve (four in a + // saddle, paired in order round the cell). + line.fill(0); + let ns = 0; + const bz = bin(zc), cells = G.cells, zs = [0, 0, 0, 0], cs = [0, 0, 0, 0]; + for (let m = G.binAt[bz]; m < G.binAt[bz + 1] && ns < 2040; m++) { + const c = cells[m]; + for (let e = 0; e < 4; e++) cs[e] = cc[c * 4 + e]; + let up = 0; + for (let e = 0; e < 4; e++) up += (zs[e] = p[cs[e] * 3 + 2] - zc) > 0; + if (up === 0 || up === 4) continue; + for (let e = 0; e < 4; e++) { + const z0 = zs[e], z1 = zs[(e + 1) & 3]; + if (z0 > 0 === z1 > 0) continue; + const t = z0 / (z0 - z1), A = cs[e] * 3, B = cs[(e + 1) & 3] * 3; + seg[ns * 2] = p[A] + (p[B] - p[A]) * t; + seg[ns * 2 + 1] = p[A + 1] + (p[B + 1] - p[A + 1]) * t; + ns++; + } + } + // Each piece soft in both views; in the side view a piece behind the + // solid is faint. + for (let s = 0; s + 1 < ns; s += 2) { + const ax = seg[s * 2], ay = seg[s * 2 + 1], bx = seg[s * 2 + 2], by = seg[s * 2 + 3]; + const axr = ax * ca - ay * sa, ayr = ax * sa + ay * ca, bxr = bx * ca - by * sa, byr = bx * sa + by * ca; + const X0 = CX + SC * axr, X1 = CX + SC * bxr, Y0 = CY - SC * (ayr * se + (zc - ZC) * ce), Y1 = CY - SC * (byr * se + (zc - ZC) * ce); + const D = zc * se - ((ayr + byr) / 2) * ce, qx = Math.round((X0 + X1) / 2 - 0.5), qy = Math.round((Y0 + Y1) / 2 - 0.5); + const zq = qx >= 0 && qx < W && qy >= 0 && qy < S ? za[qy * W + qx] : -1e9; + soft(line, X0, Y0, X1, Y1, D > zq - 0.1 ? 1 : 0.22, 0, S); + soft(line, X0, PY - SC * ayr, X1, PY - SC * byr, 1, S, 128); + } + // Ticks at the two edges of the side view mark the plane's height on the + // axis. + const ty = CY - SC * (zc - ZC) * ce, ty0 = floor(ty - 0.5), fy = ty - 0.5 - ty0; + for (const x of [0, 1, 62, 63]) + for (let c = 0; c < 2; c++) { + const yy = ty0 + c; + if (yy >= 0 && yy < S) line[yy * W + x] += (c ? fy : 1 - fy) * (x === 0 || x === 63 ? 1 : 0.45); + } + for (let q = 0, o = 0; q < N; q++, o += 3) { + const l = min(1, line[q] * 1.1); + const r = min(255, col[o]), g = min(255, col[o + 1]), b = min(255, col[o + 2]); + display.setPixel(q % W, (q / W) | 0, r + (255 - r) * l, g + (244 - g) * l, b + (205 - b) * l); + } +} From a1f63397e7add26763a5f35ebc8581525b0781c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:40:09 +0000 Subject: [PATCH 10/16] =?UTF-8?q?31=ED=8C=90:=20Setting=20Mist=20=EB=A5=BC?= =?UTF-8?q?=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/setting-mist.js | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 incoming/pattern-study/patterns/setting-mist.js diff --git a/incoming/pattern-study/patterns/setting-mist.js b/incoming/pattern-study/patterns/setting-mist.js new file mode 100644 index 000000000..2ea9e33b7 --- /dev/null +++ b/incoming/pattern-study/patterns/setting-mist.js @@ -0,0 +1,343 @@ +// ===== Patternflow pattern ===== +// Title: Setting Mist +// Family: cosmetic +// Idea: Puffs of setting spray burst in from a nozzle just past the left edge, each a cone of micro-droplets that blooms into a soft cloud, rolls and billows on the air, drifts across the deep panel and settles as it fades, with droplets catching the light as soft glints. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 31, after a setting spray misted across a face +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Spray=0..1, Droplets=0..1 +// +// Knob 1: Hue. Turns the mist and the deep ground behind it round the whole colour wheel together, opening on aqua; round orange and gold the ground turns navy, so the mist never sits on brown. +// Knob 2: Speed. How fast everything happens: at 2 a puff blooms in about a third of a second and its cloud drifts and fades over three or four; at 0.2 it all runs ten times slower. +// Knob 3: Spray. How often and how hard the nozzle fires: at 0 a puff about every second, now and then two at once; at 1 about three a second, bigger and more often doubled, until the mist fills the panel. +// Knob 4: Droplets. How fine the mist is, from a soft haze with small glints at 0 to a coarse spray at 1 where the single droplets show as dots and flash. +// Buttons: K1 tap fires a big double puff. + +const W = 64, H = 128, N = W * H; +const MAXP = 6000; +const { min, max, floor, sin, cos, exp, abs, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const rnd = () => Math.random(); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); + +// The air: a stream function made of six travelling waves, 14 to 40 LEDs +// long. Its curl moves the mist without bunching it, so the clouds roll. +// [wavelength, direction, speed of travel, amplitude] +const WAVES = [ + [40, 0.3, 0.6, 1.0], + [31, 1.9, -0.8, 0.9], + [24, 3.6, 0.9, 0.8], + [19, 4.9, -1.1, 0.7], + [16, 0.9, 1.3, 0.6], + [14, 2.7, -1.5, 0.5], +].map(([l, a, w, m], j) => { + const k = (2 * PI) / l; + return [k * cos(a), k * sin(a), w, (m * 4.2) / k, j * 2.4]; +}); +// The flow is worked out on a grid every 4 LEDs and read off between. +const GS = 4, GX = W / GS + 2, GY = H / GS + 2; +// The ground is lit a little from the upper right: its level, 0..1. +const BGL = new Float32Array(N); +for (let p = 0; p < N; p++) { + const lx = (p % W) - 60, ly = floor(p / W) - 20; + BGL[p] = (26 + 26 * exp(-(lx * lx + ly * ly) / 5000)) / 255; +} +// Round orange and yellow the ground turns to deep navy, given at full +// brightness like the hue colour it replaces. +const NAVY = [60, 95, 255]; +// Puffs are kept in a ring of NP, long enough for every cloud in the air. +const NP = 48; + +function hsv(h, s, v, out, o) { + const f = (n) => { + const k = (n + (h - floor(h)) * 6) % 6; + return v - v * s * max(0, min(k, 4 - k, 1)); + }; + out[o] = f(5); + out[o + 1] = f(3); + out[o + 2] = f(1); +} + +export function setup(params) { + const F = (n) => new Float32Array(n); + params.s = { + x: F(MAXP), y: F(MAXP), vx: F(MAXP), vy: F(MAXP), age: F(MAXP), life: F(MAXP), w: F(MAXP), gp: F(MAXP), gr: F(MAXP), dr: new Uint8Array(MAXP), pid: new Uint8Array(MAXP), ex: F(MAXP), ey: F(MAXP), + n: 0, T: 0, next: 0.3, nz: 0, puffs: [], slot: 0, + P0: F(NP).fill(-99), PY: F(NP), AX: F(NP), AY: F(NP), PL: F(NP), PG: F(NP), PR: F(NP), + U: F(GX * GY), V: F(GX * GY), D: F(N), Y: F(N), G: F(N), tmp: F(N), buf: F(N * 3), col: F(12), + hue: 0, spray: 0, drop: 0.06, started: false, + }; +} + +// A puff: the nozzle at height y, aimed at angle a (0 points straight right), +// size s. It sprays for a fifth of a second. Its front rolls up like a smoke +// ring: two eddies either side of the axis, carried out to reach PL and +// slowing, which curl the cloud back on itself into billows. +function puff(S, y, a, s, delay) { + const q = (S.slot = (S.slot + 1) % NP); + S.P0[q] = S.T + delay; + S.PY[q] = y; + S.AX[q] = cos(a); + S.AY[q] = sin(a); + S.PL[q] = (36 + 12 * s) * (0.85 + 0.3 * rnd()); + S.PR[q] = 5 + 4 * s; + S.PG[q] = (220 + 100 * s) * (0.45 + 0.55 * rnd()); + S.puffs.push({ q, y, a, s, t: -delay, left: round(380 * s), spread: 0.3 + 0.1 * s }); +} + +// The hand moves the bottle up and down the face: each puff comes from a new +// height, aimed a little toward the middle of the panel. +function fire(S, big) { + S.nz += 0.9 + rnd() * 0.8; + const y = 64 + 44 * sin(S.nz * 1.3) + (rnd() - 0.5) * 14; + const a = -0.45 * ((y - 64) / 64) + (rnd() - 0.5) * 0.3; + const s = big ? 2.2 : (1 + 0.7 * S.spray) * (0.8 + 0.4 * rnd()); + puff(S, y, a, s, 0); + if (big || rnd() < 0.22 + 0.3 * S.spray) { + const y2 = cl(y + (y < 64 ? 1 : -1) * (14 + rnd() * 14), 8, H - 8); + puff(S, y2, -0.45 * ((y2 - 64) / 64) + (rnd() - 0.5) * 0.3, big ? 2 : s * (0.7 + 0.3 * rnd()), 0.16 + rnd() * 0.12); + } +} + +function emit(S, p, k) { + for (let j = 0; j < k && S.n < MAXP; j++) { + const i = S.n++; + // Droplets leave the nozzle in a cone, faster along its axis. + const off = (rnd() + rnd() + rnd() - 1.5) * 2 * p.spread; + const a = p.a + off, v = (80 + 160 * rnd() * rnd()) * (0.85 + 0.15 * p.s) * (1 - 0.5 * abs(off)); + // Each leaves at its own moment within the frame, so the burst does not + // start as one bright knot. + const lag = rnd() * 0.035; + S.x[i] = -3 + v * cos(a) * lag; + S.y[i] = p.y + (rnd() - 0.5) * 1.5 + v * sin(a) * lag; + S.vx[i] = v * cos(a); + S.vy[i] = v * sin(a); + S.age[i] = 0; + S.life[i] = 2.6 + 1.8 * rnd(); + S.w[i] = 0.5 + 0.4 * rnd(); + S.gp[i] = rnd() * 2 * PI; + S.gr[i] = 2 + 4 * rnd(); + S.dr[i] = rnd() < 0.3 + 0.3 * S.drop ? 1 : 0; + S.pid[i] = p.q; + // Each droplet also wanders off on its own, so the cloud keeps opening. + const e = rnd() * 2 * PI, es = 3 + 6 * rnd(); + S.ex[i] = es * cos(e); + S.ey[i] = es * sin(e); + } +} + +function kill(S, i) { + const j = --S.n; + S.x[i] = S.x[j]; + S.y[i] = S.y[j]; + S.vx[i] = S.vx[j]; + S.vy[i] = S.vy[j]; + S.age[i] = S.age[j]; + S.life[i] = S.life[j]; + S.w[i] = S.w[j]; + S.gp[i] = S.gp[j]; + S.gr[i] = S.gr[j]; + S.dr[i] = S.dr[j]; + S.pid[i] = S.pid[j]; + S.ex[i] = S.ex[j]; + S.ey[i] = S.ey[j]; +} + +function flow(S) { + const T = S.T, U = S.U, V = S.V; + for (let gy = 0; gy < GY; gy++) + for (let gx = 0; gx < GX; gx++) { + const x = (gx - 1) * GS, y = (gy - 1) * GS; + let u = 0, v = 0; + for (const [kx, ky, w, m, ph] of WAVES) { + const c = m * cos(kx * x + ky * y + w * T + ph); + u += c * ky; + v -= c * kx; + } + U[gy * GX + gx] = u; + V[gy * GX + gx] = v; + } +} + +function step(S, st) { + S.T += st; + // Puffs on a clock: about one a second at Spray 0, three at Spray 1. + S.next -= st; + if (S.next <= 0) { + fire(S, false); + S.next = (1.15 - 0.8 * S.spray) * (0.75 + 0.5 * rnd()); + } + for (let q = S.puffs.length - 1; q >= 0; q--) { + const p = S.puffs[q]; + p.t += st; + if (p.t < 0) continue; + const k = p.t >= 0.2 ? p.left : min(p.left, round((p.left * st) / max(st, 0.2 - p.t + st))); + emit(S, p, k); + p.left -= k; + if (p.left <= 0) S.puffs.splice(q, 1); + } + flow(S); + const U = S.U, V = S.V, drag = 3.1 - 1.1 * S.drop, fall = 2.5 + 7 * S.drop; + const kd = 1 - exp(-drag * st); + for (let i = S.n - 1; i >= 0; i--) { + const age = (S.age[i] += st); + if (age > S.life[i] || S.x[i] > W + 8 || S.y[i] < -10 || S.y[i] > H + 10) { + kill(S, i); + continue; + } + const x = S.x[i], y = S.y[i]; + const fx = cl(x / GS + 1, 0, GX - 1.001), fy = cl(y / GS + 1, 0, GY - 1.001); + const ix = floor(fx), iy = floor(fy), ax = fx - ix, ay = fy - iy, g = iy * GX + ix; + const u = (U[g] * (1 - ax) + U[g + 1] * ax) * (1 - ay) + (U[g + GX] * (1 - ax) + U[g + GX + 1] * ax) * ay; + const v = (V[g] * (1 - ax) + V[g + 1] * ax) * (1 - ay) + (V[g + GX] * (1 - ax) + V[g + GX + 1] * ax) * ay; + // The young cloud churns hardest, then the air calms to a slow drift + // away from the nozzle, and the droplets sink as they age. + const churn = 1 + 1.6 * exp(-age * 1.6); + let ux = u * churn + 4 + S.ex[i], uy = v * churn - 1.5 + S.ey[i]; + const q = S.pid[i], pa = S.T - S.P0[q]; + if (pa < 2.5) { + // The puff's two eddies, in the frame of its axis. + const ax = S.AX[q], ay = S.AY[q], f = S.PL[q] * (1 - exp(-pa / 0.22)); + const dx = x + 3 - ax * f, dy = y - S.PY[q] - ay * f; + const sa = dx * ax + dy * ay, ta = dy * ax - dx * ay; + const R = S.PR[q] * (1 + 0.8 * pa), rc = 9 + 6 * pa, G = S.PG[q] * exp(-pa / 0.8); + const t1 = ta - R, t2 = ta + R; + const g1 = G / (sa * sa + t1 * t1 + rc), g2 = -G / (sa * sa + t2 * t2 + rc); + const vs = -(g1 * t1 + g2 * t2), vt = (g1 + g2) * sa; + ux += vs * ax - vt * ay; + uy += vs * ay + vt * ax; + } + S.vx[i] += (ux - S.vx[i]) * kd; + S.vy[i] += (uy - S.vy[i]) * kd; + S.x[i] = x + S.vx[i] * st; + S.y[i] = y + (S.vy[i] + fall * min(1, age * 0.6)) * st; + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06]; + const d = cl(dt || 0, 0, 0.1), rate = cl(k[1], 0.2, 3) / 2; + S.hue = k[0] || 0; + S.spray = cl(k[2], 0, 1); + S.drop = cl(k[3], 0, 1); + if (!S.started) { + S.started = true; + // Open with the mist already in the air. + for (let i = 0; i < 75; i++) step(S, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0]) fire(S, true); + step(S, d * rate); +} + +// Box blur of radius r along rows then columns, from a into a (b is scratch). +function blur(a, b, r) { + if (r < 1) return; + const inv = 1 / (2 * r + 1); + for (let y = 0; y < H; y++) { + const o = y * W; + let s = 0; + for (let x = -r; x <= r; x++) s += a[o + cl(x, 0, W - 1)]; + for (let x = 0; x < W; x++) { + b[o + x] = s * inv; + s += a[o + min(W - 1, x + r + 1)] - a[o + max(0, x - r)]; + } + } + for (let x = 0; x < W; x++) { + let s = 0; + for (let y = -r; y <= r; y++) s += b[cl(y, 0, H - 1) * W + x]; + for (let y = 0; y < H; y++) { + a[y * W + x] = s * inv; + s += b[min(H - 1, y + r + 1) * W + x] - b[max(0, y - r) * W + x]; + } + } +} + +function splat(A, x, y, w) { + const fx = x - 0.5, fy = y - 0.5, ix = floor(fx), iy = floor(fy), ax = fx - ix, ay = fy - iy; + if (ix < -1 || ix >= W || iy < -1 || iy >= H) return; + const p = iy * W + ix; + if (iy >= 0) { + if (ix >= 0) A[p] += w * (1 - ax) * (1 - ay); + if (ix + 1 < W) A[p + 1] += w * ax * (1 - ay); + } + if (iy + 1 < H) { + if (ix >= 0) A[p + W] += w * (1 - ax) * ay; + if (ix + 1 < W) A[p + W + 1] += w * ax * ay; + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const D = S.D, Y = S.Y, G = S.G, tmp = S.tmp, buf = S.buf, col = S.col, T = S.T, dp = S.drop; + D.fill(0); + Y.fill(0); + G.fill(0); + for (let i = 0; i < S.n; i++) { + const age = S.age[i], u = age / S.life[i]; + const env = min(1, age * 18) * (1 - u) * (1 - u); + const x = S.x[i], y = S.y[i]; + // For the first quarter second the droplets still fly in a thin cone; + // then they open into the soft cloud. + const yo = age < 0.2 ? 1 : age > 0.6 ? 0 : (0.6 - age) / 0.4; + if (yo > 0) { + // Flying droplets smear into short streaks along their path. + const wy = S.w[i] * env * yo * 0.06, sx = S.vx[i] * 0.007, sy = S.vy[i] * 0.007; + for (let j = 0; j < 4; j++) splat(Y, x - sx * j, y - sy * j, wy); + } + splat(D, x, y, S.w[i] * env * (1 - yo)); + if (!S.dr[i]) continue; + // A droplet glints when it turns the light to the eye: a smooth swell + // a few times a second, so nothing strobes. + const tw = 0.5 + 0.5 * sin(S.gp[i] + S.gr[i] * T); + const t3 = tw * tw * tw, g = env * (1 - 0.8 * yo) * (0.04 + 0.3 * dp + (0.9 + dp) * t3 * t3 * t3); + splat(G, x, y, g); + } + // Haze is soft and wide; droplets keep their shape. + const r = 3 - round(2 * dp); + blur(D, tmp, r); + blur(D, tmp, r > 1 ? r - 1 : r); + blur(Y, tmp, 1); + blur(Y, tmp, 1); + for (let p = 0; p < N; p++) D[p] += Y[p]; + if (dp > 0.3) { + // Coarse spray: the droplets themselves as small soft dots in the cloud. + for (let i = 0; i < S.n; i++) { + if (!S.dr[i]) continue; + const age = S.age[i], u = age / S.life[i]; + splat(D, S.x[i], S.y[i], (dp - 0.3) * 1.1 * min(1, age * 18) * (1 - u) * (1 - u)); + } + } + blur(G, tmp, dp > 0.6 ? 1 : 0); + // Deep ground, mist tint and glint colour from the hue. Dark orange reads + // brown and dark yellow olive, so round those hues the ground turns to + // navy and the mist is lifted to a brighter gold. + const h = S.hue + 0.48, hf = h - floor(h), wm = sm(hf / 0.06) * (1 - sm((hf - 0.17) / 0.1)); + hsv(h, 0.9, 255, col, 0); + for (let c = 0; c < 3; c++) col[c] += (NAVY[c] - col[c]) * wm; + hsv(h, 0.45 + 0.12 * wm, 255, col, 3); + hsv(h, 0.12 + 0.25 * wm, 255, col, 6); + const gain = (4.5 - 1.5 * dp) * (1 + 0.6 * wm); + for (let y = 0, q = 0, p = 0; y < H; y++) { + for (let x = 0; x < W; x++, p++, q += 3) { + const bg = BGL[p]; + const a = 1 - exp(-D[p] * gain), g = G[p]; + // Thin mist glows in the tint, thick mist goes to white. + const wt = a * a; + for (let c = 0; c < 3; c++) { + const mist = col[3 + c] * (1 - wt) + 255 * wt; + buf[q + c] = col[c] * bg * (1 - a) + mist * a * 0.92 + col[6 + c] * g; + } + } + } + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, knee(buf[p]), knee(buf[p + 1]), knee(buf[p + 2])); +} + +function knee(v) { + return v < 200 ? v : 255 - 55 * exp(-(v - 200) / 55); +} From 1891506cc5d816deeda0e8e7cde49fe7be151d2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:40:09 +0000 Subject: [PATCH 11/16] =?UTF-8?q?31=ED=8C=90:=20Sponge=20Blend=20=EB=A5=BC?= =?UTF-8?q?=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/sponge-blend.js | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 incoming/pattern-study/patterns/sponge-blend.js diff --git a/incoming/pattern-study/patterns/sponge-blend.js b/incoming/pattern-study/patterns/sponge-blend.js new file mode 100644 index 000000000..ea3630008 --- /dev/null +++ b/incoming/pattern-study/patterns/sponge-blend.js @@ -0,0 +1,301 @@ +// ===== Patternflow pattern ===== +// Title: Sponge Blend +// Family: cosmetic +// Idea: A stripe of colour is laid down a pale base and an unseen damp sponge bounces along it, each dab printing a soft patch of open-cell stipple that lifts colour off the stripe and leaves it further out, until the hard line is bounced out into a wide soft fade and the base is wiped for the next stripe. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 31, after foundation blended out with a damp makeup sponge +// Round: 31 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Coverage=0..1, Grain=0..1 +// +// Knob 1: Hue. The colour being blended, all the way round the wheel from coral at 0; the base stays a pale ivory with a breath of it, and round orange and gold the colour is lifted so it never goes brown. +// Knob 2: Speed. How fast the sponge bounces: at 2 it dabs about six times a second, lays a stripe on in a second and blends it out in about fifteen; at 0.2 everything runs ten times slower. +// Knob 3: Coverage. How much colour the sponge works: at 0 a sheer wash; at 1 full coverage, deeper and opaque, and the sponge presses a wider patch with every dab. +// Knob 4: Grain. How coarse the sponge is: fine pores a LED or two across in a fresh print at 0, open cells about four across at 1, and the grain of the base grows rougher with it. Dab after dab the pores close up into a fine even finish. +// Buttons: K1 tap lays a fresh stripe of colour for the sponge to blend out. + +const W = 64, H = 128, N = W * H; +const TS = 128, TN = TS * TS; +const { min, max, floor, sin, cos, exp, sqrt, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; + +// The sponge's pores: three tiles of noise blurred over 3, 5 and 7 LEDs, 128 +// LEDs square and wrapping, each LED's value turned into its rank 0..255. +// Cutting a tile at rank 128 gives cells from two to four LEDs across, half of +// them touching; cutting it lower leaves only the cores, small and sparse. +let TILES = null; +function tiles() { + const out = []; + const raw = Float32Array.from({ length: TN }, (_, i) => hash(i + 911) - 0.5); + for (let r = 1; r <= 3; r++) { + const b = new Float32Array(TN); + for (let y = 0; y < TS; y++) + for (let x = 0; x < TS; x++) { + let s = 0; + for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) s += raw[((y + dy) & (TS - 1)) * TS + ((x + dx) & (TS - 1))]; + b[y * TS + x] = s; + } + const order = Array.from({ length: TN }, (_, i) => i).sort((p, q) => b[p] - b[q]); + const rank = new Uint8Array(TN); + order.forEach((i, k) => (rank[i] = (k * 256) / TN)); + out.push(rank); + } + return out; +} + +// The base's grain is a fine checker: every other LED sits half a step above +// the base and the rest half a step below, so neighbouring LEDs always differ +// by about the same 28 to 41 levels and nothing reads as an outline. AMP is +// each LED's own share of that step. The pores of a print are soft dips +// spread over three LEDs, a few levels a LED, so they never cut an edge. +const AMP = Float32Array.from({ length: N }, (_, i) => 0.78 + 0.34 * hash(i + 4242)); +const ODD = Uint8Array.from({ length: N }, (_, i) => ((i % W) + floor(i / W)) & 1); +const STEP = 0.175; + +function hsv(h, s, v, out, o) { + const f = (n) => { + const k = (n + (h - floor(h)) * 6) % 6; + return v - v * s * max(0, min(k, 4 - k, 1)); + }; + out[o] = f(5); + out[o + 1] = f(3); + out[o + 2] = f(1); +} + +export function setup(params) { + const F = (n) => new Float32Array(n); + params.s = { + A: F(N), c: F(N), pm: new Uint8Array(N), cn: F(N), sh: F(N), dist: F(N).fill(99), su: F(N), + phase: 0, pt: 0, lay: 0, len: 100, pts: F(98), u: 0, dir: 1, pass: 0, side: 1, load: 0, dabT: 0, n: 0, + hue: 0, speed: 2, cov: 0, grain: 0.06, started: false, col: F(6), + }; +} + +// A new stripe: a gentle curve down most of the panel, or anywhere when +// laid by hand. Every LED's distance from it and the place along it nearest +// are kept for laying it on and for walking the sponge along it. +function stripe(S, free) { + const r = Math.random; + let x0, y0, x1, y1; + if (free) { + const a = r() * PI, l = 50 + r() * 50, cx = 16 + r() * 32, cy = 30 + r() * 68; + x0 = cx - (cos(a) * l) / 2; + y0 = cy - (sin(a) * l) / 2; + x1 = cx + (cos(a) * l) / 2; + y1 = cy + (sin(a) * l) / 2; + } else { + x0 = 14 + r() * 36; + y0 = 8 + r() * 10; + x1 = 14 + r() * 36; + y1 = 110 + r() * 10; + } + const nx = -(y1 - y0), ny = x1 - x0, nl = sqrt(nx * nx + ny * ny) || 1, bend = (r() - 0.5) * 24; + const mx = (x0 + x1) / 2 + (nx / nl) * bend, my = (y0 + y1) / 2 + (ny / nl) * bend; + const P = S.pts; + let len = 0; + for (let k = 0; k < 49; k++) { + const t = k / 48, a = (1 - t) * (1 - t), b = 2 * t * (1 - t), c = t * t; + P[k * 2] = a * x0 + b * mx + c * x1; + P[k * 2 + 1] = a * y0 + b * my + c * y1; + if (k) len += Math.hypot(P[k * 2] - P[k * 2 - 2], P[k * 2 + 1] - P[k * 2 - 1]); + } + S.len = len; + S.wid = 11 + r() * 3; + for (let y = 0, i = 0; y < H; y++) + for (let x = 0; x < W; x++, i++) { + let best = 1e9, bu = 0; + for (let k = 0; k < 48; k++) { + const ax = P[k * 2], ay = P[k * 2 + 1], bx = P[k * 2 + 2] - ax, by = P[k * 2 + 3] - ay; + const t = cl(((x + 0.5 - ax) * bx + (y + 0.5 - ay) * by) / (bx * bx + by * by + 1e-6), 0, 1); + const dx = x + 0.5 - ax - bx * t, dy = y + 0.5 - ay - by * t, d = dx * dx + dy * dy; + if (d < best) (best = d), (bu = (k + t) / 48); + } + S.dist[i] = sqrt(best); + S.su[i] = bu; + } + S.phase = 1; + S.lay = 0; + S.u = 0; + S.dir = 1; + S.pass = 0; + S.load = 0.3; +} + +// Where the sponge comes down next: back and forth along the stripe, a step +// at a time, zig-zagging to either side and further out with each pass. +const REACH = [6, 10, 14, 18, 12]; +function target(S) { + const P = S.pts, u = cl(S.u, 0, 1), k = min(47, floor(u * 48)), t = u * 48 - k; + const x = P[k * 2] + (P[k * 2 + 2] - P[k * 2]) * t, y = P[k * 2 + 1] + (P[k * 2 + 3] - P[k * 2 + 1]) * t; + const tx = P[k * 2 + 2] - P[k * 2], ty = P[k * 2 + 3] - P[k * 2 + 1], tl = sqrt(tx * tx + ty * ty) || 1; + const off = S.side * REACH[S.pass % REACH.length] * (0.2 + 0.8 * Math.random()); + return [x - (ty / tl) * off + (Math.random() - 0.5) * 3, y + (tx / tl) * off + (Math.random() - 0.5) * 3]; +} + +// One dab: the sponge's touching cells give and take colour until each holds +// nearer the patch's mean, with a little of what the sponge still carries +// from the last dab, so the colour is dragged along the way it bounces. The +// sponge soaks up a little each time, so a stripe thins as it spreads. The +// print shows the touching cells as pores of deeper colour: all of them on a +// fresh patch, and only their cores, smaller and fewer, each time the same +// spot is dabbed again, until the patch is a smooth finish with fine pores. +function dab(S, cx, cy) { + const cov = S.cov, R = 10 + 3 * cov, ox = floor(Math.random() * TS), oy = floor(Math.random() * TS); + const g = S.grain, tile = TILES[min(2, floor(g * 3))], rate = 0.7 + 0.25 * cov; + // A coarse sponge prints more and bigger pores, and prints them in thinner colour. + const fr = 0.36 + 0.24 * g, lo = 0.25 - 0.13 * g; + const A = S.A, pm = S.pm, cn = S.cn, sh = S.sh, C = S.c; + const E = R * 1.25, x0 = max(0, floor(cx - E)), x1 = min(W - 1, floor(cx + E)), y0 = max(0, floor(cy - E)), y1 = min(H - 1, floor(cy + E)); + const p1 = Math.random() * 2 * PI, p2 = Math.random() * 2 * PI; + let sum = 0, area = 0; + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const dx = x + 0.5 - cx, dy = y + 0.5 - cy, th = Math.atan2(dy, dx), i = y * W + x; + // The sponge's tip is not a perfect circle, and turns from dab to dab. + const d = sqrt(dx * dx + dy * dy) / (R * (1 + 0.13 * sin(2 * th + p1) + 0.08 * sin(3 * th + p2))); + if (d >= 1) { + C[i] = 0; + continue; + } + const w = 1 - sm((d - 0.45) / 0.55); + const rk = tile[((y + oy) & (TS - 1)) * TS + ((x + ox) & (TS - 1))]; + const c = w * (rk < 128 ? 1 : 0.15) * rate; + C[i] = c; + sum += c * A[i]; + area += c; + cn[i] += w; + sh[i] = max(sh[i], w); + // The print, except at the very rim where the sponge barely touched. + // Pores only show where colour sits, fewer the more it was dabbed. + if (w > 0.3 || hash(i + S.n * 131) < w * 3) pm[i] = A[i] > lo && rk < 256 * max(0.12, fr * exp(-cn[i] / 4)) ? 1 : 0; + } + if (area <= 0) return; + const mean = sum / area, carry = 0.75 * mean + 0.25 * S.load; + S.load = mean; + for (let y = y0; y <= y1; y++) + for (let x = x0, i = y * W + x0; x <= x1; x++, i++) { + const c = C[i]; + if (c > 0) A[i] = (A[i] + (carry - A[i]) * c) * (1 - 0.05 * c); + } + S.n++; +} + +function stepT(S, st) { + if (S.phase === 1) { + // Laying the stripe on, top to bottom, in about a second. + S.lay = min(1, S.lay + st * 1.1); + const w2 = S.wid / 2, A = S.A; + for (let i = 0; i < N; i++) { + const f = (S.lay - S.su[i]) * S.len; + if (f <= 0) continue; + // A soft shoulder three LEDs wide on each side and at the tip, never a cliff. + const v = (1 - sm((S.dist[i] - w2 + 1.5) / 3)) * sm(f / 3); + if (v > A[i]) A[i] = v; + } + if (S.lay >= 1) { + S.phase = 2; + S.pt = 0; + S.dabT = 0.15; + } + return; + } + if (S.phase === 2) { + S.pt += st; + S.dabT -= st; + if (S.dabT <= 0) { + S.dabT += 0.17 * (0.85 + 0.3 * Math.random()); + const [x, y] = target(S); + dab(S, x, y); + S.side = -S.side; + S.u += (S.dir * 6.5) / S.len; + if (S.u > 1 || S.u < 0) { + S.dir = -S.dir; + S.u = cl(S.u, 0, 1); + S.pass++; + } + } + if (S.pass >= REACH.length) { + S.phase = 3; + S.pt = 0; + } + return; + } + if (S.phase === 3) { + // A slow wipe from the top takes the colour off; then the next stripe. + S.pt += st; + const edge = S.pt * 110 - 10, A = S.A, cn = S.cn; + for (let y = 0; y < H; y++) { + const k = cl((edge - y) / 12, 0, 1) * 0.25; + if (k <= 0) break; + for (let x = 0, i = y * W; x < W; x++, i++) { + A[i] *= 1 - k; + cn[i] *= 1 - k; + if (A[i] < 0.25) S.pm[i] = 0; + } + } + if (S.pt > 1.6) stripe(S, false); + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06]; + const d = cl(dt || 0, 0, 0.1); + S.hue = k[0] || 0; + S.speed = cl(k[1], 0.2, 3); + S.cov = cl(k[2], 0, 1); + S.grain = cl(k[3], 0, 1); + if (!S.started) { + S.started = true; + if (!TILES) TILES = tiles(); + // Open with the stripe more than half laid on. + stripe(S, false); + for (let i = 0; i < 12; i++) stepT(S, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0]) { + stripe(S, true); + } + const st = (d * S.speed) / 2; + stepT(S, st); + // The wet shine dries in real time, so a fast sponge leaves several at once. + const fade = exp(-d * 3); + for (let i = 0; i < N; i++) S.sh[i] *= fade; +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const A = S.A, pm = S.pm, sh = S.sh, col = S.col; + const h = S.hue + 0.05, cov = S.cov; + // The base: a pale ivory with a breath of the hue. The colour: the hue at + // nearly the same brightness, so the blend shows as colour on a steady grain. + // Orange and gold read brown when dim, so round them the colour is lifted. + const hf = h - floor(h), wm = sm((hf - 0.03) / 0.05) * (1 - sm((hf - 0.15) / 0.07)); + hsv(h, 0.06, 224, col, 0); + hsv(h, 0.5 + 0.18 * cov + 0.1 * wm, 220 + 20 * wm, col, 3); + // Grain also roughens the base: the checker's step grows by half from 0 to 1. + const g = S.grain, op = 0.65 + 0.35 * cov, dip = 0.05 + 0.03 * g, gs = STEP * (0.9 + 0.55 * g); + for (let y = 0, i = 0; y < H; y++) + for (let x = 0; x < W; x++, i++) { + // How much pore is round this LED, 0..1 over its 3x3 neighbourhood. + let pv = 0; + for (let yy = max(0, y - 1); yy <= min(H - 1, y + 1); yy++) for (let xx = max(0, x - 1), j = yy * W + xx; xx <= min(W - 1, x + 1); xx++, j++) pv += pm[j]; + pv /= 9; + // A pore holds more colour and sits a little deeper. A fresh dab is + // wet for a moment: its colour reads deeper and the base a touch + // brighter, then it dries back. + const ac = sqrt(cl(A[i], 0, 1)), wet = sh[i]; + const a = min(1, ac * op * (1 + 0.45 * wet) + 0.45 * pv); + const l = 1 + 0.03 * wet - dip * pv + gs * AMP[i] * (ODD[i] ? -0.5 : 0.5); + display.setPixel(x, y, (col[0] + (col[3] - col[0]) * a) * l, (col[1] + (col[4] - col[1]) * a) * l, (col[2] + (col[5] - col[2]) * a) * l); + } +} From 04aa58c54c05a9ecd586504861ad5282bc8e1361 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:43:30 +0000 Subject: [PATCH 12/16] =?UTF-8?q?31=ED=8C=90:=20Sponge=20Blend=20=EC=9D=98?= =?UTF-8?q?=20=EC=9B=80=EC=A7=81=EC=9E=84=EC=9D=84=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- .../pattern-study/patterns/sponge-blend.js | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/incoming/pattern-study/patterns/sponge-blend.js b/incoming/pattern-study/patterns/sponge-blend.js index ea3630008..8fb72b5b5 100644 --- a/incoming/pattern-study/patterns/sponge-blend.js +++ b/incoming/pattern-study/patterns/sponge-blend.js @@ -1,7 +1,7 @@ // ===== Patternflow pattern ===== // Title: Sponge Blend // Family: cosmetic -// Idea: A stripe of colour is laid down a pale base and an unseen damp sponge bounces along it, each dab printing a soft patch of open-cell stipple that lifts colour off the stripe and leaves it further out, until the hard line is bounced out into a wide soft fade and the base is wiped for the next stripe. +// Idea: A stripe of colour is laid down a pale base and an unseen damp sponge bounces along it, each dab printing a soft patch of open-cell stipple that lifts colour off the stripe and leaves it further out while soft light glides over the damp canvas, until the hard line is bounced out into a wide soft fade and the base is wiped for the next stripe. // Author: patternflow-lab // Date: 2026-10-24 // SPDX-License-Identifier: CC-BY-SA-4.0 @@ -76,7 +76,7 @@ export function setup(params) { params.s = { A: F(N), c: F(N), pm: new Uint8Array(N), cn: F(N), sh: F(N), dist: F(N).fill(99), su: F(N), phase: 0, pt: 0, lay: 0, len: 100, pts: F(98), u: 0, dir: 1, pass: 0, side: 1, load: 0, dabT: 0, n: 0, - hue: 0, speed: 2, cov: 0, grain: 0.06, started: false, col: F(6), + hue: 0, speed: 2, cov: 0, grain: 0.06, started: false, col: F(6), T: 0, }; } @@ -265,9 +265,11 @@ export function update(dt, input, params) { stripe(S, true); } const st = (d * S.speed) / 2; + S.T += st; stepT(S, st); - // The wet shine dries in real time, so a fast sponge leaves several at once. - const fade = exp(-d * 3); + // The wet shine dries in real time, over a second or two, so a fast sponge + // leaves several at once. + const fade = exp(-d * 1.4); for (let i = 0; i < N; i++) S.sh[i] *= fade; } @@ -280,12 +282,19 @@ export function draw(display, params) { // nearly the same brightness, so the blend shows as colour on a steady grain. // Orange and gold read brown when dim, so round them the colour is lifted. const hf = h - floor(h), wm = sm((hf - 0.03) / 0.05) * (1 - sm((hf - 0.15) / 0.07)); - hsv(h, 0.06, 224, col, 0); - hsv(h, 0.5 + 0.18 * cov + 0.1 * wm, 220 + 20 * wm, col, 3); + hsv(h, 0.06, 212, col, 0); + hsv(h, 0.5 + 0.18 * cov + 0.1 * wm, 208 + 18 * wm, col, 3); + // The damp canvas catches the light: two broad soft bands of sheen, about + // 18 levels at most and 35 LEDs wide, glide down it at 18 LEDs a second, + // tilted a little. They change by under a level from LED to LED, so they + // add no edge. + const cyc = H + 104, b0 = ((S.T * 18) % cyc) - 52, b1 = ((S.T * 18 + cyc / 2) % cyc) - 52; // Grain also roughens the base: the checker's step grows by half from 0 to 1. const g = S.grain, op = 0.65 + 0.35 * cov, dip = 0.05 + 0.03 * g, gs = STEP * (0.9 + 0.55 * g); for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) { + const sy = y + (x - 32) * 0.35, e0 = (sy - b0) / 15, e1 = (sy - b1) / 15; + const glow = 0.085 * (exp(-e0 * e0 * 0.5) + exp(-e1 * e1 * 0.5)); // How much pore is round this LED, 0..1 over its 3x3 neighbourhood. let pv = 0; for (let yy = max(0, y - 1); yy <= min(H - 1, y + 1); yy++) for (let xx = max(0, x - 1), j = yy * W + xx; xx <= min(W - 1, x + 1); xx++, j++) pv += pm[j]; @@ -295,7 +304,7 @@ export function draw(display, params) { // brighter, then it dries back. const ac = sqrt(cl(A[i], 0, 1)), wet = sh[i]; const a = min(1, ac * op * (1 + 0.45 * wet) + 0.45 * pv); - const l = 1 + 0.03 * wet - dip * pv + gs * AMP[i] * (ODD[i] ? -0.5 : 0.5); + const l = 1 + glow + 0.08 * wet - dip * pv + gs * AMP[i] * (ODD[i] ? -0.5 : 0.5); display.setPixel(x, y, (col[0] + (col[3] - col[0]) * a) * l, (col[1] + (col[4] - col[1]) * a) * l, (col[2] + (col[5] - col[2]) * a) * l); } } From 16e80f8fe400efaf8ceaef578503e36c3905dbab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:44:19 +0000 Subject: [PATCH 13/16] =?UTF-8?q?31=ED=8C=90:=20Lip=20Gloss=20=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A8=EC=96=91=20=EB=B0=94=EB=80=9C=EC=9D=84=20=EC=98=AC?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- incoming/pattern-study/patterns/lip-gloss.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/incoming/pattern-study/patterns/lip-gloss.js b/incoming/pattern-study/patterns/lip-gloss.js index 78a5324e6..114aee86f 100644 --- a/incoming/pattern-study/patterns/lip-gloss.js +++ b/incoming/pattern-study/patterns/lip-gloss.js @@ -63,7 +63,7 @@ export function setup(params) { bx: F(MAXD * 3), by: F(MAXD * 3), br: F(MAXD * 3), nb: 0, fx: F(MAXF), fy: F(MAXF), fp: F(MAXF), fw: F(MAXF), nf: 0, cover: 0, hue: 0, rate: 1, amount: 0, shim: 0.06, started: false, - tint: F(3), absb: F(3), wall: F(3), pear: F(3), gain: 1, buf: F(N * 3), + tint: F(3), absb: F(3), wall: F(3), pear: F(3), gain: 1, dim: 0.27, buf: F(N * 3), }; } @@ -262,6 +262,7 @@ export function draw(display, params) { // is bright enough that they stay clear, and they get a touch more light. const hw = (hue - floor(hue) + 1) % 1, am = max(0, 1 - abs(hw - 0.11) / 0.1); S.gain = 1 + 0.08 * am; + S.dim = 0.27 * (1 - 0.85 * max(0, 1 - abs(hw - 0.13) / 0.16)); // The wall: a pale warm white with a breath of the tint. hsv(hue + 0.04, 0.07, 1, wall, 0); // Each column's front and the rim behind it: walking up from the bottom, @@ -320,7 +321,10 @@ export function draw(display, params) { const film = sm(0.02, 0.14, t); let R = wall[0] * wb, G = wall[1] * wb, B = wall[2] * wb; if (film > 0) { - const g = S.gain; + // Thick gloss also dims the wall a little in every channel, the band + // and the beads most, so the deep parts read deeper; less so for the + // warm tints, which dimmed turn brown and olive. + const g = S.gain * exp(-S.dim * t * t); R *= exp(-absb[0] * t) * g; G *= exp(-absb[1] * t) * g; B *= exp(-absb[2] * t) * g; From efab2dea5cca21446fb583fe1ef75394b4495917 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:57:52 +0000 Subject: [PATCH 14/16] =?UTF-8?q?31=ED=8C=90:=20index.html=20=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=A7=93=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011H2HGmtJj6aSXuZ88kTDbk --- incoming/pattern-study/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 02917bc58..3e9fee208 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,7 +141,7 @@

패턴 연구

- + +