From efe4903ec7853505b133df53e3abed6549566c03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:32:04 +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=2036=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_01A5sd4vVG4w3eC2bpCrSD5a From e197381cffe901351d0ea01993cef03a4f9f40e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:14:19 +0000 Subject: [PATCH 2/9] pattern study: Heawood Torus, Schwarz Lantern --- .../pattern-study/patterns/heawood-torus.js | 253 ++++++++++++++++++ .../pattern-study/patterns/schwarz-lantern.js | 204 ++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 incoming/pattern-study/patterns/heawood-torus.js create mode 100644 incoming/pattern-study/patterns/schwarz-lantern.js diff --git a/incoming/pattern-study/patterns/heawood-torus.js b/incoming/pattern-study/patterns/heawood-torus.js new file mode 100644 index 000000000..6488eaf54 --- /dev/null +++ b/incoming/pattern-study/patterns/heawood-torus.js @@ -0,0 +1,253 @@ +// ===== Patternflow pattern ===== +// Title: Heawood Torus +// Family: topology +// Idea: A tall torus turns and leans in front of you painted with Heawood's map of seven hexagonal countries, each of which touches all six others, so this map needs seven colours where a map on the sphere never needs more than four. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Heawood's seven-colour map on the torus (Percy Heawood, 1890), pattern round 36 +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Hole=0..1, Tilt=0..1 +// +// Knob 1: Hue. Turns the seven colours round the wheel together, a seventh apart. At 0 they are orange, lime, green, cyan, blue, violet and pink; every seventh of the way round the same seven come back, each moved on to another country, and halfway between, at 0.07, 0.21 and so on, one of them is pure red. +// Knob 2: Speed. How fast the torus turns about its long axis. At 2 a turn takes 16 s, so it goes from face-on to edge-on in 4 s, and its lean nods 3 degrees each way every 29 s; at 0.2 everything is ten times slower, at 3 a turn takes 10 s. +// Knob 3: Hole. The tube's radius against the radius of the curve it runs round, the torus keeping its width, 59 LEDs at the opening tilt. At 0 it is 0.85: the tube is 27 LEDs thick and the hole a slot 5 LEDs wide and 65 tall; at 0.5 the tube is 23 thick and the hole 14 wide; at 1 it is 0.4, a 17 LED tube round a hole 25 wide and 86 tall. +// Knob 4: Tilt. How far the top of the torus leans toward you: 8 degrees at 0.06, so you look almost straight at its side, 23 at 0.5 and 40 at 1, where you look down into the hole and see the top bend from above. +// Buttons: K1 tap draws, for six seconds, the capital of each country and the 21 roads between them in white: the complete graph on seven points, drawn on the torus with no two roads crossing, each road crossing one border. Tapping again while they show keeps them up. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, sin, cos, abs, round, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); + +// The plane tiled by hexagons, the hexagon at axial (a, b) coloured +// (a + 3b) mod 7. Its six neighbours sit at (+-1, 0), (0, +-1) and +// +-(1, -1), so their colours differ from its own by +-1, +-3 and +-2: all +// six other colours, once each. The colouring repeats on the lattice L of +// (a, b) with a + 3b = 0 mod 7, which has index 7, so the torus plane / L +// holds exactly one hexagon of each colour, and each touches the other six. +// L is spanned by E1 = (2, -3) and E2 = (1, 2) (determinant 7); of all the +// bases of L these two shear the hexagons least on the long, thin parameter +// square of this torus. +const E1A = 2, E1B = -3, E2A = 1, E2B = 2; + +// Samples round the core curve and round the tube; samples along each road. +const NU = 600, NV = 120, NS = NU * NV, NR = 200; +// The outer half width of the torus in LEDs, and its half height before it leans. +const RO = 31, HALF = 63; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// The country at fractional axial coordinates (qa, qb): round to the nearest +// hexagon centre in cube coordinates, then colour it. +function country(qa, qb) { + const x = qa, z = qb, y = -x - z; + let rx = round(x), ry = round(y), rz = round(z); + const dx = abs(rx - x), dy = abs(ry - y), dz = abs(rz - z); + if (dx > dy && dx > dz) rx = -ry - rz; + else if (dy <= dz) rz = -rx - ry; + return (((rx + 3 * rz) % 7) + 7) % 7; +} + +// The surface: a tube of radius r round a stadium, two half circles of +// radius rc joined by straights 2 ly long, standing up in the x-y plane. +// (U, V) in the unit square is the fraction of the way round the stadium and +// round the tube. core() writes the point of the stadium at U and its +// outward normal in the plane, (cx, cy, ox, oy), at out[o..o+3]; the surface +// point is (cx + r cos v ox, cy + r cos v oy, r sin v) with v = 2 pi V, and +// its normal (cos v ox, cos v oy, sin v). +function core(S, U, out, o) { + const rc = S.rc, ly = S.ly; + let s = (U - floor(U)) * S.per, cx, cy, ox, oy; + if (s < 2 * ly) (cx = rc), (cy = -ly + s), (ox = 1), (oy = 0); + else if ((s -= 2 * ly) < PI * rc) { + const a = s / rc; + (ox = cos(a)), (oy = sin(a)), (cx = rc * ox), (cy = ly + rc * oy); + } else if ((s -= PI * rc) < 2 * ly) (cx = -rc), (cy = ly - s), (ox = -1), (oy = 0); + else { + const a = PI + (s - 2 * ly) / rc; + (ox = cos(a)), (oy = sin(a)), (cx = rc * ox), (cy = -ly + rc * oy); + } + out[o] = cx; + out[o + 1] = cy; + out[o + 2] = ox; + out[o + 3] = oy; +} + +// The plane point U E1 + V E2 is the one painted at (U, V), so the square's +// sides go to E1 and E2 and the map closes up on the torus. Back the other +// way, (qa, qb) lands at U = (2 qa - qb) / 7, V = (3 qa + 2 qb) / 7. +function build(S) { + S.rc = RO / (1 + S.ratio); + S.r = S.rc * S.ratio; + S.ly = HALF - RO; + S.per = 4 * S.ly + TAU * S.rc; + const { cc, tc, ts, cid, road } = S; + for (let j = 0; j < NV; j++) { + tc[j] = cos((TAU * (j + 0.5)) / NV); + ts[j] = sin((TAU * (j + 0.5)) / NV); + } + for (let i = 0; i < NU; i++) { + const U = (i + 0.5) / NU; + core(S, U, cc, 4 * i); + for (let j = 0; j < NV; j++) cid[i * NV + j] = country(U * E1A + (j + 0.5) / NV * E2A, U * E1B + (j + 0.5) / NV * E2B); + } + // The dual graph: the capital of country a sits at the hexagon (a, 0), and + // three roads leave it toward (a + 1, 0), (a, 1) and (a + 1, -1). Seven + // capitals times three is all 21 roads of K7, each crossing one border. + const q4 = [0, 0, 0, 0]; + let n = 0; + for (let a = 0; a < 7; a++) + for (const [da, db] of [[1, 0], [0, 1], [1, -1]]) + for (let t = 0; t < NR; t++, n++) { + const qa = a + (da * t) / (NR - 1), qb = (db * t) / (NR - 1), v = (TAU * (3 * qa + 2 * qb)) / 7, o = 3 * n; + core(S, (2 * qa - qb) / 7, q4, 0); + road[o] = q4[0] + S.r * cos(v) * q4[2]; + road[o + 1] = q4[1] + S.r * cos(v) * q4[3]; + road[o + 2] = S.r * sin(v); + } +} + +export function setup(params) { + params.s = { + hue: 0, speed: 2, ratio: -1, tilt: 0.06, spin: 0, nod: 0, roads: -1, rc: 0, r: 0, ly: 0, per: 0, + cc: new Float32Array(4 * NU), tc: new Float32Array(NV), ts: new Float32Array(NV), cid: new Uint8Array(NS), road: new Float32Array(3 * 21 * NR), + zb: new Float32Array(N), ib: new Int32Array(N), ib2: new Int32Array(N), cb: new Uint8Array(N), rb: new Uint8Array(N), pal: new Float32Array(21), + }; +} + +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[3], 0, 1); + const ratio = 0.85 - 0.45 * cl(k[2], 0, 1); + if (abs(ratio - S.ratio) > 1e-4) { + S.ratio = ratio; + build(S); + } + S.spin += d * S.speed * 0.2; + S.nod += d * S.speed * 0.11; + if (S.roads >= 0) { + S.roads += d; + if (S.roads > 7) S.roads = -1; + } + if (input.btnPressed && input.btnPressed[0]) S.roads = S.roads < 0 ? 0 : min(S.roads, 0.5); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { cc, tc, ts, cid, zb, ib, ib2, cb, rb, road, pal } = S, r = S.r; + // Spin about the torus's long axis, then lean the top of that axis toward + // the viewer, who looks along +z. + const ph = S.spin, cp = cos(ph), sp = sin(ph); + const la = -(0.1 + 0.6 * S.tilt + 0.05 * sin(S.nod)), cla = cos(la), sla = sin(la); + const m0 = cp, m2 = sp; + const m3 = sla * sp, m4 = cla, m5 = -sla * cp; + const m6 = -cla * sp, m7 = sla, m8 = cla * cp; + // Fit the leaning torus to the panel height. + const sc = min(1, 63.5 / (HALF * cla - RO * sla)); + zb.fill(1e9); + ib.fill(-1); + // Each sample's screen x, y and depth are a column part plus cos v and + // sin v times a part, so the loop over the tube is a few multiplies. + const Xb = sc * m2 * r, Yb = -sc * m5 * r, Zb = m8 * r; + for (let i = 0; i < NU; i++) { + const cx = cc[4 * i], cy = cc[4 * i + 1], ox = cc[4 * i + 2], oy = cc[4 * i + 3], A = m6 * ox + m7 * oy; + const X0 = 32 + sc * m0 * cx, Xa = sc * m0 * r * ox, Y0 = 64 - sc * (m3 * cx + m4 * cy), Ya = -sc * r * (m3 * ox + m4 * oy); + const Z0 = m6 * cx + m7 * cy, Za = r * A; + for (let j = 0, k = i * NV; j < NV; j++, k++) { + const cv = tc[j], sv = ts[j]; + // Facing away from the eye. + if (cv * A + sv * m8 > 0.05) continue; + const px = floor(X0 + cv * Xa + sv * Xb), py = floor(Y0 + cv * Ya + sv * Yb); + if (px < 0 || px >= W || py < 0 || py >= H) continue; + const p = py * W + px, Z = Z0 + cv * Za + sv * Zb; + if (Z < zb[p]) (zb[p] = Z), (ib[p] = k); + } + } + // Fill LEDs that no front sample reached, or where the back of the tube + // shows through between two front samples. + ib2.set(ib); + for (let y = 1; y < H - 1; y++) + for (let x = 1; x < W - 1; x++) { + const p = y * W + x, z = zb[p]; + for (let q = 1; q <= W; q += W - 1) { + const a = zb[p - q], b = zb[p + q]; + if (a < 1e8 && b < 1e8 && abs(a - b) < 4 && z > max(a, b) + 3) { + ib2[p] = a < b ? ib[p - q] : ib[p + q]; + break; + } + } + } + for (let p = 0; p < N; p++) { + const k = ib2[p]; + cb[p] = k < 0 ? 255 : cid[k]; + if (k >= 0 && k !== ib[p]) { + const i = (k / NV) | 0, j = k - i * NV, o = 4 * i; + zb[p] = m6 * (cc[o] + r * tc[j] * cc[o + 2]) + m7 * (cc[o + 1] + r * tc[j] * cc[o + 3]) + m8 * r * ts[j]; + } + } + + // The roads, where they lie on the visible surface, and the capitals at + // their ends as 2 x 2 dots. + const e = S.roads < 0 ? 0 : sm(S.roads / 0.5) * (1 - sm(S.roads - 6)); + if (e > 0) { + rb.fill(0); + for (let i = 0, o = 0; i < 21 * NR; i++, o += 3) { + const x = road[o], y = road[o + 1], z = road[o + 2]; + const px = floor(32 + sc * (m0 * x + m2 * z)), py = floor(64 - sc * (m3 * x + m4 * y + m5 * z)); + if (px < 0 || px >= W || py < 0 || py >= H) continue; + const p = py * W + px; + if (m6 * x + m7 * y + m8 * z > zb[p] + 1.5) continue; + rb[p] = 1; + if (i % NR === 0 && px < W - 1 && py < H - 1) rb[p + 1] = rb[p + W] = rb[p + W + 1] = 1; + } + } + + // The seven colours, a seventh apart, opening off red. + const h0 = S.hue + 1 / 14; + for (let c = 0; c < 7; c++) hsv(h0 + c / 7, 1, 1, pal, 3 * c); + // Light from the upper left, in front. + const lx = -0.45, ly = 0.6, lz = -0.66; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, c = cb[p]; + if (c === 255) { + display.setPixel(x, y, 0, 0, 0); + continue; + } + const k = ib2[p], i = (k / NV) | 0, j = k - i * NV, nx = tc[j] * cc[4 * i + 2], ny = tc[j] * cc[4 * i + 3], nz = ts[j]; + const vx = m0 * nx + m2 * nz, vy = m3 * nx + m4 * ny + m5 * nz, vz = m6 * nx + m7 * ny + m8 * nz; + const dif = max(0, vx * lx + vy * ly + vz * lz); + // Half vector of the light and the eye at -z, for a small shine. + let hv = max(0, (vx * lx + vy * ly + vz * (lz - 1)) / 1.82); + (hv *= hv), (hv *= hv), (hv *= hv), (hv *= hv), (hv *= hv); + const spec = hv * 0.5; + const lit = (0.6 + 0.55 * dif) * (1 - 0.2 * e); + let R = pal[3 * c] * lit + spec, G = pal[3 * c + 1] * lit + spec, B = pal[3 * c + 2] * lit + spec; + // A dark LED where the country changes to the right or below, and + // where the surface folds behind a nearer part of the tube. + const r = x < W - 1 ? cb[p + 1] : 255, b = y < H - 1 ? cb[p + W] : 255, zf = zb[p] - 6; + if ((r !== 255 && r !== c) || (b !== 255 && b !== c) || + (x > 0 && cb[p - 1] !== 255 && zb[p - 1] < zf) || (r !== 255 && zb[p + 1] < zf) || + (y > 0 && cb[p - W] !== 255 && zb[p - W] < zf) || (b !== 255 && zb[p + W] < zf)) (R *= 0.08), (G *= 0.08), (B *= 0.08); + if (e > 0 && rb[p]) { + R += (1 - R) * e; + G += (1 - G) * e; + B += (1 - B) * e; + } + display.setPixel(x, y, cl(R * 255, 0, 255), cl(G * 255, 0, 255), cl(B * 255, 0, 255)); + } +} diff --git a/incoming/pattern-study/patterns/schwarz-lantern.js b/incoming/pattern-study/patterns/schwarz-lantern.js new file mode 100644 index 000000000..f9c4da095 --- /dev/null +++ b/incoming/pattern-study/patterns/schwarz-lantern.js @@ -0,0 +1,204 @@ +// ===== Patternflow pattern ===== +// Title: Schwarz Lantern +// Family: geometry +// Idea: A lantern of flat triangles, every corner on a cylinder the height of the panel, turns under a light while its bands multiply from 3 to 40 and back, and as it folds into a glinting accordion the bar at the left shows its area climbing to 1.88 times the cylinder's. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Schwarz lantern (Hermann Schwarz, 1880), pattern round 36 +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Sides=5..12, Light=0..1 +// +// Knob 1: Hue. The lantern's colour once round the wheel; facets tipped up lean a little one way round it and facets tipped down the other. At 0 it is azure with blue tops and teal undersides, at 0.25 violet and pink, at 0.5 orange with gold tops, at 0.75 green; the inside, seen through the open top, is a darker shade 0.12 further round. +// Knob 2: Speed. At 2 the lantern turns once in 26 s, so a facet comes round to the same place every 5 s at five sides, and the bands go from 3 to 40 and back to 3 in 20 s, lingering at both ends; at 0.2 everything takes ten times as long, at 3 two thirds as long. +// Knob 3: Sides. Corners on each ring, n, 5 to 12. The lantern's area goes with the bands over n squared, so the same 40 bands fold it to 1.88 times the cylinder's area at 5 sides, 1.51 at 6, 1.19 at 8 and only 1.04 at 12, where it stays a smooth, finely hatched column and the bar barely moves. +// Knob 4: Light. The height of the light, which comes from the front left. At 0.06 it is 44 degrees above, so the facets tipped up shine and the ones tipped down fall dark and the folds read as stripes; at 0.5 it is level and the two kinds of facet come out alike; at 1 it is 50 degrees below and lights the undersides. A second light high behind the lantern puts glints on the upper facets once the folds are deep. +// Buttons: K1 tap folds the lantern to 40 bands in 0.6 s, holds it there 2 s and lets it ease back over 2 s. + +const W = 64, H = 128, SS = 2, SW = W * SS, SH = H * SS, SN = SW * SH; +const { floor, ceil, min, max, sqrt, sin, cos, exp, log, abs, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); + +// The cylinder: radius R and height HC in LEDs, its axis at column CX, seen +// from EL radians above. The bands run from MMIN to MMAX. +const R = 26, HC = 114, CX = 34.5, EL = 0.2, MMIN = 3, MMAX = 40, NMAX = 12; +// The gauge: the lantern's area over the cylinder's, a bar up from row G0 at +// the left edge, GPX LEDs to the unit, with a mark at 1. +const G0 = 125, GPX = 44; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +export function setup(params) { + const V = (MMAX + 2) * NMAX; + params.s = { + hue: 0, speed: 2, n: 5, light: 0.06, spin: 0, sweep: 2.2, snap: -1, mu: MMIN, area: 1, + ox: new Float32Array(V), oy: new Float32Array(V), oz: new Float32Array(V), + vx: new Float32Array(V), vy: new Float32Array(V), vz: new Float32Array(V), + zb: new Float32Array(SN), cr: new Float32Array(SN), cg: new Float32Array(SN), cb: new Float32Array(SN), col: new Float32Array(3), + }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 5, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.n = Math.round(cl(k[2], 5, NMAX)); + S.light = cl(k[3], 0, 1); + S.spin += d * S.speed * 0.12; + S.sweep += d * S.speed * 0.16; + if (S.snap >= 0) { + S.snap += d; + if (S.snap > 4.6) S.snap = -1; + } + if (input.btnPressed && input.btnPressed[0]) S.snap = S.snap < 0 ? 0 : min(S.snap, 0.6); + // The bands: MMIN to MMAX and back, evenly in log, lingering at both ends. + const u = 0.5 - 0.5 * cos(S.sweep); + let mu = exp(log(MMIN) + log(MMAX / MMIN) * u); + if (S.snap >= 0) { + const e = S.snap < 0.6 ? sm(S.snap / 0.6) : 1 - sm((S.snap - 2.6) / 2); + mu = exp(log(mu) + (log(MMAX) - log(mu)) * e); + } + S.mu = mu; +} + +// Rasterise one triangle into the supersampled buffers, flat coloured, row +// by row: on each row every edge bounds the run of samples on one side. +function tri(S, ax, ay, az, bx, by, bz, cx, cy, cz, r, g, b) { + const { zb, cr, cg, cb } = S; + const det = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + if (abs(det) < 1e-6) return; + // Depth as a plane over the screen. + const dzx = ((bz - az) * (cy - ay) - (cz - az) * (by - ay)) / det; + const dzy = ((cz - az) * (bx - ax) - (bz - az) * (cx - ax)) / det; + const s = det > 0 ? 1 : -1; + const y0 = max(0, ceil(min(ay, by, cy) - 0.5)), y1 = min(SH - 1, floor(max(ay, by, cy) - 0.5)); + for (let y = y0; y <= y1; y++) { + const py = y + 0.5; + let xl = 0, xr = SW - 1; + for (let e = 0; e < 3; e++) { + const x0 = e === 0 ? ax : e === 1 ? bx : cx, y0e = e === 0 ? ay : e === 1 ? by : cy; + const x1 = e === 0 ? bx : e === 1 ? cx : ax, y1e = e === 0 ? by : e === 1 ? cy : ay; + // Inside when s ((x1 - x0)(py - y0) - (y1 - y0)(px - x0)) >= 0, linear in px. + const A = -s * (y1e - y0e), B = s * ((x1 - x0) * (py - y0e) + (y1e - y0e) * x0); + if (abs(A) < 1e-9) { + if (B < 0) xr = -1; + } else if (A > 0) xl = max(xl, ceil(-B / A - 0.5)); + else xr = min(xr, floor(-B / A - 0.5)); + } + for (let x = xl; x <= xr; x++) { + const z = az + dzx * (x + 0.5 - ax) + dzy * (py - ay), p = y * SW + x; + if (z < zb[p]) { + zb[p] = z; + cr[p] = r; + cg[p] = g; + cb[p] = b; + } + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { ox, oy, oz, vx, vy, vz, zb, cr, cg, cb, col } = S; + const n = S.n, mu = S.mu, K = ceil(mu - 1e-6); + const ce = cos(EL), se = sin(EL), cxs = CX * SS, cys = 64 * SS; + const ct = cos(S.spin), st = sin(S.spin); + // Ring k holds n corners at angles (2 pi j + pi k) / n, so each ring is + // turned pi / n from the last, at height k h / mu, the last held at the + // top: at a whole mu this is the lantern with mu bands, and between two + // whole values the top band opens from flat to full height. + for (let k = 0; k <= K; k++) { + const y = min(HC, (k * HC) / mu); + for (let j = 0; j < n; j++) { + const a = (TAU * j + PI * k) / n, x = R * cos(a), z = R * sin(a), o = k * n + j; + ox[o] = x; + oy[o] = y; + oz[o] = z; + // Turn about the axis, then look down by EL. + const xr = x * ct + z * st, zr = -x * st + z * ct, yc = y - HC / 2; + vx[o] = cxs + SS * xr; + vy[o] = cys - SS * (yc * ce + zr * se); + vz[o] = -yc * se + zr * ce; + } + } + zb.fill(1e9); + cr.fill(0); + cg.fill(0); + cb.fill(0); + // The light from the front left, its height set by K4: 44 degrees above + // at the opening, level at 0.5, 50 below at 1. The eye looks along +z. + const le = 0.87 - 1.74 * S.light, Lx = -0.55 * cos(le), Ly = sin(le), Lz = -0.83 * cos(le); + const Hx = Lx, Hy = Ly, Hz = Lz - 1, Hn = 1 / sqrt(Hx * Hx + Hy * Hy + Hz * Hz); + // The half vector of a second light high above and behind, for the + // glints off deep folds. + const Gx = 0.25, Gy = 0.8, Gz = -0.45, Gn = 1 / sqrt(Gx * Gx + Gy * Gy + Gz * Gz); + const h0 = S.hue + 0.55; + let area = 0; + for (let k = 0; k < K; k++) + for (let j = 0; j < n; j++) + for (let t = 0; t < 2; t++) { + // t = 0: base on ring k, apex on ring k + 1 between the base's ends. + // t = 1: base on ring k + 1, apex on ring k between its ends. + const i0 = t === 0 ? k * n + j : (k + 1) * n + ((j + n - 1) % n); + const i1 = t === 0 ? k * n + ((j + 1) % n) : (k + 1) * n + j; + const i2 = t === 0 ? (k + 1) * n + j : k * n + j; + const ux = ox[i1] - ox[i0], uy = oy[i1] - oy[i0], uz = oz[i1] - oz[i0]; + const wx = ox[i2] - ox[i0], wy = oy[i2] - oy[i0], wz = oz[i2] - oz[i0]; + let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx; + const nl = sqrt(nx * nx + ny * ny + nz * nz); + area += nl / 2; + if (nl < 1e-9) continue; + (nx /= nl), (ny /= nl), (nz /= nl); + // Point the normal out of the cylinder. + if (nx * (ox[i0] + ox[i1] + ox[i2]) + nz * (oz[i0] + oz[i1] + oz[i2]) < 0) (nx = -nx), (ny = -ny), (nz = -nz); + const rx = nx * ct + nz * st, rz0 = -nx * st + nz * ct; + const ry = ny * ce + rz0 * se, rz = -ny * se + rz0 * ce; + // Outside faces turn toward the eye; the inside shows through the top. + const out = rz < 0, sx = out ? rx : -rx, sy = out ? ry : -ry, sz = out ? rz : -rz; + const dif = max(0, sx * Lx + sy * Ly + sz * Lz); + let lit, spec = 0; + if (out) { + const hv = max(0, (sx * Hx + sy * Hy + sz * Hz) * Hn), gv = max(0, (sx * Gx + sy * Gy + sz * Gz) * Gn); + spec = hv ** 40 * 0.6 + gv ** 40; + hsv(h0 + 0.1 * ny, 0.85 - 0.1 * abs(ny), 1, col, 0); + lit = 0.16 + 0.84 * dif; + } else { + hsv(h0 + 0.12, 0.7, 1, col, 0); + lit = 0.1 + 0.3 * dif; + } + tri(S, vx[i0], vy[i0], vz[i0], vx[i1], vy[i1], vz[i1], vx[i2], vy[i2], vz[i2], col[0] * lit + spec, col[1] * lit + spec, col[2] * lit + spec); + } + // Summed from the triangles themselves; at whole mu it equals + // A = 2 m n r sin(pi/n) sqrt((h/m)^2 + r^2 (1 - cos(pi/n))^2). + S.area = area / (TAU * R * HC); + + const f = 255 / (SS * SS); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let r = 0, g = 0, b = 0; + for (let dy = 0; dy < SS; dy++) + for (let dx = 0; dx < SS; dx++) { + const p = (y * SS + dy) * SW + x * SS + dx; + r += cr[p]; + g += cg[p]; + b += cb[p]; + } + // The gauge: a two-LED bar up the left edge, and a mark at 1, where + // the lantern's area would equal the cylinder's. + if ((x === 1 || x === 2) && y > G0 - S.area * GPX && y <= G0) r = g = b = 190 / f; + if (x < 5 && y === G0 - GPX) r = g = b = 255 / f; + display.setPixel(x, y, cl(r * f, 0, 255), cl(g * f, 0, 255), cl(b * f, 0, 255)); + } +} From 0747863e676a1afa1510163da788c09095447354 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:17:09 +0000 Subject: [PATCH 3/9] pattern study: Curve Shortening, Catastrophe Machine --- .../patterns/catastrophe-machine.js | 422 +++++++++++++ .../patterns/curve-shortening.js | 564 ++++++++++++++++++ 2 files changed, 986 insertions(+) create mode 100644 incoming/pattern-study/patterns/catastrophe-machine.js create mode 100644 incoming/pattern-study/patterns/curve-shortening.js diff --git a/incoming/pattern-study/patterns/catastrophe-machine.js b/incoming/pattern-study/patterns/catastrophe-machine.js new file mode 100644 index 000000000..b68c7d567 --- /dev/null +++ b/incoming/pattern-study/patterns/catastrophe-machine.js @@ -0,0 +1,422 @@ +// ===== Patternflow pattern ===== +// Title: Catastrophe Machine +// Family: physics +// Idea: Zeeman's machine stands on the panel, a disc on a pivot with two elastic bands tied to one point of its rim, one to a fixed anchor above and one to a control point that sweeps back and forth below; the disc follows its energy minimum smoothly until the control point leaves the cusped diamond where two minima exist, and then it snaps across, so the control point's trail takes one colour going right and the other coming back. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Zeeman's catastrophe machine (Christopher Zeeman, 1972), pattern round 36 +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Tension=0..1, Depth=0..1 +// +// Knob 1: Hue. Turns the three colours together. At 0 the diamond is violet, B's trail amber while P is on the left of the disc and cyan while it is on the right; the disc's face is steel blue. At 0.25 the three are crimson, green and blue on a purple disc; at 0.5 green, blue and red on a pale amber one; at 0.75 cyan, pink and lime on a green one. Round orange and yellow the diamond's shading and the disc are laid paler and brighter, so they do not go olive or brown. +// Knob 2: Speed. At 2 B crosses the panel in 4 seconds and comes back in 4, and a snap carries the disc over in under half a second; at 0.2 everything runs ten times slower, at 3 half as fast again. +// Knob 3: Tension. The rest length of both bands, from one disc radius at 0 to nothing at 1, the anchor 2.5 radii from the pivot. At 0 the diamond is 30 LEDs wide and 34 tall, its top cusp 8 LEDs under the disc; at 0.25 it is 21 by 25, at 0.5 13 by 17, at 0.75 6 by 8, at 0.9 a speck. At 1 the bands are springs of zero length, the energy has one minimum wherever B is, the diamond is gone and the disc turns smoothly to face the sum of A and B. +// Knob 4: Depth. The height of B's sweep, as a share of the way from the side cusps down to the bottom cusp. At 0 it crosses 30% of the way down, give or take a quarter, where the diamond is wide, and each snap throws the disc about 120 degrees; at 0.3 about 85, at 0.5 about 70 and only on some passes; at 0.7 about 45, near the bottom cusp, where the two minima are close; from 0.8 the sweep passes under the cusp, never enters the diamond, and the disc swings across with no snap at all. +// Buttons: K1 tap throws B to the mirror point across the axis. From outside the diamond the disc snaps at once; from inside it, B lands inside again and the disc stays where it was, since inside either side is stable. + +const W = 64, H = 128, N = W * H; +// The machine in disc radii: pivot O at the origin, anchor A at (0, AD), +// bands of rest length L. On the panel one radius is SC LEDs, O sits at row +// OY, and the anchor 5 rows from the top. +const AD = 2.5, L0 = 1, SC = 18.6, OX = 32, OY = 5 + SC * AD, NF = 360, NT = 180, TRAIL = 180, SNAPS = 24; +const { floor, min, max, abs, sqrt, sin, cos, atan2, round, PI, ceil } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); +const toX = (x) => OX + SC * x, toY = (y) => OY - SC * y; + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// The energy V(t) = (|P-A| - L)+^2 / 2 + (|P-B| - L)+^2 / 2, P = (cos t, sin t), +// a band pulling only while it is stretched, and its derivative in t: each +// band adds its tension times the component of its direction along the rim. +function energy(t, L, bx, by) { + const px = cos(t), py = sin(t); + const ea = max(0, sqrt(px * px + (py - AD) * (py - AD)) - L), eb = max(0, sqrt((px - bx) ** 2 + (py - by) ** 2) - L); + return 0.5 * (ea * ea + eb * eb); +} +function torque(t, L, bx, by) { + const px = cos(t), py = sin(t), tx = -py, ty = px; + const ax = px, ay = py - AD, da = sqrt(ax * ax + ay * ay), ux = px - bx, uy = py - by, db = sqrt(ux * ux + uy * uy); + return (max(0, da - L) * (ax * tx + ay * ty)) / da + (max(0, db - L) * (ux * tx + uy * ty)) / (db || 1e-9); +} + +// The bifurcation set. At a fold the disc angle t is an equilibrium (V' = 0) +// that is about to stop being a minimum (V'' = 0). For each t the band to B +// leaves P along some direction u; V' = 0 fixes how far B must be, and the +// direction is found where V'' changes sign. One point per t, so the 360 +// points run round the diamond in order, through its four cusps. +function foldAt(t, L, be) { + const px = cos(t), py = sin(t), tx = -py, ty = px; + const ax = px, ay = py - AD, da = sqrt(ax * ax + ay * ay), uax = ax / da, uay = ay / da; + const ca = uax * tx + uay * ty, sa = uax * px + uay * py, ea = max(0, da - L); + const M = ea * ca, K = (da > L ? ca * ca : 0) + ea * ((1 - ca * ca) / da - sa); + const ux = cos(be), uy = sin(be), c = ux * tx + uy * ty, s = ux * px + uy * py; + if (abs(c) < 1e-9) return null; + const e = -M / c; + if (e <= 0) return null; + const d = L + e; + return [K + c * c + e * ((1 - c * c) / d - s), px - d * ux, py - d * uy, d]; +} +function fold(S, L) { + S.L = L; + S.nf = 0; + if (L < 0.02) return; + let guess = -1; + for (let i = 0; i < NF; i++) { + const t = (2 * PI * i) / NF; + let found = null; + // Look near the last direction first, then all the way round. + const tries = guess < 0 ? [[0, 2 * PI, 1440]] : [[guess - 0.25, guess + 0.25, 50], [0, 2 * PI, 1440]]; + for (const [b0, b1, k] of tries) { + let prev = foldAt(t, L, b0), pb = b0; + for (let j = 1; j <= k && !found; j++) { + const be = b0 + ((b1 - b0) * j) / k, cur = foldAt(t, L, be); + if (prev && cur && prev[0] * cur[0] <= 0 && abs(cur[3] - prev[3]) < 0.3 && cur[3] < 8) { + let lo = pb, hi = be, flo = prev[0]; + for (let q = 0; q < 40; q++) { + const mid = (lo + hi) / 2, m = foldAt(t, L, mid); + if (!m) break; + if (m[0] * flo > 0) (lo = mid), (flo = m[0]); + else hi = mid; + } + found = foldAt(t, L, (lo + hi) / 2); + guess = (lo + hi) / 2; + } + prev = cur; + pb = be; + } + if (found) break; + } + if (found) { + S.FX[S.nf] = found[1]; + S.FY[S.nf] = found[2]; + S.nf++; + } + } + let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; + for (let i = 0; i < S.nf; i++) { + x0 = min(x0, S.FX[i]); + x1 = max(x1, S.FX[i]); + y0 = min(y0, S.FY[i]); + y1 = max(y1, S.FY[i]); + } + S.fx0 = x0; + S.fx1 = x1; + S.fy0 = y0; + S.fy1 = y1; + // The side cusps are the leftmost and rightmost points. + let iL = 0; + for (let i = 0; i < S.nf; i++) if (S.FX[i] < S.FX[iL]) iL = i; + S.ySide = S.nf ? S.FY[iL] : -2.5; +} + +// Local minima of V over the rim, from 180 samples, each polished by a few +// steps of descent. Returns how many; their angles land in S.mins. +function minima(S, bx, by) { + const L = S.L, E = S.E; + for (let i = 0; i < NT; i++) E[i] = energy((2 * PI * i) / NT, L, bx, by); + let n = 0; + for (let i = 0; i < NT && n < 4; i++) { + const a = E[(i + NT - 1) % NT], b = E[(i + 1) % NT]; + if (E[i] < a && E[i] <= b) { + let t = (2 * PI * i) / NT; + for (let k = 0; k < 30; k++) t -= 0.5 * torque(t, L, bx, by); + S.mins[n++] = t; + } + } + return n; +} + +// Follow the local minimum the disc sits in: the nearest place within half a +// radian where V' turns from negative to positive, refined by bisection. +// When there is none, that minimum has gone, which is the snap. +function track(S, t) { + const L = S.L, bx = S.bx, by = S.by; + let best = NaN, bd = 1e9, prev = torque(t - 0.5, L, bx, by); + for (let i = 1; i <= 96; i++) { + const a = t - 0.5 + i / 96, cur = torque(a, L, bx, by); + if (prev < 0 && cur >= 0) { + let lo = a - 1 / 96, hi = a; + for (let q = 0; q < 30; q++) { + const m = (lo + hi) / 2; + if (torque(m, L, bx, by) < 0) lo = m; + else hi = m; + } + const r = (lo + hi) / 2; + if (abs(r - t) < bd) (bd = abs(r - t)), (best = r); + } + prev = cur; + } + return best; +} + +function setup(params) { + params.s = { + t: 0, w: 0, L: -1, nf: 0, FX: new Float64Array(NF), FY: new Float64Array(NF), fx0: 0, fx1: 0, fy0: 0, fy1: 0, ySide: -2.4, + E: new Float64Array(NT), mins: new Float64Array(4), nm: 1, ghost: 0, ghostA: 0, + ph: -PI / 2, wph: 0, bx: -1.35, by: -2.4, depth: 0.06, hue: 0, speed: 2, + TX: new Float32Array(TRAIL), TY: new Float32Array(TRAIL), TB: new Float32Array(TRAIL), tn: 0, th: 0, tacc: 0, + SX: new Float32Array(SNAPS), SY: new Float32Array(SNAPS), SA: new Float32Array(SNAPS).fill(99), sh: 0, + tm: 0, flash: 0, fbx: 0, fby: 0, warm: false, + R: new Float32Array(N), G: new Float32Array(N), B: new Float32Array(N), cov: new Float32Array(N), + xs: new Float32Array(4 * H * 16), xn: new Int32Array(4 * H), col: new Float32Array(24), + }; +} + +// B's path: across the panel and back, 1.35 radii either side of the axis, +// at the height Depth picks between the side cusps and the bottom cusp, +// wandering up and down a quarter of that height. From Depth 0.8 on it +// never enters the diamond. +function place(S) { + const ys = S.nf ? S.ySide : -2.5, yb = S.nf ? S.fy0 : -2.6; + S.bx = 1.35 * sin(S.ph); + S.by = ys + (0.3 + 1.2 * S.depth + 0.25 * sin(S.wph)) * (yb - ys); +} + +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.depth = cl(k[3], 0, 1); + const L = L0 * (1 - cl(k[2], 0, 1)); + if (abs(L - S.L) > 0.002) fold(S, L); + const beat = (d * S.speed) / 2; + const jump = !!(input.btnPressed && input.btnPressed[0]); + if (jump) S.ph = -S.ph; + // A crossing of the panel every 4 beats; the wander is slower. + S.ph += (beat * PI) / 4; + S.wph += beat * 0.53; + place(S); + S.nm = minima(S, S.bx, S.by); + if (!S.warm) { + // Start in the deepest minimum. + let best = 0; + for (let i = 1; i < S.nm; i++) if (energy(S.mins[i], S.L, S.bx, S.by) < energy(S.mins[best], S.L, S.bx, S.by)) best = i; + S.t = S.tm = S.mins[best]; + S.warm = true; + } + // The snap: the minimum the disc was in is gone. B is then at most one + // frame's travel past the edge of the diamond, and the mark goes there, + // unless K1 threw B across. + const tm = track(S, S.tm); + if (tm === tm) S.tm = tm; + else { + let far = 0, fd = -1; + for (let i = 0; i < S.nm; i++) { + const dd = abs(atan2(sin(S.mins[i] - S.tm), cos(S.mins[i] - S.tm))); + if (dd > fd) (fd = dd), (far = i); + } + S.tm = S.mins[far]; + S.flash = 1; + S.fbx = S.bx; + S.fby = S.by; + if (!jump) { + S.SX[S.sh] = S.bx; + S.SY[S.sh] = S.by; + S.SA[S.sh] = 0; + S.sh = (S.sh + 1) % SNAPS; + } + } + // Overdamped: the disc turns at 30 times the torque per beat, in eight + // small steps a frame. + const h = beat / 8, t0 = S.t; + for (let i = 0; i < 8; i++) S.t -= 30 * h * torque(S.t, S.L, S.bx, S.by); + S.w = beat > 0 ? abs(S.t - t0) / beat : S.w; + S.flash = max(0, S.flash - beat / 0.5); + for (let i = 0; i < SNAPS; i++) S.SA[i] += beat; + // The other minimum, when there is one, shows as a ghost on the rim. + let gi = -1, gd = 0; + for (let i = 0; i < S.nm; i++) { + const dd = abs(atan2(sin(S.mins[i] - S.t), cos(S.mins[i] - S.t))); + if (dd > 0.3 && dd > gd) (gd = dd), (gi = i); + } + if (gi >= 0) S.ghost = S.mins[gi]; + S.ghostA += ((gi >= 0 ? 1 : 0) - S.ghostA) * min(1, beat * 8); + // The trail: 15 samples a beat, 12 beats long, each with the side of the + // disc P was on. + S.tacc += beat * 15; + while (S.tacc >= 1) { + S.tacc -= 1; + S.TX[S.th] = S.bx; + S.TY[S.th] = S.by; + S.TB[S.th] = cos(S.t); + S.th = (S.th + 1) % TRAIL; + S.tn = min(TRAIL, S.tn + 1); + } +} + +// ---- Drawing ---- + +function plot(S, x, y, r, g, b) { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const p = y * W + x; + S.R[p] = max(S.R[p], r); + S.G[p] = max(S.G[p], g); + S.B[p] = max(S.B[p], b); +} + +// A line in panel coordinates, about 1.4 LEDs wide, kept at the brighter. +function line(S, ax, ay, bx, by, cr, cg, cb, wide) { + const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy || 1e-9; + const x0 = max(0, floor(min(ax, bx) - 1.5)), x1 = min(W - 1, floor(max(ax, bx) + 1.5)); + const y0 = max(0, floor(min(ay, by) - 1.5)), y1 = min(H - 1, floor(max(ay, by) + 1.5)); + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 - ax, py = y + 0.5 - ay, t = cl((px * dx + py * dy) / l2, 0, 1); + const qx = px - t * dx, qy = py - t * dy, v = cl((wide - sqrt(qx * qx + qy * qy)) / 0.55, 0, 1); + if (v > 0) plot(S, x, y, cr * v, cg * v, cb * v); + } +} + +function disc(S, cx, cy, r, cr, cg, cb, add) { + for (let y = max(0, floor(cy - r - 1)); y <= min(H - 1, floor(cy + r + 1)); y++) + for (let x = max(0, floor(cx - r - 1)); x <= min(W - 1, floor(cx + r + 1)); x++) { + const v = cl(r + 0.5 - sqrt((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2), 0, 1); + if (v <= 0) continue; + const p = y * W + x; + if (add) (S.R[p] += cr * v), (S.G[p] += cg * v), (S.B[p] += cb * v); + else (S.R[p] = S.R[p] * (1 - v) + cr * v), (S.G[p] = S.G[p] * (1 - v) + cg * v), (S.B[p] = S.B[p] * (1 - v) + cb * v); + } +} + +// Coverage of the inside of the diamond: four scanlines a row. +function fill(S) { + const { xs, xn, cov, FX, FY } = S, n = S.nf, SUB = 4, CAP = 16; + cov.fill(0); + xn.fill(0); + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + let x0 = toX(FX[i]), y0 = toY(FY[i]), x1 = toX(FX[j]), y1 = toY(FY[j]); + if (y0 === y1) continue; + if (y0 > y1) [x0, y0, x1, y1] = [x1, y1, x0, y0]; + const s0 = max(0, ceil(y0 * SUB - 0.5)), s1 = min(H * SUB - 1, ceil(y1 * SUB - 0.5) - 1); + for (let s = s0; s <= s1; s++) { + const c = xn[s]; + if (c < CAP) (xs[s * CAP + c] = x0 + (((s + 0.5) / SUB - y0) * (x1 - x0)) / (y1 - y0)), (xn[s] = c + 1); + } + } + for (let s = 0; s < H * SUB; s++) { + const c = xn[s], o = s * CAP, row = floor(s / SUB) * W; + if (c < 2) continue; + for (let a = 1; a < c; a++) { + const v = xs[o + a]; + let b = a - 1; + while (b >= 0 && xs[o + b] > v) (xs[o + b + 1] = xs[o + b]), b--; + xs[o + b + 1] = v; + } + for (let a = 0; a + 1 < c; a += 2) { + const xa = cl(xs[o + a], 0, W), xb = cl(xs[o + a + 1], 0, W); + if (xb <= xa) continue; + const ia = floor(xa), ib = min(W - 1, floor(xb)); + if (ia === ib) cov[row + ia] += (xb - xa) / SUB; + else { + cov[row + ia] += (ia + 1 - xa) / SUB; + for (let x = ia + 1; x < ib; x++) cov[row + x] += 1 / SUB; + if (ib < W) cov[row + ib] += (xb - ib) / SUB; + } + } + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { R, G, B, cov, col } = S; + R.fill(0); + G.fill(0); + B.fill(0); + const h = S.hue + 0.78, fl = S.flash; + // Round yellow the diamond's shading would go olive, so there it is paler + // and brighter. + const hh = h - floor(h), warm = sm(1 - abs(hh - 0.16) / 0.12); + hsv(h, 0.75 - 0.5 * warm, 1, col, 0); // accent: the diamond and the anchor + hsv(h + 0.35, 0.85, 1, col, 3); // the trail while P is on the left + hsv(h + 0.72, 0.8, 1, col, 6); // the trail while P is on the right + const hd = h + 0.8 - floor(h + 0.8), warmD = sm(1 - abs(hd - 0.12) / 0.13); + hsv(h + 0.8, 0.6 - 0.3 * warmD, 1 + 0.6 * warmD, col, 9); // the disc, never brown + const shade = 66 + 74 * warm; + // The bistable region, softly lit, and its cusped edge. + if (S.nf > 2) { + fill(S); + for (let p = 0; p < N; p++) { + const c = min(1, cov[p]) * shade; + if (c > 0) (R[p] += col[0] * c), (G[p] += col[1] * c), (B[p] += col[2] * c); + } + } + // The trail, oldest first, in the colour of P's side. + for (let k = S.tn - 1; k >= 2; k--) { + const i = (S.th - k + TRAIL) % TRAIL, j = (i + 1) % TRAIL, age = k / TRAIL; + const u = (1 + cl(S.TB[i] * 2, -1, 1)) / 2, v = 230 * (1 - age) * (1 - age) + 25; + line(S, toX(S.TX[i]), toY(S.TY[i]), toX(S.TX[j]), toY(S.TY[j]), v * (col[3] * (1 - u) + col[6] * u), v * (col[4] * (1 - u) + col[7] * u), v * (col[5] * (1 - u) + col[8] * u), 0.75); + } + if (S.nf > 2) { + const e = 200 + 55 * fl; + for (let i = 0; i < S.nf; i++) { + const j = (i + 1) % S.nf; + line(S, toX(S.FX[i]), toY(S.FY[i]), toX(S.FX[j]), toY(S.FY[j]), col[0] * e, col[1] * e, col[2] * e, 0.7); + } + } + // Where the snaps happened: white marks that fade over 25 beats. + for (let i = 0; i < SNAPS; i++) { + if (S.SA[i] > 25) continue; + const v = 255 * (1 - S.SA[i] / 25); + disc(S, toX(S.SX[i]), toY(S.SY[i]), 0.9, v, v, v, false); + } + // The disc: a face of six soft sectors that turns with it, so a snap shows + // as the whole wheel whirling round; a rim that flashes white; the pivot. + const ox = toX(0), oy = toY(0), px = cos(S.t), py = sin(S.t), spin = 1 + 1.6 * cl(S.w / 8, 0, 1); + for (let y = max(0, floor(oy - SC - 1)); y <= min(H - 1, floor(oy + SC + 1)); y++) + for (let x = max(0, floor(ox - SC - 1)); x <= min(W - 1, floor(ox + SC + 1)); x++) { + const dx = x + 0.5 - ox, dy = oy - y - 0.5, r = sqrt(dx * dx + dy * dy), e = cl(SC + 0.5 - r, 0, 1); + if (e <= 0) continue; + const a = atan2(dy, dx) - S.t, v = e * spin * (48 + 30 * cos(6 * a) * cl(r / 4, 0, 1)), p = y * W + x; + R[p] = col[9] * v; + G[p] = col[10] * v; + B[p] = col[11] * v; + } + for (let i = 0; i < 96; i++) { + const a0 = (2 * PI * i) / 96, a1 = (2 * PI * (i + 1)) / 96, e = 120 + 135 * fl; + line(S, ox + SC * cos(a0), oy - SC * sin(a0), ox + SC * cos(a1), oy - SC * sin(a1), col[9] * e + 255 * fl * 0.5, col[10] * e + 255 * fl * 0.5, col[11] * e + 255 * fl * 0.5, 0.7); + } + line(S, ox, oy, toX(px), toY(py), col[9] * 110, col[10] * 110, col[11] * 110, 0.6); + disc(S, ox, oy, 1.3, 200, 200, 200, false); + // The other stable angle, while B is inside the diamond. + if (S.ghostA > 0.02) { + const g = 150 * S.ghostA, u = cos(S.ghost) > 0 ? 1 : 0; + disc(S, toX(cos(S.ghost)), toY(sin(S.ghost)), 1.4, g * (col[3] * (1 - u) + col[6] * u), g * (col[4] * (1 - u) + col[7] * u), g * (col[5] * (1 - u) + col[8] * u), false); + } + // The bands, brighter the harder they pull. + const ta = max(0, sqrt(px * px + (py - AD) ** 2) - S.L), tb = max(0, sqrt((px - S.bx) ** 2 + (py - S.by) ** 2) - S.L); + const va = 70 + 185 * cl(ta / 2.6, 0, 1), vb = 70 + 185 * cl(tb / 2.6, 0, 1) + 60 * fl; + line(S, toX(0), toY(AD), toX(px), toY(py), va, va * 0.93, va * 0.82, 0.75); + line(S, toX(px), toY(py), toX(S.bx), toY(S.by), vb, vb * 0.93, vb * 0.82, 0.75); + // The anchor, P on the rim, and B. + disc(S, toX(0), toY(AD), 1.6, col[0] * 255, col[1] * 255, col[2] * 255, false); + const u = (1 + cl(px * 2, -1, 1)) / 2; + disc(S, toX(px), toY(py), 2.3, 255 * (col[3] * (1 - u) + col[6] * u), 255 * (col[4] * (1 - u) + col[7] * u), 255 * (col[5] * (1 - u) + col[8] * u), false); + disc(S, toX(px), toY(py), 1, 255, 255, 255, false); + disc(S, toX(S.bx), toY(S.by), 1.5, 255, 255, 255, false); + // A ring opening round B at each snap. + if (fl > 0) { + const r = 2 + 9 * (1 - fl * fl), v = 255 * sm(fl * 1.6); + for (let i = 0; i < 40; i++) { + const a0 = (2 * PI * i) / 40, a1 = (2 * PI * (i + 1)) / 40; + line(S, toX(S.fbx) + r * cos(a0), toY(S.fby) + r * sin(a0), toX(S.fbx) + r * cos(a1), toY(S.fby) + r * sin(a1), v, v, v, 0.95); + } + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + display.setPixel(x, y, round(min(255, R[p])), round(min(255, G[p])), round(min(255, B[p]))); + } +} diff --git a/incoming/pattern-study/patterns/curve-shortening.js b/incoming/pattern-study/patterns/curve-shortening.js new file mode 100644 index 000000000..1e1601964 --- /dev/null +++ b/incoming/pattern-study/patterns/curve-shortening.js @@ -0,0 +1,564 @@ +// ===== Patternflow pattern ===== +// Title: Curve Shortening +// Family: line +// Idea: One tangled closed curve at a time, a thick spiral, a folded worm, a many-armed star or a double coil, moves every point along its normal at the speed of its curvature, so it unwinds without ever touching itself, rounds off into a circle and shrinks to a point, losing area at exactly 2 pi per unit of flow time, while the white marks at its inflection points meet and die in pairs. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: curve-shortening flow, Grayson's theorem (Matthew Grayson, 1987), pattern round 36 +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Wind=2..3.5, Trace=0..1 +// +// Knob 1: Hue. The colour of the filled curve, once round the wheel. At 0 the fill is azure, with a pale sky rim where the curve bends outward and a gold rim where it bends inward; at 0.2 violet, at 0.3 pink, at 0.4 red, at 0.5 orange, at 0.6 yellow, at 0.75 green, at 0.9 teal. The two rim colours turn with it. +// Knob 2: Speed. At 2 the first curve is drawn round and filled in 1.9 seconds, later ones in 0.95, and each then flows to its point in 5.6 seconds whatever its size, since the area falls at the same rate the whole way; a white ring opens at the point for 0.6 seconds and the next curve starts. At 0.2 all of it takes ten times as long, at 3 two thirds as long. +// Knob 3: Wind. How tangled each new curve is. At 2 the spiral makes 2 turns of a band 6 LEDs wide with 7-LED gaps, the worm folds into 6 runs, the star has 6 arms and the double coil 2 turns each way; at 2.75 the spiral makes 2.75 turns, the worm 8 runs, the star 8 arms and the coils 3 turns; at 3.5 the spiral makes 3.5 turns of a band under 4 LEDs wide with 4-LED gaps, the worm 9 runs, the star 9 arms. Turning it more than 0.2 starts a fresh curve. +// Knob 4: Trace. At 0 the inside is filled to 0.9 of full brightness. Turning it up dims the fill, to a fifth at 1, and keeps the outline as it was at each tenth of the curve's life, so each kept outline holds a tenth of the first area less than the one before. At 1 they nest behind the curve: far apart at the tips of the arms, where the curvature is high and the flow fast, and crossing where a concave arc has pushed outward past an older outline. +// Buttons: K1 tap runs the rest of the flow in 0.6 seconds (at Speed 2), so the curve races to its round point, and the next curve follows. + +const W = 64, H = 128, NMAX = 1500, NMIN = 28, SNAPS = 10, SNAPN = 180; +// Flow time is in LEDs squared: the area falls 2 pi of them per unit. A curve +// lives LIFE beats of the clock; a beat is a second at Speed 2. +const LIFE = 5.6, DRAW0 = 1.9, DRAW = 0.95, FLASH = 0.6, DS = 1.3; +const { floor, min, max, abs, sqrt, sin, cos, atan2, round, random, PI, ceil } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (v) => ((v = cl(v, 0, 1)), v * v * (3 - 2 * v)); + +function hsv(h, s, v, out, o) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[o] = i === 0 || i === 5 ? v : i === 1 ? q : i === 4 ? t : p; + out[o + 1] = i === 1 || i === 2 ? v : i === 0 ? t : i === 3 ? q : p; + out[o + 2] = i === 3 || i === 4 ? v : i === 2 ? t : i === 5 ? q : p; +} + +// ---- Shapes. Each is a centre line with a band width, or a polygon. ---- + +function seg(out, x0, y0, x1, y1) { + const k = max(1, ceil(sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) / 0.5)); + for (let i = 0; i < k; i++) out.push(x0 + ((x1 - x0) * i) / k, y0 + ((y1 - y0) * i) / k); +} + +// A spiral on a stadium: half turns over the top and under the bottom, 2h +// apart, joined by straight runs. Each half turn is a true semicircle, the +// top ones centred on cx with radius r0 + k p, the bottom ones half a pitch +// to the right with radius r0 + (k + 1/2) p, so the arcs meet the runs with +// no corner and the band gains p every turn. It uses the tall panel the way +// a round spiral cannot. +function spiral(T) { + const p = 57 / (2 * T + 0.2), w = 0.45 * p, r0 = w / 2 + 1.5, h = 30, cx = 32, cy = 64, C = []; + const halves = 2 * T; + seg(C, cx + r0, cy + h, cx + r0, cy - h); + for (let k = 0; k < halves; k++) { + const top = k % 2 === 0, R = r0 + (k * p) / 2, xc = top ? cx : cx + p / 2, yc = top ? cy - h : cy + h; + const f1 = min(PI, (halves - k) * PI); + for (let f = 0; f < f1; f += 0.6 / R) C.push(xc + (top ? 1 : -1) * R * cos(f), yc + (top ? -1 : 1) * R * sin(f)); + const x = xc + (top ? 1 : -1) * R * cos(f1); + if (f1 < PI) C.push(x, yc + (top ? -1 : 1) * R * sin(f1)); + else seg(C, x, yc, x, top ? cy + h : cy - h); + } + return { C, w }; +} + +// Two round spirals, one over the other, joined through the middle of the +// panel as an S: the outer end of the upper one meets its mirror image there. +function coils(T) { + const k = T < 2.5 ? 2 : 3, p = 28.5 / (k + 0.55), w = 0.62 * p, r0 = 0.3 * p + 1.5, F = 2 * PI * k + PI / 2; + const Rm = r0 + (p * F) / (2 * PI), cyA = 64 - Rm, A = []; + for (let f = 0; f < F; f += 0.03) { + const r = r0 + (p * f) / (2 * PI); + A.push(32 + r * cos(f), cyA + r * sin(f)); + } + const C = A.slice(); + C.push(32, 64); + for (let i = A.length - 2; i >= 0; i -= 2) C.push(64 - A[i], 128 - A[i + 1]); + return { C, w }; +} + +// A worm folded into m runs across the panel, joined by hairpin bends. +function worm(T) { + const m = 2 + round(2 * T), p = 118 / m, w = 0.6 * p, xl = 3 + p / 2 + w / 2, xr = 61 - p / 2 - w / 2, C = []; + for (let j = 0; j < m; j++) { + const y = 5 + p / 2 + j * p, right = j % 2 === 0; + seg(C, right ? xl : xr, y, right ? xr : xl, y); + if (j === m - 1) { + C.push(right ? xr : xl, y); + break; + } + const xc = right ? xr : xl, yc = y + p / 2; + for (let a = 0; a < PI; a += 0.06) C.push(xc + (right ? 1 : -1) * (p / 2) * sin(a), yc - (p / 2) * cos(a)); + } + return { C, w }; +} + +// A star: r(t) = R (0.34 + 0.66 g(k t)), one arm per lobe of g, stretched +// 1.45 times up the panel. Being a graph over the angle, before and after the +// stretch, it cannot cross itself. +function star(T) { + const k = 2 + round(2 * T), R = 29.5, P = []; + for (let i = 0; i < 2400; i++) { + const t = (2 * PI * i) / 2400, g = ((1 + cos(k * t)) / 2) ** 1.7, r = R * (0.34 + 0.66 * g); + P.push(32 + r * sin(t), 64 - 1.45 * r * cos(t)); + } + return P; +} + +// The outline of a band of width w round a centre line, with round ends. +function band(C, w) { + const m = C.length >> 1, L = [], Rt = [], hw = w / 2; + const nrm = (i) => { + const a = max(0, i - 1), b = min(m - 1, i + 1); + let tx = C[2 * b] - C[2 * a], ty = C[2 * b + 1] - C[2 * a + 1]; + const l = sqrt(tx * tx + ty * ty) || 1; + return [tx / l, ty / l]; + }; + for (let i = 0; i < m; i++) { + const [tx, ty] = nrm(i); + L.push(C[2 * i] - ty * hw, C[2 * i + 1] + tx * hw); + Rt.push(C[2 * i] + ty * hw, C[2 * i + 1] - tx * hw); + } + const P = L.slice(); + const cap = (i, s) => { + const [tx, ty] = nrm(i), nx = -ty, ny = tx; + for (let a = 0.12; a < PI; a += 0.12) { + const c = cos(a), d = sin(a); + P.push(C[2 * i] + s * hw * (c * nx + d * tx), C[2 * i + 1] + s * hw * (c * ny + d * ty)); + } + }; + cap(m - 1, 1); + for (let i = m - 1; i >= 0; i--) P.push(Rt[2 * i], Rt[2 * i + 1]); + cap(0, -1); + return P; +} + +function area(X, Y, n) { + let a = 0; + for (let i = 0, j = n - 1; i < n; j = i++) a += X[j] * Y[i] - X[i] * Y[j]; + return a / 2; +} + +// Put n points round the closed polygon P at equal steps of arc length, +// wound so that the area comes out positive. +function resample(P, np, X, Y, n) { + const m = np, len = new Float64Array(m + 1); + for (let i = 0; i < m; i++) { + const j = (i + 1) % m; + len[i + 1] = len[i] + sqrt((P[2 * j] - P[2 * i]) ** 2 + (P[2 * j + 1] - P[2 * i + 1]) ** 2); + } + const L = len[m]; + let s = 0; + for (let k = 0; k < n; k++) { + const target = (L * k) / n; + while (s < m - 1 && len[s + 1] < target) s++; + const j = (s + 1) % m, u = (target - len[s]) / (len[s + 1] - len[s] || 1); + X[k] = P[2 * s] + u * (P[2 * j] - P[2 * s]); + Y[k] = P[2 * s + 1] + u * (P[2 * j + 1] - P[2 * s + 1]); + } + if (area(X, Y, n) < 0) + for (let a = 0, b = n - 1; a < b; a++, b--) { + let t = X[a]; + X[a] = X[b]; + X[b] = t; + t = Y[a]; + Y[a] = Y[b]; + Y[b] = t; + } + return L; +} + +function perimeter(X, Y, n) { + let L = 0; + for (let i = 0, j = n - 1; i < n; j = i++) L += sqrt((X[i] - X[j]) ** 2 + (Y[i] - Y[j]) ** 2); + return L; +} + +// A new curve: the spiral first, then any of the four but the one just +// shown, at 70 to 100% size somewhere on the panel, mirrored at random. +function build(S, wind, first) { + let kind = 0; + if (!first) { + kind = floor(random() * 4); + if (kind === S.kind) kind = (kind + 1 + floor(random() * 3)) % 4; + } + S.kind = kind; + S.wind = wind; + let P; + if (kind === 1) P = star(wind); + else { + const { C, w } = kind === 0 ? spiral(wind) : kind === 2 ? worm(wind) : coils(wind); + P = band(C, w); + } + const sc = first ? 1 : 0.7 + 0.3 * random(), fx = random() < 0.5 ? -1 : 1, fy = random() < 0.5 ? -1 : 1; + let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; + for (let i = 0; i < P.length; i += 2) { + P[i] = 32 + fx * sc * (P[i] - 32); + P[i + 1] = 64 + fy * sc * (P[i + 1] - 64); + x0 = min(x0, P[i]); + x1 = max(x1, P[i]); + y0 = min(y0, P[i + 1]); + y1 = max(y1, P[i + 1]); + } + // The first curve stands in the middle; later ones slide anywhere they + // still fit between 1.5 and 62.5 across, 1.5 and 126.5 down. + const dx = first ? 32 - (x0 + x1) / 2 : 1.5 - x0 + random() * max(0, 61 - (x1 - x0)); + const dy = first ? 64 - (y0 + y1) / 2 : 1.5 - y0 + random() * max(0, 125 - (y1 - y0)); + for (let i = 0; i < P.length; i += 2) (P[i] += dx), (P[i + 1] += dy); + const np = P.length >> 1; + let L = 0; + for (let i = 0; i < np; i++) { + const j = (i + 1) % np; + L += sqrt((P[2 * j] - P[2 * i]) ** 2 + (P[2 * j + 1] - P[2 * i + 1]) ** 2); + } + S.n = min(NMAX, max(NMIN, round(L / DS))); + resample(P, np, S.X, S.Y, S.n); + S.A0 = area(S.X, S.Y, S.n); + S.flowT = 0; + S.phase = 0; + S.pt = 0; + S.dur = first ? DRAW0 : DRAW; + S.ns = 0; + S.rush = 0; + curvature(S); +} + +// One explicit step of the flow. Vertex i moves along the normal of the +// chord from i-1 to i+1 by 2 phi / |chord| per unit time, phi the turning +// angle at i: that is the curvature, and it makes the area fall by exactly +// phi, so the whole polygon loses sum(phi) = 2 pi per unit time. A small +// slide along the same chord evens out the spacing and leaves the area alone. +// The step is at most 0.3 of the square of the shortest half chord, under the +// 0.5 where the explicit scheme starts to ring. Returns the time it took. +function step(S, todo) { + const { X, Y, VX, VY, TX, TY } = S, n = S.n; + let mc = 1e9, ml = 1e9, Ml = 0, L = 0; + for (let i = 0; i < n; i++) { + const a = i ? i - 1 : n - 1, b = i + 1 < n ? i + 1 : 0; + const ex = X[i] - X[a], ey = Y[i] - Y[a], fx = X[b] - X[i], fy = Y[b] - Y[i]; + const phi = atan2(ex * fy - ey * fx, ex * fx + ey * fy); + const cx = X[b] - X[a], cy = Y[b] - Y[a], c2 = cx * cx + cy * cy || 1e-12; + const l1 = sqrt(ex * ex + ey * ey), l2 = sqrt(fx * fx + fy * fy), g = (0.3 * (l2 - l1)) / sqrt(c2); + VX[i] = (-2 * phi * cy) / c2; + VY[i] = (2 * phi * cx) / c2; + TX[i] = g * cx; + TY[i] = g * cy; + if (c2 < mc) mc = c2; + if (l2 < ml) ml = l2; + if (l2 > Ml) Ml = l2; + L += l2; + } + const h = min(todo, 0.075 * mc); + for (let i = 0; i < n; i++) { + X[i] += h * VX[i] + TX[i]; + Y[i] += h * VY[i] + TY[i]; + } + S.ml = ml; + S.Ml = Ml; + S.L = L; + return h; +} + +// Fresh points at even spacing. Points laid on the old edges cut the corners +// and lose a little area, so every point then steps out along its normal by +// the area lost over half the sum of the chords, which puts it back to first +// order: a remesh must not move the area clock. When the curve has shrunk +// there are fewer points, the spacing near DS until the curve is down to NMIN +// points; edges squeezed under 0.45 of the spacing at a retracting tip, or +// stretched past 1.8 along an arc pushing outward, force one. +function remesh(S, force) { + const { X, Y } = S, L = perimeter(X, Y, S.n), want = max(NMIN, min(NMAX, round(L / DS))); + if (!force && want >= 0.8 * S.n && want <= 1.25 * S.n) return; + const A = area(X, Y, S.n), P = S.P, np = S.n; + for (let i = 0; i < np; i++) (P[2 * i] = X[i]), (P[2 * i + 1] = Y[i]); + S.n = want; + resample(P, np, X, Y, want); + const n = want, VX = S.VX, VY = S.VY; + let half = 0; + for (let i = 0; i < n; i++) { + const a = i ? i - 1 : n - 1, b = i + 1 < n ? i + 1 : 0, cx = X[b] - X[a], cy = Y[b] - Y[a], c = sqrt(cx * cx + cy * cy) || 1e-9; + VX[i] = cy / c; + VY[i] = -cx / c; + half += c / 2; + } + const off = (A - area(X, Y, n)) / half; + for (let i = 0; i < n; i++) (X[i] += off * VX[i]), (Y[i] += off * VY[i]); + S.ml = S.Ml = L / n; +} + +// Signed curvature, summed over seven neighbours so that straight runs read +// as zero, and the inflection points: where a run of clearly convex points +// meets a run of clearly concave ones. Runs that never pass 0.06 radians of +// turning do not count, so a nearly straight stretch does not flicker. +function curvature(S) { + const { X, Y, K, PH } = S, n = S.n; + for (let i = 0; i < n; i++) { + const a = i ? i - 1 : n - 1, b = i + 1 < n ? i + 1 : 0; + const ex = X[i] - X[a], ey = Y[i] - Y[a], fx = X[b] - X[i], fy = Y[b] - Y[i]; + PH[i] = atan2(ex * fy - ey * fx, ex * fx + ey * fy); + } + let s = 0; + for (let j = -3; j <= 3; j++) s += PH[(j + n) % n]; + for (let i = 0; i < n; i++) { + K[i] = s; + s += PH[(i + 4) % n] - PH[(i - 3 + n) % n]; + } + // Runs of one sign, starting where the sign changes. + const sg = (v) => (v > 0.025 ? 1 : v < -0.025 ? -1 : 0); + let start = -1; + for (let i = 0; i < n; i++) + if (sg(K[i]) && sg(K[i]) !== sg(K[(i - 1 + n) % n])) { + start = i; + break; + } + S.ni = 0; + if (start < 0) return; + const runs = []; + let i = 0; + while (i < n) { + const p = (start + i) % n, v = sg(K[p]); + if (!v) { + i++; + continue; + } + let j = i, peak = 0; + while (j < n && sg(K[(start + j) % n]) === v) (peak = max(peak, abs(K[(start + j) % n]))), j++; + if (peak > 0.06) { + const last = runs[runs.length - 1]; + if (last && last.v === v) last.e = j - 1; + else runs.push({ v, b: i, e: j - 1 }); + } + i = j; + } + if (runs.length > 1 && runs[0].v === runs[runs.length - 1].v) { + runs[0].b = runs[runs.length - 1].b - n; + runs.pop(); + } + if (runs.length < 2) return; + for (let r = 0; r < runs.length && S.ni < 64; r++) { + const A = runs[r], B = runs[(r + 1) % runs.length]; + let e = A.e, b = B.b; + if (b <= e) b += n; + // The zero crossing between the end of one run and the start of the next. + let q = e; + for (let t = e; t < b; t++) if (K[(start + t) % n] * A.v > 0 && K[(start + t + 1) % n] * A.v <= 0) q = t; + const p0 = (start + q + n) % n, p1 = (p0 + 1) % n, k0 = K[p0], k1 = K[p1]; + const u = k0 !== k1 ? cl(k0 / (k0 - k1), 0, 1) : 0.5; + S.IX[S.ni] = X[p0] + u * (X[p1] - X[p0]); + S.IY[S.ni] = Y[p0] + u * (Y[p1] - Y[p0]); + S.IQ[S.ni++] = p0; + } +} + +function snapshot(S) { + if (S.ns >= SNAPS) return; + const k = max(1, ceil(S.n / SNAPN)), o = S.ns * SNAPN * 2; + let m = 0; + for (let i = 0; i < S.n && m < SNAPN; i += k, m++) (S.SN[o + 2 * m] = S.X[i]), (S.SN[o + 2 * m + 1] = S.Y[i]); + S.SM[S.ns++] = m; +} + +function setup(params) { + params.s = { + X: new Float64Array(NMAX), Y: new Float64Array(NMAX), VX: new Float64Array(NMAX), VY: new Float64Array(NMAX), + TX: new Float64Array(NMAX), TY: new Float64Array(NMAX), P: new Float64Array(2 * NMAX), ml: 1, Ml: 1, L: 1, + K: new Float64Array(NMAX), PH: new Float64Array(NMAX), IX: new Float64Array(64), IY: new Float64Array(64), IQ: new Int32Array(64), ni: 0, + SN: new Float64Array(SNAPS * SNAPN * 2), SM: new Int32Array(SNAPS), ns: 0, rush: 0, + n: 0, A0: 1, flowT: 0, phase: 0, pt: 0, dur: DRAW0, kind: -1, wind: 1.5, fx: 32, fy: 64, warm: false, + hue: 0, trace: 0.06, speed: 2, + R: new Float32Array(W * H), G: new Float32Array(W * H), B: new Float32Array(W * H), cov: new Float32Array(W * H), + xs: new Float32Array(4 * H * 48), xn: new Int32Array(4 * H), col: new Float32Array(12), + }; +} + +function finish(S) { + let cx = 0, cy = 0; + for (let i = 0; i < S.n; i++) (cx += S.X[i]), (cy += S.Y[i]); + S.fx = cx / S.n; + S.fy = cy / S.n; + S.phase = 2; + S.pt = 0; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 2, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.trace = cl(k[3], 0, 1); + const wind = cl(k[2], 2, 3.5); + if (!S.warm) { + build(S, wind, true); + S.warm = true; + } else if (abs(wind - S.wind) > 0.2 && S.phase !== 2) build(S, wind, false); + // K1: the rest of the flow in 0.6 beats, at a rate fixed when pressed. + if (input.btnPressed && input.btnPressed[0] && S.phase !== 2) { + if (S.phase === 0) (S.phase = 1), (S.pt = 0), snapshot(S); + S.rush = area(S.X, S.Y, S.n) / (2 * PI * 0.6); + } + const beat = (d * S.speed) / 2; + S.pt += beat; + if (S.phase === 0 && S.pt >= S.dur) { + S.phase = 1; + S.pt = 0; + snapshot(S); + } + if (S.phase === 1) { + // The area clock: A0 / 2 pi units of flow time spread over LIFE beats. + let todo = beat * max(S.A0 / (2 * PI * LIFE), S.rush), guard = 0; + while (todo > 1e-9 && guard++ < 400) { + const h = step(S, todo), ds = S.L / S.n; + todo -= h; + S.flowT += h; + if (S.ml < 0.45 * ds || S.Ml > 1.8 * ds) remesh(S, true); + else if (guard % 10 === 0) remesh(S, false); + if (guard % 4 === 0 && area(S.X, S.Y, S.n) < 1.5) break; + } + remesh(S, false); + while (S.ns < SNAPS && S.flowT >= (S.ns * S.A0) / (2 * PI * SNAPS)) snapshot(S); + if (area(S.X, S.Y, S.n) < 1.5) finish(S); + } + if (S.phase !== 2) curvature(S); + else if (S.pt >= FLASH) build(S, wind, false); +} + +// Coverage of the inside, anti-aliased: four scanlines per row, each +// crossing interval added to the LEDs it spans in exact fractions. +function fill(S) { + const { X, Y, xs, xn, cov } = S, n = S.n, SUB = 4, CAP = 48; + cov.fill(0); + xn.fill(0); + for (let i = 0; i < n; i++) { + const j = i + 1 < n ? i + 1 : 0; + let x0 = X[i], y0 = Y[i], x1 = X[j], y1 = Y[j]; + if (y0 === y1) continue; + if (y0 > y1) [x0, y0, x1, y1] = [x1, y1, x0, y0]; + const s0 = max(0, ceil(y0 * SUB - 0.5)), s1 = min(H * SUB - 1, ceil(y1 * SUB - 0.5) - 1); + for (let s = s0; s <= s1; s++) { + const ys = (s + 0.5) / SUB, c = xn[s]; + if (c < CAP) { + xs[s * CAP + c] = x0 + ((ys - y0) * (x1 - x0)) / (y1 - y0); + xn[s] = c + 1; + } + } + } + for (let s = 0; s < H * SUB; s++) { + const c = xn[s], o = s * CAP, row = floor(s / SUB) * W; + if (c < 2) continue; + for (let a = 1; a < c; a++) { + const v = xs[o + a]; + let b = a - 1; + while (b >= 0 && xs[o + b] > v) (xs[o + b + 1] = xs[o + b]), b--; + xs[o + b + 1] = v; + } + for (let a = 0; a + 1 < c; a += 2) { + const xa = cl(xs[o + a], 0, W), xb = cl(xs[o + a + 1], 0, W); + if (xb <= xa) continue; + const ia = floor(xa), ib = min(W - 1, floor(xb)); + if (ia === ib) cov[row + ia] += (xb - xa) / SUB; + else { + cov[row + ia] += (ia + 1 - xa) / SUB; + for (let x = ia + 1; x < ib; x++) cov[row + x] += 1 / SUB; + if (ib < W) cov[row + ib] += (xb - ib) / SUB; + } + } + } +} + +// A line from (ax, ay) to (bx, by), about 1.6 LEDs wide, kept at the brighter +// of its colour and what is already there. +function line(S, ax, ay, bx, by, cr, cg, cb, wide) { + const { R, G, B } = S, dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy || 1e-9; + const x0 = max(0, floor(min(ax, bx) - 1.5)), x1 = min(W - 1, floor(max(ax, bx) + 1.5)); + const y0 = max(0, floor(min(ay, by) - 1.5)), y1 = min(H - 1, floor(max(ay, by) + 1.5)); + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 - ax, py = y + 0.5 - ay, t = cl((px * dx + py * dy) / l2, 0, 1); + const qx = px - t * dx, qy = py - t * dy, dd = sqrt(qx * qx + qy * qy); + const v = cl((wide - dd) / 0.55, 0, 1); + if (v <= 0) continue; + const p = y * W + x; + R[p] = max(R[p], cr * v); + G[p] = max(G[p], cg * v); + B[p] = max(B[p], cb * v); + } +} + +function dot(S, x, y, r, cr, cg, cb) { + const { R, G, B } = S; + for (let yy = max(0, floor(y - r - 1)); yy <= min(H - 1, floor(y + r + 1)); yy++) + for (let xx = max(0, floor(x - r - 1)); xx <= min(W - 1, floor(x + r + 1)); xx++) { + const dd = sqrt((xx + 0.5 - x) ** 2 + (yy + 0.5 - y) ** 2), v = cl((r + 0.5 - dd) / 0.7, 0, 1); + if (v <= 0) continue; + const p = yy * W + xx; + R[p] = R[p] * (1 - v) + cr * v; + G[p] = G[p] * (1 - v) + cg * v; + B[p] = B[p] * (1 - v) + cb * v; + } +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { R, G, B, cov, col, X, Y, K } = S, n = S.n; + R.fill(0); + G.fill(0); + B.fill(0); + // Fill, convex rim, concave rim. Orange to lime fills are kept bright so + // they read as colour and not brown or olive. + const h = S.hue + 0.58; + hsv(h, 0.88, 0.9, col, 0); + hsv(h - 0.03, 0.3, 1, col, 3); + hsv(h + 0.55, 0.85, 1, col, 6); + const tr = S.trace, fillLv = 255 * (1 - 0.82 * tr); + if (S.phase === 2) { + // The round point: a white ring that opens and fades. + const u = S.pt / FLASH, r = 1 + 9 * sm(u), a = 255 * (1 - u) * (1 - u); + for (let i = 0; i < 48; i++) { + const t0 = (2 * PI * i) / 48, t1 = (2 * PI * (i + 1)) / 48; + line(S, S.fx + r * cos(t0), S.fy + r * sin(t0), S.fx + r * cos(t1), S.fy + r * sin(t1), a, a, a * 0.95, 0.9); + } + if (u < 0.3) dot(S, S.fx, S.fy, 1.2, 255, 255, 255); + } else { + const drawing = S.phase === 0, u = drawing ? S.pt / S.dur : 1; + const shown = drawing ? floor(n * sm(u / 0.72)) : n, fa = drawing ? sm((u - 0.55) / 0.4) : 1; + // Earlier outlines, the chronophotograph that Trace keeps. + if (tr > 0.1 && S.phase === 1) { + const lv = 255 * 0.75 * sm((tr - 0.1) / 0.5); + for (let s = 0; s < S.ns; s++) { + const m = S.SM[s], o = s * SNAPN * 2, f = 0.3 + (0.7 * (s + 1)) / S.ns; + for (let i = 0; i < m; i++) { + const j = (i + 1) % m; + line(S, S.SN[o + 2 * i], S.SN[o + 2 * i + 1], S.SN[o + 2 * j], S.SN[o + 2 * j + 1], col[3] * lv * f * 0.75, col[4] * lv * f * 0.75, col[5] * lv * f * 0.75, 0.75); + } + } + } + if (fa > 0) { + fill(S); + for (let p = 0; p < W * H; p++) { + const c = min(1, cov[p]) * fa * fillLv; + if (c <= 0) continue; + R[p] += col[0] * c; + G[p] += col[1] * c; + B[p] += col[2] * c; + } + } + // The rim: pale where the curve bends outward, the second colour where + // it bends inward. The inflection points sit where the two meet. + for (let i = 0; i < shown; i++) { + const j = i + 1 < n ? i + 1 : 0, t = cl((K[i] + K[j]) * 6, -1, 1), a = (1 + t) / 2; + const cr = 255 * (col[3] * a + col[6] * (1 - a)), cg = 255 * (col[4] * a + col[7] * (1 - a)), cb = 255 * (col[5] * a + col[8] * (1 - a)); + line(S, X[i], Y[i], X[j], Y[j], cr, cg, cb, drawing && i > shown - 6 ? 1.2 : 0.9); + } + // White marks at the inflection points: a dark ring round a white core. + for (let q = 0; q < S.ni; q++) { + if (S.IQ[q] >= shown) continue; + dot(S, S.IX[q], S.IY[q], 1.6, 0, 0, 0); + dot(S, S.IX[q], S.IY[q], 0.8, 255, 255, 255); + } + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + display.setPixel(x, y, round(min(255, R[p])), round(min(255, G[p])), round(min(255, B[p]))); + } +} From 26624196ebe789d43ebd129ba430b58c8a4576db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:25:11 +0000 Subject: [PATCH 4/9] pattern study: Lotion Ribbon, Whipped Soap --- .../pattern-study/patterns/lotion-ribbon.js | 513 ++++++++++++++++++ .../pattern-study/patterns/whipped-soap.js | 371 +++++++++++++ 2 files changed, 884 insertions(+) create mode 100644 incoming/pattern-study/patterns/lotion-ribbon.js create mode 100644 incoming/pattern-study/patterns/whipped-soap.js diff --git a/incoming/pattern-study/patterns/lotion-ribbon.js b/incoming/pattern-study/patterns/lotion-ribbon.js new file mode 100644 index 000000000..66dd5f5e7 --- /dev/null +++ b/incoming/pattern-study/patterns/lotion-ribbon.js @@ -0,0 +1,513 @@ +// ===== Patternflow pattern ===== +// Title: Lotion Ribbon +// Family: cosmetic +// Idea: A thick glossy ribbon of lotion pours from above the top edge in a steady stream that thins as it falls, coils where it lands and lays loop on loop onto a heap that slumps and spreads across the counter, the shine running down the rope and over the folds, until the stream breaks off, its tail falls in, the heap sinks away and a fresh pour begins. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 36, after the slow pour of body lotion in skincare films; the coiling follows liquid rope coiling (Neil Ribe, 2004) and the heap spreads as a viscous gravity current (Herbert Huppert, 1982) +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Thick=-1..1, Gloss=-1..1 +// +// Knob 1: Hue. Turns the lotion and the deep wall behind it round the whole wheel together. At 0 pink lotion on plum; at 0.1 salmon on wine; at 0.2 cream and at 0.3 lemon, both on near black; at 0.45 pistachio on green; at 0.6 aqua on teal; at 0.7 powder blue on navy; at 0.8 periwinkle on indigo; at 0.9 orchid on plum. From 0.1 to 0.42 the lotion is paled and the wall taken down almost to black, so nothing reads brown or olive. +// Knob 2: Speed. At 2 the lotion leaves the top edge at 14 LEDs a second and lands at about 47, coiling about once a second; the spout runs 16 seconds, the broken tail takes 4 seconds to fall in and the next pour lands 5 seconds after that, about 21 seconds in all. At 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Thick. How heavy the lotion is. At 0 the rope is 11 LEDs wide at the top and 6 where it lands, coiling in loops 16 LEDs across that hold their folds about 3 seconds; at -1 a runny serum 9 wide, coiling 1.5 times a second in loops 10 across that melt in 2 seconds into a heap that spreads four times as fast; at 1 a rich body butter 14 wide, coiling 0.6 times a second in loops 25 across that hold 4 seconds and pile up. +// Knob 4: Gloss. At -1 a satin lotion with a broad soft sheen; at 0 a white highlight about 2 LEDs wide runs down the rope and catches the top of every fold; at 1 a wet serum, a little deeper in colour, with a thin hot highlight and a bright pearly rim. +// Buttons: K1 tap breaks the stream now: its tail falls in, the heap sinks away and a fresh pour starts from the top. + +const W = 64, H = 128, N = W * H; +// The counter under the heap, one cell an LED: x 0..63, z from -28 at the +// back to 36 at the front, below the bottom edge. +const GX = 64, GZ = 64, ZC = 28, NG = GX * GZ; +// The heap's bulk flows on cells two LEDs square; the fresh folds lie on the +// LED cells above it. +const BX = GX / 2, BZ = GZ / 2, NB = BX * BZ; +const { floor, ceil, min, max, sqrt, sin, cos, exp, pow, round, abs, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +function hsv(h, s, v, out) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const r = [v, q, p, p, t, v][i], g = [t, v, v, q, p, p][i], b = [p, p, t, v, v, q][i]; + out[0] = r; + out[1] = g; + out[2] = b; +} + +// The camera looks down at the counter from EL radians above level. A point +// x across, Y up and z toward the eye lands on screen row FY - Y*CE + z*SE, +// and lies Y*SE + z*CE toward the eye. +const EL = 0.5, CE = cos(EL), SE = sin(EL); +const FY = 113, AX = 32; +// The spout is just above the top edge. +const YN = (FY + 4) / CE; +// Gravity and the speed the lotion leaves the spout, in LEDs and seconds at +// Speed 2, and the spout's radius at Thick 0. +const G = 8, V0 = 14, A0 = 5.6; +// Coiling rate: the gravitational regime of liquid rope coiling, +// Omega = CW * (g Q^3 / (nu a1^8))^(1/4), Q the flux, nu the viscosity and a1 +// the rope's radius where it lands. +const CW = 0.144; +// The coil bends out from the stream's axis over HC coil radii of height, +// winding a quarter turn ahead of the point where it lands. +const HC = 1.5; +// Slump: the heap flows as a thin viscous layer, dh/dt = div(K h^3 grad h), +// K = KS / nu. A fresh loop holds its fold TF * nu^(1/4) seconds before it +// melts into the heap. +const KS = 3e-2, TF = 3; +// The coil does not land on one circle: its centre wanders up to EC coil +// radii off the axis, round and back about every PW seconds, so the loops +// cross and fold over each other. +const EC = 0.7, PW = 5.3; +// One pour lasts POUR seconds; after the tail lands the heap settles SETTLE +// seconds, then sinks SINK LEDs a second while the next pour falls. +const POUR = 16, SETTLE = 0.8, SINK = 5; +// Little swellings ride down the stream FB times a second, BA of the radius. +const FB = 1.1, BA = 0.05; +// Opening hue: pink, not red. +const H0 = 0.9; + +// Light from the upper left and a little in front, and the eye, in world +// axes and in the camera's (x right, y up the screen, z toward the eye). +const nrm = (v) => { + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l]; +}; +const LW = nrm([-0.5, 0.75, 0.45]); +const VW = [0, SE, CE]; +const HW = nrm([LW[0] + VW[0], LW[1] + VW[1], LW[2] + VW[2]]); +const toCam = (v) => [v[0], v[1] * CE - v[2] * SE, v[1] * SE + v[2] * CE]; +const LC = toCam(LW), HCm = toCam(HW); +// A tall strip light to the left of the eye: on the falling rope it draws a +// highlight down its whole length. +const HS = nrm([-0.3, 0, 1]); + +export function setup(params) { + params.s = { + hue: 0, speed: 2, thick: 0, gloss: 0, warm: false, + t: 0, phi: 0, tPour: 0, + // top: height of the rope's upper end (YN while the spout runs); tip: its + // lower end while a fresh pour is still falling, or -1 once it has landed. + top: YN, topV: 0, tip: -1, tipV: 0, cut: false, gone: false, wait: 0, sinking: false, + h: new Float32Array(NG), b: new Float32Array(NB), f: new Float32Array(NG), dh: new Float32Array(NB), + blur: new Float32Array(NG), tmp: new Float32Array(NG), hs: new Float32Array(NG), cc: new Float32Array(NG * 3), fl: new Float32Array(NG), + hdep: new Float32Array(N), col: new Float32Array(N * 3), + rdep: new Float32Array(N), rcov: new Float32Array(N), rcol: new Float32Array(N * 3), + bg: new Float32Array(N), + lit: [0, 0, 0], dark: [0, 0, 0], wall: [0, 0, 0], pearl: [0, 0, 0], c: [0, 0, 0], up: [0, 0, 0], + spec: new Float32Array(SPN), specKey: null, + }; + const S = params.s; + // A soft glow on the wall behind the stream, falling off to near black in + // the top corners. + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const dx = (x + 0.5 - AX) / 26, dy = (y - 78) / 70; + S.bg[y * W + x] = 0.12 + 0.88 * exp(-dx * dx - dy * dy); + } +} + +// The lotion's make-up at the current Thick. +function lotion(S) { + const nu = pow(4, S.thick), a0 = A0 * pow(1.25, S.thick); + return { nu, a0, Q: PI * a0 * a0 * V0, K: KS / nu, tf: TF * pow(nu, 0.25) }; +} + +// Free fall from the spout: speed at height Y, and the radius that keeps the +// mass flux pi a^2 v the same all the way down. +const speedAt = (Y) => sqrt(V0 * V0 + 2 * G * max(0, YN - Y)); +const radiusAt = (a0, Y) => a0 * sqrt(V0 / speedAt(Y)); + +// The heap's height under a point, bilinear. +function heightAt(S, x, z) { + const u = cl(x - 0.5, 0, GX - 1.001), v = cl(z + ZC - 0.5, 0, GZ - 1.001); + const i = floor(u), j = floor(v), fu = u - i, fv = v - j, h = S.h, p = j * GX + i; + return (h[p] * (1 - fu) + h[p + 1] * fu) * (1 - fv) + (h[p + GX] * (1 - fu) + h[p + GX + 1] * fu) * fv; +} + +// Where the rope lands now: the coil's radius and rate and the surface under it. +function coil(S, L) { + const R0 = 8; + // The surface a little ahead of the contact, where the rope is about to lie. + let Yc = heightAt(S, AX + R0 * cos(S.phi + 0.3), R0 * sin(S.phi + 0.3)); + const U1 = speedAt(Yc), a1 = radiusAt(L.a0, Yc); + const om = CW * pow((G * L.Q * L.Q * L.Q) / (L.nu * pow(a1, 8)), 0.25); + const R = U1 / om, ps = (2 * PI * S.t) / PW, e = EC * R * (0.6 + 0.4 * sin(ps * 0.37 + 1)); + const cx = AX + e * cos(ps), cz = e * sin(ps * 1.3); + Yc = heightAt(S, cx + R * cos(S.phi + a1 / R), cz + R * sin(S.phi + a1 / R)); + return { Yc, U1, a1, om, R, cx, cz }; +} + +// Lay a short piece of rope at (x, z): its volume pi a^2 ds spread over the +// cells under it, heaped in the middle. +function deposit(S, x, z, a, ds) { + const h = S.f, vol = PI * a * a * ds; + const i0 = max(0, floor(x - a - 0.5)), i1 = min(GX - 1, ceil(x + a - 0.5)); + const j0 = max(0, floor(z + ZC - a - 0.5)), j1 = min(GZ - 1, ceil(z + ZC + a - 0.5)); + let sum = 0; + for (let j = j0; j <= j1; j++) + for (let i = i0; i <= i1; i++) { + const dx = i + 0.5 - x, dz = j + 0.5 - ZC - z, q = 1 - (dx * dx + dz * dz) / (a * a); + if (q > 0) sum += q; + } + if (sum <= 0) return; + const k = vol / sum; + for (let j = j0; j <= j1; j++) + for (let i = i0; i <= i1; i++) { + const dx = i + 0.5 - x, dz = j + 0.5 - ZC - z, q = 1 - (dx * dx + dz * dz) / (a * a); + if (q > 0) h[j * GX + i] += k * q; + } +} + +// The heap flows: flux K h^3 times the slope across every face between two +// bulk cells, none out through the counter's edges, so no lotion is lost. +function slump(S, L, dt) { + const h = S.b, d = S.dh, k = L.K / 4; + let left = dt; + while (left > 1e-6) { + let hm = 0; + for (let p = 0; p < NB; p++) if (h[p] > hm) hm = h[p]; + const st = min(left, 0.2 / (k * hm * hm * hm + 1e-6)); + d.fill(0); + for (let j = 0; j < BZ; j++) + for (let i = 0; i < BX; i++) { + const p = j * BX + i, a = h[p]; + if (i < BX - 1) { + const b = h[p + 1], m = (a + b) * 0.5, f = k * m * m * m * (a - b); + d[p] -= f; + d[p + 1] += f; + } + if (j < BZ - 1) { + const b = h[p + BX], m = (a + b) * 0.5, f = k * m * m * m * (a - b); + d[p] -= f; + d[p + BX] += f; + } + } + for (let p = 0; p < NB; p++) h[p] = max(0, h[p] + d[p] * st); + left -= st; + } +} + +// The bulk under an LED cell, bilinear between bulk cell centres. +function bulkAt(b, i, j) { + const u = cl(i * 0.5 - 0.25, 0, BX - 1.001), v = cl(j * 0.5 - 0.25, 0, BZ - 1.001); + const I = floor(u), J = floor(v), fu = u - I, fv = v - J, p = J * BX + I; + return (b[p] * (1 - fu) + b[p + 1] * fu) * (1 - fv) + (b[p + BX] * (1 - fu) + b[p + BX + 1] * fu) * fv; +} + +function breakStream(S) { + if (S.cut || S.gone || S.tip >= 0) return; + S.cut = true; + S.topV = V0; +} + +function step(S, dt) { + const L = lotion(S); + S.t += dt; + const C = coil(S, L); + // A fresh pour falls until its front reaches the heap. + if (S.tip >= 0) { + S.tipV += G * dt; + S.tip -= S.tipV * dt; + if (S.tip <= C.Yc + C.a1) S.tip = -1; + } + if (!S.cut && !S.gone) { + S.tPour += dt; + if (S.tPour > POUR) breakStream(S); + } + // A broken stream's tail falls in. + if (S.cut) { + S.topV += G * dt; + S.top -= S.topV * dt; + if (S.top <= C.Yc + C.a1) { + S.cut = false; + S.gone = true; + S.wait = SETTLE; + } + } + if (S.gone) { + S.wait -= dt; + if (S.wait <= 0 && !S.sinking) { + // The heap sinks away and the next pour starts from the spout. + S.sinking = true; + S.gone = false; + S.top = YN; + S.tip = YN; + S.tipV = V0; + S.tPour = 0; + } + } + const { b, f } = S; + const toBulk = (p) => ((p / GX) >> 1) * BX + ((p % GX) >> 1); + if (S.sinking) { + let any = false; + for (let p = 0; p < NG; p++) { + b[toBulk(p)] += f[p] / 4; + f[p] = 0; + } + for (let p = 0; p < NB; p++) { + b[p] = max(0, b[p] - SINK * dt); + if (b[p] > 0) any = true; + } + if (!any) S.sinking = false; + } + // The rope lays itself down along the coil at the speed it arrives, so the + // contact runs round at Omega = U1 / R. + if (!S.gone && S.tip < 0) { + const dphi = C.om * dt, n = max(1, ceil((C.R * dphi) / 0.5)); + for (let k = 0; k < n; k++) { + const ph = S.phi + (dphi * (k + 0.5)) / n; + deposit(S, C.cx + C.R * cos(ph), C.cz + C.R * sin(ph), C.a1, (C.R * dphi) / n); + } + S.phi += dphi; + } + // Loops melt into the heap, which flows. + const m = 1 - exp(-dt / L.tf); + for (let p = 0; p < NG; p++) { + const v = f[p] * m; + f[p] -= v; + b[toBulk(p)] += v / 4; + } + slump(S, L, dt); + for (let j = 0, p = 0; j < GZ; j++) for (let i = 0; i < GX; i++, p++) S.h[p] = bulkAt(b, i, j) + f[p]; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.thick = cl(k[2], -1, 1); + S.gloss = cl(k[3], -1, 1); + if (!S.warm) { + // Open six seconds into a pour. + for (let i = 0; i < 180; i++) step(S, 1 / 30); + S.warm = true; + } + if (input.btnPressed && input.btnPressed[0]) breakStream(S); + step(S, (d * S.speed) / 2); +} + +// Colours for the hue: the lit lotion, its shadow side and the wall. +function palette(S) { + const h = S.hue + H0, hh = h - floor(h); + // Orange, yellow and lime go brown or olive when deep: there the lotion is + // paled to cream and the wall taken down almost to black. + const warm = sm(0.0, 0.08, hh) * (1 - sm(0.22, 0.32, hh)); + // A wetter finish is a little deeper in colour. + const wet = 0.5 + 0.5 * S.gloss; + hsv(h, (0.38 + 0.14 * wet) * (1 - 0.4 * warm), 0.8 - 0.08 * wet + 0.06 * warm, S.lit); + hsv(h + 0.015, (0.6 + 0.2 * wet) * (1 - 0.35 * warm), 0.4 - 0.1 * wet + 0.2 * warm, S.dark); + hsv(h - 0.04, 0.85 - 0.5 * warm, 0.2 - 0.15 * warm, S.wall); + // The pearl in the lotion glints a cooler shade at grazing angles. + hsv(h - 0.12, 0.45 - 0.15 * warm, 1, S.pearl); +} + +// The highlight against how squarely a surface faces the half vector, +// tabled once per Gloss. Satin at -1: a broad dim sheen; wet at 1: a narrow +// hot highlight. +const SPN = 1024; +function specTable(S) { + if (S.specKey === S.gloss) return; + S.specKey = S.gloss; + const g = S.gloss, P = 28 * pow(5, g), KSp = 0.6 + 0.35 * g; + for (let i = 0; i < SPN; i++) { + const n = i / (SPN - 1), n2 = n * n; + S.spec[i] = KSp * pow(n, P) + 0.16 * max(0, -g) * n2 * n2; + } +} + +function shade(S, nx, ny, nz, vx, vy, vz, lx, ly, lz, hx, hy, hz, strip, ao, out) { + // A wet finish has a bright rim. + const KR = 0.25 + 0.3 * max(0, S.gloss); + const nl = nx * lx + ny * ly + nz * lz; + const dif = cl((nl + 0.25) / 1.25, 0, 1); + let nh = max(0, nx * hx + ny * hy + nz * hz); + // The strip light, seen in the rope: only the turn of the rope across + // matters, not along it. + if (strip) { + const l = sqrt(nx * nx + nz * nz) + 1e-6; + nh = max(nh, ((nx * HS[0] + nz * HS[2]) / l) * (0.75 + 0.25 * l)); + } + const sp = S.spec[round(cl(nh, 0, 1) * (SPN - 1))], e = 1 - cl(nx * vx + ny * vy + nz * vz, 0, 1); + const rim = KR * e * e * e; + const lit = S.lit, dk = S.dark, pe = S.pearl; + for (let c = 0; c < 3; c++) out[c] = (dk[c] + (lit[c] - dk[c]) * dif) * ao + sp + rim * pe[c]; +} + +// Highlights roll off softly instead of clipping: a table over 0..2. +const TONE = new Uint8Array(1025); +for (let i = 0; i <= 1024; i++) TONE[i] = round(min(255, 319 * (1 - exp((-1.6 * 2 * i) / 1024)))); +const tone = (v) => TONE[v <= 0 ? 0 : v >= 2 ? 1024 : (v * 512) | 0]; + +export function draw(display, params) { + const S = params.s; + if (!S) return; + palette(S); + specTable(S); + const L = lotion(S), C = coil(S, L); + const { h, hdep, col, rdep, rcov, rcol, bg, wall, c, blur, tmp } = S; + // The heap's height blurred over 5 cells, for its shadow on the counter: + // a running sum along x, then along z. + for (let j = 0; j < GZ; j++) { + const o = j * GX; + let a = h[o] * 3 + h[o + 1] + h[o + 2]; + for (let i = 0; i < GX; i++) { + tmp[o + i] = a * 0.2; + a += h[o + min(GX - 1, i + 3)] - h[o + max(0, i - 2)]; + } + } + for (let i = 0; i < GX; i++) { + let a = tmp[i] * 3 + tmp[GX + i] + tmp[2 * GX + i]; + for (let j = 0; j < GZ; j++) { + blur[j * GX + i] = a * 0.2; + a += tmp[min(GZ - 1, j + 3) * GX + i] - tmp[max(0, j - 2) * GX + i]; + } + } + // Heights smoothed 1-2-1 for the normals, so the steep rim of the heap + // does not break into a comb along the grid. + const hs = S.hs; + for (let j = 0; j < GZ; j++) + for (let i = 0; i < GX; i++) { + const p = j * GX + i, l = i > 0 ? p - 1 : p, r = i < GX - 1 ? p + 1 : p; + tmp[p] = (h[l] + 2 * h[p] + h[r]) * 0.25; + } + for (let j = 0; j < GZ; j++) + for (let i = 0; i < GX; i++) { + const p = j * GX + i, b0 = j > 0 ? p - GX : p, f0 = j < GZ - 1 ? p + GX : p; + hs[p] = (tmp[b0] + 2 * tmp[p] + tmp[f0]) * 0.25; + } + // The wall. + for (let p = 0; p < N; p++) { + const v = bg[p]; + col[p * 3] = wall[0] * v; + col[p * 3 + 1] = wall[1] * v; + col[p * 3 + 2] = wall[2] * v; + hdep[p] = -1e9; + rdep[p] = -1e9; + rcov[p] = 0; + } + // Each cell's colour: the lotion lit from its normal, valleys between + // folds a little darker; and the bare counter, a satin shelf a little + // lighter than the wall, in the shade of the heap beside it. + const cc = S.cc, up = S.up; + shade(S, 0, 1, 0, VW[0], VW[1], VW[2], LW[0], LW[1], LW[2], HW[0], HW[1], HW[2], false, 1, up); + for (let j = 0; j < GZ; j++) + for (let i = 0; i < GX; i++) { + const p = j * GX + i, z = j + 0.5 - ZC; + S.fl[p] = (0.55 + 0.25 * sm(-28, 28, z)) * (1 - 0.6 * sm(0, 4, blur[p])); + if (hs[p] < 1e-3) { + cc[p * 3] = up[0]; + cc[p * 3 + 1] = up[1]; + cc[p * 3 + 2] = up[2]; + continue; + } + const hl = hs[p - (i > 0 ? 1 : 0)], hr = hs[p + (i < GX - 1 ? 1 : 0)]; + const hb = hs[p - (j > 0 ? GX : 0)], hf = hs[p + (j < GZ - 1 ? GX : 0)]; + const gx = (hr - hl) * 0.5, gz = (hf - hb) * 0.5, inv = 1 / sqrt(gx * gx + 1 + gz * gz); + const lap = hl + hr + hb + hf - 4 * hs[p]; + shade(S, -gx * inv, inv, -gz * inv, VW[0], VW[1], VW[2], LW[0], LW[1], LW[2], HW[0], HW[1], HW[2], false, 1 - 0.3 * cl(lap * 0.25, 0, 1), c); + cc[p * 3] = c[0]; + cc[p * 3 + 1] = c[1]; + cc[p * 3 + 2] = c[2]; + } + // The counter and the heap, column by column from the front back in half + // cells, height and colour taken between cell centres, each sample filling + // the rows its top rises above the samples in front of it. + const FL = S.fl; + for (let i = 0; i < GX; i++) { + let yb = H; + for (let v = GZ - 1; v >= 0; v -= 0.5) { + const j = floor(v), fv = v - j, p = j * GX + i, p2 = j < GZ - 1 ? p + GX : p; + const Yv = h[p] + (h[p2] - h[p]) * fv, z = v + 0.5 - ZC; + const y0 = ceil(FY - Yv * CE + z * SE - 0.5); + if (y0 >= yb) continue; + // Thin lotion lets the counter show through. + const film = sm(0.05, 0.9, Yv), fl = (FL[p] + (FL[p2] - FL[p]) * fv) * (1 - film); + const r = (cc[p * 3] + (cc[p2 * 3] - cc[p * 3]) * fv) * film + wall[0] * fl; + const g = (cc[p * 3 + 1] + (cc[p2 * 3 + 1] - cc[p * 3 + 1]) * fv) * film + wall[1] * fl; + const b = (cc[p * 3 + 2] + (cc[p2 * 3 + 2] - cc[p * 3 + 2]) * fv) * film + wall[2] * fl; + const dep = Yv * SE + z * CE; + for (let y = max(0, y0); y < yb; y++) { + const q = y * W + i; + col[q * 3] = r; + col[q * 3 + 1] = g; + col[q * 3 + 2] = b; + hdep[q] = dep; + } + yb = y0; + } + } + // The rope: a chain of balls half an LED apart, each a sphere, the nearest + // surface's normal kept at every LED. + const ropeOn = !S.gone; + if (ropeOn) { + const t = S.t, top = min(S.top, YN + 3); + const landed = S.tip < 0; + const coilTop = C.Yc + C.a1 + HC * C.R; + const low = landed ? coilTop : S.tip; + const ball = (x, Y, z, a) => { + if (a < 0.3) return; + const sx = x, sy = FY - Y * CE + z * SE, dd = Y * SE + z * CE; + const x0 = max(0, floor(sx - a - 1)), x1 = min(W - 1, ceil(sx + a)), y0 = max(0, floor(sy - a - 1)), y1 = min(H - 1, ceil(sy + a)); + for (let y = y0; y <= y1; y++) + for (let xx = x0; xx <= x1; xx++) { + const dx = xx + 0.5 - sx, dy = y + 0.5 - sy, r = sqrt(dx * dx + dy * dy); + const cov = cl(a - r + 0.5, 0, 1); + if (cov <= 0) continue; + const q = y * W + xx, uz = sqrt(max(0, 1 - (r * r) / (a * a))); + const depth = dd + a * uz; + if (cov < rcov[q] - 0.02 || (cov <= rcov[q] + 0.02 && depth <= rdep[q])) continue; + const m = 1 / max(a, r + 1e-6); + rcov[q] = cov; + rdep[q] = depth; + rcol[q * 3] = dx * m; + rcol[q * 3 + 1] = -dy * m; + rcol[q * 3 + 2] = uz; + } + }; + // The falling stream, with swellings that left the spout at a steady + // beat and stretch apart as the lotion speeds up. + for (let Y = top; Y > low; Y -= 0.5) { + const v = speedAt(Y), age = (v - V0) / G; + let a = radiusAt(L.a0, Y) * (1 + BA * sin(2 * PI * FB * (t - age))); + // A broken stream's upper end narrows to a rounded tail. + if (S.cut) a *= 0.45 + 0.55 * sm(0, 12, top - Y); + ball(AX, Y, 0, a); + } + if (!landed) ball(AX, S.tip, 0, radiusAt(L.a0, S.tip) * 1.15); + else { + // The coil: from the axis at its top it bends out to radius R, winding + // a quarter turn as it comes down level onto the heap. + const len = HC * C.R + 2 * C.R, n = ceil(len / 0.5); + for (let k = n; k >= 0; k--) { + const s = k / n, Y = C.Yc + C.a1 + HC * C.R * s * s; + if (Y > top) continue; + const r = C.R * (1 - s * s) * (1 - s * s), th = S.phi + s * PI * 0.5, e = (1 - s) * (1 - s); + let a = radiusAt(L.a0, Y); + if (S.cut) a *= 0.45 + 0.55 * sm(0, 12, top - Y); + ball(AX + (C.cx - AX) * e + r * cos(th), Y, C.cz * e + r * sin(th), a); + } + } + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const q = y * W + x; + let r = col[q * 3], g = col[q * 3 + 1], b = col[q * 3 + 2]; + const cv = rcov[q]; + if (cv > 0 && rdep[q] > hdep[q]) { + // The rope, lit from the normal its nearest ball left here. + shade(S, rcol[q * 3], rcol[q * 3 + 1], rcol[q * 3 + 2], 0, 0, 1, LC[0], LC[1], LC[2], HCm[0], HCm[1], HCm[2], true, 1, c); + r += (c[0] - r) * cv; + g += (c[1] - g) * cv; + b += (c[2] - b) * cv; + } + display.setPixel(x, y, tone(r), tone(g), tone(b)); + } +} diff --git a/incoming/pattern-study/patterns/whipped-soap.js b/incoming/pattern-study/patterns/whipped-soap.js new file mode 100644 index 000000000..3602b46f3 --- /dev/null +++ b/incoming/pattern-study/patterns/whipped-soap.js @@ -0,0 +1,371 @@ +// ===== Patternflow pattern ===== +// Title: Whipped Soap +// Family: cosmetic +// Idea: An unseen star nozzle pipes whipped body soap onto a tray one rosette at a time, each a tight spiral that rises as every turn lands on the one before, its star-cut ridges standing in sharp crests over soft valleys in a soft light from the upper left that sways a little, until the tray holds two across and four down and a blade scrapes it flat for the next batch. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 36, after whipped body soap piped into rosettes in handmade soap shops +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Ridges=5..10, Swirl=-1..1 +// +// Knob 1: Hue. Turns the soap and the tray under it round the whole wheel together. At 0 mint on deep teal; at 0.1 powder blue on navy; at 0.2 periwinkle on indigo; at 0.3 lilac on plum; at 0.4 orchid on deep magenta; at 0.5 rose on wine; at 0.6 peach and at 0.7 butter yellow, both on a near-black tray; at 0.8 pistachio on deep green. From 0.55 to 0.89 the foam is paled, its shadows lifted and the tray taken down, so nothing reads brown or olive. +// Knob 2: Speed. At 2 the nozzle runs 30 LEDs a second, a rosette takes about 3 seconds, an empty tray fills in about 26 and the scrape takes 3; the light swings round and back every 7 seconds. At 0.2 all of it ten times slower, at 3 one and a half times faster. +// Knob 3: Ridges. The points of the star nozzle, 5 to 10, and the star turns slowly as it goes so the ridges wind across each turn: at 5 two or three broad ridges with deep valleys between them, at 10 four or five fine ones close together. +// Knob 4: Swirl. How far each turn closes in, so how many turns a rosette takes: at -1 one turn lying flat, a coil 5 LEDs high; at 0 two and a half turns, each resting half on the one before, rising to a soft point about 12 high; at 1 nearly four turns, a tall tight swirl 17 high. +// Buttons: K1 tap scrapes the tray flat now, the foam rolling ahead of the blade and out at the bottom, and piping starts again from the top left. + +const W = 64, H = 128, N = W * H; +const { floor, ceil, min, max, sqrt, sin, cos, tan, atan2, abs, round, random, hypot, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +function hsv(h, s, v, out) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + out[0] = [v, q, p, p, t, v][i]; + out[1] = [t, v, v, q, p, p][i]; + out[2] = [p, p, t, v, v, q][i]; +} + +// The tray: two rosettes across and four down, each in a square 32 LEDs on a side. +const COLS = 2, ROWS = 4, NR = COLS * ROWS, CWD = W / COLS, CHT = H / ROWS; +// The nozzle's path starts R0 LEDs from the rosette's centre; the bead it +// lays is RB LEDs in radius across and stands SQ times that tall. +const R0 = 10, RB = 4.6, SQ = 1.2; +// How deep the star's notches cut into the bead, and how far the star turns +// round the bead for each LED the nozzle travels. +const NOTCH = 0.45, TWIST = 0.11; +// The nozzle runs VN LEDs a second at Speed 2. +const VN = 30; +// A turn lands on the foam laid at least LAG LEDs of path earlier: the +// bead never sits on itself. +const LAG = 2.6 * RB; +// A soft light from the upper left (x right, y down the panel, z out of +// it), ELV radians above the tray, swings SWING radians either way and back +// every SWAY seconds at Speed 2, so shadows and sheen slide round the swirls. +const ELV = 0.58, TANE = tan(ELV), AZ0 = atan2(-0.65, -0.6), SWING = 0.5, SWAY = 7; +// Each LED keeps how high the foam stands toward the light, as a slope, +// looking SHADOW LEDs out at three bearings across the swing. +const SHADOW = 16, NAZ = 3; +// Opening hue: mint, not red. +const H0 = 0.45; + +export function setup(params) { + params.s = { + hue: 0, speed: 2, ridges: 5, swirl: 0, begun: false, + hgt: new Float32Array(N), lag: new Float32Array(N), t: 0, + nx: new Float32Array(N), ny: new Float32Array(N), nz: new Float32Array(N).fill(1), + ao: new Float32Array(N).fill(1), hz: new Float32Array(N * NAZ), foam: new Float32Array(N), + pend: [], k: 0, pathS: 0, th: 0, ro: null, wait: 0, + scraping: false, ys: 0, vol: new Float32Array(W), roll: new Float32Array(W), tmp: new Float32Array(W), + dirty: [0, 0, W - 1, H - 1], prof: new Float32Array(41), + lit: [0, 0, 0], dark: [0, 0, 0], tray: [0, 0, 0], + }; +} + +// A rosette's place on the tray, a little off true as by hand, and where the +// nozzle starts round it. +function newRosette(S, k) { + const c = k % COLS, r = floor(k / COLS); + return { + cx: (c + 0.5) * CWD + (random() - 0.5) * 1.6, cy: (r + 0.5) * CHT + (random() - 0.5) * 1.6, + a0: random() * 2 * PI, dir: random() < 0.5 ? 1 : -1, tw: random() * 2 * PI, + }; +} + +// The nozzle's path: angle th round the centre, out of 2 pi turns in all, +// the radius closing from R0 to nothing. Each turn closes in by a step: the +// bead's full width at Swirl -1, so the turns lie side by side; 0.9 of its +// radius at 0, so each turn rests half on the one before; 0.57 at 1, a tall +// cone. +const turnsOf = (S) => R0 / (RB * (S.swirl < 0 ? 0.9 - 1.1 * S.swirl : 0.9 - 0.33 * S.swirl)); +function pathAt(S, ro, th) { + const T = 2 * PI * turnsOf(S), u = cl(th / T, 0, 1), r = R0 * (1 - u) ** 0.8; + const a = ro.a0 + ro.dir * th; + return { x: ro.cx + r * cos(a), y: ro.cy + r * sin(a), r, u, a }; +} + +// The star bead's top across its width. The star's outline, +// rho = RB (1 - NOTCH + NOTCH (1 - |sin(n phi / 2)|)^2), sharp at the points +// and round in the notches, is walked at 96 angles over its upper half, and +// each edge between two of them lifts the 41 steps across it lies over. +const NP = 96; +function profile(S, alpha, scale) { + const pr = S.prof, n = S.ridges; + pr.fill(0); + let ps = 0, pw = 0; + for (let m = 0; m <= NP; m++) { + const ph = (m / NP) * PI, cr = 1 - abs(sin((n * (ph + alpha)) / 2)); + const rho = RB * scale * (1 - NOTCH + NOTCH * cr * cr); + const s = ((rho * cos(ph)) / RB) * 20 + 20, w = rho * sin(ph) * SQ; + if (m > 0) { + const a = min(s, ps), b = max(s, ps); + for (let k = max(0, ceil(a)); k <= min(40, floor(b)); k++) { + const t = b > a ? (k - ps) / (s - ps) : 0, v = pw + (w - pw) * t; + if (v > pr[k]) pr[k] = v; + } + } + ps = s; + pw = w; + } +} + +function grow(S, x0, y0, x1, y1) { + const d = S.dirty; + d[0] = min(d[0], max(0, floor(x0))); + d[1] = min(d[1], max(0, floor(y0))); + d[2] = max(d[2], min(W - 1, ceil(x1))); + d[3] = max(d[3], min(H - 1, ceil(y1))); +} + +// Height of the foam laid at least LAG earlier, under a point, bilinear. +function lagAt(S, x, y) { + const u = cl(x - 0.5, 0, W - 1.001), v = cl(y - 0.5, 0, H - 1.001), i = floor(u), j = floor(v), fu = u - i, fv = v - j; + const g = S.lag, p = j * W + i; + return (g[p] * (1 - fu) + g[p + 1] * fu) * (1 - fv) + (g[p + W] * (1 - fu) + g[p + W + 1] * fu) * fv; +} + +// Lay the bead at one point of the path. +function lay(S, ro, th) { + // The direction of travel, from a step back along the path, which still + // has length at the centre where the radius closes to nothing. + const P = pathAt(S, ro, th), Q = pathAt(S, ro, max(0, th - 0.15)); + let tx = P.x - Q.x, ty = P.y - Q.y; + const tl = hypot(tx, ty); + if (tl < 1e-4) { + tx = -sin(P.a) * ro.dir; + ty = cos(P.a) * ro.dir; + } else { + tx /= tl; + ty /= tl; + } + // Over the last tenth the bead thins to a point as the nozzle lifts away. + const scale = 1 - 0.75 * sm(0.88, 1, P.u), lift = 2.2 * sm(0.85, 1, P.u); + profile(S, ro.tw + TWIST * S.pathS, scale); + // The bead rests on the turn before: on the foam under the nozzle or a + // little outward of it, whichever stands higher. + const ox = P.r > 0.01 ? (P.x - ro.cx) / P.r : 0, oy = P.r > 0.01 ? (P.y - ro.cy) / P.r : 0, rest = 0.45 * RB; + const base = max(lagAt(S, P.x, P.y), lagAt(S, P.x + ox * rest, P.y + oy * rest) - 0.6) + lift, pr = S.prof, R = RB + 1; + for (let y = max(0, floor(P.y - R)); y <= min(H - 1, ceil(P.y + R)); y++) + for (let x = max(0, floor(P.x - R)); x <= min(W - 1, ceil(P.x + R)); x++) { + const dx = x + 0.5 - P.x, dy = y + 0.5 - P.y, along = dx * tx + dy * ty; + if (abs(along) > 0.75) continue; + const s = -dx * ty + dy * tx, b = ((s / RB) * 0.5 + 0.5) * 40; + if (b < 0 || b > 40) continue; + const b0 = floor(b), b1 = min(40, b0 + 1), f = b - b0, w0 = pr[b0], w1 = pr[b1]; + const v = base + w0 + (w1 - w0) * f, p = y * W + x; + if (v > S.hgt[p]) { + S.hgt[p] = v; + S.pend.push(p, v, S.pathS); + } + } + grow(S, P.x - R - 2, P.y - R - 2, P.x + R + SHADOW, P.y + R + SHADOW); +} + +// Foam laid LAG or more of path ago becomes ground for the next turn. +function settle(S, all) { + const q = S.pend; + let k = 0; + while (k < q.length && (all || S.pathS - q[k + 2] > LAG)) { + const p = q[k]; + if (q[k + 1] > S.lag[p]) S.lag[p] = q[k + 1]; + k += 3; + } + if (k) q.splice(0, k); +} + +function startScrape(S) { + if (S.scraping) return; + settle(S, true); + S.scraping = true; + S.ro = null; + S.ys = -8; + S.vol.fill(0); + S.roll.fill(0); +} + +function step(S, dt) { + S.t += dt; + if (S.scraping) { + // A blade runs down the tray at 55 LEDs a second: everything above it + // is taken up, rolled ahead of it, and leaves past the bottom edge. + const y0 = S.ys, y1 = S.ys + 55 * dt; + for (let y = max(0, ceil(y0)); y < min(H, ceil(y1)); y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + S.vol[x] += S.hgt[p]; + S.hgt[p] = 0; + S.lag[p] = 0; + } + S.ys = y1; + // The roll spreads along the blade: its height across the panel is the + // scraped foam smoothed over about a rosette's width, lumpy as foam. + const v = S.vol, t = S.tmp, rl = S.roll; + for (let pass = 0; pass < 2; pass++) { + const a = pass ? t : v, b = pass ? rl : t; + for (let x = 0; x < W; x++) { + let sum = 0; + for (let k = -7; k <= 7; k++) sum += a[cl(x + k, 0, W - 1)]; + b[x] = sum / 15; + } + } + for (let x = 0; x < W; x++) rl[x] = 0.55 * sqrt(rl[x]) * (0.85 + 0.15 * sin(x * 0.9 + S.ys * 0.21)); + grow(S, 0, y0 - 3, W - 1, y1 + 10 + SHADOW); + if (S.ys > H + 14) { + S.scraping = false; + S.k = 0; + S.wait = 0.5; + } + return; + } + if (S.wait > 0) { + S.wait -= dt; + return; + } + if (!S.ro) { + if (S.k >= NR) { + startScrape(S); + return; + } + S.ro = newRosette(S, S.k); + S.th = 0; + } + // The nozzle runs on along its spiral, laying the bead every 0.4 LED. + const T = 2 * PI * turnsOf(S); + let left = VN * dt; + while (left > 0 && S.ro) { + const P = pathAt(S, S.ro, S.th), ds = min(0.4, left); + // Arc length per radian of the spiral, kept off zero at the centre. + const sp = max(1.2, hypot(P.r, (0.8 * R0 * (1 - P.u) ** -0.2) / T)); + S.th += ds / sp; + S.pathS += ds; + left -= ds; + if (S.th >= T) { + lay(S, S.ro, T); + settle(S, true); + S.ro = null; + S.k++; + S.wait = 0.3; + break; + } + lay(S, S.ro, S.th); + settle(S, false); + } +} + +// A whole rosette at once, for the tray the pattern opens on. +function pipeNow(S) { + S.ro = newRosette(S, S.k); + S.th = 0; + while (S.ro) step(S, 0.02); + S.wait = 0; +} + +// The roll of scraped foam ahead of the blade, at a column and row. +function rollAt(S, x, y) { + const d = y + 0.5 - S.ys; + if (!S.scraping || d < 0 || d > 8) return 0; + return S.roll[x] * sin((PI * d) / 8) ** 0.7; +} + +// Survey the foam inside the dirty box: its normal, how far it sits in a +// valley, and its horizon toward the light at each bearing. +function survey(S) { + const d = S.dirty; + if (d[0] > d[2]) return; + const g = S.hgt, ht = (x, y) => { + const xi = cl(x, 0, W - 1), yi = cl(y, 0, H - 1); + return max(g[yi * W + xi], rollAt(S, xi, yi)); + }; + for (let y = d[1]; y <= d[3]; y++) + for (let x = d[0]; x <= d[2]; x++) { + const p = y * W + x, h0 = ht(x, y); + const gx = (ht(x + 1, y) - ht(x - 1, y)) * 0.5, gy = (ht(x, y + 1) - ht(x, y - 1)) * 0.5; + const inv = 1 / sqrt(gx * gx + gy * gy + 1); + S.nx[p] = -gx * inv; + S.ny[p] = -gy * inv; + S.nz[p] = inv; + // Valleys: the foam lower than its ring of neighbours two LEDs out. + const ring = (ht(x - 2, y) + ht(x + 2, y) + ht(x, y - 2) + ht(x, y + 2)) * 0.25; + S.ao[p] = 1 - 0.4 * sm(0, 2.2, ring - h0); + S.foam[p] = sm(0.08, 0.7, h0); + for (let k = 0; k < NAZ; k++) { + const az = AZ0 + SWING * (k - 1), dx = cos(az), dy = sin(az); + let hor = -1; + for (let s = 1; s <= SHADOW; s++) { + const e = (ht(round(x + dx * s), round(y + dy * s)) - h0) / s; + if (e > hor) hor = e; + } + S.hz[k * N + p] = hor; + } + } + d[0] = W; + d[1] = H; + d[2] = -1; + d[3] = -1; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 5, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.ridges = round(cl(k[2], 5, 10)); + S.swirl = cl(k[3], -1, 1); + if (!S.begun) { + // Open on a tray half piped. + for (let i = 0; i < 4; i++) pipeNow(S); + S.dirty = [0, 0, W - 1, H - 1]; + S.begun = true; + } + if (input.btnPressed && input.btnPressed[0]) startScrape(S); + step(S, (d * S.speed) / 2); +} + +function palette(S) { + const h = S.hue + H0, hh = h - floor(h); + // Peach, butter and lime go brown or olive when deep: there the foam is + // paled, its shadows lifted, and the tray taken down to near black. + const warm = sm(0.0, 0.07, hh) * (1 - sm(0.24, 0.34, hh)); + hsv(h, 0.3 - 0.1 * warm, 1, S.lit); + hsv(h + 0.03, 0.5 - 0.2 * warm, 0.5 + 0.14 * warm, S.dark); + hsv(h + 0.05, 0.75 - 0.4 * warm, 0.24 - 0.15 * warm, S.tray); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + survey(S); + palette(S); + const { nx, ny, nz, ao, hz, foam, lit, dark, tray } = S; + // Where the light is now, and the half vector to the eye straight above. + const az = AZ0 + SWING * sin((2 * PI * S.t) / SWAY), ce = cos(ELV); + const lx = cos(az) * ce, ly = sin(az) * ce, lz = sin(ELV); + const hl = hypot(lx, ly, lz + 1), hx = lx / hl, hy = ly / hl, hz2 = (lz + 1) / hl; + const u = cl((az - AZ0) / SWING + 1, 0, 1.999), k = floor(u), fk = u - k, o0 = k * N, o1 = o0 + N; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x, f = foam[p]; + // Diffuse from the normal, darkened in valleys and where the foam + // toward the light stands above the light's own slope. + const hor = hz[o0 + p] + (hz[o1 + p] - hz[o0 + p]) * fk; + const shade = 1 - 0.8 * sm(TANE - 0.12, TANE + 0.4, hor); + const nl = max(0, nx[p] * lx + ny[p] * ly + nz[p] * lz); + const l = (0.2 + 0.8 * nl * shade) * ao[p]; + // A faint sheen: matte foam, not frosting. + const nh = max(0, nx[p] * hx + ny[p] * hy + nz[p] * hz2), n2 = nh * nh, n4 = n2 * n2; + const sp = 0.14 * n4 * n4 * n4 * n2 * shade * f; + // Bare tray: its own colour, lit the same way and shadowed the same. + const tl = 0.2 + 0.8 * l; + const r = (dark[0] + (lit[0] - dark[0]) * l) * f + tray[0] * tl * (1 - f) + sp; + const g = (dark[1] + (lit[1] - dark[1]) * l) * f + tray[1] * tl * (1 - f) + sp; + const b = (dark[2] + (lit[2] - dark[2]) * l) * f + tray[2] * tl * (1 - f) + sp; + display.setPixel(x, y, round(255 * min(1, r)), round(255 * min(1, g)), round(255 * min(1, b))); + } +} From 8d572b2a51b1a6feda295065c4d30753a306908a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:23:00 +0000 Subject: [PATCH 5/9] pattern study: Elliptic Chords, Gibbs Phenomenon --- .../pattern-study/patterns/elliptic-chords.js | 507 ++++++++++++++++++ .../patterns/gibbs-phenomenon.js | 276 ++++++++++ 2 files changed, 783 insertions(+) create mode 100644 incoming/pattern-study/patterns/elliptic-chords.js create mode 100644 incoming/pattern-study/patterns/gibbs-phenomenon.js diff --git a/incoming/pattern-study/patterns/elliptic-chords.js b/incoming/pattern-study/patterns/elliptic-chords.js new file mode 100644 index 000000000..396e5a75d --- /dev/null +++ b/incoming/pattern-study/patterns/elliptic-chords.js @@ -0,0 +1,507 @@ +// ===== Patternflow pattern ===== +// Title: Elliptic Chords +// Family: number +// Idea: The points of y^2 = x^3 - 6x + b add up by the chord-and-tangent rule, drawn twice at once: above, the real curve, where the line through G and kG meets the curve a third time and the mirror image of that point in the x-axis is (k+1)G; below, the same curve over the integers mod 61, where a line is 61 dots wrapping round the square and still meets the curve in exactly three of them, and the multiples of a generator light the whole group one dot at a time until the line through G and -G stands vertical and the count comes back to O. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: elliptic curve group law, chord and tangent (Henri Poincaré, 1901), pattern round 36 +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Curve=-4..8, Trail=0..1 +// +// Knob 1: Hue. Turns everything round the wheel. At 0 the plane inside the real curve and the board are deep blue, the curve pale blue, the lines pale peach and the multiples a full rainbow in visiting order, with G white; at 0.5 the plane and board are a dim grey-brown, the curve pale apricot and the lines pale sky. +// Knob 2: Speed. Group steps a second. At 2 one and a half: the line sweeps its 61 dots in a third of a second, the third point drops to its mirror image in 0.15 seconds and the new multiple lands, so the 74 multiples of the opening curve come round in 49 seconds; at 0.2 a step takes 6.7 seconds, at 3 0.44. +// Knob 3: Curve. The constant b, in whole steps. At 0, the opening, y^2 = x^3 - 6x has an egg between -2.45 and 0 beside the long branch, and over F_61 its 74 points are one cycle; at -4 the egg is half as wide and the 72 points mod 61 are not one cycle, so a generator reaches 36 of them, O included, and the rest stay grey; at 4 the same 36 of 72; at 5 the egg reaches to within 0.8 of the branch, 64 points; from 6 to 8 the two have joined into one piece with a neck, 61, 62 and 62 points. +// Knob 4: Trail. Trades the dots for the lines. At 0 a multiple stays lit for the whole cycle, fading over its last 40%, and each line lingers 1.6 steps; at 0.06 almost the same; at 0.5 the dots last 28 steps and the lines 4.4; at 1 only the last two dots stay but each line lingers 8 steps, so the lines through G stand as a fan above and a crowd of dots below. +// Buttons: K1 tap picks a new generator G at random on both halves, and the group starts again from O: the first line is the vertical through G, and the old dots fade out in 1.5 seconds. + +const W = 64, H = 128, P = 61, A = -6, CMIN = -4, CMAX = 8; +// The real window: x from -3.15 to 4.45 over the 64 columns, y from -7.75 to +// 7.75 over rows 0..62, with y = 0 on row 31. Lines stay lines under this +// stretch, and the mirror in the x-axis stays a vertical move. +const TOPH = 63, X0 = -3.15, X1 = 4.45, SX = W / (X1 - X0), YM = 7.75, SY = TOPH / (2 * YM), YC = 31; +// The F_61 board: element (x, y) is column BX + x, row BY - y; O sits on row OROW above it. +const BX = 1, BY = 126, OROW = 64; +// Phases of one group step: the line, the mirror, the landing. +const T_LINE = 0.5, T_MIR0 = 0.55, T_MIR1 = 0.78; +const NT = 1024; +// Group steps a second for each unit of Speed. +const RATE = 0.75; +const { floor, min, max, abs, sqrt, sin, cos, tan, acos, cbrt, round, random, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const md = (v) => ((v % P) + P) % P; +const NB = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const NB2 = [[2, 0], [-2, 0], [0, 2], [0, -2], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + +// Inverses mod 61, by Fermat. +const INV = new Int32Array(P); +for (let i = 1; i < P; i++) { + let r = 1, e = P - 2, q = i; + while (e) { + if (e & 1) r = (r * q) % P; + q = (q * q) % P; + e >>= 1; + } + INV[i] = r; +} + +function hsv(h, s, v, out) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[0] = rgb[0]; + out[1] = rgb[1]; + out[2] = rgb[2]; +} + +// ---- F_61 ------------------------------------------------------------------- +// A point is [x, y] with entries 0..60, or null for O. +function fpAdd(p, q, b) { + if (!p) return q; + if (!q) return p; + if (p[0] === q[0] && md(p[1] + q[1]) === 0) return null; + const m = p[0] === q[0] ? md((3 * p[0] * p[0] + A) * INV[md(2 * p[1])]) : md((q[1] - p[1]) * INV[md(q[0] - p[0])]); + const x = md(m * m - p[0] - q[0]); + return [x, md(m * (p[0] - x) - p[1])]; +} + +function fpOrder(g, b) { + let q = g, n = 1; + while (q && n < 200) { + q = fpAdd(q, g, b); + n++; + } + return n; +} + +// ---- the real curve ----------------------------------------------------------- +// The roots of x^3 - 6x + b, largest first: three when 27 b^2 < 864, so +// when |b| < 5.66, and then the curve has an egg over [e3, e2]. +function roots(b) { + if (4 * A * A * A + 27 * b * b < 0) { + const r = 2 * sqrt(-A / 3), th = acos(((3 * b) / (2 * A)) * sqrt(-3 / A)) / 3; + return [0, 1, 2].map((k) => r * cos(th - (2 * PI * k) / 3)).sort((p, q) => q - p); + } + const s = sqrt((b * b) / 4 + (A * A * A) / 27); + let x = cbrt(-b / 2 + s) + cbrt(-b / 2 - s); + for (let i = 0; i < 6; i++) x -= (x * x * x + A * x + b) / (3 * x * x + A); + return [x]; +} + +// The real points of the curve as a group are a circle, R/wZ, times {0, 1} +// when the egg is there. The circle is measured by the invariant +// differential dx/y: on the long branch u(x) = int_x^inf dt/sqrt(f), with +// t = e1 + tan^2(phi), and on the egg v(x) = int_e3^x dt/sqrt(f), with +// t = e3 + (e2 - e3) sin^2(theta), both free of the square-root ends. Both +// halves of the circle come to the same w/2. Checked against the chord rule +// in a scratch script: main(u1) + main(u2) = main(u1 + u2), main(u) + egg(v) +// = egg(u + v) and egg(v1) + egg(v2) = main(v1 + v2), to 2e-6 in the window. +function realCurve(b) { + const R = roots(b), e1 = R[0], c = e1 * e1 + A; + const U = new Float64Array(NT + 1), h = PI / 2 / NT; + const gm = (ph) => { + if (ph >= PI / 2 - 1e-12) return 2; + const s = tan(ph), t = e1 + s * s, co = cos(ph); + return 2 / sqrt(t * t + e1 * t + c) / (co * co); + }; + for (let i = NT - 1; i >= 0; i--) U[i] = U[i + 1] + (h / 6) * (gm(i * h) + 4 * gm((i + 0.5) * h) + gm((i + 1) * h)); + const C = { b, R, U, w: 2 * U[0], egg: null }; + if (R.length === 3) { + const e2 = R[1], e3 = R[2], V = new Float64Array(NT + 1); + const ge = (th) => 2 / sqrt(e1 - e3 - (e2 - e3) * sin(th) ** 2); + for (let i = 0; i < NT; i++) V[i + 1] = V[i] + (h / 6) * (ge(i * h) + 4 * ge((i + 0.5) * h) + ge((i + 1) * h)); + C.egg = { V, e2, e3 }; + } + return C; +} + +const f = (x, b) => x * x * x + A * x + b; + +// Fractional index where the monotone table T meets the value t. +function seek(T, t, rising) { + let lo = 0, hi = NT; + while (hi - lo > 1) { + const m = (lo + hi) >> 1; + if (T[m] < t === rising) lo = m; + else hi = m; + } + return lo + (t - T[lo]) / (T[hi] - T[lo] || 1); +} + +// The real point at parameter u on the branch (s = 0) or the egg (s = 1); +// null for O. +function realPoint(C, u, s) { + const w = C.w; + u -= floor(u / w) * w; + if (s === 0 && (u < 1e-9 || w - u < 1e-9)) return null; + const up = u <= w / 2, q = up ? u : w - u; + let x; + if (s === 0) { + const ph = (seek(C.U, q, false) * PI) / 2 / NT; + x = C.R[0] + tan(ph) ** 2; + } else { + const { V, e2, e3 } = C.egg, th = (seek(V, q, true) * PI) / 2 / NT; + x = e3 + (e2 - e3) * sin(th) ** 2; + } + const y = sqrt(max(0, f(x, C.b))); + // The egg runs the other way round, so that main(u) + egg(v) = egg(u + v). + return [x, (up ? 1 : -1) * (s === 0 ? 1 : -1) * y]; +} + +// ---- one curve: both halves --------------------------------------------------- +function buildCurve(b) { + const pts = []; + for (let x = 0; x < P; x++) for (let y = 0; y < P; y++) if (md(y * y - f(x, b)) === 0) pts.push([x, y]); + let N = 0, gens = []; + for (const p of pts) { + const o = fpOrder(p, b); + if (o > N) (N = o), (gens = []); + if (o === N) gens.push(p); + } + const C = realCurve(b); + const onBoard = new Uint8Array(P * P); + for (const [x, y] of pts) onBoard[x * P + y] = 1; + // The static picture of the real curve: a 1.4-LED line where y^2 = f(x), + // found by the distance |y^2 - f| / |gradient| in LEDs, and a soft fill on + // the side where f(x) > y^2, the inside of the egg and of the branch. + const line = new Float32Array(W * TOPH), fill = new Float32Array(W * TOPH); + for (let r = 0; r < TOPH; r++) + for (let col = 0; col < W; col++) { + let acc = 0, inside = 0; + for (let sy = 0; sy < 3; sy++) + for (let sx = 0; sx < 3; sx++) { + const x = X0 + (col + (sx + 0.5) / 3) / SX, y = (YC + 0.5 - (r + (sy + 0.5) / 3)) / SY; + const F = y * y - f(x, b), gx = (3 * x * x + A) / SX, gy = (2 * y) / SY; + const d = abs(F) / (sqrt(gx * gx + gy * gy) + 1e-9); + acc += cl(1.15 - d, 0, 1); + if (F < 0) inside++; + } + line[r * W + col] = acc / 9; + fill[r * W + col] = inside / 9; + } + return { b, pts, N, gens, C, onBoard, line, fill }; +} + +// ---- the cycle ---------------------------------------------------------------- +// Q_k = kG. Step k goes from Q_k to Q_k+1 = Q_k + G: the line through G and +// Q_k (the tangent at G when k = 1, the vertical through G when Q_k is O or +// -G), its third point R, and R mirrored. The real generator is a point of +// the same order N: on the egg at parameter jw/N when there is an egg and N +// is even, so the odd multiples land on the egg and the even ones on the +// branch, otherwise on the branch. +function startCycle(S, fresh) { + const K = S.curve, N = K.N, C = K.C; + S.g = K.gens[floor(random() * K.gens.length)]; + S.side = C.egg && N % 2 === 0 ? 1 : 0; + const js = []; + for (let j = 1; j < N; j++) { + let a = j, bb = N; + while (bb) [a, bb] = [bb, a % bb]; + if (a !== 1) continue; + const g = realPoint(C, (j * C.w) / N, S.side); + if (g && g[0] > X0 + 0.3 && g[0] < X1 - 0.3 && abs(g[1]) < YM - 0.4) js.push(j); + } + S.j = js.length ? js[floor(random() * js.length)] : 1; + S.gr = realPoint(C, (S.j * C.w) / N, S.side); + for (const t of S.trail) t.ghost = t.ghost || 1e-6; + if (fresh) { + // The opening shows the cycle already under way: 80% of it lit. + const k0 = round(0.8 * N); + let q = null; + for (let k = 1; k <= k0; k++) { + q = fpAdd(q, S.g, K.b); + S.trail.push({ k, gs: S.gs - (k0 - k), fp: q, re: realK(S, k), ghost: 0 }); + } + S.k = k0; + S.q = q; + } else { + S.k = 0; + S.q = null; + } + planStep(S); +} + +const realK = (S, k) => realPoint(S.curve.C, (k * S.j * S.curve.C.w) / S.curve.N, (k * S.side) & 1); + +// The F_61 line y = mx + c is the lattice of points (t, mt) mod 61 shifted +// to one of them. Its shortest vector, found by Lagrange's reduction of the +// basis (1, m), (0, 61), is never more than 8.4 long, and stepping by it still +// visits all 61 points, so the walk runs along short streaks that wrap round +// the square instead of jumping m rows at a time. +function shortStep(m) { + let ax = 1, ay = m > P / 2 ? m - P : m, bx = 0, by = P; + const n2 = (x, y) => x * x + y * y; + for (let i = 0; i < 20; i++) { + if (n2(bx, by) < n2(ax, ay)) [ax, ay, bx, by] = [bx, by, ax, ay]; + const mu = round((ax * bx + ay * by) / n2(ax, ay)); + if (mu === 0) break; + bx -= mu * ax; + by -= mu * ay; + } + return [md(ax), md(ay)]; +} + +function planStep(S) { + const K = S.curve, N = K.N, k = S.k, g = S.g, q = S.q; + const st = { k, vert: false, m: 0, start: q || g, third: null, next: null, rm: 0, rx: 0, ry: 0, rv: false, r3: null, rn: null, tR: -1 }; + // F_61: the line's slope, its 61 dots from Q_k (from G when Q_k is O), and + // how many steps along it the third point falls. + if (!q || (q[0] === g[0] && md(q[1] + g[1]) === 0)) st.vert = true; + else st.m = q[0] === g[0] ? md((3 * g[0] * g[0] + A) * INV[md(2 * g[1])]) : md((q[1] - g[1]) * INV[md(q[0] - g[0])]); + st.next = fpAdd(q, g, K.b); + st.third = st.next ? [st.next[0], md(-st.next[1])] : null; + [st.dx, st.dy] = st.vert ? [0, 1] : shortStep(st.m); + const s0 = st.start, stepT = (pt) => (st.vert ? md(pt[1] - s0[1]) : md((pt[0] - s0[0]) * INV[st.dx])); + st.tR = st.third ? stepT(st.third) : P - 1; + // The real line, through G_r with slope rm or vertical. + const G = S.gr, Q = k === 0 ? null : realK(S, k); + if (k === 0 || k === N - 1) st.rv = true; + else if (k === 1) st.rm = (3 * G[0] * G[0] + A) / (2 * G[1]); + else if (Q) st.rm = (Q[1] - G[1]) / (Q[0] - G[0]); + else st.rm = 0; + st.rx = G[0]; + st.ry = G[1]; + if (k === N - 1) st.r3 = null; + else if (k === 0) st.r3 = [G[0], -G[1]]; + else { + const x3 = st.rm * st.rm - G[0] - (Q ? Q[0] : G[0]); + st.r3 = [x3, G[1] + st.rm * (x3 - G[0])]; + } + st.rn = st.r3 ? [st.r3[0], -st.r3[1]] : null; + S.step = st; +} + +function finishStep(S) { + const st = S.step, N = S.curve.N; + S.past.push({ st, gs: S.gs + 1 }); + S.gs++; + S.k = (S.k + 1) % N; + S.q = st.next; + if (st.next) S.trail.push({ k: S.k, gs: S.gs, fp: st.next, re: st.rn, ghost: 0 }); + planStep(S); +} + +export function setup(params) { + params.s = { + hue: 0, speed: 2, trailK: 0.06, b: null, curve: null, curves: {}, g: null, gr: null, j: 1, side: 0, + k: 0, q: null, gs: 0, tau: 0, step: null, trail: [], past: [], + R: new Float32Array(W * H), G: new Float32Array(W * H), B: new Float32Array(W * H), c: [0, 0, 0], + }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, kv = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = kv[0]; + S.speed = cl(kv[1], 0.2, 3); + S.trailK = cl(kv[3], 0, 1); + const b = round(cl(kv[2], CMIN, CMAX)); + if (b !== S.b) { + S.b = b; + S.curve = S.curves[b] || (S.curves[b] = buildCurve(b)); + S.trail.length = 0; + S.past.length = 0; + S.tau = 0; + startCycle(S, true); + } + if (input.btnPressed && input.btnPressed[0]) { + S.tau = 0; + startCycle(S, false); + } + S.tau += d * S.speed * RATE; + while (S.tau >= 1) { + S.tau -= 1; + finishStep(S); + } + // Ghosts of an old cycle fade out over 1.5 seconds. + for (const t of S.trail) if (t.ghost) t.ghost += d / 1.5; + const L = trailLen(S); + S.trail = S.trail.filter((t) => (t.ghost ? t.ghost < 1 : S.gs + S.tau - t.gs < L + 1)); + S.past = S.past.filter((q) => S.gs + S.tau - q.gs < linger(S)); +} + +// Past lines linger, fading: 1.6 steps at Trail 0, 8 steps at 1. They all +// pass through G. +const linger = (S) => 1.6 + 6.4 * S.trailK ** 1.2; + +const trailLen = (S) => 2 + (S.curve.N - 2) * (1 - S.trailK) ** 1.5; + +// ---- drawing ------------------------------------------------------------------ +function add(S, x, y, col, v) { + if (x < 0 || x >= W || y < 0 || y >= H || v <= 0) return; + const p = y * W + x; + S.R[p] += col[0] * v; + S.G[p] += col[1] * v; + S.B[p] += col[2] * v; +} + +// Real coordinates to LED coordinates (LED centres at whole numbers). +const px = (x) => (x - X0) * SX - 0.5; +const py = (y) => YC - y * SY; + +function disc(S, x, y, rad, col, v) { + const cx = px(x), cy = py(y); + for (let r = floor(cy - rad - 1); r <= cy + rad + 1; r++) + for (let c = floor(cx - rad - 1); c <= cx + rad + 1; c++) { + if (r < 0 || r >= TOPH) continue; + const dd = sqrt((c - cx) ** 2 + (r - cy) ** 2); + add(S, c, r, col, v * cl(rad + 0.5 - dd, 0, 1)); + } +} + +// The real line through (x0, y0) with slope m, or the vertical through x0, +// clipped to the window: one LED wide with a soft LED of glow either side. +function realLine(S, x0, y0, m, vert, col, v) { + const k = vert ? 0 : (-m * SY) / SX, ax = px(x0), ay = py(y0); + if (!vert && abs(k) <= 1) { + // Shallow: walk the columns, spread across rows. + for (let c = 0; c < W; c++) { + const r = ay + k * (c - ax), r0 = floor(r); + for (let q = r0 - 1; q <= r0 + 2; q++) if (q >= 0 && q < TOPH) add(S, c, q, col, v * glow(abs(q - r))); + } + } else { + for (let r = 0; r < TOPH; r++) { + const c = vert ? ax : ax + (r - ay) / k, c0 = floor(c); + for (let q = c0 - 1; q <= c0 + 2; q++) add(S, q, r, col, v * glow(abs(q - c))); + } + } +} +const glow = (d) => (d < 1 ? 1 - 0.7 * d : d < 2 ? 0.3 * (2 - d) : 0); + +const inWin = (p) => p && p[0] > X0 - 0.2 && p[0] < X1 + 0.2 && abs(p[1]) < YM + 0.3; + +export function draw(display, params) { + const S = params.s; + if (!S || !S.curve) return; + const { R, G, B, c } = S, K = S.curve, N = K.N, st = S.step, tau = S.tau; + R.fill(0); + G.fill(0); + B.fill(0); + const h0 = S.hue + 0.6; + // The plane inside the real curve and the board are dim tints. + const plane = [0, 0, 0], ink = [0, 0, 0], board = [0, 0, 0], chord = [0, 0, 0], white = [1, 1, 1], base = [0, 0, 0]; + // Round orange and yellow the dim tints lose most of their colour, or + // they would read brown and olive. + const hw = h0 + 0.03 - floor(h0 + 0.03), warm = cl(1 - abs(hw - 0.12) / 0.1, 0, 1); + hsv(h0, 0.75 - 0.5 * warm, 0.16, plane); + hsv(h0 - 0.02, 0.32, 0.92, ink); + hsv(h0 + 0.03, 0.6 - 0.4 * warm, 0.115, board); + hsv(h0 + 0.5, 0.42, 1, chord); + hsv(h0, 0.2, 0.34, base); + for (let r = 0; r < TOPH; r++) + for (let col = 0; col < W; col++) { + const p = r * W + col, ln = K.line[p], fl = K.fill[p]; + R[p] = (plane[0] * fl + ink[0] * ln) * 255; + G[p] = (plane[1] * fl + ink[1] * ln) * 255; + B[p] = (plane[2] * fl + ink[2] * ln) * 255; + } + for (let y = 0; y < P; y++) + for (let x = 0; x < P; x++) { + const p = (BY - y) * W + BX + x; + R[p] = board[0] * 255; + G[p] = board[1] * 255; + B[p] = board[2] * 255; + } + for (const [x, y] of K.pts) add(S, BX + x, BY - y, base, 255); + + // The lines of the last few steps, fading: a pencil of lines through G. + for (const q of S.past) { + const w = 1 - (S.gs + tau - q.gs) / linger(S); + if (w <= 0) continue; + const ps = q.st, s0 = ps.start; + realLine(S, ps.rx, ps.ry, ps.rm, ps.rv, chord, 130 * w); + for (let t = 0; t < P; t++) { + const x = md(s0[0] + ps.dx * t), y = md(s0[1] + ps.dy * t); + add(S, BX + x, BY - y, chord, 55 * w); + } + } + + // The trail: each multiple in its order's hue, the whole cycle once round + // the wheel, held for most of the trail and then let go. + const L = trailLen(S), now = S.gs + tau; + for (const t of S.trail) { + const age = now - t.gs; + let v = age < 0.6 * L ? 1 : cl((L - age) / (0.4 * L), 0, 1); + if (t.ghost) v *= cl(1 - t.ghost, 0, 1); + if (v <= 0.01) continue; + hsv(h0 + 0.42 + t.k / N, 0.88, 1, c); + add(S, BX + t.fp[0], BY - t.fp[1], c, 255 * v); + if (inWin(t.re)) disc(S, t.re[0], t.re[1], 1.5, c, 235 * v); + for (const [dx, dy] of NB) add(S, BX + t.fp[0] + dx, BY - t.fp[1] + dy, c, 95 * v); + // A fresh landing blooms for a step and a half. + const bl = 1 - age / 1.5; + if (bl > 0 && !t.ghost) { + for (const [dx, dy] of NB2) add(S, BX + t.fp[0] + dx, BY - t.fp[1] + dy, c, 110 * bl); + if (inWin(t.re)) disc(S, t.re[0], t.re[1], 2.6, c, 90 * bl); + } + } + + // The step under way. + const lineV = tau < 0.08 ? tau / 0.08 : tau > 0.88 ? (1 - tau) / 0.12 : 1; + // Real: the line, the third point, the mirror, the new point. + realLine(S, st.rx, st.ry, st.rm, st.rv, chord, 190 * lineV); + const mir = cl((tau - T_MIR0) / (T_MIR1 - T_MIR0), 0, 1); + if (tau > T_LINE * 0.9) { + if (st.r3 && inWin(st.r3)) disc(S, st.r3[0], st.r3[1], 1.3, white, 200 * lineV); + if (!st.r3) for (let r = 0; r < 2; r++) add(S, round(px(st.rx)), r, white, 255 * lineV); + } + if (mir > 0 && st.r3) { + const ya = py(st.r3[1]), yb = py(st.rn[1]), cc = round(px(st.r3[0])); + const yt = ya + (yb - ya) * mir; + for (let r = max(0, round(min(ya, yt))); r <= min(TOPH - 1, round(max(ya, yt))); r++) if (r % 2 === 0) add(S, cc, r, white, 150 * lineV); + } + if (tau > T_MIR1 && st.rn && inWin(st.rn)) { + hsv(h0 + 0.42 + ((st.k + 1) % N) / N, 0.6, 1, c); + disc(S, st.rn[0], st.rn[1], 1.5, c, 255); + } + // F_61: the line's dots from Q_k, the head brightest, then the mirror. + const head = (min(tau, T_LINE) / T_LINE) * (P - 1), s0 = st.start; + for (let t = 0; t <= head; t++) { + const x = md(s0[0] + st.dx * t), y = md(s0[1] + st.dy * t); + const on = K.onBoard[x * P + y]; + const v = head - t < 1 ? 1 : 0.85 + (on ? 0.15 : 0); + add(S, BX + x, BY - y, on ? white : chord, 230 * v * lineV); + } + if (head >= st.tR) { + if (st.third) { + const x = BX + st.third[0], y = BY - st.third[1]; + for (const [dx, dy] of NB) add(S, x + dx, y + dy, white, 150 * lineV); + } else for (let r = OROW - 1; r <= OROW; r++) add(S, BX + st.start[0], r, white, 255 * lineV); + } + if (mir > 0 && st.third) { + const x = BX + st.third[0], ya = BY - st.third[1], yb = BY - st.next[1], yt = ya + (yb - ya) * mir; + for (let r = round(min(ya, yt)); r <= round(max(ya, yt)); r++) if (r % 2 === 0) add(S, x, r, white, 120 * lineV); + } + if (tau > T_MIR1 && st.next) { + hsv(h0 + 0.42 + ((st.k + 1) % N) / N, 0.5, 1, c); + const x = BX + st.next[0], y = BY - st.next[1]; + add(S, x, y, c, 255); + for (const [dx, dy] of NB) add(S, x + dx, y + dy, c, 140); + } + // G on both halves: white, with a dark ring round it on the real curve. + const gx = BX + S.g[0], gy = BY - S.g[1]; + add(S, gx, gy, white, 255); + for (const [dx, dy] of NB) add(S, gx + dx, gy + dy, white, 120); + if (inWin(S.gr)) { + const cx = px(S.gr[0]), cy = py(S.gr[1]); + for (let r = floor(cy - 3); r <= cy + 3; r++) + for (let q = floor(cx - 3); q <= cx + 3; q++) { + if (r < 0 || r >= TOPH || q < 0 || q >= W) continue; + const dd = sqrt((q - cx) ** 2 + (r - cy) ** 2), p = r * W + q; + if (dd < 2.6) { + const k = dd < 1.4 ? 1 : 0.15; + R[p] = 255 * k; + G[p] = 255 * k; + B[p] = 255 * k; + } + } + } + + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + display.setPixel(x, y, round(min(255, R[p])), round(min(255, G[p])), round(min(255, B[p]))); + } +} diff --git a/incoming/pattern-study/patterns/gibbs-phenomenon.js b/incoming/pattern-study/patterns/gibbs-phenomenon.js new file mode 100644 index 000000000..003e66783 --- /dev/null +++ b/incoming/pattern-study/patterns/gibbs-phenomenon.js @@ -0,0 +1,276 @@ +// ===== Patternflow pattern ===== +// Title: Gibbs Phenomenon +// Family: wave +// Idea: One period of a square wave runs across the panel and its Fourier partial sums run down it, from one term at the top to 32 at the bottom, and wherever a sum climbs past the wave it is trying to draw it burns white-gold: two horns beside each jump that press closer to the jump with every row and never get any lower, 8.95% of the jump however many terms are added. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: Gibbs phenomenon (Henry Wilbraham, 1848; J. Willard Gibbs, 1899), with Fejér's means (Lipót Fejér, 1900), pattern round 36 +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Shape=0..1, Fejer=0..1 +// +// Knob 1: Hue. Turns the two halves round the wheel together; the horns stay white-gold. At 0 the positive half is azure and the negative half violet; at 0.25 violet and amber, at 0.5 amber and green, at 0.75 green and azure. A half that comes round to orange or yellow is lifted up to 1.8 times and paled a little, so it does not go brown or olive. +// Knob 2: Speed. How fast the wave slides sideways. At 2 one period passes in 16 seconds, four LEDs a second; at 0.2 in 157 seconds, at 3 in 10.5. The rows hold still: row r always sums 32^(r/127) terms, so the count doubles every 25 rows. +// Knob 3: Shape. Square wave at 0, sawtooth at 0.5, triangle at 1, and blends between. At 0 two jumps and four horns; on the way to 0.5 the upward jump smooths into the sawtooth's ramp and its two horns fade, leaving the downward jump and its two; past 0.5 that jump shrinks too, its horns always 8.95% of what is left of it, until the triangle at 1 has no jump and no horn. +// Knob 4: Fejer. Blends each partial sum toward Fejér's mean, the average of all the partial sums up to it. Up to 0.06 the plain sums, horns 8.95% of the jump; at 0.25 8.1%; at 0.5 4.6%; from 0.75 none; at 1 the means alone, which never leave the range of the wave, and the fringes beside the jumps are gone too. +// Buttons: K1 tap races the bottom row from 32 terms to 256 and back over six seconds, the rows above scaling with it, and draws the bottom row's sum as a white graph over the top 48 rows with gold rulers 8.95% of the jump past the wave: the horns press into the jumps and their spikes keep touching the rulers. + +const W = 64, H = 128, NMAX = 32, RACE = 8; +// Subsamples per column: four, and 24 in a column within one LED of a jump. +const SS = 4, SSJ = 24; +// How far either side of a point the wave's own range is read, in radians: +// a sum is past the wave where it leaves [min, max] of the wave over x +- DEL. +const DEL = 0.12; +// The limit of the overshoot: (1/pi) int_0^pi sin t / t dt - 1/2 of the jump. +const GIBBS = 0.0894899; +// The graph the button draws: value v at row GY - GS v. +const GY = 24, GS = 16; +const { floor, min, max, abs, sin, cos, round, PI } = Math; +const TAU = 2 * PI; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +function hsv(h, s, v, out) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[0] = rgb[0]; + out[1] = rgb[1]; + out[2] = rgb[2]; +} + +// The three waves on [0, 2 pi), all between -1 and 1. The square is 1 on +// (0, pi) and -1 on (pi, 2 pi); the sawtooth rises as x / pi from -1 just past +// pi to 1 just before it; the triangle peaks at 1 at pi/2. +const sq = (x) => (x < PI ? 1 : -1); +const saw = (x) => (x < PI ? x / PI : x / PI - 2); +const tri = (x) => (x < PI / 2 ? (2 * x) / PI : x < (3 * PI) / 2 ? 2 - (2 * x) / PI : (2 * x) / PI - 4); +// Their sine coefficients: 4 / (pi k) on odd k; 2 (-1)^(k+1) / (pi k); +// 8 (-1)^((k-1)/2) / (pi k)^2 on odd k. +const bSq = (k) => (k & 1 ? 4 / (PI * k) : 0); +const bSaw = (k) => ((k & 1 ? 2 : -2) / (PI * k)); +const bTri = (k) => (k & 1 ? ((k & 2 ? -8 : 8) / (PI * PI * k * k)) : 0); + +// Shape 0..1 as weights on square, sawtooth and triangle. +function weights(s) { + return s < 0.5 ? [1 - 2 * s, 2 * s, 0] : [0, 2 - 2 * s, 2 * s - 1]; +} + +const wave = (x, w) => w[0] * sq(x) + w[1] * saw(x) + w[2] * tri(x); + +// The wave's range over [x - DEL, x + DEL]: the ends, and both sides of any +// jump or corner inside. +const SPECIAL = [0, PI / 2, PI, (3 * PI) / 2, TAU]; +const waveAt = (t, w) => wave(t - floor(t / TAU) * TAU, w); +function localRange(x, w, out) { + let lo = waveAt(x - DEL, w), hi = lo, v = waveAt(x + DEL, w); + if (v < lo) lo = v; + if (v > hi) hi = v; + for (let n = -1; n <= 1; n++) + for (let j = 0; j < SPECIAL.length; j++) { + const t = SPECIAL[j] + n * TAU; + if (t <= x - DEL || t >= x + DEL) continue; + for (const u of [t - 1e-9, t + 1e-9]) { + v = waveAt(u, w); + if (v < lo) lo = v; + if (v > hi) hi = v; + } + } + out[0] = lo; + out[1] = hi; +} + +export function setup(params) { + const NS = W * SSJ; + params.s = { + hue: 0, speed: 2, shape: 0, fejer: 0, phase: 0, race: -1, bo: new Float64Array(RACE * NMAX + 2), bs: new Float64Array(RACE * NMAX + 2), + sx: new Float64Array(NS), sl: new Float64Array(NS), sh: new Float64Array(NS), + sum: new Float32Array(H), ex: new Float32Array(H), gmin: new Float32Array(W), gmax: new Float32Array(W), rk: new Float64Array(H), + R: new Float32Array(W * H), G: new Float32Array(W * H), B: new Float32Array(W * H), rr: [0, 0], + }; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.shape = cl(k[2], 0, 1); + // Up to 0.06 the plain sums, so the opening shows the full 8.95%. + S.fejer = (max(0, cl(k[3], 0, 1) - 0.06) / 0.94) ** 2; + // At Speed 2 the wave slides 0.4 radians a second. + S.phase = (S.phase + d * S.speed * 0.2) % TAU; + if (input.btnPressed && input.btnPressed[0] && S.race < 0) S.race = 0; + if (S.race >= 0) { + S.race += d; + if (S.race > 6) S.race = -1; + } +} + +// The race: the bottom row's count of terms is multiplied by up to 8, rising +// over 2.5 seconds, holding for 2 and coming back in 1.5. +function raceFactor(t) { + if (t < 0) return 1; + const u = t < 2.5 ? t / 2.5 : t < 4.5 ? 1 : max(0, (6 - t) / 1.5); + const e = u * u * (3 - 2 * u); + return RACE ** e; +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { R, G, B, sx, sl, sh, sum, ex, rk, rr } = S; + const w = weights(S.shape), lam = S.fejer, nTop = NMAX * raceFactor(S.race); + const NN = Math.ceil(nTop); + // Row r sums the first n = 32^(r / 127) terms of each series, so the count + // doubles every 25 rows, continuous in n: between n and n + 1 the next + // term comes in by the fraction. The square and the triangle have only odd + // harmonics, so their n-th term is harmonic 2n - 1; the sawtooth's is n. + for (let r = 0; r < H; r++) rk[r] = nTop ** (r / (H - 1)); + const { bo, bs } = S; + for (let n = 1; n <= NN; n++) { + bo[n] = w[0] * bSq(2 * n - 1) + w[2] * bTri(2 * n - 1); + bs[n] = w[1] * bSaw(n); + } + // The jumps of the wave, where it has any, set where the columns go fine. + const jumpAt = []; + if (w[0] > 0.001) jumpAt.push(0); + if (w[0] + w[1] > 0.001) jumpAt.push(PI); + // The overshoot the rulers stand at: the larger jump, the one at pi, is + // 2 (w0 + w1) tall, from -(w0 + w1) to w0 + w1. + const top = w[0] + w[1], ruler = top * (1 + 2 * GIBBS); + + const cp = [0, 0, 0], cn = [0, 0, 0], gold = [1, 0.85, 0.52]; + // Round orange and yellow a half is lifted, up to 1.8 times, and paled a + // little, so it reads amber or butter and not brown or olive. + for (const [h, out] of [[S.hue + 0.56, cp], [S.hue + 0.8, cn]]) { + const hh = h - floor(h), warm = cl(1 - abs(hh - 0.11) / 0.1, 0, 1); + hsv(h, 0.85 - 0.25 * warm, 1 + 0.8 * warm, out); + } + const graph = S.race >= 0; + for (let col = 0; col < W; col++) { + // Wave coordinate of the column's left edge. + const x0 = (col / W) * TAU - S.phase; + let near = false; + for (const j of jumpAt) { + const dj = ((((x0 + PI / W - j) % TAU) + TAU + PI) % TAU) - PI; + if (abs(dj) < (1.6 * TAU) / W) near = true; + } + const ns = near ? SSJ : SS; + let fbar = 0; + for (let i = 0; i < ns; i++) { + let x = x0 + ((i + 0.5) / ns) * (TAU / W); + x -= floor(x / TAU) * TAU; + sx[i] = x; + fbar += wave(x, w) / ns; + localRange(x, w, rr); + sl[i] = rr[0]; + sh[i] = rr[1]; + } + sum.fill(0); + ex.fill(0); + let gmin = Infinity, gmax = -Infinity; + for (let i = 0; i < ns; i++) { + const x = sx[i], c1 = 2 * cos(x), c2 = 2 * cos(2 * x), lo = sl[i], hi = sh[i]; + // sin(nx) and sin((2n - 1)x) by the Chebyshev recurrence. + let a0 = 0, a1 = sin(x), o0 = -sin(x), o1 = sin(x); + // Plain sums and Fejer means of the odd series and of the sawtooth. + // Fejer averages the partial sums over the harmonic index: S_0..S_K + // over K + 1, so the odd series adds its unchanged even-index sum too. + let So = 0, To = 0, fo = 0, Ss = 0, Ts = 0, fs = 0, r = 0, last = 0; + for (let n = 1; n <= NN && r < H; n++) { + const So0 = So, fo0 = fo, Ss0 = Ss, fs0 = fs; + To += So; + So += bo[n] * o1; + To += So; + fo = To / (2 * n); + Ss += bs[n] * a1; + Ts += Ss; + fs = Ts / (n + 1); + while (r < H && rk[r] <= n + 1e-9) { + const q = rk[r] - (n - 1); + const plain = So0 + q * (So - So0) + Ss0 + q * (Ss - Ss0), mean = fo0 + q * (fo - fo0) + fs0 + q * (fs - fs0); + // Past the wave: above its local top where the sum is positive, below + // its local bottom where negative. Falling short is not overshoot. + const v = (1 - lam) * plain + lam * mean, e = v > 0 ? v - hi : lo - v; + sum[r] += v; + if (e > ex[r]) ex[r] = e; + if (r === H - 1) last = v; + r++; + } + const a2 = c1 * a1 - a0; + a0 = a1; + a1 = a2; + const o2 = c2 * o1 - o0; + o0 = o1; + o1 = o2; + } + // The bottom row's sum, for the graph. + if (last < gmin) gmin = last; + if (last > gmax) gmax = last; + } + S.gmin[col] = gmin; + S.gmax[col] = gmax; + for (let r = 0; r < H; r++) { + const v = sum[r] / ns, c = v >= 0 ? cp : cn; + // The wave's own halves: 0.1 plus 0.32 times the size of the sum, and + // 1.8 times how far the sum is past the wave (outward) or short of it, + // so the fringes beside each jump show as light and dark bands. + const lv = cl(0.1 + 0.32 * min(1, abs(v)) + 1.8 * (v >= 0 ? v - fbar : fbar - v), 0, 0.8); + // Past the wave: white-gold, full from about 80% of the overshoot's + // limit, so the horns burn and the ripples of the first rows glow. + const g = sm(0.25, 0.85, ex[r] / (2 * GIBBS)), p = r * W + col; + R[p] = 255 * (c[0] * lv * (1 - g) + gold[0] * g); + G[p] = 255 * (c[1] * lv * (1 - g) + gold[1] * g); + B[p] = 255 * (c[2] * lv * (1 - g) + gold[2] * g); + } + } + + if (graph) { + // The bottom row's sum as a graph over the top of the panel, with dim + // rulers at the wave's top and bottom and gold ones 8.95% of the jump + // past them. + const t = S.race, fade = min(1, t / 0.3, (6 - t) / 0.5); + const rows = GY + GS * 1.45; + for (let r = 0; r <= rows; r++) + for (let col = 0; col < W; col++) { + const p = r * W + col, k = 1 - 0.8 * fade; + R[p] *= k; + G[p] *= k; + B[p] *= k; + } + const rule = (v, col3, lv) => { + const r = round(GY - GS * v); + for (let x = 0; x < W; x += 2) { + const p = r * W + x; + R[p] += col3[0] * lv * fade; + G[p] += col3[1] * lv * fade; + B[p] += col3[2] * lv * fade; + } + }; + rule(top, [1, 1, 1], 70); + rule(-top, [1, 1, 1], 70); + rule(ruler, gold, 220); + rule(-ruler, gold, 220); + for (let col = 0; col < W; col++) { + const ra = round(GY - GS * S.gmax[col]), rb = round(GY - GS * S.gmin[col]); + for (let r = max(0, ra); r <= min(H - 1, rb); r++) { + const p = r * W + col; + R[p] = 255 * fade + R[p] * (1 - fade); + G[p] = 255 * fade + G[p] * (1 - fade); + B[p] = 255 * fade + B[p] * (1 - fade); + } + } + } + + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + display.setPixel(x, y, round(min(255, R[p])), round(min(255, G[p])), round(min(255, B[p]))); + } +} From dd377bf870d8a6133fd7144bfc315be2a637359a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:46:14 +0000 Subject: [PATCH 6/9] pattern study: Shimmer Oil, Blooming Gel --- .../pattern-study/patterns/blooming-gel.js | 409 ++++++++++++++++ .../pattern-study/patterns/shimmer-oil.js | 462 ++++++++++++++++++ 2 files changed, 871 insertions(+) create mode 100644 incoming/pattern-study/patterns/blooming-gel.js create mode 100644 incoming/pattern-study/patterns/shimmer-oil.js diff --git a/incoming/pattern-study/patterns/blooming-gel.js b/incoming/pattern-study/patterns/blooming-gel.js new file mode 100644 index 000000000..ce6314891 --- /dev/null +++ b/incoming/pattern-study/patterns/blooming-gel.js @@ -0,0 +1,409 @@ +// ===== Patternflow pattern ===== +// Title: Blooming Gel +// Family: cosmetic +// Idea: Drops of tinted gel polish land one by one on a wet blooming base and spread into soft flowers with feathered edges, their pigment swept ahead of the spreading gel into a deeper rim like a watercolour back-run, until a cure lamp passes down the panel and sets the design and the nail is wiped for a fresh base. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 36, after blooming gel nail art, with the drop's gel spreading by the porous medium equation and its Barenblatt fronts (G. I. Barenblatt, 1952) and its pigment run ahead to the front like a watercolour back-run +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Bloom=0..1, Rim=0..1 +// +// Knob 1: Hue. Turns the base and both tints once round the wheel; white stays white. At 0 cobalt and lilac drops on a pale lilac base; at 0.1 violet and orchid; at 0.25 raspberry and coral on rose; at 0.35 brick red and peach; at 0.42 orange and yellow on cream; at 0.5 yellow and lime; at 0.6 lime and green; at 0.75 jade and aqua on mint; at 0.9 azure and cobalt. From orange to lime the tints stay near full brightness, so a thick rim deepens toward red or orange instead of going brown or olive, and the base pales to cream. +// Knob 2: Speed. The clock. At 2 a drop lands every 0.35 to 0.8 seconds for 6.2 seconds, the cure lamp passes down the panel in 1.4 seconds, the design rests 1.2 seconds and the wipe takes 1.2, a round every 10.3 seconds; at 0.2 all of it runs ten times slower, at 3 half as fast again. +// Knob 3: Bloom. How big a drop is and how freely its gel spreads. At 0 a drop 12 LEDs across blooms to about 25 across in two seconds; at 0.5 one 15 across blooms to 34; at 1 one 18 across blooms to 38, and the blooms crowd into each other. +// Knob 4: Rim. How far ahead of the gel the pigment runs where the gel thins toward its front. At 0 the outer 4 LEDs of a bloom hold about 1.7 times the pigment of its middle, a soft deeper edge; at 0.06 about twice; at 0.5 about ten times; at 1 some seventy times, nearly all of it in a dark ring with the middle gone pale, a coffee ring. +// Buttons: K1 tap lands a drop now; once the cure lamp has started, the nail is wiped to a fresh base first. + +const W = 64, H = 128, N = W * H; +const { floor, min, max, sqrt, sin, cos, exp, random, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +function hsv(h, s, v, out) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[0] = rgb[0]; + out[1] = rgb[1]; + out[2] = rgb[2]; +} + +// The gel lives on a coarse grid of CW x CH cells, 2 LEDs each; its +// pigment on the LEDs themselves. +const CW = W / 2, CH = H / 2, CN = CW * CH; +// Tiles of 8 x 8 LEDs: only the wet ones and their neighbours are worked. +const TS = 8, TW = W / TS, TH = H / TS; +// Pigments: two tints and white. +const NK = 3; +// The drop's gel spreads as w_t = div(D w grad w), the porous medium +// equation, whose fronts move at a finite speed; D in LED^2 a second at +// Speed 2. The gel soaks into the base, losing SOAK of itself a second, and +// pigment moves only where the gel is thicker than WET. +const SOAK = 0.06, WET = 0.006; +// One painting round in seconds of nail time: drops land until PAINT, the +// cure lamp passes from CURE to HOLD, the design rests, and the wipe runs +// from WIPE to ROUND. +const PAINT = 6.2, CURE = 6.5, HOLD = 7.9, WIPE = 9.1, ROUND = 10.3; + +export function setup(params) { + const S = { + hue: 0, speed: 2, bloom: 0, rim: 0.06, t: 0, next: 0, first: true, gmob: 1, + g: new Float32Array(CN), dg: new Float32Array(CN), Gx: new Float32Array(CN), Gy: new Float32Array(CN), set: 0, lamp: -99, + w: new Float32Array(N), wt: new Float32Array(N), Mx: new Float32Array(N), My: new Float32Array(N), + p: [new Float32Array(N), new Float32Array(N), new Float32Array(N)], dp: [new Float32Array(N), new Float32Array(N), new Float32Array(N)], + tile: new Uint8Array(TW * TH), act: new Uint8Array(TW * TH), + c: [0, 0, 0], col: [new Float32Array(3), new Float32Array(3)], base: [0, 0, 0], + lastKind: 0, lastX: 32, lastY: 64, + }; + freshBase(S); + params.s = S; +} + +// Smooth value noise on a grid of cells LEDs, in -1..1, for a w x h field. +function noise(cells, w, h) { + const gw = Math.ceil(w / cells) + 2, gh = Math.ceil(h / cells) + 2, g = new Float32Array(gw * gh), out = new Float32Array(w * h); + for (let i = 0; i < g.length; i++) g[i] = random() * 2 - 1; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const u = x / cells, v = y / cells, i = floor(u), j = floor(v), fu = u - i, fv = v - j; + const su = fu * fu * (3 - 2 * fu), sv = fv * fv * (3 - 2 * fv); + const a = g[j * gw + i], b = g[j * gw + i + 1], c = g[(j + 1) * gw + i], d = g[(j + 1) * gw + i + 1]; + out[y * w + x] = (a + (b - a) * su) * (1 - sv) + (c + (d - c) * su) * sv; + } + return out; +} + +// A fresh blooming base: clear, and unevenly wet, which is what feathers +// the blooms. The gel's mobility varies up to sixfold over 6 to 12 LEDs, +// which bends the fronts into lobes; the pigment's more over 2 to 5 LEDs, +// which mottles the colour inside a bloom. +function freshBase(S) { + const a = noise(3, CW, CH), b = noise(1.5, CW, CH), m = new Float32Array(CN); + let mm = 0; + for (let i = 0; i < CN; i++) mm = max(mm, (m[i] = exp(0.6 * a[i] + 0.3 * b[i]))); + S.gmob = mm; + for (let y = 0; y < CH; y++) + for (let x = 0; x < CW; x++) { + const i = y * CW + x; + S.Gx[i] = x < CW - 1 ? 0.5 * (m[i] + m[i + 1]) : 0; + S.Gy[i] = y < CH - 1 ? 0.5 * (m[i] + m[i + CW]) : 0; + } + const c = noise(2.5, W, H), d = noise(1.2, W, H), f = new Float32Array(N), e = noise(4, W, H); + // And how thick the gel must be before pigment can enter, which varies + // fourfold or more from LED to LED: that frays the edges. + for (let i = 0; i < N; i++) S.wt[i] = WET * exp(1.2 * d[i] + 0.8 * e[i]); + for (let i = 0; i < N; i++) f[i] = exp(0.9 * c[i] + 0.5 * d[i]); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + S.Mx[i] = x < W - 1 ? 0.5 * (f[i] + f[i + 1]) : 0; + S.My[i] = y < H - 1 ? 0.5 * (f[i] + f[i + W]) : 0; + } + S.g.fill(0); + S.w.fill(0); + for (let k = 0; k < NK; k++) S.p[k].fill(0); + S.set = 0; +} + +// A drop of gel lands: a small dome of gel carrying one pigment. +// Its outline is a little wavy, four to seven soft lobes. +function drop(S, x0, y0, kind) { + const r = 6 + 3 * S.bloom, h0 = 0.5 + 0.25 * S.bloom, R = r * 1.25 + 1; + const lobes = 4 + floor(random() * 4), ph = random() * 2 * PI, amp = 0.1 + 0.1 * random(); + const shape = (dx, dy) => (dx * dx + dy * dy) / (r * r * (1 + amp * sin(lobes * Math.atan2(dy, dx) + ph)) ** 2); + for (let y = max(0, floor((y0 - R) / 2)); y <= min(CH - 1, (y0 + R) / 2); y++) + for (let x = max(0, floor((x0 - R) / 2)); x <= min(CW - 1, (x0 + R) / 2); x++) { + const d2 = shape(2 * x + 1 - x0, 2 * y + 1 - y0); + if (d2 < 1) S.g[y * CW + x] += h0 * (1 - d2); + } + const P = S.p[kind]; + for (let y = max(0, floor(y0 - R)); y <= min(H - 1, y0 + R); y++) + for (let x = max(0, floor(x0 - R)); x <= min(W - 1, x0 + R); x++) { + const d2 = shape(x + 0.5 - x0, y + 0.5 - y0); + if (d2 < 1) P[y * W + x] += 0.8 * h0 * (1 - d2); + } + S.lastKind = kind; + S.lastX = x0; + S.lastY = y0; +} + +// The next drop: near the last one as often as not, so blooms meet, and +// never the same pigment twice running. +function nextDrop(S) { + let x, y; + if (random() < 0.5) { + const a = random() * 2 * PI, d = 14 + random() * 14; + x = S.lastX + d * cos(a); + y = S.lastY + d * sin(a) * 1.4; + } else { + x = 8 + random() * (W - 16); + y = 8 + random() * (H - 16); + } + x = cl(x, 6, W - 6); + y = cl(y, 6, H - 6); + // After a tint, white one time in three; never white twice. + const kind = S.lastKind === 2 ? (random() < 0.5 ? 0 : 1) : random() < 0.67 ? 1 - S.lastKind : 2; + drop(S, x, y, kind); +} + +// One explicit step of the gel on its coarse grid: finite volumes, the flux +// across a face D w (w_j - w_i) / h^2 with w the mean of the two cells. +function gelStep(S, dt, D) { + const { g, dg, Gx, Gy } = S, k = (D * dt) / 4; + dg.fill(0); + for (let y = Math.ceil(S.set / 2); y < CH; y++) + for (let x = 0; x < CW; x++) { + const i = y * CW + x, gi = g[i]; + if (x < CW - 1) { + const gj = g[i + 1]; + if (gi > 0 || gj > 0) { + const q = k * Gx[i] * 0.5 * (gi + gj) * (gj - gi); + dg[i] += q; + dg[i + 1] -= q; + } + } + if (y < CH - 1) { + const gj = g[i + CW]; + if (gi > 0 || gj > 0) { + const q = k * Gy[i] * 0.5 * (gi + gj) * (gj - gi); + dg[i] += q; + dg[i + CW] -= q; + } + } + } + const soak = exp(-SOAK * dt), dry = 0.004 * dt; + for (let i = 0; i < CN; i++) { + const v = (g[i] + dg[i]) * soak - dry; + g[i] = v > 0 ? v : 0; + } +} + +// The gel's thickness on the LEDs, bilinear from the coarse grid, and which +// tiles have any. +function upsample(S) { + const { g, w, tile } = S; + tile.fill(0); + for (let y = 0; y < H; y++) { + const v = cl(y / 2 - 0.25, 0, CH - 1.0001), j = v | 0, fy = v - j; + for (let x = 0; x < W; x++) { + const u = cl(x / 2 - 0.25, 0, CW - 1.0001), i = u | 0, fx = u - i, q = j * CW + i; + const val = (g[q] * (1 - fx) + g[q + 1] * fx) * (1 - fy) + (g[q + CW] * (1 - fx) + g[q + CW + 1] * fx) * fy; + w[y * W + x] = val; + if (val > WET) tile[((y / TS) | 0) * TW + ((x / TS) | 0)] = 1; + } + } +} + +// One explicit step of the pigment over the wet tiles. In the body of a +// bloom it goes with the gel, at the gel's mean velocity -D grad w; where the +// gel thins toward its front it rides the surface, up to beta = 3.5 + 5.5 Rim +// times as fast, so it runs ahead to the front and piles up there, since it +// can only go where there is gel. Within the gel it spreads a little. +function pigmentStep(S, dt, D, beta) { + const { w, wt, Mx, My, p, dp, tile, act } = S; + act.fill(0); + for (let ty = 0; ty < TH; ty++) + for (let tx = 0; tx < TW; tx++) + if (tile[ty * TW + tx]) + for (let j = max(0, ty - 1); j <= min(TH - 1, ty + 1); j++) for (let i = max(0, tx - 1); i <= min(TW - 1, tx + 1); i++) act[j * TW + i] = 1; + const p0 = p[0], p1 = p[1], p2 = p[2], d0 = dp[0], d1 = dp[1], d2 = dp[2]; + const kap = 1.2 * dt, edge = 0.03 + 0.05 * S.rim; + for (let t = 0; t < TW * TH; t++) { + if (!act[t]) continue; + const x0 = (t % TW) * TS, y0 = ((t / TW) | 0) * TS; + for (let y = y0; y < y0 + TS; y++) { + const r = y * W; + d0.fill(0, r + x0, r + x0 + TS); + d1.fill(0, r + x0, r + x0 + TS); + d2.fill(0, r + x0, r + x0 + TS); + } + } + for (let t = 0; t < TW * TH; t++) { + if (!act[t]) continue; + const x0 = (t % TW) * TS, y0 = ((t / TW) | 0) * TS; + for (let y = max(y0, Math.ceil(S.set)); y < y0 + TS; y++) + for (let x = x0; x < x0 + TS; x++) { + const i = y * W + x, wi = w[i]; + for (let f = 0; f < 2; f++) { + if (f === 0 ? x === W - 1 : y === H - 1) continue; + const j = f === 0 ? i + 1 : i + W, wj = w[j]; + if (wi < WET && wj < WET) continue; + if (p0[i] + p1[i] + p2[i] + p0[j] + p1[j] + p2[j] < 1e-4) continue; + const m = f === 0 ? Mx[i] : My[i]; + // Upwind: the pigment of the LED the gel leaves. The surface's lead + // fades in below a gel thickness of about edge. + const wf = 0.5 * (wi + wj); + let v = -dt * D * (1 + (beta - 1) * (edge / (wf + edge))) * m * (wj - wi); + v = v > 0.2 ? 0.2 : v < -0.2 ? -0.2 : v; + let a0 = 0, a1 = 0, a2 = 0; + if (v > 0 && wj > wt[j]) (a0 = v * p0[i]), (a1 = v * p1[i]), (a2 = v * p2[i]); + else if (v < 0 && wi > wt[i]) (a0 = v * p0[j]), (a1 = v * p1[j]), (a2 = v * p2[j]); + if (wi > wt[i] && wj > wt[j]) { + const k = kap * m < 0.04 ? kap * m : 0.04; + a0 -= k * (p0[j] - p0[i]); + a1 -= k * (p1[j] - p1[i]); + a2 -= k * (p2[j] - p2[i]); + } + d0[i] -= a0; + d0[j] += a0; + d1[i] -= a1; + d1[j] += a1; + d2[i] -= a2; + d2[j] += a2; + } + } + } + for (let t = 0; t < TW * TH; t++) { + if (!act[t]) continue; + const x0 = (t % TW) * TS, y0 = ((t / TW) | 0) * TS; + for (let y = y0; y < y0 + TS; y++) + for (let x = x0; x < x0 + TS; x++) { + const i = y * W + x; + p0[i] += d0[i]; + p1[i] += d1[i]; + p2[i] += d2[i]; + } + } +} + +function advance(S, dt) { + const t0 = S.t % ROUND; + S.t += dt; + const t1 = S.t % ROUND; + const D = 150 + 150 * S.bloom, beta = 3.5 + 5.5 * S.rim; + if (t1 < t0) { + freshBase(S); + S.next = 0.2; + } + // Drops while painting. + if (t1 < PAINT) { + S.next -= dt; + if (S.next <= 0) { + S.next = 0.35 + 0.45 * random(); + nextDrop(S); + } + } + // The cure lamp passes down the panel and sets each row it has passed: the + // gel and its pigment stop moving there. + S.lamp = t1 >= CURE && t1 < HOLD + 0.2 ? ((t1 - CURE) / (HOLD - CURE)) * (H + 20) - 10 : -99; + if (S.lamp > -99) S.set = max(S.set, cl(S.lamp, 0, H)); + // The wipe pushes the colour down ahead of it and lifts it off. + if (t1 >= WIPE) { + const row = ((t1 - WIPE) / (ROUND - WIPE)) * (H + 24) - 12; + for (let y = H - 1; y >= 0; y--) { + const f = cl((row - y) / 10, 0, 1); + if (f <= 0) continue; + for (let k = 0; k < NK; k++) { + const P = S.p[k]; + for (let x = 0; x < W; x++) { + const i = y * W + x, v = P[i] * f * 0.3; + P[i] -= v + 0.25 * f * P[i]; + if (y < H - 1) P[i + W] += v; + } + } + } + } + // The gel in explicit steps short enough to hold steady, at most twelve + // a frame: for the moment a fresh drop is thickest, nail time runs a + // little slow rather than the sums running away. + let gm = 0; + for (let i = 0; i < CN; i++) if (S.g[i] > gm) gm = S.g[i]; + if (gm <= 0) { + S.w.fill(0); + return; + } + const lim = 0.8 / (D * S.gmob * gm), n = min(12, Math.ceil(dt / lim)), h = min(dt / n, lim); + for (let s = 0; s < n; s++) gelStep(S, h, D); + upsample(S); + // The pigment in one or two steps. No face passes on more than 0.24 of a + // LED's pigment in one, so even a LED emptying through all four faces + // keeps a little and none goes below zero. + const m = dt > 0.04 ? 2 : 1; + for (let s = 0; s < m; s++) pigmentStep(S, (n * h) / m, D, beta); +} + +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.bloom = cl(k[2], 0, 1); + S.rim = cl(k[3], 0, 1); + if (S.first) { + // Open with three drops already landing, spread down the panel. + for (let n = 0; n < 3; n++) drop(S, 18 + 28 * random(), 22 + 40 * n + 12 * random(), n); + S.first = false; + } + if (input.btnPressed && input.btnPressed[0]) { + // Once the lamp has started, a new drop needs a fresh base first. + if (S.t % ROUND >= CURE) { + S.t = (floor(S.t / ROUND) + 1) * ROUND; + freshBase(S); + S.next = 0.6; + } + nextDrop(S); + } + advance(S, (d * S.speed) / 2); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { w, p, col, base, c } = S; + // Colours: a milky base, two tints round the hue, white. Each tint is a + // pigment that takes light away as exp(-a p) (Beer-Lambert), its strength + // a set so a full drop shows the tint. + // From orange to lime a pale base reads khaki, so there it is paler still. + const h = S.hue + 0.66, hb = h + 0.04 - floor(h + 0.04), warm = sm(0.02, 0.07, hb) * (1 - sm(0.3, 0.38, hb)); + hsv(h + 0.04, 0.2 - 0.08 * warm, 0.85 + 0.08 * warm, base); + // The tints: a deep one on the hue and a lighter one 0.1 round from it. + // Round orange and yellow both are lifted to near full, so a thick rim + // deepens toward red or orange instead of going brown or olive. + for (let k = 0; k < 2; k++) { + const ht = h + 0.1 * k, hk = ht - floor(ht), wk = sm(0.0, 0.05, hk) * (1 - sm(0.28, 0.36, hk)); + hsv(ht, k ? 0.7 : 0.88, (k ? 0.76 : 0.58) + (k ? 0.18 : 0.34) * wk, c); + for (let ch = 0; ch < 3; ch++) col[k][ch] = max(0, -Math.log(max(c[ch], 0.03) / base[ch]) / 0.15); + } + const p0 = p[0], p1 = p[1], p2 = p[2], lb = S.lamp > -99 ? 1 : 0, sh = ((S.t % ROUND) - CURE) * 40 - 30; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x, a = p0[i], b = p1[i], wh = 1 - exp(-8 * p2[i]); + let r = base[0] * exp(-col[0][0] * a - col[1][0] * b); + let g = base[1] * exp(-col[0][1] * a - col[1][1] * b); + let bl = base[2] * exp(-col[0][2] * a - col[1][2] * b); + r += (0.97 - r) * wh; + g += (0.97 - g) * wh; + bl += (0.97 - bl) * wh; + // Wet gel shines: a highlight from the slope of its surface. + const wi = w[i]; + if (wi > 0) { + const gx = (x < W - 1 ? w[i + 1] : wi) - (x > 0 ? w[i - 1] : wi), gy = (y < H - 1 ? w[i + W] : wi) - (y > 0 ? w[i - W] : wi); + const nx = -gx * 14, ny = -gy * 14, nn = 1 / sqrt(1 + nx * nx + ny * ny); + const sp = (nx * -0.3 + ny * -0.45 + 0.84) * nn, s4 = sp * sp, s8 = s4 * s4, s16 = s8 * s8; + const gl = 0.45 * s16 * min(1, wi * 12); + r += gl; + g += gl; + bl += gl; + } + // The cure lamp's band, and the set coat's sheen behind it. + if (lb > 0) { + const q = (y - S.lamp) / 4, b2 = exp(-q * q) * lb; + r += 0.42 * b2; + g += 0.36 * b2; + bl += 0.5 * b2; + } + if (y < S.set) { + const q = (x * 0.5 + y - sh) / 9, b3 = 0.1 * exp(-q * q); + r += b3; + g += b3; + bl += b3; + } + display.setPixel(x, y, min(255, r * 255), min(255, g * 255), min(255, bl * 255)); + } +} diff --git a/incoming/pattern-study/patterns/shimmer-oil.js b/incoming/pattern-study/patterns/shimmer-oil.js new file mode 100644 index 000000000..2d65a8e7e --- /dev/null +++ b/incoming/pattern-study/patterns/shimmer-oil.js @@ -0,0 +1,462 @@ +// ===== Patternflow pattern ===== +// Title: Shimmer Oil +// Family: cosmetic +// Idea: An unseen fingertip stirs a tinted body oil full of mica, and the countless flat flakes turn with the flow the way they do in rheoscopic fluid, standing up where the oil is stretched and lying flat where it only turns, so the swirl itself shows as pearly silk along the shear layers, rolling eddies and filaments that stretch and fold, with the sheen sliding over them as they move. +// Author: patternflow-lab +// Date: 2026-10-24 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 36, after shimmer body oils and Paul Matisse's Kalliroscope rheoscopic fluid (1960s), with the flakes turned by Jeffery's equation (G. B. Jeffery, 1922) +// Round: 36 +// =============================== +// @matrix 64x128 +// @knobs Hue=0..1, Speed=0.2..3, Stir=0..1, Mica=0..1 +// +// Knob 1: Hue. Turns the oil and its sheen once round the wheel; the brightest silk leans 0.12 back from the oil's hue, lilac on the berry oil. At 0 a deep berry oil with pale pink silk; at 0.1 wine, at 0.2 orange, at 0.3 yellow, at 0.45 green, at 0.6 teal, at 0.8 indigo, at 0.9 violet. From orange to lime the oil glows at 0.82 of full instead of 0.34, so it never reads brown or olive. +// Knob 2: Speed. The clock. At 2 the fingertip crosses the panel and back in 9 seconds and runs down and up it in 13, so its path never quite repeats, and the oil near it moves up to 38 LEDs a second; at 0.2 all of it runs ten times slower, at 3 half as fast again. +// Knob 3: Stir. How hard the fingertip pushes, 1.9 times harder at 0.5 and 5 times at 1. At 0 it trails a slow pair of eddies and the silk lies in long arcs; at 1 the oil near it races at 87 LEDs a second, the eddies roll into each other and the silk folds into tight curls. +// Knob 4: Mica. How much mica the oil carries. At 0 and 0.06 a sheer oil whose flow shows as soft satin; at 0.5 the silk is brighter with a fine grain in it, and 250 large flakes ride in it, each glinting white for a moment as it turns to the strip light; at 1 there are 330 and the brightest silk runs to white. +// Buttons: K1 tap drops a second fingertip somewhere away from the first: it turns a spiral about 20 LEDs across for 0.7 seconds and the silk winds round it. + +const W = 64, H = 128; +const { floor, min, sqrt, sin, cos, exp, abs, random, PI } = Math; +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +function hsv(h, s, v, out) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6, f = h - floor(h), p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const rgb = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; + out[0] = rgb[0]; + out[1] = rgb[1]; + out[2] = rgb[2]; +} + +// The flow lives on a grid of NX x NY cells, HS LEDs each, with free-slip +// walls round the panel. It is solved as vorticity and stream function: the +// stream function vanishes on the walls, so a sine series holds it exactly, +// and the Poisson equation is a division in that series. +const NX = 24, NY = 48, HS = W / NX, MX = NX - 1, MY = NY - 1, GW = NX + 1, GN = GW * (NY + 1); +const SX = new Float32Array(MX * MX), SY = new Float32Array(MY * MY), LAM = new Float32Array(MX * MY); +for (let k = 1; k <= MX; k++) for (let i = 1; i <= MX; i++) SX[(k - 1) * MX + i - 1] = sin((PI * k * i) / NX); +for (let k = 1; k <= MY; k++) for (let i = 1; i <= MY; i++) SY[(k - 1) * MY + i - 1] = sin((PI * k * i) / NY); +// Eigenvalues of the five-point Laplacian, so the stream function's +// finite-difference Laplacian is exactly minus the vorticity. +for (let l = 1; l <= MY; l++) + for (let k = 1; k <= MX; k++) + LAM[(l - 1) * MX + k - 1] = (4 / (HS * HS)) * (sin((PI * k) / (2 * NX)) ** 2 + sin((PI * l) / (2 * NY)) ** 2); + +// Two-dimensional sine transform of the interior nodes (MY rows of MX). +function dst2(src, dst, tmp) { + for (let j = 0; j < MY; j++) { + const r = j * MX; + for (let k = 0; k < MX; k++) { + const s = k * MX; + let a = 0; + for (let i = 0; i < MX; i++) a += src[r + i] * SX[s + i]; + tmp[r + k] = a; + } + } + for (let l = 0; l < MY; l++) { + const s = l * MY, r = l * MX; + for (let k = 0; k < MX; k++) dst[r + k] = 0; + for (let j = 0; j < MY; j++) { + const c = SY[s + j], q = j * MX; + if (c === 0) continue; + for (let k = 0; k < MX; k++) dst[r + k] += c * tmp[q + k]; + } + } +} + +// Flow constants at Speed 2: viscosity in LED^2 a second, skin friction a +// second, and how fast the flakes settle back toward lying in the film, a +// second. +const NU = 0.6, MU = 0.12, SETTLE = 0.24; +// The largest traceless part of M: eigenvalues 36 and 1/36, a stretch of 6. +const DMAX = (36 - 1 / 36) / 2; +// Flakes: a parcel of them to each LED, and one parcel in 25 also carries a +// single large flake that glints. A flake's normal m turns with the flow as +// A m, A = F^-T for the deformation F since the flakes lay down. The flakes +// at rest lean E0 (about 14 degrees) every way, so a parcel's spread of +// normals is set by M = A A^T alone (the turn left in A only spins an even +// spread) and M is all a parcel carries. With M's eigenvalues s^2 and 1/s^2 +// and its axis e1 the flakes lie at E0 (s cos phi e1 + sin phi e2 / s), so +// how they catch a light hangs on the stretch s and, for a light from one +// side, on the angle between that side and e1. +const NP = W * H; +// The tables hold the averages over phi, for s from 1 to 12 laid out on +// 1 - 1/s. +const E0 = 0.25, NS = 96, NC = 33, SMAX = 12, EL = 0.95; +// Soft front light, ring light, strip light by angle. +const RF = new Float32Array(NS), RR = new Float32Array(NS), RS = new Float32Array(NS * NC); +{ + const NPHI = 48, se = sin(EL), ce = cos(EL), cd = [], sd = []; + for (let j = 0; j < NC; j++) { + const dl = 0.5 * Math.acos(-1 + (2 * j) / (NC - 1)); + cd.push(se * cos(dl)); + sd.push(se * sin(dl)); + } + for (let i = 0; i < NS; i++) { + const sg = 1 / (1 - (i / (NS - 1)) * (1 - 1 / SMAX)); + let f = 0, r = 0; + for (let k = 0; k < NPHI; k++) { + const ph = (PI * (k + 0.5)) / NPHI, m0 = E0 * sg * cos(ph), m1 = (E0 / sg) * sin(ph), inv = 1 / sqrt(1 + m0 * m0 + m1 * m1); + const i2 = inv * inv, i4 = i2 * i2, i8 = i4 * i4; + f += i8 * i8 * i8; + // Flakes standing at about 45 degrees catch a low ring of light from + // every side; flat ones and edge-on ones miss most of it. + const rq = (inv - 0.707) * 3.5, rb = rq * rq < 1 ? 1 - rq * rq : 0; + r += 0.05 + 0.95 * rb * rb; + for (let j = 0; j < NC; j++) { + const d = (m0 * cd[j] + m1 * sd[j] + ce) * inv, d2 = d * d, d4 = d2 * d2; + RS[i * NC + j] += (d4 * d4) / NPHI; + } + } + RF[i] = f / NPHI; + RR[i] = r / NPHI; + } +} + +// The fingertip's push, its radius in LEDs. +const SIG = 6.5; + +export function setup(params) { + const S = { + hue: 0, speed: 2, stir: 0, mica: 0.06, t: 0, fa: 0, par: 0, mdt: [0, 0], warm: false, + w: new Float32Array(GN), w2: new Float32Array(GN), psi: new Float32Array(GN), + u: new Float32Array(GN), v: new Float32Array(GN), ux: new Float32Array(GN), uy: new Float32Array(GN), vx: new Float32Array(GN), + a: new Float32Array(MX * MY), b: new Float32Array(MX * MY), c: new Float32Array(MX * MY), d: new Float32Array(MX * MY), q: new Float32Array((GW + 2) * (NY + 3)), + px: new Float32Array(NP), py: new Float32Array(NP), + M0: new Float32Array(NP), M1: new Float32Array(NP), M2: new Float32Array(NP), + gx: new Float32Array(NP), gy: new Float32Array(NP), + kp: new Float32Array(NP), cnt: new Int32Array(W * H), own: new Int32Array(W * H), gl: new Float32Array(W * H), lit: new Float32Array(W * H), wt: new Float32Array(W * H), tmp: new Float32Array(W * H), + swirl: null, c0: [0, 0, 0], c1: [0, 0, 0], c2: [0, 0, 0], + }; + // Parcels start one to each LED, their flakes at rest. + for (let p = 0; p < NP; p++) { + S.px[p] = (p % W) + random(); + S.py[p] = floor(p / W) + random(); + S.M0[p] = S.M2[p] = 1; + S.kp[p] = 0.8 + 0.4 * random(); + if (random() < 0.04) { + const a = random() * 2 * PI, r = E0 * (0.4 + 0.6 * random()); + S.gx[p] = r * cos(a); + S.gy[p] = r * sin(a) + 1e-6; + } + } + // And the oil has been swirled before: a dozen soft eddies of either sign. + for (let n = 0; n < 12; n++) { + const cx = 8 + random() * (W - 16), cy = 8 + random() * (H - 16), r = 6 + random() * 8, k = (random() < 0.5 ? -1 : 1) * (2 + 3 * random()); + for (let j = 1; j < NY; j++) + for (let i = 1; i < NX; i++) { + const dx = i * HS - cx, dy = j * HS - cy; + S.w[j * GW + i] += k * exp(-(dx * dx + dy * dy) / (r * r)); + } + } + params.s = S; +} + +// Where the fingertip is at time t: a slow loop that wanders over the panel. +function finger(t) { + return [32 + 19 * sin(t * 0.7) + 4 * sin(t * 1.9), 64 + 44 * sin(t * 0.47 + 0.6) + 8 * cos(t * 1.3)]; +} + +// One step of the flow: carry the vorticity, push it with the fingertip, +// let it decay, and solve for the stream function, velocity and its gradient. +function flowStep(S, dt) { + const { w, w2, psi, u, v, ux, uy, vx, a, b, c } = S; + const force = 16 * (0.45 + 0.5 * S.stir + 1.3 * S.stir * S.stir); + const [fx, fy] = finger(S.t), [gx, gy] = finger(S.t + 0.02); + const vxs = (gx - fx) / 0.02, vys = (gy - fy) / 0.02; + const fr = exp(-MU * dt), sw = S.swirl; + for (let j = 1; j < NY; j++) + for (let i = 1; i < NX; i++) { + const g = j * GW + i; + // Back along the flow, in grid units. + let X = i - (u[g] * dt) / HS, Y = j - (v[g] * dt) / HS; + X = X < 0 ? 0 : X > NX - 1e-4 ? NX - 1e-4 : X; + Y = Y < 0 ? 0 : Y > NY - 1e-4 ? NY - 1e-4 : Y; + const i0 = X | 0, j0 = Y | 0, tx = X - i0, ty = Y - j0, q = j0 * GW + i0; + let val = (w[q] * (1 - tx) + w[q + 1] * tx) * (1 - ty) + (w[q + GW] * (1 - tx) + w[q + GW + 1] * tx) * ty; + // The fingertip drags the oil along with it: the curl of a force along + // its velocity, spread over a Gaussian of radius SIG. + const dx = i * HS - fx, dy = j * HS - fy, r2 = (dx * dx + dy * dy) / (SIG * SIG); + if (r2 < 9) val += ((force * dt * -2) / (SIG * SIG)) * exp(-r2) * (vys * dx - vxs * dy); + if (sw) { + const ex = i * HS - sw.x, ey = j * HS - sw.y, e2 = (ex * ex + ey * ey) / (sw.r * sw.r); + if (e2 < 9) val += sw.k * dt * exp(-e2) * (1 - 0.6 * e2); + } + w2[g] = val * fr; + } + if (sw && (sw.left -= dt) <= 0) S.swirl = null; + // Viscosity, one explicit step of the five-point Laplacian (the vorticity + // is 0 on the walls). + const nu = (NU * dt) / (HS * HS); + for (let j = 1; j < NY; j++) + for (let i = 1; i < NX; i++) { + const g = j * GW + i; + w[g] = w2[g] + nu * (w2[g - 1] + w2[g + 1] + w2[g - GW] + w2[g + GW] - 4 * w2[g]); + a[(j - 1) * MX + i - 1] = w[g]; + } + // The stream function: a division in the sine series. + dst2(a, b, c); + const norm = 4 / (NX * NY); + for (let n = 0; n < MX * MY; n++) b[n] *= norm / LAM[n]; + dst2(b, c, S.d); + for (let j = 1; j < NY; j++) for (let i = 1; i < NX; i++) psi[j * GW + i] = c[(j - 1) * MX + i - 1]; + // Velocity u = dpsi/dy, v = -dpsi/dx and its gradient, by central + // differences; past a wall the stream function is mirrored with its sign + // turned over, as the sine series has it. + const Q = S.q, QW = GW + 2; + for (let j = 0; j <= NY; j++) { + const r = (j + 1) * QW + 1; + for (let i = 0; i <= NX; i++) Q[r + i] = psi[j * GW + i]; + Q[r - 1] = -Q[r + 1]; + Q[r + NX + 1] = -Q[r + NX - 1]; + } + for (let i = 0; i < QW; i++) { + Q[i] = -Q[2 * QW + i]; + Q[(NY + 2) * QW + i] = -Q[NY * QW + i]; + } + const h2 = 1 / (HS * HS), i2 = 1 / (2 * HS); + for (let j = 0; j <= NY; j++) + for (let i = 0; i <= NX; i++) { + const g = j * GW + i, q = (j + 1) * QW + i + 1, p0 = Q[q]; + const pe = Q[q + 1], pw = Q[q - 1], pn = Q[q - QW], ps = Q[q + QW]; + u[g] = (ps - pn) * i2; + v[g] = -(pe - pw) * i2; + uy[g] = (ps - 2 * p0 + pn) * h2; + vx[g] = -(pe - 2 * p0 + pw) * h2; + ux[g] = (Q[q + QW + 1] - Q[q - QW + 1] - Q[q + QW - 1] + Q[q - QW - 1]) * 0.25 * h2; + } +} + +// The parcels ride the flow, and every flake in one turns as a flat disc of +// no thickness does (Jeffery's equation): its normal m follows +// dm/dt = K m with K = -(grad u)^T, so a flake turns its face across the +// stretching and lies along the streamlines in shear. Then M follows +// dM/dt = K M + M K^T, and the film draws M back toward the identity, the +// flakes settling to rest. +function flakeStep(S, dt) { + const { psi, ux, uy, vx, px, py, M0, M1, M2 } = S; + // Half the parcels turn their flakes each step, by the time since their + // last turn; all of them move. + const par = (S.par ^= 1), dm = S.mdt[par] + dt; + S.mdt[par] = 0; + S.mdt[par ^ 1] += dt; + const st = exp(-SETTLE * dm), ih = 1 / HS, XM = NX - 1e-4, YM = NY - 1e-4, hd = 0.5 * dt * ih; + for (let p = 0; p < NP; p++) { + let x = px[p], y = py[p]; + let X = x * ih, Y = y * ih; + X = X > XM ? XM : X; + Y = Y > YM ? YM : Y; + const i0 = X | 0, j0 = Y | 0, tx = X - i0, ty = Y - j0, g = j0 * GW + i0; + // Velocity from the bilinear stream function: divergence-free inside + // every cell. + const p00 = psi[g], p10 = psi[g + 1], p01 = psi[g + GW], p11 = psi[g + GW + 1]; + const U = (1 - tx) * (p01 - p00) + tx * (p11 - p10), V = (1 - ty) * (p10 - p00) + ty * (p11 - p01); + if ((p & 1) === par) { + const w00 = (1 - tx) * (1 - ty), w10 = tx * (1 - ty), w01 = (1 - tx) * ty, w11 = tx * ty; + const a = ux[g] * w00 + ux[g + 1] * w10 + ux[g + GW] * w01 + ux[g + GW + 1] * w11; + const b = uy[g] * w00 + uy[g + 1] * w10 + uy[g + GW] * w01 + uy[g + GW + 1] * w11; + const c = vx[g] * w00 + vx[g + 1] * w10 + vx[g + GW] * w01 + vx[g + GW + 1] * w11; + // grad u = [[a, b], [c, -a]]. One step of m' = K m is the Cayley + // transform C = (I - K dt/2)^-1 (I + K dt/2); K has no trace, so + // C = ((1 - D) I + K dt) / (1 + D) with D = det(K dt) / 4, and det C = 1 + // exactly, as the flow keeps area. M' = C M C^T. + const D = (dm * dm * (-a * a - b * c)) / 4, iD = 1 / (D > -0.8 ? 1 + D : 0.2), q = 1 - D; + const c00 = (q - dm * a) * iD, c01 = -dm * c * iD, c10 = -dm * b * iD, c11 = (q + dm * a) * iD; + const mp = M0[p], mq = M1[p], mr = M2[p]; + const t00 = c00 * mp + c01 * mq, t01 = c00 * mq + c01 * mr, t10 = c10 * mp + c11 * mq, t11 = c10 * mq + c11 * mr; + // Settling: M's traceless part decays at the rate SETTLE, and past a + // stretch of 6 the film's drag holds it, so the flakes stop short of + // standing on edge. det M stays 1, as the flow keeps it: with the + // traceless part (d0, d1) the trace is 2 sqrt(1 + d0^2 + d1^2). + let d0 = 0.5 * (t00 * c00 + t01 * c01 - t10 * c10 - t11 * c11) * st, d1 = (t00 * c10 + t01 * c11) * st; + const dd = d0 * d0 + d1 * d1; + if (dd > DMAX * DMAX) { + const f = DMAX / sqrt(dd); + d0 *= f; + d1 *= f; + } + const h = sqrt(1 + d0 * d0 + d1 * d1); + M0[p] = h + d0; + M1[p] = d1; + M2[p] = h - d0; + } + // The parcel moves by the midpoint rule. + let Xm = X + hd * U, Ym = Y - hd * V; + Xm = Xm < 0 ? 0 : Xm > XM ? XM : Xm; + Ym = Ym < 0 ? 0 : Ym > YM ? YM : Ym; + const im = Xm | 0, jm = Ym | 0, sx = Xm - im, sy = Ym - jm, gm = jm * GW + im; + const q00 = psi[gm], q10 = psi[gm + 1], q01 = psi[gm + GW], q11 = psi[gm + GW + 1]; + x += ((1 - sx) * (q01 - q00) + sx * (q11 - q10)) * ih * dt; + y -= ((1 - sy) * (q10 - q00) + sy * (q11 - q01)) * ih * dt; + px[p] = x < 0.01 ? 0.01 : x > W - 0.01 ? W - 0.01 : x; + py[p] = y < 0.01 ? 0.01 : y > H - 0.01 ? H - 0.01 : y; + } +} + +// The flow keeps the oil's density even, and so would the parcels if they +// moved exactly. Where they have drifted out of a LED that its neighbours +// still reach, a parcel from a crowded LED is moved in, taking on the flakes +// of a neighbour; up to 400 a frame. +function even(S) { + const { px, py, M0, M1, M2, cnt, own } = S; + cnt.fill(0); + for (let p = 0; p < NP; p++) { + const o = (py[p] | 0) * W + (px[p] | 0); + cnt[o]++; + own[o] = p; + } + let d = 0, moved = 0; + const start = (random() * W * H) | 0; + for (let n = 0; n < W * H && moved < 400; n++) { + const e = (start + n) % (W * H); + if (cnt[e] !== 0) continue; + const x = e % W, y = (e / W) | 0; + const nb = x > 0 && cnt[e - 1] ? e - 1 : x < W - 1 && cnt[e + 1] ? e + 1 : y > 0 && cnt[e - W] ? e - W : y < H - 1 && cnt[e + W] ? e + W : -1; + if (nb < 0) continue; + while (d < W * H && cnt[d] < 3) d++; + if (d >= W * H) break; + const p = own[d], q = own[nb]; + cnt[d]--; + cnt[e] = 1; + px[p] = x + 0.1 + 0.8 * random(); + py[p] = y + 0.1 + 0.8 * random(); + M0[p] = M0[q]; + M1[p] = M1[q]; + M2[p] = M2[q]; + moved++; + } +} + +// The flow is solved every 1/15 s of oil time, the flakes moved every step. +function step(S, dt) { + S.fa += dt; + if (S.fa > 1 / 15 - 1e-6) { + flowStep(S, S.fa); + S.fa = 0; + } + flakeStep(S, dt); + S.t += dt; +} + +export function update(dt, input, params) { + if (!params.s) setup(params); + const S = params.s, k = input.knobValues || [0, 2, 0, 0.06], d = cl(dt || 0, 0, 0.1); + S.hue = k[0]; + S.speed = cl(k[1], 0.2, 3); + S.stir = cl(k[2], 0, 1); + S.mica = cl(k[3], 0, 1); + if (!S.warm) { + // Open on oil that has been stirred for 5.4 seconds, the flakes laid + // down for the last 4.2 of them. + for (let i = 0; i < 4; i++) (S.t += 0.3), flowStep(S, 0.3); + for (let i = 0; i < 6; i++) step(S, 0.4); + S.warm = true; + } + if (input.btnPressed && input.btnPressed[0]) { + // A fingertip lands well away from the stirring one and turns a spiral. + const [fx, fy] = finger(S.t); + let x = 0, y = 0; + for (let n = 0; n < 8; n++) { + x = 16 + random() * (W - 32); + y = 20 + random() * (H - 40); + if ((x - fx) ** 2 + (y - fy) ** 2 > 40 * 40) break; + } + S.swirl = { x, y, r: 10, k: random() < 0.5 ? 22 : -22, left: 0.7 }; + } + // Oil time runs at Speed / 2, in steps no longer than 1/15 s. + let left = (d * S.speed) / 2; + while (left > 1e-6) { + const h = min(left, 1 / 15); + step(S, h); + left -= h; + } + even(S); +} + +function blur(F, T) { + for (let y = 0; y < H; y++) { + const r = y * W; + T[r] = 0.75 * F[r] + 0.25 * F[r + 1]; + for (let x = 1; x < W - 1; x++) T[r + x] = 0.5 * F[r + x] + 0.25 * (F[r + x - 1] + F[r + x + 1]); + T[r + W - 1] = 0.75 * F[r + W - 1] + 0.25 * F[r + W - 2]; + } + for (let x = 0; x < W; x++) { + F[x] = 0.75 * T[x] + 0.25 * T[x + W]; + F[(H - 1) * W + x] = 0.75 * T[(H - 1) * W + x] + 0.25 * T[(H - 2) * W + x]; + } + for (let o = W; o < (H - 1) * W; o++) F[o] = 0.5 * T[o] + 0.25 * (T[o - W] + T[o + W]); +} + +export function draw(display, params) { + const S = params.s; + if (!S) return; + const { px, py, M0, M1, M2, gx, gy, kp, lit, wt, gl } = S; + const mica = S.mica, t = S.t; + // Three lights: a soft one over the viewer's shoulder for the flakes that + // lie flat, a low ring for the ones the flow has half stood up, and a strip + // low at one side, slowly circling, for those leaning toward it. + const az = 0.6 + 0.35 * t, c2a = cos(2 * az), s2a = sin(2 * az); + const h2x = sin(EL) * cos(az), h2y = sin(EL) * sin(az), h2z = cos(EL); + const wf = 0.15, wr = 0.85 + 0.3 * mica, ws = 1 + 0.6 * mica, wg = 0.5 + 1.5 * mica, gs = 0.6 * mica; + lit.fill(0); + wt.fill(0); + gl.fill(0); + // Large flakes glint once Mica brings them in, the flattest first. + const gcut = E0 * (1.05 - mica), sc = (NS - 1) / (1 - 1 / SMAX); + for (let p = 0; p < NP; p++) { + const mp = M0[p], mq = M1[p], mr = M2[p], fro = mp + mr; + // The stretch s, the root of M's larger eigenvalue, and e1's angle to + // the strip. + const dd = mp - mr, an = sqrt(dd * dd + 4 * mq * mq), s1 = sqrt(0.5 * (fro + an)); + const c2 = an > 1e-6 ? (dd * c2a + 2 * mq * s2a) / an : 0; + const ii = ((s1 > 1 ? 1 - 1 / s1 : 0) * sc + 0.5) | 0, i = ii < NS ? ii : NS - 1, j = ((c2 + 1) * 0.5 * (NC - 1) + 0.5) | 0; + let val = wf * RF[i] + wr * RR[i] + ws * RS[i * NC + j]; + val *= 1 - gs + gs * kp[p]; + // Into the LED the parcel is over; the blur below spreads it. A large + // flake's glint stays in its own LED. + const o = (py[p] | 0) * W + (px[p] | 0), ex = gx[p], ey = gy[p]; + if (ey !== 0 && ex * ex + ey * ey > gcut * gcut) { + // Its normal is sqrt(M) times its resting tilt; det M = 1, so + // sqrt(M) = (M + I) / sqrt(trace + 2). + const is = 1 / sqrt(fro + 2); + const m0 = ((mp + 1) * ex + mq * ey) * is, m1 = (mq * ex + (mr + 1) * ey) * is, inv = 1 / sqrt(1 + m0 * m0 + m1 * m1); + const d = abs(h2x * m0 + h2y * m1 + h2z) * inv, d2 = d * d, d4 = d2 * d2, d8 = d4 * d4; + gl[o] += wg * d8 * d8 * d8; + } + lit[o] += val; + wt[o] += 1; + } + // A LED sees a little past its own square: a 1-2-1 blur each way. + blur(lit, S.tmp); + blur(wt, S.tmp); + blur(lit, S.tmp); + blur(wt, S.tmp); + // Colours: the oil's body and its sheen, whose brightest light leans a + // little round the wheel as pearl pigment does. + const h = S.hue + 0.88, hh = h - floor(h); + // Deep orange reads brown on the panel and deep yellow olive, so from + // orange to lime the oil glows instead: 0.82 of full, not 0.34. + const warm = sm(0.015, 0.05, hh) * (1 - sm(0.3, 0.38, hh)); + const { c0, c1, c2 } = S; + hsv(h, 0.9 - 0.1 * warm, 0.34 + 0.48 * warm, c0); + hsv(h - 0.02, 0.42 - 0.12 * warm, 1, c1); + hsv(h - 0.12, 0.38 - 0.1 * warm, 1, c2); + const rest = RF[0] * wf + RR[0] * wr + RS[(NC - 1) >> 1] * ws; + for (let o = 0; o < W * H; o++) { + // An LED no parcel reached shows flakes at rest. + const val = (lit[o] + 0.05 * rest) / (wt[o] + 0.05) + gl[o] * 0.5; + const s = val > 1.4 ? 1.4 : val, fl = val < 0.4 ? 0 : val > 1 ? 1 : (val - 0.4) / 0.6, f = fl * fl * (3 - 2 * fl); + const sr = c1[0] + (c2[0] - c1[0]) * f, sg = c1[1] + (c2[1] - c1[1]) * f, sb = c1[2] + (c2[2] - c1[2]) * f; + const body = 1 - 0.55 * (s > 1 ? 1 : s); + // The brightest sheen runs on toward white. + const wl = val < 0.75 ? 0 : val > 1.3 ? 1 : (val - 0.75) / 0.55, wh = wl * wl * (1.5 - wl), k = s * 0.85 * (1 - wh); + const r = 255 * (c0[0] * body + sr * k + wh), g = 255 * (c0[1] * body + sg * k + wh), b = 255 * (c0[2] * body + sb * k + wh); + display.setPixel(o % W, (o / W) | 0, r > 255 ? 255 : r, g > 255 ? 255 : g, b > 255 ? 255 : b); + } +} From 16b050b3e2e53993aa79f62155aa99afa999caeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:51:20 +0000 Subject: [PATCH 7/9] =?UTF-8?q?=ED=8C=A8=ED=84=B4=2036=ED=8C=90=EC=9D=98?= =?UTF-8?q?=20README=20=EC=97=B0=EA=B5=AC=20=EB=85=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5sd4vVG4w3eC2bpCrSD5a --- incoming/pattern-study/README.md | 81 +++++++++++++++++-- .../pattern-study/patterns/shimmer-oil.js | 2 +- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index 5ee6e3843..70b87cc57 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 248개를 쉰다섯 갈래로 나눠 만들었어요. 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판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 258개를 쉰다섯 갈래로 나눠 만들었어요. 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판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. 36판도 수학 대상 여섯을 여섯 갈래에 하나씩, 매장 벽 질감 넷을 화장품 팝업 갈래에 더했어요. [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판에서 세로로 다시 짰어요. @@ -414,6 +414,21 @@ | [Thomson Problem](patterns/thomson-problem.js) | 물리·스프링 | 천천히 도는 구 위에서 같은 전하 100개가 서로 밀어 거의 삼각형인 격자로 앉아요. 이웃이 여섯인 전하는 가운데가 옅은 하늘색으로, 오일러 공식이 늘 열둘을 남기는 다섯 이웃 전하는 금색으로 빛나고, 일곱 이웃 전하가 생기면 자홍색이에요. K3 을 480 까지 올리면 열둘이 5-7-5 흉터로 자라고, K1 을 누르면 전하가 흩어졌다가 다시 격자로 앉아요. | | [Velvet Nap](patterns/velvet-nap.js) | 화장품 팝업 | 판 전체가 에메랄드 무광 벨벳이고, 보이지 않는 손끝 둘이 천천히 글씨를 쓰듯 결을 쓸어요. 빛 쪽으로 밀린 결은 짙고 깊은 색으로, 빛을 따라 누운 결은 옅은 은빛으로 남아 서로 겹쳐 덮이고, 7초마다 손바닥이 22 LED 너비 띠를 위에서 아래로 쓸어 결을 한 방향으로 눕혀요. | +### 36판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Blooming Gel](patterns/blooming-gel.js) | 화장품 팝업 | 젖은 블루밍 베이스에 코발트와 라일락, 흰 젤 방울이 하나씩 떨어져 가장자리가 해진 꽃처럼 번지고, 안료는 번지는 앞선으로 밀려 테두리가 짙어져요. 6.2초를 그리고 나면 경화 램프 띠가 위에서 아래로 지나며 무늬를 굳히고, 닦아 낸 새 베이스에서 다시 시작해요. K3 은 방울 크기와 번지는 거리를 정하고, K1 을 누르면 방울 하나가 바로 떨어져요. | +| [Catastrophe Machine](patterns/catastrophe-machine.js) | 물리·스프링 | 원판 테두리의 한 점에 고무줄 둘을 묶어 하나는 위의 고정점에, 하나는 아래를 좌우로 오가는 점 B 에 걸었어요. B 가 뾰족점 넷인 마름모, 곧 원판이 설 자리가 둘인 곳을 빠져나가는 순간 원판이 120도쯤 반대로 튀어서, B 의 자취가 가는 길과 오는 길에서 다른 색으로 남아요. K3 은 고무줄의 자연 길이를 줄여 마름모를 작게 하다가 1 에서 없애고, K1 을 누르면 B 가 축 건너편으로 건너뛰어요. | +| [Curve Shortening](patterns/curve-shortening.js) | 선·궤적 | 굵은 나선이나 접힌 지렁이, 팔 여럿인 별 같은 닫힌 곡선 하나가 점마다 곡률만큼의 빠르기로 법선을 따라 움직여요. 곡선은 제 몸에 한 번도 닿지 않고 풀려 둥글어진 뒤 한 점으로 사라지고, 넓이는 모양과 상관없이 흐름 시간 1 에 2π 씩 줄며, 흰 점으로 찍힌 변곡점은 둘씩 만나 함께 없어져요. K3 은 나선을 2바퀴에서 3.5바퀴까지 감고, K1 을 누르면 남은 흐름이 0.6초 만에 끝나요. | +| [Elliptic Chords](patterns/elliptic-chords.js) | 수론 | 위에는 실수 곡선 y² = x³ − 6x 가, 아래에는 같은 곡선을 61 로 나눈 나머지로 푼 61×61 판이 있어요. G 와 kG 를 지나는 직선이 곡선과 세 번째로 만나는 점을 x축에 뒤집으면 (k+1)G 이고, 아래에서는 그 직선이 판을 감아 도는 점 61개가 되어 곡선의 점 셋에서만 하얗게 빛나며 배수들이 방문 순서대로 무지개색으로 켜져요. K3 은 상수 b 를 바꿔 알 모양 고리를 키우다 긴 가지에 붙이고, K1 을 누르면 새 생성원 G 로 O 에서 다시 세요. | +| [Gibbs Phenomenon](patterns/gibbs-phenomenon.js) | 물결·간섭 | 사각파 한 주기가 가로로 놓이고, 아래로 내려갈수록 푸리에 부분합의 항이 늘어 맨 위 1항에서 맨 아래 32항까지 25줄마다 두 배가 돼요. 합이 파형을 넘어선 곳은 흰 금색으로 타서 점프마다 양옆에 뿔이 서고, 뿔은 아래로 갈수록 점프에 붙지만 높이는 언제나 점프의 8.95% 예요. K3 은 사각파를 톱니파를 거쳐 삼각파로 바꿔 점프와 함께 뿔을 지우고, K1 을 누르면 맨 아래 줄이 256항까지 달리며 그 합의 그래프가 금색 눈금에 닿아요. | +| [Heawood Torus](patterns/heawood-torus.js) | 위상·매듭 | 세로로 긴 토러스가 천천히 돌며 헤우드의 지도를 보여 줘요. 일곱 나라가 저마다 나머지 여섯과 모두 맞닿아서, 구 위의 지도와 달리 이 지도는 일곱 색이 있어야 칠해져요. K3 은 구멍을 넓혀 굵은 관을 가는 고리로 만들고, K1 을 누르면 여섯 초 동안 일곱 수도와 그 사이 길 21개가 흰 선으로 그려져요. | +| [Lotion Ribbon](patterns/lotion-ribbon.js) | 화장품 팝업 | 위에서 굵은 로션 줄기가 쏟아지며 가늘어지고, 닿는 자리에서 꿀처럼 똬리를 틀어 접힌 더미를 쌓아요. K3 은 로션의 무게라서 묽은 세럼은 작은 고리로 빨리 감기고 바디 버터는 25 LED 너비의 고리로 천천히 접혀요. K1 을 누르면 줄기가 끊기고, 더미가 가라앉은 뒤 새 줄기가 내려와요. | +| [Schwarz Lantern](patterns/schwarz-lantern.js) | 기하·대칭 | 꼭짓점이 모두 원기둥 위에 있는 삼각형 랜턴이 패널 높이만큼 서서 돌고, 띠가 3개에서 40개로 늘었다 줄어요. 접힐수록 아코디언처럼 반짝이고, 왼쪽 막대가 넓이를 원기둥의 1.88배까지 올려요. K3 은 한 고리의 꼭짓점 수를 5에서 12로 바꿔 같은 띠 40개를 매끈한 기둥으로 남기고, K1 을 누르면 단번에 가장 구겨진 꼴로 접혔다가 천천히 풀려요. | +| [Shimmer Oil](patterns/shimmer-oil.js) | 화장품 팝업 | 보이지 않는 손끝이 마이카가 든 베리빛 바디 오일을 저으면, 납작한 조각들이 늘어난 자리에서는 일어서고 돌기만 하는 자리에서는 누운 채라 소용돌이 자체가 진주빛 실크 결로 보여요. K3 은 젓는 힘이라 1 에서는 소용돌이가 서로 말려 들며 결이 촘촘히 접혀요. K1 을 누르면 다른 손끝이 내려와 20 LED 쯤의 나선을 감아요. | +| [Whipped Soap](patterns/whipped-soap.js) | 화장품 팝업 | 보이지 않는 별 깍지가 휘핑 바디 솝을 한 송이씩 짜는데, 한 바퀴가 앞 바퀴에 반쯤 얹히며 뾰족하게 솟아요. 쟁반이 두 칸 네 줄로 차면 긁개가 거품을 말아 아래로 밀어 내요. K3 은 깍지의 별 꼭짓점 수라서 굵은 골 두세 줄이 가는 골 네다섯 줄로 바뀌고, K1 을 누르면 쟁반을 바로 밀어요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -1608,6 +1623,50 @@ E8 의 근 240개를 고셋의 4₂₁ 다면체 꼭짓점으로 그렸어요. 주제 4 와 6 으로 헤어 광고의 광택 띠를 머리결 질감으로만 그렸어요. 가닥마다 접선과 반벡터의 각으로 카지야-케이 광을 매기고, 흰 광택은 뿌리 쪽으로, 머리색으로 물든 둘째 광택은 끝 쪽으로 밀었어요. 첫 판은 360가닥이 9~23줄 주기로 1.3 LED 씩 옆으로 떨려서 머리결이 아니라 점박이 잡음이었고 한 프레임이 12.4 ms 였는데, 230가닥으로 줄이고 떨림을 0.4 LED, 30~70줄 주기로 늘이자 세로결이 읽히고 5.6 ms 가 됐어요. Shine 노브가 띠의 날카로움만 바꿀 때는 반응이 0.06 이었고, 둔한 결일수록 띠 둘레에 넓은 윤을 펴게 하자 0.17 이 됐어요. 주황과 노랑 쪽은 이번에도 갈색과 올리브로 가서 K1 0.36 의 꿀색 금발이 카키로 보였고, 색상 0.12 둘레에서 밝기를 1.6배까지 올리고 채도를 0.18 내렸어요. 머리가 판을 다 채워 `thirds` 가 99/99/94 라서 2~8초 동안 밝기가 24/255 넘게 오르내린 LED 로 다시 세었더니 97.8/100/100% 였고, 빗을 끄면 위 3분의 1이 80.0% 로 내려가요. +## 36판 + +36판도 주제 5 「낯선 수학」 여섯과 주제 6 「화장품 브랜드 팝업」 넷이에요. 같이 돈 35판(#229)이 정폭 도형과 렌니의 주차, 배스 피즈 같은 것을 먼저 골라서, 이 판은 그와 겹치지 않게 골랐어요. 수학은 Curve Shortening, Heawood Torus, Schwarz Lantern, Gibbs Phenomenon, Elliptic Chords, Catastrophe Machine 이고, 화장품은 Lotion Ribbon, Shimmer Oil, Blooming Gel, Whipped Soap 이에요. 수학 여섯은 다 「다음 판 감」의 목록 밖에서 찾았고, 있는 갈래에 들어가서 주제 2 로 연 갈래는 없어요. Heawood Torus 가 위상·매듭 갈래의 다섯째예요. 화장품 넷도 「다음에 해 볼 질감」 밖에서 골랐어요. 주제 1 은 248개의 지도 21장이 다 `none` 이라 30판부터처럼 패턴이 하나뿐인 칸을 겨눴어요. Curve Shortening 이 Searchlights 혼자이던 채움 × 모양 바뀜 1,4 에, Heawood Torus 가 Balloon Box 혼자이던 색 × 모양 바뀜 4,3 에 둘째로 섰어요. 패턴이 하나뿐인 칸은 248개에서 23 이었고 258개에서 21 이에요. 열 개 다 어느 축의 끝도 넘지 않아서 칸 경계는 30판 그대로예요. 주제 4 는 Schwarz Lantern 과 Gibbs Phenomenon 이에요. Schwarz Lantern 은 원기둥을 114 LED 높이로 세워 `thirds` 가 69/80/71 이고, Gibbs Phenomenon 은 줄이 부분합의 항 수라 71/91/99 예요. Lotion Ribbon 도 위에서 쏟아지는 줄기와 바닥의 더미로 87/100/100 이라 주제 4 와 6 을 같이 채워요. 다섯 일꾼이 둘씩 짰고, 본체가 연구의 Lab 하네스로 다시 재고 화면을 그려 보고 합쳤어요. + +### Curve Shortening + +주제 1 과 5 로 곡선 단축 흐름을 세웠어요. 겨눈 칸은 Searchlights 혼자 서 있던 채움 × 모양 바뀜 1,4 예요. 2초에 새 나선이 패널의 36.5% 를 거의 꽉 찬 밝기로 덮고 7.5초쯤 점으로 사라지게 맞췄더니, 넓이가 같은 빠르기로 줄어서 채움은 시작의 절반쯤인 0.194 에, 모양 바뀜은 시작 넓이만큼인 0.375 에 섰어요. 처음 짠 나선은 반지름이 자라는 호를 직선에 이어 붙여서 이음매마다 0.2 라디안씩 꺾였어요. 그래서 그리는 동안 변곡점이 8개 찍혔다가 흐름이 시작되자마자 사라졌고, 진짜 반원으로 다시 짜자 2개가 됐어요. 그 판은 넓이가 4209 로 커져 채움 0.259, 모양 바뀜 0.487 로 축 끝 0.413 을 넘었어요. Wind 가 2바퀴에서 열리게 하고 띠 너비를 피치의 0.6 에서 0.45 로 줄여 넓이를 2988 로 내렸어요. Wind 3.5, Speed 3 에서는 오그라드는 끝에 점이 몰려 가장 짧은 변이 0.09 LED 까지 줄었고, 한 프레임 600 단계의 상한에 걸려 흐름이 넓이 시계보다 늦었어요. 변이 간격의 0.45배 밑으로 줄면 점을 다시 고르게 찍고, 그때 깎인 넓이만큼 모든 점을 바깥으로 밀어 되돌리게 했더니 프레임당 26 단계, 1.1 ms 가 됐어요. 잰 dA/dt 는 흐름 시간 1 에 6.27 로 2π 보다 0.2% 작았고, 걸음을 2.5배 줄이면 6.281 이 돼서 그 차이는 걸음 크기에서 와요. 60초 동안 곡선 아홉에서 자기 교차는 한 번도 없었고 변곡점 수가 는 적도 없었어요. 넓이가 고르게 줄어드는 채운 면은 채움을 시작의 절반쯤으로, 모양 바뀜을 시작 넓이만큼으로 남겨서, 채움이 낮고 모양 바뀜이 높은 칸을 겨눌 때 쓸 만해요. + +### Heawood Torus + +주제 1 과 5 로 헤우드의 일곱 색 지도를 위상·매듭 갈래의 다섯째로 세웠어요. 겨눈 칸은 Balloon Box 혼자 서 있던 색 × 모양 바뀜 4,3 이에요. 첫 판은 색 0.416, 모양 바뀜 0.223 이었어요. 색은 꺼진 LED 까지 세서, 토러스가 패널을 43% 만 덮으면 나라 색이 아무리 진해도 값이 오르지 않았어요. 관 반지름과 중심 곡선 반지름의 비를 0.8 에서 0.85 로 올려 관을 굵게 하고 채도를 1 로 올리니 0.546 이 됐고, 그늘 쪽 밝기를 0.45 에서 0.6 으로 올리자 0.592 가 됐어요. 모양 바뀜은 2초 화면에서 토러스가 정면을 얼마나 보고 있느냐에 달려 있었어요. 회전을 정면에서 시작해 2초에 46도 돈 자리에 두고 한 바퀴를 20초에서 16초로 당기니 둘 다 칸 가운데에 들어섰어요. 마지막 값은 색 0.624, 모양 바뀜 0.275, 채움 0.545 예요. 매개변수 사각형을 1400×1400 점으로 찍어 보니 21쌍의 나라가 다 경계를 나눴고, 나라마다 넓이가 꼭 7분의 1이었고, 세 나라가 만나는 점은 14개라 14 − 21 + 7 = 0 이에요. 패널에서는 일곱 나라가 육각형으로 읽히지 않아요. 이 토러스의 매개변수 사각형은 둘레 방향이 관 방향보다 2.6배 길어서, 육각형이 세 배쯤 늘어나 비스듬히 감기는 띠가 돼요. 경계는 화면에서 이웃 LED 의 나라가 다를 때 한 LED 를 어둡게 칠해서 늘 한 LED 굵기로 또렷했고, 한 바퀴 도는 동안 21쌍의 경계가 모두 LED 위에 나타나는 것을 따로 셌어요. `study.ts` 는 프레임당 1.8 ms 로 재는데 패턴 코드만 재면 0.8 ms 였어요. 나머지는 Lab 엔진 몫이라, 3차원 패턴이 2 ms 아래에 들려면 제 계산을 1 ms 안에 끝내야 해요. + +### Schwarz Lantern + +주제 4 와 5 로 만들었어요. 원기둥을 114 LED 높이로 세워 `thirds` 가 69/80/71 이에요. 닫힌 식 A = 2mn · r sin(π/n) · √((h/m)² + r²(1 − cos(π/n))²) 은 삼각형 2mn 개의 넓이를 하나씩 더한 값과 랜턴 서른 개에서 상대 오차 8.6e-14 안으로 같았어요. 원기둥 넓이로 나누면 m = n 은 1.0000 으로, m = n² 은 예측대로 1.5056 으로 가고, m = n³ 은 n 이 10 에서 10⁴ 로 가는 동안 11, 112, 1125, 11255 로 끝없이 커져요. 처음에는 띠를 60개까지 늘렸는데, 띠 하나가 1.9 LED 가 되자 접힌 면이 가로 줄무늬로 뭉개져 잡음처럼 보였어요. 그래서 한 고리의 꼭짓점을 6개에서 5개로 줄이고 띠는 40개에서 멈췄어요. 접힘이 깊어져 40개만으로도 넓이가 원기둥의 1.88배가 되고, 띠 하나가 2.9 LED 라 마름모 면이 보여요. 이 패널에서는 띠가 3 LED 를 넘어야 접힌 면이 면으로 읽혀요. 앞 왼쪽 위에서 오는 빛 하나로는 가장 구겨진 꼴이 밋밋한 줄무늬였어요. 뒤쪽 높이 둔 둘째 빛이 위로 기운 면에만 반짝임을 얹자 아코디언처럼 읽혔고, 하이라이트를 줄이고 채도를 0.8 에서 0.85 로 올려 색이 0.261 에서 0.330 이 됐어요. 띠 수는 정수라서, 맨 위 띠가 납작한 데서 제 높이까지 열리게 해 띠가 늘 때 그림이 끊기지 않게 했어요. 그 대신 고리가 모두 아래로 흘러 움직임이 2.209 로 높아요. 마지막 가장자리는 0.830, 모양 바뀜은 0.197 이에요. + +### Gibbs Phenomenon + +주제 4 와 5 로 깁스 현상을 물결 갈래에 세웠어요. 사각파 한 주기를 가로에 놓고 r 번째 줄에 푸리에 급수의 앞 32^(r/127) 항을 더해서, 항 수가 25줄마다 두 배가 돼요. 합이 그 자리 파형의 범위 바깥으로 나간 만큼을 흰 금색으로 칠해요. 따로 셈한 사각파 부분합의 최댓값은 3번째 고조파까지 1.20042, 9번째까지 1.18233, 641번째까지 1.17897 로 줄며 1.178980 에 다가갔고, 이는 점프 2 의 8.949% 예요. 패널 맨 아래 줄의 최댓값은 1.1790 이에요. 첫 판은 금빛이 화면 대부분을 덮었어요. 고원에서는 파형의 범위가 [1, 1] 이라 합이 1 에 못 미치는 것까지 넘침으로 셌던 탓이에요. 바깥쪽으로 나간 것만 세자 뿔과 맨 위의 둥근 언덕만 남았어요. 항 수를 줄 번호의 제곱으로 놓았을 때는 3항 아래가 위 4분의 1을 차지해서 로그 눈금으로 바꿨어요. 그러자 뿔이 위의 둥근 금빛에서 갈라져 점프 쪽으로 매끈하게 휘어 내려와요. 맨 아래 줄의 뿔은 점프에서 π/64 라디안 떨어진 곳에 서서, 한 칸 너비 0.098 라디안의 절반쯤이에요. 그래서 금빛을 부표본의 평균이 아니라 최댓값으로 정해야 뿔이 끝까지 같은 밝기로 남았어요. LED 마다 색을 다시 지어 한 프레임이 4.1 ms 이던 첫 판은, 색을 프레임마다 두 번만 짓고 칸마다 부표본을 6개에서 4개로 줄여 1.3 ms 가 됐어요. Fejér 노브를 선형으로 두면 0.06 에서 뿔이 7.74% 로 열려서 ((K4 − 0.06)/0.94)² 로 바꿨어요. 마지막 값은 움직임 0.517, 모양 바뀜 0.233, 채움 0.871, 색 0.329 이고 `thirds` 는 71/91/99 예요. 줄이 부분합의 항 수라서, 32판의 Talbot Carpet 처럼 위로 흐르지 않고 파형만 옆으로 미끄러져요. + +### Elliptic Chords + +주제 5 로 타원 곡선 위 점의 덧셈을 수론 갈래에 세웠어요. y² = x³ − 6x + b 에서 G 와 kG 를 잇는 직선의 세 번째 점을 x축에 뒤집어 (k+1)G 를 얻는 일을, 위에는 실수 곡선으로, 아래에는 61 로 나눈 나머지의 61×61 판으로 같은 박자에 그려요. b 를 −4 에서 8 까지 바꾸며 F₆₁ 위의 점을 다 셌더니 b = ±4 만 72개 가운데 36개를 도는 순환이 가장 길었고, 나머지 열한 곡선은 점 전체가 순환 하나였어요. 열리는 b = 0 은 O 까지 74개예요. 위쪽 생성원은 dx/y 로 잰 실수 고리 위에서 차수가 같은 N 인 점으로 골라서, 두 판이 같은 걸음에 수직선을 만나 O 로 돌아와요. 두 바퀴씩 돌린 검사에서 모든 직선은 곡선과 정확히 세 점에서 만났고 생성원은 N − 1 개 점을 빠짐없이 지났어요. F₆₁ 의 직선을 x 를 하나씩 늘려 걸으면 점이 m 줄씩 튀어서 흩뿌린 모래로 읽혔어요. 라그랑주 축소로 찾은 가장 짧은 격자 벡터로 걸으니 한 걸음이 길어야 8.06 LED 라서 판을 감아 도는 짧은 줄로 읽혀요. 첫 판은 움직임 0.018, 모양 바뀜 0.010 으로 두 축의 아래 끝 바깥이었고 K4 반응도 0.008 이었어요. 지나간 직선을 1.6 걸음 동안 남겨 G 를 지나는 직선 다발이 보이게 하고 걸음을 초당 1 에서 1.5 로 올리자 0.050 과 0.024 가 됐어요. `study.ts` 의 노브 반응은 새로 시작한 뒤 2~3초만 견줘서, 시간이 지나야 쌓이는 꼬리 노브는 첫 화면에 이력이 없으면 낮게 나와요. 한 바퀴의 80% 가 켜진 채 열리게 하자 K4 가 0.028 이 됐어요. 실수 쪽 긴 가지는 매개변수의 59% 가 창 밖이라, 짝수 배수는 반 넘게 화면 위아래로 뛰어나갔다 돌아와요. + +### Catastrophe Machine + +주제 5 로 지먼의 파국 기계를 세웠어요. 흔히 쓰는 치수, 고정점 거리 2 와 고무줄 길이 1 로 극소가 둘인 영역을 세어 보니 마름모의 위 뾰족점이 원판 안으로 들어가 가려졌어요. 고정점을 원판 반지름의 2.5배 거리로 옮기자 마름모가 가로 30 LED, 세로 34 LED 로 원판 아래 8 LED 떨어져 섰어요. 경계는 원판 각도마다 V′ = V″ = 0 을 풀어 그렸고, 격자 8319점에서 극소 개수로 센 영역과 한 점도 어긋나지 않았어요. 처음에는 원판이 빨리 돌면 튐으로 셌어요. 그런데 극소가 사라진 뒤에도 원판이 한동안 느리게 미끄러져서, 튐이 B 가 경계를 4 LED 쯤 지난 뒤에 잡혔어요. 원판이 있던 극소를 프레임마다 따라가다가 그 극소가 없어지는 프레임을 튐으로 바꾸자, 튐 자리가 계산한 경계에서 0.022 반지름 안에 찍혔어요. 선만 있던 그림은 노브 반응이 0.02~0.03 이었어요. 원판에 함께 도는 부채꼴 여섯 칸을 칠하고 빨리 돌수록 밝게 하자 0.04, 0.04, 0.05, 0.04 가 됐어요. 가는 선만으로는 이 패널에서 회전이 잘 읽히지 않았고, 무늬 있는 원판이 120도 남짓 휙 도는 것이 튐을 가장 또렷하게 보여 줬어요. 어두운 금색으로 칠한 마름모는 올리브로 보여서 보라로 바꿨어요. 잰 값은 움직임 0.213, 채움 0.195, 색 0.100, 모양 바뀜 0.053 이에요. + +### Lotion Ribbon + +주제 4 와 6 을 함께 겨눴어요. 줄기는 질량 흐름 πa²v 가 같도록 낙하 속도의 제곱근에 반비례해 가늘어지고, 고리는 닿는 속도 그대로 깔려서 ΩR = U 예요. 감는 빠르기는 리브(Neil Ribe, 2004)의 중력 영역 법칙 Ω ∝ (gQ³/νa⁸)^(1/4) 을 따르고, 더미는 h³ 이동도의 점성 중력류로 퍼져요. 처음엔 a 의 지수를 10 으로 적었는데 차원을 맞춰 보니 1/T 가 나오지 않아 8 로 고쳤어요. 검산에서 쌓인 부피는 Q·t 와 비율 1.00000 이었고, 그려진 줄기 너비로 잰 πa²v 는 위에서 아래까지 0.99 와 1.02 사이에 머물렀어요. h³ 흐름만 두면 고리가 곧바로 매끈해졌어요. 갓 깔린 고리를 따로 두었다가 3초에 걸쳐 더미로 녹이고 닿는 점을 0.7R 만큼 떠돌게 하니 그제야 접힌 결이 읽혔어요. 세로로 선 원통은 위에서 오는 빛만으로는 하이라이트가 흐리게 남아서, 눈 옆에 긴 띠 조명을 하나 더 세워야 줄기를 따라 흰 줄이 서요. 처음엔 벽이 모두 24/255 위라 채움이 축 끝 1.000 에 붙어 있었고, 위 두 모서리를 거의 검게 떨어뜨려 0.958 로 내렸어요. 한 프레임도 처음엔 4.9 ms 였어요. 줄기 공마다 빛을 다시 셈하던 것을 LED 마다 한 번으로 줄이고 더미의 h³ 흐름을 2 LED 칸으로 옮기니 1.7 ms 가 됐어요. K1 을 주황과 노랑에 두면 깊은 벽이 갈색과 올리브로 보여서, 0.1 에서 0.42 사이는 벽을 거의 검게 내리고 로션을 크림 빛으로 옅게 했어요. 잰 값은 움직임 0.066, 채움 0.958, 색 0.134, 모양 바뀜 0.036 이고 `thirds` 는 87/100/100 이에요. 줄기만으로 위 3분의 1의 17% 쯤을 켜요. + +### Shimmer Oil + +주제 6 으로 마이카가 든 쉬머 오일을 그렸어요. Gold Leaf 의 낱장 금박과 달리 조각 하나하나는 보이지 않고, 흐름이 조각을 얼마나 세웠는지가 그대로 결이 돼요. 칼리로스코프 같은 레오스코픽 유체가 흐름을 보여 주는 길이에요. 흐름은 24×48 칸의 와도와 흐름 함수로 풀고, 조각은 두께 없는 원판의 제퍼리 방정식 dm/dt = −(∇u)ᵀm 을 따라 돌려요. 흐림을 끄고 잰 순수 신장 3초에서 늘어난 정도가 3.3204 로 e^1.2 = 3.3201 과 맞았고, 단순 전단에서는 4.236 과 −76.7도가 나와 손으로 푼 값과 같았어요. 처음에는 조각마다 기운 방향을 하나씩 줘서 화면이 잡음뿐이었고, 모양 바뀜이 0.009 로 축의 아래 끝 0.021 밑이었어요. LED 마다 조각 묶음 하나가 기운 방향의 퍼짐 M = AAᵀ 를 통째로 들고 빛을 표로 평균하게 바꾸자 결이 비단처럼 이어지고 모양 바뀜이 0.094 가 됐어요. M 을 단위행렬 쪽으로 그냥 당기면 소용돌이 안에서 행렬식이 15 를 넘어 검은 반점이 박혔어요. 대각 아닌 부분만 줄이고 행렬식을 1 로 묶자 반점이 사라졌어요. 한 프레임에 5.3 ms 들던 것은 흐름을 두 프레임에 한 번 풀고 조각을 짝홀로 나눠 돌려 2.3 ms 로 줄였어요. 이 패널에서는 이력이 다른 조각이 1 LED 안에 섞이면 결이 후추처럼 깨져 보였고, 1-2-1 흐림을 두 번 거친 뒤에야 실크로 읽혔어요. 주황과 노랑에서 오일을 밝기 0.6, 채도 0.6 으로 올렸더니 황토색이 돼서, 밝기 0.82, 채도 0.8 로 아예 빛나게 두었어요. 잰 값은 움직임 0.123, 가장자리 0.000, 채움 1.000, 모양 바뀜 0.094 이고, 노브 반응은 K1 이 0.49 로 가장 크고 K3 이 0.07 로 가장 작아요. + +### Blooming Gel + +주제 6 으로 블루밍 젤 네일을 그렸어요. 방울의 젤은 다공질 매질 방정식 w_t = ∇·(D w ∇w) 로 32×64 칸에서 번져요. 안료는 LED 마다 따로 실려 있다가, 젤이 얇아지는 앞선 근처에서만 젤보다 빨리 달려가 테두리에 쌓여요. 젤 부피는 번지는 내내 소수점 넷째 자리까지 그대로였고, 앞선 반지름의 네제곱이 늘어나는 빠르기는 바렌블라트 해의 16VD/π 의 1.07배였어요. 테두리는 Rim 0.06 에서 바깥 4 LED 의 안료가 가운데의 2.1배이고, 1 에서는 70배라 가운데가 비어요. 처음 판은 옅은 바탕에 밝은 틴트를 얹어서 움직임 0.011, 촘촘함 0.003 으로 두 축의 아래 끝을 넘었어요. 잣대가 가장 밝은 채널만 읽어서, 파란 안료가 빨강과 초록을 빼도 파랑은 바탕보다 밝아 거의 바뀌지 않은 셈이었어요. 틴트를 명도 0.58 로 짙게 하자 0.037 과 0.014 가 됐어요. 한 판을 12초에서 10.3초로 줄여 경화 램프가 2~8초 안에 지나가게 하자 움직임이 0.071 로 올랐어요. 막 떨어진 방울이 두꺼울 때는 명시적 풀이가 잘게 쪼개져야 해서, 한 프레임에 열두 단계까지만 풀고 그 순간의 시간을 조금 늦췄더니 2.1 ms 에 들었어요. 이 패널에서는 안료가 들어갈 수 있는 젤 두께 문턱을 LED 마다 네 배씩 흔든 것만으로 테두리가 깃털처럼 해져 보였어요. 젤이 퍼지는 빠르기를 흔든 잡음은 다공질 매질 방정식이 금방 둥글게 펴 버렸어요. 잰 값은 움직임 0.071, 가장자리 0.148, 채움 1.000, 모양 바뀜 0.044 이고, K4 Rim 의 반응이 0.03 으로 가장 낮아요. + +### Whipped Soap + +주제 6 을 겨눴어요. 별 깍지의 단면을 ρ(φ) = 1 − c + c(1 − |sin(nφ/2)|)² 로 두어 꼭짓점은 모나고 골은 둥글게 했고, 이 단면을 나선 길을 따라 높이장에 찍어요. 검산에서 n 이 5, 7, 10 일 때 꼭짓점과 골이 n 개씩 나왔어요. 한 바퀴는 깍지 너비만큼 앞서 깔린 거품 위에 얹혀서, Swirl 이 −1, 0, 1 일 때 가장 높은 점이 5.3, 11.7, 16.9 LED 로 솟아요. 처음엔 짜는 송이 하나만 바뀌어서 움직임이 0.012 로 축 끝 0.019 아래였어요. 빛을 7초마다 ±0.5 라디안 흔들고 그림자는 세 방위의 지평선 기울기를 미리 재 두니 0.031 이 됐고, 한 프레임은 1.6 ms 로 남았어요. 쟁반도 처음엔 모두 24/255 위라 채움이 1.000 이었고, 쟁반을 낮춰 송이 그림자가 꺼지게 하니 0.976 이 됐어요. 세 칸 여섯 줄로 짜 보니 송이 하나가 21 LED 라서 패널에서는 보풀 덩어리로만 보였어요. 두 칸 네 줄로 바꿔 한 칸을 32 LED 로 키우니 나선이 읽혔어요. 별 꼭짓점의 결을 길 방향 그대로 두면 1~2 LED 간격의 동심원이 되어 송이가 동전처럼 보였어요. 깍지를 길 1 LED 마다 0.11 라디안씩 돌리니 결이 바퀴를 비스듬히 건너면서 휘핑 거품으로 보였어요. 잰 값은 움직임 0.031, 가장자리 0.364, 채움 0.976, 모양 바뀜 0.070 이에요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -1650,7 +1709,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 24일 `dev`(`e4204d2`)의 238개에 34판의 열 개를 더한 248개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 24일 `dev`(`ba6e941`)의 248개에 36판의 열 개를 더한 258개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 @@ -1666,6 +1725,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 34판에서는 Lakes of Wada 의 첫 판이 두 축의 끝을 넘었어요. 색 0.723 이 Outer Billiards 의 0.675 를, 움직임 0.017 이 Star Trails 의 0.019 를 넘어서, 248개의 `--gaps` 가 가장자리 × 색 1,4 와 움직임 × 모양 바뀜 1,4, 복잡도 × 색 4,4, 채움 × 색 1,4, 색 × 모양 바뀜 4,3 과 4,4 를 찍었어요. 여섯 다 `*` 가 없는 칸이라, 축을 넘은 패턴을 안으로 들이는 것이 길이었어요. 1 LED 운하는 땅 14 에서 물 255 로만 바뀌고, 막 판 자리가 흰빛에서 물빛으로 식어도 가장 밝은 채널은 255 그대로예요. 그래서 움직임은 1초에 파는 LED 수에 묶여 Speed 2 에서 0.0185 가 끝이었어요. 파는 빠르기를 1.4배로 올리고 채도를 0.88 로 누르자 0.023 과 0.633 이 되어 21장이 다시 다 `none` 이에요. 모양 바뀜도 블록 평균이 한쪽으로만 바뀌어 움직임의 여섯 배를 넘지 못해요. Balloon Box 혼자인 색 × 모양 바뀜 4,3 에 서려면 0.217 이 필요했는데, 그러려면 섬 하나를 6초 만에 다 파야 해서 겨누지 않았어요. Thomson Problem 은 가장자리 × 채움 1,1 에 Littlewood Roots 의 둘째로 섰어요. 판이 거의 어두운 점 그림은 노브 반응이 화면 평균 밝기 언저리에서 멈춰서, 채움 0.302 와 가장자리 0.210 아래를 지키며 노브 넷을 0.05 위로 올리려면 점의 봉우리와 번짐 너비를 맞바꿔야 했어요. 패턴이 하나뿐인 칸은 238개에서 24 이던 것이 248개에서 23 이에요. +36판의 열 개도 어느 축의 끝을 넘지 않아서 칸 경계는 30판 그대로이고, 258개에서도 지도 21장이 다 `none` 이에요. 주제 1 은 패턴이 하나뿐인 칸 둘을 겨눠서, 248개에서 23 이던 그런 칸이 21 이 됐어요. 채움 × 모양 바뀜 1,4 는 LED 의 70% 넘게 꺼진 채 2~8초에 블록 평균이 0.331 넘게 바뀌어야 하는 칸이에요. Curve Shortening 은 곡선 단축 흐름에서 넓이가 흐름 시간 1 에 꼭 2π 씩 줄어드는 것을 썼어요. 2초에 패널의 36.5% 를 거의 꽉 찬 밝기로 채운 곡선이 7.5초쯤 점으로 사라지면, 채움은 시작의 절반쯤인 0.194 에 남고 모양 바뀜은 시작 넓이만큼인 0.375 가 돼요. 첫 판은 넓이 4209 로 모양 바뀜 0.487 이라 축 끝 0.413 을 넘었어요. 색 × 모양 바뀜 4,3 은 색 0.545 위에 모양 바뀜 0.217~0.331 인 칸이에요. 색은 꺼진 LED 까지 세서, Heawood Torus 의 첫 판은 토러스가 패널을 43% 만 덮어 일곱 나라를 채도 끝까지 칠해도 0.416 이었어요. 관을 굵게 해 패널의 반을 덮고 그늘 쪽 밝기를 0.6 으로 올려 0.624 를 냈어요. 모양 바뀜은 2초에 정면에서 46도 돈 토러스가 16초에 한 바퀴 돌며 옆모습으로 바뀌는 데서 0.275 가 나와요. 패턴이 하나뿐인 칸 21 가운데 넷은 모양 바뀜이 높은 촘촘함의 칸이에요. 촘촘함 × 모양 바뀜 3,3, 3,4, 4,3, 4,4 의 Flipdot, Bait Ball, Sequins, Pin Art 예요. + 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 를 냈어요. @@ -1680,7 +1741,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ### 아직 없는 갈래 -`families.json` 의 쉰다섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 과 31판의 Boy's Surface, 33판의 Borromean Rings, 34판의 Lakes of Wada 예요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 세잎 매듭처럼 이름 있는 매듭이 그 갈래의 다섯째로 맞아요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 30판의 Swatch Row, Pearl Sheen, Bi-Phase, Satin Fold 가 그 갈래에 넷을 더했고, 31판의 Lip Gloss, Foam Lather, Setting Mist, Sponge Blend 가 넷을 더 더해 열둘이에요. 32판의 Cushion Mesh, Clay Mask, Sugar Scrub, Chrome Powder 가 넷을 더해 열여섯이고, 33판의 Cleansing Balm, Gold Leaf, Essence Drop, Peel-Off 가 넷을 더해 스물이에요. 34판의 Cat Eye, Velvet Nap, Mood Tint, Hair Gloss 가 넷을 더해 스물넷이에요. 28판이 연 수론 갈래는 Ford Circles 와 30판의 Three Gaps, 31판의 Gaussian Moat, 32판의 Gilbreath Table, 33판의 Goldbach Comet 이에요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. +`families.json` 의 쉰다섯 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 29판이 연 위상·매듭 갈래는 Hopf Fibration 과 31판의 Boy's Surface, 33판의 Borromean Rings, 34판의 Lakes of Wada, 36판의 Heawood Torus 예요. 28판이 열려다 접은 갈래라 28판의 Hairy Ball 은 노이즈·흐름에 그대로 있어요. 세잎 매듭처럼 이름 있는 매듭이 그 갈래의 여섯째로 맞아요. 29판은 화장품 팝업 갈래도 열었어요. 주제 6 의 몫이라 주제 2 로 세지 않았고, Cream Swirl, Serum Gel, Glitter Fall, Loose Powder 넷이 다 들어 있어요. 30판의 Swatch Row, Pearl Sheen, Bi-Phase, Satin Fold 가 그 갈래에 넷을 더했고, 31판의 Lip Gloss, Foam Lather, Setting Mist, Sponge Blend 가 넷을 더 더해 열둘이에요. 32판의 Cushion Mesh, Clay Mask, Sugar Scrub, Chrome Powder 가 넷을 더해 열여섯이고, 33판의 Cleansing Balm, Gold Leaf, Essence Drop, Peel-Off 가 넷을 더해 스물이에요. 34판의 Cat Eye, Velvet Nap, Mood Tint, Hair Gloss 가 넷을 더해 스물넷이고, 36판의 Lotion Ribbon, Shimmer Oil, Blooming Gel, Whipped Soap 이 넷을 더해 스물여덟이에요. 28판이 연 수론 갈래는 Ford Circles 와 30판의 Three Gaps, 31판의 Gaussian Moat, 32판의 Gilbreath Table, 33판의 Goldbach Comet, 36판의 Elliptic Chords 예요. 27판이 연 철도 갈래는 Funicular 하나이고, 필름·영화 갈래는 Horse in Motion 하나예요. 두 갈래는 주제 표가 수학 쪽으로 바뀌기 전에 열었어요. 그 뒤로 ROUND.md 는 사물로 묶는 갈래를 더 열지 않아요. 26판이 연 얼음·극지 갈래는 Iceberg 와 27판의 Sled Dogs 이고, 미생물 갈래는 Dark Field 와 27판의 Mitosis 예요. 25판이 연 농사·밭 갈래는 Rice Terraces 와 26판의 Rice Planting, 27판의 Center Pivot 이고, 화산 갈래는 Eruption 과 26판의 Geyser, 27판의 Lava Lake 예요. 24판이 연 공사·건설 갈래는 Tower Crane 과 25판의 Excavation, 26판의 Implosion 이에요. 24판이 연 도자기 갈래는 Potter's Wheel 과 25판의 Kintsugi, 26판의 Noborigama 예요. 23판이 연 무대·공연 갈래는 Proscenium 과 24판의 Flying Trapeze, 25판의 Marionette, 26판의 Shadow Play 예요. 23판이 연 짐승 갈래는 Giraffe 와 24판의 Emergence, 25판의 Snow Monkeys 예요. 22판이 연 몸 갈래는 Circulation 과 23판의 Axon, 24판의 Breath, 27판의 Fluoroscope 예요. 22판이 연 비행 갈래는 Liftoff 와 23판의 Final Approach, 24판의 Thermal, 25판의 Skydiver 예요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver, 23판의 Skein 이에요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test, 23판의 Elephant Toothpaste 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru, 23판의 Hanging Scroll, 26판의 Origami 예요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane, 23판의 High Dive, 26판의 Pole Vault, 27판의 Climbing Wall 이에요. 위아래로 움직이는 경기는 High Dive 와 Pole Vault 뒤로 27판에 실내 암벽이 섰어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori, 27판의 Glassblower 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker, 25판의 Ferris Wheel 이에요. 말이 오르내리는 회전목마는 아직 없어요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint, 23판의 Bait Ball 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp, 24판의 Waggle Dance 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables, 24판의 Pipe Organ 이에요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint, 23판의 Sequins 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. ### 멋진 것 더 멋지게 @@ -1694,6 +1755,8 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 34판의 열 개는 가장 낮은 노브가 Thomson Problem 의 K3 Charges 와 K4 Heat 로 둘 다 0.05 예요. 판이 거의 어두운 점 그림이라 비교한 두 화면에 같은 점이 하나도 없어도 반응이 화면 평균 밝기 언저리에서 멈췄어요. Moving Sofa 의 K1 Hue 는 표가 0.05 로 찍었지만 네 자리로 재면 0.0463 이었고, 마지막 판은 0.0600 이에요. 표는 노브 반응을 두 자리로 반올림해서 약한 노브를 가려요. Mood Tint 의 K4 Blend 는 판 대부분이 방 온도라 0.02 였고, 문턱의 가장 넓은 폭을 0.32 에서 0.61 로 넓히자 0.09 가 됐어요. 빠르기 노브는 미리 돌리는 처음 시간도 Speed 대로 두어야 반응이 났어요. Lakes of Wada 는 처음 섬을 Speed 와 상관없이 파 둔 판이 0.04 였고, 그 시간을 Speed 대로 파게 하자 0.12 가 됐고, 마지막 판은 0.11 이에요. +36판의 열 개는 가장 낮은 노브가 Elliptic Chords 의 K4 Trail 로 네 자리로 0.0281 이에요. 2~3초 창은 꼬리가 쌓이기 전이라, 꼬리 길이를 바꾸는 노브는 첫 화면에 쌓인 이력이 없을 때 0.008 이었어요. 한 바퀴의 80% 가 켜진 채 열리게 하자 0.028 이 됐어요. Lotion Ribbon 의 K2 Speed 와 Blooming Gel 의 K4 Rim 도 0.03 이에요. Catastrophe Machine 은 가는 선만 그린 판에서 노브 넷이 0.02~0.03 이었고, 원판에 함께 도는 부채꼴 여섯 칸을 칠하자 0.04~0.05 가 됐어요. 선 그림은 노브가 바꾸는 LED 가 적어서, 함께 움직이는 면 하나를 두면 노브 넷이 다 올라요. + Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. ### 세로 64×128 @@ -1712,9 +1775,11 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 34판에서는 Kelvin Wake 가 쐐기의 반각 19.47° 로 세로를 썼어요. 쐐기가 원천 뒤 90 LED 에서 패널 양옆에 닿아서, 원천이 판을 아래에서 위로 건너는 동안 물결이 128줄을 다 덮어요. 물을 다 켜서 `thirds` 는 99/99/99 이고, 2~8초에 밝기가 24/255 넘게 오르내린 LED 로 세어도 3분의 1마다 100% 예요. 2초 화면과 8초 화면만 견주면 0/16/30 이었어요. 물결 한 주기 6.3초가 창 안에 들어서 두 화면의 물결이 거의 같은 자리에 와요. 바뀐 LED 는 오르내린 폭으로 세야 해요. Scheidegger Rivers 는 가장 작은 실개천도 30/255 에 세워서 90/92/92 이고, 위 3분의 1은 거의 다 실개천이에요. Hair Gloss 는 머리가 판을 다 채워 99/99/94 이고, 오르내린 LED 로 세면 97.8/100/100% 예요. 빗을 끄면 위 3분의 1이 80.0% 로 내려가요. Moving Sofa 는 ㄇ자 복도의 다리 둘을 세로로 세웠어요. 다리 사이 벽의 빗금 40/255 가 셈에 들어서 45/29/28 이에요. +36판에서는 Schwarz Lantern 이 원기둥을 114 LED 높이로 세워 69/80/71 이에요. 띠가 3 LED 아래로 얇아지면 접힌 면이 가로 줄무늬로 뭉개져서, 띠를 60개까지 늘리던 첫 판을 40개에서 멈췄어요. Gibbs Phenomenon 은 줄이 부분합의 항 수라 71/91/99 예요. 항 수를 줄 번호의 제곱으로 두었을 때는 3항 아래가 위 4분의 1을 차지해서, 25줄마다 두 배가 되는 로그 눈금으로 바꿨어요. Lotion Ribbon 은 위 3분의 1에 줄기만 있어서 줄기가 그 17% 쯤을 켜고, 나머지는 벽의 빛이라 87/100/100 이에요. Curve Shortening 은 곡선 하나가 판 가운데에 서서 14/31/11 이고, Catastrophe Machine 은 고정점과 원판, 마름모를 위에서 아래로 놓았는데도 선 그림이라 10/30/17 이에요. + ### 낯선 수학 -주제 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)예요. +주제 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)예요. 36판도 여섯을 더했어요. 그레이슨의 정리가 말하는 곡선 단축 흐름(Curve Shortening), 헤우드의 일곱 색 토러스 지도(Heawood Torus), 슈바르츠 랜턴(Schwarz Lantern), 윌브러햄과 깁스의 현상(Gibbs Phenomenon), 타원 곡선의 현과 접선(Elliptic Chords), 지먼의 파국 기계(Catastrophe Machine)예요. 목록 밖에서 다음에 해 볼 것이에요. 콜라코스키 수열(1965)은 제 길이 부호를 읽으면 제가 되는 1 과 2 의 줄이라 리듬·펄스에 맞아요. 영 도형의 플랑셰렐 성장은 상자가 하나씩 붙으며 로건-셰프와 베르시크-케로프의 극한 곡선(1977)으로 다가가요. 아즈텍 다이아몬드의 북극권과 같은 극한 모양이라 같은 판에 두지 않아요. 슈타이너 사슬은 반전 하나로 고리의 원들이 미끄러지는 포리즘이고, 민코프스키의 물음표 함수(1904)는 페리 나무를 이진 나무로 펴는 계단이에요. 수론 갈래는 32판의 Gilbreath Table 까지 넷이에요. 렌니의 주차 문제(1958)는 줄마다 길이 1 인 차가 아무 데나 서다가 빈자리가 다 1 보다 짧아지면 멈추는데, 128줄이 다 멈추면 채운 몫이 렌니 상수 0.7476 언저리에 모여요. 길브레스 표는 소수 말고 다른 수열로도 세울 수 있어요. 오들리코(1993)에 따르면 2 다음에 간격이 작고 고르게 흩어진 홀수가 오는 수열에서는 1 의 열이 서고, 간격이 크면 깨져요. 간격의 크기를 노브로 걸면 1 의 열이 무너지는 자리가 보여요. 해머슬리 과정의 점 대신 격자 칸마다 지수 분포 무게를 두면 마지막 통과 스미기(로스트, 1981)가 되고, 극한 모양이 쌍곡선에서 (√x+√y)² 의 경계로 바뀌어요. @@ -1732,12 +1797,16 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 34판에서 본 것이에요. 정리가 수를 못 박는 대상은 그 수를 셈으로 지켜봤어요. Thomson Problem 은 볼록 껍질로 센 Σ(6 − z) 가 노브 넷을 다 돌리고 단추를 두 번 누른 600 프레임 동안 늘 12 였고, Pentomino Cover 는 끝까지 돈 탐색이 네 판에서 2339, 1010, 368, 2 를 찾았어요. 모양이 주인공인 대상은 그 모양이 맞는지 재는 셈을 그림보다 먼저 짰어요. Moving Sofa 는 소파가 벽 밖으로 0.0000 LED 나가는 것과 2% 큰 소파가 0.457 LED 나가는 것을 본 뒤에 그렸어요. Kelvin Wake 는 가장 밝은 물마루를 이은 각이 17.2~18.3° 로 안쪽에 서서, 에어리 무늬의 안쪽 밀림을 빼고서야 19.47° 가 나왔어요. 극한을 말하는 대상은 이번에도 LED 한 칸에서 멈췄어요. Lakes of Wada 는 땅이 다 물에 닿아도 양쪽에 물이 있는 1 LED 벽이 42~48% 였어요. Scheidegger Rivers 의 A^(2/3) 은 64×128 그물 스무 개에서 지수 0.61 로 나왔어요. +36판에서 본 것이에요. 정리가 수를 못 박는 대상은 이번에도 그 수를 셈으로 지켜봤어요. Curve Shortening 의 넓이는 흐름 시간 1 에 6.27 씩 줄어 2π 보다 0.2% 작았고, 걸음을 2.5배 줄이자 6.281 이 돼서 차이가 걸음 크기에서 온 것을 알았어요. Gibbs Phenomenon 의 맨 아래 줄은 1.1790 까지 올라 1.178980 에 닿았고, Heawood Torus 는 한 바퀴 도는 동안 LED 위에 나타난 경계로 21쌍을 다 셌어요. 교과서의 치수가 패널에 맞지 않기도 했어요. 지먼의 기계를 흔히 쓰는 고정점 거리 2 로 두면 마름모의 위 뾰족점이 원판 안에 가려져서, 2.5 로 옮겨서야 네 뾰족점이 다 보였어요. 극한을 말하는 대상은 이번에도 LED 한 칸에서 멈췄어요. Schwarz Lantern 의 넓이는 띠 40개에서 원기둥의 1.88배까지만 오르고, Gibbs Phenomenon 의 맨 아래 뿔은 점프에서 반 칸 떨어져서 부표본의 최댓값을 써야 남았어요. 유한체 위의 직선은 x 를 하나씩 늘려 그리면 흩뿌린 모래였고, 라그랑주 축소로 찾은 가장 짧은 격자 벡터로 걸어야 판을 감아 도는 줄로 읽혔어요. 회전체 토러스에 그린 헤우드의 지도는 둘레가 관보다 2.6배 길어서 육각형이 비스듬히 감긴 띠로 늘어나요. + 목록 밖에서 31판 뒤에 해 볼 것이에요. 모자 타일(스미스, 마이어스, 캐플런, 굿먼-스트라우스, 2023)은 한 가지 타일로만 평면을 비주기로 덮고, 뒤집어 놓인 타일이 판에 드문드문 흩어져요. 페르미-파스타-울람-칭구의 되돌아옴(1955)은 KdV Solitons 와 같은 뿌리라 같은 판에 두지 않아요. 목록 밖에서 33판 뒤에 해 볼 것이에요. 뷔퐁의 바늘(1777)은 Bertrand Paradox 와 같은 꼴로, 금을 넘는 바늘의 몫이 2/π 로 모이는 막대와 함께 세울 수 있어요. 토카르스키의 방(1995)은 거울로 된 다각형 방에서 한 점의 빛이 다른 한 점에만 닿지 못하는 방이라 Penrose Room 의 광선 추적을 그대로 써요. 프리드먼과 스코라(1987)는 둥근 원 셋으로는 보로메오 고리를 만들 수 없다는 것을 보였는데, 원을 밀어 넣을 때 어디서 부딪히는지가 그림이 돼요. 정렬 네트워크의 시간 t 의 순열을 점으로 찍으면 대각선에서 원판을 지나 반대 대각선으로 도는 점 구름이 돼요. 그 원판이 2007년 논문이 내다본 아르키메데스 법칙이에요. 간격이 2k 인 소수 쌍의 수는 Goldbach Comet 과 같은 FFT 로 세고, k 마다 하디-리틀우드 인수로 갈리는 줄기 그림이 돼요. 목록 밖에서 34판 뒤에 해 볼 것이에요. 타메스 문제(피터 타메스, 1930)는 구 위에 같은 원 N 개를 겹치지 않게 가장 크게 놓는 문제라, 톰슨 문제와 달리 가장 가까운 거리만 보고 어디에도 닿지 않고 덜그럭거리는 원이 생겨요. 앙투안의 목걸이(루이 앙투안, 1921)는 고리 사슬의 고리마다 더 작은 사슬을 끼우기를 되풀이한 칸토어 집합이에요. 점이 다 흩어져 있어도 바깥에 건 실이 빠지지 않아서, 세운 패널에 긴 사슬로 두고 단계마다 고리 속 사슬을 드러낼 수 있어요. 로드리게스-이투르베와 리날도의 최적 하천망(1992)은 같은 격자에서 물길마다 Q^0.5 를 더한 에너지가 가장 작은 그물을 담금질로 찾아서, 샤이데거의 무작위 그물보다 덜 구불거리는 나무가 돼요. 로믹의 양손잡이 소파(댄 로믹, 2018)는 왼쪽 모퉁이와 오른쪽 모퉁이를 다 돌 수 있어서, 지그재그 복도를 128줄 쌓고 소파 하나가 끝까지 오르게 할 수 있어요. 데이나 스콧(1958)이 처음 컴퓨터로 푼 8×8 판의 가운데 2×2 구멍 문제는 펜토미노 덮기가 65가지이고, 칸 8 LED 면 판이 64×64 로 패널 너비에 꼭 맞아요. 표면장력이 있는 물결은 원천이 최소 위상속도 23 cm/s 아래로 느려지면 항적이 사라져요(라파엘과 드 젠, 1996). Kelvin Wake 의 FFT 표에 분산식 하나만 바꾸면 그 문턱을 노브에 걸 수 있어요. +목록 밖에서 36판 뒤에 해 볼 것이에요. 아브레슈와 랑거(1986)가 분류한 꽃잎 모양 곡선은 제 몸을 가로지르는데도 곡선 단축 흐름 아래서 모양을 지킨 채 크기만 줄어요. Curve Shortening 의 흐름을 그대로 써서, 원 말고도 그렇게 사라지는 곡선을 보여 줄 수 있어요. 실라시 다면체(실라시 러요시, 1977)는 일곱 면이 다 서로 맞닿은 토러스 꼴 다면체라 Heawood Torus 의 지도를 평평한 면 일곱 장으로 접은 꼴이에요. 원통을 위아래로 누르면 생기는 요시무라 좌굴 무늬(요시무라 요시마루, 1951)는 Schwarz Lantern 과 같은 마름모라 같은 판에 두지 않아요. 룽게 현상(카를 룽게, 1901)은 Gibbs Phenomenon 의 줄과 항 수 배치를 그대로 써서, 등간격 점의 보간 다항식이 차수가 오를수록 양 끝에서 터지고 체비쇼프 점에서는 가라앉는 것을 그릴 수 있어요. 사토-테이트 추측(사토 미키오, 존 테이트, 1960년대, 2011년 증명)은 Elliptic Chords 의 곡선을 소수 p 마다 세어 p + 1 − #E(F_p) 를 2√p 로 나눈 값이 반원 분포로 쌓이는 막대예요. 르네 톰의 일곱 파국 가운데 제비꼬리는 조절 변수가 셋이라, 하나를 노브에 걸면 Catastrophe Machine 의 마름모 자리에 뾰족점 둘과 자기 교차 하나를 가진 경계가 생겼다 사라져요. + ### 화장품 팝업 주제 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 가 생겨요. @@ -1752,7 +1821,9 @@ Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. 34판이 넷을 더 썼어요. 자석을 따라 모이는 자석 젤 네일의 빛줄기(Cat Eye), 손끝이 결을 쓸어 남긴 벨벳의 글씨(Velvet Nap), 손끝의 열에 색이 바뀌는 감온 틴트(Mood Tint), 흔들리는 머리결의 광택 띠(Hair Gloss)예요. 자석 젤 네일은 「다음에 해 볼 질감」에 적혀 있던 감이에요. 빛을 털 한 올이나 반짝이 한 조각의 기울기로 셈하자 그 질감의 빛으로 읽혔어요. Velvet Nap 과 Hair Gloss 는 둘 다 카지야-케이 식으로 결과 빛 사이의 각에서 밝기를 냈고, Cat Eye 는 자기장이 표면과 이루는 기울기를 따라 도는 반짝이로 빛줄기를 세웠어요. 주황과 노랑 쪽의 갈색과 올리브는 넷 다에서 다시 왔어요. 셋은 그 색상 범위에서만 바탕을 밝혔고, Cat Eye 는 거꾸로 젤리를 12 까지 낮추고 띠 곁의 빛을 정렬 정도의 제곱으로 모아 검은 베이스 위의 금색 줄로 읽히게 했어요. Mood Tint 는 색이 바뀌는 문턱 폭을 0.014 로 좁혀서, 열이 번지는 그림인데도 자국 가장자리가 1 LED 로 날카로워요. -다음에 해 볼 질감이에요. 세팅 미스트가 유리판에 맺혀 굵어지고 흘러내리는 것은 Lip Gloss 와 Setting Mist 사이라 같은 판에 두지 않아요. 매니큐어 붓이 남긴 줄이 스스로 펴지는 것은 Cream Swirl 과 가까워서 같은 판에 두지 않아요. 젖은 립글로스나 탑코트가 굳는 것은 Clay Mask 처럼 광이 먼저, 색이 나중에 빠지는 두 문턱으로 그릴 만해요. 립밤이 체온에 녹아 번지거나 고체 향수가 문질러 펴지는 것은 Cleansing Balm 의 녹는 경계를 가로로 돌린 그림이에요. 흰 크림에 금박이 박혀 크림과 함께 천천히 접히는 것은 떠 있는 Gold Leaf 와 다른 그림이에요. 크림 한 방울이 젤 위에 떨어져 천천히 잠기는 것은 Essence Drop 의 거리장을 그대로 써요. 시트 마스크가 굴곡을 따라 붙으며 공기 방울이 밀려 빠지는 것은 Peel-Off 를 거꾸로 돌린 그림이에요. 크러시드 벨벳처럼 결이 조각마다 다른 방향으로 눌린 천을 보이지 않는 손이 쓰다듬어 조각이 하나씩 같은 방향으로 눕는 것은 Velvet Nap 의 결 벡터를 그대로 써요. 빛에 짙어지는 포토크로믹 네일은 보이지 않는 UV 손전등이 지나간 자리만 짙어졌다가 몇 초에 걸쳐 옅어져서, 바탕이 옅고 흔적이 짙은 Mood Tint 의 거꾸로예요. 헤어 오일 한 방울이 가닥을 따라 끝으로 미끄러지며 지나간 가닥만 한 톤 짙어지고 윤이 오래 남는 것은 Hair Gloss 의 가닥 위에 그릴 수 있어요. +36판이 넷을 더 썼어요. 쏟아져 똬리를 트는 로션 줄기(Lotion Ribbon), 마이카가 흐름을 따라 서는 쉬머 오일(Shimmer Oil), 젖은 베이스에 번지는 블루밍 젤 네일(Blooming Gel), 별 깍지로 짠 휘핑 바디 솝(Whipped Soap)이에요. 넷 다 「다음에 해 볼 질감」 밖에서 골랐어요. 질감마다 그 물리를 셈으로 따르자 사물로 읽혔어요. Lotion Ribbon 의 줄기는 질량 흐름을 지키며 가늘어지고 리브의 법칙(2004)으로 감기고, Shimmer Oil 의 조각은 제퍼리의 방정식(1922)으로 돌고, Blooming Gel 의 젤은 다공질 매질 방정식으로 번져요. 가장 밝은 채널만 읽는 잣대가 이번에도 질감을 놓쳤어요. Blooming Gel 의 첫 판은 옅은 바탕에 밝은 파란 틴트를 얹어 움직임 0.011, 촘촘함 0.003 으로 두 축의 아래 끝을 넘었고, 틴트를 명도 0.58 로 짙게 하자 0.037 과 0.014 가 됐어요. Shimmer Oil 은 조각마다 방향을 따로 주면 1 LED 안에서 결이 후추처럼 깨져서, LED 마다 방향의 퍼짐을 행렬 하나로 들고 다녀야 실크로 읽혔어요. Whipped Soap 은 세 칸 여섯 줄로 짜면 송이 하나가 21 LED 라 보풀 덩어리였고, 두 칸 네 줄로 키워서야 나선이 보였어요. 주황과 노랑 쪽의 갈색과 올리브는 넷 다에서 다시 왔어요. Lotion Ribbon 과 Whipped Soap 은 그 색상 범위에서 바탕을 거의 검게 내리고 크림을 옅게 했고, Shimmer Oil 은 오일을 밝기 0.82 로 아예 빛나게 두었어요. Lotion Ribbon 과 Whipped Soap 은 첫 판의 채움이 축 끝 1.000 이었어요. 위 두 모서리와 송이 그림자 아래 쟁반을 꺼서 0.958 과 0.976 으로 내렸어요. + +다음에 해 볼 질감이에요. 세팅 미스트가 유리판에 맺혀 굵어지고 흘러내리는 것은 Lip Gloss 와 Setting Mist 사이라 같은 판에 두지 않아요. 매니큐어 붓이 남긴 줄이 스스로 펴지는 것은 Cream Swirl 과 가까워서 같은 판에 두지 않아요. 젖은 립글로스나 탑코트가 굳는 것은 Clay Mask 처럼 광이 먼저, 색이 나중에 빠지는 두 문턱으로 그릴 만해요. 립밤이 체온에 녹아 번지거나 고체 향수가 문질러 펴지는 것은 Cleansing Balm 의 녹는 경계를 가로로 돌린 그림이에요. 흰 크림에 금박이 박혀 크림과 함께 천천히 접히는 것은 떠 있는 Gold Leaf 와 다른 그림이에요. 크림 한 방울이 젤 위에 떨어져 천천히 잠기는 것은 Essence Drop 의 거리장을 그대로 써요. 시트 마스크가 굴곡을 따라 붙으며 공기 방울이 밀려 빠지는 것은 Peel-Off 를 거꾸로 돌린 그림이에요. 크러시드 벨벳처럼 결이 조각마다 다른 방향으로 눌린 천을 보이지 않는 손이 쓰다듬어 조각이 하나씩 같은 방향으로 눕는 것은 Velvet Nap 의 결 벡터를 그대로 써요. 빛에 짙어지는 포토크로믹 네일은 보이지 않는 UV 손전등이 지나간 자리만 짙어졌다가 몇 초에 걸쳐 옅어져서, 바탕이 옅고 흔적이 짙은 Mood Tint 의 거꾸로예요. 헤어 오일 한 방울이 가닥을 따라 끝으로 미끄러지며 지나간 가닥만 한 톤 짙어지고 윤이 오래 남는 것은 Hair Gloss 의 가닥 위에 그릴 수 있어요. 움직이는 판 위로 떨어지는 점성 줄기는 판이 빨라질수록 똬리가 구불거리는 선으로, 다시 8자 매듭으로 바뀌어요. 치우-웹스터와 리스터의 유체 재봉틀(2006)로 컨베이어 위에 깔리는 로션 띠를 그릴 만해요. Shimmer Oil 의 마이카 장을 두 원통 사이의 테일러-쿠에트 흐름(1923)에 넣으면 세운 패널에 가로 띠 소용돌이가 층층이 쌓여요. 묽은 층이 젖은 바탕으로 밀고 들어가며 손가락처럼 갈라지는 사프만-테일러 불안정(1958)은 마블 네일의 깃털 무늬예요. 위에서 본 비누 거품은 변이 n 개인 방이 (n − 6) 에 비례해 커지거나 줄어드는 폰 노이만의 법칙(1952)으로 굵어져서, Foam Lather 와 다른 질감이 돼요. ## 다시 돌리기 diff --git a/incoming/pattern-study/patterns/shimmer-oil.js b/incoming/pattern-study/patterns/shimmer-oil.js index 2d65a8e7e..99d7f9bb4 100644 --- a/incoming/pattern-study/patterns/shimmer-oil.js +++ b/incoming/pattern-study/patterns/shimmer-oil.js @@ -236,7 +236,7 @@ function flowStep(S, dt) { // The parcels ride the flow, and every flake in one turns as a flat disc of // no thickness does (Jeffery's equation): its normal m follows // dm/dt = K m with K = -(grad u)^T, so a flake turns its face across the -// stretching and lies along the streamlines in shear. Then M follows +// stretching and lies along the flow lines in shear. Then M follows // dM/dt = K M + M K^T, and the film draws M back toward the identity, the // flakes settling to rest. function flakeStep(S, dt) { From c5df38a7c21051eded5042b6a0bb568333be6477 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:08:44 +0000 Subject: [PATCH 8/9] =?UTF-8?q?=ED=8C=A8=ED=84=B4=2036=ED=8C=90=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20258=EA=B0=9C=EB=A1=9C=20index.html=20=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=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_01A5sd4vVG4w3eC2bpCrSD5a --- incoming/pattern-study/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 517197eed..eab6313b4 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,7 +141,7 @@

패턴 연구

- + +