Files
webeditor3d/tests/domain/terrains.test.ts

635 lines
19 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createTerrainFoliageBlockerMask,
createTerrainFoliageMask,
getTerrainBounds,
getTerrainFoliageBlockerMaskValueAtSample,
getTerrainFoliageMask,
getTerrainFoliageMaskValueAtSample,
getTerrainFootprintDepth,
getTerrainFootprintWidth,
getTerrainHeightAtSample,
getTerrainPaintWeightSampleOffset,
createTerrainWithAddedLayer,
createTerrainWithRemovedLayer,
sampleTerrainHeightAtLocalPosition,
sampleTerrainFoliageBlockerMaskAtWorldPosition,
sampleTerrainFoliageMaskAtWorldPosition,
updateTerrainBoundsCacheAfterHeightPatch,
resizeTerrainGrid,
createTerrain
} from "../../src/document/terrains";
function createIndexedHeights(
sampleCountX: number,
sampleCountZ: number
): number[] {
return Array.from({ length: sampleCountX * sampleCountZ }, (_, index) => {
const sampleX = index % sampleCountX;
const sampleZ = Math.floor(index / sampleCountX);
return sampleX + sampleZ * 10;
});
}
function createIndexedPaintWeights(
sampleCountX: number,
sampleCountZ: number
): number[] {
const paintWeights: number[] = [];
for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) {
for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) {
paintWeights.push(
sampleX * 0.01 + sampleZ * 0.02,
sampleX * 0.03,
sampleZ * 0.04
);
}
}
return paintWeights;
}
function createIndexedMaskValues(
sampleCountX: number,
sampleCountZ: number
): number[] {
const sampleCount = sampleCountX * sampleCountZ;
return Array.from({ length: sampleCount }, (_, index) => index / sampleCount);
}
describe("terrain grid resizing", () => {
it("changes Cell Size from 1m to 0.5m by increasing resolution and preserving footprint", () => {
const terrain = createTerrain({
id: "terrain-cell-size-resolution",
sampleCountX: 9,
sampleCountZ: 9,
cellSize: 1,
heights: createIndexedHeights(9, 9)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: 0.5
});
expect(resizedTerrain.sampleCountX).toBe(17);
expect(resizedTerrain.sampleCountZ).toBe(17);
expect(resizedTerrain.cellSize).toBe(0.5);
expect(getTerrainFootprintWidth(resizedTerrain)).toBe(8);
expect(getTerrainFootprintDepth(resizedTerrain)).toBe(8);
expect(resizedTerrain.position).toEqual(terrain.position);
});
it("preserves terrain heights at representative local positions when Cell Size changes", () => {
const terrain = createTerrain({
id: "terrain-cell-size-height-preserve",
sampleCountX: 9,
sampleCountZ: 9,
cellSize: 1,
heights: createIndexedHeights(9, 9)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: 0.5
});
expect(sampleTerrainHeightAtLocalPosition(resizedTerrain, 2.5, 3.5)).toBeCloseTo(
sampleTerrainHeightAtLocalPosition(terrain, 2.5, 3.5) ?? 0
);
expect(sampleTerrainHeightAtLocalPosition(resizedTerrain, 6, 1)).toBeCloseTo(
sampleTerrainHeightAtLocalPosition(terrain, 6, 1) ?? 0
);
});
it("preserves paint weights at representative local positions when Cell Size changes", () => {
const terrain = createTerrain({
id: "terrain-cell-size-paint-preserve",
sampleCountX: 9,
sampleCountZ: 9,
cellSize: 1,
paintWeights: createIndexedPaintWeights(9, 9)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: 0.5
});
const originalOffset = getTerrainPaintWeightSampleOffset(terrain, 4, 2);
const resizedOffset = getTerrainPaintWeightSampleOffset(resizedTerrain, 8, 4);
expect(resizedTerrain.paintWeights[resizedOffset]).toBeCloseTo(
terrain.paintWeights[originalOffset] ?? 0
);
expect(resizedTerrain.paintWeights[resizedOffset + 1]).toBeCloseTo(
terrain.paintWeights[originalOffset + 1] ?? 0
);
expect(resizedTerrain.paintWeights[resizedOffset + 2]).toBeCloseTo(
terrain.paintWeights[originalOffset + 2] ?? 0
);
});
it("increases Samples X/Z by extending terrain instead of stretching existing data", () => {
const terrain = createTerrain({
id: "terrain-samples-extend",
sampleCountX: 3,
sampleCountZ: 3,
cellSize: 1,
heights: createIndexedHeights(3, 3)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: 5,
sampleCountZ: 5,
cellSize: terrain.cellSize
});
expect(resizedTerrain.position).toEqual(terrain.position);
expect(resizedTerrain.cellSize).toBe(1);
expect(getTerrainFootprintWidth(resizedTerrain)).toBe(4);
expect(getTerrainFootprintDepth(resizedTerrain)).toBe(4);
expect(getTerrainHeightAtSample(resizedTerrain, 0, 0)).toBe(0);
expect(getTerrainHeightAtSample(resizedTerrain, 1, 1)).toBe(11);
expect(getTerrainHeightAtSample(resizedTerrain, 2, 2)).toBe(22);
});
it("extends west and south when those cardinal resize directions are selected", () => {
const terrain = createTerrain({
id: "terrain-samples-extend-west-south",
position: { x: 10, y: 0, z: 20 },
sampleCountX: 3,
sampleCountZ: 3,
cellSize: 1,
heights: createIndexedHeights(3, 3)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: 5,
sampleCountZ: 5,
cellSize: terrain.cellSize,
resizeDirectionX: "west",
resizeDirectionZ: "south"
});
expect(resizedTerrain.position).toEqual({ x: 8, y: 0, z: 18 });
expect(getTerrainHeightAtSample(resizedTerrain, 2, 2)).toBe(0);
expect(getTerrainHeightAtSample(resizedTerrain, 4, 4)).toBe(22);
expect(getTerrainHeightAtSample(resizedTerrain, 0, 2)).toBe(0);
expect(getTerrainHeightAtSample(resizedTerrain, 2, 0)).toBe(0);
});
it("decreases Samples X/Z by cropping terrain instead of squashing existing data", () => {
const terrain = createTerrain({
id: "terrain-samples-crop",
sampleCountX: 5,
sampleCountZ: 5,
cellSize: 1,
heights: createIndexedHeights(5, 5)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: 3,
sampleCountZ: 3,
cellSize: terrain.cellSize
});
expect(resizedTerrain.position).toEqual(terrain.position);
expect(resizedTerrain.cellSize).toBe(1);
expect(getTerrainFootprintWidth(resizedTerrain)).toBe(2);
expect(getTerrainFootprintDepth(resizedTerrain)).toBe(2);
expect(getTerrainHeightAtSample(resizedTerrain, 0, 0)).toBe(0);
expect(getTerrainHeightAtSample(resizedTerrain, 1, 1)).toBe(11);
expect(getTerrainHeightAtSample(resizedTerrain, 2, 2)).toBe(22);
});
it("crops west and south when those cardinal resize directions are selected", () => {
const terrain = createTerrain({
id: "terrain-samples-crop-west-south",
sampleCountX: 5,
sampleCountZ: 5,
cellSize: 1,
heights: createIndexedHeights(5, 5)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: 3,
sampleCountZ: 3,
cellSize: terrain.cellSize,
resizeDirectionX: "west",
resizeDirectionZ: "south"
});
expect(resizedTerrain.position).toEqual({
x: terrain.position.x + 2,
y: terrain.position.y,
z: terrain.position.z + 2
});
expect(getTerrainHeightAtSample(resizedTerrain, 0, 0)).toBe(22);
expect(getTerrainHeightAtSample(resizedTerrain, 1, 1)).toBe(33);
expect(getTerrainHeightAtSample(resizedTerrain, 2, 2)).toBe(44);
});
it("fills newly extended height samples from the nearest existing edge height", () => {
const terrain = createTerrain({
id: "terrain-samples-edge-fill",
sampleCountX: 3,
sampleCountZ: 3,
cellSize: 1,
heights: createIndexedHeights(3, 3)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: 5,
sampleCountZ: 5,
cellSize: terrain.cellSize
});
expect(getTerrainHeightAtSample(resizedTerrain, 4, 1)).toBe(12);
expect(getTerrainHeightAtSample(resizedTerrain, 1, 4)).toBe(21);
expect(getTerrainHeightAtSample(resizedTerrain, 4, 4)).toBe(22);
});
it("preserves paint weights at the same world positions with west and south resizing", () => {
const terrain = createTerrain({
id: "terrain-samples-paint-west-south",
position: { x: 10, y: 0, z: 20 },
sampleCountX: 3,
sampleCountZ: 3,
cellSize: 1,
paintWeights: createIndexedPaintWeights(3, 3)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: 5,
sampleCountZ: 5,
cellSize: terrain.cellSize,
resizeDirectionX: "west",
resizeDirectionZ: "south"
});
const originalOffset = getTerrainPaintWeightSampleOffset(terrain, 1, 1);
const resizedOffset = getTerrainPaintWeightSampleOffset(resizedTerrain, 3, 3);
expect(resizedTerrain.paintWeights[resizedOffset]).toBeCloseTo(
terrain.paintWeights[originalOffset] ?? 0
);
expect(resizedTerrain.paintWeights[resizedOffset + 1]).toBeCloseTo(
terrain.paintWeights[originalOffset + 1] ?? 0
);
expect(resizedTerrain.paintWeights[resizedOffset + 2]).toBeCloseTo(
terrain.paintWeights[originalOffset + 2] ?? 0
);
});
it("keeps foliage masks and blocker masks valid after cell-size and sample-count edits", () => {
const foliageLayerId = "foliage-layer-resize-valid";
const terrain = createTerrain({
id: "terrain-masks-resize-valid",
sampleCountX: 3,
sampleCountZ: 3,
cellSize: 1,
foliageMasks: {
[foliageLayerId]: createTerrainFoliageMask({
layerId: foliageLayerId,
resolutionX: 3,
resolutionZ: 3,
values: createIndexedMaskValues(3, 3)
})
},
foliageBlockerMask: createTerrainFoliageBlockerMask({
resolutionX: 3,
resolutionZ: 3,
values: createIndexedMaskValues(3, 3)
})
});
const resolutionChangedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: 0.5
});
const extendedTerrain = resizeTerrainGrid(resolutionChangedTerrain, {
sampleCountX: 6,
sampleCountZ: 4,
cellSize: resolutionChangedTerrain.cellSize,
resizeDirectionX: "west",
resizeDirectionZ: "south"
});
const resizedMask = getTerrainFoliageMask(extendedTerrain, foliageLayerId);
expect(resizedMask?.resolutionX).toBe(extendedTerrain.sampleCountX);
expect(resizedMask?.resolutionZ).toBe(extendedTerrain.sampleCountZ);
expect(resizedMask?.values).toHaveLength(
extendedTerrain.sampleCountX * extendedTerrain.sampleCountZ
);
expect(extendedTerrain.foliageBlockerMask.resolutionX).toBe(
extendedTerrain.sampleCountX
);
expect(extendedTerrain.foliageBlockerMask.resolutionZ).toBe(
extendedTerrain.sampleCountZ
);
expect(extendedTerrain.foliageBlockerMask.values).toHaveLength(
extendedTerrain.sampleCountX * extendedTerrain.sampleCountZ
);
expect(resizedMask?.values.every((value) => Number.isFinite(value))).toBe(
true
);
expect(
extendedTerrain.foliageBlockerMask.values.every((value) =>
Number.isFinite(value)
)
).toBe(true);
});
it("chooses deterministic sample counts for non-divisible footprint and cell-size edits", () => {
const terrain = createTerrain({
id: "terrain-cell-size-non-divisible",
sampleCountX: 9,
sampleCountZ: 9,
cellSize: 1,
heights: createIndexedHeights(9, 9)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: 3
});
expect(resizedTerrain.cellSize).toBe(3);
expect(resizedTerrain.sampleCountX).toBe(4);
expect(resizedTerrain.sampleCountZ).toBe(4);
expect(getTerrainFootprintWidth(resizedTerrain)).toBe(9);
expect(getTerrainFootprintDepth(resizedTerrain)).toBe(9);
expect(resizedTerrain.position).toEqual(terrain.position);
});
it("uses cardinal directions for non-divisible footprint and cell-size edits", () => {
const terrain = createTerrain({
id: "terrain-cell-size-non-divisible-west-south",
sampleCountX: 9,
sampleCountZ: 9,
cellSize: 1,
heights: createIndexedHeights(9, 9)
});
const resizedTerrain = resizeTerrainGrid(terrain, {
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: 3,
resizeDirectionX: "west",
resizeDirectionZ: "south"
});
expect(resizedTerrain.cellSize).toBe(3);
expect(resizedTerrain.sampleCountX).toBe(4);
expect(resizedTerrain.sampleCountZ).toBe(4);
expect(getTerrainFootprintWidth(resizedTerrain)).toBe(9);
expect(getTerrainFootprintDepth(resizedTerrain)).toBe(9);
expect(resizedTerrain.position).toEqual({
x: terrain.position.x - 1,
y: terrain.position.y,
z: terrain.position.z - 1
});
});
});
describe("terrain material layers", () => {
it("adds a layer by expanding compact paint weights with zero influence", () => {
const terrain = createTerrain({
id: "terrain-add-layer",
sampleCountX: 2,
sampleCountZ: 2,
paintWeights: [
0.2,
0.3,
0.1,
0.4,
0.1,
0,
0,
0.5,
0,
0.25,
0.25,
0.25
]
});
const nextTerrain = createTerrainWithAddedLayer(terrain, "road-material");
const firstSampleOffset = getTerrainPaintWeightSampleOffset(
nextTerrain,
0,
0
);
const secondSampleOffset = getTerrainPaintWeightSampleOffset(
nextTerrain,
1,
0
);
expect(nextTerrain.layers).toHaveLength(5);
expect(nextTerrain.layers[4]).toEqual({ materialId: "road-material" });
expect(nextTerrain.paintWeights.slice(firstSampleOffset, firstSampleOffset + 4)).toEqual([
0.2,
0.3,
0.1,
0
]);
expect(nextTerrain.paintWeights.slice(secondSampleOffset, secondSampleOffset + 4)).toEqual([
0.4,
0.1,
0,
0
]);
});
it("removes non-base layers by compacting explicit weights", () => {
const terrain = createTerrainWithAddedLayer(
createTerrain({
id: "terrain-remove-layer",
sampleCountX: 2,
sampleCountZ: 2,
paintWeights: [
0.2,
0.3,
0.1,
0.4,
0.1,
0,
0,
0.5,
0,
0.25,
0.25,
0.25
]
}),
"fifth-material"
);
const nextTerrain = createTerrainWithRemovedLayer(terrain, 2);
expect(nextTerrain.layers).toHaveLength(4);
expect(nextTerrain.paintWeights).toEqual([
0.2,
0.1,
0,
0.4,
0,
0,
0,
0,
0,
0.25,
0.25,
0
]);
});
});
describe("terrain foliage blocker masks", () => {
it("creates, clones, normalizes, and samples blocker mask values", () => {
const terrain = createTerrain({
id: "terrain-foliage-blocker-mask-sample",
position: { x: 10, y: 0, z: 20 },
sampleCountX: 2,
sampleCountZ: 2,
cellSize: 2,
foliageBlockerMask: createTerrainFoliageBlockerMask({
resolutionX: 2,
resolutionZ: 2,
values: [0, 1.2, -1, 0.5]
})
});
const clonedTerrain = createTerrain(terrain);
expect(terrain.foliageBlockerMask.values).toEqual([0, 1, 0, 0.5]);
expect(clonedTerrain.foliageBlockerMask).toEqual(
terrain.foliageBlockerMask
);
expect(clonedTerrain.foliageBlockerMask).not.toBe(
terrain.foliageBlockerMask
);
expect(
getTerrainFoliageBlockerMaskValueAtSample(
terrain.foliageBlockerMask,
1,
1
)
).toBe(0.5);
expect(
sampleTerrainFoliageBlockerMaskAtWorldPosition(terrain, 11, 21)
).toBeCloseTo(0.375);
expect(
sampleTerrainFoliageBlockerMaskAtWorldPosition(terrain, 99, 99)
).toBeNull();
});
});
describe("terrain foliage masks", () => {
it("creates, clones, normalizes, and samples foliage mask values", () => {
const foliageLayerId = "foliage-layer-sample";
const terrain = createTerrain({
id: "terrain-foliage-mask-sample",
position: { x: 10, y: 0, z: 20 },
sampleCountX: 2,
sampleCountZ: 2,
cellSize: 2,
foliageMasks: {
[foliageLayerId]: createTerrainFoliageMask({
layerId: foliageLayerId,
resolutionX: 2,
resolutionZ: 2,
values: [0, 1.2, -1, 0.5]
})
}
});
const clonedTerrain = createTerrain(terrain);
const mask = getTerrainFoliageMask(terrain, foliageLayerId);
expect(mask).not.toBeNull();
expect(mask?.values).toEqual([0, 1, 0, 0.5]);
expect(clonedTerrain.foliageMasks[foliageLayerId]).toEqual(mask);
expect(clonedTerrain.foliageMasks[foliageLayerId]).not.toBe(mask);
expect(getTerrainFoliageMaskValueAtSample(mask!, 1, 1)).toBe(0.5);
expect(
sampleTerrainFoliageMaskAtWorldPosition(
terrain,
foliageLayerId,
11,
21
)
).toBeCloseTo(0.375);
expect(
sampleTerrainFoliageMaskAtWorldPosition(
terrain,
"missing-layer",
11,
21
)
).toBe(0);
expect(
sampleTerrainFoliageMaskAtWorldPosition(
terrain,
foliageLayerId,
99,
99
)
).toBeNull();
});
});
describe("terrain bounds cache", () => {
it("updates cached bounds after in-place height patches", () => {
const terrain = createTerrain({
id: "terrain-bounds-cache-grow",
position: { x: 0, y: 1, z: 0 },
sampleCountX: 2,
sampleCountZ: 2,
cellSize: 1,
heights: [0, 1, 2, 3]
});
expect(getTerrainBounds(terrain).max.y).toBe(4);
terrain.heights[0] = -5;
terrain.heights[1] = 6;
updateTerrainBoundsCacheAfterHeightPatch(terrain, [
{ index: 0, before: 0, after: -5 },
{ index: 1, before: 1, after: 6 }
]);
expect(getTerrainBounds(terrain)).toEqual({
min: { x: 0, y: -4, z: 0 },
max: { x: 1, y: 7, z: 1 }
});
});
it("rescans cached bounds when the previous extremum is reduced", () => {
const terrain = createTerrain({
id: "terrain-bounds-cache-rescan",
position: { x: 0, y: 0, z: 0 },
sampleCountX: 2,
sampleCountZ: 2,
cellSize: 1,
heights: [0, 1, 2, 3]
});
expect(getTerrainBounds(terrain).max.y).toBe(3);
terrain.heights[3] = 0.5;
updateTerrainBoundsCacheAfterHeightPatch(terrain, [
{ index: 3, before: 3, after: 0.5 }
]);
expect(getTerrainBounds(terrain).max.y).toBe(2);
});
});