Inspired by M. C. Escher's lithograph
Prentententoonstelling (1956) — Dutch for
diff --git a/src/explain/main.ts b/src/explain/main.ts
new file mode 100644
index 0000000..f1619c1
--- /dev/null
+++ b/src/explain/main.ts
@@ -0,0 +1,443 @@
+/**
+ * Live engine for explain.html — every panel rendered by the app's own GPU
+ * pipeline (PipelinePanelGLRenderer), so there is no duplicated map math.
+ *
+ * The page's spine is one pipeline, shown as three connected panels that all
+ * move together in realtime:
+ *
+ * #exp-log — "we log it": log(z − c), the flat repeating strip.
+ * #exp-bend — "we bend it": the same strip leaned over by the bend angle.
+ * #exp-exp — "we exponentiate it": rolled back up into the tententoon spiral.
+ *
+ * One bend slider leans #exp-bend and twists #exp-exp at once. Dragging the flat
+ * #exp-log strip pans all three (a slide in the flat world is a zoom-and-turn in
+ * the round one). #exp-exp also rolls between the flat strip (roll 0) and the
+ * spiral (roll 1), so you can watch the exponential happen.
+ *
+ * Two geometries feed the panels so the bend is both honest and dramatic:
+ * • the PHOTO at its own gentle nesting (S ≈ 2.1, β ≈ 6.8°) — "photo"/"overlay";
+ * • a BOLD synthetic geometry (S = 20, β ≈ 25.5°, near Escher's 26°) for the
+ * scale-invariant "ring"/"grid" patterns, which stay seamless at any scale.
+ * Switching source switches geometry; the bend slider's β and the "closed" mark
+ * follow. The source switcher is repeated under each panel but drives one state.
+ */
+
+import { fitCropToNest, type Rect } from '../lib/math/droste';
+import {
+ buildPanelGeometry,
+ panelPxPerUnit,
+ panelURef,
+ type PanelGeometry
+} from '../lib/ui1/pipeline-panels';
+import { PipelinePanelGLRenderer } from '../lib/render/pipeline-gl';
+import type { DrosteCtx } from '../lib/math/transforms';
+import { makeSource, type SourceMode } from './patterns';
+import { initPlayground } from './playground';
+
+const SOURCE = '/Droste_1260359-nevit.jpg';
+const PHOTO_NEST: Rect | null = { x: 343.2, y: 334.7, w: 583.5, h: 454.9 };
+const BOLD_S = 20; // β = atan(ln 20 / 2π) ≈ 25.5°, a hair under Escher's 26°
+
+const TWO_PI = 2 * Math.PI;
+const MAX_PX = 720;
+const BEND_MAX_DEG = 40; // slider headroom past the boldest closing angle
+const RAD = Math.PI / 180;
+
+const byId = (id: string) => document.getElementById(id) as T | null;
+
+function centeredNest(w: number, h: number): Rect {
+ const s = 0.5;
+ return { x: (w * (1 - s)) / 2, y: (h * (1 - s)) / 2, w: w * s, h: h * s };
+}
+
+function loadImage(src: string): Promise {
+ const img = new Image();
+ img.decoding = 'async';
+ img.src = src;
+ return img.decode().then(() => img);
+}
+
+const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
+
+/**
+ * A synthetic Droste geometry with the limit point at the centre of a
+ * texW×texH working rect and an arbitrary shrink S — lets the scale-invariant
+ * patterns spiral as hard as we like while still closing on themselves.
+ */
+function boldGeometry(texW: number, texH: number, S: number): PanelGeometry {
+ const cx = texW / 2;
+ const cy = texH / 2;
+ const logS = Math.log(S);
+ const rMax = Math.hypot(Math.max(cx, texW - cx), Math.max(cy, texH - cy));
+ const ctx: DrosteCtx = {
+ cx, cy, logS, rMax, W: texW, H: texH, cropX: 0, cropY: 0, sampleScale: 1
+ };
+ return { ctx, R0: rMax / Math.sqrt(S), S };
+}
+
+type Role = 'orig' | 'log' | 'bend' | 'exp';
+type Panel = { role: Role; canvas: HTMLCanvasElement; cell: HTMLElement | null; renderer: PipelinePanelGLRenderer };
+type GeomInfo = {
+ geom: PanelGeometry;
+ uRef: number;
+ lnR0: number;
+ beta: number;
+ betaDeg: number;
+};
+
+async function main(): Promise {
+ const root = byId('exp-root');
+ if (!root) return;
+ const fallback = byId('exp-fallback');
+ const readout = byId('exp-readout');
+
+ function showFallback(msg?: string): void {
+ if (fallback) {
+ fallback.hidden = false;
+ if (msg) fallback.textContent = msg;
+ }
+ document.querySelectorAll('.live-panels, .exp-controls, .ctl-row').forEach((el) => {
+ el.style.display = 'none';
+ });
+ }
+
+ let photo: HTMLImageElement;
+ try {
+ photo = await loadImage(SOURCE);
+ } catch {
+ showFallback('Could not load the test image.');
+ return;
+ }
+
+ const Wimg = photo.naturalWidth;
+ const Himg = photo.naturalHeight;
+
+ // ── Photo geometry (faithful, gentle) ──────────────────────────────────────
+ const nest = PHOTO_NEST ?? centeredNest(Wimg, Himg);
+ const crop = fitCropToNest({ width: Wimg, height: Himg }, nest, null);
+ const gPhoto = buildPanelGeometry(nest, crop);
+ if (!gPhoto) {
+ showFallback('That nest is degenerate. Pick a smaller rectangle.');
+ return;
+ }
+ const cImgX = crop.x + gPhoto.ctx.cx;
+ const cImgY = crop.y + gPhoto.ctx.cy;
+ const photoS = gPhoto.S;
+
+ // ── Bold geometry (dramatic, seamless) — matches the crop aspect ───────────
+ const texH = Math.min(960, Math.round(crop.h));
+ const texW = Math.round(texH * (crop.w / crop.h));
+ const gBold = boldGeometry(texW, texH, BOLD_S);
+
+ root.style.setProperty('--cell-ar', `${crop.w} / ${crop.h}`);
+
+ function infoFor(geom: PanelGeometry): GeomInfo {
+ const beta = Math.atan2(geom.ctx.logS, TWO_PI);
+ return {
+ geom,
+ uRef: panelURef(geom.ctx.rMax),
+ lnR0: Math.log(Math.max(geom.R0, 1e-9)),
+ beta,
+ betaDeg: (beta * 180) / Math.PI
+ };
+ }
+ const photoInfo = infoFor(gPhoto);
+ const boldInfo = infoFor(gBold);
+ const isBold = (m: SourceMode) => m === 'grid' || m === 'polar';
+ const infoForSource = (m: SourceMode): GeomInfo => (isBold(m) ? boldInfo : photoInfo);
+
+ const texCache = new Map();
+ function pixelsFor(m: SourceMode): ImageData {
+ const hit = texCache.get(m);
+ if (hit) return hit;
+ const img = isBold(m)
+ ? makeSource(m, photo, texW, texH, texW / 2, texH / 2, BOLD_S)
+ : makeSource(m, photo, Wimg, Himg, cImgX, cImgY, photoS);
+ texCache.set(m, img);
+ return img;
+ }
+
+ // ── State ──────────────────────────────────────────────────────────────────
+ const state = {
+ source: 'overlay' as SourceMode,
+ angle: photoInfo.beta, // the bend; starts at the closing angle of the active geometry
+ panU: 0, // shared pan from dragging the flat strip (slide = zoom/turn)
+ panV: 0,
+ roll: 1 // exp panel: 0 = flat bent strip … 1 = rolled-up spiral (default)
+ };
+
+ // ── Panels ───────────────────────────────────────────────────────────────
+ const idByRole: Record = {
+ orig: 'exp-orig', log: 'exp-log', bend: 'exp-bend', exp: 'exp-exp'
+ };
+ const panels: Panel[] = [];
+ try {
+ for (const role of Object.keys(idByRole) as Role[]) {
+ const canvas = byId(idByRole[role]);
+ if (!canvas) continue;
+ const renderer = new PipelinePanelGLRenderer();
+ renderer.init(canvas);
+ panels.push({ role, canvas, cell: canvas.closest('.panel-cell'), renderer });
+ }
+ } catch {
+ for (const p of panels) p.renderer.dispose();
+ showFallback();
+ return;
+ }
+
+ function canvasPx(canvas: HTMLCanvasElement): { cw: number; ch: number } | null {
+ const dpr = window.devicePixelRatio || 1;
+ const r = canvas.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) return null;
+ let cw = Math.round(r.width * dpr);
+ let ch = Math.round(r.height * dpr);
+ const long = Math.max(cw, ch);
+ if (long > MAX_PX) {
+ const s = MAX_PX / long;
+ cw = Math.max(1, Math.round(cw * s));
+ ch = Math.max(1, Math.round(ch * s));
+ }
+ return { cw, ch };
+ }
+
+ function renderPanel(p: Panel): void {
+ const dim = canvasPx(p.canvas);
+ if (!dim) return;
+ const { cw, ch } = dim;
+ const info = infoForSource(state.source);
+ const ctx = info.geom.ctx;
+ const pixels = pixelsFor(state.source);
+ const a = state.angle;
+ switch (p.role) {
+ case 'orig': // the input picture itself (no twist), zoomed/turned by the pan
+ p.renderer.render({
+ pixels, ctx, mode: 'escher', W: cw, H: ch,
+ scale: cw / ctx.W, lnR0: info.lnR0, kTwist: 0, panU: state.panU, panV: state.panV
+ });
+ break;
+ case 'log':
+ p.renderer.render({
+ pixels, ctx, mode: 'log', W: cw, H: ch,
+ pxPerUnit: panelPxPerUnit('log', ctx.logS, ch),
+ uRef: info.uRef, panU: state.panU, panV: state.panV
+ });
+ break;
+ case 'bend':
+ p.renderer.render({
+ pixels, ctx, mode: 'rotlog', W: cw, H: ch,
+ pxPerUnit: panelPxPerUnit('rotlog', ctx.logS, ch),
+ uRef: info.uRef, rot: a, panU: state.panU, panV: state.panV
+ });
+ break;
+ case 'exp':
+ p.renderer.render({
+ pixels, ctx, mode: 'unroll', W: cw, H: ch,
+ pxPerUnit: panelPxPerUnit('rotlog', ctx.logS, ch),
+ uRef: info.uRef, lnR0: info.lnR0, rot: a, kTwist: Math.tan(a),
+ morph: state.roll, panU: state.panU, panV: state.panV
+ });
+ break;
+ }
+ }
+
+ // ── Render scheduler (discrete updates + running animations) ───────────────
+ let raf = 0;
+ let dirtyAll = false;
+ const dirty = new Set();
+ let last = performance.now();
+
+ let snapping = false;
+ let snapFrom = 0;
+ let snapTo = 0;
+ let snapT = 0;
+
+ function schedule(only?: Role[]): void {
+ if (only) only.forEach((r) => dirty.add(r));
+ else dirtyAll = true;
+ if (!raf) raf = requestAnimationFrame(tick);
+ }
+
+ function tick(now: number): void {
+ raf = 0;
+ const dt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ let more = false;
+
+ if (snapping) {
+ snapT += dt / 0.45;
+ const e = snapT >= 1 ? 1 : easeOutCubic(snapT);
+ state.angle = snapFrom + (snapTo - snapFrom) * e;
+ syncBendUI();
+ dirty.add('bend');
+ dirty.add('exp');
+ if (snapT >= 1) snapping = false;
+ else more = true;
+ }
+
+ const all = dirtyAll;
+ dirtyAll = false;
+ const set = new Set(dirty);
+ dirty.clear();
+ for (const p of panels) if (all || set.has(p.role)) renderPanel(p);
+ updateReadout();
+
+ if (more) raf = requestAnimationFrame(tick);
+ }
+
+ function updateReadout(): void {
+ if (!readout) return;
+ if (state.panU === 0 && state.panV === 0) {
+ readout.textContent = 'drag the flat strip: ↔ zooms everything, ↕ turns it';
+ return;
+ }
+ const zoom = Math.exp(state.panU);
+ let deg = ((state.panV * 180) / Math.PI) % 360;
+ if (deg < 0) deg += 360;
+ readout.textContent = `slid by ${state.panU >= 0 ? '+' : ''}${state.panU.toFixed(2)} → zoom ×${zoom.toFixed(2)}, turn ${deg.toFixed(0)}°`;
+ }
+
+ // ── Bend (the lean that becomes the twist) ─────────────────────────────────
+ const bend = byId('exp-bend-range');
+ const bendVal = byId('exp-bend-val');
+ const bendNote = byId('exp-bend-note');
+ const bendCells = panels.filter((p) => p.role === 'bend' || p.role === 'exp').map((p) => p.cell);
+ const mS = byId('m-s');
+ const mLogS = byId('m-logs');
+ const mK = byId('m-k');
+ const fmtS = (s: number) => (s < 10 ? s.toFixed(2) : s.toFixed(0));
+
+ function syncBendUI(): void {
+ const deg = state.angle / RAD;
+ const betaDeg = infoForSource(state.source).betaDeg;
+ const closed = Math.abs(deg - betaDeg) < 0.4;
+ if (bend && document.activeElement !== bend) bend.value = deg.toFixed(1);
+ if (bendVal) {
+ bendVal.textContent = `${deg.toFixed(1)}°`;
+ bendVal.classList.toggle('closed', closed);
+ }
+ bendCells.forEach((c) => c?.classList.toggle('is-closed', closed));
+ if (bendNote) {
+ bendNote.innerHTML = closed
+ ? 'closed ✓ — the tiles line back up'
+ : `lines up at β ≈ ${betaDeg.toFixed(1)}°`;
+ }
+ // Live map values from the user's source + lean choices.
+ const info = infoForSource(state.source);
+ if (mS) mS.textContent = fmtS(info.geom.S);
+ if (mLogS) mLogS.textContent = info.geom.ctx.logS.toFixed(2);
+ if (mK) mK.textContent = Math.tan(state.angle).toFixed(2);
+ }
+
+ function setBendDeg(deg: number): void {
+ state.angle = deg * RAD;
+ snapping = false;
+ syncBendUI();
+ schedule(['bend', 'exp']);
+ }
+
+ if (bend) {
+ bend.min = '0';
+ bend.max = String(BEND_MAX_DEG);
+ bend.step = '0.1';
+ bend.value = photoInfo.betaDeg.toFixed(1);
+ bend.addEventListener('input', () => setBendDeg(parseFloat(bend.value)));
+ }
+ byId('exp-bend-snap')?.addEventListener('click', () => {
+ snapFrom = state.angle;
+ snapTo = infoForSource(state.source).beta;
+ snapT = 0;
+ snapping = true;
+ schedule(['bend', 'exp']);
+ });
+
+ // ── Roll (the exponential: flat strip → spiral) ────────────────────────────
+ const roll = byId('exp-roll');
+
+ function syncRollUI(): void {
+ if (roll && document.activeElement !== roll) roll.value = String(Math.round(state.roll * 100));
+ }
+ if (roll) {
+ roll.min = '0';
+ roll.max = '100';
+ roll.step = '1';
+ roll.value = '100';
+ roll.addEventListener('input', () => {
+ state.roll = parseFloat(roll.value) / 100;
+ syncRollUI();
+ schedule(['exp']);
+ });
+ }
+
+ // ── Source mode (one global state; switcher repeated under each panel) ─────
+ function setSource(mode: SourceMode): void {
+ state.source = mode;
+ state.angle = infoForSource(mode).beta; // reset to the closing angle of the new geometry
+ snapping = false;
+ document.querySelectorAll('[data-source]').forEach((b) => {
+ b.classList.toggle('on', b.dataset.source === mode);
+ });
+ syncBendUI();
+ schedule();
+ }
+ document.querySelectorAll('[data-source]').forEach((b) => {
+ b.addEventListener('click', () => setSource((b.dataset.source as SourceMode) ?? 'polar'));
+ });
+
+ // ── Drag the flat strip → pan every panel (slide = zoom + turn) ────────────
+ const logCanvas = byId('exp-log');
+ if (logCanvas) {
+ let active = false;
+ let lastX = 0;
+ let lastY = 0;
+ logCanvas.addEventListener('pointerdown', (e) => {
+ active = true;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ try {
+ logCanvas.setPointerCapture(e.pointerId);
+ } catch {
+ /* not capturable */
+ }
+ e.preventDefault();
+ });
+ logCanvas.addEventListener('pointermove', (e) => {
+ if (!active) return;
+ const h = logCanvas.getBoundingClientRect().height || 1;
+ const perUnit = h / TWO_PI; // one cell height = 2π = one full turn
+ // Natural drag: the strip follows the pointer (content moves with the hand).
+ state.panU -= (e.clientX - lastX) / perUnit;
+ state.panV -= (e.clientY - lastY) / perUnit;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ schedule(['log', 'bend', 'exp']);
+ });
+ const end = (e: PointerEvent) => {
+ active = false;
+ try {
+ logCanvas.releasePointerCapture(e.pointerId);
+ } catch {
+ /* already released */
+ }
+ };
+ logCanvas.addEventListener('pointerup', end);
+ logCanvas.addEventListener('pointercancel', end);
+ }
+
+ byId('exp-reset')?.addEventListener('click', () => {
+ state.panU = 0;
+ state.panV = 0;
+ schedule(['log', 'bend', 'exp']);
+ });
+
+ // ── Boot ─────────────────────────────────────────────────────────────────
+ document.querySelectorAll('[data-source]').forEach((b) => {
+ b.classList.toggle('on', b.dataset.source === state.source);
+ });
+ syncBendUI();
+ syncRollUI();
+ new ResizeObserver(() => schedule()).observe(root);
+ schedule();
+}
+
+void main();
+initPlayground();
diff --git a/src/explain/patterns.ts b/src/explain/patterns.ts
new file mode 100644
index 0000000..21181a8
--- /dev/null
+++ b/src/explain/patterns.ts
@@ -0,0 +1,141 @@
+/**
+ * Source content for the explain.html explorable.
+ *
+ * Four modes feed the same pipeline so you can watch the Droste→Escher map act
+ * on different things:
+ * picture — the test photograph.
+ * grid — a cartesian grid centred on the limit point c.
+ * polar — concentric circles + spokes centred on c. Each circle has its
+ * own style (thick / dashed / plain, in ink then red) so you can
+ * follow an individual ring through the transform; in the log
+ * panel a circle becomes a vertical line.
+ * overlay — the photograph with the grid and polar drawn on top.
+ *
+ * Patterns are drawn at the photo's pixel size, centred on c (image coords),
+ * and the rings are spaced by S^(1/6) so the style cycle repeats exactly once
+ * per Droste period — i.e. the pattern is invariant under scaling by S about
+ * c, the same self-similarity the photo has, so the fold stays seamless.
+ */
+
+export type SourceMode = 'picture' | 'grid' | 'polar' | 'overlay';
+
+const INK = '#26424a';
+const RED = '#d1495b';
+const RINGS_PER_PERIOD = 6;
+
+type RingStyle = { width: number; dash: number[]; color: string };
+
+function ringStyle(n: number, overlay: boolean): RingStyle {
+ const ink = overlay ? 'rgba(255,255,255,0.92)' : INK;
+ const red = overlay ? '#ff6b7a' : RED;
+ const cycle: RingStyle[] = [
+ { width: 5, dash: [], color: ink }, // thick
+ { width: 2.5, dash: [11, 9], color: ink }, // dashed
+ { width: 2.5, dash: [], color: ink }, // plain
+ { width: 5, dash: [], color: red }, // thick red
+ { width: 2.5, dash: [11, 9], color: red }, // dashed red
+ { width: 2.5, dash: [], color: red } // plain red
+ ];
+ return cycle[((n % RINGS_PER_PERIOD) + RINGS_PER_PERIOD) % RINGS_PER_PERIOD];
+}
+
+function line(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number): void {
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+}
+
+function drawGrid(ctx: CanvasRenderingContext2D, W: number, H: number, cx: number, cy: number, overlay: boolean): void {
+ const step = Math.min(W, H) / 16;
+ ctx.save();
+ ctx.lineWidth = 1.5;
+ ctx.strokeStyle = overlay ? 'rgba(255,255,255,0.42)' : 'rgba(43,58,66,0.30)';
+ for (let x = cx % step; x < W; x += step) line(ctx, Math.round(x) + 0.5, 0, Math.round(x) + 0.5, H);
+ for (let y = cy % step; y < H; y += step) line(ctx, 0, Math.round(y) + 0.5, W, Math.round(y) + 0.5);
+ // Coloured axes through c, so orientation is trackable through the warp.
+ ctx.lineWidth = overlay ? 3 : 4;
+ ctx.strokeStyle = overlay ? '#ff6b7a' : RED;
+ line(ctx, 0, cy, W, cy);
+ ctx.strokeStyle = overlay ? '#74c0ff' : '#2e86ab';
+ line(ctx, cx, 0, cx, H);
+ ctx.restore();
+}
+
+function drawPolar(ctx: CanvasRenderingContext2D, W: number, H: number, cx: number, cy: number, S: number, overlay: boolean): void {
+ // Reach past the farthest corner so the rings fill the frame.
+ const maxR = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) + 4;
+ const ratio = Math.pow(Math.max(S, 1.001), 1 / RINGS_PER_PERIOD);
+
+ ctx.save();
+ // Spokes first (under the rings) — a spoke is a horizontal line in the log.
+ ctx.lineWidth = 1.5;
+ ctx.strokeStyle = overlay ? 'rgba(255,255,255,0.45)' : 'rgba(43,58,66,0.28)';
+ for (let s = 0; s < 12; s++) {
+ const a = (s * Math.PI) / 6;
+ line(ctx, cx, cy, cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
+ }
+ // One red reference spoke at θ = 0.
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = overlay ? '#ff6b7a' : RED;
+ line(ctx, cx, cy, cx + maxR, cy);
+
+ // Rings, outermost in. Styles cycle every period (×S) → S-invariant, seamless.
+ let r = maxR;
+ let n = 0;
+ while (r > 3) {
+ const st = ringStyle(n, overlay);
+ ctx.lineWidth = st.width;
+ ctx.setLineDash(st.dash);
+ ctx.strokeStyle = st.color;
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.stroke();
+ r /= ratio;
+ n++;
+ }
+ ctx.setLineDash([]);
+ ctx.fillStyle = overlay ? '#fff' : INK;
+ ctx.beginPath();
+ ctx.arc(cx, cy, 4, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+}
+
+/**
+ * Render the chosen source to an ImageData at the photo's pixel size. `c` is
+ * the limit point in image coordinates; `S` the Droste scale (for ring
+ * spacing). The pipeline samples this exactly like a loaded photo.
+ */
+export function makeSource(
+ mode: SourceMode,
+ photo: CanvasImageSource,
+ W: number,
+ H: number,
+ cx: number,
+ cy: number,
+ S: number
+): ImageData {
+ const canvas = document.createElement('canvas');
+ canvas.width = W;
+ canvas.height = H;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) throw new Error('2d context unavailable');
+
+ if (mode === 'picture' || mode === 'overlay') {
+ ctx.drawImage(photo, 0, 0, W, H);
+ if (mode === 'overlay') {
+ ctx.fillStyle = 'rgba(18,14,8,0.30)'; // mute the photo so the lines read
+ ctx.fillRect(0, 0, W, H);
+ }
+ } else {
+ ctx.fillStyle = '#f3efe6';
+ ctx.fillRect(0, 0, W, H);
+ }
+
+ const overlay = mode === 'overlay';
+ if (mode === 'grid' || overlay) drawGrid(ctx, W, H, cx, cy, overlay);
+ if (mode === 'polar' || overlay) drawPolar(ctx, W, H, cx, cy, S, overlay);
+
+ return ctx.getImageData(0, 0, W, H);
+}
diff --git a/src/explain/playground.ts b/src/explain/playground.ts
new file mode 100644
index 0000000..08409bb
--- /dev/null
+++ b/src/explain/playground.ts
@@ -0,0 +1,515 @@
+/**
+ * Function playground for explain.html — a free, self-contained complex-function
+ * explorer that runs *before* the tententoon construction.
+ *
+ * The point is to teach the language the rest of the page speaks: a function is
+ * a machine that moves the plane. The reader drags a point on the left (the
+ * input plane) and watches where it lands on the right (the output plane), with
+ * a grid, rings, or a photo riding along so the whole warp is visible.
+ *
+ * A slider scrubs from the identity to the chosen function:
+ *
+ * f_t(z) = (1 − t)·z + t·f(z)
+ *
+ * That interpolation is a teaching tool, not the real math — it lets the plane
+ * be *pulled* into place instead of snapping there. The order of functions
+ * (z, 2z, iz, z², exp, log) walks from "nothing moves" up to the exp/log pair
+ * the tententoon is built from.
+ *
+ * Pure Canvas 2D, no WebGL and no dependency on the Droste pipeline: these are
+ * general maps, not the one specific map. Grid/ring lines are drawn by sampling
+ * each line, mapping every sample through f_t, and stroking the result as a
+ * polyline (broken where the map jumps, e.g. log's branch cut). The photo, when
+ * shown, is forward-warped through a small triangle mesh.
+ */
+
+type C = { re: number; im: number };
+
+type FnKey = 'id' | 'double' | 'rot' | 'square' | 'exp' | 'log';
+// `fixedOut`, when set, pins the output plane to that half-extent instead of
+// auto-fitting. The linear maps MUST share the input scale (D): if the output
+// viewport auto-grew to fit 2z, the doubling would be cancelled by the zoom and
+// the grid would look untouched. Pinning to D makes "everything doubled" read as
+// a visibly coarser grid and a point flung outward. Nonlinear maps (z², exp,
+// log) leave it unset and auto-fit — for them the lesson is the *shape* change,
+// not a uniform scale, and they need the extra room to stay framed.
+type Fn = { key: FnKey; label: string; map: (z: C) => C; fixedOut?: number };
+
+// Input half-extent: the plane shown is [−D, D]². Chosen as π so the vertical
+// span is exactly 2π — exp(z) maps the imaginary part to the output angle, so
+// only a 2π-tall strip rolls the full way around the origin into closed circles.
+// Anything smaller leaves the exp rings visibly open (a height of h wraps just
+// h/2π of the lap). This 2π periodicity is the same one that closes the
+// tententoon spiral.
+const D = Math.PI;
+
+const FNS: Fn[] = [
+ { key: 'id', label: 'f(z) = z', map: (z) => ({ re: z.re, im: z.im }), fixedOut: D },
+ { key: 'double', label: 'f(z) = 2z', map: (z) => ({ re: 2 * z.re, im: 2 * z.im }), fixedOut: D },
+ // i·z = i(x + iy) = −y + ix : a quarter-turn.
+ { key: 'rot', label: 'f(z) = iz', map: (z) => ({ re: -z.im, im: z.re }), fixedOut: D },
+ { key: 'square', label: 'f(z) = z²', map: (z) => ({ re: z.re * z.re - z.im * z.im, im: 2 * z.re * z.im }) },
+ {
+ key: 'exp',
+ label: 'f(z) = exp(z)',
+ map: (z) => {
+ const e = Math.exp(z.re);
+ return { re: e * Math.cos(z.im), im: e * Math.sin(z.im) };
+ }
+ },
+ {
+ key: 'log',
+ label: 'f(z) = log(z)',
+ map: (z) => ({ re: 0.5 * Math.log(z.re * z.re + z.im * z.im), im: Math.atan2(z.im, z.re) })
+ }
+];
+
+const PHOTO = '/Droste_1260359-nevit.jpg';
+const GRID_STEP = Math.PI / 8; // ≈0.393; divides D = π evenly, no edge sliver
+const NEIGH = 0.17; // half-side of the little square drawn around the dragged point
+const LINE_SAMPLES = 100;
+const MESH = 22; // triangle-mesh resolution for the photo warp
+
+const ACCENT = '#d94f2c';
+const ACCENT_DEEP = '#a83a1d';
+const INK = '#26424a';
+const RED = '#d1495b';
+const BLUE = '#2e86ab';
+
+const byId = (id: string) => document.getElementById(id) as T | null;
+const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
+const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
+
+export function initPlayground(): void {
+ const root = byId('pg-root');
+ if (!root) return;
+ const inEl = byId('pg-in');
+ const outEl = byId('pg-out');
+ const ictx0 = inEl?.getContext('2d') ?? null;
+ const octx0 = outEl?.getContext('2d') ?? null;
+ if (!inEl || !outEl || !ictx0 || !octx0) return;
+ // Narrowed, non-null aliases so the closures below stay type-clean.
+ const inCanvas = inEl;
+ const outCanvas = outEl;
+ const ictx = ictx0;
+ const octx = octx0;
+
+ const state = {
+ fn: FNS[0],
+ t: 1, // identity → f
+ z: { re: 1.1, im: 0.6 } as C,
+ grid: true,
+ rings: false,
+ image: false
+ };
+
+ // The photo, lazily; the "image" layer is dormant until it loads.
+ let photo: HTMLImageElement | null = null;
+ let photoSq: { canvas: HTMLCanvasElement; size: number } | null = null;
+ const img = new Image();
+ img.decoding = 'async';
+ img.src = PHOTO;
+ img.decode().then(() => {
+ photo = img;
+ // Pre-crop to a centred square so the domain maps to it cleanly.
+ const size = Math.min(img.naturalWidth, img.naturalHeight);
+ const c = document.createElement('canvas');
+ c.width = size;
+ c.height = size;
+ const cx = c.getContext('2d');
+ if (cx) {
+ cx.drawImage(
+ img,
+ (img.naturalWidth - size) / 2,
+ (img.naturalHeight - size) / 2,
+ size, size, 0, 0, size, size
+ );
+ photoSq = { canvas: c, size };
+ }
+ if (state.image) draw();
+ }).catch(() => { /* image layer simply stays unavailable */ });
+
+ // ── interpolated map ───────────────────────────────────────────────────────
+ function ft(z: C): C {
+ const f = state.fn.map(z);
+ const t = state.t;
+ return { re: lerp(z.re, f.re, t), im: lerp(z.im, f.im, t) };
+ }
+
+ // ── output framing: fit the t=1 image, fixed per function ───────────────────
+ // Recomputed when the function changes; held steady while dragging or
+ // scrubbing so the frame doesn't lurch under the reader.
+ let outE = D;
+ function computeOutExtent(): void {
+ if (state.fn.fixedOut !== undefined) { outE = state.fn.fixedOut; return; }
+ let m = D * 0.5;
+ const N = 24;
+ for (let i = 0; i <= N; i++) {
+ for (let j = 0; j <= N; j++) {
+ const re = lerp(-D, D, i / N);
+ const im = lerp(-D, D, j / N);
+ const r2 = re * re + im * im;
+ if (state.fn.key === 'log' && r2 < 0.04) continue; // skip the singularity
+ const w = state.fn.map({ re, im });
+ m = Math.max(m, Math.abs(w.re), Math.abs(w.im));
+ }
+ }
+ outE = clamp(m * 1.08, 1.6, 8);
+ }
+ computeOutExtent();
+
+ // ── canvas sizing (DPR-aware, square) ───────────────────────────────────────
+ let cw = 0;
+ let ch = 0;
+ function resize(): void {
+ const rect = inCanvas.getBoundingClientRect();
+ if (!rect || rect.width === 0) return; // not laid out yet; ResizeObserver retries
+ const dpr = window.devicePixelRatio || 1;
+ const px = Math.max(1, Math.round(rect.width * dpr));
+ cw = px;
+ ch = px;
+ for (const cv of [inCanvas, outCanvas]) {
+ cv.width = px;
+ cv.height = px;
+ }
+ }
+
+ // plane → pixel mappers
+ const toIn = (z: C) => ({ x: cw / 2 + (z.re / D) * (cw / 2), y: ch / 2 - (z.im / D) * (ch / 2) });
+ const toOut = (z: C) => ({ x: cw / 2 + (z.re / outE) * (cw / 2), y: ch / 2 - (z.im / outE) * (ch / 2) });
+ // pixel → plane, for the input canvas (dragging)
+ const fromIn = (x: number, y: number): C => ({
+ re: ((x - cw / 2) / (cw / 2)) * D,
+ im: -((y - ch / 2) / (ch / 2)) * D
+ });
+
+ // ── drawing helpers ─────────────────────────────────────────────────────────
+ function clearPlane(ctx: CanvasRenderingContext2D): void {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = '#0e0c09';
+ ctx.fillRect(0, 0, cw, ch);
+ }
+
+ // A polyline through f_t, broken when consecutive samples jump too far (a
+ // branch cut or a blow-up) so we don't draw a false chord across the plane.
+ function strokeMappedPath(
+ ctx: CanvasRenderingContext2D,
+ pts: C[],
+ map: (z: C) => { x: number; y: number },
+ apply: boolean
+ ): void {
+ const jump = cw * 0.5;
+ let started = false;
+ let px = 0;
+ let py = 0;
+ ctx.beginPath();
+ for (const z of pts) {
+ const w = apply ? ft(z) : z;
+ const p = map(w);
+ if (started && (Math.abs(p.x - px) > jump || Math.abs(p.y - py) > jump)) {
+ started = false; // break the line here
+ }
+ if (!started) {
+ ctx.moveTo(p.x, p.y);
+ started = true;
+ } else {
+ ctx.lineTo(p.x, p.y);
+ }
+ px = p.x;
+ py = p.y;
+ }
+ ctx.stroke();
+ }
+
+ function lineSamples(a: C, b: C): C[] {
+ const out: C[] = [];
+ for (let i = 0; i <= LINE_SAMPLES; i++) {
+ const u = i / LINE_SAMPLES;
+ out.push({ re: lerp(a.re, b.re, u), im: lerp(a.im, b.im, u) });
+ }
+ return out;
+ }
+
+ function ringSamples(r: number): C[] {
+ const out: C[] = [];
+ const N = 220;
+ for (let i = 0; i <= N; i++) {
+ const a = (i / N) * Math.PI * 2 - Math.PI; // start at −π so the cut sits at an end
+ out.push({ re: r * Math.cos(a), im: r * Math.sin(a) });
+ }
+ return out;
+ }
+
+ function drawGrid(ctx: CanvasRenderingContext2D, map: (z: C) => { x: number; y: number }, apply: boolean): void {
+ ctx.lineWidth = 1.25;
+ ctx.strokeStyle = 'rgba(220,228,232,0.22)';
+ for (let g = -D; g <= D + 1e-6; g += GRID_STEP) {
+ if (Math.abs(g) < 1e-6) continue; // axes drawn separately, coloured
+ strokeMappedPath(ctx, lineSamples({ re: g, im: -D }, { re: g, im: D }), map, apply);
+ strokeMappedPath(ctx, lineSamples({ re: -D, im: g }, { re: D, im: g }), map, apply);
+ }
+ // coloured axes — track orientation through the warp
+ ctx.lineWidth = 2.5;
+ ctx.strokeStyle = RED; // real axis (im = 0)
+ strokeMappedPath(ctx, lineSamples({ re: -D, im: 0 }, { re: D, im: 0 }), map, apply);
+ ctx.strokeStyle = BLUE; // imaginary axis (re = 0)
+ strokeMappedPath(ctx, lineSamples({ re: 0, im: -D }, { re: 0, im: D }), map, apply);
+ }
+
+ function drawRings(ctx: CanvasRenderingContext2D, map: (z: C) => { x: number; y: number }, apply: boolean): void {
+ // spokes
+ ctx.lineWidth = 1.25;
+ ctx.strokeStyle = 'rgba(220,228,232,0.20)';
+ for (let s = 0; s < 12; s++) {
+ const a = (s * Math.PI) / 6;
+ const dir = { re: Math.cos(a), im: Math.sin(a) };
+ if (s === 0) continue;
+ strokeMappedPath(ctx, lineSamples({ re: 0, im: 0 }, { re: dir.re * D, im: dir.im * D }), map, apply);
+ }
+ ctx.lineWidth = 2.5;
+ ctx.strokeStyle = RED; // reference spoke θ = 0
+ strokeMappedPath(ctx, lineSamples({ re: 0.02, im: 0 }, { re: D, im: 0 }), map, apply);
+ // rings
+ ctx.lineWidth = 1.6;
+ ctx.strokeStyle = 'rgba(120,190,230,0.55)';
+ for (let r = GRID_STEP; r <= D + 1e-6; r += GRID_STEP) {
+ strokeMappedPath(ctx, ringSamples(r), map, apply);
+ }
+ }
+
+ // Affine-map one source triangle of the photo into a destination triangle.
+ function texTri(
+ ctx: CanvasRenderingContext2D,
+ src: { canvas: HTMLCanvasElement; size: number },
+ s: { x: number; y: number }[],
+ d: { x: number; y: number }[]
+ ): void {
+ const [s0, s1, s2] = s;
+ const [d0, d1, d2] = d;
+ const den = (s1.x - s0.x) * (s2.y - s0.y) - (s2.x - s0.x) * (s1.y - s0.y);
+ if (Math.abs(den) < 1e-6) return;
+ const a = ((d1.x - d0.x) * (s2.y - s0.y) - (d2.x - d0.x) * (s1.y - s0.y)) / den;
+ const c = ((d2.x - d0.x) * (s1.x - s0.x) - (d1.x - d0.x) * (s2.x - s0.x)) / den;
+ const b = ((d1.y - d0.y) * (s2.y - s0.y) - (d2.y - d0.y) * (s1.y - s0.y)) / den;
+ const d_ = ((d2.y - d0.y) * (s1.x - s0.x) - (d1.y - d0.y) * (s2.x - s0.x)) / den;
+ const e = d0.x - a * s0.x - c * s0.y;
+ const f = d0.y - b * s0.x - d_ * s0.y;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(d0.x, d0.y);
+ ctx.lineTo(d1.x, d1.y);
+ ctx.lineTo(d2.x, d2.y);
+ ctx.closePath();
+ ctx.clip();
+ ctx.setTransform(a, b, c, d_, e, f);
+ ctx.drawImage(src.canvas, 0, 0);
+ ctx.restore();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ }
+
+ function drawPhoto(ctx: CanvasRenderingContext2D, map: (z: C) => { x: number; y: number }, apply: boolean): void {
+ if (!photoSq) return;
+ const sz = photoSq.size;
+ // domain [−D,D]² ↔ source square [0,sz]²
+ const toSrc = (re: number, im: number) => ({
+ x: ((re + D) / (2 * D)) * sz,
+ y: ((D - im) / (2 * D)) * sz
+ });
+ for (let i = 0; i < MESH; i++) {
+ for (let j = 0; j < MESH; j++) {
+ const re0 = lerp(-D, D, i / MESH);
+ const re1 = lerp(-D, D, (i + 1) / MESH);
+ const im0 = lerp(-D, D, j / MESH);
+ const im1 = lerp(-D, D, (j + 1) / MESH);
+ const corners: C[] = [
+ { re: re0, im: im0 }, { re: re1, im: im0 },
+ { re: re1, im: im1 }, { re: re0, im: im1 }
+ ];
+ const sc = corners.map((p) => toSrc(p.re, p.im));
+ const dc = corners.map((p) => map(apply ? ft(p) : p));
+ texTri(ctx, photoSq, [sc[0], sc[1], sc[2]], [dc[0], dc[1], dc[2]]);
+ texTri(ctx, photoSq, [sc[0], sc[2], sc[3]], [dc[0], dc[2], dc[3]]);
+ }
+ }
+ }
+
+ function dot(ctx: CanvasRenderingContext2D, p: { x: number; y: number }, color: string, r: number): void {
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = 'rgba(255,255,255,0.9)';
+ ctx.stroke();
+ }
+
+ // the little neighbour square — stays near-square for analytic maps
+ function neighbourSquare(z: C): C[] {
+ return [
+ { re: z.re - NEIGH, im: z.im - NEIGH },
+ { re: z.re + NEIGH, im: z.im - NEIGH },
+ { re: z.re + NEIGH, im: z.im + NEIGH },
+ { re: z.re - NEIGH, im: z.im + NEIGH }
+ ];
+ }
+
+ function drawSquare(ctx: CanvasRenderingContext2D, corners: C[], map: (z: C) => { x: number; y: number }, apply: boolean): void {
+ // dense path so the sides bend with the map
+ const pts: C[] = [];
+ for (let e = 0; e < 4; e++) {
+ const a = corners[e];
+ const b = corners[(e + 1) % 4];
+ for (let i = 0; i < 18; i++) pts.push({ re: lerp(a.re, b.re, i / 18), im: lerp(a.im, b.im, i / 18) });
+ }
+ pts.push(corners[0]);
+ ctx.save();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = ACCENT;
+ ctx.fillStyle = 'rgba(217,79,44,0.18)';
+ ctx.beginPath();
+ let started = false;
+ const jump = cw * 0.5;
+ let px = 0;
+ let py = 0;
+ for (const z of pts) {
+ const w = apply ? ft(z) : z;
+ const p = map(w);
+ if (started && (Math.abs(p.x - px) > jump || Math.abs(p.y - py) > jump)) started = false;
+ if (!started) { ctx.moveTo(p.x, p.y); started = true; } else ctx.lineTo(p.x, p.y);
+ px = p.x; py = p.y;
+ }
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ function drawAxesFrame(ctx: CanvasRenderingContext2D): void {
+ // faint origin crosshair so the centre is locatable even when layers are off
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = 'rgba(255,255,255,0.10)';
+ ctx.beginPath();
+ ctx.moveTo(cw / 2, 0); ctx.lineTo(cw / 2, ch);
+ ctx.moveTo(0, ch / 2); ctx.lineTo(cw, ch / 2);
+ ctx.stroke();
+ }
+
+ function drawSide(ctx: CanvasRenderingContext2D, map: (z: C) => { x: number; y: number }, apply: boolean): void {
+ clearPlane(ctx);
+ if (state.image) drawPhoto(ctx, map, apply);
+ drawAxesFrame(ctx);
+ if (state.grid) drawGrid(ctx, map, apply);
+ if (state.rings) drawRings(ctx, map, apply);
+ drawSquare(ctx, neighbourSquare(state.z), map, apply);
+ dot(ctx, map(apply ? ft(state.z) : state.z), ACCENT, 6.5);
+ }
+
+ function draw(): void {
+ if (cw === 0) {
+ resize();
+ if (cw === 0) return; // still not laid out; wait for ResizeObserver
+ }
+ drawSide(ictx, toIn, false);
+ drawSide(octx, toOut, true);
+ updateReadout();
+ }
+
+ // ── readouts ────────────────────────────────────────────────────────────────
+ const zOut = byId('pg-z');
+ const fzOut = byId('pg-fz');
+ const fmt = (z: C) => {
+ const r = z.re.toFixed(2);
+ const i = Math.abs(z.im).toFixed(2);
+ return `${r} ${z.im < 0 ? '−' : '+'} ${i}i`;
+ };
+ function updateReadout(): void {
+ if (zOut) zOut.textContent = fmt(state.z);
+ if (fzOut) fzOut.textContent = fmt(ft(state.z));
+ }
+
+ // ── controls ────────────────────────────────────────────────────────────────
+ function selectFn(key: FnKey): void {
+ const f = FNS.find((x) => x.key === key);
+ if (!f) return;
+ state.fn = f;
+ computeOutExtent();
+ document.querySelectorAll('.pg-fn').forEach((b) => b.classList.toggle('on', b.dataset.fn === key));
+ draw();
+ }
+ document.querySelectorAll('.pg-fn').forEach((b) => {
+ b.addEventListener('click', () => selectFn((b.dataset.fn as FnKey) ?? 'id'));
+ });
+
+ function setLayer(name: 'grid' | 'rings' | 'image', on: boolean): void {
+ state[name] = on;
+ document.querySelectorAll(`[data-layer="${name}"]`).forEach((b) => b.classList.toggle('on', on));
+ draw();
+ }
+ document.querySelectorAll('[data-layer]').forEach((b) => {
+ b.addEventListener('click', () => {
+ const name = b.dataset.layer as 'grid' | 'rings' | 'image';
+ if (name === 'image' && !photoSq) return;
+ setLayer(name, !state[name]);
+ });
+ });
+
+ const tSlider = byId('pg-t');
+ if (tSlider) {
+ tSlider.min = '0';
+ tSlider.max = '100';
+ tSlider.step = '1';
+ tSlider.value = '100';
+ tSlider.addEventListener('input', () => {
+ state.t = parseFloat(tSlider.value) / 100;
+ draw();
+ });
+ }
+
+ byId('pg-reset')?.addEventListener('click', () => {
+ state.z = { re: 1.1, im: 0.6 };
+ state.t = 1;
+ if (tSlider) tSlider.value = '100';
+ draw();
+ });
+
+ // ── drag the input point ─────────────────────────────────────────────────────
+ let dragging = false;
+ function pointerToZ(e: PointerEvent): C {
+ const rect = inCanvas.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ const x = (e.clientX - rect.left) * dpr;
+ const y = (e.clientY - rect.top) * dpr;
+ const z = fromIn(x, y);
+ return { re: clamp(z.re, -D, D), im: clamp(z.im, -D, D) };
+ }
+ inCanvas.addEventListener('pointerdown', (e) => {
+ dragging = true;
+ state.z = pointerToZ(e);
+ try { inCanvas.setPointerCapture(e.pointerId); } catch { /* noop */ }
+ e.preventDefault();
+ draw();
+ });
+ inCanvas.addEventListener('pointermove', (e) => {
+ if (!dragging) return;
+ state.z = pointerToZ(e);
+ draw();
+ });
+ const end = (e: PointerEvent) => {
+ dragging = false;
+ try { inCanvas.releasePointerCapture(e.pointerId); } catch { /* noop */ }
+ };
+ inCanvas.addEventListener('pointerup', end);
+ inCanvas.addEventListener('pointercancel', end);
+
+ // ── boot ──────────────────────────────────────────────────────────────────
+ document.querySelectorAll('.pg-fn').forEach((b) => b.classList.toggle('on', b.dataset.fn === state.fn.key));
+ // ResizeObserver fires once on observe, which lays the first frame down after
+ // layout exists — so a module that evaluates before CSS is applied still draws.
+ new ResizeObserver(() => { resize(); draw(); }).observe(root);
+ // And one deferred pass in case the observer is slow on the very first paint.
+ requestAnimationFrame(() => {
+ setLayer('grid', state.grid);
+ resize();
+ draw();
+ });
+}
diff --git a/src/lib/render/pipeline-gl.frag.glsl b/src/lib/render/pipeline-gl.frag.glsl
index 586b357..aa284bf 100644
--- a/src/lib/render/pipeline-gl.frag.glsl
+++ b/src/lib/render/pipeline-gl.frag.glsl
@@ -10,6 +10,9 @@ precision highp float;
* mode 0 log(z − c) : fill the cell with the (logS, 2π) lattice.
* mode 1 rotated log : same lattice rotated by β = atan(logS/2π).
* mode 2 tententoon still : (z − c)^α, α = 1 − i·logS/2π, at t = 0.
+ * mode 3 unroll : morph spiral (u_morph=1) ↔ rotated-log strip
+ * (u_morph=0). The "take the log" intuition made
+ * visible: the spiral is the strip rolled up.
*
* log / rotlog map the WHOLE canvas (no letterbox): log space is doubly
* periodic, so every pixel folds to a valid source ring — the panel tiles
@@ -40,6 +43,7 @@ uniform float u_lnR0; // log of the orientation radius R0
uniform float u_rot; // rotated-log rotation angle (canonical: atan(logS/2π))
uniform float u_kTwist; // tententoon twist k (canonical: logS/2π = tan(u_rot))
uniform vec2 u_pan; // log-space pan (δu, δv) applied to all panels
+uniform float u_morph; // unroll: 1 = spiral, 0 = rotated-log strip
out vec4 fragColor;
@@ -66,6 +70,38 @@ void main() {
float r = exp(bLnR);
src = u_c + r * vec2(cos(nPhi), sin(nPhi));
footA = sqrt(1.0 + k * k) * exp(k * Phi + u_pan.x) / u_scale;
+ } else if (u_mode == 3) {
+ // --- unroll --- one continuous deformation between the rolled-up spiral
+ // (m = 1) and the flat rotated-log strip (m = 0). We express BOTH ends in
+ // source log-polar coords (u = log-radius, v = angle) and mix there, so
+ // each endpoint is exact and the in-between reads as the strip curling up.
+ float k = u_kTwist;
+ float m = u_morph;
+ float cu = (pxd.x - u_canvas.x * 0.5) / u_pxPerUnit;
+ float cv = (pxd.y - u_canvas.y * 0.5) / u_pxPerUnit;
+
+ // Strip end: the rotated-log lattice (un-rotate the centred pixel by −β).
+ float cosB = cos(u_rot);
+ float sinB = sin(u_rot);
+ float uStrip = cu * cosB + cv * sinB + u_uRef;
+ float vStrip = -cu * sinB + cv * cosB;
+
+ // Spiral end: read the centred pixel as a point in the plane, take its
+ // screen log-polar (radius, angle), then apply the same twist the
+ // tententoon uses. A small radius floor leaves a clean central hole
+ // (the very spot Escher left blank) instead of a −∞ singularity.
+ float rho = max(length(vec2(cu, cv)), 0.06);
+ float phi = atan(cv, cu);
+ float Lr = log(rho);
+ float uSpiral = Lr + k * phi + u_uRef;
+ float vSpiral = phi - k * (Lr - u_lnR0);
+
+ float u = mix(uStrip, uSpiral, m) + u_pan.x;
+ float v = mix(vStrip, vSpiral, m) + u_pan.y;
+ u = u_uRef - mod(u_uRef - u, u_logS);
+ float r = exp(u);
+ src = u_c + r * vec2(cos(v), sin(v));
+ footA = r / u_pxPerUnit;
} else {
// --- log / rotated log: fill the cell, centred anchor ---
float cu = (pxd.x - u_canvas.x * 0.5) / u_pxPerUnit;
diff --git a/src/lib/render/pipeline-gl.ts b/src/lib/render/pipeline-gl.ts
index f3d87c5..2fe0f7a 100644
--- a/src/lib/render/pipeline-gl.ts
+++ b/src/lib/render/pipeline-gl.ts
@@ -19,9 +19,9 @@ import vertSrc from './escher-zoom/shader.vert.glsl?raw';
import fragSrc from './pipeline-gl.frag.glsl?raw';
import type { DrosteCtx } from '../math/transforms';
-export type PanelMode = 'log' | 'rotlog' | 'escher';
+export type PanelMode = 'log' | 'rotlog' | 'escher' | 'unroll';
-const MODE_CODE: Record = { log: 0, rotlog: 1, escher: 2 };
+const MODE_CODE: Record = { log: 0, rotlog: 1, escher: 2, unroll: 3 };
export type PipelineGLInput = {
pixels: ImageData;
@@ -45,6 +45,8 @@ export type PipelineGLInput = {
kTwist?: number;
panU?: number;
panV?: number;
+ /** unroll mode only: 1 = rolled-up spiral, 0 = flat rotated-log strip. */
+ morph?: number;
};
export class PipelinePanelGLRenderer {
@@ -111,7 +113,8 @@ export class PipelinePanelGLRenderer {
u_lnR0: input.lnR0 ?? 0,
u_rot: input.rot ?? Math.atan2(droste.logS, 2 * Math.PI),
u_kTwist: input.kTwist ?? droste.logS / (2 * Math.PI),
- u_pan: [input.panU ?? 0, input.panV ?? 0]
+ u_pan: [input.panU ?? 0, input.panV ?? 0],
+ u_morph: input.morph ?? 0
});
twgl.drawBufferInfo(gl, quad, gl.TRIANGLE_STRIP);
}
diff --git a/vite.config.ts b/vite.config.ts
index 28c1a04..572c7ae 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -130,5 +130,16 @@ export default defineConfig({
// (root, subdirectory, or static-file hosts like GitHub Pages).
base: './',
plugins: [svelte(), falUpscaleProxy(), generateVariantPages()],
+ build: {
+ rollupOptions: {
+ input: {
+ // The app, and the standalone explorable explainer (explain.html +
+ // src/explain/main.ts). Two HTML entry points → dist/index.html and
+ // dist/explain.html, each with its own bundled module.
+ main: resolve(process.cwd(), 'index.html'),
+ explain: resolve(process.cwd(), 'explain.html')
+ }
+ }
+ },
server: { host: '127.0.0.1', port: 5173, strictPort: true }
});