From fc0121fd48f1147a5d93cd9f55174312cddb035d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 17 Apr 2026 11:49:54 +0700 Subject: [PATCH] =?UTF-8?q?feat(importer):=20phase=203=20=E2=80=94=20flip?= =?UTF-8?q?=20/=20rotate=20transforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function transformRgba in src/lib/image-transform.js supports flip H, flip V, and rotation in 90° steps (fixed application order: rotate → flipH → flipV). Wired into the importer pipeline before resize with buttons for each op; ±90° rotations auto-swap the resize dims so output aspect tracks the rotation. CLI gains --flip-h, --flip-v, --rotate flags using the same shared module. 8 new unit tests cover identity, each flip, 90/180/270 rotation, the flipH+flipV ≡ 180° identity, and invalid rotation rejection. --- .../phase-03-transforms.md | 10 +- .../plan.md | 2 +- scripts/image-to-colors.js | 31 ++++-- src/client/components/ImageImporter.svelte | 64 +++++++++++-- src/lib/image-transform.js | 88 +++++++++++++++++ test/lib/image-transform.test.js | 96 +++++++++++++++++++ 6 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 src/lib/image-transform.js create mode 100644 test/lib/image-transform.test.js 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 @@ - + + + +
+ transform + + + + + {rotation}° +
@@ -376,6 +413,17 @@ input[type="number"] { width: 70px; padding: 4px 6px; background: #1a1a1a; border: 1px solid #444; color: #eee; border-radius: 4px; } select { padding: 4px 6px; background: #1a1a1a; border: 1px solid #444; color: #eee; border-radius: 4px; } .chk { font-size: 0.8rem; color: #aaa; cursor: pointer; } + .lbl { font-size: 0.8rem; color: #888; min-width: 56px; } + .rot-label { font-size: 0.8rem; color: #aaa; min-width: 32px; text-align: center; } + + .row button { + padding: 4px 10px; background: #262626; color: #ddd; + border: 1px solid #444; border-radius: 4px; cursor: pointer; + font-size: 0.85rem; + } + .row button:hover:not(:disabled) { background: #333; } + .row button:disabled { opacity: 0.35; cursor: default; } + .row button.active { background: #2d4d78; border-color: #3b6ba8; color: #fff; } .file-btn { display: inline-block; padding: 6px 12px; background: #2563eb; border-radius: 6px; diff --git a/src/lib/image-transform.js b/src/lib/image-transform.js new file mode 100644 index 0000000..4a6350d --- /dev/null +++ b/src/lib/image-transform.js @@ -0,0 +1,88 @@ +/** + * Pure RGBA transforms: flip horizontal/vertical and rotate in 90° steps. + * All functions return a fresh Uint8ClampedArray; rotations by 90/270 swap dims. + * + * Composition order in `transformRgba`: rotation → flipH → flipV. + * (Order is fixed so state is well-defined regardless of UI click sequence.) + */ + +/** + * @param {Uint8Array|Uint8ClampedArray|number[]} rgba + * @param {number} width + * @param {number} height + * @param {{flipH?: boolean, flipV?: boolean, rotation?: 0|90|180|270}} [opts] + * @returns {{rgba: Uint8ClampedArray, width: number, height: number}} + */ +export function transformRgba(rgba, width, height, opts = {}) { + const { flipH = false, flipV = false, rotation = 0 } = opts; + if (rotation !== 0 && rotation !== 90 && rotation !== 180 && rotation !== 270) { + throw new Error(`Unsupported rotation: ${rotation}. Use 0, 90, 180, or 270.`); + } + + let cur = rgba, w = width, h = height; + let owned = false; // true once we produced a fresh buffer and no longer alias the input + + if (rotation !== 0) { + const r = rotate(cur, w, h, rotation); + cur = r.rgba; w = r.width; h = r.height; owned = true; + } + if (flipH) { cur = flipHorizontal(cur, w, h); owned = true; } + if (flipV) { cur = flipVertical(cur, w, h); owned = true; } + if (!owned) cur = new Uint8ClampedArray(cur); // identity: still return a fresh buffer + return { rgba: cur, width: w, height: h }; +} + +function rotate(src, w, h, degrees) { + if (degrees === 180) { + const out = new Uint8ClampedArray(src.length); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const si = (y * w + x) * 4; + const di = ((h - 1 - y) * w + (w - 1 - x)) * 4; + out[di] = src[si]; out[di + 1] = src[si + 1]; + out[di + 2] = src[si + 2]; out[di + 3] = src[si + 3]; + } + } + return { rgba: out, width: w, height: h }; + } + // 90 CW or 270 CW — dst dims are h × w. + const dstW = h, dstH = w; + const out = new Uint8ClampedArray(dstW * dstH * 4); + for (let ny = 0; ny < dstH; ny++) { + for (let nx = 0; nx < dstW; nx++) { + const ox = degrees === 90 ? ny : (dstH - 1 - ny); + const oy = degrees === 90 ? (h - 1 - nx) : nx; + const si = (oy * w + ox) * 4; + const di = (ny * dstW + nx) * 4; + out[di] = src[si]; out[di + 1] = src[si + 1]; + out[di + 2] = src[si + 2]; out[di + 3] = src[si + 3]; + } + } + return { rgba: out, width: dstW, height: dstH }; +} + +function flipHorizontal(src, w, h) { + const out = new Uint8ClampedArray(src.length); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const si = (y * w + x) * 4; + const di = (y * w + (w - 1 - x)) * 4; + out[di] = src[si]; out[di + 1] = src[si + 1]; + out[di + 2] = src[si + 2]; out[di + 3] = src[si + 3]; + } + } + return out; +} + +function flipVertical(src, w, h) { + const out = new Uint8ClampedArray(src.length); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const si = (y * w + x) * 4; + const di = ((h - 1 - y) * w + x) * 4; + out[di] = src[si]; out[di + 1] = src[si + 1]; + out[di + 2] = src[si + 2]; out[di + 3] = src[si + 3]; + } + } + return out; +} diff --git a/test/lib/image-transform.test.js b/test/lib/image-transform.test.js new file mode 100644 index 0000000..973c5c0 --- /dev/null +++ b/test/lib/image-transform.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { transformRgba } from '../../src/lib/image-transform.js'; + +/** Build an RGBA buffer where pixel (x, y) has red = (y*w+x+1)*10, green = y, blue = x. + * Unique per-pixel so we can assert exact placement after transforms. */ +function build(w, h) { + const out = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 4; + out[i] = (y * w + x + 1) * 10; + out[i + 1] = y; + out[i + 2] = x; + out[i + 3] = 255; + } + } + return out; +} + +function pixel(rgba, w, x, y) { + const i = (y * w + x) * 4; + return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; +} + +describe('transformRgba', () => { + it('identity (no flips, rotation=0) returns input copy', () => { + const src = build(2, 3); + const { rgba, width, height } = transformRgba(src, 2, 3); + expect(width).toBe(2); + expect(height).toBe(3); + expect(rgba).toEqual(src); + expect(rgba).not.toBe(src); + }); + + it('flipH mirrors columns', () => { + const src = build(3, 2); + const { rgba, width, height } = transformRgba(src, 3, 2, { flipH: true }); + expect(width).toBe(3); + expect(height).toBe(2); + // Row 0: src cols 0,1,2 → dst cols 2,1,0 + expect(pixel(rgba, 3, 0, 0)).toEqual(pixel(src, 3, 2, 0)); + expect(pixel(rgba, 3, 2, 0)).toEqual(pixel(src, 3, 0, 0)); + }); + + it('flipV mirrors rows', () => { + const src = build(2, 3); + const { rgba } = transformRgba(src, 2, 3, { flipV: true }); + expect(pixel(rgba, 2, 0, 0)).toEqual(pixel(src, 2, 0, 2)); + expect(pixel(rgba, 2, 0, 2)).toEqual(pixel(src, 2, 0, 0)); + }); + + it('rotate 90° CW swaps dims and maps top-left → top-right', () => { + const src = build(2, 3); // 2 wide, 3 tall + const { rgba, width, height } = transformRgba(src, 2, 3, { rotation: 90 }); + expect(width).toBe(3); // h + expect(height).toBe(2); // w + // src (0,0) is top-left → dst top-right, i.e. (width-1, 0) = (2, 0) + expect(pixel(rgba, 3, 2, 0)).toEqual(pixel(src, 2, 0, 0)); + // src (1, 0) top-right → dst bottom-right (2, 1) + expect(pixel(rgba, 3, 2, 1)).toEqual(pixel(src, 2, 1, 0)); + // src (0, 2) bottom-left → dst top-left (0, 0) + expect(pixel(rgba, 3, 0, 0)).toEqual(pixel(src, 2, 0, 2)); + }); + + it('rotate 180° flips both axes', () => { + const src = build(2, 3); + const { rgba, width, height } = transformRgba(src, 2, 3, { rotation: 180 }); + expect(width).toBe(2); + expect(height).toBe(3); + expect(pixel(rgba, 2, 0, 0)).toEqual(pixel(src, 2, 1, 2)); + expect(pixel(rgba, 2, 1, 2)).toEqual(pixel(src, 2, 0, 0)); + }); + + it('rotate 270° CW is inverse of 90° CW', () => { + const src = build(2, 3); + const r90 = transformRgba(src, 2, 3, { rotation: 90 }); + const back = transformRgba(r90.rgba, r90.width, r90.height, { rotation: 270 }); + expect(back.width).toBe(2); + expect(back.height).toBe(3); + expect(back.rgba).toEqual(new Uint8ClampedArray(src)); + }); + + it('flipH + flipV equals 180° rotation (for square or rectangular)', () => { + const src = build(3, 2); + const flipped = transformRgba(src, 3, 2, { flipH: true, flipV: true }); + const rotated = transformRgba(src, 3, 2, { rotation: 180 }); + expect(flipped.rgba).toEqual(rotated.rgba); + expect(flipped.width).toBe(rotated.width); + expect(flipped.height).toBe(rotated.height); + }); + + it('throws on unsupported rotation', () => { + const src = build(2, 2); + expect(() => transformRgba(src, 2, 2, { rotation: 45 })).toThrow(); + }); +});