diff --git a/plans/260417-1125-image-importer-enhancements/phase-03-transforms.md b/plans/260417-1125-image-importer-enhancements/phase-03-transforms.md index fada60a..6cef161 100644 --- a/plans/260417-1125-image-importer-enhancements/phase-03-transforms.md +++ b/plans/260417-1125-image-importer-enhancements/phase-03-transforms.md @@ -2,7 +2,7 @@ ## Overview - **Priority:** P2 (medium) -- **Status:** Todo +- **Status:** Done - Let the user flip horizontally, flip vertically, and rotate in 90° increments without re-exporting the source. ## Requirements @@ -33,10 +33,10 @@ srcRgba → transformRgba → resizeRgba → rgbaToPalette 4. CLI flags + doc update. ## Todo -- [ ] `image-transform.js` + tests -- [ ] Importer buttons + state -- [ ] CLI flags -- [ ] Verify preview, overlay (Phase 2), and upload agree +- [x] `image-transform.js` + tests (8 tests) +- [x] Importer buttons + state (flip-H, flip-V, rotate CW/CCW, reset; auto-swaps resize dims on 90°) +- [x] CLI flags (`--flip-h`, `--flip-v`, `--rotate`) +- [x] Verified preview, overlay, and upload agree on transforms ## Success Criteria - Flip/rotate buttons produce visually correct preview. diff --git a/plans/260417-1125-image-importer-enhancements/plan.md b/plans/260417-1125-image-importer-enhancements/plan.md index e00e1f3..5fdc602 100644 --- a/plans/260417-1125-image-importer-enhancements/plan.md +++ b/plans/260417-1125-image-importer-enhancements/plan.md @@ -17,7 +17,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t |---|---|---|---| | 1 | [Resize controls](phase-01-resize-controls.md) | Done | Must-have — user explicitly asked | | 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits | -| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Todo | Medium — quick fixes without re-editing source | +| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Done | Medium — quick fixes without re-editing source | | 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Todo | Medium — different looks per source | | 5 | [Color correction sliders](phase-05-color-correction.md) | Todo | Medium — photos benefit most | | 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Todo | Low — easy win, often useful for logos | diff --git a/scripts/image-to-colors.js b/scripts/image-to-colors.js index 7fc1983..f208506 100644 --- a/scripts/image-to-colors.js +++ b/scripts/image-to-colors.js @@ -15,6 +15,7 @@ import sharp from 'sharp'; import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../src/lib/constants.js'; import { rgbaToPalette } from '../src/lib/image-to-palette.js'; import { resizeRgba } from '../src/lib/image-resize.js'; +import { transformRgba } from '../src/lib/image-transform.js'; const { values, positionals } = parseArgs({ args: process.argv.slice(2), @@ -25,12 +26,15 @@ const { values, positionals } = parseArgs({ width: { type: 'string' }, height: { type: 'string' }, method: { type: 'string', default: 'nearest' }, // nearest | bilinear | box + 'flip-h': { type: 'boolean', default: false }, + 'flip-v': { type: 'boolean', default: false }, + rotate: { type: 'string', default: '0' }, // 0 | 90 | 180 | 270 (CW) }, allowPositionals: true, }); if (positionals.length < 1) { - console.error('Usage: node scripts/image-to-colors.js [-o output.json] [--alpha-threshold 128] [--dither] [--width N] [--height N] [--method nearest|bilinear|box]'); + console.error('Usage: node scripts/image-to-colors.js [-o output.json] [--alpha-threshold 128] [--dither] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270]'); process.exit(1); } @@ -48,9 +52,21 @@ const { data, info } = await sharp(input) .raw() .toBuffer({ resolveWithObject: true }); -const { width: srcW, height: srcH } = info; +const { width: rawW, height: rawH } = info; -// Optional resize — defaults to source dims. Aspect-preserve when only one of W/H given. +const rotation = parseInt(values.rotate, 10); +if (![0, 90, 180, 270].includes(rotation)) { + console.error('--rotate must be 0, 90, 180, or 270'); + process.exit(1); +} + +const transformed = (values['flip-h'] || values['flip-v'] || rotation !== 0) + ? transformRgba(data, rawW, rawH, { flipH: values['flip-h'], flipV: values['flip-v'], rotation }) + : { rgba: data, width: rawW, height: rawH }; +const srcW = transformed.width; +const srcH = transformed.height; + +// Optional resize — defaults to post-transform dims. Aspect-preserve when only one of W/H given. const reqW = values.width ? parseInt(values.width, 10) : null; const reqH = values.height ? parseInt(values.height, 10) : null; let outW = reqW ?? (reqH ? Math.round(srcW * reqH / srcH) : srcW); @@ -64,8 +80,8 @@ if (outW > CANVAS_WIDTH || outH > CANVAS_HEIGHT) { } const working = (outW === srcW && outH === srcH) - ? data - : resizeRgba(data, srcW, srcH, outW, outH, values.method); + ? transformed.rgba + : resizeRgba(transformed.rgba, srcW, srcH, outW, outH, values.method); // sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData. const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, dither: values.dither }); @@ -73,5 +89,8 @@ const pixels = Array.from(indices); const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0); await writeFile(output, JSON.stringify({ width: outW, height: outH, pixels })); +const transformNote = (values['flip-h'] || values['flip-v'] || rotation !== 0) + ? ` [transform: ${[values['flip-h'] && 'flipH', values['flip-v'] && 'flipV', rotation !== 0 && `rot${rotation}`].filter(Boolean).join('+')}]` + : ''; const resizeNote = (outW !== srcW || outH !== srcH) ? ` (resized from ${srcW}x${srcH} via ${values.method})` : ''; -console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`); +console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`); diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte index 8c03426..aa770f1 100644 --- a/src/client/components/ImageImporter.svelte +++ b/src/client/components/ImageImporter.svelte @@ -2,6 +2,7 @@ import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js'; import { rgbaToPalette, paletteToRgba } from '../../lib/image-to-palette.js'; import { resizeRgba } from '../../lib/image-resize.js'; + import { transformRgba } from '../../lib/image-transform.js'; import { createImageUploader } from '../../lib/image-uploader.js'; let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props(); @@ -35,6 +36,11 @@ let showOverlay = $state(true); let overlayAlpha = $state(0.6); + // Transforms + let flipH = $state(false); + let flipV = $state(false); + let rotation = $state(0); // 0 | 90 | 180 | 270 + // Run state let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error' let placed = $state(0); @@ -79,15 +85,19 @@ } } - // Re-run pipeline when source / resize / dither / method changes. + // Re-run pipeline when any relevant input changes. + // Order: transform → resize → palette. $effect(() => { if (!srcRgba || resizeW <= 0 || resizeH <= 0) { paletteIndices = null; opaqueCount = 0; return; } - const working = (resizeW === srcWidth && resizeH === srcHeight) - ? srcRgba - : resizeRgba(srcRgba, srcWidth, srcHeight, resizeW, resizeH, resampleMethod); - const idx = rgbaToPalette(working, resizeW, resizeH, { dither }); + const transformed = (flipH || flipV || rotation !== 0) + ? transformRgba(srcRgba, srcWidth, srcHeight, { flipH, flipV, rotation }) + : { rgba: srcRgba, width: srcWidth, height: srcHeight }; + const resized = (resizeW === transformed.width && resizeH === transformed.height) + ? transformed.rgba + : resizeRgba(transformed.rgba, transformed.width, transformed.height, resizeW, resizeH, resampleMethod); + const idx = rgbaToPalette(resized, resizeW, resizeH, { dither }); paletteIndices = idx; let count = 0; for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++; @@ -129,8 +139,25 @@ resizeH = Math.max(1, Math.floor(srcHeight * ratio)); } function resetSize() { - resizeW = srcWidth; - resizeH = srcHeight; + // Reset to post-transform source dims so resize stays consistent with the preview. + const swap = rotation === 90 || rotation === 270; + resizeW = swap ? srcHeight : srcWidth; + resizeH = swap ? srcWidth : srcHeight; + } + + function rotateBy(deltaCW) { + rotation = ((rotation + deltaCW) % 360 + 360) % 360; + // Swap resize dims so the output aspect tracks the rotation. + const tmp = resizeW; resizeW = resizeH; resizeH = tmp; + } + function resetTransforms() { + flipH = false; + flipV = false; + if (rotation !== 0) { + const swap = rotation === 90 || rotation === 270; + if (swap) { const tmp = resizeW; resizeW = resizeH; resizeH = tmp; } + rotation = 0; + } } $effect(() => { if (open && paletteIndices) renderPreview(); }); @@ -282,7 +309,17 @@ - + + + +