Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 153 additions & 12 deletions src/SiegeFX.Core/Nav/NavMesh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ private static bool EdgeWithinDiskXZ(Vector3 e0, Vector3 e1, float cx, float cz,
/// slot-starved fallback (see <see cref="ExtraLinks"/>).</summary>
public int DoorSeamOverflowCount { get; }

/// <summary>SC-NAV-SEAM-BARRIER — overflow links the door stitcher dropped
/// because every candidate hop crossed a barrier (an Ignored nav face standing
/// above the floor — e.g. a wooden fence piece's boards between its two floor
/// strips) and the two ends were connected some other way anyway.</summary>
public int DoorSeamBarrierRejectCount { get; private init; }

public int TriangleCount => Indices.Length / 3;

// XZ uniform-grid spatial index. Built once at construction and queried by
Expand Down Expand Up @@ -585,7 +591,7 @@ int Intern(Vector3 p)
// Runs BEFORE land↔water stitching so this pass's newly-paired
// edges aren't counted as boundary candidates by the water pass.
var extraLinksBuild = new Dictionary<int, List<int>>();
int doorSeams = StitchSnoDoorSeams(graph, layout, resolveSno, sourceSnodeGuid.ToArray(), indices, neighbors, vertsArr, extraLinksBuild, out int doorSeamOverflow);
int doorSeams = StitchSnoDoorSeams(graph, layout, resolveSno, sourceSnodeGuid.ToArray(), indices, neighbors, vertsArr, extraLinksBuild, out int doorSeamOverflow, out int doorSeamBarrierRejects);
// Land↔water seam stitching: shoreline Floor and Water SNOs are authored in
// separate meshes whose vertices don't fall inside the WeldToleranceUnits bucket,
// so the manifold pass leaves them on disconnected components. Wire cross-kind
Expand Down Expand Up @@ -681,7 +687,7 @@ int Intern(Vector3 p)
originZ,
cellsX,
cellsZ,
grid);
grid) { DoorSeamBarrierRejectCount = doorSeamBarrierRejects };
}

/// <summary>SC-NAV-CROSS-SNO-STITCH — door-anchored cross-SNO nav
Expand Down Expand Up @@ -710,6 +716,36 @@ int Intern(Vector3 p)
// pairings (tread side edge vs. threshold edge). Matches the shoreline
// stitcher's |cos θ| gate.
private const float DoorSeamCollinearityCosMin = 0.85f;
// SC-NAV-SEAM-BARRIER — an Ignored edge crossing an overflow hop counts as a
// barrier when, at the crossing, it stands between these heights above the
// hop: above a kerb or a floor decal, below a lintel, arch or ceiling.
private const float BarrierMinRise = 0.25f;
private const float BarrierMaxRise = 2.0f;

/// <summary>SC-NAV-SEAM-BARRIER — does the hop <paramref name="p"/>→<paramref name="q"/>
/// cross (in XZ) one of the <paramref name="edges"/> (flat endpoint pairs) at a
/// point where the edge stands between <see cref="BarrierMinRise"/> and
/// <see cref="BarrierMaxRise"/> above the hop? A vertical fence board projects to
/// a line in XZ, so its top and diagonal edges are what register.</summary>
public static bool CrossesBarrier(Vector3 p, Vector3 q, Vector3[] edges)
{
float rx = q.X - p.X, rz = q.Z - p.Z;
for (int i = 0; i + 1 < edges.Length; i += 2)
{
var e0 = edges[i];
var e1 = edges[i + 1];
float sx = e1.X - e0.X, sz = e1.Z - e0.Z;
float denom = Cross2(rx, rz, sx, sz);
if (MathF.Abs(denom) < 1e-8f) continue; // parallel, or an edge seen end-on
float qpx = e0.X - p.X, qpz = e0.Z - p.Z;
float t = Cross2(qpx, qpz, sx, sz) / denom; // along the hop
float u = Cross2(qpx, qpz, rx, rz) / denom; // along the edge
if (t < 0f || t > 1f || u < 0f || u > 1f) continue;
float rise = (e0.Y + u * (e1.Y - e0.Y)) - (p.Y + t * (q.Y - p.Y));
if (rise >= BarrierMinRise && rise <= BarrierMaxRise) return true;
}
return false;
}

private static int StitchSnoDoorSeams(
RegionGraph graph,
Expand All @@ -720,9 +756,11 @@ private static int StitchSnoDoorSeams(
int[] neighbors,
Vector3[] verts,
Dictionary<int, List<int>> extraLinks,
out int overflowLinks)
out int overflowLinks,
out int overflowBarrierRejects)
{
overflowLinks = 0;
overflowBarrierRejects = 0;
int triCount = indices.Length / 3;
// Index unwelded edges by snode guid. Each entry is (tri, slot,
// midpoint, unit XZ direction, XZ length) — slot s is the edge
Expand Down Expand Up @@ -767,6 +805,56 @@ private static int StitchSnoDoorSeams(
}
Vector3 TriCentroid(int t) =>
(verts[indices[3 * t + 0]] + verts[indices[3 * t + 1]] + verts[indices[3 * t + 2]]) / 3f;
// SC-NAV-SEAM-BARRIER — edges of a snode's Ignored (non-walkable) nav faces,
// lifted to the region frame on first use: the authored solid parts of a
// piece (fence boards, rails, wall stubs). Flat pairs of edge endpoints.
var barrierEdges = new Dictionary<uint, Vector3[]>();
var hops = new List<(int a, int b, float score)>();
// Hops that found no barrier-free pair: kept only if dropping them would
// cut the mesh apart (see the restore pass at the end).
var deferredHops = new List<(int a, int b, float score)>();
var hopCheckNodes = new HashSet<uint>();
// Barriers of both door snodes and of their door neighbours: at a fence
// corner the boards a hop crosses can belong to a third piece.
bool HopBlocked(Vector3 p, Vector3 q, RegionGraph.NodeInstance a, RegionGraph.NodeInstance b)
{
hopCheckNodes.Clear();
foreach (var end in (ReadOnlySpan<RegionGraph.NodeInstance>)[a, b])
{
if (hopCheckNodes.Add(end.Guid) && CrossesBarrier(p, q, BarrierEdges(end))) return true;
foreach (var d in end.Doors)
{
if (!hopCheckNodes.Add(d.FarGuid) || !graph.TryGetNode(d.FarGuid, out var nb)) continue;
if (CrossesBarrier(p, q, BarrierEdges(nb))) return true;
}
}
return false;
}
Vector3[] BarrierEdges(RegionGraph.NodeInstance n)
{
if (barrierEdges.TryGetValue(n.Guid, out var cached)) return cached;
var list = new List<Vector3>();
var nodeSno = resolveSno(n.MeshGuid);
if (nodeSno is not null && layout.TryGetTransform(n.Guid, out var xf))
{
foreach (var g in nodeSno.LogicalGroupings)
{
if (g.Kind != SnoModel.FloorKind.Ignored) continue;
foreach (var f in g.Faces)
{
var a = Vector3.Transform(f.A, xf);
var b = Vector3.Transform(f.B, xf);
var c = Vector3.Transform(f.C, xf);
list.Add(a); list.Add(b);
list.Add(b); list.Add(c);
list.Add(c); list.Add(a);
}
}
}
var arr = list.ToArray();
barrierEdges[n.Guid] = arr;
return arr;
}
// Split a small tri set into its slot-connected local components.
// Stepped pieces fragment internally: riser faces are degenerate in XZ
// and dropped from the mesh, so each tread strip is its own island —
Expand Down Expand Up @@ -907,8 +995,7 @@ bool AnyLink(List<int> ca, List<int> cb)
foreach (var compB in LocalComponents(bNear))
{
if (AnyLink(compA, compB)) continue;
int bestA = -1, bestB = -1;
float bestScore = float.MaxValue;
hops.Clear();
foreach (var tA in compA)
{
var ca = TriCentroid(tA);
Expand All @@ -918,22 +1005,76 @@ bool AnyLink(List<int> ca, List<int> cb)
float dy = MathF.Abs(ca.Y - cb.Y);
if (dy > 1.0f) continue;
float ddx = ca.X - cb.X, ddz = ca.Z - cb.Z;
float score = MathF.Sqrt(ddx * ddx + ddz * ddz) + dy;
if (score < bestScore) { bestScore = score; bestA = tA; bestB = tB; }
hops.Add((tA, tB, MathF.Sqrt(ddx * ddx + ddz * ddz) + dy));
}
}
if (bestA < 0) continue;
if (!extraLinks.TryGetValue(bestA, out var la)) extraLinks[bestA] = la = new List<int>();
if (!la.Contains(bestB)) la.Add(bestB);
if (!extraLinks.TryGetValue(bestB, out var lb)) extraLinks[bestB] = lb = new List<int>();
if (!lb.Contains(bestA)) lb.Add(bestA);
if (hops.Count == 0) continue;
// SC-NAV-SEAM-BARRIER — the link is a straight hop between two
// centroids; never let it jump a barrier either snode authors
// between them. Field case: multiplayer_world town_center, the
// wooden pig-pen fence by the co-op start (players walked
// straight through it, and through house walls). Each
// t_grs01_fence_generic_wood-* piece has a floor strip either
// side of its Ignored boards, every door off it lands here (the
// strips weld to their neighbours), and the nearest-centroid hop
// tied one side of the fence to the other. Best-first, so a
// barrier-free hop around an obstacle still wins over none.
hops.Sort((x, y) => x.score.CompareTo(y.score));
int bestA = -1, bestB = -1;
foreach (var (hA, hB, _) in hops)
{
if (HopBlocked(TriCentroid(hA), TriCentroid(hB), node, farNode)) continue;
bestA = hA;
bestB = hB;
break;
}
if (bestA < 0) { deferredHops.Add(hops[0]); continue; }
AddExtraLink(extraLinks, bestA, bestB);
overflowLinks++;
}
}
}

// SC-NAV-SEAM-BARRIER restore — a hop that only crossed barriers is dropped
// when its two ends are joined some other way (the fence pen has its gate),
// but kept when it is the only thing holding the two pieces of mesh
// together: an over-eager barrier verdict must not strand floor that the
// pre-barrier stitcher left reachable.
if (deferredHops.Count > 0)
{
var parent = new int[triCount];
for (int i = 0; i < triCount; i++) parent[i] = i;
int Find(int x)
{
while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
return x;
}
void Union(int x, int y) { x = Find(x); y = Find(y); if (x != y) parent[x] = y; }
for (int t = 0; t < triCount; t++)
for (int s = 0; s < 3; s++)
if (neighbors[3 * t + s] >= 0) Union(t, neighbors[3 * t + s]);
foreach (var (t, links) in extraLinks)
foreach (var nb in links) Union(t, nb);
deferredHops.Sort((x, y) => x.score.CompareTo(y.score));
foreach (var (a, b, _) in deferredHops)
{
if (Find(a) == Find(b)) { overflowBarrierRejects++; continue; }
Union(a, b);
AddExtraLink(extraLinks, a, b);
overflowLinks++;
}
}
return stitched;
}

private static void AddExtraLink(Dictionary<int, List<int>> extraLinks, int a, int b)
{
if (!extraLinks.TryGetValue(a, out var la)) extraLinks[a] = la = new List<int>();
if (!la.Contains(b)) la.Add(b);
if (!extraLinks.TryGetValue(b, out var lb)) extraLinks[b] = lb = new List<int>();
if (!lb.Contains(a)) lb.Add(a);
}

/// <summary>Wires cross-kind adjacencies for Floor↔Water boundary edges that visually
/// share a shoreline. DS1 authors water surfaces in separate SNOs whose vertices don't
/// weld to the floor under <see cref="WeldToleranceUnits"/>, so a vertex-equality
Expand Down
131 changes: 131 additions & 0 deletions tests/SiegeFX.Tests/NavFenceSeamTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Numerics;
using System.Text;
using SiegeFX.Core.Assets;
using SiegeFX.Core.Nav;

namespace SiegeFX.Tests;

// The door stitcher's overflow fallback must not hop a fence. Layout (XZ, all floor at y=0):
//
// z 8 +-------+-------+
// | B | | F = fence piece: floor strip S1 (z 0..1.8) and S0 (z 2.2..4)
// z 4 +-------+ C | either side of Ignored boards standing at z=2 (0..1.5 high),
// | S0 | | like t_grs01_fence_generic_wood-04x04-a.
// |=boards| | B = plain tile welded to S0, door-linked to F.
// | S1 | | C = the way round (a gate): welds to S1, to S0 and to B.
// z 0 +-------+-------+
// x 0 4 8
public class NavFenceSeamTests
{
const uint Fence = 0x10, Tile = 0x20, Round = 0x30;

static SnoModel.NavFace F(Vector3 a, Vector3 b, Vector3 c) => new(a, b, c, Vector3.UnitY);

static IEnumerable<SnoModel.NavFace> Quad(float x0, float x1, float z0, float z1)
{
yield return F(new(x0, 0, z0), new(x1, 0, z0), new(x1, 0, z1));
yield return F(new(x0, 0, z0), new(x1, 0, z1), new(x0, 0, z1));
}

static SnoModel.LogicalGrouping Group(byte id, SnoModel.FloorKind kind, IEnumerable<SnoModel.NavFace> faces) =>
new() { Id = id, Kind = kind, Faces = faces.ToArray() };

static SnoModel.Door Door(uint id, Vector3 at) =>
new(id, new SnoModel.Xform4x3(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ, at), []);

static NavMesh Build(bool boards, bool wayRound)
{
var groups = new List<SnoModel.LogicalGrouping>
{
Group(0, SnoModel.FloorKind.Floor, Quad(0, 4, 2.2f, 4)), // S0
Group(1, SnoModel.FloorKind.Floor, Quad(0, 4, 0, 1.8f)), // S1
};
if (boards)
groups.Add(Group(2, SnoModel.FloorKind.Ignored, new[]
{
F(new(0, 0, 2), new(4, 0, 2), new(4, 1.5f, 2)),
F(new(0, 0, 2), new(4, 1.5f, 2), new(0, 1.5f, 2)),
}));
var snos = new Dictionary<uint, SnoModel>
{
[Fence] = new() { LogicalGroupings = groups.ToArray(), Doors = [Door(1, new(2, 0, 4))] },
[Tile] = new()
{
LogicalGroupings = [Group(0, SnoModel.FloorKind.Floor, Quad(0, 4, 4, 8))],
Doors = [Door(1, new(2, 0, 4))],
},
// C's x=4 edge is cut where S1, S0 and B end so it welds to all three.
[Round] = new()
{
LogicalGroupings = [Group(0, SnoModel.FloorKind.Floor,
Quad(4, 8, 0, 1.8f).Concat(Quad(4, 8, 1.8f, 2.2f)).Concat(Quad(4, 8, 2.2f, 4)).Concat(Quad(4, 8, 4, 8)))],
},
};

var gas = new StringBuilder("[t:terrain_nodes,n:siege_node_list]\n{\n x targetnode = 0x00000001;\n");
void Node(uint guid, uint mesh, string doors) =>
gas.Append($" [t:snode,n:0x{guid:x8}]\n {{\n x guid = 0x{guid:X8};\n x mesh_guid = 0x{mesh:X8};\n texsetabbr = grs01;\n{doors} }}\n");
Node(1, Fence, " [door*] { i fardoor = 1; x farguid = 0x00000002; i id = 1; }\n");
Node(2, Tile, " [door*] { i fardoor = 1; x farguid = 0x00000001; i id = 1; }\n");
if (wayRound) Node(3, Round, "");
gas.Append("}\n");
var graph = RegionGraph.Load(Encoding.UTF8.GetBytes(gas.ToString()));

var transforms = new Dictionary<uint, Matrix4x4> { [1] = Matrix4x4.Identity, [2] = Matrix4x4.Identity };
if (wayRound) transforms[3] = Matrix4x4.Identity;
return NavMesh.BuildForRegion(graph, RegionLayout.FromTransforms(1, transforms),
g => snos.TryGetValue(g, out var s) ? s : null);
}

static readonly Vector3 InPen = new(2.7f, 0, 1.2f); // an S1 triangle centroid
static readonly Vector3 OutsideTile = new(1.3f, 0, 5.3f); // a B triangle centroid

[Fact]
public void OverflowHopDoesNotJumpTheFenceWhenThereIsAWayRound()
{
var mesh = Build(boards: true, wayRound: true);
// The F->B and B->F door entries each offer the hop; both are refused.
Assert.Equal(0, mesh.DoorSeamOverflowCount);
Assert.Equal(2, mesh.DoorSeamBarrierRejectCount);
Assert.Null(mesh.ExtraLinks);
// Still reachable — the long way, through C.
Assert.True(mesh.TryFindTriangle(InPen, out var a));
Assert.True(mesh.TryFindTriangle(OutsideTile, out var b));
var path = new List<int>();
Assert.True(NavPathfinder.TryFindPath(mesh, a, b, path));
Assert.Contains(path, t => mesh.SourceSnodeGuid[t] == 3);
}

[Fact]
public void WithoutBoardsTheHopIsStillWired()
{
var mesh = Build(boards: false, wayRound: true);
Assert.Equal(0, mesh.DoorSeamBarrierRejectCount);
Assert.True(mesh.DoorSeamOverflowCount > 0);
}

[Fact]
public void AHopThatIsTheOnlyConnectionIsKept()
{
// No way round: dropping the hop would strand S1, so the pre-barrier link stays
// (once — the mirror door entry's copy is then redundant and refused).
var mesh = Build(boards: true, wayRound: false);
Assert.Equal(1, mesh.DoorSeamOverflowCount);
Assert.Equal(1, mesh.DoorSeamBarrierRejectCount);
Assert.True(mesh.TryFindTriangle(InPen, out var a));
Assert.True(mesh.TryFindTriangle(OutsideTile, out var b));
Assert.Equal(mesh.ComponentOf(a), mesh.ComponentOf(b));
}

[Fact]
public void BarrierTestNeedsTheEdgeToStandAboveTheHop()
{
Vector3[] board = [new(0, 0, 2), new(4, 1.5f, 2)];
Assert.True(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 3), board));
Assert.False(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 1.9f), board)); // stops short
Vector3[] kerb = [new(0, 0.1f, 2), new(4, 0.1f, 2)];
Assert.False(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 3), kerb)); // too low
Vector3[] lintel = [new(0, 3, 2), new(4, 3, 2)];
Assert.False(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 3), lintel)); // overhead
}
}
Loading