diff --git a/Prowl.Editor.Test/AssetDatabaseTests.cs b/Prowl.Editor.Test/AssetDatabaseTests.cs
index 3a4135b84..a7befedf5 100644
--- a/Prowl.Editor.Test/AssetDatabaseTests.cs
+++ b/Prowl.Editor.Test/AssetDatabaseTests.cs
@@ -784,5 +784,36 @@ public void UnknownExtension_IsTrackedButNotResolvable()
Assert.Null(Assets.Get(g));
}
+ ///
+ /// A .navmesh is written and read as binary Echo. Its payload is compressed voxelization
+ /// blobs, which as text become base64 — bigger, slower to parse, and no more readable. A
+ /// text one does not parse as binary, so it fails the import outright rather than loading
+ /// as something wrong; rebaking is the migration.
+ ///
+ [Fact]
+ public void NavMesh_RoundTripsAsBinary_AndRejectsText()
+ {
+ NavMeshData? baked = NavMeshBuilder.Build(new NavMeshBuildSettings(), [FlatQuad(20f)]);
+ Assert.NotNull(baked);
+ EchoObject echo = Serializer.Serialize(typeof(object), baked!);
+
+ echo.WriteToBinary(new FileInfo(AssetAbsolutePath("Baked.navmesh")));
+ Guid guid = Assets.ImportFile("Baked.navmesh");
+ Assert.NotEqual(Guid.Empty, guid);
+
+ var loaded = Assets.Get(guid) as NavMeshData;
+ Assert.NotNull(loaded);
+ Assert.Equal(baked!.CacheLayers.Count, loaded!.CacheLayers.Count);
+
+ File.WriteAllText(AssetAbsolutePath("Legacy.navmesh"), echo.WriteToString());
+ Assert.Null(Assets.Get(Assets.ImportFile("Legacy.navmesh")));
+ }
+
+ private static NavMeshGeometrySource FlatQuad(float size)
+ {
+ Prowl.Vector.Float3[] verts = [new(0, 0, 0), new(0, 0, size), new(size, 0, size), new(size, 0, 0)];
+ return new NavMeshGeometrySource(verts, [0, 1, 2, 0, 2, 3], Prowl.Vector.Float4x4.Identity);
+ }
+
#endregion
}
diff --git a/Prowl.Editor.Test/BuildSystemTests.cs b/Prowl.Editor.Test/BuildSystemTests.cs
index 78df9cac2..4c17e5ba9 100644
--- a/Prowl.Editor.Test/BuildSystemTests.cs
+++ b/Prowl.Editor.Test/BuildSystemTests.cs
@@ -1964,6 +1964,7 @@ public void EveryDesktopTarget_MapsToTheRightRuntimePrefix(string targetId, stri
[InlineData(PlayerSettingsFiles.Time)]
[InlineData(PlayerSettingsFiles.Assets)]
[InlineData(PlayerSettingsFiles.TagsAndLayers)]
+ [InlineData(PlayerSettingsFiles.Navigation)]
public void EveryFileThePlayerReads_IsExportedByATypeOfThatName(string expected)
{
var entry = Assert.Single(EditorRegistries.SettingsEntries.Where(e => e.Type.Name == expected));
diff --git a/Prowl.Editor.Test/ProjectSettingsTests.cs b/Prowl.Editor.Test/ProjectSettingsTests.cs
index 88a9946d8..8218264f1 100644
--- a/Prowl.Editor.Test/ProjectSettingsTests.cs
+++ b/Prowl.Editor.Test/ProjectSettingsTests.cs
@@ -16,6 +16,25 @@ public ProjectSettingsTests()
EditorRegistries.OnProjectOpened();
}
+ // ResetToDefaults is not a button — it runs when a project is opened, before that project's
+ // own settings load. Deriving the "defaults" from the live NavMeshAreas table would therefore
+ // carry the PREVIOUS project's area names and costs into the new one.
+ [Fact]
+ public void NavigationSettings_ResetToDefaults_IgnoresTheLiveAreaTable()
+ {
+ const int Custom = 3; // built-in areas are immutable, so only custom ones can drift
+ Prowl.Runtime.NavMeshAreas.SetAreaName(Custom, "Swamp");
+ Prowl.Runtime.NavMeshAreas.SetAreaCost(Custom, 5f);
+
+ var settings = EditorRegistries.GetSettings();
+ settings.ResetToDefaults();
+
+ Assert.Equal(string.Empty, settings.AreaNames[Custom]);
+ Assert.Equal(1f, settings.AreaCosts[Custom]);
+ Assert.Equal("Walkable", settings.AreaNames[Prowl.Runtime.NavMeshAreas.Walkable]);
+ Assert.Equal("Jump", settings.AreaNames[Prowl.Runtime.NavMeshAreas.Jump]);
+ }
+
// Settings persist as Echo YAML: a saved value must survive a save/load round-trip.
[Fact]
public void SettingsSaveLoad_RoundTripsYaml()
diff --git a/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs b/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs
index 214e94acd..933690280 100644
--- a/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs
+++ b/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs
@@ -34,11 +34,21 @@ public static bool ImportEchoObject(ImportContext ctx, string errorLabel)
=> ImportEcho(ctx, errorLabel);
public static bool ImportEcho(ImportContext ctx, string errorLabel) where T : EngineObject
+ => ImportEcho(ctx, errorLabel, static path => EchoObject.ReadFromString(File.ReadAllText(path)));
+
+ ///
+ /// As , for assets written with Echo's
+ /// binary format — the one to use when the payload is bulk bytes rather than something a
+ /// human reads or diffs.
+ ///
+ public static bool ImportEchoBinary(ImportContext ctx, string errorLabel) where T : EngineObject
+ => ImportEcho(ctx, errorLabel, static path => EchoObject.ReadFromBinary(new FileInfo(path)));
+
+ private static bool ImportEcho(ImportContext ctx, string errorLabel, Func read) where T : EngineObject
{
try
{
- string text = File.ReadAllText(ctx.AbsolutePath);
- var echo = EchoObject.ReadFromString(text);
+ var echo = read(ctx.AbsolutePath);
var serCtx = CreateTrackingContext(out var dependencies);
var asset = Serializer.Deserialize(echo, serCtx);
if (asset != null)
diff --git a/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs b/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs
new file mode 100644
index 000000000..3fd2b3a15
--- /dev/null
+++ b/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs
@@ -0,0 +1,18 @@
+using Prowl.Runtime;
+
+namespace Prowl.Editor.Importers;
+
+///
+/// Imports .navmesh files - Echo-serialized NavMeshData objects (baked navigation meshes).
+/// Binary, because a navmesh is almost entirely compressed voxelization blobs: as text they are
+/// base64, which is larger, slower to parse, and undiffable anyway.
+///
+[ImporterFor(".navmesh")]
+public class NavMeshDataImporter : AssetImporter
+{
+ /// Bumping this reimports every .navmesh. A text-written one does not parse as
+ /// binary, so it fails the import rather than loading wrong — rebake it.
+ public override int Version => 2;
+
+ public override bool Import(ImportContext ctx) => ImportHelper.ImportEchoBinary(ctx, "nav mesh data");
+}
diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs
index c1c019bab..6de419c53 100644
--- a/Prowl.Editor/GUI/AttributeHandlers.cs
+++ b/Prowl.Editor/GUI/AttributeHandlers.cs
@@ -290,5 +290,8 @@ public static void Register(OrigamiUI.AttributeHandlerRegistry registry)
registry.Register(new RangeAttributeHandler());
registry.Register(new TextAreaAttributeHandler());
registry.Register(new TooltipAttributeHandler());
+ registry.Register(new NavMeshAreaAttributeHandler());
+ registry.Register(new NavMeshAreaMaskAttributeHandler());
+ registry.Register(new NavMeshAgentTypeAttributeHandler());
}
}
diff --git a/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs b/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs
new file mode 100644
index 000000000..863d26102
--- /dev/null
+++ b/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs
@@ -0,0 +1,147 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.IO;
+
+using Prowl.Echo;
+using Prowl.Editor.Core;
+using Prowl.Editor.GUI;
+using Prowl.Editor.GUI.SceneView;
+using Prowl.Editor.Projects;
+using Prowl.Editor.Theming;
+using Prowl.OrigamiUI;
+using Prowl.PaperUI;
+using Prowl.Runtime;
+
+namespace Prowl.Editor.Inspector;
+
+///
+/// Inspector for : the default property grid plus an editor bake
+/// that saves the result as a .navmesh asset in a SceneName_navmesh folder (mirroring
+/// the lightmapper's SceneName_lightmaps convention) and assigns it to the surface, so the
+/// baked navmesh survives scene reloads and ships with the project.
+///
+[CustomEditor(typeof(NavMeshSurface))]
+public class NavMeshSurfaceEditor : CustomEditor
+{
+ public override void OnGUI(Paper paper, string id, object target)
+ {
+ var surface = (NavMeshSurface)target;
+
+ // Pre-snapshot: captures entire component state before any widget mutates it, so
+ // default-grid edits, the Advanced fields, and the Reset button all undo uniformly.
+ Undo.Snapshot(surface);
+
+ // Basic fields (agent type, collection, layers, geometry, data ref) via the default
+ // grid; DefaultArea and the build overrides are [HideInInspector] and drawn below —
+ // the same basic/advanced split Unity's NavMeshSurface uses.
+ DrawDefaultInspector(paper, $"{id}_def", target);
+
+ paper.Box($"{id}_adv_sp").Height(4);
+ Origami.Foldout(paper, $"{id}_adv", "Advanced").Body(() =>
+ {
+ NavMeshAreaAttributeHandler.DrawAreaField(paper, $"{id}_adv_area", "Default Area", surface.DefaultArea, v =>
+ {
+ surface.DefaultArea = v;
+ EditorSceneManager.MarkDirty();
+ });
+
+ PropertyGridUtils.Draw(paper, $"{id}_adv_ovr", surface.BuildOverrides,
+ _ => EditorSceneManager.MarkDirty());
+
+ paper.Box($"{id}_adv_reset_sp").Height(4);
+ Origami.Button(paper, $"{id}_adv_reset", "Reset Advanced To Defaults", () =>
+ {
+ surface.BuildOverrides = new NavMeshBuildOverrides();
+ surface.DefaultArea = NavMeshAreas.Walkable;
+ EditorSceneManager.MarkDirty();
+ }).Show();
+ });
+
+ paper.Box($"{id}_sp").Height(6);
+ Origami.Header(paper, $"{id}_bake_hdr", $"{EditorIcons.Map} Baking").Underline().Show();
+
+ // One Bake button (Unity-style). In edit mode it bakes to a .navmesh asset so the
+ // result persists; during play it does an in-memory bake (baking to disk mid-play is
+ // rarely intended). The code-only runtime API remains NavMeshSurface.BuildNavMesh().
+ Origami.Button(paper, $"{id}_bake", "Bake NavMesh", () =>
+ {
+ if (Application.IsPlaying) surface.BuildNavMesh();
+ else BakeToAsset(surface);
+ }).Show();
+
+ var data = surface.NavMeshData.Res;
+ if (data.IsValid() && data!.HasTiles)
+ {
+ Origami.Button(paper, $"{id}_clear", $"{EditorIcons.Trash} Clear", () => Clear(surface)).Show();
+
+ paper.Box($"{id}_sp2").Height(4);
+ Origami.Label(paper, $"{id}_stats",
+ $"{data.CacheLayers.Count} cache layers · agent r={data.Settings.AgentRadius:0.##} h={data.Settings.AgentHeight:0.##} · voxel {data.Settings.EffectiveVoxelSize:0.###} · tile {data.Settings.EffectiveTileSize}")
+ .Show();
+ }
+ }
+
+ /// Unregister and drop the surface's baked data reference. The .navmesh file (if
+ /// any) is left on disk; delete it from the Assets panel to remove it fully.
+ private static void Clear(NavMeshSurface surface)
+ {
+ surface.NavMeshData = default;
+ surface.RefreshRegistration();
+ EditorSceneManager.MarkDirty();
+ }
+
+ private static void BakeToAsset(NavMeshSurface surface)
+ {
+ try
+ {
+ // Bake in memory first (also registers with the scene while playing/valid).
+ if (!surface.BuildNavMesh())
+ return;
+
+ Runtime.NavMeshData? data = surface.NavMeshData.Res;
+ if (data.IsNotValid()) return;
+
+ var db = EditorAssetBackend.Instance;
+ var scene = surface.GameObject.Scene;
+
+ // A subfolder named after the scene, next to the scene asset, holding
+ // " NavMesh.navmesh" (e.g. Assets/Scenes/Test Scene/Test Scene NavMesh.navmesh).
+ string sceneRel = scene.IsValid() && !string.IsNullOrEmpty(scene!.AssetPath) ? scene.AssetPath : "";
+ string sceneDir = string.IsNullOrEmpty(sceneRel) ? "" : (Path.GetDirectoryName(sceneRel) ?? "").Replace('\\', '/');
+ string sceneName = string.IsNullOrEmpty(sceneRel) ? "Scene" : Path.GetFileNameWithoutExtension(sceneRel);
+ string folderRel = (string.IsNullOrEmpty(sceneDir) ? "" : sceneDir + "/") + sceneName;
+ string folderAbs = Path.Combine(Project.Current!.AssetsPath, folderRel);
+ Directory.CreateDirectory(folderAbs);
+
+ string fileRel = folderRel + "/" + Sanitize(sceneName + " NavMesh") + ".navmesh";
+ string fileAbs = Path.Combine(Project.Current.AssetsPath, fileRel);
+ data!.Name = Path.GetFileNameWithoutExtension(fileRel);
+ Serializer.Serialize(typeof(object), data).WriteToBinary(new FileInfo(fileAbs));
+
+ Guid guid = db.ImportFile(fileRel);
+ if (guid == Guid.Empty)
+ {
+ Runtime.Debug.LogError($"[Navigation] Failed to import baked navmesh at {fileRel}.");
+ return;
+ }
+
+ surface.NavMeshData = new AssetRef(guid);
+ surface.RefreshRegistration();
+ EditorSceneManager.MarkDirty();
+ Runtime.Debug.Log($"[Navigation] Baked navmesh saved to {fileRel} ({data.CacheLayers.Count} cache layers).");
+ }
+ catch (Exception e)
+ {
+ Runtime.Debug.LogError($"[Navigation] Bake failed: {e.Message}\n{e.StackTrace}");
+ }
+ }
+
+ private static string Sanitize(string name)
+ {
+ foreach (char c in Path.GetInvalidFileNameChars())
+ name = name.Replace(c, '_');
+ return string.IsNullOrEmpty(name) ? "NavMesh" : name;
+ }
+}
diff --git a/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs b/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs
new file mode 100644
index 000000000..33e5475f7
--- /dev/null
+++ b/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs
@@ -0,0 +1,225 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+
+using Prowl.PaperUI;
+using Prowl.PaperUI.LayoutEngine;
+using Prowl.Runtime;
+
+namespace Prowl.Editor.GUI;
+
+///
+/// The property-grid row recipe (gutter padding, label width/colour/truncation) these handlers
+/// draw with. They are reachable from outside the grid's field loop — a custom editor laying
+/// fields out by hand calls the same dropdowns — so the recipe lives in one place rather than
+/// being hand-copied into each, and grid metric changes land here.
+///
+internal static class HandlerRowLayout
+{
+ /// Draw a label + control row matching the default grid rows. The control is
+ /// drawn inside a stretch-width, row-height box.
+ public static void LabelledRow(Paper paper, string id, string label, Action drawControl)
+ {
+ var theme = OrigamiUI.Origami.Current;
+ var m = theme.Metrics;
+ var font = theme.Font;
+
+ using (paper.Row(id).Height(UnitValue.Auto).MinHeight(m.RowHeight)
+ .Padding(m.PaddingLarge, m.PaddingLarge, 0, 0).RowBetween(m.Padding).Enter())
+ {
+ if (font != null && !string.IsNullOrEmpty(label))
+ {
+ paper.Box($"{id}_lbl")
+ .Width(m.LabelWidth).Height(m.RowHeight)
+ .Margin(0, 0, UnitValue.Stretch(), UnitValue.Stretch())
+ .IsNotInteractable()
+ .Text(label, font).TextColor(theme.Ink.C300)
+ .FontSize(m.FontSize).Alignment(TextAlignment.MiddleLeft).TextTruncate();
+ }
+
+ using (paper.Box($"{id}_ctl").Width(UnitValue.Stretch()).Height(m.RowHeight).Enter())
+ drawControl();
+ }
+ }
+}
+
+///
+/// [NavMeshArea] - draws an int field as a dropdown of the navigation areas defined in
+/// project settings, so users pick "Walkable" instead of typing 0.
+///
+public class NavMeshAreaAttributeHandler : OrigamiUI.AttributeHandler
+{
+ public override bool OnDraw(Paper paper, string id, string label, Attribute attr,
+ FieldInfo field, object target, Action onChange, int depth)
+ {
+ if (field.FieldType != typeof(int)) return false;
+ DrawAreaField(paper, id, label, (int)(field.GetValue(target) ?? 0), v => onChange(v));
+ return true;
+ }
+
+ /// Labelled area dropdown over the defined areas — shared by the attribute
+ /// handler and custom editors that lay fields out by hand.
+ public static void DrawAreaField(Paper paper, string id, string label, int value, Action onChange)
+ {
+ List areas = NavMeshAreas.GetDefinedAreas();
+ if (!areas.Contains(value)) areas.Add(value); // never hide the current value, even if undefined
+ DrawIdDropdown(paper, id, label, value, onChange, areas, DisplayName);
+ }
+
+ internal static string DisplayName(int area)
+ {
+ string name = NavMeshAreas.GetAreaName(area);
+ return string.IsNullOrEmpty(name) ? $"Area {area}" : name;
+ }
+
+ /// Shared labelled-dropdown-over-int-ids row, laid out via
+ /// so handler-drawn fields don't visually stick out of the
+ /// inspector column.
+ internal static void DrawIdDropdown(Paper paper, string id, string label, int value,
+ Action onChange, List ids, Func display)
+ {
+ HandlerRowLayout.LabelledRow(paper, id, label, () =>
+ {
+ OrigamiUI.Origami.Dropdown(paper, $"{id}_dd", value, v => onChange(v), ids)
+ .Display(display)
+ .Show();
+ });
+ }
+}
+
+///
+/// [NavMeshAgentType] - draws an int field as a dropdown of the agent types defined in
+/// project settings, so users pick "Humanoid" instead of typing 0. On a List<int> field
+/// it draws a multi-select of agent types instead (the agent-type analogue of the
+/// LayerMask/[NavMeshAreaMask] editor), used by the modifier components' affected-types lists.
+///
+public class NavMeshAgentTypeAttributeHandler : OrigamiUI.AttributeHandler
+{
+ public override bool OnDraw(Paper paper, string id, string label, Attribute attr,
+ FieldInfo field, object target, Action onChange, int depth)
+ {
+ if (field.FieldType == typeof(List))
+ {
+ DrawAgentTypeList(paper, id, label, (List?)field.GetValue(target) ?? [], onChange);
+ return true;
+ }
+
+ if (field.FieldType != typeof(int)) return false;
+
+ var ids = new List(NavMeshAgentTypes.All.Count);
+ foreach (NavMeshAgentType type in NavMeshAgentTypes.All)
+ ids.Add(type.Id);
+ int value = (int)(field.GetValue(target) ?? 0);
+ if (!ids.Contains(value)) ids.Add(value); // stale reference stays visible
+
+ // The nicified "Agent Type Id" is noise — collapse it to Unity's "Agent Type". Any
+ // other field name keeps its own label (the handler is registered globally, so a
+ // third-party [NavMeshAgentType] int PatrolAgentType must not be relabelled).
+ string shown = label == "Agent Type Id" ? "Agent Type" : label;
+ NavMeshAreaAttributeHandler.DrawIdDropdown(paper, id, shown, value, v => onChange(v), ids, NavMeshAgentTypes.GetName);
+ return true;
+ }
+
+ private static void DrawAgentTypeList(Paper paper, string id, string label, List current, Action onChange)
+ {
+ var theme = OrigamiUI.Origami.Current;
+ var m = theme.Metrics;
+ var font = theme.Font;
+ float rh = m.RowHeight;
+
+ var ids = new List(NavMeshAgentTypes.All.Count);
+ foreach (NavMeshAgentType type in NavMeshAgentTypes.All)
+ ids.Add(type.Id);
+ foreach (int selected in current)
+ if (!ids.Contains(selected))
+ ids.Add(selected); // stale references stay visible (named "Agent Type N")
+
+ // "Affected Agent Type Ids" nicified is noise — collapse to Unity's "Affected Agents";
+ // other field names keep their own label (same rule as the int branch).
+ string shown = label == "Affected Agent Type Ids" ? "Affected Agents" : label;
+
+ // Not HandlerRowLayout.LabelledRow: the multi-select needs Height(Auto) so chip
+ // wrapping reflows the column (same as the [NavMeshAreaMask] editor) — a row-height
+ // control box would clip the wrapped chips. Label block mirrors the helper's recipe.
+ using (paper.Row(id).Height(UnitValue.Auto).MinHeight(rh).Padding(m.PaddingLarge, m.PaddingLarge, 0, 0).RowBetween(m.Padding).Enter())
+ {
+ if (font != null && !string.IsNullOrEmpty(shown))
+ {
+ paper.Box($"{id}_lbl")
+ .Width(m.LabelWidth).Height(rh).Margin(0, 0, UnitValue.Stretch(), UnitValue.Stretch())
+ .IsNotInteractable()
+ .Text(shown, font).TextColor(theme.Ink.C300)
+ .FontSize(m.FontSize).Alignment(TextAlignment.MiddleLeft).TextTruncate();
+ }
+
+ OrigamiUI.Origami.MultiDropdown(paper, $"{id}_md", current, picked => onChange(new List(picked)), ids)
+ .Display(NavMeshAgentTypes.GetName)
+ .Height(rh)
+ .SummaryFormat("{0} agent types")
+ .Searchable()
+ .Show();
+ }
+ }
+}
+
+///
+/// [NavMeshAreaMask] - draws an int field as a multi-select of the defined navigation areas
+/// (the area analogue of the LayerMask editor).
+///
+public class NavMeshAreaMaskAttributeHandler : OrigamiUI.AttributeHandler
+{
+ public override bool OnDraw(Paper paper, string id, string label, Attribute attr,
+ FieldInfo field, object target, Action onChange, int depth)
+ {
+ if (field.FieldType != typeof(int)) return false;
+ int mask = (int)(field.GetValue(target) ?? NavMeshAreas.AllAreas);
+
+ var theme = OrigamiUI.Origami.Current;
+ var m = theme.Metrics;
+ var font = theme.Font;
+ float rh = m.RowHeight;
+
+ List areas = NavMeshAreas.GetDefinedAreas();
+ var selected = new List();
+ foreach (int i in areas)
+ if ((mask & (1 << i)) != 0) selected.Add(i);
+
+ // Not HandlerRowLayout.LabelledRow: the multi-select needs Height(Auto) so chip
+ // wrapping reflows the column (same as the LayerMask editor) — a row-height control
+ // box would clip the wrapped chips. Label block mirrors the helper's recipe.
+ using (paper.Row(id).Height(UnitValue.Auto).MinHeight(rh).Padding(m.PaddingLarge, m.PaddingLarge, 0, 0).RowBetween(m.Padding).Enter())
+ {
+ if (font != null && !string.IsNullOrEmpty(label))
+ {
+ paper.Box($"{id}_lbl")
+ .Width(m.LabelWidth).Height(rh).Margin(0, 0, UnitValue.Stretch(), UnitValue.Stretch())
+ .IsNotInteractable()
+ .Text(label, font).TextColor(theme.Ink.C300)
+ .FontSize(m.FontSize).Alignment(TextAlignment.MiddleLeft).TextTruncate();
+ }
+
+ OrigamiUI.Origami.MultiDropdown(paper, $"{id}_md", selected, picked =>
+ {
+ // Everything selected stores as AllAreas (-1) so future areas are included
+ // automatically — matching how "Everything" behaves on layer masks.
+ if (picked.Count == areas.Count)
+ {
+ onChange(NavMeshAreas.AllAreas);
+ return;
+ }
+ int updated = 0;
+ foreach (int i in picked) updated |= 1 << i;
+ onChange(updated);
+ }, areas)
+ .Display(NavMeshAreaAttributeHandler.DisplayName)
+ .Height(rh)
+ .SummaryFormat("{0} areas")
+ .Searchable()
+ .Show();
+ }
+ return true;
+ }
+}
diff --git a/Prowl.Editor/Projects/Settings/NavigationSettings.cs b/Prowl.Editor/Projects/Settings/NavigationSettings.cs
new file mode 100644
index 000000000..6efd925e6
--- /dev/null
+++ b/Prowl.Editor/Projects/Settings/NavigationSettings.cs
@@ -0,0 +1,359 @@
+using System;
+using System.Collections.Generic;
+
+using Prowl.OrigamiUI;
+using Prowl.PaperUI;
+using Prowl.PaperUI.LayoutEngine;
+using Prowl.Runtime;
+using Prowl.Editor.Theming;
+
+using Prowl.Editor.GUI;
+
+namespace Prowl.Editor.Projects.Settings;
+
+///
+/// Project-wide navigation configuration: the area table (names + default path costs)
+/// applied to . Walkable, Not Walkable, and Jump are built-in;
+/// users add up to 29 more, Tags-style. Removal clears the slot rather than shifting later
+/// areas — masks and baked polygons reference areas by index, so indices must stay stable.
+/// Written to Navigation.yaml for the built player, where
+/// PlayerSettingsLoader.ApplyNavigation restores it.
+///
+[ProjectSettings("Navigation", EditorIcons.Compass, order: 21)]
+public class NavigationSettings : ProjectSettingsBase
+{
+ public List AreaNames = CreateDefaultNames();
+ public List AreaCosts = CreateDefaultCosts();
+ public List AgentTypes = [new NavMeshAgentType { Id = NavMeshAgentTypes.Humanoid, Name = "Humanoid" }];
+
+ /// Monotonic id counter for Add Agent Type. Persisted so deleting the
+ /// highest-id type can never hand its id to a later, unrelated type — surfaces and
+ /// agents still referencing the deleted id would silently rebind to the new one.
+ public int NextAgentTypeId = 1;
+
+ private int _activeTab; // 0 = Agents, 1 = Areas
+
+ // Literal defaults, not reads of NavMeshAreas: these feed ResetToDefaults, which runs as a
+ // project opens, before that project's settings load — the statics still hold the previous
+ // project's table at that point.
+ private static List CreateDefaultNames()
+ {
+ var names = new List(NavMeshAreas.MaxAreas);
+ for (int i = 0; i < NavMeshAreas.MaxAreas; i++)
+ names.Add(i switch
+ {
+ NavMeshAreas.Walkable => "Walkable",
+ NavMeshAreas.NotWalkable => "Not Walkable",
+ NavMeshAreas.Jump => "Jump",
+ _ => string.Empty,
+ });
+ return names;
+ }
+
+ private static List CreateDefaultCosts()
+ {
+ var costs = new List(NavMeshAreas.MaxAreas);
+ for (int i = 0; i < NavMeshAreas.MaxAreas; i++)
+ costs.Add(1f);
+ return costs;
+ }
+
+ private void EnsureSize()
+ {
+ while (AreaNames.Count < NavMeshAreas.MaxAreas) AreaNames.Add(string.Empty);
+ while (AreaCosts.Count < NavMeshAreas.MaxAreas) AreaCosts.Add(1f);
+ }
+
+ public override void Apply()
+ {
+ EnsureSize();
+ // Not Walkable is never traversed, so its cost is meaningless; pin it (matches Unity).
+ AreaCosts[NavMeshAreas.NotWalkable] = 1f;
+ NavMeshAreas.ApplyTable(AreaNames, AreaCosts);
+ NavMeshAgentTypes.ApplyTable(AgentTypes);
+ }
+
+ public override void ResetToDefaults()
+ {
+ AreaNames = CreateDefaultNames();
+ AreaCosts = CreateDefaultCosts();
+ AgentTypes = [new NavMeshAgentType { Id = NavMeshAgentTypes.Humanoid, Name = "Humanoid" }];
+ NextAgentTypeId = 1;
+ Apply();
+ }
+
+ private void Changed()
+ {
+ Apply();
+ EditorRegistries.SaveSettings();
+ }
+
+ /// Row swatch: the same per-area color the scene-view navmesh overlay draws
+ /// with (opaque for the UI).
+ private static System.Drawing.Color AreaSwatchColor(int areaIndex)
+ {
+ Prowl.Vector.Color c = NavMeshSurface.AreaColor(areaIndex);
+ return System.Drawing.Color.FromArgb(255,
+ (int)(Math.Clamp(c.R, 0f, 1f) * 255),
+ (int)(Math.Clamp(c.G, 0f, 1f) * 255),
+ (int)(Math.Clamp(c.B, 0f, 1f) * 255));
+ }
+
+ public override void OnGUI(Paper paper, float width)
+ {
+ EnsureSize();
+ var font = EditorTheme.DefaultFont;
+ if (font == null) return;
+
+ // Unity-style tab toolbar: Agents | Areas.
+ Origami.ButtonGroup(paper, "nav_tabs", _activeTab, t => _activeTab = t)
+ .Item("Agents")
+ .Item("Areas")
+ .Show();
+ paper.Box("nav_tabs_sp").Height(8);
+
+ if (_activeTab == 0) DrawAgentsTab(paper, font);
+ else DrawAreasTab(paper, font);
+ }
+
+ // ── Agents tab ──────────────────────────────────────────────────────
+
+ private void DrawAgentsTab(Paper paper, Prowl.Scribe.FontFile font)
+ {
+ Origami.Header(paper, "nav_agents_hdr", $"{EditorIcons.Compass} Agent Types").Underline().Show();
+
+ const float NumW = 64, DelW = 20;
+
+ // Column headers, aligned with the rows below.
+ using (paper.Row("nav_agent_cols").Height(20).RowBetween(6).ChildLeft(8).ChildRight(4).Enter())
+ {
+ paper.Box("nav_agent_cols_name")
+ .Width(UnitValue.Stretch()).Height(18).ChildLeft(4)
+ .Text("Name", font).TextColor(EditorTheme.Ink300)
+ .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
+ DrawAgentColHeader(paper, "nav_agent_cols_r", "Radius", NumW, font);
+ DrawAgentColHeader(paper, "nav_agent_cols_h", "Height", NumW, font);
+ DrawAgentColHeader(paper, "nav_agent_cols_s", "Slope°", NumW, font);
+ DrawAgentColHeader(paper, "nav_agent_cols_c", "Climb", NumW, font);
+ paper.Box("nav_agent_cols_del").Width(DelW).Height(18);
+ }
+
+ for (int i = 0; i < AgentTypes.Count; i++)
+ {
+ int idx = i;
+ NavMeshAgentType type = AgentTypes[i];
+ bool isBuiltin = type.Id == NavMeshAgentTypes.Humanoid;
+
+ using (paper.Row($"nav_agent_{type.Id}").Height(26).RowBetween(6).ChildLeft(8).ChildRight(4).Enter())
+ {
+ // Name: same control for every row; the built-in Humanoid's name is locked.
+ using (paper.Box($"nav_agent_name_{type.Id}").Width(UnitValue.Stretch()).Height(22).Enter())
+ {
+ IDisposable? dim = isBuiltin ? EnableIfAttributeHandler.PushDisabledScope() : null;
+ try
+ {
+ Origami.TextField(paper, $"nav_agent_name_tf_{type.Id}", type.Name, v =>
+ {
+ if (isBuiltin || string.IsNullOrWhiteSpace(v)) return;
+ string trimmed = v.Trim();
+ // Duplicate names would make name lookup ambiguous.
+ foreach (NavMeshAgentType other in AgentTypes)
+ if (other != AgentTypes[idx] && string.Equals(other.Name, trimmed, StringComparison.Ordinal))
+ return;
+ AgentTypes[idx].Name = trimmed;
+ Changed();
+ }).Show();
+ }
+ finally { dim?.Dispose(); }
+ }
+
+ DrawAgentNumField(paper, $"nav_agent_r_{type.Id}", NumW, type.Radius, v => { AgentTypes[idx].Radius = MathF.Max(0.01f, v); Changed(); });
+ DrawAgentNumField(paper, $"nav_agent_h_{type.Id}", NumW, type.Height, v => { AgentTypes[idx].Height = MathF.Max(0.01f, v); Changed(); });
+ DrawAgentNumField(paper, $"nav_agent_s_{type.Id}", NumW, type.MaxSlope, v => { AgentTypes[idx].MaxSlope = Math.Clamp(v, 0f, 89f); Changed(); });
+ DrawAgentNumField(paper, $"nav_agent_c_{type.Id}", NumW, type.MaxClimb, v => { AgentTypes[idx].MaxClimb = MathF.Max(0f, v); Changed(); });
+
+ if (!isBuiltin)
+ {
+ paper.Box($"nav_agent_del_{type.Id}")
+ .Width(DelW).Height(22).Rounded(3)
+ .Hovered.BackgroundColor(EditorTheme.Ink200).End()
+ .Text(EditorIcons.Xmark, font).TextColor(EditorTheme.Ink400)
+ .FontSize(9f).Alignment(TextAlignment.MiddleCenter)
+ .OnClick(idx, (id, _) =>
+ {
+ // Ids are persistent - removing an entry never renumbers others.
+ AgentTypes.RemoveAt(id);
+ Changed();
+ });
+ }
+ else
+ {
+ paper.Box($"nav_agent_del_{type.Id}").Width(DelW).Height(22); // column alignment spacer
+ }
+ }
+ }
+
+ paper.Box("nav_agents_sp").Height(4);
+
+ Origami.Button(paper, "nav_add_agent", $"{EditorIcons.Plus} Add Agent Type", () =>
+ {
+ // Max() guards settings saved before NextAgentTypeId existed (counter at default 1
+ // with higher ids already in the table).
+ int nextId = NextAgentTypeId;
+ foreach (NavMeshAgentType t in AgentTypes) nextId = Math.Max(nextId, t.Id + 1);
+ NextAgentTypeId = nextId + 1;
+
+ string name = "New Agent";
+ for (int n = 1; AgentTypes.Exists(t => t.Name == name); n++)
+ name = $"New Agent ({n})";
+
+ AgentTypes.Add(new NavMeshAgentType { Id = nextId, Name = name });
+ Changed();
+ }).Show();
+ }
+
+ private static void DrawAgentColHeader(Paper paper, string id, string label, float width, Prowl.Scribe.FontFile font)
+ {
+ paper.Box(id)
+ .Width(width).Height(18).ChildLeft(4)
+ .Text(label, font).TextColor(EditorTheme.Ink300)
+ .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
+ }
+
+ private static void DrawAgentNumField(Paper paper, string id, float width, float value, Action setter)
+ {
+ using (paper.Box(id).Width(width).Height(22).Enter())
+ {
+ Origami.NumericField(paper, $"{id}_nf", value, setter).Show();
+ }
+ }
+
+ // ── Areas tab ───────────────────────────────────────────────────────
+
+ private void DrawAreasTab(Paper paper, Prowl.Scribe.FontFile font)
+ {
+ Origami.Header(paper, "nav_areas_hdr", $"{EditorIcons.Compass} Navigation Areas").Underline().Show();
+
+ // Column layout shared by the header and every row (mirrors Unity's Areas tab):
+ // [swatch 6] [slot label 76] [Name stretch] [Cost 70] [delete 20]
+ const float SwatchW = 6, SlotW = 76, CostW = 70, DelW = 20;
+
+ using (paper.Row("nav_area_cols").Height(20).RowBetween(6).ChildLeft(8).ChildRight(4).Enter())
+ {
+ paper.Box("nav_area_cols_swatch").Width(SwatchW).Height(18);
+ paper.Box("nav_area_cols_slot").Width(SlotW).Height(18);
+ paper.Box("nav_area_cols_name")
+ .Width(UnitValue.Stretch()).Height(18).ChildLeft(4)
+ .Text("Name", font).TextColor(EditorTheme.Ink300)
+ .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
+ paper.Box("nav_area_cols_cost")
+ .Width(CostW).Height(18).ChildLeft(4)
+ .Text("Cost", font).TextColor(EditorTheme.Ink300)
+ .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
+ paper.Box("nav_area_cols_del").Width(DelW).Height(18);
+ }
+
+ for (int i = 0; i < NavMeshAreas.MaxAreas; i++)
+ {
+ int idx = i;
+ bool isBuiltin = i <= NavMeshAreas.Jump;
+ if (!isBuiltin && string.IsNullOrEmpty(AreaNames[i])) continue; // empty slot: hidden
+
+ using (paper.Row($"nav_area_{i}").Height(26).RowBetween(6).ChildLeft(8).ChildRight(4).Enter())
+ {
+ // Swatch in the same color the scene-view overlay uses for this area.
+ paper.Box($"nav_area_swatch_{i}")
+ .Width(SwatchW).Height(22).Rounded(2)
+ .BackgroundColor(AreaSwatchColor(i));
+
+ paper.Box($"nav_area_slot_{i}")
+ .Width(SlotW).Height(22)
+ .Text(isBuiltin ? $"Built-in {i}" : $"User {i}", font).TextColor(EditorTheme.Ink400)
+ .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
+
+ // Name: always the same field control so the column lines up; built-ins are
+ // rendered disabled instead of as bare labels.
+ using (paper.Box($"nav_area_name_{i}").Width(UnitValue.Stretch()).Height(22).Enter())
+ {
+ IDisposable? dim = isBuiltin ? EnableIfAttributeHandler.PushDisabledScope() : null;
+ try
+ {
+ Origami.TextField(paper, $"nav_area_name_tf_{i}", AreaNames[i], v =>
+ {
+ if (isBuiltin || string.IsNullOrWhiteSpace(v)) return;
+ string trimmed = v.Trim();
+ // Duplicate names would make GetAreaFromName ambiguous.
+ int existing = AreaNames.IndexOf(trimmed);
+ if (existing >= 0 && existing != idx) return;
+ AreaNames[idx] = trimmed;
+ Changed();
+ }).Show();
+ }
+ finally { dim?.Dispose(); }
+ }
+
+ // Cost: plain float field, clamped to >= 1 on apply (Detour's A* heuristic
+ // assumes cost >= 1; smaller values make paths suboptimal). Not Walkable is
+ // never traversed, so its cost is pinned and disabled.
+ using (paper.Box($"nav_area_cost_{i}").Width(CostW).Height(22).Enter())
+ {
+ bool costLocked = i == NavMeshAreas.NotWalkable;
+ IDisposable? dim = costLocked ? EnableIfAttributeHandler.PushDisabledScope() : null;
+ try
+ {
+ Origami.NumericField(paper, $"nav_area_cost_nf_{i}", AreaCosts[i], v =>
+ {
+ if (costLocked) return;
+ AreaCosts[idx] = MathF.Max(1f, v);
+ Changed();
+ }).Show();
+ }
+ finally { dim?.Dispose(); }
+ }
+
+ if (!isBuiltin)
+ {
+ paper.Box($"nav_area_del_{i}")
+ .Width(DelW).Height(22).Rounded(3)
+ .Hovered.BackgroundColor(EditorTheme.Ink200).End()
+ .Text(EditorIcons.Xmark, font).TextColor(EditorTheme.Ink400)
+ .FontSize(9f).Alignment(TextAlignment.MiddleCenter)
+ .OnClick(idx, (id, _) =>
+ {
+ // Clear the slot in place; shifting would re-index later areas
+ // under existing masks and baked navmeshes.
+ AreaNames[id] = string.Empty;
+ AreaCosts[id] = 1f;
+ Changed();
+ });
+ }
+ else
+ {
+ paper.Box($"nav_area_del_{i}").Width(DelW).Height(22); // spacer keeps columns aligned
+ }
+ }
+ }
+
+ paper.Box("nav_areas_sp").Height(4);
+
+ // Add a new area into the first empty slot with a unique placeholder name; rename it
+ // in the row's name field. (A text-entry "add" would fire per keystroke.)
+ Origami.Button(paper, "nav_add_area", $"{EditorIcons.Plus} Add Area", () =>
+ {
+ int slot = AreaNames.FindIndex(NavMeshAreas.Jump + 1, string.IsNullOrEmpty);
+ if (slot < 0)
+ {
+ Runtime.Debug.LogWarning($"[Navigation] All {NavMeshAreas.MaxAreas} area slots are in use.");
+ return;
+ }
+
+ string name = "New Area";
+ for (int n = 1; AreaNames.Contains(name); n++)
+ name = $"New Area ({n})";
+
+ AreaNames[slot] = name;
+ AreaCosts[slot] = 1f;
+ Changed();
+ }).Show();
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshAllocationTests.cs b/Prowl.Runtime.Test/NavMeshAllocationTests.cs
new file mode 100644
index 000000000..d63855e81
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshAllocationTests.cs
@@ -0,0 +1,88 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Runtime;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+///
+/// Locks in the tile-bake allocation behaviour destructible-map games depend on: tiles with
+/// no geometry skip the pipeline entirely, and span-pool recycling across rebuilds does not
+/// change build output.
+///
+public class NavMeshAllocationTests
+{
+ private static NavMeshBuildSettings TestSettings() => new()
+ {
+ OverrideVoxelSize = true,
+ VoxelSize = 0.5f,
+ OverrideTileSize = true,
+ TileSize = 64,
+ };
+
+ private static NavMeshGeometrySource CornerQuad()
+ {
+ // Covers tile (0,0) of a 96x96 (3x3 tile) world; the other 8 tiles are empty.
+ Float3[] verts = [new(0, 0, 0), new(0, 0, 36), new(36, 0, 36), new(36, 0, 0)];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return new NavMeshGeometrySource(verts, indices, Float4x4.Identity);
+ }
+
+ ///
+ /// Tiles nothing overlaps must cost (almost) nothing: the chunky-index check replaces the
+ /// full heightfield + pipeline run (~132 KB each before the skip). On bounded bakes of
+ /// mostly-sealed worlds ~99% of tiles take this path, so this bound is what keeps
+ /// full-bake allocation proportional to walkable area rather than world area.
+ ///
+ [Fact]
+ public void EmptyTile_SkipsPipeline_AllocatingAlmostNothing()
+ {
+ var data = NavMeshBuilder.Build(TestSettings(), [CornerQuad()],
+ worldBounds: new AABB(new Float3(0, -1, 0), new Float3(96, 1, 96)));
+ Assert.NotNull(data);
+
+ // Rebuild an empty region twice: warm-up, then measure.
+ List<(int X, int Z, List Layers)> warm = NavMeshBuilder.BuildTilesInBounds(
+ data!, [CornerQuad()], new Float3(70, -1, 70), new Float3(80, 1, 80));
+ Assert.All(warm, t => Assert.Empty(t.Layers));
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ NavMeshBuilder.BuildTilesInBounds(data!, [CornerQuad()], new Float3(70, -1, 70), new Float3(80, 1, 80));
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(allocated < 16 * 1024,
+ $"Rebuilding an empty tile should skip the pipeline (allocated {allocated / 1024.0:0.0} KB).");
+ }
+
+ ///
+ /// Span pooling must not change output: rebuilding the same tile repeatedly (cold pool,
+ /// then warm recycled pool) must produce byte-identical layers. This is the guard against a
+ /// recycled span leaking stale state into a later build.
+ ///
+ [Fact]
+ public void PooledRebuilds_ProduceIdenticalTiles()
+ {
+ var data = NavMeshBuilder.Build(TestSettings(), [CornerQuad()],
+ worldBounds: new AABB(new Float3(0, -1, 0), new Float3(96, 1, 96)));
+ Assert.NotNull(data);
+
+ var bounds = (Min: new Float3(2, -1, 2), Max: new Float3(30, 1, 30));
+ List<(int X, int Z, List Layers)> first = NavMeshBuilder.BuildTilesInBounds(data!, [CornerQuad()], bounds.Min, bounds.Max);
+ Assert.Contains(first, t => t.Layers.Count > 0);
+
+ for (int i = 0; i < 4; i++)
+ {
+ List<(int X, int Z, List Layers)> again = NavMeshBuilder.BuildTilesInBounds(data!, [CornerQuad()], bounds.Min, bounds.Max);
+ Assert.Equal(first.Count, again.Count);
+ for (int t = 0; t < first.Count; t++)
+ {
+ Assert.Equal(first[t].X, again[t].X);
+ Assert.Equal(first[t].Z, again[t].Z);
+ Assert.Equal(first[t].Layers, again[t].Layers);
+ }
+ }
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshBuildTests.cs b/Prowl.Runtime.Test/NavMeshBuildTests.cs
new file mode 100644
index 000000000..2708fd923
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshBuildTests.cs
@@ -0,0 +1,614 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Recast.Detour;
+
+using Prowl.Echo;
+using Prowl.Runtime;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+public class NavMeshBuildTests
+{
+ ///
+ /// A 20x20 quad at y=0, wound counter-clockwise when viewed from above (+Y normal),
+ /// which is what Recast considers up-facing/walkable.
+ ///
+ private static NavMeshGeometrySource FlatQuad(float size = 20f)
+ {
+ Float3[] verts =
+ [
+ new(0, 0, 0),
+ new(0, 0, size),
+ new(size, 0, size),
+ new(size, 0, 0),
+ ];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return new NavMeshGeometrySource(verts, indices, Float4x4.Identity);
+ }
+
+ /// A 10x10 up-facing plane centred on X, offset along Z. Two of these with the
+ /// default tile grid (64 voxels ≈ 10.67 units) sit either side of a tile boundary, which is
+ /// what the link-rationing tests need.
+ private static NavMeshGeometrySource Plane(float zOffset) => new(
+ [new(-5, 0, -5 + zOffset), new(-5, 0, 5 + zOffset), new(5, 0, 5 + zOffset), new(5, 0, -5 + zOffset)],
+ [0, 1, 2, 0, 2, 3], Float4x4.Identity);
+
+ private static NavMeshBuildSettings TestSettings() => new()
+ {
+ // Coarse voxels + small tiles keep the test fast.
+ OverrideVoxelSize = true,
+ VoxelSize = 0.25f,
+ OverrideTileSize = true,
+ TileSize = 64,
+ };
+
+ ///
+ /// An asset whose tiles are in a format this engine cannot read says so, naming the versions,
+ /// rather than handing bytes to Detour that mean something else now. Nothing in the engine
+ /// produces such an asset — the guard exists for the next time the tile format changes, and
+ /// this is what keeps it honest until then.
+ ///
+ [Fact]
+ public void NavMeshData_FromAnUnreadableFormat_SaysWhichVersionsItReads()
+ {
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]);
+ Assert.NotNull(data);
+ data!.FormatVersion = NavMeshData.CurrentFormatVersion + 1;
+
+ var thrown = Assert.Throws(() => data.CreateTileCache(maxObstacles: 1));
+ Assert.Contains($"{NavMeshData.MinReadableFormatVersion}..{NavMeshData.CurrentFormatVersion}", thrown.Message);
+ Assert.Contains("Rebake", thrown.Message);
+ }
+
+ ///
+ /// The winding gate: Recast derives walkability from triangle face normals, so a total
+ /// winding/handedness mismatch fails as "the bake produced nothing" rather than an error.
+ /// This test existing and passing is what proves the coordinate conventions line up.
+ ///
+ [Fact]
+ public void FlatQuad_UpFacingWinding_ProducesWalkablePolys()
+ {
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]);
+
+ Assert.NotNull(data);
+ Assert.True(data!.HasTiles, "Expected at least one non-empty tile from a flat walkable quad.");
+
+ DtNavMesh navMesh = data.CreateTileCache(1).GetNavMesh();
+ int polyCount = 0;
+ for (int i = 0; i < navMesh.GetMaxTiles(); i++)
+ {
+ DtMeshTile tile = navMesh.GetTile(i);
+ if (tile?.data?.header != null)
+ polyCount += tile.data.header.polyCount;
+ }
+
+ Assert.True(polyCount > 0, "Navmesh instantiated but contains no polygons.");
+ }
+
+ /// The inverse gate: a downward-facing quad must produce nothing walkable — and
+ /// "nothing walkable" is a null return, never an empty NavMeshData (an empty one registers
+ /// nowhere and draws nothing, silently).
+ [Fact]
+ public void FlatQuad_DownFacingWinding_ReturnsNull()
+ {
+ NavMeshGeometrySource quad = FlatQuad();
+ // Reverse winding: normals point -Y, nothing is walkable.
+ (quad.Indices[1], quad.Indices[2]) = (quad.Indices[2], quad.Indices[1]);
+ (quad.Indices[4], quad.Indices[5]) = (quad.Indices[5], quad.Indices[4]);
+
+ Assert.Null(NavMeshBuilder.Build(TestSettings(), [quad]));
+ }
+
+ ///
+ /// Explicit world bounds: a bake whose geometry covers a corner of a much larger world
+ /// must size its bounds and tile grid from the supplied extent, not the geometry —
+ /// otherwise later partial rebuilds outside the initial geometry are silently discarded
+ /// (the destructible-map case: one open spawn cavern in a sealed map).
+ ///
+ [Fact]
+ public void Build_WithWorldBounds_SizesGridFromBounds()
+ {
+ // A 10x10 quad in the corner of a declared 100x100 world.
+ var bounds = new AABB(new Float3(0, -1, 0), new Float3(100, 1, 100));
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad(10f)], worldBounds: bounds);
+
+ Assert.NotNull(data);
+ Assert.Equal(0, data!.BoundsMin.X, 3);
+ Assert.Equal(0, data.BoundsMin.Z, 3);
+ Assert.Equal(100, data.BoundsMax.X, 3);
+ Assert.Equal(100, data.BoundsMax.Z, 3);
+ // Y unions with geometry rather than trusting the declared bounds alone.
+ Assert.True(data.BoundsMin.Y <= 0f && data.BoundsMax.Y >= 0f);
+
+ // Tile capacity must span the declared world: 100/16 per axis => 7x7 = 49 tiles minimum.
+ Assert.True(data.MaxTiles >= 49, $"MaxTiles must cover the declared bounds, got {data.MaxTiles}.");
+ }
+
+ [Fact]
+ public void Build_WithNoGeometry_ReturnsNull()
+ {
+ Assert.Null(NavMeshBuilder.Build(TestSettings(), []));
+ }
+
+ [Fact]
+ public void Build_AppliesDefaultAreaToPolys()
+ {
+ const int area = 4;
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()], defaultArea: area);
+ Assert.NotNull(data);
+
+ DtNavMesh navMesh = data!.CreateTileCache(1).GetNavMesh();
+ for (int i = 0; i < navMesh.GetMaxTiles(); i++)
+ {
+ DtMeshTile tile = navMesh.GetTile(i);
+ if (tile?.data?.header == null) continue;
+ for (int p = 0; p < tile.data.header.polyCount; p++)
+ Assert.Equal(area, NavMeshAreas.FromDetourArea(tile.data.polys[p].GetArea()));
+ }
+ }
+
+ ///
+ /// Two quads separated by a gap wider than the agent: same tile grid, but paths must not
+ /// cross the gap. Locks in that disconnected geometry stays disconnected.
+ ///
+ [Fact]
+ public void SeparatedQuads_BothBake()
+ {
+ NavMeshGeometrySource left = FlatQuad(10f);
+ NavMeshGeometrySource right = FlatQuad(10f);
+ right.Transform = Float4x4.CreateTranslation(new Float3(20f, 0, 0));
+
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [left, right]);
+ Assert.NotNull(data);
+ Assert.True(data!.HasTiles);
+ }
+
+ [Fact]
+ public void NavMeshData_EchoRoundTrip_PreservesTilesAndSettings()
+ {
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()], defaultArea: 3);
+ Assert.NotNull(data);
+ Assert.True(data!.HasTiles);
+
+ // The serialize → deserialize path every .navmesh asset takes.
+ EchoObject echo = Serializer.Serialize(data);
+ NavMeshData? loaded = Serializer.Deserialize(echo);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(data.CacheLayers.Count, loaded!.CacheLayers.Count);
+ for (int i = 0; i < data.CacheLayers.Count; i++)
+ {
+ Assert.Equal(data.CacheLayers[i].X, loaded.CacheLayers[i].X);
+ Assert.Equal(data.CacheLayers[i].Z, loaded.CacheLayers[i].Z);
+ Assert.Equal(data.CacheLayers[i].Data, loaded.CacheLayers[i].Data);
+ }
+
+ Assert.Equal(data.Settings.AgentRadius, loaded.Settings.AgentRadius);
+ Assert.Equal(data.TileWorldSize, loaded.TileWorldSize);
+ Assert.Equal(data.MaxTiles, loaded.MaxTiles);
+ Assert.Equal(data.MaxPolys, loaded.MaxPolys);
+ Assert.Equal(data.Origin, loaded.Origin);
+
+ // The reloaded asset must instantiate a working navmesh.
+ DtNavMesh navMesh = loaded.CreateTileCache(1).GetNavMesh();
+ int polyCount = 0;
+ for (int i = 0; i < navMesh.GetMaxTiles(); i++)
+ {
+ DtMeshTile tile = navMesh.GetTile(i);
+ if (tile?.data?.header != null)
+ polyCount += tile.data.header.polyCount;
+ }
+ Assert.True(polyCount > 0);
+ }
+
+ /// Two coplanar adjacent quads as separate sources with different areas.
+ /// Left: x 0..10 (Walkable), right: x 10..20 (area 3).
+ private static (NavMeshGeometrySource left, NavMeshGeometrySource right) TwoAreaFloor()
+ {
+ Float3[] leftVerts = [new(0, 0, 0), new(0, 0, 20), new(10, 0, 20), new(10, 0, 0)];
+ Float3[] rightVerts = [new(10, 0, 0), new(10, 0, 20), new(20, 0, 20), new(20, 0, 0)];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return (
+ new NavMeshGeometrySource(leftVerts, indices, Float4x4.Identity, NavMeshAreas.Walkable),
+ new NavMeshGeometrySource(rightVerts, indices, Float4x4.Identity, area: 3));
+ }
+
+ private static void AssertTwoAreaSemantics(NavMeshWorld world)
+ {
+ // Each side samples as its own area (Mask is the area's bit).
+ Assert.True(world.SamplePosition(new Float3(4, 0.2f, 10), out NavMeshHit leftHit, 0.5f, NavMesh.AllAreas));
+ Assert.Equal(1 << NavMeshAreas.Walkable, leftHit.Mask);
+ Assert.True(world.SamplePosition(new Float3(16, 0.2f, 10), out NavMeshHit rightHit, 0.5f, NavMesh.AllAreas));
+ Assert.Equal(1 << 3, rightHit.Mask);
+
+ // The area boundary must remain traversable — different areas are neighbours,
+ // not walls. A rubble border must never become invisible geometry.
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(4, 0, 10), new Float3(16, 0, 10), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+
+ // Excluding area 3 makes the right side unreachable (partial path at best).
+ int maskWithout3 = ~(1 << 3);
+ world.CalculatePath(new Float3(4, 0, 10), new Float3(16, 0, 10), maskWithout3, path);
+ Assert.NotEqual(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ /// The per-source-area gate: NavMeshGeometrySource.Area must survive the bake
+ /// into Detour poly areas, on the correct side, without breaking adjacency.
+ [Fact]
+ public void Build_HonorsPerSourceAreas()
+ {
+ (NavMeshGeometrySource left, NavMeshGeometrySource right) = TwoAreaFloor();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [left, right]);
+ Assert.NotNull(data);
+
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data!);
+
+ // The triangulation must contain both areas.
+ NavMeshTriangulation tri = world.CalculateTriangulation();
+ var areas = new HashSet(tri.Areas);
+ Assert.Contains(NavMeshAreas.Walkable, areas);
+ Assert.Contains(3, areas);
+
+ AssertTwoAreaSemantics(world);
+ }
+
+ /// Per-source areas must also survive the partial-rebuild path (the drill path
+ /// builds its provider separately).
+ [Fact]
+ public void BuildTilesInBounds_HonorsPerSourceAreas()
+ {
+ // Bake the whole floor uniform first...
+ (NavMeshGeometrySource left, NavMeshGeometrySource right) = TwoAreaFloor();
+ NavMeshGeometrySource uniformRight = right;
+ uniformRight.Area = NavMeshAreas.Walkable;
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [left, uniformRight]);
+ Assert.NotNull(data);
+
+ var world = new NavMeshWorld();
+ NavMeshInstance? instance = world.AddNavMeshData(data!);
+ Assert.NotNull(instance);
+
+ // ...then rebuild with the right half as area 3 (rubble appearing after a drill).
+ List<(int X, int Z, List Layers)> rebuilt = NavMeshBuilder.BuildTilesInBounds(
+ data!, [left, right], new Float3(0, -1, 0), new Float3(20, 1, 20));
+ Assert.NotEmpty(rebuilt);
+
+ SwapRebuiltTiles(world, instance!, rebuilt);
+ AssertTwoAreaSemantics(world);
+ }
+
+ /// The tile swap NavMeshSurface.ApplyRebuiltTiles performs, without a surface: drop
+ /// each affected tile's layers (and the navmesh tiles the cache built from them), add the
+ /// regenerated blobs, then re-contour.
+ private static void SwapRebuiltTiles(NavMeshWorld world, NavMeshInstance instance,
+ List<(int X, int Z, List Layers)> rebuilt)
+ {
+ world.MutateTileCache(instance, cache =>
+ {
+ DtNavMesh navMesh = cache.GetNavMesh();
+ var added = new List();
+ foreach ((int x, int z, List blobs) in rebuilt)
+ {
+ foreach (long tileRef in cache.GetTilesAt(x, z))
+ {
+ var header = cache.GetTileByRef(tileRef)?.header;
+ if (header != null)
+ {
+ long navRef = navMesh.GetTileRefAt(header.tx, header.ty, header.tlayer);
+ if (navRef != 0) navMesh.RemoveTile(navRef);
+ }
+ cache.RemoveTile(tileRef);
+ }
+ foreach (byte[] blob in blobs)
+ {
+ long tileRef = cache.AddTile(blob, 0);
+ if (tileRef != 0) added.Add(tileRef);
+ }
+ }
+ foreach (long tileRef in added)
+ cache.BuildNavMeshTile(tileRef);
+ });
+ }
+
+ /// Higher area cost must bias route choice: with a cheap detour available around
+ /// an expensive strip, the path avoids the strip; with uniform costs it goes straight.
+ [Fact]
+ public void AreaCosts_BiasPathSelection()
+ {
+ // 30x30 floor; a full-height strip (x 12..18) of area 4 crosses the middle.
+ Float3[] leftVerts = [new(0, 0, 0), new(0, 0, 30), new(12, 0, 30), new(12, 0, 0)];
+ Float3[] stripVerts = [new(12, 0, 0), new(12, 0, 30), new(18, 0, 30), new(18, 0, 0)];
+ Float3[] rightVerts = [new(18, 0, 0), new(18, 0, 30), new(30, 0, 30), new(30, 0, 0)];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(),
+ [
+ new NavMeshGeometrySource(leftVerts, indices, Float4x4.Identity, NavMeshAreas.Walkable),
+ new NavMeshGeometrySource(stripVerts, indices, Float4x4.Identity, area: 4),
+ new NavMeshGeometrySource(rightVerts, indices, Float4x4.Identity, NavMeshAreas.Walkable),
+ ]);
+ Assert.NotNull(data);
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data!);
+
+ // The strip spans the full floor, so it cannot be avoided — but a filter that prices
+ // area 4 highly must still cross it (cost biases, never blocks).
+ var expensive = new NavMeshQueryFilter();
+ expensive.SetAreaCost(4, 10f);
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(5, 0, 15), new Float3(25, 0, 15), expensive, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ ///
+ /// Tiles carry no detail mesh — heights come from the polygon planes, which is exact on flat
+ /// geometry. Locks in that such a bake builds, serializes, round-trips, and answers queries.
+ ///
+ [Fact]
+ public void Build_WithoutDetailMesh_BakesAndQueries()
+ {
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]);
+ Assert.NotNull(data);
+ Assert.True(data!.HasTiles);
+
+ // Serialized tiles with no detail mesh must round-trip and instantiate.
+ EchoObject echo = Serializer.Serialize(data);
+ NavMeshData? loaded = Serializer.Deserialize(echo);
+ Assert.NotNull(loaded);
+
+ var world = new NavMeshWorld();
+ Assert.NotNull(world.AddNavMeshData(loaded!));
+
+ // Queries work; heights come from the polygon planes (exact on a flat floor).
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(18, 0, 18), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ Assert.True(world.SamplePosition(new Float3(10, 0.5f, 10), out NavMeshHit hit, 1f, NavMesh.AllAreas));
+ Assert.True(System.Math.Abs(hit.Position.Y) < 0.3f, $"Height should come from the poly plane, got y={hit.Position.Y:0.00}.");
+ }
+
+ /// Same input twice must produce byte-identical tiles (single-threaded build).
+ [Fact]
+ public void Build_IsDeterministic_SingleThreaded()
+ {
+ NavMeshData? a = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]);
+ NavMeshData? b = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]);
+
+ Assert.NotNull(a);
+ Assert.NotNull(b);
+ Assert.Equal(a!.CacheLayers.Count, b!.CacheLayers.Count);
+ for (int i = 0; i < a.CacheLayers.Count; i++)
+ {
+ Assert.Equal(a.CacheLayers[i].X, b.CacheLayers[i].X);
+ Assert.Equal(a.CacheLayers[i].Z, b.CacheLayers[i].Z);
+ Assert.Equal(a.CacheLayers[i].Data, b.CacheLayers[i].Data);
+ }
+ }
+
+ ///
+ /// A threaded bake produces byte-identical tiles to a serial one. Every surface bake is
+ /// threaded, and workers build through their own reusable scratch — so a partition carrying
+ /// state between the tiles it builds, or writing results out of order, would show up here.
+ ///
+ [Fact]
+ public void Build_Threaded_MatchesSingleThreaded()
+ {
+ // 40x40 spans several tiles, so the work actually partitions across threads.
+ NavMeshData? serial = NavMeshBuilder.Build(TestSettings(), [FlatQuad(40f)], threads: 1);
+ NavMeshData? threaded = NavMeshBuilder.Build(TestSettings(), [FlatQuad(40f)], threads: 4);
+
+ Assert.NotNull(serial);
+ Assert.NotNull(threaded);
+ Assert.True(serial!.CacheLayers.Count > 1, "Test geometry must span more than one tile.");
+ Assert.Equal(serial.CacheLayers.Count, threaded!.CacheLayers.Count);
+ for (int i = 0; i < serial.CacheLayers.Count; i++)
+ {
+ Assert.Equal(serial.CacheLayers[i].X, threaded.CacheLayers[i].X);
+ Assert.Equal(serial.CacheLayers[i].Z, threaded.CacheLayers[i].Z);
+ Assert.Equal(serial.CacheLayers[i].Data, threaded.CacheLayers[i].Data);
+ }
+ }
+
+ ///
+ /// A baked asset triangulates without being registered with any scene or world, which is what
+ /// lets the editor draw the surface overlay outside play mode where nothing registers it.
+ ///
+ [Fact]
+ public void NavMeshData_CalculateTriangulation_WorksWithoutRegistration()
+ {
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]);
+ Assert.NotNull(data);
+
+ NavMeshTriangulation tri = data!.CalculateTriangulation();
+
+ Assert.NotEmpty(tri.Vertices);
+ Assert.NotEmpty(tri.Indices);
+ Assert.Equal(tri.Indices.Length / 3, tri.Areas.Length);
+ Assert.All(tri.Areas, a => Assert.Equal(NavMeshAreas.Walkable, a));
+ // Indices stay inside the vertex array — a fan-triangulation slip would blow past it.
+ Assert.All(tri.Indices, i => Assert.InRange(i, 0, tri.Vertices.Length - 1));
+ }
+
+ ///
+ /// Links whose endpoints fall in DIFFERENT tiles have to bake AND instantiate, however many
+ /// of them cross the same boundary. Detour sizes a tile's link pool when the tile is built
+ /// and does not fully budget connections that leave it, so past a handful the pool overflows
+ /// and AddTile throws IndexOutOfRange — at load, on an asset that baked and saved cleanly.
+ /// The trigger is the count crossing one boundary, not the width: a single Width=5 link and
+ /// five separate zero-width links failed identically. The tile builder now rations them, so
+ /// the excess is dropped with a warning rather than taking the whole navmesh down.
+ ///
+ [Theory]
+ [InlineData(1, 0f)]
+ [InlineData(1, 5f)] // one wide link: expands to 5 parallel connections
+ [InlineData(5, 0f)] // five separate narrow links across the same boundary
+ [InlineData(40, 0f)]
+ [InlineData(8, 12f)] // both at once
+ public void Build_LinksAcrossTileBoundary_Instantiate(int linkCount, float width)
+ {
+ var links = new List();
+ for (int i = 0; i < linkCount; i++)
+ {
+ float x = linkCount == 1 ? 0f : -3f + i * (6f / (linkCount - 1));
+ links.Add(new NavMeshLinkSource(new Float3(x, 0, 3.92f), new Float3(x, 0, 6.92f),
+ width, bidirectional: true, NavMeshAreas.Jump, userId: i + 1));
+ }
+
+ NavMeshData? data = NavMeshBuilder.Build(new NavMeshBuildSettings(),
+ [Plane(0f), Plane(11.08f)], links: links);
+ Assert.NotNull(data);
+ Assert.Equal(linkCount, data!.Links.Count);
+
+ var world = new NavMeshWorld();
+ NavMeshInstance? instance = world.AddNavMeshData(data);
+ Assert.NotNull(instance);
+ // Rationing may drop the excess, but never all of them — the route must survive.
+ Assert.True(instance!.ContainsLinkId(1), "The first link must reach the live navmesh.");
+
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(0, 0, -3), new Float3(0, 0, 14), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ ///
+ /// A tile's link pool is spent by connections ARRIVING from its eight neighbours as well as by
+ /// its own, and Detour budgets nothing for arrivals — so a destination can be swamped by
+ /// sources that are individually modest. This drives every neighbour at one destination at once.
+ ///
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(4)]
+ [InlineData(8)]
+ public void Build_LinksArrivingFromEveryNeighbour_Instantiate(int perNeighbour)
+ {
+ NavMeshData? data = NavMeshBuilder.Build(new NavMeshBuildSettings(), [FlatQuad(40f)]);
+ Assert.NotNull(data);
+
+ float ts = data!.TileWorldSize;
+ Float3 o = data.Origin;
+ Float3 Centre(int tx, int tz) => new(o.X + (tx + 0.5f) * ts, 0, o.Z + (tz + 0.5f) * ts);
+
+ Float3 destination = Centre(2, 2);
+ int id = 1;
+ for (int dx = -1; dx <= 1; dx++)
+ for (int dz = -1; dz <= 1; dz++)
+ {
+ if (dx == 0 && dz == 0) continue;
+ Float3 source = Centre(2 + dx, 2 + dz);
+ // Fanned out either side of the tile centre, not off to one side: the outermost
+ // tile centre is barely a tile from the quad's edge, and a source that lands past
+ // it (or inside the eroded margin) has no polygon to attach to — which would
+ // test the geometry rather than the link pool.
+ for (int k = 0; k < perNeighbour; k++)
+ data.Links.Add(NavMeshData.NavMeshLinkEntry.From(new NavMeshLinkSource(
+ new Float3(source.X + (k - (perNeighbour - 1) * 0.5f) * 0.4f, 0, source.Z), destination,
+ width: 0f, bidirectional: true, NavMeshAreas.Jump, userId: id++)));
+ }
+
+ var world = new NavMeshWorld();
+ NavMeshInstance? instance = world.AddNavMeshData(data);
+ Assert.NotNull(instance);
+
+ for (int link = 1; link < id; link++)
+ Assert.True(instance!.ContainsLinkId(link), $"Link {link} must reach the live navmesh.");
+ }
+
+ ///
+ /// Links crowding one tile boundary all cross, however many there are and however wide.
+ /// Detour sizes a tile's link pool from the connections stored in the tile and budgets nothing
+ /// for those arriving from neighbours, so a crowded boundary is where the pool runs out.
+ ///
+ [Theory]
+ [InlineData(1, 5f)] // one wide link
+ [InlineData(1, 20f)]
+ [InlineData(4, 0f)]
+ [InlineData(6, 0f)]
+ [InlineData(12, 0f)]
+ public void Build_LinksCrowdingATileBoundary_AllCross(int linkCount, float width)
+ {
+ var links = new List();
+ for (int i = 0; i < linkCount; i++)
+ {
+ float x = linkCount == 1 ? 0f : -3f + i * (6f / (linkCount - 1));
+ links.Add(new NavMeshLinkSource(new Float3(x, 0, 3.92f), new Float3(x, 0, 6.92f),
+ width, bidirectional: true, NavMeshAreas.Jump, userId: i + 1));
+ }
+
+ NavMeshData? data = NavMeshBuilder.Build(new NavMeshBuildSettings(),
+ [Plane(0f), Plane(11.08f)], links: links);
+ Assert.NotNull(data);
+
+ var warnings = new List();
+ void Capture(string message, DebugStackTrace? trace, LogSeverity severity)
+ {
+ if (severity == LogSeverity.Warning && message.Contains("NavMeshLink")) warnings.Add(message);
+ }
+
+ Debug.OnLog += Capture;
+ try
+ {
+ var world = new NavMeshWorld();
+ NavMeshInstance? instance = world.AddNavMeshData(data!);
+ Assert.NotNull(instance);
+
+ int inMesh = 0;
+ for (int i = 1; i <= linkCount; i++)
+ if (instance!.ContainsLinkId(i)) inMesh++;
+ Assert.Equal(linkCount, inMesh);
+ Assert.Empty(warnings);
+ }
+ finally
+ {
+ Debug.OnLog -= Capture;
+ }
+ }
+
+ ///
+ /// Min Region Area (Unity's) culls islands too small to be worth standing on. The layers a
+ /// carving bake stores are partitioned at runtime, so the cull has to happen at bake time
+ /// or not at all — this pins that it happens, and that turning it off keeps the island.
+ ///
+ [Theory]
+ [InlineData(20f, false)]
+ [InlineData(0f, true)]
+ public void Build_MinRegionArea_CullsSmallIslands(float minRegionArea, bool islandSurvives)
+ {
+ NavMeshBuildSettings settings = TestSettings();
+ settings.MinRegionArea = minRegionArea;
+
+ // A 4x4 platform floating well above the floor, far enough inside one tile that it is
+ // not exempted as a border region. Erosion leaves ~6 units² of it — under the 20 above.
+ NavMeshData? data = NavMeshBuilder.Build(settings, [FlatQuad(), Platform(3f, 3f, 4f, 2f)]);
+
+ Assert.NotNull(data);
+ NavMeshTriangulation tri = data!.CalculateTriangulation();
+
+ bool island = false;
+ foreach (Float3 v in tri.Vertices)
+ if (v.Y > 1.5f) island = true;
+
+ Assert.Equal(islandSurvives, island);
+ // The floor is far too big to cull either way.
+ Assert.Contains(tri.Vertices, v => v.Y < 1.5f);
+ }
+
+ /// An up-facing quad of at height .
+ private static NavMeshGeometrySource Platform(float minX, float minZ, float size, float y)
+ {
+ Float3[] verts =
+ [
+ new(minX, y, minZ),
+ new(minX, y, minZ + size),
+ new(minX + size, y, minZ + size),
+ new(minX + size, y, minZ),
+ ];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return new NavMeshGeometrySource(verts, indices, Float4x4.Identity);
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshCollectorTests.cs b/Prowl.Runtime.Test/NavMeshCollectorTests.cs
new file mode 100644
index 000000000..d0b2c8f77
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshCollectorTests.cs
@@ -0,0 +1,797 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Recast.Detour;
+using Prowl.Runtime;
+using Prowl.Runtime.Resources;
+using Prowl.Runtime.Terrain;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+public class NavMeshCollectorTests : RuntimeTestBase
+{
+ private static NavMeshBuildSettings TestSettings() => new()
+ {
+ OverrideVoxelSize = true,
+ VoxelSize = 0.25f,
+ OverrideTileSize = true,
+ TileSize = 64,
+ };
+
+ [Fact]
+ public void BoxCollider_CollectsAndBakesWalkableFloor()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ var box = floor.AddComponent();
+ box.Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0); // top surface at y=0
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ LayerMask.Everything, TestSettings().EffectiveVoxelSize, NavMeshAreas.Walkable, sources);
+
+ Assert.Single(sources);
+
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources);
+ Assert.NotNull(data);
+ Assert.True(data!.HasTiles);
+
+ // Path across the box top must work end to end.
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data);
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ [Fact]
+ public void Collect_RespectsLayerMask()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(10, 1, 10);
+ floor.LayerIndex = 5;
+
+ List sources = [];
+ LayerMask without5 = LayerMask.Everything;
+ without5.RemoveLayer(5);
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ without5, 0.25f, NavMeshAreas.Walkable, sources);
+ Assert.Empty(sources);
+
+ LayerMask with5 = LayerMask.Everything;
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ with5, 0.25f, NavMeshAreas.Walkable, sources);
+ Assert.Single(sources);
+ }
+
+ [Fact]
+ public void Collect_SkipsDisabledObjects()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(10, 1, 10);
+ floor.Enabled = false;
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources);
+ Assert.Empty(sources);
+ }
+
+ [Fact]
+ public void MeshRenderer_Collects_WithWorldTransform()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject go = CreateGameObject("Plane");
+ scene.Add(go);
+ var renderer = go.AddComponent();
+ renderer.Mesh = Mesh.CreateCube(new Float3(10, 0.2f, 10));
+ go.Transform.Position = new Float3(100, 0, 100);
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.RenderMeshes,
+ LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources);
+ Assert.Single(sources);
+
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources);
+ Assert.NotNull(data);
+ Assert.True(data!.HasTiles);
+
+ // The navmesh must be where the object is, not at the origin.
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data);
+ Assert.True(world.SamplePosition(new Float3(100, 0.5f, 100), out NavMeshHit hit, 2f, NavMesh.AllAreas));
+ Assert.True(System.Math.Abs(hit.Position.X - 100) < 3f);
+ Assert.False(world.SamplePosition(new Float3(0, 0, 0), out _, 2f, NavMesh.AllAreas));
+ }
+
+ [Fact]
+ public void RotatedBoxCollider_BakesRotated()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject ramp = CreateGameObject("Floor");
+ scene.Add(ramp);
+ var box = ramp.AddComponent();
+ box.Size = new Float3(20, 1, 6);
+ // 45° yaw (FromEuler takes degrees): the walkable strip runs diagonally.
+ ramp.Transform.Rotation = Quaternion.FromEuler(new Float3(0, 45f, 0));
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources);
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources);
+ Assert.NotNull(data);
+
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data!);
+
+ // Center is always on the strip.
+ Assert.True(world.SamplePosition(new Float3(0, 1f, 0), out _, 2f, NavMesh.AllAreas));
+ // The unrotated +X end is ~6.4 units off the rotated strip's center line: not walkable.
+ Assert.False(world.SamplePosition(new Float3(9f, 1f, 0), out _, 2f, NavMesh.AllAreas));
+ // Exactly one diagonal lies along the rotated strip (which one depends on yaw handedness).
+ bool posDiagonal = world.SamplePosition(new Float3(5f, 1f, 5f), out _, 2f, NavMesh.AllAreas);
+ bool negDiagonal = world.SamplePosition(new Float3(5f, 1f, -5f), out _, 2f, NavMesh.AllAreas);
+ Assert.True(posDiagonal ^ negDiagonal, $"Expected exactly one diagonal walkable (got +Z:{posDiagonal}, -Z:{negDiagonal}).");
+ }
+
+ /// An agent's own geometry, and geometry on its children, stay out of the bake.
+ [Fact]
+ public void Collect_SkipsAgentsAndTheirChildren()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ GameObject agent = CreateGameObject("Agent");
+ scene.Add(agent);
+ agent.Transform.Position = new Float3(0, 1, 0);
+ agent.AddComponent();
+ agent.AddComponent();
+
+ GameObject visual = CreateGameObject("AgentVisual");
+ scene.Add(visual);
+ visual.SetParent(agent);
+ visual.AddComponent().Size = new Float3(1, 2, 1);
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources);
+
+ Assert.Single(sources); // the floor only
+ }
+
+ ///
+ /// An agent standing on the floor at bake time must not voxelize as an obstruction, or it
+ /// leaves a permanent hole in the navmesh under wherever it stood.
+ ///
+ [Fact]
+ public void Bake_WithAgentStandingOnFloor_LeavesNoHole()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ var standing = new Float3(4, 0, 4);
+ GameObject agent = CreateGameObject("Agent");
+ scene.Add(agent);
+ agent.Transform.Position = standing + new Float3(0, 1, 0);
+ agent.AddComponent();
+ var body = agent.AddComponent();
+ body.Size = new Float3(2, 2, 2);
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ LayerMask.Everything, TestSettings().EffectiveVoxelSize, NavMeshAreas.Walkable, sources);
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources);
+ Assert.NotNull(data);
+
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data!);
+
+ // The floor under the agent is walkable, and a path runs straight through it.
+ Assert.True(world.SamplePosition(standing, out NavMeshHit hit, 0.5f, NavMesh.AllAreas));
+ Assert.True(Float3.Distance(hit.Position, standing) < 0.5f);
+
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ private const float TerrainSize = 64f;
+ private const float TerrainHeight = 8f;
+
+ /// A terrain spanning 0..64 on both axes, at whatever the caller's transform says.
+ /// takes world-space X and Z and returns 0..1; flat when
+ /// omitted.
+ private TerrainComponent AddTerrain(Scene scene, Func? normalizedHeight = null,
+ float height = TerrainHeight)
+ {
+ GameObject go = CreateGameObject("Terrain");
+ scene.Add(go);
+
+ const int res = 129;
+ var data = new TerrainData { Size = TerrainSize, Height = height };
+ data.ResizeHeightmap(res);
+ if (normalizedHeight != null)
+ {
+ float cell = TerrainSize / (res - 1);
+ for (int z = 0; z < res; z++)
+ for (int x = 0; x < res; x++)
+ data.SetHeight(x, z, normalizedHeight(x * cell, z * cell));
+ }
+
+ var terrain = go.AddComponent();
+ terrain.Data = data;
+ go.AddComponent();
+ return terrain;
+ }
+
+ /// One full sine period across the terrain, gentle enough to be walkable throughout.
+ private static float RollingHills(float x, float z)
+ => 0.5f + 0.5f * MathF.Sin(x / TerrainSize * MathF.PI * 2f) * MathF.Cos(z / TerrainSize * MathF.PI * 2f);
+
+ private NavMeshWorld BakeTerrain(Scene scene, bool heightDetail = true)
+ {
+ NavMeshBuildSettings settings = TestSettings();
+ settings.BuildHeightDetail = heightDetail;
+
+ List sources = [];
+ NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders,
+ LayerMask.Everything, settings.EffectiveVoxelSize, NavMeshAreas.Walkable, sources);
+ Assert.Single(sources);
+
+ NavMeshData? data = NavMeshBuilder.Build(settings, sources);
+ Assert.NotNull(data);
+
+ var world = new NavMeshWorld();
+ world.AddNavMeshData(data!);
+ return world;
+ }
+
+ ///
+ /// Baking happens with the editor open, where nothing but
+ /// components have had a lifecycle callback. Terrain has to be readable there or every bake
+ /// from the editor silently leaves it out.
+ ///
+ [Fact]
+ public void Terrain_CollectsAndBakesInEditMode()
+ {
+ using (EditMode())
+ {
+ Scene scene = CreateScene(enable: true);
+ AddTerrain(scene);
+
+ NavMeshWorld world = BakeTerrain(scene);
+ Assert.True(world.SamplePosition(new Float3(32, 0, 32), out NavMeshHit hit, 2f, NavMesh.AllAreas));
+ Assert.True(Math.Abs(hit.Position.Y) < 1f);
+ }
+ }
+
+ /// Heights are terrain-local, so the object's transform is what puts them in the world.
+ [Fact]
+ public void Terrain_BakesWhereItsTransformPutsIt()
+ {
+ Scene scene = CreateScene(enable: true);
+ TerrainComponent terrain = AddTerrain(scene);
+ terrain.Transform.Position = new Float3(-32, 25, -32);
+ terrain.Transform.LocalScale = new Float3(2, 1, 2);
+
+ NavMeshWorld world = BakeTerrain(scene);
+
+ // Scaled to 128 a side from a corner at -32, so the far edge reaches +96.
+ Assert.True(world.SamplePosition(new Float3(90, 25, 90), out NavMeshHit hit, 2f, NavMesh.AllAreas));
+ Assert.True(Math.Abs(hit.Position.Y - 25) < 1f, $"terrain baked at y={hit.Position.Y}, expected 25");
+ Assert.False(world.SamplePosition(new Float3(110, 25, 110), out _, 2f, NavMesh.AllAreas));
+ }
+
+ ///
+ /// The navmesh has to track a curved surface, not stretch across it. Polygons carry height
+ /// only at their corners, and region growing hands a whole hillside to one polygon, so without
+ /// height detail the mesh spans dips as a flat sheet and sinks into rises — measured at nearly
+ /// a metre on this terrain, which is gentle. Sampling the whole surface is what catches that:
+ /// a spot check would land on a polygon corner and read exactly right.
+ ///
+ [Fact]
+ public void Terrain_NavMeshTracksTheSurfaceItCovers()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddTerrain(scene, RollingHills);
+
+ NavMeshWorld world = BakeTerrain(scene);
+
+ float worst = 0, deepest = 0; int samples = 0; Float3 worstAt = default;
+ for (float z = 4; z < TerrainSize - 4; z += 0.5f)
+ {
+ for (float x = 4; x < TerrainSize - 4; x += 0.5f)
+ {
+ float expected = RollingHills(x, z) * TerrainHeight;
+ Assert.True(world.SamplePosition(new Float3(x, expected, z), out NavMeshHit hit, 4f, NavMesh.AllAreas),
+ $"no navmesh over ({x}, {z})");
+ samples++;
+
+ float error = hit.Position.Y - expected;
+ deepest = MathF.Min(deepest, error);
+ if (MathF.Abs(error) > worst) { worst = MathF.Abs(error); worstAt = new Float3(x, expected, z); }
+ }
+ }
+
+ Assert.True(samples > 10000);
+ // Voxelization puts the walkable surface at the top of a voxel column, so the mesh rides
+ // slightly high everywhere. Contour corners sit on the surface rather than above it
+ // (heights come through span connectivity), so between detail samples the mesh may sag
+ // a hair below a curved rise — about a voxel height, never more.
+ Assert.True(worst < 0.5f, $"worst vertical error {worst:F3} at {worstAt}");
+ Assert.True(deepest > -0.15f, $"navmesh sits {-deepest:F3} below the terrain");
+ }
+
+ ///
+ /// What the overlay and user tooling read has to track the surface as well. Position queries
+ /// and triangulation resolve heights by different routes, so one can be right while the other
+ /// still draws each polygon as a flat outline stretched over whatever it covers.
+ ///
+ [Fact]
+ public void Terrain_TriangulationTracksTheSurfaceItCovers()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddTerrain(scene, RollingHills);
+
+ NavMeshTriangulation tri = BakeTerrain(scene).CalculateTriangulation();
+
+ float worst = 0; int checkedTris = 0; Float3 worstAt = default;
+ for (int t = 0; t < tri.Areas.Length; t++)
+ {
+ // The centroid is the part of a triangle furthest from any vertex the mesh got right.
+ Float3 centroid = (tri.Vertices[tri.Indices[t * 3 + 0]]
+ + tri.Vertices[tri.Indices[t * 3 + 1]]
+ + tri.Vertices[tri.Indices[t * 3 + 2]]) / 3f;
+ if (centroid.X < 4 || centroid.X > TerrainSize - 4 || centroid.Z < 4 || centroid.Z > TerrainSize - 4)
+ continue;
+
+ checkedTris++;
+ float error = MathF.Abs(centroid.Y - RollingHills(centroid.X, centroid.Z) * TerrainHeight);
+ if (error > worst) { worst = error; worstAt = centroid; }
+ }
+
+ Assert.True(checkedTris > 100);
+ Assert.True(worst < 0.5f, $"worst vertical error {worst:F3} at {worstAt} over {checkedTris} triangles");
+ }
+
+ ///
+ /// Steep ground is where navmesh generation degenerates, so bake terrain steep enough that
+ /// the 45° walk limit carves the surface up (24 units of relief; the walkable slopes reach
+ /// ~40°) and hold the mesh to the whole integrity set: no torn vertices, no unstitched tile
+ /// portals, no sliver polygons, no near-vertical facets, and a walked surface that is
+ /// continuous across polygon edges and stays on the ground it covers.
+ ///
+ [Fact]
+ public void Terrain_SteepBake_SurfaceIsContinuousGroundedAndSliverFree()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddTerrain(scene, RollingHills, height: 24f);
+ AssertBakeIntegrity(BakeTerrain(scene), RollingHills, 24f);
+ }
+
+ ///
+ /// A sculpted plateau: flat low ground, a ~37° ramp whose base line meanders the way
+ /// hand-sculpted terrain does, sharp creases at base and lip, flat top. The meander is the
+ /// point — it puts kinks in the walkable-region borders that a straight crease never makes,
+ /// and those kinks are what mint sliver polygons.
+ ///
+ [Fact]
+ public void Terrain_SculptedPlateauBake_SurfaceIsContinuousGroundedAndSliverFree()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddTerrain(scene, WigglyPlateau, height: 24f);
+ AssertBakeIntegrity(BakeTerrain(scene), WigglyPlateau, 24f);
+ }
+
+ private static float WigglyPlateau(float x, float z)
+ {
+ float x0 = 24f + 2.5f * MathF.Sin(z * 0.45f) + 1.2f * MathF.Sin(z * 1.3f);
+ float t = Math.Clamp((x - x0) / 16f, 0f, 1f);
+ return 0.5f * t * t * (3f - 2f * t) + 0.25f * (z / TerrainSize);
+ }
+
+ ///
+ /// Every structural guarantee a baked terrain navmesh makes, asserted in one pass:
+ ///
+ /// - no two vertices in a tile share an XZ column (a torn vertex splits the mesh into
+ /// overlapping sheets);
+ /// - every tile-border edge carries a link to its neighbouring tile (an unstitched
+ /// portal is a wall agents cannot cross);
+ /// - no facet of the walked surface stands past 60° — the walk limit is 45°, and
+ /// anything past 60° is not ground, it is an artifact drawn as a sheet and felt as a pop.
+ /// One carve-out: polygons narrower than the sliver-absorption threshold (3 voxels). A few
+ /// such strips survive where every union with a neighbour would bend reflex, and across a
+ /// strip that thin a single quantization step already reads as 45°+, so their facets are
+ /// only bounded (75°) rather than forbidden — a hand-span wedge at a crease, never a
+ /// wall;
+ /// - adjacent polygons agree about the surface along their shared edge — the two sides
+ /// read the same height cells, so any disagreement is a tear an agent falls through
+ /// visually even when Detour walks it fine. Tile borders are held tighter still: both
+ /// tiles describe the seam from the same cells, so they must meet on it, not near it;
+ /// - the surface sits on the terrain: a little high everywhere (voxelization rides the
+ /// top of the cell), never buried, because a buried stretch draws as a hole.
+ ///
+ ///
+ private void AssertBakeIntegrity(NavMeshWorld world, Func normalizedHeight, float height)
+ {
+ DtNavMesh mesh = world.GetInstance()!.NativeNavMesh;
+
+ int tornVerts = 0, unstitchedPortals = 0, steepFacets = 0, steepStripFacets = 0, crackedEdges = 0;
+ double worstCrack = 0, highestAbove = 0, deepestBelow = 0, worstSlope = 0;
+ double worstCrossTileStep = 0;
+ Float3 worstSlopeAt = default, worstCrossTileStepAt = default;
+
+ var detailCache = new Dictionary<(int t, int p), List>();
+ List Det(int dt, int dp)
+ {
+ if (!detailCache.TryGetValue((dt, dp), out List? tris))
+ detailCache[(dt, dp)] = tris = DetailTris(mesh.GetTile(dt), dp);
+ return tris;
+ }
+
+ var polyRings = new Dictionary<(int t, int p), Float3[]>();
+ var borderEdges = new List<(Float3 a, Float3 b, int t, int p)>();
+
+ for (int t = 0; t < mesh.GetMaxTiles(); t++)
+ {
+ DtMeshTile tile = mesh.GetTile(t);
+ if (tile?.data?.header == null) continue;
+
+ var columns = new HashSet<(int X, int Z)>();
+ for (int v = 0; v < tile.data.header.vertCount; v++)
+ if (!columns.Add(((int)MathF.Round(tile.data.verts[v * 3] * 64), (int)MathF.Round(tile.data.verts[v * 3 + 2] * 64))))
+ tornVerts++;
+
+ for (int p = 0; p < tile.data.header.polyCount; p++)
+ {
+ DtPoly poly = tile.data.polys[p];
+ if (poly.GetPolyType() == DtPolyTypes.DT_POLYTYPE_OFFMESH_CONNECTION) continue;
+
+ var corners = new Float3[poly.vertCount];
+ for (int v = 0; v < poly.vertCount; v++)
+ corners[v] = NavMeshConnection.VertexAt(tile, poly.verts[v]);
+ polyRings[(t, p)] = corners;
+ for (int v = 0; v < poly.vertCount; v++)
+ if (poly.neis[v] == 0)
+ borderEdges.Add((corners[v], corners[(v + 1) % poly.vertCount], t, p));
+
+ bool narrowStrip = PolyWidthXZ(corners) < 0.75; // the builder's absorption threshold (3 voxels)
+ foreach (Float3[] tri in Det(t, p))
+ {
+ Float3 n = Float3.Cross(tri[1] - tri[0], tri[2] - tri[0]);
+ double len = Math.Sqrt(n.X * n.X + n.Y * n.Y + n.Z * n.Z);
+ double slope = len < 1e-9 ? 90 : Math.Acos(Math.Clamp(Math.Abs(n.Y / len), 0, 1)) * 180.0 / Math.PI;
+ if (slope > worstSlope && !narrowStrip)
+ {
+ worstSlope = slope;
+ worstSlopeAt = (tri[0] + tri[1] + tri[2]) / 3f;
+ }
+ if (slope > 60.0 && !narrowStrip)
+ steepFacets++;
+ if (slope > 75.0 && narrowStrip)
+ steepStripFacets++;
+
+ // The surface between the vertices is where errors hide; vertices alone
+ // always read within quantization.
+ Float3[] samples =
+ [
+ (tri[0] + tri[1] + tri[2]) / 3f,
+ (tri[0] + tri[1]) / 2f,
+ (tri[1] + tri[2]) / 2f,
+ (tri[2] + tri[0]) / 2f,
+ ];
+ foreach (Float3 s in samples)
+ {
+ double dev = s.Y - normalizedHeight((float)s.X, (float)s.Z) * height;
+ highestAbove = Math.Max(highestAbove, dev);
+ deepestBelow = Math.Min(deepestBelow, dev);
+ }
+ }
+
+ for (int j = 0; j < poly.vertCount; j++)
+ {
+ Float3 a = corners[j], b = corners[(j + 1) % poly.vertCount];
+ int nei = poly.neis[j];
+
+ if ((nei & DtDetour.DT_EXT_LINK) != 0)
+ {
+ bool linked = false;
+ for (int l = poly.firstLink; l != DtDetour.DT_NULL_LINK; l = tile.links[l].next)
+ {
+ if (tile.links[l].edge != j || tile.links[l].refs == 0) continue;
+ linked = true;
+
+ // The far side reads its own tile's layer, whose heights can
+ // quantize a step or two apart from this one's.
+ DtDetour.DecodePolyId(tile.links[l].refs, out _, out int nt, out int np);
+ if (mesh.GetTile(nt)?.data?.header == null) continue;
+ double crossGap = 0;
+ for (int k = 1; k < 8; k++)
+ {
+ double u = k / 8.0;
+ double x = a.X + (b.X - a.X) * u, z = a.Z + (b.Z - a.Z) * u;
+ double? hp = DetailHeightAt(Det(t, p), x, z, 0.05);
+ double? hq = DetailHeightAt(Det(nt, np), x, z, 0.05);
+ if (hp is double vp && hq is double vq)
+ crossGap = Math.Max(crossGap, Math.Abs(vp - vq));
+ }
+ if (crossGap > worstCrossTileStep)
+ {
+ worstCrossTileStep = crossGap;
+ worstCrossTileStepAt = new Float3(
+ (a.X + b.X) / 2f, (a.Y + b.Y) / 2f, (a.Z + b.Z) / 2f);
+ }
+ }
+ if (!linked) unstitchedPortals++;
+ continue;
+ }
+
+ // Interior edge, visited once per pair: both polygons' detail surfaces must
+ // tell the same story along it.
+ if (nei == 0 || nei - 1 <= p) continue;
+ int q = nei - 1;
+
+ double gap = 0;
+ for (int k = 1; k < 8; k++)
+ {
+ double u = k / 8.0;
+ double x = a.X + (b.X - a.X) * u, z = a.Z + (b.Z - a.Z) * u;
+ double? hp = DetailHeightAt(Det(t, p), x, z);
+ double? hq = DetailHeightAt(Det(t, q), x, z);
+ if (hp is double vp && hq is double vq)
+ gap = Math.Max(gap, Math.Abs(vp - vq));
+ }
+
+ worstCrack = Math.Max(worstCrack, gap);
+ if (gap > 0.1)
+ crackedEdges++;
+ }
+ }
+ }
+
+ // Two polygons covering the same ground: the crack checks compare along shared edges,
+ // so surfaces crossing without sharing one would slip past them. A detail triangle's
+ // centroid strictly inside another polygon's footprint is coverage claimed twice.
+ int overlapSamples = 0;
+ Float3 overlapAt = default;
+ foreach (((int t, int p) ka, Float3[] _) in polyRings)
+ {
+ foreach (Float3[] tri in Det(ka.t, ka.p))
+ {
+ Float3 c = (tri[0] + tri[1] + tri[2]) / 3f;
+ foreach (((int t, int p) kb, Float3[] ringB) in polyRings)
+ {
+ if (kb == ka || !InsideXZ(ringB, c.X, c.Z, 0.02)) continue;
+ overlapSamples++;
+ overlapAt = c;
+ break;
+ }
+ }
+ }
+
+ // Detached slits: two border edges facing each other across a thin gap at similar
+ // height — a hair-wide hole between polygons that should abut, drawn as a dark tear.
+ // Border chains meeting at a corner are one hole's outline and do not count.
+ int slitSamples = 0;
+ Float3 slitAt = default;
+ foreach ((Float3 a, Float3 b, int t, int p) ea in borderEdges)
+ {
+ for (int k = 1; k < 4 && slitSamples == 0; k++)
+ {
+ Float3 m = ea.a + (ea.b - ea.a) * (k / 4f);
+ foreach ((Float3 a, Float3 b, int t, int p) eb in borderEdges)
+ {
+ if ((eb.t == ea.t && eb.p == ea.p)
+ || SamePointXZ(ea.a, eb.a) || SamePointXZ(ea.a, eb.b)
+ || SamePointXZ(ea.b, eb.a) || SamePointXZ(ea.b, eb.b))
+ continue;
+
+ (double d, double dy, double u) = PointToSegment(eb.a, eb.b, m);
+ if (d > 0.005 && d < 0.35 && dy < 0.6 && u > 0.05 && u < 0.95)
+ {
+ slitSamples++;
+ slitAt = m;
+ break;
+ }
+ }
+ }
+ }
+
+ // Tile seams close exactly, not nearly. Both tiles read the same cells and describe the
+ // seam the same way, so a residual step means one of them built its seam from something
+ // the other did not have. A fraction of a voxel is enough: it draws as a dark hairline
+ // at a grazing angle.
+ Assert.True(worstCrossTileStep < 0.005,
+ $"tile surfaces meet {worstCrossTileStep:F3} apart at a seam (worst at {worstCrossTileStepAt})");
+
+ // The outlines the debug view draws must lie on the surface it draws them around. The
+ // detail bends every polygon edge between its corners, so a chord corner to corner
+ // leaves the surface and reads as a dark seam under it — a defect that exists only in
+ // the drawing, which is the worst kind to chase.
+ NavMeshTriangulation triangulation = NavMeshTriangulation.FromNavMesh(mesh);
+ int floatingEdges = 0;
+ double worstFloatingEdge = 0;
+ Float3 floatingEdgeAt = default;
+ foreach (NavMeshEdge edge in triangulation.Edges)
+ {
+ Float3 mid = (edge.A + edge.B) / 2f;
+ double best = double.MaxValue;
+ foreach ((_, List tris) in detailCache)
+ {
+ double? surface = DetailHeightAt(tris, mid.X, mid.Z);
+ if (surface.HasValue)
+ best = Math.Min(best, Math.Abs(surface.Value - mid.Y));
+ }
+
+ if (best == double.MaxValue || best <= 0.02) continue;
+ floatingEdges++;
+ if (best > worstFloatingEdge)
+ {
+ worstFloatingEdge = best;
+ floatingEdgeAt = mid;
+ }
+ }
+
+ Assert.True(floatingEdges == 0, $"{floatingEdges} drawn outline segments leave the surface (worst {worstFloatingEdge:F2} at {floatingEdgeAt})");
+ Assert.True(tornVerts == 0, $"{tornVerts} torn vertices (same XZ column twice in one tile layer)");
+ Assert.True(unstitchedPortals == 0, $"{unstitchedPortals} tile-border edges without a link to the neighbouring tile");
+ Assert.True(overlapSamples == 0, $"{overlapSamples} detail triangles sit inside another polygon's footprint (e.g. at {overlapAt})");
+ Assert.True(slitSamples == 0, $"hair-wide holes between polygons that should abut (e.g. at {slitAt})");
+ Assert.True(steepFacets == 0, $"{steepFacets} facets steeper than 60° on ground that never exceeds ~40° (worst {worstSlope:F1}° at {worstSlopeAt})");
+ Assert.True(steepStripFacets == 0, $"{steepStripFacets} facets steeper than 75° inside sub-absorption-width strips");
+ Assert.True(crackedEdges == 0, $"{crackedEdges} shared edges where adjacent detail surfaces disagree by more than 0.1 (worst {worstCrack:F2})");
+ // Measured on these terrains: rides up to ~+0.7 high (hull corners carry the max of
+ // their 2x2 cell neighbourhood plus quantization), dips no lower than chord sag between
+ // detail samples. Growth past these bounds is a defect, not drift.
+ Assert.True(highestAbove < 0.9, $"surface floats {highestAbove:F2} above the terrain");
+ Assert.True(deepestBelow > -0.35, $"surface buried {-deepestBelow:F2} below the terrain");
+ }
+
+ /// The walked surface of one polygon: its height-detail triangles, or the corner
+ /// fan Detour falls back to when a tile carries no detail. Reads the tile directly, per
+ /// polygon, rather than through , which flattens every
+ /// polygon into one buffer. Both decode the same convention — a detail index below the
+ /// polygon's vertex count means a corner, above it means a vertex the detail added — so a
+ /// change to that convention lands on both.
+ private static List DetailTris(DtMeshTile tile, int p)
+ {
+ DtPoly poly = tile.data.polys[p];
+ var corners = new Float3[poly.vertCount];
+ for (int v = 0; v < poly.vertCount; v++)
+ corners[v] = NavMeshConnection.VertexAt(tile, poly.verts[v]);
+
+ List tris = [];
+ if (tile.data.detailMeshes == null)
+ {
+ for (int v = 2; v < poly.vertCount; v++)
+ tris.Add([corners[0], corners[v - 1], corners[v]]);
+ return tris;
+ }
+
+ DtPolyDetail det = tile.data.detailMeshes[p];
+ Float3 VertOf(int idx)
+ {
+ if (idx < poly.vertCount) return corners[idx];
+ int i = (det.vertBase + (idx - poly.vertCount)) * 3;
+ return new Float3(tile.data.detailVerts[i], tile.data.detailVerts[i + 1], tile.data.detailVerts[i + 2]);
+ }
+ for (int d = 0; d < det.triCount; d++)
+ {
+ int i = (det.triBase + d) * 4;
+ tris.Add([VertOf(tile.data.detailTris[i]), VertOf(tile.data.detailTris[i + 1]), VertOf(tile.data.detailTris[i + 2])]);
+ }
+ return tris;
+ }
+
+ /// Whether an XZ point sits strictly inside a convex ring, at least
+ /// in from every edge — points on shared edges and corners
+ /// therefore do not count.
+ private static bool InsideXZ(Float3[] ring, double x, double z, double shrink)
+ {
+ int n = ring.Length;
+ double sign = 0;
+ for (int i = 0, j = n - 1; i < n; j = i++)
+ {
+ double ex = ring[i].X - ring[j].X, ez = ring[i].Z - ring[j].Z;
+ double len = Math.Sqrt(ex * ex + ez * ez);
+ if (len < 1e-9) continue;
+ double d = (ex * (z - ring[j].Z) - ez * (x - ring[j].X)) / len;
+ if (sign == 0 && Math.Abs(d) > 1e-9) sign = Math.Sign(d);
+ if (sign != 0 && d * sign < shrink) return false;
+ }
+ return sign != 0;
+ }
+
+ private static bool SamePointXZ(Float3 a, Float3 b)
+ => Math.Abs(a.X - b.X) < 1e-3 && Math.Abs(a.Z - b.Z) < 1e-3;
+
+ /// XZ distance from a point to a segment, the height difference at the closest
+ /// spot, and how far along the segment it lies.
+ private static (double d, double dy, double u) PointToSegment(Float3 a, Float3 b, Float3 p)
+ {
+ double abx = b.X - a.X, abz = b.Z - a.Z;
+ double len2 = abx * abx + abz * abz;
+ double u = len2 > 1e-12 ? Math.Clamp(((p.X - a.X) * abx + (p.Z - a.Z) * abz) / len2, 0, 1) : 0;
+ double x = a.X + u * abx, z = a.Z + u * abz, y = a.Y + u * (b.Y - a.Y);
+ double dx = x - p.X, dz = z - p.Z;
+ return (Math.Sqrt(dx * dx + dz * dz), Math.Abs(y - p.Y), u);
+ }
+
+ /// A polygon's XZ footprint width: twice its area over its longest edge — the
+ /// width of the strip it covers, the same measure sliver absorption uses.
+ private static double PolyWidthXZ(Float3[] corners)
+ {
+ double area2 = 0, maxEdgeSq = 0;
+ for (int i = 0, j = corners.Length - 1; i < corners.Length; j = i++)
+ {
+ area2 += corners[j].X * corners[i].Z - corners[i].X * corners[j].Z;
+ double dx = corners[i].X - corners[j].X, dz = corners[i].Z - corners[j].Z;
+ maxEdgeSq = Math.Max(maxEdgeSq, dx * dx + dz * dz);
+ }
+ return Math.Abs(area2) / Math.Max(1e-9, Math.Sqrt(maxEdgeSq));
+ }
+
+ /// Height of a polygon's walked surface at an XZ point, from whichever of its
+ /// triangles covers it; null when the point is outside them all. Points exactly on a
+ /// triangle edge sit on the boundary of two, so a little slack keeps the lookup from
+ /// falling between them.
+ private static double? DetailHeightAt(List tris, double x, double z, double slackLimit = 0.02)
+ {
+ double? best = null;
+ double bestSlack = double.MaxValue;
+ foreach (Float3[] t in tris)
+ {
+ double d = (t[1].Z - t[2].Z) * (t[0].X - t[2].X) + (t[2].X - t[1].X) * (t[0].Z - t[2].Z);
+ if (Math.Abs(d) < 1e-12) continue;
+ double wa = ((t[1].Z - t[2].Z) * (x - t[2].X) + (t[2].X - t[1].X) * (z - t[2].Z)) / d;
+ double wb = ((t[2].Z - t[0].Z) * (x - t[2].X) + (t[0].X - t[2].X) * (z - t[2].Z)) / d;
+ double wc = 1 - wa - wb;
+ double slack = -Math.Min(wa, Math.Min(wb, wc));
+ if (slack < bestSlack)
+ {
+ bestSlack = slack;
+ best = wa * t[0].Y + wb * t[1].Y + wc * t[2].Y;
+ }
+ }
+ return bestSlack < slackLimit ? best : null;
+ }
+
+
+ ///
+ /// Turning height detail off has to actually skip it — the setting exists to buy back the
+ /// build time it costs on every tile, including every obstacle carve, for scenes whose ground
+ /// is flat enough that polygon corners already describe it. Asserted on the vertices
+ /// themselves: a coarse bake's surface is made of polygon corners and nothing else, while a
+ /// detailed bake of curved ground must have added some.
+ ///
+ [Fact]
+ public void Terrain_WithoutHeightDetail_KeepsTheCoarsePolygons()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddTerrain(scene, RollingHills);
+ NavMeshTriangulation detailed = BakeTerrain(scene).CalculateTriangulation();
+
+ Scene coarseScene = CreateScene(enable: true);
+ AddTerrain(coarseScene, RollingHills);
+ NavMeshTriangulation coarse = BakeTerrain(coarseScene, heightDetail: false).CalculateTriangulation();
+
+ Assert.True(Array.TrueForAll(coarse.IsPolygonCorner, c => c),
+ "the coarse bake carries height-detail vertices despite BuildHeightDetail being off");
+ Assert.Contains(false, detailed.IsPolygonCorner);
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshComponentTests.cs b/Prowl.Runtime.Test/NavMeshComponentTests.cs
new file mode 100644
index 000000000..d3c78699e
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshComponentTests.cs
@@ -0,0 +1,625 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+
+using Prowl.Runtime;
+using Prowl.Runtime.Resources;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+public class NavMeshComponentTests : RuntimeTestBase
+{
+ private (Scene scene, NavMeshSurface surface) CreateBakedFloorScene(float size = 20f)
+ => CreateFloorScene(size, bake: true);
+
+ ///
+ /// Agent-type resolution end to end: two types with different radii baked from the same
+ /// scene produce two navmeshes, and a corridor passable for the small type is eroded shut
+ /// for the large one. Also locks the agent-table → surface composition
+ /// (ResolveBuildSettings) and per-type instance lookup.
+ ///
+ [Fact]
+ public void Surfaces_TwoAgentTypes_ProduceDifferentWalkability()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f, Height = 2f, MaxSlope = 45f, MaxClimb = 0.4f },
+ new NavMeshAgentType { Id = 7, Name = "Tank", Radius = 1.4f, Height = 2f, MaxSlope = 45f, MaxClimb = 0.4f },
+ ]);
+
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Corridor");
+ scene.Add(floor);
+ // 3-unit-wide corridor: walkable inset survives radius 0.5, vanishes at radius 1.4.
+ floor.AddComponent().Size = new Float3(30, 1, 3);
+ floor.Transform.Position = new Float3(15, -0.5f, 1.5f);
+
+ GameObject smallGo = CreateGameObject("SmallSurface");
+ scene.Add(smallGo);
+ var small = smallGo.AddComponent();
+ ApplyFastBakeSettings(small);
+ small.AgentTypeId = 0;
+ Assert.True(small.BuildNavMesh());
+
+ GameObject largeGo = CreateGameObject("LargeSurface");
+ scene.Add(largeGo);
+ var large = largeGo.AddComponent();
+ ApplyFastBakeSettings(large);
+ large.AgentTypeId = 7;
+ // The large type's bake legitimately produces nothing walkable in this corridor.
+ bool largeBaked = large.BuildNavMesh();
+
+ // Resolution pulled the right envelopes from the table.
+ Assert.Equal(0.5f, small.ResolveBuildSettings().AgentRadius);
+ Assert.Equal(1.4f, large.ResolveBuildSettings().AgentRadius);
+
+ // Small type walks the corridor; large type has no mesh there (either its bake was
+ // empty or its instance has nothing at the sample point).
+ Assert.True(scene.Navigation.HasNavMesh(0));
+ Assert.True(scene.Navigation.SamplePosition(new Float3(15, 0.2f, 1.5f), out _, 0.5f,
+ new NavMeshQueryFilter { AgentTypeId = 0 }));
+
+ bool largeWalkable = largeBaked && scene.Navigation.SamplePosition(new Float3(15, 0.2f, 1.5f), out _, 0.5f,
+ new NavMeshQueryFilter { AgentTypeId = 7 });
+ Assert.False(largeWalkable, "A 1.4-radius agent type must not fit a 3-unit corridor.");
+ }
+ finally
+ {
+ // Agent-type table is global static state; restore defaults for other tests.
+ NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]);
+ }
+ }
+
+ ///
+ /// The table is read while bakes resolve their envelope from it, so a new one is built aside
+ /// and published in a single store. A source that faults part way through therefore leaves
+ /// the previous table standing rather than a half-built one.
+ ///
+ [Fact]
+ public void AgentTypes_ApplyTable_IsAllOrNothing()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid" },
+ new NavMeshAgentType { Id = 7, Name = "Tank", Radius = 1.4f },
+ ]);
+
+ Assert.Throws(() => NavMeshAgentTypes.ApplyTable(FaultingSource()));
+ Assert.Equal(1.4f, NavMeshAgentTypes.Get(7)?.Radius);
+ }
+ finally
+ {
+ NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]);
+ }
+
+ static IEnumerable FaultingSource()
+ {
+ yield return new NavMeshAgentType { Id = 0, Name = "Humanoid" };
+ throw new InvalidOperationException("Malformed settings.");
+ }
+ }
+
+ [Fact]
+ public void Surface_BakeRegistersWithSceneWorld()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene();
+
+ Assert.True(scene.Navigation.HasNavMesh());
+ Assert.NotNull(surface.Instance);
+
+ // The static facade reaches it when the scene is current.
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ [Fact]
+ public void Surface_DisableUnregisters()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene();
+ Assert.True(scene.Navigation.HasNavMesh());
+
+ surface.GameObject.Enabled = false;
+ Assert.False(scene.Navigation.HasNavMesh());
+
+ surface.GameObject.Enabled = true;
+ Assert.True(scene.Navigation.HasNavMesh());
+ }
+
+ [Fact]
+ public void Agent_WalksTowardDestination()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, -8);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 10f;
+ agent.Acceleration = 100f;
+
+ Assert.True(agent.SetDestination(new Float3(8, 0, 8)) || agent.PathPending || !agent.IsOnNavMesh);
+
+ double startDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8));
+ Tick(scene, 240); // 4 simulated seconds at 60 Hz
+
+ Assert.True(agent.IsOnNavMesh, "Agent should have joined the crowd.");
+ double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8));
+ Assert.True(endDistance < startDistance - 5.0,
+ $"Agent should approach the destination (start {startDistance:0.0}, end {endDistance:0.0}).");
+ }
+
+ [Fact]
+ public void Agent_RegistersWhenNavMeshAppearsLater()
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ // Agent first: no navmesh yet.
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-5, 0, -5);
+ var agent = agentGo.AddComponent();
+ Tick(scene, 2);
+ Assert.False(agent.IsOnNavMesh);
+
+ // Surface bakes afterwards (the runtime-rebake ordering).
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ Assert.True(surface.BuildNavMesh());
+
+ Tick(scene, 2);
+ Assert.True(agent.IsOnNavMesh, "Agent should register via NavMeshChanged once a navmesh exists.");
+ }
+
+ [Fact]
+ public void Agent_IsStoppedHaltsMovement()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, -8);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 10f;
+ agent.Acceleration = 100f;
+
+ Tick(scene, 2); // register
+ agent.SetDestination(new Float3(8, 0, 8));
+ Tick(scene, 30);
+
+ agent.IsStopped = true;
+ Tick(scene, 5); // let velocity decay
+ Float3 posWhenStopped = agentGo.Transform.Position;
+ Tick(scene, 60);
+ double drift = Float3.Distance(agentGo.Transform.Position, posWhenStopped);
+ Assert.True(drift < 0.6, $"Stopped agent should not keep moving (drifted {drift:0.00}).");
+
+ agent.IsStopped = false;
+ Tick(scene, 60);
+ double moved = Float3.Distance(agentGo.Transform.Position, posWhenStopped);
+ Assert.True(moved > 1.0, $"Resumed agent should move again (moved {moved:0.00}).");
+ }
+
+ [Fact]
+ public void Agent_WarpMovesAgentAndKeepsWorking()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, -8);
+ var agent = agentGo.AddComponent();
+ Tick(scene, 2);
+
+ Assert.True(agent.Warp(new Float3(5, 0, 5)));
+ Tick(scene, 1);
+ Assert.True(Float3.Distance(agentGo.Transform.Position, new Float3(5, 0, 5)) < 1.0);
+ Assert.True(agent.IsOnNavMesh);
+ }
+
+ ///
+ /// The destructible-map flow: block a corridor at runtime, rebuild only the affected
+ /// tiles, and the path reroutes. This is the RubbleRangers acceptance scenario.
+ ///
+ [Fact]
+ public void RebuildTiles_ReflectsChangedGeometry()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(30f);
+
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-12, 0, 0), new Float3(12, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ int cornersBefore = path.CornerCount;
+
+ // Drop a wall across the middle: x in [-1,1], z spanning the whole floor.
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 30);
+ wall.Transform.Position = new Float3(0, 2, 0);
+
+ // Partial rebuild only around the wall.
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -16), new Float3(2, 5, 16))));
+
+ // The straight path is now impossible; with the wall spanning the full width the
+ // destination becomes unreachable (partial path at best).
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-12, 0, 0), new Float3(12, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathPartial, path.Status);
+ Float3 last = path.Corners[path.CornerCount - 1];
+ Assert.True(last.X < 0, $"Partial path must stop on the near side of the wall (end x = {last.X:0.0}).");
+
+ // Remove the wall and rebuild the same tiles: route restored.
+ wall.Enabled = false;
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -16), new Float3(2, 5, 16))));
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-12, 0, 0), new Float3(12, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ _ = cornersBefore;
+ }
+
+ private static NavMeshGeometrySource FloorQuad(float minX, float minZ, float maxX, float maxZ)
+ {
+ Float3[] verts =
+ [
+ new(minX, 0, minZ),
+ new(minX, 0, maxZ),
+ new(maxX, 0, maxZ),
+ new(maxX, 0, minZ),
+ ];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return new NavMeshGeometrySource(verts, indices, Float4x4.Identity);
+ }
+
+ ///
+ /// A surface whose navmesh was built from explicit geometry with NO scene colliders or
+ /// renderers at all — the shape of a game with custom rendering/collision, where
+ /// CollectSources() returns nothing (the RubbleRangers case).
+ ///
+ private (Scene scene, NavMeshSurface surface) CreateExplicitGeometryScene()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+
+ // 30x30 floor from (0,0,0) to (30,0,30): 2x2 tiles at tileWorldSize 16.
+ NavMeshData? data = NavMeshBuilder.Build(surface.ResolveBuildSettings(), [FloorQuad(0, 0, 30, 30)]);
+ Assert.NotNull(data);
+ surface.ApplyNavMeshData(data!);
+ Assert.NotNull(surface.Instance);
+
+ // Sanity: the collectors really do see nothing in this scene.
+ Assert.Empty(surface.CollectSources());
+ return (scene, surface);
+ }
+
+ ///
+ /// The explicit-sources overload: rebuild one region from caller-supplied partial geometry
+ /// (a hole punched in the floor) and verify the changed region changed, untouched tiles
+ /// were not disturbed, and paths still cross the tile seam — i.e. the tile grid stayed
+ /// anchored to the original bake even though the incoming geometry spans only a corner.
+ ///
+ [Fact]
+ public void RebuildTiles_WithExplicitSources_ChangesRegionAndKeepsGridAnchored()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateExplicitGeometryScene();
+
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas));
+
+ // Punch a hole at x/z 4..12 by rebuilding from partial sources: the same floor but
+ // with the hole missing. The AABB passed is the CHANGED region (the hole), from which
+ // the engine derives the affected tile set (tile (0,0) here, border included); the
+ // sources cover that tile plus its erosion border (out to 18) — NOT the whole bake.
+ // That partial coverage is the point of the overload.
+ const float pad = 18f;
+ NavMeshGeometrySource[] holeRegion =
+ [
+ FloorQuad(0, 0, pad, 4), // south strip
+ FloorQuad(0, 12, pad, pad), // north strip
+ FloorQuad(0, 4, 4, 12), // west strip
+ FloorQuad(12, 4, pad, 12), // east strip
+ ];
+
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(4, -1, 4), new Float3(12, 1, 12)), holeRegion, out int rebuiltTiles));
+ Assert.True(rebuiltTiles > 0);
+
+ // Inside the hole: no longer walkable.
+ Assert.False(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas));
+ // Rebuilt tile outside the hole: still walkable.
+ Assert.True(scene.Navigation.SamplePosition(new Float3(2, 0.2f, 2), out _, 0.5f, NavMesh.AllAreas));
+ // Untouched far tile: undisturbed.
+ Assert.True(scene.Navigation.SamplePosition(new Float3(25, 0.2f, 25), out _, 0.5f, NavMesh.AllAreas));
+
+ // The grid-anchoring assertion: a path from the rebuilt region into an untouched tile
+ // must still connect across the tile seam. If the rebuild had re-anchored the grid to
+ // the incoming geometry, the swapped tiles would misalign and this seam would break.
+ Assert.True(scene.Navigation.CalculatePath(new Float3(2, 0, 2), new Float3(25, 0, 25), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ ///
+ /// An empty source list empties the affected tiles (a fully walled-in region) rather than
+ /// no-opping — "no geometry" and "no change" must not be conflated.
+ ///
+ [Fact]
+ public void RebuildTiles_WithEmptySources_EmptiesAffectedTiles()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateExplicitGeometryScene();
+
+ // The changed-region AABB sits inside tile (0,0) so border expansion stays within it.
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(2, -1, 2), new Float3(14, 1, 14)), [], out int rebuiltTiles));
+ Assert.True(rebuiltTiles > 0);
+
+ // The affected region is gone...
+ Assert.False(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas));
+ // ...but tiles outside the bounds (plus border bleed) survive.
+ Assert.True(scene.Navigation.SamplePosition(new Float3(25, 0.2f, 25), out _, 0.5f, NavMesh.AllAreas));
+
+ // Restoring the region with explicit sources brings it back (padded past the border).
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(2, -1, 2), new Float3(14, 1, 14)), [FloorQuad(0, 0, 18, 18)]));
+ Assert.True(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas));
+
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(2, 0, 2), new Float3(25, 0, 25), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ ///
+ /// The drill-outward scenario: bake a small spawn region inside declared world bounds
+ /// much larger than it, then RebuildTiles a region FAR from the original geometry and
+ /// assert walkable tiles appear there. Without explicit bounds the grid is sized to the
+ /// spawn region and every such rebuild is silently clamped away.
+ ///
+ [Fact]
+ public void RebuildTiles_OutsideOriginalGeometry_WorksWithDeclaredWorldBounds()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+
+ // Spawn cavern: a 10x10 floor in the corner of a declared 100x100 world.
+ Float3[] verts = [new(0, 0, 0), new(0, 0, 10), new(10, 0, 10), new(10, 0, 0)];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ var spawn = new NavMeshGeometrySource(verts, indices, Float4x4.Identity);
+ NavMeshData? data = NavMeshBuilder.Build(surface.ResolveBuildSettings(), [spawn],
+ worldBounds: new AABB(new Float3(0, -1, 0), new Float3(100, 1, 100)));
+ Assert.NotNull(data);
+ surface.ApplyNavMeshData(data!);
+
+ // Far region (x/z 60..80) has nothing yet.
+ Assert.False(scene.Navigation.SamplePosition(new Float3(70, 0.2f, 70), out _, 0.5f, NavMesh.AllAreas));
+
+ // Drill opens a cavern there: rebuild with sources for just that region.
+ Float3[] farVerts = [new(58, 0, 58), new(58, 0, 82), new(82, 0, 82), new(82, 0, 58)];
+ var farFloor = new NavMeshGeometrySource(farVerts, indices, Float4x4.Identity);
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(60, -1, 60), new Float3(80, 1, 80)), [spawn, farFloor], out int rebuiltTiles));
+ Assert.True(rebuiltTiles > 0, "Rebuild far from the original geometry must produce tiles, not clamp away.");
+
+ // The far region is now walkable; the untouched spawn region still is.
+ Assert.True(scene.Navigation.SamplePosition(new Float3(70, 0.2f, 70), out _, 0.5f, NavMesh.AllAreas));
+ Assert.True(scene.Navigation.SamplePosition(new Float3(5, 0.2f, 5), out _, 0.5f, NavMesh.AllAreas));
+ }
+
+ ///
+ /// Applying a second navmesh (map regeneration without a scene reload) must rebind the
+ /// crowd: the old crowd steered against a DtNavMesh that no longer exists, and agents must
+ /// rejoin the new one keeping their destination.
+ ///
+ [Fact]
+ public void Agent_SurvivesNavMeshReplacement()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, -8);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 10f;
+ agent.Acceleration = 100f;
+
+ Tick(scene, 2);
+ Assert.True(agent.IsOnNavMesh);
+ agent.SetDestination(new Float3(8, 0, 8));
+ Tick(scene, 30);
+
+ // Regenerate: a fresh bake of the same floor produces a NEW DtNavMesh instance.
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+
+ Assert.True(agent.IsOnNavMesh, "Agent must rejoin the replacement crowd after the navmesh swap.");
+ Assert.NotNull(scene.Navigation.NativeCrowd);
+
+ // Destination survived the swap and the agent still gets there on the new mesh.
+ Tick(scene, 240);
+ double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8));
+ Assert.True(endDistance < 2.0, $"Agent should reach its destination on the new mesh (got within {endDistance:0.0}).");
+ }
+
+ ///
+ /// Rotation regression (degrees fed into a radians wrap helper froze rotation ~2° in):
+ /// travelling perpendicular to the initial facing, the yaw must converge to the direction
+ /// of travel, not park a couple of degrees off.
+ ///
+ [Fact]
+ public void Agent_RotatesTowardTravelDirection()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, 0); // facing +Z by default
+ var agent = agentGo.AddComponent();
+ agent.Speed = 3f;
+ agent.Acceleration = 100f;
+ agent.AngularSpeed = 360f;
+
+ Tick(scene, 2);
+ agent.SetDestination(new Float3(8, 0, 0)); // travel +X: yaw 90° from start
+ Tick(scene, 90); // 1.5 simulated seconds: mid-travel, well past any turn time
+
+ Float3 forward = agentGo.Transform.Forward;
+ double alignment = forward.X; // dot(forward, +X)
+ Assert.True(alignment > 0.98,
+ $"Agent should face its +X travel direction (forward = ({forward.X:0.00}, {forward.Y:0.00}, {forward.Z:0.00})).");
+ }
+
+ ///
+ /// Arrival regression: with AutoBraking on (the default) and StoppingDistance 0, the
+ /// agent must still report arrival — RemainingDistance reads exactly 0, so the Unity
+ /// idiom "!PathPending && RemainingDistance <= StoppingDistance" terminates.
+ ///
+ [Fact]
+ public void Agent_DetectsArrival_WithAutoBraking()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, -8);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 6f;
+ agent.Acceleration = 50f;
+ agent.AutoBraking = true;
+ agent.StoppingDistance = 0f;
+
+ Tick(scene, 2);
+ agent.SetDestination(new Float3(8, 0, 8));
+
+ bool arrived = false;
+ for (int i = 0; i < 600 && !arrived; i++)
+ {
+ Tick(scene, 1);
+ arrived = !agent.PathPending && agent.RemainingDistance <= agent.StoppingDistance;
+ }
+
+ Assert.True(arrived, $"Arrival was never reported (RemainingDistance ended at {agent.RemainingDistance:0.000}).");
+ double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8));
+ Assert.True(endDistance < 1.0, $"Arrival reported {endDistance:0.00} away from the destination.");
+
+ // Unity parity: the destination stays readable after arrival (migrated code reads it).
+ Assert.True(Float3.Distance(agent.Destination, new Float3(8, 0, 8)) < 0.01,
+ $"Destination should still return the last target after arrival, got {agent.Destination}.");
+ }
+
+ ///
+ /// Unity parity: SetDestination on a stopped agent remembers the target but does NOT
+ /// clear the stopped state — IsStopped is a pause flag that survives new destinations.
+ ///
+ [Fact]
+ public void Agent_SetDestinationWhileStopped_StaysHalted()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, -8);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 8f;
+ agent.Acceleration = 100f;
+
+ Tick(scene, 2);
+ agent.IsStopped = true;
+ agent.SetDestination(new Float3(8, 0, 8));
+ Assert.True(agent.IsStopped, "SetDestination must not clear the stopped state.");
+
+ Float3 posBefore = agentGo.Transform.Position;
+ Tick(scene, 60);
+ Assert.True(Float3.Distance(agentGo.Transform.Position, posBefore) < 0.2,
+ "A stopped agent should not move toward a newly set destination.");
+
+ agent.IsStopped = false;
+ Tick(scene, 120);
+ Assert.True(Float3.Distance(agentGo.Transform.Position, posBefore) > 3.0,
+ "Resuming should start movement toward the remembered destination.");
+ }
+
+ ///
+ /// A changed-geometry region entirely outside the baked bounds must be a no-op — the old
+ /// clamp dragged the tile range onto the nearest edge column and, with explicit sources
+ /// that don't cover it, destroyed healthy edge tiles.
+ ///
+ [Fact]
+ public void RebuildTiles_RegionOutsideBakedBounds_IsNoOp()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(20f); // floor -10..10
+
+ // Edge of the floor is walkable before.
+ Assert.True(scene.Navigation.SamplePosition(new Float3(9, 0.2f, 0), out _, 1f, NavMesh.AllAreas));
+
+ // Region entirely outside the baked bounds, with sources that don't cover the edge.
+ bool changed = surface.RebuildTiles(new AABB(new Float3(50, -1, 50), new Float3(60, 1, 60)),
+ [], out int rebuiltTiles);
+
+ Assert.False(changed, "An out-of-bounds region should rebuild nothing.");
+ Assert.Equal(0, rebuiltTiles);
+ // The edge tiles survived.
+ Assert.True(scene.Navigation.SamplePosition(new Float3(9, 0.2f, 0), out _, 1f, NavMesh.AllAreas),
+ "Edge tiles must not be clamped into the rebuild and destroyed.");
+ }
+
+ ///
+ /// Corridor stability: a lone agent walking a 3-unit-wide corridor with tuned steering
+ /// (collision query range matched to the corridor instead of the open-level default of
+ /// radius x 12) must track the centreline instead of weaving between avoidance samples.
+ ///
+ [Fact]
+ public void Agent_TunedForCorridor_DoesNotWeave()
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject floor = CreateGameObject("Corridor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(30, 1, 3);
+ floor.Transform.Position = new Float3(15, -0.5f, 1.5f); // corridor x 0..30, z 0..3
+
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(2, 0, 1.5f);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 4f;
+ agent.Acceleration = 50f;
+ agent.CollisionQueryRange = 2f; // corridor-scale, not radius x 12 = 6
+ agent.Separation = false; // lone agent; separation has nothing useful to add
+
+ Tick(scene, 2);
+ Assert.True(agent.IsOnNavMesh);
+ agent.SetDestination(new Float3(28, 0, 1.5f));
+
+ double maxDeviation = 0;
+ for (int i = 0; i < 600; i++)
+ {
+ Tick(scene, 1);
+ Float3 pos = agentGo.Transform.Position;
+ if (pos.X > 3 && pos.X < 27) // measure the straightaway, not the endpoints
+ maxDeviation = System.Math.Max(maxDeviation, System.Math.Abs(pos.Z - 1.5));
+ if (!agent.PathPending && agent.RemainingDistance <= 0f) break;
+ }
+
+ Assert.True(maxDeviation < 0.6,
+ $"Agent weaved {maxDeviation:0.00} units off the corridor centreline.");
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshCrowdTests.cs b/Prowl.Runtime.Test/NavMeshCrowdTests.cs
new file mode 100644
index 000000000..9a7cf4748
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshCrowdTests.cs
@@ -0,0 +1,563 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Runtime;
+using Prowl.Runtime.Resources;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+///
+/// Per-agent-type crowds and per-agent steering filters (area mask + cost overrides flowing
+/// through the crowd's query-filter slots, not just explicit queries).
+///
+public class NavMeshCrowdTests : RuntimeTestBase
+{
+ private NavMeshAgent AddAgent(Scene scene, Float3 position)
+ {
+ GameObject go = CreateGameObject("Agent");
+ scene.Add(go);
+ go.Transform.Position = position;
+ var agent = go.AddComponent();
+ agent.Speed = 6f;
+ agent.Acceleration = 100f;
+ agent.Separation = false; // lone-agent tests; separation adds noise
+ agent.CollisionQueryRange = 2f; // corridor-scale steering
+ return agent;
+ }
+
+ ///
+ /// An agent walking a straight line must not shiver as it brakes into its destination. Facing
+ /// follows the steering vector rather than the crowd's actual velocity: the latter carries
+ /// avoidance and collision corrections that do not shrink with speed, so near the goal they
+ /// dominate a small vector and swing the agent's direction frame to frame.
+ ///
+ [Fact]
+ public void Agent_ApproachingDestination_DoesNotWobble()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+ NavMeshAgent agent = AddAgent(scene, new Float3(-8, 0, 0));
+ Tick(scene, 2);
+
+ // Straight down +X, no turns and nothing to avoid: every heading change is jitter.
+ Assert.True(agent.SetDestination(new Float3(8, 0, 0)));
+
+ // Only the braking zone is measured. The agent legitimately turns 90° at full angular
+ // speed at the start, to face down the path; the wobble is what happens AFTER it is
+ // already aligned and slowing, where every heading change is jitter by definition.
+ double worstStep = 0;
+ int samples = 0;
+ double previousYaw = double.NaN;
+ bool arrived = false;
+ for (int i = 0; i < 400 && !arrived; i++)
+ {
+ Tick(scene, 1);
+ arrived = !agent.PathPending && agent.RemainingDistance <= 0f;
+
+ Float3 v = agent.Velocity;
+ if (v.X * v.X + v.Z * v.Z < 0.0025) continue; // stopped: heading is meaningless
+ if (agent.RemainingDistance > 4f) { previousYaw = double.NaN; continue; }
+
+ double yaw = agent.Transform.Rotation.EulerAngles.Y;
+ if (!double.IsNaN(previousYaw))
+ {
+ double step = Math.Abs(Maths.DeltaAngle(yaw * Maths.Deg2Rad, previousYaw * Maths.Deg2Rad)) * Maths.Rad2Deg;
+ worstStep = Math.Max(worstStep, step);
+ samples++;
+ }
+ previousYaw = yaw;
+ }
+
+ Assert.True(arrived, "The agent should reach the destination.");
+ Assert.True(samples > 5, $"Expected to sample the braking approach, got {samples} frames.");
+ // Already pointed down a straight path, so a real correction is a fraction of a degree
+ // per frame. The wobble showed up as degree-scale swings back and forth near the goal.
+ Assert.True(worstStep < 0.5,
+ $"Agent heading jittered by {worstStep:0.00}° in one frame while braking on a straight path.");
+ }
+
+ ///
+ /// With nothing in range to dodge, an agent must travel the straight line it was given —
+ /// exactly, not approximately. Velocity-obstacle sampling picks from a discrete candidate set,
+ /// so running it against zero obstacles still rounds the chosen velocity, and the rounding
+ /// walks the agent centimetres off its line by the time it arrives. Skipping it also skips the
+ /// most expensive part of the crowd step.
+ ///
+ /// The floor is deliberately far wider than the walk: the same query consumes navmesh boundary
+ /// segments, so on a floor whose edge sits inside the agent's collision query range there IS
+ /// something in range and avoidance correctly stays on.
+ /// covers the other half — that a blocker in range still deflects it.
+ ///
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Agent_AloneOnAStraightPath_DoesNotDriftSideways(bool alongX)
+ {
+ // Walks ±8 with AddAgent's 2m query range, so 40 keeps every edge well out of range.
+ (Scene scene, _) = CreateBakedFloorScene(40f);
+ NavMeshAgent agent = AddAgent(scene, alongX ? new Float3(-8, 0, 0) : new Float3(0, 0, -8));
+ Assert.NotEqual(ObstacleAvoidanceType.NoObstacleAvoidance, agent.ObstacleAvoidanceQuality);
+ Tick(scene, 2);
+
+ Assert.True(agent.SetDestination(alongX ? new Float3(8, 0, 0) : new Float3(0, 0, 8)));
+
+ double worstLateral = 0;
+ bool arrived = false;
+ for (int i = 0; i < 400 && !arrived; i++)
+ {
+ Tick(scene, 1);
+ Float3 p = agent.Transform.Position;
+ worstLateral = Math.Max(worstLateral, Math.Abs(alongX ? p.Z : p.X));
+ arrived = !agent.PathPending && agent.RemainingDistance <= 0f;
+ }
+
+ Assert.True(arrived, "The agent should reach the destination.");
+ Assert.True(worstLateral < 0.005,
+ $"Agent strayed {worstLateral:0.0000} units off a straight path with nothing to avoid.");
+ }
+
+ ///
+ /// An agent alone beside a wall keeps avoidance switched on. The crowd feeds navmesh boundary
+ /// segments into the same query as neighbouring agents, so deciding on neighbour count alone
+ /// would leave a solitary agent with nothing keeping it off the edges.
+ ///
+ [Fact]
+ public void Agent_AloneNearABoundary_KeepsAvoidanceEngaged()
+ {
+ // Walk the agent one metre from an edge, inside AddAgent's 2m query range.
+ (Scene scene, _) = CreateBakedFloorScene(12f);
+ NavMeshAgent agent = AddAgent(scene, new Float3(-4, 0, 5));
+ Tick(scene, 2);
+
+ Assert.True(agent.SetDestination(new Float3(4, 0, 5)));
+ Tick(scene, 5);
+
+ Assert.Equal(0, agent.NativeAgent!.nneis);
+ Assert.True(agent.NativeAgent.boundary.GetSegmentCount() > 0, "The edge should be in range.");
+ Assert.True(agent.AvoidanceEngaged,
+ "A lone agent within range of the navmesh boundary must keep avoiding it.");
+ }
+
+ ///
+ /// SetPath steers along the route it is given, rather than throwing it away and re-planning to
+ /// its endpoint. A path that does not begin where the agent stands is refused, since adopting
+ /// it would jump the corridor somewhere the agent is not.
+ ///
+ [Fact]
+ public void Agent_SetPath_FollowsTheSuppliedRoute()
+ {
+ (Scene scene, _) = CreateBakedFloorScene(40f);
+ NavMeshAgent agent = AddAgent(scene, new Float3(-8, 0, 0));
+ Tick(scene, 2);
+
+ Assert.False(agent.SetPath(new NavMeshPath()), "An unusable path must be refused.");
+
+ var path = new NavMeshPath();
+ Assert.True(agent.CalculatePath(new Float3(8, 0, 0), path));
+ Assert.True(agent.SetPath(path));
+ Assert.True(agent.HasPath);
+ Assert.False(agent.PathPending, "An adopted path is already planned; nothing should be pending.");
+
+ bool arrived = false;
+ for (int i = 0; i < 400 && !arrived; i++)
+ {
+ Tick(scene, 1);
+ arrived = !agent.PathPending && agent.RemainingDistance <= 0f;
+ }
+
+ Assert.True(arrived, "The agent should walk the supplied path to its end.");
+ Assert.True(Float3.Distance(agent.Transform.Position, new Float3(8, 0, 0)) < 1.0,
+ $"Agent finished at {agent.Transform.Position}, not the path's end.");
+ }
+
+ // ── Per-agent-type crowds ───────────────────────────────────────────
+
+ private static void RestoreDefaultAgentTable()
+ => NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]);
+
+ ///
+ /// Two agent types in one scene get two independent crowds, and each agent walks the mesh
+ /// baked for its own type.
+ ///
+ [Fact]
+ public void TwoAgentTypes_GetSeparateCrowds_AndBothWalk()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f },
+ new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f },
+ ]);
+
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ foreach (int typeId in (int[])[0, 3])
+ {
+ GameObject surfaceGo = CreateGameObject($"Surface{typeId}");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ surface.AgentTypeId = typeId;
+ Assert.True(surface.BuildNavMesh());
+ }
+
+ NavMeshAgent humanoid = AddAgent(scene, new Float3(-8, 0, -8));
+ NavMeshAgent scout = AddAgent(scene, new Float3(-8, 0, 8));
+ scout.AgentTypeId = 3;
+
+ Tick(scene, 2);
+ Assert.True(humanoid.IsOnNavMesh);
+ Assert.True(scout.IsOnNavMesh);
+
+ // One crowd per type, and they are distinct objects.
+ Assert.NotNull(scene.Navigation.GetNativeCrowd(0));
+ Assert.NotNull(scene.Navigation.GetNativeCrowd(3));
+ Assert.NotSame(scene.Navigation.GetNativeCrowd(0), scene.Navigation.GetNativeCrowd(3));
+ // NativeCrowd stays sugar for type 0.
+ Assert.Same(scene.Navigation.NativeCrowd, scene.Navigation.GetNativeCrowd(0));
+
+ humanoid.SetDestination(new Float3(8, 0, -8));
+ scout.SetDestination(new Float3(8, 0, 8));
+ Tick(scene, 300);
+
+ Assert.True(Float3.Distance(humanoid.GameObject.Transform.Position, new Float3(8, 0, -8)) < 2.0,
+ "Humanoid should reach its destination on its own crowd.");
+ Assert.True(Float3.Distance(scout.GameObject.Transform.Position, new Float3(8, 0, 8)) < 2.0,
+ "Scout should reach its destination on its own crowd.");
+ }
+ finally
+ {
+ RestoreDefaultAgentTable();
+ }
+ }
+
+ ///
+ /// Replacing one agent type's navmesh rebinds only that type's crowd: its agents rejoin
+ /// (keeping destinations) while the other type's crowd is the same object throughout.
+ ///
+ [Fact]
+ public void NavMeshReplacement_RebindsOnlyThatTypesCrowd()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f },
+ new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f },
+ ]);
+
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ NavMeshSurface? scoutSurface = null;
+ foreach (int typeId in (int[])[0, 3])
+ {
+ GameObject surfaceGo = CreateGameObject($"Surface{typeId}");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ surface.AgentTypeId = typeId;
+ Assert.True(surface.BuildNavMesh());
+ if (typeId == 3) scoutSurface = surface;
+ }
+
+ NavMeshAgent humanoid = AddAgent(scene, new Float3(-8, 0, -8));
+ NavMeshAgent scout = AddAgent(scene, new Float3(-8, 0, 8));
+ scout.AgentTypeId = 3;
+ Tick(scene, 2);
+
+ var humanoidCrowd = scene.Navigation.GetNativeCrowd(0);
+ var scoutCrowd = scene.Navigation.GetNativeCrowd(3);
+ scout.SetDestination(new Float3(8, 0, 8));
+ humanoid.SetDestination(new Float3(8, 0, -8));
+ Tick(scene, 30);
+
+ // Regenerate the scout mesh only.
+ Assert.True(scoutSurface!.BuildNavMesh());
+ Tick(scene, 2);
+
+ Assert.True(scout.IsOnNavMesh, "Scout must rejoin its replacement crowd.");
+ Assert.NotSame(scoutCrowd, scene.Navigation.GetNativeCrowd(3));
+ Assert.Same(humanoidCrowd, scene.Navigation.GetNativeCrowd(0));
+
+ // Destinations survived on both sides of the swap.
+ Tick(scene, 300);
+ Assert.True(Float3.Distance(scout.GameObject.Transform.Position, new Float3(8, 0, 8)) < 2.0,
+ "Scout should reach its destination on the new mesh.");
+ Assert.True(Float3.Distance(humanoid.GameObject.Transform.Position, new Float3(8, 0, -8)) < 2.0,
+ "Humanoid should be undisturbed by the other type's rebind.");
+ }
+ finally
+ {
+ RestoreDefaultAgentTable();
+ }
+ }
+
+ ///
+ /// Regression: writing AgentTypeId and then having ANY navmesh event fire before the
+ /// LateUpdate drift check runs must not strand a ghost agent in the old type's still-alive
+ /// crowd. The rebind check compares against the type the agent REGISTERED under, so the
+ /// event leaves it alone and the drift check later re-places it through Unregister.
+ ///
+ [Fact]
+ public void AgentTypeIdChange_RacingNavMeshEvent_LeavesNoGhostAgent()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f },
+ new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f },
+ ]);
+
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(20, 1, 20);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ NavMeshSurface? humanoidSurface = null;
+ foreach (int typeId in (int[])[0, 3])
+ {
+ GameObject surfaceGo = CreateGameObject($"Surface{typeId}");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ surface.AgentTypeId = typeId;
+ Assert.True(surface.BuildNavMesh());
+ if (typeId == 0) humanoidSurface = surface;
+ }
+
+ NavMeshAgent agent = AddAgent(scene, new Float3(0, 0, 0));
+ Tick(scene, 2);
+ Assert.True(agent.IsOnNavMesh);
+ var typeZeroCrowd = scene.Navigation.GetNativeCrowd(0)!;
+ Assert.Single(typeZeroCrowd.GetActiveAgents());
+
+ // The race: gameplay retypes the agent, then a navmesh event (here a partial tile
+ // rebuild — the event a destructible world produces constantly) fires BEFORE any
+ // LateUpdate has run the drift check. The type-0 crowd survives the event.
+ agent.AgentTypeId = 3;
+ Assert.True(humanoidSurface!.RebuildTiles(new AABB(new Float3(-2, -1, -2), new Float3(2, 1, 2))));
+ Assert.Same(typeZeroCrowd, scene.Navigation.GetNativeCrowd(0));
+
+ Tick(scene, 2); // drift check re-places the agent onto type 3
+ Assert.True(agent.IsOnNavMesh);
+ Assert.Same(agent.NativeAgent, System.Linq.Enumerable.FirstOrDefault(scene.Navigation.GetNativeCrowd(3)!.GetActiveAgents()));
+ Assert.Empty(typeZeroCrowd.GetActiveAgents()); // no ghost left behind
+ }
+ finally
+ {
+ RestoreDefaultAgentTable();
+ }
+ }
+
+ // ── Steering filters (mask + costs through the crowd) ───────────────
+
+ private static NavMeshGeometrySource Quad(float minX, float minZ, float maxX, float maxZ, int area = NavMeshGeometrySource.UnspecifiedArea)
+ {
+ Float3[] verts =
+ [
+ new(minX, 0, minZ),
+ new(minX, 0, maxZ),
+ new(maxX, 0, maxZ),
+ new(maxX, 0, minZ),
+ ];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return new NavMeshGeometrySource(verts, indices, Float4x4.Identity, area);
+ }
+
+ private const int MudArea = 3;
+
+ ///
+ /// A two-corridor map (the plan's "which side does the agent take" scenario): bottom
+ /// corridor z 0..3 and top corridor z 6..9, joined only by connectors at both ends
+ /// (x 0..4 and x 26..30) — the gap between them is void, so the corridor's raycast-based
+ /// visibility optimization cannot splice a shortcut across (it CAN re-straighten a route
+ /// through costly-but-passable ground in an open field, which is upstream Detour
+ /// behaviour). The bottom corridor carries a mud strip (area 3) at x 12..18. From
+ /// (2,1.5) to (28,1.5) the short route runs through the mud; the alternative goes up
+ /// and around through the top corridor.
+ ///
+ private (Scene scene, NavMeshSurface surface) CreateTwoCorridorScene()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+
+ NavMeshData? data = NavMeshBuilder.Build(surface.ResolveBuildSettings(),
+ [
+ Quad(0, 0, 12, 3), // bottom corridor, west of the mud
+ Quad(12, 0, 18, 3, MudArea), // the mud strip
+ Quad(18, 0, 30, 3), // bottom corridor, east of the mud
+ Quad(0, 6, 30, 9), // top corridor
+ Quad(0, 3, 4, 6), // west connector
+ Quad(26, 3, 30, 6), // east connector
+ ]);
+ Assert.NotNull(data);
+ surface.ApplyNavMeshData(data!);
+ return (scene, surface);
+ }
+
+ /// Walk the agent from the bottom corridor's west end to its east end, recording
+ /// the highest z reached — crossing into the top corridor (z > 6) means it took the
+ /// detour; staying under z 3 means it went straight through the mud.
+ private (double maxZ, double endDistance) WalkBottomCorridor(Scene scene, NavMeshAgent agent)
+ {
+ var goal = new Float3(28, 0, 1.5f);
+ Tick(scene, 2);
+ Assert.True(agent.IsOnNavMesh);
+ agent.SetDestination(goal);
+
+ double maxZ = double.MinValue;
+ for (int i = 0; i < 900; i++)
+ {
+ Tick(scene, 1);
+ maxZ = System.Math.Max(maxZ, agent.GameObject.Transform.Position.Z);
+ if (!agent.PathPending && agent.RemainingDistance <= 0f) break;
+ }
+ return (maxZ, Float3.Distance(agent.GameObject.Transform.Position, goal));
+ }
+
+ ///
+ /// The area mask steers CROWD movement, not just explicit queries: an agent whose mask
+ /// excludes the mud area takes the top corridor, while an unrestricted agent walks
+ /// straight through the mud.
+ ///
+ [Fact]
+ public void AreaMask_RoutesCrowdSteeringAroundExcludedArea()
+ {
+ (Scene scene, _) = CreateTwoCorridorScene();
+
+ NavMeshAgent direct = AddAgent(scene, new Float3(2, 0, 1.5f));
+ (double directMaxZ, double directEnd) = WalkBottomCorridor(scene, direct);
+ Assert.True(directEnd < 2.0, $"Unrestricted agent should arrive (ended {directEnd:0.0} away).");
+ Assert.True(directMaxZ < 4.0,
+ $"Unrestricted agent should stay in the bottom corridor (reached z {directMaxZ:0.00}).");
+ direct.GameObject.Enabled = false;
+
+ NavMeshAgent masked = AddAgent(scene, new Float3(2, 0, 1.5f));
+ masked.AreaMask = NavMeshAreas.AllAreas & ~(1 << MudArea);
+ (double maskedMaxZ, double maskedEnd) = WalkBottomCorridor(scene, masked);
+ Assert.True(maskedEnd < 2.0, $"Masked agent should still arrive via the detour (ended {maskedEnd:0.0} away).");
+ Assert.True(maskedMaxZ > 6.0,
+ $"Masked agent should take the top corridor around the mud (reached only z {maskedMaxZ:0.00}).");
+ }
+
+ ///
+ /// SetAreaCost biases the crowd's corridor choice: with the mud expensive enough the
+ /// agent takes the top corridor, exactly like the mask case but by cost rather than
+ /// exclusion.
+ ///
+ [Fact]
+ public void SetAreaCost_BiasesCrowdCorridorChoice()
+ {
+ (Scene scene, _) = CreateTwoCorridorScene();
+
+ NavMeshAgent agent = AddAgent(scene, new Float3(2, 0, 1.5f));
+ agent.SetAreaCost(MudArea, 20f);
+ Assert.Equal(20f, agent.GetAreaCost(MudArea));
+
+ (double maxZ, double endDistance) = WalkBottomCorridor(scene, agent);
+ Assert.True(endDistance < 2.0, $"Agent should arrive (ended {endDistance:0.0} away).");
+ Assert.True(maxZ > 6.0,
+ $"With mud at cost 20 the agent should take the top corridor (reached only z {maxZ:0.00}).");
+ }
+
+ // ── Filter slot allocation ──────────────────────────────────────────
+
+ private (Scene scene, NavMeshSurface surface) CreateBakedFloorScene(float size = 20f)
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(size, 1, size);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ Assert.True(surface.BuildNavMesh());
+ return (scene, surface);
+ }
+
+ /// Agents with identical steering configs share one crowd filter slot; a
+ /// different config gets its own; the default config stays on slot 0.
+ [Fact]
+ public void FilterSlots_SharedByIdenticalConfigs()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ NavMeshAgent defaultAgent = AddAgent(scene, new Float3(0, 0, 0));
+ NavMeshAgent maskedA = AddAgent(scene, new Float3(2, 0, 0));
+ NavMeshAgent maskedB = AddAgent(scene, new Float3(4, 0, 0));
+ NavMeshAgent costly = AddAgent(scene, new Float3(6, 0, 0));
+ maskedA.AreaMask = ~(1 << 2);
+ maskedB.AreaMask = ~(1 << 2);
+ costly.SetAreaCost(2, 5f);
+
+ Tick(scene, 2);
+ Assert.True(defaultAgent.IsOnNavMesh && maskedA.IsOnNavMesh && maskedB.IsOnNavMesh && costly.IsOnNavMesh);
+
+ Assert.Equal(0, defaultAgent.NativeAgent!.option.queryFilterType);
+ Assert.NotEqual(0, maskedA.NativeAgent!.option.queryFilterType);
+ Assert.Equal(maskedA.NativeAgent.option.queryFilterType, maskedB.NativeAgent!.option.queryFilterType);
+ Assert.NotEqual(0, costly.NativeAgent!.option.queryFilterType);
+ Assert.NotEqual(maskedA.NativeAgent.option.queryFilterType, costly.NativeAgent.option.queryFilterType);
+ }
+
+ ///
+ /// More distinct steering configs than slots: the overflow agents warn and steer with the
+ /// default filter (slot 0) but remain fully functional, and unregistering a slotted agent
+ /// frees its slot for the next distinct config.
+ ///
+ [Fact]
+ public void FilterSlots_ExhaustionFallsBackToDefault_AndReleaseFrees()
+ {
+ (Scene scene, _) = CreateBakedFloorScene();
+
+ // 17 distinct configs against 15 assignable slots (1..15; 0 is the shared default).
+ var agents = new NavMeshAgent[17];
+ for (int i = 0; i < agents.Length; i++)
+ {
+ agents[i] = AddAgent(scene, new Float3(-8 + i, 0, -8));
+ agents[i].SetAreaCost(1, 2f + i); // each cost table is unique
+ }
+ Tick(scene, 2);
+
+ int onDefaultSlot = 0;
+ foreach (NavMeshAgent agent in agents)
+ {
+ Assert.True(agent.IsOnNavMesh);
+ if (agent.NativeAgent!.option.queryFilterType == 0) onDefaultSlot++;
+ }
+ Assert.Equal(2, onDefaultSlot); // 15 got slots, 2 overflowed onto the default
+
+ // Overflowed or not, every agent still navigates.
+ agents[16].SetDestination(new Float3(8, 0, 8));
+ Tick(scene, 300);
+ Assert.True(Float3.Distance(agents[16].GameObject.Transform.Position, new Float3(8, 0, 8)) < 2.0,
+ "An overflow agent should still reach its destination on the default filter.");
+
+ // Free one slot and bring in an 18th distinct config: it should take the freed slot.
+ NavMeshAgent slotted = System.Array.Find(agents, a => a.NativeAgent!.option.queryFilterType != 0)!;
+ slotted.GameObject.Enabled = false;
+ NavMeshAgent late = AddAgent(scene, new Float3(0, 0, 6));
+ late.SetAreaCost(1, 99f);
+ Tick(scene, 2);
+ Assert.NotEqual(0, late.NativeAgent!.option.queryFilterType);
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshLinkTests.cs b/Prowl.Runtime.Test/NavMeshLinkTests.cs
new file mode 100644
index 000000000..93299eacb
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshLinkTests.cs
@@ -0,0 +1,628 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Recast.Detour;
+
+using Prowl.Runtime;
+using Prowl.Runtime.Resources;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+///
+/// NavMeshLink: off-mesh connections baked from components — gap bridging, directionality,
+/// area masking, parallel-connection width, crowd traversal, and runtime toggling via
+/// targeted rebuilds.
+///
+public class NavMeshLinkTests : RuntimeTestBase
+{
+ /// Two 8-wide floor islands separated by a 4-unit void gap (A: x -10..-2,
+ /// B: x 2..10), with a surface ready to bake. Nothing walkable connects them.
+ private (Scene scene, NavMeshSurface surface) CreateGapScene()
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject islandA = CreateGameObject("IslandA");
+ scene.Add(islandA);
+ islandA.AddComponent().Size = new Float3(8, 1, 8);
+ islandA.Transform.Position = new Float3(-6, -0.5f, 0);
+
+ GameObject islandB = CreateGameObject("IslandB");
+ scene.Add(islandB);
+ islandB.AddComponent().Size = new Float3(8, 1, 8);
+ islandB.Transform.Position = new Float3(6, -0.5f, 0);
+
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ return (scene, surface);
+ }
+
+ private NavMeshLink AddLink(Scene scene, float width = 0f)
+ {
+ GameObject linkGo = CreateGameObject("Link");
+ scene.Add(linkGo);
+ var link = linkGo.AddComponent();
+ link.StartPoint = new Float3(-3, 0, 0); // on island A
+ link.EndPoint = new Float3(3, 0, 0); // on island B
+ link.Width = width;
+ return link;
+ }
+
+ ///
+ /// Link ids come from the component's persistent identifier, so each link has its own and it
+ /// does not change as the component is enabled and disabled. Nothing mints one at runtime,
+ /// which would dirty the scene just by entering play mode.
+ ///
+ [Fact]
+ public void Link_Id_IsDistinctPerComponentAndStable()
+ {
+ Scene scene = CreateScene(enable: true);
+ NavMeshLink a = AddLink(scene);
+ NavMeshLink b = AddLink(scene);
+
+ Assert.NotEqual(0, a.LinkId);
+ Assert.NotEqual(a.LinkId, b.LinkId);
+
+ int before = a.LinkId;
+ a.Enabled = false;
+ a.Enabled = true;
+ Assert.Equal(before, a.LinkId);
+ Assert.Same(a, scene.Navigation.FindLink(before));
+ }
+
+ ///
+ /// Baking is something you do in the editor, and a bake gathers its links from the world's
+ /// registry. A link that stayed inert outside play mode would never register, and would go
+ /// silently missing from every navmesh baked from the button — with nothing to see until an
+ /// agent refused to cross.
+ ///
+ [Fact]
+ public void Link_IsBakedIntoASurfaceBuiltInTheEditor()
+ {
+ using (EditMode())
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ AddLink(scene);
+
+ Assert.True(surface.BuildNavMesh());
+ Assert.NotEmpty(surface.NavMeshData.Res!.Links);
+ Assert.Equal(NavMeshPathStatus.PathComplete,
+ PathStatus(scene, new Float3(-6, 0, 0), new Float3(6, 0, 0)));
+ }
+ }
+
+ ///
+ /// What the scene view draws for a link comes from the mesh, not the component: the
+ /// endpoints Detour snapped onto walkable polygons, which are held on the connection's
+ /// polygon rather than on the connection (that keeps the positions originally asked for).
+ /// A link that attached to nothing is left out entirely, which is what makes a broken one
+ /// distinguishable from a working one at a glance.
+ ///
+ [Fact]
+ public void Link_ReportsTheConnectionTheMeshActuallyHolds()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+ link.Bidirectional = false;
+ Assert.True(surface.BuildNavMesh());
+
+ NavMeshConnection connection = Assert.Single(scene.Navigation.CalculateTriangulation().Connections);
+ Assert.Equal(link.LinkId, connection.LinkId);
+ Assert.Equal(NavMeshAreas.Jump, connection.Area);
+ Assert.False(connection.Bidirectional);
+
+ // Snapped onto the islands, so within a tolerance of the authored ends rather than at
+ // them — and on the walkable surface, not the y=0 plane the component was authored on.
+ Assert.True(Float3.Distance(connection.Start, link.WorldStart) < 1.5f,
+ $"Start {connection.Start} should be near the authored {link.WorldStart}.");
+ Assert.True(Float3.Distance(connection.End, link.WorldEnd) < 1.5f,
+ $"End {connection.End} should be near the authored {link.WorldEnd}.");
+
+ // Move an end over the gap: inside the tile grid, so the connection is still stored, but
+ // with no walkable polygon under it Detour never attaches that end. Either end failing
+ // makes the link untraversable, and both must therefore be reported as nothing — the far
+ // end is attached separately from the start, so it is its own way to fail.
+ link.EndPoint = new Float3(0, 0, 0);
+ Tick(scene, 2);
+ Assert.Empty(scene.Navigation.CalculateTriangulation().Connections);
+ Assert.Equal(NavMeshPathStatus.PathPartial,
+ PathStatus(scene, new Float3(-6, 0, 0), new Float3(6, 0, 0)));
+
+ link.StartPoint = new Float3(0, 0, 0);
+ link.EndPoint = new Float3(3, 0, 0);
+ Tick(scene, 2);
+ Assert.Empty(scene.Navigation.CalculateTriangulation().Connections);
+ }
+
+ ///
+ /// A frame's worth of link edits is applied as one pass per surface. Demolishing a building
+ /// disables all its ladders in the same frame; applied one at a time, each edit re-collects
+ /// the scene's links, replaces the whole link set again, and re-contours tiles the edit
+ /// before it just did — so the cost is per link rather than per affected tile.
+ ///
+ [Fact]
+ public void Links_ChangedInOneFrame_RebuildInOnePass()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ var links = new List();
+ for (int i = 0; i < 6; i++)
+ links.Add(AddLink(scene));
+
+ Assert.True(surface.BuildNavMesh());
+ Assert.True(TickUntil(scene, () => !scene.Navigation.GetInstance()!.CachePending) >= 0);
+
+ int changes = 0;
+ scene.Navigation.NavMeshChanged += () => changes++;
+
+ foreach (NavMeshLink link in links)
+ link.Activated = false;
+ Tick(scene, 2); // links mark from LateUpdate; the world drains them at the next update
+
+ // One from the coalesced rebuild, one from the pump draining the tile work it queued.
+ // Uncoalesced this is two per link — each endpoint region is its own rebuild.
+ Assert.InRange(changes, 1, 2);
+ Assert.Equal(NavMeshPathStatus.PathPartial,
+ PathStatus(scene, new Float3(-6, 0, 0), new Float3(6, 0, 0)));
+ }
+
+ ///
+ /// Draining the batch raises NavMeshChanged, and a handler is free to disable a link from it —
+ /// which marks tiles while the drain is still running. Those marks have to survive: the batch
+ /// is swapped out before draining, so they land in the next frame's instead of being dropped
+ /// when this one finishes. (A handler that dirties a surface not already in the batch would
+ /// also have mutated the collection mid-enumeration; the swap covers both, this covers the
+ /// half that is deterministic to reproduce.)
+ ///
+ [Fact]
+ public void Links_MarkedFromAChangeHandler_SurviveTheDrain()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink first = AddLink(scene);
+ NavMeshLink second = AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+ Assert.True(TickUntil(scene, () => !scene.Navigation.GetInstance()!.CachePending) >= 0);
+ Assert.NotEmpty(scene.Navigation.CalculateTriangulation().Connections);
+
+ bool disabledFromHandler = false;
+ void DisableTheOtherLink()
+ {
+ if (disabledFromHandler) return;
+ disabledFromHandler = true;
+ second.Enabled = false; // OnDisable marks its endpoint tiles, mid-drain
+ }
+
+ scene.Navigation.NavMeshChanged += DisableTheOtherLink;
+ try
+ {
+ first.Activated = false;
+ Tick(scene, 4);
+ }
+ finally
+ {
+ scene.Navigation.NavMeshChanged -= DisableTheOtherLink;
+ }
+
+ Assert.True(disabledFromHandler, "The drain should have raised NavMeshChanged.");
+ Assert.Empty(scene.Navigation.CalculateTriangulation().Connections);
+ }
+
+ /// A link answers for its own scene only: two additively loaded scenes derive ids
+ /// from their own components and have no reason to agree on them, so one scene resolving
+ /// another's link would hand an agent a component from the wrong world.
+ [Fact]
+ public void Link_ResolvesOnlyWithinItsOwnScene()
+ {
+ Scene home = CreateScene(enable: true);
+ Scene other = CreateScene(enable: true);
+ NavMeshLink link = AddLink(home);
+
+ Assert.Same(link, home.Navigation.FindLink(link.LinkId));
+ Assert.Null(other.Navigation.FindLink(link.LinkId));
+ }
+
+ private static NavMeshPathStatus PathStatus(Scene scene, Float3 from, Float3 to, int areaMask = NavMesh.AllAreas)
+ {
+ var path = new NavMeshPath();
+ return scene.Navigation.CalculatePath(from, to, areaMask, path) ? path.Status : NavMeshPathStatus.PathInvalid;
+ }
+
+ /// A link across the gap makes the far island reachable; without one (or with
+ /// Activated off at bake) the path stays partial.
+ [Fact]
+ public void Link_BridgesGap_AndActivatedOffDoesNot()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ NavMeshLink link = AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ link.Activated = false;
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ ///
+ /// The headline for links: the connection survives the cache re-contouring a tile. Tiles are
+ /// rebuilt from geometry-only layers, so connections cannot simply be baked in once — they
+ /// ride on the asset and are re-injected on every tile build. An obstacle carving inside the
+ /// link's own tile is the exact moment a baked-in connection would be regenerated away.
+ ///
+ [Fact]
+ public void Link_SurvivesObstacleCarve()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ // Carve a hole on island A, well clear of the link's landing point but in its tiles.
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(-7, 1, 2.5f);
+ var obstacle = crate.AddComponent();
+ obstacle.Size = new Float3(2, 3, 2);
+
+ bool carved = false;
+ for (int i = 0; i < 240 && !carved; i++)
+ {
+ Tick(scene, 1);
+ carved = !scene.Navigation.SamplePosition(new Float3(-7, 0.2f, 2.5f), out _, 0.4f, NavMesh.AllAreas);
+ }
+ Assert.True(carved, "The obstacle should carve.");
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ /// A link added after a bake inserts itself through the catch-up path, re-contouring
+ /// the affected tiles without re-voxelizing anything.
+ [Fact]
+ public void Link_AddedAtRuntime()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ AddLink(scene);
+ Tick(scene, 3); // catch-up runs in LateUpdate
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ /// Disabling a link at runtime removes it — the removal path runs through OnDisable,
+ /// which depends on the component already reading as disabled by the time collection
+ /// re-runs.
+ [Fact]
+ public void Link_DisabledAtRuntime()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ link.Enabled = false;
+ Tick(scene, 3);
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ link.Enabled = true;
+ Tick(scene, 3);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ ///
+ /// Agent-type scoping is part of the link's definition, so writing it after AddComponent has
+ /// to re-resolve like the endpoints do. Narrowing the scope must also REMOVE the link from
+ /// the surfaces it no longer applies to, which needs the rebuild to visit the outgoing scope
+ /// as well as the incoming one.
+ ///
+ [Fact]
+ public void Link_AgentTypeScopeEdit_RemovesItFromUnaffectedSurfaces()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid" },
+ new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f },
+ ]);
+
+ (Scene scene, NavMeshSurface surface) = CreateGapScene(); // agent type 0
+ NavMeshLink link = AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ // Spawn-then-configure, but for scoping: hand the link to another agent type only.
+ link.AffectAllAgentTypes = false;
+ link.AffectedAgentTypeIds = [3];
+ Tick(scene, 3);
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ // And back: widening re-attaches it.
+ link.AffectAllAgentTypes = true;
+ Tick(scene, 3);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+ finally
+ {
+ NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]);
+ }
+ }
+
+ /// Links round-trip on the asset: a reloaded navmesh re-injects them when it
+ /// instantiates, with no live component present.
+ [Fact]
+ public void Link_RoundTripsOnAsset()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+
+ Runtime.NavMeshData baked = surface.NavMeshData.Res!;
+ Assert.NotEmpty(baked.Links);
+
+ Prowl.Echo.EchoObject echo = Prowl.Echo.Serializer.Serialize(typeof(object), baked);
+ var loaded = Prowl.Echo.Serializer.Deserialize(echo);
+ Assert.NotNull(loaded);
+ Assert.Equal(baked.Links.Count, loaded!.Links.Count);
+
+ var world = new NavMeshWorld();
+ Assert.NotNull(world.AddNavMeshData(loaded));
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ /// A one-directional link works one way only.
+ [Fact]
+ public void Link_OneDirectional_WorksOneWayOnly()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+ link.Bidirectional = false;
+ Assert.True(surface.BuildNavMesh());
+
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(8, 0, 0), new Float3(-8, 0, 0)));
+ }
+
+ /// The link's area participates in masking: excluding it severs the route.
+ [Fact]
+ public void Link_AreaMask_ExcludingLinkAreaSeversRoute()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ AddLink(scene); // Area = Jump by default
+ Assert.True(surface.BuildNavMesh());
+
+ Assert.Equal(NavMeshPathStatus.PathComplete,
+ PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ Assert.Equal(NavMeshPathStatus.PathPartial,
+ PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas & ~(1 << NavMeshAreas.Jump)));
+ }
+
+ private static int CountOffMeshPolys(NavMeshSurface surface)
+ {
+ DtNavMesh mesh = surface.Instance!.NativeNavMesh;
+ int count = 0;
+ for (int t = 0; t < mesh.GetMaxTiles(); t++)
+ {
+ DtMeshTile tile = mesh.GetTile(t);
+ if (tile?.data?.polys == null) continue;
+ for (int p = 0; p < tile.data.header.polyCount; p++)
+ if (tile.data.polys[p].GetPolyType() == DtPolyTypes.DT_POLYTYPE_OFFMESH_CONNECTION)
+ count++;
+ }
+ return count;
+ }
+
+ /// Width expands into parallel connections (⌈width / 2·agentRadius⌉) so an agent
+ /// enters at the nearest point along the span rather than queueing through its middle;
+ /// width 0 is a single connection.
+ [Fact]
+ public void Link_Width_EmitsParallelConnections()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene, width: 3f); // agent radius 0.5 → 3 connections
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(3, CountOffMeshPolys(surface));
+
+ link.Width = 0f;
+ Assert.True(surface.BuildNavMesh());
+ Assert.Equal(1, CountOffMeshPolys(surface));
+ }
+
+ /// An agent physically crosses via the crowd, reports the off-mesh state mid-hop,
+ /// and the traversal data resolves back to the component.
+ [Fact]
+ public void Agent_CrossesLink_AndReportsOffMeshState()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, 0);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 6f;
+ agent.Acceleration = 100f;
+ agent.Separation = false;
+
+ Tick(scene, 2);
+ Assert.True(agent.IsOnNavMesh);
+ agent.SetDestination(new Float3(8, 0, 0));
+
+ bool sawOffMesh = false;
+ NavMeshLink? resolved = null;
+ for (int i = 0; i < 900; i++)
+ {
+ Tick(scene, 1);
+ if (agent.IsOnOffMeshLink)
+ {
+ sawOffMesh = true;
+ OffMeshLinkData data = agent.CurrentOffMeshLinkData;
+ if (data.Valid && data.Link != null) resolved = data.Link;
+ }
+ if (!agent.PathPending && agent.RemainingDistance <= 0f) break;
+ }
+
+ Assert.True(sawOffMesh, "Agent should traverse the gap through the off-mesh link.");
+ Assert.Same(link, resolved);
+ double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 0));
+ Assert.True(endDistance < 2.0, $"Agent should reach the far island (ended {endDistance:0.0} away).");
+ }
+
+ /// Toggling Activated at runtime rebuilds the endpoint tiles automatically
+ /// (AutoRebuild): the route severs and comes back without a full rebake.
+ [Fact]
+ public void Link_RuntimeToggle_RebuildsAffectedTiles()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ link.Activated = false;
+ Tick(scene, 2); // LateUpdate change detection → targeted rebuild
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ link.Activated = true;
+ Tick(scene, 2);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ ///
+ /// The Unity arrival idiom "!PathPending && RemainingDistance <= StoppingDistance"
+ /// must not false-fire mid-link, or a waypoint script issues its next destination during the
+ /// hop and ping-pongs the agent across the link forever. Mid-hop the value stays bounded below
+ /// by the path remaining AFTER landing.
+ ///
+ [Fact]
+ public void Agent_MidHop_NeverReportsArrival()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ AddLink(scene);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject agentGo = CreateGameObject("Agent");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, 0);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 6f;
+ agent.Acceleration = 100f;
+ agent.Separation = false;
+
+ Tick(scene, 2);
+ var goal = new Float3(8, 0, 0);
+ agent.SetDestination(goal);
+
+ // Drive with the arrival idiom, exactly like gameplay code would.
+ bool sawHop = false;
+ float minMidHopRemaining = float.MaxValue;
+ int arrivedAtTick = -1;
+ for (int i = 0; i < 900; i++)
+ {
+ Tick(scene, 1);
+ if (agent.IsOnOffMeshLink)
+ {
+ sawHop = true;
+ minMidHopRemaining = MathF.Min(minMidHopRemaining, agent.RemainingDistance);
+ }
+ if (!agent.PathPending && agent.RemainingDistance <= agent.StoppingDistance)
+ {
+ arrivedAtTick = i;
+ break;
+ }
+ }
+
+ Assert.True(sawHop, "Agent should traverse the link.");
+ // The link lands ~5 units from the goal; mid-hop the reading must never drop below
+ // most of that post-landing path (never anywhere near a stopping distance of 0).
+ Assert.True(minMidHopRemaining > 3f,
+ $"RemainingDistance collapsed to {minMidHopRemaining:0.00} mid-hop — the arrival idiom would false-fire.");
+ Assert.True(arrivedAtTick >= 0, "Arrival idiom should eventually fire at the real destination.");
+ Assert.True(Float3.Distance(agentGo.Transform.Position, goal) < 1.0,
+ $"Arrival idiom fired {Float3.Distance(agentGo.Transform.Position, goal):0.00} away from the destination.");
+ }
+
+ ///
+ /// Adverse enable order: a link enabled while no navmesh exists must insert itself when a
+ /// surface later registers a STALE bake (one that predates the link) — the NavMeshChanged
+ /// subscription closes the ordering hole.
+ ///
+ [Fact]
+ public void Link_EnabledBeforeSurfaceRegisters_CatchesUpOnStaleBake()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+
+ // Bake WITHOUT the link, then take the navmesh offline.
+ link.GameObject.Enabled = false;
+ Assert.True(surface.BuildNavMesh());
+ surface.GameObject.Enabled = false;
+
+ // Link enables first (no navmesh anywhere), surface second with the stale bake.
+ link.GameObject.Enabled = true;
+ surface.GameObject.Enabled = true;
+ Tick(scene, 2);
+
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ ///
+ /// The baked-in safeguard observed directly: re-registering a navmesh that already
+ /// contains the link must trigger NO catch-up rebuild — only the unregister/register
+ /// events themselves fire. This is the safeguard whose failure mode is "everything still
+ /// works, just slower at load", so it needs a direct observer.
+ ///
+ [Fact]
+ public void Link_BakedIn_ReregistrationTriggersNoRebuild()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ AddLink(scene);
+ Assert.True(surface.BuildNavMesh()); // link baked in
+ Tick(scene, 2);
+
+ int navMeshEvents = 0;
+ scene.Navigation.NavMeshChanged += () => navMeshEvents++;
+
+ // Unregister + re-register: a fresh NavMeshInstance from the same baked data.
+ surface.GameObject.Enabled = false;
+ surface.GameObject.Enabled = true;
+ Tick(scene, 2);
+
+ // Exactly the remove + add events; a catch-up rebuild would fire additional
+ // mutation events on top.
+ Assert.Equal(2, navMeshEvents);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+
+ /// Moving a link with AutoUpdatePosition rebuilds both the old and new endpoint
+ /// regions: the connection follows the Transform.
+ [Fact]
+ public void Link_AutoUpdatePosition_FollowsTransform()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateGapScene();
+ NavMeshLink link = AddLink(scene);
+ link.AutoUpdatePosition = true;
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ // Slide the link into the void: both endpoints now hang over the gap, so the
+ // connection can't attach and the route severs.
+ link.GameObject.Transform.Position = new Float3(0, 0, 30);
+ Tick(scene, 2);
+ Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+
+ // Slide it back: the route returns.
+ link.GameObject.Transform.Position = Float3.Zero;
+ Tick(scene, 2);
+ Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0)));
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshModifierTests.cs b/Prowl.Runtime.Test/NavMeshModifierTests.cs
new file mode 100644
index 000000000..da74ef703
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshModifierTests.cs
@@ -0,0 +1,371 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Runtime;
+using Prowl.Runtime.Resources;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+///
+/// NavMeshModifier (per-object area/exclusion at collection time), NavMeshModifierVolume
+/// (area stamped over a region post-rasterization), and the Not Walkable null-area semantics
+/// both rely on.
+///
+public class NavMeshModifierTests : RuntimeTestBase
+{
+ private const int Mud = 3; // arbitrary user area
+
+ private GameObject AddFloorBox(Scene scene, string name, Float3 center, Float3 size)
+ {
+ GameObject go = CreateGameObject(name);
+ scene.Add(go);
+ go.AddComponent().Size = size;
+ go.Transform.Position = center;
+ return go;
+ }
+
+ private NavMeshSurface AddSurface(Scene scene, int agentTypeId = 0)
+ {
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ surface.AgentTypeId = agentTypeId;
+ return surface;
+ }
+
+ private static int SampleAreaMask(Scene scene, Float3 position, int agentTypeId = 0)
+ {
+ var filter = new NavMeshQueryFilter { AgentTypeId = agentTypeId };
+ return scene.Navigation.SamplePosition(position, out NavMeshHit hit, 0.5f, filter) ? hit.Mask : 0;
+ }
+
+ // ── NavMeshModifier ─────────────────────────────────────────────────
+
+ /// OverrideArea stamps the modifier's area on that object's polys only.
+ [Fact]
+ public void Modifier_OverrideArea_LandsOnThatObjectsPolys()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Plain", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10));
+ GameObject marked = AddFloorBox(scene, "Marked", new Float3(5, -0.5f, 0), new Float3(10, 1, 10));
+ var modifier = marked.AddComponent();
+ modifier.OverrideArea = true;
+ modifier.Area = Mud;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(-5, 0.2f, 0)));
+ Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(5, 0.2f, 0)));
+ }
+
+ /// IgnoreFromBuild removes the object's geometry from the bake.
+ [Fact]
+ public void Modifier_IgnoreFromBuild_ExcludesGeometry()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Plain", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10));
+ GameObject ignored = AddFloorBox(scene, "Ignored", new Float3(5, -0.5f, 0), new Float3(10, 1, 10));
+ ignored.AddComponent().IgnoreFromBuild = true;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(-5, 0.2f, 0)));
+ Assert.Equal(0, SampleAreaMask(scene, new Float3(5, 0.2f, 0)));
+ }
+
+ /// A parent's modifier applies to children, but a child's own modifier wins.
+ [Fact]
+ public void Modifier_ChildModifier_BeatsInheritedParentModifier()
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject parent = CreateGameObject("Parent");
+ scene.Add(parent);
+ var parentModifier = parent.AddComponent();
+ parentModifier.OverrideArea = true;
+ parentModifier.Area = Mud;
+ parentModifier.ApplyToChildren = true;
+
+ GameObject inheriting = AddFloorBox(scene, "Inheriting", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10));
+ inheriting.SetParent(parent);
+ GameObject overriding = AddFloorBox(scene, "Overriding", new Float3(5, -0.5f, 0), new Float3(10, 1, 10));
+ overriding.SetParent(parent);
+ var childModifier = overriding.AddComponent();
+ childModifier.OverrideArea = true;
+ childModifier.Area = NavMeshAreas.Jump;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(-5, 0.2f, 0)));
+ Assert.Equal(1 << NavMeshAreas.Jump, SampleAreaMask(scene, new Float3(5, 0.2f, 0)));
+ }
+
+ /// A modifier scoped to another agent type is transparent to this bake.
+ [Fact]
+ public void Modifier_AgentTypeScoped_OnlyAffectsThatTypesBakes()
+ {
+ try
+ {
+ NavMeshAgentTypes.ApplyTable(
+ [
+ new NavMeshAgentType { Id = 0, Name = "Humanoid" },
+ new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f },
+ ]);
+
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20));
+ var modifier = floor.AddComponent();
+ modifier.IgnoreFromBuild = true;
+ modifier.AffectAllAgentTypes = false;
+ modifier.AffectedAgentTypeIds = [3];
+
+ // Type 0: modifier is transparent, the floor bakes.
+ Assert.True(AddSurface(scene, agentTypeId: 0).BuildNavMesh());
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+
+ // Type 3: the floor is excluded and the bake has nothing.
+ Assert.False(AddSurface(scene, agentTypeId: 3).BuildNavMesh());
+ }
+ finally
+ {
+ NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]);
+ }
+ }
+
+ /// Non-affecting modifiers are transparent, not shielding: a parent whose
+ /// modifier is scoped to another agent type is walked PAST, so the child inherits the
+ /// grandparent's override.
+ [Fact]
+ public void Modifier_WalksPastNonAffectingAncestor_ToHigherOne()
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject grandparent = CreateGameObject("Grandparent");
+ scene.Add(grandparent);
+ var grandModifier = grandparent.AddComponent();
+ grandModifier.OverrideArea = true;
+ grandModifier.Area = Mud;
+ grandModifier.ApplyToChildren = true;
+
+ GameObject parent = CreateGameObject("Parent");
+ scene.Add(parent);
+ parent.SetParent(grandparent);
+ var parentModifier = parent.AddComponent();
+ parentModifier.OverrideArea = true;
+ parentModifier.Area = NavMeshAreas.Jump;
+ parentModifier.ApplyToChildren = true;
+ parentModifier.AffectAllAgentTypes = false;
+ parentModifier.AffectedAgentTypeIds = [7]; // not this bake's type
+
+ GameObject child = AddFloorBox(scene, "Child", new Float3(0, -0.5f, 0), new Float3(10, 1, 10));
+ child.SetParent(parent);
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+ Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+ }
+
+ /// An object's own modifier shields inherited overrides even when it overrides
+ /// nothing itself: a valid child modifier with OverrideArea off means "default area",
+ /// not "fall through to the parent's override".
+ [Fact]
+ public void Modifier_OwnNoOpModifier_ShieldsInheritedOverride()
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject parent = CreateGameObject("Parent");
+ scene.Add(parent);
+ var parentModifier = parent.AddComponent();
+ parentModifier.OverrideArea = true;
+ parentModifier.Area = Mud;
+ parentModifier.ApplyToChildren = true;
+
+ GameObject inheriting = AddFloorBox(scene, "Inheriting", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10));
+ inheriting.SetParent(parent);
+ GameObject shielded = AddFloorBox(scene, "Shielded", new Float3(5, -0.5f, 0), new Float3(10, 1, 10));
+ shielded.SetParent(parent);
+ shielded.AddComponent(); // valid, but OverrideArea off — a no-op that still wins
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(-5, 0.2f, 0)));
+ Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(5, 0.2f, 0)));
+ }
+
+ // ── NavMeshModifierVolume ───────────────────────────────────────────
+
+ /// The volume stamps its area only inside its footprint.
+ [Fact]
+ public void ModifierVolume_StampsAreaOnlyInsideFootprint()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20));
+
+ GameObject volumeGo = CreateGameObject("MudZone");
+ scene.Add(volumeGo);
+ volumeGo.Transform.Position = new Float3(5, 0, 5);
+ var volume = volumeGo.AddComponent();
+ volume.Size = new Float3(6, 3, 6); // covers x/z 2..8
+ volume.Area = Mud;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(5, 0.2f, 5)));
+ Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(-5, 0.2f, -5)));
+ }
+
+ /// A Not Walkable volume erases walkability inside the footprint — the hole is
+ /// real for pathing, not just recoloured.
+ [Fact]
+ public void ModifierVolume_NotWalkable_PunchesHole()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20));
+
+ GameObject volumeGo = CreateGameObject("Hole");
+ scene.Add(volumeGo);
+ volumeGo.Transform.Position = new Float3(0, 0, 0);
+ var volume = volumeGo.AddComponent();
+ volume.Size = new Float3(4, 3, 4);
+ volume.Area = NavMeshAreas.NotWalkable;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.Equal(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(7, 0.2f, 7)));
+
+ // A path across the hole routes around it: some corner deviates from the straight line.
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ double maxDeviation = 0;
+ foreach (Float3 corner in path.Corners)
+ maxDeviation = System.Math.Max(maxDeviation, System.Math.Abs(corner.Z));
+ Assert.True(maxDeviation > 1.5, $"Path should route around the hole (max |z| = {maxDeviation:0.00}).");
+ }
+
+ /// A rotated volume marks its rotated footprint, not its axis-aligned box: a
+ /// 45°-yawed square's corner regions stay unmarked while its center is marked.
+ [Fact]
+ public void ModifierVolume_RotatedFootprint_IsHonored()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(24, 1, 24));
+
+ GameObject volumeGo = CreateGameObject("Diamond");
+ scene.Add(volumeGo);
+ volumeGo.Transform.Position = new Float3(0, 0, 0);
+ volumeGo.Transform.Rotation = Quaternion.FromEuler(new Float3(0, 45, 0));
+ var volume = volumeGo.AddComponent();
+ volume.Size = new Float3(8, 3, 8); // rotated 45°: a diamond with tips at ±5.66 on the axes
+ volume.Area = Mud;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ // Center: inside the diamond.
+ Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+ // The axis-aligned corner (4.5, 4.5) is OUTSIDE the diamond (|x|+|z| = 9 > 5.66) but
+ // inside the unrotated box's AABB — marked only if rotation were ignored.
+ Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(4.5f, 0.2f, 4.5f)));
+ }
+
+ // ── Partial rebuilds ────────────────────────────────────────────────
+
+ /// The collector-based RebuildTiles picks up modifier volumes automatically:
+ /// spawning and removing a Not Walkable volume at runtime opens and closes the hole.
+ [Fact]
+ public void RebuildTiles_AppliesAndRemovesVolume()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20));
+ NavMeshSurface surface = AddSurface(scene);
+ Assert.True(surface.BuildNavMesh());
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+
+ // Spawn a hole mid-game.
+ GameObject volumeGo = CreateGameObject("Hole");
+ scene.Add(volumeGo);
+ volumeGo.Transform.Position = new Float3(0, 0, 0);
+ var volume = volumeGo.AddComponent();
+ volume.Size = new Float3(4, 3, 4);
+ volume.Area = NavMeshAreas.NotWalkable;
+
+ var region = new AABB(new Float3(-3, -1, -3), new Float3(3, 2, 3));
+ Assert.True(surface.RebuildTiles(region));
+ Assert.Equal(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(7, 0.2f, 7)));
+
+ // Remove it and rebuild the same region: walkability returns.
+ volumeGo.Enabled = false;
+ Assert.True(surface.RebuildTiles(region));
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+ }
+
+ /// Modifiers resolve during rebuild collection too: toggling IgnoreFromBuild on
+ /// an obstacle and rebuilding its region updates the mesh.
+ [Fact]
+ public void RebuildTiles_HonorsModifierChanges()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20));
+ // A wall segment initially ignored by the bake (a ghost preview, say).
+ GameObject wall = AddFloorBox(scene, "Wall", new Float3(0, 2, 0), new Float3(2, 4, 20));
+ var modifier = wall.AddComponent();
+ modifier.IgnoreFromBuild = true;
+
+ NavMeshSurface surface = AddSurface(scene);
+ Assert.True(surface.BuildNavMesh());
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+
+ // The wall becomes real: stop ignoring it and rebuild its region.
+ modifier.IgnoreFromBuild = false;
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11))));
+
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathPartial, path.Status);
+ }
+
+ // ── Not Walkable source parity ──────────────────────────────────────
+
+ ///
+ /// A Not Walkable object sitting ON a walkable floor still erases it. Rasterizing it as the
+ /// null area would not: merging two spans in a column keeps the HIGHER of their areas, so
+ /// the floor it rests on would win and the object would come out walkable.
+ ///
+ [Fact]
+ public void Modifier_NotWalkableOnAWalkableFloor_ErasesIt()
+ {
+ Scene scene = CreateScene(enable: true);
+ AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20));
+
+ // Thin and flush with the floor, so the two span tops land within the climb threshold and
+ // their areas merge — the case where the null area would be discarded.
+ GameObject blocked = AddFloorBox(scene, "Blocked", new Float3(0, 0.05f, 0), new Float3(6, 0.1f, 6));
+ var modifier = blocked.AddComponent();
+ modifier.OverrideArea = true;
+ modifier.Area = NavMeshAreas.NotWalkable;
+
+ Assert.True(AddSurface(scene).BuildNavMesh());
+
+ Assert.Equal(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0)));
+ Assert.NotEqual(0, SampleAreaMask(scene, new Float3(8, 0.2f, 8)));
+ }
+
+ /// A source marked Not Walkable produces no navmesh at all (Unity parity): its
+ /// geometry is an obstacle, not traversable "area 1" polys.
+ [Fact]
+ public void Source_NotWalkableArea_ProducesNoPolys()
+ {
+ Float3[] verts = [new(0, 0, 0), new(0, 0, 10), new(10, 0, 10), new(10, 0, 0)];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ var source = new NavMeshGeometrySource(verts, indices, Float4x4.Identity, NavMeshAreas.NotWalkable);
+
+ var settings = new NavMeshBuildSettings { OverrideVoxelSize = true, VoxelSize = 0.25f };
+ Assert.Null(NavMeshBuilder.Build(settings, [source]));
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshObstacleTests.cs b/Prowl.Runtime.Test/NavMeshObstacleTests.cs
new file mode 100644
index 000000000..dd018fedb
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshObstacleTests.cs
@@ -0,0 +1,1038 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+using Prowl.Echo;
+using Prowl.Runtime;
+using Prowl.Runtime.Resources;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+///
+/// NavMeshObstacle carving: obstacles carve holes that incremental updates apply across frames,
+/// carve-only-stationary lifts the carve while moving, and the non-carving mode blocks agents by
+/// local avoidance instead. Also covers the bake side that carving depends on — cache layers
+/// query like finished tiles and round-trip through the asset.
+///
+public class NavMeshObstacleTests : RuntimeTestBase
+{
+ private static bool Walkable(Scene scene, Float3 position)
+ => scene.Navigation.SamplePosition(position, out _, 0.5f, NavMesh.AllAreas);
+
+ /// A bake produces cache layers, is queryable, and the layers survive an Echo
+ /// round-trip and re-instantiation.
+ [Fact]
+ public void Surface_BakesQueriesAndRoundTrips()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ Runtime.NavMeshData data = surface.NavMeshData.Res!;
+ Assert.NotEmpty(data.CacheLayers);
+
+ Assert.True(Walkable(scene, new Float3(0, 0.2f, 0)));
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+
+ // Layers round-trip through the asset serializer and instantiate again.
+ EchoObject echo = Serializer.Serialize(data);
+ Runtime.NavMeshData? loaded = Serializer.Deserialize(echo);
+ Assert.NotNull(loaded);
+ Assert.Equal(data.CacheLayers.Count, loaded!.CacheLayers.Count);
+ var cache = loaded.CreateTileCache(maxObstacles: 16);
+ Assert.NotNull(cache.GetNavMesh());
+ Assert.True(cache.GetNavMesh().GetMaxTiles() > 0);
+ }
+
+ ///
+ /// A surface with NOTHING overridden has to produce a usable navmesh. A tile wider than a
+ /// compressed layer header can describe (its dimensions are bytes) wraps to an empty layer:
+ /// the bake reports success and every tile comes out with no polygons — no gizmo, no agent
+ /// placement, no movement. Every other test here overrides the tile size, which is exactly
+ /// why that went unseen once.
+ ///
+ [Fact]
+ public void Bake_WithDefaultSettings_ProducesQueryableMesh()
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(40, 1, 40);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ surface.UseGeometry = NavMeshCollectGeometry.PhysicsColliders;
+
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+
+ Assert.True(Walkable(scene, new Float3(0, 0.2f, 0)), "A default bake must be queryable.");
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-15, 0, -15), new Float3(15, 0, 15), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ // The editor overlay reads this: empty layers drew nothing, which is how it looked unbaked.
+ Assert.NotEmpty(surface.NavMeshData.Res!.CalculateTriangulation().Vertices);
+ }
+
+ /// An explicit oversized tile size is clamped rather than silently producing an
+ /// empty mesh, and the asset records the size actually used — the TileCache instantiated
+ /// from it later reads those same settings.
+ [Fact]
+ public void Bake_ClampsTileSizeToLayerFormatLimit()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f);
+ surface.BuildOverrides.OverrideTileSize = true;
+ surface.BuildOverrides.TileSize = 1024;
+
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+
+ Assert.Equal(NavMeshBuildSettings.MaxTileSize, surface.NavMeshData.Res!.Settings.EffectiveTileSize);
+ Assert.True(Walkable(scene, new Float3(0, 0.2f, 0)));
+ }
+
+ ///
+ /// Carving costs nothing until something carves. A navmesh nobody has put an obstacle on is
+ /// never handed to the per-frame pump at all — that is what lets every surface be
+ /// carve-capable without every surface paying for it. A carve marks it, and convergence
+ /// clears the mark again.
+ ///
+ [Fact]
+ public void Surface_WithoutPendingWork_IsNotPumped()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ // Registration seeds every tile synchronously, so a fresh instance starts settled.
+ NavMeshInstance instance = surface.Instance!;
+ Assert.False(instance.CachePending);
+ Tick(scene, 5);
+ Assert.False(instance.CachePending);
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0);
+ crate.AddComponent().Size = new Float3(4, 3, 4);
+ Assert.True(instance.CachePending, "Queuing a carve must hand the instance to the pump.");
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0);
+ Assert.True(TickUntil(scene, () => !instance.CachePending) >= 0,
+ "Once the carve has converged the instance should drop out of the pump again.");
+ }
+
+ /// An obstacle carves a hole (paths detour, the hole is unwalkable) and removing
+ /// it restores the surface — all through incremental frame updates, no rebake.
+ [Fact]
+ public void Obstacle_CarvesHole_AndRemovalRestores()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+ Assert.True(Walkable(scene, new Float3(0, 0.2f, 0)));
+
+ GameObject obstacleGo = CreateGameObject("Crate");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Shape = NavMeshObstacleShape.Box;
+ obstacle.Size = new Float3(4, 3, 4);
+
+ // The obstacle sits on the 2x2 tile grid's crossing point, so the hole spans four
+ // tiles that rebuild incrementally over several frames — wait until every quadrant of
+ // the hole is carved, not just the first rebuilt tile.
+ int carveTicks = TickUntil(scene, () =>
+ !Walkable(scene, new Float3(1, 0.2f, 1)) && !Walkable(scene, new Float3(-1, 0.2f, 1))
+ && !Walkable(scene, new Float3(1, 0.2f, -1)) && !Walkable(scene, new Float3(-1, 0.2f, -1)));
+ Assert.True(carveTicks >= 0, "Obstacle should carve all affected tiles within the tick budget.");
+ // Surrounding floor survives the carve.
+ Assert.True(Walkable(scene, new Float3(7, 0.2f, 7)));
+
+ // A path across detours around the hole.
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ double maxDeviation = 0;
+ foreach (Float3 corner in path.Corners)
+ maxDeviation = System.Math.Max(maxDeviation, System.Math.Abs(corner.Z));
+ Assert.True(maxDeviation > 1.5, $"Path should route around the carved hole (max |z| = {maxDeviation:0.00}).");
+
+ // Removal restores.
+ obstacleGo.Enabled = false;
+ int restoreTicks = TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0)));
+ Assert.True(restoreTicks >= 0, "Removing the obstacle should restore walkability.");
+ }
+
+ /// With CarveOnlyStationary, the carve lifts while the obstacle moves and
+ /// re-applies after it has settled for CarvingTimeToStationary.
+ [Fact]
+ public void Obstacle_CarveOnlyStationary_LiftsWhileMoving()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject obstacleGo = CreateGameObject("Cart");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Shape = NavMeshObstacleShape.Box;
+ obstacle.Size = new Float3(4, 3, 4);
+ obstacle.CarveOnlyStationary = true;
+ obstacle.CarvingTimeToStationary = 0.2f;
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "A spawned-still obstacle should carve.");
+
+ // Drag it along: each tick moves beyond the threshold, so the carve lifts and stays
+ // lifted while in motion.
+ for (int i = 0; i < 30; i++)
+ {
+ obstacleGo.Transform.Position += new Float3(0.3f, 0, 0);
+ Tick(scene, 1);
+ }
+ Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "The original spot should be restored once the obstacle moved away.");
+ Assert.True(Walkable(scene, new Float3(obstacleGo.Transform.Position.X, 0.2f, 0)),
+ "A moving obstacle should not carve.");
+
+ // Settle: after the stationary time it carves at the new spot.
+ Float3 rest = obstacleGo.Transform.Position;
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(rest.X, 0.2f, 0))) >= 0,
+ "A settled obstacle should carve at its new position.");
+ }
+
+ /// Flipping Carve off at runtime must remove the existing hole, not leave it
+ /// carved forever (the non-carving mode is unsupported and does nothing).
+ [Fact]
+ public void Obstacle_CarveTurnedOff_RemovesExistingHole()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject obstacleGo = CreateGameObject("Ghost");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Size = new Float3(4, 3, 4);
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0);
+
+ obstacle.Carve = false;
+ Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "Turning Carve off should remove the hole.");
+
+ obstacle.Carve = true;
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "Turning Carve back on should re-carve.");
+ }
+
+ ///
+ /// A yawed box carves its ORIENTED footprint. The box is deliberately non-square (long
+ /// axis local Z), so a yaw-sign mismatch against Detour's convention would mirror the
+ /// carve onto the other diagonal and fail both assertions.
+ ///
+ [Fact]
+ public void Obstacle_RotatedBox_CarvesOrientedFootprint()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject obstacleGo = CreateGameObject("Barrier");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(0, 1, 0);
+ obstacleGo.Transform.Rotation = Quaternion.FromEuler(new Float3(0, 45, 0));
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Size = new Float3(1.5f, 3, 7); // long axis local Z, yawed 45°
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "The rotated obstacle should carve at its center.");
+
+ // Which world diagonal the long axis lies along after +45° yaw — measured from the
+ // Transform so the assertion tracks Prowl's yaw convention rather than assuming it.
+ Float3 along = obstacleGo.Transform.Forward;
+ var farAlong = new Float3((float)(along.X * 2.5f), 0.2f, (float)(along.Z * 2.5f));
+ var farAcross = new Float3((float)(-along.Z * 2.5f), 0.2f, (float)(along.X * 2.5f));
+
+ // The far end of a long obstacle reaches tiles the centre does not, and the cache
+ // rebuilds one tile per frame — waiting only for the centre samples the rest too early.
+ Assert.True(TickUntil(scene, () => !Walkable(scene, farAlong)) >= 0,
+ "The strip along the box's long axis should be carved.");
+ Assert.True(Walkable(scene, farAcross),
+ "Perpendicular to the long axis (outside the 1.5-wide footprint) should stay walkable.");
+ }
+
+ // ── Layer regeneration (geometry rebuilds) ─────────────────────────
+
+ /// RebuildTiles regenerates the affected tiles' compressed layers: dropped geometry
+ /// blocks paths, removal restores them — no rebake.
+ [Fact]
+ public void RebuildTiles_ReflectsGeometry()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 20);
+ wall.Transform.Position = new Float3(0, 2, 0);
+
+ var region = new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11));
+ Assert.True(surface.RebuildTiles(region));
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathPartial, path.Status);
+
+ wall.Enabled = false;
+ Assert.True(surface.RebuildTiles(region));
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ ///
+ /// The stage-3b headline: an obstacle's carve survives regeneration of the tiles it sits
+ /// in. Tile replacement invalidates the refs in every obstacle's touched list (salt
+ /// bump); without the refresh step the regenerated tiles rebuild WITHOUT their carves.
+ ///
+ [Fact]
+ public void LayerRegeneration_PreservesExistingCarves()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ // Carve a hole away from the wall line but inside the tile the wall rebuild touches.
+ GameObject obstacleGo = CreateGameObject("Crate");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(4, 1, 4);
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Size = new Float3(3, 3, 3);
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(4, 0.2f, 4))) >= 0);
+
+ // Destructible-world event: a wall drops and its tiles regenerate.
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 20);
+ wall.Transform.Position = new Float3(0, 2, 0);
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11))));
+
+ // The new geometry is in...
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathPartial, path.Status);
+ // ...AND the carve survived the regeneration of its tile.
+ Assert.False(Walkable(scene, new Float3(4, 0.2f, 4)),
+ "The obstacle's carve must survive layer regeneration of its tile.");
+ // Sanity: the floor between wall and carve is still there.
+ Assert.True(Walkable(scene, new Float3(4, 0.2f, -4)));
+
+ // The obstacle still owns its carve: removal restores through the incremental path.
+ obstacleGo.Enabled = false;
+ Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(4, 0.2f, 4))) >= 0,
+ "The carve must remain removable after regeneration (live obstacle refs intact).");
+ }
+
+ ///
+ /// An obstacle STRADDLING a regenerated tile and an untouched one: the refresh must
+ /// produce a mixed touched list (new ref for the regenerated tile + surviving ref for the
+ /// untouched one). A subtle refresh bug keeps half the carve — or loses half on removal.
+ ///
+ [Fact]
+ public void LayerRegeneration_ObstacleSpanningRebuiltAndUntouchedTiles()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ // Tile columns split at x = 6 (origin -10, tile world size 16). The crate straddles it.
+ GameObject obstacleGo = CreateGameObject("Crate");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(6, 1, 0);
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Size = new Float3(4, 3, 4); // carve x 4..8, both sides of the seam
+ Assert.True(TickUntil(scene, () =>
+ !Walkable(scene, new Float3(5, 0.2f, 0)) && !Walkable(scene, new Float3(7, 0.2f, 0))) >= 0);
+
+ // Regenerate only the LEFT tile column (wall far from the seam).
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 20);
+ wall.Transform.Position = new Float3(-6, 2, 0);
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-8, -1, -11), new Float3(-4, 5, 11))));
+
+ // Both halves of the carve survive: the regenerated-tile half re-applied, the
+ // untouched-tile half undisturbed.
+ Assert.False(Walkable(scene, new Float3(5, 0.2f, 0)), "Carve half in the regenerated tile must survive.");
+ Assert.False(Walkable(scene, new Float3(7, 0.2f, 0)), "Carve half in the untouched tile must survive.");
+
+ // Removal restores BOTH halves — the mixed touched list must hold the new ref and
+ // the surviving ref.
+ obstacleGo.Enabled = false;
+ Assert.True(TickUntil(scene, () =>
+ Walkable(scene, new Float3(5, 0.2f, 0)) && Walkable(scene, new Float3(7, 0.2f, 0))) >= 0,
+ "Removing the spanning obstacle must restore both sides of the seam.");
+ }
+
+ ///
+ /// A navmesh built at runtime is the surface's own — nothing else points at it — so rebuilds
+ /// land on it directly. Copying it would mean the next registration rebuilt from the original
+ /// bake and threw away every rebuild since, which for a game that generates its map is the
+ /// whole navmesh.
+ ///
+ [Fact]
+ public void LayerRegeneration_OnARuntimeBake_LandsOnTheDataItself()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+ Assert.Same(surface.NavMeshData.Res, surface.RuntimeData);
+
+ Runtime.NavMeshData data = surface.RuntimeData!;
+ var blobsBefore = new Dictionary<(int, int), byte[]>();
+ foreach (Runtime.NavMeshData.NavMeshTile layer in data.CacheLayers)
+ blobsBefore[(layer.X, layer.Z)] = layer.Data;
+
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 20);
+ wall.Transform.Position = new Float3(0, 2, 0);
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11))));
+
+ bool anyReplaced = false;
+ foreach (Runtime.NavMeshData.NavMeshTile layer in data.CacheLayers)
+ if (blobsBefore.TryGetValue((layer.X, layer.Z), out byte[]? before) && !ReferenceEquals(before, layer.Data))
+ anyReplaced = true;
+ Assert.True(anyReplaced, "Affected tiles' blobs should be replaced.");
+
+ // Re-registering must keep the rebuilt tiles rather than reverting to the original bake.
+ surface.RefreshRegistration();
+ Assert.Same(data, surface.RuntimeData);
+ }
+
+ ///
+ /// A navmesh the asset database handed out is shared with every other surface pointing at the
+ /// same .navmesh and with the next scene that loads it, so the surface rebuilds a copy and
+ /// leaves the asset holding every blob it was baked with. That copy still round-trips.
+ ///
+ [Fact]
+ public void LayerRegeneration_OnAnAsset_MirrorsIntoACopyAndLeavesTheAsset()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ // Stand in for an imported asset: what marks one is carrying a database id.
+ Runtime.NavMeshData asset = surface.NavMeshData.Res!;
+ asset.AssetID = Guid.NewGuid();
+ surface.RefreshRegistration();
+
+ Runtime.NavMeshData runtime = surface.RuntimeData!;
+ Assert.NotSame(asset, runtime);
+
+ List assetLayersBefore = [.. asset.CacheLayers];
+ var blobsBefore = new Dictionary<(int, int), byte[]>();
+ foreach (Runtime.NavMeshData.NavMeshTile layer in assetLayersBefore)
+ blobsBefore[(layer.X, layer.Z)] = layer.Data;
+
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 20);
+ wall.Transform.Position = new Float3(0, 2, 0);
+ Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11))));
+
+ bool anyReplaced = false;
+ foreach (Runtime.NavMeshData.NavMeshTile layer in runtime.CacheLayers)
+ if (blobsBefore.TryGetValue((layer.X, layer.Z), out byte[]? before) && !ReferenceEquals(before, layer.Data))
+ anyReplaced = true;
+ Assert.True(anyReplaced, "Affected tiles' blobs should be replaced on the runtime copy.");
+
+ Assert.Equal(assetLayersBefore.Count, asset.CacheLayers.Count);
+ for (int i = 0; i < assetLayersBefore.Count; i++)
+ Assert.Same(assetLayersBefore[i], asset.CacheLayers[i]);
+
+ EchoObject echo = Serializer.Serialize(runtime);
+ Runtime.NavMeshData? loaded = Serializer.Deserialize(echo);
+ Assert.NotNull(loaded);
+ var cache = loaded!.CreateTileCache(maxObstacles: 16);
+ Assert.True(cache.GetNavMesh().GetMaxTiles() > 0);
+ }
+
+ /// The async pair: layers regenerate off the main thread and apply as a swap.
+ [Fact]
+ public async Task RebuildTilesAsync_AppliesLikeSyncPath()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject wall = CreateGameObject("Wall");
+ scene.Add(wall);
+ wall.AddComponent().Size = new Float3(2, 4, 20);
+ wall.Transform.Position = new Float3(0, 2, 0);
+
+ var region = new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11));
+ var rebuilt = await surface.RebuildTilesAsync(region, surface.CollectSources());
+ Assert.NotEmpty(rebuilt);
+ Assert.True(surface.ApplyRebuiltTiles(rebuilt, out int rebuiltTiles));
+ Assert.True(rebuiltTiles > 0);
+
+ var path = new NavMeshPath();
+ Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathPartial, path.Status);
+ }
+
+ ///
+ /// A crowd agent routes around a carve rather than walking through it — the two halves of
+ /// the feature (carving, crowd) had no test that exercised them together.
+ ///
+ [Fact]
+ public void Agent_PathsAroundCarve()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject obstacleGo = CreateGameObject("Crate");
+ scene.Add(obstacleGo);
+ obstacleGo.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = obstacleGo.AddComponent();
+ obstacle.Size = new Float3(4, 3, 4); // straddles the straight line across
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0);
+
+ GameObject agentGo = CreateGameObject("Walker");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, 0);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 6f;
+ agent.Acceleration = 100f;
+ agent.Separation = false;
+ Tick(scene, 2);
+
+ Assert.True(agent.IsOnNavMesh, "The agent should place on a TileCache-built navmesh.");
+ Assert.True(agent.SetDestination(new Float3(8, 0, 0)));
+ Tick(scene, 5);
+ Assert.Equal(NavMeshPathStatus.PathComplete, agent.PathStatus);
+
+ bool arrived = false;
+ for (int i = 0; i < 600 && !arrived; i++)
+ {
+ Tick(scene, 1);
+ Float3 p = agentGo.Transform.Position;
+ Assert.False(System.Math.Abs(p.X) < 1.8 && System.Math.Abs(p.Z) < 1.8,
+ $"The agent walked into the carved hole at {p}.");
+ arrived = p.X > 7;
+ }
+ Assert.True(arrived, "The agent should reach the far side by routing around the carve.");
+ }
+
+ ///
+ /// A bake must not voxelize a carving obstacle's own geometry: the carve is the hole. Baking
+ /// it too freezes a second hole in the mesh that stays behind forever once the obstacle
+ /// moves — the carve follows, the baked one does not.
+ ///
+ [Fact]
+ public void Bake_ExcludesCarvingObstacleGeometry()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+
+ // Present at bake time, and short enough that no agent fits under it — so if its
+ // geometry were collected it really would cut the floor.
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(6, 0.6f, 6);
+ crate.AddComponent().Size = new Float3(4, 1.2f, 4);
+ var obstacle = crate.AddComponent();
+ obstacle.Size = new Float3(4, 1.2f, 4);
+ obstacle.CarvingTimeToStationary = 0.1f;
+
+ Assert.True(surface.BuildNavMesh());
+ Assert.Single(surface.CollectSources()); // the floor only — the crate is not collected
+
+ // The hole is there — carved, not baked.
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(6, 0.2f, 6))) >= 0,
+ "The obstacle should carve at its resting position.");
+
+ crate.Transform.Position = new Float3(-6, 0.6f, -6);
+ Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(6, 0.2f, 6))) >= 0,
+ "The old spot must heal — nothing about the obstacle should be baked into the mesh.");
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(-6, 0.2f, -6))) >= 0,
+ "The carve should follow the obstacle to its new resting position.");
+ }
+
+ /// Obstacles stay out of bakes in both carve settings — they block at runtime,
+ /// from wherever the object actually is.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Bake_ExcludesObstacleGeometry(bool carve)
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(6, 0.6f, 6);
+ crate.AddComponent().Size = new Float3(4, 1.2f, 4);
+ var obstacle = crate.AddComponent();
+ obstacle.Size = new Float3(4, 1.2f, 4);
+ obstacle.Carve = carve;
+
+ Assert.Single(surface.CollectSources()); // the floor only
+ }
+
+ ///
+ /// Exclusion is by presence, not enabled state — deliberately, so a bake can never depend
+ /// on when a component was last toggled. A disabled obstacle that bakes its geometry would
+ /// freeze a hole at that spot, and enabling it later and moving it would strand that hole
+ /// forever while the runtime blocking correctly follows.
+ ///
+ [Fact]
+ public void Bake_ExcludesObstacleGeometry_EvenWhileDisabled()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(6, 0.6f, 6);
+ crate.AddComponent().Size = new Float3(4, 1.2f, 4);
+ crate.AddComponent().Enabled = false;
+
+ Assert.Single(surface.CollectSources()); // the floor only
+ }
+
+ /// A blocker that cannot be placed on the navmesh is an invisible failure — it must
+ /// say so rather than sit there avoided by nobody.
+ [Fact]
+ public void NonCarvingObstacle_OffNavMesh_WarnsItCannotBlock()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject agentGo = CreateGameObject("Walker");
+ scene.Add(agentGo);
+ agentGo.AddComponent(); // creates the crowd a blocker would join
+ Tick(scene, 2);
+
+ List warnings = [];
+ void Capture(string message, DebugStackTrace? trace, LogSeverity severity)
+ {
+ if (severity == LogSeverity.Warning && message.Contains("FloatingCrate")) warnings.Add(message);
+ }
+
+ Debug.OnLog += Capture;
+ try
+ {
+ GameObject crate = CreateGameObject("FloatingCrate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 60, 0); // far above any walkable surface
+ var obstacle = crate.AddComponent();
+ obstacle.Carve = false;
+ obstacle.Size = new Float3(2, 2, 2);
+
+ Tick(scene, 30);
+ Assert.Single(warnings); // once, not once per frame
+ Assert.Contains("blocker", warnings[0]);
+ }
+ finally
+ {
+ Debug.OnLog -= Capture;
+ }
+ }
+
+ ///
+ /// Carving works outside play mode, because that is where you place buildings. The scene here
+ /// is opened rather than baked — its asset came from an earlier session — so the surface has
+ /// to register from OnEnable alone. Without that there is no live navmesh for the obstacle to
+ /// carve, and the scene view shows an untouched mesh while play mode avoids the crate fine.
+ ///
+ [Fact]
+ public void Obstacle_CarvesAndFollowsOutsidePlayMode()
+ {
+ (Scene _, NavMeshSurface baker) = CreateFloorScene(bake: true);
+ NavMeshData asset = baker.NavMeshData.Res!;
+
+ using (EditMode())
+ {
+ Scene scene = CreateScene(enable: true);
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ NavMeshSurface surface = surfaceGo.AddComponent();
+ surface.NavMeshData = asset;
+ scene.Add(surfaceGo); // enabling is what registers it
+
+ Assert.NotNull(surface.Instance);
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = crate.AddComponent();
+ obstacle.Size = new Float3(4, 3, 4);
+ obstacle.CarvingTimeToStationary = 0.1f;
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "An obstacle placed in the editor should carve.");
+
+ // And dragging it in the editor moves the hole, rather than stranding it.
+ crate.Transform.Position = new Float3(6, 1, 6);
+ Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "The old spot should heal when the obstacle is dragged away.");
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(6, 0.2f, 6))) >= 0,
+ "The carve should follow to where the obstacle was dropped.");
+ }
+ }
+
+ ///
+ /// A carve reports progress on every frame it works, but settles only when it finishes.
+ /// Anything expensive — replanning a crowd, rebuilding a cached triangulation — hangs off the
+ /// settled event so it runs on the finished navmesh rather than on each intermediate frame.
+ ///
+ [Fact]
+ public void Carve_ReportsEveryFrame_ButSettlesOnlyWhenDone()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f);
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+
+ // One tile per frame, and enough obstacles spread far enough apart to touch several
+ // tiles, so the drain is unambiguously spread across frames.
+ scene.Navigation.MaxTileUpdatesPerFrame = 1;
+
+ int changed = 0, settled = 0;
+ scene.Navigation.NavMeshChanged += () => changed++;
+ scene.Navigation.NavMeshSettled += () => settled++;
+
+ Float3[] corners =
+ [
+ new(-14, 1, -14), new(14, 1, -14), new(-14, 1, 14), new(14, 1, 14),
+ ];
+
+ foreach (Float3 spot in corners)
+ {
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = spot;
+ var obstacle = crate.AddComponent();
+ obstacle.Size = new Float3(4, 3, 4);
+ obstacle.CarvingTimeToStationary = 0.1f;
+ }
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(14, 0.2f, 14))) >= 0, "The obstacles should carve.");
+ Tick(scene, 10); // let the drain finish
+
+ Assert.True(settled >= 1, "A finished carve must settle.");
+ Assert.True(changed > settled, $"Progress should outnumber settles (changed={changed}, settled={settled}).");
+
+ // Settling ends the work, so the pump stops and neither event fires again.
+ int changedAtRest = changed, settledAtRest = settled;
+ Tick(scene, 5);
+ Assert.Equal(changedAtRest, changed);
+ Assert.Equal(settledAtRest, settled);
+ }
+
+ ///
+ /// A carved hole has to keep agents off the obstacle the way a baked wall does. A navmesh
+ /// records where an agent's CENTRE may be, not where its body fits — a bake pulls the mesh
+ /// back from every wall by the agent radius — so a hole cut to the obstacle's exact
+ /// footprint let agents walk their centre onto its surface and stand half inside it. On a
+ /// 10x10 floor the baked edge sits a full radius in from the rim; a carve must match.
+ ///
+ [Fact]
+ public void Carve_KeepsAgentsAFullRadiusClear()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0);
+ crate.AddComponent().Size = new Float3(2, 3, 2); // spans -1..1 in X and Z
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0);
+ Tick(scene, 30);
+
+ GameObject go = CreateGameObject("Walker");
+ scene.Add(go);
+ go.Transform.Position = new Float3(-6, 0, 0);
+ var agent = go.AddComponent();
+ agent.Speed = 3f;
+ agent.Acceleration = 100f;
+ Tick(scene, 2);
+ Assert.True(agent.SetDestination(new Float3(6, 0, 0))); // straight through the obstacle
+
+ double closest = double.MaxValue;
+ for (int i = 0; i < 500; i++)
+ {
+ Tick(scene, 1);
+ Float3 p = agent.Transform.Position;
+ double dx = Math.Max(Math.Abs(p.X) - 1.0, 0);
+ double dz = Math.Max(Math.Abs(p.Z) - 1.0, 0);
+ closest = Math.Min(closest, Math.Sqrt(dx * dx + dz * dz));
+ if (!agent.PathPending && agent.RemainingDistance <= 0f) break;
+ }
+
+ Assert.True(closest >= agent.Radius * 0.9,
+ $"Agent centre came within {closest:0.000} of the obstacle surface, inside its own {agent.Radius} radius.");
+ }
+
+ ///
+ /// A carve has to ANNOUNCE itself. The scene-view overlay caches its triangulation and rebuilds
+ /// it when the world reports a change, so a carve nobody is told about leaves the overlay
+ /// drawing intact floor over a real hole. Anything queued into a cache reports every frame it
+ /// works AND on the frame it finishes — a carve small enough to complete inside one cache
+ /// update would otherwise never announce at all.
+ ///
+ [Fact]
+ public void Carve_FinishingInOneUpdate_StillReportsTheChange()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ surface.AlwaysShowNavMesh = true;
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 4); // settle, so nothing else is pending
+
+ int changes = 0;
+ void Count() => changes++;
+ scene.Navigation.NavMeshChanged += Count;
+ try
+ {
+ // Small enough to land in one tile, which is what makes the cache converge at once.
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(3, 1, 3);
+ crate.AddComponent().Size = new Float3(2, 3, 2);
+
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(3, 0.2f, 3))) >= 0,
+ "The obstacle should carve.");
+ Assert.True(changes > 0, "A carve must raise NavMeshChanged, or the overlay never redraws.");
+
+ // And once it has settled the world goes quiet again, so the overlay is not
+ // re-triangulated every frame for the rest of the session.
+ Tick(scene, 5);
+ changes = 0;
+ Tick(scene, 10);
+ Assert.Equal(0, changes);
+ }
+ finally
+ {
+ scene.Navigation.NavMeshChanged -= Count;
+ }
+ }
+
+ ///
+ /// What the scene-view overlay draws must show the carve, or there is no way to tell a real
+ /// hole from an agent merely steering around something. The live triangulation reflects it;
+ /// the asset's does not, and cannot — obstacles are runtime state and never serialize — so
+ /// the overlay has to be reading the live navmesh whenever one is registered.
+ ///
+ [Fact]
+ public void LiveTriangulation_ShowsTheCarve()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+ Tick(scene, 2);
+
+ NavMeshTriangulation before = scene.Navigation.CalculateTriangulation(surface.AgentTypeId);
+ Assert.NotEmpty(before.Vertices);
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0);
+ crate.AddComponent().Size = new Float3(6, 3, 6);
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0);
+
+ NavMeshTriangulation after = scene.Navigation.CalculateTriangulation(surface.AgentTypeId);
+ int inside = 0;
+ for (int t = 0; t < after.Areas.Length; t++)
+ {
+ Float3 centroid = (after.Vertices[after.Indices[t * 3 + 0]]
+ + after.Vertices[after.Indices[t * 3 + 1]]
+ + after.Vertices[after.Indices[t * 3 + 2]]) / 3f;
+ if (Math.Abs(centroid.X) < 2 && Math.Abs(centroid.Z) < 2) inside++;
+ }
+ Assert.Equal(0, inside); // the hole is a hole in the drawn mesh too
+ Assert.NotEqual(before.Areas.Length, after.Areas.Length);
+ }
+
+ // ── Velocity obstacles (Carve off) ──────────────────────────────────
+
+ ///
+ /// Carve off is Unity's velocity-obstacle mode, not a disabled component: the mesh is
+ /// untouched (the path still leads straight through) but agents steer around the obstacle
+ /// locally. It joins the crowd as an immovable neighbour, so it works on Static surfaces
+ /// too, where carving is impossible.
+ ///
+ [Fact]
+ public void NonCarvingObstacle_DeflectsAgent_WithoutTouchingMesh()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject crate = CreateGameObject("Cart");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0); // straight across the agent's route
+ var obstacle = crate.AddComponent();
+ obstacle.Carve = false;
+ obstacle.Size = new Float3(3, 2, 3);
+
+ // The mesh is untouched: the straight line is still walkable and still the path.
+ Tick(scene, 2);
+ Assert.True(Walkable(scene, new Float3(0, 0.2f, 0)));
+
+ GameObject agentGo = CreateGameObject("Walker");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-10, 0, 0);
+ var agent = agentGo.AddComponent();
+ agent.Speed = 4f;
+ agent.Acceleration = 20f;
+ agent.Separation = false;
+ agent.Radius = 0.5f;
+ Tick(scene, 2);
+ Assert.True(agent.SetDestination(new Float3(10, 0, 0)));
+
+ double closest = double.MaxValue, maxLateral = 0;
+ bool arrived = false;
+ for (int i = 0; i < 900 && !arrived; i++)
+ {
+ Tick(scene, 1);
+ Float3 p = agentGo.Transform.Position;
+ closest = Math.Min(closest, Float3.Distance(p, new Float3(0, p.Y, 0)));
+ maxLateral = Math.Max(maxLateral, Math.Abs(p.Z));
+ arrived = p.X > 9;
+ }
+
+ Assert.True(arrived, "The agent should still reach the far side.");
+ Assert.True(maxLateral > 0.5, $"The agent should have been pushed off the straight line (max |z| = {maxLateral:0.00}).");
+ Assert.True(closest > 0.9, $"The agent should not have walked through the obstacle (closest approach {closest:0.00}).");
+ }
+
+ ///
+ /// The mode's reason for existing: an obstacle that moves. A blocker has no steering of its
+ /// own, so it only tracks the Transform because the component writes its position each
+ /// frame — and this costs no rebuild at all, unlike carving, which lifts and re-cuts the
+ /// mesh on every move.
+ ///
+ [Fact]
+ public void NonCarvingObstacle_FollowsAMovingObstacle()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject agentGo = CreateGameObject("Walker");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-10, 0, 0);
+ agentGo.AddComponent();
+ Tick(scene, 2);
+
+ GameObject cart = CreateGameObject("Cart");
+ scene.Add(cart);
+ cart.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = cart.AddComponent();
+ obstacle.Carve = false;
+ obstacle.Size = new Float3(2, 2, 2);
+ Tick(scene, 2);
+
+ for (int i = 0; i < 40; i++)
+ {
+ cart.Transform.Position += new Float3(0.2f, 0, 0.1f);
+ Tick(scene, 1);
+ }
+
+ Float3 expected = cart.Transform.Position;
+ Prowl.Recast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!;
+ foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents())
+ {
+ if (!ReferenceEquals(a.option.userData, obstacle)) continue;
+ Assert.True(Math.Abs(a.npos.X - expected.X) < 0.05f && Math.Abs(a.npos.Z - expected.Z) < 0.05f,
+ $"The blocker sat at ({a.npos.X:0.00}, {a.npos.Z:0.00}) while the obstacle moved to ({expected.X:0.00}, {expected.Z:0.00}).");
+ return;
+ }
+ Assert.Fail("The obstacle registered no blocker with the crowd.");
+ }
+
+ /// The blocker holds its ground rather than being shoved aside by the traffic it
+ /// exists to deflect.
+ [Fact]
+ public void NonCarvingObstacle_StaysPutUnderPressure()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f);
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject crate = CreateGameObject("Cart");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = crate.AddComponent();
+ obstacle.Carve = false;
+ obstacle.Size = new Float3(2, 2, 2);
+
+ for (int i = 0; i < 6; i++)
+ {
+ GameObject go = CreateGameObject($"Pusher{i}");
+ scene.Add(go);
+ go.Transform.Position = new Float3(-8, 0, -2.5f + i);
+ var pusher = go.AddComponent();
+ pusher.Speed = 5f;
+ pusher.Acceleration = 50f;
+ pusher.Radius = 0.4f;
+ Tick(scene, 1);
+ pusher.SetDestination(new Float3(10, 0, 0));
+ }
+
+ Tick(scene, 300);
+ Prowl.Recast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!;
+ foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents())
+ {
+ if (!ReferenceEquals(a.option.userData, obstacle)) continue;
+ Assert.True(Math.Abs(a.npos.X) < 0.05f && Math.Abs(a.npos.Z) < 0.05f,
+ $"The blocker drifted to ({a.npos.X:0.00}, {a.npos.Z:0.00}) under crowd pressure.");
+ return;
+ }
+ Assert.Fail("The obstacle registered no blocker with the crowd.");
+ }
+
+ /// Flipping Carve at runtime swaps modes cleanly: neither the hole nor the blocker
+ /// is left behind by the other.
+ [Fact]
+ public void Obstacle_SwitchingCarveMode_LeavesNothingBehind()
+ {
+ (Scene scene, NavMeshSurface surface) = CreateFloorScene();
+ Assert.True(surface.BuildNavMesh());
+
+ GameObject agentGo = CreateGameObject("Walker");
+ scene.Add(agentGo);
+ agentGo.Transform.Position = new Float3(-8, 0, 0);
+ agentGo.AddComponent(); // creates the crowd blockers join
+ Tick(scene, 2);
+
+ GameObject crate = CreateGameObject("Crate");
+ scene.Add(crate);
+ crate.Transform.Position = new Float3(0, 1, 0);
+ var obstacle = crate.AddComponent();
+ obstacle.Size = new Float3(4, 3, 4);
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0);
+ Assert.Equal(1, BlockerCount(scene, obstacle)); // carving: no blocker
+
+ obstacle.Carve = false;
+ Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "Switching to velocity mode must remove the hole.");
+ Assert.Equal(2, BlockerCount(scene, obstacle));
+
+ obstacle.Carve = true;
+ Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0,
+ "Switching back to carving must cut the hole again.");
+ Assert.Equal(1, BlockerCount(scene, obstacle));
+ }
+
+ /// 1 when the obstacle has no blocker in the crowd, 2 when it has one — written as
+ /// a count so a failure reports which side of the switch broke.
+ private static int BlockerCount(Scene scene, NavMeshObstacle obstacle)
+ {
+ Prowl.Recast.Detour.Crowd.DtCrowd? crowd = scene.Navigation.NativeCrowd;
+ if (crowd == null) return 0;
+ int count = 1;
+ foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents())
+ if (ReferenceEquals(a.option.userData, obstacle))
+ count++;
+ return count;
+ }
+}
diff --git a/Prowl.Runtime.Test/NavMeshQueryTests.cs b/Prowl.Runtime.Test/NavMeshQueryTests.cs
new file mode 100644
index 000000000..8813567b2
--- /dev/null
+++ b/Prowl.Runtime.Test/NavMeshQueryTests.cs
@@ -0,0 +1,257 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+
+using Prowl.Runtime;
+using Prowl.Vector;
+
+using Xunit;
+
+namespace Prowl.Runtime.Test;
+
+public class NavMeshQueryTests
+{
+ private static NavMeshBuildSettings TestSettings() => new()
+ {
+ OverrideVoxelSize = true,
+ VoxelSize = 0.25f,
+ OverrideTileSize = true,
+ TileSize = 64,
+ };
+
+ private static NavMeshGeometrySource Quad(float sizeX, float sizeZ, Float3 offset)
+ {
+ Float3[] verts =
+ [
+ new(0, 0, 0),
+ new(0, 0, sizeZ),
+ new(sizeX, 0, sizeZ),
+ new(sizeX, 0, 0),
+ ];
+ int[] indices = [0, 1, 2, 0, 2, 3];
+ return new NavMeshGeometrySource(verts, indices, Float4x4.CreateTranslation(offset));
+ }
+
+ /// A 20x20 floor whose middle is blocked by a wall spanning z=0..16 at x≈10,
+ /// leaving a 4-unit passage at the far +Z side. Paths from left to right must detour
+ /// through the passage.
+ private static NavMeshWorld BuildUShapeWorld()
+ {
+ // Wall as a tall box: two vertical faces won't rasterize as walkable, and the floor
+ // is interrupted because the wall volume overwrites walkable spans below its top.
+ Float3[] wallVerts =
+ [
+ // A solid box from (9.5,0,0) to (10.5,3,16)
+ new(9.5f, 0, 0), new(9.5f, 0, 16), new(10.5f, 0, 16), new(10.5f, 0, 0), // bottom
+ new(9.5f, 3, 0), new(9.5f, 3, 16), new(10.5f, 3, 16), new(10.5f, 3, 0), // top
+ ];
+ int[] wallIndices =
+ [
+ 4, 5, 6, 4, 6, 7, // top face (up-facing, but too high to connect: walkable island)
+ 0, 6, 5, 0, 7, 6, // sides via bottom ring (windings vary; solidity is what matters)
+ 0, 5, 1, 0, 4, 5,
+ 1, 6, 2, 1, 5, 6,
+ 2, 7, 3, 2, 6, 7,
+ 3, 4, 0, 3, 7, 4,
+ ];
+
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(),
+ [
+ Quad(20, 20, Float3.Zero),
+ new NavMeshGeometrySource(wallVerts, wallIndices, Float4x4.Identity),
+ ]);
+ Assert.NotNull(data);
+ Assert.NotNull(world.AddNavMeshData(data!));
+ return world;
+ }
+
+ [Fact]
+ public void CalculatePath_AroundWall_IsCompleteAndDetours()
+ {
+ NavMeshWorld world = BuildUShapeWorld();
+ var path = new NavMeshPath();
+
+ // Straight across the wall at z=8: must detour via the z>16 passage.
+ bool found = world.CalculatePath(new Float3(5, 0, 8), new Float3(15, 0, 8), NavMesh.AllAreas, path);
+
+ Assert.True(found);
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ Assert.True(path.CornerCount >= 3, $"A detour needs intermediate corners, got {path.CornerCount}.");
+
+ // The detour must pass beyond the wall's far end (z > 16 - some slack for corner cutting).
+ double maxZ = 0;
+ foreach (Float3 corner in path.Corners)
+ maxZ = System.Math.Max(maxZ, corner.Z);
+ Assert.True(maxZ > 14.0, $"Path should route around the wall end (max corner z was {maxZ:0.0}).");
+
+ // Path length must be well above the straight-line distance of 10.
+ double length = 0;
+ Float3[] corners = path.Corners;
+ for (int i = 1; i < corners.Length; i++)
+ length += Float3.Distance(corners[i - 1], corners[i]);
+ Assert.True(length > 14.0, $"Detour should be much longer than the 10-unit straight line, got {length:0.0}.");
+ }
+
+ [Fact]
+ public void CalculatePath_ToDisconnectedIsland_IsPartial()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(),
+ [
+ Quad(10, 10, Float3.Zero),
+ Quad(10, 10, new Float3(30, 0, 0)), // 20-unit gap: unreachable
+ ]);
+ Assert.NotNull(data);
+ world.AddNavMeshData(data!);
+
+ var path = new NavMeshPath();
+ bool found = world.CalculatePath(new Float3(5, 0, 5), new Float3(35, 0, 5), NavMesh.AllAreas, path);
+
+ Assert.True(found, "A partial path to the closest reachable point should still be returned.");
+ Assert.Equal(NavMeshPathStatus.PathPartial, path.Status);
+
+ // The partial path must end on the first island, not teleport across the gap.
+ Float3 last = path.Corners[path.CornerCount - 1];
+ Assert.True(last.X <= 10.5f, $"Partial path leaked off its island (end x = {last.X:0.0}).");
+ }
+
+ [Fact]
+ public void SamplePosition_SnapsToFloor_AndRespectsMaxDistance()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]);
+ world.AddNavMeshData(data!);
+
+ // 1.5 units above the floor: inside a 2-unit radius, outside a 0.5-unit radius.
+ Assert.True(world.SamplePosition(new Float3(5, 1.5f, 5), out NavMeshHit hit, 2f, NavMesh.AllAreas));
+ Assert.True(hit.Hit);
+ Assert.True(System.Math.Abs(hit.Position.Y) < 0.3f, $"Sample should land on the floor, got y={hit.Position.Y:0.00}.");
+ Assert.True(System.Math.Abs(hit.Position.X - 5) < 0.3f);
+
+ Assert.False(world.SamplePosition(new Float3(5, 1.5f, 5), out _, 0.5f, NavMesh.AllAreas));
+ }
+
+ [Fact]
+ public void Raycast_AcrossFloor_ClearAndBlocked()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]);
+ world.AddNavMeshData(data!);
+
+ // Within the floor: unobstructed.
+ Assert.False(world.Raycast(new Float3(2, 0, 5), new Float3(8, 0, 5), out NavMeshHit clear, NavMesh.AllAreas));
+ Assert.False(clear.Hit);
+
+ // Off the edge: blocked at the border.
+ Assert.True(world.Raycast(new Float3(5, 0, 5), new Float3(25, 0, 5), out NavMeshHit blocked, NavMesh.AllAreas));
+ Assert.True(blocked.Hit);
+ Assert.True(blocked.Position.X < 10.5f, $"Blocked ray should stop at the mesh border, got x={blocked.Position.X:0.0}.");
+ }
+
+ [Fact]
+ public void FindClosestEdge_ReturnsBorder()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]);
+ world.AddNavMeshData(data!);
+
+ Assert.True(world.FindClosestEdge(new Float3(5, 0, 5), out NavMeshHit hit, NavMesh.AllAreas));
+ Assert.True(hit.Hit);
+ // From the center of a 10x10 eroded floor, the nearest border is a few units away.
+ Assert.InRange(hit.Distance, 1f, 6f);
+ }
+
+ [Fact]
+ public void AreaMask_ExcludingArea_BlocksPath()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)], defaultArea: 3);
+ world.AddNavMeshData(data!);
+
+ var path = new NavMeshPath();
+ // Mask that excludes area 3: nothing is traversable.
+ int maskWithout3 = ~(1 << 3);
+ Assert.False(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), maskWithout3, path));
+ Assert.Equal(NavMeshPathStatus.PathInvalid, path.Status);
+
+ // Including it works.
+ Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ [Fact]
+ public void Queries_WithNoNavMesh_ReturnFalse()
+ {
+ var world = new NavMeshWorld();
+ var path = new NavMeshPath();
+
+ Assert.False(world.CalculatePath(Float3.Zero, new Float3(1, 0, 1), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathInvalid, path.Status);
+ Assert.False(world.SamplePosition(Float3.Zero, out _, 1f, NavMesh.AllAreas));
+ Assert.False(world.Raycast(Float3.Zero, new Float3(1, 0, 1), out _, NavMesh.AllAreas));
+ Assert.False(world.TryRentQuery(out _));
+ }
+
+ [Fact]
+ public void ParallelQueries_AreSafe()
+ {
+ NavMeshWorld world = BuildUShapeWorld();
+
+ System.Threading.Tasks.Parallel.For(0, 64, i =>
+ {
+ var path = new NavMeshPath();
+ var from = new Float3(2 + (i % 7), 0, 2 + (i % 11));
+ var to = new Float3(18 - (i % 5), 0, 3 + (i % 13));
+ bool found = world.CalculatePath(from, to, NavMesh.AllAreas, path);
+ Assert.True(found, $"Query {i} from {from} to {to} failed.");
+ Assert.True(world.SamplePosition(from, out _, 2f, NavMesh.AllAreas));
+ });
+ }
+
+ [Fact]
+ public void MutateTileCache_DrainsPoolAndKeepsWorking()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]);
+ NavMeshInstance? instance = world.AddNavMeshData(data!);
+ Assert.NotNull(instance);
+
+ var path = new NavMeshPath();
+ Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+
+ bool mutated = false;
+ world.MutateTileCache(instance!, _ => mutated = true);
+ Assert.True(mutated);
+
+ // Queries still work after the pool was invalidated.
+ Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), NavMesh.AllAreas, path));
+ Assert.Equal(NavMeshPathStatus.PathComplete, path.Status);
+ }
+
+ ///
+ /// An instance's lock holds wait handles that only Dispose releases, so unregistering has to
+ /// dispose it — but a query rented a moment earlier is still inside it, and disposing under
+ /// one throws on that thread instead of ours. Removal drops its own hold and bars new
+ /// queries; the last query out is what actually disposes.
+ ///
+ [Fact]
+ public void RemovingANavMesh_DisposesItsLockOnceTheLastQueryIsDone()
+ {
+ var world = new NavMeshWorld();
+ NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]);
+ NavMeshInstance instance = world.AddNavMeshData(data!)!;
+
+ Assert.True(instance.TryAcquire()); // stands in for a query in flight on another thread
+
+ world.RemoveNavMeshData(instance);
+ Assert.False(instance.TryAcquire(), "An unregistered navmesh must not admit new queries.");
+
+ instance.Lock.EnterReadLock(); // the in-flight query still has a lock to finish under
+ instance.Lock.ExitReadLock();
+
+ instance.Release();
+ Assert.Throws(instance.Lock.EnterReadLock);
+ }
+}
diff --git a/Prowl.Runtime.Test/RuntimeTestBase.cs b/Prowl.Runtime.Test/RuntimeTestBase.cs
index 3cf9e0988..87c7f87c3 100644
--- a/Prowl.Runtime.Test/RuntimeTestBase.cs
+++ b/Prowl.Runtime.Test/RuntimeTestBase.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License. See the LICENSE file in the project root for details.
using Prowl.Runtime.Resources;
+using Prowl.Vector;
namespace Prowl.Runtime.Test;
@@ -47,6 +48,34 @@ protected RuntimeTestBase()
/// The fixed timestep used by physics, mirrored from .
protected static float FixedDeltaTime => Time.FixedDeltaTime;
+ ///
+ /// Runs the block as an open editor with play mode off, so only components marked
+ /// receive gameplay callbacks. Build the scene inside
+ /// the scope: enabling one decides there and then which components get OnEnable, and a
+ /// scene assembled in play mode has already had them.
+ ///
+ protected static EditModeScope EditMode() => new();
+
+ protected readonly struct EditModeScope : IDisposable
+ {
+ private readonly bool _wasPlaying;
+ private readonly bool _wasEditor;
+
+ public EditModeScope()
+ {
+ _wasPlaying = Application.IsPlaying;
+ _wasEditor = Application.IsEditor;
+ Application.IsPlaying = false;
+ Application.IsEditor = true;
+ }
+
+ public void Dispose()
+ {
+ Application.IsPlaying = _wasPlaying;
+ Application.IsEditor = _wasEditor;
+ }
+ }
+
///
/// Creates a tracked scene. Pass to immediately enable it (play mode),
/// otherwise it starts disabled so component OnEnable is deferred until .
@@ -103,6 +132,49 @@ protected void StepPhysics(Scene scene, int steps = 1)
}
}
+ /// Coarse voxels and small tiles, so navmesh bakes in tests stay fast.
+ protected static void ApplyFastBakeSettings(NavMeshSurface surface)
+ {
+ surface.BuildOverrides.OverrideVoxelSize = true;
+ surface.BuildOverrides.VoxelSize = 0.25f;
+ surface.BuildOverrides.OverrideTileSize = true;
+ surface.BuildOverrides.TileSize = 64;
+ surface.UseGeometry = NavMeshCollectGeometry.PhysicsColliders;
+ }
+
+ /// A flat collider floor with its top surface at y=0, plus a surface configured
+ /// for a fast bake. Pass to bake and register it immediately.
+ protected (Scene scene, NavMeshSurface surface) CreateFloorScene(float size = 20f, bool bake = false)
+ {
+ Scene scene = CreateScene(enable: true);
+
+ GameObject floor = CreateGameObject("Floor");
+ scene.Add(floor);
+ floor.AddComponent().Size = new Float3(size, 1, size);
+ floor.Transform.Position = new Float3(0, -0.5f, 0);
+
+ GameObject surfaceGo = CreateGameObject("NavMeshSurface");
+ scene.Add(surfaceGo);
+ var surface = surfaceGo.AddComponent();
+ ApplyFastBakeSettings(surface);
+ if (bake && !surface.BuildNavMesh())
+ throw new InvalidOperationException("CreateFloorScene: the floor bake produced no walkable geometry.");
+
+ return (scene, surface);
+ }
+
+ /// Tick until holds — incremental cache updates
+ /// process a bounded slice per frame. Returns the ticks taken, or -1 on timeout.
+ protected int TickUntil(Scene scene, Func condition, int maxTicks = 240)
+ {
+ for (int i = 0; i < maxTicks; i++)
+ {
+ if (condition()) return i;
+ Tick(scene, 1);
+ }
+ return condition() ? maxTicks : -1;
+ }
+
public virtual void Dispose()
{
// DontDestroyOnLoad is static state, so anything a test preserved would still be in the
diff --git a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs
new file mode 100644
index 000000000..a60529398
--- /dev/null
+++ b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs
@@ -0,0 +1,828 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+
+using Prowl.Recast.Core.Numerics;
+using Prowl.Recast.Detour;
+using Prowl.Recast.Detour.Crowd;
+
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+/// Obstacle avoidance quality for a . Member names match
+/// Unity's for migration; the inspector shows the friendly display names.
+public enum ObstacleAvoidanceType
+{
+ [InspectorName("None")]
+ NoObstacleAvoidance = 0,
+ [InspectorName("Low")]
+ LowQualityObstacleAvoidance = 1,
+ [InspectorName("Medium")]
+ MedQualityObstacleAvoidance = 2,
+ [InspectorName("Good")]
+ GoodQualityObstacleAvoidance = 3,
+ [InspectorName("High")]
+ HighQualityObstacleAvoidance = 4,
+}
+
+/// State of the off-mesh link a is traversing.
+public readonly struct OffMeshLinkData
+{
+ /// True while the agent is on an off-mesh connection.
+ public readonly bool Valid;
+
+ /// World-space start of the traversal.
+ public readonly Float3 StartPos;
+
+ /// World-space end of the traversal.
+ public readonly Float3 EndPos;
+
+ /// The component the connection came from, or null
+ /// (connection baked without a link id, or the component is gone).
+ public readonly NavMeshLink? Link;
+
+ internal OffMeshLinkData(bool valid, Float3 startPos, Float3 endPos, NavMeshLink? link)
+ {
+ Valid = valid;
+ StartPos = startPos;
+ EndPos = endPos;
+ Link = link;
+ }
+}
+
+///
+/// Moves a character along the navmesh using crowd simulation: give it a
+/// (or call ) and it steers there,
+/// avoiding other agents. Mirrors Unity's NavMeshAgent API. The agent joins the scene's crowd
+/// when a navmesh for its is available and writes its position back
+/// to the Transform each LateUpdate (disable to drive a
+/// Rigidbody or CharacterController from yourself).
+///
+[AddComponentMenu("Navigation/NavMesh Agent")]
+[ComponentIcon("")] // Person Walking
+public class NavMeshAgent : MonoBehaviour
+{
+ [Header("Agent")]
+ [Tooltip("The agent type whose navmesh this agent walks on.")]
+ [NavMeshAgentType]
+ public int AgentTypeId = NavMeshAgentTypes.Humanoid;
+
+ [Tooltip("Agent radius for avoidance and crowd separation.")]
+ public float Radius = 0.5f;
+
+ [Tooltip("Agent height (used by the crowd for vertical overlap checks).")]
+ public float Height = 2.0f;
+
+ [Tooltip("Vertical offset between the navmesh surface and the Transform position.")]
+ public float BaseOffset = 0f;
+
+ [Header("Steering")]
+ [Tooltip("Maximum movement speed in world units/second.")]
+ public float Speed = 3.5f;
+
+ [Tooltip("Maximum turning speed in degrees/second, applied when UpdateRotation is on.")]
+ public float AngularSpeed = 120f;
+
+ [Tooltip("Maximum acceleration in world units/second².")]
+ public float Acceleration = 8f;
+
+ [Tooltip("Stop this far short of the destination.")]
+ public float StoppingDistance = 0f;
+
+ [Tooltip("Decelerate to a stop as the destination is approached instead of overshooting.")]
+ public bool AutoBraking = true;
+
+ [Header("Obstacle Avoidance")]
+ [Tooltip("Avoidance quality: higher avoids more reliably and costs more CPU.")]
+ [InspectorName("Quality")]
+ public ObstacleAvoidanceType ObstacleAvoidanceQuality = ObstacleAvoidanceType.MedQualityObstacleAvoidance;
+
+ [Tooltip("Agents with lower priority values are avoided by agents with higher values (0 = most important, 99 = least). Mapped to crowd separation weight.")]
+ [Range(0, 99)]
+ public int AvoidancePriority = 50;
+
+ [Tooltip("Push away from nearby agents (crowd separation). Disable when units should pack tightly or walk single file through corridors.")]
+ public bool Separation = true;
+
+ [Tooltip("How far steering scans for neighbours and navmesh borders, in world units. 0 derives it from the radius (radius x 12, the open-level default). On tile maps, ranges wider than the corridors keep the borders inside view in every direction and make avoidance oscillate - tune this down toward the corridor width.")]
+ public float CollisionQueryRange = 0f;
+
+ [Tooltip("Path visibility optimization range, in world units. 0 derives it from the radius (radius x 30).")]
+ public float PathOptimizationRange = 0f;
+
+ [Header("Pathfinding")]
+ [Tooltip("Areas this agent may traverse.")]
+ [NavMeshAreaMask]
+ public int AreaMask = NavMeshAreas.AllAreas;
+
+ [Tooltip("Automatically re-path when the navmesh changes under the current path.")]
+ public bool AutoRepath = true;
+
+ [Tooltip("Write the crowd position to the Transform each frame.")]
+ public bool UpdatePosition = true;
+
+ [Tooltip("Rotate the Transform to face the movement direction.")]
+ public bool UpdateRotation = true;
+
+ private NavMeshWorld? _world;
+ private DtCrowdAgent? _agent;
+ // The crowd _agent belongs to. Captured at registration because the world's crowd can be
+ // replaced when the navmesh is swapped (rebake/regenerate) — a stale _agent must be
+ // detached against ITS crowd, never the current one.
+ private DtCrowd? _crowd;
+ // The crowd entry _agent registered with, for filter-slot bookkeeping (same capture
+ // rationale as _crowd).
+ private NavMeshCrowdEntry? _crowdEntry;
+ // The crowd filter slot this agent steers with, and the AreaMask baked into it — the mask
+ // is re-checked each LateUpdate so writing the AreaMask field "just works" like Unity.
+ private int _filterSlot;
+ private int _slotAreaMask = NavMeshAreas.AllAreas;
+ // The agent type this agent registered under, re-checked each LateUpdate so writing the
+ // AgentTypeId field re-places the agent on its new type's crowd (Unity does the same).
+ private int _registeredAgentTypeId;
+ private NavMeshQueryFilter? _filter;
+ private Float3 _destination;
+ private bool _hasDestination;
+ private bool _isStopped;
+ private bool _arrived;
+
+ /// Whether obstacle avoidance is currently switched on for this agent. Starts on, so
+ /// a freshly added agent avoids until a crowd step proves there is nothing in range.
+ internal bool AvoidanceEngaged = true;
+
+ // Corner-window distance below which a path counts as arrived when StoppingDistance is 0.
+ private const float ArrivalEpsilon = 0.05f;
+
+ /// The raw crowd agent, while registered. Advanced use.
+ public DtCrowdAgent? NativeAgent => _agent;
+
+ /// True while the agent is registered on a navmesh crowd.
+ public bool IsOnNavMesh => _agent != null;
+
+ /// The query filter this agent paths with (area mask + agent type). Mutating its
+ /// costs directly (agent.Filter.SetAreaCost(...) ) affects explicit queries only —
+ /// crowd STEERING keeps the old cost table until the next refresh. Use
+ /// (or call after) to apply costs to
+ /// both.
+ public NavMeshQueryFilter Filter
+ {
+ get
+ {
+ _filter ??= new NavMeshQueryFilter();
+ _filter.AreaMask = AreaMask;
+ _filter.AgentTypeId = AgentTypeId;
+ return _filter;
+ }
+ }
+
+ #region Destination / movement state
+
+ /// Set or get the movement target. Setting it requests a new path.
+ public Float3 Destination
+ {
+ get => _hasDestination ? _destination : NextPosition;
+ set => SetDestination(value);
+ }
+
+ /// True while a requested path is still being computed by the crowd's path queue.
+ public bool PathPending => _agent != null &&
+ _agent.targetState is DtMoveRequestState.DT_CROWDAGENT_TARGET_REQUESTING
+ or DtMoveRequestState.DT_CROWDAGENT_TARGET_WAITING_FOR_QUEUE
+ or DtMoveRequestState.DT_CROWDAGENT_TARGET_WAITING_FOR_PATH;
+
+ /// True when the agent has a path it is following.
+ public bool HasPath => _agent != null && _agent.targetState == DtMoveRequestState.DT_CROWDAGENT_TARGET_VALID;
+
+ /// Status of the current path.
+ public NavMeshPathStatus PathStatus
+ {
+ get
+ {
+ if (_agent == null || _agent.targetState == DtMoveRequestState.DT_CROWDAGENT_TARGET_FAILED)
+ return NavMeshPathStatus.PathInvalid;
+ return _agent.partial ? NavMeshPathStatus.PathPartial : NavMeshPathStatus.PathComplete;
+ }
+ }
+
+ /// True while the agent is traversing an off-mesh link (the crowd animates the
+ /// hop; traversal is always automatic).
+ public bool IsOnOffMeshLink => _agent != null && _agent.state == DtCrowdAgentState.DT_CROWDAGENT_STATE_OFFMESH;
+
+ /// The off-mesh traversal in progress (Valid false while walking normally).
+ /// Resolves back to the component via the id stamped at bake.
+ public OffMeshLinkData CurrentOffMeshLinkData
+ {
+ get
+ {
+ if (!IsOnOffMeshLink || _agent!.animation == null || !_agent.animation.active)
+ return default;
+ DtCrowdAgentAnimation anim = _agent.animation;
+ return new OffMeshLinkData(true, ToFloat3(anim.startPos), ToFloat3(anim.endPos), ResolveLink(anim.polyRef));
+ }
+ }
+
+ /// The link component behind an off-mesh connection poly, via the user id
+ /// stamped at bake time.
+ private NavMeshLink? ResolveLink(long polyRef)
+ {
+ NavMeshInstance? instance = _world?.GetInstance(_registeredAgentTypeId);
+ if (instance == null) return null;
+ if (instance.NativeNavMesh.GetTileAndPolyByRef(polyRef, out DtMeshTile tile, out DtPoly poly).Failed())
+ return null;
+ var cons = tile?.data?.offMeshCons;
+ if (cons == null) return null;
+ foreach (DtOffMeshConnection con in cons)
+ if (ReferenceEquals(tile!.data.polys[con.poly], poly))
+ return _world!.FindLink(con.userId);
+ return null;
+ }
+
+ /// Current velocity of the agent in the crowd simulation.
+ public Float3 Velocity => _agent != null ? ToFloat3(_agent.vel) : Float3.Zero;
+
+ /// The velocity the agent wants (path steering before avoidance/acceleration limits).
+ /// Drive a Rigidbody or CharacterController from this when is off.
+ public Float3 DesiredVelocity => _agent != null ? ToFloat3(_agent.dvel) : Float3.Zero;
+
+ /// The agent's position in the crowd simulation (before ).
+ public Float3 NextPosition => _agent != null ? ToFloat3(_agent.npos) : Transform.Position;
+
+ /// The next corner the agent is steering toward.
+ public Float3 SteeringTarget => _agent != null && _agent.ncorners > 0 ? ToFloat3(_agent.corners[0].pos) : NextPosition;
+
+ ///
+ /// Distance to the end of the current path along its corners. Infinity while no path is
+ /// available. When the path's visible corner window doesn't yet reach the destination this
+ /// is a lower bound (matches Unity's remainingDistance semantics closely enough for
+ /// arrival checks against ).
+ ///
+ public float RemainingDistance
+ {
+ get
+ {
+ if (_agent == null) return float.PositiveInfinity;
+ if (_arrived) return 0f;
+ if (!HasPath) return float.PositiveInfinity;
+ // Mid-hop the corner window is empty and would read 0 — falsely "arrived" for the
+ // Unity idiom. The honest lower bound is remaining hop distance PLUS the path after
+ // landing: the hop distance alone collapses to ~0 as the animation lands, which
+ // would make waypoint scripts issue their next destination mid-hop and ping-pong.
+ if (IsOnOffMeshLink && _agent.animation is { active: true } anim)
+ {
+ Float3 landing = ToFloat3(anim.endPos);
+ return (float)(Float3.Distance(ToFloat3(_agent.npos), landing)
+ + Float3.Distance(landing, ToFloat3(_agent.targetPos)));
+ }
+ // An empty corner window is also transient right after a hop lands (corners not
+ // recomputed until the next crowd update) — measure straight to the target rather
+ // than trusting 0.
+ if (_agent.ncorners == 0)
+ return (float)Float3.Distance(ToFloat3(_agent.npos), ToFloat3(_agent.targetPos));
+ return CornerWindowDistance();
+ }
+ }
+
+ private float CornerWindowDistance()
+ {
+ if (_agent == null || _agent.ncorners == 0) return 0f;
+
+ float total = 0f;
+ RcVec3f prev = _agent.npos;
+ for (int i = 0; i < _agent.ncorners; i++)
+ {
+ total += RcVec3f.Distance(prev, _agent.corners[i].pos);
+ prev = _agent.corners[i].pos;
+ }
+ return total;
+ }
+
+ /// Stop (true) or resume (false) movement. Resuming re-requests the last
+ /// destination. Matches Unity: while stopped remembers the
+ /// target but does NOT clear the stopped state — movement resumes only when this is set
+ /// back to false.
+ public bool IsStopped
+ {
+ get => _isStopped;
+ set
+ {
+ if (_isStopped == value) return;
+ _isStopped = value;
+ if (_agent == null) return;
+
+ if (value)
+ _crowd?.ResetMoveTarget(_agent);
+ else if (_hasDestination && !_arrived)
+ RequestPathTo(_destination);
+ }
+ }
+
+ #endregion
+
+ #region Lifecycle
+
+ public override void OnEnable()
+ {
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ if (scene.IsNotValid()) return;
+
+ _world = scene!.Navigation;
+ _world.NavMeshChanged += OnNavMeshChanged;
+ _world.NavMeshSettled += OnNavMeshSettled;
+ TryRegister();
+ }
+
+ public override void OnDisable()
+ {
+ if (_world != null)
+ {
+ _world.NavMeshChanged -= OnNavMeshChanged;
+ _world.NavMeshSettled -= OnNavMeshSettled;
+ Unregister();
+ _world = null;
+ }
+ }
+
+ private void OnNavMeshChanged()
+ {
+ // Our crowd may have been dropped with its navmesh (rebake/regenerate); our crowd agent
+ // and filter slot died with it, so forget both and fall through to re-registration
+ // (which re-requests the remembered destination). MUST compare against
+ // _registeredAgentTypeId, not AgentTypeId: if gameplay rewrote the field before the
+ // LateUpdate drift check runs, the registered type's crowd is still alive and still
+ // contains our agent — forgetting it here would strand a ghost agent and leak its
+ // filter-slot refcount. Type changes are handled only by the drift check.
+ if (_agent != null && _world != null && !ReferenceEquals(_world.GetNativeCrowd(_registeredAgentTypeId), _crowd))
+ {
+ _agent = null;
+ _crowd = null;
+ _crowdEntry = null;
+ _filterSlot = 0;
+ }
+
+ if (_agent == null)
+ {
+ // A navmesh may have just become available. Re-registering re-requests the
+ // destination, so there is no replan to do here.
+ TryRegister();
+ }
+ }
+
+ /// The ground stopped moving: replan once. Carves span several frames and report a
+ /// change on each, so replanning from that would throw the path away every frame of one.
+ private void OnNavMeshSettled()
+ {
+ if (_agent != null && AutoRepath && _hasDestination && !_isStopped && !_arrived)
+ RequestPathTo(_destination);
+ }
+
+ private void TryRegister()
+ {
+ if (_agent != null || _world == null) return;
+
+ NavMeshInstance? instance = _world.GetInstance(AgentTypeId);
+ if (instance == null) return;
+
+ if (Radius > _world.CrowdMaxAgentRadius)
+ Debug.LogWarning($"[Navigation] Agent '{GameObject.Name}' radius {Radius:0.##} exceeds NavMeshWorld.CrowdMaxAgentRadius ({_world.CrowdMaxAgentRadius:0.##}); crowd proximity queries assume the smaller value. Raise CrowdMaxAgentRadius before the first agent registers.");
+
+ NavMeshCrowdEntry entry = _world.EnsureCrowd(instance);
+ _crowdEntry = entry;
+ _registeredAgentTypeId = AgentTypeId;
+ _slotAreaMask = AreaMask;
+ _filterSlot = entry.AcquireFilterSlot(AreaMask, _filter?.CostOverrides, GameObject.Name);
+ _agent = entry.Crowd.AddAgent(ToRc(Transform.Position - new Float3(0, BaseOffset, 0)), BuildAgentParams());
+ _crowd = entry.Crowd;
+ if (_hasDestination && !_isStopped && !_arrived)
+ RequestPathTo(_destination);
+ }
+
+ private void Unregister()
+ {
+ if (_agent == null) return;
+ _crowd?.RemoveAgent(_agent);
+ // Releasing into an entry the world already dropped is a harmless no-op.
+ _crowdEntry?.ReleaseFilterSlot(_filterSlot);
+ _agent = null;
+ _crowd = null;
+ _crowdEntry = null;
+ _filterSlot = 0;
+ }
+
+ private DtCrowdAgentParams BuildAgentParams()
+ {
+ int updateFlags = DtCrowdAgentUpdateFlags.DT_CROWD_ANTICIPATE_TURNS
+ | DtCrowdAgentUpdateFlags.DT_CROWD_OPTIMIZE_VIS
+ | DtCrowdAgentUpdateFlags.DT_CROWD_OPTIMIZE_TOPO;
+ if (Separation)
+ updateFlags |= DtCrowdAgentUpdateFlags.DT_CROWD_SEPARATION;
+ if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance && AvoidanceEngaged)
+ updateFlags |= DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE;
+
+ float radius = Math.Max(0.01f, Radius);
+ return new DtCrowdAgentParams
+ {
+ radius = radius,
+ height = Math.Max(0.01f, Height),
+ maxAcceleration = Acceleration,
+ maxSpeed = Speed,
+ collisionQueryRange = CollisionQueryRange > 0f ? CollisionQueryRange : radius * 12f,
+ pathOptimizationRange = PathOptimizationRange > 0f ? PathOptimizationRange : radius * 30f,
+ updateFlags = updateFlags,
+ obstacleAvoidanceType = Math.Max(0, (int)ObstacleAvoidanceQuality - 1),
+ // Unity priority 0 (most important) pushes hardest; map to separation weight 0.5..3.
+ separationWeight = 0.5f + 2.5f * (1f - AvoidancePriority / 99f),
+ queryFilterType = _filterSlot,
+ userData = this,
+ };
+ }
+
+ /// Push current inspector values (speed, radius, avoidance, area mask/costs ...)
+ /// into the live crowd agent. Called automatically on validate, on
+ /// , and when the field changes; call
+ /// manually after changing other fields from code mid-simulation.
+ public void RefreshParams()
+ {
+ if (_agent == null || _crowd == null) return;
+
+ // Re-derive the steering filter slot: release-then-acquire, so a config only this
+ // agent used frees its slot before (typically) being retaken with the new values.
+ if (_crowdEntry != null)
+ {
+ _crowdEntry.ReleaseFilterSlot(_filterSlot);
+ _slotAreaMask = AreaMask;
+ _filterSlot = _crowdEntry.AcquireFilterSlot(AreaMask, _filter?.CostOverrides, GameObject.Name);
+ }
+
+ _crowd.UpdateAgentParameters(_agent, BuildAgentParams());
+ }
+
+ public override void OnValidate() => RefreshParams();
+
+ #endregion
+
+ #region Commands
+
+ ///
+ /// Override the path cost of an area for THIS agent (explicit queries and crowd steering
+ /// both). Clamped to >= 1 — see ; to prefer an
+ /// area, raise the other areas' costs instead. Unity API parity.
+ ///
+ public void SetAreaCost(int areaIndex, float cost)
+ {
+ Filter.SetAreaCost(areaIndex, cost);
+ RefreshParams(); // re-derive the crowd filter slot with the new cost table
+ }
+
+ /// The path cost this agent pays in an area: its own override, or the project
+ /// default.
+ public float GetAreaCost(int areaIndex) => Filter.GetAreaCost(areaIndex);
+
+ /// Request a path to . Returns false when the agent is
+ /// not on a navmesh or the target cannot be mapped onto it. A stopped agent
+ /// ( ) remembers the destination but stays halted until resumed —
+ /// Unity semantics, where isStopped is a pause flag that survives new destinations.
+ public bool SetDestination(Float3 target)
+ {
+ _destination = target;
+ _hasDestination = true;
+ _arrived = false;
+ if (_agent == null || _isStopped) return false; // remembered; requested on registration/resume
+ return RequestPathTo(target);
+ }
+
+ private bool RequestPathTo(Float3 target)
+ {
+ if (_agent == null || _world == null) return false;
+ DtCrowd? crowd = _crowd;
+ if (crowd == null) return false;
+
+ if (!_world.TryRentQuery(out NavMeshQueryLease lease, AgentTypeId)) return false;
+ using (lease)
+ {
+ lease.Query.FindNearestPoly(ToRc(target), crowd.GetQueryExtents(), Filter, out long polyRef, out RcVec3f nearest, out _);
+ if (polyRef == 0) return false;
+ return crowd.RequestMoveTarget(_agent, polyRef, nearest);
+ }
+ }
+
+ ///
+ /// Follow a pre-calculated path, steering along the route it describes rather than re-planning
+ /// one to its endpoint. The path must come from (or
+ /// ) and start where
+ /// the agent is standing; the crowd still re-plans later if the navmesh invalidates it.
+ ///
+ /// False if the path is unusable, or does not begin at the agent's current polygon.
+ public bool SetPath(NavMeshPath path)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+ if (path.Status == NavMeshPathStatus.PathInvalid || path.CornerCount == 0) return false;
+ if (_agent == null || _crowd == null) return false;
+
+ Span polys = path.Polys;
+ if (polys.Length == 0) return false;
+
+ // The corridor must continue from where the agent stands, not teleport to wherever the
+ // path was computed from.
+ if (polys[0] != _agent.corridor.GetFirstPoly()) return false;
+
+ Float3 destination = path.LastCorner;
+ if (!_crowd.SetAgentPath(_agent, polys[^1], ToRc(destination), polys, polys.Length))
+ return false;
+
+ _destination = destination;
+ _hasDestination = true;
+ _arrived = false;
+ return true;
+ }
+
+ /// Clear the current path and destination without unregistering.
+ public void ResetPath()
+ {
+ _hasDestination = false;
+ _arrived = false;
+ if (_agent != null)
+ _crowd?.ResetMoveTarget(_agent);
+ }
+
+ /// Teleport the agent (and Transform) to a position on the navmesh. Keeps the
+ /// current destination.
+ public bool Warp(Float3 newPosition)
+ {
+ DtCrowd? crowd = _crowd;
+ if (_world == null || _agent == null || crowd == null)
+ {
+ // The Transform moves, but with no crowd agent nothing snapped it to the mesh and
+ // there is no one to ask whether it landed on any.
+ Transform.Position = newPosition + new Float3(0, BaseOffset, 0);
+ return false;
+ }
+
+ // Detour has no teleport: re-add the agent at the new position.
+ crowd.RemoveAgent(_agent);
+ _agent = crowd.AddAgent(ToRc(newPosition), BuildAgentParams());
+
+ // A teleport is a fresh approach, so a previous arrival would otherwise park the agent
+ // wherever it landed.
+ _arrived = false;
+ if (_hasDestination && !_isStopped)
+ RequestPathTo(_destination);
+
+ // Use the position the crowd snapped to: the requested one can be off the mesh.
+ Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0);
+ return true;
+ }
+
+ ///
+ /// Displace the agent by a world-space offset, constrained to the navmesh — the per-frame API
+ /// for driving an agent yourself. Slides the path corridor along, keeping the path, boundary
+ /// cache and neighbour set; use to jump somewhere unrelated, which rebuilds
+ /// all of that.
+ ///
+ public void Move(Float3 offset)
+ {
+ if (_agent == null || _world == null) return;
+ if (!_world.TryRentQuery(out NavMeshQueryLease lease, AgentTypeId)) return;
+
+ using (lease)
+ {
+ RcVec3f target = ToRc(NextPosition + offset);
+ _agent.corridor.MovePosition(target, lease.Query, Filter);
+ _agent.npos = _agent.corridor.GetPos();
+ }
+
+ if (UpdatePosition)
+ Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0);
+ }
+
+ /// Calculate a path from the agent's position with the agent's filter, without
+ /// moving the agent.
+ public bool CalculatePath(Float3 targetPosition, NavMeshPath path)
+ => _world?.CalculatePath(NextPosition, targetPosition, Filter, path) ?? false;
+
+ /// Navmesh raycast from the agent's position with the agent's filter.
+ public bool Raycast(Float3 targetPosition, out NavMeshHit hit)
+ {
+ if (_world != null) return _world.Raycast(NextPosition, targetPosition, out hit, Filter);
+ hit = default;
+ return false;
+ }
+
+ /// Closest navmesh edge from the agent's position with the agent's filter.
+ public bool FindClosestEdge(out NavMeshHit hit)
+ {
+ if (_world != null) return _world.FindClosestEdge(NextPosition, out hit, Filter);
+ hit = default;
+ return false;
+ }
+
+ /// Find the closest navmesh point within of a
+ /// position, using the agent's filter.
+ public bool SamplePosition(Float3 sourcePosition, float maxDistance, out NavMeshHit hit)
+ {
+ if (_world != null) return _world.SamplePosition(sourcePosition, out hit, maxDistance, Filter);
+ hit = default;
+ return false;
+ }
+
+ #endregion
+
+ public override void LateUpdate()
+ {
+ if (_agent == null)
+ {
+ // Deliberate belt-and-braces: NavMeshChanged already covers late registration, but
+ // a subscription can be lost across domain edge cases (component re-enable racing a
+ // world swap), and this retry is nearly free while unregistered.
+ TryRegister();
+ return;
+ }
+
+ // Public fields can be written directly (Unity-style), so cheap per-frame int compares
+ // keep the crowd in sync without requiring RefreshParams calls: a changed AgentTypeId
+ // re-places the agent on its new type's crowd (keeping its destination), a changed
+ // AreaMask re-derives the steering filter slot. Cost overrides go through
+ // SetAreaCost, which refreshes itself.
+ if (AgentTypeId != _registeredAgentTypeId)
+ {
+ Unregister();
+ TryRegister();
+ if (_agent == null) return;
+ }
+ if (AreaMask != _slotAreaMask)
+ RefreshParams();
+
+ // Velocity-obstacle sampling is the expensive half of a crowd step, and it picks from a
+ // DISCRETE set of candidate velocities, so running it with nothing in range still rounds
+ // the result and walks the agent sideways off a straight line. Skip it only when nothing
+ // is in range at all — that includes navmesh boundary segments, so an agent alone beside
+ // a wall still has the wall to keep off. Both are read from the last crowd step, so
+ // engaging lags a frame, which is fine at the metres-out range they're gathered from.
+ if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance)
+ {
+ bool engage = _agent.nneis > 0 || _agent.boundary.GetSegmentCount() > 0;
+ if (engage != AvoidanceEngaged)
+ {
+ AvoidanceEngaged = engage;
+ _crowd?.UpdateAgentParameters(_agent, BuildAgentParams());
+ }
+ }
+
+ if (UpdatePosition)
+ Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0);
+
+ if (UpdateRotation)
+ {
+ // Face where the agent STEERS, not where it moves: actual velocity carries avoidance
+ // corrections that don't shrink with speed, so braking into a goal lets them take
+ // over its direction and the agent shivers along a dead-straight path. The two gates
+ // below stop the heading chasing a vector that no longer means anything — one too
+ // slow to have a direction, one pointing at a target already underfoot, where
+ // following it would just spin the agent in place.
+ const double MinFacingSpeedSq = 0.01; // 0.1 m/s
+ Float3 face = ToFloat3(_agent.dvel);
+ double speedSq = face.X * face.X + face.Z * face.Z;
+ if (speedSq < MinFacingSpeedSq)
+ {
+ // Off-mesh hops: the crowd empties the steering vector and animates the agent
+ // across, so the actual velocity is the only heading available — and during a
+ // hop it is a clean straight line, with no avoidance running.
+ face = ToFloat3(_agent.vel);
+ speedSq = face.X * face.X + face.Z * face.Z;
+ }
+ // RemainingDistance walks the corner window, so only ask once the cheap gate passed.
+ if (speedSq > MinFacingSpeedSq && !(HasPath && !PathPending && RemainingDistance <= Radius))
+ {
+ // Quaternion rotate-towards, no Euler round-trip: Quaternion.FromEuler is in
+ // degrees while Maths.DeltaAngle wraps in radians — never mix the two.
+ float targetYaw = MathF.Atan2((float)face.X, (float)face.Z) * Maths.Rad2Deg;
+ Quaternion targetRotation = Quaternion.FromEuler(new Float3(0, targetYaw, 0));
+ Transform.Rotation = RotateTowards(Transform.Rotation, targetRotation, AngularSpeed * Time.DeltaTime);
+ }
+ }
+
+ UpdateArrival();
+ }
+
+ ///
+ /// Arrival detection, independent of how the agent approaches (braking is HOW it arrives,
+ /// this is WHETHER it has): once the corner window closes to within the stopping distance
+ /// (or the agent has braked to a stop inside its own radius of the goal), the move target
+ /// is released and reads exactly 0, so the Unity-style
+ /// "!PathPending && RemainingDistance <= StoppingDistance" idiom terminates.
+ ///
+ private void UpdateArrival()
+ {
+ // IsOnOffMeshLink: the crowd empties the corner window during a hop, which reads as
+ // "corridor consumed" below — latching there resets the move target mid-traversal and
+ // strands the agent at the link mouth.
+ if (_agent == null || _arrived || _isStopped || !HasPath || PathPending || IsOnOffMeshLink) return;
+
+ // The corner window is a LOWER bound (at most the crowd's few visible corners), so its
+ // distance only means "arrived" once the window reaches the path end — otherwise a tight
+ // switchback under StoppingDistance, or a congestion-jammed agent, could falsely latch.
+ // An EMPTY window is also untrustworthy: it happens both standing on the target and
+ // transiently right after a hop lands, so measure straight to the target instead.
+ float remaining;
+ if (_agent.ncorners == 0)
+ {
+ remaining = (float)Float3.Distance(ToFloat3(_agent.npos), ToFloat3(_agent.targetPos));
+ }
+ else
+ {
+ if ((_agent.corners[_agent.ncorners - 1].flags & DtStraightPathFlags.DT_STRAIGHTPATH_END) == 0)
+ return;
+ remaining = CornerWindowDistance();
+ }
+ float threshold = MathF.Max(ArrivalEpsilon, StoppingDistance);
+
+ Float3 vel = ToFloat3(_agent.vel);
+ float horizontalSpeed = MathF.Sqrt((float)(vel.X * vel.X + vel.Z * vel.Z));
+ // Auto-braking converges asymptotically, so also latch when the agent has effectively
+ // stopped within its own radius of the (visible) goal.
+ bool braked = remaining <= MathF.Max(0.1f, Radius) && horizontalSpeed <= 0.05f * MathF.Max(0.01f, Speed);
+
+ if (remaining <= threshold || braked)
+ {
+ // _hasDestination stays true: Unity keeps agent.destination readable after
+ // arrival, and migrated code does read it. _arrived gates every re-path site.
+ _arrived = true;
+ _crowd?.ResetMoveTarget(_agent);
+ }
+ }
+
+ private static Quaternion RotateTowards(Quaternion from, Quaternion to, float maxDegrees)
+ {
+ float dot = Math.Clamp(MathF.Abs(Quaternion.Dot(from, to)), 0f, 1f);
+ float angleDeg = 2f * MathF.Acos(dot) * Maths.Rad2Deg;
+ if (angleDeg <= maxDegrees || angleDeg < 1e-4f) return to;
+ return Quaternion.Slerp(from, to, maxDegrees / angleDeg);
+ }
+
+ ///
+ /// The agent's steering envelope: the crowd treats an agent as an upright cylinder of
+ /// x standing on the navmesh, so that is what is
+ /// drawn — sized and placed exactly as the simulation sees it, including
+ /// . Drawn unselected (like colliders) so a whole crowd's footprints
+ /// are visible while tuning.
+ ///
+ public override void DrawGizmos()
+ {
+ float radius = MathF.Max(0.01f, Radius);
+ float height = MathF.Max(0.01f, Height);
+
+ // BaseOffset is the gap between the Transform and the surface the agent stands on, so
+ // the cylinder's base sits that far below the Transform and rises by Height.
+ Float3 basePos = Transform.Position - new Float3(0, BaseOffset, 0);
+ Float3 center = basePos + new Float3(0, height * 0.5f, 0);
+
+ var color = new Color(0f, 0.85f, 1f, 1f);
+ Debug.DrawWireCylinder(center, Quaternion.Identity, radius, height, color);
+ // Base ring, so the footprint reads clearly against the ground.
+ Debug.DrawWireCircle(basePos, Float3.UnitY, radius, color);
+ }
+
+ ///
+ /// The route the agent is currently steering along, plus its destination — drawn from the
+ /// crowd's own corner list, so it shows what the simulation is actually following rather
+ /// than a re-planned guess. Only meaningful while a crowd is running (in the editor an
+ /// unregistered agent has no path), which matches Unity.
+ ///
+ public override void DrawGizmosSelected()
+ {
+ var pathColor = new Color(0.2f, 1f, 0.45f, 1f);
+ var lift = new Float3(0, 0.05f, 0); // clear of the surface so it isn't z-fought away
+
+ if (_hasDestination)
+ {
+ Float3 destination = _destination + lift;
+ Debug.DrawWireSphere(destination, MathF.Max(0.05f, Radius * 0.35f), pathColor);
+ }
+
+ if (_agent == null) return;
+
+ // Mid-hop across an off-mesh link the crowd empties the corner window, so draw the hop
+ // itself — otherwise the route appears to vanish exactly when it is most interesting.
+ if (IsOnOffMeshLink)
+ {
+ OffMeshLinkData hop = CurrentOffMeshLinkData;
+ if (hop.Valid)
+ Debug.DrawLine(hop.StartPos + lift, hop.EndPos + lift, new Color(1f, 0.8f, 0.2f, 1f));
+ return;
+ }
+
+ // The crowd exposes a WINDOW of upcoming corners, not the whole route, so this is the
+ // planned path as far as the simulation currently sees it.
+ Float3 previous = NextPosition + lift;
+ for (int i = 0; i < _agent.ncorners; i++)
+ {
+ Float3 corner = ToFloat3(_agent.corners[i].pos) + lift;
+ Debug.DrawLine(previous, corner, pathColor);
+ previous = corner;
+ }
+ }
+
+ private static RcVec3f ToRc(Float3 v) => new((float)v.X, (float)v.Y, (float)v.Z);
+ private static Float3 ToFloat3(RcVec3f v) => new(v.X, v.Y, v.Z);
+}
diff --git a/Prowl.Runtime/Components/Navigation/NavMeshLink.cs b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs
new file mode 100644
index 000000000..031e29bb1
--- /dev/null
+++ b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs
@@ -0,0 +1,358 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+///
+/// Connects two navmesh positions that aren't walkably connected — a jump over a gap, a drop
+/// off a ledge, a ladder. Mirrors Unity's NavMeshLink: agents whose area mask includes
+/// traverse the link automatically as part of pathing (the crowd animates
+/// the hop; manual traversal is not supported). Links are BAKED data: they are collected into
+/// bakes like geometry, and changing one at runtime requires rebuilding the tiles around its
+/// endpoints — which this component does itself when is on. A bake
+/// keeps its links beside its layers and re-injects them whenever a tile is re-contoured, so
+/// carving and links coexist.
+/// A link's traversal cost comes from its area's cost; to price a link individually, give it
+/// its own area with the desired cost.
+///
+// A link has to be enabled to be registered, and a bake gathers its links from that registry —
+// so a link inert outside play mode would go missing from every bake pressed in the editor.
+// Being live also means editing one re-contours the tiles around it there, the way it does in
+// play, and the surface overlay redraws the connection to match.
+[ExecuteAlways]
+[AddComponentMenu("Navigation/NavMesh Link")]
+[ComponentIcon("")] // link icon
+public class NavMeshLink : MonoBehaviour
+{
+ [Tooltip("Link start position, local to this GameObject.")]
+ public Float3 StartPoint = new(0, 0, -2.5f);
+
+ [Tooltip("Link end position, local to this GameObject.")]
+ public Float3 EndPoint = new(0, 0, 2.5f);
+
+ [Tooltip("World-space width of the link: how wide a span of the edge it covers, which is also how far its endpoints may snap to reach walkable surface. 0 uses the agent's own radius.")]
+ public float Width;
+
+ [Tooltip("Whether the link can be traversed in both directions.")]
+ public bool Bidirectional = true;
+
+ [Tooltip("The link's area. Traversal cost comes from this area's cost, and agents whose mask excludes it won't use the link.")]
+ [NavMeshArea]
+ public int Area = NavMeshAreas.Jump;
+
+ [Tooltip("Whether the link is traversable. Toggling at runtime rebuilds the affected tiles (with Auto Rebuild on).")]
+ public bool Activated = true;
+
+ [Tooltip("Follow Transform movement at runtime by rebuilding the affected tiles when the endpoints move. Meant for occasional repositioning, not per-frame motion — every move pays a partial rebuild.")]
+ public bool AutoUpdatePosition;
+
+ [Tooltip("Automatically rebuild the affected tiles of matching surfaces when this link changes (enable/disable, Activated, moves with Auto Update Position). Turn off in games that manage rebuilds themselves with explicit sources.")]
+ public bool AutoRebuild = true;
+
+ [Tooltip("Apply to bakes of every agent type. Turn off to pick specific types.")]
+ public bool AffectAllAgentTypes = true;
+
+ [Tooltip("Agent types whose bakes include this link, when not affecting all.")]
+ [NavMeshAgentType]
+ [EnableIf(nameof(UsesExplicitAgentTypes))]
+ public List AffectedAgentTypeIds = [];
+
+ /// Persistent id stamped on the baked connections, resolving a traversing agent back
+ /// to this component ( ). Derived from the
+ /// component's , which the scene persists, so it survives
+ /// a reload and a duplicated object gets its own. Resolution is best-effort — baked data can
+ /// outlive the component that produced it — so don't hang gameplay-critical logic on
+ /// CurrentOffMeshLinkData.Link .
+ public int LinkId => StableLinkId(Identifier);
+
+ /// Fold an identifier into the non-zero int a baked connection stores. Written out
+ /// rather than using Guid.GetHashCode, which is only guaranteed stable within one process;
+ /// a baked id has to match across sessions.
+ private static int StableLinkId(Guid identifier)
+ {
+ Span bytes = stackalloc byte[16];
+ identifier.TryWriteBytes(bytes);
+
+ int id = 0;
+ for (int i = 0; i < 16; i += 4)
+ id ^= BitConverter.ToInt32(bytes.Slice(i, 4));
+ return id == 0 ? 1 : id;
+ }
+
+ private bool UsesExplicitAgentTypes => !AffectAllAgentTypes;
+
+ // The state the navmesh last saw, for change detection in LateUpdate. World endpoints size
+ // the rebuild regions; the authored fields are tracked separately so writing them after
+ // AddComponent (spawn-then-configure) re-applies the link without needing
+ // AutoUpdatePosition, which is about following the Transform.
+ private Float3 _appliedStart, _appliedEnd;
+ private Float3 _appliedStartPoint, _appliedEndPoint;
+ private float _appliedWidth;
+ private int _appliedArea;
+ private bool _appliedBidirectional;
+ private bool _appliedActive;
+ // Agent-type scoping decides which surfaces the link resolves against, so it is part of the
+ // definition too; the id list is copied rather than aliased, or the comparison would be
+ // against the caller's own live list and never report a change.
+ private bool _appliedAffectAllAgentTypes;
+ private readonly List _appliedAgentTypeIds = [];
+
+ private void CaptureAppliedDefinition()
+ {
+ _appliedStart = WorldStart;
+ _appliedEnd = WorldEnd;
+ _appliedStartPoint = StartPoint;
+ _appliedEndPoint = EndPoint;
+ _appliedWidth = Width;
+ _appliedArea = Area;
+ _appliedBidirectional = Bidirectional;
+ _appliedActive = Activated;
+ CaptureAppliedScope();
+ }
+
+ /// Everything except the scope (see ).
+ private void CaptureAppliedGeometry()
+ {
+ _appliedStart = WorldStart;
+ _appliedEnd = WorldEnd;
+ _appliedStartPoint = StartPoint;
+ _appliedEndPoint = EndPoint;
+ _appliedWidth = Width;
+ _appliedArea = Area;
+ _appliedBidirectional = Bidirectional;
+ _appliedActive = Activated;
+ }
+
+ /// Committed separately from the rest, and only after a rebuild has run: narrowing
+ /// the scope has to rebuild the surfaces the link is being taken OFF, and those are only
+ /// identifiable from the previous snapshot.
+ private void CaptureAppliedScope()
+ {
+ _appliedAffectAllAgentTypes = AffectAllAgentTypes;
+ _appliedAgentTypeIds.Clear();
+ if (AffectedAgentTypeIds != null) _appliedAgentTypeIds.AddRange(AffectedAgentTypeIds);
+ }
+
+ /// Surfaces to revisit on a change: those this link applies to now, plus those it
+ /// applied to before. Collection filters by the current scope, so a surface in the second
+ /// group rebuilds without the link — which is how it gets removed.
+ private bool AffectsOrDidAffect(int agentTypeId)
+ => AffectsAgentType(agentTypeId)
+ || _appliedAffectAllAgentTypes
+ || _appliedAgentTypeIds.Contains(agentTypeId);
+
+ private bool DefinitionChanged()
+ => !StartPoint.Equals(_appliedStartPoint)
+ || !EndPoint.Equals(_appliedEndPoint)
+ || Width != _appliedWidth
+ || Area != _appliedArea
+ || Bidirectional != _appliedBidirectional
+ || AffectAllAgentTypes != _appliedAffectAllAgentTypes
+ || AgentTypeIdsChanged();
+
+ private bool AgentTypeIdsChanged()
+ {
+ int count = AffectedAgentTypeIds?.Count ?? 0;
+ if (count != _appliedAgentTypeIds.Count) return true;
+ for (int i = 0; i < count; i++)
+ if (AffectedAgentTypeIds![i] != _appliedAgentTypeIds[i]) return true;
+ return false;
+ }
+
+ private NavMeshWorld? _world;
+ // Instances this link has already run its catch-up check against — each instance is
+ // attempted at most once, which is what keeps the NavMeshChanged handler from looping
+ // (our own catch-up rebuild fires the event again) and keeps permanently-unattachable
+ // links (endpoints over void) from rebuilding on every navmesh event.
+ private readonly HashSet _catchUpDone = [];
+
+ /// Does this link apply to bakes for the given agent type?
+ public bool AffectsAgentType(int agentTypeId)
+ => AffectAllAgentTypes || AffectedAgentTypeIds.Contains(agentTypeId);
+
+ /// World-space start position.
+ public Float3 WorldStart => Transform.TransformPoint(StartPoint);
+
+ /// World-space end position.
+ public Float3 WorldEnd => Transform.TransformPoint(EndPoint);
+
+ /// This link as a self-contained bake payload (world space, current state).
+ public NavMeshLinkSource ToLinkSource()
+ => new(WorldStart, WorldEnd, Width, Bidirectional, Area, LinkId);
+
+ public override void OnEnable()
+ {
+ CaptureAppliedDefinition();
+
+ // Catch-up must survive any enable order between links and surfaces: a navmesh may
+ // already be live (runtime-spawned link), or may only register later (scene load
+ // order) — the NavMeshChanged subscription covers the latter, mirroring how agents
+ // handle late registration.
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ if (scene.IsValid())
+ {
+ _world = scene!.Navigation;
+ _world.NavMeshChanged += OnNavMeshChanged;
+ _world.RegisterLink(this);
+ }
+ if (Activated) CatchUp();
+ }
+
+ public override void OnDisable()
+ {
+ _catchUpDone.Clear();
+ if (_world != null)
+ {
+ _world.NavMeshChanged -= OnNavMeshChanged;
+ _world.UnregisterLink(this);
+
+ // Unregistered first, so the rebuild collects the links WITHOUT this one — but
+ // before the world reference is released, since that's what reaches the surfaces.
+ // Scene teardown costs nothing here: Scene.OnDispose clears the navigation world
+ // before GameObjects dispose, so every instance is already retired and the rebuild
+ // finds nothing to do; a gameplay disable (pooling, a destroyed building) keeps it.
+ if (_appliedActive) RequestRebuild(_appliedStart, _appliedEnd);
+
+ _world = null;
+ }
+ }
+
+ private bool _catchUpPending;
+
+ /// A navmesh registered or changed: schedule the catch-up check so links baked
+ /// out of date (added/moved since the surface's last bake) insert themselves regardless
+ /// of component enable order. Deferred to LateUpdate because NavMeshChanged fires INSIDE
+ /// AddNavMeshData — before the registering surface has assigned its Instance — so an
+ /// immediate check would see no surface to rebuild through.
+ private void OnNavMeshChanged()
+ {
+ // Only a change to the SET of navmeshes can give this link somewhere new to attach, and
+ // the event also fires per frame while a surface converges a carve. Gate on the
+ // structural counter, or every carving frame wakes a check per link to discover nothing.
+ if (_world == null || _world.StructureGeneration == _seenStructureGeneration) return;
+ _seenStructureGeneration = _world.StructureGeneration;
+ _catchUpPending = true;
+ }
+
+ private int _seenStructureGeneration = -1;
+
+ ///
+ /// For each matching surface with a live navmesh this link hasn't checked yet: if the
+ /// baked mesh already contains the link (id stamped at bake) do nothing — baked-in links
+ /// cost nothing at scene load — otherwise rebuild the endpoint tiles to insert it.
+ ///
+ private void CatchUp()
+ {
+ if (!AutoRebuild || _world == null) return;
+
+ // Replaced instances (full rebakes) would otherwise be pinned by the checked set.
+ _catchUpDone.RemoveWhere(i => _world.GetInstance(i.AgentTypeId) != i);
+
+ IReadOnlyList surfaces = _world.Surfaces;
+ for (int i = 0; i < surfaces.Count; i++)
+ {
+ NavMeshSurface surface = surfaces[i];
+ NavMeshInstance? instance = surface.Instance;
+ if (instance == null || !AffectsAgentType(surface.AgentTypeId)) continue;
+ if (!_catchUpDone.Add(instance)) continue; // one attempt per instance
+ if (instance.ContainsLinkId(LinkId)) continue; // already in the live mesh
+ MarkEndpointRegions(surface, _appliedStart, _appliedEnd);
+ }
+ }
+
+ public override void LateUpdate()
+ {
+ if (_catchUpPending)
+ {
+ _catchUpPending = false;
+ if (Activated) CatchUp();
+ }
+
+ bool activeChanged = Activated != _appliedActive;
+ bool edited = DefinitionChanged();
+ bool moved = AutoUpdatePosition
+ && (Float3.Distance(WorldStart, _appliedStart) > 0.01 || Float3.Distance(WorldEnd, _appliedEnd) > 0.01);
+ if (!activeChanged && !edited && !moved) return;
+
+ // Rebuild around both the old and the new endpoints: the old tiles drop the stale
+ // connection, the new ones gain it.
+ Float3 oldStart = _appliedStart, oldEnd = _appliedEnd;
+ bool relocated = moved || edited;
+ CaptureAppliedGeometry();
+
+ // An edited link has to be re-offered to every instance: catch-up only attempts each
+ // one once, and the earlier attempt applied the old definition.
+ if (edited) _catchUpDone.Clear();
+
+ RequestRebuild(oldStart, oldEnd);
+ if (relocated) RequestRebuild(_appliedStart, _appliedEnd);
+ CaptureAppliedScope(); // both rebuilds have seen the outgoing scope
+ }
+
+ /// Rebuild the tiles around both endpoints on every registered surface this link
+ /// affects. No-op when is off or no matching navmesh is live
+ /// (which includes scene teardown — see the note in ).
+ private void RequestRebuild(Float3 start, Float3 end)
+ {
+ if (!AutoRebuild || _world == null) return;
+
+ IReadOnlyList surfaces = _world.Surfaces;
+ for (int i = 0; i < surfaces.Count; i++)
+ {
+ NavMeshSurface surface = surfaces[i];
+ if (surface.Instance == null || !AffectsOrDidAffect(surface.AgentTypeId)) continue;
+ MarkEndpointRegions(surface, start, end);
+ }
+ }
+
+ /// Dirty the tiles around both endpoints: one region when the padded regions overlap
+ /// (the common short ladder/ledge link), two when they don't — a merged AABB across a long
+ /// link would dirty everything between the endpoints. The world applies them, so a frame that
+ /// moves many links re-contours each affected tile once however many of them touched it.
+ ///
+ private void MarkEndpointRegions(NavMeshSurface surface, Float3 start, Float3 end)
+ {
+ if (_world == null) return;
+
+ float pad = Width * 0.5f + 1f;
+ AABB startRegion = new AABB(start, start).Expanded(pad);
+ AABB endRegion = new AABB(end, end).Expanded(pad);
+
+ if (startRegion.Intersects(endRegion))
+ _world.MarkLinkTilesDirty(surface, startRegion.Encapsulating(endRegion));
+ else
+ {
+ _world.MarkLinkTilesDirty(surface, startRegion);
+ _world.MarkLinkTilesDirty(surface, endRegion);
+ }
+ }
+
+ ///
+ /// Draws whatever the navmesh made of this link — the same connection the surface's overlay
+ /// draws, in the same place, so the two agree wherever both are shown. A link that has not
+ /// attached has nothing there to draw, so it falls back to the authored line in grey: that
+ /// difference in colour is the only warning that it reached nothing walkable.
+ ///
+ public override void DrawGizmosSelected()
+ {
+ IReadOnlyList surfaces = _world?.Surfaces ?? [];
+ for (int i = 0; i < surfaces.Count; i++)
+ {
+ NavMeshInstance? instance = surfaces[i].Instance;
+ if (instance == null || !AffectsAgentType(surfaces[i].AgentTypeId)) continue;
+ if (!instance.TryGetConnection(LinkId, out NavMeshConnection connection)) continue;
+ NavMeshSurface.DrawConnection(connection, Float3.Zero);
+ return;
+ }
+
+ var color = new Color(0.6f, 0.6f, 0.6f, 1f);
+ Debug.DrawLine(WorldStart, WorldEnd, color);
+ Debug.DrawWireSphere(WorldStart, NavMeshSurface.EndpointGizmoRadius, color);
+ Debug.DrawWireSphere(WorldEnd, NavMeshSurface.EndpointGizmoRadius, color);
+ }
+}
diff --git a/Prowl.Runtime/Components/Navigation/NavMeshModifier.cs b/Prowl.Runtime/Components/Navigation/NavMeshModifier.cs
new file mode 100644
index 000000000..215a17dac
--- /dev/null
+++ b/Prowl.Runtime/Components/Navigation/NavMeshModifier.cs
@@ -0,0 +1,49 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System.Collections.Generic;
+
+namespace Prowl.Runtime;
+
+///
+/// Changes how this GameObject (and, by default, its children) contributes to navmesh bakes:
+/// exclude it entirely, or override the area its geometry is stamped with. Mirrors Unity's
+/// NavMeshModifier. Resolved at geometry-collection time — the nearest modifier up the
+/// hierarchy wins, an object's own modifier always beats an inherited one, and a modifier
+/// with off covers only its own object. Changing a modifier
+/// does not rebake anything by itself; rebuild the surface (or the affected tiles) to apply.
+/// One modifier per GameObject: additional NavMeshModifier components on the same object are
+/// ignored (matches Unity).
+///
+[AddComponentMenu("Navigation/NavMesh Modifier")]
+[ComponentIcon("")] // pen ruler
+public class NavMeshModifier : MonoBehaviour
+{
+ [Tooltip("Exclude this object's geometry from navmesh bakes entirely.")]
+ public bool IgnoreFromBuild;
+
+ [Tooltip("Stamp this object's bake geometry with Area instead of the surface's default.")]
+ public bool OverrideArea;
+
+ [Tooltip("The area applied when Override Area is on.")]
+ [NavMeshArea]
+ [EnableIf(nameof(OverrideArea))]
+ public int Area = NavMeshAreas.Walkable;
+
+ [Tooltip("Also apply to child objects. A child's own modifier always takes precedence.")]
+ public bool ApplyToChildren = true;
+
+ [Tooltip("Apply to bakes of every agent type. Turn off to pick specific types.")]
+ public bool AffectAllAgentTypes = true;
+
+ [Tooltip("Agent types whose bakes this modifier affects, when not affecting all.")]
+ [NavMeshAgentType]
+ [EnableIf(nameof(UsesExplicitAgentTypes))]
+ public List AffectedAgentTypeIds = [];
+
+ private bool UsesExplicitAgentTypes => !AffectAllAgentTypes;
+
+ /// Does this modifier apply to bakes for the given agent type?
+ public bool AffectsAgentType(int agentTypeId)
+ => AffectAllAgentTypes || AffectedAgentTypeIds.Contains(agentTypeId);
+}
diff --git a/Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs b/Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs
new file mode 100644
index 000000000..998a9ddf6
--- /dev/null
+++ b/Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs
@@ -0,0 +1,62 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System.Collections.Generic;
+
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+///
+/// Stamps an area over a world region during navmesh bakes, independent of which objects the
+/// geometry came from — mark a danger zone, make a doorway expensive, or (with Not Walkable)
+/// erase walkability inside the box. Mirrors Unity's NavMeshModifierVolume. The volume only
+/// re-marks surface that geometry produced; it never creates walkable surface. Applied during
+/// full bakes AND partial rebuilds whose tiles intersect it; like modifiers, changing a
+/// volume does not rebake anything by itself. When toggling or moving a volume at runtime,
+/// rebuild its ENTIRE footprint region — rebuilding a sub-region leaves the stamp
+/// half-applied in the untouched tiles.
+///
+[AddComponentMenu("Navigation/NavMesh Modifier Volume")]
+[ComponentIcon("")] // cube icon
+public class NavMeshModifierVolume : MonoBehaviour
+{
+ [Tooltip("Volume center, local to this GameObject.")]
+ public Float3 Center;
+
+ [Tooltip("Volume size, local to this GameObject (scaled and rotated by the Transform).")]
+ public Float3 Size = new(4, 3, 4);
+
+ [Tooltip("The area stamped inside the volume. Not Walkable erases walkability (punches a hole).")]
+ [NavMeshArea]
+ public int Area = NavMeshAreas.Walkable;
+
+ [Tooltip("Apply to bakes of every agent type. Turn off to pick specific types.")]
+ public bool AffectAllAgentTypes = true;
+
+ [Tooltip("Agent types whose bakes this volume affects, when not affecting all.")]
+ [NavMeshAgentType]
+ [EnableIf(nameof(UsesExplicitAgentTypes))]
+ public List AffectedAgentTypeIds = [];
+
+ private bool UsesExplicitAgentTypes => !AffectAllAgentTypes;
+
+ /// Does this volume apply to bakes for the given agent type?
+ public bool AffectsAgentType(int agentTypeId)
+ => AffectAllAgentTypes || AffectedAgentTypeIds.Contains(agentTypeId);
+
+ /// The world-space convex prism this volume marks (rotation and scale applied).
+ public NavMeshAreaVolume ComputeAreaVolume()
+ => NavMeshAreaVolume.FromOrientedBox(Transform.LocalToWorldMatrix, Center, Size, Area);
+
+ public override void DrawGizmosSelected()
+ {
+ // Wire box in the same per-area colour the scene-view navmesh overlay uses, drawn
+ // under the full transform so the gizmo shows the same rotated/scaled region the bake
+ // marks (same idiom as BoxCollider.DrawGizmos).
+ Color c = NavMeshSurface.AreaColor(Area);
+ Debug.PushMatrix(Transform.LocalToWorldMatrix);
+ Debug.DrawWireCube(Center, Size * 0.5f, new Color(c.R, c.G, c.B, 1f));
+ Debug.PopMatrix();
+ }
+}
diff --git a/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs
new file mode 100644
index 000000000..2e7f89997
--- /dev/null
+++ b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs
@@ -0,0 +1,477 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+
+using Prowl.Recast.Core.Numerics;
+using Prowl.Recast.Detour.TileCache;
+
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+/// Shape of a .
+public enum NavMeshObstacleShape
+{
+ /// Upright cylinder of the given radius and height.
+ Cylinder,
+ /// Oriented box (yaw only — Detour box obstacles rotate around Y).
+ Box,
+}
+
+///
+/// Blocks agents while enabled — a parked vehicle, a dropped crate, a placed building. Mirrors
+/// Unity's NavMeshObstacle, including both of its modes:
+///
+/// on cuts a hole in the navmesh, so pathfinding routes around it. Affected
+/// tiles rebuild incrementally over the following frames; with
+/// the hole lifts while moving and re-applies once still for .
+///
+/// off is Unity's velocity-obstacle mode: the mesh stays untouched and the
+/// obstacle joins each crowd as an immovable neighbour instead, so agents steer around it
+/// locally. Costs nothing per move — the right mode for something that moves often — but paths
+/// are computed as if it weren't there, so an agent with no other route presses against it.
+///
+/// Either way the object's own geometry stays out of bakes: it's a runtime thing, and
+/// voxelizing it would freeze a hole where it happened to be standing.
+///
+[AddComponentMenu("Navigation/NavMesh Obstacle")]
+[ComponentIcon("")] // road barrier
+// Carving runs in the editor as well as in play, so placing a building shows the hole it will
+// cut without entering play mode. Only the live navmesh is affected — the baked asset never
+// stores carves — and the velocity-obstacle path stays play-only, since it is crowd steering.
+[ExecuteAlways]
+public class NavMeshObstacle : MonoBehaviour
+{
+ [Tooltip("Obstacle shape. Both stand upright: a cylinder has no tilt, and a box is oriented by yaw only.")]
+ public NavMeshObstacleShape Shape = NavMeshObstacleShape.Box;
+
+ [Tooltip("Obstacle center, local to this GameObject.")]
+ public Float3 Center;
+
+ [Tooltip("Box size, local (scaled by the Transform).")]
+ [ShowIf(nameof(IsBox))]
+ public Float3 Size = new(1, 1, 1);
+
+ [Tooltip("Cylinder radius (scaled by the largest horizontal Transform scale).")]
+ [ShowIf(nameof(IsCylinder))]
+ public float Radius = 0.5f;
+
+ [Tooltip("Cylinder height (scaled by the vertical Transform scale).")]
+ [ShowIf(nameof(IsCylinder))]
+ public float Height = 2f;
+
+ [Tooltip("On: cut a hole in the navmesh so paths route around this. Off: leave the mesh alone and make agents steer around it locally instead — cheaper, and the right choice for something that moves, but paths still lead through it.")]
+ public bool Carve = true;
+
+ [Tooltip("Only carve while stationary: the carve lifts while the obstacle moves and re-applies once it has settled. Off re-carves on every move beyond the threshold — much more expensive for frequently-moving obstacles.")]
+ public bool CarveOnlyStationary = true;
+
+ [Tooltip("Movement beyond this distance (world units) counts as moving.")]
+ public float CarvingMoveThreshold = 0.1f;
+
+ [Tooltip("Seconds the obstacle must be still before it carves again (with Carve Only Stationary).")]
+ public float CarvingTimeToStationary = 0.5f;
+
+ private bool IsBox => Shape == NavMeshObstacleShape.Box;
+ private bool IsCylinder => Shape == NavMeshObstacleShape.Cylinder;
+
+ private NavMeshWorld? _world;
+ // Obstacle handle per navmesh instance the carve is registered with. Instances are
+ // per-agent-type; the obstacle applies to every one of them (Unity has no agent filter on
+ // obstacles).
+ private readonly Dictionary _refs = [];
+ private Float3 _appliedPosition;
+ private float _stillTime;
+ private bool _carveApplied;
+
+ // Velocity-obstacle mode: one immovable agent per live crowd, re-pinned every frame.
+ private readonly Dictionary _blockers = [];
+ private int _blockerCrowdCount = -1;
+ private float _blockerRadius, _blockerHeight;
+ private bool _warnedBlockerUnplaced;
+
+ // Geometry the live carve was registered with, re-checked each LateUpdate so writing the
+ // public fields after AddComponent (spawn-then-configure) re-carves — the same drift-check
+ // pattern as NavMeshAgent's AgentTypeId/AreaMask. Rotation compares quaternions by dot
+ // product: no per-frame Euler conversion, no wrap false-positives at ±180°.
+ private NavMeshObstacleShape _appliedShape;
+ private Float3 _appliedSize;
+ private float _appliedRadius, _appliedHeight;
+ private Quaternion _appliedRotation = Quaternion.Identity;
+
+ private void CaptureAppliedGeometry()
+ {
+ _appliedShape = Shape;
+ _appliedSize = Size;
+ _appliedRadius = Radius;
+ _appliedHeight = Height;
+ _appliedRotation = Transform.Rotation;
+ }
+
+ private bool GeometryChanged()
+ => Shape != _appliedShape
+ || !Size.Equals(_appliedSize)
+ || Radius != _appliedRadius
+ || Height != _appliedHeight
+ || (Shape == NavMeshObstacleShape.Box
+ && Math.Abs(Quaternion.Dot(Transform.Rotation, _appliedRotation)) < 0.9999);
+
+ public override void OnEnable()
+ {
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ if (scene.IsValid())
+ {
+ _world = scene!.Navigation;
+ _world.NavMeshChanged += OnNavMeshChanged;
+ }
+
+ _appliedPosition = Transform.Position;
+ _stillTime = CarvingTimeToStationary; // spawning still: carve immediately
+ TryApplyCarve();
+ }
+
+ public override void OnDisable()
+ {
+ RemoveCarve();
+ RemoveBlockers();
+ if (_world != null)
+ {
+ _world.NavMeshChanged -= OnNavMeshChanged;
+ _world = null;
+ }
+ }
+
+ private void OnNavMeshChanged()
+ {
+ // An instance may have been replaced (rebake) or registered late; dead entries are
+ // dropped and the carve re-applies to any new instance on the next LateUpdate. Gated on
+ // the structural counter because the event also fires every frame a carve is converging
+ // — including this obstacle's own — which would make each carve pay for itself repeatedly.
+ if (_world == null || _world.StructureGeneration == _seenStructureGeneration) return;
+ _seenStructureGeneration = _world.StructureGeneration;
+ _refsPruneNeeded = true;
+ }
+
+ private bool _refsPruneNeeded;
+ private int _seenStructureGeneration = -1;
+
+ public override void LateUpdate()
+ {
+ if (!Carve)
+ {
+ // Switching modes at runtime must not leave the other mode's effect behind.
+ if (_carveApplied) RemoveCarve();
+ // Velocity mode is crowd steering, and crowds only step during play — outside it a
+ // blocker would sit in a crowd nothing is running.
+ if (Application.IsPlaying) UpdateBlockers();
+ else if (_blockers.Count > 0) RemoveBlockers();
+ return;
+ }
+ // The other half of that: a blocker left over from velocity mode would sit inside the
+ // hole this obstacle carves, avoided by agents already routing around it.
+ if (_blockers.Count > 0) RemoveBlockers();
+
+ // Geometry drift MUST be evaluated before the new-instance pickup below: TryApplyCarve
+ // captures the applied-geometry snapshot, so running the pickup first (its flag is set
+ // by any NavMeshChanged — including the cache pump's own convergence events) would
+ // record the new field values without re-carving and swallow the drift for good.
+ if (_carveApplied && GeometryChanged())
+ {
+ RemoveCarve();
+ TryApplyCarve();
+ }
+
+ if (_refsPruneNeeded)
+ {
+ _refsPruneNeeded = false;
+ PruneDeadInstances();
+ if (_carveApplied) TryApplyCarve(); // pick up newly registered navmeshes
+ }
+
+ double moved = Float3.Distance(Transform.Position, _appliedPosition);
+ if (CarveOnlyStationary)
+ {
+ if (moved > CarvingMoveThreshold)
+ {
+ // Moving: lift the carve and restart the settle timer.
+ RemoveCarve();
+ _appliedPosition = Transform.Position;
+ _stillTime = 0f;
+ }
+ else if (!_carveApplied)
+ {
+ _stillTime += (float)Time.DeltaTime;
+ if (_stillTime >= CarvingTimeToStationary)
+ TryApplyCarve();
+ }
+ }
+ else if (moved > CarvingMoveThreshold)
+ {
+ // Follow mode: re-carve at the new position. Every move pays incremental tile
+ // rebuilds, hence the tooltip's warning.
+ RemoveCarve();
+ _appliedPosition = Transform.Position;
+ TryApplyCarve();
+ }
+ }
+
+ ///
+ /// Velocity-obstacle mode: keep one immovable agent per live crowd sitting on this
+ /// obstacle, so every other agent's local avoidance treats it as a neighbour to steer
+ /// around. Crowds appear lazily (when the first agent of a type registers), so membership
+ /// is re-derived whenever the world's crowd count changes rather than only at enable.
+ ///
+ private void UpdateBlockers()
+ {
+ if (_world == null) return;
+
+ // LossyScale walks the parent chain, so the shape is measured once per frame and passed
+ // down rather than re-derived by each helper.
+ Float3 scale = Transform.LossyScale;
+ float radius = BlockerRadius(scale);
+ float height = BlockerHeight(scale);
+ bool resized = Math.Abs(radius - _blockerRadius) > 1e-4f || Math.Abs(height - _blockerHeight) > 1e-4f;
+ if (_blockerCrowdCount != _world.CrowdCount || resized || _refsPruneNeeded)
+ {
+ _refsPruneNeeded = false;
+ _blockerRadius = radius;
+ _blockerHeight = height;
+ RefreshBlockers(radius, height);
+ }
+
+ // Write the position straight onto the crowd agent every frame: a crowd agent is
+ // normally moved by its own steering, which a blocker has none of, so this is what makes
+ // it follow the Transform. It also absorbs the crowd's collision-resolution displacement
+ // (measured under a centimetre even under pressure, but costs nothing to be exact).
+ Float3 position = BlockerPosition(height);
+ var pinned = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z);
+ foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker in _blockers.Values)
+ blocker.npos = pinned;
+ }
+
+ /// Join every live crowd not already blocked, and drop memberships whose crowd
+ /// died with its navmesh.
+ private void RefreshBlockers(float radius, float height)
+ {
+ if (_world == null) return;
+
+ Float3 position = BlockerPosition(height);
+ var rcPosition = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z);
+ foreach (NavMeshAgentType type in NavMeshAgentTypes.All)
+ {
+ Prowl.Recast.Detour.Crowd.DtCrowd? crowd = _world.GetNativeCrowd(type.Id);
+ if (crowd == null) continue;
+ if (_blockers.TryGetValue(crowd, out Prowl.Recast.Detour.Crowd.DtCrowdAgent? existing))
+ {
+ crowd.UpdateAgentParameters(existing, BlockerParams(radius, height));
+ continue;
+ }
+
+ Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker = crowd.AddAgent(rcPosition, BlockerParams(radius, height));
+ _blockers[crowd] = blocker;
+ WarnIfUnplaced(blocker);
+ }
+
+ // A crowd the world no longer owns died with its navmesh; its agents went with it.
+ List? dead = null;
+ foreach (Prowl.Recast.Detour.Crowd.DtCrowd crowd in _blockers.Keys)
+ {
+ bool live = false;
+ foreach (NavMeshAgentType type in NavMeshAgentTypes.All)
+ if (ReferenceEquals(_world.GetNativeCrowd(type.Id), crowd)) { live = true; break; }
+ if (!live) (dead ??= []).Add(crowd);
+ }
+ if (dead != null)
+ foreach (Prowl.Recast.Detour.Crowd.DtCrowd crowd in dead)
+ _blockers.Remove(crowd);
+
+ _blockerCrowdCount = _world.CrowdCount;
+ }
+
+ private void RemoveBlockers()
+ {
+ foreach ((Prowl.Recast.Detour.Crowd.DtCrowd crowd, Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker) in _blockers)
+ crowd.RemoveAgent(blocker);
+ _blockers.Clear();
+ _blockerCrowdCount = -1;
+ }
+
+ ///
+ /// A blocker that failed to place is an invisible failure: the component looks configured
+ /// and nothing avoids it. The crowd's placement probe searches only a few units vertically
+ /// (sized from ), so an obstacle floating well
+ /// above the walkable surface — a tall Center offset, spawned mid-air — lands invalid.
+ ///
+ private void WarnIfUnplaced(Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker)
+ {
+ if (blocker.state != Prowl.Recast.Detour.Crowd.DtCrowdAgentState.DT_CROWDAGENT_STATE_INVALID) return;
+ if (_warnedBlockerUnplaced) return;
+ _warnedBlockerUnplaced = true;
+ Debug.LogWarning($"[Navigation] NavMeshObstacle '{GameObject.Name}' could not place its avoidance blocker: no navmesh near its base. Agents will not steer around it. Move the obstacle onto the navmesh (check Center and the object's height), or raise NavMeshWorld.CrowdMaxAgentRadius to widen the placement search.");
+ }
+
+ /// Where the blocker stands: the obstacle's footprint centre at its base, matching
+ /// how agents sit on the mesh at their feet.
+ private Float3 BlockerPosition(float height)
+ {
+ Float3 center = Transform.TransformPoint(Center);
+ return new Float3(center.X, center.Y - height * 0.5f, center.Z);
+ }
+
+ // The shape an obstacle occupies once the Transform's scale is applied. The carve, the
+ // avoidance blocker and the gizmo all describe the same volume and have to agree on it, so
+ // each shape's dimensions are worked out in exactly one place.
+
+ /// Radius by the larger horizontal scale, height by the vertical one.
+ private (float Radius, float Height) ScaledCylinder(Float3 scale) => (
+ Radius * MathF.Max(0.01f, MathF.Max(Math.Abs(scale.X), Math.Abs(scale.Z))),
+ Height * MathF.Max(0.01f, Math.Abs(scale.Y)));
+
+ /// Per-axis, and without the carve's horizontal clearance — that is a navmesh
+ /// concern (an agent's centre must clear the box, not just its body) rather than part of
+ /// the shape the user authored.
+ private Float3 ScaledBoxHalfExtents(Float3 scale) => new(
+ Size.X * 0.5f * Math.Abs(scale.X),
+ Size.Y * 0.5f * Math.Abs(scale.Y),
+ Size.Z * 0.5f * Math.Abs(scale.Z));
+
+ /// Avoidance is circle-based, so a box is approximated by the circle enclosing its
+ /// footprint — agents give a rotated crate a slightly wider berth than its corners need.
+ private float BlockerRadius(Float3 scale)
+ {
+ if (Shape == NavMeshObstacleShape.Cylinder)
+ return MathF.Max(0.01f, ScaledCylinder(scale).Radius);
+
+ Float3 half = ScaledBoxHalfExtents(scale);
+ return MathF.Max(0.01f, MathF.Sqrt(half.X * half.X + half.Z * half.Z));
+ }
+
+ private float BlockerHeight(Float3 scale)
+ => MathF.Max(0.01f, Shape == NavMeshObstacleShape.Cylinder
+ ? ScaledCylinder(scale).Height
+ : ScaledBoxHalfExtents(scale).Y * 2f);
+
+ private Prowl.Recast.Detour.Crowd.DtCrowdAgentParams BlockerParams(float radius, float height) => new()
+ {
+ radius = radius,
+ height = height,
+ // Immovable: no steering of its own, and no update flags, so the crowd never tries to
+ // path, avoid or separate on its behalf. It exists purely to be avoided — which is also
+ // why its own neighbour query is kept to its radius rather than the multiple a steering
+ // agent needs: the crowd still runs that query every frame, and nothing consumes it.
+ maxAcceleration = 0f,
+ maxSpeed = 0f,
+ collisionQueryRange = radius,
+ pathOptimizationRange = 0f,
+ updateFlags = 0,
+ obstacleAvoidanceType = 0,
+ separationWeight = 0f,
+ queryFilterType = 0,
+ userData = this,
+ };
+
+ /// Queue the carve on every registered navmesh not already carrying it. Queued onto
+ /// the cache directly rather than through , because
+ /// nothing here touches the mesh but the request queue: the tiles rebuild in the pump, which
+ /// is where the write lock and the change notification belong.
+ private void TryApplyCarve()
+ {
+ if (_world == null || !Carve) return;
+
+ foreach (NavMeshAgentType type in NavMeshAgentTypes.All)
+ {
+ NavMeshInstance? instance = _world.GetInstance(type.Id);
+ if (instance == null || _refs.ContainsKey(instance)) continue;
+ long obstacleRef = AddToCache(instance.TileCache, instance.NavMeshData.Settings.AgentRadius);
+ if (obstacleRef == 0) continue;
+ _refs[instance] = obstacleRef;
+ instance.MarkCachePending();
+ }
+ _carveApplied = true;
+ CaptureAppliedGeometry();
+ }
+
+ // No try/catch: verified against Prowl.Recast — AllocObstacle grows its pool and
+ // the request queue is an unbounded list, so Add*Obstacle never throws and never returns
+ // 0 for capacity (maxObstacles only sizes the initial id encoding).
+ /// Envelope of the navmesh being carved. The hole is widened by it
+ /// because a navmesh stores where an agent's CENTRE may be, not where its body fits: a bake
+ /// pulls the mesh this far back from every wall, and a carve that did not would let agents
+ /// walk their centre onto the obstacle's surface and stand half inside it.
+ private long AddToCache(DtTileCache cache, float agentRadius)
+ {
+ Float3 scale = Transform.LossyScale;
+ Float3 worldCenter = Transform.TransformPoint(Center);
+ float clearance = Math.Max(0f, agentRadius);
+
+ if (Shape == NavMeshObstacleShape.Cylinder)
+ {
+ (float radius, float height) = ScaledCylinder(scale);
+ // Cylinder obstacles anchor at the base center.
+ var basePos = new RcVec3f((float)worldCenter.X, (float)(worldCenter.Y - height * 0.5f), (float)worldCenter.Z);
+ return cache.AddObstacle(basePos, radius + clearance, height);
+ }
+
+ // Clearance horizontally only: erosion is a footprint concern, and growing the box
+ // vertically would start carving under things the obstacle passes beneath.
+ Float3 half = ScaledBoxHalfExtents(scale);
+ var halfExtents = new RcVec3f(half.X + clearance, half.Y, half.Z + clearance);
+ float yawRadians = (float)(Transform.Rotation.EulerAngles.Y * Maths.Deg2Rad);
+ return cache.AddBoxObstacle(new RcVec3f((float)worldCenter.X, (float)worldCenter.Y, (float)worldCenter.Z), halfExtents, yawRadians);
+ }
+
+ /// Queue removal of the carve everywhere it is registered.
+ private void RemoveCarve()
+ {
+ foreach ((NavMeshInstance instance, long obstacleRef) in _refs)
+ {
+ instance.TileCache.RemoveObstacle(obstacleRef);
+ instance.MarkCachePending();
+ }
+ _refs.Clear();
+ _carveApplied = false;
+ }
+
+ /// Drop handles whose instance is no longer registered — the cache (and its
+ /// obstacle state) died with it, so there is nothing to remove.
+ private void PruneDeadInstances()
+ {
+ if (_refs.Count == 0 || _world == null) return;
+ List? dead = null;
+ foreach (NavMeshInstance instance in _refs.Keys)
+ {
+ if (_world.GetInstance(instance.AgentTypeId) != instance)
+ (dead ??= []).Add(instance);
+ }
+ if (dead != null)
+ foreach (NavMeshInstance instance in dead)
+ _refs.Remove(instance);
+ }
+
+ ///
+ /// The volume this obstacle actually carves, which is not the volume its Transform describes:
+ /// both shapes stand upright however the object is pitched or rolled, because Detour orients a
+ /// box obstacle by yaw alone. Drawing the full transform would promise a tilt the navmesh
+ /// never cuts.
+ ///
+ public override void DrawGizmosSelected()
+ {
+ var color = new Color(1f, 0.5f, 0.1f, 1f);
+ Float3 scale = Transform.LossyScale;
+ Float3 worldCenter = Transform.TransformPoint(Center);
+
+ if (Shape == NavMeshObstacleShape.Cylinder)
+ {
+ (float radius, float height) = ScaledCylinder(scale);
+ Debug.DrawWireCylinder(worldCenter, Quaternion.Identity, radius, height, color);
+ return;
+ }
+
+ var yaw = Quaternion.FromEuler(new Float3(0, Transform.Rotation.EulerAngles.Y, 0));
+ Debug.PushMatrix(Float4x4.CreateTRS(worldCenter, yaw, Float3.One));
+ Debug.DrawWireCube(Float3.Zero, ScaledBoxHalfExtents(scale), color);
+ Debug.PopMatrix();
+ }
+}
diff --git a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs
new file mode 100644
index 000000000..02d217802
--- /dev/null
+++ b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs
@@ -0,0 +1,848 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Prowl.Recast.Detour;
+using Prowl.Recast.Detour.TileCache;
+
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+/// Which objects a bake considers.
+public enum NavMeshCollectObjects
+{
+ /// Every active object in the scene.
+ All,
+ /// Every active object whose geometry intersects the surface's volume
+ /// ( / ).
+ Volume,
+ /// Only this GameObject and its children.
+ Children,
+}
+
+///
+/// Bakes and registers a navmesh for one agent type. The baked result is a standalone
+/// asset; at runtime the surface registers it with the scene's
+/// on enable. Rebuilds can run synchronously, in the background
+/// ( ), or per-tile for localized geometry changes
+/// ( — destructible worlds rebuild only what changed).
+///
+// Registration is the whole of this component's lifecycle — there is no per-frame work — and the
+// editor needs it as much as play does: an obstacle can only carve a live navmesh, and the scene
+// view's overlay draws one. Without this, opening a scene leaves its baked navmesh unregistered
+// until something bakes again, so carve previews only work in the session you pressed Bake in.
+[ExecuteAlways]
+[AddComponentMenu("Navigation/NavMesh Surface")]
+[ComponentIcon("")] // map icon
+public class NavMeshSurface : MonoBehaviour
+{
+ [Header("Bake")]
+ [Tooltip("The agent type this navmesh is built for (radius, height, slope, climb come from the project's agent table). Agents only use navmeshes of their own type.")]
+ [NavMeshAgentType]
+ public int AgentTypeId = NavMeshAgentTypes.Humanoid;
+
+ [Tooltip("Surface-level rasterization settings (voxel/tile sizes and Recast detail). Most bakes never need to change these.")]
+ [HideInInspector] // drawn inside the editor's Advanced foldout
+ public NavMeshBuildOverrides BuildOverrides = new();
+
+ /// The resolved bake input: the agent type's envelope composed with this
+ /// surface's . What gets handed to
+ /// ; a fresh snapshot each call.
+ public NavMeshBuildSettings ResolveBuildSettings()
+ => NavMeshAgentTypes.GetBuildSettings(AgentTypeId, BuildOverrides);
+
+ [Tooltip("Which objects contribute bake geometry. NavMeshAgents and their children never contribute — agents walk the mesh rather than forming it.")]
+ public NavMeshCollectObjects CollectObjects = NavMeshCollectObjects.All;
+
+ [Tooltip("Volume center (local to this GameObject) when CollectObjects is Volume.")]
+ [ShowIf(nameof(IsVolumeMode))]
+ public Float3 Center;
+
+ [Tooltip("Volume size when CollectObjects is Volume.")]
+ [ShowIf(nameof(IsVolumeMode))]
+ public Float3 Size = new(10, 10, 10);
+
+ [Tooltip("Only objects on these layers contribute bake geometry.")]
+ public LayerMask Layers = LayerMask.Everything;
+
+ [Tooltip("Voxelize render meshes or physics colliders.")]
+ public NavMeshCollectGeometry UseGeometry = NavMeshCollectGeometry.RenderMeshes;
+
+ [Tooltip("Area applied to all walkable geometry in this bake.")]
+ [NavMeshArea]
+ [HideInInspector] // drawn inside the editor's Advanced foldout (Unity keeps it there too)
+ public int DefaultArea = NavMeshAreas.Walkable;
+
+ [Tooltip("The baked navmesh. Assigned by baking, or point it at an existing .navmesh asset.")]
+ public AssetRef NavMeshData;
+
+ [Tooltip("Draw the walkable surface in the scene view even when this object is not selected — the only way to watch obstacles carve while playing, since entering play mode clears the selection. Debug aid: it re-triangulates the whole mesh on every frame a carve or rebuild is converging, so leave it off in scenes you are profiling.")]
+ public bool AlwaysShowNavMesh;
+
+ private NavMeshInstance? _instance;
+ private Runtime.NavMeshData? _runtimeData;
+ private bool IsVolumeMode => CollectObjects == NavMeshCollectObjects.Volume;
+
+ /// The live navmesh registration, while enabled and a navmesh is loaded.
+ public NavMeshInstance? Instance => _instance;
+
+ ///
+ /// What the live navmesh was built from, and what rebuilds rewrite. Null while unregistered.
+ /// For a .navmesh asset this is a private copy made at registration, because the object
+ /// the database hands out is shared by every surface pointing at it and by the next scene that
+ /// loads it. For a navmesh built at runtime and handed over through
+ /// it is that object itself — nothing else owns it.
+ ///
+ public Runtime.NavMeshData? RuntimeData => _runtimeData;
+
+ /// The scene's navigation world, or null when not in a scene.
+ private NavMeshWorld? World
+ {
+ get
+ {
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ return scene.IsValid() ? scene.Navigation : null;
+ }
+ }
+
+ public override void OnEnable()
+ {
+ World?.RegisterSurface(this);
+ Register();
+ }
+
+ public override void OnDisable()
+ {
+ World?.UnregisterSurface(this);
+ Unregister();
+
+ // Release the debug-overlay subscription; without this every surface ever selected
+ // stays referenced by the scene's NavMeshWorld until scene teardown.
+ if (_debugWorld != null)
+ {
+ _debugWorld.NavMeshChanged -= InvalidateDebugTriangulation;
+ _debugWorld = null;
+ }
+ _debugTriangulation = null;
+ }
+
+ private void Register()
+ {
+ if (_instance != null) return;
+ NavMeshWorld? world = World;
+ if (world == null) return;
+
+ // The navmesh has to be present now: registration happens once on enable and nothing
+ // retries it — a transient null from async streaming would leave the scene permanently
+ // without one. Block-load it, as the mesh and terrain colliders do for the same reason.
+ NavMeshData.EnsureLoaded();
+
+ Runtime.NavMeshData? data = NavMeshData.Res;
+ if (data.IsNotValid() || !data!.HasTiles) return;
+
+ // Copy only what the asset database owns. A .navmesh asset is shared with every other
+ // surface pointing at it and with the next scene that loads it, so runtime tile and link
+ // rewrites must not land on it. One built at runtime and handed over through
+ // ApplyNavMeshData has no other owner — copying it would just cost a list per
+ // registration and throw away every rebuild since the original bake on re-registering.
+ // (Handing one runtime navmesh to two surfaces still shares it, as it always has.)
+ _runtimeData = NavMeshData.AssetID == Guid.Empty ? data : data.Clone();
+ _instance = world.AddNavMeshData(_runtimeData);
+ if (_instance == null) _runtimeData = null;
+ }
+
+ private void Unregister()
+ {
+ if (_instance == null) return;
+ World?.RemoveNavMeshData(_instance);
+ _instance = null;
+ _runtimeData = null;
+ }
+
+ #region Building
+
+ ///
+ /// Collect geometry and bake the navmesh synchronously, then (re)register it with the
+ /// scene. Blocks the calling thread for the duration of the bake — prefer
+ /// during gameplay.
+ ///
+ public bool BuildNavMesh()
+ {
+ NavMeshBuildSettings settings = ResolveBuildSettings(); // one resolve per bake: collection and build must agree
+ List sources = CollectSources(null, settings.EffectiveVoxelSize);
+ Runtime.NavMeshData? data = NavMeshBuilder.Build(settings, sources, DefaultArea,
+ threads: Math.Max(1, Environment.ProcessorCount - 1), worldBounds: ExplicitWorldBounds(),
+ volumes: CollectVolumes(null), links: CollectLinks(null));
+ if (data == null)
+ {
+ Debug.LogWarning($"[Navigation] Bake of '{GameObject.Name}' produced no walkable geometry.");
+ return false;
+ }
+
+ ApplyNavMeshData(data);
+ return true;
+ }
+
+ /// In Volume mode the volume is an explicit statement of the bake's extent, so
+ /// the tile grid spans it even where no geometry exists yet (rooms opening up later can be
+ /// added via ).
+ private AABB? ExplicitWorldBounds()
+ => CollectObjects == NavMeshCollectObjects.Volume ? VolumeBounds : null;
+
+ /// World-space extent of the Volume-mode box.
+ private AABB VolumeBounds => AABB.FromCenterAndSize(Transform.TransformPoint(Center), Size);
+
+ ///
+ /// Bake in the background: geometry is collected on the calling (main) thread, the
+ /// voxelization runs on the thread pool. Apply the result with
+ /// from the main thread when the task completes.
+ ///
+ public Task BuildNavMeshAsync(CancellationToken cancellation = default)
+ {
+ NavMeshBuildSettings settings = ResolveBuildSettings(); // resolved on the main thread, once per bake
+ List sources = CollectSources(null, settings.EffectiveVoxelSize);
+ List volumes = CollectVolumes(null); // main thread: touches Transforms
+ List links = CollectLinks(null);
+ int defaultArea = DefaultArea;
+ AABB? worldBounds = ExplicitWorldBounds();
+ return Task.Run(() => NavMeshBuilder.Build(settings, sources, defaultArea,
+ threads: Math.Max(1, Environment.ProcessorCount - 1), cancellation, worldBounds, volumes, links), cancellation);
+ }
+
+ ///
+ /// Swap in a freshly built navmesh: replaces this surface's data (as a runtime resource)
+ /// and its registration in the scene. Main thread only.
+ ///
+ public void ApplyNavMeshData(NavMeshData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ Unregister();
+ NavMeshData = data;
+ Register();
+ }
+
+ /// Re-register the currently assigned (e.g. after the
+ /// asset reference was swapped by an editor bake).
+ public void RefreshRegistration()
+ {
+ Unregister();
+ Register();
+ }
+
+ ///
+ /// Rebuild only the tiles intersecting against the current
+ /// scene geometry (via this surface's collectors) and swap them into the live navmesh —
+ /// cost scales with the changed volume, not the map size. The tile grid stays anchored to
+ /// the original bake, so geometry outside the original bounds needs a full
+ /// . Requires an enabled surface with a registered navmesh.
+ ///
+ public bool RebuildTiles(AABB worldBounds)
+ {
+ Runtime.NavMeshData? data = _runtimeData;
+ if (data.IsNotValid()) return false;
+ // Collection (terrain decimation) uses the BAKED voxel size, same as the tiles being
+ // rebuilt — the current agent table may disagree with the bake this grid came from.
+ AABB? collectionBounds = RebuildCollectionBounds(worldBounds);
+ return RebuildTiles(worldBounds,
+ CollectSources(collectionBounds, data!.Settings.EffectiveVoxelSize), out _,
+ CollectVolumes(collectionBounds));
+ }
+
+ ///
+ /// Re-collect this surface's s into the live navmesh and rebuild
+ /// the tiles overlapping so the change takes effect. Cheap
+ /// next to a geometry rebuild: the compressed layers are untouched and only the affected
+ /// tiles are re-contoured from them, with the new link set injected — where
+ /// has to re-voxelize. Returns false when this surface has
+ /// no live navmesh.
+ ///
+ /// Region whose tiles pick up the change — normally the link's
+ /// endpoints. The registry is replaced wholesale; only these tiles re-contour.
+ /// The surface's complete link set. Null (the default) collects the
+ /// scene's s; games driving navigation from explicit sources pass
+ /// their own list and skip the scene scan.
+ public bool RebuildLinkTiles(AABB worldBounds, IReadOnlyList? links = null)
+ => RebuildLinkTiles([worldBounds], links);
+
+ ///
+ /// Regions whose tiles pick up the change. A tile several of them
+ /// cover re-contours once — which is the whole point of handing a frame's link edits over
+ /// together rather than applying them one at a time.
+ /// The surface's complete link set. Null (the default) collects the
+ /// scene's s; games driving navigation from explicit sources pass
+ /// their own list and skip the collection.
+ public bool RebuildLinkTiles(ReadOnlySpan worldBounds, IReadOnlyList? links = null)
+ {
+ NavMeshInstance? instance = Instance;
+ Runtime.NavMeshData? data = _runtimeData;
+ if (instance == null || data.IsNotValid()) return false;
+ if (data!.TileWorldSize <= 0) return false;
+
+ links ??= CollectLinks(null);
+ NavMeshWorld? world = World;
+ if (world == null) return false;
+
+ // Resolved before the write lock is taken, so worker-thread queries do not block on it.
+ HashSet<(int X, int Z)> tiles = [];
+ foreach (AABB bounds in worldBounds)
+ if (data.TryGetTileRange(bounds, out int tx0, out int tx1, out int tz0, out int tz1))
+ for (int tz = tz0; tz <= tz1; tz++)
+ for (int tx = tx0; tx <= tx1; tx++)
+ tiles.Add((tx, tz));
+
+ world.MutateTileCache(instance, cache =>
+ {
+ // Always replace the link set, even with no tiles in range: it is what tiles rebuilt
+ // later — by a carve, or by a rebuild of a neighbouring region — will be built from.
+ instance.TileCacheLinks.SetLinks(links, data.Settings.AgentRadius);
+
+ foreach ((int tx, int tz) in tiles)
+ foreach (long tileRef in cache.GetTilesAt(tx, tz))
+ cache.BuildNavMeshTile(tileRef);
+ });
+
+ // Mirror onto the runtime copy, so a rebuild that re-instantiates it starts from the
+ // link set the live mesh is using. The .navmesh asset is left alone: a link moving is a
+ // scene edit, and the baked artifact answers for it at the next bake.
+ data.Links.Clear();
+ foreach (NavMeshLinkSource link in links)
+ data.Links.Add(Runtime.NavMeshData.NavMeshLinkEntry.From(link));
+ return true;
+ }
+
+ ///
+ /// World rect the collectors must cover for a rebuild of :
+ /// the affected TILES (rebuilds rasterize whole tiles, so sources clipped to just the
+ /// changed AABB would erase the rest of a partially-covered tile) plus the erosion border.
+ ///
+ private AABB? RebuildCollectionBounds(AABB worldBounds)
+ {
+ Runtime.NavMeshData? data = NavMeshData.Res;
+ if (data.IsNotValid() || data!.TileWorldSize <= 0) return null; // no grid: collect everything
+
+ float ts = data.TileWorldSize;
+ // Conservative world-space erosion border (CalcBorder cells = ceil(radius/cs) + 3).
+ // Derived from the ASSET's snapshot settings — the grid being rebuilt is the one the
+ // asset was baked with, not whatever the surface's current configuration says.
+ float border = data.Settings.AgentRadius + 4f * data.Settings.EffectiveVoxelSize;
+
+ double minTx = Math.Floor((worldBounds.Min.X - border - data.Origin.X) / ts);
+ double maxTx = Math.Floor((worldBounds.Max.X + border - data.Origin.X) / ts);
+ double minTz = Math.Floor((worldBounds.Min.Z - border - data.Origin.Z) / ts);
+ double maxTz = Math.Floor((worldBounds.Max.Z + border - data.Origin.Z) / ts);
+
+ return new AABB(
+ new Float3((float)(data.Origin.X + minTx * ts - border), (float)data.BoundsMin.Y - 1, (float)(data.Origin.Z + minTz * ts - border)),
+ new Float3((float)(data.Origin.X + (maxTx + 1) * ts + border), (float)data.BoundsMax.Y + 1, (float)(data.Origin.Z + (maxTz + 1) * ts + border)));
+ }
+
+ ///
+ /// Rebuild the tiles intersecting from caller-supplied
+ /// geometry, for games whose world isn't visible to the collectors (custom renderers,
+ /// custom collision). Pass the bounds of the CHANGED geometry — the affected tile set is
+ /// derived from them, expanded by the erosion border. Sources need only cover those tiles
+ /// plus the border (derive from and
+ /// ) — never the whole bake.
+ /// An empty source list is valid and empties the affected tiles.
+ ///
+ public bool RebuildTiles(AABB worldBounds, IReadOnlyList sources)
+ => RebuildTiles(worldBounds, sources, out _);
+
+ ///
+ /// Number of tiles rebuilt or emptied, for cost profiling.
+ /// Area volumes applied to the rebuilt tiles. Null (the default)
+ /// collects the scene's s over the affected region —
+ /// note that collection walks the scene's active objects, so callers who chose explicit
+ /// sources to avoid scene scans should pass an empty list (no volumes, no scan) or their
+ /// own list.
+ public bool RebuildTiles(AABB worldBounds, IReadOnlyList sources, out int rebuiltTiles,
+ IReadOnlyList? volumes = null)
+ {
+ rebuiltTiles = 0;
+ Runtime.NavMeshData? data = _runtimeData;
+ if (World == null || _instance == null || data.IsNotValid())
+ return false;
+
+ volumes ??= CollectVolumes(RebuildCollectionBounds(worldBounds));
+ List<(int X, int Z, List Layers)> rebuilt = NavMeshBuilder.BuildTilesInBounds(
+ data!, sources, worldBounds.Min, worldBounds.Max, DefaultArea, volumes: volumes);
+ return ApplyRebuiltTiles(rebuilt, out rebuiltTiles);
+ }
+
+ ///
+ /// Voxelize the affected tiles on the thread pool, off the frame. The returned tiles are
+ /// NOT yet live — apply them with from the main thread.
+ /// Sequencing rules: do not run two rebuilds of overlapping regions concurrently, and do
+ /// not interleave with a full rebake — the build reads the asset's grid anchoring and the
+ /// apply assumes it is unchanged since dispatch.
+ ///
+ public Task Layers)>> RebuildTilesAsync(
+ AABB worldBounds, IReadOnlyList sources, CancellationToken cancellation = default,
+ IReadOnlyList? volumes = null)
+ {
+ // The surface's own copy, not the asset: the background build reads the tile grid off
+ // it, and it must be the grid the live mesh is on.
+ Runtime.NavMeshData? data = _runtimeData;
+ if (data.IsNotValid())
+ return Task.FromResult(new List<(int, int, List)>());
+
+ // Volumes are collected on the calling (main) thread — they touch Transforms; the
+ // resulting payload is self-contained for the background build. The null default scans
+ // the scene's active objects — explicit-sources callers who avoid scene scans on
+ // purpose should pass [] (or their own list) instead.
+ volumes ??= CollectVolumes(RebuildCollectionBounds(worldBounds));
+ int defaultArea = DefaultArea;
+ return Task.Run(() => NavMeshBuilder.BuildTilesInBounds(
+ data!, sources, worldBounds.Min, worldBounds.Max, defaultArea, cancellation, volumes), cancellation);
+ }
+
+ ///
+ /// Swap rebuilt tiles (from or
+ /// ) into the live TileCache and mirror them
+ /// into the asset. Main thread only. The swap quiesces pending obstacle work, replaces each
+ /// tile's layers (the cache's RemoveTile doesn't remove the paired navmesh tile, so that's
+ /// done explicitly), refreshes every obstacle's touched-tile list (stale after a tile
+ /// replacement bumps its salt), then rebuilds the new tiles with carves re-applied.
+ ///
+ public bool ApplyRebuiltTiles(List<(int X, int Z, List Layers)> rebuilt, out int rebuiltTiles)
+ {
+ ArgumentNullException.ThrowIfNull(rebuilt);
+ rebuiltTiles = 0;
+ NavMeshWorld? world = World;
+ Runtime.NavMeshData? data = _runtimeData;
+ if (world == null || _instance == null || data.IsNotValid() || rebuilt.Count == 0)
+ return false;
+ rebuiltTiles = rebuilt.Count;
+
+ world.MutateTileCache(_instance, cache =>
+ {
+ // Quiesce: every obstacle settles and no pending rebuild references the tiles
+ // being replaced. Bounded — a healthy cache converges in a handful of slices. On
+ // exhaustion the refresh below skips unsettled obstacles, which is exactly the
+ // stale-carve failure this mechanism prevents, so it must not fail silent.
+ bool converged = false;
+ for (int i = 0; i < 1024 && !converged; i++)
+ converged = cache.Update();
+ if (!converged)
+ Debug.LogWarning("[Navigation] ApplyRebuiltTiles: the tile cache did not converge within 1024 update slices; obstacle carves may not re-apply to the regenerated tiles.");
+
+ DtNavMesh navMesh = cache.GetNavMesh();
+ var addedRefs = new List();
+ foreach ((int x, int z, List blobs) in rebuilt)
+ {
+ foreach (long tileRef in cache.GetTilesAt(x, z))
+ {
+ // The cache's RemoveTile frees only the compressed tile; the built
+ // navmesh tile must be removed explicitly (while the header still exists).
+ DtTileCacheLayerHeader? header = cache.GetTileByRef(tileRef)?.header;
+ if (header != null)
+ {
+ long navRef = navMesh.GetTileRefAt(header.tx, header.ty, header.tlayer);
+ if (navRef != 0) navMesh.RemoveTile(navRef);
+ }
+ cache.RemoveTile(tileRef);
+ }
+
+ foreach (byte[] blob in blobs)
+ {
+ try
+ {
+ long added = cache.AddTile(blob, 0);
+ if (added != 0) addedRefs.Add(added);
+ else Debug.LogWarning($"[Navigation] ApplyRebuiltTiles: layer for tile ({x}, {z}) collided with an existing layer slot and was skipped.");
+ }
+ catch (Exception e)
+ {
+ // AddTile throws on cache tile-capacity exhaustion (regeneration can
+ // legitimately grow the layer count past the bake's).
+ Debug.LogWarning($"[Navigation] ApplyRebuiltTiles: failed to add a layer for tile ({x}, {z}): {e.Message}");
+ }
+ }
+ }
+
+ NavMeshTileBuilder.RefreshObstacleTouchedTiles(cache, data!);
+
+ foreach (long added in addedRefs)
+ cache.BuildNavMeshTile(added); // re-contours with carves applied via the refreshed lists
+ });
+
+ // Mirror the swap into this surface's runtime copy so a later re-instantiation agrees
+ // with the live mesh; the .navmesh asset on disk is not touched. Obstacles are runtime
+ // state and never serialize, so the copy holds clean regenerated layers. Single pass
+ // over the tile list: RemoveAll-per-tile would be O(total x rebuilt).
+ var replaced = new HashSet<(int, int)>(rebuilt.Count);
+ foreach ((int x, int z, _) in rebuilt)
+ replaced.Add((x, z));
+ data!.CacheLayers.RemoveAll(t => replaced.Contains((t.X, t.Z)));
+ foreach ((int x, int z, List blobs) in rebuilt)
+ foreach (byte[] blob in blobs)
+ data.CacheLayers.Add(new Runtime.NavMeshData.NavMeshTile { X = x, Z = z, Data = blob });
+
+ return true;
+ }
+
+ /// Collect this surface's bake geometry from the scene (main thread).
+ public List CollectSources() => CollectSources(null);
+
+ ///
+ public List CollectSources(AABB? filterBounds)
+ => CollectSources(filterBounds, ResolveBuildSettings().EffectiveVoxelSize);
+
+ ///
+ /// Collect this surface's bake geometry, restricted to objects whose bounds intersect
+ /// (conservative test against transformed local bounds) —
+ /// partial rebuilds pass their affected-tile rect so collection cost scales with the changed
+ /// region. Volume mode composes: the volume intersects with the filter.
+ /// sets terrain decimation granularity — pass whatever
+ /// settings the geometry will be voxelized with (bakes: freshly resolved; partial rebuilds:
+ /// the asset's snapshot).
+ ///
+ public List CollectSources(AABB? filterBounds, float terrainVoxelSize)
+ {
+ List sources = [];
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ if (scene.IsNotValid()) return sources;
+ if (!TryResolveCollectionBounds(filterBounds, out AABB? bounds))
+ return sources; // filter rect entirely outside the volume
+
+ IEnumerable objects = CollectObjects == NavMeshCollectObjects.Children
+ ? EnumerateSelfAndChildren(GameObject)
+ : scene!.ActiveObjects;
+
+ NavMeshGeometryCollector.Collect(objects, UseGeometry, Layers, terrainVoxelSize, DefaultArea, sources, bounds, AgentTypeId);
+ return sources;
+ }
+
+ ///
+ /// Compose an optional world-space filter with the Volume-mode extent (the one bounds
+ /// rule shared by geometry and volume collection). False when the intersection is empty —
+ /// nothing can be collected.
+ ///
+ private bool TryResolveCollectionBounds(AABB? filterBounds, out AABB? bounds)
+ {
+ bounds = filterBounds;
+ if (CollectObjects != NavMeshCollectObjects.Volume) return true;
+
+ AABB volume = VolumeBounds;
+ if (bounds is AABB b)
+ {
+ if (!b.Intersects(volume)) return false;
+ bounds = b.ClippedBy(volume);
+ }
+ else
+ {
+ bounds = volume;
+ }
+ return true;
+ }
+
+ ///
+ /// Collect the scene's s that apply to this surface's
+ /// agent type (main thread), optionally restricted to volumes overlapping
+ /// . Same object scoping (CollectObjects/Layers) as
+ /// geometry collection.
+ ///
+ public List CollectVolumes(AABB? filterBounds)
+ {
+ List volumes = [];
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ if (scene.IsNotValid()) return volumes;
+ if (!TryResolveCollectionBounds(filterBounds, out AABB? bounds))
+ return volumes;
+
+ IEnumerable objects = CollectObjects == NavMeshCollectObjects.Children
+ ? EnumerateSelfAndChildren(GameObject)
+ : scene!.ActiveObjects;
+
+ NavMeshGeometryCollector.CollectModifierVolumes(objects, Layers, AgentTypeId, volumes, bounds);
+ return volumes;
+ }
+
+ ///
+ /// Collect the scene's s that apply to this surface's agent type
+ /// (main thread), optionally restricted to links overlapping
+ /// . Same object scoping (CollectObjects/Layers) as
+ /// geometry collection.
+ ///
+ public List CollectLinks(AABB? filterBounds)
+ {
+ List links = [];
+ var scene = GameObject.IsValid() ? GameObject.Scene : null;
+ if (scene.IsNotValid()) return links;
+ if (!TryResolveCollectionBounds(filterBounds, out AABB? bounds))
+ return links;
+
+ // Scene-wide collection reads the world's registry rather than every GameObject: a link
+ // edit re-collects on the spot, so this runs at gameplay rate. Children mode still walks,
+ // because what it scopes to is the hierarchy.
+ if (CollectObjects == NavMeshCollectObjects.Children)
+ NavMeshGeometryCollector.CollectLinks(EnumerateSelfAndChildren(GameObject), Layers, AgentTypeId, links, bounds);
+ else
+ NavMeshGeometryCollector.CollectLinks(scene!.Navigation.Links, Layers, AgentTypeId, links, bounds);
+ return links;
+ }
+
+ private static IEnumerable EnumerateSelfAndChildren(GameObject root)
+ {
+ yield return root;
+ foreach (GameObject child in root.Children)
+ {
+ if (child.IsNotValid()) continue;
+ foreach (GameObject go in EnumerateSelfAndChildren(child))
+ yield return go;
+ }
+ }
+
+ #endregion
+
+ #region Gizmos
+
+ private NavMeshTriangulation? _debugTriangulation;
+ private NavMeshWorld? _debugWorld;
+ // What the cached triangulation was built from, so it rebuilds when the asset is swapped
+ // or the surface gains/loses a live registration (entering or leaving play mode).
+ private Runtime.NavMeshData? _debugSource;
+ private bool _debugFromLive;
+ private List<(Float3 Position, bool Corner)>? _debugVertexMarkers;
+ private List<(Float3 A, Float3 B)>? _debugDetailEdges;
+
+ private void InvalidateDebugTriangulation() => _debugTriangulation = null;
+
+ /// Unselected drawing: only the walkable overlay, and only when asked for. Watching
+ /// obstacles carve needs it while something else is selected — and entering play mode clears
+ /// the selection outright, so selection-only drawing cannot show a runtime carve at all.
+ ///
+ public override void DrawGizmos()
+ {
+ if (AlwaysShowNavMesh) DrawWalkableOverlay();
+ }
+
+ public override void DrawGizmosSelected()
+ {
+ if (CollectObjects == NavMeshCollectObjects.Volume)
+ Debug.DrawWireCube(Transform.TransformPoint(Center), Size * 0.5f, Color.Cyan);
+
+ Runtime.NavMeshData? data = NavMeshData.Res;
+ if (data.IsNotValid() || !data!.HasTiles)
+ return;
+
+ Debug.DrawWireCube((data.BoundsMin + data.BoundsMax) * 0.5f, (data.BoundsMax - data.BoundsMin) * 0.5f, Color.Blue);
+
+ // Already drawn unselected — drawing it twice would double the blend.
+ if (!AlwaysShowNavMesh) DrawWalkableOverlay();
+ }
+
+ /// The walkable surface, colored per area. Cached; invalidated on navmesh change.
+ private void DrawWalkableOverlay()
+ {
+ Runtime.NavMeshData? data = NavMeshData.Res;
+ if (data.IsNotValid() || !data!.HasTiles)
+ return;
+
+ NavMeshWorld? world = World;
+ if (world != null && _debugWorld != world)
+ {
+ if (_debugWorld != null) _debugWorld.NavMeshChanged -= InvalidateDebugTriangulation;
+ world.NavMeshChanged += InvalidateDebugTriangulation;
+ _debugWorld = world;
+ _debugTriangulation = null;
+ }
+
+ // Prefer the live navmesh: it is the one carving and rebuilds change. Fall back to the
+ // baked asset when this surface has no live instance to read — its data failed to
+ // instantiate, or another surface of the same agent type holds the registration.
+ bool live = world != null && world.GetInstance(AgentTypeId) != null;
+ if (_debugTriangulation == null || _debugFromLive != live || !ReferenceEquals(_debugSource, data))
+ {
+ _debugTriangulation = live ? world!.CalculateTriangulation(AgentTypeId) : data.CalculateTriangulation();
+ _debugFromLive = live;
+ _debugSource = data;
+ _debugVertexMarkers = BuildVertexMarkers(_debugTriangulation.Value);
+ _debugDetailEdges = BuildDetailEdges(_debugTriangulation.Value);
+ }
+
+ NavMeshTriangulation tri = _debugTriangulation.Value;
+ // Lift off the surface so the overlay doesn't z-fight the floor. Sized past the mesh's
+ // own error band: quantized heights interpolated between samples can dip a few
+ // centimetres below finely-tessellated ground, and fragments under the ground take the
+ // gizmo shader's faded occluded styling in patches.
+ var lift = new Float3(0, 0.08f, 0);
+ for (int t = 0; t < tri.Areas.Length; t++)
+ {
+ Color color = AreaColor(tri.Areas[t]);
+ Debug.DrawTriangle(
+ tri.Vertices[tri.Indices[t * 3 + 0]] + lift,
+ tri.Vertices[tri.Indices[t * 3 + 1]] + lift,
+ tri.Vertices[tri.Indices[t * 3 + 2]] + lift,
+ color);
+ }
+
+ // Polygon outlines over the fill, the way Unity draws its navmesh: the walkable border
+ // dark and solid, inner polygon edges light, tile seams warm — so the mesh's structure
+ // reads at a glance and a wrong edge points at itself.
+ foreach (NavMeshEdge edge in tri.Edges)
+ {
+ Color c = edge.Kind switch
+ {
+ NavMeshEdgeKind.Border => new Color(0.05f, 0.12f, 0.35f, 1f),
+ NavMeshEdgeKind.TilePortal => new Color(0.9f, 0.55f, 0.15f, 1f),
+ _ => new Color(0.65f, 0.85f, 1f, 0.9f),
+ };
+ Debug.DrawLine(edge.A + lift, edge.B + lift, c);
+ }
+
+ DrawDetailWireframe(_debugDetailEdges, lift);
+ DrawVertexMarkers(_debugVertexMarkers, lift);
+
+ foreach (NavMeshConnection con in tri.Connections)
+ DrawConnection(con, lift);
+ }
+
+ /// Each height-detail edge once. Neighbouring triangles share two thirds of their
+ /// edges, so drawing three per triangle submits most of them twice and doubles the alpha
+ /// where they overlap. Built with the markers, once per triangulation.
+ private static List<(Float3 A, Float3 B)> BuildDetailEdges(NavMeshTriangulation tri)
+ {
+ var seen = new HashSet<(int, int)>(tri.Areas.Length * 2);
+ var edges = new List<(Float3, Float3)>(tri.Areas.Length * 2);
+ for (int t = 0; t < tri.Areas.Length; t++)
+ {
+ for (int e = 0; e < 3; e++)
+ {
+ int i = tri.Indices[t * 3 + e];
+ int j = tri.Indices[t * 3 + (e + 1) % 3];
+ if (seen.Add((Math.Min(i, j), Math.Max(i, j))))
+ edges.Add((tri.Vertices[i], tri.Vertices[j]));
+ }
+ }
+
+ return edges;
+ }
+
+ /// The height-detail triangle edges, faint: the carpet inside each polygon,
+ /// distinct from the polygon outlines drawn on top of it.
+ private static void DrawDetailWireframe(List<(Float3 A, Float3 B)>? edges, Float3 lift)
+ {
+ if (edges == null) return;
+
+ var c = new Color(1f, 1f, 1f, 0.18f);
+ foreach ((Float3 a, Float3 b) in edges)
+ Debug.DrawLine(a + lift, b + lift, c);
+ }
+
+ ///
+ /// One solid dot per distinct vertex position: white for polygon corners — the welded,
+ /// tile-stitched structure — orange for vertices the height detail added. The triangulation
+ /// repeats shared corners once per polygon, so markers dedupe by position; a corner and a
+ /// detail vertex landing on the same spot counts as the corner, the stronger claim. Built
+ /// once per triangulation rebuild — this draws every frame.
+ ///
+ private static List<(Float3 Position, bool Corner)> BuildVertexMarkers(NavMeshTriangulation tri)
+ {
+ var seen = new Dictionary<(int, int, int), bool>(tri.Vertices.Length);
+ for (int v = 0; v < tri.Vertices.Length; v++)
+ {
+ Float3 p = tri.Vertices[v];
+ var key = ((int)Math.Round(p.X * 128), (int)Math.Round(p.Y * 128), (int)Math.Round(p.Z * 128));
+ bool corner = tri.IsPolygonCorner[v];
+ if (seen.TryGetValue(key, out bool wasCorner) && (wasCorner || !corner))
+ continue;
+ seen[key] = corner;
+ }
+
+ var markers = new List<(Float3, bool)>(seen.Count);
+ foreach (KeyValuePair<(int, int, int), bool> m in seen)
+ markers.Add((new Float3(m.Key.Item1 / 128f, m.Key.Item2 / 128f, m.Key.Item3 / 128f), m.Value));
+ return markers;
+ }
+
+ private static void DrawVertexMarkers(List<(Float3 Position, bool Corner)>? markers, Float3 lift)
+ {
+ if (markers == null) return;
+
+ var cornerColor = new Color(1f, 1f, 1f, 1f);
+ var detailColor = new Color(1f, 0.6f, 0.1f, 1f);
+ // One size for both: colour already says which is which, and a corner drawn larger
+ // reads as more important than the detail vertex beside it when they are the same
+ // thing to everything downstream.
+ foreach ((Float3 position, bool corner) in markers)
+ DrawSolidDot(position + lift, 0.03f, corner ? cornerColor : detailColor);
+ }
+
+ /// A tiny solid octahedron: reads as a dot from any angle, unlike a wire sphere,
+ /// and costs eight small triangles.
+ private static void DrawSolidDot(Float3 p, float r, Color color)
+ {
+ var xp = new Float3(r, 0, 0); var yp = new Float3(0, r, 0); var zp = new Float3(0, 0, r);
+ Debug.DrawTriangle(p + yp, p + xp, p + zp, color);
+ Debug.DrawTriangle(p + yp, p + zp, p - xp, color);
+ Debug.DrawTriangle(p + yp, p - xp, p - zp, color);
+ Debug.DrawTriangle(p + yp, p - zp, p + xp, color);
+ Debug.DrawTriangle(p - yp, p + zp, p + xp, color);
+ Debug.DrawTriangle(p - yp, p - xp, p + zp, color);
+ Debug.DrawTriangle(p - yp, p - zp, p - xp, color);
+ Debug.DrawTriangle(p - yp, p + xp, p - zp, color);
+ }
+
+ /// Endpoint marker size, shared so a link's own gizmo matches the overlay.
+ internal const float EndpointGizmoRadius = 0.15f;
+
+ ///
+ /// One off-mesh connection, drawn where the navmesh put it rather than where the component
+ /// asked: endpoints that snapped elsewhere show it, and a link that never attached is
+ /// visibly absent. Opaque, because the translucent surface fill is a poor read for a line.
+ ///
+ internal static void DrawConnection(NavMeshConnection con, Float3 lift)
+ {
+ Color area = AreaColor(con.Area);
+ var color = new Color(area.R, area.G, area.B, 1f);
+ Float3 start = con.Start + lift, end = con.End + lift;
+
+ Debug.DrawLine(start, end, color);
+ Debug.DrawWireSphere(start, EndpointGizmoRadius, color);
+ Debug.DrawWireSphere(end, EndpointGizmoRadius, color);
+
+ // Measured flat: the markings read as ground plan, and a steep link would otherwise
+ // splay them out of the surface.
+ var flat = new Float3(end.X - start.X, 0, end.Z - start.Z);
+ double length = Math.Sqrt(flat.X * flat.X + flat.Z * flat.Z);
+ if (length <= 1e-4) return;
+ var forward = new Float3((float)(flat.X / length), 0, (float)(flat.Z / length));
+ var perp = new Float3(-forward.Z, 0, forward.X);
+
+ // The width the connection actually covers, as a bar across each end.
+ if (con.Radius > 0f)
+ {
+ Float3 half = perp * con.Radius;
+ Debug.DrawLine(start - half, start + half, color);
+ Debug.DrawLine(end - half, end + half, color);
+ }
+
+ // One-way connections get an arrowhead; a bidirectional one is just the line.
+ if (con.Bidirectional) return;
+ Float3 back = end - forward * (EndpointGizmoRadius * 3f);
+ Float3 barb = perp * (EndpointGizmoRadius * 1.5f);
+ Debug.DrawLine(end, back + barb, color);
+ Debug.DrawLine(end, back - barb, color);
+ }
+
+ /// Stable debug color for an area index (Walkable is the familiar navmesh blue).
+ public static Color AreaColor(int areaIndex)
+ {
+ if (areaIndex == NavMeshAreas.Walkable) return new Color(0f, 0.75f, 1f, 0.35f);
+ // Deterministic hue per area index.
+ float hue = (areaIndex * 137.5f) % 360f / 360f;
+ float r = Math.Abs(hue * 6f - 3f) - 1f;
+ float g = 2f - Math.Abs(hue * 6f - 2f);
+ float b = 2f - Math.Abs(hue * 6f - 4f);
+ return new Color(Math.Clamp(r, 0f, 1f), Math.Clamp(g, 0f, 1f), Math.Clamp(b, 0f, 1f), 0.35f);
+ }
+
+ #endregion
+}
diff --git a/Prowl.Runtime/Navigation/NavMesh.cs b/Prowl.Runtime/Navigation/NavMesh.cs
new file mode 100644
index 000000000..18254882b
--- /dev/null
+++ b/Prowl.Runtime/Navigation/NavMesh.cs
@@ -0,0 +1,103 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using Prowl.Runtime.Resources;
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+///
+/// Static navigation query API over the current scene's — the
+/// Unity-style entry point (NavMesh.CalculatePath , NavMesh.SamplePosition , ...).
+/// Multi-scene setups and background threads that outlive scene changes should use
+/// on a specific scene instead; this facade always targets
+/// . Every method degrades to "no result" when no scene or navmesh
+/// is loaded.
+///
+public static class NavMesh
+{
+ /// Area mask that includes every area.
+ public const int AllAreas = NavMeshAreas.AllAreas;
+
+ /// The current scene's navigation world, or null when no scene is loaded.
+ public static NavMeshWorld? World
+ {
+ get
+ {
+ Scene? scene = Scene.Current;
+ return scene.IsValid() ? scene.Navigation : null;
+ }
+ }
+
+ /// Calculate a path between two points. Returns true when a complete or partial
+ /// path was found; carries the corners and exact status.
+ public static bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, int areaMask, NavMeshPath path)
+ => World?.CalculatePath(sourcePosition, targetPosition, areaMask, path) ?? MarkInvalid(path);
+
+ ///
+ public static bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, NavMeshQueryFilter filter, NavMeshPath path)
+ => World?.CalculatePath(sourcePosition, targetPosition, filter, path) ?? MarkInvalid(path);
+
+ private static bool MarkInvalid(NavMeshPath path)
+ {
+ path?.ClearCorners();
+ return false;
+ }
+
+ /// Find the closest navmesh point within of a position.
+ public static bool SamplePosition(Float3 sourcePosition, out NavMeshHit hit, float maxDistance, int areaMask)
+ {
+ NavMeshWorld? world = World;
+ if (world != null) return world.SamplePosition(sourcePosition, out hit, maxDistance, areaMask);
+ hit = default;
+ return false;
+ }
+
+ ///
+ public static bool SamplePosition(Float3 sourcePosition, out NavMeshHit hit, float maxDistance, NavMeshQueryFilter filter)
+ {
+ NavMeshWorld? world = World;
+ if (world != null) return world.SamplePosition(sourcePosition, out hit, maxDistance, filter);
+ hit = default;
+ return false;
+ }
+
+ /// Trace a walkability ray along the navmesh. Returns true when blocked before the target.
+ public static bool Raycast(Float3 sourcePosition, Float3 targetPosition, out NavMeshHit hit, int areaMask)
+ {
+ NavMeshWorld? world = World;
+ if (world != null) return world.Raycast(sourcePosition, targetPosition, out hit, areaMask);
+ hit = default;
+ return false;
+ }
+
+ /// Locate the closest navmesh border edge from a point.
+ public static bool FindClosestEdge(Float3 sourcePosition, out NavMeshHit hit, int areaMask)
+ {
+ NavMeshWorld? world = World;
+ if (world != null) return world.FindClosestEdge(sourcePosition, out hit, areaMask);
+ hit = default;
+ return false;
+ }
+
+ /// Triangulate the current navmesh for debug drawing or user tooling.
+ public static NavMeshTriangulation CalculateTriangulation()
+ => World?.CalculateTriangulation() ?? new NavMeshTriangulation { Vertices = [], Indices = [], Areas = [] };
+
+ /// Register a baked navmesh with the current scene. Returns its handle, or null.
+ public static NavMeshInstance? AddNavMeshData(NavMeshData navMeshData)
+ => World?.AddNavMeshData(navMeshData);
+
+ /// Unregister a navmesh from the current scene.
+ public static void RemoveNavMeshData(NavMeshInstance? instance)
+ => World?.RemoveNavMeshData(instance);
+
+ /// Find an area index by name, or -1.
+ public static int GetAreaFromName(string areaName) => NavMeshAreas.GetAreaFromName(areaName);
+
+ /// The project-default path cost multiplier for an area.
+ public static float GetAreaCost(int areaIndex) => NavMeshAreas.GetAreaCost(areaIndex);
+
+ /// Set the project-default path cost multiplier for an area.
+ public static void SetAreaCost(int areaIndex, float cost) => NavMeshAreas.SetAreaCost(areaIndex, cost);
+}
diff --git a/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs b/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs
new file mode 100644
index 000000000..2379f4015
--- /dev/null
+++ b/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs
@@ -0,0 +1,184 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+
+namespace Prowl.Runtime;
+
+///
+/// One project-level agent type: the physical envelope navmeshes are voxelized for. Surfaces
+/// and agents reference an entry by ; the inspector shows the name
+/// (via ).
+///
+public sealed class NavMeshAgentType
+{
+ /// Persistent identifier. Stable across renames and removals of other types —
+ /// never an index into the table.
+ public int Id;
+
+ public string Name = string.Empty;
+
+ /// Agent radius in world units. Walkable surfaces are eroded by this distance from walls.
+ public float Radius = 0.5f;
+
+ /// Agent height in world units. Spaces lower than this are not walkable.
+ public float Height = 2.0f;
+
+ /// Maximum walkable slope angle in degrees.
+ public float MaxSlope = 45f;
+
+ /// Maximum ledge height the agent can step up, in world units.
+ public float MaxClimb = 0.4f;
+
+ /// Copy, so the table holds entries of its own rather than the caller's objects.
+ /// Field by field: a reference field added later would be shared, and settings loading would
+ /// hand every table entry the same one.
+ public NavMeshAgentType Clone() => new()
+ {
+ Id = Id,
+ Name = Name,
+ Radius = Radius,
+ Height = Height,
+ MaxSlope = MaxSlope,
+ MaxClimb = MaxClimb,
+ };
+}
+
+///
+/// The project-wide agent type table (mirrors ): defined in the
+/// editor's navigation settings, restored in players from Navigation.yaml, with a code-side
+/// default (the built-in Humanoid, id 0) so headless and procedural use needs no settings
+/// file. Unity's Agents tab equivalent.
+///
+public static class NavMeshAgentTypes
+{
+ /// The built-in default agent type id. Always present; cannot be removed.
+ public const int Humanoid = 0;
+
+ // Replaced wholesale rather than edited in place (see NavMeshAreas): bakes resolve their
+ // envelope from here off the main thread while the settings UI rewrites the table, and a
+ // reader walking one mid-rebuild could index past its own end.
+ private static volatile NavMeshAgentType[] s_types = [CreateHumanoid()];
+
+ private static NavMeshAgentType CreateHumanoid() => new()
+ {
+ Id = Humanoid,
+ Name = "Humanoid",
+ Radius = 0.5f,
+ Height = 2.0f,
+ MaxSlope = 45f,
+ MaxClimb = 0.4f,
+ };
+
+ /// All defined agent types, in table order. Do not mutate the entries — use
+ /// (settings) to change the table.
+ public static IReadOnlyList All => s_types;
+
+ /// The agent type with the given id, or null when undefined.
+ public static NavMeshAgentType? Get(int agentTypeId) => Find(s_types, agentTypeId);
+
+ private static NavMeshAgentType? Find(IReadOnlyList types, int agentTypeId)
+ {
+ for (int i = 0; i < types.Count; i++)
+ if (types[i].Id == agentTypeId)
+ return types[i];
+ return null;
+ }
+
+ /// Display name for an agent type id ("Agent Type N" for undefined ids, so stale
+ /// references stay visible rather than blank).
+ public static string GetName(int agentTypeId)
+ => Get(agentTypeId)?.Name ?? $"Agent Type {agentTypeId}";
+
+ /// Find an agent type id by name, or -1. Null/empty never matches.
+ public static int GetIdFromName(string name)
+ {
+ if (string.IsNullOrEmpty(name)) return -1;
+ NavMeshAgentType[] types = s_types;
+ for (int i = 0; i < types.Length; i++)
+ if (string.Equals(types[i].Name, name, StringComparison.Ordinal))
+ return types[i].Id;
+ return -1;
+ }
+
+ ///
+ /// Replace the table (called by settings loading). The built-in Humanoid entry is
+ /// enforced: id 0 always exists and keeps its name, though its envelope values are
+ /// editable like Unity's.
+ ///
+ public static void ApplyTable(IEnumerable types)
+ {
+ ArgumentNullException.ThrowIfNull(types);
+
+ // Built aside and published in one store, so a reader never sees the table empty or
+ // half-filled.
+ List built = [];
+ foreach (NavMeshAgentType type in types)
+ {
+ if (type == null) continue;
+ NavMeshAgentType? clash = Find(built, type.Id);
+ if (clash != null)
+ {
+ // Unreachable from the editor UI; a hand-edited Navigation.yaml can do it.
+ // Get returns the first match, so a silent duplicate would shadow the second.
+ Debug.LogWarning($"[Navigation] Duplicate agent type id {type.Id} ('{type.Name}') ignored; '{clash.Name}' keeps the id.");
+ continue;
+ }
+ NavMeshAgentType copy = type.Clone();
+ if (copy.Id == Humanoid) copy.Name = "Humanoid";
+ built.Add(copy);
+ }
+
+ if (Find(built, Humanoid) == null)
+ built.Insert(0, CreateHumanoid());
+ s_types = [.. built];
+ }
+
+ ///
+ /// Compose the resolved bake input for an agent type: envelope from the table, everything
+ /// else from (or defaults). This is what surfaces hand to
+ /// ; the builder API itself only ever sees the resolved
+ /// . An undefined id falls back to the Humanoid envelope
+ /// with a warning — a bake with wrong-but-sane dimensions beats no bake.
+ ///
+ public static NavMeshBuildSettings GetBuildSettings(int agentTypeId, NavMeshBuildOverrides? overrides = null)
+ {
+ NavMeshAgentType? type = Get(agentTypeId);
+ if (type == null)
+ {
+ Debug.LogWarning($"[Navigation] Agent type {agentTypeId} is not defined in the navigation settings; baking with the Humanoid envelope. Define it in Project Settings > Navigation > Agents.");
+ type = Get(Humanoid) ?? CreateHumanoid();
+ }
+
+ overrides ??= s_defaultOverrides;
+ return new NavMeshBuildSettings
+ {
+ AgentTypeId = agentTypeId,
+ AgentRadius = type.Radius,
+ AgentHeight = type.Height,
+ AgentMaxSlope = type.MaxSlope,
+ AgentMaxClimb = type.MaxClimb,
+
+ OverrideVoxelSize = overrides.OverrideVoxelSize,
+ VoxelSize = overrides.VoxelSize,
+ OverrideTileSize = overrides.OverrideTileSize,
+ TileSize = overrides.TileSize,
+ MinRegionArea = overrides.MinRegionArea,
+ EdgeMaxError = overrides.EdgeMaxError,
+ FilterLowHangingObstacles = overrides.FilterLowHangingObstacles,
+ FilterLedgeSpans = overrides.FilterLedgeSpans,
+ FilterWalkableLowHeightSpans = overrides.FilterWalkableLowHeightSpans,
+ BuildHeightDetail = overrides.BuildHeightDetail,
+ };
+ }
+
+ private static readonly NavMeshBuildOverrides s_defaultOverrides = new();
+}
+
+///
+/// Draws an int field as a dropdown of the agent types defined in project settings, instead
+/// of a raw id (the agent-type analogue of ).
+///
+[AttributeUsage(AttributeTargets.Field)]
+public class NavMeshAgentTypeAttribute : Attribute { }
diff --git a/Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs b/Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs
new file mode 100644
index 000000000..574a2fde0
--- /dev/null
+++ b/Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs
@@ -0,0 +1,20 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+
+namespace Prowl.Runtime;
+
+///
+/// Draws an int field as a dropdown of the navigation areas defined in project settings
+/// (see ), instead of a raw number.
+///
+[AttributeUsage(AttributeTargets.Field)]
+public class NavMeshAreaAttribute : Attribute { }
+
+///
+/// Draws an int field as a multi-select of the navigation areas defined in project settings
+/// (like a layer mask), instead of a raw bitmask number.
+///
+[AttributeUsage(AttributeTargets.Field)]
+public class NavMeshAreaMaskAttribute : Attribute { }
diff --git a/Prowl.Runtime/Navigation/NavMeshAreaVolume.cs b/Prowl.Runtime/Navigation/NavMeshAreaVolume.cs
new file mode 100644
index 000000000..45d55df21
--- /dev/null
+++ b/Prowl.Runtime/Navigation/NavMeshAreaVolume.cs
@@ -0,0 +1,108 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+
+using Prowl.Vector;
+
+namespace Prowl.Runtime;
+
+///
+/// A world-space convex prism that stamps an area over already-rasterized geometry during a
+/// bake (the payload of , applied via Recast's convex
+/// volume marking). Self-contained — no Transform or component references — so it is safe to
+/// hand to a background build alongside s. A volume only
+/// re-marks voxels that geometry produced; it never creates walkable surface on its own, and
+/// an volume erases walkability inside its footprint
+/// (Unity's Modifier Volume behaviour).
+///
+public readonly struct NavMeshAreaVolume
+{
+ /// World-space convex footprint polygon; only X/Z are used.
+ public readonly Float3[] Footprint;
+
+ /// Vertical extent of the prism, in world space.
+ public readonly float MinY, MaxY;
+
+ /// The area stamped inside the volume (see ).
+ public readonly int Area;
+
+ public NavMeshAreaVolume(Float3[] footprint, float minY, float maxY, int area)
+ {
+ Footprint = footprint ?? throw new ArgumentNullException(nameof(footprint));
+ MinY = minY;
+ MaxY = maxY;
+ Area = area;
+ }
+
+ /// Conservative world AABB of the prism, for bounds filtering.
+ public AABB Bounds
+ {
+ get
+ {
+ // XZ from the footprint, Y from the prism's own extent (the hull keeps only the
+ // XZ-extreme corners, so their Y values don't span the prism).
+ AABB footprint = AABB.FromPoints(Footprint);
+ return new AABB(
+ new Float3(footprint.Min.X, MinY, footprint.Min.Z),
+ new Float3(footprint.Max.X, MaxY, footprint.Max.Z));
+ }
+ }
+
+ ///
+ /// Build the volume for an oriented box (local center/size under a world transform): the
+ /// 8 corners are transformed, the vertical range is their Y span, and the footprint is the
+ /// convex hull of their XZ projection. Yaw-only boxes yield a 4-gon; arbitrary rotations
+ /// project to up-to-6-gons, which stay convex and are marked exactly.
+ ///
+ public static NavMeshAreaVolume FromOrientedBox(in Float4x4 localToWorld, Float3 center, Float3 size, int area)
+ {
+ Float3 half = size * 0.5f;
+ var corners = new Float3[8];
+ float minY = float.MaxValue, maxY = float.MinValue;
+ for (int i = 0; i < 8; i++)
+ {
+ var local = new Float3(
+ center.X + ((i & 1) == 0 ? -half.X : half.X),
+ center.Y + ((i & 2) == 0 ? -half.Y : half.Y),
+ center.Z + ((i & 4) == 0 ? -half.Z : half.Z));
+ Float3 world = Float4x4.TransformPoint(local, localToWorld);
+ corners[i] = world;
+ minY = Math.Min(minY, (float)world.Y);
+ maxY = Math.Max(maxY, (float)world.Y);
+ }
+
+ return new NavMeshAreaVolume(ConvexHullXZ(corners), minY, maxY, area);
+ }
+
+ /// 2D convex hull (monotone chain) over the points' XZ projection.
+ private static Float3[] ConvexHullXZ(Float3[] points)
+ {
+ var sorted = new List(points);
+ sorted.Sort((a, b) => a.X != b.X ? a.X.CompareTo(b.X) : a.Z.CompareTo(b.Z));
+
+ static double Cross(Float3 o, Float3 a, Float3 b)
+ => (a.X - o.X) * (b.Z - o.Z) - (a.Z - o.Z) * (b.X - o.X);
+
+ // Monotone chain. <= 0 drops collinear (and duplicate) points, so degenerate
+ // projections (an edge-on box) collapse below 3 vertices and the volume marks nothing.
+ var hull = new List(sorted.Count);
+ foreach (Float3 p in sorted) // lower hull
+ {
+ while (hull.Count >= 2 && Cross(hull[^2], hull[^1], p) <= 0)
+ hull.RemoveAt(hull.Count - 1);
+ hull.Add(p);
+ }
+ int lowerEnd = hull.Count + 1;
+ for (int i = sorted.Count - 2; i >= 0; i--) // upper hull (sorted[^1] already placed)
+ {
+ Float3 p = sorted[i];
+ while (hull.Count >= lowerEnd && Cross(hull[^2], hull[^1], p) <= 0)
+ hull.RemoveAt(hull.Count - 1);
+ hull.Add(p);
+ }
+ hull.RemoveAt(hull.Count - 1); // the upper hull's last point repeats hull[0]
+ return [.. hull];
+ }
+}
diff --git a/Prowl.Runtime/Navigation/NavMeshAreas.cs b/Prowl.Runtime/Navigation/NavMeshAreas.cs
new file mode 100644
index 000000000..cbe2b60b7
--- /dev/null
+++ b/Prowl.Runtime/Navigation/NavMeshAreas.cs
@@ -0,0 +1,156 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace Prowl.Runtime;
+
+///
+/// The project-wide navigation area table: up to 32 named areas with per-area path costs,
+/// addressed by index and combined into 32-bit area masks (matching Unity's area model).
+/// Area 0 is Walkable, area 1 is Not Walkable, area 2 is Jump; 3..31 are user-defined.
+///
+/// Internally Detour stores a 6-bit area id per polygon where 0 (RC_NULL_AREA ) is
+/// reserved for "not part of the navmesh", so Prowl area index i is stored as Detour
+/// area i + 1 . / are the only
+/// places that offset is applied — never hand-roll it. BAKE-side conversions go through
+/// ProwlInputGeomProvider.DetourAreaFor instead, which additionally maps
+/// to the null area (calling directly on
+/// a source/volume area silently resurrects traversable "Not Walkable" polys).
+///
+public static class NavMeshAreas
+{
+ /// Maximum number of areas (indices 0..31), the width of an area mask.
+ public const int MaxAreas = 32;
+
+ /// The built-in default walkable area index.
+ public const int Walkable = 0;
+
+ /// The built-in not-walkable area index. Geometry marked with this area is
+ /// excluded from the navmesh entirely.
+ public const int NotWalkable = 1;
+
+ /// The built-in area index used for jump/off-mesh connections.
+ public const int Jump = 2;
+
+ /// Area mask that includes every area.
+ public const int AllAreas = -1;
+
+ // Replaced wholesale rather than edited in place: query filters read the cost table from
+ // worker threads while the settings UI rewrites it on every keystroke, and publishing a new
+ // array is one atomic store, so a reader gets a whole revision of the table rather than one
+ // caught mid-edit. Writers take a lock so two of them cannot clobber each other's copy.
+ private static volatile string[] s_names = CreateDefaultNames();
+ private static volatile float[] s_costs = CreateDefaultCosts();
+ private static readonly Lock s_writeLock = new();
+
+ private static string[] CreateDefaultNames()
+ {
+ string[] names = new string[MaxAreas];
+ for (int i = 0; i < names.Length; i++) names[i] = string.Empty;
+ names[Walkable] = "Walkable";
+ names[NotWalkable] = "Not Walkable";
+ names[Jump] = "Jump";
+ return names;
+ }
+
+ private static float[] CreateDefaultCosts()
+ {
+ float[] costs = new float[MaxAreas];
+ for (int i = 0; i < costs.Length; i++) costs[i] = 1f;
+ costs[Jump] = 2f;
+ return costs;
+ }
+
+ /// Get the name of an area, or an empty string for unnamed user areas.
+ public static string GetAreaName(int areaIndex)
+ {
+ if (areaIndex < 0 || areaIndex >= MaxAreas) return string.Empty;
+ return s_names[areaIndex];
+ }
+
+ /// Rename an area. Built-in areas (0-2) cannot be renamed.
+ public static void SetAreaName(int areaIndex, string name)
+ {
+ if (areaIndex <= Jump || areaIndex >= MaxAreas) return;
+ lock (s_writeLock)
+ {
+ string[] names = [.. s_names];
+ names[areaIndex] = name ?? string.Empty;
+ s_names = names;
+ }
+ }
+
+ /// Find an area index by name, or -1 when no area has that name. Null/empty never
+ /// matches (unnamed slots store empty strings).
+ public static int GetAreaFromName(string areaName)
+ {
+ if (string.IsNullOrEmpty(areaName)) return -1;
+ string[] names = s_names;
+ for (int i = 0; i < MaxAreas; i++)
+ if (string.Equals(names[i], areaName, StringComparison.Ordinal))
+ return i;
+ return -1;
+ }
+
+ /// The default path cost multiplier for an area (used when a query filter does not override it).
+ public static float GetAreaCost(int areaIndex)
+ {
+ if (areaIndex < 0 || areaIndex >= MaxAreas) return 1f;
+ return s_costs[areaIndex];
+ }
+
+ /// Set the default path cost multiplier for an area. Clamped to >= 1: Detour's
+ /// A* heuristic (straight-line distance) is only admissible when no traversal is cheaper
+ /// than distance itself, so costs below 1 would silently produce suboptimal paths. To
+ /// express "agents prefer this area", raise every OTHER area's cost above 1 instead.
+ public static void SetAreaCost(int areaIndex, float cost)
+ {
+ if (areaIndex < 0 || areaIndex >= MaxAreas) return;
+ lock (s_writeLock)
+ {
+ float[] costs = [.. s_costs];
+ costs[areaIndex] = Math.Max(1f, cost);
+ s_costs = costs;
+ }
+ }
+
+ /// Replace the whole area table (names + costs). Called by project-settings loading.
+ public static void ApplyTable(IReadOnlyList names, IReadOnlyList costs)
+ {
+ lock (s_writeLock)
+ {
+ string[] newNames = [.. s_names];
+ float[] newCosts = [.. s_costs];
+ for (int i = 0; i < MaxAreas; i++)
+ {
+ if (names != null && i < names.Count && i > Jump) newNames[i] = names[i] ?? string.Empty;
+ if (costs != null && i < costs.Count) newCosts[i] = Math.Max(1f, costs[i]);
+ }
+ s_names = newNames;
+ s_costs = newCosts;
+ }
+ }
+
+ /// Indices of all defined areas (the built-ins plus every user area with a
+ /// non-empty name), in index order. What area dropdowns enumerate.
+ public static List GetDefinedAreas()
+ {
+ string[] names = s_names;
+ var defined = new List(8);
+ for (int i = 0; i < MaxAreas; i++)
+ if (i <= Jump || !string.IsNullOrEmpty(names[i]))
+ defined.Add(i);
+ return defined;
+ }
+
+ ///