CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs
Meta Vens 9ea134aca9 Geometry: name the cache formatter on each CultRecordRef member
The MessagePack source generator cannot resolve CultRecordRef<T> on its own and
emitted a broken formatter reference; MessagePackKnownFormatter on the open
generic did not change that. An explicit MessagePackFormatter per member is the
same formatter the cache resolver uses, so the wire stays a key string.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 02:25:27 +02:00

428 lines
14 KiB
C#

using System;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Caching;
using MessagePack;
using static GameCult.Geometry.CultGeometryDocuments;
namespace GameCult.Geometry
{
/// <summary>
/// Schema versions for CultCache-native geometry pipeline documents.
/// </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";
}
/// <summary>
/// CultCache document containing a hierarchical geometry domain tree.
/// </summary>
[MessagePackObject]
[CultDocument("gamecult.geometry.domain", CultGeometrySchemaVersions.Domain)]
public sealed class CultGeometryDomainDocument
{
[Key(0)]
[CultName]
public string DomainId { get; set; } = string.Empty;
[Key(1)]
[CultIndex]
public string RootKey { get; set; } = string.Empty;
[Key(2)]
public string SourceRuntime { get; set; } = string.Empty;
[Key(3)]
public CultGeometryDomainNode Root { get; set; } = new CultGeometryDomainNode();
[Key(4)]
public string CreatedAt { get; set; } = string.Empty;
public static CultRecordKey CreateRecordKey(CultGeometryDomainDocument domain)
{
if (domain == null) throw new ArgumentNullException(nameof(domain));
return new CultRecordKey("geometry:domain:" + StableHash(
domain.RootKey,
domain.SourceRuntime,
domain.Root.StableFingerprint()));
}
}
/// <summary>
/// One node in a geometry domain tree.
/// </summary>
[MessagePackObject]
public sealed class CultGeometryDomainNode
{
[Key(0)]
public string Name { get; set; } = string.Empty;
[Key(1)]
public string Kind { get; set; } = string.Empty;
[Key(2)]
public float[] Translation { get; set; } = Array.Empty<float>();
[Key(3)]
public float[] RotationXyzw { get; set; } = Array.Empty<float>();
[Key(4)]
public ulong Seed { get; set; }
[Key(5)]
public CultGeometryFeatureClaim[] Claims { get; set; } = Array.Empty<CultGeometryFeatureClaim>();
[Key(6)]
public CultGeometryDomainNode[] Children { get; set; } = Array.Empty<CultGeometryDomainNode>();
public string StableFingerprint()
{
var parts = new[]
{
Name,
Kind,
StableVector(Translation),
StableVector(RotationXyzw),
Seed.ToString(CultureInfo.InvariantCulture)
}
.Concat(Claims.Select(claim => claim.StableFingerprint()))
.Concat(Children.Select(child => child.StableFingerprint()))
.ToArray();
return StableArray(parts);
}
}
/// <summary>
/// A domain feature claim before CSG lowering.
/// </summary>
[MessagePackObject]
public sealed class CultGeometryFeatureClaim
{
[Key(0)]
public string Name { get; set; } = string.Empty;
[Key(1)]
public float[] Translation { get; set; } = Array.Empty<float>();
[Key(2)]
public float[] RotationXyzw { get; set; } = Array.Empty<float>();
[Key(3)]
public float[] SupportCenter { get; set; } = Array.Empty<float>();
[Key(4)]
public float[] SupportSize { get; set; } = Array.Empty<float>();
[Key(5)]
public string Kind { get; set; } = string.Empty;
[Key(6)]
public uint Material { get; set; }
[Key(7)]
public string LoweringPolicy { get; set; } = string.Empty;
public string StableFingerprint() => StableArray([
Name,
StableVector(Translation),
StableVector(RotationXyzw),
StableVector(SupportCenter),
StableVector(SupportSize),
Kind,
Material.ToString(CultureInfo.InvariantCulture),
LoweringPolicy
]);
}
/// <summary>
/// CultCache document requesting one geometry LOD build.
/// </summary>
[MessagePackObject]
[CultDocument("gamecult.geometry.build_request", CultGeometrySchemaVersions.BuildRequest)]
public sealed class CultGeometryBuildRequest
{
[Key(0)]
[CultName]
public string RequestId { get; set; } = string.Empty;
[Key(1)]
[MessagePackFormatter(typeof(CultRecordRefFormatter<CultGeometryDomainDocument>))]
public CultRecordRef<CultGeometryDomainDocument> DomainKey { get; set; }
[Key(2)]
[CultIndex]
public string WorkerGroup { get; set; } = string.Empty;
[Key(3)]
public float[] CameraPosition { get; set; } = Array.Empty<float>();
[Key(4)]
public float[] FrustumMin { get; set; } = Array.Empty<float>();
[Key(5)]
public float[] FrustumMax { get; set; } = Array.Empty<float>();
[Key(6)]
public float ViewportHeightPixels { get; set; }
[Key(7)]
public float VerticalFovRadians { get; set; }
[Key(8)]
public float TargetError { get; set; }
[Key(9)]
public int TriangleBudget { get; set; }
[Key(10)]
public int ColliderBudget { get; set; }
[Key(11)]
public string[] SemanticFilter { get; set; } = Array.Empty<string>();
[Key(12)]
public string[] RequestedChunkKeys { get; set; } = Array.Empty<string>();
[Key(13)]
public string[] DirtyDomainKeys { get; set; } = Array.Empty<string>();
[Key(14)]
public string CreatedAt { get; set; } = string.Empty;
public static CultRecordKey CreateRecordKey(CultGeometryBuildRequest request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
return new CultRecordKey("geometry:request:" + StableHash(
request.DomainKey.Key.Value,
request.WorkerGroup,
StableVector(request.CameraPosition),
StableVector(request.FrustumMin),
StableVector(request.FrustumMax),
request.ViewportHeightPixels.ToString("R", CultureInfo.InvariantCulture),
request.VerticalFovRadians.ToString("R", CultureInfo.InvariantCulture),
request.TargetError.ToString("R", CultureInfo.InvariantCulture),
request.TriangleBudget.ToString(CultureInfo.InvariantCulture),
request.ColliderBudget.ToString(CultureInfo.InvariantCulture),
StableArray(request.SemanticFilter),
StableArray(request.RequestedChunkKeys),
StableArray(request.DirtyDomainKeys)));
}
}
/// <summary>
/// CultCache document describing the selected domain cut produced for one build request.
/// </summary>
[MessagePackObject]
[CultDocument("gamecult.geometry.selected_cut", CultGeometrySchemaVersions.SelectedCut)]
public sealed class CultGeometrySelectedCutManifest
{
[Key(0)]
[CultName]
public string CutId { get; set; } = string.Empty;
[Key(1)]
[MessagePackFormatter(typeof(CultRecordRefFormatter<CultGeometryBuildRequest>))]
public CultRecordRef<CultGeometryBuildRequest> RequestKey { get; set; }
[Key(2)]
public string[] SelectedNodes { get; set; } = Array.Empty<string>();
[Key(3)]
public string[] DeferredChildRequests { get; set; } = Array.Empty<string>();
[Key(4)]
public string[] ParentFallbackNodes { get; set; } = Array.Empty<string>();
[Key(5)]
public CultGeometryContributionRow[] Diagnostics { get; set; } = Array.Empty<CultGeometryContributionRow>();
public static CultRecordKey CreateRecordKey(CultGeometrySelectedCutManifest cut)
{
if (cut == null) throw new ArgumentNullException(nameof(cut));
return new CultRecordKey("geometry:cut:" + StableHash(
cut.RequestKey.Key.Value,
cut.CutId,
StableArray(cut.SelectedNodes),
StableArray(cut.DeferredChildRequests),
StableArray(cut.ParentFallbackNodes)));
}
}
/// <summary>
/// One diagnostic row explaining selected-cut pressure.
/// </summary>
[MessagePackObject]
public sealed class CultGeometryContributionRow
{
[Key(0)]
public string DomainKey { get; set; } = string.Empty;
[Key(1)]
public string Kind { get; set; } = string.Empty;
[Key(2)]
public float Contribution { get; set; }
[Key(3)]
public float ProjectedScreenError { get; set; }
[Key(4)]
public float SemanticPriority { get; set; }
[Key(5)]
public int EstimatedTriangleCost { get; set; }
[Key(6)]
public int ChildCost { get; set; }
[Key(7)]
public int RemainingTriangleBudget { get; set; }
[Key(8)]
public bool Requested { get; set; }
[Key(9)]
public bool Dirty { get; set; }
[Key(10)]
public bool Selected { get; set; }
[Key(11)]
public bool UsedFallback { get; set; }
[Key(12)]
public bool DeferredByBudget { get; set; }
}
/// <summary>
/// CultCache document carrying one emitted geometry chunk and its collider payload.
/// </summary>
[MessagePackObject]
[CultDocument("gamecult.geometry.chunk_artifact", CultGeometrySchemaVersions.ChunkArtifact)]
public sealed class CultGeometryChunkArtifact
{
[Key(0)]
[CultName]
public string ChunkId { get; set; } = string.Empty;
[Key(1)]
[MessagePackFormatter(typeof(CultRecordRefFormatter<CultGeometrySelectedCutManifest>))]
public CultRecordRef<CultGeometrySelectedCutManifest> CutKey { get; set; }
[Key(2)]
[CultIndex]
public string SelectedCutId { get; set; } = string.Empty;
[Key(3)]
public float[] BoundsMin { get; set; } = Array.Empty<float>();
[Key(4)]
public float[] BoundsMax { get; set; } = Array.Empty<float>();
[Key(5)]
public string[] SourceDomainKeys { get; set; } = Array.Empty<string>();
[Key(6)]
public string[] SourceClaimKeys { get; set; } = Array.Empty<string>();
[Key(7)]
public CultGeometryTriangleMesh RenderMesh { get; set; } = new CultGeometryTriangleMesh();
[Key(8)]
public CultGeometryTriangleMesh? ColliderMesh { get; set; }
[Key(9)]
public int InputBrushes { get; set; }
[Key(10)]
public int CandidatePairs { get; set; }
[Key(11)]
public int RejectedPairs { get; set; }
[Key(12)]
public ulong StableClipSeed { get; set; }
[Key(13)]
public bool SupportsParentChildCoexistence { get; set; }
public static CultRecordKey CreateRecordKey(CultGeometryChunkArtifact chunk)
{
if (chunk == null) throw new ArgumentNullException(nameof(chunk));
return new CultRecordKey("geometry:chunk:" + StableHash(
chunk.CutKey.Key.Value,
chunk.ChunkId,
chunk.SelectedCutId,
StableArray(chunk.SourceDomainKeys),
StableArray(chunk.SourceClaimKeys),
chunk.RenderMesh.StableFingerprint(),
chunk.ColliderMesh?.StableFingerprint() ?? string.Empty));
}
}
/// <summary>
/// Packed triangle mesh payload for CultCache persistence and CultNet replication.
/// </summary>
[MessagePackObject]
public sealed class CultGeometryTriangleMesh
{
[Key(0)]
public float[] Positions { get; set; } = Array.Empty<float>();
[Key(1)]
public float[] Normals { get; set; } = Array.Empty<float>();
[Key(2)]
public float[] Uvs { get; set; } = Array.Empty<float>();
[Key(3)]
public uint[] Indices { get; set; } = Array.Empty<uint>();
[Key(4)]
public uint[] TriangleMaterials { get; set; } = Array.Empty<uint>();
[IgnoreMember]
public int TriangleCount => Indices.Length / 3;
public string StableFingerprint() => StableHash(
StableVector(Positions),
StableVector(Normals),
StableVector(Uvs),
string.Join(",", Indices.Select(value => value.ToString(CultureInfo.InvariantCulture))),
string.Join(",", TriangleMaterials.Select(value => value.ToString(CultureInfo.InvariantCulture))));
}
internal static class CultGeometryDocuments
{
public static string StableHash(params string[] parts)
{
using var sha = SHA256.Create();
var canonical = string.Join("\u001f", parts);
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical));
return string.Concat(bytes.Select(value => value.ToString("x2")));
}
public static string StableVector(float[] values)
{
return string.Join(",", values.Select(StableFloat));
}
public static string StableFloat(float value)
{
return BitConverter.ToUInt32(BitConverter.GetBytes(value), 0)
.ToString("x8", CultureInfo.InvariantCulture);
}
public static string StableArray(string[] values)
{
return string.Join("\u001e", values);
}
}
}