Add polygon clipping and area calculation to water contact patch collection

This commit is contained in:
2026-04-07 05:11:31 +02:00
parent 340ea8c3d3
commit b062b2beea
3 changed files with 456 additions and 100 deletions

View File

@@ -41,6 +41,128 @@ function createInverseVolumeRotation(rotationDegrees) {
.invert();
}
function cross2d(origin, pointA, pointB) {
return (pointA.x - origin.x) * (pointB.y - origin.y) - (pointA.y - origin.y) * (pointB.x - origin.x);
}
function buildConvexHull(points) {
const sortedPoints = [...points]
.map((point) => point.clone())
.sort((left, right) => (left.x === right.x ? left.y - right.y : left.x - right.x));
const uniquePoints = [];
for (const point of sortedPoints) {
const lastPoint = uniquePoints.at(-1);
if (lastPoint === undefined || Math.abs(point.x - lastPoint.x) > WATER_CONTACT_EPSILON || Math.abs(point.y - lastPoint.y) > WATER_CONTACT_EPSILON) {
uniquePoints.push(point);
}
}
if (uniquePoints.length <= 2) {
return uniquePoints;
}
const lowerHull = [];
for (const point of uniquePoints) {
while (lowerHull.length >= 2 && cross2d(lowerHull[lowerHull.length - 2], lowerHull[lowerHull.length - 1], point) <= WATER_CONTACT_EPSILON) {
lowerHull.pop();
}
lowerHull.push(point);
}
const upperHull = [];
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
const point = uniquePoints[index];
if (point === undefined) {
continue;
}
while (upperHull.length >= 2 && cross2d(upperHull[upperHull.length - 2], upperHull[upperHull.length - 1], point) <= WATER_CONTACT_EPSILON) {
upperHull.pop();
}
upperHull.push(point);
}
lowerHull.pop();
upperHull.pop();
return [...lowerHull, ...upperHull];
}
function clipPolygonAgainstVerticalBoundary(polygon, limit, keepGreater) {
if (polygon.length === 0) {
return [];
}
const clipped = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousInside = keepGreater ? previousPoint.x >= limit - WATER_CONTACT_EPSILON : previousPoint.x <= limit + WATER_CONTACT_EPSILON;
for (const point of polygon) {
const inside = keepGreater ? point.x >= limit - WATER_CONTACT_EPSILON : point.x <= limit + WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const deltaX = point.x - previousPoint.x;
if (Math.abs(deltaX) > WATER_CONTACT_EPSILON) {
const interpolation = (limit - previousPoint.x) / deltaX;
clipped.push(new Vector2(limit, previousPoint.y + (point.y - previousPoint.y) * interpolation));
}
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousInside = inside;
}
return clipped;
}
function clipPolygonAgainstHorizontalBoundary(polygon, limit, keepGreater) {
if (polygon.length === 0) {
return [];
}
const clipped = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousInside = keepGreater ? previousPoint.y >= limit - WATER_CONTACT_EPSILON : previousPoint.y <= limit + WATER_CONTACT_EPSILON;
for (const point of polygon) {
const inside = keepGreater ? point.y >= limit - WATER_CONTACT_EPSILON : point.y <= limit + WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const deltaY = point.y - previousPoint.y;
if (Math.abs(deltaY) > WATER_CONTACT_EPSILON) {
const interpolation = (limit - previousPoint.y) / deltaY;
clipped.push(new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, limit));
}
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousInside = inside;
}
return clipped;
}
function clipPolygonToRectangle(polygon, minX, maxX, minZ, maxZ) {
let clippedPolygon = polygon;
clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, minX, true);
clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, maxX, false);
clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, minZ, true);
clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, maxZ, false);
return clippedPolygon;
}
function calculatePolygonArea(polygon) {
if (polygon.length < 3) {
return 0;
}
let doubledArea = 0;
for (let index = 0; index < polygon.length; index += 1) {
const point = polygon[index];
const nextPoint = polygon[(index + 1) % polygon.length];
if (point === undefined || nextPoint === undefined) {
continue;
}
doubledArea += point.x * nextPoint.y - nextPoint.x * point.y;
}
return Math.abs(doubledArea) * 0.5;
}
export function collectWaterContactPatches(volume, contactBounds) {
const inverseRotation = createInverseVolumeRotation(volume.rotationDegrees);
const halfX = Math.max(volume.size.x * 0.5, WATER_CONTACT_EPSILON);
@@ -53,6 +175,7 @@ export function collectWaterContactPatches(volume, contactBounds) {
for (const source of contactBounds) {
const corners = "kind" in source ? createOrientedBoxCorners(source) : createBoundsCorners(source);
const localCorners = [];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let minZ = Number.POSITIVE_INFINITY;
@@ -66,6 +189,7 @@ export function collectWaterContactPatches(volume, contactBounds) {
localPoint.y -= volume.center.y;
localPoint.z -= volume.center.z;
localPoint.applyQuaternion(inverseRotation);
localCorners.push(localPoint.clone());
minX = Math.min(minX, localPoint.x);
minY = Math.min(minY, localPoint.y);
minZ = Math.min(minZ, localPoint.z);
@@ -82,14 +206,9 @@ export function collectWaterContactPatches(volume, contactBounds) {
continue;
}
const overlapMinX = Math.max(minX, -halfX);
const overlapMaxX = Math.min(maxX, halfX);
const overlapMinZ = Math.max(minZ, -halfZ);
const overlapMaxZ = Math.min(maxZ, halfZ);
const overlapWidth = overlapMaxX - overlapMinX;
const overlapDepth = overlapMaxZ - overlapMinZ;
const clippedFootprint = clipPolygonToRectangle(buildConvexHull(localCorners.map((corner) => new Vector2(corner.x, corner.z))), -halfX, halfX, -halfZ, halfZ);
if (overlapWidth <= WATER_CONTACT_EPSILON || overlapDepth <= WATER_CONTACT_EPSILON) {
if (calculatePolygonArea(clippedFootprint) <= WATER_CONTACT_EPSILON) {
continue;
}
@@ -101,55 +220,52 @@ export function collectWaterContactPatches(volume, contactBounds) {
let axisX = 1;
let axisZ = 0;
let halfWidth = overlapWidth * 0.5;
let halfDepth = overlapDepth * 0.5;
let centerX = (overlapMinX + overlapMaxX) * 0.5;
let centerZ = (overlapMinZ + overlapMaxZ) * 0.5;
const primaryAxis = new Vector2(1, 0);
const secondaryAxis = new Vector2(0, 1);
if ("kind" in source) {
const sourceRotation = new Quaternion().setFromEuler(new Euler((source.rotationDegrees.x * Math.PI) / 180, (source.rotationDegrees.y * Math.PI) / 180, (source.rotationDegrees.z * Math.PI) / 180, "XYZ"));
const projectedSourceX = new Vector2(1, 0).set(new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x, new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z);
const projectedSourceZ = new Vector2(1, 0).set(new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x, new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z);
const primaryAxis = projectedSourceX.lengthSq() >= projectedSourceZ.lengthSq() ? projectedSourceX : projectedSourceZ;
const projectedSourceX = new Vector2(new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x, new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z);
const projectedSourceZ = new Vector2(new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x, new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z);
const nextPrimaryAxis = projectedSourceX.lengthSq() >= projectedSourceZ.lengthSq() ? projectedSourceX : projectedSourceZ;
if (primaryAxis.lengthSq() > WATER_CONTACT_EPSILON) {
primaryAxis.normalize();
const secondaryAxis = new Vector2(-primaryAxis.y, primaryAxis.x);
if (nextPrimaryAxis.lengthSq() > WATER_CONTACT_EPSILON) {
primaryAxis.copy(nextPrimaryAxis).normalize();
secondaryAxis.set(-primaryAxis.y, primaryAxis.x);
if (projectedSourceZ.lengthSq() > WATER_CONTACT_EPSILON && projectedSourceZ.clone().normalize().dot(secondaryAxis) < 0) {
secondaryAxis.negate();
}
let minPrimary = Number.POSITIVE_INFINITY;
let maxPrimary = Number.NEGATIVE_INFINITY;
let minSecondary = Number.POSITIVE_INFINITY;
let maxSecondary = Number.NEGATIVE_INFINITY;
for (const corner of corners) {
localPoint.copy(corner);
localPoint.x -= volume.center.x;
localPoint.y -= volume.center.y;
localPoint.z -= volume.center.z;
localPoint.applyQuaternion(inverseRotation);
const projectedPoint = new Vector2(localPoint.x, localPoint.z);
const primaryDistance = projectedPoint.dot(primaryAxis);
const secondaryDistance = projectedPoint.dot(secondaryAxis);
minPrimary = Math.min(minPrimary, primaryDistance);
maxPrimary = Math.max(maxPrimary, primaryDistance);
minSecondary = Math.min(minSecondary, secondaryDistance);
maxSecondary = Math.max(maxSecondary, secondaryDistance);
}
const patchCenterPrimary = (minPrimary + maxPrimary) * 0.5;
const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5;
centerX = primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary;
centerZ = primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary;
halfWidth = (maxPrimary - minPrimary) * 0.5;
halfDepth = (maxSecondary - minSecondary) * 0.5;
axisX = primaryAxis.x;
axisZ = primaryAxis.y;
}
}
let minPrimary = Number.POSITIVE_INFINITY;
let maxPrimary = Number.NEGATIVE_INFINITY;
let minSecondary = Number.POSITIVE_INFINITY;
let maxSecondary = Number.NEGATIVE_INFINITY;
for (const point of clippedFootprint) {
const primaryDistance = point.dot(primaryAxis);
const secondaryDistance = point.dot(secondaryAxis);
minPrimary = Math.min(minPrimary, primaryDistance);
maxPrimary = Math.max(maxPrimary, primaryDistance);
minSecondary = Math.min(minSecondary, secondaryDistance);
maxSecondary = Math.max(maxSecondary, secondaryDistance);
}
const halfWidth = (maxPrimary - minPrimary) * 0.5;
const halfDepth = (maxSecondary - minSecondary) * 0.5;
if (halfWidth <= WATER_CONTACT_EPSILON || halfDepth <= WATER_CONTACT_EPSILON) {
continue;
}
const patchCenterPrimary = (minPrimary + maxPrimary) * 0.5;
const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5;
const centerX = primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary;
const centerZ = primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary;
axisX = primaryAxis.x;
axisZ = primaryAxis.y;
patches.push({
x: centerX,
z: centerZ,

View File

@@ -100,6 +100,158 @@ function createInverseVolumeRotation(rotationDegrees: Vec3) {
.invert();
}
function cross2d(origin: Vector2, pointA: Vector2, pointB: Vector2) {
return (pointA.x - origin.x) * (pointB.y - origin.y) - (pointA.y - origin.y) * (pointB.x - origin.x);
}
function buildConvexHull(points: Vector2[]) {
const sortedPoints = [...points]
.map((point) => point.clone())
.sort((left, right) => (left.x === right.x ? left.y - right.y : left.x - right.x));
const uniquePoints: Vector2[] = [];
for (const point of sortedPoints) {
const lastPoint = uniquePoints.at(-1);
if (lastPoint === undefined || Math.abs(point.x - lastPoint.x) > WATER_CONTACT_EPSILON || Math.abs(point.y - lastPoint.y) > WATER_CONTACT_EPSILON) {
uniquePoints.push(point);
}
}
if (uniquePoints.length <= 2) {
return uniquePoints;
}
const lowerHull: Vector2[] = [];
for (const point of uniquePoints) {
while (lowerHull.length >= 2 && cross2d(lowerHull[lowerHull.length - 2], lowerHull[lowerHull.length - 1], point) <= WATER_CONTACT_EPSILON) {
lowerHull.pop();
}
lowerHull.push(point);
}
const upperHull: Vector2[] = [];
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
const point = uniquePoints[index];
if (point === undefined) {
continue;
}
while (upperHull.length >= 2 && cross2d(upperHull[upperHull.length - 2], upperHull[upperHull.length - 1], point) <= WATER_CONTACT_EPSILON) {
upperHull.pop();
}
upperHull.push(point);
}
lowerHull.pop();
upperHull.pop();
return [...lowerHull, ...upperHull];
}
function clipPolygonAgainstVerticalBoundary(polygon: Vector2[], limit: number, keepGreater: boolean) {
if (polygon.length === 0) {
return [];
}
const clipped: Vector2[] = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousInside = keepGreater ? previousPoint.x >= limit - WATER_CONTACT_EPSILON : previousPoint.x <= limit + WATER_CONTACT_EPSILON;
for (const point of polygon) {
const inside = keepGreater ? point.x >= limit - WATER_CONTACT_EPSILON : point.x <= limit + WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const deltaX = point.x - previousPoint.x;
if (Math.abs(deltaX) > WATER_CONTACT_EPSILON) {
const interpolation = (limit - previousPoint.x) / deltaX;
clipped.push(new Vector2(limit, previousPoint.y + (point.y - previousPoint.y) * interpolation));
}
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousInside = inside;
}
return clipped;
}
function clipPolygonAgainstHorizontalBoundary(polygon: Vector2[], limit: number, keepGreater: boolean) {
if (polygon.length === 0) {
return [];
}
const clipped: Vector2[] = [];
let previousPoint = polygon[polygon.length - 1] ?? null;
if (previousPoint === null) {
return [];
}
let previousInside = keepGreater ? previousPoint.y >= limit - WATER_CONTACT_EPSILON : previousPoint.y <= limit + WATER_CONTACT_EPSILON;
for (const point of polygon) {
const inside = keepGreater ? point.y >= limit - WATER_CONTACT_EPSILON : point.y <= limit + WATER_CONTACT_EPSILON;
if (inside !== previousInside) {
const deltaY = point.y - previousPoint.y;
if (Math.abs(deltaY) > WATER_CONTACT_EPSILON) {
const interpolation = (limit - previousPoint.y) / deltaY;
clipped.push(new Vector2(previousPoint.x + (point.x - previousPoint.x) * interpolation, limit));
}
}
if (inside) {
clipped.push(point.clone());
}
previousPoint = point;
previousInside = inside;
}
return clipped;
}
function clipPolygonToRectangle(polygon: Vector2[], minX: number, maxX: number, minZ: number, maxZ: number) {
let clippedPolygon = polygon;
clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, minX, true);
clippedPolygon = clipPolygonAgainstVerticalBoundary(clippedPolygon, maxX, false);
clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, minZ, true);
clippedPolygon = clipPolygonAgainstHorizontalBoundary(clippedPolygon, maxZ, false);
return clippedPolygon;
}
function calculatePolygonArea(polygon: Vector2[]) {
if (polygon.length < 3) {
return 0;
}
let doubledArea = 0;
for (let index = 0; index < polygon.length; index += 1) {
const point = polygon[index];
const nextPoint = polygon[(index + 1) % polygon.length];
if (point === undefined || nextPoint === undefined) {
continue;
}
doubledArea += point.x * nextPoint.y - nextPoint.x * point.y;
}
return Math.abs(doubledArea) * 0.5;
}
export function collectWaterContactPatches(volume: OrientedWaterVolume, contactBounds: WaterContactSource[]): WaterContactPatch[] {
const inverseRotation = createInverseVolumeRotation(volume.rotationDegrees);
const halfX = Math.max(volume.size.x * 0.5, WATER_CONTACT_EPSILON);
@@ -112,6 +264,7 @@ export function collectWaterContactPatches(volume: OrientedWaterVolume, contactB
for (const source of contactBounds) {
const corners = "kind" in source ? createOrientedBoxCorners(source) : createBoundsCorners(source);
const localCorners: Vector3[] = [];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let minZ = Number.POSITIVE_INFINITY;
@@ -125,6 +278,7 @@ export function collectWaterContactPatches(volume: OrientedWaterVolume, contactB
localPoint.y -= volume.center.y;
localPoint.z -= volume.center.z;
localPoint.applyQuaternion(inverseRotation);
localCorners.push(localPoint.clone());
minX = Math.min(minX, localPoint.x);
minY = Math.min(minY, localPoint.y);
minZ = Math.min(minZ, localPoint.z);
@@ -141,14 +295,15 @@ export function collectWaterContactPatches(volume: OrientedWaterVolume, contactB
continue;
}
const overlapMinX = Math.max(minX, -halfX);
const overlapMaxX = Math.min(maxX, halfX);
const overlapMinZ = Math.max(minZ, -halfZ);
const overlapMaxZ = Math.min(maxZ, halfZ);
const overlapWidth = overlapMaxX - overlapMinX;
const overlapDepth = overlapMaxZ - overlapMinZ;
const clippedFootprint = clipPolygonToRectangle(
buildConvexHull(localCorners.map((corner) => new Vector2(corner.x, corner.z))),
-halfX,
halfX,
-halfZ,
halfZ
);
if (overlapWidth <= WATER_CONTACT_EPSILON || overlapDepth <= WATER_CONTACT_EPSILON) {
if (calculatePolygonArea(clippedFootprint) <= WATER_CONTACT_EPSILON) {
continue;
}
@@ -160,10 +315,8 @@ export function collectWaterContactPatches(volume: OrientedWaterVolume, contactB
let axisX = 1;
let axisZ = 0;
let halfWidth = overlapWidth * 0.5;
let halfDepth = overlapDepth * 0.5;
let centerX = (overlapMinX + overlapMaxX) * 0.5;
let centerZ = (overlapMinZ + overlapMaxZ) * 0.5;
const primaryAxis = new Vector2(1, 0);
const secondaryAxis = new Vector2(0, 1);
if ("kind" in source) {
const sourceRotation = new Quaternion().setFromEuler(
@@ -174,56 +327,53 @@ export function collectWaterContactPatches(volume: OrientedWaterVolume, contactB
"XYZ"
)
);
const projectedSourceX = new Vector2(1, 0)
.set(
new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x,
new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z
);
const projectedSourceZ = new Vector2(1, 0)
.set(
new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x,
new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z
);
const primaryAxis = projectedSourceX.lengthSq() >= projectedSourceZ.lengthSq() ? projectedSourceX : projectedSourceZ;
const projectedSourceX = new Vector2(
new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x,
new Vector3(1, 0, 0).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z
);
const projectedSourceZ = new Vector2(
new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).x,
new Vector3(0, 0, 1).applyQuaternion(sourceRotation).applyQuaternion(inverseRotation).z
);
const nextPrimaryAxis = projectedSourceX.lengthSq() >= projectedSourceZ.lengthSq() ? projectedSourceX : projectedSourceZ;
if (primaryAxis.lengthSq() > WATER_CONTACT_EPSILON) {
primaryAxis.normalize();
const secondaryAxis = new Vector2(-primaryAxis.y, primaryAxis.x);
if (nextPrimaryAxis.lengthSq() > WATER_CONTACT_EPSILON) {
primaryAxis.copy(nextPrimaryAxis).normalize();
secondaryAxis.set(-primaryAxis.y, primaryAxis.x);
if (projectedSourceZ.lengthSq() > WATER_CONTACT_EPSILON && projectedSourceZ.clone().normalize().dot(secondaryAxis) < 0) {
secondaryAxis.negate();
}
let minPrimary = Number.POSITIVE_INFINITY;
let maxPrimary = Number.NEGATIVE_INFINITY;
let minSecondary = Number.POSITIVE_INFINITY;
let maxSecondary = Number.NEGATIVE_INFINITY;
for (const corner of corners) {
localPoint.copy(corner);
localPoint.x -= volume.center.x;
localPoint.y -= volume.center.y;
localPoint.z -= volume.center.z;
localPoint.applyQuaternion(inverseRotation);
const projectedPoint = new Vector2(localPoint.x, localPoint.z);
const primaryDistance = projectedPoint.dot(primaryAxis);
const secondaryDistance = projectedPoint.dot(secondaryAxis);
minPrimary = Math.min(minPrimary, primaryDistance);
maxPrimary = Math.max(maxPrimary, primaryDistance);
minSecondary = Math.min(minSecondary, secondaryDistance);
maxSecondary = Math.max(maxSecondary, secondaryDistance);
}
const patchCenterPrimary = (minPrimary + maxPrimary) * 0.5;
const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5;
centerX = primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary;
centerZ = primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary;
halfWidth = (maxPrimary - minPrimary) * 0.5;
halfDepth = (maxSecondary - minSecondary) * 0.5;
axisX = primaryAxis.x;
axisZ = primaryAxis.y;
}
}
let minPrimary = Number.POSITIVE_INFINITY;
let maxPrimary = Number.NEGATIVE_INFINITY;
let minSecondary = Number.POSITIVE_INFINITY;
let maxSecondary = Number.NEGATIVE_INFINITY;
for (const point of clippedFootprint) {
const primaryDistance = point.dot(primaryAxis);
const secondaryDistance = point.dot(secondaryAxis);
minPrimary = Math.min(minPrimary, primaryDistance);
maxPrimary = Math.max(maxPrimary, primaryDistance);
minSecondary = Math.min(minSecondary, secondaryDistance);
maxSecondary = Math.max(maxSecondary, secondaryDistance);
}
const halfWidth = (maxPrimary - minPrimary) * 0.5;
const halfDepth = (maxSecondary - minSecondary) * 0.5;
if (halfWidth <= WATER_CONTACT_EPSILON || halfDepth <= WATER_CONTACT_EPSILON) {
continue;
}
const patchCenterPrimary = (minPrimary + maxPrimary) * 0.5;
const patchCenterSecondary = (minSecondary + maxSecondary) * 0.5;
const centerX = primaryAxis.x * patchCenterPrimary + secondaryAxis.x * patchCenterSecondary;
const centerZ = primaryAxis.y * patchCenterPrimary + secondaryAxis.y * patchCenterSecondary;
axisX = primaryAxis.x;
axisZ = primaryAxis.y;
patches.push({
x: centerX,
z: centerZ,

View File

@@ -130,6 +130,96 @@ describe("water material helpers", () => {
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("builds a shared quality shader material for visible tinted water", () => {
const result = createWaterMaterial({
colorHex: "#4da6d9",