mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-04-17 21:07:56 -04:00
Serialization floating point inaccuracy fixes (#4493)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}" ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}" ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) > 1 - delta</param>
|
||||
/// <returns>True if nearly equal</returns>
|
||||
public readonly bool AlmostEqual( in Rotation r, float delta = 0.0001f )
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 )
|
||||
|
||||
@@ -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 )
|
||||
|
||||
@@ -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 )
|
||||
|
||||
@@ -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
|
||||
} );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
120
engine/Sandbox.Test.Unit/Math/EqualitySemantics.cs
Normal file
120
engine/Sandbox.Test.Unit/Math/EqualitySemantics.cs
Normal 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" );
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}" );
|
||||
}
|
||||
|
||||
|
||||
137
engine/Sandbox.Test/Scene/GameObjects/TransformStability.cs
Normal file
137
engine/Sandbox.Test/Scene/GameObjects/TransformStability.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user