import { Float32BufferAttribute, Group, Mesh, type Object3D, } from "three"; import type { DungeonTraversalRamp } from "../game/dungeonTypes"; import { smoothDungeonStairGeometry } from "../game/dungeonTraversalRamps"; export interface PreparedCollisionTrimesh { readonly id: string; readonly vertices: ArrayLike; readonly indices: ArrayLike; } // A surface must be within 30 degrees of vertical to occlude combat sight. // This deliberately rejects floors, ramps, slopes, and ceilings. const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5; export function prepareCollision(root: Object3D, ramps: readonly DungeonTraversalRamp[] = []): Group { root.updateWorldMatrix(true, true); const inverseRootMatrix = root.matrixWorld.clone().invert(); const prepared = new Group(); prepared.name = `${root.name || "collision"}-prepared`; root.traverse((node) => { const mesh = node as Mesh; if (!mesh.isMesh) return; const geometry = mesh.geometry.clone(); const position = geometry.getAttribute("position"); if (position.normalized || !(position.array instanceof Float32Array)) { const dequantized = new Float32Array(position.count * position.itemSize); for (let vertex = 0; vertex < position.count; vertex += 1) { const offset = vertex * position.itemSize; dequantized[offset] = position.getX(vertex); if (position.itemSize > 1) dequantized[offset + 1] = position.getY(vertex); if (position.itemSize > 2) dequantized[offset + 2] = position.getZ(vertex); if (position.itemSize > 3) dequantized[offset + 3] = position.getW(vertex); } geometry.setAttribute( "position", new Float32BufferAttribute(dequantized, position.itemSize), ); } geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld)); if (ramps.length) smoothDungeonStairGeometry(geometry, ramps); const preparedMesh = new Mesh(geometry, mesh.material); preparedMesh.name = mesh.name; preparedMesh.visible = false; prepared.add(preparedMesh); }); return prepared; } export function collisionTrimeshes(root: Object3D): readonly PreparedCollisionTrimesh[] { const result: PreparedCollisionTrimesh[] = []; root.traverse((node) => { const mesh = node as Mesh; if (!mesh.isMesh) return; const position = mesh.geometry.getAttribute("position"); const index = mesh.geometry.index; result.push({ id: mesh.uuid, vertices: position.array, indices: index?.array ?? Uint32Array.from({ length: position.count }, (_, vertex) => vertex), }); }); return result; } export function collisionLineOfSightTrimeshes(root: Object3D): readonly PreparedCollisionTrimesh[] { const result: PreparedCollisionTrimesh[] = []; root.traverse((node) => { const mesh = node as Mesh; if (!mesh.isMesh) return; const position = mesh.geometry.getAttribute("position"); const sourceIndices = mesh.geometry.index?.array ?? Uint32Array.from({ length: position.count }, (_, vertex) => vertex); const blockerSourceIndices: number[] = []; for (let offset = 0; offset + 2 < sourceIndices.length; offset += 3) { const first = Number(sourceIndices[offset]); const second = Number(sourceIndices[offset + 1]); const third = Number(sourceIndices[offset + 2]); const firstX = position.getX(first); const firstY = position.getY(first); const firstZ = position.getZ(first); const abX = position.getX(second) - firstX; const abY = position.getY(second) - firstY; const abZ = position.getZ(second) - firstZ; const acX = position.getX(third) - firstX; const acY = position.getY(third) - firstY; const acZ = position.getZ(third) - firstZ; const normalX = abY * acZ - abZ * acY; const normalY = abZ * acX - abX * acZ; const normalZ = abX * acY - abY * acX; const normalLength = Math.hypot(normalX, normalY, normalZ); if (normalLength <= Number.EPSILON) continue; if (Math.abs(normalY) / normalLength > MAX_SIGHT_BLOCKER_NORMAL_Y) continue; blockerSourceIndices.push(first, second, third); } if (!blockerSourceIndices.length) return; const remappedVertices: number[] = []; const remappedIndices = new Uint32Array(blockerSourceIndices.length); const remap = new Map(); blockerSourceIndices.forEach((sourceIndex, indexOffset) => { let remappedIndex = remap.get(sourceIndex); if (remappedIndex === undefined) { remappedIndex = remap.size; remap.set(sourceIndex, remappedIndex); remappedVertices.push( position.getX(sourceIndex), position.getY(sourceIndex), position.getZ(sourceIndex), ); } remappedIndices[indexOffset] = remappedIndex; }); result.push({ id: `${mesh.uuid}-line-of-sight`, vertices: Float32Array.from(remappedVertices), indices: remappedIndices, }); }); return result; }