[change] src/app/App.tsx [change] tests/domain/terrains.test.ts [change] tests/unit/terrain-foundation.integration.test.tsx
467 lines
14 KiB
TypeScript
467 lines
14 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("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("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("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
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|