317 lines
9.3 KiB
TypeScript
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,
|
|
};
|
|
}
|