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