From 1f8f52d78582eec677fad33adcc0d8e5ea8c9f34 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Wed, 13 May 2026 16:34:10 +0200 Subject: [PATCH] auto-git: [add] src/geometry/spline-corridor-junction-footprint.ts --- .../spline-corridor-junction-footprint.ts | 519 ++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 src/geometry/spline-corridor-junction-footprint.ts diff --git a/src/geometry/spline-corridor-junction-footprint.ts b/src/geometry/spline-corridor-junction-footprint.ts new file mode 100644 index 00000000..ef239c0f --- /dev/null +++ b/src/geometry/spline-corridor-junction-footprint.ts @@ -0,0 +1,519 @@ +import type { Vec3 } from "../core/vector"; +import { + resolveScenePath, + sampleResolvedScenePathPosition, + sampleResolvedScenePathTangent, + type ScenePathCurveMode, + type ScenePathPoint, + type ScenePathRoadSettings +} from "../document/paths"; +import type { SplineCorridorJunction } from "../document/spline-corridor-junctions"; +import type { Terrain } from "../document/terrains"; + +export interface SplineCorridorJunctionFootprintPathLike { + id: string; + loop: boolean; + curveMode?: ScenePathCurveMode; + sampledResolution?: number; + glueToTerrain?: boolean; + terrainOffset?: number; + road: ScenePathRoadSettings; + points: ReadonlyArray<{ + id?: string; + pointId?: string; + position: Vec3; + }>; +} + +export interface SplineCorridorJunctionFootprintPoint { + x: number; + z: number; + fallbackY: number; + heightOffset: number; +} + +export interface SplineCorridorJunctionFootprint { + center: SplineCorridorJunctionFootprintPoint; + points: SplineCorridorJunctionFootprintPoint[]; + forwardAxis: { x: number; z: number }; + rightAxis: { x: number; z: number }; + bounds: { + minX: number; + maxX: number; + minZ: number; + maxZ: number; + }; +} + +const CIRCLE_FALLBACK_SEGMENTS = 32; +const EPSILON = 1e-6; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function normalizePathPoint( + point: SplineCorridorJunctionFootprintPathLike["points"][number], + index: number +): ScenePathPoint { + return { + id: point.id ?? point.pointId ?? `junction-path-point-${index}`, + position: cloneVec3(point.position) + }; +} + +function resolveJunctionPath( + path: SplineCorridorJunctionFootprintPathLike, + terrains: readonly Terrain[] +) { + return resolveScenePath( + { + loop: path.loop, + curveMode: path.curveMode, + sampledResolution: path.sampledResolution, + glueToTerrain: path.glueToTerrain, + terrainOffset: path.terrainOffset, + points: path.points.map(normalizePathPoint) + }, + { terrains } + ); +} + +function normalizeHorizontalVector(vector: Vec3): { x: number; z: number } | null { + const length = Math.hypot(vector.x, vector.z); + + if (length <= EPSILON) { + return null; + } + + return { + x: vector.x / length, + z: vector.z / length + }; +} + +function getConnectionHeightOffset( + path: SplineCorridorJunctionFootprintPathLike +): number { + return path.road.heightOffset; +} + +function createFootprintPoint(options: { + position: Vec3; + leftAxis: { x: number; z: number }; + offset: number; + heightOffset: number; +}): SplineCorridorJunctionFootprintPoint { + return { + x: options.position.x + options.leftAxis.x * options.offset, + z: options.position.z + options.leftAxis.z * options.offset, + fallbackY: options.position.y + options.heightOffset, + heightOffset: options.heightOffset + }; +} + +function addConnectionBoundaryPoints(options: { + points: SplineCorridorJunctionFootprintPoint[]; + path: SplineCorridorJunctionFootprintPathLike; + progress: number; + clipDistance: number; + terrains: readonly Terrain[]; +}): { forwardAxis: { x: number; z: number }; fallbackY: number } | null { + const resolvedPath = resolveJunctionPath(options.path, options.terrains); + + if (resolvedPath.totalLength <= EPSILON) { + return null; + } + + const centerDistance = Math.min( + resolvedPath.totalLength, + Math.max(0, options.progress * resolvedPath.totalLength) + ); + const distances = [ + Math.max(0, centerDistance - options.clipDistance), + Math.min(resolvedPath.totalLength, centerDistance + options.clipDistance) + ].filter((distance, index, values) => { + const atPathStart = distance <= EPSILON && centerDistance <= EPSILON; + const atPathEnd = + distance >= resolvedPath.totalLength - EPSILON && + centerDistance >= resolvedPath.totalLength - EPSILON; + + if (atPathStart || atPathEnd) { + return false; + } + + return index === 0 || Math.abs(distance - values[index - 1]!) > EPSILON; + }); + const centerProgress = + resolvedPath.totalLength <= EPSILON + ? 0 + : centerDistance / resolvedPath.totalLength; + const centerPosition = sampleResolvedScenePathPosition( + resolvedPath, + centerProgress + ); + const centerTangent = sampleResolvedScenePathTangent( + resolvedPath, + centerProgress + ); + const forwardAxis = normalizeHorizontalVector(centerTangent); + + if (forwardAxis === null) { + return null; + } + + const heightOffset = getConnectionHeightOffset(options.path); + const halfWidth = Math.max(0.05, options.path.road.width * 0.5); + const leftAxis = { + x: -forwardAxis.z, + z: forwardAxis.x + }; + const boundaryDistances = distances.length === 0 ? [centerDistance] : distances; + + for (const distance of boundaryDistances) { + const progress = + resolvedPath.totalLength <= EPSILON + ? 0 + : Math.min(1, Math.max(0, distance / resolvedPath.totalLength)); + const position = sampleResolvedScenePathPosition(resolvedPath, progress); + const tangent = + normalizeHorizontalVector( + sampleResolvedScenePathTangent(resolvedPath, progress) + ) ?? forwardAxis; + const localLeftAxis = { + x: -tangent.z, + z: tangent.x + }; + + options.points.push( + createFootprintPoint({ + position, + leftAxis: localLeftAxis, + offset: halfWidth, + heightOffset + }), + createFootprintPoint({ + position, + leftAxis: localLeftAxis, + offset: -halfWidth, + heightOffset + }) + ); + } + + return { + forwardAxis, + fallbackY: centerPosition.y + heightOffset + }; +} + +function crossXZ( + origin: SplineCorridorJunctionFootprintPoint, + left: SplineCorridorJunctionFootprintPoint, + right: SplineCorridorJunctionFootprintPoint +): number { + return ( + (left.x - origin.x) * (right.z - origin.z) - + (left.z - origin.z) * (right.x - origin.x) + ); +} + +function dedupePoints( + points: readonly SplineCorridorJunctionFootprintPoint[] +): SplineCorridorJunctionFootprintPoint[] { + const deduped: SplineCorridorJunctionFootprintPoint[] = []; + + for (const point of points) { + const existing = deduped.find( + (candidate) => + Math.hypot(candidate.x - point.x, candidate.z - point.z) <= EPSILON + ); + + if (existing === undefined) { + deduped.push(point); + continue; + } + + existing.fallbackY = (existing.fallbackY + point.fallbackY) * 0.5; + existing.heightOffset = (existing.heightOffset + point.heightOffset) * 0.5; + } + + return deduped; +} + +function computeConvexHull( + points: readonly SplineCorridorJunctionFootprintPoint[] +): SplineCorridorJunctionFootprintPoint[] { + const sorted = dedupePoints(points).sort((left, right) => + left.x === right.x ? left.z - right.z : left.x - right.x + ); + + if (sorted.length <= 3) { + return sorted; + } + + const lower: SplineCorridorJunctionFootprintPoint[] = []; + for (const point of sorted) { + while ( + lower.length >= 2 && + crossXZ(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= + EPSILON + ) { + lower.pop(); + } + lower.push(point); + } + + const upper: SplineCorridorJunctionFootprintPoint[] = []; + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const point = sorted[index]!; + while ( + upper.length >= 2 && + crossXZ(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= + EPSILON + ) { + upper.pop(); + } + upper.push(point); + } + + upper.pop(); + lower.pop(); + return lower.concat(upper); +} + +function getBounds(points: readonly SplineCorridorJunctionFootprintPoint[]) { + return points.reduce( + (bounds, point) => ({ + minX: Math.min(bounds.minX, point.x), + maxX: Math.max(bounds.maxX, point.x), + minZ: Math.min(bounds.minZ, point.z), + maxZ: Math.max(bounds.maxZ, point.z) + }), + { + minX: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY + } + ); +} + +function isPointInsidePolygonXZ( + x: number, + z: number, + polygon: readonly SplineCorridorJunctionFootprintPoint[] +): boolean { + if (polygon.length < 3) { + return false; + } + + let inside = false; + + for ( + let index = 0, previousIndex = polygon.length - 1; + index < polygon.length; + previousIndex = index, index += 1 + ) { + const current = polygon[index]!; + const previous = polygon[previousIndex]!; + const intersects = + current.z > z !== previous.z > z && + x < + ((previous.x - current.x) * (z - current.z)) / + (previous.z - current.z) + + current.x; + + if (intersects) { + inside = !inside; + } + } + + return inside; +} + +function buildCircularFootprint(options: { + junction: SplineCorridorJunction; + fallbackY: number; + heightOffset: number; + forwardAxis: { x: number; z: number }; +}): SplineCorridorJunctionFootprint { + const points: SplineCorridorJunctionFootprintPoint[] = []; + + for (let index = 0; index < CIRCLE_FALLBACK_SEGMENTS; index += 1) { + const angle = (index / CIRCLE_FALLBACK_SEGMENTS) * Math.PI * 2; + points.push({ + x: options.junction.center.x + Math.cos(angle) * options.junction.radius, + z: options.junction.center.z + Math.sin(angle) * options.junction.radius, + fallbackY: options.fallbackY, + heightOffset: options.heightOffset + }); + } + + const bounds = getBounds(points); + const rightAxis = { + x: -options.forwardAxis.z, + z: options.forwardAxis.x + }; + + return { + center: { + x: options.junction.center.x, + z: options.junction.center.z, + fallbackY: options.fallbackY, + heightOffset: options.heightOffset + }, + points, + forwardAxis: options.forwardAxis, + rightAxis, + bounds + }; +} + +export function buildSplineCorridorJunctionFootprint(options: { + junction: SplineCorridorJunction; + paths: readonly SplineCorridorJunctionFootprintPathLike[]; + terrains?: readonly Terrain[]; +}): SplineCorridorJunctionFootprint | null { + const { junction, paths } = options; + const terrains = options.terrains ?? []; + + if (!junction.enabled || !junction.visible || junction.radius <= 0) { + return null; + } + + const points: SplineCorridorJunctionFootprintPoint[] = []; + const fallbackYs: number[] = []; + const heightOffsets: number[] = []; + const forwardAxes: Array<{ x: number; z: number }> = []; + + for (const connection of junction.connections) { + const path = + paths.find((candidate) => candidate.id === connection.pathId) ?? null; + + if (path === null || !path.road.enabled) { + continue; + } + + const boundary = addConnectionBoundaryPoints({ + points, + path, + progress: connection.progress, + clipDistance: connection.clipDistance, + terrains + }); + + if (boundary === null) { + continue; + } + + fallbackYs.push(boundary.fallbackY); + heightOffsets.push(path.road.heightOffset); + forwardAxes.push(boundary.forwardAxis); + } + + const fallbackY = + fallbackYs.length === 0 + ? junction.center.y + : fallbackYs.reduce((sum, value) => sum + value, 0) / fallbackYs.length; + const heightOffset = + heightOffsets.length === 0 + ? 0 + : heightOffsets.reduce((sum, value) => sum + value, 0) / + heightOffsets.length; + const forwardAxis = forwardAxes[0] ?? { x: 1, z: 0 }; + const hull = computeConvexHull(points); + const centerPoint = { + x: junction.center.x, + z: junction.center.z, + fallbackY, + heightOffset + }; + + if (hull.length < 3 || !isPointInsidePolygonXZ(centerPoint.x, centerPoint.z, hull)) { + return buildCircularFootprint({ + junction, + fallbackY, + heightOffset, + forwardAxis + }); + } + + return { + center: centerPoint, + points: hull, + forwardAxis, + rightAxis: { + x: -forwardAxis.z, + z: forwardAxis.x + }, + bounds: getBounds(hull) + }; +} + +function getDistanceToSegmentXZ(options: { + x: number; + z: number; + start: SplineCorridorJunctionFootprintPoint; + end: SplineCorridorJunctionFootprintPoint; +}): number { + const deltaX = options.end.x - options.start.x; + const deltaZ = options.end.z - options.start.z; + const lengthSquared = deltaX * deltaX + deltaZ * deltaZ; + const t = + lengthSquared <= EPSILON + ? 0 + : Math.min( + 1, + Math.max( + 0, + ((options.x - options.start.x) * deltaX + + (options.z - options.start.z) * deltaZ) / + lengthSquared + ) + ); + const projectedX = options.start.x + deltaX * t; + const projectedZ = options.start.z + deltaZ * t; + + return Math.hypot(options.x - projectedX, options.z - projectedZ); +} + +export function sampleSplineCorridorJunctionFootprintInfluence(options: { + footprint: SplineCorridorJunctionFootprint; + x: number; + z: number; + falloffDistance: number; +}): number { + if (isPointInsidePolygonXZ(options.x, options.z, options.footprint.points)) { + return 1; + } + + const falloffDistance = Math.max(0.001, options.falloffDistance); + let nearestDistance = Number.POSITIVE_INFINITY; + + for (let index = 0; index < options.footprint.points.length; index += 1) { + const start = options.footprint.points[index]!; + const end = + options.footprint.points[(index + 1) % options.footprint.points.length]!; + nearestDistance = Math.min( + nearestDistance, + getDistanceToSegmentXZ({ + x: options.x, + z: options.z, + start, + end + }) + ); + } + + if (nearestDistance > falloffDistance) { + return 0; + } + + const progress = Math.min(1, Math.max(0, nearestDistance / falloffDistance)); + return Math.pow(1 - progress, 2); +}