diff --git a/src/spline-corridor/spline-corridor-junctions.ts b/src/spline-corridor/spline-corridor-junctions.ts new file mode 100644 index 00000000..4a63c694 --- /dev/null +++ b/src/spline-corridor/spline-corridor-junctions.ts @@ -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; +}): 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) + ); +} +