diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs
index ca8c0616..bd792dcc 100644
--- a/src/AcDream.Core/Physics/CellSurface.cs
+++ b/src/AcDream.Core/Physics/CellSurface.cs
@@ -76,6 +76,11 @@ public sealed class CellSurface
///
/// 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
+ /// when a reference height is known.
///
public float? SampleFloorZ(float worldX, float worldY)
{
@@ -87,6 +92,11 @@ public sealed class CellSurface
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.)
+
///
/// Test if (px, py) falls inside triangle (a, b, c) projected onto
/// the XY plane. If inside, computes the barycentric Z interpolation
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 437cb422..261afe6e 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -430,6 +430,55 @@ public sealed class PhysicsEngine
/// green).
///
///
+ ///
+ /// #111: the walkable floor Z of 's PHYSICS
+ /// polygons under the world XY, nearest to .
+ /// Walkable = plane normal.Z ≥ (retail
+ /// BSPTREE::find_walkable's filter) — ceilings/roof tops never qualify,
+ /// unlike the triangle soup. Resolved polygons
+ /// are CELL-LOCAL: transform in, drop on the plane, transform out.
+ /// Returns null when the claim has no hydrated struct or no walkable
+ /// under the XY.
+ ///
+ private float? WalkableFloorZNearest(uint cellId, Vector3 worldPos, float referenceZ)
+ {
+ var cp = DataCache?.GetCellStruct(cellId);
+ if (cp is null) return null;
+
+ var local = Vector3.Transform(
+ new Vector3(worldPos.X, worldPos.Y, referenceZ), cp.InverseWorldTransform);
+
+ float? best = null;
+ float bestDist = float.MaxValue;
+ foreach (var kv in cp.Resolved)
+ {
+ var poly = kv.Value;
+ var n = poly.Plane.Normal;
+ if (n.Z < PhysicsGlobals.FloorZ) continue;
+ if (!PointInPolygonXY(poly.Vertices, local.X, local.Y)) continue;
+ // plane: n·p + d = 0 => z = -(n.x*x + n.y*y + d)/n.z
+ float lz = -(n.X * local.X + n.Y * local.Y + poly.Plane.D) / n.Z;
+ float wz = Vector3.Transform(new Vector3(local.X, local.Y, lz), cp.WorldTransform).Z;
+ float dist = MathF.Abs(wz - referenceZ);
+ if (dist < bestDist) { bestDist = dist; best = wz; }
+ }
+ return best;
+ }
+
+ /// Even-odd XY-projection point-in-polygon test (cell-local frame).
+ private static bool PointInPolygonXY(IReadOnlyList verts, float x, float y)
+ {
+ bool inside = false;
+ for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
+ {
+ var vi = verts[i]; var vj = verts[j];
+ if ((vi.Y > y) != (vj.Y > y)
+ && x < (vj.X - vi.X) * (y - vi.Y) / (vj.Y - vi.Y) + vi.X)
+ inside = !inside;
+ }
+ return inside;
+ }
+
///
/// #107: does any loaded landblock carry a for
/// this cell id? Distinguishes "partially hydrated" (floor data present,
@@ -636,16 +685,19 @@ public sealed class PhysicsEngine
// surface here (thresholds, stair lips) fall through to the legacy path.
if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u)
{
- CellSurface? claimSurface = null;
- foreach (var c in physics.Cells)
- {
- if ((c.CellId & 0xFFFFu) == (cellId & 0xFFFFu)) { claimSurface = c; break; }
- }
- float? claimFloorZ = claimSurface?.SampleFloorZ(candidatePos.X, candidatePos.Y);
+ // Ground via the claim's PHYSICS WALKABLE polygons (normal.Z ≥
+ // PhysicsGlobals.FloorZ), NOT the CellSurface triangle soup — the
+ // soup includes ceiling/roof TOP faces whose first-hit (99.475
+ // over 0x171's 94.0 floor, issue111-verify2.log) and even
+ // nearest-to-reference (the poisoned reference SAT on the ceiling
+ // face, issue111-verify3.log) selections both land on non-floors.
+ // The walkable set contains only real floors (retail
+ // BSPTREE::find_walkable's polygon filter).
+ float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z);
if (claimFloorZ is not null)
{
Console.WriteLine(System.FormattableString.Invariant(
- $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its floor z={claimFloorZ.Value:F3}"));
+ $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value),
lbPrefix | (cellId & 0xFFFFu),