import { Matrix3, Matrix4, Vector3 } from "three"; import { descriptorSourceMatrix } from "../lib/environment-assembly.mjs"; const NORMAL_EPSILON = 1e-12; export function sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit) { const meters = Number(metersPerSourceUnit); if (!Number.isFinite(meters) || meters <= 0) { throw new Error("metersPerSourceUnit must be a positive finite number."); } const sourceToThree = new Matrix4().makeScale(meters, meters, -meters); return sourceToThree.multiply(descriptorSourceMatrix(primaryPlacement).invert()); } function checkArrayLength(array, expectedLength, label) { if (!ArrayBuffer.isView(array) || array.length !== expectedLength) { throw new Error(`${label} has ${array?.length ?? "no"} values; expected ${expectedLength}.`); } } function transformPosition(sourcePosition, matrix) { const position = new Vector3().fromArray(sourcePosition).applyMatrix4(matrix); if (![position.x, position.y, position.z].every(Number.isFinite)) { throw new Error("Terrain position transform produced a non-finite coordinate."); } return position.toArray(); } function transformNormal(sourceNormal, normalMatrix) { const normal = new Vector3().fromArray(sourceNormal).applyMatrix3(normalMatrix); const lengthSq = normal.lengthSq(); if (!Number.isFinite(lengthSq) || lengthSq < NORMAL_EPSILON) { throw new Error("Terrain source contains an invalid native normal."); } return normal.normalize().toArray(); } function triangleAreaSquared(positions, a, b, c) { const ax = positions[a * 3]; const ay = positions[a * 3 + 1]; const az = positions[a * 3 + 2]; const abx = positions[b * 3] - ax; const aby = positions[b * 3 + 1] - ay; const abz = positions[b * 3 + 2] - az; const acx = positions[c * 3] - ax; const acy = positions[c * 3 + 1] - ay; const acz = positions[c * 3 + 2] - az; const nx = aby * acz - abz * acy; const ny = abz * acx - abx * acz; const nz = abx * acy - aby * acx; return nx * nx + ny * ny + nz * nz; } function faceNormalDot(positions, normals, a, b, c) { const ax = positions[a * 3]; const ay = positions[a * 3 + 1]; const az = positions[a * 3 + 2]; const abx = positions[b * 3] - ax; const aby = positions[b * 3 + 1] - ay; const abz = positions[b * 3 + 2] - az; const acx = positions[c * 3] - ax; const acy = positions[c * 3 + 1] - ay; const acz = positions[c * 3 + 2] - az; let nx = aby * acz - abz * acy; let ny = abz * acx - abx * acz; let nz = abx * acy - aby * acx; const length = Math.hypot(nx, ny, nz); if (length < NORMAL_EPSILON) return null; nx /= length; ny /= length; nz /= length; const sx = normals[a * 3] + normals[b * 3] + normals[c * 3]; const sy = normals[a * 3 + 1] + normals[b * 3 + 1] + normals[c * 3 + 1]; const sz = normals[a * 3 + 2] + normals[b * 3 + 2] + normals[c * 3 + 2]; const normalLength = Math.hypot(sx, sy, sz); if (normalLength < NORMAL_EPSILON) return null; return (nx * sx + ny * sy + nz * sz) / normalLength; } /** * Converts the extracted native grid arrays without changing their vertex grid, * texture coordinates, or source per-vertex lighting normals. Winding is reversed * once because the Runewaker-to-Three bridge reflects Z. */ export function convertNativeTerrainChunks(chunks, sourceToThree, options = {}) { if (!Array.isArray(chunks) || chunks.length === 0) { throw new Error("Native terrain conversion requires at least one chunk."); } const positionMatrix = sourceToThree.clone(); const normalMatrix = new Matrix3().getNormalMatrix(positionMatrix); const convertedChunks = []; const perChunkSummaries = []; const edgeVertices = new Map(); const seamPositionToleranceMeters = Number(options.seamPositionToleranceMeters ?? 0.02); let vertexOffset = 0; let sourceNormalLengthMin = Number.POSITIVE_INFINITY; let sourceNormalLengthMax = 0; let sourceNormalLengthSum = 0; let sourceNormalLengthCount = 0; let faceAlignedCount = 0; let faceOpposedCount = 0; let faceDegenerateCount = 0; let sourceTriangleSlots = 0; let visibleSourceTriangles = 0; let maskedSourceTriangles = 0; let collisionTriangles = 0; let excludedCollisionTriangles = 0; let maxSeamPositionDeltaMeters = 0; let maxSeamNormalDelta = 0; let seamNormalPairs = 0; let seamNormalPairsWithinOneChannelStep = 0; const dimensions = new Set(chunks.map((chunk) => Number(chunk.dimension))); if (dimensions.size !== 1) throw new Error("All native terrain chunks must have the same grid dimension."); const dimension = [...dimensions][0]; if (!Number.isInteger(dimension) || dimension < 1) throw new Error("Terrain chunk dimension is invalid."); const side = dimension + 1; const expectedVertices = side * side; if (expectedVertices > 0x10000) { throw new Error("A native terrain chunk exceeds its 16-bit source index contract."); } // Each source chunk uses 16-bit local indices. The combined GLB uses global // offsets, which need 32 bits when more than fifteen 65×65 grids are present. const IndexArray = expectedVertices * chunks.length > 0x10000 ? Uint32Array : Uint16Array; for (const chunk of chunks) { const chunkDimension = Number(chunk.dimension); const vertexCount = (chunkDimension + 1) ** 2; checkArrayLength(chunk.positions, vertexCount * 3, `${chunk.id} positions`); checkArrayLength(chunk.uvs, vertexCount * 2, `${chunk.id} UVs`); checkArrayLength(chunk.normals, vertexCount * 3, `${chunk.id} native normals`); if (!Number.isInteger(Number(chunk.coordinate?.x)) || !Number.isInteger(Number(chunk.coordinate?.z))) { throw new Error(`${chunk.id} requires integer chunk coordinates.`); } if (chunk.indices.length % 3 !== 0) throw new Error(`${chunk.id} indices are not triangular.`); const triangleCount = chunk.indices.length / 3; checkArrayLength(chunk.triangleVisibilityFlags, triangleCount, `${chunk.id} triangle visibility`); const convertedPositions = new Float32Array(vertexCount * 3); const convertedNormals = new Float32Array(vertexCount * 3); const collisionIndices = []; const visualIndices = new IndexArray(chunk.indices.length); let chunkVisibleTriangles = 0; let chunkMaskedTriangles = 0; let chunkAlignedFaces = 0; let chunkOpposedFaces = 0; let chunkDegenerateFaces = 0; for (let vertex = 0; vertex < vertexCount; vertex += 1) { const positionOffset = vertex * 3; const uvOffset = vertex * 2; const sourcePosition = [ Number(chunk.positions[positionOffset]), Number(chunk.positions[positionOffset + 1]), Number(chunk.positions[positionOffset + 2]), ]; const sourceNormal = [ Number(chunk.normals[positionOffset]), Number(chunk.normals[positionOffset + 1]), Number(chunk.normals[positionOffset + 2]), ]; const outputPosition = transformPosition(sourcePosition, positionMatrix); const outputNormal = transformNormal(sourceNormal, normalMatrix); convertedPositions.set(outputPosition, positionOffset); convertedNormals.set(outputNormal, positionOffset); const sourceLength = Math.hypot(...sourceNormal); sourceNormalLengthMin = Math.min(sourceNormalLengthMin, sourceLength); sourceNormalLengthMax = Math.max(sourceNormalLengthMax, sourceLength); sourceNormalLengthSum += sourceLength; sourceNormalLengthCount += 1; const localX = vertex % side; const localZ = Math.floor(vertex / side); const globalGridKey = `${Number(chunk.coordinate.x) * dimension + localX},${Number(chunk.coordinate.z) * dimension + localZ}`; const previous = edgeVertices.get(globalGridKey); if (previous) { const dx = previous.position[0] - outputPosition[0]; const dy = previous.position[1] - outputPosition[1]; const dz = previous.position[2] - outputPosition[2]; const positionDelta = Math.hypot(dx, dy, dz); maxSeamPositionDeltaMeters = Math.max(maxSeamPositionDeltaMeters, positionDelta); if (positionDelta > seamPositionToleranceMeters) { throw new Error(`${chunk.id} disagrees with a neighbor at terrain grid vertex ${globalGridKey} by ${positionDelta}m.`); } const normalDelta = Math.hypot( previous.normal[0] - outputNormal[0], previous.normal[1] - outputNormal[1], previous.normal[2] - outputNormal[2], ); seamNormalPairs += 1; maxSeamNormalDelta = Math.max(maxSeamNormalDelta, normalDelta); if (normalDelta <= (2 * Math.sqrt(3) / 255) + 1e-6) seamNormalPairsWithinOneChannelStep += 1; } else { edgeVertices.set(globalGridKey, { position: outputPosition, normal: outputNormal }); } if (!Number.isFinite(chunk.uvs[uvOffset]) || !Number.isFinite(chunk.uvs[uvOffset + 1])) { throw new Error(`${chunk.id} contains non-finite texture coordinates.`); } } for (let triangle = 0; triangle < triangleCount; triangle += 1) { const offset = triangle * 3; const a = Number(chunk.indices[offset]); const b = Number(chunk.indices[offset + 1]); const c = Number(chunk.indices[offset + 2]); if ([a, b, c].some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) { throw new Error(`${chunk.id} triangle ${triangle} has an invalid source vertex index.`); } const visible = Number(chunk.triangleVisibilityFlags[triangle]); if (visible !== 0 && visible !== 1) throw new Error(`${chunk.id} triangle ${triangle} has invalid visibility flag ${visible}.`); const outA = vertexOffset + a; const outB = vertexOffset + b; const outC = vertexOffset + c; // Keep the original masked degenerate slots, including [0,0,0], in visual geometry. visualIndices[offset] = outA; visualIndices[offset + 1] = outC; visualIndices[offset + 2] = outB; sourceTriangleSlots += 1; if (visible === 0) { chunkMaskedTriangles += 1; maskedSourceTriangles += 1; excludedCollisionTriangles += 1; continue; } chunkVisibleTriangles += 1; visibleSourceTriangles += 1; if (a === b || b === c || c === a || triangleAreaSquared(convertedPositions, a, b, c) < 1e-24) { excludedCollisionTriangles += 1; chunkDegenerateFaces += 1; continue; } collisionIndices.push(outA, outC, outB); collisionTriangles += 1; const dot = faceNormalDot(convertedPositions, convertedNormals, a, c, b); if (dot === null) { faceDegenerateCount += 1; chunkDegenerateFaces += 1; } else if (dot >= 0) { faceAlignedCount += 1; chunkAlignedFaces += 1; } else { faceOpposedCount += 1; chunkOpposedFaces += 1; } } convertedChunks.push({ ...chunk, vertexOffset, positions: convertedPositions, normals: convertedNormals, uvs: new Float32Array(chunk.uvs), visualIndices, collisionIndices: IndexArray.from(collisionIndices), }); perChunkSummaries.push({ id: chunk.id, coordinate: chunk.coordinate, vertexCount, sourceTriangleSlots: triangleCount, visibleSourceTriangles: chunkVisibleTriangles, maskedSourceTriangles: chunkMaskedTriangles, collisionTriangles: collisionIndices.length / 3, excludedCollisionTriangles: chunkMaskedTriangles + chunkDegenerateFaces, sourceTriangleVisibilitySha256: chunk.triangleVisibilitySha256 ?? null, sourceLayers: chunk.layers?.map((layer) => ({ layerId: layer.layerId, tileScale: layer.tileScale, sourceDDS: layer.sourceDDS, sourceClientPath: layer.sourceClientPath, })) ?? [], sourceWeights: chunk.splatMaps?.map((weight) => ({ mapIndex: weight.mapIndex, sourceDDS: weight.splatDDS, linearPngFile: weight.linearPngFile, channelLayerIds: weight.channelLayerIds, })) ?? [], nativeNormalWinding: { alignedTriangles: chunkAlignedFaces, opposedTriangles: chunkOpposedFaces, degenerateTriangles: chunkDegenerateFaces, }, }); vertexOffset += expectedVertices; } return { chunks: convertedChunks, vertexCount: vertexOffset, report: { chunkCount: chunks.length, verticesPerChunk: expectedVertices, sourceVertices: vertexOffset, sourceTriangleSlots, visibleSourceTriangles, maskedSourceTriangles, collisionTriangles, excludedCollisionTriangles, positionWelding: { performed: false, reason: "Native normal records can differ at chunk boundaries; retain independent source vertices and normals.", sharedGridVertexPairs: seamNormalPairs, seamPositionToleranceMeters, maxSharedPositionDeltaMeters: maxSeamPositionDeltaMeters, maxSharedNativeNormalDelta: maxSeamNormalDelta, normalPairsWithinOneEightBitChannelStep: seamNormalPairsWithinOneChannelStep, normalPairCount: seamNormalPairs, }, sourceNativeNormals: { minLength: sourceNormalLengthMin, meanLength: sourceNormalLengthSum / Math.max(1, sourceNormalLengthCount), maxLength: sourceNormalLengthMax, transform: "inverse-transpose of primary descriptor TRS and reflected-Z meter bridge; normalized", }, windingValidation: { rule: "swap triangle indices 1 and 2 after reflected-Z coordinate bridge", trianglesWithNativeNormalAgreement: faceAlignedCount, trianglesAgainstNativeNormal: faceOpposedCount, degenerateTriangles: faceDegenerateCount, }, chunks: perChunkSummaries, }, }; }