From 520589911ba8cbcae56a6748f4e4fb01570b1dcb Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 09:51:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20Phase=20B.3=20=E2=80=94=20CellSur?= =?UTF-8?q?face=20(indoor=20floor=20polygon=20projection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects an XY point onto a cell's floor polygons via brute-force triangle iteration + barycentric Z interpolation. Fan-triangulates quads and larger polygons. Returns null when outside all floor surfaces. Accepts pre-transformed world-space vertex positions so the caller handles EnvCell coordinate transforms. Second component of the physics collision engine. 4 new tests, all green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core/Physics/CellSurface.cs | 127 ++++++++++++++++++ .../Physics/CellSurfaceTests.cs | 90 +++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/AcDream.Core/Physics/CellSurface.cs create mode 100644 tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs new file mode 100644 index 0000000..ca8c061 --- /dev/null +++ b/src/AcDream.Core/Physics/CellSurface.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Indoor floor resolver for a single EnvCell. Projects an XY point +/// onto the cell's floor polygons and returns the Z at that point. +/// +/// +/// 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. +/// +/// +/// +/// Floor polygon iteration is brute-force (no BSP). Cell polygon +/// counts are typically < 20, making this acceptable for the MVP. +/// Each polygon is fan-triangulated and tested via point-in-triangle +/// + barycentric Z interpolation. +/// +/// +public sealed class CellSurface +{ + public uint CellId { get; } + + private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles; + + /// + /// Construct a CellSurface from pre-transformed vertex positions + /// and polygon definitions. + /// + /// The EnvCell dat id (e.g., 0xA9B40100). + /// Vertex id → world-space position map. + /// + /// List of polygons, each a list of vertex IDs. Polygons with fewer + /// than 3 vertices are skipped. Quads and larger are fan-triangulated. + /// + public CellSurface( + uint cellId, + Dictionary vertices, + List> 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(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])); + } + } + } + + /// + /// Project (worldX, worldY) onto this cell's floor polygons and + /// return the Z. Returns null if outside all floor polygons. + /// + 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; + } + + /// + /// 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 . + /// + 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; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs new file mode 100644 index 0000000..f0336d6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellSurfaceTests +{ + /// + /// Build a minimal CellSurface representing a flat square floor + /// centered at (originX, originY) with the given half-size and Z. + /// The floor polygon is a quad: 4 vertices at the corners. + /// + private static CellSurface MakeFlatFloor( + uint cellId, float originX, float originY, float z, + float halfSize = 10f) + { + // 4 vertices forming a square floor at the given Z, in WORLD space. + var vertices = new Dictionary + { + [0] = new(originX - halfSize, originY - halfSize, z), + [1] = new(originX + halfSize, originY - halfSize, z), + [2] = new(originX + halfSize, originY + halfSize, z), + [3] = new(originX - halfSize, originY + halfSize, z), + }; + + // One quad polygon with 4 vertex IDs. + var polygonVertexIds = new List> + { + new() { 0, 1, 2, 3 }, + }; + + return new CellSurface(cellId, vertices, polygonVertexIds); + } + + [Fact] + public void SampleFloorZ_InsideFlat_ReturnsZ() + { + var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f); + + float? z = surface.SampleFloorZ(50f, 50f); + + Assert.NotNull(z); + Assert.Equal(10f, z!.Value, precision: 2); + } + + [Fact] + public void SampleFloorZ_OutsideFloor_ReturnsNull() + { + var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 5f); + + float? z = surface.SampleFloorZ(100f, 100f); + + Assert.Null(z); + } + + [Fact] + public void SampleFloorZ_AtEdge_ReturnsZ() + { + var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 10f); + + // Right at the edge of the polygon. + float? z = surface.SampleFloorZ(60f, 50f); + + Assert.NotNull(z); + Assert.Equal(10f, z!.Value, precision: 2); + } + + [Fact] + public void SampleFloorZ_SlopedFloor_InterpolatesZ() + { + // A triangular floor that slopes from Z=0 to Z=20. + var vertices = new Dictionary + { + [0] = new(0f, 0f, 0f), + [1] = new(20f, 0f, 0f), + [2] = new(10f, 20f, 20f), + }; + var polygons = new List> { new() { 0, 1, 2 } }; + var surface = new CellSurface(0x0100, vertices, polygons); + + // At the centroid (10, 6.67): Z should be roughly 6.67 + float? z = surface.SampleFloorZ(10f, 6.67f); + + Assert.NotNull(z); + Assert.InRange(z!.Value, 5f, 8f); // approximate + } +}