Feat: Implement spline corridor junction edge mesh generation

This commit is contained in:
2026-05-13 16:52:37 +02:00
parent 6569e564c0
commit d3a2ba8890

View File

@@ -1,6 +1,7 @@
import { BufferGeometry, Float32BufferAttribute } from "three"; import { BufferGeometry, Float32BufferAttribute } from "three";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import type { ScenePathRoadEdgeSettings } from "../document/paths";
import type { SplineCorridorJunction } from "../document/spline-corridor-junctions"; import type { SplineCorridorJunction } from "../document/spline-corridor-junctions";
import { import {
sampleTerrainHeightAtWorldPosition, sampleTerrainHeightAtWorldPosition,
@@ -8,12 +9,27 @@ import {
} from "../document/terrains"; } from "../document/terrains";
import { import {
buildSplineCorridorJunctionFootprint, buildSplineCorridorJunctionFootprint,
type SplineCorridorJunctionFootprint,
type SplineCorridorJunctionFootprintPoint,
type SplineCorridorJunctionFootprintPathLike type SplineCorridorJunctionFootprintPathLike
} from "./spline-corridor-junction-footprint"; } from "./spline-corridor-junction-footprint";
export type SplineCorridorJunctionMeshPathLike = export type SplineCorridorJunctionMeshPathLike =
SplineCorridorJunctionFootprintPathLike; SplineCorridorJunctionFootprintPathLike;
export interface SplineCorridorJunctionEdgeMeshData {
positions: Float32Array;
uvs: Float32Array;
indices: Uint32Array;
footprintPointCount: number;
profileVertexCount: number;
}
interface JunctionEdgeProfilePoint {
offsetRatio: number;
heightOffset: number;
}
function sampleHighestTerrainWorldY( function sampleHighestTerrainWorldY(
terrains: readonly Terrain[], terrains: readonly Terrain[],
position: Vec3 position: Vec3
@@ -55,6 +71,272 @@ function shouldConformToTerrain(options: {
}); });
} }
export function resolveSplineCorridorJunctionEdgeSettings<
TPath extends SplineCorridorJunctionMeshPathLike
>(options: {
junction: SplineCorridorJunction;
paths: readonly TPath[];
}): TPath["road"]["edges"]["left"] | TPath["road"]["edges"]["right"] | null {
const sides = ["left", "right"] as const;
const enabledEdges: Array<
TPath["road"]["edges"]["left"] | TPath["road"]["edges"]["right"]
> = [];
for (const connection of options.junction.connections) {
const path =
options.paths.find((candidate) => candidate.id === connection.pathId) ??
null;
if (path === null || !path.road.enabled) {
continue;
}
for (const side of sides) {
const edge = path.road.edges[side];
if (edge.enabled && edge.width > 0) {
enabledEdges.push(edge);
}
}
}
return (
enabledEdges.find((edge) => edge.kind !== "softShoulder") ??
enabledEdges[0] ??
null
);
}
function buildJunctionEdgeProfile(
edge: ScenePathRoadEdgeSettings
): JunctionEdgeProfilePoint[] {
switch (edge.kind) {
case "curb":
return [
{ offsetRatio: 0, heightOffset: 0 },
{ offsetRatio: 0, heightOffset: edge.height },
{ offsetRatio: 1, heightOffset: edge.height },
{ offsetRatio: 1, heightOffset: 0 }
];
case "bank":
return [
{ offsetRatio: 0, heightOffset: 0 },
{ offsetRatio: 1, heightOffset: edge.height },
{ offsetRatio: 1, heightOffset: 0 }
];
case "ditch":
return [
{ offsetRatio: 0, heightOffset: 0 },
{ offsetRatio: 0.5, heightOffset: -edge.height },
{ offsetRatio: 1, heightOffset: 0 }
];
case "softShoulder":
return [
{ offsetRatio: 0, heightOffset: 0 },
{ offsetRatio: 1, heightOffset: 0 }
];
}
}
function normalizeXZ(vector: { x: number; z: number }): { x: number; z: number } {
const length = Math.hypot(vector.x, vector.z);
if (length <= 1e-8) {
return {
x: 1,
z: 0
};
}
return {
x: vector.x / length,
z: vector.z / length
};
}
function getOutwardSegmentNormal(
start: SplineCorridorJunctionFootprintPoint,
end: SplineCorridorJunctionFootprintPoint
): { x: number; z: number } {
const direction = normalizeXZ({
x: end.x - start.x,
z: end.z - start.z
});
// Footprints are generated counter-clockwise, so the right-hand normal is outward.
return {
x: direction.z,
z: -direction.x
};
}
function getMiteredOutwardOffset(options: {
footprint: SplineCorridorJunctionFootprint;
pointIndex: number;
edgeWidth: number;
}): { x: number; z: number } {
const points = options.footprint.points;
const previous = points[(options.pointIndex - 1 + points.length) % points.length]!;
const current = points[options.pointIndex]!;
const next = points[(options.pointIndex + 1) % points.length]!;
const previousNormal = getOutwardSegmentNormal(previous, current);
const nextNormal = getOutwardSegmentNormal(current, next);
const miter = normalizeXZ({
x: previousNormal.x + nextNormal.x,
z: previousNormal.z + nextNormal.z
});
const scale = Math.min(
3,
Math.max(1, 1 / Math.max(0.25, miter.x * nextNormal.x + miter.z * nextNormal.z))
);
return {
x: miter.x * options.edgeWidth * scale,
z: miter.z * options.edgeWidth * scale
};
}
function getPerimeterDistances(
points: readonly SplineCorridorJunctionFootprintPoint[]
): number[] {
const distances = [0];
for (let index = 1; index <= points.length; index += 1) {
const previous = points[index - 1]!;
const current = points[index % points.length]!;
const previousDistance = distances[index - 1]!;
distances.push(
previousDistance + Math.hypot(current.x - previous.x, current.z - previous.z)
);
}
return distances;
}
export function buildSplineCorridorJunctionEdgeMeshData(options: {
junction: SplineCorridorJunction;
paths: readonly SplineCorridorJunctionMeshPathLike[];
edge: ScenePathRoadEdgeSettings;
terrains?: readonly Terrain[];
}): SplineCorridorJunctionEdgeMeshData | null {
if (!options.edge.enabled || options.edge.width <= 0) {
return null;
}
const terrains = options.terrains ?? [];
const footprint = buildSplineCorridorJunctionFootprint({
junction: options.junction,
paths: options.paths,
terrains
});
if (footprint === null || footprint.points.length < 3) {
return null;
}
const conformToTerrain = shouldConformToTerrain({
junction: options.junction,
paths: options.paths
});
const profile = buildJunctionEdgeProfile(options.edge);
if (profile.length < 2) {
return null;
}
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
const perimeterDistances = getPerimeterDistances(footprint.points);
const profileLastIndex = Math.max(1, profile.length - 1);
function resolveBaseY(
point: SplineCorridorJunctionFootprintPoint,
x: number,
z: number
): number {
const terrainWorldY = conformToTerrain
? sampleHighestTerrainWorldY(terrains, {
x,
y: point.fallbackY,
z
})
: null;
return terrainWorldY === null
? point.fallbackY
: terrainWorldY + point.heightOffset;
}
for (let pointIndex = 0; pointIndex <= footprint.points.length; pointIndex += 1) {
const wrappedPointIndex = pointIndex % footprint.points.length;
const point = footprint.points[wrappedPointIndex]!;
const outwardOffset = getMiteredOutwardOffset({
footprint,
pointIndex: wrappedPointIndex,
edgeWidth: options.edge.width
});
for (let profileIndex = 0; profileIndex < profile.length; profileIndex += 1) {
const profilePoint = profile[profileIndex]!;
const x = point.x + outwardOffset.x * profilePoint.offsetRatio;
const z = point.z + outwardOffset.z * profilePoint.offsetRatio;
const y = resolveBaseY(point, x, z) + profilePoint.heightOffset;
positions.push(x, y, z);
uvs.push(perimeterDistances[pointIndex]!, profileIndex / profileLastIndex);
}
}
for (let pointIndex = 0; pointIndex < footprint.points.length; pointIndex += 1) {
const currentRow = pointIndex * profile.length;
const nextRow = currentRow + profile.length;
for (let profileIndex = 0; profileIndex < profile.length - 1; profileIndex += 1) {
const current = currentRow + profileIndex;
const currentNext = current + 1;
const next = nextRow + profileIndex;
const nextNext = next + 1;
indices.push(current, currentNext, next, next, currentNext, nextNext);
}
}
return {
positions: new Float32Array(positions),
uvs: new Float32Array(uvs),
indices: new Uint32Array(indices),
footprintPointCount: footprint.points.length,
profileVertexCount: profile.length
};
}
export function buildSplineCorridorJunctionEdgeMeshGeometry(options: {
junction: SplineCorridorJunction;
paths: readonly SplineCorridorJunctionMeshPathLike[];
edge: ScenePathRoadEdgeSettings;
terrains?: readonly Terrain[];
}): BufferGeometry | null {
const meshData = buildSplineCorridorJunctionEdgeMeshData(options);
if (meshData === null) {
return null;
}
const geometry = new BufferGeometry();
geometry.setAttribute(
"position",
new Float32BufferAttribute(meshData.positions, 3)
);
geometry.setAttribute("uv", new Float32BufferAttribute(meshData.uvs, 2));
geometry.setIndex([...meshData.indices]);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
export function buildSplineCorridorJunctionMeshGeometry(options: { export function buildSplineCorridorJunctionMeshGeometry(options: {
junction: SplineCorridorJunction; junction: SplineCorridorJunction;
paths: readonly SplineCorridorJunctionMeshPathLike[]; paths: readonly SplineCorridorJunctionMeshPathLike[];