auto-git:

[add] tests/domain/foliage-scatter.test.ts
This commit is contained in:
2026-05-02 04:37:58 +02:00
parent 60170a9c72
commit b3aa2cfc65

View File

@@ -0,0 +1,418 @@
import { describe, expect, it } from "vitest";
import {
createTerrain,
createTerrainFoliageMask,
type Terrain
} from "../../src/document/terrains";
import {
generateFoliageScatterForScene,
generateFoliageScatterForTerrain,
sampleFoliageScatterTerrainNormal,
type FoliageScatterResult
} from "../../src/foliage/foliage-scatter";
import { BUNDLED_FOLIAGE_PROTOTYPES } from "../../src/foliage/bundled-foliage-manifest";
import {
createFoliageLayer,
createFoliagePrototype,
type FoliageLayer,
type FoliagePrototype,
type FoliagePrototypeRegistry
} from "../../src/foliage/foliage";
import { sampleTerrainHeightAtWorldPosition } from "../../src/geometry/terrain-brush";
const TEST_LAYER_ID = "foliage-layer-scatter";
const TEST_PROTOTYPE_ID = "foliage-prototype-scatter";
const TEST_LODS = BUNDLED_FOLIAGE_PROTOTYPES[0]!.lods;
function createTestPrototype(
overrides: Partial<FoliagePrototype> = {}
): FoliagePrototype {
return createFoliagePrototype({
id: overrides.id ?? TEST_PROTOTYPE_ID,
label: overrides.label ?? "Scatter Prototype",
category: overrides.category ?? "grass",
lods: overrides.lods ?? TEST_LODS,
minScale: overrides.minScale ?? 1,
maxScale: overrides.maxScale ?? 1,
randomYaw: overrides.randomYaw ?? true,
alignToNormal: overrides.alignToNormal ?? 1,
densityWeight: overrides.densityWeight ?? 1,
colorVariation: overrides.colorVariation ?? 0,
windStrength: overrides.windStrength ?? 0,
windPhaseRandomness: overrides.windPhaseRandomness ?? 0,
defaultCullDistance: overrides.defaultCullDistance ?? 100
});
}
function createPrototypeRegistry(
prototypes: readonly FoliagePrototype[] = [createTestPrototype()]
): FoliagePrototypeRegistry {
return Object.fromEntries(
prototypes.map((prototype) => [prototype.id, prototype])
);
}
function createTestLayer(overrides: Partial<FoliageLayer> = {}): FoliageLayer {
return createFoliageLayer({
id: overrides.id ?? TEST_LAYER_ID,
name: overrides.name ?? "Scatter Layer",
prototypeIds: overrides.prototypeIds ?? [TEST_PROTOTYPE_ID],
density: overrides.density ?? 1,
minScale: overrides.minScale ?? 1,
maxScale: overrides.maxScale ?? 1,
minSlopeDegrees: overrides.minSlopeDegrees ?? 0,
maxSlopeDegrees: overrides.maxSlopeDegrees ?? 90,
alignToNormal: overrides.alignToNormal ?? 1,
noiseScale: overrides.noiseScale ?? 8,
noiseStrength: overrides.noiseStrength ?? 0,
noiseThreshold: overrides.noiseThreshold ?? 0,
colorVariation: overrides.colorVariation ?? 0,
seed: overrides.seed ?? 7,
enabled: overrides.enabled ?? true
});
}
function createMaskedTerrain(options: {
id?: string;
layerId?: string;
sampleCountX?: number;
sampleCountZ?: number;
cellSize?: number;
maskValue?: number;
maskValues?: readonly number[];
includeMask?: boolean;
position?: Terrain["position"];
heights?: readonly number[];
} = {}): Terrain {
const sampleCountX = options.sampleCountX ?? 17;
const sampleCountZ = options.sampleCountZ ?? 17;
const layerId = options.layerId ?? TEST_LAYER_ID;
const sampleCount = sampleCountX * sampleCountZ;
const maskValues =
options.maskValues ??
new Array<number>(sampleCount).fill(options.maskValue ?? 1);
return createTerrain({
id: options.id ?? "terrain-scatter",
position: options.position ?? { x: 0, y: 0, z: 0 },
sampleCountX,
sampleCountZ,
cellSize: options.cellSize ?? 1,
heights: options.heights ?? new Array<number>(sampleCount).fill(0),
foliageMasks:
options.includeMask === false
? {}
: {
[layerId]: createTerrainFoliageMask({
layerId,
resolutionX: sampleCountX,
resolutionZ: sampleCountZ,
values: maskValues
})
}
});
}
function generateForFixture(options: {
terrain?: Terrain;
layer?: FoliageLayer;
prototypes?: readonly FoliagePrototype[];
chunkSizeMeters?: number;
maxInstancesPerChunk?: number;
} = {}): FoliageScatterResult {
const layer = options.layer ?? createTestLayer();
return generateFoliageScatterForTerrain({
terrain: options.terrain ?? createMaskedTerrain({ layerId: layer.id }),
foliageLayers: {
[layer.id]: layer
},
foliagePrototypes: createPrototypeRegistry(options.prototypes),
bundledFoliagePrototypes: {},
chunkSizeMeters: options.chunkSizeMeters,
maxInstancesPerChunk: options.maxInstancesPerChunk
});
}
function flattenInstancePrototypeIds(result: FoliageScatterResult): string[] {
return result.chunks.flatMap((chunk) =>
chunk.instances.map((instance) => instance.prototypeId)
);
}
function createSlopedHeights(
sampleCountX: number,
sampleCountZ: number,
heightAtSample: (sampleX: number, sampleZ: number) => number
): number[] {
const heights: number[] = [];
for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) {
for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) {
heights.push(heightAtSample(sampleX, sampleZ));
}
}
return heights;
}
describe("foliage scatter generation", () => {
it("generates deterministic chunks and instances for the same inputs", () => {
const first = generateForFixture();
const second = generateForFixture();
expect(first.instanceCount).toBeGreaterThan(0);
expect(second).toEqual(first);
});
it("changes deterministic distribution when the layer seed changes", () => {
const seedSeven = generateForFixture({
layer: createTestLayer({ seed: 7 })
});
const seedEight = generateForFixture({
layer: createTestLayer({ seed: 8 })
});
expect(seedSeven.instanceCount).toBeGreaterThan(0);
expect(seedEight.instanceCount).toBeGreaterThan(0);
expect(seedEight.chunks).not.toEqual(seedSeven.chunks);
});
it("does not generate instances for absent or zero foliage masks", () => {
const layer = createTestLayer();
const absentMask = generateForFixture({
layer,
terrain: createMaskedTerrain({
layerId: layer.id,
includeMask: false
})
});
const zeroMask = generateForFixture({
layer,
terrain: createMaskedTerrain({
layerId: layer.id,
maskValue: 0
})
});
expect(absentMask).toEqual({ chunks: [], instanceCount: 0 });
expect(zeroMask).toEqual({ chunks: [], instanceCount: 0 });
});
it("uses painted mask density to affect generated instance counts", () => {
const fullMask = generateForFixture({
terrain: createMaskedTerrain({ maskValue: 1 }),
maxInstancesPerChunk: 1024
});
const quarterMask = generateForFixture({
terrain: createMaskedTerrain({ maskValue: 0.25 }),
maxInstancesPerChunk: 1024
});
expect(fullMask.instanceCount).toBeGreaterThan(quarterMask.instanceCount);
expect(quarterMask.instanceCount).toBeGreaterThan(0);
});
it("uses layer density to affect generated instance counts", () => {
const lowDensity = generateForFixture({
layer: createTestLayer({ density: 0.5 }),
maxInstancesPerChunk: 1024
});
const highDensity = generateForFixture({
layer: createTestLayer({ density: 2 }),
maxInstancesPerChunk: 1024
});
expect(highDensity.instanceCount).toBeGreaterThan(
lowDensity.instanceCount
);
});
it("filters samples by terrain slope", () => {
const flatExcluded = generateForFixture({
layer: createTestLayer({
minSlopeDegrees: 10,
maxSlopeDegrees: 90
})
});
const steepTerrain = createMaskedTerrain({
heights: createSlopedHeights(17, 17, (sampleX) => sampleX * 2)
});
const steepExcluded = generateForFixture({
terrain: steepTerrain,
layer: createTestLayer({
minSlopeDegrees: 0,
maxSlopeDegrees: 20
})
});
const steepIncluded = generateForFixture({
terrain: steepTerrain,
layer: createTestLayer({
minSlopeDegrees: 30,
maxSlopeDegrees: 80
})
});
expect(flatExcluded.instanceCount).toBe(0);
expect(steepExcluded.instanceCount).toBe(0);
expect(steepIncluded.instanceCount).toBeGreaterThan(0);
});
it("uses prototype density weights for deterministic prototype selection", () => {
const ignoredPrototype = createTestPrototype({
id: "foliage-prototype-ignored",
label: "Ignored",
densityWeight: 0
});
const selectedPrototype = createTestPrototype({
id: "foliage-prototype-selected",
label: "Selected",
densityWeight: 1
});
const result = generateForFixture({
prototypes: [ignoredPrototype, selectedPrototype],
layer: createTestLayer({
prototypeIds: [ignoredPrototype.id, selectedPrototype.id]
})
});
expect(result.instanceCount).toBeGreaterThan(0);
expect(new Set(flattenInstancePrototypeIds(result))).toEqual(
new Set([selectedPrototype.id])
);
});
it("places generated instances on the sampled terrain height", () => {
const terrain = createMaskedTerrain({
position: { x: 5, y: 2, z: -3 },
heights: createSlopedHeights(
17,
17,
(sampleX, sampleZ) => sampleX * 0.5 + sampleZ * 0.25
)
});
const result = generateForFixture({ terrain });
expect(result.instanceCount).toBeGreaterThan(0);
for (const chunk of result.chunks) {
for (const instance of chunk.instances) {
const sampledHeight = sampleTerrainHeightAtWorldPosition(
terrain,
instance.position.x,
instance.position.z,
true
);
expect(sampledHeight).not.toBeNull();
expect(instance.position.y).toBeCloseTo(
terrain.position.y + sampledHeight!,
6
);
}
}
});
it("generates normalized upward terrain normals", () => {
const terrain = createMaskedTerrain({
heights: createSlopedHeights(
17,
17,
(sampleX, sampleZ) => sampleX * 0.5 + sampleZ * 0.25
)
});
const result = generateForFixture({ terrain });
expect(result.instanceCount).toBeGreaterThan(0);
for (const chunk of result.chunks) {
for (const instance of chunk.instances) {
const length = Math.hypot(
instance.normal.x,
instance.normal.y,
instance.normal.z
);
expect(length).toBeCloseTo(1, 6);
expect(instance.normal.y).toBeGreaterThan(0);
}
}
expect(sampleFoliageScatterTerrainNormal(terrain, 2, 2).y).toBeGreaterThan(
0
);
});
it("emits deterministic chunk bounds for terrain/layer chunks", () => {
const result = generateForFixture({
terrain: createMaskedTerrain({
sampleCountX: 33,
sampleCountZ: 33,
maskValue: 1
}),
chunkSizeMeters: 16,
maxInstancesPerChunk: 1024
});
expect(result.chunks).toHaveLength(4);
expect(
result.chunks.map((chunk) => ({
chunkX: chunk.chunkX,
chunkZ: chunk.chunkZ,
bounds: chunk.bounds
}))
).toEqual([
{
chunkX: 0,
chunkZ: 0,
bounds: {
min: { x: 0, y: 0, z: 0 },
max: { x: 16, y: 0, z: 16 }
}
},
{
chunkX: 1,
chunkZ: 0,
bounds: {
min: { x: 16, y: 0, z: 0 },
max: { x: 32, y: 0, z: 16 }
}
},
{
chunkX: 0,
chunkZ: 1,
bounds: {
min: { x: 0, y: 0, z: 16 },
max: { x: 16, y: 0, z: 32 }
}
},
{
chunkX: 1,
chunkZ: 1,
bounds: {
min: { x: 16, y: 0, z: 16 },
max: { x: 32, y: 0, z: 32 }
}
}
]);
});
it("generates a whole-scene scatter result from terrain and layer registries", () => {
const layer = createTestLayer();
const result = generateFoliageScatterForScene({
terrains: {
"terrain-b": createMaskedTerrain({ id: "terrain-b", layerId: layer.id }),
"terrain-a": createMaskedTerrain({ id: "terrain-a", layerId: layer.id })
},
foliageLayers: {
[layer.id]: layer
},
foliagePrototypes: createPrototypeRegistry(),
bundledFoliagePrototypes: {}
});
expect(result.instanceCount).toBeGreaterThan(0);
expect(result.chunks[0]?.terrainId).toBe("terrain-a");
});
});