|
42 | 42 | // the smaller grid dimension. |
43 | 43 | latticeRadius: 0, // 0 = auto (min(W,H)/2 - 1) |
44 | 44 | latticeCurvature: 1, // scale factor on the metric (k); larger = more curved |
| 45 | + // Global signed color permutation applied to symmetry-induced linkages. |
| 46 | + // Each entry is { src: 0|1|2, sign: +1|-1 } meaning the output channel |
| 47 | + // takes ±(value of source channel). The default identity is r,g,b. |
| 48 | + // A negated channel means "flipped around 0.5": -c -> 1 - c. |
| 49 | + colorPerm: [ |
| 50 | + { src: 0, sign: 1 }, |
| 51 | + { src: 1, sign: 1 }, |
| 52 | + { src: 2, sign: 1 }, |
| 53 | + ], |
45 | 54 | symmetry: { |
46 | 55 | translationX: false, |
47 | 56 | translationY: false, |
|
57 | 66 | }, |
58 | 67 | }; |
59 | 68 | for (const a of ROT_ANGLES) state.symmetry.rotations[a] = false; |
| 69 | + // ── Color-permutation helpers ────────────── |
| 70 | + // The permutation P is a signed 3x3 monomial matrix acting on RGB values. |
| 71 | + // Negation is interpreted as flip-around-0.5, so the affine action is: |
| 72 | + // out[i] = sign_i * (in[src_i] - 0.5) + 0.5 |
| 73 | + // For composition we work in centered space (value - 0.5). |
| 74 | + const IDENTITY_PERM = [ |
| 75 | + { src: 0, sign: 1 }, |
| 76 | + { src: 1, sign: 1 }, |
| 77 | + { src: 2, sign: 1 }, |
| 78 | + ]; |
| 79 | + function applyPerm(perm, rgb) { |
| 80 | + const r = rgb[0] - 0.5, g = rgb[1] - 0.5, b = rgb[2] - 0.5; |
| 81 | + const c = [r, g, b]; |
| 82 | + return [ |
| 83 | + perm[0].sign * c[perm[0].src] + 0.5, |
| 84 | + perm[1].sign * c[perm[1].src] + 0.5, |
| 85 | + perm[2].sign * c[perm[2].src] + 0.5, |
| 86 | + ]; |
| 87 | + } |
| 88 | + // Compose perms: (a ∘ b)(x) = a(b(x)) |
| 89 | + function composePerm(a, b) { |
| 90 | + const out = new Array(3); |
| 91 | + for (let i = 0; i < 3; i++) { |
| 92 | + const ai = a[i]; |
| 93 | + const bi = b[ai.src]; |
| 94 | + out[i] = { src: bi.src, sign: ai.sign * bi.sign }; |
| 95 | + } |
| 96 | + return out; |
| 97 | + } |
| 98 | + function permPow(p, k) { |
| 99 | + let out = IDENTITY_PERM.map(e => ({ ...e })); |
| 100 | + if (k === 0) return out; |
| 101 | + const sign = k > 0 ? 1 : -1; |
| 102 | + const n = Math.abs(k); |
| 103 | + let base = sign === 1 ? p : invertPerm(p); |
| 104 | + for (let i = 0; i < n; i++) out = composePerm(out, base); |
| 105 | + return out; |
| 106 | + } |
| 107 | + function invertPerm(p) { |
| 108 | + const out = new Array(3); |
| 109 | + for (let i = 0; i < 3; i++) { |
| 110 | + // p sends src -> i with sign s; inverse sends i -> src with sign s |
| 111 | + out[p[i].src] = { src: i, sign: p[i].sign }; |
| 112 | + } |
| 113 | + return out; |
| 114 | + } |
| 115 | + function isIdentityPerm(p) { |
| 116 | + for (let i = 0; i < 3; i++) { |
| 117 | + if (p[i].src !== i || p[i].sign !== 1) return false; |
| 118 | + } |
| 119 | + return true; |
| 120 | + } |
| 121 | + // Channel-by-channel application: returns sign and source channel for output `c`. |
| 122 | + // Used inside hot diffusion loops to avoid array allocation. |
| 123 | + function permEntry(perm, c) { return perm[c]; } |
| 124 | + function permSignature(p) { |
| 125 | + return p.map(e => `${e.sign > 0 ? '+' : '-'}${e.src}`).join(','); |
| 126 | + } |
| 127 | + |
60 | 128 |
|
61 | 129 | let animHandle = null; |
62 | 130 | let lastFrameTime = 0; |
|
257 | 325 |
|
258 | 326 | // Returns list of [x,y] mirror positions for a given (x,y) |
259 | 327 | function symmetryPositions(x, y) { |
260 | | - const positions = new Set(); |
| 328 | + // Returns array of { x, y, perm } where perm is the color permutation to |
| 329 | + // apply to the source colour before depositing at (x,y). |
| 330 | + const seen = new Map(); // key "x,y" -> perm (first one wins) |
261 | 331 |
|
262 | 332 | const sym = state.symmetry; |
263 | 333 | const W = GRID_W, H = GRID_H; |
|
268 | 338 | const cx = (W - 1) / 2; |
269 | 339 | const cy = (H - 1) / 2; |
270 | 340 |
|
271 | | - const seeds = [[x, y]]; |
| 341 | + // seeds: [px, py, perm] where perm is the permutation applied to the |
| 342 | + // *source* colour to obtain the colour deposited at (px, py). The |
| 343 | + // identity seed is (x, y, identity). |
| 344 | + const P = state.colorPerm; |
| 345 | + const ID = IDENTITY_PERM; |
| 346 | + const seeds = [[x, y, ID]]; |
272 | 347 |
|
273 | | - if (sym.mirrorX) seeds.push([mx, y]); |
274 | | - if (sym.mirrorY) seeds.push([x, my]); |
275 | | - if (sym.mirrorX && sym.mirrorY) seeds.push([mx, my]); |
| 348 | + if (sym.mirrorX) seeds.push([mx, y, P]); |
| 349 | + if (sym.mirrorY) seeds.push([x, my, P]); |
| 350 | + if (sym.mirrorX && sym.mirrorY) seeds.push([mx, my, composePerm(P, P)]); |
276 | 351 |
|
277 | 352 |
|
278 | 353 |
|
|
286 | 361 | seeds.push([ |
287 | 362 | Math.round(cx + dx * cos - dy * sin), |
288 | 363 | Math.round(cy + dx * sin + dy * cos), |
| 364 | + P, |
289 | 365 | ]); |
290 | 366 | } |
291 | 367 | } |
292 | 368 |
|
293 | 369 | if (sym.diagonal) { |
294 | | - seeds.push([y, x]); |
| 370 | + seeds.push([y, x, P]); |
295 | 371 | // anti-diagonal reflection: (x,y) -> (H-1-y, W-1-x) — only add when both mirrors active |
296 | | - if (sym.mirrorX && sym.mirrorY) seeds.push([my, mx]); |
| 372 | + if (sym.mirrorX && sym.mirrorY) seeds.push([my, mx, composePerm(P, P)]); |
297 | 373 | } |
298 | 374 | // Lattice translation symmetries (fractional vectors in [0,1]). |
299 | 375 | // For each (tx,ty) we add seeds at integer multiples ±k of the vector, |
|
307 | 383 | if (Math.abs(dxCells) < 1e-6 && Math.abs(dyCells) < 1e-6) continue; |
308 | 384 | // Replicate up to a reasonable number of multiples |
309 | 385 | const maxK = 8; |
310 | | - for (const [bx, by] of baseSeeds) { |
| 386 | + for (const [bx, by, bp] of baseSeeds) { |
311 | 387 | 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]); |
| 388 | + seeds.push([bx + dxCells * k, by + dyCells * k, composePerm(bp, permPow(P, k))]); |
| 389 | + seeds.push([bx - dxCells * k, by - dyCells * k, composePerm(bp, permPow(P, -k))]); |
314 | 390 | } |
315 | 391 | } |
316 | 392 | } |
317 | 393 | } |
318 | 394 |
|
319 | 395 |
|
320 | | - for (const [sx, sy] of seeds) { |
321 | | - addPos(positions, sx, sy); |
| 396 | + const tryAdd = (sx, sy, perm) => { |
| 397 | + const ix = Math.round(sx), iy = Math.round(sy); |
| 398 | + const cxw = state.symmetry.translationX ? wrapX(ix) : ix; |
| 399 | + const cyw = state.symmetry.translationY ? wrapY(iy) : iy; |
| 400 | + if (cxw < 0 || cxw >= GRID_W || cyw < 0 || cyw >= GRID_H) return; |
| 401 | + if (activeMask && !activeMask[cyw * GRID_W + cxw]) return; |
| 402 | + const key = cxw + ',' + cyw; |
| 403 | + if (!seen.has(key)) seen.set(key, perm); |
| 404 | + }; |
| 405 | + for (const [sx, sy, perm] of seeds) { |
| 406 | + tryAdd(sx, sy, perm); |
322 | 407 | if (sym.translationX) { |
323 | | - addPos(positions, sx + Math.floor(W / 2), sy); |
324 | | - addPos(positions, sx - Math.floor(W / 2), sy); |
| 408 | + tryAdd(sx + Math.floor(W / 2), sy, perm); |
| 409 | + tryAdd(sx - Math.floor(W / 2), sy, perm); |
325 | 410 | } |
326 | 411 | if (sym.translationY) { |
327 | | - addPos(positions, sx, sy + Math.floor(H / 2)); |
328 | | - addPos(positions, sx, sy - Math.floor(H / 2)); |
| 412 | + tryAdd(sx, sy + Math.floor(H / 2), perm); |
| 413 | + tryAdd(sx, sy - Math.floor(H / 2), perm); |
329 | 414 | } |
330 | 415 | } |
331 | 416 |
|
332 | | - return [...positions].map(s => s.split(',').map(Number)); |
| 417 | + const result = []; |
| 418 | + for (const [key, perm] of seen) { |
| 419 | + const [xs, ys] = key.split(',').map(Number); |
| 420 | + result.push({ x: xs, y: ys, perm }); |
| 421 | + } |
| 422 | + return result; |
333 | 423 | } |
334 | 424 |
|
335 | 425 | // ── Drawing ──────────────────────────────── |
|
401 | 491 | const dist = latticeDist(x, y, wx, wy); |
402 | 492 | if (!isFinite(dist) || dist <= 0) continue; |
403 | 493 | const weight = 1 / dist; |
404 | | - neighbors.push({ x: wx, y: wy, w: weight }); |
| 494 | + neighbors.push({ x: wx, y: wy, w: weight, perm: IDENTITY_PERM }); |
405 | 495 | } |
406 | 496 |
|
407 | 497 | // Symmetry-induced extra connections |
|
410 | 500 | const mx = W - 1 - x, my = H - 1 - y; |
411 | 501 | const cx = (W - 1) / 2, cy = (H - 1) / 2; |
412 | 502 | const symPeers = []; |
| 503 | + // Each symPeer is [px, py, perm] — perm is the colour permutation to |
| 504 | + // apply to the peer's value before it acts on this cell. |
| 505 | + const P = state.colorPerm; |
| 506 | + const ID = IDENTITY_PERM; |
413 | 507 |
|
414 | | - if (sym.mirrorX) symPeers.push([mx, y]); |
415 | | - if (sym.mirrorY) symPeers.push([x, my]); |
| 508 | + if (sym.mirrorX) symPeers.push([mx, y, P]); |
| 509 | + if (sym.mirrorY) symPeers.push([x, my, P]); |
416 | 510 | // Per-angle rotational symmetry peers |
417 | 511 | { |
418 | 512 | const dx = x - cx, dy = y - cy; |
|
423 | 517 | symPeers.push([ |
424 | 518 | cx + dx * cos - dy * sin, |
425 | 519 | cy + dx * sin + dy * cos, |
| 520 | + P, |
426 | 521 | ]); |
427 | 522 | } |
428 | 523 | } |
429 | | - if (sym.diagonal) symPeers.push([y, x]); // already integer |
430 | | - if (sym.diagonal && sym.mirrorX && sym.mirrorY) symPeers.push([my, mx]); // anti-diagonal |
| 524 | + if (sym.diagonal) symPeers.push([y, x, P]); // already integer |
| 525 | + if (sym.diagonal && sym.mirrorX && sym.mirrorY) symPeers.push([my, mx, composePerm(P, P)]); |
431 | 526 | // Lattice translation symmetry peers: ±k * (tx*W, ty*H) |
432 | 527 | { |
433 | 528 | const latTrans = sym.latticeTranslations || []; |
|
437 | 532 | if (Math.abs(dxCells) < 1e-6 && Math.abs(dyCells) < 1e-6) continue; |
438 | 533 | const maxK = 4; |
439 | 534 | 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]); |
| 535 | + symPeers.push([x + dxCells * k, y + dyCells * k, permPow(P, k)]); |
| 536 | + symPeers.push([x - dxCells * k, y - dyCells * k, permPow(P, -k)]); |
442 | 537 | } |
443 | 538 | } |
444 | 539 | } |
445 | 540 | // Helper: add bilinear (nearest-4) weighted connections for a fractional peer position |
446 | | - function addBilinearNeighbors(fx, fy, baseWeight) { |
| 541 | + function addBilinearNeighbors(fx, fy, baseWeight, perm) { |
447 | 542 | const x0 = Math.floor(fx), y0 = Math.floor(fy); |
448 | 543 | const x1 = x0 + 1, y1 = y0 + 1; |
449 | 544 | const tx = fx - x0, ty = fy - y0; |
|
457 | 552 | if (w < 1e-6) continue; |
458 | 553 | if (cx < 0 || cx >= GRID_W || cy < 0 || cy >= GRID_H) continue; |
459 | 554 | if (cx === x && cy === y) continue; |
460 | | - neighbors.push({ x: cx, y: cy, w: baseWeight * w }); |
| 555 | + neighbors.push({ x: cx, y: cy, w: baseWeight * w, perm }); |
461 | 556 | } |
462 | 557 | } |
463 | 558 |
|
464 | 559 |
|
465 | | - for (const [sx, sy] of symPeers) { |
466 | | - addBilinearNeighbors(sx, sy, 1.0); |
| 560 | + for (const [sx, sy, perm] of symPeers) { |
| 561 | + addBilinearNeighbors(sx, sy, 1.0, perm); |
467 | 562 | } |
468 | 563 |
|
469 | 564 | return neighbors; |
|
510 | 605 | for (const nb of neighbors) { |
511 | 606 | const j = idx(nb.x, nb.y); |
512 | 607 | const wn = nb.w / (totalW || 1); |
513 | | - nr += rate * wn * (pixels[j] - pixels[i]); |
514 | | - ng += rate * wn * (pixels[j + 1] - pixels[i + 1]); |
515 | | - nb2 += rate * wn * (pixels[j + 2] - pixels[i + 2]); |
| 608 | + const perm = nb.perm || IDENTITY_PERM; |
| 609 | + // Permuted peer value (in centered space, then re-shifted): |
| 610 | + // pv[c] = sign_c * (pixels[j + src_c] - 0.5) + 0.5 |
| 611 | + const pe0 = perm[0], pe1 = perm[1], pe2 = perm[2]; |
| 612 | + const pv0 = pe0.sign * (pixels[j + pe0.src] - 0.5) + 0.5; |
| 613 | + const pv1 = pe1.sign * (pixels[j + pe1.src] - 0.5) + 0.5; |
| 614 | + const pv2 = pe2.sign * (pixels[j + pe2.src] - 0.5) + 0.5; |
| 615 | + nr += rate * wn * (pv0 - pixels[i]); |
| 616 | + ng += rate * wn * (pv1 - pixels[i + 1]); |
| 617 | + nb2 += rate * wn * (pv2 - pixels[i + 2]); |
516 | 618 | } |
517 | 619 |
|
518 | 620 | next[i] = nr; |
|
820 | 922 | s.translationX|0, s.translationY|0, s.mirrorX|0, s.mirrorY|0, |
821 | 923 | s.diagonal|0, rotKey, |
822 | 924 | latKey, |
| 925 | + permSignature(state.colorPerm), |
823 | 926 | state.spectralK, |
824 | 927 | ].join(':'); |
825 | 928 | } |
|
1237 | 1340 | [0.25, Math.sqrt(3) / 4], |
1238 | 1341 | ])); |
1239 | 1342 | renderLatTransList(); |
| 1343 | + // ── Color permutation UI ────────────────── |
| 1344 | + const PERM_OPTIONS = [ |
| 1345 | + { v: '+0', label: '+r' }, { v: '-0', label: '−r' }, |
| 1346 | + { v: '+1', label: '+g' }, { v: '-1', label: '−g' }, |
| 1347 | + { v: '+2', label: '+b' }, { v: '-2', label: '−b' }, |
| 1348 | + ]; |
| 1349 | + const permSelects = document.querySelectorAll('.perm-sel'); |
| 1350 | + permSelects.forEach(sel => { |
| 1351 | + for (const opt of PERM_OPTIONS) { |
| 1352 | + const o = document.createElement('option'); |
| 1353 | + o.value = opt.v; |
| 1354 | + o.textContent = opt.label; |
| 1355 | + sel.appendChild(o); |
| 1356 | + } |
| 1357 | + sel.addEventListener('change', () => { |
| 1358 | + const out = parseInt(sel.dataset.out); |
| 1359 | + const v = sel.value; |
| 1360 | + const sign = v[0] === '-' ? -1 : 1; |
| 1361 | + const src = parseInt(v.slice(1)); |
| 1362 | + state.colorPerm[out] = { src, sign }; |
| 1363 | + invalidateSpectral(); |
| 1364 | + }); |
| 1365 | + }); |
| 1366 | + function syncPermUI() { |
| 1367 | + permSelects.forEach(sel => { |
| 1368 | + const out = parseInt(sel.dataset.out); |
| 1369 | + const e = state.colorPerm[out]; |
| 1370 | + sel.value = (e.sign > 0 ? '+' : '-') + e.src; |
| 1371 | + }); |
| 1372 | + } |
| 1373 | + function setPerm(arr) { |
| 1374 | + state.colorPerm = arr.map(e => ({ ...e })); |
| 1375 | + syncPermUI(); |
| 1376 | + invalidateSpectral(); |
| 1377 | + } |
| 1378 | + syncPermUI(); |
| 1379 | + document.getElementById('btn-perm-id').addEventListener('click', () => setPerm([ |
| 1380 | + { src: 0, sign: 1 }, { src: 1, sign: 1 }, { src: 2, sign: 1 }, |
| 1381 | + ])); |
| 1382 | + document.getElementById('btn-perm-negate').addEventListener('click', () => setPerm([ |
| 1383 | + { src: 0, sign: -1 }, { src: 1, sign: -1 }, { src: 2, sign: -1 }, |
| 1384 | + ])); |
| 1385 | + document.getElementById('btn-perm-rgb-rot').addEventListener('click', () => setPerm([ |
| 1386 | + { src: 1, sign: 1 }, { src: 2, sign: 1 }, { src: 0, sign: 1 }, |
| 1387 | + ])); |
| 1388 | + document.getElementById('btn-perm-swap-rg').addEventListener('click', () => setPerm([ |
| 1389 | + { src: 1, sign: 1 }, { src: 0, sign: 1 }, { src: 2, sign: 1 }, |
| 1390 | + ])); |
| 1391 | + document.getElementById('btn-perm-neg-g').addEventListener('click', () => setPerm([ |
| 1392 | + { src: 0, sign: 1 }, { src: 1, sign: -1 }, { src: 2, sign: 1 }, |
| 1393 | + ])); |
1240 | 1394 |
|
1241 | 1395 |
|
1242 | 1396 | // Diffusion controls |
|
0 commit comments