Skip to content
Merged
80 changes: 80 additions & 0 deletions src/SiegeFX.Core/Assets/PropBindPose.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Numerics;

namespace SiegeFX.Core.Assets;

/// <summary>How a static prop's mesh is rooted in the world: bone 0's bind
/// pose (the Z-up to Y-up stand-up) for rigid props. The game composes a
/// map prop as scale × <see cref="Root"/> × placement × node, and so do the
/// tools that mark the same props on the nav mesh.</summary>
public static class PropBindPose
{
/// <summary>Phase 21c — bone-0's world bind pose for a static prop, used to
/// rotate Z-up authoring space into the engine's Y-up convention. Returns
/// identity for unrigged meshes (no BindPose entries) AND for genuinely
/// multi-bone skinned meshes (V2.5+ props whose geometry ships in
/// world-bind space across several bones).
///
/// SC-PROP-ATTACH-BONE — the gate was originally "exactly 1 bone", but many
/// rigid props ship EXTRA attach bones (a wall torch carries an AP_light
/// socket for its flame) while all their geometry still binds bone 0. Those
/// tripped the old count gate, got no root correction, and rendered in raw
/// Z-up — the torch cantilevering off the wall. The correct test is whether
/// any GEOMETRY binds past bone 0: attach bones carry no verts, so a rigid
/// prop reads max-geometry-bone 0 regardless of how many sockets it has.</summary>
public static Matrix4x4 Root(AspMesh asp)
{
if (asp.BindPose.Length == 0) return Matrix4x4.Identity;
if (!IsRigidRoot(asp)) return Matrix4x4.Identity;
var bp = asp.BindPose[0];
return Matrix4x4.CreateFromQuaternion(bp.Rotation) *
Matrix4x4.CreateTranslation(bp.Translation);
}

/// <summary>A prop is "rigid-root" — safe to apply bone-0's bind
/// correction to — when ALL geometry binds bone 0 AND every additional
/// bone is an attach socket (AP_light for a torch/candlestand flame,
/// AP_* generally), not a real animation bone.
///
/// SC-PROP-ATTACH-BONE — verified by a tank-wide asp sweep: of the
/// multi-bone props that bind all geometry to bone 0, two shapes exist —
/// light props (candlestands, torches) whose extra bones are AP_light
/// sockets and DO need the Z-up->Y-up correction, and hinged props
/// (grates) whose extra bone is a real pivot (Bone04) and currently
/// render right WITHOUT it. Gating on attach-socket names fixes the
/// former and leaves the latter (and every skinned mesh) untouched, so
/// this can't flip a prop that already sits correctly.</summary>
public static bool IsRigidRoot(AspMesh asp)
{
if (MaxGeometryBone(asp) != 0) return false;
for (int i = 1; i < asp.BoneNames.Count; i++)
{
var n = asp.BoneNames[i];
if (string.IsNullOrEmpty(n) ||
!n.StartsWith("ap_", StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}

/// <summary>Highest bone index that any vertex is actually weighted to
/// (its highest-weight/primary bone). Unrigged geometry is implicitly on
/// the root, so returns 0. Attach bones (AP_light, AP_*) carry no geometry
/// and never raise this, so a rigid prop with sockets still reads 0.</summary>
public static int MaxGeometryBone(AspMesh asp)
{
if (!asp.HasSkin) return 0;
int max = 0;
for (int c = 0; c < asp.SkinWeights.Length; c++)
{
var w = asp.SkinWeights[c];
uint b = asp.SkinBones[c];
int primary = 0; float pw = -1f;
if (w.X > pw) { primary = (int)( b & 0xFF); pw = w.X; }
if (w.Y > pw) { primary = (int)((b >> 8) & 0xFF); pw = w.Y; }
if (w.Z > pw) { primary = (int)((b >> 16) & 0xFF); pw = w.Z; }
if (w.W > pw) { primary = (int)((b >> 24) & 0xFF); pw = w.W; }
if (primary > max) max = primary;
}
return max;
}
}
159 changes: 158 additions & 1 deletion src/SiegeFX.Core/Nav/NavMesh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ public sealed class NavMesh

public bool IsBlocked(int tri) => Blocked is not null && tri >= 0 && tri < Blocked.Length && Blocked[tri];

/// <summary>Every triangle whose XZ bounds may overlap the box, each once,
/// from the lookup grid: the obstacle stamps used to test all ~50k
/// triangles for each of ~100 props, re-run on every break (a krug
/// smashing a barrel), a ~200ms hitch each time.</summary>
private List<int> TrianglesNear(float minX, float minZ, float maxX, float maxZ)
{
_nearBuf.Clear();
if (TriangleCount == 0) return _nearBuf;
if (_nearStamp.Length != TriangleCount) { _nearStamp = new int[TriangleCount]; _nearEpoch = 0; }
if (++_nearEpoch == int.MaxValue) { Array.Clear(_nearStamp); _nearEpoch = 1; }
int cx0 = Math.Clamp((int)MathF.Floor((minX - _gridMinX) / GridCellSize), 0, _gridCellsX - 1);
int cx1 = Math.Clamp((int)MathF.Floor((maxX - _gridMinX) / GridCellSize), 0, _gridCellsX - 1);
int cz0 = Math.Clamp((int)MathF.Floor((minZ - _gridMinZ) / GridCellSize), 0, _gridCellsZ - 1);
int cz1 = Math.Clamp((int)MathF.Floor((maxZ - _gridMinZ) / GridCellSize), 0, _gridCellsZ - 1);
for (int cz = cz0; cz <= cz1; cz++)
for (int cx = cx0; cx <= cx1; cx++)
{
var bucket = _grid[cz * _gridCellsX + cx];
if (bucket is null) continue;
foreach (int t in bucket)
{
if (_nearStamp[t] == _nearEpoch) continue;
_nearStamp[t] = _nearEpoch;
_nearBuf.Add(t);
}
}
return _nearBuf;
}
private readonly List<int> _nearBuf = new();
private int[] _nearStamp = Array.Empty<int>();
private int _nearEpoch;

/// <summary>SC-DOORS-BLOCK — reset every obstacle mark so the map can be
/// re-stamped from live state (door opened, prop destroyed). Cheap:
/// one Array.Clear; the caller re-marks everything that still blocks.</summary>
Expand Down Expand Up @@ -206,6 +238,21 @@ public int MarkObstacle(float worldX, float worldZ, float radius)
/// prop's world top so a floor tri right at the top edge still counts.</summary>
private const float ObstacleAboveTopTol = 0.5f;

/// <summary>How far above its base a prop blocks: the ground it stands on,
/// with room for slope under the footprint. Up to its top (+0.5) the old
/// band let a 1.4u cart beside a farmhouse block the house floor and
/// stair raised a meter above it on the far side of the wall.</summary>
private const float ObstacleAboveBaseTol = 0.9f;

/// <summary>Top of the vertical band a rectangle obstacle blocks: 0.9u
/// above its base, and never above its own top (+0.5). Disc obstacles
/// (long fences, big wagons) keep the top-based band: their single
/// center base would leave a hillside fence's uphill end unblocked.</summary>
private static float ObstacleBandTop(float baseY, float topY) =>
float.IsFinite(baseY)
? MathF.Min(topY + ObstacleAboveTopTol, baseY + ObstacleAboveBaseTol)
: topY + ObstacleAboveTopTol;

/// <summary>SC-NAV-OBSTACLE-YGATE — Y-aware obstacle marking. Same XZ
/// footprint test as the 3-arg overload, but a triangle is only blocked
/// when its centroid Y falls within
Expand Down Expand Up @@ -234,7 +281,7 @@ public int MarkObstacle(float worldX, float worldZ, float radius, float baseY, f
float loY = baseY - ObstacleBelowFloorTol;
float hiY = topY + ObstacleAboveTopTol;
int marked = 0;
for (int t = 0; t < TriangleCount; t++)
foreach (int t in TrianglesNear(worldX - radius, worldZ - radius, worldX + radius, worldZ + radius))
{
if (Blocked[t]) continue;
// Y-gate first — cheapest reject, and the whole point of this
Expand Down Expand Up @@ -288,6 +335,116 @@ public int MarkObstacle(float worldX, float worldZ, float radius, float baseY, f
return marked;
}

/// <summary>SC-NAV-OBSTACLE-RECT — block a prop's own oriented footprint
/// rectangle instead of the disc around it. The rectangle starts at
/// <paramref name="corner"/> and spans <paramref name="lenU"/> along
/// <paramref name="axisU"/> and <paramref name="lenV"/> along
/// <paramref name="axisV"/> (unit XZ axes, at right angles), grown by
/// <see cref="ObstacleRectMargin"/> on every side. Same rules as the disc
/// overload otherwise: the vertical band gate, centroid containment for
/// any triangle, an edge crossing only for small ones. A bed's bounding
/// disc reached a third of a meter past its foot onto the stair top
/// beside it and sealed a farmhouse's upper floor. The margin applies
/// to centroid containment only; an edge crossing seals a small
/// triangle only when <see cref="ObstacleRectEdgeDepth"/> of the edge
/// lies inside the exact footprint.</summary>
public int MarkObstacleRect(Vector3 corner, Vector3 axisU, float lenU, Vector3 axisV, float lenV,
float baseY, float topY, string? tag)
{
Blocked ??= new bool[TriangleCount];
BlockedTag ??= new string?[TriangleCount];
if (lenU <= 0f || lenV <= 0f) return 0;
float m = ObstacleRectMargin;
float u0 = -m, u1 = lenU + m, v0 = -m, v1 = lenV + m;
float loY = baseY - ObstacleBelowFloorTol;
float hiY = ObstacleBandTop(baseY, topY);
// Local footprint coordinates of a world point.
(float U, float V) Local(Vector3 p)
{
float dx = p.X - corner.X, dz = p.Z - corner.Z;
return (dx * axisU.X + dz * axisU.Z, dx * axisV.X + dz * axisV.Z);
}
bool Inside((float U, float V) q) => q.U >= u0 && q.U <= u1 && q.V >= v0 && q.V <= v1;
// Liang-Barsky: does the segment p->q cross the rectangle?
// The edge test uses the exact footprint and needs a real crossing:
// at least ObstacleRectEdgeDepth of the edge inside it. The margin
// is for bodies standing on a tile, and slivers merely grazing a
// bed's or cart's corner at a farmhouse stair foot (the stair's only
// link to the floor) sealed the stair.
float InsideLength((float U, float V) p, (float U, float V) q)
{
const float u0 = 0f, v0 = 0f;
float u1 = lenU, v1 = lenV;
float t0 = 0f, t1 = 1f, du = q.U - p.U, dv = q.V - p.V;
bool Clip(float den, float num)
{
if (MathF.Abs(den) < 1e-9f) return num >= 0f;
float r = num / den;
if (den < 0f) { if (r > t1) return false; if (r > t0) t0 = r; }
else { if (r < t0) return false; if (r < t1) t1 = r; }
return true;
}
if (!(Clip(-du, p.U - u0) && Clip(du, u1 - p.U) && Clip(-dv, p.V - v0) && Clip(dv, v1 - p.V)))
return 0f;
return MathF.Max(0f, t1 - t0) * MathF.Sqrt(du * du + dv * dv);
}
string size = $"{lenU:F2}x{lenV:F2}";
int marked = 0;
// The footprint's XZ box (with its margin) bounds every triangle the
// centroid or edge tests below can hit.
var cU = axisU * lenU; var cV = axisV * lenV;
float bx0 = corner.X + MathF.Min(0f, cU.X) + MathF.Min(0f, cV.X) - m;
float bx1 = corner.X + MathF.Max(0f, cU.X) + MathF.Max(0f, cV.X) + m;
float bz0 = corner.Z + MathF.Min(0f, cU.Z) + MathF.Min(0f, cV.Z) - m;
float bz1 = corner.Z + MathF.Max(0f, cU.Z) + MathF.Max(0f, cV.Z) + m;
foreach (int t in TrianglesNear(bx0, bz0, bx1, bz1))
{
if (Blocked[t]) continue;
float cy = Centroids[t].Y;
if (cy < loY || cy > hiY) continue;
if (Inside(Local(Centroids[t])))
{
Blocked[t] = true;
BlockedTag[t] = $"{tag}|rect {size} centroid";
marked++;
continue;
}
var a = Vertices[Indices[3 * t + 0]];
var b = Vertices[Indices[3 * t + 1]];
var c = Vertices[Indices[3 * t + 2]];
var la = Local(a); var lb = Local(b); var lc = Local(c);
float depth = MathF.Max(InsideLength(la, lb), MathF.Max(InsideLength(lb, lc), InsideLength(lc, la)));
if (depth < ObstacleRectEdgeDepth) continue;
// ...and only a triangle that lies mostly in it: its center within
// ObstacleRectEdgeReach of the footprint. A sliver merely clipping
// a corner (the farmhouse stair foot) stays walkable.
var cc = Local(Centroids[t]);
float ou = MathF.Max(0f, MathF.Max(-cc.U, cc.U - lenU));
float ov = MathF.Max(0f, MathF.Max(-cc.V, cc.V - lenV));
if (ou * ou + ov * ov > ObstacleRectEdgeReach * ObstacleRectEdgeReach) continue;
float abx = b.X - a.X, abz = b.Z - a.Z;
float acx = c.X - a.X, acz = c.Z - a.Z;
float areaXZ = MathF.Abs(abx * acz - abz * acx) * 0.5f;
if (areaXZ > EdgeSealMaxAreaXZ) continue;
Blocked[t] = true;
BlockedTag[t] = $"{tag}|rect {size} edge a={areaXZ:F2} in={depth:F2}";
marked++;
}
return marked;
}

/// <summary>How far past its own footprint a rectangle obstacle blocks
/// (see <see cref="MarkObstacleRect"/>): a little clearance for bodies.</summary>
public const float ObstacleRectMargin = 0.1f;

/// <summary>How much of a small triangle's edge must lie inside a
/// rectangle obstacle's footprint for the edge test to seal it.</summary>
public const float ObstacleRectEdgeDepth = 0.4f;

/// <summary>How far outside a rectangle obstacle's footprint a small
/// triangle's center may lie and still be sealed by an edge crossing.</summary>
public const float ObstacleRectEdgeReach = 0.3f;

/// <summary>SC-NAV-EDGE-SEAL-AREA — largest XZ triangle area (m²) the
/// edge-clip test may fully seal. Calibrated from live blame data
/// (session 2026-07-13): genuine sliver-seals (storm-door stairwell)
Expand Down
Loading
Loading