diff --git a/package-lock.json b/package-lock.json index 3e1a46f..4781c68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-dropzone": "^14.3.5", "sharp": "^0.33.5", "tailwind-merge": "^2.6.0", + "upng-js": "^2.1.0", "zod": "^3.24.1", "zustand": "^5.0.2" }, @@ -8957,6 +8958,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upng-js": { + "version": "2.1.0", + "resolved": "https://mirrors.tencent.com/npm/upng-js/-/upng-js-2.1.0.tgz", + "integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.5" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 8abdeff..c4b4256 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-dropzone": "^14.3.5", "sharp": "^0.33.5", "tailwind-merge": "^2.6.0", + "upng-js": "^2.1.0", "zod": "^3.24.1", "zustand": "^5.0.2" }, diff --git a/src/app/page.tsx b/src/app/page.tsx index a31aa7f..3f4b07e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { ArrowRight, ChevronDown, Image as ImageIcon, + Layers, Music, ShieldCheck, Sparkles, @@ -136,7 +137,7 @@ function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) { {t("home.hero.previewTitle")} -
+
{[ { icon: Video, @@ -159,6 +160,13 @@ function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) { href: "/tools/audio-compress", tint: "from-emerald-500/20", }, + { + icon: Layers, + title: t("home.tools.textureAtlas.title"), + description: t("home.tools.textureAtlas.description"), + href: "/tools/texture-atlas", + tint: "from-orange-500/20", + }, ].map((tool) => ( -
+
{items.map((item, index) => ( ({ ); } +/** + * Get metadata content and filename for an atlas + */ +function getMetadataForAtlas( + atlas: SingleAtlasResult, + config: TextureAtlasConfig, + atlasIndex: number, + totalAtlases: number +): { content: string; filename: string; mimeType: string } { + const suffix = totalAtlases > 1 ? `_${atlasIndex}` : ""; + const imageFilename = `atlas${suffix}.${config.format}`; + + let content: string; + let filename: string; + let mimeType: string; + + if (config.outputFormat === "cocos2d") { + content = exportToCocos2dPlist(atlas.placements, atlas.width, atlas.height, imageFilename); + filename = `atlas${suffix}.plist`; + mimeType = "application/xml"; + } else if (config.outputFormat === "cocos-creator") { + content = exportToCocosCreatorJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format); + filename = `atlas${suffix}.json`; + mimeType = "application/json"; + } else { + content = exportToGenericJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format); + filename = `atlas${suffix}.json`; + mimeType = "application/json"; + } + + return { content, filename, mimeType }; +} + +/** + * Compress PNG data using UPNG quantization (similar to TinyPNG) + * @param dataUrl Original PNG data URL + * @param width Image width + * @param height Image height + * @returns Compressed PNG as Blob + */ +async function compressPng(dataUrl: string, width: number, height: number): Promise { + // Create image from data URL + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = reject; + img.src = dataUrl; + }); + + // Draw to canvas to get ImageData + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + + ctx.drawImage(img, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + + // Compress using UPNG (256 colors = 8-bit PNG) + const compressed = UPNG.encode( + [imageData.data.buffer], + width, + height, + 256 // 256 colors for best quality quantization + ); + + return new Blob([compressed], { type: "image/png" }); +} + export function AtlasConfigPanel() { const { t } = useSafeTranslation(); const { pack } = useAtlasWorker(); @@ -140,11 +214,20 @@ export function AtlasConfigPanel() { config, result, status, + currentAtlasIndex, + enableMultiAtlas, + enableCompression, updateConfig, resetConfig, + setEnableMultiAtlas, + setEnableCompression, openAnimationDialog, } = useAtlasStore(); + // Get current atlas + const currentAtlas = result?.atlases[currentAtlasIndex] || null; + const atlasCount = result?.atlases.length || 0; + // Config handlers const handleConfigChange = useCallback( (key: K, value: TextureAtlasConfig[K]) => { @@ -160,76 +243,90 @@ export function AtlasConfigPanel() { } }, [sprites.length, pack]); - // Download functions - const downloadImage = useCallback(() => { - if (!result?.imageDataUrl) return; + // Download current atlas image + const downloadImage = useCallback(async () => { + if (!currentAtlas?.imageDataUrl) return; - const link = document.createElement("a"); - link.href = result.imageDataUrl; - link.download = `atlas.${config.format}`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }, [result, config.format]); - - const getMetadataInfo = useCallback(() => { - if (!result) return null; - - let content: string; - let filename: string; - let mimeType: string; - - const imageFilename = `atlas.${config.format}`; - - if (config.outputFormat === "cocos2d") { - content = exportToCocos2dPlist(result.placements, result.width, result.height, imageFilename); - filename = "atlas.plist"; - mimeType = "application/xml"; - } else if (config.outputFormat === "cocos-creator") { - content = exportToCocosCreatorJson(result.placements, result.width, result.height, imageFilename, config.format); - filename = "atlas.json"; - mimeType = "application/json"; + const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : ""; + const filename = `atlas${suffix}.${config.format}`; + + let url: string; + let needRevoke = false; + + // Apply compression if enabled and format is PNG + if (enableCompression && config.format === "png") { + const compressedBlob = await compressPng( + currentAtlas.imageDataUrl, + currentAtlas.width, + currentAtlas.height + ); + url = URL.createObjectURL(compressedBlob); + needRevoke = true; } else { - content = exportToGenericJson(result.placements, result.width, result.height, imageFilename, config.format); - filename = "atlas.json"; - mimeType = "application/json"; + url = currentAtlas.imageDataUrl; } - - return { content, filename, mimeType }; - }, [result, config.format, config.outputFormat]); - - const downloadMetadata = useCallback(() => { - const info = getMetadataInfo(); - if (!info) return; - - const { content, filename, mimeType } = info; - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); + const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); + + if (needRevoke) { + URL.revokeObjectURL(url); + } + }, [currentAtlas, currentAtlasIndex, atlasCount, config.format, enableCompression]); + + // Download current atlas metadata + const downloadMetadata = useCallback(() => { + if (!currentAtlas) return; + + const info = getMetadataForAtlas(currentAtlas, config, currentAtlasIndex, atlasCount); + const blob = new Blob([info.content], { type: info.mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = info.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); URL.revokeObjectURL(url); - }, [getMetadataInfo]); + }, [currentAtlas, config, currentAtlasIndex, atlasCount]); + // Download all atlases as zip const downloadAll = useCallback(async () => { - if (!result?.imageDataUrl) return; - - const info = getMetadataInfo(); - if (!info) return; + if (!result || result.atlases.length === 0) return; try { const zip = new JSZip(); - const imageFilename = `atlas.${config.format}`; + const totalAtlases = result.atlases.length; + const shouldCompress = enableCompression && config.format === "png"; - // Add image to zip - const base64Data = result.imageDataUrl.split(",")[1]; - zip.file(imageFilename, base64Data, { base64: true }); - - // Add metadata to zip - zip.file(info.filename, info.content); + for (let i = 0; i < totalAtlases; i++) { + const atlas = result.atlases[i]; + if (!atlas.imageDataUrl) continue; + + const suffix = totalAtlases > 1 ? `_${i}` : ""; + const imageFilename = `atlas${suffix}.${config.format}`; + + // Add image to zip (with optional compression) + if (shouldCompress) { + const compressedBlob = await compressPng( + atlas.imageDataUrl, + atlas.width, + atlas.height + ); + zip.file(imageFilename, compressedBlob); + } else { + const base64Data = atlas.imageDataUrl.split(",")[1]; + zip.file(imageFilename, base64Data, { base64: true }); + } + + // Add metadata to zip + const metaInfo = getMetadataForAtlas(atlas, config, i, totalAtlases); + zip.file(metaInfo.filename, metaInfo.content); + } // Generate and download zip const blob = await zip.generateAsync({ type: "blob" }); @@ -244,11 +341,11 @@ export function AtlasConfigPanel() { } catch (error) { console.error("Failed to create zip:", error); } - }, [result, config.format, getMetadataInfo]); + }, [result, config, enableCompression]); // Check if can process const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering"; - const hasResult = !!result; + const hasResult = !!result && result.atlases.length > 0; return (
@@ -257,7 +354,7 @@ export function AtlasConfigPanel() {
- {t("config.textureAtlas.title") || "合图设置"} + {t("config.textureAtlas.title")}
{/* Config content */}
{/* Size settings */} - + {/* Layout settings */} - + handleConfigChange("algorithm", v)} /> handleConfigChange("allowRotation", v)} /> handleConfigChange("pot", v)} /> {/* Output settings */} - + )} + {/* Advanced settings - Multi-atlas mode */} + + setEnableMultiAtlas(v)} + /> +

+ {t("atlas.multiAtlasHint")} +

+
+ + {/* Compression settings */} + + setEnableCompression(v)} + /> +

+ {t("atlas.compressionHint")} +

+
+ + {/* Unpacked sprites warning */} + {result && result.unpackedSpriteIds.length > 0 && !enableMultiAtlas && ( +
+
+ +
+

+ {t("atlas.unpackedCount", { count: result.unpackedSpriteIds.length })} +

+

+ {t("atlas.unpackedSuggestion")} +

+
+
+
+ )} + {/* Result info */} - {result && ( + {hasResult && ( - {t("atlas.resultInfo") || "合图信息"} + {t("atlas.resultInfo")}
-
- {t("tools.textureAtlas.dimensions") || "尺寸"}: -

{result.width} × {result.height}

-
-
- {t("tools.textureAtlas.sprites") || "精灵数"}: -

{result.placements.length}

-
+ {atlasCount > 1 ? ( + <> +
+ {t("atlas.atlasCount")}: +

{atlasCount}

+
+
+ {t("tools.textureAtlas.sprites")}: +

{result.packedCount}

+
+ {currentAtlas && ( + <> +
+ + {t("atlas.currentAtlas", { index: currentAtlasIndex + 1 })}: + +
+
+ {t("tools.textureAtlas.dimensions")}: +

{currentAtlas.width} × {currentAtlas.height}

+
+
+ {t("tools.textureAtlas.sprites")}: +

{currentAtlas.placements.length}

+
+ + )} + + ) : currentAtlas && ( + <> +
+ {t("tools.textureAtlas.dimensions")}: +

{currentAtlas.width} × {currentAtlas.height}

+
+
+ {t("tools.textureAtlas.sprites")}: +

{currentAtlas.placements.length}

+
+ + )}
@@ -409,12 +587,12 @@ export function AtlasConfigPanel() { {status === "packing" || status === "rendering" ? ( <> - {t("common.processing") || "处理中..."} + {t("common.processing")} ) : ( <> - {t("tools.textureAtlas.createAtlas") || "生成合图"} + {t("tools.textureAtlas.createAtlas")} )} @@ -427,7 +605,7 @@ export function AtlasConfigPanel() { disabled={sprites.length < 2} > - {t("atlas.previewAnimation") || "预览动画"} + {t("atlas.previewAnimation")} {/* Download buttons */} @@ -439,7 +617,7 @@ export function AtlasConfigPanel() { onClick={downloadImage} > - {t("tools.textureAtlas.downloadImage") || "图片"} + {t("tools.textureAtlas.downloadImage")}
)} @@ -459,7 +637,10 @@ export function AtlasConfigPanel() { onClick={downloadAll} > - {t("tools.textureAtlas.downloadAll") || "打包下载"} + {atlasCount > 1 + ? t("atlas.downloadAllAtlases", { count: atlasCount }) + : t("tools.textureAtlas.downloadAll") + } )}
diff --git a/src/components/tools/atlas/CanvasPreview.tsx b/src/components/tools/atlas/CanvasPreview.tsx index 5a0013d..7aded1c 100644 --- a/src/components/tools/atlas/CanvasPreview.tsx +++ b/src/components/tools/atlas/CanvasPreview.tsx @@ -9,7 +9,10 @@ import { Play, Layers, Move, - Download + Download, + AlertTriangle, + ChevronLeft, + ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -37,7 +40,7 @@ export function CanvasPreview() { const { t } = useSafeTranslation(); const containerRef = useRef(null); const canvasRef = useRef(null); - const imageCacheRef = useRef<{ url: string; image: HTMLImageElement } | null>(null); + const imageCacheRef = useRef>(new Map()); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const [isPanning, setIsPanning] = useState(false); const [hasMoved, setHasMoved] = useState(false); @@ -48,18 +51,24 @@ export function CanvasPreview() { result, status, progress, + currentAtlasIndex, previewScale, previewOffset, selectedSpriteIds, setPreviewScale, setPreviewOffset, + setCurrentAtlasIndex, selectSprite, openAnimationDialog, } = useAtlasStore(); + // Get current atlas + const currentAtlas = result?.atlases[currentAtlasIndex] || null; + const atlasCount = result?.atlases.length || 0; + // Calculate dimensions - const atlasWidth = result?.width || 0; - const atlasHeight = result?.height || 0; + const atlasWidth = currentAtlas?.width || 0; + const atlasHeight = currentAtlas?.height || 0; // Update container size on resize useEffect(() => { @@ -103,7 +112,7 @@ export function CanvasPreview() { ctx.fillStyle = "#0f0f11"; ctx.fillRect(0, 0, cw, ch); - if (result && result.imageDataUrl) { + if (currentAtlas && currentAtlas.imageDataUrl) { const drawImage = (img: HTMLImageElement) => { ctx.clearRect(0, 0, cw, ch); ctx.fillStyle = "#0f0f11"; @@ -132,7 +141,7 @@ export function CanvasPreview() { // Draw selection highlight for all selected sprites if (selectedSpriteIds.length > 0) { - result.placements.forEach(p => { + currentAtlas.placements.forEach(p => { if (selectedSpriteIds.includes(p.id)) { const pw = (p.rotated ? p.height : p.width) * previewScale; const ph = (p.rotated ? p.width : p.height) * previewScale; @@ -194,15 +203,16 @@ export function CanvasPreview() { }; // Use cached image if URL matches - if (imageCacheRef.current && imageCacheRef.current.url === result.imageDataUrl) { - drawImage(imageCacheRef.current.image); + const cached = imageCacheRef.current.get(currentAtlasIndex); + if (cached && cached.url === currentAtlas.imageDataUrl) { + drawImage(cached.image); } else { // Load and draw the atlas image const img = new Image(); - img.src = result.imageDataUrl; + img.src = currentAtlas.imageDataUrl; img.onload = () => { - imageCacheRef.current = { url: result.imageDataUrl!, image: img }; + imageCacheRef.current.set(currentAtlasIndex, { url: currentAtlas.imageDataUrl!, image: img }); drawImage(img); }; } @@ -213,38 +223,48 @@ export function CanvasPreview() { ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText( - t("atlas.emptyPreview") || "上传精灵图后预览合图效果", + t("atlas.emptyPreview"), cw / 2, ch / 2 ); } - }, [containerSize, result, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, t]); + }, [containerSize, currentAtlas, currentAtlasIndex, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, selectedSpriteIds, t]); - // Render loop for animation (highlights) + // Render loop for animation (selection highlight pulsing) useEffect(() => { + if (selectedSpriteIds.length === 0) return; + let animationFrame: number; + let mounted = true; + const render = () => { - // Re-trigger the main render useEffect by some means or just call a separate draw function - // For simplicity, we can just use a dummy state to force re-render if needed, - // but here we already have selectedSpriteIds in the dependency array of the main render effect. - // To get smooth pulsing, we can just request another frame. - if (selectedSpriteIds.length > 0) { - // This is a bit hacky but works for a canvas in React - // A better way would be to move drawing logic to a separate function - setContainerSize(s => ({ ...s })); - } + if (!mounted) return; + + // Force re-render by updating containerSize (triggers canvas redraw) + setContainerSize(s => ({ ...s })); animationFrame = requestAnimationFrame(render); }; animationFrame = requestAnimationFrame(render); - return () => cancelAnimationFrame(animationFrame); - }, [selectedSpriteIds]); + return () => { + mounted = false; + cancelAnimationFrame(animationFrame); + }; + }, [selectedSpriteIds.length > 0]); // Only care about whether there are selections - // Handle wheel zoom - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.1 : 0.1; - setPreviewScale(previewScale + delta); + // Handle wheel zoom - use native event listener with passive: false + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setPreviewScale(previewScale + delta); + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); }, [previewScale, setPreviewScale]); // Handle mouse down for panning and picking @@ -274,7 +294,7 @@ export function CanvasPreview() { // Handle mouse up (Picking logic) const handleMouseUp = useCallback((e: React.MouseEvent) => { - if (isPanning && !hasMoved && result && containerRef.current) { + if (isPanning && !hasMoved && currentAtlas && containerRef.current) { // Pick sprite const rect = containerRef.current.getBoundingClientRect(); const mouseX = e.clientX - rect.left; @@ -291,7 +311,7 @@ export function CanvasPreview() { const atlasY = (mouseY - centerY) / previewScale; // Find sprite under cursor - const clickedSprite = result.placements.find(p => { + const clickedSprite = currentAtlas.placements.find(p => { const pw = p.rotated ? p.height : p.width; const ph = p.rotated ? p.width : p.height; return atlasX >= p.x && atlasX <= p.x + pw && @@ -305,11 +325,11 @@ export function CanvasPreview() { } } setIsPanning(false); - }, [isPanning, hasMoved, result, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]); + }, [isPanning, hasMoved, currentAtlas, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]); // Fit to view const fitToView = useCallback(() => { - if (!result || containerSize.width === 0) return; + if (!currentAtlas || containerSize.width === 0) return; const padding = 40; const availableWidth = containerSize.width - padding * 2; @@ -321,7 +341,7 @@ export function CanvasPreview() { setPreviewScale(newScale); setPreviewOffset({ x: 0, y: 0 }); - }, [result, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]); + }, [currentAtlas, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]); // Zoom controls const zoomIn = useCallback(() => setPreviewScale(previewScale + 0.1), [previewScale, setPreviewScale]); @@ -329,15 +349,29 @@ export function CanvasPreview() { // Download image const downloadImage = useCallback(() => { - if (!result?.imageDataUrl) return; + if (!currentAtlas?.imageDataUrl) return; const link = document.createElement("a"); - link.href = result.imageDataUrl; - link.download = `atlas_${atlasWidth}x${atlasHeight}.png`; + link.href = currentAtlas.imageDataUrl; + const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : ""; + link.download = `atlas${suffix}_${atlasWidth}x${atlasHeight}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); - }, [result, atlasWidth, atlasHeight]); + }, [currentAtlas, currentAtlasIndex, atlasCount, atlasWidth, atlasHeight]); + + // Navigate atlases + const goToPrevAtlas = useCallback(() => { + if (currentAtlasIndex > 0) { + setCurrentAtlasIndex(currentAtlasIndex - 1); + } + }, [currentAtlasIndex, setCurrentAtlasIndex]); + + const goToNextAtlas = useCallback(() => { + if (currentAtlasIndex < atlasCount - 1) { + setCurrentAtlasIndex(currentAtlasIndex + 1); + } + }, [currentAtlasIndex, atlasCount, setCurrentAtlasIndex]); // Scale percentage const scalePercent = Math.round(previewScale * 100); @@ -349,9 +383,9 @@ export function CanvasPreview() {
- {t("atlas.preview") || "预览"} + {t("atlas.preview")} - {result && ( + {currentAtlas && ( {atlasWidth} × {atlasHeight} @@ -359,6 +393,37 @@ export function CanvasPreview() {
+ {/* Atlas navigation (only show when multiple atlases) */} + {atlasCount > 1 && ( + <> + + +
+ {currentAtlasIndex + 1} / {atlasCount} +
+ + + +
+ + )} + {/* Zoom controls */} @@ -405,7 +470,7 @@ export function CanvasPreview() { className="h-7 w-7" onClick={openAnimationDialog} disabled={sprites.length < 2} - title={t("atlas.previewAnimation") || "预览动画"} + title={t("atlas.previewAnimation")} > @@ -416,8 +481,8 @@ export function CanvasPreview() { size="icon" className="h-7 w-7" onClick={downloadImage} - disabled={!result?.imageDataUrl} - title={t("common.download") || "下载"} + disabled={!currentAtlas?.imageDataUrl} + title={t("common.download")} > @@ -428,7 +493,6 @@ export function CanvasPreview() {

- {status === "packing" && (t("atlas.packing") || "打包中...")} - {status === "rendering" && (t("atlas.rendering") || "渲染中...")} - {status === "loading" && (t("common.loading") || "加载中...")} + {status === "packing" && t("atlas.packing")} + {status === "rendering" && t("atlas.rendering")} + {status === "loading" && t("common.loading")}

{progress > 0 && (
@@ -473,19 +537,45 @@ export function CanvasPreview() {

- {t("atlas.emptyPreview") || "上传精灵图后预览合图效果"} + {t("atlas.emptyPreview")}

- {t("atlas.dragHint") || "拖拽文件或文件夹到左侧面板"} + {t("atlas.dragHint")}

)} {/* Pan hint */} - {result && !isPanning && ( + {currentAtlas && !isPanning && result && result.unpackedSpriteIds.length === 0 && (
- {t("atlas.panHint") || "拖拽平移,滚轮缩放"} + {t("atlas.panHint")} +
+ )} + + {/* Multi-atlas indicator */} + {atlasCount > 1 && ( +
+ + {t("atlas.atlasIndex", { current: currentAtlasIndex + 1, total: atlasCount })} +
+ )} + + {/* Unpacked sprites warning */} + {result && result.unpackedSpriteIds.length > 0 && ( +
+ +
+

+ {t("atlas.unpackedWarning", { count: result.unpackedSpriteIds.length })} +

+

+ {t("atlas.unpackedHint")} +

+
+ + {result.unpackedSpriteIds.length} / {sprites.length} +
)}
diff --git a/src/components/tools/atlas/FileListPanel.tsx b/src/components/tools/atlas/FileListPanel.tsx index dd9679f..3bc0857 100644 --- a/src/components/tools/atlas/FileListPanel.tsx +++ b/src/components/tools/atlas/FileListPanel.tsx @@ -10,7 +10,8 @@ import { Upload, X, ChevronDown, - ChevronRight + ChevronRight, + AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAtlasStore, type BrowserSprite } from "@/store/atlasStore"; @@ -81,6 +82,7 @@ export function FileListPanel() { const { sprites, folderName, + result, addSprites, removeSprite, clearSprites, @@ -89,6 +91,9 @@ export function FileListPanel() { selectSprite, setStatus, } = useAtlasStore(); + + // Get unpacked sprite IDs + const unpackedSpriteIds = result?.unpackedSpriteIds || []; /** * Process uploaded files @@ -363,6 +368,7 @@ export function FileListPanel() { group flex items-center gap-2.5 rounded-xl px-2.5 py-2 cursor-pointer transition-all duration-150 hover:bg-white/[0.06] ${selectedSpriteIds.includes(sprite.id) ? "bg-primary/15 ring-1 ring-primary/30" : ""} + ${unpackedSpriteIds.includes(sprite.id) ? "bg-amber-500/10 border border-amber-500/30" : ""} `} > {/* Thumbnail */} @@ -389,11 +395,19 @@ export function FileListPanel() { }} className="h-full w-full" /> + {/* Unpacked indicator */} + {unpackedSpriteIds.includes(sprite.id) && ( +
+ +
+ )}
{/* Info */}
-

{sprite.name}

+

+ {sprite.name} +

{sprite.width} × {sprite.height}

diff --git a/src/hooks/useAtlasWorker.ts b/src/hooks/useAtlasWorker.ts index 790c45d..850a9b4 100644 --- a/src/hooks/useAtlasWorker.ts +++ b/src/hooks/useAtlasWorker.ts @@ -1,24 +1,26 @@ /** * Hook for managing Atlas Worker communication + * Supports multi-atlas mode and PNG compression */ import { useRef, useCallback, useEffect } from "react"; -import { useAtlasStore, type BrowserSprite, type AtlasResult } from "@/store/atlasStore"; +import { useAtlasStore, type BrowserSprite, type AtlasResult, type SingleAtlasResult } from "@/store/atlasStore"; import type { TextureAtlasConfig, AtlasFrame } from "@/types"; -import type { PackerPlacement } from "@/lib/atlas-packer"; +import type { PackerPlacement, SinglePackerResult } from "@/lib/atlas-packer"; interface WorkerInputMessage { type: "pack"; sprites: { id: string; name: string; width: number; height: number }[]; config: TextureAtlasConfig; + enableMultiAtlas: boolean; } interface WorkerOutputMessage { type: "result" | "progress" | "error"; result?: { - width: number; - height: number; - placements: PackerPlacement[]; + atlases: SinglePackerResult[]; + packedCount: number; + unpackedSprites: { id: string; name: string; width: number; height: number }[]; }; progress?: number; error?: string; @@ -26,6 +28,7 @@ interface WorkerOutputMessage { /** * Render sprites to canvas and get data URL + * Always returns uncompressed data URL for preview */ async function renderAtlasToCanvas( sprites: BrowserSprite[], @@ -91,7 +94,7 @@ function buildFrames(placements: PackerPlacement[]): AtlasFrame[] { export function useAtlasWorker() { const workerRef = useRef(null); - const { sprites, config, setStatus, setProgress, setError, setResult } = useAtlasStore(); + const { sprites, config, enableMultiAtlas, setStatus, setProgress, setError, setResult } = useAtlasStore(); // Initialize worker useEffect(() => { @@ -115,23 +118,44 @@ export function useAtlasWorker() { setProgress(50); try { - // Render to canvas const currentSprites = useAtlasStore.getState().sprites; - const imageDataUrl = await renderAtlasToCanvas( - currentSprites, - result.placements, - result.width, - result.height - ); + const atlasCount = result.atlases.length; + const atlasResults: SingleAtlasResult[] = []; + + // Render each atlas + for (let i = 0; i < atlasCount; i++) { + const atlas = result.atlases[i]; + + const imageDataUrl = await renderAtlasToCanvas( + currentSprites, + atlas.placements, + atlas.width, + atlas.height + ); - const frames = buildFrames(result.placements); + const frames = buildFrames(atlas.placements); + + atlasResults.push({ + index: atlas.index, + width: atlas.width, + height: atlas.height, + placements: atlas.placements, + frames, + imageDataUrl, + spriteIds: atlas.spriteIds, + }); + + // Update progress for rendering + setProgress(50 + ((i + 1) / atlasCount) * 50); + } + + // Extract unpacked sprite IDs + const unpackedSpriteIds = result.unpackedSprites.map(s => s.id); const atlasResult: AtlasResult = { - width: result.width, - height: result.height, - placements: result.placements, - frames, - imageDataUrl, + atlases: atlasResults, + packedCount: result.packedCount, + unpackedSpriteIds, }; setResult(atlasResult); @@ -176,10 +200,11 @@ export function useAtlasWorker() { height: s.height, })), config, + enableMultiAtlas, }; workerRef.current.postMessage(message); - }, [sprites, config, setStatus, setProgress, setError]); + }, [sprites, config, enableMultiAtlas, setStatus, setProgress, setError]); return { pack }; } @@ -371,148 +396,196 @@ class ShelfPacker { } } -function packWithMaxRects(sprites, config, postProgress) { +function packSingleAtlas(sprites, config, atlasIndex) { const padding = config.padding; - const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation); - const placements = new Map(); - const sorted = sortSpritesBySize(sprites); + let packWidth = config.maxWidth; + let packHeight = config.maxHeight; - for (let i = 0; i < sorted.length; i++) { - const sprite = sorted[i]; - const paddedWidth = sprite.width + padding * 2; - const paddedHeight = sprite.height + padding * 2; - const position = packer.insert(paddedWidth, paddedHeight); - - if (!position) return null; - - placements.set(sprite.id, { - x: position.x + padding, - y: position.y + padding, - width: sprite.width, - height: sprite.height, - rotated: position.rotated, - }); - - postProgress(((i + 1) / sorted.length) * 100); + if (config.pot) { + packWidth = adjustSizeForPot(packWidth, true); + packHeight = adjustSizeForPot(packHeight, true); + packWidth = Math.min(packWidth, config.maxWidth); + packHeight = Math.min(packHeight, config.maxHeight); } + + const sortedSprites = sortSpritesBySize(sprites); + const placements = new Map(); + const unpackedSprites = []; - return placements; + if (config.algorithm === "MaxRects") { + const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation); + + for (const sprite of sortedSprites) { + const paddedWidth = sprite.width + padding * 2; + const paddedHeight = sprite.height + padding * 2; + + if (paddedWidth > packWidth || paddedHeight > packHeight) { + if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) { + const position = packer.insert(paddedHeight, paddedWidth); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: true, + }); + continue; + } + } + unpackedSprites.push(sprite); + continue; + } + + const position = packer.insert(paddedWidth, paddedHeight); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: position.rotated, + }); + } else { + unpackedSprites.push(sprite); + } + } + } else { + const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding); + + for (const sprite of sortedSprites) { + const paddedWidth = sprite.width + padding * 2; + const paddedHeight = sprite.height + padding * 2; + + if (paddedWidth > packWidth || paddedHeight > packHeight) { + if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) { + const position = packer.insert(sprite.height, sprite.width); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: true, + }); + continue; + } + } + unpackedSprites.push(sprite); + continue; + } + + const position = packer.insert(sprite.width, sprite.height); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: position.rotated, + }); + } else { + unpackedSprites.push(sprite); + } + } + } + + let maxX = 0, maxY = 0; + for (const p of placements.values()) { + const effectiveWidth = p.rotated ? p.height : p.width; + const effectiveHeight = p.rotated ? p.width : p.height; + maxX = Math.max(maxX, p.x + effectiveWidth + padding); + maxY = Math.max(maxY, p.y + effectiveHeight + padding); + } + + let finalWidth, finalHeight; + + if (config.pot) { + finalWidth = adjustSizeForPot(Math.ceil(maxX), true); + finalHeight = adjustSizeForPot(Math.ceil(maxY), true); + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); + } else { + finalWidth = Math.ceil(maxX); + finalHeight = Math.ceil(maxY); + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); + } + + const resultPlacements = []; + const spriteIds = []; + + for (const sprite of sprites) { + const placement = placements.get(sprite.id); + if (placement) { + resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement }); + spriteIds.push(sprite.id); + } + } + + return { + result: { + index: atlasIndex, + width: finalWidth || 1, + height: finalHeight || 1, + placements: resultPlacements, + spriteIds, + }, + unpackedSprites, + }; } -function packWithShelf(sprites, config, postProgress) { - const padding = config.padding; - const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding); - const placements = new Map(); - const sorted = sortSpritesBySize(sprites); - - for (let i = 0; i < sorted.length; i++) { - const sprite = sorted[i]; - const position = packer.insert(sprite.width, sprite.height); - - if (!position) return null; - - placements.set(sprite.id, { - x: position.x + padding, - y: position.y + padding, - width: sprite.width, - height: sprite.height, - rotated: position.rotated, - }); - - postProgress(((i + 1) / sorted.length) * 100); - } - - return placements; -} - -function packSprites(sprites, config, postProgress) { +function packSprites(sprites, config, enableMultiAtlas, postProgress) { if (sprites.length === 0) return null; - const padding = config.padding; - const maxSpriteWidth = Math.max(...sprites.map(s => s.width)); - const maxSpriteHeight = Math.max(...sprites.map(s => s.height)); - const totalArea = sprites.reduce((sum, s) => sum + (s.width + padding * 2) * (s.height + padding * 2), 0); - const minSide = Math.ceil(Math.sqrt(totalArea / 0.85)); - const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide); - const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide); - - const sizeAttempts = []; - - if (config.pot) { - const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(s => s <= config.maxWidth || s <= config.maxHeight); - for (const w of potSizes) { - for (const h of potSizes) { - if (w <= config.maxWidth && h <= config.maxHeight && w >= maxSpriteWidth + padding * 2 && h >= maxSpriteHeight + padding * 2) { - sizeAttempts.push({ w, h }); - } - } + const atlases = []; + let remainingSprites = [...sprites]; + let atlasIndex = 0; + let totalProcessed = 0; + + while (remainingSprites.length > 0) { + const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex); + + if (result.placements.length === 0) { + break; } - sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h); - } else { - sizeAttempts.push( - { w: estimatedWidth, h: estimatedHeight }, - { w: estimatedWidth * 1.5, h: estimatedHeight }, - { w: estimatedWidth, h: estimatedHeight * 1.5 }, - { w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 }, - { w: estimatedWidth * 2, h: estimatedHeight }, - { w: estimatedWidth, h: estimatedHeight * 2 }, - { w: estimatedWidth * 2, h: estimatedHeight * 2 }, - { w: config.maxWidth, h: config.maxHeight } - ); - } - - const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => { - const w = Math.min(Math.ceil(attempt.w), config.maxWidth); - const h = Math.min(Math.ceil(attempt.h), config.maxHeight); - return self.findIndex(a => Math.min(Math.ceil(a.w), config.maxWidth) === w && Math.min(Math.ceil(a.h), config.maxHeight) === h) === index; - }); - - for (const attempt of uniqueAttempts) { - const attemptWidth = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w), config.maxWidth); - const attemptHeight = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h), config.maxHeight); - - if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) continue; - - const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight }; - let placements; - - if (config.algorithm === "MaxRects") { - placements = packWithMaxRects(sprites, testConfig, postProgress); - } else { - placements = packWithShelf(sprites, testConfig, postProgress); + + atlases.push(result); + totalProcessed += result.placements.length; + postProgress((totalProcessed / sprites.length) * 100); + + if (!enableMultiAtlas) { + return { + atlases, + packedCount: result.placements.length, + unpackedSprites, + }; } - - if (placements) { - let maxX = 0, maxY = 0; - for (const p of placements.values()) { - const effectiveWidth = p.rotated ? p.height : p.width; - const effectiveHeight = p.rotated ? p.width : p.height; - maxX = Math.max(maxX, p.x + effectiveWidth + padding); - maxY = Math.max(maxY, p.y + effectiveHeight + padding); - } - - let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX); - let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY); - finalWidth = Math.min(finalWidth, attemptWidth); - finalHeight = Math.min(finalHeight, attemptHeight); - - const resultPlacements = []; - for (const sprite of sprites) { - const placement = placements.get(sprite.id); - if (placement) { - resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement }); - } - } - - return { width: finalWidth, height: finalHeight, placements: resultPlacements }; + + remainingSprites = unpackedSprites; + atlasIndex++; + + if (atlasIndex > 100) { + console.warn("Max atlas limit reached"); + break; } } + + if (atlases.length === 0) { + return null; + } - return null; + const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0); + + return { + atlases, + packedCount: totalPacked, + unpackedSprites: remainingSprites, + }; } self.onmessage = function(event) { - const { type, sprites, config } = event.data; + const { type, sprites, config, enableMultiAtlas } = event.data; if (type === "pack") { try { @@ -520,12 +593,12 @@ self.onmessage = function(event) { self.postMessage({ type: "progress", progress }); }; - const result = packSprites(sprites, config, postProgress); + const result = packSprites(sprites, config, enableMultiAtlas, postProgress); if (result) { self.postMessage({ type: "result", result }); } else { - self.postMessage({ type: "error", error: "Failed to pack all sprites. Try increasing max size or enabling rotation." }); + self.postMessage({ type: "error", error: "No sprites could be packed. Check sprite sizes and max dimensions." }); } } catch (error) { self.postMessage({ type: "error", error: error.message || "Unknown error" }); diff --git a/src/lib/atlas-packer.ts b/src/lib/atlas-packer.ts index 1f68a16..d7db154 100644 --- a/src/lib/atlas-packer.ts +++ b/src/lib/atlas-packer.ts @@ -1,6 +1,7 @@ /** * Browser-side Texture Atlas Packing Algorithms * Implements MaxRects and Shelf algorithms for packing sprites + * Supports multi-atlas mode (like TexturePacker) */ import type { TextureAtlasConfig } from "@/types"; @@ -39,12 +40,24 @@ export interface PackerPlacement { } /** - * Complete packing result + * Single atlas packing result */ -export interface PackerResult { +export interface SinglePackerResult { + index: number; width: number; height: number; placements: PackerPlacement[]; + spriteIds: string[]; +} + +/** + * Complete packing result (supports multiple atlases) + */ +export interface PackerResult { + atlases: SinglePackerResult[]; + packedCount: number; + /** Sprites that couldn't fit (only when multi-atlas is disabled) */ + unpackedSprites: PackerSprite[]; } /** @@ -346,202 +359,233 @@ class ShelfPacker { } /** - * Pack sprites using MaxRects algorithm + * Pack sprites into a single atlas + * Returns placements and unpacked sprites */ -function packWithMaxRects( +function packSingleAtlas( sprites: PackerSprite[], - config: TextureAtlasConfig -): Map | null { + config: TextureAtlasConfig, + atlasIndex: number +): { result: SinglePackerResult; unpackedSprites: PackerSprite[] } { const padding = config.padding; - const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation); - const placements = new Map(); - - for (const sprite of sortSpritesBySize(sprites)) { - const paddedWidth = sprite.width + padding * 2; - const paddedHeight = sprite.height + padding * 2; - - const position = packer.insert(paddedWidth, paddedHeight); - - if (!position) { - return null; // Failed to pack - } - - placements.set(sprite.id, { - x: position.x + padding, - y: position.y + padding, - width: sprite.width, - height: sprite.height, - rotated: position.rotated, - }); + + // Use the max dimensions directly for packing + let packWidth = config.maxWidth; + let packHeight = config.maxHeight; + + // For POT mode, ensure dimensions are power of two + if (config.pot) { + packWidth = adjustSizeForPot(packWidth, true); + packHeight = adjustSizeForPot(packHeight, true); + // Clamp to max + packWidth = Math.min(packWidth, config.maxWidth); + packHeight = Math.min(packHeight, config.maxHeight); } - return placements; -} - -/** - * Pack sprites using Shelf algorithm - */ -function packWithShelf( - sprites: PackerSprite[], - config: TextureAtlasConfig -): Map | null { - const padding = config.padding; - const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding); + // Sort sprites by size (largest first) for better packing + const sortedSprites = sortSpritesBySize(sprites); + + // Pack sprites one by one, tracking which ones fit const placements = new Map(); - - for (const sprite of sortSpritesBySize(sprites)) { - const position = packer.insert(sprite.width, sprite.height); - - if (!position) { - return null; + const unpackedSprites: PackerSprite[] = []; + + // Create packer with max dimensions + if (config.algorithm === "MaxRects") { + const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation); + + for (const sprite of sortedSprites) { + const paddedWidth = sprite.width + padding * 2; + const paddedHeight = sprite.height + padding * 2; + + // Check if sprite is too large even without packing + if (paddedWidth > packWidth || paddedHeight > packHeight) { + // Try rotated if allowed + if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) { + const position = packer.insert(paddedHeight, paddedWidth); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: true, + }); + continue; + } + } + unpackedSprites.push(sprite); + continue; + } + + const position = packer.insert(paddedWidth, paddedHeight); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: position.rotated, + }); + } else { + unpackedSprites.push(sprite); + } + } + } else { + // Shelf algorithm + const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding); + + for (const sprite of sortedSprites) { + const paddedWidth = sprite.width + padding * 2; + const paddedHeight = sprite.height + padding * 2; + + // Check if sprite is too large + if (paddedWidth > packWidth || paddedHeight > packHeight) { + if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) { + const position = packer.insert(sprite.height, sprite.width); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: true, + }); + continue; + } + } + unpackedSprites.push(sprite); + continue; + } + + const position = packer.insert(sprite.width, sprite.height); + if (position) { + placements.set(sprite.id, { + x: position.x + padding, + y: position.y + padding, + width: sprite.width, + height: sprite.height, + rotated: position.rotated, + }); + } else { + unpackedSprites.push(sprite); + } } - - placements.set(sprite.id, { - x: position.x + padding, - y: position.y + padding, - width: sprite.width, - height: sprite.height, - rotated: position.rotated, - }); } - return placements; + // Calculate actual dimensions based on sprite placements + let maxX = 0; + let maxY = 0; + for (const placement of placements.values()) { + const effectiveWidth = placement.rotated ? placement.height : placement.width; + const effectiveHeight = placement.rotated ? placement.width : placement.height; + maxX = Math.max(maxX, placement.x + effectiveWidth + padding); + maxY = Math.max(maxY, placement.y + effectiveHeight + padding); + } + + // Calculate the minimum required dimensions + let finalWidth: number; + let finalHeight: number; + + if (config.pot) { + finalWidth = nextPowerOfTwo(Math.ceil(maxX)); + finalHeight = nextPowerOfTwo(Math.ceil(maxY)); + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); + } else { + finalWidth = Math.ceil(maxX); + finalHeight = Math.ceil(maxY); + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); + } + + // Build result placements in original order + const resultPlacements: PackerPlacement[] = []; + const spriteIds: string[] = []; + + for (const sprite of sprites) { + const placement = placements.get(sprite.id); + if (placement) { + resultPlacements.push({ + id: sprite.id, + name: sprite.name, + ...placement, + }); + spriteIds.push(sprite.id); + } + } + + return { + result: { + index: atlasIndex, + width: finalWidth || 1, + height: finalHeight || 1, + placements: resultPlacements, + spriteIds, + }, + unpackedSprites, + }; } /** * Main packing function + * Supports multi-atlas mode */ -export function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig): PackerResult | null { +export function packSprites( + sprites: PackerSprite[], + config: TextureAtlasConfig, + enableMultiAtlas: boolean = false +): PackerResult | null { if (sprites.length === 0) { return null; } - const padding = config.padding; - const maxSpriteWidth = Math.max(...sprites.map((s) => s.width)); - const maxSpriteHeight = Math.max(...sprites.map((s) => s.height)); - - // Calculate total area for estimation - const totalArea = sprites.reduce((sum, s) => { - const pw = s.width + padding * 2; - const ph = s.height + padding * 2; - return sum + pw * ph; - }, 0); - - // Start with estimated size - const minSide = Math.ceil(Math.sqrt(totalArea / 0.85)); - const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide); - const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide); - - // Build size attempts - const sizeAttempts: { w: number; h: number }[] = []; - - if (config.pot) { - const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter( - (s) => s <= config.maxWidth || s <= config.maxHeight - ); - for (const w of potSizes) { - for (const h of potSizes) { - if ( - w <= config.maxWidth && - h <= config.maxHeight && - w >= maxSpriteWidth + padding * 2 && - h >= maxSpriteHeight + padding * 2 - ) { - sizeAttempts.push({ w, h }); - } - } - } - sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h); - } else { - sizeAttempts.push( - { w: estimatedWidth, h: estimatedHeight }, - { w: estimatedWidth * 1.5, h: estimatedHeight }, - { w: estimatedWidth, h: estimatedHeight * 1.5 }, - { w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 }, - { w: estimatedWidth * 2, h: estimatedHeight }, - { w: estimatedWidth, h: estimatedHeight * 2 }, - { w: estimatedWidth * 2, h: estimatedHeight * 2 }, - { w: config.maxWidth, h: config.maxHeight } - ); - } - - // Remove duplicates - const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => { - const w = Math.min(Math.ceil(attempt.w), config.maxWidth); - const h = Math.min(Math.ceil(attempt.h), config.maxHeight); - return ( - self.findIndex( - (a) => - Math.min(Math.ceil(a.w), config.maxWidth) === w && - Math.min(Math.ceil(a.h), config.maxHeight) === h - ) === index - ); - }); - - // Try each size - for (const attempt of uniqueAttempts) { - const attemptWidth = Math.min( - config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w), - config.maxWidth - ); - const attemptHeight = Math.min( - config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h), - config.maxHeight - ); - - if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) { - continue; - } - - const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight }; + const atlases: SinglePackerResult[] = []; + let remainingSprites = [...sprites]; + let atlasIndex = 0; + + // Pack into atlases + while (remainingSprites.length > 0) { + const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex); - let placements: Map | null; - - if (config.algorithm === "MaxRects") { - placements = packWithMaxRects(sprites, testConfig); - } else { - placements = packWithShelf(sprites, testConfig); + // If nothing was packed, we have sprites that are too large + if (result.placements.length === 0) { + break; } - - if (placements) { - // Calculate actual dimensions - let maxX = 0; - let maxY = 0; - for (const placement of placements.values()) { - const effectiveWidth = placement.rotated ? placement.height : placement.width; - const effectiveHeight = placement.rotated ? placement.width : placement.height; - maxX = Math.max(maxX, placement.x + effectiveWidth + padding); - maxY = Math.max(maxY, placement.y + effectiveHeight + padding); - } - - let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX); - let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY); - - finalWidth = Math.min(finalWidth, attemptWidth); - finalHeight = Math.min(finalHeight, attemptHeight); - - // Build result - const resultPlacements: PackerPlacement[] = []; - for (const sprite of sprites) { - const placement = placements.get(sprite.id); - if (placement) { - resultPlacements.push({ - id: sprite.id, - name: sprite.name, - ...placement, - }); - } - } - + + atlases.push(result); + + // If multi-atlas is disabled, stop after first atlas + if (!enableMultiAtlas) { + // Return with unpacked sprites info return { - width: finalWidth, - height: finalHeight, - placements: resultPlacements, + atlases, + packedCount: result.placements.length, + unpackedSprites, }; } + + // Continue with remaining sprites + remainingSprites = unpackedSprites; + atlasIndex++; + + // Safety limit to prevent infinite loops + if (atlasIndex > 100) { + console.warn("Max atlas limit reached"); + break; + } + } + + if (atlases.length === 0) { + return null; } - return null; + const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0); + + return { + atlases, + packedCount: totalPacked, + unpackedSprites: remainingSprites, + }; } /** diff --git a/src/lib/atlas-worker.ts b/src/lib/atlas-worker.ts index f695575..9f18b76 100644 --- a/src/lib/atlas-worker.ts +++ b/src/lib/atlas-worker.ts @@ -358,10 +358,25 @@ function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig, postPr maxY = Math.max(maxY, p.y + effectiveHeight + padding); } - let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX); - let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY); - finalWidth = Math.min(finalWidth, attemptWidth); - finalHeight = Math.min(finalHeight, attemptHeight); + // Calculate the minimum required dimensions based on actual content + let finalWidth: number; + let finalHeight: number; + + if (config.pot) { + // Find smallest POT size that fits the actual content + finalWidth = adjustSizeForPot(Math.ceil(maxX), true); + finalHeight = adjustSizeForPot(Math.ceil(maxY), true); + // Ensure we don't exceed max limits + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); + } else { + // For non-POT, use exact dimensions needed + finalWidth = Math.ceil(maxX); + finalHeight = Math.ceil(maxY); + // Ensure we don't exceed max limits + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); + } const resultPlacements: PackerPlacement[] = []; for (const sprite of sprites) { diff --git a/src/lib/texture-atlas.ts b/src/lib/texture-atlas.ts index bad211a..617dfa6 100644 --- a/src/lib/texture-atlas.ts +++ b/src/lib/texture-atlas.ts @@ -540,16 +540,25 @@ export async function createTextureAtlas( maxY = Math.max(maxY, placement.y + placement.height + padding); } - // Adjust final dimensions based on actual usage if POT + // Calculate the minimum required dimensions based on actual content + // For POT mode: find the smallest power of two that fits the actual content + // For non-POT mode: use the actual content size if (config.pot) { - finalWidth = adjustSizeForPot(maxX, true); - finalHeight = adjustSizeForPot(maxY, true); - // Make sure we don't exceed attempted dimensions - finalWidth = Math.min(finalWidth, attemptWidth); - finalHeight = Math.min(finalHeight, attemptHeight); + // Find smallest POT size that fits the actual content + finalWidth = adjustSizeForPot(Math.ceil(maxX), true); + finalHeight = adjustSizeForPot(Math.ceil(maxY), true); + + // Ensure we don't exceed max limits + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); } else { + // For non-POT, use exact dimensions needed finalWidth = Math.ceil(maxX); finalHeight = Math.ceil(maxY); + + // Ensure we don't exceed max limits + finalWidth = Math.min(finalWidth, config.maxWidth); + finalHeight = Math.min(finalHeight, config.maxHeight); } success = true; diff --git a/src/locales/en.json b/src/locales/en.json index 6ed38ea..d048412 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -31,7 +31,9 @@ "file": "File", "files": "files", "yes": "Yes", - "no": "No" + "no": "No", + "on": "On", + "off": "Off" }, "nav": { "tools": "Tools", @@ -120,6 +122,10 @@ "title": "Audio Compression", "description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate." }, + "textureAtlas": { + "title": "Texture Atlas", + "description": "Combine multiple sprites into optimized texture atlases. Perfect for game performance." + }, "aiTools": { "title": "More Tools", "description": "Additional utilities for game development. Coming soon." @@ -240,7 +246,11 @@ "outputCocosCreator": "Cocos Creator JSON", "outputGeneric": "Generic JSON", "algorithmMaxRects": "MaxRects (Best)", - "algorithmShelf": "Shelf (Fast)" + "algorithmShelf": "Shelf (Fast)", + "multiAtlas": "Multi-Atlas", + "multiAtlasDescription": "Auto pack overflow sprites into multiple atlases", + "compression": "PNG Compression", + "compressionDescription": "Compress PNG using quantization algorithm" } }, "tools": { @@ -355,7 +365,19 @@ "fps": "FPS", "spriteSize": "Sprite Size", "totalFrames": "Total Frames", - "duration": "Duration" + "duration": "Duration", + "advancedSettings": "Advanced Settings", + "multiAtlasHint": "When enabled, sprites that exceed the max size will be packed into multiple atlases", + "unpackedCount": "{{count}} sprites could not fit", + "unpackedSuggestion": "Try increasing max size or enable multi-atlas mode", + "unpackedWarning": "{{count}} sprites could not fit in the atlas", + "unpackedHint": "Try increasing max size or enable multi-atlas mode", + "atlasCount": "Atlas Count", + "atlasIndex": "Atlas {{current}} / {{total}}", + "currentAtlas": "Current Atlas #{{index}}", + "downloadAllAtlases": "Download All ({{count}} atlases)", + "compressionSettings": "Compression", + "compressionHint": "Enable PNG quantization to significantly reduce file size (similar to TinyPNG)" }, "footer": { "tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.", diff --git a/src/locales/zh.json b/src/locales/zh.json index 822bc1b..aa5ec82 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -31,7 +31,9 @@ "file": "文件", "files": "文件", "yes": "是", - "no": "否" + "no": "否", + "on": "开启", + "off": "关闭" }, "nav": { "tools": "工具", @@ -120,6 +122,10 @@ "title": "音频压缩", "description": "压缩并转换音频文件为多种格式。调整比特率和采样率。" }, + "textureAtlas": { + "title": "合图工具", + "description": "将多张精灵图合并为优化的纹理图集。提升游戏性能的利器。" + }, "aiTools": { "title": "更多工具", "description": "更多游戏开发实用工具,敬请期待。" @@ -240,7 +246,11 @@ "outputCocosCreator": "Cocos Creator JSON", "outputGeneric": "通用 JSON", "algorithmMaxRects": "MaxRects(最优)", - "algorithmShelf": "Shelf(快速)" + "algorithmShelf": "Shelf(快速)", + "multiAtlas": "多图打包", + "multiAtlasDescription": "超出尺寸的精灵自动打包到多张图片", + "compression": "PNG 压缩", + "compressionDescription": "使用量化算法压缩 PNG 图片" } }, "tools": { @@ -355,7 +365,19 @@ "fps": "帧率", "spriteSize": "精灵尺寸", "totalFrames": "总帧数", - "duration": "时长" + "duration": "时长", + "advancedSettings": "高级设置", + "multiAtlasHint": "开启后,超出单张合图尺寸的精灵将自动打包到多张图片中", + "unpackedCount": "有 {{count}} 张图片未能放入", + "unpackedSuggestion": "建议增大最大尺寸,或开启多图打包功能", + "unpackedWarning": "{{count}} 张精灵图未能放入合图", + "unpackedHint": "请增大最大尺寸,或开启多图打包功能", + "atlasCount": "合图数量", + "atlasIndex": "合图 {{current}} / {{total}}", + "currentAtlas": "当前合图 #{{index}}", + "downloadAllAtlases": "打包下载全部 ({{count}} 张)", + "compressionSettings": "压缩设置", + "compressionHint": "开启后使用 PNG 量化压缩,可大幅减小文件体积(类似 TinyPNG)" }, "footer": { "tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。", diff --git a/src/store/atlasStore.ts b/src/store/atlasStore.ts index b529af9..b775351 100644 --- a/src/store/atlasStore.ts +++ b/src/store/atlasStore.ts @@ -15,14 +15,41 @@ export interface BrowserSprite { } /** - * Complete atlas result + * Single atlas result */ -export interface AtlasResult { +export interface SingleAtlasResult { + index: number; width: number; height: number; placements: PackerPlacement[]; frames: AtlasFrame[]; imageDataUrl: string | null; + /** Sprite IDs in this atlas */ + spriteIds: string[]; +} + +/** + * Complete atlas result (supports multiple atlases) + */ +export interface AtlasResult { + /** All atlases */ + atlases: SingleAtlasResult[]; + /** Total number of packed sprites */ + packedCount: number; + /** Sprites that couldn't fit in any atlas (only when multi-atlas is disabled) */ + unpackedSpriteIds: string[]; +} + +/** + * Legacy single result format for backward compatibility + */ +export interface LegacyAtlasResult { + width: number; + height: number; + placements: PackerPlacement[]; + frames: AtlasFrame[]; + imageDataUrl: string | null; + unpackedSpriteIds: string[]; } /** @@ -41,14 +68,23 @@ interface AtlasState { // Configuration config: TextureAtlasConfig; + // Multi-atlas mode + enableMultiAtlas: boolean; + + // Compression mode (PNG quantization) + enableCompression: boolean; + // Processing state status: AtlasProcessStatus; progress: number; errorMessage: string | null; - // Result + // Result (supports multiple atlases) result: AtlasResult | null; + // Current preview atlas index + currentAtlasIndex: number; + // Preview state previewScale: number; previewOffset: { x: number; y: number }; @@ -67,11 +103,15 @@ interface AtlasState { updateConfig: (config: Partial) => void; resetConfig: () => void; + setEnableMultiAtlas: (enable: boolean) => void; + setEnableCompression: (enable: boolean) => void; + setStatus: (status: AtlasProcessStatus) => void; setProgress: (progress: number) => void; setError: (message: string | null) => void; setResult: (result: AtlasResult | null) => void; + setCurrentAtlasIndex: (index: number) => void; setPreviewScale: (scale: number) => void; setPreviewOffset: (offset: { x: number; y: number }) => void; @@ -81,6 +121,9 @@ interface AtlasState { openAnimationDialog: () => void; closeAnimationDialog: () => void; setAnimationFps: (fps: number) => void; + + // Computed helpers + getCurrentAtlas: () => SingleAtlasResult | null; } /** @@ -91,7 +134,7 @@ const defaultConfig: TextureAtlasConfig = { maxHeight: 1024, padding: 2, allowRotation: false, - pot: true, + pot: false, format: "png", quality: 90, outputFormat: "cocos2d", @@ -106,10 +149,13 @@ export const useAtlasStore = create((set, get) => ({ sprites: [], folderName: "", config: { ...defaultConfig }, + enableMultiAtlas: false, + enableCompression: false, status: "idle", progress: 0, errorMessage: null, result: null, + currentAtlasIndex: 0, previewScale: 1, previewOffset: { x: 0, y: 0 }, selectedSpriteIds: [], @@ -128,7 +174,7 @@ export const useAtlasStore = create((set, get) => ({ a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) ); - return { sprites: allSprites, result: null }; + return { sprites: allSprites, result: null, currentAtlasIndex: 0 }; }); }, @@ -137,6 +183,7 @@ export const useAtlasStore = create((set, get) => ({ sprites: state.sprites.filter((s) => s.id !== id), selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id), result: null, + currentAtlasIndex: 0, })); }, @@ -149,6 +196,7 @@ export const useAtlasStore = create((set, get) => ({ sprites: [], folderName: "", result: null, + currentAtlasIndex: 0, selectedSpriteIds: [], status: "idle", progress: 0, @@ -163,10 +211,17 @@ export const useAtlasStore = create((set, get) => ({ set((state) => ({ config: { ...state.config, ...partialConfig }, result: null, // Clear result when config changes + currentAtlasIndex: 0, })); }, - resetConfig: () => set({ config: { ...defaultConfig }, result: null }), + resetConfig: () => set({ config: { ...defaultConfig }, result: null, currentAtlasIndex: 0 }), + + // Multi-atlas mode + setEnableMultiAtlas: (enable) => set({ enableMultiAtlas: enable, result: null, currentAtlasIndex: 0 }), + + // Compression mode (only affects download, not preview) + setEnableCompression: (enable) => set({ enableCompression: enable }), // Status actions setStatus: (status) => set({ status }), @@ -174,7 +229,14 @@ export const useAtlasStore = create((set, get) => ({ setError: (message) => set({ errorMessage: message, status: message ? "error" : "idle" }), // Result actions - setResult: (result) => set({ result, status: result ? "completed" : "idle" }), + setResult: (result) => set({ result, status: result ? "completed" : "idle", currentAtlasIndex: 0 }), + + setCurrentAtlasIndex: (index) => { + const { result } = get(); + if (result && index >= 0 && index < result.atlases.length) { + set({ currentAtlasIndex: index, previewOffset: { x: 0, y: 0 } }); + } + }, // Preview actions setPreviewScale: (scale) => set({ previewScale: Math.max(0.1, Math.min(4, scale)) }), @@ -191,7 +253,7 @@ export const useAtlasStore = create((set, get) => ({ : [...state.selectedSpriteIds, id], }; } - return { selectedSpriteIds: [id] }; + return { selectedSpriteIds: id ? [id] : [] }; }); }, @@ -201,6 +263,13 @@ export const useAtlasStore = create((set, get) => ({ openAnimationDialog: () => set({ isAnimationDialogOpen: true }), closeAnimationDialog: () => set({ isAnimationDialogOpen: false }), setAnimationFps: (fps) => set({ animationFps: Math.max(1, Math.min(60, fps)) }), + + // Computed helpers + getCurrentAtlas: () => { + const { result, currentAtlasIndex } = get(); + if (!result || result.atlases.length === 0) return null; + return result.atlases[currentAtlasIndex] || null; + }, })); /** @@ -218,3 +287,7 @@ export const useAtlasPreview = () => useAtlasStore((state) => ({ scale: state.previewScale, offset: state.previewOffset, })); +export const useCurrentAtlas = () => useAtlasStore((state) => { + if (!state.result || state.result.atlases.length === 0) return null; + return state.result.atlases[state.currentAtlasIndex] || null; +}); diff --git a/src/types/upng-js.d.ts b/src/types/upng-js.d.ts new file mode 100644 index 0000000..8eb6a1c --- /dev/null +++ b/src/types/upng-js.d.ts @@ -0,0 +1,54 @@ +declare module "upng-js" { + /** + * Encode RGBA image data to PNG + * @param imgs Array of ArrayBuffer containing RGBA data + * @param w Width of the image + * @param h Height of the image + * @param cnum Number of colors (0 = lossless, 256 = 8-bit quantization) + * @param dels Optional delays for APNG frames + * @returns ArrayBuffer containing PNG data + */ + export function encode( + imgs: ArrayBuffer[], + w: number, + h: number, + cnum: number, + dels?: number[] + ): ArrayBuffer; + + /** + * Decode PNG to RGBA image data + * @param buffer PNG data + * @returns Decoded image info + */ + export function decode(buffer: ArrayBuffer): { + width: number; + height: number; + depth: number; + ctype: number; + frames: Array<{ + rect: { x: number; y: number; width: number; height: number }; + delay: number; + dispose: number; + blend: number; + }>; + tabs: Record; + data: Uint8Array; + }; + + /** + * Convert decoded PNG to RGBA format + * @param img Decoded image from decode() + * @param frameIndex Frame index for APNG (default 0) + * @returns Uint8Array of RGBA data + */ + export function toRGBA8(img: ReturnType, frameIndex?: number): Uint8Array[]; + + const UPNG: { + encode: typeof encode; + decode: typeof decode; + toRGBA8: typeof toRGBA8; + }; + + export default UPNG; +}