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