CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs
2026-06-28 07:54:00 +01:00

395 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
namespace GameCult.Mesh
{
/// <summary>
/// Schema versions for portable entity prefab packages.
/// </summary>
public static class CultMeshEntityPrefabSchemaVersions
{
/// <summary>Portable prefab package schema id.</summary>
public const string Package = "gamecult.mesh.entity_prefab_package.v1";
}
/// <summary>
/// Well-known portable prefab node kinds.
/// </summary>
public static class CultMeshEntityPrefabNodeKinds
{
/// <summary>Transform-only node.</summary>
public const string Empty = "empty";
/// <summary>Renderable mesh node.</summary>
public const string Mesh = "mesh";
/// <summary>Collider node.</summary>
public const string Collider = "collider";
/// <summary>Particle or projectile effect node.</summary>
public const string Effect = "effect";
/// <summary>Camera or view anchor node.</summary>
public const string Camera = "camera";
/// <summary>Runtime attachment point.</summary>
public const string Socket = "socket";
}
/// <summary>
/// Well-known prefab asset roles.
/// </summary>
public static class CultMeshEntityPrefabAssetRoles
{
/// <summary>Mesh geometry payload.</summary>
public const string Mesh = "mesh";
/// <summary>Texture image payload.</summary>
public const string Texture = "texture";
/// <summary>Material definition payload.</summary>
public const string Material = "material";
/// <summary>Animation clip payload.</summary>
public const string Animation = "animation";
/// <summary>Audio payload.</summary>
public const string Audio = "audio";
}
/// <summary>
/// Portable transform using Blender/CultMesh source coordinates before runtime lowering.
/// </summary>
[MessagePackObject]
public sealed class CultMeshEntityPrefabTransform
{
/// <summary>Local position vector.</summary>
[Key(0)]
public float[] Position { get; set; } = [0f, 0f, 0f];
/// <summary>Local rotation quaternion.</summary>
[Key(1)]
public float[] Rotation { get; set; } = [0f, 0f, 0f, 1f];
/// <summary>Local scale vector.</summary>
[Key(2)]
public float[] Scale { get; set; } = [1f, 1f, 1f];
}
/// <summary>
/// Runtime component intent attached to a portable prefab node.
/// </summary>
[MessagePackObject]
public sealed class CultMeshEntityPrefabComponent
{
/// <summary>Portable component kind or runtime adapter type.</summary>
[Key(0)]
public string Type { get; set; } = string.Empty;
/// <summary>String-valued component properties for runtime-specific lowering.</summary>
[Key(1)]
public Dictionary<string, string> Properties { get; set; } = new();
}
/// <summary>
/// Reference from a prefab package to a CDN artifact manifest.
/// </summary>
[MessagePackObject]
public sealed class CultMeshEntityPrefabAssetRef
{
/// <summary>Stable source asset id inside the prefab package.</summary>
[Key(0)]
public string AssetId { get; set; } = string.Empty;
/// <summary>Semantic role, such as mesh, texture, material, or animation.</summary>
[Key(1)]
public string Role { get; set; } = string.Empty;
/// <summary>CultCache record key for the referenced CDN artifact manifest.</summary>
[Key(2)]
public string CdnManifestRecordKey { get; set; } = string.Empty;
/// <summary>Full artifact content hash copied from the referenced CDN manifest.</summary>
[Key(3)]
public string ContentHash { get; set; } = string.Empty;
/// <summary>Artifact MIME type copied from the referenced CDN manifest.</summary>
[Key(4)]
public string MimeType { get; set; } = string.Empty;
/// <summary>Runtime and source metadata for the asset reference.</summary>
[Key(5)]
public Dictionary<string, string> Metadata { get; set; } = new();
}
/// <summary>
/// One node in a portable entity prefab graph.
/// </summary>
[MessagePackObject]
public sealed class CultMeshEntityPrefabNode
{
/// <summary>Stable node id inside the package.</summary>
[Key(0)]
public string NodeId { get; set; } = string.Empty;
/// <summary>Source object name.</summary>
[Key(1)]
public string Name { get; set; } = string.Empty;
/// <summary>Optional parent node id.</summary>
[Key(2)]
public string ParentNodeId { get; set; } = string.Empty;
/// <summary>Node kind.</summary>
[Key(3)]
public string Kind { get; set; } = CultMeshEntityPrefabNodeKinds.Empty;
/// <summary>Local transform.</summary>
[Key(4)]
public CultMeshEntityPrefabTransform Transform { get; set; } = new();
/// <summary>Optional mesh asset id used by this node.</summary>
[Key(5)]
public string MeshAssetId { get; set; } = string.Empty;
/// <summary>Material asset ids used by this node.</summary>
[Key(6)]
public string[] MaterialAssetIds { get; set; } = Array.Empty<string>();
/// <summary>Runtime component intents attached to this node.</summary>
[Key(7)]
public CultMeshEntityPrefabComponent[] Components { get; set; } = Array.Empty<CultMeshEntityPrefabComponent>();
/// <summary>Runtime tags copied from source metadata.</summary>
[Key(8)]
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>Additional source metadata.</summary>
[Key(9)]
public Dictionary<string, string> Metadata { get; set; } = new();
}
/// <summary>
/// Portable entity prefab package authored from a Blender collection and lowerable into multiple runtimes.
/// </summary>
[MessagePackObject]
[CultDocument("gamecult.mesh.entity_prefab_package", CultMeshEntityPrefabSchemaVersions.Package)]
public sealed class CultMeshEntityPrefabPackage
{
/// <summary>Stable logical prefab id.</summary>
[Key(0)]
[CultName]
public string PrefabId { get; set; } = string.Empty;
/// <summary>Caller-defined package version.</summary>
[Key(1)]
[CultIndex]
public string Version { get; set; } = string.Empty;
/// <summary>Tool that authored the package, such as Brokkr Blender.</summary>
[Key(2)]
[CultIndex]
public string SourceTool { get; set; } = string.Empty;
/// <summary>Source scene name or path.</summary>
[Key(3)]
public string SourceScene { get; set; } = string.Empty;
/// <summary>Source Blender collection name.</summary>
[Key(4)]
[CultIndex]
public string SourceCollection { get; set; } = string.Empty;
/// <summary>Stable hash of graph structure and referenced assets.</summary>
[Key(5)]
[CultIndex]
public string ContentHash { get; set; } = string.Empty;
/// <summary>Package creation timestamp in round-trip UTC format.</summary>
[Key(6)]
public string CreatedAtUtc { get; set; } = string.Empty;
/// <summary>Referenced CDN assets.</summary>
[Key(7)]
public CultMeshEntityPrefabAssetRef[] Assets { get; set; } = Array.Empty<CultMeshEntityPrefabAssetRef>();
/// <summary>Prefab hierarchy nodes.</summary>
[Key(8)]
public CultMeshEntityPrefabNode[] Nodes { get; set; } = Array.Empty<CultMeshEntityPrefabNode>();
/// <summary>Search and routing tags.</summary>
[Key(9)]
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>Package-level metadata.</summary>
[Key(10)]
public Dictionary<string, string> Metadata { get; set; } = new();
/// <summary>Builds the deterministic package record key.</summary>
public static CultRecordKey CreateRecordKey(CultMeshEntityPrefabPackage package)
{
if (package == null) throw new ArgumentNullException(nameof(package));
return CreateRecordKey(package.PrefabId, package.Version, package.ContentHash);
}
/// <summary>Builds the deterministic package record key.</summary>
public static CultRecordKey CreateRecordKey(string prefabId, string version, string contentHash)
{
var prefabToken = CultMeshCdn.StableToken(prefabId, nameof(prefabId));
var versionToken = string.IsNullOrWhiteSpace(version)
? "unversioned"
: CultMeshCdn.StableToken(version, nameof(version));
return new CultRecordKey(
"mesh:entity-prefab:" + prefabToken + ":" + versionToken + ":" + CultMeshCdn.NormalizeHash(contentHash, nameof(contentHash)));
}
}
/// <summary>
/// Helpers for publishing and validating portable entity prefab packages.
/// </summary>
public static class CultMeshEntityPrefabs
{
/// <summary>
/// Computes a stable package content hash from graph structure and referenced asset hashes.
/// </summary>
public static string ComputeContentHash(CultMeshEntityPrefabPackage package)
{
if (package == null) throw new ArgumentNullException(nameof(package));
var builder = new StringBuilder();
builder.Append(package.PrefabId).Append('\u001F')
.Append(package.Version).Append('\u001F')
.Append(package.SourceTool).Append('\u001F')
.Append(package.SourceScene).Append('\u001F')
.Append(package.SourceCollection).Append('\u001F');
foreach (var asset in package.Assets.OrderBy(asset => asset.AssetId, StringComparer.Ordinal))
{
builder.Append("asset").Append('\u001E')
.Append(asset.AssetId).Append('\u001E')
.Append(asset.Role).Append('\u001E')
.Append(asset.CdnManifestRecordKey).Append('\u001E')
.Append(CultMeshCdn.NormalizeHash(asset.ContentHash, nameof(asset.ContentHash))).Append('\u001E')
.Append(asset.MimeType).Append('\u001E')
.Append(StableMap(asset.Metadata)).Append('\u001F');
}
foreach (var node in package.Nodes.OrderBy(node => node.NodeId, StringComparer.Ordinal))
{
builder.Append("node").Append('\u001E')
.Append(node.NodeId).Append('\u001E')
.Append(node.Name).Append('\u001E')
.Append(node.ParentNodeId).Append('\u001E')
.Append(node.Kind).Append('\u001E')
.Append(StableFloats(node.Transform.Position)).Append('\u001E')
.Append(StableFloats(node.Transform.Rotation)).Append('\u001E')
.Append(StableFloats(node.Transform.Scale)).Append('\u001E')
.Append(node.MeshAssetId).Append('\u001E')
.Append(string.Join(",", node.MaterialAssetIds.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001E')
.Append(StableComponents(node.Components)).Append('\u001E')
.Append(string.Join(",", node.Tags.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001E')
.Append(StableMap(node.Metadata)).Append('\u001F');
}
builder.Append(string.Join(",", package.Tags.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001F')
.Append(StableMap(package.Metadata));
return CultMeshCdn.HashBytes(Encoding.UTF8.GetBytes(builder.ToString()));
}
/// <summary>
/// Stamps the current content hash and creation timestamp on a package.
/// </summary>
public static CultMeshEntityPrefabPackage FinalizePackage(CultMeshEntityPrefabPackage package)
{
if (package == null) throw new ArgumentNullException(nameof(package));
package.CreatedAtUtc = string.IsNullOrWhiteSpace(package.CreatedAtUtc)
? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
: package.CreatedAtUtc;
package.ContentHash = ComputeContentHash(package);
return package;
}
/// <summary>
/// Publishes a finalized portable entity prefab package into a local cache.
/// </summary>
public static async Task<CultMeshEntityPrefabPackage> PublishAsync(
CultCache cache,
CultMeshEntityPrefabPackage package)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
if (package == null) throw new ArgumentNullException(nameof(package));
ValidatePackage(package);
await cache.UpsertAsync(
package,
new CultRecordHandle<CultMeshEntityPrefabPackage>(
CultMeshEntityPrefabPackage.CreateRecordKey(package))).ConfigureAwait(false);
return package;
}
/// <summary>
/// Publishes a finalized portable entity prefab package into a distributed CultNet database.
/// </summary>
public static async Task<CultMeshEntityPrefabPackage> PublishAsync(
CultNetDatabase database,
CultMeshEntityPrefabPackage package)
{
if (database == null) throw new ArgumentNullException(nameof(database));
if (package == null) throw new ArgumentNullException(nameof(package));
ValidatePackage(package);
await database.PutAsync(CultMeshEntityPrefabPackage.CreateRecordKey(package), package)
.ConfigureAwait(false);
return package;
}
/// <summary>
/// Creates a raw CultNet document registry for entity prefab packages.
/// </summary>
public static CultNetDocumentRegistry CreateDocumentRegistry(CultDocumentRegistry? documents = null)
{
documents ??= CultDocumentRegistry.Shared;
return new CultNetDocumentRegistry(documents)
.Register(CultNetDocumentBinding.ForDocument<CultMeshEntityPrefabPackage>(documents));
}
/// <summary>
/// Creates a raw CultNet document registry for CDN assets and portable entity prefab packages.
/// </summary>
public static CultNetDocumentRegistry CreateAssetPipelineDocumentRegistry(CultDocumentRegistry? documents = null)
{
documents ??= CultDocumentRegistry.Shared;
return CultMeshCdn.CreateDocumentRegistry(documents)
.Register(CultNetDocumentBinding.ForDocument<CultMeshEntityPrefabPackage>(documents));
}
private static void ValidatePackage(CultMeshEntityPrefabPackage package)
{
if (string.IsNullOrWhiteSpace(package.PrefabId))
{
throw new ArgumentException("Prefab id must be non-empty.", nameof(package));
}
var expectedHash = ComputeContentHash(package);
if (!string.Equals(CultMeshCdn.NormalizeHash(package.ContentHash, nameof(package.ContentHash)), expectedHash, StringComparison.Ordinal))
{
throw new InvalidOperationException("Entity prefab package content hash does not match its graph.");
}
}
private static string StableComponents(IEnumerable<CultMeshEntityPrefabComponent> components)
{
return string.Join("|", components
.OrderBy(component => component.Type, StringComparer.Ordinal)
.Select(component => component.Type + "=" + StableMap(component.Properties)));
}
private static string StableFloats(IEnumerable<float> values)
{
return string.Join(",", values.Select(value => value.ToString("R", CultureInfo.InvariantCulture)));
}
private static string StableMap(Dictionary<string, string> values)
{
return string.Join("|", values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => pair.Key + "=" + pair.Value));
}
}
}