mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-15 04:18:56 +00:00
feat(importer): phase 3 — flip / rotate transforms
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.
This commit is contained in:
1 parent
75c1023554
commit
fc0121fd48
6 files changed
+271
-20
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 <input> [-o output.json] [--alpha-threshold 128] [--dither] [--width N] [--height N] [--method nearest|bilinear|box]');
|
||||
console.error('Usage: node scripts/image-to-colors.js <input> [-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]' : ''}`);
|
||||
@@ -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 @@
|
||||
</select>
|
||||
</label>
|
||||
<button onclick={fitToCanvas} title="Fit within canvas at current origin">Fit</button>
|
||||
<button onclick={resetSize} title="Reset to source dimensions">1:1</button>
|
||||
<button onclick={resetSize} title="Reset to post-transform source dimensions">1:1</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="lbl">transform</span>
|
||||
<button onclick={() => flipH = !flipH} class:active={flipH} title="Flip horizontally">⇆</button>
|
||||
<button onclick={() => flipV = !flipV} class:active={flipV} title="Flip vertically">⇅</button>
|
||||
<button onclick={() => rotateBy(-90)} title="Rotate 90° counter-clockwise">⟲</button>
|
||||
<button onclick={() => rotateBy(90)} title="Rotate 90° clockwise">⟳</button>
|
||||
<span class="rot-label">{rotation}°</span>
|
||||
<button onclick={resetTransforms} title="Reset transforms" disabled={!flipH && !flipV && rotation === 0}>reset</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user