test(render): Phase 2 — Build floods from the outdoor node into buildings (+cycle guard)

TDD characterisation test proving PortalVisibilityBuilder.Build correctly roots at the
outdoor cell node (OutdoorCellNode) and floods into adjacent buildings through their
entrance portals. No changes to Build or OutdoorCellNode were needed.

Key finding: the task spec's building fixture used InsideSide=0 for an exit portal whose
building interior is at Y>=5 (Normal=(0,-1,0), D=5). The correct InsideSide is 1
(interior where dot<=0 -> Y>=5); with InsideSide=0 the outdoor camera (Y=-3, dot=8)
incorrectly passes as "interior" of the building so OutdoorCellNode.Build's InsideSide
flip (0->1) puts the outdoor camera on the wrong side of the gate.
Corrected fixture uses InsideSide=1 matching OutdoorCellNodeTests geometry convention
(building interior = POSITIVE dot side, outdoor = negative dot side; flip makes outdoor
negative-dot side the traversable direction). Both tests pass; full suite 214/214.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 18:23:28 +02:00
parent 2a2cc97d28
commit c5b4f77fe4

View file

@ -0,0 +1,63 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class UnifiedFloodTests
{
// Shared building fixture: a building cell whose interior is at Y >= 5.
// The exit portal faces -Y (Normal=(0,-1,0)); interior is where dot<=0 -> Y>=5 (InsideSide=1).
// The outdoor camera is at Y=-3, which is the OUTDOOR side (Y<5).
// OutdoorCellNode.Build flips InsideSide to 0 so the outdoor camera (dot>=0 i.e. Y<5) passes
// the side test and the flood reaches the building.
private static LoadedCell MakeBuildingCell(uint cellId)
{
var building = new LoadedCell { CellId = cellId, SeenOutside = true };
building.WorldTransform = Matrix4x4.Identity;
building.InverseWorldTransform = Matrix4x4.Identity;
building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0));
// InsideSide=1: interior where (0,-1,0)·p+5 <= 0 -> Y>=5 (the building body is at Y>=5).
building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 1 });
building.PortalPolygons.Add(new[]
{
new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2)
});
return building;
}
[Fact]
public void Build_RootedAtOutdoorNode_FloodsIntoBuilding()
{
var building = MakeBuildingCell(0xA9B40170);
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null;
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself
Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building
}
[Fact]
public void Build_OutdoorBuildingCycle_Terminates()
{
// Building's exit portal reciprocally points back near the node; assert Build
// returns (does not hang) and the visible set is bounded/small.
var building = MakeBuildingCell(0xA9B40170);
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building
: (id & 0xFFFFu) == 0x0031 ? node : null;
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.True(frame.OrderedVisibleCells.Count < 10); // bounded, no runaway
}
}