You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

301 lines
11 KiB
JavaScript

// One-off analyzer: scan every .bbmodel in a directory and report UV-layout
// statistics that characterize the project's sorting/packing conventions.
//
// Usage:
// node analyze_uvs.mjs <path/to/bbmodel/directory>
// MODELS_DIR=path/to/dir node analyze_uvs.mjs
import fs from 'node:fs';
import path from 'node:path';
const MODELS_DIR = path.resolve(
process.argv[2] || process.env.MODELS_DIR || 'bbmodel'
);
if (!fs.existsSync(MODELS_DIR)) {
console.error(`Models dir not found: ${MODELS_DIR}`);
console.error('Pass it as an argument or set MODELS_DIR.');
process.exit(1);
}
const FACE_NAMES = ['north', 'east', 'south', 'west', 'up', 'down'];
function rectOf(uv) {
const [x1, y1, x2, y2] = uv;
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
flipX: x2 < x1,
flipY: y2 < y1,
};
}
function rectArea(r) {
return r.w * r.h;
}
function bboxOf(rects) {
if (!rects.length) return { x: 0, y: 0, w: 0, h: 0 };
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const r of rects) {
if (r.x < x0) x0 = r.x;
if (r.y < y0) y0 = r.y;
if (r.x + r.w > x1) x1 = r.x + r.w;
if (r.y + r.h > y1) y1 = r.y + r.h;
}
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
// Walk outliner tree to record group-membership for each cube uuid
function buildCubeGroupMap(outlinerNodes, parentName, out, depth = 0) {
for (const node of outlinerNodes) {
if (typeof node === 'string') {
// leaf: cube uuid
out.set(node, parentName || '__root__');
} else if (node && Array.isArray(node.children)) {
const groupName = node.name || `group@${depth}`;
buildCubeGroupMap(node.children, groupName, out, depth + 1);
}
}
}
function rectsOverlap(a, b) {
return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
}
function rectsIdentical(a, b) {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
function analyzeModel(filePath) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (data.meta?.box_uv === true) return { skipped: 'box_uv' };
const uvW = data.resolution?.width ?? 16;
const uvH = data.resolution?.height ?? 16;
const elements = data.elements || [];
const cubeGroup = new Map();
buildCubeGroupMap(data.outliner || [], null, cubeGroup);
// Per-cube faces
const allRects = [];
const groupRects = new Map(); // groupName -> [rects]
for (const el of elements) {
if (el.type && el.type !== 'cube') continue;
if (!el.faces) continue;
const groupName = cubeGroup.get(el.uuid) || '__root__';
if (!groupRects.has(groupName)) groupRects.set(groupName, []);
for (const fname of FACE_NAMES) {
const face = el.faces[fname];
if (!face || !face.uv) continue;
const r = rectOf(face.uv);
if (r.w === 0 || r.h === 0) continue;
allRects.push({ ...r, cube: el.name, group: groupName, face: fname });
groupRects.get(groupName).push({ ...r, cube: el.name, face: fname });
}
}
if (!allRects.length) return { skipped: 'no_faces' };
// Global bbox
const globalBbox = bboxOf(allRects);
const usedArea = allRects.reduce((s, r) => s + rectArea(r), 0);
const bboxArea = globalBbox.w * globalBbox.h;
const canvasArea = uvW * uvH;
// Identical-rect dedupe — count how many distinct rect positions+sizes
const uniqRects = new Set(allRects.map(r => `${r.x},${r.y},${r.w},${r.h}`));
const sharedRectFraction = 1 - uniqRects.size / allRects.length;
// Flip usage
const flippedFraction =
allRects.filter(r => r.flipX || r.flipY).length / allRects.length;
// Group clustering: for each group, how tight is its bbox vs. group total area?
const groupStats = [];
for (const [name, rects] of groupRects.entries()) {
if (!rects.length) continue;
const bb = bboxOf(rects);
const a = rects.reduce((s, r) => s + rectArea(r), 0);
const bbA = bb.w * bb.h;
groupStats.push({
name,
faces: rects.length,
totalArea: a,
bboxW: bb.w,
bboxH: bb.h,
bboxArea: bbA,
density: bbA > 0 ? a / bbA : 0,
});
}
// Group separation score: do groups overlap each other's bboxes?
let groupBboxOverlapPairs = 0;
for (let i = 0; i < groupStats.length; i++) {
for (let j = i + 1; j < groupStats.length; j++) {
const a = { x: 0, y: 0, w: 0, h: 0 }; // recompute
}
}
// Better: actually compute group bboxes once and check overlap
const gBboxes = [];
for (const [name, rects] of groupRects.entries()) {
if (!rects.length) continue;
gBboxes.push({ name, ...bboxOf(rects) });
}
for (let i = 0; i < gBboxes.length; i++) {
for (let j = i + 1; j < gBboxes.length; j++) {
if (rectsOverlap(gBboxes[i], gBboxes[j])) groupBboxOverlapPairs++;
}
}
const totalGroupPairs = (gBboxes.length * (gBboxes.length - 1)) / 2;
const groupOverlapRatio = totalGroupPairs ? groupBboxOverlapPairs / totalGroupPairs : 0;
// Sort within groups: do faces appear biggest-first in the outliner / file order?
// Approximate: take group rects in file order, see how often successor area <= prev area.
let monotoneDecreaseHits = 0, monotoneDecreaseTotal = 0;
for (const rects of groupRects.values()) {
for (let i = 1; i < rects.length; i++) {
monotoneDecreaseTotal++;
if (rectArea(rects[i]) <= rectArea(rects[i - 1])) monotoneDecreaseHits++;
}
}
const biggestFirstScore = monotoneDecreaseTotal
? monotoneDecreaseHits / monotoneDecreaseTotal
: 0;
// Padding: nearest-neighbor gap between non-overlapping rects
// For each rect, look at its right and bottom neighbors and measure gap
// Approximate by sampling: spend min gap across all neighbors
const sortedByX = [...allRects].sort((a, b) => a.y - b.y || a.x - b.x);
let zeroGapCount = 0, smallGapCount = 0, totalGapsMeasured = 0;
for (let i = 0; i < sortedByX.length; i++) {
for (let j = i + 1; j < sortedByX.length; j++) {
const a = sortedByX[i], b = sortedByX[j];
if (b.y > a.y + a.h + 4) break;
// Right-edge to left-edge gap when y-overlap exists
const yOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
if (yOverlap > 0) {
const gap = b.x - (a.x + a.w);
if (gap >= 0 && gap <= 4) {
totalGapsMeasured++;
if (gap === 0) zeroGapCount++;
else if (gap <= 1) smallGapCount++;
}
}
}
}
const zeroPaddingFraction = totalGapsMeasured ? zeroGapCount / totalGapsMeasured : 0;
return {
name: path.basename(filePath, '.bbmodel'),
canvas: { uvW, uvH },
cubes: elements.length,
groups: gBboxes.length,
faces: allRects.length,
uniqueRects: uniqRects.size,
sharedRectFraction: round(sharedRectFraction, 3),
flippedFraction: round(flippedFraction, 3),
globalBbox,
bboxFillOfCanvas: round(bboxArea / canvasArea, 3),
densityInBbox: round(usedArea / Math.max(bboxArea, 1), 3),
groupStats: groupStats.map(g => ({ ...g, density: round(g.density, 3) })),
groupOverlapRatio: round(groupOverlapRatio, 3),
biggestFirstScore: round(biggestFirstScore, 3),
zeroPaddingFraction: round(zeroPaddingFraction, 3),
paddedSamples: totalGapsMeasured,
};
}
function round(n, d) {
return Math.round(n * 10 ** d) / 10 ** d;
}
const files = fs
.readdirSync(MODELS_DIR)
.filter(f => f.endsWith('.bbmodel'))
.map(f => path.join(MODELS_DIR, f));
const results = [];
const skipped = { box_uv: 0, no_faces: 0, error: 0 };
for (const f of files) {
try {
const r = analyzeModel(f);
if (r.skipped) {
skipped[r.skipped]++;
continue;
}
results.push(r);
} catch (e) {
skipped.error++;
}
}
// Aggregate
function avg(xs) {
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
}
function median(xs) {
if (!xs.length) return 0;
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
function pctile(xs, p) {
if (!xs.length) return 0;
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor((s.length - 1) * p)];
}
const summary = {
totalAnalyzed: results.length,
skipped,
avgCubes: round(avg(results.map(r => r.cubes)), 1),
avgGroups: round(avg(results.map(r => r.groups)), 1),
avgFaces: round(avg(results.map(r => r.faces)), 1),
medianBboxFillOfCanvas: round(median(results.map(r => r.bboxFillOfCanvas)), 3),
medianDensityInBbox: round(median(results.map(r => r.densityInBbox)), 3),
medianSharedRectFraction: round(median(results.map(r => r.sharedRectFraction)), 3),
medianFlippedFraction: round(median(results.map(r => r.flippedFraction)), 3),
medianGroupOverlapRatio: round(median(results.map(r => r.groupOverlapRatio)), 3),
medianBiggestFirstScore: round(median(results.map(r => r.biggestFirstScore)), 3),
medianZeroPadFraction: round(median(results.map(r => r.zeroPaddingFraction)), 3),
// Distribution-of-density
densityP10: round(pctile(results.map(r => r.densityInBbox), 0.1), 3),
densityP50: round(pctile(results.map(r => r.densityInBbox), 0.5), 3),
densityP90: round(pctile(results.map(r => r.densityInBbox), 0.9), 3),
groupOverlapP10: round(pctile(results.map(r => r.groupOverlapRatio), 0.1), 3),
groupOverlapP50: round(pctile(results.map(r => r.groupOverlapRatio), 0.5), 3),
groupOverlapP90: round(pctile(results.map(r => r.groupOverlapRatio), 0.9), 3),
biggestFirstP10: round(pctile(results.map(r => r.biggestFirstScore), 0.1), 3),
biggestFirstP50: round(pctile(results.map(r => r.biggestFirstScore), 0.5), 3),
biggestFirstP90: round(pctile(results.map(r => r.biggestFirstScore), 0.9), 3),
};
console.log('=== AGGREGATE ===');
console.log(JSON.stringify(summary, null, 2));
// Show 10 sample model breakdowns of varying complexity
const samples = [...results].sort((a, b) => a.faces - b.faces);
const picks = [
samples[Math.floor(samples.length * 0.1)],
samples[Math.floor(samples.length * 0.3)],
samples[Math.floor(samples.length * 0.5)],
samples[Math.floor(samples.length * 0.7)],
samples[Math.floor(samples.length * 0.9)],
].filter(Boolean);
console.log('\n=== SAMPLE BREAKDOWNS (low → high complexity) ===');
for (const r of picks) {
console.log(`\n--- ${r.name} ---`);
console.log(` canvas: ${r.canvas.uvW}x${r.canvas.uvH}, cubes: ${r.cubes}, groups: ${r.groups}, faces: ${r.faces}`);
console.log(` global UV bbox: ${r.globalBbox.w}x${r.globalBbox.h} @ (${r.globalBbox.x},${r.globalBbox.y}) fill=${r.bboxFillOfCanvas} density=${r.densityInBbox}`);
console.log(` shared-rect fraction: ${r.sharedRectFraction}, flipped: ${r.flippedFraction}, zero-padding gaps: ${r.zeroPaddingFraction} (n=${r.paddedSamples})`);
console.log(` group-bbox overlap ratio: ${r.groupOverlapRatio} biggest-first score: ${r.biggestFirstScore}`);
if (r.groupStats.length <= 8) {
for (const g of r.groupStats) {
console.log(` group "${g.name}": ${g.faces} faces, area=${g.totalArea}, bbox=${g.bboxW}x${g.bboxH}, density=${g.density}`);
}
}
}