import { ShaderMaterial } from "three"; import { describe, expect, it } from "vitest"; import { MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT } from "../../src/document/brushes"; import { createBoxBrush } from "../../src/document/brushes"; import { createTerrain } from "../../src/document/terrains"; import { buildBoxBrushDerivedMeshData } from "../../src/geometry/box-brush-mesh"; import { buildTerrainDerivedMeshData } from "../../src/geometry/terrain-mesh"; import { collectWaterContactPatches, createWaterMaterial } from "../../src/rendering/water-material"; describe("water material helpers", () => { it("builds contact foam patches for bounds that cross the water surface", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 8 } }, [ { min: { x: -1, y: 0.8, z: -0.75 }, max: { x: 1, y: 1.35, z: 0.75 } } ] ); expect(patches).toHaveLength(1); expect(patches[0]?.shape).toBe("box"); expect(patches[0]?.x).toBeCloseTo(0, 5); expect(patches[0]?.z).toBeCloseTo(0, 5); expect(patches[0]?.halfWidth).toBeGreaterThan(0.9); expect(patches[0]?.halfDepth).toBeGreaterThan(0.7); }); it("ignores bounds that do not overlap the water surface band", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 6, y: 2, z: 6 } }, [ { min: { x: -1, y: -3, z: -1 }, max: { x: 1, y: -2, z: 1 } } ] ); expect(patches).toHaveLength(0); }); it("preserves oriented contact regions for rotated boxes", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [ { kind: "orientedBox", center: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 45, z: 0 }, size: { x: 2, y: 0.4, z: 1 } } ] ); expect(patches).toHaveLength(1); expect(Math.abs(patches[0]?.axisX ?? 0)).toBeGreaterThan(0.65); expect(Math.abs(patches[0]?.axisZ ?? 0)).toBeGreaterThan(0.65); }); it("clips rotated contact regions to the water footprint", () => { const centeredPatch = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 4, y: 2, z: 4 } }, [ { kind: "orientedBox", center: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 45, z: 0 }, size: { x: 3, y: 0.4, z: 1 } } ] )[0]; const clippedPatch = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 4, y: 2, z: 4 } }, [ { kind: "orientedBox", center: { x: 2.2, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 45, z: 0 }, size: { x: 3, y: 0.4, z: 1 } } ] )[0]; expect(centeredPatch).toBeDefined(); expect(clippedPatch).toBeDefined(); expect(clippedPatch?.x ?? 999).toBeLessThan(2); expect((clippedPatch?.halfWidth ?? 0) * (clippedPatch?.halfDepth ?? 0)).toBeLessThan( (centeredPatch?.halfWidth ?? 0) * (centeredPatch?.halfDepth ?? 0) ); expect(Math.abs(clippedPatch?.axisX ?? 0)).toBeGreaterThan(0.65); expect(Math.abs(clippedPatch?.axisZ ?? 0)).toBeGreaterThan(0.65); }); it("creates a foam patch when a bounds source only touches the water footprint edge", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 4, y: 2, z: 4 } }, [ { min: { x: 2, y: 0.8, z: -0.7 }, max: { x: 3, y: 1.2, z: 0.7 } } ] ); expect(patches).toHaveLength(1); expect(patches[0]?.x).toBeCloseTo(2, 5); expect(patches[0]?.halfWidth ?? 0).toBeGreaterThan(0.65); expect(patches[0]?.halfDepth ?? 0).toBeGreaterThan(0); }); it("builds contact patches for transformed triangle meshes that cross the water surface", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 8, y: 2, z: 8 } }, [ { kind: "triangleMesh", vertices: new Float32Array([ -1, 0, -1, 1, 0, -1, 1, 0, 1, -1, 0, 1 ]), indices: new Uint32Array([0, 1, 2, 0, 2, 3]), transform: { position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 35, y: 28, z: 18 }, scale: { x: 2, y: 1, z: 1.4 } } } ] ); expect(patches).toHaveLength(1); expect(patches[0]?.shape).toBe("segment"); expect(patches[0]?.halfWidth ?? 0).toBeGreaterThan(0.2); expect(Math.abs(patches[0]?.axisX ?? 0)).toBeGreaterThan(0.2); expect(Math.abs(patches[0]?.axisZ ?? 0)).toBeGreaterThan(0.2); expect(patches[0]?.halfDepth ?? 1).toBeLessThan(0.3); }); it("creates foam for triangle mesh waterlines clipped to the water footprint edge", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 4, y: 2, z: 4 } }, [ { kind: "triangleMesh", vertices: new Float32Array([ 1.6, -0.3, -0.8, 2.4, 0.3, -0.8, 2.4, 0.3, 0.8, 1.6, -0.3, 0.8 ]), indices: new Uint32Array([0, 1, 2, 0, 2, 3]), transform: { position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } } ] ); expect(patches.length).toBeGreaterThan(0); expect(patches[0]?.halfDepth ?? 0).toBeGreaterThan(0); }); it("uses narrow waterline bands for large sloped triangle surfaces", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 12, y: 2, z: 12 } }, [ { kind: "triangleMesh", mergeProfile: "aggressive", vertices: new Float32Array([ -4, -1, -3, 4, 1.4, -3, 4, 1.4, 3, -4, -1, 3 ]), indices: new Uint32Array([0, 1, 2, 0, 2, 3]), transform: { position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } } ] ); expect(patches).toHaveLength(1); expect(patches[0]?.shape).toBe("segment"); expect(patches[0]?.halfWidth ?? 0).toBeGreaterThan(1.5); expect(patches[0]?.halfDepth ?? 1).toBeLessThan(0.3); }); it("merges adjacent triangle mesh strips into one longer foam band", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [ { kind: "triangleMesh", mergeProfile: "aggressive", vertices: new Float32Array([ -2, 0, -1, 0, 0, -1, 0, 0, 1, -2, 0, 1, 2, 0, -1, 2, 0, 1 ]), indices: new Uint32Array([ 0, 1, 2, 0, 2, 3, 1, 4, 5, 1, 5, 2 ]), transform: { position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 32, y: 20, z: 12 }, scale: { x: 1, y: 1, z: 1 } } } ] ); expect(patches).toHaveLength(1); expect(patches[0]?.halfWidth ?? 0).toBeGreaterThan(1.2); expect(patches[0]?.halfDepth ?? 0).toBeGreaterThan(0.05); }); it("builds shoreline contact patches from authored terrain meshes", () => { const terrain = createTerrain({ id: "terrain-waterline-main", position: { x: -4, y: 0, z: -4 }, sampleCountX: 5, sampleCountZ: 5, cellSize: 2, heights: [ -1.2, -0.8, -0.4, 0, 0.4, -1, -0.6, -0.2, 0.2, 0.6, -0.8, -0.4, 0, 0.4, 0.8, -0.6, -0.2, 0.2, 0.6, 1, -0.4, 0, 0.4, 0.8, 1.2 ] }); const derivedMesh = buildTerrainDerivedMeshData(terrain); const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [ { kind: "triangleMesh", vertices: derivedMesh.positions, indices: derivedMesh.indices, mergeProfile: "aggressive", transform: { position: terrain.position, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } } ] ); expect(patches.length).toBeGreaterThan(0); expect(patches[0]?.shape).toBe("segment"); expect(patches[0]?.halfWidth ?? 0).toBeGreaterThan(1); }); it("keeps foam patches for both long and short edges of a box intersecting the water surface", () => { const intersectingBox = createBoxBrush({ center: { x: 0, y: 1, z: 0 }, size: { x: 4, y: 4, z: 2 } }); const derivedMesh = buildBoxBrushDerivedMeshData(intersectingBox); const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [ { kind: "triangleMesh", vertices: derivedMesh.colliderVertices, indices: derivedMesh.colliderIndices, transform: { position: intersectingBox.center, rotationDegrees: intersectingBox.rotationDegrees, scale: { x: 1, y: 1, z: 1 } } } ] ); expect(patches).toHaveLength(1); expect(patches[0]?.shape).toBe("box"); expect(patches[0]?.halfWidth ?? 0).toBeGreaterThan(1.7); expect(patches[0]?.halfDepth ?? 0).toBeGreaterThan(0.7); }); it("only uses aggressive merging for explicitly marked triangle meshes", () => { const sharedSource = { kind: "triangleMesh" as const, vertices: new Float32Array([ -0.4, -0.3, -2, -0.25, 0.3, -2, -0.25, 0.3, 2, -0.4, -0.3, 2, 0.25, -0.3, -2, 0.4, 0.3, -2, 0.4, 0.3, 2, 0.25, -0.3, 2 ]), indices: new Uint32Array([ 0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7 ]), transform: { position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } }; const defaultPatches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [sharedSource] ); const aggressivePatches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [{ ...sharedSource, mergeProfile: "aggressive" as const }] ); expect(defaultPatches.length).toBeGreaterThan(1); expect(aggressivePatches).toHaveLength(1); }); it("does not merge sharply bent triangle mesh strips even in aggressive mode", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 10, y: 2, z: 10 } }, [ { kind: "triangleMesh", mergeProfile: "aggressive", vertices: new Float32Array([ -2, 0, -1, 0, 0, -1, 0, 0, 1, -2, 0, 1, 2, 1.6, -1, 2, 1.6, 1 ]), indices: new Uint32Array([ 0, 1, 2, 0, 2, 3, 1, 4, 5, 1, 5, 2 ]), transform: { position: { x: 0, y: 1, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } } ] ); expect(patches.length).toBeGreaterThan(1); }); it("caps the authored foam contact patch count per water surface", () => { const patches = collectWaterContactPatches( { center: { x: 0, y: 0, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, size: { x: 12, y: 2, z: 12 } }, [ { min: { x: -5, y: 0.8, z: -1 }, max: { x: -4, y: 1.2, z: 1 } }, { min: { x: -3, y: 0.8, z: -1 }, max: { x: -2, y: 1.2, z: 1 } }, { min: { x: -1, y: 0.8, z: -1 }, max: { x: 0, y: 1.2, z: 1 } }, { min: { x: 1, y: 0.8, z: -1 }, max: { x: 2, y: 1.2, z: 1 } } ], 2 ); expect(patches).toHaveLength(2); }); it("builds a shared quality shader material for visible tinted water", () => { const result = createWaterMaterial({ colorHex: "#4da6d9", surfaceOpacity: 0.55, waveStrength: 0.35, opacity: 0.71, quality: true, wireframe: false, isTopFace: true, time: 0, halfSize: { x: 4, z: 4 }, contactPatches: [], reflection: { texture: null, enabled: true } }); expect(result.material).toBeInstanceOf(ShaderMaterial); const material = result.material as ShaderMaterial; expect(material.transparent).toBe(true); expect(material.fog).toBe(true); expect(material.uniforms["fogColor"]).toBeDefined(); expect(material.uniforms["fogDensity"]).toBeDefined(); expect(material.uniforms["surfaceOpacity"]?.value).toBeGreaterThan(0.14); expect(material.uniforms["waveStrength"]?.value).toBe(0.35); expect(material.uniforms["surfaceDisplacementEnabled"]?.value).toBe(0); expect(material.uniforms["isTopFace"]?.value).toBe(1); expect(material.vertexShader).toContain("surfaceDisplacementEnabled"); expect(result.contactPatchesUniform?.value).toHaveLength(MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT); expect(result.contactPatchShapesUniform?.value).toHaveLength(MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT); expect(result.reflectionTextureUniform).not.toBeNull(); expect(result.reflectionMatrixUniform).not.toBeNull(); expect(result.reflectionEnabledUniform?.value).toBe(0); expect(result.animationUniform).toBe(material.uniforms["time"]); expect(result.reflectionEnabledUniform).toBe(material.uniforms["reflectionEnabled"]); if (result.animationUniform !== null && result.reflectionEnabledUniform !== null) { result.animationUniform.value = 2.5; result.reflectionEnabledUniform.value = 0.36; expect(material.uniforms["time"]?.value).toBe(2.5); expect(material.uniforms["reflectionEnabled"]?.value).toBe(0.36); } }); });