Files
webeditor3d/tests/domain/water-material.test.ts
Victor Giers 94dec56eb4 auto-git:
[add] src/rendering/terrain-layer-material.ts
 [add] tests/domain/terrains.test.ts
 [change] src/app/App.tsx
 [change] src/core/terrain-brush.ts
 [change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document-validation.ts
 [change] src/document/scene-document.ts
 [change] src/document/terrains.ts
 [change] src/geometry/terrain-brush.ts
 [change] src/geometry/terrain-mesh.ts
 [change] src/runtime-three/rapier-collision-world.ts
 [change] src/runtime-three/runtime-host.ts
 [change] src/runtime-three/runtime-scene-build.ts
 [change] src/viewport-three/ViewportCanvas.tsx
 [change] src/viewport-three/ViewportPanel.tsx
 [change] src/viewport-three/viewport-host.ts
 [change] tests/domain/build-runtime-scene.test.ts
 [change] tests/domain/rapier-collision-world.test.ts
 [change] tests/domain/terrain.command.test.ts
 [change] tests/domain/water-material.test.ts
 [change] tests/geometry/terrain-brush.test.ts
 [change] tests/geometry/terrain-mesh.test.ts
 [change] tests/serialization/scene-document-json.test.ts
 [change] tests/unit/terrain-foundation.integration.test.tsx
 [change] tests/unit/viewport-canvas.test.tsx
2026-04-20 02:37:01 +02:00

872 lines
19 KiB
TypeScript

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);
}
});
});