Files
spritesheet-generator/src/composables/useSpritesheetSplitter.ts
2025-12-15 01:45:07 +01:00

317 lines
9.3 KiB
TypeScript

import { ref, computed, type Ref } from 'vue';
import type { SpritePreview, SpriteRegion, GridSplitOptions, AutoDetectOptions, SpriteWorkerResponse } from '@/types/spritesheet';
/**
* Composable for spritesheet splitting logic
* Handles grid-based and auto-detection sprite extraction
*/
export function useSpritesheetSplitter() {
const isProcessing = ref(false);
const previewSprites: Ref<SpritePreview[]> = ref([]);
const worker: Ref<Worker | null> = ref(null);
/**
* Split image into grid cells
*/
async function splitByGrid(img: HTMLImageElement, options: GridSplitOptions): Promise<SpritePreview[]> {
const { cellWidth, cellHeight, preserveCellSize, removeEmpty } = options;
if (cellWidth <= 0 || cellHeight <= 0) {
return [];
}
const cols = Math.floor(img.width / cellWidth);
const rows = Math.floor(img.height / cellHeight);
const sprites: SpritePreview[] = [];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const cropCanvas = document.createElement('canvas');
const cropCtx = cropCanvas.getContext('2d');
if (!ctx || !cropCtx) return [];
canvas.width = cellWidth;
canvas.height = cellHeight;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
ctx.clearRect(0, 0, cellWidth, cellHeight);
ctx.drawImage(img, col * cellWidth, row * cellHeight, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight);
const isEmpty = removeEmpty ? isCanvasEmpty(ctx, cellWidth, cellHeight) : false;
if (!removeEmpty || !isEmpty) {
let url: string;
let x = 0;
let y = 0;
let width = cellWidth;
let height = cellHeight;
if (preserveCellSize) {
// Keep full cell with transparent padding
url = canvas.toDataURL('image/png');
} else {
// Crop to sprite bounds
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
if (bounds) {
x = bounds.x;
y = bounds.y;
width = bounds.width;
height = bounds.height;
cropCanvas.width = width;
cropCanvas.height = height;
cropCtx.clearRect(0, 0, width, height);
cropCtx.drawImage(canvas, x, y, width, height, 0, 0, width, height);
url = cropCanvas.toDataURL('image/png');
} else {
url = canvas.toDataURL('image/png');
}
}
sprites.push({ url, x, y, width, height, isEmpty });
}
}
}
return sprites;
}
/**
* Detect sprites using web worker for irregular layouts
*/
async function detectSprites(img: HTMLImageElement, options: AutoDetectOptions): Promise<SpritePreview[]> {
const { sensitivity, removeEmpty } = options;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return [];
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
// Initialize worker lazily
if (!worker.value) {
try {
worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
} catch (error) {
console.error('Failed to create worker:', error);
return [];
}
}
return new Promise<SpritePreview[]>(resolve => {
const w = worker.value!;
const timeout = setTimeout(() => {
w.removeEventListener('message', handleMessage);
console.warn('Worker timeout');
resolve([]);
}, 30000);
const handleMessage = async (e: MessageEvent<SpriteWorkerResponse>) => {
clearTimeout(timeout);
w.removeEventListener('message', handleMessage);
if (e.data.type === 'spritesDetected') {
const sprites = await processDetectedSprites(img, e.data.sprites, e.data.backgroundColor, removeEmpty);
resolve(sprites);
}
};
w.addEventListener('message', handleMessage);
w.postMessage({
type: 'detectIrregularSprites',
imageData,
sensitivity,
maxSize: 2048,
});
});
}
/**
* Process sprites detected by worker into preview format
*/
async function processDetectedSprites(img: HTMLImageElement, regions: SpriteRegion[], backgroundColor: [number, number, number, number], removeEmpty: boolean): Promise<SpritePreview[]> {
if (!regions?.length) return [];
const sprites: SpritePreview[] = [];
const sourceCanvas = document.createElement('canvas');
const sourceCtx = sourceCanvas.getContext('2d');
const spriteCanvas = document.createElement('canvas');
const spriteCtx = spriteCanvas.getContext('2d');
if (!sourceCtx || !spriteCtx) return [];
sourceCanvas.width = img.width;
sourceCanvas.height = img.height;
sourceCtx.drawImage(img, 0, 0);
for (const region of regions) {
const { x, y, width, height } = region;
if (width <= 0 || height <= 0) continue;
spriteCanvas.width = width;
spriteCanvas.height = height;
spriteCtx.clearRect(0, 0, width, height);
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
// Remove background color
removeBackground(spriteCtx, width, height, backgroundColor);
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
if (!removeEmpty || !isEmpty) {
sprites.push({
url: spriteCanvas.toDataURL('image/png'),
x,
y,
width,
height,
isEmpty,
});
}
}
return sprites;
}
/**
* Get bounding box of non-transparent pixels
*/
function getSpriteBounds(ctx: CanvasRenderingContext2D, width: number, height: number): { x: number; y: number; width: number; height: number } | null {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let minX = width,
minY = height,
maxX = 0,
maxY = 0;
let hasContent = false;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const alpha = data[(y * width + x) * 4 + 3];
if (alpha > 10) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
hasContent = true;
}
}
}
if (!hasContent) return null;
// Add small padding
const pad = 1;
return {
x: Math.max(0, minX - pad),
y: Math.max(0, minY - pad),
width: Math.min(width - Math.max(0, minX - pad), maxX - minX + 1 + pad * 2),
height: Math.min(height - Math.max(0, minY - pad), maxY - minY + 1 + pad * 2),
};
}
/**
* Check if canvas is empty (all transparent or same color)
*/
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
const data = ctx.getImageData(0, 0, width, height).data;
let allTransparent = true;
let allSameColor = true;
const [firstR, firstG, firstB, firstA] = [data[0], data[1], data[2], data[3]];
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 10) allTransparent = false;
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
allSameColor = false;
}
if (!allTransparent && !allSameColor) break;
}
return allTransparent || allSameColor;
}
/**
* Remove background color from sprite, making it transparent
*/
function removeBackground(ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: [number, number, number, number]): void {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
const [bgR, bgG, bgB, bgA] = bgColor;
const tolerance = 30;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 10) {
data[i + 3] = 0;
continue;
}
const rDiff = data[i] - bgR;
const gDiff = data[i + 1] - bgG;
const bDiff = data[i + 2] - bgB;
const dist = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
if (dist <= tolerance && Math.abs(a - bgA) <= 40) {
data[i + 3] = 0;
}
}
ctx.putImageData(imageData, 0, 0);
}
/**
* Cleanup worker on unmount
*/
function cleanup() {
if (worker.value) {
worker.value.terminate();
worker.value = null;
}
}
/**
* Calculate suggested cell size based on image dimensions
*/
function getSuggestedCellSize(width: number, height: number): { width: number; height: number } {
const commonSizes = [256, 192, 128, 96, 64, 48, 32, 16];
let cellWidth = 64;
let cellHeight = 64;
for (const size of commonSizes) {
if (width % size === 0 && width / size <= 16 && width / size >= 1) {
cellWidth = size;
break;
}
}
for (const size of commonSizes) {
if (height % size === 0 && height / size <= 16 && height / size >= 1) {
cellHeight = size;
break;
}
}
return { width: cellWidth, height: cellHeight };
}
return {
isProcessing,
previewSprites,
splitByGrid,
detectSprites,
getSuggestedCellSize,
cleanup,
};
}