feat(core): Phase B.3 — CellSurface (indoor floor polygon projection)
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) <noreply@anthropic.com>
This commit is contained in:
parent
7ced94b138
commit
520589911b
2 changed files with 217 additions and 0 deletions
127
src/AcDream.Core/Physics/CellSurface.cs
Normal file
127
src/AcDream.Core/Physics/CellSurface.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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 < 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>
|
||||
/// Project (worldX, worldY) onto this cell's floor polygons and
|
||||
/// return the Z. Returns null if outside all floor polygons.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
90
tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
Normal file
90
tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ushort, Vector3>
|
||||
{
|
||||
[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<List<short>>
|
||||
{
|
||||
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<ushort, Vector3>
|
||||
{
|
||||
[0] = new(0f, 0f, 0f),
|
||||
[1] = new(20f, 0f, 0f),
|
||||
[2] = new(10f, 20f, 20f),
|
||||
};
|
||||
var polygons = new List<List<short>> { 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue