From 3c54ba00c95dc21485dcfdfec0f71651c680bd75 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 7 Apr 2026 06:00:55 +0200 Subject: [PATCH] Add waterline segment creation and update contact patch logic --- src/rendering/water-material.js | 73 ++++++++++++++++++++++- src/rendering/water-material.ts | 90 ++++++++++++++++++++++++++++- tests/domain/water-material.test.ts | 57 ++++++++++++++++++ 3 files changed, 215 insertions(+), 5 deletions(-) diff --git a/src/rendering/water-material.js b/src/rendering/water-material.js index ad147faa..771aff2a 100644 --- a/src/rendering/water-material.js +++ b/src/rendering/water-material.js @@ -275,6 +275,70 @@ function computeTriangleNormal(pointA, pointB, pointC) { return normal.normalize(); } +function addUniqueProjectedPoint(points, point) { + const alreadyExists = points.some((candidate) => Math.abs(candidate.x - point.x) <= WATER_CONTACT_EPSILON && Math.abs(candidate.y - point.y) <= WATER_CONTACT_EPSILON); + if (!alreadyExists) { + points.push(point); + } +} + +function createWaterlineSegmentFromPolygon(polygon, surfaceY) { + if (polygon.length < 2) { + return null; + } + const intersectionPoints = []; + let previousPoint = polygon[polygon.length - 1] ?? null; + if (previousPoint === null) { + return null; + } + for (const point of polygon) { + const previousDelta = previousPoint.y - surfaceY; + const delta = point.y - surfaceY; + const previousOnPlane = Math.abs(previousDelta) <= WATER_CONTACT_EPSILON; + const onPlane = Math.abs(delta) <= WATER_CONTACT_EPSILON; + if (previousOnPlane && onPlane) { + addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z)); + addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z)); + } + else if (previousOnPlane) { + addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z)); + } + else if (onPlane) { + addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z)); + } + else if ((previousDelta < 0 && delta > 0) || (previousDelta > 0 && delta < 0)) { + const interpolation = (surfaceY - previousPoint.y) / (point.y - previousPoint.y); + addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, previousPoint.z + (point.z - previousPoint.z) * interpolation)); + } + previousPoint = point; + } + if (intersectionPoints.length < 2) { + return null; + } + let startPoint = intersectionPoints[0] ?? null; + let endPoint = intersectionPoints[1] ?? null; + let longestDistanceSquared = -1; + for (let startIndex = 0; startIndex < intersectionPoints.length; startIndex += 1) { + for (let endIndex = startIndex + 1; endIndex < intersectionPoints.length; endIndex += 1) { + const candidateStart = intersectionPoints[startIndex]; + const candidateEnd = intersectionPoints[endIndex]; + if (candidateStart === undefined || candidateEnd === undefined) { + continue; + } + const distanceSquared = candidateStart.distanceToSquared(candidateEnd); + if (distanceSquared > longestDistanceSquared) { + longestDistanceSquared = distanceSquared; + startPoint = candidateStart; + endPoint = candidateEnd; + } + } + } + if (startPoint === null || endPoint === null || longestDistanceSquared <= WATER_CONTACT_EPSILON) { + return null; + } + return [startPoint.clone(), endPoint.clone()]; +} + function createPatchCornerPoints(patch) { const axis = new Vector2(patch.axisX, patch.axisZ); if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { @@ -416,10 +480,15 @@ function appendTriangleMeshContactPatches(patches, source, volume, inverseRotati continue; } const clippedPolygon = clipPolygonToContactVolume(polygon, halfX, surfaceY - surfaceBand, surfaceY + surfaceBand, halfZ); - if (clippedPolygon.length < 2) { + const waterlineSegment = createWaterlineSegmentFromPolygon(clippedPolygon, surfaceY); + if (waterlineSegment === null) { continue; } - const patch = createPatchFromProjectedPoints(clippedPolygon.map((point) => new Vector2(point.x, point.z)), null, bandMinimumThickness); + const preferredAxis = waterlineSegment[1].clone().sub(waterlineSegment[0]); + if (preferredAxis.lengthSq() <= WATER_CONTACT_EPSILON) { + continue; + } + const patch = createPatchFromProjectedPoints([waterlineSegment[0], waterlineSegment[1]], preferredAxis, bandMinimumThickness); if (patch !== null) { rawPatches.push({ patch, diff --git a/src/rendering/water-material.ts b/src/rendering/water-material.ts index 2c37fc1e..62e4a326 100644 --- a/src/rendering/water-material.ts +++ b/src/rendering/water-material.ts @@ -410,6 +410,84 @@ function computeTriangleNormal(pointA: Vector3, pointB: Vector3, pointC: Vector3 return normal.normalize(); } +function addUniqueProjectedPoint(points: Vector2[], point: Vector2) { + const alreadyExists = points.some( + (candidate) => Math.abs(candidate.x - point.x) <= WATER_CONTACT_EPSILON && Math.abs(candidate.y - point.y) <= WATER_CONTACT_EPSILON + ); + + if (!alreadyExists) { + points.push(point); + } +} + +function createWaterlineSegmentFromPolygon(polygon: Vector3[], surfaceY: number) { + if (polygon.length < 2) { + return null; + } + + const intersectionPoints: Vector2[] = []; + let previousPoint = polygon[polygon.length - 1] ?? null; + + if (previousPoint === null) { + return null; + } + + for (const point of polygon) { + const previousDelta = previousPoint.y - surfaceY; + const delta = point.y - surfaceY; + const previousOnPlane = Math.abs(previousDelta) <= WATER_CONTACT_EPSILON; + const onPlane = Math.abs(delta) <= WATER_CONTACT_EPSILON; + + if (previousOnPlane && onPlane) { + addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z)); + addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z)); + } else if (previousOnPlane) { + addUniqueProjectedPoint(intersectionPoints, new Vector2(previousPoint.x, previousPoint.z)); + } else if (onPlane) { + addUniqueProjectedPoint(intersectionPoints, new Vector2(point.x, point.z)); + } else if ((previousDelta < 0 && delta > 0) || (previousDelta > 0 && delta < 0)) { + const interpolation = (surfaceY - previousPoint.y) / (point.y - previousPoint.y); + addUniqueProjectedPoint( + intersectionPoints, + new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, previousPoint.z + (point.z - previousPoint.z) * interpolation) + ); + } + + previousPoint = point; + } + + if (intersectionPoints.length < 2) { + return null; + } + + let startPoint = intersectionPoints[0] ?? null; + let endPoint = intersectionPoints[1] ?? null; + let longestDistanceSquared = -1; + + for (let startIndex = 0; startIndex < intersectionPoints.length; startIndex += 1) { + for (let endIndex = startIndex + 1; endIndex < intersectionPoints.length; endIndex += 1) { + const candidateStart = intersectionPoints[startIndex]; + const candidateEnd = intersectionPoints[endIndex]; + if (candidateStart === undefined || candidateEnd === undefined) { + continue; + } + + const distanceSquared = candidateStart.distanceToSquared(candidateEnd); + if (distanceSquared > longestDistanceSquared) { + longestDistanceSquared = distanceSquared; + startPoint = candidateStart; + endPoint = candidateEnd; + } + } + } + + if (startPoint === null || endPoint === null || longestDistanceSquared <= WATER_CONTACT_EPSILON) { + return null; + } + + return [startPoint.clone(), endPoint.clone()] as const; +} + function createPatchCornerPoints(patch: WaterContactPatch) { const axis = new Vector2(patch.axisX, patch.axisZ); if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { @@ -594,14 +672,20 @@ function appendTriangleMeshContactPatches( } const clippedPolygon = clipPolygonToContactVolume(polygon, halfX, surfaceY - surfaceBand, surfaceY + surfaceBand, halfZ); + const waterlineSegment = createWaterlineSegmentFromPolygon(clippedPolygon, surfaceY); - if (clippedPolygon.length < 2) { + if (waterlineSegment === null) { + continue; + } + + const preferredAxis = waterlineSegment[1].clone().sub(waterlineSegment[0]); + if (preferredAxis.lengthSq() <= WATER_CONTACT_EPSILON) { continue; } const patch = createPatchFromProjectedPoints( - clippedPolygon.map((point) => new Vector2(point.x, point.z)), - null, + [waterlineSegment[0], waterlineSegment[1]], + preferredAxis, bandMinimumThickness ); diff --git a/tests/domain/water-material.test.ts b/tests/domain/water-material.test.ts index 382bea52..9ea08a0a 100644 --- a/tests/domain/water-material.test.ts +++ b/tests/domain/water-material.test.ts @@ -274,6 +274,63 @@ describe("water material helpers", () => { 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("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]?.halfWidth ?? 0).toBeGreaterThan(1.5); + expect(patches[0]?.halfDepth ?? 1).toBeLessThan(0.3); }); it("merges adjacent triangle mesh strips into one longer foam band", () => {