Files
2026-09-13 11:07:56 -04:00

250 lines
13 KiB
TypeScript

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<BufferGeometry[]> {
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<string, Riser>();
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<Riser>();
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<string, number>();
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<string, { a: number; b: number; count: number }>();
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;
});
}