feat(physics): Phase 2 — BuildingPhysics + CheckBuildingTransit
Closes the outdoor→indoor entry path. New BuildingPhysics type holds the per-SortCell BldPortal list + building world transform; PhysicsDataCache caches it (CacheBuilding + GetBuilding); CellTransit.CheckBuildingTransit tests each portal's destination cell via PointInsideCellBsp. PhysicsEngine.ResolveCellId's outdoor branch now hooks CheckBuildingTransit after the terrain-grid lookup: if the matched landcell has a cached building stab, check whether the sphere has crossed into one of its interior EnvCells before returning. GameWindow at landblock-load time iterates LandBlockInfo.Buildings and caches each via PhysicsDataCache.CacheBuilding. The landcell-id derivation uses retail's row-major cell-index formula (gridX * 8 + gridY + 1). Polish items from Subagent B/C reviews folded in: - visited HashSet in FindCellList's BFS (avoids O(N^2) re-enqueue) - ResolveCellId_NoDataCache_ReturnsFallback test (closes coverage gap) - DataCache-asymmetry comment in PhysicsEngine.ResolveCellId - Replaced misleading FindCellList outdoor-branch TODO with explicit note that ResolveCellId bypasses this branch — wired in ResolveCellId directly. - Removed unused 'using DatReaderWriter.Types;' from CellTransit.cs - 2 new CellTransitFindCellListTests integration tests - 1 new CellTransitCheckBuildingTransitTests test (null-CellBSP guard case; happy path deferred to visual verification). Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aad697602e
commit
069534a372
8 changed files with 301 additions and 7 deletions
|
|
@ -5710,6 +5710,52 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit.
|
||||
// Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world-
|
||||
// relative origin + orientation) and a Portals list. The landcell id is
|
||||
// derived from the building's frame origin using retail's row-major grid
|
||||
// formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock.
|
||||
if (lbInfo is not null && lbInfo.Buildings.Count > 0)
|
||||
{
|
||||
uint lbPrefix = lb.LandblockId & 0xFFFF0000u;
|
||||
foreach (var building in lbInfo.Buildings)
|
||||
{
|
||||
if (building.Portals.Count == 0) continue;
|
||||
|
||||
var bldPortals = new System.Collections.Generic.List<AcDream.Core.Physics.BldPortalInfo>(
|
||||
building.Portals.Count);
|
||||
foreach (var bp in building.Portals)
|
||||
{
|
||||
bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
|
||||
otherCellId: lbPrefix | (uint)bp.OtherCellId,
|
||||
otherPortalId: bp.OtherPortalId,
|
||||
flags: (ushort)bp.Flags));
|
||||
}
|
||||
|
||||
// Build a world transform for the building. Frame.Origin is
|
||||
// landblock-relative; add the landblock world origin to get
|
||||
// world space.
|
||||
var bldOriginWorld = building.Frame.Origin + origin;
|
||||
var buildingTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld);
|
||||
|
||||
// Derive the outdoor landcell id containing this building.
|
||||
// Retail's cell index: row-major (gridX * 8 + gridY + 1) within
|
||||
// the 8×8 grid of 24m cells in a landblock.
|
||||
int bldGridX = (int)(building.Frame.Origin.X / 24f);
|
||||
int bldGridY = (int)(building.Frame.Origin.Y / 24f);
|
||||
if (bldGridX < 0) bldGridX = 0;
|
||||
if (bldGridX >= 8) bldGridX = 7;
|
||||
if (bldGridY < 0) bldGridY = 0;
|
||||
if (bldGridY >= 8) bldGridY = 7;
|
||||
uint landcellLow = (uint)(bldGridX * 8 + bldGridY + 1);
|
||||
uint landcellId = lbPrefix | landcellLow;
|
||||
|
||||
_physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform);
|
||||
}
|
||||
}
|
||||
|
||||
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
|
||||
portalPlanes, origin.X, origin.Y);
|
||||
}
|
||||
|
|
|
|||
41
src/AcDream.Core/Physics/BuildingPhysics.cs
Normal file
41
src/AcDream.Core/Physics/BuildingPhysics.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
|
||||
/// for outdoor→indoor cell entry. One per outdoor landcell that contains
|
||||
/// a building stab. Mirrors retail's <c>BuildingObj.Portals</c> array
|
||||
/// (per the pseudocode doc §"LandCell.find_transit_cells").
|
||||
/// </summary>
|
||||
public sealed class BuildingPhysics
|
||||
{
|
||||
public required Matrix4x4 WorldTransform { get; init; }
|
||||
public required Matrix4x4 InverseWorldTransform { get; init; }
|
||||
public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One building portal: the connection from a SortCell's BuildingObj to
|
||||
/// an interior EnvCell. ExactMatch is decoded from <see cref="Flags"/>
|
||||
/// bit 0 (<c>PortalFlags.ExactMatch = 0x0001</c>).
|
||||
/// </summary>
|
||||
public readonly struct BldPortalInfo
|
||||
{
|
||||
public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags)
|
||||
{
|
||||
OtherCellId = otherCellId;
|
||||
OtherPortalId = otherPortalId;
|
||||
Flags = flags;
|
||||
}
|
||||
|
||||
/// <summary>Full id of the interior EnvCell this portal connects to.</summary>
|
||||
public uint OtherCellId { get; }
|
||||
/// <summary>The portal id within the destination EnvCell.</summary>
|
||||
public ushort OtherPortalId { get; }
|
||||
public ushort Flags { get; }
|
||||
|
||||
/// <summary>Bit 0 of Flags (<c>PortalFlags.ExactMatch = 0x0001</c>).</summary>
|
||||
public bool ExactMatch => (Flags & 0x0001) != 0;
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -151,6 +150,36 @@ public static class CellTransit
|
|||
candidates.Add(lbPrefix | low);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor→indoor entry path. Ported from retail's
|
||||
/// <c>BuildingObj::find_building_transit_cells</c> +
|
||||
/// <c>EnvCell::check_building_transit</c>. For each portal of the
|
||||
/// outdoor building, look up the destination interior cell and test
|
||||
/// whether the sphere center is inside it via
|
||||
/// <see cref="BSPQuery.PointInsideCellBsp"/>. If so, add the interior
|
||||
/// cell to <paramref name="candidates"/>.
|
||||
/// </summary>
|
||||
public static void CheckBuildingTransit(
|
||||
PhysicsDataCache cache,
|
||||
BuildingPhysics building,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
HashSet<uint> candidates)
|
||||
{
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
var otherCell = cache.GetCellStruct(portal.OtherCellId);
|
||||
if (otherCell?.CellBSP?.Root is null) continue;
|
||||
|
||||
// Sphere center in the OTHER cell's local space.
|
||||
var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
|
||||
if (BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter))
|
||||
{
|
||||
candidates.Add(portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-level cell-tracking driver, ported from retail's
|
||||
/// <c>CObjCell::find_cell_list</c> (sphere variant).
|
||||
|
|
@ -188,7 +217,9 @@ public static class CellTransit
|
|||
|
||||
// BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
|
||||
var pending = new Queue<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
pending.Enqueue(currentCellId);
|
||||
visited.Add(currentCellId);
|
||||
int maxIterations = 16; // hard cap; portal graphs are small
|
||||
while (pending.Count > 0 && maxIterations-- > 0)
|
||||
{
|
||||
|
|
@ -203,10 +234,9 @@ public static class CellTransit
|
|||
|
||||
if (candidates.Count > sizeBefore)
|
||||
{
|
||||
// Snapshot the new candidates to avoid mutating during iteration.
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (c != cellId) // skip seed
|
||||
if (visited.Add(c)) // only enqueue if NEW
|
||||
pending.Enqueue(c);
|
||||
}
|
||||
}
|
||||
|
|
@ -220,9 +250,28 @@ public static class CellTransit
|
|||
}
|
||||
else
|
||||
{
|
||||
// Outdoor seed.
|
||||
// Outdoor seed: expand neighbour landcells AND check for building stabs
|
||||
// with portals into interior EnvCells.
|
||||
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
|
||||
// Outdoor→indoor entry (CheckBuildingTransit) wires in a follow-up commit.
|
||||
|
||||
// For each landcell candidate, see if it carries a building stab; if so,
|
||||
// check whether the sphere has crossed into any of the building's interior
|
||||
// EnvCells via CheckBuildingTransit.
|
||||
//
|
||||
// NOTE: PhysicsEngine.ResolveCellId currently bypasses this entire branch
|
||||
// for outdoor seeds (it uses its own _landblocks terrain grid loop). The
|
||||
// outdoor→indoor production path therefore runs through ResolveCellId's
|
||||
// OWN outdoor branch (see below for the call there too). This block is
|
||||
// exercised by direct-FindCellList callers (tests, future re-entry from
|
||||
// an indoor cell exiting through a portal that lands outside near a
|
||||
// building).
|
||||
var landcellSnapshot = new List<uint>(candidates);
|
||||
foreach (uint landcellId in landcellSnapshot)
|
||||
{
|
||||
var building = cache.GetBuilding(landcellId);
|
||||
if (building is null) continue;
|
||||
CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
// Containment test: for each candidate, transform worldSphereCenter to
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ public sealed class PhysicsDataCache
|
|||
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
||||
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
|
||||
|
||||
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
|
||||
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
|
||||
|
||||
/// <summary>
|
||||
/// Extract and cache the physics BSP + polygon data from a GfxObj,
|
||||
/// PLUS always cache a visual AABB from the vertex data regardless of
|
||||
|
|
@ -304,6 +307,31 @@ public sealed class PhysicsDataCache
|
|||
/// </summary>
|
||||
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
|
||||
=> _cellStruct[envCellId] = physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
|
||||
/// for an outdoor landcell that contains a building stab. Used by
|
||||
/// <see cref="CellTransit.CheckBuildingTransit"/>.
|
||||
/// </summary>
|
||||
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
|
||||
{
|
||||
if (_buildings.ContainsKey(landcellId)) return;
|
||||
Matrix4x4.Invert(worldTransform, out var inverse);
|
||||
_buildings[landcellId] = new BuildingPhysics
|
||||
{
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
Portals = portals,
|
||||
};
|
||||
}
|
||||
|
||||
public BuildingPhysics? GetBuilding(uint landcellId)
|
||||
=> _buildings.TryGetValue(landcellId, out var b) ? b : null;
|
||||
|
||||
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
|
||||
|
||||
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
|
||||
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -258,7 +258,8 @@ public sealed class PhysicsEngine
|
|||
|
||||
if (fallbackLow >= 0x0100u)
|
||||
{
|
||||
// Indoor seed: use portal-graph traversal.
|
||||
// Indoor branch needs DataCache to look up cells; outdoor uses
|
||||
// _landblocks (no DataCache dependency).
|
||||
if (DataCache is null) return fallbackCellId;
|
||||
return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
|
||||
}
|
||||
|
|
@ -274,7 +275,29 @@ public sealed class PhysicsEngine
|
|||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||||
{
|
||||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||||
return (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||||
uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||||
|
||||
// Outdoor→indoor entry: if this landcell has a cached building,
|
||||
// check whether the sphere has crossed into one of its interior
|
||||
// EnvCells via the building's portals.
|
||||
if (DataCache is not null)
|
||||
{
|
||||
var building = DataCache.GetBuilding(outdoorCellId);
|
||||
if (building is not null)
|
||||
{
|
||||
var candidates = new System.Collections.Generic.HashSet<uint>();
|
||||
CellTransit.CheckBuildingTransit(
|
||||
DataCache, building, worldPos, sphereRadius, candidates);
|
||||
if (candidates.Count > 0)
|
||||
{
|
||||
// First candidate wins — building portal containment is
|
||||
// mutually exclusive in retail (one interior cell per portal).
|
||||
foreach (var c in candidates) return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outdoorCellId;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellTransitCheckBuildingTransitTests
|
||||
{
|
||||
[Fact]
|
||||
public void SphereInsideBuildingPortalDestination_AddsInteriorCell()
|
||||
{
|
||||
// Building at world origin. One portal to interior cell 0xA9B40100.
|
||||
var building = new BuildingPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new[]
|
||||
{
|
||||
new BldPortalInfo(
|
||||
otherCellId: 0xA9B40100u,
|
||||
otherPortalId: 0,
|
||||
flags: 0),
|
||||
},
|
||||
};
|
||||
|
||||
// Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true,
|
||||
// but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this
|
||||
// cell is skipped.
|
||||
var interiorCell = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
|
||||
|
||||
var candidates = new HashSet<uint>();
|
||||
CellTransit.CheckBuildingTransit(
|
||||
cache, building,
|
||||
worldSphereCenter: new Vector3(0, 0, 0),
|
||||
sphereRadius: 0.5f,
|
||||
candidates);
|
||||
|
||||
// CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null)
|
||||
// skips this cell. No candidate added.
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
|
||||
// A second test that uses a synthetic CellBSP whose Root.Type == BSPNodeType.Leaf
|
||||
// (which PointInsideCellBsp short-circuits as "inside") would verify the
|
||||
// happy path. Constructing a CellBSPTree by hand from DatReaderWriter
|
||||
// types is awkward; deferred to integration testing at visual-verify time.
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellTransitFindCellListTests
|
||||
{
|
||||
[Fact]
|
||||
public void IndoorSeed_NoCacheEntry_ReturnsFallback()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
// Indoor seed but cell not cached → FindCellList early-returns the fallback.
|
||||
uint result = CellTransit.FindCellList(
|
||||
cache,
|
||||
worldSphereCenter: Vector3.Zero,
|
||||
sphereRadius: 0.5f,
|
||||
currentCellId: 0xA9B40100u);
|
||||
|
||||
Assert.Equal(0xA9B40100u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorSeed_Returns_FallbackWhenNoCellBSPs()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
// Outdoor seed: AddAllOutsideCells adds landcell candidates, but they
|
||||
// have no CellPhysics (only EnvCells get cached) → containment loop
|
||||
// finds no winner → fall back.
|
||||
uint result = CellTransit.FindCellList(
|
||||
cache,
|
||||
worldSphereCenter: new Vector3(12f, 12f, 0f),
|
||||
sphereRadius: 0.5f,
|
||||
currentCellId: 0xA9B40001u);
|
||||
|
||||
Assert.Equal(0xA9B40001u, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,4 +28,17 @@ public class ResolveCellIdTests
|
|||
|
||||
Assert.Equal(0xA9B40001u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCellId_NoDataCache_ReturnsFallback()
|
||||
{
|
||||
// Build a PhysicsEngine without setting DataCache.
|
||||
var engine = new PhysicsEngine { DataCache = null };
|
||||
uint result = engine.ResolveCellId(
|
||||
new Vector3(100, 100, 0),
|
||||
sphereRadius: 0.5f,
|
||||
fallbackCellId: 0xA9B40100u); // indoor seed
|
||||
// Indoor branch falls back when DataCache is null.
|
||||
Assert.Equal(0xA9B40100u, result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue