begin live server support

This commit is contained in:
Brian Beck 2026-03-09 12:38:40 -07:00
parent 0c9ddb476a
commit e4ae265184
368 changed files with 17756 additions and 7738 deletions

View file

@ -0,0 +1,211 @@
import { describe, it, expect } from "vitest";
import { Quaternion, Vector3 } from "three";
import {
torqueToThree,
torqueScaleToThree,
matrixFToQuaternion,
torqueAxisAngleToQuaternion,
} from "./coordinates";
import type { MatrixF } from "./types";
import { IDENTITY_MATRIX } from "./types";
describe("torqueToThree", () => {
it("swizzles Torque (X,Y,Z) to Three.js (Y,Z,X)", () => {
expect(torqueToThree({ x: 1, y: 2, z: 3 })).toEqual([2, 3, 1]);
});
it("handles zero vector", () => {
expect(torqueToThree({ x: 0, y: 0, z: 0 })).toEqual([0, 0, 0]);
});
});
describe("torqueScaleToThree", () => {
it("applies same swizzle as position", () => {
expect(torqueScaleToThree({ x: 10, y: 20, z: 30 })).toEqual([20, 30, 10]);
});
});
describe("matrixFToQuaternion", () => {
it("returns identity quaternion for identity matrix", () => {
const q = matrixFToQuaternion(IDENTITY_MATRIX);
expect(q.x).toBeCloseTo(0, 5);
expect(q.y).toBeCloseTo(0, 5);
expect(q.z).toBeCloseTo(0, 5);
expect(q.w).toBeCloseTo(1, 5);
});
it("handles 90° rotation around Torque Z-axis (up)", () => {
// Torque Z-up → Three.js Y-up
// 90° around Torque Z maps to -90° around Three.js Y
// (negative due to conjugation for row-vector → column-vector convention)
const angleRad = Math.PI / 2;
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
const elements = [
c, s, 0, 0,
-s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
const q = matrixFToQuaternion(m);
const expected = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
-angleRad,
);
expect(q.x).toBeCloseTo(expected.x, 4);
expect(q.y).toBeCloseTo(expected.y, 4);
expect(q.z).toBeCloseTo(expected.z, 4);
expect(q.w).toBeCloseTo(expected.w, 4);
});
it("handles 90° rotation around Torque X-axis (right)", () => {
// Torque X → Three.js Z, angle negated
const angleRad = Math.PI / 2;
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
const elements = [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1,
];
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
const q = matrixFToQuaternion(m);
const expected = new Quaternion().setFromAxisAngle(
new Vector3(0, 0, 1),
-angleRad,
);
expect(q.x).toBeCloseTo(expected.x, 4);
expect(q.y).toBeCloseTo(expected.y, 4);
expect(q.z).toBeCloseTo(expected.z, 4);
expect(q.w).toBeCloseTo(expected.w, 4);
});
it("handles 90° rotation around Torque Y-axis (forward)", () => {
// Torque Y → Three.js X, angle negated
const angleRad = Math.PI / 2;
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
const elements = [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1,
];
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
const q = matrixFToQuaternion(m);
const expected = new Quaternion().setFromAxisAngle(
new Vector3(1, 0, 0),
-angleRad,
);
expect(q.x).toBeCloseTo(expected.x, 4);
expect(q.y).toBeCloseTo(expected.y, 4);
expect(q.z).toBeCloseTo(expected.z, 4);
expect(q.w).toBeCloseTo(expected.w, 4);
});
it("produces unit quaternion from valid rotation matrix", () => {
// Arbitrary rotation: 45° around Torque (1,1,0) normalized
const len = Math.sqrt(2);
const nx = 1 / len, ny = 1 / len, nz = 0;
const angle = Math.PI / 4;
const c = Math.cos(angle);
const s = Math.sin(angle);
const t = 1 - c;
const elements = new Array(16).fill(0);
elements[0] = t * nx * nx + c;
elements[1] = t * nx * ny + s * nz;
elements[2] = t * nx * nz - s * ny;
elements[4] = t * nx * ny - s * nz;
elements[5] = t * ny * ny + c;
elements[6] = t * ny * nz + s * nx;
elements[8] = t * nx * nz + s * ny;
elements[9] = t * ny * nz - s * nx;
elements[10] = t * nz * nz + c;
elements[15] = 1;
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
const q = matrixFToQuaternion(m);
const qLen = Math.sqrt(q.x ** 2 + q.y ** 2 + q.z ** 2 + q.w ** 2);
expect(qLen).toBeCloseTo(1, 5);
});
});
describe("torqueAxisAngleToQuaternion", () => {
it("returns identity for zero angle", () => {
const q = torqueAxisAngleToQuaternion(1, 0, 0, 0);
expect(q.x).toBeCloseTo(0, 5);
expect(q.y).toBeCloseTo(0, 5);
expect(q.z).toBeCloseTo(0, 5);
expect(q.w).toBeCloseTo(1, 5);
});
it("returns identity for zero-length axis", () => {
const q = torqueAxisAngleToQuaternion(0, 0, 0, 90);
expect(q.w).toBeCloseTo(1, 5);
});
it("90° around Torque Z maps to Three.js Y rotation", () => {
// Torque Z-axis = (0,0,1), swizzled to Three.js = (0,1,0)
const q = torqueAxisAngleToQuaternion(0, 0, 1, 90);
// Negative angle because of coordinate system handedness flip
const expected = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
-90 * (Math.PI / 180),
);
expect(q.x).toBeCloseTo(expected.x, 4);
expect(q.y).toBeCloseTo(expected.y, 4);
expect(q.z).toBeCloseTo(expected.z, 4);
expect(q.w).toBeCloseTo(expected.w, 4);
});
it("produces unit quaternion", () => {
const q = torqueAxisAngleToQuaternion(1, 2, 3, 45);
const len = Math.sqrt(q.x ** 2 + q.y ** 2 + q.z ** 2 + q.w ** 2);
expect(len).toBeCloseTo(1, 5);
});
it("agrees with matrixFToQuaternion for same rotation", () => {
// 60° around Torque Z-axis
// Both functions should produce the same quaternion.
const ax = 0, ay = 0, az = 1, angleDeg = 60;
const angleRad = angleDeg * (Math.PI / 180);
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
const elements = [
c, s, 0, 0,
-s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
const qFromMatrix = matrixFToQuaternion(m);
const qFromAxisAngle = torqueAxisAngleToQuaternion(ax, ay, az, angleDeg);
const dot =
qFromMatrix.x * qFromAxisAngle.x +
qFromMatrix.y * qFromAxisAngle.y +
qFromMatrix.z * qFromAxisAngle.z +
qFromMatrix.w * qFromAxisAngle.w;
expect(Math.abs(dot)).toBeCloseTo(1, 4);
});
});

113
src/scene/coordinates.ts Normal file
View file

@ -0,0 +1,113 @@
import { Matrix4, Quaternion } from "three";
import type { MatrixF, Vec3 } from "./types";
/**
* Convert a Torque Vec3 (X-right, Y-forward, Z-up) to Three.js (X-right, Y-up, Z-backward).
* Swizzle: three.x = torque.y, three.y = torque.z, three.z = torque.x
*
* Note: this is the same swizzle used by getPosition() in mission.ts.
*/
export function torqueToThree(v: Vec3): [number, number, number] {
return [v.y, v.z, v.x];
}
/** Convert a Torque scale Vec3 to Three.js axis order. */
export function torqueScaleToThree(v: Vec3): [number, number, number] {
return [v.y, v.z, v.x];
}
/**
* Convert a Torque MatrixF to a Three.js Quaternion.
*
* Torque MatrixF layout (row-major, idx = row*4 + col):
* [0] [1] [2] [3] m00 m01 m02 tx
* [4] [5] [6] [7] = m10 m11 m12 ty
* [8] [9] [10] [11] m20 m21 m22 tz
* [12] [13] [14] [15] 0 0 0 1
*
* Three.js Matrix4 is column-major, with elements in the same index layout
* but interpreted differently. We extract the 3×3 rotation, apply the
* TorqueThree.js coordinate transform, then decompose to quaternion.
*/
export function matrixFToQuaternion(m: MatrixF): Quaternion {
const e = m.elements;
// Extract the Torque 3×3 rotation columns (row-major storage)
// Column 0: e[0], e[1], e[2]
// Column 1: e[4], e[5], e[6]
// Column 2: e[8], e[9], e[10]
// Apply Torque→Three.js coordinate transform to the rotation matrix.
// Torque (X,Y,Z) → Three.js (Y,Z,X) means:
// Three.js row i, col j = Torque row swizzle[i], col swizzle[j]
// where swizzle maps Three axis → Torque axis: X→Y(1), Y→Z(2), Z→X(0)
//
// Build a Three.js column-major Matrix4 from the transformed rotation.
const mat4 = new Matrix4();
// Three.js Matrix4.elements is column-major:
// [m11, m21, m31, m41, m12, m22, m32, m42, m13, m23, m33, m43, m14, m24, m34, m44]
const t = mat4.elements;
// Torque col 0 (X-axis) maps to Three.js col 2 (Z-axis after swizzle)
// Torque col 1 (Y-axis) maps to Three.js col 0 (X-axis after swizzle)
// Torque col 2 (Z-axis) maps to Three.js col 1 (Y-axis after swizzle)
//
// Within each column, rows are also swizzled:
// Torque row 0 (X) → Three.js row 2 (Z)
// Torque row 1 (Y) → Three.js row 0 (X)
// Torque row 2 (Z) → Three.js row 1 (Y)
// Three.js column 0 ← Torque column 1 (Y→X), rows swizzled
t[0] = e[5]; // T_Y_Y → Three X_X
t[1] = e[6]; // T_Z_Y → Three Y_X
t[2] = e[4]; // T_X_Y → Three Z_X
t[3] = 0;
// Three.js column 1 ← Torque column 2 (Z→Y), rows swizzled
t[4] = e[9]; // T_Y_Z → Three X_Y
t[5] = e[10]; // T_Z_Z → Three Y_Y
t[6] = e[8]; // T_X_Z → Three Z_Y
t[7] = 0;
// Three.js column 2 ← Torque column 0 (X→Z), rows swizzled
t[8] = e[1]; // T_Y_X → Three X_Z
t[9] = e[2]; // T_Z_X → Three Y_Z
t[10] = e[0]; // T_X_X → Three Z_Z
t[11] = 0;
// Translation column (not used for quaternion, but set for completeness)
t[12] = 0;
t[13] = 0;
t[14] = 0;
t[15] = 1;
const q = new Quaternion();
q.setFromRotationMatrix(mat4);
// Torque uses row-vector convention (v * M), Three.js uses column-vector (M * v).
// The extracted rotation is the transpose, so conjugate to invert.
q.conjugate();
return q;
}
/**
* Convert a Torque axis-angle rotation string ("ax ay az angleDeg") to a Quaternion.
* This is the format used in .mis files. The axis is in Torque coordinates.
*/
export function torqueAxisAngleToQuaternion(
ax: number,
ay: number,
az: number,
angleDeg: number,
): Quaternion {
// Swizzle axis: Torque (X,Y,Z) → Three.js (Y,Z,X)
const threeAx = ay;
const threeAy = az;
const threeAz = ax;
const len = Math.sqrt(threeAx * threeAx + threeAy * threeAy + threeAz * threeAz);
if (len < 1e-8) return new Quaternion();
const angleRad = -angleDeg * (Math.PI / 180);
return new Quaternion().setFromAxisAngle(
{ x: threeAx / len, y: threeAy / len, z: threeAz / len } as any,
angleRad,
);
}

View file

@ -0,0 +1,171 @@
/**
* Cross-validation: misToScene and ghostToScene should produce equivalent
* scene objects for the same logical data. This catches drift between the
* two adapter paths.
*/
import { describe, it, expect } from "vitest";
import { interiorFromMis, tsStaticFromMis, skyFromMis } from "./misToScene";
import {
interiorFromGhost,
tsStaticFromGhost,
skyFromGhost,
} from "./ghostToScene";
import type { TorqueObject } from "../torqueScript";
function makeObj(
className: string,
props: Record<string, string>,
id = 42,
): TorqueObject {
const obj: TorqueObject = {
_class: className.toLowerCase(),
_className: className,
_name: "",
_id: id,
_children: [],
};
for (const [k, v] of Object.entries(props)) {
obj[k.toLowerCase()] = v;
}
return obj;
}
describe("misToScene ↔ ghostToScene cross-validation", () => {
it("InteriorInstance: identity rotation produces same transform", () => {
const misResult = interiorFromMis(
makeObj("InteriorInstance", {
interiorFile: "building.dif",
position: "100 200 300",
rotation: "1 0 0 0",
scale: "1 1 1",
showTerrainInside: "0",
}),
);
const ghostResult = interiorFromGhost(42, {
interiorFile: "building.dif",
transform: {
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 200, 300, 1],
position: { x: 100, y: 200, z: 300 },
},
scale: { x: 1, y: 1, z: 1 },
showTerrainInside: false,
skinBase: "",
alarmState: false,
});
expect(misResult.interiorFile).toBe(ghostResult.interiorFile);
expect(misResult.scale).toEqual(ghostResult.scale);
expect(misResult.showTerrainInside).toBe(ghostResult.showTerrainInside);
// Transform position
expect(misResult.transform.position.x).toBeCloseTo(
ghostResult.transform.position.x,
);
expect(misResult.transform.position.y).toBeCloseTo(
ghostResult.transform.position.y,
);
expect(misResult.transform.position.z).toBeCloseTo(
ghostResult.transform.position.z,
);
// Rotation elements (identity case)
for (let i = 0; i < 16; i++) {
expect(misResult.transform.elements[i]).toBeCloseTo(
ghostResult.transform.elements[i],
4,
);
}
});
it("InteriorInstance: 90° Z rotation matches", () => {
const misResult = interiorFromMis(
makeObj("InteriorInstance", {
interiorFile: "building.dif",
position: "0 0 0",
rotation: "0 0 1 90",
}),
);
// Build the same matrix manually: 90° around Z
const c = Math.cos(Math.PI / 2);
const s = Math.sin(Math.PI / 2);
const elements = [
c, s, 0, 0,
-s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
const ghostResult = interiorFromGhost(42, {
interiorFile: "building.dif",
transform: { elements, position: { x: 0, y: 0, z: 0 } },
scale: { x: 1, y: 1, z: 1 },
});
for (let i = 0; i < 16; i++) {
expect(misResult.transform.elements[i]).toBeCloseTo(
ghostResult.transform.elements[i],
4,
);
}
});
it("TSStatic: position and scale match", () => {
const misResult = tsStaticFromMis(
makeObj("TSStatic", {
shapeName: "tree.dts",
position: "50 60 70",
rotation: "1 0 0 0",
scale: "2 3 4",
}),
);
const ghostResult = tsStaticFromGhost(42, {
shapeName: "tree.dts",
transform: {
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 60, 70, 1],
position: { x: 50, y: 60, z: 70 },
},
scale: { x: 2, y: 3, z: 4 },
});
expect(misResult.shapeName).toBe(ghostResult.shapeName);
expect(misResult.scale).toEqual(ghostResult.scale);
expect(misResult.transform.position).toEqual(ghostResult.transform.position);
});
it("Sky: fog and cloud data match", () => {
const misResult = skyFromMis(
makeObj("Sky", {
materialList: "sky_ice.dml",
fogColor: "0.5 0.6 0.7",
visibleDistance: "2000",
fogDistance: "500",
SkySolidColor: "0.1 0.2 0.3",
useSkyTextures: "1",
windVelocity: "1 0 0",
}),
);
const ghostResult = skyFromGhost(42, {
materialList: "sky_ice.dml",
fogColor: { r: 0.5, g: 0.6, b: 0.7 },
visibleDistance: 2000,
fogDistance: 500,
skySolidColor: { r: 0.1, g: 0.2, b: 0.3 },
useSkyTextures: true,
fogVolumes: [],
cloudLayers: [],
windVelocity: { x: 1, y: 0, z: 0 },
});
expect(misResult.materialList).toBe(ghostResult.materialList);
expect(misResult.fogColor).toEqual(ghostResult.fogColor);
expect(misResult.visibleDistance).toBe(ghostResult.visibleDistance);
expect(misResult.fogDistance).toBe(ghostResult.fogDistance);
expect(misResult.skySolidColor).toEqual(ghostResult.skySolidColor);
expect(misResult.useSkyTextures).toBe(ghostResult.useSkyTextures);
expect(misResult.windVelocity).toEqual(ghostResult.windVelocity);
});
});

View file

@ -0,0 +1,159 @@
import { describe, it, expect } from "vitest";
import {
terrainFromGhost,
interiorFromGhost,
skyFromGhost,
sunFromGhost,
missionAreaFromGhost,
waterBlockFromGhost,
ghostToSceneObject,
} from "./ghostToScene";
describe("terrainFromGhost", () => {
it("extracts terrain fields", () => {
const result = terrainFromGhost(5, {
terrFileName: "ice.ter",
detailTextureName: "details/detail1.png",
squareSize: 8,
emptySquareRuns: [0, 10, 256, 5],
});
expect(result.className).toBe("TerrainBlock");
expect(result.ghostIndex).toBe(5);
expect(result.terrFileName).toBe("ice.ter");
expect(result.squareSize).toBe(8);
expect(result.emptySquareRuns).toEqual([0, 10, 256, 5]);
});
it("uses defaults for missing fields", () => {
const result = terrainFromGhost(0, {});
expect(result.terrFileName).toBe("");
expect(result.squareSize).toBe(8);
expect(result.emptySquareRuns).toBeUndefined();
});
});
describe("interiorFromGhost", () => {
it("extracts transform and scale", () => {
const transform = {
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 200, 300, 1],
position: { x: 100, y: 200, z: 300 },
};
const result = interiorFromGhost(10, {
interiorFile: "building.dif",
transform,
scale: { x: 2, y: 3, z: 4 },
showTerrainInside: true,
skinBase: "base",
alarmState: false,
});
expect(result.interiorFile).toBe("building.dif");
expect(result.transform).toBe(transform);
expect(result.scale).toEqual({ x: 2, y: 3, z: 4 });
expect(result.showTerrainInside).toBe(true);
});
it("uses identity transform for missing data", () => {
const result = interiorFromGhost(0, {});
expect(result.transform.elements).toEqual([
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
]);
expect(result.scale).toEqual({ x: 1, y: 1, z: 1 });
});
});
describe("skyFromGhost", () => {
it("extracts fog volumes and cloud layers", () => {
const result = skyFromGhost(1, {
materialList: "sky_ice.dml",
fogColor: { r: 0.5, g: 0.5, b: 0.5 },
visibleDistance: 2000,
fogDistance: 500,
skySolidColor: { r: 0.1, g: 0.2, b: 0.3 },
useSkyTextures: true,
fogVolumes: [
{ visibleDistance: 500, minHeight: 0, maxHeight: 300, color: { r: 0.5, g: 0.5, b: 0.5 } },
],
cloudLayers: [
{ texture: "cloud1.png", heightPercent: 0.35, speed: 0.001 },
],
windVelocity: { x: 1, y: 0, z: 0 },
});
expect(result.fogVolumes).toHaveLength(1);
expect(result.fogVolumes[0].visibleDistance).toBe(500);
expect(result.cloudLayers).toHaveLength(1);
expect(result.cloudLayers[0].texture).toBe("cloud1.png");
expect(result.visibleDistance).toBe(2000);
});
it("defaults to empty arrays for missing volumes/layers", () => {
const result = skyFromGhost(1, {});
expect(result.fogVolumes).toEqual([]);
expect(result.cloudLayers).toEqual([]);
});
});
describe("sunFromGhost", () => {
it("extracts direction and colors", () => {
const result = sunFromGhost(2, {
direction: { x: 0.57735, y: 0.57735, z: -0.57735 },
color: { r: 0.8, g: 0.8, b: 0.7, a: 1.0 },
ambient: { r: 0.3, g: 0.3, b: 0.4, a: 1.0 },
textures: ["sun.png"],
});
expect(result.direction.x).toBeCloseTo(0.57735);
expect(result.color.r).toBe(0.8);
expect(result.textures).toEqual(["sun.png"]);
});
it("uses defaults for missing data", () => {
const result = sunFromGhost(0, {});
expect(result.direction).toEqual({ x: 0.57735, y: 0.57735, z: -0.57735 });
expect(result.color).toEqual({ r: 0.7, g: 0.7, b: 0.7, a: 1 });
});
});
describe("missionAreaFromGhost", () => {
it("extracts area and flight ceiling", () => {
const result = missionAreaFromGhost(3, {
area: { x: -1024, y: -1024, w: 2048, h: 2048 },
flightCeiling: 5000,
flightCeilingRange: 100,
});
expect(result.area).toEqual({ x: -1024, y: -1024, w: 2048, h: 2048 });
expect(result.flightCeiling).toBe(5000);
});
});
describe("waterBlockFromGhost", () => {
it("extracts surface textures", () => {
const result = waterBlockFromGhost(4, {
surfaceName: "water.png",
envMapName: "envmap.png",
scale: { x: 512, y: 512, z: 10 },
});
expect(result.surfaceName).toBe("water.png");
expect(result.envMapName).toBe("envmap.png");
});
});
describe("ghostToSceneObject", () => {
it("dispatches by className", () => {
const terrain = ghostToSceneObject("TerrainBlock", 1, {
terrFileName: "test.ter",
});
expect(terrain?.className).toBe("TerrainBlock");
const interior = ghostToSceneObject("InteriorInstance", 2, {
interiorFile: "test.dif",
});
expect(interior?.className).toBe("InteriorInstance");
const sky = ghostToSceneObject("Sky", 3, {});
expect(sky?.className).toBe("Sky");
});
it("returns null for non-scene classes", () => {
expect(ghostToSceneObject("Player", 1, {})).toBeNull();
expect(ghostToSceneObject("Vehicle", 2, {})).toBeNull();
});
});

231
src/scene/ghostToScene.ts Normal file
View file

@ -0,0 +1,231 @@
import type {
SceneTerrainBlock,
SceneInteriorInstance,
SceneTSStatic,
SceneSky,
SceneSun,
SceneMissionArea,
SceneWaterBlock,
SceneObject,
MatrixF,
Vec3,
Color3,
Color4,
} from "./types";
type GhostData = Record<string, unknown>;
function vec3(v: unknown, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 {
if (v && typeof v === "object" && "x" in v) return v as Vec3;
return fallback;
}
function color3(v: unknown, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 {
if (v && typeof v === "object" && "r" in v) return v as Color3;
return fallback;
}
function color4(
v: unknown,
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
): Color4 {
if (v && typeof v === "object" && "r" in v) return v as Color4;
return fallback;
}
function matrixF(v: unknown): MatrixF {
if (
v &&
typeof v === "object" &&
"elements" in v &&
Array.isArray((v as any).elements)
) {
return v as MatrixF;
}
// readAffineTransform() returns {position, rotation} — convert to MatrixF.
if (
v &&
typeof v === "object" &&
"position" in v &&
"rotation" in v
) {
const { position: pos, rotation: q } = v as {
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number; w: number };
};
// Quaternion to column-major 4×4 matrix (idx = row + col*4).
const xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
const xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
const wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
return {
elements: [
1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
pos.x, pos.y, pos.z, 1,
],
position: { x: pos.x, y: pos.y, z: pos.z },
};
}
return {
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
position: { x: 0, y: 0, z: 0 },
};
}
export function terrainFromGhost(
ghostIndex: number,
data: GhostData,
): SceneTerrainBlock {
return {
className: "TerrainBlock",
ghostIndex,
terrFileName: (data.terrFileName as string) ?? "",
detailTextureName: (data.detailTextureName as string) ?? "",
squareSize: (data.squareSize as number) ?? 8,
emptySquareRuns: data.emptySquareRuns as number[] | undefined,
};
}
export function interiorFromGhost(
ghostIndex: number,
data: GhostData,
): SceneInteriorInstance {
return {
className: "InteriorInstance",
ghostIndex,
interiorFile: (data.interiorFile as string) ?? "",
transform: matrixF(data.transform),
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
showTerrainInside: (data.showTerrainInside as boolean) ?? false,
skinBase: (data.skinBase as string) ?? "",
alarmState: (data.alarmState as boolean) ?? false,
};
}
export function tsStaticFromGhost(
ghostIndex: number,
data: GhostData,
): SceneTSStatic {
return {
className: "TSStatic",
ghostIndex,
shapeName: (data.shapeName as string) ?? "",
transform: matrixF(data.transform),
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
};
}
export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky {
const fogVolumes = Array.isArray(data.fogVolumes)
? (data.fogVolumes as Array<{
visibleDistance?: number;
minHeight?: number;
maxHeight?: number;
color?: Color3;
}>).map((v) => ({
visibleDistance: v.visibleDistance ?? 0,
minHeight: v.minHeight ?? 0,
maxHeight: v.maxHeight ?? 0,
color: color3(v.color),
}))
: [];
const cloudLayers = Array.isArray(data.cloudLayers)
? (data.cloudLayers as Array<{
texture?: string;
heightPercent?: number;
speed?: number;
}>).map((c) => ({
texture: c.texture ?? "",
heightPercent: c.heightPercent ?? 0,
speed: c.speed ?? 0,
}))
: [];
return {
className: "Sky",
ghostIndex,
materialList: (data.materialList as string) ?? "",
fogColor: color3(data.fogColor),
visibleDistance: (data.visibleDistance as number) ?? 1000,
fogDistance: (data.fogDistance as number) ?? 0,
skySolidColor: color3(data.skySolidColor),
useSkyTextures: (data.useSkyTextures as boolean) ?? true,
fogVolumes,
cloudLayers,
windVelocity: vec3(data.windVelocity),
};
}
export function sunFromGhost(ghostIndex: number, data: GhostData): SceneSun {
return {
className: "Sun",
ghostIndex,
direction: vec3(data.direction, { x: 0.57735, y: 0.57735, z: -0.57735 }),
color: color4(data.color, { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
ambient: color4(data.ambient, { r: 0.5, g: 0.5, b: 0.5, a: 1 }),
textures: Array.isArray(data.textures)
? (data.textures as string[])
: undefined,
};
}
export function missionAreaFromGhost(
ghostIndex: number,
data: GhostData,
): SceneMissionArea {
const area = data.area as
| { x: number; y: number; w: number; h: number }
| undefined;
return {
className: "MissionArea",
ghostIndex,
area: area ?? { x: -512, y: -512, w: 1024, h: 1024 },
flightCeiling: (data.flightCeiling as number) ?? 2000,
flightCeilingRange: (data.flightCeilingRange as number) ?? 50,
};
}
export function waterBlockFromGhost(
ghostIndex: number,
data: GhostData,
): SceneWaterBlock {
return {
className: "WaterBlock",
ghostIndex,
transform: matrixF(data.transform),
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
surfaceName: (data.surfaceName as string) ?? "",
envMapName: (data.envMapName as string) ?? "",
surfaceOpacity: (data.surfaceOpacity as number) ?? 0.75,
waveMagnitude: (data.waveMagnitude as number) ?? 1.0,
envMapIntensity: (data.envMapIntensity as number) ?? 1.0,
};
}
/** Convert a ghost update to a typed scene object, or null if not a scene type. */
export function ghostToSceneObject(
className: string,
ghostIndex: number,
data: GhostData,
): SceneObject | null {
switch (className) {
case "TerrainBlock":
return terrainFromGhost(ghostIndex, data);
case "InteriorInstance":
return interiorFromGhost(ghostIndex, data);
case "TSStatic":
return tsStaticFromGhost(ghostIndex, data);
case "Sky":
return skyFromGhost(ghostIndex, data);
case "Sun":
return sunFromGhost(ghostIndex, data);
case "MissionArea":
return missionAreaFromGhost(ghostIndex, data);
case "WaterBlock":
return waterBlockFromGhost(ghostIndex, data);
default:
return null;
}
}

25
src/scene/index.ts Normal file
View file

@ -0,0 +1,25 @@
export type {
Vec3,
Color3,
Color4,
MatrixF,
SceneTerrainBlock,
SceneInteriorInstance,
SceneTSStatic,
SceneSky,
SceneSkyFogVolume,
SceneSkyCloudLayer,
SceneSun,
SceneMissionArea,
SceneWaterBlock,
SceneObject,
} from "./types";
export { IDENTITY_MATRIX } from "./types";
export { ghostToSceneObject } from "./ghostToScene";
export { misToSceneObject } from "./misToScene";
export {
torqueToThree,
torqueScaleToThree,
matrixFToQuaternion,
torqueAxisAngleToQuaternion,
} from "./coordinates";

View file

@ -0,0 +1,248 @@
import { describe, it, expect } from "vitest";
import {
terrainFromMis,
interiorFromMis,
skyFromMis,
sunFromMis,
missionAreaFromMis,
waterBlockFromMis,
misToSceneObject,
} from "./misToScene";
import type { TorqueObject } from "../torqueScript";
function makeObj(
className: string,
props: Record<string, string>,
id = 0,
): TorqueObject {
const obj: TorqueObject = {
_class: className.toLowerCase(),
_className: className,
_name: "",
_id: id,
_children: [],
};
for (const [k, v] of Object.entries(props)) {
obj[k.toLowerCase()] = v;
}
return obj;
}
describe("terrainFromMis", () => {
it("extracts terrain properties", () => {
const obj = makeObj("TerrainBlock", {
terrainFile: "ice.ter",
detailTexture: "details/detail1.png",
squareSize: "8",
});
const result = terrainFromMis(obj);
expect(result.className).toBe("TerrainBlock");
expect(result.terrFileName).toBe("ice.ter");
expect(result.detailTextureName).toBe("details/detail1.png");
expect(result.squareSize).toBe(8);
});
it("uses defaults for missing properties", () => {
const result = terrainFromMis(makeObj("TerrainBlock", {}));
expect(result.terrFileName).toBe("");
expect(result.squareSize).toBe(8);
});
it("parses emptySquares", () => {
const obj = makeObj("TerrainBlock", {
terrainFile: "ice.ter",
emptySquares: "0 10 256 5",
});
const result = terrainFromMis(obj);
expect(result.emptySquareRuns).toEqual([0, 10, 256, 5]);
});
});
describe("interiorFromMis", () => {
it("builds transform from position and rotation", () => {
const obj = makeObj("InteriorInstance", {
interiorFile: "building.dif",
position: "100 200 300",
rotation: "0 0 1 90",
scale: "2 3 4",
});
const result = interiorFromMis(obj);
expect(result.className).toBe("InteriorInstance");
expect(result.interiorFile).toBe("building.dif");
// Position should be at elements[12..14]
const e = result.transform.elements;
expect(e[12]).toBeCloseTo(100);
expect(e[13]).toBeCloseTo(200);
expect(e[14]).toBeCloseTo(300);
// Position object should match
expect(result.transform.position).toEqual({ x: 100, y: 200, z: 300 });
// Scale
expect(result.scale).toEqual({ x: 2, y: 3, z: 4 });
});
it("handles identity rotation (0 0 1 0)", () => {
const obj = makeObj("InteriorInstance", {
interiorFile: "test.dif",
position: "10 20 30",
rotation: "1 0 0 0",
});
const result = interiorFromMis(obj);
const e = result.transform.elements;
// Should be identity rotation
expect(e[0]).toBeCloseTo(1);
expect(e[5]).toBeCloseTo(1);
expect(e[10]).toBeCloseTo(1);
// Off-diagonals should be 0
expect(e[1]).toBeCloseTo(0);
expect(e[2]).toBeCloseTo(0);
expect(e[4]).toBeCloseTo(0);
});
it("90° rotation around Z produces correct rotation matrix", () => {
const obj = makeObj("InteriorInstance", {
interiorFile: "test.dif",
position: "0 0 0",
rotation: "0 0 1 90",
});
const result = interiorFromMis(obj);
const e = result.transform.elements;
// Rotation around Z by 90°:
// cos(90°) = 0, sin(90°) = 1
// Row-major, idx = row + col*4:
// [0]=cos [4]=-sin [8]=0
// [1]=sin [5]=cos [9]=0
// [2]=0 [6]=0 [10]=1
expect(e[0]).toBeCloseTo(0, 4);
expect(e[1]).toBeCloseTo(1, 4);
expect(e[4]).toBeCloseTo(-1, 4);
expect(e[5]).toBeCloseTo(0, 4);
expect(e[10]).toBeCloseTo(1, 4);
});
it("180° rotation is self-consistent", () => {
const obj = makeObj("InteriorInstance", {
interiorFile: "test.dif",
position: "0 0 0",
rotation: "0 0 1 180",
});
const result = interiorFromMis(obj);
const e = result.transform.elements;
// cos(180°) = -1, sin(180°) = 0
expect(e[0]).toBeCloseTo(-1, 4);
expect(e[5]).toBeCloseTo(-1, 4);
expect(e[10]).toBeCloseTo(1, 4);
});
});
describe("skyFromMis", () => {
it("parses fog volumes", () => {
const obj = makeObj("Sky", {
materialList: "sky_ice.dml",
fogVolume1: "500 0 300",
fogVolume2: "0 0 0",
fogVolume3: "1000 100 500",
visibleDistance: "2000",
fogDistance: "500",
});
const result = skyFromMis(obj);
expect(result.className).toBe("Sky");
// fogVolume2 is all zeros, should be filtered out
expect(result.fogVolumes).toHaveLength(2);
expect(result.fogVolumes[0].visibleDistance).toBe(500);
expect(result.fogVolumes[0].maxHeight).toBe(300);
expect(result.fogVolumes[1].visibleDistance).toBe(1000);
});
it("parses cloud layers", () => {
const obj = makeObj("Sky", {
cloudText1: "cloud1.png",
cloudText2: "cloud2.png",
cloudText3: "",
"cloudheightper0": "0.35",
"cloudheightper1": "0.25",
cloudSpeed1: "0.001",
});
const result = skyFromMis(obj);
expect(result.cloudLayers).toHaveLength(3);
expect(result.cloudLayers[0].texture).toBe("cloud1.png");
expect(result.cloudLayers[0].heightPercent).toBeCloseTo(0.35);
expect(result.cloudLayers[0].speed).toBeCloseTo(0.001);
});
it("parses fog and sky colors", () => {
const obj = makeObj("Sky", {
fogColor: "0.5 0.6 0.7",
SkySolidColor: "0.1 0.2 0.3",
});
const result = skyFromMis(obj);
expect(result.fogColor).toEqual({ r: 0.5, g: 0.6, b: 0.7 });
expect(result.skySolidColor).toEqual({ r: 0.1, g: 0.2, b: 0.3 });
});
});
describe("sunFromMis", () => {
it("parses direction and colors", () => {
const obj = makeObj("Sun", {
direction: "0.57735 0.57735 -0.57735",
color: "0.8 0.8 0.7 1.0",
ambient: "0.3 0.3 0.4 1.0",
});
const result = sunFromMis(obj);
expect(result.direction.x).toBeCloseTo(0.57735);
expect(result.color).toEqual({ r: 0.8, g: 0.8, b: 0.7, a: 1.0 });
expect(result.ambient).toEqual({ r: 0.3, g: 0.3, b: 0.4, a: 1.0 });
});
});
describe("missionAreaFromMis", () => {
it("parses area string", () => {
const obj = makeObj("MissionArea", {
area: "-1024 -1024 2048 2048",
flightCeiling: "5000",
flightCeilingRange: "100",
});
const result = missionAreaFromMis(obj);
expect(result.area).toEqual({ x: -1024, y: -1024, w: 2048, h: 2048 });
expect(result.flightCeiling).toBe(5000);
expect(result.flightCeilingRange).toBe(100);
});
});
describe("waterBlockFromMis", () => {
it("extracts surface and env map", () => {
const obj = makeObj("WaterBlock", {
position: "0 0 50",
rotation: "1 0 0 0",
scale: "512 512 10",
surfaceTexture: "water.png",
envMapTexture: "envmap.png",
});
const result = waterBlockFromMis(obj);
expect(result.surfaceName).toBe("water.png");
expect(result.envMapName).toBe("envmap.png");
expect(result.scale).toEqual({ x: 512, y: 512, z: 10 });
});
});
describe("misToSceneObject", () => {
it("dispatches to correct converter by className", () => {
const terrain = misToSceneObject(
makeObj("TerrainBlock", { terrainFile: "test.ter" }),
);
expect(terrain?.className).toBe("TerrainBlock");
const interior = misToSceneObject(
makeObj("InteriorInstance", { interiorFile: "test.dif" }),
);
expect(interior?.className).toBe("InteriorInstance");
});
it("returns null for unknown className", () => {
expect(misToSceneObject(makeObj("Player", {}))).toBeNull();
expect(misToSceneObject(makeObj("SimGroup", {}))).toBeNull();
});
});

288
src/scene/misToScene.ts Normal file
View file

@ -0,0 +1,288 @@
/**
* Convert .mis TorqueObject data (string properties) into typed scene objects.
* This is the adapter layer that makes .mis data look like ghost parsedData.
*/
import type { TorqueObject } from "../torqueScript";
import type {
SceneTerrainBlock,
SceneInteriorInstance,
SceneTSStatic,
SceneSky,
SceneSun,
SceneMissionArea,
SceneWaterBlock,
SceneObject,
MatrixF,
Vec3,
Color3,
Color4,
SceneSkyFogVolume,
SceneSkyCloudLayer,
} from "./types";
// ── String parsing helpers ──
function prop(obj: TorqueObject, name: string): string | undefined {
return obj[name.toLowerCase()];
}
function propFloat(obj: TorqueObject, name: string): number | undefined {
const v = prop(obj, name);
if (v == null) return undefined;
const n = parseFloat(v);
return Number.isFinite(n) ? n : undefined;
}
function propInt(obj: TorqueObject, name: string): number | undefined {
const v = prop(obj, name);
if (v == null) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function parseVec3(s: string | undefined, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
x: parts[0] ?? fallback.x,
y: parts[1] ?? fallback.y,
z: parts[2] ?? fallback.z,
};
}
function parseColor3(s: string | undefined, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
r: parts[0] ?? fallback.r,
g: parts[1] ?? fallback.g,
b: parts[2] ?? fallback.b,
};
}
function parseColor4(
s: string | undefined,
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
): Color4 {
if (!s) return fallback;
const parts = s.split(" ").map(Number);
return {
r: parts[0] ?? fallback.r,
g: parts[1] ?? fallback.g,
b: parts[2] ?? fallback.b,
a: parts[3] ?? fallback.a,
};
}
/**
* Build a MatrixF from .mis position ("x y z") and rotation ("ax ay az angleDeg").
* Torque stores rotation as axis-angle in degrees.
*/
function buildMatrixF(
positionStr: string | undefined,
rotationStr: string | undefined,
): MatrixF {
const pos = parseVec3(positionStr);
const rotParts = (rotationStr ?? "1 0 0 0").split(" ").map(Number);
const ax = rotParts[0] ?? 1;
const ay = rotParts[1] ?? 0;
const az = rotParts[2] ?? 0;
const angleDeg = rotParts[3] ?? 0;
const angleRad = angleDeg * (Math.PI / 180);
// Normalize axis
const len = Math.sqrt(ax * ax + ay * ay + az * az);
let nx = 0, ny = 0, nz = 1;
if (len > 1e-8) {
nx = ax / len;
ny = ay / len;
nz = az / len;
}
// Axis-angle to rotation matrix (Rodrigues)
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
const t = 1 - c;
// Row-major MatrixF: idx(row, col) = row + col * 4
const elements = new Array<number>(16).fill(0);
elements[0] = t * nx * nx + c;
elements[1] = t * nx * ny + s * nz;
elements[2] = t * nx * nz - s * ny;
elements[4] = t * nx * ny - s * nz;
elements[5] = t * ny * ny + c;
elements[6] = t * ny * nz + s * nx;
elements[8] = t * nx * nz + s * ny;
elements[9] = t * ny * nz - s * nx;
elements[10] = t * nz * nz + c;
elements[12] = pos.x;
elements[13] = pos.y;
elements[14] = pos.z;
elements[15] = 1;
return { elements, position: pos };
}
function parseEmptySquares(s: string | undefined): number[] | undefined {
if (!s) return undefined;
const runs = s.split(/\s+/).map(Number).filter(Number.isFinite);
return runs.length > 0 ? runs : undefined;
}
function parseFogVolume(s: string | undefined): SceneSkyFogVolume | null {
if (!s) return null;
const parts = s.split(/\s+/).map(Number);
const visDist = parts[0] ?? 0;
const minH = parts[1] ?? 0;
const maxH = parts[2] ?? 0;
if (visDist === 0 && minH === 0 && maxH === 0) return null;
return {
visibleDistance: visDist,
minHeight: minH,
maxHeight: maxH,
color: { r: 0.5, g: 0.5, b: 0.5 }, // fogVolumeColor is cosmetic only in T2
};
}
// ── Conversion functions ──
export function terrainFromMis(obj: TorqueObject): SceneTerrainBlock {
return {
className: "TerrainBlock",
ghostIndex: obj._id,
terrFileName: prop(obj, "terrainFile") ?? "",
detailTextureName: prop(obj, "detailTexture") ?? "",
squareSize: propInt(obj, "squareSize") ?? 8,
emptySquareRuns: parseEmptySquares(prop(obj, "emptySquares")),
};
}
export function interiorFromMis(obj: TorqueObject): SceneInteriorInstance {
return {
className: "InteriorInstance",
ghostIndex: obj._id,
interiorFile: prop(obj, "interiorFile") ?? "",
transform: buildMatrixF(prop(obj, "position"), prop(obj, "rotation")),
scale: parseVec3(prop(obj, "scale"), { x: 1, y: 1, z: 1 }),
showTerrainInside: prop(obj, "showTerrainInside") === "1",
skinBase: prop(obj, "skinBase") ?? "",
alarmState: false,
};
}
export function tsStaticFromMis(obj: TorqueObject): SceneTSStatic {
return {
className: "TSStatic",
ghostIndex: obj._id,
shapeName: prop(obj, "shapeName") ?? "",
transform: buildMatrixF(prop(obj, "position"), prop(obj, "rotation")),
scale: parseVec3(prop(obj, "scale"), { x: 1, y: 1, z: 1 }),
};
}
export function skyFromMis(obj: TorqueObject): SceneSky {
const fogVolumes: SceneSkyFogVolume[] = [];
for (let i = 1; i <= 3; i++) {
const vol = parseFogVolume(prop(obj, `fogVolume${i}`));
if (vol) fogVolumes.push(vol);
}
const cloudLayers: SceneSkyCloudLayer[] = [];
for (let i = 0; i < 3; i++) {
const texture = prop(obj, `cloudText${i + 1}`) ?? "";
const heightPercent = propFloat(obj, `cloudHeightPer[${i}]`) ?? propFloat(obj, `cloudheightper${i}`) ?? [0.35, 0.25, 0.2][i];
const speed = propFloat(obj, `cloudSpeed${i + 1}`) ?? [0.0001, 0.0002, 0.0003][i];
cloudLayers.push({ texture, heightPercent, speed });
}
return {
className: "Sky",
ghostIndex: obj._id,
materialList: prop(obj, "materialList") ?? "",
fogColor: parseColor3(prop(obj, "fogColor")),
visibleDistance: propFloat(obj, "visibleDistance") ?? 1000,
fogDistance: propFloat(obj, "fogDistance") ?? 0,
skySolidColor: parseColor3(prop(obj, "SkySolidColor")),
useSkyTextures: (propInt(obj, "useSkyTextures") ?? 1) !== 0,
fogVolumes,
cloudLayers,
windVelocity: parseVec3(prop(obj, "windVelocity")),
};
}
export function sunFromMis(obj: TorqueObject): SceneSun {
return {
className: "Sun",
ghostIndex: obj._id,
direction: parseVec3(prop(obj, "direction"), {
x: 0.57735,
y: 0.57735,
z: -0.57735,
}),
color: parseColor4(prop(obj, "color"), { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
ambient: parseColor4(prop(obj, "ambient"), {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1,
}),
};
}
export function missionAreaFromMis(obj: TorqueObject): SceneMissionArea {
const areaStr = prop(obj, "area");
let area = { x: -512, y: -512, w: 1024, h: 1024 };
if (areaStr) {
const parts = areaStr.split(/\s+/).map(Number);
area = {
x: parts[0] ?? area.x,
y: parts[1] ?? area.y,
w: parts[2] ?? area.w,
h: parts[3] ?? area.h,
};
}
return {
className: "MissionArea",
ghostIndex: obj._id,
area,
flightCeiling: propFloat(obj, "flightCeiling") ?? 2000,
flightCeilingRange: propFloat(obj, "flightCeilingRange") ?? 50,
};
}
export function waterBlockFromMis(obj: TorqueObject): SceneWaterBlock {
return {
className: "WaterBlock",
ghostIndex: obj._id,
transform: buildMatrixF(prop(obj, "position"), prop(obj, "rotation")),
scale: parseVec3(prop(obj, "scale"), { x: 1, y: 1, z: 1 }),
surfaceName: prop(obj, "surfaceTexture") ?? "",
envMapName: prop(obj, "envMapTexture") ?? "",
surfaceOpacity: propFloat(obj, "surfaceOpacity") ?? 0.75,
waveMagnitude: propFloat(obj, "waveMagnitude") ?? 1.0,
envMapIntensity: propFloat(obj, "envMapIntensity") ?? 1.0,
};
}
/** Convert a .mis TorqueObject to a typed scene object based on className. */
export function misToSceneObject(obj: TorqueObject): SceneObject | null {
switch (obj._className) {
case "TerrainBlock":
return terrainFromMis(obj);
case "InteriorInstance":
return interiorFromMis(obj);
case "TSStatic":
return tsStaticFromMis(obj);
case "Sky":
return skyFromMis(obj);
case "Sun":
return sunFromMis(obj);
case "MissionArea":
return missionAreaFromMis(obj);
case "WaterBlock":
return waterBlockFromMis(obj);
default:
return null;
}
}

131
src/scene/types.ts Normal file
View file

@ -0,0 +1,131 @@
/** 3D vector in Torque coordinate space (X-right, Y-forward, Z-up). */
export interface Vec3 {
x: number;
y: number;
z: number;
}
export interface Color3 {
r: number;
g: number;
b: number;
}
export interface Color4 {
r: number;
g: number;
b: number;
a: number;
}
/**
* Row-major 4×4 transform matrix as used by Torque's MatrixF.
* Index formula: idx(row, col) = row + col * 4.
* Position is at elements[12], elements[13], elements[14].
*/
export interface MatrixF {
elements: number[];
position: Vec3;
}
/** Identity MatrixF. */
export const IDENTITY_MATRIX: MatrixF = {
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
position: { x: 0, y: 0, z: 0 },
};
// ── Mission scene object types ──
// These match the ghost parsedData structures from t2-demo-parser.
export interface SceneTerrainBlock {
className: "TerrainBlock";
ghostIndex: number;
terrFileName: string;
detailTextureName: string;
squareSize: number;
emptySquareRuns?: number[];
}
export interface SceneInteriorInstance {
className: "InteriorInstance";
ghostIndex: number;
interiorFile: string;
transform: MatrixF;
scale: Vec3;
showTerrainInside: boolean;
skinBase: string;
alarmState: boolean;
}
export interface SceneTSStatic {
className: "TSStatic";
ghostIndex: number;
shapeName: string;
transform: MatrixF;
scale: Vec3;
}
export interface SceneSkyFogVolume {
visibleDistance: number;
minHeight: number;
maxHeight: number;
color: Color3;
}
export interface SceneSkyCloudLayer {
texture: string;
heightPercent: number;
speed: number;
}
export interface SceneSky {
className: "Sky";
ghostIndex: number;
materialList: string;
fogColor: Color3;
visibleDistance: number;
fogDistance: number;
skySolidColor: Color3;
useSkyTextures: boolean;
fogVolumes: SceneSkyFogVolume[];
cloudLayers: SceneSkyCloudLayer[];
windVelocity: Vec3;
}
export interface SceneSun {
className: "Sun";
ghostIndex: number;
direction: Vec3;
color: Color4;
ambient: Color4;
textures?: string[];
}
export interface SceneMissionArea {
className: "MissionArea";
ghostIndex: number;
area: { x: number; y: number; w: number; h: number };
flightCeiling: number;
flightCeilingRange: number;
}
export interface SceneWaterBlock {
className: "WaterBlock";
ghostIndex: number;
transform: MatrixF;
scale: Vec3;
surfaceName: string;
envMapName: string;
surfaceOpacity: number;
waveMagnitude: number;
envMapIntensity: number;
}
export type SceneObject =
| SceneTerrainBlock
| SceneInteriorInstance
| SceneTSStatic
| SceneSky
| SceneSun
| SceneMissionArea
| SceneWaterBlock;