250 lines
13 KiB
TypeScript
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;
|
|
});
|
|
}
|