339 lines
9.7 KiB
TypeScript
339 lines
9.7 KiB
TypeScript
import { BufferGeometry, Float32BufferAttribute } from "three";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildPartyNavigationGraph,
|
|
findNearestPartyNavigationNode,
|
|
findNearestPartyNavigationSurface,
|
|
findPartyNavigationPath,
|
|
} from "./partyNavigation";
|
|
|
|
type Point = readonly [number, number, number];
|
|
type Triangle = readonly [number, number, number];
|
|
|
|
function triangleGeometry(points: readonly Point[], triangles: readonly Triangle[]): BufferGeometry {
|
|
const geometry = new BufferGeometry();
|
|
geometry.setAttribute("position", new Float32BufferAttribute(points.flat(), 3));
|
|
geometry.setIndex(triangles.flat());
|
|
return geometry;
|
|
}
|
|
|
|
function twoCorridorGraph() {
|
|
return buildPartyNavigationGraph(triangleGeometry([
|
|
[-1, 0, 1],
|
|
[0, 0, 0],
|
|
[-1, 0, -1],
|
|
[1, 0, 1],
|
|
[0, 0, -2],
|
|
[1, 0, -1],
|
|
], [
|
|
[0, 1, 2], // start
|
|
[0, 3, 1], // short upper corridor
|
|
[1, 4, 2], // alternate lower corridor, first half
|
|
[1, 5, 4], // alternate lower corridor, second half
|
|
[1, 3, 5], // end
|
|
]));
|
|
}
|
|
|
|
describe("party navigation graph", () => {
|
|
it("rejects oversized source geometry before building the graph", () => {
|
|
const geometry = triangleGeometry([
|
|
[0, 0, 0],
|
|
[1, 0, 0],
|
|
[0, 0, 1],
|
|
[1, 0, 1],
|
|
], [
|
|
[0, 1, 2],
|
|
[1, 3, 2],
|
|
]);
|
|
|
|
expect(() => buildPartyNavigationGraph(geometry, { maxSourceTriangles: 1 }))
|
|
.toThrow("Party navigation geometry has 2 triangles; maximum is 1.");
|
|
expect(buildPartyNavigationGraph(geometry, { maxSourceTriangles: 2 }).sourceTriangleCount)
|
|
.toBe(2);
|
|
});
|
|
|
|
it("welds duplicate seam vertices within one millimeter", () => {
|
|
const geometry = triangleGeometry([
|
|
[0, 0, 0],
|
|
[1, 0, 0],
|
|
[0, 0, 1],
|
|
[1.0005, 0, 0],
|
|
[1, 0, 1],
|
|
[0, 0, 1.0005],
|
|
], [
|
|
[0, 1, 2],
|
|
[3, 4, 5],
|
|
]);
|
|
|
|
const graph = buildPartyNavigationGraph(geometry);
|
|
expect(graph).toMatchObject({
|
|
sourceVertexCount: 6,
|
|
sourceTriangleCount: 2,
|
|
weldedNodeCount: 4,
|
|
componentCount: 1,
|
|
discardedNodeCount: 0,
|
|
});
|
|
expect(graph.nodes).toHaveLength(4);
|
|
expect(graph.triangles).toEqual([
|
|
[0, 1, 2],
|
|
[1, 3, 2],
|
|
]);
|
|
expect(graph.triangleNodes?.[0].neighbors).toEqual([
|
|
{ to: 1, portal: [1, 2], cost: expect.any(Number) },
|
|
]);
|
|
expect(findPartyNavigationPath(graph, [0, 0, 0], [1, 0, 1])).toHaveLength(3);
|
|
});
|
|
|
|
it("retains the deterministic largest component and ignores disconnected islands", () => {
|
|
const geometry = triangleGeometry([
|
|
[0, 0, 0],
|
|
[1, 0, 0],
|
|
[1, 0, 1],
|
|
[0, 0, 1],
|
|
[10, 0, 10],
|
|
[11, 0, 10],
|
|
[10, 0, 11],
|
|
], [
|
|
[0, 1, 2],
|
|
[0, 2, 3],
|
|
[4, 5, 6],
|
|
]);
|
|
|
|
const graph = buildPartyNavigationGraph(geometry);
|
|
expect(graph).toMatchObject({
|
|
weldedNodeCount: 7,
|
|
componentCount: 2,
|
|
discardedNodeCount: 3,
|
|
});
|
|
expect(graph.nodes).toHaveLength(4);
|
|
const retainedTriangles = graph.triangles ?? [];
|
|
expect(retainedTriangles).toHaveLength(2);
|
|
expect(retainedTriangles.flat().every((nodeId) => nodeId < graph.nodes.length)).toBe(true);
|
|
expect(graph.nodes.every((node) => node.position[0] <= 1 && node.position[2] <= 1)).toBe(true);
|
|
const nearestIsland = findNearestPartyNavigationNode(graph, [10, 0, 10]);
|
|
expect(nearestIsland?.node.position).toEqual([1, 0, 1]);
|
|
expect(findPartyNavigationPath(graph, [0, 0, 0], [1, 0, 1])).not.toBeNull();
|
|
});
|
|
|
|
it("retains and routes across reviewed off-mesh links between disconnected surfaces", () => {
|
|
const geometry = triangleGeometry([
|
|
[0, 0, 0], [1, 0, 0], [0, 0, 1],
|
|
[10, 0, 10], [11, 0, 10], [10, 0, 11],
|
|
], [[0, 1, 2], [3, 4, 5]]);
|
|
const graph = buildPartyNavigationGraph(geometry, {
|
|
offMeshLinks: [{ start: [1, 0, 0], end: [10, 0, 10], bidirectional: true }],
|
|
});
|
|
|
|
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
|
|
expect(graph.triangles).toBeUndefined();
|
|
expect(findNearestPartyNavigationSurface(graph, [0.25, 0.5, 0.25], 1)).toMatchObject({
|
|
triangleId: -1,
|
|
distance: expect.any(Number),
|
|
});
|
|
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
|
|
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
|
|
});
|
|
|
|
it("honors one-way off-mesh links", () => {
|
|
const geometry = triangleGeometry([
|
|
[0, 0, 0], [1, 0, 0], [0, 0, 1],
|
|
[10, 0, 10], [11, 0, 10], [10, 0, 11],
|
|
], [[0, 1, 2], [3, 4, 5]]);
|
|
const graph = buildPartyNavigationGraph(geometry, {
|
|
offMeshLinks: [{ start: [1, 0, 0], end: [10, 0, 10], bidirectional: false }],
|
|
});
|
|
|
|
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
|
|
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).toBeNull();
|
|
});
|
|
|
|
it("measures endpoint snaps against triangle surfaces instead of distant vertices", () => {
|
|
const graph = buildPartyNavigationGraph(triangleGeometry([
|
|
[0, 0, 0],
|
|
[10, 0, 0],
|
|
[0, 0, 10],
|
|
], [[0, 1, 2]]));
|
|
|
|
expect(findPartyNavigationPath(graph, [4, 1, 4], [5, 1, 3], {
|
|
maxSnapDistance: 1,
|
|
})).toEqual([
|
|
[4, 0, 4],
|
|
[5, 0, 3],
|
|
]);
|
|
expect(findPartyNavigationPath(graph, [4, 1, 4], [5, 1, 3], {
|
|
maxSnapDistance: 0.99,
|
|
})).toBeNull();
|
|
});
|
|
|
|
it("projects an off-surface companion spawn onto the filled navigation surface", () => {
|
|
const graph = buildPartyNavigationGraph(triangleGeometry([
|
|
[0, 0, 0],
|
|
[10, 0, 0],
|
|
[0, 0, 10],
|
|
], [[0, 1, 2]]));
|
|
|
|
expect(findNearestPartyNavigationSurface(graph, [3, 0.75, 3], 1)).toMatchObject({
|
|
position: [3, 0, 3],
|
|
distance: 0.75,
|
|
});
|
|
expect(findNearestPartyNavigationSurface(graph, [3, 1.01, 3], 1)).toBeNull();
|
|
});
|
|
|
|
it("supports independent start and end surface-snap limits", () => {
|
|
const graph = buildPartyNavigationGraph(triangleGeometry([
|
|
[0, 0, 0],
|
|
[10, 0, 0],
|
|
[0, 0, 10],
|
|
], [[0, 1, 2]]));
|
|
const start: Point = [4, 2, 4];
|
|
const end: Point = [5, 0.5, 3];
|
|
|
|
expect(findPartyNavigationPath(graph, start, end, {
|
|
maxSnapDistance: 10,
|
|
maxStartSnapDistance: 1,
|
|
})).toBeNull();
|
|
expect(findPartyNavigationPath(graph, start, end, {
|
|
maxSnapDistance: 10,
|
|
maxStartSnapDistance: 3,
|
|
maxEndSnapDistance: 0.25,
|
|
})).toBeNull();
|
|
expect(findPartyNavigationPath(graph, start, end, {
|
|
maxSnapDistance: 0,
|
|
maxStartSnapDistance: 3,
|
|
maxEndSnapDistance: 1,
|
|
})).toEqual([
|
|
[4, 0, 4],
|
|
[5, 0, 3],
|
|
]);
|
|
});
|
|
|
|
it("preserves every centered portal in a projected triangle corridor", () => {
|
|
const graph = buildPartyNavigationGraph(triangleGeometry([
|
|
[0, 0, 0],
|
|
[4, 0, 0],
|
|
[0, 0, 4],
|
|
[4, 0, 4],
|
|
[8, 0, 0],
|
|
[8, 0, 4],
|
|
], [
|
|
[0, 1, 2],
|
|
[1, 3, 2],
|
|
[1, 4, 3],
|
|
[4, 5, 3],
|
|
]));
|
|
|
|
const route = findPartyNavigationPath(graph, [1, 2, 1], [7, 3, 3], {
|
|
// Portal points remain mandatory even when ordinary waypoint
|
|
// simplification would otherwise collapse the entire straight section.
|
|
simplifyTolerance: 100,
|
|
});
|
|
expect(route).toEqual([
|
|
[1, 0, 1],
|
|
[2, 0, 2],
|
|
[4, 0, 2],
|
|
[6, 0, 2],
|
|
[7, 0, 3],
|
|
]);
|
|
});
|
|
|
|
it("accepts short shared-edge portals without an arbitrary width cutoff", () => {
|
|
const graph = buildPartyNavigationGraph(triangleGeometry([
|
|
[0, 0, 0.001],
|
|
[1, 0, 0],
|
|
[1, 0, 0.002],
|
|
[2, 0, 0.001],
|
|
], [
|
|
[0, 1, 2],
|
|
[1, 3, 2],
|
|
]));
|
|
|
|
const route = findPartyNavigationPath(graph, [0.5, 0.5, 0.001], [1.5, 0.5, 0.001], {
|
|
maxSnapDistance: 0.5,
|
|
});
|
|
expect(route).toHaveLength(3);
|
|
expect(route?.[1][0]).toBe(1);
|
|
expect(route?.[1][1]).toBe(0);
|
|
expect(route?.[1][2]).toBeCloseTo(0.001, 6);
|
|
});
|
|
|
|
it("reroutes through the longer corridor when the preferred exit is rejected", () => {
|
|
const graph = twoCorridorGraph();
|
|
expect(findPartyNavigationPath(graph, [-0.75, 1, 0], [0.75, 1, 0])).toEqual([
|
|
[-0.75, 0, 0],
|
|
[-0.5, 0, 0.5],
|
|
[0.5, 0, 0.5],
|
|
[0.75, 0, 0],
|
|
]);
|
|
|
|
const route = findPartyNavigationPath(
|
|
graph,
|
|
[-0.75, 1, 0],
|
|
[0.75, 1, 0],
|
|
{
|
|
routeSegmentAllowed: (start, end) => !(
|
|
Math.abs(start[2]) < 0.01 && end[2] > 0.4
|
|
),
|
|
},
|
|
);
|
|
|
|
expect(route).toEqual([
|
|
[-0.75, 0, 0],
|
|
[-0.5, 0, -0.5],
|
|
[0, 0, -1],
|
|
[0.5, 0, -0.5],
|
|
[0.75, 0, 0],
|
|
]);
|
|
});
|
|
|
|
it("returns null when collision validation rejects every corridor", () => {
|
|
expect(findPartyNavigationPath(
|
|
twoCorridorGraph(),
|
|
[-0.75, 1, 0],
|
|
[0.75, 1, 0],
|
|
{
|
|
routeSegmentAllowed: (start, end) => !(
|
|
Math.abs(start[2]) < 0.01 && Math.abs(end[2]) > 0.4
|
|
),
|
|
},
|
|
)).toBeNull();
|
|
});
|
|
|
|
it("uses A* to route around a hole without simplifying through the obstacle", () => {
|
|
const geometry = triangleGeometry([
|
|
[0, 0, 1], // left endpoint
|
|
[0, 0, 2],
|
|
[1, 0, 1.5],
|
|
[1, 0, 0.5],
|
|
[0, 0, 0],
|
|
[4, 0, 2],
|
|
[3, 0, 1.5],
|
|
[4, 0, 1], // right endpoint
|
|
[3, 0, 0.5],
|
|
[4, 0, 0],
|
|
], [
|
|
[0, 1, 2],
|
|
[0, 2, 3],
|
|
[0, 3, 4],
|
|
[1, 5, 2],
|
|
[5, 6, 2],
|
|
[7, 6, 5],
|
|
[7, 8, 6],
|
|
[7, 9, 8],
|
|
[4, 3, 9],
|
|
[3, 8, 9],
|
|
]);
|
|
|
|
const graph = buildPartyNavigationGraph(geometry);
|
|
const route = findPartyNavigationPath(graph, [0, 0, 1], [4, 0, 1]);
|
|
expect(route).toEqual([
|
|
[0, 0, 1],
|
|
[0.5, 0, 1.75],
|
|
[2.5, 0, 1.75],
|
|
[3.5, 0, 1.75],
|
|
[4, 0, 1],
|
|
]);
|
|
expect(route?.slice(1, -1).every((point) => point[2] >= 1.5 || point[2] <= 0.5)).toBe(true);
|
|
expect(findPartyNavigationPath(graph, [0, 0, 1], [4, 0, 1])).toEqual(route);
|
|
});
|
|
});
|