From ea9a81fce4cf72610cdde5a8082bbe384146168e Mon Sep 17 00:00:00 2001 From: Brayden Date: Fri, 14 Aug 2026 21:14:34 -0400 Subject: [PATCH] Fix Interpolation Issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The pattern now **repeats right to the edges** of the panel, at any size or zoom level — resizing the panel just reveals more of the pattern instead of stretching it. - **Zoom controls the pattern size** 50% up to 500% - **Fixed Monitor Interpolation** by adding a formula to calculate how to display pattern **sharp and pixel-perfect** at every level and every screen size — no more mis-sized, blurry or smudged edges (avoids half pixel rounding). - The preview area now takes up about **two-thirds of the panel's height**, giving your pattern more room to shine. - Fixed an edge case where the pattern could stop just short of the panel's borders at certain zoom levels. --- CHANGELOG.md | 8 ++++ src/app.ts | 82 +++++++++++++++++++++++++++--------- src/app/previewRenderer.ts | 66 ++++++++++++++++++++++++++--- src/styles/editor-layout.css | 7 +-- 4 files changed, 131 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3bf3be..cfb4f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ *Note: some changes may be missing prior to September 2026 due to my lack of documentation.* +## August 15, 2026 — Crisper, pixel-perfect preview +The preview panel got a big clarity upgrade: +- The pattern now **repeats right to the edges** of the panel, at any size or zoom level — resizing the panel just reveals more of the pattern instead of stretching it. +- **Zoom controls the pattern size** 50% up to 500% +- **Fixed Monitor Interpolation** by adding a formula to calculate how to display pattern **sharp and pixel-perfect** at every level and every screen size — no more mis-sized, blurry or smudged edges (avoids half pixel rounding). +- The preview area now takes up about **two-thirds of the panel's height**, giving your pattern more room to shine. +- Fixed an edge case where the pattern could stop just short of the panel's borders at certain zoom levels. + ## August 14, 2026 — Favorite Colours - Added a **★ star button** next to the Color Code button so you can **favorite your current color palette** (preset or custom) with one click — tap again to remove it. - Favorited colors appear in a new **Favorites** list above the Color Presets, so you can quickly jump back to the palettes you use most. Custom palettes are listed as **"custom"**, and your favorites are remembered between sessions. diff --git a/src/app.ts b/src/app.ts index 453f5a3..0c1f55b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -304,36 +304,80 @@ document.addEventListener("DOMContentLoaded", () => { context: previewCtx, primaryColorInput: previewPrimaryColor, secondaryColorInput: previewSecondaryColor, + canvasWrap: previewCanvasWrap, + getZoom: () => previewZoom, }); // ── Preview zoom ────────────────────────────────────────────────────────── - let previewZoom = 1; - const PREVIEW_ZOOM_MIN = 0.5; - const PREVIEW_ZOOM_MAX = 4; - const PREVIEW_ZOOM_STEP = 0.5; - - const applyPreviewZoom = () => { - previewCanvas.style.transform = `scale(${previewZoom})`; - previewCanvas.style.transformOrigin = "center"; + // Zoom is the displayed CSS px per tile cell (× the encoded pattern scale). + // The renderer draws at device-pixel resolution, so every zoom level stays + // pixel-exact — including 50% — on high-DPI displays. + const PREVIEW_ZOOM_LEVELS = [0.5, 1, 2, 3, 4, 5]; + let previewZoomIndex = 1; + let previewZoom = PREVIEW_ZOOM_LEVELS[previewZoomIndex]; + + const updatePreviewZoomLabel = () => { previewZoomValue.textContent = `${Math.round(previewZoom * 100)}%`; - if (previewCanvasWrap) { - previewCanvasWrap.style.background = previewPrimaryColor.value; - } }; - previewZoomInBtn.addEventListener("click", () => { - previewZoom = Math.min(PREVIEW_ZOOM_MAX, previewZoom + PREVIEW_ZOOM_STEP); - applyPreviewZoom(); - }); + const setPreviewZoom = (index: number) => { + previewZoomIndex = Math.max( + 0, + Math.min(PREVIEW_ZOOM_LEVELS.length - 1, index) + ); + previewZoom = PREVIEW_ZOOM_LEVELS[previewZoomIndex]; + updatePreviewZoomLabel(); + renderPreviewOnly(); + }; - previewZoomOutBtn.addEventListener("click", () => { - previewZoom = Math.max(PREVIEW_ZOOM_MIN, previewZoom - PREVIEW_ZOOM_STEP); - applyPreviewZoom(); - }); + previewZoomInBtn.addEventListener("click", () => + setPreviewZoom(previewZoomIndex + 1) + ); + previewZoomOutBtn.addEventListener("click", () => + setPreviewZoom(previewZoomIndex - 1) + ); // ── Output update ───────────────────────────────────────────────────────── const clonePattern = (pattern: number[][]) => pattern.map((row) => [...row]); + // Re-renders the preview bitmap only — no history/URL side effects. Safe to + // call on panel resizes and zoom changes without polluting undo history. + const renderPreviewOnly = () => { + const grid = activeGrid(); + const pattern = grid.getCurrentPattern(); + + if (isScrapActive) { + renderPreview(pattern, true); + } else { + const scale = scaleExponent(); + try { + const base64 = generatePatternBase64( + pattern, + grid.getTileWidth(), + grid.getTileHeight(), + scale + ); + renderPreview(base64, false); + } catch { + return; + } + } + + if (previewCanvasWrap) { + previewCanvasWrap.style.background = previewPrimaryColor.value; + } + }; + + // Re-render whenever the preview panel is resized so the tile layout stays an + // integer multiple of the pattern tile at the current zoom. + if (typeof ResizeObserver !== "undefined" && previewCanvasWrap) { + new ResizeObserver(() => { + if (previewCanvasWrap.clientWidth > 0 && previewCanvasWrap.clientHeight > 0) { + renderPreviewOnly(); + } + }).observe(previewCanvasWrap); + } + let updateOutput = () => {}; updateOutput = () => { const grid = activeGrid(); diff --git a/src/app/previewRenderer.ts b/src/app/previewRenderer.ts index 5f1a6f6..818a172 100644 --- a/src/app/previewRenderer.ts +++ b/src/app/previewRenderer.ts @@ -7,20 +7,37 @@ type PreviewRendererOptions = { context: CanvasRenderingContext2D; primaryColorInput: HTMLInputElement; secondaryColorInput: HTMLInputElement; + canvasWrap: HTMLElement; + getZoom: () => number; }; -type PatternSource = { isSet: (x: number, y: number) => boolean }; +type PatternSource = { + isSetCell: (cx: number, cy: number) => boolean; + tileWidth: number; + tileHeight: number; + scale: number; +}; function patternSourceFromBase64(base64: string): PatternSource { const decoder = new PatternDecoder(base64); - return { isSet: (x, y) => decoder.isSet(x, y) }; + const scale = decoder.getScale(); + return { + // Cell-level predicate: tiles the pattern across cell indices. + isSetCell: (cx, cy) => decoder.isSet(cx << scale, cy << scale), + tileWidth: decoder.getTileWidth(), + tileHeight: decoder.getTileHeight(), + scale, + }; } function patternSourceFromMatrix(pattern: number[][]): PatternSource { const height = pattern.length; const width = pattern[0]?.length ?? 0; return { - isSet: (x, y) => (pattern[y % height]?.[x % width] ?? 0) === 1, + isSetCell: (cx, cy) => (pattern[cy % height]?.[cx % width] ?? 0) === 1, + tileWidth: width, + tileHeight: height, + scale: 0, }; } @@ -32,17 +49,50 @@ function hexToRgb(hex: string): RgbColor { } export function createPreviewRenderer(options: PreviewRendererOptions) { - const { canvas, context, primaryColorInput, secondaryColorInput } = options; + const { canvas, context, primaryColorInput, secondaryColorInput, canvasWrap, getZoom } = options; return function renderPreview(pattern: string | number[][], isScrap = false) { const source = typeof pattern === "string" ? patternSourceFromBase64(pattern) : patternSourceFromMatrix(pattern); - const width = 512; - const height = 512; + + const wrapRect = canvasWrap.getBoundingClientRect(); + const availW = wrapRect.width; + const availH = wrapRect.height; + + // Displayed size of one tile cell in CSS px (zoom × encoded pattern scale). + const zoomScale = Math.max(0.5, getZoom()) * (1 << source.scale); + + // Render at device-pixel resolution so the bitmap maps 1:1 to physical + // pixels and the compositor never scales (no half-pixel rounding). Each + // cell becomes a whole number of device px; only sub-device-pixel cells + // (e.g. 50% of a scale-0 pattern on a 1x screen) fall back to + // nearest-neighbour downsampling via image-rendering. + const dpr = window.devicePixelRatio || 1; + const deviceCell = zoomScale * dpr; + const bitmapCell = deviceCell >= 1 ? Math.round(deviceCell) : 1; + const deviceScale = deviceCell >= 1 ? 1 : deviceCell; + + // Actual displayed CSS px per cell after rounding to whole device pixels. + // Rounding can shift this from the intended zoomScale (e.g. a fractional + // devicePixelRatio), so the tile count must come from here — not zoomScale — + // or the canvas would fall short of the panel edges. + const cellDisplayPx = (bitmapCell * deviceScale) / dpr; + + // Repeat the pattern to the panel edges; partial edge tiles are clipped by + // the overflow-hidden wrap, so the preview always fills regardless of panel + // size or zoom. + const cols = Math.max(1, Math.ceil(availW / cellDisplayPx)); + const rows = Math.max(1, Math.ceil(availH / cellDisplayPx)); + + const width = cols * bitmapCell; + const height = rows * bitmapCell; + canvas.width = width; canvas.height = height; + canvas.style.width = `${(width * deviceScale) / dpr}px`; + canvas.style.height = `${(height * deviceScale) / dpr}px`; const primaryRgb = hexToRgb(primaryColorInput.value); const secondaryRgb = hexToRgb(secondaryColorInput.value); @@ -51,8 +101,10 @@ export function createPreviewRenderer(options: PreviewRendererOptions) { const data = imageData.data; let i = 0; for (let y = 0; y < height; y++) { + const cy = Math.floor(y / bitmapCell); for (let x = 0; x < width; x++) { - if (source.isSet(x, y)) { + const cx = Math.floor(x / bitmapCell); + if (source.isSetCell(cx, cy)) { data[i++] = secondaryRgb.r; data[i++] = secondaryRgb.g; data[i++] = secondaryRgb.b; diff --git a/src/styles/editor-layout.css b/src/styles/editor-layout.css index 4ae1d9b..a869f3c 100644 --- a/src/styles/editor-layout.css +++ b/src/styles/editor-layout.css @@ -797,7 +797,7 @@ } .preview-canvas-wrap { - flex: 1; + flex: 2; min-height: 0; display: flex; justify-content: center; @@ -806,13 +806,8 @@ } #preview { - width: 100%; - max-width: 100%; - height: auto; - aspect-ratio: 1; background: #fff; image-rendering: pixelated; - transform-origin: center; } /* Colors Section */