From a263a5783b95569381e89f60b2959a21cca34436 Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Sat, 30 May 2026 23:29:28 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(playground):=20complex=20playground=20?= =?UTF-8?q?=E2=80=94=20explore=20f(z)=20on=20any=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new "playground" view (5th tool-rail button) that warps the loaded image (gallery picture, bundled sample, or grid/polar pattern) through a chosen complex function f(z), GeoGebra-style: - Curated preset shelf: identity, z², zⁿ, 1/z, Möbius, Joukowski, exp, log, Escher zᵃ, sin. One GLSL branch each (uber-shader), mirrored on CPU for the no-WebGL2 fallback. Per-preset analytic f' drives mip-LOD anti-aliasing. - Live, schema-generated controls (sliders / complex number fields) + reset; the active formula is rendered live and updates as you tweak. - Hand-drag adds a complex constant c to the formula, with a toggle for domain f(z+c) vs output f(z)+c composition; wheel zooms. - Draggable on-canvas handles for a preset's zero/pole (Möbius), plus an origin cross and unit-circle overlay. - Fill modes (tile / clamp / mirror) so out-of-image samples never go black. Its own light complex frame (origin + scale), independent of the Droste doc.rect; reuses doc.image and the existing image-source paths. Verified live (no console errors); 18 unit tests cover the preset math + formula text. Shaping doc: shaping/complex-playground-shaping.md. Co-Authored-By: Claude Opus 4.8 --- shaping/complex-playground-shaping.md | 361 +++++++++++++++++++ src/components/UiVariant1.svelte | 16 + src/components/ui1/PlaygroundControls.svelte | 224 ++++++++++++ src/components/ui1/PlaygroundStage.svelte | 342 ++++++++++++++++++ src/components/ui1/ToolRail.svelte | 9 + src/lib/render/playground/cpu.ts | 92 +++++ src/lib/render/playground/gl.ts | 148 ++++++++ src/lib/render/playground/presets.ts | 292 +++++++++++++++ src/lib/render/playground/shader.frag.glsl | 99 +++++ src/lib/ui1/icons.ts | 2 + src/lib/ui1/playground.svelte.ts | 60 +++ src/lib/ui1/state.svelte.ts | 5 +- tests/render/playground-presets.test.ts | 84 +++++ 13 files changed, 1733 insertions(+), 1 deletion(-) create mode 100644 shaping/complex-playground-shaping.md create mode 100644 src/components/ui1/PlaygroundControls.svelte create mode 100644 src/components/ui1/PlaygroundStage.svelte create mode 100644 src/lib/render/playground/cpu.ts create mode 100644 src/lib/render/playground/gl.ts create mode 100644 src/lib/render/playground/presets.ts create mode 100644 src/lib/render/playground/shader.frag.glsl create mode 100644 src/lib/ui1/playground.svelte.ts create mode 100644 tests/render/playground-presets.test.ts 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..6f0ad7d --- /dev/null +++ b/src/components/ui1/PlaygroundStage.svelte @@ -0,0 +1,342 @@ + + +
+ +
{ 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..013861a --- /dev/null +++ b/src/lib/render/playground/presets.ts @@ -0,0 +1,292 @@ +/** + * 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) => { + const d = csub(z, k(p, 'zi')); + return cmul(k(p, 'k'), cdiv(csub(k(p, 'zi'), k(p, 'z0')), 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})` + } +]; + +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..a3492d6 --- /dev/null +++ b/src/lib/render/playground/shader.frag.glsl @@ -0,0 +1,99 @@ +#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)); } + +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_pc - u_pb, 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 { // sine + f = csinz(z); d = ccosz(z); + } + 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..e2c6958 --- /dev/null +++ b/src/lib/ui1/playground.svelte.ts @@ -0,0 +1,60 @@ +/** + * 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. */ +export function playgroundDirty(): boolean { + return ( + playground.zoom !== 1 || + playground.c.re !== 0 || + playground.c.im !== 0 + ); +} 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..e4c491b --- /dev/null +++ b/tests/render/playground-presets.test.ts @@ -0,0 +1,84 @@ +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)); +}); + +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)'); + }); +}); From 78215ab08a79bb0a4163ae79e4a743da5235e7e1 Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Sat, 30 May 2026 23:37:16 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(playground):=20add=20tan=20+=20sin(1/z?= =?UTF-8?q?)=20presets;=20fix=20M=C3=B6bius=20f'=20sign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Two more "cool" presets: tan(z) (periodic, poles) and sin(1/z) (essential singularity — dense kaleidoscope at the origin). Shader gains an explicit fallback branch. - Fix the Möbius derivative sign: f'(z) = k(z0−z∞)/(z−z∞)². Only fed the AA footprint (|f'|), so no visible change, but it was wrong. Locked by a test. Verified live (sin(1/z) renders, no console errors); 22 preset tests (44 total). Co-Authored-By: Claude Opus 4.8 --- src/lib/render/playground/presets.ts | 26 +++++++++++++++++++++- src/lib/render/playground/shader.frag.glsl | 14 ++++++++++-- tests/render/playground-presets.test.ts | 13 +++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/lib/render/playground/presets.ts b/src/lib/render/playground/presets.ts index 013861a..241a358 100644 --- a/src/lib/render/playground/presets.ts +++ b/src/lib/render/playground/presets.ts @@ -195,8 +195,9 @@ export const PRESETS: Preset[] = [ ], 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, 'zi'), k(p, 'z0')), cmul(d, d))); + 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) => { @@ -261,6 +262,29 @@ export const PRESETS: Preset[] = [ 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')})` } ]; diff --git a/src/lib/render/playground/shader.frag.glsl b/src/lib/render/playground/shader.frag.glsl index a3492d6..445dc70 100644 --- a/src/lib/render/playground/shader.frag.glsl +++ b/src/lib/render/playground/shader.frag.glsl @@ -38,6 +38,7 @@ 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); @@ -58,7 +59,7 @@ void evalF(vec2 z, out vec2 fOut, out float dOut) { } 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_pc - u_pb, cmul(den, 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))); @@ -70,8 +71,17 @@ void evalF(vec2 z, out vec2 fOut, out float dOut) { vec2 a = vec2(1.0, -u_pr.x); f = cpow(z, a); d = cmul(a, cpow(z, a - ONE)); - } else { // sine + } 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); diff --git a/tests/render/playground-presets.test.ts b/tests/render/playground-presets.test.ts index e4c491b..381fa8e 100644 --- a/tests/render/playground-presets.test.ts +++ b/tests/render/playground-presets.test.ts @@ -59,6 +59,13 @@ describe('playground presets — f(z) correctness (CPU mirror of the shader)', ( 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', () => { @@ -81,4 +88,10 @@ describe('playground presets — live formula text', () => { 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))'); + }); }); From b72a0ef3be1b11b9513b0367aecae8599f5dbabb Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Sun, 31 May 2026 05:51:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(playground):=20address=20codex=20review?= =?UTF-8?q?=20=E2=80=94=20reset=20dirty-state=20+=20GL=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - playgroundDirty() now also returns true when a preset parameter differs from its default (a slider nudge or dragged Möbius zero/pole), so the reset button enables on param-only edits — not just pan/zoom. Verified live. - PlaygroundStage falls back to the CPU path when WebGL2 init throws after detectCapabilities reported it available (glInitFailed flag), instead of the render guard returning forever and leaving the stage blank. Both P2 findings from codex review. Tests/typecheck/build green. Co-Authored-By: Claude Opus 4.8 --- src/components/ui1/PlaygroundStage.svelte | 13 +++++++++++-- src/lib/ui1/playground.svelte.ts | 21 +++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/ui1/PlaygroundStage.svelte b/src/components/ui1/PlaygroundStage.svelte index 6f0ad7d..ba80a37 100644 --- a/src/components/ui1/PlaygroundStage.svelte +++ b/src/components/ui1/PlaygroundStage.svelte @@ -32,6 +32,10 @@ let viewW = $state(0); let viewH = $state(0); let glRenderer = $state(null); + // WebGL2 was advertised by detectCapabilities but init threw anyway (shader + // link / context allocation). Flip to the CPU path instead of rendering + // nothing forever. + let glInitFailed = $state(false); const active = $derived(ui.view === 'playground'); const preset = $derived(PRESET_BY_ID[playground.presetId]); @@ -101,9 +105,11 @@ r = new PlaygroundGLRenderer(); r.init(canvas); glRenderer = r; + glInitFailed = false; } catch { r?.dispose(); glRenderer = null; + glInitFailed = true; // fall through to the CPU path } return () => { r?.dispose(); @@ -124,7 +130,10 @@ // effect re-runs on any change (mirrors PipelinePanel's tracking note). $effect(() => { if (!active || !canvas || !fit || !doc.image || !preset) return; - if (useGL && !glRenderer) return; + // Use GL only if it's available AND actually initialized. If init failed, + // tryGL is false and we render through the CPU path below. + const tryGL = useGL && !glInitFailed; + if (tryGL && !glRenderer) return; // still bringing the renderer up const f = fit; const dpr = window.devicePixelRatio || 1; let cw = Math.max(1, Math.round(f.w * dpr)); @@ -146,7 +155,7 @@ const params = playground.params; const raf = requestAnimationFrame(() => { - if (useGL && glRenderer) { + if (tryGL && glRenderer) { glRenderer.render({ pixels, mode, W: cw, H: ch, imgAspect: aspect, zoom, c, panMode: panMode === 'domain' ? 0 : 1, uniforms, fill diff --git a/src/lib/ui1/playground.svelte.ts b/src/lib/ui1/playground.svelte.ts index e2c6958..322af80 100644 --- a/src/lib/ui1/playground.svelte.ts +++ b/src/lib/ui1/playground.svelte.ts @@ -50,11 +50,20 @@ export function resetPlayground(): void { playground.zoom = 1; } -/** True when anything is off the current preset's defaults. */ +/** 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 { - return ( - playground.zoom !== 1 || - playground.c.re !== 0 || - playground.c.im !== 0 - ); + 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; }