147 lines
6.5 KiB
JavaScript
147 lines
6.5 KiB
JavaScript
import path from "node:path";
|
|
|
|
export const TREKKER_WATER_FOLIAGE_EXCLUSION_MASK = 0x00a00000;
|
|
|
|
export function normalizeNativeResourcePath(resource) {
|
|
if (typeof resource !== "string" || !resource.trim()) {
|
|
throw new Error("Native collision resource path must be a non-empty string.");
|
|
}
|
|
const normalized = resource.replaceAll("/", "\\").replace(/^\\+/, "");
|
|
const parts = normalized.split("\\");
|
|
if (parts.some((part) => !part || part === "." || part === "..")) {
|
|
throw new Error(`Unsafe native collision resource path: ${resource}`);
|
|
}
|
|
return parts.join("\\");
|
|
}
|
|
|
|
export function nativeResourceFile(resourceRoot, resource) {
|
|
const normalized = normalizeNativeResourcePath(resource);
|
|
const file = path.resolve(resourceRoot, ...normalized.split("\\"));
|
|
const relative = path.relative(path.resolve(resourceRoot), file);
|
|
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
|
throw new Error(`Native resource escaped the resource root: ${resource}`);
|
|
}
|
|
return file;
|
|
}
|
|
|
|
export function transformNativeCollisionPoint(source, columnMajorMatrix, metersPerSourceUnit) {
|
|
if (!Array.isArray(source) && !ArrayBuffer.isView(source)) throw new Error("Source point must contain three coordinates.");
|
|
if (source.length !== 3 || !Array.isArray(columnMajorMatrix) || columnMajorMatrix.length !== 16) {
|
|
throw new Error("Native collision transform requires a three-value point and 16-value matrix.");
|
|
}
|
|
if (!Number.isFinite(metersPerSourceUnit) || metersPerSourceUnit <= 0) {
|
|
throw new Error("Meters per source unit must be a finite positive number.");
|
|
}
|
|
const x = Number(source[0]) * metersPerSourceUnit;
|
|
const y = Number(source[1]) * metersPerSourceUnit;
|
|
const z = -Number(source[2]) * metersPerSourceUnit;
|
|
const point = [
|
|
columnMajorMatrix[0] * x + columnMajorMatrix[4] * y + columnMajorMatrix[8] * z + columnMajorMatrix[12],
|
|
columnMajorMatrix[1] * x + columnMajorMatrix[5] * y + columnMajorMatrix[9] * z + columnMajorMatrix[13],
|
|
columnMajorMatrix[2] * x + columnMajorMatrix[6] * y + columnMajorMatrix[10] * z + columnMajorMatrix[14],
|
|
];
|
|
if (point.some((value) => !Number.isFinite(value))) throw new Error("Native collision placement produced a non-finite point.");
|
|
return point;
|
|
}
|
|
|
|
export function transformNativeCollisionTriangle(sourcePoints, columnMajorMatrix, metersPerSourceUnit) {
|
|
if (!Array.isArray(sourcePoints) || sourcePoints.length !== 3) {
|
|
throw new Error("Native collision triangle must contain exactly three points.");
|
|
}
|
|
const [a, b, c] = sourcePoints.map((point) => (
|
|
transformNativeCollisionPoint(point, columnMajorMatrix, metersPerSourceUnit)
|
|
));
|
|
// The source-to-Three bridge reflects Z. Reverse winding exactly once here.
|
|
return [a, c, b];
|
|
}
|
|
|
|
export function triangleTileKey(points, tileSizeMeters) {
|
|
if (!Number.isFinite(tileSizeMeters) || tileSizeMeters <= 0) throw new Error("Tile size must be finite and positive.");
|
|
const centerX = (points[0][0] + points[1][0] + points[2][0]) / 3;
|
|
const centerZ = (points[0][2] + points[1][2] + points[2][2]) / 3;
|
|
return `${Math.floor(centerX / tileSizeMeters)}:${Math.floor(centerZ / tileSizeMeters)}`;
|
|
}
|
|
|
|
export function isTrekkerPathEligible(triangleFlag) {
|
|
return ((Number(triangleFlag) >>> 0) & TREKKER_WATER_FOLIAGE_EXCLUSION_MASK) === 0;
|
|
}
|
|
|
|
export class NativeCollisionPartAccumulator {
|
|
constructor(maxTriangles) {
|
|
if (!Number.isSafeInteger(maxTriangles) || maxTriangles < 1 || maxTriangles > 90_000) {
|
|
throw new Error("Native collision GLB part size must be an integer from 1 to 90,000 triangles.");
|
|
}
|
|
this.maxTriangles = maxTriangles;
|
|
this.positions = new Float32Array(maxTriangles * 9);
|
|
this.flags = [];
|
|
this.flagCounts = new Map();
|
|
}
|
|
|
|
get triangleCount() {
|
|
return this.flags.length;
|
|
}
|
|
|
|
add(points, flag) {
|
|
if (this.triangleCount >= this.maxTriangles) throw new Error("Flush the full native collision part before adding another triangle.");
|
|
if (!Array.isArray(points) || points.length !== 3 || points.some((point) => point.length !== 3 || [...point].some((value) => !Number.isFinite(Number(value))))) {
|
|
throw new Error("Native collision part requires a finite 3D triangle.");
|
|
}
|
|
const offset = this.triangleCount * 9;
|
|
this.positions.set([...points[0], ...points[1], ...points[2]], offset);
|
|
const normalizedFlag = Number(flag) >>> 0;
|
|
this.flags.push(normalizedFlag);
|
|
this.flagCounts.set(normalizedFlag, (this.flagCounts.get(normalizedFlag) ?? 0) + 1);
|
|
return this.triangleCount === this.maxTriangles ? this.finish() : null;
|
|
}
|
|
|
|
finish() {
|
|
if (this.triangleCount === 0) return null;
|
|
const result = {
|
|
triangleCount: this.triangleCount,
|
|
positions: this.positions.slice(0, this.triangleCount * 9),
|
|
sourceTriangleFlags: this.flags.slice(),
|
|
flagCounts: Object.fromEntries([...this.flagCounts.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
|
|
};
|
|
this.positions = new Float32Array(this.maxTriangles * 9);
|
|
this.flags = [];
|
|
this.flagCounts = new Map();
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export function validateNativeCollisionCounts({ triangleCount, flagBytes, positionVertexCount }) {
|
|
if (!Number.isSafeInteger(triangleCount) || triangleCount < 0) throw new Error("Native collision report has an invalid triangle count.");
|
|
if (flagBytes !== triangleCount * 4) {
|
|
throw new Error(`Native triangle flags contain ${flagBytes} bytes, expected ${triangleCount * 4}.`);
|
|
}
|
|
if (positionVertexCount !== triangleCount * 3) {
|
|
throw new Error(`Native collision OBJ contains ${positionVertexCount} vertices, expected ${triangleCount * 3}.`);
|
|
}
|
|
}
|
|
|
|
export function renderMeshOmissions(report) {
|
|
const omitted = [];
|
|
for (const entity of report.entityNodes ?? []) {
|
|
for (const [meshIndex, kind] of (entity.meshCollisionTypes ?? []).entries()) {
|
|
if (kind === "unsupported mesh class") {
|
|
omitted.push({
|
|
entity: entity.name,
|
|
entityType: entity.type,
|
|
meshIndex,
|
|
reason: "unsupported mesh class in native collision traversal",
|
|
entityCollisionObjectReplacesChildren: entity.hasCollisionObject,
|
|
});
|
|
} else if (String(kind).startsWith("none(serialized)")) {
|
|
omitted.push({
|
|
entity: entity.name,
|
|
entityType: entity.type,
|
|
meshIndex,
|
|
reason: "no serialized mesh collision structure; render-mesh fallback is excluded",
|
|
entityCollisionObjectReplacesChildren: entity.hasCollisionObject,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return omitted;
|
|
}
|