using System.Collections.Concurrent; using System.Numerics; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Plane = System.Numerics.Plane; namespace AcDream.Core.Physics; /// /// 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. /// public sealed class PhysicsDataCache { private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); private readonly ConcurrentDictionary _cellStruct = new(); // ── Phase 2: building portal cache for outdoor→indoor entry ─────────── private readonly ConcurrentDictionary _buildings = new(); /// /// 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. /// 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; _gfxObj[gfxObjId] = new GfxObjPhysics { BSP = gfxObj.PhysicsBSP, PhysicsPolygons = gfxObj.PhysicsPolygons, BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere, Vertices = gfxObj.VertexArray, Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), }; } /// /// Get the cached visual AABB for a GfxObj, or null if not cached. /// public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => _visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null; /// /// 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. /// 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, }; } /// /// Extract and cache the collision shape data from a Setup. /// No-ops if the id is already cached. /// 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, }; } /// /// 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. /// public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, CellStruct cellStruct, Matrix4x4 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(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 per the DatReaderWriter shape — iterate directly, no .Keys. var visibleCellIds = new System.Collections.Generic.HashSet(); if (envCell.VisibleCells is not null) { uint lbPrefix = envCellId & 0xFFFF0000u; foreach (var lowId in envCell.VisibleCells) visibleCellIds.Add(lbPrefix | lowId); } _cellStruct[envCellId] = 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, }; 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(); 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); 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})")); } } /// /// 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. /// internal static Dictionary ResolvePolygons( Dictionary polys, VertexArray vertexArray) { var resolved = new Dictionary(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, }; } return resolved; } public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : 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; /// /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached /// EnvCell ids — used by /// 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. /// public IReadOnlyCollection CellStructIds => (IReadOnlyCollection)_cellStruct.Keys; /// /// Register a pre-built directly. /// Intended for unit-test fixtures that construct synthetic BSP trees /// without needing real DAT content. /// public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics) => _gfxObj[gfxObjId] = physics; /// /// Register a pre-built directly. Intended for /// unit-test fixtures that construct synthetic cells without going through /// dat-driven . /// public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) => _cellStruct[envCellId] = physics; /// /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list /// for an outdoor landcell that contains a building stab. Used by /// . /// public void CacheBuilding(uint landcellId, IReadOnlyList 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 BuildingIds => (IReadOnlyCollection)_buildings.Keys; /// Test helper, mirrors . public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; } /// /// 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. /// public sealed class GfxObjVisualBounds { /// Local-space minimum corner of the mesh AABB. public required Vector3 Min { get; init; } /// Local-space maximum corner of the mesh AABB. public required Vector3 Max { get; init; } /// Center of the local-space AABB. public required Vector3 Center { get; init; } /// Local-space radius (diagonal half-length) — loose bound. public required float Radius { get; init; } /// Local-space half-extents ((Max - Min) * 0.5). public required Vector3 HalfExtents { get; init; } } /// /// 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. /// 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; } } /// Cached physics data for a single GfxObj part. public sealed class GfxObjPhysics { public required PhysicsBSPTree BSP { get; init; } public required Dictionary PhysicsPolygons { get; init; } public Sphere? BoundingSphere { get; init; } public required VertexArray Vertices { get; init; } /// /// 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. /// public required Dictionary Resolved { get; init; } } /// Cached collision shape data for a Setup (character/creature capsule). public sealed class SetupPhysics { public List CylSpheres { get; init; } = new(); public List Spheres { get; init; } = new(); public float Height { get; init; } public float Radius { get; init; } public float StepUpHeight { get; init; } public float StepDownHeight { get; init; } } /// /// 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. /// public sealed class CellPhysics { /// /// The physics BSP tree for this cell. Nullable so that test fixtures /// can construct a from /// alone without needing a real DAT BSP object. Production code must /// null-check before traversal: cell.BSP?.Root is not null. /// public PhysicsBSPTree? BSP { get; init; } public Dictionary? PhysicsPolygons { get; init; } public VertexArray? Vertices { get; init; } public Matrix4x4 WorldTransform { get; init; } public Matrix4x4 InverseWorldTransform { get; init; } /// /// Pre-resolved polygon data with vertex positions and computed planes. /// public required Dictionary Resolved { get; init; } // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ─────── /// /// The cell BSP used for /// (point-in-cell tests). Separate tree from /// (collision) and from the renderer's drawing-BSP. /// Source: cellStruct.CellBSP at cache time. /// Nullable: cells without a CellBSP cannot participate in portal /// containment and are skipped by . /// public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } /// /// Portal connections to neighbouring cells, in cell-local space. /// Default: empty list. Source: envCell.CellPortals. /// public IReadOnlyList Portals { get; init; } = System.Array.Empty(); /// /// Resolved VISIBLE polygons (from cellStruct.Polygons), /// keyed by polygon id. Distinct from which /// holds PhysicsPolygons. Portal lookup via /// resolves through this dict. /// Nullable when the cell has no visible polys (rare). /// public Dictionary? PortalPolygons { get; init; } /// /// The full cell ids visible from this cell (with landblock prefix). /// Populated from envCell.VisibleCells at cache time. Unused /// this phase; reserved for the optional find_cell_list /// visibility filter. /// public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); }