From e06a15776196b34a9eacaedd4fc1ed69df9efaa4 Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Fri, 29 May 2026 06:22:56 +0200 Subject: [PATCH 1/3] feat: X/Y coordinate axes on the log and rotated-log panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log: u = log|z−c| (X, period logS marker) and v = arg(z−c) (Y, 2π). - rotated log: u′/v′ axes plus the original log u-axis drawn dashed at its tilt β (visualises what the rotation does). SVG overlay with a dark drop-shadow halo so the amber axes read over any image region. Escher panel has none (it's the spiral, not a cartesian map). Co-Authored-By: Claude Opus 4.8 --- src/components/ui1/PipelinePanel.svelte | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/components/ui1/PipelinePanel.svelte b/src/components/ui1/PipelinePanel.svelte index 37136c4..5409e85 100644 --- a/src/components/ui1/PipelinePanel.svelte +++ b/src/components/ui1/PipelinePanel.svelte @@ -32,6 +32,8 @@ panelPxPerUnit, panelURef, MIN_LOGS, + LOG_V_PERIODS, + ROT_V_PERIODS, type PanelImage } from '../../lib/ui1/pipeline-panels'; @@ -96,6 +98,49 @@ return [`S = ${geom.S.toFixed(2)}`]; }); + // Coordinate-axis overlay for the two log panels (escher is the spiral — + // no cartesian axes). All in CSS px of the (cell-filling) canvas, so it's + // dpr-independent and stays aligned with the rendered lattice. The origin + // (uRef, v=0) sits at the panel centre; u increases right, v increases + // down — matching the render's pixel→(u,v) mapping. + const TWO_PI = 2 * Math.PI; + const axes = $derived.by(() => { + if (kind === 'escher' || !geom || !fit || degenerate) return null; + const W = fit.w; + const H = fit.h; + const cx = W / 2; + const cy = H / 2; + const logS = geom.ctx.logS; + if (kind === 'log') { + // One vertical lattice line per Droste period (constant-radius rings). + const cssPerUnit = H / (LOG_V_PERIODS * TWO_PI); + const stepX = logS * cssPerUnit; + const gridX: number[] = []; + if (stepX >= 10) { + for (let x = cx; x <= W + 0.5; x += stepX) gridX.push(x); + for (let x = cx - stepX; x >= -0.5; x -= stepX) gridX.push(x); + } + return { + kind, W, H, cx, cy, stepX, gridX, + xLabel: 'u = log |z − c|', yLabel: 'v = arg(z − c)' + }; + } + // rotated log: show the panel's own u′/v′ axes, plus the original log + // u-axis (v = 0) which lands at v′ = u′·tan β — a line tilted by β. That + // tilt is the whole point of the panel, so we draw it dashed. + const beta = Math.atan2(logS, TWO_PI); + const len = Math.min(W, H) * 0.42; + const c = Math.cos(beta); + const s = Math.sin(beta); + return { + kind, W, H, cx, cy, + xLabel: 'u′', yLabel: 'v′', + origX1: cx - c * len, origY1: cy - s * len, + origX2: cx + c * len, origY2: cy + s * len, + origLabelX: cx + c * len * 0.82, origLabelY: cy + s * len * 0.82 + 13 + }; + }); + $effect(() => { if (!viewport) return; const update = () => { @@ -194,6 +239,43 @@ style:width="{fit.w}px" style:height="{fit.h}px" > + {#if axes} + + {/if}
{TITLES[kind]} {#each chips as chip}{chip}{/each} @@ -231,6 +313,36 @@ display: block; image-rendering: auto; } + .axes { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + /* Dark halo so the amber axes read over any image region. */ + filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.95)); + } + .axes .axis { stroke: rgba(255, 209, 138, 0.98); stroke-width: 2; } + .axes .grid { stroke: rgba(255, 209, 138, 0.32); stroke-width: 1; } + .axes .period { stroke: rgba(255, 209, 138, 0.98); stroke-width: 1.6; } + .axes .orig { + stroke: rgba(150, 205, 255, 0.85); + stroke-width: 1.4; + stroke-dasharray: 5 4; + } + .axes .arrowhead { fill: rgba(255, 217, 160, 0.95); } + .axes .axtext { + fill: #fff; + font-family: var(--font-mono); + font-size: 11px; + paint-order: stroke; + stroke: rgba(0, 0, 0, 0.7); + stroke-width: 3px; + stroke-linejoin: round; + } + .axes .axtext.small { font-size: 10px; } + .axes .orig-label { fill: rgba(190, 224, 255, 0.95); } .label { position: absolute; left: 8px; From b215fce249262763f84da9bc40721e19452d0b6a Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Fri, 29 May 2026 06:36:08 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(experimental):=20geometry=20lab=20?= =?UTF-8?q?=E2=80=94=20test=20patterns,=20angle=20override,=20pan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three experiments for understanding the Droste → Escher geometry, behind a "geometry lab" control strip in the pipeline view: - Test patterns (test-patterns.ts): generated polar grid (concentric circles + radial spokes) and cartesian grid, loadable from the DropZone and the lab strip. A centred square nest puts the limit point at the image centre, so the polar pattern maps to a clean cartesian grid in the log panel (rings → vertical lines, spokes → horizontal lines) and to the spiral grid in the tententoon — the clearest view of what the transform does. - Rotation/twist angle override (idea 3): a γ slider re-tilts the rotated-log and sets the tententoon twist k = tan γ. Only the canonical β closes the spiral up with the Droste scale; other angles over/under-twist visibly. - Pan (idea 2): drag the rotated-log panel; the log-space pan δ = R(−γ)·drag zooms (u) and rotates (v) the tententoon. Shows the tententoon is exp() of the strip. Wired via new GL uniforms (u_rot, u_kTwist, u_pan), defaulting to canonical so non-lab rendering is unchanged. Experimental — for evaluation, not yet linked from the main UI beyond the pipeline view. Co-Authored-By: Claude Opus 4.8 --- src/components/UiVariant1.svelte | 5 +- src/components/ui1/DropZone.svelte | 27 +++++ src/components/ui1/PipelineControls.svelte | 111 ++++++++++++++++++ src/components/ui1/PipelinePanel.svelte | 65 +++++++++-- src/lib/render/pipeline-gl.frag.glsl | 30 ++--- src/lib/render/pipeline-gl.ts | 13 ++- src/lib/ui1/pipeline-experiments.svelte.ts | 34 ++++++ src/lib/ui1/test-patterns.ts | 126 +++++++++++++++++++++ 8 files changed, 388 insertions(+), 23 deletions(-) create mode 100644 src/components/ui1/PipelineControls.svelte create mode 100644 src/lib/ui1/pipeline-experiments.svelte.ts create mode 100644 src/lib/ui1/test-patterns.ts diff --git a/src/components/UiVariant1.svelte b/src/components/UiVariant1.svelte index 2be6d29..8d6914d 100644 --- a/src/components/UiVariant1.svelte +++ b/src/components/UiVariant1.svelte @@ -20,6 +20,7 @@ import PreviewStage from './ui1/PreviewStage.svelte'; import DrosteStage from './ui1/DrosteStage.svelte'; import PipelinePanel from './ui1/PipelinePanel.svelte'; + import PipelineControls from './ui1/PipelineControls.svelte'; import Timeline from './ui1/Timeline.svelte'; import DropZone from './ui1/DropZone.svelte'; import { @@ -142,7 +143,9 @@
- {#if ui.view !== 'pipeline'} + {#if ui.view === 'pipeline'} + + {:else} {/if} diff --git a/src/components/ui1/DropZone.svelte b/src/components/ui1/DropZone.svelte index 1d7416c..1db058e 100644 --- a/src/components/ui1/DropZone.svelte +++ b/src/components/ui1/DropZone.svelte @@ -6,6 +6,7 @@ import { markSourceLoaded } from '../../lib/ui1/tententoon.svelte'; import { putBlob } from '../../lib/ui1/persistence'; import { publicAssetUrl } from '../../lib/asset-url'; + import { makeTestPattern, patternNest, type PatternKind } from '../../lib/ui1/test-patterns'; let dragOver = $state(false); let input: HTMLInputElement; @@ -50,6 +51,18 @@ } } + // Generated geometry patterns. A centred square nest puts the limit point + // at the image centre, so the polar pattern maps to a clean grid in the + // log panel — the clearest way to see what the transform does. + async function tryPattern(kind: PatternKind) { + const bmp = await makeTestPattern(kind); + // Ephemeral test input — load it as the working image without touching + // the gallery/persistence source tracking (patterns aren't files). + setImage(bmp, kind === 'polar' ? 'Polar grid' : 'Cartesian grid'); + commitNewRect(patternNest()); + errorMsg = null; + } + function onPaste(e: ClipboardEvent) { const items = e.clipboardData?.items; if (!items) return; @@ -90,6 +103,11 @@ +
+ or a geometry test pattern: + + +
Stays in your browser. Nothing uploaded. {#if errorMsg} · {errorMsg} @@ -164,6 +182,15 @@ border-color: var(--accent); } .btn.ghost { background: transparent; border-color: transparent; } + .btn.chip { padding: 5px 10px; font-size: 12px; } + .patterns { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; + } + .plabel { font-size: 12px; color: var(--muted); } .footnote { font-size: 11px; color: var(--muted); margin-top: 6px; font-family: var(--font-mono); text-align: center; } .error { font-size: 11px; color: var(--accent); margin-top: 4px; font-family: var(--font-mono); } .mono { font-family: var(--font-mono); } diff --git a/src/components/ui1/PipelineControls.svelte b/src/components/ui1/PipelineControls.svelte new file mode 100644 index 0000000..000f761 --- /dev/null +++ b/src/components/ui1/PipelineControls.svelte @@ -0,0 +1,111 @@ + + +
+ geometry lab + + + + + + + drag the rotated-log panel to pan + + + + + + test pattern + + +
+ + diff --git a/src/components/ui1/PipelinePanel.svelte b/src/components/ui1/PipelinePanel.svelte index 5409e85..2faef2c 100644 --- a/src/components/ui1/PipelinePanel.svelte +++ b/src/components/ui1/PipelinePanel.svelte @@ -36,6 +36,7 @@ ROT_V_PERIODS, type PanelImage } from '../../lib/ui1/pipeline-panels'; + import { experiment } from '../../lib/ui1/pipeline-experiments.svelte'; type Kind = 'log' | 'rotlog' | 'escher'; let { kind }: { kind: Kind } = $props(); @@ -73,6 +74,13 @@ // fold would divide by ~0. Surface a hint instead of rendering black. const degenerate = $derived(!!geom && geom.ctx.logS < MIN_LOGS); + // Active rotation/twist angle: the experiment override, else canonical β. + const gammaRad = $derived.by(() => { + if (!geom) return 0; + if (experiment.angleDeg !== null) return (experiment.angleDeg * Math.PI) / 180; + return Math.atan2(geom.ctx.logS, 2 * Math.PI); + }); + // Letterboxed CSS footprint. Escher preserves the crop aspect; the log // panels fill the whole cell (the lattice tiles infinitely). const fit = $derived.by(() => { @@ -91,9 +99,10 @@ const logS = geom.ctx.logS; if (kind === 'log') return [`logS = ${logS.toFixed(3)}`, 'period_v = 2π']; if (kind === 'rotlog') { - const beta = (Math.atan2(logS, 2 * Math.PI) * 180) / Math.PI; + const deg = (gammaRad * 180) / Math.PI; + const tag = experiment.angleDeg !== null ? ' (set)' : ''; const L = Math.hypot(logS, 2 * Math.PI); - return [`β = ${beta.toFixed(1)}°`, `L = ${L.toFixed(2)}`]; + return [`β = ${deg.toFixed(1)}°${tag}`, `L = ${L.toFixed(2)}`]; } return [`S = ${geom.S.toFixed(2)}`]; }); @@ -126,9 +135,9 @@ }; } // rotated log: show the panel's own u′/v′ axes, plus the original log - // u-axis (v = 0) which lands at v′ = u′·tan β — a line tilted by β. That - // tilt is the whole point of the panel, so we draw it dashed. - const beta = Math.atan2(logS, TWO_PI); + // u-axis (v = 0) which lands at v′ = u′·tan γ — a line tilted by the + // active rotation γ. That tilt is the whole point of the panel. + const beta = gammaRad; const len = Math.min(W, H) * 0.42; const c = Math.cos(beta); const s = Math.sin(beta); @@ -215,7 +224,9 @@ if (useGL && glRenderer) { glRenderer.render({ pixels, ctx: g.ctx, mode: kind, W: cw, H: ch, - pxPerUnit: ppu, uRef, scale, lnR0 + pxPerUnit: ppu, uRef, scale, lnR0, + rot: gammaRad, kTwist: Math.tan(gammaRad), + panU: experiment.panU, panV: experiment.panV }); return; } @@ -227,10 +238,46 @@ }); return () => cancelAnimationFrame(raf); }); + + // --- Experiment: drag the rotated-log panel to pan (idea 2). The drag + // delta in the rotated (u′, v′) frame becomes a log-space pan δ = R(−γ)·d, + // written to the shared experiment state — so the same shift scrolls the + // log panels AND zooms (u) / rotates (v) the tententoon. + let dragStart: { x: number; y: number; panU: number; panV: number } | null = null; + const canPan = $derived(kind === 'rotlog' && !!geom && !degenerate); + + function onPanDown(e: PointerEvent) { + if (!canPan) return; + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + dragStart = { x: e.clientX, y: e.clientY, panU: experiment.panU, panV: experiment.panV }; + } + function onPanMove(e: PointerEvent) { + if (!dragStart || !geom || !fit) return; + const L = Math.hypot(geom.ctx.logS, 2 * Math.PI); + const cssPerUnit = fit.h / (ROT_V_PERIODS * L); + // Screen delta → rotated-frame units. Negate so content follows the cursor. + const du = -(e.clientX - dragStart.x) / cssPerUnit; + const dv = -(e.clientY - dragStart.y) / cssPerUnit; + const c = Math.cos(gammaRad); + const s = Math.sin(gammaRad); + experiment.panU = dragStart.panU + (du * c + dv * s); + experiment.panV = dragStart.panV + (-du * s + dv * c); + } + function onPanUp() { + dragStart = null; + } -
-
+
+ +
{#if doc.image && geom && fit && !degenerate} ({ + angleDeg: null, + panU: 0, + panV: 0 +}); + +export function resetExperiment(): void { + experiment.angleDeg = null; + experiment.panU = 0; + experiment.panV = 0; +} + +/** True when any experiment control is off its canonical default. */ +export function experimentActive(): boolean { + return experiment.angleDeg !== null || experiment.panU !== 0 || experiment.panV !== 0; +} diff --git a/src/lib/ui1/test-patterns.ts b/src/lib/ui1/test-patterns.ts new file mode 100644 index 0000000..5468ec5 --- /dev/null +++ b/src/lib/ui1/test-patterns.ts @@ -0,0 +1,126 @@ +/** + * Generated test patterns for understanding the Droste → Escher geometry. + * + * The pipeline maps an image around the limit point c. Two patterns make the + * map legible when c sits at the image centre (use a centred square nest): + * + * polar — concentric circles + radial spokes. In the log panel a circle + * (r = const → u = const) is a VERTICAL line and a spoke + * (θ = const → v = const) is a HORIZONTAL line, so the whole + * pattern becomes a clean cartesian grid. The tententoon turns it + * into the spiral grid. + * grid — a cartesian grid. Straight lines warp into the log-polar shape, + * showing how the transform bends space. + * + * Drawn to a square canvas and returned as an ImageBitmap (same type as a + * loaded photo), so the rest of the pipeline is unchanged. + */ + +export type PatternKind = 'polar' | 'grid'; + +export const PATTERN_SIZE = 1024; + +/** A centred square nest whose limit point lands exactly at the image + * centre (centred nest on a square image → c = centre), so the polar + * pattern maps to an axis-aligned grid in the log panel. S ≈ 2.4. */ +export function patternNest(size = PATTERN_SIZE) { + const w = Math.round(size * 0.42); + const o = Math.round((size - w) / 2); + return { x: o, y: o, w, h: w }; +} + +const BG = '#f3efe6'; +const INK = '#2b3a42'; + +export async function makeTestPattern(kind: PatternKind, size = PATTERN_SIZE): Promise { + const c = document.createElement('canvas'); + c.width = size; + c.height = size; + const ctx = c.getContext('2d'); + if (!ctx) throw new Error('2d context unavailable'); + ctx.fillStyle = BG; + ctx.fillRect(0, 0, size, size); + if (kind === 'polar') drawPolar(ctx, size); + else drawGrid(ctx, size); + return createImageBitmap(c); +} + +function drawGrid(ctx: CanvasRenderingContext2D, size: number): void { + const step = size / 16; + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(43,58,66,0.35)'; + for (let i = 1; i < 16; i++) { + const p = Math.round(i * step) + 0.5; + line(ctx, p, 0, p, size); + line(ctx, 0, p, size, p); + } + // Bold coloured centre axes so orientation is trackable through the warp. + const mid = size / 2; + ctx.lineWidth = 5; + ctx.strokeStyle = '#d1495b'; + line(ctx, 0, mid, size, mid); // horizontal (x) axis — red + ctx.strokeStyle = '#2e86ab'; + line(ctx, mid, 0, mid, size); // vertical (y) axis — blue + // A few accent cells near the centre to read scale. + ctx.fillStyle = 'rgba(46,134,171,0.18)'; + ctx.fillRect(mid, mid - step, step, step); + ctx.fillStyle = 'rgba(209,73,91,0.18)'; + ctx.fillRect(mid - step, mid, step, step); +} + +function drawPolar(ctx: CanvasRenderingContext2D, size: number): void { + const cx = size / 2; + const cy = size / 2; + const maxR = size * 0.72; // reach into the corners + const rings = 14; + const spokes = 24; // every 15° + + // Alternating shaded rings — reads as horizontal bands in the log panel. + for (let i = rings; i >= 1; i--) { + const r = (i / rings) * maxR; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = i % 2 === 0 ? 'rgba(38,109,120,0.10)' : BG; + ctx.fill(); + } + // Ring outlines. + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(38,109,120,0.55)'; + for (let i = 1; i <= rings; i++) { + const r = (i / rings) * maxR; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + } + // Radial spokes. + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(43,58,66,0.4)'; + for (let s = 0; s < spokes; s++) { + const a = (s / spokes) * Math.PI * 2; + line(ctx, cx, cy, cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR); + } + // Coloured cardinal spokes — track angle through the rotation. + const card: [number, string][] = [ + [0, '#d1495b'], // +x red (θ = 0) + [Math.PI / 2, '#2e86ab'], // +y blue (θ = 90°) + [Math.PI, '#e9a23b'], // −x amber + [(3 * Math.PI) / 2, '#6a994e'] // −y green + ]; + ctx.lineWidth = 5; + for (const [a, col] of card) { + ctx.strokeStyle = col; + line(ctx, cx, cy, cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR); + } + // Centre dot = the limit point when the nest is centred. + ctx.fillStyle = INK; + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.fill(); +} + +function line(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number): void { + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); +} From bef7fca29c0269bb45dd32bd6dd961914168ee5d Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Fri, 29 May 2026 06:42:53 +0200 Subject: [PATCH 3/3] polish: GPU/CPU parity for lab params + reset on image change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The CPU pure functions now take the same pan/rot/kTwist options as the GL shader (default canonical), so the geometry-lab controls behave identically on the WebGL2 fallback path — and the math is unit-tested. - Reset the lab pan/angle when a new image loads, so the pipeline view never opens with a stale transform. - Tests: k=0 removes the twist; a v-pan shifts the log panel while staying fully opaque (no black). Co-Authored-By: Claude Opus 4.8 --- src/components/ui1/PipelinePanel.svelte | 12 ++++-- src/lib/ui1/pipeline-panels.ts | 56 ++++++++++++++++++------- src/lib/ui1/state.svelte.ts | 4 ++ tests/render/pipeline-panels.test.ts | 29 +++++++++++++ 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/components/ui1/PipelinePanel.svelte b/src/components/ui1/PipelinePanel.svelte index 2faef2c..e8c0558 100644 --- a/src/components/ui1/PipelinePanel.svelte +++ b/src/components/ui1/PipelinePanel.svelte @@ -230,10 +230,16 @@ }); return; } + const opts = { + panU: experiment.panU, + panV: experiment.panV, + rot: gammaRad, + kTwist: Math.tan(gammaRad) + }; let out: PanelImage; - if (kind === 'log') out = renderLogPanel(pixels, g.ctx, ppu, uRef, cw, ch); - else if (kind === 'rotlog') out = renderRotatedLogPanel(pixels, g.ctx, ppu, uRef, cw, ch); - else out = renderEscherStill(pixels, g.ctx, g.R0, scale, cw, ch); + if (kind === 'log') out = renderLogPanel(pixels, g.ctx, ppu, uRef, cw, ch, opts); + else if (kind === 'rotlog') out = renderRotatedLogPanel(pixels, g.ctx, ppu, uRef, cw, ch, opts); + else out = renderEscherStill(pixels, g.ctx, g.R0, scale, cw, ch, opts); paintCpu(out); }); return () => cancelAnimationFrame(raf); diff --git a/src/lib/ui1/pipeline-panels.ts b/src/lib/ui1/pipeline-panels.ts index 3a19370..d670ca9 100644 --- a/src/lib/ui1/pipeline-panels.ts +++ b/src/lib/ui1/pipeline-panels.ts @@ -101,6 +101,20 @@ function asImageData(img: PanelImage): ImageData { return img as unknown as ImageData; } +/** + * Optional transform overrides shared by the panels (the "geometry lab"). + * Omitted → canonical behaviour, so existing callers and tests are unchanged. + * panU/panV — pan in log space (δu, δv): δu zooms, δv rotates. + * rot — rotated-log rotation angle (canonical atan(logS/2π)). + * kTwist — tententoon twist k (canonical logS/2π = tan rot). + */ +export type PanelOpts = { + panU?: number; + panV?: number; + rot?: number; + kTwist?: number; +}; + export type PanelGeometry = { /** Droste sampling context (limit point, logS, rMax, crop offset, …). */ ctx: DrosteCtx; @@ -157,15 +171,18 @@ export function renderLogPanel( pxPerUnit: number, uRef: number, W: number, - H: number + H: number, + opts: PanelOpts = {} ): PanelImage { const out = blankImage(W, H); const w = out.width; const h = out.height; const inv = 1 / pxPerUnit; + const panU = opts.panU ?? 0; + const panV = opts.panV ?? 0; renderMappedDroste(asImageData(out), pixels, ctx, (px, py, s) => { - const u = wrapToTopRing((px - w / 2) * inv + uRef, uRef, ctx.logS); - const v = (py - h / 2) * inv; + const u = wrapToTopRing((px - w / 2) * inv + uRef + panU, uRef, ctx.logS); + const v = (py - h / 2) * inv + panV; const r = Math.exp(u); s.x = ctx.cx + r * Math.cos(v); s.y = ctx.cy + r * Math.sin(v); @@ -189,20 +206,23 @@ export function renderRotatedLogPanel( pxPerUnit: number, uRef: number, W: number, - H: number + H: number, + opts: PanelOpts = {} ): PanelImage { const out = blankImage(W, H); const w = out.width; const h = out.height; const inv = 1 / pxPerUnit; - const L = Math.hypot(ctx.logS, TWO_PI); - const cosB = TWO_PI / L; // cos(atan(logS / 2π)) - const sinB = ctx.logS / L; // sin(atan(logS / 2π)) + const rot = opts.rot ?? Math.atan2(ctx.logS, TWO_PI); + const cosB = Math.cos(rot); + const sinB = Math.sin(rot); + const panU = opts.panU ?? 0; + const panV = opts.panV ?? 0; renderMappedDroste(asImageData(out), pixels, ctx, (px, py, s) => { const cu = (px - w / 2) * inv; const cv = (py - h / 2) * inv; - const u = wrapToTopRing(cu * cosB + cv * sinB + uRef, uRef, ctx.logS); - const v = -cu * sinB + cv * cosB; + const u = wrapToTopRing(cu * cosB + cv * sinB + uRef + panU, uRef, ctx.logS); + const v = -cu * sinB + cv * cosB + panV; const r = Math.exp(u); s.x = ctx.cx + r * Math.cos(v); s.y = ctx.cy + r * Math.sin(v); @@ -228,20 +248,24 @@ export function renderEscherStill( R0: number, scale: number, W: number, - H: number + H: number, + opts: PanelOpts = {} ): PanelImage { const out = blankImage(W, H); const w = out.width; const h = out.height; const data = out.data; const { cx, cy, logS, rMax } = ctx; - const k = logS / TWO_PI; + const k = opts.kTwist ?? logS / TWO_PI; + const panU = opts.panU ?? 0; + const panV = opts.panV ?? 0; const lnR0 = Math.log(Math.max(R0, 1e-9)); const lnRmax = Math.log(rMax); const alphaMag = Math.sqrt(1 + k * k); const rgba: [number, number, number, number] = [0, 0, 0, 0]; - // Inverse Lenstra map for one (sub)sample at canvas offset (ox, oy). + // Inverse Lenstra map for one (sub)sample at canvas offset (ox, oy). The + // log-space pan shifts the twisted radius (panU = zoom) and angle (panV). const sampleAt = (px: number, py: number, ox: number, oy: number): boolean => { const x = (px + ox) / scale; const y = (py + oy) / scale; @@ -251,8 +275,8 @@ export function renderEscherStill( if (R2 < 1e-12) return false; const lnR = 0.5 * Math.log(R2); const Phi = Math.atan2(dy, dx); - const newPhi = Phi - k * (lnR - lnR0); - const r = Math.exp(lnR + k * Phi); // t = 0 + const newPhi = Phi - k * (lnR - lnR0) + panV; + const r = Math.exp(lnR + k * Phi + panU); // t = 0 const sx = cx + r * Math.cos(newPhi); const sy = cy + r * Math.sin(newPhi); return sampleDroste(pixels, ctx, sx, sy, rgba); @@ -268,9 +292,9 @@ export function renderEscherStill( // Footprint = source-pixels per output-pixel; picks the SS tier. const lnR = 0.5 * Math.log(R2); const Phi = Math.atan2(dy, dx); - const baseLnR = lnR + k * Phi; + const baseLnR = lnR + k * Phi + panU; const n = Math.max(0, Math.floor((lnRmax - baseLnR) / logS)); - const footprint = (alphaMag * Math.exp(k * Phi + n * logS)) / scale; + const footprint = (alphaMag * Math.exp(k * Phi + panU + n * logS)) / scale; const offsets = ssOffsetsForFootprint(footprint); if (!offsets) { if (sampleAt(px, py, 0, 0)) { diff --git a/src/lib/ui1/state.svelte.ts b/src/lib/ui1/state.svelte.ts index 2998126..619bbf0 100644 --- a/src/lib/ui1/state.svelte.ts +++ b/src/lib/ui1/state.svelte.ts @@ -12,6 +12,7 @@ */ import { fitCropToNest, ensureNestInside, type Rect as DrosteRect } from '../math/droste'; +import { resetExperiment } from './pipeline-experiments.svelte'; export type Tool = 'select' | 'rect' | 'pan'; export type Direction = 'in' | 'out'; @@ -100,6 +101,9 @@ export function setImage(image: ImageBitmap | null, name = ''): void { doc.crop = null; playback.playing = false; playback.t = 0; + // A new image has fresh geometry; clear any stashed geometry-lab pan/angle + // so the pipeline view doesn't open with a surprising transform. + resetExperiment(); } // --- Rect/crop commit helpers ------------------------------------------ diff --git a/tests/render/pipeline-panels.test.ts b/tests/render/pipeline-panels.test.ts index b47a401..6ad23f3 100644 --- a/tests/render/pipeline-panels.test.ts +++ b/tests/render/pipeline-panels.test.ts @@ -165,6 +165,35 @@ describe('pipeline-panels', () => { dump('4-log-largenest.jpg', logImg); }); + it('escher twist k=0 removes the spiral (identity angle map)', () => { + // With k = 0 the Lenstra map has no twist: arg is unchanged and the + // radius is just |z−c|, so it reduces to a plain Droste fold. The result + // must differ from the canonical twisted spiral. + const W = 300; + const H = Math.round(W * (crop.h / crop.w)); + const sc = W / crop.w; + const twisted = renderEscherStill(pixels, geom!.ctx, geom!.R0, sc, W, H); + const flat = renderEscherStill(pixels, geom!.ctx, geom!.R0, sc, W, H, { kTwist: 0 }); + let diff = 0; + for (let i = 0; i < flat.data.length; i += 4) { + diff += Math.abs(flat.data[i] - twisted.data[i]); + } + expect(diff).toBeGreaterThan(0); + expect(opaqueFraction(flat)).toBeGreaterThan(0.5); + }); + + it('a log-space pan changes the panels (and is bounded/opaque)', () => { + const W = 256; + const H = 256; + const ppu = panelPxPerUnit('log', geom!.ctx.logS, H); + const base = renderLogPanel(pixels, geom!.ctx, ppu, uRef, W, H); + const panned = renderLogPanel(pixels, geom!.ctx, ppu, uRef, W, H, { panV: 1.0 }); + let diff = 0; + for (let i = 0; i < base.data.length; i += 4) diff += Math.abs(base.data[i] - panned.data[i]); + expect(diff).toBeGreaterThan(0); // a v-pan (rotation) visibly shifts content + expect(opaqueFraction(panned)).toBeGreaterThan(0.999); // still fills, no black + }); + it('produces tiny but valid output for sub-2px panels (no crash)', () => { const ppu = panelPxPerUnit('log', geom!.ctx.logS, 1); const img = renderLogPanel(pixels, geom!.ctx, ppu, uRef, 1, 1);