From d4df2b1f04e0731c3fd7af5c8095de350b377011 Mon Sep 17 00:00:00 2001 From: Flow-Fly <44248262+Flow-Fly@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:36:26 +0200 Subject: [PATCH 01/15] Run integration CI on develop (#302) * Run integration CI on develop * build(deps-dev): bump ws from 8.20.0 to 8.21.0 (#298) Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.21.0. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.20.0...8.21.0) --- updated-dependencies: - dependency-name: ws dependency-version: 8.21.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- package-lock.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02ef510..3225305 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main, wip] + branches: [main, develop] permissions: contents: read diff --git a/package-lock.json b/package-lock.json index aacda6f..d0297fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3162,9 +3162,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { From c36e834ef28aaf6e4deeedd0174c056d1720b9de Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 11:14:32 +0200 Subject: [PATCH 02/15] Add deterministic guide image sampling (#304) --- src/services/paint-by-number/image-file.ts | 18 +++ .../paint-by-number/image-sampling.ts | 145 ++++++++++++++++++ .../paint-by-number/image-sampling.test.ts | 120 +++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 src/services/paint-by-number/image-file.ts create mode 100644 src/services/paint-by-number/image-sampling.ts create mode 100644 tests/services/paint-by-number/image-sampling.test.ts diff --git a/src/services/paint-by-number/image-file.ts b/src/services/paint-by-number/image-file.ts new file mode 100644 index 0000000..33bab16 --- /dev/null +++ b/src/services/paint-by-number/image-file.ts @@ -0,0 +1,18 @@ +export async function decodeImageFile(file: File): Promise { + const bitmap = await createImageBitmap(file); + + try { + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) { + throw new Error('Could not create an image decoding canvas'); + } + + context.drawImage(bitmap, 0, 0); + return context.getImageData(0, 0, bitmap.width, bitmap.height); + } finally { + bitmap.close(); + } +} diff --git a/src/services/paint-by-number/image-sampling.ts b/src/services/paint-by-number/image-sampling.ts new file mode 100644 index 0000000..7495feb --- /dev/null +++ b/src/services/paint-by-number/image-sampling.ts @@ -0,0 +1,145 @@ +const DEFAULT_GUIDE_ALPHA_THRESHOLD = 128; + +export interface TargetGridSize { + width: number; + height: number; +} + +export interface SampleImageOptions { + longSide: number; + alphaThreshold?: number; +} + +export function getTargetGridSize( + sourceWidth: number, + sourceHeight: number, + longSide: number, +): TargetGridSize { + const safeWidth = requirePositiveInteger(sourceWidth, 'sourceWidth'); + const safeHeight = requirePositiveInteger(sourceHeight, 'sourceHeight'); + const safeLongSide = requirePositiveInteger(longSide, 'longSide'); + + if (safeWidth >= safeHeight) { + return { + width: safeLongSide, + height: Math.max(1, Math.round((safeHeight / safeWidth) * safeLongSide)), + }; + } + + return { + width: Math.max(1, Math.round((safeWidth / safeHeight) * safeLongSide)), + height: safeLongSide, + }; +} + +export function sampleImageToGrid( + source: ImageData, + options: SampleImageOptions, +): ImageData { + const target = getTargetGridSize(source.width, source.height, options.longSide); + const alphaThreshold = clampByte( + options.alphaThreshold ?? DEFAULT_GUIDE_ALPHA_THRESHOLD, + ); + const result = new ImageData(target.width, target.height); + const scaleX = source.width / target.width; + const scaleY = source.height / target.height; + + for (let targetY = 0; targetY < target.height; targetY += 1) { + const sourceTop = targetY * scaleY; + const sourceBottom = (targetY + 1) * scaleY; + + for (let targetX = 0; targetX < target.width; targetX += 1) { + const sourceLeft = targetX * scaleX; + const sourceRight = (targetX + 1) * scaleX; + const sample = sampleSourceRegion( + source, + sourceLeft, + sourceTop, + sourceRight, + sourceBottom, + ); + const targetIndex = (targetY * target.width + targetX) * 4; + + if (sample.alpha < alphaThreshold) { + continue; + } + + result.data[targetIndex] = sample.red; + result.data[targetIndex + 1] = sample.green; + result.data[targetIndex + 2] = sample.blue; + result.data[targetIndex + 3] = 255; + } + } + + return result; +} + +interface SampledColor { + red: number; + green: number; + blue: number; + alpha: number; +} + +function sampleSourceRegion( + source: ImageData, + left: number, + top: number, + right: number, + bottom: number, +): SampledColor { + let red = 0; + let green = 0; + let blue = 0; + let alpha = 0; + let opaqueWeight = 0; + let regionWeight = 0; + + const startX = Math.floor(left); + const endX = Math.min(Math.ceil(right), source.width); + const startY = Math.floor(top); + const endY = Math.min(Math.ceil(bottom), source.height); + + for (let sourceY = startY; sourceY < endY; sourceY += 1) { + const overlapY = Math.min(sourceY + 1, bottom) - Math.max(sourceY, top); + + for (let sourceX = startX; sourceX < endX; sourceX += 1) { + const overlapX = Math.min(sourceX + 1, right) - Math.max(sourceX, left); + const pixelWeight = overlapX * overlapY; + const sourceIndex = (sourceY * source.width + sourceX) * 4; + const sourceAlpha = source.data[sourceIndex + 3] / 255; + const colorWeight = pixelWeight * sourceAlpha; + + red += source.data[sourceIndex] * colorWeight; + green += source.data[sourceIndex + 1] * colorWeight; + blue += source.data[sourceIndex + 2] * colorWeight; + alpha += source.data[sourceIndex + 3] * pixelWeight; + opaqueWeight += colorWeight; + regionWeight += pixelWeight; + } + } + + if (regionWeight === 0 || opaqueWeight === 0) { + return { red: 0, green: 0, blue: 0, alpha: 0 }; + } + + return { + red: Math.round(red / opaqueWeight), + green: Math.round(green / opaqueWeight), + blue: Math.round(blue / opaqueWeight), + alpha: Math.round(alpha / regionWeight), + }; +} + +function requirePositiveInteger(value: number, name: string): number { + if (!Number.isFinite(value) || value < 1) { + throw new RangeError(`${name} must be at least 1`); + } + + return Math.round(value); +} + +function clampByte(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_GUIDE_ALPHA_THRESHOLD; + return Math.max(0, Math.min(255, Math.round(value))); +} diff --git a/tests/services/paint-by-number/image-sampling.test.ts b/tests/services/paint-by-number/image-sampling.test.ts new file mode 100644 index 0000000..a218a63 --- /dev/null +++ b/tests/services/paint-by-number/image-sampling.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getTargetGridSize, + sampleImageToGrid, +} from '../../../src/services/paint-by-number/image-sampling'; + +class FakeImageData { + readonly data: Uint8ClampedArray; + readonly width: number; + readonly height: number; + + constructor(width: number, height: number); + constructor(data: Uint8ClampedArray, width: number, height: number); + constructor( + dataOrWidth: Uint8ClampedArray | number, + widthOrHeight: number, + maybeHeight?: number, + ) { + if (typeof dataOrWidth === 'number') { + this.width = dataOrWidth; + this.height = widthOrHeight; + this.data = new Uint8ClampedArray(this.width * this.height * 4); + return; + } + + this.data = dataOrWidth; + this.width = widthOrHeight; + this.height = maybeHeight ?? 0; + } +} + +vi.stubGlobal('ImageData', FakeImageData); + +function imageData(width: number, height: number, pixels: number[][]): ImageData { + return new ImageData( + new Uint8ClampedArray(pixels.flat()), + width, + height, + ); +} + +describe('getTargetGridSize', () => { + it('preserves landscape, portrait, and square aspect ratios', () => { + expect(getTargetGridSize(8, 4, 4)).toEqual({ width: 4, height: 2 }); + expect(getTargetGridSize(4, 8, 4)).toEqual({ width: 2, height: 4 }); + expect(getTargetGridSize(5, 5, 3)).toEqual({ width: 3, height: 3 }); + }); + + it('keeps the shorter side at least one cell', () => { + expect(getTargetGridSize(100, 1, 8)).toEqual({ width: 8, height: 1 }); + }); + + it('rejects invalid dimensions', () => { + expect(() => getTargetGridSize(0, 1, 1)).toThrow(RangeError); + expect(() => getTargetGridSize(1, 1, 0)).toThrow(RangeError); + }); +}); + +describe('sampleImageToGrid', () => { + it('averages source regions into target cells', () => { + const source = imageData(4, 2, [ + [255, 0, 0, 255], + [0, 0, 255, 255], + [0, 255, 0, 255], + [0, 255, 0, 255], + [255, 0, 0, 255], + [0, 0, 255, 255], + [0, 255, 0, 255], + [0, 255, 0, 255], + ]); + + const result = sampleImageToGrid(source, { longSide: 2 }); + + expect([result.width, result.height]).toEqual([2, 1]); + expect([...result.data]).toEqual([ + 128, 0, 128, 255, + 0, 255, 0, 255, + ]); + }); + + it('supports non-integer downscale regions deterministically', () => { + const source = imageData(3, 1, [ + [0, 0, 0, 255], + [120, 120, 120, 255], + [240, 240, 240, 255], + ]); + + const first = sampleImageToGrid(source, { longSide: 2 }); + const second = sampleImageToGrid(source, { longSide: 2 }); + + expect([...first.data]).toEqual([40, 40, 40, 255, 200, 200, 200, 255]); + expect([...second.data]).toEqual([...first.data]); + }); + + it('uses premultiplied color averaging and hard transparency', () => { + const source = imageData(2, 1, [ + [255, 0, 0, 255], + [0, 0, 255, 0], + ]); + + const visible = sampleImageToGrid(source, { + longSide: 1, + alphaThreshold: 127, + }); + const transparent = sampleImageToGrid(source, { + longSide: 1, + alphaThreshold: 129, + }); + + expect([...visible.data]).toEqual([255, 0, 0, 255]); + expect([...transparent.data]).toEqual([0, 0, 0, 0]); + }); + + it('copies a one-pixel input', () => { + const source = imageData(1, 1, [[12, 34, 56, 255]]); + const result = sampleImageToGrid(source, { longSide: 1 }); + + expect([...result.data]).toEqual([12, 34, 56, 255]); + }); +}); From 0171bc5e4b1922c26b1910de551cfc7b19af3b9d Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 11:17:14 +0200 Subject: [PATCH 03/15] Generate bounded numbered guides (#305) --- .../paint-by-number/guide-generator.ts | 253 ++++++++++++++++++ .../paint-by-number/guide-generator.test.ts | 111 ++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/services/paint-by-number/guide-generator.ts create mode 100644 tests/services/paint-by-number/guide-generator.test.ts diff --git a/src/services/paint-by-number/guide-generator.ts b/src/services/paint-by-number/guide-generator.ts new file mode 100644 index 0000000..467b2a2 --- /dev/null +++ b/src/services/paint-by-number/guide-generator.ts @@ -0,0 +1,253 @@ +import { rgbToHex, type RGB } from '../../stores/palette/color-utils'; + +export const MAX_GUIDE_COLORS = 255; +export const DEFAULT_GUIDE_COLOR_COUNT = 12; + +export interface NumberedGuide { + palette: string[]; + target: Uint8Array; + width: number; + height: number; +} + +interface ColorBox { + pixels: RGB[]; + redRange: number; + greenRange: number; + blueRange: number; +} + +export function generateNumberedGuide( + sampledImage: ImageData, + maxColors: number = DEFAULT_GUIDE_COLOR_COUNT, +): NumberedGuide { + const colorLimit = clampColorCount(maxColors); + const pixels = collectOpaquePixels(sampledImage); + const palette = buildGeneratedPalette(pixels, colorLimit); + const target = mapImageToPalette(sampledImage, palette); + + return { + palette: palette.map((color) => rgbToHex(color.r, color.g, color.b)), + target, + width: sampledImage.width, + height: sampledImage.height, + }; +} + +export function perceptualColorDistance(left: RGB, right: RGB): number { + const redMean = (left.r + right.r) / 2; + const red = left.r - right.r; + const green = left.g - right.g; + const blue = left.b - right.b; + + return Math.sqrt( + (2 + redMean / 256) * red * red + + 4 * green * green + + (2 + (255 - redMean) / 256) * blue * blue, + ); +} + +function collectOpaquePixels(image: ImageData): RGB[] { + const pixels: RGB[] = []; + + for (let index = 0; index < image.data.length; index += 4) { + if (image.data[index + 3] < 128) continue; + + pixels.push({ + r: image.data[index], + g: image.data[index + 1], + b: image.data[index + 2], + }); + } + + return pixels; +} + +function buildGeneratedPalette(pixels: RGB[], colorLimit: number): RGB[] { + if (pixels.length === 0) return []; + + const exactColors = getExactColors(pixels); + if (exactColors.length <= colorLimit) { + return sortPalette(exactColors); + } + + const boxes: ColorBox[] = [createColorBox(pixels)]; + + while (boxes.length < colorLimit) { + const boxIndex = findBoxToSplit(boxes); + if (boxIndex === -1) break; + + const [box] = boxes.splice(boxIndex, 1); + const split = splitColorBox(box); + if (!split) { + boxes.push(box); + break; + } + + boxes.push(...split); + } + + return sortPalette(dedupeColors(boxes.map(averageBoxColor))); +} + +function getExactColors(pixels: RGB[]): RGB[] { + const colors = new Map(); + + for (const pixel of pixels) { + const key = `${pixel.r},${pixel.g},${pixel.b}`; + if (!colors.has(key)) colors.set(key, pixel); + } + + return [...colors.values()]; +} + +function createColorBox(pixels: RGB[]): ColorBox { + let minRed = 255; + let minGreen = 255; + let minBlue = 255; + let maxRed = 0; + let maxGreen = 0; + let maxBlue = 0; + + for (const pixel of pixels) { + minRed = Math.min(minRed, pixel.r); + minGreen = Math.min(minGreen, pixel.g); + minBlue = Math.min(minBlue, pixel.b); + maxRed = Math.max(maxRed, pixel.r); + maxGreen = Math.max(maxGreen, pixel.g); + maxBlue = Math.max(maxBlue, pixel.b); + } + + return { + pixels, + redRange: maxRed - minRed, + greenRange: maxGreen - minGreen, + blueRange: maxBlue - minBlue, + }; +} + +function findBoxToSplit(boxes: ColorBox[]): number { + let bestIndex = -1; + let bestScore = -1; + + for (let index = 0; index < boxes.length; index += 1) { + const box = boxes[index]; + if (box.pixels.length < 2) continue; + + const largestRange = Math.max(box.redRange, box.greenRange, box.blueRange); + const score = largestRange * box.pixels.length; + if (score > bestScore) { + bestIndex = index; + bestScore = score; + } + } + + return bestIndex; +} + +function splitColorBox(box: ColorBox): [ColorBox, ColorBox] | null { + const channel = getSplitChannel(box); + const sortedPixels = [...box.pixels].sort((left, right) => { + const difference = left[channel] - right[channel]; + if (difference !== 0) return difference; + if (left.r !== right.r) return left.r - right.r; + if (left.g !== right.g) return left.g - right.g; + return left.b - right.b; + }); + const splitIndex = Math.floor(sortedPixels.length / 2); + if (splitIndex === 0 || splitIndex === sortedPixels.length) return null; + + return [ + createColorBox(sortedPixels.slice(0, splitIndex)), + createColorBox(sortedPixels.slice(splitIndex)), + ]; +} + +function getSplitChannel(box: ColorBox): keyof RGB { + if (box.redRange >= box.greenRange && box.redRange >= box.blueRange) { + return 'r'; + } + if (box.greenRange >= box.blueRange) return 'g'; + return 'b'; +} + +function averageBoxColor(box: ColorBox): RGB { + let red = 0; + let green = 0; + let blue = 0; + + for (const pixel of box.pixels) { + red += pixel.r; + green += pixel.g; + blue += pixel.b; + } + + return { + r: Math.round(red / box.pixels.length), + g: Math.round(green / box.pixels.length), + b: Math.round(blue / box.pixels.length), + }; +} + +function dedupeColors(colors: RGB[]): RGB[] { + const unique = new Map(); + + for (const color of colors) { + unique.set(`${color.r},${color.g},${color.b}`, color); + } + + return [...unique.values()]; +} + +function sortPalette(colors: RGB[]): RGB[] { + return [...colors].sort((left, right) => { + const luminanceDifference = relativeLuminance(left) - relativeLuminance(right); + if (luminanceDifference !== 0) return luminanceDifference; + if (left.r !== right.r) return left.r - right.r; + if (left.g !== right.g) return left.g - right.g; + return left.b - right.b; + }); +} + +function relativeLuminance(color: RGB): number { + return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; +} + +function mapImageToPalette(image: ImageData, palette: RGB[]): Uint8Array { + const target = new Uint8Array(image.width * image.height); + if (palette.length === 0) return target; + + for (let pixelIndex = 0; pixelIndex < target.length; pixelIndex += 1) { + const dataIndex = pixelIndex * 4; + if (image.data[dataIndex + 3] < 128) continue; + + const color = { + r: image.data[dataIndex], + g: image.data[dataIndex + 1], + b: image.data[dataIndex + 2], + }; + target[pixelIndex] = findClosestPaletteIndex(color, palette); + } + + return target; +} + +function findClosestPaletteIndex(color: RGB, palette: RGB[]): number { + let closestIndex = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + for (let index = 0; index < palette.length; index += 1) { + const distance = perceptualColorDistance(color, palette[index]); + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + } + + return closestIndex + 1; +} + +function clampColorCount(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_GUIDE_COLOR_COUNT; + return Math.max(1, Math.min(MAX_GUIDE_COLORS, Math.round(value))); +} diff --git a/tests/services/paint-by-number/guide-generator.test.ts b/tests/services/paint-by-number/guide-generator.test.ts new file mode 100644 index 0000000..7d9abd4 --- /dev/null +++ b/tests/services/paint-by-number/guide-generator.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + generateNumberedGuide, + perceptualColorDistance, +} from '../../../src/services/paint-by-number/guide-generator'; + +class FakeImageData { + readonly data: Uint8ClampedArray; + readonly width: number; + readonly height: number; + + constructor(data: Uint8ClampedArray, width: number, height: number) { + this.data = data; + this.width = width; + this.height = height; + } +} + +vi.stubGlobal('ImageData', FakeImageData); + +function imageData(width: number, height: number, pixels: number[][]): ImageData { + return new ImageData(new Uint8ClampedArray(pixels.flat()), width, height); +} + +describe('generateNumberedGuide', () => { + it('keeps exact colors when they fit the requested limit', () => { + const image = imageData(3, 1, [ + [255, 0, 0, 255], + [0, 0, 255, 255], + [255, 0, 0, 255], + ]); + + const guide = generateNumberedGuide(image, 2); + + expect(guide.palette).toEqual(['#0000ff', '#ff0000']); + expect([...guide.target]).toEqual([2, 1, 2]); + expect([guide.width, guide.height]).toEqual([3, 1]); + }); + + it('maps transparent cells to zero', () => { + const image = imageData(2, 1, [ + [12, 34, 56, 0], + [12, 34, 56, 255], + ]); + + const guide = generateNumberedGuide(image, 4); + + expect(guide.palette).toEqual(['#0c2238']); + expect([...guide.target]).toEqual([0, 1]); + }); + + it('never exceeds the requested palette size', () => { + const image = imageData(6, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [255, 255, 0, 255], + [255, 0, 255, 255], + [0, 255, 255, 255], + ]); + + const guide = generateNumberedGuide(image, 3); + + expect(guide.palette).toHaveLength(3); + expect([...guide.target].every((index) => index >= 1 && index <= 3)).toBe(true); + }); + + it('produces stable palette and target fixtures', () => { + const image = imageData(4, 2, [ + [5, 5, 8, 255], + [20, 15, 25, 255], + [220, 40, 30, 255], + [250, 80, 35, 255], + [10, 120, 200, 255], + [20, 150, 240, 255], + [230, 210, 40, 255], + [250, 240, 90, 255], + ]); + + const first = generateNumberedGuide(image, 4); + const second = generateNumberedGuide(image, 4); + + expect(first.palette).toEqual(['#0d0a11', '#eb3c21', '#0f87dc', '#f0e141']); + expect([...first.target]).toEqual([1, 1, 2, 2, 3, 3, 4, 4]); + expect(second.palette).toEqual(first.palette); + expect([...second.target]).toEqual([...first.target]); + }); + + it('returns an empty palette for a fully transparent image', () => { + const image = imageData(1, 1, [[0, 0, 0, 0]]); + + expect(generateNumberedGuide(image, 4)).toEqual({ + palette: [], + target: new Uint8Array([0]), + width: 1, + height: 1, + }); + }); +}); + +describe('perceptualColorDistance', () => { + it('is zero for equal colors and symmetric for different colors', () => { + const red = { r: 255, g: 0, b: 0 }; + const blue = { r: 0, g: 0, b: 255 }; + + expect(perceptualColorDistance(red, red)).toBe(0); + expect(perceptualColorDistance(red, blue)).toBe( + perceptualColorDistance(blue, red), + ); + }); +}); From 113dcbead6e038c477426b3b0a29ab043168c517 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 11:19:06 +0200 Subject: [PATCH 04/15] Add restrictive guide palette options (#306) --- .../paint-by-number/guide-generator.ts | 207 +++++++++++++++++- .../paint-by-number/guide-generator.test.ts | 61 +++++- 2 files changed, 255 insertions(+), 13 deletions(-) diff --git a/src/services/paint-by-number/guide-generator.ts b/src/services/paint-by-number/guide-generator.ts index 467b2a2..eaab4a3 100644 --- a/src/services/paint-by-number/guide-generator.ts +++ b/src/services/paint-by-number/guide-generator.ts @@ -1,13 +1,35 @@ -import { rgbToHex, type RGB } from '../../stores/palette/color-utils'; +import { + hexToRgb, + normalizeHex, + rgbToHex, + type RGB, +} from '../../stores/palette/color-utils'; + +const MAX_GUIDE_COLORS = 255; +const DEFAULT_GUIDE_COLOR_COUNT = 12; + +export type GuideColorMapping = 'color' | 'luminance'; + +export interface GuideGenerationOptions { + maxColors?: number; + palette?: string[]; + mapping?: GuideColorMapping; + simplifyIsolatedPixels?: boolean; +} -export const MAX_GUIDE_COLORS = 255; -export const DEFAULT_GUIDE_COLOR_COUNT = 12; +export interface GuideComplexity { + paintableCells: number; + paletteSize: number; + isolatedCells: number; + simplifiedCells: number; +} export interface NumberedGuide { palette: string[]; target: Uint8Array; width: number; height: number; + complexity: GuideComplexity; } interface ColorBox { @@ -19,18 +41,33 @@ interface ColorBox { export function generateNumberedGuide( sampledImage: ImageData, - maxColors: number = DEFAULT_GUIDE_COLOR_COUNT, + options: number | GuideGenerationOptions = DEFAULT_GUIDE_COLOR_COUNT, ): NumberedGuide { - const colorLimit = clampColorCount(maxColors); - const pixels = collectOpaquePixels(sampledImage); - const palette = buildGeneratedPalette(pixels, colorLimit); - const target = mapImageToPalette(sampledImage, palette); + const settings = normalizeOptions(options); + const palette = settings.palette + ? parseRestrictedPalette(settings.palette) + : buildGeneratedPalette( + collectOpaquePixels(sampledImage), + clampColorCount(settings.maxColors ?? DEFAULT_GUIDE_COLOR_COUNT), + ); + const originalTarget = mapImageToPalette(sampledImage, palette, settings.mapping); + const target = settings.simplifyIsolatedPixels + ? simplifyIsolatedGuidePixels(originalTarget, sampledImage.width, sampledImage.height) + : originalTarget; + const simplifiedCells = countChangedCells(originalTarget, target); return { palette: palette.map((color) => rgbToHex(color.r, color.g, color.b)), target, width: sampledImage.width, height: sampledImage.height, + complexity: analyzeGuideComplexity( + target, + sampledImage.width, + sampledImage.height, + palette.length, + simplifiedCells, + ), }; } @@ -47,6 +84,66 @@ export function perceptualColorDistance(left: RGB, right: RGB): number { ); } +function luminanceDistance(left: RGB, right: RGB): number { + return Math.abs(relativeLuminance(left) - relativeLuminance(right)); +} + +export function simplifyIsolatedGuidePixels( + target: Uint8Array, + width: number, + height: number, +): Uint8Array { + if (target.length !== width * height) { + throw new RangeError('Guide dimensions do not match the target buffer'); + } + + const simplified = new Uint8Array(target); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = y * width + x; + const current = target[index]; + if (current === 0) continue; + + const neighbors = getNeighborIndices(target, width, height, x, y); + if (neighbors.includes(current)) continue; + + const replacement = getMajorityIndex(neighbors); + if (replacement !== 0) simplified[index] = replacement; + } + } + + return simplified; +} + +export function analyzeGuideComplexity( + target: Uint8Array, + width: number, + height: number, + paletteSize: number, + simplifiedCells: number = 0, +): GuideComplexity { + if (target.length !== width * height) { + throw new RangeError('Guide dimensions do not match the target buffer'); + } + + let paintableCells = 0; + let isolatedCells = 0; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const current = target[y * width + x]; + if (current === 0) continue; + + paintableCells += 1; + const neighbors = getNeighborIndices(target, width, height, x, y); + if (!neighbors.includes(current)) isolatedCells += 1; + } + } + + return { paintableCells, paletteSize, isolatedCells, simplifiedCells }; +} + function collectOpaquePixels(image: ImageData): RGB[] { const pixels: RGB[] = []; @@ -90,6 +187,22 @@ function buildGeneratedPalette(pixels: RGB[], colorLimit: number): RGB[] { return sortPalette(dedupeColors(boxes.map(averageBoxColor))); } +function parseRestrictedPalette(palette: string[]): RGB[] { + if (palette.length === 0) { + throw new RangeError('A restricted palette must contain at least one color'); + } + if (palette.length > MAX_GUIDE_COLORS) { + throw new RangeError(`A restricted palette cannot exceed ${MAX_GUIDE_COLORS} colors`); + } + + return palette.map((hex) => { + const normalized = normalizeHex(hex); + const color = hexToRgb(normalized); + if (!color) throw new TypeError(`Invalid palette color: ${hex}`); + return color; + }); +} + function getExactColors(pixels: RGB[]): RGB[] { const colors = new Map(); @@ -213,7 +326,11 @@ function relativeLuminance(color: RGB): number { return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; } -function mapImageToPalette(image: ImageData, palette: RGB[]): Uint8Array { +function mapImageToPalette( + image: ImageData, + palette: RGB[], + mapping: GuideColorMapping, +): Uint8Array { const target = new Uint8Array(image.width * image.height); if (palette.length === 0) return target; @@ -226,18 +343,24 @@ function mapImageToPalette(image: ImageData, palette: RGB[]): Uint8Array { g: image.data[dataIndex + 1], b: image.data[dataIndex + 2], }; - target[pixelIndex] = findClosestPaletteIndex(color, palette); + target[pixelIndex] = findClosestPaletteIndex(color, palette, mapping); } return target; } -function findClosestPaletteIndex(color: RGB, palette: RGB[]): number { +function findClosestPaletteIndex( + color: RGB, + palette: RGB[], + mapping: GuideColorMapping, +): number { let closestIndex = 0; let closestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < palette.length; index += 1) { - const distance = perceptualColorDistance(color, palette[index]); + const distance = mapping === 'luminance' + ? luminanceDistance(color, palette[index]) + : perceptualColorDistance(color, palette[index]); if (distance < closestDistance) { closestIndex = index; closestDistance = distance; @@ -247,6 +370,66 @@ function findClosestPaletteIndex(color: RGB, palette: RGB[]): number { return closestIndex + 1; } +function normalizeOptions( + options: number | GuideGenerationOptions, +): Required> + & Omit { + if (typeof options === 'number') { + return { + maxColors: options, + mapping: 'color', + simplifyIsolatedPixels: true, + }; + } + + return { + ...options, + mapping: options.mapping ?? 'color', + simplifyIsolatedPixels: options.simplifyIsolatedPixels ?? true, + }; +} + +function getNeighborIndices( + target: Uint8Array, + width: number, + height: number, + x: number, + y: number, +): number[] { + const neighbors: number[] = []; + if (x > 0) neighbors.push(target[y * width + x - 1]); + if (x + 1 < width) neighbors.push(target[y * width + x + 1]); + if (y > 0) neighbors.push(target[(y - 1) * width + x]); + if (y + 1 < height) neighbors.push(target[(y + 1) * width + x]); + return neighbors; +} + +function getMajorityIndex(indices: number[]): number { + const counts = new Map(); + for (const index of indices) { + if (index === 0) continue; + counts.set(index, (counts.get(index) ?? 0) + 1); + } + + let majorityIndex = 0; + let majorityCount = 1; + for (const [index, count] of counts) { + if (count > majorityCount || (count === majorityCount && index < majorityIndex)) { + majorityIndex = index; + majorityCount = count; + } + } + return majorityIndex; +} + +function countChangedCells(before: Uint8Array, after: Uint8Array): number { + let changed = 0; + for (let index = 0; index < before.length; index += 1) { + if (before[index] !== after[index]) changed += 1; + } + return changed; +} + function clampColorCount(value: number): number { if (!Number.isFinite(value)) return DEFAULT_GUIDE_COLOR_COUNT; return Math.max(1, Math.min(MAX_GUIDE_COLORS, Math.round(value))); diff --git a/tests/services/paint-by-number/guide-generator.test.ts b/tests/services/paint-by-number/guide-generator.test.ts index 7d9abd4..ff425c2 100644 --- a/tests/services/paint-by-number/guide-generator.test.ts +++ b/tests/services/paint-by-number/guide-generator.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import { + analyzeGuideComplexity, generateNumberedGuide, perceptualColorDistance, + simplifyIsolatedGuidePixels, } from '../../../src/services/paint-by-number/guide-generator'; class FakeImageData { @@ -30,11 +32,20 @@ describe('generateNumberedGuide', () => { [255, 0, 0, 255], ]); - const guide = generateNumberedGuide(image, 2); + const guide = generateNumberedGuide(image, { + maxColors: 2, + simplifyIsolatedPixels: false, + }); expect(guide.palette).toEqual(['#0000ff', '#ff0000']); expect([...guide.target]).toEqual([2, 1, 2]); expect([guide.width, guide.height]).toEqual([3, 1]); + expect(guide.complexity).toEqual({ + paintableCells: 3, + paletteSize: 2, + isolatedCells: 3, + simplifiedCells: 0, + }); }); it('maps transparent cells to zero', () => { @@ -94,7 +105,32 @@ describe('generateNumberedGuide', () => { target: new Uint8Array([0]), width: 1, height: 1, + complexity: { + paintableCells: 0, + paletteSize: 0, + isolatedCells: 0, + simplifiedCells: 0, + }, + }); + }); + + it('preserves a supplied palette and supports luminance matching', () => { + const image = imageData(1, 1, [[0, 255, 255, 255]]); + const palette = ['#0000ff', '#ff0000']; + + const colorGuide = generateNumberedGuide(image, { + palette, + mapping: 'color', }); + const luminanceGuide = generateNumberedGuide(image, { + palette, + mapping: 'luminance', + }); + + expect(colorGuide.palette).toEqual(palette); + expect(luminanceGuide.palette).toEqual(palette); + expect([...colorGuide.target]).toEqual([1]); + expect([...luminanceGuide.target]).toEqual([2]); }); }); @@ -109,3 +145,26 @@ describe('perceptualColorDistance', () => { ); }); }); + +describe('guide playability', () => { + it('simplifies a truly isolated cell to the neighboring majority', () => { + expect([ + ...simplifyIsolatedGuidePixels(new Uint8Array([1, 2, 1]), 3, 1), + ]).toEqual([1, 1, 1]); + }); + + it('keeps connected regions and reports complexity', () => { + const target = new Uint8Array([ + 1, 2, + 1, 2, + ]); + + expect([...simplifyIsolatedGuidePixels(target, 2, 2)]).toEqual([...target]); + expect(analyzeGuideComplexity(target, 2, 2, 2)).toEqual({ + paintableCells: 4, + paletteSize: 2, + isolatedCells: 0, + simplifiedCells: 0, + }); + }); +}); From 255a10085d1825e5f901a5423a777438e6a9a78b Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 11:22:22 +0200 Subject: [PATCH 05/15] Persist guided drawing sessions (#307) --- .../paint-by-number/guide-generator.ts | 9 +- src/stores/guided-drawing-store.ts | 84 +++++++++++++++++++ src/stores/guided-drawing.ts | 3 + src/stores/project-context.ts | 8 ++ src/stores/project-store.ts | 10 +++ src/types/guided-drawing.ts | 28 +++++++ src/types/project.ts | 5 +- tests/stores/guided-drawing-store.test.ts | 62 ++++++++++++++ tests/stores/project-context.test.ts | 18 ++++ tests/stores/project-serialization.test.ts | 39 +++++++++ tests/stores/project-version.test.ts | 4 +- 11 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 src/stores/guided-drawing-store.ts create mode 100644 src/stores/guided-drawing.ts create mode 100644 src/types/guided-drawing.ts create mode 100644 tests/stores/guided-drawing-store.test.ts diff --git a/src/services/paint-by-number/guide-generator.ts b/src/services/paint-by-number/guide-generator.ts index eaab4a3..3661044 100644 --- a/src/services/paint-by-number/guide-generator.ts +++ b/src/services/paint-by-number/guide-generator.ts @@ -4,16 +4,15 @@ import { rgbToHex, type RGB, } from '../../stores/palette/color-utils'; +import type { GuidedColorMapping } from '../../types/guided-drawing'; const MAX_GUIDE_COLORS = 255; const DEFAULT_GUIDE_COLOR_COUNT = 12; -export type GuideColorMapping = 'color' | 'luminance'; - export interface GuideGenerationOptions { maxColors?: number; palette?: string[]; - mapping?: GuideColorMapping; + mapping?: GuidedColorMapping; simplifyIsolatedPixels?: boolean; } @@ -329,7 +328,7 @@ function relativeLuminance(color: RGB): number { function mapImageToPalette( image: ImageData, palette: RGB[], - mapping: GuideColorMapping, + mapping: GuidedColorMapping, ): Uint8Array { const target = new Uint8Array(image.width * image.height); if (palette.length === 0) return target; @@ -352,7 +351,7 @@ function mapImageToPalette( function findClosestPaletteIndex( color: RGB, palette: RGB[], - mapping: GuideColorMapping, + mapping: GuidedColorMapping, ): number { let closestIndex = 0; let closestDistance = Number.POSITIVE_INFINITY; diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts new file mode 100644 index 0000000..03f368a --- /dev/null +++ b/src/stores/guided-drawing-store.ts @@ -0,0 +1,84 @@ +import { signal } from '../core/signal'; +import { + GUIDED_DRAWING_VERSION, + type GuidedDrawingSession, + type GuidedDrawingSessionFile, +} from '../types/guided-drawing'; + +class GuidedDrawingStore { + readonly session = signal(null); + + get active(): boolean { + return this.session.value !== null; + } + + start(session: GuidedDrawingSession): void { + validateSession(session); + this.session.value = cloneSession(session); + } + + load(file: GuidedDrawingSessionFile | undefined): void { + if (!file) { + this.clear(); + return; + } + + this.start({ + ...file, + target: Uint8Array.from(file.target), + }); + } + + toFile(): GuidedDrawingSessionFile | undefined { + const session = this.session.value; + if (!session) return undefined; + + return { + ...session, + target: Array.from(session.target), + settings: cloneSettings(session.settings), + }; + } + + clear(): void { + this.session.value = null; + } +} + +export function createGuidedDrawingStore(): GuidedDrawingStore { + return new GuidedDrawingStore(); +} + +function validateSession(session: GuidedDrawingSession): void { + if (session.version !== GUIDED_DRAWING_VERSION) { + throw new RangeError(`Unsupported guided drawing version: ${session.version}`); + } + if (!Number.isInteger(session.width) || session.width < 1) { + throw new RangeError('Guided drawing width must be at least 1'); + } + if (!Number.isInteger(session.height) || session.height < 1) { + throw new RangeError('Guided drawing height must be at least 1'); + } + if (session.target.length !== session.width * session.height) { + throw new RangeError('Guided drawing target does not match its dimensions'); + } +} + +function cloneSession(session: GuidedDrawingSession): GuidedDrawingSession { + return { + ...session, + target: new Uint8Array(session.target), + settings: cloneSettings(session.settings), + }; +} + +function cloneSettings( + settings: GuidedDrawingSession['settings'], +): GuidedDrawingSession['settings'] { + return { + ...settings, + restrictedPalette: settings.restrictedPalette + ? [...settings.restrictedPalette] + : undefined, + }; +} diff --git a/src/stores/guided-drawing.ts b/src/stores/guided-drawing.ts new file mode 100644 index 0000000..37b4a30 --- /dev/null +++ b/src/stores/guided-drawing.ts @@ -0,0 +1,3 @@ +import { defaultProjectContext } from './project-context'; + +export const guidedDrawingStore = defaultProjectContext.guidedDrawing; diff --git a/src/stores/project-context.ts b/src/stores/project-context.ts index bb24e32..533f668 100644 --- a/src/stores/project-context.ts +++ b/src/stores/project-context.ts @@ -4,6 +4,7 @@ import { createColorStore } from "./colors-store"; import { createDirtyRectStore } from "./dirty-rect-store"; import { createGridStore } from "./grid-store"; import { createGuidesStore } from "./guides-store"; +import { createGuidedDrawingStore } from "./guided-drawing-store"; import { createHistoryHighlightStore } from "./history-highlight-store"; import { createHistoryStore } from "./history-store"; import { createLayerStore } from "./layers-store"; @@ -18,6 +19,7 @@ export type ProjectColorStore = ReturnType; export type ProjectDirtyRectStore = ReturnType; export type ProjectGridStore = ReturnType; export type ProjectGuidesStore = ReturnType; +export type ProjectGuidedDrawingStore = ReturnType; export type ProjectHistoryHighlightStore = ReturnType< typeof createHistoryHighlightStore >; @@ -34,6 +36,7 @@ export interface ProjectContextStores { dirtyRect: ProjectDirtyRectStore; grid: ProjectGridStore; guides: ProjectGuidesStore; + guidedDrawing: ProjectGuidedDrawingStore; history: ProjectHistoryStore; historyHighlight: ProjectHistoryHighlightStore; layers: ProjectLayerStore; @@ -57,6 +60,7 @@ function createProjectContextStores( const dirtyRect = createDirtyRectStore(); const grid = createGridStore(); const guides = createGuidesStore(); + const guidedDrawing = createGuidedDrawingStore(); const historyHighlight = createHistoryHighlightStore(); const selection = createSelectionStore(); const palette = createPaletteStore({ layers, refs }); @@ -66,6 +70,7 @@ function createProjectContextStores( const project = createProjectStore({ animation, dirtyRect, + guidedDrawing, history, layers, palette, @@ -80,6 +85,7 @@ function createProjectContextStores( dirtyRect, grid, guides, + guidedDrawing, history, historyHighlight, layers, @@ -97,6 +103,7 @@ export class ProjectContext { readonly dirtyRect: ProjectDirtyRectStore; readonly grid: ProjectGridStore; readonly guides: ProjectGuidesStore; + readonly guidedDrawing: ProjectGuidedDrawingStore; readonly history: ProjectHistoryStore; readonly historyHighlight: ProjectHistoryHighlightStore; readonly layers: ProjectLayerStore; @@ -114,6 +121,7 @@ export class ProjectContext { this.dirtyRect = stores.dirtyRect; this.grid = stores.grid; this.guides = stores.guides; + this.guidedDrawing = stores.guidedDrawing; this.history = stores.history; this.historyHighlight = stores.historyHighlight; this.layers = stores.layers; diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts index 2a48f3c..aa5f242 100644 --- a/src/stores/project-store.ts +++ b/src/stores/project-store.ts @@ -33,6 +33,7 @@ import type { Layer } from "../types/layer"; import type { createAnimationStore } from "./animation/store"; import type { createDirtyRectStore } from "./dirty-rect-store"; import type { createHistoryStore } from "./history-store"; +import type { createGuidedDrawingStore } from "./guided-drawing-store"; import type { createLayerStore } from "./layers-store"; import type { createPaletteStore } from "./palette/store"; import type { createSelectionStore } from "./selection/store"; @@ -40,6 +41,7 @@ import type { createSelectionStore } from "./selection/store"; type ProjectAnimationStore = ReturnType; type ProjectDirtyRectStore = ReturnType; type ProjectHistoryStore = ReturnType; +type ProjectGuidedDrawingStore = ReturnType; type ProjectLayerStore = ReturnType; type ProjectPaletteStore = ReturnType; type ProjectSelectionStore = ReturnType; @@ -48,6 +50,7 @@ export interface ProjectStoreDependencies { animation: ProjectAnimationStore; dirtyRect: ProjectDirtyRectStore; history: ProjectHistoryStore; + guidedDrawing: ProjectGuidedDrawingStore; layers: ProjectLayerStore; palette: ProjectPaletteStore; refs: StoreRefs; @@ -102,6 +105,7 @@ class ProjectStore { private readonly animation: ProjectAnimationStore; private readonly dirtyRect: ProjectDirtyRectStore; private readonly history: ProjectHistoryStore; + private readonly guidedDrawing: ProjectGuidedDrawingStore; private readonly layers: ProjectLayerStore; private readonly loadStores: ProjectLoadStores; private readonly palette: ProjectPaletteStore; @@ -112,6 +116,7 @@ class ProjectStore { this.animation = dependencies.animation; this.dirtyRect = dependencies.dirtyRect; this.history = dependencies.history; + this.guidedDrawing = dependencies.guidedDrawing; this.layers = dependencies.layers; this.palette = dependencies.palette; this.refs = dependencies.refs; @@ -177,6 +182,8 @@ class ProjectStore { (f) => f.id === this.animation.currentFrameId.value ); + const guidedDrawing = this.guidedDrawing.toFile(); + return { version: PROJECT_VERSION, name: this.name.value, @@ -190,6 +197,7 @@ class ProjectStore { currentFrameIndex: currentFrameIndex === -1 ? 0 : currentFrameIndex, }, tags: this.animation.tags.value, + ...(guidedDrawing && { guidedDrawing }), }; } @@ -207,6 +215,7 @@ class ProjectStore { this.setSize(file.width, file.height); this.name.value = file.name || DEFAULT_PROJECT_NAME; + this.guidedDrawing.load(file.guidedDrawing); restoreProjectPaletteForLoad(this.loadStores, file); await hydrateProjectLayers(this.loadStores, file); @@ -236,6 +245,7 @@ class ProjectStore { this.setSize(width, height); this.name.value = DEFAULT_PROJECT_NAME; this.lastSaved.value = null; + this.guidedDrawing.clear(); // 2. Clear all layers while (this.layers.layers.value.length > 0) { diff --git a/src/types/guided-drawing.ts b/src/types/guided-drawing.ts new file mode 100644 index 0000000..c35c3b2 --- /dev/null +++ b/src/types/guided-drawing.ts @@ -0,0 +1,28 @@ +export const GUIDED_DRAWING_VERSION = 1; + +export type GuidedPaletteSource = 'generated' | 'restricted'; +export type GuidedColorMapping = 'color' | 'luminance'; + +export interface GuidedDrawingSettings { + longSide: number; + paletteSource: GuidedPaletteSource; + maxColors?: number; + restrictedPalette?: string[]; + mapping: GuidedColorMapping; + simplifyIsolatedPixels: boolean; +} + +export interface GuidedDrawingSessionFile { + version: typeof GUIDED_DRAWING_VERSION; + width: number; + height: number; + target: number[]; + settings: GuidedDrawingSettings; + sourceName?: string; + createdAt: number; +} + +export interface GuidedDrawingSession + extends Omit { + target: Uint8Array; +} diff --git a/src/types/project.ts b/src/types/project.ts index d853a7e..8b978d2 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -2,6 +2,7 @@ import type { FrameTag } from './animation'; import type { LayerType, BlendMode } from './layer'; import type { ReferenceLayerData } from './reference'; import type { TextLayerData, TextCelData } from './text'; +import type { GuidedDrawingSessionFile } from './guided-drawing'; /** * Current project file format version. @@ -12,8 +13,9 @@ import type { TextLayerData, TextCelData } from './text'; * * History: 3.1.0 added `ephemeralPalette`, 3.2.0 added `layers[].continuous`, * 4.0.0 removed `ephemeralPalette` and added reference layer data. + * 4.1.0 added optional durable guided drawing sessions. */ -export const PROJECT_VERSION = '4.0.0'; +export const PROJECT_VERSION = '4.1.0'; export type ProjectImageData = Uint8Array; export type LegacyProjectImageData = @@ -62,6 +64,7 @@ export interface ProjectFile { currentFrameIndex: number; }; tags?: FrameTag[]; // Frame tags (v2.0+, optional for backward compat) + guidedDrawing?: GuidedDrawingSessionFile; // v4.1+: optional numbered drawing guide } export type ProjectLayerFileInput = Omit & { diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts new file mode 100644 index 0000000..ad02740 --- /dev/null +++ b/tests/stores/guided-drawing-store.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { createGuidedDrawingStore } from '../../src/stores/guided-drawing-store'; + +function createSession() { + return { + version: 1 as const, + width: 2, + height: 1, + target: new Uint8Array([1, 2]), + settings: { + longSide: 2, + paletteSource: 'restricted' as const, + restrictedPalette: ['#000000', '#ffffff'], + mapping: 'luminance' as const, + simplifyIsolatedPixels: true, + }, + sourceName: 'source.png', + createdAt: 123, + }; +} + +describe('GuidedDrawingStore', () => { + it('owns and clones runtime session data', () => { + const store = createGuidedDrawingStore(); + const session = createSession(); + + store.start(session); + session.target[0] = 9; + session.settings.restrictedPalette[0] = '#ff00ff'; + + expect(store.session.value?.target).toEqual(new Uint8Array([1, 2])); + expect(store.session.value?.settings.restrictedPalette).toEqual([ + '#000000', + '#ffffff', + ]); + }); + + it('converts typed targets to serializable file data', () => { + const store = createGuidedDrawingStore(); + store.start(createSession()); + + expect(store.toFile()).toEqual({ + ...createSession(), + target: [1, 2], + }); + }); + + it('loads, clears, and rejects mismatched targets', () => { + const store = createGuidedDrawingStore(); + const file = { ...createSession(), target: [1, 2] }; + + store.load(file); + expect(store.active).toBe(true); + store.clear(); + expect(store.active).toBe(false); + store.load(undefined); + expect(store.active).toBe(false); + + expect(() => store.start({ ...createSession(), target: new Uint8Array([1]) })) + .toThrow('target does not match'); + }); +}); diff --git a/tests/stores/project-context.test.ts b/tests/stores/project-context.test.ts index 85071fe..8ba08db 100644 --- a/tests/stores/project-context.test.ts +++ b/tests/stores/project-context.test.ts @@ -5,6 +5,7 @@ import { colorStore } from "../../src/stores/colors"; import { dirtyRectStore } from "../../src/stores/dirty-rect"; import { gridStore } from "../../src/stores/grid"; import { guidesStore } from "../../src/stores/guides"; +import { guidedDrawingStore } from "../../src/stores/guided-drawing"; import { historyStore } from "../../src/stores/history"; import { historyHighlightStore } from "../../src/stores/history-highlight"; import { layerStore } from "../../src/stores/layers"; @@ -112,6 +113,20 @@ describe("ProjectContext", () => { contextA.project.setSize(32, 24); contextA.animation.fps.value = 24; contextA.palette.setPalette(["#111111", "#222222"]); + contextA.guidedDrawing.start({ + version: 1, + width: 1, + height: 1, + target: new Uint8Array([1]), + settings: { + longSide: 1, + paletteSource: "generated", + maxColors: 1, + mapping: "color", + simplifyIsolatedPixels: true, + }, + createdAt: 1, + }); await contextA.history.execute({ id: "command-a", name: "Command A", @@ -125,6 +140,8 @@ describe("ProjectContext", () => { expect(contextB.animation.fps.value).toBe(12); expect(contextA.palette.mainColors.value).toEqual(["#111111", "#222222"]); expect(contextB.palette.mainColors.value).not.toEqual(["#111111", "#222222"]); + expect(contextA.guidedDrawing.active).toBe(true); + expect(contextB.guidedDrawing.active).toBe(false); expect(contextA.history.canUndo.value).toBe(true); expect(contextB.history.canUndo.value).toBe(false); }); @@ -177,6 +194,7 @@ describe("ProjectContext", () => { expect(defaultProjectContext.dirtyRect).toBe(dirtyRectStore); expect(defaultProjectContext.grid).toBe(gridStore); expect(defaultProjectContext.guides).toBe(guidesStore); + expect(defaultProjectContext.guidedDrawing).toBe(guidedDrawingStore); expect(defaultProjectContext.history).toBe(historyStore); expect(defaultProjectContext.historyHighlight).toBe(historyHighlightStore); expect(defaultProjectContext.layers).toBe(layerStore); diff --git a/tests/stores/project-serialization.test.ts b/tests/stores/project-serialization.test.ts index bc7e8b4..811ee62 100644 --- a/tests/stores/project-serialization.test.ts +++ b/tests/stores/project-serialization.test.ts @@ -34,6 +34,7 @@ import { projectStore } from '../../src/stores/project'; import { layerStore } from '../../src/stores/layers'; import { animationStore } from '../../src/stores/animation'; import { paletteStore } from '../../src/stores/palette'; +import { guidedDrawingStore } from '../../src/stores/guided-drawing'; import { PROJECT_VERSION } from '../../src/types/project'; import type { ProjectFile, ProjectFileInput } from '../../src/types/project'; import { loadImageDataToCanvas } from '../../src/utils/canvas-binary'; @@ -264,6 +265,44 @@ describe('project save -> load -> save round-trip (structural)', () => { expect(paletteStore.mainColors.value).toEqual(PALETTE); }); + it('round-trips optional guided drawing state', async () => { + const saved = await buildSampleProject(); + guidedDrawingStore.start({ + version: 1, + width: 2, + height: 2, + target: new Uint8Array([1, 2, 0, 1]), + settings: { + longSide: 2, + paletteSource: 'restricted', + restrictedPalette: ['#000000', '#ffffff'], + mapping: 'luminance', + simplifyIsolatedPixels: true, + }, + sourceName: 'source.png', + createdAt: 123, + }); + + const withGuide = await projectStore.saveProject(); + expect(withGuide.guidedDrawing?.target).toEqual([1, 2, 0, 1]); + + guidedDrawingStore.clear(); + vi.useFakeTimers(); + await projectStore.loadProject(structuredClone(withGuide), false); + await vi.runAllTimersAsync(); + vi.useRealTimers(); + + expect(guidedDrawingStore.session.value?.target).toEqual( + new Uint8Array([1, 2, 0, 1]), + ); + expect((await projectStore.saveProject()).guidedDrawing).toEqual( + withGuide.guidedDrawing, + ); + + await projectStore.loadProject(saved, false); + expect(guidedDrawingStore.active).toBe(false); + }); + it('round-trips reference layer metadata without animation cels', async () => { await buildSampleProject(); const referenceBytes = new Uint8Array([1, 2, 3, 4]); diff --git a/tests/stores/project-version.test.ts b/tests/stores/project-version.test.ts index e669389..4ea9945 100644 --- a/tests/stores/project-version.test.ts +++ b/tests/stores/project-version.test.ts @@ -33,10 +33,10 @@ import { projectStore } from '../../src/stores/project'; import { layerStore } from '../../src/stores/layers'; describe('project file format version', () => { - it('is in sync with the newest schema fields (4.0.0 = reference layer data)', () => { + it('is in sync with the newest schema fields (4.1.0 = guided drawing)', () => { // If this fails, the ProjectFile schema changed without bumping // PROJECT_VERSION — bump it in the same PR (see src/types/project.ts). - expect(PROJECT_VERSION).toBe('4.0.0'); + expect(PROJECT_VERSION).toBe('4.1.0'); }); it('stamps saved projects with PROJECT_VERSION and serializes current-format fields', async () => { From a6cdddc358ddb2b319ad6c39c537767527cb4760 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 11:26:57 +0200 Subject: [PATCH 06/15] Create guided projects in separate contexts (#308) --- .../paint-by-number/guided-project.ts | 122 ++++++++++++++++++ src/services/project-library.ts | 15 +++ src/stores/workspace.ts | 32 ++++- .../paint-by-number/guided-project.test.ts | 89 +++++++++++++ tests/services/project-library.test.ts | 27 ++++ tests/stores/workspace.test.ts | 58 ++++++++- 6 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 src/services/paint-by-number/guided-project.ts create mode 100644 tests/services/paint-by-number/guided-project.test.ts diff --git a/src/services/paint-by-number/guided-project.ts b/src/services/paint-by-number/guided-project.ts new file mode 100644 index 0000000..2a9cc05 --- /dev/null +++ b/src/services/paint-by-number/guided-project.ts @@ -0,0 +1,122 @@ +import { v4 as uuidv4 } from 'uuid'; +import { normalizeProjectName } from '../project-defaults'; +import { workspaceStore, type WorkspaceProjectOptions } from '../../stores/workspace'; +import { + GUIDED_DRAWING_VERSION, + type GuidedDrawingSettings, +} from '../../types/guided-drawing'; +import { PROJECT_VERSION, type ProjectFile } from '../../types/project'; +import type { NumberedGuide } from './guide-generator'; + +export interface GuidedProjectInput { + guide: NumberedGuide; + settings: GuidedDrawingSettings; + name?: string; + sourceName?: string; + createdAt?: number; +} + +interface GuidedProjectWorkspace { + createProjectFromFile( + project: ProjectFile, + options?: WorkspaceProjectOptions, + ): ReturnType; +} + +export function createGuidedProjectFile(input: GuidedProjectInput): ProjectFile { + validateGuide(input.guide); + + const layerId = uuidv4(); + const frameId = uuidv4(); + const { guide } = input; + + return { + version: PROJECT_VERSION, + name: normalizeProjectName(input.name ?? defaultGuidedProjectName(input.sourceName)), + width: guide.width, + height: guide.height, + palette: [...guide.palette], + layers: [ + { + id: layerId, + name: 'Painting', + type: 'image', + visible: true, + opacity: 255, + blendMode: 'normal', + continuous: false, + data: new Uint8Array(0), + }, + ], + frames: [ + { + id: frameId, + duration: 100, + cels: [ + { + layerId, + data: new Uint8Array(0), + indexData: Array.from(new Uint8Array(guide.width * guide.height)), + }, + ], + }, + ], + animation: { + fps: 12, + currentFrameIndex: 0, + }, + tags: [], + guidedDrawing: { + version: GUIDED_DRAWING_VERSION, + width: guide.width, + height: guide.height, + target: Array.from(guide.target), + settings: cloneSettings(input.settings), + sourceName: input.sourceName, + createdAt: input.createdAt ?? Date.now(), + }, + }; +} + +export async function createGuidedProject( + input: GuidedProjectInput, + options: WorkspaceProjectOptions = {}, + workspace: GuidedProjectWorkspace = workspaceStore, +) { + return workspace.createProjectFromFile(createGuidedProjectFile(input), { + activate: options.activate ?? true, + saveActiveContext: options.saveActiveContext ?? true, + }); +} + +function validateGuide(guide: NumberedGuide): void { + if (guide.width < 1 || guide.height < 1) { + throw new RangeError('A guided project needs positive dimensions'); + } + if (guide.target.length !== guide.width * guide.height) { + throw new RangeError('Guide target does not match its dimensions'); + } + if (guide.palette.length < 1) { + throw new RangeError('A guided project needs at least one paint color'); + } + + for (const index of guide.target) { + if (index > guide.palette.length) { + throw new RangeError('Guide target contains an unknown palette index'); + } + } +} + +function cloneSettings(settings: GuidedDrawingSettings): GuidedDrawingSettings { + return { + ...settings, + restrictedPalette: settings.restrictedPalette + ? [...settings.restrictedPalette] + : undefined, + }; +} + +function defaultGuidedProjectName(sourceName?: string): string { + const baseName = sourceName?.replace(/\.[^.]+$/, '').trim(); + return baseName ? `${baseName} guide` : 'Guided drawing'; +} diff --git a/src/services/project-library.ts b/src/services/project-library.ts index da90fda..c82df6f 100644 --- a/src/services/project-library.ts +++ b/src/services/project-library.ts @@ -70,6 +70,21 @@ export class ProjectLibraryService { return id; } + async createProjectFromFile( + project: ProjectFile, + settings: CreateProjectSettings = {} + ): Promise { + const context = settings.context ?? defaultProjectContext; + if (settings.saveCurrent ?? true) { + await autoSaveService.saveNow(context); + } + + const id = uuidv4(); + await this.repository.save(id, structuredClone(project)); + await this.loadStoredProject(id, context); + return id; + } + async duplicateProject(id: string): Promise { const project = await this.getProjectOrThrow(id); const sourceMeta = await this.findProjectMeta(id); diff --git a/src/stores/workspace.ts b/src/stores/workspace.ts index 696ab21..9fd5f47 100644 --- a/src/stores/workspace.ts +++ b/src/stores/workspace.ts @@ -16,6 +16,7 @@ import { setActiveProjectContext, type ProjectContext, } from "./project-context"; +import type { ProjectFile } from "../types/project"; import { log } from "../utils/log"; export const WORKSPACE_OPEN_ITEM_LIMIT = 8; @@ -42,7 +43,7 @@ type WorkspaceLimitFailure = { type WorkspaceProjectLibrary = Pick< ProjectLibraryService, - "openProject" | "createProject" + "openProject" | "createProject" | "createProjectFromFile" >; type WorkspaceAutoSave = Pick< typeof autoSaveService, @@ -194,6 +195,30 @@ export class WorkspaceStore { async createProject( projectOptions: CreateProjectOptions, options: WorkspaceProjectOptions = {}, + ): Promise { + return this.createProjectInNewContext(options, (context) => + this.projectLibrary.createProject(projectOptions, { + context, + saveCurrent: false, + }), + ); + } + + async createProjectFromFile( + project: ProjectFile, + options: WorkspaceProjectOptions = {}, + ): Promise { + return this.createProjectInNewContext(options, (context) => + this.projectLibrary.createProjectFromFile(project, { + context, + saveCurrent: false, + }), + ); + } + + private async createProjectInNewContext( + options: WorkspaceProjectOptions, + createProject: (context: ProjectContext) => Promise, ): Promise { if (this.items.value.length >= this.itemLimit) { return this.createLimitFailure(); @@ -204,10 +229,7 @@ export class WorkspaceStore { const context = createProjectContext(); let projectId: string; try { - projectId = await this.projectLibrary.createProject(projectOptions, { - context, - saveCurrent: false, - }); + projectId = await createProject(context); } catch (error) { context.dispose(); throw error; diff --git a/tests/services/paint-by-number/guided-project.test.ts b/tests/services/paint-by-number/guided-project.test.ts new file mode 100644 index 0000000..eb77edd --- /dev/null +++ b/tests/services/paint-by-number/guided-project.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createGuidedProject, + createGuidedProjectFile, +} from '../../../src/services/paint-by-number/guided-project'; +import type { NumberedGuide } from '../../../src/services/paint-by-number/guide-generator'; + +function guide(): NumberedGuide { + return { + palette: ['#111111', '#eeeeee'], + target: new Uint8Array([1, 2, 0, 1]), + width: 2, + height: 2, + complexity: { + paintableCells: 3, + paletteSize: 2, + isolatedCells: 3, + simplifiedCells: 0, + }, + }; +} + +const settings = { + longSide: 2, + paletteSource: 'generated' as const, + maxColors: 2, + mapping: 'color' as const, + simplifyIsolatedPixels: true, +}; + +describe('createGuidedProjectFile', () => { + it('creates a blank normal project plus its durable guide', () => { + const file = createGuidedProjectFile({ + guide: guide(), + settings, + sourceName: 'portrait.png', + createdAt: 123, + }); + + expect(file.name).toBe('portrait guide'); + expect([file.width, file.height]).toEqual([2, 2]); + expect(file.palette).toEqual(['#111111', '#eeeeee']); + expect(file.layers).toHaveLength(1); + expect(file.layers[0].name).toBe('Painting'); + expect(file.frames).toHaveLength(1); + expect(file.frames[0].cels[0].indexData).toEqual([0, 0, 0, 0]); + expect(file.guidedDrawing).toMatchObject({ + version: 1, + target: [1, 2, 0, 1], + settings, + sourceName: 'portrait.png', + createdAt: 123, + }); + }); + + it('rejects invalid guide data', () => { + expect(() => createGuidedProjectFile({ + guide: { ...guide(), target: new Uint8Array([1]) }, + settings, + })).toThrow('target does not match'); + + expect(() => createGuidedProjectFile({ + guide: { ...guide(), palette: [] }, + settings, + })).toThrow('at least one paint color'); + }); +}); + +describe('createGuidedProject', () => { + it('delegates a separate activated project to the workspace', async () => { + const createProjectFromFile = vi.fn(async () => ({ + ok: true as const, + item: {} as never, + projectId: 'guided-id', + })); + + const result = await createGuidedProject( + { guide: guide(), settings, createdAt: 123 }, + {}, + { createProjectFromFile }, + ); + + expect(result.projectId).toBe('guided-id'); + expect(createProjectFromFile).toHaveBeenCalledWith( + expect.objectContaining({ guidedDrawing: expect.any(Object) }), + { activate: true, saveActiveContext: true }, + ); + }); +}); diff --git a/tests/services/project-library.test.ts b/tests/services/project-library.test.ts index 895d00c..9930bc6 100644 --- a/tests/services/project-library.test.ts +++ b/tests/services/project-library.test.ts @@ -214,6 +214,33 @@ describe('ProjectLibraryService', () => { expect(projects.has('current')).toBe(false); }); + it('creates and opens an exact project file without mutating its input', async () => { + const project = makeProject('Guided project', 5, 3); + project.guidedDrawing = { + version: 1, + width: 5, + height: 3, + target: new Array(15).fill(1), + settings: { + longSide: 5, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + createdAt: 123, + }; + const input = structuredClone(project); + + const id = await service.createProjectFromFile(project, { saveCurrent: false }); + + expect(project).toEqual(input); + expect(projectStore.id.value).toBe(id); + expect(projectStore.name.value).toBe('Guided project'); + expect(projectStore.width.value).toBe(5); + expect(projects.get(id)?.guidedDrawing).toEqual(project.guidedDrawing); + }); + it('saves edits to the open project before loading another project', async () => { projects.set('a', makeProject('Project A')); projects.set('b', makeProject('Project B')); diff --git a/tests/stores/workspace.test.ts b/tests/stores/workspace.test.ts index bd95721..52c0910 100644 --- a/tests/stores/workspace.test.ts +++ b/tests/stores/workspace.test.ts @@ -16,7 +16,7 @@ import { PROJECT_VERSION, type ProjectFile } from "../../src/types/project"; type WorkspaceProjectLibrary = Pick< ProjectLibraryService, - "openProject" | "createProject" + "openProject" | "createProject" | "createProjectFromFile" >; function makeProjectFile(name: string): ProjectFile { @@ -97,6 +97,17 @@ function createProjectLibraryMock() { } return projectId; }), + createProjectFromFile: vi.fn(async (project, settings = {}) => { + const projectId = `created-project-${nextProjectNumber}`; + nextProjectNumber += 1; + rememberContext(settings.context); + if (settings.context) { + settings.context.project.id.value = projectId; + settings.context.project.name.value = project.name ?? "Untitled"; + settings.context.guidedDrawing.load(project.guidedDrawing); + } + return projectId; + }), }; return projectLibrary; @@ -502,6 +513,51 @@ describe("WorkspaceStore", () => { expect(autoSave.start).toHaveBeenCalledWith(result.item.context); }); + it("creates an exact project file in a separate context", async () => { + const initialContext = createTestContext("Current Project"); + initialContext.project.id.value = "current-project"; + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext, + initialItemId: "current-project", + projectLibrary, + autoSave, + }); + const project = makeProjectFile("Guided Project"); + project.guidedDrawing = { + version: 1, + width: 8, + height: 8, + target: new Array(64).fill(1), + settings: { + longSide: 8, + paletteSource: "generated", + maxColors: 2, + mapping: "color", + simplifyIsolatedPixels: true, + }, + createdAt: 123, + }; + + const result = await workspace.createProjectFromFile(project, { + activate: true, + saveActiveContext: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.item.context).not.toBe(initialContext); + expect(result.item.context.project.name.value).toBe("Guided Project"); + expect(result.item.context.guidedDrawing.active).toBe(true); + expect(initialContext.project.name.value).toBe("Current Project"); + expect(autoSave.saveNow).toHaveBeenCalledWith(initialContext); + expect(projectLibrary.createProjectFromFile).toHaveBeenCalledWith(project, { + context: result.item.context, + saveCurrent: false, + }); + }); + it("saves the active context before opening another project when requested", async () => { const initialContext = createTestContext("Project A"); initialContext.project.id.value = "project-a"; From 4949a1e0724e42a06ce8bdcf7691a0c009260ee9 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 11:46:06 +0200 Subject: [PATCH 07/15] Add guided project setup flow (#309) --- src/components/app/pixel-forge-app.ts | 31 + .../dialogs/pf-paint-by-number-dialog.ts | 664 ++++++++++++++++++ src/components/menu/pf-menu-bar.ts | 16 + .../dialogs/pf-paint-by-number-dialog.test.ts | 186 +++++ tests/components/menu/pf-menu-bar.test.ts | 19 + 5 files changed, 916 insertions(+) create mode 100644 src/components/dialogs/pf-paint-by-number-dialog.ts create mode 100644 tests/components/dialogs/pf-paint-by-number-dialog.test.ts diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 121abae..42f1c2c 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -15,6 +15,7 @@ import "./pf-project-browser"; import "../dialogs/pf-resize-dialog"; import "../dialogs/pf-export-dialog"; import "../dialogs/pf-new-project-dialog"; +import "../dialogs/pf-paint-by-number-dialog"; import "../dialogs/pf-grid-settings-dialog"; import "../dialogs/pf-checker-settings-dialog"; import "../dialogs/pf-accent-color-dialog"; @@ -235,6 +236,7 @@ export class PixelForgeApp extends BaseComponent { @state() showResizeDialog = false; @state() showExportDialog = false; @state() showNewProjectDialog = false; + @state() showPaintByNumberDialog = false; @state() showProjectBrowser = false; @state() showDeleteCurrentDialog = false; @state() showKeyboardShortcutsDialog = false; @@ -265,6 +267,10 @@ export class PixelForgeApp extends BaseComponent { "show-new-project-dialog", this.handleShowNewProjectDialog ); + window.addEventListener( + "show-paint-by-number-dialog", + this.handleShowPaintByNumberDialog + ); window.addEventListener( "show-keyboard-shortcuts-dialog", this.handleShowKeyboardShortcutsDialog @@ -333,6 +339,19 @@ export class PixelForgeApp extends BaseComponent { this.showNewProjectDialog = true; }; + private handleShowPaintByNumberDialog = () => { + this.showProjectBrowser = false; + this.showPaintByNumberDialog = true; + }; + + private handlePaintByNumberDialogClose = () => { + this.showPaintByNumberDialog = false; + + if (!this.hasLibraryProject) { + this.showProjectBrowser = true; + } + }; + private handleNewProjectDialogClose = () => { this.showNewProjectDialog = false; @@ -394,6 +413,7 @@ export class PixelForgeApp extends BaseComponent { private handleProjectCreated = () => { this.hasLibraryProject = true; this.showNewProjectDialog = false; + this.showPaintByNumberDialog = false; this.showProjectBrowser = false; }; @@ -483,6 +503,10 @@ export class PixelForgeApp extends BaseComponent { "show-new-project-dialog", this.handleShowNewProjectDialog ); + window.removeEventListener( + "show-paint-by-number-dialog", + this.handleShowPaintByNumberDialog + ); window.removeEventListener( "show-keyboard-shortcuts-dialog", this.handleShowKeyboardShortcutsDialog @@ -569,6 +593,7 @@ export class PixelForgeApp extends BaseComponent { @resize-canvas=${() => (this.showResizeDialog = true)} @show-export-dialog=${() => (this.showExportDialog = true)} @show-new-project-dialog=${this.handleShowNewProjectDialog} + @show-paint-by-number-dialog=${this.handleShowPaintByNumberDialog} @show-project-browser=${() => (this.showProjectBrowser = true)} > @@ -647,6 +672,12 @@ export class PixelForgeApp extends BaseComponent { @project-created=${this.handleProjectCreated} > + + ${this.showProjectBrowser ? html` + Guided drawing + +
+ + +
+ Guide preset +
+ ${GUIDE_PRESETS.map((preset) => html` + + `)} +
+
+ +
+ Advanced settings +
+ + + + + + + + + +
+
+ +
+ ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} + ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} +
+ +

+ ${this.errorMessage || this.statusMessage} +

+ +
+ +
+ + +
+ + `; + } + + private renderPreviewFigure(label: string, id: string, ready: boolean) { + return html` +
+
${label}
+
+ ${ready + ? html`` + : html`

+ ${id === 'source-preview' + ? 'Your selected image appears here.' + : 'The reduced numbered guide appears here.'} +

`} +
+
+ `; + } + + private handleFileChange = async (event: Event) => { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0] ?? null; + const version = ++this.requestVersion; + this.cancelPreviewTimer(); + this.sourceFile = file; + this.sourceImage = null; + this.guide = null; + this.errorMessage = ''; + + if (!file) { + this.statusMessage = 'Choose an image to begin.'; + return; + } + + this.statusMessage = 'Reading image…'; + try { + const image = await decodeImageFile(file); + if (version !== this.requestVersion) return; + this.sourceImage = image; + this.schedulePreview(0); + } catch { + if (version !== this.requestVersion) return; + this.errorMessage = 'This image could not be read. Try a PNG, JPEG, or WebP file.'; + } + }; + + private selectPreset(preset: (typeof GUIDE_PRESETS)[number]) { + this.longSide = preset.longSide; + this.maxColors = preset.maxColors; + this.schedulePreview(); + } + + private handleLongSideChange = (event: Event) => { + this.longSide = clampNumber((event.currentTarget as HTMLInputElement).valueAsNumber, 8, 64, 24); + this.schedulePreview(); + }; + + private handleMaxColorsChange = (event: Event) => { + this.maxColors = clampNumber((event.currentTarget as HTMLInputElement).valueAsNumber, 2, 32, 8); + this.schedulePreview(); + }; + + private handlePaletteSourceChange = (event: Event) => { + this.paletteSource = (event.currentTarget as HTMLSelectElement).value as GuidedPaletteSource; + this.schedulePreview(); + }; + + private handleMappingChange = (event: Event) => { + this.mapping = (event.currentTarget as HTMLSelectElement).value as GuidedColorMapping; + this.schedulePreview(); + }; + + private handleSimplifyChange = (event: Event) => { + this.simplifyIsolatedPixels = (event.currentTarget as HTMLInputElement).checked; + this.schedulePreview(); + }; + + private schedulePreview(delay = PREVIEW_DEBOUNCE_MS) { + this.cancelPreviewTimer(); + if (!this.sourceImage) return; + + const version = ++this.requestVersion; + this.isGenerating = true; + this.errorMessage = ''; + this.statusMessage = 'Updating guide preview…'; + this.previewTimer = window.setTimeout(() => { + this.previewTimer = null; + this.generatePreview(version); + }, delay); + } + + private generatePreview(version: number) { + if (!this.sourceImage || version !== this.requestVersion) return; + + try { + const sampled = sampleImageToGrid(this.sourceImage, { longSide: this.longSide }); + const restrictedPalette = this.getRestrictedPalette(); + const guide = generateNumberedGuide(sampled, { + maxColors: this.maxColors, + palette: restrictedPalette, + mapping: this.mapping, + simplifyIsolatedPixels: this.simplifyIsolatedPixels, + }); + if (guide.palette.length === 0) { + throw new Error('The image has no visible paintable pixels.'); + } + if (version !== this.requestVersion) return; + + this.guide = guide; + this.statusMessage = this.describeGuide(guide); + } catch (error) { + if (version !== this.requestVersion) return; + this.guide = null; + this.errorMessage = error instanceof Error + ? error.message + : 'The guide preview could not be generated.'; + } finally { + if (version === this.requestVersion) this.isGenerating = false; + } + } + + private getRestrictedPalette(): string[] | undefined { + if (this.paletteSource !== 'restricted') return undefined; + return [...getActiveProjectContext().palette.mainColors.value]; + } + + private describeGuide(guide: NumberedGuide): string { + const { complexity } = guide; + const simplified = complexity.simplifiedCells > 0 + ? ` ${complexity.simplifiedCells} isolated cells simplified.` + : ''; + return `${guide.width} × ${guide.height}, ${guide.palette.length} colors, ${complexity.paintableCells} cells.${simplified}`; + } + + private createProject = async (event: Event) => { + event.preventDefault(); + if (!this.guide || !this.sourceFile || this.isCreating) return; + + this.isCreating = true; + this.errorMessage = ''; + try { + const settings = this.currentSettings(); + const result = await createGuidedProject({ + guide: this.guide, + settings, + sourceName: this.sourceFile.name, + }); + if (!result.ok) { + this.errorMessage = result.message; + return; + } + + const projectId = result.projectId; + this.close(); + this.dispatchEvent(new CustomEvent('project-created', { + bubbles: true, + composed: true, + detail: { id: projectId, guided: true }, + })); + } catch (error) { + this.errorMessage = error instanceof Error + ? error.message + : 'The guided project could not be created.'; + } finally { + this.isCreating = false; + } + }; + + private currentSettings(): GuidedDrawingSettings { + return { + longSide: this.longSide, + paletteSource: this.paletteSource, + maxColors: this.paletteSource === 'generated' ? this.maxColors : undefined, + restrictedPalette: this.getRestrictedPalette(), + mapping: this.mapping, + simplifyIsolatedPixels: this.simplifyIsolatedPixels, + }; + } + + private drawPreviews() { + if (this.sourceImage && this.sourceCanvas) { + drawImageData(this.sourceCanvas, this.sourceImage); + } + if (this.guide && this.guideCanvas) { + drawGuide(this.guideCanvas, this.guide); + } + } + + private cancelPreviewTimer() { + if (this.previewTimer === null) return; + clearTimeout(this.previewTimer); + this.previewTimer = null; + } + + private cancelPendingPreview() { + this.requestVersion += 1; + this.cancelPreviewTimer(); + } + + close = () => { + this.cancelPendingPreview(); + this.open = false; + this.dispatchEvent(new CustomEvent('close')); + }; +} + +function clampNumber(value: number, min: number, max: number, fallback: number) { + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.round(value))); +} + +function drawImageData(canvas: HTMLCanvasElement, image: ImageData) { + const context = canvas.getContext('2d'); + if (!context) return; + canvas.width = image.width; + canvas.height = image.height; + context.putImageData(image, 0, 0); +} + +function drawGuide(canvas: HTMLCanvasElement, guide: NumberedGuide) { + const context = canvas.getContext('2d'); + if (!context) return; + canvas.width = guide.width; + canvas.height = guide.height; + const image = context.createImageData(guide.width, guide.height); + + for (let index = 0; index < guide.target.length; index += 1) { + const paletteIndex = guide.target[index]; + if (paletteIndex === 0) continue; + const color = guide.palette[paletteIndex - 1]; + const rgb = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); + if (!rgb) continue; + + const dataIndex = index * 4; + image.data[dataIndex] = parseInt(rgb[1], 16); + image.data[dataIndex + 1] = parseInt(rgb[2], 16); + image.data[dataIndex + 2] = parseInt(rgb[3], 16); + image.data[dataIndex + 3] = 255; + } + + context.putImageData(image, 0, 0); +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-paint-by-number-dialog': PFPaintByNumberDialog; + } +} diff --git a/src/components/menu/pf-menu-bar.ts b/src/components/menu/pf-menu-bar.ts index 2e01b6a..6deeede 100644 --- a/src/components/menu/pf-menu-bar.ts +++ b/src/components/menu/pf-menu-bar.ts @@ -580,6 +580,15 @@ export class PFMenuBar extends BaseComponent { ); } + showPaintByNumberDialog() { + this.dispatchEvent( + new CustomEvent("show-paint-by-number-dialog", { + bubbles: true, + composed: true, + }) + ); + } + showProjectBrowser() { this.dispatchEvent( new CustomEvent("show-project-browser", { @@ -710,6 +719,13 @@ export class PFMenuBar extends BaseComponent { + diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts new file mode 100644 index 0000000..a67af07 --- /dev/null +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const decodeImageFileMock = vi.hoisted(() => vi.fn()); +const sampleImageToGridMock = vi.hoisted(() => vi.fn()); +const generateNumberedGuideMock = vi.hoisted(() => vi.fn()); +const createGuidedProjectMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../src/services/paint-by-number/image-file', () => ({ + decodeImageFile: decodeImageFileMock, +})); + +vi.mock('../../../src/services/paint-by-number/image-sampling', () => ({ + sampleImageToGrid: sampleImageToGridMock, +})); + +vi.mock('../../../src/services/paint-by-number/guide-generator', () => ({ + generateNumberedGuide: generateNumberedGuideMock, +})); + +vi.mock('../../../src/services/paint-by-number/guided-project', () => ({ + createGuidedProject: createGuidedProjectMock, +})); + +import '../../../src/components/dialogs/pf-paint-by-number-dialog'; +import type { PFPaintByNumberDialog } from '../../../src/components/dialogs/pf-paint-by-number-dialog'; + +const sourceImage = { + width: 4, + height: 2, + data: new Uint8ClampedArray(32), +} as ImageData; + +const sampledImage = { + width: 2, + height: 1, + data: new Uint8ClampedArray(8), +} as ImageData; + +const guide = { + palette: ['#111111', '#eeeeee'], + target: new Uint8Array([1, 2]), + width: 2, + height: 1, + complexity: { + paintableCells: 2, + paletteSize: 2, + isolatedCells: 2, + simplifiedCells: 1, + }, +}; + +async function settle(element: PFPaintByNumberDialog) { + await Promise.resolve(); + await element.updateComplete; + await Promise.resolve(); + await element.updateComplete; +} + +async function createDialog() { + const element = document.createElement( + 'pf-paint-by-number-dialog', + ) as PFPaintByNumberDialog; + element.open = true; + document.body.append(element); + await settle(element); + return element; +} + +function chooseFile(element: PFPaintByNumberDialog, file: File) { + const input = element.shadowRoot?.querySelector( + '#guided-source-file', + ); + Object.defineProperty(input, 'files', { + configurable: true, + value: [file], + }); + input?.dispatchEvent(new Event('change')); +} + +describe('pf-paint-by-number-dialog', () => { + beforeEach(() => { + vi.useFakeTimers(); + document.body.replaceChildren(); + vi.clearAllMocks(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + decodeImageFileMock.mockResolvedValue(sourceImage); + sampleImageToGridMock.mockReturnValue(sampledImage); + generateNumberedGuideMock.mockReturnValue(guide); + createGuidedProjectMock.mockResolvedValue({ + ok: true, + item: {}, + projectId: 'guided-project', + }); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('generates a balanced preview and creates a separate guided project', async () => { + const element = await createDialog(); + const file = new File(['pixels'], 'portrait.png', { type: 'image/png' }); + let createdProjectId = ''; + element.addEventListener('project-created', (event) => { + createdProjectId = (event as CustomEvent<{ id: string }>).detail.id; + }); + + chooseFile(element, file); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await settle(element); + + expect(sampleImageToGridMock).toHaveBeenCalledWith(sourceImage, { + longSide: 24, + }); + expect(generateNumberedGuideMock).toHaveBeenCalledWith(sampledImage, { + maxColors: 8, + palette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'generated', + maxColors: 8, + restrictedPalette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + expect(createdProjectId).toBe('guided-project'); + expect(element.open).toBe(false); + }); + + it('ignores a stale image decode after a newer file is selected', async () => { + const element = await createDialog(); + const firstImage = { ...sourceImage, width: 10 } as ImageData; + const secondImage = { ...sourceImage, width: 20 } as ImageData; + let resolveFirst: ((image: ImageData) => void) | undefined; + decodeImageFileMock + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirst = resolve; + })) + .mockResolvedValueOnce(secondImage); + + chooseFile(element, new File(['first'], 'first.png', { type: 'image/png' })); + chooseFile(element, new File(['second'], 'second.png', { type: 'image/png' })); + await Promise.resolve(); + resolveFirst?.(firstImage); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(sampleImageToGridMock).toHaveBeenCalledTimes(1); + expect(sampleImageToGridMock).toHaveBeenCalledWith(secondImage, { + longSide: 24, + }); + }); + + it('keeps the dialog open when project creation reaches the workspace limit', async () => { + const element = await createDialog(); + createGuidedProjectMock.mockResolvedValue({ + ok: false, + reason: 'tab-limit-reached', + message: 'Workspace limit reached.', + }); + + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await settle(element); + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(element.open).toBe(true); + expect(element.shadowRoot?.textContent).toContain('Workspace limit reached.'); + }); +}); diff --git a/tests/components/menu/pf-menu-bar.test.ts b/tests/components/menu/pf-menu-bar.test.ts index 4e71e82..7aa5ebe 100644 --- a/tests/components/menu/pf-menu-bar.test.ts +++ b/tests/components/menu/pf-menu-bar.test.ts @@ -316,6 +316,25 @@ describe("pf-menu-bar popovers", () => { expect(fileButton?.getAttribute("aria-expanded")).toBe("false"); }); + it("opens guided drawing setup from the File menu", async () => { + const element = await createMenuBar(); + const fileButton = button(element, "file"); + const fileMenu = menu(element, "file"); + let guidedDrawingRequested = false; + + element.addEventListener("show-paint-by-number-dialog", () => { + guidedDrawingRequested = true; + }); + + fileButton?.click(); + await element.updateComplete; + menuItem(fileMenu!, "New Guided Drawing")?.click(); + await element.updateComplete; + + expect(guidedDrawingRequested).toBe(true); + expect(fileButton?.getAttribute("aria-expanded")).toBe("false"); + }); + it("imports a reference image from the File menu into the project active when the picker opened", async () => { const contextA = createContext("Context A"); const contextB = createContext("Context B"); From 8450b64ba7de76e6e052956b75521d1ba138f62b Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:07:06 +0200 Subject: [PATCH 08/15] Derive guided drawing progress (#311) --- .../paint-by-number/guided-progress.ts | 82 ++++++++++++++ .../paint-by-number/guided-progress.test.ts | 106 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/services/paint-by-number/guided-progress.ts create mode 100644 tests/services/paint-by-number/guided-progress.test.ts diff --git a/src/services/paint-by-number/guided-progress.ts b/src/services/paint-by-number/guided-progress.ts new file mode 100644 index 0000000..53434a3 --- /dev/null +++ b/src/services/paint-by-number/guided-progress.ts @@ -0,0 +1,82 @@ +import type { ProjectContext } from '../../stores/project-context'; +import type { GuidedDrawingSession } from '../../types/guided-drawing'; + +export interface GuidedDrawingSnapshot { + session: GuidedDrawingSession; + pixels: Uint8ClampedArray; +} + +export interface GuidedDrawingProgress { + total: number; + covered: number; + remaining: number; + percentage: number; + remainingByNumber: Uint32Array; +} + +export function analyzeGuidedDrawingProgress( + target: Uint8Array, + pixels: Uint8ClampedArray, +): GuidedDrawingProgress { + if (pixels.length !== target.length * 4) { + throw new RangeError('Guided drawing pixels do not match the target buffer'); + } + + const remainingByNumber = new Uint32Array(findHighestGuideNumber(target) + 1); + let total = 0; + let covered = 0; + + for (let index = 0; index < target.length; index += 1) { + const guideNumber = target[index]; + if (guideNumber === 0) continue; + + total += 1; + if (pixels[index * 4 + 3] > 0) { + covered += 1; + } else { + remainingByNumber[guideNumber] += 1; + } + } + + const remaining = total - covered; + return { + total, + covered, + remaining, + percentage: total === 0 ? 0 : Math.round((covered / total) * 100), + remainingByNumber, + }; +} + +export function getGuidedDrawingSnapshot( + context: ProjectContext, +): GuidedDrawingSnapshot | null { + const session = context.guidedDrawing.session.value; + if (!session) return null; + + const paintingLayer = context.layers.layers.value.find((layer) => layer.type === 'image'); + const canvas = paintingLayer?.canvas; + if (!canvas || canvas.width !== session.width || canvas.height !== session.height) { + return null; + } + + const drawingContext = canvas.getContext('2d', { willReadFrequently: true }); + if (!drawingContext) return null; + + try { + return { + session, + pixels: drawingContext.getImageData(0, 0, session.width, session.height).data, + }; + } catch { + return null; + } +} + +function findHighestGuideNumber(target: Uint8Array): number { + let highest = 0; + for (const guideNumber of target) { + highest = Math.max(highest, guideNumber); + } + return highest; +} diff --git a/tests/services/paint-by-number/guided-progress.test.ts b/tests/services/paint-by-number/guided-progress.test.ts new file mode 100644 index 0000000..9497bc9 --- /dev/null +++ b/tests/services/paint-by-number/guided-progress.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + analyzeGuidedDrawingProgress, + getGuidedDrawingSnapshot, +} from '../../../src/services/paint-by-number/guided-progress'; +import { createProjectContext } from '../../../src/stores/project-context'; +import { GUIDED_DRAWING_VERSION } from '../../../src/types/guided-drawing'; + +describe('guided drawing progress', () => { + it('counts any visible painted color as creative coverage', () => { + const target = Uint8Array.from([1, 2, 0, 2]); + const pixels = new Uint8ClampedArray(target.length * 4); + pixels.set([18, 52, 86, 255], 0); + pixels.set([250, 10, 190, 128], 12); + + const progress = analyzeGuidedDrawingProgress(target, pixels); + + expect(progress).toMatchObject({ + total: 3, + covered: 2, + remaining: 1, + percentage: 67, + }); + expect([...progress.remainingByNumber]).toEqual([0, 0, 1]); + }); + + it('treats an erased cell as remaining again', () => { + const target = Uint8Array.from([1, 1]); + const painted = new Uint8ClampedArray([ + 255, 0, 0, 255, + 0, 0, 0, 0, + ]); + + expect(analyzeGuidedDrawingProgress(target, painted)).toMatchObject({ + covered: 1, + remaining: 1, + percentage: 50, + }); + + painted[3] = 0; + expect(analyzeGuidedDrawingProgress(target, painted)).toMatchObject({ + covered: 0, + remaining: 2, + percentage: 0, + }); + }); + + it('handles empty and fully covered targets without grading colors', () => { + const empty = analyzeGuidedDrawingProgress( + new Uint8Array(2), + new Uint8ClampedArray(8), + ); + expect(empty).toMatchObject({ total: 0, covered: 0, remaining: 0, percentage: 0 }); + + const full = analyzeGuidedDrawingProgress( + Uint8Array.from([1, 2]), + new Uint8ClampedArray([ + 1, 1, 1, 1, + 2, 2, 2, 255, + ]), + ); + expect(full).toMatchObject({ total: 2, covered: 2, remaining: 0, percentage: 100 }); + }); + + it('rejects pixel buffers that cannot align with the guide', () => { + expect(() => + analyzeGuidedDrawingProgress(Uint8Array.from([1]), new Uint8ClampedArray(3)), + ).toThrow('Guided drawing pixels do not match the target buffer'); + }); + + it('reads the fixed painting layer from a guided project context', () => { + const context = createProjectContext(); + context.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 2, + height: 1, + target: Uint8Array.from([1, 2]), + settings: { + longSide: 2, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + + const layer = context.layers.layers.value.find((item) => item.type === 'image'); + const pixels = new Uint8ClampedArray([ + 0, 0, 0, 255, + 0, 0, 0, 0, + ]); + const canvas = { + width: 2, + height: 1, + getContext: () => ({ getImageData: () => ({ data: pixels }) }), + } as unknown as HTMLCanvasElement; + if (layer) context.layers.updateLayer(layer.id, { canvas }); + + const snapshot = getGuidedDrawingSnapshot(context); + expect(snapshot?.session.target).toEqual(Uint8Array.from([1, 2])); + expect(snapshot?.pixels[3]).toBe(255); + + context.dispose(); + }); +}); From 4437e3c04558f8f00558c57d37a09634f51a1b98 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:09:06 +0200 Subject: [PATCH 09/15] Render readable guided cell numbers (#312) --- src/components/canvas/pf-canvas-viewport.ts | 2 + .../canvas/pf-guided-number-overlay.ts | 217 ++++++++++++++++++ .../canvas/pf-guided-number-overlay.test.ts | 98 ++++++++ 3 files changed, 317 insertions(+) create mode 100644 src/components/canvas/pf-guided-number-overlay.ts create mode 100644 tests/components/canvas/pf-guided-number-overlay.test.ts diff --git a/src/components/canvas/pf-canvas-viewport.ts b/src/components/canvas/pf-canvas-viewport.ts index ef9e0eb..122c97a 100644 --- a/src/components/canvas/pf-canvas-viewport.ts +++ b/src/components/canvas/pf-canvas-viewport.ts @@ -5,6 +5,7 @@ import { defaultProjectContext, type ProjectContext } from '../../stores/project import './pf-selection-overlay'; import './pf-marching-ants-overlay'; import './pf-brush-cursor-overlay'; +import './pf-guided-number-overlay'; import './pf-transform-handles'; import './pf-reference-transform-handles'; import './pf-text-input'; @@ -181,6 +182,7 @@ export class PFCanvasViewport extends BaseComponent { + diff --git a/src/components/canvas/pf-guided-number-overlay.ts b/src/components/canvas/pf-guided-number-overlay.ts new file mode 100644 index 0000000..32b3282 --- /dev/null +++ b/src/components/canvas/pf-guided-number-overlay.ts @@ -0,0 +1,217 @@ +import { css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { getGuidedDrawingSnapshot } from '../../services/paint-by-number/guided-progress'; +import { defaultProjectContext, type ProjectContext } from '../../stores/project-context'; +import { CanvasOverlay } from './canvas-overlay'; + +const NUMBER_ZOOM_THRESHOLD = 16; + +export interface GuidedNumberCell { + guideNumber: number; + screenX: number; + screenY: number; + size: number; +} + +export interface GuidedNumberViewport { + zoom: number; + panX: number; + panY: number; + viewportWidth: number; + viewportHeight: number; +} + +export function collectVisibleGuidedNumberCells( + target: Uint8Array, + pixels: Uint8ClampedArray, + width: number, + viewport: GuidedNumberViewport, +): GuidedNumberCell[] { + if (pixels.length !== target.length * 4) { + throw new RangeError('Guided drawing pixels do not match the target buffer'); + } + if (viewport.zoom < NUMBER_ZOOM_THRESHOLD) return []; + + const cells: GuidedNumberCell[] = []; + for (let index = 0; index < target.length; index += 1) { + const guideNumber = target[index]; + if (guideNumber === 0 || pixels[index * 4 + 3] > 0) continue; + + const x = index % width; + const y = Math.floor(index / width); + const screenX = x * viewport.zoom + viewport.panX; + const screenY = y * viewport.zoom + viewport.panY; + if (!isCellVisible(screenX, screenY, viewport)) continue; + + cells.push({ + guideNumber, + screenX, + screenY, + size: viewport.zoom, + }); + } + + return cells; +} + +@customElement('pf-guided-number-overlay') +export class PFGuidedNumberOverlay extends CanvasOverlay { + static styles = css` + :host { + position: absolute; + inset: 0; + z-index: 49; + pointer-events: none; + } + + canvas { + width: 100%; + height: 100%; + } + + .zoom-hint { + position: absolute; + inset-block-start: 34px; + inset-inline-start: 50%; + z-index: 3; + margin: 0; + padding: 6px 9px; + transform: translateX(-50%); + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: rgba(13, 16, 21, 0.9); + color: var(--pf-color-text-secondary); + box-shadow: var(--pf-shadow-md); + font-size: var(--pf-font-size-xs); + white-space: nowrap; + } + `; + + private context: ProjectContext = defaultProjectContext; + private animationFrameId = 0; + + connectedCallback() { + super.connectedCallback(); + this.subscribeToActiveProjectContext((context) => { + this.context = context; + this.requestUpdate(); + this.scheduleDraw(); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = 0; + } + + firstUpdated() { + super.firstUpdated(); + this.scheduleDraw(); + } + + updated() { + this.scheduleDraw(); + } + + protected resizeCanvas() { + super.resizeCanvas(); + this.scheduleDraw(); + } + + render() { + const session = this.context.guidedDrawing.session.value; + const zoom = this.context.viewport.zoom.value; + void this.context.viewport.panX.value; + void this.context.viewport.panY.value; + void this.context.history.version.value; + void this.context.layers.layers.value; + + return html` + + ${session && zoom < NUMBER_ZOOM_THRESHOLD + ? html` +

+ Zoom in to see guide numbers +

+ ` + : ''} + `; + } + + private scheduleDraw() { + if (this.animationFrameId) return; + this.animationFrameId = requestAnimationFrame(() => { + this.animationFrameId = 0; + this.draw(); + }); + } + + private draw() { + const drawingContext = this.prepareFrame(); + if (!drawingContext) return; + + const snapshot = getGuidedDrawingSnapshot(this.context); + if (!snapshot) return; + + const { viewport } = this.context; + const cells = collectVisibleGuidedNumberCells( + snapshot.session.target, + snapshot.pixels, + snapshot.session.width, + { + zoom: viewport.zoom.value, + panX: viewport.panX.value, + panY: viewport.panY.value, + viewportWidth: this.clientWidth, + viewportHeight: this.clientHeight, + }, + ); + + this.drawCells(drawingContext, cells); + } + + private drawCells(context: CanvasRenderingContext2D, cells: GuidedNumberCell[]) { + const fontFamily = getComputedStyle(this).fontFamily || '"Departure Mono", monospace'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + for (const cell of cells) { + const label = String(cell.guideNumber); + const fontSize = Math.min(14, cell.size * (label.length > 1 ? 0.5 : 0.58)); + const inset = Math.max(1, Math.round(cell.size * 0.1)); + const badgeWidth = cell.size - inset * 2; + const badgeHeight = cell.size - inset * 2; + const centerX = cell.screenX + cell.size / 2; + const centerY = cell.screenY + cell.size / 2; + + context.fillStyle = 'rgba(7, 9, 13, 0.78)'; + context.fillRect( + Math.round(cell.screenX + inset), + Math.round(cell.screenY + inset), + Math.max(1, Math.round(badgeWidth)), + Math.max(1, Math.round(badgeHeight)), + ); + context.font = `700 ${fontSize}px ${fontFamily}`; + context.fillStyle = '#f5f3ee'; + context.fillText(label, centerX, centerY + 0.5, badgeWidth); + } + } +} + +function isCellVisible( + screenX: number, + screenY: number, + viewport: GuidedNumberViewport, +): boolean { + return screenX + viewport.zoom >= 0 + && screenY + viewport.zoom >= 0 + && screenX <= viewport.viewportWidth + && screenY <= viewport.viewportHeight; +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-guided-number-overlay': PFGuidedNumberOverlay; + } +} diff --git a/tests/components/canvas/pf-guided-number-overlay.test.ts b/tests/components/canvas/pf-guided-number-overlay.test.ts new file mode 100644 index 0000000..3793b15 --- /dev/null +++ b/tests/components/canvas/pf-guided-number-overlay.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + collectVisibleGuidedNumberCells, + type PFGuidedNumberOverlay, +} from '../../../src/components/canvas/pf-guided-number-overlay'; +import '../../../src/components/canvas/pf-canvas-viewport'; +import { createProjectContext, restoreDefaultProjectContext, setActiveProjectContext } from '../../../src/stores/project-context'; +import { GUIDED_DRAWING_VERSION } from '../../../src/types/guided-drawing'; + +describe('guided number overlay', () => { + afterEach(() => { + document.body.innerHTML = ''; + restoreDefaultProjectContext(); + }); + + it('plans every visible unpainted numbered cell at readable zoom', () => { + const cells = collectVisibleGuidedNumberCells( + Uint8Array.from([1, 2, 3, 4]), + new Uint8ClampedArray([ + 0, 0, 0, 0, + 20, 30, 40, 255, + 0, 0, 0, 0, + 0, 0, 0, 0, + ]), + 2, + { + zoom: 16, + panX: 0, + panY: 0, + viewportWidth: 32, + viewportHeight: 16, + }, + ); + + expect(cells.map((cell) => cell.guideNumber)).toEqual([1, 3, 4]); + }); + + it('does not plan illegible or off-screen numbers', () => { + const target = Uint8Array.from([1, 2]); + const pixels = new Uint8ClampedArray(8); + + expect(collectVisibleGuidedNumberCells(target, pixels, 2, { + zoom: 8, + panX: 0, + panY: 0, + viewportWidth: 100, + viewportHeight: 100, + })).toEqual([]); + + expect(collectVisibleGuidedNumberCells(target, pixels, 2, { + zoom: 16, + panX: -40, + panY: 0, + viewportWidth: 16, + viewportHeight: 16, + })).toEqual([]); + }); + + it('shows a DOM hint below the number threshold', async () => { + const context = createProjectContext(); + context.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 1, + height: 1, + target: Uint8Array.from([1]), + settings: { + longSide: 1, + paletteSource: 'generated', + maxColors: 1, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + context.viewport.setZoom(8); + setActiveProjectContext(context); + + const element = document.createElement('pf-guided-number-overlay') as PFGuidedNumberOverlay; + document.body.append(element); + await element.updateComplete; + + expect(element.shadowRoot?.textContent).toContain('Zoom in to see guide numbers'); + + context.viewport.setZoom(16); + await element.updateComplete; + expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers'); + + context.dispose(); + }); + + it('mounts the guidance layer inside the shared canvas viewport', async () => { + const viewport = document.createElement('pf-canvas-viewport'); + document.body.append(viewport); + await viewport.updateComplete; + + expect(viewport.shadowRoot?.querySelector('pf-guided-number-overlay')).not.toBeNull(); + }); +}); From f1eef21cf27a4385e1d8a0fbae204c3e63127c0e Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:12:50 +0200 Subject: [PATCH 10/15] Add guided palette and creative progress (#313) --- .../color/palette-panel/pf-guided-palette.ts | 294 ++++++++++++++++++ src/components/color/pf-palette-panel.ts | 5 + .../paint-by-number/guided-project.ts | 1 + src/stores/guided-drawing-store.ts | 8 + src/types/guided-drawing.ts | 1 + .../color/pf-guided-palette.test.ts | 126 ++++++++ .../paint-by-number/guided-project.test.ts | 1 + 7 files changed, 436 insertions(+) create mode 100644 src/components/color/palette-panel/pf-guided-palette.ts create mode 100644 tests/components/color/pf-guided-palette.test.ts diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts new file mode 100644 index 0000000..074a43d --- /dev/null +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -0,0 +1,294 @@ +import { css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { BaseComponent } from '../../../core/base-component'; +import { + analyzeGuidedDrawingProgress, + getGuidedDrawingSnapshot, + type GuidedDrawingProgress, +} from '../../../services/paint-by-number/guided-progress'; +import { defaultProjectContext, type ProjectContext } from '../../../stores/project-context'; + +const EMPTY_PROGRESS: GuidedDrawingProgress = { + total: 0, + covered: 0, + remaining: 0, + percentage: 0, + remainingByNumber: new Uint32Array(1), +}; + +@customElement('pf-guided-palette') +export class PFGuidedPalette extends BaseComponent { + static styles = css` + :host { + display: grid; + gap: 12px; + } + + h2, + h3, + p { + margin: 0; + } + + h2, + h3 { + color: var(--pf-color-text-secondary); + font-size: var(--pf-font-size-xs); + text-transform: uppercase; + } + + .progress-copy, + .remaining, + .artist-copy { + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.4; + } + + progress { + width: 100%; + height: 6px; + overflow: hidden; + border: 0; + border-radius: 999px; + background: var(--pf-color-bg-input); + } + + progress::-webkit-progress-bar { + background: var(--pf-color-bg-input); + } + + progress::-webkit-progress-value { + background: var(--pf-color-accent); + } + + progress::-moz-progress-bar { + background: var(--pf-color-accent); + } + + .guide-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + } + + .guide-color { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + min-width: 0; + padding: 5px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: var(--pf-color-bg-dark); + color: var(--pf-color-text-main); + cursor: pointer; + text-align: start; + } + + .guide-color:hover { + border-color: var(--pf-color-border-strong); + background: var(--pf-color-bg-hover); + } + + .guide-color[aria-pressed='true'] { + border-color: var(--pf-color-accent); + box-shadow: 0 0 0 1px var(--pf-color-primary-transparent); + } + + button:focus-visible { + outline: 2px solid var(--pf-color-accent); + outline-offset: 2px; + } + + .chip-wrap { + position: relative; + width: 30px; + height: 30px; + } + + .chip { + display: block; + width: 100%; + height: 100%; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 2px; + background: var(--guide-color); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.38) inset; + } + + .number { + position: absolute; + inset-block-start: -3px; + inset-inline-start: -3px; + min-width: 16px; + padding: 1px 3px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 2px; + background: rgba(7, 9, 13, 0.9); + color: #f5f3ee; + font-size: 9px; + font-weight: 700; + line-height: 12px; + text-align: center; + } + + .color-copy { + display: grid; + min-width: 0; + } + + .hex { + overflow: hidden; + color: var(--pf-color-text-secondary); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .remaining { + font-size: 9px; + white-space: nowrap; + } + + .artist-section { + display: grid; + gap: 7px; + padding-block-start: 2px; + border-block-start: 1px solid var(--pf-color-border); + } + + .artist-grid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 2px; + } + + .artist-color { + aspect-ratio: 1; + min-width: 0; + padding: 0; + border: 1px solid var(--pf-color-border); + border-radius: 2px; + background: var(--artist-color); + cursor: pointer; + } + + .artist-color[aria-pressed='true'] { + outline: 2px solid var(--pf-color-accent); + outline-offset: 1px; + } + `; + + private context: ProjectContext = defaultProjectContext; + + connectedCallback() { + super.connectedCallback(); + this.subscribeToActiveProjectContext((context) => { + this.context = context; + this.requestUpdate(); + }); + } + + render() { + const session = this.context.guidedDrawing.session.value; + const palette = this.context.palette.mainColors.value; + const primaryColor = this.context.colors.primaryColor.value.toLowerCase(); + void this.context.history.version.value; + void this.context.layers.layers.value; + + if (!session) return html``; + + const progress = this.getProgress(); + const guideColorCount = session.guideColorCount ?? findHighestGuideNumber(session.target); + const guideColors = palette.slice(0, guideColorCount); + const artistColors = palette.slice(guideColorCount); + + return html` +
+

Guided palette

+

+ ${progress.covered} of ${progress.total} cells covered · + ${progress.remaining} remaining · ${progress.percentage}% +

+ +
+ +
+ ${guideColors.map((color, index) => { + const guideNumber = index + 1; + const remaining = progress.remainingByNumber[guideNumber] ?? 0; + const selected = color.toLowerCase() === primaryColor; + return html` + + `; + })} +
+ + ${artistColors.length > 0 + ? html` +
+

Artist colors

+

Colors you introduced while drawing off-guide.

+
+ ${artistColors.map((color) => html` + + `)} +
+
+ ` + : ''} + `; + } + + private getProgress(): GuidedDrawingProgress { + const snapshot = getGuidedDrawingSnapshot(this.context); + return snapshot + ? analyzeGuidedDrawingProgress(snapshot.session.target, snapshot.pixels) + : EMPTY_PROGRESS; + } + + private selectColor(color: string) { + this.context.colors.setPrimaryColor(color); + this.context.colors.updateLightnessVariations(color); + } +} + +function findHighestGuideNumber(target: Uint8Array): number { + let highest = 0; + for (const guideNumber of target) highest = Math.max(highest, guideNumber); + return highest; +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-guided-palette': PFGuidedPalette; + } +} diff --git a/src/components/color/pf-palette-panel.ts b/src/components/color/pf-palette-panel.ts index 4edc67c..f93bfd0 100644 --- a/src/components/color/pf-palette-panel.ts +++ b/src/components/color/pf-palette-panel.ts @@ -10,6 +10,7 @@ import './pf-save-palette-dialog'; import './pf-unsaved-changes-dialog'; import './palette-panel/pf-palette-toolbar'; import './palette-panel/pf-palette-grid'; +import './palette-panel/pf-guided-palette'; import './palette-panel/pf-extraction-section'; @customElement('pf-palette-panel') @@ -247,6 +248,10 @@ export class PFPalettePanel extends BaseComponent { } render() { + if (this.context.guidedDrawing.active) { + return html``; + } + const hasNewMarks = this.palette.newColorFlags.value.size > 0; return html` diff --git a/src/services/paint-by-number/guided-project.ts b/src/services/paint-by-number/guided-project.ts index 2a9cc05..f675e3d 100644 --- a/src/services/paint-by-number/guided-project.ts +++ b/src/services/paint-by-number/guided-project.ts @@ -71,6 +71,7 @@ export function createGuidedProjectFile(input: GuidedProjectInput): ProjectFile width: guide.width, height: guide.height, target: Array.from(guide.target), + guideColorCount: guide.palette.length, settings: cloneSettings(input.settings), sourceName: input.sourceName, createdAt: input.createdAt ?? Date.now(), diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts index 03f368a..d8b15ec 100644 --- a/src/stores/guided-drawing-store.ts +++ b/src/stores/guided-drawing-store.ts @@ -62,6 +62,14 @@ function validateSession(session: GuidedDrawingSession): void { if (session.target.length !== session.width * session.height) { throw new RangeError('Guided drawing target does not match its dimensions'); } + validateGuideColorCount(session.guideColorCount); +} + +function validateGuideColorCount(guideColorCount: number | undefined): void { + if (guideColorCount === undefined) return; + if (Number.isInteger(guideColorCount) && guideColorCount > 0) return; + + throw new RangeError('Guided drawing color count must be a positive integer'); } function cloneSession(session: GuidedDrawingSession): GuidedDrawingSession { diff --git a/src/types/guided-drawing.ts b/src/types/guided-drawing.ts index c35c3b2..4a75196 100644 --- a/src/types/guided-drawing.ts +++ b/src/types/guided-drawing.ts @@ -17,6 +17,7 @@ export interface GuidedDrawingSessionFile { width: number; height: number; target: number[]; + guideColorCount?: number; settings: GuidedDrawingSettings; sourceName?: string; createdAt: number; diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts new file mode 100644 index 0000000..55db843 --- /dev/null +++ b/tests/components/color/pf-guided-palette.test.ts @@ -0,0 +1,126 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { PFGuidedPalette } from '../../../src/components/color/palette-panel/pf-guided-palette'; +import '../../../src/components/color/palette-panel/pf-guided-palette'; +import '../../../src/components/color/pf-palette-panel'; +import { createProjectContext, restoreDefaultProjectContext, setActiveProjectContext } from '../../../src/stores/project-context'; +import { GUIDED_DRAWING_VERSION } from '../../../src/types/guided-drawing'; + +describe('guided palette', () => { + const originalHidePopover = HTMLElement.prototype.hidePopover; + + beforeAll(() => { + HTMLElement.prototype.hidePopover = () => {}; + }); + + afterAll(() => { + if (originalHidePopover) { + HTMLElement.prototype.hidePopover = originalHidePopover; + } else { + delete (HTMLElement.prototype as Partial).hidePopover; + } + }); + + afterEach(() => { + document.body.innerHTML = ''; + restoreDefaultProjectContext(); + }); + + it('shows numbered semantic colors and creative coverage', async () => { + const context = createGuidedContext(); + setActiveProjectContext(context); + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + const buttons = [...element.shadowRoot!.querySelectorAll('.guide-color')]; + expect(buttons).toHaveLength(2); + expect(buttons[0].getAttribute('aria-label')).toContain( + 'Guide color 1, #111111, 0 cells remaining', + ); + expect(buttons[1].getAttribute('aria-label')).toContain( + 'Guide color 2, #eeeeee, 1 cells remaining', + ); + expect(element.shadowRoot?.textContent).toContain('1 of 2 cells covered'); + expect(element.shadowRoot?.textContent).toContain('1 remaining · 50%'); + + buttons[1].click(); + await element.updateComplete; + expect(context.colors.primaryColor.value).toBe('#eeeeee'); + expect(buttons[1].getAttribute('aria-pressed')).toBe('true'); + + context.dispose(); + }); + + it('keeps off-guide artist colors selectable without numbering them', async () => { + const context = createGuidedContext(); + context.palette.mainColors.value = ['#111111', '#eeeeee', '#ff00ff']; + context.palette.rebuildColorMap(); + setActiveProjectContext(context); + + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + const artistColor = element.shadowRoot?.querySelector('.artist-color'); + expect(artistColor?.getAttribute('aria-label')).toBe('Artist color #ff00ff'); + artistColor?.click(); + await element.updateComplete; + expect(context.colors.primaryColor.value).toBe('#ff00ff'); + + context.dispose(); + }); + + it('replaces normal palette editing only for guided projects', async () => { + const guidedContext = createGuidedContext(); + setActiveProjectContext(guidedContext); + const panel = document.createElement('pf-palette-panel'); + document.body.append(panel); + await panel.updateComplete; + + expect(panel.shadowRoot?.querySelector('pf-guided-palette')).not.toBeNull(); + expect(panel.shadowRoot?.querySelector('pf-palette-toolbar')).toBeNull(); + + const normalContext = createProjectContext(); + setActiveProjectContext(normalContext); + await panel.updateComplete; + expect(panel.shadowRoot?.querySelector('pf-guided-palette')).toBeNull(); + expect(panel.shadowRoot?.querySelector('pf-palette-toolbar')).not.toBeNull(); + + guidedContext.dispose(); + normalContext.dispose(); + }); +}); + +function createGuidedContext() { + const context = createProjectContext(); + const layer = context.layers.layers.value.find((item) => item.type === 'image'); + const pixels = new Uint8ClampedArray([ + 12, 12, 12, 255, + 0, 0, 0, 0, + ]); + const canvas = { + width: 2, + height: 1, + getContext: () => ({ getImageData: () => ({ data: pixels }) }), + } as unknown as HTMLCanvasElement; + if (layer) context.layers.updateLayer(layer.id, { canvas }); + + context.palette.mainColors.value = ['#111111', '#eeeeee']; + context.palette.rebuildColorMap(); + context.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 2, + height: 1, + target: Uint8Array.from([1, 2]), + guideColorCount: 2, + settings: { + longSide: 2, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + return context; +} diff --git a/tests/services/paint-by-number/guided-project.test.ts b/tests/services/paint-by-number/guided-project.test.ts index eb77edd..a5587a4 100644 --- a/tests/services/paint-by-number/guided-project.test.ts +++ b/tests/services/paint-by-number/guided-project.test.ts @@ -47,6 +47,7 @@ describe('createGuidedProjectFile', () => { expect(file.guidedDrawing).toMatchObject({ version: 1, target: [1, 2, 0, 1], + guideColorCount: 2, settings, sourceName: 'portrait.png', createdAt: 123, From adc43c470d03a6f7d68da7d1cae6ba3c81a5b1e0 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:18:12 +0200 Subject: [PATCH 11/15] Keep guided project structure aligned (#314) --- .../color/palette-panel/pf-guided-palette.ts | 14 +- src/components/dialogs/pf-resize-dialog.ts | 45 ++- src/components/menu/pf-menu-bar.ts | 169 ++++++-- .../timeline/pf-playback-controls.ts | 24 +- src/components/timeline/pf-timeline-layers.ts | 381 ++++++++---------- src/components/timeline/pf-timeline.ts | 30 +- .../color/pf-guided-palette.test.ts | 3 + .../dialogs/pf-resize-dialog.test.ts | 70 ++++ tests/components/menu/pf-menu-bar.test.ts | 35 ++ ...f-timeline-layers-reference-import.test.ts | 116 ------ .../timeline/timeline-active-context.test.ts | 61 ++- 11 files changed, 567 insertions(+), 381 deletions(-) create mode 100644 tests/components/dialogs/pf-resize-dialog.test.ts delete mode 100644 tests/components/timeline/pf-timeline-layers-reference-import.test.ts diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts index 074a43d..290cedc 100644 --- a/src/components/color/palette-panel/pf-guided-palette.ts +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -39,12 +39,19 @@ export class PFGuidedPalette extends BaseComponent { .progress-copy, .remaining, - .artist-copy { + .artist-copy, + .structure-note { color: var(--pf-color-text-muted); font-size: var(--pf-font-size-xs); line-height: 1.4; } + .structure-note { + padding: 7px 8px; + border-inline-start: 2px solid var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + } + progress { width: 100%; height: 6px; @@ -245,6 +252,11 @@ export class PFGuidedPalette extends BaseComponent { })} +

+ Canvas, palette, layers, and frames stay fixed so the numbered guide remains aligned. + Drawing colors and tools are still yours. +

+ ${artistColors.length > 0 ? html`
diff --git a/src/components/dialogs/pf-resize-dialog.ts b/src/components/dialogs/pf-resize-dialog.ts index a20a502..49225f1 100644 --- a/src/components/dialogs/pf-resize-dialog.ts +++ b/src/components/dialogs/pf-resize-dialog.ts @@ -1,7 +1,7 @@ import { html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; -import { projectStore } from '../../stores/project'; +import { defaultProjectContext, type ProjectContext } from '../../stores/project-context'; import '../ui/pf-dialog'; @customElement('pf-resize-dialog') @@ -13,6 +13,13 @@ export class PFResizeDialog extends BaseComponent { gap: 4px; } + .restriction { + margin: 0; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.45; + } + label { font-size: 12px; color: var(--pf-color-text-muted); @@ -49,16 +56,27 @@ export class PFResizeDialog extends BaseComponent { @property({ type: Boolean }) open = false; @state() width = 64; @state() height = 64; + private context: ProjectContext = defaultProjectContext; + + connectedCallback() { + super.connectedCallback(); + this.subscribeToActiveProjectContext((context) => { + this.context = context; + if (this.open) this.readProjectSize(); + this.requestUpdate(); + }); + } willUpdate(changedProperties: Map) { // Update dimensions when dialog opens if (changedProperties.has('open') && this.open) { - this.width = projectStore.width.value; - this.height = projectStore.height.value; + this.readProjectSize(); } } render() { + const guidedProject = this.context.guidedDrawing.active; + return html` Resize Canvas + ${guidedProject + ? html` +

+ Guided projects keep their canvas size fixed so every number stays aligned. +

+ ` + : ''} +
this.width = parseInt((e.target as HTMLInputElement).value)} > @@ -83,6 +110,7 @@ export class PFResizeDialog extends BaseComponent { type="number" min="1" max="2048" + ?disabled=${guidedProject} .value=${this.height} @input=${(e: Event) => this.height = parseInt((e.target as HTMLInputElement).value)} > @@ -90,7 +118,7 @@ export class PFResizeDialog extends BaseComponent {
- +
`; @@ -102,9 +130,16 @@ export class PFResizeDialog extends BaseComponent { } apply() { + if (this.context.guidedDrawing.active) return; + const width = Math.max(1, Math.min(2048, this.width)); const height = Math.max(1, Math.min(2048, this.height)); - projectStore.resizeCanvas(width, height); + this.context.project.resizeCanvas(width, height); this.close(); } + + private readProjectSize() { + this.width = this.context.project.width.value; + this.height = this.context.project.height.value; + } } diff --git a/src/components/menu/pf-menu-bar.ts b/src/components/menu/pf-menu-bar.ts index 6deeede..c1a0bf7 100644 --- a/src/components/menu/pf-menu-bar.ts +++ b/src/components/menu/pf-menu-bar.ts @@ -1,12 +1,7 @@ import { html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; import { BaseComponent } from "../../core/base-component"; -import { historyStore } from "../../stores/history"; -import { layerStore } from "../../stores/layers"; -import { projectStore } from "../../stores/project"; import { getActiveProjectContext } from "../../stores/project-context"; -import { gridStore } from "../../stores/grid"; -import { viewportStore } from "../../stores/viewport"; import { FlipLayerCommand, RotateLayerCommand, @@ -244,11 +239,16 @@ export class PFMenuBar extends BaseComponent { width: 100%; } - .menu-item:hover { + .menu-item:hover:not(:disabled) { background-color: var(--pf-color-primary-transparent); color: var(--pf-color-text-main); } + button.menu-item:disabled { + cursor: not-allowed; + opacity: 0.45; + } + .menu-item.danger { color: #f0aaa2; } @@ -478,6 +478,14 @@ export class PFMenuBar extends BaseComponent { return Math.max(MENU_MARGIN, Math.min(value, maxValue)); } + private guidedActionTitle( + guidedProject: boolean, + regularTitle: string, + guidedTitle: string, + ): string { + return guidedProject ? guidedTitle : regularTitle; + } + private getMenuButton(menuId: MenuId) { return this.shadowRoot?.querySelector(`#btn-${menuId}`) ?? null; } @@ -487,16 +495,22 @@ export class PFMenuBar extends BaseComponent { } flipLayer(direction: "horizontal" | "vertical") { - const activeLayerId = layerStore.activeLayerId.value; + const context = getActiveProjectContext(); + if (context.guidedDrawing.active) return; + + const activeLayerId = context.layers.activeLayerId.value; if (activeLayerId) { - historyStore.execute(new FlipLayerCommand(activeLayerId, direction)); + void context.history.execute(new FlipLayerCommand(activeLayerId, direction, context)); } } rotateLayer(angle: number) { - const activeLayerId = layerStore.activeLayerId.value; + const context = getActiveProjectContext(); + if (context.guidedDrawing.active) return; + + const activeLayerId = context.layers.activeLayerId.value; if (activeLayerId) { - historyStore.execute(new RotateLayerCommand(activeLayerId, angle)); + void context.history.execute(new RotateLayerCommand(activeLayerId, angle, context)); } } @@ -544,12 +558,12 @@ export class PFMenuBar extends BaseComponent { const pako = await import("pako"); const decompressed = pako.default.inflate(new Uint8Array(buffer), { to: "string" }); const project = JSON.parse(decompressed) as ProjectFileInput; - await projectStore.loadProject(project); + await getActiveProjectContext().project.loadProject(project); } else { // JSON format (uncompressed) const text = await file.text(); const project = JSON.parse(text) as ProjectFileInput; - await projectStore.loadProject(project); + await getActiveProjectContext().project.loadProject(project); } } catch (error) { log.error("Failed to open file:", error); @@ -560,11 +574,25 @@ export class PFMenuBar extends BaseComponent { } importReferenceImage() { - openReferenceImagePicker(getActiveProjectContext(), (error) => { + const context = getActiveProjectContext(); + if (context.guidedDrawing.active) return; + + openReferenceImagePicker(context, (error) => { log.error("Failed to import reference image:", error); }); } + showResizeDialog() { + if (getActiveProjectContext().guidedDrawing.active) return; + + this.dispatchEvent( + new CustomEvent("resize-canvas", { + bubbles: true, + composed: true, + }) + ); + } + showExportDialog() { this.dispatchEvent( new CustomEvent("show-export-dialog", { bubbles: true, composed: true }) @@ -659,13 +687,15 @@ export class PFMenuBar extends BaseComponent { private async commitNameEdit(e: Event) { const input = e.target as HTMLInputElement; const newName = input.value.trim() || "Untitled"; - projectStore.name.value = newName; + getActiveProjectContext().project.name.value = newName; this.isEditingName = false; await autoSaveService.saveNow(); } render() { - const projectName = projectStore.name.value; + const context = getActiveProjectContext(); + const projectName = context.project.name.value; + const guidedProject = context.guidedDrawing.active; const activeCrtPreset = this.getActiveCrtPreset(); return html` @@ -742,9 +772,19 @@ export class PFMenuBar extends BaseComponent { - + @@ -767,10 +807,10 @@ export class PFMenuBar extends BaseComponent { @click=${this.handleMenuPanelClick} @toggle=${(event: Event) => this.handlePopoverToggle(event, "edit")} > -
+ ${this.renderViewControls()} +
${guideColors.map((color, index) => { const guideNumber = index + 1; @@ -287,6 +312,31 @@ export class PFGuidedPalette extends BaseComponent { : EMPTY_PROGRESS; } + private renderViewControls() { + const guidance = this.context.guidedDrawing; + const numbersVisible = guidance.numbersVisible.value; + const targetPreviewVisible = guidance.targetPreviewVisible.value; + + return html` +
+ + +
+ `; + } + private selectColor(color: string) { this.context.colors.setPrimaryColor(color); this.context.colors.updateLightnessVariations(color); diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts index d8b15ec..e4153c8 100644 --- a/src/stores/guided-drawing-store.ts +++ b/src/stores/guided-drawing-store.ts @@ -7,6 +7,8 @@ import { class GuidedDrawingStore { readonly session = signal(null); + readonly numbersVisible = signal(true); + readonly targetPreviewVisible = signal(false); get active(): boolean { return this.session.value !== null; @@ -14,9 +16,18 @@ class GuidedDrawingStore { start(session: GuidedDrawingSession): void { validateSession(session); + this.resetViewOptions(); this.session.value = cloneSession(session); } + toggleNumbers(): void { + this.numbersVisible.value = !this.numbersVisible.value; + } + + toggleTargetPreview(): void { + this.targetPreviewVisible.value = !this.targetPreviewVisible.value; + } + load(file: GuidedDrawingSessionFile | undefined): void { if (!file) { this.clear(); @@ -42,6 +53,12 @@ class GuidedDrawingStore { clear(): void { this.session.value = null; + this.resetViewOptions(); + } + + private resetViewOptions(): void { + this.numbersVisible.value = true; + this.targetPreviewVisible.value = false; } } diff --git a/tests/components/canvas/pf-guided-number-overlay.test.ts b/tests/components/canvas/pf-guided-number-overlay.test.ts index 3793b15..b218f3a 100644 --- a/tests/components/canvas/pf-guided-number-overlay.test.ts +++ b/tests/components/canvas/pf-guided-number-overlay.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { collectVisibleGuidedNumberCells, + collectVisibleGuidedTargetCells, type PFGuidedNumberOverlay, } from '../../../src/components/canvas/pf-guided-number-overlay'; import '../../../src/components/canvas/pf-canvas-viewport'; @@ -56,6 +57,22 @@ describe('guided number overlay', () => { })).toEqual([]); }); + it('plans a target preview at any zoom without reading painted pixels', () => { + const cells = collectVisibleGuidedTargetCells( + Uint8Array.from([1, 0, 2]), + 3, + { + zoom: 4, + panX: 0, + panY: 0, + viewportWidth: 12, + viewportHeight: 4, + }, + ); + + expect(cells.map((cell) => cell.guideNumber)).toEqual([1, 2]); + }); + it('shows a DOM hint below the number threshold', async () => { const context = createProjectContext(); context.guidedDrawing.start({ @@ -85,6 +102,11 @@ describe('guided number overlay', () => { await element.updateComplete; expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers'); + context.viewport.setZoom(8); + context.guidedDrawing.toggleNumbers(); + await element.updateComplete; + expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers'); + context.dispose(); }); diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts index aa35cee..bb4cd5e 100644 --- a/tests/components/color/pf-guided-palette.test.ts +++ b/tests/components/color/pf-guided-palette.test.ts @@ -46,6 +46,19 @@ describe('guided palette', () => { 'Canvas, palette, layers, and frames stay fixed', ); + const viewButtons = [ + ...element.shadowRoot!.querySelectorAll('.view-controls button'), + ]; + expect(viewButtons.map((button) => button.textContent?.trim())) + .toEqual(['Hide numbers', 'Preview target']); + viewButtons[0].click(); + viewButtons[1].click(); + await element.updateComplete; + expect(context.guidedDrawing.numbersVisible.value).toBe(false); + expect(context.guidedDrawing.targetPreviewVisible.value).toBe(true); + expect(viewButtons.map((button) => button.textContent?.trim())) + .toEqual(['Show numbers', 'Hide target']); + buttons[1].click(); await element.updateComplete; expect(context.colors.primaryColor.value).toBe('#eeeeee'); diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts index ad02740..d044ab7 100644 --- a/tests/stores/guided-drawing-store.test.ts +++ b/tests/stores/guided-drawing-store.test.ts @@ -59,4 +59,25 @@ describe('GuidedDrawingStore', () => { expect(() => store.start({ ...createSession(), target: new Uint8Array([1]) })) .toThrow('target does not match'); }); + + it('keeps temporary view choices out of the durable session', () => { + const store = createGuidedDrawingStore(); + store.start(createSession()); + + store.toggleNumbers(); + store.toggleTargetPreview(); + expect(store.numbersVisible.value).toBe(false); + expect(store.targetPreviewVisible.value).toBe(true); + expect(store.toFile()).not.toHaveProperty('numbersVisible'); + + store.load({ ...createSession(), target: [1, 2] }); + expect(store.numbersVisible.value).toBe(true); + expect(store.targetPreviewVisible.value).toBe(false); + + store.toggleNumbers(); + store.toggleTargetPreview(); + store.clear(); + expect(store.numbersVisible.value).toBe(true); + expect(store.targetPreviewVisible.value).toBe(false); + }); }); From bddabb44e9e731d46b4483ac34a49adc300bda5a Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:46:31 +0200 Subject: [PATCH 13/15] Finish guidance without changing artwork (#317) --- .../color/palette-panel/pf-guided-palette.ts | 141 +++++++++++++++++- src/stores/guided-drawing-store.ts | 18 +++ .../color/pf-guided-palette.test.ts | 78 +++++++++- tests/stores/guided-drawing-store.test.ts | 17 +++ 4 files changed, 252 insertions(+), 2 deletions(-) diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts index 8477201..a35abdb 100644 --- a/src/components/color/palette-panel/pf-guided-palette.ts +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -1,12 +1,14 @@ import { css, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, state } from 'lit/decorators.js'; import { BaseComponent } from '../../../core/base-component'; +import { autoSaveService } from '../../../services/auto-save'; import { analyzeGuidedDrawingProgress, getGuidedDrawingSnapshot, type GuidedDrawingProgress, } from '../../../services/paint-by-number/guided-progress'; import { defaultProjectContext, type ProjectContext } from '../../../stores/project-context'; +import '../../ui/pf-dialog'; const EMPTY_PROGRESS: GuidedDrawingProgress = { total: 0, @@ -69,6 +71,61 @@ export class PFGuidedPalette extends BaseComponent { color: var(--pf-color-text-main); } + .finish-section { + display: grid; + gap: 6px; + padding-block-start: 2px; + border-block-start: 1px solid var(--pf-color-border); + } + + .finish-guidance { + min-height: 32px; + padding: 6px 8px; + border: 1px solid var(--pf-color-border-strong); + border-radius: var(--pf-radius-sm); + background: transparent; + color: var(--pf-color-text-main); + cursor: pointer; + font-size: var(--pf-font-size-xs); + } + + .finish-guidance:hover { + border-color: var(--pf-color-accent); + background: var(--pf-color-bg-hover); + } + + .finish-copy, + .finish-error { + margin: 0; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.45; + } + + .finish-error { + color: var(--pf-color-danger, #f0aaa2); + } + + .dialog-action { + padding: 7px 10px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: transparent; + color: var(--pf-color-text-main); + cursor: pointer; + } + + .dialog-action.primary { + border-color: var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + color: var(--pf-color-accent-hover); + } + + .dialog-action:disabled { + cursor: wait; + opacity: 0.55; + } + .structure-note { padding: 7px 8px; border-inline-start: 2px solid var(--pf-color-accent); @@ -212,6 +269,9 @@ export class PFGuidedPalette extends BaseComponent { `; private context: ProjectContext = defaultProjectContext; + @state() private finishConfirmationOpen = false; + @state() private isFinishing = false; + @state() private finishError = ''; connectedCallback() { super.connectedCallback(); @@ -250,6 +310,7 @@ export class PFGuidedPalette extends BaseComponent { ${this.renderViewControls()} + ${this.renderFinishGuidance(progress)}
${guideColors.map((color, index) => { @@ -337,6 +398,84 @@ export class PFGuidedPalette extends BaseComponent { `; } + private renderFinishGuidance(progress: GuidedDrawingProgress) { + return html` +
+ +

You decide when the scaffold has done its job.

+
+ + + Finish guidance? +

+ Your drawing is ${progress.percentage}% covered. This removes the numbers and fixed + guide structure. Your painted pixels and undo history stay exactly as they are. +

+ ${this.finishError + ? html`` + : ''} +
+ + +
+
+ `; + } + + private openFinishConfirmation = () => { + this.finishError = ''; + this.finishConfirmationOpen = true; + }; + + private closeFinishConfirmation = () => { + if (this.isFinishing) return; + this.finishConfirmationOpen = false; + this.finishError = ''; + }; + + private finishGuidance = async () => { + if (this.isFinishing || !this.context.guidedDrawing.active) return; + + this.isFinishing = true; + this.finishError = ''; + this.context.guidedDrawing.beginFinish(); + try { + await autoSaveService.saveNow(this.context); + this.context.guidedDrawing.completeFinish(); + this.context.viewport.resetView(); + this.finishConfirmationOpen = false; + } catch { + this.context.guidedDrawing.cancelFinish(); + this.finishError = 'Guidance could not be finished safely. Your guide is still active.'; + } finally { + this.isFinishing = false; + } + }; + private selectColor(color: string) { this.context.colors.setPrimaryColor(color); this.context.colors.updateLightnessVariations(color); diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts index e4153c8..1079e1a 100644 --- a/src/stores/guided-drawing-store.ts +++ b/src/stores/guided-drawing-store.ts @@ -9,6 +9,7 @@ class GuidedDrawingStore { readonly session = signal(null); readonly numbersVisible = signal(true); readonly targetPreviewVisible = signal(false); + readonly finishPending = signal(false); get active(): boolean { return this.session.value !== null; @@ -17,6 +18,7 @@ class GuidedDrawingStore { start(session: GuidedDrawingSession): void { validateSession(session); this.resetViewOptions(); + this.finishPending.value = false; this.session.value = cloneSession(session); } @@ -41,6 +43,8 @@ class GuidedDrawingStore { } toFile(): GuidedDrawingSessionFile | undefined { + if (this.finishPending.value) return undefined; + const session = this.session.value; if (!session) return undefined; @@ -53,9 +57,23 @@ class GuidedDrawingStore { clear(): void { this.session.value = null; + this.finishPending.value = false; this.resetViewOptions(); } + beginFinish(): void { + if (this.session.value) this.finishPending.value = true; + } + + cancelFinish(): void { + this.finishPending.value = false; + } + + completeFinish(): void { + if (!this.finishPending.value) return; + this.clear(); + } + private resetViewOptions(): void { this.numbersVisible.value = true; this.targetPreviewVisible.value = false; diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts index bb4cd5e..1ec9d09 100644 --- a/tests/components/color/pf-guided-palette.test.ts +++ b/tests/components/color/pf-guided-palette.test.ts @@ -1,4 +1,12 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const autoSaveServiceMock = vi.hoisted(() => ({ + saveNow: vi.fn(), +})); + +vi.mock('../../../src/services/auto-save', () => ({ + autoSaveService: autoSaveServiceMock, +})); import type { PFGuidedPalette } from '../../../src/components/color/palette-panel/pf-guided-palette'; import '../../../src/components/color/palette-panel/pf-guided-palette'; import '../../../src/components/color/pf-palette-panel'; @@ -12,6 +20,10 @@ describe('guided palette', () => { HTMLElement.prototype.hidePopover = () => {}; }); + beforeEach(() => { + autoSaveServiceMock.saveNow.mockResolvedValue(undefined); + }); + afterAll(() => { if (originalHidePopover) { HTMLElement.prototype.hidePopover = originalHidePopover; @@ -105,8 +117,72 @@ describe('guided palette', () => { guidedContext.dispose(); normalContext.dispose(); }); + + it('finishes guidance at partial coverage without touching art or history', async () => { + const context = createGuidedContext(); + const historyEntry = { + id: 'stroke', + name: 'Pencil', + execute: vi.fn(), + undo: vi.fn(), + }; + context.history.undoStack.value = [historyEntry]; + const resetView = vi.spyOn(context.viewport, 'resetView'); + setActiveProjectContext(context); + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + const pixelsBefore = getPaintingPixels(context); + element.shadowRoot?.querySelector('.finish-guidance')?.click(); + await element.updateComplete; + expect(element.shadowRoot?.textContent).toContain('Your drawing is 50% covered'); + + element.shadowRoot?.querySelector('.finish-confirm')?.click(); + await settle(element); + + expect(autoSaveServiceMock.saveNow).toHaveBeenCalledWith(context); + expect(context.guidedDrawing.active).toBe(false); + expect(context.history.undoStack.value).toEqual([historyEntry]); + expect(getPaintingPixels(context)).toEqual(pixelsBefore); + expect(resetView).toHaveBeenCalledTimes(1); + + context.dispose(); + }); + + it('keeps guidance active when the finish save fails', async () => { + const context = createGuidedContext(); + setActiveProjectContext(context); + autoSaveServiceMock.saveNow.mockRejectedValueOnce(new Error('storage failed')); + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + element.shadowRoot?.querySelector('.finish-guidance')?.click(); + await element.updateComplete; + element.shadowRoot?.querySelector('.finish-confirm')?.click(); + await settle(element); + + expect(context.guidedDrawing.active).toBe(true); + expect(context.guidedDrawing.finishPending.value).toBe(false); + expect(element.shadowRoot?.textContent).toContain('Your guide is still active'); + + context.dispose(); + }); }); +async function settle(element: PFGuidedPalette) { + await Promise.resolve(); + await element.updateComplete; + await Promise.resolve(); + await element.updateComplete; +} + +function getPaintingPixels(context: ReturnType) { + const layer = context.layers.layers.value.find((item) => item.type === 'image'); + return [...(layer?.canvas?.getContext('2d')?.getImageData(0, 0, 2, 1).data ?? [])]; +} + function createGuidedContext() { const context = createProjectContext(); const layer = context.layers.layers.value.find((item) => item.type === 'image'); diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts index d044ab7..3bc11b0 100644 --- a/tests/stores/guided-drawing-store.test.ts +++ b/tests/stores/guided-drawing-store.test.ts @@ -80,4 +80,21 @@ describe('GuidedDrawingStore', () => { expect(store.numbersVisible.value).toBe(true); expect(store.targetPreviewVisible.value).toBe(false); }); + + it('omits guidance only while an explicit finish is being saved', () => { + const store = createGuidedDrawingStore(); + store.start(createSession()); + + store.beginFinish(); + expect(store.active).toBe(true); + expect(store.toFile()).toBeUndefined(); + + store.cancelFinish(); + expect(store.toFile()?.target).toEqual([1, 2]); + + store.beginFinish(); + store.completeFinish(); + expect(store.active).toBe(false); + expect(store.finishPending.value).toBe(false); + }); }); From 6b88cdcdaad3fafbef49e35cee1bcb4b23b4bfc9 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:51:54 +0200 Subject: [PATCH 14/15] Add guided library entry and exact resume (#318) --- src/components/app/pf-project-browser.ts | 43 ++++++++-- src/components/app/pixel-forge-app.ts | 1 + src/serialization/project-load.ts | 7 +- .../components/app/pf-project-browser.test.ts | 14 ++++ tests/components/app/pixel-forge-app.test.ts | 30 +++++++ .../paint-by-number/guided-resume.test.ts | 82 +++++++++++++++++++ 6 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 tests/services/paint-by-number/guided-resume.test.ts diff --git a/src/components/app/pf-project-browser.ts b/src/components/app/pf-project-browser.ts index c82ec8b..26f3de9 100644 --- a/src/components/app/pf-project-browser.ts +++ b/src/components/app/pf-project-browser.ts @@ -90,7 +90,8 @@ export class PFProjectBrowser extends BaseComponent { .header-actions, .card-actions, - .rename-actions { + .rename-actions, + .empty-actions { display: flex; align-items: center; gap: 8px; @@ -182,6 +183,11 @@ export class PFProjectBrowser extends BaseComponent { min-height: 226px; } + .new-card.guided { + border-color: color-mix(in srgb, var(--pf-color-accent) 62%, var(--pf-color-border)); + background: var(--pf-color-primary-transparent); + } + .thumbnail { display: grid; place-items: center; @@ -344,11 +350,14 @@ export class PFProjectBrowser extends BaseComponent {
-

Open a saved sprite or start a fresh canvas.

+

Open a saved sprite, start fresh, or use an image as guidance.

+ ${this.canClose ? html` -
+
+
+ +
+
+ +
+
`; } @@ -396,6 +412,12 @@ export class PFProjectBrowser extends BaseComponent { Choose a size and start drawing. +
+ +
${this.projects.map((project) => this.renderProject(project))}
`; @@ -693,6 +715,13 @@ export class PFProjectBrowser extends BaseComponent { return; } + if (returnValue === 'guided-drawing') { + this.dispatchEvent( + new CustomEvent('show-paint-by-number-dialog', { bubbles: true, composed: true }) + ); + return; + } + if (!this.canDismissBrowser()) { this.showNativeDialog(this.browserDialog); return; diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 42f1c2c..2d009e1 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -683,6 +683,7 @@ export class PixelForgeApp extends BaseComponent { string; goToFrame: (frameId: string) => void; linkCels: (celKeys: string[], linkType: CelLinkType) => string | null; + rebuildAllCelCanvases: () => void; rebuildAllIndexBuffers: () => void; setFrameDuration: (frameId: string, duration: number) => void; setTextCelData: ( @@ -133,6 +134,7 @@ export async function hydrateProjectFrames( } restoreLinkedCelGroups(stores, linkedCelGroups); + stores.animation.rebuildAllCelCanvases(); deletePlaceholderFrame(stores, placeholderFrameId); } @@ -304,6 +306,7 @@ function giveSharedTransparentCelOwnCanvas( !cel || cel.linkedCelId !== EMPTY_CEL_LINK_ID || !hasProjectImageData(celFile.data) + && !(celFile.indexData && celFile.indexData.length > 0) ) { return; } @@ -351,7 +354,7 @@ function restoreCelIndexBuffer( ): void { const { animation } = stores; const cel = animation.cels.value.get(celKey); - if (!cel || !hasProjectImageData(celFile.data)) return; + if (!cel) return; const cels = new Map(animation.cels.value); @@ -360,7 +363,7 @@ function restoreCelIndexBuffer( ...cel, indexBuffer: new Uint8Array(celFile.indexData), }); - } else if (canvas && !file.palette) { + } else if (canvas && hasProjectImageData(celFile.data) && !file.palette) { cels.set(celKey, { ...cel, indexBuffer: buildIndexBufferFromCanvas(canvas, stores.palette, true), diff --git a/tests/components/app/pf-project-browser.test.ts b/tests/components/app/pf-project-browser.test.ts index 78b1473..9aa6e74 100644 --- a/tests/components/app/pf-project-browser.test.ts +++ b/tests/components/app/pf-project-browser.test.ts @@ -214,6 +214,7 @@ describe('pf-project-browser', () => { expect(element.shadowRoot?.textContent).toContain('Open Project'); expect(element.shadowRoot?.textContent).toContain('Second Project'); expect(buttonWithText(element.shadowRoot!, 'New Project')).toBeTruthy(); + expect(buttonWithText(element.shadowRoot!, 'Guided Drawing')).toBeTruthy(); }); it('emits show-new-project-dialog from the native dialog return value', async () => { @@ -230,6 +231,19 @@ describe('pf-project-browser', () => { expect(requested).toBe(true); }); + it('emits the shared guided-drawing setup request from the library', async () => { + const element = await createBrowser(); + let requested = false; + element.addEventListener('show-paint-by-number-dialog', () => { + requested = true; + }); + + closeWithReturnValue(projectDialog(element.shadowRoot!), 'guided-drawing'); + await settle(element); + + expect(requested).toBe(true); + }); + it('emits project-browser-close from the dialog close button when closing is allowed', async () => { const element = await createBrowser(); element.canClose = true; diff --git a/tests/components/app/pixel-forge-app.test.ts b/tests/components/app/pixel-forge-app.test.ts index f9eba3d..2d3109b 100644 --- a/tests/components/app/pixel-forge-app.test.ts +++ b/tests/components/app/pixel-forge-app.test.ts @@ -184,4 +184,34 @@ describe('pixel-forge-app project dialogs', () => { expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); expect(element.shadowRoot?.querySelector('pf-new-project-dialog')?.open).toBe(true); }); + + it('opens guided setup when the project browser requests it', async () => { + await import('../../../src/components/app/pixel-forge-app'); + + const element = document.createElement('pixel-forge-app') as HTMLElement & { + hasLibraryProject: boolean; + showPaintByNumberDialog: boolean; + showProjectBrowser: boolean; + updateComplete: Promise; + }; + + document.body.append(element); + await element.updateComplete; + + element.hasLibraryProject = true; + element.showProjectBrowser = true; + element.showPaintByNumberDialog = false; + await element.updateComplete; + + const browser = element.shadowRoot?.querySelector('pf-project-browser'); + browser?.dispatchEvent( + new CustomEvent('show-paint-by-number-dialog', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + expect(element.showProjectBrowser).toBe(false); + expect(element.showPaintByNumberDialog).toBe(true); + expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); + expect(element.shadowRoot?.querySelector('pf-paint-by-number-dialog')?.open).toBe(true); + }); }); diff --git a/tests/services/paint-by-number/guided-resume.test.ts b/tests/services/paint-by-number/guided-resume.test.ts new file mode 100644 index 0000000..dcfd828 --- /dev/null +++ b/tests/services/paint-by-number/guided-resume.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { analyzeGuidedDrawingProgress } from '../../../src/services/paint-by-number/guided-progress'; +import { createGuidedProjectFile } from '../../../src/services/paint-by-number/guided-project'; +import { createProjectContext } from '../../../src/stores/project-context'; + +describe('guided project resume', () => { + it('restores the durable guide and derives progress from saved painted pixels', async () => { + const file = createGuidedProjectFile({ + guide: { + palette: ['#111111', '#eeeeee'], + target: Uint8Array.from([1, 2]), + width: 2, + height: 1, + complexity: { + paintableCells: 2, + paletteSize: 2, + isolatedCells: 2, + simplifiedCells: 0, + }, + }, + settings: { + longSide: 2, + paletteSource: 'generated', + maxColors: 2, + mapping: 'luminance', + simplifyIsolatedPixels: true, + }, + sourceName: 'study.png', + createdAt: 123, + }); + const editingContext = createProjectContext(); + await editingContext.project.loadProject(file); + + const layerId = editingContext.layers.layers.value[0].id; + const frameId = editingContext.animation.frames.value[0].id; + editingContext.animation.updateCelIndexBuffer( + layerId, + frameId, + Uint8Array.from([1, 0]), + ); + const saved = await editingContext.project.saveProject(); + + const resumedContext = createProjectContext(); + await resumedContext.project.loadProject(saved); + const resumedLayerId = resumedContext.layers.layers.value[0].id; + const resumedFrameId = resumedContext.animation.frames.value[0].id; + const resumedBuffer = resumedContext.animation.getCelIndexBuffer( + resumedLayerId, + resumedFrameId, + ); + const progress = analyzeGuidedDrawingProgress( + resumedContext.guidedDrawing.session.value!.target, + pixelsFromIndexBuffer(resumedBuffer!), + ); + + expect(resumedContext.guidedDrawing.session.value).toMatchObject({ + target: Uint8Array.from([1, 2]), + guideColorCount: 2, + sourceName: 'study.png', + createdAt: 123, + settings: { + mapping: 'luminance', + simplifyIsolatedPixels: true, + }, + }); + expect(resumedContext.guidedDrawing.numbersVisible.value).toBe(true); + expect(resumedContext.guidedDrawing.targetPreviewVisible.value).toBe(false); + expect(resumedBuffer).toEqual(Uint8Array.from([1, 0])); + expect(progress).toMatchObject({ total: 2, covered: 1, remaining: 1, percentage: 50 }); + + editingContext.dispose(); + resumedContext.dispose(); + }); +}); + +function pixelsFromIndexBuffer(indexBuffer: Uint8Array): Uint8ClampedArray { + const pixels = new Uint8ClampedArray(indexBuffer.length * 4); + for (let index = 0; index < indexBuffer.length; index += 1) { + if (indexBuffer[index] > 0) pixels[index * 4 + 3] = 255; + } + return pixels; +} From 3c02a62ccfc81a2dad650d66a3eafdef7d1d98a6 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 13:12:43 +0200 Subject: [PATCH 15/15] Keep editor open on first run (#320) --- src/components/app/pixel-forge-app.ts | 20 +++-- tests/components/app/pixel-forge-app.test.ts | 90 +++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 2d009e1..3fea758 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -244,6 +244,7 @@ export class PixelForgeApp extends BaseComponent { @state() timelineHeight = 200; @state() private isResizingTimeline = false; @state() private hasLibraryProject = false; + @state() private projectSelectionRequired = false; @state() private warningMessage: string | null = null; private resizeStartY = 0; @@ -347,7 +348,7 @@ export class PixelForgeApp extends BaseComponent { private handlePaintByNumberDialogClose = () => { this.showPaintByNumberDialog = false; - if (!this.hasLibraryProject) { + if (this.projectSelectionRequired) { this.showProjectBrowser = true; } }; @@ -355,7 +356,7 @@ export class PixelForgeApp extends BaseComponent { private handleNewProjectDialogClose = () => { this.showNewProjectDialog = false; - if (!this.hasLibraryProject) { + if (this.projectSelectionRequired) { this.showProjectBrowser = true; } }; @@ -400,18 +401,20 @@ export class PixelForgeApp extends BaseComponent { }; private handleProjectBrowserClose = () => { - if (this.hasLibraryProject) { + if (!this.projectSelectionRequired) { this.showProjectBrowser = false; } }; private handleProjectOpened = () => { this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showProjectBrowser = false; }; private handleProjectCreated = () => { this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showNewProjectDialog = false; this.showPaintByNumberDialog = false; this.showProjectBrowser = false; @@ -419,6 +422,7 @@ export class PixelForgeApp extends BaseComponent { private handleCurrentProjectDeleted = () => { this.hasLibraryProject = false; + this.projectSelectionRequired = true; this.showProjectBrowser = true; }; @@ -462,6 +466,7 @@ export class PixelForgeApp extends BaseComponent { (await workspaceStore.restoreWorkspace(workspaceState)) ) { this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showProjectBrowser = false; return; } @@ -482,15 +487,18 @@ export class PixelForgeApp extends BaseComponent { ) { historyStore.clear(); this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showProjectBrowser = false; } else { this.hasLibraryProject = false; - this.showProjectBrowser = true; + this.projectSelectionRequired = false; + this.showProjectBrowser = false; } } catch (error) { log.warn("Failed to load saved project, starting fresh:", error); this.hasLibraryProject = false; - this.showProjectBrowser = true; + this.projectSelectionRequired = false; + this.showProjectBrowser = false; } } @@ -681,7 +689,7 @@ export class PixelForgeApp extends BaseComponent { ${this.showProjectBrowser ? html` { expect(element.showProjectBrowser).toBe(false); }); + it('starts in the editor when no saved project can be restored', async () => { + await import('../../../src/components/app/pixel-forge-app'); + + const element = document.createElement('pixel-forge-app') as HTMLElement & { + hasLibraryProject: boolean; + showProjectBrowser: boolean; + updateComplete: Promise; + }; + + document.body.append(element); + + await vi.waitFor(() => { + expect(projectRepositoryMock.list).toHaveBeenCalled(); + }); + await element.updateComplete; + + expect(element.hasLibraryProject).toBe(false); + expect(element.showProjectBrowser).toBe(false); + expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); + expect(element.shadowRoot?.querySelector('pf-drawing-canvas')).toBeTruthy(); + }); + it('opens the project browser from the project tab strip', async () => { await import('../../../src/components/app/pixel-forge-app'); @@ -117,6 +139,9 @@ describe('pixel-forge-app project dialogs', () => { }; document.body.append(element); + await vi.waitFor(() => { + expect(projectRepositoryMock.list).toHaveBeenCalled(); + }); await element.updateComplete; element.showProjectBrowser = false; @@ -129,7 +154,52 @@ describe('pixel-forge-app project dialogs', () => { await element.updateComplete; expect(element.showProjectBrowser).toBe(true); - expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeTruthy(); + const browser = element.shadowRoot?.querySelector('pf-project-browser'); + expect(browser).toBeTruthy(); + expect(browser?.canClose).toBe(true); + + browser?.dispatchEvent( + new CustomEvent('project-browser-close', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + expect(element.showProjectBrowser).toBe(false); + expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); + }); + + it('requires another project after deleting the active library project', async () => { + await import('../../../src/components/app/pixel-forge-app'); + + const element = document.createElement('pixel-forge-app') as HTMLElement & { + hasLibraryProject: boolean; + showProjectBrowser: boolean; + updateComplete: Promise; + }; + + document.body.append(element); + await element.updateComplete; + + element.hasLibraryProject = true; + element.showProjectBrowser = true; + await element.updateComplete; + + const browser = element.shadowRoot?.querySelector('pf-project-browser'); + browser?.dispatchEvent( + new CustomEvent('current-project-deleted', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + const requiredBrowser = element.shadowRoot?.querySelector('pf-project-browser'); + expect(element.hasLibraryProject).toBe(false); + expect(element.showProjectBrowser).toBe(true); + expect(requiredBrowser?.canClose).toBe(false); + + requiredBrowser?.dispatchEvent( + new CustomEvent('project-browser-close', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + expect(element.showProjectBrowser).toBe(true); }); it('renders the canvas surface from the active project context', async () => { @@ -183,6 +253,14 @@ describe('pixel-forge-app project dialogs', () => { expect(element.showNewProjectDialog).toBe(true); expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); expect(element.shadowRoot?.querySelector('pf-new-project-dialog')?.open).toBe(true); + + element.shadowRoot?.querySelector('pf-new-project-dialog')?.dispatchEvent( + new CustomEvent('close', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + expect(element.showNewProjectDialog).toBe(false); + expect(element.showProjectBrowser).toBe(false); }); it('opens guided setup when the project browser requests it', async () => { @@ -198,7 +276,7 @@ describe('pixel-forge-app project dialogs', () => { document.body.append(element); await element.updateComplete; - element.hasLibraryProject = true; + element.hasLibraryProject = false; element.showProjectBrowser = true; element.showPaintByNumberDialog = false; await element.updateComplete; @@ -213,5 +291,13 @@ describe('pixel-forge-app project dialogs', () => { expect(element.showPaintByNumberDialog).toBe(true); expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); expect(element.shadowRoot?.querySelector('pf-paint-by-number-dialog')?.open).toBe(true); + + element.shadowRoot?.querySelector('pf-paint-by-number-dialog')?.dispatchEvent( + new CustomEvent('close', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + expect(element.showPaintByNumberDialog).toBe(false); + expect(element.showProjectBrowser).toBe(false); }); });