diff --git a/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs b/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs index 770a19be..5ac51c51 100644 --- a/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs +++ b/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs @@ -88,6 +88,12 @@ namespace GameCult.Geometry.Tests var v3 = Position(mesh.Positions, mesh.Quads[baseIndex + 3]); var cross = Cross(Subtract(v2, v0), Subtract(v3, v1)); + // Restricted to non-degenerate (non-zero-area) quads: the spec's geometric + // winding rule is only meaningful there. Degenerate/collapsed quads are covered + // separately by AssertEdgeDirectionsBalance, since a zero cross product carries + // no orientation information to check here. + if (cross.X == 0f && cross.Y == 0f && cross.Z == 0f) continue; + Dot(cross, outward).Should().BePositive($"quad for edge {edge} should wind outward"); } } @@ -136,6 +142,59 @@ namespace GameCult.Geometry.Tests mesh.Positions[2].Should().BeApproximately(0.2f / 3f, 1e-6f); } + // A single cell's 12 edges form a cube graph (3-regular, 8 vertices): the number of edges + // with differently-signed endpoints (a graph cut) is provably never 1 or 2 for any corner + // sign pattern - the minimum nonzero cut is 3 (one corner opposite the other seven), which + // the two fixtures above already exercise. 4 and 6 below are the next cuts a single cell + // can actually produce; both distinguish sum/count from the sum/3 mutant just as well. + [Test] + public void A_cells_vertex_with_four_crossings_is_their_mean_not_a_third() + { + // One face (x=0) inside, the opposite face (x=1) outside: only the 4 x-axis edges + // cross, each at the same amount since every inside/outside value is uniform. + var samples = new float[2, 2, 2]; + for (var y = 0; y < 2; y++) + for (var z = 0; z < 2; z++) + { + samples[0, y, z] = -1f; + samples[1, y, z] = 3f; + } + + var mesh = CultGeometrySurfaceNets.Extract(samples); + + // amount = (0 - -1) / (3 - -1) = 0.25 for all four x-edges, at (y, z) in {0,1}x{0,1}. + // mean = ((0.25,0,0)+(0.25,1,0)+(0.25,0,1)+(0.25,1,1)) / 4 = (0.25, 0.5, 0.5). + mesh.VertexCount.Should().Be(1); + mesh.Positions[0].Should().BeApproximately(0.25f, 1e-6f); + mesh.Positions[1].Should().BeApproximately(0.5f, 1e-6f); + mesh.Positions[2].Should().BeApproximately(0.5f, 1e-6f); + } + + [Test] + public void A_cells_vertex_with_six_crossings_is_their_mean_not_a_third() + { + // Two face-diagonal (non-adjacent) corners inside, the other six outside: a cut of 6 + // (3 edges from each inside corner; the two inside corners share no edge). + var samples = new float[2, 2, 2]; + for (var x = 0; x < 2; x++) + for (var y = 0; y < 2; y++) + for (var z = 0; z < 2; z++) samples[x, y, z] = 3f; + + samples[0, 0, 0] = -1f; + samples[1, 1, 0] = -1f; + + var mesh = CultGeometrySurfaceNets.Extract(samples); + + // amount = (0 - -1) / (3 - -1) = 0.25 for all six crossings (uniform inside/outside + // values). From (0,0,0): (0.25,0,0), (0,0.25,0), (0,0,0.25). + // From (1,1,0): (0.75,1,0), (1,0.75,0), (1,1,0.25). + // mean = (3.0, 3.0, 0.5) / 6 = (0.5, 0.5, 1/12). + mesh.VertexCount.Should().Be(1); + mesh.Positions[0].Should().BeApproximately(0.5f, 1e-6f); + mesh.Positions[1].Should().BeApproximately(0.5f, 1e-6f); + mesh.Positions[2].Should().BeApproximately(1f / 12f, 1e-6f); + } + [Test] public void Vertex_placement_applies_origin_and_cell_size() { @@ -258,11 +317,13 @@ namespace GameCult.Geometry.Tests } [Test] - public void An_extremely_small_edge_delta_falls_back_to_the_edge_midpoint() + public void A_tiny_but_normal_edge_delta_interpolates_exactly_instead_of_falling_back_to_the_midpoint() { - // Every crossing edge from the inside corner has a delta of ~3e-21, under the 1e-20 - // threshold that falls back to the 0.5 midpoint; computed directly, (0 - -1e-30) / 3e-21 - // would land near the inside endpoint instead (amt ~ 3.3e-10), not the midpoint. + // Every crossing edge from the inside corner has firstValue -1e-30f and secondValue + // 3e-21f: a delta of ~3e-21, comfortably inside float's normal range (well above the + // ~1.18e-38 normal floor) but far under the old 1e-20 guard that used to fall back to + // the 0.5 midpoint. The exact amount, computed independently in double precision + // below (not by calling Interpolate), is ~3.33e-10 - nowhere near 0.5. var samples = new float[2, 2, 2]; for (var x = 0; x < 2; x++) for (var y = 0; y < 2; y++) @@ -272,10 +333,192 @@ namespace GameCult.Geometry.Tests var mesh = CultGeometrySurfaceNets.Extract(samples); + var expected = HandDerivedSingleCornerVertex(-1e-30f, 3e-21f); + mesh.VertexCount.Should().Be(1); - mesh.Positions[0].Should().BeApproximately(1f / 6f, 1e-4f); - mesh.Positions[1].Should().BeApproximately(1f / 6f, 1e-4f); - mesh.Positions[2].Should().BeApproximately(1f / 6f, 1e-4f); + mesh.Positions[0].Should().BeApproximately(expected, 1e-12f); + mesh.Positions[1].Should().BeApproximately(expected, 1e-12f); + mesh.Positions[2].Should().BeApproximately(expected, 1e-12f); + } + + [Test] + public void A_subnormal_edge_delta_interpolates_exactly_instead_of_falling_back_to_the_midpoint() + { + // Both endpoints of every crossing edge from the inside corner are themselves + // subnormal floats (|value| well under the ~1.1755e-38 normal floor), so the delta + // between them is subnormal too. .NET keeps subnormal precision, so this must + // interpolate exactly rather than fall back to 0.5, same as the tiny-but-normal case. + var samples = new float[2, 2, 2]; + for (var x = 0; x < 2; x++) + for (var y = 0; y < 2; y++) + for (var z = 0; z < 2; z++) samples[x, y, z] = 3e-40f; + + samples[0, 0, 0] = -1e-40f; + + var mesh = CultGeometrySurfaceNets.Extract(samples); + + var expected = HandDerivedSingleCornerVertex(-1e-40f, 3e-40f); + + mesh.VertexCount.Should().Be(1); + mesh.Positions[0].Should().BeApproximately(expected, 1e-6f); + mesh.Positions[1].Should().BeApproximately(expected, 1e-6f); + mesh.Positions[2].Should().BeApproximately(expected, 1e-6f); + } + + // The four cells around a crossing edge are documented as a perimeter loop in a specific + // cyclic order per axis (see TryGetQuadCells). Each of the three tests below pins that + // exact order end to end (which welded vertex position lands in Quads[0..3]) for one axis, + // using an open-plane fixture whose four relevant cells have distinct, hand-computable + // vertex positions, so swapping or mirroring any of the four is directly observable. + [Test] + public void An_axis_0_quads_corners_follow_the_documented_perimeter_order() + { + var samples = new float[4, 4, 4]; + for (var x = 0; x < 4; x++) + for (var y = 0; y < 4; y++) + for (var z = 0; z < 4; z++) samples[x, y, z] = x - 1.5f; + + var mesh = CultGeometrySurfaceNets.Extract(samples); + var edge = new CultGeometryGridEdge(1, 1, 1, 0); + + // Cells (x, y-1, z-1), (x, y, z-1), (x, y, z), (x, y-1, z) each have their vertex at + // the cell's own (y, z) center, since the field only varies with x: (1.5, cy+0.5, + // cz+0.5). The edge's low end (x=1) is inside and axis 0 is not mirrored, so the + // documented c0..c3 order is not swapped. + var expected = new[] + { + new CultVec3(1.5f, 0.5f, 0.5f), // c0 = (1, 0, 0) + new CultVec3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0) + new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new CultVec3(1.5f, 0.5f, 1.5f), // c3 = (1, 0, 1) + }; + + AssertQuadCorners(mesh, edge, expected); + } + + [Test] + public void An_axis_1_quads_corners_follow_the_documented_perimeter_order() + { + var samples = new float[4, 4, 4]; + for (var x = 0; x < 4; x++) + for (var y = 0; y < 4; y++) + for (var z = 0; z < 4; z++) samples[x, y, z] = y - 1.5f; + + var mesh = CultGeometrySurfaceNets.Extract(samples); + var edge = new CultGeometryGridEdge(1, 1, 1, 1); + + // Cells (x-1, y, z-1), (x, y, z-1), (x, y, z), (x-1, y, z) each have their vertex at + // (cx+0.5, 1.5, cz+0.5). The edge's low end (y=1) is inside, but axis 1 is the + // mirrored one (OrientationSign[1] < 0), so the documented order is swapped: v1 and + // v3 trade places relative to the raw c0..c3 listing. + var expected = new[] + { + new CultVec3(0.5f, 1.5f, 0.5f), // c0 = (0, 1, 0) + new CultVec3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1), swapped into v1 + new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new CultVec3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0), swapped into v3 + }; + + AssertQuadCorners(mesh, edge, expected); + } + + [Test] + public void An_axis_2_quads_corners_follow_the_documented_perimeter_order() + { + var samples = new float[4, 4, 4]; + for (var x = 0; x < 4; x++) + for (var y = 0; y < 4; y++) + for (var z = 0; z < 4; z++) samples[x, y, z] = z - 1.5f; + + var mesh = CultGeometrySurfaceNets.Extract(samples); + var edge = new CultGeometryGridEdge(1, 1, 1, 2); + + // Cells (x-1, y-1, z), (x, y-1, z), (x, y, z), (x-1, y, z) each have their vertex at + // (cx+0.5, cy+0.5, 1.5). The edge's low end (z=1) is inside and axis 2 is not + // mirrored, so the documented c0..c3 order is not swapped. + var expected = new[] + { + new CultVec3(0.5f, 0.5f, 1.5f), // c0 = (0, 0, 1) + new CultVec3(1.5f, 0.5f, 1.5f), // c1 = (1, 0, 1) + new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new CultVec3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1) + }; + + AssertQuadCorners(mesh, edge, expected); + } + + // The spec's orientation rule "(d1 x d2).(outside - inside) > 0" still holds on + // non-degenerate quads (Every_quad_winds_outward_from_inside_to_outside above pins that + // directly). These fixtures target the cases the old per-quad geometric fallback got + // wrong: quads that collapse to zero area, where orientation must still come from + // topology alone and every shared undirected edge must still be traversed in opposite + // directions by its two (or four) quads. + [Test] + public void A_sample_exactly_at_the_isovalue_collapses_every_quad_but_edges_still_balance() + { + // The inside sample equals isoValue exactly, so every crossing edge from it + // interpolates to amount 0: all six quads around it collapse onto the same point. + var samples = SingleInsideSample(3, 3, 3, 1, 1, 1, 0f); + + var mesh = CultGeometrySurfaceNets.Extract(samples); + + mesh.QuadCount.Should().Be(6); + AssertEdgeDirectionsBalance(mesh); + } + + [Test] + public void A_sphere_at_a_huge_origin_with_a_tiny_cell_size_collapses_quads_but_edges_still_balance() + { + // A large origin combined with a tiny cell size used to erase the float32 precision + // the old geometric fallback needed: vertex positions could round to the same value + // even for non-degenerate quads, making the per-quad cross product an unreliable + // orientation source. Orientation no longer reads positions at all, so this must + // balance regardless. + var samples = Sphere(64); + + var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(1e4f, 1e4f, 1e4f), cellSize: 0.001f); + + mesh.QuadCount.Should().BeGreaterThan(0); + AssertEdgeDirectionsBalance(mesh); + } + + [Test] + public void An_octahedron_field_sampled_exactly_at_the_isovalue_collapses_quads_but_edges_still_balance() + { + // An L1-distance ("octahedron") field with the radius chosen to land exactly on + // several lattice points: those quads collapse just like the single-sample case. A + // one-sample uniform-outside margin (same as the checkerboard fixture elsewhere in + // this file) keeps every crossing edge's four surrounding cells in range, so an + // open-boundary artifact can't masquerade as an orientation imbalance. + var samples = new float[5, 5, 5]; + for (var x = 0; x < 5; x++) + for (var y = 0; y < 5; y++) + for (var z = 0; z < 5; z++) + { + var interior = x is >= 1 and <= 3 && y is >= 1 and <= 3 && z is >= 1 and <= 3; + samples[x, y, z] = interior ? Math.Abs(x - 2) + Math.Abs(y - 2) + Math.Abs(z - 2) - 1f : 5f; + } + + var mesh = CultGeometrySurfaceNets.Extract(samples); + + mesh.QuadCount.Should().BeGreaterThan(0); + AssertEdgeDirectionsBalance(mesh); + } + + [Test] + public void A_field_within_a_tiny_tolerance_of_the_isovalue_collapses_quads_but_edges_still_balance() + { + // A sphere field scaled down by 1e-9 so most non-boundary values sit within 1e-8 of + // the isovalue without changing which side of it they are on (scaling preserves sign). + var samples = Sphere(10); + for (var x = 0; x < 10; x++) + for (var y = 0; y < 10; y++) + for (var z = 0; z < 10; z++) samples[x, y, z] *= 1e-9f; + + var mesh = CultGeometrySurfaceNets.Extract(samples); + + mesh.QuadCount.Should().BeGreaterThan(0); + AssertEdgeDirectionsBalance(mesh); } [Test] @@ -308,6 +551,17 @@ namespace GameCult.Geometry.Tests (edge == new CultGeometryGridEdge(1, 2, 3, 1)).Should().BeFalse(); } + [Test] + public void Grid_edge_inequality_is_the_negation_of_equality() + { + // != has no caller of its own; it exists only because C# requires it whenever == is + // defined (CS0216). This is its only direct test. + var edge = new CultGeometryGridEdge(1, 2, 3, 0); + + (edge != new CultGeometryGridEdge(1, 2, 3, 0)).Should().BeFalse(); + (edge != new CultGeometryGridEdge(9, 2, 3, 0)).Should().BeTrue(); + } + [Test] public void Grid_edge_to_string_names_its_coordinates_and_axis() { @@ -345,6 +599,19 @@ namespace GameCult.Geometry.Tests act.Should().Throw(); } + [TestCase(float.NegativeInfinity)] + [TestCase(float.PositiveInfinity)] + [TestCase(float.NaN)] + public void A_non_finite_sample_is_rejected(float nonFiniteValue) + { + var samples = new float[2, 2, 2]; + samples[0, 0, 0] = nonFiniteValue; + + Action act = () => CultGeometrySurfaceNets.Extract(samples); + + act.Should().Throw(); + } + private static float[,,] SingleInsideSample(int sizeX, int sizeY, int sizeZ, int ix, int iy, int iz, float insideValue) { var samples = new float[sizeX, sizeY, sizeZ]; @@ -424,5 +691,64 @@ namespace GameCult.Geometry.Tests var offset = (int)index * 3; return new CultVec3(positions[offset], positions[offset + 1], positions[offset + 2]); } + + // Hand-derived (double precision, independent of Interpolate) expected position for a + // single-inside-corner-at-origin fixture where every one of the three crossing edges has + // the same firstValue/secondValue pair. + private static float HandDerivedSingleCornerVertex(float insideValue, float outsideValue) + { + double first = insideValue; + double second = outsideValue; + var amount = (0d - first) / (second - first); + return (float)(amount / 3d); + } + + private static void AssertQuadCorners(CultGeometryQuadMesh mesh, CultGeometryGridEdge edge, CultVec3[] expected) + { + var quadIndex = Array.IndexOf(mesh.QuadEdges, edge); + quadIndex.Should().BeGreaterThanOrEqualTo(0, $"a quad for edge {edge} should exist"); + + var baseIndex = quadIndex * 4; + for (var i = 0; i < 4; i++) + { + Position(mesh.Positions, mesh.Quads[baseIndex + i]).Should().Be(expected[i], + $"corner {i} of the quad for edge {edge} should be {expected[i]}"); + } + } + + // Every shared undirected edge of a welded quad mesh must be traversed in opposite + // directions by the quads that share it (net zero over a(); + for (var quad = 0; quad < mesh.QuadCount; quad++) + { + var baseIndex = quad * 4; + var indices = new[] + { + mesh.Quads[baseIndex], + mesh.Quads[baseIndex + 1], + mesh.Quads[baseIndex + 2], + mesh.Quads[baseIndex + 3], + }; + + for (var edge = 0; edge < 4; edge++) + { + var a = indices[edge]; + var b = indices[(edge + 1) % 4]; + if (a == b) continue; + + var key = a < b ? (a, b) : (b, a); + var sign = a < b ? 1 : -1; + net[key] = net.GetValueOrDefault(key) + sign; + } + } + + net.Values.Should().OnlyContain(value => value == 0, + "every shared undirected edge should be traversed in opposite directions by its quads"); + } } }