diff --git a/README.md b/README.md index 8b0253f..6c6cd27 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,69 @@ -# Tententoon +

tententoon

-> *Een prentententoonstelling.* — M. C. Escher, 1956 +

noun  ·  a self-repeating image whose copies spiral as they shrink — in the manner of M. C. Escher's Print Gallery (1956).

-A browser-native Droste machine. Drop in any photograph, frame the rectangle that should -contain a smaller copy of itself, and watch the image recurse into its own infinity — the -way the boy in Escher's *Print Gallery* stares into the picture he is standing inside of. - -Everything runs locally. No uploads, no backend, no account. Just the canvas, the maths, -and your image. - ---- - -## What it does - -- **Drop or pick** any image — JPEG, PNG, anything the browser can decode. -- **Frame** a rectangle inside it. That rectangle becomes the next iteration of itself. -- **Zoom forever** — a continuous, seamless loop into (or out of) the nested copy. -- **Export** the result as a still PNG, a looping MP4, or a GIF. All produced in-browser. -- **Tweak** the spiral: pick the centre, the scale ratio, the rotation, the depth. -- **Two views**: a polished editor (top bar, tool rail, canvas, inspector, timeline) and a - log-domain explorable for those who like to see the conformal map at work. - -The geometry is the same one Escher's mathematicians used to "complete" the Print Gallery: -a log-polar warp that turns nested similarity into a straight line. We let you see both -sides of that mirror. +

+from the Dutch title Prentententoonstellingprenten (prints) + tentoonstelling (exhibition),
+with the coined word sitting right where the two halves meet.
+A browser tool for making the same move with your own images. +

--- -## Why "Tententoon"? +## Same picture, two infinities -A contraction of *prentententoonstelling* — the Dutch word for "print gallery" — squeezed -the way Escher squeezed his canvas. Short enough to type, long enough to remember. + + + + + + + + + +
A photograph of a person holding a frame; inside the frame is the same person holding the same frame, repeating into the distance.The same photograph, with the nested copies rotating as they shrink, winding the whole image into a smooth spiral.
Droste — a picture inside itself, dropping straight down forever.
(this one is a real photograph)
tententoon — the same recursion, bent into a spiral that still closes seamlessly.
---- +> Same rule — a copy inside a copy inside a copy. One drops straight down; the other takes the scenic route, and still arrives on time. -## Run it locally +## The idea -```sh -bun install # or: npm install -bun run dev # or: npm run dev -``` +Nest a picture inside itself and you get the **Droste effect**: each copy sits squarely inside the last, shrinking by the same step, forever. -Then open the URL Vite prints. The editor lives at `/`; the log-domain explorable lives -at `/ui1`. +A **tententoon** asks one stranger question — *what if each copy also turns as it shrinks?* The nested frames then wind into a spiral. Straight lines bow into curves, the whole picture twists, and yet nothing tears: follow any line inward and it meets itself exactly. -### Build +The trick is a single move — **take the logarithm.** Shrinking-and-repeating is multiplication, and logarithms turn multiplication into addition, so "shrink, then repeat" becomes "slide over, then repeat" — a plain, evenly-spaced grid. Add rotation and that grid simply *tilts*; roll it back up and the tilt is a spiral. The loop is seamless because, in log-space, one full turn is just a shift by exactly one step — landing you on an identical picture. -```sh -bun run build # → dist/ -bun run preview # serve dist/ locally -bun run smoke # headless smoke test -``` +**→ The whole story — why it spirals, and why it never tears:** [silvio-tententoon.pgs.sh/explain.html](https://silvio-tententoon.pgs.sh/explain.html) ---- +## Where the name comes from -## How it works, briefly +Escher drew *Print Gallery* by hand in 1956 and left a white blot at its centre — the spiral grew too tight to finish. In 2003, **Hendrik Lenstra** and **Bart de Smit** found the exact map behind it: the idealised picture contains a copy of itself rotated **157.6256°** and shrunk by **22.5837×**. With those two numbers, they completed the picture by computer. In 2026, **3Blue1Brown** turned the whole argument into a [beautifully animated video](https://www.youtube.com/watch?v=ldxFjLJ3rVY). -The Droste effect is a single operation applied to itself: take the outer image rectangle, -map it onto the user's inner rectangle, paint, repeat. After a few iterations the inner -copy is sub-pixel and we stop. +## Make one -For the smooth infinite zoom, the inner rectangle's *scale ratio* `S` and rotation angle -`θ` define a logarithmic spiral. Mapping image coordinates through -`log(z - c) → log(z - c) + log(S) + iθ` turns that spiral into a translation in -log-space — which is why the loop is exactly seamless: translating by one full period -lands on the same picture. +Drop in any photo, draw the rectangle where the next copy should sit, and flip between the two infinities — the straight Droste fall or the tententoon spiral. Export the loop as a PNG, GIF, or video. Everything runs in your browser: no upload, no account, no server. -The renderer is WebGL via [twgl.js](https://twgljs.org/), with a CPU fallback worker for -environments without a usable GL context. Supersampling adapts to the local Jacobian so -the spiral's tight centre stays sharp without paying for it across the whole frame. +**Live:** [silvio-tententoon.pgs.sh](https://silvio-tententoon.pgs.sh/) --- -## Tech +
+Run it locally -| Layer | Choice | -|-------------|--------| -| UI | Svelte 5 (runes) + Vite | -| Rendering | WebGL (twgl.js), with worker-driven CPU fallback | -| State | `$state` runes, plain CSS custom properties for theming | -| Exports | `MediaRecorder` (MP4/WebM), `gifenc` (GIF), `.toBlob` (PNG) | -| Storage | `localStorage` for the last session's selection | -| Backend | none — by design | - ---- - -## Layout - -``` -src/ - App.svelte root: routing, bootstrap, panels - components/ - EscherPanel.svelte static Droste render - EscherZoomPanel.svelte animated spiral zoom (GPU + CPU fallback) - LogPanel.svelte log-domain view - SourcePanel.svelte the source-image picker with nested rect - UiVariant1.svelte the polished editor shell - ui1/ editor sub-components (tool rail, inspector, …) - lib/ - math/ Droste geometry, spiral, log map - render/ GL shaders, CPU worker - stores/ image + selection state - persistence.ts session restore - ui1/ editor state machine, exports -public/ - Droste_1260359-nevit.jpg default sample image (CC BY-SA 3.0, Nevit Dilmen) +```sh +npm install # or: bun install +npm run dev # or: bun run dev → open the URL Vite prints +npm run build # → dist/ +npm run preview # serve dist/ locally ``` ---- - -## Credits - -- **The Print Gallery** — M. C. Escher, 1956. The lithograph that gave this its name and - its blind spot. -- **The mathematical completion** — Bart de Smit and Hendrik Lenstra, 2003. - *Artful Mathematics: The Heritage of M. C. Escher.* Notices of the AMS. -- **Default image** — *Droste effect* photograph by [Nevit Dilmen](https://commons.wikimedia.org/wiki/File:Droste_1260359-nevit.jpg), - used under [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/). - ---- +Svelte 5 (runes) + Vite. Rendering is WebGL via [twgl.js](https://twgljs.org/) with a CPU-worker fallback; exports use `.toBlob` (PNG), `MediaRecorder` (MP4/WebM), and [`gifenc`](https://github.com/mattdesl/gifenc) (GIF). No backend by design. -## Licence +
-The code is released under the GNU Affero General Public License v3.0 or later; see -`LICENSE`. +## Credits & licence -The default sample image is not relicensed as AGPL. It carries its own CC BY-SA 3.0 -licence; see `public/ATTRIBUTION.txt`. +- **Print Gallery** / *Prentententoonstelling* — M. C. Escher, 1956. Escher's works are © The M. C. Escher Company; referenced here by description and link, not reproduced. +- **The mathematical completion** — Bart de Smit & Hendrik Lenstra, *The Mathematical Structure of Escher's Print Gallery*, [Notices of the AMS, 2003](https://www.ams.org/notices/200304/fea-escher.pdf). +- **Demo image** — *Droste effect* by [Nevit Dilmen](https://commons.wikimedia.org/wiki/File:Droste_1260359-nevit.jpg), [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) (see `public/ATTRIBUTION.txt`). +- **Code** — [AGPLv3 or later](LICENSE) (the sample image keeps its own CC BY-SA 3.0 licence). diff --git a/docs/screenshots/dictionary-card-desktop.png b/docs/screenshots/dictionary-card-desktop.png new file mode 100644 index 0000000..781700e Binary files /dev/null and b/docs/screenshots/dictionary-card-desktop.png differ diff --git a/docs/screenshots/dictionary-card-mobile.png b/docs/screenshots/dictionary-card-mobile.png new file mode 100644 index 0000000..aa3f65e Binary files /dev/null and b/docs/screenshots/dictionary-card-mobile.png differ diff --git a/docs/screenshots/explorable-polar.png b/docs/screenshots/explorable-polar.png new file mode 100644 index 0000000..53a7a56 Binary files /dev/null and b/docs/screenshots/explorable-polar.png differ diff --git a/docs/screenshots/explorable.png b/docs/screenshots/explorable.png new file mode 100644 index 0000000..9938e66 Binary files /dev/null and b/docs/screenshots/explorable.png differ diff --git a/explain.html b/explain.html new file mode 100644 index 0000000..e2c1ce1 --- /dev/null +++ b/explain.html @@ -0,0 +1,642 @@ + + + + + +What is a tententoon? + + + + + + + + + + + +
+ +
+
+ + +

+ ten·ten·toon + /ˌtɛn.tɛnˈtoːn/ + noun +

+ +

+ 1. a self-repeating image whose copies spiral as + they shrink, in the manner of M. C. Escher's + Print Gallery. +

+

a tententoon of the old + gallery, winding inward without end.

+ +

+ Origin + coined from the Dutch Prentententoonstelling + (“print exhibition”): prenten, prints + + tentoonstelling, exhibition. The coined word sits right where + the two halves meet. +

+
+
+ +

First, the easy version

+

+ Put a picture inside itself. Then put it inside that copy, and inside + the next, and don't stop. +

+ +
+
+ A photograph of a person holding a picture frame; inside the frame is the same person holding the same frame, repeating into the distance. +
+
+ A real photograph: a frame, inside a frame, inside a frame. The effect + is named after a tin of Droste cocoa that pulled the same trick back in 1904. +
+
+ +

+ Every copy sits squarely inside the one before it. The picture drops + straight down into itself, shrinking by the same step each time, + forever. That is the Droste effect, and you have seen it a + hundred times. +

+ +

Now bend it

+

+ Here is a stranger question. What if each copy does not only shrink? What + if it also turns? +

+ +
+
+ tententoon + +
+
A live tententoon: the source, wound into a spiral.
+
+
+ + +
+ +

+ Same picture. Same rule: a copy, inside a copy, inside a copy. But now every + copy is rotated a little as it shrinks, and the whole stack winds up into a + spiral. Watch the edges: straight lines bow into curves, the room twists, and + nothing tears. Follow any line inward and it meets itself exactly. Zoom + forever and you never find a seam. +

+ +

+ Same picture. Two infinities. One drops straight down; the other takes the + scenic route, and still arrives on time. +

+ +

Why doesn't it tear?

+

+ All of it comes from one move: take the logarithm. +

+

+ Measure every point by how far it sits from the centre c and at what + angle, then take the logarithm of the distance. Shrinking-and-repeating is + multiplication, and logarithms turn multiplication into addition, so the + endless nested frames unroll into a plain, repeating lattice: step + sideways by log S for one Droste jump, up or down by + for one full turn around c. Straight. Boring. Tiling forever. +

+

+ And here is the surprise, in your fingertips. The panel on the left is that + lattice, log(z − c). Drag it. A + sideways drag zooms the original on the right; an up-and-down drag + rotates it. A slide in the log is nothing but a rotate-and-zoom back + in the original picture. That is the whole trick, and the readout keeps the + score. Feed the same machine something cleaner, too: a grid, or polar circles + with each ring styled so you can follow it through the bend. +

+ +
+ + show + + + + + +
+ +
+
+
+ log(z − c) drag me + + ↔ zoom  ↕ rotate +
+
+ the original + +
+
+
+
+ drag the log panel: ↔ zoom, ↕ rotate the original + +
+ + +

+ Now add the turn for real. A copy that shrinks and rotates leans that + lattice over by a fixed angle, β = arctan(log S / 2π). It + is the one tilt that lets the pattern line up with itself again after a slide: +

+ +
+
+ rotated log + +
+
The same lattice, leaned over by β. That lean is the whole difference between a Droste and an Escher.
+
+ +

+ Roll the logarithm back up and the tilted lattice winds into the spiral you saw + above: the tententoon. In a single line it is the map + w(z) = c + (z − c)α with + α = 1 − i·(log S / 2π): the real part carries + the picture, the imaginary part is the lean. +

+

+ And here is why it never tears. In the lattice there is nothing to tear, + only a pattern that repeats. Slide it by exactly one tile and you land on an + identical picture; roll that back up and “one tile” becomes + “one full turn of the spiral.” The loop closes because the shift + closes. The seam isn't hidden; there simply isn't one. +

+ +

Escher got there first, by hand

+

+ Escher had no computer in 1956. He worked the curved grid out by eye, ruled it + onto the canvas, and painted a gallery, a print, a town, and the gallery again + into the bend. And he got it right: the mathematics later showed his + intuition was very nearly exact. +

+

+ But a spiral tightens forever toward its centre, and a pen can only go so fine. + So Escher stopped, left a soft white patch in the middle of the picture, curled + his signature into it, and called it finished. The one place the picture could + not finish itself. +

+ +

The map that filled the hole

+

+ In 2003, two mathematicians in Leiden (Bart de Smit and + Hendrik Lenstra) worked out the exact map hiding in Escher's + grid. The idealised Print Gallery, they showed, contains a complete + copy of itself rotated by 157.6256° and shrunk by a factor + of 22.5837. Pin those two numbers down and the rest of the + picture is forced, including the part Escher left blank. +

+

+ This is not a theorem so much as a recipe: with the map in hand, you can hand + the missing centre to a computer and have it do what a pen could not. That is + exactly what they did, continuing the spiral inward far past the reach of any + hand and closing the white hole at last. +

+ +

See it move

+

+ If you'd like the whole argument in motion, Grant Sanderson + (of 3Blue1Brown) made a beautiful animated tour of the paper in 2026. + It is the clearest walk through the mathematics there is: + watch it here. +

+ +

Now make one

+

+ This tool does the bending for you. Drop in any photo, draw the rectangle where + the next copy should sit, and flip between the two infinities: the straight + Droste fall, or the tententoon spiral. Export the loop as a PNG, a GIF, or a + video. It all runs in your browser: no upload, no account, no server. +

+
+ Open the tool → +
+ + + +
+ + + diff --git a/public/tententoon-demo.gif b/public/tententoon-demo.gif new file mode 100644 index 0000000..2955d68 Binary files /dev/null and b/public/tententoon-demo.gif differ diff --git a/src/components/ui1/InfoModal.svelte b/src/components/ui1/InfoModal.svelte index 0bd6867..b860771 100644 --- a/src/components/ui1/InfoModal.svelte +++ b/src/components/ui1/InfoModal.svelte @@ -24,14 +24,15 @@

About tententoon

-

A toy for re-creating the Droste effect from Escher's Print Gallery.

+

A toy for re-creating the Droste effect from Escher's Print Gallery.

-
+ +

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..a1a9d8c --- /dev/null +++ b/src/explain/main.ts @@ -0,0 +1,281 @@ +/** + * Live panels for explain.html, rendered by the app's own GPU pipeline + * renderer (PipelinePanelGLRenderer) — no duplicated map math. + * + * #exp-escher — the tententoon spiral, in "Now bend it". Its twist follows + * the angle slider, so you see how β shapes the final spiral. + * #exp-rotlog — the rotated-log lattice (leans by the same angle). + * #exp-log — log(z − c). DRAGGABLE. + #exp-orig — "the original". + * + * Two global controls feed every panel from one swappable test image: + * • source mode — picture / grid / polar / overlay (see patterns.ts). + * • twist angle — overrides β = atan(logS / 2π); only at β does the spiral + * close on itself. + * + * The experiment: dragging #exp-log pans log space. Horizontal pan = a shift in + * u = log|z − c| (a ZOOM); vertical pan = a shift in v = arg(z − c) (a + * ROTATION). The same pan drives #exp-orig (the picture with the twist off), so + * a slide in the log visibly becomes a rotate-and-zoom of the original. + */ + +import { fitCropToNest, type Rect } from '../lib/math/droste'; +import { buildPanelGeometry, panelPxPerUnit, panelURef } from '../lib/ui1/pipeline-panels'; +import { PipelinePanelGLRenderer } from '../lib/render/pipeline-gl'; +import { makeSource, type SourceMode } from './patterns'; + +// ─── Swappable test image ─────────────────────────────────────────────────── +// To use a different picture: drop it in /public and point SOURCE at it. NEST +// is the nest rectangle in *image pixels* — load it in the editor, draw the +// rectangle, read x / y / w / h off the readout. Leave NEST = null to drop a +// ~half-size nest in the centre of whatever image you give (S ≈ 2). +const SOURCE = '/Droste_1260359-nevit.jpg'; +const NEST: Rect | null = { x: 343.2, y: 334.7, w: 583.5, h: 454.9 }; +// ───────────────────────────────────────────────────────────────────────────── + +const TWO_PI = 2 * Math.PI; +const MAX_PX = 720; + +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); +} + +type Role = 'tententoon' | 'log' | 'orig' | 'rotlog'; +type Panel = { role: Role; canvas: HTMLCanvasElement; renderer: PipelinePanelGLRenderer }; + +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').forEach((el) => { + el.style.display = 'none'; + }); + } + + let photo: HTMLImageElement; + try { + photo = await loadImage(SOURCE); + } catch { + showFallback('Could not load the test image.'); + return; + } + + const W = photo.naturalWidth; + const H = photo.naturalHeight; + const nest = NEST ?? centeredNest(W, H); + const crop = fitCropToNest({ width: W, height: H }, nest, null); + const geom = buildPanelGeometry(nest, crop); + if (!geom) { + showFallback('That nest is degenerate. Pick a smaller rectangle.'); + return; + } + const { ctx, R0, S } = geom; + const uRef = panelURef(ctx.rMax); + const lnR0 = Math.log(Math.max(R0, 1e-9)); + const cImgX = crop.x + ctx.cx; + const cImgY = crop.y + ctx.cy; + const beta = Math.atan2(ctx.logS, TWO_PI); // canonical twist angle + + // Image-aspect cells so the "original" panel shows the picture undistorted. + root.style.setProperty('--cell-ar', `${W} / ${H}`); + + // ── State ────────────────────────────────────────────────────────────── + const state = { source: 'picture' as SourceMode, angle: beta, panU: 0, panV: 0 }; + let pixels = makeSource(state.source, photo, W, H, cImgX, cImgY, S); + + const roles: Role[] = ['tententoon', 'log', 'orig', 'rotlog']; + const idByRole: Record = { + tententoon: 'exp-escher', + log: 'exp-log', + orig: 'exp-orig', + rotlog: 'exp-rotlog' + }; + const panels: Panel[] = []; + try { + for (const role of roles) { + const canvas = byId(idByRole[role]); + if (!canvas) continue; + const renderer = new PipelinePanelGLRenderer(); + renderer.init(canvas); + panels.push({ role, canvas, renderer }); + } + } catch { + for (const p of panels) p.renderer.dispose(); + showFallback(); + return; + } + + function renderPanel(p: Panel): void { + const dpr = window.devicePixelRatio || 1; + const r = p.canvas.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return; + 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)); + } + const a = state.angle; + switch (p.role) { + case 'tententoon': + p.renderer.render({ + pixels, ctx, mode: 'escher', W: cw, H: ch, + scale: cw / ctx.W, lnR0, kTwist: Math.tan(a) + }); + break; + case 'orig': // the picture itself (twist off), rotated/zoomed by the pan + p.renderer.render({ + pixels, ctx, mode: 'escher', W: cw, H: ch, + scale: cw / ctx.W, 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, panU: state.panU, panV: state.panV + }); + break; + case 'rotlog': + p.renderer.render({ + pixels, ctx, mode: 'rotlog', W: cw, H: ch, + pxPerUnit: panelPxPerUnit('rotlog', ctx.logS, ch), uRef, rot: a + }); + break; + } + } + + function updateReadout(): void { + if (!readout) return; + if (state.panU === 0 && state.panV === 0) { + readout.textContent = 'drag the log panel: ↔ zoom, ↕ rotate the original'; + return; + } + const zoom = Math.exp(state.panU); + let deg = ((state.panV * 180) / Math.PI) % 360; + if (deg < 0) deg += 360; + readout.textContent = `the original is now zoomed ×${zoom.toFixed(2)} and rotated ${deg.toFixed(0)}°`; + } + + let raf = 0; + let dirtyAll = false; + const dirty = new Set(); + function scheduleRender(only?: Role[]): void { + if (only) only.forEach((r) => dirty.add(r)); + else dirtyAll = true; + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + 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(); + }); + } + + // ── Source mode ────────────────────────────────────────────────────────── + function setSource(mode: SourceMode): void { + state.source = mode; + pixels = makeSource(mode, photo, W, H, cImgX, cImgY, S); + document.querySelectorAll('[data-source]').forEach((b) => { + b.classList.toggle('on', b.dataset.source === mode); + }); + scheduleRender(); // new texture → re-render every panel + } + document.querySelectorAll('[data-source]').forEach((b) => { + b.addEventListener('click', () => setSource((b.dataset.source as SourceMode) ?? 'picture')); + }); + + // ── Twist angle ──────────────────────────────────────────────────────── + const angleInput = byId('exp-angle'); + const angleVal = byId('exp-angle-val'); + const betaDeg = (beta * 180) / Math.PI; + function setAngleDeg(deg: number): void { + state.angle = (deg * Math.PI) / 180; + if (angleVal) { + const closed = Math.abs(deg - betaDeg) < 0.4; + angleVal.textContent = `${deg.toFixed(1)}°${closed ? ' · closed ✓' : ''}`; + angleVal.classList.toggle('closed', closed); + } + scheduleRender(['tententoon', 'rotlog']); + } + if (angleInput) { + angleInput.min = '0'; + angleInput.max = Math.max(24, Math.ceil(betaDeg * 3)).toString(); + angleInput.step = '0.1'; + angleInput.value = betaDeg.toFixed(1); + angleInput.addEventListener('input', () => setAngleDeg(parseFloat(angleInput.value))); + setAngleDeg(betaDeg); + } + const betaNote = byId('exp-angle-note'); + if (betaNote) betaNote.textContent = `closes on itself at β ≈ ${betaDeg.toFixed(1)}°`; + + // ── Drag-to-pan on the log panel ───────────────────────────────────────── + 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 (synthetic event) */ + } + 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 + state.panU += (e.clientX - lastX) / perUnit; + state.panV += (e.clientY - lastY) / perUnit; + lastX = e.clientX; + lastY = e.clientY; + scheduleRender(['log', 'orig']); + }); + 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; + scheduleRender(['log', 'orig']); + }); + + new ResizeObserver(() => scheduleRender()).observe(root); + scheduleRender(); +} + +void main(); 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/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 } });