auto-git:

[add] src/spline-corridor/spline-corridor-junctions.ts
This commit is contained in:
2026-05-13 13:16:12 +02:00
parent 61426b5e79
commit 4f3fe73413

View File

@@ -0,0 +1,537 @@
import type { Vec3 } from "../core/vector";
import {
resolveScenePath,
sampleResolvedScenePathPosition,
type ResolvedScenePath,
type ResolvedScenePathSegment,
type ScenePath
} from "../document/paths";
import {
createSplineCorridorJunction,
getSplineCorridorJunctions,
type SplineCorridorJunction,
type SplineCorridorJunctionRegistry
} from "../document/spline-corridor-junctions";
import type { Terrain } from "../document/terrains";
import {
mergeSplineCorridorPathClipIntervals,
type SplineCorridorPathClipIntervalMap
} from "./spline-corridor-clips";
export interface SplineCorridorJunctionCandidateConnection {
pathId: string;
progress: number;
clipDistance: number;
}
export interface SplineCorridorJunctionCandidate {
id: string;
center: Vec3;
radius: number;
materialId: string | null;
connections: SplineCorridorJunctionCandidateConnection[];
}
interface ResolvedRoadPath {
path: ScenePath;
resolved: ResolvedScenePath;
}
const MAX_VERTICAL_JUNCTION_DELTA = 2;
const ENDPOINT_JOIN_DISTANCE = 0.5;
const CANDIDATE_DEDUPE_DISTANCE = 0.6;
const EPSILON = 1e-6;
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function getRoadInfluenceRadius(path: ScenePath): number {
return path.road.width * 0.5 + path.road.shoulderWidth;
}
function getCandidateRadius(paths: readonly ScenePath[]): number {
return Math.max(
1.5,
...paths.map((path) => getRoadInfluenceRadius(path) * 1.15)
);
}
function createCandidateId(
connections: readonly SplineCorridorJunctionCandidateConnection[]
): string {
return connections
.map(
(connection) =>
`${connection.pathId}@${Math.round(connection.progress * 10000)}`
)
.sort()
.join("|");
}
function distanceXZ(left: Vec3, right: Vec3): number {
return Math.hypot(left.x - right.x, left.z - right.z);
}
function lerp(start: number, end: number, alpha: number): number {
return start + (end - start) * alpha;
}
function interpolateSegmentPosition(
segment: ResolvedScenePathSegment,
alpha: number
): Vec3 {
return {
x: lerp(segment.start.x, segment.end.x, alpha),
y: lerp(segment.start.y, segment.end.y, alpha),
z: lerp(segment.start.z, segment.end.z, alpha)
};
}
function resolveSegmentProgress(
path: ResolvedScenePath,
segment: ResolvedScenePathSegment,
alpha: number
): number {
if (path.totalLength <= EPSILON) {
return 0;
}
return Math.min(
1,
Math.max(
0,
(segment.distanceStart + segment.length * alpha) / path.totalLength
)
);
}
function resolveSegmentIntersectionXZ(
left: ResolvedScenePathSegment,
right: ResolvedScenePathSegment
): { leftAlpha: number; rightAlpha: number; position: Vec3 } | null {
const pX = left.start.x;
const pZ = left.start.z;
const rX = left.end.x - left.start.x;
const rZ = left.end.z - left.start.z;
const qX = right.start.x;
const qZ = right.start.z;
const sX = right.end.x - right.start.x;
const sZ = right.end.z - right.start.z;
const denominator = rX * sZ - rZ * sX;
if (Math.abs(denominator) <= EPSILON) {
return null;
}
const qMinusPX = qX - pX;
const qMinusPZ = qZ - pZ;
const leftAlpha = (qMinusPX * sZ - qMinusPZ * sX) / denominator;
const rightAlpha = (qMinusPX * rZ - qMinusPZ * rX) / denominator;
if (
leftAlpha < -EPSILON ||
leftAlpha > 1 + EPSILON ||
rightAlpha < -EPSILON ||
rightAlpha > 1 + EPSILON
) {
return null;
}
const clampedLeftAlpha = Math.min(1, Math.max(0, leftAlpha));
return {
leftAlpha: clampedLeftAlpha,
rightAlpha: Math.min(1, Math.max(0, rightAlpha)),
position: interpolateSegmentPosition(left, clampedLeftAlpha)
};
}
function areSamePathSegmentsAdjacent(options: {
path: ResolvedScenePath;
left: ResolvedScenePathSegment;
right: ResolvedScenePathSegment;
}): boolean {
const indexDelta = Math.abs(options.left.index - options.right.index);
if (indexDelta <= 1) {
return true;
}
return (
options.path.loop &&
indexDelta === Math.max(0, options.path.segments.length - 1)
);
}
function createCandidateFromConnections(options: {
center: Vec3;
paths: readonly ScenePath[];
connections: readonly Omit<
SplineCorridorJunctionCandidateConnection,
"clipDistance"
>[];
}): SplineCorridorJunctionCandidate {
const radius = getCandidateRadius(options.paths);
const connections = options.connections.map((connection) => ({
...connection,
clipDistance: radius
}));
return {
id: createCandidateId(connections),
center: cloneVec3(options.center),
radius,
materialId:
options.paths.find((path) => path.road.materialId !== null)?.road
.materialId ?? null,
connections
};
}
function resolveRoadPaths(options: {
paths: readonly ScenePath[];
terrains?: readonly Terrain[];
}): ResolvedRoadPath[] {
const terrains = options.terrains ?? [];
return options.paths
.filter((path) => path.enabled && path.visible && path.road.enabled)
.map((path) => ({
path,
resolved: resolveScenePath(path, { terrains })
}))
.filter((path) => path.resolved.totalLength > EPSILON);
}
function candidateCoveredByExistingJunction(options: {
candidate: SplineCorridorJunctionCandidate;
existingJunctions: readonly SplineCorridorJunction[];
resolvedPaths: ReadonlyMap<string, ResolvedRoadPath>;
}): boolean {
return options.existingJunctions.some((junction) => {
let coveredConnections = 0;
for (const candidateConnection of options.candidate.connections) {
const resolvedPath = options.resolvedPaths.get(candidateConnection.pathId);
if (resolvedPath === undefined) {
continue;
}
const matchingConnection = junction.connections.find((connection) => {
if (connection.pathId !== candidateConnection.pathId) {
return false;
}
const distanceDelta =
Math.abs(connection.progress - candidateConnection.progress) *
resolvedPath.resolved.totalLength;
return (
distanceDelta <=
Math.max(connection.clipDistance, candidateConnection.clipDistance)
);
});
if (matchingConnection !== undefined) {
coveredConnections += 1;
}
}
return coveredConnections >= Math.min(2, options.candidate.connections.length);
});
}
function isDuplicateCandidate(
candidate: SplineCorridorJunctionCandidate,
existingCandidates: readonly SplineCorridorJunctionCandidate[]
): boolean {
return existingCandidates.some((existingCandidate) => {
if (distanceXZ(candidate.center, existingCandidate.center) > CANDIDATE_DEDUPE_DISTANCE) {
return false;
}
const candidatePaths = new Set(
candidate.connections.map((connection) => connection.pathId)
);
const sharedPathCount = existingCandidate.connections.filter((connection) =>
candidatePaths.has(connection.pathId)
).length;
return sharedPathCount >= 2;
});
}
function addCandidate(
candidates: SplineCorridorJunctionCandidate[],
candidate: SplineCorridorJunctionCandidate
) {
if (!isDuplicateCandidate(candidate, candidates)) {
candidates.push(candidate);
}
}
function addIntersectionCandidates(
candidates: SplineCorridorJunctionCandidate[],
resolvedPaths: readonly ResolvedRoadPath[]
) {
for (let leftPathIndex = 0; leftPathIndex < resolvedPaths.length; leftPathIndex += 1) {
const leftPath = resolvedPaths[leftPathIndex]!;
for (let rightPathIndex = leftPathIndex; rightPathIndex < resolvedPaths.length; rightPathIndex += 1) {
const rightPath = resolvedPaths[rightPathIndex]!;
const samePath = leftPath.path.id === rightPath.path.id;
for (const leftSegment of leftPath.resolved.segments) {
for (const rightSegment of rightPath.resolved.segments) {
if (samePath) {
if (leftSegment.index >= rightSegment.index) {
continue;
}
if (
areSamePathSegmentsAdjacent({
path: leftPath.resolved,
left: leftSegment,
right: rightSegment
})
) {
continue;
}
}
const intersection = resolveSegmentIntersectionXZ(
leftSegment,
rightSegment
);
if (intersection === null) {
continue;
}
const leftPosition = interpolateSegmentPosition(
leftSegment,
intersection.leftAlpha
);
const rightPosition = interpolateSegmentPosition(
rightSegment,
intersection.rightAlpha
);
if (
Math.abs(leftPosition.y - rightPosition.y) >
MAX_VERTICAL_JUNCTION_DELTA
) {
continue;
}
const leftProgress = resolveSegmentProgress(
leftPath.resolved,
leftSegment,
intersection.leftAlpha
);
const rightProgress = resolveSegmentProgress(
rightPath.resolved,
rightSegment,
intersection.rightAlpha
);
addCandidate(
candidates,
createCandidateFromConnections({
center: {
x: intersection.position.x,
y: (leftPosition.y + rightPosition.y) * 0.5,
z: intersection.position.z
},
paths: samePath
? [leftPath.path]
: [leftPath.path, rightPath.path],
connections: [
{
pathId: leftPath.path.id,
progress: leftProgress
},
{
pathId: rightPath.path.id,
progress: rightProgress
}
]
})
);
}
}
}
}
}
function getEndpointConnections(path: ResolvedRoadPath) {
const first = sampleResolvedScenePathPosition(path.resolved, 0);
const last = sampleResolvedScenePathPosition(path.resolved, 1);
return [
{
path,
progress: 0,
position: first
},
{
path,
progress: 1,
position: last
}
];
}
function addEndpointJoinCandidates(
candidates: SplineCorridorJunctionCandidate[],
resolvedPaths: readonly ResolvedRoadPath[]
) {
const endpoints = resolvedPaths.flatMap(getEndpointConnections);
for (let leftIndex = 0; leftIndex < endpoints.length; leftIndex += 1) {
const left = endpoints[leftIndex]!;
for (let rightIndex = leftIndex + 1; rightIndex < endpoints.length; rightIndex += 1) {
const right = endpoints[rightIndex]!;
if (left.path.path.id === right.path.path.id) {
continue;
}
if (distanceXZ(left.position, right.position) > ENDPOINT_JOIN_DISTANCE) {
continue;
}
if (
Math.abs(left.position.y - right.position.y) >
MAX_VERTICAL_JUNCTION_DELTA
) {
continue;
}
addCandidate(
candidates,
createCandidateFromConnections({
center: {
x: (left.position.x + right.position.x) * 0.5,
y: (left.position.y + right.position.y) * 0.5,
z: (left.position.z + right.position.z) * 0.5
},
paths: [left.path.path, right.path.path],
connections: [
{
pathId: left.path.path.id,
progress: left.progress
},
{
pathId: right.path.path.id,
progress: right.progress
}
]
})
);
}
}
}
export function detectSplineCorridorJunctionCandidates(options: {
paths: readonly ScenePath[];
junctions?: SplineCorridorJunctionRegistry;
terrains?: readonly Terrain[];
}): SplineCorridorJunctionCandidate[] {
const resolvedPaths = resolveRoadPaths(options);
const resolvedPathMap = new Map(
resolvedPaths.map((path) => [path.path.id, path])
);
const candidates: SplineCorridorJunctionCandidate[] = [];
addIntersectionCandidates(candidates, resolvedPaths);
addEndpointJoinCandidates(candidates, resolvedPaths);
const existingJunctions = getSplineCorridorJunctions(options.junctions ?? {});
return candidates.filter(
(candidate) =>
!candidateCoveredByExistingJunction({
candidate,
existingJunctions,
resolvedPaths: resolvedPathMap
})
);
}
export function createSplineCorridorJunctionFromCandidate(
candidate: SplineCorridorJunctionCandidate
): SplineCorridorJunction {
return createSplineCorridorJunction({
center: candidate.center,
radius: candidate.radius,
materialId: candidate.materialId,
connections: candidate.connections
});
}
export function resolveSplineCorridorJunctionClipIntervals(options: {
paths: readonly ScenePath[];
junctions: readonly SplineCorridorJunction[];
terrains?: readonly Terrain[];
}): SplineCorridorPathClipIntervalMap {
const resolvedPaths = new Map(
resolveRoadPaths({
paths: options.paths,
terrains: options.terrains
}).map((path) => [path.path.id, path])
);
const intervalsByPath: SplineCorridorPathClipIntervalMap = new Map();
for (const junction of options.junctions) {
if (!junction.enabled) {
continue;
}
for (const connection of junction.connections) {
const resolvedPath = resolvedPaths.get(connection.pathId);
if (resolvedPath === undefined) {
continue;
}
const centerDistance =
connection.progress * resolvedPath.resolved.totalLength;
const interval = {
junctionId: junction.id,
startDistance: Math.max(0, centerDistance - connection.clipDistance),
endDistance: Math.min(
resolvedPath.resolved.totalLength,
centerDistance + connection.clipDistance
)
};
const intervals = intervalsByPath.get(connection.pathId) ?? [];
intervals.push(interval);
intervalsByPath.set(connection.pathId, intervals);
}
}
for (const [pathId, intervals] of intervalsByPath) {
intervalsByPath.set(pathId, mergeSplineCorridorPathClipIntervals(intervals));
}
return intervalsByPath;
}
export function getSplineCorridorJunctionsConnectedToPath(options: {
junctions: SplineCorridorJunctionRegistry;
pathId: string;
}): SplineCorridorJunction[] {
return getSplineCorridorJunctions(options.junctions).filter((junction) =>
junction.connections.some((connection) => connection.pathId === options.pathId)
);
}