auto-git:
[add] src/spline-corridor/spline-corridor-junctions.ts
This commit is contained in:
537
src/spline-corridor/spline-corridor-junctions.ts
Normal file
537
src/spline-corridor/spline-corridor-junctions.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user