diff --git a/src/GameCult.Geometry/CultGeometryIsoSurface.cs b/src/GameCult.Geometry/CultGeometryIsoSurface.cs
new file mode 100644
index 00000000..6fc9355c
--- /dev/null
+++ b/src/GameCult.Geometry/CultGeometryIsoSurface.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using CultMath;
+
+namespace GameCult.Geometry
+{
+ ///
+ /// Extracts engine-neutral triangle meshes from sampled scalar fields.
+ ///
+ public static class CultGeometryIsoSurface
+ {
+ private static readonly float3[] CubeCorners =
+ {
+ new(0f, 0f, 0f),
+ new(1f, 0f, 0f),
+ new(1f, 1f, 0f),
+ new(0f, 1f, 0f),
+ new(0f, 0f, 1f),
+ new(1f, 0f, 1f),
+ new(1f, 1f, 1f),
+ new(0f, 1f, 1f),
+ };
+
+ // A fixed six-tetrahedra decomposition keeps neighboring cube faces consistent.
+ private static readonly int[,] CubeTetrahedra =
+ {
+ { 0, 5, 1, 6 },
+ { 0, 1, 2, 6 },
+ { 0, 2, 3, 6 },
+ { 0, 3, 7, 6 },
+ { 0, 7, 4, 6 },
+ { 0, 4, 5, 6 },
+ };
+
+ ///
+ /// Extracts the surface with marching tetrahedra.
+ /// Values less than or equal to the isovalue are treated as inside.
+ ///
+ public static CultGeometryTriangleMesh 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("An isosurface field requires at least two samples on every axis.", nameof(samples));
+ }
+
+ if (!(cellSize > 0f) || float.IsInfinity(cellSize))
+ {
+ throw new ArgumentOutOfRangeException(nameof(cellSize), "Cell size must be finite and positive.");
+ }
+
+ var positions = new List();
+ var normals = new List();
+ var indices = new List();
+ var originValue = new float3(origin.X, origin.Y, origin.Z);
+ var cornerPositions = new float3[8];
+ var cornerValues = new float[8];
+ var tetraPositions = new float3[4];
+ var tetraValues = new float[4];
+
+ for (var x = 0; x < samples.GetLength(0) - 1; x++)
+ for (var y = 0; y < samples.GetLength(1) - 1; y++)
+ for (var z = 0; z < samples.GetLength(2) - 1; z++)
+ {
+ var cubeOrigin = originValue + new float3(x, y, z) * cellSize;
+ for (var corner = 0; corner < CubeCorners.Length; corner++)
+ {
+ var offset = CubeCorners[corner];
+ cornerPositions[corner] = cubeOrigin + offset * cellSize;
+ cornerValues[corner] = samples[
+ x + (int)offset.x,
+ y + (int)offset.y,
+ z + (int)offset.z];
+ }
+
+ for (var tetrahedron = 0; tetrahedron < CubeTetrahedra.GetLength(0); tetrahedron++)
+ {
+ for (var vertex = 0; vertex < 4; vertex++)
+ {
+ var corner = CubeTetrahedra[tetrahedron, vertex];
+ tetraPositions[vertex] = cornerPositions[corner];
+ tetraValues[vertex] = cornerValues[corner];
+ }
+
+ PolygonizeTetrahedron(tetraPositions, tetraValues, isoValue, positions, normals, indices);
+ }
+ }
+
+ return new CultGeometryTriangleMesh
+ {
+ Positions = positions.ToArray(),
+ Normals = normals.ToArray(),
+ Indices = indices.ToArray(),
+ Uvs = Array.Empty(),
+ TriangleMaterials = new uint[indices.Count / 3],
+ };
+ }
+
+ private static void PolygonizeTetrahedron(
+ float3[] positions,
+ float[] values,
+ float isoValue,
+ List outputPositions,
+ List outputNormals,
+ List outputIndices)
+ {
+ Span inside = stackalloc int[4];
+ Span outside = stackalloc int[4];
+ var insideCount = 0;
+ var outsideCount = 0;
+
+ for (var vertex = 0; vertex < 4; vertex++)
+ {
+ if (values[vertex] <= isoValue) inside[insideCount++] = vertex;
+ else outside[outsideCount++] = vertex;
+ }
+
+ if (insideCount == 0 || insideCount == 4) return;
+
+ var outward = Centroid(positions, outside, outsideCount) - Centroid(positions, inside, insideCount);
+ if (insideCount == 1 || outsideCount == 1)
+ {
+ var loneIsInside = insideCount == 1;
+ var lone = loneIsInside ? inside[0] : outside[0];
+ var others = loneIsInside ? outside : inside;
+ var a = Interpolate(positions[lone], values[lone], positions[others[0]], values[others[0]], isoValue);
+ var b = Interpolate(positions[lone], values[lone], positions[others[1]], values[others[1]], isoValue);
+ var c = Interpolate(positions[lone], values[lone], positions[others[2]], values[others[2]], isoValue);
+ EmitOrientedTriangle(a, b, c, outward, outputPositions, outputNormals, outputIndices);
+ return;
+ }
+
+ var p00 = Interpolate(positions[inside[0]], values[inside[0]], positions[outside[0]], values[outside[0]], isoValue);
+ var p01 = Interpolate(positions[inside[0]], values[inside[0]], positions[outside[1]], values[outside[1]], isoValue);
+ var p10 = Interpolate(positions[inside[1]], values[inside[1]], positions[outside[0]], values[outside[0]], isoValue);
+ var p11 = Interpolate(positions[inside[1]], values[inside[1]], positions[outside[1]], values[outside[1]], isoValue);
+ EmitOrientedTriangle(p00, p01, p11, outward, outputPositions, outputNormals, outputIndices);
+ EmitOrientedTriangle(p00, p11, p10, outward, outputPositions, outputNormals, outputIndices);
+ }
+
+ private static float3 Interpolate(float3 first, float firstValue, float3 second, float secondValue, float isoValue)
+ {
+ var delta = secondValue - firstValue;
+ var amount = math.abs(delta) <= 1e-20f ? 0.5f : (isoValue - firstValue) / delta;
+ return math.lerp(first, second, amount);
+ }
+
+ private static float3 Centroid(float3[] positions, Span vertices, int count)
+ {
+ var sum = float3.zero;
+ for (var index = 0; index < count; index++) sum += positions[vertices[index]];
+ return sum / count;
+ }
+
+ private static void EmitOrientedTriangle(
+ float3 a,
+ float3 b,
+ float3 c,
+ float3 outward,
+ List positions,
+ List normals,
+ List indices)
+ {
+ var normal = math.cross(b - a, c - a);
+ if (math.dot(normal, outward) < 0f)
+ {
+ (b, c) = (c, b);
+ normal = -normal;
+ }
+
+ normal = math.normalize(normal);
+ var firstIndex = (uint)(positions.Count / 3);
+ Append(a, positions);
+ Append(b, positions);
+ Append(c, positions);
+ for (var vertex = 0; vertex < 3; vertex++) Append(normal, normals);
+ indices.Add(firstIndex);
+ indices.Add(firstIndex + 1);
+ indices.Add(firstIndex + 2);
+ }
+
+ private static void Append(float3 value, List destination)
+ {
+ destination.Add(value.x);
+ destination.Add(value.y);
+ destination.Add(value.z);
+ }
+ }
+}
diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj
index a0192f69..a3ad9457 100644
--- a/src/GameCult.Geometry/GameCult.Geometry.csproj
+++ b/src/GameCult.Geometry/GameCult.Geometry.csproj
@@ -1,6 +1,6 @@
- Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir
+ CultGeometry algorithms and typed CultCache geometry artifacts
GameCult.Geometry
game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir
README.md
@@ -8,6 +8,7 @@
+
@@ -15,5 +16,6 @@
+
diff --git a/src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md b/src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md
new file mode 100644
index 00000000..a15a4933
--- /dev/null
+++ b/src/GameCult.Geometry/ISOSURFACE-PROVENANCE.md
@@ -0,0 +1,32 @@
+# Isosurface extraction provenance
+
+`CultGeometryIsoSurface` is a clean implementation of marching tetrahedra over
+a regular scalar grid. The implementation follows the tetrahedral case shape
+described by Peter Shirley and Allan Tuchman, “A Polygonal Approximation to
+Direct Scalar Volume Rendering,” Computer Graphics 24(5), 1990,
+doi:10.1145/99308.99322.
+
+The DELVE/HOLD port was behaviorally motivated by these unversioned local Unity
+sources:
+
+- `D:\WIP4\Projects\VoxelTerrain\Assets\Scripts\Procedural\GenerateTerrain.cs`
+- `D:\WIP4\Projects\VoxelTerrain\Assets\Plugins\MarchingCubes.cs`
+
+The source directory is not a Git checkout and the plugin carries no license or
+author notice. Its code and lookup tables were therefore not copied. The
+CultGeometry implementation uses a fresh six-tetrahedra decomposition, explicit
+inside/outside classification, interpolation, winding, and flat-normal output.
+
+Deliberate behavior retained:
+
+- scalar samples less than or equal to the isovalue are inside;
+- intersections are linearly interpolated along cell edges;
+- vertices are emitted per triangle rather than welded;
+- output is engine-neutral positions, normals, indices, and material slots.
+
+Deliberate changes:
+
+- no Unity mesh, physics, coroutine, navigation, or random dependency;
+- no mutable global target, mode, or winding state;
+- outward winding is derived from inside-to-outside direction;
+- invalid fields and cell sizes fail at the public boundary.
diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md
index 200f14ed..1ea60768 100644
--- a/src/GameCult.Geometry/README.md
+++ b/src/GameCult.Geometry/README.md
@@ -1,8 +1,17 @@
# GameCult.Geometry
-`GameCult.Geometry` defines shared geometry values plus CultCache-native
-documents for procedural geometry domains, LOD build requests, selected-cut
-manifests, and mesh chunk artifacts.
+The project is known as **CultGeometry**. `GameCult.Geometry` is its clean .NET
+package and namespace identity.
+
+`GameCult.Geometry` owns reusable procedural geometry algorithms together with
+the CultCache-native documents that carry their domains, build requests,
+selected cuts, and mesh chunk artifacts.
+
+Its first engine-neutral algorithm is `CultGeometryIsoSurface.Extract`, a
+marching-tetrahedra extractor over regular scalar grids. It uses CultMath for
+numeric primitives and emits `CultGeometryTriangleMesh`; Godot, Unity, native
+workers, and other consumers adapt that neutral result into their own runtime
+objects. See `ISOSURFACE-PROVENANCE.md` for the clean-port boundary.
The package is deliberately substrate-shaped:
@@ -33,10 +42,10 @@ runtime equivalents instead of bespoke `{ minX, minY, maxX, maxY }` payloads.
## Runtime Path
-`vg-csg` emits domain documents, selected-cut manifests, and chunk artifacts as
-MessagePack CultCache documents. A Unity runtime, local worker, or remote
-geometry process should receive the same records through CultMesh/CultNet and
-read them back as `CultGeometry*` types.
+CultGeometry CSG (`GameCult.Geometry.Csg`) emits domain documents, selected-cut
+manifests, and chunk artifacts as MessagePack CultCache documents. A Godot
+runtime, local worker, or remote geometry process receives the same records
+through CultMesh/CultNet and reads them back as `CultGeometry*` types.
```csharp
using GameCult.Caching;
diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs
index b39b5182..16e66b8e 100644
--- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs
+++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs
@@ -192,7 +192,7 @@ namespace GameCult.Geometry.Tests
chunk,
new CultNetDocumentMessageOptions
{
- SourceRuntimeId = "vg-csg",
+ SourceRuntimeId = "gamecult.geometry.csg",
SourceRole = "geometry-worker",
Tags = ["ragnarok", "chunk"]
});
@@ -282,7 +282,7 @@ namespace GameCult.Geometry.Tests
StartServer = false,
DatabaseOptions = new CultNetDatabaseOptions
{
- RuntimeId = "unity-runtime",
+ RuntimeId = "godot-runtime",
DocumentRegistry = registry
}
}))
@@ -314,7 +314,7 @@ namespace GameCult.Geometry.Tests
{
DomainId = "ragnarok-column",
RootKey = "ragnarok-column",
- SourceRuntime = "vg-csg",
+ SourceRuntime = "gamecult.geometry.csg",
CreatedAt = "2026-05-29T00:00:00.0000000Z",
Root = new CultGeometryDomainNode
{
@@ -359,6 +359,7 @@ namespace GameCult.Geometry.Tests
return Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Fixtures",
+ // Immutable interoperability fixtures emitted before the CSG package rename.
"vg-csg-ragnarok",
fileName);
}
diff --git a/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs
new file mode 100644
index 00000000..90d5f7f6
--- /dev/null
+++ b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs
@@ -0,0 +1,89 @@
+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 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();
+ }
+
+ 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;
+ }
+ }
+}