diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index a9fb2c9..659add2 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -364,6 +364,61 @@ public class PortalVisibilityBuilderTests $"neighbour CellView MinX {bView.MinX} must still cover the LEFT reciprocal opening (minX {leftMinX})"); } + // ----------------------------------------------------------------------- + // Phase U.4c: the threshold "flap". A chain camera(C0) -> mid(C1) -> exit(C2) + // where the C0->C1 portal's clip plane sits just in front of the camera. + // BOTH poses are legitimately inside C0 and SHOULD see the exit window; they + // straddle the C0->C1 side-test boundary by a few cm. Pre-fix, the pose just + // behind the plane hard-culls C0->C1 (CameraOnInteriorSide), C2 is never + // reached, and OutsideView empties — the flap. The fix must keep the exit + // cell visible (OutsideView non-empty) at BOTH poses. + // ----------------------------------------------------------------------- + private static Matrix4x4 ViewProjAt(Vector3 eye) + { + var view = Matrix4x4.CreateLookAt(eye, eye + new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); + return view * proj; + } + + private static (LoadedCell cam, Dictionary all) FlapChain() + { + const uint C0 = 0x0001, C1 = 0x0002, C2 = 0x0003; + // C0 -> C1 portal at z=-1, with a clip plane (normal +Z, InsideSide=0) at z=-1. + // dot = camZ + D; with D = 1 the plane is at camZ = -1: inside iff camZ >= -1 - eps. + var c0 = Cell(C0, new CellPortalInfo((ushort)C1, 0, 0, 0)); + c0.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -1f)); + c0.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1f, InsideSide = 0 }); + // C1 -> C2 (no clip plane → never culled), C2 has the exit window. + var c1 = Cell(C1, new CellPortalInfo((ushort)C2, 0, 0, 0)); + c1.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); + var c2 = Cell(C2, new CellPortalInfo(0xFFFF, 0, 0, 0)); + c2.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -7f)); + var all = new Dictionary { [C0] = c0, [C1] = c1, [C2] = c2 }; + return (c0, all); + } + + [Fact] + public void Build_NearBoundaryIntermediatePortal_ExitCellStaysVisibleAcrossPose() + { + var (cam, all) = FlapChain(); + Func lookup = id => all.TryGetValue(id, out var c) ? c : null; + + // Pose A: a few cm IN FRONT of the C0->C1 plane (camZ = -0.9 >= -1 → inside). + var poseA = new Vector3(0, 0, -0.9f); + var frameA = PortalVisibilityBuilder.Build(cam, poseA, lookup, ViewProjAt(poseA)); + + // Pose B: a few cm BEHIND it (camZ = -1.1 < -1 → pre-fix the side test culls C0->C1). + var poseB = new Vector3(0, 0, -1.1f); + var frameB = PortalVisibilityBuilder.Build(cam, poseB, lookup, ViewProjAt(poseB)); + + // The exit cell — and therefore OutsideView — must be present at BOTH poses. + Assert.False(frameA.OutsideView.IsEmpty, "pose A should see the exit window"); + Assert.False(frameB.OutsideView.IsEmpty, + "pose B (a few cm away) must ALSO see the exit window — this is the flap: " + + "an intermediate side-test flip must not drop the exit cell from the set"); + Assert.Contains(0x0003u, frameB.OrderedVisibleCells); + } + // Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC. private static float CellViewArea(CellView view) {