Add terrain-related functionality
This commit is contained in:
291
src/document/terrains.ts
Normal file
291
src/document/terrains.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { createOpaqueId } from "../core/ids";
|
||||||
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
|
export interface Terrain {
|
||||||
|
id: string;
|
||||||
|
kind: "terrain";
|
||||||
|
name?: string;
|
||||||
|
visible: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
position: Vec3;
|
||||||
|
sampleCountX: number;
|
||||||
|
sampleCountZ: number;
|
||||||
|
cellSize: number;
|
||||||
|
heights: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TERRAIN_VISIBLE = true;
|
||||||
|
export const DEFAULT_TERRAIN_ENABLED = true;
|
||||||
|
export const MIN_TERRAIN_SAMPLE_COUNT = 2;
|
||||||
|
export const DEFAULT_TERRAIN_SAMPLE_COUNT_X = 9;
|
||||||
|
export const DEFAULT_TERRAIN_SAMPLE_COUNT_Z = 9;
|
||||||
|
export const DEFAULT_TERRAIN_CELL_SIZE = 1;
|
||||||
|
export const DEFAULT_TERRAIN_HEIGHT = 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 be finite on every axis.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTerrainName(
|
||||||
|
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 normalizeTerrainSampleCount(
|
||||||
|
value: number,
|
||||||
|
label: string
|
||||||
|
): number {
|
||||||
|
if (!Number.isInteger(value) || value < MIN_TERRAIN_SAMPLE_COUNT) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} must be an integer greater than or equal to ${MIN_TERRAIN_SAMPLE_COUNT}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTerrainCellSize(value: number): number {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new Error("Terrain cell size must be a positive finite number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFlatTerrainHeights(
|
||||||
|
sampleCountX: number,
|
||||||
|
sampleCountZ: number,
|
||||||
|
height = DEFAULT_TERRAIN_HEIGHT
|
||||||
|
): number[] {
|
||||||
|
const normalizedSampleCountX = normalizeTerrainSampleCount(
|
||||||
|
sampleCountX,
|
||||||
|
"Terrain sampleCountX"
|
||||||
|
);
|
||||||
|
const normalizedSampleCountZ = normalizeTerrainSampleCount(
|
||||||
|
sampleCountZ,
|
||||||
|
"Terrain sampleCountZ"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Number.isFinite(height)) {
|
||||||
|
throw new Error("Terrain height samples must be finite.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Array(normalizedSampleCountX * normalizedSampleCountZ).fill(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrainSampleIndex(
|
||||||
|
terrain: Pick<Terrain, "sampleCountX" | "sampleCountZ">,
|
||||||
|
sampleX: number,
|
||||||
|
sampleZ: number
|
||||||
|
): number {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(sampleX) ||
|
||||||
|
sampleX < 0 ||
|
||||||
|
sampleX >= terrain.sampleCountX
|
||||||
|
) {
|
||||||
|
throw new Error(`Terrain sampleX ${sampleX} is out of range.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isInteger(sampleZ) ||
|
||||||
|
sampleZ < 0 ||
|
||||||
|
sampleZ >= terrain.sampleCountZ
|
||||||
|
) {
|
||||||
|
throw new Error(`Terrain sampleZ ${sampleZ} is out of range.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampleZ * terrain.sampleCountX + sampleX;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrainHeightAtSample(
|
||||||
|
terrain: Terrain,
|
||||||
|
sampleX: number,
|
||||||
|
sampleZ: number
|
||||||
|
): number {
|
||||||
|
return terrain.heights[getTerrainSampleIndex(terrain, sampleX, sampleZ)] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrainWorldSamplePosition(
|
||||||
|
terrain: Terrain,
|
||||||
|
sampleX: number,
|
||||||
|
sampleZ: number
|
||||||
|
): Vec3 {
|
||||||
|
return {
|
||||||
|
x: terrain.position.x + sampleX * terrain.cellSize,
|
||||||
|
y: terrain.position.y + getTerrainHeightAtSample(terrain, sampleX, sampleZ),
|
||||||
|
z: terrain.position.z + sampleZ * terrain.cellSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrainBounds(terrain: Terrain): { min: Vec3; max: Vec3 } {
|
||||||
|
const width = (terrain.sampleCountX - 1) * terrain.cellSize;
|
||||||
|
const depth = (terrain.sampleCountZ - 1) * terrain.cellSize;
|
||||||
|
let minHeight = Number.POSITIVE_INFINITY;
|
||||||
|
let maxHeight = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const height of terrain.heights) {
|
||||||
|
minHeight = Math.min(minHeight, height);
|
||||||
|
maxHeight = Math.max(maxHeight, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) {
|
||||||
|
minHeight = 0;
|
||||||
|
maxHeight = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: {
|
||||||
|
x: terrain.position.x,
|
||||||
|
y: terrain.position.y + minHeight,
|
||||||
|
z: terrain.position.z
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
x: terrain.position.x + width,
|
||||||
|
y: terrain.position.y + maxHeight,
|
||||||
|
z: terrain.position.z + depth
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultTerrainPosition(
|
||||||
|
sampleCountX: number,
|
||||||
|
sampleCountZ: number,
|
||||||
|
cellSize: number
|
||||||
|
): Vec3 {
|
||||||
|
return {
|
||||||
|
x: -((sampleCountX - 1) * cellSize) * 0.5,
|
||||||
|
y: 0,
|
||||||
|
z: -((sampleCountZ - 1) * cellSize) * 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTerrain(
|
||||||
|
overrides: Partial<
|
||||||
|
Pick<
|
||||||
|
Terrain,
|
||||||
|
| "id"
|
||||||
|
| "name"
|
||||||
|
| "visible"
|
||||||
|
| "enabled"
|
||||||
|
| "position"
|
||||||
|
| "sampleCountX"
|
||||||
|
| "sampleCountZ"
|
||||||
|
| "cellSize"
|
||||||
|
| "heights"
|
||||||
|
>
|
||||||
|
> = {}
|
||||||
|
): Terrain {
|
||||||
|
const sampleCountX = normalizeTerrainSampleCount(
|
||||||
|
overrides.sampleCountX ?? DEFAULT_TERRAIN_SAMPLE_COUNT_X,
|
||||||
|
"Terrain sampleCountX"
|
||||||
|
);
|
||||||
|
const sampleCountZ = normalizeTerrainSampleCount(
|
||||||
|
overrides.sampleCountZ ?? DEFAULT_TERRAIN_SAMPLE_COUNT_Z,
|
||||||
|
"Terrain sampleCountZ"
|
||||||
|
);
|
||||||
|
const cellSize = normalizeTerrainCellSize(
|
||||||
|
overrides.cellSize ?? DEFAULT_TERRAIN_CELL_SIZE
|
||||||
|
);
|
||||||
|
const position = cloneVec3(
|
||||||
|
overrides.position ??
|
||||||
|
createDefaultTerrainPosition(sampleCountX, sampleCountZ, cellSize)
|
||||||
|
);
|
||||||
|
const heights =
|
||||||
|
overrides.heights !== undefined
|
||||||
|
? [...overrides.heights]
|
||||||
|
: createFlatTerrainHeights(sampleCountX, sampleCountZ);
|
||||||
|
const visible = overrides.visible ?? DEFAULT_TERRAIN_VISIBLE;
|
||||||
|
const enabled = overrides.enabled ?? DEFAULT_TERRAIN_ENABLED;
|
||||||
|
|
||||||
|
assertFiniteVec3(position, "Terrain position");
|
||||||
|
|
||||||
|
if (typeof visible !== "boolean") {
|
||||||
|
throw new Error("Terrain visible must be a boolean.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
throw new Error("Terrain enabled must be a boolean.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heights.length !== sampleCountX * sampleCountZ) {
|
||||||
|
throw new Error(
|
||||||
|
`Terrain heights must contain exactly ${sampleCountX * sampleCountZ} samples.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heights.some((height) => !Number.isFinite(height))) {
|
||||||
|
throw new Error("Terrain heights must remain finite.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? createOpaqueId("terrain"),
|
||||||
|
kind: "terrain",
|
||||||
|
name: normalizeTerrainName(overrides.name),
|
||||||
|
visible,
|
||||||
|
enabled,
|
||||||
|
position,
|
||||||
|
sampleCountX,
|
||||||
|
sampleCountZ,
|
||||||
|
cellSize,
|
||||||
|
heights
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneTerrain(terrain: Terrain): Terrain {
|
||||||
|
return createTerrain(terrain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areTerrainsEqual(left: Terrain, right: Terrain): boolean {
|
||||||
|
return (
|
||||||
|
left.id === right.id &&
|
||||||
|
left.kind === right.kind &&
|
||||||
|
left.name === right.name &&
|
||||||
|
left.visible === right.visible &&
|
||||||
|
left.enabled === right.enabled &&
|
||||||
|
areVec3Equal(left.position, right.position) &&
|
||||||
|
left.sampleCountX === right.sampleCountX &&
|
||||||
|
left.sampleCountZ === right.sampleCountZ &&
|
||||||
|
left.cellSize === right.cellSize &&
|
||||||
|
left.heights.length === right.heights.length &&
|
||||||
|
left.heights.every((height, index) => height === right.heights[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareTerrains(left: Terrain, right: Terrain): number {
|
||||||
|
const leftName = left.name ?? "";
|
||||||
|
const rightName = right.name ?? "";
|
||||||
|
|
||||||
|
if (leftName !== rightName) {
|
||||||
|
return leftName.localeCompare(rightName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrains(terrains: Record<string, Terrain>): Terrain[] {
|
||||||
|
return Object.values(terrains).sort(compareTerrains);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerrainKindLabel(): string {
|
||||||
|
return "Terrain";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user