A --since:29e50ad Stryker pass found real gaps, all inside this cut's own files: - vertex-placement and normals fixtures anchored a vertex at the world origin, so a mutated (+ instead of -) or dropped cellSize multiply was invisible against a zero operand; moved them off the origin; - the cube normals test only checked length, and three orthogonal +-2-length face contributions summed to the same length no matter which one a mutant negated; now checks the exact expected direction per vertex; - the open-boundary rule only had an X-axis test, leaving TryGetQuadCells's Y- and Z-axis guards (and TryGetOtherEndpoint's Y/Z cases) uncovered; added the Y- and Z-axis twins of the existing plane test; - the 1e-20 near-zero-delta fallback in Interpolate was never exercised; added a fixture with an edge delta of ~3e-21; - CultGeometryGridEdge's Equals and ToString had no direct test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
5.6 KiB
C#
158 lines
5.6 KiB
C#
using System;
|
|
using FluentAssertions;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Geometry.Tests
|
|
{
|
|
[TestFixture]
|
|
public sealed class QuadNormalsTests
|
|
{
|
|
[Test]
|
|
public void A_sliver_sharing_a_vertex_does_not_move_the_large_quads_normal()
|
|
{
|
|
// A 10x10 quad in the XY plane (facing +Z) sharing vertex 0 with a near-degenerate
|
|
// sliver tilted into the XZ plane. The sliver's area is ~1e6 times smaller. Offset away
|
|
// from the origin so a diagonal computed as (p2 + p0) instead of (p2 - p0) is not
|
|
// coincidentally the same value (p0 = 0 would erase that distinction).
|
|
var mesh = new CultGeometryQuadMesh
|
|
{
|
|
Positions = new[]
|
|
{
|
|
// Large quad: 0,1,2,3
|
|
5f, 7f, 9f,
|
|
15f, 7f, 9f,
|
|
15f, 17f, 9f,
|
|
5f, 17f, 9f,
|
|
// Sliver quad: 0 (shared), 4, 5, 6
|
|
5.001f, 7f, 9f,
|
|
5.001f, 7f, 9.001f,
|
|
5f, 7f, 9.001f,
|
|
},
|
|
Quads = new[]
|
|
{
|
|
0u, 1u, 2u, 3u,
|
|
0u, 4u, 5u, 6u,
|
|
},
|
|
QuadEdges = new[]
|
|
{
|
|
new CultGeometryGridEdge(0, 0, 0, 2),
|
|
new CultGeometryGridEdge(0, 0, 0, 1),
|
|
},
|
|
};
|
|
|
|
var normals = CultGeometryQuadNormals.FaceWeighted(mesh);
|
|
|
|
normals[0].Should().BeApproximately(0f, 1e-4f);
|
|
normals[1].Should().BeApproximately(0f, 1e-4f);
|
|
normals[2].Should().BeApproximately(1f, 1e-4f);
|
|
}
|
|
|
|
[Test]
|
|
public void A_closed_cube_gets_unit_length_normals_pointing_away_from_center()
|
|
{
|
|
var mesh = Cube();
|
|
|
|
var normals = CultGeometryQuadNormals.FaceWeighted(mesh);
|
|
|
|
// Each vertex's normal must point away from the cube's center (0.5,0.5,0.5) along every
|
|
// axis, i.e. its sign must match the vertex's own offset from the center. Checking the
|
|
// exact direction (not just the length) catches a mutant that flips the sign of one of a
|
|
// vertex's three contributing faces: three orthogonal +-2-length vectors summed have the
|
|
// same length no matter which one is negated, so a length-only check cannot tell them apart.
|
|
var oneOverRoot3 = 1f / MathF.Sqrt(3f);
|
|
for (var vertex = 0; vertex < mesh.VertexCount; vertex++)
|
|
{
|
|
var px = mesh.Positions[vertex * 3];
|
|
var py = mesh.Positions[(vertex * 3) + 1];
|
|
var pz = mesh.Positions[(vertex * 3) + 2];
|
|
var expected = new[]
|
|
{
|
|
px > 0.5f ? oneOverRoot3 : -oneOverRoot3,
|
|
py > 0.5f ? oneOverRoot3 : -oneOverRoot3,
|
|
pz > 0.5f ? oneOverRoot3 : -oneOverRoot3,
|
|
};
|
|
|
|
normals[vertex * 3].Should().BeApproximately(expected[0], 1e-5f);
|
|
normals[(vertex * 3) + 1].Should().BeApproximately(expected[1], 1e-5f);
|
|
normals[(vertex * 3) + 2].Should().BeApproximately(expected[2], 1e-5f);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void A_zero_area_quad_contributes_nothing_and_an_untouched_vertex_is_a_finite_zero()
|
|
{
|
|
var mesh = new CultGeometryQuadMesh
|
|
{
|
|
// Vertex 4 is never referenced by any quad.
|
|
Positions = new[]
|
|
{
|
|
0f, 0f, 0f,
|
|
1f, 0f, 0f,
|
|
2f, 0f, 0f,
|
|
3f, 0f, 0f,
|
|
9f, 9f, 9f,
|
|
},
|
|
Quads = new[] { 0u, 1u, 2u, 3u },
|
|
QuadEdges = new[] { new CultGeometryGridEdge(0, 0, 0, 0) },
|
|
};
|
|
|
|
var normals = CultGeometryQuadNormals.FaceWeighted(mesh);
|
|
|
|
normals.Should().HaveCount(15);
|
|
normals.Should().OnlyContain(value => value == 0f);
|
|
normals.Should().OnlyContain(value => float.IsFinite(value));
|
|
}
|
|
|
|
[Test]
|
|
public void Null_mesh_is_rejected()
|
|
{
|
|
Action act = () => CultGeometryQuadNormals.FaceWeighted(null!);
|
|
|
|
act.Should().Throw<ArgumentNullException>();
|
|
}
|
|
|
|
private static CultGeometryQuadMesh Cube()
|
|
{
|
|
// 0..7: (0,0,0) (1,0,0) (1,1,0) (0,1,0) (0,0,1) (1,0,1) (1,1,1) (0,1,1)
|
|
var positions = new[]
|
|
{
|
|
0f, 0f, 0f,
|
|
1f, 0f, 0f,
|
|
1f, 1f, 0f,
|
|
0f, 1f, 0f,
|
|
0f, 0f, 1f,
|
|
1f, 0f, 1f,
|
|
1f, 1f, 1f,
|
|
0f, 1f, 1f,
|
|
};
|
|
|
|
// Each face ordered so d1 x d2 points outward.
|
|
var quads = new[]
|
|
{
|
|
0u, 3u, 2u, 1u, // -Z
|
|
4u, 5u, 6u, 7u, // +Z
|
|
0u, 1u, 5u, 4u, // -Y
|
|
3u, 7u, 6u, 2u, // +Y
|
|
0u, 4u, 7u, 3u, // -X
|
|
1u, 2u, 6u, 5u, // +X
|
|
};
|
|
|
|
var quadEdges = new[]
|
|
{
|
|
new CultGeometryGridEdge(0, 0, 0, 0),
|
|
new CultGeometryGridEdge(0, 0, 0, 1),
|
|
new CultGeometryGridEdge(0, 0, 0, 2),
|
|
new CultGeometryGridEdge(0, 1, 0, 0),
|
|
new CultGeometryGridEdge(1, 0, 0, 1),
|
|
new CultGeometryGridEdge(0, 0, 1, 2),
|
|
};
|
|
|
|
return new CultGeometryQuadMesh
|
|
{
|
|
Positions = positions,
|
|
Quads = quads,
|
|
QuadEdges = quadEdges,
|
|
};
|
|
}
|
|
}
|
|
}
|