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
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}`);
|
|
}
|
|
}
|
|
}
|