Merge surface nets and face-weighted normals into GameCult.Geometry

Asura Cut 2 (GameCult/Asura docs/cut-map.md). CultGeometrySurfaceNets.Extract
emits a welded quad mesh with each quad named by the grid edge it crosses,
(cell, axis), in strictly ascending order; orientation comes from the inside
flag and a per-axis handedness table, one authority. Face-weighted normals
are area-weighted via quad diagonals. Non-finite inputs are rejected.
Stryker is scoped to GameCult.Geometry.

Closed after three Soul passes; byte-identical behaviour across the
closing refactor on 31 fixtures. The 7 GeometryDocumentTests failures
predate this merge (e420410, registry check D11) and are tracked
separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-25 21:05:58 +02:00
commit 4163001c91
9 changed files with 1517 additions and 3 deletions

View file

@ -35,6 +35,18 @@ project:
dotnet stryker -t mtp --since:<base-commit-or-branch>
```
Use a full commit SHA for `--since`, not an abbreviation: `git rev-parse
<short-sha>` first, then pass the full result. Stryker resolves `--since`
through the same git plumbing a short SHA can resolve ambiguously (or fail to
resolve at all) depending on the checkout's object database, and a
misresolved base silently changes what "since" means instead of erroring.
In a container, also set `git config --global --add safe.directory '*'`
before running Stryker (or any other command that shells out to `git`
against this checkout) - git refuses to operate on a repository owned by a
different user by default, which is the common case for a bind-mounted
checkout in a container.
Reports land in `StrykerOutput/` next to the test project (git-ignored).
Open the `reports/mutation-report.html` file for a browsable view, or read
`reports/mutation-report.json` for a scriptable one.
@ -75,12 +87,73 @@ inside a `static readonly` field initializer as unproven until confirmed
with a direct `dotnet test` run against the same mutation; do not triage it
as a missing test without that check.
A survivor that looks like it must be covered is unproven until confirmed
with a direct `dotnet test` run against the same hand-applied mutation - but
confirm what the mutant actually is before drawing a tooling conclusion from
it. An earlier pass here reported three survivors in
`CultGeometrySurfaceNets.TryGetOtherEndpoint`'s per-axis `if (x + 1 >=
sizeX) return false;` guards and initially triaged them as a `vstest`
coverage false-survivor (the same shape as the `mtp`/`static readonly`
caveat above). That triage was wrong: the actual mutant Stryker reports
there is `return false` -> `return true` inside the guard, which is
equivalent, not a coverage miss. Returning `true` from
`TryGetOtherEndpoint` still leaves the out `other` coordinate equal to the
current coordinate (see the method: `other` is cloned from `coord` and only
one component is incremented when the method returns `true`), so the caller
compares a sample against itself, the inside/outside flags always match,
and the crossing is skipped exactly as it would have been had the method
correctly returned `false`. No test can distinguish the two branches because
there is nothing to distinguish - it is a legitimate equivalent mutant, not
evidence of a `vstest` false survivor.
`CultGeometrySurfaceNets.Extract`'s `if (inside == (OrientationSign[axis] <
0))` (the sole authority for quad winding after the geometric cross-product
fallback was deleted) has three mutants on that line: `==` -> `!=`, and `< 0`
negated to `> 0` or widened to `<= 0`. The first two, `inside != (OrientationSign[axis]
< 0)` and `OrientationSign[axis] > 0`, are both reported `Killed`. The third,
`OrientationSign[axis] <= 0`, survives, but it is an equivalent mutant, not a
coverage gap: `OrientationSign` is the fixed table `{ 1, -1, 1 }` and never
holds `0`, so `< 0` and `<= 0` agree on every value the table can actually
produce. There is nothing for a test to distinguish, the same shape as the
`TryGetOtherEndpoint` equivalent mutant above.
No `vstest` coverage-based false survivor has actually been confirmed for
GameCult.Geometry. Every survivor triaged here so far has turned out to be a
genuine equivalent mutant once hand-mutated and checked directly against
`dotnet test`; treat any survivor that looks like it must obviously be
covered as unproven until confirmed the same way, but do not cite this
project as precedent for the `vstest` caveat itself - that caveat is
`mtp`-runner-specific and documented above for `static readonly` field
initializers, and GameCult.Geometry has never actually hit it.
## Follow-ups
CultMath.Tests is the only project currently scoped. `GameCult.Caching`,
`GameCult.Geometry`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
CultMath.Tests and GameCult.Geometry.Tests are the only projects currently
scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner`
entry: the project is NUnit 4 with `NUnit3TestAdapter` and
`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies
directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3
suite).
GameCult.Geometry has only ever been run `--since:`-scoped to the cut that
added surface nets; a full-project baseline (running mutation against all of
`CultGeometryIsoSurface`, `CultGeometryDocuments`, and
`CultGeometryPrimitives`, not just the diff) is a recorded follow-up, not
something either cut did.
That first `--since` pass did not actually stay diff-scoped: adding
`stryker-config.json` itself in the same cut that first used it made Stryker
log "391 mutants will be tested because: Non-CSharp files in test project
were changed" and mutate the whole `mutate` glob (all four existing
GameCult.Geometry source files), not just the new surface-nets files. A cut
that changes a non-C# file inside the test project (a fixture, this config
file, anything under `Fixtures/`) loses `--since` line-level scoping for that
run; a future cut whose diff is C#-only, against a base commit that already
has `stryker-config.json`, should scope correctly.
`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each need their own
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own
`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for
Rust, `StrykerJS` for TypeScript, `mutmut` for Python) and a baseline pass.
None of that is wired into CI yet.

View file

@ -0,0 +1,89 @@
using System;
namespace GameCult.Geometry
{
/// <summary>
/// The grid edge from sample (X, Y, Z) to (X, Y, Z) + e_Axis, where Axis 0/1/2 is X/Y/Z.
/// </summary>
public readonly struct CultGeometryGridEdge : IEquatable<CultGeometryGridEdge>, IComparable<CultGeometryGridEdge>
{
/// <summary>Creates a grid edge.</summary>
public CultGeometryGridEdge(int x, int y, int z, byte axis)
{
X = x;
Y = y;
Z = z;
Axis = axis;
}
/// <summary>Gets the starting sample's x coordinate.</summary>
public int X { get; }
/// <summary>Gets the starting sample's y coordinate.</summary>
public int Y { get; }
/// <summary>Gets the starting sample's z coordinate.</summary>
public int Z { get; }
/// <summary>Gets the axis the edge runs along: 0 for x, 1 for y, 2 for z.</summary>
public byte Axis { get; }
/// <summary>Returns whether this edge is equal to another edge.</summary>
public bool Equals(CultGeometryGridEdge other) =>
X == other.X && Y == other.Y && Z == other.Z && Axis == other.Axis;
/// <inheritdoc />
public override bool Equals(object? obj) => obj is CultGeometryGridEdge other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(X, Y, Z, Axis);
/// <summary>Compares edges by (X, Y, Z, Axis) in that order.</summary>
public int CompareTo(CultGeometryGridEdge other)
{
var byX = X.CompareTo(other.X);
if (byX != 0) return byX;
var byY = Y.CompareTo(other.Y);
if (byY != 0) return byY;
var byZ = Z.CompareTo(other.Z);
if (byZ != 0) return byZ;
return Axis.CompareTo(other.Axis);
}
/// <inheritdoc />
public override string ToString() => $"({X}, {Y}, {Z})+e{Axis}";
// The relational operators (<, >, <=, >=) had no caller and no test anywhere in the repo:
// ordering goes through CompareTo (see
// Extraction_is_deterministic_and_quad_edges_are_strictly_ascending), so they were deleted
// rather than carried as untested surface. != is kept only because C# requires it whenever
// == is defined (CS0216); it is exercised directly by a test.
public static bool operator ==(CultGeometryGridEdge left, CultGeometryGridEdge right) => left.Equals(right);
public static bool operator !=(CultGeometryGridEdge left, CultGeometryGridEdge right) => !left.Equals(right);
}
/// <summary>
/// An engine-neutral quad mesh with welded vertices, produced by surface nets extraction.
/// Not a CultCache document: this is an in-process algorithm result, not a persisted artifact.
/// </summary>
public sealed class CultGeometryQuadMesh
{
/// <summary>Gets or sets the welded vertex positions, xyz per vertex.</summary>
public float[] Positions { get; set; } = Array.Empty<float>();
/// <summary>Gets or sets the quad vertex indices, 4 per quad.</summary>
public uint[] Quads { get; set; } = Array.Empty<uint>();
/// <summary>Gets or sets the grid edge each quad was emitted for, one per quad, strictly ascending.</summary>
public CultGeometryGridEdge[] QuadEdges { get; set; } = Array.Empty<CultGeometryGridEdge>();
/// <summary>Gets the number of quads.</summary>
public int QuadCount => Quads.Length / 4;
/// <summary>Gets the number of welded vertices.</summary>
public int VertexCount => Positions.Length / 3;
}
}

View file

@ -0,0 +1,65 @@
using System;
using CultMath;
namespace GameCult.Geometry
{
/// <summary>
/// Derives per-vertex normals for a welded quad mesh by face-weighted averaging.
/// </summary>
public static class CultGeometryQuadNormals
{
/// <summary>
/// Computes a per-vertex normal for <paramref name="mesh"/> as the sum, over every quad
/// touching that vertex, of the quad's diagonal cross product (which points along the
/// quad's normal and whose length is twice a non-planar quad's area), then normalized.
/// A vertex touched only by zero-area quads gets a zero normal.
/// </summary>
public static float[] FaceWeighted(CultGeometryQuadMesh mesh)
{
if (mesh == null) throw new ArgumentNullException(nameof(mesh));
var sums = new float3[mesh.VertexCount];
for (var quad = 0; quad < mesh.QuadCount; quad++)
{
var baseIndex = quad * 4;
var i0 = mesh.Quads[baseIndex];
var i1 = mesh.Quads[baseIndex + 1];
var i2 = mesh.Quads[baseIndex + 2];
var i3 = mesh.Quads[baseIndex + 3];
var p0 = Position(mesh.Positions, i0);
var p1 = Position(mesh.Positions, i1);
var p2 = Position(mesh.Positions, i2);
var p3 = Position(mesh.Positions, i3);
var faceVector = math.cross(p2 - p0, p3 - p1);
sums[i0] += faceVector;
sums[i1] += faceVector;
sums[i2] += faceVector;
sums[i3] += faceVector;
}
var normals = new float[mesh.VertexCount * 3];
for (var vertex = 0; vertex < sums.Length; vertex++)
{
var sum = sums[vertex];
// A vertex whose contributions cancel or were all zero-area gets a zero normal;
// normalize follows HLSL and would return NaN for it (matches Extract's degenerate rule).
var normal = math.dot(sum, sum) > 0f ? math.normalize(sum) : float3.zero;
normals[vertex * 3] = normal.x;
normals[vertex * 3 + 1] = normal.y;
normals[vertex * 3 + 2] = normal.z;
}
return normals;
}
private static float3 Position(float[] positions, uint index)
{
var offset = (int)index * 3;
return new float3(positions[offset], positions[offset + 1], positions[offset + 2]);
}
}
}

View file

@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using CultMath;
namespace GameCult.Geometry
{
/// <summary>
/// Extracts engine-neutral welded quad meshes from sampled scalar fields with surface nets.
/// </summary>
public static class CultGeometrySurfaceNets
{
// For axis A, the two perpendicular axes in (u, v) order. Axis 0 (X) pairs (Y, Z) and axis
// 2 (Z) pairs (X, Y): both right-handed, u x v == +A_hat. Axis 1 (Y) pairs (X, Z), which is
// left-handed (X x Z == -Y). OrientationSign below records that mirroring explicitly so
// quad winding can be derived from it instead of a per-quad geometric test.
private static readonly (int U, int V)[] PerpAxes = { (1, 2), (0, 2), (0, 1) };
// +1 when (PerpAxes[axis].U, .V) is right-handed, -1 when it is mirrored (axis 1 only).
private static readonly int[] OrientationSign = { 1, -1, 1 };
// The perimeter loop around a crossing edge, in perpendicular (u, v) space: a proper square
// loop (0,0) -> (1,0) -> (1,1) -> (0,1), not a diagonal cross. Added to a cell's own
// coordinate it gives the axis's four parallel edges inside that cell (for vertex
// placement); shifted by -1 it gives the four cells that share a crossing edge (for quad
// assembly). One table, two uses, instead of three axis-specific copies of each.
private static readonly (int U, int V)[] PerimeterLoop = { (0, 0), (1, 0), (1, 1), (0, 1) };
/// <summary>
/// Extracts the <paramref name="isoValue"/> surface with surface nets.
/// Values less than or equal to the isovalue are treated as inside.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="samples"/> is null.</exception>
/// <exception cref="ArgumentException">
/// <paramref name="samples"/> is too small or has a non-finite sample, or
/// <paramref name="isoValue"/> or a component of <paramref name="origin"/> is non-finite.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="cellSize"/> is non-positive or infinite.</exception>
/// <remarks>
/// Non-finite results from otherwise-finite inputs (for example an extreme
/// <paramref name="cellSize"/> or samples near float's magnitude limit producing an
/// infinite vertex position) are a float-domain overflow, not a validated input error;
/// this method does not range-check magnitudes for that case.
/// </remarks>
public static CultGeometryQuadMesh Extract(
float[,,] samples,
float isoValue = 0f,
CultVec3 origin = default,
float cellSize = 1f)
{
if (samples == null) throw new ArgumentNullException(nameof(samples));
if (samples.GetLength(0) < 2 || samples.GetLength(1) < 2 || samples.GetLength(2) < 2)
{
throw new ArgumentException("A surface nets field requires at least two samples on every axis.", nameof(samples));
}
foreach (var sample in samples)
{
if (!float.IsFinite(sample))
{
throw new ArgumentException("A surface nets field requires every sample to be finite.", nameof(samples));
}
}
if (!float.IsFinite(isoValue))
{
throw new ArgumentException("The isovalue must be finite.", nameof(isoValue));
}
if (!float.IsFinite(origin.X) || !float.IsFinite(origin.Y) || !float.IsFinite(origin.Z))
{
throw new ArgumentException("The origin must be finite.", nameof(origin));
}
if (!(cellSize > 0f) || float.IsInfinity(cellSize))
{
throw new ArgumentOutOfRangeException(nameof(cellSize), "Cell size must be finite and positive.");
}
var sizeX = samples.GetLength(0);
var sizeY = samples.GetLength(1);
var sizeZ = samples.GetLength(2);
var cellCounts = new[] { sizeX - 1, sizeY - 1, sizeZ - 1 };
var originValue = new float3(origin.X, origin.Y, origin.Z);
var vertexIndex = new Dictionary<(int, int, int), uint>();
var positions = new List<float>();
for (var cx = 0; cx < cellCounts[0]; cx++)
for (var cy = 0; cy < cellCounts[1]; cy++)
for (var cz = 0; cz < cellCounts[2]; cz++)
{
var cellCoord = new[] { cx, cy, cz };
var vertex = CellVertex(samples, cellCoord, isoValue, originValue, cellSize);
if (vertex == null) continue;
vertexIndex[(cx, cy, cz)] = (uint)(positions.Count / 3);
Append(vertex.Value, positions);
}
var quads = new List<uint>();
var quadEdges = new List<CultGeometryGridEdge>();
for (var x = 0; x < sizeX; x++)
for (var y = 0; y < sizeY; y++)
for (var z = 0; z < sizeZ; z++)
{
var coord = new[] { x, y, z };
for (byte axis = 0; axis < 3; axis++)
{
// Only an edge whose far endpoint is still inside the grid can cross into a
// quad. Bounding the loop with this one condition, instead of a helper that
// returned a bool for the caller to branch on, leaves one path through the
// rest of the body instead of two that happened to produce the same result.
if (coord[axis] < cellCounts[axis])
{
var other = (int[])coord.Clone();
other[axis] += 1;
var inside = IsInside(samples[x, y, z], isoValue);
var otherInside = IsInside(samples[other[0], other[1], other[2]], isoValue);
if (inside != otherInside && TryGetQuadCells(cellCounts, coord, axis, out var cells))
{
// The four cells around a crossing edge each contain that same crossing
// edge among their own twelve, so each of them is mixed and has a
// vertex: this indexer cannot miss without the extraction invariant
// already being broken.
var v = new uint[4];
for (var i = 0; i < 4; i++) v[i] = vertexIndex[cells[i]];
// Orientation comes only from which endpoint is inside and the axis's
// fixed handedness (OrientationSign) - never from the quad's own
// geometry, so degenerate (zero-area) quads still orient consistently
// with their neighbours.
if (inside == (OrientationSign[axis] < 0))
{
(v[1], v[3]) = (v[3], v[1]);
}
quads.Add(v[0]);
quads.Add(v[1]);
quads.Add(v[2]);
quads.Add(v[3]);
quadEdges.Add(new CultGeometryGridEdge(x, y, z, axis));
}
}
}
}
return new CultGeometryQuadMesh
{
Positions = positions.ToArray(),
Quads = quads.ToArray(),
QuadEdges = quadEdges.ToArray(),
};
}
private static bool IsInside(float sample, float isoValue) => sample <= isoValue;
private static float3? CellVertex(
float[,,] samples,
int[] cellCoord,
float isoValue,
float3 origin,
float cellSize)
{
var sum = float3.zero;
var count = 0;
for (byte axis = 0; axis < 3; axis++)
{
AccumulateAxisEdges(samples, cellCoord, axis, isoValue, origin, cellSize, ref sum, ref count);
}
return count == 0 ? null : sum / count;
}
private static void AccumulateAxisEdges(
float[,,] samples,
int[] cellCoord,
byte axis,
float isoValue,
float3 origin,
float cellSize,
ref float3 sum,
ref int count)
{
var (u, v) = PerpAxes[axis];
for (var i = 0; i < 4; i++)
{
var start = (int[])cellCoord.Clone();
start[u] += PerimeterLoop[i].U;
start[v] += PerimeterLoop[i].V;
var end = (int[])start.Clone();
end[axis] += 1;
var startValue = samples[start[0], start[1], start[2]];
var endValue = samples[end[0], end[1], end[2]];
if (IsInside(startValue, isoValue) == IsInside(endValue, isoValue)) continue;
var startPosition = origin + new float3(start[0], start[1], start[2]) * cellSize;
var endPosition = origin + new float3(end[0], end[1], end[2]) * cellSize;
sum += Interpolate(startPosition, startValue, endPosition, endValue, isoValue);
count++;
}
}
private static float3 Interpolate(float3 first, float firstValue, float3 second, float secondValue, float isoValue)
{
// A straddling edge has exactly one endpoint <= isoValue and the other > isoValue, so
// delta is always non-zero (.NET preserves subnormals; there is no value of delta for
// which a fallback produces a better answer than the exact interpolation).
var amount = (isoValue - firstValue) / (secondValue - firstValue);
return math.lerp(first, second, amount);
}
// The four cells that share the grid edge from (x, y, z) to (x, y, z) + e_axis, listed as a
// perimeter loop around the edge (not crossed diagonally) so the caller can build a quad
// directly from them. The edge's own coordinate is unchanged for all four cells: it is
// guaranteed to already index a valid cell there because the caller's own loop bound
// already verified coord[axis] < cellCounts[axis].
private static bool TryGetQuadCells(int[] cellCounts, int[] coord, byte axis, out (int, int, int)[] cells)
{
cells = Array.Empty<(int, int, int)>();
var (u, v) = PerpAxes[axis];
if (!InRange(coord[u] - 1, cellCounts[u]) || !InRange(coord[u], cellCounts[u]) ||
!InRange(coord[v] - 1, cellCounts[v]) || !InRange(coord[v], cellCounts[v]))
{
return false;
}
var result = new (int, int, int)[4];
for (var i = 0; i < 4; i++)
{
var cell = (int[])coord.Clone();
cell[u] += PerimeterLoop[i].U - 1;
cell[v] += PerimeterLoop[i].V - 1;
result[i] = (cell[0], cell[1], cell[2]);
}
cells = result;
return true;
}
private static bool InRange(int value, int count) => value >= 0 && value < count;
private static void Append(float3 value, List<float> destination)
{
destination.Add(value.x);
destination.Add(value.y);
destination.Add(value.z);
}
}
}

View file

@ -17,5 +17,6 @@
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="ISOSURFACE-PROVENANCE.md" Pack="true" PackagePath="\" />
<None Include="SURFACE-NETS-PROVENANCE.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,49 @@
# Surface nets extraction provenance
`CultGeometrySurfaceNets` is a clean implementation of surface nets over a
regular scalar grid. It is implemented fresh from the published algorithm
description; no code or lookup tables were copied from any source. `CultGeometryQuadNormals`
is likewise a fresh implementation of face-weighted vertex normal averaging.
Cited literature:
- S. F. F. Gibson, "Constrained Elastic Surface Nets: Generating Smooth
Surfaces from Binary Segmented Data," MICCAI 1998 (MERL Technical Report
TR99-24).
- M. Lysenko, "Smooth Voxel Terrain (Part 2)," 0fps.net, 2012.
- N. Max, "Weights for Computing Vertex Normals from Facet Normals," Journal
of Graphics Tools 4(2), 1999.
Deliberate behavior:
- a scalar sample less than or equal to the isovalue is inside, matching
`CultGeometryIsoSurface`'s sign convention;
- a cell's vertex is the mean of the linearly interpolated crossings of its
own twelve edges;
- Gibson's elastic relaxation of the vertex toward its neighbors is omitted;
the vertex stays at the unrelaxed dual-cell mean;
- one quad is emitted per crossing interior grid edge, from the (up to) four
cells that share it; an edge whose four surrounding cells do not all exist
emits no quad, leaving the mesh open at the field boundary;
- quad winding is decided by which of the crossing edge's two endpoints is
inside, times the per-axis handedness table (`OrientationSign`: `+1, -1,
+1`), never by a per-quad geometric test: axis 1's perpendicular pair (X,
Z) is left-handed, so its sign is mirrored relative to axes 0 and 2. On a
non-degenerate (non-zero-area) quad this decision has the outward-facing
property as a corollary: the diagonal cross product of the quad has a
positive dot product with the direction from the edge's inside endpoint to
its outside endpoint. A degenerate quad carries no such geometric property
to check, but still orients consistently with its neighbors because the
rule never looks at its geometry;
- non-finite `isoValue` or `origin` components are rejected, matching the
existing rejection of non-finite samples and cell size;
- an extreme but finite input (a `cellSize` near float's magnitude limit, or
finite samples whose interpolated position overflows to infinity) is a
float-domain overflow, not a validated input error, and is deliberately not
range-checked;
- vertices are welded: one vertex per active cell, shared by every quad
touching that cell;
- face-weighted normals sum each quad's diagonal cross product (unnormalized,
so a bigger quad outweighs a sliver) per touched vertex, then normalize; a
vertex touched only by zero-area quads gets a zero normal rather than NaN,
matching `CultGeometryIsoSurface`'s degenerate-normal rule.

View file

@ -0,0 +1,158 @@
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,
};
}
}
}

View file

@ -0,0 +1,811 @@
using System;
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
namespace GameCult.Geometry.Tests
{
[TestFixture]
public sealed class SurfaceNetsTests
{
[Test]
public void A_sample_exactly_on_the_isovalue_is_inside_like_a_sample_below_it()
{
var atIso = SingleInsideSample(3, 3, 3, 1, 1, 1, 0f);
var belowIso = SingleInsideSample(3, 3, 3, 1, 1, 1, -1e-4f);
var aboveIso = SingleInsideSample(3, 3, 3, 1, 1, 1, 1e-4f);
var meshAtIso = CultGeometrySurfaceNets.Extract(atIso);
var meshBelowIso = CultGeometrySurfaceNets.Extract(belowIso);
var meshAboveIso = CultGeometrySurfaceNets.Extract(aboveIso);
meshAtIso.QuadEdges.Should().Equal(meshBelowIso.QuadEdges);
meshAtIso.QuadCount.Should().Be(6);
meshAboveIso.QuadCount.Should().Be(0);
}
[Test]
public void A_single_interior_inside_sample_names_its_six_quads_by_their_crossing_edge()
{
var samples = SingleInsideSample(3, 3, 3, 1, 1, 1, -1f);
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.QuadCount.Should().Be(6);
mesh.QuadEdges.Should().BeEquivalentTo(new[]
{
new CultGeometryGridEdge(1, 1, 1, 0),
new CultGeometryGridEdge(1, 1, 1, 1),
new CultGeometryGridEdge(1, 1, 1, 2),
new CultGeometryGridEdge(0, 1, 1, 0),
new CultGeometryGridEdge(1, 0, 1, 1),
new CultGeometryGridEdge(1, 1, 0, 2),
});
}
[Test]
public void A_non_cubic_grid_names_quads_by_edge_without_swapping_axes()
{
var samples = SingleInsideSample(4, 3, 5, 2, 1, 2, -1f);
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.QuadCount.Should().Be(6);
mesh.QuadEdges.Should().BeEquivalentTo(new[]
{
new CultGeometryGridEdge(2, 1, 2, 0),
new CultGeometryGridEdge(2, 1, 2, 1),
new CultGeometryGridEdge(2, 1, 2, 2),
new CultGeometryGridEdge(1, 1, 2, 0),
new CultGeometryGridEdge(2, 0, 2, 1),
new CultGeometryGridEdge(2, 1, 1, 2),
});
}
[Test]
public void Every_quad_winds_outward_from_inside_to_outside()
{
var samples = SingleInsideSample(3, 3, 3, 1, 1, 1, -1f);
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.QuadCount.Should().Be(6);
for (var quad = 0; quad < mesh.QuadCount; quad++)
{
var edge = mesh.QuadEdges[quad];
var insideCoord = new CultVec3(1f, 1f, 1f);
var edgeStart = new CultVec3(edge.X, edge.Y, edge.Z);
var edgeEnd = OtherEndpoint(edge);
// The single inside sample is (1,1,1); the edge's other endpoint is outside.
var outsideCoord = edgeStart.Equals(insideCoord) ? edgeEnd : edgeStart;
var outward = Subtract(outsideCoord, insideCoord);
var baseIndex = quad * 4;
var v0 = Position(mesh.Positions, mesh.Quads[baseIndex]);
var v1 = Position(mesh.Positions, mesh.Quads[baseIndex + 1]);
var v2 = Position(mesh.Positions, mesh.Quads[baseIndex + 2]);
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");
}
}
[Test]
public void A_cells_vertex_is_the_mean_of_its_symmetric_edge_crossing_midpoints()
{
// A single cell with one inside corner cut symmetrically by +-1 values: each of the
// three crossing edges interpolates to its own midpoint.
var samples = new float[2, 2, 2];
samples[0, 0, 0] = -1f;
samples[1, 0, 0] = 1f;
samples[0, 1, 0] = 1f;
samples[0, 0, 1] = 1f;
samples[1, 1, 0] = 5f;
samples[1, 0, 1] = 5f;
samples[0, 1, 1] = 5f;
samples[1, 1, 1] = 5f;
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.VertexCount.Should().Be(1);
mesh.Positions[0].Should().BeApproximately(1f / 6f, 1e-6f);
mesh.Positions[1].Should().BeApproximately(1f / 6f, 1e-6f);
mesh.Positions[2].Should().BeApproximately(1f / 6f, 1e-6f);
}
[Test]
public void A_cells_vertex_is_the_mean_of_unequal_edge_crossings()
{
var samples = new float[2, 2, 2];
samples[0, 0, 0] = -1f;
samples[1, 0, 0] = 3f; // crossing at 0.25 along x
samples[0, 1, 0] = 1f; // crossing at 0.5 along y
samples[0, 0, 1] = 4f; // crossing at 0.2 along z
samples[1, 1, 0] = 5f;
samples[1, 0, 1] = 6f;
samples[0, 1, 1] = 7f;
samples[1, 1, 1] = 8f;
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.VertexCount.Should().Be(1);
mesh.Positions[0].Should().BeApproximately(0.25f / 3f, 1e-6f);
mesh.Positions[1].Should().BeApproximately(0.5f / 3f, 1e-6f);
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()
{
// The inside corner is (1,1,1), not (0,0,0): every crossing edge's start sample has at
// least one non-zero coordinate, so a cellSize applied by division instead of
// multiplication (or dropped altogether) changes the result instead of coincidentally
// matching it (a zero component erases that distinction either way).
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] = 9f;
samples[1, 1, 1] = -1f;
var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f);
mesh.VertexCount.Should().Be(1);
mesh.Positions[0].Should().BeApproximately(10f + (2.9f / 3f * 2f), 1e-5f);
mesh.Positions[1].Should().BeApproximately(20f + (2.9f / 3f * 2f), 1e-5f);
mesh.Positions[2].Should().BeApproximately(30f + (2.9f / 3f * 2f), 1e-5f);
}
[TestCase(6)]
[TestCase(8)]
[TestCase(10)]
public void A_closed_sphere_field_welds_into_a_genus_zero_manifold(int size)
{
var samples = Sphere(size);
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.QuadCount.Should().BeGreaterThan(0);
var usage = EdgeUsage(mesh);
usage.Values.Should().OnlyContain(count => count == 2);
(mesh.VertexCount - mesh.QuadCount).Should().Be(2);
}
[Test]
public void An_ambiguous_checkerboard_field_has_even_but_not_necessarily_manifold_edge_usage()
{
// A checkerboard pattern with a one-sample uniform-outside margin, so every crossing
// edge this exercises has all four surrounding cells in range: any odd usage would be
// a real non-manifold artifact of the ambiguity, not an open-boundary side effect.
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 ? (((x + y + z) % 2 == 0) ? -1f : 1f) : 5f;
}
var mesh = CultGeometrySurfaceNets.Extract(samples);
mesh.QuadCount.Should().BeGreaterThan(0);
var usage = EdgeUsage(mesh);
usage.Values.Should().OnlyContain(count => count % 2 == 0);
}
[Test]
public void An_open_plane_emits_quads_only_where_all_four_surrounding_cells_exist()
{
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);
// The plane crosses 16 grid edges (all y,z at the x=1|2 boundary), but only the
// interior y,z in {1,2} have all four surrounding cells, so only 4 quads are emitted.
mesh.QuadCount.Should().Be(4);
mesh.QuadEdges.Should().OnlyContain(edge =>
edge.X == 1 && edge.Axis == 0 &&
(edge.Y == 1 || edge.Y == 2) &&
(edge.Z == 1 || edge.Z == 2));
}
[Test]
public void An_open_plane_along_y_emits_quads_only_where_all_four_surrounding_cells_exist()
{
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);
mesh.QuadCount.Should().Be(4);
mesh.QuadEdges.Should().OnlyContain(edge =>
edge.Y == 1 && edge.Axis == 1 &&
(edge.X == 1 || edge.X == 2) &&
(edge.Z == 1 || edge.Z == 2));
}
[Test]
public void An_open_plane_along_z_emits_quads_only_where_all_four_surrounding_cells_exist()
{
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);
mesh.QuadCount.Should().Be(4);
mesh.QuadEdges.Should().OnlyContain(edge =>
edge.Z == 1 && edge.Axis == 2 &&
(edge.X == 1 || edge.X == 2) &&
(edge.Y == 1 || edge.Y == 2));
}
[Test]
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 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++)
for (var z = 0; z < 2; z++) samples[x, y, z] = 3e-21f;
samples[0, 0, 0] = -1e-30f;
var mesh = CultGeometrySurfaceNets.Extract(samples);
var expected = HandDerivedSingleCornerVertex(-1e-30f, 3e-21f);
mesh.VertexCount.Should().Be(1);
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]
public void Extraction_is_deterministic_and_quad_edges_are_strictly_ascending()
{
var samples = Sphere(8);
var first = CultGeometrySurfaceNets.Extract(samples);
var second = CultGeometrySurfaceNets.Extract(samples);
second.Positions.Should().Equal(first.Positions);
second.Quads.Should().Equal(first.Quads);
second.QuadEdges.Should().Equal(first.QuadEdges);
for (var index = 1; index < first.QuadEdges.Length; index++)
{
first.QuadEdges[index].CompareTo(first.QuadEdges[index - 1]).Should().BePositive();
}
}
[Test]
public void Grid_edge_equality_requires_every_component_to_match()
{
var edge = new CultGeometryGridEdge(1, 2, 3, 0);
(edge == new CultGeometryGridEdge(1, 2, 3, 0)).Should().BeTrue();
(edge == new CultGeometryGridEdge(9, 2, 3, 0)).Should().BeFalse();
(edge == new CultGeometryGridEdge(1, 9, 3, 0)).Should().BeFalse();
(edge == new CultGeometryGridEdge(1, 2, 9, 0)).Should().BeFalse();
(edge == new CultGeometryGridEdge(1, 2, 3, 1)).Should().BeFalse();
}
[Test]
public void Grid_edge_object_equals_distinguishes_every_component_and_rejects_null_and_other_types()
{
var edge = new CultGeometryGridEdge(1, 2, 3, 0);
object same = new CultGeometryGridEdge(1, 2, 3, 0);
object diffX = new CultGeometryGridEdge(9, 2, 3, 0);
object diffY = new CultGeometryGridEdge(1, 9, 3, 0);
object diffZ = new CultGeometryGridEdge(1, 2, 9, 0);
object diffAxis = new CultGeometryGridEdge(1, 2, 3, 1);
edge.Equals(same).Should().BeTrue();
edge.Equals(diffX).Should().BeFalse();
edge.Equals(diffY).Should().BeFalse();
edge.Equals(diffZ).Should().BeFalse();
edge.Equals(diffAxis).Should().BeFalse();
edge.Equals(null).Should().BeFalse();
edge.Equals("not an edge").Should().BeFalse();
edge.Equals((object)42).Should().BeFalse();
// object.Equals must agree with the typed Equals(CultGeometryGridEdge) it delegates to,
// and with GetHashCode wherever the two report equal, so the type is safe as a
// dictionary/set key (Extract uses it as one via vertexIndex's cell-coordinate tuples'
// sibling role, and tests key EdgeUsage dictionaries by it directly).
edge.Equals(same).Should().Be(edge.Equals((CultGeometryGridEdge)same));
edge.GetHashCode().Should().Be(((CultGeometryGridEdge)same).GetHashCode());
}
[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()
{
var edge = new CultGeometryGridEdge(1, 2, 3, 2);
edge.ToString().Should().Be("(1, 2, 3)+e2");
}
[Test]
public void Null_field_is_rejected()
{
Action act = () => CultGeometrySurfaceNets.Extract(null!);
act.Should().Throw<ArgumentNullException>();
}
[Test]
public void Invalid_field_shape_is_rejected()
{
Action act = () => CultGeometrySurfaceNets.Extract(new float[1, 2, 2]);
act.Should().Throw<ArgumentException>();
}
[TestCase(0f)]
[TestCase(-1f)]
[TestCase(float.NaN)]
[TestCase(float.PositiveInfinity)]
public void Invalid_cell_size_is_rejected(float cellSize)
{
var samples = new float[2, 2, 2];
Action act = () => CultGeometrySurfaceNets.Extract(samples, cellSize: cellSize);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[TestCase(float.NegativeInfinity)]
[TestCase(float.PositiveInfinity)]
[TestCase(float.NaN)]
public void A_non_finite_isoValue_is_rejected(float nonFiniteValue)
{
var samples = new float[2, 2, 2];
Action act = () => CultGeometrySurfaceNets.Extract(samples, isoValue: nonFiniteValue);
act.Should().Throw<ArgumentException>();
}
[TestCase(float.NegativeInfinity, 0f, 0f)]
[TestCase(float.PositiveInfinity, 0f, 0f)]
[TestCase(float.NaN, 0f, 0f)]
[TestCase(0f, float.NegativeInfinity, 0f)]
[TestCase(0f, float.PositiveInfinity, 0f)]
[TestCase(0f, float.NaN, 0f)]
[TestCase(0f, 0f, float.NegativeInfinity)]
[TestCase(0f, 0f, float.PositiveInfinity)]
[TestCase(0f, 0f, float.NaN)]
public void A_non_finite_origin_component_is_rejected(float x, float y, float z)
{
var samples = new float[2, 2, 2];
Action act = () => CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(x, y, z));
act.Should().Throw<ArgumentException>();
}
[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<ArgumentException>();
}
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];
for (var x = 0; x < sizeX; x++)
for (var y = 0; y < sizeY; y++)
for (var z = 0; z < sizeZ; z++) samples[x, y, z] = 1f;
samples[ix, iy, iz] = insideValue;
return samples;
}
private static float[,,] Sphere(int size)
{
var samples = new float[size, size, size];
var center = (size - 1) / 2f;
var radius = center - 1f;
for (var x = 0; x < size; x++)
for (var y = 0; y < size; y++)
for (var z = 0; z < size; z++)
{
var dx = x - center;
var dy = y - center;
var dz = z - center;
var distance = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
samples[x, y, z] = distance - radius;
}
return samples;
}
private static Dictionary<(uint, uint), int> EdgeUsage(CultGeometryQuadMesh mesh)
{
var usage = new Dictionary<(uint, uint), int>();
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];
var key = a < b ? (a, b) : (b, a);
usage[key] = usage.GetValueOrDefault(key) + 1;
}
}
return usage;
}
private static CultVec3 OtherEndpoint(CultGeometryGridEdge edge) => edge.Axis switch
{
0 => new CultVec3(edge.X + 1, edge.Y, edge.Z),
1 => new CultVec3(edge.X, edge.Y + 1, edge.Z),
_ => new CultVec3(edge.X, edge.Y, edge.Z + 1),
};
private static CultVec3 Subtract(CultVec3 left, CultVec3 right) =>
new(left.X - right.X, left.Y - right.Y, left.Z - right.Z);
private static CultVec3 Cross(CultVec3 left, CultVec3 right) => new(
(left.Y * right.Z) - (left.Z * right.Y),
(left.Z * right.X) - (left.X * right.Z),
(left.X * right.Y) - (left.Y * right.X));
private static float Dot(CultVec3 left, CultVec3 right) =>
(left.X * right.X) + (left.Y * right.Y) + (left.Z * right.Z);
private static CultVec3 Position(float[] positions, uint index)
{
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<b vs b<a traversals), whether it
// is shared by two quads (the ordinary manifold case) or four (an ambiguous/ degenerate
// crossing). Coincident-vertex (zero-length) quad edges from a fully collapsed quad are
// not real shared edges and are excluded.
private static void AssertEdgeDirectionsBalance(CultGeometryQuadMesh mesh)
{
var net = new Dictionary<(uint, uint), int>();
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");
}
}
}

View file

@ -0,0 +1,11 @@
{
"stryker-config": {
"project": "GameCult.Geometry.csproj",
"mutate": [
"**/src/GameCult.Geometry/**/*.cs"
],
"mutation-level": "Standard",
"reporters": [ "progress", "html", "json" ],
"thresholds": { "high": 80, "low": 60, "break": 0 }
}
}