using NUnit.Framework;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
namespace AutomaticDoorSystem.Tests
{
///
/// Approach-side detection (DoorSideMath) - the rule that decides which way a Forward-style
/// rotating door swings. It used to quantize the door root's world yaw to a cardinal world axis
/// and compare raw world coordinates against the root position, so a door turned 30°, a root
/// sitting off the doorway, or a second entity in an overlapping trigger volume made doors open
/// toward the player or always the same way. These pin the replacement: the split is the
/// root's local +Z plane through the panel pivots, and the nearest entity decides.
///
public class DoorSideDetectionTests
{
private static float4x4 Trs(Vector3 position, Quaternion rotation, Vector3 scale)
{
return float4x4.TRS(position, rotation, scale);
}
private static byte Side(float4x4 doorToWorld, Vector3 planeLocalOrigin, Vector3 worldPoint, bool invert = false)
{
var worldToDoor = math.inverse(doorToWorld);
float3 origin = planeLocalOrigin;
float3 point = worldPoint;
return DoorSideMath.DirectionForward(in worldToDoor, in origin, invert, in point);
}
// ---- Which side is front ----
[Test]
public void UnrotatedDoor_PlusZIsFront_MinusZIsBack()
{
var door = Trs(Vector3.zero, Quaternion.identity, Vector3.one);
Assert.That(Side(door, Vector3.zero, new Vector3(0f, 1f, 2f)), Is.EqualTo(1), "+Z should be FRONT");
Assert.That(Side(door, Vector3.zero, new Vector3(0f, 1f, -2f)), Is.EqualTo(0), "-Z should be BACK");
}
[Test]
public void DoorTurnedAQuarterTurn_FrontFollowsItsLocalZ()
{
// Local +Z becomes world +X - the cardinal case the old quantization also handled.
var door = Trs(new Vector3(5f, 0f, 5f), Quaternion.Euler(0f, 90f, 0f), Vector3.one);
Assert.That(Side(door, Vector3.zero, new Vector3(7f, 1f, 5f)), Is.EqualTo(1));
Assert.That(Side(door, Vector3.zero, new Vector3(3f, 1f, 5f)), Is.EqualTo(0));
}
[Test]
public void DoorAtAnObliqueAngle_SplitsAlongItsOwnPlane_NotAWorldAxis()
{
// The clinic case: a door at 30° yaw. The old code snapped it to world +Z and compared
// world z, so a player walking the corridor diagonally could be read on the wrong side.
var rotation = Quaternion.Euler(0f, 30f, 0f);
var door = Trs(Vector3.zero, rotation, Vector3.one);
var inFront = rotation * new Vector3(0f, 1f, 1.5f);
var behind = rotation * new Vector3(0f, 1f, -1.5f);
Assert.That(Side(door, Vector3.zero, inFront), Is.EqualTo(1));
Assert.That(Side(door, Vector3.zero, behind), Is.EqualTo(0));
// A point in the door's own front half-space but with world z < 0 - the old rule got this wrong.
var frontButNegativeWorldZ = rotation * new Vector3(3f, 1f, 0.5f);
Assert.That(frontButNegativeWorldZ.z, Is.LessThan(0f), "test setup: world z must be negative");
Assert.That(Side(door, Vector3.zero, frontButNegativeWorldZ), Is.EqualTo(1));
}
[Test]
public void RootOffTheDoorway_SplitsAtThePanelPivotPlane()
{
// Root 1 m in front of the doorway (source-file pivot). A player between the doorway
// and the root is in FRONT of the door but BEHIND the root - the old rule compared
// against the root and read them as behind, so the door swung toward them.
var door = Trs(Vector3.zero, Quaternion.identity, Vector3.one);
var planeOrigin = new Vector3(0f, 0f, -1f); // hinge, root-local
Assert.That(Side(door, planeOrigin, new Vector3(0f, 1f, -0.5f)), Is.EqualTo(1), "between doorway and root = FRONT");
Assert.That(Side(door, planeOrigin, new Vector3(0f, 1f, -1.5f)), Is.EqualTo(0), "past the doorway = BACK");
}
[Test]
public void MirroredDoor_NegativeZScale_FlipsFrontWithTheGeometry()
{
// A door instance mirrored with scale.z = -1 has its geometry's front on world -Z, and
// local space follows: the runtime reads local +Z, which is now world -Z.
var door = Trs(Vector3.zero, Quaternion.identity, new Vector3(1f, 1f, -1f));
Assert.That(Side(door, Vector3.zero, new Vector3(0f, 1f, -2f)), Is.EqualTo(1));
Assert.That(Side(door, Vector3.zero, new Vector3(0f, 1f, 2f)), Is.EqualTo(0));
}
[Test]
public void InvertForwardSide_SwapsTheAnswerOnly()
{
var door = Trs(Vector3.zero, Quaternion.Euler(0f, 45f, 0f), Vector3.one);
var point = Quaternion.Euler(0f, 45f, 0f) * new Vector3(0.3f, 1f, 2f);
Assert.That(Side(door, Vector3.zero, point, invert: false), Is.EqualTo(1));
Assert.That(Side(door, Vector3.zero, point, invert: true), Is.EqualTo(0));
}
[Test]
public void FrontDepth_IsMeasuredFromThePlane_InLocalUnits()
{
var worldToDoor = math.inverse(Trs(new Vector3(2f, 0f, 3f), Quaternion.identity, new Vector3(1f, 1f, 2f)));
float3 origin = new float3(0f, 0f, 0.5f);
float3 point = new float3(2f, 0f, 3f + 2f * 1.5f); // local z = 1.5 after the scale
Assert.That(DoorSideMath.FrontDepth(in worldToDoor, in origin, in point), Is.EqualTo(1f).Within(1e-4f));
}
// ---- Who decides, and the trigger count ----
private static void Evaluate(float4x4 doorToWorld, Vector3 triggerCenter, Vector3 triggerSize,
Vector3[] positions, int[] layers, int layerMask, out int inside, out byte direction, bool invert = false,
Vector3 planeOrigin = default)
{
var nativePositions = new NativeArray(positions.Length, Allocator.Temp);
var nativeLayers = new NativeArray(positions.Length, Allocator.Temp);
try
{
for (var i = 0; i < positions.Length; i++)
{
nativePositions[i] = positions[i];
nativeLayers[i] = layers[i];
}
float3 origin = planeOrigin;
float3 center = triggerCenter;
float3 size = triggerSize;
DoorSideMath.Evaluate(in doorToWorld, in origin, invert, in center, in size, layerMask,
nativePositions, nativeLayers, out inside, out direction);
}
finally
{
nativePositions.Dispose();
nativeLayers.Dispose();
}
}
[Test]
public void NearestEntityToTheDoorwayDecidesTheSide_NotTheLastOneInTheArray()
{
// Overlapping trigger volumes in a corridor: an NPC far away on the BACK side is listed
// AFTER the player who is right at the door on the FRONT side. Last-one-wins swung the
// door toward the player; nearest-wins must not.
var door = Trs(Vector3.zero, Quaternion.identity, Vector3.one);
var positions = new[] { new Vector3(0f, 1f, 0.4f), new Vector3(0.5f, 1f, -1.4f) };
var layers = new[] { 3, 3 };
Evaluate(door, Vector3.zero, new Vector3(3f, 3f, 3f), positions, layers, 1 << 3, out var inside, out var direction);
Assert.That(inside, Is.EqualTo(2));
Assert.That(direction, Is.EqualTo(1), "the player at 0.4 m in front must decide");
// Same two entities in the other order must give the same answer.
Evaluate(door, Vector3.zero, new Vector3(3f, 3f, 3f), new[] { positions[1], positions[0] }, layers, 1 << 3,
out inside, out direction);
Assert.That(direction, Is.EqualTo(1));
}
[Test]
public void EntitiesOutsideTheVolumeOrOnOtherLayers_DoNotCountOrDecide()
{
var door = Trs(Vector3.zero, Quaternion.identity, Vector3.one);
var positions = new[]
{
new Vector3(0f, 1f, -0.5f), // inside, BACK, wrong layer
new Vector3(0f, 1f, 5f), // right layer, outside the 3 m box
new Vector3(0f, 1f, 1f), // inside, FRONT, right layer
};
var layers = new[] { 4, 3, 3 };
Evaluate(door, Vector3.zero, new Vector3(3f, 3f, 3f), positions, layers, 1 << 3, out var inside, out var direction);
Assert.That(inside, Is.EqualTo(1));
Assert.That(direction, Is.EqualTo(1));
}
[Test]
public void EmptyVolume_ReportsNobodyAndKeepsForwardAsDefault()
{
var door = Trs(Vector3.zero, Quaternion.identity, Vector3.one);
var found = false;
using (var positions = new NativeArray(0, Allocator.Temp))
using (var layers = new NativeArray(0, Allocator.Temp))
{
float3 zero = float3.zero;
float3 size = new float3(3f, 3f, 3f);
found = DoorSideMath.Evaluate(in door, in zero, false, in zero, in size, ~0,
positions, layers, out var inside, out var direction);
Assert.That(inside, Is.EqualTo(0));
Assert.That(direction, Is.EqualTo(1));
}
Assert.That(found, Is.False);
}
[Test]
public void TriggerVolumeCentre_GoesThroughTheRootTransform()
{
// Rotated door: a centre authored 1 m along local +X lands on world -Z... unless the
// centre is added to the position without rotating it, which is what this guards.
var door = Trs(new Vector3(10f, 0f, 0f), Quaternion.Euler(0f, 90f, 0f), Vector3.one);
var localCenter = new Vector3(1f, 1f, 0f);
var worldCenter = new Vector3(10f, 1f, -1f);
Evaluate(door, localCenter, new Vector3(1f, 1f, 1f), new[] { worldCenter }, new[] { 0 }, 1,
out var inside, out _);
Assert.That(inside, Is.EqualTo(1));
Evaluate(door, localCenter, new Vector3(1f, 1f, 1f), new[] { new Vector3(11f, 1f, 0f) }, new[] { 0 }, 1,
out inside, out _);
Assert.That(inside, Is.EqualTo(0), "the unrotated centre must not detect anything");
}
// ---- The authoring-side helper the editor tools and the baker share ----
[Test]
public void DoorAuthoring_SidePlaneOrigin_IsTheMeanOfThePanelPivots_RootLocal()
{
var root = new GameObject("Door");
var config = ScriptableObject.CreateInstance();
try
{
config.doorMovement = DoorConfig.DoorMovementEnum.Rotating;
config.doorCount = DoorConfig.DoorCountEnum.Double;
root.transform.SetPositionAndRotation(new Vector3(4f, 0f, 4f), Quaternion.Euler(0f, 90f, 0f));
var left = new GameObject("Left").transform;
var right = new GameObject("Right").transform;
left.SetParent(root.transform, false);
right.SetParent(root.transform, false);
left.localPosition = new Vector3(-1f, 0f, 0.5f);
right.localPosition = new Vector3(1f, 0f, 0.5f);
var door = root.AddComponent();
door.doorConfig = config;
door.leftDoorMesh = left;
door.rightDoorMesh = right;
var origin = door.SidePlaneLocalOrigin;
Assert.That(Vector3.Distance(origin, new Vector3(0f, 0f, 0.5f)), Is.LessThan(1e-4f));
// FRONT is local +Z = world +X for this yaw; the split sits at local z = 0.5.
Assert.That(door.DirectionForwardFor(root.transform.TransformPoint(new Vector3(0f, 1f, 0.6f)), out _), Is.EqualTo(1));
Assert.That(door.DirectionForwardFor(root.transform.TransformPoint(new Vector3(0f, 1f, 0.4f)), out _), Is.EqualTo(0));
Assert.That(Vector3.Distance(door.FrontDirectionWorld, Vector3.right), Is.LessThan(1e-4f));
// Config flag and per-door override combine: override wins.
config.invertForwardSide = true;
Assert.That(door.EffectiveInvertForwardSide, Is.True);
Assert.That(door.DirectionForwardFor(root.transform.TransformPoint(new Vector3(0f, 1f, 0.6f)), out _), Is.EqualTo(0));
door.invertForwardSideOverride = DoorAuthoring.ConfigBoolOverride.ForceOff;
Assert.That(door.EffectiveInvertForwardSide, Is.False);
// Start-locked follows the same override rule.
config.startLocked = true;
Assert.That(door.EffectiveStartLocked, Is.True);
door.startLockedOverride = DoorAuthoring.ConfigBoolOverride.ForceOff;
Assert.That(door.EffectiveStartLocked, Is.False);
config.startLocked = false;
door.startLockedOverride = DoorAuthoring.ConfigBoolOverride.ForceOn;
Assert.That(door.EffectiveStartLocked, Is.True);
}
finally
{
Object.DestroyImmediate(root);
Object.DestroyImmediate(config);
}
}
}
}