using System.Collections.Generic; using System.Linq; namespace SceneTests.Components; [TestClass] public class PolygonMeshSerializationTest { /// /// Old format with Position, Rotation, and world-space texture parameters. /// const string LegacyMeshJson = """ { "Topology": "H4sIAAAAAAAACl2QCQ7CMAwEp4Qj3NBy/f+laMRGsohUVbFn1+t04ANs+Z0VsM69AwfgBFyAKZ9MPXdgk3qLnsI+Um/h/me9i2ZXfFvYOazafTJN0XRgKZmPyWtfXvYVL73PZRd52WdYPa7xtC8veys7u6t17/7t6Tn0ZtFjvIXZR895zjKjPWf4NiOrGt+ivpXZ3XnozaOndVn9vvVkkTjEAQAA", "Position": "4.695496,-71.68547,64", "Rotation": "0,0,0,1", "Positions": [ "-26.3793,-152.6447,64", "26.3793,-152.6447,64", "26.3793,152.6447,64", "-26.3793,152.6447,64", "-26.3793,152.6447,-64", "26.3793,152.6447,-64", "26.3793,-152.6447,-64", "-26.3793,-152.6447,-64" ], "Blends": ["0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0"], "Colors": ["0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0","0,0,0,0"], "TextureCoord": [ "0.2427719,1.752579", "-0.1694047,-1", "0.2427719,-0.632494", "-1.752579,-1", "-0.1694047,-0.632494", "-0.2427719,-1", "-0.1694047,1.752579", "-0.632494,-1", "0.2427719,-0.632494", "0.1694047,0", "0.2427719,1.752579", "0.632494,0", "-0.1694047,1.752579", "0.2427719,0", "-0.1694047,-0.632494", "1.752579,0", "1.752579,-1", "-0.1694047,0", "-0.632494,0", "0.1694047,-1", "-1.752579,0", "0.2427719,-1", "0.632494,-1", "-0.2427719,0" ], "TextureUAxis": ["1,0,0","1,0,-0","0,-1,0","0,1,0","-1,0,0","1,-0,0"], "TextureVAxis": ["0,-1,0","0,-1,0","0,0,-1","-0,0,-1","0,0,-1","-0,0,-1"], "TextureScale": ["0.25,0.25","0.25,0.25","0.25,0.25","0.25,0.25","0.25,0.25","0.25,0.25"], "TextureOffset": ["0,0","0,0","0,0","0,0","0,0","0,0"], "MaterialIndex": [-1,-1,-1,-1,-1,-1], "EdgeFlags": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] } """; [TestMethod] public void ReadLegacyFormatWithWorldSpaceFields() { var mesh = Json.Deserialize( LegacyMeshJson ); Assert.IsNotNull( mesh ); Assert.AreEqual( new Vector3( 4.695496f, -71.68547f, 64f ), mesh.Transform.Position ); Assert.AreEqual( 8, mesh.VertexHandles.Count() ); } [TestMethod] public void WriteDoesNotContainWorldSpaceFields() { var mesh = Json.Deserialize( LegacyMeshJson ); var json = Json.Serialize( mesh ); Assert.IsFalse( json.Contains( "\"Position\"" ), "Should not write world-space Position" ); Assert.IsFalse( json.Contains( "\"Rotation\"" ), "Should not write world-space Rotation" ); Assert.IsFalse( json.Contains( "\"TextureUAxis\"" ), "Should not write derived TextureUAxis" ); Assert.IsFalse( json.Contains( "\"TextureVAxis\"" ), "Should not write derived TextureVAxis" ); Assert.IsFalse( json.Contains( "\"TextureScale\"" ), "Should not write derived TextureScale" ); Assert.IsFalse( json.Contains( "\"TextureOffset\"" ), "Should not write derived TextureOffset" ); } [TestMethod] public void WritesBinaryData() { var mesh = Json.Deserialize( LegacyMeshJson ); var json = Json.Serialize( mesh ); Assert.IsTrue( json.Contains( "\"Data\"" ) ); foreach ( var stream in new[] { "Topology", "Positions", "Blends", "Colors", "TextureCoord", "MaterialIndex", "EdgeFlags" } ) Assert.IsFalse( json.Contains( $"\"{stream}\"" ), $"{stream} should be packed into the binary blob, not written as a JSON array" ); } [TestMethod] public void RoundTripPreservesUvs() { var original = Json.Deserialize( LegacyMeshJson ); var restored = Json.Deserialize( Json.Serialize( original ) ); var transform = new Transform( new Vector3( 32, -16, 8 ), Rotation.FromYaw( 30 ) ); original.Transform = transform; restored.Transform = transform; var originalFaces = original.FaceHandles.ToArray(); var restoredFaces = restored.FaceHandles.ToArray(); Assert.AreEqual( original.VertexHandles.Count(), restored.VertexHandles.Count() ); Assert.AreEqual( originalFaces.Length, restoredFaces.Length ); for ( int i = 0; i < originalFaces.Length; i++ ) { original.GetFaceTextureParameters( originalFaces[i], out var u1, out var v1, out var s1 ); restored.GetFaceTextureParameters( restoredFaces[i], out var u2, out var v2, out var s2 ); Assert.AreEqual( u1, u2, $"Face {i} TextureUAxis changed through the blob" ); Assert.AreEqual( v1, v2, $"Face {i} TextureVAxis changed through the blob" ); Assert.AreEqual( s1, s2, $"Face {i} TextureScale changed through the blob" ); } } [TestMethod] public void BlobContextWritesReference() { var mesh = Json.Deserialize( LegacyMeshJson ); var inlineJson = Json.Serialize( mesh ); using var blobs = BlobDataSerializer.Capture(); var referencedJson = Json.Serialize( mesh ); Assert.IsTrue( referencedJson.Contains( "$blob" ), "An active context should reference the blob by guid" ); Assert.IsTrue( referencedJson.Length < inlineJson.Length, "Referencing the sidecar should be smaller than embedding base64 inline" ); var restored = Json.Deserialize( referencedJson ); Assert.AreEqual( mesh.VertexHandles.Count(), restored.VertexHandles.Count() ); Assert.AreEqual( mesh.FaceHandles.Count(), restored.FaceHandles.Count() ); } [TestMethod] public void RoundTripPreservesData() { var original = Json.Deserialize( LegacyMeshJson ); var json = Json.Serialize( original ); var restored = Json.Deserialize( json ); Assert.AreEqual( original.VertexHandles.Count(), restored.VertexHandles.Count() ); Assert.AreEqual( original.FaceHandles.Count(), restored.FaceHandles.Count() ); } [TestMethod] public void RoundTripIsDeterministic() { var mesh = Json.Deserialize( LegacyMeshJson ); var json1 = Json.Serialize( mesh ); var json2 = Json.Serialize( mesh ); Assert.AreEqual( json1, json2 ); } [TestMethod] public void ResaveDoesNotDrift() { var mesh = Json.Deserialize( LegacyMeshJson ); var json1 = Json.Serialize( mesh ); var mesh2 = Json.Deserialize( json1 ); var json2 = Json.Serialize( mesh2 ); Assert.AreEqual( json1, json2, "Second serialize should be identical — no drift from world-space recomputation" ); } [TestMethod] public void TextureParametersRestoredAfterTransformSet() { var original = Json.Deserialize( LegacyMeshJson ); var json = Json.Serialize( original ); var worldTransform = new Transform( new Vector3( 100, 200, 300 ), Rotation.FromYaw( 45 ) ); // Deserialize twice from new format (no world-space fields) var mesh1 = Json.Deserialize( json ); var mesh2 = Json.Deserialize( json ); // This is what MeshComponent.TransformChanged does: Mesh.Transform = WorldTransform mesh1.Transform = worldTransform; mesh2.Transform = worldTransform; // Both should produce identical texture parameters from the same UVs + transform var faces1 = mesh1.FaceHandles.ToArray(); var faces2 = mesh2.FaceHandles.ToArray(); Assert.AreEqual( faces1.Length, faces2.Length ); for ( int i = 0; i < faces1.Length; i++ ) { mesh1.GetFaceTextureParameters( faces1[i], out var u1, out var v1, out var s1 ); mesh2.GetFaceTextureParameters( faces2[i], out var u2, out var v2, out var s2 ); Assert.AreEqual( u1, u2, $"Face {i} TextureUAxis mismatch" ); Assert.AreEqual( v1, v2, $"Face {i} TextureVAxis mismatch" ); Assert.AreEqual( s1, s2, $"Face {i} TextureScale mismatch" ); } // Verify something was actually computed (not all zeros) mesh1.GetFaceTextureParameters( faces1[0], out var axisU, out _, out var scale ); Assert.AreNotEqual( Vector4.Zero, axisU, "TextureUAxis should not be zero" ); Assert.AreNotEqual( Vector2.Zero, scale, "TextureScale should not be zero" ); } [TestMethod] public void TransformChangeDoesNotDirtyMeshJson() { var mesh = Json.Deserialize( LegacyMeshJson ); var json1 = Json.Serialize( mesh ); // Simulate MeshComponent moving: Mesh.Transform = WorldTransform var restored = Json.Deserialize( json1 ); restored.Transform = new Transform( new Vector3( 500, -300, 100 ), Rotation.FromYaw( 90 ) ); // Re-serialize — should be unchanged since world-space fields are no longer written var json2 = Json.Serialize( restored ); Assert.AreEqual( json1, json2, "Mesh JSON should not change when the transform changes" ); } /// /// TextureCoord is the stable persisted source: a mesh serialized under one /// transform and restored under a completely different transform keeps identical /// UVs and serialized json. The derived texture parameters are world-space /// dependent - they're recomputed from UVs + transform on every transform change - /// so passing through a different transform doesn't corrupt them: setting the /// original transform again reproduces them exactly. /// [TestMethod] public void TextureParametersStableAcrossDifferentTransforms() { var mesh = Json.Deserialize( LegacyMeshJson ); var json = Json.Serialize( mesh ); var transform1 = new Transform( new Vector3( 100, 0, 0 ), Rotation.Identity ); var transform2 = new Transform( new Vector3( 50000, 50000, 50000 ), Rotation.FromYaw( 180 ) ); // Serialize under transform1, restore under transform2 var mesh1 = Json.Deserialize( json ); mesh1.Transform = transform1; var roundTripped = Json.Deserialize( Json.Serialize( mesh1 ) ); roundTripped.Transform = transform2; // The persisted data (including TextureCoord) is transform independent Assert.AreEqual( Json.Serialize( mesh1 ), Json.Serialize( roundTripped ), "The serialized mesh must not change under a different transform" ); // Returning to transform1 recomputes the exact same parameters from the UVs - // no drift from having passed through transform2 roundTripped.Transform = transform1; var faces1 = mesh1.FaceHandles.ToArray(); var faces2 = roundTripped.FaceHandles.ToArray(); Assert.AreEqual( faces1.Length, faces2.Length ); for ( int i = 0; i < faces1.Length; i++ ) { mesh1.GetFaceTextureParameters( faces1[i], out var u1, out var v1, out var s1 ); roundTripped.GetFaceTextureParameters( faces2[i], out var u2, out var v2, out var s2 ); Assert.AreEqual( u1, u2, $"Face {i} TextureUAxis drifted after round-trip" ); Assert.AreEqual( v1, v2, $"Face {i} TextureVAxis drifted after round-trip" ); Assert.AreEqual( s1, s2, $"Face {i} TextureScale drifted after round-trip" ); } } }