auto-git:
[add] src/geometry/spline-road-terrain.ts
This commit is contained in:
362
src/geometry/spline-road-terrain.ts
Normal file
362
src/geometry/spline-road-terrain.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import type { TerrainBrushPatch } from "../core/terrain-brush";
|
||||||
|
import {
|
||||||
|
resolveScenePath,
|
||||||
|
type ResolvedScenePath,
|
||||||
|
type ResolvedScenePathSegment,
|
||||||
|
type ScenePath
|
||||||
|
} from "../document/paths";
|
||||||
|
import {
|
||||||
|
createTerrain,
|
||||||
|
getTerrainFoliageMaskSampleIndex,
|
||||||
|
getTerrainHeightAtSample,
|
||||||
|
getTerrainPaintWeightSampleOffset,
|
||||||
|
getTerrainSampleIndex,
|
||||||
|
getTerrainSampleLayerWeights,
|
||||||
|
TERRAIN_LAYER_COUNT,
|
||||||
|
type Terrain
|
||||||
|
} from "../document/terrains";
|
||||||
|
import { createTerrainBrushPatchFromTerrains } from "./terrain-brush";
|
||||||
|
|
||||||
|
interface RoadProjection {
|
||||||
|
distance: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoadBounds {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(start: number, end: number, alpha: number): number {
|
||||||
|
return start + (end - start) * alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerrainConformPath(path: ScenePath): ScenePath {
|
||||||
|
return path.road.terrainConform
|
||||||
|
? {
|
||||||
|
...path,
|
||||||
|
glueToTerrain: true,
|
||||||
|
terrainOffset: path.glueToTerrain ? path.terrainOffset : 0
|
||||||
|
}
|
||||||
|
: path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoadInfluence(distance: number, path: ScenePath): number {
|
||||||
|
const halfWidth = path.road.width * 0.5;
|
||||||
|
|
||||||
|
if (distance <= halfWidth) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.road.shoulderWidth <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shoulderProgress = (distance - halfWidth) / path.road.shoulderWidth;
|
||||||
|
|
||||||
|
if (shoulderProgress >= 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exponent = 1 + path.road.falloff * 3;
|
||||||
|
return Math.pow(1 - clamp(shoulderProgress, 0, 1), exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectWorldPointOntoSegmentXZ(
|
||||||
|
segment: ResolvedScenePathSegment,
|
||||||
|
worldX: number,
|
||||||
|
worldZ: number
|
||||||
|
): RoadProjection {
|
||||||
|
const deltaX = segment.end.x - segment.start.x;
|
||||||
|
const deltaZ = segment.end.z - segment.start.z;
|
||||||
|
const lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
|
||||||
|
const t =
|
||||||
|
lengthSquared <= 1e-8
|
||||||
|
? 0
|
||||||
|
: clamp(
|
||||||
|
((worldX - segment.start.x) * deltaX +
|
||||||
|
(worldZ - segment.start.z) * deltaZ) /
|
||||||
|
lengthSquared,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const projectedX = segment.start.x + deltaX * t;
|
||||||
|
const projectedZ = segment.start.z + deltaZ * t;
|
||||||
|
|
||||||
|
return {
|
||||||
|
distance: Math.hypot(worldX - projectedX, worldZ - projectedZ),
|
||||||
|
y: lerp(segment.start.y, segment.end.y, t)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectWorldPointOntoRoadXZ(
|
||||||
|
path: ResolvedScenePath,
|
||||||
|
worldX: number,
|
||||||
|
worldZ: number
|
||||||
|
): RoadProjection | null {
|
||||||
|
let nearestProjection: RoadProjection | null = null;
|
||||||
|
|
||||||
|
for (const segment of path.segments) {
|
||||||
|
const projection = projectWorldPointOntoSegmentXZ(segment, worldX, worldZ);
|
||||||
|
|
||||||
|
if (
|
||||||
|
nearestProjection === null ||
|
||||||
|
projection.distance < nearestProjection.distance
|
||||||
|
) {
|
||||||
|
nearestProjection = projection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestProjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoadBounds(path: ResolvedScenePath, radius: number): RoadBounds | null {
|
||||||
|
if (path.segments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Number.POSITIVE_INFINITY;
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY;
|
||||||
|
let minZ = Number.POSITIVE_INFINITY;
|
||||||
|
let maxZ = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const segment of path.segments) {
|
||||||
|
minX = Math.min(minX, segment.start.x, segment.end.x);
|
||||||
|
maxX = Math.max(maxX, segment.start.x, segment.end.x);
|
||||||
|
minZ = Math.min(minZ, segment.start.z, segment.end.z);
|
||||||
|
maxZ = Math.max(maxZ, segment.start.z, segment.end.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: minX - radius,
|
||||||
|
maxX: maxX + radius,
|
||||||
|
minZ: minZ - radius,
|
||||||
|
maxZ: maxZ + radius
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTerrainSampleBoundsForRoad(
|
||||||
|
terrain: Terrain,
|
||||||
|
bounds: RoadBounds
|
||||||
|
): { minSampleX: number; maxSampleX: number; minSampleZ: number; maxSampleZ: number } | null {
|
||||||
|
const minSampleX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((bounds.minX - terrain.position.x) / terrain.cellSize)
|
||||||
|
);
|
||||||
|
const maxSampleX = Math.min(
|
||||||
|
terrain.sampleCountX - 1,
|
||||||
|
Math.ceil((bounds.maxX - terrain.position.x) / terrain.cellSize)
|
||||||
|
);
|
||||||
|
const minSampleZ = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((bounds.minZ - terrain.position.z) / terrain.cellSize)
|
||||||
|
);
|
||||||
|
const maxSampleZ = Math.min(
|
||||||
|
terrain.sampleCountZ - 1,
|
||||||
|
Math.ceil((bounds.maxZ - terrain.position.z) / terrain.cellSize)
|
||||||
|
);
|
||||||
|
|
||||||
|
return minSampleX > maxSampleX || minSampleZ > maxSampleZ
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
minSampleX,
|
||||||
|
maxSampleX,
|
||||||
|
minSampleZ,
|
||||||
|
maxSampleZ
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoadPaintLayerIndex(path: ScenePath, terrain: Terrain): number | null {
|
||||||
|
if (path.road.materialId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerIndex = terrain.layers.findIndex(
|
||||||
|
(layer) => layer.materialId === path.road.materialId
|
||||||
|
);
|
||||||
|
|
||||||
|
return layerIndex === -1 ? null : layerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoadTargetWeights(
|
||||||
|
layerIndex: number
|
||||||
|
): [number, number, number, number] {
|
||||||
|
return [
|
||||||
|
layerIndex === 0 ? 1 : 0,
|
||||||
|
layerIndex === 1 ? 1 : 0,
|
||||||
|
layerIndex === 2 ? 1 : 0,
|
||||||
|
layerIndex === 3 ? 1 : 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoadPaintWeights(
|
||||||
|
terrain: Terrain,
|
||||||
|
sampleX: number,
|
||||||
|
sampleZ: number,
|
||||||
|
layerIndex: number,
|
||||||
|
influence: number,
|
||||||
|
changedPaintWeightIndices: Set<number>
|
||||||
|
) {
|
||||||
|
const currentWeights = getTerrainSampleLayerWeights(terrain, sampleX, sampleZ);
|
||||||
|
const targetWeights = createRoadTargetWeights(layerIndex);
|
||||||
|
const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let layerOffset = 0;
|
||||||
|
layerOffset < TERRAIN_LAYER_COUNT - 1;
|
||||||
|
layerOffset += 1
|
||||||
|
) {
|
||||||
|
const paintWeightIndex = offset + layerOffset;
|
||||||
|
const currentWeight = currentWeights[layerOffset + 1] ?? 0;
|
||||||
|
const nextWeight = lerp(
|
||||||
|
currentWeight,
|
||||||
|
targetWeights[layerOffset + 1] ?? 0,
|
||||||
|
influence
|
||||||
|
);
|
||||||
|
|
||||||
|
if (terrain.paintWeights[paintWeightIndex] === nextWeight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
terrain.paintWeights[paintWeightIndex] = nextWeight;
|
||||||
|
changedPaintWeightIndices.add(paintWeightIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSplineRoadTerrainPatch(options: {
|
||||||
|
path: ScenePath;
|
||||||
|
terrain: Terrain;
|
||||||
|
}): TerrainBrushPatch | null {
|
||||||
|
const { path, terrain } = options;
|
||||||
|
|
||||||
|
if (!path.road.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolveScenePath(createTerrainConformPath(path), {
|
||||||
|
terrains: [terrain]
|
||||||
|
});
|
||||||
|
const influenceRadius = path.road.width * 0.5 + path.road.shoulderWidth;
|
||||||
|
const roadBounds = getRoadBounds(resolvedPath, influenceRadius);
|
||||||
|
|
||||||
|
if (roadBounds === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleBounds = getTerrainSampleBoundsForRoad(terrain, roadBounds);
|
||||||
|
|
||||||
|
if (sampleBounds === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTerrain = createTerrain(terrain);
|
||||||
|
const changedHeightIndices = new Set<number>();
|
||||||
|
const changedPaintWeightIndices = new Set<number>();
|
||||||
|
const changedFoliageBlockerIndices = new Set<number>();
|
||||||
|
const roadPaintLayerIndex = getRoadPaintLayerIndex(path, nextTerrain);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let sampleZ = sampleBounds.minSampleZ;
|
||||||
|
sampleZ <= sampleBounds.maxSampleZ;
|
||||||
|
sampleZ += 1
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let sampleX = sampleBounds.minSampleX;
|
||||||
|
sampleX <= sampleBounds.maxSampleX;
|
||||||
|
sampleX += 1
|
||||||
|
) {
|
||||||
|
const worldX = terrain.position.x + sampleX * terrain.cellSize;
|
||||||
|
const worldZ = terrain.position.z + sampleZ * terrain.cellSize;
|
||||||
|
const projection = projectWorldPointOntoRoadXZ(
|
||||||
|
resolvedPath,
|
||||||
|
worldX,
|
||||||
|
worldZ
|
||||||
|
);
|
||||||
|
|
||||||
|
if (projection === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const influence = getRoadInfluence(projection.distance, path);
|
||||||
|
|
||||||
|
if (influence <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleIndex = getTerrainSampleIndex(terrain, sampleX, sampleZ);
|
||||||
|
const currentHeight = getTerrainHeightAtSample(
|
||||||
|
nextTerrain,
|
||||||
|
sampleX,
|
||||||
|
sampleZ
|
||||||
|
);
|
||||||
|
const targetHeight =
|
||||||
|
projection.y + path.road.heightOffset - terrain.position.y;
|
||||||
|
const nextHeight = lerp(currentHeight, targetHeight, influence);
|
||||||
|
|
||||||
|
if (nextHeight !== currentHeight) {
|
||||||
|
nextTerrain.heights[sampleIndex] = nextHeight;
|
||||||
|
changedHeightIndices.add(sampleIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roadPaintLayerIndex !== null) {
|
||||||
|
applyRoadPaintWeights(
|
||||||
|
nextTerrain,
|
||||||
|
sampleX,
|
||||||
|
sampleZ,
|
||||||
|
roadPaintLayerIndex,
|
||||||
|
influence,
|
||||||
|
changedPaintWeightIndices
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockerIndex = getTerrainFoliageMaskSampleIndex(
|
||||||
|
nextTerrain.foliageBlockerMask,
|
||||||
|
sampleX,
|
||||||
|
sampleZ
|
||||||
|
);
|
||||||
|
const currentBlocker =
|
||||||
|
nextTerrain.foliageBlockerMask.values[blockerIndex] ?? 0;
|
||||||
|
const nextBlocker = Math.max(currentBlocker, influence);
|
||||||
|
|
||||||
|
if (nextBlocker !== currentBlocker) {
|
||||||
|
nextTerrain.foliageBlockerMask.values[blockerIndex] = nextBlocker;
|
||||||
|
changedFoliageBlockerIndices.add(blockerIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = createTerrainBrushPatchFromTerrains({
|
||||||
|
before: terrain,
|
||||||
|
after: nextTerrain,
|
||||||
|
heightSampleIndices: changedHeightIndices,
|
||||||
|
paintWeightIndices: changedPaintWeightIndices,
|
||||||
|
foliageBlockerMaskValueIndices: changedFoliageBlockerIndices
|
||||||
|
});
|
||||||
|
|
||||||
|
return patch.heightSamples.length === 0 &&
|
||||||
|
patch.paintWeights.length === 0 &&
|
||||||
|
patch.foliageBlockerMaskValues.length === 0
|
||||||
|
? null
|
||||||
|
: patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSplineRoadTerrainPatches(options: {
|
||||||
|
path: ScenePath;
|
||||||
|
terrains: readonly Terrain[];
|
||||||
|
}): TerrainBrushPatch[] {
|
||||||
|
return options.terrains
|
||||||
|
.filter((terrain) => terrain.enabled)
|
||||||
|
.map((terrain) =>
|
||||||
|
createSplineRoadTerrainPatch({
|
||||||
|
path: options.path,
|
||||||
|
terrain
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((patch): patch is TerrainBrushPatch => patch !== null);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user