From a8b831c23bb3674b99964b2bb9b8677f6ae54f2e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jun 2026 16:21:08 +0200 Subject: [PATCH] =?UTF-8?q?test(render):=20Phase=20W=20Stage=204/5=20?= =?UTF-8?q?=E2=80=94=20assembler=20OutsideView=20AABB=20+=20PView=20BFS=20?= =?UTF-8?q?+=20entity-clip=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClipFrameAssemblerTests (3 new): - Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds - Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid - Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero PortalVisibilityBuilderTests (3 new): - Build_ExitPortalVisible_OutsideViewNonEmpty - Build_NoExitPortal_OutsideViewEmpty - Build_RootCellAlwaysFirstInOrderedVisibleCells EntityClipTests (new file, 5 tests): - EntityClip_ParentInVisibleSet_Included - EntityClip_ParentNotInVisibleSet_Excluded - EntityClip_NullVisibleSet_IncludesAll - EntityClip_NullParentCell_NullVisibleSet_Included - EntityClip_NullParentCell_NonNullVisibleSet_Included WbDrawDispatcher.EntityPassesVisibleCellGate changed private → internal static (AcDream.App already has InternalsVisibleTo AcDream.App.Tests; no new seam needed). 160 → 171 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 2 +- .../Rendering/ClipFrameAssemblerTests.cs | 74 +++++++++++++ .../Rendering/EntityClipTests.cs | 101 ++++++++++++++++++ .../Rendering/PortalVisibilityBuilderTests.cs | 55 ++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.App.Tests/Rendering/EntityClipTests.cs diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index d2cbd9c..741162d 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -1736,7 +1736,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true; - private static bool EntityPassesVisibleCellGate( + internal static bool EntityPassesVisibleCellGate( WorldEntity entity, HashSet? visibleCellIds, EntitySet set) diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs index fea2dae..1f1cbfe 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -159,6 +159,80 @@ public class ClipFrameAssemblerTests Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); } + // ----------------------------------------------------------------------- + // Phase W Stage 4: HasOutsideView + OutsideViewNdcAabb + // ----------------------------------------------------------------------- + + [Fact] + public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds() + { + // A single convex quad in OutsideView reduces to Planes. HasOutsideView must + // be true and OutsideViewNdcAabb must match the polygon's own Min/Max values. + var pv = new PortalVisibilityFrame(); + var poly = Square(-0.3f, 0.2f, 0.25f); + pv.OutsideView.Add(poly); + // No interior cells needed for this assertion. + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + Assert.True(asm.HasOutsideView); + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + + // The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max. + var expected = new System.Numerics.Vector4( + pv.OutsideView.MinX, pv.OutsideView.MinY, + pv.OutsideView.MaxX, pv.OutsideView.MaxY); + Assert.Equal(expected, asm.OutsideViewNdcAabb); + } + + [Fact] + public void Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid() + { + // Two polygons in OutsideView → union (non-convex) → ClipPlaneSet forces + // scissor fallback. HasOutsideView must still be true, TerrainMode must be + // Scissor, and OutsideViewNdcAabb must equal the union bounds (same values + // as TerrainScissorNdcAabb in this mode). + var pv = new PortalVisibilityFrame(); + pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f)); + pv.OutsideView.Add(Square(0.6f, 0f, 0.15f)); + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + Assert.True(asm.HasOutsideView); + Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode); + + // Union bounds from the CellView (spans both squares). + var expectedAabb = new System.Numerics.Vector4( + pv.OutsideView.MinX, pv.OutsideView.MinY, + pv.OutsideView.MaxX, pv.OutsideView.MaxY); + Assert.Equal(expectedAabb, asm.OutsideViewNdcAabb); + + // In Scissor mode OutsideViewNdcAabb and TerrainScissorNdcAabb are the same + // value (both are the union CellView bounds). + Assert.Equal(asm.TerrainScissorNdcAabb, asm.OutsideViewNdcAabb); + } + + [Fact] + public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero() + { + // An empty OutsideView means no exit portal was in view → TerrainMode.Skip, + // HasOutsideView false, OutsideViewNdcAabb degenerate zero. + const uint cellA = 0xA9B40100; + var pv = new PortalVisibilityFrame(); + pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); + pv.OrderedVisibleCells.Add(cellA); + // OutsideView left empty (no exit portal). + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + Assert.False(asm.HasOutsideView); + Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode); + Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb); + } + [Fact] public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies() { diff --git a/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs b/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs new file mode 100644 index 0000000..70ae1ec --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs @@ -0,0 +1,101 @@ +// EntityClipTests.cs +// +// Phase W Stage 4/5: unit-test WbDrawDispatcher.EntityPassesVisibleCellGate. +// The gate is internal static (AcDream.App is InternalsVisibleTo AcDream.App.Tests) +// and pure — tests it without a GL context. Covers: ParentCellId in the visible +// set → included; ParentCellId NOT in the set → excluded; null visibleCellIds → +// everything included (outdoor / unconstrained root). +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public sealed class EntityClipTests +{ + // Minimal WorldEntity factory. EntityPassesVisibleCellGate only reads + // ParentCellId and IsBuildingShell/BuildingShellAnchorCellId from the entity; + // the other required fields are set to safe sentinel values. + private static WorldEntity Entity(uint? parentCellId, bool isShell = false, uint? shellAnchor = null) => + new WorldEntity + { + Id = 1u, + SourceGfxObjOrSetupId = 0u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + ParentCellId = parentCellId, + IsBuildingShell = isShell, + BuildingShellAnchorCellId = shellAnchor, + }; + + [Fact] + public void EntityClip_ParentInVisibleSet_Included() + { + // Entity whose ParentCellId is in the visible set must pass the gate. + const uint cellId = 0xA9B40170u; + var visibleCellIds = new HashSet { cellId }; + var entity = Entity(parentCellId: cellId); + + bool result = WbDrawDispatcher.EntityPassesVisibleCellGate( + entity, visibleCellIds, WbDrawDispatcher.EntitySet.All); + + Assert.True(result); + } + + [Fact] + public void EntityClip_ParentNotInVisibleSet_Excluded() + { + // Entity whose ParentCellId is NOT in the visible set must fail the gate. + const uint visibleCell = 0xA9B40170u; + const uint entityCell = 0xA9B40172u; + var visibleCellIds = new HashSet { visibleCell }; + var entity = Entity(parentCellId: entityCell); + + bool result = WbDrawDispatcher.EntityPassesVisibleCellGate( + entity, visibleCellIds, WbDrawDispatcher.EntitySet.All); + + Assert.False(result); + } + + [Fact] + public void EntityClip_NullVisibleSet_IncludesAll() + { + // Null visibleCellIds means the outdoor root — no culling, all entities pass. + var entity = Entity(parentCellId: 0xA9B40172u); + + bool result = WbDrawDispatcher.EntityPassesVisibleCellGate( + entity, visibleCellIds: null, WbDrawDispatcher.EntitySet.All); + + Assert.True(result); + } + + [Fact] + public void EntityClip_NullParentCell_NullVisibleSet_Included() + { + // An outdoor entity (ParentCellId == null) with null visibleCellIds passes. + var entity = Entity(parentCellId: null); + + bool result = WbDrawDispatcher.EntityPassesVisibleCellGate( + entity, visibleCellIds: null, WbDrawDispatcher.EntitySet.All); + + Assert.True(result); + } + + [Fact] + public void EntityClip_NullParentCell_NonNullVisibleSet_Included() + { + // An outdoor entity (ParentCellId == null) with a non-null visibleCellIds + // falls through to the final return-true (not a shell, not shell-scoped); + // outdoor scenery is not gated by the indoor cell filter. + var visibleCellIds = new HashSet { 0xA9B40170u }; + var entity = Entity(parentCellId: null); + + bool result = WbDrawDispatcher.EntityPassesVisibleCellGate( + entity, visibleCellIds, WbDrawDispatcher.EntitySet.All); + + Assert.True(result); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 9d4fb02..a648a07 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -378,6 +378,61 @@ public class PortalVisibilityBuilderTests // see docs/research/2026-05-31-u4c-flap-characterization.md. // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // Phase W Stage 4/5: BFS exit-portal + root-cell ordering guarantees + // ----------------------------------------------------------------------- + + [Fact] + public void Build_ExitPortalVisible_OutsideViewNonEmpty() + { + // A cell with a single exit portal (OtherCellId == 0xFFFF) whose polygon + // projects in front of the camera. The BFS must union the clipped region + // into OutsideView, making it non-empty. + var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); // in front, in frustum + var all = new Dictionary { [0x0001] = cam }; + + var frame = Build(cam, all); + + Assert.True(frame.OutsideView.Polygons.Count > 0, + "An exit portal (OtherCellId=0xFFFF) in front of the camera must populate OutsideView"); + } + + [Fact] + public void Build_NoExitPortal_OutsideViewEmpty() + { + // A cell whose only portal leads to another interior cell (OtherCellId != 0xFFFF). + // With no exit portal in any reachable cell the OutsideView must remain empty. + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f)); + var inner = Cell(0x0002); // no portals — no exit + var all = new Dictionary { [0x0001] = cam, [0x0002] = inner }; + + var frame = Build(cam, all); + + Assert.True(frame.OutsideView.Polygons.Count == 0, + "No exit portal in any reachable cell must leave OutsideView empty"); + } + + [Fact] + public void Build_RootCellAlwaysFirstInOrderedVisibleCells() + { + // The camera cell seeds the BFS at distance 0 so it must always pop + // first → OrderedVisibleCells[0] == cameraCell.CellId. + // Use the three-cell chain: A→B→C with exit window on C. + var (cells, lookup) = SyntheticChain(); + var f = PortalVisibilityBuilder.Build( + cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj()); + + Assert.NotEmpty(f.OrderedVisibleCells); + Assert.Equal(cells[0].CellId, f.OrderedVisibleCells[0]); + } + + // Note: Build_CyclicPortalGraph_Terminates is already covered by + // Builder_CyclicGraph_TerminatesWithBoundedPolys and Build_CyclicHub_TerminatesAndBounds. + // The dedup invariant (each cell appears at most once) is validated by + // Build_CyclicHub_TerminatesAndBounds via OrderedVisibleCells.Distinct().Count(). + // Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC. private static float CellViewArea(CellView view) {