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
+ }
+}