Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions src/components/canvas/pf-guided-number-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
143 changes: 143 additions & 0 deletions src/services/paint-by-number/guided-fill.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
105 changes: 83 additions & 22 deletions src/tools/fill-tool.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -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,
};
}
}
13 changes: 13 additions & 0 deletions tests/components/canvas/pf-guided-number-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]),
Expand Down
Loading
Loading