diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs
index 36311996..d0d867fd 100644
--- a/src/SiegeFX.Core/Nav/NavMesh.cs
+++ b/src/SiegeFX.Core/Nav/NavMesh.cs
@@ -356,6 +356,12 @@ private static bool EdgeWithinDiskXZ(Vector3 e0, Vector3 e1, float cx, float cz,
/// slot-starved fallback (see ).
public int DoorSeamOverflowCount { get; }
+ /// 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.
+ public int DoorSeamBarrierRejectCount { get; private init; }
+
public int TriangleCount => Indices.Length / 3;
// XZ uniform-grid spatial index. Built once at construction and queried by
@@ -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 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
@@ -681,7 +687,7 @@ int Intern(Vector3 p)
originZ,
cellsX,
cellsZ,
- grid);
+ grid) { DoorSeamBarrierRejectCount = doorSeamBarrierRejects };
}
/// SC-NAV-CROSS-SNO-STITCH — door-anchored cross-SNO nav
@@ -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;
+
+ /// SC-NAV-SEAM-BARRIER — does the hop →
+ /// cross (in XZ) one of the (flat endpoint pairs) at a
+ /// point where the edge stands between and
+ /// above the hop? A vertical fence board projects to
+ /// a line in XZ, so its top and diagonal edges are what register.
+ 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,
@@ -720,9 +756,11 @@ private static int StitchSnoDoorSeams(
int[] neighbors,
Vector3[] verts,
Dictionary> 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
@@ -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();
+ 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();
+ // 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)[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();
+ 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 —
@@ -907,8 +995,7 @@ bool AnyLink(List ca, List 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);
@@ -918,22 +1005,76 @@ bool AnyLink(List ca, List 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();
- if (!la.Contains(bestB)) la.Add(bestB);
- if (!extraLinks.TryGetValue(bestB, out var lb)) extraLinks[bestB] = lb = new List();
- 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> extraLinks, int a, int b)
+ {
+ if (!extraLinks.TryGetValue(a, out var la)) extraLinks[a] = la = new List();
+ if (!la.Contains(b)) la.Add(b);
+ if (!extraLinks.TryGetValue(b, out var lb)) extraLinks[b] = lb = new List();
+ if (!lb.Contains(a)) lb.Add(a);
+ }
+
/// 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 , so a vertex-equality
diff --git a/tests/SiegeFX.Tests/NavFenceSeamTests.cs b/tests/SiegeFX.Tests/NavFenceSeamTests.cs
new file mode 100644
index 00000000..1a69c357
--- /dev/null
+++ b/tests/SiegeFX.Tests/NavFenceSeamTests.cs
@@ -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 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 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
+ {
+ 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
+ {
+ [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 { [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();
+ 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
+ }
+}