using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class PortalVisibilityBuilderTests { private static Matrix4x4 ViewProj() { var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); return view * proj; } private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[] { new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z), new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z), }; private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell { CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = new List(portals), }; private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary all) => PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); [Fact] public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow() { var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.1f, 1.0f, -3f)); // narrow stairwell var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0)); ground.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // wide window var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; var frame = Build(cam, all); Assert.False(frame.OutsideView.IsEmpty); float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX; float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth( new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj()); Assert.True(outsideWidth < windowOnlyWidth * 0.5f, $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); } [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); cam.PortalPolygons.Add(Quad(0, 0, 0.1f, 1f, -3f)); var inner = Cell(0x0002); // no portals at all var all = new Dictionary { [0x0001] = cam, [0x0002] = inner }; Assert.True(Build(cam, all).OutsideView.IsEmpty); } [Fact] public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow() { var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0)); cam.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f)); var all = new Dictionary { [0x0001] = cam }; var frame = Build(cam, all); Assert.False(frame.OutsideView.IsEmpty); Assert.True(frame.OutsideView.MaxX - frame.OutsideView.MinX > 0.3f); } [Fact] public void Builder_BackFacingPortal_NotTraversed() { // Portal to 0x0002, but its clip plane puts the camera (origin) on the OUTSIDE. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); cam.PortalPolygons.Add(Quad(0, 0, 0.5f, 0.5f, -3f)); cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = -1f, InsideSide = 0 }); // dot = (0,0,1)·origin + (-1) = -1 < 0; InsideSide==0 requires dot >= -eps → camera OUTSIDE → skip. var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0)); ground.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f)); var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; var frame = Build(cam, all); Assert.False(frame.CellViews.ContainsKey(0x0002)); // neighbour never reached Assert.True(frame.OutsideView.IsEmpty); // its window never marked } [Fact] public void Builder_CwWoundExitPortal_OutsideRegionIsCcw() { // Exit portal authored CLOCKWISE — the builder must normalize to CCW so downstream stays valid. var cwQuad = new[] { new Vector3(-1, -1, -6), new Vector3(-1, 1, -6), new Vector3(1, 1, -6), new Vector3(1, -1, -6), }; var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0)); cam.PortalPolygons.Add(cwQuad); var all = new Dictionary { [0x0001] = cam }; var frame = Build(cam, all); Assert.False(frame.OutsideView.IsEmpty); var p = frame.OutsideView.Polygons[0].Vertices; float area2 = 0f; for (int i = 0; i < p.Length; i++) { var a = p[i]; var b = p[(i + 1) % p.Length]; area2 += a.X * b.Y - b.X * a.Y; } Assert.True(area2 > 0f, "clipped OutsideView region should be CCW after winding normalization"); } [Fact] public void Builder_CyclicGraph_TerminatesWithBoundedPolys() { // A <-> B cycle; B also has an exit window. Must terminate and not blow up. var a = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0)); a.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f)); var b = Cell(0x0002, new CellPortalInfo(0x0001, 0, 0), new CellPortalInfo(0xFFFF, 1, 0)); b.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // back to A b.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // exit window var all = new Dictionary { [0x0001] = a, [0x0002] = b }; var frame = Build(a, all); // must return (no infinite loop) Assert.False(frame.OutsideView.IsEmpty); Assert.True(frame.OutsideView.Polygons.Count < 256, $"OutsideView poly count {frame.OutsideView.Polygons.Count} — termination/dedup regression guard"); } } internal static class PortalFrameTestHelper { public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp) { var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp); var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp); return System.MathF.Abs(a.X / a.W - b.X / b.W); } }