mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-03 04:20:32 +00:00
initial demo support
This commit is contained in:
parent
0f2e103294
commit
359a036558
406 changed files with 10513 additions and 1158 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
239
scripts/check-mount-points.ts
Normal file
239
scripts/check-mount-points.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
200
scripts/compute-mount-world.ts
Normal file
200
scripts/compute-mount-world.ts
Normal 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);
|
||||
241
scripts/inspect-glb-nodes.ts
Normal file
241
scripts/inspect-glb-nodes.ts
Normal 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
244
scripts/play-demo.ts
Normal 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")),
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue