acdream/src/AcDream.Core/Physics/PhysicsDataCache.cs
Erik 8e703bef22 feat(core): UCG Stage 1 — populate CellGraph from CacheCellStruct + AddLandblock (inert)
PhysicsDataCache gains a `CellGraph` property (UCG Stage 1). The env-cell
hook is placed at the very top of CacheCellStruct — before the idempotency
guard and the null-PhysicsBSP early-return — so BSP-less cells are included
in the graph even though they are dropped from the legacy _cellStruct map.
PhysicsEngine.AddLandblock/RemoveLandblock mirror terrain registration into
the graph via a null-guarded DataCache?.CellGraph call. Zero behavior change:
CellGraph has no readers this stage.

A using-alias (UcgEnvCell / UcgCellGraph) resolves the EnvCell name
collision between AcDream.Core.World.Cells and DatReaderWriter.DBObjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:29:30 +02:00

577 lines
24 KiB
C#

using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Plane = System.Numerics.Plane;
using UcgEnvCell = AcDream.Core.World.Cells.EnvCell;
using UcgCellGraph = AcDream.Core.World.Cells.CellGraph;
namespace AcDream.Core.Physics;
/// <summary>
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
/// dat objects during streaming. Populated by the streaming worker thread;
/// read by the physics engine on the game/render thread. ConcurrentDictionary
/// makes cross-thread access safe without a global lock.
/// </summary>
public sealed class PhysicsDataCache
{
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
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>
/// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches.
/// Consumed by nobody this stage (zero behavior change).
/// </summary>
public UcgCellGraph CellGraph { get; } = 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
/// the HasPhysics flag. The visual AABB is used as a collision fallback
/// for entities whose Setup has no retail physics data — it lets the
/// user collide with decorative meshes that don't have a CylSphere or
/// per-part BSP.
/// </summary>
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
{
// Always cache a visual AABB from the mesh vertices — this is cheap
// and fed by the mesh data that's already loaded. It serves as the
// fallback collision shape for pure-visual entities.
if (!_visualBounds.ContainsKey(gfxObjId) && gfxObj.VertexArray != null)
{
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
}
if (_gfxObj.ContainsKey(gfxObjId)) return;
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
if (gfxObj.PhysicsBSP?.Root is null) return;
if (gfxObj.VertexArray is null) return;
var physics = new GfxObjPhysics
{
BSP = gfxObj.PhysicsBSP,
PhysicsPolygons = gfxObj.PhysicsPolygons,
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
};
_gfxObj[gfxObjId] = physics;
if (PhysicsDiagnostics.ProbeDumpGfxObjsEnabled
&& PhysicsDiagnostics.ProbeDumpGfxObjIds.Contains(gfxObjId))
{
try
{
var dump = GfxObjDumpSerializer.Capture(gfxObjId, physics);
var path = System.IO.Path.Combine(
PhysicsDiagnostics.ProbeDumpGfxObjsPath,
System.FormattableString.Invariant($"0x{gfxObjId:X8}.gfxobj.json"));
GfxObjDumpSerializer.Write(dump, path);
Console.WriteLine(System.FormattableString.Invariant(
$"[gfxobj-dump] wrote 0x{gfxObjId:X8} polys={dump.ResolvedPolygons.Count} → {path}"));
}
catch (Exception ex)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[gfxobj-dump] FAILED to dump 0x{gfxObjId:X8}: {ex.GetType().Name}: {ex.Message}"));
}
}
}
/// <summary>
/// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
/// <summary>
/// Compute a tight axis-aligned bounding box over all vertices in the mesh.
/// Used as a fallback collision shape for entities whose Setup has no
/// physics data — we approximate collision using the visual extent.
/// </summary>
private static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
{
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
{
return new GfxObjVisualBounds
{
Min = Vector3.Zero,
Max = Vector3.Zero,
Center = Vector3.Zero,
Radius = 0f,
HalfExtents = Vector3.Zero,
};
}
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var kv in vertexArray.Vertices)
{
var p = kv.Value.Origin;
if (p.X < min.X) min.X = p.X;
if (p.Y < min.Y) min.Y = p.Y;
if (p.Z < min.Z) min.Z = p.Z;
if (p.X > max.X) max.X = p.X;
if (p.Y > max.Y) max.Y = p.Y;
if (p.Z > max.Z) max.Z = p.Z;
}
var center = (min + max) * 0.5f;
var halfExt = (max - min) * 0.5f;
float radius = halfExt.Length();
return new GfxObjVisualBounds
{
Min = min,
Max = max,
Center = center,
Radius = radius,
HalfExtents = halfExt,
};
}
/// <summary>
/// Extract and cache the collision shape data from a Setup.
/// No-ops if the id is already cached.
/// </summary>
public void CacheSetup(uint setupId, Setup setup)
{
if (_setup.ContainsKey(setupId)) return;
_setup[setupId] = new SetupPhysics
{
CylSpheres = setup.CylSpheres ?? new(),
Spheres = setup.Spheres ?? new(),
Height = setup.Height,
Radius = setup.Radius,
StepUpHeight = setup.StepUpHeight,
StepDownHeight = setup.StepDownHeight,
};
}
/// <summary>
/// Extract and cache the physics BSP + polygon data from a CellStruct
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
/// </summary>
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct, Matrix4x4 worldTransform)
{
// UCG Stage 1: register in the unified graph for ALL cells — before the
// idempotency + null-BSP guards below, so BSP-less cells are still included.
if (!CellGraph.Contains(envCellId))
CellGraph.Add(UcgEnvCell.FromDat(envCellId, envCell, cellStruct, worldTransform));
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
Matrix4x4.Invert(worldTransform, out var inverseTransform);
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
// Portal list from envCell.CellPortals.
var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
foreach (var p in envCell.CellPortals)
{
portals.Add(new PortalInfo(
otherCellId: p.OtherCellId,
polygonId: p.PolygonId,
flags: (ushort)p.Flags));
}
// VisibleCells set — populated for future use; not consulted this phase.
// envCell.VisibleCells is List<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
if (envCell.VisibleCells is not null)
{
uint lbPrefix = envCellId & 0xFFFF0000u;
foreach (var lowId in envCell.VisibleCells)
visibleCellIds.Add(lbPrefix | lowId);
}
var cellPhysics = new CellPhysics
{
BSP = cellStruct.PhysicsBSP,
PhysicsPolygons = cellStruct.PhysicsPolygons,
Vertices = cellStruct.VertexArray,
WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform,
Resolved = resolved,
// ── Phase 2 portal fields ──
CellBSP = cellStruct.CellBSP,
Portals = portals,
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
};
_cellStruct[envCellId] = cellPhysics;
if (PhysicsDiagnostics.ProbeDumpCellsEnabled
&& PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId))
{
try
{
var dump = CellDumpSerializer.Capture(envCellId, cellPhysics);
var path = System.IO.Path.Combine(
PhysicsDiagnostics.ProbeDumpCellsPath,
System.FormattableString.Invariant($"0x{envCellId:X8}.json"));
CellDumpSerializer.Write(dump, path);
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-dump] wrote 0x{envCellId:X8} polys={dump.ResolvedPolygons.Count} portals={dump.Portals.Count} → {path}"));
}
catch (Exception ex)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-dump] FAILED to dump 0x{envCellId:X8}: {ex.GetType().Name}: {ex.Message}"));
}
}
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
{
var root = cellStruct.PhysicsBSP?.Root;
int bspRootPolyCount = root?.Polygons?.Count ?? 0;
bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
int bspTotalLeafPolys = 0;
int bspUnmatchedIds = 0;
if (root is not null)
{
var stack = new System.Collections.Generic.Stack<DatReaderWriter.Types.PhysicsBSPNode>();
stack.Push(root);
while (stack.Count > 0)
{
var n = stack.Pop();
if (n.Polygons is not null)
{
foreach (var pid in n.Polygons)
{
bspTotalLeafPolys++;
if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
}
}
if (n.PosNode is not null) stack.Push(n.PosNode);
if (n.NegNode is not null) stack.Push(n.NegNode);
}
}
var bs = root?.BoundingSphere;
string bsStr = bs is null
? "bsphere=n/a"
: System.FormattableString.Invariant(
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
// A6.P3 slice 4 (2026-05-22): also dump portal targets so we
// can see which cells the player should be able to transition
// to (issue #98 investigation: cellar-up stuck at top of ramp).
string portalTargets;
if (portals.Count == 0)
{
portalTargets = "portalTargets=[]";
}
else
{
var sb = new System.Text.StringBuilder("portalTargets=[");
for (int i = 0; i < portals.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(System.FormattableString.Invariant(
$"(cell=0x{portals[i].OtherCellId:X4},poly=0x{portals[i].PolygonId:X4},flags=0x{portals[i].Flags:X4})"));
}
sb.Append(']');
portalTargets = sb.ToString();
}
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2}) {portalTargets}"));
}
if (PhysicsDiagnostics.ProbeWalkMissEnabled)
{
int walkableCount = 0;
foreach (var entry in WalkMissDiagnostic.EnumerateWalkable(
resolved, PhysicsGlobals.FloorZ))
walkableCount++;
Console.Write(System.FormattableString.Invariant(
$"[floor-polys] cellId=0x{envCellId:X8} walkableCount={walkableCount}"));
foreach (var entry in WalkMissDiagnostic.EnumerateWalkable(
resolved, PhysicsGlobals.FloorZ))
{
Console.Write(System.FormattableString.Invariant(
$" [id=0x{entry.PolyId:X4} nz={entry.NormalZ:F3} bbox=({entry.BboxMin.X:F2},{entry.BboxMin.Y:F2})..({entry.BboxMax.X:F2},{entry.BboxMax.Y:F2}) planeZ@center={entry.PlaneZAtBboxCenter:F3}]"));
}
Console.WriteLine();
}
}
/// <summary>
/// Pre-resolve all physics polygons: lookup vertex positions from VertexArray
/// and compute the face plane. Matches ACE's Polygon constructor which calls
/// make_plane() and resolves Vertices from VertexIDs at load time.
/// </summary>
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(polys.Count);
foreach (var (id, poly) in polys)
{
int numVerts = poly.VertexIds.Count;
if (numVerts < 3) continue;
var verts = new Vector3[numVerts];
bool valid = true;
for (int i = 0; i < numVerts; i++)
{
ushort vid = (ushort)poly.VertexIds[i];
if (!vertexArray.Vertices.TryGetValue(vid, out var sv))
{ valid = false; break; }
verts[i] = sv.Origin;
}
if (!valid) continue;
// Compute plane normal using ACE's make_plane algorithm:
// fan cross-product accumulation + normalization.
var normal = Vector3.Zero;
for (int i = 1; i < numVerts - 1; i++)
{
var v1 = verts[i] - verts[0];
var v2 = verts[i + 1] - verts[0];
normal += Vector3.Cross(v1, v2);
}
float len = normal.Length();
if (len < 1e-8f) continue;
normal /= len;
// D = -(average dot(normal, vertex))
float dotSum = 0f;
for (int i = 0; i < numVerts; i++)
dotSum += Vector3.Dot(normal, verts[i]);
float d = -(dotSum / numVerts);
resolved[id] = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(normal, d),
NumPoints = numVerts,
SidesType = poly.SidesType,
Id = id,
};
}
return resolved;
}
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
/// <summary>
/// Issue #101 (2026-05-25): retail-faithful phantom check for
/// GfxObj-only entity sources. Returns true when the entity's
/// <c>SourceGfxObjOrSetupId</c> is a GfxObj (high byte
/// <c>0x01</c>) AND has no cached <see cref="GfxObjPhysics"/> —
/// meaning the underlying GfxObj had <c>HasPhysics=False</c> or
/// a null <c>PhysicsBSP.Root</c>, so <see cref="CacheGfxObj"/>
/// short-circuited at the early-return on line 45/46. Retail's
/// <c>CPartArray::InitParts</c> emits NO collision shapes for
/// these — acdream's <c>mesh-aabb-fallback</c> synthesis at
/// <c>GameWindow.cs:6127</c> must do the same.
/// </summary>
public bool IsPhantomGfxObjSource(uint sourceId)
{
if ((sourceId & 0xFF000000u) != 0x01000000u) return false;
return GetGfxObj(sourceId)?.BSP?.Root is null;
}
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public int GfxObjCount => _gfxObj.Count;
public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
/// to enumerate occluder candidates without exposing the underlying
/// dictionary. Returns the live key-set; callers should snapshot the
/// collection if they need stability across frames.
/// </summary>
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
/// without needing real DAT content.
/// </summary>
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
=> _gfxObj[gfxObjId] = physics;
/// <summary>
/// Register a pre-built <see cref="CellPhysics"/> directly. Intended for
/// unit-test fixtures that construct synthetic cells without going through
/// dat-driven <see cref="CacheCellStruct"/>.
/// </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>
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
/// of whether it has physics data. Used as a collision fallback shape for
/// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative
/// meshes). Provides an approximate cylinder matching the visible mesh extent.
/// </summary>
public sealed class GfxObjVisualBounds
{
/// <summary>Local-space minimum corner of the mesh AABB.</summary>
public required Vector3 Min { get; init; }
/// <summary>Local-space maximum corner of the mesh AABB.</summary>
public required Vector3 Max { get; init; }
/// <summary>Center of the local-space AABB.</summary>
public required Vector3 Center { get; init; }
/// <summary>Local-space radius (diagonal half-length) — loose bound.</summary>
public required float Radius { get; init; }
/// <summary>Local-space half-extents ((Max - Min) * 0.5).</summary>
public required Vector3 HalfExtents { get; init; }
}
/// <summary>
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
/// to avoid per-collision-test vertex lookups.
/// </summary>
public sealed class ResolvedPolygon
{
public required Vector3[] Vertices { get; init; }
public required Plane Plane { get; init; }
public required int NumPoints { get; init; }
public required CullMode SidesType { get; init; }
/// <summary>
/// Polygon index within its parent (cell or GfxObj). Used by the
/// `ACDREAM_PROBE_POLY_DUMP` probe (A6.P3 slice 4 investigation,
/// 2026-05-22) to identify which dat polygon a push-back hit so we
/// can compare our extracted vertices/plane against WorldBuilder's
/// straight-from-dat read. Defaults to 0 for test fixtures that
/// don't care about polygon identity.
/// </summary>
public ushort Id { get; init; }
}
/// <summary>Cached physics data for a single GfxObj part.</summary>
public sealed class GfxObjPhysics
{
public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; }
public required VertexArray Vertices { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
public sealed class SetupPhysics
{
public List<CylSphere> CylSpheres { get; init; } = new();
public List<Sphere> Spheres { get; init; } = new();
public float Height { get; init; }
public float Radius { get; init; }
public float StepUpHeight { get; init; }
public float StepDownHeight { get; init; }
}
/// <summary>
/// Cached physics data for an indoor cell's room geometry (CellStruct).
/// Used for wall/floor/ceiling collision in EnvCells.
/// ACE: EnvCell.find_env_collisions queries CellStructure.PhysicsBSP.
/// </summary>
public sealed class CellPhysics
{
/// <summary>
/// The physics BSP tree for this cell. Nullable so that test fixtures
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
/// alone without needing a real DAT BSP object. Production code must
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
/// </summary>
public PhysicsBSPTree? BSP { get; init; }
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
public VertexArray? Vertices { get; init; }
public Matrix4x4 WorldTransform { get; init; }
public Matrix4x4 InverseWorldTransform { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
/// </summary>
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
/// <summary>
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
/// holds <c>PhysicsPolygons</c>. Portal lookup via
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
/// Nullable when the cell has no visible polys (rare).
/// </summary>
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
/// <summary>
/// The full cell ids visible from this cell (with landblock prefix).
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
/// this phase; reserved for the optional <c>find_cell_list</c>
/// visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
}