From f1957f1eb8bdbe6a445dc44500203ab7bac12cc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:11:30 +0000 Subject: [PATCH 01/15] =?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=2028=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_01HXbdUBpnpkK9obqLQFXaCS From e83c968014863f0c43cb1240fbb88c9916440adf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:20:46 +0000 Subject: [PATCH 02/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20=EC=88=98=EB=A1=A0=EA=B3=BC=20=EC=9C=84?= =?UTF-8?q?=EC=83=81=20=EA=B0=88=EB=9E=98=EB=A5=BC=20=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HXbdUBpnpkK9obqLQFXaCS --- incoming/pattern-study/families.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/incoming/pattern-study/families.json b/incoming/pattern-study/families.json index 2937fc977..d1b1757d3 100644 --- a/incoming/pattern-study/families.json +++ b/incoming/pattern-study/families.json @@ -48,5 +48,7 @@ "farm": { "label": "농사·밭", "color": "#449911" }, "volcano": { "label": "화산", "color": "#bb2200" }, "polar": { "label": "얼음·극지", "color": "#00ffff" }, - "microbe": { "label": "미생물", "color": "#aa00aa" } + "microbe": { "label": "미생물", "color": "#aa00aa" }, + "number": { "label": "수론", "color": "#00aaff" }, + "topology": { "label": "위상", "color": "#44dd44" } } From 5b87e9b73e89d20f53eefe38c393137d14a8b106 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:42:42 +0000 Subject: [PATCH 03/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Hyperbolic=20Band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/hyperbolic-band.js | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 incoming/pattern-study/patterns/hyperbolic-band.js diff --git a/incoming/pattern-study/patterns/hyperbolic-band.js b/incoming/pattern-study/patterns/hyperbolic-band.js new file mode 100644 index 000000000..8915b17a9 --- /dev/null +++ b/incoming/pattern-study/patterns/hyperbolic-band.js @@ -0,0 +1,263 @@ +// ===== Patternflow pattern ===== +// Title: Hyperbolic Band +// Family: space +// Idea: The hyperbolic plane tiled by heptagons, three at every corner, drawn in the band model, where the panel's 64 columns hold the whole width of the plane: the tiling slides up the band for ever and its tiles shrink to nothing at both side edges. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the band model of the hyperbolic plane, with the Poincaré disk (Eugenio Beltrami 1868, Henri Poincaré 1882) and Coxeter's regular {p,q} tilings, pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Tiling=0..4, Lines=0..1 +// +// Knob 1: Hue. Turns the glass round the colour wheel. At 0 the tiles on the middle line are cyan and blue, and they go violet and then magenta out towards the edges. +// Knob 2: Speed. How fast the plane slides along the band. At 2 the tiles on the middle line climb 12 LEDs a second; at 0.2 a tenth of that. +// Knob 3: Tiling. Which regular tiling {p,q}, p-gons meeting q at a corner: heptagons three at a corner {7,3}, then octagons {8,3}, pentagons four at a corner {5,4}, squares five at a corner {4,5} and triangles seven at a corner {3,7}. +// Knob 4: Lines. At 0 dark leading between bright glass. Turned up, the leading goes pewter and then white, and the glass dims to a fifth. +// Buttons: K1 tap turns the plane half round about the edge middle nearest the panel's centre in three seconds, with the mirrors through the tile centres lit. A half turn about an edge middle is a symmetry of the tiling, so it lands back on itself. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, sin, cos, atan2, log, asinh, acosh, abs, tanh, 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); +}; + +// The band |Im w| < 1 goes onto the Poincaré disk by z = tanh(pi w / 4). Here +// Im w runs across the panel, one unit to 32 LEDs, and Re w up it, so the +// middle line of the band is the disk's real diameter, a geodesic. Along it a +// unit of w is pi / 2 of hyperbolic length; at a distance v off it, a LED is +// 1 / cos(pi v / 2) times longer. +const ZX = new Float64Array(N), ZY = new Float64Array(N), RHO = new Float64Array(W); +for (let x = 0; x < W; x++) { + const v = (x + 0.5 - W / 2) / 32; + RHO[x] = PI / 64 / cos((PI * v) / 2); + for (let y = 0; y < H; y++) { + const u = (H / 2 - y - 0.5) / 32, a = (PI * u) / 2, b = (PI * v) / 2, d = Math.cosh(a) + cos(b); + ZX[y * W + x] = Math.sinh(a) / d; + ZY[y * W + x] = sin(b) / d; + } +} + +// The tilings. For each, the fundamental triangle of its reflection group: +// the corner at the origin (a tile's centre) with angle pi/p between the real +// axis and the ray at pi/p, and the circle mirror (the tile's edge) crossing +// the real axis square at distance a. a is the tile's inradius, R its +// circumradius, e half an edge. T is the shortest translation along the real +// axis that is a symmetry: twice the gap between the mirrors that cross the +// axis square, which depends on what the axis meets on its way. +const TIL = [[7, 3], [8, 3], [5, 4], [4, 5], [3, 7]].map(([p, q]) => { + const a = acosh(cos(PI / q) / sin(PI / p)), R = acosh(1 / (Math.tan(PI / p) * Math.tan(PI / q))), e = acosh(cos(PI / p) / sin(PI / q)); + const m = tanh(a / 2), C = (m + 1 / m) / 2, r = (1 / m - m) / 2; + const T = p % 2 === 0 ? 2 * a : q % 2 === 0 ? 4 * (a + R) : 2 * (a + R + e); + const rc = new Float64Array(p), rs = new Float64Array(p); + for (let k = 0; k < p; k++) (rc[k] = cos((2 * PI * k) / p)), (rs[k] = sin((2 * PI * k) / p)); + // The wedge number, p added, taken round to 0..p-1. + const wr = new Int8Array(3 * p).map((_, i) => i % p); + return { p, q, a, R, C, r, r2: r * r, Tb: (2 * T) / PI, m0: (2 * a) / PI, rc, rs, wr, sf: sin(PI / p), cf: cos(PI / p) }; +}); + +const MAXIT = 40; +const CODE = new Int8Array(MAXIT); +let lutHue = NaN; +const LUT = new Float32Array(1024 * 3); +function hsv(h, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h); + const rgb = [[1, f, 0], [1 - f, 1, 0], [0, 1, f], [0, 1 - f, 1], [f, 0, 1], [1, 0, 1 - f]][i]; + for (let c = 0; c < 3; c++) out[o + c] = rgb[c]; +} + +export function setup(params) { + params.s = { s: 0, hue: 0, til: 0, lines: 0.06, speed: 2, spin: -1, m: 0, buf: new Float32Array(N * 3) }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.speed = cl(k[1], 0.2, 3); + P.til = cl(Math.round(k[2]), 0, TIL.length - 1); + P.lines = cl(k[3], 0, 1); + const G = TIL[P.til]; + // Tiles climb: the point of the tiling under the panel's centre moves down + // the band. 12 LEDs a second at Speed 2. + const slow = P.spin >= 0 ? 1 - 0.8 * sin(PI * P.spin) : 1; + P.s -= d * P.speed * 0.1875 * slow; + P.s -= G.Tb * floor(P.s / G.Tb + 0.5); + if (P.spin >= 0) { + P.spin += d / 3; + if (P.spin >= 1) P.spin = -1; + } else if (input.btnPressed && input.btnPressed[0]) { + // The nearest point where a mirror crosses the axis square: the middle of + // an edge, or for even p a tile's centre too. + const h = G.Tb / 2; + P.m = G.m0 + h * Math.round((P.s - G.m0) / h); + P.spin = 0; + } +} + +// Complex 2x2 Möbius matrices as [ar, ai, br, bi, cr, ci, dr, di]. +function mul(A, B) { + const o = []; + for (let i = 0; i < 2; i++) + for (let j = 0; j < 2; j++) { + const p = A[i * 4], pi = A[i * 4 + 1], q = A[i * 4 + 2], qi = A[i * 4 + 3]; + const u = B[j * 2], ui = B[j * 2 + 1], v = B[4 + j * 2], vi = B[5 + j * 2]; + o.push(p * u - pi * ui + q * v - qi * vi, p * ui + pi * u + q * vi + qi * v); + } + return o; +} +const trans = (wb) => { + const t = tanh((PI * wb) / 4); + return [1, 0, t, 0, t, 0, 1, 0]; +}; +const rot = (th) => [cos(th / 2), sin(th / 2), 0, 0, 0, 0, cos(th / 2), -sin(th / 2)]; + +// The colour of a point of the tiling from where it sits on the band: cyan +// glass on the middle line going through blue and violet to magenta at the +// edges, and swinging a little along the band with the period T. Both are +// kept by the half turn of K1 about any point where a mirror crosses the axis +// square, and by the slide of one period. +function hueAt(G, x, y) { + const u = (1 / PI) * log(((1 + x) ** 2 + y * y) / ((1 - x) ** 2 + y * y)), v = (2 / PI) * atan2(2 * y, 1 - x * x - y * y); + const h = 0.5 + 0.06 * cos((2 * PI * (u - G.m0)) / G.Tb) + 0.3 * sqrt(abs(v)); + return (((h - floor(h)) * 1024) | 0) * 3; +} + +// atan2 to a hundred-thousandth of a radian, which is all the fold needs to +// pick its wedge: a point that lands a hair across a straight mirror is on +// that mirror either way. +function fat(y, x) { + const ax = x < 0 ? -x : x, ay = y < 0 ? -y : y, a = (ax < ay ? ax : ay) / ((ax < ay ? ay : ax) + 1e-300), q = a * a; + let r = ((-0.0464964749 * q + 0.15931422) * q - 0.327622764) * q * a + a; + if (ay > ax) r = PI / 2 - r; + if (x < 0) r = PI - r; + return y < 0 ? -r : r; +} + +const PREV = new Int8Array(MAXIT); +export function draw(display, params) { + const P = params.s; + if (!P) return; + const G = TIL[P.til], { p, C, r, r2, rc, rs, sf, cf, wr, a: ina, R: circ } = G, pp = p / PI; + const buf = P.buf, L = P.lines; + // From the panel's disk to the tiling: slide s along the axis, and during + // the K1 half turn, turn by th about the crossing m on the way. + let M = trans(P.s), mir = 0; + if (P.spin >= 0) { + const u = P.spin, th = PI * u * u * (3 - 2 * u); + M = mul(trans(P.m), mul(rot(th), trans(P.s - P.m))); + mir = sin(PI * u); + } + const [ar, ai, br, bi, cr, ci, dr, di] = M; + if (lutHue !== P.hue) for (let i = 0; i < 1024; i++) hsv(i / 1024 + (lutHue = P.hue), LUT, i * 3); + const glassV = 1 - 0.78 * L, haze = 0.62 * glassV + 0.12 * L; + const lineR = 255 * L, lineG = 245 * L, lineB = 225 * L; + // The leading and the mirrors are lines of fixed hyperbolic width, box + // filtered over the LED, so they thin out with the tiles. + const hw = 0.035, mw = 0.025; + // The haze comes in as a tile's inradius drops from about three LEDs to one. + for (let x = 0; x < W; x++) { + const rho = RHO[x], fog = sm(0.3 * ina, 1.1 * ina, rho); + let pn = -1, po = 0; + for (let y = 0; y < H; y++) { + const i = y * W + x, zx = ZX[i], zy = ZY[i]; + const nx = ar * zx - ai * zy + br, ny = ar * zy + ai * zx + bi; + const dx = cr * zx - ci * zy + dr, dy = cr * zy + ci * zx + di; + const qd = 1 / (dx * dx + dy * dy); + let X = (nx * dx + ny * dy) * qd, Y = (ny * dx - nx * dy) * qd; + let R = 0, Gc = 0, B = 0; + // Where the tiles are smaller than a LED, the glass's average colour. + if (fog > 0) { + const o = hueAt(G, X, Y); + R = LUT[o] * 255 * haze; + Gc = LUT[o + 1] * 255 * haze; + B = LUT[o + 2] * 255 * haze; + } + if (fog < 1) { + // Fold into the fundamental triangle: turn into the wedge of angle + // pi/p, then reflect in the edge circle, until the edge is not crossed. + // Each turn and whether it flipped is kept, to find the tile's centre. + let n = 0, par = 0, ok = 1; + for (;;) { + const m = floor(fat(Y, X) * pp), f = m & 1, jj = wr[((m + 1) >> 1) + p]; + const c = rc[jj], s = rs[jj], X1 = X * c + Y * s; + Y = Y * c - X * s; + X = X1; + if (f) Y = -Y; + par ^= f; + const ex = X - C, e2 = ex * ex + Y * Y; + if (e2 >= r2) break; + if (n >= MAXIT) { + ok = 0; + break; + } + CODE[n++] = jj * 2 + f; + const g = r2 / e2; + X = C + ex * g; + Y *= g; + par ^= 1; + } + if (!ok) { + const o = hueAt(G, X, Y); + (R = LUT[o] * 255 * haze), (Gc = LUT[o + 1] * 255 * haze), (B = LUT[o + 2] * 255 * haze); + } else { + // The same steps as the LED above: the same tile. + let same = n === pn; + for (let k = 0; same && k < n; k++) same = CODE[k] === PREV[k]; + if (!same) { + // The tile's centre is the origin taken back through the steps. + let cx = 0, cy = 0; + if (n > 0) { + cx = 1 / C; + for (let k = n - 1; k >= 0; k--) { + const jj = CODE[k] >> 1; + if (CODE[k] & 1) cy = -cy; + const c = rc[jj], s = rs[jj], x1 = cx * c - cy * s; + cy = cx * s + cy * c; + cx = x1; + if (k > 0) { + const ex = cx - C, e2 = ex * ex + cy * cy, g = r2 / e2; + cx = C + ex * g; + cy *= g; + } + } + } + po = hueAt(G, cx, cy); + pn = n; + for (let k = 0; k < n; k++) PREV[k] = CODE[k]; + } + // Distances in the triangle: to the tile's edge, its centre, and the + // two straight mirrors that cut the tile into 2p facets. + const z2 = X * X + Y * Y, den = 1 - z2, zr = sqrt(z2); + const de = asinh((1 + z2 - 2 * C * X) / (r * den)), dc = log((1 + zr) / (1 - zr)); + const dm = min(2 * Y, 2 * (X * sf - Y * cf)) / den; + const facet = 1 + (par ? -0.09 : 0.09) * min(1, dm / rho); + const gl = (1 - 0.42 * (dc / circ) ** 2) * facet * glassV * 255; + let r0 = LUT[po] * gl, g0 = LUT[po + 1] * gl, b0 = LUT[po + 2] * gl; + const cov = cl((min(de + rho / 2, hw) - max(de - rho / 2, -hw)) / rho, 0, 1); + r0 += (lineR - r0) * cov; + g0 += (lineG - g0) * cov; + b0 += (lineB - b0) * cov; + if (mir > 0) { + const mc = cl((min(dm + rho / 2, mw) - max(dm - rho / 2, -mw)) / rho, 0, 1) * mir * 0.85; + r0 += (250 - r0) * mc; + g0 += (235 - g0) * mc; + b0 += (200 - b0) * mc; + } + R = R * fog + r0 * (1 - fog); + Gc = Gc * fog + g0 * (1 - fog); + B = B * fog + b0 * (1 - fog); + } + } + const q = i * 3; + buf[q] = R; + buf[q + 1] = Gc; + buf[q + 2] = B; + } + } + for (let q = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, q += 3) display.setPixel(x, y, buf[q], buf[q + 1], buf[q + 2]); +} From dbeeec9f32c0a49b644b819619a3a98298ad3da7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:43:09 +0000 Subject: [PATCH 04/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Stadium=20Billiard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../patterns/stadium-billiard.js | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 incoming/pattern-study/patterns/stadium-billiard.js diff --git a/incoming/pattern-study/patterns/stadium-billiard.js b/incoming/pattern-study/patterns/stadium-billiard.js new file mode 100644 index 000000000..0d55cc27a --- /dev/null +++ b/incoming/pattern-study/patterns/stadium-billiard.js @@ -0,0 +1,294 @@ +// ===== Patternflow pattern ===== +// Title: Stadium Billiard +// Family: physics +// Idea: Two hundred balls leave one spot as a single white bead, their headings three thousandths of a radian apart, and the round ends of a Bunimovich stadium the size of the panel stretch the bead into a rainbow line and fold it until the balls fill the table as a gas. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the Bunimovich stadium (Leonid Bunimovich, 1974), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Round=0..1, Trail=0..1 +// +// Knob 1: Hue. Each ball's colour is its place in the fan, from yellow-green at one edge through cyan to violet at the other at 0; turning it takes that rainbow round the wheel, and the faint cloth from teal to violet and back. +// Knob 2: Speed. At 2 a ball flies 125 LEDs a second, about the panel's height: the bead holds together for two seconds, is a rainbow line at three and a gas by seven. At 0.2 it is 12.5 LEDs a second and the gas is a drift of single dots. +// Knob 3: Round. At 0 the straight sides are 64 LEDs long and the table is the whole panel, half circles of radius 32 touching the top and bottom rows; turning it shortens them, and at 1 the table is a circle in the middle 64 rows. A circle is integrable: a bunch launched there is still a bead when the next one leaves, and its chords never come inside a ring round the centre, which shows as a hole once Trail is up. +// Knob 4: Trail. How long a path stays lit. At 0.06 a ball draws a streak of about 12 LEDs; at 1 a path takes two seconds to fade and the paths add up to a haze with the bright dots running through it. +// Buttons: K1 tap sends every ball straight to a new spot in under a second and a fresh bunch leaves it. The gas also gathers by itself after 1900 LEDs of flight, fifteen seconds at the opening speed. + +const W = 64, H = 128, N = W * H, CX = 32, CY = 64, R = 32; +const { min, max, floor, ceil, sqrt, exp, cos, sin, abs, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// Two hundred balls, launched with headings spread over FAN radians. VEL +// LEDs a second per unit of Speed; TAU the shortest trail's time constant. +// After CYCLE LEDs of flight the gas gathers for GATHER seconds and a new +// bunch leaves. The cloth and its edge sit just under the 24/255 an LED +// needs to count as lit, so the table shows on the panel as a faint shape. +const NB = 200; +const FAN = 3e-3, VEL = 62.5, TAU = 0.035; +const CYCLE = 1900, GATHER = 0.9; +const CLOTH = 0.075, RIM = 0.094; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h), f = h - i, p = v * (1 - s), q = v * (1 - s * f), t = v * (1 - s * (1 - f)); + const r = [v, q, p, p, t, v][i], g = [t, v, v, q, p, p][i], b = [p, p, t, v, v, q][i]; + out[o] = r; + out[o + 1] = g; + out[o + 2] = b; +} + +function inside(a, x, y, m) { + if (abs(y) <= a) return abs(x) <= R - m; + const cy = y > 0 ? a : -a; + return x * x + (y - cy) * (y - cy) <= (R - m) * (R - m); +} + +// Where along (dx, dy) a ball at (x, y) meets the wall, and the wall's +// normal there. +const HIT = [0, 0, 0]; +function hit(a, x, y, dx, dy) { + let t = Infinity, nx = 0, ny = 0; + if (dx > 1e-12) { + const u = (R - x) / dx; + if (u > 1e-9 && abs(y + u * dy) <= a) (t = u), (nx = 1), (ny = 0); + } else if (dx < -1e-12) { + const u = (-R - x) / dx; + if (u > 1e-9 && abs(y + u * dy) <= a) (t = u), (nx = -1), (ny = 0); + } + for (let s = -1; s <= 1; s += 2) { + const cy = s * a, py = y - cy, b = x * dx + py * dy, c = x * x + py * py - R * R, d = b * b - c; + if (d < 0) continue; + const u = -b + sqrt(d); + if (u <= 1e-9 || u >= t) continue; + const hy = y + u * dy - cy; + if (s * hy < -1e-6) continue; + t = u; + nx = (x + u * dx) / R; + ny = hy / R; + } + HIT[0] = t; + HIT[1] = nx; + HIT[2] = ny; +} + +// Lights the LEDs a ball passes between (x0, y0) and (x1, y1), each once, +// w0 at the start fading up to w1 at the end. +function trail(P, i, x0, y0, x1, y1, w0, w1) { + const n = max(1, ceil(sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0)) / 0.6)), buf = P.buf, c = i * 3; + const cr = P.col[c], cg = P.col[c + 1], cb = P.col[c + 2]; + for (let j = 1; j <= n; j++) { + const t = j / n, X = floor(x0 + (x1 - x0) * t + CX), Y = floor(y0 + (y1 - y0) * t + CY); + const p = cl(Y, 0, H - 1) * W + cl(X, 0, W - 1); + if (p === P.lastp) continue; + P.lastp = p; + const w = w0 + (w1 - w0) * t, q = p * 3; + buf[q] += cr * w; + buf[q + 1] += cg * w; + buf[q + 2] += cb * w; + } +} + +// Ball i flies s LEDs, bouncing off the walls exactly. +function fly(P, i, s, w0, w1) { + const a = P.a; + let x = P.x[i], y = P.y[i], dx = P.dx[i], dy = P.dy[i], done = 0; + for (let k = 0; k < 24 && done < s; k++) { + hit(a, x, y, dx, dy); + // Every ray from inside meets the wall; a ball that finds none has been + // left outside, and goes back to the middle. + if (HIT[0] === Infinity && !inside(a, x, y, -0.5)) (x = 0), (y = 0); + const bounce = HIT[0] < s - done, t = bounce ? HIT[0] : s - done; + const x1 = x + t * dx, y1 = y + t * dy; + trail(P, i, x, y, x1, y1, w0 + ((w1 - w0) * done) / s, w0 + ((w1 - w0) * (done + t)) / s); + x = x1; + y = y1; + done = bounce ? done + t : s; + if (bounce) { + const nx = HIT[1], ny = HIT[2], d = dx * nx + dy * ny; + dx -= 2 * d * nx; + dy -= 2 * d * ny; + } + } + P.x[i] = x; + P.y[i] = y; + P.dx[i] = dx; + P.dy[i] = dy; +} + +// How far apart the two edge balls of a bunch launched from (x, y) at +// angle th are after flying s LEDs. +function split(a, x, y, th, s) { + const e = []; + for (const o of [-FAN / 2, FAN / 2]) { + let px = x, py = y, dx = cos(th + o), dy = sin(th + o), done = 0; + for (let k = 0; k < 200 && done < s; k++) { + hit(a, px, py, dx, dy); + if (HIT[0] >= s - done) { + px += (s - done) * dx; + py += (s - done) * dy; + break; + } + px += HIT[0] * dx; + py += HIT[0] * dy; + done += HIT[0]; + const d = dx * HIT[1] + dy * HIT[2]; + dx -= 2 * d * HIT[1]; + dy -= 2 * d * HIT[2]; + } + e.push(px, py); + } + return sqrt((e[0] - e[2]) ** 2 + (e[1] - e[3]) ** 2); +} + +// A new launch: of sixteen spots and headings, the one whose bunch holds +// together for the first 250 LEDs of flight and is furthest apart after 400 +// and 550, two and three and a half seconds at the opening speed. Headings +// stay at least 30 degrees off the bouncing-ball orbits that run straight +// across between the flat sides and take their time to come apart. +function pick(P) { + const a = P.a; + let best = [0, 0, PI / 3], bd = -2; + for (let k = 0; k < 16; k++) { + const x = (Math.random() * 2 - 1) * 20, y = (Math.random() * 2 - 1) * (a + 16); + if (!inside(a, x, y, 6)) continue; + let th = (0.17 + Math.random() * 0.3) * PI; + if (Math.random() < 0.5) th = -th; + if (Math.random() < 0.5) th = PI - th; + const d = a < 4 ? 0 : split(a, x, y, th, 250) < 6 ? split(a, x, y, th, 400) + split(a, x, y, th, 550) : -1; + if (d > bd) (bd = d), (best = [x, y, th]); + } + return best; +} + +function launch(P, x, y, th) { + if (!inside(P.a, x, y, 1)) (x = 0), (y = 0); + for (let i = 0; i < NB; i++) { + const t = th + FAN * (i / (NB - 1) - 0.5); + P.x[i] = x; + P.y[i] = y; + P.dx[i] = cos(t); + P.dy[i] = sin(t); + } + P.run = 0; + P.g = -1; +} + +// The cloth: 1 on the table, 2 on its edge, 0 in the corners outside it. +function table(P) { + const a = P.a, m = P.cloth; + for (let p = 0; p < N; p++) m[p] = inside(a, (p % W) + 0.5 - CX, ((p / W) | 0) + 0.5 - CY, 0) ? 1 : 0; + const put = (x, y) => (m[cl(floor(y + CY), 0, H - 1) * W + cl(floor(x + CX), 0, W - 1)] = 2); + for (let y = -a; y <= a; y += 0.5) put(-R + 0.01, y), put(R - 0.01, y); + for (let k = 0; k <= 400; k++) { + const t = (k / 400) * PI, x = R * cos(t) * 0.9997, y = R * sin(t) * 0.9997; + put(x, a + y); + put(x, -a - y); + } +} + +export function setup(params) { + const F64 = (n) => new Float64Array(n); + params.s = { + x: F64(NB), y: F64(NB), dx: F64(NB), dy: F64(NB), gx: F64(NB), gy: F64(NB), + col: new Float32Array(NB * 3), buf: new Float32Array(N * 3), img: new Float32Array(N * 3), + a: 32, cloth: new Uint8Array(N), run: 0, g: -1, L: null, lastp: -1, hue: NaN, tint: [0, 0, 0, 0, 0, 0], + }; + table(params.s); + launch(params.s, -19.9, 11.2, 2.389); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + const v = VEL * cl(k[1], 0.2, 3), tr = cl(k[3], 0, 1), tau = TAU + 2.2 * tr * tr; + const f = exp(-d / tau), B = min(1, 0.07 / tau); + // Each ball keeps its place in the fan as a colour, six tenths of the + // wheel from one edge to the other, so the bead is white where they all + // overlap and the line it stretches into is a rainbow. The cloth turns + // from teal to violet and back and is never a dim orange, which reads brown. + if (k[0] !== P.hue) { + P.hue = k[0]; + for (let i = 0; i < NB; i++) hsv(k[0] + 0.5 + 0.6 * (i / (NB - 1) - 0.5), 1, 1, P.col, i * 3); + const c = 0.52 + 0.28 * sin(PI * k[0]) ** 2; + hsv(c, 0.75, CLOTH, P.tint, 0); + hsv(c, 0.6, RIM, P.tint, 3); + } + + // The table: two half circles of radius 32 joined by straight sides 2a long. + const a = 32 * (1 - cl(k[2], 0, 1)); + if (abs(a - P.a) > 1e-6) { + P.a = a; + table(P); + for (let i = 0; i < NB; i++) { + let x = P.x[i], y = P.y[i]; + if (inside(a, x, y, 0.01)) continue; + const cy = abs(y) <= a ? y : y > 0 ? a : -a, ox = x, oy = y - cy, r = sqrt(ox * ox + oy * oy) || 1; + x = (ox / r) * (R - 0.02); + y = cy + (oy / r) * (R - 0.02); + const nx = ox / r, ny = oy / r, dd = P.dx[i] * nx + P.dy[i] * ny; + if (dd > 0) (P.dx[i] -= 2 * dd * nx), (P.dy[i] -= 2 * dd * ny); + P.x[i] = x; + P.y[i] = y; + } + } + + const buf = P.buf; + for (let q = 0; q < N * 3; q++) buf[q] *= f; + if (P.g >= 0 && !inside(a, P.L[0], P.L[1], 1)) (P.L[0] = 0), (P.L[1] = 0); + + if (P.g < 0 && ((input.btnPressed && input.btnPressed[0]) || P.run > CYCLE)) { + P.L = pick(P); + P.g = 0; + P.gx.set(P.x); + P.gy.set(P.y); + } + if (P.g >= 0) { + // Gathering: every ball runs straight to the next launch spot. + const u0 = P.g / GATHER, u1 = min(1, (P.g + d) / GATHER); + const e0 = u0 * u0 * (3 - 2 * u0), e1 = u1 * u1 * (3 - 2 * u1), [lx, ly, th] = P.L; + for (let i = 0; i < NB; i++) { + const x0 = P.gx[i] + (lx - P.gx[i]) * e0, y0 = P.gy[i] + (ly - P.gy[i]) * e0; + const x1 = P.gx[i] + (lx - P.gx[i]) * e1, y1 = P.gy[i] + (ly - P.gy[i]) * e1; + P.lastp = -1; + trail(P, i, x0, y0, x1, y1, B * f, B); + P.x[i] = x1; + P.y[i] = y1; + } + P.g += d; + if (u1 >= 1) launch(P, lx, ly, th); + } else if (d > 0) { + const s = v * d; + for (let i = 0; i < NB; i++) { + P.lastp = -1; + fly(P, i, s, B * f, B); + } + P.run += s; + } + // Where the whole bunch passes at once the light piles up; capped, the + // bead's tail is no longer than a lone ball's. + for (let q = 0; q < N * 3; q++) if (buf[q] > 1.5) buf[q] = 1.5; +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const img = P.img, col = P.col, t = P.tint, m = P.cloth, buf = P.buf; + for (let p = 0, q = 0; p < N; p++, q += 3) { + const o = m[p] === 2 ? 3 : 0, c = m[p] ? 1 : 0; + img[q] = max(buf[q], t[o] * c); + img[q + 1] = max(buf[q + 1], t[o + 1] * c); + img[q + 2] = max(buf[q + 2], t[o + 2] * c); + } + for (let i = 0; i < NB; i++) { + const q = (cl(floor(P.y[i] + CY), 0, H - 1) * W + cl(floor(P.x[i] + CX), 0, W - 1)) * 3, c = i * 3; + img[q] += col[c]; + img[q + 1] += col[c + 1]; + img[q + 2] += col[c + 2]; + } + for (let p = 0, q = 0; p < N; p++, q += 3) { + display.setPixel(p % W, (p / W) | 0, min(255, img[q] * 255) | 0, min(255, img[q + 1] * 255) | 0, min(255, img[q + 2] * 255) | 0); + } +} From 84c3fd66b029fc228609b4c25236e179a3b42044 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:45:24 +0000 Subject: [PATCH 05/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Gosset=20Polytope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/gosset-polytope.js | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 incoming/pattern-study/patterns/gosset-polytope.js diff --git a/incoming/pattern-study/patterns/gosset-polytope.js b/incoming/pattern-study/patterns/gosset-polytope.js new file mode 100644 index 000000000..63270ae2f --- /dev/null +++ b/incoming/pattern-study/patterns/gosset-polytope.js @@ -0,0 +1,281 @@ +// ===== Patternflow pattern ===== +// Title: Gosset Polytope +// Family: geometry +// Idea: The 240 roots of E8 and the 6720 edges between them, Gosset's 4₂₁ polytope, projected out of eight dimensions into two windows: the upper one opens on the Coxeter plane picture, eight rings of 30, and swings from there through the Coxeter planes of E7, E6, D5, D4 and D6 and back, while the lower one runs a plane ahead. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Gosset's 4₂₁ polytope (Thorold Gosset, 1900) and the E8 root system (Wilhelm Killing 1888, Élie Cartan 1894), in Peter McMullen's Petrie projection, pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Edges=-1..1, Plane=1..5 +// +// Knob 1: Hue. Turns the eight ring colours round the colour wheel together; at 0 the inner ring is gold and the outer one cyan. +// Knob 2: Speed. At 2 each plane holds for two and a half seconds and the swing to the next takes three and a half, so the round of six planes takes 36 seconds; at 0.2 it takes six minutes. +// Knob 3: Edges. How bright the web of 6720 edges is: at -1 only the 240 vertices, at 0 a faint web, at 1 four times brighter, where the overlaps burn white. +// Knob 4: Plane. How many planes ahead along the round the lower window runs. At 1 it shows E7's 18-fold picture under the 30-fold one and each picture rises from the lower window to the upper; at 4 it is D4's six-pointed star, and at 5 it runs one plane behind. +// Buttons: K1 tap runs both windows back (or on) along the round until the upper one stands on the Coxeter plane again, in a second or so. + +// Each window is 64 LEDs square; the polytope fills a circle of radius 29.5 +// in it, so the outer ring of the Coxeter plane picture has 30 vertices about +// six LEDs apart and the inner one 30 on a circle of radius six. +const W = 64, HV = 64, CX = 31.5, RAD = 29.5; +const { max, floor, ceil, round, sqrt, cos, sin, abs, exp, atan2, hypot, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0); + +// The roots: every (±1, ±1, 0, 0, 0, 0, 0, 0) in any order, 112 of them, +// and every (±1/2)^8 with an even number of minus signs, 128 more. +const ROOTS = []; +for (let i = 0; i < 8; i++) + for (let j = i + 1; j < 8; j++) + for (let s = 0; s < 4; s++) { + const v = new Array(8).fill(0); + v[i] = s & 1 ? -1 : 1; + v[j] = s & 2 ? -1 : 1; + ROOTS.push(v); + } +for (let m = 0; m < 256; m++) { + let neg = 0; + for (let k = 0; k < 8; k++) neg += (m >> k) & 1; + if (neg % 2 === 0) ROOTS.push([...Array(8)].map((_, k) => ((m >> k) & 1 ? -0.5 : 0.5))); +} +const NV = ROOTS.length; +const RF = new Float64Array(NV * 8); +ROOTS.forEach((r, i) => r.forEach((x, k) => (RF[i * 8 + k] = x))); + +// Two roots are joined when they stand sqrt 2 apart, when their dot product +// is 1: 56 edges at each vertex, 6720 in all. +const EDGE = []; +for (let i = 0; i < NV; i++) for (let j = i + 1; j < NV; j++) if (dot(ROOTS[i], ROOTS[j]) === 1) EDGE.push(i, j); +const NE = EDGE.length / 2; +const EI = Uint16Array.from(EDGE); + +// The simple roots of E8 (Bourbaki's numbering): a1 is the spinor root, a2 +// hangs off a4, and a1 a3 a4 a5 a6 a7 a8 is the long arm. +const e = (i, s = 1, j = -1, t = 0) => [...Array(8)].map((_, k) => (k === i ? s : k === j ? t : 0)); +const SIMPLE = [[0.5, -0.5, -0.5, -0.5, -0.5, -0.5, -0.5, 0.5], e(0, 1, 1, 1), e(1, 1, 0, -1), e(2, 1, 1, -1), e(3, 1, 2, -1), e(4, 1, 3, -1), e(5, 1, 4, -1), e(6, 1, 5, -1)]; + +// The Coxeter plane of a simply laced root system from its simple roots +// (Steinberg): colour the Dynkin diagram black and white, take the +// Perron-Frobenius vector x of the diagram, and the plane is spanned by the +// sum of x_i a_i over the black roots and the same sum over the white ones. +// The product of all the reflections turns it by one h-th of a turn. +function coxeterPlane(S) { + const k = S.length, adj = S.map((a) => S.map((b) => (dot(a, b) === -1 ? 1 : 0))); + const col = new Array(k).fill(-1), stack = [0]; + col[0] = 0; + while (stack.length) { + const i = stack.pop(); + for (let j = 0; j < k; j++) if (adj[i][j] && col[j] < 0) (col[j] = 1 - col[i]), stack.push(j); + } + let x = new Array(k).fill(1), lam = 0; + for (let it = 0; it < 600; it++) { + const y = x.map((xi, i) => 2 * xi + adj[i].reduce((s, a, j) => s + a * x[j], 0)); + lam = sqrt(dot(y, y)) - 2; + x = y.map((v) => v / (lam + 2)); + } + const u = new Array(8).fill(0), w = new Array(8).fill(0); + S.forEach((a, i) => a.forEach((ai, d) => ((col[i] ? w : u)[d] += x[i] * ai))); + const nu = sqrt(dot(u, u)); + for (let d = 0; d < 8; d++) u[d] /= nu; + const pr = dot(w, u); + for (let d = 0; d < 8; d++) w[d] -= pr * u[d]; + const nw = sqrt(dot(w, w)); + // The diagram's largest eigenvalue is 2 cos(pi / h), h the Coxeter number. + return [u, w.map((v) => v / nw), round(PI / Math.acos(lam / 2))]; +} + +// The round: E8 (30-fold), then down the chain E8 > E7 > E6 > D5 > D4 one +// node of the diagram at a time (18, 12, 8 and 6-fold), up through D6 +// (10-fold) and back. +const STOPS = [SIMPLE, SIMPLE.slice(0, 7), SIMPLE.slice(0, 6), SIMPLE.slice(1, 6), SIMPLE.slice(1, 5), SIMPLE.slice(1, 7)].map(coxeterPlane); +const NS = STOPS.length; + +// Each swing is the shortest way from one plane to the next (a geodesic in +// the Grassmannian): the two principal angles between the planes, turned +// through together. The frame carries its own turn within the plane from +// one swing to the next, so the windows never jump. +function svd2(a, b, c, d) { + const E = (a + d) / 2, F = (a - d) / 2, G = (c + b) / 2, H = (c - b) / 2; + const Q = hypot(E, H), R = hypot(F, G), a1 = atan2(G, F), a2 = atan2(H, E); + // C = rot(phi) diag(Q + R, Q - R) rot(theta) + return { s1: Q + R, s2: Q - R, phi: (a2 + a1) / 2, th: (a2 - a1) / 2 }; +} +const comb = (p, q, cp, cq) => p.map((x, i) => cp * x + cq * q[i]); +// Each stop is shown turned so a vertex of its outer polygon points straight +// up; the turn is picked among the h equal ones to be the nearest to the +// last, and the swing eases from one to the next. +function upright(u, v, h, near) { + let best = 0, a = 0; + for (const r of ROOTS) { + const x = dot(r, u), y = dot(r, v), q = hypot(x, y); + if (q > best + 1e-9) (best = q), (a = atan2(y, x)); + } + const t = -PI / 2 - a, step = (2 * PI) / h; + return t + round((near - t) / step) * step; +} +const SEG = []; +let F = STOPS[0], turn = upright(F[0], F[1], 30, 0); +for (let k = 0; k < NS; k++) { + let [v1, v2, h] = STOPS[(k + 1) % NS]; + const [f1, f2] = F; + if (dot(f1, v1) * dot(f2, v2) - dot(f1, v2) * dot(f2, v1) < 0) v2 = v2.map((x) => -x); + const { s1, s2, phi, th } = svd2(dot(f1, v1), dot(f1, v2), dot(f2, v1), dot(f2, v2)); + const ca = cos(phi), sa = sin(phi), cb = cos(-th), sb = sin(-th); + // Principal vectors: P = F rot(phi), Q = V rot(theta)^T. + const p = [comb(f1, f2, ca, sa), comb(f1, f2, -sa, ca)]; + const q = [comb(v1, v2, cb, sb), comb(v1, v2, -sb, cb)]; + const ang = [], n = []; + [s1, s2].forEach((s, i) => { + const t = comb(q[i], p[i], 1, -cl(s, -1, 1)), nt = sqrt(dot(t, t)); + ang.push(Math.acos(cl(s, -1, 1))); + n.push(nt > 1e-9 ? t.map((x) => x / nt) : new Array(8).fill(0)); + }); + F = [comb(q[0], q[1], cos(phi), -sin(phi)), comb(q[0], q[1], sin(phi), cos(phi))]; + // Back on the Coxeter plane the frame may come round mirrored; the + // picture there is symmetric under the mirror, so turning a vertex up + // closes the loop all the same. + const next = upright(F[0], F[1], h, turn); + SEG.push({ p, n, ang, phi, t0: turn, t1: next }); + turn = next; +} + +// Where the frame stands at phase s (in stops): hold 5/12 of each stop, then +// swing to the next with a smooth start and end. Returns the turn. +const HOLD = 5 / 12; +function frameAt(s, out) { + const n = ((s % NS) + NS) % NS, k = floor(n), f = n - k, G = SEG[k]; + let m = cl((f - HOLD) / (1 - HOLD), 0, 1); + m = m * m * m * (m * (m * 6 - 15) + 10); + const c1 = cos(m * G.ang[0]), s1 = sin(m * G.ang[0]), c2 = cos(m * G.ang[1]), s2 = sin(m * G.ang[1]); + const cp = cos(G.phi), sp = sin(G.phi); + for (let d = 0; d < 8; d++) { + const g1 = c1 * G.p[0][d] + s1 * G.n[0][d], g2 = c2 * G.p[1][d] + s2 * G.n[1][d]; + out[d] = g1 * cp - g2 * sp; + out[8 + d] = g1 * sp + g2 * cp; + } + return G.t0 + (G.t1 - G.t0) * m; +} + +// Eight rings of 30 on the Coxeter plane; each vertex keeps the colour of +// its ring wherever the swings carry it. +const RING = new Uint8Array(NV); +{ + const [u, v] = STOPS[0], rr = ROOTS.map((r) => hypot(dot(r, u), dot(r, v))); + const sorted = [...rr].sort((a, b) => a - b), levels = sorted.filter((x, i) => i === 0 || x - sorted[i - 1] > 1e-3); + rr.forEach((x, i) => (RING[i] = levels.filter((l) => l < x + 1e-3).length - 1)); +} + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + for (let c = 0; c < 3; c++) out[o + c] = rgb[c]; +} + +export function setup(params) { + params.s = { + ph: 0, lead: 1, hue: -1, ew: 1, snap: null, + buf: new Float32Array(W * HV * 2 * 3), fr: new Float64Array(16), + px: new Float32Array(NV), py: new Float32Array(NV), + vc: new Float32Array(NV * 3), ec: new Float32Array(NE * 3), rc: new Float32Array(8 * 3), + }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + const sp = cl(k[1], 0.2, 3); + P.lead = cl(k[3], 1, 5); + P.ew = cl(k[2], -1, 1); + const hue = k[0] || 0; + if (hue !== P.hue) { + P.hue = hue; + for (let r = 0; r < 8; r++) hsv(0.15 + hue - r * 0.095, 0.95 - 0.02 * r, 1, P.rc, r * 3); + for (let i = 0; i < NV; i++) for (let c = 0; c < 3; c++) P.vc[i * 3 + c] = P.rc[RING[i] * 3 + c]; + for (let j = 0; j < NE; j++) for (let c = 0; c < 3; c++) P.ec[j * 3 + c] = (P.vc[EI[2 * j] * 3 + c] + P.vc[EI[2 * j + 1] * 3 + c]) / 2; + } + if (P.snap) { + P.snap.t += d; + const u = cl(P.snap.t / P.snap.T, 0, 1), m = u * u * (3 - 2 * u); + P.ph = P.snap.a + (P.snap.b - P.snap.a) * m; + if (u >= 1) P.snap = null; + } else { + P.ph = (P.ph + (d * sp) / 12) % NS; + // K1: run back or on to the nearer Coxeter plane, 0.4 s a stop. + if (input.btnPressed && input.btnPressed[0]) { + const b = round(P.ph / NS) * NS; + P.snap = { a: P.ph, b, t: 0, T: max(0.8, abs(b - P.ph) * 0.4) }; + } + } +} + +// One window: project the 240 roots onto the plane, draw the 6720 edges as +// additive light, then the vertices on top. +function view(P, ph, y0) { + const fr = P.fr, buf = P.buf, px = P.px, py = P.py; + const a = frameAt(ph, fr), ca = cos(a), sa = sin(a); + let rmax = 0; + for (let i = 0; i < NV; i++) { + let x = 0, y = 0; + for (let d = 0, o = i * 8; d < 8; d++) (x += RF[o + d] * fr[d]), (y += RF[o + d] * fr[8 + d]); + px[i] = ca * x - sa * y; + py[i] = sa * x + ca * y; + rmax = max(rmax, px[i] * px[i] + py[i] * py[i]); + } + const sc = RAD / sqrt(rmax), cy = y0 + 31.5; + for (let i = 0; i < NV; i++) (px[i] = CX + px[i] * sc - 0.5), (py[i] = cy + py[i] * sc - 0.5); + const ew = P.ew <= -1 ? 0 : 0.012 * (P.ew + 1) * (P.ew + 1), ec = P.ec, W3 = W * 3; + // RAD keeps every point a pixel inside the window, so no line needs clipping. + if (ew > 0) + for (let j = 0; j < NE; j++) { + const i0 = EI[2 * j], i1 = EI[2 * j + 1]; + let x = px[i0], y = py[i0]; + const dx = px[i1] - x, dy = py[i1] - y, adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy; + const n = ceil(adx > ady ? adx : ady) || 1, w = (ew * sqrt(dx * dx + dy * dy)) / n; + const r = ec[j * 3] * w, g = ec[j * 3 + 1] * w, b = ec[j * 3 + 2] * w, sx = dx / n, sy = dy / n; + for (let s = 0; s <= n; s++, x += sx, y += sy) { + const xi = x | 0, yi = y | 0, fx = x - xi, fy = y - yi, q = (yi * W + xi) * 3; + const w11 = fx * fy, w10 = fx - w11, w01 = fy - w11, w00 = 1 - fx - w01; + buf[q] += r * w00; + buf[q + 1] += g * w00; + buf[q + 2] += b * w00; + buf[q + 3] += r * w10; + buf[q + 4] += g * w10; + buf[q + 5] += b * w10; + buf[q + W3] += r * w01; + buf[q + W3 + 1] += g * w01; + buf[q + W3 + 2] += b * w01; + buf[q + W3 + 3] += r * w11; + buf[q + W3 + 4] += g * w11; + buf[q + W3 + 5] += b * w11; + } + } + // With the web turned off the vertices burn brighter. + const vc = P.vc, vw = 0.6 + 0.5 * max(0, -P.ew); + for (let i = 0; i < NV; i++) { + const x = px[i], y = py[i], xi = x | 0, yi = y | 0, fx = x - xi, fy = y - yi, q = (yi * W + xi) * 3; + const w11 = fx * fy * vw, w10 = fx * vw - w11, w01 = fy * vw - w11, w00 = vw - fx * vw - w01; + for (let c = 0; c < 3; c++) { + const v = vc[i * 3 + c]; + buf[q + c] += v * w00; + buf[q + 3 + c] += v * w10; + buf[q + W3 + c] += v * w01; + buf[q + W3 + 3 + c] += v * w11; + } + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const buf = P.buf; + buf.fill(0); + view(P, P.ph, 0); + view(P, P.ph + P.lead, HV); + for (let p = 0, y = 0; y < 2 * HV; y++) + for (let x = 0; x < W; x++, p += 3) + display.setPixel(x, y, 255 * (1 - exp(-1.6 * buf[p])), 255 * (1 - exp(-1.6 * buf[p + 1])), 255 * (1 - exp(-1.6 * buf[p + 2]))); +} From 4107cb7dc469c8d0056ba70f167b8f508f5ee846 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:47:43 +0000 Subject: [PATCH 06/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Ford=20Circles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/ford-circles.js | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 incoming/pattern-study/patterns/ford-circles.js diff --git a/incoming/pattern-study/patterns/ford-circles.js b/incoming/pattern-study/patterns/ford-circles.js new file mode 100644 index 000000000..fd0cec5f4 --- /dev/null +++ b/incoming/pattern-study/patterns/ford-circles.js @@ -0,0 +1,299 @@ +// ===== Patternflow pattern ===== +// Title: Ford Circles +// Family: number +// Idea: The real line stands up the left edge with a circle of radius 1/(2q²) resting on every fraction p/q, and the picture zooms for ever into the golden ratio while ever smaller circles keep appearing at the mediants of the ones that touch. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Ford circles (Lester R. Ford, 1938) and the Farey sequence (John Farey, 1816), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Target=0..4, Fill=0..1 +// +// Knob 1: Hue. Turns every colour round the wheel together. Colour follows the denominator q: at 0 the big circles of small q are teal and the smaller ones blue and violet, and as the zoom reaches larger q the hues swing to pink and back. +// Knob 2: Speed. How fast it zooms. At 2 the picture grows by a factor of e every three seconds and a new convergent of the golden ratio comes up every 2.9 s; at 0.2 everything takes ten times as long. +// Knob 3: Target. The number the zoom falls into, read from its continued fraction: 0 the golden ratio [1; 1, 1, ...], 1 the square root of 2, 2 the square root of 3, 3 e, 4 pi. Pi stalls for about half a minute at Speed 2 beside 355/113, where its term 292 sits, while a chain of nearly equal circles grows along the edge of one giant one. +// Knob 4: Fill. Rims only at 0. At the start each circle glows in from its rim, 2 LEDs deep on a small circle and up to 18 on a giant one; at 1 the discs are solid. +// Buttons: K1 tap lights the Stern-Brocot descent into the target: from the two convergents framing the window, each mediant whose parents still hold the target between them lights in turn, each smaller and touching the last, down to the spark on the line. The other circles dim for the five seconds it takes. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, exp, log, sin, abs, round } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// Screen rows run down the real line with larger numbers at the top; the +// target sits in the middle of row 64. Circles narrower than RMIN LEDs are +// left out, and the window jumps to the next convergent with MARG to spare. +const CY = 64.5, TROW = 64, RMIN = 0.28, MARG = 1.08, MAXC = 1200, S0 = 132; + +// The continued fractions of the targets, a(i) for i = 0, 1, 2, ... The +// golden ratio is [1; 1, 1, ...], the square roots of 2 and 3 repeat, e runs +// [2; 1, 2, 1, 1, 4, 1, 1, 6, ...] and pi is written out to 50 terms. After +// term 39 for e and 49 for pi the terms start over, so the number the zoom +// falls into agrees with e and pi to more than 30 decimal places. +const PIC = [3, 7, 15, 1, 292, 1, 1, 1, 2, 1, 3, 1, 14, 2, 1, 1, 2, 2, 2, 2, 1, 84, 2, 1, 1, 15, 3, 13, 1, 4, 2, 6, 6, 99, 1, 2, 2, 6, 3, 5, 1, 1, 6, 8, 1, 7, 1, 2, 3, 7]; +const TG = [ + () => 1, + (i) => (i ? 2 : 1), + (i) => (i ? 2 - (i & 1) : 1), + (i) => { + if (!i) return 2; + i = 1 + ((i - 1) % 39); + return i % 3 === 2 ? (2 * (i + 1)) / 3 : 1; + }, + (i) => PIC[i < 50 ? i : 1 + ((i - 1) % 49)], +]; +// [a(i); a(i+1), a(i+2), ...] to double precision. +function tail(a, i) { + let v = a(i + 26); + for (let j = i + 25; j >= i; j--) v = a(j) + 1 / v; + return v; +} + +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +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] = 255 * (i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p); + out[1] = 255 * (i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p); + out[2] = 255 * (i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p); +} +const smooth = (a, b, v) => { + const u = cl((v - a) / (b - a), 0, 1); + return u * u * (3 - 2 * u); +}; + +// The window is kept in the frame of the convergents p/q (index n) and +// p'/q' (index n-1) of the target alpha. The map X -> (pX + p')/(qX + q') +// sends the Ford circles onto themselves, sends 1/0 and 0/1 to p/q and p'/q', +// and sends the tail t = [a(n+1); a(n+2), ...] to alpha. A fraction P/Q in +// that frame lands on the LEDs at s(p/q - alpha) = sig D (P - tQ) / ((P + kQ)(t + k)) +// with radius sig / (2 (P + kQ)^2), where k = q'/q, D = pq' - p'q = +-1 and +// sig = s/q^2. All of these stay small however deep the zoom goes, so it +// can run for ever in double precision. +function start(S, tgt) { + const a = TG[tgt]; + S.tgt = tgt; + S.a = a; + S.n = 0; + S.k = 0; + S.D = -1; + S.lnq = 0; + S.t = tail(a, 1); + S.sig = max(S0, 64 * MARG * S.t + 1); + // p, q, p' and q' modulo 2^32, for each circle's own twinkle. + S.pn = a(0); + S.pm = 1; + S.qn = 1; + S.qm = 0; + S.fade = 0; +} +// The next convergent's frame is used as soon as the window sits wholly +// between it and the one before: both tangent points off the panel, and the +// two circles touching beyond the right edge. +function advance(S) { + for (let g = 0; g < 8; g++) { + const an = S.a(S.n + 1), k2 = 1 / (an + S.k), t2 = tail(S.a, S.n + 2); + if (S.sig * k2 * k2 <= 64 * MARG * (t2 + k2)) return; + S.sig *= k2 * k2; + S.lnq -= log(k2); + S.k = k2; + S.t = t2; + S.D = -S.D; + S.n++; + const pn = (Math.imul(an, S.pn) + S.pm) | 0, qn = (Math.imul(an, S.qn) + S.qm) | 0; + S.pm = S.pn; + S.qm = S.qn; + S.pn = pn; + S.qn = qn; + } +} + +export function setup(params) { + const S = (params.s = { + time: 0, wave: -1, hue: 0, glow: 0.55, depth: 1.8, flat: 0.05, + Y: new Float64Array(MAXC), R: new Float64Array(MAXC), Wt: new Float64Array(MAXC), + PP: new Int32Array(MAXC), QQ: new Int32Array(MAXC), PA: new Int32Array(MAXC), PB: new Int32Array(MAXC), + CV: new Uint8Array(MAXC), PH: new Uint8Array(MAXC), stack: new Int32Array(MAXC * 4), conv: new Float64Array(96), + buf: new Float32Array(N * 3), col: [0, 0, 0], + }); + start(S, 0); + S.tgt = -1; +} + +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.time += d; + S.hue = k[0]; + // Fill lights the inside of each circle in from its rim: a glow a few + // LEDs deep at first, then a flat fill under it up to a solid disc. + const u = cl(k[3], 0, 1), fk = sqrt(u); + S.glow = min(0.55, 2.4 * fk); + S.depth = 0.8 + 4 * fk; + S.flat = 0.85 * u; + const tgt = cl(round(k[2]), 0, 4); + if (tgt !== S.tgt) { + const first = S.tgt < 0; + start(S, tgt); + if (first) S.fade = 1; + } + S.fade = min(1, S.fade + d * 2.5); + S.sig *= exp(d * cl(k[1], 0.2, 3) * 0.165); + advance(S); + if (S.wave >= 0) { + S.wave += d; + if (S.wave > WT) S.wave = -1; + } + if (input.btnPressed && input.btnPressed[0]) S.wave = 0; +} + +// Every circle that reaches the panel: the two convergents framing the +// window, then the Stern-Brocot tree between them, each new fraction the +// mediant of two that touch. A gap whose two tangent points both lie off one +// end of the panel holds nothing on it, and a mediant too small to see has +// only smaller ones below it. +function circles(S) { + const { Y, R, Wt, PP, QQ, PA, PB, CV, PH, stack, conv } = S; + const sig = S.sig, k = S.k, t = S.t, D = S.D, tk = t + k, wmax = sqrt(sig / (2 * RMIN)); + // The convergents of the tail are the convergents of the target. + let nc = 0; + for (let j = 0, h1 = 1, k1 = 0, h0 = S.a(S.n + 1), k0 = 1; j < 40 && h0 + k * k0 <= wmax; j++) { + conv[nc++] = h0; + conv[nc++] = k0; + const a = S.a(S.n + 2 + j), h2 = a * h0 + h1, k2 = a * k0 + k1; + (h1 = h0), (k1 = k0), (h0 = h2), (k0 = k2); + } + PP[0] = 0, QQ[0] = 1, Wt[0] = k, CV[0] = 1, PH[0] = 1; + if (k > 0) (R[0] = sig / (2 * k * k)), (Y[0] = CY + (D * sig * t) / (k * tk)); + else (R[0] = 0), (Y[0] = CY + D * 1e30); + PP[1] = 1, QQ[1] = 0, Wt[1] = 1, CV[1] = 1, PH[1] = 1, R[1] = sig / 2, Y[1] = CY - (D * sig) / tk; + let n = 2, sp = 0; + stack[sp++] = 0; + stack[sp++] = 1; + while (sp > 0 && n < MAXC) { + const b = stack[--sp], a = stack[--sp], ya = Y[a], yb = Y[b]; + if ((ya < -1 && yb < -1) || (ya > H + 1 && yb > H + 1)) continue; + const P = PP[a] + PP[b], Q = QQ[a] + QQ[b], w = P + k * Q; + if (w > wmax) continue; + PP[n] = P, QQ[n] = Q, Wt[n] = w, PA[n] = a, PB[n] = b; + R[n] = sig / (2 * w * w); + Y[n] = CY - (D * sig * (P - t * Q)) / (w * tk); + let c = 0; + for (let j = 0; j < nc; j += 2) if (conv[j] === P && conv[j + 1] === Q) c = 1; + CV[n] = c; + // On the descent to the target: the target lies between the parents. + PH[n] = (PP[a] - t * QQ[a]) * (PP[b] - t * QQ[b]) < 0 ? 1 : 0; + stack[sp++] = a; + stack[sp++] = n; + stack[sp++] = n; + stack[sp++] = b; + n++; + } + return n; +} + +// The rim is RW LEDs either side of the circle's edge. +const RW = 0.85; +// Colour by denominator: the hue walks HA round the wheel and back as ln q +// climbs by HL and HL again, so the circles stay on the cool side of the +// wheel (teal, blue, violet, pink at the start) however deep the zoom goes. +const HA = 0.36, HL = 4; +// The descent's front runs WV units of ln(size) a second; a tap lasts WT s. +const WV = 1.8, WT = 5; +function disc(buf, r, yc, cr, cg, cb, ra, ga, gl, fa, cw) { + const y0 = max(0, floor(yc - r - 1)), y1 = min(H - 1, floor(yc + r + 1)), x1 = min(W - 1, floor(2 * r + 1)), rw = RW * cw; + for (let y = y0; y <= y1; y++) { + const dy = y + 0.5 - yc, dy2 = dy * dy; + const ady = abs(dy), xs = ady <= r ? dy2 / (r + sqrt(r * r - dy2)) : r; + for (let x = max(0, floor(xs - 2)); x <= x1; x++) { + const X = x + 0.5, dist = sqrt((X - r) * (X - r) + dy2), d = (X * X - 2 * X * r + dy2) / (dist + r); + if (d > rw) { + if (X > r) break; + continue; + } + let v = ra * (d > -rw ? 1 - abs(d) / rw : 0); + if (d < 0.5) v += cl(0.5 - d, 0, 1) * (fa + ga * exp(d / gl) + (y === TROW ? 0.32 : 0)); + const p = (y * W + x) * 3; + buf[p] += cr * v; + buf[p + 1] += cg * v; + buf[p + 2] += cb * v; + } + } +} +function dot(buf, x, y, v, cr, cg, cb) { + const xi = floor(x), yi = floor(y), fx = x - xi, fy = y - yi; + for (let j = 0; j < 2; j++) + for (let i = 0; i < 2; i++) { + const px = xi + i, py = yi + j; + if (px < 0 || px >= W || py < 0 || py >= H) continue; + const w = v * (i ? fx : 1 - fx) * (j ? fy : 1 - fy), p = (py * W + px) * 3; + buf[p] += cr * w; + buf[p + 1] += cg * w; + buf[p + 2] += cb * w; + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const buf = S.buf, col = S.col, n = circles(S), { Y, R, Wt, PP, QQ, PA, PB, CV, PH } = S; + buf.fill(0); + // The target's ray. A circle the row through alpha crosses has + // |alpha - p/q| < 1/(2q^2), and by Legendre's theorem that makes p/q a + // convergent of alpha: the zoom passes through convergents and nothing + // else. Convergents get whiter, wider rims; the row lights inside them. + for (let x = 0; x < W; x++) { + const p = (TROW * W + x) * 3, v = 0.13 * (1 - x / 90); + buf[p] += 255 * v; + buf[p + 1] += 240 * v; + buf[p + 2] += 200 * v; + } + // After a K1 tap the Stern-Brocot descent into the target lights up: a + // front runs from the biggest circles to the smallest and lights each + // fraction whose two parents hold the target between them, so the light + // hops mediant by mediant, each circle touching the last, down to the + // spark on the line. The rest of the window dims while it runs. + const on = S.wave >= 0, wf = S.wave * WV - 1.2, wd = on ? smooth(0, 0.3, S.wave) * min(1, (WT - S.wave) / 0.9) : 0; + for (let i = 0; i < n; i++) { + const r = R[i]; + if (r < RMIN || (i === 0 && S.k === 0)) continue; + const lq = (S.lnq + log(Wt[i])) / HL; + hsv(S.hue + 0.5 + HA * abs(lq - 2 * floor(lq / 2 + 0.5)), 0.82, 1, col); + let cr = col[0], cg = col[1], cb = col[2], ra = 0.9, cw = 1, ga = S.glow, fa = S.flat; + let vis = smooth(RMIN, 1.6, r); + if (CV[i]) (cr = cr * 0.65 + 89), (cg = cg * 0.65 + 84), (cb = cb * 0.65 + 70), (ra = 1), (cw = 1.3); + if (on && PH[i]) { + const lv = log(64 / r), fr = exp(-(((wf - lv) / 0.35) ** 2)), lit = smooth(-0.25, 0.25, wf - lv) * wd; + (cr += (255 - cr) * (0.25 * lit + 0.5 * fr)), (cg += (255 - cg) * (0.25 * lit + 0.5 * fr)), (cb += (255 - cb) * (0.25 * lit + 0.5 * fr)); + (ra += 0.4 * lit + fr), (ga += 0.4 * lit), (fa += 0.12 * lit + 0.35 * fr), (cw += 0.4 * lit); + } else vis *= 1 - 0.55 * wd; + disc(buf, r, Y[i], cr, cg, cb, ra * vis, ga * vis, S.depth * (1 + 0.1 * min(r, 90)), fa * vis, cw); + } + // The points where circles touch glint, each at its own pace. Every + // tangency on the panel is a mediant touching one of its two parents. + const { pn, pm, qn, qm } = S; + for (let i = 2; i < n; i++) { + const rm = R[i]; + if (rm < 1.2) continue; + const pa = Math.imul(pn, PP[i]) + Math.imul(pm, QQ[i]), qa = Math.imul(qn, PP[i]) + Math.imul(qm, QQ[i]); + const fr = on && PH[i] ? 2 * exp(-(((wf - log(64 / rm)) / 0.35) ** 2)) : 0; + for (let e = 0; e < 2; e++) { + const j = e ? PB[i] : PA[i], rp = R[j]; + if (j === 0 && S.k === 0) continue; + const tx = (2 * rp * rm) / (rp + rm), ty = Y[i] + (Y[j] > Y[i] ? 1 : -1) * ((2 * rm * sqrt(rp * rm)) / (rp + rm)); + if (tx > W || ty < -1 || ty > H) continue; + const h = hash(pa ^ (Math.imul(pn, PP[j]) + Math.imul(pm, QQ[j])), qa ^ (Math.imul(qn, PP[j]) + Math.imul(qm, QQ[j]))); + const sn = sin(S.time * (1.1 + h * 1.7) + h * 40), g = (0.3 + 0.9 * sn ** 6 + fr) * smooth(1.2, 4, rm); + dot(buf, tx - 0.5, ty - 0.5, g * 1.6, 255, 245, 225); + } + } + // The target itself, a spark on the line. + dot(buf, 0.25, TROW, 1.4, 255, 250, 235); + const f = S.fade; + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, min(255, buf[p] * f), min(255, buf[p + 1] * f), min(255, buf[p + 2] * f)); +} From 17c066ae42101868d24e378456c6ff202bf73806 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:52:10 +0000 Subject: [PATCH 07/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Aztec=20Diamond?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/aztec-diamond.js | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 incoming/pattern-study/patterns/aztec-diamond.js diff --git a/incoming/pattern-study/patterns/aztec-diamond.js b/incoming/pattern-study/patterns/aztec-diamond.js new file mode 100644 index 000000000..9ae02b4b3 --- /dev/null +++ b/incoming/pattern-study/patterns/aztec-diamond.js @@ -0,0 +1,283 @@ +// ===== Patternflow pattern ===== +// Title: Aztec Diamond +// Family: cells +// Idea: Aztec diamonds tile the panel, two whole and six cut by its edges, and each grows by domino shuffling: every domino slides one LED out in its own direction, the holes fill with fresh pairs, and at full size the four corners have frozen into brick walls of one colour each while the dominoes churn on inside the arctic circle. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: domino shuffling on the Aztec diamond (Elkies, Kuperberg, Larsen and Propp, 1992) and the arctic circle theorem (Jockusch, Propp and Shor, 1995), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Bias=-1..1, Checker=-1..1 +// +// Knob 1: Hue. Turns the four domino colours round the wheel together. At 0 the north-going dominoes are orange, east green, south blue and west magenta; each quarter turn hands the same four colours on to the next direction, and in between come other sets, at 0.15 lime, cyan, violet and red. +// Knob 2: Speed. At 2 a diamond grows one order every 0.45 s, so order 1 to 32 takes 14 seconds, and a grown one churns for 15 seconds (the six cut ones for 40 to 60) before it fades and grows again; at 0.2 it all runs ten times slower. +// Knob 3: Bias. The odds that a fresh 2x2 block gets two horizontal dominoes rather than two vertical ones. At 0 they are even and every arctic boundary is a circle. Turned right, the two whole diamonds lean horizontal (0.9 at 1) and their circles flatten into wide ellipses while the six round them lean vertical into tall ones; turned left it goes the other way. +// Knob 4: Checker. How dark the dark squares of the checkerboard under the tiling burn; every domino covers one light and one dark square. At -1 both burn alike and the dominoes merge into flat fields of colour, at the start the dark squares burn at 60%, and at 1 at a quarter, so each domino shows as one bright LED. +// Buttons: K1 tap fades every diamond and grows all eight again from order 1 at once; after that the bottom diamond waits half a cycle, so the two whole ones take turns again. + +const W = 64, H = 128, NP = W * H; +const { min, floor, abs, random } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ease = (u) => u * u * (3 - 2 * u); + +// The four kinds of domino, by orientation and by the colour of the +// checkerboard square under their first cell: 0 north, 1 east, 2 south, +// 3 west. At order k a horizontal domino whose left cell is (x, y) goes north +// when x + y + k is odd and south when it is even; a vertical one goes west +// when odd and east when even. +const kind = (x, y, v, k) => ((x + y + k) & 1 ? (v ? 3 : 0) : v ? 1 : 2); +const STEP = [[0, -1], [1, 0], [0, 1], [-1, 0]]; + +// Aztec diamonds tile the plane. Two of order 32 stand whole on the panel, +// one above the other; the gaps round them are diamonds of order 31, halved +// by the sides and quartered by the corners. Those grow only to order 30, so +// a one-LED diagonal of dark runs between every two diamonds. +// [centre x, centre y, order, hold in seconds at Speed 2, clock at the start, order at the start] +const LAYOUT = [ + [32, 32, 32, 15, -21, 32], + [32, 96, 32, 15, 0, 18], + [0, 64, 30, 50, 39, 30], + [64, 64, 30, 50, 25, 30], + [0, 0, 30, 50, 4, 30], + [64, 0, 30, 50, 32, 30], + [0, 128, 30, 50, 18, 30], + [64, 128, 30, 50, 11, 30], +]; + +// A diamond keeps its dominoes as lists (left or top cell, vertical or not, +// grain) and two pictures of its cells to fade between: per cell 0 for empty +// or 1 + the kind of the domino on it, and that domino's grain, 204 to 255. +function makeDiamond([cx, cy, N, hold]) { + const F = 2 * N, M = N * (N + 1) + 4, C = F * F; + return { + cx, cy, N, F, hold, k: 0, n: 0, p: 0.5, wait: 0, + dx: new Int16Array(M), dy: new Int16Array(M), dv: new Uint8Array(M), dg: new Uint8Array(M), + grid: new Int16Array(C).fill(-1), + ft: new Uint8Array(C), fg: new Uint8Array(C), tt: new Uint8Array(C), tg: new Uint8Array(C), + stage: 0, sub: 1, u: 1, dur: 0.1, clock: 0, + }; +} + +function regrid(D) { + const g = D.grid, F = D.F; + g.fill(-1); + for (let i = 0; i < D.n; i++) { + const c = D.dx[i] + D.dy[i] * F; + g[c] = i; + g[D.dv[i] ? c + F : c + 1] = i; + } +} + +function add(D, x, y, v) { + const i = D.n++; + (D.dx[i] = x), (D.dy[i] = y), (D.dv[i] = v), (D.dg[i] = 204 + floor(random() * 52)); + return i; +} + +// Shuffling, order k to k + 1, in three moves. First the bad blocks go: a +// south-going domino right above a north-going one, or an east-going one +// right left of a west-going one, would pass through each other. +function destroy(D) { + regrid(D); + const { dx, dy, dv, grid: g, k, F } = D; + const dead = new Uint8Array(D.n); + for (let i = 0; i < D.n; i++) { + const t = kind(dx[i], dy[i], dv[i], k), c = dx[i] + dy[i] * F; + if (t === 2) { + const j = g[c + F]; + if (j >= 0 && !dv[j] && dx[j] === dx[i] && dy[j] === dy[i] + 1) dead[i] = dead[j] = 1; + } else if (t === 1) { + const j = g[c + 1]; + if (j >= 0 && dv[j] && dx[j] === dx[i] + 1 && dy[j] === dy[i]) dead[i] = dead[j] = 1; + } + } + let m = 0; + for (let i = 0; i < D.n; i++) + if (!dead[i]) (dx[m] = dx[i]), (dy[m] = dy[i]), (dv[m] = dv[i]), (D.dg[m] = D.dg[i]), m++; + D.n = m; +} + +// Then every domino slides one cell in its own direction. +function slide(D) { + const { dx, dy, dv, k } = D; + for (let i = 0; i < D.n; i++) { + const s = STEP[kind(dx[i], dy[i], dv[i], k)]; + (dx[i] += s[0]), (dy[i] += s[1]); + } + D.k = k + 1; + regrid(D); +} + +// And the holes left in the bigger diamond, which always come as 2x2 +// blocks, each take a random pair: two horizontal with probability p, +// two vertical otherwise. +function create(D) { + const { grid: g, k, p, N, F } = D; + for (let y = N - k; y < N + k; y++) { + const r = k - abs(2 * y + 1 - F) / 2 + 0.5; + for (let x = N - r; x < N + r; x++) { + const c = x + y * F; + if (g[c] >= 0) continue; + if (random() < p) { + g[c] = g[c + 1] = add(D, x, y, 0); + g[c + F] = g[c + F + 1] = add(D, x, y + 1, 0); + } else { + g[c] = g[c + F] = add(D, x, y, 1); + g[c + 1] = g[c + F + 1] = add(D, x + 1, y, 1); + } + } + } +} + +// At full size the tiling keeps moving by flips: a 2x2 block covered by two +// parallel dominoes turns over to the other pair with the same odds as a +// fresh block. The weighted uniform measure stays put under it, so the +// frozen corners hold still and only the arctic circle churns. +function flips(D, tries) { + const { dx, dy, dv, grid: g, p, F } = D; + for (let t = 0; t < tries; t++) { + const x = floor(random() * (F - 1)), y = floor(random() * (F - 1)), c = x + y * F, a = g[c]; + if (a < 0 || dx[a] !== x || dy[a] !== y) continue; + const h = random() < p; + if (!dv[a]) { + const b = g[c + F]; + if (b < 0 || dv[b] || dx[b] !== x || dy[b] !== y + 1 || h) continue; + (dv[a] = dv[b] = 1), (dx[b] = x + 1), (dy[b] = y); + (g[c + F] = a), (g[c + 1] = b); + } else { + const b = g[c + 1]; + if (b < 0 || !dv[b] || dx[b] !== x + 1 || dy[b] !== y || !h) continue; + (dv[a] = dv[b] = 0), (dx[b] = x), (dy[b] = y + 1); + (g[c + 1] = a), (g[c + F] = b); + } + } +} + +// The picture a diamond fades to. +function paint(D) { + const { dx, dy, dv, dg, k, F, tt, tg } = D; + tt.fill(0); + for (let i = 0; i < D.n; i++) { + const t = kind(dx[i], dy[i], dv[i], k), c = dx[i] + dy[i] * F, c2 = dv[i] ? c + F : c + 1; + (tt[c] = tt[c2] = t + 1), (tg[c] = tg[c2] = dg[i]); + } +} + +function retire(D, dur) { + D.ft.set(D.tt), D.fg.set(D.tg); + (D.u = 0), (D.stage = 2), (D.dur = dur), (D.n = 0); + paint(D); +} + +// One move of a diamond's life. Growing, a step from order k to k + 1 takes +// two fades: the bad pairs go out while every other domino slides, then the +// holes fill. Grown, it flips blocks a batch at a time until its hold runs +// out, fades and grows again from order 1. +function begin(D, P) { + D.ft.set(D.tt), D.fg.set(D.tg); + D.u = 0; + const s = P.speed, T = 0.9 / s; + if (D.stage === 0) { + if (D.sub === 0) create(D), (D.sub = 1), (D.dur = 0.4 * T); + else if (D.k >= D.N) { + (D.stage = 1), (D.clock = -D.wait), (D.wait = 0); + return begin(D, P); + } else if (D.k === 0) (D.k = 1), regrid(D), create(D), (D.dur = 0.4 * T); + else destroy(D), slide(D), (D.sub = 0), (D.dur = 0.6 * T); + } else if (D.stage === 1) { + if (D.clock > (D.hold * 2) / s) { + if (D.N < 32) D.hold = 40 + 20 * random(); + return retire(D, 0.8); + } + flips(D, 500), (D.dur = 0.35 / s); + } else { + (D.stage = 0), (D.sub = 1), (D.k = 0), (D.n = 0); + return begin(D, P); + } + paint(D); +} + +// Which diamond and which of its cells each LED shows; -1 is the dark seam. +const OWN = new Int8Array(NP).fill(-1), CELL = new Int32Array(NP); +LAYOUT.forEach(([cx, cy, N], d) => { + for (let y = 0; y < 2 * N; y++) + for (let x = 0; x < 2 * N; x++) { + const X = cx - N + x, Y = cy - N + y; + if (X < 0 || X >= W || Y < 0 || Y >= H || abs(2 * x + 1 - 2 * N) + abs(2 * y + 1 - 2 * N) > 2 * N) continue; + (OWN[X + Y * W] = d), (CELL[X + Y * W] = x + y * 2 * N); + } +}); + +export function setup(params) { + params.s = { fresh: true, speed: 2, hue: 0, dark: 0.4, D: LAYOUT.map(makeDiamond), col: new Float32Array(12) }; +} + +// The top diamond opens grown and churning, the bottom one at order 18 and +// still growing, the six round them grown and due to regrow one by one, +// the first after 11 seconds. They grow on the first frame, once Bias is known. +function opening(P) { + P.D.forEach((D, i) => { + begin(D, P); + while (D.k < LAYOUT[i][5] || (D.k >= D.N && D.stage === 0)) begin(D, P); + D.clock = LAYOUT[i][4]; + }); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + const bias = cl(k[2], -1, 1); + P.hue = k[0]; + P.speed = cl(k[1], 0.2, 3); + P.dark = 0.38 * (1 + cl(k[3], -1, 1)); + P.D.forEach((D, i) => (D.p = 0.5 + (i < 2 ? 0.4 : -0.4) * bias)); + if (P.fresh) (P.fresh = false), opening(P); + // K1: every diamond fades and grows again from order 1, all together; the + // bottom one then holds half a cycle longer, so the two take turns again. + if (input.btnPressed && input.btnPressed[0]) + P.D.forEach((D, i) => { + D.wait = ((i === 1 ? 15 : i > 1 ? 30 * random() : 0) * 2) / P.speed; + retire(D, 0.4); + }); + for (const D of P.D) { + D.clock += d; + D.u += d / D.dur; + if (D.u >= 1) begin(D, P); + } +} + +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]; + for (let c = 0; c < 3; c++) out[o + c] = rgb[c]; +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const col = P.col, sm = 1 - P.dark, E = P.D.map((D) => ease(cl(D.u, 0, 1))); + // Orange north, green east, blue south, magenta west at Hue 0. + for (let t = 0; t < 4; t++) hsv(P.hue + 0.1 + t * 0.25, 0.94, 1, col, t * 3); + for (let p = 0; p < NP; p++) { + const d = OWN[p], x = p % W, y = (p / W) | 0; + if (d < 0) { + display.setPixel(x, y, 0, 0, 0); + continue; + } + // The checkerboard stays put on the LEDs while the dominoes slide over it. + const D = P.D[d], c = CELL[p], a = D.ft[c], b = D.tt[c], e = E[d], lv = (x + y) & 1 ? sm : 1; + let r = 0, g = 0, bl = 0; + if (a) { + const w = D.fg[c] * lv * (1 - e), q = (a - 1) * 3; + (r += w * col[q]), (g += w * col[q + 1]), (bl += w * col[q + 2]); + } + if (b) { + const w = D.tg[c] * lv * e, q = (b - 1) * 3; + (r += w * col[q]), (g += w * col[q + 1]), (bl += w * col[q + 2]); + } + display.setPixel(x, y, min(255, r), min(255, g), min(255, bl)); + } +} From f56a9a2bb35de18c99aeeec2f53fd125123791b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:52:55 +0000 Subject: [PATCH 08/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Rauzy=20Fractal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/rauzy-fractal.js | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 incoming/pattern-study/patterns/rauzy-fractal.js diff --git a/incoming/pattern-study/patterns/rauzy-fractal.js b/incoming/pattern-study/patterns/rauzy-fractal.js new file mode 100644 index 000000000..4ef591bbc --- /dev/null +++ b/incoming/pattern-study/patterns/rauzy-fractal.js @@ -0,0 +1,265 @@ +// ===== Patternflow pattern ===== +// Title: Rauzy Fractal +// Family: fractal +// Idea: The self-replicating tiling of the tribonacci fractal, copies of the fractal at three sizes, zooms for ever into its fixed point; each tile grows into the next size up, and a ring running out from the middle splits the biggest into their three pieces. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the Rauzy fractal of the tribonacci substitution (Gérard Rauzy, 1982), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Depth=1..4, Glow=0..1 +// +// Knob 1: Hue. Turns every colour round the wheel together. At 0 the young (smallest) tiles are magenta, the middle ones blue and the old ones cyan, and each tile's three pieces are three shades of its colour. +// Knob 2: Speed. How fast it zooms. At 2 the picture grows by 1/|beta| = 1.356 every five seconds, turning 4.7 degrees, and after three such levels it is the same picture again; at 0.2 a level takes fifty seconds, at 3 a little over three. +// Knob 3: Depth. How many levels of pieces a tile shows. At 1 only its own three pieces; each step up shades the pieces inside those pieces, four levels at 4. +// Knob 4: Glow. At 0 the seams between tiles are dark lines. Turning it up dims the tiles and lights the seams, until at 1 the fractal outlines glow pale on a dark ground. +// Buttons: K1 tap runs the domain exchange. The zoom stops, the three pieces of every tile slide along their own vectors and lock into the same outline in a new order, and a second later they take the colours of the pieces they now lie in. + +const W = 64, H = 128, N = W * H, CX = 32, CY = 64; +const { min, floor, ceil, sqrt, cos, sin, atan2, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sstep = (a, b, x) => { + const t = cl((x - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// The tribonacci constant TH, the real root of x^3 = x^2 + x + 1, and beta, +// one of the other two roots, |beta| = 1/sqrt(TH) = 0.7374. +let TH = 2; +for (let i = 0; i < 40; i++) TH -= (TH ** 3 - TH * TH - TH - 1) / (3 * TH * TH - 2 * TH - 1); +const BR = (1 - TH) / 2, BI = sqrt(1 / TH - BR * BR), BM = sqrt(BR * BR + BI * BI), BA = atan2(BI, BR); +// The substitution 1 -> 12, 2 -> 13, 3 -> 1 counts letters with the matrix +// M = [[1,1,1],[1,0,0],[0,1,0]]. A vector v of letter counts lands in the +// contracting plane as z(v) = v1 + v2 (beta - 1) + v3 / beta, and z(M v) is +// beta z(v). ZX, ZY are z of one letter 1, 2 or 3. +const ZX = [1, BR - 1, BR * TH], ZY = [0, BI, -BI * TH]; +// The lattice of z(v) with v1 + v2 + v3 = 0, spanned by z(e2 - e3), z(e3 - e1). +const AX = ZX[1] - ZX[2], AY = ZY[1] - ZY[2], BX = ZX[2] - 1, BY = ZY[2], DET = AX * BY - AY * BX; +// The height along the expanding direction, h(v) = TH v1 + (TH^2 - TH) v2 + v3, +// written as AL (v1 + v2 + v3) + HX Re z(v) + HY Im z(v). +const LH = [TH, TH * TH - TH, 1]; +const [AL, HX, HY] = (() => { + const m = [0, 1, 2].map((j) => [1, ZX[j], ZY[j]]); + const d3 = (a, b, c) => a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]); + const D = d3(m[0], m[1], m[2]), col = (k) => m.map((r, j) => r.map((v, q) => (q === k ? LH[j] : v))); + return [0, 1, 2].map((k) => { const c = col(k); return d3(c[0], c[1], c[2]) / D; }); +})(); + +// The fixed word 1213121121312... and where each prefix lands. A torus cell +// (the plane modulo the lattice, G x G cells) remembers the letter after a +// prefix landing in it and, two bits a letter, the letters of its ancestors +// under the substitution: the piece, then the piece of the piece, sixteen +// deep. KA, KB say which copy of the fractal the prefix fell in. +const G = 256, GM = G - 1, NW = 160000; +const CH = new Int32Array(G * G), KA = new Int8Array(G * G), KB = new Int8Array(G * G); +let HR0 = 1e9, HR1 = -1e9, MX = 0, MY = 0; +{ + const ch = new Int32Array(NW); + let n = 0; + for (let m = 0; n < NW; m++) { + const a = ch[m] & 3, up = ch[m] << 2; + ch[n++] = up; + if (a < 2 && n < NW) ch[n++] = up | (a + 1); + } + let x = 0, y = 0; + for (let i = 0; i < NW; i++) { + const a = (x * BY - y * BX) / DET, b = (AX * y - AY * x) / DET, fa = floor(a), fb = floor(b); + const c = min(GM, floor((a - fa) * G)) + min(GM, floor((b - fb) * G)) * G; + CH[c] = ch[i]; + KA[c] = fa; + KB[c] = fb; + const hr = HX * x + HY * y; + if (hr < HR0) HR0 = hr; + if (hr > HR1) HR1 = hr; + const l = ch[i] & 3; + MX += x / NW; + MY += y / NW; + x += ZX[l]; + y += ZY[l]; + } +} + +// The middle of R is MX, MY; R_1 = beta R, R_2 = beta^2 R + 1 and +// R_3 = beta^3 R + beta + 1, and CEN holds their middles. +const CEN = []; +{ + let x = MX, y = MY; + for (let i = 0; i < 3; i++) { + [x, y] = [BR * x - BI * y, BI * x + BR * y]; + CEN.push(x + (i > 0 ? 1 : 0) + (i > 1 ? BR : 0), y + (i > 1 ? BI : 0)); + } +} + +// The self-replicating tiling: a copy z(v) + R_i of piece i for every +// v in Z^3 with 0 <= h(v) < h(e_i). The pieces are R scaled by beta, beta^2 +// and beta^3, so every tile is the fractal itself at one of three sizes. +// For a point p, the copy of piece i holding it has z(v) = p - r with r in +// R, and only two or three counts v1 + v2 + v3 = k can pass the height test. +let TCH = 0, TID = 0, TX = 0, TY = 0; +function tile(px, py) { + const cp = HX * px + HY * py, k1 = (HR1 - cp + LH[0]) / AL; + for (let k = ceil((HR0 - cp) / AL - 1e-6); k <= k1; k++) { + const qx = px - k, a = (qx * BY - py * BX) / DET, b = (AX * py - AY * qx) / DET; + const fa = floor(a), fb = floor(b), c = (floor((a - fa) * G) & GM) + (floor((b - fb) * G) & GM) * G; + const ta = fa - KA[c], tb = fb - KB[c], ch = CH[c], t = ch & 3; + const v1 = k - tb, v2 = ta, v3 = tb - ta, h = LH[0] * v1 + LH[1] * v2 + v3; + if (h >= 0 && h < LH[t]) { + TCH = ch; + TID = ((v1 * 1024 + v2) * 1024 + v3) * 4 + t; + TX = v1 * ZX[0] + v2 * ZX[1] + v3 * ZX[2] + CEN[t * 2]; + TY = v1 * ZY[0] + v2 * ZY[1] + v3 * ZY[2] + CEN[t * 2 + 1]; + return t; + } + } + return -1; +} + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + for (let c = 0; c < 3; c++) out[o + c] = rgb[c] * 255; +} + +// A tile's colour follows its age. The smallest tiles (copies of piece 3) +// are young, piece 2 middle-aged, piece 1 old; a level of zoom later each +// has grown into the next, and the old ones have split into three. Hue, +// saturation and value at ages 0, 1 and 2; LUT holds them from age -1 to 2.5 +// for each of a tile's own three pieces, which turn the hue by PH and light +// it by PV. +const AGE = [ + [0.86, 0.8, 0.85], + [0.68, 0.85, 0.95], + [0.5, 0.9, 1], +]; +const PH = [0, 0.035, -0.03], PV = [1, 0.7, 0.48]; +const LN = 71, LA = -1, LS = 0.05; + +// The domain exchange: piece j of R moves by z(e_j), and the three land in +// R again. A tile of type t is R scaled by beta^(t+1), so its pieces move by +// beta^(t+1) z(e_j), EV[t*6 + j*2]. +const EV = []; +{ + let x = 1, y = 0; + for (let t = 0; t < 3; t++) { + [x, y] = [BR * x - BI * y, BI * x + BR * y]; + for (let j = 0; j < 3; j++) EV.push(x * ZX[j] - y * ZY[j], x * ZY[j] + y * ZX[j]); + } +} + +// LEDs a unit of the plane at the start of a level, the turn of the view per +// level (a turn of beta less a third of a full turn, so three levels close +// the loop), how far the splitting ring runs in a level, and the exchange's +// slide, hold and settle in seconds. +const S0 = 24, ROT = BA - (2 * PI) / 3, RING = 140, EX1 = 1.5, EX2 = 2.3, EXT = 3.3; + +export function setup(params) { + params.s = { m: 0, f: 0, ex: -1, hue: 0, depth: 1, glow: 0.06, id: new Float64Array(N), buf: new Float32Array(N * 3), lut: new Float32Array(3 * LN * 3) }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.depth = cl(k[2], 1, 4); + P.glow = cl(k[3], 0, 1); + if (P.ex >= 0) { + P.ex += d; + if (P.ex > EXT) P.ex = -1; + } else { + P.f += d * cl(k[1], 0.2, 3) * 0.1; + if (P.f >= 1) (P.f -= 1), (P.m = (P.m + 1) % 3); + if (input.btnPressed && input.btnPressed[0]) P.ex = 0; + } +} + +// The light of a point from the pieces it lies in, level by level from +// letter kk down; a piece of type s splits again s + 1 letters lower. +function tone(ch, kk, D) { + let v = 1; + for (let n = 2; n <= 5 && kk < 16; n++) { + const s = (ch >> (2 * kk)) & 3, w = cl(D + 1 - n, 0, 1) * 0.5; + v *= 1 - w * (1 - PV[s]); + kk += 1 + s; + } + return v; +} + +// The colour of the plane point (px, py) into CR, CG, CB, and its tile (and +// once split, its piece) into CID. Returns the tile's type, or -1 on the +// rare point the table cannot place. +let CR = 0, CG = 0, CB = 0, CID = 0; +function shade(px, py, f, ring, sc, D, lut) { + const t = tile(px, py); + if (t < 0) return -1; + const ch = TCH, s1 = (ch >> (2 * t + 2)) & 3; + let a = t ? 2 - t + f : 2, v = tone(ch, t + 2 + s1, D), li = (s1 * LN + round((a - LA) / LS)) * 3; + CID = TID * 4; + if (t === 0) { + // An old tile splits as the ring passes its middle: each piece takes + // the colour of the young tile it becomes, with a flash. + const sp = cl((ring - sqrt(TX * TX + TY * TY) / sc) / 16, 0, 1); + if (sp > 0) { + const s2 = (ch >> (4 + 2 * s1)) & 3, a2 = 1 - s1 + f, v2 = tone(ch, 3 + s1 + s2, D) * (1 + 1.4 * sp * (1 - sp)); + const l2 = (s2 * LN + cl(round((a2 - LA) / LS), 0, LN - 1)) * 3, u = 1 - sp; + CR = (lut[li] * v * u + lut[l2] * v2 * sp); + CG = (lut[li + 1] * v * u + lut[l2 + 1] * v2 * sp); + CB = (lut[li + 2] * v * u + lut[l2 + 2] * v2 * sp); + if (sp > 0.5) CID += s1 + 1; + return 0; + } + } + CR = lut[li] * v; + CG = lut[li + 1] * v; + CB = lut[li + 2] * v; + return t; +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const f = P.f, sc = BM ** f / S0, an = ROT * f - ((2 * PI) / 3) * P.m, co = cos(an) * sc, si = sin(an) * sc; + const ID = P.id, buf = P.buf, lut = P.lut, D = P.depth, ring = (f - 0.04) * RING; + for (let s = 0; s < 3; s++) + for (let i = 0; i < LN; i++) { + const a = cl(LA + i * LS, 0, 2), j = min(1, floor(a)), u = a - j, A = AGE[j], B = AGE[j + 1], young = cl(1 + (LA + i * LS) * 0.4, 0.6, 1); + hsv(P.hue + A[0] + (B[0] - A[0]) * u + PH[s], A[1] + (B[1] - A[1]) * u, (A[2] + (B[2] - A[2]) * u) * young * PV[s], lut, (s * LN + i) * 3); + } + // While the pieces slide, each LED looks for a piece that has arrived on + // it: a point q of a type-t tile in piece j with q + slide = p. + const ex = P.ex, sl = ex < 0 ? 0 : sstep(0, EX1, ex), back = ex < 0 ? 1 : sstep(EX2, EXT, ex); + for (let p = 0, y = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + const X = x + 0.5 - CX, Y = y + 0.5 - CY, px = co * X - si * Y, py = si * X + co * Y, o = p * 3; + let r = 0, g = 0, b = 0, id = -1; + if (back < 1) + for (let e = 0; e < 18 && id < 0; e += 2) { + const t = (e / 6) | 0; + if (shade(px - sl * EV[e], py - sl * EV[e + 1], f, ring, sc, D, lut) === t && ((TCH >> (2 * t + 2)) & 3) === (e % 6) / 2) + (r = CR), (g = CG), (b = CB), (id = TID * 32 + 1 + e); + } + if (back > 0 && (shade(px, py, f, ring, sc, D, lut) >= 0 || shade(px + 0.3 * sc, py, f, ring, sc, D, lut) >= 0)) { + r += (CR - r) * back; + g += (CG - g) * back; + b += (CB - b) * back; + if (back > 0.5) id = CID; + } + ID[p] = id; + buf[o] = r; + buf[o + 1] = g; + buf[o + 2] = b; + } + // A seam between tiles: dark at Glow 0, pale light at 1. + const gl = P.glow, dim = 1 - 0.55 * gl; + for (let p = 0, y = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + const s = (x < W - 1 && ID[p + 1] !== ID[p]) || (y < H - 1 && ID[p + W] !== ID[p]), o = p * 3; + for (let c = 0; c < 3; c++) { + const v = min(255, buf[o + c]) * dim; + buf[o + c] = s ? v * (0.12 + 0.3 * gl) + 170 * gl : v; + } + } + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, buf[p], buf[p + 1], buf[p + 2]); +} From c8cc5e2e6a97f9a186fe248aeea6f39c6264b3ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:54:31 +0000 Subject: [PATCH 09/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Hairy=20Ball?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/hairy-ball.js | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 incoming/pattern-study/patterns/hairy-ball.js diff --git a/incoming/pattern-study/patterns/hairy-ball.js b/incoming/pattern-study/patterns/hairy-ball.js new file mode 100644 index 000000000..c3c82bf14 --- /dev/null +++ b/incoming/pattern-study/patterns/hairy-ball.js @@ -0,0 +1,291 @@ +// ===== Patternflow pattern ===== +// Title: Hairy Ball +// Family: topology +// Idea: A hairy sphere above a hairy doughnut, both turning in the light: however the sphere is combed, one or two cowlicks glow where its hair has nowhere to point, while the doughnut's hair lies smooth all the way round, and the small 2 and 0 are their Euler characteristics. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the hairy ball theorem (Henri Poincaré 1885, L. E. J. Brouwer 1912) and the Poincaré–Hopf index theorem (Heinz Hopf, 1926), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Length=8..13, Comb=-1..1 +// +// Knob 1: Hue. Turns the blue sphere, the teal doughnut and the gold cowlicks round the colour wheel together. +// Knob 2: Speed. At 2 the sphere turns once in about 10 seconds and is combed again every 7, with two cowlicks far apart, then close together, then merged into one of index 2; at 0.2 everything runs ten times slower. +// Knob 3: Length. The hair length in LEDs, 8 to 13, each hair a little longer or shorter; long hair lies flatter. +// Knob 4: Comb. How the sphere's hair runs round its cowlicks: at -1 straight out of one and into the other, at 0 in a whorl, at 1 round and round them. +// Buttons: K1 tap combs the sphere and the doughnut again now; the hair sweeps to a new direction and the cowlicks jump somewhere else. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, ceil, sqrt, exp, cos, sin, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The sphere sits in the top half, the doughnut in the bottom half. +const SX = 32, SY = 31, RS = 24.5; +const TX = 32, TY = 97, TR = 18, TQ = 8.2; +// Roots spread evenly at the same density on both: the doughnut's area +// 4 pi^2 R r against the sphere's 4 pi R^2. +const NS = 1100, NT = round((NS * PI * TR * TQ) / (RS * RS)); + +// Fixed grain: a seeded generator used once, so the roots never move on the skin. +let seed = 0x9e3779b9; +function rnd() { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +// Sphere roots on a jittered Fibonacci spiral, unit vectors. +const SP = new Float32Array(NS * 3), SJ = new Float32Array(NS * 2); +for (let i = 0; i < NS; i++) { + const y = 1 - (2 * (i + 0.2 + 0.6 * rnd())) / NS, r = sqrt(max(0, 1 - y * y)), a = i * 2.39996323 + (rnd() - 0.5) * 0.9 * (2 / sqrt(NS)) / max(r, 0.05); + SP[i * 3] = r * cos(a); + SP[i * 3 + 1] = y; + SP[i * 3 + 2] = r * sin(a); + SJ[i * 2] = 0.8 + 0.35 * rnd(); + SJ[i * 2 + 1] = 0.75 + 0.4 * rnd(); +} + +// Doughnut roots: u round the ring, v round the tube, v drawn with density +// R + r cos v so the hair is as thick inside the hole as outside. +const TP = new Float32Array(NT * 3), TN = new Float32Array(NT * 3), TU = new Float32Array(NT * 3), TV = new Float32Array(NT * 3), TJ = new Float32Array(NT * 2); +for (let i = 0; i < NT; i++) { + const u = 2 * PI * ((i * 0.7548776662 + 0.3 * rnd() / NT * 40) % 1), F = (i * 0.5698402910 + 0.5 + (rnd() - 0.5) * 0.02) % 1; + let v = 2 * PI * F; + for (let k = 0; k < 8; k++) v -= (TR * v + TQ * sin(v) - 2 * PI * TR * F) / (TR + TQ * cos(v)); + const cu = cos(u), su = sin(u), cv = cos(v), sv = sin(v), rr = TR + TQ * cv, o = i * 3; + TP[o] = rr * cu, TP[o + 1] = rr * su, TP[o + 2] = TQ * sv; + TN[o] = cv * cu, TN[o + 1] = cv * su, TN[o + 2] = sv; + TU[o] = -su, TU[o + 1] = cu, TU[o + 2] = 0; + TV[o] = -sv * cu, TV[o + 1] = -sv * su, TV[o + 2] = cv; + TJ[i * 2] = 0.8 + 0.35 * rnd(); + TJ[i * 2 + 1] = 0.75 + 0.4 * rnd(); +} + +// 3x3 rotations, row major. +const mul = (A, B) => { + const C = new Array(9); + for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) C[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c]; + return C; +}; +const rx = (a) => [1, 0, 0, 0, cos(a), -sin(a), 0, sin(a), cos(a)]; +const ry = (a) => [cos(a), 0, sin(a), 0, 1, 0, -sin(a), 0, cos(a)]; +const rz = (a) => [cos(a), -sin(a), 0, sin(a), cos(a), 0, 0, 0, 1]; + +// Light from the upper left, in front; H halfway to the eye. +const LX = -0.45 / 0.996, LY = -0.55 / 0.996, LZ = 0.7 / 0.996; +const HN = Math.hypot(LX, LY, LZ + 1), HX = LX / HN, HY = LY / HN, HZ = (LZ + 1) / HN; + +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]; + for (let c = 0; c < 3; c++) out[o + c] = rgb[c]; +} + +// The combs the sphere goes through: how far apart its two cowlicks stand +// (1 opposite each other, 0 merged into one zero of index 2) and the angle +// of the doughnut's hair (0 along the ring, pi/2 along the tube). +const PROG = [[0.55, 0.12], [0, 0.85], [1, -0.55], [0.3, 0.4], [0, -0.9], [1, 0.25]]; + +// The sphere's body turns about its own vertical axis, tipped toward the +// eye; the field turns inside it on its own, from the angles a. +const bodyM = (T) => mul(rx(-0.42 + 0.12 * sin(T * 0.21)), ry(T * 0.6)); +const fieldM = (a, T) => mul(rz(a[0] + T * 0.09), mul(rx(a[1] + 0.5 * sin(T * 0.05 + 1)), rz(a[2]))); +// The two zeros in field coordinates sit at w = +-t on the stereographic +// plane, symmetric about the south pole; t = 1 puts them opposite. Out +// come both as unit vectors in M's frame. +function zeros(M, t) { + const t2 = t * t, Z = []; + for (const sg of [1, -1]) { + const fx = (2 * sg * t) / (1 + t2), fz = (t2 - 1) / (1 + t2); + Z.push(M[0] * fx + M[2] * fz, M[3] * fx + M[5] * fz, M[6] * fx + M[8] * fz); + } + return Z; +} +// A new comb is turned so that a cowlick faces the eye for most of the +// seven units it lasts, both of them when they stand close enough. +function aim(T, sep, from) { + let best = -9, pick = from; + for (let c = 0; c < 40; c++) { + const a = from.map((v) => v + (Math.random() < 0.5 ? -1 : 1) * (0.9 + 1.3 * Math.random())); + let lo = 9, sum = 0; + for (let j = 0; j < 5; j++) { + const Tj = T + 0.8 + j * 1.5, Z = zeros(mul(bodyM(Tj), fieldM(a, Tj)), sep), v = max(Z[2], Z[5]) + 0.5 * min(Z[2], Z[5]); + (lo = min(lo, v)), (sum += v); + } + const score = lo + sum / 5; + if (score > best) (best = score), (pick = a); + } + return pick; +} + +// 3x5 digits, one row of three bits per line, top first. +const DIG2 = [7, 1, 7, 4, 7], DIG0 = [7, 5, 5, 5, 7]; +function digit(buf, rows, x0, y0, c) { + for (let j = 0; j < 5; j++) + for (let i = 0; i < 3; i++) + if ((rows[j] >> (2 - i)) & 1) { + const q = ((y0 + j) * W + x0 + i) * 3; + for (let k = 0; k < 3; k++) buf[q + k] = 40 + 120 * c[k]; + } +} + +export function setup(params) { + const a = aim(-0.8, PROG[0][0], [0, 0, 0]); + params.s = { + T: 0, clock: 0, step: 0, a: a.slice(), ta: a, + sep: PROG[0][0], al: PROG[0][1], + hue: 0, len: 8, th: PI / 4, + buf: new Float32Array(N * 3), zb: new Float32Array(N), zh: new Float32Array(N), + cs: new Float32Array(3), ct: new Float32Array(3), cg: new Float32Array(3), + }; +} + +function recomb(P) { + P.step = (P.step + 1) % PROG.length; + P.clock = 0; + P.ta = aim(P.T, PROG[P.step][0], P.ta); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.len = cl(k[2], 8, 13); + P.th = (PI / 4) * (1 + cl(k[3], -1, 1)); + const dT = d * cl(k[1], 0.2, 3) * 0.5; + P.T += dT; + P.clock += dT; + if (input.btnPressed && input.btnPressed[0]) recomb(P); + else if (P.clock > 7) recomb(P); + const e = 1 - exp(-max(dT, d * 0.5) * 2.4), g = PROG[P.step]; + for (let i = 0; i < 3; i++) P.a[i] += (P.ta[i] - P.a[i]) * e; + P.sep += (g[0] - P.sep) * e; + P.al += (g[1] - P.al) * e; +} + +// One hair: root (x, y, z) on screen, direction (hx, hy, hz) times its +// length, colour (r, g, b) at the tip; the root end is darker. +function hair(P, x, y, z, hx, hy, hz, r, g, b) { + const n = max(2, ceil(Math.hypot(hx, hy) * 1.4) + 1), buf = P.buf, zb = P.zb, zh = P.zh; + for (let j = 0; j < n; j++) { + const s = j / (n - 1), X = floor(x + hx * s), Y = floor(y + hy * s); + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const p = Y * W + X, Z = z + hz * s; + if (Z < zb[p] - 1.2 || Z <= zh[p]) continue; + zh[p] = Z; + const m = 0.22 + 0.78 * s * s, q = p * 3; + buf[q] = r * m, buf[q + 1] = g * m, buf[q + 2] = b * m; + } +} + +// Hair light after Kajiya and Kay: a fibre is lit by how square it lies to +// the light and shines where it lies square to the half vector. +function shade(tx, ty, tz, nx, ny, nz) { + const tl = tx * LX + ty * LY + tz * LZ, th = tx * HX + ty * HY + tz * HZ, nl = nx * LX + ny * LY + nz * LZ; + const lam = 0.22 + 0.78 * max(0, nl), dif = sqrt(max(0, 1 - tl * tl)), sp = (1 - th * th) ** 12; + return [lam * (0.35 + 0.65 * dif), 0.75 * sp * max(0, nl + 0.2)]; +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const buf = P.buf, zb = P.zb, zh = P.zh, cs = P.cs, ct = P.ct, cg = P.cg, T = P.T; + buf.fill(0); + zb.fill(-1e9); + zh.fill(-1e9); + hsv(P.hue + 0.6, 0.75, 1, cs, 0); + hsv(P.hue + 0.47, 0.8, 1, ct, 0); + hsv(P.hue + 0.12, 0.6, 1, cg, 0); + const pulse = 0.85 + 0.15 * sin(T * 3.1); + + // The sphere. + const Mb = bodyM(T), Rf = fieldM(P.a, T), M = mul(Mb, Rf), t = P.sep, t2 = t * t, Z = zeros(M, t); + // Skin: dark, with the cowlicks glowing through. + const R2 = RS * RS; + for (let y = max(0, floor(SY - RS)); y <= min(H - 1, ceil(SY + RS)); y++) + for (let x = max(0, floor(SX - RS)); x <= min(W - 1, ceil(SX + RS)); x++) { + const dx = x + 0.5 - SX, dy = y + 0.5 - SY, rr = dx * dx + dy * dy; + if (rr >= R2) continue; + const dz = sqrt(R2 - rr), nx = dx / RS, ny = dy / RS, nz = dz / RS, p = y * W + x, q = p * 3; + zb[p] = dz; + const lam = 0.08 + 0.22 * max(0, nx * LX + ny * LY + nz * LZ); + const gl = exp(-(1 - (nx * Z[0] + ny * Z[1] + nz * Z[2])) / 0.05) + exp(-(1 - (nx * Z[3] + ny * Z[4] + nz * Z[5])) / 0.05); + for (let c = 0; c < 3; c++) buf[q + c] = 255 * (cs[c] * lam + cg[c] * min(1, gl) * 0.9 * pulse); + } + // The field: v(w) = e^{i theta} (w^2 - t^2) on the plane, carried back to + // the sphere. Its only zeros are w = t and w = -t, each of index 1; at + // t = 0 they merge into one zero of index 2. There is always one. + const kc = cos(P.th), ks = sin(P.th), L = P.len, lb = 0.35 - 0.02 * (L - 8); + for (let i = 0; i < NS; i++) { + const o = i * 3, bx = SP[o], by = SP[o + 1], bz = SP[o + 2]; + const nx = Mb[0] * bx + Mb[1] * by + Mb[2] * bz, ny = Mb[3] * bx + Mb[4] * by + Mb[5] * bz, nz = Mb[6] * bx + Mb[7] * by + Mb[8] * bz; + if (nz < -0.3) continue; + // Into field coordinates: f = Rf^T b. + const fx = Rf[0] * bx + Rf[3] * by + Rf[6] * bz, fy = Rf[1] * bx + Rf[4] * by + Rf[7] * bz, fz = Rf[2] * bx + Rf[5] * by + Rf[8] * bz; + const den = max(1e-7, 1 - fz), a = fx / den, b = fy / den, re = a * a - b * b - t2, im = 2 * a * b; + const va = kc * re - ks * im, vb = ks * re + kc * im, s = 1 + a * a + b * b, qq = (a * va + b * vb) / s; + let gx = va - 2 * a * qq, gy = vb - 2 * b * qq, gz = 2 * qq; + const gn = Math.hypot(gx, gy, gz) || 1; + gx /= gn, gy /= gn, gz /= gn; + // Field direction on screen. + const dx = M[0] * gx + M[1] * gy + M[2] * gz, dy = M[3] * gx + M[4] * gy + M[5] * gz, dz = M[6] * gx + M[7] * gy + M[8] * gz; + // Near a zero the hair stands up and glows. + const gl = min(1, exp(-(1 - (nx * Z[0] + ny * Z[1] + nz * Z[2])) / 0.07) + exp(-(1 - (nx * Z[3] + ny * Z[4] + nz * Z[5])) / 0.07)); + const lift = lb + 0.8 * gl, cL = cos(lift), sL = sin(lift); + const hx = dx * cL + nx * sL, hy = dy * cL + ny * sL, hz = dz * cL + nz * sL, len = L * SJ[i * 2 + 1]; + const [li, sp] = shade(hx, hy, hz, nx, ny, nz), br = 255 * SJ[i * 2] * li, w = 255 * sp, gg = 255 * gl * pulse; + hair(P, SX + RS * nx, SY + RS * ny, RS * nz, hx * len, hy * len, hz * len, + cs[0] * br + w + cg[0] * gg, cs[1] * br + w + cg[1] * gg, cs[2] * br + w + cg[2] * gg); + } + + // The doughnut, seen from about 40 degrees above, spinning on its + // own axis and swaying. Its hair lies along cos(al) e_u + sin(al) e_v: no + // zero anywhere, which the torus allows because its Euler characteristic is 0. + const Mt = mul(ry(0.3 * sin(T * 0.13)), mul(rx(0.84 + 0.08 * sin(T * 0.17)), rz(T * 0.5))); + const RO = TR + TQ, ox = Mt[6], oy = Mt[7], oz = Mt[8]; + for (let y = max(0, floor(TY - RO)); y <= min(H - 1, ceil(TY + RO)); y++) + for (let x = max(0, floor(TX - RO)); x <= min(W - 1, ceil(TX + RO)); x++) { + const dx = x + 0.5 - TX, dy = y + 0.5 - TY; + if (dx * dx + dy * dy >= RO * RO) continue; + // March a ray into the doughnut's own coordinates. + const z0 = RO + 1; + const px0 = Mt[0] * dx + Mt[3] * dy + Mt[6] * z0, py0 = Mt[1] * dx + Mt[4] * dy + Mt[7] * z0, pz0 = Mt[2] * dx + Mt[5] * dy + Mt[8] * z0; + let tt = 0, hit = false, px = 0, py = 0, pz = 0; + for (let it = 0; it < 40 && tt < 2 * z0; it++) { + px = px0 - ox * tt, py = py0 - oy * tt, pz = pz0 - oz * tt; + const qd = sqrt(px * px + py * py) - TR, dd = sqrt(qd * qd + pz * pz) - TQ; + if (dd < 0.03) { + hit = true; + break; + } + tt += dd; + } + if (!hit) continue; + const rl = sqrt(px * px + py * py) || 1, bx = (px - (TR * px) / rl) / TQ, by = (py - (TR * py) / rl) / TQ, bz = pz / TQ; + const nx = Mt[0] * bx + Mt[1] * by + Mt[2] * bz, ny = Mt[3] * bx + Mt[4] * by + Mt[5] * bz, nz = Mt[6] * bx + Mt[7] * by + Mt[8] * bz; + const p = y * W + x, q = p * 3, lam = 0.08 + 0.22 * max(0, nx * LX + ny * LY + nz * LZ); + zb[p] = z0 - tt; + for (let c = 0; c < 3; c++) buf[q + c] = 255 * ct[c] * lam; + } + const ca = cos(P.al), sa = sin(P.al), cL = cos(lb), sL = sin(lb); + for (let i = 0; i < NT; i++) { + const o = i * 3, bnx = TN[o], bny = TN[o + 1], bnz = TN[o + 2]; + const nx = Mt[0] * bnx + Mt[1] * bny + Mt[2] * bnz, ny = Mt[3] * bnx + Mt[4] * bny + Mt[5] * bnz, nz = Mt[6] * bnx + Mt[7] * bny + Mt[8] * bnz; + const gx = ca * TU[o] + sa * TV[o], gy = ca * TU[o + 1] + sa * TV[o + 1], gz = ca * TU[o + 2] + sa * TV[o + 2]; + const dx = Mt[0] * gx + Mt[1] * gy + Mt[2] * gz, dy = Mt[3] * gx + Mt[4] * gy + Mt[5] * gz, dz = Mt[6] * gx + Mt[7] * gy + Mt[8] * gz; + const bx = TP[o], by = TP[o + 1], bz = TP[o + 2]; + const X = Mt[0] * bx + Mt[1] * by + Mt[2] * bz, Y = Mt[3] * bx + Mt[4] * by + Mt[5] * bz, Zz = Mt[6] * bx + Mt[7] * by + Mt[8] * bz; + const hx = dx * cL + nx * sL, hy = dy * cL + ny * sL, hz = dz * cL + nz * sL, len = L * TJ[i * 2 + 1]; + const [li, sp] = shade(hx, hy, hz, nx, ny, nz), br = 255 * TJ[i * 2] * li, w = 255 * sp; + hair(P, TX + X, TY + Y, Zz, hx * len, hy * len, hz * len, ct[0] * br + w, ct[1] * br + w, ct[2] * br + w); + } + + // The Euler characteristic beside each: 2 for the sphere, 0 for the torus. + digit(buf, DIG2, 59, 2, cs); + digit(buf, DIG0, 59, 121, ct); + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, min(255, buf[p]), min(255, buf[p + 1]), min(255, buf[p + 2])); +} From d6668a412d5f7b95edd20688641a025eddededf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:58:04 +0000 Subject: [PATCH 10/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Wythoff=20Nim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/wythoff-nim.js | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 incoming/pattern-study/patterns/wythoff-nim.js diff --git a/incoming/pattern-study/patterns/wythoff-nim.js b/incoming/pattern-study/patterns/wythoff-nim.js new file mode 100644 index 000000000..7d338b310 --- /dev/null +++ b/incoming/pattern-study/patterns/wythoff-nim.js @@ -0,0 +1,210 @@ +// ===== Patternflow pattern ===== +// Title: Wythoff Nim +// Family: algo +// Idea: The table of Sprague-Grundy values of Wythoff's game stands on its corner, the losing positions in gold along two golden-ratio rays, and a sweep lights every position of value 0, then 1, then 2 and on, so each value flashes along its own pair of rays and fades back into the table. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Wythoff's game (Willem Abraham Wythoff, 1907) and its Sprague-Grundy values (Roland Sprague 1935, Patrick Grundy 1939), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Zoom=1..4, Glow=0..1 +// +// Knob 1: Hue. Turns every colour round the wheel together. At 0 the positions of value 0 are gold, the small values round them crimson and the large ones navy going to teal; at 0.25 the zeros are green on red and purple. +// Knob 2: Speed. How fast the sweep climbs through the values; it lingers on the small ones and hurries through the large. At 2 it lights value 1 after 0.4 s, value 10 after 3 s and value 50 after 8 s, and at Zoom 1 it reaches 102, the largest value on the panel, after 11 s and starts again from 0. At 0.2 it all takes ten times as long. The game slows down only by half. +// Knob 3: Zoom. LEDs to a unit of the table, 1 to 4 in whole steps. At 1 the panel holds every position of up to 126 counters, a cell to each pair of LEDs; at 4 it holds the corner up to about 30 counters as diamonds eight LEDs across with a dark line between them, and the zeros can be counted along their rays. +// Knob 4: Glow. How much of the light comes from the sweep. At 0 the whole table shows in its colours and the sweep runs over it as sparks; at 1 the table stands dark but for its zeros, and each position lights in its own colour when the sweep reaches its value and fades over about two seconds. +// Buttons: K1 tap drops a counter, a white diamond, low on the table and plays the game out to the corner: the winner's moves, each onto a position of value 0, light up gold, the loser's blue. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, round, abs, exp, random } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// Two heaps of x and y counters. A move takes any number from one heap, or +// the same number from both; whoever takes the last counter wins. A +// position's Grundy value is the smallest number not among the values one +// move away: left along its row, up its column, up its diagonal. The +// positions of value 0 are the ones to move to: (floor(n phi), +// floor(n phi^2)) and their mirror images, on two rays of slopes phi and +// 1/phi. Every other value gathers along the same two rays, wobbling off +// them without ever falling into a period (Dress, Flammenkamp and Pink, +// 1999), and down the middle the values grow like x + y. +const T = 82; +const G = new Int16Array(T * T); +let GALL = 0; +{ + const mark = new Int32Array(4 * T); + for (let y = 0, id = 1; y < T; y++) + for (let x = 0; x < T; x++, id++) { + for (let i = 0; i < x; i++) mark[G[y * T + i]] = id; + for (let j = 0; j < y; j++) mark[G[j * T + x]] = id; + for (let d = 1; d <= min(x, y); d++) mark[G[(y - d) * T + x - d]] = id; + let m = 0; + while (mark[m] === id) m++; + G[y * T + x] = m; + GALL = max(GALL, m); + } +} +// The cells of each value, for the sweep. +const BY = Array.from({ length: GALL + 1 }, () => []); +for (let c = 0; c < T * T; c++) BY[G[c]].push(c); + +// The table stands on its corner: position (x, y) is drawn at u = x - y +// across and v = x + y down, s LEDs to a unit, with (0, 0) at the top. +// Taking from one heap moves up and to the side at 45 degrees; taking from +// both moves straight up, and the two rays run down from the corner 13 +// degrees either side of the middle. At Zoom 1 a cell is the LED at its +// centre and the one above it. +function place(P, s) { + P.s = s; + P.vis = 0; + const top = 0.5 + s; + for (let p = 0; p < N; p++) { + const u = ((p % W) + 0.5 - 32.5) / s, v = (floor(p / W) + 0.5 - top) / s + 1e-3; + const xf = (u + v) / 2, yf = (v - u) / 2, x = round(xf), y = round(yf); + if (x < 0 || y < 0 || x >= T || y >= T) { + P.ci[p] = -1; + continue; + } + const c = y * T + x; + P.ci[p] = c; + P.vis = max(P.vis, G[c]); + // How far into the cell, in LEDs, for the lines between cells. + P.edge[p] = min(0.5 - abs(xf - x), 0.5 - abs(yf - y)) * 1.414 * s; + } +} + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + for (let c = 0; c < 3; c++) out[o + c] = rgb[c] * 255; +} + +export function setup(params) { + const P = (params.s = { + ts: 0, K: -1, hue: -1, glow: 0.06, s: 0, vis: 0, + ci: new Int32Array(N), edge: new Float32Array(N), + L: new Float32Array(T * T), pw: new Float32Array(T * T), pl: new Float32Array(T * T), + pal: new Float32Array((GALL + 1) * 3), pb: new Float32Array((GALL + 1) * 3), game: null, + }); + place(P, 1); +} + +// The sweep's value climbs slowly through the small values, where the rays +// are, and faster through the large ones: K = 12 (e^(2.5 t / 12) - 1), t in +// seconds at Speed 2. +const C = 12, RATE = 2.5; + +function light(P, g) { + if (g >= 0 && g <= GALL) for (const c of BY[g]) P.L[c] = 1; +} + +// A move: to a position of value 0 if there is one (the winner's move), +// otherwise a few counters from a heap or both (the loser's). +function nextMove(x, y) { + if (G[y * T + x] !== 0) { + for (let i = 0; i < x; i++) if (G[y * T + i] === 0) return [i, y, 1]; + for (let j = 0; j < y; j++) if (G[j * T + x] === 0) return [x, j, 1]; + for (let q = min(x, y); q > 0; q--) if (G[(y - q) * T + x - q] === 0) return [x - q, y - q, 1]; + } + for (;;) { + const kind = floor(random() * 3), a = kind === 0 ? x : kind === 1 ? y : min(x, y); + if (a < 1) continue; + const q = 1 + floor(random() * min(a, 9)); + return [kind === 1 ? x : x - q, kind === 0 ? y : y - q, 0]; + } +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + const s = round(cl(k[2], 1, 4)); + if (abs(s - P.s) > 1e-3) place(P, s); + if (k[0] !== P.hue) { + P.hue = k[0]; + hsv(P.hue + 0.13, 0.8, 1, P.pal, 0); + hsv(P.hue + 0.13, 0.5, 1, P.pb, 0); + for (let g = 1; g <= GALL; g++) { + const h = P.hue + 0.1 - 0.5 * (1 - exp(-g / 14)) - g / 900; + hsv(h, 0.85, 0.17 + 0.6 * exp(-g / 35), P.pal, g * 3); + hsv(h, 0.5, 1, P.pb, g * 3); + } + } + P.glow = cl(k[3], 0, 1); + const sp = cl(k[1], 0.2, 3) / 2, ds = d * sp; + + // The sweep. + const fade = exp(-ds / (0.45 + 1.6 * P.glow)); + for (let c = 0; c < T * T; c++) P.L[c] *= fade; + const k0 = P.K; + P.ts += ds; + P.K = P.ts < 0 ? -1 : floor(C * (exp((P.ts * RATE) / C) - 1) + 1e-6); + for (let g = max(k0 + 1, 0); g <= P.K; g++) light(P, g); + if (P.K > P.vis + 2) (P.ts = -0.8), (P.K = -1); + + // The game. + const gd = d * (0.5 + 0.5 * sp), gone = exp(-d / (P.game ? 8 : 1.2)); + for (let c = 0; c < T * T; c++) (P.pw[c] *= gone), (P.pl[c] *= gone); + if (input.btnPressed && input.btnPressed[0]) { + const vmax = (H - 1.5 - P.s) / P.s, v = round(vmax * (0.6 + 0.35 * random())); + let u = round((random() - 0.5) * 1.6 * min(v, 31 / P.s)); + if ((u + v) & 1) u += u > 0 ? -1 : 1; + const x = (u + v) >> 1, y = (v - u) >> 1; + P.game = { x, y, cx: x, cy: y, m: nextMove(x, y), f: 0, wait: 0.4 }; + } + const gm = P.game; + if (gm) { + if (gm.wait > 0) gm.wait -= gd * 2; + else { + const [tx, ty, win] = gm.m, len = max(abs(tx - gm.x), abs(ty - gm.y)); + gm.f = min(1, gm.f + (gd * 30) / (len + 8)); + const sx = Math.sign(tx - gm.x), sy = Math.sign(ty - gm.y), at = round(gm.f * len); + for (let i = 0; i <= at; i++) (win ? P.pw : P.pl)[(gm.y + sy * i) * T + gm.x + sx * i] = 1; + gm.cx = gm.x + sx * gm.f * len; + gm.cy = gm.y + sy * gm.f * len; + if (gm.f >= 1) { + gm.x = tx; + gm.y = ty; + gm.f = 0; + gm.wait = 0.5; + if (win) P.L[ty * T + tx] = 1; + if (tx === 0 && ty === 0) P.game = null; + else gm.m = nextMove(tx, ty); + } + } + } +} + +const WIN = [255, 226, 130], LOSE = [90, 180, 255]; + +export function draw(display, params) { + const P = params.s; + if (!P) return; + const base = 1 - 0.85 * P.glow, grid = P.s > 2 ? 0.6 : 0, gm = P.game, S = P.s; + // The counter, a diamond of light where the game stands. + const ux = gm ? 32 + S * (gm.cx - gm.cy) : -99, vy = gm ? S + S * (gm.cx + gm.cy) - (S === 1 ? 0.5 : 0) : -99; + for (let p = 0; p < N; p++) { + const c = P.ci[p], X = p % W, Y = floor(p / W); + if (c < 0) { + display.setPixel(X, Y, 0, 0, 0); + continue; + } + const q = G[c] * 3, l = P.L[c], a = P.pw[c] * 0.8, b = P.pl[c] * 0.75, f = q ? base : 0.6 + 0.4 * base; + const e = 1 - grid * cl(1 - P.edge[p] * 1.6, 0, 1); + // A lit cell flashes white, fades to its own colour at full strength + // and then down into the table. + const m = min(1, l * 1.5), wt = l * l * l; + let r = P.pal[q] * f, g = P.pal[q + 1] * f, bl = P.pal[q + 2] * f; + r += (P.pb[q] + (255 - P.pb[q]) * wt - r) * m; + g += (P.pb[q + 1] + (255 - P.pb[q + 1]) * wt - g) * m; + bl += (P.pb[q + 2] + (255 - P.pb[q + 2]) * wt - bl) * m; + r += (LOSE[0] - r) * b + (WIN[0] - r) * a; + g += (LOSE[1] - g) * b + (WIN[1] - g) * a; + bl += (LOSE[2] - bl) * b + (WIN[2] - bl) * a; + const k = cl(S + 1.6 - abs(X - ux) - abs(Y - vy), 0, 1.6) / 1.6; + if (k > 0) (r += (255 - r) * k), (g += (255 - g) * k), (bl += (255 - bl) * k); + display.setPixel(X, Y, r * e, g * e, bl * e); + } +} From d0d7c45717fd72c48cc056779737a7aa27b40083 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:02:34 +0000 Subject: [PATCH 11/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Look=20and=20Say?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/look-and-say.js | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 incoming/pattern-study/patterns/look-and-say.js diff --git a/incoming/pattern-study/patterns/look-and-say.js b/incoming/pattern-study/patterns/look-and-say.js new file mode 100644 index 000000000..291a2bcef --- /dev/null +++ b/incoming/pattern-study/patterns/look-and-say.js @@ -0,0 +1,342 @@ +// ===== Patternflow pattern ===== +// Title: Look and Say +// Family: type +// Idea: Conway's look-and-say sequence types itself down the page, each row read aloud run by run from the row above, and as the rows grow by 1.3 a step the page steps down from numerals to bars to one LED a digit, every Conway element in its own colour. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the look-and-say sequence and its audioactive decay into 92 elements (John Horton Conway, 1986), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Seed=1..5, Digits=0..1 +// +// Knob 1: Hue. Turns every colour round the wheel together. At 0 the elements run from pink at hydrogen through violet and blue to green at uranium, and the first seven rows of seed 1, which are not yet elements, are cream. +// Knob 2: Speed. How fast the rows are read. At 2 a row of 78 digits takes three and a half seconds and a row of 5808 six and a half, and the page from seed 1 starts again after about a minute and a half; at 0.2 the reading takes ten times as long. +// Knob 3: Seed. The first row: 1, then 3 (uranium), 312 (zinc), 311311 (barium) and 22 (hydrogen, the one row that reads back as itself, so the page fills with 22s). +// Knob 4: Digits. At 0 each Conway element has its own colour, with a dim strip of it behind its numerals; past the middle the colour says only which digit it is: 1 cyan, 2 violet, 3 gold. +// Buttons: K1 tap starts the page again from the next seed. + +const W = 64, H = 128; +const { min, max, floor, ceil, exp, pow, round } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// Conway's 92 elements, H to U: the name, the digits, and what the element +// becomes when it is read aloud. Every term of the sequence from the eighth +// on is a row of these, and each element decays on its own. +const TABLE = `H 22 H|He 13112221133211322112211213322112 Hf.Pa.H.Ca.Li|Li 312211322212221121123222112 He|Be 111312211312113221133211322112211213322112 Ge.Ca.Li|B 1321132122211322212221121123222112 Be|C 3113112211322112211213322112 B|N 111312212221121123222112 C|O 132112211213322112 N|F 31121123222112 O|Ne 111213322112 F|Na 123222112 Ne|Mg 3113322112 Pm.Na|Al 1113222112 Mg|Si 1322112 Al|P 311311222112 Ho.Si|S 1113122112 P|Cl 132112 S|Ar 3112 Cl|K 1112 Ar|Ca 12 K|Sc 3113112221133112 Ho.Pa.H.Ca.Co|Ti 11131221131112 Sc|V 13211312 Ti|Cr 31132 V|Mn 111311222112 Cr.Si|Fe 13122112 Mn|Co 32112 Fe|Ni 11133112 Zn.Co|Cu 131112 Ni|Zn 312 Cu|Ga 13221133122211332 Eu.Ca.Ac.H.Ca.Zn|Ge 31131122211311122113222 Ho.Ga|As 11131221131211322113322112 Ge.Na|Se 13211321222113222112 As|Br 3113112211322112 Se|Kr 11131221222112 Br|Rb 1321122112 Kr|Sr 3112112 Rb|Y 1112133 Sr.U|Zr 12322211331222113112211 Y.H.Ca.Tc|Nb 1113122113322113111221131221 Er.Zr|Mo 13211322211312113211 Nb|Tc 311322113212221 Mo|Ru 132211331222113112211 Eu.Ca.Tc|Rh 311311222113111221131221 Ho.Ru|Pd 111312211312113211 Rh|Ag 132113212221 Pd|Cd 3113112211 Ag|In 11131221 Cd|Sn 13211 In|Sb 3112221 Pm.Sn|Te 1322113312211 Eu.Ca.Sb|I 311311222113111221 Ho.Te|Xe 11131221131211 I|Cs 13211321 Xe|Ba 311311 Cs|La 11131 Ba|Ce 1321133112 La.H.Ca.Co|Pr 31131112 Ce|Nd 111312 Pr|Pm 132 Nd|Sm 311332 Pm.Ca.Zn|Eu 1113222 Sm|Gd 13221133112 Eu.Ca.Co|Tb 3113112221131112 Ho.Gd|Dy 111312211312 Tb|Ho 1321132 Dy|Er 311311222 Ho.Pm|Tm 11131221133112 Er.Ca.Co|Yb 1321131112 Tm|Lu 311312 Yb|Hf 11132 Lu|Ta 13112221133211322112211213322113 Hf.Pa.H.Ca.W|W 312211322212221121123222113 Ta|Re 111312211312113221133211322112211213322113 Ge.Ca.W|Os 1321132122211322212221121123222113 Re|Ir 3113112211322112211213322113 Os|Pt 111312212221121123222113 Ir|Au 132112211213322113 Pt|Hg 31121123222113 Au|Tl 111213322113 Hg|Pb 123222113 Tl|Bi 3113322113 Pm.Pb|Po 1113222113 Bi|At 1322113 Po|Rn 311311222113 Ho.At|Fr 1113122113 Rn|Ra 132113 Fr|Ac 3113 Ra|Th 1113 Ac|Pa 13 Th|U 3 Pa`; +const ROWS = TABLE.split("|").map((r) => r.split(" ")); +const NAME = ROWS.map((r) => r[0]); +const LEN = ROWS.map((r) => r[1].length); +const DEC = ROWS.map((r) => r[2].split(".").map((n) => NAME.indexOf(n))); +const NONE = 92; + +// The seeds: the first row, the row from which it is a compound of elements, +// and that compound. Seed 1 runs seven rows before it splits into Hf.Sn. +const SEEDS = [ + ["1", 8, "Hf.Sn"], + ["3", 1, "U"], + ["312", 1, "Zn"], + ["311311", 1, "Ba"], + ["22", 1, "H"], +]; + +// The four prints: cell width and height, digits a line, blank lines between +// rows, top margin, and how many lines the row read and the row typed may +// fill together before the page steps down to the next print. Numerals +// first, then bars one LED wide and as tall as the digit, so a run of equal +// digits stands as a flat step; then one LED a digit, two LEDs tall, then +// one. +const LV = [ + { cw: 4, ch: 6, per: 16, gap: 0, top: 1, fit: 16 }, + { cw: 1, ch: 4, per: 64, gap: 1, top: 0, fit: 24 }, + { cw: 1, ch: 2, per: 64, gap: 1, top: 0, fit: 48 }, + { cw: 1, ch: 1, per: 64, gap: 1, top: 0, fit: 999 }, +]; +// 3x5 numerals 1, 2 and 3, one octal digit a row from the top. No other +// digit ever appears: no run in these seeds is longer than three. +const FONT = [0, 0o26227, 0o71747, 0o71317]; +// The largest row the finest print still holds, in lines of 64. +const LAST = 120; +// Rows the page rattles off at the start before it settles to reading. +const BURST = 20; + +function say(d) { + const o = []; + for (let i = 0; i < d.length; ) { + let j = i + 1; + while (j < d.length && d[j] === d[i]) j++; + o.push(j - i, d[i]); + i = j; + } + return Uint8Array.from(o); +} + +function build(P, si) { + const [s0, at, comp] = SEEDS[si]; + const T = []; + let d = Uint8Array.from(s0, (c) => +c), E = null; + for (let n = 1; n <= 48; n++) { + if (n === at) E = comp.split(".").map((x) => NAME.indexOf(x)); + else if (E) E = E.flatMap((k) => DEC[k]); + const e = new Uint8Array(d.length).fill(NONE), end = new Uint8Array(d.length); + if (E) { + let p = 0; + for (const k of E) e.fill(k, p, (p += LEN[k])), (end[p - 1] = 1); + } + T.push({ d, e, end, n: d.length, ri: null }); + const nx = say(d); + if (nx.length > LAST * 64) break; + d = nx; + } + P.si = si; + P.T = T; + P.lv = 0; + P.ls = lines(T, 0); + P.l0 = P.ls; + P.sc = 0; + P.n = 0; + P.st = 1; + P.w = 0.1; + P.r = 0; + // The seed is on the page from the start. + P.typed = T[0].n; + P.at = new Float32Array(T[0].n).fill(P.clock - 1); + P.u = 0; +} + +// Where each row starts, in lines of a print. +function lines(T, lv) { + const L = LV[lv], ls = new Int32Array(T.length + 1); + for (let k = 0; k < T.length; k++) ls[k + 1] = ls[k] + ceil(T[k].n / L.per) + L.gap; + return ls; +} +const rowLines = (P, k, lv) => ceil(P.T[k].n / LV[lv].per); + +// The runs of the row being read: for each digit, the number of its run. +function runs(t) { + if (t.ri) return; + t.ri = new Int32Array(t.n); + t.rs = []; + for (let i = 0, r = -1; i < t.n; i++) { + if (i === 0 || t.d[i] !== t.d[i - 1]) r++, t.rs.push(i); + t.ri[i] = r; + } +} + +// How long a row takes to type: seconds for its runs at the Speed knob. +function dur(P, k) { + const runsN = P.T[k].n / 2, f = 2 / P.sp; + if (P.l0[k] < BURST) return 0.15 * pow(f, 0.5); + if (P.lv === 0) return f * (0.2 + 0.55 * pow(runsN, 0.5)); + return f * (0.5 + 0.55 * pow(runsN, 0.3)); +} + +// The scroll that keeps the typing line at the foot of the panel and the +// reading head on it. +function want(P, lv, ls, typed) { + const L = LV[lv], n = P.n; + if (n >= P.T.length) return P.sc; + const tl = ls[n] + floor(min(typed, P.T[n].n - 1) / L.per); + let s = L.top + (tl + 1) * L.ch + 1 - H; + if (n > 0) { + const src = P.T[n - 1], i = src.rs ? src.rs[min(src.rs.length - 1, floor(P.r))] : 0; + s = min(s, L.top + (ls[n - 1] + floor(i / L.per)) * L.ch - 2); + } + return max(0, s); +} + +function next(P) { + P.n++; + P.r = 0; + P.typed = 0; + if (P.n >= P.T.length) return void ((P.st = 3), (P.w = 3)); + const t = P.T[P.n]; + runs(P.T[P.n - 1]); + P.at = new Float32Array(t.n); + P.st = 0; + const lv = P.lv; + if (lv < 3 && rowLines(P, P.n - 1, lv) + rowLines(P, P.n, lv) > LV[lv].fit) { + // Step down a print: every digit flies from its old place to its new one. + P.nl = lv + 1; + P.nls = lines(P.T, lv + 1); + P.nsc = want(P, lv + 1, P.nls, 0); + P.st = 2; + P.u = 0; + } +} + +export function setup(params) { + params.s = { si: 0, off: 0, sp: 2, hue: 0, dig: 0.06, col: new Float32Array(93 * 4 * 3), buf: new Float32Array(W * H * 3), clock: 0 }; + build(params.s, 0); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const P = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + P.hue = k[0]; + P.sp = cl(k[1], 0.2, 3); + P.dig = cl(k[3], 0, 1); + if (input.btnPressed && input.btnPressed[0]) P.off++; + const si = (round(cl(k[2], 1, 5)) - 1 + P.off) % SEEDS.length; + if (si !== P.si) build(P, si); + P.clock += d; + if (P.st === 0) { + const t = P.T[P.n], rn = t.n / 2; + P.r = min(rn, P.r + (d * rn) / dur(P, P.n)); + // The count is spoken as the run lights up, the digit just after. + while (P.typed < t.n && (P.typed >> 1) + (P.typed & 1 ? 0.55 : 0.12) <= P.r) P.at[P.typed++] = P.clock; + if (P.typed >= t.n) (P.st = 1), (P.w = P.l0[P.n] < BURST ? 0.03 : 0.35 * (2 / P.sp)); + } else if (P.st === 1) { + P.w -= d; + if (P.w <= 0) next(P); + } else if (P.st === 2) { + P.u += d / 1.6; + if (P.u >= 1) (P.lv = P.nl), (P.ls = P.nls), (P.sc = P.nsc), (P.st = 0); + } else if (P.st === 3) { + P.w -= d; + if (P.w <= 0) (P.st = 4), (P.u = 0); + } else if (P.st === 4) { + P.u += d / 0.8; + if (P.u >= 1) build(P, P.si); + } + if (P.st !== 2) P.sc += (want(P, P.lv, P.ls, P.typed) - P.sc) * (1 - exp(-d * 10)); +} + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const c = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[o] = c[0] * 255; + out[o + 1] = c[1] * 255; + out[o + 2] = c[2] * 255; +} + +// Colour of each element and digit: element hue by place in Conway's table, +// from pink at hydrogen through violet and blue to green at uranium, so an +// element and what it decays into are near in colour. The Digits knob mixes +// toward a hue for each digit. Rows before the split into elements are pale. +const tmp = new Float32Array(6); +function palette(P) { + const h0 = P.hue + 0.95, col = P.col, k = cl((P.dig - 0.3) / 0.4, 0, 1), g = k * k * (3 - 2 * k); + for (let e = 0; e <= NONE; e++) + for (let dg = 1; dg <= 3; dg++) { + if (e === NONE) hsv(h0 + 0.2, 0.3, 1, tmp, 0); + else hsv(h0 - 0.58 * (e / 91), 0.75, 1, tmp, 0); + hsv(h0 + [0, 0.56, 0.85, 0.19][dg], 0.8, 1, tmp, 3); + const o = (e * 4 + dg) * 3; + for (let c = 0; c < 3; c++) col[o + c] = tmp[c] + (tmp[c + 3] - tmp[c]) * g; + } +} + +// One digit onto the buffer in print lv at (x, y), the palette colour at o +// scaled by v and whitened by w. Behind a numeral lies a strip of tint, open +// after the last digit of an element; a bar at the end of an element is dimmer. +function put(P, lv, x, y, dg, o, v, w, tint, end) { + const buf = P.buf, col = P.col; + const r = col[o] * v, g = col[o + 1] * v, b = col[o + 2] * v; + const R = r + (255 - r) * w, G = g + (255 - g) * w, B = b + (255 - b) * w; + if (lv === 0) { + const bits = FONT[dg], tw = end ? 3 : 4; + for (let gy = 0; gy < 5; gy++) { + const yy = y + gy; + if (yy < 0 || yy >= H) continue; + const row = (bits >> ((4 - gy) * 3)) & 7; + for (let gx = 0; gx < tw; gx++) { + const xx = x + gx; + if (xx >= W) break; + const q = (yy * W + xx) * 3; + if (gx < 3 && row & (4 >> gx)) (buf[q] = R), (buf[q + 1] = G), (buf[q + 2] = B); + else if (tint > 0) (buf[q] = col[o] * tint), (buf[q + 1] = col[o + 1] * tint), (buf[q + 2] = col[o + 2] * tint); + } + } + return; + } + const m = lv === 1 ? (end ? 0.6 : 1) : lv === 2 ? [0, 0.7, 0.86, 1][dg] : [0, 0.8, 0.9, 1][dg]; + for (let yy = lv === 1 ? y + 3 - dg : y, y1 = lv === 1 ? y + 3 : y + LV[lv].ch; yy < y1; yy++) { + if (yy < 0 || yy >= H || x >= W) continue; + const q = (yy * W + x) * 3; + buf[q] = R * m; + buf[q + 1] = G * m; + buf[q + 2] = B * m; + } +} + +// Brightness and whitening of digit i of row k: rows long read are dim, the +// row being read dims behind the reading head, the run under the head lights +// up, and a digit just typed flashes. +const OLD = 0.62, SRC = 0.95; +function look(P, k, i, out) { + const n = P.n; + out[0] = OLD; + out[1] = 0; + if (k === n && P.st <= 1) { + out[0] = 1; + out[1] = 0.85 * exp(-(P.clock - P.at[i]) / 0.22); + } else if (k === n - 1 && P.st <= 1) { + const t = P.T[k], dr = P.r - t.ri[i]; + if (dr < 0) out[0] = SRC; + else { + const tau = dur(P, n) / (P.T[n].n / 2), s = dr < 1 ? 0 : ((dr - 1) * tau) / 0.25; + out[1] = 0.8 * exp(-s); + out[0] = OLD + (SRC - OLD) * exp(-s * 0.3); + } + } else if (k === n - 1) out[0] = SRC; +} + +const lk = new Float32Array(2); +function drawPage(P, lv, ls, sc) { + const L = LV[lv], top = L.top - round(sc), tintBase = (1 - P.dig) * 0.13; + const last = min(P.n, P.T.length - 1); + for (let k = 0; k <= last; k++) { + const t = P.T[k], y0 = top + ls[k] * L.ch; + if (y0 >= H) break; + if (y0 + (ls[k + 1] - ls[k]) * L.ch < 0) continue; + const cnt = k === P.n ? (P.st <= 1 ? P.typed : 0) : t.n; + for (let i = 0; i < cnt; i++) { + const y = y0 + floor(i / L.per) * L.ch; + if (y + L.ch <= 0) { + i += L.per - 1 - (i % L.per); + continue; + } + if (y >= H) break; + look(P, k, i, lk); + const e = t.e[i], dg = t.d[i]; + put(P, lv, (i % L.per) * L.cw, y, dg, (e * 4 + dg) * 3, lk[0], lk[1], e === NONE ? 0 : tintBase * (0.6 + 0.4 * lk[0]), t.end[i]); + } + } +} + +// Stepping down a print: line by line from the top, each numeral drops to a +// block and flies to its place in the finer print. +function drawFlight(P) { + const A = LV[P.lv], B = LV[P.nl], u = P.u, tintBase = (1 - P.dig) * 0.13; + const ta = A.top - round(P.sc), tb = B.top - round(P.nsc); + for (let k = 0; k < P.n; k++) { + const t = P.T[k]; + for (let i = 0; i < t.n; i++) { + const xa = (i % A.per) * A.cw, ya = ta + (P.ls[k] + floor(i / A.per)) * A.ch; + const xb = (i % B.per) * B.cw, yb = tb + (P.nls[k] + floor(i / B.per)) * B.ch; + if ((ya < -6 || ya >= H) && (yb < -6 || yb >= H)) continue; + const v = k === P.n - 1 ? SRC : OLD, e = t.e[i], dg = t.d[i], o = (e * 4 + dg) * 3; + const s = cl((u * 1.5 - 0.5 * cl(ya / H, 0, 1)) * 1.25, 0, 1); + if (s <= 0) put(P, P.lv, xa, ya, dg, o, v, 0, e === NONE ? 0 : tintBase * (0.6 + 0.4 * v), t.end[i]); + else { + const f = s * s * (3 - 2 * s); + put(P, P.nl, round(xa + (xb - xa) * f), round(ya + (yb - ya) * f), dg, o, v, 0.5 * (1 - f), 0, t.end[i]); + } + } + } +} + +export function draw(display, params) { + const P = params.s; + if (!P) return; + palette(P); + const buf = P.buf; + buf.fill(0); + if (P.st === 2) drawFlight(P); + else drawPage(P, P.lv, P.ls, P.sc); + const fade = P.st === 4 ? 1 - P.u : 1; + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, buf[p] * fade, buf[p + 1] * fade, buf[p + 2] * fade); +} From 35b74ca28c2dac361c643e609a01a28bcd0822f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:10:46 +0000 Subject: [PATCH 12/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20Loewner=20Trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/loewner-trace.js | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 incoming/pattern-study/patterns/loewner-trace.js diff --git a/incoming/pattern-study/patterns/loewner-trace.js b/incoming/pattern-study/patterns/loewner-trace.js new file mode 100644 index 000000000..2b22f5aff --- /dev/null +++ b/incoming/pattern-study/patterns/loewner-trace.js @@ -0,0 +1,421 @@ +// ===== Patternflow pattern ===== +// Title: Loewner Trace +// Family: line +// Idea: A Schramm-Loewner curve driven by Brownian motion climbs a channel as wide as the panel, and the conformal grid of the map that unzips it bends round its tip. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Schramm-Loewner evolution (Oded Schramm, 2000) grown with Loewner's equation (Charles Loewner, 1923), pattern round 28 +// Round: 28 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Kappa=4..8, Trace=0..1 +// +// Knob 1: Hue. Turns every colour round the wheel together. At 0 the grid is blue and teal and the trace runs from white-gold at the tip through orange and rose to violet as it ages. +// Knob 2: Speed. Loewner steps a second, 1200 at 2. The tip passes the middle of the panel in two and a half seconds, then climbs 17 to 24 LEDs a second, fastest at Kappa 4, and the view scrolls after it. At 0.2 everything runs ten times slower. +// Knob 3: Kappa. The kappa of SLE, how hard the Brownian motion drives the curve. At 4 the curve is simple and never touches itself or the walls. Above 4 it hits both, and the ground it closes off is swallowed and hatched in the colour of the moment it was cut off. At 8 it fills everything behind the tip. +// Knob 4: Trace. Fades the grid out from behind the trace. At 0 the conformal grid lights the channel round the curve; at 1 it is gone and the trace burns on black. +// Buttons: K1 tap starts a fresh Brownian path from the floor of the channel. + +const W = 64, H = 128, N = W * H; +const { min, max, floor, sqrt, log, atan2, exp, abs, sin, cos, PI, round } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The panel is a half-strip of the plane 65 LEDs wide, its walls and floor +// one LED beyond the outer LEDs so that none sits on the boundary. sin() +// carries the half-strip onto the upper half-plane, where the Loewner +// equation lives: the floor goes to [-1, 1], the walls to the rest of the +// real line, and the top of the channel, however far it scrolls, to +// infinity. Each row up the channel is a factor e^(pi/65) further out. +// Scale invariance of SLE makes that a channel the curve climbs for ever. +const PX = PI / 65; +const M = 12; // Laurent terms of g_t at infinity, for rows that scroll in +const CAP = 1 << 16; // steps of driving function kept for the tip +const BACK = 6000; // steps walked back when finding the tip +const V = 0.025; // LEDs the curve climbs a step, on average +const RATE = 600; // steps a second at Speed 1 +const NEAR = 0.6; // an LED this close to the curve is the curve +const KNEAR = 0.1; // the next slit against the w-distance of the LED nearest the tip +const TIPROW = 52; // the view scrolls to keep the tip at least this far down + +function zOf(s, x, py) { + const xi = (x - 31.5) * PX, eta = (127 - py + 1 + s.scroll) * PX; + const a = exp(eta - s.lnL) * 0.5, b = exp(-eta - s.lnL) * 0.5, il = exp(-s.lnL); + const ch = a + b, sh = a - b, c = cos(xi); + return [sin(xi) * ch, c * sh, PX * sqrt(c * c * il * il + sh * sh)]; +} + +function fresh(s) { + s.scroll = 0; + s.lnL = 0; + s.t = 0; + s.Wd = 0; + s.n = 0; + s.tau = 0; + s.near = Infinity; + s.b.fill(0); + s.mk.fill(0); + for (let py = 0, p = 0; py < H; py++) + for (let x = 0; x < W; x++, p++) { + const [zx, zy, S] = zOf(s, x, py); + s.wx[p] = zx; + s.wy[p] = zy; + s.d2[p] = S * S; + } + s.tipx = 31.5; + s.tipy = 128; +} + +function gauss(s) { + if (s.spare !== null) { + const g = s.spare; + s.spare = null; + return g; + } + let u = Math.random(); + if (u < 1e-12) u = 1e-12; + const r = sqrt(-2 * log(u)), a = 2 * PI * Math.random(); + s.spare = r * sin(a); + return r * cos(a); +} + +// One Loewner step: the driving point jumps, then a vertical slit of +// half-plane capacity 2 dt grows at it. Every LED's g_t(z) goes through the +// slit map w -> W + sqrt((w - W)^2 + 4 dt), and so do the Laurent +// coefficients of g_t at infinity. +function step(s, kappa) { + const il2 = exp(-2 * s.lnL), t = s.t; + const base = V * PX * sqrt((t + 0.25 * PX * PX * il2) * (il2 + 4 * t)); + // Where the tip sits in a sheltered passage a small slit at W unrolls + // into a long arc on the panel; the LEDs closest to the tip in the + // w-plane say how small the next slit must be to stay within one of them. + const dt = max(base * 0.02, min(base, s.near * KNEAR)); + s.Wd += sqrt(kappa * dt) * gauss(s); + const Wk = s.Wd, c4 = 4 * dt, wx = s.wx, wy = s.wy, d2 = s.d2, mk = s.mk, mt = s.mt, tau = s.tau; + const near2 = NEAR * NEAR; + let mmin = Infinity; + for (let p = 0; p < N; p++) { + if (mk[p]) continue; + const ux = wx[p] - Wk, uy = wy[p]; + const a = ux * ux - uy * uy + c4, b = 2 * ux * uy, m = sqrt(a * a + b * b); + let re, im; + if (a >= 0) { + re = sqrt((m + a) * 0.5); + im = b / (2 * re); + } else { + im = sqrt((m - a) * 0.5); + re = abs(b) / (2 * im); + if (b < 0) im = -im; + } + if (ux < 0) (re = -re), (im = -im); + wx[p] = Wk + re; + wy[p] = im; + if (m < mmin) mmin = m; + const g = (d2[p] *= (ux * ux + uy * uy) / m); + if (im * im < near2 * g) (mk[p] = 1), (mt[p] = tau); + } + s.near = mmin; + // Laurent coefficients: g = z (1 + sum V_k z^-k), V_1 = -W, V_k = b_{k-1}. + const Vc = s.Vc, P = s.P, Q = s.Q, bb = s.b; + Vc[0] = 1; + Vc[1] = -Wk; + for (let k = 2; k <= M + 1; k++) Vc[k] = bb[k - 1]; + for (let k = 0; k <= M + 1; k++) { + let acc = 0; + for (let j = 0; j <= k; j++) acc += Vc[j] * Vc[k - j]; + P[k] = acc + (k === 2 ? c4 : 0); + } + Q[0] = 1; + for (let k = 1; k <= M + 1; k++) { + let acc = P[k]; + for (let j = 1; j < k; j++) acc -= Q[j] * Q[k - j]; + Q[k] = acc * 0.5; + } + for (let n = 1; n <= M; n++) bb[n] = Q[n + 1]; + if (s.n === CAP) { + s.hw.copyWithin(0, CAP / 2); + s.ht.copyWithin(0, CAP / 2); + s.n = CAP / 2; + } + s.hw[s.n] = Wk; + s.ht[s.n] = dt; + s.n++; + s.t = t + dt; +} + +// For kappa above 4 the curve hits itself and the walls, and whatever it +// closes off from the top of the channel leaves the domain: an LED no path +// of open LEDs joins to the top row is swallowed. At 4 and below SLE is a +// simple curve that touches nothing, so nothing is filled there, even where +// two strands pass within an LED of each other. +function fill(s) { + const mk = s.mk, seen = s.seen, st = s.stack; + seen.fill(0); + let top = 0; + for (let x = 0; x < W; x++) if (!mk[x]) (seen[x] = 1), (st[top++] = x); + while (top) { + const p = st[--top], x = p % W; + if (x > 0 && !mk[p - 1] && !seen[p - 1]) (seen[p - 1] = 1), (st[top++] = p - 1); + if (x < W - 1 && !mk[p + 1] && !seen[p + 1]) (seen[p + 1] = 1), (st[top++] = p + 1); + if (p >= W && !mk[p - W] && !seen[p - W]) (seen[p - W] = 1), (st[top++] = p - W); + if (p < N - W && !mk[p + W] && !seen[p + W]) (seen[p + W] = 1), (st[top++] = p + W); + } + for (let p = 0; p < N; p++) if (!mk[p] && !seen[p]) (mk[p] = 2), (s.mt[p] = s.tau); +} + +// The tip, g_t^{-1}(W_t): the slit maps undone newest first. +function tip(s) { + const n = s.n; + if (!n) return [0, 0]; + let zx = s.hw[n - 1], zy = 2 * sqrt(s.ht[n - 1]), T = s.t - s.ht[n - 1]; + const stop = max(0, n - BACK); + for (let k = n - 2; k >= stop; k--) { + const Wk = s.hw[k], c4 = 4 * s.ht[k]; + T -= s.ht[k]; + const ux = zx - Wk, uy = zy; + const a = ux * ux - uy * uy - c4, b = 2 * ux * uy, m = sqrt(a * a + b * b); + let re, im; + if (a >= 0) { + re = sqrt((m + a) * 0.5); + im = re > 0 ? b / (2 * re) : 0; + } else { + im = sqrt((m - a) * 0.5); + re = abs(b) / (2 * im); + if (b < 0) im = -im; + } + if (ux < 0) (re = -re), (im = -im); + zx = Wk + re; + zy = im; + } + // Whatever lies further back is small beside the tip: g^{-1}(w) ~ w - 2T/w. + if (stop > 0 && T > 0) { + const r2 = zx * zx + zy * zy; + zx -= (2 * T * zx) / r2; + zy += (2 * T * zy) / r2; + } + return [zx, zy]; +} + +// Where a point of the half-plane sits on the panel: arcsin back to the strip. +function toPanel(s, zx, zy) { + const il2 = exp(-2 * s.lnL); + // q = -i z + sqrt(1/L^2 - z^2), the root of larger modulus. + const ax = il2 - (zx * zx - zy * zy), ay = -2 * zx * zy, m = sqrt(ax * ax + ay * ay); + let rx, ry; + if (ax >= 0) (rx = sqrt((m + ax) * 0.5)), (ry = rx > 0 ? ay / (2 * rx) : 0); + else (ry = sqrt((m - ax) * 0.5)), (rx = abs(ay) / (2 * ry)), (ry = ay < 0 ? -ry : ry); + let qx = zy + rx, qy = -zx + ry; + const q2x = zy - rx, q2y = -zx - ry; + if (q2x * q2x + q2y * q2y > qx * qx + qy * qy) (qx = q2x), (qy = q2y); + const xi = -atan2(qy, qx), eta = s.lnL + 0.5 * log(qx * qx + qy * qy); + return [xi / PX + 31.5, 128 + s.scroll - eta / PX]; +} + +// A row scrolling in at the top, far above the hull, takes g_t from its +// Laurent series at infinity. +function newRow(s) { + const wx = s.wx, wy = s.wy, d2 = s.d2, mk = s.mk, bb = s.b; + wx.copyWithin(W, 0, N - W); + wy.copyWithin(W, 0, N - W); + d2.copyWithin(W, 0, N - W); + mk.copyWithin(W, 0, N - W); + s.mt.copyWithin(W, 0, N - W); + s.scroll++; + for (let x = 0; x < W; x++) { + const [zx, zy, S] = zOf(s, x, 0); + const r2 = zx * zx + zy * zy, yx = zx / r2, yy = -zy / r2; + // sum b_n y^n and sum n b_n y^(n-1) by Horner. + let fx = bb[M], fy = 0, dx = M * bb[M], dy = 0; + for (let n = M - 1; n >= 1; n--) { + const tx = fx * yx - fy * yy + bb[n]; + fy = fx * yy + fy * yx; + fx = tx; + const ex = dx * yx - dy * yy + n * bb[n]; + dy = dx * yy + dy * yx; + dx = ex; + } + const gx = fx * yx - fy * yy, gy = fx * yy + fy * yx; + const y2x = yx * yx - yy * yy, y2y = 2 * yx * yy; + const hx = 1 - (dx * y2x - dy * y2y), hy = -(dx * y2y + dy * y2x); + wx[x] = zx + gx; + wy[x] = zy + gy; + d2[x] = (hx * hx + hy * hy) * S * S; + mk[x] = 0; + } + // Keep the numbers small: SLE does not see the scale. + if (s.scroll * PX - s.lnL > 9) renorm(s, 4); +} + +function renorm(s, lnl) { + const l = exp(lnl), il = 1 / l; + s.lnL += lnl; + for (let p = 0; p < N; p++) { + s.wx[p] *= il; + s.wy[p] *= il; + s.d2[p] *= il * il; + } + s.Wd *= il; + s.t *= il * il; + for (let k = 0; k < s.n; k++) (s.hw[k] *= il), (s.ht[k] *= il * il); + s.near *= il * il; + let f = il; + for (let n = 1; n <= M; n++) (f *= il), (s.b[n] *= f); +} + +export function setup(params) { + const F = (n) => new Float64Array(n); + params.s = { + wx: F(N), wy: F(N), d2: F(N), mk: new Uint8Array(N), mt: F(N), + b: F(M + 1), Vc: F(M + 2), P: F(M + 2), Q: F(M + 2), hw: F(CAP), ht: F(CAP), + seen: new Uint8Array(N), stack: new Int32Array(N), spare: null, acc: 0, hue: 0, kappa: 4, trace: 0.06, + buf: new Float32Array(N * 3), tlut: new Float32Array(LUT * 3), blut: new Float32Array(LUT * 3), + side: new Float32Array(12), lutHue: NaN, + }; + fresh(params.s); +} + +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.kappa = cl(k[2], 4, 8); + s.trace = cl(k[3], 0, 1); + if (input.btnPressed && input.btnPressed[0]) fresh(s); + const sp = cl(k[1], 0.2, 3); + s.acc += d * sp * RATE; + let n = floor(s.acc); + s.acc -= n; + s.tau += d * sp; + while (n-- > 0) step(s, s.kappa); + if (s.kappa > 4) fill(s); + const [zx, zy] = tip(s); + const [px, py] = toPanel(s, zx, zy); + s.tipx = px; + s.tipy = py; + const rows = min(3, ceilPos(TIPROW - py)); + for (let i = 0; i < rows; i++) newRow(s); + s.tipy = py + rows; +} +const ceilPos = (v) => (v > 0 ? Math.ceil(v) : 0); + +// Colour stops, turned round the colour wheel by Hue: the trace by its age, +// new to old; swallowed ground by when it was swallowed; the field on the +// right and on the left of the curve; the rays and the rings of the grid. +const TRACE = [[255, 238, 196], [255, 178, 84], [250, 92, 112], [206, 62, 196], [124, 76, 236]]; +const BUBBLE = [[150, 40, 92], [112, 34, 128], [58, 40, 128], [96, 30, 70], [150, 40, 92]]; +const RIGHT = [16, 32, 78], LEFT = [8, 50, 72], RAY = [52, 150, 172], RING = [96, 112, 206]; +const LUT = 64; +function ramp(stops, out, rot) { + const n = stops.length - 1; + for (let i = 0; i < LUT; i++) { + const u = (i / (LUT - 1)) * n, j = min(n - 1, floor(u)), f = u - j; + const c = [0, 1, 2].map((k) => stops[j][k] + (stops[j + 1][k] - stops[j][k]) * f); + turn(c, rot, out, i * 3); + } +} +// A hue turn that keeps the grey axis: rotation about (1, 1, 1). +function turn(c, a, out, o) { + const co = cos(a), si = sin(a), k = (1 - co) / 3, r = sqrt(1 / 3) * si; + const m0 = co + k, m1 = k - r, m2 = k + r; + out[o] = cl(c[0] * m0 + c[1] * m1 + c[2] * m2, 0, 255); + out[o + 1] = cl(c[0] * m2 + c[1] * m0 + c[2] * m1, 0, 255); + out[o + 2] = cl(c[0] * m1 + c[1] * m2 + c[2] * m0, 0, 255); +} + +// The grid is the conformal map log(g_t(z) - W_t), which unrolls what is +// left of the channel into a straight strip with the tip at minus infinity: +// its level lines are circles round the tip and the rays out of it. Where a +// cell grows large a finer grid fades in, up to three halvings. +const DELTA = PI / 8, LEVELS = 4, LOG7 = log(DELTA / 7); +export function draw(display, params) { + const s = params.s; + if (!s) return; + const buf = s.buf, wx = s.wx, wy = s.wy, d2 = s.d2, mk = s.mk, mt = s.mt, Wk = s.Wd; + const fld = 1 - s.trace, tr = s.tlut, bu = s.blut, side = s.side; + if (s.lutHue !== s.hue) { + const rot = s.hue * 2 * PI; + ramp(TRACE, tr, rot); + ramp(BUBBLE, bu, rot); + turn(RIGHT, rot, side, 0); + turn(LEFT, rot, side, 3); + turn(RAY, rot, side, 6); + turn(RING, rot, side, 9); + s.lutHue = s.hue; + } + for (let py = 0, p = 0; py < H; py++) + for (let x = 0; x < W; x++, p++) { + const q = p * 3; + if (mk[p]) { + const age = max(0, s.tau - mt[p]); + if (mk[p] === 1) { + const i = min(LUT - 1, floor((age / 8) * (LUT - 1))) * 3; + buf[q] = tr[i]; + buf[q + 1] = tr[i + 1]; + buf[q + 2] = tr[i + 2]; + } else { + // Swallowed ground keeps the colour of the moment it was cut off, + // and dims as it ages. + const u = mt[p] / 5, i = floor((u - floor(u)) * (LUT - 1)) * 3, v = 0.55 + 0.45 * exp(-age / 6); + const hat = ((x + (floor(u * 7) & 1 ? 1 : -1) * (py - s.scroll) + 4096) & 3) === 0 ? 1 : 0.5; + buf[q] = bu[i] * v * hat; + buf[q + 1] = bu[i + 1] * v * hat; + buf[q + 2] = bu[i + 2] * v * hat; + } + continue; + } + const ux = wx[p] - Wk, uy = wy[p], r2 = ux * ux + uy * uy, rr = sqrt(r2); + const lr = log(rr), th = atan2(uy, ux), lg = 0.5 * log(d2[p]); + const cell = DELTA * exp(lr - lg); // LEDs per grid cell here + const fa = lr / DELTA, fb = th / DELTA; + // Lines, coarse to fine. + let ring = 0, ray = 0, cj = cell, sa = fa, sb = fb, chk = 0; + const lc = cl((lr - lg + LOG7) * 1.4427, 0, LEVELS - 1.001), jc = floor(lc), fc = lc - jc; + for (let j = 0; j < LEVELS && (j <= jc + 1 || cj >= 6); j++) { + const ia = floor(sa), ib = floor(sb), nb = round(sb); + // The rays at angle 0 and pi are the boundary itself, not grid. The + // ray at pi/2 is the hyperbolic geodesic from the tip to the top of + // the channel, where the curve is bound, and burns brighter. + const da = min(sa - ia, ia + 1 - sa) * cj, db = nb > 0 && nb < 8 << j ? abs(sb - nb) * cj : 99; + const fade = j === 0 ? cl((cj - 2) / 3, 0, 1) : cl((cj - 6) / 6, 0, 1) * 0.6; + ring = max(ring, cl(1 - da / 0.8, 0, 1) * fade); + ray = max(ray, cl(1 - db / 0.8, 0, 1) * fade * (j === 0 && nb === 4 ? 1.6 : 1)); + if (j === jc) chk += ((ia + ib) & 1) * (1 - fc); + if (j === jc + 1) chk += ((ia + ib) & 1) * fc; + cj *= 0.5; + sa *= 2; + sb *= 2; + } + // The two sides of the curve: th / pi is the chance that a Brownian + // walker from here leaves by the left-hand side. + const hs = th / PI, cf = (0.82 + 0.36 * chk * cl((cell - 2) / 3, 0, 1)) * (0.4 + 0.6 * sqrt(uy / rr)); + let r = (side[0] + (side[3] - side[0]) * hs) * cf * fld; + let gg = (side[1] + (side[4] - side[1]) * hs) * cf * fld; + let bl = (side[2] + (side[5] - side[2]) * hs) * cf * fld; + let li = ring * fld; + r += (side[9] - r) * li; + gg += (side[10] - gg) * li; + bl += (side[11] - bl) * li; + li = min(1, ray) * fld; + const hot = max(0, ray - 1) * 1.6 * fld; + r += (side[6] - r) * li; + gg += (side[7] - gg) * li; + bl += (side[8] - bl) * li; + r += (210 - r) * hot; + gg += (240 - gg) * hot; + bl += (240 - bl) * hot; + buf[q] = r; + buf[q + 1] = gg; + buf[q + 2] = bl; + } + // The tip. + const tx = s.tipx, ty = s.tipy; + for (let y = max(0, floor(ty - 3)); y <= min(H - 1, floor(ty + 3)); y++) + for (let x = max(0, floor(tx - 3)); x <= min(W - 1, floor(tx + 3)); x++) { + const dd = (x - tx) ** 2 + (y - ty) ** 2, w = exp(-dd / 1.5); + const q = (y * W + x) * 3; + buf[q] += (255 - buf[q]) * w; + buf[q + 1] += (250 - buf[q + 1]) * w; + buf[q + 2] += (235 - buf[q + 2]) * w; + } + for (let p = 0, y = 0; y < H; y++) for (let x = 0; x < W; x++, p += 3) display.setPixel(x, y, buf[p], buf[p + 1], buf[p + 2]); +} From 74ac42b7328bc54530493efd50a1b26b5da4a97b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:16:22 +0000 Subject: [PATCH 13/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20README=20=EC=97=90=2028=ED=8C=90=20=ED=91=9C?= =?UTF-8?q?=EC=99=80=20=EC=A0=88,=20=EB=8B=A4=EC=9D=8C=20=ED=8C=90=20?= =?UTF-8?q?=EA=B0=90=EC=9D=98=20=EB=82=AF=EC=84=A0=20=EC=88=98=ED=95=99?= =?UTF-8?q?=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HXbdUBpnpkK9obqLQFXaCS --- incoming/pattern-study/README.md | 77 ++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index c53a832bc..21b7d376e 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 178개를 쉰두 갈래로 나눠 만들었어요. 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판도 열 갈래에 하나씩 열 개를 더했고, 철도와 필름·영화 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 188개를 쉰세 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. 23판도 열 갈래에 하나씩 열 개를 더했고, 무대·공연과 짐승 갈래를 새로 열었어요. 24판도 열 갈래에 하나씩 열 개를 더했고, 공사·건설과 도자기 갈래를 새로 열었어요. 25판도 열 갈래에 하나씩 열 개를 더했고, 농사·밭과 화산 갈래를 새로 열었어요. 26판도 열 갈래에 하나씩 열 개를 더했고, 얼음·극지와 미생물 갈래를 새로 열었어요. 27판도 열 갈래에 하나씩 열 개를 더했고, 철도와 필름·영화 갈래를 새로 열었어요. 28판은 주제 표가 수학 쪽으로 바뀐 첫 판이라 이름 있는 수학 대상 열 개를 열 갈래에 하나씩 더했고, 수론 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. 패널은 세로로 서요. 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판에서 세로로 다시 짰어요. @@ -309,6 +309,21 @@ | [Mitosis](patterns/mitosis.js) | 미생물 | 검은 바탕에 찍은 면역형광 사진 속 세포 배양이에요. DNA 는 파랑, 미세소관은 초록, 액틴은 빨강으로 물든 세포가 둥글게 말려 염색체를 두 방추극 사이에 세우고, 양쪽 극으로 끌어 나눈 뒤 빨간 수축 고리에서 잘록해져 둘이 돼요. K1 이 염료를 바꾸고, 누르면 가운데 세포가 바로 분열을 시작해요. | | [Sled Dogs](patterns/sled-dogs.js) | 얼음·극지 | 드론처럼 바로 위에서 본 개썰매 팀이 둘씩 갱라인에 매여 해빙 위를 달려 올라가요. 낮은 해가 개마다 긴 파란 그림자를 비스듬히 뒤로 던지고, 바람이 깎은 사스트루기 사이로 압력 능선과 다시 언 리드가 발밑을 흘러가요. K1 은 금빛 해에서 오로라 뜬 달밤으로 가고, 누르면 머셔가 외쳐 팀이 방향을 틀어요. | +### 28판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Aztec Diamond](patterns/aztec-diamond.js) | 보로노이·셀 | 아즈텍 다이아몬드 여덟 개가 패널을 덮고 저마다 도미노 셔플로 자라요. 도미노가 제 방향으로 한 LED 씩 미끄러지고 빈 2×2 칸에 새 쌍이 들어서면, 다 자란 다이아몬드의 네 모서리는 한 색 벽돌로 얼고 북극권 원 안만 뒤섞여요. K3 이 가로와 세로의 몫을 기울여 원을 타원으로 바꾸고, 누르면 처음부터 다시 자라요. | +| [Ford Circles](patterns/ford-circles.js) | 수론 | 세운 왼쪽 가장자리가 실수선이고, 분수 p/q 마다 반지름 1/(2q²) 인 원이 그 자리에 기대 서요. 화면은 황금비로 끝없이 들어가고, 맞닿은 두 원 사이의 중간수 자리에 더 작은 원이 자꾸 나와요. K3 이 과녁을 √2, √3, e, π 로 바꾸고, 누르면 스턴-브로코 나무를 따라 과녁까지 내려가는 길이 켜져요. | +| [Gosset Polytope](patterns/gosset-polytope.js) | 기하·대칭 | E8 의 근 240개와 그 사이 모서리 6720개, 고셋의 4₂₁ 다면체를 8차원에서 두 창에 비춰요. 위 창은 30개씩 고리 여덟이 서는 콕서터 평면에서 열려 E7, E6, D5, D4, D6 의 평면을 지나 돌아오고, 아래 창은 몇 평면 앞서가요. 누르면 둘 다 콕서터 평면으로 돌아가요. | +| [Hairy Ball](patterns/hairy-ball.js) | 노이즈·흐름 | 위에는 털 난 공, 아래에는 털 난 도넛이 빛을 받으며 돌아요. 공은 어떻게 빗어도 털이 갈 데 없는 가마가 하나나 둘 금빛으로 남고, 도넛은 한 바퀴 매끈하게 빗겨요. 귀퉁이의 2 와 0 은 둘의 오일러 지표예요. 누르면 둘 다 다시 빗어서 가마가 다른 자리로 뛰어요. | +| [Hyperbolic Band](patterns/hyperbolic-band.js) | 공간·원근 | 칠각형이 한 꼭짓점에 셋씩 모인 쌍곡 평면을 띠 모형으로 세웠어요. 패널 너비 64 LED 에 평면의 폭이 다 들어가고, 쪽매는 띠를 따라 끝없이 올라가며 양옆 가장자리에서 한없이 작아져요. K3 이 {8,3}, {5,4}, {4,5}, {3,7} 로 쪽매를 바꾸고, 누르면 반 바퀴 돌아 제 모습으로 내려앉아요. | +| [Loewner Trace](patterns/loewner-trace.js) | 선·궤적 | 브라운 운동이 모는 슈람-뢰브너 곡선이 패널만 한 골짜기를 기어오르고, 그 곡선을 푸는 등각 사상의 격자가 곡선 끝을 돌아 휘어요. 화면은 곡선을 따라 끝없이 위로 흘러가요. K3 이 κ 를 4 에서 8 까지 올리면 곡선이 제 몸과 벽에 닿아 가둔 땅을 삼키고, 8 에서는 다 메워요. 누르면 새 브라운 경로로 다시 자라요. | +| [Look and Say](patterns/look-and-say.js) | 데이터 그림 | 콘웨이의 보고 말하기 수열이 한 줄씩 제 위 줄을 소리 내어 읽으며 쪽을 내려와요. 줄이 한 걸음에 1.3배씩 길어지면 글자가 3×5 숫자에서 막대로, LED 하나에 숫자 하나로 작아지고, 콘웨이의 원소 92개가 저마다 제 색이에요. K3 이 씨앗을 3, 312, 311311, 고정점 22 로 바꿔요. | +| [Rauzy Fractal](patterns/rauzy-fractal.js) | 프랙탈 | 트리보나치 치환의 라우지 프랙탈이 세 크기의 제 사본으로 판을 덮고, 그 고정점으로 끝없이 들어가요. 타일은 자라 한 크기 위가 되고, 가운데서 퍼지는 고리가 가장 큰 타일을 세 조각으로 나눠요. 누르면 조각 셋이 저마다의 벡터로 미끄러져 같은 테두리 안에 다시 맞물려요. | +| [Stadium Billiard](patterns/stadium-billiard.js) | 물리·스프링 | 공 이백 개가 방향을 1000분의 3 라디안 안에서만 달리해 한 점에서 흰 구슬 하나로 떠나요. 패널만 한 부니모비치 경기장의 둥근 끝이 구슬을 무지개 선으로 늘이고 접어서, 몇 초 만에 공들이 기체처럼 판을 채워요. K3 을 돌리면 탁자가 원이 되어 공이 흩어지지 않고, 누르면 새 무리가 떠나요. | +| [Wythoff Nim](patterns/wythoff-nim.js) | 알고리즘 | 위토프 게임의 스프라그-그런디 값 표를 모서리로 세웠어요. 지는 자리(값 0)는 황금비 두 줄을 따라 금빛이고, 값 0, 1, 2 가 차례로 쓸고 지나가며 저마다의 두 줄에서 번쩍였다 표로 가라앉아요. 누르면 흰 말이 떨어져 이기는 수로 모서리까지 게임을 해요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -1195,6 +1210,50 @@ K4 Spate 0 이면 물이 실 몇 가닥으로 갈라져요. 처음 값 0.06 이 남은 자리로 입자 갈래의 넷째를 세웠어요. 과학관에 놓인 확산 안개상자를 위에서 내려다봐요. 드라이아이스로 식힌 검은 펠트 위에서 알코올 안개가 왼쪽 벽의 등줄 빛을 받아요. 우주선 뮤온은 0.5 LED 폭의 곧은 선을 패널 끝까지 긋고, 라돈의 알파 입자는 3~14 LED 의 굵은 막대를, 전자는 18~73 LED 의 흐리고 구불구불한 실을 남겨요. 자취는 0.07초 만에 맺혀 처음 0.4초 동안 1.6배 밝다가, 9×17 마디의 대류 격자를 따라 1초에 1~2 LED 씩 흐르며 3.4초쯤에 번져 사라져요. 곁 칸으로 움직임 × 채움 4,1 을 재 봤어요. Warp 혼자 선 칸이에요. 채움은 씨앗 여덟이 다 0.302 아래인 0.17~0.26 이었지만, 움직임은 0.059~0.105 로 1.49 에 한참 못 미쳤어요. 켜진 LED 가 20% 일 때 움직임 1.49 를 내려면 켜진 LED 가 프레임마다 밝기의 4분의 1씩 바뀌어야 하고, 긴 뮤온 자취로 치면 1초에 50개쯤이에요. 한순간에 맺혀 천천히 흐르는 자취로는 닿지 않아서 그림대로 두었고, 움직임 × 채움 2,1 에 일곱 패턴과 함께 서요. 첫 판은 움직임 0.021 에 모양 바뀜 0.019 였고, 2초 화면이 거의 비어 있었어요. 자취가 생기는 빈도를 두 배로 올리고 뮤온 밝기를 0.6 에서 1.0 으로 올리자 0.074 와 0.094 가 됐고, 갓 맺힌 자취를 0.4초 동안 더 밝게 하자 움직임이 0.104 가 됐어요. 이 패널에서 새로 본 것은 0.5 LED 폭의 선이에요. LED 격자에 걸리는 자리마다 밝기가 달라져서, 비스듬한 뮤온이 저절로 물방울을 꿴 줄처럼 보여요. 토륨 막대의 알파 부채는 처음에 1초에 10개를 4.4초씩 남겨 흰 덩어리였고, 1초에 5개를 2.6초씩 남기자 한 줄기씩 읽혀요. 노브 넷은 0.05, 0.11, 0.13, 0.07 이에요. +## 28판 + +28판은 ROUND.md 의 주제 표가 바뀐 첫 판이에요(#212). 열 개가 다 주제 5 「낯선 수학」이라, 이름 있는 수학 대상 하나가 그림의 주인공이고 사물은 그리지 않았어요. 「낯선 수학 예」에서는 슈람-뢰브너 진화와 아즈텍 다이아몬드 둘을 골랐고, 나머지 여덟은 목록 밖에서 찾았어요. 일을 시작할 때는 표가 바뀌기 전이라 새 갈래를 수론과 위상 둘로 열었다가, 머지된 표가 새 갈래를 많아야 하나로 묶어서 수론(`number`) 하나만 남겼어요. Hairy Ball 은 노이즈·흐름으로, Look and Say 는 글자 대신 표가 적은 추상 갈래인 데이터 그림으로 옮겼어요. 주제 1 은 이번에도 겨눌 `*` 칸이 없었어요. 기준 178개에서 21장 모두 빈 칸이 없어서, 패턴이 하나뿐이던 칸 둘을 겨눴어요. Aztec Diamond 가 Millefiori 혼자이던 촘촘함 × 색 4,4 에, Stadium Billiard 가 Warp 혼자이던 움직임 × 채움 4,1 에 섰어요. 겨누지 않은 다섯 칸에도 둘째가 들어서 패턴이 하나뿐인 칸은 37 에서 30 이 됐고, 새로 혼자 선 칸은 없어요. 주제 4 는 Ford Circles 와 Loewner Trace, Hyperbolic Band, Stadium Billiard 로 넷이에요. 색 축의 위 끝이 Test Card 의 0.627 에서 Hyperbolic Band 의 0.642 로 올라갔어요. 주제 3 은 앞 바퀴 PR 에 창작자 글이 없어서 하지 않았어요. + +### Ford Circles + +주제 2 로 수론 갈래를 열고, 주제 4 로 실수선을 패널의 왼쪽 가장자리에 세웠어요. 분수 p/q 마다 반지름 1/(2q²) 인 원이 그 자리에 기대 서서 오른쪽으로 부풀고, 화면은 황금비 쪽으로 끝없이 들어가요. 맞닿은 두 원은 페리 이웃이고, 그 사이 중간수 (p+r)/(q+s) 자리에 다음 원이 나와요. Speed 2 에서 2.9초마다 황금비의 다음 수렴 분수가 올라와요. 창을 이웃한 두 수렴 분수의 틀로 잡아서 수가 늘 작게 남고, 1800초를 돌려 q 가 e^445 언저리에 가도 NaN 이 없었어요. 첫 판은 원 안을 고르게 칠해서 채움 0.971 에 `thirds` 98/93/98 인 회색 판이었어요. 테두리에서 안으로 번지는 빛을 반지름에 따라 깊게 하고 18 LED 에서 자르자 채움 0.801, 색 0.129 에서 0.180, 모양 바뀜 0.072 에서 0.200 이 됐고 `thirds` 는 76/88/75 예요. π 는 연분수의 292 때문에 큰 원 하나 안에서 20초쯤 머무는데, 그 자름 덕에 그동안도 테두리로 읽혀요. + +### Loewner Trace + +주제 4 로 슈람-뢰브너 곡선을 패널 바닥에서 위로 자라게 했어요. 그냥 위 반평면에 두면 SLE 가 높이만큼 옆으로도 퍼져서 1:2 패널의 양옆으로 나갔어요. 그래서 패널을 반 띠로 잡고 sin 으로 위 반평면에 옮겨, 한 줄 올라갈 때마다 크기가 e^(π/65) 배가 되게 했어요. 그러면 SLE 의 크기 불변이 끝없이 위로 흐르는 화면이 돼요. LED 마다 g_t(z) 와 |g_t'| 를 들고 다니며 갈라진 사상 하나마다 고치고, Im w/|g'| 가 0.6 LED 아래로 내려가면 곡선으로 칠해요. 바탕은 log(g_t − W_t) 의 격자라서, 곡선 끝에서 뻗는 빛살과 끝을 도는 동그라미가 곡선을 따라 휘어요. κ 가 4 를 넘으면 곡선이 가둔 땅을 삼킨 때의 색으로 빗금 쳐요. 처음에는 Im g 의 문턱으로 삼킨 땅을 정했다가 κ=4 의 깊은 열린 골까지 메워서, 맨 윗줄에서 채우는 flood fill 로 바꿨어요. 곡선이 가린 땅을 지운 첫 판은 아래 3분의 1이 19% 였고, 어둡게만 하자 70% 가 됐어요. `thirds` 는 100/95/70 이고, 움직임 2.631 은 이번 열 개 가운데 가장 커요. + +### Hyperbolic Band + +주제 4 로 쌍곡 평면을 띠 모형으로 세웠어요. 패널의 64 열에 평면의 폭이 다 들어가서, 칠각형이 한 꼭짓점에 셋씩 모인 {7,3} 쪽매가 띠의 가운뎃줄을 따라 끝없이 올라가고 양옆으로 갈수록 한없이 작아져요. 가운뎃줄이 쪽매의 대칭인 평행 이동 하나의 축이라 흐름에 끝이 없어요. 한 주기가 {7,3} 은 59 LED, {8,3} 은 31 LED, {5,4} 는 120 LED 이고, 주기를 돌면 화면이 한 LED 도 다르지 않게 돌아와요. LED 마다 기본 삼각형으로 거울 접기를 하는데 한 LED 가 평균 2.3번만 뒤집혀서, 접기 한 걸음의 값이 시간을 정했어요. atan2 를 다항식으로 바꾸고 쐐기 표를 두자 4.6 ms 가 3.7 ms 가 됐어요. 0~2번 열은 LED 하나가 타일보다 커서 색이 평균 낸 안개로 번지고, 선은 쌍곡 길이로 같은 굵기를 지켜요. 첫 색표는 초록이 많아서 청록에서 자홍으로 가는 색표로 바꿨고, 색 0.642 로 색 축의 위 끝이 됐어요. 누르면 패널 가운데에 가까운 변의 가운데를 축으로 3초 동안 반 바퀴 돌아요. 반 바퀴는 쪽매의 대칭이라 끝난 화면이 처음 화면과 평균 0.001 만 달라요. + +### Stadium Billiard + +주제 4 로 패널을 부니모비치 경기장으로 썼어요. 곧은 변이 패널의 양옆이고 위아래 끝이 반지름 32 의 반원이라 탁자가 패널 그대로예요. 주제 1 대신 Warp 혼자이던 움직임 × 채움 4,1 을 겨눠서, 빠르고 성긴 점이 검은 판을 채우게 했어요. 공 이백 개가 방향을 3/1000 라디안 안에서만 달리해 한 점에서 떠나면 2초쯤 흰 구슬 하나로 가다가, 3초에 무지개 선으로 늘어나고 7초에 기체가 돼요. 처음에는 1/1000 라디안에 1초 75 LED 로 보냈더니 8초까지 갈라지지 않았어요. 재 보니 둥근 끝을 지나며 78 LED 마다 벌어짐이 e 배였어요. 그래서 3/1000 라디안에 1초 125 LED 로 바꿨어요. 움직임 2.362, 채움 0.186 으로 겨눈 칸에 섰고 `thirds` 는 17/22/15 예요. 19/255 인 탁자 천은 켜진 LED 로 세지 않아요. 2~5초만 재면 움직임 0.63, 채움 0.07 이고 기체만 재면 3.1 과 0.25 라서, 칸은 두 때를 섞은 평균이 정했어요. K1 Hue 는 첫 판에서 0.0044 로 걸렸어요. 2~3초에 켜진 것이 구슬뿐이어서였고, 색상을 따라가는 탁자 천을 깔자 0.04 가 됐어요. 천을 더하기로 섞으면 모든 자취가 늘어나 기체의 채움이 0.44 였고, 천과 자취 가운데 밝은 쪽을 고르자 0.25 가 됐어요. K3 Round 를 끝까지 돌리면 탁자가 원이 돼요. 원은 적분 가능한 당구라서 공이 한 구슬로 남아요. + +### Aztec Diamond + +주제 1 대신 Millefiori 혼자이던 촘촘함 × 색 4,4 를 겨눴어요. 아즈텍 다이아몬드 여덟 개(온전한 것 둘, 패널 가장자리에 잘린 것 여섯)가 패널을 덮고, 도미노 셔플로 차수 1 에서 32 까지 자라요. Speed 2 에서 0.45초마다 한 차수씩 커서 14초에 다 자라요. 다 자라면 2×2 칸 뒤집기로 같은 분포를 지키며 원 안만 계속 뒤섞여요. 첫 판은 검은 바탕에 다이아몬드 둘이라 촘촘함 0.075, 색 0.417 이었고 패널의 52% 가 꺼져 있었어요. 도미노마다 뒤 LED 를 어둡게 해도 촘촘함은 0.21 까지만 갔고 색이 0.34 로 떨어졌어요. 패널을 다이아몬드로 다 덮자 채움 0.89, 색 0.69 가 됐어요. 도미노마다 밝기를 흩거나 방향마다 밝기를 달리해도 촘촘함은 0.24 와 0.18 이었어요. LED 에 묶은 체커판 하나로 어두운 칸을 60% 로 누르자 같은 색에서 0.35 가 됐고, 도미노가 미끄러질 때마다 모서리가 깜박이던 것도 멎었어요. LED 한 칸 격자에서 한 줄씩 엇갈린 두 칸 벽돌은 체커판이나 고른 색으로밖에 보이지 않아요. 겨눈 칸에는 촘촘함 0.350, 색 0.640 으로 섰고, 촘촘함 × 모양 바뀜 4,2 와 가장자리 × 복잡도 4,3 에서도 Sandpile 과 Stained Glass 의 둘째가 됐어요. 체커판이 화면을 시끄럽게 하는 값을 치렀어요. K4 Checker 를 -1 로 돌리면 고른 색이에요. + +### Hairy Ball + +털 난 공 정리를 그렸어요. 위에는 털 난 공, 아래에는 털 난 도넛이 빛을 받으며 돌아요. 공의 벡터장은 입체 사영한 평면에서 v(w) = e^{iθ}(w² − t²) 라서 지표 1 인 영점이 꼭 둘 있고, t=0 에서 지표 2 인 영점 하나로 합쳐져요. 가마의 빛은 그 참된 영점에 앉아요. 7초마다 다시 빗으며 맞선 가마 둘, 가까운 가마 둘, 합친 가마 하나를 차례로 돌고, 빗을 때마다 마흔 방향 가운데 가마가 보는 쪽을 향하는 것을 골라요. 첫 판은 털 길이 4, 뿌리 1500개라 털이 잡음으로 읽혔고 가마가 자주 뒤쪽에 있었어요. 채움 0.415, `thirds` 56/21/47, K4 Comb 반응 0.05 였어요. 뿌리를 1100 으로 줄이고 처음 길이를 8 로 늘리고 영점 가까이에서 털이 서게 하자 채움 0.524, `thirds` 69/32/54, Comb 0.08 이 됐어요. 이 해상도에서 털은 어두운 뿌리와 밝은 끝, Kajiya-Kay 윤기가 있어야 털로 읽혀요. 도넛을 관을 따라 빗으면 구멍이 가려져서 빗는 각을 ±0.9 라디안 안에 뒀어요. 위상 갈래는 열지 않아서 벡터장이 곧 흐름인 노이즈·흐름에 뒀어요. 움직임 × 복잡도 4,3 에서 Mandelbrot 의 둘째예요. + +### Gosset Polytope + +E8 의 근 240개를 고셋의 4₂₁ 다면체 꼭짓점으로 그렸어요. 모서리는 거리가 √2 인 쌍 6720개예요. 위 창은 콕서터 평면에서 열려 30개씩 고리 여덟이 서고, E7, E6, D5, D4, D6 의 콕서터 평면을 지나 돌아와요. Speed 2 에서 한 평면에 2.5초 서고 3.5초 동안 옮겨 가서 한 바퀴가 36초예요. 옮겨 가는 길은 주각을 따라가는 그라스만 측지선이고, 한 바퀴가 정확히 닫히는 것을 수로 확인했어요. 아래 창은 K4 만큼 평면 앞서가요. 첫 판은 두 창이 천천히 돌며 9.8 ms 였고, 모든 점이 반지름 29.5 안에 드니 걸음마다 자르던 것을 빼자 5.6 ms 가 됐어요. 그 돌림은 D4 의 육각별을 기울게 해서 빼고, 멈출 때마다 바깥 꼭짓점 하나가 위를 가리키게 돌렸어요. 움직임은 0.202 에서 0.103 이 됐지만 머무는 동안 고요한 결정으로 읽혀요. 64 LED 에서 E8 의 그림은 고리 여덟의 꼭짓점으로 읽히고, 모서리 그물은 선 한 LED 에 0.012 씩 쌓이는 빛으로만 보여요. D4, D5, E6 처럼 작은 콕서터 평면은 꼭짓점 여럿이 한 LED 에 떨어져 또렷해요. 첫 색표는 거의 분홍이었고, 속은 따뜻하고 가장자리는 차가운 색표로 바꾸자 색이 0.105 에서 0.152 가 됐어요. + +### Rauzy Fractal + +트리보나치 치환 1→12, 2→13, 3→1 의 라우지 프랙탈이에요. 처음에는 격자로 판을 덮은 주기 쪽매를 β⁻¹ 로 확대하려 했는데, 그 쪽매는 확대해도 제게 겹치지 않아요. βΛ 가 Λ 안에 들지 않아서예요. 그래서 세 크기의 제 사본으로 덮는 자기 복제 쪽매로 바꿔, 그 고정점으로 5초마다 1.356 배(1/|β|)씩 들어가며 4.7° 돌아요. 세 단계가 한 고리예요. 타일 하나가 무작위 점의 99.85% 를 덮고, β⁻¹p 의 종류가 p 의 하위 타일 글자와 99.1% 맞는 것을 수로 확인했어요. 글자 16만 개를 256×256 토러스 표에 걸자 빈 칸이 0 이었어요. 6만 6천 개로는 8,770 칸이 비었어요. 칸마다 조상 글자 16개를 넣어서 LED 하나가 표를 세 번쯤만 봐요. 처음 색은 조각마다 색상을, 단계마다 어두움을 줬는데 어두운 호박색이 갈색으로, 갈라질 때 섞인 색이 올리브로 보였어요. 나이를 따라가는 그러데이션으로 바꾸자 깨끗해졌어요. 누르면 타일마다 세 조각이 미끄러져 같은 테두리 안에 다시 맞물리는 도메인 교환이에요. 쪽매 전체에서 하면 미끄러지는 중간이 검은 틈과 회색 겹침뿐이라 타일마다 했어요. 채움 0.999, `thirds` 99/99/99 예요. + +### Look and Say + +콘웨이의 보고 말하기 수열이에요. 원소 92개의 표와 붕괴 규칙을 파일에 넣고, 원소마다 소리 내어 읽은 것이 붕괴 산물과 같은지, 씨앗 1 의 8항(Hf.Sn)부터 45항까지 맞는지 따로 확인했어요. 기억으로 적은 바나듐의 숫자가 틀렸던 것을 그 확인이 잡았어요. 줄은 한 걸음에 1.3배 길어져서 쪽이 네 크기의 활자로 내려가요. 3×5 숫자 다음은 너비 1 LED 에 숫자만큼(1~3 LED) 높은 막대라서, 같은 숫자의 연속이 평평한 계단으로 보여요. 3×5 숫자에서 바로 LED 하나에 숫자 하나로 가면 쪽의 3분의 2가 비었어요. 첫 판은 빈 쪽에서 시작해서 2~8초의 `thirds` 가 24/62/64 였어요. 20번 줄까지 빠르게 쳐 내고 거기서 읽는 빠르기로 늦추자 48/68/69, 움직임 0.87 에서 1.73, 채움 0.50 에서 0.62 가 됐어요. 색상을 청록에서 0.68 만큼 돌리던 첫 판은 첫 줄들이 고동색이었고, 반대로 돌리자 풀렸어요. 원소 색은 원자 번호를 따라가서 붕괴 산물끼리 색이 가까워요. 글자 갈래가 맞는 그림이지만, ROUND.md 가 주제 5 에 적은 추상 갈래 열셋에 글자가 없어서 데이터 그림에 뒀어요. + +### Wythoff Nim + +위토프 게임의 스프라그-그런디 값 표예요. 지는 자리(값 0)는 (⌊nφ⌋, ⌊nφ²⌋) 와 그 거울이라 황금비 두 줄을 따르고, 다른 값 k 도 저마다 그 기울기 언저리의 두 줄에 모이며 주기 없이 흔들려요. 처음에는 x 를 가로, y 를 아래로 그렸더니 가파른 줄이 103번 줄에서 패널을 나가고 왼쪽 아래가 빈 안개였어요. 표를 45° 돌려(u = x−y, v = x+y) 세우자 두 줄이 세로에서 ±13° 로 맞서서 아래 두 귀퉁이까지 닿았어요. 두 더미에서 같이 덜어 내는 수는 그 덕에 곧장 위를 가리켜요. 처음 색표는 천천히 올라가서 값 0~5 가 한 금빛 띠로 읽혔고 정확한 0 줄이 묻혔어요. 0 에만 밝은 금빛을 주고 1 부터 진홍으로 빨리 올리자 풀렸어요. 정수가 아닌 Zoom 은 들쭉날쭉한 무아레 마름모를 만들어서 정수로 반올림했어요. 쓸기는 값 K = 12(e^(2.5t/12) − 1) 로 가서 Speed 2 에서 3초에 10, 8초에 50 에 닿아요. K2 Speed 의 반응 0.04 가 넷 가운데 가장 약해요. `thirds` 는 62/100/100 이에요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -1237,13 +1296,13 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 24일 `dev`(`e25ae10`)의 168개에 27판의 열 개를 더한 178개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 24일 `dev`(`d99e838`)의 178개에 28판의 열 개를 더한 188개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 `study.ts --check --gaps` 가 `*` 를 붙인 칸 가운데 그림이 떠오르는 칸이에요. 칸은 가로와 세로를 낮은 쪽부터 1~4 로 세요. 페이지 기본 지도(가장자리 × 움직임)는 16칸이 다 차 있어요. -23판 뒤로는 `*` 가 붙은 칸이 없어요. 22판까지 남은 두 칸, 촘촘함 × 모양 바뀜 3,4 와 4,3 에 23판의 Bait Ball 과 Sequins 가 섰어요. 하나 남은 빈 칸이던 복잡도 × 채움 4,3 에는 24판의 Waggle Dance 가 섰어요. 복잡도가 2.173 을 넘고 채움이 0.558~0.815 인 자리예요. 복잡도 축 끝의 Contour Map(2.735)을 빼고 세면 차는 칸이라 `*` 가 붙지 않았고, 그 아래 복잡도 2.4~2.6 에 선 Woodpecker, Dripstone, Agate, Guilloché 는 채움이 다 0.93 을 넘었어요. 이제 지도 21장의 칸이 다 차서 `--gaps` 는 모든 줄에 `none` 을 찍어요. 주제 1 은 축 끝의 패턴 하나가 축을 늘려 새 빈 칸이 날 때까지 고를 칸이 없어요. Waggle Dance 에서 어려운 쪽은 채움이었어요. 벌집은 꺼진 LED 가 거의 없는 그림이라, 빈 방 바닥과 봉한 방 사이 이음매를 24 아래로 눌러 채움 0.732 를 냈어요. 복잡도는 켜진 LED 의 세 채널을 따로 흔드는 낟알로 쉽게 올라서, 첫 판은 오히려 2.766 으로 축 끝을 넘었어요. 25판도 기준 148개의 21장이 다 `none` 이라 주제 1 은 겨눌 칸이 없었어요. 그 대신 패턴이 하나만 선 칸을 셌어요. 148개에서 49칸이었고, 남은 자리로 짠 Penrose 가 그 가운데 여섯 칸에 둘째로 서서 158개에서는 43칸이에요. Penrose 가 겨눈 칸은 촘촘함 × 색 3,4 예요. 촘촘함 0.195~0.299 에 색 0.509 위인 자리이고, 4판의 Jacquard Loom 혼자 서 있었어요. Penrose 는 촘촘함 0.264, 색 0.583 으로 거기 섰어요. 같은 값으로 움직임 × 촘촘함 4,3 과 촘촘함 × 채움 3,3 의 Moire Rings, 촘촘함 × 복잡도 3,2 의 Peg Light, 복잡도 × 색 2,4 의 Gear Train, 채움 × 색 3,4 의 Marbling 곁에도 섰어요. 1 LED 어두운 선으로 나눈 작은 타일에 선명한 색을 여럿 두면 촘촘함과 색이 함께 높은 자리에 닿아요. 26판도 기준 158개의 21장이 다 `none` 이었어요. 패턴이 하나뿐인 칸 43 가운데 넷을 겨눴어요. Dark Field 는 검은 바탕 위의 흩은 빛으로 채움 0.263 에 촘촘함 0.222, 복잡도 2.081 이라, Foxfire 혼자이던 복잡도 × 채움 3,1 과 Peg Light 혼자이던 촘촘함 × 채움 3,1 에 씨앗 여덟이 다 들어요. Origami 는 어두운 탁자 위 종이 한 장이라 채움 0.264 이고, 2초의 정사각형이 8초에 좁은 학 기본형이 되어 모양 바뀜 0.255 로 Domino Run 혼자이던 채움 × 모양 바뀜 1,3 에 서요. Lichtenberg 는 Rabbit Ears 혼자이던 복잡도 × 모양 바뀜 4,3 에 씨앗 여덟 가운데 일곱이 들어요. 네 칸 말고는 바뀐 칸이 없어서, 168개에서 패턴이 하나뿐인 칸은 39칸이에요. 27판도 기준 168개의 21장이 다 `none` 이었고, 39칸 가운데 둘을 겨눴어요. Mitosis 는 채움 0.263, 색 0.540 으로 Neon Alley 곁의 채움 × 색 1,4 에 서요. 판의 4분의 1만 켜는 그림에서 색 0.5 를 넘기려면 켜진 LED 가 거의 다 순색 최대 밝기여야 해서, 형광의 번짐은 셈에 들지 않는 21/255 아래로 눌렀어요. Lava Lake 는 복잡도 2.222, 채움 0.271 로 Moth Lamp 곁의 복잡도 × 채움 4,1 에 서요. 복잡도는 프레임 바이트 값의 엔트로피를 거의 그대로 따라서, 어두운 지각의 결을 채널마다 0~24 전부로 넓히자 1.866 이 2.010 이 됐어요. 붉은 용암은 켜진 LED 하나에 24 를 넘는 바이트가 빨강 하나뿐이라, 이음매를 늘리기보다 주황 쪽으로 데우는 편이 채움을 덜 쓰고 복잡도를 벌었어요. 색 축의 아래 끝은 Cloud Chamber 의 0.052, 모양 바뀜 축의 아래 끝은 Climbing Wall 의 0.021 로 옮겨졌지만 혼자 서는 칸은 그대로라서, 178개에서 패턴이 하나뿐인 칸은 37칸이에요. +23판 뒤로는 `*` 가 붙은 칸이 없어요. 22판까지 남은 두 칸, 촘촘함 × 모양 바뀜 3,4 와 4,3 에 23판의 Bait Ball 과 Sequins 가 섰어요. 하나 남은 빈 칸이던 복잡도 × 채움 4,3 에는 24판의 Waggle Dance 가 섰어요. 복잡도가 2.173 을 넘고 채움이 0.558~0.815 인 자리예요. 복잡도 축 끝의 Contour Map(2.735)을 빼고 세면 차는 칸이라 `*` 가 붙지 않았고, 그 아래 복잡도 2.4~2.6 에 선 Woodpecker, Dripstone, Agate, Guilloché 는 채움이 다 0.93 을 넘었어요. 이제 지도 21장의 칸이 다 차서 `--gaps` 는 모든 줄에 `none` 을 찍어요. 주제 1 은 축 끝의 패턴 하나가 축을 늘려 새 빈 칸이 날 때까지 고를 칸이 없어요. Waggle Dance 에서 어려운 쪽은 채움이었어요. 벌집은 꺼진 LED 가 거의 없는 그림이라, 빈 방 바닥과 봉한 방 사이 이음매를 24 아래로 눌러 채움 0.732 를 냈어요. 복잡도는 켜진 LED 의 세 채널을 따로 흔드는 낟알로 쉽게 올라서, 첫 판은 오히려 2.766 으로 축 끝을 넘었어요. 25판도 기준 148개의 21장이 다 `none` 이라 주제 1 은 겨눌 칸이 없었어요. 그 대신 패턴이 하나만 선 칸을 셌어요. 148개에서 49칸이었고, 남은 자리로 짠 Penrose 가 그 가운데 여섯 칸에 둘째로 서서 158개에서는 43칸이에요. Penrose 가 겨눈 칸은 촘촘함 × 색 3,4 예요. 촘촘함 0.195~0.299 에 색 0.509 위인 자리이고, 4판의 Jacquard Loom 혼자 서 있었어요. Penrose 는 촘촘함 0.264, 색 0.583 으로 거기 섰어요. 같은 값으로 움직임 × 촘촘함 4,3 과 촘촘함 × 채움 3,3 의 Moire Rings, 촘촘함 × 복잡도 3,2 의 Peg Light, 복잡도 × 색 2,4 의 Gear Train, 채움 × 색 3,4 의 Marbling 곁에도 섰어요. 1 LED 어두운 선으로 나눈 작은 타일에 선명한 색을 여럿 두면 촘촘함과 색이 함께 높은 자리에 닿아요. 26판도 기준 158개의 21장이 다 `none` 이었어요. 패턴이 하나뿐인 칸 43 가운데 넷을 겨눴어요. Dark Field 는 검은 바탕 위의 흩은 빛으로 채움 0.263 에 촘촘함 0.222, 복잡도 2.081 이라, Foxfire 혼자이던 복잡도 × 채움 3,1 과 Peg Light 혼자이던 촘촘함 × 채움 3,1 에 씨앗 여덟이 다 들어요. Origami 는 어두운 탁자 위 종이 한 장이라 채움 0.264 이고, 2초의 정사각형이 8초에 좁은 학 기본형이 되어 모양 바뀜 0.255 로 Domino Run 혼자이던 채움 × 모양 바뀜 1,3 에 서요. Lichtenberg 는 Rabbit Ears 혼자이던 복잡도 × 모양 바뀜 4,3 에 씨앗 여덟 가운데 일곱이 들어요. 네 칸 말고는 바뀐 칸이 없어서, 168개에서 패턴이 하나뿐인 칸은 39칸이에요. 27판도 기준 168개의 21장이 다 `none` 이었고, 39칸 가운데 둘을 겨눴어요. Mitosis 는 채움 0.263, 색 0.540 으로 Neon Alley 곁의 채움 × 색 1,4 에 서요. 판의 4분의 1만 켜는 그림에서 색 0.5 를 넘기려면 켜진 LED 가 거의 다 순색 최대 밝기여야 해서, 형광의 번짐은 셈에 들지 않는 21/255 아래로 눌렀어요. Lava Lake 는 복잡도 2.222, 채움 0.271 로 Moth Lamp 곁의 복잡도 × 채움 4,1 에 서요. 복잡도는 프레임 바이트 값의 엔트로피를 거의 그대로 따라서, 어두운 지각의 결을 채널마다 0~24 전부로 넓히자 1.866 이 2.010 이 됐어요. 붉은 용암은 켜진 LED 하나에 24 를 넘는 바이트가 빨강 하나뿐이라, 이음매를 늘리기보다 주황 쪽으로 데우는 편이 채움을 덜 쓰고 복잡도를 벌었어요. 색 축의 아래 끝은 Cloud Chamber 의 0.052, 모양 바뀜 축의 아래 끝은 Climbing Wall 의 0.021 로 옮겨졌지만 혼자 서는 칸은 그대로라서, 178개에서 패턴이 하나뿐인 칸은 37칸이에요. 28판도 기준 178개의 21장이 다 `none` 이었고, 37칸 가운데 둘을 겨눴어요. Aztec Diamond 는 촘촘함 0.350, 색 0.640 으로 Millefiori 곁의 촘촘함 × 색 4,4 에 서요. 촘촘함은 가장 밝은 채널로 재서 네 색 도미노가 맞닿아도 턱이 없었고, LED 에 묶은 체커판이 그 턱을 줬어요. Stadium Billiard 는 움직임 2.362, 채움 0.186 으로 Warp 곁의 움직임 × 채움 4,1 에 서요. 공 이백 개가 1초에 125 LED 를 가면서 짧은 자취만 남겨요. 겨누지 않은 다섯 칸에도 둘째가 들어서 188개에서 패턴이 하나뿐인 칸은 30칸이에요. 남은 칸 가운데 수학으로 겨눠 볼 만한 것은 가장자리 × 움직임 1,4(Mandelbrot 혼자)예요. 번지면서 바쁜 자리라, 모듈러 형식의 영역 색칠에서 |f| 의 등고선이 빠르게 흘러가면 들어설 수 있어요. 색상만 도는 것은 움직임이 거의 없어요. 움직임은 가장 밝은 채널의 바뀜만 세요. 촘촘함이 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 를 냈어요. @@ -1257,7 +1316,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ### 아직 없는 갈래 -`families.json` 의 쉰두 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. +`families.json` 의 쉰세 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 28판이 연 수론 갈래는 Ford Circles 하나예요. 위상·매듭 갈래는 28판이 열려다가 한 판에 하나라는 새 표에 따라 열지 않아서, Hairy Ball 은 노이즈·흐름에 있어요. 호프 올뭉치나 매듭이 들어오는 판이 위상 갈래를 열 수 있어요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. ### 멋진 것 더 멋지게 @@ -1271,6 +1330,16 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 2~8초 동안 켜진 LED 가 5% 아래인 3분의 1이 있는 패턴이 넷이에요. `study.ts` 표의 `thirds` 칸이 이 값이에요. Jelly 4.5% 와 Pulse Bars 2.6% 는 위 3분의 1이 비고, Sparks 2.0% 와 Dragon Fold 4.9% 는 아래 3분의 1이 비어요. Dragon Fold 는 문턱 바로 아래예요. 6판의 Jacob's Ladder 는 위, 가운데, 아래가 27.0%, 21.4%, 35.1% 예요. 햇빛층에서 심해까지 내려가는 바다는 16판의 Abyss 가 했고, 덩굴은 15판의 Ivy 가 했어요. 어두운 판 위의 가는 빛 하나는 128줄을 다 지나가도 3분의 1마다 10% 를 넘기 어려워요. 6판의 처음 아크는 가운데 3분의 1을 7.4% 켰어요. 9판의 Sprout 은 접시꽃 둘이 위 3분의 1을 8.1% 켜요. 이렇게 철이 도는 패턴은 2~8초가 어느 철에 걸리느냐에 따라 값이 몇 배씩 달라서, 씨앗 여럿으로 재 봐야 해요. 테두리를 그리는 패턴은 테두리를 빼고 재요. 10판의 Tetromino Well 은 양쪽 벽 2 LED 만으로 3분의 1마다 6.3% 를 켜서, 벽을 넣어 재면 위 3분의 1이 늘 문턱을 넘어요. 바탕을 다 그리는 패턴은 `thirds` 칸이 3분의 1마다 90 을 넘어요. 14판의 Waterfall 은 98/93/97 이에요. 그런 패턴은 2~8초 동안 밝기가 24/255 넘게 바뀌는 LED 로 세어야 그림이 128줄을 쓰는지 보여요. 그렇게 세면 Waterfall 의 위 3분의 1은 25.9% 예요. 15판의 Ivy 는 벽을 그려 `thirds` 가 84/89/82 이고, 바뀐 LED 로 세면 48.7%, 70.6%, 29.2% 예요. 가만히 있는 가는 선은 켜져 있어도 바뀌지 않아요. 15판의 Incense 는 곧은 연기 실이 가운데 3분의 1을 지나도 바뀐 LED 가 3.7% 였고, 소용돌이가 그 3분의 1 안에서 시작되게 하자 15.5% 가 됐어요. 16판의 Abyss 는 물을 다 칠해서 `thirds` 가 100/80/22 이고, 바뀐 LED 로 세면 84.5%, 59.3%, 36.0% 예요. 한밤층은 아귀와 불꽃, 잠수정 등빛뿐이라 아래 3분의 1이 가장 낮아요. 16판의 Harp 는 액자를 빼고 줄만 재면 48/53/12 이고, 바뀐 LED 는 아래 3분의 1이 9.8% 예요. 거기는 받침과 공명통 발이고, 줄은 300 LED 남짓한 삼각형에만 있어요. 17판의 Marble Run 은 레일과 리프트가 서 있어서 `thirds` 가 39/30/68 이고, 바뀐 LED 는 40.7%, 54.3%, 59.7% 예요. 17판의 Ant Farm 은 모래가 바탕이라 `thirds` 가 100/97/96 이고, 바뀐 LED 는 6.9%, 34.5%, 53.4% 예요. 위 3분의 1은 모래 윗면과 그 위의 어두운 유리라서 개미가 드나드는 입구와 더미만 바뀌어요. 18판의 Server Rack 은 앞판을 13~23 으로 24 아래에 두어서, `thirds` 63/63/61 이 다 드라이브와 코드와 LED 의 빛이에요. 바뀐 LED 셈은 2~8초 가운데 한 프레임만 24 를 넘어도 그 LED 를 세서, 무작위 반짝임이 값을 부풀려요. 18판의 Pint 는 잔 기포 반짝임을 켜면 50.3%, 60.2%, 40.0% 이고 끄면 50.2%, 46.8%, 26.3% 예요. 빛의 흔들림도 그래요. Longcase Clock 은 촛불일 때 41.8%, 45.6%, 45.3% 이고 흔들림 없는 달빛에서는 2.6%, 4.2%, 5.9% 예요. 자라는 그림은 미리 돌리기를 시간 대신 상태로 멈추면 씨앗마다 같은 자리에서 열려요. 18판의 Bismuth 는 무리 꼭대기가 20번 줄에 닿을 때 멈춰요. 14번 줄까지 기다리면 2초에 이미 다 자라 있어서 모양 바뀜이 0.027 로 떨어졌어요. 19판의 Dripstone 은 잘린 암반이 4/255 쯤이라 위아래가 검게 보여 `thirds` 가 68/99/77 이었고, 암반에 제 빛을 주자 92/98/99 가 됐어요. Foxfire 는 땅을 80번 줄에 두었을 때 위 3분의 1이 5% 였고, 64번 줄로 올리고 그루터기를 세우자 16% 가 됐어요. 쌓이는 그림은 2~8초에 쌓이는 자리가 보이게 열어야 해요. Popcorn 은 더미가 높게 시작하면 아래 3분의 1의 바뀐 LED 가 4.4% 였고, 낮게 시작해 자라게 하자 27.9% 가 됐어요. 넓은 면을 물들이는 조명은 반짝임 없이도 바뀐 LED 를 올려요. Dripstone 은 세 조명이 15초에 한 바퀴 번갈아 켜져서 57.6%, 84.9%, 79.2% 예요. 20판에서는 한 번 일어나는 일이 2~8초 창에 드는지가 값을 갈랐어요. High Striker 는 7.3초에 종이 한 번 울려서 위 3분의 1의 바뀐 LED 가 6.5% 에서 95.6% 가 됐고, Kettle 은 7.1초에 스위치가 꺼지는 것이 창에 들어 모양 바뀜이 0.065 에서 0.168 이 됐어요. 위로 오르는 앞선은 모양 바뀜을 적게 내요. Chromatography 는 6초 동안 앞선이 26줄을 올라도 블록이 16줄 높이라 0.040 이었어요. 21판의 세로 일곱은 Nixie 를 빼고 방이나 바탕을 다 그려서 `thirds` 가 3분의 1마다 70 을 넘었어요. 그런 그림은 바탕을 지우거나 멈추고 한 번 더 재야 주인공이 128줄을 쓰는지 보여요. Chemical Garden 은 유리와 방을 검게 지우자 정원만의 위 3분의 1이 6.6% 였고, 씨앗 열여섯 가운데 하나는 3.2% 였어요. Lap Pool 은 바닥 햇빛 무늬의 시간을 멈추자 바뀐 LED 가 3분의 1마다 18.7~52.1% 였어요. Woodpecker 는 새를 빼고 재도 바뀐 LED 가 57.3/35.1/50.0% 라서, 그 값은 거의 다 잎 사이로 움직이는 햇빛 반점에서 와요. 조금 위에서 본 장면은 세로를 싸게 얻어요. Pop-up Book 은 누운 페이지가 깊이의 5분의 1 줄만 써서, 책을 아래 34줄에 두고도 선 카드가 위 3분의 1까지 닿아요. 닉시관 하나는 지름이 높이의 절반쯤이라 Nixie 의 IN-18 은 54 LED 너비에 109줄로 서요.Slinky 는 계단과 벽을 빼고 스프링과 그림자만 세면 켜진 LED 가 0.4/2.6/3.0% 이고, 스프링이 켠 계단등까지 세면 20.9/16.5/8.6% 예요. 22판의 세로 다섯 가운데 넷은 바탕을 다 칠해서 `thirds` 가 말해 주는 것이 적었어요. 바탕을 검게 지운 사본으로 다시 재면 Liftoff 는 로켓만의 위 3분의 1이 켜진 LED 3.7%, 바뀐 LED 8.2% 라서, 그 3분의 1의 변화는 대부분 카메라가 고개를 들며 어두워지는 하늘에서 와요. Bowling Lane 은 공을 던지지 않게 하면 바뀐 LED 가 5.2/9.5/6.6% 로 떨어져서, 그 값은 거의 다 공과 그 빛이에요. Circulation 은 바탕을 21 아래로 내려 몸만 켜진 LED 로 세게 해서 `thirds` 가 34/50/26 이에요. 남은 자리로 짠 Flame Test 는 실험대가 어두워 아래 3분의 1의 바뀐 LED 가 4.6% 였어요. 불꽃빛의 몫을 0.42 에서 0.52 로 올려도 1.3 포인트만 올라서, 어두운 바닥은 빛을 비추는 것보다 2~8초 창 안에 그 자리로 가는 움직임이 있어야 바뀐 LED 가 올라요. 23판의 세로 일곱 가운데 Proscenium 은 금박 아치가 세 3분의 1을 다 지나가서, 아치를 검게 바꿔 다시 재면 `thirds` 가 94/97/69 에서 63/85/65 가 돼요. Final Approach 는 위 3분의 1을 밤하늘이 채워서, 도시 빛을 받은 천정을 33/255 로 두어 문턱 24 를 넘겼어요. 가는 선 하나가 지나가는 3분의 1은 둘레를 같이 밝혀야 해요. Axon 의 가운데 3분의 1은 축삭 한 줄이 지나가는 자리라서, 수초의 빛과 비스듬히 지나가는 이웃 축삭 둘을 두어 바뀐 LED 가 23.6~35.5% 가 됐어요. 한 번 일어나는 일은 2~8초 창에 들게 짰어요. Hanging Scroll 은 첫 그림이 걸려 있는 시간을 2.2초로 줄여 4.8~6.8초에 말려 올라가게 했고, Giraffe 는 첫 물 마시기를 3.4초에 두었어요. Elephant Toothpaste 의 아래 3분의 1은 바뀐 LED 가 9.7% 로 일곱 가운데 가장 낮아요. 기둥이 6.8초에 꺾이기 전까지는 실린더와 실험대가 서 있기만 해서예요. 24판의 세로 일곱 가운데 다섯은 바탕을 다 칠해서 바뀐 LED 로 봤어요. 23판이 물은 어두운 하늘의 떼는 Emergence 가 답했어요. 하늘을 지우고 박쥐만 세면 3분의 1마다 13.4~13.6, 9.9~10.2, 5.1~5.3% 가 켜져서, 기둥이 좁은 아래 3분의 1은 문턱을 겨우 넘어요. 2~8초 동안 바뀐 LED 로 세면 아래 3분의 1도 23.5~25.0% 예요. 자라는 것의 키가 위 3분의 1을 정해요. Potter's Wheel 은 그릇을 64~90줄로 빚을 때 씨앗 넷 가운데 둘이 위 3분의 1의 바뀐 LED 0.6% 와 0.0% 였고, 76~92줄로 키우자 열여섯 모두 15.2% 를 넘었어요. 바탕의 움직임을 멈추고 다시 재면 주인공의 몫이 보여요. Tower Crane 은 구름을 멈춰도 바뀐 LED 가 45.2/54.1/36.3% 이고, Pipe Organ 은 장미창 빛을 꺼도 3분의 1마다 18.2% 를 넘어요. Flying Trapeze 는 `thirds` 가 81/35/34 로 일곱 가운데 가장 고르지 않지만, 팔로 스폿 빛줄기가 연무 속을 쓸어서 바뀐 LED 는 93.9/87.9/57.5% 예요. 25판의 세로 여섯 가운데 넷은 바탕을 다 칠해서 `thirds` 가 3분의 1마다 97 을 넘었고, 바탕을 멈추거나 지운 사본으로 다시 쟀어요. Eruption 은 하늘의 불빛과 부푸는 지층을 멈춰도 바뀐 LED 가 56.4~67.4/39.1~55.5/15.7~17.7% 이고, 아래 3분의 1의 값은 마그마 방의 대류와 화도를 오르는 가스 덩어리에서 와요. 빨강이 255 에 붙은 불빛은 가장 밝은 채널로 재면 결이 보이지 않아요. 플리니식에서 마그마 방이 밝기 끝에 붙자 아래 3분의 1의 바뀐 LED 가 5.3% 였고, 방의 온도가 기둥의 맥박을 따라 0.16 오르내리게 하자 17.7% 가 됐어요. Excavation 은 구름을 멈추면 24.3/12.5/15.3%, Skydiver 는 구름과 그림자를 멈추면 26.7/66.2/23.7% 예요. Rice Terraces 는 하늘 36줄을 빼도 위 3분의 1의 바뀐 LED 가 12.5% 라서, 맨 위 논들의 구름 반사가 그 3분의 1을 움직여요. 가는 선도 쓸고 지나가면 바뀐 LED 를 많이 켜요. Marionette 는 무대를 검게 지우면 인형과 실, 조종대가 켜는 LED 가 20.5/18.1/10.4% 인데 바뀐 LED 는 67.3/76.8/64.2% 예요. 조종대가 기울 때마다 실 일곱이 옆으로 쓸려서, 15판 Incense 의 곧은 연기 실이 3.7% 였던 것과 반대예요. 물에 비친 그림은 아래 3분의 1을 싸게 채워요. Ferris Wheel 은 위 장면을 0.72 로 눌러 41줄 호수에 비춰서 아래 3분의 1의 바뀐 LED 가 80~81% 예요. 이야기가 있는 데이터 그림은 여는 해가 세로를 정해요. Population Pyramid 는 1820년에서 열 때 막대가 0~78살에만 있어서, `thirds` 13/20/40 의 위 3분의 1이 연도와 라벨뿐이었어요. 라벨과 눈금을 지운 사본의 위 3분의 1은 1975년에서 열면 5.6%, 1985년 6.2%, 1995년 7.3%, 2000년 7.8% 여서 1995년에서 열어요. 26판의 세로 여섯 가운데 다섯은 바탕을 다 칠해서 `thirds` 가 3분의 1마다 79 이상이었고, 바탕을 지운 사본으로 다시 쟀어요. 단면을 물높이나 땅에서 자르면 주인공이 세 3분의 1에 다 걸쳐요. Iceberg 는 물높이를 30번 줄에 두어 빙산의 9분의 8이 물 아래에 서서, 하늘과 물을 지워도 빙산만으로 켜진 LED 가 30/67/30% 예요. Geyser 는 땅속 화도와 물방이 아래 3분의 1을 맡아서, 하늘과 땅을 지운 사본의 아래 3분의 1이 13.6% 켜지고 14.2~14.7% 바뀌어요. 가마 불꽃은 Eruption 의 마그마 방과 같은 문턱에 걸렸어요. Noborigama 의 흰 열 칸은 빨강이 255 에 붙고 일렁임이 온도로만 갈 때 2~8초 동안 바뀐 LED 가 0.8% 와 4.2% 였어요. 빨강을 232 에서 멈추고 밝기를 0.72~1 배로 흔들자 96.9% 와 97.0% 가 됐어요. 가장 밝은 채널이 끝에 붙은 불빛은 색만 흔들어서는 바뀐 LED 로 잡히지 않아요. 위에서 내려다본 판은 3분의 1마다 맡을 것을 따로 두어야 해요. Rice Planting 은 심은 모가 위, 일꾼 줄이 가운데, 모판과 못자리가 아래를 맡아서 물을 지운 사본의 켜진 LED 가 15/16~18/40% 예요. 못자리까지 지우면 아래 3분의 1이 3.9~5.0% 라서, 논둑 길에 모판 둘을 놓아 5.7~6.7% 로 올렸어요. 한 번 일어나는 일은 이번에도 2~8초 창에 두었어요. Implosion 은 2.4초에 발파해 7초쯤 먼지가 꼭대기에 닿고, Pole Vault 는 1.97초에 달려 나가 5.14초에 가로대 위 정점을 찍어요. 가는 선이 쓸고 지나가는 그림은 켜진 LED 가 적어도 바뀐 LED 가 많아요. Pole Vault 는 경기장을 지우면 선수와 장대가 위 3분의 1을 1.4~1.7% 만 켜지만, 2~8초 동안 바뀐 LED 는 22.8~23.2% 이고 장대가 휘며 쓰는 가운데 3분의 1은 92.8% 예요. 27판의 Funicular 는 그 물음을 케이블로 다시 쟀어요. 바탕을 지우고 케이블만 남기면 2~8초 동안 바뀐 LED 가 2.7%, 3.3%, 0.3% 이고, 객차와 함께 두면 케이블이 보태는 몫은 위 3분의 1의 0.1%p 예요. 제자리에서 길이 방향으로 꼬임만 미끄러지는 선은 제 밑의 LED 만 바꿔요. 가는 선은 패널을 가로질러 쓸고 가야 바뀐 LED 를 싸게 얻어요. Climbing Wall 은 홀드를 높이 전체에 고르게 박아도 사람과 로프만 재면 켜진 LED 가 2.1%, 4.1%, 6.1% 로 Pole Vault 와 같은 자리였어요. 키 20줄 남짓한 사람 하나는 어느 3분의 1에 있든 그 칸을 몇 % 밖에 켜지 못하고, 그 사람을 따라 오르는 스폿 빛이 바뀐 LED 를 22% 언저리로 올려요. Glassblower 는 덩어리가 105번 줄까지만 내려와 아래 3분의 1이 1.1% 였고, 잘린 조각이 떨어져 부서지는 컬릿 더미를 바닥에 깔자 41% 가 됐어요. 떨어지는 것이 닿을 바닥을 그리는 것이 아래 3분의 1을 채우는 가장 싼 길이었어요. +28판의 넷은 수학이 세로를 고른 경우예요. Ford Circles 는 실수선을, Hyperbolic Band 는 띠 모형의 띠를, Loewner Trace 는 위 반평면을 옮긴 반 띠를, Stadium Billiard 는 경기장의 곧은 변을 패널의 긴 변에 놓았어요. 넷 다 화면 전체가 그 수학이라 지울 바탕이 없어요. 셋은 3분의 1마다 70% 를 넘고, Stadium Billiard 는 19/255 인 탁자 천이 셈에 들지 않아 공과 자취만으로 17/22/15 예요. 그냥 위 반평면의 SLE 는 높이만큼 옆으로도 퍼져서 1:2 패널에 맞지 않았어요. 크기가 한 줄마다 e^(π/65) 배가 되는 반 띠로 옮기자 크기 불변이 위로 흐르는 화면이 됐어요. + +### 낯선 수학 + +주제 5 로 쓴 수학과 다음에 해 볼 수학이에요. 28판이 처음 열 개를 썼어요. 슈람-뢰브너 진화(Loewner Trace), 포드 원(Ford Circles), 털 난 공 정리(Hairy Ball), 아즈텍 다이아몬드와 북극권(Aztec Diamond), 부니모비치 경기장(Stadium Billiard), 라우지 프랙탈(Rauzy Fractal), 고셋의 4₂₁ 다면체와 E8(Gosset Polytope), 콘웨이의 보고 말하기 수열(Look and Say), 위토프 게임의 그런디 값(Wythoff Nim), 쌍곡 평면의 띠 모형(Hyperbolic Band)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드 둘이고, 모듈러 형식과 리틀우드 다항식의 근, 호프 올뭉치, 클라인 군의 극한 집합, 세 간격 정리, 외부 당구가 남았어요. + +목록 밖에서 다음에 해 볼 것이에요. 리만 제타 함수를 임계선 위에서 리만-지겔 공식으로 셈하면 하디의 Z(t) 가 세운 패널을 따라 흐르고 영점이 지나갈 때마다 번쩍여요. t 가 수백이어도 항이 열 개 안팎이라 가벼워요. 길브레스 추측(1958)의 표는 소수의 차를 거듭 절댓값으로 빼 내린 줄들이라 128줄이 그대로 표가 되고, 맨 앞 칸이 늘 1 이에요. 콜라코스키 수열(1965)은 제 길이 부호를 읽으면 제가 되는 1 과 2 의 줄이라 리듬·펄스에 맞아요. 영 도형의 플랑셰렐 성장은 상자가 하나씩 붙으며 로건-셰프와 베르시크-케로프의 극한 곡선(1977)으로 다가가요. 아즈텍 다이아몬드의 북극권과 같은 극한 모양이라 같은 판에 두지 않아요. 슈타이너 사슬은 반전 하나로 고리의 원들이 미끄러지는 포리즘이고, 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. Ford Circles 와 같은 수론이라 그 갈래의 둘째로 맞아요. + +28판에서 본 것이에요. 수학 대상은 셈이 정확해서, 끝없는 확대나 흐름이 거의 다 작은 틀 하나로 돌아요. Ford Circles 는 창을 이웃한 두 수렴 분수의 틀로 잡아 1800초 뒤에도 수가 작고, Hyperbolic Band 는 59 LED 마다, Rauzy Fractal 은 세 단계마다, Gosset Polytope 는 36초마다 화면이 정확히 돌아와요. 반대로 무작위가 주인공인 셋은 2~8초 창이 어느 때에 걸리느냐가 값을 정했어요. Stadium Billiard 는 2~5초만 재면 움직임 0.63, 기체만 재면 3.1 이었어요. 차례대로 자라는 Look and Say 는 빈 쪽에서 열리면 2~8초의 `thirds` 가 24/62/64 였고, 처음 스무 줄을 빠르게 쳐 내자 48/68/69 가 됐어요. 수학을 그대로 그린 화면은 그대로 두면 너무 고요하거나 너무 어두웠어요. Ford Circles 의 고른 원판은 채움 0.971 의 회색 판이었고, Gosset Polytope 의 모서리 그물은 LED 하나에 0.012 씩만 쌓였어요. 빛을 테두리나 고리처럼 수학이 정한 자리에 모으면 읽혔어요. + ## 다시 돌리기 `web/` 에서 실행해요. `web/node_modules` 가 있어야 해요. From 43dcf15be67d14f308bfe1e5fd4a277fc01552b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:27:10 +0000 Subject: [PATCH 14/15] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2028=ED=8C=90:=20index.html=20=EC=9D=84=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EC=A7=93=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HXbdUBpnpkK9obqLQFXaCS --- incoming/pattern-study/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index d28b25c45..175ee5e46 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,7 +141,7 @@

패턴 연구

- +