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 + } +}