From c1d38e4fdedde38810c4d0e49f36f333bd3955e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 21:44:00 +0000 Subject: [PATCH 01/10] =?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=2039=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_01WSruBmW95rasfF2WmM8DZ7 From 8716206144217fa2419411e07e5d6c420c3e0a4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:33:58 +0000 Subject: [PATCH 02/10] =?UTF-8?q?incoming:=20Brachistochrone,=20Blotting?= =?UTF-8?q?=20Paper=20=ED=8C=A8=ED=84=B4=20(39=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_01WSruBmW95rasfF2WmM8DZ7 --- .../pattern-study/patterns/blotting-paper.js | 257 ++++++++++++ .../pattern-study/patterns/brachistochrone.js | 396 ++++++++++++++++++ 2 files changed, 653 insertions(+) create mode 100644 incoming/pattern-study/patterns/blotting-paper.js create mode 100644 incoming/pattern-study/patterns/brachistochrone.js diff --git a/incoming/pattern-study/patterns/blotting-paper.js b/incoming/pattern-study/patterns/blotting-paper.js new file mode 100644 index 000000000..f6322a863 --- /dev/null +++ b/incoming/pattern-study/patterns/blotting-paper.js @@ -0,0 +1,257 @@ +// ===== Patternflow pattern ===== +// Title: Blotting Paper +// Family: cosmetic +// Idea: A thin tinted sheet of oil-blotting paper with its fibres showing hangs in front of a lit wall and, pressed to skin we never see, takes up oil where each pore touches it: translucent spots open with a deeper rim at their wet edge and widen with the square root of time, running a little ahead along the fibres and into one another, and wherever the paper has gone clear the wall shines through brighter and deeper in tint, until most of the sheet glows, holds, and a fresh sheet slides down over it. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 39, after oil-blotting paper pressed to the skin, going translucent where the sebum wicks into its fibres (Lucas-Washburn wicking: Richard Lucas, 1918; Edward Washburn, 1921) +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Pores=20..80, Grain=0..1 +// +// Knob 1: Hue. Turns the paper's tint round the whole wheel; the dry paper is a dim pale tint and the clear spots the same hue, deeper. At 0 rose, at 0.05 red, 0.1 coral, 0.15 apricot, 0.2 butter, 0.25 pale yellow, 0.3 lime, 0.4 green, 0.5 aqua, 0.6 sky blue, 0.7 blue, 0.8 violet, 0.9 orchid. From 0.02 to 0.34 the dry paper is lifted up to 1.5 times and paled toward grey and the spots a little paler, so red, orange and yellow paper never reads brown or olive. +// Knob 2: Speed. At 2 the first pores touch 0.4 s after a sheet is pressed and the last about 6.5 s after; a tenth of the sheet is clear by 2.5 s, half by 6.7 s and nine tenths by 13 s, then it holds 2.5 s and a fresh sheet slides down in 1.4 s, about 16 s a sheet. At 0.2 the wicking is ten times slower, at 3 one and a half times faster; the slide always takes 1.4 s. +// Knob 3: Pores. How many pores touch the sheet, each later one where the paper is still dry: 20 at the start, whose widest spots reach about 45 LEDs across before they meet, up to 80 at the top, about 24 across. Each spot's radius goes as the square root of its age, and the more pores, the slower each one spreads, so the sheet clears in about the same time. +// Knob 4: Grain. How far the fibres line up along the panel. Up to 0.06 they lie every which way and the spots stay round; at 1 nearly all run up and down, nearly twice as long, and the oil runs along them two and a half times as fast as across, so the spots stretch into tall ovals and the clear patches join in columns. + +const W = 64, H = 128, N = W * H; +// Opening tint: rose. +const H0 = 0.94; +// Fibres in a sheet, the width of the wicking front in LEDs, and how far the +// oil runs ahead along a fibre. +const NF = 440, FRONT = 2.6, AHEAD = 1.8; +// Brightness of the dry and the clear paper over the wall, 0..1. +const DRY = 0.24, CLEAR = 1; +// Sheet seconds at Speed 2: the hold, once 90% of the sheet is clear, and the +// slide of the next sheet in real seconds. +const HOLD = 2.5, SLIDE = 1.4; +// Sheet seconds for the paper behind the front to soak to its clearest. +const SOAK = 2.5; +// Spreading rate: the area all the spots open together, in LEDs a second. +const RATE = 1290, LATE = 6.5; +const { floor, min, max, abs, sqrt, sin, cos, exp, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), a | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// A sheet: its fibres, its paper grain and the slow cockle that makes each +// spot a little uneven. The fibres come from their own generator, so the +// grain knob changes the fibres and never where the pores touch. +function sheet(seed, grain) { + const F = new Float32Array(N), g = new Float32Array(N), r = rng(seed); + const spread = 1 - 0.9 * grain; + for (let f = 0; f < NF; f++) { + const cx = r() * (W + 8) - 4, cy = r() * (H + 8) - 4, len = (4 + 11 * r()) * (1 + 0.8 * grain), a = PI / 2 + spread * (r() - 0.5) * PI, w = 0.35 + 0.65 * r(); + const dx = cos(a), dy = sin(a), n = Math.ceil(len / 0.5); + for (let j = 0; j <= n; j++) { + const s = (j / n - 0.5) * len, x = cx + dx * s, y = cy + dy * s, i = floor(x), k = floor(y), fx = x - i, fy = y - k; + for (let b = 0; b < 2; b++) + for (let c = 0; c < 2; c++) { + const xx = i + c, yy = k + b; + if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; + F[yy * W + xx] += 0.5 * w * (c ? fx : 1 - fx) * (b ? fy : 1 - fy); + } + } + } + for (let p = 0; p < N; p++) { + F[p] = min(1, F[p]); + g[p] = r() * 2 - 1; + } + // The cockle roughens the spots' edges; the flocs, clumps of fibres a few + // LEDs across, cloud the paper where it has gone clear. + const nz = noise(r, 7, 1), fl = noise(r, 9, 0.5); + return { F, g, nz, fl, seed }; +} + +// Smooth value noise, -1..1, on a grid of cells `cell` LEDs wide, with a +// second octave of half the size at weight w2. +function noise(r, cell, w2) { + const out = new Float32Array(N); + for (const [c, w] of [[cell, 1], [cell / 2, w2]]) { + const gw = Math.ceil(W / c) + 2, gh = Math.ceil(H / c) + 2, G = new Float32Array(gw * gh); + for (let i = 0; i < G.length; i++) G[i] = r() * 2 - 1; + for (let y = 0; y < H; y++) { + const v = y / c, j = floor(v), fy = (v - j) * (v - j) * (3 - 2 * (v - j)); + for (let x = 0; x < W; x++) { + const u = x / c, i = floor(u), fx = (u - i) * (u - i) * (3 - 2 * (u - i)), a = G[j * gw + i], b = G[j * gw + i + 1], e = G[(j + 1) * gw + i], d = G[(j + 1) * gw + i + 1]; + out[y * W + x] += (w * (a + (b - a) * fx + (e + (d - e) * fx - a - (b - a) * fx) * fy)) / (1 + w2); + } + } + } + return out; +} + +// Where the pores touch: each the best of four tries at standing clear of +// the ones before it, so the first n are spread out for any n. +function pores(seed) { + const r = rng(seed ^ 0x5bd1e995), out = []; + for (let i = 0; i < 80; i++) { + let best = null, bd = -1; + for (let c = 0; c < 12; c++) { + const x = r() * (W + 6) - 3, y = r() * (H + 6) - 3; + let d = 1e9; + for (const o of out) d = min(d, (o.x - x) ** 2 + (o.y - y) ** 2); + if (d > bd) { + bd = d; + best = { x, y }; + } + } + // How fast it wicks, and a share of the time it touches. + best.k = 0.75 + 0.5 * r(); + best.r = 0.85 + 0.3 * r(); + out.push(best); + } + return out; +} + +// For every LED, the pore whose oil reaches it first: when that spot opened, +// how fast it spreads and how far the LED is from it. Lucas-Washburn: a spot's +// radius is k sqrt(t - t0), so the oil reaches distance d at t0 + (d / k)^2. +// Distance is stretched across the grain, cut short along the fibres and +// roughened by the cockle. +function wick(S) { + const { F, nz } = S.cur, P = S.pores.slice(0, S.n), al = sqrt(1 + 1.5 * S.grain), T0 = S.T0, K = S.K, D = S.D; + // The first pores touch 0.4 s after the press and the rest follow over + // LATE seconds, each later one where the sheet is still dry. + P.forEach((o, i) => (o.t0 = 0.4 + LATE * (i / S.n) ** 1.4 * o.r)); + // Each pore's k scaled so that n spots together open RATE LEDs a second. + let sk = 0; + for (const o of P) sk += o.k * o.k; + const kk = sqrt(RATE / (PI * sk)), arr = new Float32Array(N); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, rough = 1 + 0.3 * nz[p], ahead = AHEAD * F[p]; + let bt = 1e9, bi = 0, bd = 0; + for (let i = 0; i < P.length; i++) { + const o = P[i], dx = (x - o.x) * al, dy = (y - o.y) / al; + const d = max(0, sqrt(dx * dx + dy * dy) * rough - ahead), k = o.k * kk, t = o.t0 + (d / k) * (d / k); + if (t < bt) { + bt = t; + bi = i; + bd = d; + } + } + T0[p] = P[bi].t0; + K[p] = P[bi].k * kk; + D[p] = bd; + arr[p] = bt; + } + // The hold starts when nine tenths of the sheet has been reached. + arr.sort(); + S.full = arr[floor(0.9 * N)]; +} + +function lay(S, seed) { + S.cur = sheet(seed, S.grain); + S.pores = pores(seed); + S.next = sheet(seed + 7919, S.grain); + wick(S); +} + +export function setup(params) { + params.s = { + hue: 0, speed: 2, n: 20, grain: 0.06, t: 0, phase: 0, slide: 0, seed: floor(Math.random() * 1e9), begun: false, + T0: new Float32Array(N), K: new Float32Array(N), D: new Float32Array(N), V: new Float32Array(N), C: new Float32Array(N), + }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 20, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + const n = round(cl(k[2], 20, 80)), grain = cl(k[3], 0, 1); + if (!S.begun) { + S.n = n; + S.grain = grain; + lay(S, S.seed); + S.begun = true; + } else if (abs(grain - S.grain) > 0.01) { + S.grain = grain; + S.n = n; + S.cur = sheet(S.cur.seed, grain); + S.next = sheet(S.cur.seed + 7919, grain); + wick(S); + } else if (n !== S.n) { + S.n = n; + wick(S); + } + if (S.phase === 0) { + S.t += (d * S.speed) / 2; + if (S.t > S.full + HOLD) S.phase = 1; + } else { + // A fresh sheet slides down over the old one. + S.slide += d / SLIDE; + if (S.slide >= 1) { + S.phase = 0; + S.slide = 0; + S.t = 0; + S.seed = (S.seed + 7919) >>> 0; + S.cur = S.next; + S.pores = pores(S.seed); + S.next = sheet(S.seed + 7919, S.grain); + wick(S); + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.begun) return; + const { T0, K, D, V, C } = S, t = S.t, h = S.hue + H0, hh = h - floor(h); + const pure = [0, 0, 0]; + { + const x = hh * 6, i = floor(x) % 6, f = x - floor(x); + pure[0] = [1, 1 - f, 0, 0, f, 1][i]; + pure[1] = [f, 1, 1, 1 - f, 0, 0][i]; + pure[2] = [0, 0, f, 1, 1, 1 - f][i]; + } + // Orange and yellow paper goes brown when dim: there the dry paper is + // lifted and paled. + const warm = cl(1.6 - abs(hh - 0.12) / 0.1, 0, 1); + const dryV = DRY * (1 + 0.5 * warm), dryS = 0.2 - 0.15 * warm, clrS = 0.56 - 0.1 * warm; + // Each LED's brightness is its dry level until the oil reaches it and then + // rises once, smoothly, to its clear level; nothing else on the sheet moves. + const { F, g, fl } = S.cur; + for (let y = 0; y < H; y++) { + const vy = (y - 63.5) / 92; + for (let x = 0; x < W; x++) { + const p = y * W + x, vx = (x - 31.5) / 46, wall = 0.84 + 0.16 * (1 - vx * vx - vy * vy); + const m = K[p] * sqrt(max(0, t - T0[p])) - D[p], age = t - T0[p] - (D[p] / K[p]) ** 2; + let s = cl(m / FRONT + 0.5, 0, 1); + s = s * s * (3 - 2 * s); + // Behind the front the paper keeps soaking up oil and clears further. + const soak = age > 0 ? 1 - exp(-age / SOAK) : 0; + const f = F[p], dry = dryV * wall * (1 + 0.5 * (f - 0.25) + 0.05 * g[p] + 0.06 * fl[p]), clr = CLEAR * wall * (1 - 0.17 * f + 0.03 * g[p] + 0.11 * fl[p]); + V[p] = dry + (clr - dry) * (0.68 * s + 0.32 * soak); + // The wet front carries a deeper rim of tint; it changes the colour, + // not the brightness. + C[p] = dryS + (clrS - dryS) * s + 1.2 * s * (1 - s); + } + } + const edge = S.phase === 1 ? round(H * (1 - (1 - S.slide) ** 3)) : -1, nf = S.next.F, ng = S.next.g, nl = S.next.fl; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + let v = V[p], c = C[p]; + if (y < edge) { + // The fresh sheet, dry, its fibres sliding down with it. + const q = (y - edge + H) * W + x, vx = (x - 31.5) / 46, vy = (y - 63.5) / 92; + v = dryV * (0.84 + 0.16 * (1 - vx * vx - vy * vy)) * (1 + 0.5 * (nf[q] - 0.25) + 0.05 * ng[q] + 0.06 * nl[q]); + c = dryS; + if (y === edge - 1) v *= 1.5; + } else if (edge >= 0 && y - edge < 3) v *= 0.45 + 0.18 * (y - edge); + v = min(1, v); + display.setPixel(x, y, round(255 * v * (1 - c + c * pure[0])), round(255 * v * (1 - c + c * pure[1])), round(255 * v * (1 - c + c * pure[2]))); + } +} diff --git a/incoming/pattern-study/patterns/brachistochrone.js b/incoming/pattern-study/patterns/brachistochrone.js new file mode 100644 index 000000000..ace057c6e --- /dev/null +++ b/incoming/pattern-study/patterns/brachistochrone.js @@ -0,0 +1,396 @@ +// ===== Patternflow pattern ===== +// Title: Brachistochrone +// Family: physics +// Idea: Beads let go together from a point at the top left slide without friction down five tracks to a point on the right, a straight line, Galileo's circle, a parabola, a deep drop to the bottom of the panel and the cycloid, while the curve of all the points a bead could have reached by now spreads from the start down the whole panel and leaves rings behind; the cycloid's bead rides that front all the way and arrives first on a track longer than the line, the tracks light up in the order their beads come in, and every second race six beads let go from different heights on the cycloid all reach its bottom at once. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the brachistochrone problem (Johann Bernoulli, 1696), solved by the cycloid, and the tautochrone (Christiaan Huygens, 1673), pattern round 39 +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Gravity=0.2..3, End=0..1, Tracks=5..13 +// +// Knob 1: Hue. Turns every colour round the wheel together. At 0 the cycloid gold, Galileo's circle violet, the parabola green, the line cyan, the deep drop rose, and the front and its rings pale blue. From 0.33 to 0.68, where the front would turn red, orange, yellow or lime, it is paled and lifted so it reads cream, never brown. +// Knob 2: Gravity. At 2 g is 31 LEDs a second squared: the cycloid's bead takes 2.4 s, the line's 2.84 s and the deep drop's 4.27 s, then the finish holds 1.3 s and fades in 0.6 s. Every time goes as one over the square root of g, so at 0.2 the race takes 3.2 times as long and at 3 it is 1.22 times as fast. +// Knob 3: End. Where the end point sits on the right edge. At 0 at row 39, the bottom of the cycloid's arch, where the cycloid wins by 1.2% over the circle, 4.3% over the parabola and 18.5% over the line. Turned up the end point rises to row 13 and the cycloid dips 10 rows below it and climbs back, now 37.6% ahead of the circle and 74% ahead of the line; the beads of the second race pass the bottom of the arch together and those let go below the end swing back and meet there again. +// Knob 4: Tracks. How many tracks race. At 5 the cycloid and its four classic rivals; each step adds another smooth track from the start to the end, eight more at 13, some starting shallow and some dipping below the end. None of them beats the cycloid. + +const W = 64, H = 128, N = W * H; +// The start point A, and the column the end point B sits in. +const AX = 3, AY = 3, BX = 60, DX = BX - AX; +// B's depth below A: at End 0 the bottom of the arch, 2/pi of the width; at +// End 1 ten rows. The deep drop always falls to row 124. +const BY0 = (2 * DX) / Math.PI, BY1 = 10, YDEEP = 121; +// g at Gravity 2, in LEDs a second squared: the cycloid's race at End 0 then +// takes 2.4 s. +const G2 = 31.1; +// Points in each track's table, and the seconds of the hold, the fade, the +// wait at the start and the light trail behind a bead. +const M = 640, HOLD = 1.3, FADE = 0.6, WAIT = 0.5, TRAIL = 0.28; +// Rings of the front left behind every sixth of the cycloid's time. +const RINGS = 6; +// Track colours, hue and saturation: the cycloid, Galileo's circle, the +// parabola, the line, the deep drop, then the extra tracks. +const COL = [[0.13, 0.62], [0.79, 0.55], [0.35, 0.6], [0.5, 0.6], [0.96, 0.55]]; +// Extra tracks: the angle they leave A at, degrees from straight down; the +// angle they come into B at, degrees below level (negative: coming up); and +// the lengths of their two handles as shares of the chord. +const EXTRA = [[62, 12, 0.45, 0.3], [14, -35, 0.55, 0.45], [38, 38, 0.4, 0.4], [4, 2, 0.85, 0.3], [74, 55, 0.5, 0.35], [24, -55, 0.8, 0.5], [50, -12, 0.5, 0.35], [9, 22, 0.6, 0.3]]; +const EXH = [0.06, 0.24, 0.43, 0.56, 0.68, 0.88, 0.17, 0.74]; +const { floor, min, max, abs, sqrt, exp, sin, cos, acos, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +function hsv(h, s, v) { + 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); + return [[v, q, p, p, t, v][i], [t, v, v, q, p, p][i], [p, p, t, v, v, q][i]]; +} + +// The cycloid from A through a point bx across and by down: the angle the +// rolling circle has turned at that point, and its radius a. +function cycloidTo(bx, by) { + const r = bx / by; + let lo = 1e-9, hi = 2 * PI - 1e-9; + for (let i = 0; i < 56; i++) { + const m = (lo + hi) / 2, s = sin(m / 2); + if ((m - sin(m)) / (2 * s * s) < r) lo = m; + else hi = m; + } + const th = (lo + hi) / 2, s = sin(th / 2); + return [th, by / (2 * s * s)]; +} + +// A track from a curve f(u), u from 0 at A to 1 at B, sampled densely at both +// ends. Times are in units where g = 1: each chord is taken at constant +// slope, so the bead's time on it is exactly its length over the mean of the +// speeds sqrt(2y) at its two ends, and the sum is the time of the polyline. +function track(f, hue, sat) { + const x = new Float32Array(M), y = new Float32Array(M), t = new Float64Array(M); + let px = 0, py = 0; + for (let i = 0; i < M; i++) { + const u = 0.5 - 0.5 * cos((PI * i) / (M - 1)), p = f(u); + x[i] = p[0]; + y[i] = max(0, p[1]); + if (i) t[i] = t[i - 1] + (Math.hypot(x[i] - px, y[i] - py) * Math.SQRT2) / (sqrt(py) + sqrt(y[i]) || 1); + px = x[i]; + py = y[i]; + } + return { x, y, t, T: t[M - 1], hue, sat, pix: null, wt: null, at: -1 }; +} + +function bezier(P) { + return (u) => { + const v = 1 - u, a = v * v * v, b = 3 * v * v * u, c = 3 * v * u * u, d = u * u * u; + return [a * P[0] + b * P[2] + c * P[4] + d * P[6], a * P[1] + b * P[3] + c * P[5] + d * P[7]]; + }; +} + +// Lay a track into the panel as a thin core and a soft halo, kept as a list +// of the pixels it touches. +function rasterize(S, k) { + const R = S.tmp; + R.fill(0); + const { x, y } = k; + for (let i = 1; i < M; i++) { + const x0 = x[i - 1] + AX, y0 = y[i - 1] + AY, len = Math.hypot(x[i] + AX - x0, y[i] + AY - y0), n = Math.ceil(len / 0.4); + for (let j = 0; j < n; j++) { + const f = (j + 0.5) / n, px = x0 + (x[i] + AX - x0) * f, py = y0 + (y[i] + AY - y0) * f, w = len / n; + const ix = round(px), iy = round(py); + for (let b = -2; b <= 2; b++) { + const yy = iy + b; + if (yy < 0 || yy >= H) continue; + for (let a = -2; a <= 2; a++) { + const xx = ix + a; + if (xx < 0 || xx >= W) continue; + const d2 = (xx - px) ** 2 + (yy - py) ** 2; + R[yy * W + xx] += w * (0.75 * exp(-d2 / 0.5) + 0.07 * exp(-d2 / 5)); + } + } + } + } + const pix = [], wt = []; + for (let p = 0; p < N; p++) + if (R[p] > 0.01) { + pix.push(p); + wt.push(min(1.2, R[p])); + } + k.pix = Int32Array.from(pix); + k.wt = Float32Array.from(wt); +} + +// Every track for the end point's place, and the cycloid's numbers. +function build(S) { + const bx = DX, by = BY0 * (BY1 / BY0) ** S.end; + const [th, a] = cycloidTo(bx, by); + S.by = by; + S.th = th; + S.a = a; + const tr = []; + // The cycloid, the curve a point on a rolling circle draws. + tr.push(track((u) => [a * (th * u - sin(th * u)), 2 * a * sin((th * u) / 2) ** 2], ...COL[0])); + // Galileo's circle: the arc down to B whose lowest point is B. + const R = (bx * bx + by * by) / (2 * by), cy = by - R, a0 = Math.atan2(-cy, -bx); + tr.push(track((u) => [bx + R * cos(a0 + (PI / 2 - a0) * u), cy + R * sin(a0 + (PI / 2 - a0) * u)], ...COL[1])); + // The parabola whose lowest point is B. + tr.push(track((u) => [bx * u, by * (1 - (1 - u) ** 2)], ...COL[2])); + // The straight line, the shortest way. + tr.push(track((u) => [bx * u, by * u], ...COL[3])); + // The deep drop: straight down the left side to row 124, along the bottom + // and up into B. D is found so that its lowest point is there. + let lo = by, hi = 4 * YDEEP; + for (let i = 0; i < 30; i++) { + const D = (lo + hi) / 2; + let m = 0; + for (let j = 1; j < 64; j++) { + const u = j / 64; + m = max(m, 3 * u * (1 - u) * D + u * u * u * by); + } + if (m < YDEEP) lo = D; + else hi = D; + } + tr.push(track(bezier([0, 0, 0, lo, bx, lo, bx, by]), ...COL[4])); + const C = Math.hypot(bx, by); + for (let j = 0; j < S.ntr - 5; j++) { + const [p, q, h1, h2] = EXTRA[j], pa = (p * PI) / 180, qa = (q * PI) / 180; + tr.push(track(bezier([0, 0, h1 * C * sin(pa), h1 * C * cos(pa), bx - h2 * C * cos(qa), by - h2 * C * sin(qa), bx, by]), EXH[j], 0.5)); + } + for (const k of tr) rasterize(S, k); + // Keep the arrivals already seen in this race. + if (S.tracks) for (let i = 0; i < tr.length && i < S.tracks.length; i++) if (S.tracks[i].at >= 0 && S.tau >= tr[i].T) tr[i].at = S.tracks[i].at; + S.tracks = tr; + S.tauB = th * sqrt(a); +} + +// Where the field of least times is: the time of the cycloid from A through +// each pixel, the fastest way there, and the bead's speed there. The front at +// time t is the curve these times equal t on, and all of it moves at the speed +// a bead would have there, so its distance in LEDs is the time to go times +// that speed. +function field(S) { + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, dy = y - AY, dx = abs(x - AX); + if (dy < 0 || (dy === 0 && dx === 0)) { + S.tau0[p] = dy < 0 ? 1e9 : 0; + S.q[p] = 0; + continue; + } + if (dy === 0) S.tau0[p] = 2 * PI * sqrt(dx / (2 * PI)); + else { + const [th, a] = cycloidTo(dx, dy); + S.tau0[p] = th * sqrt(a); + } + S.q[p] = sqrt(2 * dy); + } +} + +export function setup(params) { + params.s = { + hue: 0, g: G2, end: -1, ntr: 0, tracks: null, mode: 0, phase: 0, clock: 0, tau: 0, fade: 1, t: 0, meet: [], + tau0: new Float32Array(N), q: new Float32Array(N), tmp: new Float32Array(N), + R: new Float32Array(N), G: new Float32Array(N), B: new Float32Array(N), + }; + field(params.s); +} + +// The tautochrone: from rest at arc length s0 above the bottom of the arch, a +// bead's arc length is s0 cos(t sqrt(g / 4a)), whatever s0 is. +function tautoBeads(S) { + const a = S.a, out = []; + for (let j = 0; j < 6; j++) out.push(4 * a * (1 - j / 6)); + return out; +} +function onCycloid(S, s) { + const th = 2 * acos(cl(s / (4 * S.a), -1, 1)); + return [S.a * (th - sin(th)), S.a * (1 - cos(th)), th]; +} + +// The race's last moment, in g = 1 time. +function raceEnd(S) { + if (S.mode === 0) { + let m = 0; + for (const k of S.tracks) m = max(m, k.T); + return m; + } + const bot = PI * sqrt(S.a); + return S.th <= PI + 1e-6 ? S.tauB : 3 * bot; +} + +function at(k, tau) { + const t = k.t; + if (tau >= k.T) return [k.x[M - 1], k.y[M - 1]]; + if (tau <= 0) return [0, 0]; + let lo = 0, hi = M - 1; + while (hi - lo > 1) { + const m = (lo + hi) >> 1; + if (t[m] <= tau) lo = m; + else hi = m; + } + const f = (tau - t[lo]) / (t[hi] - t[lo] || 1); + return [k.x[lo] + (k.x[hi] - k.x[lo]) * f, k.y[lo] + (k.y[hi] - k.y[lo]) * f]; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 5], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.g = G2 * (cl(k[1], 0.2, 3) / 2); + const end = cl(k[2], 0, 1), ntr = round(cl(k[3], 5, 13)); + if (abs(end - S.end) > 0.002 || ntr !== S.ntr) { + S.end = end; + S.ntr = ntr; + build(S); + } + const sg = sqrt(S.g); + S.t += d; + S.clock += d; + if (S.phase === 0 && S.clock >= WAIT) { + S.phase = 1; + S.tau = 0; + } else if (S.phase >= 1) { + const before = S.tau; + S.tau += d * sg; + if (S.mode === 0) + for (const tk of S.tracks) + // The arrival, placed inside the frame where it fell. + if (tk.at < 0 && S.tau >= tk.T) tk.at = S.t - (S.tau - tk.T) / sg; + if (S.mode === 1 && S.th >= PI - 1e-6) { + const bot = PI * sqrt(S.a); + for (const m of [bot, 3 * bot]) if (before < m && S.tau >= m) S.meet.push(S.t - (S.tau - m) / sg); + } + if (S.phase === 1 && S.tau >= raceEnd(S)) { + S.phase = 2; + S.clock = 0; + } else if (S.phase === 2 && S.clock >= HOLD) { + S.phase = 3; + S.clock = 0; + } else if (S.phase === 3) { + S.fade = 1 - S.clock / FADE; + if (S.clock >= FADE) { + S.phase = 0; + S.clock = 0; + S.fade = 1; + S.tau = 0; + S.mode ^= 1; + S.meet = []; + for (const tk of S.tracks) tk.at = -1; + } + } + } +} + +// A soft dot of light into the colour buffers. +function dot(S, x, y, r, w, c) { + const xi = round(x + AX), yi = round(y + AY), k = -1 / (r * r); + for (let j = yi - 2; j <= yi + 2; j++) { + if (j < 0 || j >= H) continue; + for (let i = xi - 2; i <= xi + 2; i++) { + if (i < 0 || i >= W) continue; + const e = w * exp(((i - x - AX) ** 2 + (j - y - AY) ** 2) * k), p = j * W + i; + S.R[p] += e * c[0]; + S.G[p] += e * c[1]; + S.B[p] += e * c[2]; + } + } +} + +function ring(S, x, y, age, c) { + const r = 1.5 + 16 * age, w = 0.8 * exp(-age / 0.22), n = round(6 * r + 8); + if (w < 0.03) return; + for (let j = 0; j < n; j++) { + const a = (2 * PI * j) / n; + dot(S, x + r * cos(a), y + r * sin(a), 0.7, w, c); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.tracks) return; + const { R, G, B, tau0, q } = S; + const fade = S.phase === 3 ? S.fade : 1, live = S.phase >= 1, tau = live ? S.tau : 0, sg = sqrt(S.g); + // The front, the light it leaves behind and its rings, pale blue. + const fh = 0.6 + S.hue, dh = abs(fh - floor(fh) - 0.11), warm = cl(1.9 - min(dh, 1 - dh) / 0.09, 0, 1); + // Orange and yellow go brown this faint: there the front is paled and lifted. + const fc = hsv(fh, 0.5 - 0.3 * warm, 1 + 0.4 * warm), rk = S.tauB / RINGS; + const nring = live ? floor(tau / rk) : 0; + for (let p = 0; p < N; p++) { + let v = 0; + if (live && q[p] > 0) { + const dd = (tau0[p] - tau) * q[p]; + if (dd < 2.5) { + if (dd > -2.5) v += 0.55 * exp(-dd * dd); + if (dd < 0) v += 0.09 + 0.16 * exp(dd / 7); + for (let r = 1; r <= nring; r++) { + const e = (tau0[p] - r * rk) * q[p]; + if (e > -2 && e < 2) v += 0.24 * exp(-e * e * 2); + } + } + } + v *= fade; + R[p] = v * fc[0]; + G[p] = v * fc[1]; + B[p] = v * fc[2]; + } + // The tracks, faint, each lit up when its bead arrives and staying lit. + const tc = []; + for (const k of S.tracks) { + const c = hsv(k.hue + S.hue, k.sat, 1); + tc.push(c); + let f = 0.3; + if (S.mode === 1 && k !== S.tracks[0]) f = 0.16; + if (k.at >= 0) { + const age = S.t - k.at; + f += fade * (0.22 + 0.5 * exp(-age / 0.3)); + } + for (let i = 0; i < k.pix.length; i++) { + const p = k.pix[i], w = k.wt[i] * f; + R[p] += w * c[0]; + G[p] += w * c[1]; + B[p] += w * c[2]; + } + } + const white = [1, 1, 1], bx = DX, by = S.by; + if (live) { + const dtr = (TRAIL * sg) / 12; + if (S.mode === 0) + S.tracks.forEach((k, i) => { + const c = tc[i]; + if (k.at >= 0) { + ring(S, bx, by, S.t - k.at, c); + if (S.t - k.at > 0.15) return; + } + for (let j = 12; j >= 1; j--) { + const p = at(k, tau - j * dtr); + dot(S, p[0], p[1], 0.9, fade * 0.55 * (1 - j / 13), c); + } + const p = at(k, tau); + dot(S, p[0], p[1], 0.8, fade * 1.1, c); + dot(S, p[0], p[1], 0.55, fade * 0.9, white); + }); + else { + // The second race: beads from six heights on the cycloid, the one at A + // the same bead as in the race. + const c = tc[0], w = 1 / (2 * sqrt(S.a)), sB = 4 * S.a * cos(S.th / 2); + for (const s0 of tautoBeads(S)) { + // A bead let go above the end point's height leaves the track there. + const tEnd = s0 >= abs(sB) - 1e-6 ? acos(cl(sB / s0, -1, 1)) / w : 1e9; + if (tau > tEnd + 0.15 * sg) continue; + for (let j = 12; j >= 0; j--) { + const tt = min(max(0, tau - j * dtr), tEnd), p = onCycloid(S, s0 * cos(tt * w)); + if (j) dot(S, p[0], p[1], 0.9, fade * 0.55 * (1 - j / 13), c); + else { + dot(S, p[0], p[1], 0.8, fade * 1.1, c); + dot(S, p[0], p[1], 0.55, fade * 0.9, white); + } + } + } + for (const m of S.meet) ring(S, PI * S.a, 2 * S.a, S.t - m, c); + } + } + // A and B. + dot(S, 0, 0, 0.9, 0.7, white); + dot(S, bx, by, 0.9, 0.7, white); + for (let p = 0; p < N; p++) + display.setPixel(p % W, (p / W) | 0, round(255 * min(1, R[p])), round(255 * min(1, G[p])), round(255 * min(1, B[p]))); +} From 7af7851c1b5a16600f3c25277c56a7f803ee1076 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:34:27 +0000 Subject: [PATCH 03/10] =?UTF-8?q?incoming:=20Kingman=20Coalescent,=20Borsu?= =?UTF-8?q?k-Ulam=20=ED=8C=A8=ED=84=B4=20(39=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_01WSruBmW95rasfF2WmM8DZ7 --- .../pattern-study/patterns/borsuk-ulam.js | 348 ++++++++++++++++++ .../patterns/kingman-coalescent.js | 238 ++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 incoming/pattern-study/patterns/borsuk-ulam.js create mode 100644 incoming/pattern-study/patterns/kingman-coalescent.js diff --git a/incoming/pattern-study/patterns/borsuk-ulam.js b/incoming/pattern-study/patterns/borsuk-ulam.js new file mode 100644 index 000000000..b6ed4565d --- /dev/null +++ b/incoming/pattern-study/patterns/borsuk-ulam.js @@ -0,0 +1,348 @@ +// ===== Patternflow pattern ===== +// Title: Borsuk-Ulam +// Family: topology +// Idea: A turning globe carries a slowly changing temperature and pressure, the top disk showing the side that faces you and the bottom disk the far side laid so that each point sits where its antipode sits above, and the white line where the temperature equals the temperature at the antipode crosses the magenta line where the pressure does at an odd number of gold points, the same in both disks, where two opposite places on the Earth have the same weather. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the Borsuk-Ulam theorem (Karol Borsuk, 1933, after a question of Stanisław Ulam) +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Degree=3..6, Speed=0.2..3, Weather=0..1, Hue=0..1 +// +// Knob 1: Degree. The highest degree of the random spherical harmonics in both fields, the next degree fading in between whole numbers. The two curves hang only on the odd degrees, so at 3 they are cubic curves and meet at 1 to 9 pairs, 2.8 on average: one pair 36% of the time, three 42%, five 19%, seven 3%. 4 adds a finer even layer to the colours and the isobars and leaves the curves cubic; at 5 and 6 the curves are quintic, wind into loops and meet at up to 25 pairs, about 5 on average. +// Knob 2: Speed. How fast the globe turns: at 2 once in 20 seconds round an axis leaning 24 degrees toward you, at 0.2 once in 200 seconds, at 3 once in 13. The curves and the pairs turn with it; a pair that slips over the rim comes back at once on the opposite rim, where its other point is rising. +// Knob 3: Weather. How fast the temperature and the pressure change. At 0 each coefficient swings once in 56 to 168 seconds, the curves creep and a pair of pairs is born or dies about 12 times a minute at Degree 3; at 1 eight times faster, 7 to 21 seconds, about 74 times a minute. Pairs come and go two at a time, where the curves touch, so the count stays odd. +// Knob 4: Hue. Turns the palette: at the start azure for cold and orange for warm with a magenta pressure curve; at 0.25 violet and green with red; at 0.5 red and cyan with green; at 0.75 green and violet with cyan. The temperature curve stays white and the pairs gold. Where cold or warm lands on orange or yellow the pale bands are lifted toward full brightness, so they never go brown or olive. + +// Each disk sits in a 64 x 64 square, the near side above the far side. +const S2 = 64; +const { floor, min, abs, sqrt, sin, cos, round, random, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// The disks: radius RAD about the centre of each 64 x 64 square. +const RAD = 31, CX = 32, CY = 32; +// Each field is a sum over AX zonal harmonics sqrt(2l+1) P_l(u . x) for every +// degree l up to LMAX, each axis u wandering on a great circle. +const AX = 5, LMAX = 6; +// The globe turns round an axis leaning TILT toward you. +const TILT = 0.42; +// Grid seeds for Newton's method each frame, over one hemisphere. +const SEEDS = 72; + +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] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +function unit() { + for (;;) { + const x = 2 * random() - 1, y = 2 * random() - 1, z = 2 * random() - 1, r = x * x + y * y + z * z; + if (r > 0.01 && r < 1) { + const k = 1 / sqrt(r); + return [x * k, y * k, z * k]; + } + } +} + +// One field: per axis a great circle (two orthonormal vectors and a speed) +// and per degree a coefficient swinging with its own speed and phase. +function field() { + const F = { e: [], w: [], amp: [], om: [], ph: [], u: new Float64Array(3 * AX), c: new Float64Array(AX * (LMAX + 1)) }; + for (let j = 0; j < AX; j++) { + const a = unit(), b0 = unit(); + const d = a[0] * b0[0] + a[1] * b0[1] + a[2] * b0[2]; + const b = [b0[0] - d * a[0], b0[1] - d * a[1], b0[2] - d * a[2]], bl = Math.hypot(b[0], b[1], b[2]); + F.e.push([a, [b[0] / bl, b[1] / bl, b[2] / bl], random() * TAU]); + F.w.push((0.08 + 0.12 * random()) * (random() < 0.5 ? -1 : 1)); + for (let l = 0; l <= LMAX; l++) { + F.amp.push(l === 0 ? 0 : (random() < 0.5 ? -1 : 1) * (0.6 + 0.4 * random())); + F.om.push(0.15 + 0.3 * random()); + F.ph.push(random() * TAU); + } + } + return F; +} + +// Axes and coefficients at weather time t, with degree l weighted by how far +// the Degree knob has come past it. +function place(F, t, deg) { + for (let j = 0; j < AX; j++) { + const [a, b, p] = F.e[j], th = p + F.w[j] * t, cs = cos(th), sn = sin(th); + for (let i = 0; i < 3; i++) F.u[3 * j + i] = a[i] * cs + b[i] * sn; + for (let l = 1; l <= LMAX; l++) { + const q = j * (LMAX + 1) + l, fade = cl(deg - l + 1, 0, 1); + F.c[q] = (fade * F.amp[q] * sqrt(2 * l + 1) * sin(F.om[q] * t + F.ph[q])) / Math.pow(l, 0.3); + } + } +} + +// Even and odd parts of a field at unit vector x, and the gradient of the odd +// part when grad is given: f(x) = even + odd, f(-x) = even - odd. +function evalField(F, x, y, z, L, out, grad) { + let ev = 0, od = 0, gx = 0, gy = 0, gz = 0; + for (let j = 0; j < AX; j++) { + const ux = F.u[3 * j], uy = F.u[3 * j + 1], uz = F.u[3 * j + 2], s = ux * x + uy * y + uz * z, base = j * (LMAX + 1); + let p0 = 1, p1 = s, d0 = 0, d1 = 1, o = F.c[base + 1] * p1, e = 0, dd = F.c[base + 1]; + for (let l = 1; l < L; l++) { + const p2 = ((2 * l + 1) * s * p1 - l * p0) / (l + 1), d2 = d0 + (2 * l + 1) * p1, c = F.c[base + l + 1]; + if ((l + 1) & 1) { + o += c * p2; + dd += c * d2; + } else e += c * p2; + p0 = p1; + p1 = p2; + d0 = d1; + d1 = d2; + } + ev += e; + od += o; + if (grad) { + gx += dd * ux; + gy += dd * uy; + gz += dd * uz; + } + } + out[0] = ev; + out[1] = od; + if (grad) { + grad[0] = gx; + grad[1] = gy; + grad[2] = gz; + } +} + +export function setup(params) { + const P = S2 * S2; + // World direction of each pixel of the top square: x right, y up, z + // toward you, pulled onto the rim outside the disk. + const wx = new Float32Array(P), wy = new Float32Array(P), wz = new Float32Array(P), cov = new Float32Array(P); + for (let y = 0; y < S2; y++) + for (let x = 0; x < S2; x++) { + const p = y * S2 + x, u = (x + 0.5 - CX) / RAD, v = (CY - y - 0.5) / RAD, r = sqrt(u * u + v * v); + cov[p] = cl((1 - r) * RAD + 0.5, 0, 1); + if (r < 1) { + wx[p] = u; + wy[p] = v; + wz[p] = sqrt(1 - r * r); + } else { + wx[p] = u / r; + wy[p] = v / r; + wz[p] = 0; + } + } + // Seeds: a Fibonacci spiral over the upper hemisphere. + const seeds = []; + for (let i = 0; i < SEEDS; i++) { + const z = (i + 0.5) / SEEDS, r = sqrt(1 - z * z), a = i * 2.39996323; + seeds.push([r * cos(a), r * sin(a), z]); + } + params.s = { + init: false, t: 0, wt: 0, deg: 3, L: 3, f: field(), g: field(), wx, wy, wz, cov, seeds, + fe: new Float32Array(P), fo: new Float32Array(P), ge: new Float32Array(P), go: new Float32Array(P), + lf: new Float32Array(P), lg: new Float32Array(P), roots: [], M: [1, 0, 0, 0, 1, 0, 0, 0, 1], hue: 0.06, + o1: [0, 0], o2: [0, 0], g1: [0, 0, 0], g2: [0, 0, 0], c: [0, 0, 0], d: [0, 0, 0], + }; +} + +// Newton's method on the sphere for fodd = godd = 0: the step is the +// smallest tangent move that zeroes both linearised, taken from x and pulled +// back onto the sphere. +function newton(S, x0) { + let x = x0[0], y = x0[1], z = x0[2]; + const { o1, o2, g1, g2 } = S; + for (let it = 0; it < 24; it++) { + evalField(S.f, x, y, z, S.L, o1, g1); + evalField(S.g, x, y, z, S.L, o2, g2); + const F1 = o1[1], F2 = o2[1]; + if (abs(F1) < 1e-13 && abs(F2) < 1e-13) return [x, y, z]; + const d1 = g1[0] * x + g1[1] * y + g1[2] * z, d2 = g2[0] * x + g2[1] * y + g2[2] * z; + const ax = g1[0] - d1 * x, ay = g1[1] - d1 * y, az = g1[2] - d1 * z; + const bx = g2[0] - d2 * x, by = g2[1] - d2 * y, bz = g2[2] - d2 * z; + const a = ax * ax + ay * ay + az * az, b = ax * bx + ay * by + az * bz, c = bx * bx + by * by + bz * bz, det = a * c - b * b; + if (det < 1e-14) return null; + const al = (-F1 * c + F2 * b) / det, be = (-F2 * a + F1 * b) / det; + let sx = al * ax + be * bx, sy = al * ay + be * by, sz = al * az + be * bz; + const sl = sqrt(sx * sx + sy * sy + sz * sz); + if (sl > 0.35) { + sx *= 0.35 / sl; + sy *= 0.35 / sl; + sz *= 0.35 / sl; + } + x += sx; + y += sy; + z += sz; + const n = 1 / sqrt(x * x + y * y + z * z); + x *= n; + y *= n; + z *= n; + } + evalField(S.f, x, y, z, S.L, o1); + evalField(S.g, x, y, z, S.L, o2); + return abs(o1[1]) < 1e-10 && abs(o2[1]) < 1e-10 ? [x, y, z] : null; +} + +// All antipodal pairs, by Newton's method from three kinds of start: last +// frame's pairs, every 2 x 2 block of LEDs on the near side where both odd +// parts change sign, and a fixed spiral of seeds; each pair kept once. +function findRoots(S) { + const found = []; + const add = (r) => { + if (!r) return; + for (const q of found) { + const d = (q[0] - r[0]) ** 2 + (q[1] - r[1]) ** 2 + (q[2] - r[2]) ** 2, e = (q[0] + r[0]) ** 2 + (q[1] + r[1]) ** 2 + (q[2] + r[2]) ** 2; + if (d < 1e-8 || e < 1e-8) return; + } + found.push(r); + }; + for (const r of S.roots) add(newton(S, r)); + const { fo, go, wx, wy, wz, M } = S; + for (let y = 0; y < S2 - 1; y++) + for (let x = 0; x < S2 - 1; x++) { + const p = y * S2 + x, q = [p, p + 1, p + S2, p + S2 + 1]; + let fp = 0, fn = 0, gp = 0, gn = 0; + for (const i of q) { + if (fo[i] > 0) fp = 1; + else fn = 1; + if (go[i] > 0) gp = 1; + else gn = 1; + } + if (!(fp && fn && gp && gn)) continue; + const X = wx[p], Y = wy[p], Z = wz[p]; + add(newton(S, [M[0] * X + M[1] * Y + M[2] * Z, M[3] * X + M[4] * Y + M[5] * Z, M[6] * X + M[7] * Y + M[8] * Z])); + } + for (const s of S.seeds) add(newton(S, s)); + S.roots = found; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [3, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.deg = cl(k[0], 3, 6); + S.L = Math.ceil(S.deg - 1e-9); + const speed = cl(k[1], 0.2, 3), weather = 0.25 + 1.75 * cl(k[2], 0, 1); + S.hue = k[3]; + S.t += d * speed; + S.wt += d * weather; + place(S.f, S.wt + 3, S.deg); + place(S.g, S.wt + 3, S.deg); + // Body from world: undo the lean, then the turn. + const ph = S.t * 0.025 * TAU, cp = cos(ph), sp = sin(ph), ct = cos(TILT), st = sin(TILT); + // World = Rx(TILT) Ry(ph) body; body = Ry(-ph) Rx(-TILT) world. + // Rx(-TILT): (x, y, z) -> (x, ct y + st z, -st y + ct z). + // Ry(-ph): (x, y, z) -> (cp x - sp z, y, sp x + cp z). + S.M = [cp, sp * st, -sp * ct, 0, ct, st, sp, -cp * st, cp * ct]; + const { wx, wy, wz, fe, fo, ge, go, o1, o2, M } = S, L = S.L; + for (let p = 0; p < S2 * S2; p++) { + const X = wx[p], Y = wy[p], Z = wz[p]; + const x = M[0] * X + M[1] * Y + M[2] * Z, y = M[3] * X + M[4] * Y + M[5] * Z, z = M[6] * X + M[7] * Y + M[8] * Z; + evalField(S.f, x, y, z, L, o1); + evalField(S.g, x, y, z, L, o2); + fe[p] = o1[0]; + fo[p] = o1[1]; + ge[p] = o2[0]; + go[p] = o2[1]; + } + findRoots(S); +} + +// Length of the screen gradient of A + sgn B at LED (x, y), per LED. +function slope(A, B, sgn, x, y) { + const p = y * S2 + x, xl = x > 0 ? p - 1 : p, xr = x < S2 - 1 ? p + 1 : p, yu = y > 0 ? p - S2 : p, yd = y < S2 - 1 ? p + S2 : p; + const gx = (A[xr] - A[xl] + sgn * (B[xr] - B[xl])) / (xr - xl), gy = (A[yd] - A[yu] + sgn * (B[yd] - B[yu])) / ((yd - yu) / S2); + return sqrt(gx * gx + gy * gy) + 1e-9; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { fe, fo, ge, go, cov, wz, c } = S; + const h0 = 0.52 + S.hue, FS = 2.2, ISO = 2, gc = S.d; + // Orange and yellow go brown and olive when dim, so near hue 0.12 the + // pale bands are lifted toward full brightness. + const yl = [0, 0.5].map((o) => { + const h = h0 + o - floor(h0 + o); + return 0.7 * cl(1 - abs(h - 0.12) / 0.11, 0, 1); + }); + hsv(h0 + 0.25, 0.75, 1, gc); + // Screen places of the pairs: the member that faces you. + const pts = []; + for (const r of S.roots) { + const M = S.M; + // World from body is the transpose of M. + let X = M[0] * r[0] + M[3] * r[1] + M[6] * r[2], Y = M[1] * r[0] + M[4] * r[1] + M[7] * r[2], Z = M[2] * r[0] + M[5] * r[1] + M[8] * r[2]; + if (Z < 0) { + X = -X; + Y = -Y; + Z = -Z; + } + pts.push([CX + X * RAD, CY - Y * RAD]); + } + const pulse = 0.75 + 0.25 * sin(S.t * 3), { lf, lg } = S; + // The two odd curves, the same in both disks: fodd(-x) = -fodd(x). + for (let y = 0; y < S2; y++) + for (let x = 0; x < S2; x++) { + const p = y * S2 + x; + lf[p] = cl(1.25 - abs(fo[p]) / slope(fo, fo, 0, x, y), 0, 1) * cov[p]; + lg[p] = cl(1.15 - abs(go[p]) / slope(go, go, 0, x, y), 0, 1) * cov[p]; + } + for (let half = 0; half < 2; half++) { + const sg = half ? -1 : 1; + for (let y = 0; y < S2; y++) + for (let x = 0; x < S2; x++) { + const p = y * S2 + x, a = cov[p]; + if (a <= 0) { + display.setPixel(x, y + half * S2, 0, 0, 0); + continue; + } + // Temperature: two tones apart from a pale middle, in soft bands. + const tv = cl((fe[p] + sg * fo[p]) / FS, -1, 1), m = abs(tv) * 4, band = (floor(m) + sm(0.35, 0.65, m - floor(m))) / 4; + const side = tv < 0 ? 0 : 1, v0 = 0.6 + 0.4 * band; + hsv(h0 + 0.5 * side, 0.1 + 0.72 * band, v0 + (1 - v0) * yl[side] * sm(0, 0.4, band), c); + let shade = (0.86 + 0.14 * wz[p]) * a; + // Pressure: a few contour lines, darker. + const gv = (ge[p] + sg * go[p]) / ISO, fr = abs(gv - round(gv)) * ISO; + shade *= 1 - 0.55 * cl(0.95 - fr / slope(ge, go, sg, x, y), 0, 1); + let R = c[0] * shade, G = c[1] * shade, B = c[2] * shade; + // The curve godd = 0, where the pressure is the same at the antipode. + const ga = lg[p]; + R += (gc[0] - R) * ga; + G += (gc[1] - G) * ga; + B += (gc[2] - B) * ga; + // The curve fodd = 0, where the temperature is. + const fa = lf[p]; + R += (1 - R) * fa; + G += (1 - G) * fa; + B += (1 - B) * fa; + // The pairs: a gold core inside a dark ring, and a glow round it. + for (const q of pts) { + const dd = Math.hypot(x + 0.5 - q[0], y + 0.5 - q[1]); + if (dd >= 6.5) continue; + if (dd < 2.1) { + R = 1; + G = 0.86; + B = 0.45; + } else if (dd < 3.2) { + R *= 0.12; + G *= 0.12; + B *= 0.12; + } else { + const gw = pulse * 0.9 * (1 - (dd - 3.2) / 3.3) ** 1.5; + R += (1 - R) * gw; + G += (0.86 - G) * gw; + B += (0.45 - B) * gw * 0.5; + } + } + display.setPixel(x, y + half * S2, round(255 * min(1, R)), round(255 * min(1, G)), round(255 * min(1, B))); + } + } +} diff --git a/incoming/pattern-study/patterns/kingman-coalescent.js b/incoming/pattern-study/patterns/kingman-coalescent.js new file mode 100644 index 000000000..b4bbf2d2a --- /dev/null +++ b/incoming/pattern-study/patterns/kingman-coalescent.js @@ -0,0 +1,238 @@ +// ===== Patternflow pattern ===== +// Title: Kingman Coalescent +// Family: data +// Idea: A Moran population of 64 runs forward one birth at a time while the panel draws the family tree of the living as a flame chart, now along the bottom row and the past going up, each LED in the colour of the ancestor its column came from at that time, so the narrow blocks at the bottom merge fast into a few wide ones, the last two take about as long to meet as all the others together, and when one of them dies out the common ancestor jumps forward and the panel takes the survivor's colours. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Kingman's coalescent (John Kingman, 1982), traced back through a Moran population (Patrick Moran, 1958) +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Span=3..9, Speed=0.2..3, Width=1..4, Mutation=0..1 +// +// Knob 1: Span. How far back the top row reaches, in units of Kingman time: N(N-1)/2 = 2016 Moran events, about 31 generations of the 64. At 3 a row is 48 events and the common ancestor of all 64 sits on average 1.97 units back, at row 83 of 128; at 6 at row 42; at 9 a row is 143 events, the tree crowds into the bottom 28 rows and above it the one line of ancestors shows its mutations as bands. +// Knob 2: Speed. At 2 the population runs 0.06 units of Kingman time a second, 121 births, and the panel climbs 2.5 rows a second at Span 3; the common ancestor of all 64 moves forward on average every 17 seconds, by about 43 rows. At 0.2 ten times slower, at 3 one and a half times faster. +// Knob 3: Width. LEDs for each traced individual. At 1 all 64 are traced, one column each, and a block gets its dark seam from two LEDs wide; at 2 the first 32 of the population's 64 places, at 3 the first 21, at 4 the first 16, each 4 LEDs with a seam. The common ancestor of a sample can also move back, when a newborn's line joins the sample's tree above it: at 4 it moves forward every 0.34 units of Kingman time and back every 0.5. +// Knob 4: Mutation. The chance that a birth carries a mutation, which steps the child's hue 0.05 to 0.11 round the wheel either way. 0.03 at 0 and 0.066 at the start, about two along each line per unit of Kingman time, so the two families of the last split mostly differ in colour; at 1 0.63, nearly every row of every block steps and the blocks turn to stripes. + +const W = 64, H = 128, N = 64; +// One unit of Kingman time: N(N-1)/2 Moran events, in which a given pair of +// lineages meets once on average going back. +const UNIT = (N * (N - 1)) / 2; +// Every individual ever born gets an id, in order of birth; its parent, the +// event it was born in and its hue sit in a ring of the last RING births. +const RING = 1 << 16, MASK = RING - 1; +// Kingman time run before the first frame, so the history covers the panel. +const BURN = 16; +// Kingman time per second at Speed 1. +const RATE = 0.03; +const { floor, round, random, sin, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +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] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +export function setup(params) { + params.s = { + init: false, ev: 0, next: 0, acc: 0, n: 0, span: 3, speed: 2, mu: 0.05, shift: 0, + par: new Int32Array(RING), born: new Float64Array(RING), hue: new Float32Array(RING), + slot: new Int32Array(N), front: new Int32Array(N), tm: 0, order: new Int32Array(N), anc: new Int32Array(W * H), + c: [0, 0, 0], + }; +} + +// The founders: N individuals with no parent, born at event 0. +function found(S) { + for (let i = 0; i < N; i++) { + S.par[i] = -1; + S.born[i] = 0; + S.hue[i] = 0.58; + S.slot[i] = i; + } + S.next = N; + S.ev = 0; +} + +// One Moran event: a parent picked at random has a child, which takes the +// place of another individual picked at random, who dies. +function event(S) { + const p = floor(random() * N); + let d = floor(random() * (N - 1)); + if (d >= p) d++; + const id = S.next++, j = id & MASK, pa = S.slot[p]; + S.ev++; + S.par[j] = pa; + S.born[j] = S.ev; + // A mutation, with chance mu: the hue steps a little either way. + let m = 0; + if (random() < S.mu) m = (random() < 0.5 ? -1 : 1) * (0.05 + 0.06 * random()); + S.hue[j] = S.hue[pa & MASK] + m; + S.slot[d] = id; +} + +const parent = (S, a) => (a < N || S.next - a > RING - 2 * N ? -1 : S.par[a & MASK]); + +// Going back from the first n slots, always stepping the youngest ancestor +// to its parent, until one is left: returns that common ancestor and, in +// S.tm, the Moran events back to the merge of the last two lineages. +function mrca(S, n) { + const f = S.front; + let k = 0; + for (let i = 0; i < n; i++) { + const a = S.slot[i]; + let dup = false; + for (let j = 0; j < k; j++) if (f[j] === a) dup = true; + if (!dup) f[k++] = a; + } + S.tm = 0; + while (k > 1) { + let m = 0; + for (let j = 1; j < k; j++) if (f[j] > f[m]) m = j; + const a = f[m], q = parent(S, a); + if (q < 0) return -1; + let dup = false; + for (let j = 0; j < k; j++) if (f[j] === q) dup = true; + if (dup) { + f[m] = f[--k]; + S.tm = S.ev - S.born[a & MASK] + 1; + } else f[m] = q; + } + return f[0]; +} + +// Order of two individuals in the flame chart: follow both lines back to +// where they meet; the one that is that ancestor itself comes first, +// otherwise the one whose line left it earlier. The order of two living +// individuals never changes, so the chart only moves where they are born +// and die. +function cmp(S, a, b) { + let pa = -1, pb = -1; + while (a !== b) { + if (a > b) { + const q = parent(S, a); + if (q < 0) return a - b; + pa = a; + a = q; + } else { + const q = parent(S, b); + if (q < 0) return a - b; + pb = b; + b = q; + } + } + if (pa < 0) return -1; + if (pb < 0) return 1; + return pa - pb; +} + +function sortOrder(S) { + const { order, slot } = S, n = S.n; + for (let i = 1; i < n; i++) { + const v = order[i], id = slot[v]; + let j = i - 1; + while (j >= 0 && cmp(S, slot[order[j]], id) > 0) { + order[j + 1] = order[j]; + j--; + } + order[j + 1] = v; + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [3, 2, 1, 0.06], d = cl(dt || 0, 0, 0.1); + S.span = cl(k[0], 3, 9); + S.speed = cl(k[1], 0.2, 3); + const w = round(cl(k[2], 1, 4)), n = floor(W / w); + S.mu = 0.03 + 0.6 * cl(k[3], 0, 1); + if (!S.init) { + found(S); + for (let e = 0; e < BURN * UNIT; e++) event(S); + // Run on until the traced tree is a typical one, its last merge between + // 0.8 and 1.3 times Kingman's mean 2(1 - 1/n) back, and turn the colours + // so that the common ancestor is azure. + const mean = 2 * (1 - 1 / n) * UNIT; + for (let tries = 0; tries < 60; tries++) { + const a = mrca(S, n); + if (a >= 0 && S.tm > 0.8 * mean && S.tm < 1.3 * mean) break; + for (let e = 0; e < UNIT / 5; e++) event(S); + } + const a = mrca(S, n); + if (a >= 0) S.shift = 0.58 - S.hue[a & MASK]; + S.init = true; + } + if (n !== S.n) { + S.n = n; + for (let i = 0; i < n; i++) S.order[i] = i; + } + S.acc += d * S.speed * RATE * UNIT; + while (S.acc >= 1) { + event(S); + S.acc -= 1; + } + sortOrder(S); + // Each lineage's ancestor at each row's time. Rows sit on a grid of + // absolute times, so the picture climbs a whole row at a time. + const { anc, order, slot, born } = S, step = (S.span * UNIT) / (H - 1), K = floor(S.ev / step); + for (let i = 0; i < n; i++) { + let a = slot[order[i]]; + anc[i] = a; + for (let r = 1; r < H; r++) { + const T = (K - r + 1) * step; + for (;;) { + if (born[a & MASK] <= T) break; + const q = parent(S, a); + if (q < 0) break; + a = q; + } + anc[r * W + i] = a; + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.init) return; + const { anc, hue, c } = S, n = S.n; + const col = new Int32Array(W); + for (let x = 0; x < W; x++) col[x] = floor((x * n) / W); + let top = -1; + for (let r = 0; r < H; r++) { + const row = r * W, y = H - 1 - r; + if (top < 0 && anc[row] === anc[row + n - 1]) top = r; + const val = 1 - 0.12 * (r / (H - 1)); + // Each block: a run of columns with one ancestor in this row. A dark + // seam takes its first column when it is two or more wide, and it is + // shaded round like a tube. + for (let x0 = 0; x0 < W; ) { + const a = anc[row + col[x0]]; + let x1 = x0 + 1; + while (x1 < W && anc[row + col[x1]] === a) x1++; + const seam = x0 > 0 && x1 - x0 > 1 ? 1 : 0, l0 = x0 + seam, wd = x1 - l0; + hsv(hue[a & MASK] + S.shift, 0.68, val, c); + for (let x = x0; x < x1; x++) { + if (x < l0) { + display.setPixel(x, y, 0, 0, 0); + continue; + } + const sh = 0.66 + 0.34 * sin((PI * (x - l0 + 0.5)) / wd); + let R = c[0] * 255 * sh, G = c[1] * 255 * sh, B = c[2] * 255 * sh; + // A merge: two blocks of the row below meet here. + if (r > 0 && x > x0 && anc[row - W + col[x]] !== anc[row - W + col[x - 1]]) { + const g = r === top ? 0.95 : 0.7; + R += (255 - R) * g; + G += (255 - G) * g; + B += (255 - B) * g; + } + display.setPixel(x, y, round(R), round(G), round(B)); + } + x0 = x1; + } + } +} From 08cbf7ab5cb9a1d16a441f9896869c0828603be7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:38:39 +0000 Subject: [PATCH 04/10] =?UTF-8?q?incoming:=20Explicit=20Formula,=20Soap=20?= =?UTF-8?q?Film=20=ED=8C=A8=ED=84=B4=20(39=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_01WSruBmW95rasfF2WmM8DZ7 --- .../patterns/explicit-formula.js | 315 +++++++++++++++++ incoming/pattern-study/patterns/soap-film.js | 328 ++++++++++++++++++ 2 files changed, 643 insertions(+) create mode 100644 incoming/pattern-study/patterns/explicit-formula.js create mode 100644 incoming/pattern-study/patterns/soap-film.js diff --git a/incoming/pattern-study/patterns/explicit-formula.js b/incoming/pattern-study/patterns/explicit-formula.js new file mode 100644 index 000000000..6fc82265a --- /dev/null +++ b/incoming/pattern-study/patterns/explicit-formula.js @@ -0,0 +1,315 @@ +// ===== Patternflow pattern ===== +// Title: Explicit Formula +// Family: number +// Idea: The prime staircase psi(x), x running down the rows and psi across, is rebuilt from the zeros of the zeta function one pair at a time: the bare line x - log 2 pi grows waves that sharpen into steps, each step snapping onto its prime power and its band lighting up as it does, while the zeros taken so far light up one by one down a strip at the right and a count at the top. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Riemann's explicit formula (Bernhard Riemann, 1859), in von Mangoldt's form for the Chebyshev function psi (Hans von Mangoldt, 1895), pattern round 39 +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Reach=24..144, Tilt=0..1 +// +// Knob 1: Hue. Turns the fill round the wheel, the shortfall colour a quarter turn on; gold stays gold. At 0 azure fill, violet where the sum falls short of the staircase and gold where it runs past; at 0.25 violet with orange, at 0.5 pale orange with green, at 0.75 green with azure. Orange and yellow fill is paled and dims less in its shaded bands, so it never reads brown. +// Knob 2: Speed. How fast the zeros come in, each a little faster than the last: at 2 the first is in after 0.7 s, 10 by 4.2 s, 100 by 10.9 s and all 200 by 13.1 s; the staircase holds for 4 s, the zeros are taken out again in 1.9 s and the bare line rests 1 s, 20 s round. At 0.2 ten times slower, at 3 one and a half times faster. +// Knob 3: Reach. How far x runs down the 128 rows, from 1 to Reach, with psi = 0 on column 3 and psi = Reach on column 57. At 24, the start, 13 prime powers, 5.6 rows to each unit of x and steps 1.6 to 7 LEDs wide; at 84 33 of them; at 144 47, the steps under 2 LEDs, and the staircase leans on the line x as the prime number theorem says it must. The 200 zeros resolve the staircase less sharply the further x runs. +// Knob 4: Tilt. Takes Tilt times x away from both curves and widens the scale to match. Up to 0.06 the staircase itself; at 1 only psi(x) - x is left, a sawtooth that slides left one unit for each unit of x and jumps right by log p at each prime power, drawn 5 LEDs to the unit at Reach 24: the part of psi that the zeros alone make, their waves summing to the teeth. +// Buttons: K1 tap takes every zero out and starts adding them again from the first. + +const W = 64, H = 128, SS = 4, NS = H * SS, NZ = 200; +// The imaginary parts of the first 200 zeros of the zeta function on the +// critical line, rho = 1/2 + i gamma, to six places. 14.134725 is the first, +// 396.381854 the 200th. +const GAM = [ + 14.134725, 21.022040, 25.010858, 30.424876, 32.935062, 37.586178, 40.918719, 43.327073, 48.005151, 49.773832, + 52.970321, 56.446248, 59.347044, 60.831779, 65.112544, 67.079811, 69.546402, 72.067158, 75.704691, 77.144840, + 79.337375, 82.910381, 84.735493, 87.425275, 88.809111, 92.491899, 94.651344, 95.870634, 98.831194, 101.317851, + 103.725538, 105.446623, 107.168611, 111.029536, 111.874659, 114.320221, 116.226680, 118.790783, 121.370125, 122.946829, + 124.256819, 127.516684, 129.578704, 131.087689, 133.497737, 134.756510, 138.116042, 139.736209, 141.123707, 143.111846, + 146.000982, 147.422765, 150.053520, 150.925258, 153.024694, 156.112909, 157.597592, 158.849988, 161.188964, 163.030710, + 165.537069, 167.184440, 169.094515, 169.911976, 173.411537, 174.754192, 176.441434, 178.377408, 179.916484, 182.207078, + 184.874468, 185.598784, 187.228923, 189.416159, 192.026656, 193.079727, 195.265397, 196.876482, 198.015310, 201.264752, + 202.493595, 204.189672, 205.394697, 207.906259, 209.576510, 211.690863, 213.347919, 214.547045, 216.169539, 219.067596, + 220.714919, 221.430706, 224.007000, 224.983325, 227.421444, 229.337413, 231.250189, 231.987235, 233.693404, 236.524230, + 237.769820, 239.555478, 241.049158, 242.823272, 244.070898, 247.136990, 248.101990, 249.573690, 251.014948, 253.069987, + 255.306256, 256.380714, 258.610439, 259.874407, 260.805085, 263.573894, 265.557852, 266.614974, 267.921915, 269.970449, + 271.494056, 273.459609, 275.587493, 276.452050, 278.250744, 279.229251, 282.465115, 283.211186, 284.835964, 286.667445, + 287.911921, 289.579855, 291.846291, 293.558434, 294.965370, 295.573255, 297.979277, 299.840326, 301.649325, 302.696750, + 304.864371, 305.728913, 307.219496, 310.109463, 311.165142, 312.427801, 313.985286, 315.475616, 317.734806, 318.853104, + 321.160134, 322.144559, 323.466970, 324.862866, 327.443901, 329.033072, 329.953240, 331.474468, 333.645379, 334.211355, + 336.841850, 338.339993, 339.858217, 341.042261, 342.054878, 344.661703, 346.347871, 347.272678, 349.316261, 350.408419, + 351.878649, 353.488900, 356.017575, 357.151302, 357.952685, 359.743755, 361.289362, 363.331331, 364.736024, 366.212710, + 367.993575, 368.968438, 370.050919, 373.061928, 373.864874, 375.825913, 376.324092, 378.436680, 379.872975, 381.484469, + 383.443529, 384.956117, 385.861301, 387.222890, 388.846128, 391.456084, 392.245083, 393.427744, 395.582870, 396.381854, +]; +// psi = 0 sits on column C0 and psi = Reach on column C0 + CW. The critical +// line runs down columns ZC..ZC + 2, gamma 0 to GTOP over the 128 rows. +const C0 = 3, CW = 54, ZC = 61, GTOP = 400; +// Zeros come in at dz/dt = ZB (ZA + z) a second at Speed 2: the first after +// 0.7 s, 10 by 4.2 s, 100 by 10.9 s, all 200 by 13.1 s. Then they hold for +// HOLD seconds, go out BACK times as fast and the bare line rests REST. +const ZA = 4, ZB = 0.3, HOLD = 4, BACK = 7, REST = 1; +// Opening hue: azure. +const H0 = 0.56; +const LOG2PI = Math.log(2 * Math.PI); +const { floor, min, max, abs, sqrt, log, exp, sin, cos, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// 3 x 5 digits, row by row from the top, three bits a row. +const FONT = [ + 0b111101101101111, 0b010110010010111, 0b111001111100111, 0b111001111001111, 0b101101111001001, + 0b111100111001111, 0b111100111101111, 0b111001001001001, 0b111101111101111, 0b111101111001111, +]; + +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); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[0] = rgb[0]; + out[1] = rgb[1]; + out[2] = rgb[2]; +} + +// The prime powers p^k up to X, each with log p and k. +function primePowers(X) { + const out = []; + for (let n = 2; n <= X; n++) { + let p = 2; + while (n % p) p++; + let m = n, k = 0; + while (m % p === 0) (m /= p), k++; + if (m === 1) out.push([n, log(p), k]); + } + return out; +} + +export function setup(params) { + params.s = { + hue: 0, speed: 2, reach: 0, tilt: 0, z: 0, stage: 0, clock: 0, nIn: 0, + xs: new Float64Array(NS), band: new Uint8Array(NS), base: new Float64Array(NS), tru: new Float64Array(NS), A: new Float64Array(NS), + v: new Float64Array(NS), lit: new Float32Array(64), err: new Float64Array(64), cnt: new Float64Array(64), bx: new Float64Array(64), cN: new Float32Array(SS), cT: new Float32Array(SS), pp: [], + R: new Float32Array(W * H), G: new Float32Array(W * H), B: new Float32Array(W * H), + fill0: [0, 0, 0], fill1: [0, 0, 0], bg: [0, 0, 0], rim: [0, 0, 0], dot: [0, 0, 0], over: [0, 0, 0], short: [0, 0, 0], + }; +} + +// The pair rho, its conjugate at sample j: 2 Re(x^rho / rho) +// = 2 sqrt(x) (cos(g L) / 2 + g sin(g L)) / (1/4 + g^2), L = log x. +function term(S, n, j) { + const g = GAM[n], x = S.xs[j], L = log(x); + return (2 * sqrt(x) * (0.5 * cos(g * L) + g * sin(g * L))) / (0.25 + g * g); +} + +// Zero n fully in (sign 1) or taken out (sign -1). +function addZero(S, n, sign) { + for (let j = 0; j < NS; j++) S.A[j] += sign * term(S, n, j); +} + +function setReach(S, X) { + S.reach = X; + S.pp = primePowers(X); + for (let j = 0; j < NS; j++) { + // Row y holds x from 1 + y (X - 1) / 128 to one row further on. + const x = 1 + ((j + 0.5) / NS) * (X - 1); + S.xs[j] = x; + S.base[j] = x - LOG2PI - 0.5 * log(1 - 1 / (x * x)); + let p = 0, c = 0; + for (const q of S.pp) if (q[0] <= x) (p += q[1]), c++; + S.tru[j] = p; + S.band[j] = c; + } + S.A.fill(0); + S.lit.fill(0); + for (let n = 0; n < S.nIn; n++) addZero(S, n, 1); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 24, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + // Up to 0.06 upright as it is, so the opening shows the staircase itself. + S.tilt = max(0, cl(k[3], 0, 1) - 0.06) / 0.94; + const X = round(cl(k[2], 24, 144)); + if (X !== S.reach) setReach(S, X); + if (input.btnPressed && input.btnPressed[0]) { + S.stage = 0; + S.z = 0; + } + const sp = (d * S.speed) / 2; + if (S.stage === 0) { + S.z += sp * ZB * (ZA + S.z); + if (S.z >= NZ) (S.z = NZ), (S.stage = 1), (S.clock = 0); + } else if (S.stage === 1) { + S.clock += sp; + if (S.clock > HOLD) S.stage = 2; + } else if (S.stage === 2) { + S.z -= sp * ZB * BACK * (ZA + S.z); + if (S.z <= 0) (S.z = 0), (S.stage = 3), (S.clock = 0); + } else { + S.clock += sp; + if (S.clock > REST) S.stage = 0; + } + const n = min(NZ, floor(S.z)); + while (S.nIn < n) addZero(S, S.nIn++, 1); + while (S.nIn > n) addZero(S, --S.nIn, -1); + // The sum at every sample, the fading zero in by its fraction. + const { v, xs, base, tru, A, err, cnt, bx, band, lit } = S, f = n < NZ ? S.z - n : 0; + for (let j = 0; j < NS; j++) v[j] = base[j] - A[j] - (f > 0 ? f * term(S, n, j) : 0); + // A band has snapped onto its step when the sum's mean distance from the + // staircase across it is under 0.04 sqrt(x); it starts to light at 0.12. + const nb = S.pp.length + 1; + err.fill(0, 0, nb); + cnt.fill(0, 0, nb); + bx.fill(0, 0, nb); + for (let j = 0; j < NS; j++) { + const q = band[j]; + err[q] += abs(v[j] - tru[j]); + bx[q] += xs[j]; + cnt[q]++; + } + const ease = 1 - exp(-d / 0.3); + for (let q = 0; q < nb; q++) { + const r = cnt[q] ? err[q] / cnt[q] / sqrt(bx[q] / cnt[q]) : 1, u = cl((0.12 - r) / 0.08, 0, 1); + lit[q] += (u * u * (3 - 2 * u) - lit[q]) * ease; + } +} + +// A colour that would read brown or olive, a dark orange or yellow, is +// paled toward grey and lifted. +function tone(h, s, v, out) { + const hh = h - floor(h), warm = cl(1.2 - abs(hh - 0.12) / 0.13, 0, 1) * (1 - v); + hsv(h, s * (1 - 0.9 * warm), v * (1 + 1.1 * warm), out); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { R, G, B, v, xs, tru, cN, cT, fill0, fill1, bg, rim, dot, over, short } = S; + const X = S.reach, n = S.nIn, f = n < NZ ? S.z - n : 0, t = S.tilt; + // Columns: psi - t x, so at Tilt 1 only what the zeros add is left, the + // scale closing in from the whole of psi to about 2.2 sqrt(Reach). + const sc = CW / ((1 - t) * X + t * 2.2 * sqrt(X)), c0 = C0 + 35 * t; + + // Orange and yellow fill dims less, so its shaded bands never read brown. + const h = S.hue + H0, hh = h - floor(h), keep = 1 - 0.7 * cl(1.2 - abs(hh - 0.12) / 0.13, 0, 1); + tone(h, 0.8, 0.6, fill0); + tone(h + 0.05, 0.75, 1, fill1); + hsv(0.64, 0.45, 0.15, bg); + tone(h + 0.25, 0.65, 0.8, short); + hsv(h - 0.03, 0.2, 1, rim); + hsv(h, 0.35, 1, dot); + over[0] = 1; + over[1] = 0.8; + over[2] = 0.45; + // The newest zero's wave, faintly in the fill while it comes in. + const wave = n < NZ ? 0.1 * sin(PI * f) * sqrt(GAM[0] / GAM[n]) : 0, gw = n < NZ ? GAM[n] : 0; + + let prevC = c0 + (v[0] - t * xs[0]) * sc; + for (let y = 0; y < H; y++) { + const u = y / (H - 1); + const fr = fill0[0] + (fill1[0] - fill0[0]) * u, fg = fill0[1] + (fill1[1] - fill0[1]) * u, fb = fill0[2] + (fill1[2] - fill0[2]) * u; + const bk = 0.8 + 0.4 * u; + // Where the curve runs in this row: the span between the last sample of + // the row above and every sample of this one. + let lo = prevC, hi = prevC; + for (let q = 0; q < SS; q++) { + const j = y * SS + q, cc = c0 + (v[j] - t * xs[j]) * sc; + cN[q] = cc; + cT[q] = c0 + (tru[j] - t * xs[j]) * sc; + if (cc < lo) lo = cc; + if (cc > hi) hi = cc; + prevC = cc; + } + // Between one prime power and the next the fill is a band, every other + // band a little lighter, so the gaps between them show as band heights, + // and a band stays dim until the sum has snapped onto its step. + const bq = S.band[y * SS + 2]; + const wv = (1 + wave * sin(gw * log(xs[y * SS + 2]))) * (bq & 1 ? 1.08 : 0.92) * (0.45 + 0.55 * S.lit[bq]); + const edge = (cN[0] + cN[1] + cN[2] + cN[3]) / 4; + for (let x = 0; x < W; x++) { + const p = y * W + x; + // Under both the sum and the staircase: the fill. Under the sum alone, + // where it runs past the staircase: gold. Under the staircase alone, + // where the sum falls short: the second colour. Under neither: dark. + let both = 0, ov = 0, sh = 0; + for (let q = 0; q < SS; q++) { + const a = cl(cN[q] - x, 0, 1), b = cl(cT[q] - x, 0, 1), m = a < b ? a : b; + both += m; + ov += a - m; + sh += b - m; + } + both /= SS; + ov /= SS; + sh /= SS; + const no = 1 - both - ov - sh, gl = 1 - (1 - wv * (0.55 + 0.45 * exp((x - edge) / 9))) * keep; + let r = bg[0] * bk * no + fr * gl * both + over[0] * ov + short[0] * sh; + let g = bg[1] * bk * no + fg * gl * both + over[1] * ov + short[1] * sh; + let b = bg[2] * bk * no + fb * gl * both + over[2] * ov + short[2] * sh; + // The curve itself: every LED its span touches. + const rr = cl(1 - max(lo - (x + 1), x - hi), 0, 1) * 0.75; + r += (rim[0] - r) * rr; + g += (rim[1] - g) * rr; + b += (rim[2] - b) * rr; + R[p] = r; + G[p] = g; + B[p] = b; + } + } + // Ticks down the left edge at each prime power: three LEDs for a prime, + // two for a square, one for a higher power. + for (const q of S.pp) { + const y = floor(((q[0] - 1) / (X - 1)) * H); + if (y < 0 || y >= H) continue; + const len = q[2] === 1 ? 3 : q[2] === 2 ? 2 : 1; + for (let x = 0; x < len; x++) { + const p = y * W + x; + R[p] = over[0]; + G[p] = over[1]; + B[p] = over[2]; + } + } + // The critical line: every zero a dot at its height, dim until it is in. + for (let x = ZC; x < W; x++) + for (let y = 0; y < H; y++) { + const p = y * W + x; + R[p] *= 0.5; + G[p] *= 0.5; + B[p] *= 0.5; + } + for (let m = 0; m < NZ; m++) { + const yy = (GAM[m] / GTOP) * H - 0.5, y0 = floor(yy), fy = yy - y0; + const a = m < n ? 1 : m === n ? 0.15 + 0.85 * f : 0.15, flash = m === n ? 1 : m === n - 1 ? 1 - f : 0; + for (let x = ZC; x < W; x++) + for (let e = 0; e < 2; e++) { + const y = y0 + e, wgt = e ? fy : 1 - fy; + if (y < 0 || y >= H) continue; + const p = y * W + x; + R[p] += wgt * a * (dot[0] + flash); + G[p] += wgt * a * (dot[1] + flash); + B[p] += wgt * a * (dot[2] + flash); + } + } + // How many zeros are in, at the top right. + const digits = String(min(NZ, round(S.z))); + let x0 = ZC - 3 - (digits.length * 8 - 2); + for (const ch of digits) { + const bits = FONT[+ch]; + for (let r = 0; r < 5; r++) + for (let cc = 0; cc < 3; cc++) + if ((bits >> (14 - r * 3 - cc)) & 1) + for (let a = 0; a < 4; a++) { + const p = (3 + r * 2 + (a >> 1)) * W + x0 + cc * 2 + (a & 1); + R[p] = dot[0]; + G[p] = dot[1]; + B[p] = dot[2]; + } + x0 += 8; + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + display.setPixel(x, y, round(255 * min(1, R[p])), round(255 * min(1, G[p])), round(255 * min(1, B[p]))); + } +} diff --git a/incoming/pattern-study/patterns/soap-film.js b/incoming/pattern-study/patterns/soap-film.js new file mode 100644 index 000000000..065790d4f --- /dev/null +++ b/incoming/pattern-study/patterns/soap-film.js @@ -0,0 +1,328 @@ +// ===== Patternflow pattern ===== +// Title: Soap Film +// Family: cosmetic +// Idea: A soap film fills the panel and drains, thinner toward the top, so Newton's interference colours stack in bands that sink while slow eddies wind them into spirals, thin film pulled from the sides rises in plumes and the black film spreads down from the top, until the film bursts and a new one is drawn up from the bottom. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 39, after the film of a foaming cleanser stretched across a ring, coloured by thin-film interference in Newton's colour series (Isaac Newton, Opticks, 1704) and thinning from the top as it drains +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Light=0..1, Swirl=0..1 +// +// Knob 1: Hue. Turns every colour of the film round the grey axis together; the black film stays grey. At 0 Newton's own colours: a gold and cream band of the first order under the black film, then indigo, blue, yellow, orange and magenta, then pastel greens and pinks lower down. At 0.25 the first-order band is green, at 0.5 sky blue, at 0.75 magenta, the rest following. Orange, yellow and lime that come out dark are lifted most of the way to full and paled a little, so they read amber, gold or lime and never brown or olive, and no colour of the film falls below a fifth. +// Knob 2: Speed. At 2 the film opens 2 s into its drainage; the black film appears at the top within a second and comes down 7.7 LEDs a second, past the middle by 9 s, while the bottom thins from 950 nm when drawn to 550 six seconds later. When the black film is 68% of the way down, 11.8 s in, the film bursts: a hole opens near the top and its rim runs across the panel in 0.9 s throwing spray, the panel stays dark 0.4 s and a new film is drawn up from the bottom in 1.2 s, 15 s round after the first. At 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Light. The wall behind the film, black at 0, so the black film reads black. Turned up, a white wall shows through wherever the film reflects little: the black film and the hole of a burst go grey and then white, and the colours pale toward pastel; at 1 a pearly white sheet with pastel bands. +// Knob 4: Swirl. How hard the eddies spin. One starts every 1.1 to 2.1 s somewhere in the film, 9 to 17 LEDs across its core, and spins up and down over 4 to 8 s; the film keeps the twist and drainage sorts it back into layers over about 8 s. At 0 an eddy winds the bands most of a turn at its core, at the start about one turn, at 1 two and a half, curling the gold edge of the black film into spirals. +// Buttons: K1 tap bursts the film now, and a new one is drawn up after it. + +const W = 64, H = 128, N = W * H; +// Film index, and the thickness table in nm. +const NF = 1.33, HMAX = 1400; +// Thinner than this the film is black film, nm. +const BLACK = 30; +// Seconds of drainage at Speed 2 before the first frame. +const AGE0 = 2; +// Gain and gamma of the table. +const GAIN = 0.95, GAM = 0.8; +const { floor, min, max, abs, sqrt, exp, sin, cos, round, random, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// CIE 1931 colour matching, the piecewise Gaussian fit of Wyman, Sloan and +// Shirley (2013). +const g1 = (l, m, a, b) => { + const t = (l - m) / (l < m ? a : b); + return exp(-0.5 * t * t); +}; +const cmf = (l) => [ + 1.056 * g1(l, 599.8, 37.9, 31) + 0.362 * g1(l, 442, 16, 26.7) - 0.065 * g1(l, 501.1, 20.4, 26.2), + 0.821 * g1(l, 568.8, 46.9, 40.5) + 0.286 * g1(l, 530.9, 16.3, 31.1), + 1.217 * g1(l, 437, 11.8, 36) + 0.681 * g1(l, 459, 26, 13.8), +]; +const toRGB = (X, Y, Z) => [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z, 0.0557 * X - 0.204 * Y + 1.057 * Z]; + +// Reflectance of a film h nm thick, two beams: the front face reflects with +// r = (1 - n) / (1 + n) and the back with -r, a half wave apart, so +// R = 4 r^2 sin^2(2 pi n h / lambda), zero as h goes to zero. Summed over +// 400 to 700 nm in 10 nm steps against an equal-energy light and divided by +// the full white, so a film reflecting every wavelength fully is 1, 1, 1. +function baseTable() { + const L = [], C = []; + for (let l = 400; l <= 700; l += 10) L.push(l), C.push(cmf(l)); + let wx = 0, wy = 0, wz = 0; + for (const c of C) (wx += c[0]), (wy += c[1]), (wz += c[2]); + const wr = toRGB(wx, wy, wz); + const T = new Float32Array(HMAX * 3); + for (let h = 0; h < HMAX; h++) { + let X = 0, Y = 0, Z = 0; + for (let i = 0; i < L.length; i++) { + const s = sin((2 * PI * NF * h) / L[i]), R = s * s; + X += R * C[i][0]; + Y += R * C[i][1]; + Z += R * C[i][2]; + } + const c = toRGB(X, Y, Z); + for (let k = 0; k < 3; k++) T[h * 3 + k] = max(0, c[k] / wr[k]); + } + return T; +} + +function hsvOf(r, g, b) { + const mx = max(r, g, b), mn = min(r, g, b), d = mx - mn; + let h = 0; + if (d > 1e-6) h = mx === r ? ((g - b) / d + 6) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; + return [h / 6, mx > 0 ? d / mx : 0, mx]; +} +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 rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[o] = rgb[0]; + out[o + 1] = rgb[1]; + out[o + 2] = rgb[2]; +} + +// The table as the panel shows it: turned round the grey axis by the hue, +// a light gamma, and paled toward a white wall behind as Light turns up. +function displayTable(S) { + const B = S.base, D = S.lut, a = 2 * PI * S.hue, ca = cos(a), sa = sin(a), k = (1 - ca) / 3, q = sa / sqrt(3); + const m = [ca + k, k - q, k + q, k + q, ca + k, k - q, k - q, k + q, ca + k]; + const lw = S.light, tmp = [0, 0, 0]; + for (let h = 0; h < HMAX; h++) { + const r = B[h * 3], g = B[h * 3 + 1], b = B[h * 3 + 2]; + const Y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let R, G, Bb; + if (h < BLACK) { + // Black film keeps its cool grey whatever the hue. + const v = (GAIN * Y) ** GAM; + (R = 0.85 * v), (G = 0.93 * v), (Bb = 1.1 * v); + } else { + R = min(1, max(0, m[0] * r + m[1] * g + m[2] * b) * GAIN) ** GAM; + G = min(1, max(0, m[3] * r + m[4] * g + m[5] * b) * GAIN) ** GAM; + Bb = min(1, max(0, m[6] * r + m[7] * g + m[8] * b) * GAIN) ** GAM; + // No colour of the film itself falls below a fifth; and orange, yellow + // and lime that come out dark, which read brown or olive, are lifted + // most of the way to full and paled a little. + const [hh, ss, vv] = hsvOf(R, G, Bb), warm = cl(1.4 - abs(hh - 0.15) / 0.12, 0, 1) * sm(0.97, 0.6, vv); + const v2 = 0.2 + 0.8 * vv; + hsv(hh, ss * (1 - 0.3 * warm), v2 + (0.95 - v2) * warm, tmp, 0); + (R = tmp[0]), (G = tmp[1]), (Bb = tmp[2]); + } + // A white wall behind shows through where the film reflects little. + const wl = lw * (0.35 + 0.65 * (1 - min(1, Y))); + D[h * 3] = 250 * (R + (1 - R) * wl); + D[h * 3 + 1] = 250 * (G + (1 - G) * wl); + D[h * 3 + 2] = 250 * (Bb + (1 - Bb) * wl); + } +} + +const hash = (n) => { + const s = sin(n * 12.9898) * 43758.5453; + return s - floor(s); +}; + +// Smooth value noise on a 64 x 64 lattice that wraps. +function noise(L, x, y) { + const xi = floor(x), yi = floor(y), fx = x - xi, fy = y - yi; + const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy); + const x0 = xi & 63, y0 = yi & 63, x1 = (x0 + 1) & 63, y1 = (y0 + 1) & 63; + const a = L[y0 * 64 + x0], b = L[y0 * 64 + x1], c = L[y1 * 64 + x0], d = L[y1 * 64 + x1]; + return a + (b - a) * ux + (c - a + (d - c - b + a) * ux) * uy; +} + +export function setup(params) { + const L = new Float32Array(64 * 64); + for (let i = 0; i < L.length; i++) L[i] = random() * 2 - 1; + params.s = { + hue: -1, light: -1, speed: 2, swirl: 0, begun: false, t: 0, age: 0, + base: baseTable(), lut: new Float32Array(HMAX * 3), L, + MX: new Float32Array(N), MY: new Float32Array(N), T: new Float32Array(N), + NX: new Float32Array(N), NY: new Float32Array(N), NT: new Float32Array(N), + eddies: [], nextEddy: 0, nextPlume: 0, side: false, hole: null, draw: 0, + }; + reset(params.s); +} + +// A fresh film: every point where it was drawn, nothing thin in it yet. +function reset(S) { + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + S.MX[y * W + x] = x; + S.MY[y * W + x] = y; + } + S.T.fill(0); + S.age = 0; +} + +function spawnEddy(S) { + S.eddies.push({ + x: 8 + random() * 48, y: 16 + random() * 104, R: 9 + random() * 8, + w: (random() < 0.5 ? -1 : 1) * (0.7 + 0.6 * random()), age: 0, life: 4 + 4 * random(), + vx: (random() - 0.5) * 3, vy: (random() - 0.3) * 3, + }); +} + +// Thin film pulled out of the border at the sides, which then rises. +function spawnPlume(S, front) { + S.side = !S.side; + const x0 = S.side ? 0.5 + random() * 3 : W - 1.5 - random() * 3; + const y0 = H * (front + (1 - front) * (0.25 + 0.75 * random())), r = 2 + random() * 1.5, a = 0.7 + 0.3 * random(); + for (let y = max(0, floor(y0 - 7 * r)); y <= min(H - 1, y0 + 7 * r); y++) + for (let x = max(0, floor(x0 - 3 * r)); x <= min(W - 1, x0 + 3 * r); x++) { + const d2 = ((x - x0) ** 2 + ((y - y0) * 0.4) ** 2) / (r * r), p = y * W + x; + S.T[p] = min(1, S.T[p] + a * exp(-d2)); + } +} + +function bilerp(A, x, y) { + x = cl(x, 0, W - 1.001); + y = cl(y, 0, H - 1.001); + const xi = floor(x), yi = floor(y), fx = x - xi, fy = y - yi, p = yi * W + xi; + const a = A[p], b = A[p + 1], c = A[p + W], d = A[p + W + 1]; + return a + (b - a) * fx + (c - a + (d - c - b + a) * fx) * fy; +} + +// Where the black film reaches, 0 at the top and 1 at the bottom, for a +// film of this age. +const frontAt = (age) => cl((age - 2.5) * 0.06, 0, 1); + +function step(S, d) { + S.t += d; + S.age += d; + const front = frontAt(S.age); + // Eddies: each spins up and down over its life; the film keeps the twist. + if (S.t > S.nextEddy) { + spawnEddy(S); + S.nextEddy = S.t + 1.1 + random(); + } + for (const e of S.eddies) { + e.age += d; + e.x += e.vx * d; + e.y += e.vy * d; + } + S.eddies = S.eddies.filter((e) => e.age < e.life); + if (S.t > S.nextPlume && !S.draw && !S.hole) { + spawnPlume(S, front); + S.nextPlume = S.t + 0.12 + 0.12 * random(); + } + const { MX, MY, T, NX, NY, NT, L } = S, sw = 1.4 + 2.6 * S.swirl, t = S.t; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + // Fine turbulence everywhere, two noise fields as the flow. + const n1 = noise(L, x * 0.21 + t * 0.4, y * 0.21 - t * 0.3), n2 = noise(L, x * 0.21 + 17.3 - t * 0.35, y * 0.21 + 9.1 + t * 0.25); + let vx = 2.2 * n1, vy = 2.2 * n2; + for (const e of S.eddies) { + const dx = x - e.x, dy = y - e.y, r2 = dx * dx + dy * dy, R2 = e.R * e.R; + if (r2 > 6 * R2) continue; + const env = sin((PI * e.age) / e.life), om = sw * e.w * env * exp(-r2 / R2); + vx -= om * dy; + vy += om * dx; + } + const qx = x - vx * d, qy = y - vy * d; + NX[p] = bilerp(MX, qx, qy); + NY[p] = bilerp(MY, qx, qy); + // Thin film from the border is lighter and rises through the rest. + NT[p] = bilerp(T, qx, qy + 15 * T[p] * d) * (1 - 0.1 * d); + } + // Drainage sorts the film back into layers: every point eases toward where + // it is, over about 8 s. + const rx = 1 - exp(-d / 8); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + MX[p] = NX[p] + (x - NX[p]) * rx; + MY[p] = NY[p] + (y - NY[p]) * rx; + T[p] = NT[p]; + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.speed = cl(k[1], 0.2, 3); + S.swirl = cl(k[3], 0, 1); + const hue = k[0] - floor(k[0]), light = cl(k[2], 0, 1); + if (abs(hue - S.hue) > 1e-3 || abs(light - S.light) > 1e-3) { + S.hue = hue; + S.light = light; + displayTable(S); + } + if (!S.begun) { + S.begun = true; + for (let i = 0; i < AGE0 * 10; i++) step(S, 0.1); + } + if (input.btnPressed && input.btnPressed[0] && !S.hole) S.hole = { x: 10 + random() * 44, y: 4 + random() * 10, r: 0, gap: 0 }; + const sd = (d * S.speed) / 2; + if (!S.hole && frontAt(S.age) > 0.68) S.hole = { x: 10 + random() * 44, y: 4 + random() * 10, r: 0, gap: 0 }; + if (S.hole) { + const o = S.hole; + if (o.r < 160) o.r += 180 * sd; + else { + o.gap += sd; + if (o.gap > 0.4) { + reset(S); + S.hole = null; + S.draw = 1; + } + } + } + if (S.draw > 0) S.draw = max(0, S.draw - sd / 1.2); + step(S, sd); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { MX, MY, T, L, lut } = S, age = S.age, t = S.t; + const front = frontAt(age); + // Thickness at the bottom and just under the black film, nm. + const hb = 950 / sqrt(1 + age / 3), ht = 90 + 260 * exp(-age / 1.2); + const o = S.hole, dy = S.draw > 0 ? H * S.draw : -1, bgv = 255 * S.light; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + if (o && (x - o.x) ** 2 + (y - o.y) ** 2 < o.r * o.r) { + display.setPixel(x, y, round(bgv * 0.95 + 4), round(bgv * 0.95 + 4), round(bgv * 0.97 + 7)); + continue; + } + if (y < dy) { + display.setPixel(x, y, round(bgv * 0.95 + 4), round(bgv * 0.95 + 4), round(bgv * 0.97 + 7)); + continue; + } + // Where this bit of film sat when the film was layered, and how much + // thin film from the border it carries. + const mx = MX[p], my = MY[p]; + let u = my / (H - 1) - 0.2 * T[p]; + if (dy >= 0) u = (u - S.draw) / (1 - S.draw); + const edge = front - 0.02 + 0.025 * noise(L, mx * 0.09 + 31, t * 0.15), e0 = max(0, edge); + let h; + if (u < edge) { + // Black film, 1 to 21 nm, reflecting next to nothing. + h = 11 + 6 * noise(L, mx * 0.45 + 5, my * 0.45) + 4 * noise(L, mx * 1.2 + 23, my * 1.2 - t * 0.3); + } else { + const s = cl((u - e0) / (1 - e0), 0, 1); + h = ht + (hb - ht) * s ** 0.8; + h += 16 * noise(L, mx * 0.75 + 40, my * 0.75) + 6 * noise(L, x * 0.55 + t * 0.6, y * 0.55); + } + h = cl(h, 0, HMAX - 2); + const hi = floor(h), f = h - hi, a = hi * 3; + let r = lut[a] + (lut[a + 3] - lut[a]) * f, g = lut[a + 1] + (lut[a + 4] - lut[a + 1]) * f, b = lut[a + 2] + (lut[a + 5] - lut[a + 2]) * f; + if (o) { + // The rim the burst gathers, running out ahead of the hole, and the + // spray it throws. + const dr = Math.hypot(x - o.x, y - o.y) - o.r; + let rim = dr < 4 ? exp(-dr * 0.7) : 0; + if (dr < 9 && hash(x * 7 + y * 131 + floor(o.r) * 17) > 0.9) rim = max(rim, 1 - dr / 9); + r += (250 - r) * rim; + g += (250 - g) * rim; + b += (255 - b) * rim; + } + display.setPixel(x, y, round(cl(r, 0, 255)), round(cl(g, 0, 255)), round(cl(b, 0, 255))); + } +} From 8dc0c935f2a5a470aa3d0194953cdc8462da49ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:39:26 +0000 Subject: [PATCH 05/10] =?UTF-8?q?incoming:=20Isospectral=20Drums,=20Crackl?= =?UTF-8?q?e=20Polish=20=ED=8C=A8=ED=84=B4=20(39=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_01WSruBmW95rasfF2WmM8DZ7 --- .../pattern-study/patterns/crackle-polish.js | 363 ++++++++++++++++++ .../patterns/isospectral-drums.js | 348 +++++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 incoming/pattern-study/patterns/crackle-polish.js create mode 100644 incoming/pattern-study/patterns/isospectral-drums.js diff --git a/incoming/pattern-study/patterns/crackle-polish.js b/incoming/pattern-study/patterns/crackle-polish.js new file mode 100644 index 000000000..9d989f047 --- /dev/null +++ b/incoming/pattern-study/patterns/crackle-polish.js @@ -0,0 +1,363 @@ +// ===== Patternflow pattern ===== +// Title: Crackle Polish +// Family: cosmetic +// Idea: A glossy colour fills the panel, a dark top coat is brushed over it from the top down, and as the coat dries it splits, first in long cracks across the panel and then in shorter and shorter cracks that each start on an older one at a right angle, while the gaps widen and the islands of coat shrink into rounded beads with a gloss on their edges and the colour glowing up through the network, until a fresh coat goes on. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 39, after crackle nail polish, a top coat that shrinks as it dries, and the hierarchical crack patterns that form by successive division of domains (Steffen Bohn, Ludovic Pauchard and Yves Couder, 2005) +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Craze=0..1, Coat=0..1 +// +// Knob 1: Hue. Turns the base colour round the whole wheel: at 0 hot pink, 0.1 red, 0.15 orange, 0.22 yellow, 0.3 lime, 0.4 green, 0.55 aqua, 0.65 azure, 0.75 blue, 0.85 violet, 0.95 magenta. Orange and yellow bases are paled a little, lit to full and shaded less beside the islands, so no gap turns brown. +// Knob 2: Speed. At 2 the coat goes on in 1.2 s, the first crack opens 0.6 s later and runs across the panel in about a second, the last starts about 4.5 s after the first, and when the gaps have finished widening the crackle holds until a new coat goes on, about 11 s after the last. At 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Craze. How far the coat keeps splitting. At 0 a domain stops splitting once it is under about 20 LEDs long, which leaves about 50 islands, most of them 9 to 18 LEDs across; at 1 under about 11, about 155 islands of 5 to 10, and the gaps open half as wide. +// Knob 4: Coat. The colour of the top coat: black lacquer at 0, a deep jewel tone opposite the base on the wheel at 0.5, a pale pearl at 1. On the dark coats the gloss is a white rim on the edges facing the light; on the pearl the edges turned away fall into a cool grey-lilac shade, as a pearl pigment shifts with the angle. +// Buttons: K1 tap brushes a fresh coat on now. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, exp, sin, cos, log, round, random, PI } = Math; +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)); +// The cycle, in seconds at Speed 2: the brush crosses the panel in LAY, the +// first crack opens at T1 and the last near T2, and a new coat comes HOLD +// after the last. The run opens at START, with the first crack running. +const LAY = 1.2, T1 = 1.8, T2 = 6, HOLD = 5, START = 2.4; +// Crack tips run at VTIP LEDs a second; a gap opens with time constant TAU. +const VTIP = 70, TAU = 1.3; +// Towards the light, x right and y down: up and to the left. +const LX = -0.6, LY = -0.8; +// Opening hue: hot pink. +const H0 = 0.93; + +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] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +// The domains are worked out on a grid of half-LED cells: each cell holds +// the domain it belongs to, or -1 on a crack. +const FW = 2 * W, FH = 2 * H; +// A crack tip moves STEP LEDs at a time, and within BEND LEDs of an older +// crack it turns to meet it square. +const STEP = 0.25, BEND = 3; +// Gap pixels are listed out to REACH LEDs from a crack's line. +const REACH = 7; + +// The whole crackle of one coat, worked out when it goes on. The panel is +// one domain. A domain splits across its long axis: a crack starts on its +// rim, square to the older crack it leaves, runs across, and turns to meet +// the crack on the far side square. Each half splits the same way until it +// is too short; bigger domains crack sooner. +function crackle(craze) { + const stop = 20 - 9 * craze, span = log((W * H) / (0.45 * stop * stop)); + const lab = new Int32Array(FW * FH), cracks = []; + const wall = (x, y) => { + const i = floor(2 * x), j = floor(2 * y); + return i < 0 || j < 0 || i >= FW || j >= FH || lab[j * FW + i] < 0; + }; + let ids = 1; + const todo = [{ id: 0, b: [0, 0, FW - 1, FH - 1], ready: 0 }]; + while (todo.length) { + const d = todo.pop(), [i0, j0, i1, j1] = d.b; + let n = 0, sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0; + const nb = [FW, FH, 0, 0]; + for (let j = j0; j <= j1; j++) + for (let i = i0; i <= i1; i++) + if (lab[j * FW + i] === d.id) { + const x = (i + 0.5) / 2, y = (j + 0.5) / 2; + n++; + sx += x; + sy += y; + sxx += x * x; + syy += y * y; + sxy += x * y; + nb[0] = min(nb[0], i); + nb[1] = min(nb[1], j); + nb[2] = max(nb[2], i); + nb[3] = max(nb[3], j); + } + if (n < 8) continue; + const mx = sx / n, my = sy / n, vxx = sxx / n - mx * mx, vyy = syy / n - my * my, vxy = sxy / n - mx * my; + const ang = 0.5 * Math.atan2(2 * vxy, vxx - vyy); + // A bar of length L has variance L^2 / 12 along its axis. + const long = sqrt(12 * (vxx * cos(ang) ** 2 + 2 * vxy * cos(ang) * sin(ang) + vyy * sin(ang) ** 2)); + // The cut is turned up to 20 degrees off square; its ends bend square anyway. + const tu = ang + (random() - 0.5) * 0.7, ux = cos(tu), uy = sin(tu); + if (long < stop * (0.85 + 0.3 * random())) continue; + // Where the cut line, across the long axis near the middle, meets the rim. + const off = (random() - 0.5) * 0.36 * long, cx = mx + ux * off, cy = my + uy * off; + if (wall(cx, cy)) continue; + const ends = [-1, 1].map((sg) => { + let x = cx, y = cy; + while (!wall(x - sg * uy * STEP, y + sg * ux * STEP)) { + x -= sg * uy * STEP; + y += sg * ux * STEP; + } + return [x, y, sg]; + }); + const [sx0, sy0, sg] = ends[random() < 0.5 ? 0 : 1]; + // Heading: across, toward the other end; it starts square off its rim. + const hx = sg * uy, hy = -sg * ux; + let x = sx0, y = sy0, dx = hx, dy = hy, run = 0, ok = false; + const pts = [[x, y]], cells = []; + for (let k = 0; k < (4 * long) / STEP; k++) { + // The nearest older crack (or the panel's edge) within BEND, and the + // way square off it, from all its cells nearby. + let best = BEND * BEND, gx = 0, gy = 0; + const ci = floor(2 * x), cj = floor(2 * y), r = 2 * BEND; + for (let j = cj - r; j <= cj + r; j++) + for (let i = ci - r; i <= ci + r; i++) { + if (i >= 0 && j >= 0 && i < FW && j < FH && lab[j * FW + i] >= 0) continue; + const ex = x - (cl(i, -1, FW) + 0.5) / 2, ey = y - (cl(j, -1, FH) + 0.5) / 2, e2 = ex * ex + ey * ey; + best = min(best, e2); + if (e2 < 4) { + const wt = exp(-e2); + gx += ex * wt; + gy += ey * wt; + } + } + const dist = sqrt(best), gl = sqrt(gx * gx + gy * gy); + let calm = 1; + if (best < BEND * BEND && gl > 0) { + gx /= gl; + gy /= gl; + // Leaving its own rim: square off it. Coming up on another: turn square into it. + const into = run < 1.2 ? -0.5 : dx * gx + dy * gy < 0 ? 0.6 * (1 - dist / BEND) : 0; + if (into) { + dx -= into * gx; + dy -= into * gy; + calm = dist / BEND; + } + } + const tw = (random() - 0.5) * 0.22 * calm; + dx += 0.05 * hx * calm - tw * dy; + dy += 0.05 * hy * calm + tw * dx; + const dl = sqrt(dx * dx + dy * dy); + dx /= dl; + dy /= dl; + x += dx * STEP; + y += dy * STEP; + run += STEP; + const ci2 = floor(2 * x), cj2 = floor(2 * y), last = cells[cells.length - 1]; + // Cells of the crack, joined edge to edge so nothing leaks past it. + if (last && last[0] !== ci2 && last[1] !== cj2) cells.push([ci2, last[1]]); + if (!last || last[0] !== ci2 || last[1] !== cj2) cells.push([ci2, cj2]); + if (wall(x, y)) { + pts.push([x, y]); + ok = run > 2; + break; + } + if (k % 4 === 3) pts.push([x, y]); + } + if (!ok) continue; + for (const [i, j] of cells) if (i >= 0 && j >= 0 && i < FW && j < FH && lab[j * FW + i] === d.id) lab[j * FW + i] = -1; + // Fill one side with a new domain. + const m = pts[pts.length >> 1], q = pts[(pts.length >> 1) + 1] || pts[pts.length - 1]; + const tx = q[0] - m[0], ty = q[1] - m[1], tl = Math.hypot(tx, ty) || 1; + const si = floor(2 * (m[0] - (ty / tl) * 0.8)), sj = floor(2 * (m[1] + (tx / tl) * 0.8)); + if (si < 0 || sj < 0 || si >= FW || sj >= FH || lab[sj * FW + si] !== d.id) continue; + const nid = ids++, stack = [sj * FW + si]; + lab[sj * FW + si] = nid; + while (stack.length) { + const c = stack.pop(), i = c % FW, j = (c - i) / FW; + for (const e of [i > 0 ? c - 1 : -1, i < FW - 1 ? c + 1 : -1, j > 0 ? c - FW : -1, j < FH - 1 ? c + FW : -1]) + if (e >= 0 && lab[e] === d.id) { + lab[e] = nid; + stack.push(e); + } + } + let len = 0; + for (let k = 1; k < pts.length; k++) len += Math.hypot(pts[k][0] - pts[k - 1][0], pts[k][1] - pts[k - 1][1]); + const age = cl(log((W * H) / (n / 4)) / span, 0, 1), t = max(d.ready + 0.06, T1 + (T2 - T1) * age); + // Final half-width: the first cracks open widest. + cracks.push({ pts, len, t, w: 1.5 - 0.85 * age }); + const ready = t + len / VTIP; + todo.push({ id: d.id, b: nb, ready }, { id: nid, b: nb, ready }); + } + return gapLists(cracks); +} + +// For each crack, every pixel within REACH of its line: how far from it, +// and how far along the crack the nearest point lies. +function gapLists(cracks) { + const dist = new Float32Array(N), along = new Float32Array(N), seen = new Int32Array(N).fill(-1); + cracks.forEach((c, ci) => { + const idx = []; + let u0 = 0; + for (let k = 1; k < c.pts.length; k++) { + const [ax, ay] = c.pts[k - 1], [bx, by] = c.pts[k], ex = bx - ax, ey = by - ay, el = sqrt(ex * ex + ey * ey) || 1e-6; + for (let y = max(0, floor(min(ay, by) - REACH)); y <= min(H - 1, floor(max(ay, by) + REACH)); y++) + for (let x = max(0, floor(min(ax, bx) - REACH)); x <= min(W - 1, floor(max(ax, bx) + REACH)); x++) { + const px = x + 0.5 - ax, py = y + 0.5 - ay, f = cl((px * ex + py * ey) / (el * el), 0, 1); + const qx = px - f * ex, qy = py - f * ey, dd = sqrt(qx * qx + qy * qy), p = y * W + x; + if (dd > REACH) continue; + if (seen[p] !== ci) { + seen[p] = ci; + idx.push(p); + dist[p] = 1e9; + } + if (dd < dist[p]) { + dist[p] = dd; + along[p] = u0 + f * el; + } + } + u0 += el; + } + c.idx = Int32Array.from(idx); + c.d = Float32Array.from(idx, (p) => dist[p]); + c.u = Float32Array.from(idx, (p) => along[p]); + c.pts = null; + }); + return cracks; +} + +export function setup(params) { + params.s = { + hue: 0, speed: 2, craze: 0, coat: 0.06, begun: false, t: START, end: 0, cracks: [], old: null, brush: new Float32Array(W), + G: new Float32Array(N), + base: [0, 0, 0], ink: [0, 0, 0], a: [0, 0, 0], b: [0, 0, 0], cool: [0, 0, 0], + }; +} + +function newCoat(S, t0) { + S.old = S.cracks; + // The brush's bristles: a mark per column, softened. + let m = random(); + for (let x = 0; x < W; x++) S.brush[x] = 0.07 * (m = 0.55 * m + 0.45 * random()); + S.cracks = crackle(S.craze); + let last = 0; + for (const c of S.cracks) last = max(last, c.t); + S.end = last + HOLD; + S.t = t0; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06]; + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.craze = cl(k[2], 0, 1); + S.coat = cl(k[3], 0, 1); + if (!S.begun) { + newCoat(S, START); + S.old = null; + S.begun = true; + } + S.t += (cl(dt || 0, 0, 0.1) * S.speed) / 2; + if (S.t > S.end || (input.btnPressed && input.btnPressed[0])) newCoat(S, 0); +} + +// Polynomial smooth minimum: where two gaps come within k of each other +// they merge in a fillet, which rounds the island's corner between them. +function smin(a, b, k) { + const h = max(k - abs(a - b), 0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// G: signed distance to the open gaps, in LEDs, positive on the coat. Each +// crack is a gap round its line as far as its tip, its half-width growing +// from a hairline as it opens, and the corners it makes with older cracks +// rounding as the coat dries. +function field(S, list, t) { + const G = S.G; + G.fill(9); + const narrow = 1 - 0.5 * S.craze; + for (const c of list) { + const age = t - c.t; + if (age <= 0) continue; + const reach = min(c.len, age * VTIP), open = 1 - exp(-age / TAU), run = reach < c.len; + const hw = 0.42 + (c.w * narrow - 0.42) * open, k = (0.5 + 2.3 * open) * narrow, { idx, d, u } = c; + for (let e = 0; e < idx.length; e++) { + const over = u[e] - reach; + if (over > REACH) continue; + const dd = over > 0 ? sqrt(d[e] * d[e] + over * over) : d[e]; + // A running tip is sharp. + const tip = run ? min(1, 0.35 + max(0, -over) / 5) : 1; + G[idx[e]] = smin(G[idx[e]], dd - hw * tip, k); + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const t = S.t, h = S.hue + H0, hh = h - floor(h), cz = S.coat; + // While the brush is on its way down, the old crackle shows below it. + const laying = t < LAY && S.old, front = -6 + ((H + 12) * t) / LAY; + field(S, laying ? S.old : S.cracks, laying ? 1e9 : t); + const G = S.G, { base, ink, a, b } = S; + // Orange and yellow bases go brown at the dim edge of a gap: paled a + // little and lit to full. + const warm = cl(1.5 - abs(hh - 0.13) / 0.07, 0, 1); + hsv(h, 0.82 - 0.2 * warm, 0.9 + 0.1 * warm, base); + // The coat: black lacquer, a jewel tone opposite the base, pearl. + const hc = h + 0.5, wc = cl(1.5 - abs(hc - floor(hc) - 0.13) / 0.07, 0, 1); + if (cz < 0.5) { + hsv(hc, 0.5, 0.05, a); + hsv(hc, 0.72 - 0.2 * wc, 0.5 + 0.3 * wc, b); + } else { + hsv(hc, 0.72 - 0.2 * wc, 0.5 + 0.3 * wc, a); + hsv(h + 0.04, 0.12, 0.9, b); + } + const f = sm(0, 1, cz < 0.5 ? cz * 2 : cz * 2 - 1); + for (let c = 0; c < 3; c++) ink[c] = a[c] + (b[c] - a[c]) * f; + const pearl = sm(0.5, 1, cz), dark = 1 - sm(0, 0.6, cz), cool = S.cool; + hsv(h + 0.55, 0.28, 0.72, cool); + // A strip light reflected in the gloss, sliding down the panel once a + // coat, starting as the brush leaves it. + const sheen = (((t - LAY) * 24 + 280) % 280) - 5; + // The fresh coat is wet: a stronger reflection, brush marks running down + // it and the base colour faintly through it, all gone a second after it is on. + const dry = laying ? 0 : exp(-(t - LAY) / 0.9); + // Above the brush's front the coat is fresh and whole. + const at = (x, y) => { + const v = G[cl(y, 0, H - 1) * W + cl(x, 0, W - 1)]; + return laying ? max(v, (front - y) * 3) : v; + }; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const v = at(x, y); + const gx = min(at(x + 1, y), 3) - min(at(x - 1, y), 3), gy = min(at(x, y + 1), 3) - min(at(x, y - 1), 3); + const gl = sqrt(gx * gx + gy * gy) + 1e-6, face = -(gx * LX + gy * LY) / gl; + const band = exp(-(((x * 0.5 + y - sheen) / 13) ** 2)); + // The base through the gaps, in the shade of the island on its lit side. + const shade = 1 - (0.45 - 0.3 * warm) * sm(-0.5, 0.8, at(x - 1, y - 2)); + const bl = 0.3 * band; + let r = (base[0] + (1 - base[0]) * bl) * shade, g = (base[1] + (1 - base[1]) * bl) * shade, bb = (base[2] + (1 - base[2]) * bl) * shade; + // The island: how far inside its edge, as the height of a bead. Its + // cover falls off as a square root at the edge, so the base glows up + // through a hairline crack near full instead of as a dim line. + const cov = 1 - (1 - sm(-0.55, 0.55, v)) ** 0.5, hgt = cl(v / 2.5, 0, 1); + if (cov > 0) { + const edge = 1 - sm(0.15, 0.75, hgt), lit = max(0, face) * edge, away = max(0, -face) * edge; + // Dark coats: a white gloss on the lit rim and the strip light on + // the top. + const wet = laying ? 1 - sm(front - 2, front, y) : dry; + const brush = wet * S.brush[x] * (0.8 + 0.2 * sin(y * 0.09 + x)); + const gloss = (dark * 0.75 + 0.35 * (1 - dark)) * lit * lit + (0.12 + 0.18 * dark + 0.3 * wet) * band * sm(0.3, 0.9, hgt) + brush; + const tint = 0.14 * wet * dark; + // Pearl shifts toward a cool interference tint on the rim turned away. + const dim = 1 - 0.3 * pearl * away - 0.08 * pearl * (1 - hgt), sh = 0.6 * pearl * away; + const ir = (ink[0] + (cool[0] - ink[0]) * sh) * dim + (1 - ink[0]) * gloss + base[0] * tint; + const ig = (ink[1] + (cool[1] - ink[1]) * sh) * dim + (1 - ink[1]) * gloss + base[1] * tint; + const ib = (ink[2] + (cool[2] - ink[2]) * sh) * dim + (1 - ink[2]) * gloss + base[2] * tint; + r += (ir - r) * cov; + g += (ig - g) * cov; + bb += (ib - bb) * cov; + } + if (laying) { + // The wet bead along the brush's edge. + const bead = 0.55 * exp(-(((y - front - 0.6 * sin(x * 0.4 + t * 3)) / 1.1) ** 2)); + r += (1 - r) * bead; + g += (1 - g) * bead; + bb += (1 - bb) * bead; + } + display.setPixel(x, y, round(255 * min(1, r)), round(255 * min(1, g)), round(255 * min(1, bb))); + } +} diff --git a/incoming/pattern-study/patterns/isospectral-drums.js b/incoming/pattern-study/patterns/isospectral-drums.js new file mode 100644 index 000000000..f2ca9a21f --- /dev/null +++ b/incoming/pattern-study/patterns/isospectral-drums.js @@ -0,0 +1,348 @@ +// ===== Patternflow pattern ===== +// Title: Isospectral Drums +// Family: wave +// Idea: Two drums of different shape, each glued from the same seven half-squares, one above the other, ring in the same eigenmode at the same frequency, one tone where the membrane is up and the opposite tone where it is down with dark nodal lines between, while a strip between them marks their one shared spectrum and the mode they play as they climb it. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: isospectral drums (Carolyn Gordon, David Webb and Scott Wolpert, 1992), the answer to Mark Kac's "Can one hear the shape of a drum?" (1966); the drums built from the Fano plane as Peter Buser, John Conway, Peter Doyle and Klaus-Dieter Semmler did (1994), pattern round 39 +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Mode=0..24, Beat=0..1 +// +// Knob 1: Hue. Turns the two tones round the wheel together, always half a turn apart: at 0 the membrane is rose where it is up and aqua where it is down, at 0.1 coral and sky blue, at 0.25 yellow-lime and indigo, at 0.5 aqua and rose, at 0.75 indigo and yellow-lime. Orange and yellow tones are paled a little and lifted up to 1.5 times, so the dim parts of a swing do not turn brown or olive. +// Knob 2: Speed. At 2 the fundamental swings once in 2.5 s and the 24th mode, 3.3 times higher, in 0.76 s, and on its climb the pair holds each mode 5 s. At 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Mode. At 0 the drums climb their spectrum on their own from the 6th mode, a step every 5 s at Speed 2, back to the fundamental after the 24th. From 1 to 24 they hold that mode: 1 is a single dome; at 9 the nodal lines are exactly the seams between the seven half-squares, because that mode is the half-square's own lowest mode turned over at every seam; by 24 each drum is cut into eight to ten nodal domains, never laid out the same in the two. +// Knob 4: Beat. Mixes in the mode three steps up, to half and half at 1. The two frequencies beat and the nodal lines sweep across both drums and come back in the same rhythm, because the two drums share every frequency; the strip lights both modes. + +const W = 64, H = 128; +// Leg of a half-square in LEDs. A drum spans three legs each way, 58 LEDs +// with its rim; the lower drum's grid starts at row 70. +const S = 19, NG = 3 * S + 1, X0 = 3, TOP = [0, 70]; +// Modes found at setup, and the Lanczos steps that find them. +const K = 24, M = 64; +// The spectrum strip, rows 59 to 68: its axis runs from 0 to 1.05 times the +// 24th eigenvalue across the drums' width. +const AX = 1.05, TICK = [0.7, 0.8, 0.85]; +// Fundamental frequency in Hz at Speed 2; seconds a mode is held on the +// climb, of which the last XF fade into the next; the mode the climb opens on. +const F1 = 0.4, DWELL = 5, XF = 1.2, FIRST = 6; +// Membrane level where it is flat, so the drums stay in sight between swings. +const FLOOR = 0.18; +// Opening hue: rose up, aqua down. +const H0 = 0.96; +const { floor, min, max, abs, sqrt, cos, round, hypot, PI } = Math; +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)); + +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] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +// The Fano plane: its seven points are the nonzero vectors (x, y, z) of +// F2^3, held as the numbers 1 to 7, and so are its seven lines, a line v +// holding the points p with p.v = 0. Three involutions of PSL(3,2) act on +// both: a swaps x and z, b swaps y and z, c adds z to x. On lines an element +// acts by its inverse transpose, which for a and b is themselves and for c +// adds x to z. +const bit = (v, k) => (v >> k) & 1, vec = (x, y, z) => (x << 2) | (y << 1) | z; +const swapXZ = (v) => vec(bit(v, 0), bit(v, 1), bit(v, 2)), swapYZ = (v) => vec(bit(v, 2), bit(v, 0), bit(v, 1)); +const ON_POINTS = [swapXZ, swapYZ, (v) => vec(bit(v, 2) ^ bit(v, 0), bit(v, 1), bit(v, 0))]; +const ON_LINES = [swapXZ, swapYZ, (v) => vec(bit(v, 2), bit(v, 1), bit(v, 2) ^ bit(v, 0))]; + +// The base tile is the half-square (0,0), (1,0), (0,1); a and b are its +// legs, c its hypotenuse. Reflection across each, as (x, y) -> (m0 x + m1 y +// + m4, m2 x + m3 y + m5). Legs on the LED axes and the hypotenuse at 45 +// degrees, so every reflection maps the LED grid onto itself. +const REF = [[1, 0, 0, -1, 0, 0], [-1, 0, 0, 1, 0, 0], [0, -1, -1, 0, 1, 1]]; +const comp = (g, h) => [g[0] * h[0] + g[1] * h[2], g[0] * h[1] + g[1] * h[3], g[2] * h[0] + g[3] * h[2], g[2] * h[1] + g[3] * h[3], g[0] * h[4] + g[1] * h[5] + g[4], g[2] * h[4] + g[3] * h[5] + g[5]]; + +// Copy i of the tile is glued to copy a(i) across side a, and so on; a +// side an involution fixes is the drum's rim. The gluings make a tree, so +// unfolding it from copy 1 places every copy in the plane. +function unfold(inv) { + const g = []; + g[1] = [1, 0, 0, 1, 0, 0]; + const q = [1]; + while (q.length) { + const i = q.pop(); + for (let s = 0; s < 3; s++) { + const j = inv[s](i); + if (j !== i && !g[j]) { + g[j] = comp(g[i], REF[s]); + q.push(j); + } + } + } + return g.slice(1); +} + +// Each grid point of the drum: 0 outside, 1 on the rim, 2 inside. A point +// is inside when a step into every one of the eight 45-degree wedges round +// it stays on the drum; every tile edge runs along a wedge's side. +function drumMask(tiles, flip) { + let mx = 1e9, my = 1e9; + for (const g of tiles) + for (const [u, v] of [[0, 0], [1, 0], [0, 1]]) { + mx = min(mx, g[0] * u + g[1] * v + g[4]); + my = min(my, g[2] * u + g[3] * v + g[5]); + } + const on = (x, y) => { + for (const g of tiles) { + const px = x / S + mx - g[4], py = y / S + my - g[5]; + const u = g[0] * px + g[2] * py, v = g[1] * px + g[3] * py; + if (u > -1e-9 && v > -1e-9 && u + v < 1 + 1e-9) return true; + } + return false; + }; + const m = new Uint8Array(NG * NG); + for (let y = 0; y < NG; y++) + for (let x = 0; x < NG; x++) { + const gy = flip ? NG - 1 - y : y; + if (!on(x, gy)) continue; + let all = true; + for (const [dx, dy] of [[2, 1], [1, 2], [-1, 2], [-2, 1], [-2, -1], [-1, -2], [1, -2], [2, -1]]) if (!on(x + 0.05 * dx, gy + 0.05 * dy)) all = false; + m[y * NG + x] = all ? 2 : 1; + } + return m; +} + +// Implicit QL on a symmetric tridiagonal matrix (EISPACK's tql2): d the +// diagonal, e[1..n-1] below it; z starts as the identity and leaves holding +// the eigenvectors in its columns. +function tql2(n, d, e, z) { + for (let i = 1; i < n; i++) e[i - 1] = e[i]; + e[n - 1] = 0; + let f = 0, tst = 0; + for (let l = 0; l < n; l++) { + tst = max(tst, abs(d[l]) + abs(e[l])); + let m = l; + while (m < n && abs(e[m]) > 2.2e-16 * tst) m++; + if (m > l) + do { + let g = d[l], p = (d[l + 1] - g) / (2 * e[l]), r = hypot(p, 1); + if (p < 0) r = -r; + d[l] = e[l] / (p + r); + d[l + 1] = e[l] * (p + r); + const dl1 = d[l + 1]; + let h = g - d[l]; + for (let i = l + 2; i < n; i++) d[i] -= h; + f += h; + p = d[m]; + let c = 1, c2 = 1, c3 = 1, s = 0, s2 = 0; + const el1 = e[l + 1]; + for (let i = m - 1; i >= l; i--) { + c3 = c2; + c2 = c; + s2 = s; + g = c * e[i]; + h = c * p; + r = hypot(p, e[i]); + e[i + 1] = s * r; + s = e[i] / r; + c = p / r; + p = c * d[i] - s * g; + d[i + 1] = h + s * (c * g + s * d[i]); + for (let k = 0; k < n; k++) { + const a = z[k * n + i], b = z[k * n + i + 1]; + z[k * n + i + 1] = s * a + c * b; + z[k * n + i] = c * a - s * b; + } + } + p = (-s * s2 * c3 * el1 * e[l]) / dl1; + e[l] = s * p; + d[l] = c * p; + } while (abs(e[l]) > 2.2e-16 * tst); + d[l] += f; + e[l] = 0; + } +} + +// The lowest K eigenpairs of the 5-point Dirichlet Laplacian on the drum's +// inside points: Lanczos on its inverse, applied by a banded Cholesky +// factor, with full reorthogonalisation. Eigenvalues in units of one LED. +// Every reflection of the gluing maps the LED grid onto itself, so the two +// pixel drums are isospectral exactly, not only in the limit: both have +// 1179 inside points and the same 1179 eigenvalues, and the lowest 24 found +// here for each agree to 1e-11. +function drumModes(mask) { + const id = new Int32Array(NG * NG).fill(-1), pos = []; + for (let p = 0; p < NG * NG; p++) if (mask[p] === 2) id[p] = pos.push(p) - 1; + const n = pos.length; + let bw = 1; + for (let i = 0; i < n; i++) if (id[pos[i] - NG] >= 0) bw = max(bw, i - id[pos[i] - NG]); + const B = bw + 1, L = new Float64Array(n * B); + for (let i = 0; i < n; i++) + for (let j = max(0, i - bw); j <= i; j++) { + const dp = pos[i] - pos[j]; + let s = i === j ? 4 : dp === 1 || dp === NG ? -1 : 0; + for (let k = max(0, i - bw); k < j; k++) s -= L[i * B + i - k] * L[j * B + j - k]; + L[i * B + i - j] = i === j ? sqrt(s) : s / L[j * B]; + } + const solve = (b, x) => { + for (let i = 0; i < n; i++) { + let s = b[i]; + for (let k = max(0, i - bw); k < i; k++) s -= L[i * B + i - k] * x[k]; + x[i] = s / L[i * B]; + } + for (let i = n - 1; i >= 0; i--) { + let s = x[i]; + for (let k = i + 1; k <= min(n - 1, i + bw); k++) s -= L[k * B + k - i] * x[k]; + x[i] = s / L[i * B]; + } + }; + const Q = new Float64Array(M * n), w = new Float64Array(n), d = new Float64Array(M), e = new Float64Array(M); + // A fixed start, so both drums and every run find the same modes. + let seed = 12345, nn = 0; + for (let i = 0; i < n; i++) { + seed = (seed * 1103515245 + 12345) >>> 0; + Q[i] = seed / 4294967296 - 0.5; + nn += Q[i] * Q[i]; + } + for (let i = 0; i < n; i++) Q[i] /= sqrt(nn); + for (let j = 0; j < M; j++) { + solve(Q.subarray(j * n, j * n + n), w); + for (let pass = 0; pass < 2; pass++) + for (let i = 0; i <= j; i++) { + let dot = 0; + for (let k = 0; k < n; k++) dot += w[k] * Q[i * n + k]; + if (!pass && i === j) d[j] = dot; + for (let k = 0; k < n; k++) w[k] -= dot * Q[i * n + k]; + } + let b = 0; + for (let k = 0; k < n; k++) b += w[k] * w[k]; + b = sqrt(b); + if (j + 1 < M) { + e[j + 1] = b; + for (let k = 0; k < n; k++) Q[(j + 1) * n + k] = w[k] / b; + } + } + const z = new Float64Array(M * M); + for (let i = 0; i < M; i++) z[i * M + i] = 1; + tql2(M, d, e, z); + const order = [...d.keys()].sort((a, b) => d[b] - d[a]).slice(0, K); + const lam = new Float64Array(K), V = new Float32Array(K * n); + order.forEach((col, m) => { + lam[m] = 1 / d[col]; + const v = new Float64Array(n); + for (let j = 0; j < M; j++) { + const c = z[j * M + col]; + for (let i = 0; i < n; i++) v[i] += c * Q[j * n + i]; + } + // The sign of a mode is free: its strongest antinode is taken as up. + let big = 0; + for (let i = 0; i < n; i++) if (abs(v[i]) > abs(big)) big = v[i]; + for (let i = 0; i < n; i++) V[m * n + i] = v[i] / big; + }); + return { n, pos, lam, V }; +} + +export function setup(params) { + // Drum A: copies of the tile at the points; drum B: at the lines. + const masks = [drumMask(unfold(ON_POINTS), true), drumMask(unfold(ON_LINES), true)]; + params.s = { + hue: 0, speed: 2, mode: 0, beat: 0.06, t: 0, tour: 0, + masks, drums: masks.map(drumModes), w: new Float32Array(K), swing: new Float32Array(K), U: new Float32Array(NG * NG), strip: new Float32Array(W * 10 * 3), + up: [0, 0, 0], down: [0, 0, 0], rim: [0, 0, 0], + }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1) * (cl(k[1], 0.2, 3) / 2); + S.hue = k[0]; + S.mode = round(cl(k[2], 0, K)); + S.beat = cl(k[3], 0, 1); + S.t += d; + // The modes playing and their shares: one held mode, or on the climb the + // mode of this step fading into the next. + const w = S.w, base = []; + w.fill(0); + if (S.mode > 0) base.push([S.mode - 1, 1]); + else { + S.tour += d; + const step = floor(S.tour / DWELL), x = sm(DWELL - XF, DWELL, S.tour - step * DWELL), m = (FIRST - 1 + step) % K; + base.push([m, 1 - x], [(m + 1) % K, x]); + } + // Beat shares each mode with the one three steps above it (below, for + // the top three). + for (const [m, s] of base) { + w[m] += s * (1 - S.beat / 2); + w[m + 3 < K ? m + 3 : m - 3] += (s * S.beat) / 2; + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { up, down, rim, w, U, swing } = S; + const lam = S.drums[0].lam, h = S.hue + H0; + // Yellow and orange read olive and brown when dim: there the tone is paled + // a little and the membrane lifted up to 1.5 times. + const warm = (hh) => cl(1.5 - abs(hh - floor(hh) - 0.16) / 0.08, 0, 1), wu = warm(h), wd = warm(h + 0.5); + hsv(h, 0.72 - 0.14 * wu, 1, up); + hsv(h + 0.5, 0.78 - 0.14 * wd, 1, down); + const lift = [1 + 0.5 * wu, 1 + 0.5 * wd]; + hsv(h + 0.25, 0.12, 0.62, rim); + // Each mode swings as cos(omega t), omega in proportion to the square root + // of its eigenvalue, and the same omega in both drums. + for (let m = 0; m < K; m++) swing[m] = w[m] > 0.002 ? cos(2 * PI * F1 * sqrt(lam[m] / lam[0]) * S.t) : 0; + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) display.setPixel(x, y, 0, 0, 0); + S.drums.forEach((D, di) => { + const mask = S.masks[di], { n, pos, V } = D, y0 = TOP[di]; + U.fill(0); + let scale = 0; + for (let m = 0; m < K; m++) { + if (w[m] <= 0.002) continue; + const a = w[m] * swing[m]; + scale += w[m]; + for (let i = 0; i < n; i++) U[pos[i]] += a * V[m * n + i]; + } + scale = max(scale, 1e-6); + for (let gy = 0; gy < NG; gy++) + for (let gx = 0; gx < NG; gx++) { + const p = gy * NG + gx, kind = mask[p]; + if (!kind) continue; + let r, g, b; + if (kind === 1) [r, g, b] = rim; + else { + // How far the nearest nodal line is, in LEDs: the value over the + // slope, the same at every moment of a swing. + const u = U[p], sx = (U[p + 1] - U[p - 1]) / 2, sy = (U[p + NG] - U[p - NG]) / 2; + const dist = abs(u) / sqrt(sx * sx + sy * sy + 1e-30), node = sm(0.15, 0.85, dist); + const a = min(1, abs(u) / scale), tone = u >= 0 ? up : down, wh = 0.35 * a * a * a; + const lv = (FLOOR + (1 - FLOOR) * a ** 0.7) * node * lift[u >= 0 ? 0 : 1]; + r = (tone[0] + (1 - tone[0]) * wh) * lv; + g = (tone[1] + (1 - tone[1]) * wh) * lv; + b = (tone[2] + (1 - tone[2]) * wh) * lv; + } + display.setPixel(X0 + gx, y0 + gy, round(255 * min(1, r)), round(255 * min(1, g)), round(255 * min(1, b))); + } + }); + // The shared spectrum: a dim tick at every eigenvalue, and the modes + // playing lit the full height of the strip, brightest at the top of their + // swing and in the tone of the side it swings to. + const span = (NG - 1) / (AX * lam[K - 1]), st = S.strip; + st.fill(0); + for (let m = 0; m < K; m++) { + const xf = X0 + lam[m] * span, xi = floor(xf), fr = xf - xi; + const tone = swing[m] >= 0 ? up : down, on = cl(w[m] * 1.4, 0, 1) * (0.45 + 0.55 * abs(swing[m])); + for (let j = 0; j < 10; j++) { + const tick = j >= 2 && j <= 7 ? 0.26 : 0, lit = on * (j === 0 || j === 9 ? 0.5 : 1); + for (const [xx, f] of [[xi, 1 - fr], [xi + 1, fr]]) { + const q = (j * W + xx) * 3; + for (let c = 0; c < 3; c++) st[q + c] += (tick * (1 - lit) * TICK[c] + lit * (tone[c] + (1 - tone[c]) * 0.35)) * f; + } + } + } + for (let j = 0; j < 10; j++) + for (let x = 0; x < W; x++) { + const q = (j * W + x) * 3; + display.setPixel(x, 59 + j, round(255 * min(1, st[q])), round(255 * min(1, st[q + 1])), round(255 * min(1, st[q + 2]))); + } +} From 6a259610dd4359321329f387c907d07c0054c3bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:42:38 +0000 Subject: [PATCH 06/10] =?UTF-8?q?incoming:=20Steiner=20Chain,=20Serum=20Dr?= =?UTF-8?q?ip=20=ED=8C=A8=ED=84=B4=20(39=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_01WSruBmW95rasfF2WmM8DZ7 --- incoming/pattern-study/patterns/serum-drip.js | 464 ++++++++++++++++++ .../pattern-study/patterns/steiner-chain.js | 257 ++++++++++ 2 files changed, 721 insertions(+) create mode 100644 incoming/pattern-study/patterns/serum-drip.js create mode 100644 incoming/pattern-study/patterns/steiner-chain.js diff --git a/incoming/pattern-study/patterns/serum-drip.js b/incoming/pattern-study/patterns/serum-drip.js new file mode 100644 index 000000000..7631598f8 --- /dev/null +++ b/incoming/pattern-study/patterns/serum-drip.js @@ -0,0 +1,464 @@ +// ===== Patternflow pattern ===== +// Title: Serum Drip +// Family: cosmetic +// Idea: A thread of clear tinted serum falls from the top edge the height of the panel, necks and swells into beads on a string, the filaments between them thin and snap, and round drops with now and then a small satellite fall into a shallow pool at the bottom, while the stream keeps coming and the lit wall shows through every bead, magnified. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 39, after a serum stretched from a dropper into a thread that beads up and breaks (the Plateau-Rayleigh instability, Joseph Plateau, 1873, and Lord Rayleigh, 1878) +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Viscosity=-1..1, Flow=-1..1 +// +// Knob 1: Hue. Turns the serum round the whole wheel, and the wall behind it takes a faint tint of the same hue. At 0 gold on a grey wall; at 0.04 yellow, 0.1 lime, 0.2 green, 0.36 aqua, 0.46 azure, 0.53 blue, 0.6 violet, 0.7 magenta, 0.78 rose, 0.86 red, 0.92 vermilion and 0.96 orange. From red-orange through gold to lime the wall is greyed and lifted a little and the pool lit from within, so the serum and its pool read amber, gold or lemon, never brown or olive. +// Knob 2: Speed. At 2 the serum leaves the top edge at 28 LEDs a second, falls the height of the panel in about 3 seconds, and a drop breaks off about every 1.4 seconds; at 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Viscosity. At 0 the ripples grow down the thread into beads on a string, the filaments between them thin and snap about row 80, and drops 15 LEDs across fall into a pool 14 rows deep, now and then with a satellite. At -1 the serum is nine times thinner and snaps into clean drops about row 45. Turned up, the beads stay joined longer: from about 0.35 the thread reaches the pool unbroken as a string of beads, and at 1, nine times thicker than at 0, the ripples barely grow and the pool, draining slower, stands 21 rows deep. +// Knob 4: Flow. How fast the serum leaves the top edge, 2.2 times slower at -1 and 2.2 times faster at 1. At -1 it cannot keep a thread going and drips: a drop swells below the top edge to 21 LEDs across and lets go about every 10 seconds; at -0.5 the thread breaks about row 60 into drops 19 across; from 0.5 it runs into the pool before it can break, and the pool rises to 19 rows. + +const W = 64, H = 128, NMAX = 800, PMAX = 64; +const { floor, min, max, abs, sqrt, exp, sin, cos, round, random, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The thread, in LEDs and seconds at Speed 2, is a slender-thread model: a +// chain of slices of fixed volume between nodes on the vertical axis, each +// slice a short cylinder. The area of the surface pulls on the nodes +// (surface tension over density SIG), gravity GR pulls them down, and +// viscosity resists stretching. A still thread in this model grows ripples +// at the rates of Rayleigh's theory, fastest at nine radii. The serum leaves +// an opening of radius A0, ZN above the top edge and never drawn. +// TS slows the whole film down without changing a shape, Q scales the +// thread: every rate below is set so the same shapes come at any Q. +const TS = 0.7, Q = 1.18; +const ZN = -5, A0 = 3.4 * Q, GR = 12 * Q * TS * TS, SIG = 2200 * Q ** 3 * TS * TS, DZ = 0.9, HCUT = 0.13; +// A tremor in the flow ripples the thread's radius by about a sixth as it +// leaves, at 8.8 radii, the wavelength that grows fastest on a thread at +// rest, wandering a little either side of it. The fall stretches the +// ripples as they go. +const LAM = 30 * Q; +// Viscosity at Viscosity 0, in LEDs squared a second. +const NU0 = 55 * Q * Q * TS; +// The pool's rest surface, its depth into the wall, and the ripple speed. +const POOL = 117, DEPTH = 18, CW = 40; +// Opening hue: gold. +const H0 = 0.14; + +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] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +export function setup(params) { + const F = () => new Float64Array(NMAX); + params.s = { + hue: 0, rate: 1, nu: 30, v0: 16, begun: false, t: 0, + n: 0, z: F(), u: F(), x: F(), V: F(), L: F(), h: F(), G: F(), f: F(), m: F(), lk: new Uint8Array(NMAX), + ca: F(), cb: F(), cc: F(), cd: F(), + drops: [], noise: 0, sway: random() * 6.28, pour: 0, ph: 0, jit: 0, + lvl: POOL, eta: new Float64Array(W), etv: new Float64Array(W), + PH: new Float64Array(2 * H), PX: new Float64Array(2 * H), PD: new Float64Array(2 * H), PT: new Float64Array(2 * H), + ser: [0, 0, 0], wall: [0, 0, 0], + }; + const S = params.s; + // The node at the opening. + S.n = 1; + S.z[0] = ZN; + S.u[0] = S.v0; + S.x[0] = 32; +} + +// Where the serum leaves the top edge: the hand above drifts slowly from +// side to side, and the thread below keeps the curve of where it has been. +const openX = (S) => 32 + 5 * sin(S.sway + S.t * 0.6 * TS) + 1.5 * sin(S.sway * 2.3 + S.t * 1.4 * TS); + +function insert(S, i) { + const { z, u, x, V, lk } = S; + z.copyWithin(i + 1, i, S.n); + u.copyWithin(i + 1, i, S.n); + x.copyWithin(i + 1, i, S.n); + V.copyWithin(i + 1, i, S.n); + lk.copyWithin(i + 1, i, S.n); + S.n++; +} + +function remove(S, i) { + const { z, u, x, V, lk } = S; + z.copyWithin(i, i + 1, S.n); + u.copyWithin(i, i + 1, S.n); + x.copyWithin(i, i + 1, S.n); + V.copyWithin(i, i + 1, S.n); + lk.copyWithin(i, i + 1, S.n); + S.n--; +} + +// Node i is kinematic: the opening, or a node already in the pool. +const kin = (S, i) => i === 0 || S.z[i] > S.lvl; + +function step(S, dt) { + const { z, u, V, L, h, G, f, m, lk, ca, cb, cc, cd } = S, n = S.n; + // The node at the opening moves at the speed the serum leaves it. + u[0] = S.v0; + // Slices: length and radius. + for (let i = 0; i < n - 1; i++) { + G[i] = 0; + if (!lk[i]) continue; + L[i] = max(1e-4, z[i + 1] - z[i]); + h[i] = sqrt(V[i] / (PI * L[i])); + } + // dArea / dLength of each slice: half a cylinder at each end (with a flat + // cap at a free end), a frustum between the middles of every two slices. + for (let i = 0; i < n - 1; i++) { + if (!lk[i]) continue; + const hi = h[i], Li = L[i], dhi = -hi / (2 * Li); + const top = i === 0 || !lk[i - 1], bot = i + 1 === n - 1 || !lk[i + 1]; + if (top) G[i] += PI * (hi / 2 - (kin(S, i) ? 0 : (hi * hi) / Li)); + if (bot) G[i] += PI * (hi / 2 - (kin(S, i + 1) ? 0 : (hi * hi) / Li)); + else { + const j = i + 1, hj = h[j], Lj = L[j], dhj = -hj / (2 * Lj), d = (Li + Lj) / 2, dl = hi - hj, Q = sqrt(d * d + dl * dl), hs = hi + hj; + G[i] += PI * (dhi * Q + (hs * (d / 2 + dl * dhi)) / Q); + G[j] += PI * (dhj * Q + (hs * (d / 2 - dl * dhj)) / Q); + } + } + for (let i = 0; i < n; i++) { + f[i] = 0; + m[i] = 0; + } + for (let i = 0; i < n - 1; i++) { + if (!lk[i]) continue; + f[i] += SIG * G[i]; + f[i + 1] -= SIG * G[i]; + m[i] += V[i] / 2; + m[i + 1] += V[i] / 2; + } + for (let i = 0; i < n; i++) if (!kin(S, i) && m[i] > 0) u[i] += dt * (f[i] / m[i] + GR); + // Viscosity, implicit: each slice resists stretching with 3 nu V / L^2, + // solved along each chain as a tridiagonal system. + const nu = S.nu; + let a = 0; + while (a < n) { + let b = a; + while (b < n - 1 && lk[b]) b++; + if (b > a) { + for (let i = a; i <= b; i++) { + const cl0 = i > a ? (3 * nu * V[i - 1]) / (L[i - 1] * L[i - 1]) : 0, cr = i < b ? (3 * nu * V[i]) / (L[i] * L[i]) : 0; + if (kin(S, i)) { + ca[i] = 0; + cb[i] = 1; + cc[i] = 0; + cd[i] = u[i]; + } else { + ca[i] = -dt * cl0; + cb[i] = m[i] + dt * (cl0 + cr); + cc[i] = -dt * cr; + cd[i] = m[i] * u[i]; + } + } + for (let i = a + 1; i <= b; i++) { + const w = ca[i] / cb[i - 1]; + cb[i] -= w * cc[i - 1]; + cd[i] -= w * cd[i - 1]; + } + u[b] = cd[b] / cb[b]; + for (let i = b - 1; i >= a; i--) u[i] = (cd[i] - cc[i] * u[i + 1]) / cb[i]; + } + a = b + 1; + } + for (let i = 0; i < n; i++) z[i] += dt * u[i]; + S.t += dt; +} + +// After a step: feed the opening, split long slices, merge short ones, cut +// the thread where it has thinned to nothing, roll free pieces up into drops, +// and pour what reaches the pool into it. +function tidy(S) { + const { z, u, x, V, lk } = S; + // The opening: a new slice each time the last one has left it. + if (z[0] >= ZN + DZ && S.n < NMAX - 2) { + insert(S, 0); + z[0] = z[1] - DZ; + u[0] = S.v0; + x[0] = openX(S); + S.noise += 0.35 * (random() - 0.5) - 0.12 * S.noise; + S.jit += 0.2 * (random() - 0.5) - 0.05 * S.jit; + S.ph += ((2 * PI * DZ) / LAM) * (1 + 0.25 * S.jit); + V[0] = PI * A0 * A0 * DZ * (1 + 0.35 * sin(S.ph) + 0.04 * S.noise); + lk[0] = 1; + } + z[0] = max(z[0], ZN); + for (let i = 0; i < S.n - 1; i++) { + if (!lk[i]) continue; + const L = z[i + 1] - z[i], hh = sqrt(V[i] / (PI * max(L, 1e-4))), lo = 0.22 + 0.2 * hh; + if (L > max(1, 2.6 * lo) && S.n < NMAX - 2) { + insert(S, i + 1); + z[i + 1] = (z[i] + z[i + 2]) / 2; + u[i + 1] = (u[i] + u[i + 2]) / 2; + x[i + 1] = (x[i] + x[i + 2]) / 2; + V[i] /= 2; + V[i + 1] = V[i]; + lk[i + 1] = 1; + } else if (hh < HCUT) { + // Pinch-off. + lk[i] = 0; + if (i > 0 && lk[i - 1]) V[i - 1] += V[i] / 2; + if (i + 1 < S.n - 1 && lk[i + 1]) V[i + 1] += V[i] / 2; + } else if (L < lo) { + // Merge with a neighbour by dropping the shared node. + const left = i > 0 && lk[i - 1] && (!(i + 1 < S.n - 1 && lk[i + 1]) || z[i] - z[i - 1] < z[i + 2] - z[i + 1]); + const right = !left && i + 1 < S.n - 1 && lk[i + 1]; + if (left) { + // The dropped node's momentum goes half to each neighbour. + const mr = (V[i - 1] + V[i]) / 2, ma = V[i - 1] / 2, mb = V[i] / 2; + u[i - 1] = (u[i - 1] * ma + (u[i] * mr) / 2) / (ma + mr / 2); + u[i + 1] = (u[i + 1] * mb + (u[i] * mr) / 2) / (mb + mr / 2); + V[i - 1] += V[i]; + remove(S, i); + i--; + } else if (right) { + const j = i + 1, mr = (V[i] + V[j]) / 2; + u[i] = (u[i] * V[i] / 2 + (u[j] * mr) / 2) / (V[i] / 2 + mr / 2); + u[j + 1] = (u[j + 1] * V[j] / 2 + (u[j] * mr) / 2) / (V[j] / 2 + mr / 2); + V[i] += V[j]; + lk[i] = 1; + remove(S, j); + } + } + } + // Nodes left with no slice on either side go, but not the one at the opening. + for (let i = S.n - 1; i > 0; i--) if (!lk[i - 1] && !(i < S.n - 1 && lk[i])) remove(S, i); + // Chains: roll a short free piece into a drop; pour what is in the pool. + let a = 0; + while (a < S.n) { + let b = a; + while (b < S.n - 1 && lk[b]) b++; + if (b > a) { + let vol = 0, mom = 0, zc = 0, xc = 0; + for (let i = a; i < b; i++) { + vol += V[i]; + mom += (V[i] * (u[i] + u[i + 1])) / 2; + zc += (V[i] * (z[i] + z[i + 1])) / 2; + xc += (V[i] * (x[i] + x[i + 1])) / 2; + } + const R = (0.75 * vol / PI) ** (1 / 3), len = z[b] - z[a]; + if (a > 0 && (b - a <= 2 || len < 2.3 * R) && z[b] <= S.lvl && S.drops.length < PMAX) { + S.drops.push({ y: zc / vol, x: xc / vol, v: mom / vol, R, vol, e: cl(len / (2 * R) - 1, -0.4, 0.6), ph: 0 }); + for (let i = b; i >= a; i--) remove(S, i); + continue; + } + // Slices wholly under the surface go into the pool. + while (b > a && z[b - 1] > S.lvl + 1) { + S.pour += V[b - 1]; + remove(S, b); + lk[b - 1] = 0; + b--; + } + } + a = b + 1; + } +} + +function drops(S, d) { + const { eta, etv } = S; + for (const p of S.drops) { + p.v += GR * d; + p.y += p.v * d; + // A drop rings at its lowest mode, sqrt(8 SIG / R^3), damped by viscosity. + p.ph += sqrt((8 * SIG) / (p.R * p.R * p.R)) * d; + p.e *= exp((-d * (5 * S.nu + 4)) / (p.R * p.R)); + } + S.drops = S.drops.filter((p) => { + if (p.y - 0.3 * p.R < S.lvl + eta[cl(round(p.x), 0, W - 1)]) return true; + S.pour += p.vol; + const k = p.vol * p.v * 0.0004; + for (let i = -6; i <= 6; i++) { + const c = cl(round(p.x) + i, 0, W - 1); + etv[c] -= k * exp(-(i * i) / (p.R * p.R + 2)); + } + return false; + }); + // The pool fills with what comes in and drains back toward its rest level. + // A thicker serum drains away more slowly, so its pool stands higher. + S.lvl += -S.pour / (W * DEPTH) + (POOL - S.lvl) * min(1, d / (3 * (S.nu / NU0) ** 0.45)); + S.pour = 0; + for (let i = 0; i < W; i++) { + const l = eta[i > 0 ? i - 1 : i], r = eta[i < W - 1 ? i + 1 : i]; + etv[i] += d * (CW * CW * (l + r - 2 * eta[i]) - 4 * etv[i]); + } + for (let i = 0; i < W; i++) eta[i] = cl(eta[i] + d * etv[i], -5, 5); +} + +function advance(S, d) { + // Substeps short enough for the stiffest slice: a capillary wave as short + // as the slice rings at sqrt(SIG h / 2) (pi / L)^2. + let left = d; + while (left > 1e-6) { + let w = 0; + for (let i = 0; i < S.n - 1; i++) { + if (!S.lk[i]) continue; + const L = max(0.05, S.z[i + 1] - S.z[i]), hh = sqrt(S.V[i] / (PI * L)); + w = max(w, (sqrt((SIG * hh) / 2) * PI * PI) / (L * L)); + } + const dt = min(left, 0.6 / max(w, 1), 1 / 120); + step(S, dt); + tidy(S); + drops(S, dt); + left -= dt; + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.rate = cl(k[1], 0.2, 3) / 2; + S.nu = NU0 * 9 ** cl(k[2], -1, 1); + S.v0 = 34 * Q * TS * 2.2 ** cl(k[3], -1, 1); + if (!S.begun) { + S.begun = true; + for (let i = 0; i < 90; i++) advance(S, 1 / 30); + // The pool opens at the level where it drains as fast as it fills. + S.lvl = POOL - (PI * A0 * A0 * S.v0 * 3 * (S.nu / NU0) ** 0.45) / (W * DEPTH); + } + advance(S, d * S.rate); +} + +// The radius and centre of the liquid at half-row samples. +function profile(S) { + const { PH, PX, PD, z, x, V, lk } = S, n = S.n; + PH.fill(0); + PD.fill(0); + const put = (za, ha, xa, zb, hb, xb) => { + const k0 = max(0, Math.ceil(2 * za - 0.5)), k1 = min(2 * H - 1, floor(2 * zb - 0.5)); + for (let k = k0; k <= k1; k++) { + const zz = (k + 0.5) / 2, t = zb > za ? (zz - za) / (zb - za) : 0, hh = ha + (hb - ha) * t; + if (hh > PH[k]) { + PH[k] = hh; + PX[k] = xa + (xb - xa) * t; + PD[k] = zb > za ? (hb - ha) / (zb - za) : 0; + } + } + }; + let a = 0; + while (a < n) { + let b = a; + while (b < n - 1 && lk[b]) b++; + if (b > a) { + let pz = 0, ph = 0, px = 0; + for (let i = a; i < b; i++) { + const L = max(1e-3, z[i + 1] - z[i]), hh = sqrt(V[i] / (PI * L)), zm = (z[i] + z[i + 1]) / 2, xm = (x[i] + x[i + 1]) / 2; + if (i === a) { + // The upper end: the thread runs off the top edge, a free end is capped. + if (a === 0) put(z[a] - 2, hh, xm, zm, hh, xm); + else + for (let c = 0; c < 4; c++) { + const t0 = c / 4, t1 = (c + 1) / 4, cap = max(L / 2, 0.8 * hh); + put(zm - cap * sqrt(1 - t0 * t0), hh * t0, xm, zm - cap * sqrt(1 - t1 * t1), hh * t1, xm); + } + } else put(pz, ph, px, zm, hh, xm); + if (i === b - 1) { + if (z[b] > S.lvl) put(zm, hh, xm, S.lvl + 2, hh, xm); + else + for (let c = 0; c < 4; c++) { + const t0 = c / 4, t1 = (c + 1) / 4, cap = max(L / 2, 0.8 * hh); + put(zm + cap * sqrt(1 - t1 * t1), hh * t1, xm, zm + cap * sqrt(1 - t0 * t0), hh * t0, xm); + } + } + pz = zm; + ph = hh; + px = xm; + } + } + a = b + 1; + } + for (const p of S.drops) { + const dd = p.e * cos(p.ph), ry = p.R * (1 + dd), rx = p.R / sqrt(1 + dd); + const k0 = max(0, Math.ceil(2 * (p.y - ry) - 0.5)), k1 = min(2 * H - 1, floor(2 * (p.y + ry) - 0.5)); + for (let k = k0; k <= k1; k++) { + const w = ((k + 0.5) / 2 - p.y) / ry, hh = rx * sqrt(max(0, 1 - w * w)); + if (hh > PH[k]) { + PH[k] = hh; + PX[k] = p.x; + PD[k] = (-rx * w) / ry / max(0.15, sqrt(max(0, 1 - w * w))); + } + } + } + // Smooth the radius along the thread so the light runs over it without + // steps, and take the slope from the smoothed radius. + const { PT } = S; + for (let pass = 0; pass < 2; pass++) { + for (let k = 0; k < 2 * H; k++) { + const a = PH[max(0, k - 1)], b = PH[min(2 * H - 1, k + 1)]; + PT[k] = PH[k] > 0 && a > 0 && b > 0 ? 0.25 * a + 0.5 * PH[k] + 0.25 * b : PH[k]; + } + PH.set(PT); + } + for (let k = 0; k < 2 * H; k++) { + const a = PH[max(0, k - 1)], b = PH[min(2 * H - 1, k + 1)]; + if (PH[k] > 0 && a > 0 && b > 0) PD[k] = cl(b - a, -4, 4); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.begun) return; + profile(S); + const { PH, PX, PD, ser, wall, eta } = S; + const hue = S.hue + H0; + hsv(hue, 0.85, 1, ser); + // Orange and yellow go brown and olive on a dim wall: there the wall is + // greyed and lifted and the pool glows with the serum's own colour. + const wu = (hue - floor(hue) - 0.13) / 0.19, wm = wu * wu < 1 ? (1 - wu * wu) ** 2 : 0; + hsv(hue, 0.14 - 0.1 * wm, 1 + 0.18 * wm, wall); + const [sr, sg, sb] = ser, [wr, wg, wb] = wall; + // Absorption per LED of path through the serum, per channel. + const ar = 0.3 * (1 - sr), ag = 0.3 * (1 - sg), ab = 0.3 * (1 - sb); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let R = 0, G = 0, B = 0; + const px = x + 0.5, e0 = eta[max(0, x - 1)], e1 = eta[x], e2 = eta[min(W - 1, x + 1)]; + const surf = S.lvl + 0.25 * e0 + 0.5 * e1 + 0.25 * e2, slope = (e2 - e0) / 2, pf = cl(y + 1 - surf, 0, 1); + if (pf < 1) + for (let sub = 0; sub < 2; sub++) { + const k = 2 * y + sub, zz = (k + 0.5) / 2, wv = lit(px, zz); + let r = wv * wr, g = wv * wg, b = wv * wb; + const hh = PH[k], dx = px - PX[k], cov = cl(hh - abs(dx) + 0.5, 0, 1); + if (cov > 0 && hh > 0.2) { + // Clear serum: the wall behind seen magnified through it and + // tinted by the path the light takes, a darker rim, an inner + // glow on the far side, a highlight down the near side. + const uu = cl(dx / hh, -0.97, 0.97), c = sqrt(1 - uu * uu), hz = PD[k], nn = 1 / sqrt(1 + hz * hz); + const nx = uu * nn, ny = c * nn, nz = -hz * nn; + const lv = 1.35 * lit(PX[k] + dx * 0.55, zz + 0.45 * hh * hz), path = 2 * hh * c + 0.5; + const rim = 1 - 0.5 * exp(-c * 4), glow = 0.7 * max(0, 0.55 * nx + 0.25 * ny + 0.55 * nz) ** 2; + const sp = max(0, -0.4 * nx + 0.82 * ny - 0.42 * nz), spec = 0.95 * sp ** 40 + 0.12 * sp ** 6; + r += (lv * wr * exp(-ar * path) * rim + sr * glow + spec - r) * cov; + g += (lv * wg * exp(-ag * path) * rim + sg * glow + spec - g) * cov; + b += (lv * wb * exp(-ab * path) * rim + sb * glow + spec - b) * cov; + } + R += r / 2; + G += g / 2; + B += b / 2; + } + if (pf > 0) { + // The pool: the wall seen through it, a little lower and deeper in + // colour further down, lit from within, a dark band under the surface. + const top = max(0, y + 0.5 - surf), wv = lit(px, surf + top * 0.75) * 1.15, path = 3 + 0.3 * top; + const dk = 1 - 0.35 * exp(-((top - 1.4) ** 2)), gl = 0.2 + 0.06 * wm; + R += ((wv * wr * exp(-0.4 * ar * path) + gl * sr) * dk - R) * pf; + G += ((wv * wg * exp(-0.4 * ag * path) + gl * sg) * dk - G) * pf; + B += ((wv * wb * exp(-0.4 * ab * path) + gl * sb) * dk - B) * pf; + } + // The bright line of the surface, brighter where a ripple tilts it to the light. + const ln = exp(-(((y + 0.5 - surf) / 0.55) ** 2)) * (0.5 + cl(-slope * 0.8, -0.35, 0.5)); + R += ln; + G += ln; + B += ln; + display.setPixel(x, y, round(255 * min(1, R)), round(255 * min(1, G)), round(255 * min(1, B))); + } +} + +// The lit wall, as a level: pale, light from the upper left, soft vertical +// flutes 8 LEDs apart for the beads to magnify. +const lit = (x, z) => (0.4 + 0.24 * (1 - x / 90 - z / 300)) * (1 + 0.07 * cos((2 * PI * x) / 8)); diff --git a/incoming/pattern-study/patterns/steiner-chain.js b/incoming/pattern-study/patterns/steiner-chain.js new file mode 100644 index 000000000..363302177 --- /dev/null +++ b/incoming/pattern-study/patterns/steiner-chain.js @@ -0,0 +1,257 @@ +// ===== Patternflow pattern ===== +// Title: Steiner Chain +// Family: geometry +// Idea: A ring of circles each touching its two neighbours and the two circles that hold it turns in the top square, rigid and equal, while the bottom square shows the same ring inverted in a point outside it, the circles swelling as they pass the wide side and shrinking through the narrow one, each in its own colour in both squares and every touch lit, so wherever the chain starts it always closes. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Steiner's porism (Jakob Steiner, 1826), shown through circle inversion, pattern round 39 +// Round: 39 +// =============================== +// @matrix 64x128 +// @knobs Circles=6..12, Speed=0.2..3, Turns=1..4, Offset=0..1 +// +// Knob 1: Circles. How many circles the chain holds, 6 to 12, each in its own colour, hues spaced evenly round the wheel. At 6 the inner circle is a third of the outer one across and the chain circles are 10 LEDs in radius in the top square; at 12 the inner circle is 0.59 of the outer and the circles are beads 6.2 LEDs in radius. +// Knob 2: Speed. At 2 the chain turns once in 30 seconds, and a fresh start lays it circle by circle in 1.5 seconds; at 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Turns. How many times the chain winds round before it closes, 1 to 4, at most (Circles - 1) / 2. Neighbours then stand 360 times Turns over Circles degrees apart, sin(180 Turns / Circles) = (1 - inner) / (1 + inner) fixes the inner circle, and the circles overlap in a rosette round it: 7 circles at 2 turns leave it 0.12 of the outer one across, at 3 turns 0.013. Where Turns and Circles share a factor the circles fall into that many chains, each closed: 6 circles at 2 turns are two chains of 3. +// Knob 4: Offset. How near the chain the point of the inversion sits, and with it how far off-centre the inner circle of the bottom square stands: 12 LEDs above the centre at the start, where a circle passing the wide side at the bottom is 3.4 times as wide as one passing the narrow side; 3 times at 0, and 24 times at 1, where the inner circle stands 25 LEDs up, nearly touching the outer one, and the circles squeezing past it are beads 2 LEDs across. +// Buttons: K1 tap lays the chain again from where it stands, one circle at a time, and the last one closes the gap with a flash. + +const W = 64, H = 128; +const { floor, min, max, sqrt, sin, cos, exp, round, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +// Each square's centre and the outer circle's radius, in LEDs; pixel centres at x + 0.5. +const CX = 32, CY = [32, 96], RPX = 30.4; +// The inversion point lies on this ray from the centre of the concentric +// picture, above it. A point near the inversion point images far away, so +// the wide side of the bottom square is at the bottom. +const PSI = -PI / 2; +// Seconds per turn of the chain at Speed 2, and to lay it. +const TURN = 30, LAY = 1.5; +// Opening hue of circle 0. +const H0 = 0.55; + +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] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +const gcd = (a, b) => (b ? gcd(b, a % b) : a); + +export function setup(params) { + params.s = { + n: 6, m: 1, q: 3.4, phi: 0, lay: 0, flash: [], begun: false, + // Per square: circles as x, y, r (the outer, the inner, then the chain in + // the order it is laid); the touches as x, y, and which two circles meet. + C: [new Float64Array(3 * 14), new Float64Array(3 * 14)], + T: [new Float64Array(2 * 40), new Float64Array(2 * 40)], nt: 0, tri: [], + A: new Float32Array(3 * W * H), col: [0, 0, 0], fil: [0, 0, 0], + }; +} + +// The inversion point's distance a > 1 from the centre, in units of the +// outer radius, at which a chain circle passing the near side images q times +// as wide as one passing the far side: the gaps between the two circles on +// the line through the point image to (1 - rho) / ((a - 1)(a - rho)) and +// (1 - rho) / ((a + 1)(a + rho)), and their ratio is q. +function dist(q, rho) { + const A = q - 1, B = (q + 1) * (1 + rho), C = (q - 1) * rho; + return (B + sqrt(B * B - 4 * A * C)) / (2 * A); +} + +// The chain at start angle phi: circle i of sub-chain j, step k, centred on +// the ring at angle phi + 2 pi (k m + j) / n. Square 0 is the concentric +// picture; square 1 its image in the unit circle about the point a, scaled +// so the outer circle's image is RPX across and centred. +function geometry(S) { + const { n, m } = S, s = sin((PI * m) / n), rho = (1 - s) / (1 + s), r = (1 - rho) / 2, d = (1 + rho) / 2; + const g = gcd(n, m), len = n / g, a = dist(S.q, rho), ax = a * cos(PSI), ay = a * sin(PSI); + const inv = (x, y) => { + const u = x - ax, v = y - ay, k = 1 / (u * u + v * v); + return [ax + u * k, ay + v * k]; + }; + const invC = (x, y, rr) => { + const u = x - ax, v = y - ay, D = u * u + v * v - rr * rr; + return [ax + u / D, ay + v / D, rr / Math.abs(D)]; + }; + // Model circles: 0 the outer, 1 the inner, then the chain. + const M = [[0, 0, 1], [0, 0, rho]]; + for (let j = 0; j < g; j++) + for (let k = 0; k < len; k++) { + const t = S.phi + (TAU * (k * m + j)) / n; + M.push([d * cos(t), d * sin(t), r]); + } + const O = invC(0, 0, 1), sc = RPX / O[2]; + const C0 = S.C[0], C1 = S.C[1]; + for (let i = 0; i < M.length; i++) { + const [x, y, rr] = M[i], [X, Y, R] = invC(x, y, rr); + C0[3 * i] = CX + RPX * x; + C0[3 * i + 1] = CY[0] + RPX * y; + C0[3 * i + 2] = RPX * rr; + C1[3 * i] = CX + sc * (X - O[0]); + C1[3 * i + 1] = CY[1] + sc * (Y - O[1]); + C1[3 * i + 2] = sc * R; + } + // The touches: each circle with the outer and the inner, and each with + // the next in its sub-chain, the last with the first. + let nt = 0; + const T0 = S.T[0], T1 = S.T[1], tri = []; + const put = (x, y, a, b) => { + const [X, Y] = inv(x, y); + T0[2 * nt] = CX + RPX * x; + T0[2 * nt + 1] = CY[0] + RPX * y; + T1[2 * nt] = CX + sc * (X - O[0]); + T1[2 * nt + 1] = CY[1] + sc * (Y - O[1]); + tri.push([a, b]); + nt++; + }; + for (let j = 0; j < g; j++) + for (let k = 0; k < len; k++) { + const i = 2 + j * len + k, nx = 2 + j * len + ((k + 1) % len); + const [x, y] = M[i], [x2, y2] = M[nx], ux = x / d, uy = y / d; + put(ux, uy, i, 0); + put(rho * ux, rho * uy, i, 1); + put((x + x2) / 2, (y + y2) / 2, i, nx); + } + S.nt = nt; + S.tri = tri; + S.model = M; + S.rho = rho; + S.a = a; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [6, 2, 1, 0.06], dd = cl(dt || 0, 0, 0.1); + const n = round(cl(k[0], 6, 12)), rate = cl(k[1], 0.2, 3) / 2; + const m = max(1, min(round(cl(k[2], 1, 4)), floor((n - 1) / 2))); + S.q = 3 * 8 ** cl(k[3], 0, 1); + if (n !== S.n || m !== S.m) { + S.n = n; + S.m = m; + S.flash = []; + } + if (!S.begun) { + S.begun = true; + S.lay = 0; + } + if (input.btnPressed && input.btnPressed[0]) { + S.lay = 0; + S.flash = []; + } + const wasLaid = S.lay >= LAY; + S.lay += dd * rate; + S.phi += (dd * rate * TAU) / TURN; + if (!wasLaid && S.lay >= LAY) S.flash.push(0); + for (let i = 0; i < S.flash.length; i++) S.flash[i] += dd * rate; + S.flash = S.flash.filter((f) => f < 0.8); + geometry(S); +} + +// How far circle i is laid, 0..1: they go down one after another. +function shown(S, i) { + if (i < 2) return 1; + return cl(((S.lay / LAY) * S.n - (i - 2)) * 1.5, 0, 1); +} + +// Orange and yellow go brown and olive dimmed, so their discs are lit +// brighter inside and paler. +const warm = (h) => { + const u = (h - floor(h) - 0.13) / 0.14; + return u * u < 1 ? (1 - u * u) ** 2 : 0; +}; + +function disc(A, x0, y0, r, w, c, f, lift, lo, hi) { + const ext = r + 2, rr = max(r, 0.35); + for (let y = max(lo, floor(y0 - ext)); y <= min(hi, floor(y0 + ext)); y++) { + const dy = y + 0.5 - y0; + for (let x = max(0, floor(x0 - ext)); x <= min(W - 1, floor(x0 + ext)); x++) { + const dx = x + 0.5 - x0, D = sqrt(dx * dx + dy * dy); + if (D > ext) continue; + const u = min(1, D / rr), fill = cl(rr - D + 0.5, 0, 1), e = (D - rr) / 0.7; + // A soft lit disc: dim in the middle, rising toward a bright rim, a + // glow on the upper left where the light comes from. + const hx = dx + 0.42 * rr, hy = dy + 0.42 * rr, glow = exp(-(hx * hx + hy * hy) / (0.22 * rr * rr + 0.6)); + const vf = w * fill * (0.2 + 0.28 * u * u + 0.32 * glow) * lift; + let v = 0.85 * exp(-e * e); + if (r < 1.5) v += (1.5 - r) * 0.5 * fill; + v *= w; + // Screen blend, so where wound chains overlap the lenses show without + // washing out to white. + const p = 3 * (y * W + x); + A[p] = 1 - (1 - A[p]) * (1 - min(1, c[0] * v + f[0] * vf)); + A[p + 1] = 1 - (1 - A[p + 1]) * (1 - min(1, c[1] * v + f[1] * vf)); + A[p + 2] = 1 - (1 - A[p + 2]) * (1 - min(1, c[2] * v + f[2] * vf)); + } + } +} + +function ring(A, x0, y0, r, lv, lo, hi) { + const ext = r + 2; + for (let y = max(lo, floor(y0 - ext)); y <= min(hi, floor(y0 + ext)); y++) { + const dy = y + 0.5 - y0; + for (let x = max(0, floor(x0 - ext)); x <= min(W - 1, floor(x0 + ext)); x++) { + const dx = x + 0.5 - x0, e = (sqrt(dx * dx + dy * dy) - r) / 0.6; + if (e > 4 || e < -4) continue; + const v = lv * exp(-e * e), p = 3 * (y * W + x); + A[p] += v; + A[p + 1] += v; + A[p + 2] += v; + } + } +} + +function dot(A, x0, y0, s, lv, lo, hi) { + const ext = 3 * s; + for (let y = max(lo, floor(y0 - ext)); y <= min(hi, floor(y0 + ext)); y++) { + const dy = y + 0.5 - y0; + for (let x = max(0, floor(x0 - ext)); x <= min(W - 1, floor(x0 + ext)); x++) { + const dx = x + 0.5 - x0, v = lv * exp(-(dx * dx + dy * dy) / (2 * s * s)), p = 3 * (y * W + x); + A[p] += v; + A[p + 1] += v; + A[p + 2] += v; + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.begun) return; + const { A, col, fil, n } = S; + A.fill(0); + for (let sq = 0; sq < 2; sq++) { + const C = S.C[sq], T = S.T[sq], lo = sq * 64, hi = lo + 63; + ring(A, C[0], C[1], C[2], 0.5, lo, hi); + ring(A, C[3], C[4], C[5], 0.5, lo, hi); + for (let i = 2; i < n + 2; i++) { + const w = shown(S, i); + if (w <= 0) continue; + const h = H0 + (i - 2) / n, wm = warm(h); + hsv(h, 0.8, 1, col); + hsv(h, 0.74 - 0.3 * wm, 1, fil); + // A wound chain stacks its discs Turns deep, so each is filled fainter. + disc(A, C[3 * i], C[3 * i + 1], C[3 * i + 2], w, col, fil, (1 + 0.9 * wm) * S.m ** -0.7, lo, hi); + } + // Every touch, lit once both its circles are laid; small ones smaller. + for (let t = 0; t < S.nt; t++) { + const [a, b] = S.tri[t], w = min(shown(S, a), shown(S, b)); + if (w < 1) continue; + const rr = min(C[3 * a + 2], b > 1 ? C[3 * b + 2] : 99); + dot(A, T[2 * t], T[2 * t + 1], cl(0.25 * rr, 0.45, 0.8), 0.75 / S.m, lo, hi); + } + // The closing: the touch between the last circle laid and the first. + for (const f of S.flash) { + const len = n / gcd(n, S.m), i = 2 + len - 1, t = 3 * (i - 2) + 2, e = 1 - f / 0.8; + dot(A, T[2 * t], T[2 * t + 1], 1.4 + 2.5 * f, 1.6 * e * e, lo, hi); + } + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = 3 * (y * W + x); + display.setPixel(x, y, round(255 * min(1, A[p])), round(255 * min(1, A[p + 1])), round(255 * min(1, A[p + 2]))); + } +} From e7d21e59100af7a2960ea3ffdabe99366adcb72e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:48:26 +0000 Subject: [PATCH 07/10] =?UTF-8?q?incoming:=20README=20=EC=97=90=2039?= =?UTF-8?q?=ED=8C=90=20=ED=91=9C=EC=99=80=20=EC=A0=88,=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=ED=8C=90=20=EA=B0=90=EC=9D=84=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WSruBmW95rasfF2WmM8DZ7 --- 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 917cd6ce3..0df7b2757 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 288개를 쉰다섯 갈래로 나눠 만들었어요. 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판은 주제 표가 수학 여섯과 화장품 팝업 넷으로 나뉜 첫 판이라, 이름 있는 수학 대상 여섯과 화장품 매장 벽에 틀 질감 넷을 더했고, 위상·매듭과 화장품 팝업 갈래를 새로 열었어요. 30판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩 더하고, 팝업 매장의 LED 벽에 틀 질감 넷을 화장품 팝업 갈래에 더했어요. 31판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했고, 새 갈래는 열지 않았어요. 32판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽의 질감 넷을 화장품 팝업 갈래에 더했어요. 33판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 34판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 35판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 36판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 37판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 38판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 298개를 쉰다섯 갈래로 나눠 만들었어요. 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판은 주제 표가 수학 여섯과 화장품 팝업 넷으로 나뉜 첫 판이라, 이름 있는 수학 대상 여섯과 화장품 매장 벽에 틀 질감 넷을 더했고, 위상·매듭과 화장품 팝업 갈래를 새로 열었어요. 30판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩 더하고, 팝업 매장의 LED 벽에 틀 질감 넷을 화장품 팝업 갈래에 더했어요. 31판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했고, 새 갈래는 열지 않았어요. 32판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽의 질감 넷을 화장품 팝업 갈래에 더했어요. 33판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 34판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 35판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 36판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 37판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 38판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 39판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. [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판에서 세로로 다시 짰어요. @@ -474,6 +474,21 @@ | [Serum Fingers](patterns/serum-fingers.js) | 화장품 팝업 | 유리판 두 장 사이 진한 크림에 묽은 세럼을 밑에서 밀어 넣으면, 둥근 손가락이 부풀고 갈라지며 위로 다투어 올라가요. LED 는 세럼이 닿을 때 한 번만 바뀌고, 한 손가락이 꼭대기에 닿으면 크림이 가는 손가락부터 다시 덮고 새로 밀어요. K3 은 표면 장력을 낮춰 굵은 손가락 셋을 가는 손가락 열둘로 바꾸고, K1 을 누르면 크림이 바로 다시 덮어요. | | [Sperner's Lemma](patterns/sperner-lemma.js) | 위상·매듭 | 세운 삼각형을 작은 방으로 나누고, 꼭짓점마다 연속 사상이 가장 많이 줄이는 좌표의 번호를 붙여 파랑, 빨강, 초록 영토로 칠해요. 흰 걸음이 밑변의 1-2 문으로 들어와 방마다 다른 문으로 나가다가 세 번호를 다 가진 무지개 방에서 멈추고, 그 방은 사상의 고정점 위에 있어요. K3 은 사상을 고정점 둘레로 비틀어 영토 경계를 감고, K4 는 고정점을 늘려 무지개 방을 홀수 개로 늘려요. | +### 39판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Blotting Paper](patterns/blotting-paper.js) | 화장품 팝업 | 결이 비치는 얇은 기름종이가 살에 눌린 자리마다 피지를 빨아들여, 반투명한 점이 결을 따라 √t 로 번지고 합쳐져요. 젖은 자리는 뒤 벽의 빛이 비쳐 밝고 짙은 색이 되고, LED 마다 한 번만 밝아져요. 종이가 거의 다 비치면 새 종이가 내려와 덮어요. | +| [Borsuk-Ulam](patterns/borsuk-ulam.js) | 위상·매듭 | 기온과 기압이 천천히 바뀌는 지구가 돌아요. 위 원판은 앞 반구, 아래 원판은 뒤 반구라서 −x 가 x 와 같은 자리에 그려지고, 대척점과 기온이 같은 흰 선과 기압이 같은 자홍 선이 두 원판의 같은 자리를 지나요. 두 선이 만나는 금색 점이 보르수크-울람 정리가 늘 있다고 하는 대척점 쌍이에요. | +| [Brachistochrone](patterns/brachistochrone.js) | 물리·스프링 | 왼쪽 위 한 점에서 구슬 다섯이 직선, 갈릴레이의 원호, 포물선, 깊이 떨어지는 길, 사이클로이드를 타고 함께 미끄러져요. 직선보다 긴 길을 타는 사이클로이드의 구슬이 먼저 닿고, 구슬이 지금까지 닿을 수 있는 점들의 앞면이 고리를 남기며 패널 아래까지 퍼져요. 한 판 걸러 사이클로이드의 여러 높이에서 놓은 구슬이 바닥에 함께 닿아요. | +| [Crackle Polish](patterns/crackle-polish.js) | 화장품 팝업 | 형광 분홍 바탕 위의 검은 크래클 탑코트가 마르며 갈라져요. 긴 금이 먼저 패널을 가로지르고, 짧은 금이 옛 금에서 직각으로 나와 다른 금에 직각으로 닿고, 틈이 벌어지며 조각이 광택 테를 두른 구슬이 돼요. 다 갈라지면 새 코트를 위에서 칠해 내려와요. | +| [Explicit Formula](patterns/explicit-formula.js) | 수론 | 소수 거듭제곱마다 log p 씩 뛰는 계단 ψ(x) 를 제타 함수의 영점으로 다시 지어요. x 가 아래로, ψ 가 옆으로 가고, 영점이 한 쌍씩 더해질 때마다 매끈한 선 x 에 물결이 붙다가 계단으로 날카로워지고, 제자리를 찾은 계단 띠부터 켜져요. 오른쪽 끝 세 열이 임계선이라 더해진 영점이 제 높이에서 켜져요. | +| [Isospectral Drums](patterns/isospectral-drums.js) | 물결·간섭 | 모양이 다른 북 두 개가 위아래에서 같은 높이의 소리로 울려요. 반정사각형 일곱 장으로 지은 고든, 웹, 월퍼트의 북이라 고유값이 하나도 다르지 않고, 가운데 띠의 눈금이 그 고유값이에요. 북마다 제 노드선을 그리며 같은 박자로 떨어요. | +| [Kingman Coalescent](patterns/kingman-coalescent.js) | 데이터 그림 | 64개체 모런 집단이 앞으로 살고, 패널은 지금 살아 있는 64개체의 가계도예요. 맨 아랫줄이 지금, 위로 갈수록 과거이고, 같은 조상의 자손이 붙어 서도록 열을 줄 세워서 좁은 블록이 위로 갈수록 합쳐지는 불꽃 그림이 돼요. 마지막 두 갈래가 만나는 데 걸리는 시간이 전체의 절반쯤이라 긴 이음매 하나가 위쪽을 가르고, 한 집안이 사라지면 공통 조상이 앞으로 뛰어요. | +| [Serum Drip](patterns/serum-drip.js) | 화장품 팝업 | 위 끝에서 흘러드는 금빛 세럼 실이 목이 졸리고 부풀어 구슬을 꿴 줄이 되고, 가는 실이 끊기며 큰 방울과 작은 위성 방울이 바닥의 웅덩이로 떨어져요. 세로 홈이 진 벽이 구슬마다 크게 비쳐요. | +| [Soap Film](patterns/soap-film.js) | 화장품 팝업 | 고리에 건 세안 거품의 막이 세로로 서서 빠져요. 위로 갈수록 얇아서 뉴턴의 색 띠가 가로로 쌓여 가라앉고, 옆에서 얇은 막이 기둥으로 올라오고, 위에서 검은 막이 내려와요. 다 얇아지면 막이 터지고 새 막이 아래에서 올라와요. | +| [Steiner Chain](patterns/steiner-chain.js) | 기하·대칭 | 위에서는 고리 안의 같은 원 여섯이 통째로 돌고, 아래는 그 사슬을 반전한 그림이라 원이 넓은 쪽을 지나며 부풀고 좁은 쪽에서 줄어요. 원마다 위아래가 같은 색이고, 어디서 시작해도 사슬이 닫히는 슈타이너의 포리즘이 접점의 흰 불꽃으로 보여요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -1844,6 +1859,50 @@ E8 의 근 240개를 고셋의 4₂₁ 다면체 꼭짓점으로 그렸어요. 주제 1 과 6 으로 사프만-테일러의 손가락을 세웠고, 겨눈 칸은 Day and Night 혼자이던 움직임 × 모양 바뀜 1,3 이에요. 움직임 0.061 아래에 모양 바뀜 0.217~0.331 인 칸이에요. 처음 판은 바닥 가운데 한 점에서 밀어 넣었는데 표면 장력이 세서 둥근 산 하나만 자랐어요(움직임 0.031, 모양 바뀜 0.186). 장력을 줄이자 가는 손가락 하나가 8초 전에 꼭대기에 닿아 지우기가 잰 구간 안에 들어왔어요(모양 바뀜 0.084). 장력을 압력장에 넣는 셈은 퍼져서, 곡률을 성장 속도에 곱하는 꼴로 바꾸고 바닥 전체를 틈으로 바꾸자 둥근 손가락 여럿이 다투며 올라갔어요. LED 마다 한 번 오르기만 하면 모양 바뀜은 침범한 넓이 곱하기 밝기 차이이고, 움직임은 그 6분의 1 이에요. 2초에서 8초 사이에 오른 합이 0.251, 내린 합이 0.0000 이었어요. 그래서 모양은 두고 밀어 넣는 양을 1초에 580 LED 로 정하고, 크림을 밝기 71 의 회색으로 낮춰 세럼과 0.7 쯤 벌려서 움직임 0.042, 모양 바뀜 0.251 로 칸 가운데에 맞췄어요. 씨앗 없이 12번 돌려도 손가락이 꼭대기에 닿는 때는 8.8초에서 11.3초여서 지우기는 늘 10초 뒤에 시작해요. +## 39판 + +39판도 주제 5 「낯선 수학」 여섯과 주제 6 「화장품 브랜드 팝업」 넷이에요. 같이 돈 37판(#231)은 이 판 도중에 dev 에 머지됐어요. 37판이 쓴 여섯 갈래(알고리즘, 공간·원근, 시뮬레이션, 입자, 보로노이·셀, 노이즈·흐름)를 빼고, 수학 여섯을 물결·간섭, 수론, 물리·스프링, 기하·대칭, 데이터 그림, 위상·매듭에 하나씩 넣었어요. 수학은 Isospectral Drums, Explicit Formula, Brachistochrone, Steiner Chain, Kingman Coalescent, Borsuk-Ulam 이고, 슈타이너 사슬 말고는 「다음 판 감」의 목록 밖에서 찾았어요. 화장품은 Soap Film, Blotting Paper, Serum Drip, Crackle Polish 이고, 기름종이와 스포이트의 실은 「다음에 해 볼 질감」에 적혀 있던 감이에요. 주제 1 은 37판까지의 288개에도 빈 칸이 없어서 패턴이 하나뿐인 칸을 겨눴어요. Blotting Paper 가 움직임 × 모양 바뀜 1,4 에, Soap Film 이 복잡도 × 모양 바뀜 4,4 에 둘째로 섰어요. 주제 4 는 Brachistochrone 과 Serum Drip 이에요. + +### Isospectral Drums + +주제 5 로 고든, 웹, 월퍼트의 등스펙트럼 북(1992)을 세웠어요. 마크 카츠가 1966년에 물은 「북 소리로 모양을 들을 수 있나」에 「아니요」라고 답한 북 한 쌍이에요. 좌표를 외워 쓰지 않고 지었어요. PSL(3,2) 의 대합 셋이 파노 평면의 일곱 점에 작용하면 북 A 가, 일곱 직선에 작용하면 북 B 가 되도록 반정사각형 일곱 장을 반사로 붙였어요. 대합 셋을 다 뒤져 보니 평면에 겹치지 않게 펴지면서 두 모양이 합동이 아닌 짝은 이 하나뿐이었어요. 반정사각형의 다리를 LED 축에, 빗변을 45도에 두면 LED 격자의 5점 라플라시안도 정리를 그대로 지켜요. 다리 19 LED 에서 두 북의 안쪽 점은 1179개씩이고, 고유값 1179개가 4.9e-14 안에서 같았어요. 9번째 고유값은 4 − 2cos(π/19) − 2cos(2π/19) = 0.135642909793 과 끝자리까지 맞았어요. 반정사각형 한 장의 가장 낮은 모드를 이음매마다 뒤집은 모드라서, 노드선이 타일의 이음매 그대로예요. 정상파는 cos 이 0 을 지날 때마다 화면 전체가 꺼져서 북 바닥에 0.18 의 밝기를 깔았고, 노드선은 값을 기울기로 나눈 거리로 그려 진폭과 상관없이 같은 굵기로 남겼어요. 여는 노브에서 북은 6번 모드부터 5초마다 한 모드씩 올라가요. 잰 값은 움직임 0.295, 채움 0.333, `thirds` 42/19/38 이에요. + +### Explicit Formula + +주제 5 로 리만의 명시 공식(1859)을 폰 망골트의 꼴(1895)로 세웠어요. 체비쇼프 함수 ψ(x) 의 계단을 x 에서 영점 항을 하나씩 빼며 다시 지어요. 영점은 mpmath 로 200개를 소수 여섯째 자리까지 구해 넣었고, γ₁ = 14.134725, γ₂₀₀ = 396.381854 예요. T 까지 센 영점 수는 리만-폰 망골트 공식과 T = 100 에서 29 와 29.00, T = 300 에서 138 과 137.71 이었어요. 여는 노브는 x 가 1~24 예요. 소수 거듭제곱에서 0.5 넘게 떨어진 점의 가장 큰 어긋남이 영점 0개에서 1.599, 10개에서 0.775, 100개에서 0.163, 200개에서 0.122 로 줄었고, 계단이 뛰는 자리에서는 두 단의 가운데로 가요. ψ₂₀₀(7) 은 5.0746 이고 가운데는 5.0673 이에요. 처음에는 참 계단과 어긋난 넓이만 칠했는데, 어긋남이 1~2 LED 라 테두리 선에 묻혔어요. 소수 거듭제곱 사이를 띠로 나누고 합이 제 계단에 맞아 들어간 띠부터 켜지게 하자 계단이 자리를 찾는 것이 읽혔고, 모양 바뀜도 0.02 에서 0.103 이 됐어요. 36판의 Gibbs Phenomenon 이 부분합을 줄마다 쌓아서, 이 판은 영점 수를 시간에 걸었어요. 2초에 영점이 셋쯤이라 처음에 둔 매끄럽게 하기 노브는 2~3초 창에서 반응이 약해 빼고, 기울기를 빼는 Tilt 를 넣었어요. Tilt 1 에서는 ψ(x) − x 만 남아 영점만으로 지은 톱니가 보여요. + +### Brachistochrone + +주제 4 와 5 로 요한 베르누이의 최단 강하선(1696)을 세웠어요. 처음에는 출발점을 위에, 도착점을 아래에 두려 했어요. 그러면 옆 거리가 낙차의 0.48배라 사이클로이드가 직선보다 2.7%, 원호보다 0.5% 빠를 뿐이고, 2.4초 경주에서 6~95 ms 차이라 구슬이 한꺼번에 닿아요. 그래서 도착점을 사이클로이드 아치의 바닥, 39번째 줄 오른쪽 끝에 두어 직선이 18.5% 늦게 했어요. 그 자리는 하위헌스의 등시 곡선(1673)도 그대로 줘서, 한 판 걸러 높이가 다른 여섯 구슬을 놓으면 모두 2.39956초에 바닥에 닿아요. 높이는 124번째 줄까지 떨어졌다 올라오는 길과 베르누이의 동시선으로 채웠어요. 동시선은 지금까지 구슬이 닿을 수 있는 점들의 앞면이에요. LED 마다 그 점을 지나는 사이클로이드를 따라 가장 짧은 시간을 셈해서, 사이클로이드의 구슬만 그 앞면을 끝까지 타요. 사이클로이드 시간은 π√(a/g) = 2.399563 초와 상대 오차 4e-7 로 맞았고, 원호는 1.19%, 포물선은 4.29%, 직선은 18.54% 늦었어요. 사이클로이드는 72.6 LED 로 직선의 67.6 보다 긴 길이에요. 잰 값은 `thirds` 46/22/20 이고, 2~8초에 밝기가 24 넘게 바뀐 LED 는 위, 가운데, 아래가 91%, 100%, 100% 예요. + +### Steiner Chain + +주제 5 로 슈타이너의 포리즘(1826)을 세웠어요. 「다음 판 감」에 적혀 있던 감이에요. 위 정사각형에서는 고리 안의 같은 원 여섯이 통째로 돌고, 아래 정사각형에는 그 사슬을 위쪽 한 점에 대해 반전한 그림이 서요. 아래에서는 안쪽 원이 가운데에서 12 LED 비켜 있고, 사슬의 원이 넓은 쪽을 지날 때 좁은 쪽의 3.4배로 부풀어요. 원마다 제 색을 두 정사각형에서 같게 칠해서, 어느 원이 어느 원의 상인지 눈으로 짝지어요. 64 LED 에서는 테두리를 밝히고 접점마다 흰 불꽃을 찍어야 「닿아 있다」가 읽혔어요. K3 Turns 는 사슬이 m 바퀴 감기게 해요. 고리 너비는 sin(πm/n) = (1 − ρ)/(1 + ρ) 로 정해지고, m 과 n 이 공약수를 가지면 원 여섯이 세 개씩 닫힌 사슬 둘로 갈라져요. 패턴이 그린 원을 LED 단위로 읽어 67,200 가지 배치에서 맞닿은 쌍 363만 개를 봤어요. 중심 거리와 반지름 합의 차가 가장 큰 것이 1.8e-13 LED 였어요. 아래 정사각형의 주어진 두 원만으로 사슬을 처음부터 이분법으로 놓아 본 4,200 개도 닫히는 틈이 가장 커야 6.6e-11 LED 였어요. 잰 값은 움직임 0.344, 채움 0.610, `thirds` 63/55/63 이에요. + +### Kingman Coalescent + +주제 5 로 킹먼의 합류(1982)를 모런 집단(1958)으로 세웠어요. 64개체가 한 번에 하나씩 태어나고 하나씩 죽는 동안, 지금 살아 있는 64개체의 조상을 위로 거슬러 칠해요. 열을 줄마다의 조상으로 줄 세우면 줄이 어느 때에 걸리느냐에 따라 형제 블록의 차례가 뒤집혀서, 조상 사슬 전체로 줄 세웠어요. 그러면 살아 있는 두 개체 사이의 차례가 바뀌지 않아요. 64개체의 공통 조상까지 평균 시간은 1.9845 로 킹먼의 2(1 − 1/64) = 1.9688 언저리이고, 마지막 합류가 그 시간의 0.510 을 차지해 킹먼의 0.508 과 같았어요. 표본 5,000개로 잰 값이에요. 합류의 93% 가 공통 조상까지 시간의 아래 4분의 1 에 몰려서, 패널 아래는 촘촘하고 위쪽은 긴 이음매 하나가 갈라요. 태어날 때마다 색을 조금씩 바꾸자 블록마다 줄마다 색이 달라 가로 줄무늬로 읽혀서, 색 바뀜을 드물게 했어요. 표본 16개만 따라가면 새로 난 개체의 조상이 더 위에서 합류해 공통 조상이 뒤로도 뛰어서, 64개체를 다 따라가요. study 의 씨앗이 연 첫 나무는 공통 조상이 0.6 단위로 낮아 바닥 25줄에 눌렸어요. 마지막 합류가 평균의 0.8~1.3배가 될 때까지 더 돌린 뒤에 열어서 늘 흔한 나무로 열려요. 잰 값은 움직임 1.252, 채움 0.967, 색 0.410 이에요. + +### Borsuk-Ulam + +주제 5 로 보르수크-울람 정리(1933)를 위상·매듭 갈래의 일곱째로 세웠어요. 위 원판은 앞 반구이고, 아래 원판은 뒤 반구를 −x 가 x 자리에 오게 그려요. 그래서 대척점과 기온이 같은 흰 선과 기압이 같은 자홍 선이 두 원판의 같은 자리에 서요. 두 선은 마당의 홀수 차수에만 매여요. 대척점 쌍은 픽셀을 맞춰 찾지 않고 구 위의 뉴턴법으로 풀어서, 노브 여섯 자리의 12,000 프레임에서 |f(x) − f(−x)| 와 |g(x) − g(−x)| 가 2.0e-13 안이었어요. 쌍이 없는 프레임은 없었고, 쌍의 수는 늘 홀수였어요. 하나인 프레임이 3,125, 셋이 4,102, 다섯이 3,061 이에요. 시작점 72개만으로는 접점에서 막 생긴 쌍을 9,000 프레임 가운데 12번 놓쳤어요. 두 홀함수의 부호가 함께 바뀌는 2×2 칸을 시작점으로 더하자 한 번도 놓치지 않았어요. 쌍을 흰 점으로 찍었을 때는 흰 선에 묻혀서, 금색 심에 어두운 고리를 둘렀어요. 잰 값은 움직임 0.306, 채움 0.751, 색 0.339 예요. + +### Soap Film + +주제 1 과 6 으로 세로로 선 비누막을 세웠고, 겨눈 칸은 Bread Mould 혼자이던 복잡도 × 모양 바뀜 4,4 예요. 복잡도가 2.173 을, 모양 바뀜이 0.331 을 넘어야 하는 칸이에요. 색은 두께에서 셈했어요. 앞면과 뒷면에서 반사한 두 빛의 간섭을 400~700 nm 에서 10 nm 마다 더해 sRGB 로 옮겼고, 뒷면 반사의 부호가 바뀌어서 막이 얇아지면 검어져요. 가장 밝은 흰빛이 103 nm 에, 첫째와 둘째 차수 사이의 자주가 200 nm 언저리에 왔어요. 2nh ≈ 550 nm 로 셈한 207 nm 와 가까워요. 첫 판은 채도를 꽉 채워 채널이 0 과 255 에 붙어서 복잡도가 1.461 이었고, 모양 바뀜은 0.516 으로 축 끝을 넘었어요. 채도를 내리고 검은 막에도 1~21 nm 의 결을 주고, 검은 막이 내려오는 빠르기를 1초에 0.060 으로 맞추자 복잡도 2.342, 모양 바뀜 0.378 이 됐어요. deflate 는 바이트 값이 고르게 퍼질수록 덜 눌려요. slug 를 바꿔 씨앗을 바꾸면 모양 바뀜이 0.30~0.36 으로 흔들려서 칸의 가운데를 겨눴어요. 막은 여는 노브에서 11.8초에 터져서 잰 창 밖이에요. K1 은 회색 축을 따라 결과 전체를 돌려서, 첫 차수 띠가 0 에서 금색, 0.25 에서 초록, 0.5 에서 하늘색이에요. + +### Blotting Paper + +주제 1 과 6 으로 기름종이를 세웠고, 겨눈 칸은 Lunation 혼자이던 움직임 × 모양 바뀜 1,4 예요. 움직임이 0.061 아래, 모양 바뀜이 0.331 위라서 둘의 비가 5.46 을 넘어야 하는 칸이에요. LED 마다 가장 밝은 채널이 한 방향으로만 바뀌면 모양 바뀜은 2~8초에 바뀐 양이고 움직임은 그 5.97분의 1 이라, 비가 6 언저리에 서요. 기름이 닿은 점은 루커스-워시번 식대로 반지름이 √t 로 자라고, 결을 따라 조금 앞서 가요. 젖은 가장자리의 짙은 테는 색만 바꾸고 밝기는 그대로 둬서 잣대에 걸리지 않아요. 첫 판이 움직임 0.0608, 모양 바뀜 0.3648 로 비는 이미 6.00 이었어요. 점을 판에 고르게 깔고 뒤에 오는 점을 아직 마른 자리에 두어 0.0572 와 0.3434 로 칸 안에 들였어요. 무작위로 흩은 점은 마지막 10% 가 오래 남았어요. 판의 10% 가 비치는 데 2.5초, 절반이 6.7초, 90% 가 13초이고, 새 종이는 15.6초에 내려와요. 2~8초에 밝기가 24 넘게 바뀐 LED 는 위, 가운데, 아래가 67%, 73%, 66% 예요. 주황과 노랑 쪽에서는 마른 종이를 1.5배까지 밝히고 회색 쪽으로 옅게 해서 갈색이 나지 않아요. + +### Serum Drip + +주제 4 와 6 으로 스포이트에서 늘어지다 구슬로 끊기는 세럼을 세웠어요. 「다음에 해 볼 질감」에 적혀 있던 레일리-플라토 불안정이에요. 실은 1차원 가는 실 모형으로 풀었어요. 부피 조각 사이의 마디를 표면 넓이가 당기고 중력과 점성이 더해져서, 조각이 갈라지고 합쳐지고 끊겨요. 패턴의 `step()` 으로 가만히 선 실에 작은 물결을 주고 2.5~4초의 성장률을 맞춰 보니, 점성이 없을 때 가는 실 이론과 0.7~2.4% 안이었어요. 가장 빨리 자라는 파장은 ka = 0.697, 반지름의 9.0배였어요. 노브의 세 점성에서도 1.1~1.5% 안이었어요. 잡음만으로는 실이 패널 안에서 끊기지 않아서, 흘러드는 세럼에 반지름 8.8배 파장의 떨림을 넣어 위쪽 3분의 1부터 구슬이 보여요. 여는 노브에서 8 LED 너비의 금색 실이 80번째 줄 언저리에서 끊기고, 지름 15 LED 의 방울이 1.4초쯤마다 바닥의 웅덩이에 떨어져요. 처음에는 밝은 크림색 벽을 두었더니 금색 세럼과 가장 밝은 채널이 같아서 모양 바뀜이 0.013 으로 축의 아래 끝 0.021 을 넘었어요. 벽을 반쯤 어둡게 하고 세럼이 안에서 빛나게 하자 0.048 이 됐어요. 벽이 다 켜져서 `thirds` 는 100/100/100 이고, 2~8초에 밝기가 24 넘게 바뀐 LED 는 위, 가운데, 아래가 26.6%, 29.4%, 46.5% 예요. K3 Viscosity 는 웅덩이가 빠지는 빠르기도 늦춰서 반응이 0.03 에서 0.04 가 됐어요. + +### Crackle Polish + +주제 6 으로 크래클 매니큐어를 세웠어요. 새 금이 옛 금에서 직각으로 나오는 것은 16판의 Drying Mud 와 같아요. 이 판은 그 금 사이로 바탕이 빛나는 매장 벽으로 그렸어요. 검은 래커 조각이 광택 테를 두른 구슬이 되고, 그 틈으로 형광 분홍 바탕이 비쳐요. 볼록 다각형을 직선으로 자르면 만나는 각이 54~88도로 벌어졌고, 직각을 억지로 맞추면 벽돌 격자가 됐어요. 반 LED 격자 위에서 금의 끝을 조각의 긴 축을 가로질러 걸어 가게 하고 한쪽을 채우는 Bohn, Pauchard, Couder(2005)의 차례 나눔으로 바꿨어요. 금 끝 3만 개쯤이 다 옛 금이나 패널 끝에 닿았고, 닿는 각의 가운데 값이 87.6도예요. 여는 노브의 8초에는 조각이 53개이고, 긴 변이 9~17.8 LED 예요. 주황 바탕에서 난 갈색은 어두운 그늘이 아니라 1 LED 틈 가장자리에서 반쯤 섞인 LED 에서 왔어요. 틈을 덮는 몫을 제곱근으로 줄이자 사라졌어요. 잰 값은 움직임 0.061, 채움 0.504, 색 0.285 예요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -1886,7 +1945,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 24일 `dev`(`3f5d8c6`)의 268개에 38판의 열 개를 더한 278개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 24일 `dev`(`902f5ea`)의 288개에 39판의 열 개를 더한 298개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 @@ -1922,7 +1981,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ### 아직 없는 갈래 -`families.json` 의 쉰다섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 과 31판의 Boy's Surface, 33판의 Borromean Rings, 34판의 Lakes of Wada, 36판의 Heawood Torus, 38판의 Sperner's Lemma 예요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 세잎 매듭처럼 이름 있는 매듭이 그 갈래의 일곱째로 맞아요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 30판의 Swatch Row, Pearl Sheen, Bi-Phase, Satin Fold 가 그 갈래에 넷을 더했고, 31판의 Lip Gloss, Foam Lather, Setting Mist, Sponge Blend 가 넷을 더 더해 열둘이에요. 32판의 Cushion Mesh, Clay Mask, Sugar Scrub, Chrome Powder 가 넷을 더해 열여섯이고, 33판의 Cleansing Balm, Gold Leaf, Essence Drop, Peel-Off 가 넷을 더해 스물이에요. 34판의 Cat Eye, Velvet Nap, Mood Tint, Hair Gloss 가 넷을 더해 스물넷이고, 35판의 Bath Fizz, Toner Pad, Sheet Mask, Gilded Cream 이 넷을 더해 스물여덟이고, 36판의 Lotion Ribbon, Shimmer Oil, Blooming Gel, Whipped Soap 이 넷을 더해 서른둘이에요. 38판의 Milk Ring, Couette Shimmer, Photochromic, Serum Fingers 가 넷을 더해 서른여섯이에요. 28판이 연 수론 갈래는 Ford Circles 와 30판의 Three Gaps, 31판의 Gaussian Moat, 32판의 Gilbreath Table, 33판의 Goldbach Comet, 35판의 Kolakoski, 36판의 Elliptic Chords 예요. 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, 32판의 Arnold Tongues, 35판의 Chimera State, 38판의 Infinity Series 가 섰어요. +`families.json` 의 쉰다섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 과 31판의 Boy's Surface, 33판의 Borromean Rings, 34판의 Lakes of Wada, 36판의 Heawood Torus, 38판의 Sperner's Lemma, 39판의 Borsuk-Ulam 이에요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 세잎 매듭처럼 이름 있는 매듭이 그 갈래의 여덟째로 맞아요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 30판의 Swatch Row, Pearl Sheen, Bi-Phase, Satin Fold 가 그 갈래에 넷을 더했고, 31판의 Lip Gloss, Foam Lather, Setting Mist, Sponge Blend 가 넷을 더 더해 열둘이에요. 32판의 Cushion Mesh, Clay Mask, Sugar Scrub, Chrome Powder 가 넷을 더해 열여섯이고, 33판의 Cleansing Balm, Gold Leaf, Essence Drop, Peel-Off 가 넷을 더해 스물이에요. 34판의 Cat Eye, Velvet Nap, Mood Tint, Hair Gloss 가 넷을 더해 스물넷이고, 35판의 Bath Fizz, Toner Pad, Sheet Mask, Gilded Cream 이 넷을 더해 스물여덟이고, 36판의 Lotion Ribbon, Shimmer Oil, Blooming Gel, Whipped Soap 이 넷을 더해 서른둘이에요. 38판의 Milk Ring, Couette Shimmer, Photochromic, Serum Fingers 가 넷을 더해 서른여섯이에요. 39판의 Soap Film, Blotting Paper, Serum Drip, Crackle Polish 가 넷을 더해, 37판의 넷까지 마흔넷이에요. 28판이 연 수론 갈래는 Ford Circles 와 30판의 Three Gaps, 31판의 Gaussian Moat, 32판의 Gilbreath Table, 33판의 Goldbach Comet, 35판의 Kolakoski, 36판의 Elliptic Chords, 39판의 Explicit Formula 예요. 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, 32판의 Arnold Tongues, 35판의 Chimera State, 38판의 Infinity Series 가 섰어요. ### 멋진 것 더 멋지게 @@ -1942,6 +2001,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 38판의 열 개는 가장 낮은 노브가 Serum Fingers 의 K3 Fingers 로 네 자리로 0.0454 이고, Anderson Localization 의 K3 Disorder 0.0464 와 K4 Samples 0.0484 가 그다음이에요. 둘 다 자라는 그림이라 2~3초 창에는 손가락이 판 아래쪽에만 있고 원뿔도 다 열리기 전이어서, 노브가 바꿀 넓이가 적어요. 한 그림의 얼룩을 평균해 매끈하게만 하는 Samples 는 0.03 언저리였고, 뒤의 줄무늬가 표본 수에 반비례해 옅어지게 하자 0.05 가 됐어요. Figure-Eight Orbit 은 꼬리를 오래된 것부터 칠해 세 색을 살리자 K3 Trail 이 0.04 로 떨어져서, 꼬리 머리를 넓히고 끝을 빨리 가늘게 해 0.06 으로 올렸어요. +39판의 열 개는 가장 낮은 노브가 Serum Drip 의 K3 Viscosity 로 네 자리로 0.0374 이고, Brachistochrone 의 K4 Tracks 0.0380 과 Blotting Paper 의 K3 Pores 0.0394, Brachistochrone 의 K1 Hue 0.0398 이 그다음이에요. Serum Drip 은 점성이 실의 구슬만 바꿀 때 0.03 이었고, 웅덩이가 빠지는 빠르기도 점성에 따라 늦추자 0.04 가 됐어요. Brachistochrone 은 판의 70% 가 어두운 선 그림이라 트랙을 더해도 바뀌는 LED 가 적어요. Isospectral Drums 의 K4 Beat 는 바로 위 모드를 섞으면 맥놀이가 2~3초 창보다 느려서 0.03 이었고, 세 모드 위를 섞자 0.0400 이 됐어요. Explicit Formula 는 처음에 둔 매끄럽게 하기 노브가 영점이 셋쯤뿐인 2~3초 창에서 약해서 기울기를 빼는 노브로 바꿨어요. + Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. ### 세로 64×128 @@ -1966,11 +2027,13 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 38판에서는 Anderson Localization 이 시간을 128줄 아래로 내려 55/64/52 이에요. 뒤의 위치 에너지 줄무늬를 빼고 재도 29/40/38 이라 파동만으로 세 칸을 다 써요. 로그 밝기의 바닥을 1e-4 에 두었을 때는 꼬리가 판 폭을 다 채워 멈춘 원뿔이 보이지 않았고, 1e-3 으로 올리자 27 LED 너비에서 멈춘 기둥이 섰어요. Milk Ring 은 세럼이 다 켜져 100/100/100 이라 바뀐 LED 로 셌어요. 2초와 8초 두 화면 사이로는 12/18/29 이고, 2~8초 어느 때든 바뀐 것으로는 26/48/51 이에요. 첫 고리가 47줄, 둘째 대가 76줄에서 깨지고 마지막 고리가 117줄에서 녹아서, 패널 높이가 그대로 갈라지는 차례예요. Couette Shimmer 도 99/99/99 이고 바뀐 LED 로는 35/28/32 예요. 띠가 층층이 쌓이는 테일러 소용돌이는 세운 원통을 옆에서 본 그림이라 세운 패널에 맞았어요. Pólya Urn 은 줄이 로그 시계의 뽑기 차례라 100/100/100 이고, Bernoulli Convolutions 는 줄이 λ 라 68/96/77 이에요. Infinity Series 는 네 줄이 위에서 타격선까지 떨어져 33/33/33 이에요. Sperner's Lemma 는 꼭짓점이 위에 선 삼각형이라 12/42/82 로 위가 좁고, Figure-Eight Orbit 은 8자가 패널 높이로 서도 선 그림이라 14/16/14 예요. +39판에서는 Brachistochrone 과 Serum Drip 이 128줄을 써요. Brachistochrone 은 도착점을 위쪽 39번째 줄에 두어서 경주는 위 3분의 1에서 끝나요. 아래는 124번째 줄까지 떨어졌다 올라오는 길과 베르누이의 동시선이 채워서 `thirds` 가 46/22/20 이고, 2~8초에 밝기가 24 넘게 바뀐 LED 는 위, 가운데, 아래가 91%, 100%, 100% 예요. 동시선의 앞면이 패널을 쓸고 내려가서 바뀐 LED 는 아래쪽이 더 많아요. Serum Drip 은 벽이 켜져 100/100/100 이고, 바뀐 LED 는 26.6%, 29.4%, 46.5% 예요. 실은 위에서 구슬이 되고 80번째 줄 언저리에서 끊겨서, 아래 3분의 1에는 떨어지는 방울과 웅덩이의 물결이 있어요. Isospectral Drums 는 북 둘을 위아래에 두어 42/19/38 이에요. 가운데 3분의 1은 두 북의 끝과 고유값 띠라서 가장 낮아요. + ### 낯선 수학 -주제 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)이에요. 30판도 여섯을 더했어요. 세 간격 정리(Three Gaps), 호프스태터 나비와 TKNN 정수(Hofstadter Butterfly), 노이만의 외부 당구(Outer Billiards), 볼드의 탄도 퇴적과 KPZ 앞면(Ballistic Deposition), 아르놀트의 미앤더(Meander), 브로드벤트와 해머슬리의 스미기(Percolation)예요. 31판도 여섯을 더했어요. 마스킷 조각의 클라인 군 극한 집합(Indra's Pearls), 리틀우드 다항식의 근(Littlewood Roots), 고든의 가우스 해자(Gaussian Moat), 아르놀트의 고양이 사상(Cat Map), 자부스키와 크루스칼의 KdV 솔리톤(KdV Solitons), 보이 곡면(Boy's Surface)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드, 모듈러 형식, 호프 올뭉치, 세 간격 정리, 외부 당구 여섯이었고, 31판이 리틀우드 다항식의 근과 클라인 군의 극한 집합을 써서 여덟을 다 썼어요. 32판도 여섯을 더했어요. 탤벗 효과와 양자 카펫(Talbot Carpet), 길브레스 추측의 표(Gilbreath Table), 원 사상의 아르놀트 혀(Arnold Tongues), 콘웨이와 라딘의 핀휠 타일링(Pinwheel Tiling), 윌킨슨과 윌렘센의 침입 스미기(Invasion Percolation), 울람의 문제와 해머슬리 과정(Hammersley Lines)이에요. 33판도 여섯을 더했어요. 에인절, 홀로이드, 로믹, 비라그의 무작위 정렬 네트워크(Sorting Network), 베르트랑의 역설(Bertrand Paradox), 펜로즈의 비출 수 없는 방(Penrose Room), 당들랭 구(Dandelin Spheres), 테이트의 보로메오 고리(Borromean Rings), 골드바흐 혜성(Goldbach Comet)이에요. 34판도 여섯을 더했어요. 톰슨 문제와 오일러가 남기는 다섯 이웃 열둘(Thomson Problem), 켈빈의 배 물결(Kelvin Wake), 요네야마가 낸 와다의 호수(Lakes of Wada), 샤이데거의 강 그물(Scheidegger Rivers), 모저의 소파 문제와 해머슬리의 소파(Moving Sofa), 크누스의 춤추는 링크로 푼 펜토미노 덮기(Pentomino Cover)예요. 35판도 여섯을 더했어요. 뢸로의 폭이 일정한 곡선(Constant Width), 렌니의 주차 문제(Rényi Parking), 카리의 비주기 왕 타일(Kari-Culik Tiles), 콜라코스키 수열(Kolakoski), 쿠라모토와 바톡토흐의 키메라 상태(Chimera State), 주콥스키 날개(Joukowsky Airfoil)예요. 36판도 여섯을 더했어요. 그레이슨의 정리가 말하는 곡선 단축 흐름(Curve Shortening), 헤우드의 일곱 색 토러스 지도(Heawood Torus), 슈바르츠 랜턴(Schwarz Lantern), 윌브러햄과 깁스의 현상(Gibbs Phenomenon), 타원 곡선의 현과 접선(Elliptic Chords), 지먼의 파국 기계(Catastrophe Machine)예요. 37판도 여섯을 더했어요. 포천의 쓸기 선 알고리즘(Fortune's Sweep), 앙투안의 목걸이(Antoine's Necklace), 카르만 소용돌이 길의 안정성(Kármán Street), 단단한 원판의 헥사틱 상(Hexatic Phase), 존슨-멜 쪽매와 아브라미 법칙(Johnson-Mehl), 쿠라모토-시바신스키 방정식(Kuramoto-Sivashinsky)이에요. 38판도 여섯을 더했어요. 앤더슨 국소화(Anderson Localization), 무어가 찾고 Chenciner 와 Montgomery 가 증명한 세 몸 문제의 8자 해(Figure-Eight Orbit), 예센과 빈트너, 에르되시의 베르누이 합성곱(Bernoulli Convolutions), 브라우어르의 고정점 정리를 증명하는 슈페르너 보조정리(Sperner's Lemma), 에겐베르거와 폴리아의 항아리(Pólya Urn), 뇌르고르의 무한 수열(Infinity Series)이에요. +주제 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)이에요. 30판도 여섯을 더했어요. 세 간격 정리(Three Gaps), 호프스태터 나비와 TKNN 정수(Hofstadter Butterfly), 노이만의 외부 당구(Outer Billiards), 볼드의 탄도 퇴적과 KPZ 앞면(Ballistic Deposition), 아르놀트의 미앤더(Meander), 브로드벤트와 해머슬리의 스미기(Percolation)예요. 31판도 여섯을 더했어요. 마스킷 조각의 클라인 군 극한 집합(Indra's Pearls), 리틀우드 다항식의 근(Littlewood Roots), 고든의 가우스 해자(Gaussian Moat), 아르놀트의 고양이 사상(Cat Map), 자부스키와 크루스칼의 KdV 솔리톤(KdV Solitons), 보이 곡면(Boy's Surface)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드, 모듈러 형식, 호프 올뭉치, 세 간격 정리, 외부 당구 여섯이었고, 31판이 리틀우드 다항식의 근과 클라인 군의 극한 집합을 써서 여덟을 다 썼어요. 32판도 여섯을 더했어요. 탤벗 효과와 양자 카펫(Talbot Carpet), 길브레스 추측의 표(Gilbreath Table), 원 사상의 아르놀트 혀(Arnold Tongues), 콘웨이와 라딘의 핀휠 타일링(Pinwheel Tiling), 윌킨슨과 윌렘센의 침입 스미기(Invasion Percolation), 울람의 문제와 해머슬리 과정(Hammersley Lines)이에요. 33판도 여섯을 더했어요. 에인절, 홀로이드, 로믹, 비라그의 무작위 정렬 네트워크(Sorting Network), 베르트랑의 역설(Bertrand Paradox), 펜로즈의 비출 수 없는 방(Penrose Room), 당들랭 구(Dandelin Spheres), 테이트의 보로메오 고리(Borromean Rings), 골드바흐 혜성(Goldbach Comet)이에요. 34판도 여섯을 더했어요. 톰슨 문제와 오일러가 남기는 다섯 이웃 열둘(Thomson Problem), 켈빈의 배 물결(Kelvin Wake), 요네야마가 낸 와다의 호수(Lakes of Wada), 샤이데거의 강 그물(Scheidegger Rivers), 모저의 소파 문제와 해머슬리의 소파(Moving Sofa), 크누스의 춤추는 링크로 푼 펜토미노 덮기(Pentomino Cover)예요. 35판도 여섯을 더했어요. 뢸로의 폭이 일정한 곡선(Constant Width), 렌니의 주차 문제(Rényi Parking), 카리의 비주기 왕 타일(Kari-Culik Tiles), 콜라코스키 수열(Kolakoski), 쿠라모토와 바톡토흐의 키메라 상태(Chimera State), 주콥스키 날개(Joukowsky Airfoil)예요. 36판도 여섯을 더했어요. 그레이슨의 정리가 말하는 곡선 단축 흐름(Curve Shortening), 헤우드의 일곱 색 토러스 지도(Heawood Torus), 슈바르츠 랜턴(Schwarz Lantern), 윌브러햄과 깁스의 현상(Gibbs Phenomenon), 타원 곡선의 현과 접선(Elliptic Chords), 지먼의 파국 기계(Catastrophe Machine)예요. 37판도 여섯을 더했어요. 포천의 쓸기 선 알고리즘(Fortune's Sweep), 앙투안의 목걸이(Antoine's Necklace), 카르만 소용돌이 길의 안정성(Kármán Street), 단단한 원판의 헥사틱 상(Hexatic Phase), 존슨-멜 쪽매와 아브라미 법칙(Johnson-Mehl), 쿠라모토-시바신스키 방정식(Kuramoto-Sivashinsky)이에요. 38판도 여섯을 더했어요. 앤더슨 국소화(Anderson Localization), 무어가 찾고 Chenciner 와 Montgomery 가 증명한 세 몸 문제의 8자 해(Figure-Eight Orbit), 예센과 빈트너, 에르되시의 베르누이 합성곱(Bernoulli Convolutions), 브라우어르의 고정점 정리를 증명하는 슈페르너 보조정리(Sperner's Lemma), 에겐베르거와 폴리아의 항아리(Pólya Urn), 뇌르고르의 무한 수열(Infinity Series)이에요. 39판도 여섯을 더했어요. 고든, 웹, 월퍼트의 등스펙트럼 북(Isospectral Drums), 폰 망골트의 꼴로 쓴 리만의 명시 공식(Explicit Formula), 요한 베르누이의 최단 강하선(Brachistochrone), 슈타이너의 포리즘(Steiner Chain), 모런 집단으로 그린 킹먼의 합류(Kingman Coalescent), 보르수크-울람 정리(Borsuk-Ulam)예요. -목록 밖에서 다음에 해 볼 것이에요. 영 도형의 플랑셰렐 성장은 상자가 하나씩 붙으며 로건-셰프와 베르시크-케로프의 극한 곡선(1977)으로 다가가요. 아즈텍 다이아몬드의 북극권과 같은 극한 모양이라 같은 판에 두지 않아요. 슈타이너 사슬은 반전 하나로 고리의 원들이 미끄러지는 포리즘이고, 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. 수론 갈래는 32판의 Gilbreath Table 까지 넷이에요. 길브레스 표는 소수 말고 다른 수열로도 세울 수 있어요. 오들리코(1993)에 따르면 2 다음에 간격이 작고 고르게 흩어진 홀수가 오는 수열에서는 1 의 열이 서고, 간격이 크면 깨져요. 간격의 크기를 노브로 걸면 1 의 열이 무너지는 자리가 보여요. 해머슬리 과정의 점 대신 격자 칸마다 지수 분포 무게를 두면 마지막 통과 스미기(로스트, 1981)가 되고, 극한 모양이 쌍곡선에서 (√x+√y)² 의 경계로 바뀌어요. +목록 밖에서 다음에 해 볼 것이에요. 영 도형의 플랑셰렐 성장은 상자가 하나씩 붙으며 로건-셰프와 베르시크-케로프의 극한 곡선(1977)으로 다가가요. 아즈텍 다이아몬드의 북극권과 같은 극한 모양이라 같은 판에 두지 않아요. 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. 수론 갈래는 32판의 Gilbreath Table 까지 넷이에요. 길브레스 표는 소수 말고 다른 수열로도 세울 수 있어요. 오들리코(1993)에 따르면 2 다음에 간격이 작고 고르게 흩어진 홀수가 오는 수열에서는 1 의 열이 서고, 간격이 크면 깨져요. 간격의 크기를 노브로 걸면 1 의 열이 무너지는 자리가 보여요. 해머슬리 과정의 점 대신 격자 칸마다 지수 분포 무게를 두면 마지막 통과 스미기(로스트, 1981)가 되고, 극한 모양이 쌍곡선에서 (√x+√y)² 의 경계로 바뀌어요. 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 씩만 쌓였어요. 빛을 테두리나 고리처럼 수학이 정한 자리에 모으면 읽혔어요. @@ -1994,6 +2057,8 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 38판에서 본 것이에요. 정리가 수를 못 박는 대상은 이번에도 셈으로 지켜봤어요. Sperner's Lemma 는 노브 60 자리의 7,740 프레임에서 무지개 방이 늘 홀수였고, Anderson Localization 의 노름은 1 에서 1.0e-4 안에, Figure-Eight Orbit 의 에너지는 200주기 동안 9.3e-9 안에 머물렀어요. 특이한 측도가 패널에서는 매끈해 보였어요. 황금비의 베르누이 합성곱은 차원이 0.9957 이라 64칸에서 이웃 줄과 같아 보였고, 처음 몇 항의 합이 정확히 겹치는 수를 세서야 피소 줄이 섰어요. 자기 닮음은 같은 크기로 그려야 읽혔어요. Infinity Series 는 네 번째 음마다 읽은 선율을 1행짜리 점으로 두었을 때 원래 선율과 견줄 수 없었고, 한 박 내내 깔아 같은 블록으로 세우자 맞아떨어졌어요. 극한의 분포는 줄을 세워야 보였어요. Pólya Urn 의 64열은 저마다 다른 비율로 굳는데, 그 비율이 고르게 퍼졌다는 것은 열들이 차례로 선 뒤에야 곧은 그러데이션으로 보여요. 증명의 걸음이 그림이 되기도 했어요. 슈페르너의 문 걷기는 방마다 다른 문으로 나가기만 해서, 걸음 하나가 고정점을 찾아가는 길로 읽혀요. 대칭을 지키는 흔들기는 너그러웠어요. 8자 해는 시작 배치의 대칭을 지키며 민 Kick 0.6 에서도 71주기를 버텼지만, 도는 중에 0.05 로 민 것은 90초 안에 깨졌어요. +39판에서 본 것이에요. 정리를 LED 격자 위에서 그대로 지킬 수 있는 대상이 있었어요. Isospectral Drums 는 반정사각형의 다리를 LED 축에 맞추자 5점 라플라시안의 고유값 1179개가 두 북에서 4.9e-14 안으로 같았어요. 연속인 북을 어림한 것이 아니라 이 패널의 북이 그대로 등스펙트럼이에요. 정리가 수를 못 박는 대상은 이번에도 셈으로 지켜봤어요. Borsuk-Ulam 은 12,000 프레임에서 대척점 쌍이 없거나 짝수인 적이 한 번도 없었고, Steiner Chain 은 4,200 사슬이 6.6e-11 LED 안에서 닫혔어요. 패널의 모양이 교과서의 배치를 바꾸기도 했어요. 세운 패널에서 최단 강하선의 끝점을 아래에 두면 사이클로이드가 원호보다 0.5% 빠를 뿐이라, 끝점을 아치의 바닥으로 올리고 높이는 동시선으로 채웠어요. 무작위 과정은 이번에도 study 의 씨앗이 여는 그림을 정했어요. Kingman Coalescent 는 마지막 합류가 평균의 0.8~1.3배가 될 때까지 더 돌린 뒤에 열어서, 어느 씨앗에서나 흔한 나무로 열려요. 다가가는 것을 보이려면 어긋남보다 맞아 들어간 것을 칠해야 했어요. Explicit Formula 는 1~2 LED 의 어긋남만 칠하면 테두리에 묻혔고, 계단 띠가 자리를 찾을 때 켜지자 읽혔어요. + 목록 밖에서 31판 뒤에 해 볼 것이에요. 모자 타일(스미스, 마이어스, 캐플런, 굿먼-스트라우스, 2023)은 한 가지 타일로만 평면을 비주기로 덮고, 뒤집어 놓인 타일이 판에 드문드문 흩어져요. 페르미-파스타-울람-칭구의 되돌아옴(1955)은 KdV Solitons 와 같은 뿌리라 같은 판에 두지 않아요. 목록 밖에서 33판 뒤에 해 볼 것이에요. 뷔퐁의 바늘(1777)은 Bertrand Paradox 와 같은 꼴로, 금을 넘는 바늘의 몫이 2/π 로 모이는 막대와 함께 세울 수 있어요. 토카르스키의 방(1995)은 거울로 된 다각형 방에서 한 점의 빛이 다른 한 점에만 닿지 못하는 방이라 Penrose Room 의 광선 추적을 그대로 써요. 프리드먼과 스코라(1987)는 둥근 원 셋으로는 보로메오 고리를 만들 수 없다는 것을 보였는데, 원을 밀어 넣을 때 어디서 부딪히는지가 그림이 돼요. 정렬 네트워크의 시간 t 의 순열을 점으로 찍으면 대각선에서 원판을 지나 반대 대각선으로 도는 점 구름이 돼요. 그 원판이 2007년 논문이 내다본 아르키메데스 법칙이에요. 간격이 2k 인 소수 쌍의 수는 Goldbach Comet 과 같은 FFT 로 세고, k 마다 하디-리틀우드 인수로 갈리는 줄기 그림이 돼요. @@ -2008,9 +2073,11 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 목록 밖에서 38판 뒤에 해 볼 것이에요. 오브리-앙드레 모형(세르주 오브리, 길 앙드레, 1980)은 Anderson Localization 의 무작위 에너지를 무리수 주기의 코사인으로 바꾼 사슬이라, 세기 2 에서 모든 상태가 한꺼번에 퍼짐에서 갇힘으로 넘어가요. 같은 시공간 그림에 그 문턱을 노브로 걸 수 있어요. 수바코프와 드미트라시노비치(2013)가 찾은 세 몸의 주기 궤도 열세 갈래는 나비와 나방, 요구르트 같은 이름이 붙은 꼴이라 Figure-Eight Orbit 의 적분을 그대로 써요. 게일(1979)이 보인 헥스 정리는 칸이 다 찬 헥스 판에는 꼭 한쪽이 이기는 길이 있다는 것으로, 슈페르너 보조정리처럼 브라우어르의 고정점 정리와 같은 힘이에요. 마름모 판을 세워 두 색이 차오르며 이긴 길이 켜지는 그림이 돼요. 쉬츠와 트림퍼의 코끼리 걸음(2004)은 지난 걸음 가운데 하나를 골라 따르는 기억 걸음이라, 따르는 확률 3/4 에서 퍼짐이 바뀌어요. Pólya Urn 처럼 64열을 나란히 둘 수 있어요. +목록 밖에서 39판 뒤에 해 볼 것이에요. 퐁슬레의 포리즘(장빅토르 퐁슬레, 1822)은 두 원뿔 곡선 사이에 끼운 다각형이 한 번 닫히면 어디서 시작해도 닫혀서, Steiner Chain 의 「한 번 닫히면 늘 닫힌다」를 원 대신 선으로 그려요. 같은 판에 두지 않아요. 체비쇼프의 편향(파프누티 체비쇼프, 1853)은 4로 나눈 나머지가 3 인 소수가 1 인 소수보다 거의 늘 앞서는 경주이고, 루빈스타인과 사르낙(1994)이 앞서는 몫을 0.9959 로 셌어요. Explicit Formula 의 영점 합에 L 함수의 영점을 넣으면 같은 꼴로 그 경주를 세울 수 있어요. 햄 샌드위치 정리(스톤과 튜키, 1942)는 보르수크-울람에서 바로 나와서, 평면의 두 무리를 한 직선이 늘 반씩 가르는 그림이 Borsuk-Ulam 다음의 위상 그림이 될 만해요. 평면 브라운 경로의 바깥 경계는 차원이 4/3 이라는 만델브로트의 추측(1982)을 롤러, 슈람, 베르너(2001)가 증명했어요. 지나간 자리가 한 번만 켜지는 경로 위에 바깥 경계만 따로 빛나게 할 수 있어요. + ### 화장품 팝업 -주제 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 가 생겨요. +주제 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 가 생겨요. 비누막이 터질 때 구멍 가장자리가 테일러와 컬릭의 속도(1959, 1960)로 고르게 벌어지며 물방울 테를 남기는 것은 Soap Film 의 끝을 늘인 그림이에요. 클렌징 오일이 물을 만나 가장자리부터 우윳빛으로 유화되는 것은 Blotting Paper 처럼 LED 마다 한 번만 바뀌는 셈으로 그릴 만해요. 30판이 넷을 더 썼어요. 손가락으로 끈 스와치의 색조표(Swatch Row), 펄 안료의 박막 간섭과 듀오크롬(Pearl Sheen), 이중층 클렌저의 상 분리(Bi-Phase), 흰 새틴과 금색 실 하나(Satin Fold)예요. 29판의 넷과 겹치지 않게 골랐어요. 바탕을 밝게 두면 채움이 1.000 이고 `thirds` 가 100/100/100 이라, 이 넷은 지도에서 채움 축의 위 끝에 모여요. 아이보리 바탕 198/186/164 에서는 밝은 색조가 바탕보다 밝아야 색으로 읽혔고, 180/168/152 는 회갈색이었어요. 연한 색 위의 Hue 노브는 채도가 낮아 반응이 작을 것 같았는데, Satin Fold 는 주름 골의 채도 0.46 이 그 몫을 해서 K1 이 0.11 이에요. 주황과 노랑 쪽 색조는 이번에도 중간 밝기에서 갈색과 올리브로 갔어요. Pearl Sheen 은 색이 노랑에 가까운 만큼 밝기를 올리고 채도를 내려서 샴페인을 밝은 베이지로 남겼어요. @@ -2030,7 +2097,9 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 38판이 넷을 더 썼어요. 젤에 떨어져 고리로 말리고 작은 고리로 갈라지며 잠기는 크림 방울(Milk Ring), 두 원통 사이 테일러 소용돌이에 든 쉬머 세럼(Couette Shimmer), UV 손전등이 지나간 자리만 짙어지는 포토크로믹 네일(Photochromic), 진한 크림에 밀려 들어가 손가락으로 갈라지는 세럼(Serum Fingers)이에요. 넷 다 「다음에 해 볼 질감」에 적혀 있던 감이에요. 이번에도 물리를 따르자 사물로 읽혔어요. Milk Ring 은 톰슨과 뉴얼(1885)이 본 고리의 갈라짐을 대마다 따르고, Serum Fingers 는 압력장을 풀어 압력 기울기가 가장 큰 앞부터 밀어요. 가장 밝은 채널만 읽는 잣대는 이번에는 칸을 겨누는 손잡이가 됐어요. Photochromic 은 채도만 오르면 모양 바뀜이 그대로라 짙은 상태의 바탕을 0.11 까지 내렸고, Serum Fingers 는 크림을 밝기 71 의 회색으로 두어 세럼과 0.7 쯤 벌렸어요. 주황과 노랑 쪽의 갈색과 올리브는 넷 다에서 다시 왔어요. Milk Ring 은 그 범위에서 유리 아래쪽을 2.1배까지 밝혔고, Couette Shimmer 는 어두운 띠도 밝기 0.85 를 지키고 채도로만 갈랐고, Photochromic 은 짙어지는 대신 크림색에서 밝은 원색으로 바뀌어요. Milk Ring 은 고리 심을 반지름의 4분의 1 로 두꺼이 그렸을 때 하얀 알약으로 보였고, 8분의 1 로 줄이고 가운데로 세럼이 비치게 하자 고리로 읽혔어요. -다음에 해 볼 질감이에요. 세팅 미스트가 유리판에 맺혀 굵어지고 흘러내리는 것은 Lip Gloss 와 Setting Mist 사이라 같은 판에 두지 않아요. 매니큐어 붓이 남긴 줄이 스스로 펴지는 것은 Cream Swirl 과 가까워서 같은 판에 두지 않아요. 젖은 립글로스나 탑코트가 굳는 것은 Clay Mask 처럼 광이 먼저, 색이 나중에 빠지는 두 문턱으로 그릴 만해요. 립밤이 체온에 녹아 번지거나 고체 향수가 문질러 펴지는 것은 Cleansing Balm 의 녹는 경계를 가로로 돌린 그림이에요. 크러시드 벨벳처럼 결이 조각마다 다른 방향으로 눌린 천을 보이지 않는 손이 쓰다듬어 조각이 하나씩 같은 방향으로 눕는 것은 Velvet Nap 의 결 벡터를 그대로 써요. 물 위에 매니큐어 고리를 떨어뜨려 겹치고 바늘로 끄는 워터 마블 네일은 Bath Fizz 의 유체를 그대로 써요. 기름종이가 피지를 빨아들인 자리만 반투명해지는 것은 Toner Pad 의 길이장으로 그릴 만해요. 하이드로겔 마스크처럼 두껍고 맑은 젤 시트가 붙는 것은 Sheet Mask 의 젖는 선을 그대로 써요. 움직이는 판 위로 떨어지는 점성 줄기는 판이 빨라질수록 똬리가 구불거리는 선으로, 다시 8자 매듭으로 바뀌어요. 치우-웹스터와 리스터의 유체 재봉틀(2006)로 컨베이어 위에 깔리는 로션 띠를 그릴 만해요. 위에서 본 비누 거품은 변이 n 개인 방이 (n − 6) 에 비례해 커지거나 줄어드는 폰 노이만의 법칙(1952)으로 굵어져서, Foam Lather 와 다른 질감이 돼요. 크림 하이라이터를 손끝 열로 녹여 펴는 것은 Embossed Glow 의 엠보싱을 Cleansing Balm 의 녹는 경계로 무너뜨리는 그림이에요. 반투명한 젤리 네일을 여러 번 겹쳐 바르면 겹친 곳만 짙어져서, Taffy Cream 의 크림 대신 붓 자국이 남는 얇은 층을 쌓을 만해요. 오일 방울이 머리결 대신 속눈썹처럼 짧고 굽은 털 다발을 타고 끝에 맺히는 것은 Hair Oil 의 방울 셈을 그대로 써요. 세럼이 스포이트에서 실로 늘어지다 구슬로 끊기는 레일리-플라토 불안정(조제프 플라토, 1873; 레일리 경, 1878)은 위에서 아래로 늘어지는 실과 구슬 줄이라 세운 패널에 맞아요. 토너나 향수 잔 벽을 타고 오르내리는 와인의 눈물(제임스 톰슨, 1855; 카를로 마랑고니, 1865)도 기어오르는 막과 흘러내리는 줄기로 128줄을 써요. 두 유리판 사이의 크림을 떼어 낼 때 공기가 가운데로 파고들며 나뭇가지처럼 갈라지는 것은 Serum Fingers 의 성장 법칙을 바깥에서 안으로 돌려 그릴 만해요. +39판이 넷을 더 썼어요. 세로로 선 비누막의 간섭색(Soap Film), 피지를 빨아들여 비치는 기름종이(Blotting Paper), 스포이트에서 늘어지다 구슬로 끊기는 세럼(Serum Drip), 마르며 갈라지는 크래클 매니큐어(Crackle Polish)예요. 둘째와 셋째는 「다음에 해 볼 질감」에 적혀 있던 감이에요. 넷 다 물리를 셈으로 풀었어요. Soap Film 은 두께에서 간섭색을 파장마다 더해 뉴턴의 색 차례를 냈고, Blotting Paper 는 루커스-워시번의 √t 로 번지고, Serum Drip 은 레일리의 성장률을 1~2% 안에서 따라요. 가장 밝은 채널만 읽는 잣대가 이번에는 그림을 한 번 막고 한 번 도왔어요. Serum Drip 은 크림색 벽 앞의 금색 세럼이 빨강 채널에서 벽과 같아 모양 바뀜이 0.013 이었고, 벽을 반쯤 어둡게 해서 0.048 이 됐어요. Blotting Paper 의 젖은 테는 채도만 바꿔서 잣대에 걸리지 않았고, 테를 그려도 LED 마다 한 번만 밝아지는 셈이 깨지지 않았어요. 주황과 노랑 쪽의 갈색과 올리브는 넷 다에서 다시 왔어요. Blotting Paper 는 그 범위의 마른 종이를 1.5배까지 밝히고 회색 쪽으로 옅게 했고, Serum Drip 은 벽을 회색으로 빼고 웅덩이를 세럼 빛으로 밝혔어요. Crackle Polish 의 갈색은 어두운 그늘이 아니라 1 LED 틈 가장자리에서 반쯤 섞인 LED 에서 왔어요. 복잡도를 겨눌 때는 채도가 거꾸로 갔어요. Soap Film 은 채도를 꽉 채우자 채널이 0 과 255 에 붙어 복잡도가 1.461 로 떨어졌고, 채도를 내리자 2.342 가 됐어요. + +다음에 해 볼 질감이에요. 세팅 미스트가 유리판에 맺혀 굵어지고 흘러내리는 것은 Lip Gloss 와 Setting Mist 사이라 같은 판에 두지 않아요. 매니큐어 붓이 남긴 줄이 스스로 펴지는 것은 Cream Swirl 과 가까워서 같은 판에 두지 않아요. 젖은 립글로스나 탑코트가 굳는 것은 Clay Mask 처럼 광이 먼저, 색이 나중에 빠지는 두 문턱으로 그릴 만해요. 립밤이 체온에 녹아 번지거나 고체 향수가 문질러 펴지는 것은 Cleansing Balm 의 녹는 경계를 가로로 돌린 그림이에요. 크러시드 벨벳처럼 결이 조각마다 다른 방향으로 눌린 천을 보이지 않는 손이 쓰다듬어 조각이 하나씩 같은 방향으로 눕는 것은 Velvet Nap 의 결 벡터를 그대로 써요. 물 위에 매니큐어 고리를 떨어뜨려 겹치고 바늘로 끄는 워터 마블 네일은 Bath Fizz 의 유체를 그대로 써요. 하이드로겔 마스크처럼 두껍고 맑은 젤 시트가 붙는 것은 Sheet Mask 의 젖는 선을 그대로 써요. 움직이는 판 위로 떨어지는 점성 줄기는 판이 빨라질수록 똬리가 구불거리는 선으로, 다시 8자 매듭으로 바뀌어요. 치우-웹스터와 리스터의 유체 재봉틀(2006)로 컨베이어 위에 깔리는 로션 띠를 그릴 만해요. 위에서 본 비누 거품은 변이 n 개인 방이 (n − 6) 에 비례해 커지거나 줄어드는 폰 노이만의 법칙(1952)으로 굵어져서, Foam Lather 와 다른 질감이 돼요. 크림 하이라이터를 손끝 열로 녹여 펴는 것은 Embossed Glow 의 엠보싱을 Cleansing Balm 의 녹는 경계로 무너뜨리는 그림이에요. 반투명한 젤리 네일을 여러 번 겹쳐 바르면 겹친 곳만 짙어져서, Taffy Cream 의 크림 대신 붓 자국이 남는 얇은 층을 쌓을 만해요. 오일 방울이 머리결 대신 속눈썹처럼 짧고 굽은 털 다발을 타고 끝에 맺히는 것은 Hair Oil 의 방울 셈을 그대로 써요. 토너나 향수 잔 벽을 타고 오르내리는 와인의 눈물(제임스 톰슨, 1855; 카를로 마랑고니, 1865)도 기어오르는 막과 흘러내리는 줄기로 128줄을 써요. 두 유리판 사이의 크림을 떼어 낼 때 공기가 가운데로 파고들며 나뭇가지처럼 갈라지는 것은 Serum Fingers 의 성장 법칙을 바깥에서 안으로 돌려 그릴 만해요. ## 다시 돌리기 From 42cadfd7dce232b037621b36b9441f52c1819e08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:48:59 +0000 Subject: [PATCH 08/10] =?UTF-8?q?incoming:=20Crackle=20Polish=20=EC=99=80?= =?UTF-8?q?=20Steiner=20Chain=20=EC=9D=98=20=EC=A3=BC=EC=84=9D=20=EB=91=90?= =?UTF-8?q?=20=EC=A4=84=EC=9D=84=20=EC=85=8B=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=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_01WSruBmW95rasfF2WmM8DZ7 --- incoming/pattern-study/patterns/crackle-polish.js | 6 +++--- incoming/pattern-study/patterns/steiner-chain.js | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/incoming/pattern-study/patterns/crackle-polish.js b/incoming/pattern-study/patterns/crackle-polish.js index 9d989f047..8ff92b45a 100644 --- a/incoming/pattern-study/patterns/crackle-polish.js +++ b/incoming/pattern-study/patterns/crackle-polish.js @@ -51,9 +51,9 @@ const REACH = 7; // The whole crackle of one coat, worked out when it goes on. The panel is // one domain. A domain splits across its long axis: a crack starts on its -// rim, square to the older crack it leaves, runs across, and turns to meet -// the crack on the far side square. Each half splits the same way until it -// is too short; bigger domains crack sooner. +// rim, square to the older crack it leaves, and runs across until it turns +// to meet the crack on the far side square. Each half splits the same way +// until it is too short; bigger domains crack sooner. function crackle(craze) { const stop = 20 - 9 * craze, span = log((W * H) / (0.45 * stop * stop)); const lab = new Int32Array(FW * FH), cracks = []; diff --git a/incoming/pattern-study/patterns/steiner-chain.js b/incoming/pattern-study/patterns/steiner-chain.js index 363302177..e2101af20 100644 --- a/incoming/pattern-study/patterns/steiner-chain.js +++ b/incoming/pattern-study/patterns/steiner-chain.js @@ -46,7 +46,8 @@ export function setup(params) { params.s = { n: 6, m: 1, q: 3.4, phi: 0, lay: 0, flash: [], begun: false, // Per square: circles as x, y, r (the outer, the inner, then the chain in - // the order it is laid); the touches as x, y, and which two circles meet. + // the order it is laid); each touch as its point x, y and the pair of + // circles that meet there. C: [new Float64Array(3 * 14), new Float64Array(3 * 14)], T: [new Float64Array(2 * 40), new Float64Array(2 * 40)], nt: 0, tri: [], A: new Float32Array(3 * W * H), col: [0, 0, 0], fil: [0, 0, 0], From ba49c0ee4b1009dcfd65cefc29e451e39c0646ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:04:57 +0000 Subject: [PATCH 09/10] =?UTF-8?q?incoming:=2039=ED=8C=90=20=EC=97=B4=20?= =?UTF-8?q?=EA=B0=9C=EA=B0=80=20=EB=93=A0=20index.html=20=EA=B3=BC=20READM?= =?UTF-8?q?E=20=EC=A7=80=EB=8F=84=EC=9D=98=20=EB=B9=88=20=EC=B9=B8?= 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_01WSruBmW95rasfF2WmM8DZ7 --- incoming/pattern-study/README.md | 2 ++ incoming/pattern-study/index.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index 0df7b2757..4541543f7 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1967,6 +1967,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 38판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로이고, 278개에서도 지도 21장이 다 `none` 이에요. 주제 1 은 패턴이 하나뿐인 칸을 겨눠서, 36판까지의 268개에서 16 이던 그런 칸이 14 가 됐어요. 움직임 × 모양 바뀜 1,3 은 1초에 바뀌는 빛이 0.061 아래인데 2~8초에 블록 평균이 0.217 넘게 바뀌어야 하는 칸이에요. 블록 평균이 한쪽으로만 바뀌면 모양 바뀜은 6초 동안 바뀐 양이라 움직임의 여섯 배가 끝이고, 그 끝에 닿으려면 LED 마다 한 번만, 깜박임 없이 바뀌어야 해요. Serum Fingers 는 세럼이 닿는 LED 를 한 번만 밝혀서 2~8초에 오른 합이 0.251, 내린 합이 0.0000 이었고, 움직임 0.042 가 모양 바뀜의 6분의 1 이에요. 그러면 칸은 모양이 아니라 1초에 밀어 넣는 넓이와 밝기 차이로 골라져요. 촘촘함 × 모양 바뀜 3,4 는 한 LED 결이 판의 반 넘게 한꺼번에 어두워져야 하는 칸이에요. Photochromic 은 LED 마다 펄 조각이나 맨 폴리시를 두어 촘촘함 0.210 을 내고, 짙어진 바탕을 0.11 까지 내려 모양 바뀜 0.365 를 냈어요. 가장 밝은 채널로 재서, 채도만 오르는 짙어짐은 모양 바뀜에 잡히지 않았어요. 남은 14칸 가운데 넷은 여전히 모양 바뀜이 높은 촘촘함의 칸이에요. 촘촘함 × 모양 바뀜 2,4, 3,3, 4,3, 4,4 의 Marbling, Flipdot, Sequins, Pin Art 예요. 움직임 × 모양 바뀜 1,4 의 Lunation 곁에 서려면 모양 바뀜이 0.331 을 넘어야 해서, 한 번씩만 바뀌는 그림이라도 움직임 0.055 에서 0.061 사이의 좁은 띠를 써야 해요. +39판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로이고, 298개에서도 지도 21장이 다 `none` 이에요. 주제 1 은 패턴이 하나뿐인 칸을 겨눠서, 37판까지의 288개에서 12 이던 그런 칸이 10 이 됐어요. 움직임 × 모양 바뀜 1,4 는 모양 바뀜이 0.331 을 넘으면서 움직임은 0.061 아래여야 해서, 둘의 비가 5.46 을 넘는 칸이에요. 2~8초의 화면은 프레임 180장이 5.97초에 걸쳐 있어서, LED 마다 가장 밝은 채널이 한 방향으로만 바뀌면 비가 꼭 5.97 이에요. Blotting Paper 는 반투명해지는 자리가 밝아지기만 하고 젖은 테는 채도만 바꿔서 비가 6.00 이고, 움직임 0.057, 모양 바뀜 0.343 으로 Lunation 옆에 섰어요. 복잡도 × 모양 바뀜 4,4 는 화면이 deflate 로 잘 눌리지 않으면서 블록 평균이 0.331 넘게 바뀌어야 하는 칸이에요. Soap Film 은 검은 막이 위에서 내려오며 밝은 간섭색을 지워 모양 바뀜이 0.378 이 됐고, 채도를 내리고 검은 막에도 옅은 결을 남겨 복잡도 2.342 로 Bread Mould 옆에 섰어요. 남은 열 칸은 다 촘촘함 축이 든 지도에 있어요. 넷이 촘촘함 × 모양 바뀜이고, 셋이 가장자리 0.210 아래의 가장자리 × 촘촘함이에요. + 23판부터 28판까지는 `*` 가 붙은 칸이 없었어요. 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 를 냈어요. diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 5a75e8563..2accd9f00 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,7 +141,7 @@

패턴 연구

- + +