Serialization floating point inaccuracy fixes (#4493)

This commit is contained in:
Lorenz Junglas
2026-04-09 08:46:42 +02:00
committed by GitHub
parent afb663d16d
commit 7b853ce569
17 changed files with 382 additions and 33 deletions

View File

@@ -371,10 +371,20 @@ public abstract partial class Collider : Component, Component.ExecuteInEditor, C
if ( !IsProxy )
{
var tx = go.WorldTransform;
tx.Position = body.Position;
tx.Rotation = body.Rotation;
go.WorldTransform = tx;
var currentWorldTx = go.WorldTransform;
// Only snap the GameObject to the physics body if there's a meaningful difference.
// A naive world→local round-trip loses precision at large world coordinates, which
// would introduce phantom transform overrides in prefab instances.
// Position: 1cm tolerance. Rotation: dot-product threshold (1 - 1e-6 ≈ 0.16°).
if ( !currentWorldTx.Position.AlmostEqual( body.Position, 0.01f ) ||
!currentWorldTx.Rotation.AlmostEqual( body.Rotation, 0.000001f ) )
{
currentWorldTx.Position = body.Position;
currentWorldTx.Rotation = body.Rotation;
go.WorldTransform = currentWorldTx;
}
go.Transform.ClearLocalInterpolation();
}

View File

@@ -752,7 +752,10 @@ public partial class GameObject
tx.Position = node[JsonKeys.Position]?.Deserialize<Vector3>() ?? Vector3.Zero;
tx.Rotation = node[JsonKeys.Rotation]?.Deserialize<Rotation>() ?? Rotation.Identity;
tx.Scale = node[JsonKeys.Scale]?.Deserialize<Vector3>() ?? Vector3.One;
LocalTransform = tx;
// Use exact (bitwise) equality to avoid Vector3.operator== swallowing tiny
// differences within its 0.0001 AlmostEqual tolerance during deserialization.
Transform.SetLocalTransformExact( tx );
}
}

View File

@@ -145,6 +145,27 @@ public partial class GameTransform
return true;
}
/// <summary>
/// Sets the local transform with exact (bitwise) equality checks, bypassing Vector3's
/// approximate AlmostEqual operator. Use during deserialization so that tiny floating-point
/// values (e.g. -7.2e-05) are not silently swallowed by the 0.0001 tolerance.
/// </summary>
internal void SetLocalTransformExact( in Transform value )
{
// Exact bitwise comparison — avoid Vector3/Rotation operator== which use AlmostEqual
if ( _targetLocal.Position.Equals( value.Position )
&& _targetLocal.Rotation.Equals( value.Rotation )
&& _targetLocal.Scale.Equals( value.Scale ) )
return;
_hasPositionSet = true;
_interpolatedLocal = value;
_targetLocal = value;
_positionBuffer?.Clear();
_rotationBuffer?.Clear();
TransformChanged();
}
/// <summary>
/// The target world transform. For internal use only.

View File

@@ -55,7 +55,7 @@ namespace Sandbox.Internal.JsonConvert
public override void Write( Utf8JsonWriter writer, Angles val, JsonSerializerOptions options )
{
writer.WriteStringValue( $"{val.pitch:0.####},{val.yaw:0.####},{val.roll:0.####}" );
writer.WriteStringValue( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{val.pitch:G9},{val.yaw:G9},{val.roll:G9}" ) );
}
}
}

View File

@@ -128,7 +128,7 @@ public partial struct RangedFloat
return Range == RangeType.Between ? SandboxSystem.Random.Float( Min, Max ) : Min;
}
[GeneratedRegex( """^[\[\]\s"]*(?<min>-?\d+(?:\.\d+)?)(?:[\s,;]+(?<max>-?\d+(?:\.\d+)?))?(?:[\s,;]+(?<format>\d+))?[\[\]\s"]*$""" )]
[GeneratedRegex( """^[\[\]\s"]*(?<min>-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?:[\s,;]+(?<max>-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?))?(?:[\s,;]+(?<format>\d+))?[\[\]\s"]*$""" )]
private static partial Regex Pattern();
private static float? ParseOptionalFloat( Group group )
@@ -181,8 +181,8 @@ public partial struct RangedFloat
{
return Range switch
{
RangeType.Fixed => Min.ToString( "R", CultureInfo.InvariantCulture ),
RangeType.Between => FormattableString.Invariant( $"{Min:R} {Max:R}" ),
RangeType.Fixed => Min.ToString( "G9", CultureInfo.InvariantCulture ),
RangeType.Between => FormattableString.Invariant( $"{Min:G9} {Max:G9}" ),
_ => "0"
};
}

View File

@@ -56,7 +56,7 @@ namespace Sandbox.Internal.JsonConvert
public override void Write( Utf8JsonWriter writer, Rotation val, JsonSerializerOptions options )
{
writer.WriteStringValue( $"{val.x:0.#################################},{val.y:0.#################################},{val.z:0.#################################},{val.w:0.#################################}" );
writer.WriteStringValue( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{val.x:G9},{val.y:G9},{val.z:G9},{val.w:G9}" ) );
}
}
}

View File

@@ -667,17 +667,17 @@ public struct Rotation : System.IEquatable<Rotation>, IParsable<Rotation>, IInte
#endregion
#region equality
public static bool operator ==( Rotation left, Rotation right ) => left.Equals( right );
public static bool operator !=( Rotation left, Rotation right ) => !(left == right);
public static bool operator ==( Rotation left, Rotation right ) => left.AlmostEqual( right );
public static bool operator !=( Rotation left, Rotation right ) => !left.AlmostEqual( right );
public readonly override bool Equals( object obj ) => obj is Rotation o && Equals( o );
public readonly bool Equals( Rotation o ) => _quat.X.AlmostEqual( o._quat.X, 0.000001f ) && _quat.Y.AlmostEqual( o._quat.Y, 0.000001f ) && _quat.Z.AlmostEqual( o._quat.Z, 0.000001f ) && _quat.W.AlmostEqual( o._quat.W, 0.000001f );
public readonly override int GetHashCode() => HashCode.Combine( _quat );
public readonly bool Equals( Rotation o ) => _quat.Equals( o._quat );
public readonly override int GetHashCode() => _quat.GetHashCode();
/// <summary>
/// Returns true if we're nearly equal to the passed rotation.
/// </summary>
/// <param name="r">The value to compare with</param>
/// <param name="delta">The max difference between component values</param>
/// <param name="delta">Dot-product threshold: rotations are equal when Dot(a, b) &gt; 1 - delta</param>
/// <returns>True if nearly equal</returns>
public readonly bool AlmostEqual( in Rotation r, float delta = 0.0001f )
{

View File

@@ -345,10 +345,10 @@ public struct Transform : System.IEquatable<Transform>, IInterpolator<Transform>
}
#region equality
public static bool operator ==( Transform left, Transform right ) => left.Equals( right );
public static bool operator !=( Transform left, Transform right ) => !(left == right);
public static bool operator ==( Transform left, Transform right ) => left.AlmostEqual( right );
public static bool operator !=( Transform left, Transform right ) => !left.AlmostEqual( right );
public readonly override bool Equals( object obj ) => obj is Transform o && Equals( o );
public readonly bool Equals( Transform o ) => (Position, Scale, Rotation) == (o.Position, o.Scale, o.Rotation);
public readonly bool Equals( Transform o ) => Position.Equals( o.Position ) && Scale.Equals( o.Scale ) && Rotation.Equals( o.Rotation );
public readonly override int GetHashCode() => HashCode.Combine( Position, Scale, Rotation );
/// <summary>

View File

@@ -74,7 +74,7 @@ namespace Sandbox.Internal.JsonConvert
public override void Write( Utf8JsonWriter writer, Vector2 val, JsonSerializerOptions options )
{
writer.WriteStringValue( $"{val.x:0.#################################},{val.y:0.#################################}" );
writer.WriteStringValue( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{val.x:G9},{val.y:G9}" ) );
}
public override Vector2 ReadAsPropertyName( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
{
@@ -83,7 +83,7 @@ namespace Sandbox.Internal.JsonConvert
public override void WriteAsPropertyName( Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options )
{
writer.WritePropertyName( value.ToString() );
writer.WritePropertyName( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{value.x:G9},{value.y:G9}" ) );
}
public override bool CanConvert( Type typeToConvert )

View File

@@ -55,7 +55,7 @@ namespace Sandbox.Internal.JsonConvert
public override void Write( Utf8JsonWriter writer, Vector3 val, JsonSerializerOptions options )
{
writer.WriteStringValue( $"{val.x:0.#################################},{val.y:0.#################################},{val.z:0.#################################}" );
writer.WriteStringValue( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{val.x:G9},{val.y:G9},{val.z:G9}" ) );
}
public override Vector3 ReadAsPropertyName( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
@@ -65,7 +65,7 @@ namespace Sandbox.Internal.JsonConvert
public override void WriteAsPropertyName( Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options )
{
writer.WritePropertyName( value.ToString() );
writer.WritePropertyName( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{value.x:G9},{value.y:G9},{value.z:G9}" ) );
}
public override bool CanConvert( Type typeToConvert )

View File

@@ -56,7 +56,7 @@ namespace Sandbox.Internal.JsonConvert
public override void Write( Utf8JsonWriter writer, Vector4 val, JsonSerializerOptions options )
{
writer.WriteStringValue( val.ToString() );
writer.WriteStringValue( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{val.x:G9},{val.y:G9},{val.z:G9},{val.w:G9}" ) );
}
public override Vector4 ReadAsPropertyName( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
@@ -66,7 +66,7 @@ namespace Sandbox.Internal.JsonConvert
public override void WriteAsPropertyName( Utf8JsonWriter writer, Vector4 value, JsonSerializerOptions options )
{
writer.WritePropertyName( value.ToString() );
writer.WritePropertyName( string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{value.x:G9},{value.y:G9},{value.z:G9},{value.w:G9}" ) );
}
public override bool CanConvert( Type typeToConvert )

View File

@@ -8,7 +8,7 @@ public class Serialization
var json = Json.Serialize( obj );
var obj_from = Json.Deserialize<T>( json );
Assert.AreEqual( obj.ToString(), obj_from.ToString() ); // comparing via string to avoid expected accuracy issues
Assert.AreEqual( obj, obj_from );
}
[TestMethod]
@@ -19,6 +19,16 @@ public class Serialization
RoundTripTest( new Vector3( -1, 0, 0 ) );
RoundTripTest( new Vector3( -10, -10, -10 ) );
RoundTripTest( new Vector3( 10030.12f, 1000.543f, 1340.1234f ) );
// Fuzz with a fixed seed to catch precision loss across a wide range of float values
var rng = new System.Random( 42 );
for ( var i = 0; i < 1000; i++ )
{
var x = (float)(rng.NextDouble() * 20000.0 - 10000.0);
var y = (float)(rng.NextDouble() * 20000.0 - 10000.0);
var z = (float)(rng.NextDouble() * 20000.0 - 10000.0);
RoundTripTest( new Vector3( x, y, z ) );
}
}
[TestMethod]
@@ -29,6 +39,13 @@ public class Serialization
RoundTripTest( new Vector3Int( -1, 0, 0 ) );
RoundTripTest( new Vector3Int( -10, -10, -10 ) );
RoundTripTest( new Vector3Int( 0, -1, 0 ) );
// Fuzz with a fixed seed for broad integer coverage
var rng = new System.Random( 42 );
for ( var i = 0; i < 1000; i++ )
{
RoundTripTest( new Vector3Int( rng.Next( -10000, 10000 ), rng.Next( -10000, 10000 ), rng.Next( -10000, 10000 ) ) );
}
}
[TestMethod]
@@ -36,6 +53,10 @@ public class Serialization
{
RoundTripTest( new Angles( 0, 0, 0 ) );
RoundTripTest( new Angles( 180, 12, 45 ) );
var rng = new System.Random( 42 );
for ( var i = 0; i < 1000; i++ )
RoundTripTest( new Angles( (float)(rng.NextDouble() * 360 - 180), (float)(rng.NextDouble() * 360 - 180), (float)(rng.NextDouble() * 360 - 180) ) );
}
[TestMethod]
@@ -46,6 +67,10 @@ public class Serialization
RoundTripTest( new Vector2( -180.3f, 12.234f ) );
RoundTripTest( new Vector2( -180.3f, -12.234f ) );
RoundTripTest( new Vector2( -134534680.553f, -13453456.2434f ) );
var rng = new System.Random( 42 );
for ( var i = 0; i < 1000; i++ )
RoundTripTest( new Vector2( (float)(rng.NextDouble() * 20000 - 10000), (float)(rng.NextDouble() * 20000 - 10000) ) );
}
[TestMethod]
@@ -56,6 +81,10 @@ public class Serialization
RoundTripTest( new Vector2Int( -1, 0 ) );
RoundTripTest( new Vector2Int( -10, -10 ) );
RoundTripTest( new Vector2Int( 0, -1 ) );
var rng = new System.Random( 42 );
for ( var i = 0; i < 1000; i++ )
RoundTripTest( new Vector2Int( rng.Next( -10000, 10000 ), rng.Next( -10000, 10000 ) ) );
}
[TestMethod]
@@ -64,6 +93,10 @@ public class Serialization
RoundTripTest( Rotation.FromAxis( Vector3.Up, 45 ) );
RoundTripTest( Rotation.FromAxis( Vector3.Up, -45 ) );
RoundTripTest( Rotation.FromAxis( Vector3.Up + Vector3.Right, -45 ) );
var rng = new System.Random( 42 );
for ( var i = 0; i < 1000; i++ )
RoundTripTest( Rotation.FromAxis( new Vector3( (float)(rng.NextDouble() * 2 - 1), (float)(rng.NextDouble() * 2 - 1), (float)(rng.NextDouble() * 2 - 1) ), (float)(rng.NextDouble() * 360) ) );
}
[TestMethod]
@@ -99,4 +132,6 @@ public class Serialization
// UpVector is default
} );
}
}

View File

@@ -0,0 +1,120 @@
namespace MathTest;
/// <summary>
/// Formalizes the equality contract for math types that use approximate == and exact Equals:
/// - operator == -> AlmostEqual (approximate, for gameplay comparisons)
/// - Equals() -> exact bitwise (for serialization, hashing, dictionary keys)
///
/// This ensures we don't accidentally regress either direction:
/// - Making == exact would break gameplay code that relies on tolerance
/// - Making Equals approximate would break serialization round-trip checks
/// </summary>
[TestClass]
public class EqualitySemantics
{
[TestMethod]
public void Vector3_OperatorEquals_IsApproximate()
{
var a = new Vector3( 1, 2, 3 );
var b = new Vector3( 1, 2, 3 + 5e-5f );
Assert.IsTrue( a == b, "operator == should use AlmostEqual and treat tiny differences as equal" );
Assert.IsFalse( a != b );
}
[TestMethod]
public void Vector3_Equals_IsExact()
{
var a = new Vector3( 1, 2, 3 );
var b = new Vector3( 1, 2, 3 + 5e-5f );
Assert.IsFalse( a.Equals( b ), "Equals should be exact bitwise and reject any difference" );
}
[TestMethod]
public void Vector3_Equals_IdenticalValues()
{
var a = new Vector3( 1, 2, 3 );
var b = new Vector3( 1, 2, 3 );
Assert.IsTrue( a == b );
Assert.IsTrue( a.Equals( b ) );
}
[TestMethod]
public void Rotation_OperatorEquals_IsApproximate()
{
var a = Rotation.Identity;
var b = Rotation.Identity;
// Nudge one quaternion component by a tiny amount within tolerance
b._quat.X += 1e-5f;
Assert.IsTrue( a == b, "operator == should use AlmostEqual and treat tiny differences as equal" );
Assert.IsFalse( a != b );
}
[TestMethod]
public void Rotation_Equals_IsExact()
{
var a = Rotation.Identity;
var b = Rotation.Identity;
b._quat.X += 1e-5f;
Assert.IsFalse( a.Equals( b ), "Equals should be exact bitwise and reject any difference" );
}
[TestMethod]
public void Rotation_Equals_IdenticalValues()
{
var a = Rotation.FromAxis( Vector3.Up, 45 );
var b = Rotation.FromAxis( Vector3.Up, 45 );
Assert.IsTrue( a == b );
Assert.IsTrue( a.Equals( b ) );
}
[TestMethod]
public void Transform_OperatorEquals_IsApproximate()
{
var a = new Transform( new Vector3( 100, 200, 300 ), Rotation.Identity, 1 );
var b = new Transform( new Vector3( 100, 200, 300 + 5e-5f ), Rotation.Identity, 1 );
Assert.IsTrue( a == b, "operator == should use AlmostEqual and treat tiny differences as equal" );
Assert.IsFalse( a != b );
}
[TestMethod]
public void Transform_Equals_IsExact()
{
var a = new Transform( new Vector3( 100, 200, 300 ), Rotation.Identity, 1 );
var b = new Transform( new Vector3( 100, 200, 300 + 5e-5f ), Rotation.Identity, 1 );
Assert.IsFalse( a.Equals( b ), "Equals should be exact bitwise and reject any difference" );
}
[TestMethod]
public void Transform_Equals_IdenticalValues()
{
var a = new Transform( new Vector3( 100, 200, 300 ), Rotation.FromAxis( Vector3.Up, 90 ), 2 );
var b = new Transform( new Vector3( 100, 200, 300 ), Rotation.FromAxis( Vector3.Up, 90 ), 2 );
Assert.IsTrue( a == b );
Assert.IsTrue( a.Equals( b ) );
}
/// <summary>
/// The exact scenario that caused phantom prefab overrides: a value below
/// the 0.0001 AlmostEqual tolerance must be distinguishable via Equals.
/// </summary>
[TestMethod]
public void Transform_Equals_DetectsSubToleranceDrift()
{
var prefab = new Transform( new Vector3( 226, -4446, -7.247925E-05f ), Rotation.Identity, 1 );
var instance = new Transform( new Vector3( 226, -4446, 0 ), Rotation.Identity, 1 );
Assert.IsTrue( prefab == instance, "operator == should consider these approximately equal" );
Assert.IsFalse( prefab.Equals( instance ), "Equals must detect the sub-tolerance difference" );
}
}

View File

@@ -34,6 +34,6 @@ public class MatrixTest
var mat = Matrix.FromTransform( transform );
var tx = mat.ExtractTransform();
Assert.AreEqual( transform, tx );
Assert.IsTrue( transform.AlmostEqual( tx ) );
}
}

View File

@@ -47,7 +47,7 @@ public class RangedFloatTest
[TestMethod]
[DataRow( 1f, null, "1" )]
[DataRow( 1f, 1f, "1 1" )]
[DataRow( 0.19851673f, null, "0.19851673" )]
[DataRow( 0.19851673f, null, "0.198516726" )]
public void TestToString( float min, float? max, string str )
{
var range = max is null ? new RangedFloat( min ) : new RangedFloat( min, max.Value );
@@ -71,5 +71,28 @@ public class RangedFloatTest
Assert.AreEqual( src.ToString(), dst.ToString() );
}
}
/// <summary>
/// G9 format can produce scientific notation (e.g. 7.247925E-05).
/// Verify Parse handles these and the ToString/Parse round-trip contract holds.
/// </summary>
[TestMethod]
[DataRow( 7.247925E-05f, null )]
[DataRow( -7.247925E-05f, null )]
[DataRow( 1.23456789E+10f, null )]
[DataRow( -9.99999944E-11f, null )]
[DataRow( 7.247925E-05f, 1.5f )]
[DataRow( -1E-06f, 1E+06f )]
public void ScientificNotationRoundTrip( float min, float? max )
{
var src = max is null ? new RangedFloat( min ) : new RangedFloat( min, max.Value );
var str = src.ToString();
var dst = RangedFloat.Parse( str );
Assert.AreEqual( src.Min, dst.Min, $"Min mismatch: \"{str}\"" );
Assert.AreEqual( src.Max, dst.Max, $"Max mismatch: \"{str}\"" );
Assert.AreEqual( src.Range, dst.Range, $"Range type mismatch: \"{str}\"" );
Assert.AreEqual( str, dst.ToString(), $"Double round-trip mismatch" );
}
}

View File

@@ -20,8 +20,8 @@ public class TransformTest
var childWorldTest = parent.ToWorld( childLocal );
Assert.IsTrue( (childWorldTest.Position - childWorld.Position).Length < 0.0001f );
Assert.AreEqual( childWorldTest.Rotation, childWorld.Rotation );
Assert.AreEqual( childWorldTest.Scale, childWorld.Scale );
Assert.IsTrue( childWorldTest.Rotation.AlmostEqual( childWorld.Rotation ) );
Assert.IsTrue( childWorldTest.Scale.AlmostEqual( childWorld.Scale ) );
}
[TestMethod]
@@ -42,8 +42,8 @@ public class TransformTest
var childWorldTest = parent.ToWorld( childLocal );
Assert.IsTrue( (childWorldTest.Position - childWorld.Position).Length < 0.0001f );
Assert.AreEqual( childWorldTest.Rotation, childWorld.Rotation );
Assert.AreEqual( childWorldTest.Scale, childWorld.Scale );
Assert.IsTrue( childWorldTest.Rotation.AlmostEqual( childWorld.Rotation ) );
Assert.IsTrue( childWorldTest.Scale.AlmostEqual( childWorld.Scale ) );
}
[TestMethod]
@@ -56,8 +56,8 @@ public class TransformTest
var childWorldTest = parent.ToWorld( childLocal );
Assert.AreEqual( childWorldTest.Rotation, childWorld.Rotation );
Assert.AreEqual( childWorldTest.Scale, childWorld.Scale );
Assert.IsTrue( childWorldTest.Rotation.AlmostEqual( childWorld.Rotation ) );
Assert.IsTrue( childWorldTest.Scale.AlmostEqual( childWorld.Scale ) );
Assert.IsTrue( (childWorldTest.Position - childWorld.Position).Length < 0.001f, $"{(childWorldTest.Position - childWorld.Position).Length}" );
}

View File

@@ -0,0 +1,137 @@
using Sandbox.Internal;
using System.Collections.Generic;
using System.Linq;
namespace GameObjects;
/// <summary>
/// Ensures that serialize → deserialize round-trips with IsRefreshing
/// do not silently alter local transforms. Components that do world -> local
/// conversions during deserialization are the typical offenders.
/// </summary>
[TestClass]
public class TransformStability
{
TypeLibrary TypeLibrary;
TypeLibrary _oldTypeLibrary;
[TestInitialize]
public void TestInitialize()
{
_oldTypeLibrary = Game.TypeLibrary;
TypeLibrary = new TypeLibrary();
TypeLibrary.AddAssembly( typeof( ModelRenderer ).Assembly, false );
TypeLibrary.AddAssembly( typeof( TransformStability ).Assembly, false );
Game.TypeLibrary = TypeLibrary;
}
[TestCleanup]
public void Cleanup() => Game.TypeLibrary = _oldTypeLibrary;
[TestMethod]
public void RefreshDeserializePreservesTransform()
{
var componentTypes = TypeLibrary.GetTypes<Component>()
.Where( t => t.TargetType.IsPublic && !t.IsAbstract && !t.IsGenericType )
// PlayerController/MoveMode modify WorldRotation in OnEnabled only when !Scene.IsEditor.
// Prefab refresh runs in the editor so these are safe — exclude from test.
.Where( t => !typeof( PlayerController ).IsAssignableFrom( t.TargetType ) )
.Where( t => !typeof( Sandbox.Movement.MoveMode ).IsAssignableFrom( t.TargetType ) )
.ToList();
Assert.IsTrue( componentTypes.Count > 10 );
var failures = new List<string>();
foreach ( var (parentPos, childLocal) in GenerateTestCases() )
foreach ( var type in componentTypes )
if ( TestComponentRefresh( type, parentPos, childLocal ) is { } err )
failures.Add( err );
if ( failures.Count > 0 )
Assert.Fail( $"{failures.Count} component(s) drifted:\n{string.Join( "\n", failures.Take( 20 ) )}" );
}
/// <summary>
/// Known-bad cases plus seeded random fuzz across many magnitudes.
/// </summary>
static IEnumerable<(Vector3 parentPos, Transform childLocal)> GenerateTestCases()
{
// Static cases that reproduce known real-world issues
Vector3[] parentPositions = [new( 100_000, 80_000, 5_000 ), new( -50_000, -120_000, 3_000 )];
Transform[] childTransforms =
[
new( new Vector3( 1580, 811.0145f, -2 ), Rotation.Identity, 1 ),
new( new Vector3( -2689.168f, 2076.5f, 5.839f ), Rotation.FromAxis( Vector3.Up, 45 ), 1 ),
new( new Vector3( 0.5f, -0.25f, 100 ), Rotation.Identity, 2 ),
// Values near/below the 0.0001 AlmostEqual tolerance — these triggered phantom overrides
new( new Vector3( 226, -4446, -7.247925E-05f ), Rotation.Identity, 1 ),
new( new Vector3( 2468.001f, 1865.999f, -4.424155E-05f ), Rotation.Identity, 1 ),
new( new Vector3( 0, 0, 1e-5f ), Rotation.Identity, 1 ),
new( new Vector3( 5000, 3000, 9.9e-5f ), Rotation.FromAxis( Vector3.Up, 90 ), 1 ),
];
foreach ( var p in parentPositions )
foreach ( var c in childTransforms )
yield return (p, c);
// Seeded fuzz: near-zero, small, medium, large, very large magnitudes
var rng = new System.Random( 42 );
float[] magnitudes = [1e-5f, 0.001f, 0.1f, 1f, 100f, 5000f, 50_000f, 200_000f];
float[] scales = [0.5f, 1f, 1f, 1f, 2f, 5f];
for ( var i = 0; i < 50; i++ )
{
float RandRange() => (float)(rng.NextDouble() * 2 - 1);
var pm = magnitudes[rng.Next( magnitudes.Length )];
var cm = magnitudes[rng.Next( magnitudes.Length )];
var parentPos = new Vector3( RandRange() * pm * 10, RandRange() * pm * 10, RandRange() * pm );
var childPos = new Vector3( RandRange() * cm, RandRange() * cm, RandRange() * cm );
var childRot = Rotation.FromAxis(
new Vector3( (float)rng.NextDouble(), (float)rng.NextDouble(), (float)rng.NextDouble() ).Normal,
(float)(rng.NextDouble() * 360)
);
yield return (parentPos, new Transform( childPos, childRot, scales[rng.Next( scales.Length )] ));
}
}
static string TestComponentRefresh( TypeDescription componentType, Vector3 parentPos, Transform childLocal )
{
var scene = new Scene();
using var sceneScope = scene.Push();
try
{
var parent = scene.CreateObject();
parent.Name = "Parent";
parent.LocalPosition = parentPos;
var child = new GameObject( parent, true, "Child" );
child.LocalTransform = childLocal;
// Capture what was actually stored, the setter uses AlmostEqual(0.0001) which
// may coalesce tiny component values. We test the serialize -> deserialize invariant:
// whatever is stored must survive a round-trip exactly.
var before = child.LocalTransform;
try { if ( child.Components.Create( componentType ) is null ) return null; }
catch { return null; }
var json = child.Serialize();
using ( CallbackBatch.Isolated() )
child.Deserialize( json, new GameObject.DeserializeOptions { IsRefreshing = true } );
var after = child.LocalTransform;
if ( after.Position.Equals( before.Position ) && after.Rotation.Equals( before.Rotation ) && after.Scale.Equals( before.Scale ) )
return null;
var delta = after.Position - before.Position;
return $"{componentType.Name} @ parent={parentPos}: expected={before.Position:R} got={after.Position:R} delta={delta} (mag={delta.Length})";
}
catch { return null; }
}
}