diff --git a/src/spline-corridor/spline-corridor-junctions.ts b/src/spline-corridor/spline-corridor-junctions.ts index df4c7991..416d5d5e 100644 --- a/src/spline-corridor/spline-corridor-junctions.ts +++ b/src/spline-corridor/spline-corridor-junctions.ts @@ -11,6 +11,7 @@ import { import { createSplineCorridorJunction, getSplineCorridorJunctions, + type SplineCorridorJunctionConnection, type SplineCorridorJunction, type SplineCorridorJunctionRegistry } from "../document/spline-corridor-junctions"; @@ -36,6 +37,13 @@ export interface SplineCorridorJunctionCandidate { connections: SplineCorridorJunctionCandidateConnection[]; } +export interface ReattachSplineCorridorJunctionsOptions { + paths: readonly ScenePath[]; + junctions: SplineCorridorJunctionRegistry; + changedPathIds?: readonly string[]; + terrains?: readonly Terrain[]; +} + interface ResolvedRoadPath { path: ScenePath; resolved: ResolvedScenePath; @@ -98,6 +106,28 @@ function distanceXZ(left: Vec3, right: Vec3): number { return Math.hypot(left.x - right.x, left.z - right.z); } +function averageVec3(vectors: readonly Vec3[]): Vec3 { + if (vectors.length === 0) { + return { + x: 0, + y: 0, + z: 0 + }; + } + + return { + x: + vectors.reduce((total, vector) => total + vector.x, 0) / + vectors.length, + y: + vectors.reduce((total, vector) => total + vector.y, 0) / + vectors.length, + z: + vectors.reduce((total, vector) => total + vector.z, 0) / + vectors.length + }; +} + function lerp(start: number, end: number, alpha: number): number { return start + (end - start) * alpha; } @@ -556,3 +586,212 @@ export function getSplineCorridorJunctionsConnectedToPath(options: { junction.connections.some((connection) => connection.pathId === options.pathId) ); } + +function countConnectionPathIds( + connections: readonly Pick[] +): Map { + const counts = new Map(); + + for (const connection of connections) { + counts.set(connection.pathId, (counts.get(connection.pathId) ?? 0) + 1); + } + + return counts; +} + +function haveSameConnectionPathMultiset(options: { + junction: SplineCorridorJunction; + candidate: SplineCorridorJunctionCandidate; +}): boolean { + const junctionCounts = countConnectionPathIds(options.junction.connections); + const candidateCounts = countConnectionPathIds(options.candidate.connections); + + if (junctionCounts.size !== candidateCounts.size) { + return false; + } + + for (const [pathId, count] of junctionCounts) { + if (candidateCounts.get(pathId) !== count) { + return false; + } + } + + return true; +} + +function findBestReattachmentCandidate(options: { + junction: SplineCorridorJunction; + candidates: readonly SplineCorridorJunctionCandidate[]; +}): SplineCorridorJunctionCandidate | null { + let bestCandidate: SplineCorridorJunctionCandidate | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const candidate of options.candidates) { + if ( + !haveSameConnectionPathMultiset({ + junction: options.junction, + candidate + }) + ) { + continue; + } + + const distance = distanceXZ(options.junction.center, candidate.center); + + if ( + distance < bestDistance - EPSILON || + (Math.abs(distance - bestDistance) <= EPSILON && + candidate.id.localeCompare(bestCandidate?.id ?? "") < 0) + ) { + bestCandidate = candidate; + bestDistance = distance; + } + } + + return bestCandidate; +} + +function mapCandidateConnectionsToExisting(options: { + existingConnections: readonly SplineCorridorJunctionConnection[]; + candidateConnections: readonly SplineCorridorJunctionCandidateConnection[]; +}): SplineCorridorJunctionConnection[] { + const usedCandidateIndexes = new Set(); + + return options.existingConnections.map((existingConnection) => { + let bestCandidateIndex: number | null = null; + let bestProgressDelta = Number.POSITIVE_INFINITY; + + options.candidateConnections.forEach((candidateConnection, index) => { + if ( + usedCandidateIndexes.has(index) || + candidateConnection.pathId !== existingConnection.pathId + ) { + return; + } + + const progressDelta = Math.abs( + candidateConnection.progress - existingConnection.progress + ); + + if (progressDelta < bestProgressDelta) { + bestCandidateIndex = index; + bestProgressDelta = progressDelta; + } + }); + + if (bestCandidateIndex === null) { + return existingConnection; + } + + usedCandidateIndexes.add(bestCandidateIndex); + const candidateConnection = + options.candidateConnections[bestCandidateIndex]!; + + return { + ...existingConnection, + progress: candidateConnection.progress + }; + }); +} + +function reattachJunctionToCandidate(options: { + junction: SplineCorridorJunction; + candidate: SplineCorridorJunctionCandidate; +}): SplineCorridorJunction { + return createSplineCorridorJunction({ + ...options.junction, + center: options.candidate.center, + connections: mapCandidateConnectionsToExisting({ + existingConnections: options.junction.connections, + candidateConnections: options.candidate.connections + }) + }); +} + +function reprojectJunctionConnections(options: { + junction: SplineCorridorJunction; + resolvedPaths: ReadonlyMap; +}): SplineCorridorJunction { + const sampledPositions: Vec3[] = []; + const connections = options.junction.connections.map((connection) => { + const resolvedPath = options.resolvedPaths.get(connection.pathId); + + if (resolvedPath === undefined) { + return connection; + } + + const nearest = resolveNearestPointOnResolvedScenePath( + resolvedPath.resolved, + options.junction.center + ); + + sampledPositions.push(nearest.position); + + return { + ...connection, + progress: nearest.progress + }; + }); + + if (sampledPositions.length === 0) { + return options.junction; + } + + return createSplineCorridorJunction({ + ...options.junction, + center: averageVec3(sampledPositions), + connections + }); +} + +export function reattachSplineCorridorJunctionsToPaths( + options: ReattachSplineCorridorJunctionsOptions +): SplineCorridorJunctionRegistry { + const changedPathIdSet = + options.changedPathIds === undefined + ? null + : new Set(options.changedPathIds); + const resolvedPaths = resolveRoadPaths({ + paths: options.paths, + terrains: options.terrains + }); + const resolvedPathMap = new Map( + resolvedPaths.map((path) => [path.path.id, path]) + ); + const candidates = detectSplineCorridorJunctionCandidates({ + paths: options.paths, + terrains: options.terrains + }); + const nextJunctions: SplineCorridorJunctionRegistry = {}; + + for (const [junctionId, junction] of Object.entries(options.junctions)) { + const shouldReattach = + changedPathIdSet === null || + junction.connections.some((connection) => + changedPathIdSet.has(connection.pathId) + ); + + if (!shouldReattach) { + nextJunctions[junctionId] = createSplineCorridorJunction(junction); + continue; + } + + const bestCandidate = findBestReattachmentCandidate({ + junction, + candidates + }); + + nextJunctions[junctionId] = + bestCandidate === null + ? reprojectJunctionConnections({ + junction, + resolvedPaths: resolvedPathMap + }) + : reattachJunctionToCandidate({ + junction, + candidate: bestCandidate + }); + } + + return nextJunctions; +}