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:
commit
4163001c91
9 changed files with 1517 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
89
src/GameCult.Geometry/CultGeometryQuadMesh.cs
Normal file
89
src/GameCult.Geometry/CultGeometryQuadMesh.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/GameCult.Geometry/CultGeometryQuadNormals.cs
Normal file
65
src/GameCult.Geometry/CultGeometryQuadNormals.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
257
src/GameCult.Geometry/CultGeometrySurfaceNets.cs
Normal file
257
src/GameCult.Geometry/CultGeometrySurfaceNets.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
49
src/GameCult.Geometry/SURFACE-NETS-PROVENANCE.md
Normal file
49
src/GameCult.Geometry/SURFACE-NETS-PROVENANCE.md
Normal 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.
|
||||
158
tests/GameCult.Geometry.Tests/QuadNormalsTests.cs
Normal file
158
tests/GameCult.Geometry.Tests/QuadNormalsTests.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
811
tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs
Normal file
811
tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tests/GameCult.Geometry.Tests/stryker-config.json
Normal file
11
tests/GameCult.Geometry.Tests/stryker-config.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue