diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs index c7eab48..422d998 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs @@ -99,6 +99,103 @@ namespace MagnetismHeroes.Tests Debug.Log($"[ProgressRestoreMeshDiagnostic] mesh={collider.sharedMesh.name}, point={point:R}, closestSurface={surface:R}, distance={closest:R}, triangle={closestTriangle}, position={collider.transform.position:R}, rotation={collider.transform.rotation:R}, scale={collider.transform.lossyScale:R}, vertices={vertices.Length}, triangles={triangles.Length/3}"); } } + private delegate void MeshDistance(Vector3 point, out Vector3 nearest, out float distance, out bool inside); + [UnityTest] public IEnumerator ApprovedTreeDistanceMatchesFrozenReferenceAndNativeAndSyntheticEntriesRemainStable() + { + _store.Save.puzzleStates=new[]{new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==2)).ToPuzzleState(GameSession.DefaultCamera()),new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==9)).ToPuzzleState(GameSession.DefaultCamera())}; + Reload();_app.StartStage(6);yield return null; + var cache=(PlaygroundRestoreGeometry)new SerializedObject(_app).FindProperty("_restoreGeometry").objectReferenceValue; + var meshType=typeof(PlaygroundResume).GetNestedType("RestoreMesh",System.Reflection.BindingFlags.NonPublic); + var nativePoint=new Vector3(-8.3752327f,1.6241438f,-4.0848165f); + var native=_app.Environment.GetComponentsInChildren().Where(mesh=>mesh.enabled&&mesh.sharedMesh.name=="RoundCanopyTree").OrderBy(mesh=>Vector3.Distance(mesh.bounds.center,nativePoint)).First(); + Assert.That(Vector3.Distance(native.bounds.center,nativePoint),Is.LessThan(.001f)); + foreach(string fixture in new[]{"native-stage6","synthetic-tree"}) + { + var collider=native; + if(fixture=="synthetic-tree") + { + foreach(var item in _app.Environment.GetComponentsInChildren()) if(!item.isTrigger&&!item.name.Contains("Clearing")) item.enabled=false; + var obstacle=Object.Instantiate(AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/RoundCanopyTree.prefab"),_app.Environment.transform);_fixtures.Add(obstacle); + obstacle.transform.localScale=Vector3.one*2;collider=obstacle.GetComponentInChildren();Physics.SyncTransforms(); + obstacle.transform.position+=new Vector3(0,3,0)-collider.bounds.center;Physics.SyncTransforms(); + } + Assert.That(collider.sharedMesh.isReadable,Is.False);Assert.That(cache.TryGet(collider.sharedMesh,out var local,out var triangles),Is.True); + Assert.That(local.Length,Is.EqualTo(4187));Assert.That(triangles.Length/3,Is.EqualTo(4840)); + var world=local.Select(collider.transform.TransformPoint).ToArray(); + var geometry=System.Activator.CreateInstance(meshType,new object[]{collider,local,triangles});Assert.That(meshType.GetProperty("IsClosed").GetValue(geometry),Is.True); + var distance=(MeshDistance)meshType.GetMethod("Distance").CreateDelegate(typeof(MeshDistance),geometry); + var samples=new List{collider.bounds.center,collider.bounds.min-Vector3.one,collider.bounds.max+Vector3.one}; + for(int index=0;index<24;index++) + { + int triangle=(index*(triangles.Length/3)/24)*3;var a=world[triangles[triangle]];var b=world[triangles[triangle+1]];var c=world[triangles[triangle+2]]; + var point=(a+b+c)/3;var normal=Vector3.Cross(b-a,c-a).normalized; + samples.Add(point);samples.Add(point+normal*.002f);samples.Add(point-normal*.002f); + } + int insideSamples=0,outsideSamples=0; + foreach(var point in samples) + { + ReferenceMeshDistance(world,triangles,point,out var expectedNearest,out float expectedDistance,out bool expectedInside,out double winding); + distance(point,out var actualNearest,out float actualDistance,out bool actualInside); + Assert.That(actualNearest.x,Is.EqualTo(expectedNearest.x));Assert.That(actualNearest.y,Is.EqualTo(expectedNearest.y));Assert.That(actualNearest.z,Is.EqualTo(expectedNearest.z)); + Assert.That(actualDistance,Is.EqualTo(expectedDistance));Assert.That(actualInside,Is.EqualTo(expectedInside));Assert.That(actualInside,Is.EqualTo(System.Math.Abs(winding)>System.Math.PI*2)); + if(expectedInside) insideSamples++;else outsideSamples++; + } + Assert.That(insideSamples,Is.GreaterThan(0));Assert.That(outsideSamples,Is.GreaterThan(0)); + AssetDatabase.TryGetGUIDAndLocalFileIdentifier(collider.sharedMesh,out string guid,out long fileId); + Debug.Log($"[ResumeMagnitudeReference] fixture={fixture}, meshGuid={guid}, fileId={fileId}, geometryHash={PlaygroundRestoreGeometry.Hash(local,triangles)}, vertices={local.Length}, triangles={triangles.Length/3}, position={collider.transform.position:R}, rotation={collider.transform.rotation:R}, scale={collider.transform.lossyScale:R}, samples={samples.Count}, inside={insideSamples}, outside={outsideSamples}"); + var timingPoints=samples.Take(12).ToArray(); + for(int warmup=0;warmup<2;warmup++) foreach(var point in timingPoints) {ReferenceMeshDistance(world,triangles,point,out _,out _,out _,out _);distance(point,out _,out _,out _);} + for(int batch=0;batch<5;batch++) + { + var timer=System.Diagnostics.Stopwatch.StartNew();foreach(var point in timingPoints) ReferenceMeshDistance(world,triangles,point,out _,out _,out _,out _);timer.Stop();double referenceMilliseconds=timer.Elapsed.TotalMilliseconds; + timer.Restart();foreach(var point in timingPoints) distance(point,out _,out _,out _);timer.Stop(); + Debug.Log($"[ResumeMagnitudeQueryTiming] fixture={fixture}, batch={batch}, queries={timingPoints.Length}, referenceMs={referenceMilliseconds:R}, runtimeMs={timer.Elapsed.TotalMilliseconds:R}"); + } + string puzzles=string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state)));var completed=(int[])_store.Save.completedStageIds.Clone();var tutorials=(string[])_store.Save.seenTutorialIds.Clone(); + string expected=null; + for(int iteration=0;iteration<4;iteration++) + { + _store.Save.latestFreePlay=new FreePlayState{sourceStageId=6,balls=new[]{new BallState{id="native-tree-interior",position=BoardPresenter.ToPosition(collider.bounds.center)}},groups=new[]{Group("native-group","native-tree-interior")},camera=GameSession.DefaultCamera()}; + Reload();int writes=_store.Writes;var timer=System.Diagnostics.Stopwatch.StartNew();_app.ContinueFreePlay();timer.Stop();yield return null; + Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.FreePlay));Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);Assert.That(_store.Writes,Is.EqualTo(writes+1)); + Assert.That(_app.Session.Board.Balls.Select(ball=>ball.id),Is.EqualTo(new[]{"native-tree-interior"}));Assert.That(_app.Session.Board.Groups.Single().id,Is.EqualTo("native-group")); + Assert.That(string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state))),Is.EqualTo(puzzles));Assert.That(_store.Save.completedStageIds,Is.EqualTo(completed));Assert.That(_store.Save.seenTutorialIds,Is.EqualTo(tutorials)); + string result=JsonUtility.ToJson(_store.Save.latestFreePlay);if(expected==null) expected=result;else Assert.That(result,Is.EqualTo(expected)); + Debug.Log($"[ResumeMagnitudeEntryTiming] fixture={fixture}, warmup={iteration==0}, sample={iteration-1}, utc={System.DateTime.UtcNow:O}, entryMs={timer.Elapsed.TotalMilliseconds:R}, result={result}"); + } + _app.ShowHome();string stable=JsonUtility.ToJson(_store.Save.latestFreePlay);_app.ContinueFreePlay();yield return null;Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay),Is.EqualTo(stable));Assert.That(_app.Instruction,Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요.")); + } + } + private static void ReferenceMeshDistance(Vector3[] vertices, int[] triangles, Vector3 point, out Vector3 nearest, out float distance, out bool inside, out double winding) + { + nearest = Vector3.zero; float squared = float.PositiveInfinity; winding = 0; + for (int index = 0; index < triangles.Length; index += 3) + { + var a = vertices[triangles[index]]; var b = vertices[triangles[index + 1]]; var c = vertices[triangles[index + 2]]; + var candidate = ReferenceClosestTriangle(point, a, b, c); float candidateSquared = (candidate - point).sqrMagnitude; + if (candidateSquared < squared) { squared = candidateSquared; nearest = candidate; } + + a -= point; b -= point; c -= point; + double numerator = Vector3.Dot(a, Vector3.Cross(b, c)); + double denominator = (double)a.magnitude * b.magnitude * c.magnitude + (double)Vector3.Dot(a, b) * c.magnitude + + (double)Vector3.Dot(b, c) * a.magnitude + (double)Vector3.Dot(c, a) * b.magnitude; + winding += 2 * System.Math.Atan2(numerator, denominator); + } + distance = Mathf.Sqrt(squared); inside = System.Math.Abs(winding) > System.Math.PI * 2; + } + private static Vector3 ReferenceClosestTriangle(Vector3 point, Vector3 a, Vector3 b, Vector3 c) + { + var ab = b - a; var ac = c - a; var ap = point - a; float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap); + if (d1 <= 0 && d2 <= 0) return a; + var bp = point - b; float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp); + if (d3 >= 0 && d4 <= d3) return b; + float vc = d1 * d4 - d3 * d2; if (vc <= 0 && d1 >= 0 && d3 <= 0) return a + ab * (d1 / (d1 - d3)); + var cp = point - c; float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp); + if (d6 >= 0 && d5 <= d6) return c; + float vb = d5 * d2 - d1 * d6; if (vb <= 0 && d2 >= 0 && d6 <= 0) return a + ac * (d2 / (d2 - d6)); + float va = d3 * d6 - d5 * d4; if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) return b + (c - b) * ((d4 - d3) / (d4 - d3 + d5 - d6)); + float inverse = 1 / (va + vb + vc); return a + ab * (vb * inverse) + ac * (vc * inverse); + } [UnityTest] public IEnumerator ActualGardenSplitFiveResumeRepairsWithoutChangingThreeAndTwoMembershipOrOtherSlots() { _store.Save.puzzleStates=new[]{new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==2)).ToPuzzleState(GameSession.DefaultCamera()),new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==6)).ToPuzzleState(GameSession.DefaultCamera())}; diff --git a/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs b/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs index ad33cff..e2fe9a6 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs @@ -295,12 +295,14 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap { private readonly Vector3[] _vertices; private readonly int[] _triangles; + private readonly float[] _magnitudes; private readonly bool _closed; public bool IsClosed => _closed; public RestoreMesh(MeshCollider collider, Vector3[] vertices, int[] triangles) { _triangles = triangles; _vertices = vertices.Select(collider.transform.TransformPoint).ToArray(); + _magnitudes = new float[_vertices.Length]; var edges = new Dictionary<(Vector3 first, Vector3 second), (int count, int orientation)>(); for (int index = 0; index < _triangles.Length; index += 3) for (int edge = 0; edge < 3; edge++) @@ -316,6 +318,8 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap public void Distance(Vector3 point, out Vector3 nearest, out float distance, out bool inside) { nearest = Vector3.zero; float squared = float.PositiveInfinity; double winding = 0; + // Each query keeps the original float magnitude and triangle summation order, reusing shared vertices. + if (_closed) for (int index = 0; index < _vertices.Length; index++) _magnitudes[index] = (_vertices[index] - point).magnitude; for (int index = 0; index < _triangles.Length; index += 3) { var a = _vertices[_triangles[index]]; var b = _vertices[_triangles[index + 1]]; var c = _vertices[_triangles[index + 2]]; @@ -324,8 +328,9 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap if (!_closed) continue; a -= point; b -= point; c -= point; double numerator = Vector3.Dot(a, Vector3.Cross(b, c)); - double denominator = (double)a.magnitude * b.magnitude * c.magnitude + (double)Vector3.Dot(a, b) * c.magnitude - + (double)Vector3.Dot(b, c) * a.magnitude + (double)Vector3.Dot(c, a) * b.magnitude; + float aMagnitude = _magnitudes[_triangles[index]], bMagnitude = _magnitudes[_triangles[index + 1]], cMagnitude = _magnitudes[_triangles[index + 2]]; + double denominator = (double)aMagnitude * bMagnitude * cMagnitude + (double)Vector3.Dot(a, b) * cMagnitude + + (double)Vector3.Dot(b, c) * aMagnitude + (double)Vector3.Dot(c, a) * bMagnitude; winding += 2 * Math.Atan2(numerator, denominator); } distance = Mathf.Sqrt(squared); inside = _closed && Math.Abs(winding) > Math.PI * 2;