initial demo support

This commit is contained in:
Brian Beck 2026-02-28 17:58:09 -08:00
parent 0f2e103294
commit 359a036558
406 changed files with 10513 additions and 1158 deletions

View file

@ -65,6 +65,7 @@ print(f"[dif2gltf] Processing {len(input_files)} file(s)...")
total = len(input_files)
success_count = 0
failure_count = 0
failed_paths = []
for i, in_path in enumerate(input_files, start=1):
# Derive output path: same location, same name, but .glb/.gltf extension
ext = ".gltf" if args.format == "GLTF_SEPARATE" else ".glb"
@ -88,6 +89,7 @@ for i, in_path in enumerate(input_files, start=1):
raise RuntimeError(f"Import failed via {op_id}")
except Exception:
failure_count += 1
failed_paths.append(in_path)
print(f"\n{RED}[dif2gltf] [{i}/{total}] FAIL:{RESET} {in_path}")
continue
@ -111,6 +113,7 @@ for i, in_path in enumerate(input_files, start=1):
)
if "FINISHED" not in res:
failure_count += 1
failed_paths.append(in_path)
print(f"\n{RED}[dif2gltf] [{i}/{total}] FAIL (export):{RESET} {out_path}")
continue
@ -118,3 +121,7 @@ for i, in_path in enumerate(input_files, start=1):
print(f"{GREEN}[dif2gltf] [{i}/{total}] OK:{RESET} {in_path} -> {out_path}")
print(f"[dif2gltf] Done! Converted {success_count} file(s), {failure_count} failed.")
if failed_paths:
print(f"\n{RED}[dif2gltf] Failed paths:{RESET}")
for p in failed_paths:
print(os.path.relpath(p))

View file

@ -65,6 +65,7 @@ print(f"[dts2gltf] Processing {len(input_files)} file(s)...")
total = len(input_files)
success_count = 0
failure_count = 0
failed_paths = []
for i, in_path in enumerate(input_files, start=1):
# Derive output path: same location, same name, but .glb/.gltf extension
ext = ".gltf" if args.format == "GLTF_SEPARATE" else ".glb"
@ -79,11 +80,12 @@ for i, in_path in enumerate(input_files, start=1):
# Import
print(f"[dts2gltf] [{i}/{total}] Converting: {in_path}")
try:
res = op_call(filepath=in_path, merge_verts=True)
res = op_call(filepath=in_path, merge_verts=True, import_sequences=True)
if "FINISHED" not in res:
raise RuntimeError(f"Import failed via {op_id}")
except Exception:
failure_count += 1
failed_paths.append(in_path)
print(f"\n{RED}[dts2gltf] [{i}/{total}] FAIL (import):{RESET} {in_path}")
continue
@ -100,6 +102,9 @@ for i, in_path in enumerate(input_files, start=1):
# Export custom properties, which is where we store the original
# resource path.
export_extras=True,
# Include armature animations (DTS sequences)
export_animations=True,
export_animation_mode='ACTIONS',
# Blender and T2 are Z-up, but these assets are destined for Three.js which
# is Y-up. It's easiest to match the Y-up of our destination engine.
export_yup=True,
@ -109,6 +114,7 @@ for i, in_path in enumerate(input_files, start=1):
)
if "FINISHED" not in res:
failure_count += 1
failed_paths.append(in_path)
print(f"\n{RED}[dts2gltf] [{i}/{total}] FAIL (export):{RESET} {out_path}")
continue
@ -116,3 +122,7 @@ for i, in_path in enumerate(input_files, start=1):
print(f"{GREEN}[dts2gltf] [{i}/{total}] OK:{RESET} {in_path} -> {out_path}")
print(f"[dts2gltf] Done! Converted {success_count} file(s), {failure_count} failed.")
if failed_paths:
print(f"\n{RED}[dts2gltf] Failed paths:{RESET}")
for p in failed_paths:
print(os.path.relpath(p))

View file

@ -0,0 +1,239 @@
/**
* Extract right-hand mesh centroids from player GLBs and Mountpoint positions
* from weapon GLBs. Used to derive correct mount offsets for demo playback.
*/
import fs from "node:fs/promises";
import path from "node:path";
// Minimal GLB/glTF parser — just enough to read node names and mesh positions.
interface GltfNode {
name?: string;
mesh?: number;
children?: number[];
translation?: [number, number, number];
rotation?: [number, number, number, number];
scale?: [number, number, number];
}
interface GltfAccessor {
bufferView: number;
componentType: number;
count: number;
type: string;
min?: number[];
max?: number[];
byteOffset?: number;
}
interface GltfBufferView {
buffer: number;
byteOffset?: number;
byteLength: number;
byteStride?: number;
}
interface GltfMesh {
name?: string;
primitives: Array<{ attributes: Record<string, number> }>;
}
interface GltfJson {
nodes: GltfNode[];
meshes?: GltfMesh[];
accessors?: GltfAccessor[];
bufferViews?: GltfBufferView[];
}
function parseGlb(buffer: Buffer): { json: GltfJson; bin: Buffer } {
const magic = buffer.readUInt32LE(0);
if (magic !== 0x46546c67) throw new Error("Not a GLB file");
// Chunk 0: JSON
const jsonLen = buffer.readUInt32LE(12);
const jsonStr = buffer.subarray(20, 20 + jsonLen).toString("utf-8");
const json = JSON.parse(jsonStr) as GltfJson;
// Chunk 1: BIN
const binOffset = 20 + jsonLen;
const binLen = buffer.readUInt32LE(binOffset);
const bin = buffer.subarray(binOffset + 8, binOffset + 8 + binLen);
return { json, bin };
}
/** Compute the centroid (average of min/max) of a mesh's POSITION accessor. */
function getMeshCentroid(
json: GltfJson,
bin: Buffer,
meshIndex: number,
): [number, number, number] | null {
const mesh = json.meshes?.[meshIndex];
if (!mesh) return null;
const posAccessorIdx = mesh.primitives[0]?.attributes?.POSITION;
if (posAccessorIdx == null) return null;
const accessor = json.accessors?.[posAccessorIdx];
if (!accessor || !accessor.min || !accessor.max) return null;
// Centroid from bounding box
return [
(accessor.min[0] + accessor.max[0]) / 2,
(accessor.min[1] + accessor.max[1]) / 2,
(accessor.min[2] + accessor.max[2]) / 2,
];
}
/** Get the world-space position of a named node, accounting for parent chain. */
function getNodeWorldPosition(
json: GltfJson,
nodeName: string,
): [number, number, number] | null {
// Build parent map
const parentMap = new Map<number, number>();
for (let i = 0; i < json.nodes.length; i++) {
const node = json.nodes[i];
if (node.children) {
for (const child of node.children) {
parentMap.set(child, i);
}
}
}
// Find the node
const nodeIdx = json.nodes.findIndex(
(n) => n.name?.toLowerCase() === nodeName.toLowerCase(),
);
if (nodeIdx === -1) return null;
// Walk up parent chain accumulating translations (ignoring rotation for now)
let pos: [number, number, number] = [0, 0, 0];
let current: number | undefined = nodeIdx;
while (current != null) {
const node = json.nodes[current];
const t = node.translation;
if (t) {
pos[0] += t[0];
pos[1] += t[1];
pos[2] += t[2];
}
current = parentMap.get(current);
}
return pos;
}
const baseDir = "/Users/exogen/Projects/t2-mapper/docs/base";
// Player models
const playerModels = [
"@vl2/shapes.vl2/shapes/light_male.glb",
"@vl2/shapes.vl2/shapes/medium_male.glb",
"@vl2/shapes.vl2/shapes/heavy_male.glb",
"@vl2/shapes.vl2/shapes/bioderm_light.glb",
"@vl2/shapes.vl2/shapes/bioderm_medium.glb",
"@vl2/shapes.vl2/shapes/bioderm_heavy.glb",
"@vl2/shapes.vl2/shapes/light_female.glb",
"@vl2/shapes.vl2/shapes/medium_female.glb",
];
// Weapon models
const weaponModels = [
"@vl2/shapes.vl2/shapes/weapon_disc.glb",
"@vl2/shapes.vl2/shapes/weapon_chaingun.glb",
"@vl2/shapes.vl2/shapes/weapon_mortar.glb",
"@vl2/shapes.vl2/shapes/weapon_plasma.glb",
"@vl2/shapes.vl2/shapes/weapon_grenade_launcher.glb",
"@vl2/shapes.vl2/shapes/weapon_repair.glb",
"@vl2/shapes.vl2/shapes/weapon_shocklance.glb",
"@vl2/shapes.vl2/shapes/weapon_missile.glb",
"@vl2/shapes.vl2/shapes/weapon_sniper.glb",
"@vl2/shapes.vl2/shapes/weapon_energy.glb",
"@vl2/shapes.vl2/shapes/weapon_elf.glb",
"@vl2/shapes.vl2/shapes/weapon_targeting.glb",
];
console.log("=== PLAYER RIGHT-HAND MESH CENTROIDS ===\n");
for (const rel of playerModels) {
const filePath = path.join(baseDir, rel);
try {
const buf = await fs.readFile(filePath);
const { json, bin } = parseGlb(buf);
const name = path.basename(rel, ".glb");
// Find right-hand mesh node
const rhNodeIdx = json.nodes.findIndex((n) => {
const lower = n.name?.toLowerCase() ?? "";
return lower.includes("rhand") || lower.includes("r_hand");
});
if (rhNodeIdx === -1) {
console.log(`${name}: no rhand mesh found`);
continue;
}
const rhNode = json.nodes[rhNodeIdx];
let centroid: [number, number, number] | null = null;
if (rhNode.mesh != null) {
centroid = getMeshCentroid(json, bin, rhNode.mesh);
}
// Also get the node's own translation
const translation = rhNode.translation ?? [0, 0, 0];
// Apply ShapeModel's 90° Y rotation: (x,y,z) → (z, y, -x)
if (centroid) {
const entitySpace: [number, number, number] = [
centroid[2],
centroid[1],
-centroid[0],
];
console.log(
`${name}: rhand mesh="${rhNode.name}" glbCentroid=(${centroid.map((v) => v.toFixed(3)).join(", ")}) entitySpace=(${entitySpace.map((v) => v.toFixed(3)).join(", ")})`,
);
} else {
console.log(
`${name}: rhand node="${rhNode.name}" translation=(${translation.map((v: number) => v.toFixed(3)).join(", ")}) (no mesh centroid)`,
);
}
} catch (e: any) {
console.log(`${name}: error - ${e.message}`);
}
}
console.log("\n=== WEAPON MOUNTPOINT POSITIONS ===\n");
for (const rel of weaponModels) {
const filePath = path.join(baseDir, rel);
try {
const buf = await fs.readFile(filePath);
const { json } = parseGlb(buf);
const name = path.basename(rel, ".glb");
// List all node names
const nodeNames = json.nodes.map((n) => n.name ?? "(unnamed)");
// Find Mountpoint
const mpIdx = json.nodes.findIndex((n) => {
const lower = n.name?.toLowerCase() ?? "";
return lower === "mountpoint";
});
if (mpIdx === -1) {
console.log(`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`);
continue;
}
// Get world position (walking parent chain)
const worldPos = getNodeWorldPosition(json, "Mountpoint");
const localTrans = json.nodes[mpIdx].translation ?? [0, 0, 0];
const localRot = json.nodes[mpIdx].rotation;
console.log(
`${name}: Mountpoint localTranslation=(${localTrans.map((v: number) => v.toFixed(4)).join(", ")})` +
(localRot
? ` localRotation=(${localRot.map((v: number) => v.toFixed(4)).join(", ")})`
: "") +
(worldPos
? ` worldPos=(${worldPos.map((v) => v.toFixed(4)).join(", ")})`
: ""),
);
} catch (e: any) {
console.log(`${path.basename(rel, ".glb")}: error - ${e.message}`);
}
}

View file

@ -0,0 +1,200 @@
/**
* Compute world-space positions of Mount0 and weapon Mountpoint from GLB data,
* then compute the mount transform needed to align them.
*/
import fs from "node:fs/promises";
type Vec3 = [number, number, number];
type Quat = [number, number, number, number]; // [x, y, z, w]
function rotateVec3(v: Vec3, q: Quat): Vec3 {
const [vx, vy, vz] = v;
const [qx, qy, qz, qw] = q;
// q * v * q^(-1), optimized
const ix = qw * vx + qy * vz - qz * vy;
const iy = qw * vy + qz * vx - qx * vz;
const iz = qw * vz + qx * vy - qy * vx;
const iw = -qx * vx - qy * vy - qz * vz;
return [
ix * qw + iw * -qx + iy * -qz - iz * -qy,
iy * qw + iw * -qy + iz * -qx - ix * -qz,
iz * qw + iw * -qz + ix * -qy - iy * -qx,
];
}
function multiplyQuat(a: Quat, b: Quat): Quat {
const [ax, ay, az, aw] = a;
const [bx, by, bz, bw] = b;
return [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
}
function inverseQuat(q: Quat): Quat {
return [-q[0], -q[1], -q[2], q[3]];
}
interface GltfNode {
name?: string;
children?: number[];
translation?: Vec3;
rotation?: Quat;
}
interface GltfDoc {
nodes: GltfNode[];
scenes: { nodes: number[] }[];
scene?: number;
}
function parseGlb(buffer: Buffer): GltfDoc {
const magic = buffer.readUInt32LE(0);
if (magic !== 0x46546c67) throw new Error("Not GLB");
const chunk0Length = buffer.readUInt32LE(12);
const jsonStr = buffer.toString("utf-8", 20, 20 + chunk0Length);
return JSON.parse(jsonStr);
}
function findParent(doc: GltfDoc, nodeIndex: number): number | null {
for (let i = 0; i < doc.nodes.length; i++) {
if (doc.nodes[i].children?.includes(nodeIndex)) return i;
}
return null;
}
function getAncestorChain(doc: GltfDoc, nodeIndex: number): number[] {
const chain: number[] = [];
let idx: number | null = nodeIndex;
while (idx != null) {
chain.unshift(idx);
idx = findParent(doc, idx);
}
return chain;
}
function computeWorldTransform(
doc: GltfDoc,
nodeIndex: number,
): { position: Vec3; quaternion: Quat } {
const chain = getAncestorChain(doc, nodeIndex);
let pos: Vec3 = [0, 0, 0];
let quat: Quat = [0, 0, 0, 1];
for (const idx of chain) {
const node = doc.nodes[idx];
const t: Vec3 = node.translation ?? [0, 0, 0];
const r: Quat = node.rotation ?? [0, 0, 0, 1];
// World = parent * local
// new_pos = parent_pos + rotate(local_pos, parent_quat)
const rotatedT = rotateVec3(t, quat);
pos = [pos[0] + rotatedT[0], pos[1] + rotatedT[1], pos[2] + rotatedT[2]];
quat = multiplyQuat(quat, r);
}
return { position: pos, quaternion: quat };
}
function findNodeByName(doc: GltfDoc, name: string): number | null {
for (let i = 0; i < doc.nodes.length; i++) {
if (doc.nodes[i].name === name) return i;
}
return null;
}
function fmt(v: number[]): string {
return `(${v.map((n) => n.toFixed(4)).join(", ")})`;
}
async function main() {
const playerBuf = await fs.readFile(
"docs/base/@vl2/shapes.vl2/shapes/light_male.glb"
);
const weaponBuf = await fs.readFile(
"docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb"
);
const playerDoc = parseGlb(playerBuf);
const weaponDoc = parseGlb(weaponBuf);
// Compute Mount0 world transform
const mount0Idx = findNodeByName(playerDoc, "Mount0")!;
const mount0 = computeWorldTransform(playerDoc, mount0Idx);
console.log("Mount0 world position (GLB space):", fmt(mount0.position));
console.log("Mount0 world rotation (GLB space):", fmt(mount0.quaternion));
// Compute Mountpoint world transform
const mpIdx = findNodeByName(weaponDoc, "Mountpoint")!;
const mp = computeWorldTransform(weaponDoc, mpIdx);
console.log("\nMountpoint world position (GLB space):", fmt(mp.position));
console.log("Mountpoint world rotation (GLB space):", fmt(mp.quaternion));
// The ShapeRenderer applies a 90° Y rotation to the GLB scene.
// R90 = quaternion for 90° around Y = (0, sin(45°), 0, cos(45°)) = (0, 0.7071, 0, 0.7071)
const R90: Quat = [0, Math.SQRT1_2, 0, Math.SQRT1_2];
const R90_inv = inverseQuat(R90);
// Mount0 in entity space (after ShapeRenderer rotation)
const m0_entity_pos = rotateVec3(mount0.position, R90);
const m0_entity_quat = multiplyQuat(R90, mount0.quaternion);
console.log("\nMount0 entity-space position:", fmt(m0_entity_pos));
console.log("Mount0 entity-space rotation:", fmt(m0_entity_quat));
// The mount transform T_mount must satisfy:
// T_mount * R90 * MP = R90 * M0
// So: T_mount = R90 * M0 * MP^(-1) * R90^(-1)
//
// For position: mount_pos = R90 * (M0_pos - R_M0 * R_MP^(-1) * MP_pos)
// Wait, let me use the full matrix formula:
// T_mount = R90 * M0 * MP^(-1) * R90^(-1)
// Step 1: MP^(-1)
const mp_inv_quat = inverseQuat(mp.quaternion);
const mp_inv_pos: Vec3 = rotateVec3(
[-mp.position[0], -mp.position[1], -mp.position[2]],
mp_inv_quat,
);
// Step 2: M0 * MP^(-1)
const combined_pos: Vec3 = (() => {
const rotated = rotateVec3(mp_inv_pos, mount0.quaternion);
return [
mount0.position[0] + rotated[0],
mount0.position[1] + rotated[1],
mount0.position[2] + rotated[2],
] as Vec3;
})();
const combined_quat = multiplyQuat(mount0.quaternion, mp_inv_quat);
// Step 3: R90 * combined * R90^(-1)
const mount_pos = rotateVec3(combined_pos, R90);
// For rotation: R90 * combined_quat * R90^(-1)
const mount_quat = multiplyQuat(multiplyQuat(R90, combined_quat), R90_inv);
console.log("\n=== CORRECT MOUNT TRANSFORM ===");
console.log("Position:", fmt(mount_pos));
console.log("Quaternion:", fmt(mount_quat));
// For comparison, show what the current code computes:
// Current: pos = (mount0Pos.z, mount0Pos.y, -mount0Pos.x)
// Current: quat = rot90 * mount0Quat
const current_pos: Vec3 = [
mount0.position[2],
mount0.position[1],
-mount0.position[0],
];
const current_quat = multiplyQuat(R90, mount0.quaternion);
console.log("\n=== CURRENT CODE COMPUTES ===");
console.log("Position:", fmt(current_pos));
console.log("Quaternion:", fmt(current_quat));
// Show Cam node for reference
const camIdx = findNodeByName(playerDoc, "Cam")!;
const cam = computeWorldTransform(playerDoc, camIdx);
console.log("\nCam world position (GLB space):", fmt(cam.position));
}
main().catch(console.error);

View file

@ -0,0 +1,241 @@
/**
* 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);
}
}

244
scripts/play-demo.ts Normal file
View file

@ -0,0 +1,244 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import puppeteer from "puppeteer";
import { parseArgs } from "node:util";
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const { values, positionals } = parseArgs({
options: {
headless: {
type: "boolean",
default: true,
},
wait: {
type: "string",
default: "10",
short: "w",
},
screenshot: {
type: "boolean",
default: false,
short: "s",
},
},
allowPositionals: true,
});
const demoPath = positionals[0];
const headless = values.headless;
const waitSeconds = parseInt(values.wait!, 10);
const takeScreenshot = values.screenshot;
if (!demoPath) {
console.error("Usage: npx tsx scripts/play-demo.ts [options] <demo.rec>");
console.error();
console.error("Options:");
console.error(" --no-headless Show the browser window");
console.error(" --wait, -w <s> Seconds to wait after loading (default: 10)");
console.error(" --screenshot, -s Take a screenshot after loading");
console.error();
console.error("Examples:");
console.error(
" npx tsx scripts/play-demo.ts ~/Projects/t2-demo-parser/demo022.rec",
);
console.error(
" npx tsx scripts/play-demo.ts --no-headless -w 30 ~/Projects/t2-demo-parser/demo022.rec",
);
process.exit(1);
}
const absoluteDemoPath = path.resolve(demoPath);
const browser = await puppeteer.launch({ headless });
const page = await browser.newPage();
await page.setViewport({ width: 900, height: 600 });
// Capture all console output from the page, serializing object arguments.
page.on("console", async (msg) => {
const type = msg.type();
const prefix =
type === "error" ? "ERROR" : type === "warn" ? "WARN" : type.toUpperCase();
const args = msg.args();
const parts: string[] = [];
for (const arg of args) {
try {
const val = await arg.jsonValue();
parts.push(typeof val === "string" ? val : JSON.stringify(val));
} catch {
parts.push(msg.text());
break;
}
}
console.log(`[browser ${prefix}] ${parts.join(" ")}`);
});
page.on("pageerror", (err: Error) => {
console.error(`[browser EXCEPTION] ${err.message}`);
});
// Set up settings before navigating.
await page.evaluateOnNewDocument(() => {
localStorage.setItem(
"settings",
JSON.stringify({
fov: 80,
audioEnabled: false,
animationEnabled: false,
debugMode: false,
fogEnabled: true,
}),
);
});
const baseUrl = "http://localhost:3000/t2-mapper/";
console.log(`Loading: ${baseUrl}`);
await page.goto(baseUrl, { waitUntil: "load" });
await page.waitForNetworkIdle({ idleTime: 500 });
// Close any popover by pressing Escape.
await page.keyboard.press("Escape");
await sleep(100);
// Hide controls from screenshots.
await page.$eval("#controls", (el: HTMLElement) => {
el.style.visibility = "hidden";
});
// Upload the demo file via the hidden file input.
console.log(`Loading demo: ${absoluteDemoPath}`);
const fileInput = await page.waitForSelector(
'input[type="file"][accept=".rec"]',
);
if (!fileInput) {
console.error("Could not find demo file input");
await browser.close();
process.exit(1);
}
await fileInput.uploadFile(absoluteDemoPath);
// Wait for the mission to load (demo triggers a mission switch).
console.log("Waiting for mission to load...");
await sleep(2000);
try {
await page.waitForSelector("#loadingIndicator", {
hidden: true,
timeout: 30000,
});
} catch {
console.warn(
"Loading indicator did not disappear within 30s, continuing anyway",
);
}
await page.waitForNetworkIdle({ idleTime: 1000 });
await sleep(1000);
// Dismiss any popovers and start playback via JS to avoid triggering UI menus.
await page.evaluate(() => {
// Close any Radix popover by removing it from DOM.
document
.querySelectorAll("[data-radix-popper-content-wrapper]")
.forEach((el) => el.remove());
});
await sleep(200);
// Seek forward if requested via SEEK env var.
const seekTo = process.env.SEEK ? parseFloat(process.env.SEEK) : null;
if (seekTo != null) {
console.log(`Seeking to ${seekTo}s...`);
await page.evaluate((t: number) => {
const input = document.querySelector('input[type="range"]');
if (input) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(input, String(t));
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}, seekTo);
await sleep(500);
}
// Click play button directly.
await page.evaluate(() => {
const playBtn = document.querySelector('button[aria-label="Play"]');
if (playBtn) {
(playBtn as HTMLButtonElement).click();
}
});
await sleep(500);
console.log("Started playback.");
console.log(`Demo loaded. Waiting ${waitSeconds}s for console output...`);
await sleep(waitSeconds * 1000);
// Inspect the Three.js scene for entity groups.
const sceneInfo = await page.evaluate(() => {
const scene = (window as any).__THREE_SCENE__;
if (!scene) return "No scene found (window.__THREE_SCENE__ not set)";
const results: string[] = [];
scene.traverse((obj: any) => {
if (
obj.name &&
(obj.name.startsWith("player_") ||
obj.name.startsWith("vehicle_") ||
obj.name.startsWith("item_") ||
obj.name === "camera")
) {
const pos = obj.position;
const childCount = obj.children?.length ?? 0;
results.push(
`${obj.name}: pos=(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)}) children=${childCount} visible=${obj.visible}`,
);
}
});
// Check AnimationMixer state.
let mixerInfo = "No mixer found";
scene.traverse((obj: any) => {
// The root group of DemoPlayback is the direct parent of entity groups.
if (obj.children?.some((c: any) => c.name?.startsWith("player_"))) {
// This is the root group. Check for active animations.
const animations = (obj as any)._mixer;
mixerInfo = `Root group found. Has _mixer: ${!!animations}`;
}
});
const entityCount = results.length;
const summary = `Found ${entityCount} entity groups. ${mixerInfo}`;
return [summary, ...results.slice(0, 5)].join("\n");
});
console.log("[scene]", sceneInfo);
if (takeScreenshot) {
// Remove any popovers that might be covering the canvas.
await page.evaluate(() => {
document
.querySelectorAll("[data-radix-popper-content-wrapper]")
.forEach((el) => el.remove());
});
const canvas = await page.waitForSelector("canvas");
if (canvas) {
const tempDir = path.join(os.tmpdir(), "t2-mapper");
await fs.mkdir(tempDir, { recursive: true });
const demoName = path.basename(absoluteDemoPath, ".rec");
const date = new Date().toISOString().replace(/([:-]|\..*$)/g, "");
const outputPath = path.join(tempDir, `${date}.demo.${demoName}.png`);
await canvas.screenshot({ path: outputPath, type: "png" });
console.log(`Screenshot saved to: ${outputPath}`);
}
}
console.log("Done.");
await Promise.race([
browser.close(),
sleep(3000).then(() => browser.process()?.kill("SIGKILL")),
]);