From 0afb6dd318fc6a2365340190a2052eb8a2a957ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:01:49 +0000 Subject: [PATCH 1/9] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=97=B0=EA=B5=AC=2049=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_017mBLLjEyWbBi1Ag71cFsLX From 9452e640fe1f5de44824b0753b5573e5de63ab65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:49:12 +0000 Subject: [PATCH 2/9] patterns: add Tennis Racket and Gargoyle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017mBLLjEyWbBi1Ag71cFsLX --- incoming/pattern-study/patterns/gargoyle.js | 454 ++++++++++++++++++ .../pattern-study/patterns/tennis-racket.js | 400 +++++++++++++++ 2 files changed, 854 insertions(+) create mode 100644 incoming/pattern-study/patterns/gargoyle.js create mode 100644 incoming/pattern-study/patterns/tennis-racket.js diff --git a/incoming/pattern-study/patterns/gargoyle.js b/incoming/pattern-study/patterns/gargoyle.js new file mode 100644 index 000000000..a1a36b63b --- /dev/null +++ b/incoming/pattern-study/patterns/gargoyle.js @@ -0,0 +1,454 @@ +// ===== Patternflow pattern ===== +// Title: Gargoyle +// Family: halloween +// Idea: A stone gargoyle juts from a cathedral ledge into a night storm, rain slanting across bruised purple clouds, and pours the gutter's water from its open jaws in a long arc that falls the whole height of the panel into a splashing puddle; every few seconds lightning flashes behind the spires, throws the stone into black silhouette with a bright rim, and leaves the gargoyle's eyes glowing poison green. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Rain=0..1, Lightning=0..1 +// +// Knob 1: Hue. The storm's tint, kept to the cool side so the dark sky never reads brown: bruised violet at 0, blue at 0.25, teal at 0.5, sea green at 0.75 and a sickly green at 1. The stone takes a little of it; the water, the stained glass and the green eyes stay as they are. +// Knob 2: Speed. At 2 a parcel of water takes 1.2 seconds from the jaws to the puddle and a raindrop crosses the panel in 0.8; at 0.2 the water hangs ten times slower, at 3 it falls half as fast again. The clouds scud on the same clock, and the gaps between strikes stretch up to 1.6 times at 0.2 and shrink by a fifth at 3. A flash always lasts the same half second. +// Knob 3: Rain. At 0 a steady rain of 90 drops and a stream three LEDs thick that frays into drops on the way down; at 1 a downpour of 280 drops slanting harder, and a gush four LEDs thick at the jaws, thrown 5 LEDs further, that frays to eight and splashes twice as high. +// Knob 4: Lightning. The first strike always comes 1.5 seconds after the start. Then at 0 one every 4.5 seconds on average, a bolt with at most one branch; at the opening 0.06 one every 4.3; at 1 one every 0.9 seconds, brighter, with up to five branches and a third return stroke. Each strike leaves the eyes glowing green for about 2.5 seconds. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, exp, hypot, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const q20 = (v) => floor(v * 20) / 20; + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p, i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p, i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p]; +} + +// A fixed generator for the stonework, so the cathedral never changes; +// Math.random is left for the weather. +function prng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +// Smooth value noise on a 16 x 16 lattice that wraps, for the clouds. +const LAT = new Float32Array(256); +{ + const r = prng(11); + for (let i = 0; i < 256; i++) LAT[i] = r(); +} +function noise(x, y) { + const xi = floor(x), yi = floor(y), fx = x - xi, fy = y - yi, u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); + const a = LAT[(yi & 15) * 16 + (xi & 15)], b = LAT[(yi & 15) * 16 + ((xi + 1) & 15)]; + const c = LAT[((yi + 1) & 15) * 16 + (xi & 15)], d = LAT[((yi + 1) & 15) * 16 + ((xi + 1) & 15)]; + return mix(mix(a, b, u), mix(c, d, u), v); +} + +// What each LED is: sky, the far tower, a near pinnacle, the near wall and +// ledge, the gargoyle, its open mouth, the street, the puddle, stained glass. +const SKY = 0, FAR = 1, PIN = 2, WALL = 3, GARG = 4, MOUTH = 5, STREET = 6, POOL = 7, GLASS = 8; +// The water leaves the jaws here; the street's top row; the puddle. +const MX = 34.5, MY = 25, GROUND = 120, PX = 51, PY = 123.5, PRX = 13, PRY = 3.6; +// The eye, two LEDs on the head. +const EYE = [20 * W + 29, 20 * W + 30]; +// Water: gravity and the jet's speed out of the jaws at Speed 2, in LEDs. +const G = 150, VX = 14, VY = -6, WIND = 3; +// Raindrops on the panel at Rain 0 and at 1, and their fall in LEDs a second. +const RAIN0 = 90, RAIN1 = 280, FALL = 165; +// Where a new drop starts: far enough left that its slant brings it in. +const spawnX = (vx) => -((vx / FALL) * H) + (W + (vx / FALL) * H) * Math.random(); + +const inEl = (x, y, cx, cy, rx, ry) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 < 1; +function inCap(x, y, ax, ay, bx, by, r) { + const dx = bx - ax, dy = by - ay, t = cl(((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy), 0, 1); + return hypot(x - ax - t * dx, y - ay - t * dy) < r; +} +function inPoly(x, y, P) { + let c = false; + for (let i = 0, j = P.length - 2; i < P.length; j = i, i += 2) + if (P[i + 1] > y !== P[j + 1] > y && x < ((P[j] - P[i]) * (y - P[i + 1])) / (P[j + 1] - P[i + 1]) + P[i]) c = !c; + return c; +} +// The gargoyle, crouched on the ledge facing right: haunch, back, chest, +// neck, head, snout and jaw, horn and ear, legs, and a folded bat wing. +const WING = [17, 24.5, 14, 19, 10.5, 13.5, 10, 18, 6.8, 14.5, 6, 19.5, 2.6, 17.5, 3.4, 24, 8, 27.5]; +function gargoyle(x, y) { + if (inPoly(x, y, [27.4, 24.3, 34.8, 23.7, 34.3, 26.1, 27.6, 25.7])) return MOUTH; + const body = + inEl(x, y, 9, 31.5, 7.5, 6.5) || inCap(x, y, 9, 28, 20, 26.5, 5) || inEl(x, y, 21, 29.5, 4, 5) || + inCap(x, y, 22, 26, 26.5, 22.5, 3.3) || inEl(x, y, 28, 21.5, 4.2, 3.6) || inCap(x, y, 29, 21.6, 34.2, 22.6, 1.7) || + inCap(x, y, 27.5, 26.4, 32.8, 27.2, 1.2) || inPoly(x, y, [25.2, 19.5, 27.6, 18.6, 22, 11.5]) || inPoly(x, y, [29, 18.8, 31, 19.2, 31.6, 14.6]) || + inCap(x, y, 21.5, 32, 22.5, 37, 2.1) || inEl(x, y, 23.6, 37.4, 2.7, 1.3) || inCap(x, y, 5.5, 33, 7.5, 37.2, 2.4) || + inEl(x, y, 9.2, 37.6, 3, 1) || inPoly(x, y, WING); + return body ? GARG : -1; +} + +export function setup(params) { + const kind = new Uint8Array(N), tone = new Float32Array(N), rnd = prng(5); + const blk = new Float32Array(64); + for (let i = 0; i < 64; i++) blk[i] = 0.42 + 0.16 * rnd(); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, cx = x + 0.5, cy = y + 0.5; + let k = SKY, t = 0; + // The far tower: a spire with crockets every 8 rows on a square tower, + // corner pinnacles where they meet. + const ax = abs(cx - 52); + if (y >= 72 && ax <= 6.5) k = FAR; + else if (y >= 12 && y < 72 && ax <= 0.6 + (5.4 * (y - 12)) / 60 + ((y - 12) % 8 < 2 ? 1 : 0)) k = FAR; + else if (y >= 62 && y < 72 && (abs(cx - 45.5) <= ((y - 62) * 1.2) / 10 || abs(cx - 58.5) <= ((y - 62) * 1.2) / 10)) k = FAR; + // Two near pinnacles on the parapet, then the parapet, the ledge + // and the wall with its blocks. + for (const [px, tip, hw] of [[4.5, 1, 4.2], [20, 4, 3.2]]) { + if (y >= tip && y < 30) { + const u = y - tip, w = u < 3 ? (u === 1 ? 1.4 : 0.6) : 0.5 + (hw * (u - 3)) / (27 - tip) + (u % 5 === 3 ? 1 : 0); + if (abs(cx - px) <= w) (k = PIN), (t = cx < px ? 0.66 : 0.42); + } + } + if (y >= 30 && y < 38 && x <= 22) (k = WALL), (t = y >= 32 && y <= 35 && x % 5 >= 2 && x % 5 <= 3 ? 0.08 : 0.5); + if (y >= 38 && y < 42 && x <= 24) (k = WALL), (t = [0.85, 0.62, 0.45, 0.25][y - 38]); + if (y >= 42 && y < GROUND && x <= 14) { + k = WALL; + const row = floor((y - 42) / 6), bx = x + (row & 1 ? 4 : 0); + t = (y - 42) % 6 === 5 || bx % 8 === 7 ? 0.22 : blk[(row * 3 + floor(bx / 8)) & 63]; + if (x === 14) t *= 0.6; + // A lancet window with a pointed head and diamond leading. + const wy = y - 58, hwin = wy < 0 ? -1 : wy < 6 ? 3 * sqrt(1 - ((6 - wy) / 6.5) ** 2) - 0.3 : 3; + if (y <= 92 && abs(cx - 7.5) <= hwin) (k = GLASS), (t = (x + y) % 4 === 0 || (x - y + 400) % 4 === 0 ? 0 : 1 + ((floor((x + y) / 4) + floor((x - y + 400) / 4)) % 3)); + else if (y <= 93 && abs(cx - 7.5) <= hwin + 1.2 && wy >= -1) t = 0.75; + } + // Cobbles in rows two LEDs deep, wider toward us, each row offset by + // half a stone, the top of each stone wet and lighter. + if (y >= GROUND) { + const row = (y - GROUND) >> 1, sw = 3 + (row >> 1), sx = x + (row & 1 ? sw >> 1 : 0); + k = STREET; + t = sx % sw === 0 ? 0.06 : (y - GROUND) & 1 ? 0.16 : 0.27 + 0.08 * ((floor(sx / sw) * 5 + row) % 3 === 0); + } + if (y >= GROUND && inEl(cx, cy, PX, PY, PRX, PRY)) k = POOL; + const g = gargoyle(cx, cy); + if (g >= 0) k = g; + (kind[p] = k), (tone[p] = t); + } + // The gargoyle's stone shaded by how deep each LED lies inside it (a + // chamfer distance) and which way the edge faces: lit from the upper left + // in four steps; the wing ribs and the mouth dark, teeth pale. + const d = new Float32Array(N); + for (let p = 0; p < N; p++) d[p] = kind[p] === GARG || kind[p] === MOUTH ? 99 : 0; + for (let y = 1; y < H - 1; y++) for (let x = 1; x < W - 1; x++) { const p = y * W + x; d[p] = min(d[p], d[p - 1] + 1, d[p - W] + 1, d[p - W - 1] + 1.4, d[p - W + 1] + 1.4); } + for (let y = H - 2; y > 0; y--) for (let x = W - 2; x > 0; x--) { const p = y * W + x; d[p] = min(d[p], d[p + 1] + 1, d[p + W] + 1, d[p + W + 1] + 1.4, d[p + W - 1] + 1.4); } + for (let y = 1; y < H - 1; y++) + for (let x = 1; x < W - 1; x++) { + const p = y * W + x; + if (kind[p] !== GARG) continue; + const gx = min(d[p + 1], 4) - min(d[p - 1], 4), gy = min(d[p + W], 4) - min(d[p - W], 4), gl = hypot(gx, gy) || 1; + const face = d[p] < 3.5 ? (0.55 * gx + 0.8 * gy) / gl : 0, lit = 0.6 + 0.4 * face * (1 - min(d[p], 3) / 4); + tone[p] = [0.32, 0.48, 0.64, 0.82][cl(floor(lit * 4 - 0.6), 0, 3)]; + } + const rib = (ax, ay, bx, by) => { + for (let s = 0; s <= 20; s++) { + const p = floor(mix(ay, by, s / 20)) * W + floor(mix(ax, bx, s / 20)); + if (kind[p] === GARG) tone[p] = 0.2; + } + }; + rib(16, 24, 10.5, 14.5), rib(15.5, 24.5, 7, 15.5), rib(15, 25, 3, 18.5); + for (let x = 28; x <= 34; x += 2) { + if (kind[24 * W + x] === MOUTH) (tone[24 * W + x] = 1), (kind[24 * W + x] = GARG); + if (kind[25 * W + x + 1] === MOUTH) (tone[25 * W + x + 1] = 1), (kind[25 * W + x + 1] = GARG); + } + for (const p of EYE) tone[p] = 0.06; + tone[19 * W + 29] = tone[19 * W + 30] = 0.9; + // The trough along the gargoyle's back that carries the gutter's water to + // its jaws: the top LED of its stone in each column from the wing to the head. + const trough = []; + for (let x = 17; x <= 26; x++) + for (let y = 14; y < 40; y++) + if (inCap(x + 0.5, y + 0.5, 9, 28, 20, 26.5, 5) || inCap(x + 0.5, y + 0.5, 22, 26, 26.5, 22.5, 3.3)) { + if (kind[y * W + x] === GARG) trough.push(y * W + x); + break; + } + // The puddle's far edge, where it meets the cobbles, catches the light. + for (let p = W; p < N; p++) if (kind[p] === POOL && kind[p - W] !== POOL) tone[p] = 1; + // Stone that touches the sky catches the flash behind it. + const rim = new Uint8Array(N); + for (let y = 1; y < H - 1; y++) + for (let x = 1; x < W - 1; x++) { + const p = y * W + x, k = kind[p]; + if (k === SKY || k >= STREET) continue; + if (kind[p - W] === SKY || kind[p + 1] === SKY || kind[p - 1] === SKY) rim[p] = 1; + } + params.s = { + kind, tone, rim, trough, + hue: 0, speed: 2, rain: 0, bolt: 0.06, t: 0, clock: 0, flow: 0, + landX: 50, strikeAt: -9, next: 1.5, strokes: [0], boltMask: new Float32Array(N), boltX: 40, amp: 0.6, eye: 0, + jet: new Float32Array(1200 * 4), nj: 0, emit: 0, + drops: new Float32Array(RAIN1 * 3), spl: new Float32Array(300 * 4), ns: 0, + rip: new Float32Array(48 * 3), nr: 0, ripAt: 0, + rgb: new Float32Array(N * 3), pal: new Float32Array(21 * 11 * 3), palH: -1, near: new Float32Array(W), + }; + const S = params.s; + for (let i = 0; i < RAIN1; i++) (S.drops[i * 3] = spawnX(28)), (S.drops[i * 3 + 1] = -8 + 136 * Math.random()), (S.drops[i * 3 + 2] = 0.8 + 0.4 * Math.random()); + // The stream is already falling when the picture opens. + for (let i = 0; i < 45; i++) water(S, 1 / 30); +} + +// A new bolt: a jagged channel from above the panel down behind the far +// tower or the wall, with branches; drawn into a mask the sky reads. +function strike(S) { + const m = S.boltMask, n = 1 + floor(4 * S.bolt); + m.fill(0); + let x = 22 + 40 * Math.random(), y = -2; + S.boltX = x; + const end = 70 + 35 * Math.random(), lean = (Math.random() - 0.5) * 1.2; + const put = (px, py, v) => { + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + const xx = floor(px) + dx, yy = floor(py) + dy; + if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue; + const w = dx || dy ? v * 0.3 : v; + if (m[yy * W + xx] < w) m[yy * W + xx] = w; + } + }; + const seg = (x0, y0, x1, y1, v) => { + const s = ceil1(max(abs(x1 - x0), abs(y1 - y0))); + for (let i = 0; i <= s; i++) put(mix(x0, x1, i / s), mix(y0, y1, i / s), v); + }; + const forks = []; + while (y < end) { + const nx = x + lean + (Math.random() - 0.5) * 5, ny = y + 2 + 3 * Math.random(); + seg(x, y, nx, ny, 1); + if (forks.length < n && Math.random() < 0.12) forks.push([nx, ny, Math.random() < 0.5 ? -1 : 1]); + (x = nx), (y = ny); + } + for (const [fx, fy, sg] of forks) { + let bx = fx, by = fy; + const len = 6 + floor(10 * Math.random()); + for (let i = 0; i < len; i++) { + const nx = bx + sg * (1 + 2 * Math.random()), ny = by + 1.5 + 2 * Math.random(); + seg(bx, by, nx, ny, 0.6); + (bx = nx), (by = ny); + } + } + S.strokes = S.bolt > 0.5 ? [0, 0.16, 0.31] : [0, 0.16]; + S.amp = 0.72 + 0.28 * S.bolt; + S.strikeAt = S.t; +} +const ceil1 = (v) => Math.max(1, Math.ceil(v)); + +// The water for h seconds of the story: parcels leave the jaws, fall under +// gravity with a little wind and land in the puddle, throwing up splashes +// and rings; the rain falls and rings the puddle too. +function water(S, h) { + const jet = S.jet, rain = S.rain; + S.emit += h * (190 + 170 * rain); + while (S.emit >= 1 && S.nj < 1200) { + S.emit--; + const o = S.nj++ * 4, gs = () => Math.random() + Math.random() - 1; + jet[o] = MX + 2 * Math.random() - 1; + jet[o + 1] = MY + (Math.random() - 0.5) * (2.2 + 2.2 * rain); + jet[o + 2] = VX * (1 + 0.3 * rain) + gs() * (2.2 + 1.6 * rain); + jet[o + 3] = VY + gs() * (1.2 + rain); + } + for (let i = 0; i < S.nj; i++) { + const o = i * 4; + jet[o + 3] += G * h; + jet[o + 2] += WIND * h; + jet[o] += jet[o + 2] * h; + jet[o + 1] += jet[o + 3] * h; + const x = jet[o], y = jet[o + 1]; + if (y >= GROUND + 1 || x > W + 4) { + if (inEl(x, y, PX, PY, PRX, PRY)) { + S.landX = mix(S.landX, x, 0.05); + const n = Math.random() < 0.5 + 0.4 * rain ? 1 + floor(Math.random() * 2) : 0; + for (let k = 0; k < n && S.ns < 300; k++) { + const s = S.ns++ * 4; + (S.spl[s] = x), (S.spl[s + 1] = GROUND + 1), (S.spl[s + 2] = (Math.random() - 0.5) * 50), (S.spl[s + 3] = -(25 + 45 * Math.random()) * (1 + rain)); + } + if (S.t >= S.ripAt) (S.ripAt = S.t + 0.12), ring(S, x, y); + } + // Removed by moving the last parcel into its slot. + S.nj--; + for (let k = 0; k < 4; k++) jet[o + k] = jet[S.nj * 4 + k]; + i--; + } + } + for (let i = 0; i < S.ns; i++) { + const o = i * 4, sp = S.spl; + sp[o + 3] += G * h; + sp[o] += sp[o + 2] * h; + sp[o + 1] += sp[o + 3] * h; + if (sp[o + 1] > GROUND + 2) { + S.ns--; + for (let k = 0; k < 4; k++) sp[o + k] = sp[S.ns * 4 + k]; + i--; + } + } + const dr = S.drops, live = RAIN0 + floor((RAIN1 - RAIN0) * rain), vx = 28 + 34 * rain; + for (let i = 0; i < RAIN1; i++) { + const o = i * 3; + dr[o] += vx * h * dr[o + 2]; + dr[o + 1] += FALL * h * dr[o + 2]; + if (dr[o + 1] > GROUND + 1 + 7 * ((i * 0.37) % 1)) { + if (i < live && inEl(dr[o], dr[o + 1], PX, PY, PRX, PRY) && Math.random() < 0.5) ring(S, dr[o], dr[o + 1]); + (dr[o] = spawnX(vx)), (dr[o + 1] = -6 - 6 * Math.random()), (dr[o + 2] = 0.8 + 0.4 * Math.random()); + } + } + for (let i = 0; i < S.nr; i++) { + S.rip[i * 3 + 2] += h; + if (S.rip[i * 3 + 2] > 0.55) { + S.nr--; + for (let k = 0; k < 3; k++) S.rip[i * 3 + k] = S.rip[S.nr * 3 + k]; + i--; + } + } +} +function ring(S, x, y) { + if (S.nr >= 48) return; + const o = S.nr++ * 3; + (S.rip[o] = x), (S.rip[o + 1] = y), (S.rip[o + 2] = 0); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = cl(k[0], 0, 1); + S.speed = cl(k[1], 0.2, 3); + S.rain = cl(k[2], 0, 1); + S.bolt = cl(k[3], 0, 1); + const h = (d * S.speed) / 2; + S.t += d; + S.clock += h; + S.flow += h; + water(S, h); + // Strikes: the first at 1.5 s, then gaps round the Lightning knob's mean. + if (S.t >= S.next || (input.btnPressed && input.btnPressed.some(Boolean))) { + strike(S); + S.next = S.t + mix(4.5, 0.9, S.bolt) * (0.7 + 0.6 * Math.random()) * min(2.5, 2 / S.speed) ** 0.5; + } + // The eyes kindle a quarter second after each strike, glow, and fade. + const e = S.t - S.strikeAt; + S.eye = e < 0.25 ? max(0, S.eye - d) : e < 0.45 ? max(S.eye, sm(0.25, 0.45, e)) : e < 2 ? 1 : 1 - sm(2, 3, e); +} + +// How bright the strike is t seconds after it began: a spike for each return +// stroke and an afterglow; and how strongly the channel itself shows. +function flashAt(S, t) { + if (t < 0 || t > 2) return [0, 0]; + let spike = 0, chan = 0; + for (const s of S.strokes) + if (t >= s) (spike = max(spike, exp(-(t - s) / 0.05))), (chan = max(chan, exp(-(t - s) / 0.1))); + return [min(1, 0.7 * spike + 0.3 * exp(-t / 0.4)) * S.amp, chan]; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { kind, tone, rim, rgb } = S; + const [flash, chan] = flashAt(S, S.t - S.strikeAt), fq = q20(flash); + const skyH = 0.78 - 0.45 * S.hue, lite = hsv(skyH, 0.28, 0.86), stone = hsv(skyH - 0.04, 0.16, 0.62); + const dark = 1 - min(1, flash * 2.4), far = hsv(skyH, 0.55, 0.1); + const glass = [[0.04, 0.02, 0.05], [1, 0.74, 0.16], [0.86, 0.1, 0.2], [0.36, 0.3, 0.95]]; + const flick = 0.62 + 0.08 * floor(3 * noise(S.clock * 3, 4.5)); + // The sky's colours for this hue by brightness step and cloud step, and + // how much of the flash each column gets from the bolt's place. + const pal = S.pal, near = S.near; + if (S.palH !== skyH) { + S.palH = skyH; + for (let vi = 0; vi <= 20; vi++) for (let ci = 0; ci <= 10; ci++) pal.set(hsv(skyH + 0.003 * ci, 0.72 - 0.03 * ci, vi / 20), (vi * 11 + ci) * 3); + } + for (let x = 0; x < W; x++) near[x] = 0.55 + 0.45 * exp(-((x - S.boltX) ** 2) / 500); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, o = p * 3, k = kind[p]; + let r, g, b; + if (k === SKY || k === POOL) { + // Bruised clouds drifting right, in 1/20 steps, lighter toward the + // street; the puddle shows the sky over it, darker. + const yy = k === POOL ? 2 * GROUND - y - 30 : y; + const c1 = noise(x / 17 + S.clock * 0.3, yy / 11 + 1 + S.clock * 0.04), c2 = noise(x / 8 + S.clock * 0.55 + 5, yy / 6); + const ci = floor(cl((0.7 * c1 + 0.3 * c2 - 0.32) * 1.8, 0, 0.99) * 10), cq = ci / 10; + const vi = floor((0.15 + 0.13 * (yy / H) + 0.22 * cq * cq) * (k === POOL ? 15 : 20)), po = (vi * 11 + ci) * 3; + const f = q20(fq * near[x] * (0.8 + 0.3 * cq)) * (k === POOL ? 0.6 : 1); + (r = mix(pal[po], lite[0], f)), (g = mix(pal[po + 1], lite[1], f)), (b = mix(pal[po + 2], lite[2], f)); + const bl = k === SKY ? S.boltMask[p] * chan : 0; + if (bl > 0.05) (r = mix(r, 1, bl)), (g = mix(g, 1, bl)), (b = mix(b, 1, bl)); + // In the puddle: the lit far edge, and the falling stream mirrored + // under the place it lands. + if (k === POOL) { + const e = tone[p] ? 0.4 : abs(x + 0.5 - S.landX) < 1.6 ? 0.3 : 0; + if (e) (r = mix(r, 0.7, e)), (g = mix(g, 0.86, e)), (b = mix(b, 1, e)); + } + } else if (k === FAR) { + (r = far[0] * dark), (g = far[1] * dark), (b = far[2] * dark); + if (rim[p]) (r += lite[0] * fq * 0.7), (g += lite[1] * fq * 0.7), (b += lite[2] * fq * 0.7); + } else if (k === GLASS) { + const c = glass[tone[p]], v = flick; + (r = c[0] * v), (g = c[1] * v), (b = c[2] * v); + } else if (k === STREET) { + const v = tone[p] * (1 + fq); + (r = stone[0] * v), (g = stone[1] * v), (b = stone[2] * v); + } else { + // Stone: lit dimly by the storm, black against the flash, its edges + // catching the light behind it. + const v = k === MOUTH ? 0.04 : tone[p] * (k === PIN ? 0.8 : 1), dk = k === PIN ? dark : mix(dark, 1, 0.25); + (r = stone[0] * v * dk), (g = stone[1] * v * dk), (b = stone[2] * v * dk); + if (rim[p]) (r += lite[0] * fq), (g += lite[1] * fq), (b += lite[2] * fq); + } + (rgb[o] = r), (rgb[o + 1] = g), (rgb[o + 2] = b); + } + const add = (x, y, c, v) => { + x = floor(x), y = floor(y); + if (x < 0 || x >= W || y < 0 || y >= H) return; + const o = (y * W + x) * 3; + (rgb[o] = max(rgb[o], c[0] * v)), (rgb[o + 1] = max(rgb[o + 1], c[1] * v)), (rgb[o + 2] = max(rgb[o + 2], c[2] * v)); + }; + // The trough on its back: water glinting as it runs to the jaws. + const wat = [0.66, 0.86, 1]; + S.trough.forEach((p, i) => add(p % W, floor(p / W), wat, (i - S.flow * 14) % 5 < -3.5 || (i - S.flow * 14) % 5 > 3.5 ? 0.85 : 0.42)); + // The puddle's rings, flattened by the angle we see it at. + for (let i = 0; i < S.nr; i++) { + const cx = S.rip[i * 3], cy = S.rip[i * 3 + 1], a = S.rip[i * 3 + 2], rr = 1 + a * 14, v = 0.6 - 0.15 * floor(a * 6); + for (let s = 0; s < 24; s++) { + const x = cx + rr * cos((s * PI) / 12), y = cy + 0.3 * rr * sin((s * PI) / 12); + if (inEl(x, y, PX, PY, PRX, PRY)) add(x, y, lite, v); + } + } + // The stream: each parcel a streak over the way it came in the last frame, + // or at slow speeds over the way it comes in a frame at Speed 1.2, so the + // slowed stream still reads as water and not as beads. + const step = (1 / 30) * (max(1.2, S.speed) / 2), wc = [mix(0.72, 1, fq), mix(0.88, 1, fq), 1]; + for (let i = 0; i < S.nj; i++) { + const o = i * 4, x = S.jet[o], y = S.jet[o + 1], vx = S.jet[o + 2], vy = S.jet[o + 3]; + const n = ceil1(abs(vy * step)); + for (let s = 0; s < n; s++) add(x - (vx * step * s) / n, y - (vy * step * s) / n, wc, s ? 0.7 : 0.92); + } + for (let i = 0; i < S.ns; i++) add(S.spl[i * 4], S.spl[i * 4 + 1], wc, 0.8); + // Rain in front of it all, a bright head and a fainter tail along its slant. + const live = RAIN0 + floor((RAIN1 - RAIN0) * S.rain), vx = 28 + 34 * S.rain, rc = hsv(skyH, 0.22, 1), len = 4 + floor(S.speed / 1.5); + for (let i = 0; i < live; i++) { + const o = i * 3, x = S.drops[o], y = S.drops[o + 1]; + for (let s = 0; s < len; s++) add(x - (vx / FALL) * s, y - s, rc, (s ? 0.34 : 0.55) + 0.3 * fq); + } + // The eyes, poison green, with a stepped glow round them. + if (S.eye > 0) { + const e = q20(S.eye), gc = [0.42, 1, 0.12]; + for (let dy = -2; dy <= 2; dy++) + for (let dx = -2; dx <= 3; dx++) { + const r2 = hypot(dx - 0.5, dy), v = r2 < 0.9 ? 1 : r2 < 1.8 ? 0.4 : r2 < 2.6 ? 0.18 : 0; + if (v) add(29 + dx, 20 + dy, gc, v * e); + } + } + for (let i = 0; i < N; i++) display.setPixel(i % W, floor(i / W), (255 * cl(rgb[i * 3], 0, 1)) | 0, (255 * cl(rgb[i * 3 + 1], 0, 1)) | 0, (255 * cl(rgb[i * 3 + 2], 0, 1)) | 0); +} diff --git a/incoming/pattern-study/patterns/tennis-racket.js b/incoming/pattern-study/patterns/tennis-racket.js new file mode 100644 index 000000000..0115a7a31 --- /dev/null +++ b/incoming/pattern-study/patterns/tennis-racket.js @@ -0,0 +1,400 @@ +// ===== Patternflow pattern ===== +// Title: Tennis Racket +// Family: sim +// Idea: A T-handle spinning about its middle axis turns itself over by half a turn again and again while its angular momentum stays fixed and upright, and below it the momentum seen from the handle runs round the momentum sphere along the polhodes, past the separatrix that joins the two ends of the middle axis. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the tennis racket theorem: Euler's equations of a free rigid body (Leonhard Euler, 1765) and their polhodes (Louis Poinsot, 1834), seen in a wing nut by Vladimir Dzhanibekov on Salyut 7 (1985), pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Axis=-1..1, Nudge=0..1 +// +// Knob 1: Hue. Turns the three colours round the wheel together: at 0 the crossbar and the polhodes round its axis are azure, the stem, its tip and the momentum's trail amber, and the polhodes round the third axis violet; at 0.5 they are orange, blue and green. Orange and yellow are kept bright so they never dim to brown. +// Knob 2: Speed. At 2 the handle spins at 4 radians a second, so the first flip comes at 2.3 seconds and the next ones every 3.9 seconds. At 0.2 time runs ten times slower, at 3 half as fast again. +// Knob 3: Axis. Which principal axis the throw starts near: at 0 the middle one, the stem, and the handle flips; at -1 the smallest, the crossbar, and at 1 the largest, the normal to the T, where it only wobbles round a small polhode. Between them it starts on a wider polhode and rolls over steadily instead of flipping: once every 1.5 seconds at 0.5 and every 1.0 at -0.5. A new setting throws again 0.3 seconds after the knob stops. +// Knob 4: Nudge. How far off that axis the throw starts: 0.072 radians at the opening 0.06, 1.2 radians at 1. At 0 it spins exactly about the middle axis and never flips. The time between flips grows like log(1 / nudge): 5.1, 3.9, 2.6 and 2.0 seconds at nudges of 0.017, 0.06, 0.25 and 0.5, and each e-fold smaller nudge adds 0.93 seconds. +// Buttons: any button throws again with the knobs as they are. + +const W = 64, H = 128, N = W * H, HALF = 64; +const { floor, ceil, min, max, abs, sqrt, sin, cos, hypot, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p, i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p, i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p]; +} +// How near a hue is to orange and yellow, 0 to 1. +const warm = (h) => max(0, 1 - abs(h - floor(h) - 0.11) / 0.13); +const nrm = (p) => { + const l = hypot(p[0], p[1], p[2]) || 1; + return [p[0] / l, p[1] / l, p[2] / l]; +}; + +// The T-handle as two boxes of one density, centre and half sizes: the +// crossbar along the body's x axis and the stem along y, both centred on +// x = z = 0, so x, y and z through the centre of mass are its principal axes. +// Worked out in setup, per unit mass, the moments are 0.160, 0.306 and +// 0.448: the crossbar's axis is the smallest, the stem's the middle, the +// normal to the T the largest, in the ratio 1 : 1.917 : 2.807. +const BOXES = [ + [0, 0, 0, 1.1, 0.17, 0.17], + [0, 0.76, 0, 0.14, 0.59, 0.14], +]; +// Spin about the middle axis at Speed 2, radians a second. +const OMEGA = 4; +// Nudge 1 starts the throw this many radians off the axis. +const NUDGE = 1.2; +// The nudge leans the start toward this body direction (made perpendicular +// to the start axis), 16 degrees off the separatrix, so it never starts on it. +const LEAN = [0.35, 0, 0.94]; +// The camera over the handle: distance, LEDs per unit at the centre, the +// angle it looks down at, and the screen row of the centre of mass. +const CAMD = 11, FOC = 11 * 25, ELEV = 0.36, CY0 = 31; +// The light, a unit vector toward it in the space frame. +const LIGHT = nrm([-0.45, 0.75, 0.55]); +// The momentum sphere below: centre and radius in LEDs, and the direction +// it is seen from, in the handle's own frame. +const SX = 31.5, SY = 96.5, SR = 28; +const VIEW = nrm([0.3, 0.3, 0.9]); +// Frames of trail kept: the momentum's on the sphere, the stem tip's above. +const TRAIL = 150, TIP = 40; + +export function setup(params) { + // Moments of inertia from the boxes, about the centre of mass. + let M = 0, cy = 0; + for (const b of BOXES) (M += b[3] * b[4] * b[5]), (cy += b[3] * b[4] * b[5] * b[1]); + cy /= M; + const I = [0, 0, 0]; + for (const b of BOXES) { + const m = b[3] * b[4] * b[5], x = 2 * b[3], y = 2 * b[4], z = 2 * b[5], d = b[1] - cy; + I[0] += (m * (y * y + z * z)) / 12 + m * d * d; + I[1] += (m * (x * x + z * z)) / 12; + I[2] += (m * (x * x + y * y)) / 12 + m * d * d; + } + for (let k = 0; k < 3; k++) I[k] /= M; + // The boxes' faces round the centre of mass, each as four corners, its + // outward normal and which box it is on; face 1 of box 1 is the stem's tip. + const faces = []; + BOXES.forEach((b, bi) => { + for (let ax = 0; ax < 3; ax++) + for (const sg of [1, -1]) { + const u = (ax + 1) % 3, v = (ax + 2) % 3, h = [b[3], b[4], b[5]], c = [b[0], b[1] - cy, b[2]]; + const pts = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([pu, pv]) => { + const p = c.slice(); + (p[ax] += sg * h[ax]), (p[u] += pu * h[u]), (p[v] += pv * h[v]); + return p; + }); + if (sg < 0) pts.reverse(); + const n = [0, 0, 0]; + n[ax] = sg; + faces.push({ pts, n, part: bi === 1 && ax === 1 && sg > 0 ? 2 : bi }); + } + }); + // The sphere's look, fixed because the handle's frame is: for every LED of + // the disc, the point of the sphere in front and the one behind, which of + // the four regions the separatrices cut it into each lies in, and which of + // three polhodes per region it lies past. The polhodes are curves of + // constant energy e = L1^2 / I1 + L2^2 / I2 + L3^2 / I3 on |L| = 1; + // rho runs from 0 at a region's pole to 1 at its separatrix. + const B = VIEW, U = nrm([-B[1] * B[0], 1 - B[1] * B[1], -B[1] * B[2]]); + const R = [U[1] * B[2] - U[2] * B[1], U[2] * B[0] - U[0] * B[2], U[0] * B[1] - U[1] * B[0]]; + const iI = I.map((v) => 1 / v), eS = iI[1]; + const code = (p) => { + const e = p[0] * p[0] * iI[0] + p[1] * p[1] * iI[1] + p[2] * p[2] * iI[2]; + const s = e > eS ? (e - eS) / (iI[0] - eS) : (eS - e) / (eS - iI[2]); + const reg = e > eS ? (p[0] > 0 ? 0 : 1) : p[2] > 0 ? 2 : 3; + return reg * 4 + floor(sqrt(cl(1 - s, 0, 0.9999)) * 4); + }; + const front = new Int16Array(W * HALF).fill(-1), back = new Int16Array(W * HALF).fill(-1); + const eF = new Float32Array(W * HALF), eB = new Float32Array(W * HALF); + const en = (p) => p[0] * p[0] * iI[0] + p[1] * p[1] * iI[1] + p[2] * p[2] * iI[2]; + for (let y = 0; y < HALF; y++) + for (let x = 0; x < W; x++) { + const px = (x + 0.5 - SX) / SR, py = (SY - (y + HALF + 0.5)) / SR, r2 = px * px + py * py; + if (r2 >= 1) continue; + const pz = sqrt(1 - r2), i = y * W + x; + const at = (z) => [px * R[0] + py * U[0] + z * B[0], px * R[1] + py * U[1] + z * B[1], px * R[2] + py * U[2] + z * B[2]]; + const pf = at(pz), pb = at(-pz); + (front[i] = code(pf)), (back[i] = code(pb)), (eF[i] = en(pf)), (eB[i] = en(pb)); + } + // Classes: 1 and 2 the front fill round the e1 and e3 poles, 4 the + // separatrix behind, 5 and 6 a polhode in front round e1 and e3, 7 the + // separatrix in front, 8 the rim. A line is where the code changes to a + // neighbour; a change of region is the separatrix. The polhodes behind + // (class 3) are left out: drawn, they crowded the ones in front. + const cls = new Uint8Array(W * HALF); + const edge = (A, i, x, y) => { + let c = 0; + for (const j of [x + 1 < W ? i + 1 : -1, y + 1 < HALF ? i + W : -1, x > 0 ? i - 1 : -1, y > 0 ? i - W : -1]) { + if (j < 0 || A[j] < 0 || A[j] === A[i]) continue; + if (A[j] >> 2 !== A[i] >> 2) c = 2; + else if ((A[j] & 3) > (A[i] & 3)) c = max(c, 1); + } + return c; + }; + for (let y = 0; y < HALF; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + if (front[i] < 0) continue; + const rim = (x > 0 && front[i - 1] < 0) || (x < W - 1 && front[i + 1] < 0) || (y > 0 && front[i - W] < 0) || (y < HALF - 1 && front[i + W] < 0); + const ef = edge(front, i, x, y), eb = edge(back, i, x, y), reg = front[i] >> 2; + cls[i] = rim ? 8 : ef === 2 ? 7 : ef === 1 ? (reg < 2 ? 5 : 6) : eb === 2 ? 4 : reg < 2 ? 1 : 2; + } + params.s = { + I, iI, faces, cls, R, U, B, tipY: 1.35 - cy, front, back, eF, eB, cap: new Uint8Array(W * HALF), + hue: 0, speed: 2, axis: 0, nudge: 0.06, a0: 0, n0: 0.06, pend: 0, t: 0, flips: 0, + L: new Float64Array(3), q: new Float64Array(4), m: new Float64Array(9), + trail: new Float32Array(TRAIL * 3), tn: 0, tip: new Float32Array(TIP * 9), pn: 0, + zb: new Float32Array(W * HALF), rgb: new Float32Array(N * 3), + }; + launch(params.s, 0, 0.06); +} + +// A throw: |L| set so the handle would spin about the middle axis at OMEGA, +// the start direction moved from the middle axis toward e1 (Axis -1) or e3 +// (Axis 1), then tipped by the nudge; the orientation is the least turn that +// sets that direction upright, so the angular momentum points straight up. +function launch(S, axis, nudge) { + const t = (axis * PI) / 2, c0 = [max(0, -sin(t)), cos(t), max(0, sin(t))]; + let d = LEAN.map((v, k) => v - (LEAN[0] * c0[0] + LEAN[1] * c0[1] + LEAN[2] * c0[2]) * c0[k]); + if (hypot(d[0], d[1], d[2]) < 1e-6) d = [1, 0, 0]; + d = nrm(d); + const a = nudge * NUDGE, l = nrm(c0.map((v, k) => v * cos(a) + d[k] * sin(a))), Lm = OMEGA * S.I[1]; + for (let k = 0; k < 3; k++) S.L[k] = l[k] * Lm; + // The least turn taking l to (0, 1, 0): about l x up, by the angle between. + const ax = [-l[2], 0, l[0]], s = hypot(ax[0], ax[2]), ang = Math.atan2(s, l[1]); + if (s < 1e-9) (S.q[0] = 1), (S.q[1] = S.q[2] = S.q[3] = 0); + else (S.q[0] = cos(ang / 2)), (S.q[1] = (sin(ang / 2) * ax[0]) / s), (S.q[2] = 0), (S.q[3] = (sin(ang / 2) * ax[2]) / s); + (S.a0 = axis), (S.n0 = nudge), (S.tn = 0), (S.pn = 0), (S.pend = 0), (S.flips = 0); + // The polhode this throw rides: its energy is fixed, so it is fixed too. + // The cap it bounds round its pole is tinted (1), the curve drawn in front + // (2) and behind (3). The region code names the pole: 0 and 1 are +e1 and + // -e1, 2 and 3 are +e3 and -e3. + const iI = S.iI, e = l[0] * l[0] * iI[0] + l[1] * l[1] * iI[1] + l[2] * l[2] * iI[2], eS = iI[1]; + const reg = e > eS ? (l[0] > 0 ? 0 : 1) : l[2] > 0 ? 2 : 3, sg = e > eS ? 1 : -1; + const { front, back, eF, eB, cap } = S; + const inF = (i) => front[i] >= 0 && front[i] >> 2 === reg && sg * (eF[i] - e) > 0; + const inB = (i) => back[i] >= 0 && back[i] >> 2 === reg && sg * (eB[i] - e) > 0; + cap.fill(0); + for (let y = 0; y < HALF; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + if (front[i] < 0) continue; + const nb = [x + 1 < W ? i + 1 : -1, y + 1 < HALF ? i + W : -1, x > 0 ? i - 1 : -1, y > 0 ? i - W : -1]; + const f = inF(i), b = inB(i); + if (f && nb.some((j) => j >= 0 && front[j] >= 0 && !inF(j))) cap[i] = 2; + else if (f) cap[i] = 1; + else if (b && nb.some((j) => j >= 0 && back[j] >= 0 && !inB(j))) cap[i] = 3; + } +} + +// Euler's equations dL/dt = L x w with w = L / I in the handle's frame, and +// the orientation dq/dt = q (0, w) / 2, stepped together by RK4. +function deriv(S, s, o) { + const iI = S.iI, wx = s[0] * iI[0], wy = s[1] * iI[1], wz = s[2] * iI[2], w = s[3], x = s[4], y = s[5], z = s[6]; + o[0] = s[1] * wz - s[2] * wy; + o[1] = s[2] * wx - s[0] * wz; + o[2] = s[0] * wy - s[1] * wx; + o[3] = -0.5 * (x * wx + y * wy + z * wz); + o[4] = 0.5 * (w * wx + y * wz - z * wy); + o[5] = 0.5 * (w * wy + z * wx - x * wz); + o[6] = 0.5 * (w * wz + x * wy - y * wx); +} +const ST = new Float64Array(7), K1 = new Float64Array(7), K2 = new Float64Array(7), K3 = new Float64Array(7), K4 = new Float64Array(7), TMP = new Float64Array(7); +function step(S, h) { + for (let k = 0; k < 3; k++) ST[k] = S.L[k]; + for (let k = 0; k < 4; k++) ST[3 + k] = S.q[k]; + deriv(S, ST, K1); + for (let k = 0; k < 7; k++) TMP[k] = ST[k] + 0.5 * h * K1[k]; + deriv(S, TMP, K2); + for (let k = 0; k < 7; k++) TMP[k] = ST[k] + 0.5 * h * K2[k]; + deriv(S, TMP, K3); + for (let k = 0; k < 7; k++) TMP[k] = ST[k] + h * K3[k]; + deriv(S, TMP, K4); + for (let k = 0; k < 7; k++) ST[k] += (h / 6) * (K1[k] + 2 * K2[k] + 2 * K3[k] + K4[k]); + for (let k = 0; k < 3; k++) S.L[k] = ST[k]; + // Only the quaternion is renormalised; L is left as RK4 leaves it. + const l = hypot(ST[3], ST[4], ST[5], ST[6]); + for (let k = 0; k < 4; k++) S.q[k] = ST[3 + k] / l; +} + +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.speed = cl(k[1], 0.2, 3); + S.axis = cl(k[2], -1, 1); + S.nudge = cl(k[3], 0, 1); + // A turned Axis or Nudge throws again once the knob has rested 0.3 s; the + // first frame throws at once with the knobs it is handed. + if (S.t === 0 && (S.axis !== S.a0 || S.nudge !== S.n0)) launch(S, S.axis, S.nudge); + else if (abs(S.axis - S.a0) > 1e-4 || abs(S.nudge - S.n0) > 1e-4) { + if (S.pend <= 0) S.pend = 0.3; + if (abs(S.axis - (S.ax1 ?? S.axis)) > 1e-4 || abs(S.nudge - (S.nu1 ?? S.nudge)) > 1e-4) S.pend = 0.3; + S.pend -= d; + if (S.pend <= 0) launch(S, S.axis, S.nudge); + } + (S.ax1 = S.axis), (S.nu1 = S.nudge); + if (input.btnPressed && input.btnPressed.some(Boolean)) launch(S, S.axis, S.nudge); + // Speed 2 is real time; steps of at most 1/240 s of the handle's time. + const span = (d * S.speed) / 2, n = max(1, ceil(span * 240)); + const was = S.L[1]; + for (let i = 0; i < n; i++) step(S, span / n); + if (was > 0 !== S.L[1] > 0) S.flips++; + S.t += d; + const q = S.q, m = S.m, w = q[0], x = q[1], y = q[2], z = q[3]; + m[0] = 1 - 2 * (y * y + z * z), m[1] = 2 * (x * y - w * z), m[2] = 2 * (x * z + w * y); + m[3] = 2 * (x * y + w * z), m[4] = 1 - 2 * (x * x + z * z), m[5] = 2 * (y * z - w * x); + m[6] = 2 * (x * z - w * y), m[7] = 2 * (y * z + w * x), m[8] = 1 - 2 * (x * x + y * y); + // The trails: the momentum's direction in the handle's frame, and where + // the stem's tip is in space. + const Lm = hypot(S.L[0], S.L[1], S.L[2]) || 1, o = (S.tn % TRAIL) * 3; + for (let j = 0; j < 3; j++) S.trail[o + j] = S.L[j] / Lm; + S.tn++; + const ty = S.tipY, p = (S.pn % TIP) * 9; + for (let j = 0; j < 3; j++) { + S.tip[p + j] = m[j * 3 + 1] * ty; + S.tip[p + 3 + j] = m[j * 3] * 1.1 - m[j * 3 + 1] * (1.35 - ty); + S.tip[p + 6 + j] = -m[j * 3] * 1.1 - m[j * 3 + 1] * (1.35 - ty); + } + S.pn++; +} + +// World (centre of mass at the origin, y up, z toward the camera) to the +// screen: [x, y, 1 / depth]. +const CE = cos(ELEV), SE = sin(ELEV); +function proj(p, out, o) { + const vx = p[0], vy = p[1] - CAMD * SE, vz = p[2] - CAMD * CE; + const zc = -(vy * SE + vz * CE), ux = vx, uy = vy * CE - vz * SE; + out[o] = 32 + (FOC * ux) / zc; + out[o + 1] = CY0 - (FOC * uy) / zc; + out[o + 2] = 1 / zc; +} + +function tri(S, ax, ay, az, bx, by, bz, cx, cy, cz, r, g, b) { + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + if (abs(area) < 1e-6) return; + const x0 = max(0, floor(min(ax, bx, cx))), x1 = min(W - 1, ceil(max(ax, bx, cx))); + const y0 = max(0, floor(min(ay, by, cy))), y1 = min(HALF - 2, ceil(max(ay, by, cy))); + const zb = S.zb, rgb = S.rgb; + for (let y = y0; y <= y1; y++) { + const py = y + 0.5; + for (let x = x0; x <= x1; x++) { + const px = x + 0.5; + const w0 = ((bx - px) * (cy - py) - (by - py) * (cx - px)) / area, w1 = ((cx - px) * (ay - py) - (cy - py) * (ax - px)) / area, w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + const iz = w0 * az + w1 * bz + w2 * cz, i = y * W + x; + if (iz <= zb[i]) continue; + zb[i] = iz; + (rgb[i * 3] = r), (rgb[i * 3 + 1] = g), (rgb[i * 3 + 2] = b); + } + } +} + +// A line of LEDs from a to b in colour c times v, kept off LEDs the handle +// covers when depth is given. +function line(S, ax, ay, bx, by, c, v, y0, y1) { + const steps = ceil(max(abs(bx - ax), abs(by - ay))) + 1, rgb = S.rgb; + for (let s = 0; s <= steps; s++) { + const t = s / steps, x = floor(mix(ax, bx, t)), y = floor(mix(ay, by, t)); + if (x < 0 || x >= W || y < y0 || y > y1) continue; + const i = y * W + x; + if (y < HALF && S.zb[i] > 0) continue; + (rgb[i * 3] = max(rgb[i * 3], c[0] * v)), (rgb[i * 3 + 1] = max(rgb[i * 3 + 1], c[1] * v)), (rgb[i * 3 + 2] = max(rgb[i * 3 + 2], c[2] * v)); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { m, zb, rgb, faces, cls } = S; + // The three colours: A the crossbar and the e1 polhodes, B the stem, its + // tip and the momentum, C the e3 polhodes. Warm ones are lifted. + const hA = 0.58 + S.hue, hB = 0.09 + S.hue, hC = 0.79 + S.hue; + const cA = hsv(hA, 0.8, 1), cB = hsv(hB, 0.82, 1), cC = hsv(hC, 0.62, 1), wB = warm(hB), wA = warm(hA), wC = warm(hC); + const tipC = hsv(hB, 0.35, 1); + rgb.fill(0); + zb.fill(0); + // The handle: each face that looks toward the camera, flat shaded. + const wp = [0, 0, 0], sp = new Float64Array(12); + for (const f of faces) { + const n = f.n, nx = m[0] * n[0] + m[1] * n[1] + m[2] * n[2], ny = m[3] * n[0] + m[4] * n[1] + m[5] * n[2], nz = m[6] * n[0] + m[7] * n[1] + m[8] * n[2]; + const p0 = f.pts[0], cx = m[0] * p0[0] + m[1] * p0[1] + m[2] * p0[2], cy = m[3] * p0[0] + m[4] * p0[1] + m[5] * p0[2], cz = m[6] * p0[0] + m[7] * p0[1] + m[8] * p0[2]; + if (nx * cx + ny * (cy - CAMD * SE) + nz * (cz - CAMD * CE) >= 0) continue; + for (let j = 0; j < 4; j++) { + const p = f.pts[j]; + wp[0] = m[0] * p[0] + m[1] * p[1] + m[2] * p[2]; + wp[1] = m[3] * p[0] + m[4] * p[1] + m[5] * p[2]; + wp[2] = m[6] * p[0] + m[7] * p[1] + m[8] * p[2]; + proj(wp, sp, j * 3); + } + const lit = max(0, nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]), wm = f.part === 0 ? wA : wB; + const sh = floor((0.3 + 0.45 * wm + (0.7 - 0.45 * wm) * lit) * 10) / 10, col = f.part === 0 ? cA : f.part === 1 ? cB : tipC; + tri(S, sp[0], sp[1], sp[2], sp[3], sp[4], sp[5], sp[6], sp[7], sp[8], col[0] * sh, col[1] * sh, col[2] * sh); + tri(S, sp[0], sp[1], sp[2], sp[6], sp[7], sp[8], sp[9], sp[10], sp[11], col[0] * sh, col[1] * sh, col[2] * sh); + } + // The angular momentum, fixed and upright: a dim line through the centre + // of mass with an arrowhead at the top, where the handle does not cover it. + const grey = [0.5, 0.5, 0.56]; + line(S, 32, 61, 32, 1, grey, 0.42, 0, HALF - 2); + line(S, 32, 1, 28, 5, grey, 0.42, 0, HALF - 2), line(S, 32, 1, 36, 5, grey, 0.42, 0, HALF - 2); + // The stem tip's path over the last 1.3 s and the crossbar ends' over the + // last 0.3 s, older steps dimmer. + const a = new Float64Array(3), b2 = new Float64Array(3); + for (let j = 1; j < min(S.pn, TIP); j++) + for (let e = 0; e < 3; e++) { + if (e && j > 10) break; + const i0 = ((S.pn - j) % TIP) * 9 + e * 3, i1 = ((S.pn - j - 1) % TIP) * 9 + e * 3; + const v = e ? 0.15 + 0.45 * floor((1 - j / 10) * 4) / 4 : 0.2 + 0.6 * floor((1 - j / TIP) * 5) / 5; + proj(S.tip.subarray(i0, i0 + 3), a, 0); + proj(S.tip.subarray(i1, i1 + 3), b2, 0); + line(S, a[0], a[1], b2[0], b2[1], e ? cA : cB, v, 0, HALF - 2); + } + // The momentum sphere, its classes coloured for this hue. + const lift = (c, v, w) => [c[0] * v * (1 + 0.5 * w), c[1] * v * (1 + 0.5 * w), c[2] * v * (1 + 0.5 * w)]; + const pal = [ + [0, 0, 0], lift(cA, 0.17, wA), lift(cC, 0.17, wC), [0.27, 0.27, 0.32], [0.48, 0.48, 0.54], + lift(cA, 0.62, wA), lift(cC, 0.62, wC), [0.7, 0.7, 0.76], [0.34, 0.34, 0.4], + ]; + const capA = lift(cA, 0.3, wA), capC = lift(cC, 0.3, wC), orb = lift(cB, 0.5, 0), orbB = lift(cB, 0.27, 0), cap = S.cap; + for (let i = 0; i < W * HALF; i++) { + const k = cls[i], o = (i + W * HALF) * 3, f = cap[i]; + let c = pal[k]; + if (f === 2 && k !== 8) c = orb; + else if (f === 1 && k < 3) c = k === 1 ? capA : capC; + else if (f === 3 && k < 3) c = orbB; + (rgb[o] = c[0]), (rgb[o + 1] = c[1]), (rgb[o + 2] = c[2]); + } + // The two ends of the middle axis, where the separatrices cross, ringed in + // the stem's colour. + const { R, U, B } = S, scr = (p) => [SX + SR * (p[0] * R[0] + p[1] * R[1] + p[2] * R[2]), SY - SR * (p[0] * U[0] + p[1] * U[1] + p[2] * U[2]), p[0] * B[0] + p[1] * B[1] + p[2] * B[2]]; + for (const sg of [1, -1]) { + const [x, y, z] = scr([0, sg, 0]), v = z > 0 ? 0.9 : 0.5; + for (const [dx, dy] of [[-2, 0], [2, 0], [0, -2], [0, 2], [-1, -1], [1, -1], [-1, 1], [1, 1]]) line(S, x + dx, y + dy, x + dx, y + dy, cB, v, HALF, H - 1); + } + // The momentum's trail in the handle's frame over the last 5 s: in front + // of the sphere bright, behind it dimmer, older steps dimmer still. + const cnt = min(S.tn, TRAIL), tr = S.trail; + for (let j = 1; j < cnt; j++) { + const i0 = ((S.tn - j) % TRAIL) * 3, i1 = ((S.tn - j - 1) % TRAIL) * 3; + const p = scr([tr[i0], tr[i0 + 1], tr[i0 + 2]]), q = scr([tr[i1], tr[i1 + 1], tr[i1 + 2]]); + const v = (0.3 + 0.7 * floor((1 - j / TRAIL) * 5) / 5) * (p[2] + q[2] > 0 ? 1 : 0.45) * (1 + 0.3 * wB); + line(S, p[0], p[1], q[0], q[1], cB, v, HALF, H - 1); + line(S, p[0] + 1, p[1], q[0] + 1, q[1], cB, v * 0.8, HALF, H - 1); + } + // The momentum now: a bright dot, white at the centre. + const nowI = ((S.tn - 1 + TRAIL) % TRAIL) * 3, now = scr([tr[nowI], tr[nowI + 1], tr[nowI + 2]]); + for (let dy = -2; dy <= 2; dy++) + for (let dx = -2; dx <= 2; dx++) { + const x = floor(now[0]) + dx, y = floor(now[1]) + dy, r = abs(dx) + abs(dy); + if (r > 2 || x < 0 || x >= W || y < HALF || y >= H) continue; + const o = (y * W + x) * 3, c = r === 0 ? [1, 1, 1] : r === 1 ? tipC : cB; + (rgb[o] = c[0]), (rgb[o + 1] = c[1]), (rgb[o + 2] = c[2]); + } + for (let i = 0; i < N; i++) + display.setPixel(i % W, floor(i / W), (255 * cl(rgb[i * 3], 0, 1)) | 0, (255 * cl(rgb[i * 3 + 1], 0, 1)) | 0, (255 * cl(rgb[i * 3 + 2], 0, 1)) | 0); +} From 22b3a6a4edd5bbfe84bb52f0dfcdc18061df3099 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:51:07 +0000 Subject: [PATCH 3/9] =?UTF-8?q?patterns:=20add=20D=C3=BCrer's=20Unfolding?= =?UTF-8?q?=20and=20Party=20Lights?= 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_017mBLLjEyWbBi1Ag71cFsLX --- .../pattern-study/patterns/durer-unfolding.js | 442 ++++++++++++++++++ .../pattern-study/patterns/party-lights.js | 228 +++++++++ 2 files changed, 670 insertions(+) create mode 100644 incoming/pattern-study/patterns/durer-unfolding.js create mode 100644 incoming/pattern-study/patterns/party-lights.js diff --git a/incoming/pattern-study/patterns/durer-unfolding.js b/incoming/pattern-study/patterns/durer-unfolding.js new file mode 100644 index 000000000..f9fdd4e75 --- /dev/null +++ b/incoming/pattern-study/patterns/durer-unfolding.js @@ -0,0 +1,442 @@ +// ===== Patternflow pattern ===== +// Title: Dürer's Unfolding +// Family: space +// Idea: A convex solid, its faces hatched like an engraving, lies flat as one net up the panel, folds shut hinge by hinge along a spanning tree of its edges, turns in the dark, shrinks away and comes back as the next solid, which turns and unfolds along its own tree into a new net that never overlaps itself. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Dürer's unfolding problem (Albrecht Dürer, 1525; Geoffrey Shephard, 1975), edge unfoldings of the Platonic solids never overlap (Takashi Horiyama and Wataru Shoji, 2011), uniform spanning trees by loop-erased random walk (David Wilson, 1996), pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Solid=0..5, Tree=0..1 +// +// Knob 1: Hue. Each face is coloured by how many hinges lie between it and the root face the net is folded round: at 0 the root is azure and the faces furthest out pink, through blue and violet; turning it carries that third of the wheel round together, so at 0.5 the root is red orange and the far faces green. A face that comes round to orange or yellow keeps at least 0.8 of full value, so it never dims to brown. The plain backs of the faces, seen while the net folds, are a dim grey of the face's own colour, cross-hatched. +// Knob 2: Speed. At 2 one solid's story takes 10.6 seconds: it grows out of a point in 0.4, turns for 0.9, unfolds hinge by hinge in 3, lies flat swaying for 2.4, folds shut in 2.6, turns for 0.9 and shrinks away in 0.4. A fresh start opens with the last hinges opening and folds at 3.4 seconds. At 0.2 it all runs ten times slower, at 3 half as fast again. +// Knob 3: Solid. Which solid the round starts from, and the five after it follow in turn: 0 cube (6 squares, edges 26 LEDs long), 1 octahedron (8 triangles, 36), 2 dodecahedron (12 pentagons, 15), 3 icosahedron (20 triangles, 23), 4 truncated octahedron (8 hexagons and 6 squares, 13), 5 cuboctahedron (8 triangles and 6 squares, 21). Each net is scaled to fill a 60 by 124 LED box, so the edge length depends on the solid and the tree. +// Knob 4: Tree. Which spanning tree of hinges the solid unfolds along, out of 8 different nets kept for each solid from 160 uniform random spanning trees: at 0 the net that fills the most of the box (the cube's covers 51% of the panel), at 1 the eighth (the cube's covers 32%). Every later round of the six solids takes the next tree. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, acos, atan2, hypot, round, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ease = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t)); + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p, i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p, i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p]; +} +// How near a hue is to orange and yellow, 0 to 1. +const warm = (h) => max(0, 1 - abs(h - floor(h) - 0.11) / 0.13); +const sub = (p, q) => [p[0] - q[0], p[1] - q[1], p[2] - q[2]]; +const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2]; +const crs = (p, q) => [p[1] * q[2] - p[2] * q[1], p[2] * q[0] - p[0] * q[2], p[0] * q[1] - p[1] * q[0]]; +const nrm = (p) => { + const l = hypot(p[0], p[1], p[2]) || 1; + return [p[0] / l, p[1] / l, p[2] / l]; +}; + +// The solids as vertex sets: every signed permutation of a triple, or only +// the cyclic ones. +function signed(b, all) { + const out = [], seen = new Set(); + const ps = all ? [[0, 1, 2], [1, 2, 0], [2, 0, 1], [0, 2, 1], [2, 1, 0], [1, 0, 2]] : [[0, 1, 2], [1, 2, 0], [2, 0, 1]]; + for (const p of ps) + for (let s = 0; s < 8; s++) { + const v = p.map((i, k) => ((s >> k) & 1 ? -b[i] : b[i]) + 0); + const key = v.map((x) => x.toFixed(4)).join(); + if (!seen.has(key)) seen.add(key), out.push(v); + } + return out; +} +const PHI = (1 + sqrt(5)) / 2; +const SOLIDS = [ + () => signed([1, 1, 1], 1), + () => signed([1, 0, 0], 1), + () => signed([1, 1, 1], 1).concat(signed([0, 1 / PHI, PHI], 0)), + () => signed([0, 1, PHI], 0), + () => signed([0, 1, 2], 1), + () => signed([1, 1, 0], 1), +]; + +// The faces of the hull: every plane through three vertices with all the +// others on one side, its vertices ordered counter-clockwise from outside. +function hull(V) { + const F = [], seen = new Set(), n = V.length; + for (let i = 0; i < n; i++) + for (let j = i + 1; j < n; j++) + for (let k = j + 1; k < n; k++) { + let c = crs(sub(V[j], V[i]), sub(V[k], V[i])); + if (hypot(c[0], c[1], c[2]) < 1e-9) continue; + c = nrm(c); + const d = dot(c, V[i]), on = []; + let pos = 0, neg = 0; + for (let m = 0; m < n; m++) { + const s = dot(c, V[m]) - d; + if (s > 1e-6) pos++; + else if (s < -1e-6) neg++; + else on.push(m); + } + if (pos && neg) continue; + if (pos) c = [-c[0], -c[1], -c[2]]; + const key = on.join(); + if (seen.has(key)) continue; + seen.add(key); + const ce = [0, 1, 2].map((a) => on.reduce((s, m) => s + V[m][a], 0) / on.length); + const e1 = nrm(sub(V[on[0]], ce)), e2 = crs(c, e1); + const ang = (m) => { + const p = sub(V[m], ce); + return atan2(dot(p, e2), dot(p, e1)); + }; + on.sort((a, b) => ang(a) - ang(b)); + F.push({ v: on, n: c, c: ce }); + } + return F; +} + +// A uniform random spanning tree of the face graph, by loop-erased random +// walks (Wilson's algorithm): parent of each face, -1 at the root. +function wilson(adj) { + const n = adj.length, inT = new Uint8Array(n), nx = new Int32Array(n).fill(-1); + inT[0] = 1; + for (let i = 1; i < n; i++) { + let u = i; + while (!inT[u]) u = nx[u] = adj[u][floor(Math.random() * adj[u].length)]; + for (u = i; !inT[u]; u = nx[u]) inT[u] = 1; + } + nx[0] = -1; + return nx; +} + +// Lay a tree out flat. From the root outward, each face is turned about its +// hinge into its parent's plane, on the far side of the hinge. Returns each +// face's polygon in the root's plane, the BFS order, parents and depths. +function layout(V, F, adj, par, root) { + const n = F.length, kids = F.map(() => []); + for (let f = 0; f < n; f++) if (par[f] >= 0) kids[par[f]].push(f), kids[f].push(par[f]); + const P = new Int32Array(n).fill(-2), dep = new Int32Array(n), order = [root]; + P[root] = -1; + for (let q = 0; q < order.length; q++) + for (const g of kids[order[q]]) if (P[g] === -2) (P[g] = order[q]), (dep[g] = dep[order[q]] + 1), order.push(g); + const pos = new Array(n), f0 = F[root], e1 = nrm(sub(V[f0.v[0]], f0.c)), e2 = crs(f0.n, e1); + pos[root] = f0.v.map((m) => { + const p = sub(V[m], f0.c); + return [dot(p, e1), dot(p, e2)]; + }); + for (const f of order.slice(1)) { + const p = P[f], pf = F[p], ff = F[f], [a, b] = ff.v.filter((m) => pf.v.includes(m)); + const pa = pos[p][pf.v.indexOf(a)], pb = pos[p][pf.v.indexOf(b)], ux = pb[0] - pa[0], uy = pb[1] - pa[1], ul = hypot(ux, uy); + let wx = -uy / ul, wy = ux / ul, cx = 0, cy = 0; + for (const q of pos[p]) (cx += q[0]), (cy += q[1]); + if ((cx / pos[p].length - pa[0]) * wx + (cy / pos[p].length - pa[1]) * wy > 0) (wx = -wx), (wy = -wy); + const u = nrm(sub(V[b], V[a])), r = sub(ff.c, V[a]), t = dot(r, u), w = nrm([r[0] - t * u[0], r[1] - t * u[1], r[2] - t * u[2]]); + pos[f] = ff.v.map((m) => { + const q = sub(V[m], V[a]), s = dot(q, u), h = dot(q, w); + return [pa[0] + (s * ux) / ul + h * wx, pa[1] + (s * uy) / ul + h * wy]; + }); + } + return { pos, order, P, dep, e1, e2 }; +} + +// The turn of the net and the LEDs per unit that fit it into the box: K +// turns tried over half a circle, then a finer look round the best. +const BOXW = 60, BOXH = 124; +function fitNet(pos, K = 48) { + let best = { s: 0 }; + const tryA = (a) => { + const c = cos(a), s = sin(a); + let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; + for (const poly of pos) + for (const [x, y] of poly) { + const X = c * x - s * y, Y = s * x + c * y; + (x0 = min(x0, X)), (x1 = max(x1, X)), (y0 = min(y0, Y)), (y1 = max(y1, Y)); + } + const sc = min(BOXW / (x1 - x0), BOXH / (y1 - y0)); + if (sc > best.s) best = { s: sc, a, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 }; + }; + for (let k = 0; k < K; k++) tryA((k / K) * PI); + const a0 = best.a; + for (let k = -6; k <= 6; k++) tryA(a0 + (k / 6) * (PI / K)); + return best; +} + +// Two convex polygons of the net overlap when no edge of either separates +// them by less than a hair: neighbours along a hinge only touch. Run on +// 20,000 uniform random trees of each of the six solids it found no net +// that overlaps, as Horiyama and Shoji proved for the Platonic ones; on the +// truncated icosahedron it finds about one in a hundred (47 and 51 of +// 5,000 in two runs), so it does see an overlap where there is one. +// The corners round each solid vertex in those nets leave gaps that add up +// to 720 degrees, Descartes' total defect, within 8e-13 degrees. +function overlap(A, B) { + for (const poly of [A, B]) + for (let i = 0; i < poly.length; i++) { + const p = poly[i], q = poly[(i + 1) % poly.length], ax = q[1] - p[1], ay = p[0] - q[0]; + let a0 = 1e9, a1 = -1e9, b0 = 1e9, b1 = -1e9; + for (const v of A) (a0 = min(a0, v[0] * ax + v[1] * ay)), (a1 = max(a1, v[0] * ax + v[1] * ay)); + for (const v of B) (b0 = min(b0, v[0] * ax + v[1] * ay)), (b1 = max(b1, v[0] * ax + v[1] * ay)); + if (min(a1, b1) - max(a0, b0) < 1e-6 * hypot(ax, ay)) return false; + } + return true; +} + +// Trees tried per solid and kept per solid. +const TRIES = 160, KEEP = 8; +function buildSolid(S, k) { + const V = SOLIDS[k](), F = hull(V), n = F.length, em = new Map(); + F.forEach((f, fi) => + f.v.forEach((a, j) => { + const b = f.v[(j + 1) % f.v.length], key = min(a, b) * 64 + max(a, b); + if (!em.has(key)) em.set(key, []); + em.get(key).push(fi); + }), + ); + const adj = F.map(() => []); + for (const [p, q] of em.values()) adj[p].push(q), adj[q].push(p); + let area = 0; + for (const f of F) + for (let j = 1; j + 1 < f.v.length; j++) { + const c = crs(sub(V[f.v[j]], V[f.v[0]]), sub(V[f.v[j + 1]], V[f.v[0]])); + area += hypot(c[0], c[1], c[2]) / 2; + } + // Sample trees, drop nets already seen (same face-centre distances from + // the middle), rank the rest by how much of the box a rough fit fills, + // and keep the best that do not overlap. + const cand = [], sigs = new Set(); + for (let t = 0; t < TRIES; t++) { + const par = wilson(adj), L = layout(V, F, adj, par, 0), fit = fitNet(L.pos, 12); + const cs = L.pos.map((p) => [p.reduce((s, q) => s + q[0], 0) / p.length, p.reduce((s, q) => s + q[1], 0) / p.length]); + const gx = cs.reduce((s, q) => s + q[0], 0) / n, gy = cs.reduce((s, q) => s + q[1], 0) / n; + const sig = cs.map((q) => hypot(q[0] - gx, q[1] - gy).toFixed(3)).sort().join(); + if (sigs.has(sig)) continue; + sigs.add(sig); + cand.push({ par, L, cov: (area * fit.s * fit.s) / N }); + } + cand.sort((a, b) => b.cov - a.cov); + const trees = []; + for (const c of cand) { + if (trees.length === KEEP) break; + let bad = false; + for (let i = 0; i < n && !bad; i++) for (let j = i + 1; j < n && !bad; j++) bad = overlap(c.L.pos[i], c.L.pos[j]); + S.tested++; + if (bad) { + S.overlaps++; + continue; + } + c.fit = fitNet(c.L.pos); + c.cov = (area * c.fit.s * c.fit.s) / N; + trees.push(makeTree(V, F, adj, c)); + } + trees.sort((a, b) => b.cov - a.cov); + return { V, F, n, trees }; +} + +// A kept tree, rooted at the face nearest the middle of its net, with what +// the fold needs: each face's hinge, the angle that lays it flat, and the +// turn from solid to screen that stands the net up the panel. +function makeTree(V, F, adj, c) { + const n = F.length, { fit } = c; + let root = 0, bd = 1e9; + const ca = cos(fit.a), sa = sin(fit.a); + c.L.pos.forEach((p, f) => { + let x = 0, y = 0; + for (const q of p) (x += q[0] / p.length), (y += q[1] / p.length); + const d = hypot(ca * x - sa * y - fit.cx, sa * x + ca * y - fit.cy); + if (d < bd) (bd = d), (root = f); + }); + const L = layout(V, F, adj, c.par, root), f2 = fitNet(L.pos), s = f2.s, c2 = cos(f2.a), s2 = sin(f2.a); + const f0 = F[root]; + // Solid to screen: x and y along the net's turned frame, z out of the root face. + const Q = [0, 1, 2].map((r) => [0, 1, 2].map((k) => s * (r === 0 ? c2 * L.e1[k] - s2 * L.e2[k] : r === 1 ? s2 * L.e1[k] + c2 * L.e2[k] : f0.n[k]))); + const o = Q.map((row, r) => -dot(row, f0.c) - (r === 0 ? f2.cx * s : r === 1 ? f2.cy * s : 0)); + const ax = [], an = [], phi = new Float64Array(n), hv = []; + let maxD = 0; + for (let f = 0; f < n; f++) { + maxD = max(maxD, L.dep[f]); + const ff = F[f]; + let a, b; + if (f === root) (a = ff.v[0]), (b = ff.v[1]); + else [a, b] = ff.v.filter((m) => F[L.P[f]].v.includes(m)); + const u = nrm(sub(V[b], V[a])); + ax[f] = [V[a], u]; + if (f !== root) { + const np = F[L.P[f]].n, th = acos(cl(dot(ff.n, np), -1, 1)), r = rot(u, th), m = mul(r, ff.n); + phi[f] = dot(m, np) > 0.999 ? th : -th; + } + // The hatching is laid on each face from its hinge: each corner's + // distance from the hinge and along it, in LEDs (kept positive). + hv[f] = ff.v.map((m) => { + const q = sub(V[m], V[a]), t = dot(q, u); + return [s * hypot(q[0] - t * u[0], q[1] - t * u[1], q[2] - t * u[2]), s * t + 64]; + }); + an[f] = ff.n; + } + // Shut, the solid is brought nearer until it is 58 LEDs across. + const rc = max(...V.map((v) => hypot(v[0], v[1], v[2]))); + return { root, order: L.order, P: L.P, dep: L.dep, maxD, Q, o, ax, an, phi, hv, s, cov: c.cov, pos: L.pos, zoom: min(1.8, 29 / (rc * s)) }; +} + +// Rotation matrices, row-major 3x3 as nested arrays. +function rot(u, th) { + const c = cos(th), s = sin(th), t = 1 - c, [x, y, z] = u; + return [ + [c + t * x * x, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, c + t * y * y, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, c + t * z * z], + ]; +} +const mul = (m, v) => [dot(m[0], v), dot(m[1], v), dot(m[2], v)]; +const mm = (a, b) => a.map((row) => [0, 1, 2].map((k) => row[0] * b[0][k] + row[1] * b[1][k] + row[2] * b[2][k])); + +// The story of one solid, in seconds at Speed 2: grow out of a point, turn, +// unfold, lie flat, fold, turn, shrink away. A fresh start opens on the net. +const GROW = 0.4, TURN = 0.9, UNF = 3, HOLD = 2.4, FOLD = 2.6, SHR = 0.4; +const CYC = GROW + TURN + UNF + HOLD + FOLD + TURN + SHR; +const T0 = GROW + TURN + UNF + HOLD - 3.4; +// Each hinge swings over this share of the unfolding, the last starting at its end. +const SWING = 0.4; +// The camera's distance in LEDs, the light (a unit vector toward it), and +// the hatching: lines 1 LED wide at 45 degrees to each face's hinge, every +// 2.1 LEDs, at HD of the face's colour, crossed at right angles where a face +// turns from the light and on the plain backs. The map places this pattern +// by the hatching: with no lines the window read detail 0.039; lines every +// 2.8 LEDs along the hinge at 0.4 gave 0.071, and at 45 degrees with the net +// held longer 0.122. Drift comes from the dark moment between two solids, so +// it is the flat net's mean level: 0.351 at HD 0.4, 0.365 at 0.46. +const CAM = 170, LIGHT = nrm([-0.35, 0.45, 0.82]), HP = 3, HD = 0.46; + +export function setup(params) { + params.s = { + hue: 0.5, speed: 2, solidK: 0.5, treeK: 0.06, tau: T0, + solids: [], tested: 0, overlaps: 0, + zb: new Float32Array(N), id: new Int16Array(N), hb: new Float32Array(N), hc: new Float32Array(N), + cur: null, tr: null, + }; +} + +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.speed = cl(k[1], 0.2, 3); + S.solidK = cl(k[2], 0, 5); + S.treeK = cl(k[3], 0, 1); + S.tau += (d * S.speed) / 2; + const cyc = floor(S.tau / CYC), t = S.tau - cyc * CYC; + const si = (floor(S.solidK + 0.5) + cyc) % 6; + if (!S.solids[si]) S.solids[si] = buildSolid(S, si); + const sol = S.solids[si], tr = sol.trees[(min(KEEP - 1, floor(S.treeK * KEEP)) + floor(cyc / 6)) % sol.trees.length]; + S.cur = sol; + S.tr = tr; + // How open the net is, 0 shut to 1 flat, its scale, and the turn. + let open = 0, g = 1; + const t1 = GROW + TURN, t2 = t1 + UNF, t3 = t2 + HOLD, t4 = t3 + FOLD, t5 = t4 + TURN; + if (t < GROW) g = ease(t / GROW); + else if (t < t1) open = 0; + else if (t < t2) open = (t - t1) / UNF; + else if (t < t3) open = 1; + else if (t < t4) open = 1 - (t - t3) / FOLD; + else if (t >= t5) g = 1 - ease((t - t5) / SHR); + // The turn slows to rest as the net opens and picks up as it shuts. + const inEnd = t1 + 0.6 * UNF, outStart = t3 + 0.4 * FOLD; + let th = 0; + if (t < inEnd) th = 5.6 * (1 - t / inEnd) ** 2; + else if (t > outStart) th = -5.6 * ((t - outStart) / (CYC - outStart)) ** 2; + S.turn = rot(nrm([0.45 * sin(cyc * 2.3), 1, 0.45 * cos(cyc * 2.3)]), th); + // Lying flat, the net sways a little about the panel's long axis. + if (t >= t2 && t < t3) S.turn = rot([0, 1, 0], 0.2 * sin((PI * (t - t2)) / HOLD) * sin((2 * PI * (t - t2)) / HOLD)); + S.g = g; + // Each hinge in BFS order swings in its own window of the unfolding. + const n = sol.n, nh = n - 1, ang = new Float64Array(n); + let sum = 0, tot = 0; + for (let q = 1; q < n; q++) { + const f = tr.order[q], st = nh > 1 ? ((q - 1) / (nh - 1)) * (1 - SWING) : 0, o = ease((open - st) / SWING); + ang[f] = o * tr.phi[f]; + sum += o * abs(tr.phi[f]); + tot += abs(tr.phi[f]); + } + S.ang = ang; + S.shut = 1 - sum / tot; + // Shut, it is seen from a little above, so a turn always shows its top. + S.turn = mm(rot([1, 0, 0], 0.42 * S.shut), S.turn); +} + +// Fill one screen triangle, nearest wins: q is 1 over depth, and the two +// hatching coordinates are carried perspective-correct as h q. +function tri(S, A, B, C, f) { + const area = (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]); + if (abs(area) < 1e-6) return; + const x0 = max(0, floor(min(A[0], B[0], C[0]))), x1 = min(W - 1, floor(max(A[0], B[0], C[0]))); + const y0 = max(0, floor(min(A[1], B[1], C[1]))), y1 = min(H - 1, floor(max(A[1], B[1], C[1]))); + const { zb, id, hb, hc } = S; + for (let y = y0; y <= y1; y++) { + const py = y + 0.5; + for (let x = x0; x <= x1; x++) { + const px = x + 0.5; + const w0 = ((B[0] - px) * (C[1] - py) - (B[1] - py) * (C[0] - px)) / area, w1 = ((C[0] - px) * (A[1] - py) - (C[1] - py) * (A[0] - px)) / area, w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + const q = w0 * A[2] + w1 * B[2] + w2 * C[2], i = y * W + x; + if (q <= zb[i]) continue; + zb[i] = q; + id[i] = f; + hb[i] = (w0 * A[3] * A[2] + w1 * B[3] * B[2] + w2 * C[3] * C[2]) / q; + hc[i] = (w0 * A[4] * A[2] + w1 * B[4] * B[2] + w2 * C[4] * C[2]) / q; + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.tr) return; + const { cur: sol, tr, zb, id, hb, hc, ang, turn: R, g } = S; + const { V, F, n } = sol, { Q, o, order, P, ax, an, hv } = tr; + zb.fill(0); + id.fill(-1); + // Each face's motion from the shut solid: its parent's, then its own + // swing about its hinge, in BFS order from the root. + const MR = new Array(n), MT = new Array(n), col = new Array(n), side = new Int8Array(n), cross = new Uint8Array(n); + const sh = S.shut, cxv = o[0] * sh, cyv = o[1] * sh, czv = o[2] * sh, gz = g * (1 + (tr.zoom - 1) * sh); + for (const f of order) { + if (f === tr.root) (MR[f] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]), (MT[f] = [0, 0, 0]); + else { + const p = P[f], [a, u] = ax[f], r = rot(u, ang[f]), ra = mul(r, a), pr = MR[p]; + MR[f] = mm(pr, r); + const v = mul(pr, sub(a, ra)); + MT[f] = [v[0] + MT[p][0], v[1] + MT[p][1], v[2] + MT[p][2]]; + } + // Corners to the screen: solid, fold, stand up, centre, turn, scale, project. + const ff = F[f], pts = []; + for (let j = 0; j < ff.v.length; j++) { + const p0 = mul(MR[f], V[ff.v[j]]), p1 = [p0[0] + MT[f][0], p0[1] + MT[f][1], p0[2] + MT[f][2]]; + const v = [dot(Q[0], p1) + o[0] - cxv, dot(Q[1], p1) + o[1] - cyv, dot(Q[2], p1) + o[2] - czv], w = mul(R, v); + const q = 1 / (CAM - gz * w[2]); + pts.push([32 + CAM * gz * w[0] * q, 64 - CAM * gz * w[1] * q, q, hv[f][j][0], hv[f][j][1]]); + } + let a2 = 0; + for (let j = 0; j < pts.length; j++) { + const p = pts[j], q = pts[(j + 1) % pts.length]; + a2 += p[0] * q[1] - q[0] * p[1]; + } + side[f] = a2 < 0 ? 1 : 0; + for (let j = 1; j + 1 < pts.length; j++) tri(S, pts[0], pts[j], pts[j + 1], f); + // Light on the face, in five steps. + const nn = mul(R, mul(Q, mul(MR[f], an[f]))), nl = hypot(nn[0], nn[1], nn[2]); + const lit = round(cl(0.3 + 0.85 * (dot(nn, LIGHT) / nl) * (side[f] ? 1 : -1), 0.3, 1) * 5) / 5; + const hue = 0.55 + S.hue + (0.32 * tr.dep[f]) / max(1, tr.maxD), wm = warm(hue); + col[f] = side[f] ? hsv(hue, 0.72, max(lit, 0.4 + 0.4 * wm)) : hsv(hue, 0.2, 0.4); + cross[f] = !side[f] || lit < 0.8 ? 1 : 0; + } + for (let i = 0; i < N; i++) { + const f = id[i], x = i % W, y = (i - x) / W; + let r = 0, gg = 0, b = 0; + if (f >= 0) { + const c = col[f], fr = x + 1 < W ? id[i + 1] : -1, fd = y + 1 < H ? id[i + W] : -1; + // An LED whose right or lower neighbour is another face is a dark seam. + let m = 1; + if ((fr >= 0 && fr !== f) || (fd >= 0 && fd !== f)) m = 0.08; + else if ((hb[i] + hc[i]) % HP < 1.41 || (cross[f] && (hb[i] - hc[i] + 999) % HP < 1.41)) m = HD; + (r = c[0] * m), (gg = c[1] * m), (b = c[2] * m); + } + display.setPixel(x, y, (255 * r) | 0, (255 * gg) | 0, (255 * b) | 0); + } +} diff --git a/incoming/pattern-study/patterns/party-lights.js b/incoming/pattern-study/patterns/party-lights.js new file mode 100644 index 000000000..8eee1296c --- /dev/null +++ b/incoming/pattern-study/patterns/party-lights.js @@ -0,0 +1,228 @@ +// ===== Patternflow pattern ===== +// Title: Party Lights +// Family: halloween +// Idea: Strings of big orange, purple, poison green and bone white bulbs sag across a dark porch wall at different heights down the panel, running a chase, a sparkle, a slow breath and a flash on the beat in turn, while gusts swing the strings like pendulums and a garland of black paper bats twists on its threads between the bulbs. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Warmth=-1..1, Speed=0.2..3, Strands=3..7, Gust=0..1 +// +// Knob 1: Warmth. At 0 the bulbs are orange, purple, poison green and bone white in turn along each string; at 1 red orange, rose, amber and candle white, and the wall paint takes their light a little warmer; at -1 magenta, blue violet, aqua green and ice white on a colder wall. A dimmed bulb sinks to deep red, plum, dark green or lavender grey, never brown. +// Knob 2: Speed. At 2 each program runs 4 seconds, in turn: a chase (one bulb in three lit, stepping 6 times a second, each string the other way), a sparkle (every bulb at two thirds, each flaring on its own 0.7 to 1.6 times a second), a breath (a 2.4 second swell rolling down the strings) and the beat (every bulb flashing together at 120 a minute, every fourth flash held twice as long). Gusts come every 5 seconds, the first at 1.7. At 0.2 it all runs ten times slower, at 3 half as fast again. +// Knob 3: Strands. How many strings hang down the panel, 3 to 7, each slung from edge to edge with one end a few rows higher than the other: 3 sag 22 rows each, 7 sag 9 rows and fill the panel, and every string carries 6 bulbs 10 LEDs apart. The paper bats always hang from the second string. +// Knob 4: Gust. The wind swings each string out of the wall like a pendulum, so it lifts, its low point slides sideways and the lower strings swing a little slower. At 0 the strings hang still; at 0.06 a breeze swings them up to 0.5 radians, lifting the sag by an eighth and sliding the low point 5 LEDs; at 1 each gust swings them past a radian, lifting them to a third of their sag and sliding the low point 11 LEDs, while the bulbs knock about and the bats spin on their threads. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, exp, atan, tanh, round, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const ease = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t)); +const fr = (x) => x - floor(x); + +// The four bulbs, each lit and dimmed, at Warmth -1, 0 and 1. Dimmed, the +// warm ones sink to red rather than brown. +const PAL = [ + // orange + [[[0.95, 0.2, 0.9], [0.5, 0.04, 0.55]], [[1, 0.46, 0.02], [0.78, 0.1, 0.02]], [[1, 0.22, 0.02], [0.72, 0.04, 0.02]]], + // purple + [[[0.35, 0.3, 1], [0.14, 0.1, 0.7]], [[0.7, 0.2, 1], [0.42, 0.08, 0.72]], [[1, 0.12, 0.5], [0.68, 0.03, 0.3]]], + // poison green + [[[0.15, 1, 0.6], [0.03, 0.55, 0.3]], [[0.45, 1, 0.08], [0.12, 0.58, 0.04]], [[1, 0.72, 0.05], [0.75, 0.16, 0.02]]], + // bone white + [[[0.75, 0.9, 1], [0.35, 0.42, 0.62]], [[1, 0.95, 0.8], [0.55, 0.45, 0.62]], [[1, 0.82, 0.5], [0.72, 0.36, 0.2]]], +]; +// A glass bulb hanging from its socket: half its width and length in LEDs, +// and how far below the socket its middle hangs. +const BRX = 2.1, BRY = 3, BDY = 3.6; +// A paper bat, 9 wide and 5 tall, its middle column at 4: ears, head, +// wings with raised tips and a scalloped hem. +const BAT = ["#..#.#..#", "##.###.##", "#########", ".#######.", "..#.#.#.."]; +// The light's rings round a bulb, radius in LEDs and share of the bulb's colour. +const RING = [[3.2, 0.45], [5.6, 0.3], [9, 0.2], [13, 0.1]]; +// The same as squared radii and the share inside each. +const RR = RING.map(([r]) => r * r), RS = RING.map((_, k) => RING.slice(k).reduce((s, q) => s + q[1], 0)); +// The programs, each this many story seconds; a chase steps this many times +// a second; the beat is this long. +const PROG = 4, STEP = 6, BEAT = 0.5; +// Gusts come every GUSTS seconds, the first at G0. +const GUSTS = 5, G0 = 2.2; + +export function setup(params) { + // Each bulb's sparkle rate and phase, so they twinkle out of step. + const spark = []; + for (let i = 0; i < 7 * 9; i++) spark.push([0.7 + 0.9 * Math.random(), Math.random()]); + params.s = { + warmth: 0.5, speed: 2, strands: 3, gust: 0.06, pt: 0.5, + phi: new Float64Array(7), dphi: new Float64Array(7), wind: 0.5, spark, + yw: new Float32Array(7 * W), light: new Float32Array(N * 3), rgb: new Float32Array(N * 3), + bulbs: [], + }; +} + +// Where a strand hangs. i of n, its anchors at the panel's edges, its sag, +// and the lowest point pushed sideways and lifted as it swings out of the +// wall by phi, seen through a soft limit so it never swings past 75 degrees. +function strandY(S, i, n, x) { + const D = (H - 22) / n, yl = 5 + i * D + (i % 2 ? 5 : 0), yr = yl + (i % 2 ? -6 : 5), sag = 0.62 * D; + const ph = 1.3 * tanh(S.phi[i] / 1.3), u = (x + 0.5) / W, u0 = 0.5 + 0.18 * sin(ph), sg = sag * cos(ph); + const v = u < u0 ? (u - u0) / u0 : (u - u0) / (1 - u0); + return mix(yl, yr, u) + sg * (1 - v * v); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 3, 0.06], d = cl(dt || 0, 0, 0.1); + S.warmth = cl(k[0], -1, 1); + S.speed = cl(k[1], 0.2, 3); + S.strands = round(cl(k[2], 3, 7)); + S.gust = cl(k[3], 0, 1); + const ds = (d * S.speed) / 2; + S.pt += ds; + // The wind: a breeze and a gust every few seconds, each a swell of about + // a second and a half. + const t = S.pt, gt = t - G0, gk = floor(gt / GUSTS + 0.5), dg = gt - gk * GUSTS; + S.wind = sqrt(S.gust) * (0.35 + 1.1 * exp(-((dg / 0.75) ** 2)) * (gk % 2 ? 0.75 : 1)); + // Each strand swings out of the wall like a pendulum, the lower and + // longer ones slower, pushed by the wind and slowly damped. + for (let i = 0; i < 7; i++) { + const w = TAU / (1.5 + 0.12 * i), steps = 3; + for (let s = 0; s < steps; s++) { + const h = ds / steps, a = -w * w * S.phi[i] - 0.5 * w * S.dphi[i] + w * w * 1.1 * S.wind * (1 + 0.15 * sin(i * 1.7)); + S.dphi[i] += a * h; + S.phi[i] = cl(S.phi[i] + S.dphi[i] * h, -3, 3); + } + } + const n = S.strands; + for (let i = 0; i < n; i++) for (let x = 0; x < W; x++) S.yw[i * W + x] = strandY(S, i, n, x); + // The program and how far into it: chase, sparkle, breath, beat, with a + // short cross-fade from the one before. + const pn = floor(t / PROG), pf = t - pn * PROG, p = pn % 4, q = (pn + 3) % 4, xf = pn > 0 ? ease(pf / 0.4) : 1; + const level = (pr, i, j) => { + if (pr === 0) { + // Chase: one bulb in three lit, stepping along, each strand the other way. + const st = floor(t * STEP); + return ((((i % 2 ? j : -j) + st + i) % 3) + 3) % 3 === 0 ? 1 : 0.25; + } + if (pr === 1) { + // Sparkle: every bulb two thirds lit, each flaring at its own times. + const [r, o] = S.spark[(i * 9 + j) % S.spark.length], f = fr(t * r + o); + return 0.68 + 0.32 * max(0, 1 - f * 5) ** 2; + } + if (pr === 2) return 0.4 + 0.6 * (0.5 + 0.5 * sin(TAU * (t / 2.4 - i * 0.11 - j * 0.03))); + // Beat: all strings flash together and hold, every fourth beat longer. + const b = floor(t / BEAT), f = t - b * BEAT - (b % 4 ? 0.1 : 0.22); + return f < 0 ? 1 : 0.35 + 0.65 * exp(-f / 0.12); + }; + // The bulbs: every 10 LEDs along each strand, the kinds in turn, each + // hanging off the wire tilted by its slope and knocked about by the wind. + S.bulbs.length = 0; + for (let i = 0; i < n; i++) + for (let j = 0; j < 7; j++) { + const x = (i % 2 ? 7 : 2) + 10 * j; + if (x > W - 3) continue; + const xi = min(W - 1, x), y = S.yw[i * W + xi], sl = S.yw[i * W + min(W - 1, xi + 1)] - S.yw[i * W + max(0, xi - 1)]; + const tilt = 0.6 * atan(sl / 2) + S.wind * 0.35 * sin(S.pt * (5 + j * 0.7) + i * 2 + j); + const lv = mix(level(q, i, j), level(p, i, j), xf); + S.bulbs.push({ x, y, tilt, kind: (j + i) % 4, lv }); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.bulbs.length) return; + const { light: L, rgb, bulbs, yw } = S, n = S.strands, wm = S.warmth; + // The bulbs' colours at this warmth, lit and dimmed. + const col = PAL.map((k) => [0, 1].map((m) => [0, 1, 2].map((c) => (wm < 0 ? mix(k[1][m][c], k[0][m][c], -wm) : mix(k[1][m][c], k[2][m][c], wm))))); + const bulbCol = (b) => [0, 1, 2].map((c) => mix(col[b.kind][1][c], col[b.kind][0][c], cl((b.lv - 0.35) / 0.65, 0, 1))); + // The light on the wall: stepped rings round each bulb, by its brightness. + L.fill(0); + for (const b of bulbs) { + const c = col[b.kind][0], cx = b.x + 0.5 + BDY * sin(b.tilt), cy = b.y + BDY * cos(b.tilt), a = b.lv; + const R = RING[RING.length - 1][0]; + for (let y = max(0, floor(cy - R)); y <= min(H - 1, floor(cy + R)); y++) + for (let x = max(0, floor(cx - R)); x <= min(W - 1, floor(cx + R)); x++) { + const r2 = (x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2; + if (r2 >= RR[3]) continue; + const s = r2 < RR[0] ? RS[0] : r2 < RR[1] ? RS[1] : r2 < RR[2] ? RS[2] : RS[3]; + const o = (y * W + x) * 3; + (L[o] += c[0] * s * a), (L[o + 1] += c[1] * s * a), (L[o + 2] += c[2] * s * a); + } + } + // The porch wall: clapboards 6 rows tall, each with a shadowed lower edge, + // dark violet and lit by the bulbs, its paint a little warmer or colder + // with the bulbs. + const ar = 0.85 + 0.15 * wm, ab = 0.85 - 0.15 * wm; + for (let y = 0; y < H; y++) { + const edge = y % 6 === 5, alb = edge ? 0.53 : 1, br = edge ? 0.5 : 1; + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3; + rgb[o] = 0.04 * br + alb * ar * min(1, L[o]); + rgb[o + 1] = 0.025 * br + alb * 0.85 * min(1, L[o + 1]); + rgb[o + 2] = 0.075 * br + alb * ab * min(1, L[o + 2]); + } + } + const put = (x, y, r, g, b) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const o = (y * W + x) * 3; + (rgb[o] = r), (rgb[o + 1] = g), (rgb[o + 2] = b); + }; + // The wires, dark green, lit where the bulbs are near. + for (let i = 0; i < n; i++) { + let py = round(yw[i * W]); + for (let x = 0; x < W; x++) { + const y = round(yw[i * W + x]); + for (let yy = min(py, y); yy <= max(py, y); yy++) { + const o = (yy * W + x) * 3; + if (yy >= 0 && yy < H) put(x, yy, 0.06 + 0.5 * min(1, L[o]), 0.12 + 0.5 * min(1, L[o + 1]), 0.05 + 0.5 * min(1, L[o + 2])); + } + py = y; + } + } + // The garland of paper bats on the second strand, one between each pair + // of bulbs on a thread 8 LEDs long, so they hang in the glow below the + // bulbs, each twisting on its thread. + const gi = 1, off = gi % 2 ? 7 : 2; + for (let j = 0; j < 6; j++) { + const x0 = off + 5 + 10 * j; + if (x0 > W - 3) continue; + const yt = round(yw[gi * W + x0]), tw = S.pt * (1.3 + 0.37 * j) + j * 1.9, amp = 0.35 + 2.2 * S.wind; + const sw = cos(amp * sin(tw) + 0.25 * j), wid = max(0.12, abs(sw)); + for (let yy = yt + 1; yy <= yt + 8; yy++) put(x0, yy, 0.16, 0.13, 0.18); + const by = yt + 9, flap = sin(S.pt * 9 + j) > 0.6 ? 1 : 0; + for (let r = 0; r < BAT.length; r++) + for (let cxx = 0; cxx < 9; cxx++) { + if (BAT[r][cxx] !== "#") continue; + const dx = (cxx - 4) * wid, x = round(x0 + dx), y = by + r - (flap && abs(cxx - 4) > 2 && r < 3 ? 1 : 0); + // A bat turned past a quarter shows its plain back, which takes a + // little more of the bulbs' light. + if (x >= 0 && x < W && y >= 0 && y < H) { + const o = (y * W + x) * 3, lit = sw < 0 ? 0.25 : 0.1; + put(x, y, 0.015 + lit * min(1, L[o]), 0.01 + lit * min(1, L[o + 1]), 0.02 + lit * min(1, L[o + 2])); + } + } + } + // The bulbs: a dark socket on the wire, then the glass, lit in its own + // colour with a paler fleck where it is brightest. + for (const b of bulbs) { + const c = bulbCol(b), s = sin(b.tilt), co = cos(b.tilt), bx = b.x + 0.5, by = b.y + 0.5; + for (let dx = -1; dx <= 1; dx++) put(floor(bx + dx * co), floor(by + 0.5 - dx * s), 0.1, 0.13, 0.08); + const mx = bx + BDY * s, my = by + BDY * co, hot = cl((b.lv - 0.6) / 0.4, 0, 1) * 0.45; + for (let y = floor(my - 4); y <= my + 4; y++) + for (let x = floor(mx - 4); x <= mx + 4; x++) { + const px = x + 0.5 - mx, py = y + 0.5 - my, u = (px * co - py * s) / BRX, v = (px * s + py * co) / BRY, e = u * u + v * v; + if (e > 1) continue; + // The glass glows paler toward its middle, and a fleck shines up and left. + const m = e < 0.3 ? hot : 0, fl = b.lv > 0.6 && u < -0.2 && u > -0.75 && v < -0.15 && v > -0.6 ? 0.55 : 0; + put(x, y, mix(c[0], 1, max(m, fl)), mix(c[1], 1, max(m, fl)), mix(c[2], 1, max(m, fl))); + } + } + for (let i = 0; i < N; i++) { + const o = i * 3; + display.setPixel(i % W, floor(i / W), (255 * cl(rgb[o], 0, 1)) | 0, (255 * cl(rgb[o + 1], 0, 1)) | 0, (255 * cl(rgb[o + 2], 0, 1)) | 0); + } +} From 5ae0a23656e0d2643ba17e462ff7d5dd78b717e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:00:36 +0000 Subject: [PATCH 4/9] patterns: add Kempe Chains and Werewolf Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017mBLLjEyWbBi1Ag71cFsLX --- .../pattern-study/patterns/kempe-chains.js | 575 ++++++++++++++++++ incoming/pattern-study/patterns/werewolf.js | 460 ++++++++++++++ 2 files changed, 1035 insertions(+) create mode 100644 incoming/pattern-study/patterns/kempe-chains.js create mode 100644 incoming/pattern-study/patterns/werewolf.js diff --git a/incoming/pattern-study/patterns/kempe-chains.js b/incoming/pattern-study/patterns/kempe-chains.js new file mode 100644 index 000000000..72a3849e3 --- /dev/null +++ b/incoming/pattern-study/patterns/kempe-chains.js @@ -0,0 +1,575 @@ +// ===== Patternflow pattern ===== +// Title: Kempe Chains +// Family: cells +// Idea: A map of drifting countries is kept four-coloured as their borders change: when a new border puts two neighbours in one colour, the country takes a free colour if one is left, and if all four surround it a Kempe chain of two colours lights up, shows its links from capital to capital and swaps its colours to free one. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Kempe chains (Alfred Kempe, 1879; the gap found by Percy Heawood, 1890), four colour theorem (Kenneth Appel and Wolfgang Haken, 1976), pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Countries=15..40, Sizes=0..1 +// +// Knob 1: Hue. Turns the four colours round the wheel together, a quarter apart: at 0 orange, green, azure and pink. At 0.25 each has moved on to the next, so every country wears another of the same four; at 0.5 orange and azure trade places, and so do green and pink. +// Knob 2: Speed. How fast the stirring carries the countries. At 2 a capital moves at up to 14 LEDs a second, and with 15 countries a new border appears 2 to 3 times a second, a quarter to a third of them between two countries of one colour; at 0.2 everything drifts ten times slower, at 3 half as fast again. A repair shows in the same time at any Speed: 0.2 s of glow, 0.5 s of cross-fade, 0.3 s to cool. +// Knob 3: Countries. How many countries share the panel, from 15 (each about 23 LEDs across) to 40 (about 14). With 15 there is a repair every 1.2 to 2 seconds and half of them or more need a chain; with 40 five or six a second, three in four with a chain. +// Knob 4: Sizes. At 0 every capital has the same weight and the map is a Voronoi diagram, its largest country under twice the area of its smallest. Turning it up makes it a power diagram whose weights spread: at 1 the largest is 8 times the smallest with 15 countries and 18 times with 40, and a big country can border eight or nine others. + +const W = 64, H = 128, N = W * H, M = 40; +const { floor, min, max, abs, sqrt, sin, cos, round, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ease = (t) => ((t = cl(t, 0, 1)), t * t * (3 - 2 * t)); + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// A repair as the panel shows it, in seconds whatever the Speed: the +// countries it touches glow, cross-fade to their new colours and cool. +const RISE = 0.2, CROSS = 0.5, FALL = 0.3, LIFE = RISE + CROSS + FALL; +// Where four countries nearly meet at a point, the LED grid lets the border +// between one pair and the border between the other flicker for a few +// frames. A border counts for this long (in drift seconds) after it was last +// seen, so the colouring is kept proper for both and a repair is not undone +// by the next frame. +const HOLD = 0.4; +// A capital's own wandering speed and the stirring's strength, in LEDs per +// drift second. +const V0 = 2, FLOW = 10; + +export function setup(params) { + const f = () => new Float32Array(M); + params.s = { + t: 0, clock: 0, n: 0, hue: 0, speed: 2, sizes: 0.06, started: false, plan: 0, + x: f(), y: f(), vx: f(), vy: f(), th: f(), om: f(), u: f(), w: f(), r: f(), + own: new Uint8Array(N), col: new Uint8Array(M).fill(255), + // seen: drift time each pair last shared a border; cur: sharing one this frame; act: counts for the colouring. + seen: new Float32Array(M * M).fill(-1e9), cur: new Uint8Array(M * M), act: new Uint8Array(M * M), + area: f(), cx: f(), cy: f(), dy2: f(), + // The colour each country fades from, how far into its repair it is, and how hard it glows. + from: new Float32Array(M * 3), age: new Float32Array(M).fill(LIFE), gl: f(), + pal: new Float32Array(12), fl: [0, 0], disp: new Float32Array(M * 3), ov: new Float32Array(N), + mark: new Uint8Array(M), queue: new Int16Array(M), + // Events still showing: chain links between capitals and the new border that started it. + ev: [], + stats: { frames: 0, proper: 0, repairs: 0, free: 0, swap1: 0, swap2: 0, local: 0, failed: 0, forget: 0, firstSwap: -1, chainSizes: 0 }, + }; +} + +function addSeed(S, i) { + S.x[i] = 2 + Math.random() * (W - 4); + S.y[i] = 2 + Math.random() * (H - 4); + S.vx[i] = S.vy[i] = 0; + S.th[i] = Math.random() * TAU; + S.om[i] = 0.6 + 0.8 * Math.random(); + S.u[i] = Math.random() * 2 - 1; + S.col[i] = 255; + S.age[i] = 0; + S.gl[i] = 0.3; + S.from.fill(0.08, 3 * i, 3 * i + 3); + for (let j = 0; j < M; j++) S.seen[i * M + j] = S.seen[j * M + i] = -1e9; +} + +// The stirring: a stream function of four box modes whose strengths rise +// and fall out of step, so the map is sheared one way and then another and +// neighbours keep changing. A box mode never flows through the frame. +const MODES = [[1, 2, 0.21, 0], [2, 3, 0.17, 2.1], [1, 3, 0.13, 4.2], [2, 1, 0.11, 1.3]]; +function flow(t, x, y, out) { + let u = 0, v = 0; + for (const [a, b, f, ph] of MODES) { + const A = FLOW * cos(TAU * f * t + ph), ka = (PI * a) / W, kb = (PI * b) / H, k = 1 / max(ka, kb); + u += A * k * kb * sin(ka * x) * cos(kb * y); + v -= A * k * ka * cos(ka * x) * sin(kb * y); + } + out[0] = u; + out[1] = v; +} + +// Each capital wanders on a turning heading and is pushed off the others and +// the frame by its size. A capital's size r is half the distance it keeps to +// a neighbour; its power weight is r squared, so the border between two +// touching countries falls where their two sizes meet. +function move(S, ds, wander) { + const n = S.n, A = N / n, { x, y, vx, vy, th, om, u, w, r } = S; + for (let i = 0; i < n; i++) { + const g = 1 + 0.82 * u[i] * S.sizes; + r[i] = 0.58 * sqrt(A * g); + w[i] = 0.3364 * A * (g - 1); + } + if (ds <= 0) return; + for (let i = 0; i < n; i++) { + let fx = 0, fy = 0; + if (wander) { + // The heading turns on two slow waves of the capital's own, so the + // whole drift follows from where things are: the opening can look ahead. + const a = th[i] + 1.6 * sin(0.53 * S.t * om[i] + 5 * th[i]) + 0.9 * sin(1.31 * S.t + 3 * om[i]); + fx = V0 * cos(a); + fy = V0 * sin(a); + flow(S.t, x[i], y[i], S.fl); + fx += S.fl[0]; + fy += S.fl[1]; + } + for (let j = 0; j < n; j++) { + if (j === i) continue; + const dx = x[i] - x[j], dy = y[i] - y[j], d = sqrt(dx * dx + dy * dy) + 1e-6, rr = r[i] + r[j]; + if (d < rr) { + const k = (3.5 * (rr - d)) / d; + fx += dx * k; + fy += dy * k; + } + } + const m = 0.7 * r[i]; + if (x[i] < m) fx += 3.5 * (m - x[i]); + if (x[i] > W - m) fx -= 3.5 * (x[i] - W + m); + if (y[i] < m) fy += 3.5 * (m - y[i]); + if (y[i] > H - m) fy -= 3.5 * (y[i] - H + m); + const e = min(1, ds * 2.5); + vx[i] += (fx - vx[i]) * e; + vy[i] += (fy - vy[i]) * e; + } + for (let i = 0; i < n; i++) { + x[i] = cl(x[i] + vx[i] * ds, 0.5, W - 0.5); + y[i] = cl(y[i] + vy[i] * ds, 0.5, H - 0.5); + } +} + +// The map: every LED belongs to the capital of least power distance +// |p - c|^2 - w. Two countries share a border when two LEDs side by side +// (not only corner to corner) belong to them. +function map(S) { + const n = S.n, { x, y, w, own, cur, seen, act, area, cx, cy, dy2 } = S; + for (let y0 = 0, p = 0; y0 < H; y0++) { + const py = y0 + 0.5; + for (let i = 0; i < n; i++) dy2[i] = (py - y[i]) * (py - y[i]) - w[i]; + for (let x0 = 0; x0 < W; x0++, p++) { + const px = x0 + 0.5; + let best = 1e9, bi = 0; + for (let i = 0; i < n; i++) { + const dx = px - x[i], v = dx * dx + dy2[i]; + if (v < best) (best = v), (bi = i); + } + own[p] = bi; + } + } + cur.fill(0); + area.fill(0); + cx.fill(0); + cy.fill(0); + for (let y0 = 0, p = 0; y0 < H; y0++) + for (let x0 = 0; x0 < W; x0++, p++) { + const o = own[p]; + area[o]++; + cx[o] += x0 + 0.5; + cy[o] += y0 + 0.5; + if (x0 < W - 1 && own[p + 1] !== o) cur[o * M + own[p + 1]] = cur[own[p + 1] * M + o] = 1; + if (y0 < H - 1 && own[p + W] !== o) cur[o * M + own[p + W]] = cur[own[p + W] * M + o] = 1; + } + for (let i = 0; i < n; i++) { + if (area[i]) (cx[i] /= area[i]), (cy[i] /= area[i]); + else (cx[i] = x[i]), (cy[i] = y[i]); + } + act.fill(0); + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) { + const k = i * M + j; + if (cur[k]) seen[k] = S.t; + if (i !== j && S.t - seen[k] < HOLD) act[k] = 1; + } +} + +// Colours used round country v, as four bits (uncoloured neighbours ignored). +function used(S, v) { + let m = 0; + for (let j = 0; j < S.n; j++) if (S.act[v * M + j] && S.col[j] < 4) m |= 1 << S.col[j]; + return m; +} + +// The Kempe chain(s): every country reachable from the starts through +// countries coloured a or b, never through v. Marks them in S.mark and +// returns their indices. +function chain(S, v, starts, a, b) { + const { mark, queue, col, act, n } = S; + mark.fill(0); + let h = 0, t = 0; + for (const s of starts) if (!mark[s]) (mark[s] = 1), (queue[t++] = s); + while (h < t) { + const i = queue[h++]; + for (let j = 0; j < n; j++) + if (j !== v && !mark[j] && act[i * M + j] && (col[j] === a || col[j] === b)) (mark[j] = 1), (queue[t++] = j); + } + return Array.from(queue.subarray(0, t)); +} + +const nbOf = (S, v, c) => { + const out = []; + for (let j = 0; j < S.n; j++) if (S.act[v * M + j] && S.col[j] === c) out.push(j); + return out; +}; + +// One swap: for some pair of colours (a, b), the a/b chains through v's +// a-coloured neighbours reach none of its b-coloured ones, so swapping a and b +// on them leaves no a round v. Of the swaps that work, the one shown is the +// longest chain of up to five countries, then the shortest longer one; a +// chain of one country only when nothing else works. +function single(S, xs) { + let best = null, score = 1e9; + for (const v of xs) + for (let a = 0; a < 4; a++) { + const starts = nbOf(S, v, a); + if (!starts.length) continue; + for (let b = 0; b < 4; b++) { + if (b === a) continue; + const comp = chain(S, v, starts, a, b); + let ok = true; + for (let j = 0; j < S.n && ok; j++) if (S.mark[j] && S.col[j] === b && S.act[v * M + j]) ok = false; + if (!ok) continue; + const L = comp.length, sc = L === 1 ? 100 : L <= 5 ? 10 - L : 10 + L; + if (sc < score) (score = sc), (best = { v, a, b, comp }); + } + } + return best; +} + +function swap(S, comp, a, b) { + for (const i of comp) S.col[i] = S.col[i] === a ? b : a; +} + +// The colour a country shows now, part way through its repair. +function shown(S, i, out, o) { + const e = ease((S.age[i] - RISE) / CROSS), c = S.col[i] < 4 ? S.col[i] : 0, p = S.pal; + for (let k = 0; k < 3; k++) out[o + k] = S.from[3 * i + k] + (p[3 * c + k] - S.from[3 * i + k]) * e; +} + +// Give i the colour c; on screen it glows by g and fades over from what it shows now. +function paint(S, i, c, show, g) { + if (show) { + shown(S, i, S.from, 3 * i); + S.gl[i] = max(g, S.age[i] < LIFE ? S.gl[i] * 0.5 : 0); + S.age[i] = 0; + } + S.col[i] = c; + if (!show) { + S.age[i] = LIFE; + S.gl[i] = 0; + } +} + +function event(S, comp, v, a, b, show) { + if (!show) return; + // Links between capitals along the chain, and from v to the chain. + const links = []; + const inC = new Uint8Array(M); + for (const i of comp) inC[i] = 1; + for (const i of comp) for (const j of comp) if (i < j && S.act[i * M + j]) links.push(i, j); + for (const j of comp) if (S.act[v * M + j]) links.push(v, j); + S.ev.push({ links, age: 0, a, b }); +} + +// Repair the conflict on the border u-v (u -1: v is a new, uncoloured country). +function repair(S, u, v, show, last) { + const st = S.stats, xs = u < 0 ? [v] : [v, u]; + if (show) st.repairs++; + // A free colour. + for (const x of xs) { + const m = used(S, x); + if (m !== 15) { + const free = []; + for (let c = 0; c < 4; c++) if (!(m & (1 << c))) free.push(c); + paint(S, x, free[floor(Math.random() * free.length)], show, 0.28); + if (show) st.free++, S.ev.push({ links: [], age: 0, a: u, b: v }); + return true; + } + } + // One Kempe swap. + let k = single(S, xs); + if (k) { + for (const i of k.comp) paint(S, i, S.col[i] === k.a ? k.b : k.a, show, 0.6); + paint(S, k.v, k.a, show, 0.6); + if (show) { + st.swap1++; + st.chainSizes += k.comp.length; + if (st.firstSwap < 0) st.firstSwap = S.clock; + event(S, k.comp, k.v, u, v, true); + } + return true; + } + // Two swaps, as Kempe argued for a country of five neighbours: the first + // swap on any a/b chain through a neighbour, then one swap that works. + for (const x of xs) + for (let a = 0; a < 4; a++) + for (let b = 0; b < 4; b++) { + if (a === b) continue; + for (const w of nbOf(S, x, a)) { + const c1 = chain(S, x, [w], a, b); + swap(S, c1, a, b); + k = single(S, [x]); + if (k) { + swap(S, c1, a, b); + for (const i of c1) paint(S, i, S.col[i] === a ? b : a, show, 0.6); + for (const i of k.comp) paint(S, i, S.col[i] === k.a ? k.b : k.a, show, 0.6); + paint(S, x, k.a, show, 0.6); + if (show) { + st.swap2++; + if (st.firstSwap < 0) st.firstSwap = S.clock; + event(S, c1.concat(k.comp), x, u, v, true); + } + return true; + } + swap(S, c1, a, b); + } + } + // Neither worked (Heawood's case or worse). While remembered borders are in + // the graph, the caller forgets them and asks again; on the map as it is, a + // short local search: each step takes a country in conflict and gives it + // the colour fewest of its neighbours have. + if (!last) { + if (show) st.repairs--; + return false; + } + if (show) st.local++; + const n = S.n, col = S.col.slice(0, n); + for (let step = 0; step < 4000; step++) { + const bad = []; + for (let i = 0; i < n; i++) { + if (S.area[i] === 0 && col[i] < 4) continue; + if (col[i] > 3) { + bad.push(i); + continue; + } + for (let j = 0; j < n; j++) if (S.act[i * M + j] && col[j] === col[i]) { + bad.push(i); + break; + } + } + if (!bad.length) { + for (let i = 0; i < n; i++) if (col[i] !== S.col[i]) paint(S, i, col[i], show, 0.6); + return true; + } + const i = bad[floor(Math.random() * bad.length)], cnt = [0, 0, 0, 0]; + for (let j = 0; j < n; j++) if (S.act[i * M + j] && col[j] < 4) cnt[col[j]]++; + let bc = 0; + if (Math.random() < 0.15) bc = floor(Math.random() * 4); + else for (let c = 1; c < 4; c++) if (cnt[c] < cnt[bc] || (cnt[c] === cnt[bc] && Math.random() < 0.5)) bc = c; + col[i] = bc; + } + if (show) st.failed++; + return false; +} + +function repairAll(S, show) { + const n = S.n, col = S.col, act = S.act; + for (let guard = 0, forgot = false; guard < 120; guard++) { + let u = -1, v = -1; + for (let i = 0; i < n && v < 0; i++) if (col[i] > 3 && S.area[i] > 0) v = i; + for (let i = 0; i < n && v < 0; i++) + for (let j = i + 1; j < n; j++) + if (act[i * M + j] && col[i] === col[j]) { + // Recolour the smaller of the two first. + [u, v] = S.area[i] < S.area[j] ? [j, i] : [i, j]; + break; + } + if (v < 0) return; + let mem = false; + for (let k = 0; k < M * M && !mem; k++) if (act[k] && !S.cur[k]) mem = true; + if (repair(S, u, v, show, forgot || !mem)) continue; + // Kempe's moves found nothing on the graph with the remembered borders in + // it, which need not be planar: two countries touched a moment ago that + // no longer do, and the map has closed round them. Forget every border + // not on the map now and repair again on the map as it is. + if (forgot) return; + forgot = true; + if (show) S.stats.forget++; + for (let k = 0; k < M * M; k++) if (!S.cur[k]) (S.seen[k] = -1e9), (act[k] = 0); + } +} + +// The opening. The drift is worked out 1.9 drift seconds ahead, and the first +// colouring is chosen so that one of the first six borders to appear after +// 0.6 s joins two countries of one colour, each already ringed by the other +// three colours: no free colour is left for either, so the first repair needs +// a chain. Borders that appear before it get proper colours from the start. +// If no such colouring turns up, the first new border at least joins one +// colour, and if there is none the map is coloured plainly. +function opening(S, ds) { + const n = S.n, keep = [S.x, S.y, S.vx, S.vy].map((a) => a.slice()), t0 = S.t, G0 = S.cur.slice(); + const pre = S.cur.slice(), first = new Float32Array(M * M).fill(-1), acts = []; + for (let k = 1; S.t < t0 + 1.9; k++) { + move(S, ds, true); + S.t += ds; + map(S); + acts.push(S.act.slice()); + for (let i = 0; i < n; i++) + for (let j = i + 1; j < n; j++) if (S.cur[i * M + j] && !G0[i * M + j] && first[i * M + j] < 0) first[i * M + j] = S.t - t0; + } + [S.x, S.y, S.vx, S.vy].forEach((a, i) => a.set(keep[i])); + S.t = t0; + S.seen.fill(-1e9); + map(S); + const cands = []; + for (let k = 0; k < M * M; k++) if (first[k] >= 0.6) cands.push(k); + cands.sort((a, b) => first[a] - first[b]); + for (const k of cands.slice(0, 6)) { + const c = floor(k / M), d = k % M, step = round(first[k] / ds) - 1, Gt = acts[min(acts.length - 1, step)]; + // Every border seen before this one must be proper from the start. + pre.set(G0); + for (let q = 0; q < M * M; q++) if (first[q] >= 0 && first[q] < first[k]) pre[q] = pre[(q % M) * M + floor(q / M)] = 1; + for (let tries = 0; tries < 40; tries++) { + if (!colourWith(S, pre, c, d)) break; + let ok = true; + for (const x of [c, d]) { + let m = 0; + for (let j = 0; j < n; j++) if (j !== c && j !== d && Gt[x * M + j]) m |= 1 << S.col[j]; + if ((m | (1 << S.col[c])) !== 15) ok = false; + } + if (ok) return first[k]; + } + } + // No such pair: at least let the first new border join one colour. + if (cands.length && colourWith(S, G0, floor(cands[0] / M), cands[0] % M)) return -first[cands[0]]; + S.col.fill(255); + return 0; +} + +// A random proper colouring of the graph g with c and d given one colour, by +// backtracking over the countries in random order, most neighbours first. +function colourWith(S, g, c, d) { + const n = S.n, col = S.col, order = []; + for (let i = 0; i < n; i++) if (i !== d) order.push(i); + const dg = new Float32Array(M); + for (const i of order) { + for (let j = 0; j < n; j++) dg[i] += g[i * M + j] + (i === c ? g[d * M + j] : 0); + dg[i] += Math.random(); + } + order.sort((a, b) => dg[b] - dg[a]); + col.fill(255); + let budget = 5000; + const fits = (i, k) => { + for (let j = 0; j < n; j++) if (col[j] === k && (g[i * M + j] || (i === c && g[d * M + j]))) return false; + return true; + }; + const go = (q) => { + if (q === order.length) return true; + if (--budget < 0) return false; + const i = order[q], o = floor(Math.random() * 4); + for (let e = 0; e < 4; e++) { + const k = (o + e) % 4; + if (!fits(i, k)) continue; + col[i] = k; + if (i === c) col[d] = k; + if (go(q + 1)) return true; + } + col[i] = 255; + if (i === c) col[d] = 255; + return false; + }; + return go(0); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 15, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.sizes = cl(k[3], 0, 1); + const n = round(cl(k[2], 15, 40)), ds = (d * S.speed) / 2; + S.clock += d; + for (let c = 0; c < 4; c++) hsv(S.hue + 0.075 + c / 4, 0.72, 0.92, S.pal, 3 * c); + if (!S.started) { + S.started = true; + S.n = n; + for (let i = 0; i < n; i++) addSeed(S, i); + // Spread the capitals out before the first frame. + for (let i = 0; i < 25; i++) move(S, 0.05, false); + move(S, 0, true); + map(S); + S.plan = opening(S, (S.speed / 2) / 30); + repairAll(S, false); + } else { + if (n > S.n) for (let i = S.n; i < n; i++) addSeed(S, i); + S.n = n; + move(S, ds, true); + S.t += ds; + map(S); + repairAll(S, true); + } + // Count the frames whose map, as it is drawn, is properly four-coloured. + const st = S.stats; + let ok = true; + for (let i = 0; i < S.n && ok; i++) { + if (S.area[i] > 0 && S.col[i] > 3) ok = false; + for (let j = i + 1; j < S.n; j++) if (S.cur[i * M + j] && S.col[i] === S.col[j]) ok = false; + } + st.frames++; + if (ok) st.proper++; + for (let i = 0; i < M; i++) S.age[i] = min(LIFE, S.age[i] + d); + for (const e of S.ev) e.age += d; + S.ev = S.ev.filter((e) => e.age < LIFE); +} + +// A glow envelope over a repair's life: up in RISE, held, down in FALL. +const env = (a) => (a < RISE ? a / RISE : a > LIFE - FALL ? max(0, (LIFE - a) / FALL) : 1); +// Channels in steps of 4 of 255, so flat countries stay flat. +const q = (v) => (v <= 0 ? 0 : v >= 1 ? 252 : round(v * 63) * 4); + +function line(ov, x0, y0, x1, y1, a) { + const steps = max(1, round(max(abs(x1 - x0), abs(y1 - y0)))); + for (let s = 0; s <= steps; s++) { + const px = floor(x0 + ((x1 - x0) * s) / steps), py = floor(y0 + ((y1 - y0) * s) / steps); + if (px >= 0 && px < W && py >= 0 && py < H) ov[py * W + px] = max(ov[py * W + px], a); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const { own, disp, ov, cx, cy, gl, age } = S; + for (let i = 0; i < S.n; i++) shown(S, i, disp, 3 * i); + ov.fill(0); + // The new border that started each repair flashes white; the chain's links run capital to capital. + const flash = new Float32Array(M * M); + for (const e of S.ev) { + const a = env(e.age); + if (e.a >= 0 && e.b >= 0) flash[e.a * M + e.b] = flash[e.b * M + e.a] = max(flash[e.a * M + e.b], a); + const L = e.links; + for (let i = 0; i < L.length; i += 2) { + line(ov, cx[L[i]], cy[L[i]], cx[L[i + 1]], cy[L[i + 1]], a * 0.85); + for (const j of [L[i], L[i + 1]]) + for (let dy = -1; dy <= 0; dy++) + for (let dx = -1; dx <= 0; dx++) { + const px = floor(cx[j]) + dx, py = floor(cy[j]) + dy; + if (px >= 0 && px < W && py >= 0 && py < H) ov[py * W + px] = max(ov[py * W + px], a); + } + } + } + for (let y = 0, p = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + const o = own[p], r = x < W - 1 ? own[p + 1] : o, b = y < H - 1 ? own[p + W] : o; + let R, G, B; + if (r !== o || b !== o) { + // A dark border LED, white while its border is the new one. + const f = max(r !== o ? flash[o * M + r] : 0, b !== o ? flash[o * M + b] : 0); + R = G = B = f; + R += 0.07 * (1 - f); + G += 0.05 * (1 - f); + B += 0.09 * (1 - f); + } else { + const g = gl[o] * env(age[o]); + R = disp[3 * o] + (1 - disp[3 * o]) * g; + G = disp[3 * o + 1] + (1 - disp[3 * o + 1]) * g; + B = disp[3 * o + 2] + (1 - disp[3 * o + 2]) * g; + } + const v = ov[p]; + if (v > 0) { + R += (1 - R) * v; + G += (1 - G) * v; + B += (1 - B) * v; + } + display.setPixel(x, y, q(R), q(G), q(B)); + } +} diff --git a/incoming/pattern-study/patterns/werewolf.js b/incoming/pattern-study/patterns/werewolf.js new file mode 100644 index 000000000..b02e040e7 --- /dev/null +++ b/incoming/pattern-study/patterns/werewolf.js @@ -0,0 +1,460 @@ +// ===== Patternflow pattern ===== +// Title: Werewolf +// Family: halloween +// Idea: Clouds slide off a huge full moon and the black figure of a man standing on a rock in front of it bends forward with his back arching, his arms stretching down into forelegs and his face into a muzzle, until a wolf with pricked ears stands there and howls rings into the night; then the clouds come back over the moon and the wolf shrinks into the man again. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Clouds=0..1, Moon=-1..1 +// +// Knob 1: Hue. The night sky and the clouds, through the cold colours only: at 0 purple, at 0.25 indigo, at 0.5 deep sea blue, at 0.75 teal and at 1 poison green. The moon stays pale gold. +// Knob 2: Speed. At 2 one night takes 15 seconds: the bank of clouds is past the man by 2 s, the change runs from 0.4 s to 4.4 s, the wolf raises its head at 5 s and howls from 5.5 s to 8.6 s with a ring every 0.3 s, the clouds are back over the moon by 12 s and the wolf is a man again by 13.5 s. At 0.2 ten times slower, at 3 half as fast again. +// Knob 3: Clouds. At 0 four thin clouds veil the moon, letting about half its light through, and two wisps cross the high sky; at 1 the four are heavy, three more trail behind them across the moon until 4 s into the night, and six thick wisps fill the high sky. +// Knob 4: Moon. Its radius: 22 LEDs at -1, 27 at the opening 0.06 and 32 at 1, where it fills the panel's width. + +const W = 64, H = 128, N = W * H; +const { floor, ceil, min, max, abs, sqrt, sin, cos, atan2, acos, round, PI } = Math; +const hypot = (x, y) => sqrt(x * x + y * y); +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ease = (t) => ((t = cl(t, 0, 1)), t * t * (3 - 2 * t)); +const lerp = (a, b, t) => a + (b - a) * t; +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p, i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p, i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p]; +} +// Channels in steps of 4 of 255: flat areas stay flat for the panel. +const q4 = (v) => (v <= 0 ? 0 : v >= 1 ? 252 : round(v * 63) * 4); + +// Signed distances in LEDs, negative inside. +const circle = (px, py, cx, cy, r) => hypot(px - cx, py - cy) - r; +function capsule(px, py, ax, ay, bx, by, ra, rb) { + const ex = bx - ax, ey = by - ay, qx = px - ax, qy = py - ay, u = cl((qx * ex + qy * ey) / (ex * ex + ey * ey || 1), 0, 1); + return hypot(qx - u * ex, qy - u * ey) - (ra + (rb - ra) * u); +} +const smin = (a, b, k) => { + const h = cl(0.5 + (0.5 * (b - a)) / k, 0, 1); + return b + (a - b) * h - k * h * (1 - h); +}; + +// The moon, the ground line the figure stands on, and one night in story seconds. +const MX = 32, MY = 74, G = 104, NIGHT = 15; +const MOON = [1, 0.9, 0.6]; +// The rock's top edge per column, flat under the figure and falling away in +// steps at both ends, and the far hills, hidden behind the rock in the middle. +const CREST = new Float32Array(W), RIDGE = new Float32Array(W); +for (let x = 0; x < W; x++) { + const l = max(0, 14 - x), r = max(0, x - 50); + // Boulders at both ends of the top, clear of where the feet go. + const knob = -2.2 * max(0, 1 - abs(x - 14.5) / 3) - 1.6 * max(0, 1 - abs(x - 50) / 2.5); + CREST[x] = G + knob + 0.95 * l + 1.15 * r + min(1, (l + r) / 3) * (2.2 * abs(sin(x * 0.75)) + 0.8 * sin(x * 2.1)); + RIDGE[x] = 96 + 9 * cos((PI * (x - 32)) / 64) ** 2 + 1.3 * sin(x * 0.37) + 0.6 * sin(x * 1.3); +} +// Seas on the moon as [dx, dy, rx, ry] in units of its radius. +const SEAS = [[-0.3, -0.3, 0.22, 0.18], [0.27, -0.4, 0.18, 0.15], [0.36, 0.1, 0.22, 0.22], [-0.42, 0.22, 0.18, 0.22], [0.04, 0.45, 0.22, 0.14]]; +// Stars: LED and twinkle phase, in the high sky. +const STARS = []; +for (let i = 0; STARS.length < 24; i++) { + const x = floor(hash(3 * i + 1) * W), y = floor(2 + hash(3 * i + 2) * 40); + STARS.push([y * W + x, hash(3 * i + 3) * TAU]); +} +// The bank of clouds that covers and uncovers the moon, [dx, dy, half length, +// thickness] about the moon's centre, most important first; and the wisps of +// the high sky, [row, start, half length, thickness]. Each is a row of puffs +// with a flat foot. The first four always come; the other three trail to the +// right, so on a cloudy night they are still crossing the moon while the +// change begins. +const BANK = [[-4, -9, 22, 9], [9, 10, 22, 8], [-12, 27, 18, 7], [10, -28, 17, 6], [46, -16, 18, 8], [58, 6, 20, 9], [70, 24, 20, 8]]; +const WISPS = [[14, 0.1, 16, 3.5], [31, 0.55, 19, 4], [6, 0.8, 12, 3], [41, 0.3, 14, 3.5], [23, 0.95, 17, 3], [49, 0.7, 12, 3]]; +[...BANK, ...WISPS].forEach((C, j) => { + const L = C[2], T = C[3], n = 1 + Math.ceil((2 * L) / (0.8 * T)); + C.puffs = []; + for (let i = 0; i < n; i++) { + const e = (2 * i) / (n - 1) - 1, r = T * (0.7 + 0.55 * hash(j * 31 + i)) * (1 - 0.45 * e * e); + C.puffs.push([L * e, 0.35 * T - 0.55 * r, r]); + } +}); + +// A cloud's distance field: its puffs blended, its foot cut flat. +function puff(C, px, py, th) { + let d = 99; + for (const [ux, uy, ur] of C.puffs) d = smin(d, hypot(px - ux, py - uy * th) - ur * th, 0.5 * C[3]); + return max(d, py - C[3] * 0.45 * th); +} + +// A cloud's shape at thickness th as a mask over its box, its centre on an +// LED: 1 edge, 2 body, plus 4 along the lit top. +function shape(C, th) { + const L = C[2], T = C[3], ox = -ceil(L + T) - 1, oy = -ceil(2 * T * th) - 1, w = 1 - 2 * ox, h = 2 - oy + ceil(T); + const m = new Uint8Array(w * h); + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + const px = ox + i + 0.5, py = oy + j + 0.5, d = puff(C, px, py, th); + if (d <= 0) m[j * w + i] = (d > -1.2 ? 1 : 2) | (puff(C, px, py - 1.3, th) > 0 ? 4 : 0); + } + C.mask = { th, ox, oy, w, h, m }; +} + +// Two poses for the one skeleton, facing right: a man standing and a wolf on +// four legs. Points are LEDs on the panel, angles in radians (0 right, pi/2 +// down), radii in LEDs. The legs are placed by where the feet go, so the +// change keeps them on the rock. hip: where it is; spine: the back's angle +// and length; rad: the body's radius at hip, middle and shoulder; head: angle +// and distance from the shoulder; arm: where the arm leaves the shoulder; +// up, fore: arm bones; wrists and hand: where the hands go and the hand +// itself; toes and foot: where the toes go and the foot back to the ankle. +const MAN = { + hip: [31.5, 80.5], spine: -1.5, len: 18, rad: [3.4, 3.3, 3.9], chest: 0, + head: [-1.38, 7.8], face: 0.05, neck: [1.6, 1.6], + arm: [0.3, 1.5], up: 9.5, fore: 8.5, wrists: [[2.6, 16.6], [-2.4, 16.2]], hand: [[0.7, 3.2], [-0.3, 3.2]], ar: [1.6, 1.25, 1], + thigh: 12, shin: 11.5, toes: [[36.5, G], [33, G]], foot: [[4.3, 1.4], [4.3, 1.4]], lr: [2.4, 1.7, 1.15], +}; +const WOLF = { + hip: [21, 82.5], spine: -0.07, len: 21, rad: [4.2, 4.4, 5.4], chest: 4.8, + head: [-0.8, 10.5], face: 0.15, neck: [3.9, 2.7], + arm: [1.5, 2.5], up: 9, fore: 10, wrists: [[46.5, 101], [40.5, 101]], hand: [[1.6, 2.8], [1.6, 2.8]], ar: [2.2, 1.35, 1.05], + thigh: 9.5, shin: 9, toes: [[25.5, G], [18.5, G]], foot: [[2.5, 6.6], [2.5, 6.6]], lr: [3.2, 1.6, 1.05], +}; + +// Two-bone reach from (ax, ay) toward (tx, ty); bend 1 puts the middle joint +// on the clockwise side. Writes joint and end into out. +function reach(ax, ay, tx, ty, l1, l2, bend, out) { + const dx = tx - ax, dy = ty - ay, d = cl(hypot(dx, dy), abs(l1 - l2) + 0.01, l1 + l2 - 0.01), a = atan2(dy, dx); + const th = acos(cl((l1 * l1 + d * d - l2 * l2) / (2 * l1 * d), -1, 1)), a1 = a + bend * th; + out[0] = ax + l1 * cos(a1); + out[1] = ay + l1 * sin(a1); + const b = atan2(ty - out[1], tx - out[0]); + out[2] = out[0] + l2 * cos(b); + out[3] = out[1] + l2 * sin(b); +} + +export function setup(params) { + params.s = { + t: 0, hue: 0, speed: 2, clouds: 0, moon: 0.06, key: "", m: 0, lift: 0, bankX: 0, howling: false, night: 0, + rings: [], ringClock: 0, P: null, + base: new Float32Array(N * 3), mc: new Float32Array(N), nm: new Float32Array(N), buf: new Float32Array(N * 3), sil: new Float32Array(N), ra: new Float32Array(N), info: { u: 0, v: 0 }, + }; +} + +// Where the story is at story time t of the night: the change m (0 man, 1 +// wolf), the howl's lift and the bank's offset from the moon. +function story(S) { + const t = S.t % NIGHT; + S.m = t < 10.5 ? ease((t - 0.4) / 4) : 1 - ease((t - 10.5) / 3); + S.lift = ease((t - 5) / 0.6) - ease((t - 8.6) / 0.6); + S.bankX = t < 9 ? -130 * ease(t / 5) : 130 * (1 - ease((t - 9) / 3)); + S.howling = t > 5.5 && t < 8.6; + S.night = t; +} + +// The skeleton at the change m, each part on its own schedule: the back bends +// first and arches most half way, the arms and legs follow, then the face, +// and last the ears, tail and fur. +function pose(S) { + const m = S.m, l = S.lift, sp = ease(m / 0.6), fl = ease((m - 0.12) / 0.6), hl = ease((m - 0.2) / 0.6); + const hd = ease((m - 0.3) / 0.5), late = ease((m - 0.5) / 0.45), bulk = ease((m - 0.1) / 0.7); + // A shudder while it changes. + const sh = sin(PI * m) * sin(S.t * 23) * 0.05, A = MAN, B = WOLF; + const P = (S.P = S.P || { leg: [[0, 0, 0, 0], [0, 0, 0, 0]], arm: [[0, 0, 0, 0], [0, 0, 0, 0]], ft: [[0, 0], [0, 0]], hn: [[0, 0], [0, 0]], tail: new Float32Array(10) }); + P.hd = hd; + P.late = late; + P.fur = ease((m - 0.35) / 0.6); + P.bulk = bulk; + const hx = lerp(A.hip[0], B.hip[0], sp), hy = lerp(A.hip[1], B.hip[1], hl) - 1.2 * l; + const sa = lerp(A.spine, B.spine, sp) + sh - 0.12 * l, ln = lerp(A.len, B.len, sp); + const arch = 4.5 * sin(PI * cl(m / 0.85, 0, 1)) + 1.2 * bulk; + P.hip = [hx, hy]; + P.sh = [hx + ln * cos(sa), hy + ln * sin(sa)]; + // The middle of the back bulges away from the belly: behind him while he + // stands, up once the back is level. + P.mid = [hx + 0.5 * ln * cos(sa) + arch * sin(sa), hy + 0.5 * ln * sin(sa) - arch * cos(sa)]; + P.rad = A.rad.map((r, i) => lerp(r, B.rad[i], bulk)); + P.chest = lerp(A.chest, B.chest, bulk); + // The head: up and back in the howl, thrown back once in the middle of the change. + const throwBack = 0.5 * sin(PI * cl((m - 0.25) / 0.5, 0, 1)); + const ha = lerp(A.head[0], B.head[0], hd) - 0.5 * l - throwBack, hl2 = lerp(A.head[1], B.head[1], hd); + P.head = [P.sh[0] + hl2 * cos(ha), P.sh[1] + hl2 * sin(ha)]; + P.face = lerp(A.face, B.face, hd) - 1.25 * l - 0.9 * throwBack; + P.neck = A.neck.map((r, i) => lerp(r, B.neck[i], bulk)); + P.jaw = 0.35 * ease((S.night - 5.4) / 0.3) * (1 - ease((S.night - 8.5) / 0.3)) + 0.25 * throwBack; + // Arms to forelegs: the wrists go from beside the thighs to the rock ahead. + const ax = P.sh[0] + lerp(A.arm[0], B.arm[0], fl), ay = P.sh[1] + lerp(A.arm[1], B.arm[1], fl) - 1.5 * l; + P.ar = A.ar.map((r, i) => lerp(r, B.ar[i], fl)); + for (let k = 0; k < 2; k++) { + const wa = A.wrists[k], wb = B.wrists[k]; + const tx = lerp(P.sh[0] + wa[0], wb[0], fl), ty = lerp(P.sh[1] + wa[1], wb[1], fl) + sh * 20 * (k ? -1 : 1); + reach(ax, ay, tx, ty, lerp(A.up, B.up, fl), lerp(A.fore, B.fore, fl), 1, P.arm[k]); + const hv = A.hand[k], wv = B.hand[k]; + P.hn[k] = [P.arm[k][2] + lerp(hv[0], wv[0], fl), P.arm[k][3] + lerp(hv[1], wv[1], fl)]; + } + P.root = [ax, ay]; + // Legs: the man's foot becomes the wolf's long hind foot, the heel rising to a hock. + P.lr = A.lr.map((r, i) => lerp(r, B.lr[i], hl)); + for (let k = 0; k < 2; k++) { + const ta = A.toes[k], tb = B.toes[k], fa = A.foot[k], fb = B.foot[k]; + const tx = lerp(ta[0], tb[0], hl), fx = lerp(fa[0], fb[0], hl), fy = lerp(fa[1], fb[1], hl); + P.ft[k] = [tx, G]; + reach(hx, hy + 1, tx - fx, G - fy, lerp(A.thigh, B.thigh, hl), lerp(A.shin, B.shin, hl), -1, P.leg[k]); + } + // The box the figure lies in: every joint, the muzzle's tip and the ears. + const fc = cos(P.face), fs = sin(P.face), pts = [P.hip, P.mid, P.sh, P.head, P.root, ...P.hn, ...P.ft, [P.head[0] + 10 * fc, P.head[1] + 10 * fs], [P.head[0] + 7 * fs, P.head[1] - 7 * fc]]; + for (const a of [...P.arm, ...P.leg]) pts.push([a[0], a[1]], [a[2], a[3]]); + let x0 = 99, x1 = -99, y0 = 999, y1 = -99; + for (const [x, y] of pts) (x0 = min(x0, x)), (x1 = max(x1, x)), (y0 = min(y0, y)), (y1 = max(y1, y)); + P.box = [x0 - 6, x1 + 6, y0 - 6, y1 + 3]; + // The tail grows from the rump and hangs, swaying. + const T = P.tail; + let x = hx - 2.5 * sp, y = hy - 1, a = 2.45; + T[0] = x; + T[1] = y; + for (let i = 1; i < 5; i++) { + a -= 0.12 + 0.05 * sin(S.t * 2.1 + i); + x += 4.2 * late * cos(a); + y += 4.2 * late * sin(a); + T[2 * i] = x; + T[2 * i + 1] = y; + P.box[0] = min(P.box[0], x - 4); + } +} + +// The body as a list of tapered capsules, 11 numbers each: two ends, two +// radii, how softly it joins what came before (0 a hard union), and a box +// beyond which it cannot change the field where it matters, within 3 LEDs of +// the edge. +const PART = 11, MAXP = 24; +function parts(S) { + const P = S.P, L = (S.parts = S.parts || new Float32Array(PART * MAXP)); + let n = 0; + const add = (ax, ay, bx, by, ra, rb, k) => { + const o = PART * n++, r = max(ra, rb) + 3 + k; + L.set([ax, ay, bx, by, ra, rb, k, min(ax, bx) - r, max(ax, bx) + r, min(ay, by) - r, max(ay, by) + r], o); + }; + const [rh, rm, rs] = P.rad; + add(P.hip[0], P.hip[1], P.mid[0], P.mid[1], rh, rm, 0); + add(P.mid[0], P.mid[1], P.sh[0], P.sh[1], rm, rs, 2); + if (P.chest > 0.1) add(P.sh[0] + 1.5, P.sh[1] + 2.6, P.sh[0] + 1.5, P.sh[1] + 2.6, P.chest, P.chest, 2); + add(P.sh[0], P.sh[1], P.head[0], P.head[1], P.neck[0], P.neck[1], 1.5); + for (let k = 0; k < 2; k++) { + const a = P.arm[k], r = P.ar; + add(P.root[0] - k * 1.5, P.root[1], a[0], a[1], r[0] + P.bulk * 0.8, r[1], 1.2); + add(a[0], a[1], a[2], a[3], r[1], r[2], 0); + add(a[2], a[3], P.hn[k][0], P.hn[k][1], r[2], 0.8 + 0.2 * P.late, 0); + } + for (let k = 0; k < 2; k++) { + const g = P.leg[k], r = P.lr; + add(P.hip[0] - k, P.hip[1] + 1, g[0], g[1], r[0] + P.bulk, r[1], 1.5); + add(g[0], g[1], g[2], g[3], r[1], r[2], 0); + add(g[2], g[3], P.ft[k][0], P.ft[k][1] - 0.6, r[2], 0.9, 0); + } + if (P.late > 0.02) { + const T = P.tail, f = (i) => 1.2 + 1.3 * P.late * sin((PI * (i + 0.6)) / 4.6); + for (let i = 0; i < 4; i++) add(T[2 * i], T[2 * i + 1], T[2 * i + 2], T[2 * i + 3], f(i), f(i + 1), 1); + } + S.np = n; + P.fc = cos(P.face); + P.fs = sin(P.face); + P.jc = cos(P.jaw); + P.js = sin(P.jaw); +} + +// The figure's distance field at (px, py). +function figure(S, px, py) { + const P = S.P, info = S.info, L = S.parts; + let d = 99; + for (let i = 0, o = 0; i < S.np; i++, o += PART) { + if (px < L[o + 7] || px > L[o + 8] || py < L[o + 9] || py > L[o + 10]) continue; + const c = capsule(px, py, L[o], L[o + 1], L[o + 2], L[o + 3], L[o + 4], L[o + 5]), k = L[o + 6]; + d = k > 0 && d < 50 ? smin(d, c, k) : min(d, c); + } + // The head in its own frame (u along the face, v down): the man's head and + // the wolf's head blended as distance fields, so the muzzle grows out of the + // face and the ears rise out of the skull. + const qx = px - P.head[0], qy = py - P.head[1], u = qx * P.fc + qy * P.fs, v = -qx * P.fs + qy * P.fc; + info.u = u; + info.v = v; + if (u > -9 && u < 14 && v > -12 && v < 8) { + const man = min(circle(u, v, 0, -0.3, 3.5), capsule(u, v, 2.4, 0.6, 3.9, 1.2, 1, 0.7), capsule(u, v, 0.5, 2.2, 2.6, 2.8, 1.4, 0.9)); + let wolf = smin(circle(u, v, -0.5, -0.4, 3.4), capsule(u, v, 1.5, -0.6, 9, 0.5, 2.3, 1.15), 1.2); + const ju = 1.5 + (u - 1.5) * P.jc + (v - 1.6) * P.js, jv = 1.6 - (u - 1.5) * P.js + (v - 1.6) * P.jc; + wolf = smin(wolf, capsule(ju, jv, 1.5, 1.6, 8, 1.8, 1.5, 0.75), 0.8); + // Ears grow out of the skull late in the change, laid back in the howl. + const e = P.late, back = (0.9 + 0.4 * S.lift) * e, up = 6.4 * e; + wolf = min(wolf, capsule(u, v, -2, -1.6, -2 - back, -1.6 - up, 1.5, 0.15), capsule(u, v, 0.1, -1.6, 0.1 - back, -1.6 - up, 1.5, 0.15)); + d = min(d, man + (wolf - man) * P.hd); + } + // Fur: a ragged edge that grows with the change. + if (P.fur > 0 && d > -2 && d < 2) d -= P.fur * 0.8 * max(0, sin(px * 1.9 + py * 0.8) * sin(py * 1.7 - px * 0.6) + 0.2); + return d; +} + +// Sky, moon, glow, ridge and rock, kept until Hue or Moon move. mc is 1 where +// the moon shows, for the rim light and the rings; nm is how near the moon an +// LED is (1 on it, 0 from 14 LEDs off), for lighting the clouds. +function background(S, R) { + const { base, mc, nm } = S, sky = 0.76 - 0.42 * S.hue, halo = hsv(sky, 0.85, 1); + for (let p = 0; p < N; p++) { + const x = p % W, y = floor(p / W), dx = x + 0.5 - MX, dy = y + 0.5 - MY, r = hypot(dx, dy); + const t = round(12 * cl(y / G, 0, 1) ** 1.3) / 12; + let [cr, cg, cb] = hsv(sky, 0.78, 0.05 + 0.36 * t); + // The halo: half moonlight, half the sky's own colour at full strength. + const gl = r > R ? round((0.42 * Math.exp(-(r - R) / 5) + 0.12 * Math.exp(-(r - R) / 18)) * 20) / 20 : 0; + cr += (MOON[0] + halo[0]) * gl * 0.4; + cg += (MOON[1] + halo[1]) * gl * 0.35; + cb += (MOON[2] + halo[2]) * gl * 0.35; + let moon = 0; + if (r < R) { + let e = 9; + for (const [sx, sy, rx, ry] of SEAS) e = min(e, ((dx / R - sx) / rx) ** 2 + ((dy / R - sy) / ry) ** 2 + 0.2 * sin(dx * 0.9 + dy)); + const k = e < 1 ? 0.84 : e < 1.5 ? 0.92 : 1; + [cr, cg, cb] = [MOON[0] * k, MOON[1] * k, MOON[2] * k]; + moon = 1; + } + // The far ridge across the moon's foot, then the rock, its top moonlit. + if (y + 0.5 > RIDGE[x]) { + [cr, cg, cb] = hsv(sky, 0.7, y + 0.5 - RIDGE[x] < 1.2 ? 0.2 : 0.12); + moon = 0; + } + if (y + 0.5 > CREST[x]) { + // Black rock, its top edge moonlit, split by three long cracks. + const top = y + 0.5 - CREST[x] < 1.3, dy = y + 0.5 - G; + let crack = false; + for (const c of [19, 34, 47]) if (abs(x + 0.5 - c + 0.55 * dy - 2 * sin(dy * 0.3)) < 0.5 && dy > 2 && dy < 20) crack = true; + const flat = CREST[x] - G < 1.5; + [cr, cg, cb] = top ? (flat ? [0.42, 0.37, 0.3] : [0.16, 0.13, 0.15]) : crack ? [0.09, 0.07, 0.11] : [0.025, 0.015, 0.04]; + moon = 0; + } + mc[p] = moon; + nm[p] = moon ? 1 : y + 0.5 > RIDGE[x] ? 0 : round(cl(1 - (r - R) / 14, 0, 1) * 4) / 4; + base[3 * p] = cr; + base[3 * p + 1] = cg; + base[3 * p + 2] = cb; + } +} + +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 = cl(k[0], 0, 1); + S.speed = cl(k[1], 0.2, 3); + S.clouds = cl(k[2], 0, 1); + S.moon = cl(k[3], -1, 1); + const ds = (d * S.speed) / 2; + S.t += ds; + story(S); + pose(S); + parts(S); + // Rings leave the nose every 0.3 s while it holds the note, and spread. + if (S.howling && (S.ringClock -= ds) <= 0) { + S.ringClock = 0.3; + const P = S.P, ca = cos(P.face), sa = sin(P.face); + S.rings.push({ x: P.head[0] + 9.5 * ca - 0.5 * sa, y: P.head[1] + 9.5 * sa + 0.5 * ca, a: P.face, r: 1 }); + } + for (const r of S.rings) r.r += 20 * ds; + S.rings = S.rings.filter((r) => r.r < 56); +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.P) return; + const R = 27 + 5 * S.moon, key = `${S.hue.toFixed(3)} ${R.toFixed(2)}`; + if (key !== S.key) { + S.key = key; + background(S, R); + } + const { base, mc, nm, buf, sil } = S, sky = 0.76 - 0.42 * S.hue; + buf.set(base); + // Stars twinkle in the high sky. + for (const [p, ph] of STARS) { + const v = 0.35 + 0.3 * sin(S.t * 2.3 + ph); + if (buf[3 * p + 2] < 0.3) buf.fill(v, 3 * p, 3 * p + 3); + } + // Clouds: dark bodies that let some of the moon through, their edges lit + // where they cross it or pass near it. + const nb = 4 + round(3 * S.clouds), nw = 2 + round(4 * S.clouds), op = 0.55 + 0.37 * S.clouds; + const body = hsv(sky, 0.35, 0.27), lit = [0.72, 0.66, 0.54]; + const cloud = (C, cx, cy, a, th) => { + if (!C.mask || C.mask.th !== th) shape(C, th); + const { ox, oy, w, h, m } = C.mask, x0 = round(cx) + ox, y0 = round(cy) + oy; + if (x0 >= W || x0 + w <= 0) return; + for (let j = 0; j < h; j++) { + const y = y0 + j; + if (y < 0 || y > G) continue; + for (let i = 0; i < w; i++) { + const x = x0 + i, k = m[j * w + i]; + if (!k || x < 0 || x >= W) continue; + const p = y * W + x, o = 3 * p, near = nm[p], rim = k & 4 ? 0.25 + 0.6 * near : 0, al = k & 1 ? a * 0.75 : a; + for (let c = 0; c < 3; c++) buf[o + c] += (body[c] + 0.12 * near + (lit[c] - body[c]) * rim - buf[o + c]) * al; + } + } + }; + const tb = round((0.8 + 0.45 * S.clouds) * 20) / 20, tw = round((0.7 + 1.1 * S.clouds) * 20) / 20; + for (let i = 0; i < nb; i++) cloud(BANK[i], MX + BANK[i][0] + S.bankX, MY + BANK[i][1], op, tb); + for (let i = 0; i < nw; i++) { + const C = WISPS[i], span = W + 2 * C[2] + 24, x = (((C[1] * span - S.t * (2.5 + i * 0.4)) % span) + span) % span - C[2] - 12; + cloud(C, x, C[0], op * 0.8, tw); + } + // Rings of the howl: arcs about the muzzle's line, pale on the sky and a + // cool shadow on the moon, fading as they spread. + const ra = S.ra, hit = []; + for (const g of S.rings) { + const fade = 1 - g.r / 56; + for (const rad of [g.r - 0.4, g.r + 0.4]) { + const n = ceil(3 * rad) + 2; + for (let i = 0; i < n; i++) { + const th = -0.75 + (1.5 * i) / (n - 1), x = floor(g.x + rad * cos(g.a + th)), y = floor(g.y + rad * sin(g.a + th)); + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const p = y * W + x, a = fade * (1 - abs(th) / 0.75) * 0.9; + if (!ra[p]) hit.push(p); + ra[p] = max(ra[p], a); + } + } + } + for (const p of hit) { + const o = 3 * p, a = ra[p], on = mc[p] > 0; + buf[o] += ((on ? 0.42 : 0.6) - buf[o]) * a; + buf[o + 1] += ((on ? 0.66 : 1) - buf[o + 1]) * a; + buf[o + 2] += ((on ? 0.5 : 0.7) - buf[o + 2]) * a; + ra[p] = 0; + } + // The figure, black. Its edge takes a rim of moonlight one LED wide, but + // only where the figure is thicker than that, so a thin leg stays black. + const P = S.P, bx0 = max(1, floor(P.box[0])), bx1 = min(W - 1, ceil(P.box[1])), by0 = max(1, floor(P.box[2])), by1 = min(G + 1, ceil(P.box[3])); + let eyeP = -1, eyeU = 9; + sil.fill(1); + for (let y = by0; y < by1; y++) + for (let x = bx0; x < bx1; x++) { + const p = y * W + x, dd = (sil[p] = figure(S, x + 0.5, y + 0.5)); + // The eye: the LED nearest its place in the head's frame. + const eu = S.info.u - 1.3, ev = S.info.v + 1.1, e2 = eu * eu + ev * ev; + if (dd < -0.6 && e2 < eyeU) (eyeU = e2), (eyeP = p); + } + for (let y = by0; y < by1; y++) + for (let x = bx0; x < bx1; x++) { + const p = y * W + x, dd = sil[p]; + if (dd >= 0) continue; + const deep = min(sil[p - 1], sil[p + 1], sil[p - W], sil[p + W]) < -1.3; + const o = 3 * p, rim = dd > -1 && deep ? (mc[p] > 0 ? 0.42 : 0.3) : 0; + buf[o] = 0.02 + MOON[0] * rim; + buf[o + 1] = 0.012 + MOON[1] * rim; + buf[o + 2] = 0.03 + MOON[2] * rim; + } + // The eye kindles amber as the face changes. + if (eyeP >= 0 && P.hd > 0.15) { + const e = ease((P.hd - 0.15) / 0.4); + buf[3 * eyeP] = e; + buf[3 * eyeP + 1] = 0.72 * e; + buf[3 * eyeP + 2] = 0.1 * e; + } + for (let y = 0, p = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) display.setPixel(x, y, q4(buf[3 * p]), q4(buf[3 * p + 1]), q4(buf[3 * p + 2])); +} From 13bfd498090c4e30bb7ee9f4d83c1ef38b8e9946 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:12:01 +0000 Subject: [PATCH 5/9] patterns: add Curlicue and Music Box Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017mBLLjEyWbBi1Ag71cFsLX --- incoming/pattern-study/patterns/curlicue.js | 255 ++++++++++ incoming/pattern-study/patterns/music-box.js | 484 +++++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 incoming/pattern-study/patterns/curlicue.js create mode 100644 incoming/pattern-study/patterns/music-box.js diff --git a/incoming/pattern-study/patterns/curlicue.js b/incoming/pattern-study/patterns/curlicue.js new file mode 100644 index 000000000..6c4a385de --- /dev/null +++ b/incoming/pattern-study/patterns/curlicue.js @@ -0,0 +1,255 @@ +// ===== Patternflow pattern ===== +// Title: Curlicue +// Family: number +// Idea: The partial sums of the theta sum over -N <= n <= N, one unit step per n with step n turned by pi n^2 x, grow out from the middle of the panel in both directions into a lace of separate LEDs while the view zooms out for ever, and every level of the zoom brings the same curls back, because N steps of x trace the shape of N x steps of -1/x, turned 45 degrees and drawn 1/sqrt(x) times larger. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: curlicues and their renormalisation (Michael Berry and Jonathan Goldberg, 1988), approximate functional equation of the theta sum (G. H. Hardy and J. E. Littlewood, 1914), pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Number=0..1, Steps=0..1 +// +// Knob 1: Hue. Turns the colours of the path round the wheel. At 0 the oldest steps, in the middle, are deep blue, turning to cyan toward the two ends, where the newest steps are white; at 0.5 the old steps are yellow and the new ones red. +// Knob 2: Speed. How fast the view zooms out. At 2 the picture shrinks to half its size every 4 seconds while the two ends add steps to keep the panel filled, and the first growth from one step to the full lace takes 1.2 seconds; at 0.2 ten times slower, at 3 half as fast again. +// Knob 3: Number. Which x the sum is for, in five steps: at 0 the square root of 3, at 0.25 the golden ratio, at 0.5 the square root of 2, at 0.75 sqrt 26 - 5 = 1/(10 + 1/(10 + ...)), at 1 sqrt 101 - 10 = 1/(20 + 1/(20 + ...)). The first three grow a lace with hooked arms; the last two sit close to 1/10 and 1/20, so their paths run straight between curls and the curls read as Euler spirals. Each chain of renormalised numbers comes round after one or two levels, so the zoom never ends and never changes its kind. +// Knob 4: Steps. How many steps each half of the path holds when a level begins. At 0.06 about 2,000, so the path is a lace of single LEDs; at 1 about 120, each step a dotted stroke several LEDs long, so the curls read as lines. + +const W = 64, H = 128; +const { floor, round, sqrt, sin, cos, log, exp, atan2, hypot, min, max, abs, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The box the path is fitted into, and the most LEDs one step may span. +const BW = 62, BH = 126, SMAX = 9; +// Story seconds at Speed 2: the size halves every ZOOM seconds; the first +// growth from 1 step to the level's start takes INTRO seconds. +const ZOOM = 4, INTRO = 1.2; +// Most partial sums a level may hold. +const CAP = 100000; + +// The five numbers on the knob, each a quadratic irrational, so its chain +// of renormalised numbers comes round again after one or two levels: +// the square root of 3 (2 - sqrt 3 = 0.268, which renormalises to itself), +// the golden ratio (0.382 and 0.618 in turn), the square root of 2 (0.586 +// and 0.293), sqrt 26 - 5 = 1/(10 + 1/(10 + ...)) and sqrt 101 - 10 = +// 1/(20 + 1/(20 + ...)), each its own mirror image one level down. The last +// two sit close to 1/10 and 1/20, so their paths run straight for a while +// between curls. +const STOPS = [sqrt(3), (1 + sqrt(5)) / 2, sqrt(2), sqrt(26) - 5, sqrt(101) - 10]; +const mod2 = (v) => v - 2 * floor(v / 2); + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), u = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? u : p, i === 1 || i === 2 ? v : i === 0 ? u : i === 3 ? q : p, i === 3 || i === 4 ? v : i === 2 ? u : i === 5 ? q : p]; +} +// Colour bands by age, from the middle out; the last one is the white ends. +const BANDS = 10; + +// Start the chain at the knob's number: x mod 2, folded into (0, 1]. A +// number above 1 is drawn as the mirror image of 2 - x, the same sum conjugated. +function begin(S, num) { + let x = mod2(STOPS[round(num * 4)]); + S.mirror = x > 1; + if (S.mirror) x = 2 - x; + S.xh = x; + S.orbit = [x]; + S.filled = 0; + S.num = num; +} + +// The partial sums S_k = sum of e^(i pi n^2 x) over 0 <= n < k, added as the +// ends reach them. The path over -N..N is S_k - 1/2 and its negative. +function fill(S, n) { + const re = S.re, im = S.im, x = S.xh; + n = min(n, CAP); + if (!S.filled) (re[0] = 0), (im[0] = 0); + let a = re[S.filled], b = im[S.filled]; + for (let k = S.filled; k < n; k++) { + const ph = PI * ((k * k * x) % 2); + a += cos(ph); + b += sin(ph); + re[k + 1] = a; + im[k + 1] = b; + } + S.filled = max(S.filled, n); +} + +// One level deeper. Hardy and Littlewood: for 0 < x <= 1, +// S_n(x) = e^(i pi/4) x^(-1/2) S_(floor(n x) + 1)(-1/x) + O(x^(-1/2)), +// and -1/x mod 2 folds into (0, 1] as y or as the mirror of 2 - y. So the n +// steps on the panel are swapped for floor(n x) + 1 steps of the new number, +// and the view is carried through the same turn and scale, so nothing moves. +function deeper(S) { + const x = S.xh, N = S.N; + let y = mod2(-1 / x), flip = false; + if (y > 1) (y = 2 - y), (flip = true); + // A number that has come round before is set back to its first value, so + // rounding does not build up level after level and the chain repeats exactly. + for (const o of S.orbit) if (abs(y - o) < 1e-9) y = o; + if (!S.orbit.includes(y)) S.orbit.push(y); + const oldRe = S.re, oldIm = S.im, n0 = min(floor(N), S.filled); + S.re = S.re2; + S.im = S.im2; + S.re2 = oldRe; + S.im2 = oldIm; + S.xh = y; + S.filled = 0; + fill(S, floor(N * x) + 2); + // The check: how far the old half path lies from the new one carried + // through the similarity, after the mean offset, against the path's size. + const ar = cos(PI / 4) / sqrt(x), ai = sin(PI / 4) / sqrt(x), sg = flip ? -1 : 1, stride = max(1, floor(n0 / 1500)); + let dr = 0, di = 0, c = 0, gap = 0, size = 1; + for (let pass = 0; pass < 2; pass++) + for (let n = 1; n <= n0; n += stride) { + const m = floor(n * x) + 1, qr = S.re[m] - 0.5, qi = sg * S.im[m]; + const u = oldRe[n] - 0.5 - (ar * qr - ai * qi), v = oldIm[n] - (ai * qr + ar * qi); + if (pass === 0) (dr += u), (di += v), c++; + else (gap = max(gap, hypot(u - dr, v - di))), (size = max(size, hypot(oldRe[n] - 0.5, oldIm[n]))); + if (pass === 0 && n + stride > n0) (dr /= c), (di /= c); + } + S.gap = max(S.gap, gap / size); + S.offset = max(S.offset, hypot(dr, di)); + S.levels++; + // Carry the view: screen = alpha w + centre, w the half path or its mirror. + const Ai = S.mirror ? -ai : ai; + for (const v of [S.view, S.goal]) [v.ar, v.ai] = [v.ar * ar - v.ai * Ai, v.ar * Ai + v.ai * ar]; + S.mirror = S.mirror !== flip; + S.N = N * x; + S.L = log(S.N); +} + +// Where the view wants to be: the long axis upright, the whole path in the +// box. The path is symmetric about the middle of step 0, so that point is +// always the middle of the panel and a half turn changes nothing. +function aim(S) { + const n = min(floor(S.N), S.filled), re = S.re, im = S.im, sg = S.mirror ? -1 : 1, G = S.goal; + const stride = max(1, floor(n / 3000)); + let sxx = 0, syy = 0, sxy = 0; + for (let i = 1; i <= n; i += stride) { + const u = re[i] - 0.5, v = sg * im[i]; + sxx += u * u; + syy += v * v; + sxy += u * v; + } + let ang = atan2(G.ai, G.ar); + const spread = sxx + syy, aniso = spread > 0 ? hypot(sxx - syy, 2 * sxy) / spread : 0; + if (aniso > 0.12 || n < 40) { + const a = PI / 2 - 0.5 * atan2(2 * sxy, sxx - syy), da = a - ang; + ang += da - PI * round(da / PI); + } + const ca = cos(ang), sa = sin(ang); + let mu = 1e-6, mv = 1e-6; + for (let i = 1; i <= n; i++) { + const u = re[i] - 0.5, v = sg * im[i]; + mu = max(mu, abs(ca * u - sa * v)); + mv = max(mv, abs(sa * u + ca * v)); + } + const s = min(BW / (2 * mu), BH / (2 * mv), SMAX); + G.ar = s * ca; + G.ai = s * sa; +} + +export function setup(params) { + const S = { + re: new Float64Array(CAP + 2), im: new Float64Array(CAP + 2), re2: new Float64Array(CAP + 2), im2: new Float64Array(CAP + 2), + view: { ar: SMAX - 0.5, ai: 0.5 }, goal: { ar: SMAX - 0.5, ai: 0.5 }, + hue: 0.5, speed: 1.5, n1: 2000.5, N: 1, L: 0, levels: 0, gap: 0, offset: 0, xh: 0.5, num: 0.5, mirror: false, filled: 0, orbit: [], + pal: new Uint8Array((BANDS + 2) * 3), idx: new Uint8Array(W * H), palHue: -1, + }; + begin(S, 0); + params.s = 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.speed = cl(k[1], 0.2, 3); + // 2,400 steps a half at 0, about 2,000 at 0.06, 120 at 1. + S.n1 = 2400 * 0.05 ** cl(k[3], 0, 1); + const num = cl(k[2], 0, 1); + // A new number: the same number of steps of it, from its first level. + if (abs(num - S.num) > 1e-9) begin(S, num); + // Below the level's start the path grows fast, from 1 step to n1 in + // INTRO seconds; from there the zoom runs at its steady pace. + const g = (d * S.speed) / 2, ln1 = log(S.n1); + S.L = S.L < ln1 ? min(ln1, S.L + (g * ln1) / INTRO) : S.L + (g * 2 * Math.LN2) / ZOOM; + S.N = exp(S.L); + // A chain that has not come round in 16 levels starts over. + if (S.orbit.length > 16) begin(S, S.num); + for (let guard = 0; guard < 8 && S.N >= min(S.n1 / S.xh, CAP); guard++) deeper(S); + fill(S, floor(S.N) + 1); + aim(S); + // Ease the view toward its aim in angle and size; shrink fast, so the + // ends never leave the box, and grow slowly. + const V = S.view, G = S.goal; + const la = log(hypot(V.ar, V.ai)), lg = log(hypot(G.ar, G.ai)), es = 1 - exp(-d / (lg < la ? 0.05 : 0.5)); + const aa = atan2(V.ai, V.ar), ag = atan2(G.ai, G.ar), da = ag - aa, ea = 1 - exp(-d / 0.6); + const a = aa + (da - PI * round(da / PI)) * ea, ca = cos(a), sa = sin(a); + // While the angle is still turning, the size the aim chose may not fit + // the angle the view stands at: shrink to fit that too. + const n = min(floor(S.N), S.filled), sg = S.mirror ? -1 : 1; + let mu = 1e-6, mv = 1e-6; + for (let i = 1; i <= n; i++) { + const u = S.re[i] - 0.5, v = sg * S.im[i]; + mu = max(mu, abs(ca * u - sa * v)); + mv = max(mv, abs(sa * u + ca * v)); + } + const r = min(exp(la + (lg - la) * es), BW / (2 * mu), BH / (2 * mv)); + V.ar = r * ca; + V.ai = r * sa; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const pal = S.pal, idx = S.idx; + if (S.palHue !== S.hue) { + S.palHue = S.hue; + // Deep blue for the oldest steps, turning to cyan for the newest. Every + // colour has a channel at 255, so the old blue reads dim on the panel + // while each LED of the lace stands at full level. + for (let b = 0; b < BANDS; b++) { + const q = b / (BANDS - 1), c = hsv(0.66 - 0.18 * q + S.hue, 1 - 0.25 * q, 1); + for (let j = 0; j < 3; j++) pal[(b + 1) * 3 + j] = round(255 * c[j]); + } + for (let j = 0; j < 3; j++) pal[(BANDS + 1) * 3 + j] = 255; + } + idx.fill(0); + const n = min(floor(S.N), S.filled), re = S.re, im = S.im, sg = S.mirror ? -1 : 1; + const ar = S.view.ar, ai = S.view.ai, step = hypot(ar, ai), head = n - max(2, floor(n * 0.012)); + const put = (X, Y, c) => { + // The nearest LED of one checkerboard colour, so lit LEDs never touch + // side to side and each step stands as its own point. + const a = round((X + Y - 1) / 2), b = round((X - Y) / 2), u = a + b, v = a - b; + if (u >= 0 && u < W && v >= 0 && v < H) idx[v * W + u] = c; + // The newer half of the path is drawn with a nib two LEDs long on the + // diagonal, so the young curls stand bolder than the old lace inside. + if (c > BANDS / 2 && u + 1 < W && v + 1 < H && u >= -1 && v >= -1) idx[(v + 1) * W + u + 1] = c; + }; + let px = -ar * 0.5, py = -ai * 0.5; + for (let i = 1; i <= n; i++) { + const u = re[i] - 0.5, v = sg * im[i], X = ar * u - ai * v, Y = ai * u + ar * v; + const c = i > head ? BANDS + 1 : 1 + floor((i / (n + 1)) * BANDS); + // Long steps, while the path is young or the Steps knob is high, are + // drawn as dotted strokes. + if (step > 1.6) { + const k = Math.ceil(step / 0.8); + for (let j = 1; j < k; j++) { + const sx = px + ((X - px) * j) / k, sy = py + ((Y - py) * j) / k; + put(32 + sx, 64 + sy, c); + put(32 - sx, 64 - sy, c); + } + } + put(32 + X, 64 + Y, c); + put(32 - X, 64 - Y, c); + px = X; + py = Y; + } + for (let i = 0; i < W * H; i++) { + const o = idx[i] * 3; + display.setPixel(i % W, floor(i / W), pal[o], pal[o + 1], pal[o + 2]); + } +} diff --git a/incoming/pattern-study/patterns/music-box.js b/incoming/pattern-study/patterns/music-box.js new file mode 100644 index 000000000..b8b9c7275 --- /dev/null +++ b/incoming/pattern-study/patterns/music-box.js @@ -0,0 +1,484 @@ +// ===== Patternflow pattern ===== +// Title: Music Box +// Family: halloween +// Idea: A haunted music box on a velvet shelf: its brass cylinder turns and each pin plucks a steel tooth that flashes and rings, a skeleton ballerina in a tutu pirouettes on the spindle under a cone of moonlight, then the spring runs down, the melody drags to a stop with her facing us, a cloud covers the moon and her eye sockets glow green, until an unseen hand spins the key and it plays again. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Velvet=0..1, Speed=0.2..3, Tune=0..1, Wind=0..1 +// +// Knob 1: Velvet. The colour of the velvet on the box, the lining behind the cylinder and the shelf, through the cold hues only so it never turns brown: purple at 0, blue at 0.33, teal at 0.67, poison green at 1. The brass, the steel, the bones and the moonlight stay as they are. +// Knob 2: Speed. At 2 the waltz plays a beat every 0.42 seconds and the ballerina turns once a bar; at Wind 0.06 a wind plays 4.8 seconds at full tempo and then runs down over 1.2 to 4.2, as long as it takes her to stop facing us; the eyes glow for 2.2 and the key spins for 1.5. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Tune. Which of three A minor melodies is on the box, each on its own cylinder with its own dance: below 1/3 a waltz with an oom-pah-pah bass on brass, the ballerina in lilac with her arms rounded overhead; from 1/3 a slow lullaby on nickel silver, a pale blue tutu and the arms open wide; from 2/3 a brisk minuet on blackened steel, a black tutu edged in orange and the arms low in front. She turns once a bar of each tune's tempo. +// Knob 4: Wind. How far the key is wound, so how long the box plays at full tempo before it runs down: 0.8 seconds at 0, 4.8 at 0.06, 9 at 0.25, 17.2 at 1. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, round, sin, cos, sqrt, atan2, exp, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const smooth = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t)); + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p, i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p, i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p]; +} + +// The box, front on: its top from row 60 to the brass front edge at 66, +// the velvet front to 115, the shelf below. A glass window shows the +// cylinder (rows 71 to 87) and the comb whose 13 teeth hang from row 88. +const TOP = 60, RIM = 66, BOT = 115, BX0 = 2, BX1 = 57; +const WX0 = 6, WX1 = 53, WY0 = 69, WY1 = 106; +const CX0 = 9, CX1 = 50, CY = 79, CR = 8; +const TEETH = 13, TX = 11, TIP = 88, PLATE = 103; +const toothLen = (i) => round(15 - 0.55 * i); +// The spindle and the ballerina's toe, the key on the box's right side. +const SPX = 30, TOE = 58, KX = 60, KY = 92; +// The run-down's share of a wind, the glow and the winding, in seconds. +const SLOW = 1.8, GLOW = 2.2, WIND = 1.5; +// The cloud's puffs: offset across, offset down, radius. +const PUFFS = [[-13, 4, 4.5], [-7, 2, 6.5], [0, 0, 8], [7.5, 2, 6.5], [13, 5, 4.5]], CLOUD = [0.13, 0.1, 0.2], CLOUD_RIM = [0.36, 0.33, 0.5]; +// A cylinder for each tune, five shades and its pins: brass with steel pins, +// nickel silver with brass ones, blackened steel with silver ones. +const METALS = [ + [[[0.76, 0.48, 0.08], [0.88, 0.6, 0.12], [0.96, 0.72, 0.2], [1, 0.84, 0.36], [1, 0.93, 0.62]], [1, 0.97, 0.86]], + [[[0.42, 0.45, 0.52], [0.58, 0.62, 0.7], [0.72, 0.76, 0.84], [0.86, 0.9, 0.96], [0.97, 0.98, 1]], [1, 0.78, 0.25]], + [[[0.1, 0.1, 0.14], [0.17, 0.17, 0.23], [0.25, 0.26, 0.33], [0.36, 0.37, 0.46], [0.55, 0.56, 0.66]], [0.95, 0.96, 1]], +]; +// And a tutu for each: lilac tulle, pale ghostly blue, black edged in orange. +const TUTUS = [ + [[0.55, 0.4, 0.7], [0.78, 0.62, 0.9], [0.95, 0.85, 1]], + [[0.5, 0.62, 0.75], [0.72, 0.84, 0.95], [0.93, 0.98, 1]], + [[0.1, 0.06, 0.12], [0.24, 0.16, 0.28], [1, 0.55, 0.1]], +]; + +// The comb, low to high: A3 B3 C4 D4 E4 F4 G#4 A4 B4 C5 D5 E5 F5, A minor +// with the raised seventh. Each tune is 8 bars of 3 beats, one turn of the +// cylinder, written as [beat, tooth] pins, and its beat in story seconds. +const TUNES = [ + // Waltz: bass on the bar, a chord tone on beats 2 and 3, the tune on top. + { + beat: 0.42, + bass: [[0, 4, 4], [1, 3, 3], [1, 3, 3], [0, 4, 4], [3, 5, 5], [0, 4, 4], [1, 3, 3], [0, 4, 4]], + tune: [[0, 11], [2, 9], [3, 8], [4, 9], [5, 8], [6, 6], [8, 4], [9, 7], [12, 12], [14, 10], [15, 9], [16, 10], [17, 9], [18, 8], [19, 6], [20, 8], [21, 7]], + }, + // Lullaby: slow, the bass and one tone a bar, the tune in long notes. + { + beat: 0.55, + bass: [[0, 2, -1], [1, 3, -1], [0, 4, -1], [3, 5, -1], [3, 5, -1], [0, 2, -1], [1, 3, -1], [0, 4, -1]], + tune: [[0, 7], [2, 9], [3, 8], [5, 6], [6, 7], [8, 4], [9, 5], [12, 10], [14, 12], [15, 11], [17, 9], [18, 8], [20, 6], [21, 7]], + }, + // Minuet: brisk, a note on every beat. + { + beat: 0.32, + bass: [[0, -1, -1], [3, -1, -1], [0, -1, -1], [1, -1, -1], [3, -1, -1], [0, -1, -1], [1, -1, -1], [0, -1, -1]], + tune: [7, 9, 11, 10, 9, 8, 9, 7, 4, 6, 8, 11, 12, 11, 10, 9, 8, 7, 8, 6, 4, 7, -1, 11].map((t, b) => [b, t]).filter((q) => q[1] >= 0), + }, +]; +// Every pin of a tune, sorted by beat. +const PINS = TUNES.map((T) => { + const p = T.tune.slice(); + T.bass.forEach(([b, c1, c2], bar) => { + p.push([bar * 3, b]); + if (c1 >= 0) p.push([bar * 3 + 1, c1]); + if (c2 >= 0) p.push([bar * 3 + 2, c2]); + }); + return p.sort((a, b) => a[0] - b[0] || a[1] - b[1]); +}); + +// The ballerina in her own frame, y up from the toe, z toward us when she +// faces us: standing leg en pointe, the other drawn up in retire with its +// knee turned out, arms rounded overhead. One unit is SC LEDs. +const J = { + toe: [0, 0, 0], ank: [-0.1, 2.5, 0], kn: [-0.5, 9.5, 0], hipS: [-1.2, 16.6, 0], + hipW: [1.2, 16.6, 0], knW: [6.8, 12.6, 1.4], ftW: [-0.1, 10.2, 1.1], + waist: [0, 18.2, 0], chest: [0, 26, 0], neck: [0, 29.4, 0], head: [0, 33, 0.2], + shL: [-3.6, 27, 0], shR: [3.6, 27, 0], elL: [-6.4, 31.8, 0.9], elR: [6.4, 31.8, 0.9], haL: [-1.4, 38.2, 1.1], haR: [1.4, 38.2, 1.1], +}; +// The other two dances: the lullaby's attitude, the leg lifted behind and +// the arms open to the sides; the minuet's arms rounded low in front. +const POSES = [ + {}, + { knW: [2.4, 16.4, -7.2], ftW: [3.6, 20.6, -11.4], elL: [-8.2, 28.4, 0.6], elR: [8.2, 28.4, 0.6], haL: [-12.6, 29.8, 0], haR: [12.6, 29.8, 0] }, + { elL: [-5.6, 23.6, 2.6], elR: [5.6, 23.6, 2.6], haL: [-1.6, 21.6, 4.6], haR: [1.6, 21.6, 4.6] }, +]; +const SC = 1.3, ELEV = 0.28, CE = cos(ELEV), SE = sin(ELEV), HEAD_R = 3.5, TUTU_Y = 17.6, TUTU_R = 9.5; +const RIBS = [[20.8, 2.3], [22.4, 2.9], [24, 3.1], [25.6, 2.9]]; + +// The cloud's shape, 40 by 12 about its middle: 1 inside a puff, 2 on a +// lower rim not covered by another puff. +function cloudMask() { + const m = new Uint8Array(40 * 12); + for (let y = 0; y < 12; y++) + for (let j = 0; j < 40; j++) { + let inside = false, deep = false; + for (const [dx, dy, r] of PUFFS) { + const d = Math.hypot(j + 0.5 - 20 - dx, y + 0.5 - dy); + if (d < r) (inside = true), (deep = deep || d < r - 1.2); + } + if (inside) m[y * 40 + j] = !deep && y > 3 ? 2 : 1; + } + return m; +} + +// The tutu's LEDs: for each, where it meets each of the three layers of the +// disc, as a radius and an angle in the room; turning her only adds phi. +function tutuMap() { + const out = [], yc = floor(TOE + 0.5 - SC * TUTU_Y * CE), RM = TUTU_R + 0.7; + for (let y = yc - 6; y <= yc + 6; y++) + for (let x = SPX - 14; x <= SPX + 15; x++) { + const xr = (x + 0.5 - SPX - 0.5) / SC, layers = []; + for (const h of [TUTU_Y - 0.7, TUTU_Y, TUTU_Y + 0.7]) { + const zr = ((y + 0.5 - TOE - 0.5) / SC + h * CE) / SE, r = Math.hypot(xr, zr); + if (r < RM) layers.push(r, atan2(zr, xr)); + } + if (layers.length) out.push([x, y, layers]); + } + return out; +} + +export function setup(params) { + const motes = []; + for (let i = 0; i < 18; i++) motes.push([Math.random(), Math.random() * 58, 1.5 + 2.5 * Math.random(), Math.random() * TAU]); + params.s = { + rgb: new Float32Array(N * 3), cloudMask: cloudMask(), tutu: tutuMap(), motes, started: false, + hue: 0.5, speed: 1.5, tuneI: 0, wind: 0.06, t: 0.5, + phase: "play", ph: 0.5, tau: 4.5, beat: 0.5, phi: 0.5, phiA: 0.5, D: 1.5, + key: 0.5, amp: 0.5, cloud: 0.5, ring: new Float32Array(TEETH), wave: new Float32Array(TEETH), spark: new Float32Array(TEETH), eyes: 0.5, + }; +} + +// Seconds of play a wind gives, the run-down included. +const runFor = (w) => 2.6 + 16.4 * sqrt(w); +// Full tempo while playing; in the run-down it falls as (left / D)^0.8. +const tempo = (S) => (S.phase === "play" ? 1 : S.phase === "slow" ? (max(0, S.D - S.ph) / S.D) ** 0.8 : 0); + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.tuneI = min(2, floor(cl(k[2], 0, 1) * 3)); + S.wind = cl(k[3], 0, 1); + const T = TUNES[S.tuneI], pins = PINS[S.tuneI], g = (d * S.speed) / 2; + if (!S.started) { + // The first pin plucks a quarter second in, the ballerina already turning. + S.started = true; + S.tau = runFor(S.wind) - SLOW; + S.beat = 24 - 0.25 / T.beat; + S.phi = 0; + S.ph = 0; + S.t = 0; + } + S.t += g; + S.ph += g; + const f = tempo(S), om = TAU / (3 * T.beat); + if (S.phase === "play" || S.phase === "slow") { + // The cylinder: beats go by at the tempo, and every pin passed plucks. + const b0 = S.beat, b1 = b0 + (g * f) / T.beat; + for (const [b, tooth] of pins) { + const at = b < b0 ? b + 24 : b; + if (at > b0 && at <= b1) (S.ring[tooth] = 1), (S.spark[tooth] = 1); + } + S.beat = b1 >= 24 ? b1 - 24 : b1; + S.key += 0.6 * f * g; + } + if (S.phase === "play") { + S.phi += om * g; + S.tau -= g; + if (S.tau <= 0) { + // The run-down: she turns om D / 1.8 more as the tempo falls to 0 over + // D seconds, so D is chosen to end that on a whole turn, facing us, + // and at least 1.2 seconds long. + const k = Math.ceil((S.phi + (1.2 * om) / 1.8) / TAU); + (S.phase = "slow"), (S.ph = 0), (S.phiA = S.phi), (S.D = (1.8 * (TAU * k - S.phi)) / om); + } + } else if (S.phase === "slow") { + const u = min(S.ph, S.D); + S.phi = S.phiA + ((om * S.D) / 1.8) * (1 - ((S.D - u) / S.D) ** 1.8); + if (S.ph >= S.D) (S.phi = 0), (S.phase = "glow"), (S.ph = 0); + } else if (S.phase === "glow" && S.ph >= GLOW) (S.phase = "wind"), (S.ph = 0); + else if (S.phase === "wind") { + // Two and a half turns of the key, eased in and out. + S.key += ((2.5 * TAU) / WIND) * 2 * (1 - abs(2 * (S.ph / WIND) - 1)) * g; + if (S.ph >= WIND) (S.phase = "play"), (S.ph = 0), (S.tau = runFor(S.wind) - SLOW); + } + // Teeth ring and fade; the ripple along a tooth runs at its pitch, and + // drags lower as the tempo falls, so the box goes flat as it stops. + for (let i = 0; i < TEETH; i++) { + S.ring[i] *= exp(-g / 0.5); + S.spark[i] *= exp(-g / 0.07); + S.wave[i] += g * (14 + 2.2 * i) * (0.35 + 0.65 * f); + } + // The moonlight swells a little with each note. + let amp = 0; + for (let i = 0; i < TEETH; i++) amp = max(amp, S.ring[i]); + S.amp = amp * f; + const eyeT = S.phase === "glow" ? smooth(S.ph / 0.6) : S.phase === "wind" ? 1 - smooth(S.ph / 0.7) : 0; + S.eyes = eyeT * (0.82 + 0.18 * sin(S.t * 23) * sin(S.t * 7.7)); + // A cloud over the moon: it slides off as the box first plays, drifts back + // as the spring runs down, covers the moon while the eyes glow and slides + // off again while the key turns. + const run = S.phase === "play" || S.phase === "slow" ? 1 - f : S.phase === "glow" ? 1 : 1 - smooth(S.ph / WIND); + S.cloud = max(run, 1 - smooth(S.t / 1.2)); +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.started) return; + const rgb = S.rgb; + const put = (x, y, c, a = 1) => { + x = floor(x); + y = floor(y); + if (x < 0 || x >= W || y < 0 || y >= H) return; + const o = (y * W + x) * 3; + if (a >= 1) (rgb[o] = c[0]), (rgb[o + 1] = c[1]), (rgb[o + 2] = c[2]); + else (rgb[o] = mix(rgb[o], c[0], a)), (rgb[o + 1] = mix(rgb[o + 1], c[1], a)), (rgb[o + 2] = mix(rgb[o + 2], c[2], a)); + }; + const line = (x0, y0, x1, y1, c) => { + const n = max(1, Math.ceil(max(abs(x1 - x0), abs(y1 - y0)))); + for (let j = 0; j <= n; j++) put(x0 + ((x1 - x0) * j) / n, y0 + ((y1 - y0) * j) / n, c); + }; + // The moonlight: a quarter of itself behind the cloud, swelling a little + // on each note while the moon is clear. + const cv = S.cloud, dim = 1 - 0.75 * cv + 0.15 * S.amp * (1 - cv); + // The velvet's shades this frame: the front's sheen, the moonlit top, the + // shelf and the lining. + const vel = (v, sat = 0.78) => hsv(0.78 - 0.45 * S.hue, sat, v); + const sheen = [0, 1, 2, 3].map((k) => vel(0.42 + (0.16 * k) / 3)); + const topC = [vel(0.82 * dim, 0.55), vel(0.62 * dim), vel(0.45)], shelfC = [vel(0.3, 0.7), vel(0.5, 0.7), vel(0.22, 0.7)], lining = vel(0.16, 0.85); + + // A run of one colour along a row, written straight into the frame. + const span = (y, x0, x1, c) => { + x0 = max(0, x0); + x1 = min(W - 1, x1); + for (let o = (y * W + x0) * 3, e = (y * W + x1) * 3; o <= e; o += 3) (rgb[o] = c[0]), (rgb[o + 1] = c[1]), (rgb[o + 2] = c[2]); + }; + // The room: near-black violet, the moon peeking in at the top and its + // cone in three bands. + const room = [0.035, 0.012, 0.06], cone = [0.17, 0.25, 0.34].map((a) => [0.55 * a * dim + 0.03, 0.55 * a * dim + 0.01, 0.85 * a * dim + 0.05]); + for (let y = 0; y < H; y++) span(y, 0, W - 1, room); + for (let y = 0; y < TOP; y++) { + const hw = 8 + 0.3 * y; + for (let b = 0; b < 3; b++) { + const e = [1, 0.75, 0.45][b] * hw; + span(y, Math.ceil(SPX - e - 0.5), floor(SPX + e - 0.5), cone[b]); + } + } + for (let y = 0; y < 8; y++) + for (let x = SPX - 10; x <= SPX + 10; x++) { + const mx = x + 0.5 - SPX, my = y + 0.5 + 3, mr = mx * mx + my * my; + if (mr >= 100) continue; + const crater = (mx + 3) ** 2 + (my - 3) ** 2 < 6 || (mx - 4) ** 2 + (my - 5) ** 2 < 3, c = crater ? 0.78 : mr > 80 ? 0.86 : 0.95; + put(x, y, [c, c, c + 0.05]); + } + // The cloud: dark puffs with a rim lit by the moon behind them. + const cx = round(SPX + 52 * (1 - smooth(cv))); + for (let y = 0; y < 12; y++) + for (let j = 0; j < 40; j++) { + const m = S.cloudMask[y * 40 + j]; + if (m) put(cx - 20 + j, y, m === 2 ? CLOUD_RIM : CLOUD); + } + // Dust in the beam, drifting down and twinkling. + for (const m of S.motes) { + const y = (m[1] + S.t * m[2]) % 58, hw = 8 + 0.3 * y, x = SPX + (m[0] * 2 - 1) * hw * 0.85; + const a = (0.35 + 0.35 * sin(S.t * 3 + m[3])) * dim; + if (a > 0.2) put(x, y + 10, [0.6 + a * 0.4, 0.6 + a * 0.4, 0.75 + a * 0.25], 0.6); + } + + // The box top, in perspective, lit in a pool round the spindle. + for (let y = TOP; y < RIM; y++) { + const inset = (RIM - 1 - y) * 0.6; + for (let x = floor(BX0 + inset); x <= BX1 - inset; x++) { + const px = (x + 0.5 - SPX) / 22, py = (y + 0.5 - 62.5) / 3.2, pool = px * px + py * py; + put(x, y, topC[pool < 0.35 ? 0 : pool < 1 ? 1 : 2]); + } + } + const brass = METALS[0][0]; + line(BX0, RIM, BX1, RIM, brass[3]); + // The front: velvet with a sheen in four steps, brass trim at the edges. + for (let x = BX0; x <= BX1; x++) { + const c = sheen[min(3, floor(4 * (0.5 + 0.5 * cos((x - 14) / 9))))]; + for (let y = RIM + 1; y <= BOT; y++) put(x, y, c); + } + line(BX0, RIM + 1, BX0, BOT, brass[2]); + line(BX1, RIM + 1, BX1, BOT, brass[1]); + line(BX0, BOT, BX1, BOT, brass[1]); + // The shelf. + for (let y = BOT + 1; y < H; y++) span(y, 0, W - 1, shelfC[y < BOT + 4 ? 0 : y === BOT + 4 ? 1 : 2]); + // The window: dark lining, brass frame. + for (let y = WY0 + 1; y < WY1; y++) span(y, WX0 + 1, WX1 - 1, lining); + line(WX0, WY0, WX1, WY0, brass[3]); + line(WX0, WY1, WX1, WY1, brass[1]); + line(WX0, WY0, WX0, WY1, brass[2]); + line(WX1, WY0, WX1, WY1, brass[1]); + + // The cylinder, shaded in five steps from its highlight above the middle. + const [metal, pinC] = METALS[S.tuneI]; + for (let y = CY - CR; y <= CY + CR - 1; y++) { + const v = (y + 0.5 - CY) / CR, lv = v < -0.82 ? 1 : v < -0.5 ? 3 : v < -0.2 ? 4 : v < 0.45 ? 2 : v < 0.8 ? 1 : 0; + span(y, CX0, CX1, metal[lv]); + put(CX0 - 2, y, brass[max(0, lv - 1)]); + put(CX0 - 1, y, brass[max(0, lv - 1)]); + put(CX1 + 1, y, brass[max(0, lv - 1)]); + // A gear on the right end, its teeth running with the cylinder. + const gt = floor(y + S.beat * 3) % 2 === 0; + put(CX1 + 2, y, brass[gt ? 3 : 1]); + } + // The pins: from the top edge, coming round from behind, down to the + // bottom edge, where each one plucks its tooth. + const pins = PINS[S.tuneI], silver = pinC, shadow = metal[0]; + for (const [b, tooth] of pins) { + let a = b - S.beat; + if (a < 0) a += 24; + if (a > 12) continue; + const y = CY + CR * cos((PI * a) / 12) - 0.5, x = TX + 3 * tooth; + if (a > 0.6 && a < 11.4) (put(x, y + 1, shadow), put(x + 1, y + 1, shadow)); + const c = S.spark[tooth] > 0.3 && a < 0.6 ? [1, 1, 1] : silver; + put(x, y, c); + put(x + 1, y, c); + } + // The comb: a steel plate under slanted roots, a tooth for each note. + const steel = [0.5, 0.56, 0.7], steelHi = [0.72, 0.78, 0.9], plate = [0.3, 0.33, 0.45], bright = [0.88, 0.97, 1]; + const glowC = [0.3, 0.55, 0.85], white = [1, 1, 1]; + for (let i = 0; i < TEETH; i++) { + const x = TX + 3 * i, root = TIP + toothLen(i); + for (let y = root; y <= PLATE; y++) for (let dx = -1; dx <= 2; dx++) put(x + dx, y, plate); + // A ringing tooth flashes, bright and half bright bands running down it. + const r = S.ring[i], hq = floor(r * 3) / 3; + const tone = (c, a) => (a > 0.05 ? [mix(c[0], bright[0], a), mix(c[1], bright[1], a), mix(c[2], bright[2], a)] : c); + const hiA = tone(steelHi, r), hiB = tone(steelHi, r / 2), loA = tone(steel, r), loB = tone(steel, r / 2); + for (let y = TIP; y < root; y++) { + const band = sin((y - TIP) * 1.3 - S.wave[i]) > 0; + put(x, y, band ? hiA : hiB); + put(x + 1, y, band ? loA : loB); + // The ringing glows into the gaps either side, in three steps. + if (hq > 0) (put(x - 1, y, glowC, hq * 0.8), put(x + 2, y, glowC, hq * 0.8)); + } + if (S.spark[i] > 0.3) put(x, TIP, white), put(x + 1, TIP, white); + } + for (const sx of [TX + 4, TX + 31]) put(sx, PLATE - 1, brass[4]); + + // The key on the right side: a brass shaft and two wings turning about it, + // seen edge on, so they swing between full height and a sliver. + line(BX1 + 1, KY, KX, KY, brass[2]); + const kc = cos(S.key), ks = abs(sin(S.key)); + for (const sg of [-1, 1]) { + const ext = 5.5 * kc * sg, n = Math.ceil(abs(ext)); + for (let j = 1; j <= n; j++) { + const u = j / (n + 0.5), hw = 1.6 * sin(PI * (0.25 + 0.75 * u)); + for (let dx = -1; dx <= 1; dx++) if (abs(dx) <= hw) put(KX + 1 + dx, KY - Math.sign(ext) * j, brass[ks > 0.7 ? 2 : abs(kc) > 0.7 ? 4 : 3]); + } + } + put(KX + 1, KY, brass[4]); + // While it winds, a pale green glow where a hand would be. + if (S.phase === "wind") { + const a = 0.45 * sin((PI * S.ph) / WIND); + for (let y = KY - 7; y <= KY + 7; y++) + for (let x = KX - 2; x < W; x++) { + const e = ((x - KX - 1) / 3.5) ** 2 + ((y - KY) / 7.5) ** 2; + if (e < 1) put(x, y, [0.35, 0.95, 0.45], a * (e < 0.4 ? 0.6 : 0.3)); + } + } + + // The spindle and the ballerina. + line(SPX, TOE + 1, SPX, 62, brass[3]); + dancer(S, put, line); + + const q = (v) => (v >= 1 ? 255 : v <= 0 ? 0 : (v * 255) | 0); + for (let y = 0, o = 0; y < H; y++) for (let x = 0; x < W; x++, o += 3) display.setPixel(x, y, q(rgb[o]), q(rgb[o + 1]), q(rgb[o + 2])); +} + +// The skeleton ballerina, turned by phi about her standing toe and seen a +// little from above: legs first, then the tutu, then the upper body and the +// skull from back to front. +function dancer(S, put, line) { + const phi = S.phi, c = cos(phi), s = sin(phi), Q = { ...J, ...POSES[S.tuneI] }; + const P = (p) => { + const x = p[0] * c + p[2] * s, z = -p[0] * s + p[2] * c; + return [SPX + 0.5 + SC * x, TOE + 0.5 - SC * (p[1] * CE - z * SE), z]; + }; + // Lit by the moon: half as bright while the cloud covers it. + const L = 1 - 0.5 * S.cloud, lit = (c) => [c[0] * L, c[1] * L, c[2] * L]; + const bone = lit([0.96, 0.93, 0.84]), boneLo = lit([0.7, 0.66, 0.62]), pink = lit([1, 0.62, 0.75]); + // A bone; a wide one is two LEDs thick, doubled across its length. + const seg = (a, b, col, wide) => { + const A = P(a), B = P(b); + line(A[0], A[1], B[0], B[1], col); + if (wide) abs(B[0] - A[0]) < abs(B[1] - A[1]) ? line(A[0] + 1, A[1], B[0] + 1, B[1], col) : line(A[0], A[1] + 1, B[0], B[1] + 1, col); + }; + // Legs: the back one first. + const legS = [[Q.toe, Q.ank, pink], [Q.ank, Q.kn, bone], [Q.kn, Q.hipS, bone]]; + const legW = [[Q.hipW, Q.knW, bone], [Q.knW, Q.ftW, bone]]; + const zW = P(Q.knW)[2]; + for (const L of zW < 0 ? [legW, legS] : [legS, legW]) for (const [a, b, col] of L) seg(a, b, col, true); + const kn = P(Q.kn), kw = P(Q.knW); + put(kn[0], kn[1], boneLo); + put(kw[0], kw[1], boneLo); + // The tutu: three layers of a scalloped disc, pleats turning with her. + const [lilacLo, lilac, edge] = TUTUS[S.tuneI].map(lit); + for (const [x, y, layers] of S.tutu) { + let hit = -1; + for (let j = 0; j < layers.length; j += 2) { + const beta = layers[j + 1] + phi, R = TUTU_R + 0.7 * cos(12 * beta); + if (layers[j] < R) hit = max(hit, layers[j] > R - 1.2 ? 2 : (floor(((beta + PI) / TAU) * 16) % 2 + 2) % 2); + } + if (hit >= 0) put(x, y, hit === 2 ? edge : hit === 1 ? lilac : lilacLo); + } + // The upper body, sorted back to front. + const ribs = RIBS.map(([y, w]) => { + const pts = []; + for (let j = 0; j <= 6; j++) { + const a = -PI / 2 + (PI * j) / 6; + pts.push([w * sin(a), y - 0.4 * cos(a), 0.7 * w * cos(a)]); + } + return pts; + }); + const parts = [ + { z: P(Q.chest)[2] - 0.2, draw: () => (seg(Q.waist, Q.chest, boneLo, true), seg(Q.chest, Q.neck, bone)) }, + ...ribs.map((pts) => ({ z: P(pts[3])[2], draw: () => { for (let j = 0; j < 6; j++) seg(pts[j], pts[j + 1], bone); } })), + { z: P(Q.neck)[2], draw: () => (seg(Q.shL, Q.neck, bone), seg(Q.neck, Q.shR, bone)) }, + { z: P(Q.elL)[2], draw: () => (seg(Q.shL, Q.elL, bone, true), seg(Q.elL, Q.haL, bone)) }, + { z: P(Q.elR)[2], draw: () => (seg(Q.shR, Q.elR, bone, true), seg(Q.elR, Q.haR, bone)) }, + { z: 0.1, draw: () => skull(S, P, put, c, s) }, + ].sort((a, b) => a.z - b.z); + for (const p of parts) p.draw(); +} + +// The skull: a round cranium and a narrower jaw, top lit by the moon; the +// sockets, nose and teeth sit on its face and slide round as she turns. +function skull(S, P, put, c, s) { + const H0 = P(J.head), r = HEAD_R * SC, L = 1 - 0.5 * S.cloud, dark = [0.06, 0.02, 0.09]; + const bone = [0.96 * L, 0.93 * L, 0.84 * L], boneLo = [0.74 * L, 0.7 * L, 0.66 * L]; + for (let y = floor(H0[1] - r - 1); y <= H0[1] + r + 2; y++) + for (let x = floor(H0[0] - r - 1); x <= H0[0] + r + 1; x++) { + const dx = x + 0.5 - H0[0], dy = y + 0.5 - H0[1]; + const cran = dx * dx + dy * dy < r * r, jaw = dy > 0 && dy < r + 1.8 && abs(dx) < r * 0.62; + if (cran || jaw) put(x, y, dy > r * 0.35 ? boneLo : bone); + } + // Face features in the head's frame: eyes, nose, the line of teeth. + const face = (fx, fy) => { + const p = [J.head[0] + fx, J.head[1] + fy, J.head[2] + sqrt(max(0, HEAD_R * HEAD_R - fx * fx - fy * fy * 0.6))]; + const q = P(p); + return [q[0], q[1], -p[0] * s + p[2] * c]; + }; + const g = S.eyes, glow = [0.4, 1, 0.35], halo = [0.15, 0.6, 0.18]; + for (const ex of [-1.3, 1.3]) { + const e = face(ex, 0.5); + if (e[2] < 0.8) continue; + const wide = e[2] > 2.2 ? 2 : 1; + if (g > 0.05) for (let dy = -1; dy <= 2; dy++) for (let dx = -1; dx <= wide; dx++) put(e[0] + dx, e[1] + dy, halo, g * 0.55); + for (let dy = 0; dy <= 1; dy++) for (let dx = 0; dx < wide; dx++) put(e[0] + dx, e[1] + dy, g > 0.05 ? [mix(dark[0], glow[0], g), mix(dark[1], glow[1], g), mix(dark[2], glow[2], g)] : dark); + } + const n = face(0, -1.1); + if (n[2] > 0.8) put(n[0], n[1], dark); + for (const tx of [-1.2, 0, 1.2]) { + const t = face(tx, -2.6); + if (t[2] > 0.6) put(t[0], t[1], boneLo); + } +} From 781063d6ff9885282163eba0de110a9a6919d30c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:12:57 +0000 Subject: [PATCH 6/9] patterns: add Square Peg and Pit and Pendulum Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017mBLLjEyWbBi1Ag71cFsLX --- .../pattern-study/patterns/pit-pendulum.js | 395 +++++++++++++++++ incoming/pattern-study/patterns/square-peg.js | 414 ++++++++++++++++++ 2 files changed, 809 insertions(+) create mode 100644 incoming/pattern-study/patterns/pit-pendulum.js create mode 100644 incoming/pattern-study/patterns/square-peg.js diff --git a/incoming/pattern-study/patterns/pit-pendulum.js b/incoming/pattern-study/patterns/pit-pendulum.js new file mode 100644 index 000000000..967d75cf8 --- /dev/null +++ b/incoming/pattern-study/patterns/pit-pendulum.js @@ -0,0 +1,395 @@ +// ===== Patternflow pattern ===== +// Title: Pit and Pendulum +// Family: halloween +// Idea: A torchlit stone dungeon seen from the front: from a winch in the dark vault a long rod swings a huge steel crescent across the panel, its edge glinting and its shadows sweeping the wall, sinking a few LEDs with every pass toward an empty stone slab where rats gather; a hand above the slab the torches gutter, the blade stops, and the chain hauls it back up into the dark to start again. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, after Edgar Allan Poe's story The Pit and the Pendulum (1842), pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Descent=3..12, Torch=0..1 +// +// Knob 1: Hue. The torch fire and the light it throws on the stone, in rings that turn toward violet as they dim: at 0 orange flames lighting the wall gold, orange, red and rose; at 0.1 red; at 0.25 magenta; at 0.4 violet; at 0.55 blue; at 0.75 cyan; at 1 green witch fire. Yellow is left out, because its dim rings turn olive. The slab and the steel stay pale. +// Knob 2: Speed. At 2 the pendulum swings there and back in 2.3 s at the top of its run and 3.2 s just above the slab, as a real pendulum slows when it lengthens, and a whole descent with its haul back up takes 25 s at Descent 3. At 0.2 everything, flames too, runs ten times slower; at 3 half as fast again. +// Knob 3: Descent. LEDs the blade sinks with each pass: at 3 it comes down from the middle of the panel to a hand above the slab in 14 passes, 18 seconds at Speed 2, while the rats on the slab grow from two to seven; at 12 it takes 4 passes and under 5 seconds. +// Knob 4: Torch. How far the torchlight reaches: at 0.06 each torch lights the wall 66 LEDs round it in five steps, gold by the flame and rose at the edge, and the floor and the corners stay dark; at 1 the light reaches 100 LEDs and fills the dungeon. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, asin, hypot, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ease = (t) => t * t * (3 - 2 * t); + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// The room: the vault's arch drops from row 12 at the middle to row 23 at +// the sides, the wall meets the floor at row 104, and the slab's top face +// runs rows 95 to 98 and its front 99 to 113, from column 9 to 54. +const FLOOR = 104, SLAB_X0 = 9, SLAB_X1 = 54, SLAB_TOP = 95, SLAB_FRONT = 99, SLAB_BOT = 113; +const archY = (x) => 12 + 20 * (1 - sqrt(max(0, 1 - ((x + 0.5 - 32) / 36) ** 2))); +// The pendulum: the winch in the vault, the rod's length to the blade at +// the start of a descent, at its end (the blade's edge a hand above the +// slab) and hauled up; the rigid rod under the chain; how far the blade +// reaches sideways at the end of a swing; the period at length 40. +const PX = 32, PY = 8, L_START = 34, L_END = 74, L_TOP = 10, ROD = 24, REACH = 22, T40 = 2.4; +// The blade, a crescent hung by its middle with its horns up: inside the +// outer circle and outside the inner one, both centred on the rod's line, +// in LEDs with v down the rod from its end. Its edge is 7.8 below the +// rod's end and its horns 20.8 to either side, 9.1 above it. +const OC = -13.46, OR = 21.26, IC = -28.33, IR = 28.33; +// Two torches on the walls and the shadow's spread from each onto the wall. +const TX = [8, 56], TY = 52, SPREAD = 2.4; +// The story at Speed 2: the torches gutter and the swing dies, the chain +// hauls the blade into the dark, the torches catch again, it is let down. +const GUTTER = 1.4, HAUL = 3, RELIGHT = 0.7, LOWER = 1.5; + +// The rat, facing right, feet on the bottom row: # body, e eye. +const RAT = [" ## ", " #####e# ", " ####### ", "## # # "]; + +export function setup(params) { + // What each LED of the room is: 0 the dark vault, 1 the arch's stones, + // 2 and 3 the wall's blocks in two tones, 4 the mortar, 5 the floor's + // flags, 6 their joints. + const cls = new Uint8Array(N); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x, ay = archY(x); + if (y < ay) cls[i] = 0; + else if (y < ay + 4) cls[i] = abs((x + 0.5 - 32) % 7) < 0.6 ? 4 : 1; + else if (y < FLOOR) { + const row = floor((y - 10) / 7), off = row % 2 ? 6 : 0, bx = floor((x + off) / 12); + const mortar = (y - 10) % 7 === 0 || (x + off) % 12 === 0; + cls[i] = mortar ? 4 : ((row * 7 + bx * 13) % 5) < 2 ? 3 : 2; + } else { + const fy = y - FLOOR, band = fy === 0 || fy === 4 || fy === 9 || fy === 15 || fy === 22; + const k = ((x + 0.5 - 32) / (y - 70)) / 0.32, joint = abs(k - Math.round(k)) * (y - 70) * 0.32 < 0.55; + cls[i] = band || joint ? 6 : 5; + } + } + const R = Math.random; + params.s = { + cls, rgb: new Float32Array(N * 3), pal: new Float64Array(8 * 3), + hue: 0.5, speed: 1.5, descent: 3.5, torch: 0.06, rt: 0.5, flick: new Float64Array(2), + // The story: phase, time in it, rod length, swing phase and how much + // of the swing is left, the torches' strength, the winch's turn. + phase: "swing", pt: 0.5, len: L_START + 2.5, phi: 0.35, amp: 0.5, fire: 0.5, wheel: 0.5, th: 0.5, + ax: 32.5, ay: 40.5, + // Two rats on the slab already, the rest come as the blade sinks. + rats: [ + { side: 0, s: 52.5, to: 52.5, wait: 1.5, face: 1, wig: R() * TAU }, + { side: 1, s: 58.5, to: 58.5, wait: 2.5, face: -1, wig: R() * TAU }, + ], + spawn: 0.5, + }; +} + +// The rats' track from either side: in along the floor, up the end of the +// slab, and along its top toward the middle. s counts LEDs from off-panel. +const TRACK = [[-10, 124], [7, 124], [10, 98], [31, 98]]; +const SEG = TRACK.slice(1).map((p, k) => hypot(p[0] - TRACK[k][0], p[1] - TRACK[k][1])); +const TRACK_LEN = SEG.reduce((a, b) => a + b, 0), ON_SLAB = SEG[0] + SEG[1]; +function ratAt(r) { + let s = cl(r.s, 0, TRACK_LEN), k = 0; + while (k < 2 && s > SEG[k]) (s -= SEG[k]), k++; + const a = TRACK[k], b = TRACK[k + 1], u = cl(s / SEG[k], 0, 1), x = a[0] + (b[0] - a[0]) * u; + return [r.side ? 63 - x : x, a[1] + (b[1] - a[1]) * u]; +} + +function rats(S, d, want) { + const R = Math.random; + S.spawn -= d; + const staying = S.rats.filter((r) => r.to > 0); + if (staying.length < want && S.spawn <= 0) { + S.rats.push({ side: R() < 0.5 ? 0 : 1, s: 0, to: ON_SLAB + 3 + R() * (TRACK_LEN - ON_SLAB - 4), wait: 1 + R() * 2, face: 1, wig: R() * TAU }); + S.spawn = 0.35 + 0.3 * R(); + } else if (staying.length > want) staying[staying.length - 1].to = -2; + for (const r of S.rats) { + const gap = r.to - r.s; + if (abs(gap) > 0.3) { + // A rat runs at 16 LEDs a second, faster when it flees. + const v = (r.to < 0 ? 30 : 16) * d; + r.s += cl(gap, -v, v); + r.face = (gap > 0 ? 1 : -1) * (r.side ? -1 : 1); + } else if ((r.wait -= d) <= 0 && r.to > 0) { + // Now and then a rat shuffles along the slab. + r.to = cl(r.s + (R() - 0.5) * 12, ON_SLAB + 3, TRACK_LEN - 1); + r.wait = 1.2 + 2.5 * R(); + } + r.wig += d * 9; + } + S.rats = S.rats.filter((r) => r.to > 0 || r.s > 0.5); +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 3, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = cl(k[0], 0, 1); + S.speed = cl(k[1], 0.2, 3); + S.descent = cl(k[2], 3, 12); + S.torch = cl(k[3], 0, 1); + const g = (d * S.speed) / 2; + S.rt += g; + S.pt += g; + // Each torch's light breathes and now and then jumps, on the story's clock. + for (let t = 0; t < 2; t++) { + const c = S.rt * 1.7 + t * 2.3; + S.flick[t] = 0.88 + 0.07 * sin(c * 3.1) * sin(c * 1.3 + 1) + 0.05 * sin(c * 7.7 + t); + } + // A real pendulum: the swing's rate falls as the square root of its length. + const w = (TAU / T40) * sqrt(40 / S.len); + let want = 2; + if (S.phase === "swing") { + S.phi += w * g; + // Each pass, half a swing, lowers it Descent LEDs. + S.len += (S.descent * w * g) / PI; + S.amp = 1; + S.fire = 1; + want = 2 + Math.round((5 * (S.len - L_START)) / (L_END - L_START)); + if (S.len >= L_END) (S.len = L_END), (S.phase = "gutter"), (S.pt = 0); + } else if (S.phase === "gutter") { + // The flames sink and sputter, and the swing dies to a stop over the slab. + const u = cl(S.pt / GUTTER, 0, 1); + S.phi += w * g; + S.amp = 1 - ease(u); + S.fire = 1 - 0.72 * ease(cl(u * 1.6, 0, 1)); + want = 7; + if (u >= 1) (S.phase = "haul"), (S.pt = 0); + } else if (S.phase === "haul") { + const u = cl(S.pt / HAUL, 0, 1), was = S.len; + S.len = L_END + (L_TOP - L_END) * ease(u); + S.wheel += (was - S.len) / 5; + S.amp = 0; + S.fire = 0.28; + want = 0; + if (u >= 1) (S.phase = "relight"), (S.pt = 0); + } else if (S.phase === "relight") { + const u = cl(S.pt / RELIGHT, 0, 1); + S.fire = 0.28 + 0.72 * ease(u) + 0.25 * sin(PI * u); + want = 0; + if (u >= 1) (S.phase = "lower"), (S.pt = 0), (S.phi = 0); + } else { + // Let down to the middle of the panel, swinging again as it goes. + const u = cl(S.pt / LOWER, 0, 1), was = S.len; + S.len = L_TOP + (L_START - L_TOP) * ease(u); + S.wheel += (was - S.len) / 5; + S.phi += w * g; + S.amp = ease(u); + S.fire = 1; + want = 1; + if (u >= 1) (S.phase = "swing"), (S.pt = 0); + } + if (S.phase === "swing") S.wheel -= (S.descent * w * g) / PI / 5; + // The swing: the blade reaches REACH LEDs to either side at full swing. + S.th = S.amp * asin(min(1, REACH / S.len)) * sin(S.phi); + S.ax = PX + S.len * sin(S.th); + S.ay = PY + S.len * cos(S.th); + rats(S, d, want); +} + +// Is (x, y) on the blade? Only the blade casts a shadow: the rod's, a thin +// line across the whole wall, read as a second rope. +function onBlade(S, x, y) { + const ex = sin(S.th), ey = cos(S.th), bx = x - S.ax, by = y - S.ay, u = bx * ey - by * ex, v = bx * ex + by * ey; + if (v > 8 || v < -10 || abs(u) > 21) return false; + return u * u + (v - OC) ** 2 < OR * OR && u * u + (v - IC) ** 2 > IR * IR; +} + +// The frame's colours: the stone in six steps of torchlight, from unlit +// through rings that turn from the fire's own hue toward violet as they +// dim (gold, orange, red, rose for orange fire), so no ring goes brown. +// Hue turns the fire from orange the long way round to green, past red, +// violet and blue, and never through yellow, whose dim rings go olive. +function palette(S) { + const P = S.pal, h = 0.075 - 0.775 * S.hue; + // The short way round the wheel from the fire's hue to violet. + let to = 0.76 - h; + to -= Math.round(to); + const ring = (f, s, v, o) => hsv(h + to * f, s, v, P, o * 3); + (P[0] = 0.04), (P[1] = 0.03), (P[2] = 0.07); + ring(0.75, 0.75, 0.2, 1); + ring(0.5, 0.85, 0.32, 2); + ring(0.25, 0.9, 0.48, 3); + ring(0.05, 0.88, 0.7, 4); + hsv(h + 0.03, 0.6, 0.92, P, 15); + // The flame's own colours: its hue at full and a pale core. + hsv(h, 0.9, 1, P, 18); + hsv(h + 0.05, 0.35, 1, P, 21); +} + +function room(S, R) { + const { rgb, cls, pal: P } = S, LY = TY - 4, R2 = R * R; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x, c = cls[i], o = i * 3; + if (c === 0) { + rgb[o] = rgb[o + 1] = rgb[o + 2] = 0; + continue; + } + let f = 0; + for (let t = 0; t < 2; t++) { + const dx = x + 0.5 - TX[t], dy = y + 0.5 - LY, dd = dx * dx + dy * dy; + if (dd >= R2) continue; + // In the blade's shadow: the line back to the torch, cut at the + // pendulum's depth, meets the blade. + if (onBlade(S, TX[t] + dx / SPREAD, LY + dy / SPREAD)) continue; + f += (1 - sqrt(dd) / R) * S.flick[t]; + } + let lv = min(5, floor(min(1, f * S.fire) * 6)); + // Mortar and floor joints a step darker, the darker blocks a little. + if (c === 4 || c === 6) lv = max(0, lv - 1); + const dim = c === 3 ? 0.82 : c === 5 ? 0.9 : 1; + (rgb[o] = P[lv * 3] * dim), (rgb[o + 1] = P[lv * 3 + 1] * dim), (rgb[o + 2] = P[lv * 3 + 2] * dim); + } +} + +function put(rgb, x, y, r, g, b) { + x = floor(x); + y = floor(y); + if (x < 0 || x >= W || y < 0 || y >= H) return; + const o = (y * W + x) * 3; + (rgb[o] = r), (rgb[o + 1] = g), (rgb[o + 2] = b); +} +function add(rgb, x, y, r, g, b) { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const o = (y * W + x) * 3; + (rgb[o] += r), (rgb[o + 1] += g), (rgb[o + 2] += b); +} + +// The torches: an iron bracket, a flame that leans and leaps, and round it +// three steps of glow in the air. +function torches(S) { + const { rgb, pal: P } = S, t = S.rt, fire = S.fire; + for (let k = 0; k < 2; k++) { + const x0 = TX[k], fl = 0.75 + 0.25 * sin(t * 9.1 + k * 2) * sin(t * 4.3 + k), hgt = (6 + 3 * fl) * (0.4 + 0.6 * fire), lean = 0.8 * sin(t * 2.7 + k * 1.3); + for (let y = TY - 16; y <= TY + 10; y++) + for (let x = x0 - 12; x <= x0 + 12; x++) { + const dd = hypot(x + 0.5 - x0, y + 0.5 - (TY - 4)) / ((0.6 + 0.4 * fire) * S.flick[k]), a = dd < 5 ? 0.42 : dd < 8 ? 0.26 : dd < 11 ? 0.13 : 0; + if (a > 0) add(rgb, x, y, P[18] * a * fire, P[19] * a * fire, P[20] * a * fire); + } + // The bracket and the torch's handle. + for (let y = TY; y <= TY + 7; y++) put(rgb, x0, y, 0.22, 0.14, 0.1), put(rgb, x0 - 1, y, 0.3, 0.2, 0.13); + for (let x = x0 - 3; x <= x0 + 3; x++) put(rgb, x, TY + 7, 0.28, 0.26, 0.3); + for (let y = 0; y < hgt; y++) { + const q = y / hgt, wd = (q < 0.35 ? 2.4 : 2.4 * (1 - (q - 0.35) / 0.65)) + 0.2, cx = x0 + lean * q * q; + for (let x = floor(cx - wd); x <= cx + wd; x++) { + const core = abs(x + 0.5 - cx) < wd * 0.45 && q < 0.6; + const c = core ? 21 : 18; + put(rgb, x, TY - 1 - y, P[c], P[c + 1], P[c + 2]); + } + } + } +} + +// The slab: a pale top face, its front in two steps, the torchlight on both. +function slab(S, R) { + const { rgb, pal: P } = S, LY = TY - 4; + for (let y = SLAB_TOP; y <= SLAB_BOT; y++) + for (let x = SLAB_X0; x <= SLAB_X1; x++) { + const top = y < SLAB_FRONT, edge = x === SLAB_X0 || x === SLAB_X1 || y === SLAB_FRONT; + let f = 0; + for (let t = 0; t < 2; t++) f += max(0, 1 - hypot(x + 0.5 - TX[t], y + 0.5 - LY) / (R * 1.25)); + const lv = min(4, floor(min(1, f * S.fire) * 6)), base = top ? 0.42 : y > SLAB_BOT - 3 ? 0.17 : 0.26, a = top ? 0.5 : 0.35; + const o = (y * W + x) * 3, e = edge ? 0.8 : 1; + (rgb[o] = (base + P[lv * 3] * a) * e), (rgb[o + 1] = (base * 0.94 + P[lv * 3 + 1] * a) * e), (rgb[o + 2] = (base * 1.08 + P[lv * 3 + 2] * a) * e); + } +} + +function drawRats(S) { + const { rgb } = S; + for (const r of S.rats) { + const [x0, y0] = ratAt(r), sit = abs(r.to - r.s) < 0.3, bob = sit ? (sin(r.wig) > 0.6 ? -1 : 0) : floor(r.wig) % 2 ? -1 : 0; + for (let ry = 0; ry < 4; ry++) + for (let rx = 0; rx < 10; rx++) { + const ch = RAT[ry][rx]; + if (ch === " ") continue; + const x = x0 + (r.face > 0 ? rx - 5 : 4 - rx), y = y0 - 3 + ry + (ry < 2 ? bob : 0); + // The eye catches the torchlight, and burns red while the torches gutter. + if (ch === "e") put(rgb, x, y, 1, S.fire < 0.6 ? 0.1 : 0.35, 0.2); + else { + // Black fur, the ears and back caught by the torches. + const rim = (ry === 0 || (ry === 1 && RAT[0][rx] === " ")) * S.fire; + put(rgb, x, y, 0.07 + 0.26 * rim, 0.05 + 0.15 * rim, 0.07 + 0.14 * rim); + } + } + // The tail, a curl along the stone. + for (let q = 1; q <= 5; q++) put(rgb, x0 + (r.face > 0 ? -5 - q : 4 + q), y0 + (q > 3 ? -1 : 0), 0.13, 0.08, 0.1); + } +} + +function pendulum(S) { + const { rgb, pal: P } = S, ex = sin(S.th), ey = cos(S.th), lit = 0.45 + 0.55 * S.fire; + // The winch in the vault: a drum that turns as the chain pays out, its + // rim and four spokes. + for (let y = PY - 6; y <= PY + 6; y++) + for (let x = PX - 6; x <= PX + 6; x++) { + const r = hypot(x + 0.5 - PX, y + 0.5 - PY); + if (r < 5.8) put(rgb, x, y, (r > 4.6 ? 0.36 : 0.15) * lit, (r > 4.6 ? 0.32 : 0.13) * lit, (r > 4.6 ? 0.37 : 0.17) * lit); + } + for (let s = 0; s < 4; s++) { + const t = S.wheel + (s * PI) / 2; + for (let q = 1; q < 5; q++) put(rgb, PX + q * cos(t), PY + q * sin(t), 0.3 * lit, 0.27 * lit, 0.31 * lit); + } + // The chain from the winch, its links moving with it, then the iron rod. + for (let q = 0; q <= S.len * 2; q++) { + const a = q / 2, x = PX + a * ex, y = PY + a * ey; + if (a < S.len - ROD) { + // Links lying flat and edge on by turns, each 4 LEDs. + let link = (a + S.wheel * 5) % 4; + if (link < 0) link += 4; + const b = link < 2.5 ? 0.58 : 0.22, b2 = link < 2.5 ? 0.24 : 0.46; + put(rgb, x, y, b * lit, b * 0.94 * lit, b * 0.9 * lit); + put(rgb, x + ey, y - ex, b2 * lit, b2 * 0.94 * lit, b2 * 0.92 * lit); + } else { + put(rgb, x, y, 0.62 * lit, 0.58 * lit, 0.58 * lit); + put(rgb, x + ey, y - ex, 0.3 * lit, 0.27 * lit, 0.32 * lit); + } + } + // The blade: dark steel inside, brighter toward the edge, the edge itself + // near white, and a glint that runs along it and flares as it passes the bottom. + const gl = sin(S.phi) * S.amp, flare = S.amp * max(0, 1 - abs(cos(S.phi)) * 4); + // Hauled up into the vault the blade leaves the torchlight. + const bl = lit * cl((S.ay - 8) / 30, 0.3, 1); + for (let y = floor(S.ay - 22); y <= S.ay + 22; y++) + for (let x = floor(S.ax - 22); x <= S.ax + 22; x++) { + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const bx = x + 0.5 - S.ax, by = y + 0.5 - S.ay, u = bx * ey - by * ex, v = bx * ex + by * ey; + const dOut = OR - sqrt(u * u + (v - OC) ** 2), dIn = sqrt(u * u + (v - IC) ** 2) - IR; + if (dOut <= 0 || dIn <= 0) continue; + const hot = abs(u + gl * 16) < 2.6 && dOut < 1.5; + const st = dOut < 1.1 ? (hot ? 1 : 0.92) : dOut < 2.4 ? 0.62 : 0.4; + const tint = dOut < 1.1 ? 0.08 : 0.2; + const o = (y * W + x) * 3; + (rgb[o] = (st * (1 - tint) + P[12] * tint) * bl), (rgb[o + 1] = (st * 0.98 * (1 - tint) + P[13] * tint) * bl), (rgb[o + 2] = (st * 1.04 * (1 - tint) + P[14] * tint) * bl); + } + if (flare > 0.05) { + // A four-pointed star of light on the edge, under the rod. + const sx = S.ax + ex * 7.5, sy = S.ay + ey * 7.5, n = 2 + floor(5 * flare); + for (let q = -n; q <= n; q++) { + const a = 1 - abs(q) / (n + 1); + put(rgb, sx + q, sy, 1, 1, 0.9 * a + 0.1); + put(rgb, sx, sy + q, 1, 1, 0.9 * a + 0.1); + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const rgb = S.rgb, R = 64 + 36 * S.torch; + palette(S); + room(S, R); + torches(S); + slab(S, R); + drawRats(S); + pendulum(S); + for (let y = 0, o = 0; y < H; y++) + for (let x = 0; x < W; x++, o += 3) display.setPixel(x, y, (255 * cl(rgb[o], 0, 1)) | 0, (255 * cl(rgb[o + 1], 0, 1)) | 0, (255 * cl(rgb[o + 2], 0, 1)) | 0); +} diff --git a/incoming/pattern-study/patterns/square-peg.js b/incoming/pattern-study/patterns/square-peg.js new file mode 100644 index 000000000..a5bcb2a3a --- /dev/null +++ b/incoming/pattern-study/patterns/square-peg.js @@ -0,0 +1,414 @@ +// ===== Patternflow pattern ===== +// Title: Square Peg +// Family: topology +// Idea: A closed curve wobbles and twists its lobes into curling arms without ever crossing itself, and every frame all the squares with their four corners on it are found afresh and drawn as bright outlines over its dim inside, born and dying in pairs as it changes while their count stays odd. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: inscribed square problem (Otto Toeplitz, 1911), solved for smooth curves (Lev Schnirelmann, 1929); inscribed rectangles of every aspect ratio (Joshua Greene and Andrew Lobb, 2020), pattern round 49 +// Round: 49 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Ratio=1..3, Wobble=0..1 +// +// Knob 1: Hue. Moves the whole palette along a strip of the wheel from green through cyan, blue and violet to rose, leaving out red, orange and yellow, which go brown and olive when dim: at 0 the curve's inside is deep blue and its squares green, violet and rose; at 0.28 the inside is rose and the squares green to violet; at 0.78 the inside is green and the squares cyan to rose; at 1 blue again. +// Knob 2: Speed. At 2 each lobe swells and shrinks every 13 to 25 seconds while it turns, and the twist swings from one side to the other and back in 33 seconds; at 0.2 ten times slower, at 3 half as fast again. +// Knob 3: Ratio. The aspect ratio of the rectangles sought: at 1 squares, at 2 rectangles twice as long as they are wide, at 3 three times. Each square at 1 becomes two rectangles as the ratio leaves it, so away from 1 the count is even in more than nine frames of ten. +// Knob 4: Wobble. How far the curve folds: at 0.06 its lobes reach 0.41 of the mean radius in and out and the twist turns its outermost points up to 2.5 radians past its innermost, and one to five squares stand on it; at 1 the lobes reach 0.5 and the twist 3.6 radians, the arms curl over the valleys between them, and up to 23 squares stand at once. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, atan2, hypot, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} +// Every colour comes off a strip of the wheel that skips red, orange and +// yellow, which turn brown and olive when dim: u from 0 to 1 runs green, +// cyan, blue, violet, magenta to rose. +const wheel = (u) => 0.34 + 0.63 * u; + +// The curve. In its own plane it is star-shaped, r0(th) = 1 + four Fourier +// lobes; then a twist turns each point by tau * rho^2 about the centre, a +// smooth map of the plane onto itself with a smooth inverse, so the image +// is still a simple closed curve however far it curls; then it is +// stretched to stand 2:1 on the panel. Its implicit function, phi(x) = +// rho - r0(angle - tau * rho^2) after undoing the stretch, is zero exactly +// on the curve and negative inside, and angle - tau * rho^2 of a point on +// the curve is its parameter. +const MODES = [2, 3, 4, 5], WEIGHT = [0.22, 0.36, 0.26, 0.16]; +const CX = 32, CY = 64, HALF_W = 29.5, HALF_H = 61.5; +// Curve samples for the search and for drawing, and the r0 table. +const M = 128, MD = 480, TAB = 1024; +// The search drops candidate pairs this close along the curve (the trivial +// squares of side 0) and solutions with a side under MIN_SIDE LEDs. +const NEAR = 2, MIN_SIDE = 3; +// Seconds a square born or dying flashes; frames a square may go unfound +// before it counts as dead. +const FLASH = 0.6, MISS = 2; + +export function setup(params) { + const R = Math.random; + params.s = { + // Each lobe: rate and phase of its swell, rate and start of its turn. + nu: MODES.map(() => 0.25 + 0.25 * R()), c0: MODES.map(() => TAU * R()), + mu: MODES.map((k, i) => (i % 2 ? -1 : 1) * (0.08 + 0.12 * R()) * (3 / k)), p0: MODES.map(() => TAU * R()), + amp: new Float64Array(4), ph: new Float64Array(4), ac: new Float64Array(4), as: new Float64Array(4), + t: 0.5, hue: 0.5, speed: 1.5, ratio: 1.5, wob: 0.06, tau: 0.5, sx: 20.5, sy: 40.5, + tab: new Float64Array(TAB + 1), phi: new Float32Array(N), ring: new Uint8Array(N), + px: new Float64Array(M), py: new Float64Array(M), f1: new Float32Array(M * M), f2: new Float32Array(M * M), done: new Uint8Array(M * M), + sq: [], ghosts: [], next: 0, first: true, gp: new Float64Array(4), gq: new Float64Array(4), + // What the search found: frames searched at ratio 1, frames with a + // square, frames with an odd count, the largest corner residual in + // LEDs, and how many frames had each count. + stat: { frames: 0, found: 0, odd: 0, res: 0, hist: {} }, + rgb: new Float32Array(N * 3), col: new Float64Array(3), pal: new Float64Array(24), + }; +} + +// r0 exactly, for the solver and the drawn line; the table, for the field. +// The lobes are cos(k th - ph) for k = 2..5, built from cos th and sin th +// by the multiple-angle recurrence, with a * cos ph and a * sin ph kept per +// frame. The slope dr0/dth is left in RD. +let RD = 0; +function r0(S, th) { + const c1 = cos(th), s1 = sin(th), ac = S.ac, as = S.as; + let ck = c1 * c1 - s1 * s1, sk = 2 * s1 * c1, r = 1 + ac[0] * ck + as[0] * sk, dr = 2 * (as[0] * ck - ac[0] * sk); + for (let m = 1; m < 4; m++) { + const c = ck * c1 - sk * s1; + sk = sk * c1 + ck * s1; + ck = c; + r += ac[m] * ck + as[m] * sk; + dr += (m + 2) * (as[m] * ck - ac[m] * sk); + } + RD = dr; + return r; +} +// The curve's point at parameter s and its velocity, in LEDs. +function gamma(S, s, out) { + const r = r0(S, s), dr = RD, a = s + S.tau * r * r, da = 1 + 2 * S.tau * r * dr, ca = cos(a), sa = sin(a); + out[0] = CX + S.sx * r * ca; + out[1] = CY + S.sy * r * sa; + out[2] = S.sx * (dr * ca - r * sa * da); + out[3] = S.sy * (dr * sa + r * ca * da); +} +// The field and its slope per LED. +function phiAt(S, x, y, g) { + const X = (x - CX) / S.sx, Y = (y - CY) / S.sy, rr = X * X + Y * Y, rho = sqrt(rr); + const f = rho - r0(S, atan2(Y, X) - S.tau * rr), dr = RD; + if (g) { + g[0] = (X / rho - dr * (-Y / rr - 2 * S.tau * X)) / S.sx; + g[1] = (Y / rho - dr * (X / rr - 2 * S.tau * Y)) / S.sy; + } + return f; +} +// atan2 to within 1e-5 rad, for the per-LED field only; the solver uses +// the exact one. +function fatan2(y, x) { + const ax = abs(x), ay = abs(y), a = min(ax, ay) / (max(ax, ay) || 1), q = a * a; + let r = ((-0.0464964749 * q + 0.15931422) * q - 0.327622764) * q * a + a; + if (ay > ax) r = 1.57079637 - r; + if (x < 0) r = 3.14159274 - r; + return y < 0 ? -r : r; +} +function paramOf(S, x, y) { + const X = (x - CX) / S.sx, Y = (y - CY) / S.sy, a = atan2(Y, X) - S.tau * (X * X + Y * Y); + return a - floor(a / TAU) * TAU; +} + +// The rectangle on side p(s) -> q(t), its other side Ratio times as long: +// corners in o[0..7], the field at the two far corners in o[8], o[9] (both +// zero is a rectangle), and their slopes in s and t in o[10..13]. +const GR = new Float64Array(2), GU = new Float64Array(2); +function corners(S, s, t, o) { + const P = S.gp, Q = S.gq, k = S.ratio; + gamma(S, s, P); + gamma(S, t, Q); + const jx = -(Q[1] - P[1]) * k, jy = (Q[0] - P[0]) * k; + (o[0] = P[0]), (o[1] = P[1]), (o[2] = Q[0]), (o[3] = Q[1]); + (o[4] = Q[0] + jx), (o[5] = Q[1] + jy), (o[6] = P[0] + jx), (o[7] = P[1] + jy); + o[8] = phiAt(S, o[4], o[5], GR); + o[9] = phiAt(S, o[6], o[7], GU); + // r = q + kJ(q - p) and u = p + kJ(q - p), J the quarter turn. + o[10] = GR[0] * k * P[3] - GR[1] * k * P[2]; + o[11] = GR[0] * (Q[2] - k * Q[3]) + GR[1] * (Q[3] + k * Q[2]); + o[12] = GU[0] * (P[2] + k * P[3]) + GU[1] * (P[3] - k * P[2]); + o[13] = -GU[0] * k * Q[3] + GU[1] * k * Q[2]; +} + +// Newton's method on (s, t) from a cell of the search grid. +const V = new Float64Array(14); +function solve(S, s, t) { + for (let it = 0; it < 14; it++) { + corners(S, s, t, V); + if (abs(V[8]) + abs(V[9]) < 1e-13) return true; + // Near the diagonal s = t every corner falls on p, the square of side 0, + // and a start drawn toward it is given up. + if (it >= 2 && abs(V[2] - V[0]) + abs(V[3] - V[1]) < MIN_SIDE / 2) return false; + const a = V[10], b = V[11], c = V[12], d = V[13], det = a * d - b * c; + if (!det) return false; + let ds = (d * V[8] - b * V[9]) / det, dt = (a * V[9] - c * V[8]) / det; + const step = max(abs(ds), abs(dt)); + if (step > 0.06) (ds *= 0.06 / step), (dt *= 0.06 / step); + s -= ds; + t -= dt; + } + return false; +} + +function search(S) { + const { px, py, f1, f2, done, phi: F } = S, k = S.ratio; + for (let i = 0; i < M; i++) { + gamma(S, (TAU * i) / M, S.gp); + (px[i] = S.gp[0]), (py[i] = S.gp[1]); + } + // The field at each far corner, read between LED centres; off the panel + // is outside. + for (let i = 0; i < M; i++) { + const xi = px[i], yi = py[i]; + for (let j = 0; j < M; j++) { + const jx = -(py[j] - yi) * k, jy = (px[j] - xi) * k, o = i * M + j; + for (let c = 0; c < 2; c++) { + const x = (c ? xi : px[j]) + jx - 0.5, y = (c ? yi : py[j]) + jy - 0.5; + let f = 0.5; + if (x > -0.5 && x < W - 0.5 && y > -0.5 && y < H - 0.5) { + const gx = x < 0 ? 0 : x > W - 1.001 ? W - 1.001 : x, gy = y < 0 ? 0 : y > H - 1.001 ? H - 1.001 : y; + const ix = gx | 0, iy = gy | 0, fx = gx - ix, fy = gy - iy, n = iy * W + ix; + f = (F[n] + (F[n + 1] - F[n]) * fx) * (1 - fy) + (F[n + W] + (F[n + W + 1] - F[n + W]) * fx) * fy; + } + if (c) f2[o] = f; + else f1[o] = f; + } + } + } + done.fill(0); + // A cell where both far corners cross the curve may hold a solution. + // Newton starts where the two fields, each taken as a plane through the + // cell, cross, kept to within a cell of it. Each rectangle turns up once + // per side that can be p -> q, four for a square and two otherwise, so + // once one is found the cells of the others are done. + const found = [], sqr = abs(k - 1) < 1e-9, period = sqr ? PI / 2 : PI; + const mark = (a, b) => (done[floor((a / TAU) * M) * M + floor((b / TAU) * M)] = 1); + for (let i = 0; i < M; i++) { + const i1 = (i + 1) % M; + for (let j = 0; j < M; j++) { + const gap = abs(i - j), a = i * M + j; + if (min(gap, M - gap) <= NEAR || done[a]) continue; + const j1 = (j + 1) % M, b = i * M + j1, c = i1 * M + j, d = i1 * M + j1; + const A1 = f1[a], B1 = f1[b], C1 = f1[c], D1 = f1[d], A2 = f2[a], B2 = f2[b], C2 = f2[c], D2 = f2[d]; + if ((A1 > 0) === (B1 > 0) && (A1 > 0) === (C1 > 0) && (A1 > 0) === (D1 > 0)) continue; + if ((A2 > 0) === (B2 > 0) && (A2 > 0) === (C2 > 0) && (A2 > 0) === (D2 > 0)) continue; + const m1 = (A1 + B1 + C1 + D1) / 4, u1 = (C1 + D1 - A1 - B1) / 2, w1 = (B1 + D1 - A1 - C1) / 2; + const m2 = (A2 + B2 + C2 + D2) / 4, u2 = (C2 + D2 - A2 - B2) / 2, w2 = (B2 + D2 - A2 - C2) / 2, det = u1 * w2 - w1 * u2; + if (!det) continue; + const di = cl((-m1 * w2 + w1 * m2) / det, -1, 1), dj = cl((-u1 * m2 + m1 * u2) / det, -1, 1); + if (!solve(S, (TAU * (i + 0.5 + di)) / M, (TAU * (j + 0.5 + dj)) / M)) continue; + const len = hypot(V[2] - V[0], V[3] - V[1]); + if (len < MIN_SIDE) continue; + const v = Array.from(V.subarray(0, 8)), ps = [0, 1, 2, 3].map((q) => paramOf(S, v[2 * q], v[2 * q + 1])); + for (let q = 0; q < 4; q += sqr ? 1 : 2) mark(ps[q], ps[(q + 1) % 4]); + const cx = (v[0] + v[2] + v[4] + v[6]) / 4, cy = (v[1] + v[3] + v[5] + v[7]) / 4; + let ang = atan2(v[3] - v[1], v[2] - v[0]) % period; + if (ang < 0) ang += period; + let dup = false; + for (const f of found) { + let da = abs(f.ang - ang); + da = min(da, period - da); + if (abs(f.cx - cx) < 0.05 && abs(f.cy - cy) < 0.05 && abs(f.len - len) < 0.05 && da < 0.005) dup = true; + } + if (!dup) found.push({ cx, cy, len, ang, v }); + } + } + return found; +} + +// Each square found takes the id and age of the nearest one last frame; +// the rest are born. One unfound for more than MISS frames dies. +function track(S, found, dt) { + const old = S.sq, used = new Uint8Array(old.length), next = []; + for (const f of found) { + let best = -1, bc = 4; + old.forEach((o, i) => { + if (used[i]) return; + const c = hypot(o.cx - f.cx, o.cy - f.cy) + abs(o.len - f.len); + if (c < bc) (bc = c), (best = i); + }); + if (best >= 0) (used[best] = 1), (f.id = old[best].id), (f.age = old[best].age + dt); + else (f.id = S.next++), (f.age = S.first ? 9 : 0); + f.miss = 0; + next.push(f); + } + old.forEach((o, i) => { + if (used[i]) return; + if (++o.miss > MISS) S.ghosts.push({ v: o.v, life: FLASH }); + else (o.age += dt), next.push(o); + }); + for (const g of S.ghosts) g.life -= dt; + S.ghosts = S.ghosts.filter((g) => g.life > 0); + S.sq = next; + S.first = false; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, kv = input.knobValues || [0, 2, 1, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = cl(kv[0], 0, 1); + S.speed = cl(kv[1], 0.2, 3); + S.ratio = cl(kv[2], 1, 3); + S.wob = cl(kv[3], 0, 1); + S.t += (d * S.speed) / 2; + const t = S.t, A = 0.4 + 0.1 * S.wob; + for (let m = 0; m < 4; m++) { + S.amp[m] = A * WEIGHT[m] * (0.7 + 0.3 * sin(S.nu[m] * t + S.c0[m])); + S.ph[m] = S.p0[m] + S.mu[m] * t; + (S.ac[m] = S.amp[m] * cos(S.ph[m])), (S.as[m] = S.amp[m] * sin(S.ph[m])); + } + let rmax = 0; + for (let i = 0; i <= TAB; i++) rmax = max(rmax, (S.tab[i] = r0(S, (TAU * i) / TAB))); + // The twist turns a point at radius 1 + A this far past one at 1 - A, + // swinging from one side to the other; and the curve is scaled to just fit. + S.tau = ((2.4 + 1.2 * S.wob) * sin(0.19 * t + 1.1)) / (4 * A); + (S.sx = HALF_W / rmax), (S.sy = HALF_H / rmax); + const F = S.phi, tab = S.tab; + for (let y = 0; y < H; y++) { + const Y = (y + 0.5 - CY) / S.sy; + for (let x = 0; x < W; x++) { + const X = (x + 0.5 - CX) / S.sx, rho2 = X * X + Y * Y; + let u = ((fatan2(Y, X) - S.tau * rho2) / TAU) * TAB; + u -= floor(u / TAB) * TAB; + const iu = u | 0; + F[y * W + x] = sqrt(rho2) - (tab[iu] + (tab[iu + 1] - tab[iu]) * (u - iu)); + } + } + const found = search(S); + if (S.ratio === 1) { + const st = S.stat; + st.frames++; + if (found.length) st.found++; + if (found.length % 2) st.odd++; + st.hist[found.length] = (st.hist[found.length] || 0) + 1; + for (const f of found) + for (let c = 0; c < 4; c++) { + // A corner's distance from the curve in LEDs: the field over its slope. + const e = abs(phiAt(S, f.v[2 * c], f.v[2 * c + 1], GR)); + st.res = max(st.res, e / hypot(GR[0], GR[1])); + } + } + track(S, found, d); +} + +// The inside: blue at Hue 0, along the strip to rose at 0.28, back down +// to green at 0.78 and up to blue again at 1. A square's own colour: its id +// picks a place on the rest of the strip, at least a quarter of it away +// from the inside's. +const U0 = 0.444; +const insideAt = (S) => { + const f = (U0 + 2 * S.hue) % 2; + return f > 1 ? 2 - f : f; +}; +function squareHue(S, id) { + const p = insideAt(S), lo = max(0, p - 0.25), x = ((id * 0.618034) % 1) * (lo + max(0, 0.75 - p)); + return wheel(x < lo ? x : x - lo + p + 0.25); +} + +function line(rgb, x0, y0, x1, y1, r, g, b) { + const n = max(1, Math.ceil(hypot(x1 - x0, y1 - y0) * 2)); + for (let q = 0; q <= n; q++) { + const x = floor(x0 + ((x1 - x0) * q) / n), y = floor(y0 + ((y1 - y0) * q) / n); + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const o = (y * W + x) * 3; + (rgb[o] = r), (rgb[o + 1] = g), (rgb[o + 2] = b); + } +} + +// The square's inside in its own colour, except on the curve's line. +function fill(S, v, r, g, b) { + const { rgb, ring } = S, xs = [v[0], v[2], v[4], v[6]], ys = [v[1], v[3], v[5], v[7]]; + const x0 = max(0, floor(min(...xs))), x1 = min(W - 1, floor(max(...xs))), y0 = max(0, floor(min(...ys))), y1 = min(H - 1, floor(max(...ys))); + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const px = x + 0.5, py = y + 0.5; + let pos = 0, neg = 0; + for (let c = 0; c < 4; c++) { + const ax = xs[c], ay = ys[c], cr = (xs[(c + 1) % 4] - ax) * (py - ay) - (ys[(c + 1) % 4] - ay) * (px - ax); + if (cr > 0) pos++; + else neg++; + } + const i = y * W + x; + if ((pos && neg) || ring[i] === 3) continue; + (rgb[i * 3] = r), (rgb[i * 3 + 1] = g), (rgb[i * 3 + 2] = b); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { rgb, ring, phi, col } = S, hb = wheel(insideAt(S)); + // The line: the curve sampled finely, so a fold thinner than an LED still + // draws both its sides. Then two rings round it. + ring.fill(0); + let a = [], b = [], x0 = 0, y0 = 0; + for (let q = 0; q <= MD; q++) { + gamma(S, (TAU * q) / MD, S.gp); + const x1 = S.gp[0], y1 = S.gp[1], n = q ? Math.ceil(2 * max(abs(x1 - x0), abs(y1 - y0))) : 0; + for (let e = 0; e <= n; e++) { + const x = floor(x1 + ((x0 - x1) * e) / (n || 1)), y = floor(y1 + ((y0 - y1) * e) / (n || 1)), i = y * W + x; + if (x >= 0 && x < W && y >= 0 && y < H && !ring[i]) (ring[i] = 3), a.push(i); + } + (x0 = x1), (y0 = y1); + } + for (let lv = 2; lv > 0; lv--) { + for (const i of a) { + const x = i % W; + if (x > 0 && !ring[i - 1]) (ring[i - 1] = lv), b.push(i - 1); + if (x < W - 1 && !ring[i + 1]) (ring[i + 1] = lv), b.push(i + 1); + if (i >= W && !ring[i - W]) (ring[i - W] = lv), b.push(i - W); + if (i < N - W && !ring[i + W]) (ring[i + W] = lv), b.push(i + W); + } + (a = b), (b = []); + } + // Inside dim and flat with a brighter band along the line; outside black + // with a two-step halo. Five colours, no gradient. pal holds them by + // ring outside (0..3) and then ring inside (4..7). + const pal = S.pal, lv = [[0, 0, 0], [hb, 0.85, 0.2], [hb, 0.75, 0.42], [hb + 0.03, 0.35, 1], [hb, 0.85, 0.2], [hb, 0.8, 0.3], [hb, 0.75, 0.36], [hb + 0.03, 0.35, 1]]; + for (let c = 0; c < 8; c++) hsv(lv[c][0], lv[c][1], lv[c][2], pal, c * 3); + for (let i = 0, o = 0; i < N; i++, o += 3) { + const c = (ring[i] + (phi[i] < 0 ? 4 : 0)) * 3; + (rgb[o] = pal[c]), (rgb[o + 1] = pal[c + 1]), (rgb[o + 2] = pal[c + 2]); + } + // Squares, largest first so small ones show on top: the inside in the + // square's colour, then the outline, then the corners. + const list = S.sq.slice().sort((a, b) => b.len - a.len); + for (const f of list) { + const h = squareHue(S, f.id); + hsv(h, 0.8, 0.42, col, 0); + fill(S, f.v, col[0], col[1], col[2]); + } + for (const f of list) { + // Newborn squares flash white and settle into their colour. + const v = f.v, wh = f.age < FLASH ? 1 - f.age / FLASH : 0; + hsv(squareHue(S, f.id), 0.6 * (1 - wh), 1, col, 0); + for (let c = 0; c < 4; c++) line(rgb, v[2 * c], v[2 * c + 1], v[(2 * c + 2) % 8], v[(2 * c + 3) % 8], col[0], col[1], col[2]); + for (let c = 0; c < 4; c++) { + const x = floor(v[2 * c]), y = floor(v[2 * c + 1]); + for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) { + const X = x + dx, Y = y + dy; + if (X < 0 || X >= W || Y < 0 || Y >= H) continue; + const o = (Y * W + X) * 3, core = dx === 0 && dy === 0; + (rgb[o] = core ? 1 : col[0]), (rgb[o + 1] = core ? 1 : col[1]), (rgb[o + 2] = core ? 1 : col[2]); + } + } + } + // A square that died flashes white where it last stood, then goes. + for (const g of S.ghosts) { + const a = floor((g.life / FLASH) * 4) / 4 + 0.25; + for (let c = 0; c < 4; c++) line(rgb, g.v[2 * c], g.v[2 * c + 1], g.v[(2 * c + 2) % 8], g.v[(2 * c + 3) % 8], a, a, a); + } + for (let y = 0, o = 0; y < H; y++) + for (let x = 0; x < W; x++, o += 3) display.setPixel(x, y, (255 * cl(rgb[o], 0, 1)) | 0, (255 * cl(rgb[o + 1], 0, 1)) | 0, (255 * cl(rgb[o + 2], 0, 1)) | 0); +} From 1cec089b3d7bd8d7f5a2e28556615c2725881969 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:23:25 +0000 Subject: [PATCH 7/9] =?UTF-8?q?README=20=EC=97=90=2049=ED=8C=90=20?= =?UTF-8?q?=ED=91=9C=EC=99=80=20=EC=97=B0=EA=B5=AC=20=EB=85=B8=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017mBLLjEyWbBi1Ag71cFsLX --- incoming/pattern-study/README.md | 85 ++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index d2a191305..cea377736 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 388개를 쉰여섯 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. 23판도 열 갈래에 하나씩 열 개를 더했고, 무대·공연과 짐승 갈래를 새로 열었어요. 24판도 열 갈래에 하나씩 열 개를 더했고, 공사·건설과 도자기 갈래를 새로 열었어요. 25판도 열 갈래에 하나씩 열 개를 더했고, 농사·밭과 화산 갈래를 새로 열었어요. 26판도 열 갈래에 하나씩 열 개를 더했고, 얼음·극지와 미생물 갈래를 새로 열었어요. 27판도 열 갈래에 하나씩 열 개를 더했고, 철도와 필름·영화 갈래를 새로 열었어요. 28판은 주제 표가 수학 쪽으로 바뀐 첫 판이라 이름 있는 수학 대상 열 개를 열 갈래에 하나씩 더했고, 수론 갈래를 새로 열었어요. 29판은 주제 표가 수학 여섯과 화장품 팝업 넷으로 나뉜 첫 판이라, 이름 있는 수학 대상 여섯과 화장품 매장 벽에 틀 질감 넷을 더했고, 위상·매듭과 화장품 팝업 갈래를 새로 열었어요. 30판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩 더하고, 팝업 매장의 LED 벽에 틀 질감 넷을 화장품 팝업 갈래에 더했어요. 31판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했고, 새 갈래는 열지 않았어요. 32판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽의 질감 넷을 화장품 팝업 갈래에 더했어요. 33판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 34판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 35판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 36판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 37판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 38판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 39판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 40판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 41판은 주제 표가 수학·추상 다섯과 할로윈 다섯으로 바뀐 첫 판이라, 수학 대상 다섯을 다섯 갈래에 하나씩 더하고 할로윈 갈래를 새로 열어 10월 31일에 틀 패턴 다섯을 넣었어요. 42판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩 더하고, 할로윈 밤에 틀 그림 다섯을 41판이 연 할로윈 갈래에 더했어요. 43판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 44판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩 더하고, 할로윈 밤에 틀 그림 다섯을 할로윈 갈래에 더했어요. 45판도 같은 표로 돌아서 수학 대상 다섯을 패턴이 열 개 아래인 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 46판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 47판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 밤에 틀 그림 다섯을 할로윈 갈래에 더했어요. 48판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 398개를 쉰여섯 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. 23판도 열 갈래에 하나씩 열 개를 더했고, 무대·공연과 짐승 갈래를 새로 열었어요. 24판도 열 갈래에 하나씩 열 개를 더했고, 공사·건설과 도자기 갈래를 새로 열었어요. 25판도 열 갈래에 하나씩 열 개를 더했고, 농사·밭과 화산 갈래를 새로 열었어요. 26판도 열 갈래에 하나씩 열 개를 더했고, 얼음·극지와 미생물 갈래를 새로 열었어요. 27판도 열 갈래에 하나씩 열 개를 더했고, 철도와 필름·영화 갈래를 새로 열었어요. 28판은 주제 표가 수학 쪽으로 바뀐 첫 판이라 이름 있는 수학 대상 열 개를 열 갈래에 하나씩 더했고, 수론 갈래를 새로 열었어요. 29판은 주제 표가 수학 여섯과 화장품 팝업 넷으로 나뉜 첫 판이라, 이름 있는 수학 대상 여섯과 화장품 매장 벽에 틀 질감 넷을 더했고, 위상·매듭과 화장품 팝업 갈래를 새로 열었어요. 30판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩 더하고, 팝업 매장의 LED 벽에 틀 질감 넷을 화장품 팝업 갈래에 더했어요. 31판도 같은 표로 돌아서 이름 있는 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했고, 새 갈래는 열지 않았어요. 32판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽의 질감 넷을 화장품 팝업 갈래에 더했어요. 33판도 같은 표로 돌아서 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 34판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 35판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 36판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 37판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 38판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 39판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 40판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 41판은 주제 표가 수학·추상 다섯과 할로윈 다섯으로 바뀐 첫 판이라, 수학 대상 다섯을 다섯 갈래에 하나씩 더하고 할로윈 갈래를 새로 열어 10월 31일에 틀 패턴 다섯을 넣었어요. 42판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩 더하고, 할로윈 밤에 틀 그림 다섯을 41판이 연 할로윈 갈래에 더했어요. 43판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 44판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩 더하고, 할로윈 밤에 틀 그림 다섯을 할로윈 갈래에 더했어요. 45판도 같은 표로 돌아서 수학 대상 다섯을 패턴이 열 개 아래인 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 46판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 47판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 밤에 틀 그림 다섯을 할로윈 갈래에 더했어요. 48판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. 49판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. [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판에서 세로로 다시 짰어요. @@ -630,6 +630,21 @@ | [Trick or Treat](patterns/trick-or-treat.js) | 할로윈 | 보라 하늘 아래 좁은 골목이 소실점으로 올라가고, 홑이불 유령과 빗자루 든 마녀, 고양이 셋이 손전등을 휘두르며 걸어가다 노란 빛이 쏟아지는 문 앞에서 사탕을 받고 점점 작아져요. K3 은 불 켠 집의 몫, K4 는 바람이고, K1 을 누르면 불 켠 집의 문이 열려요. | | [Grim Reaper](patterns/grim-reaper.js) | 할로윈 | 두 눈에 독한 초록 불을 켠 두건 사신이 해진 옷자락을 날리며 창백한 보름달 앞을 떠가고, 머리 위로 큰 낫을 휘두를 때마다 날이 번뜩이며, 지나가는 자리의 무덤 촛불은 꺼졌다가 등 뒤에서 다시 켜져요. K3 은 무덤 수, K4 는 안개이고, K1 을 누르면 바로 휘둘러요. | +### 49판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Dürer's Unfolding](patterns/durer-unfolding.js) | 공간·원근 | 판화처럼 빗금 친 입체가 펼친 그물로 패널에 섰다가 경첩마다 접혀 닫히고, 어둠 속에서 돌다 점으로 사라지면 다음 입체가 돌며 겹치지 않는 새 그물로 펼쳐져요. K3 은 정육면체부터 육팔면체까지 여섯 입체 가운데 처음 것을, K4 는 입체마다 골라 둔 그물 여덟 가운데 하나를 골라요. | +| [Curlicue](patterns/curlicue.js) | 수론 | 세타 합의 부분합이 가운데에서 양쪽으로 한 걸음씩 자라 서로 닿지 않는 LED 레이스가 되고, 화면이 끝없이 물러나도 단계마다 같은 갈고리가 다시 감겨 나와요. K3 은 √3 부터 √101 − 10 까지 무리수 다섯 가운데 x 를, K4 는 한 단계의 걸음 수를 골라요. | +| [Tennis Racket](patterns/tennis-racket.js) | 시뮬레이션 | 가운데 축으로 돌던 T자 손잡이가 몇 초마다 반 바퀴씩 뒤집히고, 아래 구에서는 각운동량이 분리선을 따라 한쪽 끝에서 반대쪽 끝으로 건너가요. K3 은 어느 주축 가까이에서 던질지, K4 는 그 축에서 얼마나 비껴 던질지이고, 단추를 누르면 다시 던져요. | +| [Kempe Chains](patterns/kempe-chains.js) | 보로노이·셀 | 떠다니는 나라들의 국경이 바뀌어도 지도는 네 색을 지켜요. 새 국경이 같은 색 둘을 붙이면 남은 색을 칠하고, 네 색이 다 둘러싸면 두 색의 켐프 사슬이 빛나며 색을 맞바꿔요. K3 은 나라 수, K4 는 나라 크기가 갈리는 정도예요. | +| [Square Peg](patterns/square-peg.js) | 위상·매듭 | 구불거리며 비틀리는 닫힌 곡선 위에 네 꼭짓점을 얹은 정사각형을 프레임마다 다 찾아 그리면, 정사각형이 둘씩 태어나고 사라져도 그 수는 늘 홀수예요. K3 은 찾는 직사각형의 가로세로 비, K4 는 곡선이 휘는 정도예요. | +| [Gargoyle](patterns/gargoyle.js) | 할로윈 | 폭풍 속 성당 처마의 가고일이 입으로 물줄기를 패널 아래 웅덩이까지 쏟고, 번개가 칠 때마다 검게 드러났다가 눈에 독한 초록 불이 켜져요. K3 은 비, K4 는 번개가 치는 잦기예요. | +| [Music Box](patterns/music-box.js) | 할로윈 | 원통의 핀이 빗살을 튕기면 해골 발레리나가 돌다가, 태엽이 풀려 음악이 늘어지면 우리를 보고 멈춰 눈구멍이 초록으로 빛나고, 보이지 않는 손이 열쇠를 감아 다시 시작해요. K3 은 곡 셋 가운데 하나를, K4 는 태엽을 감은 만큼이에요. | +| [Werewolf](patterns/werewolf.js) | 할로윈 | 구름이 걷힌 보름달 앞 바위 위에서 남자가 등을 굽히고 팔이 앞다리로 늘어나 늑대가 되어 울부짖고, 구름이 다시 달을 덮으면 남자로 돌아와요. K3 은 구름, K4 는 달의 크기예요. | +| [Pit and Pendulum](patterns/pit-pendulum.js) | 할로윈 | 횃불 켠 지하 감옥에서 커다란 초승달 칼날이 벽에 그림자를 휘두르며 한 번 지날 때마다 돌 침대 쪽으로 내려오고, 쥐들이 모여들다 손 한 뼘 위에서 횃불이 잦아들면 쇠사슬이 칼날을 어둠 속으로 끌어올려요. K3 은 한 번 지날 때 내려오는 깊이, K4 는 횃불이 닿는 거리예요. | +| [Party Lights](patterns/party-lights.js) | 할로윈 | 주황과 보라, 독한 초록, 뼈처럼 흰 전구 줄이 어두운 현관 벽에 처지며 빛을 달리고 반짝이고 숨 쉬듯 켜고 박자에 번쩍이며, 바람이 줄을 그네처럼 흔들면 종이 박쥐들이 실에 매달려 빙글 돌아요. K3 은 전구 줄 수, K4 는 바람이에요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -2440,6 +2455,50 @@ E8 의 근 240개를 고셋의 4₂₁ 다면체 꼭짓점으로 그렸어요. 주제 6 으로 사신을 그렸어요. 해진 검은 옷의 두건 사신이 반지름 30 의 창백한 달 앞을 10초에 한 번 건너요. 3.2초마다 큰 낫을 어깨 뒤로 젖혔다가 머리 위로 휘둘러 앞에 곧게 내리고, 날 안쪽이 밝아 휘두를 때 빛이 날을 따라 달려요. 사신이 26 LED 안으로 오면 무덤 촛불이 흔들리다 옷자락 밑에서 꺼지고, 옷자락이 지나고 0.6~1.8초 뒤 다시 켜져요. 하늘은 짙은 보라에서 색상환을 짧은 쪽으로 돌아 병든 초록 지평선에 닿아요. 검은 옷은 어두운 보라 아래 하늘에서 사라졌어요. 아래 하늘을 밝은 청록과 초록으로 바꾸자 사신이 다 읽혔어요. 앞으로 내려 긋는 낫은 날이 64 너비 밖으로 나가서, 머리 위로 휘둘러 곧게 내리게 했어요. 하늘 전체를 색상 노브로 돌리면 고동색과 갈색 하늘이 나와서, K1 은 지평선 빛과 달빛, 눈만 바꿔요. 움직임은 첫 판이 0.084 였어요. 빨리 건너고 크게 오르내리게 하자 0.189 가 됐고, 달과 낫을 키우자 0.295 가 됐어요. 머리 위로 휘두르는 것과 옷자락 부스러기로 0.331, 옷이 부푸는 것으로 0.347 이에요. 큰 것이 밝은 것 앞을 다녀야 셈에 들었어요. K3 은 무덤 한 줄로는 열여섯을 세워도 0.02~0.03 이었어요. 무덤 띠가 판의 18% 뿐이라, 무덤을 여섯씩 언덕을 따라 줄지어 올리자 0.05 가 됐어요. 잰 값은 움직임 0.347, 채움 0.989, 복잡도 0.242 예요. +## 49판 + +49판은 주제 5 「수학 · 추상」 다섯과 주제 6 「할로윈」 다섯이에요. 같이 돈 48판(#259)의 브랜치를 받아 열 개의 Idea 줄을 읽고, 뫼비우스 에너지와 이중 소용돌이, 원숭이올빼미와 사신 같은 것을 피해서 골랐어요. 앞 바퀴 PR 들에 따를 글은 없었어요. 수학은 Dürer's Unfolding, Curlicue, Tennis Racket, Kempe Chains, Square Peg 이고 공간·원근, 수론, 시뮬레이션, 보로노이·셀, 위상·매듭에 하나씩 들어갔어요. 48판까지 넣어 세면 추상 갈래 가운데 노이즈·흐름과 시뮬레이션, 보로노이·셀이 아홉으로 가장 적었고, 수론과 위상·매듭, 공간·원근, 데이터 그림, 입자, 리듬·펄스가 열이었어요. 노이즈·흐름에 떠오른 감은 48판의 Double Gyre 처럼 흐름에 염료를 푸는 그림이라 건너뛰었어요. 다섯 다 목록 밖에서 새로 찾았고, 「흔한 것」에도 「낯선 수학 예」에도 없어요. 처음 고른 폰 노이만의 거품 법칙은 31판의 Foam Lather 가 이미 그려서 켐프 사슬로 바꿨어요. 민코프스키의 물음표 함수는 수직선을 왼쪽 벽에 세우는 꼴이 Ford Circles 와 같아서 세타 합으로 바꿨어요. 할로윈은 Gargoyle, Music Box, Werewolf, Pit and Pendulum, Party Lights 예요. 늑대로 바뀌는 남자는 46판 뒤에 적혀 있던 감이고, 넷은 새로 찾았어요. 주제 1 은 48판까지의 388개에도 빈 칸과 하나뿐인 칸이 없어서, 둘뿐인 칸 열아홉 가운데 촘촘함 × 모양 바뀜 2,4 와 촘촘함 × 채움 4,1 을 겨눴어요. Dürer's Unfolding 이 Marbling 과 Polytope Slices 뿐이던 촘촘함 × 모양 바뀜 2,4 에, Curlicue 가 Hydrogen Orbital 과 One Bit 뿐이던 촘촘함 × 채움 4,1 에 섰어요. 주제 4 는 Gargoyle 과 Pit and Pendulum 이 맡았어요. 열 개는 어느 축의 끝도 넘지 않았고, 복잡도는 0.091 에서 0.599, 움직임은 0.108 에서 1.100 이에요. 열 개는 `shelf.json` 의 어느 묶음에도 넣지 않아서 페이지 맨 위 「아직 안 추린 것」에 서요. 다섯 일꾼이 둘씩 짰고, 본체가 연구의 Lab 하네스로 다시 재고 화면을 그려 보고 합쳤어요. + +### Dürer's Unfolding + +주제 5 와 1 로 뒤러의 전개 문제를 세웠어요. 알브레히트 뒤러가 1525년 『측량 지침』에서 다면체를 모서리를 따라 잘라 한 장의 그물로 펼쳐 그렸고, 제프리 셰퍼드가 1975년에 볼록 다면체는 다 겹치지 않는 모서리 전개가 있다고 추측했어요. 아직 풀리지 않은 문제예요. 호리야마 다카시와 쇼지 와타루(2011)는 정다면체의 모서리 전개가 어느 것도 겹치지 않는다는 것을 보였어요. 입체는 펼친 그물로 패널에 섰다가 경첩마다 접혀 닫히고, 위에서 조금 내려다본 채 돌다가 점으로 작아지면 다음 입체가 자라 제 그물로 펼쳐져요. 정육면체, 정팔면체, 정십이면체, 정이십면체, 깎은 정팔면체, 육팔면체가 차례로 와요. 펼칠 나무는 데이비드 윌슨(1996)의 고리 지운 걸음으로 고르게 뽑은 생성 나무예요. 입체마다 160개를 뽑아 서로 다른 그물 여덟을 패널을 많이 채우는 차례로 남겼고, 남긴 그물은 패널의 32~55% 를 덮어요. 면은 판화처럼 경첩에 45도로 빗금을 치고, 빛을 등진 면과 면의 뒤에는 엇빗금을 쳐요. 색은 나무에서 뿌리까지의 깊이로 칠해요. 패턴 자신의 함수로 입체마다 20,000개, 모두 120,000개의 무작위 그물을 펼쳐 보니 겹친 것이 하나도 없었어요. 정다면체는 호리야마와 쇼지가 말한 대로이고, 두 아르키메데스 입체도 그랬어요. 같은 시험이 깎은 정이십면체에서는 5,000개 가운데 47개와 51개를 겹침으로 잡아서, 시험이 겹침을 놓치지 않는 것도 봤어요. 그물에서 꼭짓점마다 남는 틈을 더하면 데카르트 정리의 720° 가 120,000 그물 모두에서 8.0e-13° 안으로 나왔어요. 경첩을 다 편 3차원 그림은 2차원 그물과 1.0e-13 LED 안에서 겹쳐요. 첫 판은 경첩에 나란한 빗금을 4 LED 마다 0.5 밝기로 쳐서 촘촘함이 0.039 였어요. 닫힌 입체가 2~8초 창의 대부분을 차지했기 때문이에요. 빗금을 3 LED 마다 0.4 로 좁히고 엇빗금을 넣고 닫힌 입체를 58 LED 쯤으로 키우자 0.071 이 됐어요. 빗금을 경첩에 45도로 눕히고 그물을 3.4초까지 펼친 채 두어 0.122 가 됐고, 빗금 밝기를 면 색의 0.46 으로 올려 촘촘함 0.113, 모양 바뀜 0.365 에 섰어요. + +### Curlicue + +주제 5 와 1 로 세타 합의 꼬부랑 곡선을 세웠어요. 마이클 베리와 조너선 골드버그가 1988년에 그 재규격화를 셈한 곡선이에요. n 번째 걸음을 π n² x 만큼 돌린 단위 걸음을 이어 가면, 무리수 x 에서 길은 크기가 다른 오일러 나선이 겹겹이 감긴 꼴이 돼요. 까닭은 하디와 리틀우드(1914)의 근사 함수 방정식이에요. x 로 N 걸음 간 합은 −1/x 로 ⌊Nx⌋+1 걸음 간 합과 같은 꼴이고, 45도 돌고 1/√x 배 커져 있어요. 패턴은 −N 에서 N 까지의 대칭 합을 가운데에서 양쪽으로 키우며 화면을 끝없이 물러나게 하고, 한 단계가 끝날 때마다 n 걸음을 ⌊nx⌋+1 걸음의 x′ = −1/x mod 2 로 바꿔요. 화면도 같은 각과 배율로 옮겨서 아무것도 뛰지 않아요. K3 은 √3, 황금비, √2, √26 − 5, √101 − 10 가운데 x 를 골라요. 다섯 다 이차 무리수라 그 사슬이 1e-9 안에서 되돌아와요. √3 은 {0.2679} 하나로, 황금비는 {0.3820, 0.6180} 둘로 돌아서 10초와 18초의 화면이 같아 보여요. 600초씩 돌리며 단계마다 잰 재규격화의 틈은 가장 커도 길 크기의 1.70% 였어요. N 을 1e3 에서 1e7 까지 늘려 잰 |S_N| 의 최댓값은 기울기가 0.488~0.509 로 √N 을 따랐고, 견주어 본 π 는 0.668 이었어요. 처음에는 x 를 무리수 사이로 천천히 미끄러뜨리려 했어요. N 이 2,000 언저리면 x 를 1e-6 만 바꿔도 위상이 π n² dx 만큼 움직여 먼 반쪽이 프레임마다 다시 짜여서, 확대가 x 의 사슬을 걷게 바꿨어요. e 와 π 도 뺐어요. 그 사슬은 1 의 첨점 가까이 지나서 틈이 11~16% 였어요. 첫 판은 한쪽 합이라 채움 0.121, 촘촘함 0.241 이었고 `thirds` 가 5/21/9 였어요. 대칭 합으로 바꾸자 0.157 과 0.313 이 됐고, 새로 자란 반쪽을 대각선 두 LED 펜으로 그어 채움 0.168, 촘촘함 0.335 가 됐어요. 켜진 LED 는 다 체커판의 한 색 위에 있어서 어느 두 LED 도 옆으로 닿지 않아요. + +### Tennis Racket + +주제 5 로 테니스 라켓 정리를 세웠어요. 주축 셋의 관성 모멘트가 다른 굳은 몸은 가장 작은 축과 가장 큰 축으로는 안정하게 돌지만, 가운데 축으로 돌면 반 바퀴씩 거듭 뒤집혀요. 레온하르트 오일러가 1765년에 그 방정식을 썼고, 루이 푸앵소가 1834년에 각운동량이 몸에서 그리는 폴호드로 그림을 그렸어요. 1985년 살류트 7호에서 블라디미르 자니베코프가 날개 너트로 그 뒤집힘을 봤어요. 위쪽은 각운동량이 곧게 서 있는 공간에서 본 T자 손잡이이고, 아래쪽은 손잡이에서 본 각운동량 구예요. 구에 폴호드와 가운데 축의 두 끝을 잇는 분리선을 긋고, 지금 점과 그 꼬리를 그려요. 상자 둘로 지은 손잡이의 관성 모멘트는 1 : 1.917 : 2.807 이에요. 오일러 방정식과 사원수 자세를 1/240초 RK4 로 풀고 각운동량을 다시 맞추지 않아도, 60초 동안 |L| 은 3.4e-11, 에너지는 7.0e-11 안에 머물렀고 공간의 L 은 수직에서 2.5e-7 도 안에 있었어요. 뒤집힘 사이 시간은 야코비 타원 함수로 낸 2K(k)/속도와 상대 오차 1e-8~3e-7 안에서 같았어요. 비끼는 각이 0.06 이면 3.887974초, 1 이면 1.575875초예요. 비끼는 각을 e 배 줄일 때마다 0.926초씩 늘어서 이론의 2/λ = 0.927초와 맞았어요. 첫 판은 구를 (0.55, 0.2, 0.81) 에서 봐서 분리선 하나가 곧은 흰 선으로 서고 다른 하나는 테두리에 누웠어요. (0.3, 0.3, 0.9) 로 옮기자 둘 다 ±e2 에서 엇갈리는 타원이 됐어요. 지금 던진 폴호드 안쪽을 물들이고, 가로대 두 끝에 0.3초 꼬리를 달고, 손잡이를 단위당 21 에서 25 LED 로 키워 Speed 가 0.0497 에서 0.059 가 됐어요. 주황 손잡이는 그늘진 면에서 갈색이라 따뜻한 색의 가장 어두운 그늘을 0.51 에서 0.69 로 올렸어요. 처음 값에서 손잡이는 2.0~3.0초에 넘어가고, 3.4초에는 손잡이가 아래를 향해요. + +### Kempe Chains + +주제 5 로 켐프 사슬을 세웠어요. 앨프리드 켐프가 1879년에 네 색 정리를 증명했다며 낸 길이고, 퍼시 히우드가 1890년에 그 틈을 찾았어요. 정리는 케네스 아펠과 볼프강 하켄이 1976년에 증명했어요. 수도 15~40개가 떠다니는 지도를 보로노이나 파워 다이어그램으로 나누고, 국경이 바뀔 때마다 네 색을 지켜요. 새 국경이 같은 색 이웃 둘을 붙이면 비는 색이 있을 때는 그 색을 칠해요. 네 색이 다 둘러싸면 이웃 하나를 품은 두 색의 켐프 사슬이 빛나며 수도에서 수도로 흰 고리를 잇고, 두 색을 맞바꿔 색 하나를 비워요. 나라 수와 크기, 빠르기를 바꿔 가며 120초씩 72번 돌린 259,200 프레임이 다 네 색으로 바르게 칠해졌어요. 고친 22,242번 가운데 비는 색으로 고친 것이 6,354번(28.6%), 사슬 한 번이 13,936번(62.7%), 사슬 두 번이 1,952번(8.8%)이었고, 국소 탐색까지 간 것과 실패한 것은 0 이었어요. 첫 판은 서로 미는 수도 15개가 격자처럼 굳어서 60초에 아홉 번만 고쳤고, 첫 사슬이 29.7초에 왔어요. 상자 모드 넷의 흐름 함수로 휘젓자 1분에 29~51번이 됐어요. 나라 20개에서는 60초에 11번을 고치지 못했어요. 정확한 되추적으로 보니 0.4초 기억한 국경까지 넣은 그래프가 그 순간 네 색으로 칠해지지 않았고, 그려진 지도는 늘 칠해졌어요. 그래서 고치지 못하면 기억한 국경을 버리고 다시 고치게 했고, 실패가 0 이 됐어요. 연구의 씨앗으로는 첫 사슬이 6.9초에 왔어요. 떠다님을 정해 두고 1.9초 앞을 미리 셈해서, 일찍 생길 국경이 네 색에 둘러싸인 같은 색 둘을 붙이도록 첫 칠을 고르자 첫 사슬이 1.20초에 와요. 사슬은 다섯 나라까지 되는 것 가운데 가장 긴 것을 골라서, 평균 길이가 2.0 에서 4.1 나라가 됐어요. 넷째 노브는 사슬이 빛나는 동안에만 보이는 Glow 대신, 첫 프레임부터 모든 국경을 바꾸는 Sizes 로 바꿨어요. 첫 판의 색 0.903 은 축 끝 0.675 를 넘어서, 팔레트의 채도를 0.92 에서 0.72 로 내려 0.624 가 됐어요. + +### Square Peg + +주제 5 로 내접 정사각형 문제를 세웠어요. 오토 퇴플리츠가 1911년에 낸 물음이에요. 평면의 닫힌 곡선마다 네 꼭짓점을 그 위에 얹은 정사각형이 있느냐는 것이고, 매끈한 곡선은 레프 시니렐만(1929)이 풀었지만 모든 조르당 곡선에서는 아직 열려 있어요. 조슈아 그린과 앤드루 로브는 2020년에 매끈한 조르당 곡선이 모든 가로세로 비의 직사각형을 품는다는 것을 보였어요. 곡선은 별꼴 곡선의 엽을 흔들고 바깥으로 갈수록 더 비트는 매끈한 역사상으로 지어서 제 몸을 가로지르지 않아요. 1,024 마디의 곡선 1,080개에서 엇갈림이 0 이었고, 가장 좁은 목은 1.65 LED 였어요. 프레임마다 곡선 위 점 쌍을 한 변으로 두고 나머지 두 꼭짓점을 곡선의 거리장에 대 본 뒤, 해석적 야코비안으로 뉴턴 걸음을 밟아 정사각형을 다 찾아요. 꼭짓점은 곡선에서 8.0e-12 LED 안에 있어요. 특별한 자리에 있지 않은 매끈한 곡선은 정사각형을 홀수 개 품어서, 패턴도 그 수를 세요. 처음 값으로 60초를 돌리니 1,800 프레임 모두에 정사각형이 섰고 1,799 프레임에서 홀수였어요. 768 등분의 촘촘한 탐색으로는 1,800 이었어요. 정사각형은 곡선이 바뀌며 둘씩 태어나고 사라져서, 태어난 것은 희게 번쩍이고 사라진 것은 흰 윤곽을 남기며 흐려져요. K3 Ratio 가 1 을 떠나면 정사각형 하나가 직사각형 둘이 돼요. 1.001 에서는 1,800 프레임 가운데 1,780 에서 직사각형 수가 정사각형 수의 꼭 두 배였어요. 비 1.5, 2, 3 에서는 모든 프레임에 직사각형이 서고, 그 수가 94~98% 의 프레임에서 짝수였어요. 이 짝수는 잰 값이고 문헌이 말하는 것이 아니에요. 첫 판은 꼬임을 곡선의 실제 반지름 폭으로 늘려서, 엽이 작을 때 같은 비틀림이 좁은 띠에 몰려 옆구리가 1~3 LED 지그재그로 접혔어요. 그 안의 작은 정사각형을 놓쳐 600 프레임 가운데 479 만 홀수였고, 놓친 570 가운데 523 이 3 LED 아래였어요. 꼬임을 엽의 이름값 폭으로 늘리자 지그재그가 사라졌어요. 어둑한 따뜻한 칠은 갈색과 올리브가 돼서 색을 초록에서 장미색까지의 띠에서만 골라요. 한 프레임이 Node 에서 2.2~2.9 ms 라 2 ms 를 조금 넘어요. + +### Gargoyle + +주제 6 과 4 로 폭풍 속 가고일을 그렸어요. 보랏빛 먹구름 아래 성당 처마에서 뿔 달린 돌짐승이 날개를 접고 웅크려 튀어나와, 홈통의 물을 벌린 입으로 쏟아요. 물줄기는 긴 호를 그리며 패널 높이를 다 떨어져 아래 웅덩이에서 터지고, 비가 비스듬히 가로질러요. 번개는 1.5초에 처음 치고, 처음 값에서는 그 뒤로 4.3초쯤마다 첨탑 뒤에서 쳐요. 번개가 치면 돌이 밝은 테를 두른 검은 실루엣이 되고, 지나면 가고일의 눈에 독한 초록 불이 남아요. 번개 사이에는 돌을 네 단의 회색으로 칠하고 위쪽 모서리를 밝혀요. 물줄기는 아래 3분의 1 에서 낱방울로 풀리고, 웅덩이에서 방울 100개쯤과 납작한 고리로 터져요. 첫 판은 움직임 0.678, 복잡도 0.598, 한 프레임 2.0 ms 였어요. 하늘을 프레임마다 21×11 팔레트로 짓자 복잡도 0.440, 한 프레임 1.6 ms 가 됐어요. 구름을 세 배 빠르게 흘려 Speed 가 0.04 에서 0.053 이 됐고, 번쩍임을 세게 해 모양 바뀜이 0.173 에서 0.204 가 됐어요. 먼 탑에 켠 1~2 LED 창은 주황 막대로 읽혀서 지웠고, 길의 줄무늬는 쇠창살로 보여 엇갈린 돌길로 바꿨어요. 가까운 첨탑은 날개 뒤에 숨어서 높이고 꼭대기 장식을 달았어요. K1 은 남색에서 청록을 지나 병든 초록까지 찬 색으로만 돌아서, 어느 자리에서도 갈색이 오지 않아요. + +### Music Box + +주제 6 으로 귀신 들린 오르골을 그렸어요. 유리창 난 상자 안에서 핀 박힌 원통이 돌며 이가 13개인 쇠빗을 튕기고, 튕긴 이는 번쩍이며 울림이 이를 따라 내려가요. 곡은 G# 을 쓰는 가단조의 세 박자 8마디예요. 그 위 달빛 원뿔 아래에서 치마 입은 해골 발레리나가 마디마다 한 바퀴 돌아요. 태엽이 풀리면 박자가 늘어지고 울림도 느려져서 음이 처지는 것처럼 보이고, 발레리나는 우리를 보고 멈춰요. 구름이 달을 덮고 눈구멍이 초록으로 빛나면 보이지 않는 손이 열쇠를 돌리고, 오르골이 다시 울려요. 첫 튕김은 0.267초예요. K3 은 왈츠와 자장가, 미뉴에트를 고르고, 곡마다 핀과 박자, 자세, 원통의 쇠붙이가 달라요. 첫 판은 움직임 0.067, 모양 바뀜 0.022, 노브 0.07, 0.02, 0.02, 0.01 이었어요. 이마다 소리 고리를 피워 올리자 움직임이 0.481 이 됐지만, 패널에서는 발레리나를 가둔 철사 새장처럼 보여서 뺐어요. 발레리나를 키우고 뼈를 두 LED 로 굵히고 곡마다 자세를 달리해도 Tune 은 0.02 였어요. 멈춰 있는 동안 구름이 달빛을 4분의 1 로 줄이고 곡마다 쇠붙이와 치마를 달리하자 0.06 이 됐어요. 처음에는 발레리나를 조향으로 우리 쪽에 세웠는데 871 프레임에서 거꾸로 돌았어요. 저절로 느려지는 길이가 꼭 한 바퀴로 끝나게 풀리는 시간을 1.47~3.63초에서 고르자, 멈춤 180번에 거꾸로 도는 프레임이 0 이었고 마지막 프레임은 우리 쪽에서 0.007 rad 안이었어요. 가장 어두운 놋쇠를 (0.60, 0.36, 0.05) 에서 (0.76, 0.48, 0.08) 로 올려 원통 끝이 갈색으로 보이지 않게 했어요. + +### Werewolf + +주제 6 으로 늑대 인간을 그렸어요. 46판 뒤에 적혀 있던 감이에요. 큰 보름달에서 구름이 걷히면 그 앞 바위에 선 남자의 검은 실루엣이 등을 굽히고, 팔이 무릎까지 늘어나 앞다리가 되고, 얼굴이 주둥이로 늘어나며 귀가 서요. 늑대가 고개를 젖혀 울면 소리 고리가 하늘에서는 초록으로, 달 위에서는 찬 그림자로 퍼져요. 구름이 9~12초에 다시 달을 가리면 10.5~13.5초에 남자로 돌아와요. 1.9초에 허리 굽은 남자, 2.6초에 휜 등, 3.3초에 주둥이와 귀, 꼬리를 단 늑대예요. 46판 뒤에는 Howling Wolf 의 거리장 둘을 섞자고 적었는데, 몸 전체의 거리장 둘을 섞으니 팔이 늘어나지 않고 다리 속으로 녹아들었어요. 그래서 뼈대 하나의 뼈를 사람에서 늑대로 옮기고, 다리는 두 마디로 발 디딜 자리에 닿게 하고, 머리만 거리장 둘을 섞었어요. 첫 판은 Clouds 가 0.0088 로 검사에 걸렸어요. 2~3초 창에는 구름이 이미 그림 밖으로 나가 있었어요. 구름 넷이 늘 오고 셋이 4초까지 뒤따라 달을 건너게 해 0.069 가 됐어요. 한 프레임은 11.0 ms 에서 구름 가면을 미리 셈해 4.5, 몸을 제 상자 안에서만 셈해 3.5, 몸을 상자 친 캡슐로 담아 2 ms 언저리가 됐어요. 2 LED 굵기 팔다리에 1 LED 테를 두르면 다리가 회색 빗금이라, 테는 몸 안으로 1.3 LED 넘게 들어간 자리에만 켜요. 길이 0 인 귀는 2초에 모자챙으로 읽혀서 머리 안에서 자라 나오게 했어요. 캡슐 구름은 회색 막대였고, 부드럽게 섞은 덩이를 위가 트인 LED 에만 밝혀서야 구름이 됐어요. 빛 테를 두른 바위는 탁자로 읽혔고, 윗면만 밝혀도 아직 높은 대지처럼 보여요. + +### Pit and Pendulum + +주제 6 과 4 로 에드거 앨런 포의 「함정과 진자」(1842)를 그렸어요. 횃불 둘이 켜진 지하 감옥의 벽을 앞에서 보고, 어두운 천장의 권양기에서 내린 긴 막대가 뿔 끝에서 뿔 끝까지 42 LED 인 초승달 칼날을 흔들어요. 칼날은 흰 날을 따라 빛이 달리고 흔들림의 바닥마다 별처럼 번쩍이며, 한 번 지날 때마다 빈 돌 침대 쪽으로 몇 LED 씩 내려와요. 붉은 눈의 쥐가 둘에서 일곱까지 침대 가장자리에 모여요. 칼날이 손 한 뼘 위에 오면 횃불이 보라 어둠으로 잦아들고 흔들림이 죽고, 쇠사슬이 칼날을 천장으로 끌어올리는 동안 쥐가 흩어져요. 횃불이 다시 일면 칼날이 흔들리며 내려와요. 처음 값에서 한 번 내려오는 데 14번 지나며 18.5초쯤 걸리고 한 바퀴가 25초쯤이에요. 막대가 길어지며 흔들림 주기는 2.3초에서 3.2초로 늘어요. 첫 판은 Speed 와 Descent 가 0.04 였어요. 횃불 일렁임을 벽 빛에 싣고, 칼날 그림자를 넓히고, Descent 범위를 3..8 에서 3..12 로 넓히고, 칼날을 1.3배 키워도 0.047 이었어요. 횃불을 42줄에서 52줄로 내려 칼날 그림자를 벽의 가장 밝은 자리로 던지자 Speed 0.098, Descent 0.076 이 됐어요. 막대의 그림자는 밧줄이 더 있는 것처럼 보여서 칼날만 그림자를 던져요. K1 이 노랑을 지나면 어둑한 고리가 올리브라서, 주황에서 빨강과 보라, 파랑, 청록을 지나 초록까지 긴 쪽으로 돌아요. + +### Party Lights + +주제 6 으로 현관에 건 할로윈 전구 줄을 그렸어요. 무서운 장면보다 10월 31일 밤 파티에 틀 꾸밈이에요. 어두운 판자벽에 전구 줄 셋에서 일곱이 높이를 달리해 처지고, 줄마다 4×6 LED 전구 여섯이 주황과 보라, 독한 초록, 뼈처럼 흰 빛을 네 단의 빛무리로 켜요. 빛이 줄을 따라 달리는 차례, 반짝이는 차례, 천천히 숨 쉬는 차례, 1분에 120번 박자로 번쩍이는 차례가 4초씩 돌아요. 바람이 불면 줄이 벽에서 그네처럼 들려 처진 바닥이 옆으로 미끄러지고, 둘째 줄 아래 8 LED 실에 매단 검은 종이 박쥐들이 빙글 돌며 퍼덕여요. 첫 판은 채움 0.231, 색 0.115 였어요. 전구가 작았고, 반짝이는 차례는 거의 어두웠고, 박쥐는 벽 앞에서 보이지 않았어요. 전구를 4×6 타원으로 키우고 빛무리를 13 LED 로 넓히고, 박쥐를 13×7 에서 9×5 로 줄여 전구 아래 빛무리 속으로 내리자 채움 0.535, 색 0.182 가 됐어요. 검은 박쥐는 켜진 전구 곁에서만 읽혀서, 빛이 줄을 따라 달리는 차례에 가장 잘 보여요. 박자 번쩍임은 0.1초 만에 꺼지면 프레임 대부분이 어두워서, 번쩍일 때마다 0.1초(넷째 박자는 0.22초) 머문 뒤 꺼져요. 어둑한 주황은 짙은 빨강으로, 어둑한 흰빛은 라벤더 회색으로 칠해서 어느 것도 갈색으로 보이지 않아요. 첫 바람은 0.1초에 불어 Gust 가 0.02 였고, 1.7초로 미뤄 노브를 재는 창에 들게 하자 0.09 가 됐어요. 노브 값의 제곱근을 쓰고 흔들림의 끝을 부드럽게 막아 0.069 에 섰어요. 한 프레임은 빛무리 반지름을 미리 셈해 4.7 ms 에서 1.2~1.8 ms 가 됐어요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -2482,7 +2541,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 25일 `dev`(`f72355d`)의 328개에 43판부터 48판까지의 예순 개를 더한 388개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 25일 `dev`(`f72355d`)의 328개에 43판부터 49판까지의 일흔 개를 더한 398개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 @@ -2524,6 +2583,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 48판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로이고, 388개에서도 지도 21장이 다 `none` 이에요. 주제 1 은 47판까지의 378개에 남은 둘뿐인 칸 스물하나 가운데 가장자리 × 움직임 1,4 와 촘촘함 × 모양 바뀜 3,3 을 겨눠서, 그런 칸이 388개에서 열아홉이에요. 가장자리 × 움직임 1,4 에는 47판이 적은 대로 판 전체가 부드럽게 끓는 그림이 맞았어요. Flashing Ratchet 은 입자 3,000개를 둘레 네 LED 에 나눠 찍고 다섯 칸 번짐을 걸어서, 판을 다 덮은 구름이 프레임마다 떨려도 40/255 넘게 뛰는 이웃이 드물어요. 가장자리 0.032, 움직임 2.019 로 두 경계에서 다 멀리 섰어요. 촘촘함 × 모양 바뀜 3,3 은 이웃 차이의 평균이 0.189~0.297 이면서 2~8초에 블록 평균이 0.217~0.331 바뀌어야 하는 칸이에요. Double Gyre 의 능선은 그대로 그리면 촘촘함이 0.15 에 머물렀어요. LED 마다 둘레 3×3 평균에서 다섯 배 떼어 놓아 능선을 1 LED 선으로 세우자 촘촘함은 0.24 로 올랐지만 모양 바뀜이 0.24 에서 0.215 로 내려 칸 아래로 빠졌어요. 경계가 출렁이는 폭을 0.25 에서 0.30 으로 키워 모양 바뀜을 0.262 로 되돌렸어요. 날카롭게 세우는 셈은 촘촘함을 올리는 만큼 모양 바뀜을 깎았어요. 남은 열아홉 가운데 열셋은 촘촘함 축이 든 칸이고, 여섯은 복잡도가 2.173 을 넘어야 하는 칸이에요. +49판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로이고, 398개에서도 지도 21장이 다 `none` 이에요. 주제 1 은 48판까지의 388개에 남은 둘뿐인 칸 열아홉 가운데 촘촘함 × 모양 바뀜 2,4 와 촘촘함 × 채움 4,1 을 겨눠서, 그런 칸이 398개에서 열일곱이에요. 촘촘함 × 모양 바뀜 2,4 는 이웃 차이의 평균이 0.082~0.189 이면서 2~8초에 블록 평균이 0.331 넘게 바뀌어야 하는 칸이에요. Dürer's Unfolding 의 첫 판은 모양 바뀜이 0.409 로 이미 칸 안이었지만, 닫힌 입체가 창의 대부분을 차지해서 촘촘함이 0.039 였어요. 면에 판화처럼 빗금을 치고, 빗금을 경첩에 45도로 눕히고, 그물을 3.4초까지 펼친 채 두어서 0.113 에 섰어요. 빗금은 밝기를 덜어 내는 만큼 촘촘함을 올렸고, 45도로 눕히자 덜어 낸 밝기에 대한 촘촘함이 1.1 배에서 1.4 배가 됐어요. 모양 바뀜 0.365 는 두 입체 사이의 어두운 순간이 정해서, 펼친 그물의 평균 밝기와 같아요. Polytope Slices 와 같은 셈이에요. 촘촘함 × 채움 4,1 은 이웃 차이의 평균이 0.297 을 넘으면서 켜진 LED 가 30.2% 아래인 칸이에요. 켜진 LED 가 옆으로 붙어 있으면 촘촘함이 켜진 몫을 넘기 어려워요. Curlicue 는 켜진 LED 를 다 체커판의 한 색 위에 두어서 어느 두 LED 도 옆으로 닿지 않아요. 같은 점을 그냥 격자에 찍으면 촘촘함이 켜진 몫의 0.6 배쯤이었고, 체커판에서는 2.0 배쯤이었어요. 한쪽 합만 그리면 켜진 몫이 0.12~0.15 에 머물러서 대칭 합으로 바꾸고, 새로 자란 반쪽을 대각선 두 LED 펜으로 그어 촘촘함 0.335, 채움 0.168 이 됐어요. 길 전체를 그 펜으로 그으면 0.360 이라 축 끝의 Rule Scroll(0.375)에 너무 가까웠어요. 그 끝이 0.42 넘게 밀리면 Curlicue 가 셋째 열로 넘어가요. 남은 열일곱 가운데 열하나는 촘촘함 축이 든 칸이고, 여섯은 복잡도가 2.173 을 넘어야 하는 칸이에요. + 23판부터 28판까지는 `*` 가 붙은 칸이 없었어요. 22판까지 남은 두 칸, 촘촘함 × 모양 바뀜 3,4 와 4,3 에 23판의 Bait Ball 과 Sequins 가 섰어요. 하나 남은 빈 칸이던 복잡도 × 채움 4,3 에는 24판의 Waggle Dance 가 섰어요. 복잡도가 2.173 을 넘고 채움이 0.558~0.815 인 자리예요. 복잡도 축 끝의 Contour Map(2.735)을 빼고 세면 차는 칸이라 `*` 가 붙지 않았고, 그 아래 복잡도 2.4~2.6 에 선 Woodpecker, Dripstone, Agate, Guilloché 는 채움이 다 0.93 을 넘었어요. 이제 지도 21장의 칸이 다 차서 `--gaps` 는 모든 줄에 `none` 을 찍어요. 주제 1 은 축 끝의 패턴 하나가 축을 늘려 새 빈 칸이 날 때까지 고를 칸이 없어요. Waggle Dance 에서 어려운 쪽은 채움이었어요. 벌집은 꺼진 LED 가 거의 없는 그림이라, 빈 방 바닥과 봉한 방 사이 이음매를 24 아래로 눌러 채움 0.732 를 냈어요. 복잡도는 켜진 LED 의 세 채널을 따로 흔드는 낟알로 쉽게 올라서, 첫 판은 오히려 2.766 으로 축 끝을 넘었어요. 25판도 기준 148개의 21장이 다 `none` 이라 주제 1 은 겨눌 칸이 없었어요. 그 대신 패턴이 하나만 선 칸을 셌어요. 148개에서 49칸이었고, 남은 자리로 짠 Penrose 가 그 가운데 여섯 칸에 둘째로 서서 158개에서는 43칸이에요. Penrose 가 겨눈 칸은 촘촘함 × 색 3,4 예요. 촘촘함 0.195~0.299 에 색 0.509 위인 자리이고, 4판의 Jacquard Loom 혼자 서 있었어요. Penrose 는 촘촘함 0.264, 색 0.583 으로 거기 섰어요. 같은 값으로 움직임 × 촘촘함 4,3 과 촘촘함 × 채움 3,3 의 Moire Rings, 촘촘함 × 복잡도 3,2 의 Peg Light, 복잡도 × 색 2,4 의 Gear Train, 채움 × 색 3,4 의 Marbling 곁에도 섰어요. 1 LED 어두운 선으로 나눈 작은 타일에 선명한 색을 여럿 두면 촘촘함과 색이 함께 높은 자리에 닿아요. 26판도 기준 158개의 21장이 다 `none` 이었어요. 패턴이 하나뿐인 칸 43 가운데 넷을 겨눴어요. Dark Field 는 검은 바탕 위의 흩은 빛으로 채움 0.263 에 촘촘함 0.222, 복잡도 2.081 이라, Foxfire 혼자이던 복잡도 × 채움 3,1 과 Peg Light 혼자이던 촘촘함 × 채움 3,1 에 씨앗 여덟이 다 들어요. Origami 는 어두운 탁자 위 종이 한 장이라 채움 0.264 이고, 2초의 정사각형이 8초에 좁은 학 기본형이 되어 모양 바뀜 0.255 로 Domino Run 혼자이던 채움 × 모양 바뀜 1,3 에 서요. Lichtenberg 는 Rabbit Ears 혼자이던 복잡도 × 모양 바뀜 4,3 에 씨앗 여덟 가운데 일곱이 들어요. 네 칸 말고는 바뀐 칸이 없어서, 168개에서 패턴이 하나뿐인 칸은 39칸이에요. 27판도 기준 168개의 21장이 다 `none` 이었고, 39칸 가운데 둘을 겨눴어요. Mitosis 는 채움 0.263, 색 0.540 으로 Neon Alley 곁의 채움 × 색 1,4 에 서요. 판의 4분의 1만 켜는 그림에서 색 0.5 를 넘기려면 켜진 LED 가 거의 다 순색 최대 밝기여야 해서, 형광의 번짐은 셈에 들지 않는 21/255 아래로 눌렀어요. Lava Lake 는 복잡도 2.222, 채움 0.271 로 Moth Lamp 곁의 복잡도 × 채움 4,1 에 서요. 복잡도는 프레임 바이트 값의 엔트로피를 거의 그대로 따라서, 어두운 지각의 결을 채널마다 0~24 전부로 넓히자 1.866 이 2.010 이 됐어요. 붉은 용암은 켜진 LED 하나에 24 를 넘는 바이트가 빨강 하나뿐이라, 이음매를 늘리기보다 주황 쪽으로 데우는 편이 채움을 덜 쓰고 복잡도를 벌었어요. 색 축의 아래 끝은 Cloud Chamber 의 0.052, 모양 바뀜 축의 아래 끝은 Climbing Wall 의 0.021 로 옮겨졌지만 혼자 서는 칸은 그대로라서, 178개에서 패턴이 하나뿐인 칸은 37칸이에요. 28판도 기준 178개의 21장이 다 `none` 이었고, 37칸 가운데 둘을 겨눴어요. Aztec Diamond 는 촘촘함 0.350, 색 0.640 으로 Millefiori 곁의 촘촘함 × 색 4,4 에 서요. 촘촘함은 가장 밝은 채널로 재서 네 색 도미노가 맞닿아도 턱이 없었고, LED 에 묶은 체커판이 그 턱을 줬어요. Stadium Billiard 는 움직임 2.362, 채움 0.186 으로 Warp 곁의 움직임 × 채움 4,1 에 서요. 공 이백 개가 1초에 125 LED 를 가면서 짧은 자취만 남겨요. 겨누지 않은 다섯 칸에도 둘째가 들어서 188개에서 패턴이 하나뿐인 칸은 30칸이에요. 29판도 기준 188개의 21장이 다 `none` 이었고, 30칸 가운데 둘을 겨눴어요. 28판이 모듈러 형식의 등고선으로 겨눠 보라던 가장자리 × 움직임 1,4(Mandelbrot 혼자)는 J-Invariant 가 움직임 0.372 로 모자랐고, Hopf Fibration 이 가장자리 0.207, 움직임 1.607 로 섰어요. 원 일흔 개가 저마다 1초에 몇 LED 씩 쓸고 가서 움직임이 높고, 원마다 옅은 빛 번짐을 둬서 가장자리가 낮아요. J-Invariant 는 복잡도 2.693, 색 0.400 으로 Spectrogram 곁의 복잡도 × 색 4,3 에 서요. 같은 판의 Cream Swirl(0.028)과 Serum Gel(0.079)이 가장자리 축의 아래 끝을 Fire 의 0.120 에서 내려서, 가장자리 칸 경계가 0.305, 0.560, 0.815 에서 0.232, 0.514, 0.796 으로 옮겨졌어요. 그래서 198개에서는 가장자리 × 촘촘함 1,2 가 23판 뒤로 처음 `*` 가 붙은 빈 칸이에요. 가장자리 0.232 아래에 촘촘함 0.091~0.195 인 자리라, 번지는 바탕 위에 잔 결이 고르게 깔린 그림이 들어설 수 있어요. 같은 지도의 1,3 과 1,4, 가장자리 × 채움 1,1 도 비었지만 끝의 패턴 하나를 빼면 차서 `*` 가 없어요. 198개에서 패턴이 하나뿐인 칸은 24칸이에요. 촘촘함이 0.299 를 넘는 패턴이 20판에서 셋 늘었고, 셋 다 그 값을 다른 길로 냈어요. Agate 는 1 LED 띠를 벽까지의 거리로 그어서, 축에 나란한 띠는 이웃 쌍의 절반에서만 차이가 나고 대각선 띠는 70% 쯤에서 나요. 방향이 고르게 섞이면 띠 너비 0.85 LED 언저리가 가장 높았어요. Millefiori 는 촘촘함을 가장 밝은 채널로 재서 코발트와 카나리아 노랑이 맞닿아도 턱이 0 이었어요. 고리마다 밝은 유리와 그 유리를 어둡게 뽑은 유리를 번갈아 넣어야 했어요. Sunglint 는 반짝임을 물결 띠마다 체커판의 한쪽 칸에만 둬서 반짝임끼리 닿지 않게 했어요. 셋 다 채움이 0.85 를 넘어서, 촘촘함 × 채움 4,4 는 꺼진 LED 없이도 닿는 칸이었어요. 21판의 Sandpile 이 일곱째예요. 칸 하나가 LED 하나이고 알 셋 옆의 알 둘이 가장 흔한 이웃 쌍이라, 밝기를 0, 2, 1, 3 차례로 올려 더미 안의 촘촘함이 0.40 언저리예요. 판의 75% 는 덮여야 0.3 을 넘어요. 22판의 Pin Art 와 Night Flight 가 여덟째와 아홉째예요. Pin Art 는 핀과 틈의 체커 결이라 이웃 쌍이 모두 핀과 틈의 쌍이고, Night Flight 는 Pancakes 의 체커 결에 1 LED 가로등을 LED 의 12.7% 만큼 얹어서 가장자리 0.258 에 촘촘함 0.328 이에요. 촘촘함과 가장자리는 가장 밝은 채널로 재서, 색을 섞는 자리마다 섞은 뒤 가장 밝은 채널을 다시 맞춰야 했어요. 땅과 강물을 섞은 자리에서 가장 밝은 채널이 0.68 까지 내려가 체커 결의 37 이 41~60 으로 넘어갔고, 그대로 둔 판은 가장자리가 0.05 높았어요. 23판의 Sequins 가 열째예요. 체커 결 없이, 가로 5 LED 와 세로 3 LED 간격으로 엇갈려 단 스팽글의 빛점과 그림자 줄로 0.327 을 냈어요. 촘촘함은 무늬의 모양보다 턱의 크기를 따라가서, 스팽글 몸통을 0.8 에서 0.6 으로 어둡게 하자 은빛 면이 0.339 에서 0.311 로 내려갔어요. 26판의 Lichtenberg 는 0.2 를 넘긴 판을 버렸어요. 판 어디서나 돋는 1 LED 잔가지로 0.248 을 냈더니 검은 직교 그물이 나무판을 덮어 미로로 보였어요. 잔가지를 탄 줄기에만 달아 빈 나무를 42% 남기자 0.130 이었어요. 촘촘함은 판 전체 이웃 쌍의 평균이라, 빈 나무가 넓게 남는 그림은 줄기 둘레가 아무리 잘아도 평균이 낮아요. 같은 판의 Dark Field 는 검은 바탕에 세균 몇백과 밝은 테두리를 판 곳곳에 흩어 0.222 를 냈어요. @@ -2538,7 +2599,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ### 아직 없는 갈래 -`families.json` 의 쉰여섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 과 31판의 Boy's Surface, 33판의 Borromean Rings, 34판의 Lakes of Wada, 36판의 Heawood Torus, 38판의 Sperner's Lemma, 39판의 Borsuk-Ulam, 44판의 Ham Sandwich, 46판의 Reeb Graph, 48판의 Möbius Energy 예요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 세잎 매듭은 48판의 Möbius Energy 가 처음 세웠어요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 30판의 Swatch Row, Pearl Sheen, Bi-Phase, Satin Fold 가 그 갈래에 넷을 더했고, 31판의 Lip Gloss, Foam Lather, Setting Mist, Sponge Blend 가 넷을 더 더해 열둘이에요. 32판의 Cushion Mesh, Clay Mask, Sugar Scrub, Chrome Powder 가 넷을 더해 열여섯이고, 33판의 Cleansing Balm, Gold Leaf, Essence Drop, Peel-Off 가 넷을 더해 스물이에요. 34판의 Cat Eye, Velvet Nap, Mood Tint, Hair Gloss 가 넷을 더해 스물넷이고, 35판의 Bath Fizz, Toner Pad, Sheet Mask, Gilded Cream 이 넷을 더해 스물여덟이고, 36판의 Lotion Ribbon, Shimmer Oil, Blooming Gel, Whipped Soap 이 넷을 더해 서른둘이에요. 38판의 Milk Ring, Couette Shimmer, Photochromic, Serum Fingers 가 넷을 더해 서른여섯이에요. 39판의 Soap Film, Blotting Paper, Serum Drip, Crackle Polish 가 넷을 더해, 37판의 넷까지 마흔넷이에요. 41판은 할로윈 갈래를 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Jack-o'-Lantern, Sheet Ghosts, Spider Drop, Cauldron, Eyes in the Dark 다섯이 들어 있어요. 28판이 연 수론 갈래는 Ford Circles 와 30판의 Three Gaps, 31판의 Gaussian Moat, 32판의 Gilbreath Table, 33판의 Goldbach Comet, 35판의 Kolakoski, 36판의 Elliptic Chords, 39판의 Explicit Formula, 44판의 Bulgarian Solitaire, 48판의 Chebyshev's Bias 예요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing, 32판의 Arnold Tongues, 35판의 Chimera State, 38판의 Infinity Series, 41판의 Siteswap, 44판의 Kac Ring, 46판의 Stochastic Resonance 가 섰어요. +`families.json` 의 쉰여섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 과 31판의 Boy's Surface, 33판의 Borromean Rings, 34판의 Lakes of Wada, 36판의 Heawood Torus, 38판의 Sperner's Lemma, 39판의 Borsuk-Ulam, 44판의 Ham Sandwich, 46판의 Reeb Graph, 48판의 Möbius Energy, 49판의 Square Peg 예요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 세잎 매듭은 48판의 Möbius Energy 가 처음 세웠어요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 30판의 Swatch Row, Pearl Sheen, Bi-Phase, Satin Fold 가 그 갈래에 넷을 더했고, 31판의 Lip Gloss, Foam Lather, Setting Mist, Sponge Blend 가 넷을 더 더해 열둘이에요. 32판의 Cushion Mesh, Clay Mask, Sugar Scrub, Chrome Powder 가 넷을 더해 열여섯이고, 33판의 Cleansing Balm, Gold Leaf, Essence Drop, Peel-Off 가 넷을 더해 스물이에요. 34판의 Cat Eye, Velvet Nap, Mood Tint, Hair Gloss 가 넷을 더해 스물넷이고, 35판의 Bath Fizz, Toner Pad, Sheet Mask, Gilded Cream 이 넷을 더해 스물여덟이고, 36판의 Lotion Ribbon, Shimmer Oil, Blooming Gel, Whipped Soap 이 넷을 더해 서른둘이에요. 38판의 Milk Ring, Couette Shimmer, Photochromic, Serum Fingers 가 넷을 더해 서른여섯이에요. 39판의 Soap Film, Blotting Paper, Serum Drip, Crackle Polish 가 넷을 더해, 37판의 넷까지 마흔넷이에요. 41판은 할로윈 갈래를 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Jack-o'-Lantern, Sheet Ghosts, Spider Drop, Cauldron, Eyes in the Dark 다섯이 들어 있어요. 28판이 연 수론 갈래는 Ford Circles 와 30판의 Three Gaps, 31판의 Gaussian Moat, 32판의 Gilbreath Table, 33판의 Goldbach Comet, 35판의 Kolakoski, 36판의 Elliptic Chords, 39판의 Explicit Formula, 44판의 Bulgarian Solitaire, 48판의 Chebyshev's Bias, 49판의 Curlicue 예요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing, 32판의 Arnold Tongues, 35판의 Chimera State, 38판의 Infinity Series, 41판의 Siteswap, 44판의 Kac Ring, 46판의 Stochastic Resonance 가 섰어요. ### 멋진 것 더 멋지게 @@ -2578,6 +2639,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 48판의 열 개는 가장 낮은 노브가 Barn Owl 의 K3 Hunger 로 네 자리로 0.0410 이고, Grim Reaper 의 K4 Fog 0.0460 과 K3 Graves 0.0466 이 그다음이에요. 노브 마흔 개가 다 0.04 를 넘어요. Barn Owl 의 Hunger 는 사냥 사이의 기다림을 바꾸는 노브라서, 첫 사냥을 2.4초, 1.6초, 0.7초로 당겨 3초 창 안에 들게 해야 셈에 들었어요. 47판이 본 대로 창 뒤의 일을 바꾸는 노브는 0 으로 셈해져요. Trick or Treat 의 K3 도 무리 수였을 때 둘째 무리가 9초쯤에야 와서 0.0000 이었어요. Fractal Percolation 의 K3 은 셋업에서 집합을 다 깎아 둔 첫 판에서 0.000 이었어요. 노브를 읽기 전에 그림이 정해지면 노브가 바꿀 것이 없어요. Grim Reaper 의 Graves 는 한 줄의 무덤 띠가 판의 18% 뿐이라 0.02~0.03 이었고, 무덤을 여섯씩 언덕을 따라 쌓자 올랐어요. +49판의 열 개는 가장 낮은 노브가 Music Box 의 K4 Wind 로 네 자리로 0.0453 이고, Gargoyle 의 K2 Speed 0.0531 과 Party Lights 의 K2 Speed 0.0544 가 그다음이에요. 노브 마흔 개가 다 0.045 를 넘어요. Wind 는 태엽을 감은 만큼이라 노브를 재는 2~3초 창 뒤의 일을 바꾸는 노브였어요. 가장 낮은 자리가 2.6초쯤 풀려 멈추게 2.6 + 16.4√Wind 초로 걸어서 셈에 들었어요. Kempe Chains 의 넷째 노브도 사슬 빛을 바꾸는 Glow 로는 그 창에 사슬이 켜져 있을 때만 보여서, 첫 프레임부터 국경을 다 바꾸는 Sizes 로 바꿨어요. Werewolf 의 Clouds 는 첫 판에서 0.0088 로 검사에 걸렸어요. 구름이 2초 전에 달을 다 지나가서 창에 남은 구름이 없었고, 뒤따르는 구름 셋을 4초까지 달 앞에 두자 0.069 가 됐어요. 노브가 바꾸는 넓이를 그림자로 늘리기도 했어요. Pit and Pendulum 의 Speed 와 Descent 는 칼날만 바꿔서 0.04 였고, 횃불을 내려 칼날 그림자를 밝은 벽에 크게 던지자 0.098 과 0.076 이 됐어요. + Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. ### 세로 64×128 @@ -2622,9 +2685,11 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 48판에서는 Flashing Ratchet 과 Barn Owl 이 128줄을 써요. Flashing Ratchet 은 16 LED 짜리 톱니 여덟이 판을 위아래로 다 채우고 입자가 위아래로 이어져서 `thirds` 가 91/93/91 이에요. 바탕까지 칠하는 그림이라 2~8초에 밝기가 24/255 넘게 바뀐 LED 로 세면 100/100/99.8 이에요. Barn Owl 은 나무가 풀밭에서 꼭대기까지 서서 92/89/76 이고, 나무를 빼도 하늘이 켜져 100/100/84 예요. 바뀐 LED 로 세면 46.3/78.1/66.7 이고, 올빼미는 2~8초에 25줄에서 122줄까지 다녀요. Chebyshev's Bias(100/100/100)와 Grim Reaper(98/99/98), Headless Rider(99/72/73), Bone Pile(86/91/91), Trick or Treat(87/84/89), Fractal Percolation(73/80/75), Double Gyre(66/66/66)도 128줄을 다 쓰지만 주제 4 로 세지 않았어요. Möbius Energy 는 검은 판에 관 하나라 17/23/10 이에요. +49판에서는 Gargoyle 과 Pit and Pendulum 이 128줄을 써요. Gargoyle 은 성당과 하늘이 판을 덮어 `thirds` 가 97/92/91 이에요. 성당과 하늘, 길을 지우고 가고일과 물줄기, 비, 웅덩이만 그리면 22/8/14 예요. 첫 판은 그 값이 20.8/5.0/11.1 이었고 Speed 0.2 에서는 가운데가 2.5 였어요. 아래로 꺾이는 물줄기는 가로 폭을 지켜서, 입에서 굵게 내보내도 가운데 3분의 1 에서는 1.5~2 LED 였어요. 출발점을 옆으로 ±1 LED 퍼뜨리고 옆 빠르기의 흔들림을 0.8 에서 2.2 LED/초로 키우자 30줄에서 3.0, 100줄에서 3.6 LED 가 됐어요. 비는 패널 밖에서 생겨 떨어지던 것을 비가 기운 쪽에서 생기게 고쳐 70 방울에서 90 방울이 됐어요. Pit and Pendulum 은 67/95/53 이고, 벽과 바닥을 지우고 막대와 칼날, 돌 침대, 쥐, 횃불 빛만 그리면 10/23/31 이에요. 칼날 그림자를 밝은 벽에 던지려고 횃불을 42줄에서 52줄로 내렸더니 지운 그림의 위 3분의 1 이 7 로 내려가서, 권양기 드럼과 두 LED 사슬로 10 에 되돌렸어요. Kempe Chains(93/92/93)와 Party Lights(54/58/47), Werewolf(53/86/48), Music Box(45/80/91), Square Peg(35/82/40), Dürer's Unfolding(32/45/20), Tennis Racket(24/28/67), Curlicue(14/20/14)도 세 칸이 다 5 를 넘지만 주제 4 로 세지 않았어요. + ### 낯선 수학 -주제 5 로 쓴 수학과 다음에 해 볼 수학이에요. 28판이 처음 열 개를 썼어요. 슈람-뢰브너 진화(Loewner Trace), 포드 원(Ford Circles), 털 난 공 정리(Hairy Ball), 아즈텍 다이아몬드와 북극권(Aztec Diamond), 부니모비치 경기장(Stadium Billiard), 라우지 프랙탈(Rauzy Fractal), 고셋의 4₂₁ 다면체와 E8(Gosset Polytope), 콘웨이의 보고 말하기 수열(Look and Say), 위토프 게임의 그런디 값(Wythoff Nim), 쌍곡 평면의 띠 모형(Hyperbolic Band)이에요. 29판은 여섯을 더했어요. 클라인의 j 불변량(J-Invariant), 호프 올뭉치(Hopf Fibration), 다이슨 브라운 운동(Dyson Gas), 가케야 바늘 문제의 페론 나무(Perron Tree), 임계선 위의 리만 제타 함수(Zeta Loops), 길버트-섀넌-리즈 리플과 베이어-디아코니스의 일곱 번(Seven Shuffles)이에요. 30판도 여섯을 더했어요. 세 간격 정리(Three Gaps), 호프스태터 나비와 TKNN 정수(Hofstadter Butterfly), 노이만의 외부 당구(Outer Billiards), 볼드의 탄도 퇴적과 KPZ 앞면(Ballistic Deposition), 아르놀트의 미앤더(Meander), 브로드벤트와 해머슬리의 스미기(Percolation)예요. 31판도 여섯을 더했어요. 마스킷 조각의 클라인 군 극한 집합(Indra's Pearls), 리틀우드 다항식의 근(Littlewood Roots), 고든의 가우스 해자(Gaussian Moat), 아르놀트의 고양이 사상(Cat Map), 자부스키와 크루스칼의 KdV 솔리톤(KdV Solitons), 보이 곡면(Boy's Surface)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드, 모듈러 형식, 호프 올뭉치, 세 간격 정리, 외부 당구 여섯이었고, 31판이 리틀우드 다항식의 근과 클라인 군의 극한 집합을 써서 여덟을 다 썼어요. 32판도 여섯을 더했어요. 탤벗 효과와 양자 카펫(Talbot Carpet), 길브레스 추측의 표(Gilbreath Table), 원 사상의 아르놀트 혀(Arnold Tongues), 콘웨이와 라딘의 핀휠 타일링(Pinwheel Tiling), 윌킨슨과 윌렘센의 침입 스미기(Invasion Percolation), 울람의 문제와 해머슬리 과정(Hammersley Lines)이에요. 33판도 여섯을 더했어요. 에인절, 홀로이드, 로믹, 비라그의 무작위 정렬 네트워크(Sorting Network), 베르트랑의 역설(Bertrand Paradox), 펜로즈의 비출 수 없는 방(Penrose Room), 당들랭 구(Dandelin Spheres), 테이트의 보로메오 고리(Borromean Rings), 골드바흐 혜성(Goldbach Comet)이에요. 34판도 여섯을 더했어요. 톰슨 문제와 오일러가 남기는 다섯 이웃 열둘(Thomson Problem), 켈빈의 배 물결(Kelvin Wake), 요네야마가 낸 와다의 호수(Lakes of Wada), 샤이데거의 강 그물(Scheidegger Rivers), 모저의 소파 문제와 해머슬리의 소파(Moving Sofa), 크누스의 춤추는 링크로 푼 펜토미노 덮기(Pentomino Cover)예요. 35판도 여섯을 더했어요. 뢸로의 폭이 일정한 곡선(Constant Width), 렌니의 주차 문제(Rényi Parking), 카리의 비주기 왕 타일(Kari-Culik Tiles), 콜라코스키 수열(Kolakoski), 쿠라모토와 바톡토흐의 키메라 상태(Chimera State), 주콥스키 날개(Joukowsky Airfoil)예요. 36판도 여섯을 더했어요. 그레이슨의 정리가 말하는 곡선 단축 흐름(Curve Shortening), 헤우드의 일곱 색 토러스 지도(Heawood Torus), 슈바르츠 랜턴(Schwarz Lantern), 윌브러햄과 깁스의 현상(Gibbs Phenomenon), 타원 곡선의 현과 접선(Elliptic Chords), 지먼의 파국 기계(Catastrophe Machine)예요. 37판도 여섯을 더했어요. 포천의 쓸기 선 알고리즘(Fortune's Sweep), 앙투안의 목걸이(Antoine's Necklace), 카르만 소용돌이 길의 안정성(Kármán Street), 단단한 원판의 헥사틱 상(Hexatic Phase), 존슨-멜 쪽매와 아브라미 법칙(Johnson-Mehl), 쿠라모토-시바신스키 방정식(Kuramoto-Sivashinsky)이에요. 38판도 여섯을 더했어요. 앤더슨 국소화(Anderson Localization), 무어가 찾고 Chenciner 와 Montgomery 가 증명한 세 몸 문제의 8자 해(Figure-Eight Orbit), 예센과 빈트너, 에르되시의 베르누이 합성곱(Bernoulli Convolutions), 브라우어르의 고정점 정리를 증명하는 슈페르너 보조정리(Sperner's Lemma), 에겐베르거와 폴리아의 항아리(Pólya Urn), 뇌르고르의 무한 수열(Infinity Series)이에요. 39판도 여섯을 더했어요. 고든, 웹, 월퍼트의 등스펙트럼 북(Isospectral Drums), 폰 망골트의 꼴로 쓴 리만의 명시 공식(Explicit Formula), 요한 베르누이의 최단 강하선(Brachistochrone), 슈타이너의 포리즘(Steiner Chain), 모런 집단으로 그린 킹먼의 합류(Kingman Coalescent), 보르수크-울람 정리(Borsuk-Ulam)예요. 40판도 여섯을 더했어요. 앨더스의 브라운 연속 랜덤 나무(Continuum Random Tree), 슈뢰딩거의 수소 원자 고유 함수(Hydrogen Orbital), 젤도비치 근사의 팬케이크(Zeldovich Pancakes), 망델브로가 내다보고 롤러, 슈람, 베르너가 증명한 브라운 운동의 바깥 경계(Brownian Frontier), 복소 긴즈부르크-란다우 방정식의 결함 난류(Defect Turbulence), 루퍼트 왕자의 정육면체(Prince Rupert's Cube)예요. 41판은 다섯을 더했어요. 홀과 왜건의 현수선 길과 정다각형 바퀴(Square Wheels), 퐁슬레의 포리즘(Poncelet Porism), 슐레플리의 4차원 정다포체를 자른 단면(Polytope Slices), 클림켁과 티먼, 마그누손의 사이트스왑(Siteswap), Dudeney 의 잡화상 퍼즐과 월리스-보여이-게르비엔 정리(Hinged Dissection)예요. 42판은 다섯을 더했어요. 바른스도르프 규칙으로 도는 기사 여행(Knight's Tour), 페르미와 파스타, 울람, 칭구의 되돌아옴(FPUT Recurrence), 포슬리에-립킨 반전기(Peaucellier Linkage), 몰리의 삼등분선 정리(Morley Triangle), 랄과 마드라스, 소칼의 피벗 알고리즘으로 돌린 자기 회피 걸음(Pivot Walk)이에요. 43판은 다섯을 더했어요. 월리스 워커의 칼레이도사이클(Kaleidocycle), 쌍곡형에서 록소드롬형으로 감기는 뫼비우스 변환의 흐름(Möbius Loxodrome), 터트의 용수철 정리(Tutte Embedding), 디펀트와 크래비츠의 고리 정리를 품은 히토메자시 사시코(Hitomezashi), 룽게 현상과 체비쇼프 점(Runge Phenomenon)이에요. 44판은 다섯을 더했어요. 마크 카츠의 고리 모형(Kac Ring), 스톤과 튜키의 햄 샌드위치 정리(Ham Sandwich), 브란트의 불가리아 솔리테어(Bulgarian Solitaire), 엘스켄스와 프리시의 탄도 소멸(Ballistic Annihilation), 페레그린의 솔리톤(Peregrine Soliton)이에요. 45판은 다섯을 더했어요. 로건-셰프와 베르시크-케로프의 극한 곡선으로 다가가는 영 도형의 플랑셰렐 성장(Plancherel Growth), 도브루신 경계 조건을 건 임계 이징 모형의 SLE(3) 경계(Ising Interface), 프리예제프와 동료들의 오일러 걷개로 쌓는 로터 라우터 응집과 레빈-페레스의 스매시 합(Rotor-Router), 폴리아의 과수원 문제(Pólya's Orchard), 넬슨의 평면 색칠 문제와 아이즈벨의 일곱 색(Hadwiger-Nelson)이에요. 46판은 다섯을 더했어요. 브릴루앙의 구역(Brillouin Zones), 모스 이론과 레브의 그래프(Reeb Graph), 로렌츠 기체와 시나이의 당구(Lorentz Gas), 리언 글래스의 점무늬(Glass Patterns), 벤치와 수테라, 불피아니의 확률 공명(Stochastic Resonance)이에요. 47판은 다섯을 더했어요. 오키프와 홍, 스트로가츠의 스워멀레이터(Swarmalators), 빌스의 외로운 달리기 선수 추측(Lonely Runner), 샤츠의 올로이드와 그 전개도(Oloid), 쾨베의 원 채우기 정리와 콜린스-스티븐슨의 반복(Koebe Packing), 콘웨이의 병사와 황금비 무게(Conway's Soldiers)예요. 48판은 다섯을 더했어요. 오하라의 매듭 뫼비우스 에너지(Möbius Energy), 체비쇼프의 편향과 루빈스타인-사르낙의 밀도(Chebyshev's Bias), 섀든과 레키엔, 마스든의 이중 소용돌이 능선(Double Gyre), 아자리와 프로스트의 깜빡이는 래칫(Flashing Ratchet), 망델브로의 프랙탈 스미기(Fractal Percolation)예요. +주제 5 로 쓴 수학과 다음에 해 볼 수학이에요. 28판이 처음 열 개를 썼어요. 슈람-뢰브너 진화(Loewner Trace), 포드 원(Ford Circles), 털 난 공 정리(Hairy Ball), 아즈텍 다이아몬드와 북극권(Aztec Diamond), 부니모비치 경기장(Stadium Billiard), 라우지 프랙탈(Rauzy Fractal), 고셋의 4₂₁ 다면체와 E8(Gosset Polytope), 콘웨이의 보고 말하기 수열(Look and Say), 위토프 게임의 그런디 값(Wythoff Nim), 쌍곡 평면의 띠 모형(Hyperbolic Band)이에요. 29판은 여섯을 더했어요. 클라인의 j 불변량(J-Invariant), 호프 올뭉치(Hopf Fibration), 다이슨 브라운 운동(Dyson Gas), 가케야 바늘 문제의 페론 나무(Perron Tree), 임계선 위의 리만 제타 함수(Zeta Loops), 길버트-섀넌-리즈 리플과 베이어-디아코니스의 일곱 번(Seven Shuffles)이에요. 30판도 여섯을 더했어요. 세 간격 정리(Three Gaps), 호프스태터 나비와 TKNN 정수(Hofstadter Butterfly), 노이만의 외부 당구(Outer Billiards), 볼드의 탄도 퇴적과 KPZ 앞면(Ballistic Deposition), 아르놀트의 미앤더(Meander), 브로드벤트와 해머슬리의 스미기(Percolation)예요. 31판도 여섯을 더했어요. 마스킷 조각의 클라인 군 극한 집합(Indra's Pearls), 리틀우드 다항식의 근(Littlewood Roots), 고든의 가우스 해자(Gaussian Moat), 아르놀트의 고양이 사상(Cat Map), 자부스키와 크루스칼의 KdV 솔리톤(KdV Solitons), 보이 곡면(Boy's Surface)이에요. ROUND.md 의 「낯선 수학 예」 여덟 가운데 쓴 것은 슈람-뢰브너 진화와 아즈텍 다이아몬드, 모듈러 형식, 호프 올뭉치, 세 간격 정리, 외부 당구 여섯이었고, 31판이 리틀우드 다항식의 근과 클라인 군의 극한 집합을 써서 여덟을 다 썼어요. 32판도 여섯을 더했어요. 탤벗 효과와 양자 카펫(Talbot Carpet), 길브레스 추측의 표(Gilbreath Table), 원 사상의 아르놀트 혀(Arnold Tongues), 콘웨이와 라딘의 핀휠 타일링(Pinwheel Tiling), 윌킨슨과 윌렘센의 침입 스미기(Invasion Percolation), 울람의 문제와 해머슬리 과정(Hammersley Lines)이에요. 33판도 여섯을 더했어요. 에인절, 홀로이드, 로믹, 비라그의 무작위 정렬 네트워크(Sorting Network), 베르트랑의 역설(Bertrand Paradox), 펜로즈의 비출 수 없는 방(Penrose Room), 당들랭 구(Dandelin Spheres), 테이트의 보로메오 고리(Borromean Rings), 골드바흐 혜성(Goldbach Comet)이에요. 34판도 여섯을 더했어요. 톰슨 문제와 오일러가 남기는 다섯 이웃 열둘(Thomson Problem), 켈빈의 배 물결(Kelvin Wake), 요네야마가 낸 와다의 호수(Lakes of Wada), 샤이데거의 강 그물(Scheidegger Rivers), 모저의 소파 문제와 해머슬리의 소파(Moving Sofa), 크누스의 춤추는 링크로 푼 펜토미노 덮기(Pentomino Cover)예요. 35판도 여섯을 더했어요. 뢸로의 폭이 일정한 곡선(Constant Width), 렌니의 주차 문제(Rényi Parking), 카리의 비주기 왕 타일(Kari-Culik Tiles), 콜라코스키 수열(Kolakoski), 쿠라모토와 바톡토흐의 키메라 상태(Chimera State), 주콥스키 날개(Joukowsky Airfoil)예요. 36판도 여섯을 더했어요. 그레이슨의 정리가 말하는 곡선 단축 흐름(Curve Shortening), 헤우드의 일곱 색 토러스 지도(Heawood Torus), 슈바르츠 랜턴(Schwarz Lantern), 윌브러햄과 깁스의 현상(Gibbs Phenomenon), 타원 곡선의 현과 접선(Elliptic Chords), 지먼의 파국 기계(Catastrophe Machine)예요. 37판도 여섯을 더했어요. 포천의 쓸기 선 알고리즘(Fortune's Sweep), 앙투안의 목걸이(Antoine's Necklace), 카르만 소용돌이 길의 안정성(Kármán Street), 단단한 원판의 헥사틱 상(Hexatic Phase), 존슨-멜 쪽매와 아브라미 법칙(Johnson-Mehl), 쿠라모토-시바신스키 방정식(Kuramoto-Sivashinsky)이에요. 38판도 여섯을 더했어요. 앤더슨 국소화(Anderson Localization), 무어가 찾고 Chenciner 와 Montgomery 가 증명한 세 몸 문제의 8자 해(Figure-Eight Orbit), 예센과 빈트너, 에르되시의 베르누이 합성곱(Bernoulli Convolutions), 브라우어르의 고정점 정리를 증명하는 슈페르너 보조정리(Sperner's Lemma), 에겐베르거와 폴리아의 항아리(Pólya Urn), 뇌르고르의 무한 수열(Infinity Series)이에요. 39판도 여섯을 더했어요. 고든, 웹, 월퍼트의 등스펙트럼 북(Isospectral Drums), 폰 망골트의 꼴로 쓴 리만의 명시 공식(Explicit Formula), 요한 베르누이의 최단 강하선(Brachistochrone), 슈타이너의 포리즘(Steiner Chain), 모런 집단으로 그린 킹먼의 합류(Kingman Coalescent), 보르수크-울람 정리(Borsuk-Ulam)예요. 40판도 여섯을 더했어요. 앨더스의 브라운 연속 랜덤 나무(Continuum Random Tree), 슈뢰딩거의 수소 원자 고유 함수(Hydrogen Orbital), 젤도비치 근사의 팬케이크(Zeldovich Pancakes), 망델브로가 내다보고 롤러, 슈람, 베르너가 증명한 브라운 운동의 바깥 경계(Brownian Frontier), 복소 긴즈부르크-란다우 방정식의 결함 난류(Defect Turbulence), 루퍼트 왕자의 정육면체(Prince Rupert's Cube)예요. 41판은 다섯을 더했어요. 홀과 왜건의 현수선 길과 정다각형 바퀴(Square Wheels), 퐁슬레의 포리즘(Poncelet Porism), 슐레플리의 4차원 정다포체를 자른 단면(Polytope Slices), 클림켁과 티먼, 마그누손의 사이트스왑(Siteswap), Dudeney 의 잡화상 퍼즐과 월리스-보여이-게르비엔 정리(Hinged Dissection)예요. 42판은 다섯을 더했어요. 바른스도르프 규칙으로 도는 기사 여행(Knight's Tour), 페르미와 파스타, 울람, 칭구의 되돌아옴(FPUT Recurrence), 포슬리에-립킨 반전기(Peaucellier Linkage), 몰리의 삼등분선 정리(Morley Triangle), 랄과 마드라스, 소칼의 피벗 알고리즘으로 돌린 자기 회피 걸음(Pivot Walk)이에요. 43판은 다섯을 더했어요. 월리스 워커의 칼레이도사이클(Kaleidocycle), 쌍곡형에서 록소드롬형으로 감기는 뫼비우스 변환의 흐름(Möbius Loxodrome), 터트의 용수철 정리(Tutte Embedding), 디펀트와 크래비츠의 고리 정리를 품은 히토메자시 사시코(Hitomezashi), 룽게 현상과 체비쇼프 점(Runge Phenomenon)이에요. 44판은 다섯을 더했어요. 마크 카츠의 고리 모형(Kac Ring), 스톤과 튜키의 햄 샌드위치 정리(Ham Sandwich), 브란트의 불가리아 솔리테어(Bulgarian Solitaire), 엘스켄스와 프리시의 탄도 소멸(Ballistic Annihilation), 페레그린의 솔리톤(Peregrine Soliton)이에요. 45판은 다섯을 더했어요. 로건-셰프와 베르시크-케로프의 극한 곡선으로 다가가는 영 도형의 플랑셰렐 성장(Plancherel Growth), 도브루신 경계 조건을 건 임계 이징 모형의 SLE(3) 경계(Ising Interface), 프리예제프와 동료들의 오일러 걷개로 쌓는 로터 라우터 응집과 레빈-페레스의 스매시 합(Rotor-Router), 폴리아의 과수원 문제(Pólya's Orchard), 넬슨의 평면 색칠 문제와 아이즈벨의 일곱 색(Hadwiger-Nelson)이에요. 46판은 다섯을 더했어요. 브릴루앙의 구역(Brillouin Zones), 모스 이론과 레브의 그래프(Reeb Graph), 로렌츠 기체와 시나이의 당구(Lorentz Gas), 리언 글래스의 점무늬(Glass Patterns), 벤치와 수테라, 불피아니의 확률 공명(Stochastic Resonance)이에요. 47판은 다섯을 더했어요. 오키프와 홍, 스트로가츠의 스워멀레이터(Swarmalators), 빌스의 외로운 달리기 선수 추측(Lonely Runner), 샤츠의 올로이드와 그 전개도(Oloid), 쾨베의 원 채우기 정리와 콜린스-스티븐슨의 반복(Koebe Packing), 콘웨이의 병사와 황금비 무게(Conway's Soldiers)예요. 48판은 다섯을 더했어요. 오하라의 매듭 뫼비우스 에너지(Möbius Energy), 체비쇼프의 편향과 루빈스타인-사르낙의 밀도(Chebyshev's Bias), 섀든과 레키엔, 마스든의 이중 소용돌이 능선(Double Gyre), 아자리와 프로스트의 깜빡이는 래칫(Flashing Ratchet), 망델브로의 프랙탈 스미기(Fractal Percolation)예요. 49판은 다섯을 더했어요. 뒤러의 전개 문제와 셰퍼드의 추측(Dürer's Unfolding), 베리와 골드버그가 재규격화한 세타 합의 꼬부랑 곡선(Curlicue), 오일러 방정식과 푸앵소의 폴호드로 본 테니스 라켓 정리(Tennis Racket), 켐프의 사슬과 네 색 정리(Kempe Chains), 퇴플리츠의 내접 정사각형 문제와 그린-로브의 직사각형(Square Peg)이에요. 목록 밖에서 다음에 해 볼 것이에요. 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. 수론 갈래는 32판의 Gilbreath Table 까지 넷이에요. 길브레스 표는 소수 말고 다른 수열로도 세울 수 있어요. 오들리코(1993)에 따르면 2 다음에 간격이 작고 고르게 흩어진 홀수가 오는 수열에서는 1 의 열이 서고, 간격이 크면 깨져요. 간격의 크기를 노브로 걸면 1 의 열이 무너지는 자리가 보여요. 해머슬리 과정의 점 대신 격자 칸마다 지수 분포 무게를 두면 마지막 통과 스미기(로스트, 1981)가 되고, 극한 모양이 쌍곡선에서 (√x+√y)² 의 경계로 바뀌어요. @@ -2670,6 +2735,8 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 48판에서 본 것이에요. 정리를 셈으로 지키려면 셈이 만든 치우침부터 막아야 했어요. Flashing Ratchet 의 첫 오일러 걸음은 퍼텐셜을 켜 둔 채 짐 없이 두어도 위로 흐르는 흐름을 지어냈고, 4096 칸 잡음 표의 치우침도 그랬어요. 래칫은 작은 치우침을 한쪽으로 모으는 장치라서 셈의 치우침이 그대로 오름으로 보였어요. 메트로폴리스 시험과 값마다 음수를 짝지은 잡음 표로 막았어요. Möbius Energy 는 에너지만으로 흐르게 하면 뫼비우스 불변성을 따라 미끄러져 꼴을 잃어서, 굽힘 항을 더해야 매듭이 섰어요. Double Gyre 의 뒤로 장은 앞으로 장을 180도 돌린 것인데, 위아래로만 뒤집은 첫 판은 직접 적분과 상관이 0.2 였어요. 정리가 말하지 않은 수도 보였어요. Chebyshev's Bias 의 43 은 1 의 제곱근이 둘인 법인데 로그 눈금의 42% 에서 잉여 쪽이 앞섰어요. Fractal Percolation 의 건너기 확률은 이 LED 판에서 p 0.89 와 0.90 사이에서 0.67 쯤으로 뛰었어요. 겹겹이 쪼갠 것은 쪼개는 순간에만 보였어요. p 0.88 에서 LED 의 13% 가 낱 LED 구멍이라, 다 깎은 판은 잡음처럼 읽혔어요. 퍼지는 것과 오르는 것이 비슷한 빠르기면 띠를 물들여 보일 수 없었어요. Flashing Ratchet 은 오름 0.13 톱니/초와 퍼짐 0.12 톱니²/초가 비슷해서, 한 골을 금빛으로 표지해 평균만 따라갔어요. +49판에서 본 것이에요. 정리가 수를 못 박는 대상은 이번에도 셈으로 지켜봤어요. Dürer's Unfolding 은 무작위 그물 120,000개가 하나도 겹치지 않았고, 그물의 틈이 데카르트 정리의 720° 를 8.0e-13° 안에서 맞췄어요. Kempe Chains 는 259,200 프레임이 다 네 색이었고, Square Peg 는 1,800 프레임 가운데 1,799 에서 정사각형 수가 홀수였어요. 그림이 기억하는 것은 정리가 말하는 그래프가 아니었어요. Kempe Chains 는 0.4초 기억한 국경까지 넣은 그래프에서 11번 막혔고, 그 그래프는 그 순간 네 색으로 칠해지지 않았어요. 그려진 지도만 평면 그래프였어요. 찾는 셈이 놓친 것은 정리가 틀린 것처럼 보였어요. Square Peg 의 첫 판은 600 프레임 가운데 479 만 홀수였는데, 놓친 정사각형의 92% 가 접힌 옆구리 속의 3 LED 아래 정사각형이었어요. 닫힌 꼴의 해가 셈을 재 주었어요. Tennis Racket 의 뒤집힘 사이 시간은 야코비 타원 함수의 2K(k) 로 낸 값과 상대 오차 1e-8~3e-7 안에서 같았어요. 무리수를 노브로 미끄러뜨리면 부분합이 흐트러졌어요. Curlicue 는 x 를 1e-6 만 바꿔도 먼 걸음의 위상이 π n² dx 만큼 움직여서, x 를 움직이는 대신 재규격화 사슬을 걸었어요. 이차 무리수의 사슬은 1e-9 안에서 되돌아왔고, e 와 π 의 사슬은 1 의 첨점 가까이 지나서 틈이 11~16% 였어요. 이웃 차이를 재는 축에서는 LED 의 자리가 셈을 정했어요. 같은 점을 그냥 격자에 찍으면 촘촘함이 켜진 몫의 0.6 배였고, 체커판의 한 색에만 찍자 2.0 배였어요. + 목록 밖에서 31판 뒤에 해 볼 것이에요. 모자 타일(스미스, 마이어스, 캐플런, 굿먼-스트라우스, 2023)은 한 가지 타일로만 평면을 비주기로 덮고, 뒤집어 놓인 타일이 판에 드문드문 흩어져요. 페르미-파스타-울람-칭구의 되돌아옴(1955)은 KdV Solitons 와 같은 뿌리라 같은 판에 두지 않아요. 목록 밖에서 33판 뒤에 해 볼 것이에요. 뷔퐁의 바늘(1777)은 Bertrand Paradox 와 같은 꼴로, 금을 넘는 바늘의 몫이 2/π 로 모이는 막대와 함께 세울 수 있어요. 토카르스키의 방(1995)은 거울로 된 다각형 방에서 한 점의 빛이 다른 한 점에만 닿지 못하는 방이라 Penrose Room 의 광선 추적을 그대로 써요. 프리드먼과 스코라(1987)는 둥근 원 셋으로는 보로메오 고리를 만들 수 없다는 것을 보였는데, 원을 밀어 넣을 때 어디서 부딪히는지가 그림이 돼요. 정렬 네트워크의 시간 t 의 순열을 점으로 찍으면 대각선에서 원판을 지나 반대 대각선으로 도는 점 구름이 돼요. 그 원판이 2007년 논문이 내다본 아르키메데스 법칙이에요. 간격이 2k 인 소수 쌍의 수는 Goldbach Comet 과 같은 FFT 로 세고, k 마다 하디-리틀우드 인수로 갈리는 줄기 그림이 돼요. @@ -2702,6 +2769,8 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 목록 밖에서 48판 뒤에 해 볼 것이에요. 소수의 끝자리에도 편향이 있어요. 렘키 올리버와 사운다라라잔(2016)은 소수 다음 소수가 같은 끝자리로 끝나는 일이 기대보다 드물다는 것을 보였어요. 10 으로 나눈 나머지 1, 3, 7, 9 의 4×4 짝 표는 Chebyshev's Bias 의 체를 그대로 써서 셀 수 있어요. 비클리 제트(이리나 리피나와 동료들, 2007)는 사행하는 제트 둘레에 소용돌이가 줄지어 서는 흐름이라, Double Gyre 의 능선 셈으로 제트 한가운데의 넘지 못하는 벽을 그릴 수 있어요. 파인먼의 톱니와 멈춤쇠(1962)는 온도가 같으면 돌지 않고 다르면 도는 래칫이라, Flashing Ratchet 의 입자 셈에 온도 둘을 걸어 견줄 만해요. 망델브로의 곱셈 캐스케이드(1974)는 남기기 대신 무게를 곱해 다중 프랙탈 측도를 만들어서, Fractal Percolation 의 쪼개기에 밝기를 실어 볼 수 있어요. +목록 밖에서 49판 뒤에 해 볼 것이에요. 깎은 정이십면체는 무작위 모서리 전개 5,000개 가운데 마흔 몇 개가 겹쳐서, Dürer's Unfolding 의 셈으로 겹치는 그물과 겹치지 않는 그물을 나란히 펼치면 셰퍼드의 추측이 왜 아직 열려 있는지 보여요. 에너지를 조금씩 잃는 굳은 몸은 가장 큰 축으로 옮겨 가 돌아요. 1958년 익스플로러 1호가 휘는 안테나 때문에 가장 작은 축에서 가장 큰 축으로 돌아누웠어요. Tennis Racket 의 적분에 감쇠를 걸면 폴호드를 건너 큰 축으로 내려앉는 길을 그릴 수 있어요. 허버트 본(1977)은 곡선 위 점 쌍을 뫼비우스 띠로 놓아 모든 조르당 곡선이 직사각형을 품는다는 것을 보였어요. Square Peg 의 곡선에서 그 띠를 세워 그리면 띠가 제 몸을 지나는 자리가 직사각형이에요. + ### 화장품 팝업 주제 6 으로 쓴 질감과 다음에 해 볼 질감이에요. 29판이 처음 넷을 썼어요. 팔레트 나이프로 펴 바르는 크림(Cream Swirl), 구슬이 뜬 젤과 빛 그물(Serum Gel), 쏟아져 쌓이는 글리터(Glitter Fall), 퍼프가 눌렀다 든 압축 파우더(Loose Powder)예요. 넷 다 K1 이 색상환 한 바퀴라서, 색을 돌리면 주황과 노랑이 갈색이나 올리브로 보이는 자리가 있었어요. Serum Gel 은 그 둘레에서만 밝기를 0.62 와 0.3 에서 0.92 와 0.68 로 올렸어요. 밝은 바탕은 반짝임을 지웠어요. 200 위에 55 를 더한 반짝임은 거의 안 보여서 Glitter Fall 은 어두운 바탕에 섰고, 흰 바탕과 금색 한 줄은 Loose Powder 의 아래 띠로만 썼어요. 크림과 젤은 가장자리 0.028 과 0.079 로 연구에서 가장 번지는 그림이에요. 그 둘이 가장자리 축의 아래 끝을 옮겨서 지도에 새 빈 칸이 났어요. 크림은 빛만 움직여서는 움직임이 0.019 에 머물렀어요. 결이 깊어야 빛이 돌 때 바뀌는 LED 가 생겨요. 비누막이 터질 때 구멍 가장자리가 테일러와 컬릭의 속도(1959, 1960)로 고르게 벌어지며 물방울 테를 남기는 것은 Soap Film 의 끝을 늘인 그림이에요. 클렌징 오일이 물을 만나 가장자리부터 우윳빛으로 유화되는 것은 Blotting Paper 처럼 LED 마다 한 번만 바뀌는 셈으로 그릴 만해요. @@ -2762,7 +2831,7 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 46판에서 본 것이에요. 빛 위에 겹친 빛은 움직임에 들지 않았어요. Howling Wolf 의 소리 고리는 가장 밝은 채널이 이미 255 인 달 위에서 더해도 잰 값이 바뀌지 않아서 초록으로 섞어 그렸고, 그래도 0.229 였어요. 어두운 판의 부드러운 빛도 Lantern Procession 의 움직임을 0.15 언저리에 묶었어요. 안개는 덮어 칠하면 거품이 됐어요. Vampire Coffin 의 옅은 안개는 벽을 어둡게 해서 방울처럼 보였고, 빛으로 더하자 안개로 읽혔어요. 검은 것은 이번에도 검은 것 앞에서 사라져서, Belfry 의 박쥐는 검은 탑 앞을 지날 때 보라로 비치게 칠했어요. 채널을 4~6 단위로 끊는 것이 이번에도 복잡도를 내렸어요. Lantern Procession 은 0.84 에서 0.50, Vampire Coffin 은 0.85 에서 0.57 이 됐고 둘 다 패널에서 띠가 보이지 않았어요. 25 KB 가 그림을 깎기도 했어요. Howling Wolf 는 박쥐를, Ghost Ship 은 떠오르는 혼령을 뺐어요. -46판 뒤에 해 볼 것이에요. 달빛을 받은 사람 그림자가 늑대로 바뀌는 변신은 Howling Wolf 의 거리장 둘 사이를 섞으면 그릴 만해요. Belfry 의 종 진자를 길이가 다른 종 셋에 걸면 세 종이 서로 다른 주기로 쳐서 엇박이 맞물렸다 풀려요. +46판 뒤에 해 볼 것이에요. Belfry 의 종 진자를 길이가 다른 종 셋에 걸면 세 종이 서로 다른 주기로 쳐서 엇박이 맞물렸다 풀려요. 47판이 다섯을 더 그렸어요. 독한 초록 액체 속에 떠서 두리번거리다 나를 보는 눈알(Specimen Jar), 128줄을 감아 오르며 작은 호박 등을 켜는 덩굴(Pumpkin Vine), 칼이 얼굴을 도려내고 촛불이 켜지는 호박(Pumpkin Carving), 나방이 버둥댈 때마다 빛이 실을 타고 퍼지는 거미줄(Web Tremor), 포인터가 글자를 짚어 말을 거는 판(Talking Board)이에요. 넷은 43판과 45판 뒤에 적혀 있던 감이고, 병 속 눈알만 새로 찾았어요. 켜진 몫은 Specimen Jar 의 0.665 에서 Pumpkin Vine 의 0.974 이고, 움직임은 Pumpkin Vine 의 0.058 에서 Web Tremor 의 0.527, 복잡도는 Pumpkin Carving 의 0.224 에서 Pumpkin Vine 의 0.747 이에요. @@ -2776,6 +2845,12 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 48판 뒤에 해 볼 것이에요. Headless Rider 의 말을 개울 위 다리로 달리게 하면, 다리를 건너면 쫓아오지 못한다는 이야기대로 다리 끝에서 불붙은 호박이 날아와 터지는 장면이 돼요. 해골 둘이 서로의 머리를 바꿔 끼우고 다시 바꾸는 장면은 Bone Pile 의 조각 17개를 그대로 써요. +49판이 다섯을 더 그렸어요. 폭풍 속 성당 처마에서 물을 쏟는 가고일(Gargoyle), 태엽이 풀리면 우리를 보고 멈추는 해골 발레리나의 오르골(Music Box), 보름달 앞에서 늑대로 바뀌는 남자(Werewolf), 지하 감옥에서 내려오는 초승달 칼날(Pit and Pendulum), 현관에 처진 할로윈 전구 줄(Party Lights)이에요. 늑대로 바뀌는 남자는 46판 뒤에 적혀 있던 감이에요. 움직임은 Werewolf 의 0.108 에서 Gargoyle 의 0.982, 복잡도는 Music Box 의 0.147 에서 Party Lights 의 0.599 예요. + +49판에서 본 것이에요. 몸 둘의 거리장을 통째로 섞으면 팔다리가 녹았어요. Werewolf 는 사람과 늑대의 거리장을 섞자 팔이 늘어나지 않고 다리 속으로 녹아들어서, 뼈대 하나의 뼈를 옮기고 머리만 거리장 둘을 섞었어요. 1~2 LED 의 작은 것은 이번에도 다른 것으로 읽혔어요. Gargoyle 의 먼 탑에 켠 창은 주황 막대였고 길의 줄무늬는 쇠창살이었어요. Werewolf 의 빛 테 두른 바위는 탁자였고, 길이 0 인 귀는 모자챙이었어요. Music Box 의 미뉴에트 검은 치마는 달빛 앞에서 주황 고리로 보였어요. 아래로 꺾이는 물은 가로 폭을 지켰어요. Gargoyle 의 물줄기는 입에서 굵게 내보내도 가운데 3분의 1 에서 1.5~2 LED 여서, 출발점과 옆 빠르기를 흔들어 굵혔어요. 검은 것은 이번에도 밝은 것 곁에서만 읽혀서, Party Lights 의 종이 박쥐는 켜진 전구 곁에서만 보여요. 멈추는 모습은 멈출 때를 셈해야 했어요. Music Box 의 발레리나를 조향으로 세우자 871 프레임에서 거꾸로 돌았고, 저절로 느려지는 길이가 꼭 한 바퀴로 끝나게 풀리는 시간을 고르자 0 이 됐어요. 그림자가 노브를 살렸어요. Pit and Pendulum 의 칼날은 제 넓이만 바꿔서 노브가 0.04 였고, 횃불을 내려 칼날 그림자를 밝은 벽에 크게 던지자 Speed 가 0.098 이 됐어요. + +49판 뒤에 해 볼 것이에요. Werewolf 의 뼈대 옮기기로 흡혈귀가 망토를 펴며 박쥐로 줄어드는 변신을 그릴 수 있어요. Pit and Pendulum 의 그림자 던지기로는 촛불 든 손이 나선 계단을 오르며 벽의 그림자를 늘이는 장면을 세운 패널의 높이로 그릴 만해요. + ## 다시 돌리기 `web/` 에서 실행해요. `web/node_modules` 가 있어야 해요. From 8ecefa81181f8f1dd6c89b981bb70c8514968dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:24:10 +0000 Subject: [PATCH 8/9] =?UTF-8?q?Gargoyle=20=EB=A8=B8=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EC=A4=84=EC=9D=84=20=EB=8D=94=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017mBLLjEyWbBi1Ag71cFsLX --- incoming/pattern-study/README.md | 2 +- incoming/pattern-study/patterns/gargoyle.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index cea377736..30550350f 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -639,7 +639,7 @@ | [Tennis Racket](patterns/tennis-racket.js) | 시뮬레이션 | 가운데 축으로 돌던 T자 손잡이가 몇 초마다 반 바퀴씩 뒤집히고, 아래 구에서는 각운동량이 분리선을 따라 한쪽 끝에서 반대쪽 끝으로 건너가요. K3 은 어느 주축 가까이에서 던질지, K4 는 그 축에서 얼마나 비껴 던질지이고, 단추를 누르면 다시 던져요. | | [Kempe Chains](patterns/kempe-chains.js) | 보로노이·셀 | 떠다니는 나라들의 국경이 바뀌어도 지도는 네 색을 지켜요. 새 국경이 같은 색 둘을 붙이면 남은 색을 칠하고, 네 색이 다 둘러싸면 두 색의 켐프 사슬이 빛나며 색을 맞바꿔요. K3 은 나라 수, K4 는 나라 크기가 갈리는 정도예요. | | [Square Peg](patterns/square-peg.js) | 위상·매듭 | 구불거리며 비틀리는 닫힌 곡선 위에 네 꼭짓점을 얹은 정사각형을 프레임마다 다 찾아 그리면, 정사각형이 둘씩 태어나고 사라져도 그 수는 늘 홀수예요. K3 은 찾는 직사각형의 가로세로 비, K4 는 곡선이 휘는 정도예요. | -| [Gargoyle](patterns/gargoyle.js) | 할로윈 | 폭풍 속 성당 처마의 가고일이 입으로 물줄기를 패널 아래 웅덩이까지 쏟고, 번개가 칠 때마다 검게 드러났다가 눈에 독한 초록 불이 켜져요. K3 은 비, K4 는 번개가 치는 잦기예요. | +| [Gargoyle](patterns/gargoyle.js) | 할로윈 | 폭풍 속 성당 처마의 가고일이 입으로 물줄기를 패널 아래 웅덩이까지 쏟고, 번개가 칠 때마다 검게 드러났다가 눈에 독한 초록 불이 켜져요. K3 은 비, K4 는 번개가 치는 잦기이고, 단추를 누르면 바로 번개가 쳐요. | | [Music Box](patterns/music-box.js) | 할로윈 | 원통의 핀이 빗살을 튕기면 해골 발레리나가 돌다가, 태엽이 풀려 음악이 늘어지면 우리를 보고 멈춰 눈구멍이 초록으로 빛나고, 보이지 않는 손이 열쇠를 감아 다시 시작해요. K3 은 곡 셋 가운데 하나를, K4 는 태엽을 감은 만큼이에요. | | [Werewolf](patterns/werewolf.js) | 할로윈 | 구름이 걷힌 보름달 앞 바위 위에서 남자가 등을 굽히고 팔이 앞다리로 늘어나 늑대가 되어 울부짖고, 구름이 다시 달을 덮으면 남자로 돌아와요. K3 은 구름, K4 는 달의 크기예요. | | [Pit and Pendulum](patterns/pit-pendulum.js) | 할로윈 | 횃불 켠 지하 감옥에서 커다란 초승달 칼날이 벽에 그림자를 휘두르며 한 번 지날 때마다 돌 침대 쪽으로 내려오고, 쥐들이 모여들다 손 한 뼘 위에서 횃불이 잦아들면 쇠사슬이 칼날을 어둠 속으로 끌어올려요. K3 은 한 번 지날 때 내려오는 깊이, K4 는 횃불이 닿는 거리예요. | diff --git a/incoming/pattern-study/patterns/gargoyle.js b/incoming/pattern-study/patterns/gargoyle.js index a1a36b63b..8f7c05953 100644 --- a/incoming/pattern-study/patterns/gargoyle.js +++ b/incoming/pattern-study/patterns/gargoyle.js @@ -15,6 +15,7 @@ // Knob 2: Speed. At 2 a parcel of water takes 1.2 seconds from the jaws to the puddle and a raindrop crosses the panel in 0.8; at 0.2 the water hangs ten times slower, at 3 it falls half as fast again. The clouds scud on the same clock, and the gaps between strikes stretch up to 1.6 times at 0.2 and shrink by a fifth at 3. A flash always lasts the same half second. // Knob 3: Rain. At 0 a steady rain of 90 drops and a stream three LEDs thick that frays into drops on the way down; at 1 a downpour of 280 drops slanting harder, and a gush four LEDs thick at the jaws, thrown 5 LEDs further, that frays to eight and splashes twice as high. // Knob 4: Lightning. The first strike always comes 1.5 seconds after the start. Then at 0 one every 4.5 seconds on average, a bolt with at most one branch; at the opening 0.06 one every 4.3; at 1 one every 0.9 seconds, brighter, with up to five branches and a third return stroke. Each strike leaves the eyes glowing green for about 2.5 seconds. +// Buttons: any button calls a strike at once. const W = 64, H = 128, N = W * H; const { floor, min, max, abs, sqrt, sin, cos, exp, hypot, PI } = Math; @@ -337,7 +338,7 @@ export function update(dt, input, params) { strike(S); S.next = S.t + mix(4.5, 0.9, S.bolt) * (0.7 + 0.6 * Math.random()) * min(2.5, 2 / S.speed) ** 0.5; } - // The eyes kindle a quarter second after each strike, glow, and fade. + // The eyes kindle a quarter second after each strike and stay lit until two seconds after it, then fade. const e = S.t - S.strikeAt; S.eye = e < 0.25 ? max(0, S.eye - d) : e < 0.45 ? max(S.eye, sm(0.25, 0.45, e)) : e < 2 ? 1 : 1 - sm(2, 3, e); } From 3e2bc3f4c51694b39613dc069c572d2b5c02ab1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:50:45 +0000 Subject: [PATCH 9/9] =?UTF-8?q?index.html=20=EC=9D=84=2049=ED=8C=90?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20398=EA=B0=9C=EB=A1=9C=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_017mBLLjEyWbBi1Ag71cFsLX --- incoming/pattern-study/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 5bcbc97a4..7fc482271 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -210,8 +210,8 @@

패턴 연구

- - + +