|
35 | 35 | // Reaction-diffusion parameters (Gray-Scott-ish, applied per channel pair) |
36 | 36 | rdFeed: 0.055, |
37 | 37 | rdKill: 0.062, |
| 38 | + // Lattice geometry: 'euclidean' | 'circular' | 'hyperbolic' | 'spherical' |
| 39 | + lattice: 'euclidean', |
| 40 | + // For non-euclidean modes, the grid is bounded by a disk; cells outside |
| 41 | + // the disk are inactive (masked). Radius is in cells, defaults to half |
| 42 | + // the smaller grid dimension. |
| 43 | + latticeRadius: 0, // 0 = auto (min(W,H)/2 - 1) |
| 44 | + latticeCurvature: 1, // scale factor on the metric (k); larger = more curved |
38 | 45 | symmetry: { |
39 | 46 | translationX: false, |
40 | 47 | translationY: false, |
|
43 | 50 | diagonal: false, |
44 | 51 | // Per-angle rotation toggles, keyed by integer degree |
45 | 52 | rotations: {}, |
| 53 | + // Lattice translation symmetries: array of {x, y} in relative |
| 54 | + // coordinates where (1,0) = full grid width, (0,1) = full grid height. |
| 55 | + // Each vector adds peers at (px + tx*W, py + ty*H) and its negative. |
| 56 | + latticeTranslations: [], |
46 | 57 | }, |
47 | 58 | }; |
48 | 59 | for (const a of ROT_ANGLES) state.symmetry.rotations[a] = false; |
49 | 60 |
|
50 | 61 | let animHandle = null; |
51 | 62 | let lastFrameTime = 0; |
| 63 | + // ── Lattice mask ─────────────────────────── |
| 64 | + // active[y*W + x] = 1 if the cell participates in the lattice. For |
| 65 | + // 'euclidean' all cells are active. For disk-based geometries, only |
| 66 | + // cells inside the disk are active. |
| 67 | + let activeMask = null; |
| 68 | + function isActive(x, y) { |
| 69 | + return activeMask[y * GRID_W + x] === 1; |
| 70 | + } |
| 71 | + function latticeRadius() { |
| 72 | + if (state.latticeRadius > 0) return state.latticeRadius; |
| 73 | + return Math.min(GRID_W, GRID_H) / 2 - 1; |
| 74 | + } |
| 75 | + function latticeCenter() { |
| 76 | + return [(GRID_W - 1) / 2, (GRID_H - 1) / 2]; |
| 77 | + } |
| 78 | + // Disk-normalised coordinates: maps cell (x,y) into the open unit disk |
| 79 | + // (used by hyperbolic & spherical metrics). Returns [u, v, r] where r=√(u²+v²). |
| 80 | + function diskCoords(x, y) { |
| 81 | + const [cx, cy] = latticeCenter(); |
| 82 | + const R = latticeRadius(); |
| 83 | + const u = (x - cx) / R; |
| 84 | + const v = (y - cy) / R; |
| 85 | + return [u, v, Math.sqrt(u * u + v * v)]; |
| 86 | + } |
| 87 | + // Distance between two cells under the current lattice geometry. |
| 88 | + // Returns Infinity if either endpoint is inactive. |
| 89 | + function latticeDist(x1, y1, x2, y2) { |
| 90 | + if (!isActive(x1, y1) || !isActive(x2, y2)) return Infinity; |
| 91 | + const k = state.latticeCurvature || 1; |
| 92 | + switch (state.lattice) { |
| 93 | + case 'circular': { |
| 94 | + // Plain Euclidean distance (the disk is just a clipped Cartesian grid). |
| 95 | + const dx = x2 - x1, dy = y2 - y1; |
| 96 | + return Math.sqrt(dx * dx + dy * dy); |
| 97 | + } |
| 98 | + case 'hyperbolic': { |
| 99 | + // Poincaré disk distance: d = arccosh(1 + 2|a-b|² / ((1-|a|²)(1-|b|²))) |
| 100 | + const [u1, v1] = diskCoords(x1, y1); |
| 101 | + const [u2, v2] = diskCoords(x2, y2); |
| 102 | + const a2 = u1 * u1 + v1 * v1; |
| 103 | + const b2 = u2 * u2 + v2 * v2; |
| 104 | + const du = u2 - u1, dv = v2 - v1; |
| 105 | + const num = 2 * (du * du + dv * dv); |
| 106 | + const den = Math.max(1e-9, (1 - a2) * (1 - b2)); |
| 107 | + const arg = 1 + num / den; |
| 108 | + return k * Math.acosh(Math.max(1, arg)); |
| 109 | + } |
| 110 | + case 'spherical': { |
| 111 | + // Map disk → sphere via stereographic projection (north-pole based): |
| 112 | + // (u,v) ∈ unit disk → 3D point on unit sphere. |
| 113 | + // Then return the great-circle (geodesic) distance. |
| 114 | + const [u1, v1] = diskCoords(x1, y1); |
| 115 | + const [u2, v2] = diskCoords(x2, y2); |
| 116 | + const s1 = 1 + u1 * u1 + v1 * v1; |
| 117 | + const s2 = 1 + u2 * u2 + v2 * v2; |
| 118 | + const p1 = [2 * u1 / s1, 2 * v1 / s1, (s1 - 2) / s1]; |
| 119 | + const p2 = [2 * u2 / s2, 2 * v2 / s2, (s2 - 2) / s2]; |
| 120 | + let dot = p1[0] * p2[0] + p1[1] * p2[1] + p1[2] * p2[2]; |
| 121 | + if (dot > 1) dot = 1; |
| 122 | + if (dot < -1) dot = -1; |
| 123 | + return k * Math.acos(dot); |
| 124 | + } |
| 125 | + case 'euclidean': |
| 126 | + default: { |
| 127 | + const dx = x2 - x1, dy = y2 - y1; |
| 128 | + return Math.sqrt(dx * dx + dy * dy); |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + function rebuildLatticeMask() { |
| 133 | + const N = GRID_W * GRID_H; |
| 134 | + activeMask = new Uint8Array(N); |
| 135 | + if (state.lattice === 'euclidean') { |
| 136 | + activeMask.fill(1); |
| 137 | + return; |
| 138 | + } |
| 139 | + const [cx, cy] = latticeCenter(); |
| 140 | + const R = latticeRadius(); |
| 141 | + for (let y = 0; y < GRID_H; y++) { |
| 142 | + for (let x = 0; x < GRID_W; x++) { |
| 143 | + const dx = x - cx, dy = y - cy; |
| 144 | + if (dx * dx + dy * dy <= R * R) activeMask[y * GRID_W + x] = 1; |
| 145 | + } |
| 146 | + } |
| 147 | + // Zero out inactive pixels so they don't contribute stale colour |
| 148 | + if (pixels) { |
| 149 | + for (let i = 0; i < N; i++) { |
| 150 | + if (!activeMask[i]) { |
| 151 | + pixels[i * 3] = 0; |
| 152 | + pixels[i * 3 + 1] = 0; |
| 153 | + pixels[i * 3 + 2] = 0; |
| 154 | + } |
| 155 | + } |
| 156 | + } |
| 157 | + } |
52 | 158 | // ── Spectral cache ───────────────────────── |
53 | 159 | // Eigendecomposition of the (negative) graph Laplacian for the current |
54 | 160 | // grid + symmetry settings. Recomputed lazily when invalidated. |
|
79 | 185 | pixels = new Float32Array(GRID_W * GRID_H * 3); |
80 | 186 | imageData = ctx.createImageData(GRID_W, GRID_H); |
81 | 187 | resizeCanvas(); |
| 188 | + rebuildLatticeMask(); |
82 | 189 | clearPixels(); |
83 | 190 | invalidateSpectral(); |
84 | 191 | } |
|
107 | 214 |
|
108 | 215 | function setPixel(x, y, r, g, b) { |
109 | 216 | if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return; |
| 217 | + if (activeMask && !activeMask[y * GRID_W + x]) return; |
110 | 218 | const i = idx(x, y); |
111 | 219 | pixels[i] = Math.max(0, Math.min(1, r)); |
112 | 220 | pixels[i + 1] = Math.max(0, Math.min(1, g)); |
|
119 | 227 | } |
120 | 228 |
|
121 | 229 | function randomPixels() { |
122 | | - for (let i = 0; i < pixels.length; i++) pixels[i] = Math.random(); |
| 230 | + const N = GRID_W * GRID_H; |
| 231 | + for (let i = 0; i < N; i++) { |
| 232 | + if (activeMask && !activeMask[i]) { |
| 233 | + pixels[i * 3] = 0; pixels[i * 3 + 1] = 0; pixels[i * 3 + 2] = 0; |
| 234 | + } else { |
| 235 | + pixels[i * 3] = Math.random(); |
| 236 | + pixels[i * 3 + 1] = Math.random(); |
| 237 | + pixels[i * 3 + 2] = Math.random(); |
| 238 | + } |
| 239 | + } |
123 | 240 | render(); |
124 | 241 | } |
125 | 242 |
|
|
129 | 246 |
|
130 | 247 | // Clamp-or-wrap a position depending on translation symmetry flags |
131 | 248 | function addPos(positions, px, py) { |
132 | | - const cx = state.symmetry.translationX ? wrapX(px) : px; |
133 | | - const cy = state.symmetry.translationY ? wrapY(py) : py; |
| 249 | + // Round fractional positions (lattice translations may produce non-integers) |
| 250 | + const ix = Math.round(px), iy = Math.round(py); |
| 251 | + const cx = state.symmetry.translationX ? wrapX(ix) : ix; |
| 252 | + const cy = state.symmetry.translationY ? wrapY(iy) : iy; |
134 | 253 | if (cx < 0 || cx >= GRID_W || cy < 0 || cy >= GRID_H) return; |
| 254 | + if (activeMask && !activeMask[cy * GRID_W + cx]) return; |
135 | 255 | positions.add(cx + ',' + cy); |
136 | 256 | } |
137 | 257 |
|
|
175 | 295 | // anti-diagonal reflection: (x,y) -> (H-1-y, W-1-x) — only add when both mirrors active |
176 | 296 | if (sym.mirrorX && sym.mirrorY) seeds.push([my, mx]); |
177 | 297 | } |
| 298 | + // Lattice translation symmetries (fractional vectors in [0,1]). |
| 299 | + // For each (tx,ty) we add seeds at integer multiples ±k of the vector, |
| 300 | + // for k=1..maxK, as long as they remain potentially in-grid. |
| 301 | + const latTrans = sym.latticeTranslations || []; |
| 302 | + if (latTrans.length) { |
| 303 | + const baseSeeds = seeds.slice(); // snapshot before extending |
| 304 | + for (const t of latTrans) { |
| 305 | + const dxCells = t.x * W; |
| 306 | + const dyCells = t.y * H; |
| 307 | + if (Math.abs(dxCells) < 1e-6 && Math.abs(dyCells) < 1e-6) continue; |
| 308 | + // Replicate up to a reasonable number of multiples |
| 309 | + const maxK = 8; |
| 310 | + for (const [bx, by] of baseSeeds) { |
| 311 | + for (let k = 1; k <= maxK; k++) { |
| 312 | + seeds.push([bx + dxCells * k, by + dyCells * k]); |
| 313 | + seeds.push([bx - dxCells * k, by - dyCells * k]); |
| 314 | + } |
| 315 | + } |
| 316 | + } |
| 317 | + } |
| 318 | + |
178 | 319 |
|
179 | 320 | for (const [sx, sy] of seeds) { |
180 | 321 | addPos(positions, sx, sy); |
|
235 | 376 | // ── Neighborhood / Diffusion ─────────────── |
236 | 377 | function getNeighbors(x, y) { |
237 | 378 | const neighbors = []; |
| 379 | + if (activeMask && !activeMask[y * GRID_W + x]) return neighbors; |
238 | 380 | const n = state.neighborhood; |
239 | 381 |
|
240 | 382 | const offsets4 = [[1,0],[-1,0],[0,1],[0,-1]]; |
|
250 | 392 | const wy = state.symmetry.translationY ? wrapY(ny) : ny; |
251 | 393 |
|
252 | 394 | if (wx < 0 || wx >= GRID_W || wy < 0 || wy >= GRID_H) continue; |
253 | | - |
254 | | - const dist = Math.sqrt(dx * dx + dy * dy); |
| 395 | + if (activeMask && !activeMask[wy * GRID_W + wx]) continue; |
| 396 | + |
| 397 | + // Use the lattice metric to compute a geometric distance, then |
| 398 | + // weight inversely. For non-Euclidean lattices this means cells |
| 399 | + // near the disk boundary effectively become much further apart |
| 400 | + // (hyperbolic) or closer (spherical), shaping diffusion accordingly. |
| 401 | + const dist = latticeDist(x, y, wx, wy); |
| 402 | + if (!isFinite(dist) || dist <= 0) continue; |
255 | 403 | const weight = 1 / dist; |
256 | 404 | neighbors.push({ x: wx, y: wy, w: weight }); |
257 | 405 | } |
|
280 | 428 | } |
281 | 429 | if (sym.diagonal) symPeers.push([y, x]); // already integer |
282 | 430 | if (sym.diagonal && sym.mirrorX && sym.mirrorY) symPeers.push([my, mx]); // anti-diagonal |
| 431 | + // Lattice translation symmetry peers: ±k * (tx*W, ty*H) |
| 432 | + { |
| 433 | + const latTrans = sym.latticeTranslations || []; |
| 434 | + for (const t of latTrans) { |
| 435 | + const dxCells = t.x * W; |
| 436 | + const dyCells = t.y * H; |
| 437 | + if (Math.abs(dxCells) < 1e-6 && Math.abs(dyCells) < 1e-6) continue; |
| 438 | + const maxK = 4; |
| 439 | + for (let k = 1; k <= maxK; k++) { |
| 440 | + symPeers.push([x + dxCells * k, y + dyCells * k]); |
| 441 | + symPeers.push([x - dxCells * k, y - dyCells * k]); |
| 442 | + } |
| 443 | + } |
| 444 | + } |
283 | 445 | // Helper: add bilinear (nearest-4) weighted connections for a fractional peer position |
284 | 446 | function addBilinearNeighbors(fx, fy, baseWeight) { |
285 | 447 | const x0 = Math.floor(fx), y0 = Math.floor(fy); |
|
651 | 813 | function symmetrySignature() { |
652 | 814 | const s = state.symmetry; |
653 | 815 | const rotKey = ROT_ANGLES.map(a => s.rotations[a] ? a : '').join(','); |
| 816 | + const latKey = (s.latticeTranslations || []) |
| 817 | + .map(t => `${t.x.toFixed(4)},${t.y.toFixed(4)}`).join('|'); |
654 | 818 | return [ |
655 | 819 | GRID_W, GRID_H, state.neighborhood, |
656 | 820 | s.translationX|0, s.translationY|0, s.mirrorX|0, s.mirrorY|0, |
657 | 821 | s.diagonal|0, rotKey, |
| 822 | + latKey, |
658 | 823 | state.spectralK, |
659 | 824 | ].join(':'); |
660 | 825 | } |
|
780 | 945 | for (let i = 0; i < GRID_W * GRID_H; i++) { |
781 | 946 | const pi = i * 3; |
782 | 947 | const di = i * 4; |
783 | | - data[di] = Math.round(pixels[pi] * 255); |
784 | | - data[di + 1] = Math.round(pixels[pi + 1] * 255); |
785 | | - data[di + 2] = Math.round(pixels[pi + 2] * 255); |
786 | | - data[di + 3] = 255; |
| 948 | + if (activeMask && !activeMask[i]) { |
| 949 | + // Inactive cells: dim background to indicate the lattice boundary |
| 950 | + data[di] = 18; |
| 951 | + data[di + 1] = 18; |
| 952 | + data[di + 2] = 36; |
| 953 | + data[di + 3] = 255; |
| 954 | + } else { |
| 955 | + data[di] = Math.round(pixels[pi] * 255); |
| 956 | + data[di + 1] = Math.round(pixels[pi + 1] * 255); |
| 957 | + data[di + 2] = Math.round(pixels[pi + 2] * 255); |
| 958 | + data[di + 3] = 255; |
| 959 | + } |
787 | 960 | } |
788 | 961 | ctx.putImageData(imageData, 0, 0); |
789 | 962 |
|
|
1000 | 1173 | document.getElementById('btn-rot-c6').addEventListener('click', () => setRotations(cyclicAngles(6))); |
1001 | 1174 | document.getElementById('btn-rot-c8').addEventListener('click', () => setRotations(cyclicAngles(8))); |
1002 | 1175 | document.getElementById('btn-rot-c12').addEventListener('click', () => setRotations(cyclicAngles(12))); |
| 1176 | + // ── Lattice translation symmetries ──────── |
| 1177 | + const latTransListEl = document.getElementById('lat-trans-list'); |
| 1178 | + function renderLatTransList() { |
| 1179 | + latTransListEl.innerHTML = ''; |
| 1180 | + const list = state.symmetry.latticeTranslations; |
| 1181 | + if (!list.length) { |
| 1182 | + const empty = document.createElement('div'); |
| 1183 | + empty.style.cssText = 'opacity:0.5; font-size:11px; padding:2px 0;'; |
| 1184 | + empty.textContent = '(none)'; |
| 1185 | + latTransListEl.appendChild(empty); |
| 1186 | + return; |
| 1187 | + } |
| 1188 | + list.forEach((t, i) => { |
| 1189 | + const row = document.createElement('div'); |
| 1190 | + row.className = 'lat-trans-item'; |
| 1191 | + row.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:11px; padding:1px 0;'; |
| 1192 | + const lab = document.createElement('span'); |
| 1193 | + lab.style.flex = '1'; |
| 1194 | + lab.textContent = `(${t.x.toFixed(3)}, ${t.y.toFixed(3)})`; |
| 1195 | + const rm = document.createElement('button'); |
| 1196 | + rm.className = 'mini-btn'; |
| 1197 | + rm.textContent = '✕'; |
| 1198 | + rm.addEventListener('click', () => { |
| 1199 | + state.symmetry.latticeTranslations.splice(i, 1); |
| 1200 | + invalidateSpectral(); |
| 1201 | + renderLatTransList(); |
| 1202 | + }); |
| 1203 | + row.appendChild(lab); |
| 1204 | + row.appendChild(rm); |
| 1205 | + latTransListEl.appendChild(row); |
| 1206 | + }); |
| 1207 | + } |
| 1208 | + function addLatTrans(x, y) { |
| 1209 | + if (!isFinite(x) || !isFinite(y)) return; |
| 1210 | + // Avoid duplicates within tolerance |
| 1211 | + const eps = 1e-4; |
| 1212 | + const list = state.symmetry.latticeTranslations; |
| 1213 | + for (const t of list) { |
| 1214 | + if (Math.abs(t.x - x) < eps && Math.abs(t.y - y) < eps) return; |
| 1215 | + } |
| 1216 | + list.push({ x, y }); |
| 1217 | + invalidateSpectral(); |
| 1218 | + renderLatTransList(); |
| 1219 | + } |
| 1220 | + function setLatTrans(vectors) { |
| 1221 | + state.symmetry.latticeTranslations = vectors.map(([x, y]) => ({ x, y })); |
| 1222 | + invalidateSpectral(); |
| 1223 | + renderLatTransList(); |
| 1224 | + } |
| 1225 | + document.getElementById('btn-lat-trans-add').addEventListener('click', () => { |
| 1226 | + const x = parseFloat(document.getElementById('lat-trans-x').value); |
| 1227 | + const y = parseFloat(document.getElementById('lat-trans-y').value); |
| 1228 | + addLatTrans(x, y); |
| 1229 | + }); |
| 1230 | + document.getElementById('btn-lat-trans-clear').addEventListener('click', () => setLatTrans([])); |
| 1231 | + document.getElementById('btn-lat-half').addEventListener('click', () => setLatTrans([[0.5, 0], [0, 0.5]])); |
| 1232 | + document.getElementById('btn-lat-third').addEventListener('click', () => setLatTrans([[1/3, 0], [0, 1/3]])); |
| 1233 | + document.getElementById('btn-lat-quarter').addEventListener('click', () => setLatTrans([[0.25, 0], [0, 0.25]])); |
| 1234 | + // Hexagonal-ish: two vectors at 60° apart |
| 1235 | + document.getElementById('btn-lat-hex').addEventListener('click', () => setLatTrans([ |
| 1236 | + [0.5, 0], |
| 1237 | + [0.25, Math.sqrt(3) / 4], |
| 1238 | + ])); |
| 1239 | + renderLatTransList(); |
| 1240 | + |
1003 | 1241 |
|
1004 | 1242 | // Diffusion controls |
1005 | 1243 | const diffRateSlider = document.getElementById('diff-rate'); |
|
1098 | 1336 |
|
1099 | 1337 | document.getElementById('btn-clear').addEventListener('click', clearPixels); |
1100 | 1338 | document.getElementById('btn-random').addEventListener('click', randomPixels); |
| 1339 | + // ── Lattice geometry controls ────────────── |
| 1340 | + const latticeSelect = document.getElementById('lattice-mode'); |
| 1341 | + latticeSelect.addEventListener('change', e => { |
| 1342 | + state.lattice = e.target.value; |
| 1343 | + rebuildLatticeMask(); |
| 1344 | + invalidateSpectral(); |
| 1345 | + render(); |
| 1346 | + }); |
| 1347 | + const latticeCurvSlider = document.getElementById('lattice-curvature'); |
| 1348 | + if (latticeCurvSlider) { |
| 1349 | + latticeCurvSlider.addEventListener('input', () => { |
| 1350 | + state.latticeCurvature = parseInt(latticeCurvSlider.value) / 100; |
| 1351 | + document.getElementById('lattice-curvature-val').textContent = |
| 1352 | + state.latticeCurvature.toFixed(2); |
| 1353 | + invalidateSpectral(); |
| 1354 | + }); |
| 1355 | + } |
| 1356 | + const latticeRadSlider = document.getElementById('lattice-radius'); |
| 1357 | + if (latticeRadSlider) { |
| 1358 | + latticeRadSlider.addEventListener('input', () => { |
| 1359 | + state.latticeRadius = parseInt(latticeRadSlider.value); |
| 1360 | + document.getElementById('lattice-radius-val').textContent = |
| 1361 | + state.latticeRadius === 0 ? 'auto' : state.latticeRadius; |
| 1362 | + rebuildLatticeMask(); |
| 1363 | + invalidateSpectral(); |
| 1364 | + render(); |
| 1365 | + }); |
| 1366 | + } |
1101 | 1367 |
|
1102 | 1368 | document.getElementById('btn-save').addEventListener('click', () => { |
1103 | 1369 | const link = document.createElement('a'); |
|
0 commit comments