From 8a219a479a6ffc4df17f1900fb7f335672a18801 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:12:55 +0100 Subject: [PATCH] Publish typed geometry v2 contracts --- ...th-gamecult-geometry-migration-progress.md | 11 +++ .../CultGeometryDocuments.cs | 59 +++++++----- .../GeometryDocumentTests.cs | 91 +++++++++++-------- 3 files changed, 97 insertions(+), 64 deletions(-) diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 678d98c6..4f964208 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -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 diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 306b5849..68fc5514 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -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 /// 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"; } /// @@ -67,10 +68,10 @@ namespace GameCult.Geometry public string Kind { get; set; } = string.Empty; [Key(2)] - public float[] Translation { get; set; } = Array.Empty(); + public float3 Translation { get; set; } [Key(3)] - public float[] RotationXyzw { get; set; } = Array.Empty(); + 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(); + public float3 Translation { get; set; } [Key(2)] - public float[] RotationXyzw { get; set; } = Array.Empty(); + public quaternion Rotation { get; set; } = quaternion.identity; [Key(3)] - public float[] SupportCenter { get; set; } = Array.Empty(); + public float3 SupportCenter { get; set; } [Key(4)] - public float[] SupportSize { get; set; } = Array.Empty(); + 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(); + public float3 CameraPosition { get; set; } [Key(4)] - public float[] FrustumMin { get; set; } = Array.Empty(); + public float3 FrustumMin { get; set; } [Key(5)] - public float[] FrustumMax { get; set; } = Array.Empty(); + 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(); + public float3 BoundsMin { get; set; } [Key(4)] - public float[] BoundsMax { get; set; } = Array.Empty(); + public float3 BoundsMax { get; set; } [Key(5)] public string[] SourceDomainKeys { get; set; } = Array.Empty(); @@ -374,13 +375,13 @@ namespace GameCult.Geometry public sealed class CultGeometryTriangleMesh { [Key(0)] - public float[] Positions { get; set; } = Array.Empty(); + public float3[] Positions { get; set; } = Array.Empty(); [Key(1)] - public float[] Normals { get; set; } = Array.Empty(); + public float3[] Normals { get; set; } = Array.Empty(); [Key(2)] - public float[] Uvs { get; set; } = Array.Empty(); + public float2[] Uvs { get; set; } = Array.Empty(); [Key(3)] public uint[] Indices { get; set; } = Array.Empty(); @@ -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) { diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index 1c50ab80..345c270e 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -87,6 +87,15 @@ namespace GameCult.Geometry.Tests .Should().Be(CultGeometrySchemaVersions.SelectedCut); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.ChunkArtifact); + + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:e3358436f8a07c84cc66b5196380cdfcd6920fa15f15d052d30b42b73762421d"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:2de3162db128fc2c5cc2ea3dae46d0196110a9e5f969997002309e636a3721d3"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:864412d3cf0f6b079ceb0bbd3b36232f11380e63cdf0bfd64d505a710fb49ff8"); + cache.Registry.GetRequired().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(payload); + var domain = SampleDomain(); + var payload = CultDocumentMessagePackSerialization.Serialize(domain); + var decoded = CultDocumentMessagePackSerialization.Deserialize(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(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(payload)) + .Should().Throw(); } [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); + } } }