diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index bdd9fff6..9de7c718 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -53,6 +53,40 @@ if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)." } + +# Release gate: cultlib's Unity package resolves CultMath through the org.gamecult.cultmath +# dependency, so a cultlib release must not run ahead of the CultMath package it names. +# (1) The declared dependency version must not exceed the CultMath package's own version. +# (2) The CultMath.dll that package tracks must define every type CultMathResolver formats. +# The names come from the resolver source (its Shape entries); scripts\check-cultmath-types.cs +# looks them up in the assembly's type-definition table, which Windows PowerShell cannot load +# the assembly to do. It checks type names, not signatures. +$cultMathPackageRoot = Join-Path $repoRoot "packages\cultmath\unity\org.gamecult.cultmath" +$cultMathDeclared = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).dependencies.'org.gamecult.cultmath' +$cultMathAvailable = (Get-Content -LiteralPath (Join-Path $cultMathPackageRoot "package.json") -Raw | ConvertFrom-Json).version +if ([string]::IsNullOrWhiteSpace($cultMathDeclared)) { + throw "package.json must declare org.gamecult.cultmath, which supplies CultMath.dll." +} +foreach ($declared in @(@("org.gamecult.cultlib's org.gamecult.cultmath dependency", $cultMathDeclared), @("the CultMath package version", $cultMathAvailable))) { + if ($declared[1] -notmatch '^\d+\.\d+\.\d+$') { + throw "$($declared[0]) is '$($declared[1])'. The release gate compares exact release versions (major.minor.patch); prerelease and range versions are not supported." + } +} +if ([version]$cultMathDeclared -gt [version]$cultMathAvailable) { + throw "Release order: org.gamecult.cultlib depends on org.gamecult.cultmath $cultMathDeclared, but the CultMath package is at $cultMathAvailable. Release CultMath first." +} +$resolverSource = Get-Content -LiteralPath (Join-Path $repoRoot "src\GameCult.Caching.MessagePack\CultMathResolver.cs") -Raw +$resolverTypes = @([regex]::Matches($resolverSource, '\bShape<(?!T>)(\w+)>\(') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) +if ($resolverTypes.Count -eq 0) { + throw "Found no CultMath types in CultMathResolver.cs; the release gate cannot check the CultMath package." +} +$missingTypes = & dotnet run (Join-Path $PSScriptRoot "check-cultmath-types.cs") -- (Join-Path $cultMathPackageRoot "Runtime\Plugins\CultMath.dll") @resolverTypes +if ($LASTEXITCODE -eq 1) { + throw "The CultMath.dll in org.gamecult.cultmath $cultMathAvailable lacks types CultMathResolver formats: $missingTypes. Release CultMath first." +} +if ($LASTEXITCODE -ne 0) { + throw "scripts\check-cultmath-types.cs failed with exit code $LASTEXITCODE." +} # The tracked DLLs and pdbs are committed beside their source, so none may name a commit or a worktree: # Source Link writes the commit SHA into the pdb, the informational version carries it too, and each DLL # carries its pdb's content id. ContinuousIntegrationBuild maps the local source path to /_/. @@ -128,6 +162,15 @@ $expectedAssemblies = @( "System.Text.Json.dll", "System.Threading.Channels.dll" ) +# Assemblies the package references but never ships: org.gamecult.cultmath owns CultMath.dll, and the +# package.json dependency delivers it. A second copy in Runtime\Plugins would be a duplicate assembly. +$externalAssemblies = @("CultMath.dll") +$asmdef = Get-Content -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") -Raw | ConvertFrom-Json +$referencedAssemblies = @($asmdef.precompiledReferences | Sort-Object) +$declaredAssemblies = @($expectedAssemblies + $externalAssemblies | Sort-Object) +if (($referencedAssemblies -join "|") -ne ($declaredAssemblies -join "|")) { + throw "GameCult.CultLib.asmdef precompiledReferences differ from the shipped plus external assembly lists." +} $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly diff --git a/scripts/check-cultmath-types.cs b/scripts/check-cultmath-types.cs new file mode 100644 index 00000000..dab33397 --- /dev/null +++ b/scripts/check-cultmath-types.cs @@ -0,0 +1,30 @@ +// Release gate helper for build-unity-package.ps1: reads an assembly's type-definition table and +// reports which of the named CultMath types it does not define as a public top-level type. +// Usage: dotnet run scripts/check-cultmath-types.cs -- ... +// Exit 0 when every type is defined, 1 when any is missing, 2 on bad usage. +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +if (args.Length < 2) +{ + Console.Error.WriteLine("usage: check-cultmath-types ..."); + return 2; +} + +using var stream = File.OpenRead(args[0]); +using var pe = new PEReader(stream); +var metadata = pe.GetMetadataReader(); +var defined = new HashSet(); +foreach (var handle in metadata.TypeDefinitions) +{ + var type = metadata.GetTypeDefinition(handle); + if (!type.GetDeclaringType().IsNil) continue; + if ((type.Attributes & TypeAttributes.VisibilityMask) != TypeAttributes.Public) continue; + if (metadata.GetString(type.Namespace) == "CultMath") defined.Add(metadata.GetString(type.Name)); +} + +var missing = args.Skip(1).Where(name => !defined.Contains(name)).ToArray(); +if (missing.Length == 0) return 0; +Console.WriteLine(string.Join(", ", missing)); +return 1; diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index ef1b4092..f591d764 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -57,7 +57,7 @@ public static class CultDocumentMessagePackSerialization { return MessagePackSerializerOptions.Standard .WithResolver(CompositeResolver.Create( - consumerResolvers.Append(CultDocumentResolver.Instance).Append(StandardResolver.Instance).ToArray())) + consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray())) .WithSecurity(CultMessagePackSecurity.Instance); } diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs new file mode 100644 index 00000000..438c6150 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CultMath; +using Random = CultMath.Random; + +namespace GameCult.Caching.MessagePack; + +/// +/// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of +/// . Vectors, quaternions and colors are objects keyed by their +/// field names ({"x":1,"y":2}), matrices are arrays of row objects, rect is +/// {"min":..,"max":..}, and CultCellular is {"nearest":..,"edge":..,"id":..}. +/// Property names are fixed: PropertyNamingPolicy does not rename them, and +/// PropertyNameCaseInsensitive makes reading case-insensitive. +/// +/// Reading skips unknown properties (UnmappedMemberHandling.Disallow refuses them) and +/// leaves missing ones at zero (a missing Color32.a is 255). A repeated property replaces +/// the whole earlier value, never merging component-wise, and is refused when +/// AllowDuplicateProperties is false. A wrong token, a number outside its component type, +/// or a matrix that is not an array of exactly its row count throws +/// . +/// +/// +/// Numbers follow as System.Text.Json's own +/// float does. NaN and the infinities are the strings "NaN", "Infinity", +/// "-Infinity": written when AllowNamedFloatingPointLiterals or +/// WriteAsString is set (otherwise ), read when +/// AllowNamedFloatingPointLiterals or AllowReadingFromString is set. +/// AllowReadingFromString reads quoted numbers without surrounding whitespace, and +/// WriteAsString writes quoted numbers. Two intended divergences: a bare number beyond +/// the component's range (1e40 as a float) is refused where System.Text.Json reads +/// infinity, and refusals on write are where it throws +/// . +/// +/// +/// Every type is also a dictionary key: the flattened components joined by commas in +/// declaration order, invariant culture, shortest round-trip floats ("1,2", +/// "true,false", matrices row by row, rect as "minX,minY,maxX,maxY"). +/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key. +/// +/// Register with ; JSON options are the consumer's own. +/// +public static class CultMathJson +{ + /// The CultMath types that have a converter, for coverage checks. + public static IReadOnlyCollection ConvertedTypes => Converters.Keys; + + /// Adds the CultMath converters to and returns it. + public static JsonSerializerOptions AddCultMathConverters(this JsonSerializerOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + foreach (var converter in Converters.Values) options.Converters.Add(converter); + return options; + } + + private static bool LiteralsWrite(JsonSerializerOptions o) => + (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.WriteAsString)) != 0; + + private static bool LiteralsRead(JsonSerializerOptions o) => + (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString)) != 0; + + private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0; + private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0; + + // AllowDuplicateProperties arrived after the System.Text.Json this library compiles against. + private static readonly PropertyInfo? AllowDuplicateProperties = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + + private static bool DuplicatesAllowed(JsonSerializerOptions o) => AllowDuplicateProperties == null || (bool)AllowDuplicateProperties.GetValue(o)!; + + private const NumberStyles FloatStyle = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent; + + // One component kind. Every CultMath type is a flat list of one kind, so a Scalar plus a Node tree describes it. + private abstract class Scalar where S : unmanaged + { + public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o); + public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o); + + /// The canonical key spelling, whatever the options allow. + public abstract string Spelling(S value); + + /// The key spelling, refusing what the options would refuse to write. + public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value); + + public abstract S Parse(string text, JsonSerializerOptions o); + + /// Reads a key component; keys are always text, so AllowReadingFromString does not apply. + public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o); + } + + private abstract class FloatingScalar : Scalar where S : unmanaged + { + protected static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; + protected abstract bool IsFinite(S value); + protected abstract string Literal(S value); + protected abstract S FromLiteral(string literal); + protected abstract bool TryNumber(string text, out S value); + protected abstract bool TryToken(ref Utf8JsonReader r, out S value); + protected abstract void WriteNumber(Utf8JsonWriter w, S value); + protected abstract string Number(S value); + + public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o) + { + if (!IsFinite(v)) + { + if (!LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString."); + w.WriteStringValue(Literal(v)); + } + else if (AsString(o)) w.WriteStringValue(Number(v)); + else WriteNumber(w, v); + } + + public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!TryToken(ref r, out var v) || !IsFinite(v)) throw new JsonException($"The number is outside the {typeof(S).Name} range."); + return v; + } + + if (r.TokenType == JsonTokenType.String) return Parse(r.GetString()!, o); + throw new JsonException($"Expected a number, found {r.TokenType}."); + } + + public override string Spelling(S v) => Number(v); + + public override string Text(S v, JsonSerializerOptions o) + { + if (!IsFinite(v) && !LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString."); + return Spelling(v); + } + + public override S Parse(string text, JsonSerializerOptions o) + { + if (!IsLiteral(text) && !FromString(o)) throw new JsonException($"Expected a number, found the string '{text}'."); + return ParseKey(text, o); + } + + public override S ParseKey(string text, JsonSerializerOptions o) + { + if (IsLiteral(text)) + { + if (!LiteralsRead(o)) throw new JsonException($"'{text}' needs JsonNumberHandling.AllowNamedFloatingPointLiterals or AllowReadingFromString."); + return FromLiteral(text); + } + + if (TryNumber(text, out var v) && IsFinite(v)) return v; + throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'."); + } + } + + private sealed class FloatScalar : FloatingScalar + { + public static readonly FloatScalar Instance = new(); + protected override bool IsFinite(float v) => float.IsFinite(v); + protected override string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity"; + protected override float FromLiteral(string s) => s == "NaN" ? float.NaN : s == "Infinity" ? float.PositiveInfinity : float.NegativeInfinity; + protected override bool TryNumber(string s, out float v) => float.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v); + protected override bool TryToken(ref Utf8JsonReader r, out float v) => r.TryGetSingle(out v); + protected override void WriteNumber(Utf8JsonWriter w, float v) => w.WriteNumberValue(v); + protected override string Number(float v) => v.ToString("R", CultureInfo.InvariantCulture); + } + + private sealed class DoubleScalar : FloatingScalar + { + public static readonly DoubleScalar Instance = new(); + protected override bool IsFinite(double v) => double.IsFinite(v); + protected override string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity"; + protected override double FromLiteral(string s) => s == "NaN" ? double.NaN : s == "Infinity" ? double.PositiveInfinity : double.NegativeInfinity; + protected override bool TryNumber(string s, out double v) => double.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v); + protected override bool TryToken(ref Utf8JsonReader r, out double v) => r.TryGetDouble(out v); + protected override void WriteNumber(Utf8JsonWriter w, double v) => w.WriteNumberValue(v); + protected override string Number(double v) => v.ToString("R", CultureInfo.InvariantCulture); + } + + private abstract class IntegerScalar : Scalar where S : unmanaged + { + protected abstract bool TryToken(ref Utf8JsonReader r, out S value); + protected abstract bool TryText(string text, out S value); + protected abstract void WriteNumber(Utf8JsonWriter w, S value); + + public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o) + { + if (AsString(o)) w.WriteStringValue(Spelling(v)); else WriteNumber(w, v); + } + + public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!TryToken(ref r, out var v)) throw new JsonException($"The number is not a {typeof(S).Name}."); + return v; + } + + if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); + throw new JsonException($"Expected an integer, found {r.TokenType}."); + } + + public override S Parse(string text, JsonSerializerOptions o) => + TryText(text, out var v) ? v : throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'."); + } + + private sealed class IntScalar : IntegerScalar + { + public static readonly IntScalar Instance = new(); + protected override bool TryToken(ref Utf8JsonReader r, out int v) => r.TryGetInt32(out v); + protected override bool TryText(string s, out int v) => int.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, int v) => w.WriteNumberValue(v); + public override string Spelling(int v) => v.ToString(CultureInfo.InvariantCulture); + } + + private sealed class UIntScalar : IntegerScalar + { + public static readonly UIntScalar Instance = new(); + protected override bool TryToken(ref Utf8JsonReader r, out uint v) => r.TryGetUInt32(out v); + protected override bool TryText(string s, out uint v) => uint.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, uint v) => w.WriteNumberValue(v); + public override string Spelling(uint v) => v.ToString(CultureInfo.InvariantCulture); + } + + private sealed class ByteScalar : IntegerScalar + { + public static readonly ByteScalar Instance = new(); + protected override bool TryToken(ref Utf8JsonReader r, out byte v) => r.TryGetByte(out v); + protected override bool TryText(string s, out byte v) => byte.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, byte v) => w.WriteNumberValue(v); + public override string Spelling(byte v) => v.ToString(CultureInfo.InvariantCulture); + } + + private sealed class BoolScalar : Scalar + { + public static readonly BoolScalar Instance = new(); + + public override void Write(Utf8JsonWriter w, bool v, JsonSerializerOptions o) => w.WriteBooleanValue(v); + + public override bool Read(ref Utf8JsonReader r, JsonSerializerOptions o) => + r.TokenType == JsonTokenType.True ? true + : r.TokenType == JsonTokenType.False ? false + : throw new JsonException($"Expected true or false, found {r.TokenType}."); + + public override string Spelling(bool v) => v ? "true" : "false"; + + public override bool Parse(string text, JsonSerializerOptions o) => + text == "true" ? true : text == "false" ? false : throw new JsonException($"A bool cannot be read from '{text}'."); + } + + // The JSON structure over a flat component list: leaves consume one component each, in order. + // Read fills `flat` (pre-loaded with `defaults`), so a missing property keeps its default. + private abstract class Node where S : unmanaged + { + public int Count { get; protected set; } + public abstract void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o); + public abstract void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o); + } + + private sealed class Leaf : Node where S : unmanaged + { + public Leaf() { Count = 1; } + + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o); + + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o); + } + + private sealed class Fields : Node where S : unmanaged + { + private readonly string[] names; + private readonly byte[][] utf8Names; + private readonly JsonEncodedText[] encoded; + private readonly Node[] children; + private readonly int[] offsets; + + public Fields(params (string Name, Node Child)[] fields) + { + names = new string[fields.Length]; + utf8Names = new byte[fields.Length][]; + encoded = new JsonEncodedText[fields.Length]; + children = new Node[fields.Length]; + offsets = new int[fields.Length]; + for (var i = 0; i < fields.Length; i++) + { + names[i] = fields[i].Name; + utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name); + encoded[i] = JsonEncodedText.Encode(fields[i].Name); + children[i] = fields[i].Child; + offsets[i] = Count; + Count += fields[i].Child.Count; + } + } + + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) + { + w.WriteStartObject(); + for (var i = 0; i < children.Length; i++) + { + w.WritePropertyName(encoded[i]); + children[i].Write(w, flat.Slice(offsets[i], children[i].Count), scalar, o); + } + + w.WriteEndObject(); + } + + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) + { + if (r.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected a JSON object, found {r.TokenType}."); + var ignoreCase = o.PropertyNameCaseInsensitive; + var seen = 0; + while (r.Read() && r.TokenType == JsonTokenType.PropertyName) + { + var index = IndexOf(ref r, ignoreCase); + r.Read(); + if (index < 0) + { + if (o.UnmappedMemberHandling == JsonUnmappedMemberHandling.Disallow) + throw new JsonException("The JSON object has a property that is not a component."); + r.Skip(); + continue; + } + + var slice = flat.Slice(offsets[index], children[index].Count); + var bit = 1 << index; + if ((seen & bit) != 0) + { + if (!DuplicatesAllowed(o)) throw new JsonException($"The property '{names[index]}' appears more than once."); + defaults.Slice(offsets[index], slice.Length).CopyTo(slice); + } + + seen |= bit; + children[index].Read(ref r, slice, defaults.Slice(offsets[index], slice.Length), scalar, o); + } + } + + private int IndexOf(ref Utf8JsonReader r, bool ignoreCase) + { + if (!ignoreCase) + { + for (var i = 0; i < utf8Names.Length; i++) + if (r.ValueTextEquals(utf8Names[i])) return i; + return -1; + } + + var name = r.GetString()!; + for (var i = 0; i < names.Length; i++) + if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i; + return -1; + } + } + + private sealed class Rows : Node where S : unmanaged + { + private readonly Node row; + private readonly int rows; + + public Rows(int rows, Node row) + { + this.rows = rows; + this.row = row; + Count = rows * row.Count; + } + + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) + { + w.WriteStartArray(); + for (var i = 0; i < rows; i++) row.Write(w, flat.Slice(i * row.Count, row.Count), scalar, o); + w.WriteEndArray(); + } + + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) + { + if (r.TokenType != JsonTokenType.StartArray) throw new JsonException($"Expected a JSON array of {rows} rows, found {r.TokenType}."); + for (var i = 0; i < rows; i++) + { + if (!r.Read() || r.TokenType == JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); + row.Read(ref r, flat.Slice(i * row.Count, row.Count), defaults.Slice(i * row.Count, row.Count), scalar, o); + } + + if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); + } + } + + private delegate T Make(ReadOnlySpan components) where S : unmanaged; + private delegate void Flatten(T value, Span components) where S : unmanaged; + + private sealed class Shape : JsonConverter where S : unmanaged + { + private readonly Scalar scalar; + private readonly Node root; + private readonly S[] defaults; + private readonly Make make; + private readonly Flatten flatten; + + public Shape(Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial) + { + this.scalar = scalar; + this.root = root; + this.make = make; + this.flatten = flatten; + defaults = initial ?? new S[root.Count]; + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Span flat = stackalloc S[root.Count]; + defaults.CopyTo(flat); + root.Read(ref reader, flat, defaults, scalar, options); + return make(flat); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + Span flat = stackalloc S[root.Count]; + flatten(value, flat); + root.Write(writer, flat, scalar, options); + } + + public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var text = reader.GetString() ?? throw new JsonException("A dictionary key cannot be null."); + var parts = text.Split(','); + if (parts.Length != root.Count) throw new JsonException($"A {typeof(T).Name} key has {root.Count} comma-separated components."); + Span flat = stackalloc S[root.Count]; + for (var i = 0; i < parts.Length; i++) flat[i] = scalar.ParseKey(parts[i], options); + var value = make(flat); + if (Spell(flat) != text) throw new JsonException($"'{text}' is not the canonical spelling of a {typeof(T).Name} key."); + return value; + } + + public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + Span flat = stackalloc S[root.Count]; + flatten(value, flat); + for (var i = 0; i < flat.Length; i++) scalar.Text(flat[i], options); + writer.WritePropertyName(Spell(flat)); + } + + private string Spell(ReadOnlySpan flat) + { + var parts = new string[flat.Length]; + for (var i = 0; i < parts.Length; i++) parts[i] = scalar.Spelling(flat[i]); + return string.Join(",", parts); + } + } + + private static Node Val() where S : unmanaged => new Leaf(); + + private static Node Xy() where S : unmanaged => new Fields(("x", Val()), ("y", Val())); + private static Node Xyz() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val())); + private static Node Xyzw() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val())); + + private static KeyValuePair Add( + Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial = null) where S : unmanaged => + new(typeof(T), new Shape(scalar, root, make, flatten, initial)); + + private static readonly Dictionary Converters = Build(); + + private static Dictionary Build() + { + var f = FloatScalar.Instance; + var shapes = new[] + { + Add(f, Xy(), a => new float2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(f, Xyz(), a => new float3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(f, Xyzw(), a => new float4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(f, Xyzw(), a => new quaternion(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(DoubleScalar.Instance, Xy(), a => new double2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(DoubleScalar.Instance, Xyz(), a => new double3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(IntScalar.Instance, Xy(), a => new int2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(IntScalar.Instance, Xyz(), a => new int3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(IntScalar.Instance, Xyzw(), a => new int4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(BoolScalar.Instance, Xy(), a => new bool2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }), + Add(BoolScalar.Instance, Xyz(), a => new bool3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }), + Add(BoolScalar.Instance, Xyzw(), a => new bool4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), + Add(ByteScalar.Instance, + new Fields(("r", Val()), ("g", Val()), ("b", Val()), ("a", Val())), + a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }, + initial: new byte[] { 0, 0, 0, 255 }), + Add(UIntScalar.Instance, new Fields(("state", Val())), + a => new Random { state = a[0] }, (v, a) => a[0] = v.state), + + // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}. + Add(f, new Rows(2, Xy()), + a => new float2x2(a[0], a[1], a[2], a[3]), + (v, a) => { a[0] = v[0].x; a[1] = v[0].y; a[2] = v[1].x; a[3] = v[1].y; }), + Add(f, new Rows(3, Xyz()), + a => new float3x3(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]), + (v, a) => + { + for (var row = 0; row < 3; row++) + { + a[row * 3] = v[row].x; + a[(row * 3) + 1] = v[row].y; + a[(row * 3) + 2] = v[row].z; + } + }), + Add(f, new Fields(("min", Xy()), ("max", Xy())), + a => new rect(a[0], a[1], a[2], a[3]), + (v, a) => { a[0] = v.min.x; a[1] = v.min.y; a[2] = v.max.x; a[3] = v.max.y; }), + Add(f, new Fields(("nearest", Xyzw()), ("edge", Xyzw()), ("id", Val())), + a => new CultCellular(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7]), a[8]), + (v, a) => + { + a[0] = v.nearest.x; a[1] = v.nearest.y; a[2] = v.nearest.z; a[3] = v.nearest.w; + a[4] = v.edge.x; a[5] = v.edge.y; a[6] = v.edge.z; a[7] = v.edge.w; + a[8] = v.id; + }), + }; + + var map = new Dictionary(); + foreach (var shape in shapes) map.Add(shape.Key, shape.Value); + return map; + } +} diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs new file mode 100644 index 00000000..803b3873 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using CultMath; +using MessagePack; +using MessagePack.Formatters; +using Random = CultMath.Random; + +namespace GameCult.Caching.MessagePack; + +/// +/// The canonical MessagePack encoding of every public CultMath value type. Each type is an +/// array of its components in declaration order: vectors and quaternions are flat scalar +/// arrays, matrices are arrays of row vectors, and rect is [min, max]. Decoding +/// skips trailing elements and leaves missing ones at zero (a missing Color32 alpha is 255); nil is refused. The resolver is +/// part of the default chain, so a consumer +/// registers nothing. +/// +public sealed class CultMathResolver : IFormatterResolver +{ + public static readonly CultMathResolver Instance = new(); + private CultMathResolver() { } + + /// The CultMath types this resolver formats, for coverage checks. + public static IReadOnlyCollection FormattedTypes => Formatters.Keys; + + public IMessagePackFormatter? GetFormatter() => Cache.Formatter; + + private static class Cache + { + public static readonly IMessagePackFormatter? Formatter = + Formatters.TryGetValue(typeof(T), out var formatter) ? (IMessagePackFormatter)formatter : null; + } + + private delegate void WriteFn(ref MessagePackWriter writer, T value); + private delegate T ReadFn(ref MessagePackReader reader); + + private sealed class ArrayShape : IMessagePackFormatter + { + private readonly WriteFn write; + private readonly ReadFn read; + + public ArrayShape(WriteFn write, ReadFn read) + { + this.write = write; + this.read = read; + } + + public void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options) => write(ref writer, value); + + public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return read(ref reader); + } + } + + private static KeyValuePair Shape(WriteFn write, ReadFn read) => + new(typeof(T), new ArrayShape(write, read)); + + // Each reader takes the first dst.Length elements and skips the rest. + private static void Singles(ref MessagePackReader r, scoped Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadSingle(); else r.Skip(); + } + + private static void Doubles(ref MessagePackReader r, scoped Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadDouble(); else r.Skip(); + } + + private static void Ints(ref MessagePackReader r, scoped Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadInt32(); else r.Skip(); + } + + private static void Bools(ref MessagePackReader r, scoped Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadBoolean(); else r.Skip(); + } + + private static void Bytes(ref MessagePackReader r, scoped Span dst) + { + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < dst.Length) dst[i] = r.ReadByte(); else r.Skip(); + } + + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan v) + { + w.WriteArrayHeader(v.Length); + foreach (var e in v) w.Write(e); + } + + private static void PutFloat2(ref MessagePackWriter w, float2 v) => Put(ref w, stackalloc float[] { v.x, v.y }); + private static void PutFloat3(ref MessagePackWriter w, float3 v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z }); + private static void PutFloat4(ref MessagePackWriter w, float4 v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }); + + private static float2 GetFloat2(ref MessagePackReader r) + { + Span s = stackalloc float[2]; + Singles(ref r, s); + return new float2(s[0], s[1]); + } + + private static float3 GetFloat3(ref MessagePackReader r) + { + Span s = stackalloc float[3]; + Singles(ref r, s); + return new float3(s[0], s[1], s[2]); + } + + private static float4 GetFloat4(ref MessagePackReader r) + { + Span s = stackalloc float[4]; + Singles(ref r, s); + return new float4(s[0], s[1], s[2], s[3]); + } + + private static readonly Dictionary Formatters = Build(); + + private static Dictionary Build() + { + var shapes = new[] + { + Shape(PutFloat2, GetFloat2), + Shape(PutFloat3, GetFloat3), + Shape(PutFloat4, GetFloat4), + Shape((ref MessagePackWriter w, double2 v) => Put(ref w, stackalloc double[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc double[2]; Doubles(ref r, s); return new double2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, double3 v) => Put(ref w, stackalloc double[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc double[3]; Doubles(ref r, s); return new double3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, int2 v) => Put(ref w, stackalloc int[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc int[2]; Ints(ref r, s); return new int2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, int3 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc int[3]; Ints(ref r, s); return new int3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, int4 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc int[4]; Ints(ref r, s); return new int4(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, bool2 v) => Put(ref w, stackalloc bool[] { v.x, v.y }), + (ref MessagePackReader r) => { Span s = stackalloc bool[2]; Bools(ref r, s); return new bool2(s[0], s[1]); }), + Shape((ref MessagePackWriter w, bool3 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z }), + (ref MessagePackReader r) => { Span s = stackalloc bool[3]; Bools(ref r, s); return new bool3(s[0], s[1], s[2]); }), + Shape((ref MessagePackWriter w, bool4 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc bool[4]; Bools(ref r, s); return new bool4(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, quaternion v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }), + (ref MessagePackReader r) => { Span s = stackalloc float[4]; Singles(ref r, s); return new quaternion(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, Color32 v) => Put(ref w, stackalloc byte[] { v.r, v.g, v.b, v.a }), + (ref MessagePackReader r) => { Span s = stackalloc byte[4]; s[3] = 255; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }), + Shape((ref MessagePackWriter w, Random v) => { w.WriteArrayHeader(1); w.Write(v.state); }, + (ref MessagePackReader r) => + { + var value = default(Random); + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i == 0) value.state = r.ReadUInt32(); else r.Skip(); + return value; + }), + + // Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id]. + Shape((ref MessagePackWriter w, float2x2 v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v[0]); PutFloat2(ref w, v[1]); }, + (ref MessagePackReader r) => + { + var rows = new float2[2]; + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < 2) rows[i] = GetFloat2(ref r); else r.Skip(); + return new float2x2(rows[0], rows[1]); + }), + Shape((ref MessagePackWriter w, float3x3 v) => { w.WriteArrayHeader(3); PutFloat3(ref w, v[0]); PutFloat3(ref w, v[1]); PutFloat3(ref w, v[2]); }, + (ref MessagePackReader r) => + { + var rows = new float3[3]; + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < 3) rows[i] = GetFloat3(ref r); else r.Skip(); + return new float3x3(rows[0], rows[1], rows[2]); + }), + Shape((ref MessagePackWriter w, rect v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v.min); PutFloat2(ref w, v.max); }, + (ref MessagePackReader r) => + { + var corners = new float2[2]; + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + if (i < 2) corners[i] = GetFloat2(ref r); else r.Skip(); + return new rect(corners[0], corners[1]); + }), + Shape((ref MessagePackWriter w, CultCellular v) => { w.WriteArrayHeader(3); PutFloat4(ref w, v.nearest); PutFloat4(ref w, v.edge); w.Write(v.id); }, + (ref MessagePackReader r) => + { + var value = default(CultCellular); + for (int i = 0, n = r.ReadArrayHeader(); i < n; i++) + { + switch (i) + { + case 0: value.nearest = GetFloat4(ref r); break; + case 1: value.edge = GetFloat4(ref r); break; + case 2: value.id = r.ReadSingle(); break; + default: r.Skip(); break; + } + } + return value; + }), + }; + + var map = new Dictionary(); + foreach (var shape in shapes) map.Add(shape.Key, shape.Value); + return map; + } +} diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index c2bd1c1b..c2588e24 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -6,7 +6,9 @@ + + diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index c69ab887..fdf91ab2 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -9,6 +9,7 @@ format for the attribute-first `GameCult.Caching` stack. - `CultDocumentMessagePackSerialization` - `CultDocumentResolver` - `CultRecordRefFormatter` +- `CultMathResolver`: the canonical component-array encoding of every public CultMath value type, part of the default resolver chain Cult document payloads serialize through `MessagePackSerializer` with the options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`), @@ -56,3 +57,42 @@ await cache.PullAllBackingStoresAsync(); persisted through hand-written MessagePack array layouts in this package. - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. + +## CultMath Value Shapes + +Every public CultMath value type has one MessagePack shape (`CultMathResolver`, +in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's +options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns +no JSON options of its own). MessagePack decoding skips extra elements, leaves +missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading +skips unknown properties (`UnmappedMemberHandling.Disallow` refuses them), leaves +missing ones at zero, honours `PropertyNameCaseInsensitive`, lets a repeated +property replace the whole earlier value (refused when `AllowDuplicateProperties` +is false), and throws `JsonException` for a wrong token or an out-of-range number. +Numbers follow `JsonSerializerOptions.NumberHandling` as System.Text.Json's own +float does: `"NaN"`, `"Infinity"` and `"-Infinity"` are written under +`AllowNamedFloatingPointLiterals` or `WriteAsString` and read under +`AllowNamedFloatingPointLiterals` or `AllowReadingFromString`; quoted numbers read +under `AllowReadingFromString` with no surrounding whitespace. +Every type is also a dictionary key: its flattened components joined by commas, +invariant culture (`"1,2"`, `"true,false"`, matrices row by row, `rect` as +`"minX,minY,maxX,maxY"`), and only that exact spelling reads back. Two intended +divergences from System.Text.Json: a bare number beyond a component's range +(`1e40` as a float) is refused where System.Text.Json reads infinity, and a +refusal on write is a `JsonException` where System.Text.Json throws +`ArgumentException`. A consumer resolver or converter +registered ahead of these wins. + +| Type | MessagePack | JSON | +| --- | --- | --- | +| `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` | +| `float3`, `double3`, `int3`, `bool3` | `[x, y, z]` | `{"x":..,"y":..,"z":..}` | +| `float4`, `int4`, `bool4`, `quaternion` | `[x, y, z, w]` | `{"x":..,"y":..,"z":..,"w":..}` | +| `Color32` | `[r, g, b, a]` as uint8 | `{"r":..,"g":..,"b":..,"a":..}` | +| `Random` | `[state]` | `{"state":..}` | +| `float2x2`, `float3x3` | array of row vectors (row-major) | array of row objects | +| `rect` | `[min, max]` of `float2` | `{"min":{..},"max":{..}}` | +| `CultCellular` | `[nearest, edge, id]` | `{"nearest":{..},"edge":{..},"id":..}` | + +Floats are MessagePack float32 (`0xCA`), doubles float64 (`0xCB`), integers the +shortest MessagePack integer. diff --git a/src/GameCult.Geometry/CultGeometryIsoSurface.cs b/src/GameCult.Geometry/CultGeometryIsoSurface.cs index 583fdd87..b0967ece 100644 --- a/src/GameCult.Geometry/CultGeometryIsoSurface.cs +++ b/src/GameCult.Geometry/CultGeometryIsoSurface.cs @@ -39,7 +39,7 @@ namespace GameCult.Geometry public static CultGeometryTriangleMesh Extract( float[,,] samples, float isoValue = 0f, - CultVec3 origin = default, + float3 origin = default, float cellSize = 1f) { if (samples == null) throw new ArgumentNullException(nameof(samples)); @@ -56,7 +56,6 @@ namespace GameCult.Geometry 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]; @@ -66,7 +65,7 @@ namespace GameCult.Geometry 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; + var cubeOrigin = origin + new float3(x, y, z) * cellSize; for (var corner = 0; corner < CubeCorners.Length; corner++) { var offset = CubeCorners[corner]; diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs index 99587b8e..580f2b71 100644 --- a/src/GameCult.Geometry/CultGeometryPrimitives.cs +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -1,197 +1,10 @@ using System; using System.Text.Json.Serialization; +using CultMath; using MessagePack; namespace GameCult.Geometry { - /// - /// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec2 : IEquatable - { - /// Creates a two-dimensional vector. - public CultVec2(float x, float y) - { - X = x; - Y = y; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec2 Zero => new(0f, 0f); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y); - - /// - public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y); - - /// - public override string ToString() => $"({X}, {Y})"; - - public static CultVec2 operator +(CultVec2 left, CultVec2 right) => new(left.X + right.X, left.Y + right.Y); - public static CultVec2 operator -(CultVec2 left, CultVec2 right) => new(left.X - right.X, left.Y - right.Y); - public static CultVec2 operator *(CultVec2 value, float scalar) => new(value.X * scalar, value.Y * scalar); - public static CultVec2 operator /(CultVec2 value, float scalar) => new(value.X / scalar, value.Y / scalar); - public static bool operator ==(CultVec2 left, CultVec2 right) => left.Equals(right); - public static bool operator !=(CultVec2 left, CultVec2 right) => !left.Equals(right); - } - - /// - /// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec3 : IEquatable - { - /// Creates a three-dimensional vector. - public CultVec3(float x, float y, float z) - { - X = x; - Y = y; - Z = z; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the z component. - [Key(2)] - public float Z { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec3 Zero => new(0f, 0f, 0f); - - /// Gets the XY plane projection. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xy => new(X, Y); - - /// Gets the XZ plane projection for Unity compatibility adapters. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xz => new(X, Z); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y) + (Z * Z); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); - - /// - public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y, Z); - - /// - public override string ToString() => $"({X}, {Y}, {Z})"; - - public static CultVec3 operator +(CultVec3 left, CultVec3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); - public static CultVec3 operator -(CultVec3 left, CultVec3 right) => new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); - public static CultVec3 operator *(CultVec3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar); - public static CultVec3 operator /(CultVec3 value, float scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar); - public static bool operator ==(CultVec3 left, CultVec3 right) => left.Equals(right); - public static bool operator !=(CultVec3 left, CultVec3 right) => !left.Equals(right); - } - - /// - /// Axis-aligned two-dimensional rectangle represented by canonical min and max corners. - /// - [MessagePackObject] - public readonly struct CultRect : IEquatable - { - /// Creates a rectangle and canonicalizes min/max corners. - public CultRect(CultVec2 min, CultVec2 max) - { - Min = new CultVec2(Math.Min(min.X, max.X), Math.Min(min.Y, max.Y)); - Max = new CultVec2(Math.Max(min.X, max.X), Math.Max(min.Y, max.Y)); - } - - /// Creates a rectangle and canonicalizes min/max corners. - public CultRect(float minX, float minY, float maxX, float maxY) - : this(new CultVec2(minX, minY), new CultVec2(maxX, maxY)) - { - } - - /// Gets the canonical minimum corner. - [Key(0)] - public CultVec2 Min { get; } - - /// Gets the canonical maximum corner. - [Key(1)] - public CultVec2 Max { get; } - - /// Gets the rectangle size. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Size => Max - Min; - - /// Gets the rectangle center. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Center => (Min + Max) * 0.5f; - - /// Returns whether the point lies inside or on the rectangle edge. - public bool Contains(CultVec2 point) - { - return point.X >= Min.X && - point.X <= Max.X && - point.Y >= Min.Y && - point.Y <= Max.Y; - } - - /// Returns whether this rectangle intersects another rectangle. - public bool Intersects(CultRect other) - { - return Min.X <= other.Max.X && - Max.X >= other.Min.X && - Min.Y <= other.Max.Y && - Max.Y >= other.Min.Y; - } - - /// Returns whether this rectangle is equal to another rectangle. - public bool Equals(CultRect other) => Min == other.Min && Max == other.Max; - - /// - public override bool Equals(object? obj) => obj is CultRect other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(Min, Max); - - /// - public override string ToString() => $"[{Min}..{Max}]"; - - public static bool operator ==(CultRect left, CultRect right) => left.Equals(right); - public static bool operator !=(CultRect left, CultRect right) => !left.Equals(right); - } - /// /// Two-dimensional circle for spatial queries. /// @@ -199,7 +12,8 @@ namespace GameCult.Geometry public readonly struct CultCircle : IEquatable { /// Creates a circle. - public CultCircle(CultVec2 center, float radius) + [JsonConstructor] + public CultCircle(float2 center, float radius) { if (radius < 0f) { @@ -212,7 +26,7 @@ namespace GameCult.Geometry /// Gets the circle center. [Key(0)] - public CultVec2 Center { get; } + public float2 Center { get; } /// Gets the circle radius. [Key(1)] @@ -221,29 +35,29 @@ namespace GameCult.Geometry /// Gets the circle bounds. [IgnoreMember] [JsonIgnore] - public CultRect Bounds => new( - Center.X - Radius, - Center.Y - Radius, - Center.X + Radius, - Center.Y + Radius); + public rect Bounds => new( + Center.x - Radius, + Center.y - Radius, + Center.x + Radius, + Center.y + Radius); /// Returns whether the point lies inside or on the circle edge. - public bool Contains(CultVec2 point) + public bool Contains(float2 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return math.lengthsq(delta) <= Radius * Radius; } /// Returns whether this circle intersects a rectangle. - public bool Intersects(CultRect rect) + public bool Intersects(rect rect) { - var clampedX = Math.Max(rect.Min.X, Math.Min(Center.X, rect.Max.X)); - var clampedY = Math.Max(rect.Min.Y, Math.Min(Center.Y, rect.Max.Y)); - return Contains(new CultVec2(clampedX, clampedY)); + var clampedX = Math.Max(rect.min.x, Math.Min(Center.x, rect.max.x)); + var clampedY = Math.Max(rect.min.y, Math.Min(Center.y, rect.max.y)); + return Contains(new float2(clampedX, clampedY)); } /// Returns whether this circle is equal to another circle. - public bool Equals(CultCircle other) => Center == other.Center && Radius.Equals(other.Radius); + public bool Equals(CultCircle other) => Center.Equals(other.Center) && Radius.Equals(other.Radius); /// public override bool Equals(object? obj) => obj is CultCircle other && Equals(other); @@ -262,7 +76,8 @@ namespace GameCult.Geometry public readonly struct CultSphere : IEquatable { /// Creates a sphere. - public CultSphere(CultVec3 center, float radius) + [JsonConstructor] + public CultSphere(float3 center, float radius) { if (radius < 0f) { @@ -275,7 +90,7 @@ namespace GameCult.Geometry /// Gets the sphere center. [Key(0)] - public CultVec3 Center { get; } + public float3 Center { get; } /// Gets the sphere radius. [Key(1)] @@ -284,17 +99,17 @@ namespace GameCult.Geometry /// Gets the XY circle projection. [IgnoreMember] [JsonIgnore] - public CultCircle XyCircle => new(Center.Xy, Radius); + public CultCircle XyCircle => new(Center.xy, Radius); /// Returns whether the point lies inside or on the sphere surface. - public bool Contains(CultVec3 point) + public bool Contains(float3 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return math.lengthsq(delta) <= Radius * Radius; } /// Returns whether this sphere is equal to another sphere. - public bool Equals(CultSphere other) => Center == other.Center && Radius.Equals(other.Radius); + public bool Equals(CultSphere other) => Center.Equals(other.Center) && Radius.Equals(other.Radius); /// public override bool Equals(object? obj) => obj is CultSphere other && Equals(other); diff --git a/src/GameCult.Geometry/CultGeometrySurfaceNets.cs b/src/GameCult.Geometry/CultGeometrySurfaceNets.cs index 45c3f1ec..bd78e053 100644 --- a/src/GameCult.Geometry/CultGeometrySurfaceNets.cs +++ b/src/GameCult.Geometry/CultGeometrySurfaceNets.cs @@ -44,7 +44,7 @@ namespace GameCult.Geometry public static CultGeometryQuadMesh Extract( float[,,] samples, float isoValue = 0f, - CultVec3 origin = default, + float3 origin = default, float cellSize = 1f) { if (samples == null) throw new ArgumentNullException(nameof(samples)); @@ -66,7 +66,7 @@ namespace GameCult.Geometry throw new ArgumentException("The isovalue must be finite.", nameof(isoValue)); } - if (!float.IsFinite(origin.X) || !float.IsFinite(origin.Y) || !float.IsFinite(origin.Z)) + if (!float.IsFinite(origin.x) || !float.IsFinite(origin.y) || !float.IsFinite(origin.z)) { throw new ArgumentException("The origin must be finite.", nameof(origin)); } @@ -80,7 +80,6 @@ namespace GameCult.Geometry var sizeY = samples.GetLength(1); var sizeZ = samples.GetLength(2); var cellCounts = new[] { sizeX - 1, sizeY - 1, sizeZ - 1 }; - var originValue = new float3(origin.X, origin.Y, origin.Z); var vertexIndex = new Dictionary<(int, int, int), uint>(); var positions = new List(); @@ -90,7 +89,7 @@ namespace GameCult.Geometry for (var cz = 0; cz < cellCounts[2]; cz++) { var cellCoord = new[] { cx, cy, cz }; - var vertex = CellVertex(samples, cellCoord, isoValue, originValue, cellSize); + var vertex = CellVertex(samples, cellCoord, isoValue, origin, cellSize); if (vertex == null) continue; vertexIndex[(cx, cy, cz)] = (uint)(positions.Count / 3); diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 2d12dc25..9929e387 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -31,12 +31,9 @@ The package is deliberately substrate-shaped: ## Value Primitives The small value layer is for cross-runtime query contracts, physics probes, -native slices, and UI surfaces: +native slices, and UI surfaces. Vectors and rectangles are CultMath's `float2`, +`float3` and `rect`, and `GameCult.Caching.MessagePack` serializes them. -- `CultVec2` and `CultVec3`: whole-vector values for positions, velocities, - accelerations, and query inputs. SoA layouts may store them efficiently, but - the semantic API does not force callers to split x/y/z into unrelated fields. -- `CultRect`: canonical XY viewport/query rectangle stored as min/max corners. - `CultCircle`: 2D influence/query brush with rect intersection helpers. - `CultSphere`: 3D query primitive with whole-vector center and XY projection. @@ -85,8 +82,8 @@ cache miss; that is split-brain geometry. ## Documents -- `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, `CultSphere`: shared value - primitives for query and view contracts. +- `CultCircle`, `CultSphere`: shared value primitives for query and view + contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for Ragnarok/Fensalir-style feature DSL output. - `CultGeometryBuildRequest`: one LOD/frustum/budget request for workers. diff --git a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md index 93fb3f3c..5b0048de 100644 --- a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md +++ b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md @@ -116,12 +116,12 @@ var aetheria = verse.Use(AetheriaRuntime.Schema); var viewport = await aetheria.Zone(zoneId) .Objects() .VisibleTo(controlledUnits) - .Within(CultRect.FromBounds(-400, -250, 400, 250)) + .Within(new rect(-400f, -250f, 400f, 250f)) .QueryAsync(); await aetheria.Entity(ravenId) .Pilot() - .MoveAsync(CultVec2.Right, claim: "pilot-control"); + .MoveAsync(new float2(1f, 0f), claim: "pilot-control"); ``` Current seed: @@ -134,7 +134,7 @@ var verse = await CultMesh.ConnectVerseAsync( new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven") }); var aetheria = verse.Use(context => new AetheriaGeneratedFacade(context)); -await aetheria.Entity(ravenId).Pilot.MoveAsync(CultVec2.Right); +await aetheria.Entity(ravenId).Pilot.MoveAsync(new float2(1f, 0f)); ``` ```ts @@ -177,7 +177,7 @@ let viewport = aetheria .zone(zone_id) .objects() .visible_to(&controlled_units) - .within(CultRect::from_bounds(-400.0, -250.0, 400.0, 250.0)) + .within(rect::new(-400.0, -250.0, 400.0, 250.0)) .query() .await?; ``` diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index df1735b6..e9f19ab7 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -407,8 +407,8 @@ var view = new CultMeshNativeSliceViewDescriptor( rowCount, new[] { - CultMeshNativeSliceColumn.For("position"), - CultMeshNativeSliceColumn.For("velocity") + CultMeshNativeSliceColumn.For("position"), + CultMeshNativeSliceColumn.For("velocity") }, new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); ``` diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs new file mode 100644 index 00000000..87e5b742 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using CultMath; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public sealed class CultMathJsonTests + { + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions().AddCultMathConverters(); + + private static string Json(T value) => JsonSerializer.Serialize(value, Options); + + private static T Parse(string json) => JsonSerializer.Deserialize(json, Options)!; + + [Test] + public void EveryShape_IsPinned() + { + Json(new float2(1f, 2f)).Should().Be("""{"x":1,"y":2}"""); + Json(new float3(1f, 2f, 3f)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new float4(1f, 2f, 3f, 4f)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}"""); + Json(new double2(1d, 2.5d)).Should().Be("""{"x":1,"y":2.5}"""); + Json(new double3(1d, 2d, 3d)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new int2(3, -4)).Should().Be("""{"x":3,"y":-4}"""); + Json(new int3(1, 2, 3)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new int4(1, 2, 3, 4)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}"""); + Json(new bool2(true, false)).Should().Be("""{"x":true,"y":false}"""); + Json(new bool3(true, false, true)).Should().Be("""{"x":true,"y":false,"z":true}"""); + Json(new bool4(true, false, false, true)).Should().Be("""{"x":true,"y":false,"z":false,"w":true}"""); + Json(new quaternion(0f, 0f, 0f, 1f)).Should().Be("""{"x":0,"y":0,"z":0,"w":1}"""); + Json(new Color32(255, 128, 0, 7)).Should().Be("""{"r":255,"g":128,"b":0,"a":7}"""); + Json(new CultMath.Random(12345u)).Should().Be("""{"state":12345}"""); + Json(new float2x2(1f, 2f, 3f, 4f)).Should().Be("""[{"x":1,"y":2},{"x":3,"y":4}]"""); + Json(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be( + """[{"x":1,"y":2,"z":3},{"x":4,"y":5,"z":6},{"x":7,"y":8,"z":9}]"""); + Json(new rect(5f, 7f, -1f, -2f)).Should().Be("""{"min":{"x":-1,"y":-2},"max":{"x":5,"y":7}}"""); + Json(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( + """{"nearest":{"x":1,"y":2,"z":3,"w":4},"edge":{"x":5,"y":6,"z":7,"w":8},"id":0.25}"""); + } + + [Test] + public void EveryShape_RoundTrips() + { + RoundTrip(new float2(1.5f, -2f)); + RoundTrip(new float3(1f, 2f, 3f)); + RoundTrip(new float4(1f, 2f, 3f, 4f)); + RoundTrip(new double2(1e300, -2.5)); + RoundTrip(new double3(1, 2, 3)); + RoundTrip(new int2(-7, 900000)); + RoundTrip(new int3(1, -2, 3)); + RoundTrip(new int4(1, 2, 3, int.MinValue)); + RoundTrip(new bool2(true, false)); + RoundTrip(new bool3(false, true, true)); + RoundTrip(new bool4(true, false, false, true)); + RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f)); + RoundTrip(new Color32(255, 128, 0, 7)); + RoundTrip(new CultMath.Random(12345u)); + RoundTrip(new float2x2(1f, 2f, 3f, 4f)); + RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)); + RoundTrip(new rect(-1f, -2f, 5f, 7f)); + RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f)); + } + + [Test] + public void Reading_SkipsUnknownPropertiesAndZeroesMissingOnes() + { + Parse("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f)); + Parse("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255)); + Parse("null").Should().BeNull(); + } + + [Test] + public void Reading_RefusesShapesThatAreNotTheType() + { + Action array = () => Parse("[1,2]"); + array.Should().Throw(); + Action shortMatrix = () => Parse("""[{"x":1,"y":2}]"""); + shortMatrix.Should().Throw(); + Action nil = () => Parse("null"); + nil.Should().Throw(); + } + + [Test] + public void EveryPublicCultMathValueType_HasAConverter() + { + var valueTypes = typeof(float2).Assembly.GetExportedTypes() + .Where(t => t.IsValueType && !t.IsEnum) + .ToHashSet(); + + valueTypes.Except(CultMathJson.ConvertedTypes).Should().BeEmpty(); + CultMathJson.ConvertedTypes.Except(valueTypes).Should().BeEmpty(); + foreach (var type in valueTypes) + { + var json = JsonSerializer.Serialize(Activator.CreateInstance(type), type, Options); + JsonSerializer.Deserialize(json, type, Options).Should().Be(Activator.CreateInstance(type)); + } + } + + private static JsonSerializerOptions With(JsonNumberHandling handling = JsonNumberHandling.Strict, bool ignoreCase = false) => + new JsonSerializerOptions { NumberHandling = handling, PropertyNameCaseInsensitive = ignoreCase }.AddCultMathConverters(); + + private const JsonNumberHandling Named = JsonNumberHandling.AllowNamedFloatingPointLiterals; + + [Test] + public void NonFiniteFloats_NeedNamedLiterals_AsInSystemTextJson() + { + Action nan = () => JsonSerializer.Serialize(new float2(float.NaN, 1f), Options); + nan.Should().Throw(); + Action inf = () => JsonSerializer.Serialize(new double3(1d, double.PositiveInfinity, 0d), Options); + inf.Should().Throw(); + Action named = () => JsonSerializer.Deserialize("""{"x":"NaN","y":1}""", Options); + named.Should().Throw(); + + var options = With(Named); + JsonSerializer.Serialize(new float2(float.NaN, 1f), options).Should().Be("""{"x":"NaN","y":1}"""); + JsonSerializer.Serialize(new float3(float.PositiveInfinity, float.NegativeInfinity, 0f), options) + .Should().Be("""{"x":"Infinity","y":"-Infinity","z":0}"""); + var back = JsonSerializer.Deserialize("""{"x":"Infinity","y":"-Infinity","z":"NaN"}""", options); + back.x.Should().Be(float.PositiveInfinity); + back.y.Should().Be(float.NegativeInfinity); + float.IsNaN(back.z).Should().BeTrue(); + JsonSerializer.Deserialize("""{"x":"NaN","y":"-Infinity"}""", options).y.Should().Be(double.NegativeInfinity); + } + + [Test] + public void Numbers_OutsideTheComponentType_AreRefused() + { + foreach (var json in new[] { """{"x":1e40}""", """{"x":-1e40}""" }) + { + Action tooBig = () => Parse(json); + tooBig.Should().Throw(json); + } + + Action doubleTooBig = () => Parse("""{"x":1e400}"""); + doubleTooBig.Should().Throw(); + Action alpha = () => Parse("""{"r":300}"""); + alpha.Should().Throw(); + Action negativeByte = () => Parse("""{"g":-1}"""); + negativeByte.Should().Throw(); + Action fraction = () => Parse("""{"x":1.5}"""); + fraction.Should().Throw(); + Action wrongToken = () => Parse("""{"x":"a"}"""); + wrongToken.Should().Throw(); + Action boolean = () => Parse("""{"x":1}"""); + boolean.Should().Throw(); + Action negativeState = () => Parse("""{"state":-1}"""); + negativeState.Should().Throw(); + } + + [Test] + public void NumberHandling_QuotesAndUnquotesNumbers() + { + JsonSerializer.Serialize(new float2(1f, 2.5f), With(JsonNumberHandling.WriteAsString)).Should().Be("""{"x":"1","y":"2.5"}"""); + JsonSerializer.Serialize(new int2(1, 2), With(JsonNumberHandling.WriteAsString)).Should().Be("""{"x":"1","y":"2"}"""); + JsonSerializer.Deserialize("""{"x":"1.5","y":2}""", With(JsonNumberHandling.AllowReadingFromString)) + .Should().Be(new float2(1.5f, 2f)); + JsonSerializer.Deserialize("""{"x":"7","y":2}""", With(JsonNumberHandling.AllowReadingFromString)) + .Should().Be(new int2(7, 2)); + Action strict = () => Parse("""{"x":"1.5"}"""); + strict.Should().Throw(); + Action tooBig = () => JsonSerializer.Deserialize("""{"x":"1e40"}""", With(JsonNumberHandling.AllowReadingFromString)); + tooBig.Should().Throw(); + } + + [Test] + public void PropertyNames_FollowTheCaseSensitivityOption() + { + JsonSerializer.Deserialize("""{"X":1,"Y":2}""", With(ignoreCase: true)).Should().Be(new float2(1f, 2f)); + JsonSerializer.Deserialize("""{"MIN":{"X":1,"y":2},"Max":{"x":3,"Y":4}}""", With(ignoreCase: true)) + .Should().Be(new rect(1f, 2f, 3f, 4f)); + Parse("""{"X":1,"Y":2}""").Should().Be(float2.zero); + } + + [Test] + public void EveryShape_WorksAsADictionaryKey() + { + Json(new Dictionary { [new int2(1, -2)] = "a" }).Should().Be("""{"1,-2":"a"}"""); + Parse>("""{"1,-2":"a"}""")[new int2(1, -2)].Should().Be("a"); + + KeyOf(new float2(1.5f, -2f)).Should().Be("1.5,-2"); + KeyOf(new float3(0.1f, 2f, 3f)).Should().Be("0.1,2,3"); + KeyOf(new bool2(true, false)).Should().Be("true,false"); + KeyOf(new Color32(255, 128, 0, 7)).Should().Be("255,128,0,7"); + KeyOf(new CultMath.Random(12345u)).Should().Be("12345"); + KeyOf(new float2x2(1f, 2f, 3f, 4f)).Should().Be("1,2,3,4"); + KeyOf(new rect(5f, 7f, -1f, -2f)).Should().Be("-1,-2,5,7"); + KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity"); + + var nan = JsonSerializer.Deserialize>("""{"NaN,Infinity":1}""", With(Named))!; + nan.Keys.Single().y.Should().Be(float.PositiveInfinity); + + Action refuseNonFinite = () => KeyOf(new float2(float.NaN, 0f)); + refuseNonFinite.Should().Throw(); + Action wrongArity = () => Parse>("""{"1,2":1}"""); + wrongArity.Should().Throw(); + Action overflow = () => Parse>("""{"1e40,0":1}"""); + overflow.Should().Throw(); + } + + [Test] + public void EveryPublicCultMathValueType_RoundTripsAsADictionaryKey() + { + foreach (var type in typeof(float2).Assembly.GetExportedTypes().Where(t => t.IsValueType && !t.IsEnum)) + { + var dictionary = (IDictionary)Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(type, typeof(int)))!; + dictionary.Add(Activator.CreateInstance(type)!, 7); + + var json = JsonSerializer.Serialize(dictionary, dictionary.GetType(), Options); + var back = (IDictionary)JsonSerializer.Deserialize(json, dictionary.GetType(), Options)!; + + back.Count.Should().Be(1, type.Name); + back[Activator.CreateInstance(type)!].Should().Be(7, type.Name); + } + } + + private static string KeyOf(T value, JsonNumberHandling handling = JsonNumberHandling.Strict) where T : notnull + { + var json = JsonSerializer.Serialize(new Dictionary { [value] = 1 }, With(handling)); + return JsonDocument.Parse(json).RootElement.EnumerateObject().Single().Name; + } + + private const JsonNumberHandling ReadStrings = JsonNumberHandling.AllowReadingFromString; + private const JsonNumberHandling WriteStrings = JsonNumberHandling.WriteAsString; + + // Every row was measured against System.Text.Json's own float. + [Test] + public void NamedLiterals_FollowTheSameFlagsAsSystemTextJsonFloat() + { + foreach (var handling in new[] { Named, ReadStrings, ReadStrings | Named }) + { + JsonSerializer.Deserialize("""{"x":"NaN","y":"-Infinity"}""", With(handling))!.y.Should().Be(float.NegativeInfinity, handling.ToString()); + } + + foreach (var handling in new[] { JsonNumberHandling.Strict, WriteStrings }) + { + Action read = () => JsonSerializer.Deserialize("""{"x":"NaN"}""", With(handling)); + read.Should().Throw(handling.ToString()); + } + + foreach (var handling in new[] { Named, WriteStrings, WriteStrings | Named }) + { + JsonSerializer.Serialize(new float2(float.NaN, float.PositiveInfinity), With(handling)) + .Should().Be("""{"x":"NaN","y":"Infinity"}""", handling.ToString()); + } + + foreach (var handling in new[] { JsonNumberHandling.Strict, ReadStrings }) + { + Action write = () => JsonSerializer.Serialize(new float2(float.NaN, 0f), With(handling)); + write.Should().Throw(handling.ToString()); + } + + JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}"""); + JsonSerializer.Deserialize("""{"z":"Infinity"}""", With(ReadStrings))!.z.Should().Be(double.PositiveInfinity); + JsonSerializer.Serialize(new double2(double.NegativeInfinity, 2d), With(WriteStrings)).Should().Be("""{"x":"-Infinity","y":"2"}"""); + } + + [Test] + public void QuotedNumbers_RefuseWhitespace() + { + var options = With(ReadStrings); + foreach (var json in new[] { """{"x":" 1.5"}""", """{"x":"1.5 "}""", """{"x":"\t1"}""" }) + { + Action floatRead = () => JsonSerializer.Deserialize(json, options); + floatRead.Should().Throw(json); + } + + Action doubleRead = () => JsonSerializer.Deserialize("""{"y":" 2"}""", options); + doubleRead.Should().Throw(); + Action intRead = () => JsonSerializer.Deserialize("""{"x":" 1"}""", options); + intRead.Should().Throw(); + Action padded = () => JsonSerializer.Deserialize("""{"x":" NaN"}""", With(ReadStrings | Named)); + padded.Should().Throw(); + } + + [Test] + public void UnknownProperties_AreSkippedWhateverTheyContain() + { + var expected = new float3(1f, 2f, 3f); + foreach (var json in new[] + { + """{"u":{"a":[1,{"b":2}]},"x":1,"y":2,"z":3}""", + """{"x":1,"u":[[1],[2,[3]]],"y":2,"v":{"a":{}},"z":3}""", + """{"x":1,"y":2,"z":3,"u":{"a":{"b":[]}}}""", + """{"x":1,"y":2,"z":3,"u":[{"a":[{}]}]}""", + }) + { + Parse(json).Should().Be(expected, json); + } + + Parse("""{"min":{"q":[{}],"x":1,"y":2},"skip":{"a":[1]},"max":{"x":3,"y":4,"q":{"a":[]}}}""") + .Should().Be(new rect(1f, 2f, 3f, 4f)); + Parse("""[{"x":1,"u":[1],"y":2},{"y":4,"u":{"a":1},"x":3}]""").Should().Be(new float2x2(1f, 2f, 3f, 4f)); + } + + [Test] + public void UnmappedMemberHandling_Disallow_RefusesUnknownProperties() + { + var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters(); + Action flat = () => JsonSerializer.Deserialize("""{"x":1,"z":3}""", options); + flat.Should().Throw(); + Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"q":1},"max":{}}""", options); + nested.Should().Throw(); + JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void DuplicateProperties_ReplaceTheWholeValue() + { + Parse("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f)); + Parse("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f)); + Parse("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1)); + Parse("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255)); + Parse("""{"nearest":{"x":1,"y":2},"nearest":{"z":3},"id":1}""") + .Should().Be(new CultCellular(new float4(0f, 0f, 3f, 0f), float4.zero, 1f)); + } + + [Test] + public void AllowDuplicatePropertiesFalse_RefusesRepeats() + { + var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties"); + var options = new JsonSerializerOptions().AddCultMathConverters(); + property!.SetValue(options, false); + + Action flat = () => JsonSerializer.Deserialize("""{"x":1,"x":2}""", options); + flat.Should().Throw(); + Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"x":2}}""", options); + nested.Should().Throw(); + JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void DictionaryKeys_ReadOnlyTheCanonicalSpelling() + { + foreach (var key in new[] { "1.0,2", "+1,2", " 1,2", "1, 2", "01,2", "1,2 ", "1e0,2", "1,2,", ",1" }) + { + Action read = () => Parse>($$"""{"{{key}}":1}"""); + read.Should().Throw(key); + } + + foreach (var key in new[] { "01,2", "+1,2", "1,-0" }) + { + Action read = () => Parse>($$"""{"{{key}}":1}"""); + read.Should().Throw(key); + } + + Action bools = () => Parse>("""{"True,false":1}"""); + bools.Should().Throw(); + Parse>("""{"1,2":1}""").Should().ContainKey(new float2(1f, 2f)); + } + + [Test] + public void NegativeZero_IsADistinctKey() + { + var negative = new float2(-0f, 0f); + KeyOf(negative).Should().Be("-0,0"); + KeyOf(float2.zero).Should().Be("0,0"); + KeyOf(new double2(0d, -0d)).Should().Be("0,-0"); + + var parsed = Parse>("""{"-0,0":1}""").Keys.Single(); + float.IsNegative(parsed.x).Should().BeTrue(); + var positive = Parse>("""{"0,0":1}""").Keys.Single(); + float.IsNegative(positive.x).Should().BeFalse(); + } + + [Test] + public void Culture_DoesNotChangeKeysOrValues() + { + var german = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + german.NumberFormat.NumberDecimalSeparator = ","; + german.NumberFormat.NumberGroupSeparator = "."; + german.NumberFormat.NegativeSign = "−"; + german.NumberFormat.PositiveSign = "➕"; + var swedish = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + swedish.NumberFormat.NegativeSign = "−"; + swedish.NumberFormat.NumberDecimalSeparator = ","; + + var original = CultureInfo.CurrentCulture; + try + { + foreach (var culture in new[] { german, swedish }) + { + CultureInfo.CurrentCulture = culture; + var options = With(ReadStrings | WriteStrings | Named); + + KeyOf(new float2(-1.5f, 2.25f)).Should().Be("-1.5,2.25"); + KeyOf(new double3(-1e-7, 1234.5, -0d)).Should().Be("-1E-07,1234.5,-0"); + KeyOf(new int2(-3, 4)).Should().Be("-3,4"); + KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity"); + Parse>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f)); + Parse>("""{"-3,4":1}""").Keys.Single().Should().Be(new int2(-3, 4)); + + JsonSerializer.Serialize(new float2(-1.5f, 2.25f), options).Should().Be("""{"x":"-1.5","y":"2.25"}"""); + JsonSerializer.Deserialize("""{"x":"-1.5","y":"2.25"}""", options).Should().Be(new float2(-1.5f, 2.25f)); + JsonSerializer.Serialize(new int2(-3, 4), options).Should().Be("""{"x":"-3","y":"4"}"""); + JsonSerializer.Deserialize("""{"x":"-3","y":"4"}""", options).Should().Be(new int2(-3, 4)); + Json(new float2(-1.5f, 2.25f)).Should().Be("""{"x":-1.5,"y":2.25}"""); + } + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value); + } +} diff --git a/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs new file mode 100644 index 00000000..32531679 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathSerializationTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using CultMath; +using MessagePack; +using MessagePack.Formatters; +using NUnit.Framework; + +[assembly: CultCacheFormatterResolver(typeof(GameCult.Caching.Tests.CultMathSerializationTests.Float2Override))] + +namespace GameCult.Caching.Tests +{ + public sealed class CultMathSerializationTests + { + private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options; + + private static string Hex(T value) => Convert.ToHexString(MessagePackSerializer.Serialize(value, Options)); + + private static T Decode(string hex) => MessagePackSerializer.Deserialize(Convert.FromHexString(hex), Options); + + [MessagePackObject] + public sealed class Carrier + { + [Key(0)] public float2 Position; + [Key(1)] public float3? Velocity; + [Key(2)] public float2[] Path = Array.Empty(); + [Key(3)] public List Cells = new(); + } + + // Bytes the pre-collapse CultVec2/CultVec3/CultRect encodings and Aetheria's Float2/Int2/Bool2/Float3/Float4 + // formatters produce: an array of components in declaration order, float32 as 0xCA. + [Test] + public void Float2_MatchesTheLegacyVectorBytes() + { + const string hex = "92CA3F800000CA40000000"; + Hex(new float2(1f, 2f)).Should().Be(hex); + Decode(hex).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void Float3_MatchesTheLegacyVectorBytes() + { + const string hex = "93CA3F800000CA40000000CA40400000"; + Hex(new float3(1f, 2f, 3f)).Should().Be(hex); + Decode(hex).Should().Be(new float3(1f, 2f, 3f)); + } + + [Test] + public void Rect_MatchesTheLegacyCultRectBytes() + { + const string hex = "9292CABF800000CAC000000092CA40A00000CA40E00000"; + Hex(new rect(5f, 7f, -1f, -2f)).Should().Be(hex); + Decode(hex).Should().Be(new rect(-1f, -2f, 5f, 7f)); + } + + [Test] + public void ConsumerFormatterShapes_MatchByteForByte() + { + Hex(new float4(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000"); + Hex(new int2(3, -4)).Should().Be("9203FC"); + Hex(new bool2(true, false)).Should().Be("92C3C2"); + } + + [Test] + public void Decode_SkipsExtraElements_AndLeavesMissingOnesZero() + { + Decode("93CA3F800000CA40000000CA40400000").Should().Be(new float2(1f, 2f)); + Decode("92CA3F800000CA40000000").Should().Be(new float3(1f, 2f, 0f)); + Decode("9392CABF800000CAC000000092CA40A00000CA40E0000001").Should().Be(new rect(-1f, -2f, 5f, 7f)); + } + + [Test] + public void Decode_RefusesNilAndNonArrays() + { + Action nil = () => Decode("C0"); + nil.Should().Throw(); + Action notArray = () => Decode("01"); + notArray.Should().Throw(); + } + + [Test] + public void EveryFormattedType_RoundTrips() + { + RoundTrip(new float2(1.5f, -2f)); + RoundTrip(new float3(1f, 2f, 3f)); + RoundTrip(new float4(1f, 2f, 3f, 4f)); + RoundTrip(new double2(1e300, -2.5)); + RoundTrip(new double3(1, 2, 3)); + RoundTrip(new int2(-7, 900000)); + RoundTrip(new int3(1, -2, 3)); + RoundTrip(new int4(1, 2, 3, int.MinValue)); + RoundTrip(new bool2(true, false)); + RoundTrip(new bool3(false, true, true)); + RoundTrip(new bool4(true, false, false, true)); + RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f)); + RoundTrip(new Color32(255, 128, 0, 7)); + RoundTrip(new CultMath.Random(12345u)); + RoundTrip(new float2x2(1f, 2f, 3f, 4f)); + RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)); + RoundTrip(new rect(-1f, -2f, 5f, 7f)); + RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f)); + } + + [Test] + public void Matrices_AreArraysOfRows() + { + Hex(new float2x2(1f, 2f, 3f, 4f)).Should().Be("9292CA3F800000CA4000000092CA40400000CA40800000"); + } + + + [Test] + public void EveryShape_IsPinnedByteForByte() + { + Hex(new double2(1d, 2d)).Should().Be("92CB3FF0000000000000CB4000000000000000"); + Hex(new double3(1d, 2d, 3d)).Should().Be("93CB3FF0000000000000CB4000000000000000CB4008000000000000"); + Hex(new int3(1, -2, 3)).Should().Be("9301FE03"); + Hex(new int4(1, 2, 3, -1)).Should().Be("94010203FF"); + Hex(new bool3(true, false, true)).Should().Be("93C3C2C3"); + Hex(new bool4(false, true, true, false)).Should().Be("94C2C3C3C2"); + Hex(new quaternion(1f, 2f, 3f, 4f)).Should().Be("94CA3F800000CA40000000CA40400000CA40800000"); + Hex(new Color32(255, 128, 0, 7)).Should().Be("94CCFFCC800007"); + Hex(new CultMath.Random(12345u)).Should().Be("91CD3039"); + Hex(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be( + "93" + + "93CA3F800000CA40000000CA40400000" + + "93CA40800000CA40A00000CA40C00000" + + "93CA40E00000CA41000000CA41100000"); + Hex(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( + "93" + + "94CA3F800000CA40000000CA40400000CA40800000" + + "94CA40A00000CA40C00000CA40E00000CA41000000" + + "CA3E800000"); + } + + [Test] + public void Color32_MissingAlphaDecodesAsOpaque() + { + Decode("93CCFFCC8000").Should().Be(new Color32(255, 128, 0, 255)); + Decode("94CCFFCC800007").Should().Be(new Color32(255, 128, 0, 7)); + } + + + [Test] + public void Random_StateZero_IsTheGeneratorDefaultSeed() + { + // CultMath's generator maps state 0 to its default seed, so a zero or missing state is not degenerate. + var reference = new CultMath.Random(0u); + var expected = new[] { reference.NextUInt(), reference.NextUInt(), reference.NextUInt() }; + + foreach (var hex in new[] { "9100", "90" }) + { + var decoded = Decode(hex); + new[] { decoded.NextUInt(), decoded.NextUInt(), decoded.NextUInt() }.Should().Equal(expected); + } + } + [Test] + public void ConsumerResolvers_WinOverCultMathResolver() + { + var overridden = CultDocumentMessagePackSerialization.OptionsFor(typeof(Float2Override).Assembly); + + MessagePackSerializer.Serialize(new float2(1f, 2f), overridden).Should().Equal(0xC3); + Hex(new float2(1f, 2f)).Should().Be("92CA3F800000CA40000000"); + } + + public sealed class Float2Override : IFormatterResolver + { + public static readonly Float2Override Instance = new(); + + public IMessagePackFormatter? GetFormatter() => + typeof(T) == typeof(float2) ? (IMessagePackFormatter)(object)Formatter.Instance : null; + + private sealed class Formatter : IMessagePackFormatter + { + public static readonly Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options) => writer.Write(true); + public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => throw new NotSupportedException(); + } + } + [Test] + public void EveryPublicCultMathValueType_HasAFormatter() + { + var valueTypes = typeof(float2).Assembly.GetExportedTypes() + .Where(t => t.IsValueType && !t.IsEnum) + .ToHashSet(); + + valueTypes.Except(CultMathResolver.FormattedTypes).Should().BeEmpty( + "every public CultMath value type serializes through CultMathResolver"); + CultMathResolver.FormattedTypes.Except(valueTypes).Should().BeEmpty(); + } + + [Test] + public void TheDefaultChain_ComposesMathIntoNullablesCollectionsAndDocuments() + { + var value = new Carrier + { + Position = new float2(1f, 2f), + Velocity = new float3(3f, 4f, 5f), + Path = new[] { new float2(0f, 1f), new float2(2f, 3f) }, + Cells = new List { new int2(1, 2) }, + }; + + var decoded = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(value, Options), Options); + + decoded.Position.Should().Be(value.Position); + decoded.Velocity.Should().Be(value.Velocity); + decoded.Path.Should().Equal(value.Path); + decoded.Cells.Should().Equal(value.Cells); + MessagePackSerializer.Serialize(new Carrier(), Options).Should().NotBeEmpty(); + + var absent = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(new Carrier(), Options), Options); + absent.Velocity.Should().BeNull(); + } + + private static void RoundTrip(T value) + { + var bytes = MessagePackSerializer.Serialize(value, Options); + MessagePackSerializer.Deserialize(bytes, Options).Should().Be(value); + } + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index 302f53f3..8741f891 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; @@ -19,54 +20,66 @@ namespace GameCult.Geometry.Tests [Test] public void GeometryPrimitives_CanonicalizeAndIntersect() { - var viewport = new CultRect(8f, 4f, -8f, -4f); + var viewport = new rect(8f, 4f, -8f, -4f); - viewport.Min.Should().Be(new CultVec2(-8f, -4f)); - viewport.Max.Should().Be(new CultVec2(8f, 4f)); - viewport.Center.Should().Be(CultVec2.Zero); - viewport.Contains(new CultVec2(2f, 3f)).Should().BeTrue(); - viewport.Intersects(new CultRect(7f, 3f, 10f, 5f)).Should().BeTrue(); - viewport.Intersects(new CultRect(9f, 5f, 10f, 6f)).Should().BeFalse(); + viewport.min.Should().Be(new float2(-8f, -4f)); + viewport.max.Should().Be(new float2(8f, 4f)); + viewport.center.Should().Be(float2.zero); + viewport.Contains(new float2(2f, 3f)).Should().BeTrue(); + viewport.Intersects(new rect(7f, 3f, 10f, 5f)).Should().BeTrue(); + viewport.Intersects(new rect(9f, 5f, 10f, 6f)).Should().BeFalse(); - var gravityBrush = new CultCircle(new CultVec2(10f, 0f), 3f); + var gravityBrush = new CultCircle(new float2(10f, 0f), 3f); gravityBrush.Intersects(viewport).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(12f, 0f)).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(14f, 0f)).Should().BeFalse(); + gravityBrush.Contains(new float2(12f, 0f)).Should().BeTrue(); + gravityBrush.Contains(new float2(14f, 0f)).Should().BeFalse(); } [Test] public void GeometryPrimitives_PreserveWholeVectorsForSoaAndPhysicsQueries() { - var sphere = new CultSphere(new CultVec3(1f, 2f, 3f), 5f); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); - sphere.Contains(new CultVec3(1f, 6f, 3f)).Should().BeTrue(); - sphere.Contains(new CultVec3(1f, 8f, 3f)).Should().BeFalse(); - sphere.Center.Xy.Should().Be(new CultVec2(1f, 2f)); - sphere.Center.Xz.Should().Be(new CultVec2(1f, 3f)); - sphere.XyCircle.Bounds.Should().Be(new CultRect(-4f, -3f, 6f, 7f)); - } - - [Test] - public void GeometryPrimitives_RoundTripThroughMessagePack() - { - var rect = new CultRect(new CultVec2(5f, -2f), new CultVec2(-1f, 7f)); - var payload = MessagePackSerializer.Serialize(rect); - var decoded = MessagePackSerializer.Deserialize(payload); - - decoded.Should().Be(rect); - decoded.Min.Should().Be(new CultVec2(-1f, -2f)); - decoded.Max.Should().Be(new CultVec2(5f, 7f)); + sphere.Contains(new float3(1f, 6f, 3f)).Should().BeTrue(); + sphere.Contains(new float3(1f, 8f, 3f)).Should().BeFalse(); + sphere.Center.xy.Should().Be(new float2(1f, 2f)); + sphere.Center.xz.Should().Be(new float2(1f, 3f)); + sphere.XyCircle.Bounds.Should().Be(new rect(-4f, -3f, 6f, 7f)); } [Test] public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() { - var json = JsonSerializer.Serialize(new CultVec3(1f, 2f, 3f)); + var options = new JsonSerializerOptions().AddCultMathConverters(); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); + var circle = new CultCircle(new float2(1f, 2f), 3f); - json.Should().Be("""{"X":1,"Y":2,"Z":3}"""); - json.Should().NotContain("LengthSquared"); - json.Should().NotContain("Xy"); - json.Should().NotContain("Xz"); + var sphereJson = JsonSerializer.Serialize(sphere, options); + var circleJson = JsonSerializer.Serialize(circle, options); + + sphereJson.Should().Be("""{"Center":{"x":1,"y":2,"z":3},"Radius":5}"""); + circleJson.Should().Be("""{"Center":{"x":1,"y":2},"Radius":3}"""); + JsonSerializer.Deserialize(sphereJson, options).Should().Be(sphere); + JsonSerializer.Deserialize(circleJson, options).Should().Be(circle); + } + + // The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9). + [Test] + public void GeometryPrimitives_KeepTheirPreCollapseBytes() + { + var options = CultDocumentMessagePackSerialization.Options; + var circle = new CultCircle(new float2(1f, 2f), 3f); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); + + Convert.ToHexString(MessagePackSerializer.Serialize(circle, options)) + .Should().Be("9292CA3F800000CA40000000CA40400000"); + Convert.ToHexString(MessagePackSerializer.Serialize(sphere, options)) + .Should().Be("9293CA3F800000CA40000000CA40400000CA40A00000"); + + MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(circle, options), options) + .Should().Be(circle); + MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(sphere, options), options) + .Should().Be(sphere); } [Test] diff --git a/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs index 63b794b3..52f7219e 100644 --- a/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs +++ b/tests/GameCult.Geometry.Tests/IsoSurfaceTests.cs @@ -1,4 +1,5 @@ using System; +using CultMath; using FluentAssertions; using NUnit.Framework; @@ -77,8 +78,8 @@ namespace GameCult.Geometry.Tests 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); + var first = CultGeometryIsoSurface.Extract(samples, origin: new float3(10f, 20f, 30f), cellSize: 2f); + var second = CultGeometryIsoSurface.Extract(samples, origin: new float3(10f, 20f, 30f), cellSize: 2f); second.Positions.Should().Equal(first.Positions); second.Normals.Should().Equal(first.Normals); diff --git a/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs b/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs index 3e3b3892..82c8fd43 100644 --- a/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs +++ b/tests/GameCult.Geometry.Tests/SurfaceNetsTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using CultMath; using FluentAssertions; using NUnit.Framework; @@ -73,28 +74,28 @@ namespace GameCult.Geometry.Tests for (var quad = 0; quad < mesh.QuadCount; quad++) { var edge = mesh.QuadEdges[quad]; - var insideCoord = new CultVec3(1f, 1f, 1f); - var edgeStart = new CultVec3(edge.X, edge.Y, edge.Z); + var insideCoord = new float3(1f, 1f, 1f); + var edgeStart = new float3(edge.X, edge.Y, edge.Z); var edgeEnd = OtherEndpoint(edge); // The single inside sample is (1,1,1); the edge's other endpoint is outside. var outsideCoord = edgeStart.Equals(insideCoord) ? edgeEnd : edgeStart; - var outward = Subtract(outsideCoord, insideCoord); + var outward = outsideCoord - insideCoord; var baseIndex = quad * 4; var v0 = Position(mesh.Positions, mesh.Quads[baseIndex]); var v1 = Position(mesh.Positions, mesh.Quads[baseIndex + 1]); var v2 = Position(mesh.Positions, mesh.Quads[baseIndex + 2]); var v3 = Position(mesh.Positions, mesh.Quads[baseIndex + 3]); - var cross = Cross(Subtract(v2, v0), Subtract(v3, v1)); + var cross = math.cross(v2 - v0, v3 - v1); // Restricted to non-degenerate (non-zero-area) quads: the spec's geometric // winding rule is only meaningful there. Degenerate/collapsed quads are covered // separately by AssertEdgeDirectionsBalance, since a zero cross product carries // no orientation information to check here. - if (cross.X == 0f && cross.Y == 0f && cross.Z == 0f) continue; + if (cross.Equals(float3.zero)) continue; - Dot(cross, outward).Should().BePositive($"quad for edge {edge} should wind outward"); + math.dot(cross, outward).Should().BePositive($"quad for edge {edge} should wind outward"); } } @@ -209,7 +210,7 @@ namespace GameCult.Geometry.Tests samples[1, 1, 1] = -1f; - var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(10f, 20f, 30f), cellSize: 2f); + var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new float3(10f, 20f, 30f), cellSize: 2f); mesh.VertexCount.Should().Be(1); mesh.Positions[0].Should().BeApproximately(10f + (2.9f / 3f * 2f), 1e-5f); @@ -387,10 +388,10 @@ namespace GameCult.Geometry.Tests // documented c0..c3 order is not swapped. var expected = new[] { - new CultVec3(1.5f, 0.5f, 0.5f), // c0 = (1, 0, 0) - new CultVec3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0) - new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) - new CultVec3(1.5f, 0.5f, 1.5f), // c3 = (1, 0, 1) + new float3(1.5f, 0.5f, 0.5f), // c0 = (1, 0, 0) + new float3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0) + new float3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new float3(1.5f, 0.5f, 1.5f), // c3 = (1, 0, 1) }; AssertQuadCorners(mesh, edge, expected); @@ -413,10 +414,10 @@ namespace GameCult.Geometry.Tests // v3 trade places relative to the raw c0..c3 listing. var expected = new[] { - new CultVec3(0.5f, 1.5f, 0.5f), // c0 = (0, 1, 0) - new CultVec3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1), swapped into v1 - new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) - new CultVec3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0), swapped into v3 + new float3(0.5f, 1.5f, 0.5f), // c0 = (0, 1, 0) + new float3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1), swapped into v1 + new float3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new float3(1.5f, 1.5f, 0.5f), // c1 = (1, 1, 0), swapped into v3 }; AssertQuadCorners(mesh, edge, expected); @@ -438,10 +439,10 @@ namespace GameCult.Geometry.Tests // mirrored, so the documented c0..c3 order is not swapped. var expected = new[] { - new CultVec3(0.5f, 0.5f, 1.5f), // c0 = (0, 0, 1) - new CultVec3(1.5f, 0.5f, 1.5f), // c1 = (1, 0, 1) - new CultVec3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) - new CultVec3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1) + new float3(0.5f, 0.5f, 1.5f), // c0 = (0, 0, 1) + new float3(1.5f, 0.5f, 1.5f), // c1 = (1, 0, 1) + new float3(1.5f, 1.5f, 1.5f), // c2 = (1, 1, 1) + new float3(0.5f, 1.5f, 1.5f), // c3 = (0, 1, 1) }; AssertQuadCorners(mesh, edge, expected); @@ -476,7 +477,7 @@ namespace GameCult.Geometry.Tests // balance regardless. var samples = Sphere(64); - var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(1e4f, 1e4f, 1e4f), cellSize: 0.001f); + var mesh = CultGeometrySurfaceNets.Extract(samples, origin: new float3(1e4f, 1e4f, 1e4f), cellSize: 0.001f); mesh.QuadCount.Should().BeGreaterThan(0); AssertEdgeDirectionsBalance(mesh); @@ -651,7 +652,7 @@ namespace GameCult.Geometry.Tests { var samples = new float[2, 2, 2]; - Action act = () => CultGeometrySurfaceNets.Extract(samples, origin: new CultVec3(x, y, z)); + Action act = () => CultGeometrySurfaceNets.Extract(samples, origin: new float3(x, y, z)); act.Should().Throw(); } @@ -725,28 +726,17 @@ namespace GameCult.Geometry.Tests return usage; } - private static CultVec3 OtherEndpoint(CultGeometryGridEdge edge) => edge.Axis switch + private static float3 OtherEndpoint(CultGeometryGridEdge edge) => edge.Axis switch { - 0 => new CultVec3(edge.X + 1, edge.Y, edge.Z), - 1 => new CultVec3(edge.X, edge.Y + 1, edge.Z), - _ => new CultVec3(edge.X, edge.Y, edge.Z + 1), + 0 => new float3(edge.X + 1, edge.Y, edge.Z), + 1 => new float3(edge.X, edge.Y + 1, edge.Z), + _ => new float3(edge.X, edge.Y, edge.Z + 1), }; - private static CultVec3 Subtract(CultVec3 left, CultVec3 right) => - new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); - - private static CultVec3 Cross(CultVec3 left, CultVec3 right) => new( - (left.Y * right.Z) - (left.Z * right.Y), - (left.Z * right.X) - (left.X * right.Z), - (left.X * right.Y) - (left.Y * right.X)); - - private static float Dot(CultVec3 left, CultVec3 right) => - (left.X * right.X) + (left.Y * right.Y) + (left.Z * right.Z); - - private static CultVec3 Position(float[] positions, uint index) + private static float3 Position(float[] positions, uint index) { var offset = (int)index * 3; - return new CultVec3(positions[offset], positions[offset + 1], positions[offset + 2]); + return new float3(positions[offset], positions[offset + 1], positions[offset + 2]); } // Hand-derived (double precision, independent of Interpolate) expected position for a @@ -760,7 +750,7 @@ namespace GameCult.Geometry.Tests return (float)(amount / 3d); } - private static void AssertQuadCorners(CultGeometryQuadMesh mesh, CultGeometryGridEdge edge, CultVec3[] expected) + private static void AssertQuadCorners(CultGeometryQuadMesh mesh, CultGeometryGridEdge edge, float3[] expected) { var quadIndex = Array.IndexOf(mesh.QuadEdges, edge); quadIndex.Should().BeGreaterThanOrEqualTo(0, $"a quad for edge {edge} should exist"); diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md index 88a083e1..8716d7e4 100644 --- a/unity/org.gamecult.cultlib/README.md +++ b/unity/org.gamecult.cultlib/README.md @@ -17,6 +17,15 @@ Install version `1.0.57` through Unity Package Manager with: https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57 ``` +The package depends on `org.gamecult.cultmath` (0.3.0 or later) and does not +carry its own `CultMath.dll`. Unity Package Manager resolves git packages only +from the project manifest, so add CultMath beside CultLib in +`Packages/manifest.json`: + +```text +"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0" +``` + CultMesh chooses mapped memory for reachable same-machine bodies and advertised QUIC for remote realtime state. Applications register the packaged `CultMeshNativeQuicRealtimeTransportConnector`; they do not load or configure diff --git a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef index bd13cb38..0ddbb9a6 100644 --- a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef +++ b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef @@ -8,6 +8,7 @@ "overrideReferences": true, "precompiledReferences": [ "ConcurrentCollections.dll", + "CultMath.dll", "GameCult.Caching.dll", "GameCult.Caching.MessagePack.dll", "GameCult.Logging.dll", diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index 722190cf..6545bed1 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -3,6 +3,9 @@ "displayName": "CultLib", "version": "1.0.60", "unity": "2021.3", + "dependencies": { + "org.gamecult.cultmath": "0.3.0" + }, "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT", "author": {