using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using DatReaderWriter; using DatReaderWriter.Options; using Xunit; using Xunit.Abstractions; namespace AcDream.App.Tests.Rendering; /// /// #124 — far-building interiors under an INTERIOR root. Retail seeds the /// look-in flood by clipping a building's aperture against the CURRENTLY /// INSTALLED view (PView::GetClip 0x005a4320 inside ConstructView(CBldPortal) /// 0x005a59a0): full screen outdoors, the accumulated doorway (outside) view /// when looked into from inside. These tests pin BuildFromExterior's /// seedRegion parameter — the port of that installed-view clip — against the /// real Holtburg corner-building door. /// public class Issue124LookInSeedRegionTests { private readonly ITestOutputHelper _out; public Issue124LookInSeedRegionTests(ITestOutputHelper output) => _out = output; private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u; private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) { var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f); return view * proj; } private static (Dictionary cells, LoadedCell exitCell, int exitIdx, Vector3 centroid, Vector3 outward) LoadFixture(DatCollection dats) { var cells = CornerFloodReplayTests.LoadBuilding(dats); var exitCell = cells[ExitCellId]; int exitIdx = -1; for (int i = 0; i < exitCell.Portals.Count; i++) { if (exitCell.Portals[i].OtherCellId == 0xFFFF && i < exitCell.PortalPolygons.Count && exitCell.PortalPolygons[i].Length >= 3) { exitIdx = i; break; } } Assert.True(exitIdx >= 0); var localPoly = exitCell.PortalPolygons[exitIdx]; Vector3 centroid = Vector3.Zero; foreach (var lp in localPoly) centroid += Vector3.Transform(lp, exitCell.WorldTransform); centroid /= localPoly.Length; var plane = exitCell.ClipPlanes[exitIdx]; var normal = Vector3.TransformNormal(plane.Normal, exitCell.WorldTransform); var cellCenter = Vector3.Transform( (exitCell.LocalBoundsMin + exitCell.LocalBoundsMax) * 0.5f, exitCell.WorldTransform); // outward = away from the cell interior. if (Vector3.Dot(normal, cellCenter - centroid) > 0) normal = -normal; return (cells, exitCell, exitIdx, centroid, Vector3.Normalize(normal)); } private static Vector4 ApertureNdcAabb(LoadedCell cell, int idx, Matrix4x4 viewProj) { float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue; foreach (var lp in cell.PortalPolygons[idx]) { var w = Vector3.Transform(lp, cell.WorldTransform); var c = Vector4.Transform(new Vector4(w, 1f), viewProj); Assert.True(c.W > 0.05f, "fixture eye must keep the aperture fully in front"); minX = MathF.Min(minX, c.X / c.W); maxX = MathF.Max(maxX, c.X / c.W); minY = MathF.Min(minY, c.Y / c.W); maxY = MathF.Max(maxY, c.Y / c.W); } return new Vector4(minX, minY, maxX, maxY); } private static ViewPolygon Quad(float minX, float minY, float maxX, float maxY) => new(new[] { new Vector2(minX, minY), new Vector2(maxX, minY), new Vector2(maxX, maxY), new Vector2(minX, maxY), }); [Fact] public void SeedRegion_ContainingAperture_Floods_DisjointRegion_DoesNot() { var datDir = CornerFloodReplayTests.ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var (cells, exitCell, exitIdx, centroid, outward) = LoadFixture(dats); LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; // Eye OUTSIDE the building, 3 m in front of the exit door, gaze at it // — the look-in geometry of a viewer peering at this building through // some other opening. var eye = centroid + outward * 3f; var viewProj = ViewProjFor(eye, centroid); var ap = ApertureNdcAabb(exitCell, exitIdx, viewProj); _out.WriteLine(FormattableString.Invariant( $"aperture ndc=({ap.X:F3},{ap.Y:F3},{ap.Z:F3},{ap.W:F3})")); // Sanity: the full-screen (outdoor-root) seed floods. var full = PortalVisibilityBuilder.BuildFromExterior( cells.Values, eye, Lookup, viewProj); Assert.True(full.OrderedVisibleCells.Count > 0, "full-screen seed must flood"); // A region containing the aperture floods — and never MORE than the // full-screen seed (region-restricting can only shrink the flood). var containing = new[] { Quad(ap.X - 0.05f, ap.Y - 0.05f, ap.Z + 0.05f, ap.W + 0.05f) }; var seeded = PortalVisibilityBuilder.BuildFromExterior( cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, containing); Assert.True(seeded.OrderedVisibleCells.Count > 0, "containing region must flood"); Assert.True(seeded.OrderedVisibleCells.Count <= full.OrderedVisibleCells.Count); // A region strictly disjoint from the aperture must not flood — the // doorway doesn't show this building, so its interior never builds // (retail: GetClip vs the installed view returns empty → no look-in). Assert.True(ap.Z < 0.70f || ap.X > -0.70f, "fixture aperture unexpectedly fills the screen"); var disjoint = ap.Z < 0.70f ? new[] { Quad(0.75f, 0.75f, 0.99f, 0.99f) } : new[] { Quad(-0.99f, -0.99f, -0.75f, -0.75f) }; var none = PortalVisibilityBuilder.BuildFromExterior( cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, disjoint); Assert.True(none.OrderedVisibleCells.Count == 0, FormattableString.Invariant($"disjoint region flooded {none.OrderedVisibleCells.Count} cells")); } [Fact] public void EyeOnInteriorSide_ExitDoorNeverSeeds() { // The root's own doorway must not look-in on itself: the seed eye-side // test (retail ConstructView's sidedness vs portal_side) excludes any // aperture the eye is on the interior side of — this is what lets the // interior-root gather pass ALL nearby buildings including the // viewer's own without special-casing. var datDir = CornerFloodReplayTests.ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var (cells, exitCell, _, centroid, outward) = LoadFixture(dats); LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; var eye = centroid - outward * 2f; // 2 m INSIDE the doorway var viewProj = ViewProjFor(eye, centroid); var frame = PortalVisibilityBuilder.BuildFromExterior( new[] { exitCell }, eye, Lookup, viewProj); Assert.True(frame.OrderedVisibleCells.Count == 0, "an interior-side eye must not seed its own cell's exit portal"); } }