using System.Collections.Generic;
using jeanf.scenemanagement;
using NUnit.Framework;
using Unity.Mathematics;
using UnityEngine;
namespace jeanf.scenemanagement.Tests
{
///
/// Locks the two pure functions behind static collider proxies. The placement math is the
/// regression-prone part: get it wrong and every proxy is silently misplaced — the exact bug
/// class the seat baker documents on itself.
///
public class StaticColliderMathTests
{
private const float Tolerance = 1e-3f;
private static void AssertApproximately(float3 expected, float3 actual, string label)
{
Assert.That(math.distance(expected, actual), Is.LessThan(Tolerance),
$"{label}: expected {expected}, got {actual}");
}
[Test]
public void DecomposeTrs_IdentityMatrix_YieldsIdentityTrs()
{
StaticColliderBake.DecomposeTrs(float4x4.identity, out var position, out var rotation, out var scale);
AssertApproximately(float3.zero, position, "position");
AssertApproximately(new float3(1f, 1f, 1f), scale, "scale");
Assert.That(math.abs(math.abs(rotation.value.w) - 1f), Is.LessThan(Tolerance), "rotation should be identity");
}
[Test]
public void DecomposeTrs_RoundTripsTranslationRotationScale()
{
var expectedPosition = new float3(1.5f, -2.25f, 0.75f);
var expectedRotation = quaternion.Euler(math.radians(20f), math.radians(-35f), math.radians(10f));
var expectedScale = new float3(2f, 2f, 2f);
var m = float4x4.TRS(expectedPosition, expectedRotation, expectedScale);
StaticColliderBake.DecomposeTrs(m, out var position, out var rotation, out var scale);
AssertApproximately(expectedPosition, position, "position");
AssertApproximately(expectedScale, scale, "scale");
// Compare the rotations by their effect: q and -q describe the same rotation.
AssertApproximately(math.mul(expectedRotation, math.right()), math.mul(rotation, math.right()), "rotated axis");
}
///
/// The case that matters in practice: a collider on a rotated, offset, scaled CHILD of the
/// authoring transform must resolve to a local TRS that reproduces the collider's world pose
/// when applied under a proxy placed at the authoring's world pose.
///
[Test]
public void DecomposeTrs_ChildRelativeToAuthoring_ReproducesWorldPose()
{
var authoring = float4x4.TRS(
new float3(10f, 3f, -4f),
quaternion.Euler(0f, math.radians(45f), 0f),
new float3(1f, 1f, 1f));
var colliderWorld = float4x4.TRS(
new float3(11.5f, 3.5f, -4.25f),
quaternion.Euler(math.radians(15f), math.radians(80f), 0f),
new float3(1f, 1f, 1f));
var local = math.mul(math.inverse(authoring), colliderWorld);
StaticColliderBake.DecomposeTrs(local, out var position, out var rotation, out var scale);
// Re-composing under the authoring transform must land back on the collider's world pose.
var recomposed = math.mul(authoring, float4x4.TRS(position, rotation, scale));
AssertApproximately(colliderWorld.c3.xyz, recomposed.c3.xyz, "recomposed world position");
AssertApproximately(
math.mul(colliderWorld, new float4(0f, 0f, 1f, 0f)).xyz,
math.mul(recomposed, new float4(0f, 0f, 1f, 0f)).xyz,
"recomposed world forward");
}
[Test]
public void IsLossyPlacement_UniformScale_IsNeverLossy()
{
var rotated = quaternion.Euler(math.radians(30f), math.radians(60f), 0f);
Assert.IsFalse(StaticColliderBake.IsLossyPlacement(rotated, new float3(3f, 3f, 3f)));
}
[Test]
public void IsLossyPlacement_NonUniformScaleWithoutRotation_IsNotLossy()
{
Assert.IsFalse(StaticColliderBake.IsLossyPlacement(quaternion.identity, new float3(1f, 5f, 2f)));
}
[Test]
public void IsLossyPlacement_NonUniformScaleWithRotation_IsLossy()
{
var rotated = quaternion.Euler(0f, math.radians(45f), 0f);
Assert.IsTrue(StaticColliderBake.IsLossyPlacement(rotated, new float3(1f, 5f, 2f)));
}
[Test]
public void TryDescribe_PrimitiveColliders_AreSupported()
{
var go = new GameObject("probe");
try
{
var box = go.AddComponent();
box.size = new Vector3(2f, 3f, 4f);
Assert.IsTrue(StaticColliderBake.TryDescribe(box, out var boxElement));
Assert.AreEqual(ProxyColliderShape.Box, boxElement.Shape);
AssertApproximately(new float3(2f, 3f, 4f), boxElement.Size, "box size");
var capsule = go.AddComponent();
capsule.direction = 2;
capsule.height = 5f;
Assert.IsTrue(StaticColliderBake.TryDescribe(capsule, out var capsuleElement));
Assert.AreEqual(ProxyColliderShape.Capsule, capsuleElement.Shape);
Assert.AreEqual(2, capsuleElement.Direction);
Assert.That(capsuleElement.Height, Is.EqualTo(5f).Within(Tolerance));
}
finally
{
Object.DestroyImmediate(go);
}
}
[Test]
public void TryDescribe_MeshCollider_IsRejected()
{
var go = new GameObject("probe");
try
{
var mesh = go.AddComponent();
Assert.IsFalse(StaticColliderBake.TryDescribe(mesh, out _),
"MeshCollider must be rejected so the baker can warn instead of baking a wrong shape.");
}
finally
{
Object.DestroyImmediate(go);
}
}
[Test]
public void SelectNearest_UnderCap_KeepsEveryCandidateInOrder()
{
var distances = new List { 9f, 1f, 4f };
var result = new List();
StaticColliderBridge.SelectNearest(distances, 10, result);
CollectionAssert.AreEqual(new[] { 0, 1, 2 }, result);
}
[Test]
public void SelectNearest_NoCap_KeepsEveryCandidate()
{
var distances = new List { 9f, 1f, 4f };
var result = new List();
StaticColliderBridge.SelectNearest(distances, 0, result);
Assert.AreEqual(3, result.Count);
}
[Test]
public void SelectNearest_OverCap_KeepsTheNearest()
{
var distances = new List { 100f, 1f, 50f, 4f, 25f };
var result = new List();
StaticColliderBridge.SelectNearest(distances, 3, result);
Assert.AreEqual(3, result.Count);
CollectionAssert.AreEquivalent(new[] { 1, 3, 4 }, result, "the three nearest candidates must win");
CollectionAssert.DoesNotContain(result, 0, "the furthest candidate must be dropped");
}
[Test]
public void SelectNearest_TiedDistances_BreaksTiesByIndexForStability()
{
var distances = new List { 5f, 5f, 5f, 5f };
var result = new List();
StaticColliderBridge.SelectNearest(distances, 2, result);
CollectionAssert.AreEqual(new[] { 0, 1 }, result,
"ties must resolve deterministically, or proxies churn between reconciles");
}
}
}