From b5b02cfddc713c8309293f686ae47e78af2953ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:44:54 +0000 Subject: [PATCH 01/14] =?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=2029=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_01Fz23eRpkazDP4tpajkAeja From 798132e897f08a31fefc51fa01bde0573eae1bf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:50:01 +0000 Subject: [PATCH 02/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20=EC=9C=84=EC=83=81=C2=B7=EB=A7=A4=EB=93=AD?= =?UTF-8?q?=EA=B3=BC=20=ED=99=94=EC=9E=A5=ED=92=88=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98=EB=A5=BC=20=EC=97=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_01Fz23eRpkazDP4tpajkAeja --- incoming/pattern-study/families.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/incoming/pattern-study/families.json b/incoming/pattern-study/families.json index a98425d36..8cadbbc82 100644 --- a/incoming/pattern-study/families.json +++ b/incoming/pattern-study/families.json @@ -50,5 +50,7 @@ "polar": { "label": "얼음·극지", "color": "#00ffff" }, "microbe": { "label": "미생물", "color": "#aa00aa" }, "rail": { "label": "철도", "color": "#00bb66" }, - "film": { "label": "필름·영화", "color": "#ff0000" } + "film": { "label": "필름·영화", "color": "#ff0000" }, + "topology": { "label": "위상·매듭", "color": "#80ff40" }, + "cosmetic": { "label": "화장품 팝업", "color": "#f2c6c2" } } From 6778b8523d0a5462d82d286c3931bc957a227de1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:18:17 +0000 Subject: [PATCH 03/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Dyson=20Gas=20=EA=B3=BC=20Seven=20Shuffles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/dyson-gas.js | 306 +++++++++++++++++ .../pattern-study/patterns/seven-shuffles.js | 311 ++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 incoming/pattern-study/patterns/dyson-gas.js create mode 100644 incoming/pattern-study/patterns/seven-shuffles.js diff --git a/incoming/pattern-study/patterns/dyson-gas.js b/incoming/pattern-study/patterns/dyson-gas.js new file mode 100644 index 000000000..664a01678 --- /dev/null +++ b/incoming/pattern-study/patterns/dyson-gas.js @@ -0,0 +1,306 @@ +// ===== Patternflow pattern ===== +// Title: Dyson Gas +// Family: particle +// Idea: The eigenvalues of a random Hermitian matrix whose entries diffuse jiggle along a line and push each other apart, and their world lines scroll down the panel as a rainbow of threads that braid but never cross, crowded in the middle and sparse at the sides as Wigner's semicircle says. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Dyson Brownian motion (Freeman Dyson, 1962), pattern round 29 +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Count=10..28, Beta=1..9 +// +// Knob 1: Hue. Turns the rainbow of threads round the colour wheel; the lowest eigenvalue on the left and the highest on the right always sit at the two ends of it. +// Knob 2: Speed. How fast the matrix diffuses and the history scrolls: at 2 about twenty rows a second, and each row is a longer stretch of the process, so the threads shiver harder. +// Knob 3: Count. The size of the matrix, 10 to 28 eigenvalues. The semicircle always spans the panel, so more of them crowd the middle: about four and a half LEDs apart there at 10, under two at 28. +// Knob 4: Beta. The strength of the repulsion against the noise: 1, 2 and 4 are the real, complex and quaternion matrices (GOE, GUE, GSE). Higher, the gaps go stiff and even and the threads run like the bars of a crystal. +// Buttons: K1 tap turns the repulsion off for about three seconds at Speed 2: the eigenvalues walk on their own like the diagonal of a matrix with nothing off it, and the threads sag together and cross; then the repulsion comes back, they spring apart and the colours sort themselves back into a rainbow. K2 tap shrinks the matrix to zero and lets it go: the threads pinch into one point and fan out again. + +const W = 64, H = 128, N = W * H, NMAX = 28, CX = 31.5; +const { min, max, floor, round, sqrt, abs, asin, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const rnd = () => Math.random(); +// A normal sample from four uniforms: bounded, which keeps a substep small. +const gauss = () => (rnd() + rnd() + rnd() + rnd() - 2) * 1.7320508; + +// The process, in Dyson's units with an Ornstein-Uhlenbeck pull: +// d lambda_i = sum_j 1 / (lambda_i - lambda_j) dt - lambda_i / 2 dt + sqrt(2 / beta) dB_i +// Its resting state is the Gaussian beta ensemble, whose eigenvalues fill +// Wigner's semicircle on [-2 sqrt(n), 2 sqrt(n)] whatever beta is; beta only +// sets how hard the noise shakes them. The semicircle's edge sits RPX LEDs +// either side of the middle. +const RPX = 29.5; +// The longest substep. Near a close pair the step shrinks with the square of +// the gap, so the pair is pushed apart before the noise can carry it across. +// A frame takes at most SUBMAX substeps; right after a pinch, when the gaps +// are tiny, the rest of that frame's time is let go and the fan opens a +// little slower for a few frames. +const DTMAX = 0.004, SUBMAX = 500; +// History rows kept, one more than the panel so the scroll can interpolate. +const HB = H + 2; +// Where the rainbow starts at Hue 0 (sea green) and how much of the wheel it +// spans, so the highest eigenvalue is pink. +const H0 = 0.42, SPAN = 0.52; +// The beads ride on this row; the threads hang from it. +const TOP = 3; + +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; +} + +// Where the k-th of n eigenvalues sits in the semicircle, as a fraction of +// its radius: the inverse of F(u) = 1/2 + (u sqrt(1 - u^2) + asin u) / pi. +function quantile(q) { + let a = -1, b = 1; + for (let it = 0; it < 40; it++) { + const u = (a + b) / 2; + if (0.5 + (u * sqrt(1 - u * u) + asin(u)) / PI < q) a = u; + else b = u; + } + return (a + b) / 2; +} + +export function setup(params) { + const P = (params.s = { + n: 10, beta: 1, sp: 2, hue: -1, started: false, + lam: new Float64Array(NMAX), sm: new Float64Array(NMAX), f: new Float64Array(NMAX), ord: new Int8Array(NMAX), hc: new Float32Array(NMAX), + hx: new Float32Array(HB * NMAX), hh: new Float32Array(HB * NMAX), hn: new Uint8Array(HB), he: new Uint16Array(HB), + head: 0, acc: 0, ep: 0, free: 0, pinch: -1, snap: new Float64Array(NMAX), + X: new Float32Array((H + 1) * NMAX), XH: new Float32Array((H + 1) * NMAX), Xn: new Uint8Array(H + 1), Xe: new Uint16Array(H + 1), + buf: new Float32Array(N * 3), lut: new Float32Array(256 * 3), + }); + paint(P, 0); + fill(P); +} + +// The rainbow for this Hue, 256 steps from the lowest eigenvalue's colour to +// the highest's. +function paint(P, hue) { + P.hue = hue; + for (let j = 0; j < 256; j++) hsv(hue + H0 + (SPAN * j) / 255, 0.82, 255, P.lut, j * 3); +} + +// A fresh matrix of the current size, already at rest, with a full panel of +// history behind it so the first frame shows the threads. +function fill(P) { + const n = P.n, R = 2 * sqrt(n); + P.ep++; + for (let i = 0; i < n; i++) (P.lam[i] = R * quantile((i + 0.5) / n)), (P.ord[i] = i), (P.hc[i] = n > 1 ? i / (n - 1) : 0.5); + P.free = 0, P.pinch = -1, P.acc = 0; + step(P, 2); + P.sm.set(P.lam); + const tr = rowTau(P.sp); + for (let k = 0; k < HB; k++) step(P, tr), look(P, 1), push(P); +} + +// The threads are drawn through a short exposure: each one follows its +// eigenvalue with a lag of SMOOTH rows. An average of ordered positions is +// still ordered, so the drawn threads cannot cross where the eigenvalues +// do not. +const SMOOTH = 3; +function look(P, rows) { + const a = 1 - Math.exp(-rows / SMOOTH); + for (let i = 0; i < P.n; i++) P.sm[i] += (P.lam[i] - P.sm[i]) * a; +} + +// Rows a second and stretch of the process per row, both from Speed. +const rowRate = (s) => 5 + 7.5 * s; +const rowTau = (s) => 0.01 + 0.01 * s; + +function push(P) { + const n = P.n, sc = RPX / (2 * sqrt(n)); + P.head = (P.head + 1) % HB; + const o = P.head * NMAX; + for (let i = 0; i < n; i++) (P.hx[o + i] = CX + P.sm[i] * sc), (P.hh[o + i] = P.hc[i]); + P.hn[P.head] = n, P.he[P.head] = P.ep; +} + +// Advance the process by T. With the repulsion on, the eigenvalues keep the +// order they had when it came on: ord lists them left to right, and if a +// substep ever lets a pair overtake, the two swap places rather than cross. +function step(P, T) { + const n = P.n, lam = P.lam, f = P.f, ord = P.ord, rep = P.free <= 0, sd0 = sqrt(2 / P.beta); + let left = T; + for (let it = 0; it < SUBMAX && left > 1e-9; it++) { + for (let i = 0; i < n; i++) f[i] = -0.5 * lam[i]; + let dt = DTMAX; + if (rep) { + for (let i = 0; i < n; i++) + for (let j = i + 1; j < n; j++) { + let d = lam[i] - lam[j]; + if (abs(d) < 1e-6) d = d < 0 ? -1e-6 : 1e-6; + const q = 1 / d; + (f[i] += q), (f[j] -= q); + } + // No eigenvalue may move more than a fifth of the closest gap in one + // substep, by the push or by the noise. + let g = 1e9, fm = 1e-9; + for (let r = 0; r + 1 < n; r++) g = min(g, lam[ord[r + 1]] - lam[ord[r]]); + for (let i = 0; i < n; i++) fm = max(fm, abs(f[i])); + g = max(g, 1e-6); + dt = min(DTMAX, 0.2 * g * g, (0.2 * g) / fm); + } + if (dt > left) dt = left; + const sd = sd0 * sqrt(dt); + for (let i = 0; i < n; i++) lam[i] += f[i] * dt + sd * gauss(); + if (rep) + for (let r = 0; r + 1 < n; r++) { + const a = ord[r], b = ord[r + 1]; + if (lam[b] < lam[a]) { + const t = lam[a]; + (lam[a] = lam[b]), (lam[b] = t); + } + } + left -= dt; + } +} + +// When the repulsion comes back, the eigenvalues are ranked where they +// stand and each colour starts drifting back to its rank's place in the +// rainbow. +function rank(P) { + const n = P.n, ord = P.ord, lam = P.lam; + for (let i = 0; i < n; i++) ord[i] = i; + for (let i = 1; i < n; i++) { + const v = ord[i]; + let j = i - 1; + while (j >= 0 && lam[ord[j]] > lam[v]) (ord[j + 1] = ord[j]), j--; + ord[j + 1] = v; + } +} + +// A new size keeps the picture: the new eigenvalues take the old ones' +// quantiles, scaled to the new semicircle. +function resize(P, n) { + const m = P.n, ord = P.ord, lam = P.lam, u = new Float64Array(m); + rank(P); + for (let r = 0; r < m; r++) u[r] = lam[ord[r]] / (2 * sqrt(m)); + P.n = n, P.ep++, P.free = 0, P.pinch = -1; + for (let i = 0; i < n; i++) { + const q = n > 1 ? (i / (n - 1)) * (m - 1) : (m - 1) / 2, a = floor(q), b = min(m - 1, a + 1); + lam[i] = (u[a] + (u[b] - u[a]) * (q - a)) * 2 * sqrt(n); + ord[i] = i, P.hc[i] = n > 1 ? i / (n - 1) : 0.5; + } + P.sm.set(lam); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 10, 1], d = cl(dt || 0, 0, 0.1); + const pr = input.btnPressed || []; + if (k[0] !== P.hue) paint(P, k[0]); + P.sp = cl(k[1], 0.2, 3); + P.beta = cl(k[3], 1, 9); + const n = round(cl(k[2], 10, NMAX)); + // The first frame's history is made again with the knobs as they stand. + if (!P.started) (P.started = true), (P.n = n), fill(P); + else if (n !== P.n) resize(P, n); + + if (pr[0] && P.pinch < 0) P.free = 2; + if (pr[1] && P.pinch < 0) { + P.pinch = 0, P.free = 0; + for (let i = 0; i < P.n; i++) P.snap[i] = P.lam[i]; + } + + const tau = d * rowRate(P.sp) * rowTau(P.sp); + if (P.pinch >= 0) { + // The matrix scaled down to nothing: every eigenvalue scales with it, so + // the threads run straight into the middle. At a thirtieth of its size it + // is let go and the repulsion fans it out again. + P.pinch += d; + const t = cl(P.pinch / 0.55, 0, 1), s = 0.03 + 0.97 * (1 - t * t * (3 - 2 * t)); + for (let i = 0; i < P.n; i++) P.lam[i] = P.snap[i] * s; + if (t >= 1) (P.pinch = -1), rank(P); + } else if (P.free > 0) { + step(P, tau); + P.free -= tau; + if (P.free <= 0) rank(P); + } else step(P, tau); + + // Colours ease back to their rank's place once the order is settled again. + if (P.free <= 0) { + const n1 = max(1, P.n - 1), e = min(1, d * 1.2); + for (let r = 0; r < P.n; r++) P.hc[P.ord[r]] += (r / n1 - P.hc[P.ord[r]]) * e; + } + look(P, d * rowRate(P.sp)); + P.acc += d * rowRate(P.sp); + while (P.acc >= 1) (P.acc -= 1), push(P); +} + +// One thread's light on one row: a 1-LED core across the span it moves +// through between this row and the next, and a soft halo either side. +function span(buf, y, a, b, r, g, bl) { + const x0 = max(0, floor(a - 2.5)), x1 = min(W - 1, floor(b + 2.5)), o = y * W; + for (let x = x0; x <= x1; x++) { + const c = x + 0.5, dd = c < a ? a - c : c > b ? c - b : 0; + let w = 1 - dd; + const h = 0.3 / (1 + dd * dd * 1.6); + if (w < h) w = h; + const q = (o + x) * 3; + buf[q] += r * w, buf[q + 1] += g * w, buf[q + 2] += bl * w; + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const n = P.n, sc = RPX / (2 * sqrt(n)), X = P.X, XH = P.XH, Xn = P.Xn, Xe = P.Xe, buf = P.buf, lut = P.lut, L = H - TOP; + // History row 0 is now, on the beads; the newest stored row is acc rows + // down, the next one a row further. Each drawn row interpolates between + // the two stored rows it falls between, so the scroll glides. + for (let y = 0; y <= L; y++) { + const o = y * NMAX; + let ia, ib, t; + if (y < P.acc) (ia = -1), (ib = P.head), (t = y / P.acc); + else { + const u = y - P.acc, kk = floor(u); + (ia = (P.head - kk + HB * 2) % HB), (ib = (ia - 1 + HB) % HB), (t = u - kk); + } + const ea = ia < 0 ? P.ep : P.he[ia], eb = P.he[ib], na = ia < 0 ? n : P.hn[ia], nb = P.hn[ib]; + if (ea === eb && na === nb) { + for (let i = 0; i < na; i++) { + const xa = ia < 0 ? CX + P.sm[i] * sc : P.hx[ia * NMAX + i], ha = ia < 0 ? P.hc[i] : P.hh[ia * NMAX + i]; + X[o + i] = xa + (P.hx[ib * NMAX + i] - xa) * t; + XH[o + i] = ha + (P.hh[ib * NMAX + i] - ha) * t; + } + (Xn[y] = na), (Xe[y] = ea); + } else { + // A row from before Count was turned: no thread joins across it. + const s = t < 0.5 && ia >= 0 ? ia : ib; + for (let i = 0; i < P.hn[s]; i++) (X[o + i] = P.hx[s * NMAX + i]), (XH[o + i] = P.hh[s * NMAX + i]); + (Xn[y] = P.hn[s]), (Xe[y] = P.he[s]); + } + } + buf.fill(0); + for (let y = 0; y < L; y++) { + const o = y * NMAX, m = Xn[y]; + const okU = y > 0 && Xe[y - 1] === Xe[y] && Xn[y - 1] === m, okD = Xe[y + 1] === Xe[y] && Xn[y + 1] === m; + // The past dims a little toward the bottom; the newest rows burn + // brighter and whiter. + const fade = 1 - 0.42 * (y / L), hot = y < 5 ? 0.35 * (1 - y / 5) : 0, f1 = fade * (1 + hot), wh = 90 * hot; + for (let i = 0; i < m; i++) { + const x = X[o + i], xu = okU ? (X[o - NMAX + i] + x) / 2 : x, xd = okD ? (X[o + NMAX + i] + x) / 2 : x; + const q = round(cl(XH[o + i], 0, 1) * 255) * 3; + span(buf, y + TOP, min(x, xu, xd), max(x, xu, xd), lut[q] * f1 + wh, lut[q + 1] * f1 + wh, lut[q + 2] * f1 + wh); + } + } + // The eigenvalues themselves: a bead on the top of each thread, white at + // the heart and its own colour round the rim. + for (let i = 0; i < n; i++) { + const x = X[i], q = round(cl(P.hc[i], 0, 1) * 255) * 3; + const r = lut[q] * 0.7 + 76, g = lut[q + 1] * 0.7 + 76, b = lut[q + 2] * 0.7 + 76; + for (let yy = 0; yy < TOP + 4; yy++) + for (let xx = max(0, floor(x - 2.5)); xx <= min(W - 1, floor(x + 2.5)); xx++) { + const dx = xx + 0.5 - x, dy = yy - TOP, r2 = dx * dx + dy * dy * 1.2, w = 1.35 / (1 + r2 * 0.9) - 0.14, p = (yy * W + xx) * 3; + if (w <= 0) continue; + const wc = 140 * w * w; + (buf[p] += r * w + wc), (buf[p + 1] += g * w + wc), (buf[p + 2] += b * w + wc); + } + } + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, min(255, buf[p]), min(255, buf[p + 1]), min(255, buf[p + 2])); +} diff --git a/incoming/pattern-study/patterns/seven-shuffles.js b/incoming/pattern-study/patterns/seven-shuffles.js new file mode 100644 index 000000000..af30be430 --- /dev/null +++ b/incoming/pattern-study/patterns/seven-shuffles.js @@ -0,0 +1,311 @@ +// ===== Patternflow pattern ===== +// Title: Seven Shuffles +// Family: algo +// Idea: A deck seen edge-on as a stack of rainbow bars is riffle shuffled again and again, each riffle zipping two packets together so the gradient splits into 2, 4, 8 interleaved runs until it reads as confetti, then the deck is unriffled back into order by a radix sort and starts over. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Gilbert-Shannon-Reeds riffle shuffle (Edgar Gilbert and Claude Shannon, 1955; Jim Reeds, 1981), seven shuffles suffice (Dave Bayer and Persi Diaconis, 1992), pattern round 29 +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Cards=32..128, Neatness=0..1 +// +// Knob 1: Hue. Turns the rainbow the cards are painted in, which runs from the bottom card of the new deck to the top one. +// Knob 2: Speed. How fast the hands work: at 2 a riffle takes under two seconds and a whole round, from order through the riffles and back, about twenty; at 0.2 ten times longer. +// Knob 3: Cards. How many cards are in the deck, 32 to 128: four rows a card at 32, two at 52 and 64, one from 65 up, so the bars thin to single lines. +// Knob 4: Neatness. Up to 0.1 every riffle is the random Gilbert-Shannon-Reeds one and the strip on the left wall shows how far the deck is from random; turned up, the cut moves to the middle and the cards fall more strictly one from each side, until at 1 every riffle is a perfect faro out-shuffle and the deck comes back to order by itself (five out-shuffles for 32 cards, eight for 52, seven for 128). +// Buttons: K1 tap riffles the deck once more, right away; tapped while the deck is being put back, the riffle comes as soon as it is in order. + +const W = 64, H = 128, NMAX = 128; +const { min, max, floor, round, exp, log, abs } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ease = (t) => ((t = cl(t, 0, 1)), t * t * (3 - 2 * t)); +const rnd = () => Math.random(); + +// The deck at rest spans columns XL to XL + BW. In a riffle the bottom packet +// slides DX to the left and the top packet DX to the right, so where they +// interleave each side shows only its own cards: a comb. +const XL = 8, BW = 48, DX = 6; +// The strip on the left wall shows how far the deck is from random; the +// marks on the right wall count the riffles. +const SX = 0, MX = 63; +// Where the rainbow starts at Hue 0 (blue) and how much of the wheel it spans. +const H0 = 0.6, SPAN = 0.78; +// Stop shuffling once the deck is this close to random, in total variation: +// six riffles for 32 cards, seven for 52 (0.334, where six leave 0.614), +// nine for 128. +const ENOUGH = 0.4; + +// Phases, and their length in seconds at Speed 2. A riffle splits the deck, +// drops the cards together and squares it up; an unriffle pass does the +// same backwards. REST holds the ordered deck before the first riffle, WAIT +// the shuffled one before it is put back. +const REST = 0, SPLIT = 1, DROP = 2, JOIN = 3, PAUSE = 4, WAIT = 5; +const RIF = [0.8, 0.3, 0.9, 0.3, 0.25, 1.4], UNR = [0.8, 0.2, 0.55, 0.2, 0.08, 1.4]; + +// log k! for the binomials in Bayer and Diaconis's formula. +const LF = new Float64Array(8400); +for (let i = 1; i < LF.length; i++) LF[i] = LF[i - 1] + log(i); + +// Bayer and Diaconis: after k riffles, a deck whose order has r rising +// sequences turns up with probability C(2^k + n - r, n) / 2^(kn), and the +// Eulerian number A(n, r - 1) counts the orders with r of them. Half the +// summed gap from 1/n! is the total variation distance from random. +function tvTable(n) { + let row = new Float64Array(n + 1), nx = new Float64Array(n + 1); + row[0] = 1; + for (let m = 2; m <= n; m++) { + nx.fill(0); + for (let j = 0; j < m; j++) nx[j] = (j + 1) * row[j] + (m - j) * (j > 0 ? row[j - 1] : 0); + [row, nx] = [nx, row]; + } + const tv = new Float64Array(14); + for (let k = 0; k < 14; k++) { + const q = 2 ** k; + let s = 0; + for (let r = 1; r <= n; r++) { + const w = exp(log(row[r - 1]) - LF[n]); + const ratio = q >= r ? exp(LF[q + n - r] - LF[q - r] - k * n * Math.LN2) : 0; + s += w * abs(ratio - 1); + } + tv[k] = min(1, s / 2); + } + return tv; +} + +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; +} + +// Each card's colour by its place in the new deck, for this Hue. +function paint(P, hue) { + P.hue = hue; + for (let c = 0; c < P.n; c++) hsv(hue + H0 + (SPAN * c) / (P.n - 1), 0.88, 255, P.col, c * 3); +} + +export function setup(params) { + params.s = { + n: 32, nu: 0, sp: 2, hue: 0, + deck: new Int16Array(NMAX), from: new Float32Array(NMAX), to: new Float32Array(NMAX), side: new Int8Array(NMAX), nd: new Int16Array(NMAX), + job: 0, ph: REST, t: 0, k: 0, want: 0, pass: 0, bits: 5, tv: null, kmax: 6, faro: 5, strip: 1, glow: 0, mk: new Float32Array(16), + col: new Float32Array(NMAX * 3), buf: new Float32Array(W * H * 3), order: new Int16Array(NMAX), + }; + fresh(params.s, 32); +} + +// A new deck of n cards in order: card c at place c, counted from the bottom. +function fresh(P, n) { + P.n = n; + for (let c = 0; c < n; c++) P.deck[c] = c; + P.tv = tvTable(n); + P.kmax = 1; + while (P.kmax < 13 && P.tv[P.kmax] >= ENOUGH) P.kmax++; + P.bits = 1; + while (1 << P.bits < n) P.bits++; + // How many perfect out-shuffles bring this deck home. + const a = new Int16Array(n), b = new Int16Array(n); + for (let c = 0; c < n; c++) a[c] = c; + P.faro = 0; + do { + faro(a, b, n); + a.set(b.subarray(0, n)); + P.faro++; + } while (P.faro < 200 && !sorted(a, n)); + P.job = 0, P.ph = REST, P.t = 0, P.k = 0, P.strip = 1; + P.mk.fill(0); + paint(P, P.hue); +} +function sorted(d, n) { + for (let i = 0; i < n; i++) if (d[i] !== i) return false; + return true; +} +// The out-shuffle: the bottom half and the top half alternate from the +// bottom up, so the bottom and top cards stay where they are. +function faro(a, b, n) { + const c = (n + 1) >> 1; + for (let i = 0, l = 0, r = c; i < n; i++) b[i] = i & 1 ? a[r++] : a[l++]; +} + +// Plan one riffle. The cut is binomial, as if each card picked a hand by a +// coin; then the cards fall from the bottoms of the two packets, each from a +// packet with chance in proportion to the cards it still holds. Neatness +// pulls the cut to the middle and the falls toward strict alternation. +function riffle(P) { + const n = P.n, nu = P.nu, d = P.deck; + let coin = 0; + for (let i = 0; i < n; i++) coin += rnd() < 0.5 ? 1 : 0; + const c = cl(round(n / 2 + (coin - n / 2) * (1 - nu) + (n & 1 ? 0.25 : 0)), 0, n); + let a = c, b = n - c, l = 0, r = c, last = 1; + for (let j = 0; j < n; j++) { + const pg = a / (a + b), pa = last > 0 ? 1 : 0; + const fromL = b === 0 || (a > 0 && rnd() < (1 - nu) * pg + nu * pa); + const card = fromL ? d[l++] : d[r++]; + fromL ? a-- : b--; + last = fromL ? -1 : 1; + plan(P, card, fromL ? -1 : 1, j); + } + P.job = 0; +} + +// Plan one unriffle: a pass of a binary radix sort. Cards whose original +// place has a 0 in this bit slide left and drop to the bottom, the 1s slide +// right and rise to the top, each side keeping its order. An inverse riffle +// is exactly this with the bits drawn at random. +function unriffle(P) { + const n = P.n, d = P.deck, bit = 1 << P.pass; + let z = 0; + for (let i = 0; i < n; i++) if (!(d[i] & bit)) z++; + for (let i = 0, lo = 0, hi = z; i < n; i++) { + const card = d[i]; + if (card & bit) plan(P, card, 1, hi++); + else plan(P, card, -1, lo++); + } + P.job = 1; +} + +function plan(P, card, side, j) { + P.side[card] = side; + P.to[card] = j; + P.nd[j] = card; +} + +function begin(P) { + for (let i = 0; i < P.n; i++) P.from[P.deck[i]] = i; + P.ph = SPLIT, P.t = 0; +} + +// What comes after a phase ends. +function next(P) { + if (P.ph === SPLIT) return (P.ph = DROP), (P.t = 0); + if (P.ph === DROP) return (P.ph = JOIN), (P.t = 0); + if (P.ph === JOIN) { + for (let j = 0; j < P.n; j++) P.deck[j] = P.nd[j]; + if (P.job === 0) { + P.k++; + if (P.k <= 16) P.mk[min(15, P.k - 1)] = 1; + } else P.pass++; + return (P.ph = PAUSE), (P.t = 0); + } + // At rest or after a pause: shuffle on, wait, or put the deck back. + const home = sorted(P.deck, P.n); + if (P.job === 1 && P.pass < P.bits) return unriffle(P), begin(P); + if (P.job === 1 || (home && P.k > 0)) { + // Back in order: by the radix sort, or by itself after perfect faros. + if (P.job === 0) P.glow = 1; + P.job = 0, P.k = 0; + if (P.want <= 0) return (P.ph = REST), (P.t = 0); + } + if (P.want > 0 || (P.ph !== WAIT && P.k < (P.nu > 0.97 ? max(P.faro, 1) : P.kmax) && P.k < 12)) { + P.want = max(0, P.want - 1); + return riffle(P), begin(P); + } + if (P.ph !== WAIT) return (P.ph = WAIT), (P.t = 0); + P.pass = 0; + return unriffle(P), begin(P); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 32, 0.06], d = cl(dt || 0, 0, 0.1); + const n = round(cl(k[2], 32, NMAX)); + if (n !== P.n) fresh(P, n); + if (P.hue !== k[0]) paint(P, k[0]); + P.sp = cl(k[1], 0.2, 3); + // Below a tenth the riffle is the pure random one the strip is true for. + P.nu = cl((k[3] - 0.1) / 0.9, 0, 1); + if (input.btnPressed && input.btnPressed[0]) { + P.want++; + if (P.ph === REST || P.ph === WAIT) next(P); + } + // The hands slow down with Speed, but the waits between riffles do not + // drag on past a second or two. + const still = P.ph === REST || P.ph === WAIT || P.ph === PAUSE; + P.t += d * (still ? max(P.sp / 2, 0.75) : P.sp / 2); + const T = P.job ? UNR : RIF; + if (P.t >= T[P.ph]) next(P); + + // The strip eases to the distance after k riffles, back to full once the + // deck is being put in order; it fades out as the riffles grow neat, where + // the formula no longer holds. + const goal = P.job === 1 ? 1 : P.tv[min(13, P.k)]; + P.strip += (goal - P.strip) * min(1, d * 3); + P.glow = max(0, P.glow - d * 0.7); + for (let i = 0; i < 16; i++) { + const on = i < P.k && P.job === 0 ? 1 : 0; + P.mk[i] += (on * 0.55 - P.mk[i]) * min(1, d * (on ? 6 : 2.5)); + } +} + +// One card edge-on: h rows from row top, its face lit from above and its +// lower edge dim (dark for the thick cards, a faint line at two rows a +// card), across columns a to a + BW with the ends anti-aliased. +function card(P, c, top, h, a, pass) { + const buf = P.buf, col = P.col, b = a + BW, gap = h >= 3 ? 0 : 0.28; + const g = P.glow * P.glow * 0.6, r0 = col[c * 3], g0 = col[c * 3 + 1], b0 = col[c * 3 + 2]; + const R = r0 + (255 - r0) * g, G = g0 + (255 - g0) * g, B = b0 + (255 - b0) * g; + for (let yy = 0; yy < h; yy++) { + const Y = top + yy, edge = yy === h - 1 && h > 1; + if (Y < 0 || Y >= H || edge !== (pass === 0)) continue; + const lv = edge ? gap : 1 - 0.13 * yy; + for (let x = max(0, floor(a)); x <= min(W - 1, floor(b)); x++) { + const w = (min(x + 1, b) - max(x, a)) * lv, q = (Y * W + x) * 3; + if (w <= 0) continue; + buf[q] += (R - buf[q]) * w; + buf[q + 1] += (G - buf[q + 1]) * w; + buf[q + 2] += (B - buf[q + 2]) * w; + } + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const n = P.n, buf = P.buf, T = P.job ? UNR : RIF, u = P.t / T[P.ph]; + const h = n <= 32 ? 4 : n <= 42 ? 3 : n <= 64 ? 2 : 1, y0 = floor((H - n * h) / 2); + buf.fill(0); + const moving = P.ph === SPLIT || P.ph === DROP || P.ph === JOIN; + // While the packets pass through each other the right one is in front. + const ord = P.order; + if (P.ph === SPLIT || P.ph === DROP) { + let m = 0; + for (let j = 0; j < n; j++) if (P.side[P.nd[j]] < 0) ord[m++] = P.nd[j]; + for (let j = 0; j < n; j++) if (P.side[P.nd[j]] > 0) ord[m++] = P.nd[j]; + } else for (let j = 0; j < n; j++) ord[j] = moving ? P.nd[j] : P.deck[j]; + // Two passes: the dim edge rows first, then the faces, so a card passing + // in front never greys another card's face with its edge. + for (let pass = 0; pass < 2; pass++) + for (let j = 0; j < n; j++) { + const c = ord[j]; + let y = j, off = 0; + if (moving) { + const s = P.side[c] * DX; + if (P.ph === SPLIT) (y = P.from[c]), (off = s * ease(u)); + else if (P.ph === DROP) (y = P.from[c] + (P.to[c] - P.from[c]) * ease(u)), (off = s); + else (y = P.to[c]), (off = s * (1 - ease(u))); + } + card(P, c, y0 + round((n - 1 - y) * h), h, XL + off, pass); + } + // The strip: full height while the deck is far from random, sinking as + // the riffles add up. + const fade = max(0, 1 - P.nu * 8), sh = P.strip * H; + if (fade > 0) + for (let y = 0; y < H; y++) { + const w = cl(sh - (H - 1 - y), 0, 1) * fade * (H - y < sh - 1.5 ? 0.38 : 0.85), q = (y * W + SX) * 3; + buf[q] = 230 * w, buf[q + 1] = 226 * w, buf[q + 2] = 214 * w; + } + // One mark per riffle down the right wall. + for (let i = 0; i < 16; i++) { + const w = P.mk[i]; + if (w < 0.02) continue; + for (let y = 2 + i * 6; y < 5 + i * 6 && y < H; y++) { + const q = (y * W + MX) * 3; + buf[q] = 240 * w, buf[q + 1] = 236 * w, buf[q + 2] = 224 * w; + } + } + 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 522d55e3748cf9451e74c8beea2fa1573de9c85d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:35:34 +0000 Subject: [PATCH 04/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Hopf=20Fibration=20=EA=B3=BC=20Perron=20Tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/hopf-fibration.js | 220 ++++++++++++++++++ .../pattern-study/patterns/perron-tree.js | 175 ++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 incoming/pattern-study/patterns/hopf-fibration.js create mode 100644 incoming/pattern-study/patterns/perron-tree.js diff --git a/incoming/pattern-study/patterns/hopf-fibration.js b/incoming/pattern-study/patterns/hopf-fibration.js new file mode 100644 index 000000000..5cfcf2df0 --- /dev/null +++ b/incoming/pattern-study/patterns/hopf-fibration.js @@ -0,0 +1,220 @@ +// ===== Patternflow pattern ===== +// Title: Hopf Fibration +// Family: topology +// Idea: The 3-sphere filled with circles, one over each point of the 2-sphere, stereographically projected into space and seen in perspective: the fibres over each circle of latitude sweep out a torus, the tori nest round a ring and a vertical line through infinity, and any two circles link exactly once. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Hopf fibration (Heinz Hopf, 1931), pattern round 29 +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Rings=3..7, Fibres=0..1 +// +// Knob 1: Hue. Turns the colours round the wheel; each fibre takes its hue from the longitude of its base point. +// Knob 2: Speed. How fast the base points run round their latitudes (the tori turn, neighbours the opposite way), the latitudes breathe and the view rocks. +// Knob 3: Rings. How many circles of latitude carry base points, 3 to 7; each is one torus, and the outer ones open into tall loops round the line. +// Knob 4: Fibres. How many fibres each torus has, from 9 to 16. +// Buttons: K1 tap lights two fibres from different tori and dims the rest for five seconds, so you can follow them through each other. K2 tap tilts the circles of base points on the sphere, so the tori lean over and turn lopsided; tap again to stand them up. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, sin, cos, atan2, acos, abs, 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)); + +// Points round one fibre, and the most fibres on show: seven tori of +// sixteen, the ring and the line. +const NT = 120, MAXF = 7 * 16 + 2, MAXS = MAXF * NT; +const CT = new Float32Array(NT), ST = new Float32Array(NT); +for (let i = 0; i < NT; i++) (CT[i] = cos((TAU * i) / NT)), (ST[i] = sin((TAU * i) / NT)); + +// Camera distance from the middle of the ring and focal length in LEDs: the +// ring, radius 1, is 16 LEDs from the line. Depth bins for the painter. +const D = 7, F = D * 16, CX = 31.5, CY = 64, NB = 64, NEAR = 0.4; +// Polar angles of the innermost and outermost circle of base points. +const TH0 = 0.6, TH1 = 2.35; + +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); + const c = i === 0 ? [v, t, p] : i === 1 ? [q, v, p] : i === 2 ? [p, v, t] : i === 3 ? [p, q, v] : i === 4 ? [t, p, v] : [v, p, q]; + out[o] = c[0] * 255; + out[o + 1] = c[1] * 255; + out[o + 2] = c[2] * 255; +} + +export function setup(params) { + const F32 = (n) => new Float32Array(n), I32 = (n) => new Int32Array(n); + params.s = { + T: 0, tilt: 0, tiltTo: 0, pair: 0, hue: 0, rings: 3, fib: 9, + buf: F32(N * 3), + // Every fibre point on screen with its depth. + sx: F32(MAXS), sy: F32(MAXS), sz: F32(MAXS), + col: F32(MAXF * 3), lit: F32(MAXF), + // Segments as (first point, last point), sorted far to near by depth bin. + sa: I32(MAXS), sb: I32(MAXS), key: new Uint8Array(MAXS), ord: I32(MAXS), bin: I32(NB + 1), + }; +} + +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.T += d * cl(k[1], 0.2, 3); + P.rings = cl(k[2], 3, 7); + P.fib = 9 + 7 * cl(k[3], 0, 1); + const b = input.btnPressed || []; + if (b[0]) P.pair = 5; + if (b[1]) P.tiltTo = P.tiltTo ? 0 : 1; + P.pair = max(0, P.pair - d); + P.tilt += cl(P.tiltTo - P.tilt, -d * 0.5, d * 0.5); +} + +// One fibre into the point arrays. The base point sits at polar angle th +// from the pole whose fibre is the ring, at longitude ph; with a = th/2 its +// fibre is (cos a e^{i(t+ph)}, sin a e^{it}) on the 3-sphere, projected +// stereographically from (0,0,0,1) and then through the camera. +function fibre(P, f, th, ph, cam) { + const a = th / 2, ca = cos(a), sa = sin(a), cp = cos(ph) * ca, sp = sin(ph) * ca; + const [rx, ry, ux, uy, uz, fx, fy, fz] = cam; + for (let i = 0, o = f * NT; i < NT; i++, o++) { + const c = CT[i], s = ST[i], q = 1 / (1 - sa * s + 1e-6); + const X = (c * cp - s * sp) * q, Y = (s * cp + c * sp) * q, Z = sa * c * q; + const zc = D + X * fx + Y * fy + Z * fz; + P.sz[o] = zc; + P.sx[o] = CX + (F * (X * rx + Y * ry)) / zc; + P.sy[o] = CY - (F * (X * ux + Y * uy + Z * uz)) / zc; + } +} + +// One segment: an antialiased core whose half width hw comes from depth, and +// a faint halo of the same light. Each LED is pulled toward the line's +// colour by its cover, so a near strand passes over a far one with a thin +// dark seam: that seam is what shows which of two fibres is in front. +function line(buf, x0, y0, x1, y1, r, g, b, hw, op) { + const R = hw + 1.7, E = hw + 1.2; + const by0 = max(0, floor(min(y0, y1) - R)), by1 = min(H - 1, floor(max(y0, y1) + R)); + const dx = x1 - x0, dy = y1 - y0, L2 = dx * dx + dy * dy; + if (by0 > by1 || L2 < 1e-4) return; + const il = 1 / sqrt(L2), nx = -dy * il, ny = dx * il, ux = dx / L2, uy = dy / L2; + for (let y = by0; y <= by1; y++) { + // The LEDs of this row within reach of the segment, solved from the two + // linear conditions rather than tested over its whole box. + const py = y + 0.5 - y0; + let lo = -R - 2, hi = R + 2 + abs(dx); + if (abs(nx) > 1e-6) { + const p = (-E - py * ny) / nx, q = (E - py * ny) / nx; + lo = max(lo, min(p, q)); + hi = min(hi, max(p, q)); + } else if (abs(py * ny) > E) continue; + if (abs(ux) > 1e-9) { + const p = (-py * uy) / ux, q = (1 - py * uy) / ux; + lo = max(lo, min(p, q)); + hi = min(hi, max(p, q)); + } else if (py * uy < 0 || py * uy >= 1) continue; + const xa = max(0, Math.ceil(lo + x0 - 0.5)), xb = min(W - 1, floor(hi + x0 - 0.5)); + for (let x = xa, p = y * W + xa; x <= xb; x++, p++) { + const px = x + 0.5 - x0, u = px * ux + py * uy; + if (u < 0 || u >= 1) continue; + const dd = abs(px * nx + py * ny) - hw; + if (dd > 1.2) continue; + const w = (dd < 0.5 ? cl(0.5 - dd, 0.3, 1) : 0.3 * (1 - (dd - 0.5) / 0.7)) * op, q = p * 3; + buf[q] += (r - buf[q]) * w; + buf[q + 1] += (g - buf[q + 1]) * w; + buf[q + 2] += (b - buf[q + 2]) * w; + } + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const T = P.T, buf = P.buf, { sx, sy, sz, sa, sb, key, ord, bin, col, lit } = P; + buf.fill(0); + // The camera turns slowly round the line and rocks between looking across + // the tori and looking down into the ring. + const az = 0.5 + T * 0.05, el = 0.75 + 0.2 * sin(T * 0.11); + const ce = cos(el), se = sin(el), ca = cos(az), sn = sin(az); + const cam = [-sn, ca, -se * ca, -se * sn, ce, -ce * ca, -ce * sn, -se]; + const nr = P.rings, n = floor(P.fib), tl = sm(P.tilt) * 1.1, ct = cos(tl), st = sin(tl); + // After a K1 tap one fibre of the innermost torus and one of the next stay + // lit and the rest dim to under a third. + const hi = sm(P.pair) * 0.7, pick0 = 0, pick1 = floor(n / 2) + 1; + let nf = 0; + for (let j = 0; j < 7; j++) { + const on = cl(nr - j, 0, 1); + if (on <= 0) break; + // Latitudes spread evenly from near the ring's pole to near the line's, + // each breathing on its own phase; a ring the knob is still bringing in + // fades up on the outermost one while the others close up. + const u = min(j, nr - 1) / (nr - 1), th0 = TH0 + (TH1 - TH0) * u + 0.1 * sin(T * 0.35 + j * 1.7), dir = j & 1 ? -1 : 1; + for (let i = 0; i < n; i++) { + const ph = (TAU * i) / n + dir * T * 0.25 + j * 0.4; + // The base point, its circle of latitude tipped about the x axis. + const bx = sin(th0) * cos(ph), by0 = sin(th0) * sin(ph), bz0 = cos(th0); + const by = by0 * ct - bz0 * st, bz = by0 * st + bz0 * ct; + fibre(P, nf, acos(cl(bz, -1, 1)), atan2(by, bx), cam); + // Hue from longitude, and paler toward the ring's pole, deeper and + // darker toward the line's. + hsv(P.hue + 0.55 + i / n, 0.45 + 0.5 * u, on * (1 - 0.4 * u), col, nf * 3); + const picked = (j === 0 && i === pick0) || (j === 1 && i === pick1); + lit[nf] = picked ? 1 + hi : 1 - hi; + nf++; + } + } + // The ring, fibre over one pole, and the line through infinity, the fibre + // over the other: on the panel a vertical line through the middle. + const rl = 1 - hi * 0.6; + fibre(P, nf, 0, 0, cam); + col[nf * 3] = col[nf * 3 + 1] = 170; + col[nf * 3 + 2] = 185; + lit[nf++] = rl; + for (let i = 0, o = nf * NT; i < NT; i++, o++) { + const Z = (i / (NT - 1) - 0.5) * 60, zc = D + Z * cam[7]; + sz[o] = zc; + sx[o] = CX; + sy[o] = CY - (F * Z * cam[4]) / zc; + } + col[nf * 3] = col[nf * 3 + 1] = 170; + col[nf * 3 + 2] = 185; + lit[nf++] = rl; + + // Cut each fibre into segments about three LEDs long on screen, skipping + // what lies behind the camera or off the panel, and bin them by depth. + bin.fill(0); + let ns = 0; + for (let f = 0; f < nf; f++) { + const o = f * NT, loop = f < nf - 1, end = loop ? NT : NT - 1; + let a = o; + for (let i = 1; i <= end; i++) { + const b = o + (i % NT), ok = sz[a] > NEAR && sz[b] > NEAR; + const ddx = sx[b] - sx[a], ddy = sy[b] - sy[a], L = abs(ddx) + abs(ddy); + if (ok && L < 3 && i < end) continue; + if (ok && L < 70) { + const x0 = sx[a], y0 = sy[a], x1 = sx[b], y1 = sy[b]; + if (!((x0 < -3 && x1 < -3) || (x0 > W + 3 && x1 > W + 3) || (y0 < -3 && y1 < -3) || (y0 > H + 3 && y1 > H + 3))) { + const kb = cl(floor((((sz[a] + sz[b]) / 2 - (D - 4)) / 8) * NB), 0, NB - 1); + key[ns] = kb; + sa[ns] = a; + sb[ns++] = b; + bin[kb + 1]++; + } + } + a = b; + } + } + for (let i = 1; i <= NB; i++) bin[i] += bin[i - 1]; + for (let s = 0; s < ns; s++) ord[bin[key[s]]++] = s; + // Far to near. A fibre is lit 1 near the camera and a quarter far off, + // and its core is two LEDs wide near and one far. A slow swell of light + // runs round every fibre at once: the circle action of the fibration, + // which moves each point of the 3-sphere along its own fibre. + for (let r = ns - 1; r >= 0; r--) { + const s = ord[r], a = sa[s], b = sb[s], f = floor(a / NT), c = f * 3, lf = lit[f]; + const dn = cl(((sz[a] + sz[b]) / 2 - (D - 3)) / 6, 0, 1) * (lf > 1 ? 2 - lf : 1); + const v = (1 - 0.75 * dn) * lf * (0.72 + 0.28 * cos((TAU * (a % NT)) / NT - 1.6 * T)); + line(buf, sx[a], sy[a], sx[b], sy[b], min(255, col[c] * v), min(255, col[c + 1] * v), min(255, col[c + 2] * v), 0.25 + 0.6 * (1 - dn), lf < 1 ? lf : 1); + } + 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]); +} diff --git a/incoming/pattern-study/patterns/perron-tree.js b/incoming/pattern-study/patterns/perron-tree.js new file mode 100644 index 000000000..14433fb6f --- /dev/null +++ b/incoming/pattern-study/patterns/perron-tree.js @@ -0,0 +1,175 @@ +// ===== Patternflow pattern ===== +// Title: Perron Tree +// Family: geometry +// Idea: A tall triangle that holds a needle in every direction between its sides is cut into thin slivers that slide over each other level by level, as Perron did, until it is a tree of sprouts on a third of the area that still holds every direction, and a white needle turns through all of them, hopping from sprout to sprout. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Kakeya needle problem (Sōichi Kakeya, 1917), Besicovitch set drawn as Oskar Perron's tree (1928), pattern round 29 +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Depth=5..8, Slide=-1..1 +// +// Knob 1: Hue. Turns the sweep of colours that marks each sliver's direction round the wheel. +// Knob 2: Speed. How fast the slivers slide and the needle turns; at 2 the tree takes five and a half seconds to grow, stands for six and folds back up in three. +// Knob 3: Depth. How many times the base is halved, 5 to 8: from 32 slivers to 256, and as many levels of sliding. The finished tree covers 30% of the triangle at 5 and 24% at 8. +// Knob 4: Slide. How far each pair slides over the other: at -1 a little, and the tree is a tall trunk with a small crown frayed out at the top; at 1 a long way, and a short stub carries a crown that fans out nearly as wide as the triangle was. +// Buttons: K1 tap starts again from the single triangle. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, exp, cos, abs, pow, round, 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)); + +// The triangle: base on the bottom edge, BW LEDs wide, apex HT LEDs up. +const BW = 60, HT = 120, Y0 = 128, MAXN = 8, MAXM = 1 << MAXN; +// How much of the blurred light comes back as glow (the blur sums to 81). +const GLOW = 0.22 / 81; + +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); + const c = i === 0 ? [v, t, p] : i === 1 ? [q, v, p] : i === 2 ? [p, v, t] : i === 3 ? [p, q, v] : i === 4 ? [t, p, v] : [v, p, q]; + out[o] = c[0]; + out[o + 1] = c[1]; + out[o + 2] = c[2]; +} + +export function setup(params) { + params.s = { + lam: 0, hold: 0, phase: 0, needle: 0, n: 5, g: 0.8, hue: 0, + acc: new Float32Array(N * 3), trail: new Float32Array(N), fade: 1, blo: new Float32Array(N * 3), sh: new Float32Array(MAXM), tj: new Float32Array(MAXN + 1), dur: new Float32Array(MAXN + 1), + col: new Float32Array(MAXM * 3), + }; +} + +// Perron's slides. At level j the base pieces pair up in groups of 2^j and +// the right half of each group slides left by tj[j]: first closing the gap +// its heart left at the level before, then a further 2(1 - g) of its half +// base, so the two halves cross at 2g - 1 of the group triangle's height and +// fork into two sprouts above it. What lies below is the group's heart, its +// triangle shrunk by g^j toward its left corner, and the next level slides +// hearts the same way. (Stein's write-up of Schoenberg's version.) +function slides(P, n, g) { + const w = BW / (1 << n); + for (let j = 1; j <= n; j++) { + const half = w * (1 << (j - 1)), s = pow(g, j - 1); + P.tj[j] = half * (1 - s) + 2 * (1 - g) * s * half; + } + // Seconds per level at Speed 2: the first levels move a fraction of an + // LED and go by quickly, the last ones take their time. + for (let j = 1; j <= n; j++) P.dur[j] = 0.3 + 1.7 * pow(P.tj[j] / P.tj[n], 0.6); +} + +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) * cl(k[1], 0.2, 3) / 2; + P.hue = cl(k[0], 0, 1); + const n = cl(round(k[2]), 5, MAXN), g = 0.8 - 0.12 * cl(k[3], -1, 1); + if (n !== P.n || abs(g - P.g) > 1e-4) { + // Keep the same share of the build when the depth changes. + const frac = P.lam / P.n; + P.n = n; + P.g = g; + P.lam = frac * n; + } + slides(P, P.n, P.g); + if (input.btnPressed && input.btnPressed[0]) (P.phase = 0), (P.lam = 0), (P.hold = 0); + // Build level by level, hold the tree, take it apart again from the top + // level down at twice the pace, rest on the whole triangle, and again. + if (P.phase === 0) { + P.lam += d / P.dur[min(P.n, floor(P.lam) + 1)]; + if (P.lam >= P.n) (P.lam = P.n), (P.phase = 1), (P.hold = 0); + } else if (P.phase === 1) { + if ((P.hold += d) > 6) P.phase = 2; + } else if (P.phase === 2) { + P.lam -= (2 * d) / P.dur[cl(Math.ceil(P.lam), 1, P.n)]; + if (P.lam <= 0) (P.lam = 0), (P.phase = 3), (P.hold = 0); + } else if ((P.hold += d) > 1.2) P.phase = 0; + P.needle += d * 0.11; + P.fade = exp(-cl(dt || 0, 0, 0.1) / 0.12); +} + +// One row of one sliver into the light buffer: the span [l, r) at row y. +// A span under an LED wide is drawn one LED wide and dimmer, so the sprouts +// taper in light rather than vanish between LEDs. +function span(acc, y, l, r, cr, cg, cb) { + let wd = r - l, k = 1; + if (wd < 1) { + const c = (l + r) / 2; + k = sqrt(max(wd, 0)); + (l = c - 0.5), (r = c + 0.5), (wd = 1); + } + const x0 = max(0, floor(l)), x1 = min(W - 1, floor(r - 1e-6)); + for (let x = x0, p = (y * W + x0) * 3; x <= x1; x++, p += 3) { + const cov = (min(r, x + 1) - max(l, x)) * k; + if (cov <= 0) continue; + acc[p] += cr * cov; + acc[p + 1] += cg * cov; + acc[p + 2] += cb * cov; + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const n = P.n, M = 1 << n, w = BW / M, acc = P.acc, sh = P.sh, col = P.col; + acc.fill(0); + // How far each level has slid: level j runs while lam goes from j-1 to j. + let lo = 1e9, hi = -1e9; + for (let i = 0; i < M; i++) { + let s = 0; + for (let j = 1; j <= n; j++) if ((i >> (j - 1)) & 1) s -= P.tj[j] * sm(P.lam - (j - 1)); + sh[i] = s; + lo = min(lo, s + i * w, s + BW / 2); + hi = max(hi, s + (i + 1) * w, s + BW / 2); + } + // Keep the whole figure centred as it slides. + const ox = W / 2 - (lo + hi) / 2; + // Each sliver's colour from its direction, the hue sweeping 0.8 of the wheel + // from one side of the triangle to the other. + // One sliver alone tones to about 140 of 255, two to 200, four near white. + for (let i = 0; i < M; i++) hsv(P.hue + 0.5 + (0.8 * (i + 0.5)) / M, 0.85, 0.5, col, i * 3); + for (let y = Y0 - HT; y < Y0; y++) { + const h = Y0 - (y + 0.5), f = h / HT; + if (f < 0 || f > 1) continue; + for (let i = 0; i < M; i++) { + const a = ox + sh[i] + BW / 2, l = ox + sh[i] + i * w, r = l + w; + span(acc, y, l + (a - l) * f, r + (a - r) * f, col[i * 3], col[i * 3 + 1], col[i * 3 + 2]); + } + } + // The needle: a segment the height of the triangle, swinging through every + // direction between its sides and back. Its direction picks the base point + // it stands on, and that point the sliver whose sprout holds it. + const sw = 0.5 - 0.5 * cos(P.needle * 2 * PI), b = sw * BW, i = min(M - 1, floor(b / w)); + const bx = ox + sh[i] + b, ax = ox + sh[i] + BW / 2, tr = P.trail, fd = P.fade; + // Its light fades over about a tenth of a second, which softens each hop. + for (let p = 0; p < N; p++) tr[p] *= fd; + for (let y = Y0 - HT; y < Y0; y++) { + const h = Y0 - (y + 0.5), f = h / HT, x = bx + (ax - bx) * f; + for (let xx = max(0, floor(x - 2)); xx <= min(W - 1, floor(x + 2)); xx++) { + const dd = abs(xx + 0.5 - x), wgt = dd < 0.5 ? 1 : dd < 1.5 ? 0.9 * (1.5 - dd) : 0.12 * (2.5 - dd), p = y * W + xx; + if (wgt > tr[p]) tr[p] = wgt; + } + } + for (let p = 0, q = 0; p < N; p++, q += 3) { + const v = tr[p]; + if (v < 0.01) continue; + acc[q] += 1.6 * v; + acc[q + 1] += 1.55 * v; + acc[q + 2] += 1.4 * v; + } + // A soft glow round everything bright: a 5 by 5 blur, about a fifth of it + // added back, so the thin sprouts read from a few metres away. + const blo = P.blo; + for (let y = 0; y < H; y++) + for (let x = 0, p = y * W * 3; x < W; x++, p += 3) + for (let c = 0; c < 3; c++) blo[p + c] = (x > 1 ? acc[p + c - 6] : 0) + 2 * (x > 0 ? acc[p + c - 3] : 0) + 3 * acc[p + c] + 2 * (x < W - 1 ? acc[p + c + 3] : 0) + (x < W - 2 ? acc[p + c + 6] : 0); + // Overlaps add up: light piles toward white where many slivers cross. + const R = W * 3, tone = (q, y) => { + const g = (y > 1 ? blo[q - 2 * R] : 0) + 2 * (y > 0 ? blo[q - R] : 0) + 3 * blo[q] + 2 * (y < H - 1 ? blo[q + R] : 0) + (y < H - 2 ? blo[q + 2 * R] : 0); + return 255 * (1 - exp(-1.6 * (acc[q] + GLOW * g))); + }; + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, tone(p, y), tone(p + 1, y), tone(p + 2, y)); +} From 5e6276426a18c0186445be23fb6b326dc7c4820c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:36:30 +0000 Subject: [PATCH 05/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Glitter=20Fall=20=EA=B3=BC=20Loose=20Powder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/glitter-fall.js | 346 +++++++++++++++++ .../pattern-study/patterns/loose-powder.js | 351 ++++++++++++++++++ 2 files changed, 697 insertions(+) create mode 100644 incoming/pattern-study/patterns/glitter-fall.js create mode 100644 incoming/pattern-study/patterns/loose-powder.js diff --git a/incoming/pattern-study/patterns/glitter-fall.js b/incoming/pattern-study/patterns/glitter-fall.js new file mode 100644 index 000000000..9f62b010b --- /dev/null +++ b/incoming/pattern-study/patterns/glitter-fall.js @@ -0,0 +1,346 @@ +// ===== Patternflow pattern ===== +// Title: Glitter Fall +// Family: cosmetic +// Idea: Glitter pours from the top of a dark panel in a swaying stream, every tiny hexagonal flake tumbling and flashing as its face turns to the lamp, and piles into dunes at the bottom that glint as the lamp moves until the floor opens and the heap drains away like an hourglass. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 29, after loose cosmetic glitter poured under a display lamp +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Amount=1..3, Size=0..1 +// +// Knob 1: Hue. The colour of the glitter, all the way round the wheel from rose pink at 0; the silver flakes in the mix stay silver while they fall. +// Knob 2: Speed. How fast everything happens: at 2 a flake takes about eight seconds to fall the 128 rows and turns over every two seconds or so; at 0.2 it drifts down ten times slower. +// Knob 3: Amount. How much glitter pours, from a steady shower at 1 to a heavy pour at 3 that fills the panel with flakes and builds the heap three times as fast. +// Knob 4: Size. The flakes, from fine glitter one LED across near 0 to chunky hexagons three LEDs across at 1 that visibly thin to an edge as they tumble. +// Buttons: K1 tap throws the top of the heap up into the air, and it flashes all the way back down. + +const W = 64, H = 128, N = W * H; +const MAXP = 2600; +const { min, max, floor, sin, cos, exp, abs, sqrt, log, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// Every flake carries one of NS seeds, which fix its colour class, how it lies +// once it has landed and how bright it is. A grain in the heap keeps its seed, +// so the heap's texture and glints stay put while the lamp moves. +const NS = 1024; +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; +const TX = new Float32Array(NS), TY = new Float32Array(NS), JIT = new Float32Array(NS), CLS = new Uint8Array(NS); +for (let i = 0; i < NS; i++) { + // A landed flake lies roughly flat: its tilt is a small Gaussian. + const r = sqrt(-2 * log(hash(i * 4) + 1e-6)) * 0.3, a = 2 * PI * hash(i * 4 + 1); + TX[i] = r * cos(a); + TY[i] = r * sin(a); + JIT[i] = hash(i * 4 + 2); + const c = hash(i * 4 + 3); + // Main colour, a lighter tint, a deeper shade, and silver. + CLS[i] = c < 0.5 ? 0 : c < 0.7 ? 1 : c < 0.85 ? 2 : 3; +} +// Hue offset, saturation and value of each class, and how much of the hue a +// flash keeps. Silver keeps a breath of the hue so it sits in the same mix. +const PAL = [ + [0, 0.85, 0.8, 0.45], + [-0.03, 0.55, 0.95, 0.35], + [0.03, 1, 0.65, 0.55], + [0, 0.1, 0.5, 0.08], +]; + +// The heap's grains sit one to an LED, stacked in columns from the floor up. +// HEAP is how many grains make the floor open, LOW how many it keeps. +const HEAP = 2600, LOW = 1150; + +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), ph: F(MAXP), om: F(MAXP), al: F(MAXP), sw: F(MAXP), vt: F(MAXP), sd: new Uint16Array(MAXP), + n: 0, G: new Int16Array(N).fill(-1), h: new Int16Array(W), grains: 0, T: 0, acc: 0, ca: 0, dir: 1, + drain: 0, dx0: 0, dx1: 0, dtime: 0, started: false, + buf: F(N * 3), base: F(12), fl: F(12), hue: 0, amount: 1, size: 0.06, lx: 32, ly: 80, + }; +} + +// One flake into the air at x, y. +function spawn(S, x, y, vx, vy, seed) { + if (S.n >= MAXP) return; + const i = S.n++; + S.x[i] = x; + S.y[i] = y; + S.vx[i] = vx; + S.vy[i] = vy; + // Its own spin: phase and rate, so each flash is a smooth function of time. + S.ph[i] = Math.random() * 2 * PI; + S.om[i] = (1.6 + 3 * Math.random()) * (Math.random() < 0.5 ? -1 : 1); + S.al[i] = Math.random() * PI; + S.sw[i] = Math.random() * 2 * PI; + S.vt[i] = 12 + 6 * Math.random(); + S.sd[i] = seed; +} + +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.ph[i] = S.ph[j]; + S.om[i] = S.om[j]; + S.al[i] = S.al[j]; + S.sw[i] = S.sw[j]; + S.vt[i] = S.vt[j]; + S.sd[i] = S.sd[j]; +} + +// Where the stream comes down: a centre that wanders the width of the panel. +const pour = (T) => 32 + 20 * sin(T * 0.19) + 7 * sin(T * 0.47 + 1.3); + +// Moves the top grain of column a onto the top of column b. +function slide(S, a, b) { + const h = S.h, G = S.G, from = (H - h[a]) * W + a; + h[b]++; + G[(H - h[b]) * W + b] = G[from]; + G[from] = -1; + h[a]--; +} + +// The heap settles like sand: wherever a column stands two or more grains +// above its neighbour, grains slip from its top down onto the neighbour's, at +// most three a step, so a wall slumps to the angle of repose within a few +// frames and the slope can be seen running. +function settle(S) { + const h = S.h, G = S.G; + S.dir = -S.dir; + for (let k = 0; k < W - 1; k++) { + const x = S.dir > 0 ? k : W - 2 - k; + let d = h[x] - h[x + 1]; + // Some steps hold at 45 degrees and some do not, which roughens the slopes. + const lim = Math.random() < 0.5 ? 2 : 3; + if (d >= lim) for (let n = min(3, d >> 1); n > 0; n--) slide(S, x, x + 1); + else if (-d >= lim) for (let n = min(3, -d >> 1); n > 0; n--) slide(S, x + 1, x); + } + // An open floor lets the grains over the slot fall out of the bottom: the + // whole column sinks by one, so its glitter is seen running down. + if (S.drain) + for (let x = S.dx0; x <= S.dx1; x++) { + if (h[x] <= 0 || Math.random() > 0.8) continue; + for (let y = H - 1; y > H - h[x]; y--) G[y * W + x] = G[(y - 1) * W + x]; + G[(H - h[x]) * W + x] = -1; + h[x]--; + S.grains--; + } +} + +// Lands a falling flake on top of column cx. +function land(S, cx, seed) { + if (S.h[cx] >= H - 1) return; + S.h[cx]++; + S.G[(H - S.h[cx]) * W + cx] = seed; + S.grains++; +} + +function step(S, st) { + S.T += st; + const T = S.T; + // The lamp drifts round the lower two thirds of the panel. + S.lx = 32 + 34 * sin(T * 0.29); + S.ly = 84 + 34 * sin(T * 0.21 + 0.7); + // Pouring: a little over half the glitter comes down in the stream, the + // rest is sprinkled across the whole width. + S.acc += st * 80 * S.amount; + const c = pour(T); + while (S.acc >= 1) { + S.acc -= 1; + const x = Math.random() < 0.55 ? c + (Math.random() + Math.random() - 1) * 9 : Math.random() * W; + spawn(S, cl(x, 0, W - 0.01), -2 * Math.random(), 0, 12, floor(Math.random() * NS)); + } + // Glitter falls slowly, rocking from side to side on its way down. + const k = min(1, 1.5 * st), air = 3 * sin(T * 0.37); + for (let i = S.n - 1; i >= 0; i--) { + S.sw[i] += st * 2; + S.vy[i] += (S.vt[i] - S.vy[i]) * k; + S.vx[i] += (air + 5 * sin(S.sw[i]) - S.vx[i]) * k; + S.ph[i] += S.om[i] * st; + const oy = S.y[i]; + let x = S.x[i] + S.vx[i] * st; + const y = oy + S.vy[i] * st; + if (x < 0) (x = -x), (S.vx[i] = abs(S.vx[i])); + if (x >= W) (x = 2 * W - 0.01 - x), (S.vx[i] = -abs(S.vx[i])); + S.x[i] = x; + S.y[i] = y; + if (S.vy[i] <= 0 || y < 0) continue; + // It lands once it reaches the top of the heap in its column. + const cx = floor(x); + if (y >= H - S.h[cx] - 0.5) { + land(S, cx, S.sd[i]); + kill(S, i); + } + } + S.ca += st * 30; + while (S.ca >= 1) { + S.ca -= 1; + settle(S); + } + // A deep heap opens the floor under where the stream has been piling it; + // the floor closes once about half of it has run out or after twelve seconds. + // The slot widens with the pour so it always drains faster than it fills. + if (!S.drain && S.grains > HEAP) { + const m = round(cl(c, 12, W - 13)), w = round(4 + 2 * S.amount); + S.drain = 1; + S.dx0 = m - w; + S.dx1 = m + w; + S.dtime = 0; + } else if (S.drain) { + S.dtime += st; + if (S.grains < LOW || S.dtime > 12) S.drain = 0; + } +} + +// K1: the top layer of the heap leaps into the air. +function burst(S) { + const G = S.G, h = S.h; + for (let x = 0; x < W; x++) + for (let got = 0; got < 6 && h[x] > 2; got++) { + const i = (H - h[x]) * W + x; + spawn(S, x + Math.random(), H - h[x] + Math.random(), (x - 32) * 0.4 + (Math.random() - 0.5) * 30, -75 - 95 * Math.random(), G[i]); + G[i] = -1; + h[x]--; + S.grains--; + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 1, 0.06]; + const d = cl(dt || 0, 0, 0.1), rate = cl(k[1], 0.2, 3) / 2; + S.hue = k[0]; + S.amount = cl(k[2], 1, 3); + S.size = cl(k[3], 0, 1); + if (!S.started) { + S.started = true; + // Open on a heap already lying in dunes and the stream already falling. + for (let x = 0; x < W; x++) { + const h = round(17 + 6 * sin(x * 0.09 + 1) + 4 * sin(x * 0.23)); + for (let y = H - h; y < H; y++) S.G[y * W + x] = floor(Math.random() * NS); + S.h[x] = h; + S.grains += h; + } + for (let i = 0; i < 300; i++) step(S, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0]) burst(S); + step(S, d * rate); +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const buf = S.buf, base = S.base, fl = S.fl, G = S.G, lx = S.lx, ly = S.ly; + const hue = S.hue + 0.92; + for (let c = 0; c < 4; c++) { + const p = PAL[c]; + hsv(hue + p[0], p[1], p[2] * 255, base, c * 3); + hsv(hue + p[0], p[3], 255, fl, c * 3); + } + // A deep field, warmed a little round the lamp but kept under what an LED + // shows as lit, so every flake reads against black. + for (let y = 0, q = 0; y < H; y++) + for (let x = 0; x < W; x++, q += 3) { + const g = 9 * exp(-((x - lx) ** 2 + (y - ly) ** 2) / 1800); + buf[q] = (4 + g) * (0.4 + base[0] / 425); + buf[q + 1] = (3 + g) * (0.4 + base[1] / 425); + buf[q + 2] = (6 + g) * (0.4 + base[2] / 425); + } + // The heap: each grain its seed's colour, lit by the lamp and shaded with + // depth. A grain glints when its tilt sends the lamp to the eye: the tilt + // needed changes across the heap and the lamp moves, so the glints travel. + for (let x = 0; x < W; x++) + for (let y = H - S.h[x]; y < H; y++) { + const i = y * W + x, g = G[i]; + if (g < 0) continue; + // Silver grains in the heap take the light tint: grey ones read as grit. + const dep = y - H + S.h[x], ex = x - lx, ey = y - ly, c = CLS[g] * 3, cb = c === 9 ? 3 : c; + const lamp = 0.6 + 0.5 * exp(-(ex * ex + ey * ey) / 900); + const v = (0.6 + 0.25 * JIT[g]) * lamp * (dep < 1 ? 1.25 : max(0.6, 1 - 0.01 * dep)); + const hx = TX[g] - ex * 0.0105, hy = TY[g] - ey * 0.0105; + const gl = exp(-(hx * hx + hy * hy) / 0.016) * (dep < 6 ? 1 : 0.6); + const q = i * 3; + buf[q] += base[cb] * v + fl[c] * gl * 1.4; + buf[q + 1] += base[cb + 1] * v + fl[c + 1] * gl * 1.4; + buf[q + 2] += base[cb + 2] * v + fl[c + 2] * gl * 1.4; + if (gl > 0.35) star(buf, x + 0.5, y + 0.5, fl, c, gl * 0.8, 1 + S.size * 2); + } + // The falling flakes: a hexagon turned in the plane and thinned as it + // tumbles, flashing when its face swings through the angle that sends the + // lamp's light to the eye: once a turn, the back being matt. + const r0 = 0.55 + 1.05 * S.size; + for (let i = 0; i < S.n; i++) { + const x = S.x[i], y = S.y[i]; + if (y < -3 || y > H + 3) continue; + const g = S.sd[i], c = CLS[g] * 3, a = S.ph[i]; + const sq = max(0.22, abs(cos(a))), f = exp((cos(a - (x - lx) * 0.006 - (y - ly) * 0.003) - 1) / 0.035); + // A big flake is dimmer per LED, so a heavy stream of them does not burn white. + const r = r0 * (0.8 + 0.4 * JIT[g]), v = ((0.5 + 0.35 * JIT[g]) * (0.55 + 0.45 * sq)) / (0.7 + 0.3 * r0); + const ca = cos(S.al[i]), sa = sin(S.al[i]), e = r + 1.2; + // Small flakes keep their light when they land between LEDs. + const norm = r < 1 ? 1.15 / (0.35 + r) : 1; + 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 px = X + 0.5 - x, py = Y + 0.5 - y; + const u = abs(px * ca + py * sa) / sq, w = abs(py * ca - px * sa); + const dh = max(w, w * 0.5 + u * 0.866) - r; + const cov = cl(0.5 - dh, 0, 1) * norm; + if (cov <= 0) continue; + const q = (Y * W + X) * 3, b = cov * v, h = (cov * f * 1.5) / (0.6 + 0.4 * r0); + buf[q] += base[c] * b + fl[c] * h; + buf[q + 1] += base[c + 1] * b + fl[c + 1] * h; + buf[q + 2] += base[c + 2] * b + fl[c + 2] * h; + } + if (f > 0.3) star(buf, x, y, fl, c, f, 1.5 + r0 * 1.5); + } + 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])); +} + +// A flash spills its light into a four-pointed star round the flake, the arms +// fading out over len LEDs, with a breath on the diagonals. +function star(buf, x, y, fl, c, f, len) { + const cx = floor(x), cy = floor(y), n = Math.ceil(len); + for (let k = 1; k <= n; k++) { + const w = f * 0.75 * (1 - (k - 1) / (len + 0.5)) ** 2; + if (w <= 0.01) break; + add(buf, cx + k, cy, fl, c, w); + add(buf, cx - k, cy, fl, c, w); + add(buf, cx, cy + k, fl, c, w); + add(buf, cx, cy - k, fl, c, w); + } + const dw = f * 0.22; + add(buf, cx + 1, cy + 1, fl, c, dw); + add(buf, cx - 1, cy + 1, fl, c, dw); + add(buf, cx + 1, cy - 1, fl, c, dw); + add(buf, cx - 1, cy - 1, fl, c, dw); +} +function add(buf, x, y, fl, c, w) { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const q = (y * W + x) * 3; + buf[q] += fl[c] * w; + buf[q + 1] += fl[c + 1] * w; + buf[q + 2] += fl[c + 2] * w; +} + +// Bright pile-ups roll off toward white instead of clipping flat. +function knee(v) { + return v < 190 ? v : 255 - 65 * exp(-(v - 190) / 65); +} diff --git a/incoming/pattern-study/patterns/loose-powder.js b/incoming/pattern-study/patterns/loose-powder.js new file mode 100644 index 000000000..13dd278ea --- /dev/null +++ b/incoming/pattern-study/patterns/loose-powder.js @@ -0,0 +1,351 @@ +// ===== Patternflow pattern ===== +// Title: Loose Powder +// Family: cosmetic +// Idea: A pressed powder embossed with a flower medallion fills the panel under a low light; an unseen puff, shown only by its shadow, comes down and presses a soft dent into it, and as it lifts a pale cloud of powder rises with mica glinting in it, drifts up the panel on slow air and settles back as a dusting, over a white band edged with one gold line. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 29, after the embossed relief of pressed face powder and the puff lifted from it +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Shimmer=1..5, Air=0..1 +// +// Knob 1: Hue. The colour of the powder, all the way round the wheel from rose at 0; the cloud stays a pale tint of it, and the white band and the gold line stay as they are. +// Knob 2: Speed. How fast the puff and the air move: at 2 the puff comes down every seven seconds and its cloud hangs for about ten; at 0.2 everything runs ten times slower. +// Knob 3: Shimmer. How much mica is in the powder: at 1 it is matte with a few glints in the cloud; turned up, a satin sheen runs along the slopes of the relief that face the light, the surface glints here and there and the cloud carries up to five times the mica. +// Knob 4: Air. How far the cloud drifts: at 0 it creeps a few rows up the panel and settles near the dent; at 1 a draught carries it off the top in a few seconds and lifts wisps of loose powder off the surface by itself. +// Buttons: K1 tap brings the puff down now, once the last one has lifted. + +const W = 64, H = 128, N = W * H; +const MAXP = 2600; +const { min, max, floor, sin, cos, exp, sqrt, hypot, atan2, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sstep = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; + +// The powder ends at row FLOOR, where a gold line edges a white band: the +// packaging, the only part of it on show. The medallion sits centred above. +const FLOOR = 115, MY = 57; + +// The emboss, as a height: an eight-petalled flower with a raised rim round +// each petal, a ring at its heart, a wide ring cut off by the panel's sides +// and fine rays beyond it. GRAIN is each LED's fixed bit of powder texture and +// TW the seed of the mica that may sit there. +const E = new Float32Array(N), GRAIN = new Float32Array(N), TW = new Float32Array(N); +for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const dx = x + 0.5 - 32, dy = y + 0.5 - MY, r = hypot(dx, dy), a = atan2(dy, dx); + const pet = 21 + 6 * cos(a * 8); + let h = cl((pet - r) / 3, 0, 1) * 0.6; + h += 0.4 * exp(-(((r - pet) / 1.2) ** 2)); + h += 0.5 * exp(-(((r - 8.5) / 1.2) ** 2)); + h += 0.5 * exp(-(((r - 42) / 1.3) ** 2)); + if (r > 44) h += 0.25 * (0.5 + 0.5 * cos(a * 48)); + const i = y * W + x; + E[i] = h; + GRAIN[i] = (hash(i) - 0.5) * 0.07; + TW[i] = hash(i + 77777); + } + +// The LEDs that could hold mica, in the order Shimmer turns them on. +const MICA = Int32Array.from({ length: FLOOR * W }, (_, i) => i) + .filter((i) => TW[i] < 0.02) + .sort((a, b) => TW[a] - TW[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); + 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), z: F(MAXP), vx: F(MAXP), vy: F(MAXP), vz: F(MAXP), ph: F(MAXP), om: F(MAXP), mica: new Uint8Array(MAXP), + n: 0, dent: F(N), wear: F(N).fill(1), dust: F(N), prof: F(N), hm: F(N), den: F(N), sh: F(N), tmp: F(N), buf: F(N * 3), + T: 0, u: 99, wisp: 0, px: 32, py: 64, pz: 40, started: false, hue: 0, shim: 1, air: 0.06, az: 0, col: F(6), + }; +} + +// One grain of powder into the air at height z. When the air is full, it +// takes the place of a grain already there. +function emit(S, x, y, z, vx, vy, vz) { + let i = S.n; + if (i >= MAXP) i = floor(Math.random() * MAXP); + else S.n++; + S.x[i] = x; + S.y[i] = y; + S.z[i] = z; + S.vx[i] = vx; + S.vy[i] = vy; + S.vz[i] = vz; + // A mica flake turns at its own rate and flashes once a turn. + S.mica[i] = Math.random() < 0.04 * S.shim ? 1 : 0; + S.ph[i] = Math.random() * 2 * PI; + S.om[i] = (1.5 + 3.5 * Math.random()) * (Math.random() < 0.5 ? -1 : 1); +} +function kill(S, i) { + const j = --S.n; + S.x[i] = S.x[j]; + S.y[i] = S.y[j]; + S.z[i] = S.z[j]; + S.vx[i] = S.vx[j]; + S.vy[i] = S.vy[j]; + S.vz[i] = S.vz[j]; + S.ph[i] = S.ph[j]; + S.om[i] = S.om[j]; + S.mica[i] = S.mica[j]; +} + +// The puff's round, in seconds at Speed 2: it comes down, presses, lifts, and +// the cloud has the rest. R is its radius in LEDs, ZTOP how high it waits. +const DOWN = 1.2, PRESS = 0.8, LIFT = 0.7, ROUND = 7, R = 16, ZTOP = 34; + +// A new round: the puff picks a spot, and the dent it will press is drawn up. +function start(S) { + S.u = 0; + S.px = 18 + Math.random() * 28; + S.py = 22 + Math.random() * (FLOOR - 46); + const p = S.prof; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const r = hypot(x + 0.5 - S.px, y + 0.5 - S.py); + p[y * W + x] = 1 - sstep(R * 0.3, R, r); + } +} + +function step(S, st) { + S.T += st; + S.az = 0.32 * sin(S.T * 0.21) + 0.12 * sin(S.T * 0.53 + 1); + if (S.u > ROUND) start(S); + const u0 = S.u; + S.u += st; + const u = S.u; + // Height of the puff above the powder, and how deep it is pressed. + if (u < DOWN) S.pz = ZTOP * (1 - sstep(0, DOWN, u)); + else if (u < DOWN + PRESS) S.pz = 0; + else S.pz = ZTOP * sstep(DOWN + PRESS, DOWN + PRESS + LIFT * 1.6, u); + const press = u < DOWN ? 0 : u < DOWN + PRESS ? sstep(DOWN, DOWN + PRESS * 0.6, u) : 1; + // Pressing dents the powder and flattens the emboss under the puff. Both + // come back slowly as the cloud dusts the surface, so the round can repeat. + const dent = S.dent, wear = S.wear, dust = S.dust, prof = S.prof; + const fd = exp(-st / 7), fw = st / 9, fu = exp(-st / 5); + for (let i = 0; i < N; i++) { + dent[i] *= fd; + wear[i] += (1 - wear[i]) * fw; + dust[i] *= fu; + if (press > 0 && u < DOWN + PRESS + 0.05) { + const p = prof[i] * press; + if (p > dent[i]) dent[i] = p; + const w = 1 - 0.6 * p; + if (w < wear[i]) wear[i] = w; + } + } + // Squeezed out at the rim as it presses in, lifted out of the dent as it rises. + if (u0 < DOWN + 0.1 && u >= DOWN + 0.1) + for (let k = 0; k < 420; k++) { + const a = Math.random() * 2 * PI, s = 10 + 22 * Math.random(); + emit(S, S.px + cos(a) * R * 0.9, S.py + sin(a) * R * 0.9, 0.5 + Math.random() * 2, cos(a) * s, sin(a) * s, 4 + 8 * Math.random()); + } + if (u > DOWN + PRESS && u < DOWN + PRESS + LIFT) { + const n = floor((850 * st) / LIFT + Math.random()); + for (let k = 0; k < n; k++) { + const a = Math.random() * 2 * PI, r = R * sqrt(Math.random()) * 0.9, s = 3 + 9 * Math.random(); + emit(S, S.px + cos(a) * r, S.py + sin(a) * r, Math.random() * 3, cos(a) * s, sin(a) * s, 8 + 12 * Math.random()); + } + } + // The air: slow eddies and a drift up the panel, both stronger with Air. + // A strong draught lifts wisps of loose powder off the surface by itself. + const A = S.air, T = S.T, kd = min(1, 1.2 * st), kz = min(1, 0.9 * st); + S.wisp += st * 160 * A * A; + while (S.wisp >= 1) { + S.wisp -= 1; + emit(S, Math.random() * W, 10 + Math.random() * (FLOOR - 10), 0.2, 0, 0, 2 + 4 * Math.random()); + } + for (let i = S.n - 1; i >= 0; i--) { + const x = S.x[i], y = S.y[i]; + const ex = (1.5 + 6 * A) * sin(y * 0.06 + T * 0.4) + (1 + 3 * A) * sin(y * 0.13 - T * 0.3 + x * 0.05); + const ey = -(2 + 16 * A) + (1.5 + 4 * A) * sin(x * 0.09 - T * 0.33); + S.vx[i] += (ex - S.vx[i]) * kd; + S.vy[i] += (ey - S.vy[i]) * kd; + S.vz[i] += (-1.3 - S.vz[i]) * kz; + S.x[i] = x + S.vx[i] * st; + S.y[i] = y + S.vy[i] * st; + S.z[i] += S.vz[i] * st; + S.ph[i] += S.om[i] * st; + const nx = S.x[i], ny = S.y[i]; + if (nx < -4 || nx > W + 4 || ny < -6 || ny > FLOOR + 4) { + kill(S, i); + continue; + } + if (S.z[i] <= 0) { + // Settling: it dusts the powder where it lands. + const p = floor(ny) * W + floor(nx); + if (nx >= 0 && nx < W && ny >= 0 && ny < FLOOR) dust[p] = min(1.5, dust[p] + 0.05); + kill(S, i); + } + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 1, 0.06]; + const d = cl(dt || 0, 0, 0.1), rate = cl(k[1], 0.2, 3) / 2; + S.hue = k[0]; + S.shim = cl(k[2], 1, 5); + S.air = cl(k[3], 0, 1); + // Open with the last round's cloud still in the air and the next puff a + // second and a half away. + if (!S.started) { + S.started = true; + start(S); + for (let i = 0; i < 162; i++) step(S, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0] && S.u > DOWN + PRESS + LIFT) start(S); + step(S, d * rate); +} + +// Spreads a splat buffer over its neighbours, for a soft cloud. +function blur(a, t, passes) { + for (let pass = 0; pass < passes; pass++) { + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + t[i] = (a[i] * 2 + (x > 0 ? a[i - 1] : a[i]) + (x < W - 1 ? a[i + 1] : a[i])) * 0.25; + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + a[i] = (t[i] * 2 + (y > 0 ? t[i - W] : t[i]) + (y < H - 1 ? t[i + W] : t[i])) * 0.25; + } + } +} +function splat(a, x, y, w) { + const x0 = floor(x - 0.5), y0 = floor(y - 0.5), fx = x - 0.5 - x0, fy = y - 0.5 - y0; + for (let j = 0; j < 2; j++) + for (let i = 0; i < 2; i++) { + const px = x0 + i, py = y0 + j; + if (px < 0 || px >= W || py < 0 || py >= H) continue; + a[py * W + px] += w * (i ? fx : 1 - fx) * (j ? fy : 1 - fy); + } +} + +// The four LEDs beside one, as offsets into the colour buffer. +const NB = [-3, 3, -W * 3, W * 3]; + +// The light comes low from the upper left, so the relief stands out, and sways +// a little: the sheen and the glints move with it. +const LA = atan2(-0.65, -0.55), LZ = 0.5; + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const buf = S.buf, hm = S.hm, den = S.den, sh = S.sh, col = S.col; + hsv(S.hue + 0.95, 0.5, 205, col, 0); + hsv(S.hue + 0.95, 0.07, 255, col, 3); + const la = LA + S.az, lx = cos(la), ly = sin(la); + const hl = hypot(lx * 0.894, ly * 0.894, 1.447), hx = (lx * 0.894) / hl, hy = (ly * 0.894) / hl, hz = 1.447 / hl; + const sheen = 0.1 + 0.45 * (S.shim - 1); + // The height of the surface, then the cloud's density and its shadow, + // thrown away from the light by each grain's height. + for (let i = 0; i < N; i++) hm[i] = E[i] * S.wear[i] - 0.9 * S.dent[i] + 0.25 * S.dust[i]; + den.fill(0); + sh.fill(0); + for (let i = 0; i < S.n; i++) { + const z = S.z[i]; + splat(den, S.x[i], S.y[i], 0.55); + splat(sh, S.x[i] - lx * z * 0.8, S.y[i] - ly * z * 0.8, 0.35); + } + blur(den, S.tmp, 2); + blur(sh, S.tmp, 1); + // The puff's shadow, cast away from the light, softer the higher it is. + const sx = S.px - lx * S.pz * 0.8, sy = S.py - ly * S.pz * 0.8, soft = 1.5 + S.pz * 0.3, dark = 0.36 - 0.2 * (S.pz / ZTOP); + const sr = R * (1 + S.pz * 0.01); + for (let y = 0; y < FLOOR; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x, q = i * 3; + const gx = (hm[y * W + min(W - 1, x + 1)] - hm[y * W + max(0, x - 1)]) * 1.6; + const gy = (hm[min(FLOOR - 1, y + 1) * W + x] - hm[max(0, y - 1) * W + x]) * 1.6; + const inv = 1 / sqrt(gx * gx + gy * gy + 1), lam = max(0, (-gx * lx - gy * ly + LZ) * inv) / LZ; + // Shimmer: a satin sheen on the slopes that face halfway between the + // light and the eye. + let sp = max(0, (-gx * hx - gy * hy + hz) * inv); + sp *= sp; + sp *= sp; + sp *= sp; + sp = sp * sp * sp * sheen; + let v = (0.42 + 0.5 * lam) * (1 + GRAIN[i]); + // Fresh loose powder on top is paler; the pressed patch a touch deeper. + const du = min(1, S.dust[i]) * 0.3, pr = S.dent[i] * 0.1; + v *= 1 - pr; + const c = sh[i] * 1.4; + v *= 1 - (0.16 * c) / (1 + c); + const ddx = x + 0.5 - sx, ddy = y + 0.5 - sy, dd = ddx * ddx + ddy * ddy; + if (S.pz < ZTOP * 0.99 && dd < (sr + soft) ** 2) v *= 1 - dark * (1 - sstep(sr - soft, sr + soft, sqrt(dd))); + let r = col[0] * v + 250 * sp, g = col[1] * v + 240 * sp, b = col[2] * v + 225 * sp; + r += (col[3] * v - r) * du; + g += (col[4] * v - g) * du; + b += (col[5] * v - b) * du; + // The cloud in the air, lit and pale. + const e = den[i] * 1.8, a = min(0.9, e / (1 + e)); + buf[q] = r + (col[3] - r) * a; + buf[q + 1] = g + (col[4] - g) * a; + buf[q + 2] = b + (col[5] - b) * a; + } + // The white band, and the gold line along its top with a highlight that + // slides along it as the light sways. + { + const gx = 32 + 110 * S.az; + for (let y = FLOOR; y < H; y++) + for (let x = 0; x < W; x++) { + const q = (y * W + x) * 3, gold = y === FLOOR ? 1 : y === FLOOR + 1 ? 0.35 : 0; + const hl = 0.8 + 0.45 * exp(-((x - gx) ** 2) / 50); + buf[q] = 222 + (240 * hl - 222) * gold; + buf[q + 1] = 216 + (178 * hl - 216) * gold; + buf[q + 2] = 208 + (70 * hl - 208) * gold; + } + } + // Mica pressed into the powder: a few LEDs, more with Shimmer, each glinting + // when the swaying light passes the angle of its face. + const lim = 0.004 * S.shim; + for (let k = 0; k < MICA.length && TW[MICA[k]] < lim; k++) { + const i = MICA[k]; + const g = exp((cos(TW[i] * 1500 + S.az * 7) - 1) / 0.03) * (1 - min(1, S.dent[i])); + if (g < 0.02) continue; + const q = i * 3, v = 230 * g; + buf[q] += v; + buf[q + 1] += v * 0.92; + buf[q + 2] += v * 0.8; + } + // Mica in the cloud: each flake turns and flashes as its face meets the light. + for (let i = 0; i < S.n; i++) { + if (!S.mica[i]) continue; + const f = exp((cos(S.ph[i]) - 1) / 0.05), v = 40 + 260 * f; + const x = floor(S.x[i]), y = floor(S.y[i]); + if (x < 1 || x >= W - 1 || y < 1 || y >= FLOOR - 1) continue; + const q = (y * W + x) * 3; + buf[q] += v; + buf[q + 1] += v * 0.95; + buf[q + 2] += v * 0.85; + // A flash spills a little onto the LEDs beside it, or it looks pinpricked. + if (f > 0.3) + for (const o of NB) { + buf[q + o] += v * 0.25; + buf[q + o + 1] += v * 0.24; + buf[q + o + 2] += v * 0.21; + } + } + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, min(255, buf[p]), min(255, buf[p + 1]), min(255, buf[p + 2])); +} From 551264551e27a62b2ed8de1586f5b24d858e30d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:37:27 +0000 Subject: [PATCH 06/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Cream=20Swirl=20=EA=B3=BC=20Serum=20Gel?= 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_01Fz23eRpkazDP4tpajkAeja --- .../pattern-study/patterns/cream-swirl.js | 496 ++++++++++++++++++ incoming/pattern-study/patterns/serum-gel.js | 347 ++++++++++++ 2 files changed, 843 insertions(+) create mode 100644 incoming/pattern-study/patterns/cream-swirl.js create mode 100644 incoming/pattern-study/patterns/serum-gel.js diff --git a/incoming/pattern-study/patterns/cream-swirl.js b/incoming/pattern-study/patterns/cream-swirl.js new file mode 100644 index 000000000..2b9468027 --- /dev/null +++ b/incoming/pattern-study/patterns/cream-swirl.js @@ -0,0 +1,496 @@ +// ===== Patternflow pattern ===== +// Title: Cream Swirl +// Family: cosmetic +// Idea: An unseen palette knife sweeps slow S-curves and spirals through a thick satin cream, leaving a smooth band, pushing a glossy ridge along its edge and drawing dollops of tinted cream out into ribbons. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 29, after the swatch of face cream spread with a spatula in a macro product film +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Tones=1..3, Gloss=0..1 +// +// Knob 1: Hue. Turns the cream and its tints round the whole colour wheel together, opening on rose. +// Knob 2: Speed. How fast and how hard the knife works: at 2 it travels 20 LEDs a second and an S-sweep down the panel takes about eight seconds; slow, it glides and leaves smooth bands, fast, it chatters and throws taller ridges. +// Knob 3: Tones. How many tints are folded into the pale cream, from one pastel at 1 to three at 3, a pale, a mid and a deep one; the ribbons split into their bands at once, and with more tints more dollops fall in each stroke's path. +// Knob 4: Gloss. At 0 the cream has a soft satin sheen spread over its slopes; at 1 it is wet, a little deeper in colour, and each ridge carries a thin white highlight. +// Buttons: K1 tap drops a fresh dollop of the deepest tint in the knife's path, and the knife sweeps it into a ribbon. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, sin, cos, abs, pow, PI } = Math; +const rnd = () => Math.random(); +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 knife's edge is cut into NB bins. Each bin carries the cream it has +// scraped up and that cream's tint, so a dollop picked up at one end of the +// blade is laid down as a ribbon behind that end, not across the whole band. +const NB = 16; +// Path points are half an LED apart. +const STEP = 0.5, PMAX = 1200; +// How fast a bin's tint turns to the cream it scrapes, per unit of height +// scraped; how much of the band's skin is the cream the blade carries; what +// share of each bin's load slides on to the next bin along the angled blade +// for each LED travelled. +const PICK = 0.22, FILM = 0.6, SLIP = 1.05; +// The ridge the spill builds stands at RS height per LED on its flanks, and +// is never taller than RMAX. +const RS = 0.4, RMAX = 3.6; +// How deep the roll of cream in front of the blade is, in LEDs. +const ROLL = 6; +// How deep the visible skin is: scraping this much off shows the bed. +const SKIN = 0.8; +// The opening hue: rose, not red. +const H0 = 0.93; + +// A soft light low from the top left, and the half vector to a viewer +// straight in front, for the sheen. +const LX = -0.6, LY = -0.62, LZ = 0.5; +const HL = sqrt(LX * LX + LY * LY + (LZ + 1) * (LZ + 1)), HX = LX / HL, HY = LY / HL, HZ = (LZ + 1) / HL; +// How tall the height field looks: a slope of 1 tilts the surface 56 degrees. +const KZ = 1.5; +// The light is a big soft box: it wraps a little way round the cream, so a +// slope turned from it shades off gently instead of dropping into shadow. +const WRAP = 0.45; +// How much a fold darkens and a crest brightens, per unit of height. +const CAV = 0.35; +// Cream the knife has just laid down holds a wet film that lifts it by this +// much, fading as it dulls. +const GLINT = 26; + +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; +} + +// The tint concentrations a dollop can come in, for one, two and three tints. +const DOSE = [[0.55], [0.4, 0.88], [0.3, 0.62, 1]]; +// Where the colour ramp steps between tints, so folded cream shows distinct +// bands rather than one blurred pink: thresholds and the level each band shows. +const STEPS = [ + [[0.22], [0, 0.55]], + [[0.18, 0.6], [0, 0.42, 0.88]], + [[0.14, 0.44, 0.78], [0, 0.3, 0.62, 1]], +]; +function band(c, n) { + const [th, lv] = STEPS[n]; + let v = lv[0]; + for (let i = 0; i < th.length; i++) v += (lv[i + 1] - lv[i]) * sm((c - th[i]) / 0.26 + 0.5); + return v; +} + +export function setup(params) { + params.s = { + // Height, the tint of the skin, the tint of the bed under it. + hf: new Float32Array(N), tn: new Float32Array(N), ut: new Float32Array(N), + dh: new Float32Array(N), dt: new Float32Array(N), bl: new Float32Array(N), av: new Float32Array(N), + stamp: new Int32Array(N).fill(-999), sub: 0, fr: new Float32Array(N), + path: new Float32Array(PMAX * 2), plen: 0, pos: 0, type: 0, done: 0, + m: new Float32Array(NB), c: new Float32Array(NB), bm: 0, bc: 0, + L: 24, ca: 1, sa: 0, B: 0, tilt: 0, gPh: 0, cc: 0, cA: 0, cP: 8, cB: 0, slip: 1, rh: 0, + sp: new Float32Array(8), pause: 0, live: false, force: 1, + dol: [], hue: 0, tones: 1, gloss: 0.06, ready: false, + lut: new Float32Array(64 * 3), lus: new Float32Array(64 * 3), + kx: 0, ky: 0, dx: 0, dy: 1, tx: 1, ty: 0, + }; +} + +function blur(a, b) { + for (let y = 0; y < H; y++) + for (let x = 0, p = y * W; x < W; x++, p++) b[p] = (a[x > 0 ? p - 1 : p] + 2 * a[p] + a[x < W - 1 ? p + 1 : p]) * 0.25; + for (let y = 0; y < H; y++) + for (let x = 0, p = y * W; x < W; x++, p++) a[p] = (b[y > 0 ? p - W : p] + 2 * b[p] + b[y < H - 1 ? p + W : p]) * 0.25; +} + +// A round dollop: a soft mound of tint c with a peak where it left the spoon. It is all skin, so a knife that +// scrapes it away finds the cream it was dropped on. +function dollop(P, x0, y0, r, ht, c, f) { + const x1 = max(0, floor(x0 - r)), x2 = min(W - 1, floor(x0 + r) + 1); + const y1 = max(0, floor(y0 - r)), y2 = min(H - 1, floor(y0 + r) + 1); + for (let y = y1; y <= y2; y++) + for (let x = x1; x <= x2; x++) { + const d = ((x - x0) ** 2 + (y - y0) ** 2) / (r * r); + if (d >= 1) continue; + const p = y * W + x, a = ht * f * (1 - sqrt(d)) * (1 - d * 0.5); + P.hf[p] += a; + P.tn[p] += (c - P.tn[p]) * min(1, a * 2); + } +} + +// The stroke's path as a function of u from 0 to 1, from the parameters +// startStroke puts in P.sp. +function pathAt(P, u, o) { + const s = P.sp; + if (P.type === 0) { + // An S down (or up) the panel. + o[0] = s[0] + s[1] * sin(2 * PI * s[2] * u + s[3]); + o[1] = s[4] + (s[5] - s[4]) * u; + } else if (P.type === 1) { + // A spiral in to a point: the swirl on top of a jar. + const a = s[3] + s[4] * u * 2 * PI, r = s[5] + (s[6] - s[5]) * (1 - (1 - u) * (1 - u)); + o[0] = s[0] + r * cos(a); + o[1] = s[1] + r * sin(a) * 1.15; + } else { + // A bowed smear from off one side to the middle, or right across. + const v = 1 - u, mx = (s[0] + s[2]) / 2 + s[4], my = (s[1] + s[3]) / 2 + s[5]; + o[0] = v * v * s[0] + 2 * v * u * mx + u * u * s[2]; + o[1] = v * v * s[1] + 2 * v * u * my + u * u * s[3]; + } +} + +const TMP = [0, 0]; +function startStroke(P, k3) { + const s = P.sp, r = rnd(), hf = P.hf; + // Heights only matter against each other: keep the cream's mean level at + // 0, and cut every band below it, so each stroke has cream to push. + let mean = 0; + for (let p = 0; p < N; p++) mean += hf[p]; + mean /= N; + for (let p = 0; p < N; p++) hf[p] -= mean; + P.type = r < 0.5 ? 0 : r < 0.8 ? 1 : 2; + if (P.type === 0) { + const down = rnd() < 0.5; + s[0] = 22 + rnd() * 20; + s[1] = 10 + rnd() * 10; + s[2] = 0.6 + rnd() * 0.5; + s[3] = rnd() * 2 * PI; + s[4] = down ? -14 : H + 14; + s[5] = down ? H + 14 : -14; + P.L = 34 + rnd() * 10; + P.B = -0.9 + rnd() * 0.5; + } else if (P.type === 1) { + s[0] = 26 + rnd() * 12; + s[1] = 36 + rnd() * 56; + s[3] = rnd() * 2 * PI; + s[4] = (rnd() < 0.5 ? -1 : 1) * (1.3 + rnd() * 0.5); + s[5] = 25 + rnd() * 6; + s[6] = 3; + P.L = 18 + rnd() * 5; + P.B = -0.6 + rnd() * 0.4; + } else { + const across = rnd() < 0.4, side = rnd() < 0.5 ? -1 : 1; + if (across) { + s[0] = side < 0 ? -14 : W + 14; + s[2] = side < 0 ? W + 14 : -14; + s[1] = 14 + rnd() * 100; + s[3] = cl(s[1] + (rnd() - 0.5) * 40, 10, H - 10); + } else { + s[0] = 32 + side * (34 + rnd() * 6); + s[1] = 10 + rnd() * 108; + s[2] = 32 - side * (rnd() * 10); + s[3] = cl(s[1] + (rnd() - 0.5) * 70, 18, H - 18); + } + s[4] = 0; + s[5] = (rnd() - 0.5) * 36; + P.L = 28 + rnd() * 8; + P.B = -0.8 + rnd() * 0.5; + } + // Lay the path down half an LED a point. + const path = P.path; + pathAt(P, 0, TMP); + let px = TMP[0], py = TMP[1], n = 1; + path[0] = px; + path[1] = py; + for (let i = 1; i <= 4000 && n < PMAX; i++) { + pathAt(P, i / 4000, TMP); + const d = sqrt((TMP[0] - px) ** 2 + (TMP[1] - py) ** 2); + if (d >= STEP) { + px += ((TMP[0] - px) * STEP) / d; + py += ((TMP[1] - py) * STEP) / d; + path[n * 2] = px; + path[n * 2 + 1] = py; + n++; + } + } + P.plen = (n - 1) * STEP; + P.pos = 0; + // The blade sits at an angle to its track, tilted a little and bowed, so + // the band is a shallow trough, and it chatters as it goes, leaving soft + // folds across the band. A slow knife glides and cuts shallow; a fast one + // chatters harder and digs deeper, so it throws a taller ridge. + const al = (rnd() - 0.5) * 0.6; + P.ca = cos(al); + P.sa = sin(al); + P.tilt = (rnd() - 0.5) * 0.6; + P.cc = 0.2 + rnd() * 0.4; + P.cA = (0.1 + rnd() * 0.08) * (0.35 + 0.65 * P.force); + P.B -= 0.35 * (P.force - 1); + P.cP = 11 + rnd() * 5; + P.cB = (rnd() < 0.5 ? -1 : 1) * (3 + rnd() * 4); + P.gPh = rnd() * 2 * PI; + P.slip = rnd() < 0.5 ? -1 : 1; + if (P.type === 1) { + // A swirl throws its cream outwards. + locate(P, 6); + P.slip = P.tx * (P.kx - s[0]) + P.ty * (P.ky - s[1]) > 0 ? 1 : -1; + } + P.m.fill(0); + P.c.fill(0); + P.rh = 0; + P.live = true; + // Every stroke goes through fresh dollops dropped a little way along: one + // for one tint, two or three when three tints are folded in. + const nd = k3 === 1 ? 1 : k3 === 2 ? (rnd() < 0.5 ? 2 : 1) : rnd() < 0.4 ? 3 : 2; + for (let i = 0; i < nd; i++) plan(P, 0.1 + rnd() * 0.45, rnd() < 0.4 ? 0 : DOSE[k3 - 1][floor(rnd() * k3)], 10 + rnd() * 3); +} + +// A dollop waiting to fall at a point on the path ahead of the knife. +function plan(P, u, c, r) { + if (P.dol.length > 3) return; + const i = floor((P.pos + (P.plen - P.pos) * u) / STEP); + const x = P.path[i * 2], y = P.path[i * 2 + 1]; + P.dol.push({ x: cl(x + (rnd() - 0.5) * 6, 6, W - 7), y: cl(y + (rnd() - 0.5) * 6, 8, H - 9), r, h: 2.6 + rnd() * 0.6, c, f: 0 }); +} + +// The knife's point, its direction of travel and the blade's direction. +function locate(P, pos) { + const path = P.path, last = floor(P.plen / STEP); + const i = cl(floor(pos / STEP), 0, last - 1), f = cl(pos / STEP - i, 0, 1); + P.kx = path[i * 2] + (path[i * 2 + 2] - path[i * 2]) * f; + P.ky = path[i * 2 + 1] + (path[i * 2 + 3] - path[i * 2 + 1]) * f; + const a = max(0, i - 3), b = min(last, i + 4); + let dx = path[b * 2] - path[a * 2], dy = path[b * 2 + 1] - path[a * 2 + 1]; + const d = sqrt(dx * dx + dy * dy) || 1; + dx /= d; + dy /= d; + P.dx = dx; + P.dy = dy; + P.tx = -dy * P.ca - dx * P.sa; + P.ty = dx * P.ca - dy * P.sa; +} + +// The load and tint of the blade at a point a along it, read between the +// bins so what it lays down has no seams. +function load(P, a) { + const fb = cl(((a + P.L / 2) / P.L) * NB - 0.5, 0, NB - 1), j = min(NB - 2, floor(fb)), f = fb - j; + P.bm = P.m[j] + (P.m[j + 1] - P.m[j]) * f; + P.bc = P.c[j] + (P.c[j + 1] - P.c[j]) * f; +} + +// The ridge across its width r (0 at the band's edge, 1 at its foot): a +// steep inner face where the blade cut it, a long outer slope. +const ridge = (r) => (r < 0.3 ? sm(r / 0.3) : 1 - sm((r - 0.3) / 0.7)); + +// One move of the knife by ds LEDs. Each LED is worked once a pass, as the +// back of the blade leaves it: what stood above the blade goes into the +// bin over it, and the band, the ridge beside it and their tints are laid +// down. +function knife(P, ds) { + P.pos += ds; + P.sub++; + if (P.pos >= P.plen) return lift(P); + locate(P, P.pos); + const { hf, tn, ut, m, c, stamp, L } = P, hl = L / 2, sl = P.slip; + const kx = P.kx, ky = P.ky, dx = P.dx, dy = P.dy, tx = P.tx, ty = P.ty, det = tx * dy - ty * dx; + // Pressure comes on over the first 8 LEDs and goes off over the last 6. + const pr = sm(P.pos / 8) * sm((P.plen - P.pos) / 6); + const rw = max(3, (2 * P.rh) / RS), end = sl > 0 ? NB - 1 : 0, lvEnd = P.B + P.tilt * sl + P.cc; + const rx = abs(tx) * (hl + rw) + 3, ry = abs(ty) * (hl + rw) + 3; + const x1 = max(0, floor(kx - rx)), x2 = min(W - 1, floor(kx + rx) + 1); + const y1 = max(0, floor(ky - ry)), y2 = min(H - 1, floor(ky + ry) + 1); + for (let y = y1; y <= y2; y++) + for (let x = x1; x <= x2; x++) { + const ox = x - kx, oy = y - ky, s = (tx * oy - ty * ox) / det; + // On a curve the LEDs on the outside move further than ds, so the + // strip is wider than a step and the stamp keeps it to once a pass. + if (s < -3 - ds || s >= -0.3) continue; + const a = (ox * dy - oy * dx) / det, as = a * sl, p = y * W + x; + if (as <= -hl - 1.5 || as >= hl + rw || P.sub - stamp[p] < 40) continue; + stamp[p] = P.sub; + const ho = hf[p], to = tn[p]; + if (as < hl && as > -hl) { + // The chatter marks bow back towards the blade's ends, as a flexible + // blade leaves them, and come and go along it. + const u = a / hl, w = (2 * PI * (P.pos + P.cB * u * u)) / P.cP; + const lv = P.B + P.tilt * u + P.cc * u * u + P.cA * (0.65 + 0.35 * sin(a * 0.31 + P.gPh)) * (sin(w) + 0.5 * sin(w * 1.63 + 1.3)), e = ho - lv; + const j = cl(floor(((a + hl) / L) * NB), 0, NB - 1); + if (e > 0) { + m[j] += e * pr; + c[j] += (to - c[j]) * min(1, e * PICK * pr); + } + // Under what was scraped off lies the bed; over it the blade leaves a + // film of the cream it carries. + const ex = e > 0 ? to + (ut[p] - to) * min(1, e / SKIN) : to; + load(P, a); + const tw = ex + (P.bc - ex) * FILM * min(1, P.bm); + hf[p] = ho + (lv - ho) * pr; + P.fr[p] = max(P.fr[p], pr); + tn[p] = to + (tw - to) * pr; + ut[p] += (tw - ut[p]) * pr * 0.4; + } else { + // Beside the band: the ridge on the spill side, a thin lip of + // squeezed cream on the other. + const hr = as > 0 ? lvEnd + P.rh * ridge((as - hl) / rw) : P.B - P.tilt * sl + 0.2 * P.rh * ridge((-as - hl) / 1.5); + if (hr <= ho) continue; + const k = pr * min(1, (hr - ho) / SKIN), ce = as > 0 ? c[end] : to; + hf[p] = ho + (hr - ho) * pr; + P.fr[p] = max(P.fr[p], pr); + tn[p] = to + (ce - to) * k; + ut[p] += (ce - ut[p]) * k; + } + } + // The load slides along the angled blade and spills off its end, and the + // ridge stands as tall as the cream spilling into it. + const f = min(0.9, SLIP * ds); + for (let k = 1; k < NB; k++) { + const j = sl > 0 ? NB - 1 - k : k, to = j + sl, a = m[j] * f; + if (a <= 0) continue; + c[to] = (c[to] * m[to] + c[j] * a) / (m[to] + a); + m[to] += a; + m[j] -= a; + } + const spill = m[end] * f; + m[end] -= spill; + const target = min(RMAX, sqrt(0.75 * (spill / ds) * RS)); + P.rh += (target - P.rh) * min(1, ds * 0.25); +} + +// The knife lifts: what it still carries stays where it stopped, a raised +// lip across the end of the band. +function lift(P) { + locate(P, P.plen); + roll(P, P.hf, P.tn, P.ut); + P.live = false; + P.done++; + P.pause = 0.5 + rnd() * 0.4; +} + +// The cream on the blade's face as a rounded roll in front of it, laid into +// (h, t): for the picture while the knife moves, for good when it lifts. +function roll(P, h, t, u) { + const hl = P.L / 2, kx = P.kx, ky = P.ky, dx = P.dx, dy = P.dy, tx = P.tx, ty = P.ty, det = tx * dy - ty * dx; + const rx = abs(tx) * hl + ROLL + 2, ry = abs(ty) * hl + ROLL + 2; + const x1 = max(0, floor(kx - rx)), x2 = min(W - 1, floor(kx + rx) + 1); + const y1 = max(0, floor(ky - ry)), y2 = min(H - 1, floor(ky + ry) + 1); + for (let y = y1; y <= y2; y++) + for (let x = x1; x <= x2; x++) { + const ox = x - kx, oy = y - ky, a = (ox * dy - oy * dx) / det, s = (tx * oy - ty * ox) / det; + if (s <= -0.3 || s >= ROLL || a <= -hl || a >= hl) continue; + load(P, a); + // A bin's load spread as a mound ROLL deep, rounded off at the blade's + // ends: as tall as that much cream stands, and never a wall. + const ht = min(2.4, sqrt((P.bm * NB) / P.L) * 0.7) * sin((PI * (s + 0.3)) / (ROLL + 0.3)) * sqrt(sin((PI * (a + hl)) / P.L)); + const p = y * W + x; + if (P.B + ht <= h[p]) continue; + const k = min(1, (P.B + ht - h[p]) / SKIN); + h[p] = P.B + ht; + t[p] += (P.bc - t[p]) * k; + if (u) u[p] += (P.bc - u[p]) * k; + } +} + +// One frame of the studio: the pause between strokes, dollops falling, the knife. +function tick(P, dt, speed, k3) { + for (const d of P.dol) { + if (d.f >= 1) continue; + const g = min(1 - d.f, dt * 2.5 * max(0.6, speed / 2)); + dollop(P, d.x, d.y, d.r, d.h, d.c, g); + d.f += g; + } + // Freshly worked cream glistens; left alone it dulls to a satin over + // about ten seconds at Speed 2. + const fr = P.fr, k = 1 - dt * 0.07 * max(0.5, speed); + for (let p = 0; p < N; p++) fr[p] *= k; + if (!P.live) { + P.pause -= dt * max(0.5, speed / 2); + if (P.pause <= 0) startStroke(P, k3); + } else { + let ds = dt * speed * 10; + while (ds > 1e-6) { + const st = min(ds, 0.5); + knife(P, st); + ds -= st; + if (!P.live) break; + } + } + // A dollop is forgotten once it has fallen and the knife has gone on. + if (P.dol.length && P.dol[0].f >= 1 && (!P.live || P.dol.length > 2)) P.dol.shift(); +} + +// The first frame already shows cream that has been worked: a level layer +// and three strokes through dollops of tint. +function prime(P, k3) { + const { hf } = P; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) hf[y * W + x] = 0.3 * sin(x * 0.11 + y * 0.05) * sin(y * 0.07 - x * 0.04); + P.pause = 0; + while (P.done < 3) tick(P, 1 / 30, 2, k3); + while (!P.live) tick(P, 1 / 30, 2, k3); + // Start the live stroke a fifth of the way along, its dollops down. + for (const d of P.dol) if (d.f < 1) (dollop(P, d.x, d.y, d.r, d.h, d.c, 1 - d.f), (d.f = 1)); + while (P.live && P.pos < P.plen * 0.2) tick(P, 1 / 30, 2, k3); + P.ready = true; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 1, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.tones = cl(k[2], 1, 3); + P.gloss = cl(k[3], 0, 1); + const k3 = cl(Math.round(P.tones), 1, 3); + P.force = cl(k[1], 0.2, 3) / 2; + if (!P.ready) prime(P, k3); + if (input.btnPressed && input.btnPressed[0]) { + // A fresh dollop in the deepest tint, where the knife will reach it. + if (P.live && P.plen - P.pos > 40) plan(P, 0.25 + rnd() * 0.2, DOSE[k3 - 1][k3 - 1], 9); + else P.dol.push({ x: 14 + rnd() * 36, y: 20 + rnd() * 88, r: 12, h: 3.2, c: DOSE[k3 - 1][k3 - 1], f: 0 }); + } + tick(P, d, cl(k[1], 0.2, 3), k3); +} + +export function draw(display, params) { + const P = params.s; + if (!P || !P.ready) return; + const { dh, dt, lut, lus } = P; + dh.set(P.hf); + dt.set(P.tn); + // The cream riding on the blade's face shows where the unseen knife is. + if (P.live) roll(P, dh, dt, null); + // The light reads the surface and its colour through a small blur, so the + // LED steps of the height field do not show as grain. + blur(dh, P.bl); + // A wide blur of the surface: cream that stands above it is a crest and + // catches more light, cream below it lies in a fold and gets less. + const av = P.av; + av.set(dh); + for (let i = 0; i < 4; i++) blur(av, P.bl); + blur(dt, P.bl); + // The colour of each tint concentration: white cream at 0, the deepest + // tint at 1, stepped into the bands Tones asks for. Light goes a little + // way into cream, so its shadows are a deeper tint rather than grey. + // Wet cream (Gloss up) is a little deeper in colour, as anything wet is. + const t = P.tones, n0 = floor(t) - 1, ft = t - floor(t), n1 = min(2, n0 + 1), hue = H0 + P.hue, g = P.gloss; + for (let i = 0; i < 64; i++) { + const c = i / 63, v = band(c, n0) + (band(c, n1) - band(c, n0)) * ft; + hsv(hue, 0.22 + 0.72 * v + 0.06 * g, (1 - 0.1 * v) * (1 - 0.1 * g), lut, i * 3); + hsv(hue, min(1, 0.4 + 0.6 * v + 0.1 * g), 0.58 - 0.08 * v - 0.14 * g, lus, i * 3); + } + // The sheen is measured from what a level surface would show, so level + // cream stays its colour and only the slopes turned to the light shine. + const shin = 6 + 70 * g * g, ks = (0.4 + 0.9 * g) * 255, s0 = pow(HZ, shin), sk = ks / (1 - s0), fr = P.fr; + for (let y = 0; y < H; y++) { + const yu = y > 0 ? -W : 0, yd = y < H - 1 ? W : 0; + for (let x = 0; x < W; x++) { + const p = y * W + x; + const gx = (dh[x < W - 1 ? p + 1 : p] - dh[x > 0 ? p - 1 : p]) * 0.5 * KZ; + const gy = (dh[p + yd] - dh[p + yu]) * 0.5 * KZ; + const il = 1 / sqrt(gx * gx + gy * gy + 1), nx = -gx * il, ny = -gy * il, nz = il; + const df = nx * LX + ny * LY + nz * LZ, nh = nx * HX + ny * HY + nz * HZ; + const f = (df > -WRAP ? (0.85 * (df + WRAP)) / (LZ + WRAP) : 0) * (1 + cl((dh[p] - av[p]) * CAV, -0.45, 0.25)), sp = (nh > HZ ? (pow(nh, shin) - s0) * sk * (0.5 + 0.5 * fr[p]) : 0) + fr[p] * GLINT; + const o = cl(floor(dt[p] * 63 + 0.5), 0, 63) * 3, r = lus[o], gg = lus[o + 1], b = lus[o + 2]; + display.setPixel(x, y, min(255, r + (lut[o] - r) * f + sp), min(255, gg + (lut[o + 1] - gg) * f + sp * 0.97), min(255, b + (lut[o + 2] - b) * f + sp * 0.92)); + } + } +} diff --git a/incoming/pattern-study/patterns/serum-gel.js b/incoming/pattern-study/patterns/serum-gel.js new file mode 100644 index 000000000..89a9b86e2 --- /dev/null +++ b/incoming/pattern-study/patterns/serum-gel.js @@ -0,0 +1,347 @@ +// ===== Patternflow pattern ===== +// Title: Serum Gel +// Family: cosmetic +// Idea: A clear tinted gel fills the panel with pearls and air bubbles hanging in it; light through its jiggling surface draws a slow net of caustics on the back, the beads catch a highlight and a rim, and a tap sets the whole gel wobbling until the beads swing and settle. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 29, after the macro shots of serum and gel cream with suspended beads in skincare films +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Beads=8..40, Wobble=0..1 +// +// Knob 1: Hue. Turns the gel, its light and the pearls' sheen round the whole colour wheel together, opening on lilac. +// Knob 2: Speed. How fast the gel moves: the caustics crawl and the beads sway at 2; at 0.2 everything hangs nearly still. +// Knob 3: Beads. How many beads hang in the gel, from eight big ones to forty small ones, with two air bubbles for every three; the fewer there are, the bigger each one. +// Knob 4: Wobble. How much the gel jiggles on its own: at 0 it only breathes, turned up its surface ripples, the caustics sharpen into a busy net and the beads keep swinging. +// Buttons: K1 tap knocks the jar: the gel wobbles hard, the caustics swing with it, and the beads swing and settle. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, sin, cos, exp, pow, abs, PI } = Math; +const rnd = () => Math.random(); +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 opening hue: lilac, not red. +const H0 = 0.78; +// Light rays are cast from a grid SS times finer than the LEDs, over a +// margin M beyond the panel so the edges get light from outside too. How +// far each ray is bent is worked out on a coarser grid, GS LEDs a step, +// since the ripples are much longer than that, and read between its points. +const SS = 1.5, M = 4, GS = 2, GW = (W + 2 * M) / GS + 2, GH = (H + 2 * M) / GS + 2; +const RW = (W + 2 * M) * SS, RH = (H + 2 * M) * SS; +// The surface ripples: six waves 18 to 40 LEDs long. +const NWV = 6; +// Specks of air, always there whatever the Beads knob says. +const SPECKS = 16; + +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; +} + +// The gel's low modes of wobble, as displacement fields: side to side with +// the top swinging most, a bounce, a twist, and a squeeze. Each is a damped +// spring with its angular frequency at Speed 2. +const MODES = 4; +const OMEGA = [3.1, 4.3, 2.6, 5.4]; +// They are kept on the coarse grid, which runs M LEDs past the panel on +// every side; the gel past the panel's edges does not move. +const GN = GW * GH, MX = new Float32Array(MODES * GN), MY = new Float32Array(MODES * GN); +for (let j = 0; j < GH; j++) + for (let i = 0; i < GW; i++) { + const x = cl(i * GS - M, 0, W), y = cl(j * GS - M, 0, H), p = j * GW + i; + const sx = sin((PI * x) / W), sy = sin((PI * y) / H), top = cos((PI * y) / (2 * H)); + MX[p] = sx * top; + MY[GN + p] = sx * sy; + MX[2 * GN + p] = (-(y - H / 2) / 40) * sx * sy; + MY[2 * GN + p] = ((x - W / 2) / 40) * sx * sy; + MX[3 * GN + p] = sin((2 * PI * x) / W) * sy; + } +// A cosine table, 1024 steps a turn. +const CT = new Float32Array(1024).map((_, i) => cos((i * 2 * PI) / 1024)); +const fcos = (a) => CT[(a * 162.97466) & 1023]; + +export function setup(params) { + params.s = { + t: 0, wv: new Float32Array(NWV * 8), ph: new Float32Array(NWV), md: new Float32Array(MODES * 2), drive: new Float32Array(MODES), dead: 0, + gx: new Float32Array(GN), gy: new Float32Array(GN), acc: new Float32Array(N), tmp: new Float32Array(N), + beads: [], want: 0, hue: 0, wob: 0.06, sp: 2, ready: false, + bg: new Float32Array(N * 3), col: new Float32Array(8 * 3), + }; + const P = params.s; + // Each ripple: its wave vector, how fast it creeps (0.4 to 1.2 LEDs a + // second at Speed 2), its phase, the direction it bends light, and how + // fast it jiggles to and fro in place, as a gel's surface does. + for (let i = 0; i < NWV; i++) { + const a = (i / NWV) * PI + rnd() * 0.5, L = 18 + rnd() * 22, o = i * 8; + P.wv[o] = (cos(a) * 2 * PI) / L; + P.wv[o + 1] = (sin(a) * 2 * PI) / L; + P.wv[o + 2] = ((0.4 + rnd() * 0.8) * 2 * PI) / L; + P.wv[o + 3] = rnd() * 2 * PI; + P.wv[o + 4] = cos(a); + P.wv[o + 5] = sin(a); + P.wv[o + 6] = 1.6 + rnd() * 1.6; + P.wv[o + 7] = rnd() * 2 * PI; + } +} + +// A bead hanging at rest point (hx, hy), depth z from 0 (near) to 1 (far), +// and where it is now. Kind 0 is a pearl, 3 a tinted capsule, 1 an air +// bubble, 2 one of the specks of air that every gel holds. The fewer beads, +// the bigger they are. Each new one tries a few places in its own band of +// the panel's height and keeps the one furthest from the others, so they +// spread out. +function bead(P, n, kind, all, band, bands) { + const big = 10 * sqrt(8 / n), z = rnd(), f = 1 - 0.45 * z; + const r = kind === 2 ? 0.7 + rnd() * 0.8 : kind === 1 ? big * (0.3 + 0.3 * rnd()) * f : big * (0.65 + 0.35 * rnd()) * f; + let bx = 0, by = 0, best = -1; + for (let tries = 0; tries < 12; tries++) { + const x = r + 1 + rnd() * (W - 2 * r - 2), y = cl(((band + rnd()) / bands) * H, r + 2, H - r - 3); + let near = 1e9; + for (const b of all) if (!b.gone && b.kind !== 2) near = min(near, sqrt((b.hx - x) ** 2 + (b.hy - y) ** 2) - b.r - r); + if (near > best) (best = near), (bx = x), (by = y); + } + if (kind === 0 && rnd() < 0.4) kind = 3; + return { hx: bx, hy: by, x: bx, y: by, vx: 0, vy: 0, r, z, kind, ph: rnd() * 2 * PI, fq: 0.5 + rnd() * 0.5, a: 0, gone: false }; +} + +// Keep as many beads, pearls and capsules, as the knob asks for, and two +// bubbles for every three beads: new ones fade in, spare ones fade out, and +// the far ones are drawn first. +function census(P, n) { + if (!P.beads.length) for (let i = 0; i < SPECKS; i++) P.beads.push(bead(P, 8, 2, P.beads, i, SPECKS)); + for (let kind = 0; kind < 2; kind++) { + const want = kind ? Math.round((n * 2) / 3) : n, live = P.beads.filter((b) => !b.gone && (b.kind === kind || b.kind === kind + 3)); + if (live.length < want) for (let i = live.length; i < want; i++) P.beads.push(bead(P, n, kind, P.beads, (i * 7) % want, want)); + else for (let i = want; i < live.length; i++) live[i].gone = true; + } + P.beads.sort((a, b) => b.z - a.z); +} + +function blur(a, b) { + for (let y = 0; y < H; y++) + for (let x = 0, p = y * W; x < W; x++, p++) b[p] = (a[x > 0 ? p - 1 : p] + 2 * a[p] + a[x < W - 1 ? p + 1 : p]) * 0.25; + for (let y = 0; y < H; y++) + for (let x = 0, p = y * W; x < W; x++, p++) a[p] = (b[y > 0 ? p - W : p] + 2 * b[p] + b[y < H - 1 ? p + W : p]) * 0.25; +} + +function step(P, dt, sp, wob) { + P.t += dt * sp * 0.5; + // The modes ring like springs; a slow random push keeps them moving by as + // much as Wobble asks. + const md = P.md, dr = P.drive, k = sp * 0.5; + for (let m = 0; m < MODES; m++) { + const w = OMEGA[m] * k, ze = 0.07; + dr[m] += (rnd() - 0.5) * dt * 6; + dr[m] *= 1 - dt * 0.8; + const f = dr[m] * (0.6 + 9 * wob) * w * w * 0.25; + let x = md[m * 2], v = md[m * 2 + 1]; + v += (-w * w * x - 2 * ze * w * v + f) * dt; + x += v * dt; + md[m * 2] = x; + md[m * 2 + 1] = v; + } + // Beads hang from the gel on springs of their own, softer than the gel, so + // they lag behind its swing and then catch up; each drifts a little round + // its rest point on its own. + for (const b of P.beads) { + b.a = b.gone ? max(0, b.a - dt * 1.5) : min(1, b.a + dt * 1.5); + if (b.gone && b.a <= 0) P.dead = 1; + const p = Math.round((b.hy + M) / GS) * GW + Math.round((b.hx + M) / GS); + let ux = 0, uy = 0; + for (let m = 0; m < MODES; m++) { + ux += md[m * 2] * MX[m * GN + p]; + uy += md[m * 2] * MY[m * GN + p]; + } + const par = 1.15 - 0.4 * b.z, dfx = 1.4 * sin(P.t * b.fq + b.ph), dfy = 1.8 * sin(P.t * b.fq * 0.71 + b.ph * 1.3); + const tx = b.hx + ux * par + dfx, ty = b.hy + uy * par + dfy, w = 2.4 * k * (1.1 - 0.3 * b.z); + b.vx += ((tx - b.x) * w * w - 2 * 0.12 * w * b.vx) * dt; + b.vy += ((ty - b.y) * w * w - 2 * 0.12 * w * b.vy) * dt; + b.x += b.vx * dt; + b.y += b.vy * dt; + } + if (P.dead) (P.beads = P.beads.filter((b) => !b.gone || b.a > 0)), (P.dead = 0); +} + +// Light comes down through the gel's rippled surface and lands on the back +// wall. Each ray is bent by the surface's slope where it entered, by up to a +// few LEDs, and by the gel's wobble; where the rays crowd together the wall +// is bright, the caustic net. +function caustics(P, wob) { + const { gx, gy, acc, wv, md } = P; + // A jiggling gel ripples more: the bend grows with Wobble and with how + // hard the modes are swinging. + let e = 0; + for (let m = 0; m < MODES; m++) e += abs(md[m * 2]); + const amp = (1.1 + 0.8 * wob + 0.15 * min(6, e)) / sqrt(NWV / 3), t = P.t * 2, ph = P.ph, jig = 0.5 + 0.8 * wob + 0.12 * min(6, e); + for (let w = 0; w < NWV; w++) ph[w] = wv[w * 8 + 3] - wv[w * 8 + 2] * t + jig * sin(wv[w * 8 + 6] * t + wv[w * 8 + 7]); + const m0 = md[0] * 0.8, m1 = md[2] * 0.8, m2 = md[4] * 0.8, m3 = md[6] * 0.8; + for (let j = 0, q = 0; j < GH; j++) + for (let i = 0; i < GW; i++, q++) { + const x = i * GS - M, y = j * GS - M; + let sx = 0, sy = 0; + for (let w = 0; w < NWV; w++) { + const c = fcos(wv[w * 8] * x + wv[w * 8 + 1] * y + ph[w]); + sx += wv[w * 8 + 4] * c; + sy += wv[w * 8 + 5] * c; + } + // The back wall moves with the gel, so the whole net swings with it. + gx[q] = sx * amp + m0 * MX[q] + m2 * MX[2 * GN + q] + m3 * MX[3 * GN + q]; + gy[q] = sy * amp + m1 * MY[GN + q] + m2 * MY[2 * GN + q]; + } + acc.fill(0); + const wgt = 1 / (SS * SS), inv = 1 / SS, gi = 1 / GS; + for (let rj = 0; rj < RH; rj++) { + const fy = rj * inv, uy = fy * gi, j0 = floor(uy), ty = uy - j0; + for (let ri = 0; ri < RW; ri++) { + const fx = ri * inv, ux = fx * gi, i0 = floor(ux), tx = ux - i0, q = j0 * GW + i0; + const X = fx - M + (gx[q] * (1 - tx) + gx[q + 1] * tx) * (1 - ty) + (gx[q + GW] * (1 - tx) + gx[q + GW + 1] * tx) * ty; + const Y = fy - M + (gy[q] * (1 - tx) + gy[q + 1] * tx) * (1 - ty) + (gy[q + GW] * (1 - tx) + gy[q + GW + 1] * tx) * ty; + const ix = floor(X), iy = floor(Y); + if (ix < -1 || ix >= W || iy < -1 || iy >= H) continue; + const ax = X - ix, ay = Y - iy; + if (iy >= 0) { + if (ix >= 0) acc[iy * W + ix] += wgt * (1 - ax) * (1 - ay); + if (ix < W - 1) acc[iy * W + ix + 1] += wgt * ax * (1 - ay); + } + if (iy < H - 1) { + if (ix >= 0) acc[(iy + 1) * W + ix] += wgt * (1 - ax) * ay; + if (ix < W - 1) acc[(iy + 1) * W + ix + 1] += wgt * ax * ay; + } + } + } + // Gel is not quite clear: the light softens on its way to the wall, and + // each line of it gets a halo on the LEDs beside it. + blur(acc, P.tmp); + blur(acc, P.tmp); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 8, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.sp = cl(k[1], 0.2, 3); + P.wob = cl(k[3], 0, 1); + const n = cl(Math.round(k[2]), 8, 40); + if (n !== P.want) (P.want = n), census(P, n); + if (!P.ready) { + // The first frame already shows the gel settled and swaying. + for (const b of P.beads) b.a = 1; + for (let i = 0; i < 90; i++) step(P, 1 / 30, P.sp, P.wob); + P.ready = true; + } + if (input.btnPressed && input.btnPressed[0]) { + // A knock: the sway and the bounce get a hard kick, the others a little. + const s = rnd() < 0.5 ? -1 : 1, w = P.sp * 0.5; + P.md[1] += s * 10 * OMEGA[0] * w; + P.md[3] += (rnd() - 0.5) * 8 * OMEGA[1] * w; + P.md[5] += (rnd() - 0.5) * 4 * OMEGA[2] * w; + P.md[7] += (rnd() - 0.5) * 3 * OMEGA[3] * w; + } + let t = d; + while (t > 1e-6) { + const h = min(t, 1 / 60); + step(P, h, P.sp, P.wob); + t -= h; + } +} + +export function draw(display, params) { + const P = params.s; + if (!P || !P.ready) return; + caustics(P, P.wob); + const { acc, bg, col } = P, hue = H0 + P.hue; + // Gel light and deep, caustic light, pearl one side, pearl rim, pearl + // other side, capsule body and its gathered light. Orange and yellow gel + // darkened reads as brown and olive on LEDs, so around those hues the gel + // is kept lighter, like honey. + const hw = (hue - floor(hue) + 0.38) % 1, am = max(0, 1 - abs(hw - 0.5) / 0.11); + hsv(hue, 0.72 - 0.12 * am, 0.62 + 0.3 * am, col, 0); + hsv(hue + 0.02, 0.85 - 0.1 * am, 0.3 + 0.38 * am, col, 3); + hsv(hue - 0.03, 0.35, 1, col, 6); + hsv(hue + 0.12, 0.2, 1, col, 9); + hsv(hue - 0.14, 0.6, 1, col, 12); + hsv(hue - 0.1, 0.2, 1, col, 15); + hsv(hue + 0.05, 0.95, 0.5, col, 18); + hsv(hue + 0.02, 0.5, 1, col, 21); + // The gel: light from above, so the top glows and the bottom is deeper, + // and over it the net of caustic light. + for (let y = 0; y < H; y++) { + const g = 1 - (0.55 * y) / H; + for (let x = 0; x < W; x++) { + const p = y * W + x, l = cl(acc[p] - 0.6, 0, 1.6) * 0.5; + for (let q = 0; q < 3; q++) bg[p * 3 + q] = col[q] * g + col[3 + q] * (1 - g) + col[6 + q] * l * (0.55 + 0.45 * g); + } + } + // Beads and bubbles, far ones first. + for (const b of P.beads) drawBead(P, b); + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p++) display.setPixel(x, y, min(255, bg[p * 3]), min(255, bg[p * 3 + 1]), min(255, bg[p * 3 + 2])); +} + +// Pearls are lit from above through the gel, their white leaning one way +// round the colour wheel on one side and the other way on the other, as a +// pearl's sheen shifts, with a rim and a white highlight. A capsule is clear and deeply tinted: light coming down through +// it gathers into a bright spot low inside it. An air bubble is clear, the +// gel behind it brighter, with a thin bright rim and a highlight; a speck is +// a point of light. Far ones sink into the gel's colour. +function drawBead(P, b) { + // A bead swinging through the gel is drawn out a little along its path + // and squeezed across it, so it jiggles as it swings and settles round. + const ax = abs(b.vx), ay = abs(b.vy), qx = cl(1 + 0.02 * ax - 0.01 * ay, 0.82, 1.18), qy = cl(1 + 0.02 * ay - 0.01 * ax, 0.82, 1.18); + const { bg, col, acc } = P, r = b.r, x1 = max(0, floor(b.x - r * qx - 1)), x2 = min(W - 1, floor(b.x + r * qx + 1)); + const y1 = max(0, floor(b.y - r * qy - 1)), y2 = min(H - 1, floor(b.y + r * qy + 1)); + const fog = 0.45 * b.z, cp = cl(floor(b.y), 0, H - 1) * W + cl(floor(b.x), 0, W - 1), glint = 0.85 + 0.35 * cl(acc[cp] - 0.6, 0, 1.5); + for (let y = y1; y <= y2; y++) + for (let x = x1; x <= x2; x++) { + const dx = (x + 0.5 - b.x) / (r * qx), dy = (y + 0.5 - b.y) / (r * qy), d2 = dx * dx + dy * dy, d = sqrt(d2); + const cov = cl((1 - d) * r + 0.5, 0, 1) * b.a; + if (cov <= 0) continue; + const nz = sqrt(max(0, 1 - d2)), p = (y * W + x) * 3, rim = pow(1 - nz, 2.2); + let R, G, B; + if (b.kind === 2) { + const k = 0.9 * (1 - d); + R = bg[p] + col[6] * k; + G = bg[p + 1] + col[7] * k; + B = bg[p + 2] + col[8] * k; + } else if (b.kind === 1) { + // Air in gel: clear in the middle, the light bent into a bright rim, + // and a highlight up towards the light. + const ring = sm((d - 0.6) / 0.25) * (1 - sm((d - 0.95) / 0.1)), hi = exp(-((dx + 0.35) ** 2 + (dy + 0.4) ** 2) * 22); + const k = 0.15 + 0.85 * ring; + R = bg[p] * 1.1 + col[6] * k * 0.85 + 255 * hi; + G = bg[p + 1] * 1.1 + col[7] * k * 0.85 + 255 * hi; + B = bg[p + 2] * 1.1 + col[8] * k * 0.85 + 255 * hi; + } else { + const hi = pow(max(0, -dx * 0.36 - dy * 0.5 + nz * 0.79), 40) * 255, edge = 1 - 0.3 * sm((d - 0.86) / 0.12); + if (b.kind === 0) { + const df = max(0, -dx * 0.3 - dy * 0.75 + nz * 0.6), lit = (0.72 + 0.4 * df) * glint * edge, u = cl(0.5 + 0.6 * (dx - dy) * 0.7, 0, 1); + R = (col[9] + (col[15] - col[9]) * u) * lit + col[12] * rim * 0.75 + hi; + G = (col[10] + (col[16] - col[10]) * u) * lit + col[13] * rim * 0.75 + hi; + B = (col[11] + (col[17] - col[11]) * u) * lit + col[14] * rim * 0.75 + hi; + } else { + const core = exp(-(dx * dx + (dy - 0.42) ** 2) * 5) * glint, body = 0.55 * edge; + R = col[18] * body + col[21] * core + col[21] * rim * 0.5 + hi; + G = col[19] * body + col[22] * core + col[22] * rim * 0.5 + hi; + B = col[20] * body + col[23] * core + col[23] * rim * 0.5 + hi; + } + } + R += (bg[p] - R) * fog; + G += (bg[p + 1] - G) * fog; + B += (bg[p + 2] - B) * fog; + bg[p] += (R - bg[p]) * cov; + bg[p + 1] += (G - bg[p + 1]) * cov; + bg[p + 2] += (B - bg[p + 2]) * cov; + } +} From 5a6ee7e2be34f8591fb608f6ab554a9f111fb96e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:41:12 +0000 Subject: [PATCH 07/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20J-Invariant=20=EA=B3=BC=20Zeta=20Loops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/j-invariant.js | 195 ++++++++++++ incoming/pattern-study/patterns/zeta-loops.js | 286 ++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 incoming/pattern-study/patterns/j-invariant.js create mode 100644 incoming/pattern-study/patterns/zeta-loops.js diff --git a/incoming/pattern-study/patterns/j-invariant.js b/incoming/pattern-study/patterns/j-invariant.js new file mode 100644 index 000000000..87984a7de --- /dev/null +++ b/incoming/pattern-study/patterns/j-invariant.js @@ -0,0 +1,195 @@ +// ===== Patternflow pattern ===== +// Title: J-Invariant +// Family: fractal +// Idea: Klein's j function painted over the upper half-plane with the real axis along the foot of the panel: every triangle j maps onto the upper half of its values glows pale gold and every one onto the lower half deep blue, so Klein's tessellation repeats smaller and smaller toward the bottom edge while contour bands of |j| stream out of each zero into the cusps and the window slides along the axis. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the modular j-invariant (Richard Dedekind, 1877; Felix Klein, 1878), drawn by domain colouring, pattern round 29 +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Depth=0..1, Bands=0..1 +// +// Knob 1: Hue. Turns both colour families round the wheel together; at 0 the triangles are pale gold and deep blue. +// Knob 2: Speed. At 2 a new contour band leaves each zero about every two seconds and the window slides four LEDs a second along the axis; at 0.2 both go ten times slower. +// Knob 3: Depth. Zooms toward the real axis: at 0 the window is two thirds of a period wide and shows the arcs of the fundamental domain; at 1 it is eight times closer and fans of thin tiles round the cusps fill the panel. +// Knob 4: Bands. The contour bands of |j|: a few soft ones at 0, each a factor of nine in |j|; at 1 sharp ones a factor of one and a half apart, too fine to draw in the small tiles, which turn flat. +// Buttons: K1 tap sends a wave of light down the tiles generation by generation, from the tile at the top through each further inversion t -> -1/t to the axis. K2 tap lays the edges of the modular tiles on as thin gold geodesics; tap again to take them off. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, exp, log, cos, sin, atan2, sqrt, abs, round, asinh, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => ((v = cl((v - a) / (b - a), 0, 1)), v * v * (3 - 2 * v)); + +// Re units per LED at Depth 0, and how far below the bottom row the real +// axis lies, in LEDs. +const S0 = 1 / 96, GAP = 0.5; + +// One point of the half-plane: it is carried into the fundamental domain +// |Re t| <= 1/2, |t| >= 1 by t -> t + n and t -> -1/t, and j is read off +// there, where |q| = |e^(2 pi i t)| <= e^(-pi sqrt 3) < 0.0044, so five terms +// of each q-series are exact to the last bit that shows. The results land in +// OUT: log|j|, arg j, how fast log j turns per unit of the original t, how +// many times t -> -1/t was taken (the tile's generation, counted down from +// the top) and the point in the fundamental domain. +const OUT = new Float64Array(6); +function jAt(x, y) { + const y0 = y; + let gen = 0; + for (; gen < 60; gen++) { + x -= round(x); + const r2 = x * x + y * y; + if (r2 >= 1) break; + x = -x / r2; + y = y / r2; + } + // q = e^(2 pi i t); E4 and E6 by their divisor sums, and the log of the + // eta product as L = sum sigma(m)/m q^m, so Delta = q e^(-24 L) and + // j = E4^3 / Delta. + const m = exp(-TAU * y), qr = m * cos(TAU * x), qi = m * sin(TAU * x); + const q2r = qr * qr - qi * qi, q2i = 2 * qr * qi; + const q3r = q2r * qr - q2i * qi, q3i = q2r * qi + q2i * qr; + const q4r = q2r * q2r - q2i * q2i, q4i = 2 * q2r * q2i; + const q5r = q4r * qr - q4i * qi, q5i = q4r * qi + q4i * qr; + const e4r = 1 + 240 * (qr + 9 * q2r + 28 * q3r + 73 * q4r + 126 * q5r); + const e4i = 240 * (qi + 9 * q2i + 28 * q3i + 73 * q4i + 126 * q5i); + const e6r = 1 - 504 * (qr + 33 * q2r + 244 * q3r + 1057 * q4r + 3126 * q5r); + const e6i = -504 * (qi + 33 * q2i + 244 * q3i + 1057 * q4i + 3126 * q5i); + const Lr = qr + 1.5 * q2r + (4 / 3) * q3r + 1.75 * q4r + 1.2 * q5r; + const Li = qi + 1.5 * q2i + (4 / 3) * q3i + 1.75 * q4i + 1.2 * q5i; + const a4 = e4r * e4r + e4i * e4i + 1e-30; + OUT[0] = 1.5 * log(a4) + TAU * y + 24 * Lr; + OUT[1] = 3 * atan2(e4i, e4r) - TAU * x + 24 * Li; + // d log j / dt = -2 pi i E6 / E4 (Ramanujan's identities), and the map + // into the domain shrinks lengths by y / y0. + OUT[2] = TAU * sqrt((e6r * e6r + e6i * e6i) / a4) * (y / y0); + OUT[3] = gen; + OUT[4] = x; + OUT[5] = y; +} + +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; +} + +// The phase palette. Where j is real the half-plane is cut into Klein's +// triangles, and j maps each triangle onto one half of the plane of values: +// those onto Im j > 0 take the light family, those onto Im j < 0 the dark +// one, so the two alternate across every edge as in Klein's own figure. +// Across a triangle the phase runs through pi; f runs along a family from the +// edge where j > 0 to the edge where j < 0. 64 steps each, then the mean +// colour the two families blend to where the triangles are too small to draw. +const PN = 64; +function palette(P) { + const t = P.pal; + for (let f = 0; f < PN; f++) { + const u = f / (PN - 1); + hsv(P.hue + 0.13 - 0.11 * u, 0.5 + 0.25 * u, 1, t, f * 3); + // A dark orange or olive reads as brown, so the dark family lifts its + // light where its hue passes through them. + let h = P.hue + 0.58 + 0.16 * u; + h -= floor(h); + const lift = max(0, 1 - abs(h - 0.13) / 0.14); + hsv(h, 0.9, 0.5 + 0.08 * u + 0.38 * lift, t, (PN + f) * 3); + } + for (let c = 0; c < 3; c++) { + let a = 0; + for (let f = 0; f < 2 * PN; f++) a += t[f * 3 + c]; + t[2 * PN * 3 + c] = a / (2 * PN); + } + P.palHue = P.hue; +} + +export function setup(params) { + params.s = { x: 0.18, flow: 0, hue: 0, depth: 0, bands: 0.06, gold: 0, g: 0, wave: -1, pal: new Float32Array((2 * PN + 1) * 3), palHue: -1, buf: new Float32Array(N * 3) }; +} + +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.depth = cl(k[2], 0, 1); + P.bands = cl(k[3], 0, 1); + // The window slides about four LEDs a second at Speed 2, whatever the + // zoom. j repeats every 1 along the axis. + const s = S0 * 8 ** -P.depth; + P.x = (P.x + d * sp * 2 * s) % 1; + P.flow += d * sp * 0.22; + const b = input.btnPressed || []; + // K1 sends a wave of light down the generations, two and a half a + // second; K2 lays the gold edges on or takes them off. + if (b[0]) P.wave = 0; + if (P.wave >= 0) P.wave = P.wave > 30 ? -1 : P.wave + d * 2.5; + if (b[1]) P.gold = 1 - P.gold; + P.g += (P.gold - P.g) * min(1, d * 5); +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + if (P.palHue !== P.hue) palette(P); + const s = S0 * 8 ** -P.depth, buf = P.buf, pal = P.pal, MEAN = 2 * PN * 3; + // Bands: nepers of |j| per band, how much of each band is ramp, and how + // dark a band starts. + const L = 2.2 * 0.18 ** P.bands, ramp = 0.85 - 0.6 * P.bands, lo = 0.6 - 0.32 * P.bands; + for (let py = 0, p = 0; py < H; py++) { + const y = (H - py - 0.5 + GAP) * s; + for (let px = 0; px < W; px++, p += 3) { + jAt(P.x + (px + 0.5 - W / 2) * s, y); + // How fast log j turns per LED. Near an image of i it stalls while + // the tiles round it stay small, so the tile size at this height, + // about y, sets a floor; and the last rows above the axis, where every + // tile is a few LEDs at most, fade whatever it says. + const g = max(OUT[2] * s, (5 * s) / y), hf = sm(2.5, 9, y / s); + // A band or a phase turn per LED or more cannot be drawn; the colour + // there would be static. Contrast and colour fade as they crowd, and + // the light with them, so the cusps and the axis sink into the dark + // instead of shimmering. + const cb = (1 - sm(0.25, 0.55, g / L)) * hf, ch = (1 - sm(0.3, 0.7, g / PI)) * hf; + let u = OUT[0] / L - P.flow; + u -= floor(u); + // A sawtooth: each band brightens slowly and drops at its edge. + const saw = u < ramp ? u / ramp : (1 - u) / (1 - ramp); + const v = ((lo + (1 - lo) * saw) * cb + 0.78 * (1 - cb)) * (0.3 + 0.7 * ch); + let a = OUT[1] / TAU; + a -= floor(a); + const up = a < 0.5, f = up ? 2 * a : 2 - 2 * a, fi = (f * (PN - 1)) | 0, o = (up ? fi : PN + fi) * 3; + // Half an LED either side of a triangle edge the two families blend. + const e = (min(f, 1 - f) * PI) / g, w = e < 0.5 ? 0.5 - e : 0, o2 = (up ? PN + fi : fi) * 3; + let r = pal[o] + (pal[o2] - pal[o]) * w, gg = pal[o + 1] + (pal[o2 + 1] - pal[o + 1]) * w, b = pal[o + 2] + (pal[o2 + 2] - pal[o + 2]) * w; + r = (r * ch + pal[MEAN] * (1 - ch)) * v * 255; + gg = (gg * ch + pal[MEAN + 1] * (1 - ch)) * v * 255; + b = (b * ch + pal[MEAN + 2] * (1 - ch)) * v * 255; + if (P.wave >= 0) { + // The wave: a tile flares as the front reaches its generation and + // keeps a little of the light for a while after. + const dw = P.wave - OUT[3], fl = max(exp(-dw * dw * 3), dw > 0 ? 0.35 * exp(-dw * 0.7) : 0) * (0.3 + 0.7 * ch); + r += (255 - r) * fl * 0.8; + gg += (250 - gg) * fl * 0.8; + b += (235 - b) * fl * 0.8; + } + if (P.g > 0.01) { + // The tile edges as gold geodesics a LED wide, faded where the + // tiles are too small to draw. The hyperbolic distance to the nearer + // of the walls Re = +-1/2 and the floor |t| = 1, times y, is the + // distance on the panel. + const X = OUT[4], Y = OUT[5], e2 = (asinh(min(0.5 - abs(X), (X * X + Y * Y - 1) / 2) / Y) * y) / s; + const w2 = cl(1.1 - e2, 0, 1) * P.g * ch; + r += (255 - r) * w2; + gg += (205 - gg) * w2; + b += (90 - b) * w2; + } + buf[p] = r; + buf[p + 1] = gg; + buf[p + 2] = b; + } + } + 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]); +} diff --git a/incoming/pattern-study/patterns/zeta-loops.js b/incoming/pattern-study/patterns/zeta-loops.js new file mode 100644 index 000000000..96af1aa57 --- /dev/null +++ b/incoming/pattern-study/patterns/zeta-loops.js @@ -0,0 +1,286 @@ +// ===== Patternflow pattern ===== +// Title: Zeta Loops +// Family: line +// Idea: A bright head draws the Riemann zeta function along the critical line as t climbs, looping round the plane and passing exactly through the origin at every nontrivial zero, where the origin flashes and a ring opens from it. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the Riemann zeta function on the critical line (Bernhard Riemann, 1859), pattern round 29 +// Round: 29 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Sigma=-0.25..0.25, Trail=0..1 +// +// Knob 1: Hue. Turns the colour of the curve round the colour wheel; at 0 it is cyan with a blue tail. +// Knob 2: Speed. How fast t climbs: at 2 the head reaches a new zero every two seconds or so, at 0.2 ten times slower. +// Knob 3: Sigma. How far the line runs from the critical line Re s = 1/2: at 0 every loop hits the origin, and off it the loops miss it to one side, wider the further you turn. +// Knob 4: Trail. How long the drawn curve stays lit: under a second at 0, about ten at the opening 0.06, half a minute at 1, when the loops stack into a rosette round the origin. +// Buttons: K1 tap sends the head racing ahead to the next zero. K2 tap starts t again from 0. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, exp, log, cos, sin, sqrt, hypot, abs, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The plane turned a quarter turn: the real axis runs up the panel through +// column OX, the imaginary axis runs left, the origin sits OY rows down. +const OX = 32, OY = 92; +// LEDs to one unit at t. The loops widen with t faster than the panel does, +// so the view backs off a little as t climbs: 26 at the start, 15 by +// t = 118, which keeps most of each loop inside the side walls and still +// lets the later loops come out bigger. +const scaleAt = (t) => 26 / (1 + t / 70) ** 0.55; +// t runs from 0 to TEND and starts again. Samples are DT apart in t. +const TEND = 118, DT = 0.008, CAP = 16384; + +// Euler-Maclaurin summation: the first N-1 terms of the Dirichlet series, +// the integral of the tail, and six Bernoulli corrections. With N past +// t / 2 pi the corrections shrink fast, and ten more terms make it good to +// many digits at the t this pattern reaches. +const NMAX = 64; +const LN = new Float64Array(NMAX + 1).map((_, n) => (n ? log(n) : 0)); +const BF = [1 / 12, -1 / 720, 1 / 30240, -1 / 1209600, 1 / 47900160, -691 / 1307674368000]; +function zeta(sr, si, out) { + const n0 = min(NMAX - 1, floor(abs(si) / (2 * PI)) + 10); + let zr = 0, zi = 0; + for (let n = 1; n < n0; n++) { + const m = exp(-sr * LN[n]), a = -si * LN[n]; + zr += m * cos(a); + zi += m * sin(a); + } + // N^-s, then N^(1-s) / (s - 1) and half of N^-s. + const L = LN[n0], m = exp(-sr * L), a = -si * L, pr = m * cos(a), pi = m * sin(a); + const dr = sr - 1, d2 = dr * dr + si * si, nr = n0 * pr, ni = n0 * pi; + zr += (nr * dr + ni * si) / d2 + pr / 2; + zi += (ni * dr - nr * si) / d2 + pi / 2; + // B2k / (2k)! times s(s+1)...(s+2k-2) times N^(-s-2k+1). + let cr = pr / n0, ci = pi / n0, fr = sr, fi = si; + for (let k = 0; k < 6; k++) { + const c = BF[k]; + zr += c * (fr * cr - fi * ci); + zi += c * (fr * ci + fi * cr); + const a1 = sr + 2 * k + 1, a2 = sr + 2 * k + 2; + const gr = fr * a1 - fi * si, gi = fr * si + fi * a1; + fr = gr * a2 - gi * si; + fi = gr * si + gi * a2; + cr /= n0 * n0; + ci /= n0 * n0; + } + out[0] = zr; + out[1] = zi; +} + +function hsv(h, s, v, out) { + 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[0] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +export function setup(params) { + const P = (params.s = { + t: 0, acc: 0, clock: 0, sc: 26, sig: 0, n: 0, head: -1, race: 0, + // Each sample: t, the point, sigma it was taken at, the clock, and 1 when + // the curve breaks before it (a restart). + T: new Float64Array(CAP), X: new Float32Array(CAP), Y: new Float32Array(CAP), S: new Float64Array(CAP), + C: new Float32Array(CAP), B: new Uint8Array(CAP), + fix: -1, fixN: 0, dPrev: 9, falling: 0, flashes: [], hue: 0, life: 3, z: new Float64Array(2), + R: new Float32Array(N), G: new Float32Array(N), Bl: new Float32Array(N), col: new Float32Array(3), + sR: new Float32Array(N), sG: new Float32Array(N), sB: new Float32Array(N), tmp: new Float32Array(N), + }); + // The first frame already shows the curve on its way to the first zero at + // t = 14.13: eight seconds of drawing at half the opening speed. + for (let i = 0; i < 240; i++) step(P, 1 / 30, 1, 0, 0.06); +} + +function push(P, brk) { + zeta(0.5 + P.sig, P.t, P.z); + const i = (P.head = (P.head + 1) % CAP); + P.T[i] = P.t; + P.X[i] = P.z[0]; + P.Y[i] = P.z[1]; + P.S[i] = P.sig; + P.C[i] = P.clock; + P.B[i] = brk; + P.n = min(P.n + 1, CAP); + // A pass by the origin is the moment the distance stops falling. Its + // flash is as bright as the pass is close: full on the critical line, a + // glimmer a few LEDs off it. + const d = hypot(P.z[0], P.z[1]); + if (!brk && d > P.dPrev && P.falling && P.dPrev < 0.6) { + const j = (i + CAP - 1) % CAP, k = (i + CAP - 2) % CAP; + const miss = segDist(P.X[k], P.Y[k], P.X[j], P.Y[j], P.X[i], P.Y[i]) * P.sc; + const s = exp(-(miss * miss) / 3); + if (s > 0.05) P.flashes.push([P.clock, s]); + if (P.race && P.t > P.race) P.race = 0; + } + P.falling = d < P.dPrev ? 1 : 0; + P.dPrev = brk ? 9 : d; +} + +// How close the two segments a-b-c come to the origin. +function segDist(ax, ay, bx, by, cx, cy) { + const one = (px, py, qx, qy) => { + const dx = qx - px, dy = qy - py, l = dx * dx + dy * dy, u = l > 0 ? cl(-(px * dx + py * dy) / l, 0, 1) : 0; + return hypot(px + u * dx, py + u * dy); + }; + return min(one(ax, ay, bx, by), one(bx, by, cx, cy)); +} + +function step(P, d, speed, sig, trail) { + P.clock += d; + P.life = 0.8 + 29.2 * trail ** 0.4; + P.sc += (scaleAt(P.t) - P.sc) * min(1, d * 1.5); + if (abs(sig - P.sig) > 0.002) (P.sig = sig), (P.fix = P.head), (P.fixN = P.n); + // t climbs a little slower as it rises, so the head's speed on the panel + // grows only slowly while the loops widen and the zeros crowd closer. + const rate = (speed * 1.55 * (P.race ? 10 : 1)) / (1 + P.t / 90); + P.acc += rate * d; + let brk = P.head < 0 ? 1 : 0; + for (; P.acc >= DT; P.acc -= DT) { + P.t += DT; + if (P.t > TEND) (P.t = 0), (brk = 1), (P.race = 0); + push(P, brk); + brk = 0; + } + // The trail redraws itself at a new Sigma, newest first, a few hundred + // samples a frame. + if (P.fix >= 0) { + for (let c = 0; c < 500 && P.fix >= 0; c++) { + const i = P.fix; + if (P.S[i] !== P.sig) { + zeta(0.5 + P.sig, P.T[i], P.z); + P.X[i] = P.z[0]; + P.Y[i] = P.z[1]; + P.S[i] = P.sig; + } + const j = (i + CAP - 1) % CAP; + P.fix = --P.fixN > 0 && P.clock - P.C[j] < P.life ? j : -1; + } + } + P.flashes = P.flashes.filter((f) => P.clock - f[0] < 1.6); +} + +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 = k[0]; + const b = input.btnPressed || []; + // The race runs past a zero the head is about to reach anyway and stops + // at the one after. + if (b[0]) P.race = P.t + 1; + if (b[1]) (P.t = TEND + 1), (P.race = 0); + step(P, d, cl(k[1], 0.2, 3), cl(k[2], -0.25, 0.25), cl(k[3], 0, 1)); +} + +// A soft dot of light, kept to the brightest of what is already there: full +// on the LED under it, fading to nothing 1.3 LEDs out, so the curve draws +// as a line a little under two LEDs wide. +function dot(P, x, y, r, g, b) { + const cx = floor(x), cy = floor(y); + for (let yy = cy - 1; yy <= cy + 1; yy++) { + if (yy < 0 || yy >= H) continue; + const dy = yy + 0.5 - y; + for (let xx = cx - 1; xx <= cx + 1; xx++) { + if (xx < 0 || xx >= W) continue; + const dx = xx + 0.5 - x, w = 1.3 - sqrt(dx * dx + dy * dy); + if (w <= 0) continue; + const p = yy * W + xx, v = w > 1 ? 1 : w; + if (P.R[p] < r * v) P.R[p] = r * v; + if (P.G[p] < g * v) P.G[p] = g * v; + if (P.Bl[p] < b * v) P.Bl[p] = b * v; + } + } +} +// Light added on top of the curve and its glow. +const add = (P, x, y, r, g, b) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const p = y * W + x; + P.sR[p] += r; + P.sG[p] += g; + P.sB[p] += b; +}; + +// Two passes of a five-wide box along rows, then along columns, each a +// running sum; the panel's edges count as dark. +const GLOW = 0.6; +function box(a, o, step, n, out) { + let v = a[o] + a[o + step] + a[o + 2 * step]; + for (let i = 0; i < n; i++) { + out[o + i * step] = v / 5; + if (i + 3 < n) v += a[o + (i + 3) * step]; + if (i >= 2) v -= a[o + (i - 2) * step]; + } +} +function blur(src, tmp, dst) { + for (let pass = 0; pass < 2; pass++) { + for (let y = 0; y < H; y++) box(pass ? dst : src, y * W, 1, W, tmp); + for (let x = 0; x < W; x++) box(tmp, x, W, H, dst); + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const R = P.R, G = P.G, B = P.Bl, col = P.col; + R.fill(0); + G.fill(0); + B.fill(0); + // The curve, oldest first, each segment walked in steps under half an LED. + const life = P.life; + let n = 0; + while (n < P.n && P.clock - P.C[(P.head + CAP - n) % CAP] < life) n++; + let i = (P.head + CAP - n + 1) % CAP; + for (let c = 0, px = 0, py = 0, ok = 0; c < n; c++, i = (i + 1) % CAP) { + const age = P.clock - P.C[i]; + const x = OX + 0.5 - P.Y[i] * P.sc, y = OY + 0.5 - P.X[i] * P.sc; + const f = 1 - age / life, v = sqrt(f); + // Young curve is pale, then the hue, then a deeper tail. + hsv(P.hue + 0.5 + 0.18 * (1 - f), 0.6 + 0.35 * min(1, (1 - f) * 4), v, col); + if (ok && !P.B[i]) { + const L = hypot(x - px, y - py), m = max(1, Math.ceil(L / 0.5)); + for (let s = 1; s <= m; s++) dot(P, px + ((x - px) * s) / m, py + ((y - py) * s) / m, col[0] * 255, col[1] * 255, col[2] * 255); + } else dot(P, x, y, col[0] * 255, col[1] * 255, col[2] * 255); + ok = 1; + px = x; + py = y; + } + // The curve glows: a soft blur of its own light, two box passes each + // way, laid under it. On the panel a bare 1-LED line looks thin. + const sR = P.sR, sG = P.sG, sB = P.sB; + blur(R, P.tmp, sR); + blur(G, P.tmp, sG); + blur(B, P.tmp, sB); + for (let p = 0; p < N; p++) (sR[p] = R[p] + sR[p] * GLOW), (sG[p] = G[p] + sG[p] * GLOW), (sB[p] = B[p] + sB[p] * GLOW); + // The axes, dim: the real axis up the panel with a tick at each whole + // number, and a short stub of the imaginary axis through the origin. + for (let y = 0; y < H; y++) { + const re = (OY - y) / P.sc, tick = abs(re - Math.round(re)) * P.sc < 0.5; + add(P, OX, y, 14, 14, 18); + if (tick) for (let x = -2; x <= 2; x++) if (x) add(P, OX + x, y, 16, 16, 20); + } + for (let x = OX - 6; x <= OX + 6; x++) if (x !== OX) add(P, x, OY, 12, 12, 16); + // Each pass through the origin: the origin flares and a ring opens, in a + // pale rose that stays clear of the curve's colour and never browns. + hsv(P.hue + 0.92, 0.45, 1, col); + const fr = col[0] * 255, fg = col[1] * 255, fb = col[2] * 255; + for (const [c0, s] of P.flashes) { + const a = P.clock - c0, rad = 1.5 + a * 34, v = s * exp(-a * 2); + for (let y = max(0, floor(OY - rad - 2)); y <= min(H - 1, OY + rad + 2); y++) + for (let x = max(0, floor(OX - rad - 2)); x <= min(W - 1, OX + rad + 2); x++) { + const d = hypot(x - OX, y - OY), w = max(0, 1 - abs(d - rad) / 1.1) + max(0, 1 - d / 3.5) * exp(-a * 3) * 1.6; + if (w > 0) add(P, x, y, fr * v * w, fg * v * w, fb * v * w); + } + } + add(P, OX, OY, 40, 40, 44); + // The head: white with a small glow. + if (P.n) { + const h = P.head, hx = OX + 0.5 - P.Y[h] * P.sc, hy = OY + 0.5 - P.X[h] * P.sc; + for (let y = floor(hy) - 2; y <= floor(hy) + 2; y++) + for (let x = floor(hx) - 2; x <= floor(hx) + 2; x++) { + const d = hypot(x + 0.5 - hx, y + 0.5 - hy), w = exp(-d * d * 0.9); + add(P, x, y, 255 * w, 250 * w, 235 * w); + } + } + for (let y = 0, p = 0; y < H; y++) for (let x = 0; x < W; x++, p++) display.setPixel(x, y, min(255, sR[p]), min(255, sG[p]), min(255, sB[p])); +} From 8945e4cafc24b5f67eff8dfd4ee14801abe5b363 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:45:55 +0000 Subject: [PATCH 08/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Hopf=20Fibration=20=EC=9D=B4=20=EA=B2=B9?= =?UTF-8?q?=EC=B9=9C=20=EC=86=8C=EC=9A=A9=EB=8F=8C=EC=9D=B4=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B4=EB=A6=AC=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/hopf-fibration.js | 156 ++++++++++-------- 1 file changed, 88 insertions(+), 68 deletions(-) diff --git a/incoming/pattern-study/patterns/hopf-fibration.js b/incoming/pattern-study/patterns/hopf-fibration.js index 5cfcf2df0..1dcc0137a 100644 --- a/incoming/pattern-study/patterns/hopf-fibration.js +++ b/incoming/pattern-study/patterns/hopf-fibration.js @@ -9,12 +9,12 @@ // Round: 29 // =============================== // @matrix 64x128 -// @knobs Hue=0..1, Speed=0.2..3, Rings=3..7, Fibres=0..1 +// @knobs Hue=0..1, Speed=0.2..3, Rings=5..7, Fibres=14..17 // // Knob 1: Hue. Turns the colours round the wheel; each fibre takes its hue from the longitude of its base point. // Knob 2: Speed. How fast the base points run round their latitudes (the tori turn, neighbours the opposite way), the latitudes breathe and the view rocks. -// Knob 3: Rings. How many circles of latitude carry base points, 3 to 7; each is one torus, and the outer ones open into tall loops round the line. -// Knob 4: Fibres. How many fibres each torus has, from 9 to 16. +// Knob 3: Rings. How many circles of latitude carry base points, 5 to 7; each is one torus, and the outer ones open into tall loops round the line. +// Knob 4: Fibres. How many fibres each torus has, from 14 to 17. // Buttons: K1 tap lights two fibres from different tori and dims the rest for five seconds, so you can follow them through each other. K2 tap tilts the circles of base points on the sphere, so the tori lean over and turn lopsided; tap again to stand them up. const W = 64, H = 128, N = W * H; @@ -24,8 +24,8 @@ 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)); // Points round one fibre, and the most fibres on show: seven tori of -// sixteen, the ring and the line. -const NT = 120, MAXF = 7 * 16 + 2, MAXS = MAXF * NT; +// seventeen, the ring and the line. +const NT = 96, MAXF = 7 * 17 + 2, MAXS = MAXF * NT; const CT = new Float32Array(NT), ST = new Float32Array(NT); for (let i = 0; i < NT; i++) (CT[i] = cos((TAU * i) / NT)), (ST[i] = sin((TAU * i) / NT)); @@ -47,11 +47,11 @@ function hsv(h, s, v, out, o) { export function setup(params) { const F32 = (n) => new Float32Array(n), I32 = (n) => new Int32Array(n); params.s = { - T: 0, tilt: 0, tiltTo: 0, pair: 0, hue: 0, rings: 3, fib: 9, + T: 0, tilt: 0, tiltTo: 0, pair: 0, hue: 0, rings: 5, fib: 14, ns: 0, cam: new Float64Array(8), buf: F32(N * 3), // Every fibre point on screen with its depth. sx: F32(MAXS), sy: F32(MAXS), sz: F32(MAXS), - col: F32(MAXF * 3), lit: F32(MAXF), + col: F32(MAXF * 3), lit: F32(MAXF), pp: F32(MAXF), // Segments as (first point, last point), sorted far to near by depth bin. sa: I32(MAXS), sb: I32(MAXS), key: new Uint8Array(MAXS), ord: I32(MAXS), bin: I32(NB + 1), }; @@ -62,8 +62,8 @@ export function update(dt, input, 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.T += d * cl(k[1], 0.2, 3); - P.rings = cl(k[2], 3, 7); - P.fib = 9 + 7 * cl(k[3], 0, 1); + P.rings = cl(k[2], 5, 7); + P.fib = cl(k[3], 14, 17); const b = input.btnPressed || []; if (b[0]) P.pair = 5; if (b[1]) P.tiltTo = P.tiltTo ? 0 : 1; @@ -77,49 +77,39 @@ export function update(dt, input, params) { // stereographically from (0,0,0,1) and then through the camera. function fibre(P, f, th, ph, cam) { const a = th / 2, ca = cos(a), sa = sin(a), cp = cos(ph) * ca, sp = sin(ph) * ca; - const [rx, ry, ux, uy, uz, fx, fy, fz] = cam; + const rx = cam[0], ry = cam[1], ux = cam[2], uy = cam[3], uz = cam[4], fx = cam[5], fy = cam[6], fz = cam[7]; + const sx = P.sx, sy = P.sy, sz = P.sz; for (let i = 0, o = f * NT; i < NT; i++, o++) { const c = CT[i], s = ST[i], q = 1 / (1 - sa * s + 1e-6); const X = (c * cp - s * sp) * q, Y = (s * cp + c * sp) * q, Z = sa * c * q; - const zc = D + X * fx + Y * fy + Z * fz; - P.sz[o] = zc; - P.sx[o] = CX + (F * (X * rx + Y * ry)) / zc; - P.sy[o] = CY - (F * (X * ux + Y * uy + Z * uz)) / zc; + const iz = F / (D + X * fx + Y * fy + Z * fz); + sz[o] = F / iz; + sx[o] = CX + (X * rx + Y * ry) * iz; + sy[o] = CY - (X * ux + Y * uy + Z * uz) * iz; } } // One segment: an antialiased core whose half width hw comes from depth, and -// a faint halo of the same light. Each LED is pulled toward the line's -// colour by its cover, so a near strand passes over a far one with a thin -// dark seam: that seam is what shows which of two fibres is in front. -function line(buf, x0, y0, x1, y1, r, g, b, hw, op) { - const R = hw + 1.7, E = hw + 1.2; - const by0 = max(0, floor(min(y0, y1) - R)), by1 = min(H - 1, floor(max(y0, y1) + R)); - const dx = x1 - x0, dy = y1 - y0, L2 = dx * dx + dy * dy; - if (by0 > by1 || L2 < 1e-4) return; - const il = 1 / sqrt(L2), nx = -dy * il, ny = dx * il, ux = dx / L2, uy = dy / L2; - for (let y = by0; y <= by1; y++) { - // The LEDs of this row within reach of the segment, solved from the two - // linear conditions rather than tested over its whole box. - const py = y + 0.5 - y0; - let lo = -R - 2, hi = R + 2 + abs(dx); - if (abs(nx) > 1e-6) { - const p = (-E - py * ny) / nx, q = (E - py * ny) / nx; - lo = max(lo, min(p, q)); - hi = min(hi, max(p, q)); - } else if (abs(py * ny) > E) continue; - if (abs(ux) > 1e-9) { - const p = (-py * uy) / ux, q = (1 - py * uy) / ux; - lo = max(lo, min(p, q)); - hi = min(hi, max(p, q)); - } else if (py * uy < 0 || py * uy >= 1) continue; - const xa = max(0, Math.ceil(lo + x0 - 0.5)), xb = min(W - 1, floor(hi + x0 - 0.5)); - for (let x = xa, p = y * W + xa; x <= xb; x++, p++) { - const px = x + 0.5 - x0, u = px * ux + py * uy; - if (u < 0 || u >= 1) continue; - const dd = abs(px * nx + py * ny) - hw; - if (dd > 1.2) continue; - const w = (dd < 0.5 ? cl(0.5 - dd, 0.3, 1) : 0.3 * (1 - (dd - 0.5) / 0.7)) * op, q = p * 3; +// a faint halo hl wide of the same light. Each LED is pulled toward the +// line's colour by its cover, so a near strand passes over a far one with a +// thin dark seam: that seam is what shows which of two fibres is in front. +// The segment is walked one LED at a time along its longer axis and owns the +// LEDs whose centres fall in [start, end) there, so the segments of one +// fibre meet without gaps or double cover. +function line(buf, x0, y0, x1, y1, r, g, b, hw, hl, op) { + const dx = x1 - x0, dy = y1 - y0, E = hw + 0.5 + hl, flat = abs(dx) >= abs(dy); + if (abs(dx) + abs(dy) < 1e-3) return; + // Along the major axis a, across it c; k is the slope, cs the cosine that + // turns a distance across into a distance from the line. + const a0 = flat ? x0 : y0, a1 = flat ? x1 : y1, c0 = flat ? y0 : x0, k = flat ? dy / dx : dx / dy; + const cs = 1 / sqrt(1 + k * k), ec = E / cs, na = flat ? W : H, nc = flat ? H : W, sa = flat ? 3 : W * 3, sc = flat ? W * 3 : 3; + const s0 = max(0, Math.ceil(min(a0, a1) - 0.5)), s1 = min(na - 1, Math.ceil(max(a0, a1) - 0.5) - 1); + for (let i = s0; i <= s1; i++) { + const cc = c0 + (i + 0.5 - a0) * k, j0 = max(0, Math.ceil(cc - ec - 0.5)), j1 = min(nc - 1, floor(cc + ec - 0.5)); + for (let j = j0, q = i * sa + j0 * sc; j <= j1; j++, q += sc) { + const e = (j + 0.5 - cc) * cs, dd = (e < 0 ? -e : e) - hw; + if (dd > E - hw) continue; + const w = (dd < -0.5 ? 1 : dd < 0.2 ? 0.5 - dd : dd < 0.5 ? 0.3 : (0.3 * (E - hw - dd)) / hl) * op; buf[q] += (r - buf[q]) * w; buf[q + 1] += (g - buf[q + 1]) * w; buf[q + 2] += (b - buf[q + 2]) * w; @@ -127,6 +117,19 @@ function line(buf, x0, y0, x1, y1, r, g, b, hw, op) { } } +// One segment into the depth bins, unless it is off the panel or stretched +// across it by passing close to the camera. +function emit(P, a, b) { + const { sx, sy, sz } = P, x0 = sx[a], y0 = sy[a], x1 = sx[b], y1 = sy[b]; + if (abs(x1 - x0) + abs(y1 - y0) >= 70) return; + if ((x0 < -3 && x1 < -3) || (x0 > W + 3 && x1 > W + 3) || (y0 < -3 && y1 < -3) || (y0 > H + 3 && y1 > H + 3)) return; + const kb = cl(floor((((sz[a] + sz[b]) / 2 - (D - 4)) / 8) * NB), 0, NB - 1); + P.key[P.ns] = kb; + P.sa[P.ns] = a; + P.sb[P.ns++] = b; + P.bin[kb + 1]++; +} + export function draw(display, params) { const P = params.s; if (!P) return; @@ -136,7 +139,15 @@ export function draw(display, params) { // the tori and looking down into the ring. const az = 0.5 + T * 0.05, el = 0.75 + 0.2 * sin(T * 0.11); const ce = cos(el), se = sin(el), ca = cos(az), sn = sin(az); - const cam = [-sn, ca, -se * ca, -se * sn, ce, -ce * ca, -ce * sn, -se]; + const cam = P.cam; + cam[0] = -sn; + cam[1] = ca; + cam[2] = -se * ca; + cam[3] = -se * sn; + cam[4] = ce; + cam[5] = -ce * ca; + cam[6] = -ce * sn; + cam[7] = -se; const nr = P.rings, n = floor(P.fib), tl = sm(P.tilt) * 1.1, ct = cos(tl), st = sin(tl); // After a K1 tap one fibre of the innermost torus and one of the next stay // lit and the rest dim to under a third. @@ -160,6 +171,7 @@ export function draw(display, params) { hsv(P.hue + 0.55 + i / n, 0.45 + 0.5 * u, on * (1 - 0.4 * u), col, nf * 3); const picked = (j === 0 && i === pick0) || (j === 1 && i === pick1); lit[nf] = picked ? 1 + hi : 1 - hi; + P.pp[nf] = ph + j * 1.3; nf++; } } @@ -167,6 +179,7 @@ export function draw(display, params) { // over the other: on the panel a vertical line through the middle. const rl = 1 - hi * 0.6; fibre(P, nf, 0, 0, cam); + P.pp[nf] = 0; col[nf * 3] = col[nf * 3 + 1] = 170; col[nf * 3 + 2] = 185; lit[nf++] = rl; @@ -178,43 +191,50 @@ export function draw(display, params) { } col[nf * 3] = col[nf * 3 + 1] = 170; col[nf * 3 + 2] = 185; + P.pp[nf] = 0; lit[nf++] = rl; - // Cut each fibre into segments about three LEDs long on screen, skipping - // what lies behind the camera or off the panel, and bin them by depth. + // Cut each fibre into segments as long as they can be while the curve + // between their ends stays within 0.3 LED of the chord (and under 12 LEDs), + // skipping what lies behind the camera or off the panel; bin them by depth. bin.fill(0); - let ns = 0; + P.ns = 0; for (let f = 0; f < nf; f++) { - const o = f * NT, loop = f < nf - 1, end = loop ? NT : NT - 1; - let a = o; - for (let i = 1; i <= end; i++) { - const b = o + (i % NT), ok = sz[a] > NEAR && sz[b] > NEAR; - const ddx = sx[b] - sx[a], ddy = sy[b] - sy[a], L = abs(ddx) + abs(ddy); - if (ok && L < 3 && i < end) continue; - if (ok && L < 70) { - const x0 = sx[a], y0 = sy[a], x1 = sx[b], y1 = sy[b]; - if (!((x0 < -3 && x1 < -3) || (x0 > W + 3 && x1 > W + 3) || (y0 < -3 && y1 < -3) || (y0 > H + 3 && y1 > H + 3))) { - const kb = cl(floor((((sz[a] + sz[b]) / 2 - (D - 4)) / 8) * NB), 0, NB - 1); - key[ns] = kb; - sa[ns] = a; - sb[ns++] = b; - bin[kb + 1]++; + const o = f * NT, end = f < nf - 1 ? NT : NT - 1; + let ia = 0; + for (let i = 1; i <= end; ) { + const a = o + ia, b = o + (i % NT), ok = sz[a] > NEAR && sz[b] > NEAR; + if (ok && i < end && i - ia < 16) { + const dx = sx[b] - sx[a], dy = sy[b] - sy[a], m = o + ((ia + i) >> 1); + const dev = abs(dx * (sy[m] - sy[a]) - dy * (sx[m] - sx[a])) / (sqrt(dx * dx + dy * dy) + 1e-6); + if (abs(dx) + abs(dy) < 12 && dev < 0.3) { + i++; + continue; + } + if (i - ia > 1) { + emit(P, a, b - 1); + ia = i - 1; + continue; } } - a = b; + if (ok) emit(P, a, b); + ia = i++; } } + const ns = P.ns; for (let i = 1; i <= NB; i++) bin[i] += bin[i - 1]; for (let s = 0; s < ns; s++) ord[bin[key[s]]++] = s; // Far to near. A fibre is lit 1 near the camera and a quarter far off, // and its core is two LEDs wide near and one far. A slow swell of light - // runs round every fibre at once: the circle action of the fibration, - // which moves each point of the 3-sphere along its own fibre. + // runs round every fibre at the same rate: the circle action of the + // fibration, which moves each point of the 3-sphere along its own fibre. + // Each fibre's swell starts from its own longitude so the whole picture + // never dims at once. for (let r = ns - 1; r >= 0; r--) { const s = ord[r], a = sa[s], b = sb[s], f = floor(a / NT), c = f * 3, lf = lit[f]; const dn = cl(((sz[a] + sz[b]) / 2 - (D - 3)) / 6, 0, 1) * (lf > 1 ? 2 - lf : 1); - const v = (1 - 0.75 * dn) * lf * (0.72 + 0.28 * cos((TAU * (a % NT)) / NT - 1.6 * T)); - line(buf, sx[a], sy[a], sx[b], sy[b], min(255, col[c] * v), min(255, col[c + 1] * v), min(255, col[c + 2] * v), 0.25 + 0.6 * (1 - dn), lf < 1 ? lf : 1); + const v = (1 - 0.75 * dn) * lf * (0.72 + 0.28 * cos((TAU * (a % NT)) / NT + P.pp[f] - 1.6 * T)); + line(buf, sx[a], sy[a], sx[b], sy[b], min(255, col[c] * v), min(255, col[c + 1] * v), min(255, col[c + 2] * v), 0.25 + 0.6 * (1 - dn), 0.05 + 0.65 * (1 - dn), lf < 1 ? lf * lf : 1); } 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 b2a02a0f7a6bf06c991953a01896eb5ae2554cb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:45:56 +0000 Subject: [PATCH 09/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Cream=20Swirl=20=EC=9D=98=20=EB=B9=9B?= =?UTF-8?q?=EC=9D=B4=20=EC=B2=9C=EC=B2=9C=ED=9E=88=20=EC=9B=80=EC=A7=81?= =?UTF-8?q?=EC=9D=B4=EA=B3=A0=20=EB=A6=AC=EB=B3=B8=EC=9D=B4=20=EA=B8=B8?= =?UTF-8?q?=EC=96=B4=EC=A7=84=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_01Fz23eRpkazDP4tpajkAeja --- .../pattern-study/patterns/cream-swirl.js | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/incoming/pattern-study/patterns/cream-swirl.js b/incoming/pattern-study/patterns/cream-swirl.js index 2b9468027..5cda49c00 100644 --- a/incoming/pattern-study/patterns/cream-swirl.js +++ b/incoming/pattern-study/patterns/cream-swirl.js @@ -1,7 +1,7 @@ // ===== Patternflow pattern ===== // Title: Cream Swirl // Family: cosmetic -// Idea: An unseen palette knife sweeps slow S-curves and spirals through a thick satin cream, leaving a smooth band, pushing a glossy ridge along its edge and drawing dollops of tinted cream out into ribbons. +// Idea: An unseen palette knife sweeps slow S-curves and spirals through a thick satin cream, leaving a smooth band, pushing a glossy ridge along its edge and drawing dollops of tinted cream out into ribbons, while a soft light glides to and fro so the sheen slides over the folds. // Author: patternflow-lab // Date: 2026-10-24 // SPDX-License-Identifier: CC-BY-SA-4.0 @@ -12,7 +12,7 @@ // @knobs Hue=0..1, Speed=0.2..3, Tones=1..3, Gloss=0..1 // // Knob 1: Hue. Turns the cream and its tints round the whole colour wheel together, opening on rose. -// Knob 2: Speed. How fast and how hard the knife works: at 2 it travels 20 LEDs a second and an S-sweep down the panel takes about eight seconds; slow, it glides and leaves smooth bands, fast, it chatters and throws taller ridges. +// Knob 2: Speed. How fast and how hard the knife works: at 2 it travels 20 LEDs a second, an S-sweep down the panel takes about eight seconds and the light swings across and back in seven; slow, the knife glides and leaves smooth bands, fast, it chatters and throws taller ridges. // Knob 3: Tones. How many tints are folded into the pale cream, from one pastel at 1 to three at 3, a pale, a mid and a deep one; the ribbons split into their bands at once, and with more tints more dollops fall in each stroke's path. // Knob 4: Gloss. At 0 the cream has a soft satin sheen spread over its slopes; at 1 it is wet, a little deeper in colour, and each ridge carries a thin white highlight. // Buttons: K1 tap drops a fresh dollop of the deepest tint in the knife's path, and the knife sweeps it into a ribbon. @@ -33,7 +33,9 @@ const STEP = 0.5, PMAX = 1200; // scraped; how much of the band's skin is the cream the blade carries; what // share of each bin's load slides on to the next bin along the angled blade // for each LED travelled. -const PICK = 0.22, FILM = 0.6, SLIP = 1.05; +const PICK = 0.3, FILM = 0.45, SLIP = 1.05; +// The share of the dragged skin left behind for each LED travelled. +const DRAG = 0.03; // The ridge the spill builds stands at RS height per LED on its flanks, and // is never taller than RMAX. const RS = 0.4, RMAX = 3.6; @@ -44,17 +46,17 @@ const SKIN = 0.8; // The opening hue: rose, not red. const H0 = 0.93; -// A soft light low from the top left, and the half vector to a viewer -// straight in front, for the sheen. -const LX = -0.6, LY = -0.62, LZ = 0.5; -const HL = sqrt(LX * LX + LY * LY + (LZ + 1) * (LZ + 1)), HX = LX / HL, HY = LY / HL, HZ = (LZ + 1) / HL; +// A soft light low over the top left, its height LZ and where it sits round +// the panel, LA radians; it glides LS either way of that and back over SWING +// seconds at Speed 2, so the sheen slides along the ridges between strokes. +const LZ = 0.5, LA = -2.34, LS = 0.9, SWING = 7; // How tall the height field looks: a slope of 1 tilts the surface 56 degrees. -const KZ = 1.5; +const KZ = 1.8; // The light is a big soft box: it wraps a little way round the cream, so a // slope turned from it shades off gently instead of dropping into shadow. -const WRAP = 0.45; +const WRAP = 0.3; // How much a fold darkens and a crest brightens, per unit of height. -const CAV = 0.35; +const CAV = 0.5; // Cream the knife has just laid down holds a wet film that lifts it by this // much, fading as it dulls. const GLINT = 26; @@ -86,7 +88,7 @@ const STEPS = [ function band(c, n) { const [th, lv] = STEPS[n]; let v = lv[0]; - for (let i = 0; i < th.length; i++) v += (lv[i + 1] - lv[i]) * sm((c - th[i]) / 0.26 + 0.5); + for (let i = 0; i < th.length; i++) v += (lv[i + 1] - lv[i]) * sm((c - th[i]) / 0.1 + 0.5); return v; } @@ -97,9 +99,9 @@ export function setup(params) { dh: new Float32Array(N), dt: new Float32Array(N), bl: new Float32Array(N), av: new Float32Array(N), stamp: new Int32Array(N).fill(-999), sub: 0, fr: new Float32Array(N), path: new Float32Array(PMAX * 2), plen: 0, pos: 0, type: 0, done: 0, - m: new Float32Array(NB), c: new Float32Array(NB), bm: 0, bc: 0, + m: new Float32Array(NB), c: new Float32Array(NB), ft: new Float32Array(NB), fm: new Float32Array(NB), bm: 0, bc: 0, L: 24, ca: 1, sa: 0, B: 0, tilt: 0, gPh: 0, cc: 0, cA: 0, cP: 8, cB: 0, slip: 1, rh: 0, - sp: new Float32Array(8), pause: 0, live: false, force: 1, + sp: new Float32Array(8), pause: 0, live: false, force: 1, lt: 0, dol: [], hue: 0, tones: 1, gloss: 0.06, ready: false, lut: new Float32Array(64 * 3), lus: new Float32Array(64 * 3), kx: 0, ky: 0, dx: 0, dy: 1, tx: 1, ty: 0, @@ -115,16 +117,19 @@ function blur(a, b) { // A round dollop: a soft mound of tint c with a peak where it left the spoon. It is all skin, so a knife that // scrapes it away finds the cream it was dropped on. -function dollop(P, x0, y0, r, ht, c, f) { +// Its colour goes on with the first of it, as deep as the whole dollop will +// be thick: its thin rim only half covers what it landed on, so a knife that +// scrapes it off leaves no ring. +function dollop(P, x0, y0, r, ht, c, f, first) { const x1 = max(0, floor(x0 - r)), x2 = min(W - 1, floor(x0 + r) + 1); const y1 = max(0, floor(y0 - r)), y2 = min(H - 1, floor(y0 + r) + 1); for (let y = y1; y <= y2; y++) for (let x = x1; x <= x2; x++) { const d = ((x - x0) ** 2 + (y - y0) ** 2) / (r * r); if (d >= 1) continue; - const p = y * W + x, a = ht * f * (1 - sqrt(d)) * (1 - d * 0.5); - P.hf[p] += a; - P.tn[p] += (c - P.tn[p]) * min(1, a * 2); + const p = y * W + x, a = ht * (1 - sqrt(d)) * (1 - d * 0.5); + P.hf[p] += a * f; + if (first) P.tn[p] += (c - P.tn[p]) * min(1, a / 1.2); } } @@ -224,7 +229,7 @@ function startStroke(P, k3) { P.sa = sin(al); P.tilt = (rnd() - 0.5) * 0.6; P.cc = 0.2 + rnd() * 0.4; - P.cA = (0.1 + rnd() * 0.08) * (0.35 + 0.65 * P.force); + P.cA = (0.13 + rnd() * 0.1) * (0.35 + 0.65 * P.force); P.B -= 0.35 * (P.force - 1); P.cP = 11 + rnd() * 5; P.cB = (rnd() < 0.5 ? -1 : 1) * (3 + rnd() * 4); @@ -237,12 +242,14 @@ function startStroke(P, k3) { } P.m.fill(0); P.c.fill(0); + P.ft.fill(0); + P.fm.fill(0); P.rh = 0; P.live = true; // Every stroke goes through fresh dollops dropped a little way along: one // for one tint, two or three when three tints are folded in. const nd = k3 === 1 ? 1 : k3 === 2 ? (rnd() < 0.5 ? 2 : 1) : rnd() < 0.4 ? 3 : 2; - for (let i = 0; i < nd; i++) plan(P, 0.1 + rnd() * 0.45, rnd() < 0.4 ? 0 : DOSE[k3 - 1][floor(rnd() * k3)], 10 + rnd() * 3); + for (let i = 0; i < nd; i++) plan(P, 0.1 + rnd() * 0.45, rnd() < 0.4 ? 0 : DOSE[k3 - 1][floor(rnd() * k3)], (10 + rnd() * 3) / sqrt(nd)); } // A dollop waiting to fall at a point on the path ahead of the knife. @@ -270,12 +277,12 @@ function locate(P, pos) { P.ty = dx * P.ca - dy * P.sa; } -// The load and tint of the blade at a point a along it, read between the -// bins so what it lays down has no seams. +// The load of the blade at a point a along it and the tint it drags there, +// read between the bins so what it lays down has no seams. function load(P, a) { const fb = cl(((a + P.L / 2) / P.L) * NB - 0.5, 0, NB - 1), j = min(NB - 2, floor(fb)), f = fb - j; P.bm = P.m[j] + (P.m[j + 1] - P.m[j]) * f; - P.bc = P.c[j] + (P.c[j + 1] - P.c[j]) * f; + P.bc = P.ft[j] + (P.ft[j + 1] - P.ft[j]) * f; } // The ridge across its width r (0 at the band's edge, 1 at its foot): a @@ -291,7 +298,8 @@ function knife(P, ds) { P.sub++; if (P.pos >= P.plen) return lift(P); locate(P, P.pos); - const { hf, tn, ut, m, c, stamp, L } = P, hl = L / 2, sl = P.slip; + const { hf, tn, ut, m, c, ft, fm, stamp, L } = P, hl = L / 2, sl = P.slip; + for (let j = 0; j < NB; j++) fm[j] *= 1 - DRAG * ds; const kx = P.kx, ky = P.ky, dx = P.dx, dy = P.dy, tx = P.tx, ty = P.ty, det = tx * dy - ty * dx; // Pressure comes on over the first 8 LEDs and goes off over the last 6. const pr = sm(P.pos / 8) * sm((P.plen - P.pos) / 6); @@ -318,6 +326,11 @@ function knife(P, ds) { if (e > 0) { m[j] += e * pr; c[j] += (to - c[j]) * min(1, e * PICK * pr); + // The skin of tint the blade drags stays in its own lane and is + // mixed by how much of each colour it gathered, so a dollop + // becomes a long ribbon that pales as white cream dilutes it. + fm[j] += e * pr; + ft[j] += ((to - ft[j]) * e * pr) / fm[j]; } // Under what was scraped off lies the bed; over it the blade leaves a // film of the cream it carries. @@ -363,7 +376,7 @@ function lift(P) { roll(P, P.hf, P.tn, P.ut); P.live = false; P.done++; - P.pause = 0.5 + rnd() * 0.4; + P.pause = 0.15 + rnd() * 0.2; } // The cream on the blade's face as a rounded roll in front of it, laid into @@ -395,11 +408,12 @@ function tick(P, dt, speed, k3) { for (const d of P.dol) { if (d.f >= 1) continue; const g = min(1 - d.f, dt * 2.5 * max(0.6, speed / 2)); - dollop(P, d.x, d.y, d.r, d.h, d.c, g); + dollop(P, d.x, d.y, d.r, d.h, d.c, g, d.f === 0); d.f += g; } // Freshly worked cream glistens; left alone it dulls to a satin over // about ten seconds at Speed 2. + P.lt += dt * speed * 0.5; const fr = P.fr, k = 1 - dt * 0.07 * max(0.5, speed); for (let p = 0; p < N; p++) fr[p] *= k; if (!P.live) { @@ -428,7 +442,7 @@ function prime(P, k3) { while (P.done < 3) tick(P, 1 / 30, 2, k3); while (!P.live) tick(P, 1 / 30, 2, k3); // Start the live stroke a fifth of the way along, its dollops down. - for (const d of P.dol) if (d.f < 1) (dollop(P, d.x, d.y, d.r, d.h, d.c, 1 - d.f), (d.f = 1)); + for (const d of P.dol) if (d.f < 1) (dollop(P, d.x, d.y, d.r, d.h, d.c, 1 - d.f, d.f === 0), (d.f = 1)); while (P.live && P.pos < P.plen * 0.2) tick(P, 1 / 30, 2, k3); P.ready = true; } @@ -471,15 +485,22 @@ export function draw(display, params) { // tint at 1, stepped into the bands Tones asks for. Light goes a little // way into cream, so its shadows are a deeper tint rather than grey. // Wet cream (Gloss up) is a little deeper in colour, as anything wet is. + // The deeper tints also darken, so three of them stay apart, except round + // orange and yellow, where darker reads as brown and olive on LEDs. const t = P.tones, n0 = floor(t) - 1, ft = t - floor(t), n1 = min(2, n0 + 1), hue = H0 + P.hue, g = P.gloss; + const hw = (hue - floor(hue) + 0.38) % 1, dk = 0.3 * min(1, abs(hw - 0.5) / 0.11); for (let i = 0; i < 64; i++) { const c = i / 63, v = band(c, n0) + (band(c, n1) - band(c, n0)) * ft; - hsv(hue, 0.22 + 0.72 * v + 0.06 * g, (1 - 0.1 * v) * (1 - 0.1 * g), lut, i * 3); + hsv(hue, 0.22 + 0.72 * v + 0.06 * g, (1 - 0.06 * v - dk * v * v) * (1 - 0.1 * g), lut, i * 3); hsv(hue, min(1, 0.4 + 0.6 * v + 0.1 * g), 0.58 - 0.08 * v - 0.14 * g, lus, i * 3); } // The sheen is measured from what a level surface would show, so level // cream stays its colour and only the slopes turned to the light shine. - const shin = 6 + 70 * g * g, ks = (0.4 + 0.9 * g) * 255, s0 = pow(HZ, shin), sk = ks / (1 - s0), fr = P.fr; + const la = LA + LS * sin((2 * PI * P.lt) / SWING), lr = sqrt(1 - LZ * LZ), LX = lr * cos(la), LY = lr * sin(la); + const HL = sqrt(LX * LX + LY * LY + (LZ + 1) * (LZ + 1)), HX = LX / HL, HY = LY / HL, HZ = (LZ + 1) / HL; + // A broad satin sheen is always there; Gloss adds a narrow wet one on top. + const shin = 10 + 70 * g * g, ks = 0.95 * g * 255, s0 = pow(HZ, shin), sk = ks / (1 - s0), fr = P.fr; + const b0 = pow(HZ, 6), bk = (0.4 * 255) / (1 - b0); for (let y = 0; y < H; y++) { const yu = y > 0 ? -W : 0, yd = y < H - 1 ? W : 0; for (let x = 0; x < W; x++) { @@ -488,7 +509,7 @@ export function draw(display, params) { const gy = (dh[p + yd] - dh[p + yu]) * 0.5 * KZ; const il = 1 / sqrt(gx * gx + gy * gy + 1), nx = -gx * il, ny = -gy * il, nz = il; const df = nx * LX + ny * LY + nz * LZ, nh = nx * HX + ny * HY + nz * HZ; - const f = (df > -WRAP ? (0.85 * (df + WRAP)) / (LZ + WRAP) : 0) * (1 + cl((dh[p] - av[p]) * CAV, -0.45, 0.25)), sp = (nh > HZ ? (pow(nh, shin) - s0) * sk * (0.5 + 0.5 * fr[p]) : 0) + fr[p] * GLINT; + const f = (df > -WRAP ? (0.85 * (df + WRAP)) / (LZ + WRAP) : 0) * (1 + cl((dh[p] - av[p]) * CAV, -0.45, 0.25)), n2 = nh * nh, sp = (nh > HZ ? ((g > 0.01 ? (pow(nh, shin) - s0) * sk : 0) + (n2 * n2 * n2 - b0) * bk) * (0.5 + 0.5 * fr[p]) : 0) + fr[p] * GLINT; const o = cl(floor(dt[p] * 63 + 0.5), 0, 63) * 3, r = lus[o], gg = lus[o + 1], b = lus[o + 2]; display.setPixel(x, y, min(255, r + (lut[o] - r) * f + sp), min(255, gg + (lut[o + 1] - gg) * f + sp * 0.97), min(255, b + (lut[o + 2] - b) * f + sp * 0.92)); } From f7870655502f34ea17bf319bd5ec1784ad9260dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:48:35 +0000 Subject: [PATCH 10/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Hopf=20Fibration=20=EC=9D=B4=20=EC=A1=B0?= =?UTF-8?q?=EA=B8=88=20=EB=B9=A8=EB=A6=AC=20=EB=8F=8C=EC=95=84=20=EA=B0=80?= =?UTF-8?q?=EC=9E=A5=EC=9E=90=EB=A6=AC=20=C3=97=20=EC=9B=80=EC=A7=81?= =?UTF-8?q?=EC=9E=84=201,4=20=EC=97=90=20=EC=84=A0=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_01Fz23eRpkazDP4tpajkAeja --- incoming/pattern-study/patterns/hopf-fibration.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/incoming/pattern-study/patterns/hopf-fibration.js b/incoming/pattern-study/patterns/hopf-fibration.js index 1dcc0137a..c749dab53 100644 --- a/incoming/pattern-study/patterns/hopf-fibration.js +++ b/incoming/pattern-study/patterns/hopf-fibration.js @@ -61,7 +61,9 @@ 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.T += d * cl(k[1], 0.2, 3); + // 1.3 a second at Speed 2, so the strands sweep across the panel rather + // than drift. + P.T += d * cl(k[1], 0.2, 3) * 1.3; P.rings = cl(k[2], 5, 7); P.fib = cl(k[3], 14, 17); const b = input.btnPressed || []; From aec9e21776abc6ea326d71b6025c620d5b66e0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:54:39 +0000 Subject: [PATCH 11/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Hopf=20Fibration=20=EC=9D=98=20=EB=A8=BC=20?= =?UTF-8?q?=EC=9B=90=EC=97=90=EB=8F=84=20=EC=98=85=EC=9D=80=20=EB=B9=9B=20?= =?UTF-8?q?=EB=B2=88=EC=A7=90=EC=9D=84=20=EB=82=A8=EA=B8=B4=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_01Fz23eRpkazDP4tpajkAeja --- incoming/pattern-study/patterns/hopf-fibration.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/incoming/pattern-study/patterns/hopf-fibration.js b/incoming/pattern-study/patterns/hopf-fibration.js index c749dab53..6212bfe0a 100644 --- a/incoming/pattern-study/patterns/hopf-fibration.js +++ b/incoming/pattern-study/patterns/hopf-fibration.js @@ -227,16 +227,18 @@ export function draw(display, params) { for (let i = 1; i <= NB; i++) bin[i] += bin[i - 1]; for (let s = 0; s < ns; s++) ord[bin[key[s]]++] = s; // Far to near. A fibre is lit 1 near the camera and a quarter far off, - // and its core is two LEDs wide near and one far. A slow swell of light - // runs round every fibre at the same rate: the circle action of the - // fibration, which moves each point of the 3-sphere along its own fibre. + // and its core is two LEDs wide near and one far; even far off it keeps a + // quarter-LED halo, so thin strands fade into the dark instead of stepping + // off it. A slow swell of light runs round every fibre at the same rate: + // the circle action of the fibration, which moves each point of the + // 3-sphere along its own fibre. // Each fibre's swell starts from its own longitude so the whole picture // never dims at once. for (let r = ns - 1; r >= 0; r--) { const s = ord[r], a = sa[s], b = sb[s], f = floor(a / NT), c = f * 3, lf = lit[f]; const dn = cl(((sz[a] + sz[b]) / 2 - (D - 3)) / 6, 0, 1) * (lf > 1 ? 2 - lf : 1); const v = (1 - 0.75 * dn) * lf * (0.72 + 0.28 * cos((TAU * (a % NT)) / NT + P.pp[f] - 1.6 * T)); - line(buf, sx[a], sy[a], sx[b], sy[b], min(255, col[c] * v), min(255, col[c + 1] * v), min(255, col[c + 2] * v), 0.25 + 0.6 * (1 - dn), 0.05 + 0.65 * (1 - dn), lf < 1 ? lf * lf : 1); + line(buf, sx[a], sy[a], sx[b], sy[b], min(255, col[c] * v), min(255, col[c + 1] * v), min(255, col[c + 2] * v), 0.25 + 0.6 * (1 - dn), 0.25 + 0.65 * (1 - dn), lf < 1 ? lf * lf : 1); } 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 5f8af8f8c927d701c9a821dc8b4e673235e53071 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:58:28 +0000 Subject: [PATCH 12/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20README=20=EC=97=90=2029=ED=8C=90=20=ED=91=9C?= =?UTF-8?q?=EC=99=80=20=EC=97=B0=EA=B5=AC=20=EB=85=B8=ED=8A=B8=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_01Fz23eRpkazDP4tpajkAeja --- incoming/pattern-study/README.md | 83 +++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index 326695810..870824c90 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 188개를 쉰세 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. 23판도 열 갈래에 하나씩 열 개를 더했고, 무대·공연과 짐승 갈래를 새로 열었어요. 24판도 열 갈래에 하나씩 열 개를 더했고, 공사·건설과 도자기 갈래를 새로 열었어요. 25판도 열 갈래에 하나씩 열 개를 더했고, 농사·밭과 화산 갈래를 새로 열었어요. 26판도 열 갈래에 하나씩 열 개를 더했고, 얼음·극지와 미생물 갈래를 새로 열었어요. 27판도 열 갈래에 하나씩 열 개를 더했고, 철도와 필름·영화 갈래를 새로 열었어요. 28판은 주제 표가 수학 쪽으로 바뀐 첫 판이라 이름 있는 수학 대상 열 개를 열 갈래에 하나씩 더했고, 수론 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 198개를 쉰다섯 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. 23판도 열 갈래에 하나씩 열 개를 더했고, 무대·공연과 짐승 갈래를 새로 열었어요. 24판도 열 갈래에 하나씩 열 개를 더했고, 공사·건설과 도자기 갈래를 새로 열었어요. 25판도 열 갈래에 하나씩 열 개를 더했고, 농사·밭과 화산 갈래를 새로 열었어요. 26판도 열 갈래에 하나씩 열 개를 더했고, 얼음·극지와 미생물 갈래를 새로 열었어요. 27판도 열 갈래에 하나씩 열 개를 더했고, 철도와 필름·영화 갈래를 새로 열었어요. 28판은 주제 표가 수학 쪽으로 바뀐 첫 판이라 이름 있는 수학 대상 열 개를 열 갈래에 하나씩 더했고, 수론 갈래를 새로 열었어요. 29판은 주제 표가 수학 여섯과 화장품 팝업 넷으로 나뉜 첫 판이라, 이름 있는 수학 대상 여섯과 화장품 매장 벽에 틀 질감 넷을 더했고, 위상·매듭과 화장품 팝업 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. 패널은 세로로 서요. 64 LED 너비에 128 LED 높이가 기본이라서 모든 패턴의 머리에 `@matrix 64x128` 이 있어요. 레포의 패널 설정은 그대로예요. `shared/constants.json` 의 `PANEL_GEOM_DEFAULT` 는 `128x64_SINGLE` 이고, 패턴이 가로세로를 바꾼 판을 적으면 펌웨어의 `firmware/patternflow/src/core_canvas.h` 가 90도 돌려 패널을 꽉 채워요. 1판 23개는 처음에 가로 128×64 로 만들었다가 2판에서 세로로 다시 짰어요. @@ -324,6 +324,21 @@ | [Stadium Billiard](patterns/stadium-billiard.js) | 물리·스프링 | 공 이백 개가 방향을 1000분의 3 라디안 안에서만 달리해 한 점에서 흰 구슬 하나로 떠나요. 패널만 한 부니모비치 경기장의 둥근 끝이 구슬을 무지개 선으로 늘이고 접어서, 몇 초 만에 공들이 기체처럼 판을 채워요. K3 을 돌리면 탁자가 원이 되어 공이 흩어지지 않고, 누르면 새 무리가 떠나요. | | [Wythoff Nim](patterns/wythoff-nim.js) | 알고리즘 | 위토프 게임의 스프라그-그런디 값 표를 모서리로 세웠어요. 지는 자리(값 0)는 황금비 두 줄을 따라 금빛이고, 값 0, 1, 2 가 차례로 쓸고 지나가며 저마다의 두 줄에서 번쩍였다 표로 가라앉아요. 누르면 흰 말이 떨어져 이기는 수로 모서리까지 게임을 해요. | +### 29판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Cream Swirl](patterns/cream-swirl.js) | 화장품 팝업 | 보이지 않는 팔레트 나이프가 크림을 S자와 소용돌이로 펴 바르며 매끈한 띠와 윤기 나는 둔덕을 남기고, 떨어진 색 크림 덩이를 길쭉한 리본으로 끌고 가요. 부드러운 빛이 7초에 한 번 좌우로 오가며 결 위로 윤이 미끄러져요. K3 을 올리면 섞이는 색조가 셋까지 늘고, 누르면 가장 진한 색 덩이가 칼 앞에 떨어져요. | +| [Dyson Gas](patterns/dyson-gas.js) | 입자 | 고유값 열 개가 맨 위 줄에 구슬로 떨며 서로 밀어내고, 그 지나온 길이 실이 되어 아래로 흘러요. 실은 가운데에 빽빽하고 가장자리로 갈수록 성기며 서로 건너지 않아요. K3 은 행렬 크기(10~28)이고, K1 을 누르면 밀어내는 힘이 3초쯤 꺼져 실이 엉키며 건너요. | +| [Glitter Fall](patterns/glitter-fall.js) | 화장품 팝업 | 검은 화면 위로 분홍 글리터가 흔들리는 줄기로 쏟아지고, 육각 조각 하나하나가 제 박자로 돌다가 면이 빛을 향할 때 반짝여요. 바닥에 쌓인 더미는 바닥 틈으로 모래시계처럼 빠져요. K1 은 색상환을 다 돌고, 누르면 더미 윗층이 공중으로 튀어 올라요. | +| [Hopf Fibration](patterns/hopf-fibration.js) | 위상·매듭 | 3차원 구면을 채운 원들을 입체 사영해 원근으로 그려요. 같은 위도의 밑점에서 온 원들이 겹겹의 토러스로 소용돌이치고, 가운데 세로선이 무한을 지나는 원이에요. K3 은 토러스 수(5~7)이고, K1 을 누르면 두 원만 남아 한 번 얽힌 것이 보여요. | +| [J-Invariant](patterns/j-invariant.js) | 프랙탈 | 클라인의 j 함수를 위 반평면에 칠해요. j 가 위쪽 값으로 보내는 삼각형은 옅은 금색, 아래쪽 값으로 보내는 삼각형은 짙은 파랑이라 같은 타일이 패널 아래 실수축으로 갈수록 잘게 되풀이되고, j 의 크기를 나눈 등고선 띠가 영점마다 흘러나와요. K3 을 돌리면 실수축 쪽으로 여덟 배까지 다가가고, K1 을 누르면 빛이 맨 위 타일부터 세대마다 아래로 번져요. | +| [Loose Powder](patterns/loose-powder.js) | 화장품 팝업 | 꽃 무늬가 눌린 분홍 압축 파우더 위로 보이지 않는 퍼프가 그림자로만 내려와 자국을 내고, 들릴 때 하얀 가루 구름이 운모 반짝임과 함께 피어 위로 흘러가요. K3 을 올리면 무늬 비탈에 새틴 광이 돌고, 누르면 퍼프가 바로 내려와요. | +| [Perron Tree](patterns/perron-tree.js) | 기하·대칭 | 키 120 LED 삼각형을 2^n 조각으로 잘라 페론처럼 한 단씩 겹쳐 밀면, 모든 방향을 품은 채 넓이가 30% 로 줄어든 나무가 돼요. 흰 바늘이 모든 방향을 돌며 싹에서 싹으로 건너뛰어요. K3 은 단 수(32~256 조각)이고 K1 을 누르면 삼각형부터 다시 지어요. | +| [Serum Gel](patterns/serum-gel.js) | 화장품 팝업 | 연보라 젤 속에 진주와 캡슐, 기포가 떠서 천천히 흔들리고, 뒤쪽 벽에는 젤 표면을 지난 빛이 부드러운 그물로 어른거려요. K3 은 구슬 수를 여덟에서 마흔까지 바꾸고, 누르면 젤이 크게 출렁이며 구슬이 늘어났다 줄어들며 흔들리다 가라앉아요. | +| [Seven Shuffles](patterns/seven-shuffles.js) | 알고리즘 | 카드 한 벌을 옆에서 본 무지개 막대 더미를 리플 셔플로 섞어요. 한 번 섞을 때마다 그러데이션이 2, 4, 8 갈래로 엇갈려 끼다가 일곱 번쯤에 색종이처럼 흩어지고, 그다음 기수 정렬로 되돌려 다시 시작해요. K4 를 끝까지 돌리면 완벽한 파로 셔플이 되어 32장은 다섯 번 만에 처음 순서로 돌아와요. | +| [Zeta Loops](patterns/zeta-loops.js) | 선·궤적 | 리만 제타 함수를 임계선 위에서 t 를 올리며 그려요. 실수축이 패널 위로 서 있고, 밝은 머리가 고리를 돌다가 영점마다 원점을 정확히 지나며 원점에서 고리가 퍼져요. K3 으로 임계선에서 벗어나면 고리가 원점을 비껴가고, K1 을 누르면 머리가 다음 영점까지 달려가요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -1254,6 +1269,50 @@ E8 의 근 240개를 고셋의 4₂₁ 다면체 꼭짓점으로 그렸어요. 위토프 게임의 스프라그-그런디 값 표예요. 지는 자리(값 0)는 (⌊nφ⌋, ⌊nφ²⌋) 와 그 거울이라 황금비 두 줄을 따르고, 다른 값 k 도 저마다 그 기울기 언저리의 두 줄에 모이며 주기 없이 흔들려요. 처음에는 x 를 가로, y 를 아래로 그렸더니 가파른 줄이 103번 줄에서 패널을 나가고 왼쪽 아래가 빈 안개였어요. 표를 45° 돌려(u = x−y, v = x+y) 세우자 두 줄이 세로에서 ±13° 로 맞서서 아래 두 귀퉁이까지 닿았어요. 두 더미에서 같이 덜어 내는 수는 그 덕에 곧장 위를 가리켜요. 처음 색표는 천천히 올라가서 값 0~5 가 한 금빛 띠로 읽혔고 정확한 0 줄이 묻혔어요. 0 에만 밝은 금빛을 주고 1 부터 진홍으로 빨리 올리자 풀렸어요. 정수가 아닌 Zoom 은 들쭉날쭉한 무아레 마름모를 만들어서 정수로 반올림했어요. 쓸기는 값 K = 12(e^(2.5t/12) − 1) 로 가서 Speed 2 에서 3초에 10, 8초에 50 에 닿아요. K2 Speed 의 반응 0.04 가 넷 가운데 가장 약해요. `thirds` 는 62/100/100 이에요. +## 29판 + +29판은 주제 표가 수학 여섯과 화장품 팝업 넷으로 나뉜 첫 판이에요(#216). 주제 5 「낯선 수학」으로 Hopf Fibration, J-Invariant, Dyson Gas, Perron Tree, Zeta Loops, Seven Shuffles 여섯을, 주제 6 「화장품 브랜드 팝업」으로 Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷을 냈어요. 「낯선 수학 예」에서는 모듈러 형식과 호프 올뭉치 둘을 골랐고, 나머지 넷은 28판의 열 개와 겹치지 않게 목록 밖에서 찾았어요. 주제 2 로 위상·매듭 갈래를 Hopf Fibration 으로 열었어요. 화장품 팝업 갈래는 주제 6 의 몫이라 주제 2 로 세지 않고 이 판이 처음 열었어요. 주제 1 은 이번에도 겨눌 `*` 칸이 없었어요. 기준 188개에서 21장 모두 빈 칸이 없어서 패턴이 하나뿐이던 칸 둘을 겨눴어요. Hopf Fibration 이 Mandelbrot 혼자이던 가장자리 × 움직임 1,4 에, J-Invariant 가 Spectrogram 혼자이던 복잡도 × 색 4,3 에 섰어요. 패턴이 하나뿐인 칸은 30 에서 24 가 됐고, 새로 혼자 선 칸은 없어요. 주제 4 는 Dyson Gas 와 Perron Tree, Glitter Fall 셋이에요. Cream Swirl 과 Serum Gel 이 가장자리 축의 아래 끝을 Fire 의 0.120 에서 0.028 로 내려서, 가장자리 × 촘촘함 1,2 에 23판 뒤로 처음 `*` 빈 칸이 났어요. 주제 3 은 앞 바퀴 PR 에 창작자 글이 없어서 하지 않았어요. + +### Hopf Fibration + +주제 5 의 둘째 「낯선 수학 예」로 호프 올뭉치를 골랐고, 주제 2 로 위상·매듭 갈래를 이 패턴으로 열었어요. 3차원 구면의 원 하나하나를 입체 사영한 뒤 원근으로 그리고, 가까운 원은 밝고 두껍게, 먼 원은 4분의 1 밝기로 그렸어요. 같은 위도의 밑점에서 온 원들이 토러스 하나를 이루고, 한 극 위의 원은 무한을 지나 패널 가운데의 세로선이 돼요. 처음에는 토러스 셋에 원 아홉 개씩으로 열었더니 가운데 작은 덩어리와 떠도는 고리 몇 개뿐이었어요. 토러스 다섯에 원 열네 개씩으로 열게 바꾸자 채움이 0.377 에서 0.579 로, `thirds` 가 21/73/18 에서 36/92/44 로 올랐어요. 원이 70개가 되면서 한 프레임이 무거워져서, 줄마다 구간을 푸는 대신 선분의 긴 축을 따라 한 LED 씩 걸어 그리자 가장 무거운 노브에서 2.2 ms 가 됐어요. 주제 1 로는 Mandelbrot 혼자이던 가장자리 × 움직임 1,4 를 겨눴어요. 그 판은 움직임 1.355 로 그 칸 바로 아래라 시간을 1.3 배로 돌려 1.650 을 냈는데, 같은 판의 크림과 젤이 가장자리 칸 경계를 0.305 에서 0.232 로 내려서 0.233 이 걸렸어요. 먼 원에도 4분의 1 LED 빛 번짐을 남기자 가장자리 0.207, 움직임 1.607 로 그 칸에 섰어요. 채움은 0.588, `thirds` 는 37/93/45 예요. 원이 서로 얽힌 것은 가까운 선이 먼 선 위를 지나며 남기는 한 LED 의 어두운 틈으로 보였어요. K1 을 누르면 두 원만 남아 한 번 얽힌 것이 보여요. + +### J-Invariant + +주제 5 로 「낯선 수학 예」의 모듈러 형식을 골랐어요. 실수축을 패널 바닥 반 LED 아래에 두고, LED 마다 그 점을 τ → τ + n 과 τ → −1/τ 로 기본 영역에 데려가 q 급수 다섯 항으로 E4³/Δ 를 셈해요. j(i) = 1728 과 j(2i) = 287496 이 1e-9 안에서 맞았어요. 처음에는 arg j 를 색상환 한 바퀴에 얹었는데, 패널에서는 무지개 플라즈마로만 보이고 타일이 읽히지 않았어요. 그때 색이 0.555 였어요. j 가 실수인 선이 클라인의 (2,3,∞) 삼각형 경계라서, 위쪽 값으로 가는 삼각형은 옅은 금색, 아래쪽 값으로 가는 삼각형은 짙은 파랑으로 나누자 테셀레이션이 바로 보였고 색은 0.400 이 됐어요. ρ 의 상마다 여섯 갈래 바람개비가, i 의 상마다 네 갈래 십자가 서요. 실수축 바로 위 123~125 줄에 남던 낱 LED 반짝임은 축 위 아홉 줄에 걸친 페이드로 지웠어요. 주제 1 로는 복잡도 2.693, 색 0.400 으로 Spectrogram 혼자이던 복잡도 × 색 4,3 에 서요. 복잡도는 축 끝 Contour Map 의 2.735 바로 아래예요. 28판은 모듈러 형식의 등고선이 빠르게 흐르면 가장자리 × 움직임 1,4 에 들 수 있다고 적었는데, 띠가 2초에 하나씩 흐르는 이 판은 움직임이 0.372 예요. 그 칸에 들려면 움직임이 네 배는 돼야 해서, 그 칸은 Hopf Fibration 이 맡았어요. 노브는 0.35, 0.24, 0.26, 0.07 이에요. + +### Dyson Gas + +주제 5 와 4 를 같이 겨눴어요. 다이슨 브라운 운동의 고유값이 서로 밀어내는 모습을 세로 128줄에 세계선으로 그렸어요. 고유값마다 다른 고유값에게서 β/(λi−λj) 로 밀려나고 오른스타인-울렌벡 당김을 받아 위그너의 반원 분포로 모여서, 실은 가운데에 빽빽하고 양옆으로 성겨요. 처음에는 한 줄에 과정을 많이 담아서 실이 줄마다 1.3 LED 씩 떨렸고, 패널에서는 실이 아니라 지글거리는 잡음으로 보였어요. 한 줄에 담는 시간을 줄이고 3줄쯤 늦게 따라오는 노출을 걸자 움직임이 2.71 에서 1.329 로 내려오고 실이 땋은 끈처럼 읽혔어요. 순서를 지킨 값들의 평균은 순서를 지키니까, 이 노출 때문에 실이 건너는 일은 없어요. `thirds` 는 41/37/33 이고 노브는 0.13, 0.15, 0.23, 0.13 이에요. Beta 노브는 처음에 0.05 였는데, 첫 화면의 지난 줄을 setup 의 β 대신 지금 노브 값으로 다시 짓자 0.13 이 됐어요. + +### Perron Tree + +주제 5 와 4 로 가케야 바늘 문제의 페론 나무를 짓는 과정을 그렸어요. 너비 60, 높이 120 LED 의 삼각형 밑변을 2^n 조각으로 자르고, 단마다 이웃 조각을 겹쳐 밀어요. 스타인과 쇤베르크가 다시 쓴 페론의 짓기예요. 조각마다 제 방향의 색을 칠하고 겹친 곳의 빛을 더해서, 조각이 다 모이는 허리는 희게 빛나고 위로는 싹들이 부채처럼 퍼져요. 흰 바늘은 모든 방향을 돌며 그 방향을 품은 싹으로 건너뛰어요. 처음 범위에서는 다 지은 나무가 거의 뒤집힌 삼각형이라, 미는 비율 g 를 0.79 로 열어 줄기와 가지가 갈리는 모래시계 꼴로 바꿨어요. 넓이를 직접 재니 5단에서 삼각형의 30%, 8단에서도 24% 였어요. 256 조각으로는 넓이가 0 에 다가가지 않아요. 한 LED 보다 가는 싹은 사라져서, 한 LED 너비에 폭의 제곱근만큼의 밝기로 그렸더니 끝까지 가늘어지며 보였어요. `thirds` 는 30/25/25 이고 싹 끝이 8번 줄까지 닿아요. + +### Glitter Fall + +주제 6 과 4 를 같이 겨눴어요. 위에서 쏟아져 128줄을 채우는 글리터예요. 조각마다 제 회전 위상과 빠르기가 있어서, 반짝임은 프레임마다 새로 뽑지 않고 그 회전의 매끈한 함수로 와요. 바탕은 15/255 아래라서 `thirds` 21/21/72 는 다 글리터가 켠 LED 예요. 처음에는 더미 알갱이를 한 칸씩 옮겨서, 바닥 틈이 열리면 세로 구멍이 뚫린 채 벽이 무너지지 않았어요. 기둥 높이로 바꿔 한 걸음에 세 알까지 미끄러지게 하자 45도 비탈과 모래시계 깔때기가 나왔어요. 조각이 초당 25줄로 떨어지며 한 바퀴에 두 번 반짝일 때는 움직임이 2.06 이었고, 초당 12~18줄로 늦추고 한 번만 반짝이게 하자 1.382 가 됐어요. 밝은 크림색 바탕도 해 봤는데, 반짝임이 200 위에 55 만 더해져 거의 안 보였어요. 그래서 바탕을 어둡게 뒀어요. + +### Zeta Loops + +주제 5 로 리만 제타 함수를 임계선 위에서 그렸어요. 28판이 「다음 판 감」에 적은 하디의 Z(t) 와 같은 함수인데, 값을 실수 하나로 접지 않고 복소평면의 곡선으로 두었어요. 실수축이 패널 가운데를 따라 위로 서고, 밝은 머리가 고리를 돌다가 영점마다 원점을 정확히 지나요. 오일러-매클로린 합으로 셈해서 ζ(1/2 + 14.1347i) 가 1e-7 언저리로 나왔어요. 처음 판은 1 LED 굵기 선에 1 단위가 14 LED 라 켜진 LED 가 3% 였고 K1 반응이 0.01 이었어요. 1 단위를 26 LED 에서 시작해 t 가 오르면 15 LED 로 물러나게 하고 선을 두 LED 가까이 굵히자 채움 0.169, 노브 0.04~0.06 이 됐어요. 곡선은 t 가 60 아래일 때 실수 쪽과 허수 쪽 폭이 거의 같아서 위 3분의 1이 6% 만 켜지고, 그래서 주제 4 로 세지 않았어요. K3 으로 σ 를 1/2 에서 비키면 고리가 원점을 비껴가요. Trail 을 끝까지 돌리면 원점을 지나는 고리가 겹겹이 쌓여 장미 꼴이 돼요. + +### Seven Shuffles + +주제 5 로 베이어와 디아코니스의 「일곱 번이면 섞인다」(1992)를 글자 없이 보여 주려 했어요. 카드가 옆에서 본 막대로 쌓이고, 길버트-섀넌-리즈 리플마다 덱을 이항 분포로 자르고 두 묶음에서 묶음 크기에 비례해 한 장씩 떨어뜨려요. 한 번 섞을 때마다 처음 순서의 그러데이션이 2, 4, 8 갈래로 엇갈려 끼어요. 왼쪽 벽 한 줄은 그 덱 크기의 총변동 거리를 오일러 수로 정확히 셈해 띄워요. 52장에서 그들의 표와 같은 0.924, 0.614, 0.334, 0.167 이 나왔어요. 거리가 0.4 아래로 내려가면 섞기를 멈추고, 이진 기수 정렬로 거꾸로 풀어 처음 순서로 돌려요. K4 를 끝까지 돌리면 완벽한 파로 아웃셔플이 되어 128장은 일곱 번, 52장은 여덟 번 만에 저절로 처음 순서로 돌아와요. 처음에는 카드가 아래에서부터 차례로 떨어져서 한 묶음 안에서 아래 카드가 위 카드를 앞질렀어요. 두 묶음이 함께 늘어나게 바꾸고 오른쪽 묶음을 앞에 그리자 서로 지나가는 모양이 읽혔어요. 움직임은 3.420, `thirds` 는 55/58/54, 노브는 0.36, 0.32, 0.33, 0.22 예요. + +### Cream Swirl + +주제 6 으로 팔레트 나이프가 크림을 펴 바르는 손놀림을 그렸어요. 칼날이 지나간 LED 마다 띠의 단면과 옆 둔덕을 한 번씩 찍고, 칼날을 16칸으로 나눠 칸마다 긁어 온 크림과 그 색을 들고 가서 떨어진 색 덩이가 칼 뒤에 리본으로 끌려요. 처음에는 크림 높이를 질량대로 옮기고 경사가 0.55 를 넘으면 무너지게 했는데, 둔덕이 1~2 LED 폭의 선으로만 남아 석고 부조처럼 보였어요. 다음 판은 획 사이에 패널 대부분이 멈춰 있어서 움직임이 0.012 였어요. 연구에서 가장 고요하던 Star Trails 의 0.019 보다도 낮았어요. 빛이 왼쪽 위를 가운데 두고 7초에 한 번 양옆 0.9 라디안씩 오가게 하고 획 사이 쉼을 0.15~0.35초로 줄이자 0.037 이 됐어요. 빛만 움직여서는 0.019 에 머물렀어요. 평평한 크림은 빛이 돌아도 바뀌는 LED 가 없어서, 칼 떨림 자국을 1.3 배 깊게 하고 그림자 쪽 대비를 올린 것이 같이 필요했어요. 초록에 Tones 3 을 두면 덩이 셋의 색이 칼날을 따라 옆으로 번져 흐린 얼룩이 됐고, 끌리는 색을 칼날의 제 칸에만 두자 길쭉한 리본으로 남았어요. 가장자리는 0.028 로 연구에서 가장 번지는 그림이고, 노브는 0.21, 0.07, 0.10, 0.11 이에요. + +### Serum Gel + +주제 6 으로 세럼이나 젤 크림의 접사 영상에서 보이는 떠 있는 구슬과 빛 그물을 겨눴어요. 빛을 LED 보다 1.5 배 촘촘한 격자에서 쏘아 젤 표면의 기울기만큼 꺾은 뒤 뒷벽에 쌓아서, 빛이 몰린 곳이 밝은 그물이 돼요. 처음에는 빛이 0.2 LED 밖에 꺾이지 않아 그물이 안 보였고, 1.1~1.9 LED 로 꺾자 그물이 섰어요. 구슬은 저마다 젤에 더 무른 용수철로 매달려 있어서, 누르면 젤보다 늦게 흔들리며 움직이는 쪽으로 18% 까지 늘어나요. 색상환을 돌려 보니 주황과 노랑 젤은 밝기 0.62 와 0.3 에서 갈색과 올리브색으로 보여서, 그 둘레에서만 밝기를 0.92 와 0.68 로 올려 꿀색으로 맞췄어요. 꺾임을 2 LED 간격 격자에서 셈하게 바꿔 한 프레임이 5.3 ms 에서 2.1 ms 가 됐어요. 움직임은 0.179, 색은 0.213, 노브는 0.37, 0.16, 0.08, 0.08 이에요. + +### Loose Powder + +주제 6 으로 압축 파우더의 양각 무늬와 퍼프가 남기는 구름을 겨눴어요. 꽃잎 여덟 장의 메달 무늬가 위 115줄을 채우고, 그 아래 흰 띠에 금색 한 줄이 패키지처럼 지나가요. 퍼프는 그리지 않고 다가오는 그림자와 눌린 자국으로만 보여요. 가루 알갱이마다 높이를 줘서 그림자가 높이만큼 비켜 떨어지니, 바로 위에서 내려다본 그림인데도 구름이 떠오르고 내려앉는 것이 읽혀요. 처음 파우더는 명도 225, 채도 0.42 라서 하얀 구름이 거의 안 보였고, 205 와 0.50 으로 바꾸자 구름이 피는 순간이 보였어요. Shimmer 는 운모 점만 늘릴 때 반응이 0.0041 로 검사에 걸렸고, 빛을 향한 무늬 비탈에 새틴 광을 더하자 0.05 가 됐어요. 움직임은 0.076, 모양 바뀜은 0.052 예요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -1296,19 +1355,19 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 24일 `dev`(`d99e838`)의 178개에 28판의 열 개를 더한 188개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 24일 `dev`(`796f035`)의 188개에 29판의 열 개를 더한 198개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 `study.ts --check --gaps` 가 `*` 를 붙인 칸 가운데 그림이 떠오르는 칸이에요. 칸은 가로와 세로를 낮은 쪽부터 1~4 로 세요. 페이지 기본 지도(가장자리 × 움직임)는 16칸이 다 차 있어요. -23판 뒤로는 `*` 가 붙은 칸이 없어요. 22판까지 남은 두 칸, 촘촘함 × 모양 바뀜 3,4 와 4,3 에 23판의 Bait Ball 과 Sequins 가 섰어요. 하나 남은 빈 칸이던 복잡도 × 채움 4,3 에는 24판의 Waggle Dance 가 섰어요. 복잡도가 2.173 을 넘고 채움이 0.558~0.815 인 자리예요. 복잡도 축 끝의 Contour Map(2.735)을 빼고 세면 차는 칸이라 `*` 가 붙지 않았고, 그 아래 복잡도 2.4~2.6 에 선 Woodpecker, Dripstone, Agate, Guilloché 는 채움이 다 0.93 을 넘었어요. 이제 지도 21장의 칸이 다 차서 `--gaps` 는 모든 줄에 `none` 을 찍어요. 주제 1 은 축 끝의 패턴 하나가 축을 늘려 새 빈 칸이 날 때까지 고를 칸이 없어요. Waggle Dance 에서 어려운 쪽은 채움이었어요. 벌집은 꺼진 LED 가 거의 없는 그림이라, 빈 방 바닥과 봉한 방 사이 이음매를 24 아래로 눌러 채움 0.732 를 냈어요. 복잡도는 켜진 LED 의 세 채널을 따로 흔드는 낟알로 쉽게 올라서, 첫 판은 오히려 2.766 으로 축 끝을 넘었어요. 25판도 기준 148개의 21장이 다 `none` 이라 주제 1 은 겨눌 칸이 없었어요. 그 대신 패턴이 하나만 선 칸을 셌어요. 148개에서 49칸이었고, 남은 자리로 짠 Penrose 가 그 가운데 여섯 칸에 둘째로 서서 158개에서는 43칸이에요. Penrose 가 겨눈 칸은 촘촘함 × 색 3,4 예요. 촘촘함 0.195~0.299 에 색 0.509 위인 자리이고, 4판의 Jacquard Loom 혼자 서 있었어요. Penrose 는 촘촘함 0.264, 색 0.583 으로 거기 섰어요. 같은 값으로 움직임 × 촘촘함 4,3 과 촘촘함 × 채움 3,3 의 Moire Rings, 촘촘함 × 복잡도 3,2 의 Peg Light, 복잡도 × 색 2,4 의 Gear Train, 채움 × 색 3,4 의 Marbling 곁에도 섰어요. 1 LED 어두운 선으로 나눈 작은 타일에 선명한 색을 여럿 두면 촘촘함과 색이 함께 높은 자리에 닿아요. 26판도 기준 158개의 21장이 다 `none` 이었어요. 패턴이 하나뿐인 칸 43 가운데 넷을 겨눴어요. Dark Field 는 검은 바탕 위의 흩은 빛으로 채움 0.263 에 촘촘함 0.222, 복잡도 2.081 이라, Foxfire 혼자이던 복잡도 × 채움 3,1 과 Peg Light 혼자이던 촘촘함 × 채움 3,1 에 씨앗 여덟이 다 들어요. Origami 는 어두운 탁자 위 종이 한 장이라 채움 0.264 이고, 2초의 정사각형이 8초에 좁은 학 기본형이 되어 모양 바뀜 0.255 로 Domino Run 혼자이던 채움 × 모양 바뀜 1,3 에 서요. Lichtenberg 는 Rabbit Ears 혼자이던 복잡도 × 모양 바뀜 4,3 에 씨앗 여덟 가운데 일곱이 들어요. 네 칸 말고는 바뀐 칸이 없어서, 168개에서 패턴이 하나뿐인 칸은 39칸이에요. 27판도 기준 168개의 21장이 다 `none` 이었고, 39칸 가운데 둘을 겨눴어요. Mitosis 는 채움 0.263, 색 0.540 으로 Neon Alley 곁의 채움 × 색 1,4 에 서요. 판의 4분의 1만 켜는 그림에서 색 0.5 를 넘기려면 켜진 LED 가 거의 다 순색 최대 밝기여야 해서, 형광의 번짐은 셈에 들지 않는 21/255 아래로 눌렀어요. Lava Lake 는 복잡도 2.222, 채움 0.271 로 Moth Lamp 곁의 복잡도 × 채움 4,1 에 서요. 복잡도는 프레임 바이트 값의 엔트로피를 거의 그대로 따라서, 어두운 지각의 결을 채널마다 0~24 전부로 넓히자 1.866 이 2.010 이 됐어요. 붉은 용암은 켜진 LED 하나에 24 를 넘는 바이트가 빨강 하나뿐이라, 이음매를 늘리기보다 주황 쪽으로 데우는 편이 채움을 덜 쓰고 복잡도를 벌었어요. 색 축의 아래 끝은 Cloud Chamber 의 0.052, 모양 바뀜 축의 아래 끝은 Climbing Wall 의 0.021 로 옮겨졌지만 혼자 서는 칸은 그대로라서, 178개에서 패턴이 하나뿐인 칸은 37칸이에요. 28판도 기준 178개의 21장이 다 `none` 이었고, 37칸 가운데 둘을 겨눴어요. Aztec Diamond 는 촘촘함 0.350, 색 0.640 으로 Millefiori 곁의 촘촘함 × 색 4,4 에 서요. 촘촘함은 가장 밝은 채널로 재서 네 색 도미노가 맞닿아도 턱이 없었고, LED 에 묶은 체커판이 그 턱을 줬어요. Stadium Billiard 는 움직임 2.362, 채움 0.186 으로 Warp 곁의 움직임 × 채움 4,1 에 서요. 공 이백 개가 1초에 125 LED 를 가면서 짧은 자취만 남겨요. 겨누지 않은 다섯 칸에도 둘째가 들어서 188개에서 패턴이 하나뿐인 칸은 30칸이에요. 남은 칸 가운데 수학으로 겨눠 볼 만한 것은 가장자리 × 움직임 1,4(Mandelbrot 혼자)예요. 번지면서 바쁜 자리라, 모듈러 형식의 영역 색칠에서 |f| 의 등고선이 빠르게 흘러가면 들어설 수 있어요. 색상만 도는 것은 움직임이 거의 없어요. 움직임은 가장 밝은 채널의 바뀜만 세요. +23판 뒤로는 `*` 가 붙은 칸이 없어요. 22판까지 남은 두 칸, 촘촘함 × 모양 바뀜 3,4 와 4,3 에 23판의 Bait Ball 과 Sequins 가 섰어요. 하나 남은 빈 칸이던 복잡도 × 채움 4,3 에는 24판의 Waggle Dance 가 섰어요. 복잡도가 2.173 을 넘고 채움이 0.558~0.815 인 자리예요. 복잡도 축 끝의 Contour Map(2.735)을 빼고 세면 차는 칸이라 `*` 가 붙지 않았고, 그 아래 복잡도 2.4~2.6 에 선 Woodpecker, Dripstone, Agate, Guilloché 는 채움이 다 0.93 을 넘었어요. 이제 지도 21장의 칸이 다 차서 `--gaps` 는 모든 줄에 `none` 을 찍어요. 주제 1 은 축 끝의 패턴 하나가 축을 늘려 새 빈 칸이 날 때까지 고를 칸이 없어요. Waggle Dance 에서 어려운 쪽은 채움이었어요. 벌집은 꺼진 LED 가 거의 없는 그림이라, 빈 방 바닥과 봉한 방 사이 이음매를 24 아래로 눌러 채움 0.732 를 냈어요. 복잡도는 켜진 LED 의 세 채널을 따로 흔드는 낟알로 쉽게 올라서, 첫 판은 오히려 2.766 으로 축 끝을 넘었어요. 25판도 기준 148개의 21장이 다 `none` 이라 주제 1 은 겨눌 칸이 없었어요. 그 대신 패턴이 하나만 선 칸을 셌어요. 148개에서 49칸이었고, 남은 자리로 짠 Penrose 가 그 가운데 여섯 칸에 둘째로 서서 158개에서는 43칸이에요. Penrose 가 겨눈 칸은 촘촘함 × 색 3,4 예요. 촘촘함 0.195~0.299 에 색 0.509 위인 자리이고, 4판의 Jacquard Loom 혼자 서 있었어요. Penrose 는 촘촘함 0.264, 색 0.583 으로 거기 섰어요. 같은 값으로 움직임 × 촘촘함 4,3 과 촘촘함 × 채움 3,3 의 Moire Rings, 촘촘함 × 복잡도 3,2 의 Peg Light, 복잡도 × 색 2,4 의 Gear Train, 채움 × 색 3,4 의 Marbling 곁에도 섰어요. 1 LED 어두운 선으로 나눈 작은 타일에 선명한 색을 여럿 두면 촘촘함과 색이 함께 높은 자리에 닿아요. 26판도 기준 158개의 21장이 다 `none` 이었어요. 패턴이 하나뿐인 칸 43 가운데 넷을 겨눴어요. Dark Field 는 검은 바탕 위의 흩은 빛으로 채움 0.263 에 촘촘함 0.222, 복잡도 2.081 이라, Foxfire 혼자이던 복잡도 × 채움 3,1 과 Peg Light 혼자이던 촘촘함 × 채움 3,1 에 씨앗 여덟이 다 들어요. Origami 는 어두운 탁자 위 종이 한 장이라 채움 0.264 이고, 2초의 정사각형이 8초에 좁은 학 기본형이 되어 모양 바뀜 0.255 로 Domino Run 혼자이던 채움 × 모양 바뀜 1,3 에 서요. Lichtenberg 는 Rabbit Ears 혼자이던 복잡도 × 모양 바뀜 4,3 에 씨앗 여덟 가운데 일곱이 들어요. 네 칸 말고는 바뀐 칸이 없어서, 168개에서 패턴이 하나뿐인 칸은 39칸이에요. 27판도 기준 168개의 21장이 다 `none` 이었고, 39칸 가운데 둘을 겨눴어요. Mitosis 는 채움 0.263, 색 0.540 으로 Neon Alley 곁의 채움 × 색 1,4 에 서요. 판의 4분의 1만 켜는 그림에서 색 0.5 를 넘기려면 켜진 LED 가 거의 다 순색 최대 밝기여야 해서, 형광의 번짐은 셈에 들지 않는 21/255 아래로 눌렀어요. Lava Lake 는 복잡도 2.222, 채움 0.271 로 Moth Lamp 곁의 복잡도 × 채움 4,1 에 서요. 복잡도는 프레임 바이트 값의 엔트로피를 거의 그대로 따라서, 어두운 지각의 결을 채널마다 0~24 전부로 넓히자 1.866 이 2.010 이 됐어요. 붉은 용암은 켜진 LED 하나에 24 를 넘는 바이트가 빨강 하나뿐이라, 이음매를 늘리기보다 주황 쪽으로 데우는 편이 채움을 덜 쓰고 복잡도를 벌었어요. 색 축의 아래 끝은 Cloud Chamber 의 0.052, 모양 바뀜 축의 아래 끝은 Climbing Wall 의 0.021 로 옮겨졌지만 혼자 서는 칸은 그대로라서, 178개에서 패턴이 하나뿐인 칸은 37칸이에요. 28판도 기준 178개의 21장이 다 `none` 이었고, 37칸 가운데 둘을 겨눴어요. Aztec Diamond 는 촘촘함 0.350, 색 0.640 으로 Millefiori 곁의 촘촘함 × 색 4,4 에 서요. 촘촘함은 가장 밝은 채널로 재서 네 색 도미노가 맞닿아도 턱이 없었고, LED 에 묶은 체커판이 그 턱을 줬어요. Stadium Billiard 는 움직임 2.362, 채움 0.186 으로 Warp 곁의 움직임 × 채움 4,1 에 서요. 공 이백 개가 1초에 125 LED 를 가면서 짧은 자취만 남겨요. 겨누지 않은 다섯 칸에도 둘째가 들어서 188개에서 패턴이 하나뿐인 칸은 30칸이에요. 29판도 기준 188개의 21장이 다 `none` 이었고, 30칸 가운데 둘을 겨눴어요. 28판이 모듈러 형식의 등고선으로 겨눠 보라던 가장자리 × 움직임 1,4(Mandelbrot 혼자)는 J-Invariant 가 움직임 0.372 로 모자랐고, Hopf Fibration 이 가장자리 0.207, 움직임 1.607 로 섰어요. 원 일흔 개가 저마다 1초에 몇 LED 씩 쓸고 가서 움직임이 높고, 원마다 옅은 빛 번짐을 둬서 가장자리가 낮아요. J-Invariant 는 복잡도 2.693, 색 0.400 으로 Spectrogram 곁의 복잡도 × 색 4,3 에 서요. 같은 판의 Cream Swirl(0.028)과 Serum Gel(0.079)이 가장자리 축의 아래 끝을 Fire 의 0.120 에서 내려서, 가장자리 칸 경계가 0.305, 0.560, 0.815 에서 0.232, 0.514, 0.796 으로 옮겨졌어요. 그래서 198개에서는 가장자리 × 촘촘함 1,2 가 23판 뒤로 처음 `*` 가 붙은 빈 칸이에요. 가장자리 0.232 아래에 촘촘함 0.091~0.195 인 자리라, 번지는 바탕 위에 잔 결이 고르게 깔린 그림이 들어설 수 있어요. 같은 지도의 1,3 과 1,4, 가장자리 × 채움 1,1 도 비었지만 끝의 패턴 하나를 빼면 차서 `*` 가 없어요. 198개에서 패턴이 하나뿐인 칸은 24칸이에요. 촘촘함이 0.299 를 넘는 패턴이 20판에서 셋 늘었고, 셋 다 그 값을 다른 길로 냈어요. Agate 는 1 LED 띠를 벽까지의 거리로 그어서, 축에 나란한 띠는 이웃 쌍의 절반에서만 차이가 나고 대각선 띠는 70% 쯤에서 나요. 방향이 고르게 섞이면 띠 너비 0.85 LED 언저리가 가장 높았어요. Millefiori 는 촘촘함을 가장 밝은 채널로 재서 코발트와 카나리아 노랑이 맞닿아도 턱이 0 이었어요. 고리마다 밝은 유리와 그 유리를 어둡게 뽑은 유리를 번갈아 넣어야 했어요. Sunglint 는 반짝임을 물결 띠마다 체커판의 한쪽 칸에만 둬서 반짝임끼리 닿지 않게 했어요. 셋 다 채움이 0.85 를 넘어서, 촘촘함 × 채움 4,4 는 꺼진 LED 없이도 닿는 칸이었어요. 21판의 Sandpile 이 일곱째예요. 칸 하나가 LED 하나이고 알 셋 옆의 알 둘이 가장 흔한 이웃 쌍이라, 밝기를 0, 2, 1, 3 차례로 올려 더미 안의 촘촘함이 0.40 언저리예요. 판의 75% 는 덮여야 0.3 을 넘어요. 22판의 Pin Art 와 Night Flight 가 여덟째와 아홉째예요. Pin Art 는 핀과 틈의 체커 결이라 이웃 쌍이 모두 핀과 틈의 쌍이고, Night Flight 는 Pancakes 의 체커 결에 1 LED 가로등을 LED 의 12.7% 만큼 얹어서 가장자리 0.258 에 촘촘함 0.328 이에요. 촘촘함과 가장자리는 가장 밝은 채널로 재서, 색을 섞는 자리마다 섞은 뒤 가장 밝은 채널을 다시 맞춰야 했어요. 땅과 강물을 섞은 자리에서 가장 밝은 채널이 0.68 까지 내려가 체커 결의 37 이 41~60 으로 넘어갔고, 그대로 둔 판은 가장자리가 0.05 높았어요. 23판의 Sequins 가 열째예요. 체커 결 없이, 가로 5 LED 와 세로 3 LED 간격으로 엇갈려 단 스팽글의 빛점과 그림자 줄로 0.327 을 냈어요. 촘촘함은 무늬의 모양보다 턱의 크기를 따라가서, 스팽글 몸통을 0.8 에서 0.6 으로 어둡게 하자 은빛 면이 0.339 에서 0.311 로 내려갔어요. 26판의 Lichtenberg 는 0.2 를 넘긴 판을 버렸어요. 판 어디서나 돋는 1 LED 잔가지로 0.248 을 냈더니 검은 직교 그물이 나무판을 덮어 미로로 보였어요. 잔가지를 탄 줄기에만 달아 빈 나무를 42% 남기자 0.130 이었어요. 촘촘함은 판 전체 이웃 쌍의 평균이라, 빈 나무가 넓게 남는 그림은 줄기 둘레가 아무리 잘아도 평균이 낮아요. 같은 판의 Dark Field 는 검은 바탕에 세균 몇백과 밝은 테두리를 판 곳곳에 흩어 0.222 를 냈어요. 모양 바뀜이 높은 칸을 겨누면 움직이는 것과 바탕의 밝기가 달라야 해요. 3판의 Lava Lamp 는 방울이 화면 높이를 다 오르는데도 왁스와 물이 둘 다 밝아서 모양 바뀜이 0.068 이에요. 4판의 Jacquard Loom 도 천이 1초에 4 LED 씩 내려가는데, 씨실 여섯 색이 모두 밝기 끝까지라 0.041 이에요. 19판의 Rabbit Ears 도 첫 판에서는 그림이 눈 속으로 사라져도 블록 평균이 0.5 쯤으로 같아서 0.153 이었어요. 바다를 어둡게 하고 신호가 없을 때 눈을 밝게 올리자 0.286 이 됐어요. 모양 바뀜의 칸은 4 LED 너비에 16 LED 높이라서, 옆으로 쓸고 가는 것이 위아래로 오르내리는 것보다 네 배 잘 잡혀요. 7판의 Searchlights 가 그렇게 0.357 까지 갔어요. 색이 많아도 모양 바뀜은 밝기만 봐요. 11판의 Balloon Box 는 천의 모든 색을 밝기 끝에 둬서, 모양 바뀜과 가장자리는 풍선과 어두운 하늘의 차이에서만 와요. 16판의 Domino Run 은 꺼지는 쪽에서 모양 바뀜을 얻었어요. 넘어지는 도미노는 같은 4×16 칸 안에서 눕기만 해서 처음에 0.096 이었고, 낮은 램프가 누운 면을 스치게 하자 켜진 LED 가 0.44 에서 0.15 로 줄며 0.261 이 됐어요. 모양 바뀜은 2초 화면에서 멀어진 거리라 6초 동안 바뀐 양을 넘지 못해서, 움직임의 여섯 배가 끝이에요. 16판의 Lunation 은 2~8초 동안 어두워지는 LED 가 하나도 없게 짜서 움직임 0.058 에 모양 바뀜 0.348 로 그 끝에 섰어요. 아래 끝도 끝이에요. 모양 바뀜 축의 아래 끝은 Galton Board 의 0.025 이고, 17판의 열 개 가운데 여섯이 처음 판에 그 아래였어요. Ant Farm 0.010, One Bit 0.014, Marble Run 0.019, Circuit Board 0.021, Crossroads 0.023, Shoreline 0.029 예요. 바탕이 서 있고 작은 것만 움직이면 4×16 칸 평균이 거의 그대로라서, 블록만 한 것이 천천히 바뀌어야 해요. Ant Farm 은 젖은 모래 얼룩, Circuit Board 는 데워지는 선, Marble Run 은 구슬이 지나며 켜는 플라스틱 조각, One Bit 는 오르내리는 안개 둑으로 0.04~0.06 에 섰어요. 18판에서는 Server Rack 의 첫 판이 0.024 였어요. 깜박이는 LED 는 4×16 칸 안에서 평균이 지워져서, 백업이 쓴 드라이브의 빛길을 켜 둔 채 남기고 부하 물결을 넓혀 0.098 이 됐어요. 거꾸로 1초가 안 되는 섬광은 모양 바뀜을 크게 올려요. 모양 바뀜은 첫 화면에서 가장 멀어진 프레임 하나로 정해져서, 18판 Drop Tower 의 0.4초 남짓 보이는 브레이크 섬광이 0.087 을 0.163 으로 올렸어요. 19판에서는 Helter Skelter 의 첫 판이 0.021 이었어요. 전구 체이스가 나선을 따라 내려가며 미끄럼틀과 탑을 밝히고 8초마다 전구가 1.5초 다 켜지게 하자 0.069 가 됐어요. 21판의 Bread Mould 는 곰팡이로 어두워진 빵을 밝은 새 빵이 옆에서 밀어내는 일을 2.4초에 두어 3.5초에 0.356 에 닿았어요. 도마를 늙은 곰팡이와 새 속살 사이의 밝기 100 쯤에 두어서, 빵이 덮는 넓이만 모양 바뀜에 들어가요.아래 끝을 넘을 뻔한 패턴도 있었어요. 21판의 Slinky 는 250 LED 남짓한 스프링이 계단을 내려가기만 할 때 0.014 로 Galton Board 아래였고, 내려앉은 칸마다 계단등이 켜져 8초 남게 하자 0.056 이 됐어요. 22판에서는 아래 끝을 넘은 첫 판이 둘이었어요. Weaver 는 0.017 이었고, 하늘 아닌 곳에 구름 그림자 두 줄을 1초에 3 LED 씩 지나가게 하자 0.044 가 됐어요. Galilean Moons 는 0.022 였고, 도표의 하루를 6줄에서 10줄로 늘리자 0.031 이 됐어요. 높은 쪽은 셈으로 맞출 수 있었어요. Pin Art 는 0.613 × 0.575 = 0.352 가 잰 값 0.357 이고, Flipdot 은 0.36 × (1 − 0.248) 이 잰 값 0.265 예요. 둘 다 바뀐 넓이에 칸 평균의 차이를 곱한 셈이에요. 23판에서는 그 셈을 그림의 차례로 맞췄어요. Sequins 는 2초 화면에 아래 52% 의 파도가 있고 7.6초에 위 33% 의 하트가 있어서 판의 75% 가 바뀌어 0.250 이에요. Bait Ball 은 어두운 등을 보이던 떼가 2.1초부터 은빛으로 돌아서 0.372 이고, 은빛일 때 공 둘레 물까지 밝히는 빛무리가 0.304 를 0.348 로 올렸어요. 씨앗마다 0.351~0.400 으로 흔들리던 값은 2초 화면에 사냥꾼이 있느냐로 갈렸고, 첫 돌진 시각을 고정하자 0.367~0.377 로 좁아졌어요. 거꾸로 Elephant Toothpaste 는 밝은 기둥이 어두운 판을 위아래로 다 가로질러도 패널 너비의 40% 쯤만 덮어서 0.102 에 그쳤어요. 24판에서는 하늘이 모양 바뀜을 가렸어요. Thermal 은 적운이 자라는데도 첫 판이 0.021 이었어요. 가장 밝은 채널로 보면 200~222 의 파란 하늘과 흰 구름이 거의 같아서, 천정을 (14,50,150) 으로 어둡게 하자 구름이 자라는 위의 16줄 띠 둘이 0.0066 에서 0.0207 이 됐어요. Pendulum Wave 는 색을 올리려고 바닥을 밝히자 추와 바닥의 차이가 줄어 모양 바뀜이 0.039 에서 0.023 으로 떨어졌어요. Shoot 'em Up 은 보스의 마지막 폭발 섬광 하나가 8초 창 안에 들면 0.30, 밖이면 0.09~0.13 이에요. 25판에서는 서 있는 그림이 넓은 빛 하나로 모양 바뀜을 얻었어요. Excavation 은 굴착기와 클램셸만 움직일 때 0.013 이었고, 데크 틈으로 드는 햇빛 기둥이 2초의 구름 그늘(틈 아래 평균 0.52)에서 8초에 0.97 로 개게 하자 0.054 가 됐어요. Kintsugi 는 6.5초에 한 번 흔들리는 종이 등의 빛무리만으로 0.116 이라서, 사발이 깨지는 몫은 등을 멈춘 사본으로 따로 재야 보였어요. 그 사본은 0.039~0.053 이에요. Snow Monkeys 도 김을 끄면 0.115 가 0.024 로 떨어져요. 김 뒤의 비탈을 평균 밝기 69 로 어둡게 두어서 240 의 김이 블록을 바꿔요. 이어진 구름층은 흘러도 모양 바뀜을 내지 않아요. Skydiver 는 적운이 서로 이어져 흰 띠가 되자 0.030 이었고, 128열에 적운을 넷만 두어 틈을 내자 구름층이 지나는 16줄 칸 줄의 몫이 0.0049 에서 0.0142 가 되어 0.045 가 됐어요. 제자리에서 도는 원도 거의 내지 않아요. Ferris Wheel 은 연무와 구름을 멈추면 0.045 였고, 쇼의 평균 빛을 하늘 연무에 묻히자 0.100 이 됐어요. 띠가 위아래로 흐르는 그림은 밝기의 주기가 값을 정했어요. Population Pyramid 의 10줄 세대 띠를 두 단 밝기로 번갈아 칠하면 20줄 주기라 16줄 칸 안의 평균이 거의 그대로여서 0.029 였어요. 세 단으로 30줄 주기를 만들자 다른 것은 그대로인 채 0.073 이 됐어요. Rice Terraces 는 하늘을 비추는 논이 판의 70% 쯤이라 바탕이 모양 바뀜을 냈어요. 논이 지평선 쪽 주황만 비출 때는 0.023 이었고, 가까운 논이 짙은 파랑의 머리 위 하늘을 비추게 하자 구름 반사가 블록만 하게 지나가며 0.063 이 됐어요. 26판의 Implosion 은 먼지를 퍼프의 빛을 무게로 평균 내 칠하자 판 전체가 평평한 회색 안개가 되어 0.046 이었어요. 퍼프마다 해와 하늘에서 빛을 받는 공으로 그려 앞의 것 위에 겹치자 0.09 가 됐어요. 흰 김도 그래서, Geyser 는 김 덩어리에 왼쪽 위 빛의 음영을 주고 정오 하늘을 (20,62,152) 로 짙게 해 0.036 에서 0.044~0.050 이 됐어요. 판 전체가 한꺼번에 바뀌는 빛도 모양 바뀜을 내요. Lichtenberg 는 빈 나무가 40% 를 넘자 0.20 아래로 내려갔고, 소다물 광택이 전원이 켜진 동안 판 전체에서 마르게 하자 0.241 이 됐어요. -움직임 축의 아래 끝도 끝이에요. 아래 끝은 Star Trails 의 0.019 이고, 18판의 Stained Glass 는 첫 판이 0.007, Peg Light 는 0.023 이었어요. 블록만 한 구름 그림자가 1초에 5 LED 로 지나가면 모양 바뀜은 0.036 이어도 1초 사이에 바뀌는 빛은 작아요. Stained Glass 는 그림자를 11 LED 덩어리로 얼룩지게 하고 1초에 18 LED 로 지나가게 해서 0.027 이 됐어요. Peg Light 는 1초에 페그 100개를 꽂아도 LED 하나가 한 번 바뀌는 셈이라 그 몫이 0.012 였고, 켜진 페그마다 ±3.5% 로 1.4 Hz 에 일렁이게 해서 0.035 가 됐어요. 위 끝 쪽은 프레임마다 새로 뿌리는 결이 값을 부풀려요. 26판의 Dark Field 는 켜진 LED 마다 ±20% 의 반짝임과 검은 바탕 전체의 23/255 아래 결을 프레임마다 새로 뿌려서 움직임이 2.306 이었어요. 패널에서는 텔레비전 잡음처럼 끓는 판이었어요. 같은 결을 LED 마다 한 번 정해 두자 움직임은 1.381 로 내려갔고, 복잡도는 2.080 에서 2.081, 촘촘함과 채움은 그대로였어요. 복잡도는 한 화면을 재서, 결이 그 자리에 서 있어도 똑같이 올라요. +움직임 축의 아래 끝도 끝이에요. 아래 끝은 Star Trails 의 0.019 이고, 18판의 Stained Glass 는 첫 판이 0.007, Peg Light 는 0.023 이었어요. 블록만 한 구름 그림자가 1초에 5 LED 로 지나가면 모양 바뀜은 0.036 이어도 1초 사이에 바뀌는 빛은 작아요. Stained Glass 는 그림자를 11 LED 덩어리로 얼룩지게 하고 1초에 18 LED 로 지나가게 해서 0.027 이 됐어요. Peg Light 는 1초에 페그 100개를 꽂아도 LED 하나가 한 번 바뀌는 셈이라 그 몫이 0.012 였고, 켜진 페그마다 ±3.5% 로 1.4 Hz 에 일렁이게 해서 0.035 가 됐어요. 29판의 Cream Swirl 도 첫 판이 0.012 였어요. 칼이 지나가는 몇 LED 말고는 크림이 멈춰 있었고, 빛을 7초에 한 번 오가게 해도 평평한 크림은 바뀌지 않아 0.019 였어요. 결을 깊게 해서 빛이 돌 때 바뀌는 비탈을 늘리자 0.037 이 됐어요. 위 끝 쪽은 프레임마다 새로 뿌리는 결이 값을 부풀려요. 26판의 Dark Field 는 켜진 LED 마다 ±20% 의 반짝임과 검은 바탕 전체의 23/255 아래 결을 프레임마다 새로 뿌려서 움직임이 2.306 이었어요. 패널에서는 텔레비전 잡음처럼 끓는 판이었어요. 같은 결을 LED 마다 한 번 정해 두자 움직임은 1.381 로 내려갔고, 복잡도는 2.080 에서 2.081, 촘촘함과 채움은 그대로였어요. 복잡도는 한 화면을 재서, 결이 그 자리에 서 있어도 똑같이 올라요. 13판에서 채움 축의 아래 끝이 Glyph Rain 의 0.059 에서 Dragon Fold 의 0.117 로 올라가서, 채움 칸의 경계가 0.257, 0.530, 0.802 에서 0.302, 0.558, 0.815 로 옮겨졌어요. 그래서 복잡도 × 채움 3,1 은 한동안 `*` 가 빠졌고, 18판 뒤에는 다시 `*` 가 붙어 있었어요. 19판의 Foxfire 가 채움 0.196, 복잡도 2.001 로 그 칸을 채웠어요. @@ -1316,7 +1375,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ### 아직 없는 갈래 -`families.json` 의 쉰세 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 28판이 연 수론 갈래는 Ford Circles 하나예요. 위상·매듭 갈래는 28판이 열려다가 한 판에 하나라는 새 표에 따라 열지 않아서, Hairy Ball 은 노이즈·흐름에 있어요. 호프 올뭉치나 매듭이 들어오는 판이 위상 갈래를 열 수 있어요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. +`families.json` 의 쉰다섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 하나예요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 매듭이나 사영 평면을 몰입한 보이 곡면이 그 갈래의 둘째로 맞아요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 28판이 연 수론 갈래는 Ford Circles 하나예요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. ### 멋진 것 더 멋지게 @@ -1332,14 +1391,24 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 28판의 넷은 수학이 세로를 고른 경우예요. Ford Circles 는 실수선을, Hyperbolic Band 는 띠 모형의 띠를, Loewner Trace 는 위 반평면을 옮긴 반 띠를, Stadium Billiard 는 경기장의 곧은 변을 패널의 긴 변에 놓았어요. 넷 다 화면 전체가 그 수학이라 지울 바탕이 없어요. 셋은 3분의 1마다 70% 를 넘고, Stadium Billiard 는 19/255 인 탁자 천이 셈에 들지 않아 공과 자취만으로 17/22/15 예요. 그냥 위 반평면의 SLE 는 높이만큼 옆으로도 퍼져서 1:2 패널에 맞지 않았어요. 크기가 한 줄마다 e^(π/65) 배가 되는 반 띠로 옮기자 크기 불변이 위로 흐르는 화면이 됐어요. +29판의 셋은 떨어지는 것과 자라는 것이에요. Dyson Gas 는 시간이 패널을 타고 내려가는 세계선이라 41/37/33 이고, Glitter Fall 은 위에서 쏟아져 바닥에 쌓이는 글리터라 21/21/72 예요. 바탕이 15/255 아래라 이 값은 다 글리터예요. Perron Tree 는 높이 120 LED 삼각형에서 자라는 나무라 30/25/25 이고, 한 LED 보다 가는 싹 끝에 폭의 제곱근만큼 밝기를 줘서 위 3분의 1까지 닿았어요. Zeta Loops 는 실수축을 세웠는데도 t 가 60 아래에서는 곡선의 폭과 높이가 비슷해서 위 3분의 1이 6% 였어요. 수학의 모양이 세로로 길지 않으면 축을 세워도 128줄을 다 쓰지 않아요. + ### 낯선 수학 -주제 5 로 쓴 수학과 다음에 해 볼 수학이에요. 28판이 처음 열 개를 썼어요. 슈람-뢰브너 진화(Loewner Trace), 포드 원(Ford Circles), 털 난 공 정리(Hairy Ball), 아즈텍 다이아몬드와 북극권(Aztec Diamond), 부니모비치 경기장(Stadium Billiard), 라우지 프랙탈(Rauzy Fractal), 고셋의 4₂₁ 다면체와 E8(Gosset Polytope), 콘웨이의 보고 말하기 수열(Look and Say), 위토프 게임의 그런디 값(Wythoff Nim), 쌍곡 평면의 띠 모형(Hyperbolic Band)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드 둘이고, 모듈러 형식과 리틀우드 다항식의 근, 호프 올뭉치, 클라인 군의 극한 집합, 세 간격 정리, 외부 당구가 남았어요. +주제 5 로 쓴 수학과 다음에 해 볼 수학이에요. 28판이 처음 열 개를 썼어요. 슈람-뢰브너 진화(Loewner Trace), 포드 원(Ford Circles), 털 난 공 정리(Hairy Ball), 아즈텍 다이아몬드와 북극권(Aztec Diamond), 부니모비치 경기장(Stadium Billiard), 라우지 프랙탈(Rauzy Fractal), 고셋의 4₂₁ 다면체와 E8(Gosset Polytope), 콘웨이의 보고 말하기 수열(Look and Say), 위토프 게임의 그런디 값(Wythoff Nim), 쌍곡 평면의 띠 모형(Hyperbolic Band)이에요. 29판은 여섯을 더했어요. 클라인의 j 불변량(J-Invariant), 호프 올뭉치(Hopf Fibration), 다이슨 브라운 운동(Dyson Gas), 가케야 바늘 문제의 페론 나무(Perron Tree), 임계선 위의 리만 제타 함수(Zeta Loops), 길버트-섀넌-리즈 리플과 베이어-디아코니스의 일곱 번(Seven Shuffles)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드, 모듈러 형식, 호프 올뭉치 넷이고, 리틀우드 다항식의 근과 클라인 군의 극한 집합, 세 간격 정리, 외부 당구가 남았어요. -목록 밖에서 다음에 해 볼 것이에요. 리만 제타 함수를 임계선 위에서 리만-지겔 공식으로 셈하면 하디의 Z(t) 가 세운 패널을 따라 흐르고 영점이 지나갈 때마다 번쩍여요. t 가 수백이어도 항이 열 개 안팎이라 가벼워요. 길브레스 추측(1958)의 표는 소수의 차를 거듭 절댓값으로 빼 내린 줄들이라 128줄이 그대로 표가 되고, 맨 앞 칸이 늘 1 이에요. 콜라코스키 수열(1965)은 제 길이 부호를 읽으면 제가 되는 1 과 2 의 줄이라 리듬·펄스에 맞아요. 영 도형의 플랑셰렐 성장은 상자가 하나씩 붙으며 로건-셰프와 베르시크-케로프의 극한 곡선(1977)으로 다가가요. 아즈텍 다이아몬드의 북극권과 같은 극한 모양이라 같은 판에 두지 않아요. 슈타이너 사슬은 반전 하나로 고리의 원들이 미끄러지는 포리즘이고, 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. Ford Circles 와 같은 수론이라 그 갈래의 둘째로 맞아요. +목록 밖에서 다음에 해 볼 것이에요. 가우스 소수는 복소평면에 점으로 흩어지는데, 걸음 k 안에서 건널 수 있는 소수끼리 물들이면 고든의 가우스 해자 문제(1962)가 섬으로 갈려 보여요. 핀휠 타일링(콘웨이와 라딘, 1994)은 직각삼각형이 다섯으로 나뉘며 방향이 끝없이 많아져서, 확대할수록 Penrose 와 달리 모든 방향이 고르게 섞여요. 길브레스 추측(1958)의 표는 소수의 차를 거듭 절댓값으로 빼 내린 줄들이라 128줄이 그대로 표가 되고, 맨 앞 칸이 늘 1 이에요. 콜라코스키 수열(1965)은 제 길이 부호를 읽으면 제가 되는 1 과 2 의 줄이라 리듬·펄스에 맞아요. 영 도형의 플랑셰렐 성장은 상자가 하나씩 붙으며 로건-셰프와 베르시크-케로프의 극한 곡선(1977)으로 다가가요. 아즈텍 다이아몬드의 북극권과 같은 극한 모양이라 같은 판에 두지 않아요. 슈타이너 사슬은 반전 하나로 고리의 원들이 미끄러지는 포리즘이고, 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. Ford Circles 와 같은 수론이라 그 갈래의 둘째로 맞아요. 28판에서 본 것이에요. 수학 대상은 셈이 정확해서, 끝없는 확대나 흐름이 거의 다 작은 틀 하나로 돌아요. Ford Circles 는 창을 이웃한 두 수렴 분수의 틀로 잡아 1800초 뒤에도 수가 작고, Hyperbolic Band 는 59 LED 마다, Rauzy Fractal 은 세 단계마다, Gosset Polytope 는 36초마다 화면이 정확히 돌아와요. 반대로 무작위가 주인공인 셋은 2~8초 창이 어느 때에 걸리느냐가 값을 정했어요. Stadium Billiard 는 2~5초만 재면 움직임 0.63, 기체만 재면 3.1 이었어요. 차례대로 자라는 Look and Say 는 빈 쪽에서 열리면 2~8초의 `thirds` 가 24/62/64 였고, 처음 스무 줄을 빠르게 쳐 내자 48/68/69 가 됐어요. 수학을 그대로 그린 화면은 그대로 두면 너무 고요하거나 너무 어두웠어요. Ford Circles 의 고른 원판은 채움 0.971 의 회색 판이었고, Gosset Polytope 의 모서리 그물은 LED 하나에 0.012 씩만 쌓였어요. 빛을 테두리나 고리처럼 수학이 정한 자리에 모으면 읽혔어요. +29판에서 본 것이에요. 영역 색칠을 색상환 한 바퀴에 그대로 얹으면 패널에서는 무지개 플라즈마가 됐어요. J-Invariant 는 j 가 실수인 선, 곧 클라인 삼각형의 경계로 두 색 무리를 나누자 테셀레이션이 주인공이 됐고 색은 0.555 에서 0.400 으로 내려갔어요. 3차원 그림은 여는 노브가 그림을 정했어요. Hopf Fibration 은 토러스 셋으로 열면 엉킨 덩어리였고 다섯으로 열자 겹친 소용돌이가 읽혔어요. 앞뒤는 가까운 선이 먼 선 위에 남기는 한 LED 의 어두운 틈이 알려 줬어요. 무작위 과정은 한 줄에 담는 시간이 그림을 정했어요. Dyson Gas 는 한 줄에 과정을 많이 담자 실이 줄마다 1.3 LED 씩 떨려 잡음이 됐어요. 정리가 극한을 말하는 대상은 패널에서 그 극한까지 가지 않았어요. Perron Tree 의 넓이는 256 조각에서도 삼각형의 24% 였고, Seven Shuffles 는 52장이 아니라 화면을 채우는 32장으로 열어서 여섯 번에 섞였어요. + +### 화장품 팝업 + +주제 6 으로 쓴 질감과 다음에 해 볼 질감이에요. 29판이 처음 넷을 썼어요. 팔레트 나이프로 펴 바르는 크림(Cream Swirl), 구슬이 뜬 젤과 빛 그물(Serum Gel), 쏟아져 쌓이는 글리터(Glitter Fall), 퍼프가 눌렀다 든 압축 파우더(Loose Powder)예요. 넷 다 K1 이 색상환 한 바퀴라서, 색을 돌리면 주황과 노랑이 갈색이나 올리브로 보이는 자리가 있었어요. Serum Gel 은 그 둘레에서만 밝기를 0.62 와 0.3 에서 0.92 와 0.68 로 올렸어요. 밝은 바탕은 반짝임을 지웠어요. 200 위에 55 를 더한 반짝임은 거의 안 보여서 Glitter Fall 은 어두운 바탕에 섰고, 흰 바탕과 금색 한 줄은 Loose Powder 의 아래 띠로만 썼어요. 크림과 젤은 가장자리 0.028 과 0.079 로 연구에서 가장 번지는 그림이에요. 그 둘이 가장자리 축의 아래 끝을 옮겨서 지도에 새 빈 칸이 났어요. 크림은 빛만 움직여서는 움직임이 0.019 에 머물렀어요. 결이 깊어야 빛이 돌 때 바뀌는 LED 가 생겨요. + +다음에 해 볼 질감이에요. 립글로스처럼 두꺼운 투명 액체가 천천히 흘러내리며 아랫단에 빛이 맺히는 것, 쿠션 퍼프의 망사를 지나 리퀴드가 스며 나오는 것, 펄 섀도의 운모가 손끝에 밀려 번지며 보는 각도에 따라 색이 바뀌는 간섭색, 거품 클렌저의 거품이 꺼지며 남는 벌집 무늬예요. 매니큐어 붓이 남긴 줄이 스스로 펴지는 것은 Cream Swirl 과 가까워서 같은 판에 두지 않아요. + ## 다시 돌리기 `web/` 에서 실행해요. `web/node_modules` 가 있어야 해요. From db207d9f202c34db869bbfcab4d1b7be1915b66e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:59:06 +0000 Subject: [PATCH 13/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20Seven=20Shuffles=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=EC=9D=84=20=EA=B3=A0=EC=B9=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_01Fz23eRpkazDP4tpajkAeja --- incoming/pattern-study/patterns/seven-shuffles.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incoming/pattern-study/patterns/seven-shuffles.js b/incoming/pattern-study/patterns/seven-shuffles.js index af30be430..d13b5813c 100644 --- a/incoming/pattern-study/patterns/seven-shuffles.js +++ b/incoming/pattern-study/patterns/seven-shuffles.js @@ -191,7 +191,7 @@ function next(P) { } else P.pass++; return (P.ph = PAUSE), (P.t = 0); } - // At rest or after a pause: shuffle on, wait, or put the deck back. + // At rest or after a pause, pick what the deck does next. const home = sorted(P.deck, P.n); if (P.job === 1 && P.pass < P.bits) return unriffle(P), begin(P); if (P.job === 1 || (home && P.k > 0)) { From 54b621ccad39792ddd3dd7677a0ae9117e49b479 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 12:11:53 +0000 Subject: [PATCH 14/14] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2029=ED=8C=90:=20index.html=20=EC=9D=84=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=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_01Fz23eRpkazDP4tpajkAeja --- incoming/pattern-study/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 175ee5e46..fa53e7b6f 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,9 +141,9 @@

패턴 연구

- +