CultLib/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs
Meta Vens 111b918ec0 Make CultMath normalize follow dxc and release 0.2.2
dxc 1.9.0.5402 lowers float2/3/4 normalize(x) to x * Rsqrt(Dot(x, x)),
and DXIL.rst defines Rsqrt as 1 / sqrt(src). A zero vector normalizes
to NaN and a NaN component poisons every component. CultMath divided
by max(length, 1e-20), which returned 0 for a zero vector and a huge
finite component beside a NaN one.

- math.normalize(float2/3/4) is now value * (1 / sqrt(dot(value, value))).
- The HLSL mirror drops cultmath_normalize and its epsilon constant: it
  now equals the normalize intrinsic, and nothing consumed it.
- HlslSemanticsTests pins zero and NaN normalize, the rsqrt bit pattern,
  and zero length/distance. design.md states the rule and notes that
  DXIL.rst's Rsqrt special-value table is a copy of Round's.
- GameCult.Geometry's iso-surface gave degenerate triangles (an inside
  vertex on the isovalue) a zero normal through the old epsilon. It now
  guards them explicitly, with a test.
- quaternion normalize keeps its identity fallback: quaternion is not
  an HLSL type.
- Bump to 0.2.2 and rebuild the tracked Unity DLL and pdb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:04:04 +02:00

106 lines
3.5 KiB
C#

using System;
using FluentAssertions;
using NUnit.Framework;
namespace GameCult.Geometry.Tests
{
[TestFixture]
public sealed class IsoSurfaceTests
{
[TestCase(-1f)]
[TestCase(1f)]
public void Uniform_field_has_no_surface(float value)
{
var samples = Filled(2, value);
var mesh = CultGeometryIsoSurface.Extract(samples);
mesh.Positions.Should().BeEmpty();
mesh.Normals.Should().BeEmpty();
mesh.Indices.Should().BeEmpty();
}
[Test]
public void Plane_field_interpolates_to_the_isovalue_and_faces_outward()
{
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] = -0.5f;
samples[1, y, z] = 0.5f;
}
var mesh = CultGeometryIsoSurface.Extract(samples);
mesh.TriangleCount.Should().BeGreaterThan(0);
for (var index = 0; index < mesh.Positions.Length; index += 3)
{
mesh.Positions[index].Should().BeApproximately(0.5f, 1e-6f);
}
for (var index = 0; index < mesh.Normals.Length; index += 3)
{
mesh.Normals[index].Should().BeApproximately(1f, 1e-6f);
mesh.Normals[index + 1].Should().BeApproximately(0f, 1e-6f);
mesh.Normals[index + 2].Should().BeApproximately(0f, 1e-6f);
}
}
[Test]
public void Samples_on_the_isovalue_emit_finite_normals()
{
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] = 0f;
samples[1, y, z] = 1f;
}
var mesh = CultGeometryIsoSurface.Extract(samples);
mesh.TriangleCount.Should().BeGreaterThan(0);
mesh.Normals.Should().OnlyContain(value => float.IsFinite(value));
}
[Test]
public void Extraction_is_deterministic_and_applies_origin_and_cell_size()
{
var samples = new float[2, 2, 2];
samples[0, 0, 0] = -1f;
samples[1, 0, 0] = 1f;
samples[0, 1, 0] = 1f;
samples[1, 1, 0] = 1f;
samples[0, 0, 1] = 1f;
samples[1, 0, 1] = 1f;
samples[0, 1, 1] = 1f;
samples[1, 1, 1] = 1f;
var first = CultGeometryIsoSurface.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f);
var second = CultGeometryIsoSurface.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f);
second.Positions.Should().Equal(first.Positions);
second.Normals.Should().Equal(first.Normals);
second.Indices.Should().Equal(first.Indices);
first.Positions.Should().OnlyContain(value => value >= 10f);
}
[Test]
public void Invalid_field_shape_is_rejected()
{
Action act = () => CultGeometryIsoSurface.Extract(new float[1, 2, 2]);
act.Should().Throw<ArgumentException>();
}
private static float[,,] Filled(int size, float value)
{
var samples = new float[size, size, size];
for (var x = 0; x < size; x++)
for (var y = 0; y < size; y++)
for (var z = 0; z < size; z++) samples[x, y, z] = value;
return samples;
}
}
}