From 19d75fa3e103db1f1df1edb9825cca324c84267b Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 15:47:29 +0200 Subject: [PATCH 1/2] Soften guided number labels (#323) --- .../canvas/pf-guided-number-overlay.ts | 43 +++++++++++++------ .../canvas/pf-guided-number-overlay.test.ts | 13 ++++++ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/components/canvas/pf-guided-number-overlay.ts b/src/components/canvas/pf-guided-number-overlay.ts index bd3352d..04103c8 100644 --- a/src/components/canvas/pf-guided-number-overlay.ts +++ b/src/components/canvas/pf-guided-number-overlay.ts @@ -21,6 +21,27 @@ export interface GuidedNumberViewport { viewportHeight: number; } +export interface GuidedNumberLabelStyle { + fillStyle: string; + fontSize: number; + fontWeight: number; + lineWidth: number; + strokeStyle: string; +} + +export function getGuidedNumberLabelStyle( + cellSize: number, + labelLength: number, +): GuidedNumberLabelStyle { + return { + fillStyle: 'rgba(245, 243, 238, 0.62)', + fontSize: Math.min(12, Math.max(7, cellSize * (labelLength > 1 ? 0.46 : 0.52))), + fontWeight: 500, + lineWidth: Math.min(1.5, Math.max(1, cellSize * 0.06)), + strokeStyle: 'rgba(7, 9, 13, 0.46)', + }; +} + export function collectVisibleGuidedTargetCells( target: Uint8Array, width: number, @@ -246,23 +267,17 @@ export class PFGuidedNumberOverlay extends CanvasOverlay { 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 style = getGuidedNumberLabelStyle(cell.size, label.length); 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); + context.font = `${style.fontWeight} ${style.fontSize}px ${fontFamily}`; + context.lineJoin = 'round'; + context.lineWidth = style.lineWidth; + context.strokeStyle = style.strokeStyle; + context.strokeText(label, centerX, centerY + 0.5, cell.size); + context.fillStyle = style.fillStyle; + context.fillText(label, centerX, centerY + 0.5, cell.size); } } } diff --git a/tests/components/canvas/pf-guided-number-overlay.test.ts b/tests/components/canvas/pf-guided-number-overlay.test.ts index b218f3a..49efaba 100644 --- a/tests/components/canvas/pf-guided-number-overlay.test.ts +++ b/tests/components/canvas/pf-guided-number-overlay.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { collectVisibleGuidedNumberCells, collectVisibleGuidedTargetCells, + getGuidedNumberLabelStyle, type PFGuidedNumberOverlay, } from '../../../src/components/canvas/pf-guided-number-overlay'; import '../../../src/components/canvas/pf-canvas-viewport'; @@ -57,6 +58,18 @@ describe('guided number overlay', () => { })).toEqual([]); }); + it('keeps number labels restrained at the readable zoom threshold', () => { + const singleDigit = getGuidedNumberLabelStyle(16, 1); + const doubleDigit = getGuidedNumberLabelStyle(16, 2); + + expect(singleDigit.fontWeight).toBe(500); + expect(singleDigit.fontSize).toBeLessThan(9); + expect(doubleDigit.fontSize).toBeLessThan(singleDigit.fontSize); + expect(singleDigit.fillStyle).toContain('0.62'); + expect(singleDigit.strokeStyle).toContain('0.46'); + expect(singleDigit.lineWidth).toBe(1); + }); + it('plans a target preview at any zoom without reading painted pixels', () => { const cells = collectVisibleGuidedTargetCells( Uint8Array.from([1, 0, 2]), From e4c0f63286528676cf788da790152f62631cdb5c Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 15:52:00 +0200 Subject: [PATCH 2/2] Fill connected guided regions with bucket (#324) --- src/services/paint-by-number/guided-fill.ts | 143 ++++++++++++++++++ src/tools/fill-tool.ts | 105 ++++++++++--- .../paint-by-number/guided-fill.test.ts | 94 ++++++++++++ tests/tools/fill-active-context.test.ts | 41 +++++ 4 files changed, 361 insertions(+), 22 deletions(-) create mode 100644 src/services/paint-by-number/guided-fill.ts create mode 100644 tests/services/paint-by-number/guided-fill.test.ts diff --git a/src/services/paint-by-number/guided-fill.ts b/src/services/paint-by-number/guided-fill.ts new file mode 100644 index 0000000..6066cdd --- /dev/null +++ b/src/services/paint-by-number/guided-fill.ts @@ -0,0 +1,143 @@ +import type { Rect } from '../../types/geometry'; + +export interface GuidedFillRegion { + bounds: Rect; + guideNumber: number; + indices: number[]; +} + +export interface GuidedFillColor { + r: number; + g: number; + b: number; + a: number; + paletteIndex: number; +} + +export function collectGuidedFillRegion( + target: Uint8Array, + pixels: Uint8ClampedArray, + width: number, + height: number, + startX: number, + startY: number, +): GuidedFillRegion | null { + assertMatchingBuffers(target, pixels, width, height); + if (!isPointInside(startX, startY, width, height)) return null; + + const startIndex = startY * width + startX; + const guideNumber = target[startIndex]; + if (guideNumber === 0) return null; + + const connectedIndices = collectConnectedGuideIndices( + target, + width, + startIndex, + guideNumber, + ); + const indices = connectedIndices.filter((index) => pixels[index * 4 + 3] === 0); + if (indices.length === 0) return null; + + return { + bounds: findBounds(indices, width), + guideNumber, + indices, + }; +} + +function collectConnectedGuideIndices( + target: Uint8Array, + width: number, + startIndex: number, + guideNumber: number, +): number[] { + const visited = new Uint8Array(target.length); + const queue = new Int32Array(target.length); + const connected: number[] = []; + let queueStart = 0; + let queueEnd = 1; + const height = target.length / width; + + queue[0] = startIndex; + visited[startIndex] = 1; + + const enqueue = (index: number) => { + if (visited[index] || target[index] !== guideNumber) return; + visited[index] = 1; + queue[queueEnd] = index; + queueEnd += 1; + }; + + while (queueStart < queueEnd) { + const index = queue[queueStart]; + queueStart += 1; + connected.push(index); + const x = index % width; + const y = Math.floor(index / width); + + if (x > 0) enqueue(index - 1); + if (x + 1 < width) enqueue(index + 1); + if (y > 0) enqueue(index - width); + if (y + 1 < height) enqueue(index + width); + } + + return connected; +} + +function findBounds(indices: number[], width: number): Rect { + let minX = width; + let minY = Number.POSITIVE_INFINITY; + let maxX = -1; + let maxY = -1; + + for (const index of indices) { + const x = index % width; + const y = Math.floor(index / width); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + return { + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1, + }; +} + +function assertMatchingBuffers( + target: Uint8Array, + pixels: Uint8ClampedArray, + width: number, + height: number, +) { + if (target.length !== width * height || pixels.length !== target.length * 4) { + throw new RangeError('Guided fill buffers do not match the project dimensions'); + } +} + +function isPointInside(x: number, y: number, width: number, height: number) { + return x >= 0 && x < width && y >= 0 && y < height; +} + +export function paintGuidedFillRegion( + pixels: Uint8ClampedArray, + indexBuffer: Uint8Array, + region: GuidedFillRegion, + color: GuidedFillColor, +) { + if (pixels.length !== indexBuffer.length * 4) { + throw new RangeError('Guided fill pixel and palette buffers do not match'); + } + + for (const index of region.indices) { + const offset = index * 4; + pixels[offset] = color.r; + pixels[offset + 1] = color.g; + pixels[offset + 2] = color.b; + pixels[offset + 3] = color.a; + indexBuffer[index] = color.paletteIndex; + } +} diff --git a/src/tools/fill-tool.ts b/src/tools/fill-tool.ts index 386911f..69d6bb9 100644 --- a/src/tools/fill-tool.ts +++ b/src/tools/fill-tool.ts @@ -1,6 +1,22 @@ import { BaseTool } from './base-tool'; -import { floodFill } from '../services/drawing/algorithms'; +import { floodFill, type FloodFillColor } from '../services/drawing/algorithms'; +import { + collectGuidedFillRegion, + paintGuidedFillRegion, +} from '../services/paint-by-number/guided-fill'; import { isPaintableLayer } from '../utils/layer-capabilities'; +import type { Rect } from '../types/geometry'; + +interface FillRequest { + frameId: string; + height: number; + hex: string; + imageData: ImageData; + layerId: string; + startX: number; + startY: number; + width: number; +} export class FillTool extends BaseTool { name = 'fill'; @@ -16,36 +32,27 @@ export class FillTool extends BaseTool { if (startX < 0 || startX >= width || startY < 0 || startY >= height) return; - const { animation, colors, layers, palette } = this.projectContext; + const { animation, colors, layers } = this.projectContext; const layerId = layers.activeLayerId.value; const layer = layers.layers.value.find((candidate) => candidate.id === layerId); if (!isPaintableLayer(layer)) return; const imageData = this.ctx.getImageData(0, 0, width, height); - // Get index buffer for indexed color mode - const frameId = animation.currentFrameId.value; - const hex = colors.primaryColor.value; - - const indexBuffer = animation.ensureCelIndexBuffer(layer.id, frameId); - // Get palette index for drawing - adds to the palette if needed - const fillPaletteIndex = palette.getOrAddColorForDrawing(hex); - - const bounds = floodFill( - imageData.data, - width, + const guidedSession = this.projectContext.guidedDrawing.session.value; + const request: FillRequest = { + frameId: animation.currentFrameId.value, height, + hex: colors.primaryColor.value, + imageData, + layerId: layer.id, startX, startY, - { - r: parseInt(hex.slice(1, 3), 16), - g: parseInt(hex.slice(3, 5), 16), - b: parseInt(hex.slice(5, 7), 16), - a: 255, // Assuming full opacity for now - paletteIndex: fillPaletteIndex, - }, - indexBuffer - ); + width, + }; + const bounds = guidedSession + ? this.fillGuidedRegion(request, guidedSession.target) + : this.fillPixelRegion(request); if (!bounds) return; this.ctx.putImageData(imageData, 0, 0); @@ -55,4 +62,58 @@ export class FillTool extends BaseTool { onMove(_x: number, _y: number) {} onDrag(_x: number, _y: number) {} onUp(_x: number, _y: number) {} + + private fillGuidedRegion(request: FillRequest, target: Uint8Array): Rect | null { + const session = this.projectContext.guidedDrawing.session.value; + if (!session || session.width !== request.width || session.height !== request.height) { + return null; + } + + const region = collectGuidedFillRegion( + target, + request.imageData.data, + request.width, + request.height, + request.startX, + request.startY, + ); + if (!region) return null; + + const { animation, palette } = this.projectContext; + const indexBuffer = animation.ensureCelIndexBuffer(request.layerId, request.frameId); + const paletteIndex = palette.getOrAddColorForDrawing(request.hex); + paintGuidedFillRegion( + request.imageData.data, + indexBuffer, + region, + this.fillColor(request.hex, paletteIndex), + ); + return region.bounds; + } + + private fillPixelRegion(request: FillRequest): Rect | null { + const { animation, palette } = this.projectContext; + const indexBuffer = animation.ensureCelIndexBuffer(request.layerId, request.frameId); + const paletteIndex = palette.getOrAddColorForDrawing(request.hex); + + return floodFill( + request.imageData.data, + request.width, + request.height, + request.startX, + request.startY, + this.fillColor(request.hex, paletteIndex), + indexBuffer, + ); + } + + private fillColor(hex: string, paletteIndex: number): FloodFillColor { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + a: 255, + paletteIndex, + }; + } } diff --git a/tests/services/paint-by-number/guided-fill.test.ts b/tests/services/paint-by-number/guided-fill.test.ts new file mode 100644 index 0000000..0ed35be --- /dev/null +++ b/tests/services/paint-by-number/guided-fill.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + collectGuidedFillRegion, + paintGuidedFillRegion, +} from '../../../src/services/paint-by-number/guided-fill'; + +function transparentPixels(length: number) { + return new Uint8ClampedArray(length * 4); +} + +describe('guided region fill', () => { + it('collects only the four-connected island sharing the clicked number', () => { + const target = Uint8Array.from([ + 1, 1, 2, 1, + 1, 2, 2, 2, + 3, 3, 1, 1, + ]); + + const region = collectGuidedFillRegion( + target, + transparentPixels(target.length), + 4, + 3, + 0, + 0, + ); + + expect(region).toEqual({ + bounds: { x: 0, y: 0, width: 2, height: 2 }, + guideNumber: 1, + indices: [0, 1, 4], + }); + }); + + it('does not join regions that only touch diagonally', () => { + const target = Uint8Array.from([ + 1, 2, + 2, 1, + ]); + + const region = collectGuidedFillRegion( + target, + transparentPixels(target.length), + 2, + 2, + 0, + 0, + ); + + expect(region?.indices).toEqual([0]); + }); + + it('preserves painted cells without letting them split the guide region', () => { + const target = Uint8Array.from([1, 1, 1]); + const pixels = transparentPixels(target.length); + pixels.set([9, 8, 7, 255], 4); + const indexBuffer = Uint8Array.from([0, 4, 0]); + const region = collectGuidedFillRegion(target, pixels, 3, 1, 0, 0); + + expect(region?.indices).toEqual([0, 2]); + paintGuidedFillRegion(pixels, indexBuffer, region!, { + r: 18, + g: 52, + b: 86, + a: 255, + paletteIndex: 6, + }); + + expect([...pixels.slice(0, 4)]).toEqual([18, 52, 86, 255]); + expect([...pixels.slice(4, 8)]).toEqual([9, 8, 7, 255]); + expect([...pixels.slice(8, 12)]).toEqual([18, 52, 86, 255]); + expect([...indexBuffer]).toEqual([6, 4, 6]); + }); + + it('does nothing for an unnumbered cell or a fully painted region', () => { + expect(collectGuidedFillRegion( + Uint8Array.from([0]), + transparentPixels(1), + 1, + 1, + 0, + 0, + )).toBeNull(); + + expect(collectGuidedFillRegion( + Uint8Array.from([1]), + Uint8ClampedArray.from([1, 2, 3, 255]), + 1, + 1, + 0, + 0, + )).toBeNull(); + }); +}); diff --git a/tests/tools/fill-active-context.test.ts b/tests/tools/fill-active-context.test.ts index 56767fa..8a513ef 100644 --- a/tests/tools/fill-active-context.test.ts +++ b/tests/tools/fill-active-context.test.ts @@ -14,7 +14,9 @@ import { type ProjectContext, } from '../../src/stores/project-context'; import { FillTool } from '../../src/tools/fill-tool'; +import { analyzeGuidedDrawingProgress } from '../../src/services/paint-by-number/guided-progress'; import type { Cel } from '../../src/types/animation'; +import { GUIDED_DRAWING_VERSION } from '../../src/types/guided-drawing'; import type { Layer } from '../../src/types/layer'; type Rgba = [number, number, number, number]; @@ -161,4 +163,43 @@ describe('FillTool active project context', () => { expect(projectBDirty).not.toHaveBeenCalled(); expect(defaultDirty).not.toHaveBeenCalled(); }); + + it('fills only the connected numbered region in a guided project', () => { + const project = createContext(); + const canvas = new FakeCanvasContext(3, 1); + const indexBuffer = new Uint8Array(3); + installDrawingState(project, canvas.canvas, 'guided-layer', indexBuffer); + project.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 3, + height: 1, + target: Uint8Array.from([1, 1, 2]), + settings: { + longSide: 3, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + project.colors.setPrimaryColor('#123456'); + + const tool = new FillTool(); + tool.setContext(canvas as unknown as CanvasRenderingContext2D); + tool.setProjectContext(project); + tool.onDown(0, 0); + + const paletteIndex = project.palette.getColorIndex('#123456'); + expect([...indexBuffer]).toEqual([paletteIndex, paletteIndex, 0]); + expect(canvas.getPixel(0, 0)).toEqual([18, 52, 86, 255]); + expect(canvas.getPixel(1, 0)).toEqual([18, 52, 86, 255]); + expect(canvas.getPixel(2, 0)).toEqual([0, 0, 0, 0]); + + const pixels = canvas.getImageData(0, 0, 3, 1).data; + expect(analyzeGuidedDrawingProgress( + project.guidedDrawing.session.value!.target, + pixels, + ).covered).toBe(2); + }); });