Skip to content

Commit 5033f31

Browse files
committed
wip
1 parent df53e8d commit 5033f31

2 files changed

Lines changed: 315 additions & 9 deletions

File tree

experiments/symmetry_simple/app.js

Lines changed: 275 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
// Reaction-diffusion parameters (Gray-Scott-ish, applied per channel pair)
3636
rdFeed: 0.055,
3737
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
3845
symmetry: {
3946
translationX: false,
4047
translationY: false,
@@ -43,12 +50,111 @@
4350
diagonal: false,
4451
// Per-angle rotation toggles, keyed by integer degree
4552
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: [],
4657
},
4758
};
4859
for (const a of ROT_ANGLES) state.symmetry.rotations[a] = false;
4960

5061
let animHandle = null;
5162
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+
}
52158
// ── Spectral cache ─────────────────────────
53159
// Eigendecomposition of the (negative) graph Laplacian for the current
54160
// grid + symmetry settings. Recomputed lazily when invalidated.
@@ -79,6 +185,7 @@
79185
pixels = new Float32Array(GRID_W * GRID_H * 3);
80186
imageData = ctx.createImageData(GRID_W, GRID_H);
81187
resizeCanvas();
188+
rebuildLatticeMask();
82189
clearPixels();
83190
invalidateSpectral();
84191
}
@@ -107,6 +214,7 @@
107214

108215
function setPixel(x, y, r, g, b) {
109216
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return;
217+
if (activeMask && !activeMask[y * GRID_W + x]) return;
110218
const i = idx(x, y);
111219
pixels[i] = Math.max(0, Math.min(1, r));
112220
pixels[i + 1] = Math.max(0, Math.min(1, g));
@@ -119,7 +227,16 @@
119227
}
120228

121229
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+
}
123240
render();
124241
}
125242

@@ -129,9 +246,12 @@
129246

130247
// Clamp-or-wrap a position depending on translation symmetry flags
131248
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;
134253
if (cx < 0 || cx >= GRID_W || cy < 0 || cy >= GRID_H) return;
254+
if (activeMask && !activeMask[cy * GRID_W + cx]) return;
135255
positions.add(cx + ',' + cy);
136256
}
137257

@@ -175,6 +295,27 @@
175295
// anti-diagonal reflection: (x,y) -> (H-1-y, W-1-x) — only add when both mirrors active
176296
if (sym.mirrorX && sym.mirrorY) seeds.push([my, mx]);
177297
}
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+
178319

179320
for (const [sx, sy] of seeds) {
180321
addPos(positions, sx, sy);
@@ -235,6 +376,7 @@
235376
// ── Neighborhood / Diffusion ───────────────
236377
function getNeighbors(x, y) {
237378
const neighbors = [];
379+
if (activeMask && !activeMask[y * GRID_W + x]) return neighbors;
238380
const n = state.neighborhood;
239381

240382
const offsets4 = [[1,0],[-1,0],[0,1],[0,-1]];
@@ -250,8 +392,14 @@
250392
const wy = state.symmetry.translationY ? wrapY(ny) : ny;
251393

252394
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;
255403
const weight = 1 / dist;
256404
neighbors.push({ x: wx, y: wy, w: weight });
257405
}
@@ -280,6 +428,20 @@
280428
}
281429
if (sym.diagonal) symPeers.push([y, x]); // already integer
282430
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+
}
283445
// Helper: add bilinear (nearest-4) weighted connections for a fractional peer position
284446
function addBilinearNeighbors(fx, fy, baseWeight) {
285447
const x0 = Math.floor(fx), y0 = Math.floor(fy);
@@ -651,10 +813,13 @@
651813
function symmetrySignature() {
652814
const s = state.symmetry;
653815
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('|');
654818
return [
655819
GRID_W, GRID_H, state.neighborhood,
656820
s.translationX|0, s.translationY|0, s.mirrorX|0, s.mirrorY|0,
657821
s.diagonal|0, rotKey,
822+
latKey,
658823
state.spectralK,
659824
].join(':');
660825
}
@@ -780,10 +945,18 @@
780945
for (let i = 0; i < GRID_W * GRID_H; i++) {
781946
const pi = i * 3;
782947
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+
}
787960
}
788961
ctx.putImageData(imageData, 0, 0);
789962

@@ -1000,6 +1173,71 @@
10001173
document.getElementById('btn-rot-c6').addEventListener('click', () => setRotations(cyclicAngles(6)));
10011174
document.getElementById('btn-rot-c8').addEventListener('click', () => setRotations(cyclicAngles(8)));
10021175
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+
10031241

10041242
// Diffusion controls
10051243
const diffRateSlider = document.getElementById('diff-rate');
@@ -1098,6 +1336,34 @@
10981336

10991337
document.getElementById('btn-clear').addEventListener('click', clearPixels);
11001338
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+
}
11011367

11021368
document.getElementById('btn-save').addEventListener('click', () => {
11031369
const link = document.createElement('a');

0 commit comments

Comments
 (0)