2026-05-13 16:37:33 +02:00
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
|
|
|
|
import { createScenePath } from "../../src/document/paths";
|
|
|
|
|
import { createSplineCorridorJunction } from "../../src/document/spline-corridor-junctions";
|
|
|
|
|
import {
|
|
|
|
|
buildSplineCorridorJunctionFootprint,
|
2026-05-13 17:05:55 +02:00
|
|
|
isSplineCorridorJunctionFootprintRoadMouthEdge,
|
2026-05-13 16:37:33 +02:00
|
|
|
sampleSplineCorridorJunctionFootprintInfluence
|
|
|
|
|
} from "../../src/geometry/spline-corridor-junction-footprint";
|
2026-05-13 16:53:24 +02:00
|
|
|
import {
|
|
|
|
|
buildSplineCorridorJunctionEdgeMeshData,
|
2026-05-13 18:53:57 +02:00
|
|
|
buildSplineCorridorJunctionMeshGeometry,
|
|
|
|
|
resolveSplineCorridorJunctionRoadEdgeSeams
|
2026-05-13 16:53:24 +02:00
|
|
|
} from "../../src/geometry/spline-corridor-junction-mesh";
|
2026-05-13 16:37:33 +02:00
|
|
|
|
|
|
|
|
function createRoadPath(
|
|
|
|
|
id: string,
|
|
|
|
|
points: Array<{ x: number; y?: number; z: number }>
|
|
|
|
|
) {
|
|
|
|
|
return createScenePath({
|
|
|
|
|
id,
|
|
|
|
|
road: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
width: 2,
|
|
|
|
|
shoulderWidth: 1,
|
|
|
|
|
heightOffset: 0.1
|
|
|
|
|
},
|
|
|
|
|
points: points.map((point, index) => ({
|
|
|
|
|
id: `${id}-point-${index}`,
|
|
|
|
|
position: {
|
|
|
|
|
x: point.x,
|
|
|
|
|
y: point.y ?? 0,
|
|
|
|
|
z: point.z
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("spline corridor junction mesh generation", () => {
|
|
|
|
|
it("builds a connection-shaped footprint from clipped corridor boundaries", () => {
|
|
|
|
|
const pathA = createRoadPath("path-junction-footprint-a", [
|
|
|
|
|
{ x: 0, z: 0 },
|
|
|
|
|
{ x: 10, z: 0 }
|
|
|
|
|
]);
|
|
|
|
|
const pathB = createRoadPath("path-junction-footprint-b", [
|
|
|
|
|
{ x: 5, z: -5 },
|
|
|
|
|
{ x: 5, z: 5 }
|
|
|
|
|
]);
|
|
|
|
|
const junction = createSplineCorridorJunction({
|
|
|
|
|
id: "junction-footprint",
|
|
|
|
|
center: { x: 5, y: 0, z: 0 },
|
|
|
|
|
radius: 3,
|
|
|
|
|
connections: [
|
|
|
|
|
{
|
|
|
|
|
pathId: pathA.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
pathId: pathB.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const footprint = buildSplineCorridorJunctionFootprint({
|
|
|
|
|
junction,
|
|
|
|
|
paths: [pathA, pathB]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(footprint).not.toBeNull();
|
|
|
|
|
expect(footprint?.points).toHaveLength(8);
|
|
|
|
|
expect(footprint?.bounds).toEqual({
|
|
|
|
|
minX: 3,
|
|
|
|
|
maxX: 7,
|
|
|
|
|
minZ: -2,
|
|
|
|
|
maxZ: 2
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("builds junction fill geometry from the connection footprint instead of a circle", () => {
|
|
|
|
|
const pathA = createRoadPath("path-junction-mesh-a", [
|
|
|
|
|
{ x: 0, z: 0 },
|
|
|
|
|
{ x: 10, z: 0 }
|
|
|
|
|
]);
|
|
|
|
|
const pathB = createRoadPath("path-junction-mesh-b", [
|
|
|
|
|
{ x: 5, z: -5 },
|
|
|
|
|
{ x: 5, z: 5 }
|
|
|
|
|
]);
|
|
|
|
|
const junction = createSplineCorridorJunction({
|
|
|
|
|
id: "junction-mesh",
|
|
|
|
|
center: { x: 5, y: 0, z: 0 },
|
|
|
|
|
radius: 3,
|
|
|
|
|
connections: [
|
|
|
|
|
{
|
|
|
|
|
pathId: pathA.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
pathId: pathB.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const geometry = buildSplineCorridorJunctionMeshGeometry({
|
|
|
|
|
junction,
|
|
|
|
|
paths: [pathA, pathB]
|
|
|
|
|
});
|
|
|
|
|
const positionAttribute = geometry?.getAttribute("position");
|
|
|
|
|
|
|
|
|
|
expect(positionAttribute?.count).toBe(9);
|
|
|
|
|
expect(Array.from(positionAttribute?.array ?? [])).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
3,
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
-1,
|
|
|
|
|
7,
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
1,
|
|
|
|
|
4,
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
-2,
|
|
|
|
|
6,
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
2
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 18:38:17 +02:00
|
|
|
it("builds junction perimeter edges with exact road-mouth seam rows", () => {
|
2026-05-13 16:53:50 +02:00
|
|
|
const pathA = createScenePath({
|
|
|
|
|
id: "path-junction-edge-a",
|
|
|
|
|
road: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
width: 2,
|
|
|
|
|
shoulderWidth: 1,
|
|
|
|
|
heightOffset: 0.1,
|
|
|
|
|
edges: {
|
|
|
|
|
left: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
collisionEnabled: false,
|
|
|
|
|
kind: "curb",
|
|
|
|
|
width: 0.4,
|
|
|
|
|
height: 0.2,
|
|
|
|
|
materialId: "curb-material"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
points: [
|
|
|
|
|
{
|
|
|
|
|
id: "path-junction-edge-a-start",
|
|
|
|
|
position: { x: 0, y: 0, z: 0 }
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "path-junction-edge-a-end",
|
|
|
|
|
position: { x: 10, y: 0, z: 0 }
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
const pathB = createRoadPath("path-junction-edge-b", [
|
|
|
|
|
{ x: 5, z: -5 },
|
|
|
|
|
{ x: 5, z: 5 }
|
|
|
|
|
]);
|
|
|
|
|
const junction = createSplineCorridorJunction({
|
|
|
|
|
id: "junction-edge",
|
|
|
|
|
center: { x: 5, y: 0, z: 0 },
|
|
|
|
|
radius: 3,
|
2026-05-13 17:20:35 +02:00
|
|
|
edge: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
collisionEnabled: false,
|
|
|
|
|
kind: "curb",
|
|
|
|
|
width: 0.4,
|
|
|
|
|
height: 0.2,
|
|
|
|
|
materialId: "curb-material"
|
|
|
|
|
},
|
2026-05-13 16:53:50 +02:00
|
|
|
connections: [
|
|
|
|
|
{
|
|
|
|
|
pathId: pathA.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
pathId: pathB.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const meshData = buildSplineCorridorJunctionEdgeMeshData({
|
|
|
|
|
junction,
|
|
|
|
|
paths: [pathA, pathB],
|
2026-05-13 17:20:35 +02:00
|
|
|
edge: junction.edge
|
2026-05-13 16:53:50 +02:00
|
|
|
});
|
2026-05-13 17:05:55 +02:00
|
|
|
const footprint = buildSplineCorridorJunctionFootprint({
|
|
|
|
|
junction,
|
|
|
|
|
paths: [pathA, pathB]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (footprint === null) {
|
|
|
|
|
throw new Error("Expected a junction footprint.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const roadMouthSegmentCount = footprint.points.filter((point, index) =>
|
|
|
|
|
isSplineCorridorJunctionFootprintRoadMouthEdge(
|
|
|
|
|
point,
|
|
|
|
|
footprint.points[(index + 1) % footprint.points.length]!
|
|
|
|
|
)
|
|
|
|
|
).length;
|
2026-05-13 16:53:50 +02:00
|
|
|
|
2026-05-13 17:20:35 +02:00
|
|
|
expect(junction.edge.materialId).toBe("curb-material");
|
2026-05-13 17:05:55 +02:00
|
|
|
expect(roadMouthSegmentCount).toBe(4);
|
2026-05-13 16:53:50 +02:00
|
|
|
expect(meshData?.footprintPointCount).toBe(8);
|
|
|
|
|
expect(meshData?.profileVertexCount).toBe(4);
|
2026-05-13 18:53:57 +02:00
|
|
|
expect(meshData?.positions).toHaveLength(8 * 4 * 3);
|
|
|
|
|
expect(meshData?.indices).toHaveLength(4 * 3 * 6 + 8 * 2 * 3);
|
2026-05-13 16:54:00 +02:00
|
|
|
const firstProfile = Array.from(meshData!.positions.slice(0, 12));
|
2026-05-13 18:38:17 +02:00
|
|
|
const firstProfileWidth = Math.hypot(
|
|
|
|
|
firstProfile[6]! - firstProfile[3]!,
|
|
|
|
|
firstProfile[8]! - firstProfile[5]!
|
|
|
|
|
);
|
|
|
|
|
const profileRows = Array.from(
|
|
|
|
|
{ length: meshData!.positions.length / 12 },
|
|
|
|
|
(_, index) => Array.from(meshData!.positions.slice(index * 12, index * 12 + 12))
|
|
|
|
|
);
|
2026-05-13 16:54:00 +02:00
|
|
|
|
2026-05-13 18:53:57 +02:00
|
|
|
expect(firstProfileWidth).toBeGreaterThan(0.4);
|
2026-05-13 18:55:23 +02:00
|
|
|
expect(profileRows[0]).toEqual([
|
|
|
|
|
3,
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
-1,
|
|
|
|
|
3,
|
|
|
|
|
expect.closeTo(0.3, 5),
|
|
|
|
|
-1,
|
|
|
|
|
expect.closeTo(2.8343145, 5),
|
|
|
|
|
expect.closeTo(0.3, 5),
|
|
|
|
|
expect.closeTo(-1.4, 5),
|
|
|
|
|
expect.closeTo(2.8343145, 5),
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
expect.closeTo(-1.4, 5)
|
|
|
|
|
]);
|
|
|
|
|
expect(profileRows[1]).toEqual([
|
|
|
|
|
4,
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
-2,
|
|
|
|
|
4,
|
|
|
|
|
expect.closeTo(0.3, 5),
|
|
|
|
|
-2,
|
|
|
|
|
expect.closeTo(3.6, 5),
|
|
|
|
|
expect.closeTo(0.3, 5),
|
|
|
|
|
expect.closeTo(-2.1656854, 5),
|
|
|
|
|
expect.closeTo(3.6, 5),
|
|
|
|
|
expect.closeTo(0.1, 5),
|
|
|
|
|
expect.closeTo(-2.1656854, 5)
|
|
|
|
|
]);
|
2026-05-13 18:53:57 +02:00
|
|
|
|
|
|
|
|
const seamsByPath = resolveSplineCorridorJunctionRoadEdgeSeams({
|
|
|
|
|
junctions: [junction],
|
|
|
|
|
paths: [pathA, pathB]
|
|
|
|
|
});
|
|
|
|
|
const pathASeams = seamsByPath.get(pathA.id) ?? [];
|
|
|
|
|
|
|
|
|
|
expect(pathASeams).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
pathId: pathA.id,
|
|
|
|
|
side: "left",
|
|
|
|
|
distance: 3,
|
|
|
|
|
outerOffset: {
|
|
|
|
|
x: expect.closeTo(-0.1656854, 5),
|
|
|
|
|
z: expect.closeTo(0.4, 5)
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
pathId: pathA.id,
|
|
|
|
|
side: "right",
|
|
|
|
|
distance: 3,
|
|
|
|
|
outerOffset: {
|
|
|
|
|
x: expect.closeTo(-0.1656854, 5),
|
|
|
|
|
z: expect.closeTo(-0.4, 5)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
])
|
|
|
|
|
);
|
2026-05-13 16:53:50 +02:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 16:37:33 +02:00
|
|
|
it("samples full terrain influence inside the connection footprint and falloff outside it", () => {
|
|
|
|
|
const pathA = createRoadPath("path-junction-influence-a", [
|
|
|
|
|
{ x: 0, z: 0 },
|
|
|
|
|
{ x: 10, z: 0 }
|
|
|
|
|
]);
|
|
|
|
|
const pathB = createRoadPath("path-junction-influence-b", [
|
|
|
|
|
{ x: 5, z: -5 },
|
|
|
|
|
{ x: 5, z: 5 }
|
|
|
|
|
]);
|
|
|
|
|
const junction = createSplineCorridorJunction({
|
|
|
|
|
id: "junction-influence",
|
|
|
|
|
center: { x: 5, y: 0, z: 0 },
|
|
|
|
|
radius: 3,
|
|
|
|
|
connections: [
|
|
|
|
|
{
|
|
|
|
|
pathId: pathA.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
pathId: pathB.id,
|
|
|
|
|
progress: 0.5,
|
|
|
|
|
clipDistance: 2
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
const footprint = buildSplineCorridorJunctionFootprint({
|
|
|
|
|
junction,
|
|
|
|
|
paths: [pathA, pathB]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (footprint === null) {
|
|
|
|
|
throw new Error("Expected a junction footprint.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
sampleSplineCorridorJunctionFootprintInfluence({
|
|
|
|
|
footprint,
|
|
|
|
|
x: 5,
|
|
|
|
|
z: 0,
|
|
|
|
|
falloffDistance: 1
|
|
|
|
|
})
|
|
|
|
|
).toBe(1);
|
|
|
|
|
expect(
|
|
|
|
|
sampleSplineCorridorJunctionFootprintInfluence({
|
|
|
|
|
footprint,
|
|
|
|
|
x: 2.5,
|
|
|
|
|
z: 0,
|
|
|
|
|
falloffDistance: 1
|
|
|
|
|
})
|
|
|
|
|
).toBeGreaterThan(0);
|
|
|
|
|
expect(
|
|
|
|
|
sampleSplineCorridorJunctionFootprintInfluence({
|
|
|
|
|
footprint,
|
|
|
|
|
x: 1,
|
|
|
|
|
z: 0,
|
|
|
|
|
falloffDistance: 1
|
|
|
|
|
})
|
|
|
|
|
).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
});
|