import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { MeshoptDecoder } from "meshoptimizer"; import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Matrix4 } from "three"; import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes"; import { dungeonRampHeight } from "../../src/game/dungeonTraversalRamps.ts"; const io = new NodeIO().registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); /** Decode exactly as GLTFLoader does, expanding quantized coordinates before baking transforms. */ export async function readDungeonGeometry(file: string): Promise { await MeshoptDecoder.ready; const document = await io.read(file); const geometries: BufferGeometry[] = []; for (const node of document.getRoot().listNodes()) { for (const primitive of node.getMesh()?.listPrimitives() ?? []) { const accessor = primitive.getAttribute("POSITION"); if (!accessor?.getArray()) continue; const source = new BufferAttribute(accessor.getArray()!, 3, accessor.getNormalized()); const positions = new Float32Array(source.count * 3); for (let index = 0; index < source.count; index += 1) { positions.set([source.getX(index), source.getY(index), source.getZ(index)], index * 3); } const geometry = new BufferGeometry(); geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); const indices = primitive.getIndices()?.getArray(); if (indices) geometry.setIndex(Array.from(indices)); geometry.applyMatrix4(new Matrix4().fromArray(node.getWorldMatrix())); geometries.push(geometry); } } return geometries; } interface Riser { direction: number; along: number; low: number; high: number; left: number; right: number; } /** Repeated short vertical faces, not arbitrary slopes or nearby disconnected floors. */ export function dungeonStairRisers(geometry: BufferGeometry): Riser[] { const p = geometry.getAttribute("position"); const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i); const faces = new Map(); for (let offset = 0; offset + 2 < indices.length; offset += 3) { const vertices = [indices[offset], indices[offset + 1], indices[offset + 2]]; const points = vertices.map((i) => [p.getX(i), p.getY(i), p.getZ(i)]); const [a, b, c] = points; const ab = b.map((v, i) => v - a[i]); const ac = c.map((v, i) => v - a[i]); const nx = ab[1] * ac[2] - ab[2] * ac[1]; const ny = ab[2] * ac[0] - ab[0] * ac[2]; const nz = ab[0] * ac[1] - ab[1] * ac[0]; if (Math.hypot(nx, nz) < 0.1 || Math.abs(ny) / Math.hypot(nx, ny, nz) > 0.025) continue; const low = Math.min(...points.map((v) => v[1])); const high = Math.max(...points.map((v) => v[1])); if (high - low < 0.08 || high - low > 0.82) continue; // Opposite winding still describes the same plane. Quantization permits ~1 degree noise. const direction = ((Math.round(Math.atan2(nz, nx) / Math.PI * 180) % 180) + 180) % 180; const angle = direction * Math.PI / 180; const along = points.reduce((sum, v) => sum + v[0] * Math.cos(angle) + v[2] * Math.sin(angle), 0) / 3; const across = points.map((v) => -v[0] * Math.sin(angle) + v[2] * Math.cos(angle)); const left = Math.min(...across); const right = Math.max(...across); if (right - left < 0.9) continue; const key = [direction, Math.round(along / 0.04), Math.round(low / 0.04), Math.round(high / 0.04)].join(":"); const previous = faces.get(key); if (previous && Math.min(previous.right, right) - Math.max(previous.left, left) >= -0.04) { previous.left = Math.min(previous.left, left); previous.right = Math.max(previous.right, right); } else faces.set(`${key}${previous ? `:${Math.round(left / 0.04)}` : ""}`, { direction, along, low, high, left, right }); } return [...faces.values()]; } export function detectDungeonStairRamps(risers: Riser[], merge = true): DungeonTraversalRamp[] { const ramps: DungeonTraversalRamp[] = []; const used = new Set(); const sorted = [...risers].sort((a, b) => a.direction - b.direction || a.along - b.along || a.low - b.low); for (const first of sorted) { if (used.has(first)) continue; const neighbors = sorted.filter((r) => Math.min(Math.abs(r.direction - first.direction), 180 - Math.abs(r.direction - first.direction)) <= 2) .map((source) => { const difference = (source.direction - first.direction) * Math.PI / 180; const across = (source.left + source.right) / 2; const halfWidth = (source.right - source.left) / 2 * Math.abs(Math.cos(difference)); const center = source.along * Math.sin(difference) + across * Math.cos(difference); return { source, ...source, along: source.along * Math.cos(difference) - across * Math.sin(difference), left: center - halfWidth, right: center + halfWidth }; }).sort((a, b) => a.along - b.along || a.low - b.low); const chain = [first]; const chainSources = [first]; let sign = 0; while (true) { const current = chain[chain.length - 1]; const next = neighbors.find((candidate) => { if (used.has(candidate.source)) return false; const run = candidate.along - current.along; const rise = candidate.low - current.low; const overlap = Math.min(...chain.map((r) => r.right), candidate.right) - Math.max(...chain.map((r) => r.left), candidate.left); return run > 0.12 && run < 2.5 && Math.abs(rise) >= 0.08 && Math.abs(rise) <= 0.82 && Math.abs(rise) / run < 0.85 && overlap >= 1 && (!sign || Math.sign(rise) === sign) && Math.min(Math.abs(candidate.low - current.high), Math.abs(candidate.high - current.low)) < 0.22; }); if (!next) break; if (chain.length > 1) { const run = chain[1].along - first.along; const rise = chain[1].low - first.low; if (Math.abs(next.low - (first.low + rise / run * (next.along - first.along))) > 0.1) break; } chain.push(next); chainSources.push(next.source); sign = Math.sign(next.low - current.low); } if (chain.length < 3) continue; const last = chain[chain.length - 1]; const run = (last.along - first.along) / (chain.length - 1); const rise = (last.low - first.low) / (chain.length - 1); const left = Math.max(...chain.map((r) => r.left)); const right = Math.min(...chain.map((r) => r.right)); const across = (left + right) / 2; const angle = first.direction * Math.PI / 180; const point = (along: number, y: number): [number, number, number] => [ along * Math.cos(angle) - across * Math.sin(angle), y, along * Math.sin(angle) + across * Math.cos(angle), ]; // Meet the lower/upper landings, including the first/last riser, without a lip. ramps.push({ id: `stair-flight-${ramps.length + 1}`, start: point(first.along - (sign < 0 ? run : 0.04), sign < 0 ? first.high : first.low), end: point(last.along + (sign > 0 ? run : 0.04), sign > 0 ? last.high : last.low), width: right - left + 0.02, }); chainSources.forEach((r) => used.add(r)); } return merge ? mergeDungeonStairRamps(ramps) : ramps; } /** Keep one plane per continuous flight; independent overlapping fragments introduce new lips. */ export function mergeDungeonStairRamps(input: DungeonTraversalRamp[]): DungeonTraversalRamp[] { const ramps = [...input].sort((a, b) => Math.hypot(b.end[0] - b.start[0], b.end[2] - b.start[2]) - Math.hypot(a.end[0] - a.start[0], a.end[2] - a.start[2])); const result: DungeonTraversalRamp[] = []; for (const ramp of ramps) { let combined = false; for (let index = 0; index < result.length; index += 1) { const previous = result[index]; const dx = previous.end[0] - previous.start[0]; const dz = previous.end[2] - previous.start[2]; const length = Math.hypot(dx, dz); const rdX = ramp.end[0] - ramp.start[0]; const rdZ = ramp.end[2] - ramp.start[2]; const rLength = Math.hypot(rdX, rdZ); if ((dx * rdX + dz * rdZ) / length / rLength < 0.999) continue; const along = (p: readonly number[]) => ((p[0] - previous.start[0]) * dx + (p[2] - previous.start[2]) * dz) / length; const across = (p: readonly number[]) => (-(p[0] - previous.start[0]) * dz + (p[2] - previous.start[2]) * dx) / length; const start = along(ramp.start); const end = along(ramp.end); const acrossOffset = (across(ramp.start) + across(ramp.end)) / 2; const left = Math.max(-previous.width / 2, acrossOffset - ramp.width / 2); const right = Math.min(previous.width / 2, acrossOffset + ramp.width / 2); if (right - left < 1 || start > length + 1.5 || end < -1.5) continue; const slope = (previous.end[1] - previous.start[1]) / length; const rSlope = (ramp.end[1] - ramp.start[1]) / rLength; if (Math.abs(slope - rSlope) > 0.12 || Math.abs(ramp.start[1] - (previous.start[1] + start * slope)) > 0.32 || Math.abs(ramp.end[1] - (previous.start[1] + end * slope)) > 0.32) continue; const low = Math.min(0, start); const high = Math.max(length, end); const center = (left + right) / 2; const point = (t: number, y: number): [number, number, number] => [ previous.start[0] + dx / length * t - dz / length * center, y, previous.start[2] + dz / length * t + dx / length * center, ]; result[index] = { id: previous.id, start: point(low, start < 0 ? ramp.start[1] : previous.start[1]), end: point(high, end > length ? ramp.end[1] : previous.end[1]), width: right - left, }; combined = true; break; } if (!combined) result.push(ramp); } return result.map((ramp, index) => ({ ...ramp, id: `stair-flight-${index + 1}` })); } /** Fill only separate Recast boundary portals wholly inside a verified stair footprint. */ export function dungeonStairNavigationBridges(geometry: BufferGeometry, ramps: DungeonTraversalRamp[]): DungeonTraversalRamp[] { const p = geometry.getAttribute("position"); const indices = geometry.index?.array ?? Array.from({ length: p.count }, (_, i) => i); const parents = Array.from({ length: p.count }, (_, i) => i); const root = (i: number): number => { while (parents[i] !== i) { parents[i] = parents[parents[i]]; i = parents[i]; } return i; }; const weld = new Map(); const point = (i: number): [number, number, number] => [p.getX(i), p.getY(i), p.getZ(i)]; for (let i = 0; i < p.count; i += 1) { const key = point(i).map((v) => Math.round(v / 0.002)).join(":"); const previous = weld.get(key); if (previous !== undefined) parents[i] = previous; else weld.set(key, i); } const edges = new Map(); for (let offset = 0; offset + 2 < indices.length; offset += 3) { const triangle = [indices[offset], indices[offset + 1], indices[offset + 2]]; for (let side = 0; side < 3; side += 1) { const a = triangle[side]; const b = triangle[(side + 1) % 3]; const wa = weld.get(point(a).map((v) => Math.round(v / 0.002)).join(":"))!; const wb = weld.get(point(b).map((v) => Math.round(v / 0.002)).join(":"))!; parents[root(a)] = root(b); const key = wa < wb ? `${wa}:${wb}` : `${wb}:${wa}`; const edge = edges.get(key); if (edge) edge.count += 1; else edges.set(key, { a, b, count: 1 }); } } const boundary = [...edges.values()].filter((e) => e.count === 1); return ramps.map((ramp) => { const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2]; const length = Math.hypot(dx, dz); const along = (v: number[]) => ((v[0] - ramp.start[0]) * dx + (v[2] - ramp.start[2]) * dz) / length; const across = (v: number[]) => (-(v[0] - ramp.start[0]) * dz + (v[2] - ramp.start[2]) * dx) / length; const portals = boundary.flatMap((e) => { const points = [point(e.a), point(e.b)].sort((a, b) => across(a) - across(b)); if (points.some((v) => { const y = dungeonRampHeight(ramp, v[0], v[2]); return y === null || Math.abs(v[1] - y) > 0.8; })) return []; if (Math.abs(along(points[0]) - along(points[1])) > 0.15 || across(points[1]) - across(points[0]) < 0.7) return []; return [{ ...e, points: points as [[number, number, number], [number, number, number]], along: (along(points[0]) + along(points[1])) / 2 }]; }).sort((a, b) => a.along - b.along); const bridges = []; for (let index = 1; index < portals.length; index += 1) { const a = portals[index - 1]; const b = portals[index]; const gap = b.along - a.along; if (gap < 0.15 || gap > 2.5 || root(a.a) === root(b.a)) continue; const overlap = Math.min(across(a.points[1]), across(b.points[1])) - Math.max(across(a.points[0]), across(b.points[0])); if (overlap < 0.7) continue; bridges.push({ linkId: `${ramp.id}-seam-${bridges.length + 1}`, startPortal: a.points, endPortal: b.points }); parents[root(a.a)] = root(b.a); } return bridges.length ? { ...ramp, navigationBridges: bridges } : ramp; }); }