Two grounding selection rules failed against live ACE restores before the right one: (1) first-hit SampleFloorZ returned 0x171's 99.475 ceiling TOP face over its 94.0 floor (issue111-verify2.log) - the player committed onto the roof level, and the session's heartbeats poisoned ACE's save with z=99.475; (2) nearest-to-reference self-confirmed that poison (the reference SAT on the ceiling face, issue111-verify3.log). Root insight: ceiling/roof top faces are upward-facing and XY-projectable - geometrically indistinguishable from floors in the render-ish CellSurface soup. The PHYSICS walkable set (plane normal.Z >= PhysicsGlobals.FloorZ over the claim's Resolved cell-local polygons - retail BSPTREE::find_walkable's filter) contains only real floors: PhysicsEngine.WalkableFloorZNearest transforms into the cell frame, drops on each walkable plane under the XY, picks nearest the reference. Verified live (issue111-verify4.log): ACE restored the roof-poisoned (0xA9B40171, z=99.475); the snap validated the claim and grounded to z=94.000 - the first fully clean indoor login of the arc: [snap] claim=0xA9B40171 VALIDATED -> grounded to its walkable floor z=94.000 [cell-transit] 0x00000000 -> 0xA9B40171 pos=(155.525,12.416,94.000) Baseline: Core 1381 + 4 pre-existing #99 failures + 1 skip; App/UI/Net green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
4.8 KiB
C#
137 lines
4.8 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 < 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.
|
|
/// ⚠️ 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;
|
|
}
|
|
}
|