From b884487ec96cae2a89d81e21501f7ac4c8235a32 Mon Sep 17 00:00:00 2001 From: Dennis Postma Date: Sun, 6 Apr 2025 03:43:08 +0200 Subject: [PATCH] v1.3.0 --- public/CHANGELOG.md | 5 ++ src/App.vue | 36 +++++++- src/components/SpritesheetSplitter.vue | 114 ++++++++++++++++++++++--- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index b1d5217..64b0281 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,10 @@ All notable changes to this project will be documented in this file. +## [1.3.0] - 2025-04-06 + +### Fixed +- 📄 When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells. + ## [1.2.0] - 2025-04-06 ### Added diff --git a/src/App.vue b/src/App.vue index 0b5c65c..7961f01 100644 --- a/src/App.vue +++ b/src/App.vue @@ -98,6 +98,14 @@ y: number; } + interface SpriteFile { + file: File; + x: number; + y: number; + width: number; + height: number; + } + const sprites = ref([]); const columns = ref(4); const isPreviewModalOpen = ref(false); @@ -261,9 +269,31 @@ }; // Handle the split spritesheet result - const handleSplitSpritesheet = (files: File[]) => { - // Process the split sprite files - processImageFiles(files); + const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => { + // Process sprite files with their positions + Promise.all( + spriteFiles.map(spriteFile => { + return new Promise(resolve => { + const url = URL.createObjectURL(spriteFile.file); + const img = new Image(); + img.onload = () => { + resolve({ + id: crypto.randomUUID(), + file: spriteFile.file, + img, + url, + width: img.width, + height: img.height, + x: spriteFile.x, // Use the position from the splitter + y: spriteFile.y, // Use the position from the splitter + }); + }; + img.src = url; + }); + }) + ).then(newSprites => { + sprites.value = [...sprites.value, ...newSprites]; + }); }; // Export spritesheet as JSON with base64 images diff --git a/src/components/SpritesheetSplitter.vue b/src/components/SpritesheetSplitter.vue index c8e986b..44abde1 100644 --- a/src/components/SpritesheetSplitter.vue +++ b/src/components/SpritesheetSplitter.vue @@ -84,9 +84,17 @@ const emit = defineEmits<{ (e: 'close'): void; - (e: 'split', sprites: File[]): void; + (e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[] }>(); + interface SpriteFile { + file: File; + x: number; + y: number; + width: number; + height: number; + } + // Get settings from store const settingsStore = useSettingsStore(); @@ -155,6 +163,46 @@ } } + function getSpriteBoundingBox(ctx: CanvasRenderingContext2D, width: number, height: number) { + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + + let minX = width; + let minY = height; + let maxX = 0; + let maxY = 0; + let hasContent = false; + + // Scan through all pixels to find the bounding box + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + // Check if pixel is not transparent (alpha > 0) + if (data[idx + 3] > 10) { + // Allow some tolerance for compression artifacts + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + hasContent = true; + } + } + } + + // If no non-transparent pixels found, return null + if (!hasContent) { + return null; + } + + // Return bounding box with a small padding + return { + x: Math.max(0, minX - 1), + y: Math.max(0, minY - 1), + width: Math.min(width, maxX - minX + 3), // +1 for inclusive bounds, +2 for padding + height: Math.min(height, maxY - minY + 3), + }; + } + // Split spritesheet manually based on rows and columns async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) { const spriteWidth = Math.floor(img.width / columns); @@ -162,10 +210,15 @@ const sprites: SpritePreview[] = []; - // Create a canvas for processing + // Create a canvas for processing the full sprite const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); - if (!ctx) return; + + // Create a second canvas for the cropped sprite + const croppedCanvas = document.createElement('canvas'); + const croppedCtx = croppedCanvas.getContext('2d'); + + if (!ctx || !croppedCtx) return; canvas.width = spriteWidth; canvas.height = spriteHeight; @@ -184,15 +237,43 @@ // If we're not removing empty sprites or the sprite is not empty if (!removeEmpty.value || !isEmpty) { - // Convert to data URL - const url = canvas.toDataURL('image/png'); + // Get bounding box of non-transparent pixels + const boundingBox = getSpriteBoundingBox(ctx, spriteWidth, spriteHeight); + + let url; + let x = 0; // Default position (will be updated if we have a bounding box) + let y = 0; + let width = spriteWidth; + let height = spriteHeight; + + if (boundingBox) { + // The key change: preserve the original position where the sprite was found + x = boundingBox.x; + y = boundingBox.y; + width = boundingBox.width; + height = boundingBox.height; + + // Set dimensions for the cropped sprite + croppedCanvas.width = boundingBox.width; + croppedCanvas.height = boundingBox.height; + + // Draw only the non-transparent part + croppedCtx.clearRect(0, 0, croppedCanvas.width, croppedCanvas.height); + croppedCtx.drawImage(canvas, boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height, 0, 0, boundingBox.width, boundingBox.height); + + // Convert to data URL + url = croppedCanvas.toDataURL('image/png'); + } else { + // No non-transparent pixels found, use the original sprite + url = canvas.toDataURL('image/png'); + } sprites.push({ url, - x: 0, - y: 0, - width: spriteWidth, - height: spriteHeight, + x, + y, + width, + height, isEmpty, }); } @@ -298,8 +379,8 @@ } // Convert preview sprites to actual files - async function createSpriteFiles(): Promise { - const files: File[] = []; + async function createSpriteFiles(): Promise { + const spriteFiles: SpriteFile[] = []; for (let i = 0; i < previewSprites.value.length; i++) { const sprite = previewSprites.value[i]; @@ -312,10 +393,17 @@ const fileName = `sprite_${i + 1}.png`; const file = new File([blob], fileName, { type: 'image/png' }); - files.push(file); + // Create sprite file with position information + spriteFiles.push({ + file, + x: sprite.x, + y: sprite.y, + width: sprite.width, + height: sprite.height, + }); } - return files; + return spriteFiles; } // Actions