import { createOpaqueId } from "../core/ids"; import type { Vec3 } from "../core/vector"; import { sampleTerrainHeightAtWorldPosition, type Terrain } from "./terrains"; export interface ScenePathPoint { id: string; position: Vec3; } export type ScenePathCurveMode = "linear" | "catmullRom"; export interface ScenePath { id: string; kind: "path"; name?: string; visible: boolean; enabled: boolean; loop: boolean; curveMode: ScenePathCurveMode; sampledResolution: number; glueToTerrain: boolean; terrainOffset: number; points: ScenePathPoint[]; } export interface ResolvedScenePathSegment { index: number; startPointId: string; endPointId: string; start: Vec3; end: Vec3; length: number; distanceStart: number; distanceEnd: number; tangent: Vec3; } export interface ResolvedScenePath { loop: boolean; curveMode: ScenePathCurveMode; sampledResolution: number; glueToTerrain: boolean; terrainOffset: number; points: ScenePathPoint[]; segments: ResolvedScenePathSegment[]; totalLength: number; } export interface ResolvedScenePathProjectionSource { loop: boolean; curveMode?: ScenePathCurveMode; sampledResolution?: number; glueToTerrain?: boolean; terrainOffset?: number; points: Array<{ position: Vec3; }>; segments: Array< Pick< ResolvedScenePathSegment, | "index" | "start" | "end" | "length" | "distanceStart" | "distanceEnd" | "tangent" > >; totalLength: number; } export interface ResolvedScenePathNearestPoint { progress: number; distance: number; distanceAlongPath: number; segmentIndex: number | null; position: Vec3; tangent: Vec3; } export interface ProjectedWorldSegmentPoint { progress: number; distance: number; position: Vec3; } export interface MappedScenePathProgressBetweenWorldPoints { trackProgress: number; railProgress: number; projectedTrackPosition: Vec3; } interface PathPointLike { position: Vec3; } interface ResolvedPathSegmentLike { start: Vec3; end: Vec3; length: number; distanceStart: number; distanceEnd: number; tangent: Vec3; } interface ResolvedPathLike< TPoint extends PathPointLike = ScenePathPoint, TSegment extends ResolvedPathSegmentLike = ResolvedScenePathSegment > { loop: boolean; curveMode?: ScenePathCurveMode; sampledResolution?: number; glueToTerrain?: boolean; terrainOffset?: number; points: TPoint[]; segments: TSegment[]; totalLength: number; } interface SmoothedPathSample { distance: number; position: Vec3; tangent: Vec3; } export interface ResolveScenePathOptions { terrains?: readonly Terrain[]; } export const DEFAULT_SCENE_PATH_VISIBLE = true; export const DEFAULT_SCENE_PATH_ENABLED = true; export const DEFAULT_SCENE_PATH_LOOP = false; export const DEFAULT_SCENE_PATH_CURVE_MODE: ScenePathCurveMode = "linear"; export const DEFAULT_SCENE_PATH_SAMPLED_RESOLUTION = 12; export const DEFAULT_SCENE_PATH_GLUE_TO_TERRAIN = false; export const DEFAULT_SCENE_PATH_TERRAIN_OFFSET = 0; export const MIN_SCENE_PATH_SAMPLED_RESOLUTION = 1; export const MAX_SCENE_PATH_SAMPLED_RESOLUTION = 64; export const MIN_SCENE_PATH_POINT_COUNT = 2; const DEFAULT_SCENE_PATH_POINT_POSITIONS: ReadonlyArray = [ { x: -1, y: 0, z: 0 }, { x: 1, y: 0, z: 0 } ]; function cloneVec3(vector: Vec3): Vec3 { return { x: vector.x, y: vector.y, z: vector.z }; } function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } function assertFiniteVec3(vector: Vec3, label: string) { if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { throw new Error(`${label} must remain finite on every axis.`); } } function normalizeDelta(delta: Vec3): Vec3 { const length = Math.hypot(delta.x, delta.y, delta.z); if (length <= 0) { return { x: 0, y: 0, z: 0 }; } return { x: delta.x / length, y: delta.y / length, z: delta.z / length }; } function subtractVec3(left: Vec3, right: Vec3): Vec3 { return { x: left.x - right.x, y: left.y - right.y, z: left.z - right.z }; } function getVec3Distance(left: Vec3, right: Vec3): number { return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z); } function clampProgress(progress: number): number { if (!Number.isFinite(progress)) { throw new Error("Path progress must be a finite number."); } if (progress <= 0) { return 0; } if (progress >= 1) { return 1; } return progress; } export function isScenePathCurveMode( value: unknown ): value is ScenePathCurveMode { return value === "linear" || value === "catmullRom"; } export function normalizeScenePathSampledResolution(value: number): number { if (!Number.isFinite(value) || !Number.isInteger(value)) { throw new Error("Path sampled resolution must be a finite integer."); } if (value < MIN_SCENE_PATH_SAMPLED_RESOLUTION) { return MIN_SCENE_PATH_SAMPLED_RESOLUTION; } if (value > MAX_SCENE_PATH_SAMPLED_RESOLUTION) { return MAX_SCENE_PATH_SAMPLED_RESOLUTION; } return value; } export function normalizeScenePathTerrainOffset(value: number): number { if (!Number.isFinite(value)) { throw new Error("Path terrain offset must be a finite number."); } return value; } function resolvePathSegmentSample( path: ResolvedPathLike, progress: number ): { segmentIndex: number | null; distance: number } { if (path.segments.length === 0 || path.totalLength <= 0) { return { segmentIndex: null, distance: 0 }; } const distance = clampProgress(progress) * path.totalLength; if (distance >= path.totalLength) { return { segmentIndex: path.segments.length - 1, distance }; } const segmentIndex = path.segments.findIndex( (segment) => distance <= segment.distanceEnd ); return { segmentIndex: segmentIndex === -1 ? path.segments.length - 1 : segmentIndex, distance }; } function findNonZeroSegmentTangent( path: ResolvedPathLike, index: number ): Vec3 { for (let candidateIndex = index; candidateIndex < path.segments.length; candidateIndex += 1) { const candidate = path.segments[candidateIndex]; if (candidate !== undefined && candidate.length > 0) { return cloneVec3(candidate.tangent); } } for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) { const candidate = path.segments[candidateIndex]; if (candidate !== undefined && candidate.length > 0) { return cloneVec3(candidate.tangent); } } return { x: 0, y: 0, z: 0 }; } const SMOOTH_PATH_CORNER_CUTTING_PASSES = 3; function lerpVec3(start: Vec3, end: Vec3, t: number): Vec3 { return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t, z: start.z + (end.z - start.z) * t }; } function catmullRomVec3( previous: Vec3, start: Vec3, end: Vec3, next: Vec3, t: number ): Vec3 { const t2 = t * t; const t3 = t2 * t; return { x: 0.5 * (2 * start.x + (-previous.x + end.x) * t + (2 * previous.x - 5 * start.x + 4 * end.x - next.x) * t2 + (-previous.x + 3 * start.x - 3 * end.x + next.x) * t3), y: 0.5 * (2 * start.y + (-previous.y + end.y) * t + (2 * previous.y - 5 * start.y + 4 * end.y - next.y) * t2 + (-previous.y + 3 * start.y - 3 * end.y + next.y) * t3), z: 0.5 * (2 * start.z + (-previous.z + end.z) * t + (2 * previous.z - 5 * start.z + 4 * end.z - next.z) * t2 + (-previous.z + 3 * start.z - 3 * end.z + next.z) * t3) }; } function buildSmoothedPolylinePoints( path: ResolvedPathLike ): Vec3[] { let points = path.points.map((point) => cloneVec3(point.position)); for ( let passIndex = 0; passIndex < SMOOTH_PATH_CORNER_CUTTING_PASSES; passIndex += 1 ) { if (points.length < 2) { return points; } const refined: Vec3[] = []; if (path.loop) { for (let pointIndex = 0; pointIndex < points.length; pointIndex += 1) { const start = points[pointIndex]!; const end = points[(pointIndex + 1) % points.length]!; refined.push(lerpVec3(start, end, 0.25)); refined.push(lerpVec3(start, end, 0.75)); } } else { refined.push(cloneVec3(points[0]!)); for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex += 1) { const start = points[pointIndex]!; const end = points[pointIndex + 1]!; refined.push(lerpVec3(start, end, 0.25)); refined.push(lerpVec3(start, end, 0.75)); } refined.push(cloneVec3(points[points.length - 1]!)); } points = refined; } return points; } function buildSmoothedPathSamples( path: ResolvedPathLike ): SmoothedPathSample[] { if (path.points.length === 0) { return [ { distance: 0, position: { x: 0, y: 0, z: 0 }, tangent: { x: 0, y: 0, z: 0 } } ]; } if (path.points.length < 3 || path.totalLength <= 0) { return path.points.map((point, index) => ({ distance: index === 0 ? 0 : path.segments[Math.min(index - 1, path.segments.length - 1)]?.distanceEnd ?? 0, position: cloneVec3(point.position), tangent: index < path.segments.length ? cloneVec3(path.segments[index]!.tangent) : cloneVec3(path.segments[path.segments.length - 1]?.tangent ?? { x: 0, y: 0, z: 0 }) })); } const samples: SmoothedPathSample[] = []; const points = buildSmoothedPolylinePoints(path); const segmentCount = path.loop ? points.length : points.length - 1; let cumulativeDistance = 0; let previousPosition = cloneVec3(points[0]!); samples.push({ distance: 0, position: previousPosition, tangent: { x: 0, y: 0, z: 0 } }); for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) { const nextPosition = cloneVec3( points[(segmentIndex + 1) % points.length]! ); cumulativeDistance += getVec3Distance(previousPosition, nextPosition); samples.push({ distance: cumulativeDistance, position: nextPosition, tangent: normalizeDelta(subtractVec3(nextPosition, previousPosition)) }); previousPosition = nextPosition; } return samples; } function sampleSmoothedPath( path: ResolvedPathLike, progress: number ): { position: Vec3; tangent: Vec3 } { const samples = buildSmoothedPathSamples(path); if (samples.length === 0) { return { position: { x: 0, y: 0, z: 0 }, tangent: { x: 0, y: 0, z: 0 } }; } const totalDistance = samples[samples.length - 1]!.distance; if (totalDistance <= 0) { return { position: cloneVec3(samples[0]!.position), tangent: cloneVec3(samples[0]!.tangent) }; } const targetDistance = clampProgress(progress) * totalDistance; if (targetDistance >= totalDistance) { return { position: cloneVec3(samples[samples.length - 1]!.position), tangent: cloneVec3(samples[samples.length - 1]!.tangent) }; } const sampleIndex = samples.findIndex( (sample) => sample.distance >= targetDistance ); if (sampleIndex <= 0) { return { position: cloneVec3(samples[0]!.position), tangent: cloneVec3(samples[0]!.tangent) }; } const previousSample = samples[sampleIndex - 1]!; const nextSample = samples[sampleIndex]!; const spanDistance = nextSample.distance - previousSample.distance; const t = spanDistance <= 0 ? 0 : (targetDistance - previousSample.distance) / spanDistance; const position = { x: previousSample.position.x + (nextSample.position.x - previousSample.position.x) * t, y: previousSample.position.y + (nextSample.position.y - previousSample.position.y) * t, z: previousSample.position.z + (nextSample.position.z - previousSample.position.z) * t }; return { position, tangent: normalizeDelta(subtractVec3(nextSample.position, previousSample.position)) }; } export function normalizeScenePathName( name: string | null | undefined ): string | undefined { if (name === undefined || name === null) { return undefined; } const trimmedName = name.trim(); return trimmedName.length === 0 ? undefined : trimmedName; } export function createScenePathPoint( overrides: Partial> = {} ): ScenePathPoint { const position = cloneVec3( overrides.position ?? { x: 0, y: 0, z: 0 } ); assertFiniteVec3(position, "Path point position"); return { id: overrides.id ?? createOpaqueId("path-point"), position }; } export function cloneScenePathPoint(point: ScenePathPoint): ScenePathPoint { return createScenePathPoint(point); } export function createDefaultScenePathPoints(anchor?: Vec3): ScenePathPoint[] { return DEFAULT_SCENE_PATH_POINT_POSITIONS.map((position) => createScenePathPoint({ position: { x: position.x + (anchor?.x ?? 0), y: position.y + (anchor?.y ?? 0), z: position.z + (anchor?.z ?? 0) } }) ); } export function createScenePath( overrides: Partial< Pick< ScenePath, | "id" | "name" | "visible" | "enabled" | "loop" | "curveMode" | "sampledResolution" | "glueToTerrain" | "terrainOffset" | "points" > > = {} ): ScenePath { const points = overrides.points === undefined ? createDefaultScenePathPoints() : overrides.points.map(cloneScenePathPoint); const visible = overrides.visible ?? DEFAULT_SCENE_PATH_VISIBLE; const enabled = overrides.enabled ?? DEFAULT_SCENE_PATH_ENABLED; const loop = overrides.loop ?? DEFAULT_SCENE_PATH_LOOP; const curveMode = overrides.curveMode ?? DEFAULT_SCENE_PATH_CURVE_MODE; const sampledResolution = normalizeScenePathSampledResolution( overrides.sampledResolution ?? DEFAULT_SCENE_PATH_SAMPLED_RESOLUTION ); const glueToTerrain = overrides.glueToTerrain ?? DEFAULT_SCENE_PATH_GLUE_TO_TERRAIN; const terrainOffset = normalizeScenePathTerrainOffset( overrides.terrainOffset ?? DEFAULT_SCENE_PATH_TERRAIN_OFFSET ); if (points.length < MIN_SCENE_PATH_POINT_COUNT) { throw new Error( `Paths must define at least ${MIN_SCENE_PATH_POINT_COUNT} points.` ); } if (typeof visible !== "boolean") { throw new Error("Path visible must be a boolean."); } if (typeof enabled !== "boolean") { throw new Error("Path enabled must be a boolean."); } if (typeof loop !== "boolean") { throw new Error("Path loop must be a boolean."); } if (!isScenePathCurveMode(curveMode)) { throw new Error("Path curve mode must be linear or catmullRom."); } if (typeof glueToTerrain !== "boolean") { throw new Error("Path glue to terrain must be a boolean."); } const seenPointIds = new Set(); for (const point of points) { if (point.id.trim().length === 0) { throw new Error("Path point ids must be non-empty strings."); } if (seenPointIds.has(point.id)) { throw new Error(`Duplicate path point id ${point.id}.`); } seenPointIds.add(point.id); } return { id: overrides.id ?? createOpaqueId("path"), kind: "path", name: normalizeScenePathName(overrides.name), visible, enabled, loop, curveMode, sampledResolution, glueToTerrain, terrainOffset, points }; } export function cloneScenePath(path: ScenePath): ScenePath { return createScenePath(path); } export function areScenePathsEqual(left: ScenePath, right: ScenePath): boolean { return ( left.id === right.id && left.kind === right.kind && left.name === right.name && left.visible === right.visible && left.enabled === right.enabled && left.loop === right.loop && left.curveMode === right.curveMode && left.sampledResolution === right.sampledResolution && left.glueToTerrain === right.glueToTerrain && left.terrainOffset === right.terrainOffset && left.points.length === right.points.length && left.points.every( (point, index) => point.id === right.points[index]?.id && areVec3Equal(point.position, right.points[index].position) ) ); } export function compareScenePaths(left: ScenePath, right: ScenePath): number { const leftName = left.name ?? ""; const rightName = right.name ?? ""; if (leftName !== rightName) { return leftName.localeCompare(rightName); } return left.id.localeCompare(right.id); } export function getScenePaths(paths: Record): ScenePath[] { return Object.values(paths).sort(compareScenePaths); } export function getScenePathLabel(path: ScenePath, index: number): string { return path.name ?? `Path ${index + 1}`; } export function getScenePathPointIndex( path: Pick, pointId: string ): number { return path.points.findIndex((point) => point.id === pointId); } export function getScenePathPoint( path: Pick, pointId: string ): ScenePathPoint | null { const pointIndex = getScenePathPointIndex(path, pointId); return pointIndex === -1 ? null : cloneScenePathPoint(path.points[pointIndex]); } export function createAppendedScenePathPoint(path: ScenePath): ScenePathPoint { const lastPoint = path.points.at(-1); const previousPoint = path.points.length > 1 ? path.points[path.points.length - 2] : null; if (lastPoint === undefined) { return createScenePathPoint(); } if (previousPoint === null) { return createScenePathPoint({ position: { x: lastPoint.position.x + 1, y: lastPoint.position.y, z: lastPoint.position.z } }); } const delta = { x: lastPoint.position.x - previousPoint.position.x, y: lastPoint.position.y - previousPoint.position.y, z: lastPoint.position.z - previousPoint.position.z }; const offset = delta.x === 0 && delta.y === 0 && delta.z === 0 ? { x: 1, y: 0, z: 0 } : delta; return createScenePathPoint({ position: { x: lastPoint.position.x + offset.x, y: lastPoint.position.y + offset.y, z: lastPoint.position.z + offset.z } }); } function createResolvedPathSegment( options: { index: number; startPointId: string; endPointId: string; start: Vec3; end: Vec3; }, distanceStart: number ): ResolvedScenePathSegment { const delta = subtractVec3(options.end, options.start); const length = Math.hypot(delta.x, delta.y, delta.z); return { index: options.index, startPointId: options.startPointId, endPointId: options.endPointId, start: cloneVec3(options.start), end: cloneVec3(options.end), length, distanceStart, distanceEnd: distanceStart + length, tangent: normalizeDelta(delta) }; } function sampleHighestTerrainWorldY( terrains: readonly Terrain[] | undefined, position: Vec3, terrainOffset: number ): number | null { if (terrains === undefined || terrains.length === 0) { return null; } let highestWorldY: number | null = null; for (const terrain of terrains) { const terrainHeight = sampleTerrainHeightAtWorldPosition( terrain, position.x, position.z, false ); if (terrainHeight === null) { continue; } const worldY = terrain.position.y + terrainHeight + terrainOffset; if (highestWorldY === null || worldY > highestWorldY) { highestWorldY = worldY; } } return highestWorldY; } function applyTerrainGlueToPosition( position: Vec3, options: ResolveScenePathOptions, terrainOffset: number ): Vec3 { const terrainWorldY = sampleHighestTerrainWorldY( options.terrains, position, terrainOffset ); return { x: position.x, y: terrainWorldY ?? position.y, z: position.z }; } function applyTerrainGlueToPoint( point: ScenePathPoint, options: ResolveScenePathOptions, terrainOffset: number ): ScenePathPoint { return { ...point, position: applyTerrainGlueToPosition(point.position, options, terrainOffset) }; } function applyTerrainGlueToSegments( segments: ResolvedScenePathSegment[], options: ResolveScenePathOptions, terrainOffset: number ): ResolvedScenePathSegment[] { let totalLength = 0; return segments.map((segment, index) => { const nextSegment = createResolvedPathSegment( { index, startPointId: segment.startPointId, endPointId: segment.endPointId, start: applyTerrainGlueToPosition( segment.start, options, terrainOffset ), end: applyTerrainGlueToPosition(segment.end, options, terrainOffset) }, totalLength ); totalLength = nextSegment.distanceEnd; return nextSegment; }); } function buildLinearResolvedPathSegments( points: ScenePathPoint[], loop: boolean ): ResolvedScenePathSegment[] { const segmentPairs = points.slice(1).map((point, index) => ({ start: points[index]!, end: point })); if (loop && points.length > 1) { segmentPairs.push({ start: points[points.length - 1]!, end: points[0]! }); } let totalLength = 0; return segmentPairs.map(({ start, end }, index) => { const segment = createResolvedPathSegment( { index, startPointId: start.id, endPointId: end.id, start: start.position, end: end.position }, totalLength ); totalLength = segment.distanceEnd; return segment; }); } function buildCatmullRomResolvedPathSegments( points: ScenePathPoint[], loop: boolean, sampledResolution: number ): ResolvedScenePathSegment[] { if (points.length < 3) { return buildLinearResolvedPathSegments(points, loop); } const sourceSegmentCount = loop ? points.length : points.length - 1; const segments: ResolvedScenePathSegment[] = []; let totalLength = 0; for ( let sourceSegmentIndex = 0; sourceSegmentIndex < sourceSegmentCount; sourceSegmentIndex += 1 ) { const startIndex = sourceSegmentIndex; const endIndex = (sourceSegmentIndex + 1) % points.length; const previousIndex = loop ? (sourceSegmentIndex - 1 + points.length) % points.length : Math.max(0, sourceSegmentIndex - 1); const nextIndex = loop ? (sourceSegmentIndex + 2) % points.length : Math.min(points.length - 1, sourceSegmentIndex + 2); const previous = points[previousIndex]!; const start = points[startIndex]!; const end = points[endIndex]!; const next = points[nextIndex]!; for (let sampleIndex = 0; sampleIndex < sampledResolution; sampleIndex += 1) { const sampleStart = catmullRomVec3( previous.position, start.position, end.position, next.position, sampleIndex / sampledResolution ); const sampleEnd = catmullRomVec3( previous.position, start.position, end.position, next.position, (sampleIndex + 1) / sampledResolution ); const segment = createResolvedPathSegment( { index: segments.length, startPointId: start.id, endPointId: end.id, start: sampleStart, end: sampleEnd }, totalLength ); totalLength = segment.distanceEnd; segments.push(segment); } } return segments; } export function resolveScenePath( path: Pick & Partial< Pick< ScenePath, "curveMode" | "sampledResolution" | "glueToTerrain" | "terrainOffset" > >, options: ResolveScenePathOptions = {} ): ResolvedScenePath { const authoredPoints = path.points.map(cloneScenePathPoint); const curveMode = path.curveMode ?? DEFAULT_SCENE_PATH_CURVE_MODE; const sampledResolution = normalizeScenePathSampledResolution( path.sampledResolution ?? DEFAULT_SCENE_PATH_SAMPLED_RESOLUTION ); const glueToTerrain = path.glueToTerrain ?? DEFAULT_SCENE_PATH_GLUE_TO_TERRAIN; const terrainOffset = normalizeScenePathTerrainOffset( path.terrainOffset ?? DEFAULT_SCENE_PATH_TERRAIN_OFFSET ); const authoredSegments = curveMode === "catmullRom" ? buildCatmullRomResolvedPathSegments( authoredPoints, path.loop, sampledResolution ) : buildLinearResolvedPathSegments(authoredPoints, path.loop); const points = glueToTerrain ? authoredPoints.map((point) => applyTerrainGlueToPoint(point, options, terrainOffset) ) : authoredPoints; const segments = glueToTerrain ? applyTerrainGlueToSegments(authoredSegments, options, terrainOffset) : authoredSegments; const totalLength = segments.at(-1)?.distanceEnd ?? 0; return { loop: path.loop, curveMode, sampledResolution, glueToTerrain, terrainOffset, points, segments, totalLength }; } export function getScenePathLength( path: Pick & Partial< Pick< ScenePath, "curveMode" | "sampledResolution" | "glueToTerrain" | "terrainOffset" > >, options: ResolveScenePathOptions = {} ): number { return resolveScenePath(path, options).totalLength; } export function sampleResolvedScenePathPosition( path: ResolvedPathLike, progress: number, options: { smooth?: boolean } = {} ): Vec3 { if (options.smooth && path.curveMode !== "catmullRom") { return sampleSmoothedPath(path, progress).position; } if (path.points.length === 0) { return { x: 0, y: 0, z: 0 }; } const { segmentIndex, distance } = resolvePathSegmentSample(path, progress); if (segmentIndex === null) { return cloneVec3(path.points[0].position); } const segment = path.segments[segmentIndex]; if (segment.length <= 0) { return cloneVec3(segment.start); } const localDistance = Math.min( segment.length, Math.max(0, distance - segment.distanceStart) ); const t = localDistance / segment.length; return { x: segment.start.x + (segment.end.x - segment.start.x) * t, y: segment.start.y + (segment.end.y - segment.start.y) * t, z: segment.start.z + (segment.end.z - segment.start.z) * t }; } export function projectWorldPointOntoSegment( point: Vec3, start: Vec3, end: Vec3 ): ProjectedWorldSegmentPoint { assertFiniteVec3(point, "Projected world point"); assertFiniteVec3(start, "Projected segment start"); assertFiniteVec3(end, "Projected segment end"); const delta = subtractVec3(end, start); const lengthSquared = delta.x * delta.x + delta.y * delta.y + delta.z * delta.z; const pointOffset = subtractVec3(point, start); const unclampedT = lengthSquared <= 1e-8 ? 0 : (pointOffset.x * delta.x + pointOffset.y * delta.y + pointOffset.z * delta.z) / lengthSquared; const progress = clampProgress(unclampedT); const position = { x: start.x + delta.x * progress, y: start.y + delta.y * progress, z: start.z + delta.z * progress }; return { progress, distance: getVec3Distance(position, point), position }; } export function mapWorldPointToScenePathProgressBetweenPoints(options: { point: Vec3; trackStartPoint: Vec3; trackEndPoint: Vec3; railStartProgress: number; railEndProgress: number; }): MappedScenePathProgressBetweenWorldPoints { const projectedTrackPoint = projectWorldPointOntoSegment( options.point, options.trackStartPoint, options.trackEndPoint ); const railProgress = clampProgress( options.railStartProgress + (options.railEndProgress - options.railStartProgress) * projectedTrackPoint.progress ); return { trackProgress: projectedTrackPoint.progress, railProgress, projectedTrackPosition: projectedTrackPoint.position }; } export function resolveNearestPointOnResolvedScenePath( path: ResolvedScenePathProjectionSource, point: Vec3 ): ResolvedScenePathNearestPoint { assertFiniteVec3(point, "Nearest path query point"); if (path.points.length === 0) { return { progress: 0, distance: Math.hypot(point.x, point.y, point.z), distanceAlongPath: 0, segmentIndex: null, position: { x: 0, y: 0, z: 0 }, tangent: { x: 0, y: 0, z: 0 } }; } if (path.segments.length === 0 || path.totalLength <= 0) { const firstPoint = path.points[0]!.position; return { progress: 0, distance: getVec3Distance(firstPoint, point), distanceAlongPath: 0, segmentIndex: null, position: cloneVec3(firstPoint), tangent: { x: 0, y: 0, z: 0 } }; } let nearestSample: ResolvedScenePathNearestPoint | null = null; for (const segment of path.segments) { const delta = subtractVec3(segment.end, segment.start); const lengthSquared = delta.x * delta.x + delta.y * delta.y + delta.z * delta.z; const pointOffset = subtractVec3(point, segment.start); const unclampedT = lengthSquared <= 1e-8 ? 0 : (pointOffset.x * delta.x + pointOffset.y * delta.y + pointOffset.z * delta.z) / lengthSquared; const t = Math.min(1, Math.max(0, unclampedT)); const position = { x: segment.start.x + delta.x * t, y: segment.start.y + delta.y * t, z: segment.start.z + delta.z * t }; const distanceAlongPath = segment.distanceStart + segment.length * t; const progress = clampProgress(distanceAlongPath / path.totalLength); const distance = getVec3Distance(position, point); const tangent = segment.length > 1e-8 ? cloneVec3(segment.tangent) : findNonZeroSegmentTangent( path as ResolvedPathLike, segment.index ); const candidate: ResolvedScenePathNearestPoint = { progress, distance, distanceAlongPath, segmentIndex: segment.index, position, tangent }; if ( nearestSample === null || candidate.distance < nearestSample.distance - 1e-8 || (Math.abs(candidate.distance - nearestSample.distance) <= 1e-8 && candidate.progress < nearestSample.progress) ) { nearestSample = candidate; } } return nearestSample!; } export function sampleScenePathPosition( path: Pick & Partial< Pick< ScenePath, "curveMode" | "sampledResolution" | "glueToTerrain" | "terrainOffset" > >, progress: number, options: { smooth?: boolean; terrains?: readonly Terrain[] } = {} ): Vec3 { return sampleResolvedScenePathPosition( resolveScenePath(path, { terrains: options.terrains }), progress, options ); } export function sampleResolvedScenePathTangent( path: ResolvedPathLike, progress: number, options: { smooth?: boolean } = {} ): Vec3 { if (options.smooth && path.curveMode !== "catmullRom") { return sampleSmoothedPath(path, progress).tangent; } const { segmentIndex } = resolvePathSegmentSample(path, progress); if (segmentIndex === null) { return { x: 0, y: 0, z: 0 }; } return findNonZeroSegmentTangent(path, segmentIndex); } export function sampleScenePathTangent( path: Pick & Partial< Pick< ScenePath, "curveMode" | "sampledResolution" | "glueToTerrain" | "terrainOffset" > >, progress: number, options: { smooth?: boolean; terrains?: readonly Terrain[] } = {} ): Vec3 { return sampleResolvedScenePathTangent( resolveScenePath(path, { terrains: options.terrains }), progress, options ); }