mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-16 10:50:57 +00:00
begin live server support
This commit is contained in:
parent
0c9ddb476a
commit
e4ae265184
368 changed files with 17756 additions and 7738 deletions
211
src/scene/coordinates.spec.ts
Normal file
211
src/scene/coordinates.spec.ts
Normal 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
113
src/scene/coordinates.ts
Normal 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
|
||||
* Torque→Three.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,
|
||||
);
|
||||
}
|
||||
171
src/scene/crossValidation.spec.ts
Normal file
171
src/scene/crossValidation.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
159
src/scene/ghostToScene.spec.ts
Normal file
159
src/scene/ghostToScene.spec.ts
Normal 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
231
src/scene/ghostToScene.ts
Normal 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
25
src/scene/index.ts
Normal 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";
|
||||
248
src/scene/misToScene.spec.ts
Normal file
248
src/scene/misToScene.spec.ts
Normal 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
288
src/scene/misToScene.ts
Normal 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
131
src/scene/types.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue