From 3aa01dd044d3222ff5e2fc499ba7954a7181489f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 15 Dec 2025 01:45:07 +0100 Subject: [PATCH] [FEAT] Improved splitting --- src/components/SpritesheetSplitter.vue | 911 ++++------------------ src/composables/useSpritesheetSplitter.ts | 316 ++++++++ src/types/spritesheet.ts | 54 ++ 3 files changed, 503 insertions(+), 778 deletions(-) create mode 100644 src/composables/useSpritesheetSplitter.ts create mode 100644 src/types/spritesheet.ts diff --git a/src/components/SpritesheetSplitter.vue b/src/components/SpritesheetSplitter.vue index ad833d1..4bffa4e 100644 --- a/src/components/SpritesheetSplitter.vue +++ b/src/components/SpritesheetSplitter.vue @@ -1,91 +1,92 @@ diff --git a/src/composables/useSpritesheetSplitter.ts b/src/composables/useSpritesheetSplitter.ts new file mode 100644 index 0000000..da7ed92 --- /dev/null +++ b/src/composables/useSpritesheetSplitter.ts @@ -0,0 +1,316 @@ +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 = ref([]); + const worker: Ref = ref(null); + + /** + * Split image into grid cells + */ + async function splitByGrid(img: HTMLImageElement, options: GridSplitOptions): Promise { + 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 { + 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(resolve => { + const w = worker.value!; + + const timeout = setTimeout(() => { + w.removeEventListener('message', handleMessage); + console.warn('Worker timeout'); + resolve([]); + }, 30000); + + const handleMessage = async (e: MessageEvent) => { + 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 { + 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, + }; +} diff --git a/src/types/spritesheet.ts b/src/types/spritesheet.ts new file mode 100644 index 0000000..c256d18 --- /dev/null +++ b/src/types/spritesheet.ts @@ -0,0 +1,54 @@ +/** + * Spritesheet splitting types + */ + +/** A detected sprite region with bounds and position */ +export interface SpriteRegion { + x: number; + y: number; + width: number; + height: number; + pixelCount?: number; +} + +/** A sprite preview with data URL for display */ +export interface SpritePreview { + url: string; + x: number; + y: number; + width: number; + height: number; + isEmpty: boolean; +} + +/** Detection mode for sprite splitting */ +export type DetectionMode = 'grid' | 'auto'; + +/** Options for grid-based splitting */ +export interface GridSplitOptions { + cellWidth: number; + cellHeight: number; + preserveCellSize: boolean; + removeEmpty: boolean; +} + +/** Options for auto-detection splitting */ +export interface AutoDetectOptions { + sensitivity: number; + removeEmpty: boolean; +} + +/** Worker message for irregular sprite detection */ +export interface SpriteWorkerMessage { + type: 'detectIrregularSprites'; + imageData: ImageData; + sensitivity: number; + maxSize?: number; +} + +/** Worker response with detected sprites */ +export interface SpriteWorkerResponse { + type: 'spritesDetected'; + sprites: SpriteRegion[]; + backgroundColor: [number, number, number, number]; +}