diff --git a/shaping/complex-playground-shaping.md b/shaping/complex-playground-shaping.md new file mode 100644 index 0000000..a04e07a --- /dev/null +++ b/shaping/complex-playground-shaping.md @@ -0,0 +1,361 @@ +--- +shaping: true +--- + +# Complex Playground — Shaping + +## Source + +> Create a new "complex playground" page. You should be able to epxeriment with a +> few complex transformations on any image in your gallery or the sample image or +> grid/circles patterna. + +> Make it like a geogebra graph, with ui to change values. Keep the hand-scrolling: +> it should influence the formula with an addition of a complex number. It needs to +> be fun to explore. Include some ready made formulas that could be cool. + +--- + +## Problem + +The app maps images through exactly one complex transformation — the Droste/Escher +log-polar spiral — and every control is wired to that one map. There is no place to +play with complex-plane functions in general: to swap in `z²`, `1/z`, a Möbius map, +or `exp(z)`, tweak their parameters, and watch a photo (or the test patterns) warp in +real time. The existing `pipeline` view is the closest thing, but it's locked to the +Droste pipeline, its "pan" is a log-space `(u, v)` shift (not a complex `+c`), and it +shows no editable formula. + +## Outcome + +A dedicated, GeoGebra-flavoured playground where you: + +- Load any image the app already knows (a gallery picture, the bundled sample, or the + generated grid/polar patterns) onto a complex plane. +- Pick a "cool" complex function from a preset shelf and watch the image transform. +- Change the function's values with live on-screen controls and see it update instantly. +- Keep the familiar hand-drag, now repurposed: dragging adds a complex constant `c` to + the active formula — the displayed formula and the image both respond as you scroll. +- Have fun: it's immediate, smooth, and the presets are worth a "whoa". + +--- + +## Decisions (locked 2026-05-30) + +| # | Decision | Choice | +|---|----------|--------| +| D1 | Formula model | **Shape A — curated presets.** No free-form typing (R8 → Out; B/C dropped). | +| D2 | Pan `c` composition | **Toggle both** — switch between domain `f(z+c)` and output `f(z)+c`. Default: domain. | +| D3 | Mount | **New `ViewMode 'playground'`** in the existing shell; reuse TopBar / Gallery / DropZone (sample + patterns) / `doc.image`. | + +Defaulted (vetoable) — see Detail A: +- **Origin** fixed at image centre for v1; draggable is a stretch. +- **Controls** are sliders + number fields for v1; draggable on-canvas points (zeros/poles) are a stretch. +- **Fill** offers tile (default) · clamp · mirror; the Droste self-similar fold is an optional bonus mode. +- **AA** via per-preset analytic `|f'(z)|` → mip LOD; internal render resolution capped during drag. + +--- + +## Requirements (R) + +| ID | Requirement | Status | +|----|-------------|--------| +| R0 | A dedicated playground to apply a chosen complex function `f(z)` to an image and explore the result live | Core goal | +| R1 | Works on any image the app can already provide — a gallery picture, the bundled sample, or the generated grid/polar test patterns | Must-have | +| R2 | GeoGebra-style live controls: each function exposes its parameters as on-screen controls (sliders / number fields, ideally draggable points); changing a value re-renders immediately; one action resets to defaults | Must-have | +| R3 | 🟡 The existing hand-drag is kept but repurposed: dragging feeds a complex constant `c` into the active formula. A **toggle** picks whether `c` attaches to the **domain** `f(z+c)` or the **output** `f(z)+c`. Image + displayed formula update live as you drag | Must-have | +| R4 | The active formula is shown in readable notation and updates as parameters / pan change | Must-have | +| R5 | A one-click shelf of ready-made "cool" complex functions (e.g. `z²`, `1/z`, Möbius, Joukowski, `exp`, `log`, Escher power `zᵃ`, `zⁿ`) | Must-have | +| R6 | Fun & immediate: rendering stays smooth and real-time during drag and control changes | Must-have | +| R7 | A sensible pixel↔complex coordinate frame (origin + scale, adjustable); samples that fall outside the image are filled by a tiling/clamp/mirror mode rather than black voids | Must-have | +| R8 | 🟡 ~~Enter an arbitrary complex formula~~ — **Out**: curated presets only (Shape A selected) | Out | + +**Notes:** +- Rendering convention (matches the repo's existing maps): for each output pixel at + complex coord `z`, we **sample the source at `f(z)`** (inverse map). So the on-screen + warp is what you'd expect from "apply `f`" and every output pixel is covered. + +--- + +## Shapes + +### CURRENT: the `pipeline` view (baseline) + +The existing 4-panel pipeline view. Fixed log → rotated-log → Escher stages computed +from the Droste nest `doc.rect`. "Pan" is `panU/panV`, a shift in log space. No choice +of function, no formula display, no preset shelf. + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| C1 | Fixed Droste pipeline (`pipeline-gl` shader / `pipeline-panels.ts`) driven by `doc.rect` + `drosteGeometry` | | +| C2 | Log-space pan `panU/panV` + angle override (`pipeline-experiments.svelte.ts`) | | +| C3 | Reuses `doc.image` → already gets gallery / sample / pattern sources | | + +--- + +### A: Curated preset playground (uber-shader + sliders + drag-`c`) + +A new playground view in the existing shell. A fixed, hand-written library of +parameterised complex functions; one GLSL branch each (the proven `pipeline-gl` +`u_mode` pattern), a CPU mirror for the fallback, auto-generated controls per preset, +a live-rendered formula, and drag → `+c`. No free-text formula parsing. + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| **A1** | **Playground view** — new `ViewMode` `'playground'` in the existing shell; reuses TopBar / DropZone (sample + pattern buttons) / Gallery for image sources; swaps the bottom strip for a playground control panel | | +| **A2** | **Playground state** — light `$state`: complex frame (origin px, scale px/unit), active preset id, per-preset param values, pan `c`. Independent of the Droste `doc.rect`; shares only `doc.image` | | +| **A3** | **Complex-op GLSL library** — `cadd/cmul/cdiv/cexp/clog/cpow/csin/…` + an uber fragment shader: one branch per preset computes `f(z)`, shared sampling tail with selectable fill (tile / clamp / mirror) | | +| **A4** | **CPU mirror** — the same presets in JS for the no-GPU fallback (mirrors how `pipeline-panels.ts` mirrors the shader) | | +| **A5** | **Auto-generated controls** — each preset declares a param schema → sliders / number fields rendered from it; reset-to-defaults; (stretch) draggable on-canvas points bound to zeros/poles/centre | ⚠ (draggable points only) | +| **A6** | **Drag → `c`** — pointer drag/scroll writes pan `c` into A2; formula display + render read params + `c` and update live | | +| **A7** | **Preset library data** — `{id, label, latex/notation template, param defs, defaults, f'(z) for AA}`: `z+c`, `z²`, `1/z`, Möbius `(az+b)/(pz+q)`, Joukowski `½(z+1/z)`, `exp`, `log`, Escher `zᵃ` (`a=1−ik`), `zⁿ` | | +| **A8** | **Conformal overlay** [nice-to-have] — faint complex grid / unit circle / singularity markers so the structure reads GeoGebra-style | ⚠ | + +--- + +### B: Free-form formula compiler + +GeoGebra-true: a text field where you type `z^2 + c`, parsed and compiled to GLSL/JS on +the fly; sliders auto-appear for free variables. Maximum expressiveness, much larger +build, and unproven in this repo. + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| B1 | Same view + state shell as A1 / A2 | | +| B2 | Complex-expression lexer + parser (`z`, `i`, `+ − × ÷`, `^`, parens, named params, `exp/log/sin/…`) | ⚠ | +| B3 | AST → GLSL codegen (and JS for the CPU path) over the complex-op library | ⚠ | +| B4 | Compile-on-edit: GL program relink, params kept as **uniforms** (so drag/slider stay smooth), error surfacing UI | ⚠ | +| B5 | Auto-detect free variables → generate sliders | ⚠ | +| B6 | Pan `c` injected as a bound variable usable in the expression | | +| B7 | Presets become editable starter expressions in the field | | + +--- + +### C: Hybrid (A now + B later) + +Preset shelf + sliders + drag-`c` + live formula (all of A) as the on-ramp, **plus** an +editable formula field backed by B's parser/codegen for power users. Most capable; +A's parts are proven, B's parts remain flagged until Spike S1. + +Composition: **C = A1–A8 + B2–B5** (the editable field replaces A's read-only formula +display once the compiler exists). + +--- + +## Fit Check + +| Req | Requirement | Status | CURRENT | A | B | C | +|-----|-------------|--------|:-------:|:-:|:-:|:-:| +| R0 | Dedicated playground applying a chosen `f(z)` to an image, explored live | Core goal | ❌ | ✅ | ❌ | ✅ | +| R1 | Works on any app image source (gallery / sample / grid+polar patterns) | Must-have | ✅ | ✅ | ✅ | ✅ | +| R2 | GeoGebra-style live controls per function + reset | Must-have | ❌ | ✅ | ❌ | ✅ | +| R3 | Hand-drag feeds a complex `+c` into the active formula; both update live | Must-have | ❌ | ✅ | ❌ | ✅ | +| R4 | Active formula shown in readable notation, updates live | Must-have | ❌ | ✅ | ❌ | ✅ | +| R5 | One-click shelf of ready-made cool functions | Must-have | ❌ | ✅ | ❌ | ✅ | +| R6 | Smooth, real-time during drag and control changes | Must-have | ✅ | ✅ | ❌ | ✅ | +| R7 | Adjustable complex coord frame + non-black fill (tile/clamp/mirror) | Must-have | ❌ | ✅ | ✅ | ✅ | +| R8 | 🟡 Enter an arbitrary complex formula (now **Out** — presets only) | Out | ❌ | ❌ | ❌ | ❌ | + +**Selected: Shape A** (D1). B and C are not pursued; kept below for the audit trail. + +**Notes:** +- **CURRENT** fails R0/R2/R3/R4/R5: it runs only the fixed Droste map; "pan" is a + log-space `(u,v)` shift, not a complex `+c`; there is no formula display or preset shelf. +- **A** fails only R8 — by design it is a curated shelf, no free-text entry. +- **B** fails R0/R2/R3/R4/R5/R6 in the fit check because each of those leans on the + parser/codegen/compile mechanisms (B2–B5), which are **flagged unknowns** (⚠) until + Spike S1 resolves them. A flagged mechanism can't claim ✅. +- **C** matches A on every provable requirement (its A-half carries R0–R7). Its only + delta over A is the editable field — the same flagged unknown as B — so C also can't + claim R8 yet. **Today, A and C are identical except in ambition/sequencing.** +- No shape satisfies **R8** yet. It is gated by Spike S1; until then it stays a fork, + not a committed requirement. + +--- + +## Spike S1 — complex-expression compiler (gates R8 / Shapes B & C) + +> 🟡 **Not pursued.** D1 selected presets-only (Shape A); R8 is Out. Kept for the +> audit trail in case free-form entry is revisited. + +### Context +B and C promise free-form formula entry. The risk is not "can we parse" but "can we +recompile without killing R6 (smoothness) and still anti-alias (need `f'`)". + +### Questions +| # | Question | +|---|----------| +| **S1-Q1** | What grammar covers the cool cases (`z`, `i`, params, `+−×÷ ^`, `exp/log/sin/conj/…`) with the least surface area? | +| **S1-Q2** | How do we keep params as **uniforms** so editing values never recompiles — only editing the formula *structure* relinks the GL program? | +| **S1-Q3** | How do we get `|f'(z)|` for footprint AA from an arbitrary AST (symbolic diff of the node set, or finite differences in-shader)? | +| **S1-Q4** | What's the error-surfacing UX for a half-typed / invalid expression (last-good program + inline error)? | + +### Acceptance +We can describe the grammar, the compile-vs-uniform split, the derivative strategy, and +the error UX — enough to judge whether B's delta over A is worth it. + +--- + +## Component decisions + +| # | Decision | Resolution | +|---|----------|------------| +| 1 | Pan composition | **Toggle** domain `f(z+c)` / output `f(z)+c` (D2). Default domain. | +| 2 | Mount | **New `ViewMode 'playground'`** in the shell (D3). | +| 3 | Coordinate origin | Fixed at image centre for v1; **draggable = stretch (V5)**. | +| 4 | Fill mode | Selector **tile (default) · clamp · mirror**; Droste fold = optional bonus. | +| 5 | Controls | **Sliders + number fields** for v1; **draggable points = stretch (V5)**. | +| 6 | Preset set | See **Preset library** below — confirm/trim. | +| 7 | Render res / AA | Cap internal res during drag; AA via per-preset analytic `|f'(z)|` → mip LOD. | + +--- + +## Detail A — Affordances & Wiring + +### UI Affordances + +| Affordance | Place | Wires Out | +|------------|-------|-----------| +| Playground tool-rail button (5th) | Tool rail | sets `ui.view = 'playground'` | +| Canvas (image warped by `f`) | Stage | reads renderer output | +| Drag gesture on empty canvas | Stage | writes `playground.c` (pan constant) | +| Preset shelf (chips) | Controls | sets `playground.preset` + loads its defaults | +| Auto param controls (sliders / number fields from schema) | Controls | write `playground.params` | +| Pan-mode toggle (domain ⇄ output) | Controls | sets `playground.panMode` | +| Fill selector (tile / clamp / mirror) | Controls | sets `playground.fill` | +| Zoom control (wheel + slider) | Controls / Stage | sets `playground.scale` | +| Reset | Controls | params + `c` ← preset defaults | +| Live formula display | Controls | reads preset + params + `c` → notation | +| Sample / pattern quick-load | Controls | `setImage(…)` | +| Empty drop zone (reused) | Stage | `setImage(…)` | +| (stretch) draggable param points (zero / pole / origin) | Stage | write specific params | +| (stretch) conformal overlay (grid · unit circle · singularities) | Stage | reads frame + preset | + +### Non-UI Affordances + +| Affordance | Place | Wires Out | +|------------|-------|-----------| +| `playground` `$state` rune (preset · params · c · panMode · scale · fill) | Playground state | drives renderer + formula | +| `ViewMode 'playground'` added to `state.svelte.ts` | Shared shell | gates mount / visibility | +| `doc.image` (reused) | Shared shell | source texture | +| `presets.ts` library (id · label · notation · param defs · defaults · `f` · `f'`) | Preset library | feeds shelf, controls, shader, formula | +| Formula-notation builder | Preset library | params + c → readable string | +| `playground-gl.ts` + uber fragment shader (one branch per preset, shared fill + LOD tail) | Renderer | uniforms → draw | +| `playground-cpu.ts` mirror (JS fallback) | Renderer | per-pixel `f(z)` → sample | + +### Wiring + +```mermaid +flowchart TD + subgraph rail[Tool rail] + PB[Playground button] + end + subgraph shell[Shared shell] + VM["ui.view = 'playground'"] + IMG[doc.image] + DZ[DropZone empty state] + GAL[Gallery / sample / patterns] + end + subgraph st[Playground state] + ST["playground rune:\npreset · params · c · panMode · scale · fill"] + end + subgraph ctrl[Playground controls] + SHELF[Preset shelf] + PARAMS[Auto param controls] + TOG[Pan-mode toggle] + FILL[Fill selector] + ZOOM[Zoom] + RESET[Reset] + FX[Live formula] + QL[Sample/pattern quick-load] + end + subgraph stg[Playground stage] + CV[Canvas] + DRAG[Drag gesture] + PTS["(stretch) draggable points"] + end + subgraph lib[Preset library] + PRESETS["presets.ts: f · f' · params · notation"] + end + subgraph rndr[Renderer] + GL[playground-gl + uber shader] + CPU[playground-cpu mirror] + end + + PB --> VM + VM --> stg + VM --> ctrl + GAL --> IMG + DZ --> IMG + QL --> IMG + SHELF --> ST + SHELF --> PRESETS + PRESETS --> PARAMS + PARAMS --> ST + TOG --> ST + FILL --> ST + ZOOM --> ST + RESET --> ST + DRAG --> ST + PTS --> ST + ST --> FX + PRESETS --> FX + ST --> GL + IMG --> GL + PRESETS --> GL + GL --> CV + ST --> CPU + CPU --> CV +``` + +### Preset library (A7) — proposed shelf + +For each output pixel at complex `z`, sample the source at `f(z)`. Pan `c` is applied per +the toggle (domain `f(z+c)` or output `f(z)+c`). `f'` is for footprint anti-aliasing. + +| id | Label | `f(z)` | Params (default) | `f'(z)` | +|----|-------|--------|------------------|---------| +| `identity` | Identity | `z` | — | `1` | +| `square` | Square | `z²` | — | `2z` | +| `power` | Power n | `zⁿ` | `n = 2` (real, 0.2–5) | `n·zⁿ⁻¹` | +| `recip` | Reciprocal | `1/z` | — | `−1/z²` | +| `mobius` | Möbius | `k·(z−z₀)/(z−z∞)` | `k=1, z₀=−0.5, z∞=0.5` (complex) | `k·(z∞−z₀)/(z−z∞)²` | +| `joukowski` | Joukowski | `½(z + 1/z)` | — | `½(1 − 1/z²)` | +| `exp` | Exponential | `exp(z)` | — | `exp(z)` | +| `log` | Logarithm | `log z` (principal) | — | `1/z` | +| `escher` | Escher power | `zᵃ, a = 1 − ik` | `k = 0.30` (0–1) | `a·zᵃ⁻¹` | +| `sine` | Sine | `sin(z)` | — | `cos(z)` | + +The **Möbius** zero `z₀` and pole `z∞` are the natural draggable points (V5); **Escher +power** ties the playground back to the app's core map. + +--- + +## Slices — IMPLEMENTED (2026-05-30, branch `feat/complex-playground`) + +All slices landed in one pass; verified live via `/browse` (no console errors) +and 18 unit tests in `tests/render/playground-presets.test.ts`. + +| Slice | Scope | Status | +|-------|-------|--------| +| **V1 — Skeleton** | `'playground'` ViewMode + tool-rail button + `PlaygroundStage` + `playground` state + GPU uber-shader + drag→`c` + live formula | ✅ done | +| **V2 — Shelf + controls** | `presets.ts` (10 presets) + preset chips + schema-driven sliders/number fields + reset | ✅ done | +| **V3 — Frame & feel** | Pan-mode toggle (domain/output) + fill selector (tile/clamp/mirror) + wheel + slider zoom + sample/pattern quick-load | ✅ done | +| **V4 — Robust** | CPU mirror fallback (`cpu.ts`, capabilities tiering) + capped internal res + per-preset analytic `f'` AA via mip LOD | ✅ done | +| **V5 — GeoGebra polish** | Draggable on-canvas handles (Möbius zero/pole) + origin cross + unit circle overlay | ✅ done (conformal grid not added) | + +### Files + +| File | Role | +|------|------| +| `src/lib/render/playground/presets.ts` | Pure preset library: complex ops, `f`/`f'`, uniforms, `formulaText` | +| `src/lib/render/playground/shader.frag.glsl` | Uber fragment shader (one branch per `mode`) | +| `src/lib/render/playground/gl.ts` | `PlaygroundGLRenderer` (WebGL2 + per-fill wrap) | +| `src/lib/render/playground/cpu.ts` | CPU fallback mirror | +| `src/lib/ui1/playground.svelte.ts` | `playground` state rune + actions | +| `src/components/ui1/PlaygroundStage.svelte` | Canvas, render loop, drag→`c`, wheel-zoom, overlay | +| `src/components/ui1/PlaygroundControls.svelte` | Preset shelf, param controls, toggle, fill, zoom, reset, quick-load | +| `tests/render/playground-presets.test.ts` | Preset math + formula-text unit tests | + +Wired into `state.svelte.ts` (ViewMode), `ToolRail.svelte` (5th button), +`UiVariant1.svelte` (mount + show/hide CSS), `icons.ts` (`viewPlayground`). diff --git a/src/components/UiVariant1.svelte b/src/components/UiVariant1.svelte index 8d6914d..75b1ffd 100644 --- a/src/components/UiVariant1.svelte +++ b/src/components/UiVariant1.svelte @@ -21,6 +21,8 @@ import DrosteStage from './ui1/DrosteStage.svelte'; import PipelinePanel from './ui1/PipelinePanel.svelte'; import PipelineControls from './ui1/PipelineControls.svelte'; + import PlaygroundStage from './ui1/PlaygroundStage.svelte'; + import PlaygroundControls from './ui1/PlaygroundControls.svelte'; import Timeline from './ui1/Timeline.svelte'; import DropZone from './ui1/DropZone.svelte'; import { @@ -142,9 +144,16 @@ + + {#if ui.view === 'pipeline'} + {:else if ui.view === 'playground'} + {:else} {/if} @@ -210,6 +219,8 @@ .stages :global(.droste) { display: none; } /* Pipeline's three derived panels are hidden in every non-pipeline view. */ .stages :global(.ppanel) { display: none; } + /* The complex playground stage is hidden in every non-playground view. */ + .stages :global(.playground-stage) { display: none; } .stages.view-preview :global(.stage) { display: none; } .stages.view-droste :global(.stage), .stages.view-droste :global(.preview) { display: none; } @@ -226,6 +237,11 @@ .stages.view-pipeline :global(.droste) { display: none; } .stages.view-pipeline :global(.stage) { display: flex; } .stages.view-pipeline :global(.ppanel) { display: flex; } + /* Playground: single full-bleed stage; hide the editor/spiral/droste. */ + .stages.view-playground :global(.stage), + .stages.view-playground :global(.preview), + .stages.view-playground :global(.droste) { display: none; } + .stages.view-playground :global(.playground-stage) { display: flex; } /* Narrow viewports: stack the four panels in a single column. */ @media (max-width: 640px) { .stages.view-pipeline { diff --git a/src/components/ui1/PlaygroundControls.svelte b/src/components/ui1/PlaygroundControls.svelte new file mode 100644 index 0000000..7c90ad7 --- /dev/null +++ b/src/components/ui1/PlaygroundControls.svelte @@ -0,0 +1,224 @@ + + +
+ complex playground + + +
+ {#each PRESETS as p} + + {/each} +
+ + + + + {#if preset && preset.params.length > 0} + {#each preset.params as def} + {#if def.kind === 'real'} + + {:else} + + {def.label}{def.draggable ? ' ✥' : ''} + setCplx(def.id, 're', +(e.currentTarget as HTMLInputElement).value)} + /> + + + setCplx(def.id, 'im', +(e.currentTarget as HTMLInputElement).value)} + /> + i + + {/if} + {/each} + + {/if} + + + drag adds c: +
+ + +
+ + + + + fill +
+ {#each FILLS as f} + + {/each} +
+ + + + + + + + + + + + image + + + +
+ + diff --git a/src/components/ui1/PlaygroundStage.svelte b/src/components/ui1/PlaygroundStage.svelte new file mode 100644 index 0000000..ba80a37 --- /dev/null +++ b/src/components/ui1/PlaygroundStage.svelte @@ -0,0 +1,351 @@ + + +
+ +
{ onMove(e); onHandleMove(e); }} + onpointerup={(e) => { onUp(); onHandleUp(e); }} + onpointercancel={(e) => { onUp(); onHandleUp(e); }} + onwheel={onWheel} + > + {#if doc.image && fit} + + + + +
{formula}
+
{playground.zoom.toFixed(2)}×
+ {/if} +
+
+ + diff --git a/src/components/ui1/ToolRail.svelte b/src/components/ui1/ToolRail.svelte index 97ee822..d761b65 100644 --- a/src/components/ui1/ToolRail.svelte +++ b/src/components/ui1/ToolRail.svelte @@ -52,6 +52,15 @@ > +
diff --git a/src/lib/render/playground/cpu.ts b/src/lib/render/playground/cpu.ts new file mode 100644 index 0000000..0553648 --- /dev/null +++ b/src/lib/render/playground/cpu.ts @@ -0,0 +1,92 @@ +/** + * CPU fallback for the complex playground — the no-WebGL2 path. Mirrors + * shader.frag.glsl using the presets' JS `f` (kept in lockstep). No mipmaps, + * so this is a plain bilinear sample at a capped resolution; correctness over + * polish, since it only runs where WebGL2 is unavailable. + */ + +import { cadd, cx, type Complex, type FillMode, type PanMode, type Params, type Preset } from './presets'; + +export type PlaygroundCpuInput = { + pixels: ImageData; + preset: Preset; + params: Params; + W: number; + H: number; + imgAspect: number; + zoom: number; + c: Complex; + panMode: PanMode; + fill: FillMode; +}; + +/** Wrap a normalized coord into [0,1) per fill mode. */ +function wrap1(t: number, fill: FillMode): number { + if (fill === 'tile') return t - Math.floor(t); + if (fill === 'clamp') return t < 0 ? 0 : t > 1 ? 1 : t; + const m = Math.abs(t) % 2; // mirror + return m > 1 ? 2 - m : m; +} + +function sampleBilinear( + src: ImageData, + u: number, + v: number, + out: [number, number, number, number] +): void { + const W = src.width; + const H = src.height; + const x = u * (W - 1); + const y = v * (H - 1); + const x0 = Math.max(0, Math.min(W - 1, Math.floor(x))); + const y0 = Math.max(0, Math.min(H - 1, Math.floor(y))); + const x1 = Math.min(W - 1, x0 + 1); + const y1 = Math.min(H - 1, y0 + 1); + const fx = x - Math.floor(x); + const fy = y - Math.floor(y); + const d = src.data; + const i00 = (y0 * W + x0) * 4; + const i10 = (y0 * W + x1) * 4; + const i01 = (y1 * W + x0) * 4; + const i11 = (y1 * W + x1) * 4; + const w00 = (1 - fx) * (1 - fy); + const w10 = fx * (1 - fy); + const w01 = (1 - fx) * fy; + const w11 = fx * fy; + for (let k = 0; k < 4; k++) { + out[k] = d[i00 + k] * w00 + d[i10 + k] * w10 + d[i01 + k] * w01 + d[i11 + k] * w11; + } +} + +export function renderPlaygroundCpu(input: PlaygroundCpuInput): ImageData { + const { pixels, preset, params, W, H, imgAspect, zoom, c, panMode, fill } = input; + const out = new ImageData(W, H); + const halfX = Math.max(imgAspect, 1); + const halfY = Math.max(1 / imgAspect, 1); + const rgba: [number, number, number, number] = [0, 0, 0, 0]; + + for (let py = 0; py < H; py++) { + const nyUp = 1 - (py + 0.5) / H; + const zim = (2 * nyUp - 1) * halfY / zoom; + for (let px = 0; px < W; px++) { + const nx = (px + 0.5) / W; + const zre = (2 * nx - 1) * halfX / zoom; + let z: Complex = { re: zre, im: zim }; + if (panMode === 'domain') z = cadd(z, c); + let w = preset.f(z, params); + if (panMode === 'output') w = cadd(w, c); + const u = wrap1(0.5 + 0.5 * w.re / halfX, fill); + const v = wrap1(0.5 - 0.5 * w.im / halfY, fill); + sampleBilinear(pixels, u, v, rgba); + const idx = (py * W + px) * 4; + out.data[idx] = rgba[0]; + out.data[idx + 1] = rgba[1]; + out.data[idx + 2] = rgba[2]; + out.data[idx + 3] = rgba[3]; + } + } + return out; +} + +// re-export so the stage can build a zero pan without importing presets twice +export const ZERO_C = cx(0, 0); diff --git a/src/lib/render/playground/gl.ts b/src/lib/render/playground/gl.ts new file mode 100644 index 0000000..f313598 --- /dev/null +++ b/src/lib/render/playground/gl.ts @@ -0,0 +1,148 @@ +/** + * WebGL2 renderer for the complex playground. One full-screen quad + the + * uber fragment shader (shader.frag.glsl), one branch per preset. Mirrors + * PipelinePanelGLRenderer: texture cached by identity, main-thread only + * (each frame is a handful of complex ops per pixel — trivially 60fps). + * + * Fill mode maps straight to the sampler wrap: tile → REPEAT, mirror → + * MIRRORED_REPEAT, clamp → CLAMP_TO_EDGE, so out-of-image samples never + * show black. + */ + +import * as twgl from 'twgl.js'; +import vertSrc from '../escher-zoom/shader.vert.glsl?raw'; +import fragSrc from './shader.frag.glsl?raw'; +import type { FillMode, PresetUniforms } from './presets'; + +export type PlaygroundGLInput = { + pixels: ImageData; + mode: number; + /** Canvas pixel dims. */ + W: number; + H: number; + imgAspect: number; + zoom: number; + c: [number, number]; + /** 0 = domain f(z+c), 1 = output f(z)+c. */ + panMode: number; + uniforms: PresetUniforms; + fill: FillMode; +}; + +export class PlaygroundGLRenderer { + private canvas: HTMLCanvasElement | OffscreenCanvas | null = null; + private gl: WebGL2RenderingContext | null = null; + private programInfo: twgl.ProgramInfo | null = null; + private quad: twgl.BufferInfo | null = null; + private texture: WebGLTexture | null = null; + private texPixels: ImageData | null = null; + private maxAniso = 1; + + init(canvas: HTMLCanvasElement | OffscreenCanvas): void { + this.canvas = canvas; + const gl = canvas.getContext('webgl2', { + antialias: false, + premultipliedAlpha: false + }) as WebGL2RenderingContext | null; + if (!gl) throw new Error('webgl2 context unavailable'); + this.gl = gl; + + const aniso = gl.getExtension('EXT_texture_filter_anisotropic'); + if (aniso) { + this.maxAniso = Math.min(4, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT) as number); + } + + this.programInfo = twgl.createProgramInfo(gl, [vertSrc, fragSrc]); + this.quad = twgl.createBufferInfoFromArrays(gl, { + a_pos: { numComponents: 2, data: [-1, -1, 1, -1, -1, 1, 1, 1] } + }); + this.quad.indices = undefined; + } + + render(input: PlaygroundGLInput): void { + const gl = this.gl; + const canvas = this.canvas; + const prog = this.programInfo; + const quad = this.quad; + if (!gl || !canvas || !prog || !quad) return; + + const { pixels, W, H } = input; + if (canvas.width !== W) canvas.width = W; + if (canvas.height !== H) canvas.height = H; + + this.uploadTextureIfChanged(pixels); + this.applyWrap(input.fill); + + gl.viewport(0, 0, W, H); + gl.useProgram(prog.program); + twgl.setBuffersAndAttributes(gl, prog, quad); + twgl.setUniforms(prog, { + u_src: this.texture, + u_canvas: [W, H], + u_mode: input.mode, + u_imgAspect: input.imgAspect, + u_zoom: input.zoom, + u_c: input.c, + u_panMode: input.panMode, + u_pr: input.uniforms.pr, + u_pa: input.uniforms.pa, + u_pb: input.uniforms.pb, + u_pc: input.uniforms.pc, + u_texSize: [pixels.width, pixels.height] + }); + twgl.drawBufferInfo(gl, quad, gl.TRIANGLE_STRIP); + } + + dispose(): void { + const gl = this.gl; + if (gl) { + if (this.texture) gl.deleteTexture(this.texture); + if (this.programInfo) gl.deleteProgram(this.programInfo.program); + if (this.quad?.attribs) { + for (const a of Object.values(this.quad.attribs)) { + if (a.buffer) gl.deleteBuffer(a.buffer); + } + } + } + this.gl = null; + this.canvas = null; + this.programInfo = null; + this.quad = null; + this.texture = null; + this.texPixels = null; + } + + private applyWrap(fill: FillMode): void { + const gl = this.gl!; + if (!this.texture) return; + const wrap = + fill === 'tile' ? gl.REPEAT : fill === 'mirror' ? gl.MIRRORED_REPEAT : gl.CLAMP_TO_EDGE; + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap); + } + + private uploadTextureIfChanged(pixels: ImageData): void { + const gl = this.gl!; + if (pixels === this.texPixels && this.texture) return; + + if (this.texture) gl.deleteTexture(this.texture); + const tex = gl.createTexture(); + if (!tex) throw new Error('gl.createTexture returned null'); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D( + gl.TEXTURE_2D, 0, gl.RGBA, pixels.width, pixels.height, 0, + gl.RGBA, gl.UNSIGNED_BYTE, pixels.data + ); + gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + if (this.maxAniso > 1) { + const aniso = gl.getExtension('EXT_texture_filter_anisotropic'); + if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, this.maxAniso); + } + this.texture = tex; + this.texPixels = pixels; + } +} diff --git a/src/lib/render/playground/presets.ts b/src/lib/render/playground/presets.ts new file mode 100644 index 0000000..241a358 --- /dev/null +++ b/src/lib/render/playground/presets.ts @@ -0,0 +1,316 @@ +/** + * Complex-playground preset library — the single source of truth shared by + * the GPU shader, the CPU fallback, and the live-formula display. + * + * Each preset is a complex function f(z). The playground renders by inverse + * map: for every output pixel at complex coord z, it samples the source image + * at f(z) (so the picture warps the way you'd expect from "apply f"). The pan + * constant `c` is composed per the user's toggle — domain `f(z + c)` or output + * `f(z) + c` — outside this file (see the renderer + state). + * + * `mode` is the integer branch the GLSL shader (shader.frag.glsl) switches on; + * the order here MUST match the shader. `f` / `fp` are the JS mirror used by + * the CPU fallback and kept in lockstep with the shader's math. `fp` (= f'(z)) + * feeds footprint anti-aliasing. + * + * Pure module: no DOM, no Svelte, no twgl — importable from tests and the + * worker-free CPU path alike. + */ + +export type Complex = { re: number; im: number }; + +// --- complex arithmetic (mirrors the GLSL helpers) --------------------- +export const cx = (re: number, im = 0): Complex => ({ re, im }); +export const cadd = (a: Complex, b: Complex): Complex => ({ re: a.re + b.re, im: a.im + b.im }); +export const csub = (a: Complex, b: Complex): Complex => ({ re: a.re - b.re, im: a.im - b.im }); +export const cmul = (a: Complex, b: Complex): Complex => ({ + re: a.re * b.re - a.im * b.im, + im: a.re * b.im + a.im * b.re +}); +export const cdiv = (a: Complex, b: Complex): Complex => { + const d = b.re * b.re + b.im * b.im || 1e-30; + return { re: (a.re * b.re + a.im * b.im) / d, im: (a.im * b.re - a.re * b.im) / d }; +}; +export const cexp = (z: Complex): Complex => { + const e = Math.exp(z.re); + return { re: e * Math.cos(z.im), im: e * Math.sin(z.im) }; +}; +export const clog = (z: Complex): Complex => ({ + re: 0.5 * Math.log(z.re * z.re + z.im * z.im || 1e-30), + im: Math.atan2(z.im, z.re) +}); +export const cpow = (z: Complex, w: Complex): Complex => { + if (z.re * z.re + z.im * z.im < 1e-20) return { re: 0, im: 0 }; + return cexp(cmul(w, clog(z))); +}; +export const cabs = (z: Complex): number => Math.hypot(z.re, z.im); +const csinh = (x: number) => Math.sinh(x); +const ccosh = (x: number) => Math.cosh(x); +export const csin = (z: Complex): Complex => ({ + re: Math.sin(z.re) * ccosh(z.im), + im: Math.cos(z.re) * csinh(z.im) +}); +export const ccos = (z: Complex): Complex => ({ + re: Math.cos(z.re) * ccosh(z.im), + im: -Math.sin(z.re) * csinh(z.im) +}); + +const ONE = cx(1, 0); + +// --- parameter schema -------------------------------------------------- + +export type ParamValue = number | Complex; + +export type RealParam = { + kind: 'real'; + id: string; + label: string; + min: number; + max: number; + step: number; + default: number; +}; +export type ComplexParam = { + kind: 'complex'; + id: string; + label: string; + /** Slider extent for re/im (±range), and the draggable handle's reach. */ + range: number; + default: Complex; + /** Show a draggable handle for this point on the canvas. */ + draggable?: boolean; +}; +export type ParamDef = RealParam | ComplexParam; + +export type Params = Record; + +/** GPU uniform packing: up to 4 real scalars + 3 complex params. */ +export type PresetUniforms = { + pr: [number, number, number, number]; + pa: [number, number]; + pb: [number, number]; + pc: [number, number]; +}; + +export type Preset = { + id: string; + /** Shader branch — MUST match the switch in shader.frag.glsl. */ + mode: number; + label: string; + params: ParamDef[]; + /** f(z) for the CPU path. */ + f: (z: Complex, p: Params) => Complex; + /** f'(z) for footprint anti-aliasing (CPU path). */ + fp: (z: Complex, p: Params) => Complex; + /** Pack params into shader uniforms. */ + uniforms: (p: Params) => PresetUniforms; + /** + * Render f as readable notation in terms of `arg` (the symbol substituted + * for z — "z" normally, "z + c" in domain-pan mode). `compound` is true when + * `arg` is not a bare variable, so the preset can parenthesise it. + */ + expr: (arg: string, p: Params, compound: boolean) => string; +}; + +const r = (p: Params, id: string) => p[id] as number; +const k = (p: Params, id: string) => p[id] as Complex; +const u2 = (c: Complex): [number, number] => [c.re, c.im]; +const NO_U: PresetUniforms = { pr: [0, 0, 0, 0], pa: [0, 0], pb: [0, 0], pc: [0, 0] }; + +// --- formatting helpers (for `expr`) ----------------------------------- + +const fmt = (n: number) => { + const s = Math.abs(n) < 1e-9 ? '0' : n.toFixed(2); + return s.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1'); +}; +/** A complex literal: "0.4", "−0.4i", "0.4 − 0.2i". */ +export function fmtComplex(c: Complex): string { + const re = Math.abs(c.re) > 1e-9; + const im = Math.abs(c.im) > 1e-9; + if (!re && !im) return '0'; + if (re && !im) return fmt(c.re); + const iPart = `${fmt(Math.abs(c.im))}i`; + if (!re) return `${c.im < 0 ? '−' : ''}${iPart}`; + return `${fmt(c.re)} ${c.im < 0 ? '−' : '+'} ${iPart}`; +} +/** "z − 0.4" / "z + 0.4" / "z − (0.4 + 0.2i)" — folds the sign nicely. */ +function sub(arg: string, c: Complex): string { + if (Math.abs(c.re) < 1e-9 && Math.abs(c.im) < 1e-9) return arg; + if (Math.abs(c.im) < 1e-9) return `${arg} ${c.re < 0 ? '+' : '−'} ${fmt(Math.abs(c.re))}`; + return `${arg} − (${fmtComplex(c)})`; +} +const par = (arg: string, compound: boolean) => (compound ? `(${arg})` : arg); + +// --- the shelf --------------------------------------------------------- + +export const PRESETS: Preset[] = [ + { + id: 'identity', + mode: 0, + label: 'Identity', + params: [], + f: (z) => z, + fp: () => ONE, + uniforms: () => NO_U, + expr: (a) => a + }, + { + id: 'square', + mode: 1, + label: 'Square', + params: [], + f: (z) => cmul(z, z), + fp: (z) => cmul(cx(2), z), + uniforms: () => NO_U, + expr: (a, _p, compound) => `${par(a, compound)}²` + }, + { + id: 'power', + mode: 2, + label: 'Power zⁿ', + params: [{ kind: 'real', id: 'n', label: 'n', min: 0.2, max: 6, step: 0.05, default: 3 }], + f: (z, p) => cpow(z, cx(r(p, 'n'))), + fp: (z, p) => cmul(cx(r(p, 'n')), cpow(z, cx(r(p, 'n') - 1))), + uniforms: (p) => ({ ...NO_U, pr: [r(p, 'n'), 0, 0, 0] }), + expr: (a, p, compound) => `${par(a, compound)}^${fmt(r(p, 'n'))}` + }, + { + id: 'recip', + mode: 3, + label: 'Reciprocal', + params: [], + f: (z) => cdiv(ONE, z), + fp: (z) => cdiv(cx(-1), cmul(z, z)), + uniforms: () => NO_U, + expr: (a, _p, compound) => `1 / ${par(a, compound)}` + }, + { + id: 'mobius', + mode: 4, + label: 'Möbius', + params: [ + { kind: 'complex', id: 'k', label: 'k', range: 3, default: cx(1, 0) }, + { kind: 'complex', id: 'z0', label: 'zero z₀', range: 2.5, default: cx(-0.4, 0), draggable: true }, + { kind: 'complex', id: 'zi', label: 'pole z∞', range: 2.5, default: cx(0.4, 0), draggable: true } + ], + f: (z, p) => cmul(k(p, 'k'), cdiv(csub(z, k(p, 'z0')), csub(z, k(p, 'zi')))), + fp: (z, p) => { + // d/dz [k(z−z0)/(z−z∞)] = k(z0−z∞)/(z−z∞)² + const d = csub(z, k(p, 'zi')); + return cmul(k(p, 'k'), cdiv(csub(k(p, 'z0'), k(p, 'zi')), cmul(d, d))); + }, + uniforms: (p) => ({ pr: [0, 0, 0, 0], pa: u2(k(p, 'k')), pb: u2(k(p, 'z0')), pc: u2(k(p, 'zi')) }), + expr: (a, p, compound) => { + const arg = par(a, compound || a !== 'z'); + const kv = k(p, 'k'); + const km = Math.abs(kv.re - 1) < 1e-9 && Math.abs(kv.im) < 1e-9 ? '' : `(${fmtComplex(kv)})·`; + return `${km}(${sub(arg, k(p, 'z0'))}) / (${sub(arg, k(p, 'zi'))})`; + } + }, + { + id: 'joukowski', + mode: 5, + label: 'Joukowski', + params: [], + f: (z) => cmul(cx(0.5), cadd(z, cdiv(ONE, z))), + fp: (z) => cmul(cx(0.5), csub(ONE, cdiv(ONE, cmul(z, z)))), + uniforms: () => NO_U, + expr: (a, _p, compound) => { + const arg = par(a, compound); + return `½(${arg} + 1/${arg})`; + } + }, + { + id: 'exp', + mode: 6, + label: 'Exponential', + params: [], + f: (z) => cexp(z), + fp: (z) => cexp(z), + uniforms: () => NO_U, + expr: (a) => `exp(${a})` + }, + { + id: 'log', + mode: 7, + label: 'Logarithm', + params: [], + f: (z) => clog(z), + fp: (z) => cdiv(ONE, z), + uniforms: () => NO_U, + expr: (a) => `log(${a})` + }, + { + id: 'escher', + mode: 8, + label: 'Escher zᵃ', + params: [{ kind: 'real', id: 'k', label: 'k', min: 0, max: 1.5, step: 0.01, default: 0.3 }], + f: (z, p) => cpow(z, cx(1, -r(p, 'k'))), + fp: (z, p) => { + const a = cx(1, -r(p, 'k')); + return cmul(a, cpow(z, csub(a, ONE))); + }, + uniforms: (p) => ({ ...NO_U, pr: [r(p, 'k'), 0, 0, 0] }), + expr: (a, p, compound) => `${par(a, compound || a !== 'z')}^(1 − ${fmt(r(p, 'k'))}i)` + }, + { + id: 'sine', + mode: 9, + label: 'Sine', + params: [], + f: (z) => csin(z), + fp: (z) => ccos(z), + uniforms: () => NO_U, + expr: (a) => `sin(${a})` + }, + { + id: 'tan', + mode: 10, + label: 'Tangent', + params: [], + f: (z) => cdiv(csin(z), ccos(z)), + fp: (z) => cdiv(ONE, cmul(ccos(z), ccos(z))), // sec²z + uniforms: () => NO_U, + expr: (a) => `tan(${a})` + }, + { + id: 'sininv', + mode: 11, + label: 'sin(1/z)', + params: [], + f: (z) => csin(cdiv(ONE, z)), + fp: (z) => { + const inv = cdiv(ONE, z); + return cmul(ccos(inv), cdiv(cx(-1), cmul(z, z))); // cos(1/z)·(−1/z²) + }, + uniforms: () => NO_U, + expr: (a) => `sin(1 / ${par(a, a !== 'z')})` + } +]; + +export const PRESET_BY_ID: Record = Object.fromEntries( + PRESETS.map((p) => [p.id, p]) +); + +/** Fresh copy of a preset's default params (so edits don't mutate the def). */ +export function defaultParams(preset: Preset): Params { + const out: Params = {}; + for (const d of preset.params) { + out[d.id] = d.kind === 'real' ? d.default : { ...d.default }; + } + return out; +} + +export type FillMode = 'tile' | 'clamp' | 'mirror'; +export type PanMode = 'domain' | 'output'; + +/** The full live-formula string, with the pan term composed in. */ +export function formulaText(preset: Preset, p: Params, c: Complex, panMode: PanMode): string { + const zero = Math.abs(c.re) < 1e-9 && Math.abs(c.im) < 1e-9; + if (panMode === 'output') { + const base = preset.expr('z', p, false); + return zero ? `f(z) = ${base}` : `f(z) = ${base} + (${fmtComplex(c)})`; + } + const arg = zero ? 'z' : sub('z', { re: -c.re, im: -c.im }); // z − (−c) = z + c + return `f(z) = ${preset.expr(arg, p, !zero)}`; +} diff --git a/src/lib/render/playground/shader.frag.glsl b/src/lib/render/playground/shader.frag.glsl new file mode 100644 index 0000000..445dc70 --- /dev/null +++ b/src/lib/render/playground/shader.frag.glsl @@ -0,0 +1,109 @@ +#version 300 es +precision highp float; + +/** + * Complex-playground uber fragment shader. One full-screen quad; one branch + * per preset (u_mode), matching the `mode` order in presets.ts. The output + * canvas is letter-boxed to the source-image aspect, so normalized output + * coords equal normalized image coords at zoom 1 (identity = passthrough). + * + * For each output pixel at complex coord z (origin at image centre, +im up): + * domain pan: w = f(z + c) + * output pan: w = f(z) + c + * then sample the source at w. Wrap (tile / clamp / mirror) is set on the + * sampler by the renderer. f'(z) drives the mip LOD for footprint AA. + */ + +uniform sampler2D u_src; +uniform vec2 u_canvas; // output size (px) +uniform int u_mode; +uniform float u_imgAspect; // source W/H +uniform float u_zoom; // 1 = whole image fits +uniform vec2 u_c; // pan constant +uniform int u_panMode; // 0 = domain f(z+c), 1 = output f(z)+c +uniform vec4 u_pr; // real scalar params +uniform vec2 u_pa; // complex params +uniform vec2 u_pb; +uniform vec2 u_pc; +uniform vec2 u_texSize; // source px + +out vec4 fragColor; + +const float PI = 3.141592653589793; + +vec2 cmul(vec2 a, vec2 b) { return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); } +vec2 cdiv(vec2 a, vec2 b) { float d = dot(b, b) + 1e-30; return vec2(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / d; } +vec2 cexp(vec2 z) { float e = exp(z.x); return e * vec2(cos(z.y), sin(z.y)); } +vec2 clog(vec2 z) { return vec2(0.5 * log(dot(z, z) + 1e-30), atan(z.y, z.x)); } +vec2 cpow(vec2 z, vec2 w) { if (dot(z, z) < 1e-20) return vec2(0.0); return cexp(cmul(w, clog(z))); } +vec2 csinz(vec2 z) { return vec2(sin(z.x) * cosh(z.y), cos(z.x) * sinh(z.y)); } +vec2 ccosz(vec2 z) { return vec2(cos(z.x) * cosh(z.y), -sin(z.x) * sinh(z.y)); } +vec2 ctanz(vec2 z) { return cdiv(csinz(z), ccosz(z)); } + +const vec2 ONE = vec2(1.0, 0.0); + +// Evaluate f and f' for the active mode at point z. Returns f in `fOut`, +// derivative magnitude (for AA) in `dOut`. +void evalF(vec2 z, out vec2 fOut, out float dOut) { + vec2 f; vec2 d; + if (u_mode == 0) { // identity + f = z; d = ONE; + } else if (u_mode == 1) { // square + f = cmul(z, z); d = 2.0 * z; + } else if (u_mode == 2) { // power zⁿ + float n = u_pr.x; + f = cpow(z, vec2(n, 0.0)); + d = cmul(vec2(n, 0.0), cpow(z, vec2(n - 1.0, 0.0))); + } else if (u_mode == 3) { // 1/z + f = cdiv(ONE, z); d = cdiv(vec2(-1.0, 0.0), cmul(z, z)); + } else if (u_mode == 4) { // Möbius k(z−z0)/(z−zi) + vec2 den = z - u_pc; + f = cmul(u_pa, cdiv(z - u_pb, den)); + d = cmul(u_pa, cdiv(u_pb - u_pc, cmul(den, den))); + } else if (u_mode == 5) { // Joukowski ½(z + 1/z) + f = 0.5 * (z + cdiv(ONE, z)); + d = 0.5 * (ONE - cdiv(ONE, cmul(z, z))); + } else if (u_mode == 6) { // exp + f = cexp(z); d = cexp(z); + } else if (u_mode == 7) { // log + f = clog(z); d = cdiv(ONE, z); + } else if (u_mode == 8) { // Escher zᵃ, a = 1 − ik + vec2 a = vec2(1.0, -u_pr.x); + f = cpow(z, a); + d = cmul(a, cpow(z, a - ONE)); + } else if (u_mode == 9) { // sine + f = csinz(z); d = ccosz(z); + } else if (u_mode == 10) { // tan + f = ctanz(z); + d = cdiv(ONE, cmul(ccosz(z), ccosz(z))); + } else if (u_mode == 11) { // sin(1/z) + vec2 inv = cdiv(ONE, z); + f = csinz(inv); + d = cmul(ccosz(inv), cdiv(vec2(-1.0, 0.0), cmul(z, z))); + } else { + f = z; d = ONE; + } + fOut = f; + dOut = length(d); +} + +void main() { + vec2 nz = gl_FragCoord.xy / u_canvas; // [0,1], y up + vec2 half2 = vec2(max(u_imgAspect, 1.0), max(1.0 / u_imgAspect, 1.0)); + vec2 z = (2.0 * nz - 1.0) * half2 / u_zoom; // complex, origin centre + + vec2 zin = (u_panMode == 0) ? z + u_c : z; + + vec2 w; float dmag; + evalF(zin, w, dmag); + if (u_panMode == 1) w += u_c; + + // source uv (v flipped: +im up → top row v = 0) + vec2 uv = vec2(0.5 + 0.5 * w.x / half2.x, 0.5 - 0.5 * w.y / half2.y); + + // footprint AA: texels covered per output pixel ≈ |f'| · texW / (zoom · canvasW) + float footprint = dmag * u_texSize.x / max(u_zoom * u_canvas.x, 1.0); + float lod = max(0.0, log2(max(footprint, 1e-6))); + + fragColor = textureLod(u_src, uv, lod); +} diff --git a/src/lib/ui1/icons.ts b/src/lib/ui1/icons.ts index 219c4cb..aa7bca6 100644 --- a/src/lib/ui1/icons.ts +++ b/src/lib/ui1/icons.ts @@ -49,6 +49,8 @@ export const ICON = { '', viewPipeline: '', + viewPlayground: + '', share: '', info: diff --git a/src/lib/ui1/playground.svelte.ts b/src/lib/ui1/playground.svelte.ts new file mode 100644 index 0000000..322af80 --- /dev/null +++ b/src/lib/ui1/playground.svelte.ts @@ -0,0 +1,69 @@ +/** + * Complex-playground state. Independent of the Droste `doc.rect`/`crop`; the + * playground only borrows `doc.image` as its source texture. A light rune the + * stage (renderer) and controls (UI) both read. + */ + +import { + PRESET_BY_ID, + defaultParams, + type Complex, + type FillMode, + type PanMode, + type Params +} from '../render/playground/presets'; + +const DEFAULT_PRESET = 'escher'; + +export const playground = $state<{ + presetId: string; + params: Params; + /** Pan constant fed by hand-drag; composed per `panMode`. */ + c: Complex; + panMode: PanMode; + /** 1 = whole image fits the view; > 1 zooms in. */ + zoom: number; + fill: FillMode; +}>({ + presetId: DEFAULT_PRESET, + params: defaultParams(PRESET_BY_ID[DEFAULT_PRESET]), + c: { re: 0, im: 0 }, + panMode: 'domain', + zoom: 1, + fill: 'tile' +}); + +/** Switch presets, loading that preset's default params and clearing the pan. */ +export function selectPreset(id: string): void { + const p = PRESET_BY_ID[id]; + if (!p) return; + playground.presetId = id; + playground.params = defaultParams(p); + playground.c = { re: 0, im: 0 }; +} + +/** Back to the current preset's defaults (params + pan + zoom). */ +export function resetPlayground(): void { + const p = PRESET_BY_ID[playground.presetId]; + playground.params = defaultParams(p); + playground.c = { re: 0, im: 0 }; + playground.zoom = 1; +} + +/** True when anything is off the current preset's defaults — zoom, pan, OR + * any parameter (a slider nudge or a dragged Möbius zero/pole counts). */ +export function playgroundDirty(): boolean { + if (playground.zoom !== 1 || playground.c.re !== 0 || playground.c.im !== 0) return true; + const defs = defaultParams(PRESET_BY_ID[playground.presetId]); + for (const id in defs) { + const cur = playground.params[id]; + const def = defs[id]; + if (typeof def === 'number') { + if (cur !== def) return true; + } else { + const c = cur as Complex; + if (c.re !== def.re || c.im !== def.im) return true; + } + } + return false; +} diff --git a/src/lib/ui1/state.svelte.ts b/src/lib/ui1/state.svelte.ts index 619bbf0..e22c27f 100644 --- a/src/lib/ui1/state.svelte.ts +++ b/src/lib/ui1/state.svelte.ts @@ -28,12 +28,15 @@ export type Theme = 'light-neutral' | 'light-warm' | 'dark-warm'; * pipeline— the 4-panel explorable: rect editor (top-left) + the log, * rotated-log, and tententoon-still derived panels. A static * view of the math; playback/exports are irrelevant here. + * playground— the complex playground: pick a complex function f(z), tweak + * its parameters live, and hand-drag to add a complex constant. + * Uses doc.image only (its own complex frame, not doc.rect). * All stages stay mounted in every mode so each one's renderFrame * binding survives view switches; the inactive stages are hidden in * CSS, which also short-circuits their render effects via 0×0 * ResizeObserver readouts. */ -export type ViewMode = 'split' | 'preview' | 'droste' | 'pipeline'; +export type ViewMode = 'split' | 'preview' | 'droste' | 'pipeline' | 'playground'; export type Rect = { x: number; y: number; w: number; h: number }; diff --git a/tests/render/playground-presets.test.ts b/tests/render/playground-presets.test.ts new file mode 100644 index 0000000..381fa8e --- /dev/null +++ b/tests/render/playground-presets.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { + PRESETS, + PRESET_BY_ID, + defaultParams, + formulaText, + cx, + type Complex, + type Preset +} from '../../src/lib/render/playground/presets'; + +/** Approx-equal for a complex value. */ +function near(a: Complex, re: number, im: number) { + expect(a.re).toBeCloseTo(re, 6); + expect(a.im).toBeCloseTo(im, 6); +} + +const P = (id: string): Preset => PRESET_BY_ID[id]; +const evalF = (id: string, z: Complex) => P(id).f(z, defaultParams(P(id))); + +describe('playground presets — registry', () => { + it('modes are 0..N-1, contiguous and unique (must match the shader switch)', () => { + const modes = PRESETS.map((p) => p.mode).sort((a, b) => a - b); + expect(modes).toEqual(PRESETS.map((_, i) => i)); + expect(new Set(modes).size).toBe(modes.length); + }); + + it('defaultParams returns a fresh clone (no shared references with the def)', () => { + const m = P('mobius'); + const a = defaultParams(m); + const b = defaultParams(m); + (a.z0 as Complex).re = 99; + expect((b.z0 as Complex).re).not.toBe(99); // clones are independent + expect((m.params.find((d) => d.id === 'z0') as { default: Complex }).default.re).not.toBe(99); + }); + + it('every preset packs finite uniforms from its defaults', () => { + for (const p of PRESETS) { + const u = p.uniforms(defaultParams(p)); + for (const v of [...u.pr, ...u.pa, ...u.pb, ...u.pc]) { + expect(Number.isFinite(v)).toBe(true); + } + } + }); +}); + +describe('playground presets — f(z) correctness (CPU mirror of the shader)', () => { + it('identity: f(z) = z', () => near(evalF('identity', cx(2, 3)), 2, 3)); + it('square: f(2i) = -4', () => near(evalF('square', cx(0, 2)), -4, 0)); + it('power n=3 default: f(2) = 8', () => near(evalF('power', cx(2, 0)), 8, 0)); + it('reciprocal: f(2) = 0.5', () => near(evalF('recip', cx(2, 0)), 0.5, 0)); + it('mobius default (z+0.4)/(z-0.4): f(0) = -1', () => near(evalF('mobius', cx(0, 0)), -1, 0)); + it('joukowski: f(i) = 0', () => near(evalF('joukowski', cx(0, 1)), 0, 0)); + it('joukowski: f(1) = 1', () => near(evalF('joukowski', cx(1, 0)), 1, 0)); + it('exp: f(0) = 1', () => near(evalF('exp', cx(0, 0)), 1, 0)); + it('log: f(1) = 0', () => near(evalF('log', cx(1, 0)), 0, 0)); + it('escher k=0 ⇒ a=1 ⇒ identity', () => { + const p = P('escher'); + near(p.f(cx(2, 3), { k: 0 }), 2, 3); + }); + it('sine: f(π/2) = 1', () => near(evalF('sine', cx(Math.PI / 2, 0)), 1, 0)); + it('tan: f(0) = 0', () => near(evalF('tan', cx(0, 0)), 0, 0)); + it('sin(1/z): f(2/π) = sin(π/2) = 1', () => near(evalF('sininv', cx(2 / Math.PI, 0)), 1, 0)); + + it("mobius f' sign: f'(0) = k(z0−z∞)/(z−z∞)² = -5 for defaults", () => { + const m = P('mobius'); + near(m.fp(cx(0, 0), defaultParams(m)), -5, 0); + }); +}); + +describe('playground presets — live formula text', () => { + it('output pan, c = 0: bare f(z)', () => { + expect(formulaText(P('square'), {}, cx(0, 0), 'output')).toBe('f(z) = z²'); + expect(formulaText(P('recip'), {}, cx(0, 0), 'output')).toBe('f(z) = 1 / z'); + }); + + it('output pan composes "+ (c)"', () => { + expect(formulaText(P('square'), {}, cx(0.5, 0), 'output')).toBe('f(z) = z² + (0.5)'); + expect(formulaText(P('square'), {}, cx(0.5, -0.2), 'output')).toBe('f(z) = z² + (0.5 − 0.2i)'); + }); + + it('domain pan substitutes z → z + c inside f, folding the sign', () => { + // domain mode shows f(z + c): c = 0.5 renders as "z + 0.5" inside f. + expect(formulaText(P('square'), {}, cx(0.5, 0), 'domain')).toBe('f(z) = (z + 0.5)²'); + expect(formulaText(P('recip'), {}, cx(-0.5, 0), 'domain')).toBe('f(z) = 1 / (z − 0.5)'); + }); + + it('escher shows its exponent a = 1 − ki', () => { + expect(formulaText(P('escher'), { k: 0.3 }, cx(0, 0), 'output')).toBe('f(z) = z^(1 − 0.3i)'); + }); + + it('new presets render their notation', () => { + expect(formulaText(P('tan'), {}, cx(0, 0), 'output')).toBe('f(z) = tan(z)'); + expect(formulaText(P('sininv'), {}, cx(0, 0), 'output')).toBe('f(z) = sin(1 / z)'); + expect(formulaText(P('sininv'), {}, cx(0.5, 0), 'domain')).toBe('f(z) = sin(1 / (z + 0.5))'); + }); +});