acdream/src/AcDream.Core/Physics/CellSurface.cs
Erik 82f8d4f82e refactor(physics): remove parsed collision graphs
Publish prepared GfxObj, Setup, CellStruct, and EnvCell collision records without retaining their parsed DAT BSP, polygon, vertex, or shape graphs. Strip temporary physics bundles at stable world commit, keep graph traversal only as an explicit test/tooling oracle, and report graph residency from actual retained fields.

Validated by 115 focused collision/streaming tests, a zero-warning Release build, and 8,413 passing Release tests with five pre-existing skips.

Co-authored-by: Codex <codex@openai.com>
2026-07-25 18:21:19 +02:00

184 lines
6.5 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor floor resolver for a single EnvCell. Projects an XY point
/// onto the cell's floor polygons and returns the Z at that point.
///
/// <para>
/// Uses a simplified constructor that takes pre-transformed vertex
/// positions (world-space) and polygon vertex-id lists. The caller
/// is responsible for transforming CellStruct vertices from cell-local
/// space to world space using EnvCell.Position before constructing
/// this surface.
/// </para>
///
/// <para>
/// Floor polygon iteration is brute-force (no BSP). Cell polygon
/// counts are typically &lt; 20, making this acceptable for the MVP.
/// Each polygon is fan-triangulated and tested via point-in-triangle
/// + barycentric Z interpolation.
/// </para>
/// </summary>
public sealed class CellSurface
{
public uint CellId { get; }
private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles;
/// <summary>
/// Construct a CellSurface from pre-transformed vertex positions
/// and polygon definitions.
/// </summary>
/// <param name="cellId">The EnvCell dat id (e.g., 0xA9B40100).</param>
/// <param name="vertices">Vertex id → world-space position map.</param>
/// <param name="polygonVertexIds">
/// List of polygons, each a list of vertex IDs. Polygons with fewer
/// than 3 vertices are skipped. Quads and larger are fan-triangulated.
/// </param>
public CellSurface(
uint cellId,
Dictionary<ushort, Vector3> vertices,
List<List<short>> polygonVertexIds)
{
CellId = cellId;
_triangles = new List<(Vector3, Vector3, Vector3)>();
foreach (var polyVerts in polygonVertexIds)
{
if (polyVerts.Count < 3) continue;
// Resolve vertex positions.
var positions = new List<Vector3>(polyVerts.Count);
bool skip = false;
foreach (var vid in polyVerts)
{
if (!vertices.TryGetValue((ushort)vid, out var pos))
{
skip = true;
break;
}
positions.Add(pos);
}
if (skip) continue;
// Fan triangulation: (v0, v1, v2), (v0, v2, v3), ...
for (int i = 1; i < positions.Count - 1; i++)
{
_triangles.Add((positions[0], positions[i], positions[i + 1]));
}
}
}
/// <summary>
/// Construct directly from the immutable production polygon table. This
/// preserves the same polygon/fan order as the dictionary fixture path
/// without reconstructing a temporary vertex dictionary and nested ID
/// lists during publication.
/// </summary>
public CellSurface(
uint cellId,
FlatPolygonTable polygons,
Quaternion rotation,
Vector3 translation)
{
ArgumentNullException.ThrowIfNull(polygons);
CellId = cellId;
int triangleCount = 0;
for (int i = 0; i < polygons.Polygons.Length; i++)
triangleCount += Math.Max(0, polygons.Polygons[i].VertexRange.Count - 2);
_triangles = new List<(Vector3, Vector3, Vector3)>(triangleCount);
for (int polygonIndex = 0;
polygonIndex < polygons.Polygons.Length;
polygonIndex++)
{
FlatIndexRange range =
polygons.Polygons[polygonIndex].VertexRange;
if (range.Count < 3)
continue;
Vector3 first =
Vector3.Transform(polygons.Vertices[range.Start], rotation)
+ translation;
Vector3 previous =
Vector3.Transform(polygons.Vertices[range.Start + 1], rotation)
+ translation;
for (int vertexOffset = 2;
vertexOffset < range.Count;
vertexOffset++)
{
Vector3 current = Vector3.Transform(
polygons.Vertices[range.Start + vertexOffset],
rotation) + translation;
_triangles.Add((first, previous, current));
previous = current;
}
}
}
/// <summary>
/// Project (worldX, worldY) onto this cell's floor polygons and
/// return the Z. Returns null if outside all floor polygons.
/// ⚠️ Returns the FIRST triangle hit in list order — a cell's triangle
/// soup includes ceiling/roof top faces, so the result can be a surface
/// far above the actual floor (#111: 0xA9B40171 returned its 99.475
/// ceiling over its 94.0 floor). Use <see cref="SampleFloorZNearest"/>
/// when a reference height is known.
/// </summary>
public float? SampleFloorZ(float worldX, float worldY)
{
foreach (var (a, b, c) in _triangles)
{
if (PointInTriangleXY(worldX, worldY, a, b, c, out float z))
return z;
}
return null;
}
// (#111 note: a SampleFloorZNearest variant was tried and removed — even
// nearest-to-reference lands on ceiling faces when the reference itself
// sits on one. Placement snaps must ground via the PHYSICS walkable
// polygons instead: PhysicsEngine.WalkableFloorZNearest.)
/// <summary>
/// Test if (px, py) falls inside triangle (a, b, c) projected onto
/// the XY plane. If inside, computes the barycentric Z interpolation
/// and returns it via <paramref name="z"/>.
/// </summary>
private static bool PointInTriangleXY(
float px, float py,
Vector3 a, Vector3 b, Vector3 c,
out float z)
{
z = 0;
// Barycentric coordinate computation in 2D (XY plane).
float v0x = c.X - a.X, v0y = c.Y - a.Y;
float v1x = b.X - a.X, v1y = b.Y - a.Y;
float v2x = px - a.X, v2y = py - a.Y;
float dot00 = v0x * v0x + v0y * v0y;
float dot01 = v0x * v1x + v0y * v1y;
float dot02 = v0x * v2x + v0y * v2y;
float dot11 = v1x * v1x + v1y * v1y;
float dot12 = v1x * v2x + v1y * v2y;
float denom = dot00 * dot11 - dot01 * dot01;
if (MathF.Abs(denom) < 1e-10f) return false; // degenerate triangle
float invDenom = 1f / denom;
float u = (dot11 * dot02 - dot01 * dot12) * invDenom;
float v = (dot00 * dot12 - dot01 * dot02) * invDenom;
if (u < -1e-6f || v < -1e-6f || u + v > 1f + 1e-6f)
return false;
// Barycentric Z interpolation.
z = a.Z * (1 - u - v) + b.Z * v + c.Z * u;
return true;
}
}