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 { 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 { 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(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(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"); }); });