import type { BufferGeometry } from "three"; import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes"; // Only the stair relief is reshaped. Upper walls, ceilings and other floors // remain untouched even where they share the staircase's horizontal footprint. const MAX_STAIR_SURFACE_DELTA = 0.85; const PORTAL_VERTEX_TOLERANCE = 0.002; export function dungeonRampHeight( ramp: DungeonTraversalRamp, x: number, z: number, ): number | null { const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2]; const length = Math.hypot(dx, dz); if ( ![...ramp.start, ...ramp.end, ramp.width].every(Number.isFinite) || length <= 0 || ramp.width <= 0 ) { throw new Error(`Invalid dungeon traversal ramp: ${ramp.id}.`); } const offsetX = x - ramp.start[0]; const offsetZ = z - ramp.start[2]; const along = (offsetX * dx + offsetZ * dz) / length; const across = (offsetX * dz - offsetZ * dx) / length; if (along < 0 || along > length || Math.abs(across) > ramp.width * 0.5) return null; return ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length; } /** Flatten tread/riser relief onto a shared plane, without removing walls. */ export function smoothDungeonStairGeometry( geometry: BufferGeometry, ramps: readonly DungeonTraversalRamp[], surfaceClearance = 0, ): void { const position = geometry.getAttribute("position"); // Fleet packages can contain many short flights and hundreds of thousands of // vertices. Index their footprints once rather than checking every flight // (and validating/recomputing its direction) for every collision vertex. const cellSize = 16; const cells = new Map(); for (const ramp of ramps) { dungeonRampHeight(ramp, ramp.start[0], ramp.start[2]); const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2]; const length = Math.hypot(dx, dz); const xPadding = Math.abs(dz) / length * ramp.width / 2; const zPadding = Math.abs(dx) / length * ramp.width / 2; for (let x = Math.floor((Math.min(ramp.start[0], ramp.end[0]) - xPadding) / cellSize); x <= Math.floor((Math.max(ramp.start[0], ramp.end[0]) + xPadding) / cellSize); x += 1) { for (let z = Math.floor((Math.min(ramp.start[2], ramp.end[2]) - zPadding) / cellSize); z <= Math.floor((Math.max(ramp.start[2], ramp.end[2]) + zPadding) / cellSize); z += 1) { const key = `${x}:${z}`; const bucket = cells.get(key) ?? []; bucket.push({ ramp, dx, dz, length }); cells.set(key, bucket); } } } for (let vertex = 0; vertex < position.count; vertex += 1) { const x = position.getX(vertex); const z = position.getZ(vertex); for (const { ramp, dx, dz, length } of cells.get(`${Math.floor(x / cellSize)}:${Math.floor(z / cellSize)}`) ?? []) { const offsetX = x - ramp.start[0]; const offsetZ = z - ramp.start[2]; const along = (offsetX * dx + offsetZ * dz) / length; const across = (offsetX * dz - offsetZ * dx) / length; if (along < 0 || along > length || Math.abs(across) > ramp.width / 2) continue; const height = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length; if (Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue; position.setY(vertex, height + surfaceClearance); break; } } } /** Stitch reviewed boundary edges, so routing can use filled triangles rather than vertex-only links. */ export function bridgeDungeonStairNavigation( geometry: BufferGeometry, ramps: readonly DungeonTraversalRamp[], surfaceClearance: number, ): void { const position = geometry.getAttribute("position"); const indices = geometry.index ? Array.from(geometry.index.array) : Array.from({ length: position.count }, (_, vertex) => vertex); const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => { const height = dungeonRampHeight(ramp, point[0], point[2]); if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`); for (let vertex = 0; vertex < position.count; vertex += 1) { if (Math.hypot( position.getX(vertex) - point[0], position.getY(vertex) - height - surfaceClearance, position.getZ(vertex) - point[2], ) <= PORTAL_VERTEX_TOLERANCE) return vertex; } throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`); }; for (const ramp of ramps) { for (const bridge of dungeonRampNavigationBridges(ramp)) { const [a, b] = bridge.startPortal.map((point) => portalVertex(ramp, point)); const [c, d] = bridge.endPortal.map((point) => portalVertex(ramp, point)); indices.push(a, b, d, a, d, c); } } geometry.setIndex(indices); } export function dungeonRampNavigationBridges(ramp: DungeonTraversalRamp) { return [...(ramp.navigationBridge ? [ramp.navigationBridge] : []), ...(ramp.navigationBridges ?? [])]; }