From b3c9216db87697cd76e5bf70ea34721d973be97c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:54:20 +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=2047=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_01PMCSedMgeD9NiVSkNiWyK3 From 59774f947139d7b3adb2919130eb480a8fc16b89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:35:22 +0000 Subject: [PATCH 2/9] =?UTF-8?q?pattern-study:=20Lonely=20Runner,=20Pumpkin?= =?UTF-8?q?=20Vine=20(47=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_01PMCSedMgeD9NiVSkNiWyK3 --- .../pattern-study/patterns/lonely-runner.js | 186 ++++++++ .../pattern-study/patterns/pumpkin-vine.js | 442 ++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 incoming/pattern-study/patterns/lonely-runner.js create mode 100644 incoming/pattern-study/patterns/pumpkin-vine.js diff --git a/incoming/pattern-study/patterns/lonely-runner.js b/incoming/pattern-study/patterns/lonely-runner.js new file mode 100644 index 000000000..6a09d736e --- /dev/null +++ b/incoming/pattern-study/patterns/lonely-runner.js @@ -0,0 +1,186 @@ +// ===== Patternflow pattern ===== +// Title: Lonely Runner +// Family: rhythm +// Idea: Runners at distinct whole-number speeds climb side by side round a track that is the panel's 128 rows wrapped top to bottom, each a bold bar in its own lane carrying a zone of 128/k rows either way, and whenever no other runner is inside its zone the runner is lonely, so its zone lights up and its bar burns white; with speeds 1 to k they all go lonely together, equally spaced round the track, and with scattered speeds the flares come one by one at odd times. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the lonely runner conjecture (Jörg M. Wills, 1967; Thomas Cusick, 1973), proven for up to seven runners by Javier Barajas and Oriol Serra (2008), pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Runners=3..8, Scatter=0..1 +// +// Knob 1: Hue. Turns the runners' colours round the wheel together: at 0 three runners are cyan, magenta and yellow; at 0.25 violet, orange and green; at 0.5 red, green and blue. With more runners the colours sit closer together round the same wheel. +// Knob 2: Speed. At 2 the fastest runner laps in 1.8 seconds; with speeds 1, 2 and 3 the slowest laps in 5.4, and then all three are back on the line together and the race goes round again. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Runners. How many run, 3 to 8, each in its own lane, and the zone shrinks to 128/k rows either way: 43 rows with three, 16 with eight. Turning it starts a new race from the line at the bottom. With eight at speeds 1 to 8 the first lonely moment is a staircase of all eight bars, 1.8 seconds in. +// Knob 4: Scatter. Below 0.2 the speeds are 1, 2, ... k, the case where all the runners go lonely together. In four steps up to 1 they become k different whole numbers drawn from 1 to k+3, k+6, k+9 and k+12, and the lonely moments break apart into single flares, some held for a second or more. Each step starts a new race. + +const W = 64, H = 128; +const { floor, min, max, abs, round, exp } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [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 fastest runner's lap, in story seconds (one a second at Speed 2). The +// slowest runner's lap is the whole race, since every speed is a whole +// number: at its end all of them are back on the line together. +const LAP_FAST = 1.8; +// A runner's bar, in rows, and the row its middle starts on: the bars stand +// on the bottom edge at the start. +const BAR = 14, START = H - BAR / 2; +// Lonely means no other runner nearer than 1/k of a lap, either way round. +// With speeds 1..k the nearest neighbours reach exactly 1/k for a single +// instant, which no frame would ever land on, so the check runs at 24 +// moments inside each frame and allows half an LED row (1/256 of a lap). +const SUB = 24, TOL = 1 / 256; +// How long a flare takes to fade after the runner stops being lonely. +const FADE = 0.35; +// The tail behind a bar is this many story seconds of its running. +const TAIL = 0.25; + +export function setup(params) { + params.s = { + t: 0, hue: 0, speed: 2, k: 0, level: -1, + v: [], pos: new Float32Array(8), flare: new Float32Array(8), lonely: new Uint8Array(8), + lane: new Int8Array(W), shade: new Float32Array(W), col: [], colHue: NaN, colK: 0, + }; +} + +// A small fixed hash, so each Scatter step draws the same speeds every time. +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; + +// The speeds for k runners at Scatter step L: 1..k at step 0, otherwise k +// distinct whole numbers from 1..k+3L, shuffled by a fixed seed and sorted +// so the slowest runs in the left lane. +function speeds(k, L) { + const pool = []; + for (let i = 1; i <= k + 3 * L; i++) pool.push(i); + if (L > 0) + for (let i = pool.length - 1; i > 0; i--) { + const j = floor(hash(k * 101 + L * 7 + i * 13) * (i + 1)); + const tmp = pool[i]; + pool[i] = pool[j]; + pool[j] = tmp; + } + return pool.slice(0, k).sort((a, b) => a - b); +} + +// A new race: lanes for k runners with a dark LED between them, and every +// runner back on the line. +function restart(S, k, L) { + S.k = k; + S.level = L; + S.v = speeds(k, L); + S.t = 0; + S.flare.fill(0); + for (let x = 0; x < W; x++) { + const j = floor((x * k) / W); + const edge = round(((j + 1) * W) / k) - 1; + S.lane[x] = x === edge && j < k - 1 ? -1 : j; + const x0 = round((j * W) / k), u = (x - x0 + 0.5) / (edge - x0 + 1); + S.shade[x] = 0.62 + 0.38 * (1 - (2 * u - 0.7) ** 2 / 1.69); + } +} + +// Nearest distance from runner i to any other, as a share of a lap, for +// the race at story time t. +function nearest(S, i, t, P) { + const pi = (S.v[i] * t) / P; + let d = 1; + for (let j = 0; j < S.k; j++) { + if (j === i) continue; + let g = pi - (S.v[j] * t) / P; + g -= floor(g); + d = min(d, g, 1 - g); + } + return d; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, kv = input.knobValues || [0, 2, 3, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = kv[0]; + S.speed = cl(kv[1], 0.2, 3); + const k = cl(round(kv[2]), 3, 8), L = min(4, floor(cl(kv[3], 0, 1) * 5)); + if (k !== S.k || L !== S.level) restart(S, k, L); + const g = (d * S.speed) / 2, P = LAP_FAST * S.v[k - 1], t0 = S.t; + S.t += g; + const need = 1 / k - TOL; + for (let i = 0; i < k; i++) { + let lonely = 0; + for (let s = 1; s <= SUB && !lonely; s++) if (nearest(S, i, t0 + (g * s) / SUB, P) >= need) lonely = 1; + S.lonely[i] = lonely; + S.flare[i] = lonely ? 1 : S.flare[i] * exp(-g / FADE); + const p = (S.v[i] * S.t) / P; + S.pos[i] = p - floor(p); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.k) return; + const k = S.k; + if (S.colHue !== S.hue || S.colK !== k) { + S.col = []; + for (let i = 0; i < k; i++) S.col.push(hsv(0.5 + S.hue + i / k, 0.9, 1)); + S.colHue = S.hue; + S.colK = k; + } + const R = H / k, half = BAR / 2; + // Each runner's middle row; it climbs, so the row falls as the lap goes on. + const c = []; + for (let i = 0; i < k; i++) c.push(START - S.pos[i] * H); + for (let y = 0; y < H; y++) { + // Signed distance in rows from each runner, the short way round. + const dy = []; + for (let i = 0; i < k; i++) { + let e = y + 0.5 - c[i]; + e -= H * floor(e / H + 0.5); + dy.push(e); + } + for (let x = 0; x < W; x++) { + const ln = S.lane[x]; + let r = 0, gg = 0, b = 0; + for (let i = 0; i < k; i++) { + const e = dy[i], a = abs(e), cc = S.col[i], f = S.flare[i]; + let v = 0; + if (ln === i) { + // The runner's own lane. Its zone shows faintly; while the runner + // is lonely it lights up, brightest at the runner, its two edges + // 128/k rows away drawn as lines. With speeds 1..k those edges + // land exactly on the neighbours' lines: the case is tight. + if (a < R) { + v = 0.1; + if (f > 0.02) v = max(v, f * (a > R - 1 ? 0.9 : 0.15 + 0.5 * (1 - a / R) ** 1.5)); + } + // A tail below the bar, as long as a quarter second of its running. + const tail = TAIL * S.v[i] * (H / (LAP_FAST * S.v[k - 1])); + if (e > half && e < half + tail) v = max(v, 0.7 * (1 - (e - half) / tail) ** 2); + if (a < half) v = 1; + else if (a < half + 1) v = max(v, 1 - (a - half)); + // Lit from the left, so a lane reads as a rounded rod. + v *= S.shade[x]; + } else if (a < 0.5) { + // Every runner draws a line across the other lanes at its middle, + // so in each lane you can see who is inside that runner's zone. + v = 0.55; + } + // A lonely runner's bar burns white-hot for a beat. + const w = ln === i && a < half + 1 ? 0.55 * f * v : 0; + r += cc[0] * v + w; + gg += cc[1] * v + w; + b += cc[2] * v + w; + } + display.setPixel(x, y, round(255 * min(1, r)), round(255 * min(1, gg)), round(255 * min(1, b))); + } + } +} diff --git a/incoming/pattern-study/patterns/pumpkin-vine.js b/incoming/pattern-study/patterns/pumpkin-vine.js new file mode 100644 index 000000000..246923e21 --- /dev/null +++ b/incoming/pattern-study/patterns/pumpkin-vine.js @@ -0,0 +1,442 @@ +// ===== Patternflow pattern ===== +// Title: Pumpkin Vine +// Family: halloween +// Idea: Under a violet night sky and a pale moon a pumpkin vine climbs from the hill at the bottom of the panel to near the top, its stem winding up as broad five-lobed leaves unfold at every joint and tendrils coil out beside them, small orange pumpkins swell at the joints and light one by one from the bottom as jack-o'-lanterns with glowing carved faces, each one lifting the night a little, and when the whole vine has stood lit for a few seconds its leaves wither to a dead violet, sink into the dark, and a new vine starts from the ground. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Spell=0..1, Speed=0.2..3, Vines=1..3, Curl=0..1 +// +// Knob 1: Spell. At 0 an ordinary pumpkin patch under a violet night: leaf-green leaves, candle-yellow faces and an orange glow. Turning it bewitches the vine: by 0.5 the leaves are teal, the faces lime and the night dark slate; at 1 the leaves are violet with toxic green veins, the lanterns burn green and the night is a sickly green. The pumpkins stay orange. +// Knob 2: Speed. At 2 one vine takes 13 seconds: it starts climbing at 0.45, reaches its full height at 6.45, the four lanterns catch at about 3.9, 5.3, 6.0 and 7.4, it stands lit until 11, withers in 1.5 and the ground rests half a second. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Vines. One vine leans away from the moon with four pumpkins. Two share the width with leaves a fifth smaller and three pumpkins each, three with leaves a third smaller and two each, the one under the moon stopping lower. Turning it lays the vines out again at the same stage of growth. +// Knob 4: Curl. How hard the stem winds, how flat the leaves spread and how far the tendrils coil: at 0.06 the stem sways gently, the leaves stand half upright and each tendril makes one turn; at 1 the stem swings in wide S-bends, the leaves spread flat out and the tendrils are springs of two and a half turns. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, round, sin, cos, sqrt, atan2, asin, hypot, 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 sstep = (u) => (u <= 0 ? 0 : u >= 1 ? 1 : u * u * (3 - 2 * u)); +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]; +} + +// One vine, in story seconds (one a second at Speed 2): the stem starts at +// T0 and reaches the top GROW later; the vine stands until HOLD_END, +// withers in WITHER and the ground rests until CYCLE. +const T0 = 0.45, GROW = 6, HOLD_END = 11, WITHER = 1.5, CYCLE = 13; +// A leaf unfolds in LEAF_T after the tip passes its joint, a pumpkin swells +// in PUMP_T, a tendril coils out in TEND_T, a lantern catches in LIGHT_T. +const LEAF_T = 1.2, PUMP_T = 1.4, TEND_T = 1.3, LIGHT_T = 0.5; +// Each LED a part of the vine covers fades in over this long. +const RAMP = 0.15; +// How much brighter the night is once every lantern is lit. +const SKY_GLOW = 0.33; +// The stem stops this far below the top edge; joints come every GAP LEDs of +// stem; a leaf's radius at the bottom joint. +const TOP_Y = 22, GAP = 13, LEAF_R = 15.5; +// The moon, top right. +const MX = 52, MY = 11, MR = 7.5; +// A pumpkin's half-width and half-height at full size. +const PRX = 8, PRY = 6.5; +// Up to this many parts of the vine are kept per LED, newest on top. +const EV = 4; + +export function setup(params) { + params.s = { + t: 0, spell: 0, speed: 2, vines: 0, curl: -1, + bg: new Float32Array(N * 3), bgSpell: NaN, + evN: new Uint8Array(N), evT: new Float32Array(N * EV), evA: new Float32Array(N * EV), evK: new Uint8Array(N * EV), + tmpT: new Float32Array(N), tmpA: new Float32Array(N), tmpK: new Uint8Array(N), tmpD: new Float32Array(N), touched: [], + rgb: new Float32Array(N * 3), lv: new Float32Array(N), pm: new Uint8Array(N), pal: null, palSpell: NaN, + pumpkins: [], stars: [], seed: 0, rng: null, + }; +} + +// The palette for a Spell setting. Parts of the vine store only a kind, so +// turning Spell recolours a vine that has already grown. +// Kinds: 1 stem, 2 stem highlight, 3 leaf lit half, 4 leaf shaded half, +// 5 vein, 6 leaf rim, 7 tendril, and 8, 9 and 11 the lit half, shaded half +// and rim of every other leaf, a yellower green. +function palette(sp) { + const lh = 0.3 + 0.47 * sp; + return { + 1: hsv(0.27 + 0.05 * sp, 0.78, 0.8), + 2: hsv(0.24 + 0.06 * sp, 0.6, 0.95), + 3: hsv(lh, 0.86, 0.94), + 4: hsv(lh + 0.02, 0.9, 0.86), + 5: hsv(0.22 + 0.08 * sp, 0.62 + 0.2 * sp, 0.97), + 6: hsv(lh + 0.03, 0.92, 0.7), + 7: hsv(0.2 + 0.08 * sp, 0.6, 0.92), + 8: hsv(lh - 0.07, 0.88, 0.96), + 9: hsv(lh - 0.06, 0.9, 0.88), + 11: hsv(lh - 0.05, 0.92, 0.72), + fire: hsv(0.14 + 0.17 * sp, 0.62, 1), + halo: hsv(0.07 + 0.24 * sp, 0.95, 1), + }; +} + +// The night: a violet sky darker towards the ground, a few still stars, the +// moon with two faint seas and a soft ring, and a low dark hill. +function sky(S) { + const bg = S.bg, sp = S.spell; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 3, u = y / H; + // Spell turns the violet night to a sickly green one. + let r = mix(mix(0.12, 0.035, u), mix(0.02, 0.008, u), sp), g = mix(mix(0.03, 0.008, u), mix(0.17, 0.06, u), sp), b = mix(mix(0.22, 0.075, u), mix(0.09, 0.03, u), sp); + const d = hypot(x + 0.5 - MX, y + 0.5 - MY); + if (d < MR) { + const sea = hypot(x - MX + 2.5, y - MY + 1.5) < 2.6 || hypot(x - MX - 1.5, y - MY - 3) < 2 ? 0.86 : 1; + r = 1 * sea; + g = mix(0.95, 1, sp) * sea; + b = mix(0.76, 0.7, sp) * sea; + } else if (d < MR + 6) { + const k = ((MR + 6 - d) / 6) ** 2 * 0.35; + r += k * 0.9; + g += k * 0.8; + b += k * 0.7; + } + // The hill the vine grows out of. + const hill = H - 5 + 3 * cos((x - 30) / 14); + if (y + 0.5 > hill) (r = 0.18), (g = 0.05), (b = 0.27); + if (abs(y + 0.5 - hill) < 0.6) (r = 0.32), (g = 0.12), (b = 0.42); + bg[i] = r; + bg[i + 1] = g; + bg[i + 2] = b; + } + for (const [x, y, v] of S.stars) { + const i = (y * W + x) * 3; + bg[i] = max(bg[i], 0.8 * v); + bg[i + 1] = max(bg[i + 1], 0.75 * v); + bg[i + 2] = max(bg[i + 2], v); + } + S.bgSpell = sp; +} + +// Building a vine: each part is stamped into a scratch layer first (an +// arrival time, a coverage and a kind per LED), then the layer goes on top +// of the parts already there. +function stamp(S, x, y, te, a, kind, d) { + if (x < 0 || x >= W || y < 0 || y >= H || a <= 0) return; + const p = y * W + x; + if (S.tmpA[p] === 0) (S.touched.push(p), (S.tmpT[p] = te), (S.tmpD[p] = 1e9)); + S.tmpT[p] = min(S.tmpT[p], te); + S.tmpA[p] = max(S.tmpA[p], a); + if (d < S.tmpD[p]) (S.tmpD[p] = d), (S.tmpK[p] = kind); +} +function flush(S) { + for (const p of S.touched) { + let n = S.evN[p]; + if (n === EV) { + // Full: the oldest part is under three others; drop it. + for (let j = 1; j < EV; j++) { + S.evT[p * EV + j - 1] = S.evT[p * EV + j]; + S.evA[p * EV + j - 1] = S.evA[p * EV + j]; + S.evK[p * EV + j - 1] = S.evK[p * EV + j]; + } + n--; + } + // Kept in order of arrival, so the one that comes last is drawn on top. + let j = n; + while (j > 0 && S.evT[p * EV + j - 1] > S.tmpT[p]) { + S.evT[p * EV + j] = S.evT[p * EV + j - 1]; + S.evA[p * EV + j] = S.evA[p * EV + j - 1]; + S.evK[p * EV + j] = S.evK[p * EV + j - 1]; + j--; + } + S.evT[p * EV + j] = S.tmpT[p]; + S.evA[p * EV + j] = S.tmpA[p]; + S.evK[p * EV + j] = S.tmpK[p]; + S.evN[p] = n + 1; + S.tmpA[p] = 0; + } + S.touched.length = 0; +} + +// A round brush along a polyline of [x, y, time] points, or [x, y, time, +// radius] where it tapers. +function brush(S, pts, rad, kindAt) { + for (const [px, py, te, rr] of pts) { + const r = rr === undefined ? rad : rr; + for (let y = floor(py - r - 1); y <= py + r + 1; y++) + for (let x = floor(px - r - 1); x <= px + r + 1; x++) { + const d = hypot(x + 0.5 - px, y + 0.5 - py); + stamp(S, x, y, te, cl(r + 0.5 - d, 0, 1), kindAt(d / r), d); + } + } +} + +// The inverse of smoothstep: the share of the way through an eased +// unfolding at which it has reached u. +const unease = (u) => 0.5 - sin(asin(cl(1 - 2 * u, -1, 1)) / 3); + +// One vine from the ground at x0, leaning towards lean as it climbs and +// stopping at row top; it starts at t0, its leaves are scaled by lr and it +// carries np pumpkins. +function vine(S, x0, lean, top, t0, lr, np) { + const R = S.rng, curl = S.curl; + // The stem: steered by a heading that swings from side to side, pulled + // back towards the line it leans along. + const pts = [], amp = 0.365 + 0.95 * (curl - 0.06), lam = 46 + 10 * R(), ph = TAU * R(); + let x = x0, y = H + 1, s = 0; + while (y > top) { + const xc = mix(x0, lean, cl((H - y) / H, 0, 1)), th = amp * sin((TAU * s) / lam + ph) - 0.035 * (x - xc); + x += 0.5 * sin(th); + y -= 0.5 * cos(th); + s += 0.5; + pts.push([x, y, s]); + } + const len = s, tipAt = (ss) => t0 + (GROW * ss) / len; + brush(S, pts.map(([px, py, ss]) => [px, py, tipAt(ss), (2.2 - (1.1 * ss) / len) * (0.6 + 0.4 * lr)]), 0, (u) => (u < 0.45 ? 2 : 1)); + flush(S); + // The joints, alternating sides. + const nodes = []; + let side = R() < 0.5 ? -1 : 1; + for (let sn = 9; sn < len - 8; sn += GAP) { + const pt = pts[min(pts.length - 1, round(sn / 0.5))]; + nodes.push({ s: sn, x: pt[0], y: pt[1], side, t: tipAt(sn) + 0.15 }); + side = -side; + } + // Pumpkins at evenly spread joints from the second to the one below the + // top. + const last = max(1, nodes.length - 2), pick = new Set(); + for (let j = 0; j < np; j++) pick.add(round(1 + (j * (last - 1)) / max(1, np - 1))); + const prx = PRX * (0.7 + 0.3 * lr); + nodes.forEach((nd, j) => { + // The leaf: a petiole out and up, then a five-lobed blade that unfolds + // from its stalk outwards, the middle lobe first. Curl spreads the + // leaves from half upright to flat out. + const rad = LEAF_R * lr * (1.08 - (0.32 * nd.s) / len), pl = (3.5 + 1.5 * R()) * lr; + let a = nd.side * (0.75 + (curl - 0.06) + 0.25 * R()); + // A leaf that would cover the moon grows on the other side instead. + if (hypot(nd.x + sin(a) * (pl + 0.62 * rad) - MX, nd.y - cos(a) * (pl + 0.62 * rad) - MY) < rad + MR + 2) (a = -a), (nd.side = -nd.side); + const dx = sin(a), dy = -cos(a); + const bx = nd.x + dx * pl, by = nd.y + dy * pl, cx = bx + dx * 0.62 * rad, cy = by + dy * 0.62 * rad; + const stalk = [], alt = j % 2 ? 5 : 0; + for (let l = 0; l <= pl; l += 0.5) stalk.push([nd.x + dx * l, nd.y + dy * l, nd.t + 0.15 * LEAF_T * (l / pl)]); + brush(S, stalk, 0.9, () => 1); + for (let yy = floor(cy - rad - 2); yy <= cy + rad + 2; yy++) + for (let xx = floor(cx - rad - 2); xx <= cx + rad + 2; xx++) { + const ex = xx + 0.5 - cx, ey = yy + 0.5 - cy, rho = hypot(ex, ey); + const phi = atan2(ex * dy - ey * dx, ex * dx + ey * dy); + const edge = rad * (0.62 + 0.38 * abs(cos(2.5 * phi)) ** 0.55); + const cov = cl(edge - rho + 0.5, 0, 1); + if (cov <= 0) continue; + // Veins run from the stalk to the three upper lobe tips. + const fx = xx + 0.5 - bx, fy = yy + 0.5 - by, fr = hypot(fx, fy); + const psi = atan2(fx * dy - fy * dx, fx * dx + fy * dy); + let vein = 9; + for (const la of [0, 0.4 * PI, -0.4 * PI]) { + const lx = cx + rad * (cos(la) * dx + sin(la) * dy) - bx, ly = cy + rad * (cos(la) * dy - sin(la) * dx) - by; + const ll = hypot(lx, ly), t2 = cl((fx * lx + fy * ly) / (ll * ll), 0, 1); + vein = min(vein, hypot(fx - t2 * lx, fy - t2 * ly)); + } + // Every other leaf is a yellower green, so neighbours stand apart. + const kind = edge - rho < 1.1 ? 6 + alt : vein < 0.55 ? 5 : (phi * nd.side > 0 ? 3 : 4) + alt; + const u = cl((0.72 * fr) / (1.62 * rad) + (0.28 * abs(psi)) / PI, 0, 1); + stamp(S, xx, yy, nd.t + LEAF_T * (0.15 + 0.85 * unease(u)), cov, kind, 0); + } + flush(S); + if (pick.has(j)) { + // A pumpkin hangs on the side away from the leaf; the lowest sits on + // the hill. + const px = cl(nd.x - nd.side * (prx * 0.75 + 1.5), prx + 0.5, W - prx - 0.5); + S.pumpkins.push({ x: px, top: min(nd.y + 1, H - 6 - 2 * PRY * (0.7 + 0.3 * lr)), t: nd.t + 0.45, sc: 0.7 + 0.3 * lr }); + } else { + // A tendril: an Euler spiral, its curvature growing along it, so it + // runs out straight and winds into a tight coil. + const turns = mix(1, 2.5, curl), tl = mix(10, 17, curl) * lr, a0 = -nd.side * (1.1 + 0.3 * R()), c0 = 0.04; + const cb = (TAU * turns - c0 * tl) / (tl * tl), spin = -nd.side; + const tp = []; + let qx = nd.x, qy = nd.y, l = 0; + while (l < tl) { + const hd = a0 + spin * (c0 * l + cb * l * l); + qx += 0.3 * sin(hd); + qy -= 0.3 * cos(hd); + l += 0.3; + tp.push([qx, qy, nd.t + 0.3 + TEND_T * (l / tl)]); + } + brush(S, tp, 0.55, () => 7); + flush(S); + } + }); +} + +// Lay out the vines: one leans away from the moon; two or three share the +// width with smaller leaves, starting 0.15 seconds apart. +const LAYOUT = { + 1: [[0, 20, TOP_Y]], + 2: [[17, 15, TOP_Y], [45, 38, TOP_Y + 10]], + 3: [[11, 10, TOP_Y], [32, 28, TOP_Y + 4], [53, 44, TOP_Y + 16]], +}; +// A small seeded generator: a vine keeps its seed until the next one +// starts, so turning Curl or Vines bends the same vine instead of drawing +// a new one at every step of the knob. +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), a | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function build(S) { + S.rng = rng(S.seed); + S.evN.fill(0); + S.lv.fill(0); + S.pm.fill(0); + S.pumpkins = []; + const nv = S.vines, lr = [0, 1, 0.8, 0.68][nv], np = [0, 4, 3, 2][nv]; + LAYOUT[nv].forEach(([x0, lean, top], v) => vine(S, x0 || 30 + 8 * S.rng(), lean, top, T0 + 0.15 * v, lr, np)); + // The lanterns catch one by one in the order their pumpkins swelled. + S.pumpkins.sort((a, b) => a.t - b.t); + let light = 0; + for (const P of S.pumpkins) P.light = light = max(P.t + PUMP_T + 0.25, light + 0.55); +} + +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.spell = cl(k[0], 0, 1); + S.speed = cl(k[1], 0.2, 3); + const nv = cl(round(k[2]), 1, 3), curl = cl(k[3], 0, 1); + if (S.stars.length === 0) { + for (let i = 0; i < 22; i++) S.stars.push([floor(Math.random() * W), floor(Math.random() * 70), 0.25 + 0.3 * Math.random()]); + S.seed = floor(Math.random() * 4294967296); + } + if (nv !== S.vines || abs(curl - S.curl) > 0.004) { + S.vines = nv; + S.curl = curl; + build(S); + } + S.t += (d * S.speed) / 2; + if (S.t >= CYCLE) { + S.t -= CYCLE; + S.seed = floor(Math.random() * 4294967296); + build(S); + } +} + +// A pumpkin at swell sw (0 to 1) hanging from (x, top), carved face lit by +// f; writes into rgb over whatever is there and marks its LEDs in pm, so +// the withering knows them. +function pumpkin(S, P, sw, f, rgb) { + const rx = PRX * P.sc * sw, ry = PRY * P.sc * sw, cx = P.x, cy = P.top + 1 + ry, fire = S.pal.fire, stem = S.pal[1]; + for (let y = floor(cy - ry - 3); y <= cy + ry + 1; y++) + for (let x = floor(cx - rx - 1); x <= cx + rx + 1; x++) { + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const u = (x + 0.5 - cx) / rx, v = (y + 0.5 - cy) / ry, rr = sqrt(u * u + v * v); + const i = (y * W + x) * 3; + // The stalk, a green stub on top. + if (y + 0.5 < cy - ry + 1 && y + 0.5 > cy - ry - 2.5 && abs(x + 0.5 - cx - 0.4) < 1.1) { + rgb[i] = stem[0]; + rgb[i + 1] = stem[1]; + rgb[i + 2] = stem[2]; + S.pm[y * W + x] = 1; + continue; + } + const a = cl((1 - rr) * rx + 0.5, 0, 1); + if (a <= 0) continue; + // Every part of a pumpkin keeps red at full, so as it swells no LED + // under it gets dimmer: ribs are a deeper orange, the shine a yellower one. + const w = u / sqrt(max(0.05, 1 - v * v)), rib = abs(abs(w) - 0.5) < 0.12 || abs(w) < 0.06; + let g = rib ? 0.3 : 0.5; + if (u < -0.15 && v < -0.2 && u * u + v * v > 0.25 && u * u + v * v < 0.6) g = 0.68; + let r = 1, b = 0; + if (f > 0) { + // The carved face: two triangle eyes, a nose and a jagged grin. + const eye = v <= -0.06 && v >= -0.5 + abs(abs(u) - 0.4) * 1.9 && abs(u) < 0.7; + const nose = v <= 0.18 && v >= -0.02 + abs(u) * 1.8; + const top = 0.3 + 0.08 * u * u, bot = 0.62 - 0.18 * u * u; + let mouth = abs(u) < 0.66 && v >= top && v <= bot; + if (mouth && ((u > -0.32 && u < -0.14 && v < top + 0.15) || (u > 0.14 && u < 0.32 && v > bot - 0.15))) mouth = false; + if (eye || nose || mouth) (g = mix(g, fire[1], f)), (b = fire[2] * f), (r = mix(1, max(fire[0], 0.85), f)); + } + rgb[i] = mix(rgb[i], r, a); + rgb[i + 1] = mix(rgb[i + 1], g, a); + rgb[i + 2] = mix(rgb[i + 2], b, a); + S.pm[y * W + x] = 1; + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.pumpkins) return; + if (S.bgSpell !== S.spell) sky(S); + if (S.palSpell !== S.spell) (S.pal = palette(S.spell)), (S.palSpell = S.spell), S.lv.fill(0); + const t = S.t, rgb = S.rgb, bg = S.bg, pal = S.pal; + const wi = t > HOLD_END ? sstep((t - HOLD_END) / WITHER) : 0; + // Each lantern that catches lifts the whole night a step, from deep + // violet towards a warmer, lighter one. + let glow = 0; + for (const P of S.pumpkins) glow += cl((t - P.light) / LIGHT_T, 0, 1); + glow = (glow / max(1, S.pumpkins.length)) * (1 - cl(wi * 2, 0, 1)); + const sg = 1 + SKY_GLOW * glow, warm = 0.025 * glow; + for (let p = 0; p < N; p++) { + const i = p * 3; + let r = bg[i] * sg + warm, g = bg[i + 1] * sg, b = bg[i + 2] * sg; + for (let j = 0; j < S.evN[p]; j++) { + const q = p * EV + j, a = S.evA[q] * cl((t - S.evT[q]) / RAMP, 0, 1); + if (a <= 0) break; + const c = pal[S.evK[q]]; + r = mix(r, c[0], a); + g = mix(g, c[1], a); + b = mix(b, c[2], a); + } + rgb[i] = r; + rgb[i + 1] = g; + rgb[i + 2] = b; + } + // Pumpkins swell, then light one by one; each lit lantern throws a warm + // glow on the leaves and the sky round it. + const lit = []; + for (const P of S.pumpkins) { + const sw = sstep((t - P.t) / PUMP_T); + if (sw <= 0) continue; + const f = cl((t - P.light) / LIGHT_T, 0, 1) * (1 - cl(wi * 2, 0, 1)); + pumpkin(S, P, 0.25 + 0.75 * (1 - (1 - sw) ** 2), f, rgb); + if (f > 0) lit.push([P.x, P.top + 1 + PRY * P.sc, f, P.sc]); + } + const halo = pal.halo, HR = 22; + for (const [hx, hy, f, sc] of lit) + for (let y = max(0, floor(hy - HR)); y <= min(H - 1, hy + HR); y++) + for (let x = max(0, floor(hx - HR)); x <= min(W - 1, hx + HR); x++) { + const dd = hypot((x + 0.5 - hx) / (PRX * sc), (y + 0.5 - hy) / (PRY * sc)); + if (dd < 1.05) continue; + const k = 0.45 * f * (1 - min(1, hypot(x + 0.5 - hx, y + 0.5 - hy) / HR)) ** 2, i = (y * W + x) * 3; + rgb[i] += halo[0] * k; + rgb[i + 1] += halo[1] * k; + rgb[i + 2] += halo[2] * k; + } + for (let p = 0; p < N; p++) { + const i = p * 3; + let r = min(1, rgb[i]), g = min(1, rgb[i + 1]), b = min(1, rgb[i + 2]); + if (wi > 0) { + // Withering: the vine dulls to a dead violet and sinks into the night. + // The sky only loses the lanterns' glow. + const L = max(r, g, b) * 0.6, w2 = S.evN[p] || S.pm[p] ? cl(wi * 1.6, 0, 1) : 0, w3 = sstep((wi - 0.35) / 0.65); + r = mix(mix(r, L * 0.62, w2), bg[i], w3); + g = mix(mix(g, L * 0.18, w2), bg[i + 1], w3); + b = mix(mix(b, L, w2), bg[i + 2], w3); + } else { + // While the vine grows no LED ever gets dimmer: a part that arrives + // darker than what it covers is lifted to the level that was there. + const L = max(r, g, b); + if (L < S.lv[p] && L > 0) { + const k = S.lv[p] / L; + (r = min(1, r * k)), (g = min(1, g * k)), (b = min(1, b * k)); + } else S.lv[p] = L; + } + display.setPixel(p % W, floor(p / W), round(255 * r), round(255 * g), round(255 * b)); + } +} From 01a112d5f1bcf14e76dabb6764e067ddd44ac820 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:38:07 +0000 Subject: [PATCH 3/9] =?UTF-8?q?pattern-study:=20Swarmalators,=20Specimen?= =?UTF-8?q?=20Jar=20(47=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_01PMCSedMgeD9NiVSkNiWyK3 --- .../pattern-study/patterns/specimen-jar.js | 363 ++++++++++++++++++ .../pattern-study/patterns/swarmalators.js | 175 +++++++++ 2 files changed, 538 insertions(+) create mode 100644 incoming/pattern-study/patterns/specimen-jar.js create mode 100644 incoming/pattern-study/patterns/swarmalators.js diff --git a/incoming/pattern-study/patterns/specimen-jar.js b/incoming/pattern-study/patterns/specimen-jar.js new file mode 100644 index 000000000..a8c378792 --- /dev/null +++ b/incoming/pattern-study/patterns/specimen-jar.js @@ -0,0 +1,363 @@ +// ===== Patternflow pattern ===== +// Title: Specimen Jar +// Family: halloween +// Idea: A glass specimen jar on a dark violet shelf, full of glowing toxic-green fluid with light rippling through it, holds a bloodshot eyeball with a violet iris that bobs and turns as it floats, trailing its pink nerve, its gaze wandering round the jar until it swings round to stare straight out, swims up to the glass and opens its pupil wide while the fluid glows up, and bubbles rise past it to pop at the surface. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Bubbles=0..1, Stare=0..1 +// +// Knob 1: Hue. Turns the fluid from toxic green at 0 through teal at 0.3 and deep blue at 0.6 to magenta at 1, never round through orange, which glows brown this dim. The iris always takes the opposite colour: violet at 0, red at 0.3, gold at 0.6, green at 1. The eye's white, its veins, the lid and the shelf stay as they are. +// Knob 2: Speed. At 2 the eye bobs through 16 rows every 7 seconds, a small bubble takes about 6 seconds to rise and a big one 4, and the light ripples drift a few LEDs a second. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Bubbles. At 0 a trickle of 4 bubbles a second from three spots on the bottom, a third of them rings 2 to 4 LEDs across and the rest single sparks; at 0.5 about 15 a second; at 1 a fizz of 46 a second from all over the bottom, most of them rings up to 6 LEDs across. +// Knob 4: Stare. How often the eye turns to stare straight out at you: every 14 seconds at 0, every 13.4 at 0.06 (the first time 4 seconds in), every 3.6 at 1, where it hardly looks away. Each stare lasts about 3 seconds: the eye swings round to face out, swims up to the glass a fifth bigger, its pupil opens from a third of the iris to most of it, and the fluid glows up round it. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, round, sin, cos, sqrt, atan2, acos, exp, hypot, 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 q = (v, n) => round(v * n) / n; +const wrap = (a) => a - TAU * floor((a + PI) / TAU); +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), qq = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [i === 0 || i === 5 ? v : i === 1 ? qq : i === 4 ? t : p, i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? qq : p, i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? qq : p]; +} + +// The jar, row by row: the lid, the neck, the shoulder curving out, the body +// 50 LEDs across inside, the rounded bottom, and the shelf it stands on. +const CX = 32; +const LID0 = 9, LID1 = 20, NECK0 = 21, SH0 = 25, SH1 = 31, BOT0 = 106, BOT1 = 112, SHELF = 114; +// The fluid's surface, a few rows below the shoulder. +const SURF = 34; +function halfIn(y) { + if (y < NECK0 || y >= BOT1) return -1; + if (y < SH0) return 19.5; + if (y < SH1) return 19.5 + 5.5 * sqrt(1 - ((SH1 - y) / (SH1 - SH0)) ** 2); + if (y < BOT0) return 25; + const u = (y - BOT0) / (BOT1 - BOT0); + return 25 - 5 * (1 - sqrt(1 - u * u)); +} + +// The eye's size, and its iris and pupil as angles from the gaze axis. +const ER = 14.5, IRIS = 0.62; +// Veins on the white: eight from the back of the eye towards the iris, each +// with one branch, laid out in the eye's own angles (th from the gaze axis, +// ph round it) so they turn with it. Each is tabled once, ph against th. +const VEINS = [], VT = 129; +for (let k = 0; k < 8; k++) { + const ph0 = (k * TAU) / 8 + 0.35 * sin(k * 2.3), a = 0.22 + 0.08 * sin(k * 1.7), f = 3 + (k % 3), p = k * 1.3; + const tab = new Float32Array(VT); + for (let j = 0; j < VT; j++) { + const th = (j * PI) / (VT - 1); + tab[j] = ph0 + a * sin(f * th + p) + 0.1 * sin(9 * th + 2 * p); + } + VEINS.push({ tab, end: 0.74 + 0.1 * ((k * 5) % 3), br: 1.3 + 0.25 * ((k * 3) % 4), bd: k % 2 ? 1 : -1 }); +} + +export function setup(params) { + const reg = new Uint8Array(N), hw = new Float32Array(H); + for (let y = 0; y < H; y++) hw[y] = halfIn(y); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x, dx = abs(x + 0.5 - CX); + // 1 inside the glass, 2 the glass wall, 3 the thick base, 4 the lid, + // 5 the shelf top, 6 the shelf front, 0 the wall behind. + if (y >= LID0 && y <= LID1) { + const r = y === LID0 ? 20.5 : y === LID0 + 1 ? 22 : 23; + if (dx < r) reg[i] = 4; + } else if (hw[y] > 0 && dx < hw[y]) reg[i] = 1; + else if (hw[y] > 0 && dx < hw[y] + 1.3) reg[i] = 2; + else if (y >= BOT1 && y < SHELF && dx < 23) reg[i] = 3; + if (y >= SHELF && y < SHELF + 2) reg[i] = 5; + else if (y >= SHELF + 2 && y < SHELF + 9) reg[i] = 6; + } + params.s = { + t: 0, hue: 0, speed: 2, bub: 0, stareK: 0.06, + reg, hw, + // Where the eye looks and where it is headed, the stare's phase, how far + // it has turned to stare and how wide the pupil is. + yaw: 0.7, pitch: 0.2, roll: 0, tyaw: 0.7, tpitch: 0.2, next: 1.2, + sp: 0.7, stare: 0, dil: 0, + bubbles: [], acc: 0, pops: [], + rgb: new Float32Array(N * 3), nerve: new Float32Array(N), cs: new Float32Array(2 * W + H), + }; +} + +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.bub = cl(k[2], 0, 1); + S.stareK = cl(k[3], 0, 1); + const g = (d * S.speed) / 2; + S.t += g; + // The stare comes round every 14 seconds at 0 and every 3.6 at 1; for 3.1 + // seconds of each round the eye turns to look straight out of the jar. + const period = mix(14, 3.6, S.stareK); + S.sp += g / period; + const into = (S.sp - floor(S.sp)) * period; + const staring = into < 3.1; + S.stare += ((staring ? 1 : 0) - S.stare) * min(1, g * (staring ? 3.5 : 2)); + S.dil += ((staring && into > 0.5 ? 1 : 0) - S.dil) * min(1, g * 2.2); + // Wandering: somewhere new to look every 1.5 to 3.5 seconds, a quarter of + // the time far round to the side, so the back of the eye and its nerve + // turn into view. + S.next -= g; + if (S.next <= 0) { + S.next = 1.5 + 2 * Math.random(); + S.tyaw = (Math.random() * 2 - 1) * (Math.random() < 0.25 ? 2.3 : 1.1); + S.tpitch = (Math.random() * 2 - 1) * 0.6; + } + const ty = mix(S.tyaw, 0, S.stare), tp = mix(S.tpitch, 0, S.stare); + const rate = min(1, g * (1.2 + 3 * S.stare)); + S.yaw += (ty - S.yaw) * rate; + S.pitch += (tp - S.pitch) * rate; + S.roll = 0.35 * sin(S.t * 0.23) + 0.15 * sin(S.t * 0.61); + // Bubbles: a trickle from three vents at 0, a fizz from all over the + // bottom at 1, more of them big. + S.acc += g * mix(4, 46, S.bub * S.bub); + while (S.acc >= 1) { + S.acc -= 1; + const vent = Math.random() < 0.85 - 0.6 * S.bub; + const x = vent ? CX + [-14, 3, 15][floor(Math.random() * 3)] + Math.random() * 2 - 1 : CX + (Math.random() * 2 - 1) * 22; + const r = Math.random() < 0.3 + 0.4 * S.bub ? 1.1 + Math.random() * (0.9 + S.bub) : 0.5; + S.bubbles.push({ x, y: BOT1 - 1, z: Math.random() * 2 - 1, r, v: 2, ph: TAU * Math.random() }); + } + for (const b of S.bubbles) { + b.v = min(b.v + g * 30, 9 + 7 * b.r); + b.y -= b.v * g; + // A zigzag of about one LED either side as it climbs. + b.x += 3 * sin(b.ph + b.y * 0.25) * g; + if (b.y < SURF + 0.5) { + b.dead = 1; + S.pops.push({ x: b.x, life: 0.25 }); + } + } + S.bubbles = S.bubbles.filter((b) => !b.dead); + for (const p of S.pops) p.life -= g; + S.pops = S.pops.filter((p) => p.life > 0); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { rgb, reg, hw, nerve, cs } = S, t = S.t; + const fl = hsv(0.29 + 0.63 * S.hue, 0.92, 1); + const iris = hsv(0.79 + 0.63 * S.hue, 0.8, 1); + // The eye: bobbing, swaying, swimming up to the glass when it stares. + const bobA = 1 - 0.6 * S.stare; + const ex = CX + 4 * sin(t * 0.45 + 1) * bobA; + const ey = 70 + (8 * sin(t * 0.9) + 1.5 * sin(t * 2.1)) * bobA; + const er = ER * (1 + 0.2 * S.stare); + const ca = cos(S.yaw), sa = sin(S.yaw), cb = cos(S.pitch), sb = sin(S.pitch); + const f = [sa * cb, sb, ca * cb]; + // Two axes across the gaze, turned by the roll, for the angle round it. + const r0 = [ca, 0, -sa], u0 = [-sa * sb, cb, -ca * sb]; + const cr = cos(S.roll), sr = sin(S.roll); + const rx = [r0[0] * cr + u0[0] * sr, r0[1] * cr + u0[1] * sr, r0[2] * cr + u0[2] * sr]; + const ux = [u0[0] * cr - r0[0] * sr, u0[1] * cr - r0[1] * sr, u0[2] * cr - r0[2] * sr]; + const pupil = mix(0.36, 0.7, S.dil); + + // The optic nerve, a pink cord trailing from the back of the eye, waving + // in the fluid. Behind the eye while it looks out, in front of it when it + // looks away. + nerve.fill(0); + const front = f[2] < 0; + { + let bx = -f[0], by = -f[1]; + const bl = hypot(bx, by); + let dx = bl > 0.05 ? bx / bl : 0, dy = bl > 0.05 ? by / bl : 1; + let x = ex + er * bx, y = ey + er * by; + for (let s = 0; s < 13; s++) { + const rad = 2.3 - 0.09 * s; + for (let yy = floor(y - rad); yy <= y + rad; yy++) + for (let xx = floor(x - rad); xx <= x + rad; xx++) { + if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue; + const dd = hypot(xx + 0.5 - x, yy + 0.5 - y); + if (dd < rad) nerve[yy * W + xx] = max(nerve[yy * W + xx], dd < rad - 1 ? 1 : 0.6); + } + // Each step bends towards hanging down and waves with the fluid. + const a = atan2(dy, dx), down = PI / 2, wave = 0.32 * sin(t * 1.6 - s * 0.45); + const na = a + 0.12 * wrap(down - a) + wave * 0.35; + dx = cos(na); + dy = sin(na); + x += 2 * dx; + y += 2 * dy; + } + } + + // The caustics: three wavy bands, light where their sum crosses zero. + // Their inner waves are kept per row, per column and per diagonal. + for (let i = 0; i < W + H; i++) cs[i] = 0.7 * sin((i - H) * 0.1 + t * 0.9); + for (let x = 0; x < W; x++) cs[W + H + x] = 0.9 * sin(x * 0.15 - t * 0.6); + const glowE = 0.1 * (0.6 + 1.4 * S.stare), flare = 1 + 0.5 * S.stare; + const gx = CX - 18.5, gy = 37 + ((t * 11) % 130); + + for (let y = 0; y < H; y++) { + const rowW = 0.9 * sin(y * 0.13 + t * 0.9); + for (let x = 0; x < W; x++) { + const i = y * W + x, rg = reg[i], px = x + 0.5, py = y + 0.5; + let r = 0.035, gg = 0.012, b = 0.06; + if (rg === 1) { + // The fluid's surface waves, and climbs a row at the glass. + const sy = SURF + 0.6 * sin(0.35 * x + 1.9 * t) + 0.4 * sin(0.6 * x - 2.7 * t) - (abs(px - CX) > hw[y] - 2 ? 1 : 0); + if (py < sy - 0.5) { + // Air above the fluid, faintly lit from below. + r = 0.03 + 0.06 * fl[0]; + gg = 0.02 + 0.08 * fl[1]; + b = 0.06 + 0.06 * fl[2]; + } else { + const cyl = 1 - ((px - CX) / hw[y]) ** 2; + const a1 = x * 0.21 + rowW + t * 0.75; + const a2 = y * 0.17 + cs[W + H + x] - t * 1.1; + const a3 = (x + y) * 0.12 + cs[x - y + H]; + const cv = abs(sin(a1) + sin(a2) + sin(a3)); + const caus = cv < 0.3 ? 1 - cv / 0.3 : 0; + const de = sqrt((px - ex) ** 2 + (py - ey) ** 2) - er; + let L = (0.07 + 0.2 * cyl * cyl) * flare + caus * (0.3 + 0.45 * cyl) + (de > 0 && de < 24 ? glowE * exp(-de / 6) : 0); + if (py < sy + 0.5) L = 0.95; + L = q(L, 14); + r = fl[0] * L; + gg = fl[1] * L; + b = fl[2] * L; + } + } else if (rg === 2) { + r = 0.16 + 0.22 * fl[0]; + gg = 0.18 + 0.26 * fl[1]; + b = 0.22 + 0.22 * fl[2]; + } else if (rg === 3) { + r = fl[0] * 0.5; + gg = fl[1] * 0.5; + b = fl[2] * 0.5; + } else if (rg === 4) { + // Pewter lid: ridges, round shading, a bright band and a faint one. + const dx = abs(px - CX), round_ = 0.6 + 0.4 * sqrt(max(0, 1 - (dx / 23) ** 2)); + const ridge = (y - LID0) % 2 === 0 ? 1.25 : 0.8; + const spec = abs(px - (CX - 12)) < 1.5 ? 2.8 : abs(px - (CX + 9)) < 1 ? 1.6 : 1; + const v = ridge * spec * round_ * (y === LID1 ? 0.6 : 1); + r = 0.17 * v; + gg = 0.13 * v; + b = 0.25 * v; + if (y === LID0) (r = 0.42), (gg = 0.36), (b = 0.52); + } else if (rg === 5) { + const pool = exp(-abs(px - CX) / 16); + r = 0.2 + 0.45 * pool * fl[0]; + gg = 0.1 + 0.45 * pool * fl[1]; + b = 0.28 + 0.45 * pool * fl[2]; + } else if (rg === 6) { + const edge = y === SHELF + 2; + r = edge ? 0.26 : 0.1; + gg = edge ? 0.15 : 0.04; + b = edge ? 0.36 : 0.16; + } else if (y < SHELF) { + // The wall behind: violet dark, with the fluid's glow round the jar. + const hwy = hw[cl(y, NECK0, BOT1 - 1)]; + const dxo = max(0, abs(px - CX) - (hwy > 0 ? hwy : 22)); + const glow = 0.1 * exp(-dxo / 5) * (y > SURF ? 1 : 0.4); + r += glow * fl[0]; + gg += glow * fl[1]; + b += glow * fl[2]; + } + if (rg === 1) { + const u = (px - ex) / er, v = (py - ey) / er, d2 = u * u + v * v; + const nv = nerve[i]; + if (nv && (front || d2 >= 1)) { + r = 0.9 * nv; + gg = 0.32 * nv; + b = 0.42 * nv; + } else if (d2 < 1) { + const nz = sqrt(1 - d2), cosA = u * f[0] + v * f[1] + nz * f[2]; + const th = acos(cl(cosA, -1, 1)); + // Lit by the fluid all round, brightest where it faces out. + const sh = 0.55 + 0.45 * nz; + if (th < IRIS * pupil) (r = 0), (gg = 0), (b = 0); + else if (th < IRIS) { + const tt = th / IRIS; + const ph = atan2(u * ux[0] + v * ux[1] + nz * ux[2], u * rx[0] + v * rx[1] + nz * rx[2]); + // Fibres round the pupil, a pale ring at its edge, a near-black + // ring at the rim. The iris never goes below 0.6 of full, so a + // gold one stays gold and does not turn olive. + const streak = 0.8 + 0.2 * sin(ph * 15 + 3 * sin(ph * 4)); + const vv = tt > 0.86 ? 0.05 : streak * (tt < pupil + 0.12 ? 1 : 0.85) * (0.75 + 0.25 * sh); + r = iris[0] * vv; + gg = iris[1] * vv; + b = iris[2] * vv; + } else { + const rim = (1 - nz) * (1 - nz); + r = mix(1, fl[0], 0.7 * rim) * sh; + gg = mix(0.97, fl[1], 0.7 * rim) * sh; + b = mix(0.95, fl[2], 0.7 * rim) * sh; + const ph = atan2(u * ux[0] + v * ux[1] + nz * ux[2], u * rx[0] + v * rx[1] + nz * rx[2]); + const st = sin(th) * er, j = round((th / PI) * (VT - 1)), w = 0.45 + 0.35 * (th / PI); + for (const vn of VEINS) { + if (th < vn.end) continue; + const vp = vn.tab[j]; + if (abs(wrap(ph - vp)) * st < w || (th < vn.br && th > vn.end + 0.1 && abs(wrap(ph - vp - vn.bd * 0.9 * (vn.br - th))) * st < w * 0.8)) { + r = 0.8 * sh; + gg = 0.02 * sh; + b = 0.06 * sh; + break; + } + } + } + // The wet glint. + if ((u + 0.38) ** 2 + (v + 0.42) ** 2 < 0.018) (r = 1), (gg = 1), (b = 1); + } + } + // The glass catches the light: a streak down its left side with a glint + // sliding down it, a fainter one on the right. + if ((rg === 1 || rg === 2) && y > 36 && y < 104) { + let hl = 0; + if (abs(px - gx) < 1) hl = abs(py - gy) < 3 ? 0.7 : 0.26; + else if (abs(px - (CX + 21.5)) < 0.6 && y > 42 && y < 98) hl = 0.16; + r += hl; + gg += hl; + b += hl; + } + const o = i * 3; + rgb[o] = r; + rgb[o + 1] = gg; + rgb[o + 2] = b; + } + } + // Bubbles: a bright point, or a ring for the bigger ones; those behind the + // eye are hidden by it. + for (const bb of S.bubbles) { + const R = bb.r, x0 = floor(bb.x - R - 1), y0 = floor(bb.y - R - 1); + for (let y = y0; y <= y0 + 2 * R + 2; y++) + for (let x = x0; x <= x0 + 2 * R + 2; x++) { + if (x < 0 || x >= W || y < 0 || y >= H || reg[y * W + x] !== 1) continue; + if (bb.z < 0 && (x + 0.5 - ex) ** 2 + (y + 0.5 - ey) ** 2 < er * er) continue; + const dd = hypot(x + 0.5 - bb.x, y + 0.5 - bb.y); + if (R < 1 ? dd < 0.75 : abs(dd - R) < 0.55) { + const o = (y * W + x) * 3; + rgb[o] = mix(fl[0], 1, 0.6); + rgb[o + 1] = mix(fl[1], 1, 0.6); + rgb[o + 2] = mix(fl[2], 1, 0.6); + } + } + } + // A popped bubble flicks two drops off the surface. + for (const p of S.pops) { + const y = SURF - 2 - (p.life < 0.12 ? 1 : 0); + for (const dx of [-1, 1]) { + const x = floor(p.x + dx * (1.5 - p.life * 2)); + if (x < 0 || x >= W || reg[y * W + x] !== 1) continue; + const o = (y * W + x) * 3; + rgb[o] = mix(fl[0], 1, 0.5); + rgb[o + 1] = mix(fl[1], 1, 0.5); + rgb[o + 2] = mix(fl[2], 1, 0.5); + } + } + for (let i = 0; i < N; i++) { + const o = i * 3; + display.setPixel(i % W, floor(i / W), round(255 * cl(rgb[o], 0, 1)), round(255 * cl(rgb[o + 1], 0, 1)), round(255 * cl(rgb[o + 2], 0, 1))); + } +} diff --git a/incoming/pattern-study/patterns/swarmalators.js b/incoming/pattern-study/patterns/swarmalators.js new file mode 100644 index 000000000..5ec4b4416 --- /dev/null +++ b/incoming/pattern-study/patterns/swarmalators.js @@ -0,0 +1,175 @@ +// ===== Patternflow pattern ===== +// Title: Swarmalators +// Family: particle +// Idea: Two swarms of 150 swarmalators stacked down the panel, each a hard dot coloured by its phase that is pulled towards dots of a like colour and kept off by all of them, settle into rainbow rings whose colour runs once round, and as the phase coupling swings slowly up and down the rings squeeze into ovals, splinter and churn, the lower swarm always a step further along the coupling, where its dots circulate round the ring. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Swarmalators (Kevin O'Keeffe, Hyunsuk Hong and Steven Strogatz, 2017), pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Coupling=-1..0.5, Tour=0..1 +// +// Knob 1: Hue. Turns every dot's colour round the wheel together. Each ring always carries the whole wheel, so the knob turns which colour sits where: at 0 the top ring starts red on its right, cyan on its left, blue and magenta on top and yellow and green below; at 0.5 the two halves swap. A swarm that has synchronised is one solid disc: with Coupling at 0.5 the top one goes red at Hue 0 and cyan at 0.5. +// Knob 2: Speed. At 2 the equations run 4 of their time units a second, so a dot circulating in the bottom ring moves about 11 LEDs a second, and the coupling swing takes 24 seconds. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Coupling. The phase coupling K of the top swarm; the bottom swarm's is always 0.75 lower. At 0 the top is the static phase wave, a still rainbow ring, and the bottom the active phase wave, its dots circulating and jostling round a looser ring. At 0.5 the top synchronises within 8 seconds, folding its ring into one solid disc of a single colour 23 LEDs across, while the bottom stiffens into a clean still ring. At -1 the top churns at the edge of disorder and the bottom is static async, a disc of confetti. +// Knob 4: Tour. How far K swings, slowly, about where Coupling puts it: plus and minus the square root of Tour, once every 24 seconds at Speed 2. At 0 there is no swing and only a faint spread of natural frequencies keeps the still ring creeping; at 0.06 K swings by 0.25, so the top ring squeezes into an oval towards sync for 12 seconds and splinters for 12; at 1 K swings by 1: every lap the top ring folds into a one-colour disc, breaks into confetti and opens back into a ring, and the bottom runs from a still ring through its circulating one to confetti and back. + +const W = 64, H = 128; +const { floor, sqrt, sin, cos, PI, round, ceil, max } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + return [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 swarm is round and the panel is twice as tall as it is wide, so it +// carries two swarms, one in each 64 by 64 half, each the model untouched. +// 150 dots a swarm, one unit of the equations is 18.5 LEDs (the widest +// state, the static phase wave, reaches 1.53 units, 28 LEDs, from its +// centre), and a dot is a hard disc of radius 1.76 LEDs, about 10 LEDs. +// At 120 dots of radius 1.6 the panel lit 22% and its colourfulness was +// 0.49; this size lights 29% and reaches 0.57. +const N = 150, SC = 18.5, R2 = 3.1; +// The bottom swarm's coupling sits this far below the top's, so the two show +// neighbouring states; the slow swing of K has this period in story seconds; +// and the natural frequencies are spread evenly over plus and minus this. +const GAP = 0.75, PERIOD = 24, SIG = 0.02; + +// The hue scale lingers on the six pure colours and hurries through the +// in-between ones, so a ring reads as six bands of red, yellow, green, cyan, +// blue and magenta, each dot at 255 in its strongest channel. Applied twice: +// once still showed orange and azure arcs that the panel draws dull. +const ux = (h) => h - sin(6 * TAU * h) / (6 * TAU); + +// A swarm: positions, phases, natural frequencies. The top one starts as the +// static phase wave it is about to be, phase equal to angle round a ring; the +// bottom starts as the paper does, phases at random in a disc. +function swarm(cy, ring) { + const x = new Float32Array(N), y = new Float32Array(N), th = new Float32Array(N), om = new Float32Array(N); + for (let i = 0; i < N; i++) { + const a = (TAU * (i + Math.random())) / N, r = ring ? 0.65 + 0.85 * Math.random() : 1.2 * sqrt(Math.random()); + const b = ring ? a : TAU * Math.random(); + x[i] = r * cos(b); + y[i] = r * sin(b); + th[i] = a; + om[i] = SIG * ((2 * (i + 0.5)) / N - 1); + } + for (let i = N - 1; i > 0; i--) { + const j = floor(Math.random() * (i + 1)), t = om[i]; + om[i] = om[j]; + om[j] = t; + } + return { cy, x, y, th, om, vx: new Float32Array(N), vy: new Float32Array(N), w: new Float32Array(N), c: new Float32Array(N), s: new Float32Array(N), K: 0 }; +} + +// One Euler step of the swarmalator equations with A = B = J = 1: +// dx_i/dt = 1/N sum_j [ (x_j - x_i)/|x_j - x_i| (1 + cos(th_j - th_i)) - (x_j - x_i)/|x_j - x_i|^2 ] +// dth_i/dt = om_i + K/N sum_j sin(th_j - th_i)/|x_j - x_i| +// Each pair is visited once and its pull handed to both dots with opposite +// signs, so the swarm's centre never moves. cos and sin of the phase +// difference come from each dot's own cos and sin, with no trigonometry in +// the pair loop. A frame at Speed 2 is two steps of each swarm, 11175 pairs +// a step, and with the drawing it takes under 2 ms. +function step(sw, K, dt) { + const { x, y, th, om, vx, vy, w, c, s } = sw; + for (let i = 0; i < N; i++) { + c[i] = cos(th[i]); + s[i] = sin(th[i]); + vx[i] = vy[i] = w[i] = 0; + } + for (let i = 0; i < N; i++) { + const xi = x[i], yi = y[i], ci = c[i], si = s[i]; + let ax = 0, ay = 0, aw = 0; + for (let j = i + 1; j < N; j++) { + const dx = x[j] - xi, dy = y[j] - yi, d2 = dx * dx + dy * dy + 1e-6, inv = 1 / sqrt(d2); + const cd = c[j] * ci + s[j] * si, sd = s[j] * ci - c[j] * si; + // Attraction of constant strength 1 + cos, stronger between like + // phases and gone between opposite ones; repulsion falling as 1/r. + const f = (1 + cd) * inv - 1 / d2; + const fx = f * dx, fy = f * dy; + ax += fx; + ay += fy; + vx[j] -= fx; + vy[j] -= fy; + const pw = sd * inv; + aw += pw; + w[j] -= pw; + } + vx[i] += ax; + vy[i] += ay; + w[i] += aw; + } + for (let i = 0; i < N; i++) { + x[i] += (dt * vx[i]) / N; + y[i] += (dt * vy[i]) / N; + const t = th[i] + dt * (om[i] + (K * w[i]) / N); + th[i] = t - TAU * floor(t / TAU); + } +} + +export function setup(params) { + const top = swarm(32, true), bot = swarm(96, false); + // Thirty time units at K = -0.75 before the first frame: from its random + // disc the bottom swarm has found the active phase wave by then. + for (let q = 0; q < 300; q++) step(bot, -GAP, 0.1); + params.s = { hue: 0, speed: 2, K: 0, tour: 0.06, t: 0, sw: [top, bot], buf: new Uint8Array(W * H * 3) }; +} + +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.K = cl(k[2], -1, 0.5); + S.tour = cl(k[3], 0, 1); + const g = (d * S.speed) / 2; + S.t += g; + // The tour: both couplings swing together, a sine about the knob's K. + const swing = sqrt(S.tour) * sin((TAU * S.t) / PERIOD); + S.sw[0].K = S.K + swing; + S.sw[1].K = S.K - GAP + swing; + // Four time units a story second, in steps of at most 0.07. + const T = g * 4; + const n = max(1, ceil(T / 0.07)); + for (let q = 0; q < n; q++) for (const sw of S.sw) step(sw, sw.K, T / n); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const buf = S.buf; + buf.fill(0); + for (const sw of S.sw) { + // Each swarm keeps to its own half: a dot flung out (one in antiphase + // with the rest feels no pull, only the push) leaves the panel rather + // than wander into the other swarm. + const y0h = sw.cy - 32, y1h = sw.cy + 32; + let mx = 0, my = 0; + for (let i = 0; i < N; i++) (mx += sw.x[i]), (my += sw.y[i]); + mx /= N; + my /= N; + for (let i = 0; i < N; i++) { + const c = hsv(ux(ux(sw.th[i] / TAU + S.hue)), 1, 1); + const r = round(c[0] * 255), g = round(c[1] * 255), b = round(c[2] * 255); + const px = 32 + (sw.x[i] - mx) * SC, py = sw.cy + (sw.y[i] - my) * SC; + const x0 = floor(px), y0 = floor(py); + for (let dy = -2; dy <= 2; dy++) + for (let dx = -2; dx <= 2; dx++) { + const ex = x0 + dx + 0.5 - px, ey = y0 + dy + 0.5 - py; + if (ex * ex + ey * ey > R2) continue; + const xx = x0 + dx, yy = y0 + dy; + if (xx < 0 || xx >= W || yy < y0h || yy >= y1h) continue; + const o = (yy * W + xx) * 3; + buf[o] = r; + buf[o + 1] = g; + buf[o + 2] = b; + } + } + } + for (let i = 0; i < W * H; i++) display.setPixel(i % W, floor(i / W), buf[i * 3], buf[i * 3 + 1], buf[i * 3 + 2]); +} From edc4f96a7616f954bb3d85d14d6d0a28f9f3765a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:38:40 +0000 Subject: [PATCH 4/9] =?UTF-8?q?pattern-study:=20Koebe=20Packing,=20Web=20T?= =?UTF-8?q?remor=20(47=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_01PMCSedMgeD9NiVSkNiWyK3 --- .../pattern-study/patterns/koebe-packing.js | 549 ++++++++++++++++++ incoming/pattern-study/patterns/web-tremor.js | 520 +++++++++++++++++ 2 files changed, 1069 insertions(+) create mode 100644 incoming/pattern-study/patterns/koebe-packing.js create mode 100644 incoming/pattern-study/patterns/web-tremor.js diff --git a/incoming/pattern-study/patterns/koebe-packing.js b/incoming/pattern-study/patterns/koebe-packing.js new file mode 100644 index 000000000..654b2561f --- /dev/null +++ b/incoming/pattern-study/patterns/koebe-packing.js @@ -0,0 +1,549 @@ +// ===== Patternflow pattern ===== +// Title: Koebe Packing +// Family: cells +// Idea: A tall rectangle of discs, each touching exactly its neighbours in a triangulation drawn as spokes through the kissing points, re-solves itself every time an edge of the triangulation flips or a vertex is born or removed, so the discs swell and shrink into the one packing the new graph allows. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: circle packing theorem (Paul Koebe, 1936; E. M. Andreev, 1970; William Thurston, 1980s), computed by the iteration of Charles Collins and Kenneth Stephenson (2003), pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Circles=22..56, Graph=0..1 +// +// Knob 1: Hue. Turns the colours of the degrees round the wheel: at 0 the small discs of degree 4 are pink, 5 amber, 6 cyan, 7 blue and the big ones of degree 8 violet; at 0.5 the same five are green, blue, red, amber and lime. +// Knob 2: Speed. At 2 the triangulation changes every 1.2 seconds and the discs take about a second to settle into the new packing; at 0.2 a change comes every 12 seconds, at 3 every 0.8. +// Knob 3: Circles. About how many discs the packing holds, 22 to 56 (a birth or a removal moves it by one or two for a while); the rim of the rectangle grows with them, 14 boundary discs up to 24, 20 up to 40, 26 above. Turning it grows a new triangulation from points. +// Knob 4: Graph. At 0 the discs are solid and the triangulation shows as dark spokes from each centre to the points where it kisses its neighbours; toward 1 the discs empty to rings and the spokes turn white, so the triangulation itself is the picture. + +const W = 64, H = 128; +const { floor, min, max, abs, sqrt, sin, cos, asin, atan2, round, 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; + +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]; +} + +// Hue of each degree, added to Hue + 0.9 so that 0 opens on pink, not red. +// A boundary disc counts as the degree its angle would have inside: faces +// times 2 pi over its target angle, so a side disc with three faces is a 6. +const DEG_HUE = [0, 0, 0, 0, 0, 0.21, 0.6, 0.73, 0.86]; +// Interior degree limits for an ordinary flip; a disc being born or removed +// may sit at 3 for a while. +const DMIN = 4, DMAX = 8; +// One change in this many story seconds (one a second at Speed 2). +const EVERY = 1.2; +// How freely a flip may push degrees away from 6. +const TEMP = 4; + +// --- the triangulation --- +// Each vertex keeps its flower: the neighbours in counterclockwise order, +// closed for an interior vertex, open for a boundary vertex (from the next +// boundary vertex round to the previous one). kind is 0 inside, 1 on a side +// of the rectangle, 2 at a corner. Target angle sums: 2 pi inside, pi along +// a side (the centres line up), pi/2 at a corner. + +function faces(S, v) { + return S.kind[v] ? S.fl[v].length - 1 : S.fl[v].length; +} +function target(S, v) { + return S.kind[v] === 0 ? TAU : S.kind[v] === 1 ? PI : PI / 2; +} +function effDeg(S, v, df = 0) { + return round(((faces(S, v) + df) * TAU) / target(S, v)); +} +function hasNb(S, a, b) { + return S.fl[a].indexOf(b) >= 0; +} +// Two boundary vertices may be joined across the inside only when they are +// the two neighbours of one corner (the edge cuts that corner off); joined +// anywhere else they would have to touch through the line of discs between. +function cornerCut(S, a, b) { + if (!S.kind[a] || !S.kind[b]) return true; + const B = S.B, d = (S.bi[b] - S.bi[a] + B) % B; + if (d === 2) return S.kind[S.ring[(S.bi[a] + 1) % B]] === 2; + if (d === B - 2) return S.kind[S.ring[(S.bi[b] + 1) % B]] === 2; + return false; +} +// Can vertex v lose a face, or gain one? A side disc keeps two to four +// faces, a corner disc one or two. +function canLose(S, v, focus) { + const f = faces(S, v); + if (S.kind[v] === 0) return f - 1 >= (v === focus ? 3 : DMIN); + return f - 1 >= (S.kind[v] === 2 ? 1 : 2); +} +function canGain(S, v) { + const f = faces(S, v); + if (S.kind[v] === 0) return f + 1 <= DMAX; + return f + 1 <= (S.kind[v] === 2 ? 2 : 4); +} + +// The edge uv with its two triangles (u, v, a) and (u, b, v), both +// counterclockwise, or null when uv lies on the boundary. +function quad(S, u, v) { + const fu = S.fl[u], i = fu.indexOf(v), n = fu.length; + if (i < 0) return null; + if (S.kind[u]) { + if (i === 0 || i === n - 1) return null; + return [fu[i + 1], fu[i - 1]]; + } + return [fu[(i + 1) % n], fu[(i - 1 + n) % n]]; +} + +function canFlip(S, u, v, focus) { + const q = quad(S, u, v); + if (!q) return null; + const [a, b] = q; + if (a === b || hasNb(S, a, b) || !cornerCut(S, a, b)) return null; + if (!canLose(S, u, focus) || !canLose(S, v, focus) || !canGain(S, a) || !canGain(S, b)) return null; + return q; +} + +// Flip uv to ab: the quadrilateral u, b, v, a keeps its four sides and +// swaps its diagonal. +function flip(S, u, v, a, b) { + const rm = (x, y) => S.fl[x].splice(S.fl[x].indexOf(y), 1); + const ins = (x, after, y) => S.fl[x].splice(S.fl[x].indexOf(after) + 1, 0, y); + rm(u, v); + rm(v, u); + ins(a, u, b); + ins(b, v, a); + S.flash.push({ a, b, t: 0 }); +} + +function newId(S) { + for (let i = 0; i < S.kind.length; i++) if (!S.alive[i] && S.dr[i] <= 0) return i; + S.kind.push(0), S.fl.push([]), S.alive.push(0), S.bi.push(-1); + for (const k of ["R", "x", "y", "dx", "dy", "dr", "hu"]) S[k].push(0); + return S.kind.length - 1; +} + +// A new vertex in triangle (u, v, w), counterclockwise: it starts with +// degree 3, the small circle in the gap between three that touch. +function insert(S, u, v, w) { + const x = newId(S); + S.kind[x] = 0; + S.alive[x] = 1; + S.fl[x] = [u, v, w]; + const ins = (p, after, y) => S.fl[p].splice(S.fl[p].indexOf(after) + 1, 0, y); + ins(u, v, x); + ins(v, w, x); + ins(w, u, x); + S.R[x] = 0.15 * min(S.R[u], S.R[v], S.R[w]); + // Born at the gap between the three, with no size yet. + S.dx[x] = (S.dx[u] + S.dx[v] + S.dx[w]) / 3; + S.dy[x] = (S.dy[u] + S.dy[v] + S.dy[w]) / 3; + S.dr[x] = 0; + S.born = x; + for (const p of [u, v, w]) S.flash.push({ a: x, b: p, t: 0 }); + return x; +} + +// Remove an interior vertex of degree 3: its three triangles become one. +function remove(S, x) { + for (const p of S.fl[x]) S.fl[p].splice(S.fl[p].indexOf(x), 1); + S.fl[x] = []; + S.alive[x] = 0; +} + +// The starting triangulation: a grid of c by 2c vertices, each square cut +// by a random diagonal, its outer ring the boundary with corners at the +// grid's corners; then interior vertices put into random triangles or taken +// out until there are n, and a few hundred random flips to forget the grid. +function build(S, n, grow) { + const c = max(3, round(sqrt(n / 2))), r = 2 * c; + Object.assign(S, { kind: [], fl: [], alive: [], bi: [], R: [], x: [], y: [], dx: [], dy: [], dr: [], hu: [], flash: [], focus: -1, mode: 0, born: -1 }); + const id = (i, j) => j * c + i, adj = []; + for (let k = 0; k < c * r; k++) newId(S), (S.alive[k] = 1), (adj[k] = new Set()); + const link = (p, q) => (adj[p].add(q), adj[q].add(p)); + for (let j = 0; j < r; j++) + for (let i = 0; i < c; i++) { + if (i + 1 < c) link(id(i, j), id(i + 1, j)); + if (j + 1 < r) link(id(i, j), id(i, j + 1)); + if (i + 1 < c && j + 1 < r) Math.random() < 0.5 ? link(id(i, j), id(i + 1, j + 1)) : link(id(i + 1, j), id(i, j + 1)); + } + // The boundary ring, counterclockwise (with y up): along the bottom, + // up the right, back along the top, down the left. + const ring = []; + for (let i = 0; i < c - 1; i++) ring.push(id(i, 0)); + for (let j = 0; j < r - 1; j++) ring.push(id(c - 1, j)); + for (let i = c - 1; i > 0; i--) ring.push(id(i, r - 1)); + for (let j = r - 1; j > 0; j--) ring.push(id(0, j)); + S.ring = ring; + S.B = ring.length; + S.corners = [id(0, 0), id(c - 1, 0), id(c - 1, r - 1), id(0, r - 1)]; + ring.forEach((v, k) => ((S.kind[v] = 1), (S.bi[v] = k))); + for (const v of S.corners) S.kind[v] = 2; + // Flowers by angle round each grid point; a boundary flower starts just + // after the widest gap, which is the outside. + for (let k = 0; k < c * r; k++) { + const i = k % c, j = floor(k / c); + const nb = [...adj[k]].map((q) => [atan2(floor(q / c) - j, (q % c) - i), q]).sort((p, q) => p[0] - q[0]); + let start = 0; + if (S.kind[k]) { + let gap = -1; + for (let m = 0; m < nb.length; m++) { + const g = (nb[m][0] - nb[(m - 1 + nb.length) % nb.length][0] + TAU) % TAU || TAU; + if (g > gap) (gap = g), (start = m); + } + } + S.fl[k] = nb.map((_, m) => nb[(start + m) % nb.length][1]); + S.alive[k] = 1; + S.R[k] = 1; + } + // Each new vertex is followed by flips, which spread the degrees and free + // room for the next one. + for (let tries = 0; tries < 400 && count(S) !== n; tries++) { + const t = triangles(S).filter((p) => p.every((q) => canGain(S, q))); + if (count(S) > n) drop(S); + else if (t.length) insert(S, ...t[floor(Math.random() * t.length)]); + for (let k = 0; k < 20; k++) randomFlip(S, TEMP); + } + S.born = -1; + for (let k = 0; k < 300; k++) randomFlip(S, TEMP); + S.flash = []; + for (let k = 0; k < 600; k++) sweep(S); + layout(S); + for (let v = 0; v < S.kind.length; v++) { + S.dx[v] = S.x[v]; + S.dy[v] = S.y[v]; + S.dr[v] = S.alive[v] && !grow ? S.tr[v] : 0; + S.hu[v] = hueOf(S, v); + } +} + +// Take one interior vertex out: flip its edges away until it has three, +// then remove it. +function drop(S) { + const vs = []; + for (let v = 0; v < S.kind.length; v++) if (S.alive[v] && !S.kind[v]) vs.push(v); + vs.sort((p, q) => S.fl[p].length - S.fl[q].length); + for (const x of vs.slice(0, 6)) { + S.focus = x; + for (let k = 0; k < 12 && S.fl[x].length > 3; k++) { + const opts = S.fl[x].filter((p) => canFlip(S, x, p, x)); + if (!opts.length) break; + const p = opts[floor(Math.random() * opts.length)], q = canFlip(S, x, p, x); + flip(S, x, p, q[0], q[1]); + } + if (S.fl[x].length === 3 && S.fl[x].every((p) => canLose(S, p, -1))) { + remove(S, x); + break; + } + } + S.focus = -1; +} + +function count(S) { + let n = 0; + for (let v = 0; v < S.kind.length; v++) n += S.alive[v]; + return n; +} + +// Every triangle once, counterclockwise. +function triangles(S) { + const out = []; + for (let v = 0; v < S.kind.length; v++) { + if (!S.alive[v]) continue; + const f = S.fl[v], n = f.length, m = S.kind[v] ? n - 1 : n; + for (let i = 0; i < m; i++) { + const a = f[i], b = f[(i + 1) % n]; + if (v < a && v < b) out.push([v, a, b]); + } + } + return out; +} + +// Interior degrees like to stay near 6: a flip that moves them away is +// taken less often, so the packing keeps a mix of sizes without running to +// one huge disc. +function energy(S, vs, d) { + let e = 0; + vs.forEach((v, k) => { + const g = effDeg(S, v, d[k]); + e += (g - 6) * (g - 6); + }); + return e; +} + +function randomFlip(S, temp) { + for (let tries = 0; tries < 40; tries++) { + const u = floor(Math.random() * S.kind.length); + if (!S.alive[u] || u === S.focus) continue; + const f = S.fl[u], v = f[floor(Math.random() * f.length)]; + if (v === S.focus) continue; + const q = canFlip(S, u, v, -1); + if (!q) continue; + const vs = [u, v, q[0], q[1]], dE = energy(S, vs, [-1, -1, 1, 1]) - energy(S, vs, [0, 0, 0, 0]); + if (dE > 0 && Math.random() > exp(-dE / temp)) continue; + flip(S, u, v, q[0], q[1]); + return true; + } + return false; +} + +// One change of the triangulation. A disc being born keeps growing by +// flips of the edges across from it until it has degree 5 or 6; a disc +// being removed loses an edge a change until it is down to three, then goes. +function change(S) { + const x = S.focus; + if (x >= 0 && S.mode === 1) { + // Grow: flip an edge ab of x's link to the vertex c across it. + const f = S.fl[x], n = f.length, opts = []; + for (let i = 0; i < n; i++) { + const a = f[i], b = f[(i + 1) % n], q = canFlip(S, a, b, x); + if (q && (q[0] === x || q[1] === x)) opts.push([a, b, q]); + } + if (opts.length && n < S.goal) { + const [a, b, q] = opts[floor(Math.random() * opts.length)]; + flip(S, a, b, q[0], q[1]); + if (S.fl[x].length >= S.goal) S.focus = -1; + return; + } + S.focus = -1; + } else if (x >= 0 && S.mode === 2) { + // Shrink: flip one of x's own edges away. + if (S.fl[x].length <= 3) { + if (S.fl[x].every((p) => canLose(S, p, -1))) remove(S, x); + S.focus = -1; + return; + } + const opts = S.fl[x].filter((p) => canFlip(S, x, p, x)); + if (opts.length) { + const p = opts[floor(Math.random() * opts.length)], q = canFlip(S, x, p, x); + flip(S, x, p, q[0], q[1]); + return; + } + S.focus = -1; + } + const n = count(S), r = Math.random(); + if (r < 0.22 && n <= S.n) { + // Born in the biggest gap: the triangle with the largest circles. + let best = null, bs = -1; + for (const t of triangles(S)) { + if (!t.every((p) => canGain(S, p))) continue; + const s = min(S.R[t[0]], S.R[t[1]], S.R[t[2]]) * (0.8 + 0.4 * Math.random()); + if (s > bs) (bs = s), (best = t); + } + if (best) { + S.focus = insert(S, ...best); + S.mode = 1; + S.goal = 5 + (Math.random() < 0.5 ? 1 : 0); + return; + } + } else if (r < 0.44 && n >= S.n) { + // Removed: an interior disc of low degree, not one just born. + const vs = []; + for (let v = 0; v < S.kind.length; v++) if (S.alive[v] && !S.kind[v] && v !== S.born) vs.push(v); + vs.sort((p, q) => S.fl[p].length - S.fl[q].length); + if (vs.length) { + S.focus = vs[floor(Math.random() * min(3, vs.length))]; + S.mode = 2; + change(S); + return; + } + } + randomFlip(S, TEMP); +} + +// --- the packing --- +// The angle at v in the triangle of three mutually tangent circles with +// radii r, a, b: sides r + a, r + b, a + b, and by the half-angle form of +// the law of cosines sin^2(theta / 2) = ab / ((r + a)(r + b)). +const ang = (r, a, b) => 2 * asin(sqrt((a * b) / ((r + a) * (r + b)))); + +// One sweep of Collins and Stephenson: each vertex is given the radius its +// angle sum would need if its k neighbours were all one size (the uniform +// neighbour model): beta = sin(sum / 2k) gives that size, delta = +// sin(target / 2k) the radius that meets the target with it. Every target +// is an angle, so the packing is free in scale; the mean radius is held at 1. +function sweep(S) { + const R = S.R; + let sum = 0, n = 0, err = 0; + for (let v = 0; v < S.kind.length; v++) { + if (!S.alive[v]) continue; + const f = S.fl[v], m = f.length, k = S.kind[v] ? m - 1 : m, r = R[v]; + let th = 0; + for (let i = 0; i < k; i++) th += ang(r, R[f[i]], R[f[(i + 1) % m]]); + const T = target(S, v); + err = max(err, abs(th - T)); + const beta = min(0.99999, sin(th / (2 * k))), delta = sin(T / (2 * k)); + R[v] = ((beta * r) / (1 - beta)) * ((1 - delta) / delta); + sum += R[v]; + n++; + } + for (let v = 0; v < S.kind.length; v++) if (S.alive[v]) R[v] *= n / sum; + return err; +} + +// Lay the circles out from one placed triangle: each placed vertex places +// the rest of its flower by the angles round it, counterclockwise from a +// placed neighbour (and clockwise too on the boundary). Then turn the +// rectangle upright from its first corner to its second and fit it to the +// panel. +function layout(S) { + const nV = S.kind.length, R = S.R, X = new Float64Array(nV), Y = new Float64Array(nV), done = new Uint8Array(nV); + let v0 = 0; + while (!S.alive[v0]) v0++; + const v1 = S.fl[v0][0]; + done[v0] = done[v1] = 1; + X[v1] = R[v0] + R[v1]; + const queue = [v0, v1]; + while (queue.length) { + const v = queue.shift(), f = S.fl[v], m = f.length; + let i0 = -1; + for (let i = 0; i < m; i++) if (done[f[i]]) (i0 = i), (i = m); + if (i0 < 0) continue; + const step = (dir) => { + let prev = f[i0], i = i0; + for (let s = 1; s < m; s++) { + const j = S.kind[v] ? i + dir : (i + dir + m) % m; + if (j < 0 || j >= m) break; + const u = f[j]; + if (!done[u]) { + const a = atan2(Y[prev] - Y[v], X[prev] - X[v]) + dir * ang(R[v], R[prev], R[u]), d = R[v] + R[u]; + X[u] = X[v] + d * cos(a); + Y[u] = Y[v] + d * sin(a); + done[u] = 1; + queue.push(u); + } + prev = u; + i = j; + } + }; + step(1); + if (S.kind[v]) step(-1); + } + const [k0, k1] = S.corners, rot = -atan2(Y[k1] - Y[k0], X[k1] - X[k0]), cr = cos(rot), sr = sin(rot); + let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; + for (let v = 0; v < nV; v++) { + if (!S.alive[v]) continue; + const x = X[v] * cr - Y[v] * sr, y = X[v] * sr + Y[v] * cr; + (X[v] = x), (Y[v] = y); + x0 = min(x0, x - R[v]), x1 = max(x1, x + R[v]), y0 = min(y0, y - R[v]), y1 = max(y1, y + R[v]); + } + const sc = min((W - 1) / (x1 - x0), (H - 1) / (y1 - y0)), ox = (W - sc * (x1 - x0)) / 2, oy = (H - sc * (y1 - y0)) / 2; + S.tr = S.tr && S.tr.length === nV ? S.tr : new Float64Array(nV); + for (let v = 0; v < nV; v++) { + if (!S.alive[v]) continue; + S.x[v] = ox + (X[v] - x0) * sc; + // Panel rows run down, so y up becomes the row counted from the bottom. + S.y[v] = H - (oy + (Y[v] - y0) * sc); + S.tr[v] = R[v] * sc; + } +} + +function hueOf(S, v) { + return 0.9 + S.hue + DEG_HUE[cl(effDeg(S, v), 4, 8)]; +} + +export function setup(params) { + params.s = { hue: 0, speed: 2, graph: 0.06, n: 0, clock: 0, buf: new Float32Array(W * H * 3) }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 22, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0] || 0; + S.speed = cl(k[1], 0.2, 3); + S.graph = cl(k[3], 0, 1); + const n = round(cl(k[2], 22, 56)); + if (n !== S.n) { + // A new count grows a new packing from points; the first one opens + // already grown. + build(S, n, S.n > 0); + S.n = n; + // The first change comes soon after the start. + S.clock = EVERY * 0.6; + } + const g = (d * S.speed) / 2; + S.clock += g; + if (S.clock >= EVERY) { + S.clock -= EVERY; + change(S); + } + for (let i = 0; i < 25; i++) sweep(S); + layout(S); + // The drawn discs ease toward the packing, about a second to settle at + // Speed 2; a removed disc shrinks to nothing where it was. + const e = 1 - exp(-g * 2.6); + for (let v = 0; v < S.kind.length; v++) { + if (S.alive[v]) { + S.dx[v] += (S.x[v] - S.dx[v]) * e; + S.dy[v] += (S.y[v] - S.dy[v]) * e; + S.dr[v] += (S.tr[v] - S.dr[v]) * e; + // The colour turns the short way round the wheel, so it stays bright + // on the way instead of passing through grey. + const dh = hueOf(S, v) - S.hu[v]; + S.hu[v] += (dh - round(dh)) * e; + } else if (S.dr[v] > 0) { + S.dr[v] -= S.dr[v] * e + g * 0.5; + if (S.dr[v] < 0.3) S.dr[v] = 0; + } + } + for (const f of S.flash) f.t += g; + S.flash = S.flash.filter((f) => f.t < 1.4 && S.alive[f.a] && S.alive[f.b]); +} + +function line(buf, x0, y0, x1, y1, fn) { + const n = Math.ceil(max(abs(x1 - x0), abs(y1 - y0))); + for (let i = 0; i <= n; i++) { + const t = n ? i / n : 0, x = floor(mix(x0, x1, t) + 0.5), y = floor(mix(y0, y1, t) + 0.5); + if (x >= 0 && x < W && y >= 0 && y < H) fn((y * W + x) * 3); + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.kind) return; + const buf = S.buf, gk = S.graph; + buf.fill(0); + const fill = 1 - 0.82 * gk; + // Discs: a solid inside (dimmed toward Graph 1), a full-bright rim one LED + // wide, and half an LED of black at the edge so two discs part everywhere + // but where they kiss. + for (let v = 0; v < S.kind.length; v++) { + const r = S.dr[v]; + if (r < 0.6) continue; + const cx = S.dx[v], cy = S.dy[v], ro = r - 0.45, ri = r - 1.55, c3 = hsv(S.hu[v], 0.9, 1); + const xa = max(0, floor(cx - r)), xb = min(W - 1, floor(cx + r)), ya = max(0, floor(cy - r)), yb = min(H - 1, floor(cy + r)); + for (let y = ya; y <= yb; y++) + for (let x = xa; x <= xb; x++) { + const dd = sqrt((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2); + if (dd >= ro) continue; + const b = dd > ri ? 1 : fill, o = (y * W + x) * 3; + buf[o] = c3[0] * b; + buf[o + 1] = c3[1] * b; + buf[o + 2] = c3[2] * b; + } + } + // The triangulation: each edge from centre to centre through the point + // where the two discs touch. Dark on a solid disc, white on a ring. + const dark = 0.3 * (1 - gk), wt = 0.85 * gk; + for (let v = 0; v < S.kind.length; v++) { + if (!S.alive[v]) continue; + for (const u of S.fl[v]) { + if (u < v && S.alive[u]) continue; + line(buf, S.dx[v], S.dy[v], S.dx[u], S.dy[u], (o) => { + buf[o] = buf[o] * dark + wt; + buf[o + 1] = buf[o + 1] * dark + wt; + buf[o + 2] = buf[o + 2] * dark + wt; + }); + } + } + // A new edge flashes white for a second as the two discs come to touch. + for (const f of S.flash) { + const a = cl(1.4 - f.t, 0, 1); + line(buf, S.dx[f.a], S.dy[f.a], S.dx[f.b], S.dy[f.b], (o) => { + buf[o] = mix(buf[o], 1, a); + buf[o + 1] = mix(buf[o + 1], 1, a); + buf[o + 2] = mix(buf[o + 2], 1, a); + }); + } + for (let o = 0, y = 0; y < H; y++) + for (let x = 0; x < W; x++, o += 3) display.setPixel(x, y, round(255 * min(1, buf[o])), round(255 * min(1, buf[o + 1])), round(255 * min(1, buf[o + 2]))); +} diff --git a/incoming/pattern-study/patterns/web-tremor.js b/incoming/pattern-study/patterns/web-tremor.js new file mode 100644 index 000000000..9bc23e038 --- /dev/null +++ b/incoming/pattern-study/patterns/web-tremor.js @@ -0,0 +1,520 @@ +// ===== Patternflow pattern ===== +// Title: Web Tremor +// Family: halloween +// Idea: A pale moth blunders into a tall orb web under the moon and every time it struggles a pulse of light runs out from it along the silk, fast down the radial threads and slower round the spiral, until the spider at the hub feels it, turns, runs out, wraps the moth into a white bundle and walks back, the web sagging and quivering under them, and then another moth comes. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Struggle=0..1, Sag=0..1 +// +// Knob 1: Hue. The colour of the pulses: at 0 toxic green, at 0.25 icy cyan, at 0.5 moonlight white, at 0.75 violet, each held for 0.06 either side of its point and blending into the next, and 1 is green again. The silk, the moon, the moth and the spider keep their colours. +// Knob 2: Speed. At 2 one hunt takes about 13 seconds: the moth flutters in for 2, struggles for 3 to 4 while the spider feels it, turns and runs out, is wrapped for 2.8, and the spider walks back for 2 and rests for 1. A pulse crosses the web in about 2 seconds. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Struggle. How hard the moth fights: at 0 it heaves in twos about every 1.3 seconds and a pulse has lost half its light 52 LEDs out; at 1 it heaves in threes every 0.6 seconds, the pulses start a quarter brighter and keep half their light to 83 LEDs, and the web round the moth shakes three times as hard. +// Knob 4: Sag. How slack the web hangs: at 0.06 the hub sits 1.4 LEDs low, 3 with the spider on it, and the moth pulls its crossing about 3 down; at 1 the web bellies 7 LEDs at the hub, 11 with the spider, and the moth drags its crossing about 9 down. The frame never moves. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, atan2, round, 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 q = (v, n) => round(v * n) / n; +const rnd = (a, b) => a + (b - a) * Math.random(); + +// Pulse colours round K1: toxic green, icy cyan, moonlight white, violet. +const PULSE = [[0.35, 1, 0.12], [0.2, 0.9, 1], [0.88, 0.94, 1], [0.8, 0.3, 1]]; +// Silk in moonlight, the moon, the moth, the spider (a cold violet grey +// with green eyes), the wrapping silk. +const SILK = [0.56, 0.6, 0.92], MOON = [0.86, 0.9, 1], WING = [0.88, 0.85, 0.98], MBODY = [1, 0.96, 0.86]; +const SBODY = [0.42, 0.32, 0.66], SLEG = [0.58, 0.54, 0.78], SEYE = [0.4, 1, 0.3], WRAP = [0.97, 0.97, 1]; +const SILK_V = 0.34; +// The web: hub, sixteen radials out to a frame just inside the panel, and +// the sticky spiral as rings 4.6 LEDs apart; a ring stops where a radial is +// too short for it, so the long upper and lower parts have more turns. +const HX = 32, HY = 63, K = 16, HUBR = 3, R0 = 7.5, GAP = 4.6; +const BOX = [3, 6, 60, 122]; +const MOONX = 46, MOONY = 19, MOONR = 10; +// A pulse runs along the graph of the web at V LEDs a second, the spiral +// costing SPIRAL times its length (vibration carries best down the +// radials, which are stiffer), with a sharp front and a short tail. +const V = 44, SPIRAL = 1.8, FRONT = 3, TAIL = 9; +// Story seconds (one a second at Speed 2): the moth's flight in, the +// spider's turn, the wrap, the rest at the hub; and the spider's pace out +// and back, in LEDs a second. +const FLY = 2, TURN = 0.5, WRAPT = 2.8, REST = 1; +const RUN = 30, BACK = 20; + +function buildWeb(S) { + const nx = [], ny = [], nu = [], edges = [], th = [], R = []; + const node = (x, y, u) => (nx.push(x), ny.push(y), nu.push(u), nx.length - 1); + const hub = node(HX, HY, 0); + for (let k = 0; k < K; k++) { + const a = ((k + 0.5) * TAU) / K + rnd(-0.07, 0.07), c = cos(a), s = sin(a); + // Out to the box, a little short of it. + let L = 1e9; + if (c > 0) L = min(L, (BOX[2] - HX) / c); + if (c < 0) L = min(L, (BOX[0] - HX) / c); + if (s > 0) L = min(L, (BOX[3] - HY) / s); + if (s < 0) L = min(L, (BOX[1] - HY) / s); + th.push(a); + R.push(L * 0.97); + } + // Rings: the hub ring, then the sticky rings while any radial reaches. + const rings = [HUBR]; + for (let r = R0; r < max(...R) - 3; r += GAP) rings.push(r); + const id = []; + for (let k = 0; k < K; k++) { + id.push([]); + for (let j = 0; j < rings.length; j++) { + const r = rings[j]; + id[k].push(r <= R[k] - 2.5 ? node(HX + r * cos(th[k]), HY + r * sin(th[k]), r / R[k]) : -1); + } + id[k].frame = node(HX + R[k] * cos(th[k]), HY + R[k] * sin(th[k]), 1); + } + // Edges: 0 radial, 1 spiral, 2 frame. + for (let k = 0; k < K; k++) { + let prev = hub; + for (let j = 0; j < rings.length; j++) if (id[k][j] >= 0) edges.push([prev, id[k][j], 0]), (prev = id[k][j]); + edges.push([prev, id[k].frame, 0]); + const k2 = (k + 1) % K; + for (let j = 0; j < rings.length; j++) if (id[k][j] >= 0 && id[k2][j] >= 0) edges.push([id[k][j], id[k2][j], 1]); + edges.push([id[k].frame, id[k2].frame, 2]); + } + // Moorings from the frame to the panel's edges, out of the graph. + const moor = []; + for (const [k, ex, ey] of [[1, 63, 127], [6, 0, 127], [9, 0, 0], [14, 63, 0]]) moor.push([id[k].frame, ex, ey]); + const n = nx.length, cost = edges.map(([a, b, t]) => sqrt((nx[a] - nx[b]) ** 2 + (ny[a] - ny[b]) ** 2) * (t === 1 ? SPIRAL : t === 2 ? 1.3 : 1)); + const adj = Array.from({ length: n }, () => []); + edges.forEach(([a, b], e) => (adj[a].push([b, e]), adj[b].push([a, e]))); + Object.assign(S, { nx, ny, nu, edges, cost, adj, id, rings, th, R, hub, moor, n, px: new Float32Array(n), py: new Float32Array(n) }); +} + +// Graph distance of every node from a source node, by Dijkstra. +function dists(S, src) { + const d = new Float32Array(S.n).fill(1e9), done = new Uint8Array(S.n); + d[src] = 0; + for (let it = 0; it < S.n; it++) { + let u = -1, best = 1e9; + for (let i = 0; i < S.n; i++) if (!done[i] && d[i] < best) (best = d[i]), (u = i); + if (u < 0) break; + done[u] = 1; + for (const [w, e] of S.adj[u]) if (d[u] + S.cost[e] < d[w]) d[w] = d[u] + S.cost[e]; + } + return d; +} + +export function setup(params) { + const S = { + hue: 0, speed: 2, fight: 0, sag: 0.06, t: 0, rt: 0, + rgb: new Float32Array(N * 3), P: new Float32Array(N), B: new Float32Array(N), T: new Float32Array(N), bg: new Float32Array(N * 3), + pulses: [], kicks: [], bundles: [], + phase: "fly", pt: FLY * 0.45, moth: null, sp: { x: HX, y: HY + 1, ox: HX, oy: HY, hd: PI / 2, gait: 0, s: 0, state: "wait", st: 0, feel: 0, heard: 0 }, + }; + buildWeb(S); + // The sky: violet at the top to blue-black below, in steps of a 64th, + // and the moon's halo in steps of an eighth. + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x, v = 1 - y / H, d = sqrt((x + 0.5 - MOONX) ** 2 + (y + 0.5 - MOONY) ** 2); + const halo = q(cl(1 - (d - MOONR) / 22, 0, 1) ** 2, 8); + S.bg[i * 3] = q(0.035 + 0.045 * v, 64) + 0.12 * halo; + S.bg[i * 3 + 1] = q(0.008 + 0.006 * v, 64) + 0.1 * halo; + S.bg[i * 3 + 2] = q(0.075 + 0.05 * v, 64) + 0.2 * halo; + } + params.s = S; + newMoth(S); + web(S); +} + +// A new moth: it picks a crossing of the spiral and a radial away from the +// hub, not on or next to a radial with a bundle already hanging, and flies +// in from the side it is nearer. +function newMoth(S) { + let k, j, node, tries = 0; + do { + k = floor(Math.random() * K); + j = 5 + floor(Math.random() * (S.rings.length - 5)); + node = S.id[k][j]; + tries++; + } while ((node < 0 || S.bundles.some((b) => b.node === node || abs(b.k - k) % (K - 1) < 2)) && tries < 60); + if (node < 0) (k = 4), (j = 4), (node = S.id[4][4]); + const left = S.nx[node] > HX ? 0 : 1; + const d = dists(S, node); + S.moth = { k, j, node, d, dmax: max(...d), x0: left ? -8 : W + 8, y0: rnd(20, 108), x: 0, y: 0, flap: 0, wrap: 0, next: 0, heave: 0 }; +} + +// A struggle: a pulse from the moth along the silk, and a kick to the web. +function heave(S, amp) { + S.pulses.push({ t0: S.t, a: amp, d: S.moth.d }); + S.kicks.push({ t0: S.t, a: amp, x: S.moth.x, y: S.moth.y }); + S.moth.heave = 0.3; +} + +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] || 0; + S.speed = cl(k[1], 0.2, 3); + S.fight = cl(k[2], 0, 1); + S.sag = cl(k[3], 0, 1); + const g = (d * S.speed) / 2, M = S.moth, sp = S.sp; + S.t += g; + S.rt += d; + S.pt += g; + M.heave = max(0, M.heave - g); + M.flap += g * (S.phase === "fly" ? 60 : M.heave > 0 ? 75 : 8); + // The moth heaves in bouts, two or three heaves a fifth of a second + // apart, then a rest; Struggle shortens the rest and adds the third. + const every = mix(1.3, 0.6, S.fight), amp = mix(1.1, 1.375, S.fight); + if (S.phase === "fly") { + const u = cl(S.pt / FLY, 0, 1), e = u * u * (3 - 2 * u); + M.x = mix(M.x0, S.px[M.node], e) + 5 * sin(u * PI * 3) * (1 - u); + M.y = mix(M.y0, S.py[M.node], e) + 4 * sin(u * PI * 5) * (1 - u) + 1.5 * sin(S.t * 9) * (1 - u); + if (u >= 1) { + S.phase = "caught"; + S.pt = 0; + heave(S, 1.5); + M.next = 0.2; + M.bout = 3; + sp.state = "wait"; + sp.heard = 0; + } + } else { + M.x = S.px[M.node]; + M.y = S.py[M.node]; + } + if (S.phase === "caught" || S.phase === "wrap") { + M.next -= g; + if (M.next <= 0) { + // While it is being wrapped it fights less and less. + heave(S, amp * (S.phase === "wrap" ? 0.7 * (1 - M.wrap) : rnd(0.8, 1))); + if (--M.bout > 0) M.next = 0.2; + else { + M.next = every * rnd(0.8, 1.2) * (S.phase === "wrap" ? 1.5 : 1); + M.bout = 2 + (Math.random() < S.fight ? 1 : 0); + } + } + } + // The spider: at the hub until the first pulse reaches it, then it turns + // to the moth's radial, waits for more to be sure (six in all, or four + // seconds), runs out along the radial, wraps, and walks back. + const hubT = M.d[S.hub] / V; + const path = [S.hub]; + for (let j = 0; j <= M.j; j++) if (S.id[M.k][j] >= 0) path.push(S.id[M.k][j]); + const plen = [0]; + for (let i = 1; i < path.length; i++) plen.push(plen[i - 1] + sqrt((S.px[path[i]] - S.px[path[i - 1]]) ** 2 + (S.py[path[i]] - S.py[path[i - 1]]) ** 2)); + const total = plen[plen.length - 1]; + const at = (s) => { + let i = 1; + while (i < path.length - 1 && plen[i] < s) i++; + const f = cl((s - plen[i - 1]) / (plen[i] - plen[i - 1] || 1), 0, 1); + return [mix(S.px[path[i - 1]], S.px[path[i]], f), mix(S.py[path[i - 1]], S.py[path[i]], f), atan2(S.py[path[i]] - S.py[path[i - 1]], S.px[path[i]] - S.px[path[i - 1]])]; + }; + sp.st += g; + sp.feel = max(0, sp.feel - g * 2); + // Count the pulses that have reached the hub. + let heard = 0; + for (const p of S.pulses) if (p.d === M.d && S.t - p.t0 >= hubT) heard++; + if (heard > sp.heard) (sp.feel = 1), (sp.heard = heard); + let moving = 0; + if (sp.state === "wait") { + sp.s = 0; + turnTo(sp, PI / 2, g); + if (S.phase === "caught" && heard >= 1) (sp.state = "turn"), (sp.st = 0); + } else if (sp.state === "turn") { + turnTo(sp, S.th[M.k], g * 1.6); + if (sp.st > TURN && (heard >= 6 || sp.st > 4)) (sp.state = "run"), (sp.st = 0); + } else if (sp.state === "run") { + sp.s = min(total - 3, sp.s + RUN * g); + moving = RUN * g; + if (sp.s >= total - 3) { + sp.state = "wrap"; + sp.st = 0; + S.phase = "wrap"; + S.pt = 0; + sp.orb = S.th[M.k] + PI; + } + } else if (sp.state === "wrap") { + // Round and round the moth, turning it in silk. + sp.orb += g * 5.5; + M.wrap = cl(S.pt / WRAPT, 0, 1); + moving = g * 18; + if (M.wrap >= 1) { + S.bundles.push({ node: M.node, k: M.k, life: 1 }); + if (S.bundles.length > 2) S.bundles[0].fade = 1; + sp.state = "back"; + sp.st = 0; + S.phase = "rest"; + S.pt = 0; + sp.s = total - 3; + } + } else if (sp.state === "back") { + sp.s = max(0, sp.s - BACK * g); + moving = BACK * g; + if (sp.s <= 0) (sp.state = "home"), (sp.st = 0); + } else if (sp.state === "home") { + turnTo(sp, PI / 2, g * 1.2); + if (sp.st > REST) { + sp.state = "wait"; + newMoth(S); + S.phase = "fly"; + S.pt = 0; + S.pulses.length = 0; + } + } + if (sp.state === "wrap") { + const mx = S.px[M.node], my = S.py[M.node]; + sp.x = mx + 4 * cos(sp.orb); + sp.y = my + 4 * sin(sp.orb); + sp.hd = sp.orb + PI; + } else { + const [x, y, a] = at(sp.s); + sp.x = x; + sp.y = y + (sp.s === 0 ? 1 : 0); + if (sp.state === "run") turnTo(sp, a, g * 3); + if (sp.state === "back") turnTo(sp, a + PI, g * 3); + } + sp.gait += moving * 0.9; + for (const b of S.bundles) if (b.fade) b.life -= g / 2.5; + S.bundles = S.bundles.filter((b) => b.life > 0); + S.pulses = S.pulses.filter((p) => V * (S.t - p.t0) < M.dmax + 4 * TAIL); + S.kicks = S.kicks.filter((c) => S.t - c.t0 < 2.5); + web(S); +} + +function turnTo(sp, a, rate) { + const dh = ((a - sp.hd + 3 * PI) % TAU) - PI; + sp.hd += dh * min(1, rate * 6); +} + +// Where every node of the web hangs now: the web's own belly, deepest at +// the hub and nothing at the frame; the pull of the moth, the spider and +// the bundles where they hang; and the quiver each heave sets going. +function web(S) { + const G = 1 + 6 * S.sag, load = 3 + 5 * S.sag, M = S.moth, sp = S.sp; + const loads = []; + if (S.phase !== "fly") loads.push([S.nx[M.node], S.ny[M.node], load]); + loads.push([sp.ox, sp.oy, load * 0.5]); + for (const b of S.bundles) loads.push([S.nx[b.node], S.ny[b.node], load * 0.6 * b.life]); + let qy = 0; + const kx = [], ky = [], ka = []; + for (const c of S.kicks) { + const tau = S.t - c.t0, a = c.a * mix(1, 3, S.fight) * exp(-tau / 0.45) * sin(TAU * 5 * tau); + qy += 0.6 * a; + kx.push(c.x), ky.push(c.y), ka.push(a); + } + for (let i = 0; i < S.n; i++) { + const x = S.nx[i], y = S.ny[i], u = S.nu[i], taper = 1 - u * u; + let dy = G, dx = 0; + for (const [lx, ly, w] of loads) dy += w * exp(-((x - lx) ** 2 + (y - ly) ** 2) / (2 * 14 * 14)); + let jig = qy; + for (let c = 0; c < kx.length; c++) { + const f = exp(-((x - kx[c]) ** 2 + (y - ky[c]) ** 2) / (2 * 16 * 16)); + jig += 1.4 * ka[c] * f; + dx += 0.8 * ka[c] * f * sin(i * 2.3); + } + S.px[i] = x + dx * taper; + S.py[i] = y + (dy + jig) * taper; + } + // The spider's own weight follows it out along the radial. + sp.ox = sp.x; + sp.oy = sp.y; +} + +// The pulse colour for K1, holding near each of the four stops. +function pulseColour(h) { + h = (h - floor(h)) * 4; + const i = floor(h) % 4, j = (i + 1) % 4, f = h - floor(h), g = cl((f - 0.25) / 0.5, 0, 1), s = g * g * (3 - 2 * g); + return [0, 1, 2].map((c) => mix(PULSE[i][c], PULSE[j][c], s)); +} + +// The light a pulse puts on a point d along the silk from the moth: a +// sharp front running out at V, a short tail behind it, fading with the +// distance it has run. +function glow(S, d) { + const fr = S.fr, am = S.am; + let v = 0; + for (let i = 0; i < fr.length; i++) { + const x = fr[i] - d; + if (x < -FRONT * 3 || x > TAIL * 4) continue; + v += am[i] * (x < 0 ? exp(-((x / FRONT) ** 2)) : exp(-x / TAIL)); + } + return v > 0 ? v * exp(-d / S.reach) : 0; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { rgb, P, bg } = S; + rgb.set(bg); + P.fill(0); + // The moon: pale, with darker seas in steps. + for (let y = MOONY - MOONR - 1; y <= MOONY + MOONR + 1; y++) + for (let x = MOONX - MOONR - 1; x <= MOONX + MOONR + 1; x++) { + if (x < 0 || x >= W || y < 0) continue; + const dx = x + 0.5 - MOONX, dy = y + 0.5 - MOONY, r = sqrt(dx * dx + dy * dy); + if (r > MOONR) continue; + const sea = exp(-((dx + 3) ** 2 + (dy + 2) ** 2) / 14) * 0.5 + exp(-((dx - 4) ** 2 + (dy - 4) ** 2) / 8) * 0.4 + exp(-((dx - 2) ** 2 + (dy + 5) ** 2) / 5) * 0.3; + const v = q(0.95 - 0.35 * sea - 0.12 * (r / MOONR) ** 4, 10), o = (y * W + x) * 3; + rgb[o] = MOON[0] * v; + rgb[o + 1] = MOON[1] * v; + rgb[o + 2] = MOON[2] * v; + } + // Silk: each thread from node to node, and on it the pulses, with the + // distance along the silk from the moth taken the short way round. + const pc = pulseColour(S.hue), M = S.moth, D = M.d; + // Where each pulse's front has got to, once a frame. + S.fr = S.pulses.map((p) => V * (S.t - p.t0)); + S.am = S.pulses.map((p) => p.a); + S.reach = mix(75, 120, S.fight); + const plot = (x, y, base, pv) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const i = y * W + x, o = i * 3; + // Over the moon the silk is a dark line against it. + const onMoon = (x + 0.5 - MOONX) ** 2 + (y + 0.5 - MOONY) ** 2 < MOONR * MOONR; + if (onMoon) (rgb[o] *= 0.55), (rgb[o + 1] *= 0.55), (rgb[o + 2] *= 0.6); + else (rgb[o] = max(rgb[o], SILK[0] * base)), (rgb[o + 1] = max(rgb[o + 1], SILK[1] * base)), (rgb[o + 2] = max(rgb[o + 2], SILK[2] * base)); + if (pv > P[i]) P[i] = pv; + }; + const seg = (a, b, ca, cb, cost, base) => { + const x0 = S.px[a], y0 = S.py[a], x1 = S.px[b], y1 = S.py[b]; + const n = Math.ceil(max(abs(x1 - x0), abs(y1 - y0))); + for (let s = 0; s <= n; s++) { + const t = n ? s / n : 0, dd = min(ca + t * cost, cb + (1 - t) * cost); + plot(floor(mix(x0, x1, t) + 0.5), floor(mix(y0, y1, t) + 0.5), base, glow(S, dd)); + } + }; + S.edges.forEach(([a, b, t], e) => seg(a, b, D[a], D[b], S.cost[e], t === 1 ? SILK_V * 0.85 : SILK_V)); + for (const [a, ex, ey] of S.moor) { + const x0 = S.px[a], y0 = S.py[a], n = Math.ceil(max(abs(ex - x0), abs(ey - y0))); + for (let s = 0; s <= n; s++) plot(floor(mix(x0, ex, s / n) + 0.5), floor(mix(y0, ey, s / n) + 0.5), SILK_V * 0.7, 0); + } + // The pulse on the silk, and a soft glow round it so it reads from + // across a room: a blur two LEDs wide, across then down. + const B = S.B, T = S.T; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + let s = 3 * P[i]; + if (x > 0) s += 2 * P[i - 1]; + if (x > 1) s += P[i - 2]; + if (x < W - 1) s += 2 * P[i + 1]; + if (x < W - 2) s += P[i + 2]; + T[i] = s / 9; + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + let s = 3 * T[i]; + if (y > 0) s += 2 * T[i - W]; + if (y > 1) s += T[i - 2 * W]; + if (y < H - 1) s += 2 * T[i + W]; + if (y < H - 2) s += T[i + 2 * W]; + B[i] = s / 9; + } + for (let i = 0; i < N; i++) { + const p = P[i], b = B[i], o = i * 3; + if (p <= 0.02 && b <= 0.02) continue; + const v = max(min(1.2, p), q(min(0.6, 1.4 * b), 6)); + rgb[o] = max(rgb[o], pc[0] * v); + rgb[o + 1] = max(rgb[o + 1], pc[1] * v); + rgb[o + 2] = max(rgb[o + 2], pc[2] * v); + } + // Each heave flashes round the moth, the light the pulses start from. + if (M.heave > 0 && S.phase !== "fly") { + const a = M.heave / 0.3, R = 9; + for (let y = floor(M.y - R); y <= M.y + R; y++) + for (let x = floor(M.x - R); x <= M.x + R; x++) { + const r = sqrt((x + 0.5 - M.x) ** 2 + (y + 0.5 - M.y) ** 2) / R; + if (r < 1) put(S, x, y, pc, q(a * 0.7 * (1 - r) ** 1.5, 6)); + } + } + // Bundles of silk left in the web. + for (const b of S.bundles) drawBundle(S, S.px[b.node], S.py[b.node], 1, b.life); + // The moth: wings flapping as it flies, heaving in the web, folding away + // into the bundle as it is wrapped. + drawMoth(S, M); + drawSpider(S, S.sp); + for (let o = 0, y = 0; y < H; y++) + for (let x = 0; x < W; x++, o += 3) display.setPixel(x, y, round(255 * cl(rgb[o], 0, 1)), round(255 * cl(rgb[o + 1], 0, 1)), round(255 * cl(rgb[o + 2], 0, 1))); +} + +function put(S, x, y, c, a) { + x = floor(x); + y = floor(y); + if (x < 0 || x >= W || y < 0 || y >= H) return; + const o = (y * W + x) * 3; + S.rgb[o] = mix(S.rgb[o], c[0], a); + S.rgb[o + 1] = mix(S.rgb[o + 1], c[1], a); + S.rgb[o + 2] = mix(S.rgb[o + 2], c[2], a); +} + +function drawBundle(S, cx, cy, size, life) { + for (let y = floor(cy - 6); y <= cy + 6; y++) + for (let x = floor(cx - 4); x <= cx + 4; x++) { + const u = (x + 0.5 - cx) / (3 * size), v = (y + 0.5 - cy) / (4.6 * size), r = u * u + v * v; + if (r > 1) continue; + // Wound round: bands of brighter and dimmer silk across it. + const band = ((y + x * 0.6 + 40) % 2.4) < 1.2 ? 1 : 0.78; + put(S, x, y, WRAP.map((c) => c * band), min(1, life * 1.5)); + } +} + +function drawMoth(S, M) { + const wrap = M.wrap, open = 1 - wrap; + if (wrap > 0) drawBundle(S, M.x, M.y, 0.25 + 0.75 * wrap, 1); + if (open <= 0.05) return; + // Wing span: the flap swings it between full and a sliver. + const f = 0.35 + 0.65 * abs(cos(M.flap)), span = 7 * f * open; + for (let side = -1; side <= 1; side += 2) + for (let y = -5; y <= 5; y++) + for (let x = 1; x <= 8; x++) { + const wx = x / max(0.6, span), wy = y / 5; + // Forewing above, smaller hindwing below. + const fore = wy < 0.2 && wx < 1 && wy > -1 + 0.6 * wx; + const hind = wy >= 0.2 && wx < 0.75 && wy < 0.95 - 0.5 * wx; + if (!fore && !hind) continue; + // A grey eye-spot on each forewing, a darker leading edge. + const spot = fore && abs(wx - 0.5) < 0.17 && abs(wy + 0.35) < 0.15; + const shade = spot ? 0.58 : hind ? 0.75 : x === 1 || wy < -0.6 ? 0.85 : 1; + put(S, M.x + side * (x - 0.5), M.y + y, WING.map((c) => c * shade * (0.6 + 0.4 * open)), 1); + } + for (let y = -4; y <= 4; y++) put(S, M.x, M.y + y, MBODY, 1); + put(S, M.x - 1, M.y - 5, MBODY, 0.7); + put(S, M.x + 1, M.y - 5, MBODY, 0.7); +} + +// The spider: abdomen behind, head in front, eight legs that step as it +// walks and twitch when a pulse reaches it. +function drawSpider(S, sp) { + const fx = cos(sp.hd), fy = sin(sp.hd), sx = -fy, sy = fx, x = sp.x, y = sp.y; + const line = (x0, y0, x1, y1, c) => { + const n = Math.ceil(max(abs(x1 - x0), abs(y1 - y0))); + for (let s = 0; s <= n; s++) put(S, mix(x0, x1, s / (n || 1)) + 0.5, mix(y0, y1, s / (n || 1)) + 0.5, c, 1); + }; + const tw = 0.25 * sp.feel * sin(S.rt * 40); + for (let side = -1; side <= 1; side += 2) + for (let k = 0; k < 4; k++) { + const a = [0.5, 1.1, 1.9, 2.5][k] + 0.25 * sin(sp.gait + k * 1.7 + (side > 0 ? PI : 0)) + tw; + const ca = cos(a), sa = side * sin(a), r = 3.2; + const kx = x + (ca * fx + sa * sx) * r, ky = y + (ca * fy + sa * sy) * r; + // The lower leg bends forward for the front pair, back for the rear. + const ex = kx + (ca * fx + sa * sx) * 2.8 + (k < 2 ? fx : -fx) * 1.2, ey = ky + (ca * fy + sa * sy) * 2.8 + (k < 2 ? fy : -fy) * 1.2; + line(x, y, kx, ky, SLEG); + line(kx, ky, ex, ey, SLEG); + } + const blob = (cx, cy, ra, rb, c) => { + for (let yy = floor(cy - 4); yy <= cy + 4; yy++) + for (let xx = floor(cx - 4); xx <= cx + 4; xx++) { + const px = xx + 0.5 - cx, py = yy + 0.5 - cy, u = px * fx + py * fy, v = px * sx + py * sy; + if ((u / ra) ** 2 + (v / rb) ** 2 <= 1) put(S, xx, yy, c, 1); + } + }; + blob(x - 2.2 * fx, y - 2.2 * fy, 2.8, 2.2, SBODY); + blob(x + 0.8 * fx, y + 0.8 * fy, 1.5, 1.3, SBODY.map((c) => c * 1.2)); + put(S, x + 1.6 * fx + 0.7 * sx, y + 1.6 * fy + 0.7 * sy, SEYE, 1); + put(S, x + 1.6 * fx - 0.7 * sx, y + 1.6 * fy - 0.7 * sy, SEYE, 1); +} From 76bd8a99a7aad598fcaf01ab3925bc51c7502ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:55:12 +0000 Subject: [PATCH 5/9] =?UTF-8?q?pattern-study:=20Conway's=20Soldiers,=20Tal?= =?UTF-8?q?king=20Board=20(47=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_01PMCSedMgeD9NiVSkNiWyK3 --- .../patterns/conways-soldiers.js | 350 ++++++++++++++ .../pattern-study/patterns/talking-board.js | 437 ++++++++++++++++++ 2 files changed, 787 insertions(+) create mode 100644 incoming/pattern-study/patterns/conways-soldiers.js create mode 100644 incoming/pattern-study/patterns/talking-board.js diff --git a/incoming/pattern-study/patterns/conways-soldiers.js b/incoming/pattern-study/patterns/conways-soldiers.js new file mode 100644 index 000000000..390aff3db --- /dev/null +++ b/incoming/pattern-study/patterns/conways-soldiers.js @@ -0,0 +1,350 @@ +// ===== Patternflow pattern ===== +// Title: Conway's Soldiers +// Family: algo +// Idea: Gold soldiers below a white line leapfrog each other across it, each jumped soldier blinking off the board, until the last one lands 1, 2, 3 and then 4 rows past the line with the fewest that can do it (2, 4, 8 and 20); each soldier carries a violet disc of light whose area is its Conway weight, the discs merge as they jump and the last soldier's disc exactly fills the ring of weight 1, and then an endless army floods the board and its gathered light still cannot fill the ring round the marked square on row 5. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Conway's soldiers (John Horton Conway, 1961), pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Row=0..5, Glow=0..1 +// +// Knob 1: Hue. Turns the soldiers round the wheel from gold at 0 through green at 0.25, pale blue at 0.5 and magenta at 0.75; the light of the weights swings through the cold hues only, violet at 0, pink at 0.25, violet at 0.5 and cyan at 0.75, so it never goes a muddy orange. The square on row 5 is pink-red at 0. +// Knob 2: Speed. At 2 a jump takes half a second: the 20 soldiers of row 4 line up in 1.4 seconds and make their 19 jumps in 9.5, and the round of all five campaigns takes 39 seconds. At 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Row. At 0 the campaigns come in turn: row 4 first on squares of 7 LEDs, then the endless army that tries for row 5, then rows 1, 2 and 3 on squares of 11. At 1 to 4 that row's campaign plays over and over (2, 4, 8 or 20 soldiers); at 5 the endless army floods the board again and again. +// Knob 4: Glow. How far the weights glow on the empty board round the target: at 0 a violet diamond about four squares across each way, with bands running in towards the target; at 1 every square on the panel shows its weight, each 0.618 of its neighbour one step nearer. + +const W = 64, H = 128, CX = 32; +const { floor, min, max, abs, round, sin, sqrt, PI, pow } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const ss = (t) => 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]; +} + +// Conway's weight: a square at taxicab distance d from the target weighs +// X^d with X = (sqrt 5 - 1) / 2 = 1/phi, so X + X^2 = 1. A jump straight +// at the target keeps the weight (X^(d+2) + X^(d+1) = X^d), any other jump +// loses some, so an army that ends with a soldier on the target must start +// at weight 1 or more. For row 5 the whole half-plane below the line +// weighs exactly 1 (the columns sum to phi^3, the rows to X^3), so every +// finite army falls short. +const X = (sqrt(5) - 1) / 2; +// The line is the gap between row 0 and row 1, pixel row 76. A square of +// side s has its rows at y = LINE - s r .. LINE - s (r - 1). +const LINE = 76.5; +// The board kept: columns -6..6, rows -10..8. +const C0 = 6, R0 = 10, NC = 13, NR = 19; +const key = (c, r) => (c + C0) + (r + R0) * NC; + +// The four campaigns, each the fewest soldiers that can reach its row, as +// starting squares [column, row] (row 0 is just below the line) and jumps +// [from column, from row, to column, to row]. Found offline by a search that +// kept only armies of weight 1 or more and pruned any jump that would drop +// the weight under 1; for rows 1 to 3 the armies are the column, the T and +// the cross, and the 20 of row 4 came out with 19 jumps, all of them +// straight at the target, so each army here weighs exactly 1 from the +// first jump to the last. Rows 1 to 3 stand on squares of 11 LEDs, row 4 +// and the endless army on squares of 7. +const CAMP = [ + null, + { s: 11, start: [[0, 0], [0, -1]], moves: [[0, -1, 0, 1]] }, + { s: 11, start: [[-2, 0], [-1, 0], [0, 0], [0, -1]], moves: [[0, -1, 0, 1], [-2, 0, 0, 0], [0, 0, 0, 2]] }, + { + s: 11, + start: [[-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0], [0, -1], [0, -2], [0, -3]], + moves: [[0, -1, 0, 1], [-2, 0, 0, 0], [0, 0, 0, 2], [2, 0, 0, 0], [0, -3, 0, -1], [0, -1, 0, 1], [0, 1, 0, 3]], + }, + { + s: 7, + start: [[-4, 0], [-3, 0], [-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [-3, -1], [-2, -1], [-1, -1], [0, -1], [1, -1], [-2, -2], [-1, -2], [0, -2], [1, -2], [-2, -3], [0, -3]], + moves: [ + [-2, -1, -2, 1], [-1, -1, -1, 1], [-2, 1, 0, 1], [0, 0, 0, 2], [-4, 0, -2, 0], [2, 0, 0, 0], [0, -1, 0, 1], [0, 1, 0, 3], + [1, -2, 1, 0], [4, 0, 2, 0], [2, 0, 0, 0], [-2, -3, -2, -1], [-3, -1, -1, -1], [-1, -2, -1, 0], [0, -3, 0, -1], [0, -1, 0, 1], + [-2, 0, 0, 0], [0, 0, 0, 2], [0, 2, 0, 4], + ], + }, + // Row 5: the endless army, a whole row at a time. + { s: 7 }, +]; +// Story seconds (one a second at Speed 2): a jump, the part of it in the +// air, the soldiers' line-up, the wait before the first jump, the win, the +// fade, and for the endless army one row every FLOOD. +const STEP = 0.5, FLY = 0.36, LINEUP = 1.4, WAIT = 0.4, WIN = 1.6, FADE = 0.6, FLOOD = 0.3, ROWS5 = 8, HOLD5 = 2; +// Soldier radius in squares, and the order campaigns come in at Row 0. +const RAD = 0.42, ORDER = [4, 5, 1, 2, 3]; +// The radius, in squares, of the light disc of weight 1. +const HALO = 2.4; + +export function setup(params) { + params.s = { + hue: 0, speed: 2, row: 0, glowK: 0.06, + camp: 4, ci: 0, phase: "lineup", pt: 0, + s: 7, sFrom: 7, sTo: 7, T: 4, Tfrom: 4, Tto: 4, zoomT: 0.6, + occ: new Uint8Array(NC * NR), amp: new Float32Array(NC * NR), fl: new Float32Array(NC * NR), + order: [], rec: 0, mi: 0, fly: null, gauge: 0, rows5: 0, t: 0, flow: 0, + }; + enter(params.s, 4, true); +} + +const weight = (c, r, n) => pow(X, abs(c) + abs(r - n)); + +function enter(S, camp, now) { + S.camp = camp; + const C = CAMP[camp]; + S.occ.fill(0); + S.amp.fill(0); + S.fl.fill(0); + S.fly = null; + S.mi = 0; + S.rec = 0; + S.rows5 = 0; + S.sFrom = S.s; + S.sTo = C.s; + S.Tfrom = S.T; + S.Tto = camp; + S.zoomT = abs(S.sTo - S.sFrom) > 0.01 ? 1.0 : abs(S.Tto - S.Tfrom) > 0.01 ? 0.7 : 0.4; + S.phase = now ? (camp === 5 ? "flood" : "lineup") : "zoom"; + if (now) (S.s = S.sTo), (S.T = S.Tto); + S.pt = 0; + // The line-up: nearest the line first, from the middle out. + if (C.start) S.order = C.start.slice().sort((a, b) => b[1] - a[1] || abs(a[0]) - abs(b[0]) || a[0] - b[0]); +} + +function next(S) { + if (S.row > 0) enter(S, S.row, false); + else { + S.ci = (S.ci + 1) % ORDER.length; + enter(S, ORDER[S.ci], false); + } +} + +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.glowK = cl(k[3], 0, 1); + const row = cl(round(k[2]), 0, 5); + if (row !== S.row) { + S.row = row; + if (row > 0) enter(S, row, true); + else (S.ci = 0), enter(S, 4, true); + } + const g = (d * S.speed) / 2; + S.t += g; + S.pt += g; + S.flow += g * 0.7; + const C = CAMP[S.camp], { occ, amp, fl } = S; + // Soldiers grow in over 0.15 s, the jumped and the faded shrink away, and + // white flashes die down. + for (let i = 0; i < occ.length; i++) { + if (occ[i]) amp[i] = min(1, amp[i] + g / 0.15); + else if (amp[i] > 0) amp[i] = max(0, amp[i] - g / 0.22); + if (fl[i] > 0) fl[i] = max(0, fl[i] - g / 0.35); + } + if (S.phase === "zoom") { + const u = ss(cl(S.pt / S.zoomT, 0, 1)); + S.s = S.sFrom + (S.sTo - S.sFrom) * u; + S.T = S.Tfrom + (S.Tto - S.Tfrom) * u; + if (S.pt >= S.zoomT) (S.phase = S.camp === 5 ? "flood" : "lineup"), (S.pt = 0), (S.s = S.sTo), (S.T = S.Tto); + } else if (S.phase === "lineup") { + const iv = min(0.25, LINEUP / S.order.length); + while (S.rec < S.order.length && S.pt >= S.rec * iv) { + const [c, r] = S.order[S.rec++], i = key(c, r); + occ[i] = 1; + fl[i] = 0.8; + } + if (S.rec >= S.order.length && S.pt >= S.order.length * iv + WAIT) (S.phase = "jump"), (S.pt = 0), (S.mi = 0); + } else if (S.phase === "jump") { + const m = C.moves[S.mi], p = S.pt / FLY; + if (!S.fly) { + // Lift off: the soldier leaves its square and is in the air. + const ia = key(m[0], m[1]); + occ[ia] = 0; + amp[ia] = 0; + S.fly = { x0: m[0], y0: m[1], x1: m[2], y1: m[3], bx: (m[0] + m[2]) / 2, by: (m[1] + m[3]) / 2, over: false }; + } + if (!S.fly.over && p >= 0.5) { + // Over the top: the jumped soldier blinks white and goes. + const ib = key(S.fly.bx, S.fly.by); + occ[ib] = 0; + fl[ib] = 1; + S.fly.over = true; + } + if (p >= 1 && !S.fly.landed) { + const ic = key(S.fly.x1, S.fly.y1); + occ[ic] = 1; + amp[ic] = 1; + fl[ic] = 0.55; + S.fly.landed = true; + } + if (S.pt >= STEP) { + S.fly = null; + S.mi++; + S.pt -= STEP; + if (S.mi >= C.moves.length) S.phase = "win"; + } + } else if (S.phase === "win") { + if (S.pt >= WIN) (S.phase = "fade"), (S.pt = 0); + } else if (S.phase === "flood") { + // A whole row of the endless army at a time, from the line down to the + // bottom of the panel. Each row counts as the whole infinite row. + const want = min(ROWS5, floor(S.pt / FLOOD) + 1); + while (S.rows5 < want) { + const r = -S.rows5; + if (r >= -R0) for (let c = -C0; c <= C0; c++) (occ[key(c, r)] = 1), (fl[key(c, r)] = 0.5); + S.rows5++; + } + if (S.pt >= ROWS5 * FLOOD + HOLD5) (S.phase = "fade"), (S.pt = 0); + } else if (S.phase === "fade") { + const f = 1 - cl(S.pt / FADE, 0, 1); + for (let i = 0; i < occ.length; i++) if (occ[i]) amp[i] = min(amp[i], f); + if (S.pt >= FADE) next(S); + } + // The gauge on the line: the army's weight against its target, 1 filling + // the line. The soldier in the air carries its own weight and, once it is + // over, the jumped one's too, so a straight jump never moves the gauge. + let wsum = 0; + if (S.camp === 5) wsum = 1 - pow(X, S.rows5); + else { + const n = S.camp; + for (let r = -R0; r < NR - R0; r++) + for (let c = -C0; c <= C0; c++) { + const i = key(c, r); + if (occ[i]) wsum += amp[i] * weight(c, r, n); + } + if (S.fly && !S.fly.landed) wsum += weight(S.fly.x0, S.fly.y0, n) + (S.fly.over ? weight(S.fly.bx, S.fly.by, n) : 0); + } + if (S.phase === "fade") wsum = min(wsum, 1 - cl(S.pt / FADE, 0, 1)); + S.gauge += (wsum - S.gauge) * min(1, g * 10); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const s = S.s, T = S.T, n = S.camp, { occ, amp, fl } = S; + // The glow keeps to the cold hues; where the soldiers' hue comes near it + // (Hue about 0.5 to 0.6) the soldiers pale towards white to stand apart. + const hs = 0.12 + S.hue, hg = 0.725 + 0.225 * sin(2 * PI * S.hue); + const near = cl(1 - abs(((hs - hg + 1.5) % 1) - 0.5) / 0.2, 0, 1); + const sol = hsv(hs, 0.85 - 0.55 * near, 1); + const glowC = hsv(hg, 0.72, 1); + const mark = hsv(0.95 + S.hue, 0.8, 1); + // How far the weights glow: the square at distance d shows X^(gam d). + const gam = 0.82 - 0.62 * S.glowK, amp0 = 0.62; + const xg = pow(X, gam); + // The row-5 square blinks while the endless army stands. + const blink5 = n === 5 && S.phase !== "zoom" ? 0.55 + 0.45 * sin(S.t * 9) : 1; + // The target ring brightens on the win. + const winK = S.phase === "win" ? 0.5 + 0.5 * sin(S.pt * 14) : 0; + const half = s / 2 - 0.7; + const fly = S.fly && !S.fly.landed ? S.fly : null; + let fx = 0, fy = 0, fr2 = 0; + if (fly) { + const p = ss(cl(S.pt / FLY, 0, 1)); + fx = fly.x0 + (fly.x1 - fly.x0) * p; + fy = fly.y0 + (fly.y1 - fly.y0) * p; + // The hop: the soldier swells as it rises towards us and shrinks as it lands. + const rr = RAD * (1 + 0.38 * sin(PI * p)); + fr2 = rr * rr; + } + const gw = S.gauge * 32; + // Each soldier's weight as light: a disc whose area is its weight, so the + // light on the board is the army's weight. When a soldier jumps straight + // at the target its disc swallows the jumped one's and the light stays + // the same; the last soldier on the target holds a disc of weight 1, the + // size of the ring drawn round it. + const halos = []; + if (n === 5) { + // The endless army stands too close for its discs to show: its light is + // gathered on the target instead, a disc of area 1 - X^k after k whole + // rows, which creeps towards the ring and never fills it. + if (S.phase !== "zoom") halos.push(0, 5, HALO * HALO * S.gauge); + } else { + for (let r = -R0; r < NR - R0; r++) + for (let c = -C0; c <= C0; c++) { + const i = key(c, r); + if (!occ[i] || amp[i] <= 0) continue; + const w = weight(c, r, n) * amp[i]; + if (w > 0.02) halos.push(c, r, HALO * HALO * w); + } + if (fly) halos.push(fx, fy, HALO * HALO * (weight(fly.x0, fly.y0, n) + (fly.over ? weight(fly.bx, fly.by, n) : 0))); + } + const nh = halos.length; + const ringR = HALO, ringK = n === 5 ? blink5 : 0.4; + for (let y = 0; y < H; y++) { + const v = (LINE - (y + 0.5)) / s + 0.5, r = round(v), fv = v - r; + for (let x = 0; x < W; x++) { + const u = (x + 0.5 - CX) / s, c = round(u), fu = u - c; + let R = 0.012, G = 0.01, B = 0.03; + const ox = abs(fu) * s, oy = abs(fv) * s; + if (y === 76) { + // The line, dim, lit from the middle out by the army's weight. + const dx = abs(x + 0.5 - CX); + R = G = B = 0.24; + if (dx < gw) (R = 0.55 + 0.45 * sol[0]), (G = 0.55 + 0.45 * sol[1]), (B = 0.55 + 0.45 * sol[2]); + else if (n === 5 && S.phase !== "zoom" && dx > min(30, gw)) (R = mark[0] * blink5), (G = mark[1] * blink5), (B = mark[2] * blink5); + } else if (ox < half && oy < half) { + // A square of the board, lit by its weight against the target row. + // Bands run in along the rings of equal distance towards the + // target, two squares apart. + const dist = abs(c) + abs(r - T); + const gl = amp0 * pow(xg, dist) * (0.62 + 0.38 * sin(2 * PI * (dist / 2 + S.flow))) + (r <= 0 ? 0.088 : 0.035); + R = glowC[0] * gl; + G = glowC[1] * gl; + B = glowC[2] * gl; + if (c === 0 && r === 5) { + // The square no army reaches: a red ring and a cross. + if (ox > half - 1 || oy > half - 1 || abs(ox - oy) < 0.6) (R = mark[0] * blink5), (G = mark[1] * blink5), (B = mark[2] * blink5); + } else if (c === 0 && r === n && n < 5 && !(amp[key(0, n)] > 0) && (ox > half - 1 || oy > half - 1)) { + const k = 0.55 + 0.45 * winK; + R = max(R, k * (0.6 + 0.4 * sol[0])); + G = max(G, k * (0.6 + 0.4 * sol[1])); + B = max(B, k * (0.6 + 0.4 * sol[2])); + } + if (c >= -C0 && c <= C0 && r >= -R0 && r < NR - R0) { + const i = key(c, r), a = amp[i]; + if (a > 0) { + const rad = RAD * a * (n < 5 && S.phase === "win" && c === 0 && r === n ? 1 + 0.12 * winK : 1); + if (fu * fu + fv * fv < rad * rad) { + const w = fl[i]; + R = sol[0] + (1 - sol[0]) * w; + G = sol[1] + (1 - sol[1]) * w; + B = sol[2] + (1 - sol[2]) * w; + } + } + } + } + // The halos add up where they overlap. + let hl = 0; + for (let h = 0; h < nh; h += 3) { + const du = u - halos[h], dv = v - halos[h + 1]; + if (du * du + dv * dv < halos[h + 2]) hl += 0.3; + } + // The ring of weight 1 round the target: what the army has to fill. + const tu = u, tv = v - n, td = sqrt(tu * tu + tv * tv) * s; + if (abs(td - ringR * s) < 0.55 && !(R > 0.5 && G > 0.3)) { + const k = n === 5 ? ringK : 0.4; + R = max(R, (n === 5 ? mark[0] : 0.8) * k); + G = max(G, (n === 5 ? mark[1] : 0.8) * k); + B = max(B, (n === 5 ? mark[2] : 0.85) * k); + } + if (hl > 0 && !(G > 0.3 && R > 0.5 && fu * fu + fv * fv < RAD * RAD)) { + R += glowC[0] * hl; + G += glowC[1] * hl; + B += glowC[2] * hl; + } + if (fly) { + const du = u - fx, dv = v - fy; + if (du * du + dv * dv < fr2) (R = 0.6 + 0.4 * sol[0]), (G = 0.6 + 0.4 * sol[1]), (B = 0.6 + 0.4 * sol[2]); + } + display.setPixel(x, y, round(255 * cl(R, 0, 1)), round(255 * cl(G, 0, 1)), round(255 * cl(B, 0, 1))); + } + } +} diff --git a/incoming/pattern-study/patterns/talking-board.js b/incoming/pattern-study/patterns/talking-board.js new file mode 100644 index 000000000..7adf6e0ec --- /dev/null +++ b/incoming/pattern-study/patterns/talking-board.js @@ -0,0 +1,437 @@ +// ===== Patternflow pattern ===== +// Title: Talking Board +// Family: halloween +// Idea: A near-black spirit board lies on the table between two candles, its bone-white alphabet in two arcs, the numbers, YES and NO by a sun and a moon, GOODBYE at the foot, and the heart-shaped pointer glides over it on its own, circling, tilting as it swings, stopping with its round glass window over one letter at a time, which lights up green through the glass and joins a word spelled out above the board: HELLO, BOO, BEHIND YOU, YES, I SEE YOU, 1031, NO, RUN and at last GOODBYE. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Flicker=0..1, Aura=0..1 +// +// Knob 1: Hue. The colour of whatever answers: the light in and round the pointer's window, the lit letter and the spelled word open a poison green at 0, turn cyan at 0.25, blue at 0.5, violet at 0.75 and pink at 1. The board's dark lacquer turns with it through the cold hues only, violet at 0, magenta at 0.25 and blue at 0.75, so it never goes brown; the bone letters and the candles stay as they are. +// Knob 2: Speed. At 2 the pointer glides about 55 LEDs a second and rests 0.65 seconds on each letter once it lights; HELLO takes 13 seconds from the first circling to the word fading, and all nine messages 111. At 0.2 everything runs ten times slower, at 3 half as fast again. The candles flicker at their own pace. +// Knob 3: Flicker. How the two candles burn: at 0 low and nearly still, the foot of the board in a steady pool of light; at 1 the flames stand 40 per cent taller, gutter and lean, a ragged dip can halve one, and their light reaches nearly twice as far up the board, swelling and sinking with them. +// Knob 4: Aura. The answer's own light round the pointer: at 0.06 a soft glow about 18 LEDs round the window that brightens when a letter lights; at 1 it spreads over 42 LEDs, twice as strong, and washes most of the board in its colour. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, round, sin, cos, sqrt, hypot, 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 ss = (t) => t * t * (3 - 2 * t); +const q = (v, n) => round(v * n) / n; + +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 board's letters, 3 by 5: five rows of three bits, as octal digits. +const F3 = { + A: "25755", B: "65656", C: "34443", D: "65556", E: "74647", F: "74644", G: "34553", H: "55755", I: "72227", + J: "11152", K: "55655", L: "44447", M: "57755", N: "65555", O: "25552", P: "65644", Q: "25563", R: "65655", + S: "34216", T: "72222", U: "55557", V: "55552", W: "55775", X: "55255", Y: "55222", Z: "71247", + 0: "75557", 1: "26227", 2: "61247", 3: "61216", 4: "55711", 5: "74616", 6: "34757", 7: "71222", 8: "75757", 9: "75716", +}; +// The spelled word's letters, 5 by 7: seven rows of five bits. +const F5 = { + A: [14, 17, 17, 31, 17, 17, 17], B: [30, 17, 17, 30, 17, 17, 30], C: [14, 17, 16, 16, 16, 17, 14], D: [28, 18, 17, 17, 17, 18, 28], + E: [31, 16, 16, 30, 16, 16, 31], F: [31, 16, 16, 30, 16, 16, 16], G: [14, 17, 16, 23, 17, 17, 15], H: [17, 17, 17, 31, 17, 17, 17], + I: [14, 4, 4, 4, 4, 4, 14], J: [7, 2, 2, 2, 2, 18, 12], K: [17, 18, 20, 24, 20, 18, 17], L: [16, 16, 16, 16, 16, 16, 31], + M: [17, 27, 21, 21, 17, 17, 17], N: [17, 17, 25, 21, 19, 17, 17], O: [14, 17, 17, 17, 17, 17, 14], P: [30, 17, 17, 30, 16, 16, 16], + Q: [14, 17, 17, 17, 21, 18, 13], R: [30, 17, 17, 30, 20, 18, 17], S: [15, 16, 16, 14, 1, 1, 30], T: [31, 4, 4, 4, 4, 4, 4], + U: [17, 17, 17, 17, 17, 17, 14], V: [17, 17, 17, 17, 17, 10, 4], W: [17, 17, 17, 21, 21, 21, 10], X: [17, 17, 10, 4, 10, 17, 17], + Y: [17, 17, 17, 10, 4, 4, 4], Z: [31, 1, 2, 4, 8, 16, 31], 0: [14, 17, 19, 21, 25, 17, 14], 1: [4, 12, 4, 4, 4, 4, 14], + 2: [14, 17, 1, 2, 4, 8, 31], 3: [31, 2, 4, 2, 1, 17, 14], 4: [2, 6, 10, 18, 31, 2, 2], 5: [31, 16, 30, 1, 1, 17, 14], + 6: [6, 8, 16, 30, 17, 17, 14], 7: [31, 1, 2, 4, 8, 8, 8], 8: [14, 17, 17, 14, 17, 17, 14], 9: [14, 17, 17, 15, 1, 2, 12], +}; + +// The board fills the panel below the spelled word, from row 20 down, +// with a thin edge line 2 LEDs in. +const BTOP = 20; +// The two alphabet arcs share a centre below them: A to M on the outer +// arc from -50 to 50 degrees, N to Z on the inner one from -66 to 66. +const ARC_X = 32, ARC_Y = 80.5, R1 = 36.5, R2 = 23.5; +// Where the pointer waits between words, and the two candles. +const REST = [32, 96]; +const CANDLES = [[7, 120], [56, 120]]; +// The messages, spelled in turn. YES, NO and GOODBYE are answered with +// the printed words. +const MESSAGES = ["HELLO", "BOO", "BEHIND YOU", "YES", "I SEE YOU", "1031", "NO", "RUN", "GOODBYE"]; +// Story seconds (one a second at Speed 2): the search loop before a word, +// settling on a letter, the letter lighting, the rest on it once lit, the +// hold on a finished word and its fade. +const SEARCH = 1.6, SETTLE = 0.15, LIGHT = 0.22, LINGER = 0.65, HOLD = 1.4, FADE = 1.4; + +export function setup(params) { + const ink = new Uint8Array(N), glyphOf = new Int16Array(N).fill(-1), haloOf = new Int16Array(N).fill(-1); + const glyphs = [], targets = {}; + // Print one 3 by 5 glyph with its centre at (cx, cy); returns its id. + const put = (ch, cx, cy) => { + const x0 = round(cx - 1.5), y0 = round(cy - 2.5), id = glyphs.length, rows = F3[ch]; + glyphs.push({ x0, y0 }); + for (let r = 0; r < 5; r++) + for (let c = 0; c < 3; c++) + if ((+rows[r] >> (2 - c)) & 1) { + const p = (y0 + r) * W + x0 + c; + ink[p] = 1; + glyphOf[p] = id; + } + return id; + }; + const word = (text, cx, cy) => { + const ids = [], x0 = cx - (text.length * 4 - 1) / 2 + 1.5; + [...text].forEach((ch, i) => ids.push(put(ch, x0 + i * 4, cy))); + targets[text] = { x: cx, y: cy, ids }; + }; + const arc = (letters, R, a0) => { + [...letters].forEach((ch, i) => { + const a = ((-a0 + (2 * a0 * i) / (letters.length - 1)) * PI) / 180; + const x = ARC_X + R * sin(a), y = ARC_Y - R * cos(a); + targets[ch] = { x: round(x - 1.5) + 1.5, y: round(y - 2.5) + 2.5, ids: [put(ch, x, y)] }; + }); + }; + arc("ABCDEFGHIJKLM", R1, 50); + arc("NOPQRSTUVWXYZ", R2, 66); + [..."1234567890"].forEach((ch, i) => { + const x = 32 + (i - 4.5) * 6; + targets[ch] = { x: round(x - 1.5) + 1.5, y: 84.5, ids: [put(ch, x, 84.5)] }; + }); + word("YES", 16.5, 30.5); + word("NO", 48, 30.5); + word("GOODBYE", 32, 108.5); + // A glow ring round each glyph, for when it lights. + for (let p = 0; p < N; p++) { + if (ink[p]) continue; + const x = p % W, y = floor(p / W); + for (let dy = -1; dy <= 1 && haloOf[p] < 0; dy++) + for (let dx = -1; dx <= 1; dx++) { + const xx = x + dx, yy = y + dy; + if (xx >= 0 && xx < W && yy >= 0 && yy < H && glyphOf[yy * W + xx] >= 0) { + haloOf[p] = glyphOf[yy * W + xx]; + break; + } + } + } + // The sun by YES and the moon by NO, the board's edge, and the table. + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, px = x + 0.5, py = y + 0.5; + const ds = hypot(px - 5, py - 30.5); + if (ds < 2.3 || (ds > 3.4 && ds < 4.6 && floor(((Math.atan2(py - 30.5, px - 5) / TAU) * 16 + 16.5) % 2) === 0)) ink[p] = 2; + const dm = hypot(px - 58, py - 30.5), dm2 = hypot(px - 59.6, py - 29.3); + if (dm < 3.6 && dm2 > 3) ink[p] = 2; + // The edge: a thin line 2 LEDs in from a rounded rectangle. + const ex = max(0, abs(px - 32) - 25), ey = max(0, abs(py - 74) - 47); + const e = hypot(ex, ey); + if (abs(e - 4.5) < 0.5) ink[p] = 3; + if (e > 6.5 || py < BTOP) ink[p] = 4; + } + params.s = { + hue: 0, speed: 2, flick: 0, aura: 0.06, rt: 0, + ink, glyphOf, haloOf, glyphs, targets, lit: new Float32Array(glyphs.length), + // The pointer: where it is, its tilt, and the step it is on. + px: REST[0], py: REST[1], ang: 0, vx: 0, + msg: 0, steps: [], si: 0, st: 0, mv: null, said: "", flash: 0, wordA: 1, + flame: [1, 1], sway: [0, 0], light: new Float32Array(N * 3), + }; + plan(params.s); + // The first search is a short one. + params.s.steps[0] = { k: "loop", r: 6, dur: 0.5 }; +} + +// The steps for one message: a search loop, then for each letter a glide +// to it (a small loop first when a letter repeats), a hold and a fade. +function plan(S) { + const text = MESSAGES[S.msg % MESSAGES.length], steps = [{ k: "loop", r: 10, dur: SEARCH }]; + if (S.targets[text] && text.length > 1) steps.push({ k: "go", t: S.targets[text], say: text }); + else { + let prev = null; + for (const ch of text) { + if (ch === " ") { + steps.push({ k: "go", t: { x: REST[0], y: REST[1] - 4, ids: [] }, say: " " }); + prev = null; + continue; + } + if (prev === ch) steps.push({ k: "loop", r: 5, dur: 0.7 }); + steps.push({ k: "go", t: S.targets[ch], say: ch }); + prev = ch; + } + } + steps.push({ k: "hold", dur: HOLD }, { k: "fade", dur: FADE }); + S.steps = steps; + S.si = 0; + S.st = 0; + S.mv = null; + S.said = ""; + S.text = text; + S.wordA = 1; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.flick = cl(k[2], 0, 1); + S.aura = cl(k[3], 0, 1); + const g = (d * S.speed) / 2; + S.rt += d; + // The candles keep their own time: two beats each and a slow sway. With + // Flicker up the flames burn taller and throw more light, and a ragged + // gutter can drop one to half. + const fk = 0.12 + 0.88 * S.flick, t = S.rt; + for (let i = 0; i < 2; i++) { + const o = i * 2.3; + const gut = 0.5 + 0.5 * sin(t * 3.1 + o) * sin(t * 1.7 + 2 * o); + S.flame[i] = (1 + 0.4 * S.flick) * (1 - fk * (0.1 + 0.08 * sin(t * 13.1 + o) + 0.06 * sin(t * 7.3 + 1.3 * sin(t * 2.1 + o))) - 0.4 * S.flick * gut * gut); + S.sway[i] = fk * (0.8 * sin(t * 1.7 + o) + 0.5 * sin(t * 4.3 + 3 * o)); + } + const x0 = S.px, y0 = S.py; + const step = S.steps[S.si]; + S.st += g; + if (step.k === "loop") { + // A loop that starts and ends where the pointer is. + if (!S.mv) S.mv = { x: S.px, y: S.py }; + const u = cl(S.st / step.dur, 0, 1), a = TAU * ss(u); + S.px = S.mv.x + step.r * sin(a); + S.py = S.mv.y - step.r * (1 - cos(a)) * 0.8; + if (u >= 1) nextStep(S); + } else if (step.k === "go") { + if (!S.mv) { + // A curved glide: a quadratic path bowed to one side by up to a + // third of its length, at about 55 LEDs a second. + const dx = step.t.x - S.px, dy = step.t.y - S.py, L = hypot(dx, dy); + const bow = (Math.random() - 0.5) * 0.6 * L; + S.mv = { x0: S.px, y0: S.py, cx: S.px + dx / 2 - (dy / (L || 1)) * bow, cy: S.py + dy / 2 + (dx / (L || 1)) * bow, dur: 0.3 + L / 55, said: false }; + } + const m = S.mv, u = cl(S.st / m.dur, 0, 1), e = ss(u); + const a = (1 - e) * (1 - e), b = 2 * (1 - e) * e, c = e * e; + S.px = a * m.x0 + b * m.cx + c * step.t.x; + S.py = a * m.y0 + b * m.cy + c * step.t.y; + const rest = S.st - m.dur; + if (rest > SETTLE) for (const id of step.t.ids) S.lit[id] = max(S.lit[id], cl((rest - SETTLE) / LIGHT, 0, 1)); + if (rest > SETTLE + LIGHT && !m.said) { + // The letter is read: it joins the word. + m.said = true; + S.said += step.say; + S.flash = 1; + } + if (rest > SETTLE + LIGHT + LINGER) nextStep(S); + } else if (step.k === "hold") { + if (S.st >= step.dur) nextStep(S); + } else if (step.k === "fade") { + // The word fades as the pointer drifts back to where it waits. + if (!S.mv) S.mv = { x: S.px, y: S.py }; + const u = cl(S.st / step.dur, 0, 1), e = ss(u); + S.px = mix(S.mv.x, REST[0], e); + S.py = mix(S.mv.y, REST[1], e); + S.wordA = 1 - e; + if (u >= 1) { + S.msg++; + plan(S); + } + } + // Letters let go of their light once the pointer leaves them. + const cur = S.steps[S.si]; + const keep = cur && cur.k === "go" && S.mv && S.mv.said ? cur.t.ids : null; + for (let i = 0; i < S.lit.length; i++) if (!keep || !keep.includes(i)) S.lit[i] = max(0, S.lit[i] - g / 0.5); + S.flash = max(0, S.flash - g / 0.6); + // Tilt with the swing: the pointer leans into its sideways motion. + if (g > 0) { + const vx = (S.px - x0) / g; + S.vx += (vx - S.vx) * min(1, g * 8); + S.ang += (cl(-S.vx * 0.006, -0.38, 0.38) - S.ang) * min(1, g * 6); + } +} + +function nextStep(S) { + S.si = min(S.si + 1, S.steps.length - 1); + S.st = 0; + S.mv = null; +} + +// The pointer, in its own frame with the window centre at the origin: a +// heart with its point up, 25 LEDs wide and 24 tall. Returns 0 outside, 1 +// for the body, 2 for its rim, 3 for the window's brass ring and 4 inside +// the glass. +function pointer(lx, ly) { + const rw = hypot(lx, ly); + if (rw < 4.4) return 4; + if (rw < 5.6) return 3; + const X = lx / 11, Y = (ly + 1.8) / 11; + const h = X * X + Y * Y - 1; + if (h * h * h - X * X * Y * Y * Y > 0) return 0; + // Within about 1.3 LEDs of the outline: the rim. + const X2 = lx / 9.7, Y2 = (ly + 1.3) / 9.7, h2 = X2 * X2 + Y2 * Y2 - 1; + return h2 * h2 * h2 - X2 * X2 * Y2 * Y2 * Y2 > 0 ? 2 : 1; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { ink, glyphOf, haloOf, lit, light } = S; + // The answer's colour runs from green through cyan, blue and violet to + // pink and never through orange, which dims to brown on a dark board. + const spirit = hsv(0.33 + 0.64 * S.hue, 0.85, 1); + // The board's lacquer turns with Hue only through the cold hues, violet + // at 0, so a near-black board never reads brown. + const tint = hsv(0.77 + 0.17 * sin(2 * PI * S.hue), 0.7, 1); + // Candlelight: a broad pool in the lacquer's colour, and a warm halo + // close round each flame, bright enough to stay orange. + const fl0 = S.flame[0], fl1 = S.flame[1]; + const c0x = CANDLES[0][0], c0y = CANDLES[0][1] - 6, c1x = CANDLES[1][0], c1y = CANDLES[1][1] - 6; + // The answer's own light round the pointer's window, wider with Aura. + const glowA = 0.35 + 0.65 * litMax(lit), ar2 = (18 + 24 * S.aura) ** 2, aK = (0.36 + 0.36 * S.aura) * glowA; + // The pool's reach grows with Flicker. + const reach = 900 + 1900 * S.flick; + const PX = S.px, PY = S.py; + for (let p = 0; p < N; p++) { + const x = (p % W) + 0.5, y = floor(p / W) + 0.5; + const d0 = (x - c0x) ** 2 + (y - c0y) ** 2, d1 = (x - c1x) ** 2 + (y - c1y) ** 2; + const o = p * 3; + // In steps of a 20th, a 16th and a 16th, so the glows do not cost a + // smooth gradient. + light[o] = q(fl0 / (1 + d0 / reach) + fl1 / (1 + d1 / reach), 20); + light[o + 1] = q((fl0 * fl0) / (1 + d0 / 45) + (fl1 * fl1) / (1 + d1 / 45), 16); + const da = (x - PX) ** 2 + (y - PY) ** 2; + light[o + 2] = q(aK / (1 + (2.2 * da) / ar2), 16); + } + // The pointer's frame. + const ca = cos(S.ang), sa = sin(S.ang); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, kind = ink[p], L = light[p * 3], Lw = light[p * 3 + 1], La = light[p * 3 + 2]; + let r, g, b; + if (kind === 4) { + // The table: almost black. + (r = 0.01 + 0.3 * Lw), (g = 0.008 + 0.15 * Lw), (b = 0.016); + } else { + // The board: near-black lacquer, lit by the pool and the flames. + const k = 0.05 + 0.15 * L; + (r = tint[0] * k + 0.8 * Lw), (g = tint[1] * k + 0.42 * Lw), (b = tint[2] * k + 0.08 * Lw); + if (kind === 1 || kind === 2 || kind === 3) { + // Bone paint, brighter in the candlelight. + const v = (0.34 + 0.36 * L + 0.4 * Lw) * (kind === 3 ? 0.5 : 1); + (r = 0.86 * v), (g = 0.8 * v), (b = 0.72 * v); + } + const gi = glyphOf[p], hi = haloOf[p]; + if (gi >= 0 && lit[gi] > 0) { + const a = lit[gi]; + (r = mix(r, 0.3 + 0.7 * spirit[0], a)), (g = mix(g, 0.3 + 0.7 * spirit[1], a)), (b = mix(b, 0.3 + 0.7 * spirit[2], a)); + } else if (hi >= 0 && lit[hi] > 0) { + const a = lit[hi] * 0.6; + (r += spirit[0] * a), (g += spirit[1] * a), (b += spirit[2] * a); + } + (r += spirit[0] * La), (g += spirit[1] * La), (b += spirit[2] * La); + } + // The pointer: a shadow first, then its body over the board. + const dx = x + 0.5 - PX, dy = y + 0.5 - PY; + if (abs(dx) < 18 && abs(dy) < 19) { + const lx = ca * dx + sa * dy, ly = -sa * dx + ca * dy; + const pk = pointer(lx, ly); + if (pk === 0) { + const sx = ca * (dx - 2) + sa * (dy - 3), sy = -sa * (dx - 2) + ca * (dy - 3); + if (pointer(sx, sy) > 0) (r *= 0.45), (g *= 0.45), (b *= 0.45); + } else if (pk === 1) { + // Smoked glass over the board: the letters show through dimly. + (r = r * 0.42 + 0.05), (g = g * 0.42 + 0.04), (b = b * 0.42 + 0.06); + } else if (pk === 2) { + const v = 0.78 + 0.22 * min(1, L); + (r = 0.92 * v), (g = 0.88 * v), (b = 0.8 * v); + } else if (pk === 3) { + // The window's ring takes the colour of what answers. + (r = mix(0.95, spirit[0], 0.5 * glowA)), (g = mix(0.85, spirit[1], 0.5 * glowA)), (b = mix(0.6, spirit[2], 0.5 * glowA)); + } else { + // The glass: clear, the board a little brighter through it, tinted. + const a = 0.18 + 0.3 * glowA; + (r = r * 1.15 + spirit[0] * a), (g = g * 1.15 + spirit[1] * a), (b = b * 1.15 + spirit[2] * a); + } + } + display.setPixel(x, y, round(255 * cl(r, 0, 1)), round(255 * cl(g, 0, 1)), round(255 * cl(b, 0, 1))); + } + drawCandles(S, display); + drawWord(S, display, spirit); +} + +const litMax = (lit) => { + let m = 0; + for (let i = 0; i < lit.length; i++) if (lit[i] > m) m = lit[i]; + return m; +}; + +// Two candles seen from above at the board's foot: an ivory top with its +// wick, and a flame leaning away from us that sways and stretches. +function drawCandles(S, display) { + for (let i = 0; i < 2; i++) { + const [cx, cy] = CANDLES[i], fl = S.flame[i], sw = S.sway[i]; + for (let y = cy - 14; y < min(H, cy + 6); y++) + for (let x = cx - 6; x <= cx + 6; x++) { + if (x < 0 || x >= W) continue; + const px = x + 0.5, py = y + 0.5, dw = hypot(px - cx, (py - cy) * 1.15); + let c = null; + if (dw < 5) { + const v = dw < 1 ? 0.25 : 0.72 + 0.28 * fl - 0.08 * (dw / 5); + c = dw < 1 ? [0.2, 0.15, 0.12] : [v, v * 0.93, v * 0.8]; + } + const fh = 9 + 2.5 * fl, fy = cy - 1 - py, fx = px - cx - sw * (fy / fh); + if (fy > -0.5 && fy < fh) { + const u = fy / fh, w = (u < 0.25 ? 2.4 * sqrt(u / 0.25) : 2.4 * (1 - (u - 0.25) / 0.75) ** 0.8) * (0.85 + 0.15 * fl); + if (abs(fx) < w + 0.25) { + if (abs(fx) < w * 0.45 && u < 0.6) c = [1, 0.97, 0.78]; + else if (abs(fx) < w * 0.8 && u < 0.85) c = [1, 0.78, 0.2]; + else c = [1, 0.5, 0.06]; + } + } + if (c) display.setPixel(x, y, round(255 * c[0]), round(255 * c[1]), round(255 * c[2])); + } + } +} + +// The word spelled so far, above the board: twice size for five letters +// or fewer, the newest letter flashing white as it arrives, every letter +// glowing two LEDs round in the answer's colour. +const WH = 20, wmask = new Uint8Array(W * WH); +function drawWord(S, display, spirit) { + const text = S.text, said = S.said, sc = text.length <= 5 ? 2 : 1, pitch = 6 * sc; + const width = text.length * pitch - sc, x0 = round(32 - width / 2), y0 = sc === 2 ? 3 : 7; + const a = S.wordA; + if (a <= 0 || !said.length) return; + wmask.fill(0); + for (let i = 0; i < said.length; i++) { + const rows = F5[said[i]]; + if (!rows) continue; + for (let ry = 0; ry < 7 * sc; ry++) + for (let rx = 0; rx < 5 * sc; rx++) { + if (!((rows[floor(ry / sc)] >> (4 - floor(rx / sc))) & 1)) continue; + const x = x0 + i * pitch + rx, y = y0 + ry; + if (x >= 0 && x < W) wmask[y * W + x] = i === said.length - 1 ? 2 : 1; + } + } + const fr = S.flash; + for (let y = 0; y < WH; y++) + for (let x = 0; x < W; x++) { + const m = wmask[y * W + x]; + let r, g, b; + if (m) { + const f = m === 2 ? fr : 0; + (r = mix(spirit[0], 1, f)), (g = mix(spirit[1], 1, f)), (b = mix(spirit[2], 1, f)); + } else { + // The glow: the nearest letter LED within two. + let best = 9; + for (let dy = -2; dy <= 2; dy++) + for (let dx = -2; dx <= 2; dx++) { + const xx = x + dx, yy = y + dy; + if (xx >= 0 && xx < W && yy >= 0 && yy < WH && wmask[yy * W + xx]) best = min(best, dx * dx + dy * dy); + } + if (best > 5) continue; + const k = best <= 2 ? 0.34 : 0.14; + (r = 0.01 + spirit[0] * k), (g = 0.008 + spirit[1] * k), (b = 0.016 + spirit[2] * k); + } + display.setPixel(x, y, round(255 * cl(r * a, 0, 1)), round(255 * cl(g * a, 0, 1)), round(255 * cl(b * a, 0, 1))); + } +} From ddd49e9b7181bba1f9c24bbf5353a653752d11fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:55:31 +0000 Subject: [PATCH 6/9] =?UTF-8?q?pattern-study:=20Oloid,=20Pumpkin=20Carving?= =?UTF-8?q?=20(47=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_01PMCSedMgeD9NiVSkNiWyK3 --- incoming/pattern-study/patterns/oloid.js | 358 ++++++++++++++ .../pattern-study/patterns/pumpkin-carving.js | 463 ++++++++++++++++++ 2 files changed, 821 insertions(+) create mode 100644 incoming/pattern-study/patterns/oloid.js create mode 100644 incoming/pattern-study/patterns/pumpkin-carving.js diff --git a/incoming/pattern-study/patterns/oloid.js b/incoming/pattern-study/patterns/oloid.js new file mode 100644 index 000000000..30203a33a --- /dev/null +++ b/incoming/pattern-study/patterns/oloid.js @@ -0,0 +1,358 @@ +// ===== Patternflow pattern ===== +// Title: Oloid +// Family: space +// Idea: An oloid, the hull of two circles each through the other's centre, rolls toward you down a dark floor in its swaying wobble, touching the floor along one straight ruling at a time, and leaves behind it the print of its own surface unrolled flat: the half round each circle in its own colour, the two locked together in a serpentine track that recedes up the panel. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: the oloid (Paul Schatz, 1929), rolled on its development as Hans Dirnböck and Hellmuth Stachel unrolled it (1997), pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Tilt=0..1, Fade=0..1 +// +// Knob 1: Hue. Turns both halves round the wheel together, 0.38 of a turn apart: at 0 the half round one circle is azure and the half round the other pink; at 0.15 violet and orange; at 0.45 red and green; at 0.6 yellow and cyan; at 0.8 green and violet. Orange and yellow are printed brighter so they never dim to brown. +// Knob 2: Speed. At 2 the oloid rolls its whole surface over the floor once in 5 seconds, coming 4.8 circle radii toward you and swaying 0.28 radii either way twice on the way. At 0.2 it rolls ten times slower, at 3 half as fast again. +// Knob 3: Tilt. The camera's angle over the floor: at 0 it looks down at 53 degrees from in front, and the top of the panel is the floor five rolls back, where the track has narrowed to a third of its width; at 1 it looks straight down, the oloid tumbles at the foot of the panel and the track is a flat band of two serpents locked together. +// Knob 4: Fade. How long the print stays on the floor: at 0.06 it loses only a third of its brightness over five rolls and so runs up to the top of the panel, dimmed a little more by distance; at 0.5 it is down to a third two rolls back; at 1 it fades within half a roll, a short tongue of print behind the oloid. + +const W = 64, H = 128, N = W * H; +const { floor, ceil, min, max, abs, sqrt, sin, cos, acos, 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; + +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 two circles, radius 1. A lies in the xy-plane round the origin and B +// in the xz-plane round (1, 0, 0), so each passes through the other's +// centre. Both angles count from the point farthest from the other circle. +const cA = (a) => [-cos(a), sin(a), 0]; +const cB = (b) => [1 + cos(b), 0, sin(b)]; +// A(a) and B(b) span a ruling of the hull when the tangent plane at A(a) +// also touches B at B(b). With d = B - A and the two tangents, that is +// det(tA, tB, d) = 0, which works out to cos a + cos b + cos a cos b = 0: +// for |b| up to 2 pi / 3 it gives one |a| up to 2 pi / 3, and the four +// signs are the four quarters of the surface. Every ruling is sqrt 3 long. +const partner = (u) => acos(cl(-cos(u) / (1 + cos(u)), -1, 1)); +// The closed loop of rulings, s from 0 to 8. Each eighth steps the angle +// that moves fastest there, so the fans round the ends of the arcs, where +// one end of the ruling barely moves, are spaced as finely as the rest. +function ruling(s) { + const sg = s >= 4 ? -1 : 1; + if (s >= 4) s -= 4; + let a, b; + if (s < 1) (a = (PI / 2) * s), (b = partner(a)); + else if (s < 2) (b = (PI / 2) * (2 - s)), (a = partner(b)); + else if (s < 3) (b = (-PI / 2) * (s - 2)), (a = partner(b)); + else (a = (PI / 2) * (4 - s)), (b = -partner(a)); + return [sg * a, sg * b]; +} + +// Rulings per eighth of the loop for the roll, and every RS-th of them is +// an edge of the mesh drawn. +const M = 24, R = 8 * M, RS = 2, RM = R / RS; +// The centroid, by the symmetry that swaps the circles. +const CEN = [0.5, 0, 0]; +// The camera: distance from the oloid, focal length in LEDs, and the row the +// oloid sits on, low so the track has room above it. +const CAMD = 7.4, FOC = 118, CY0 = 98; +// The light, a unit vector toward it: above, to the left, from the camera's side. +const LIGHT = nrm([-0.5, -0.55, 0.75]); +// The print on the floor is kept on a grid of CS radii over one roll. +const CS = 0.04, GX0 = -0.3, GNX = 96; +// One roll of the whole surface, in seconds at Speed 2. +const ROLL = 5; + +export function setup(params) { + // The rulings, their outward normals, and the development: unroll the + // strip between neighbouring rulings into the plane, two triangles at a + // time with every edge length kept, each new point on the side of the old + // edge it lies on in space (the normal points down into the floor, so the + // turn flips). + const rA = [], rB = [], rn = [], a2 = [], b2 = []; + for (let i = 0; i <= R; i++) { + const [a, b] = ruling((i / M) % 8), A = cA(a), B = cB(b), d = sub(B, A); + // The tangents can be parallel (at a = b = pi / 2), so the normal comes + // from the ruling and whichever tangent is further from it. + const c1 = crs(d, [sin(a), cos(a), 0]), c2 = crs(d, [-sin(b), 0, cos(b)]); + let n = nrm(dot(c1, c1) > dot(c2, c2) ? c1 : c2); + if (dot(n, sub([(A[0] + B[0]) / 2, A[1] / 2, B[2] / 2], CEN)) < 0) n = [-n[0], -n[1], -n[2]]; + rA.push(A), rB.push(B), rn.push(n); + } + const place = (o, d1, p, d2, sg) => { + const dx = p[0] - o[0], dy = p[1] - o[1], D = hypot(dx, dy), x = (d1 * d1 - d2 * d2 + D * D) / (2 * D), h = sqrt(max(0, d1 * d1 - x * x)); + return [o[0] + (x * dx + sg * h * dy) / D, o[1] + (x * dy - sg * h * dx) / D]; + }; + const turn = (o, p, q, n) => (dot(crs(sub(p, o), sub(q, o)), n) < 0 ? -1 : 1); + const len = (p, q) => hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]); + a2.push([0, 0]), b2.push([sqrt(3), 0]); + for (let i = 0; i < R; i++) { + const n = rn[i].map((v, k) => v + rn[i + 1][k]); + const b = place(a2[i], len(rB[i + 1], rA[i]), b2[i], len(rB[i + 1], rB[i]), turn(rA[i], rB[i], rB[i + 1], n)); + b2.push(b); + a2.push(place(a2[i], len(rA[i + 1], rA[i]), b, len(rA[i + 1], rB[i + 1]), turn(rA[i], rB[i + 1], rA[i + 1], n))); + } + // One roll moves the print on by T: 4.836 radii straight down -y, with no + // turn (checked: under 1e-7 degrees), so the oloid rolls in a straight line. + const T = [a2[R][0] - a2[0][0], a2[R][1] - a2[0][1]]; + // The pose with ruling i on the floor: the ruling laid on its developed + // place, the tangent plane along it on the floor, as a rotation (a + // quaternion) and where the centroid lands. + const q = new Float64Array((R + 1) * 4), cw = new Float64Array((R + 1) * 3), cum = new Float64Array(R + 1); + for (let i = 0; i <= R; i++) { + const e1 = nrm(sub(rB[i], rA[i])), e3 = rn[i], e2 = crs(e3, e1); + const f = nrm([b2[i][0] - a2[i][0], b2[i][1] - a2[i][1], 0]), g1 = f, g2 = [f[1], -f[0], 0], g3 = [0, 0, -1]; + const m = [0, 1, 2].map((r) => [0, 1, 2].map((c) => g1[r] * e1[c] + g2[r] * e2[c] + g3[r] * e3[c])); + const o = i * 4, tr = m[0][0] + m[1][1] + m[2][2]; + if (tr > 0) { + const s = 2 * sqrt(tr + 1); + (q[o] = s / 4), (q[o + 1] = (m[2][1] - m[1][2]) / s), (q[o + 2] = (m[0][2] - m[2][0]) / s), (q[o + 3] = (m[1][0] - m[0][1]) / s); + } else { + const k = m[0][0] > m[1][1] && m[0][0] > m[2][2] ? 0 : m[1][1] > m[2][2] ? 1 : 2, k1 = (k + 1) % 3, k2 = (k + 2) % 3; + const s = 2 * sqrt(1 + m[k][k] - m[k1][k1] - m[k2][k2]); + q[o] = (m[k2][k1] - m[k1][k2]) / s; + q[o + 1 + k] = s / 4; + q[o + 1 + k1] = (m[k1][k] + m[k][k1]) / s; + q[o + 1 + k2] = (m[k2][k] + m[k][k2]) / s; + } + if (i > 0 && q[o] * q[o - 4] + q[o + 1] * q[o - 3] + q[o + 2] * q[o - 2] + q[o + 3] * q[o - 1] < 0) for (let j = 0; j < 4; j++) q[o + j] = -q[o + j]; + const c = sub(CEN, rA[i]); + for (let r = 0; r < 3; r++) cw[i * 3 + r] = m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2] + (r < 2 ? a2[i][r] : 0); + // The roll is timed by the centroid's path: it moves at an even speed. + if (i > 0) cum[i] = cum[i - 1] + hypot(cw[i * 3] - cw[i * 3 - 3], cw[i * 3 + 1] - cw[i * 3 - 2], cw[i * 3 + 2] - cw[i * 3 - 1]); + } + let xmid = 0; + for (let i = 0; i < R; i++) xmid += cw[i * 3] / R; + // The mesh: each drawn ruling as A, its midpoint and B, so each strip is + // a quad on the A half and a quad on the B half; the normal is the same + // all along a ruling, as on any developable surface. + const mesh = new Float64Array((RM + 1) * 9), mn = new Float64Array(RM * 3); + for (let j = 0; j <= RM; j++) { + const A = rA[j * RS], B = rB[j * RS]; + for (let k = 0; k < 3; k++) (mesh[j * 9 + k] = A[k]), (mesh[j * 9 + 3 + k] = (A[k] + B[k]) / 2), (mesh[j * 9 + 6 + k] = B[k]); + if (j < RM) { + const n = nrm(rn[j * RS].map((v, k) => v + rn[j * RS + RS][k])); + for (let k = 0; k < 3; k++) mn[j * 3 + k] = n[k]; + } + } + // The rims: the two arcs of 240 degrees that are edges of the hull. + const ARC = 48, arcs = new Float64Array(2 * (ARC + 1) * 3); + for (let c = 0; c < 2; c++) + for (let j = 0; j <= ARC; j++) { + const p = (c ? cB : cA)(((j / ARC) * 2 - 1) * ((2 * PI) / 3)); + for (let k = 0; k < 3; k++) arcs[(c * (ARC + 1) + j) * 3 + k] = p[k]; + } + // The print of one roll on the floor grid: strip i's A half codes 2i, + // its B half 2i + 1. A strip that pokes past either end of the roll is + // filed at the other end, marked as from the roll before or after. + const P = -T[1], GNY = ceil(P / CS), grid = new Int16Array(GNX * GNY).fill(-1), wrap = new Int8Array(GNX * GNY); + const fill = (pts, code) => { + const xs = pts.map((p) => p[0]), ys = pts.map((p) => -p[1]); + const x0 = floor((min(...xs) - GX0) / CS), x1 = ceil((max(...xs) - GX0) / CS), y0 = floor(min(...ys) / CS), y1 = ceil(max(...ys) / CS); + for (let gy = y0; gy <= y1; gy++) + for (let gx = max(0, x0); gx <= min(GNX - 1, x1); gx++) { + const px = GX0 + (gx + 0.5) * CS, py = -(gy + 0.5) * CS; + let inside = true, sgn = 0; + for (let k = 0; k < pts.length && inside; k++) { + const p = pts[k], o = pts[(k + 1) % pts.length], c = (o[0] - p[0]) * (py - p[1]) - (o[1] - p[1]) * (px - p[0]); + if (abs(c) < 1e-12) continue; + if (sgn === 0) sgn = c > 0 ? 1 : -1; + else if ((c > 0 ? 1 : -1) !== sgn) inside = false; + } + if (!inside) continue; + const w = gy < 0 ? 1 : gy >= GNY ? -1 : 0, row = gy + w * GNY; + grid[row * GNX + gx] = code; + wrap[row * GNX + gx] = w; + } + }; + for (let i = 0; i < R; i++) { + const m0 = [(a2[i][0] + b2[i][0]) / 2, (a2[i][1] + b2[i][1]) / 2], m1 = [(a2[i + 1][0] + b2[i + 1][0]) / 2, (a2[i + 1][1] + b2[i + 1][1]) / 2]; + fill([a2[i], m0, m1, a2[i + 1]], 2 * i); + fill([m0, b2[i], b2[i + 1], m1], 2 * i + 1); + } + params.s = { + hue: 0, speed: 2, tilt: 0, fade: 0.06, dist: 0, U: 0, + q, cw, cum, T, P, GNY, grid, wrap, xmid, mesh, mn, arcs, ARC, + rot: new Float64Array(9), pos: new Float64Array(3), cam: new Float64Array(12), + vw: new Float64Array((RM + 1) * 9), vs: new Float64Array((RM + 1) * 9), aw: new Float64Array(arcs.length), + zb: new Float32Array(N), rgb: new Float32Array(N * 3), + }; +} + +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.tilt = cl(k[2], 0, 1); + S.fade = cl(k[3], 0, 1); + const Lc = S.cum[R]; + S.dist += ((d * S.speed) / 2) * (Lc / ROLL); + // Where the roll is: ruling i and the fraction f on to the next, found by + // the centroid's path length within this roll. + const kc = floor(S.dist / Lc), u = S.dist - kc * Lc, cum = S.cum; + let lo = 0, hi = R; + while (hi - lo > 1) { + const m = (lo + hi) >> 1; + if (cum[m] <= u) lo = m; + else hi = m; + } + const f = cl((u - cum[lo]) / (cum[lo + 1] - cum[lo] || 1), 0, 1); + S.U = kc * R + lo + f; + // The pose between rulings lo and lo + 1: the rotations blended and + // renormalised, the centroid moved along, and kc rolls added on. + const q = S.q, o = lo * 4; + let w = mix(q[o], q[o + 4], f), x = mix(q[o + 1], q[o + 5], f), y = mix(q[o + 2], q[o + 6], f), z = mix(q[o + 3], q[o + 7], f); + const l = hypot(w, x, y, z); + (w /= l), (x /= l), (y /= l), (z /= l); + const m = S.rot; + 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); + const cw = S.cw; + for (let r = 0; r < 3; r++) { + const c = mix(cw[lo * 3 + r], cw[lo * 3 + 3 + r], f) + (r < 2 ? kc * S.T[r] : 0); + S.pos[r] = c - (m[r * 3] * CEN[0] + m[r * 3 + 1] * CEN[1] + m[r * 3 + 2] * CEN[2]); + } + // The camera follows the centroid down the track and keeps to the middle + // of its sway, so the sway shows; it sits in front, looking back up the + // track, from 53 degrees over the floor at Tilt 0 to straight down at 1. + const pitch = mix(0.92, PI / 2 - 1e-4, S.tilt), cy = mix(cw[lo * 3 + 1], cw[lo * 3 + 4], f) + kc * S.T[1]; + const C = S.cam; + C[0] = S.xmid; + C[1] = cy - CAMD * cos(pitch); + C[2] = 0.6 + CAMD * sin(pitch); + // Forward, right and up. + (C[3] = 0), (C[4] = cos(pitch)), (C[5] = -sin(pitch)); + (C[6] = 1), (C[7] = 0), (C[8] = 0); + (C[9] = 0), (C[10] = sin(pitch)), (C[11] = cos(pitch)); +} + +// Fill a screen triangle into the depth buffer, nearest wins: iz is 1 over +// the depth, interpolated across the screen as it is linear there. +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(H - 1, 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); + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { rot: m, pos, cam: C, zb, rgb, grid, wrap, GNY, P } = S; + // The two halves' colours. Orange and yellow dimmed read brown and olive, + // so a half that comes round to them is printed brighter and shaded less deep. + const hA = 0.56 + S.hue, hB = 0.94 + S.hue, wA = warm(hA), wB = warm(hB); + const colA = hsv(hA, 0.78, 1), colB = hsv(hB, 0.72, 1); + const pvA = 0.62 + 0.3 * wA, pvB = 0.62 + 0.3 * wB, loA = 0.3 + 0.25 * wA, loB = 0.3 + 0.25 * wB; + // World to screen: [x, y, 1 / depth]. + const proj = (wx, wy, wz, out, o) => { + const vx = wx - C[0], vy = wy - C[1], vz = wz - C[2], zc = vx * C[3] + vy * C[4] + vz * C[5]; + out[o] = 32 + (FOC * (vx * C[6] + vy * C[7] + vz * C[8])) / zc; + out[o + 1] = CY0 - (FOC * (vx * C[9] + vy * C[10] + vz * C[11])) / zc; + out[o + 2] = 1 / zc; + }; + const toWorld = (src, so, out, o) => { + const x = src[so], y = src[so + 1], z = src[so + 2]; + out[o] = m[0] * x + m[1] * y + m[2] * z + pos[0]; + out[o + 1] = m[3] * x + m[4] * y + m[5] * z + pos[1]; + out[o + 2] = m[6] * x + m[7] * y + m[8] * z + pos[2]; + }; + // The floor, ray by ray: where each LED's ray meets it, and what of the + // print lies there. A strip of roll kc was printed when the roll passed + // it, so its age is how many rulings ago that was. + const fade = R * 0.25 * 60 ** (1 - S.fade); + for (let py = 0; py < H; py++) { + const vy = (CY0 - py - 0.5) / FOC; + for (let px = 0; px < W; px++) { + const i = py * W + px, vx = (px + 0.5 - 32) / FOC; + zb[i] = 0; + const dx = C[3] + vx * C[6] + vy * C[9], dy = C[4] + vx * C[7] + vy * C[10], dz = C[5] + vx * C[8] + vy * C[11]; + let r = 0.025, g = 0.012, b = 0.06; + if (dz < 0) { + const t = -C[2] / dz, fx = C[0] + t * dx, fy = C[1] + t * dy; + const yy = -fy, kc = floor(yy / P), gy = floor((yy - kc * P) / CS), gx = floor((fx - GX0) / CS); + if (gx >= 0 && gx < GNX && gy >= 0 && gy < GNY) { + const c = grid[gy * GNX + gx]; + if (c >= 0) { + const age = S.U - ((kc + wrap[gy * GNX + gx]) * R + (c >> 1) + 0.5); + if (age > 0) { + const v = (c & 1 ? pvB : pvA) * exp(-age / fade) * cl(1.25 - t / 40, 0.25, 1), col = c & 1 ? colB : colA; + (r += col[0] * v), (g += col[1] * v), (b += col[2] * v); + } + } + } + } + (rgb[i * 3] = r), (rgb[i * 3 + 1] = g), (rgb[i * 3 + 2] = b); + } + } + // The body: every mesh point to the world and the screen, then each quad + // facing the camera as two triangles, shaded by its ruling's normal. + const { mesh, mn, vw, vs } = S; + for (let j = 0; j < (RM + 1) * 3; j++) { + toWorld(mesh, j * 3, vw, j * 3); + proj(vw[j * 3], vw[j * 3 + 1], vw[j * 3 + 2], vs, j * 3); + } + for (let j = 0; j < RM; j++) { + const nx = m[0] * mn[j * 3] + m[1] * mn[j * 3 + 1] + m[2] * mn[j * 3 + 2]; + const ny = m[3] * mn[j * 3] + m[4] * mn[j * 3 + 1] + m[5] * mn[j * 3 + 2]; + const nz = m[6] * mn[j * 3] + m[7] * mn[j * 3 + 1] + m[8] * mn[j * 3 + 2]; + const o = j * 9 + 3; + if (nx * (vw[o] - C[0]) + ny * (vw[o + 1] - C[1]) + nz * (vw[o + 2] - C[2]) > 0.02) continue; + const lit = max(0, nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]); + for (let h = 0; h < 2; h++) { + const col = h ? colB : colA, lo = h ? loB : loA, sh = lo + (1 - lo) * lit, p = j * 9 + h * 3, q = p + 9; + tri(S, vs[p], vs[p + 1], vs[p + 2], vs[p + 3], vs[p + 4], vs[p + 5], vs[q + 3], vs[q + 4], vs[q + 5], col[0] * sh, col[1] * sh, col[2] * sh); + tri(S, vs[p], vs[p + 1], vs[p + 2], vs[q + 3], vs[q + 4], vs[q + 5], vs[q], vs[q + 1], vs[q + 2], col[0] * sh, col[1] * sh, col[2] * sh); + } + } + // The rims, the two circles' arcs, drawn where nothing nearer hides them. + const { arcs, aw, ARC } = S; + for (let j = 0; j < arcs.length / 3; j++) toWorld(arcs, j * 3, aw, j * 3); + const a = [0, 0, 0], bq = [0, 0, 0]; + for (let c = 0; c < 2; c++) { + const col = c ? colB : colA; + for (let j = 0; j < ARC; j++) { + const o = (c * (ARC + 1) + j) * 3; + proj(aw[o], aw[o + 1], aw[o + 2], a, 0); + proj(aw[o + 3], aw[o + 4], aw[o + 5], bq, 0); + const steps = ceil(max(abs(bq[0] - a[0]), abs(bq[1] - a[1])) * 2) + 1; + for (let s = 0; s <= steps; s++) { + const t = s / steps, x = floor(mix(a[0], bq[0], t)), y = floor(mix(a[1], bq[1], t)); + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const i = y * W + x, iz = mix(a[2], bq[2], t); + if (iz < zb[i] * 0.985) continue; + (rgb[i * 3] = 0.55 + 0.45 * col[0]), (rgb[i * 3 + 1] = 0.55 + 0.45 * col[1]), (rgb[i * 3 + 2] = 0.55 + 0.45 * col[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); +} diff --git a/incoming/pattern-study/patterns/pumpkin-carving.js b/incoming/pattern-study/patterns/pumpkin-carving.js new file mode 100644 index 000000000..daa5eda79 --- /dev/null +++ b/incoming/pattern-study/patterns/pumpkin-carving.js @@ -0,0 +1,463 @@ +// ===== Patternflow pattern ===== +// Title: Pumpkin Carving +// Family: halloween +// Idea: A big orange pumpkin on a dark table at night: a glinting knife saws out two eyes, a nose and a jagged grin piece by piece, each pushed in to drop into the dark inside; a match flares, the candle catches and the face burns gold and flickers, its light spilling over the table, then it gutters out and the pumpkin turns to bring round fresh skin for a different face. +// Author: patternflow-lab +// Date: 2026-10-25 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 47 +// Round: 47 +// =============================== +// @matrix 64x128 +// @knobs Warmth=-1..1, Speed=0.2..3, Fierce=0..1, Spill=0..1 +// +// Knob 1: Warmth. The pumpkin and its candlelight together: at 0 a bright orange pumpkin burning gold; at -1 a red one burning orange; at 1 a golden yellow one, which the candle turns orange round a pale yellow face. The night and the stem stay as they are. +// Knob 2: Speed. At 2 a calm face takes about 13 seconds: 6 to carve, 0.9 for the match, 3.5 burning, 0.9 to gutter out and 1.8 to turn; a fierce face's long grin adds 2 more to the carving. At 0.2 the story runs ten times slower, at 3 half as fast again; the flame flickers at its own pace. +// Knob 3: Fierce. The faces the knife chooses, each one new: at 0 upright triangle eyes and a smile with two or three square teeth; at 1 bigger eyes slanted down to the nose and a wider, deeper grin with seven or eight pointed teeth above and fangs standing up between them, which appear past 0.3. +// Knob 4: Spill. How far the candlelight carries: at 0.06 a pool of gold, orange and crimson rings 38 LEDs either side and 16 rows down the table, and a crimson rim three LEDs round the pumpkin; at 1 the pool covers the table and the glow reaches 18 to 20 LEDs into the night. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, abs, sqrt, sin, cos, asin, hypot, 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 * 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 pumpkin, a spheroid with ten ribs: centre, half-width, half-height; +// the table's back edge; the candle flame's x, seen only through the holes. +const CX = 32, CY = 68, PA = 29.5, PB = 26, RIBS = 10, TABLE = 88, FX = 32; +// The moon's light on the skin, a unit vector toward it: upper left, in front. +const LX = -0.5, LY = -0.62, LZ = 0.6; +// Where the pumpkin stands on the table. +const BASE = CY + PB; +// The story in seconds at Speed 2: the knife moves to a piece, saws round it +// at KNIFE LEDs a second and pushes it in; match, burn, gutter, half turn. +const TRAVEL = 0.22, KNIFE = 60, PUSH = 0.45, MATCH = 0.9, BURN = 3.5, OUT = 0.9, TURN = 1.8, REST = 0.3; +// Across a rib, groove to groove in 256 steps: its bulge and the lean of its normal. +const RIB_B = new Float32Array(256), RIB_L = new Float32Array(256); +for (let i = 0; i < 256; i++) (RIB_B[i] = sin((PI * (i + 0.5)) / 256)), (RIB_L[i] = -0.55 * cos((PI * (i + 0.5)) / 256)); +// The knife: blade and handle in LEDs, pointing up and right as a right hand holds it. +const BLADE = 15, HANDLE = 10, KDX = 0.55, KDY = -0.835; +const BOLSTER = [1, 0.95, 0.7, 1, 0.95, 0.7, 1, 0.95, 0.7], HANDLE_C = [0.5, 0.3, 0.62, 0.32, 0.12, 0.42, 0.18, 0.06, 0.26]; + +// A new face in head-on panel LEDs: left eye, right eye, nose, mouth, each a +// polygon the knife saws round in turn. Fierce scowls the eyes and deepens and +// multiplies the teeth; the rest is chance, so no two faces are the same. +function makeFace(fierce) { + const R = Math.random, f = fierce, pieces = []; + const ex = 10 + 2.5 * R(), ey = CY - 9 - 2 * R(), bw = 9 + 3 * R() + 3 * f, eh = 7 + 3 * R() + 2 * f, ap = (R() - 0.5) * 3; + for (const sd of [-1, 1]) { + // q runs from the outer corner to the nose. Calm, the triangle stands on + // its base; fierce, its top runs down to the inner corner. + const calm = [[-bw / 2, eh / 2], [bw / 2, eh / 2], [ap, -eh / 2]]; + const mad = [[-bw / 2, eh / 2 + 0.5], [bw / 2 + 2, eh / 2 - 1.5], [-bw / 2 + 1, -eh / 2 - 1.5]]; + pieces.push(calm.map(([q, y], i) => [CX + sd * ex - sd * mix(q, mad[i][0], f), ey + mix(y, mad[i][1], f)])); + } + const nw = 5 + 2 * R(), nh = 4.5 + 2 * R(), up = R() > 0.25 + 0.4 * f ? 1 : -1; + pieces.push([[CX - nw / 2, CY + 1 + (up * nh) / 2], [CX + nw / 2, CY + 1 + (up * nh) / 2], [CX, CY + 1 - (up * nh) / 2]]); + // The mouth: a smile between two curves, its top edge sawn into teeth, + // square at Fierce 0 and pointed at 1, and past 0.3 its bottom into fangs. + const mw = 15 + 4 * R() + 4 * f, y0 = CY + 8 + R(), ct = 3 + 2.5 * R(), th0 = 3.5, cb = ct + 5 + 2 * R() + 4 * f; + const nt = round(2 + 5 * f + R() * 1.4), nb = f > 0.3 ? max(1, nt - 1) : 0, sharp = 0.12 + 0.38 * f; + const top = (u) => y0 + ct * (1 - u * u), bot = (u) => y0 + th0 + cb * (1 - u * u); + const edge = (n, yf, dep, dir) => { + const pts = [], S = 2 * n + 1, nq = max(2, Math.ceil(10 / S)); + for (let j = 0; j < S; j++) { + const u0 = -1 + (2 * j) / S, u1 = -1 + (2 * (j + 1)) / S; + if (j % 2 === 0) for (let q = 0; q < nq; q++) { + const u = u0 + ((u1 - u0) * q) / nq; + pts.push([CX + mw * u, yf(u)]); + } + else { + const ua = mix(u0, u1, sharp), ub = mix(u1, u0, sharp); + pts.push([CX + mw * u0, yf(u0)], [CX + mw * ua, yf(ua) + dir * dep(ua)]); + if (ub > ua + 1e-6) pts.push([CX + mw * ub, yf(ub) + dir * dep(ub)]); + } + } + pts.push([CX + mw, yf(1)]); + return pts; + }; + const gap = (u) => bot(u) - top(u), deepT = 0.45 + 0.1 * f, deepB = 0.3 * f; + const mouth = edge(nt, top, (u) => deepT * gap(u), 1).concat(edge(nb, bot, (u) => deepB * gap(u), -1).reverse()); + pieces.push(mouth); + return pieces.map((pts) => { + let len = 0; + const cum = [0]; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], b = pts[(i + 1) % pts.length]; + len += hypot(b[0] - a[0], b[1] - a[1]); + cum.push(len); + } + let cx = 0, cy = 0; + for (const p of pts) (cx += p[0] / pts.length), (cy += p[1] / pts.length); + return { pts, cum, len, cx, cy, state: 0, t: 0.5 }; + }); +} + +// Which piece covers each LED of the head-on face, 1 to 4, 0 for skin. +// The candle stands so its flame rises from just inside the top of the mouth. +function maskFace(S) { + const m = S.mask; + m.fill(0); + S.face.forEach((pc, k) => { + const xs = pc.pts.map((p) => p[0]), ys = pc.pts.map((p) => p[1]); + for (let y = max(0, floor(min(...ys))); y <= min(H - 1, floor(max(...ys))); y++) + for (let x = max(0, floor(min(...xs))); x <= min(W - 1, floor(max(...xs))); x++) { + const px = x + 0.5, py = y + 0.5; + let inside = false; + for (let i = 0, j = pc.pts.length - 1; i < pc.pts.length; j = i++) { + const [xi, yi] = pc.pts[i], [xj, yj] = pc.pts[j]; + if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside; + } + if (inside) m[y * W + x] = k + 1; + } + }); + let top = H; + for (let i = 0; i < N; i++) if (m[i] === 4) top = min(top, floor(i / W)); + S.fy = top + 3; +} + +export function setup(params) { + // A few stars in the sky, each with its own twinkle. + const stars = []; + for (let i = 0; i < 16; i++) stars.push([floor(Math.random() * W), floor(Math.random() * 40), Math.random() * TAU]); + // The pumpkin's fixed geometry per LED: longitude at no turn (-10 off the + // pumpkin), its cosine and sine, and the spheroid's normal. + const geo = new Float32Array(N).fill(-10), cth = new Float32Array(N), sth = new Float32Array(N); + const gnx = new Float32Array(N), gny = new Float32Array(N), gnz = new Float32Array(N); + for (let y = 0; y < H; y++) { + const v = (y + 0.5 - CY) / PB, c = sqrt(max(0, 1 - v * v)), rw = PA * c; + for (let x = 0; x < W; x++) { + const dx = x + 0.5 - CX, i = y * W + x; + if (abs(dx) >= rw) continue; + const u = dx / rw, th = asin(u); + (geo[i] = th), (cth[i] = cos(th)), (sth[i] = u); + (gnx[i] = u * c), (gny[i] = v * 0.9), (gnz[i] = sqrt(max(0, 1 - u * u)) * c); + } + } + params.s = { + geo, cth, sth, gnx, gny, gnz, + // Numbers that change every frame start as fractions, so the engine + // keeps them as floats from the start and does not recompile. + warmth: 0.5, speed: 1.5, fierce: 0.5, spill: 0.06, rt: 0.5, made: false, + face: null, mask: new Uint8Array(N), stars, fy: 80.5, + // The story: phase, time in it, the piece being cut and how far round. + phase: "cut", pt: 0.5, piece: 3, sub: "trace", cut: 0.5, from: [CX + 0.5, CY + 0.5], + turned: 0.5, phi: 0.5, light: 0.5, flare: 0.5, mx: 0.5, my: 0.5, glow: 0.5, sway: 0.5, + rgb: new Float32Array(N * 3), pal: new Float64Array(36), lit: new Float32Array(N), skin: new Uint8Array(N), + }; +} + +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.warmth = cl(k[0], -1, 1); + S.speed = cl(k[1], 0.2, 3); + S.fierce = cl(k[2], 0, 1); + S.spill = cl(k[3], 0, 1); + const g = (d * S.speed) / 2; + S.rt += d; + if (!S.made) { + // The first face opens with the knife part way round the mouth. + S.made = true; + S.face = makeFace(S.fierce); + maskFace(S); + for (let i = 0; i < 3; i++) S.face[i].state = 3; + S.face[3].state = 1; + S.cut = S.face[3].len * 0.45; + } + // The flame: a breath, a shiver, now and then a short gust; it leans with them. + const t = S.rt, gust = max(0, sin(t * 1.9 + 2 * sin(t * 0.7))) ** 6; + S.glow = cl(1 - 0.14 * (0.5 + 0.5 * sin(t * 11.3) * sin(t * 4.1 + 1)) - 0.05 * sin(t * 23.7) - 0.2 * gust, 0.25, 1.05); + S.sway = 0.5 * (0.6 * sin(t * 2.3) + 0.4 * sin(t * 5.1 + 0.4)) + 0.8 * gust * sin(t * 0.9); + S.pt += g; + const pc = S.face[S.piece]; + if (S.phase === "cut") { + if (S.sub === "travel") { + if (S.pt >= TRAVEL) (S.sub = "trace"), (S.pt = 0), (pc.state = 1), (S.cut = 0); + } else if (S.sub === "trace") { + S.cut += g * KNIFE; + if (S.cut >= pc.len) (S.sub = "push"), (S.pt = 0), (pc.state = 2), (pc.t = 0); + } else { + pc.t = cl(S.pt / PUSH, 0, 1); + if (S.pt >= PUSH) { + pc.state = 3; + S.from = pc.pts[0]; + S.pt = 0; + if (S.piece < 3) (S.piece++), (S.sub = "travel"); + else S.phase = "match"; + } + } + } else if (S.phase === "match" && S.pt >= MATCH) (S.phase = "burn"), (S.pt = 0); + else if (S.phase === "burn" && S.pt >= BURN) (S.phase = "out"), (S.pt = 0); + else if (S.phase === "out" && S.pt >= OUT) (S.phase = "turn"), (S.pt = 0); + else if (S.phase === "turn" && S.pt >= TURN) { + // Fresh skin faces us now; the ribs repeat every half turn. + (S.phase = "rest"), (S.pt = 0), (S.phi = 0), (S.turned += PI); + S.face = makeFace(S.fierce); + maskFace(S); + } else if (S.phase === "rest" && S.pt >= REST) (S.phase = "cut"), (S.pt = 0), (S.piece = 0), (S.sub = "travel"), (S.from = [CX + 22.5, CY - 30.5]); + S.phi = S.phase === "turn" ? PI * ease(cl(S.pt / TURN, 0, 1)) : 0; + // How lit the inside is: the match flares and sinks to a small flame, the + // candle catches with a flash and burns, then gutters out. + S.flare = 0; + if (S.phase === "match") { + const u = S.pt / MATCH; + S.flare = u < 0.15 ? u / 0.15 : u < 0.5 ? 1 - ((u - 0.15) / 0.35) * 0.7 : 0.3; + S.light = u < 0.45 ? 0 : u < 0.55 ? 0.3 : 0.3 + 0.7 * ease((u - 0.55) / 0.45) + 0.4 * max(0, 1 - abs(u - 0.75) / 0.1); + // Struck in the dark right of the stem, put in through the mouth. + const v = ease(cl((u - 0.2) / 0.3, 0, 1)); + S.mx = mix(50, FX + 2, v); + S.my = mix(34, S.fy - 1, v * v); + } else if (S.phase === "burn") S.light = 1; + else if (S.phase === "out") { + const u = S.pt / OUT; + S.light = (1 - u) * (1 - u) * (0.7 + 0.3 * sin(S.pt * 40)); + } else S.light = 0; +} + +// The frame's colours in S.pal, three floats each: skin lit, shaded, in the +// grooves and sunk while the candle burns, all saturated (a dim orange reads +// brown); cut flesh; deep orange, gold, white-hot and crimson light; and, by +// how bright the flame burns, the light itself and its pool's inner rings. +const HI = 0, LO = 3, GROOVE = 6, SUNK = 9, FLESH = 12, DEEP = 15, GOLD = 18, HOT = 21, CRIM = 24, GLOW = 27, POOL0 = 30, POOL1 = 33; +const RED = [0.88, 0.08, 0.02]; +function hsvTo(P, o, h, s, v) { + const c = hsv(h, s, v); + (P[o] = c[0]), (P[o + 1] = c[1]), (P[o + 2] = c[2]); +} +function palette(S, dim) { + // Warmth runs the pumpkin from red through orange to golden yellow. Lit + // from inside, a yellow skin would pale to the face's own colour, so it + // sinks toward orange instead. + const P = S.pal, hp = 0.075 + 0.075 * S.warmth, wy = cl((hp - 0.09) / 0.06, 0, 1); + hsvTo(P, HI, hp + 0.015, 0.88, 1); + hsvTo(P, LO, hp - 0.02, 1, 0.88); + hsvTo(P, GROOVE, hp - 0.04, 1, 0.74); + hsvTo(P, SUNK, hp - 0.035 - 0.06 * wy, 1, 0.84); + hsvTo(P, FLESH, min(hp + 0.06, 0.15), 0.5, 0.95); + hsvTo(P, DEEP, hp - 0.01, 1, 1); + hsvTo(P, GOLD, min(hp + 0.06, 0.15), 0.88 - 0.25 * wy, 1); + hsvTo(P, HOT, min(hp + 0.1, 0.16), 0.28 - 0.1 * wy, 1); + hsvTo(P, CRIM, hp - 0.07, 0.88, 0.95); + // Gold at full, deep orange at half, ember red as it gutters, so a dim + // flame turns red, not brown. + for (let c = 0; c < 3; c++) { + P[GLOW + c] = dim < 0.5 ? mix(RED[c], P[DEEP + c], dim * 2) : mix(P[DEEP + c], P[GOLD + c], dim * 2 - 1); + P[POOL0 + c] = mix(P[CRIM + c], P[GOLD + c], dim); + P[POOL1 + c] = mix(P[CRIM + c], P[DEEP + c], dim); + } +} + +// The night: violet sky in bands, the table with the pumpkin's shadow, and +// while the candle burns its glow in the air and its pool on the table. +function night(S, L) { + const P = S.pal, rgb = S.rgb, sp = S.spill, lamp = min(1, L); + const poolX = 36 + 30 * sp, poolY = 15 + 22 * sp, halo = 0.06 + 0.62 * sp, breathe = 0.72 + 0.3 * L; + for (let y = 0; y < H; y++) { + const band = floor((y / TABLE) * 9) / 8, v = (y + 0.5 - CY) / PB; + const sky = y < TABLE, front = y >= TABLE + 14; + const b2 = band * band, r0 = sky ? 0.02 + 0.2 * b2 : front ? 0.15 : 0.12, g0 = sky ? 0.004 + 0.05 * b2 : front ? 0.065 : 0.05, b0 = sky ? 0.05 + 0.27 * b2 : front ? 0.22 : 0.19; + const haloRow = L > 0 && sky && v > -1 - halo && v < 1 + halo, poolRow = L > 0 && y > BASE - 1, shadeRow = !sky && y > BASE - 5 && y < BASE + 5; + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3; + let r = r0, g = g0, b = b0; + if (haloRow) { + // Three rings of crimson round the pumpkin. + const hx = (x + 0.5 - CX) / PA, e = (sqrt(hx * hx + v * v) - 1) / halo; + if (e > 0 && e < 1) { + const a = (e < 0.35 ? 0.55 : e < 0.7 ? 0.36 : 0.2) * lamp, c = e < 0.35 ? POOL1 : CRIM; + (r = mix(r, P[c], a)), (g = mix(g, P[c + 1], a)), (b = mix(b, P[c + 2], a)); + } + } + if (shadeRow) { + const sx = (x + 0.5 - CX - 3) / 31, sy = (y + 0.5 - BASE) / 4.5; + if (sx * sx + sy * sy < 1) (r *= 0.4), (g *= 0.4), (b *= 0.45); + } + if (poolRow) { + // Gold, then orange, then crimson, all sinking to crimson as the + // flame dips, and the pool breathes with it. + const px = (x + 0.5 - CX) / poolX, py = (y + 0.5 - BASE - 1) / poolY, dd = sqrt(px * px + py * py) / breathe; + if (dd < 1) { + const c = dd < 0.42 ? POOL0 : dd < 0.7 ? POOL1 : CRIM, a = (dd < 0.42 ? 0.88 : dd < 0.7 ? 0.66 : 0.45) * (0.4 + 0.6 * lamp); + (r = mix(r, P[c], a)), (g = mix(g, P[c + 1], a)), (b = mix(b, P[c + 2], a)); + } + } + (rgb[o] = r), (rgb[o + 1] = g), (rgb[o + 2] = b); + } + } +} + +// The skin: its longitude, turned with the body, sets the ribs, whose sides +// lean the normal so the moon lights one. Five steps of light, three of groove. +function skinAt(S, i, rot, warm) { + const P = S.pal, rgb = S.rgb, o = i * 3; + const gq = (((S.geo[i] - rot) * RIBS) / TAU) % 1, ri = floor((gq < 0 ? gq + 1 : gq) * 256) & 255, bulge = RIB_B[ri], lean = RIB_L[ri]; + const nx = S.gnx[i] + lean * S.cth[i], ny = S.gny[i], nz = S.gnz[i] - lean * S.sth[i]; + const lam = floor(cl((nx * LX + ny * LY + nz * LZ) / (sqrt(nx * nx + ny * ny + nz * nz) || 1), 0, 0.999) * 5) / 4; + const gw = bulge < 0.25 ? 0 : bulge < 0.6 ? 0.55 : 1; + for (let c = 0; c < 3; c++) rgb[o + c] = mix(mix(P[GROOVE + c], mix(P[LO + c], P[HI + c], lam), gw), P[SUNK + c], warm); +} + +// A hole, or a piece on its way in. k is the piece, (fx, y) the LED in +// head-on coordinates. Returns false where the pushed piece still covers it. +function holeAt(S, i, k, fx, y, v, L, dim) { + const P = S.pal, rgb = S.rgb, mask = S.mask, pc = S.face[k - 1], o = i * 3; + if (pc.state === 2) { + // Pushed in: at once a dark gap opens round the piece, then it shrinks + // and sinks inside its hole, darkening as it goes. + const p = pc.t, sc = 0.9 - 0.3 * p, qx = floor(pc.cx + (fx + 0.5 - pc.cx) / sc), qy = floor(pc.cy + (y + 0.5 - pc.cy - 0.5 - 4 * p * p) / sc); + if (qx >= 0 && qx < W && qy >= 0 && qy < H && mask[qy * W + qx] === k) { + const d = 0.35 + 0.65 * p; + (rgb[o] = mix(rgb[o], P[GROOVE], d)), (rgb[o + 1] = mix(rgb[o + 1], P[GROOVE + 1], d) * (1 - 0.5 * p)), (rgb[o + 2] *= 1 - d); + return false; + } + } + // The wall of the cut shows on the side away from the middle. + const wx = floor(fx + 0.5 + ((fx + 0.5 - CX) / PA) * 2.6), wy = floor(y + 0.5 + v * 2.2); + if (wx < 0 || wx >= W || wy < 0 || wy >= H || mask[wy * W + wx] !== k) { + const a = mix(0.9, 1, cl(S.light, 0, 1)); + for (let c = 0; c < 3; c++) rgb[o + c] = mix(P[FLESH + c], P[HOT + c], dim) * a; + return true; + } + // Inside: dark, or the far wall candlelit, gold to white-hot by the flame, + // brighter than the skin so the face reads as burning. + const flameX = FX + S.sway, ex = fx + 0.5 - flameX, ey = (y + 0.5 - S.fy) * 0.8; + const near = floor(cl(1 - sqrt(ex * ex + ey * ey) / 24, 0, 0.999) * 4) / 3; + const a = (0.55 + 0.45 * min(1, L)) * cl(S.light * 4, 0, 1), hn = near * 0.75 * dim; + (rgb[o] = 0.03 + mix(P[GLOW], P[HOT], hn) * a), (rgb[o + 1] = 0.01 + mix(P[GLOW + 1], P[HOT + 1], hn) * a), (rgb[o + 2] = 0.04 + mix(P[GLOW + 2], P[HOT + 2], hn) * a); + S.lit[i] = a; + // The flame itself, through whichever hole it lies behind. + const flameH = 6 + 1.5 * S.glow, fy = S.fy - (y + 0.5), fxo = ex - S.sway * 0.4 * (fy / flameH); + if (S.light > 0.12 && fy > -1 && fy < flameH) { + const q = fy / flameH, w = q < 0.3 ? 2.2 * sqrt(q / 0.3) : 2.2 * (1 - (q - 0.3) / 0.7) ** 0.8; + if (fxo > -w - 0.2 && fxo < w + 0.2) { + const core = fxo > -w * 0.5 && fxo < w * 0.5 && q < 0.65; + (rgb[o] = 1), (rgb[o + 1] = core ? 0.97 : 0.75), (rgb[o + 2] = core ? 0.8 : 0.2); + } + } + return true; +} + +// The pumpkin: skin, and the face carried round with the turn. +function pumpkin(S, L, dim) { + const { skin, mask, face, geo } = S, rot = S.turned + S.phi, fl = S.phi; + // While the candle burns the skin sinks to a saturated red-orange round the face. + const warm = 0.5 * cl(L, 0, 1); + for (let y = CY - PB; y < CY + PB; y++) { + const v = (y + 0.5 - CY) / PB, rw = PA * sqrt(max(0, 1 - v * v)); + for (let x = max(0, floor(CX - rw)); x <= min(W - 1, floor(CX + rw)); x++) { + const i = y * W + x, th = geo[i]; + if (th < -9) continue; + skinAt(S, i, rot, warm); + skin[i] = 1; + const thr = th - fl; + if (thr <= -PI / 2 || thr >= PI / 2) continue; + const fx = fl === 0 ? x : floor(CX + rw * sin(thr)), k = fx >= 0 && fx < W ? mask[y * W + fx] : 0; + if (k && face[k - 1].state >= 2 && holeAt(S, i, k, fx, y, v, L, dim)) skin[i] = 0; + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S || !S.face) return; + const { rgb, lit, skin, face, pal: P } = S; + const L = S.light * S.glow, dim = cl((L - 0.3) / 0.5, 0, 1), rot = S.turned + S.phi; + palette(S, dim); + lit.fill(0); + skin.fill(0); + night(S, L); + pumpkin(S, L, dim); + // Light from the holes blooms gold onto the skin two LEDs round them. + if (L > 0.02) + for (let i = (CY - PB) * W; i < (CY + PB) * W; i++) { + if (!skin[i]) continue; + const n1 = max(lit[i - 1], lit[i + 1], lit[i - W], lit[i + W]), n2 = max(lit[i - 2], lit[i + 2], lit[i - 2 * W], lit[i + 2 * W]); + const a = n1 > 0 ? 0.67 * min(1, n1 * 1.2) : 0.33 * min(1, n2 * 1.2); + if (a > 0) for (let c = 0; c < 3; c++) rgb[i * 3 + c] = mix(rgb[i * 3 + c], P[GOLD + c], a); + } + // The stem: a thick green hook from the dimple on top, its lean turning + // with the pumpkin. + const lean = 5 * sin(0.9 + rot), sx0 = CX + 0.5, sy0 = CY - PB + 3; + for (let s = 0; s <= 24; s++) { + const q = s / 24, px = sx0 + lean * q * q, py = sy0 - 12 * q, rr = 2.6 - 1.1 * q; + for (let y = floor(py - rr); y <= py + rr; y++) + for (let x = floor(px - rr); x <= px + rr; x++) { + if (x < 0 || x >= W || y < 0 || y >= H || (x + 0.5 - px) ** 2 + (y + 0.5 - py) ** 2 > rr * rr) continue; + const o = (y * W + x) * 3, hi = x + 0.5 < px - 0.4; + (rgb[o] = hi ? 0.45 : 0.22), (rgb[o + 1] = hi ? 0.95 : 0.72), (rgb[o + 2] = hi ? 0.25 : 0.12); + } + } + // The stars, twinkling. + for (const [x, y, ph] of S.stars) { + const o = (y * W + x) * 3, a = 0.35 + 0.3 * sin(S.rt * 1.7 + ph); + if (!skin[y * W + x]) (rgb[o] = max(rgb[o], a)), (rgb[o + 1] = max(rgb[o + 1], a * 0.9)), (rgb[o + 2] = max(rgb[o + 2], a)); + } + const put = (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); + }; + if (S.phase === "cut") { + const pc = face[S.piece]; + // The cut so far, a pale line of fresh flesh, ends where the blade is in. + let tip = [pc.cx + 1, pc.cy]; + if (S.sub === "travel") { + const u = ease(cl(S.pt / TRAVEL, 0, 1)), to = pc.pts[0]; + tip = [mix(S.from[0], to[0], u), mix(S.from[1], to[1], u)]; + } else if (pc.state === 1) + for (let j = 0; j < pc.pts.length && pc.cum[j] < S.cut; j++) { + const a = pc.pts[j], b = pc.pts[(j + 1) % pc.pts.length], l = pc.cum[j + 1] - pc.cum[j] || 1, e = min(1, (S.cut - pc.cum[j]) / l); + for (let q = 0; q <= e * l * 2; q++) put(mix(a[0], b[0], q / (2 * l)), mix(a[1], b[1], q / (2 * l)), P[FLESH], P[FLESH + 1], P[FLESH + 2]); + tip = [mix(a[0], b[0], e), mix(a[1], b[1], e)]; + } + // The knife saws up and down its own length. + const saw = S.sub === "trace" ? 1.6 * sin(S.rt * 31) : 0; + const bl = BLADE + saw, sparkle = (S.rt * 1.3) % 1; + for (let s = 0; s <= (bl + HANDLE) * 2; s++) { + const a = s / 2, x = tip[0] + KDX * a, y = tip[1] + KDY * a; + if (a <= bl) { + // Silver, the edge brightest; the glint runs up it. + const gl = max(0, 1 - abs(a - sparkle * bl) / 2.5); + put(x, y, 0.78 + 0.22 * gl, 0.82 + 0.18 * gl, 0.92 + 0.08 * gl); + put(x + 1, y, 0.5 + 0.4 * gl, 0.54 + 0.4 * gl, 0.66 + 0.3 * gl); + } else { + // A brass bolster, then a violet handle lit on its left. + const c = a < bl + 1 ? BOLSTER : HANDLE_C; + for (let j = 0; j < 3; j++) put(x + j - 1, y, c[j * 3], c[j * 3 + 1], c[j * 3 + 2]); + } + } + } + if (S.phase === "match" && S.flare > 0) { + // The match: a pale stick, a white burst with rays as it is struck, + // then its own small flame above the head. + const f = S.flare, x0 = S.mx, y0 = S.my; + for (let j = 1; j <= 6; j++) put(x0 + 0.55 * j, y0 + 0.85 * j, 0.95, 0.86, 0.62); + const reach = 3 + 7 * f, core = 1.2 + 2.6 * f; + for (let y = floor(y0 - reach); y <= y0 + reach; y++) + for (let x = floor(x0 - reach); x <= x0 + reach; x++) { + if (x < 0 || x >= W || y < 0 || y >= H) continue; + const ddx = x + 0.5 - x0, ddy = y + 0.5 - y0, dd = sqrt(ddx * ddx + ddy * ddy); + const ray = (abs(ddx) < 0.6 || abs(ddy) < 0.6 ? 1 : abs(abs(ddx) - abs(ddy)) < 0.7 ? 0.65 : 0) * max(0, 1 - dd / reach); + const a = min(1, max(ray * f * 1.3, dd < core ? 1 : 0)); + if (a <= 0.05) continue; + const o = (y * W + x) * 3; + (rgb[o] = mix(rgb[o], 1, a)), (rgb[o + 1] = mix(rgb[o + 1], dd < core * 0.6 ? 1 : 0.9, a)), (rgb[o + 2] = mix(rgb[o + 2], dd < core * 0.6 ? 0.9 : 0.45, a)); + } + for (let j = 1; j <= 4; j++) { + const w = j < 3 ? 1 : 0; + for (let dx = -w; dx <= w; dx++) put(x0 + dx, y0 - j, 1, j < 2 ? 1 : 0.8, j < 2 ? 0.8 : 0.25); + } + } + 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 efda8901905db94058e33105f088b4cd502a9515 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:04:30 +0000 Subject: [PATCH 7/9] =?UTF-8?q?pattern-study:=2047=ED=8C=90=20=EC=97=B0?= =?UTF-8?q?=EA=B5=AC=20=EB=85=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PMCSedMgeD9NiVSkNiWyK3 --- incoming/pattern-study/README.md | 91 ++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index a0dc3ab61..c4260f4eb 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 368개를 쉰여섯 갈래로 나눠 만들었어요. 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판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 그림 다섯을 할로윈 갈래에 더했어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 378개를 쉰여섯 갈래로 나눠 만들었어요. 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판도 같은 표로 돌아서 수학 대상 다섯을 다섯 갈래에 하나씩, 할로윈 밤에 틀 그림 다섯을 할로윈 갈래에 더했어요. [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판에서 세로로 다시 짰어요. @@ -600,6 +600,21 @@ | [Belfry](patterns/belfry.js) | 할로윈 | 보라 폭풍 하늘 아래 종탑의 청동 종이 진자로 흔들리며 추가 테를 치고, 칠 때마다 박쥐가 쏟아져요. 시계는 30초에 한 시간을 가서 자정에 열두 번 울려요. K3 은 박쥐 수, K4 는 구름과 비와 번개이고, K1 을 누르면 바로 자정을 쳐요. | | [Ghost Ship](patterns/ghost-ship.js) | 할로윈 | 누더기 돛의 유령선이 너울에 앞뒤로 기울며 안개 속을 떠가고, 초록 등불이 흔들리며 이따금 선체 너머로 달이 비쳐요. K3 은 너울 높이, K4 는 안개이고, K1 을 누르면 안개 속으로 사라졌다 돌아와요. | +### 47판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Swarmalators](patterns/swarmalators.js) | 입자 | 위상이 비슷한 점끼리 더 끌리고 모두에게서 밀리는 점 150개짜리 무리 둘이 위아래로 서서, 색이 고리를 한 바퀴 도는 무지개 고리가 돼요. 결합이 천천히 오르내리면 위 고리는 타원으로 눌렸다 쪼개지고, 아래 고리에서는 점들이 고리를 따라 돌아요. K3 은 결합 K, K4 는 그 흔들림의 폭이에요. | +| [Lonely Runner](patterns/lonely-runner.js) | 리듬·펄스 | 위아래로 이은 128줄 트랙에서 주자마다 제 레인을 정수 빠르기로 달리고, 위아래 128/k 줄 안에 아무도 없으면 그 구역이 밝아지고 막대가 하얗게 달아올라요. 처음 셋은 1, 2, 3 으로 달려서 경주마다 1.8초와 3.6초에 한꺼번에 외로워져요. K3 은 주자 수, K4 는 빠르기를 흩는 정도예요. | +| [Oloid](patterns/oloid.js) | 공간·원근 | 서로의 중심을 지나는 두 원의 볼록 껍질이 곧은 괘선 하나로 바닥을 짚으며 흔들흔들 굴러오고, 굴러간 겉이 전개도 그대로 바닥에 찍혀 두 색 뱀이 맞물린 띠로 패널 위로 멀어져요. K3 은 카메라 기울기, K4 는 자국이 바래는 빠르기예요. | +| [Koebe Packing](patterns/koebe-packing.js) | 보로노이·셀 | 원마다 삼각분할의 이웃 원과만 맞닿은 세로 직사각형 원 배치예요. 1.2초마다 변이 뒤집히거나 원이 생기고 사라지면, 모든 원이 새 그래프가 허락하는 하나뿐인 배치로 부풀고 줄어들고 새 변이 흰빛으로 번쩍여요. K3 은 원 수, K4 는 그래프를 어두운 살로 보일지 흰 선으로 보일지예요. | +| [Conway's Soldiers](patterns/conways-soldiers.js) | 알고리즘 | 선 아래 금색 병사가 옆 병사를 뛰어넘어 하나를 1, 2, 3, 4줄 위로 보내는 데 2, 4, 8, 20명이 들어요. 병사마다 단 x^d 무게만 한 보라 원판이 뛰어넘을 때마다 합쳐져 마지막 병사에서 무게 1 의 고리를 꼭 채우고, 5줄에는 끝없는 군대도 닿지 못해 빨간 X 가 남아요. K3 은 출정, K4 는 무게 빛이에요. | +| [Specimen Jar](patterns/specimen-jar.js) | 할로윈 | 보랏빛 선반 위 표본 병의 독한 초록 액체 속에서 핏발 선 눈알이 오르내리며 두리번거리다, 몇 초마다 돌아서 유리 쪽으로 다가와 동공을 열고 나를 똑바로 봐요. K3 은 거품, K4 는 응시가 잦은 정도예요. | +| [Pumpkin Vine](patterns/pumpkin-vine.js) | 할로윈 | 보랏빛 밤 언덕에서 호박 덩굴이 128줄을 감아 오르며 잎을 펼치고 덩굴손을 감고, 마디의 작은 호박이 부풀어 아래부터 하나씩 호박 등으로 켜져요. 다 자라면 시들어 사라지고 새 덩굴이 자라요. K3 은 덩굴 수, K4 는 감기는 정도예요. | +| [Pumpkin Carving](patterns/pumpkin-carving.js) | 할로윈 | 밤 탁자 위 큰 호박을 칼날이 한 조각씩 도려내 눈과 코와 이빨 난 입을 내고, 성냥불로 촛불이 붙으면 얼굴이 금빛으로 일렁이며 탁자에 빛이 번져요. 촛불이 꺼지면 호박이 돌아 새 얼굴을 깎아요. K3 은 얼굴이 사나운 정도, K4 는 빛이 번지는 거리예요. | +| [Web Tremor](patterns/web-tremor.js) | 할로윈 | 달 아래 키 큰 거미줄에 나방이 걸려 버둥댈 때마다 빛이 실을 타고 세로줄로는 빠르게, 나선으로는 느리게 퍼지고, 허브의 거미가 달려가 나방을 흰 고치로 감고 돌아와요. K3 은 나방의 몸부림, K4 는 그물이 처지는 정도예요. | +| [Talking Board](patterns/talking-board.js) | 할로윈 | 두 촛불 사이 검보라 판 위를 하트 포인터가 스스로 미끄러져 글자에 멈추면, 유리창 아래 글자가 초록으로 켜지고 판 위에 HELLO 나 GOODBYE 같은 말이 한 자씩 적혀요. K3 은 촛불의 일렁임, K4 는 포인터 둘레 빛이에요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -2322,6 +2337,50 @@ E8 의 근 240개를 고셋의 4₂₁ 다면체 꼭짓점으로 그렸어요. 주제 6 으로 안개 속 유령선을 그렸어요. 누더기 돛을 단 세대박이 배가 달빛 아래 여섯 겹 너울의 셋째 줄에 떠서 앞뒤로 기울고 오르내려요. 기울기는 배 밑 물결의 기울기에서 나와서 Swell 1 에서 ±0.1 라디안, 4 에서 ±0.38 라디안이에요. 구멍 난 돛은 1초에 1.2번 펄럭이고 늘어진 밧줄이 흔들려요. 돛대 끝에서는 세인트 엘모의 불이 깜빡여요. 뱃머리와 고물의 초록 등은 흔들리며 물과 안개에 빛을 던져요. 8~15초마다 배가 반투명해져 선체 너머로 달이 비치고, 12 LED 를 떠간 뒤에는 안개에 잠겼다가 오른쪽에서 다시 나와요. 처음에는 Hue 가 등불만 바꿔서 반응이 0.010 으로 문턱 아래였어요. 배 윤곽을 7×7 로 두 번 흐려 네 단계로 끊은 유령 빛을 두르자 0.032 가 됐어요. 부드러운 안개와 윤곽 빛으로 복잡도가 0.87 까지 올랐고, 등불이 안개를 비추는 빛을 0.1 단위로 끊자 0.76 이 됐어요. 25 KB 안에 넣으려고 패널에서 거의 보이지 않던 떠오르는 혼령은 뺐어요. 잰 값은 움직임 0.338, 복잡도 0.759 예요. +## 47판 + +47판은 주제 5 「수학 · 추상」 다섯과 주제 6 「할로윈」 다섯이에요. 같이 돈 46판(#257)이 고른 브릴루앙 구역과 레브 그래프, 안에서 열리는 관과 종탑 같은 것을 피해서 골랐고, 앞 바퀴 PR 들에 따를 글은 없었어요. 수학은 Swarmalators, Lonely Runner, Oloid, Koebe Packing, Conway's Soldiers 이고 입자, 리듬·펄스, 공간·원근, 보로노이·셀, 알고리즘에 하나씩 들어갔어요. 46판까지 넣어 세면 앞의 네 갈래는 패턴이 여덟이나 아홉이었고, 알고리즘은 열하나였어요. 콘웨이의 병사는 뛰어넘는 수열을 찾는 셈이 그림이라 알고리즘에 뒀어요. 외로운 달리기 선수는 45판 뒤에, 올로이드와 콘웨이의 병사는 42판 뒤에, 원 채우기는 43판 뒤에 「목록 밖에서 해 볼 것」으로 적혀 있던 감이고, 스워멀레이터만 목록 밖에서 새로 찾았어요. 다섯 다 「흔한 것」에도 「낯선 수학 예」에도 없어요. 할로윈은 Specimen Jar, Pumpkin Vine, Pumpkin Carving, Web Tremor, Talking Board 예요. 넷은 43판과 45판 뒤에 적혀 있던 감이고, 병 속 눈알만 새로 찾았어요. 주제 1 은 46판까지의 368개에도 빈 칸과 하나뿐인 칸이 없어서, 둘뿐인 칸 스물셋 가운데 채움 × 색 1,4 와 움직임 × 모양 바뀜 1,4 를 겨눴어요. Swarmalators 가 Bertrand Paradox 와 Neon Alley 뿐이던 채움 × 색 1,4 에, Pumpkin Vine 이 Blotting Paper 와 Lunation 뿐이던 움직임 × 모양 바뀜 1,4 에 섰어요. 주제 4 는 Pumpkin Vine 과 Lonely Runner 가 맡았어요. 열 개는 어느 축의 끝도 넘지 않았고, 복잡도는 0.115 에서 0.747, 움직임은 0.058 에서 1.264 예요. 열 개는 `shelf.json` 의 어느 묶음에도 넣지 않아서 페이지 맨 위 「아직 안 추린 것」에 서요. 다섯 일꾼이 둘씩 짰고, 본체가 연구의 Lab 하네스로 다시 재고 화면을 그려 보고 합쳤어요. + +### Swarmalators + +주제 5 와 1 로 오키프와 홍, 스트로가츠가 2017년에 낸 스워멀레이터를 세웠어요. 점마다 자리와 위상이 있어서, 위상이 비슷한 점끼리 더 끌리고 모두에게서 밀리며, 가까운 점끼리 위상을 맞춰요. 식은 A = B = J = 1 그대로이고 위상을 색상환에 얹어 칠했어요. 무리는 둥근데 패널은 세로라서 64×64 반쪽마다 150개짜리 무리를 하나씩 쌓았어요. 위 무리의 결합 K 가 Coupling 노브 값이고 아래 무리는 늘 0.75 낮아서, 처음 값에서 위는 색이 고리를 한 바퀴 도는 정지 위상파이고 아래는 점들이 고리를 따라 도는 능동 위상파예요. 정지 위상파는 그대로 서 버려서 K 를 24초에 한 번 오르내리게 했고, 그 폭이 K4 Tour 예요. 처음 값에서는 ±0.25 라 위 고리가 동기 쪽으로 타원처럼 눌렸다가 쪼개지기를 되풀이해요. 겨눈 칸은 채움 × 색 1,4 예요. 첫 판은 점 120개에 반지름 1.6 이라 채움 0.219, 색 0.493 이었어요. 색상을 두 번 휘어 여섯 순색에 머물게 하고 점을 150개, 반지름 1.76 으로 키우자 채움 0.286, 색 0.565 가 됐어요. 켜진 LED 가 다 255 라서 가장자리는 축 끝과 같은 1.000 이에요. J 가 A 와 같아서 위상이 정반대인 점 사이에는 끌림이 0 이 되고 밀림만 남아, 몇 점이 무리 밖으로 튀어 나가요. 그래서 무리마다 제 반쪽 밖은 그리지 않았어요. + +### Lonely Runner + +주제 5 와 4 로 외르크 빌스가 1967년에 내놓은 외로운 달리기 선수 추측을 세웠어요. 토머스 큐식도 1973년에 따로 내놓았고, 하비에르 바라하스와 오리올 세라가 2008년에 일곱 명까지 증명했어요. 128줄을 위아래로 이은 트랙에서 주자 k 명이 레인 하나씩 차지하고 서로 다른 정수 빠르기로 위로 달려요. 주자마다 위아래 128/k 줄의 구역을 끌고 다니다가 그 안에 아무도 없으면 외로운 거라서, 구역이 레인 안에서 밝아지고 막대가 하얗게 달아올라요. 다른 주자의 자리는 1 LED 가로선으로 모든 레인을 가로질러서, 구역이 비었는지 눈으로 보여요. 빠르기가 1 부터 k 면 구역이 1/k 에 꼭 맞게 닿는 순간이 한 점뿐이라, 프레임 사이를 24번 나눠 보고 반 줄의 여유를 줬어요. 한 바퀴의 1/256 이에요. 처음 값은 셋이 1, 2, 3 으로 달려서 5.4초 경주의 1.8초와 3.6초에 셋이 한꺼번에 외로워져요. 가운데 주자는 한 번에 1.84초씩 외롭고 양 끝 주자는 그 순간뿐이에요. 빠르기 묶음 30가지를 다 돌려 보니 여덟 명까지 모든 주자가 한 번은 외로웠어요. 첫 판은 외로운 구역이 패널 너비를 다 덮어 섞이면서 탁한 올리브가 됐고, 단색 면이라 복잡도가 0.049 로 축의 아래 끝 0.058 을 넘었어요. 구역을 레인 안에 두고 세로 그러데이션과 막대 뒤 꼬리를 넣자 0.356 이 됐어요. `thirds` 는 43/46/45 예요. + +### Oloid + +주제 5 로 파울 샤츠가 1929년에 찾은 올로이드를 세웠어요. 서로의 중심을 지나는 두 단위원의 볼록 껍질이에요. 겉은 한 원의 점과 다른 원의 점을 잇는 곧은 괘선으로 덮이는데, 원 A 의 각을 원 B 에서 가장 먼 점부터 재면 괘선의 두 끝이 cos α + cos β + cos α cos β = 0 을 지켜요. 원 B 쪽 점 (1,0,0) 부터 재면 cos α cos β + cos α − cos β = 0 이 돼요. 괘선 192개 사이의 띠를 삼각형 두 개씩 길이를 지켜 바닥에 펼치고, 펼친 자리에 닿는 괘선을 놓고 그 괘선의 접평면을 바닥으로 맞춰 굴렸어요. 한스 디른뵈크와 헬무트 슈타헬이 1997년에 펼친 전개도를 따라 구르는 셈이에요. 괘선은 다 √3 길이이고, 한 바퀴에 반지름의 4.84배를 돌지 않고 곧게 나아가요. 무게중심 높이는 0.707 과 0.75 사이를 오가고 옆으로 0.28 씩 두 번 흔들려요. 바닥에는 굴러간 겉의 전개도가 두 원 쪽 반쪽 색으로 찍혀서, 두 마리 뱀이 맞물린 띠가 패널 위로 멀어져요. 첫 판은 34도 카메라와 짧은 자국 때문에 위 3분의 1 이 비어 `thirds` 가 0/20/22, 채움 0.140 이었어요. 카메라를 53도로 세우고 K4 를 자국이 오래 남는 쪽이 0.06 인 Fade 로 뒤집고, 한 바퀴를 6초에서 5초로 줄여 움직임 0.267, 채움 0.368 이 됐어요. 전개 가능한 면이라 괘선을 따라 음영이 같아서, 평면 음영만으로 괘선 줄무늬가 보여요. + +### Koebe Packing + +주제 5 로 파울 쾨베가 1936년에 보인 원 채우기 정리를 세웠어요. 평면 삼각분할 하나는 꼭 그 그래프대로 맞닿는 원 배치를 정해요. 안드레예프가 1970년에, 서스턴이 1980년대에 다시 보였어요. 꼭짓점마다 반지름을 두고, 이웃 둘과 맞닿은 세 원이 만드는 삼각형의 각을 코사인 법칙으로 더해 찰스 콜린스와 케네스 스티븐슨(2003)처럼 반지름을 고쳐 갔어요. 각의 합을 안쪽 꼭짓점은 2π, 변은 π, 모서리는 π/2 에 맞추니 원의 중심이 네 변에 줄을 서는 세로 직사각형이 나와요. 1.2초마다 변이 뒤집히거나 원이 태어나거나 사라지고, 프레임마다 25번 반복해 다시 풀어서 원이 1초쯤 걸려 새 배치로 부풀거나 줄어들어요. 차수가 같은 원은 같은 색이고 원마다 차수만큼의 어두운 살이 접점까지 그어져서, 그래프가 원을 정하는 것이 보여요. 새로 생긴 변은 흰빛으로 번쩍여요. 60초를 돌린 뒤 재니 각의 합 오차가 0.00000 rad, 접점 오차가 0.000 LED 였어요. 첫 판은 차수 8 인 초록 원이 판을 덮고 높이를 다 채우지 못했어요. 격자를 c×2c 로 맞추고 개수가 넘치면 꼭짓점을 빼게 하고, 큰 원을 보라로 바꿨어요. 뒤집을 변을 고르는 셈에서 경계 꼭짓점의 차수를 실제대로 세게 고치자 원 크기가 너무 고르게 돼서, 뒤집기 온도를 4 로 올려 크기를 다시 섞었어요. 원 22개면 지름 15~25 LED 원이 나와서 멀리서도 읽혀요. 흔한 원 채우기 그림은 서로 닿지 않는 원을 무작위로 채우는 것이라, 원마다 이웃과만 맞닿고 그래프가 바뀌면 판 전체가 다시 풀리는 이 그림과는 달라요. + +### Conway's Soldiers + +주제 5 로 존 호턴 콘웨이가 1961년에 보인 콘웨이의 병사를 세웠어요. 선 아래 칸에 선 병사가 옆 병사를 뛰어넘어 빈칸에 내려서고, 넘긴 병사는 판을 떠나요. 병사 하나를 선 너머 1, 2, 3, 4줄에 보내는 데 2, 4, 8, 20명이 들고, 5줄에는 몇 명으로도 닿지 못해요. 콘웨이는 칸마다 과녁까지의 거리 d 로 x^d 의 무게를 줬어요. x = 1/φ 라서 x + x² = 1 이고, 뛰어넘기는 무게의 합을 늘리지 못하며, 5줄을 겨누면 선 아래 반평면 전체의 무게가 꼭 1 이에요. 수열은 판 밖의 스크립트로 찾았어요. 무게 합이 1 이상인 출발 배치만 남기고 뛰어넘은 뒤 1 아래로 떨어지면 가지를 쳐서, 4줄의 20명도 후보 8526개 가운데 몇 초 만에 나왔어요. 가운데 맞춘 일곱 칸 너비 안에는 4줄의 풀이가 없다는 것도 후보 2947개를 다 돌려 202초 만에 확인했어요. 그래서 4줄은 아홉 칸이 다 들어가는 7 LED 칸으로, 1~3줄은 11 LED 칸으로 그리고 그 사이를 확대와 축소로 이었어요. 찾은 네 군대는 무게가 다 꼭 1 이고, 모든 뛰어넘기가 과녁 쪽으로 두 칸 다가가 무게를 잃지 않았어요. 그래서 병사마다 무게만 한 넓이의 보라 원판을 달면, 뛰어넘을 때 원판이 합쳐지고 마지막 병사의 원판이 무게 1 의 고리를 꼭 채워요. 5줄 차례에는 끝없는 군대가 한 줄씩 판을 채워도 모은 빛이 1 − x^k 에 머물러 고리를 못 채우고, 5줄 칸에 빨간 X 가 남아요. 첫 판은 움직임 0.033, 복잡도 0.067 로 복잡도 축의 아래 끝 0.058 에 붙어 있었고, 빛 원판과 과녁 쪽으로 흘러드는 무게 띠를 더해 0.099 와 0.115 가 됐어요. + +### Specimen Jar + +주제 6 으로 독한 초록 액체가 든 표본 병을 그렸어요. 퓨터 뚜껑을 덮은 유리병이 보랏빛 선반 위에 서고, 안쪽 50 LED 폭을 채운 액체 속에 지름 29 LED 눈알이 떠 있어요. 눈알은 구를 LED 마다 풀어 그려요. 시선축에서 잰 각으로 홍채와 동공을 나누고, 눈 자신의 각좌표에 미리 표로 만든 핏줄 여덟 가닥을 그려서 눈이 돌면 핏줄도 같이 돌아요. 눈은 7초에 16줄을 오르내리고 1.5~3.5초마다 볼 곳을 새로 골라요. 네 번에 한 번은 옆으로 크게 돌아 뒤쪽의 핏줄 그물과 분홍 시신경이 앞으로 나와요. K4 Stare 가 처음 값이면 13.4초마다, 처음에는 4초에 정면을 봐요. 그때 눈이 5분의 1 커지며 유리로 다가오고, 동공이 홍채의 3분의 1 에서 대부분으로 열리고, 액체가 환해져요. 첫 판은 액체를 다 밝은 초록으로 칠해 채움 0.708, 움직임 0.212, Stare 반응 0.046 이었어요. 바탕을 어둡게 하고 세 사인 합의 영점선으로 그린 빛 무늬를 밝히고, 응시할 때 액체가 0.5배 더 환해지게 해서 움직임 0.317, Stare 0.062 가 됐어요. 액체를 Hue 0.6 의 파랑으로 돌리면 홍채가 노랑이 되는데, 어둡게 칠하면 올리브 갈색으로 보여서 홍채 밝기를 0.6 아래로 내리지 않았어요. K1 은 주황을 지나지 않게 초록에서 자홍까지만 돌아요. + +### Pumpkin Vine + +주제 6 과 4, 1 로 호박 덩굴을 그렸어요. 43판 뒤에 적혀 있던 감이에요. 보랏빛 밤하늘과 달 아래에서 덩굴이 언덕에서 위쪽 22줄 근처까지 6초 동안 감아 오르고, 마디마다 다섯 갈래 잎이 펼쳐지고 덩굴손이 오일러 나선으로 감겨 나와요. 호박 넷이 차례로 부풀었다가 3.9, 5.3, 6.0, 7.4초에 아래부터 하나씩 얼굴에 불이 켜지고, 11초까지 서 있다가 보라로 시들어 사라지면 13초에 새 덩굴이 자라요. 겨눈 칸은 움직임 × 모양 바뀜 1,4 예요. 2~8초 창 안에서 어떤 LED 도 어두워지지 않게, 잎마다 LED 별 도착 시간을 미리 셈해 덮기만 하고 늦게 온 조각이 더 어두우면 먼저 밝기를 지키게 했어요. 호박은 부풀어도 빨강 채널을 255 로 두고 갈빗살과 빛은 초록 채널로만 그렸고, 등불이 켜질 때마다 하늘이 33% 씩 밝아져요. 그래서 모양 바뀜과 움직임의 비가 꼭 6.00 이고, 움직임 0.058, 모양 바뀜 0.347 이에요. 첫 판은 0.033 과 0.201 이었고, 잎 반지름을 12.5 에서 15.5 로 키우고 하늘을 어둡게, 잎을 밝게 해서 올렸어요. 셋째 노브였던 Lanterns(3..6)는 반응이 0.0003 이라 덩굴 수 Vines(1..3)로 바꾸고, 자라기 시작하는 때를 0.8초에서 0.45초로 당겨 Vines 와 Curl 이 노브를 재는 2~3초 창에서 0.05 를 넘게 했어요. 그만큼 줄어든 모양 바뀜은 등불을 따라 밝아지는 하늘로 채웠어요. + +### Pumpkin Carving + +주제 6 으로 호박을 깎는 밤을 그렸어요. 45판 뒤에 적혀 있던 감이고, 41판의 Jack-o'-Lantern 이 그리지 않은 앞 이야기예요. 갈비 열 개를 경도로 돌리는 회전 타원체 호박을 칼날이 눈 둘과 코, 이빨 난 입의 다각형 테두리를 따라 자르면 조각이 작아지며 안으로 가라앉아요. 하늘에서 성냥이 번쩍여 입으로 들어가고 촛불이 켜졌다 꺼지면 호박이 반 바퀴 돌아 새 껍질을 내밀어요. 다음 얼굴은 늘 새로 정해요. 첫 판은 복잡도 1.018, 채움 0.976, 한 프레임 3.35 ms 였어요. 빛을 다섯 단, 홈을 세 단, 하늘을 아홉 띠로 끊고 위 하늘을 24/255 아래로 내려 복잡도 0.224, 채움 0.669 가 됐어요. 켜진 얼굴이 처음에는 껍질보다 어두워서(197,121,10 대 248,148,25) 읽히지 않았어요. 구멍을 금색에서 흰색으로 밝히고 껍질을 붉은 주황으로 가라앉히자 얼굴이 섰어요. Flicker 노브는 반응이 0.015~0.022 라 빛이 퍼지는 거리를 바꾸는 Spill 로 바꿔 0.070 이 됐어요. 색상환을 다 도는 Hue 는 노랑과 초록 호박에서 켜진 얼굴이 묻혀서, 빨강부터 노랑까지인 Warmth -1..1 로 좁혔어요. 큰 draw 함수가 단계마다 처음 타는 분기에서 V8 의 최적화를 버려서 Lab 의 한 프레임이 2.9 ms 였는데, 작은 함수로 나누고 상태 숫자를 소수로 시작하게 하자 1.0~1.4 ms 가 됐어요. + +### Web Tremor + +주제 6 으로 떨리는 거미줄을 그렸어요. 43판 뒤에 적혀 있던 감이에요. 43판의 Orb Weaver 가 그물을 짓는 그림이라면 이것은 다 지은 그물에서 하는 사냥이에요. 세로줄 16개와 4.6 LED 간격의 고리 실로 키 큰 그물을 그래프로 짓고, 고리는 세로줄이 짧은 옆쪽에서 끊겨 위아래로 긴 그물이 돼요. 나방이 몸부림칠 때마다 다익스트라로 잰 실 위 거리를 따라 빛의 앞머리가 1초에 44 LED 로 퍼져요. 나선 실은 길이의 1.8배로 셈해서 빛이 세로줄을 타고 먼저 달리고 나선으로 늦게 번져요. 거미는 첫 빛이 허브에 닿으면 돌아서고, 여섯째 빛을 느끼거나 4초가 지나면 세로줄을 따라 달려가 2.8초 동안 감아 흰 고치를 남기고 돌아와요. 첫 판은 색 0.056 이 축의 아래 끝 0.052 에 붙고 모양 바뀜 0.012 가 아래 끝 0.021 을 넘었고, 빛이 약하고 드물었어요. 빛을 두세 번씩 묶어 치게 하고 꼬리를 9 LED 로 줄이고 번짐과 나방 둘레의 섬광을 더했어요. 첫 충돌을 1.1초로 당겨 노브를 재는 2~3초 창에 빛이 차게 하자 Hue 반응이 0.024 에서 0.072 로, 모양 바뀜이 0.095 로 올랐어요. 얇은 실만 물들이면 색 노브 반응이 0.03 아래라서, 빛이 실 둘레에도 번져야 했어요. + +### Talking Board + +주제 6 으로 말하는 판을 그렸어요. 45판 뒤에 적혀 있던 감이에요. 검보라 판에 뼈색 3×5 글자로 알파벳 두 호와 숫자 줄, 해와 달 옆의 YES 와 NO, 아래의 GOODBYE 를 놓고, 폭 25 LED 하트 포인터가 휘어진 길로 미끄러지며 움직이는 쪽으로 기울어요. 유리창 아래 글자에서 멈추면 그 글자가 초록으로 켜지고 판 위에 5×7 글자로 더해져요. 다섯 자 이하는 두 배로 써요. HELLO 부터 GOODBYE 까지 말 아홉이 111초에 한 바퀴 돌아요. 첫 판은 노브 반응이 0.001, 0.024, 0.015, 0.000 으로 거의 없었어요. 색은 창과 낱말에만 닿아 넓이가 작았고, 넷째 노브 Linger 는 첫 글자가 2초 넘어 켜져서 노브를 재는 2~3초 창에서 아무것도 바꾸지 못했어요. Linger 를 포인터 둘레 빛 Aura 로 바꾸고, 판의 칠이 Hue 를 따라 찬 색 안에서 돌게 하고, 포인터를 21 에서 25 LED 로 키우고 쉬는 자리에서 출발하게 해서 0.058, 0.051, 0.066, 0.073 이 됐어요. 촛불 빛을 LED 마다 부드럽게 그리자 복잡도가 0.46 에서 1.02 로 뛰어서, 빛을 20분의 1, 16분의 1 계단으로 끊어 0.40 으로 내렸어요. 어두운 판 위의 주황 빛은 갈색으로 보여서 답의 색은 초록에서 분홍까지만 돌아요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -2364,7 +2423,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 25일 `dev`(`f72355d`)의 328개에 43판부터 46판까지의 마흔 개를 더한 368개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 25일 `dev`(`f72355d`)의 328개에 43판부터 47판까지의 쉰 개를 더한 378개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 @@ -2402,6 +2461,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 46판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로예요. 45판까지의 358개에서도 지도 21장이 다 `none` 이고 패턴이 하나뿐인 칸도 없어서, 주제 1 은 패턴이 둘뿐인 칸을 겨눴어요. 그런 칸은 358개에서 스물다섯이고, 46판이 둘을 셋으로 만들어 368개에서 스물셋이에요. 가장자리 × 채움 1,1 은 LED 의 70% 넘게 꺼진 채 셈에 드는 이웃 차이의 79% 넘게가 40/255 아래의 무른 차이여야 하는 칸이에요. Lantern Procession 은 하늘과 땅을 14 와 3 아래에 두고 빛을 다 부드럽게 퍼뜨려 가장자리 0.040, 채움 0.223 이에요. 다만 무른 빛만으로는 움직임이 0.15 언저리에 머물렀어요. 색 × 모양 바뀜 4,3 은 색이 0.545 를 넘으면서 2~8초에 블록 평균이 0.217~0.331 바뀌는 칸이에요. 채도 높은 색으로 판을 다 덮으면 색은 쉽게 넘어서, 길은 모양 바뀜이었어요. Brillouin Zones 는 격자가 휘고 도는 것만으로는 0.08 이었고, 구역 번호 차례로 달리는 어두운 띠를 더해 0.258 이 됐어요. 남은 둘뿐인 칸 가운데 채움 × 색 1,4 와 가장자리 × 움직임 1,4 는 판을 성기게 두거나 번지게 둔 채 다른 축을 끝까지 올려야 하는 칸이에요. +47판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로이고, 378개에서도 지도 21장이 다 `none` 이에요. 주제 1 은 46판까지의 368개에 남은 둘뿐인 칸 스물셋 가운데 채움 × 색 1,4 와 움직임 × 모양 바뀜 1,4 를 겨눠서, 그런 칸이 378개에서 스물하나예요. 채움 × 색 1,4 는 LED 의 70% 넘게 꺼진 채 색이 0.545 를 넘어야 하는 칸이에요. 색은 꺼진 LED 까지 세서, 켜진 LED 가 다 255 의 순색이고 색상이 고루 퍼지면 색이 켜진 몫의 제곱근에 1.1 쯤을 곱한 값이에요. 그래서 이 칸은 채움 0.265~0.302 의 좁은 띠예요. Swarmalators 는 점의 가장자리를 번지지 않게 끊어 켜진 LED 를 다 255 에 두고, 색상환을 두 번 휘어 여섯 순색에 머물게 해서 채움 0.286, 색 0.565 예요. 색상환을 고르게 돌리면 크기의 rms 가 251 이라 같은 채움에서 0.53 에 그쳤어요. 움직임 × 모양 바뀜 1,4 는 39판의 Blotting Paper 처럼 LED 마다 가장 밝은 채널이 한 방향으로만 바뀌어야 닿는 칸이에요. Pumpkin Vine 은 2~8초에 덩굴이 언덕에서 꼭대기까지 자라는 동안 어떤 LED 도 어두워지지 않게 해서 움직임 0.058, 모양 바뀜 0.347, 비가 6.00 이에요. 늦게 덮인 잎 조각이 먼저 덮인 조각보다 어두우면 먼저 밝기를 지키고, 호박이 부풀어도 빨강 채널을 255 로 둔 채 결과 빛을 초록 채널로만 그렸어요. 남은 스물하나 가운데 열넷은 촘촘함 축이 든 칸이고, 여섯은 복잡도가 2.173 을 넘어야 하는 칸이에요. 나머지 하나인 가장자리 × 움직임 1,4 는 번진 그림이 1초에 1.49 넘게 바뀌어야 하는 칸이라, Kuramoto-Sivashinsky 처럼 판 전체가 부드럽게 끓는 그림이에요. + 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 를 냈어요. @@ -2452,6 +2513,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 46판의 열 개는 가장 낮은 노브가 Howling Wolf 의 K3 Pack 으로 네 자리로 0.0180 이고, Belfry 의 K3 Bats 0.0293 과 Ghost Ship 의 K1 Hue 0.0323 이 그다음이에요. 셋 다 판 대부분이 켜진 장면에서 작은 것의 수나 색만 바꿔서 화면 평균의 차이가 작아요. 무리 수는 화답이 2~3초 창에 와야 셈에 들어서, Howling Wolf 는 무리가 클수록 일찍, 촘촘히 화답하게 하고서야 문턱을 넘었어요. Vampire Coffin 의 K3 Bats 도 첫 폭발을 1.3초에 두지 않았을 때는 0 이었어요. Ghost Ship 의 K1 은 등불만 물들일 때 0.010 이었고, 배 윤곽에 유령 빛을 둘러 0.032 가 됐어요. +47판의 열 개는 가장 낮은 노브가 Talking Board 의 K2 Speed 로 네 자리로 0.0510 이고, Pumpkin Vine 의 K4 Curl 0.0529 와 Talking Board 의 K1 Hue 0.0575 가 그다음이에요. 열 개의 노브 마흔 개가 다 0.05 를 넘어요. Talking Board 의 첫 판은 넷이 0.001, 0.024, 0.015, 0.000 이었어요. 답의 초록이 포인터 창과 낱말에만 실려서 Hue 가 바꾸는 넓이가 작았고, 판의 칠이 같이 돌게 하자 올랐어요. 넷째 노브는 글자에 머무는 시간이었는데 첫 글자가 2초 넘어 켜져서 0.000 이었어요. 노브는 2~3초 창에서만 재서, 그 창 뒤에 오는 일을 바꾸는 노브는 0 으로 셈해져요. Pumpkin Vine 의 Lanterns 도 같은 까닭으로 0.0003 이라 Vines 로 바꿨어요. + Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. ### 세로 64×128 @@ -2492,9 +2555,11 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 46판에서는 Reeb Graph 와 Belfry 가 128줄을 써요. Reeb Graph 는 구멍 둘의 사슬을 세워 패널 높이를 다 쓰게 해서 `thirds` 가 32/51/31 이에요. 곡면을 수직축으로 돌리면 높이 함수가 그대로라서, 훑는 평면이 128줄을 오르는 동안 레브 그래프가 바뀌지 않아요. Belfry 는 종탑이 바닥에서 첨탑까지 128줄을 다 채우고 하늘을 다 켜서 73/79/56 이에요. Brillouin Zones(100/100/100)와 Ghost Ship(81/100/100), Vampire Coffin(80/89/91), Stochastic Resonance(71/96/96)도 128줄을 다 쓰지만 주제 4 로 세지 않았어요. Lantern Procession 은 하늘을 14/255 아래에 두어 3/21/42 예요. +47판에서는 Pumpkin Vine 과 Lonely Runner 가 128줄을 써요. Lonely Runner 는 128줄을 위아래로 이은 원형 트랙이라 `thirds` 가 43/46/45 예요. Pumpkin Vine 은 덩굴이 언덕에서 위쪽 22줄 근처까지 감아 올라 100/100/92 인데, 보랏빛 하늘이 24 위라서 그 몫이 든 값이에요. 같은 씨앗으로 하늘을 지우고 재면 덩굴과 달만으로 23/41/64 예요. Koebe Packing(37/67/44)과 Specimen Jar(57/71/70), Web Tremor(99/72/33), Talking Board(38/94/98), Oloid(29/46/33)도 128줄을 쓰지만 주제 4 로 세지 않았어요. Pumpkin Carving 은 위 하늘을 24 아래로 내려 8/98/93 이에요. + ### 낯선 수학 -주제 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)이에요. +주제 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)예요. 목록 밖에서 다음에 해 볼 것이에요. 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. 수론 갈래는 32판의 Gilbreath Table 까지 넷이에요. 길브레스 표는 소수 말고 다른 수열로도 세울 수 있어요. 오들리코(1993)에 따르면 2 다음에 간격이 작고 고르게 흩어진 홀수가 오는 수열에서는 1 의 열이 서고, 간격이 크면 깨져요. 간격의 크기를 노브로 걸면 1 의 열이 무너지는 자리가 보여요. 해머슬리 과정의 점 대신 격자 칸마다 지수 분포 무게를 두면 마지막 통과 스미기(로스트, 1981)가 되고, 극한 모양이 쌍곡선에서 (√x+√y)² 의 경계로 바뀌어요. @@ -2536,6 +2601,8 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 46판에서 본 것이에요. 정리가 수를 못 박는 대상은 이번에도 셈으로 지켜봤어요. Reeb Graph 는 205가지 설정에서 극소 − 안장 + 극대가 다 2 − 2g 였고, 기울여 고리가 겹치며 극소와 극대가 늘어도 그 합은 그대로였어요. Brillouin Zones 는 25번 접은 조각이 1구역 15,966 LED 를 빈틈도 겹침도 없이 덮었어요. 넓이가 같다는 정리는 LED 위에서 어림으로만 섰어요. 칸이 804 LED 일 때 구역 넓이의 rms 차이가 0.9%, 402 LED 일 때 1.6% 라서, 경계를 LED 로 반올림하는 몫이 칸이 작을수록 커져요. 맞춤이 갈라 주지 못하는 것을 비가 갈랐어요. Lorentz Gas 의 퍼짐은 문턱 아래에서도 직선 맞춤의 R² 이 0.999 를 넘었고, MSD(2t)/MSD(t) 가 2 에서 2.48 까지 오르는 것을 보고서야 시간보다 빨리 퍼지는 것을 알았어요. 어림이 맞는 범위의 끝도 그림을 정했어요. Stochastic Resonance 의 시간 맞추기는 밀기가 문턱의 55% 일 때 맞았고, 95% 에서는 가장 큰 응답이 예측의 절반인 잡음으로 내려갔어요. 눈이 읽는 가장 작은 단위도 셈으로 찾았어요. Glass Patterns 는 점 짝으로는 잡음이었고 네 점 사슬이어야 흐름이 보였어요. +47판에서 본 것이에요. 정리가 한 점에서만 참인 순간은 프레임 사이로 빠져나갔어요. Lonely Runner 는 빠르기가 1 부터 k 면 구역이 1/k 에 꼭 맞게 닿는 순간이 한 점뿐이라, 프레임 사이를 24번 나눠 보고 한 바퀴의 1/256 의 여유를 주고서야 셋이 함께 외로워지는 것이 보였어요. 셈이 찾은 풀이가 정리보다 많은 것을 말해 주기도 했어요. Conway's Soldiers 의 네 군대는 무게가 다 꼭 1 이었고, 모든 뛰어넘기가 과녁 쪽으로 두 칸 다가가 무게를 하나도 잃지 않았어요. 그래서 무게를 넓이로 그린 원판이 그대로 합쳐져 고리를 채웠어요. 식을 적는 좌표도 셈으로 가렸어요. Oloid 의 괘선 식은 원 A 의 각을 어디서 재느냐에 따라 cos α + cos β + cos α cos β = 0 과 cos α cos β + cos α − cos β = 0 으로 갈려서, 접선 둘과 괘선이 한 평면에 놓이는지를 행렬식으로 다시 셈해 맞췄어요. 끌림과 밀림이 맞서는 식은 한쪽이 0 이 되는 자리에서 그림이 깨졌어요. Swarmalators 는 J = A 라서 위상이 정반대인 점 사이에 끌림이 사라지고 밀림만 남아, 몇 점이 무리 밖으로 날아갔어요. Koebe Packing 은 정리가 하나뿐이라고 한 배치를 60초 뒤에도 각의 합 오차 0.00000 rad 로 지켰어요. + 목록 밖에서 31판 뒤에 해 볼 것이에요. 모자 타일(스미스, 마이어스, 캐플런, 굿먼-스트라우스, 2023)은 한 가지 타일로만 평면을 비주기로 덮고, 뒤집어 놓인 타일이 판에 드문드문 흩어져요. 페르미-파스타-울람-칭구의 되돌아옴(1955)은 KdV Solitons 와 같은 뿌리라 같은 판에 두지 않아요. 목록 밖에서 33판 뒤에 해 볼 것이에요. 뷔퐁의 바늘(1777)은 Bertrand Paradox 와 같은 꼴로, 금을 넘는 바늘의 몫이 2/π 로 모이는 막대와 함께 세울 수 있어요. 토카르스키의 방(1995)은 거울로 된 다각형 방에서 한 점의 빛이 다른 한 점에만 닿지 못하는 방이라 Penrose Room 의 광선 추적을 그대로 써요. 프리드먼과 스코라(1987)는 둥근 원 셋으로는 보로메오 고리를 만들 수 없다는 것을 보였는데, 원을 밀어 넣을 때 어디서 부딪히는지가 그림이 돼요. 정렬 네트워크의 시간 t 의 순열을 점으로 찍으면 대각선에서 원판을 지나 반대 대각선으로 도는 점 구름이 돼요. 그 원판이 2007년 논문이 내다본 아르키메데스 법칙이에요. 간격이 2k 인 소수 쌍의 수는 Goldbach Comet 과 같은 FFT 로 세고, k 마다 하디-리틀우드 인수로 갈리는 줄기 그림이 돼요. @@ -2556,16 +2623,16 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 목록 밖에서 41판 뒤에 해 볼 것이에요. 길과 바퀴는 거꾸로도 맞춰요. 사인 곡선 길을 먼저 두고 그 위에서 축이 수평으로 가는 바퀴를 θ = ∫ dx / (−f(x)) 로 찾으면, 한 주기에 2π/n 을 돌게 축 깊이를 맞춘 바퀴가 닫히고, 둔덕 높이를 노브에 걸면 바퀴가 원에서 멀어져요. 퐁슬레 다각형의 변을 늘인 직선들이 만나는 점은 몇 개의 원뿔 곡선 위에 놓여서(리처드 슈워츠의 퐁슬레 격자, 2007), 꼭짓점이 미끄러질 때 그 교점들이 보이지 않는 타원을 따라 흘러요. 정120포체의 단면은 면이 수십 개라 Polytope Slices 의 큰 모양과는 멀지만, 단면 하나만 크게 띄우고 화소마다 볼 면 후보를 미리 줄이면 2 ms 안에 들 만해요. 두 손이 같은 박자에 함께 던지는 동기 사이트스왑 (4x,4x) 와 (6x,4)* 는 Siteswap 의 목록에 없는 좌우 대칭 아치예요. 그렉 프레더릭슨의 『Hinged Dissections』(2002)에는 정오각형과 정육각형을 정사각형으로 접는 경첩 해부가 있어서, 조각이 대여섯으로 늘어도 사슬 전체를 돌려 64 폭 안에 세울 수 있는지 재 볼 만해요. -목록 밖에서 42판 뒤에 해 볼 것이에요. 올로이드(파울 샤츠, 1929)는 서로의 중심을 지나는 두 원의 볼록 껍질이라, 바닥에 구르면 겉 전체가 차례로 닿아요. 콘웨이의 병사(1961)는 선 아래의 병사들이 뛰어넘기로 다섯째 줄에 닿지 못한다는 것을 황금비의 무게로 보이는 셈이라, 세운 패널에서 병사가 위로 뛰어오르는 그림이 돼요. - -목록 밖에서 43판 뒤에 해 볼 것이에요. 경첩마다 꼬임을 더한 뫼비우스 칼레이도사이클(요하네스 쇤케, 엘리엇 프리드, 2019)은 고리가 한 번 뒤집혀 닫히는데도 자유도가 하나라서, Kaleidocycle 의 닫힘 셈을 그대로 쓸 만해요. 그래프의 꼭짓점마다 원을 두고 이웃끼리 맞닿게 반지름을 고쳐 가는 원 채우기(파울 쾨베, 1936)를 Tutte Embedding 의 그물에 걸면, 엇갈림 없는 그림이 맞닿은 원 무더기로 풀려요. 히토메자시를 위아래와 양옆이 이어진 토러스에 감으면 실이 다 닫혀요. 22×44 토러스에서 세어 보니 감기지 않은 고리 27,637개는 다 8로 나눠 4가 남았고, 감아 도는 고리 777개 가운데 334개는 8의 배수였어요. 두 고리를 다른 색으로 칠하면 정리가 어디까지인지 보여요. 르베그 함수를 세워 그리면 같은 간격 점에서는 끝에서 솟고 체비쇼프 점에서는 (2/π) log n 높이로 낮게 물결쳐요. 고정점 둘이 하나로 겹친 포물형 뫼비우스 변환은 w = 1/(z − p) 에서 평행 이동이 되어, 체커가 한 점에서 나와 같은 점으로 돌아가는 원들을 따라 흘러요. +목록 밖에서 43판 뒤에 해 볼 것이에요. 경첩마다 꼬임을 더한 뫼비우스 칼레이도사이클(요하네스 쇤케, 엘리엇 프리드, 2019)은 고리가 한 번 뒤집혀 닫히는데도 자유도가 하나라서, Kaleidocycle 의 닫힘 셈을 그대로 쓸 만해요. 히토메자시를 위아래와 양옆이 이어진 토러스에 감으면 실이 다 닫혀요. 22×44 토러스에서 세어 보니 감기지 않은 고리 27,637개는 다 8로 나눠 4가 남았고, 감아 도는 고리 777개 가운데 334개는 8의 배수였어요. 두 고리를 다른 색으로 칠하면 정리가 어디까지인지 보여요. 르베그 함수를 세워 그리면 같은 간격 점에서는 끝에서 솟고 체비쇼프 점에서는 (2/π) log n 높이로 낮게 물결쳐요. 고정점 둘이 하나로 겹친 포물형 뫼비우스 변환은 w = 1/(z − p) 에서 평행 이동이 되어, 체커가 한 점에서 나와 같은 점으로 돌아가는 원들을 따라 흘러요. 목록 밖에서 44판 뒤에 해 볼 것이에요. 표시를 걸음마다 새로 뽑는 확률적 카츠 고리는 정말로 1/2 로 가라앉아서, Kac Ring 옆에 세우면 되돌아오는 고리와 섞이는 고리를 한 화면에서 견줄 수 있어요. 두 구름을 일부러 엇갈려 겹치면 햄 샌드위치의 칼이 셋, 다섯으로 늘어나요. 칼을 다 그리면 칼이 짝으로 태어나고 짝으로 사라지는 것이 보여요. 불가리아 솔리테어를 영 도형으로 그리면 한 수는 첫 열을 떼어 한 줄로 눕히는 일이라, 도형이 계단 삼각형으로 굳어 가는 길을 한 그림에 겹쳐 볼 수 있어요. 배경의 다섯 배까지 솟는 2차 유리해(아크메디에프, 안키에비치, 소토-크레스포, 2009)는 시공간 기록에 꼭대기 하나와 구멍 넷을 찍어서 Peregrine Soliton 의 바다에 그대로 띄울 수 있어요. -목록 밖에서 45판 뒤에 해 볼 것이에요. 같은 도브루신 경계에서 스핀 대신 포르투인과 카스텔레인(1972)의 FK 덩이를 그리면 경계가 SLE(16/3) 이 돼서, Ising Interface 의 SLE(3) 실과 한 패널에서 견줄 수 있어요. 외로운 달리기 선수 추측(외르크 빌스, 1967)은 서로 다른 빠르기로 원형 트랙을 도는 선수마다 다른 선수 모두에게서 트랙의 1/k 넘게 떨어지는 때가 온다는 것이라, 트랙을 세로로 세우고 외로워진 선수를 켜면 박자 그림이 돼요. 린드스트룀(1973)과 게셀, 비엔노(1985)의 보조정리는 겹치지 않는 격자 경로 묶음을 행렬식으로 세는 것이라, 세운 패널을 아래에서 위로 건너는 경로 다발이 될 만해요. +목록 밖에서 45판 뒤에 해 볼 것이에요. 같은 도브루신 경계에서 스핀 대신 포르투인과 카스텔레인(1972)의 FK 덩이를 그리면 경계가 SLE(16/3) 이 돼서, Ising Interface 의 SLE(3) 실과 한 패널에서 견줄 수 있어요. 린드스트룀(1973)과 게셀, 비엔노(1985)의 보조정리는 겹치지 않는 격자 경로 묶음을 행렬식으로 세는 것이라, 세운 패널을 아래에서 위로 건너는 경로 다발이 될 만해요. 목록 밖에서 46판 뒤에 해 볼 것이에요. 일관성 공명(아르카디 피코프스키, 위르겐 쿠르츠, 1997)은 밀기 없이 잡음만으로 들뜨는 피츠휴-나구모 신경 세포가 어떤 잡음 세기에서 가장 고른 박자로 튀는 일이에요. Stochastic Resonance 의 기록 꼴을 그대로 쓰면 박자 없이도 띠가 스스로 서요. 정사각 격자의 로렌츠 기체는 원판이 맞닿기 전까지 늘 통로가 열려 있어서, Lorentz Gas 의 삼각 격자 옆에 두면 문턱이 있는 격자와 없는 격자를 견줄 수 있어요. 면심입방 격자의 첫 브릴루앙 구역은 깎은 팔면체이고 높은 구역은 조각이 늘어서, Polytope Slices 처럼 단면을 쌓아 볼 만해요. +목록 밖에서 47판 뒤에 해 볼 것이에요. 콜린 로버츠가 1969년에 만든 스페리콘과 중심 거리가 반지름의 √2 배인 두 원 롤러는 Oloid 와 같은 전개도 셈으로 굴릴 수 있어요. 두 원 롤러는 무게중심 높이가 그대로라서, 0.707 과 0.75 사이를 오가는 올로이드의 흔들림과 나란히 두면 견줄 수 있어요. Koebe Packing 의 경계를 직사각형 대신 원판으로 바꾸면 최대 배치가 되고, 그 위에 뫼비우스 변환을 걸면 원 무더기 전체가 한 점 쪽으로 흘러가요. 33칸 펙 솔리테어는 가운데 한 알만 남기는 31수 풀이가 있어서, Conway's Soldiers 의 무게 셈으로 끝낼 수 없는 칸을 칠한 채 그 풀이를 보여 줄 수 있어요. + ### 화장품 팝업 주제 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 마다 한 번만 바뀌는 셈으로 그릴 만해요. @@ -2612,7 +2679,7 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 43판에서 본 것이에요. 검은 실루엣은 이번에도 밝은 것을 등져야 읽혔어요. Grave Hand 는 지름 50 LED 의 달 한가운데에서 손이 오르내리고, Scarecrow Crows 는 반지름 26 의 달을 팔 뒤에 두고 하늘을 맨 위까지 레벨 168 아래로 내리지 않았어요. 달 앞의 실루엣은 테두리 빛이 달과 밝기가 같으면 움직여도 셈에 들지 않았어요. Grave Hand 의 초록 테두리를 0.8 로 낮추자 움직임이 0.140 에서 올랐어요. 검은 것끼리 겹치면 늘린 것이 묻혔어요. Scarecrow Crows 의 늘린 까마귀는 검은 몸통 앞을 돌 때 K3 반응이 0.01 이라, 자리를 못 찾은 까마귀를 지평선 빛 앞 땅에 앉혀 0.05 로 올렸어요. 매끈한 빛무리는 이번에도 복잡도를 올려서 단계로 끊었어요. Grave Hand 의 달무리는 0.02 단계로 1.37 에서 1.04 로, Bone Jig 의 빛웅덩이와 아지랑이는 1/24 단계로 1.07 에서 0.754 로 내렸어요. 주황은 이번에도 갈색이 되기 쉬웠어요. Leaf Devil 은 깔때기 뒤쪽 잎도 0.76 밝기 아래로 어둡히지 않았고, Bone Jig 의 옅은 잔상은 보라를 띠다가 0.3 위에서야 빛의 색이 돼요. 무리를 높이마다 고르게 두는 데도 셈이 들었어요. Leaf Devil 의 첫 판은 잎이 다 꼭대기로 던져져 깔때기가 1초 남짓만 찼고, 잎마다 제 높이를 정해 거기서 돌게 하자 4.2초 내내 기둥이 됐어요. 사물의 꼴이 패널에서 다른 것으로 읽히기도 했어요. Scarecrow Crows 의 옥수숫단은 체스 룩으로, 다음엔 팔을 든 사람으로 보여서 호박 셋으로 바꿨어요. -43판 뒤에 해 볼 것이에요. 거미줄에 걸린 나방이 버둥거릴 때마다 떨림이 실을 타고 퍼지고, 허브의 거미가 그 떨림을 따라 달려가 실로 감싸는 장면은 Orb Weaver 의 그물을 그대로 써요. 박자에 맞춰 뼈 더미로 무너졌다가 한 뼈씩 날아올라 다시 맞춰지는 해골은 Bone Jig 의 뼈대를 써요. 패널 바닥에서 호박 덩굴이 128줄을 타고 오르며 잎 사이의 작은 호박 등이 하나씩 켜지는 그림은 세로 패널에 맞아요. +43판 뒤에 해 볼 것이에요. 박자에 맞춰 뼈 더미로 무너졌다가 한 뼈씩 날아올라 다시 맞춰지는 해골은 Bone Jig 의 뼈대를 써요. 44판이 다섯을 더 그렸어요. 깨진 창의 레이스 커튼 뒤로 눌러 오는 얼굴(Lace Phantom), 돌 턱에서 늘어져 떨어지는 점액(Slime Drip), 해골로 모이는 수정 구슬 속 연기(Crystal Ball), 혼자 흔들리는 빈 그네(Haunted Swing), 달 앞 담 위의 검은 고양이(Black Cat)예요. 42판 뒤에 적은 대로 큰 것 하나가 움직이는 쪽을 겨눴어요. 켜진 몫은 Slime Drip 의 0.351 에서 Black Cat 의 0.631 이에요. 움직임은 0.353 에서 0.449, 복잡도는 0.101 에서 0.461 이에요. @@ -2624,14 +2691,18 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 45판에서 본 것이에요. 사물의 꼴은 이번에도 다른 것으로 읽혔어요. Poltergeist 의 의자는 앞에서 그리면 가로대 때문에 사다리로 보여서 옆에서 본 h 꼴로 그렸어요. 어둑한 방은 빛이 줄어드는 거리로 정해졌어요. Poltergeist 의 등불이 29 LED 에서야 반으로 줄 때는 벽이 다 주황이었고, 19 LED 로 줄이자 빛 웅덩이 밖이 보라 그늘로 남았어요. 손전등은 빛이 벽에 막히는 셈이 그림을 정했어요. Corn Maze 는 빛이 옥수수를 지나는 LED 마다 줄게 했을 때 통로 하나만 비추는 막대였고, 지나온 옥수수 줄 수로 줄이자 원뿔이 됐어요. 떨어지는 것은 늦게 떨어질수록 패널 위쪽을 채웠어요. Candy Corn 은 끝 빠르기를 70 에서 55 LED 로 늦추자 위 3분의 1 이 9 에서 10 이 됐어요. 글은 굵은 LED 글꼴로 64 너비에 석 자까지 읽혔어요. Fogged Mirror 는 BOO 와 HI 를 한 줄에, GET OUT 과 LOOK BACK 을 두 줄에 썼어요. -45판 뒤에 해 볼 것이에요. 글자판 위를 미끄러지는 플랑셰트가 글자에 멈출 때마다 그 글자가 켜지는 말하는 판은 Fogged Mirror 의 글꼴을 쓸 수 있어요. 칼이 호박의 눈과 입을 차례로 도려내고 안에 촛불이 켜지는 장면은 Jack-o'-Lantern 이 그리지 않은 앞 이야기예요. - 46판이 다섯을 더 그렸어요. 언덕길을 오르는 등불 행렬(Lantern Procession), 안에서 열려 박쥐 떼를 쏟는 관(Vampire Coffin), 보름달 앞에서 우는 늑대(Howling Wolf), 자정에 우는 종탑(Belfry), 안개 속 유령선(Ghost Ship)이에요. 켜진 몫은 Lantern Procession 의 0.223 에서 Ghost Ship 의 0.939 이고, 움직임은 0.148 에서 0.981, 복잡도는 0.357 에서 0.759 예요. 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판 뒤에 해 볼 것이에요. Lantern Procession 의 길 원근에 손전등을 든 아이들을 걷게 하면, 집집이 호박 등이 켜진 골목을 따라 사탕을 받으러 가는 그림이 돼요. 달빛을 받은 사람 그림자가 늑대로 바뀌는 변신은 Howling Wolf 의 거리장 둘 사이를 섞으면 그릴 만해요. 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 이에요. + +47판에서 본 것이에요. 불이 켜진 구멍은 껍질보다 밝아야 구멍으로 읽혔어요. Pumpkin Carving 의 켜진 얼굴은 처음에 (197,121,10) 으로 껍질 (248,148,25) 보다 어두워서 무늬처럼 보였고, 구멍을 흰빛으로 올리고 껍질을 붉은 주황으로 내리자 얼굴이 섰어요. 노브를 재는 2~3초 창이 이야기의 처음을 정했어요. Web Tremor 는 나방이 처음 부딪히는 때를 1.1초로 당겨 그 창에 빛이 차게 하자 Hue 가 0.024 에서 0.072 로 올랐고, Pumpkin Carving 은 칼이 첫 입을 반쯤 돈 자리에서 시작해요. 얇은 것만 물들이는 색 노브는 이번에도 약했어요. Web Tremor 의 빛은 실 둘레로 번지게 하고서야 Hue 가 0.03 을 넘었어요. 어두운 노랑은 이번에도 올리브 갈색이 돼서, Specimen Jar 는 노란 홍채의 밝기를 0.6 아래로 내리지 않았어요. 매끈한 촛불 빛은 이번에도 복잡도를 올려서 Talking Board 는 1.02 에서 0.40 으로, Pumpkin Carving 은 1.018 에서 0.224 로 계단을 지어 내렸어요. + +47판 뒤에 해 볼 것이에요. 선반 위에 병 셋을 두고 색이 다른 액체에 눈알을 하나씩 넣어, 한 눈이 응시하면 나머지도 차례로 돌아보게 하면 Specimen Jar 의 구 그리기를 그대로 써요. Web Tremor 의 그래프 파동을 덤불에 이어 걸린 거미줄 여러 장에 걸면, 빛이 한 그물에서 다른 그물로 건너가는 것을 보여 줄 수 있어요. + ## 다시 돌리기 `web/` 에서 실행해요. `web/node_modules` 가 있어야 해요. From 81dd9a04bef0e8d24b6fcb4f3674f99e08d085e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:05:30 +0000 Subject: [PATCH 8/9] =?UTF-8?q?pattern-study:=20Web=20Tremor=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=98=20=EC=85=8B=20=EB=82=98=EC=97=B4=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PMCSedMgeD9NiVSkNiWyK3 --- incoming/pattern-study/patterns/web-tremor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incoming/pattern-study/patterns/web-tremor.js b/incoming/pattern-study/patterns/web-tremor.js index 9bc23e038..819fd4cfc 100644 --- a/incoming/pattern-study/patterns/web-tremor.js +++ b/incoming/pattern-study/patterns/web-tremor.js @@ -200,7 +200,7 @@ export function update(dt, input, params) { } // The spider: at the hub until the first pulse reaches it, then it turns // to the moth's radial, waits for more to be sure (six in all, or four - // seconds), runs out along the radial, wraps, and walks back. + // seconds), then runs out along the radial to wrap the moth and walk back. const hubT = M.d[S.hub] / V; const path = [S.hub]; for (let j = 0; j <= M.j; j++) if (S.id[M.k][j] >= 0) path.push(S.id[M.k][j]); From 19c8bc2690bd4c941687f7f47f30ae3f109a9e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:31:09 +0000 Subject: [PATCH 9/9] =?UTF-8?q?pattern-study:=2047=ED=8C=90=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=93=A0=20index.html=20=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=A7=93=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PMCSedMgeD9NiVSkNiWyK3 --- 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 28586207d..ad1c08449 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -210,8 +210,8 @@

패턴 연구

- - + +