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:
Meta Vens 2026-09-29 18:34:09 +02:00
commit 69bd45ff14
21 changed files with 1625 additions and 300 deletions

View file

@ -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

View 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;

View file

@ -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);
}

View 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;
}
}

View 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;
}
}

View file

@ -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>

View file

@ -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.

View file

@ -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];

View file

@ -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);

View file

@ -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);

View file

@ -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.

View file

@ -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?;
```

View file

@ -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));
```

View 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);
}
}

View 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);
}
}
}

View file

@ -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]

View file

@ -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);

View file

@ -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");

View file

@ -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

View file

@ -8,6 +8,7 @@
"overrideReferences": true,
"precompiledReferences": [
"ConcurrentCollections.dll",
"CultMath.dll",
"GameCult.Caching.dll",
"GameCult.Caching.MessagePack.dll",
"GameCult.Logging.dll",

View file

@ -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": {