Merge canonical CultMath serialization and the Geometry vector collapse (P4)
Every public CultMath value type serializes: MessagePack in the default resolver chain (byte-identical to Aetheria's and StreamPixels' copies across 653 values and all 115 float fields of the real Aetheria.cc) and System.Text.Json converters (STJ-parity number handling, math types as dictionary keys). CultVec2/3 and CultRect are gone with bytes unchanged. The cultlib Unity package depends on cultmath; the release gate refuses until CultMath 0.3.0 carries every type. Three Soul passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
69bd45ff14
21 changed files with 1625 additions and 300 deletions
|
|
@ -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<T> 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
|
||||
|
|
|
|||
30
scripts/check-cultmath-types.cs
Normal file
30
scripts/check-cultmath-types.cs
Normal file
|
|
@ -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 -- <assembly.dll> <TypeName>...
|
||||
// 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 <assembly.dll> <TypeName>...");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(args[0]);
|
||||
using var pe = new PEReader(stream);
|
||||
var metadata = pe.GetMetadataReader();
|
||||
var defined = new HashSet<string>();
|
||||
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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
516
src/GameCult.Caching.MessagePack/CultMathJson.cs
Normal file
516
src/GameCult.Caching.MessagePack/CultMathJson.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of
|
||||
/// <see cref="CultMathResolver"/>. Vectors, quaternions and colors are objects keyed by their
|
||||
/// field names (<c>{"x":1,"y":2}</c>), matrices are arrays of row objects, <c>rect</c> is
|
||||
/// <c>{"min":..,"max":..}</c>, and <c>CultCellular</c> is <c>{"nearest":..,"edge":..,"id":..}</c>.
|
||||
/// Property names are fixed: <c>PropertyNamingPolicy</c> does not rename them, and
|
||||
/// <c>PropertyNameCaseInsensitive</c> makes reading case-insensitive.
|
||||
/// <para>
|
||||
/// Reading skips unknown properties (<c>UnmappedMemberHandling.Disallow</c> refuses them) and
|
||||
/// leaves missing ones at zero (a missing <c>Color32.a</c> is 255). A repeated property replaces
|
||||
/// the whole earlier value, never merging component-wise, and is refused when
|
||||
/// <c>AllowDuplicateProperties</c> 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
|
||||
/// <see cref="JsonException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Numbers follow <see cref="JsonSerializerOptions.NumberHandling"/> as System.Text.Json's own
|
||||
/// float does. NaN and the infinities are the strings <c>"NaN"</c>, <c>"Infinity"</c>,
|
||||
/// <c>"-Infinity"</c>: written when <c>AllowNamedFloatingPointLiterals</c> or
|
||||
/// <c>WriteAsString</c> is set (otherwise <see cref="JsonException"/>), read when
|
||||
/// <c>AllowNamedFloatingPointLiterals</c> or <c>AllowReadingFromString</c> is set.
|
||||
/// <c>AllowReadingFromString</c> reads quoted numbers without surrounding whitespace, and
|
||||
/// <c>WriteAsString</c> writes quoted numbers. Two intended divergences: a bare number beyond
|
||||
/// the component's range (<c>1e40</c> as a float) is refused where System.Text.Json reads
|
||||
/// infinity, and refusals on write are <see cref="JsonException"/> where it throws
|
||||
/// <see cref="ArgumentException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every type is also a dictionary key: the flattened components joined by commas in
|
||||
/// declaration order, invariant culture, shortest round-trip floats (<c>"1,2"</c>,
|
||||
/// <c>"true,false"</c>, matrices row by row, <c>rect</c> as <c>"minX,minY,maxX,maxY"</c>).
|
||||
/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key.
|
||||
/// </para>
|
||||
/// Register with <see cref="AddCultMathConverters"/>; JSON options are the consumer's own.
|
||||
/// </summary>
|
||||
public static class CultMathJson
|
||||
{
|
||||
/// <summary>The CultMath types that have a converter, for coverage checks.</summary>
|
||||
public static IReadOnlyCollection<Type> ConvertedTypes => Converters.Keys;
|
||||
|
||||
/// <summary>Adds the CultMath converters to <paramref name="options"/> and returns it.</summary>
|
||||
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<S> where S : unmanaged
|
||||
{
|
||||
public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o);
|
||||
public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o);
|
||||
|
||||
/// <summary>The canonical key spelling, whatever the options allow.</summary>
|
||||
public abstract string Spelling(S value);
|
||||
|
||||
/// <summary>The key spelling, refusing what the options would refuse to write.</summary>
|
||||
public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value);
|
||||
|
||||
public abstract S Parse(string text, JsonSerializerOptions o);
|
||||
|
||||
/// <summary>Reads a key component; keys are always text, so AllowReadingFromString does not apply.</summary>
|
||||
public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o);
|
||||
}
|
||||
|
||||
private abstract class FloatingScalar<S> : Scalar<S> 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<float>
|
||||
{
|
||||
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<double>
|
||||
{
|
||||
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<S> : Scalar<S> 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<int>
|
||||
{
|
||||
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<uint>
|
||||
{
|
||||
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<byte>
|
||||
{
|
||||
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<bool>
|
||||
{
|
||||
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<S> where S : unmanaged
|
||||
{
|
||||
public int Count { get; protected set; }
|
||||
public abstract void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o);
|
||||
public abstract void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o);
|
||||
}
|
||||
|
||||
private sealed class Leaf<S> : Node<S> where S : unmanaged
|
||||
{
|
||||
public Leaf() { Count = 1; }
|
||||
|
||||
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o);
|
||||
|
||||
public override void Read(ref Utf8JsonReader r, scoped Span<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o);
|
||||
}
|
||||
|
||||
private sealed class Fields<S> : Node<S> where S : unmanaged
|
||||
{
|
||||
private readonly string[] names;
|
||||
private readonly byte[][] utf8Names;
|
||||
private readonly JsonEncodedText[] encoded;
|
||||
private readonly Node<S>[] children;
|
||||
private readonly int[] offsets;
|
||||
|
||||
public Fields(params (string Name, Node<S> Child)[] fields)
|
||||
{
|
||||
names = new string[fields.Length];
|
||||
utf8Names = new byte[fields.Length][];
|
||||
encoded = new JsonEncodedText[fields.Length];
|
||||
children = new Node<S>[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<S> flat, Scalar<S> 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<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> 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<S> : Node<S> where S : unmanaged
|
||||
{
|
||||
private readonly Node<S> row;
|
||||
private readonly int rows;
|
||||
|
||||
public Rows(int rows, Node<S> row)
|
||||
{
|
||||
this.rows = rows;
|
||||
this.row = row;
|
||||
Count = rows * row.Count;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter w, ReadOnlySpan<S> flat, Scalar<S> 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<S> flat, scoped ReadOnlySpan<S> defaults, Scalar<S> 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<T, S>(ReadOnlySpan<S> components) where S : unmanaged;
|
||||
private delegate void Flatten<T, S>(T value, Span<S> components) where S : unmanaged;
|
||||
|
||||
private sealed class Shape<T, S> : JsonConverter<T> where S : unmanaged
|
||||
{
|
||||
private readonly Scalar<S> scalar;
|
||||
private readonly Node<S> root;
|
||||
private readonly S[] defaults;
|
||||
private readonly Make<T, S> make;
|
||||
private readonly Flatten<T, S> flatten;
|
||||
|
||||
public Shape(Scalar<S> scalar, Node<S> root, Make<T, S> make, Flatten<T, S> 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<S> 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<S> 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<S> 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<S> 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<S> 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<S> Val<S>() where S : unmanaged => new Leaf<S>();
|
||||
|
||||
private static Node<S> Xy<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()));
|
||||
private static Node<S> Xyz<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()));
|
||||
private static Node<S> Xyzw<S>() where S : unmanaged => new Fields<S>(("x", Val<S>()), ("y", Val<S>()), ("z", Val<S>()), ("w", Val<S>()));
|
||||
|
||||
private static KeyValuePair<Type, JsonConverter> Add<T, S>(
|
||||
Scalar<S> scalar, Node<S> root, Make<T, S> make, Flatten<T, S> flatten, S[]? initial = null) where S : unmanaged =>
|
||||
new(typeof(T), new Shape<T, S>(scalar, root, make, flatten, initial));
|
||||
|
||||
private static readonly Dictionary<Type, JsonConverter> Converters = Build();
|
||||
|
||||
private static Dictionary<Type, JsonConverter> Build()
|
||||
{
|
||||
var f = FloatScalar.Instance;
|
||||
var shapes = new[]
|
||||
{
|
||||
Add<float2, float>(f, Xy<float>(), a => new float2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<float3, float>(f, Xyz<float>(), a => new float3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<float4, float>(f, Xyzw<float>(), 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<quaternion, float>(f, Xyzw<float>(), 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<double2, double>(DoubleScalar.Instance, Xy<double>(), a => new double2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<double3, double>(DoubleScalar.Instance, Xyz<double>(), a => new double3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<int2, int>(IntScalar.Instance, Xy<int>(), a => new int2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<int3, int>(IntScalar.Instance, Xyz<int>(), a => new int3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<int4, int>(IntScalar.Instance, Xyzw<int>(), 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<bool2, bool>(BoolScalar.Instance, Xy<bool>(), a => new bool2(a[0], a[1]), (v, a) => { a[0] = v.x; a[1] = v.y; }),
|
||||
Add<bool3, bool>(BoolScalar.Instance, Xyz<bool>(), a => new bool3(a[0], a[1], a[2]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; }),
|
||||
Add<bool4, bool>(BoolScalar.Instance, Xyzw<bool>(), 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<Color32, byte>(ByteScalar.Instance,
|
||||
new Fields<byte>(("r", Val<byte>()), ("g", Val<byte>()), ("b", Val<byte>()), ("a", Val<byte>())),
|
||||
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<Random, uint>(UIntScalar.Instance, new Fields<uint>(("state", Val<uint>())),
|
||||
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<float2x2, float>(f, new Rows<float>(2, Xy<float>()),
|
||||
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<float3x3, float>(f, new Rows<float>(3, Xyz<float>()),
|
||||
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<rect, float>(f, new Fields<float>(("min", Xy<float>()), ("max", Xy<float>())),
|
||||
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<CultCellular, float>(f, new Fields<float>(("nearest", Xyzw<float>()), ("edge", Xyzw<float>()), ("id", Val<float>())),
|
||||
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<Type, JsonConverter>();
|
||||
foreach (var shape in shapes) map.Add(shape.Key, shape.Value);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
230
src/GameCult.Caching.MessagePack/CultMathResolver.cs
Normal file
230
src/GameCult.Caching.MessagePack/CultMathResolver.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>rect</c> is <c>[min, max]</c>. 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 <see cref="CultDocumentMessagePackSerialization"/> chain, so a consumer
|
||||
/// registers nothing.
|
||||
/// </summary>
|
||||
public sealed class CultMathResolver : IFormatterResolver
|
||||
{
|
||||
public static readonly CultMathResolver Instance = new();
|
||||
private CultMathResolver() { }
|
||||
|
||||
/// <summary>The CultMath types this resolver formats, for coverage checks.</summary>
|
||||
public static IReadOnlyCollection<Type> FormattedTypes => Formatters.Keys;
|
||||
|
||||
public IMessagePackFormatter<T>? GetFormatter<T>() => Cache<T>.Formatter;
|
||||
|
||||
private static class Cache<T>
|
||||
{
|
||||
public static readonly IMessagePackFormatter<T>? Formatter =
|
||||
Formatters.TryGetValue(typeof(T), out var formatter) ? (IMessagePackFormatter<T>)formatter : null;
|
||||
}
|
||||
|
||||
private delegate void WriteFn<T>(ref MessagePackWriter writer, T value);
|
||||
private delegate T ReadFn<T>(ref MessagePackReader reader);
|
||||
|
||||
private sealed class ArrayShape<T> : IMessagePackFormatter<T>
|
||||
{
|
||||
private readonly WriteFn<T> write;
|
||||
private readonly ReadFn<T> read;
|
||||
|
||||
public ArrayShape(WriteFn<T> write, ReadFn<T> 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<Type, object> Shape<T>(WriteFn<T> write, ReadFn<T> read) =>
|
||||
new(typeof(T), new ArrayShape<T>(write, read));
|
||||
|
||||
// Each reader takes the first dst.Length elements and skips the rest.
|
||||
private static void Singles(ref MessagePackReader r, scoped Span<float> 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<double> 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<int> 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<bool> 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<byte> 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<float> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan<double> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan<int> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan<bool> v)
|
||||
{
|
||||
w.WriteArrayHeader(v.Length);
|
||||
foreach (var e in v) w.Write(e);
|
||||
}
|
||||
|
||||
private static void Put(ref MessagePackWriter w, scoped ReadOnlySpan<byte> 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<float> s = stackalloc float[2];
|
||||
Singles(ref r, s);
|
||||
return new float2(s[0], s[1]);
|
||||
}
|
||||
|
||||
private static float3 GetFloat3(ref MessagePackReader r)
|
||||
{
|
||||
Span<float> 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<float> s = stackalloc float[4];
|
||||
Singles(ref r, s);
|
||||
return new float4(s[0], s[1], s[2], s[3]);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<Type, object> Formatters = Build();
|
||||
|
||||
private static Dictionary<Type, object> Build()
|
||||
{
|
||||
var shapes = new[]
|
||||
{
|
||||
Shape<float2>(PutFloat2, GetFloat2),
|
||||
Shape<float3>(PutFloat3, GetFloat3),
|
||||
Shape<float4>(PutFloat4, GetFloat4),
|
||||
Shape<double2>((ref MessagePackWriter w, double2 v) => Put(ref w, stackalloc double[] { v.x, v.y }),
|
||||
(ref MessagePackReader r) => { Span<double> s = stackalloc double[2]; Doubles(ref r, s); return new double2(s[0], s[1]); }),
|
||||
Shape<double3>((ref MessagePackWriter w, double3 v) => Put(ref w, stackalloc double[] { v.x, v.y, v.z }),
|
||||
(ref MessagePackReader r) => { Span<double> s = stackalloc double[3]; Doubles(ref r, s); return new double3(s[0], s[1], s[2]); }),
|
||||
Shape<int2>((ref MessagePackWriter w, int2 v) => Put(ref w, stackalloc int[] { v.x, v.y }),
|
||||
(ref MessagePackReader r) => { Span<int> s = stackalloc int[2]; Ints(ref r, s); return new int2(s[0], s[1]); }),
|
||||
Shape<int3>((ref MessagePackWriter w, int3 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z }),
|
||||
(ref MessagePackReader r) => { Span<int> s = stackalloc int[3]; Ints(ref r, s); return new int3(s[0], s[1], s[2]); }),
|
||||
Shape<int4>((ref MessagePackWriter w, int4 v) => Put(ref w, stackalloc int[] { v.x, v.y, v.z, v.w }),
|
||||
(ref MessagePackReader r) => { Span<int> s = stackalloc int[4]; Ints(ref r, s); return new int4(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<bool2>((ref MessagePackWriter w, bool2 v) => Put(ref w, stackalloc bool[] { v.x, v.y }),
|
||||
(ref MessagePackReader r) => { Span<bool> s = stackalloc bool[2]; Bools(ref r, s); return new bool2(s[0], s[1]); }),
|
||||
Shape<bool3>((ref MessagePackWriter w, bool3 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z }),
|
||||
(ref MessagePackReader r) => { Span<bool> s = stackalloc bool[3]; Bools(ref r, s); return new bool3(s[0], s[1], s[2]); }),
|
||||
Shape<bool4>((ref MessagePackWriter w, bool4 v) => Put(ref w, stackalloc bool[] { v.x, v.y, v.z, v.w }),
|
||||
(ref MessagePackReader r) => { Span<bool> s = stackalloc bool[4]; Bools(ref r, s); return new bool4(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<quaternion>((ref MessagePackWriter w, quaternion v) => Put(ref w, stackalloc float[] { v.x, v.y, v.z, v.w }),
|
||||
(ref MessagePackReader r) => { Span<float> s = stackalloc float[4]; Singles(ref r, s); return new quaternion(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<Color32>((ref MessagePackWriter w, Color32 v) => Put(ref w, stackalloc byte[] { v.r, v.g, v.b, v.a }),
|
||||
(ref MessagePackReader r) => { Span<byte> s = stackalloc byte[4]; s[3] = 255; Bytes(ref r, s); return new Color32(s[0], s[1], s[2], s[3]); }),
|
||||
Shape<Random>((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<float2x2>((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<float3x3>((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<rect>((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<CultCellular>((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<Type, object>();
|
||||
foreach (var shape in shapes) map.Add(shape.Key, shape.Value);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,9 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ format for the attribute-first `GameCult.Caching` stack.
|
|||
- `CultDocumentMessagePackSerialization`
|
||||
- `CultDocumentResolver`
|
||||
- `CultRecordRefFormatter<T>`
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -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<float>();
|
||||
var normals = new List<float>();
|
||||
var indices = new List<uint>();
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -1,197 +1,10 @@
|
|||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using CultMath;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public readonly struct CultVec2 : IEquatable<CultVec2>
|
||||
{
|
||||
/// <summary>Creates a two-dimensional vector.</summary>
|
||||
public CultVec2(float x, float y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
|
||||
/// <summary>Gets the x component.</summary>
|
||||
[Key(0)]
|
||||
public float X { get; }
|
||||
|
||||
/// <summary>Gets the y component.</summary>
|
||||
[Key(1)]
|
||||
public float Y { get; }
|
||||
|
||||
/// <summary>Gets the zero vector.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public static CultVec2 Zero => new(0f, 0f);
|
||||
|
||||
/// <summary>Gets the squared vector length.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public float LengthSquared => (X * X) + (Y * Y);
|
||||
|
||||
/// <summary>Returns whether this vector is equal to another vector.</summary>
|
||||
public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => HashCode.Combine(X, Y);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public readonly struct CultVec3 : IEquatable<CultVec3>
|
||||
{
|
||||
/// <summary>Creates a three-dimensional vector.</summary>
|
||||
public CultVec3(float x, float y, float z)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Z = z;
|
||||
}
|
||||
|
||||
/// <summary>Gets the x component.</summary>
|
||||
[Key(0)]
|
||||
public float X { get; }
|
||||
|
||||
/// <summary>Gets the y component.</summary>
|
||||
[Key(1)]
|
||||
public float Y { get; }
|
||||
|
||||
/// <summary>Gets the z component.</summary>
|
||||
[Key(2)]
|
||||
public float Z { get; }
|
||||
|
||||
/// <summary>Gets the zero vector.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public static CultVec3 Zero => new(0f, 0f, 0f);
|
||||
|
||||
/// <summary>Gets the XY plane projection.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public CultVec2 Xy => new(X, Y);
|
||||
|
||||
/// <summary>Gets the XZ plane projection for Unity compatibility adapters.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public CultVec2 Xz => new(X, Z);
|
||||
|
||||
/// <summary>Gets the squared vector length.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public float LengthSquared => (X * X) + (Y * Y) + (Z * Z);
|
||||
|
||||
/// <summary>Returns whether this vector is equal to another vector.</summary>
|
||||
public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => HashCode.Combine(X, Y, Z);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned two-dimensional rectangle represented by canonical min and max corners.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public readonly struct CultRect : IEquatable<CultRect>
|
||||
{
|
||||
/// <summary>Creates a rectangle and canonicalizes min/max corners.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Creates a rectangle and canonicalizes min/max corners.</summary>
|
||||
public CultRect(float minX, float minY, float maxX, float maxY)
|
||||
: this(new CultVec2(minX, minY), new CultVec2(maxX, maxY))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Gets the canonical minimum corner.</summary>
|
||||
[Key(0)]
|
||||
public CultVec2 Min { get; }
|
||||
|
||||
/// <summary>Gets the canonical maximum corner.</summary>
|
||||
[Key(1)]
|
||||
public CultVec2 Max { get; }
|
||||
|
||||
/// <summary>Gets the rectangle size.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public CultVec2 Size => Max - Min;
|
||||
|
||||
/// <summary>Gets the rectangle center.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public CultVec2 Center => (Min + Max) * 0.5f;
|
||||
|
||||
/// <summary>Returns whether the point lies inside or on the rectangle edge.</summary>
|
||||
public bool Contains(CultVec2 point)
|
||||
{
|
||||
return point.X >= Min.X &&
|
||||
point.X <= Max.X &&
|
||||
point.Y >= Min.Y &&
|
||||
point.Y <= Max.Y;
|
||||
}
|
||||
|
||||
/// <summary>Returns whether this rectangle intersects another rectangle.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Returns whether this rectangle is equal to another rectangle.</summary>
|
||||
public bool Equals(CultRect other) => Min == other.Min && Max == other.Max;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is CultRect other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => HashCode.Combine(Min, Max);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-dimensional circle for spatial queries.
|
||||
/// </summary>
|
||||
|
|
@ -199,7 +12,8 @@ namespace GameCult.Geometry
|
|||
public readonly struct CultCircle : IEquatable<CultCircle>
|
||||
{
|
||||
/// <summary>Creates a circle.</summary>
|
||||
public CultCircle(CultVec2 center, float radius)
|
||||
[JsonConstructor]
|
||||
public CultCircle(float2 center, float radius)
|
||||
{
|
||||
if (radius < 0f)
|
||||
{
|
||||
|
|
@ -212,7 +26,7 @@ namespace GameCult.Geometry
|
|||
|
||||
/// <summary>Gets the circle center.</summary>
|
||||
[Key(0)]
|
||||
public CultVec2 Center { get; }
|
||||
public float2 Center { get; }
|
||||
|
||||
/// <summary>Gets the circle radius.</summary>
|
||||
[Key(1)]
|
||||
|
|
@ -221,29 +35,29 @@ namespace GameCult.Geometry
|
|||
/// <summary>Gets the circle bounds.</summary>
|
||||
[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);
|
||||
|
||||
/// <summary>Returns whether the point lies inside or on the circle edge.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Returns whether this circle intersects a rectangle.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Returns whether this circle is equal to another circle.</summary>
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is CultCircle other && Equals(other);
|
||||
|
|
@ -262,7 +76,8 @@ namespace GameCult.Geometry
|
|||
public readonly struct CultSphere : IEquatable<CultSphere>
|
||||
{
|
||||
/// <summary>Creates a sphere.</summary>
|
||||
public CultSphere(CultVec3 center, float radius)
|
||||
[JsonConstructor]
|
||||
public CultSphere(float3 center, float radius)
|
||||
{
|
||||
if (radius < 0f)
|
||||
{
|
||||
|
|
@ -275,7 +90,7 @@ namespace GameCult.Geometry
|
|||
|
||||
/// <summary>Gets the sphere center.</summary>
|
||||
[Key(0)]
|
||||
public CultVec3 Center { get; }
|
||||
public float3 Center { get; }
|
||||
|
||||
/// <summary>Gets the sphere radius.</summary>
|
||||
[Key(1)]
|
||||
|
|
@ -284,17 +99,17 @@ namespace GameCult.Geometry
|
|||
/// <summary>Gets the XY circle projection.</summary>
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public CultCircle XyCircle => new(Center.Xy, Radius);
|
||||
public CultCircle XyCircle => new(Center.xy, Radius);
|
||||
|
||||
/// <summary>Returns whether the point lies inside or on the sphere surface.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Returns whether this sphere is equal to another sphere.</summary>
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is CultSphere other && Equals(other);
|
||||
|
|
|
|||
|
|
@ -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<float>();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
```
|
||||
|
|
|
|||
|
|
@ -407,8 +407,8 @@ var view = new CultMeshNativeSliceViewDescriptor(
|
|||
rowCount,
|
||||
new[]
|
||||
{
|
||||
CultMeshNativeSliceColumn.For<CultVec2>("position"),
|
||||
CultMeshNativeSliceColumn.For<CultVec2>("velocity")
|
||||
CultMeshNativeSliceColumn.For<float2>("position"),
|
||||
CultMeshNativeSliceColumn.For<float2>("velocity")
|
||||
},
|
||||
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory));
|
||||
```
|
||||
|
|
|
|||
415
tests/GameCult.Caching.Tests/CultMathJsonTests.cs
Normal file
415
tests/GameCult.Caching.Tests/CultMathJsonTests.cs
Normal file
|
|
@ -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>(T value) => JsonSerializer.Serialize(value, Options);
|
||||
|
||||
private static T Parse<T>(string json) => JsonSerializer.Deserialize<T>(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<float3>("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f));
|
||||
Parse<Color32>("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255));
|
||||
Parse<float2?>("null").Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reading_RefusesShapesThatAreNotTheType()
|
||||
{
|
||||
Action array = () => Parse<float2>("[1,2]");
|
||||
array.Should().Throw<JsonException>();
|
||||
Action shortMatrix = () => Parse<float2x2>("""[{"x":1,"y":2}]""");
|
||||
shortMatrix.Should().Throw<JsonException>();
|
||||
Action nil = () => Parse<rect>("null");
|
||||
nil.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[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<JsonException>();
|
||||
Action inf = () => JsonSerializer.Serialize(new double3(1d, double.PositiveInfinity, 0d), Options);
|
||||
inf.Should().Throw<JsonException>();
|
||||
Action named = () => JsonSerializer.Deserialize<float2>("""{"x":"NaN","y":1}""", Options);
|
||||
named.Should().Throw<JsonException>();
|
||||
|
||||
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<float3>("""{"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<double2>("""{"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<float2>(json);
|
||||
tooBig.Should().Throw<JsonException>(json);
|
||||
}
|
||||
|
||||
Action doubleTooBig = () => Parse<double2>("""{"x":1e400}""");
|
||||
doubleTooBig.Should().Throw<JsonException>();
|
||||
Action alpha = () => Parse<Color32>("""{"r":300}""");
|
||||
alpha.Should().Throw<JsonException>();
|
||||
Action negativeByte = () => Parse<Color32>("""{"g":-1}""");
|
||||
negativeByte.Should().Throw<JsonException>();
|
||||
Action fraction = () => Parse<int2>("""{"x":1.5}""");
|
||||
fraction.Should().Throw<JsonException>();
|
||||
Action wrongToken = () => Parse<float2>("""{"x":"a"}""");
|
||||
wrongToken.Should().Throw<JsonException>();
|
||||
Action boolean = () => Parse<bool2>("""{"x":1}""");
|
||||
boolean.Should().Throw<JsonException>();
|
||||
Action negativeState = () => Parse<CultMath.Random>("""{"state":-1}""");
|
||||
negativeState.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[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<float2>("""{"x":"1.5","y":2}""", With(JsonNumberHandling.AllowReadingFromString))
|
||||
.Should().Be(new float2(1.5f, 2f));
|
||||
JsonSerializer.Deserialize<int2>("""{"x":"7","y":2}""", With(JsonNumberHandling.AllowReadingFromString))
|
||||
.Should().Be(new int2(7, 2));
|
||||
Action strict = () => Parse<float2>("""{"x":"1.5"}""");
|
||||
strict.Should().Throw<JsonException>();
|
||||
Action tooBig = () => JsonSerializer.Deserialize<float2>("""{"x":"1e40"}""", With(JsonNumberHandling.AllowReadingFromString));
|
||||
tooBig.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PropertyNames_FollowTheCaseSensitivityOption()
|
||||
{
|
||||
JsonSerializer.Deserialize<float2>("""{"X":1,"Y":2}""", With(ignoreCase: true)).Should().Be(new float2(1f, 2f));
|
||||
JsonSerializer.Deserialize<rect>("""{"MIN":{"X":1,"y":2},"Max":{"x":3,"Y":4}}""", With(ignoreCase: true))
|
||||
.Should().Be(new rect(1f, 2f, 3f, 4f));
|
||||
Parse<float2>("""{"X":1,"Y":2}""").Should().Be(float2.zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryShape_WorksAsADictionaryKey()
|
||||
{
|
||||
Json(new Dictionary<int2, string> { [new int2(1, -2)] = "a" }).Should().Be("""{"1,-2":"a"}""");
|
||||
Parse<Dictionary<int2, string>>("""{"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<Dictionary<float2, int>>("""{"NaN,Infinity":1}""", With(Named))!;
|
||||
nan.Keys.Single().y.Should().Be(float.PositiveInfinity);
|
||||
|
||||
Action refuseNonFinite = () => KeyOf(new float2(float.NaN, 0f));
|
||||
refuseNonFinite.Should().Throw<JsonException>();
|
||||
Action wrongArity = () => Parse<Dictionary<float3, int>>("""{"1,2":1}""");
|
||||
wrongArity.Should().Throw<JsonException>();
|
||||
Action overflow = () => Parse<Dictionary<float2, int>>("""{"1e40,0":1}""");
|
||||
overflow.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[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>(T value, JsonNumberHandling handling = JsonNumberHandling.Strict) where T : notnull
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new Dictionary<T, int> { [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<float2>("""{"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<float2>("""{"x":"NaN"}""", With(handling));
|
||||
read.Should().Throw<JsonException>(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<JsonException>(handling.ToString());
|
||||
}
|
||||
|
||||
JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}""");
|
||||
JsonSerializer.Deserialize<double3>("""{"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<float2>(json, options);
|
||||
floatRead.Should().Throw<JsonException>(json);
|
||||
}
|
||||
|
||||
Action doubleRead = () => JsonSerializer.Deserialize<double2>("""{"y":" 2"}""", options);
|
||||
doubleRead.Should().Throw<JsonException>();
|
||||
Action intRead = () => JsonSerializer.Deserialize<int2>("""{"x":" 1"}""", options);
|
||||
intRead.Should().Throw<JsonException>();
|
||||
Action padded = () => JsonSerializer.Deserialize<float2>("""{"x":" NaN"}""", With(ReadStrings | Named));
|
||||
padded.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[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<float3>(json).Should().Be(expected, json);
|
||||
}
|
||||
|
||||
Parse<rect>("""{"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<float2x2>("""[{"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<float2>("""{"x":1,"z":3}""", options);
|
||||
flat.Should().Throw<JsonException>();
|
||||
Action nested = () => JsonSerializer.Deserialize<rect>("""{"min":{"x":1,"q":1},"max":{}}""", options);
|
||||
nested.Should().Throw<JsonException>();
|
||||
JsonSerializer.Deserialize<float2>("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DuplicateProperties_ReplaceTheWholeValue()
|
||||
{
|
||||
Parse<float2>("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f));
|
||||
Parse<rect>("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f));
|
||||
Parse<Color32>("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1));
|
||||
Parse<Color32>("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255));
|
||||
Parse<CultCellular>("""{"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<float2>("""{"x":1,"x":2}""", options);
|
||||
flat.Should().Throw<JsonException>();
|
||||
Action nested = () => JsonSerializer.Deserialize<rect>("""{"min":{"x":1,"x":2}}""", options);
|
||||
nested.Should().Throw<JsonException>();
|
||||
JsonSerializer.Deserialize<float2>("""{"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<Dictionary<float2, int>>($$"""{"{{key}}":1}""");
|
||||
read.Should().Throw<JsonException>(key);
|
||||
}
|
||||
|
||||
foreach (var key in new[] { "01,2", "+1,2", "1,-0" })
|
||||
{
|
||||
Action read = () => Parse<Dictionary<int2, int>>($$"""{"{{key}}":1}""");
|
||||
read.Should().Throw<JsonException>(key);
|
||||
}
|
||||
|
||||
Action bools = () => Parse<Dictionary<bool2, int>>("""{"True,false":1}""");
|
||||
bools.Should().Throw<JsonException>();
|
||||
Parse<Dictionary<float2, int>>("""{"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<Dictionary<float2, int>>("""{"-0,0":1}""").Keys.Single();
|
||||
float.IsNegative(parsed.x).Should().BeTrue();
|
||||
var positive = Parse<Dictionary<float2, int>>("""{"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<Dictionary<float2, int>>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f));
|
||||
Parse<Dictionary<int2, int>>("""{"-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<float2>("""{"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<int2>("""{"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>(T value) => Parse<T>(Json(value)).Should().Be(value);
|
||||
}
|
||||
}
|
||||
222
tests/GameCult.Caching.Tests/CultMathSerializationTests.cs
Normal file
222
tests/GameCult.Caching.Tests/CultMathSerializationTests.cs
Normal file
|
|
@ -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>(T value) => Convert.ToHexString(MessagePackSerializer.Serialize(value, Options));
|
||||
|
||||
private static T Decode<T>(string hex) => MessagePackSerializer.Deserialize<T>(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<float2>();
|
||||
[Key(3)] public List<int2> 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<float2>(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<float3>(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<rect>(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<float2>("93CA3F800000CA40000000CA40400000").Should().Be(new float2(1f, 2f));
|
||||
Decode<float3>("92CA3F800000CA40000000").Should().Be(new float3(1f, 2f, 0f));
|
||||
Decode<rect>("9392CABF800000CAC000000092CA40A00000CA40E0000001").Should().Be(new rect(-1f, -2f, 5f, 7f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Decode_RefusesNilAndNonArrays()
|
||||
{
|
||||
Action nil = () => Decode<float2>("C0");
|
||||
nil.Should().Throw<MessagePackSerializationException>();
|
||||
Action notArray = () => Decode<float3>("01");
|
||||
notArray.Should().Throw<MessagePackSerializationException>();
|
||||
}
|
||||
|
||||
[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<Color32>("93CCFFCC8000").Should().Be(new Color32(255, 128, 0, 255));
|
||||
Decode<Color32>("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<CultMath.Random>(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<T>? GetFormatter<T>() =>
|
||||
typeof(T) == typeof(float2) ? (IMessagePackFormatter<T>)(object)Formatter.Instance : null;
|
||||
|
||||
private sealed class Formatter : IMessagePackFormatter<float2>
|
||||
{
|
||||
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<int2> { new int2(1, 2) },
|
||||
};
|
||||
|
||||
var decoded = MessagePackSerializer.Deserialize<Carrier>(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<Carrier>(MessagePackSerializer.Serialize(new Carrier(), Options), Options);
|
||||
absent.Velocity.Should().BeNull();
|
||||
}
|
||||
|
||||
private static void RoundTrip<T>(T value)
|
||||
{
|
||||
var bytes = MessagePackSerializer.Serialize(value, Options);
|
||||
MessagePackSerializer.Deserialize<T>(bytes, Options).Should().Be(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CultRect>(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<CultSphere>(sphereJson, options).Should().Be(sphere);
|
||||
JsonSerializer.Deserialize<CultCircle>(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<CultCircle>(MessagePackSerializer.Serialize(circle, options), options)
|
||||
.Should().Be(circle);
|
||||
MessagePackSerializer.Deserialize<CultSphere>(MessagePackSerializer.Serialize(sphere, options), options)
|
||||
.Should().Be(sphere);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ArgumentException>();
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"ConcurrentCollections.dll",
|
||||
"CultMath.dll",
|
||||
"GameCult.Caching.dll",
|
||||
"GameCult.Caching.MessagePack.dll",
|
||||
"GameCult.Logging.dll",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue