UI and application enhancements

This commit is contained in:
2025-08-09 15:46:41 +02:00
parent 6953a41ca4
commit f8d9d2ae83
7 changed files with 371 additions and 89 deletions

View File

@@ -1,5 +1,5 @@
<template>
<Modal :is-open="isOpen" @close="cancel" title="Split Spritesheet">
<Modal :is-open="isOpen" @close="cancel" title="Split spritesheet">
<div class="space-y-6">
<div class="flex flex-col space-y-4">
<div class="flex items-center justify-center mb-4">
@@ -85,7 +85,7 @@
:disabled="previewSprites.length === 0 || isProcessing"
data-rybbit-event="spritesheet-split"
>
Split Spritesheet
Split spritesheet
</button>
</div>
</div>
@@ -224,19 +224,26 @@
return null;
}
// Return bounding box with a small padding
// Return bounding box with a small padding, ensuring it stays within bounds
const bx = Math.max(0, minX - 1);
const by = Math.max(0, minY - 1);
const bw = Math.min(width - bx, maxX - minX + 3); // +1 for inclusive bounds, +2 for padding
const bh = Math.min(height - by, maxY - minY + 3);
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),
x: bx,
y: by,
width: Math.max(1, bw),
height: Math.max(1, bh),
};
}
// Split spritesheet manually based on rows and columns
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
const spriteWidth = Math.floor(img.width / columns);
const spriteHeight = Math.floor(img.height / rows);
const safeColumns = Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 1;
const safeRows = Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1;
const spriteWidth = Math.max(1, Math.floor(img.width / safeColumns));
const spriteHeight = Math.max(1, Math.floor(img.height / safeRows));
const sprites: SpritePreview[] = [];
@@ -254,8 +261,8 @@
canvas.height = spriteHeight;
// Split the image into individual sprites
for (let row = 0; row < rows; row++) {
for (let col = 0; col < columns; col++) {
for (let row = 0; row < safeRows; row++) {
for (let col = 0; col < safeColumns; col++) {
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
@@ -339,35 +346,233 @@
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
if (detectedWidth > 0 && detectedHeight > 0) {
const detectedRows = Math.floor(img.height / detectedHeight);
const detectedColumns = Math.floor(img.width / detectedWidth);
// Sanity thresholds to avoid absurdly tiny tiles/huge counts
const MIN_TILE = 8;
const MAX_SPRITES = 1024;
if (detectedWidth < MIN_TILE || detectedHeight < MIN_TILE) {
// Fallback if tile is too small
const s = Math.max(1, Math.min(100, sensitivity.value));
const divisor = 3 + Math.round(s / 20); // 3..8
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
await splitSpritesheet(img, estimatedRows, estimatedColumns);
return;
}
const detectedRows = Math.max(1, Math.floor(img.height / detectedHeight));
const detectedColumns = Math.max(1, Math.floor(img.width / detectedWidth));
// If the detected combination is unreasonably high, fallback to estimate
if (detectedRows * detectedColumns > MAX_SPRITES) {
const s = Math.max(1, Math.min(100, sensitivity.value));
const divisor = 3 + Math.round(s / 20); // 3..8
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
await splitSpritesheet(img, estimatedRows, estimatedColumns);
return;
}
// Use the detected size to split the spritesheet
await splitSpritesheet(img, detectedRows, detectedColumns);
} else {
// Fallback to manual splitting with a reasonable guess
const estimatedSize = Math.max(16, Math.floor(Math.min(img.width, img.height) / 8));
const estimatedRows = Math.floor(img.height / estimatedSize);
const estimatedColumns = Math.floor(img.width / estimatedSize);
// Fallback to manual splitting with a reasonable guess based on sensitivity
const s = Math.max(1, Math.min(100, sensitivity.value));
const divisor = 3 + Math.round(s / 20); // 3..8
const estimatedSize = Math.max(8, Math.floor(Math.min(img.width, img.height) / divisor));
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
await splitSpritesheet(img, estimatedRows, estimatedColumns);
}
}
// Helper function to detect sprite size based on transparency patterns
// Helper function to detect sprite size based on transparency/color gutters and edge periodicity
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
// This is a simplified implementation
// A real implementation would be more sophisticated
// Map sensitivity (1-100) to thresholds:
// Higher sensitivity -> allows stricter background matching and lower gutter proportion thresholds
const s = Math.max(1, Math.min(100, sensitivity.value));
// The sensitivity affects how aggressive we are in detecting patterns
const threshold = 100 - sensitivity.value; // Lower threshold = more sensitive
// Background/color thresholds
const alphaBgThresh = Math.round(255 * (0.15 + (100 - s) * 0.001)); // 15%-25% depending on sensitivity
const colorTol = Math.round(10 + (100 - s) * 0.8); // 10..90 Euclidean approx
const gutterPropThresh = 0.92 - s * 0.004; // 0.92 down to ~0.52
// For now, return a simple estimate based on image size
// In a real implementation, we would analyze the image data to find patterns
return {
detectedWidth: 0, // Return 0 to fall back to the manual method
detectedHeight: 0,
};
function getPixel(x: number, y: number) {
const idx = (y * width + x) * 4;
return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]] as [number, number, number, number];
}
// Estimate background color from corners (median of corners)
const corners: [number, number, number, number][] = [getPixel(0, 0), getPixel(width - 1, 0), getPixel(0, height - 1), getPixel(width - 1, height - 1)];
function median(arr: number[]) {
const a = arr.slice().sort((a, b) => a - b);
const mid = (a.length - 1) / 2;
return (a[Math.floor(mid)] + a[Math.ceil(mid)]) / 2;
}
const bg = [median(corners.map(c => c[0])), median(corners.map(c => c[1])), median(corners.map(c => c[2])), median(corners.map(c => c[3]))] as [number, number, number, number];
function isBg(r: number, g: number, b: number, a: number) {
if (a <= alphaBgThresh) return true;
const dr = r - bg[0];
const dg = g - bg[1];
const db = b - bg[2];
// Use Manhattan distance approximation for speed
const manhattan = Math.abs(dr) + Math.abs(dg) + Math.abs(db);
// Normalize approx to 0..~765 and compare to scaled tolerance
return manhattan <= colorTol * 3;
}
// Sample stride to speed up scanning large sheets
const rowSample = Math.max(1, Math.floor(height / 64));
const colSample = Math.max(1, Math.floor(width / 64));
// Compute background proportion per column and row
const colBgProp: number[] = new Array(width).fill(0);
for (let x = 0; x < width; x++) {
let bgCount = 0;
let total = 0;
for (let y = 0; y < height; y += rowSample) {
const [r, g, b, a] = getPixel(x, y);
if (isBg(r, g, b, a)) bgCount++;
total++;
}
colBgProp[x] = total > 0 ? bgCount / total : 1;
}
const rowBgProp: number[] = new Array(height).fill(0);
for (let y = 0; y < height; y++) {
let bgCount = 0;
let total = 0;
for (let x = 0; x < width; x += colSample) {
const [r, g, b, a] = getPixel(x, y);
if (isBg(r, g, b, a)) bgCount++;
total++;
}
rowBgProp[y] = total > 0 ? bgCount / total : 1;
}
function extractRuns(bgProp: number[]): { emptyRuns: [number, number][]; segSizes: number[] } {
const emptyRuns: [number, number][] = [];
const segSizes: number[] = [];
let inEmpty = false;
let runStart = 0;
let lastSeparatorEnd = -1;
for (let i = 0; i < bgProp.length; i++) {
const isEmpty = bgProp[i] >= gutterPropThresh;
if (isEmpty && !inEmpty) {
inEmpty = true;
runStart = i;
if (lastSeparatorEnd >= 0) {
const seg = runStart - lastSeparatorEnd - 1;
if (seg > 0) segSizes.push(seg);
}
} else if (!isEmpty && inEmpty) {
inEmpty = false;
emptyRuns.push([runStart, i - 1]);
lastSeparatorEnd = i - 1;
}
}
if (inEmpty) {
emptyRuns.push([runStart, bgProp.length - 1]);
lastSeparatorEnd = bgProp.length - 1;
}
// Trailing segment after last empty run
if (lastSeparatorEnd >= 0 && lastSeparatorEnd < bgProp.length - 1) {
const seg = bgProp.length - 1 - lastSeparatorEnd;
if (seg > 0) segSizes.push(seg);
}
return { emptyRuns, segSizes };
}
function modeWithTolerance(values: number[], tol = 2): number {
if (values.length === 0) return 0;
values.sort((a, b) => a - b);
let bestCount = 0;
let bestVal = values[0];
for (let i = 0; i < values.length; i++) {
const base = values[i];
let count = 1;
for (let j = i + 1; j < values.length; j++) {
if (Math.abs(values[j] - base) <= tol) count++;
else break;
}
if (count > bestCount) {
bestCount = count;
bestVal = base;
}
}
return bestVal;
}
const colRuns = extractRuns(colBgProp);
const rowRuns = extractRuns(rowBgProp);
let detectedWidth = modeWithTolerance(colRuns.segSizes, 2);
let detectedHeight = modeWithTolerance(rowRuns.segSizes, 2);
// Fallback: use edge periodicity via autocorrelation if gutters not found
function edgeAutocorrLength(axis: 'x' | 'y'): number {
const maxLen = axis === 'x' ? width : height;
const otherLen = axis === 'x' ? height : width;
const sampleStepMajor = Math.max(1, Math.floor(maxLen / 512));
const sampleStepMinor = Math.max(1, Math.floor(otherLen / 64));
const energy: number[] = new Array(maxLen).fill(0);
if (axis === 'x') {
for (let x = 0; x < maxLen - 1; x += sampleStepMajor) {
let e = 0;
for (let y = 0; y < otherLen; y += sampleStepMinor) {
const [r1, g1, b1, a1] = getPixel(x, y);
const [r2, g2, b2, a2] = getPixel(x + 1, y);
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
}
energy[x] = e;
}
} else {
for (let y = 0; y < maxLen - 1; y += sampleStepMajor) {
let e = 0;
for (let x = 0; x < otherLen; x += sampleStepMinor) {
const [r1, g1, b1, a1] = getPixel(x, y);
const [r2, g2, b2, a2] = getPixel(x, y + 1);
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
}
energy[y] = e;
}
}
const minTile = Math.max(3, Math.floor(Math.min(maxLen / 32, 128)));
const maxTile = Math.max(minTile + 1, Math.floor(Math.min(maxLen / 2, 512)));
let bestLag = 0;
let bestVal = -Infinity;
for (let lag = minTile; lag <= maxTile; lag++) {
let sum = 0;
for (let i = 0; i + lag < energy.length; i++) {
const e1 = energy[i] || 0;
const e2 = energy[i + lag] || 0;
sum += e1 * e2;
}
if (sum > bestVal) {
bestVal = sum;
bestLag = lag;
}
}
return bestLag;
}
if (detectedWidth <= 0 || detectedWidth > width) {
const lagX = edgeAutocorrLength('x');
if (lagX > 0 && lagX <= width) detectedWidth = lagX;
}
if (detectedHeight <= 0 || detectedHeight > height) {
const lagY = edgeAutocorrLength('y');
if (lagY > 0 && lagY <= height) detectedHeight = lagY;
}
// Sanity checks
if (!Number.isFinite(detectedWidth) || detectedWidth <= 0 || detectedWidth > width) detectedWidth = 0;
if (!Number.isFinite(detectedHeight) || detectedHeight <= 0 || detectedHeight > height) detectedHeight = 0;
return { detectedWidth, detectedHeight };
}
// Check if a canvas is empty (all transparent or same color)