mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-03 04:20:32 +00:00
241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
/**
|
|
* Inspect nodes in GLB files, showing names, translations, rotations,
|
|
* and hierarchy. Useful for finding eye nodes, mount points, etc.
|
|
*
|
|
* Usage: npx tsx scripts/inspect-glb-nodes.ts <glb-file> [glb-file...]
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
interface GltfNode {
|
|
name?: string;
|
|
children?: number[];
|
|
translation?: [number, number, number];
|
|
rotation?: [number, number, number, number];
|
|
scale?: [number, number, number];
|
|
mesh?: number;
|
|
skin?: number;
|
|
camera?: number;
|
|
matrix?: number[];
|
|
extras?: Record<string, unknown>;
|
|
}
|
|
|
|
interface GltfSkin {
|
|
name?: string;
|
|
joints: number[];
|
|
skeleton?: number;
|
|
inverseBindMatrices?: number;
|
|
}
|
|
|
|
interface GltfScene {
|
|
name?: string;
|
|
nodes?: number[];
|
|
}
|
|
|
|
interface GltfDocument {
|
|
scenes?: GltfScene[];
|
|
scene?: number;
|
|
nodes?: GltfNode[];
|
|
skins?: GltfSkin[];
|
|
meshes?: { name?: string }[];
|
|
animations?: { name?: string; channels?: unknown[] }[];
|
|
}
|
|
|
|
function parseGlb(buffer: Buffer): GltfDocument {
|
|
// GLB header: magic(4) + version(4) + length(4)
|
|
const magic = buffer.readUInt32LE(0);
|
|
if (magic !== 0x46546c67) {
|
|
throw new Error("Not a valid GLB file");
|
|
}
|
|
// First chunk should be JSON
|
|
const chunk0Length = buffer.readUInt32LE(12);
|
|
const chunk0Type = buffer.readUInt32LE(16);
|
|
if (chunk0Type !== 0x4e4f534a) {
|
|
throw new Error("Expected JSON chunk");
|
|
}
|
|
|
|
const jsonStr = buffer.toString("utf-8", 20, 20 + chunk0Length);
|
|
return JSON.parse(jsonStr);
|
|
}
|
|
|
|
function formatVec3(v: [number, number, number] | undefined): string {
|
|
if (!v) return "(0, 0, 0)";
|
|
return `(${v.map((n) => n.toFixed(4)).join(", ")})`;
|
|
}
|
|
|
|
function formatQuat(q: [number, number, number, number] | undefined): string {
|
|
if (!q) return "(0, 0, 0, 1)";
|
|
return `(${q.map((n) => n.toFixed(4)).join(", ")})`;
|
|
}
|
|
|
|
function printNodeTree(
|
|
doc: GltfDocument,
|
|
nodeIndex: number,
|
|
depth: number,
|
|
visited: Set<number>
|
|
): void {
|
|
if (visited.has(nodeIndex)) return;
|
|
visited.add(nodeIndex);
|
|
|
|
const node = doc.nodes![nodeIndex];
|
|
const indent = " ".repeat(depth);
|
|
const name = node.name || `(unnamed #${nodeIndex})`;
|
|
|
|
let extras = "";
|
|
if (node.mesh != null) {
|
|
const meshName = doc.meshes?.[node.mesh]?.name;
|
|
extras += ` [mesh: ${meshName ?? node.mesh}]`;
|
|
}
|
|
if (node.skin != null) extras += ` [skin: ${node.skin}]`;
|
|
if (node.camera != null) extras += ` [camera: ${node.camera}]`;
|
|
|
|
console.log(`${indent}[${nodeIndex}] ${name}${extras}`);
|
|
console.log(`${indent} T: ${formatVec3(node.translation)}`);
|
|
|
|
// Only show rotation if non-identity
|
|
const r = node.rotation;
|
|
if (r && (r[0] !== 0 || r[1] !== 0 || r[2] !== 0 || r[3] !== 1)) {
|
|
console.log(`${indent} R: ${formatQuat(node.rotation)}`);
|
|
}
|
|
|
|
// Only show scale if non-identity
|
|
const s = node.scale;
|
|
if (s && (s[0] !== 1 || s[1] !== 1 || s[2] !== 1)) {
|
|
console.log(`${indent} S: ${formatVec3(node.scale as [number, number, number])}`);
|
|
}
|
|
|
|
// Show matrix if present
|
|
if (node.matrix) {
|
|
console.log(`${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`);
|
|
}
|
|
|
|
if (node.children) {
|
|
for (const childIdx of node.children) {
|
|
printNodeTree(doc, childIdx, depth + 1, visited);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function inspectGlb(filePath: string): Promise<void> {
|
|
const absPath = path.resolve(filePath);
|
|
console.log(`\n${"=".repeat(80)}`);
|
|
console.log(`FILE: ${absPath}`);
|
|
console.log(`${"=".repeat(80)}\n`);
|
|
|
|
const buffer = await fs.readFile(absPath);
|
|
const doc = parseGlb(buffer);
|
|
|
|
const nodeCount = doc.nodes?.length ?? 0;
|
|
const meshCount = doc.meshes?.length ?? 0;
|
|
const skinCount = doc.skins?.length ?? 0;
|
|
const animCount = doc.animations?.length ?? 0;
|
|
|
|
console.log(`Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`);
|
|
|
|
// Show skins (skeletons)
|
|
if (doc.skins && doc.skins.length > 0) {
|
|
console.log(`\n--- Skins ---`);
|
|
for (let i = 0; i < doc.skins.length; i++) {
|
|
const skin = doc.skins[i];
|
|
console.log(`Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`);
|
|
console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
|
|
console.log(
|
|
` Joint node indices: [${skin.joints.join(", ")}]`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Show animations
|
|
if (doc.animations && doc.animations.length > 0) {
|
|
console.log(`\n--- Animations ---`);
|
|
for (let i = 0; i < doc.animations.length; i++) {
|
|
const anim = doc.animations[i];
|
|
console.log(` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`);
|
|
}
|
|
}
|
|
|
|
// Print full node tree
|
|
console.log(`\n--- Node Tree ---`);
|
|
const visited = new Set<number>();
|
|
|
|
// Start from scene root nodes
|
|
const sceneIdx = doc.scene ?? 0;
|
|
const scene = doc.scenes?.[sceneIdx];
|
|
if (scene?.nodes) {
|
|
for (const rootIdx of scene.nodes) {
|
|
printNodeTree(doc, rootIdx, 0, visited);
|
|
}
|
|
}
|
|
|
|
// Print any orphan nodes not reached from scene
|
|
if (doc.nodes) {
|
|
for (let i = 0; i < doc.nodes.length; i++) {
|
|
if (!visited.has(i)) {
|
|
console.log(`\n (orphan node not in scene tree:)`);
|
|
printNodeTree(doc, i, 1, visited);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Highlight interesting nodes
|
|
const keywords = ["eye", "mount", "hand", "cam", "head", "weapon", "muzzle", "node", "jet", "contrail"];
|
|
const interesting: { index: number; name: string; node: GltfNode }[] = [];
|
|
if (doc.nodes) {
|
|
for (let i = 0; i < doc.nodes.length; i++) {
|
|
const node = doc.nodes[i];
|
|
const name = (node.name || "").toLowerCase();
|
|
if (keywords.some((kw) => name.includes(kw))) {
|
|
interesting.push({ index: i, name: node.name || "", node });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (interesting.length > 0) {
|
|
console.log(`\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`);
|
|
for (const { index, name, node } of interesting) {
|
|
console.log(` [${index}] "${name}"`);
|
|
console.log(` Translation: ${formatVec3(node.translation)}`);
|
|
if (node.rotation) {
|
|
console.log(` Rotation: ${formatQuat(node.rotation)}`);
|
|
}
|
|
if (node.scale) {
|
|
console.log(` Scale: ${formatVec3(node.scale as [number, number, number])}`);
|
|
}
|
|
// Find parent
|
|
if (doc.nodes) {
|
|
for (let j = 0; j < doc.nodes.length; j++) {
|
|
if (doc.nodes[j].children?.includes(index)) {
|
|
console.log(` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also show a flat list of ALL node names for easy scanning
|
|
console.log(`\n--- All Node Names (flat list) ---`);
|
|
if (doc.nodes) {
|
|
for (let i = 0; i < doc.nodes.length; i++) {
|
|
const node = doc.nodes[i];
|
|
console.log(` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main
|
|
const files = process.argv.slice(2);
|
|
if (files.length === 0) {
|
|
// Default files to inspect
|
|
const defaultFiles = [
|
|
"docs/base/@vl2/shapes.vl2/shapes/light_male.glb",
|
|
"docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb",
|
|
];
|
|
for (const f of defaultFiles) {
|
|
await inspectGlb(f);
|
|
}
|
|
} else {
|
|
for (const f of files) {
|
|
await inspectGlb(f);
|
|
}
|
|
}
|