diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs new file mode 100644 index 00000000..0830f096 --- /dev/null +++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell +/// (spec docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md). +/// Its "shell" is the landscape (drawn by the terrain renderer); its portals are the +/// reverse of each nearby building's exit portal (OtherCellId==0xFFFF). One node per +/// frame, keyed by the viewer's outdoor landcell id. WorldTransform is identity +/// (portals stored in world space). Mirrors retail's outdoor landcell that +/// DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635). +/// +public static class OutdoorCellNode +{ + public static LoadedCell Build(uint outdoorCellId, IReadOnlyList nearbyBuildingCells) + { + var node = new LoadedCell + { + CellId = outdoorCellId, + SeenOutside = true, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + foreach (var bcell in nearbyBuildingCells) + { + for (int i = 0; i < bcell.Portals.Count; i++) + { + if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors + if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue; + + // Reverse portal: outdoor node -> this building cell. + node.Portals.Add(new CellPortalInfo( + OtherCellId: (ushort)(bcell.CellId & 0xFFFFu), + PolygonId: bcell.Portals[i].PolygonId, + Flags: bcell.Portals[i].Flags, + OtherPortalId: (ushort)i)); + + // Entrance polygon -> world space (node transform is identity). + var srcPoly = bcell.PortalPolygons[i]; + var worldPoly = new Vector3[srcPoly.Length]; + for (int v = 0; v < srcPoly.Length; v++) + worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform); + node.PortalPolygons.Add(worldPoly); + + // Clip plane -> world space, inside-side flipped (outdoor half-space is "inside"). + var src = bcell.ClipPlanes[i]; + var worldNormal = Vector3.Normalize(Vector3.TransformNormal(src.Normal, bcell.WorldTransform)); + var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform); + node.ClipPlanes.Add(new PortalClipPlane + { + Normal = worldNormal, + D = -Vector3.Dot(worldNormal, pointOnPlane), + InsideSide = src.InsideSide == 0 ? 1 : 0, + }); + } + } + + return node; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs new file mode 100644 index 00000000..1129b295 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class OutdoorCellNodeTests +{ + // A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF) + // whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal + // back into that building cell, with the entrance polygon moved to world space and the + // inside-side flipped (so the outdoor half-space is "inside" the node). + private static LoadedCell BuildingWithOneExit(uint cellId) + { + var cell = new LoadedCell { CellId = cellId }; + cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f); + cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f); + cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0)); + cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 }); + cell.PortalPolygons.Add(new[] + { + new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2) + }); + return cell; + } + + [Fact] + public void Build_FromBuildingExit_AddsReversePortalIntoBuilding() + { + uint outdoorId = 0xA9B40031; + var building = BuildingWithOneExit(0xA9B40170); + var node = OutdoorCellNode.Build(outdoorId, new[] { building }); + + Assert.Equal(outdoorId, node.CellId); + Assert.True(node.SeenOutside); + Assert.Equal(Matrix4x4.Identity, node.WorldTransform); + Assert.Single(node.Portals); + Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId); + // Reversed inside-side: the building's exit was InsideSide=0, the node's is 1. + Assert.Equal(1, node.ClipPlanes[0].InsideSide); + // Entrance polygon moved to world space (building translated +10 X): first vert x≈10. + Assert.Equal(10f, node.PortalPolygons[0][0].X, 3); + } + + [Fact] + public void Build_NoBuildings_ReturnsEmptyPortalNode() + { + var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty()); + Assert.Empty(node.Portals); + Assert.True(node.SeenOutside); + } +}