Publish typed geometry v2 contracts

This commit is contained in:
Meta Vens 2026-07-22 03:12:55 +01:00
commit 8a219a479a
3 changed files with 98 additions and 65 deletions

View file

@ -175,6 +175,17 @@ a broader stage gate.
- CultMesh native-slice metadata fixtures now name `CultMath.float2` and
`CultMath.float3` while retaining 8/12-byte element widths. Only those three
overlapping lines were staged from the already-dirty TypeScript test file.
- All four root geometry schemas now form a v2 cohort with their original slot
numbers. Semantic single vectors use `float3`/`quaternion`; mesh vertices,
normals, and UVs use `float3[]`/`float2[]`. The runtime contains no v1 DTO,
reader, writer, or conversion authority.
- Live v2 semantic SchemaIds are pinned as domain `sha256:e335...421d`, build
request `sha256:2de3...21d3`, selected cut `sha256:8644...9ff8`, and chunk
artifact `sha256:2d28...6156`. Full values live in the executable tests.
- Typed stable fingerprints flatten components into the prior ordered IEEE-bit
stream. Domain/chunk round trips preserve record identity, while the checked-
in flat-mesh v1 payload is retained as negative evidence and rejected by the
v2 decoder. The Geometry suite passes 19/19 after the cohort cut.
## Next Actions

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Caching;
using CultMath;
using MessagePack;
using static GameCult.Geometry.CultGeometryDocuments;
@ -14,10 +15,10 @@ namespace GameCult.Geometry
/// </summary>
public static class CultGeometrySchemaVersions
{
public const string Domain = "gamecult.geometry.domain.v1";
public const string BuildRequest = "gamecult.geometry.build_request.v1";
public const string SelectedCut = "gamecult.geometry.selected_cut.v1";
public const string ChunkArtifact = "gamecult.geometry.chunk_artifact.v1";
public const string Domain = "gamecult.geometry.domain.v2";
public const string BuildRequest = "gamecult.geometry.build_request.v2";
public const string SelectedCut = "gamecult.geometry.selected_cut.v2";
public const string ChunkArtifact = "gamecult.geometry.chunk_artifact.v2";
}
/// <summary>
@ -67,10 +68,10 @@ namespace GameCult.Geometry
public string Kind { get; set; } = string.Empty;
[Key(2)]
public float[] Translation { get; set; } = Array.Empty<float>();
public float3 Translation { get; set; }
[Key(3)]
public float[] RotationXyzw { get; set; } = Array.Empty<float>();
public quaternion Rotation { get; set; } = quaternion.identity;
[Key(4)]
public ulong Seed { get; set; }
@ -88,7 +89,7 @@ namespace GameCult.Geometry
Name,
Kind,
StableVector(Translation),
StableVector(RotationXyzw),
StableVector(Rotation),
Seed.ToString(CultureInfo.InvariantCulture)
}
.Concat(Claims.Select(claim => claim.StableFingerprint()))
@ -109,16 +110,16 @@ namespace GameCult.Geometry
public string Name { get; set; } = string.Empty;
[Key(1)]
public float[] Translation { get; set; } = Array.Empty<float>();
public float3 Translation { get; set; }
[Key(2)]
public float[] RotationXyzw { get; set; } = Array.Empty<float>();
public quaternion Rotation { get; set; } = quaternion.identity;
[Key(3)]
public float[] SupportCenter { get; set; } = Array.Empty<float>();
public float3 SupportCenter { get; set; }
[Key(4)]
public float[] SupportSize { get; set; } = Array.Empty<float>();
public float3 SupportSize { get; set; }
[Key(5)]
public string Kind { get; set; } = string.Empty;
@ -132,7 +133,7 @@ namespace GameCult.Geometry
public string StableFingerprint() => StableArray([
Name,
StableVector(Translation),
StableVector(RotationXyzw),
StableVector(Rotation),
StableVector(SupportCenter),
StableVector(SupportSize),
Kind,
@ -161,13 +162,13 @@ namespace GameCult.Geometry
public string WorkerGroup { get; set; } = string.Empty;
[Key(3)]
public float[] CameraPosition { get; set; } = Array.Empty<float>();
public float3 CameraPosition { get; set; }
[Key(4)]
public float[] FrustumMin { get; set; } = Array.Empty<float>();
public float3 FrustumMin { get; set; }
[Key(5)]
public float[] FrustumMax { get; set; } = Array.Empty<float>();
public float3 FrustumMax { get; set; }
[Key(6)]
public float ViewportHeightPixels { get; set; }
@ -321,10 +322,10 @@ namespace GameCult.Geometry
public string SelectedCutId { get; set; } = string.Empty;
[Key(3)]
public float[] BoundsMin { get; set; } = Array.Empty<float>();
public float3 BoundsMin { get; set; }
[Key(4)]
public float[] BoundsMax { get; set; } = Array.Empty<float>();
public float3 BoundsMax { get; set; }
[Key(5)]
public string[] SourceDomainKeys { get; set; } = Array.Empty<string>();
@ -374,13 +375,13 @@ namespace GameCult.Geometry
public sealed class CultGeometryTriangleMesh
{
[Key(0)]
public float[] Positions { get; set; } = Array.Empty<float>();
public float3[] Positions { get; set; } = Array.Empty<float3>();
[Key(1)]
public float[] Normals { get; set; } = Array.Empty<float>();
public float3[] Normals { get; set; } = Array.Empty<float3>();
[Key(2)]
public float[] Uvs { get; set; } = Array.Empty<float>();
public float2[] Uvs { get; set; } = Array.Empty<float2>();
[Key(3)]
public uint[] Indices { get; set; } = Array.Empty<uint>();
@ -409,10 +410,20 @@ namespace GameCult.Geometry
return string.Concat(bytes.Select(value => value.ToString("x2")));
}
public static string StableVector(float[] values)
{
return string.Join(",", values.Select(StableFloat));
}
public static string StableVector(float2 value) =>
string.Join(",", StableFloat(value.x), StableFloat(value.y));
public static string StableVector(float3 value) =>
string.Join(",", StableFloat(value.x), StableFloat(value.y), StableFloat(value.z));
public static string StableVector(quaternion value) =>
string.Join(",", StableFloat(value.x), StableFloat(value.y), StableFloat(value.z), StableFloat(value.w));
public static string StableVector(float2[] values) =>
string.Join(",", values.SelectMany(value => new[] { value.x, value.y }).Select(StableFloat));
public static string StableVector(float3[] values) =>
string.Join(",", values.SelectMany(value => new[] { value.x, value.y, value.z }).Select(StableFloat));
public static string StableFloat(float value)
{

View file

@ -87,6 +87,15 @@ namespace GameCult.Geometry.Tests
.Should().Be(CultGeometrySchemaVersions.SelectedCut);
cache.Registry.GetRequired<CultGeometryChunkArtifact>().SchemaVersion
.Should().Be(CultGeometrySchemaVersions.ChunkArtifact);
cache.Registry.GetRequired<CultGeometryDomainDocument>().SchemaId
.Should().Be("sha256:e3358436f8a07c84cc66b5196380cdfcd6920fa15f15d052d30b42b73762421d");
cache.Registry.GetRequired<CultGeometryBuildRequest>().SchemaId
.Should().Be("sha256:2de3162db128fc2c5cc2ea3dae46d0196110a9e5f969997002309e636a3721d3");
cache.Registry.GetRequired<CultGeometrySelectedCutManifest>().SchemaId
.Should().Be("sha256:864412d3cf0f6b079ceb0bbd3b36232f11380e63cdf0bfd64d505a710fb49ff8");
cache.Registry.GetRequired<CultGeometryChunkArtifact>().SchemaId
.Should().Be("sha256:2d28046b7c76244ced70ee6de09bcbb5c64d364aa578e5a4c903d397a7a56156");
}
[Test]
@ -98,9 +107,9 @@ namespace GameCult.Geometry.Tests
{
DomainKey = domainKey.Value,
WorkerGroup = "ragnarok-column-workers",
CameraPosition = [36f, -42f, 30f],
FrustumMin = [-75f, -75f, -30f],
FrustumMax = [75f, 75f, 120f],
CameraPosition = new float3(36f, -42f, 30f),
FrustumMin = new float3(-75f, -75f, -30f),
FrustumMax = new float3(75f, 75f, 120f),
ViewportHeightPixels = 1080f,
VerticalFovRadians = 1.0471976f,
TargetError = 0.01f,
@ -131,6 +140,11 @@ namespace GameCult.Geometry.Tests
decoded.ChunkId.Should().Be(chunk.ChunkId);
decoded.RenderMesh.TriangleCount.Should().Be(1);
decoded.BoundsMin.Should().Be(new float3(-1f, -1f, 0f));
decoded.RenderMesh.Positions.Should().Equal(chunk.RenderMesh.Positions);
decoded.RenderMesh.Normals.Should().HaveCount(decoded.RenderMesh.Positions.Length);
decoded.RenderMesh.Uvs.Should().HaveCount(decoded.RenderMesh.Positions.Length);
decoded.RenderMesh.Indices.Length.Should().Be(decoded.RenderMesh.TriangleMaterials.Length * 3);
CultGeometryChunkArtifact.CreateRecordKey(decoded)
.Should().Be(CultGeometryChunkArtifact.CreateRecordKey(chunk));
}
@ -152,29 +166,26 @@ namespace GameCult.Geometry.Tests
}
[Test]
public void RustExportedDomainDocument_DecodesWithStableCultCacheKey()
public void TypedV2Domain_RoundTripsAndPreservesTheV1RecordKey()
{
var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack"));
var domain = CultDocumentMessagePackSerialization.Deserialize<CultGeometryDomainDocument>(payload);
var domain = SampleDomain();
var payload = CultDocumentMessagePackSerialization.Serialize(domain);
var decoded = CultDocumentMessagePackSerialization.Deserialize<CultGeometryDomainDocument>(payload);
domain.DomainId.Should().Be("ragnarok-column");
domain.Root.Children.Should().NotBeEmpty();
domain.Root.Children[0].Name.Should().Be("stellarator-column-00");
CultGeometryDomainDocument.CreateRecordKey(domain).Value
.Should().Be("geometry:domain:02c9b5810977406b0c206f3a3494327a423abb9448192be1b9a1863cd0f2ed95");
decoded.Root.Rotation.Should().Be(quaternion.identity);
decoded.Root.Children[0].Claims[0].SupportCenter.Should().Be(new float3(0f, 0f, 45f));
CultGeometryDomainDocument.CreateRecordKey(decoded).Value.Should().Be(
"geometry:domain:175899ea97548da0599e12bcaccd07fa1d6009ed450fadb3de229d47bab04431");
}
[Test]
public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey()
public void V2Chunk_DoesNotAcceptTheLegacyFlatMeshPayload()
{
var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack"));
var chunk = CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload);
chunk.ChunkId.Should().StartWith("chunk/");
chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0);
chunk.SourceDomainKeys.Should().NotBeEmpty();
CultGeometryChunkArtifact.CreateRecordKey(chunk).Value
.Should().Be("geometry:chunk:b94dcd7d6776df15d0fa5fcf7f4b24f1384876b13b2d38cc57c4468136521af5");
FluentActions.Invoking(() =>
CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload))
.Should().Throw<MessagePackSerializationException>();
}
[Test]
@ -325,8 +336,8 @@ namespace GameCult.Geometry.Tests
{
Name = "ragnarok-column",
Kind = "Root",
Translation = [0f, 0f, 0f],
RotationXyzw = [0f, 0f, 0f, 1f],
Translation = float3.zero,
Rotation = quaternion.identity,
Seed = 0x5EED,
Claims = [],
Children =
@ -335,18 +346,18 @@ namespace GameCult.Geometry.Tests
{
Name = "stellarator-column-00",
Kind = "Column",
Translation = [0f, 0f, 0f],
RotationXyzw = [0f, 0f, 0f, 1f],
Translation = float3.zero,
Rotation = quaternion.identity,
Seed = 0xC0110000,
Claims =
[
new CultGeometryFeatureClaim
{
Name = "column-support-shell",
Translation = [0f, 0f, 0f],
RotationXyzw = [0f, 0f, 0f, 1f],
SupportCenter = [0f, 0f, 45f],
SupportSize = [18f, 18f, 96f],
Translation = float3.zero,
Rotation = quaternion.identity,
SupportCenter = new float3(0f, 0f, 45f),
SupportSize = new float3(18f, 18f, 96f),
Kind = "SupportShell",
Material = 10,
LoweringPolicy = "RenderAndCollider"
@ -359,15 +370,6 @@ namespace GameCult.Geometry.Tests
};
}
private static string FixturePath(string fileName)
{
return Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Fixtures",
"vg-csg-ragnarok",
fileName);
}
private static CultGeometryChunkArtifact SampleChunk()
{
return new CultGeometryChunkArtifact
@ -375,15 +377,15 @@ namespace GameCult.Geometry.Tests
ChunkId = "chunk/ragnarok-column/column-00",
CutKey = "geometry:cut:test",
SelectedCutId = "cut-test",
BoundsMin = [-1f, -1f, 0f],
BoundsMax = [1f, 1f, 1f],
BoundsMin = new float3(-1f, -1f, 0f),
BoundsMax = new float3(1f, 1f, 1f),
SourceDomainKeys = ["ragnarok-column/stellarator-column-00"],
SourceClaimKeys = ["ragnarok-column/stellarator-column-00/claim/support"],
RenderMesh = new CultGeometryTriangleMesh
{
Positions = [0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f, 0f],
Normals = [0f, 0f, 1f, 0f, 0f, 1f, 0f, 0f, 1f],
Uvs = [0f, 0f, 1f, 0f, 0f, 1f],
Positions = [new float3(0f, 0f, 0f), new float3(1f, 0f, 0f), new float3(0f, 1f, 0f)],
Normals = [new float3(0f, 0f, 1f), new float3(0f, 0f, 1f), new float3(0f, 0f, 1f)],
Uvs = [new float2(0f, 0f), new float2(1f, 0f), new float2(0f, 1f)],
Indices = [0u, 1u, 2u],
TriangleMaterials = [20u]
},
@ -393,5 +395,14 @@ namespace GameCult.Geometry.Tests
SupportsParentChildCoexistence = true
};
}
private static string FixturePath(string fileName)
{
return Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Fixtures",
"vg-csg-ragnarok",
fileName);
}
}
}