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..f0602fd --- /dev/null +++ b/explain.html @@ -0,0 +1,1157 @@ + + + + + +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. +

+
+
+ +

Start with an ordinary photo

+

+ Here is one: someone on a couch, holding an empty frame. +

+ +
+
+ A photograph of a person sitting on a couch, holding up an empty rectangular picture frame. +
+
Nothing unusual yet: a person, a couch, an empty frame.
+
+ +

+ Now do one thing to it. Take the whole photo — couch, smile, frame and + all — shrink it, and slot it into that empty frame. The frame isn't empty + now; it holds the photo. But the copy you just dropped in has a frame of its + own, and that one is empty. So fill it the same way. And the copy + inside it. There is no last step. +

+ +
+
+ The same photograph, but now the frame holds a smaller copy of the whole photo, which holds a smaller copy again, repeating inward. +
+
The same photo, dropped into its own frame, again and again.
+
+ +

+ That is the Droste effect, after the 1904 cocoa tin that + used it. Each copy sits squarely inside the last, the same shape a fixed + fraction smaller, and the picture falls straight toward its own centre — + with no bottom to reach. +

+ +

+ That straight fall is one kind of infinity. Here is a second: let every copy + turn a little as it shrinks, and the stack winds into a spiral. +

+ +

+ To build that, we need one idea first — the one Escher was really using + when he drew his curved grid by hand. So before the spiral, a short detour to + pick up the language the rest of the page speaks. +

+ +

First, what is a function?

+

+ A function is a rule with an input and an output. For us the input is a point + on the plane, and the output is another point: +

+

zf(z)

+

+ So if z is one point on the picture, f(z) tells us + where that point goes. Now do it for every point at once. The grid + moves. The rings move. The photo moves. That is all a warp is: one function, + evaluated everywhere. +

+

+ Drag the point on the left and watch where it lands on the right. Switch the + function. Then turn on the grid, and the formula stops being a formula — + it becomes a machine moving the plane. +

+ +
+
+
+ input plane drag me + +
+
inputz =
+
+
+
+
function
+
+ + + + + + +
+
+
+
show
+
+ + + +
+
+
+
identity → function
+ +
zf(z)
+
+ +
+
+
+ output plane + +
+
outputf(z) =
+
+
+

+ The identity → function slider doesn't jump straight to + f(z); it pulls the plane there continuously, so you can watch + the motion instead of just the before and after. (That in-between is a teaching + aid, not the real map.) +

+ +

+ Walk down the list and the moves get bolder. f(z) = z + leaves every point exactly where it was — dull, but it's the baseline. + f(z) = 2z doubles every distance from the + centre: pure scale. f(z) = iz turns + everything a quarter-turn: multiplication can mean rotation. Put those + two together and you get a scale-and-spin in one step — which is exactly + the move the spiral will need. +

+

+ Then the plane starts to bend. f(z) = z2 + curves the whole grid, yet—look closely—the little squares stay + nearly square. The last two are the pair this whole page turns on: + exp rolls the plane around the origin, so upright lines become + rings; and log is its undo, unrolling those rings back into + upright lines. Hold on to that. The tententoon is built from precisely those + two, with a scale-and-spin wedged between them. +

+ +

The function we need

+

+ Now we can state the job. We have a Droste image: zoom in by the right amount + and the same picture appears again. We want a single function where moving + around the centre also carries us one Droste step inward + — turning and zooming folded into the same motion. +

+

+ From the playground we already have the parts. The detour is three moves: + log to unroll the rings into a flat repeating strip, a + scale-and-spin to lean that strip until its copies line up + diagonally, and exp to roll the strip back into rings. The + three panels below are those moves, wired together — move any control and + the whole row answers at once. +

+ +
+
+
in the original
+
shrink S = 2.1
+
+ source + +
+
+ + + + + + +
+
+
+
1 we log it
+
ζ = log(z − c)  ·  log S = 0.75
+
+ flat strip drag me + + ↔ zoom  ↕ turn +
+
+ + + + + + +
+
+
+
2 we bend it
+
ζαζα = 1 − 0.12i
+
+ leaned over + +
+
+ + +
+
+
+ + + + + + +
+
+
+
3 we exponentiate it
+
w = c + (z − c)α
+
+ rolled up + +
+
+ +
+
+ + + + + + +
+
+
+
+ drag the flat strip: ↔ zooms everything, ↕ turns it + +
+ +

+ The photo's own nesting is gentle, so it closes at only about 7°. + The ring and grid patterns repeat at every scale, so they + close at the full ~25° — almost exactly the 26° Escher used. The + source choice under each panel is shared across all three. +

+ +

1 · We log it

+

+ Start with the turning. A ring around the centre has no end: travel round it, + return to your mark, set off again, as many laps as you please. Lay that trip + out straight and it's a line on which one lap repeats, then an identical lap, + then another. Going around always brings you home — so flattened, the + picture is periodic: shift it by one lap and nothing has changed. +

+ +
+
+ + + + + + + + + + one trip around + + unroll + + + + + + + + + + + + + one lap + + the same point returns every lap + +
+
+ A ring has no end, so unrolling it gives a line that repeats: the same point + comes back every lap. That is the periodic strip in the first panel, read + along its short way. +
+
+ +

+ Going inward usually breaks the spell — zoom toward the middle of a photo + and you meet new detail forever, never the same view twice. The Droste is the + exception, and we built it on purpose: one frame inward is an exact copy of the + whole, so inward repeats as well. Flatten both directions and the picture turns + into a plain grid of identical tiles. (The flattening trades a point's distance + from the centre for the number of shrink-steps that reach it, so repeated + multiplying becomes repeated adding. Its name is the logarithm.) +

+

+ This is the quiet fact the whole spiral rests on: in that grid, a step + of exactly one tile lands you on a region identical to where you started. + Drag the flat strip and feel it — a sideways slide zooms, an up-and-down + slide turns, and after one full tile everything is precisely as it was. +

+ +

2 · We bend it

+

+ Bending a spiral by hand is hopeless; leaning a flat grid is easy. Push the + lean slider on the middle panel and the strip tilts. Most + angles drag the tiles out of step, but at one the rows click back into + register — every tile still meets its neighbours, no gaps, no overlaps. + That angle is the one marked closed ✓; snap + lands on it exactly. +

+

+ Here's why any lean can work. The grid repeats one step over and one + step up, so it also repeats along a staircase built from those two steps. Lean + the strip along such a staircase and it still lies on top of itself. Only that + family of angles keeps the tiles aligned, and the shallowest of them is the one + that will close the spiral. +

+ +

3 · We exponentiate it

+

+ One move remains: roll the leaned strip back up — the exact reverse of + flattening it. Press roll it up on the third panel and the + strip winds around the centre; the lean becomes a turn, and the grid of tiles + closes into the spiral. Because we only ever leaned a pattern that repeats, the + spiral meets itself with no seam — follow any line inward and it returns + to where it began. That is the tententoon. +

+

+ Rolling up is the undo of logging: where one counted shrink-steps to flatten + the picture, the other grows them back. Both infinities live at its two ends + — leave the strip upright and it rolls into the straight Droste fall; lean + it first and it rolls into Escher's spiral. Same picture, same roll — only + the lean is different. +

+ +

Why the picture still looks like a picture

+

+ A careless warp wrecks an image: squares shear into parallelograms, faces + smear, straight edges buckle. The overall shape might land where you wanted, + but up close the picture stops being readable. Escher needed the opposite + — an enormous global bend in which every small neighbourhood + stays almost untouched. +

+

+ This is the real work the playground's z2, exp, + and log were doing. Near any single point, each of them acts + like nothing more than a scale plus a rotation — not a squash, not a + shear. So the whole plane can twist violently around itself while the little + squares stay square. Turn the grid back on and watch one tile through the + bend: it moves and turns and shrinks, but it keeps its shape. That is why this + family of functions is the right tool for warping a photograph, and it is the + quiet reason the spiral still reads as Escher's gallery rather than a smear. +

+ +

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. +

+

+ With the exact map written down, the blank centre is no longer a place a hand + has to reach: it can be computed. So they did — continuing the spiral + inward, far past any pen, and closing the white hole at last. +

+ +

See it move

+

+ If you'd like the whole argument animated, Grant Sanderson + (of 3Blue1Brown) made a tour of the de Smit–Lenstra paper in + 2026 — the very log(Escher) step you just played with, and more. +

+ + +

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/3b1b-print-gallery.jpg b/public/3b1b-print-gallery.jpg new file mode 100644 index 0000000..d1b2d71 Binary files /dev/null and b/public/3b1b-print-gallery.jpg differ diff --git a/public/pre-droste-nevit.jpg b/public/pre-droste-nevit.jpg new file mode 100644 index 0000000..f81c265 Binary files /dev/null and b/public/pre-droste-nevit.jpg differ 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..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 } });