# onepath docs, all pages (OnePath 1.1.0) Every page of https://recursiveai.org/onepath/docs/ and https://recursiveai.org/onepath/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/onepath/llms.txt # Getting started with OnePath Install OnePath for .NET 10, find a path on a grid, build a navigation mesh from level geometry and query it, and walk an agent across it in a crowd, with results that are the same bits on every platform. ## Install OnePath targets `net10.0`, so you need the .NET 10 SDK. This guide uses 1.1.0. ```shell dotnet new console -o OnePathStart cd OnePathStart dotnet add package RecursiveAI.OnePath --version 1.1.0 ``` Its only runtime dependency is [OneMath](https://recursiveai.org/onemath/) (1.0 or later), installed with it, for vectors and deterministic math. OnePath works under NativeAOT, trimming and browser WebAssembly, and never reads files or the network: glTF files, saved meshes and snapshots come in as bytes you supply. One `using RecursiveAI.OnePath;` reaches every type on this page; the navmesh calls also use OneMath’s vectors. ## A first program ```csharp using RecursiveAI.OneMath; using RecursiveAI.OnePath; // 1. A square grid: '.' open, '#' wall. var grid = Grid.FromText(""" ........ .#####.. .#...#.. .#.#.#.. ...#.... """); GridPath path = grid.FindPath(new Cell(0, 0), new Cell(4, 3)); Console.WriteLine($"grid: found {path.Found}, cost {path.Cost:F4}, {path.Cells.Length} cells"); Console.WriteLine(" " + string.Join(" ", path.Cells.ToArray().Select(c => $"({c.X},{c.Y})"))); // 2. A navigation mesh from level geometry: a 40 x 40 floor, Y up. var floor = new[] { new Triangle3D(new Vector3D(0, 0, 0), new Vector3D(0, 0, 40), new Vector3D(40, 0, 0)), new Triangle3D(new Vector3D(40, 0, 0), new Vector3D(0, 0, 40), new Vector3D(40, 0, 40)), }; NavMesh level = NavMesh.Build(floor, NavSettings.Humanoid); PathResult route = level.FindPath(new Vector3D(3, 0, 3), new Vector3D(36, 0, 36)); Console.WriteLine($"navmesh: has path {route.HasPath}, length {route.Length:F4}, {route.Points.Count} points"); // 3. A crowd on that mesh, stepped with a fixed time step. Crowd crowd = level.CreateCrowd(); Agent walker = crowd.Add(new Vector3D(3, 0, 3), Agent.Humanoid); walker.SetDestination(new Vector3D(36, 0, 36)); for (int i = 0; i < 20; i++) crowd.Step(0.05); Console.WriteLine($"crowd: after 1 s at {walker.Position}, {walker.RemainingDistance:F4} m to go"); ``` ```text grid: found True, cost 11.0000, 12 cells (0,0) (0,1) (0,2) (0,3) (0,4) (1,4) (2,4) (2,3) (2,2) (3,2) (4,2) (4,3) navmesh: has path True, length 46.6690, 2 points crowd: after 1 s at (5.05112451814824, 0, 4.929277245053733), 43.8543 m to go ``` ## What each part does - **`Grid.FromText`** reads `.` as open and `#` as a wall; digits `1`–`9` are area ids that you give a cost with `grid.Areas[id] = 3.0`. Moves are eight-connected without cutting corners by default, so the path takes no diagonal step past the corner of a wall. `GridOptions` picks the algorithm (A\*, JPS, JPS+, Theta\*, Lazy Theta\*, HPA\*), agent size, extra costs and blocked cells: see [Grid pathfinding](https://recursiveai.org/onepath/cookbook/grid-pathfinding/). - **`NavMesh.Build`** turns a triangle soup (Y up) into a tiled navigation mesh for an agent described by `NavSettings` (radius, height, step and slope). `NavSettings.Humanoid` is a preset. `NavMesh.Build2D` builds one from a tile map or outlines, and `NavMesh.BuildGltf` reads a glTF file. - **`FindPath`** returns a `PathResult` with `HasPath`, the string-pulled `Points`, `Length` and the polygon corridor. On an open floor that is two points, the start and the goal. - **`Crowd`** moves agents along their corridors and keeps them apart with ORCA. You call `Step` with a fixed time step; the crowd has no clock of its own. World positions are `double` and exact within ±2^21; a coordinate beyond that throws `ArgumentOutOfRangeException`. ## Determinism Results depend only on the inputs and options: never on the OS, CPU, SIMD width, runtime, JIT tier, WebAssembly or thread count. Ties break on node or polygon index, never on hash order. No 1.x release changes an existing path, mesh build, query, search or crowd step, and every 1.x release reads every format an earlier 1.x release saved. That makes OnePath safe for lockstep and rollback hosts: see [Crowds in a rollback game](https://recursiveai.org/onepath/cookbook/crowd-rollback/). ## Next - [Grid pathfinding](https://recursiveai.org/onepath/cookbook/grid-pathfinding/) - [Replanning and flow fields](https://recursiveai.org/onepath/cookbook/replanning-and-flow-fields/) - [Navigation mesh queries](https://recursiveai.org/onepath/cookbook/navmesh-queries/) - [From Recast, Detour or Unity](https://recursiveai.org/onepath/docs/migrating/) # onepath **Pathfinding and navigation for .NET, written from first principles: grids, graphs, navigation meshes and crowds in one package, with results that are bit-identical on every platform.** Square, hex and 3D voxel grids with A\*, JPS, JPS+, Theta\*, HPA\* and incremental replanning; weighted and point graphs; an exact 2D navmesh builder and a Recast-class 3D builder into one tiled format; path, raycast and nearest-point queries; crowds with navmesh-aware ORCA. Pure C# with one runtime dependency, our own deterministic math library. [![CI](https://github.com/recursive-ai-oss/onepath-csharp/actions/workflows/ci.yml/badge.svg)](https://github.com/recursive-ai-oss/onepath-csharp/actions/workflows/ci.yml) [![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4)](#install) [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#licence) **Status: 1.1.0.** Every part meets its speed target on x64 and arm64 (see [the table](#speed-against-the-references)). The public API follows semantic versioning, and results and saved formats are stable across every 1.x release (see [Determinism](#determinism)). See [CHANGELOG.md](https://recursiveai.org/onepath/docs/changelog/). ## Packages | Package | What it is | |---|---| | `RecursiveAI.OnePath` | Grids (square, hex, voxel), graphs, the navmesh builders, the query layer, the run-time tile cache and crowds. Trimming drops the parts an app does not use: an app that only uses grids trims the navmesh builders away. | | [`RecursiveAI.OneMath`](https://www.nuget.org/packages/RecursiveAI.OneMath) | The only dependency (1.0 or later): vectors, our own transcendental functions, exact geometric predicates and the fixed-point bridge. Installed automatically. | ## Install ``` dotnet add package RecursiveAI.OnePath ``` The library targets `net10.0` on Windows, Linux and macOS (x64 and arm64), is NativeAOT- and trim-compatible, runs in browser WASM, and uses only the .NET base class library and `RecursiveAI.OneMath` at runtime. It never reads files or the network: glTF files, saved meshes and snapshots come in as bytes you supply. ## The three tiers Each tier is built on the one below. **The happy path** (this page's snippets) is options objects with presets (`GridOptions`, `NavSettings.Humanoid`, `Agent.Humanoid`) and plain delegates; you never write a struct. **The middle tier** exposes every build stage as a replaceable static method, one `NavMeshQuery` with filters, area costs and sync, sliced and span forms, and struct-generic cost and heuristic hooks for the grid and graph searches. **The raw tier** is voxel spans, regions, contours, polygons, tile data, the open and closed sets and the ORCA solver. The [documentation index](https://recursiveai.org/onepath/docs/docs-map/) is organised the same way. ## Quick start One `using RecursiveAI.OnePath;` reaches every type below, grids, graphs, navmeshes and crowds alike (the navmesh examples also use OneMath's vectors); `PathResult`, `PathOptions`, `QueryFilter`, `FollowerState` and `PartitionType` live there too. Options are plain objects with named presets and optional delegates; you never write a struct to use the happy path (the struct-generic search cores are there for tuning, in `RecursiveAI.OnePath.Grids` and `.Graphs`). World positions are doubles within ±2^21. ```csharp using RecursiveAI.OneMath; using RecursiveAI.OnePath; ``` **Square grid.** `.` is open, `#` a wall, `1`-`9` are area ids (give an area a cost with `grid.Areas[id] = 3.0`). You can also build a grid from a `bool[,]` of walkable cells, or from row-major area bytes. ```csharp // quickstart: grid var grid = Grid.FromText(""" ........ .#####.. .#...#.. .#.#.#.. ...#.... """); GridPath path = grid.FindPath(new Cell(0, 0), new Cell(4, 3)); if (path.Found) { // path.Cost is exact for eight-connected octile moves and path.Cells lists the cells; query with // WorldPoint arguments (after setting grid.Space) to also get path.Points. } ``` Every tunable is a property of `GridOptions`: connectivity, corner rule, agent size, algorithm (A\*, JPS, JPS+, Theta\*, Lazy Theta\*, HPA\*), heuristic, extra costs and blocked cells, and an iteration budget for time slicing. ```csharp // quickstart: grid-options var wide = new Grid(64, 64); GridPath fast = wide.FindPath(new Cell(1, 1), new Cell(60, 50), new GridOptions { Algorithm = GridAlgorithm.JpsPlus }); GridPath straight = wide.FindPath(new Cell(1, 1), new Cell(60, 50), new GridOptions { Algorithm = GridAlgorithm.ThetaStar }); GridPath fat = wide.FindPath(new Cell(1, 1), new Cell(60, 50), GridOptions.ForAgentSize(3)); GridPath avoid = wide.FindPath(new Cell(1, 1), new Cell(60, 50), new GridOptions { Blocked = c => c.X == 30 && c.Y < 60 }); ``` **Hex grid.** The same calls, with a layout for the offset array. ```csharp // quickstart: hex var hex = HexGrid.FromText(""" ...... ..##.. ..#... ...... """, HexLayout.PointyOddRow); GridPath hexPath = hex.FindPath(new Cell(0, 0), new Cell(5, 3)); ``` **Voxel grid.** Six-, 18- or 26-connected moves in a dense or mostly-empty block. ```csharp // quickstart: voxel var voxels = new VoxelGrid(32, 32, 32); for (int y = 0; y < 32; y++) { for (int z = 0; z < 32; z++) { voxels.SetWalkable(16, y, z, y == 5 && z == 5); // a wall with one hole } } VoxelPath flight = voxels.FindPath(new VoxelCell(0, 0, 0), new VoxelCell(31, 31, 31), VoxelOptions.SixConnected); ``` **Graphs.** Weighted graphs, and point graphs that link nearby points. Landmarks (ALT) speed up repeated queries. ```csharp // quickstart: graph var graph = Graph.From(new[] { new GraphEdge(0, 1, 4.0), new GraphEdge(1, 2, 1.5), new GraphEdge(0, 2, 7.0) }); GraphPath route = graph.FindPath(0, 2); // route.Nodes is 0, 1, 2 and route.Cost is 5.5 Landmarks landmarks = graph.BuildLandmarks(); GraphPath fastRoute = graph.FindPath(0, 2, GraphOptions.WithLandmarks(landmarks)); var points = PointGraph.From(new[] { new Point3(0, 0), new Point3(1, 0), new Point3(2, 0), new Point3(2, 1) }, new PointGraphOptions { MaxDistance = 1.1 }); GraphPath walk = points.FindPath(new Point3(0, 0), new Point3(2, 1)); ``` **Replanning.** A replanner keeps its search state between edits and repairs only what changed (D\* Lite by default). ```csharp // quickstart: replan var yard = new Grid(8, 8); using GridReplanner replanner = yard.CreateReplanner(new Cell(0, 0), new Cell(7, 0)); GridPath straightRoute = replanner.Replan(); yard.SetRect(3, 0, 1, 7, AreaCosts.Wall); // a wall appears GridPath detour = replanner.Replan(); replanner.MoveStart(new Cell(1, 1)); // the agent moved GridPath onward = replanner.Replan(); ``` **Flow fields.** One field serves any number of agents heading for the same goal. ```csharp // quickstart: flow-field var field = new Grid(16, 16); using GridFlowField toGoal = field.CreateFlowField(new Cell(15, 15)); double cost = toGoal.GetCost(new Cell(0, 0)); // least cost from a cell to the goal if (toGoal.TryGetNext(new Cell(0, 0), out Cell next)) // the step a unit on that cell takes { // move from (0, 0) to next } ``` **Navigation mesh from a tile map** (a mesh from outlines, `NavMesh.Build2D(outlines)`, works the same way). ```csharp // quickstart: navmesh-2d var open = new bool[40 * 40]; Array.Fill(open, true); NavMesh mesh = NavMesh.Build2D(open, 40, 40, 1.0, new Vector2D(0, 0)); PathResult route2D = mesh.FindPath(new Vector3D(3, 0, 3), new Vector3D(36, 0, 36)); // route2D.HasPath, route2D.Points (the string-pulled path), route2D.Length ``` **Navigation mesh from level geometry** (triangles, Y up; `NavMesh.BuildGltf` reads a glTF file). `NavSettings` presets carry the agent's radius, height, step and slope. ```csharp // quickstart: navmesh-3d // A 40 x 40 floor from two triangles (Y is up); real levels pass their triangle soup the same way. var floor = new[] { new Triangle3D(new Vector3D(0, 0, 0), new Vector3D(0, 0, 40), new Vector3D(40, 0, 0)), new Triangle3D(new Vector3D(40, 0, 0), new Vector3D(0, 0, 40), new Vector3D(40, 0, 40)), }; NavMesh level = NavMesh.Build(floor, NavSettings.Humanoid); PathResult route3D = level.FindPath(new Vector3D(3, 0, 3), new Vector3D(36, 0, 36)); ``` **Crowds.** Agents follow corridors to their destinations and avoid each other with ORCA; you call `Step` with a fixed time step. ```csharp // quickstart: crowd Crowd crowd = level.CreateCrowd(); Agent walker = crowd.Add(new Vector3D(3, 0, 3), Agent.Humanoid); PathStatus status = walker.SetDestination(new Vector3D(36, 0, 36)); for (int i = 0; i < 20; i++) { crowd.Step(0.05); // fixed time step; the crowd has no clock of its own } // status.HasPath(), walker.HasPath, walker.RemainingDistance, walker.Velocity, walker.Position ``` The snippets above are compiled and run by `tests/OnePath.Tests/ReadmeQuickstartTests.cs`. ## Determinism Results depend only on the inputs and options, never on the operating system, CPU, SIMD width, runtime version, JIT tier, WASM versus native, or thread count. The library uses only IEEE-exact `+ - * /` and `sqrt`, its own transcendental functions (from `RecursiveAI.OneMath`), fixed reduction orders and index-ordered parallel work; ties break on node or polygon index, never on hash order. The same inputs give the same bits on x64, arm64 and WASM, checked by a hash matrix of fixed scenarios across JIT, scalar, NativeAOT, thread-count and WASM runs ([docs/hash-matrix.md](https://recursiveai.org/onepath/docs/hash-matrix/)). For a deterministic game host (lockstep, rollback) that means: - **Coordinate rule.** World positions cross the API as `double` and are exact within ±2^21 (a Q31.32 fixed-point value converts exactly); a coordinate beyond that throws `ArgumentOutOfRangeException` naming the value and the bound. Inside, meshes store float32 coordinates relative to a double tile origin. - **No clock.** `Crowd.Step(dt)` takes the step from you; sliced searches, rebuilds and replans count iterations, never time. - **Snapshot and restore.** Grid searches, replanners, flow fields, dynamic meshes (rebuilt tiles, obstacles) and crowds save to bytes and restore to the same state; the same state writes the same bytes on every platform. - **Debug geometry** (polygons, links, paths, corridors, agent velocities) comes out as plain data. - **Across versions.** Within a major version, every release gives the same bits for the same inputs and options. No 1.x minor or patch release changes an existing path, mesh build, query, search or crowd step. A change that would alter an existing result, even a better one, does one of two things. It ships behind a new option whose default keeps the old result. Or it waits for the next major version and is listed in the changelog. Features added in a minor release then keep their results for the rest of that major version. The math underneath, `RecursiveAI.OneMath`, makes the same promise, so updating either package within its major version never moves a result. Peers in a lockstep session, and replays recorded on another 1.x release, stay in sync. The hash matrix checks this: every line of the previous release's manifest must appear unchanged in the next one's. - **Saved data.** Every 1.x release reads every format written by an earlier 1.x release: navigation meshes, tile caches and every saved state (grids, searches, replanners, flow fields, dynamic meshes, crowds). This promise is backward reading only. A later release may write a newer format version, and an earlier release need not read it. Fixtures written by 1.0.0 are kept in the tests and loaded by every later release. The formats, tie-breaks and budget units are written down in [docs/](https://recursiveai.org/onepath/docs/docs-map/) so a port can reproduce the same bits from the text. ## What it does not do - **No rendering or engine glue.** Debug geometry is data; drawing it and moving your game objects is yours. - **No steering beyond ORCA avoidance.** Formations and general steering behaviours are not included. - **No volume pathfinding for flying agents** beyond the voxel grid search. - **Memory is higher than the native references'** (see the speed section): the library trades memory for speed and safety. ## Documentation Coming from Recast, Detour, DotRecast or Unity? [docs/migrating.md](https://recursiveai.org/onepath/docs/migrating/) maps the types, settings and units. The normative documents in [docs/](https://recursiveai.org/onepath/docs/docs-map/) hold every choice the papers leave open. API reference is the XML documentation shipped with the package. ## Speed against the references Every row is **our time divided by the reference's** (below 1.00 is faster than the reference), warm, in-process, best of three, on the same inputs and settings, with grid costs checked against the optimal costs. The bar is 1.15 for navmesh parts and 1.00 for grids. Grid, build and tile-cache rows are from one locked run; the path-query rows ("later run") are from a short locked run after the partial-path fix; the crowd rows are from the locked run before, because crowd stepping has not changed since. Rows refreshed after each run. Millisecond-scale rows (queries, tile cache) warm up for at least two seconds before timing. Method: [docs/benchmarking.md](https://recursiveai.org/onepath/docs/benchmarking/). - **x64:** AMD Ryzen 9 7950X3D (16 cores, 32 threads), Linux, .NET 10.0.13. - **arm64:** Apple M2 (8 cores), macOS, .NET 10.0.11. - **References:** Recast and Detour v1.6.0 (with DetourCrowd, DetourTileCache), RVO2 v2.0.2 (C++), RVO2-CS v2.0.1, DotRecast 2026.3.1, Warthog v0.6.2 (warthog-core and warthog-jps). All are used as black boxes through their documented APIs; none of their code is in this repository or package. | Part | Scenario | Reference | x64 | arm64 | Notes | |---|---|---|---:|---:|---| | Grid A\* | 9 Moving AI maps | Warthog | 0.83-0.98 | 0.86-1.00 | optimal costs match on every instance | | Grid JPS | 9 Moving AI maps | Warthog | 0.49-0.79 | 0.46-0.70 | | | Grid JPS+ | 9 Moving AI maps | Warthog | 0.64-0.85 | 0.45-0.82 | | | Grid JPS, improved pruning | 9 Moving AI maps | Warthog | 0.71-0.88 | 0.57-0.88 | | | Grid JPS+, improved pruning | 9 Moving AI maps | Warthog | 0.80-1.00 | 0.58-0.95 | | | Path query | city, tiled | Detour | 0.53 | 0.45 | later run | | Path query | terraces, tiled | Detour | 0.50 | 0.40 | later run | | Path query | terraces, single tile | Detour | 0.73 | 0.57 | later run | | Path query | overlapping floors, single tile | Detour | 0.14 | 0.13 | later run | | Path query | city, single tile | Detour | 0.92 | 0.91 | later run | | Path query | tile map, tiled | Detour | 1.04 | 0.94 | later run (1.40 / 1.26 before the partial-path fix) | | 3D build | 8 levels, solo and tiled | Recast | 0.23-0.89 | 0.24-1.06 | same settings; walkable coverage within 0.35% of Recast's on every level | | 2D build | tile map, solo and tiled, radius 0.5 / 0.75 / 1.0 | Recast | 0.42-0.89 | 0.43-0.95 | see the coverage note below | | Crowd | 100-5000 agents on a city mesh | DetourCrowd | 0.47-0.72 | 0.48-0.66 | earlier run (crowd code unchanged) | | Crowd, ORCA only | circles of 250, 1000, 5000 agents, one thread | RVO2 (C++) | 0.71-1.11 | 0.69-1.01 | earlier run | | Crowd, ORCA only | circles of 250, 1000, 5000 agents, all cores | RVO2-CS | 0.05-0.25 | 0.08-0.35 | earlier run | | Tile cache | obstacle add and remove, city and terraces, tiled | DetourTileCache | 0.78-0.93 | 0.68-0.91 | | | Tile cache | obstacle add and remove, city and terraces, tiled | DotRecast | 0.33-0.45 | 0.31-0.43 | | DotRecast is faster than ours on none of the build, query, crowd and tile-cache rows above. **2D coverage.** The 2D builder is checked against the exact eroded area of the input (walkable coverage within 1%): ours is within 0.01% of it on every tile-map row. Recast's coverage on the same settings is 31-44% below that area (DotRecast 31-51%), because its walkable area ends at the agent radius plus one cell (ledge filtering and minimum region size); we report it as information, not as a target. **Memory.** Peak resident memory is higher than the native references': 35-49 MB against 5-24 MB on the grid rows, 61-1060 MB against 5-85 MB on the build rows (Recast). **WASM** speed is measured and reported, not gated. Every other row of the full run (including DotRecast on each build, query and crowd row) is in the bar runner's CSV output (`bench/Bar`). The maps and scenarios are the Moving AI Lab benchmarks, used under the Open Data Commons Attribution License; see [THIRD-PARTY-NOTICES.md](https://github.com/recursive-ai-oss/onepath-csharp/blob/60bc896185cfabd4db36753fdbebf54dadb2d3f7/THIRD-PARTY-NOTICES.md) and cite N. Sturtevant, *Benchmarks for Grid-Based Pathfinding*, IEEE TCIAIG 4(2), 2012, for any result computed from them. ## Layout | Path | What | |---|---| | `src/OnePath/` | the package (`RecursiveAI.OnePath`, target `net10.0`) | | `tests/OnePath.Tests/` | unit tests (xUnit v3) | | `tests/AotSmoke/` | NativeAOT + full-trim smoke app | | `tests/WasmSmoke/` | browser-wasm smoke app, run headless under Node | | `bench/Bar/` | the bar runner: time, memory and quality rows as CSV | | `bench/Micro/` | BenchmarkDotNet micro-benchmarks | | `fuzz/OnePath.Fuzz/` | SharpFuzz / libFuzzer targets | | `tools/HashMatrix/` | hash-matrix runner and `tools/hash-matrix.sh` (see `docs/hash-matrix.md`) | | `ci/` | the build, test and smoke scripts CI runs; run them locally the same way | ## Building Requires the .NET 10 SDK (`global.json` pins 10.0.100 and rolls forward to later 10.0 feature bands). ```sh ci/build.sh # Release and Checked builds of the core projects ci/test.sh # unit tests, Release and Checked ci/aot-smoke.sh # NativeAOT + trimmed publish of the smoke app, then runs it ci/wasm-smoke.sh # browser-wasm build, run headless under Node (installs the wasm-tools workload if missing) ``` Each script takes configurations as arguments (`ci/test.sh Debug`). `dotnet build OnePath.slnx -c Release` builds everything, including the WASM smoke app, which needs the `wasm-tools` workload. - **Configurations:** `Debug`, `Release`, and `Checked` (optimized, defines `CHECKED`; the internal unchecked-access helpers become bounds-checked, and tests and fuzzing run in it). - **Output location:** `bin/` and `obj/` never appear in the tree. They go to `$ONEPATH_ARTIFACTS` if set, else `/onepath-artifacts/`. - **Package ID:** `$(PackagePrefix).OnePath`, with `PackagePrefix` defined once in `Directory.Build.props`. - **Warnings are errors**, analyzers run at `latest-recommended`, and `BannedSymbols.txt` forbids in `src/` the calls that could make results machine-dependent (platform maths functions, fused multiply-add, `Vector`, clocks, `Random`, processor count). - **Math package:** `src/OnePath` depends on `RecursiveAI.OneMath` (version in `Directory.Packages.props`), its only runtime dependency (1.0.0, restored from nuget.org). To use another build, set `ONEMATH_FEED` to a folder containing the `.nupkg` and restore picks it up; the `ci/` scripts default it to `~/onepath/feed` when that exists. On a machine that cannot see the folder, `tools/install-feed.sh ` copies the package into `~/onepath/feed`. (For a remote job runner that only syncs the git tree, ship the package with the runner's per-file include option and run that script on the remote machine.) - `ONEPATH_DOTNET_ROOT`: scripts put this user-local SDK folder first on `PATH`. ## Licence Licensed under either of [MIT](https://github.com/recursive-ai-oss/onepath-csharp/tree/60bc896185cfabd4db36753fdbebf54dadb2d3f7/LICENSE-MIT) or [Apache-2.0](https://github.com/recursive-ai-oss/onepath-csharp/tree/60bc896185cfabd4db36753fdbebf54dadb2d3f7/LICENSE-APACHE) at your option. Copyright Recursive AI, LLC. Third-party material (benchmark maps and the reference implementations used as black-box comparisons) is listed with its terms in [THIRD-PARTY-NOTICES.md](https://github.com/recursive-ai-oss/onepath-csharp/blob/60bc896185cfabd4db36753fdbebf54dadb2d3f7/THIRD-PARTY-NOTICES.md). # Migrating to onepath For code that uses Recast and Detour (C++), DotRecast (C#) or Unity's built-in navigation. It maps concepts and types, and calls out the places where a number means something different. The mapping comes from the three projects' published API documentation; where you depend on an exact behaviour of the old library, check it against that library's own documentation. Three differences run through everything: - **Units are world units.** Recast's build settings count in voxels (cells); onepath's `NavSettings` are metres-style world units, and the library does the conversion. Pass `AgentRadius = 0.6`, not `walkableRadius = 2`. - **Coordinates are `double`** at the public boundary (`Vector3D` from `RecursiveAI.OneMath`), exact within ±2^21, with Y up. Positions beyond that bound throw `ArgumentOutOfRangeException`. Convert engine vectors with `Vector3D.FromSystemNumerics(v)` and `vector3D.ToSystemNumerics()`; `NavMesh.Build(ReadOnlySpan vertices, ReadOnlySpan indices, settings)` takes the float arrays engines hold (the float to double step is exact). - **Mesh bytes are not interchangeable.** A Detour or DotRecast navigation-mesh file is not read by onepath (it fails with a typed `NavMeshFormatException`, a kind of `OnePathFormatException`: bad magic), and onepath's files are not read by them. **Re-bake from your geometry** with `NavMesh.Build(...)`; there is no converter, because the polygon sets differ (see [navmesh-format.md](https://recursiveai.org/onepath/docs/navmesh-format/)). ## Building a mesh One call replaces the Recast pipeline (heightfield, compact heightfield, regions, contours, polygon mesh, detail mesh, mesh data, nav mesh): ```csharp NavMesh mesh = NavMesh.Build(vertices, indices, NavSettings.Humanoid with { AgentRadius = 0.4, CellSize = 0.25 }); ``` Each stage is also callable on its own in tier 2 (`Mesh3DPipeline`, [build3d-spans.md](https://recursiveai.org/onepath/docs/build3d-spans/) onward). | Recast / DotRecast build setting | Unit there | onepath (`NavSettings`) | Unit here | |---|---|---|---| | `cs` (cell size) | world units | `CellSize` (default 0.3) | world units | | `ch` (cell height) | world units | `CellHeight` (0.2) | world units | | `walkableSlopeAngle` | degrees | `AgentMaxSlopeDegrees` (45) | degrees, 0 to 90 | | `walkableHeight` | voxels | `AgentHeight` (2) | world units | | `walkableClimb` | voxels | `WalkableClimb` (0.9) | world units | | `walkableRadius` | voxels | `AgentRadius` (0.6) | world units | | `maxEdgeLen` | voxels | `MaxEdgeLength` (3.6) | **world units**; 0 disables | | `maxSimplificationError` | voxels | `ContourMaxError` (1.3) | cells (same meaning) | | `minRegionArea` | cells; the samples pass a side length and square it | `MinRegionArea` (64) | **cells, an area**: pass `8 * 8`, not `8` | | `mergeRegionArea` | cells; same convention | `MergeRegionArea` (400) | **cells, an area**: pass `20 * 20` | | `maxVertsPerPoly` | vertices | `MaxVertsPerPoly` (6) | vertices, 3 to 16 | | `detailSampleDist` | the samples multiply by `cs` | `DetailSampleDistance` (1.8) | **world units**; 0 turns height detail off | | `detailSampleMaxError` | the samples multiply by `ch` | `DetailMaxError` (0.2) | **world units** | | `tileSize`, `borderSize` | voxels | `TileSize` (64) | **world units**; the border is internal | | partition (watershed, monotone, layers) | enum | `Partition` | `PartitionType` | | `bmin`, `bmax` | world | `OriginX`, `OriginZ` | tile grid origin; the heightfield bounds come from the input | Flags and areas: Detour polygons carry a 16-bit flags value and a 6-bit area id; so do onepath's (`NavSettings.Flags`, `Area` for a 2D build; areas from `SpanSettings` and volumes in 3D; 64 areas). Area costs are multipliers in a `QueryFilter` (`WithAreaCost(area, cost)`), not a per-area array on the query. Off-mesh connections are `NavSettings.OffMeshLinks` (`OffMeshLinkDefinition`). | Concept | Recast / Detour / DotRecast | onepath | |---|---|---| | Triangle input | `rcRasterizeTriangles` over `float[]` | `NavMesh.Build(float or double vertices, indices)`, `Triangle3D`, `NavMesh.BuildGltf` | | Voxel input | height field built by hand | `NavMesh.Build(VoxelChunk)`, `NavMesh.Build(SolidHeightfield)` | | Tile map / 2D outlines | not supported | `NavMesh.Build2D(...)` (exact, no voxels) | | Tiled mesh | `dtNavMesh` plus `dtNavMeshCreateParams` per tile | `NavMesh` is tiled by `NavSettings.TileSize`; `TileStreamer` and `NavWorld` stream and mix agent sizes | | Several agent sizes | one mesh per size, by hand | `NavWorld` (one mesh per agent class) | | Run-time obstacles and area volumes | tile cache | `TileCache` | | Save and load | `dtNavMesh` data blobs | `NavMeshSerializer`, snapshots in [navmesh-snapshot.md](https://recursiveai.org/onepath/docs/navmesh-snapshot/) | ## Queries | Detour / DotRecast | onepath | Notes | |---|---|---| | `findNearestPoly(center, halfExtents, filter)` | `NavMeshQuery.FindNearestPoly(center, halfExtents, filter)` | Returns `NearestPoly` (poly reference and point) | | `findPath` then `findStraightPath` | `NavMesh.FindPath(start, end, options)` | One call, returns `PathResult` with `Points`, `Status`, `IsPartial`; options carry the filter and search extents (default half-extents 2, 4, 2). Tier 2: `BeginPath`/`UpdatePath`/`FinishPath` and `FindStraightPath` | | sliced path query (`initSlicedFindPath`, `updateSlicedFindPath`, `finalizeSlicedFindPath`) | `BeginPath`, `UpdatePath(maxIterations)`, `FinishPath` | The budget counts iterations, never time; slicing gives the same result as one call | | `raycast` | `NavMesh.Raycast(from, to, out NavMeshHit, filter)`; `NavMeshQuery.Raycast` | `NavMeshHit` has position, normal and distance | | `moveAlongSurface` | `NavMeshQuery.MoveAlongSurface` | | | `findRandomPoint`, `findRandomPointAroundCircle` | `NavMesh.TryGetRandomPoint(ref SeededRandom, ...)`, `NavMeshQuery.RandomPoint`/`RandomPointAround` | The generator is passed in and seeded: there is no hidden random state | | `findDistanceToWall`, `findPolysAroundCircle`, `findLocalNeighbourhood` | `DistanceToWall`, `FindPolysInCircle`, `FindPolysInShape`, `FindLocalNeighbourhood` | | | `closestPointOnPoly` | `ClosestPointOnPoly` | | | `dtQueryFilter` (include/exclude flags, area costs) | `QueryFilter` | Immutable; `new QueryFilter { ExcludeFlags = 2 }.WithAreaCost(3, 5)`. Struct form for the zero-allocation span queries: `DefaultQueryFilter`, or your own `IQueryFilter` | | `dtStatus` bit flags | `QueryStatus`, `PathStatus` | Enums, not bit masks; see [path-results.md](https://recursiveai.org/onepath/docs/path-results/) | | Poly reference `dtPolyRef` | `PolyRef` | 64-bit (24 bits salt, 20 slot, 20 polygon) | A path that cannot reach the goal returns the corridor to the nearest reachable polygon with `IsPartial = true`, as Detour does. ## Crowds `dtCrowd`/`DtCrowd` ≈ `Crowd` (`mesh.CreateCrowd()`); `dtCrowdAgentParams` ≈ `AgentOptions` (`Agent.Humanoid`, `Small`, `Large`). | Detour crowd parameter | onepath | Notes | |---|---|---| | `radius` | `AgentOptions.Radius` | Body radius between agents; the mesh is already shrunk by its build radius | | `height` | `NavSettings.AgentHeight` | Build time, not per agent | | `maxSpeed`, `maxAcceleration` | `MaxSpeed`, `MaxAcceleration` | units per second, per second squared | | `collisionQueryRange` | `CrowdOptions.MaxWallsPerAgent`, `WallRefreshDistance`; neighbours by `Quality` | | | `pathOptimizationRange` | `FollowerOptions.OptimizeRange` (through `AgentOptions.Path`) | | | `separationWeight` | `SeparationDistance` and `SeparationWeight` | **Not the same scale.** Detour's weight is a non-negative number (the samples use values such as 2); onepath's `SeparationWeight` is **0 to 1**, the fraction of `MaxSpeed` a touching neighbour adds to the wanted velocity (default 0.3), and separation is off until `SeparationDistance` is above 0. Values above 1 throw. Start with `SeparationDistance = 2 * Radius` and tune | | `obstacleAvoidanceType` | `Quality`: `Off`, `Low`, `Medium`, `High` | Presets for neighbour count and time horizon | | `queryFilterType` | `CrowdOptions.Filter` (one filter per crowd) | | | `updateFlags` (anticipate turns, optimize visibility, optimize topology, obstacle avoidance, separation) | `FollowerOptions` intervals and budgets; `Quality`; `SeparationDistance` | A feature is off when its interval, distance or quality is 0 | | `requestMoveTarget` | `Agent.SetDestination(position)` → `PathStatus` | Returns `GoalInvalid` if the goal is not near the mesh | | `update(dt)` | `Crowd.Step(dt)` | Fixed `dt` is the supported use; the crowd has no clock | | `getActiveAgents` | `foreach (Agent a in crowd)`, `CopyAgents(span)` | Slot order | | `addAgent` returning an index | `Crowd.Add(position, options)` returns an `Agent` | Throws if the position is off the mesh or the crowd is full | The velocity solver is ORCA (van den Berg et al.), made aware of mesh edges; it is not the sampled velocity-obstacle search Detour uses, so agent paths differ in detail even with matching numbers. Details in [crowds.md](https://recursiveai.org/onepath/docs/crowds/) and [orca.md](https://recursiveai.org/onepath/docs/orca/). ## From Unity's navigation | Unity | onepath | |---|---| | `NavMeshSurface` / `NavMeshBuilder.BuildNavMeshData` | `NavMesh.Build(vertices, indices, settings)`, or `NavMesh.BuildGltf`; Unity's `float[]` and `Vector3[]` meshes convert with `Vector3D.FromSystemNumerics` | | Agent Type (radius, height, step height, max slope) | `NavSettings` (`AgentRadius`, `AgentHeight`, `WalkableClimb`, `AgentMaxSlopeDegrees`); one mesh per size through `NavWorld` | | `NavMesh.SamplePosition(point, out hit, maxDistance, areaMask)` | `NavMesh.TrySamplePosition(position, maxDistance, out point, filter)` | | `NavMesh.CalculatePath(a, b, areaMask, path)` | `NavMesh.FindPath(a, b, options)`; the path is `PathResult.Points` and `Status` tells complete from partial | | `NavMesh.Raycast` | `NavMesh.Raycast(from, to, out NavMeshHit, filter)` (returns true when the ray is blocked, like Unity) | | Area costs and `areaMask` | `QueryFilter.WithAreaCost` and `IncludeFlags`/`ExcludeFlags` | | `NavMeshAgent.speed`, `acceleration`, `radius`, `avoidancePriority` | `AgentOptions.MaxSpeed`, `MaxAcceleration`, `Radius`, `Priority` (**greater priority gives way less**, the opposite direction to Unity's lower-number-wins `avoidancePriority`) | | `NavMeshAgent.stoppingDistance` | `AgentOptions.ArrivalRadius` | | `NavMeshAgent.SetDestination`, `remainingDistance`, `velocity`, `hasPath` | `Agent.SetDestination`, `RemainingDistance`, `Velocity`, `HasPath` | | `NavMeshAgent.Warp` | `Agent.Teleport(position)` (returns false when off the mesh) | | `NavMeshAgent` moving the transform | You move your own objects: read `Agent.Position` after `crowd.Step(dt)` | | `NavMeshObstacle` (carving) | `TileCache` obstacles | | `OffMeshLink` | `NavSettings.OffMeshLinks`; with `OffMeshMode.Host` the host drives the crossing and calls `Agent.CompleteOffMeshLink()` | | Per-frame `Update` with `Time.deltaTime` | `crowd.Step(fixedDt)`: use a fixed step, not the frame time, if you need identical results on every machine | Unity's navigation is not deterministic across platforms; onepath's is. If your game relied on float quirks of the old library (a path that happened to cut a corner), expect small differences and re-tune against the settings above. # Path results Every one-call path query reports through the same vocabulary, whatever the world is made of. ## `PathStatus` (namespace `RecursiveAI.OnePath`) | Value | Number | Meaning | |---|---|---| | `None` | 0 | No query has produced a result. This is `default(PathStatus)`: an unassigned result, a zeroed array slot or a default struct field never reads as success. | | `Found` | 1 | A path to the goal exists in the result. | | `StartInvalid` | 2 | Start outside the world, blocked, or no walkable surface near it. | | `GoalInvalid` | 3 | Same for the goal. | | `NoPath` | 4 | The reachable set was exhausted; grid and graph queries return no best-effort path. | | `BudgetExhausted` | 5 | The iteration budget ran out; a sliced query may be continued. | | `Partial` | 6 | Goal unreachable (or node limit hit); the path leads to the closest reachable place. Navmesh queries and crowd agents. | | `InProgress` | 7 | Not finished; later crowd steps continue it. | The numbers are fixed (hash scenarios and logs write them). `HasPath()` is true for `Found` and `Partial` and false for `None`. **Zero is never success.** Every status enum a result can carry starts with a neutral value at 0 (`None`, or `NotStarted` for a search that has not begun), so `default` means "nothing happened". The success value is 1 in `PathStatus` (`Found`), `QueryStatus` (`Success`), `AreaQueryStatus` (`Success`) and `RaycastStatus` (`Ok`). ## Result members | Member | `GridPath` (square, hex) | `VoxelPath` | `GraphPath` (graph, point graph) | `PathResult` (navmesh) | |---|---|---|---|---| | `Status` | `PathStatus` | same | same | same (`Detail` holds the `QueryStatus`) | | `Found`, `HasPath`, `IsPartial` | yes | yes | yes | yes | | `Cost` | search cost, +∞ without a path | same | same | corridor cost under the filter | | `Length` | polyline length of `Points`; 0 without a path; NaN when a path exists but the query made no points | same | same (plain graphs: NaN) | straight-path length, 0 without a path | | Discrete path | `Cells` | `Cells` | `Nodes` | `Corridor` (polygons) | | World-space path | `Points` (`WorldPoint`; the `Cell` overload of `FindPath` leaves it empty, `Grid.FindPathWithPoints` fills it through `Grid.Space`) | `Points` (`VoxelPoint`) | `Points` (`Point3`, point graphs) | `Points` (`StraightPathPoint`) | `GridSearch.Status` and `GridSearch.Step` speak the same vocabulary: `None` before a search has begun, `InProgress` while it has work left, then `Found` or `NoPath`. The raw engines (`AStar`, `JumpSearch`, `ThetaSearch`, `GraphAStar`) keep `SearchStatus` (`NotStarted`, `Running`, `Found`, `NotFound`). `Agent.SetDestination` returns a `PathStatus`: `Found`, `Partial`, `InProgress`, `GoalInvalid` (nothing walkable near the destination) or `StartInvalid` (the agent is not on the mesh). ## Positions that are not on the mesh One rule across the crowd members: a position with no walkable polygon near it is an expected outcome, reported by the member's result, except where the member returns the new agent itself. | Member | Off the mesh | |---|---| | `NavMesh.FindPath`, `NavWorld.FindPath` | `PathStatus.StartInvalid` / `GoalInvalid` in the result | | `Agent.SetDestination` | `GoalInvalid` (or `StartInvalid` when the agent itself is off the mesh) | | `Agent.Teleport` | returns `false`, nothing changes | | `NavMesh.TrySamplePosition`, `Raycast` | `false` / blocked | | `Crowd.TryAdd` | returns `false`, `agent` is null | | `Crowd.Add` | throws `ArgumentException` naming `position` (its behaviour since 1.0.0) | `Crowd.Add` and `Crowd.TryAdd` also refuse when the crowd holds `CrowdOptions.MaxAgents` agents: `Add` throws `InvalidOperationException`, `TryAdd` returns `false` (compare `Count` with `Options.MaxAgents` to tell the two refusals apart). Coordinates that are not finite or beyond ±2^21 and settings out of range are mistakes in the call, not outcomes: both members throw `ArgumentOutOfRangeException`. ## Tier 2 and 3 keep their detail `QueryStatus` (namespace `RecursiveAI.OnePath.Mesh.Queries`) is the polygon-level outcome of `NavMeshQuery`'s sliced and span forms, straight paths and path corridors: `None` (0), `Success` (1), `Partial` (2), `InProgress` (3), `InvalidStartPoly` (4), `InvalidEndPoly` (5), `NoStartNearby` (6), `NoEndNearby` (7), `InvalidCorridor` (8). `QueryStatus.ToPathStatus()` maps it: None→None, Success→Found, Partial→Partial, InProgress→InProgress, the start errors→StartInvalid, the end errors→GoalInvalid, InvalidCorridor→NoPath. `AreaQueryStatus` is `None` (0), `Success` (1), `InProgress` (2), `InvalidStartPoly` (3), `InvalidShape` (4); `RaycastStatus` is `None` (0), `Ok` (1), `InvalidStartPoly` (2), `StartOutsidePolygon` (3). `SearchStatus` (the raw engines), `CorridorStatus` and `FlowFieldStatus` describe their own operations and stay as they are. # Grids: square and hex, open set, A*, area costs, clearance Normative. A port must reproduce the same expansion order, costs and paths from this text. Code lives in `src/OnePath/Grids/`. The tier-1 types (`Grid`, `HexGrid`, `VoxelGrid`, their options and results, `Cell`, `WorldPoint`, `VoxelCell`, `VoxelPoint`, `GridSpace`, `VoxelSpace`, `AreaCosts`, `HexCoord`, `GridAlgorithm`, `PathStatus`, `GridFlowField` and the replanners) live in the root namespace `RecursiveAI.OnePath`; the search cores, topologies, heuristics, buffers and indexes (tiers 2 and 3) live in `RecursiveAI.OnePath.Grids` and `.Grids.Voxel`. ## Layout - **Square grid** (`Grid`): `Width` x `Height` cells, one byte each, indexed row-major: `index = y * Width + x`. `x` is the column (0 at the left), `y` the row (0 at the top). At most 2^28 cells. - **Area ids.** A cell byte is an area id: 0..254 are walkable areas, **255 is a wall**. `AreaCosts` holds one multiplier per walkable area (default 1; must be positive and finite). Edits go through `Grid.SetArea`, which bumps `Version` and tells registered `IGridChangeListener`s the changed cell. - **Hex grid** (`HexGrid`): a rectangular offset array (`Width` columns, `Height` rows), same indexing, same area ids. `HexLayout` says which rows/columns are shifted: `PointyOddRow`, `PointyEvenRow`, `FlatOddColumn`, `FlatEvenColumn`. Axial coordinates (q, r) from offset (col, row): - pointy odd: `q = col - (row - (row & 1)) / 2`, `r = row`; - pointy even: `q = col - (row + (row & 1)) / 2`, `r = row`; - flat odd: `q = col`, `r = row - (col - (col & 1)) / 2`; - flat even: `q = col`, `r = row - (col + (col & 1)) / 2`. Hex distance: with `dq, dr, ds = -dq - dr`, `(|dq| + |dr| + |ds|) / 2`. ## World mapping and the coordinate rule `GridSpace(originX, originY, cellSize)`. Square cell (x, y) has its centre at `origin + ((x + 0.5) * size, (y + 0.5) * size)`; a world point maps to `floor((p - origin) / size)` per axis and is outside the grid unless both results lie in range. Hex: the origin is the **centre of cell (0, 0)** and `size` is the distance between adjacent centres; pointy layouts place axial (q, r) at `size * (q + r/2, (√3/2) r)`, flat layouts at `size * ((√3/2) q, r + q/2)`; the inverse divides, then rounds fractional cube coordinates: round each of q, r, s = -q-r with `floor(v + 0.5)`, then repair the coordinate with the largest rounding error so the three sum to 0 (q if its error is strictly larger than both others, else r if strictly larger than the error of s, else s, which is implied). `(√3/2)` is the double 0.8660254037844386. World coordinates (origin, query points) cross the boundary as double and must satisfy `|v| <= 2^21` (exact for Q31.32 fixed point). NaN, infinities and larger values throw `ArgumentOutOfRangeException` naming the value and the bound. Internally grids are integer cell indices and costs are double; there is no float32 on the grid paths. A point outside the grid (but within the bound) is not an exception: the query returns `StartInvalid`/`GoalInvalid`. ## Movement **Square, neighbour order** (normative; it decides which of two equal-cost parents wins): north (0,-1), east (1,0), south (0,1), west (-1,0), then, if eight-connected, north-east (1,-1), south-east (1,1), south-west (-1,1), north-west (-1,-1). **Hex**: axial directions (+1,0), (+1,-1), (0,-1), (-1,0), (-1,+1), (0,+1). **Diagonal rule** (square, eight-connected). For a diagonal move the two *beside* cells are the two cells that share an edge with both the start and the target (for north-east: north and east of the start). - `NoCornerCutting` (default; the Moving AI benchmark convention, Sturtevant 2012): both beside cells walkable. - `NoSqueezing`: at least one beside cell walkable. - `AllowAll`: no condition beyond the target being walkable. **Step cost** of a move from cell `a` to cell `b`: `length * ((m[a] + m[b]) * 0.5)` evaluated in exactly that order in double, where `m` is the area multiplier of the cell's area id and `length` is 1 for a straight step, `GridMath.Sqrt2` for a diagonal (hex: always 1). A caller's cost hook may replace the result (see Tier 2). The cost of a path is the sum of its step costs added in path order starting from 0. **√2.** `Sqrt2 = 1.4142135623730951` (bits `0x3FF6A09E667F3BCD`), the correctly rounded double of √2; it equals `Math.Sqrt(2.0)` because IEEE-754 square root is correctly rounded. Octile costs `a + b√2` are therefore exact up to ordinary double rounding and identical on every platform. (The published Moving AI lengths differ from exact sums by up to 1.7e-9 relative; see Verification.) ## Clearance and agent sizes An agent of size `s` (1..255 cells) occupies the `s x s` block of cells whose **top-left cell is the agent's position** (extending towards +x and +y). **True clearance** of a cell is the side of the largest square of walkable cells with that cell as its top-left corner (0 for walls; cells outside the grid count as walls; capped at 255). It is computed by `c(x,y) = 0` for a wall, else `1 + min(c(x+1,y), c(x,y+1), c(x+1,y+1))` from the bottom-right corner, in O(cells). A cell is walkable for size `s` iff `clearance >= s`. All movement rules above are then applied to the "walkable for `s`" predicate: for a diagonal move the beside cells are tested for size `s`, which is exactly "the swept blocks are free". The step cost uses the area ids of the two position cells only. Clearance is recomputed lazily after edits; create a new `SquareTopology` after editing cells. Hex grids support only size 1. ## Open set `OpenSet` is an indexed 4-ary min-heap over node indices with decrease-key. **Order: smaller `f` first; on equal `f` larger `g` first; on equal `g` smaller node index first**, comparing doubles with `==`/`<`/`>`; NaN keys are rejected. A node is in the set at most once (pushing an existing node replaces its keys and re-sifts), so the pop order depends only on the keys. The arity is an implementation detail. ## A* Hart, Nilsson & Raphael 1968. `f = g + w * h` with weight `w` (default 1); `h` from `IHeuristic` after `SetGoal(goal)`. 1. `g[start] = 0`, push start with `f = w * h(start)`. 2. **Iteration**: pop the best node `n` (an iteration is one pop); mark it closed; if `n == goal` finish (cost `g[goal]`); otherwise expand: visit the topology's neighbours in order; for each, `step = hook(n, m, baseCost)`; skip it if `step` is NaN, negative or +infinity; `g' = g[n] + step`; if the neighbour was never reached, or `g' < g[m]` strictly, set `g[m] = g'`, `parent[m] = n`, mark it open (reopening it if closed) and push `(g' + w*h(m), g')`. Equal-cost alternatives therefore keep the **first** parent found. 3. If the open set empties first, there is no path. The goal test happens at pop. With `ZeroHeuristic` this is Dijkstra. Heuristics: Manhattan `scale*(|dx|+|dy|)`; octile `scale*((max-min) + min*√2)` (with `dx, dy` integer cell distances); hex distance `scale*steps`; `scale` is the smallest area multiplier (`AreaCosts.MinCost`), which keeps them admissible when multipliers are below 1. Heuristics are admissible and consistent for the default cost model; hooks that lower costs, or weights above 1, give up optimality. `Auto` picks Manhattan for four-connected, octile for eight-connected, hex distance for hex. **Sliced search.** `AStar.Step(n)` performs at most `n` iterations and returns `Running` while work remains; `Run()` is `Step(int.MaxValue)` with cooperative cancellation; time is never consulted. Any sequence of budgets gives the same expansion order, path and cost as one unsliced run. The tier-1 `GridOptions.IterationBudget` ends the query with `PathStatus.BudgetExhausted` after that many iterations. **Cancellation and async (tier 1).** Every one-line `FindPath` (square, hex, voxel) takes an optional `CancellationToken`. A token that can be cancelled makes the engine run in slices of 256 iterations and check the token before each one; cancelling throws `OperationCanceledException`, leaves the grid untouched and the next query exact. Slicing never changes a result (see above), so a token that is never cancelled returns the same path, cost and counts as no token, and `IterationBudget` counts the same iterations. `Grid.FindPathAsync` (cells or world points) is the cooperative sliced form: it drives the resumable `GridSearch` on the caller's synchronization context in slices of `GridOptions.IterationsPerSlice` (default 256) iterations with a `Task.Yield()` between slices, checks the token between slices and returns what `FindPath` returns, however the iterations are sliced (a budget that runs out gives `BudgetExhausted` after the same `IterationBudget` iterations). It supports `AStar`, `Jps` and `JpsPlus` without `RecordExpansions` and refuses other options with an `ArgumentException` through the task. Callbacks (`Blocked`, `StepCost`) run on the calling thread. For a search on another thread run `FindPath` inside `Task.Run`; the grid must not be edited until the task completes, and cancellation, like any error, comes through the task. Hex and voxel grids have no async form. ## API tiers 1. **Happy path.** `Grid.From(bool[,])` (or spans, `Grid.FromText(picture)` with `.` open, `#` wall and `1`-`9` areas, or `MovingAiMap.ToGrid()`; `HexGrid.FromText` likewise), `grid.FindPath(a, b[, GridOptions])` with cells or world points; `GridOptions` (connectivity, diagonal rule, agent size, heuristic, weight, `StepCost` and `Blocked` delegates, expansion recording, iteration budget) with presets `Default`, `FourConnected`, `NoSqueezing`, `ForAgentSize(n)`. `GridOptions`, `VoxelOptions` and `PathOptions` are immutable once built: set properties in an object initializer, and a shared preset such as `GridOptions.Default` cannot be changed under another caller. Start or goal outside the grid, blocked or too narrow for the agent give `StartInvalid`/`GoalInvalid`, not exceptions. The delegates are adapted onto the struct-generic core; nothing here needs a struct callback. 2. **Middle.** `grid.CreateTopology(agentSize, connectivity, diagonals)` gives a `SquareTopology` (hex: `HexGrid.CreateTopology()`); `new AStar(topology, cost, heuristic, buffers)` with `IStepCost` / `IHeuristic` structs; `GridSearchBuffers` are reusable working memory (the search allocates nothing in steady state; marks carry a generation number so nothing is cleared between searches). 3. **Raw.** `OpenSet`, `IGridTopology` + `INeighborVisitor` (implement your own graph), `GridSearchBuffers`, `AreaCosts`, `IGridChangeListener`; for jump point search `JumpRules` (pruning), `IJumper` with `OnlineJumper`, `BlockJumper` and `TableJumper`, `BlockMap`, `JpsPlusIndex` (the tables), and `JumpSearch` (tier 2: struct-generic over the jumper and heuristic). Tier 1 picks them with `GridOptions.Algorithm`. Debug data: `GridOptions.RecordExpansions` / `GridSearchBuffers.RecordExpansions` record the expanded nodes in order (`GridPath.ExpandedCells`, `GridSearchBuffers.ExpandedNodes`); the path is `GridPath.Cells` / `Points`. ## Jump point search (JPS, JPS+) Papers: Harabor & Grastien 2011 (*Online Graph Pruning for Pathfinding on Grid Maps*) for JPS; Harabor & Grastien 2014 (*Improving Jump Point Search*) for block-based scanning and the offline (JPS+) variant, with Rabin's formulation of the preprocessed distances (Game AI Pro 2). Code: `src/OnePath/Grids/Jps/`. **When it applies.** Eight-connected moves, `DiagonalRule.NoCornerCutting`, agent size 1, every area with the same cost multiplier `m` (straight step `m`, diagonal step `m * √2`, the same values A* uses when all multipliers are equal), no step-cost or blocking callbacks, heuristic weight 1, heuristic octile or zero. The one-line API (`GridOptions.Algorithm`) falls back to A* when a query does not qualify (and `GridPath.Algorithm` says which ran); JPS+ also falls back to JPS when a side of the grid exceeds 32766 cells. Other rules (squeezing, allow-all, four moves) and agent sizes above 1 are A*'s. The default is A*. **Pruned successors** (`JumpRules.Successors`). Let a node be reached by a step of sign (dx, dy) from its parent (the parent may be many cells away; only the direction matters). The start node searches all eight directions. - Diagonal arrival: the diagonal and its two cardinal components. A diagonal step into a cell needs both side cells free (no corner cutting), so no neighbour is forced. - Straight arrival: straight on; and on each side s (perpendicular to the step), if the cell beside the node on side s is free and the cell beside the *previous* cell on that side is a wall, that side cell is reachable at the optimal cost only through this node: the side's cardinal direction and the diagonal (step + s) are searched too. This is the forced neighbour of the 2011 paper moved one step along, because a diagonal step out of the previous cell is illegal when its side cell is a wall. **Jump** (`IJumper.Jump`). From a node in a direction, walk until a cell is reached that is the goal or (straight) has a forced neighbour as above, or (diagonal) is the goal or has a straight scan in one of its two cardinal directions that finds such a cell; a wall stops the walk with no result. A diagonal step needs both side cells and the target free. The result is the cell and the number of unit steps. **Search** (`JumpSearch`). A* over jump points with the grid core's `OpenSet`: smaller f, then larger g, then smaller cell index. f = g + octile(m) (or g for the zero heuristic); g adds `steps * m` or `steps * (m * √2)` per jump (so a path cost is a sum of one product per segment, not one addition per cell, and may differ from A*'s cell-by-cell sum in the last bits). A cheaper route reopens a node. Successors are generated in direction order 0..7 (north, east, south, west, north-east, south-east, south-west, north-west); on equal g the first parent found stays. The goal is tested when popped. One iteration pops and expands one node, so a sliced search equals an unsliced one; the iteration budget and expansion counts follow A*'s rules. The returned path lists every cell between the jump points (consecutive cells are neighbours). Jump points are not unique to the search: any valid optimal path is returned, and A*, JPS and JPS+ may return different paths of equal cost. **Block-based scanning** (`BlockMap`, `BlockJumper`). The walkable cells are kept as bits, 64 per word, once by rows and once by columns (bit i of word k of a line is position 64k+i; positions past the line end are blocked). A straight scan reads whole words: stop bits are `~walkable | forced`, where forced = `side & ~(side shifted by one position along the scan)` for the two neighbouring lines (a missing line counts as blocked), masked to positions beyond the start, and the first set bit (lowest bit forwards, highest backwards) is the stop; a goal on the line before the stop wins. The map follows grid edits immediately (a bit per edit in each orientation). It finds exactly the cells the cell-by-cell scan (`OnlineJumper`) finds. **JPS+ tables** (`JpsPlusIndex`). Per cell and direction one signed 16-bit value, stored cell by cell (`table[cell * 8 + direction]`, so the eight values of a cell share a cache line; `Tables` returns the same numbers as a direction-major copy, `[direction * cellCount + cell]`), direction order as above, cells row-major. Value `d > 0`: the next jump point is `d` steps away; `d <= 0`: no jump point before a wall, and `-d` is the number of walkable cells before it. A wall cell has all zeros. Straight direction: look at the next cell `q`; if `q` is a wall, 0; if `q` has a forced neighbour for this arrival, 1; else the value of `q` moved one step further (`v + 1` if `v > 0`, else `v - 1`). Diagonal: if the step is illegal (side cell or target a wall), 0; if `q`'s table for either cardinal component is positive, 1; else the same chain rule on the diagonal table. Build order: the four straight tables, then the diagonals, each from the far end. Size 16 bytes per cell. - *Goal bounding at query time.* Straight: the goal on the line ahead within `|d|` steps is returned directly (cost = distance). Diagonal: if the goal lies strictly inside the quadrant and `min(|gx-x|,|gy-y|) <= |d|`, the run stops at that many steps (the cell level with the goal in one axis), even if the stored jump point lies further. Otherwise a positive `d` returns the stored jump point and a non-positive one nothing. Stopping early adds nodes that plain JPS would not create; it never loses an optimal path (a superset of the nodes JPS visits) and never adds a cheaper one. - *Incremental update.* The index subscribes to the grid's `IGridChangeListener`; `Refresh()` (called by every query) applies the pending edits: for each edited cell recompute the east and west tables of its row and the rows above and below, the north and south tables of its column and the columns beside it, recording the cells whose sign (positive or not) changed; then for every edited cell and sign-changed cell recompute the four diagonal entries that read it (the cell itself, the cells one step behind it horizontally, vertically and diagonally), and follow each changed diagonal entry backwards along its diagonal until an entry comes out unchanged. The tables then equal a fresh build (checked on >1000 random edit sequences and in the fuzz target). More than max(256, cells/64) pending edits are answered by a full rebuild. **Cost with areas.** JPS assumes one cost for every step. Distinct area multipliers make a query fall back to A*; the table index is independent of costs, so changing a multiplier needs no rebuild. **Cost model of preprocessing.** O(8 · cells) time to build, 16 bytes per cell; the block map takes cells/4 bytes. ## The specialised octile searches (one-line A*, JPS, JPS+; `Grids/Fast/`) `Grid.FindPath` runs these when the query qualifies for JPS above (eight moves, no corner cutting, agent size 1, one cost for every area, no callbacks, weight 1, octile or zero estimate) with `GridAlgorithm.AStar`, `Jps` or `JpsPlus`. Everything else, sliced searches (`AStar<,,>`, `JumpSearch<,>`), snapshots and replanning use the generic cores, which are unchanged. Costs are optimal and equal to the generic cores'; the returned path may be another path of that cost. **Exact costs.** A path costs `a + b·√2` for `a` straight and `b` diagonal steps. The searches keep the integer pair `(a, b)` per node. Keys are computed from pairs: `g = a + b·√2`, `f = (a + (max − min)) + (b + min)·√2` with `min`/`max` of the absolute column and row distance to the goal, each evaluated as one product and sums of doubles. Equal pairs give identical doubles, so nodes with equal `f` in exact arithmetic tie exactly (cell-by-cell double sums would differ in the last bit and break ties by noise); distinct pairs differ by more than 1e-6 for any grid that fits in memory, far above rounding, so double comparison orders them exactly. A path's reported cost is `m · (a + b·√2)` for the cell cost `m`. The estimate is consistent, so a closed node is final and never reopened. **Open list order.** Smaller `f`; on equal `f` larger `g` (rounded to float32, which is IEEE-exact), then smaller node index (A*, with decrease-key in a 4-ary heap). The jump point searches use a 4-ary heap without decrease-key (an improved node is pushed again; the older entry is skipped when it pops, the node being closed) with one addition: entries whose `f` equals the `f` of the entry popped last go on a stack and pop first, last in first out, before the heap resumes (a heap entry with a smaller `f` than the current level wins). The order is therefore fully determined by the sequence of pushes. With this tie-break A* expands exactly the nodes that a (g descending) open list does: 3 594 911 expansions on dao-brc000d. **A\*** pads the map with a wall all round and keeps wall cells in the node array as permanently closed records, so a neighbour test is a load of the record the relaxation needs anyway. Node record 16 bytes: the pair, the heap slot, and a tag `generation << 4 | arrival direction << 1 | closed`; the generation increases per search so nothing is cleared between queries. **Jump point searches** (`OctileJump`, finders `BitFinder` and `TableFinder`). Node record 16 bytes (pair, parent, tag). A neighbour byte per cell (`OctileMap.Neighbors`, bit d = neighbour in direction d walkable) and a table of 8 × 256 entries (`OctileMap.Successors`, `JumpRules.Successors` evaluated once for every arrival direction and neighbour byte) give the pruned successor set without cell lookups; a diagonal step is allowed when the neighbour byte has the step's two components and the diagonal itself. The bit rows and columns of `OctileMap` are padded with 64 blocked positions before each line and a blocked word after it, so scans never leave the array and the carry between words needs no test. A goal on the scan line is a stop bit of its own. To make a scan step one word read, the map also keeps four arrays in the layout of the bit rows and columns (`RowStopForward`, `RowStopBackward`, `ColStopForward`, `ColStopBackward`): bit i of word k is set when a scan along that line direction stops at that cell, that is when the cell is a wall (`~w`) or has a forced neighbour (a cell beside the line is free while the one beside the previous line cell is not; the neighbouring lines' words and the carry bit of the adjacent word are read once, when the array is built). Padding words and padding lines are all ones. A cell edit recomputes the words of the three lines around it and the words before and after the edited word. A scan masks the stop word with the positions beyond the start, adds the goal bit, and takes the first set bit; the result is a wall (no jump point, 0) or the step count, as before. The JPS+ finder reads `JpsPlusIndex` with the goal bounding above. **Improved pruning** (`BitFinder`/`TableFinder` with `improvedPruning`; Harabor & Grastien 2014, section "Improved Pruning Rules"; not reachable from the one-line API). Without corner cutting every diagonal jump point is an intermediate turning point. A diagonal run from a node therefore does not stop at such cells: at each cell of the run it offers the straight jump points (or the goal) reached from it, with the node as their parent and the straight direction as their arrival direction, and it continues until a wall; the run's last cell is not a node. A hop from a node to such a successor is walked diagonal steps first, then straight, when the path is written out, which is obstacle free because it is the run followed by the scan. In a test on dao-brc000d this expands 83 678 nodes against 210 340 for JPS and 83 501 for the black-box reference's improved JPS. The improved-pruning search is internal: the bar runner and `GridOptima` construct it directly, and no public option selects it. Should an option expose it, it ships marked `[Experimental("ONEPATH0001")]` (`System.Diagnostics.CodeAnalysis.ExperimentalAttribute`, diagnostic id `ONEPATH0001`) until a locked comparison confirms it is as fast as the black-box reference on every Moving AI row. **Bar driver.** `bench/Bar/OursGrid.cs` loads the map and builds the bit maps and tables outside `search_time` (`preprocess_time` reports them), runs 40 untimed warm-up instances, then times one pass over the scenario (`ONEPATH_GRID_PASSES=N` takes the best of N passes in the process). The search loops are marked `AggressiveOptimization` so that short queries run fully optimised code from the first call. ## Any-angle search (Theta*, Lazy Theta*) Papers: Nash, Daniel, Koenig & Felner 2007 (*Theta\*: Any-Angle Path Planning on Grids*; JAIR 2010) and Nash, Koenig & Tovey 2010 (*Lazy Theta\**). Code: `src/OnePath/Grids/ThetaStar/`. **Model.** Nodes are cell centres (the same nodes as A*), not cell corners as in the papers, so every algorithm of this document shares one node set, start and goal definition and clearance rule. A waypoint list is a path; the agent moves in straight segments between consecutive centres. **Line of sight** (`GridLineOfSight`). Two walkable cells see each other iff the closed segment between their centres touches no wall cell, where a wall is the *closed* unit square (touching a wall's corner blocks). Diagonal neighbours therefore see each other exactly when both beside cells are walkable: every legal `NoCornerCutting` move is a line of sight. Cells outside the grid are walls. Integers only. With `dx = x1-x0`, `dy = y1-y0`, `nx = |dx|`, `ny = |dy|`, steps `sx, sy = sign`: - `nx = 0` or `ny = 0`: all cells on the row/column between the endpoints. - else walk from (x0, y0) with counters `ix = iy = 0` until `ix = nx` and `iy = ny`; `d = (2ix+1)*ny - (2iy+1)*nx` (64-bit). `d < 0`: step in x (`ix++`); `d > 0`: step in y (`iy++`); `d = 0`: the segment passes through a lattice corner; the two cells beside it `(x+sx, y)` and `(x, y+sy)` must be walkable, then step in both. Every cell entered must be walkable. **Applies when** (otherwise the query runs as A* and `GridPath.Algorithm` says so): eight-connected, `NoCornerCutting`, agent size 1, one cost multiplier `m` for every area, no `StepCost`/`Blocked` callbacks, heuristic `Auto`, `Euclidean` or `Zero`. The cost of a segment is `m * sqrt(dx*dx + dy*dy)` (`dx`, `dy` integer, the sum exact in 64 bits, one correctly rounded square root), so a straight cell step costs `m`, a diagonal one `m * Sqrt2` as in A*. A path's cost is the sum of its segment costs added in path order from 0 (`GridPath.Cost`; `Cells` and `Points` list the waypoints only). Heuristic: `m * sqrt(dx*dx + dy*dy)` to the goal (`EuclideanHeuristic`), weight `GridOptions.HeuristicWeight`. **Search** (`ThetaSearch`, tier 2). The open set is the grid core's `OpenSet` (smaller f, larger g, smaller cell index); one iteration pops one node; the goal is tested when popped; budgets and slicing as in A*. Neighbours of an expanded node `s` are its legal grid moves in the order north, east, south, west, north-east, south-east, south-west, north-west; closed nodes are never reopened (as in the papers) and a neighbour's cost is replaced only when strictly lower, so the first parent found wins ties. The start has no parent (the papers make it its own parent). Let `p` be the parent of `s`, `c(a, b)` the segment cost. - *Theta\** (`ThetaVariant.Theta`): for each neighbour `t` that is not closed: if `p` exists and `LOS(p, t)`, candidate `g(p) + c(p, t)` with parent `p`; otherwise `g(s) + c(s, t)` with parent `s`. - *Lazy Theta\** (`ThetaVariant.Lazy`, the paper): the candidate is always `g(p) + c(p, t)` with parent `p` (no test; the start uses `s`). When a node is popped, `SetVertex`: if it has a parent `p` and not `LOS(p, node)`, the parent becomes the closed grid neighbour `u` (neighbour order, first on ties) minimising `g(u) + c(u, node)`, and `g(node)` is set to that sum; then the node is closed and expanded. - *Lazy Theta\* with re-queue* (`ThetaVariant.LazyRequeue`, the tier-1 `GridAlgorithm.LazyThetaStar`): as `Lazy`, but a node whose `SetVertex` changed its parent is pushed back into the open set under its corrected `g` and `f` and not expanded in this iteration (the pop still counts). After the repair the parent is an adjacent cell, so the next pop does not repair again. **Why:** the paper's order expands such a node under its optimistic cost, and on random grids that occasionally returns a path longer than the A* path (15 of about 50,000 queries, at most 13% longer); with the re-queue none was seen in the same checks. It costs a few more iterations. The papers prove no optimality; paths are short, not shortest. Length lies between the visibility-graph optimum (shortest path turning only at cell centres) and, in all checks, the A* length. ## Flow fields (many agents, one goal set) Integration step of flow-field pathfinding (Emerson 2013, *Crowd Pathfinding and Steering Using Flow Field Tiles*; Pentheny 2013): Dijkstra's algorithm run outwards from the goals. Code: `Grids/Flow/` (`FlowField` tier 2/3, `GridFlowField` tier 1 via `Grid.CreateFlowField` and `HexGrid.CreateFlowField`). - **Integration field** `cost[n]` (double, +∞ = unreachable or not walkable). Goals (sorted, deduplicated; a goal the agent cannot stand on is inactive) cost 0. Every other node costs `min over neighbours u of (cost[u] + move(n, u))`, `move(n, u)` being the cost of the move *out of* `n` (the topology's move cost passed through the cost hook; the same cost in both directions is required because the wave runs backwards along the neighbour lists). Sums are IEEE doubles formed as `cost[u] + move`. Square grids use the same neighbour set, diagonal rule, agent clearance and `(m[from] + m[to]) / 2` costs as A*; hex grids the six hex moves. The tier-1 blocked filter isolates a cell (no move into or out of it); the step-cost delegate sees `(from, to, grid cost)`. - **Settling order.** The open list is `OpenSet` with `f = g = cost`, so ties on cost pop by smaller node index. The field does not depend on that order: a node's final cost is the minimum, over neighbours, of the same rounded sums of their final costs (rounding is monotone and moves cost ≥ 0, so a neighbour settled later never beats one settled earlier). - **Direction field** `next[n]` (node index, −1 for goals and unreachable nodes): the **first** neighbour in the topology's neighbour order (square: N, E, S, W, NE, SE, SW, NW; hex: axial order) that attains the minimum of `cost[u] + move(n, u)`. It is eight-way (six-way on hex), not a continuous gradient: `GetDirection` returns the unit vector from this cell's centre to the next one's, and following `next` from any reachable cell reaches a goal with total move cost equal to `cost[n]` (exactly, in the same arithmetic, up to the order of addition: a sum taken from the cell towards the goal can differ from the wave's sum in the last bits when move costs are not dyadic, because √2 is rounded). A continuous gradient and a line-of-sight shortcut near the goal are not provided. - **Iteration budget.** One iteration pops one node and relaxes its neighbours. `Step(n)` does at most `n`; a sliced field equals an unsliced one and reports the same `Settled`. `GridOptions.IterationBudget` bounds the constructor's run; call `Step`/`Run` to finish. - **Incremental repair.** `Refresh()` after cell edits (found through `IGridChangeListener`) recomputes only the affected region (Ramalingam & Reps 1996 style): the *dirty* nodes — the 3x3 block around an edited cell, widened up and left by the agent size − 1 for agents wider than one cell (hex: the cell and its neighbours) — are invalidated together with every node whose chosen next node is invalid, then each invalid node is seeded from its still-valid neighbours and the wave refills the region (improvements spread beyond it). Directions are recomputed for every node whose cost changed and its neighbours. The result is bit-identical to a field computed from scratch (cost bits and `next`). A change to an area multiplier, or `Recompute()`, recomputes everything. Changes the grid cannot report (a delegate answering differently) need `Recompute()`. - **Threads.** A field is computed on one thread. Fields for different goal sets may be computed concurrently over one grid that is not being edited (listener registration is locked); results do not depend on the thread count. Nothing in the flow-field code uses thread-count-dependent scheduling. - **Verification.** Integration field equals a per-cell O(n²) Dijkstra over 10^4 random square grids (4- and 8-connected, all three diagonal rules, agent sizes 1 and 2, area costs, 1–3 goals; bit-exact for four-connected where the costs are dyadic, 1e-9 relative with √2) and 600 hex grids (bit-exact); following the direction field from every cell reaches a goal at the field's cost; repair equals recompute (bit-exact cost and `next`) over 600 edit sequences including agent sizes 2–3, hex and area-cost changes; sliced equals unsliced for budgets 1–50; fields on 8 threads equal sequential ones. On Moving AI maps `GridOptima --alg flow` (`ALG=flow LIMIT=n sweep.sh`) computes the whole-map field to each scenario's goal and checks the start's cost against the published optimum (relative 1e-6): dao, sc1, bg512 and room, 4000 scenarios each, 0 mismatches. Hash scenarios `grids/flow/*`; fuzz target `grid-flow`. ## Tile edits and incremental clearance `Grid.SetRect(x, y, w, h, area)` and `SetRect(x, y, w, h, areas)` (and the same on `HexGrid`) change a rectangle as one edit: cells are written in row-major order, `Version` goes up once if any cell changed (nothing happens otherwise), and the listeners hear about it once. A listener implementing `IGridBatchListener` gets `OnRectChanged` with the smallest rectangle containing every changed cell (cells inside it may be unchanged); any other listener gets `OnCellChanged` for each changed cell, row-major. The grid already holds the final state in both cases. A rectangle outside the grid throws `ArgumentOutOfRangeException` before any cell is written; a source of the wrong length throws `ArgumentException`. **Clearance repair.** Clearance of a cell reads only the cells right of, below and diagonally below-right of it, so it can change only if the cell was edited or one of those three changed. The grid keeps the rectangles edited since the clearance was last current (more than 32 are merged into their bounding box). The next read repairs rows from the lowest edited row upwards, each row right to left: the cells of the row that lie in an edited rectangle, plus the cells one step left of and below the changed interval of the row below, are recomputed, and the sweep continues left past that range only while values keep changing. Edits made before the clearance was ever read cost nothing. The result equals the full recompute (checked on random edit sequences, `SetRect` included). `JpsPlusIndex` implements `IGridBatchListener`: a rectangle adds its cells to the pending edits (or schedules a full rebuild past the usual threshold). `BlockMap` updates per changed cell. ## Incremental replanning (LPA\*, D\* Lite) Papers: Koenig, Likhachev & Furcy, *Lifelong Planning A\** (2004); Koenig & Likhachev, *D\* Lite* (2002), the version with the key modifier `km`. One engine (`ReplanSearch`) serves both. It searches from a **root** (LPA\*: the start; D\* Lite: the goal) towards a **focus** (LPA\*: the goal; D\* Lite: the start, which moves). `g` is the cost from the root and `rhs` the one-step lookahead, `rhs(root) = 0` and otherwise the minimum over walkable neighbours `v` of `g(v) + cost` where the move cost is the move `v -> node` (LPA\*) or `node -> v` (D\* Lite) through the cost hook. Unreachable and non-walkable nodes have `rhs = +inf`. - *Queue.* Nodes with `g != rhs` are queued with key `[min(g, rhs) + h(focus, node) + km, min(g, rhs)]`. Order (`ReplanOpenSet`): first key, then second key, then node index; two keys within a relative 1e-11 count as equal. - *Loop.* While the top key is smaller than the focus's key, or the focus is inconsistent (`g != rhs`): pop `u`; if its stored key is smaller than its current key, re-queue it with the current key; else if `g(u) > rhs(u)` set `g(u) = rhs(u)`, remove it and recompute `rhs` of its neighbours; else set `g(u) = +inf` and recompute `rhs` of `u` and its neighbours. Recomputing a node's `rhs` is a full minimum over its neighbours (not the paper's per-edge shortcut), then queue it if inconsistent, remove it if consistent. One pop is one **iteration** (the unit of `IterationBudget`/`Replan(budget)`); pops that change a `g` are **expansions**. - *Tolerance.* Costs are sums of doubles added in different orders by different repairs, so costs equal on paper can differ in the last bits. "Smaller key", "equal" and "consistent" therefore use a relative tolerance of 1e-11 (absolute 1e-11 below 1), and a node whose key ties the focus's key on paper is processed when its second key is smaller. Stopping late is harmless, stopping early leaves stale costs. Reported costs are exact sums along the returned path, in start-to-goal order. - *Moving the start* (D\* Lite): `km += h(old start, new start)` and the heuristic's goal becomes the new start; the queue keeps all its work. For LPA\* a moved start or changed goal restarts the search. D\* Lite restarts on a changed goal. - *Changes.* The node set to re-examine for an edit at cell `(x, y)` with agent size `s` on a square grid is the cells `[x - s, x + 1] x [y - s, y + 1]` (for a rectangle edit `[x - s, x + w] x [y - s, y + h]`): a move between two nodes reads the two nodes' areas, the cells beside a diagonal move, and the `s x s` blocks both nodes occupy, all within one cell of both nodes, and the walkability of a node for size `s` reads the block starting at it. Both ends of every changed move are in the set, so recomputing `rhs` there is complete. On a hex grid the changed cell and its neighbours are re-examined. A change touching more than a quarter of the cells (or more pending nodes than the grid has) restarts the search instead. A change to `AreaCosts` (a multiplier) rebuilds the search (the heuristic's scale depends on the smallest multiplier). Changes the grid cannot see (a delegate that now answers differently) are announced with `Invalidate`. - *Path.* From the focus, repeatedly step to the neighbour minimising `g + move cost`, preferring neighbours with `g == rhs` (settled; a queued neighbour may hold an out-of-date cost), ties broken by neighbour order (see Movement); LPA\* walks goal to start and reverses. Many equal-cost paths exist, so the path may differ from A\*'s, but never the cost. - *Requirements.* The move graph must be undirected in structure (true for the square and hex topologies); the heuristic must be consistent and symmetric (octile, Manhattan, hex distance, zero); the heuristic weight must be 1; costs of +inf/NaN remove a move. - *Slicing.* `Replan(budget)` pops at most `budget` times; on `BudgetExhausted` the next call resumes, and any sequence of budgets ends with the result, expansion count and path of one unsliced call. Tier 1: `grid.CreateReplanner(start, goal, options)` (`GridOptions.Algorithm` = `LpaStar`, else D\* Lite) returns a `GridReplanner` (`HexReplanner`) that listens to the grid: `Replan()`, `MoveStart`, `SetGoal`, `Invalidate`, `Dispose`. Tier 2: `ReplanSearch<,,>` over any `IGridTopology` with struct cost and heuristic. Tier 3: `ReplanOpenSet`, the node-level `MarkChanged`/`Replan`/`ExtractPath`. A one-off `FindPath` with `LpaStar` or `DStarLite` runs A\*. ## HPA* (hierarchical path-finding over chunks) Botea, Müller & Schaeffer 2004, one level. `GridAlgorithm.Hpa` (tier 1; `HpaChunkSize` default 16, `HpaSmoothing` default true), `HpaHierarchy` and `HpaSearch` (tier 2), the per-chunk data through `ClusterNodes`, `IntraCost`, `GetInterEdges` (tier 3). **Scope.** Square grids, four or eight moves, rules `NoCornerCutting` and `NoSqueezing`, any agent size, area costs. Not supported (the query runs as A* and `GridPath.Algorithm` says so): step-cost or blocking callbacks, `AllowAll` with eight moves. The heuristic options and weight are ignored. Hex grids are not covered. No multi-level hierarchy. Clearance: one hierarchy is built over the cells walkable for one agent size (true clearance); tier 1 caches a hierarchy per (chunk size, agent size, moves, rule), at most eight, oldest dropped first. The paper's annotated variant (one graph for all sizes) is not used. **Chunks.** Side C; chunk `(cx, cy)` holds cells `[cx·C, min(W, cx·C+C)) × [cy·C, min(H, cy·C+C))`, indexed `cy · ClustersX + cx`. **Entrances and transitions.** For two chunks sharing a border, border position `p` is open when the two cells facing each other are both walkable. A maximal run `[lo, hi]` of open positions is an entrance. Width `hi-lo+1 ≥ T` (`EntranceThreshold`, default 6) and `hi ≠ lo`: two transitions at `lo` and `hi`; otherwise one at `lo + (width-1)/2`. A transition is the pair (cell in chunk A, cell in chunk B); both cells are abstract nodes joined by inter-edges in both directions at the move cost `(m[a] + m[b]) · 0.5`. Diagonal border moves get no entrances of their own: under both supported rules a diagonal crossing implies a walkable straight crossing beside it, and the cells of one run are connected inside each chunk, so abstract connectivity equals real connectivity. **Intra-edges.** For every ordered pair of nodes of a chunk, the cost of the cheapest path that stays inside the chunk (Dijkstra over the topology's own moves, targets settled early; open order as in `OpenSet`, with the chunk-local cell index as last tie-break). +∞ when none. Moves between two cells of a chunk read only cells of that chunk, so a chunk's edges depend on its cells alone. **Canonical form.** A chunk's nodes are sorted by cell index; its inter-edges by (node, target cell). The graph is a function of cells, costs and options only: `ContentEquals` and `GetContentHash` (FNV-1a 64 over node cells, cost bits and inter-edges in chunk order) compare it. **Query.** Insertion: from the start, one chunk-restricted Dijkstra gives the cost to every node of its chunk (and to the goal when in the same chunk); the same from the goal gives the cost of the edges into the goal (move costs are symmetric). Abstract A* runs with cells as node ids (open order: smaller f, larger g, smaller cell index), the octile (eight moves) or Manhattan heuristic times the smallest area cost, and edges expanded in the order: start edges or intra-edges by node index, edge to the goal, inter-edges by target. One iteration is one pop of the abstract open set; `IterationBudget` and `Step(n)` count these. The abstract path is refined by repeating the chunk-restricted search that priced each intra-edge (so the cells match the edge's cost); a border crossing is one step. **Smoothing** (paper section 5, on by default). Walking from path position `i` in each allowed direction (order: N, E, S, W, NE, SE, SW, NW) while moves are legal, a walk that meets a later path cell `j > i+1` and costs less than the path from `i` to `j` by more than 1e-9 is a candidate. The candidate with the largest `j` wins, then the lower cost, then the lower direction; the path continues from `j`. A cell visited twice by the path maps to its last position. `GridPath.Cost` is always the sum of the final cells' move costs. **Guarantees.** A path is found exactly when A* finds one; each step is a legal move; the cost is never below the optimum. It is not optimal: the paper reports a few percent over; Moving AI measurements are under Verification. **Incremental update.** The hierarchy is an `IGridChangeListener`. A cell edit at `(x, y)` marks every chunk intersecting `[x-(S-1)-1, x+1] × [y-(S-1)-1, y+1]` (`S` the agent size: the edit changes clearance up and left by `S-1`, and entrances read the neighbouring chunk's cell), clipped to the grid. `Refresh` (called by every query) rebuilds marked chunks in index order; the result equals a full rebuild. Edits to the area cost table (`AreaCosts.Version`) mark everything. `Grid` notifies listeners on every edit, so batches of edits cost one `Refresh`. **Cost model.** Build: one Dijkstra per node over at most C² cells; node count per chunk is roughly `4·C/T` to `4·C/2`. Memory: `n²` doubles per chunk. ## 3D voxel grids `VoxelGrid` (root namespace) is a bounded block of voxels, one byte each: an area id 0..254 or 255 for solid, the same ids and `AreaCosts` table as the square grid. It is searched by the same `AStar` through `VoxelTopology`. **Storage (normative).** The block is cut into 16 x 16 x 16 chunks. The chunk table is dense, `ceil(sx/16) * ceil(sy/16) * ceil(sz/16)` slots, slot index `cx + ChunksX * (cy + ChunksY * cz)`. A slot is null (every voxel of the chunk has the grid's default area, fixed at construction: 0 for open space, 255 for a solid block to carve) or a 4096-byte array indexed `(x & 15) | ((y & 15) << 4) | ((z & 15) << 8)`. An edit that differs from the default allocates the chunk, which then stays. Padding voxels past the size in an edge chunk are never read. At most 2^30 voxels. **Search nodes.** A voxel's node is `x + sx * (y + sy * z)`; it is the final open-list tie-break. State per node is cost 8, parent 4, mark 4, open-set position 4 (20 bytes). The one-line query (`grid.FindPath`) keeps that state in pages of 8 x 8 x 8 voxels (512 nodes, 10 KB), allocated when the search first reaches a voxel of the block and found through a dense table with one int per block (4 MB for 5x10^8 voxels); the parent is stored as a slot (page << 9 | in-page index), the in-page index is `(x & 7) | ((y & 7) << 3) | ((z & 7) << 6)`, and the search reads each voxel's coordinates from its page, so no node index is ever divided. Search memory is therefore proportional to the blocks the search touches. The paged search is the same algorithm as `AStar` (same open-list order, strict-improvement relaxation with reopening, neighbour order), so cost, path, expansion order and iteration counts are equal (tested against the dense search). Pages are pooled between queries; a pool above 4096 pages (about 40 MB) is dropped after the query. The tier-2 `AStar` with `GridSearchBuffers` stays dense, 20 bytes per voxel of the block (a 518-million-voxel block needs about 10 GB). **Moves and costs.** 6 (faces), 18 (+ edges) or 26 (+ corners) neighbours; the order is faces, then edges, then corners, each by z, then y, then x ascending from -1 to 1 (an 18- or 6-move view uses a prefix). Face moves have length 1, edge moves `GridMath.Sqrt2`, corner moves `VoxelMath.Sqrt3` (the double nearest the true root, 1.7320508075688772). The cost of a move is `length * ((m[from] + m[to]) * 0.5)`. The destination must be open. **Corner rule** (`VoxelCornerRule`) for edge and corner moves. The swept cells are those inside the move's cube other than the start and the destination: two face cells for an edge move; three face cells and three edge cells for a corner move. `NoCornerCutting` (default, the Moving AI voxel convention): all swept cells open. `NoSqueezing`: some monotone route of face steps from start to destination through open cells exists (an edge move needs one of its two face cells; a corner move needs a face cell and an edge cell containing it). `AllowAll`: the destination only. **Heuristic.** The exact empty-grid distance of the connectivity times `AreaCosts.MinCost`, so it is admissible and consistent. With sorted absolute offsets a <= b <= c and m = a + b + c: six moves, m; 26 moves, `a*sqrt3 + (b-a)*sqrt2 + (c-b)`; 18 moves, `e*sqrt2 + (m - 2e)` with `e = min(floor(m/2), a + b)`. A* tie-breaking and the open list are those of the square grid (`OpenSet`: smaller f, then larger g, then smaller node index, as documented above). **Changes.** `SetArea`/`SetWalkable` notify `IVoxelChangeListener`s and bump `Version`; topologies read the live chunk table, so a query after an edit equals a query on a grid built from scratch with the final contents (tested). There is no cached state to repair. **API tiers.** Tier 1: `grid.FindPath(VoxelCell|VoxelPoint, ..., VoxelOptions?)` with plain options and delegates (`StepCost`, `Blocked`), presets `VoxelOptions.Default/SixConnected/EighteenConnected`. Tier 2: `AStar` with struct cost hooks, resumable `Step(budget)` equal to the unsliced run. Tier 3: `VoxelGrid.GetChunk`, `VoxelTopology`, `VoxelHeuristic`, `GridSearchBuffers`. World positions are doubles within +-2^21 (`VoxelSpace`: origin and cell size, voxel (i,j,k) spans `origin + (i,j,k) * size`); anything beyond throws `ArgumentOutOfRangeException` naming the value; a position outside the block gives `StartInvalid`/`GoalInvalid`. **Moving AI voxel files.** `VoxelMap.Parse` reads `.3dmap` (`voxel X Y Z`, then one `x y z` line per *solid* voxel); `VoxelScenarios.Parse` reads `.3dscen` (`version 1`, the map name, then `sx sy sz gx gy gz length ratio`). Malformed input throws `GridFormatException` with the line. The published lengths of the Warframe set equal 26-move, no-corner-cutting costs with the constants above: the check allows an absolute 1e-6 (observed differences are below 1e-7: the files print eight decimals); the two looser rules give different costs on 95% of the first 2000 scenarios of Simple. ## Moving AI files `MovingAiMap.Parse` reads the `.map` format (`type`, `height`, `width`, `map`, then rows). `.` and `G` are walkable; `@`, `O`, `T` blocked; `S` (swamp) walkable and `W` (water) blocked by default (`MovingAiOptions`; the published optimal lengths of the wc3maps512 set need exactly this). `MovingAiScenarios.Parse` reads version 1 `.scen` files (9 whitespace separated fields per line). Both reject malformed input with `GridFormatException` (line number in the message); sizes are bounded (65536 per side, 2^28 cells) and a map's cells must be present in the file before they are allocated. ## Verification - Costs equal an independent O(n²) Dijkstra written from the definitions on 10^4 random grids per variant (4-/8-connected, the three diagonal rules, area costs, agent sizes 1-3, the four hex layouts); returned paths are checked move by move. - Published optimal lengths of the Moving AI 2D sets (dao, da2, bg512, sc1, wc3maps512, street, maze, random, room), 8-connected, no corner cutting, via `tools/GridOptima`. The published numbers differ from exact octile sums by at most 1.7e-9 of the length (consistent with some being summed with a diagonal cost of 1.41421356); the check allows a relative 1e-6. - Hash-matrix scenarios `grids/*` write status, cost bits, expansion counts and paths for generated maps. - Fuzz targets `movingai-map`, `movingai-scen`, `grid-query`, `grid-jps`, `grid-theta` (`fuzz/run.sh`). - JPS and JPS+: costs equal Dijkstra on 10^4 + 4·10^3 + 600 random grids, on rooms with gaps, and A* on grids wider than a bitmap word; table entries equal the cell-by-cell scan; incremental tables equal a rebuild over 1200 edit sequences; the block scan equals the cell scan with goals anywhere; every Moving AI 2D set above equals the published optimal costs with both (`ALG=jps sweep.sh`, `ALG=jps+ sweep.sh`, or `GridOptima --alg jps|jps+`). ### Any-angle verification - Line of sight equals an independent exact segment-versus-closed-square test (separating axes, integers) on 3,000 grids x 60 pairs and 300 grids up to 40 x 40, and is symmetric. - On 10^4 random grids up to 12 x 12 (and 2,000 with a unit cost of 2.5, 300 up to 30 x 30): same reachability as A*; every segment passes the exact test; the cost equals the segment lengths added in order, bit for bit; Theta* and `LazyRequeue` are never longer than A*; every variant is at least the visibility-graph optimum (Dijkstra over all line-of-sight links between walkable centres). - `GridOptima --alg theta|lazytheta` reports, on Moving AI scenarios, the ratio to the published eight-connected optimum (at most 1), re-verifies every 64th path with the independent test, and compares with the visibility-graph optimum on eight deterministic 40 x 40 windows per map (`--random N` does the same on random 24 x 24 grids). - Replanning: after every change in 10.8k random change/move sequences (LPA\* and D\* Lite; 4- and 8-connected, three diagonal rules, agent sizes 1-3, area costs, cell and tile edits, start moves) the replanned cost equals an independent O(n^2) Dijkstra and the library's A\*, and paths are legal and sum to their cost; hex layouts the same; sliced equals unsliced; incremental clearance and JPS+ tables after tile edits equal a full recompute. On Moving AI dao and room (`GridOptima --replan lpa|dstar`): random wall toggles along the current path, costs equal fresh A\*. - HPA*: refined paths are checked move by move against the brute-force move rules and priced independently on 10^4+ random grids (chunk 2-7, thresholds 1-8, both connectivities and rules, agent sizes 1-2, area costs): found exactly when Dijkstra reaches the goal, never cheaper than it, smoothing never longer; the hierarchy after random edit batches (agent sizes 1-3, area edits) equals a rebuild bit for bit; sliced equals whole for budgets 1-17. `GridOptima --alg hpa [--chunk N] [--smooth 0|1]` checks every Moving AI scenario's path and reports the mean and maximum percentage over the published optimum (chunk 16, smoothing on, 8-connected: dao 0.40% mean, bg512 0.33%, sc1 0.22%, room 0.08%; unsmoothed 4.9%, 4.1%, 3.2%, 1.6%; zero invalid or missing paths). Maxima are large only for very short scenarios (a few cells), where one detour through a transition is a big fraction. Fuzz target `grid-hpa`. - Voxel grids: costs equal an independent O(n²) Dijkstra (moves enumerated from the definition, corner rules evaluated over the move's bounding box) on 10^4 random blocks per connectivity (all three corner rules, area costs, both heuristics), on larger blocks across chunk borders, and the heuristic equals the empty-grid distance; edits equal rebuilds; sliced equals unsliced for budgets 1, 2, 7 and 50. Fuzz targets `voxel-map`, `voxel-scen`, `voxel-query`; hash scenarios `grids/voxel/*`; the Warframe set via `tools/GridOptima/voxel-sweep.sh` (`GridOptima --voxel`). ## Host API: resumable handle, tick-path calls, hash, snapping, exact costs For a lockstep or rollback host whose simulation ticks must not allocate. **`GridSearch`** (`Grid.CreateSearch(options)`) is a non-generic resumable A\*, JPS or JPS+: `TryBegin(start, goal)` returns `InProgress`, `StartInvalid` or `GoalInvalid` (the checks `FindPath` makes: inside the grid, clearance for `AgentSize`, `Blocked`; a refusal leaves the running search alone; `Begin` is the throwing twin), `Step(n)` pops at most `n` times and returns `InProgress`, `Found` or `NoPath`, `CopyPath(Span)` / `CopyPath(Span)` write the path, `SaveState`/`RestoreState` snapshot it (`grids-state.md`, kind 12). It supports `GridAlgorithm.AStar`, `Jps` and `JpsPlus` (the others throw `ArgumentException`). It chooses its engine exactly as `FindPath` does: for A\* the specialised octile engine when the options allow it, otherwise the generic `AStar` instantiation `FindPath` would build; for JPS and JPS+ the specialised octile jump point engine when the options allow it, otherwise the generic `JumpSearch` over the block map (JPS) or the JPS+ tables, and options a jump point search cannot honour (four moves, agent size above 1, area costs that differ, callbacks, a weighted or Manhattan estimate) run plain generic A\*, as `FindPath` does. One iteration of a jump point search is one pop of a jump point (the scans that find jump points are not counted). `FindPath(start, goal, o)` is `TryBegin` then `Step(o.IterationBudget)`, so for every slicing the handle returns cell for cell the path `FindPath` returns, with the same `ExpandedCount` and cost bits (tested on generated random, room, maze, cave and area-cost maps and eight option variants for A\*, nine for JPS and JPS+). The grid must not be edited while a search is in progress (`Step` throws `InvalidOperationException`). After the first search `TryBegin`, `Step`, `CopyPath`, `GetStateSize`, `SaveState(Span)` and `RestoreState` allocate nothing on every engine; a state save costs a pass over the cells plus the visited cells (the generic engines serialise their own state into a buffer the handle keeps, which grows to the largest state seen). **Which engine breaks ties how.** The specialised engine (`Grids/Fast`) keeps costs as exact integer pairs (straight steps, diagonal steps), orders its open list by `f`, then larger `g` rounded to float32, then smaller cell index, and relaxes neighbours in the order N, E, S, W, NE, SE, SW, NW. The generic engine sums double step costs and orders by `f`, then larger double `g`, then smaller node index, in the same neighbour order. Both are optimal, but on a tie between equal-cost paths they can choose different cells. We did not make the generic engine reproduce the pairs: its cost type is an arbitrary `double` (hooks, area multipliers), so no exact pair exists, and the specialised engine's speed depends on them. Instead **`GridOptions.SpecializedEngines = false`** forces the generic engine in `FindPath` and `CreateSearch`, and that is then cell for cell what a hand-built `AStar(topology, default, new OctileHeuristic(width, Areas.MinCost))` returns (tested). A host that mixes tier-1 and tier-2 searches and needs one answer per request sets it for all of them; otherwise use one entry point per request kind. JPS and JPS+ honour the switch too (off means the generic jump search). **`AStar.TryBegin(start, goal)`** (generic form) returns the same statuses as an integer-node check instead of throwing; `AStar.Topology`, `Start` and `Goal` are read-only views. **`GridReplanner.TryReplan(budget)`** repairs like `Replan(budget)` but returns a `PathStatus`; on `Found` read `PathLength`, `PathCost` (summed from the start in path order, bit-equal to `Replan().Cost`) and `CopyPath`. Path buffers are reused, so a steady-state call allocates nothing. `RecordExpansions` is not kept by this form. **`Grid.ContentHash()`** (64 bit): feed words `w` into `h = rotl(h ^ (w * P2), 31) * P1 + P3`, from `h = P1 ^ P2`, with `P1 = 0x9E3779B185EBCA87`, `P2 = 0xC2B2AE3D27D4EB4F`, `P3 = 0x165667B19E3779F9` (all arithmetic mod 2^64). Words, in order: `width | height << 32`; the cells row-major, eight per little-endian word, the last zero-padded; the 255 area multipliers 0..254 as binary64 bit patterns. Finish: `h ^= h >> 33; h *= P2; h ^= h >> 29; h *= P3; h ^= h >> 32`. Version, world mapping and edit history are not part of it. Known answers are in the tests. The definition changes only with a note in the changelog. The value is cached: a cell edit (`SetArea`, `SetRect`), a state restore or any area-cost change (`Areas[...]`, `CopyFrom`) invalidates it, and the next call recomputes; a repeated call with no edit in between is O(1) and returns the same bits a recompute would. `HexGrid` and `VoxelGrid` have no content hash. **`NavMesh.Build2D(Grid, NavSettings?)`** builds the 2D mesh from a square grid: a cell is walkable when its area is not 255, cell size and origin come from `Grid.Space` (grid column x is world X, row y is world Z), and the result is byte-equal to `Build2D(bool cells, width, height, cellSize, origin)` on the same walkability. Area ids and multipliers are not carried into the mesh (the 2D builder gives every polygon `NavSettings.Area`); agent size comes from `NavSettings.AgentRadius`, not from grid clearance. A grid without walkable cells gives a mesh with no tiles. **Large agents.** An agent of side *s* occupies the *s* × *s* block whose top-left cell is the position. `CanFit(cell, s)` is `clearance >= s`, the test `FindPath` applies to its end cells; `MaxAgentSizeAt(cell)` is the clearance without exceptions (0 outside the grid). **`Grid.TryFindNearestWalkable(cell | world point, out cell, options)`** returns the cell that fits `AgentSize` and is not refused by `Blocked`, nearest by squared Euclidean distance between cell indexes, ties to the smaller row and then smaller column. The query may be outside the grid. `NearestRadius` bounds the search in Chebyshev rings. It scans rings outward and stops once a ring is farther than the best hit. **Exact integer costs.** `Grid.TryGetFixedCost(cells, out long)` prices a path of adjacent cells in Q31.32: every step is the double the default search charges (the mean of the two cells' area multipliers; times `1.4142135623730951` for a diagonal), multiplied by 2^32 (exact) and rounded half to even, and the integers are added. `GridPath.TryGetStepCounts` gives the exact numbers of straight and diagonal steps. Both refuse a path with a non-adjacent step (any-angle, abstract) and `StepCost` hooks are not priced. ## Host hooks Snapshot and restore of grids, voxel grids, graphs, replanners, flow fields and sliced searches, the byte format, what one iteration of each family's budget is, and the debug geometry types are specified in `grids-state.md`. # Graphs: generic and point graphs, A*, bidirectional search, ALT The tier-1 types (`Graph`, `PointGraph`, `GraphOptions`, `GraphPath`, `Landmarks`, `LandmarkOptions`, `GraphEdge`, `Point3`) live in the root namespace `RecursiveAI.OnePath`; the CSR form, searches and heuristics (tiers 2 and 3) live in `RecursiveAI.OnePath.Graphs`. Graphs are their own family: grids do not route through these types. Everything here follows the same determinism rules as the grids: IEEE-exact `+ - * / sqrt` on double, ties broken on node ids, no hash-collection order, no clocks. A port that follows this text reproduces the same paths, costs and expansion orders bit for bit. ## Layout - `GraphEdge(from, to, weight)`: a directed edge. Weights are finite doubles, 0 or more (zero-weight edges are allowed). `Point3(x, y, z)`: a position; `Z = 0` for plane graphs. - `CsrGraph` (tier 3): immutable compressed sparse rows. Node ids `0..N-1`, at most 2^26 nodes. Out-edges of `v` are slots `offsets[v]..offsets[v+1]-1` of `targets`/`weights`; the reverse adjacency (`reverseOffsets`, `sources`, `reverseWeights`) is stored alongside. **Order:** a stable counting sort, so within one node the edges appear in input-list order, in both directions. Parallel edges and self-loops are kept. Construction validates endpoints and weights (typed `ArgumentException` naming the edge index). - `CsrGraph.Fingerprint()`: 64-bit FNV-1a (offset 14695981039346656037, prime 1099511628211) over the little-endian bytes, low byte first, of: node count; every offset; then for every edge slot its target followed by the IEEE bit pattern of its weight. - `Graph` (tier 1): a mutable edge list that compiles to a `CsrGraph`. Node ids are stable (nodes are never removed); every edge gets an id (its position in the list of all edges ever added) that is never reused. Removing an edge or changing a weight marks the compiled form stale; the next query, or `Compile()`, rebuilds it in O(nodes + edges) from the live edges in id order. `Graph.From(edges, nodeCount = -1, directed = false)` adds each listed edge in both directions unless `directed`; the reverse of edge `i` has id `2i+1`. - `PointGraph` (tier 1): positions plus a `CsrGraph` whose weights are straight-line distances; immutable. ## Search `GraphAStar` is A* (Hart, Nilsson & Raphael 1968); with the zero estimate it is Dijkstra. It is the grid search's loop transplanted onto CSR, and it uses the grid core's `OpenSet` and `GridSearchBuffers` unchanged: - Open list order: smaller `f`, then larger `g`, then smaller node id (see `grids.md`, *Open set*). - `g[start] = 0`; push `(h(start, goal), 0)`. An iteration is one pop. The goal test happens at pop. - Expanding `n`: for each out-edge slot in CSR order, `c = cost(n, v, weight)`; skip the edge if `c` is NaN, negative or +infinity; `g' = g[n] + c`; if `v` was never reached or `g' < g[v]` strictly, then compute `h = estimate(v, goal)`; if `h` is +infinity (the goal cannot be reached from `v`) skip `v`; otherwise set `g[v] = g'`, `parent[v] = n`, push `(g' + h, g')`, reopening `v` if it was closed. Equal-cost alternatives keep the first parent found. - `ExpandedCount` counts expansions (the goal's pop is not one); `Iterations` counts pops. - Sliced search: `Step(n)` does at most `n` iterations; any sequence of budgets gives the same result as one run. Tier-1 `GraphOptions.IterationBudget` ends the query with `PathStatus.BudgetExhausted` after that many iterations. `Run(token)` checks cancellation every 256 iterations. ### Estimates `IGraphHeuristic.Estimate(source, target)` is a lower bound on the distance from `source` to `target`; +infinity means "unreachable". Admissible and consistent estimates give optimal paths. Tier-1 combines every estimate that exists by taking the maximum (a maximum of consistent estimates is consistent): the straight-line distance of a point graph, the ALT bound of `GraphOptions.Landmarks`, and `GraphOptions.Heuristic`. Straight-line distance is consistent only while every edge weight is at least the distance between its endpoints (true for the weights `PointGraph` builds, not for an `EdgeCost` hook that lowers them). `Algorithm = Auto` runs A* when an estimate exists and Dijkstra otherwise; asking for an A* variant without any estimate runs the Dijkstra variant, and `GraphPath.Algorithm` reports what ran. ## Bidirectional search `BidirectionalGraphSearch` runs a forward search from the start over the out-edges and a backward search from the goal over the in-edges (cost hooks are always called with the edge's true direction), each in its own `GridSearchBuffers`. - **Potentials** (Ikeda et al. 1994; Goldberg & Harrelson 2005): `pf(v) = (h(v, goal) - h(start, v)) / 2`, computed as `0.5 * (ht - hs)`. The forward key is `g + pf`, the backward key is `g - pf`; with the zero estimate both are plain Dijkstra keys (bidirectional Dijkstra, Pohl 1969). A node for which either estimate is +infinity cannot lie on a start-goal path and is never pushed. Both heuristics are evaluated, so the estimate must bound distances in both directions. - **Turns:** strict alternation, forward first. One iteration is one expansion of either side. A side expands as in the unidirectional search (same open set order, same strict-improvement rule, same reopening). - **Meeting:** whenever a relaxation improves `g[v]` on one side and the other side has reached `v`, the candidate `mu' = g_this[v] + g_other[v]` replaces the best cost `mu` if strictly smaller (so the first-found of equal candidates stays); `meet = v`. - **Stopping rule:** before every expansion, stop when either open set is empty (every node that side can reach is settled), or when `peekF(forward) + peekF(backward) >= mu`. For consistent estimates this is exact: no unsettled path can be cheaper than `mu`. Stopping at the first meeting would be wrong and is not done. If `mu` is infinite at the stop there is no path. The check precedes the budget test, so finishing never consumes budget and slicing cannot change the result. - **Path:** the forward parent chain from `meet` back to the start, then the backward parent chain from `meet` to the goal. - `start == goal` is found at once with cost 0. - With fractional weights the sums `g + p` and the stopping comparison are floating-point: costs equal Dijkstra's to within rounding (a few ulps). With weights that are integers (or any dyadic values) the results are exactly equal. ## ALT Goldberg & Harrelson, *Computing the Shortest Path: A\* Search Meets Graph Theory*, SODA 2005. `Landmarks` stores, for each landmark `L`, `d(L, v)` (forward Dijkstra) and `d(v, L)` (Dijkstra over the reverse edges) for all `v`, as doubles (+infinity when unreachable). Layout `table[l * N + v]`. - **Bound:** `LowerBound(u, v) = max(0, max over L of max(d(L,v) - d(L,u) [if d(L,u) finite], d(u,L) - d(v,L) [if d(v,L) finite]))`. Admissible and consistent; works for directed graphs; the result is +infinity when the table proves `v` unreachable from `u`. Landmarks are used in table order and all of them are always used. - **Shortest-path trees** use `OpenSet` with key `(d, d)`, so equal distances settle by smaller node id; a node's parent is the first predecessor that gave it its final distance (strict improvement only). - **Selection** (`SeededRandom(seed)` provides the roots; `NextInt(N)` per landmark, drawn even when not needed): - *Farthest:* the first landmark is the node with the greatest finite distance from a random root (ties: smaller id); each next one is the node (not yet a landmark, finite distance) with the greatest distance to its nearest landmark in the forward tables (ties: smaller id). - *Avoid* (the default): grow the forward shortest-path tree from a random root `r`; weight of `v` is `d(r, v) - LowerBound_current(r, v)`; `size(v)` is the sum of weights in the subtree of `v`, or 0 (blocked) if the subtree holds a landmark; the heaviest unblocked subtree root (ties: smaller id) is chosen, then descend to the child with the greatest size (ties: smaller id) until a leaf; the leaf is the landmark. - If no candidate exists (nothing reachable, all weights zero) the smallest-id node that is not yet a landmark is used. - The count is capped at the node count; landmarks are distinct. - **Serialization** (`Serialize`/`Deserialize`), little-endian: `"OPLM"`, u32 format version `1`, i32 node count, i32 landmark count, u64 graph fingerprint, i32 landmark ids, forward table, backward table (f64 bit patterns, landmark-major). `Deserialize(bytes, graph)` is strict: exact length, count in `1..N`, ids in range, fingerprint equal to the graph's, every distance `>= 0` or +infinity (NaN and negatives rejected), landmark-to-itself distance 0. Anything else is a `GraphStateException`. - **Staleness:** a table belongs to the graph it was built for. A query with `GraphOptions.Landmarks` compares the table's graph (same object, or same node count and fingerprint) with the queried graph and throws `InvalidOperationException` on a mismatch, so edits to a `Graph` after building landmarks are caught. Tables assume edge costs are not below the stored weights; an `EdgeCost` hook that lowers costs voids the guarantee. ## Point graphs - **Coordinates** obey the coordinate rule: finite and within ±2^21 on every axis, otherwise `ArgumentOutOfRangeException` naming the point, the axis, the value and the bound. Distance is `sqrt((dx*dx + dy*dy) + dz*dz)` evaluated exactly in that order. - **Link sources** (all optional, merged): explicit pairs; every pair within `MaxDistance` (inclusive; compared as squared distance); each point's `NearestCount` nearest points (with `MaxDistance` also set: only those within it). Self-links and duplicate pairs are dropped; every pair becomes two edges weighted with the distance. Pairs are sorted by (smaller index, larger index) and emitted as `a -> b` then `b -> a`, so edge order is canonical. - **Nearest:** by (squared distance, index), ties to the smaller index, found by rings of grid cells; cell size is `max(2^-30, extent / max(1, floor(sqrt(N))))` (1 for zero extent). After scanning rings `0..r` everything strictly closer than `r * cell` is known, so only such candidates are examined, in order. Links are made mutual. - **Validation delegate** `LinkValid(a, b)` (a line-of-sight raycast, say) is applied to generated links only, with the lower point index first. With `NearestCount` it filters candidates in (distance, index) order until `k` valid ones are found, so a point near a wall links to its nearest visible points. It is called in a fixed order, so a deterministic delegate yields a deterministic graph. - **Permutation:** the set of links (as pairs of positions) is the same for any ordering of the input points when `MaxDistance` or explicit links are used; with `NearestCount` it is the same unless two candidates for a point's last slot are at exactly equal distance, where the smaller index wins. Spatial hashing uses exact integer cell coordinates and only looks cells up, never enumerates them. `MaxDistance` is 0 (off) or at least 2^-30. - `NearestNode(p)`: linear scan, smaller id on ties. `FindPath(Point3, Point3)` snaps both ends with it. ## API tiers 1. `Graph.From(edges).FindPath(a, b)`, `PointGraph.From(points, options).FindPath(a, b)` (or by position), with `GraphOptions` carrying plain delegates: `EdgeCost(from, to, weight)`, `Blocked(node)`, `Heuristic(from, to)`, plus `Algorithm`, `Landmarks`, `IterationBudget`, `RecordExpansions`; presets `GraphOptions.Default`, `Bidirectional` and `WithLandmarks(table)`; `graph.BuildLandmarks(LandmarkOptions?)` (count 16, avoid, seed 1 by default). No struct callbacks. The options are adapted onto one struct adapter per hook, so the happy path instantiates each search once. 2. `GraphAStar` and `BidirectionalGraphSearch` with `IGraphCost` / `IGraphHeuristic` structs (`DefaultGraphCost`, `ZeroGraphHeuristic`, `EuclideanGraphHeuristic`, `AltGraphHeuristic`), caller-owned buffers and `Begin`/`Step`/`Run`. 3. `CsrGraph`, `Landmarks`, `ShortestPathTree.Compute`, `OpenSet`, `GridSearchBuffers`. Tier-1 `FindPath` reuses pooled working memory (one pair of buffers per graph, taken and returned atomically), so a steady stream of queries allocates only the result. Concurrent queries on an unedited graph are safe; edits must not overlap queries. ## Verification - Every variant (Dijkstra, A*, bidirectional Dijkstra, bidirectional A*) with no estimate, landmarks, a geometric estimate and both equals an independent O(V^2) Dijkstra on 12,000 generated graphs (sparse directed with zero weights, dense, grid-like, road-like), with paths checked edge by edge; fractional-weight point graphs agree to 1e-9 relative. - Landmark bounds never exceed true distances and are consistent on 200 graphs; serialization round-trips and hostile bytes give `GraphStateException`. - Sliced equals unsliced for several budgets; link building equals brute force and is permutation-stable. - Hash-matrix scenarios `graphs/*`; fuzz targets `graph-query`, `graph-landmarks`, `graph-points`. - `tools/GraphRoad` prints expansion counts for the four searches on a DIMACS road graph (`--gr`) or a generated planar road-like graph (`--generate`). # Grid, graph and search state: snapshot and restore A host that rolls back (lockstep with prediction, rollback netcode, replays) needs to put the mutable pathfinding state back exactly and get the results it would have got had it never left. This document is the normative description of what is saved, the byte format, what is *not* saved and why, and what each family counts as one iteration of a budget. A port must reproduce the same bytes. ## What is covered | Object | Saved | Rebuilt after restore | |---|---|---| | `Grid`, `HexGrid` | cells, the 255 area multipliers and the area table's version, `Version`, `Space` | clearance (recomputed in place at once if it had been computed), jump point indexes, HPA\* hierarchies (dropped; built on the next query) | | `VoxelGrid` | which chunks are allocated and their 4096 bytes (padding included), area table and version, `Version`, `Space` | nothing | | `Graph` | node count and every edge ever added in id order (from, to, weight, alive) | the compiled CSR graph, on demand | | `GridReplanner`, `HexReplanner` (LPA\*, D\* Lite) | start, goal, g and rhs of every planned node, the queue in heap order, km, pending edits and the overflow flag, counters, the area version it was built against | the search object itself (so heuristic scale follows the restored area table) | | `GridFlowField` (square and hex) | goals, integration costs, direction steps, the open heap of an unfinished run or repair, the touched list of a repair in progress, `Settled`, status, pending edits, area version | repair scratch stamps | | `GridSearch` (the non-generic A\*, JPS and JPS+ handle) | the engine in use and, for the specialised A\* engine, the visited cells with their exact cost pair, direction and closed flag; for the specialised jump point engine the visited cells (cost pair, parent, direction, closed flag) and the open list exactly as it lies; for the generic engines the embedded `AStar` or `JumpSearch` state | the open list of the specialised A\* engine (rebuilt from the visited cells), the engines' wall tags and maps | | `AStar`, `JumpSearch`, `ThetaSearch`, `GraphAStar` (the sliced `Begin`/`Step` forms) | status, goal (and start for A\*), counters, path cost, every visited node (closed flag, g, parent), the open heap in heap order, the recorded expansion list | the heuristic's goal, the jumper's index | Not covered, and why: **HPA\*** and **bidirectional** sliced searches (an HPA\* search works on temporary abstract nodes of the hierarchy it was built on, a bidirectional search has two buffer sets and a meeting node; both are cheap to restart and neither is what a host holds across frames, but their *grids* roll back like any other), `PointGraph` and `CsrGraph` and `Landmarks` (immutable), the navmesh (its own snapshot: `docs/navmesh-snapshot.md`). Tier-1 one-shot queries keep no state between calls. ## Restoring: the contract * `SaveState()` returns a `byte[]`; `RestoreState(ReadOnlySpan)` puts it back in place; `FromState(...)` on `Grid`, `HexGrid`, `VoxelGrid` and `Graph` creates a new object. Saving twice without a change gives the same bytes; save → restore → save gives the same bytes; the same history gives the same bytes on every platform. * Objects that are *built over* something (a replanner, a flow field, a sliced search) are restored in place into an object that was created the same way: same grid (restored first), same options (agent size, connectivity, diagonal rule, algorithm, delegate hooks, heuristic weight). Delegates cannot be saved, so the caller supplies them again. What can be checked is checked: sizes, algorithm, mode, goals and the heuristic weight must match, otherwise `GridStateException` names the mismatch. (Graph state, saved by `Graph.SaveState` and `GraphAStar`, throws `GraphStateException` instead; both are `OnePathFormatException`.) * **Order:** restore the grid, then everything built on it. Restoring a grid tells its listeners: flow fields, replanners and HPA\* hierarchies drop what they derived and rebuild on next use; other listeners see each cell that differs as an ordinary edit (voxel listeners likewise per voxel, in ascending node order). Restoring a dependent afterwards replaces that rebuild with its exact saved state. * **Versions can repeat after a rollback** (restore to version 7, edit once, and an object cached at "version 8" from the other timeline matches by number only). Nothing derived is therefore trusted across a restore; this is why the caches above are dropped. * A restore validates before it changes anything where it can: `Grid`, `HexGrid`, `VoxelGrid`, `Graph`, `GridReplanner` and `HexReplanner` are unchanged when `GridStateException` or `GraphStateException` is thrown. Flow fields and sliced searches are read in place and are unusable after a failed restore until restored again, recomputed (`Recompute`) or begun again. * Hostile bytes: every count is bounded by the object's size and by the bytes left, every node index and parent is range-checked, heaps may not list a node twice, NaN is rejected, and the parent links of a search must lead only to visited nodes that cost no more and contain no cycle. The state is *structurally* validated, not semantically: a restored cost field (replanner g/rhs, flow integration costs) is trusted to be consistent, as a snapshot of your own memory is. An inconsistent one cannot index out of range or hang a bounded call, but it can make a path query report `InvalidOperationException` ("direction field contains a cycle", "has a cost but no path"). * A graph state names its node count outright (isolated nodes cost no bytes), up to the 2^26 `new Graph(n)` allows: restoring allocates nothing for them, but the first compile or query does. * Must not overlap a running query or an edit on the same object (as for edits in general). ## Byte format (version 1) Little-endian throughout (`BinaryPrimitives`); `i32` is a signed 32-bit integer, `f64` a binary64 written by its bit pattern, `u8 flag` is 0 or 1 (any other value is an error). Counts are `i32` and must be in range. Header (8 bytes): `u32 magic = 0x5347504F` (the bytes `4F 50 47 53`, "OPGS"), `u16 format version = 1`, `u16 kind`. | kind | object | |---|---| | 1 | Grid | | 2 | HexGrid | | 3 | VoxelGrid | | 4 | Graph | | 5 | GridReplanner | | 6 | HexReplanner | | 7 | GridFlowField | | 8 | AStar | | 9 | JumpSearch | | 10 | ThetaSearch | | 11 | GraphAStar | | 12 | GridSearch | After the payload nothing may follow. ### Pieces * **Space** (GridSpace): `f64 originX, f64 originY, f64 cellSize`. All three zero is the unassigned default (behaves as unit cells); otherwise the same range rules as the constructor apply (|origin| ≤ 2^21, 0 < cellSize ≤ 2^21). VoxelSpace has four values, `originX, originY, originZ, cellSize`. * **Areas**: `i32 version` (≥ 0), then 255 × `f64` multiplier for areas 0..254 (each > 0 and finite). Area 255 is the wall and is not stored. * **Heap `OpenSet`** (F, G, node): `i32 count`, then per slot in heap order `i32 node, f64 F, f64 G`. Slots are written as they lie in the array so a restored heap behaves exactly as the original; nodes must be distinct and F, G not NaN. * **Heap `ReplanOpenSet`** (key1, key2, node): `i32 count`, then per slot in heap order `i32 node, f64 key1, f64 key2`. * **Search buffers** (over `nodeCount` nodes): `i32 visited`, then per visited node in increasing index `i32 node, u8 closed, f64 g, i32 parent` (parent −1 or a node); then the open heap (`OpenSet`); then `i32 expandedCount` and that many `i32` nodes. "Visited" means seen by the current search; the buffers' epoch counter is not saved because it never affects a result. ### Objects * **Grid** (1): `i32 width, i32 height, i32 version, Space, Areas, u8[width*height] cells` (row-major). Restore requires the same width and height. * **HexGrid** (2): `i32 width, i32 height, i32 layout, i32 version, Space, Areas, cells`. Same width, height and layout required. * **VoxelGrid** (3): `i32 sizeX, sizeY, sizeZ, u8 defaultArea, i32 version, VoxelSpace, Areas`, then for each of `chunksX*chunksY*chunksZ` chunks (x fastest, then y, then z) `u8 flag` and, when 1, 4096 bytes (index `x | y<<4 | z<<8` within the chunk, padding past the grid edge included). Same size and default area required. * **Graph** (4): `i32 nodeCount` (0..2^26), `i32 edgeCount`, then per edge in id order `i32 from, i32 to, f64 weight, u8 alive` (endpoints < nodeCount, weight ≥ 0 and finite). * **Replan search**: `i32 nodeCount, u8 mode (0 LPA*, 1 D* Lite), i32 start, i32 goal, u8 initialized, u8 found, i64 iterations, i64 expanded, i32 lastExpanded`. When initialized: `f64 km, u8 overflow, i32 pendingCount` and the pending entries (`i32`: node, or the bitwise complement of the node when its neighbours are to be re-examined too; none when the overflow flag is set), `i32 plannedCount` and per node with a finite g or rhs, in increasing index, `i32 node, f64 g, f64 rhs`, then the `ReplanOpenSet`. An uninitialised search (never planned, or restarted by `InvalidateAll`, `SetGoal`, or a start move under LPA\*) stores nothing more, and reads back with g and rhs at +∞: its old values are never used because planning overwrites them. * **GridReplanner / HexReplanner** (5, 6): `i32 width, i32 height, u8 algorithm (GridAlgorithm value), i32 areaVersion, i32 start, i32 goal`, then the replan search. * **Flow field search**: `i32 nodeCount, u8 status (0 not started, 1 running, 2 complete), i32 settled, u8 repairing, i32 goalCount` and goal nodes (strictly increasing), `i32 reachedCount` and per node with a finite cost in increasing index `i32 node, f64 cost`, `i32 stepCount` and per node with a step, in increasing index, `i32 node, i32 next` (the next node must be reached and cost no more than the node), the `OpenSet`, and when repairing `i32 touchedCount` and that many distinct nodes. * **GridFlowField** (7): `i32 width, i32 height, i32 areaVersion, i32 goalCount` and the goal nodes in the order given at creation (must equal the field's own), `i32 pendingCount` and the pending edited cells, then the flow field search. * **A\*, jump point, Theta\*, graph A\*** (8–11): `i32 nodeCount, u8 status (SearchStatus), f64 heuristicWeight, i32 start (−1 if unused), i32 goal, i32 expanded, i32 iterations, f64 pathCost`; unless the status is NotStarted, the search buffers. The weight must equal the restoring search's (A\* and Theta\*; 1.0 for the others). Every visited node's parent must be -1 or a visited node that costs no more, with no cycles. * **GridSearch** (12): `u8 mode (0 not begun, 1 specialised A*, 2 generic, 3 specialised jump point), i32 width, i32 height`. Mode 1: `u8 status, u8 zeroEstimate, i32 start, i32 goal` (cell indexes), `i32 expanded, i32 iterations, i32 pathA, i32 pathB, i32 visited`, then per visited cell in increasing index `i32 cell, i32 a, i32 b, u8 flags` (a, b: straight and diagonal steps so far; flags bit 0 closed, bits 1–3 the direction to the parent: 0 south, 1 west, 2 north, 3 east, 4 south-west, 5 north-west, 6 north-east, 7 south-east). Restore checks that every cell but the start has a closed visited parent whose pair differs by exactly one step, that the start has cost 0, and that a found search has its goal closed with the reported pair; the open list is the visited cells that are not closed. Mode 2: `i32 startX, startY, goalX, goalY, i32 length` and an embedded AStar (8) state. The restoring search must pick the same engine from its options and the (restored) grid. Mode 2 holds an embedded AStar (8) state when the options select A\* (including a jump point algorithm whose options fall back to A\*) and an embedded JumpSearch (9) state when they select the generic jump point search; the embedded kind is checked. Mode 3 (added without changing the format version; older readers refuse it as an undefined mode): `u8 finder (0 JPS block scans, 1 JPS+ tables)`, then the same fixed fields as mode 1 (`u8 status, u8 zeroEstimate, i32 start, goal, expanded, iterations, pathA, pathB, visited`), then per visited cell in increasing index `i32 cell, i32 a, i32 b, i32 parent, u8 flags` (a, b: straight and diagonal steps of the cost; parent −1 for the start; flags bit 0 closed, bits 1–3 the direction of the last move, 0 north, 1 east, 2 south, 3 west, 4 north-east, 5 south-east, 6 south-west, 7 north-west), then the open list as it lies: `f64 level` (bit pattern; NaN when no entry has popped), `i32 heapCount` and per slot in array order `f64 F, u64 T` (T: the bitwise complement of the cost so far as float32 in the high 32 bits, the cell in the low 32), `i32 stackCount` and that many `i32` cells (the entries of the current level, popped last-in first-out). The list is saved as it lies, stale entries included, because the pop order among equal estimates depends on the order of pushing. Restore checks that: every cell is inside the grid, walkable and listed once in increasing order; the start has cost 0, no parent, and is closed unless nothing has popped; every other cell has a closed visited parent that is a valid hop back (the cost pair grows by max(|dx|,|dy|) − min(|dx|,|dy|) straight and min(|dx|,|dy|) diagonal steps) and a direction that points along the hop; the goal is closed exactly when the status is Found, with the reported pair; the counters equal the closed cells (iterations = closed, expanded = iterations − 1 when found); every entry names a visited cell, the heap array is a heap, every open cell has an entry, and a search that ran out of cells has none. The restoring search must select the specialised jump point engine and the same finder from its options and the (restored) grid. Restoring a grid and then a search needs no extra step: the search compares the grid's version and restore count, not its own cache. ## What one iteration is Every sliced form counts iterations, never time; a budget of *n* does the same work on every machine, and sliced = unsliced for every budget (tested). | Family | One iteration | Not counted | |---|---|---| | A\*, voxel A\*, Theta\*, Lazy Theta\* | one open-list pop (the goal's pop included) | line-of-sight checks, relaxing neighbours | | JPS, JPS+ (also through `GridSearch`) | one pop of a jump point | the scans that find jump points | | HPA\* | one pop in the abstract graph (temporary start and goal nodes included) | building or refreshing the hierarchy, refining the path, smoothing | | LPA\*, D\* Lite (`Replan(budget)`) | one queue pop, including a pop that only re-keys a stale entry | applying pending edits | | Flow field (`Step(n)`, `IterationBudget`) | one settled cell | the invalidation walk that starts a repair; the final direction pass | | Graph A\*, Dijkstra | one pop | | | Bidirectional graph search | one pop from one side, sides alternating | | Tier-1 `GridOptions.IterationBudget`, `VoxelOptions.IterationBudget` and `GraphOptions.IterationBudget` (0 = unlimited) are a single cap: the query stops with `PathStatus.BudgetExhausted` and is not resumable. To resume, use the tier-2 `Begin`/`Step` form, whose state can also be saved. ## Debug geometry `RecursiveAI.OnePath.Debug.GridDebug.Describe(...)` copies a result into immutable plain data: | Input | Data | |---|---| | `GridPath` (A\*, JPS, JPS+, Theta\*, Lazy Theta\*, HPA\*, hex, replanner results) | algorithm, status, cost, path cells and world points, expanded cells in order, expanded count | | `VoxelPath` | the same for voxels | | `GraphPath` | nodes, positions, expanded nodes, cost, algorithm | | `GridFlowField` | integration costs, next cell per cell, goals, status, settled (a sliced run shows its progress) | | `HpaHierarchy` | abstract nodes (entrance cells) with their chunk, intra-chunk and inter-chunk edges with costs | Expansion lists are opt-in (`RecordExpansions` on `GridOptions`, `VoxelOptions`, `GraphOptions`), so the happy path pays nothing; when recorded, the list's length equals `ExpandedCount`. What an "expanded cell" is: the cell popped and expanded (A\*, voxel, Theta\*); the jump point popped (JPS); the abstract node's cell (HPA\*); for a replanner result, each node that replan settled (overconsistent) or reset (underconsistent), in order. # Navigation mesh queries Normative behaviour of `NavMeshQuery` (namespace `RecursiveAI.OnePath.Mesh.Queries`) over a `TiledNavMesh` (`docs/navmesh-format.md`). Everything here is deterministic: results depend only on the mesh, the inputs and the options, never on platform, thread count or time. All arithmetic is IEEE `+ − × ÷ sqrt` on double (positions are world doubles; vertices are float32 tile-local values widened to double and added to the double tile origin). ## Inputs and errors - Positions are `Vector3D` doubles. A non-finite coordinate or one with |v| ≥ 2^21 throws `ArgumentOutOfRangeException` naming the value. Search extents must be finite and not negative (same exception). - Polygon references that are invalid, stale (the tile was removed or replaced) or rejected by the filter never throw: the call returns a status (`InvalidStartPoly`, `InvalidEndPoly`, `RaycastStatus.InvalidStartPoly`, `InvalidCorridor`) or `false`. - A query object is not thread-safe, may be reused for any number of queries, and allocates nothing in steady state when the caller supplies the output buffers (the `Span` forms). The one-call forms that return `PathResult` allocate the result only. ## Filters and costs A filter decides which polygons may be used and prices travel: - `QueryFilter` (plain): `IncludeFlags` (default 0xFFFF = anything, including polygons with no flags set; otherwise at least one flag bit must match), `ExcludeFlags` (default 0; any match rejects), per-area multipliers `WithAreaCost(area, cost)` (returns a new filter; a filter is immutable once built) (default 1; `+∞` forbids the area; costs must be > 0), and an optional `Func` predicate. - `IQueryFilter` (struct generic, for speed): `Passes(flags, area)`, `AreaCost(area)`, `MinAreaCost`. - `DefaultQueryFilter` is the struct form of `new QueryFilter()` (every polygon passes, every area costs 1, `MinAreaCost` 1): same results, and it lets the span `FindPath` run allocation-free without writing a filter. `MinAreaCost` must not exceed the true minimum area cost or paths stop being optimal; 0 disables the heuristic. Off-mesh links are tested with their own flags and area as well as the target polygon's. ## Nearest polygon `FindNearestPoly(center, halfExtents, filter)` considers every polygon whose axis-aligned vertex box overlaps the box `center ± halfExtents` (all tiles and layers) and that the filter accepts. For each candidate the *closest point* is: - if `center` is inside the polygon's outline seen from above (inclusive of the boundary): the point straight above or below it on the polygon surface, `OverPolygon = true`; - otherwise the nearest point of the outline in X/Z (lowest edge index on equal distance), with the height interpolated linearly along that edge. Surface height at (x, z): with detail, the first detail triangle (in index order) containing the point, by barycentric coordinates with a tolerance of 1e-9; without detail, the first triangle of the fan (v0, vi, vi+1) containing it. A point in no triangle (rounding at the boundary) takes the plane of the triangle it violates least. A polygon with 12 or more detail triangles is looked up through a per-tile grid (built on first use, cached with the tile): the polygon's detail box is cut into `d × d` cells, `d = clamp(floor(sqrt(triangles / 2)), 2, 32)`, each cell lists, in triangle order, every triangle whose box (widened by `1e-6 × its larger side + 1e-9`) touches the cell (widened by 1e-6 of a cell) plus every ill-shaped triangle (twice-area below `1e-6 × side²`). The first listed triangle that contains the point is the first containing triangle overall, so the result is bit-identical to the plain scan; when none contains it the plain scan runs for the least-violating plane. The winner minimises the squared 3D distance from `center` to the closest point; ties go to the lowest (tile column, tile row, layer, polygon index). This is the multi-layer rule: on overlapping floors the one whose surface is vertically closest to the query point wins (inside the outline the distance is purely vertical). The result carries the closest point and `DistanceSquared`; `Poly` is `PolyRef.Invalid` if nothing qualifies. Each tile builds, once and on first use, a bounding-volume hierarchy over its polygon boxes (recursive median split of the longest axis of the node box, polygons sorted by centroid with the polygon index as tie-break, leaves of at most 4); it is cached on the tile and only decides which polygons are examined, never the answer. If the box covers more grid cells than the mesh has tile slots, all tiles are scanned instead of the covered cells. ## Path search `BeginPath` / `UpdatePath(maxIterations)` / `FinishPath` (sliced), `FindPath` (all three at once), and the one-call `FindPath(start, end, PathOptions)` which first finds the nearest polygon to each position. **Cost model.** A route is a sequence of *crossings*. A crossing leaves a polygon through an *exit* and sits at a fixed position: - an in-tile edge shared with a neighbour: the midpoint of the edge (average of the two end vertices, in world coordinates); - a tile-portal link: the point at fraction `(RangeMin + RangeMax) / 510` along the source edge from its first vertex; - an off-mesh link: the link's far end point (the route first walks to the near end point inside the polygon, then crosses the link). Cost of a leg inside polygon *P* from position *a* to position *b* is `|a − b| × AreaCost(area of P)` (3D Euclidean). Crossing an off-mesh link adds `|start − end| × AreaCost(link area)`. The route starts at the start position in the start polygon and ends with a leg from the last crossing to the end position inside the end polygon. The search returns the route of least total cost for this model, so the cost equals Dijkstra's on the crossing graph (tests check it). Exits are numbered per polygon: in-tile neighbour edges by edge index, then the polygon's links in link order. **Algorithm.** A\* on crossings (node = leaving polygon + exit; a polygon can be entered through several nodes). Heuristic: `MinAreaCost × 3D distance to the end position`. Open list: binary heap ordered by (f, insertion sequence), so the pop order is fully specified; a stale heap entry (node already expanded) is skipped. One *iteration* is one expansion of a node (stale pops are free). Reaching the end polygon adds a terminal node (cost of the final leg); the search ends when the terminal node is popped (`Success`). **Node model.** The query object has a `PathNodeModel`, fixed when it is created; `Polygon` unless the constructor is given `Crossing`. - `Crossing` is the algorithm above: exact for the midpoint-crossing cost model, about three times the nodes of `Polygon`. - `Polygon` (the default): one node per polygon, keyed by the polygon alone. A node sits at the position where the cheapest route found so far enters the polygon, and its cost is that route's cost to that position. Leaving a polygon from position *c* (start position for the start polygon) through an in-tile edge from vertex *a* to vertex *b*, with *e* the end position and *w = e − c* in X/Z, the entry position is `a + s (b − a)` (heights interpolated the same way) with `s = cross(c − a, w) / cross(b − a, w)` clamped to [0, 1], or `s = 0.5` when `cross(b − a, w)` is zero; through a tile-portal link, the same `s` clamped to `[RangeMin / 255, RangeMax / 255]` (the fraction `(RangeMin + RangeMax) / 510` when the denominator is zero); through an off-mesh link, the far end point as above. Leg costs, the heuristic, the terminal node, the open-list order and the iteration count are as for `Crossing`. A relaxation succeeds when the new estimated total (cost plus `MinAreaCost` times the straight distance from the entry position to the end position, the open-list key) is strictly lower than the node's, even if the node was already expanded (it is then reopened and its position and parent change). Comparing the cost alone would keep whichever entry point was cheapest to reach however far it lies from the way on; in a large polygon that is a long way round, and when the end is unreachable and the whole reachable area is priced, the corridor to the polygon nearest the end came out about 20% too long on overlapping floors. Moves back into the polygon the node was reached from are not generated. The cost is a real walk's length but not the least one of the polygon-to-polygon cost model, so corridors can differ from `Crossing`'s; it expands about half the nodes. On generated test levels the mean string-pulled path length is 0% to 2.8% shorter than `Crossing`'s (it aims each entry point at the end position). *Implementation note (no effect on results):* the `Polygon` model finds a polygon's node through a table indexed by tile slot and polygon index (8 bytes per polygon of the mesh, resized when the mesh is edited) and not through a hash; the table is a speed device only. **Partial paths.** If the open list empties, or the node limit (`maxNodes` of the query object, default 65536; new nodes beyond it are dropped) stops the search from finding the end, the result is `Partial`: the corridor leads to the expanded node closest (3D) to the end position (ties: the earliest expanded). `NodeLimitReached` tells which. An unfinished sliced search finished early returns the same kind of corridor with status `InProgress`. **Corridor.** Polygons along the route, start first. If the route left a polygon and came back, the loop is removed (the first occurrence is kept), so every polygon appears once. A buffer shorter than the corridor receives its first part and `truncated` is set. `Cost` is the route cost before loop removal. **Slicing.** The result of `UpdatePath` calls totalling any number of iterations followed by completion equals the result of one unlimited call, for every budget: slicing only decides where the same loop pauses. `UpdatePath(int)` uses the plain filter given to `BeginPath`; `UpdatePath` must be given the same filter value each time. `FindPathAsync(start, end, options, token)` runs slices of `PathOptions.IterationsPerSlice` (default 256) iterations on the caller's context, awaiting `Task.Yield()` between them, checks the cancellation token between slices (throws `OperationCanceledException`) and returns exactly what the synchronous call returns. The tier-1 `NavMesh.FindPath` takes an optional token too (checked every 256 expansions; cancelling throws and abandons the search, which the next call on the same query object replaces), and `NavMesh.FindPathAsync` is `NavMeshQuery.FindPathAsync` on a pooled query object. Nothing moves to the thread pool; to search on another thread run `FindPath` inside `Task.Run`. **Pooled query objects (tier 1).** `NavMesh.FindPath`/`FindPathAsync`, `NavWorld.FindPath`, the `TiledNavMesh.FindPath` extension and the tier-1 `TrySamplePosition`, `Raycast` and `TryGetRandomPoint` borrow a default-sized query object (65,536 nodes, polygon node model) from the mesh for the length of the call. The mesh keeps up to four spares; a borrow takes one with an atomic exchange, a call that finds none builds a new one, and a return that finds all four slots full drops its object. A query holds no result state between searches (each search starts by stamping its tables afresh and reads the mesh edit counter), so a pooled object gives exactly the bits a new one gives, after any sequence of mesh edits. The tier-1 `Raycast` snaps its start to the nearest polygon within (2, 4, 2) first; a start with none is reported as blocked at the start. `TrySamplePosition` searches the box of half extent `maxDistance` and accepts a polygon point within `maxDistance` (3D). `TryGetRandomPoint` is `RandomPoint` on the whole mesh. ## Straight path (string pulling) `FindStraightPath(start, end, corridor, result)` first moves `start` and `end` to the closest points (nearest rule above) of the first and last corridor polygons, then applies the simple stupid funnel algorithm (after Lee and Preparata 1984; Hershberger and Snoeyink) to the portals between consecutive corridor polygons, in the X/Z plane. Portals: - in-tile neighbours: the shared edge; the edge's first vertex is on the right and its second on the left when walking from the first polygon to the second (polygons are counter-clockwise); - tile portals: the part of the source edge shared with the facing polygon's boundary edges on the opposite side, computed exactly from the two edges' extents along the boundary (not from the 1/255 link range); where the facing polygon has several collinear edges on that side the hull of their overlaps is used; - off-mesh links (no shared edge): the funnel is closed at the link's entry point, the entry (flag `OffMeshConnection`) and exit points are emitted, and a new funnel starts at the exit. Reverse traversal swaps entry and exit. Heights of corner points are the interpolated heights along the portal edge. A point equal in position to the previous one is merged into it (flags are or-ed). Points carry `Start` / `End` / `OffMeshConnection` marks and the corridor polygon entered after the point (the end point carries the last polygon). Output beyond the buffer sets `truncated`. A corridor whose neighbours do not touch, with stale references or empty is `InvalidCorridor` and writes nothing. The result is the Euclidean shortest path inside the corridor in X/Z (tests compare with a visibility-graph oracle). `FindStraightPath(start, end, corridor, result, maxCorners)` is the same computation stopped as soon as `maxCorners` points (the start included) have been found: the points written are a prefix of the full result, `truncated` is true when the limit was reached (the end point may or may not be among them), and the work follows the number of corners asked for, not the corridor length (the funnel stops; the validation of the corridor and the collection of the portals still run over the whole corridor). ## Raycast `Raycast(startPoly, start, end, filter, visited)` follows the segment across the surface in the X/Z plane. In each polygon the exit edge is found by clipping the segment against the convex outline: the least parameter *t* at which it leaves an edge's line. Edges whose lines it leaves at the same *t* (equal within 1e-9·(1+t): consecutive collinear edges, or the two edges at a vertex the segment passes through) are candidates; of those, the ones whose extent is nearest the crossing point (squared distance within 1e-18 of the smallest) are tried in index order and the first that leads to a polygon the filter accepts is taken, so a segment slipping through a vertex into the next polygon goes on; if none leads anywhere, the lowest index of them is the wall that was hit. If *t* ≥ 1 the segment ends inside: `Hit = false`, `T = 1`. Otherwise the ray crosses into the neighbour across the edge if there is one the filter accepts: an in-tile neighbour, or a tile-portal link of that edge whose range (`RangeMin − 1 .. RangeMax + 1` in 1/255ths of the edge) contains the crossing point. Otherwise it hits a wall there: `Hit = true`, `T`, the outward unit normal of the edge in the X/Z plane (`Y = 0`), `HitPoly`, `HitEdge`. Off-mesh links are not crossed. Heights are not tested. The start must be inside the polygon (within 1e-6 of its outline) or the status is `StartOutsidePolygon`. The visited list (start first) is truncated to the buffer (`Truncated`); `visitedCount` is the number written. ## Polygon-set queries (circle, shape, local neighbourhood) These answer "which polygons can I get to near here" without costs: only the filter's `Passes` matters, area costs are ignored. All geometry is in the X/Z plane (heights are ignored); world coordinates are the tile origin plus the float32 tile-local value, in double. **Portals.** A polygon edge leads to a neighbour through a *portal*: for an in-tile neighbour the whole edge; for a tile-border link (`NavLinkKind.Portal`) the part of the source edge from fraction `RangeMin/255` to `RangeMax/255` (computed as `a + (c - a) * s`, with `s = 0` and `s = 1` returning the end vertices exactly). Off-mesh links are not followed. A neighbour is entered only if the filter accepts it. **Shapes.** A circle (centre, radius 0 to 2^22) or a convex polygon of 3 to 64 points (either winding; Y ignored; collinear points allowed; zero area, non-convex, self-crossing or repeated-point input is `InvalidShape`; internally reordered counter-clockwise and checked by the exact orientation predicate against every edge). A portal *touches* a circle when the squared distance from its segment to the centre is `<= r*r`; it touches a polygon when an end point is inside or on it, or the segment meets one of its edges (closed segments, exact orientation tests with magnitudes below 2^-100 read as 0). The *reference point* is the circle centre or the mean of the polygon's points; an entry's *key* is the squared distance from the reference point to the portal's segment. **Search.** Best-first over polygons from the start polygon (which must pass the filter, else `InvalidStartPoly`, and is always in the result even if the shape misses it). Pop the node of least `(key, insertion sequence)` (binary heap, so the order is fully specified; a stale entry is skipped and costs no iteration). For each edge of the popped polygon in edge order (the in-tile neighbour, or else that edge's portal links in link order) whose target passes the filter and whose portal touches the shape: if the target has no node it gets one with this key and parent; if it has an open node with a larger key the key and parent are lowered; closed nodes are not changed. One iteration = one expansion. The set of polygons found is exactly those reachable from the start by a chain of touching portals (tests compare with a breadth-first oracle); their order, parents and keys are the expansion order described here. The query's `maxNodes` also caps this search (nodes beyond it are dropped, `AreaNodeLimitReached`). `Begin/UpdateAreaSearch/FinishAreaSearch` are the sliced form; `Update` any number of times with any budgets ends with the same lists as one unlimited call. `Finish` of an unfinished search lists the polygons expanded so far (status `InProgress`). `FindPolysInCircle` / `FindPolysInShape` / `FindLocalNeighbourhood` run begin, update-to-end and finish. The output arrays are written in expansion order; `parents` holds the polygon each was reached from (`Invalid` for the start), `distancesSquared` its key; both may be empty or shorter than `polys`. Anything beyond the `polys` buffer sets `truncated`. **Local neighbourhood.** The same search with one extra rule: when a polygon would get a node, it is skipped if its outline overlaps (interiors intersect, tested exactly with the separating-edge test; touching is not overlap) any polygon that already has a node. So stacked floors appear once, the one met first. A skipped polygon is not expanded through. ## Distance to wall `DistanceToWall(startPoly, center, radius, filter)` runs the circle search above, then scans every polygon found (expansion order) for wall pieces within the radius. A *wall piece* is the part of an edge with nothing walkable behind it: an edge with no neighbour and no border side; an in-tile neighbour the filter rejects (whole edge); on a tile-border edge, the stretch not covered by the ranges of links to accepted polygons (gaps between merged ranges, at `k/255` fractions). The nearest point of each piece to the centre is found by clamped projection; the nearest overall within `radius` wins, ties going to the lowest (polygon reference, edge index, fraction along the edge). The hit point gets its height by linear interpolation along the edge. `Normal` is the unit vector from the hit point toward the centre in X/Z (the edge's inward normal if the centre lies on the wall). If nothing is within the radius, `Found` is false and `Distance` is the radius. `FinishDistanceToWall` does the scan for a sliced circle search (on the polygons expanded so far). ## All walls near a position `FindWalls(startPoly, center, radius, filter, walls)` runs the same circle search and the same wall-piece rule as `DistanceToWall`, but returns every wall piece whose distance from the centre is at most `radius` instead of the nearest: a `WallSegment` holds the piece's two ends (world X, Y, Z, the heights interpolated along the edge in edge direction), the owning polygon and the edge index. The ends run in the polygon's winding, so the walkable side is to the left of first end → second end in X/Z. Pieces are ordered by (distance, then the order in which the search met them: polygons in expansion order, edges in order, pieces along the edge in order); when the buffer is full the nearest are kept (a later piece replaces the last only if strictly nearer), and `truncated` says some were dropped. The distance is the clamped X/Z distance to the piece; a piece of a different floor reachable through a link is included (callers compare heights). ## Random points `RandomPoint(ref SeededRandom, filter)`: area-weighted over every polygon the filter accepts, in (tile slot, polygon index) order, weight = X/Z outline area (sum of the fan triangles `(v0, vi, vi+1)`, in tile-local doubles). The generator (SplitMix64) supplies exactly four doubles in `[0, 1)`: `u0` picks the first polygon whose running area total exceeds `u0 * total` (if rounding leaves none, the last polygon of positive area); `u1` picks the fan triangle the same way; with `r1 = sqrt(u2)` and `r2 = u3` the point is `(1 - r1) A + r1 (1 - r2) B + r1 r2 C` (Osada et al. 2002). The height is the polygon surface height at the point. If no accepted polygon has positive area, no point is returned and the generator is not advanced. The state of `SeededRandom` is the whole state: copy it to snapshot, assign it to restore. `RandomPointAround(ref rng, startPoly, center, radius, filter)`: the circle search, then rejection sampling that is exactly uniform over the part of the found polygons inside the circle (stacked polygons each count). Each found polygon is clipped (Sutherland and Hodgman 1974) to the circle's bounding square; the clipped areas are the weights, summed in expansion order. An attempt draws four doubles as above (polygon from the clipped weights, then triangle and barycentric draws inside the clipped polygon) and keeps the point if it is inside the circle (`<= r*r`). After 64 failed attempts, or if radius is 0 or the start is invalid, nothing is returned; the generator has advanced by the draws made. `FinishRandomPointAround` does the draw for a sliced circle search. ## Move along surface `MoveAlongSurface(startPoly, start, end, filter, visited)` traces the segment as `Raycast` does. If it ends inside the mesh the result is `end` (X/Z) with the surface height of the last polygon, `Blocked = false`. If it hits a wall, the result is the point of the wall's edge closest to `end` (clamped to the edge, height interpolated along it), in the polygon owning that edge, `Blocked = true`. One slide only: call again from the result to keep sliding. The result is always on a polygon of the mesh. ## One-call and tier-1 forms `NavMeshQuery.FindPath(start, end, PathOptions?)` = nearest polygon at each end (extents default (2, 4, 2)), sliced search run to completion, funnel; a partial path ends at the closest point of the last corridor polygon to the snapped end. `PathResult.Status` is the shared `PathStatus` (`docs/path-results.md`); `PathResult.Detail` keeps the query-level `QueryStatus` (`Success`, `Partial`, `NoStartNearby`, `NoEndNearby`, `InvalidCorridor`, which cannot occur for results of the search itself). The extension `TiledNavMesh.FindPath(a, b, options)` creates a query object per call; hold a `NavMeshQuery` when asking repeatedly. ## Debug geometry `QueryDebug.Collect(query, result)` returns, as plain data, the outlines of the polygons expanded by the last search (each once, in expansion order), the corridor outlines and the straight path's points. # Tiled navigation mesh format (version 2, frozen) Status: **frozen at version 2**. Header flag bit 0 (the draft marker) is always 0. The reader accepts version 2 only: version 1 (the draft layout) and every unknown version are rejected with `NavMeshVersionException`, whose message and `Found`/`Expected` properties name the version found and the one expected. Any later layout change is version 3 with a reader for version 2. See "Version policy" and "Build fingerprint" below. The format serves two builders (an exact 2D polygon builder and a voxel-based 3D builder), the query layer and crowds. Nothing here is specific to one builder. ## Concepts - A **mesh** (`TiledNavMesh`) is a grid of **tiles** plus the parameters that tie them together (`NavMeshParams`). One mesh serves one agent class (radius, height, climb, slope are recorded in the parameters). Meshes for several agent sizes are several meshes over the same tile grid, each built with its own agent values; they share nothing. - A **tile** is addressed by `(TileX, TileZ, Layer)`. Layers hold overlapping floors (bridges, multi-storey buildings); tiles of different layers at the same column/row are independent and do not connect through their interiors. `Layer` is 0 to 1023. - A tile holds convex **polygons** over shared **vertices**, per-edge adjacency, **areas** and **flags**, optional per-polygon **height detail**, and **off-mesh link slots**. - **Axes:** Y is up. The walkable surface is described in the X/Z plane. A polygon is convex and counter-clockwise when X points right and Z points up (positive signed area `Σ(xᵢ·zᵢ₊₁ − xᵢ₊₁·zᵢ)`). A builder that works in an X/Y plane maps its second axis to Z. ## Coordinate rule - World positions cross the public boundary as `double` and must lie strictly within ±2^21 (2 097 152); anything else is rejected with `ArgumentOutOfRangeException` naming the value and the bound (a Q31.32 fixed-point host converts exactly inside this range). - `NavMeshParams.TileSize` is a double that must be exactly representable as float32, so the boundary coordinate `(float)TileSize` is the same number in double and float32. - Tile origin: `OriginX + TileX·TileSize`, `OriginZ + TileZ·TileSize` (double products and sums, evaluated exactly in this order: product, then sum). Tile Y origin is free (`OriginY` of the tile, finite, within ±2^21). - Vertices are float32 relative to the tile origin. Bounds: X and Z in `[−TileSize, 2·TileSize]` (builders may overlap the neighbours by up to one tile), |Y| ≤ `MaxLocalExtent`. - **Resolution rule.** `TileSize ≤ 8192·CellSize` and `MaxLocalExtent = 16384·CellSize`. The largest local coordinate therefore has float32 spacing at most `CellSize·2^14·2^-23 = CellSize/512`, so every vertex is stored with at least a 512th of a cell of resolution. This is the "margin". - The parameters are checked as a whole (`NavMeshParams.Validate`): finite origins within the bound, positive finite cell size, tile size rule above, 3 ≤ `MaxVertsPerPoly` ≤ 16, non-negative climb/radius/height, slope in 0..90 degrees. ## Tile contents and rules All rules are enforced by `NavTile.Create` (hand-built data) and by the deserializer; violations throw `NavMeshFormatException` naming the polygon, edge or array. | Array | Meaning | |---|---| | `Vertices` | 3 float32 per vertex (x, y, z); at most 65 536 vertices; all finite; nonzero X/Z magnitudes ≥ 1e-30 | | `PolyVertexCounts` | per polygon, 3 .. `MaxVertsPerPoly`; at most 1 048 575 polygons | | `PolyIndices` | all polygons' vertex indices concatenated (uint16) | | `EdgeNeighbors` | per polygon edge (edge *i* runs from vertex *i* to *i+1* cyclically): 0 = wall or portal, *k* > 0 = polygon *k−1* of this tile | | `EdgePortalSides` | per polygon edge: 255 = none, else the tile side the edge lies on: 0 = +X, 1 = +Z, 2 = −X, 3 = −Z | | `PolyAreas` | per polygon, 0..63 | | `PolyFlags` | per polygon, 16 bits, opaque to the format | | `DetailMeshes` | per polygon `{VertexBase, TriangleBase, VertexCount, TriangleCount}`; all zero = no detail (the 2D builder leaves it so) | | `DetailVertices` | 3 float32 per extra detail vertex (same bounds as vertices) | | `DetailTriangles` | 4 bytes per triangle: three indices and an edge-flag byte | | `OffMeshLinks` | see below | Polygon rules: distinct vertex indices; no zero-length edge in X/Z; **convex and counter-clockwise**, checked with the exact orientation predicate: every other vertex lies left of or on every edge, and at least one strictly left (collinear vertices are allowed, self-overlapping star shapes and zero-area polygons are not). Adjacency rules: an in-tile neighbour must be another polygon that has the reversed edge (same two vertex indices, opposite direction) naming the first polygon back. An edge with an in-tile neighbour has no portal side. A portal edge has neighbour 0, lies exactly (bitwise) on the coordinate `(float)TileSize` (sides 0, 1) or `0` (sides 2, 3) of its axis (X for sides 0 and 2, Z for 1 and 3), and has nonzero length along the boundary. Detail: for polygon *p* with *n* vertices and detail mesh *m*, triangle indices *i* < *n* refer to the polygon's own vertices and *n ≤ i < n + m.VertexCount* to detail vertices `m.VertexBase + (i − n)`. Ranges must lie inside the arrays. Edge-flag bit *k* of a triangle marks its edge *k* as lying on the polygon boundary (builders choose; the format only carries it). ### Off-mesh link slots Per link: start and end (tile-local float32 x, y, z, each |v| ≤ `MaxLocalExtent`; the end may lie in any tile, expressed in the owning tile's frame), `Radius` (> 0), `Area` (0..63), `PolyFlags`, `Bidirectional`, `UserId`. The slot only records the link; stitching resolves it (below). ## References `PolyRef` is a 64-bit value: **salt (24 bits) | tile slot (20 bits) | polygon index (20 bits)**, from the top. Salt 0 is never issued, so the zero value is "invalid". A mesh has numbered tile **slots** (at most 1 048 575). Every slot has a generation (salt) starting at 1; it advances (`salt = (salt + 1) & 0xFFFFFF`, skipping 0) whenever the slot's tile is removed or replaced. A reference is valid iff its slot's current salt equals the reference's salt and the slot holds a tile with that polygon index. A new tile takes the **lowest free slot**. `TileRef` is the same (salt, slot) pair. Replacing a tile (same position) keeps the slot and advances the salt: old references are rejected even if the new tile looks identical. Salts and the slot layout are part of the mesh's serialized state. ## Stitching (links) Links join polygons across tile boundaries and through off-mesh links. They are **derived**, not serialized: the set of links is a pure function of the set of tiles and is rebuilt on every add, remove and replace, so any order of edits that ends in the same tiles in the same slots gives the same links. **Portals.** For every portal edge A (side *s*, in tile T) and every portal edge B on side *s* ⊕ 2 of every layer of the tile column `(TileX + dx(s), TileZ + dz(s))` (dx, dz: +1/0, 0/+1, −1/0, 0/−1 for sides 0..3): let `u` be the coordinate along the shared boundary (Z for sides 0, 2; X for sides 1, 3), and `[lo, hi]` the intersection of the two edges' `u` intervals. A link A→B exists iff `hi > lo` and the world heights (`tile.OriginY + y`, double) of A and B, linearly interpolated along each edge at `u = lo` and at `u = hi`, differ by at most `WalkableClimb` at both. The test is symmetric, so links come in pairs. The link stores the covered part of A's edge as `RangeMin..RangeMax`, in 1/255ths measured from A's first vertex: `floor(min t·255)` and `ceil(max t·255)` of the parameters of `lo` and `hi`, clamped to 0..255. **Off-mesh links.** The start point is attached to the polygon *of the owning tile*, the end point to a polygon of any tile layer in the tile column under the end point. A point attaches to the polygon with the smallest score among those whose horizontal distance to the polygon (0 when inside; Euclidean to the nearest edge point otherwise) is ≤ `Radius` and whose vertical distance outside the polygon's vertex height range is ≤ `WalkableClimb`; score = `distance² + dy²`; ties go to the lower layer (end point) and then the lower polygon index. A link that cannot attach at either end stays unconnected (no error). A forward link (kind `OffMesh`) leaves the start polygon; a bidirectional link also gives a reverse link (kind `OffMeshReverse`) leaving the end polygon. **Order.** The links of a polygon are ordered: portal links in (side, portal order of the tile, neighbour layer, neighbour portal order), then forward off-mesh links in link order, then reverse off-mesh links in (owner slot, link index) order. (Re-stitching happens for the changed column and its four side neighbours; if any tile of the mesh has off-mesh links, for every tile, because an end point can be anywhere.) ## Byte layout Little-endian throughout (`BinaryPrimitives`); floats and doubles by their IEEE bit pattern (so `-0.0` and NaN payloads are preserved, although NaN is rejected in coordinates); no padding except where listed. The same mesh writes the same bytes on every platform. ### Tile (`ONPT`) | Offset | Size | Field | |---|---|---| | 0 | 4 | magic `4F 4E 50 54` | | 4 | 2 | version (2) | | 6 | 2 | flags (0; any bit set is an error) | | 8 | 16 | build fingerprint of the mesh the tile was built for (below) | | 24 | 12 | TileX, TileZ, Layer (int32) | | 36 | 24 | origin X, Y, Z (double). X and Z must equal the grid formula bitwise | | 60 | 24 | counts (uint32): vertices *nv*, polygons *np*, indices *ni*, detail vertices *ndv*, detail triangles *ndt*, off-mesh links *nom* | | 84 | 12·nv | vertices (float32 x, y, z) | | | 16·np | polygon records: u8 vertex count, u8 area, u16 flags, u32 detail vertex base, u32 detail triangle base, u16 detail vertex count, u16 detail triangle count | | | 2·ni | polygon indices (uint16) | | | 4·ni | edge neighbours (int32) | | | ni | edge portal sides (uint8) | | | 12·ndv | detail vertices | | | 4·ndt | detail triangles | | | 36·nom | off-mesh records: 7 float32 (start xyz, end xyz, radius), u8 area, u8 flags (bit 0 bidirectional; other bits 0), u16 poly flags, u32 user id | The body must be exactly as long as the counts imply; the reader checks this before allocating. Anything else (trailing bytes included) is an error. A tile is read against the mesh parameters (bounds, `MaxVertsPerPoly`), and its fingerprint must equal the parameters' fingerprint, otherwise `NavMeshFingerprintException` (a `NavMeshFormatException`) is thrown. ### Mesh (`ONPM`) | Offset | Size | Field | |---|---|---| | 0 | 4 | magic `4F 4E 50 4D` | | 4 | 2 | version (2) | | 6 | 2 | flags (0) | | 8 | 40 | OriginX, OriginY, OriginZ, TileSize, CellSize (double) | | 48 | 2 | MaxVertsPerPoly (u16) | | 50 | 2 | reserved, 0 | | 52 | 16 | WalkableClimb, AgentRadius, AgentHeight, AgentMaxSlopeDegrees (float32) | | 68 | 8 | settings digest (u64; below) | | 76 | 4 | slot count *S* (uint32, ≤ 1 048 575, and ≤ remaining bytes / 5) | | 80 | … | per slot, in slot order: u32 salt (1..0xFFFFFF), u8 present (0/1), and if present u32 byte length followed by a tile | The parameter block is offsets 8..75 (68 bytes); snapshots reuse it verbatim. Reading restores the slots exactly (free slots and generations too) and stitches once. Duplicate (column, row, layer) positions are an error. ### Tier-1 envelopes (`ONNM`, `ONNW`; added in release 1.1.0) `NavMesh.Save` wraps the frozen mesh bytes above, unchanged, in an envelope that also carries the `NavSettings` the mesh was built with, so `NavMesh.Load` returns a tier-1 mesh without the caller repeating the settings. The mesh layout and version stay 2. All integers are little-endian. `ONNM` (one mesh): | Field | Size | Notes | |---|---|---| | magic | 4 | `4F 4E 4E 4D` | | version | 2 | 1 | | flags | 2 | 0 | | AgentRadius, AgentHeight, WalkableClimb, AgentMaxSlopeDegrees, CellSize, TileSize, CellHeight, OriginX, OriginZ | 9 × 8 | double | | MaxVertsPerPoly | 4 | i32 | | FloorY | 8 | double | | Area, Flags | 1 + 2 | u8, u16 | | SnapBits present, SnapBits | 1 + 4 | u8 (0/1), i32 (0 when absent) | | Partition, MinRegionArea, MergeRegionArea | 3 × 4 | i32 each; Partition must be a defined value | | ContourMaxError, MaxEdgeLength, DetailSampleDistance, DetailMaxError | 4 × 8 | double | | link count *L* | 4 | u32, at most remaining bytes / 64 | | per link | 64 | Start xyz, End xyz, Radius (7 × double), Bidirectional u8 (0/1), Area u8, Flags u16, UserId u32 | | mesh length | 4 | u32 | | mesh | … | the `ONPM` bytes above; nothing may follow | The reader builds the settings through their validating setters (a value out of range is a `NavMeshFormatException` naming it), reads the mesh, and requires the parameter block the settings produce (grid, agent class and settings digest) to equal the mesh's, else `NavMeshFingerprintException`. A caller-supplied `NavSettings` is checked the same way. Bare `ONPM` bytes are also accepted by `NavMesh.Load` when the caller supplies the settings. `ONNW` (a `NavWorld`): magic `4F 4E 4E 57`, u16 version 1, u16 flags 0, u32 class count (1 or more, at most remaining bytes / 8), then per class a u32 length and an `ONNM` envelope; nothing may follow. The classes keep their order. ## Behaviour on bad input Deserialization and `NavTile.Create` never index out of range on hostile input: counts are checked against the remaining bytes first, then every rule above. All failures are `NavMeshFormatException` or a subclass (`NavMeshVersionException`, `NavMeshFingerprintException`) (parameters outside their bounds read from a file are reported the same way). The mesh API throws `ArgumentOutOfRangeException` for world coordinates beyond the bound, `InvalidOperationException` for adding a tile into an occupied position, and `NavMeshFingerprintException` for adding, replacing, streaming in or restoring a tile built with other settings. ## Debug geometry `NavMeshDebug.Collect(mesh)` returns plain data: each polygon's world-space outline, area and flags, and every stitched link as a world-space segment (the covered part of the shared edge for a portal, departure to arrival for an off-mesh link), in slot / polygon / link order. ## Hand-over from builders - **2D builder:** `NavTileAssembler.FromPolygons` takes world-space double vertices, per-polygon vertex counts and indices (polygons already cut to one tile and sharing vertices by index), optional areas and flags. It converts to tile-local float32, snaps coordinates within `CellSize/1024` of a tile boundary onto it, reverses clockwise polygons, derives in-tile adjacency from reversed shared edges (an edge shared by other than two polygons stays a wall) and marks wall edges lying on a boundary as portals. Detail is left empty. - **3D builder:** fills a `NavTileData` directly from its polygon mesh and detail mesh: polygon vertices as tile-local float32, `DetailMeshes` / `DetailVertices` / `DetailTriangles` from the detail stage, `EdgePortalSides` from the contour edges that touch a tile border, `Layer` from the layer partitioner, `OffMeshLinks` from the link markup. `NavTile.Create` then validates it. ## Freeze decisions (candidates collected through the 3D builder, authored links, agent classes and streaming) Every item the draft listed as possibly changing, with what the builders, queries, crowds, authoring and streaming showed. Only the fingerprint row changes the layout; the others are decisions to keep it. | Candidate | Decision | Reason | |---|---|---| | Polygon vertex limit, index width | **Keep**: 3 to 16 vertices (builders use 6), `uint16` vertex indices, 65 536 vertices and 1 048 575 polygons per tile | Builders stay far inside on every test level; a tile that would exceed them fails with a message naming the tile and advising a smaller tile size. | | Per-edge flags ("jumpable", "crouch") | **Reject** | Jumps and ladders are off-mesh links (authored, below); crouch and similar are polygon areas and flags. Edge flags would double the per-edge data for no query that needs them. | | Per-polygon walkable height / clearance | **Reject** | One mesh per agent class (`NavWorld`) already bakes clearance into each class's polygons. | | Wider area ids | **Keep** 6 bits (0 to 63) | Matches the cost table size of queries and filters; 64 areas covers every use found. | | Stored tile bounding-volume tree | **Reject** | Derived data: queries find polygons by tile bounds and a scan of the tile; storing a tree would make equal geometry unequal on disk. | | Layer-cell map | **Reject** | Derived from the polygons when needed. | | Heightfield detail instead of a detail mesh | **Reject** | The detail mesh is what the 3D builder produces and what height queries read; a second representation would need a second conversion. | | Off-mesh start/end polygon indices baked into the tile | **Reject** | They depend on the neighbouring tile (the end may be in another tile) and would make a tile's bytes depend on its neighbours. Stitching resolves them (rules above); authored links need nothing more than the slot. | | Detail edge-flag semantics | **Keep** (bit *k* = triangle edge *k* lies on the polygon boundary) | The 3D builder follows it. | | Wider layer index | **Keep** 0 to 1023 | Builders report the layer count needed; no level needs more than a few. | | A parameter fingerprint in the tile header | **Add** (version 2) | A tile built for another mesh (other cell size, tile size, agent class, partition, detail) would otherwise be accepted when streamed in whenever its numbers pass validation. The 16-byte fingerprint below is compared on add, replace, stream-in and snapshot restore. | Nothing else in the draft layout changed: version 2 is the draft layout plus the tile fingerprint, the settings digest in the parameter block, version 2 and flag bit 0 clear. ## Build fingerprint A 128-bit value identifying every setting that changes a tile's content. It is **not** stored in the mesh header (the mesh recomputes it from its parameter block); every tile header carries it. - **Input bytes:** the 4 ASCII bytes `ONFP`, the format version as u16 (2), then the 68-byte parameter block exactly as the mesh header stores it (origin X/Y/Z, tile size, cell size as f64; max vertices per polygon u16; 0 u16; walkable climb, agent radius, agent height, agent maximum slope as f32; settings digest u64), all little-endian IEEE bit patterns. - **Hash:** SHA-256 (FIPS 180-4) of those 74 bytes; the fingerprint is the first 16 bytes of the digest, written to the tile in digest order. - **Settings digest:** the builder settings that change tile content but are not parameter fields. `NavSettings.ToParams` writes the first 8 bytes of SHA-256 over: `ONSD` (ASCII), then CellHeight (f64), SnapBits (i32; -1 when automatic), Partition (i32 enum value), MinRegionArea (i32), MergeRegionArea (i32), ContourMaxError (f32), MaxEdgeLength (f64), DetailSampleDistance (f64), DetailMaxError (f64), Height (f64), Area (u8), Flags (u16), authored link count (u32) and per link start xyz, end xyz, radius (f64 each), bidirectional (u8), area (u8), flags (u16), user id (u32); read as a little-endian u64. Hand-built parameters have digest 0. - **Where it is checked:** `TiledNavMesh.AddTile`/`ReplaceTile`, `NavMeshSerializer.ReadTile` (so `TileStreamer`), and snapshot restore (the snapshot's parameter block carries the digest, and library tiles and target meshes must have the same fingerprint). The check precedes every other validation of the tile. - Tiles take the fingerprint from the parameters they are created against (`NavTile.Create`), so builders set nothing. Because it is part of a tile's bytes it is part of the tile content id. ## Version policy The layout above is frozen. A change to any byte of a tile, mesh or snapshot layout, to a rule that decides validity, or to the fingerprint's encoding is **version 3**, and its reader also reads version 2. A reader rejects any version it does not know, and any set flag bit, with a typed error. Adding a field is never done in place. ## Authoring: off-mesh links, agent classes, streaming **Authored links** (`NavSettings.OffMeshLinks`, a list of `OffMeshLinkDefinition`): world-space start and end (double, inside the coordinate rule), radius, direction, area, flags and a caller id. Every build (2D and 3D) and every rebuild of a tile bakes into the slots of the tile that holds the link's start: 1. The start's column is `WorldToTile(start.x, start.z)`. If the build produced no tile there, the link is dropped. 2. With several layers in the column, the link goes to the layer whose polygon is nearest the start by the stitching rule (horizontal distance to the polygon within the radius, height outside the polygon's vertex range within the climb; score horizontal² + vertical²); layers with no polygon in reach score infinity; ties and all-infinity go to the lowest layer. 3. Slot values are `(float)(world − tileOrigin)`, X and Z against the tile's origin, Y against the tile's `OriginY`. The end may lie in any column but within `MaxLocalExtent` of the start's tile origin in X and Z; otherwise the build throws `ArgumentOutOfRangeException` naming `OffMeshLinks[i]`. Non-finite or out-of-bound numbers, a radius not above 0, an area above 63 are rejected the same way. A link whose start or end lies more than `MaxLocalExtent` above or below the tile's `OriginY` is dropped (it could not connect from there anyway, and one odd link must not fail a build or rebuild). 4. Links of one tile are appended after any slots the builder already filled, in definition order. A tile's link order therefore equals the order of the definitions whose start it holds. Resolving to polygons, one-way and two-way behaviour, run-time switches and the corridors' crossing all work as stitching defines. A link that finds no polygon within its radius at either end stays unconnected. **Agent classes** (`NavWorld`): one `NavMesh` per `NavSettings`, built from the same input in the order given; class *i* is byte-identical to a single-class build with `classes[i]`. `ClassFor(radius)` is the class with the smallest `AgentRadius` ≥ radius (lowest index among equals), or the largest class if none is large enough. `WorldCrowd` holds one crowd per class and steps them in class order; agents of different classes walk different meshes and do not see each other. **Streaming** (`TileStreamer`): operations are queued and applied in queue order, `Update(maxOperations)` applying at most that many. One operation is one tile added or replaced (`ReadTile` against the mesh's parameters, then `AddTile` or `ReplaceTile`), or one tile column removed (all layers, lowest first). A failing operation leaves the queue before its exception propagates. Adding takes the lowest free slot and advances slot generations as for any edit, so tile bytes and query answers return after out-and-in but polygon references and slot numbers do not. Links stitch again after every operation. A crowd whose destination polygon streamed out has no route (`NoPath`); the host sends it again after the tile returns. The queue is not part of a mesh snapshot. # Navigation mesh snapshots Status: follows the frozen mesh format, version 2 (`navmesh-format.md`); the snapshot header carries the same version as the mesh format. Its parameter block includes the settings digest, so a snapshot carries the build fingerprint: restoring it into a mesh with a different fingerprint, or against library tiles built with another one, fails with `NavMeshFingerprintException`. A deterministic host that rolls back and resimulates needs to put a mesh back exactly as it was. A **snapshot** is the dynamic state of a `TiledNavMesh`, as bytes. Restoring it gives a mesh in which every query, every stitched link and every polygon reference behaves as it did when the snapshot was taken, and which snapshots back to the same bytes. ## What is dynamic state Everything about a mesh that can change after construction: 1. **Slots.** For each slot index 0..*S*−1 (S = `SlotCount`): its generation (salt), and the tile in it, if any. Slot indices, free slots and generations are state because polygon references embed them: after a restore a reference issued before the snapshot is valid exactly when it was valid at that moment, and a stale one stays stale. 2. **Polygon edits.** Run-time changes to a polygon's area and flags (`SetPolyArea`, `SetPolyFlags`): a door that opens, a zone that closes. Stored as differences from the tile as built. 3. **Off-mesh link switches.** Links turned off with `SetOffMeshLinkEnabled`. All links start on. Stitched links, bounding-volume trees and the like are derived data: they are rebuilt on restore by the same deterministic stitching that built them (`navmesh-format.md`, "Stitching"), so they are never stored. Tile contents (geometry, adjacency, detail, off-mesh slots, as-built areas and flags) are state of the *world*, not of the run: they are stored in full (`Full`) or named by content id (`Referenced`). Not state: the mesh parameters (stored only to check that a snapshot belongs to the mesh it is restored into), query objects and their buffers. Temporary obstacles, run-time area volumes and the rebuild queue of a 3D-built mesh are state of the tile cache; they wrap this snapshot rather than extending it (see "Tile cache snapshot"), so this format is unchanged by them. ## Tile content id `TileContentId` = SHA-256 of the tile serialized as in `navmesh-format.md` ("Tile") **with the as-built polygon areas and flags**, i.e. ignoring run-time edits. SHA-256 is FIPS 180-4, computed by the library itself (the platform's hash is not available on every target). Two tiles with equal ids have equal geometry, adjacency, detail, off-mesh slots, areas and flags as built. `NavTile.ContentId` returns it; `TileLibrary` finds tiles by it. ## Ownership of tiles A mesh keeps its **own copy** of every tile added to it: the immutable arrays (vertices, indices, detail, off-mesh slots) are shared, and the areas, flags and link switches are copied. Editing a mesh therefore never changes the `NavTile` object the caller passed in, nor another mesh holding the same object. `GetTile` returns the mesh's copy. Adding a tile that was taken from an edited mesh carries its current areas and flags and link switches with it; the as-built values (for the content id and for snapshot differences) stay those of the original build. `ReplaceTile` resets a position's edits to the new tile's values. ## Modes | Mode | Tile entry | Restore needs | |---|---|---| | `Full` (default) | the tile's as-built bytes | nothing: self-contained | | `Referenced` | the 32-byte content id | a `TileLibrary` holding every named tile | A host that keeps its tile data (it loaded or built them) takes `Referenced` snapshots every frame cheaply and keeps `Full` ones for save files. One snapshot is all one mode; a reader also accepts a mixture. The writer is canonical: the same state in the same mode always gives the same bytes, on every platform. ## Byte layout Little-endian throughout; the same conventions as the mesh format. | Offset | Size | Field | |---|---|---| | 0 | 4 | magic `4F 4E 50 53` ("ONPS") | | 4 | 2 | version (2) | | 6 | 2 | flags (0) | | 8 | 68 | the mesh parameters, exactly as in the mesh format (offsets 8..75) | | 76 | 4 | section count *N* (≤ remaining bytes / 8) | | 80 | … | *N* sections | ### Sections Each section: u16 id, u16 reserved (0), u32 body length, body. Sections appear in strictly ascending id order, each at most once. A reader of this version knows section 1 only and **rejects any other id**: an unknown section is state it cannot restore, so skipping it would silently lose it. The snapshot must contain section 1. A later version that adds sections (temporary obstacles, rebuilt-tile queues) raises the version number. #### Section 1: slots | Field | Size | |---|---| | slot count *S* (≤ 1 048 575 and ≤ remaining bytes / 5) | u32 | | per slot, in slot order: | | | generation (1 to 16 777 215) | u32 | | kind: 0 free, 1 inline tile, 2 referenced tile | u8 | | (kind 1) tile byte length, then the as-built tile bytes | u32 + bytes | | (kind 2) tile content id | 32 bytes | | (kinds 1, 2) polygon edit count *E* (≤ the tile's polygon count) | u32 | | *E* edits, ascending polygon index: polygon u32, area u8, flags u16 | 7·*E* | | (kinds 1, 2) disabled off-mesh link count *D* (≤ the tile's link count) | u32 | | *D* link indices, strictly ascending | u32 each | An edit is written only for a polygon whose current area or flags differ from the as-built values; a reader rejects an edit equal to the as-built values (one state, one encoding). An area above 63 is rejected. Trailing bytes after the last section, and section bodies not consumed exactly, are errors. ## Restore `NavMeshSnapshot.Restore(bytes, library)` builds a new mesh; `TiledNavMesh.Restore(bytes, library)` replaces the state of an existing mesh in place, keeping the object (query objects holding it keep working). Both: 1. Parse and validate everything first: header, parameters, section framing, every count against the bytes that remain before allocating, every inline tile completely (`NavTile` rules), every referenced tile found in the library with equal mesh parameters, edit and link indices against the tile, the uniqueness of (column, row, layer) positions. 2. Build the complete new state on the side: slots and generations placed exactly, edits and switches applied, then stitched once in slot order. 3. Only then, for the in-place form, swap it in. The swap cannot fail. Any error before it leaves the mesh untouched. The in-place form also requires the snapshot's parameters to equal the mesh's, compared bitwise. Errors are `NavMeshFormatException` with a message naming the slot or field. Other exceptions from hostile bytes are bugs. ## Guarantees (tested) - snapshot → mutate → restore → snapshot gives identical bytes, in both modes; - every query (nearest, path with filters and area costs, string pulling, raycast), the stitched links and the validity of polygon references, current and forged, equal those of a mesh that applied the same operations without the excursion; - carrying on with the same operations after a restore keeps the two meshes byte-identical, step by step; - snapshot bytes are equal on every platform and thread count (hash-matrix scenario `navmesh/snapshot-bytes`). ## Relation to `WriteMesh` `NavMeshSerializer.WriteMesh` stores a mesh as a file: tiles with their *current* areas and flags, and no link switches. It is for content, not for rollback. Reading it back makes the current values the as-built ones. ## Tile cache snapshot `TileCache.Snapshot` (`build3d-runtime.md`) captures the state of a mesh built by the 3D builder with run-time changes: obstacles, area volumes, the rebuild queue, and a complete mesh snapshot as above. Little-endian; the same state gives the same bytes in every mode. | Field | Size | |---|---| | magic `4F 4E 54 4B` ("ONTK") | 4 | | version (1) | u16 | | flags (as the mesh format) | u16 | | next obstacle id, next volume id (each 1 to 2^31 - 1) | u32, u32 | | obstacle count *O* (at most 2^20 and the remaining bytes / 13) | u32 | | per obstacle, ascending id (below the next id): id u32, shape u8 (1 cylinder, 2 box, 3 oriented box), then 5, 6 or 7 float64 as in `build3d-runtime.md` | | | volume count *V* (at most 2^20 and the remaining bytes / 41) | u32 | | per volume, ascending id (below the next id): id u32, area u8 (0 to 63), min y f64, max y f64, corner count *K* u32 (3 to 256), 2*K* f64 | | | queue length *Q* (at most the number of cached tiles) | u32 | | per queued tile, front first: tile column i32, tile row i32 (each distinct, each a cached tile) | | | mesh snapshot length, then the mesh snapshot bytes (`Full` or `Referenced`) | u32 + bytes | The obstacle and volume values go through their constructors again on restore, so every rule of the shape applies to hostile bytes. Restore parses everything first, then restores the mesh (itself all or nothing), then swaps in the lists; an error leaves the cache untouched. Trailing bytes are an error. A snapshot is restorable into a cache built from the same geometry and settings; a queued tile the cache has no data for is rejected. # 2D builder: tiles, plane mapping, rounding Normative description of `TileSlicer`, `Mesh2DPipeline` and `NavMesh.Build2D`: a walkable `Region` (see `build2d-geometry.md`) becomes a stitched `TiledNavMesh` (see `navmesh-format.md`). A later port that follows this text produces the same tiles, bit for bit. ## Stages 1. **Resolve:** outlines or a tile map → `Region` (union of the outlines; true cells of a tile map). Obstacles are `Region.Difference`. This is the geometry document's job. 2. **Shrink:** the one-sided shrink by `AgentRadius` described in the geometry document (round joins, equal-area arcs, arc tolerance `max(CellSize / 4, min(0.0825 × AgentRadius, CellSize / 2))`); radius 0 skips it. One mesh serves one agent class; several agent sizes are several builds with different settings. 3. **Slice:** cut the region along the tile lines and partition each tile into convex polygons (below). 4. **Tile data:** `Mesh2DPipeline.ToTileData` per tile, through `NavTileAssembler.FromPolygons`. 5. **Assemble:** `NavTile.Create` validates each tile; `TiledNavMesh.AddTile` stitches neighbours. Each stage is a public static method over plain types; `Mesh2DPipeline.Build` runs them in this order. ## Plane mapping The geometry plane maps to the mesh as **x → world X, y → world Z**, and world Y is the constant `NavSettings.FloorY` for every vertex (tile-local Y is 0, the tile's `OriginY` is the height). A polygon that is counter-clockwise in the plane is counter-clockwise in X (right) / Z (up), which is the format's winding. There is no detail mesh, one layer (0), one area id and one flags value for all polygons (`NavSettings.Area`, `NavSettings.Flags`). ## Snap grid and the format The region's grid unit must be at least `CellSize / 256` (`TileSlicer.MinUnitDivisor`): then two grid-distinct points stay distinct as tile-local float32 (the format keeps resolution of at least `CellSize / 512` everywhere) and none is pulled onto a tile edge by the assembler's `CellSize / 1024` snap. A finer region is rejected with an `ArgumentException` naming the `SnapBits` to use (`TileSlicer.FinestSnapBits(cellSize)`, the largest value up to 16 that satisfies the rule). `NavSettings.SnapBits` null means exactly that value. Features thinner than the unit vanish in snap rounding, by design. ## Tile lines Tile line k along an axis is the world coordinate `Origin + k * TileSize`, **computed once in double and rounded to the grid** (`Math.Round`, ties to even, as for every input coordinate). A line must lie within `CellSize / 1024` of its true position; otherwise the tiling is rejected (`ArgumentException`: use a tile origin and size on the grid or a finer `SnapBits`). Tile k spans line k to line k + 1; a point on a line belongs to the tile above it. ## Slicing rule 1. Take the region's bounding box. Lines strictly inside the box (on neither extreme) are cut lines; each is one segment across the box, vertical for X lines and horizontal for Z lines. At most 2^12 lines per axis and 2^20 crossings of X lines with Z lines are accepted (the arrangement's noding cost grows with lines times the points near them; larger worlds use larger tiles or several builds). 2. The cut lines join the region's boundary edges in **one snap-rounded arrangement** (`build2d-geometry.md`, "Arrangement"): cut lines meet each other at grid points, so those crossings are exact; where a boundary edge crosses a line, the crossing is computed exactly as a rational and **rounded once** to the grid, and both the boundary and the line pass through the rounded point. Nothing is rounded twice and no step depends on the order of the input. 3. The constrained Delaunay triangulation uses boundary and cut segments as constraints, so **no triangle crosses a tile line**; the winding is that of the boundary alone (cut segments change nothing). Triangles inside the region are kept. 4. Each triangle belongs to the tile whose cell contains its centroid (compared in integers as the sum of the three vertices against three times the line). Tiles are processed in ascending (row, column) order. 5. Per tile, the triangles are merged into convex polygons exactly as for an untiled region (Hertel & Mehlhorn, greedy longest diagonal first), with at most `MaxVertsPerPoly` vertices; an edge on a tile line has no neighbour, so no polygon crosses a line. Unused vertices are dropped and the others keep their (x, y) order. Consequences, all exact: every polygon is convex and counter-clockwise; polygons are valid on the exact grid, so convexity and non-overlap never depend on float rounding; **both sides of a tile line are cut at the same vertices**, because every vertex on a line is in the one arrangement, so portal edges of neighbouring tiles that overlap are identical and every overlap is linked by stitching. The union of the tile polygons is the region, except that cut points on the region's boundary move to the rounded crossing: the area changes by less than the boundary length times one grid unit (and not at all when every crossing is a grid point, as for axis-aligned and 45 degree boundaries on the integer grid). ## Coordinate rule Outline and tile-map coordinates are double, finite and within ±2^21 (otherwise `ArgumentOutOfRangeException` naming the value). The mesh format also bounds each **tile**: a tile whose extent leaves ±2^21 is rejected by the tile validator with an `ArgumentOutOfRangeException` naming the tile, even if the region itself is inside the bound. Tile size is at most 8192 cells. ## Presets `NavSettings.Humanoid` (radius 0.6, height 2, climb 0.9, slope 45 degrees, cell 0.3, tile 64, 6 vertices per polygon), `Small` (0.3, 1, 0.4, 45, cell 0.15, tile 32) and `Large` (1.5, 4, 1.2, 40, cell 0.5, tile 128). Floor height 0, area 0, flags 1. # 2D geometry for the exact navmesh builder Normative description of `RecursiveAI.OnePath.Mesh.Build2D`: outlines and tile maps → regions → boolean operations and offsetting → constrained Delaunay triangulation → convex polygons with adjacency. A later port that follows this text produces the same output, bit for bit. Sources: Hobby, *Practical segment intersection with finite precision output* (1999) and Guibas & Marimont, *Rounding arrangements dynamically* (1998) for snap rounding; Chew, *Constrained Delaunay triangulations* (1989) and Sloan, *A fast algorithm for generating constrained Delaunay triangulations* (1993) for the triangulation; Hertel & Mehlhorn, *Fast triangulation of the plane with respect to simple polygons* (1983) for the convex merge; Lawson (1977) for flips; Shewchuk (1997) for the exact predicates, supplied by the math package (`docs/predicates.md` there). ## Coordinates and the snap grid - Public coordinates are `double` (`Vector2D`) and must be finite with magnitude at most 2^21. Anything else throws `ArgumentOutOfRangeException` naming the outline, ring and vertex (or tile-map grid line). - `Geometry2DOptions.SnapBits` (default 16, range 0 to 16) fixes the grid unit 2^-SnapBits. **Every input coordinate is rounded to the nearest multiple of the unit (ties to even, `Math.Round`) before anything else.** A snapped coordinate is an integer of at most 37 bits; all later geometry is exact integer geometry. - Constructed points (crossings, offset corners) are rounded to the same grid. Nothing is ever kept in floating point between stages: a `Region` stores integer grid points and reports `double` values `i × 2^-SnapBits`, which are exact. - Signs of orientation and in-circle determinants come from the math package's exact predicates on the integer coordinates (exact in `double` up to 2^53). Constructions use 128-bit integers; every grid coordinate stays within 2^39 so no product overflows. - Both operands of a binary operation must use the same `SnapBits`. ## Outlines, fill rule, regions An `Outline` is a set of rings (outer boundary and holes, any order and orientation, last vertex joined to the first; at least 3 vertices each). **An outline's area is the even-odd region of its rings.** Overlapping, self-intersecting, touching and duplicate-point rings are all legal. `Region.FromOutlines` resolves each outline alone, then takes the **union** of the outlines. A `Region` is a set of rings that never cross or overlap and touch only at shared vertices, with the interior on the left of every directed edge: outer boundaries run counter-clockwise, holes clockwise. Collinear degree-2 vertices are dropped. At a vertex where several rings meet, a ring continues along the outgoing edge first met turning clockwise from the reverse of its incoming edge, which separates the region's wedges into minimal rings. Rings start at their smallest directed edge in (x, y) order, and rings are ordered by that edge: the ring list depends only on the set of boundary edges. `Region.FromTileMap(cells, width, height, cellSize, origin)`: cell (x, y), row-major, spans `origin + (x, y) × cellSize` to `origin + (x + 1, y + 1) × cellSize`. Grid line i is `origin + i × cellSize` computed once and snapped, so neighbours share coordinates exactly. The cell size must be at least one grid unit. The boundary between solid and non-solid cells is traced into rings directly (no noding is needed). ## Arrangement: noding by snap rounding All boolean operations and offsets reduce to one routine. Input: directed edges, each tagged operand 0 or 1. Output: non-crossing segments between grid points; each unique segment (vertex indices A < B in (x, y) order) carries the net number of operand-0 and operand-1 input edges running A→B minus B→A. Zero-net segments are dropped. 1. Edges with equal endpoints are dropped. 2. **Hot pixels:** every endpoint, plus the rounded point of every proper crossing (two edges whose open interiors meet in a single interior point). The crossing is computed exactly as a rational and rounded once: `floor(x + 1/2)` per coordinate. A pixel is the half-open square `[c − 1/2, c + 1/2)²` of the points that round to grid point `c`, so every point belongs to exactly one pixel. 3. **Reroute:** each edge becomes the polyline through its own endpoints and every other hot pixel it meets (closed segment against the half-open square; evaluated exactly in doubled coordinates with a symbolic shrink of the open sides), ordered by the projection of the pixel centres onto the edge, ties by (x, y). 4. **Repair loop:** until nothing changes — fragments that properly cross get their rounded crossing added as a hot pixel and are rerouted again; a vertex lying exactly inside a fragment splits it. This normally makes one verifying pass; it guarantees the result has no proper crossings and no vertex inside a segment. After 256 rounds a `GeometryException` is thrown (a defect, never bad input). 5. Fragments are merged by endpoints; the vertex set is sorted by (x, y). **Finding crossings and nearby pixels.** Steps 2 to 4 only need *candidates*: every pair or pixel that could matter. Each candidate is then decided by the exact tests above, so the way candidates are found never changes a result. Both searches use a uniform grid of square cells (side 2^s grid units, s ≥ 1, the least power of two at least the mean fragment extent `max(|dx|, |dy|)`, raised until there are at most about 2n cells): fragments are listed in every cell their segment passes through (a row range per column, from exact integer floor divisions), hot pixels in the one cell holding them. A fragment's crossing partners are the other fragments in its cells; the pixels it may meet are those in the cells it visits and their eight neighbours (a pixel within one unit of the segment sits in a neighbouring cell because cells are at least two units wide). When the cell counts show clustered input (the sum over cells of count² exceeding 32 × the number of items), the grids are not used and the older searches take over: a sweep in increasing minimum x with an active list for crossings, and a binary search of the x range of the sorted pixels for pixels. Time is then linear in the input plus the number of candidates for evenly spread input and what the sweep costs otherwise. Distortion: a segment can move by up to one grid unit near other geometry (every vertex captures the segments passing through its pixel). With the default grid that is about 1.5e-5 units. ## Labelling Winding numbers (per operand) are 0 on the unbounded face and change across a segment running u→v (u before v in (x, y) order) from its right to its left by the segment's net delta in direction u→v. They come from a plane sweep over the non-crossing segments, vertices visited in (x, y) order (which treats vertical segments as if the sweep line were tilted by an infinitesimal angle). The status is the list of segments cut by the sweep line, bottom to top, with the winding just above each. At a vertex v: binary search of the status with the exact orientation of v against each segment finds the block of segments ending at v; the winding below the block is that of the segment under it (0 if none); the block is removed; the segments starting at v are sorted bottom to top by the exact cross product of their directions and inserted in its place, each getting winding-above = winding-below + its net delta. Every segment is therefore labelled with the windings on both sides. The status is a plain list, so insertion and removal move memory proportional to its length (a few hundred entries for tile-sized input). A face is inside according to the operation: | Operation | Inside when (w0, w1 windings) | |---|---| | even-odd (resolving an outline) | w0 odd | | union of resolved edge sets | w0 > 0 or w1 > 0 | | intersection | w0 > 0 and w1 > 0 | | difference (operand 0 minus 1) | w0 > 0 and w1 ≤ 0 | | symmetric difference | exactly one of w0 > 0, w1 > 0 | Segments separating inside from outside form the boundary, directed with the inside on the left; they are chained into rings as above. (`Region.Triangulate` still labels triangles by flooding the constrained Delaunay triangulation from the unbounded face; both methods give the same windings.) ## Offsetting `Region.Offset(distance, join, arcTolerance)` with `|distance| ≤ 2^20`: positive grows, negative shrinks. The result is `region ∪ S` (grow) or `region ∖ S` (shrink), where `S` is the union (windings > 0) of convex pieces swept along every boundary edge, so thin features vanish and narrow gaps close exactly. Let `r = |distance| × 2^SnapBits` (grid units). - **Round:** per boundary edge a rectangle of half-width `r` (corners `p ± r·n`, `n` the unit normal of the edge, each rounded to the grid, counter-clockwise), and per distinct boundary vertex a regular m-gon *circumscribed* about the circle of radius `r`, with its first vertex at half a step from the positive x axis, counter-clockwise, vertices rounded to the grid. The m-gon never lies inside the true circle; its vertices lie at most the arc tolerance outside it. `m` is the least of 8, 16, 32, 64, 128, 256 with `r / cos(π/m) − r ≤ tol` (`tol` = `arcTolerance` in grid units if at least 1, else `max(1, r/128)`), capped at 256. The unit vectors come from half-angle recurrences using only `+ − × ÷ sqrt` (`cos(θ/2) = sqrt((1 + cos θ)/2)`, from the 4-gon), and vertices are generated by repeated rotation, so they are identical everywhere. - **Square:** per boundary edge the convex hull (exact, strictly convex, counter-clockwise) of the axis-aligned squares of half-side `max(1, round(r))` centred on its two endpoints: the Minkowski sum of the region with an axis-aligned square. - Offset geometry beyond ±2^21 + 2^20 throws `ArgumentOutOfRangeException`. ### Shrinking for the build (`Mesh2DPipeline.Shrink`) The agent-radius shrink in the build pipeline removes `S` with fewer pieces. A point of the region that is within `r` of the boundary is within `r` of an edge or of a vertex that is reflex for the interior; a convex vertex is never the nearest boundary point of an interior point. So only the interior side of each edge and only the reflex vertices are swept: - per boundary edge `a→b` (interior on the left, `n` as above): the rectangle `a, b, b + r·n, a + r·n`, the two far corners rounded to the grid; - per vertex `a` where `prev→a→b` turns right: the circular sector from the outgoing edge's normal direction counter-clockwise to the incoming edge's normal direction (less than a half turn), as the polygon `a`, the tip `a + r·n_out`, the vertices of the circumscribed m-gon strictly inside the sector in counter-clockwise order, the tip `a + r·n_in`. The tips are rounded by the same expression as the rectangles' corners, so a sector's straight sides coincide exactly with the rectangles' end sides; the m-gon is the one used for round offsets above. Collinear and left-turning vertices have no sector. The pieces are fed to the arrangement as edges, not as closed polygons, and only the edges that can matter are fed: a rectangle's side `a→b` is the region's own edge, so that edge is added once, counting for both operands (`Op` 2); where a sector exists its two straight sides run over the same points as the end sides of the two rectangles they meet, in opposite directions, so all four are left out (they would cancel in the arrangement). What remains per reflex vertex is the arc between the two tips. The removed set equals the round offset's in exact arithmetic; the polygonal arcs stay within the arc tolerance of the circle, and the rings can differ from `Region.Offset` by that much (about 0.2% of the area on a dense noisy tile map). The pipeline passes `arcTolerance = max(CellSize / 4, min(0.0825 × AgentRadius, CellSize / 2))`: a quarter cell (the mesh cannot resolve finer), raised to the octagon's tolerance so that radii up to six cells use 8 vertices per circle (more vertices at those radii only add small triangles), and never above half a cell, so a larger circle gets more vertices. On the noisy tile map (cell 0.25) the octagon at radius 1.0, where the quarter-cell rule used 16 vertices, takes the polygon count from 12.6k to 5.0k and leaves coverage within +0.005% of the exact eroded area. `Region.Offset` itself is unchanged. **Equal-area arcs.** A circumscribed polygon removes more than the disc, by the same amount at every round corner (`m tan(π/m) − π` times r², about 4% of a disc for m = 16, 10% for m = 8). Across many corners, or around a small leftover such as the cusp between a quarter disc and two walls, that is a large share of the walkable area. So the shrink reshapes the polygon, keeping the same `m`: let `q = m/4`, `h = r·tan(π/m)` (the tangent offset of the circumscribed polygon's end vertices). In the quadrant of directions from 0 to 90 degrees the vertices are - the first, `(r, λh)`, and the last, `(λh, r)`, which slide along the tangent lines through the quadrant's two axis points `(r, 0)` and `(0, r)`, so walls that run along the axes continue straight and the axis points stay collinear; - the `q − 2` vertices between, the circumscribed polygon's own, multiplied by `λ`. `λ` in (0, 1] is the value at which the polygon `centre, (r, 0), the q vertices, (0, r)` has area `π r² / 4`. That area is quadratic in `λ` and is below the quarter disc at 0 and above it at 1, so it is found by 60 bisection steps on [0, 1] (`mid = (lo + hi) / 2`, keep `[mid, hi]` when the area is below, else `[lo, mid]`; use `hi`), which uses only `+ − × ÷` and gives the same bits everywhere. The other three quadrants are the first rotated by 90, 180 and 270 degrees (`(x, y) → (−y, x)` applied to the vertex `q` places earlier). Sector pieces take the vertices strictly between their two tips from this ring exactly as before. Clearance deviates from `r` by a bounded amount in both directions: the vertices lie up to 5.8% of `r` outside the circle and the chords up to 4.9% inside it for 8 vertices (1.9% and 1.2% for 16), where the circumscribed polygon lay 8.2% (1.96%) outside. So where the shrunk boundary turns a round corner an agent's centre may come up to 1.2% (16 vertices) or 4.9% (8) of its radius closer to the wall than `r`, at the chord midpoints; this is the one place the build over-covers. On the pillar-room shapes this brings coverage from −1.14% and −4.7% (cusps) of the exact eroded area to within ±0.1%. ## Constrained Delaunay triangulation Input: distinct grid points sorted by (x, y), and noded constraints (segments meeting only at endpoints). 1. A bounding triangle with corners (−2^45, −2^45), (3·2^45, −2^45), (−2^45, 3·2^45) grid units encloses everything. 2. Points are inserted into the Delaunay triangulation by Lawson's incremental algorithm in **biased randomised order** (Amenta, Choi & Rote 2003). Vertex `i` (its index in the sorted input) belongs to round `min(top, tz(h(i)))`, where `h` is SplitMix64's output function applied to `i + 0x9E3779B97F4A7C15`, `tz` counts trailing zero bits and `top = max(0, floor(log2 n) − 3)`; the rounds are inserted from the highest to round 0. Within a round points follow the Hilbert curve of order 12 over the cells `(x − minX) >> s, (y − minY) >> s` (`s` the least shift that gives cells below 4096 per axis), ties by index. With fewer than 64 points the order is the sorted order. The order only matters where points are cocircular (which of two diagonals survives), so it is part of the result. Sorted order walked across whole columns of the triangulation for every point (20 flips per point on a tile map; this order needs about 3). Locate by a visibility walk from the last triangle (edge order 0, 1, 2; exact orientation), split the containing triangle (or the two triangles sharing the edge the point lies on), then flip edges opposite the new point while the opposite vertex lies strictly inside the circumcircle (exact in-circle). Cocircular points are not flipped. 3. Constraints are inserted in (A, B) index order by Sloan's method: walk the triangles crossed by the segment, queue the crossed edges, and flip each whose quadrilateral is strictly convex (exact test), re-queueing flipped edges that still cross, until the segment is an edge; then it is marked constrained. 4. Passes over all triangles in index order flip non-constrained edges whose opposite vertex is strictly inside the circumcircle (and whose quadrilateral is convex) until a pass flips nothing. The result depends only on the sorted points and constraints. ## `Region.Triangulate` Constraint edges are the region's ring edges; faces with winding > 0 are kept, so the triangles exactly cover the region and every boundary edge is a triangle edge. Vertices are the ring vertices in (x, y) order. Each triangle is rotated to start at its smallest vertex index; triangles are sorted by (v0, v1, v2). Neighbour entry `3t + i` is the triangle across edge `(v_i, v_{i+1})`, or −1 on the boundary. ## `Region.PartitionConvex(maxVerticesPerPolygon)` (3 to 16, default 6) Hertel–Mehlhorn greedy merge. Candidate diagonals are the interior triangle edges, longest first (squared length in `double`; ties by smaller then larger vertex index). A diagonal is removed (its two polygons joined) when (a) the joined polygon has at most the limit's vertices, (b) the polygons share no vertex other than the diagonal's ends, and (c) the turns at both diagonal ends are left or straight (exact orientation ≥ 0). A single pass in that order is exhaustive, because a merge only widens corners and enlarges polygons. The polygons are therefore **weakly convex**: a vertex on a straight edge stays where a neighbouring polygon needs it. Each polygon starts at its smallest vertex index; polygons are sorted lexicographically by their index lists. The neighbour of polygon edge `(v_i, v_{i+1})` is the polygon holding the reversed edge, or −1. ## What is promised - Every output is valid: rings never cross, polygons are convex and counter-clockwise, adjacency is symmetric, areas add up exactly. - Results depend only on the **set** of input edges (not on outline order, ring order, start vertex or direction of a ring's even-odd reading) and on `SnapBits` / limits, and are identical on every platform and thread count. - Operations are exact on the snapped input; the distortion bound above is the only approximation. - Costs: for evenly spread input noding, labelling and tracing take time near linear in the number of edges and candidates; input that crowds into few grid cells (a handful of enormous segments over many tiny ones) falls back to the sweep and the x-range search, which are quadratic in the worst case. # 3D builder, front half: voxelization and the span stage Normative. This describes what `RecursiveAI.OnePath.Mesh.Build3D` computes up to the compact open-span heightfield, which is the hand-off to the region stage. A port must reproduce the same spans from these rules. Everything is IEEE-754 float32 (`+ − × ÷ sqrt` only) unless a type says double; nothing here uses a transcendental function. ## 1. The grid (`HeightfieldFrame`) - World origin `(ox, oy, oz)` (double), cell size `cs` (x and z), cell height `ch` (y), both float32 > 0, and the dimensions `W × D × H` in cells. A column is `(x, z)`, index `x + z·W`; a row is `y`. - Cell size and cell height must lie in [2^-20, 2^20]. - Limits: `W, D ≤ 65536`, `W·D ≤ 2^24`, `H ≤ 65535`. Tile-local coordinates then stay below 2^16 cells, where a float32 resolves a cell to 1/256. - Coordinate rule: every world coordinate (vertices, bounds, origin, volume corners) must be finite and within ±2^21. NaN is an `ArgumentException`, a value beyond the bound an `ArgumentOutOfRangeException` naming the value and the bound. - `FromBounds(min, max, cs, ch)`: origin = min; `W = ceil((maxx − minx)/cs)` and likewise for `D` (with z) and `H` (with y, `ch`), computed in double. Empty or inverted bounds are an `ArgumentException`. - A vertex becomes tile-local as `(float)(x − ox)` (double subtraction, rounded once to float32), then scaled to cell units: `u = lx / cs`, `v = ly / ch`, `w = lz / cs` (float32 division). All clipping happens in `(u, v, w)`, so every cell boundary is an integer. ## 2. Triangle rasterization (`Voxelizer.Rasterize`) Triangles are processed in index order. Triangle `(a, b, c)` is wound counter-clockwise seen from the side it faces; its normal is `n = (b − a) × (c − a)` computed on the tile-local float32 coordinates (not the scaled ones). **Validation (before anything is added).** Index arrays must have a length divisible by 3 and every index must address a vertex (`ArgumentOutOfRangeException`); every referenced vertex must be finite and within ±2^21. On failure nothing is added. **Degenerate triangles.** `len = sqrt(nx² + ny² + nz²)` in float32. If `len` is 0 or infinite the triangle is skipped and counted in `VoxelizeStats.Degenerate`. **Walkability.** Triangle area id = 63 when `ny > slopeCosine · len`, else 0 (`ClassifyTriangles`; the caller can supply any area ids instead). **Cells touched.** A cell is the half-open footprint `[i, i+1) × [j, j+1)` in `(u, w)`; the triangle is clipped to the *closed* footprint (Sutherland–Hodgman against axis-aligned planes at integers; vertices exactly on a plane belong to both sides; the intersection point on a crossed edge is `p + (q − p)·t` with `t = dp/(dp − dq)` in float32 and its clipped coordinate set exactly to the plane). Splitting runs rows first (planes `w = j+1`, from low `j` upwards), then cells within a row (planes `u = i+1`), after first clipping to the grid rectangle `[0,W]×[0,D]`. - A triangle whose footprint has positive area touches a cell exactly when its clipped piece has positive area. The area is `Σ (p_i − p_0) × (p_{i+1} − p_0)` over the piece's fan, in float32; pieces whose points share an axis-aligned line have area exactly 0. A triangle that only touches a cell's edge or corner therefore does not touch the cell. - A **vertical** triangle (footprint cross product exactly 0 but `len > 0`) touches a cell when its clipped piece has positive length in `(u, w)` (`max u − min u > 0` or `max w − min w > 0`). If all three `u` are equal to an integer `k` (a wall exactly on a grid line) the wall belongs to column `k − 1` if `nx > 0`, else `k`; likewise for `w` with `nz`: it belongs to the side *behind* the surface, so a closed solid's walls stay inside it. Out-of-grid columns drop the wall. **Span of one piece.** `lo = floor(min v)`, `hi = ceil(max v)` over the piece's vertices. If `lo == hi` (a horizontal piece exactly on a row boundary): `ny > 0` → `lo = hi − 1` (the solid is below an upward-facing surface), else `hi = lo + 1`. The piece is dropped if `hi ≤ 0` or `lo ≥ H`; otherwise rows are clamped to `[0, H]` and the span `[lo, hi)` with the triangle's area id is added to the column (§3). A surface exactly at the bottom of the grid facing up has no row to occupy and is dropped. The surface of a span is its top: row `Max`, world height `oy + Max·ch`. ## 3. Spans and merging (`SolidHeightfield.AddSpan`) A column holds disjoint, non-touching spans sorted by `Min` (a span `[Min, Max)`, `Min < Max`). Adding `[min, max)` with area `a` and threshold `T` merges with every existing span that overlaps **or touches** it (`s.Min ≤ max` and `s.Max ≥ min`), one span at a time from the bottom up: - if `s.Max > max`: `a = (s.Max − max ≤ T) ? maxArea(a, s.Area) : s.Area`, `max = s.Max`; - else if `max − s.Max ≤ T`: `a = maxArea(a, s.Area)` (otherwise `a` is kept); - `min = min(min, s.Min)`. The result depends on the order triangles are added (the rule is not associative) but on nothing else. `T` is `SpanSettings.MergeThreshold`, default the walkable climb. Soup of *surfaces* gives shells: the interior of a closed box has a floor span and a ceiling span with a gap between; overlapping or vertically stacked closed meshes leave interior floors, as with any surface voxelizer. ## 4. Height grids `RasterizeHeightGrid` turns `countX × countZ` heights (x fastest, spacing `s`, origin `(x0, z0)`) into two triangles per square, `(a, c, d)` and `(a, d, b)` with `a = (i,j)`, `b = (i+1,j)`, `c = (i,j+1)`, `d = (i+1,j+1)`, facing up, and rasterizes them as triangles. ## 5. Voxel chunks (`VoxelChunk`, `Voxelizer.AddChunk`) A chunk is run-length columns `(x, z) → runs (start, length, material)`, sorted, disjoint, non-empty, material 1..255, inside `0..Height`. `FromDense` takes `(z·W + x)·H + y` indexing with 0 as empty and joins equal neighbours into runs. `AddChunk` places the chunk at an offset in the grid, drops what falls outside, and adds each run as span `[start, start+length)` with area `materialAreas[material]` (default 63 for every material) and merge threshold 0, columns in z-then-x order, runs bottom-up. A solid box built as triangles and the same box as voxels give the same spans except for the shell gap of §3; after the filters (which cannot see the gap from a walkable floor) the compact heightfields are identical. ## 6. Filters (on the solid heightfield; each only changes area ids) All lengths in rows. `Unbounded = 2^30`. The ceiling of a span is the `Min` of the next span up, or `Unbounded` for the top one. 1. **Low-hanging obstacles** `(climb)`: for consecutive spans `p` below `s` in a column, if `s.Area = 0`, `p.Area ≠ 0` (original values) and `s.Max − p.Max ≤ climb` then `s.Area := p.Area`. 2. **Ledge spans** `(height, climb)`: for every span `s` with `Area ≠ 0`, floor `f = s.Max`, ceiling `c`. For each of the four neighbour columns: outside the grid → a drop of `−climb − 1`. Otherwise list the neighbour's gaps: `(floor −climb, ceiling first.Min)` (`Unbounded` if empty), and for every neighbour span `n` `(floor n.Max, ceiling next.Min or Unbounded)`. A gap is *passable* when `min(c, gapCeiling) − max(f, gapFloor) ≥ height`. For each passable gap: `drop = gapFloor − f`; track the lowest drop; if `|drop| ≤ climb` track min and max gapFloor. `s` is a ledge (`Area := 0`) if the lowest drop `< −climb`, or (max − min of accessible floors) `> climb`. Geometry only is read, so the result is independent of visiting order. 3. **Low height** `(height)`: `s.Area := 0` when `s.Area ≠ 0`, a span above exists and its `Min − s.Max < height`. `SpanPipeline` applies 1, 2, 3 in that order. ## 7. Compact open-span heightfield (`CompactHeightfield.Build(height, climb)`) For every solid span with `Area ≠ 0`, in column-major, bottom-up order, one `CompactSpan`: `Y = s.Max`, `Height = min(65535, nextMin − s.Max)` (65535 for the top span), `Area`, `Region = 0`. `CellFirst/CellCount` index the spans of each column. Directions: 0 = −x, 1 = +z, 2 = +x, 3 = −z. A span links in direction `d` to the **lowest** span `t` (index `m` inside the neighbour cell) with `min(s.Y + s.Height, t.Y + t.Height) − max(s.Y, t.Y) ≥ height` and `|t.Y − s.Y| ≤ climb`; the link stores `m` in 6 bits at `6d` (63 = none). Cells with 63 or more spans cannot be linked beyond index 62. ## 8. Erosion (`Erosion.ErodeWalkableArea(radius)`) A span is a *boundary* span when its area is 0 or in any direction it has no link or its link target has area 0. `dist(s)` is the least cost of a path of steps from `s` to a boundary span, where a step is an orthogonal link (cost 2) or two perpendicular links in either order (a diagonal, cost 3); boundary spans have distance 0 and distances are capped at `2·radius`. Every span with `dist < 2·radius` gets area 0. The distance is the exact shortest path (a fixed point, independent of sweep order), not a two-pass approximation. `radius` is in cells, 0 disables, at most 2^20. ## 9. Area volumes (`ConvexVolume.Apply`) The polygon (3 to 256 corners, either winding, convex and simple, non-zero area; checked by testing every corner against every edge) and the interval `[MinY, MaxY]`. A compact span with area ≠ 0 gets the volume's area when the cell centre `(ox + (x+0.5)·cs, oz + (z+0.5)·cs)` (double) is inside or on the polygon and the floor height `oy + Y·ch` (double) lies in `[MinY, MaxY]`. Volumes are applied in list order after erosion; later ones overwrite. ## 10. Settings defaults (`SpanSettings.Humanoid`) Slope cosine 0.70710677 (45°), height 10 rows, climb 4 rows, radius 2 cells, merge threshold = climb. For cell size 0.3 and cell height 0.2 these mean about 2.0 tall, 0.8 step, 0.6 radius. ## 11. Determinism Single-threaded; no result depends on thread count, SIMD or platform. The hash-matrix scenarios `build3d/*` cover §2–§9. # 3D builder: regions and contours Normative. Continues `build3d-spans.md`: what `RecursiveAI.OnePath.Mesh.Build3D` computes from the compact heightfield up to contours, which are the hand-off to the polygon stage. Integer arithmetic only (cell and corner coordinates are `int`, products `long`); nothing here uses floating point except the error threshold compare in §6, which is IEEE `double` multiplication and division of exactly representable integers. Throughout, *neighbour* and *link* mean a **mutual** link: span `a` has a neighbour `b` in direction `d` only when `a` links to `b` in `d` and `b` links to `a` in the opposite direction (with several floors in one cell a link goes to the lowest qualifying span and can be one-way; boundary tracing needs a symmetric relation to close its loops). A one-way link counts as no link. Directions are the compact heightfield's: 0 = −x, 1 = +z, 2 = +x, 3 = −z. A span's *neighbour* in direction `d` is the span its link in `d` points to (or none). Region id 0 means "no region"; ids 1..`RegionCount` are written to `CompactSpan.Region`. A region has exactly one area id and is connected through neighbour links. ## 1. Distance field (`Regions.BuildDistanceField`) For every span the exact shortest-path distance to a boundary span, as in `build3d-spans.md` §8 (boundary = area 0, a missing link, or a link to an area-0 span; orthogonal step 2, diagonal step 3), without a cap. Spans of area 0 have distance 0. ## 2. Watershed (`Regions.BuildWatershed`) Walkable (area ≠ 0) spans are visited by distance from the largest value down; spans of equal distance form a *level*, ordered by span index. At each level `L`: 1. **Growth.** Candidates are the level's spans. In synchronous rounds, every candidate with at least one neighbour that already has a region *and the same area* joins the smallest such region id; all joins of a round are decided from the state at the start of the round. The next round's candidates are the still-unassigned level spans next to a span that joined in the round just finished, in span-index order. Growth ends when a round joins nothing. 2. **Basins.** Each still-unassigned span of the level, in span-index order, becomes a new region (next id) and floods, breadth first, every connected unassigned span of the same area and the same level. Then the filter of §5 runs. ## 3. Monotone (`Regions.BuildMonotone`) Rows `z = 0, 1, …`. Within a row a *sweep* is a maximal chain of walkable spans linked in direction 0 (−x) with equal area, found while visiting cells in increasing `x`; a span with no such predecessor starts a new sweep. For a sweep, look at the spans' neighbours in direction 3 (−z) that have a region and the same area. The sweep *continues* region `R` of the previous row when all those neighbours have region `R` (at least one exists) and no other sweep of the row touches `R`; otherwise it starts a new region (next id, ids assigned in sweep order). Without filtering, a region has at most one sweep per row, so each of its rows is one run along x. Then the filter of §5 runs. ## 4. Layers (`Regions.BuildLayers`) Start from the unfiltered monotone regions. Two regions *overlap* if some column holds a span of each; they are *adjacent* if a link joins them. Repeat until nothing changes: for `a` in increasing id order (alive regions only), for adjacent `b` in increasing id order with the same area that does not overlap `a`, merge the larger id into the smaller one (overlap and adjacency sets are unioned) and restart for the next `a`. The result is a set of layers in which no column holds two spans of one layer, and any two adjacent same-area layers overlap. Then only the island removal of §5 runs (minimum area; no merging). *Shortcut (same result).* When no column holds two walkable spans, no two regions overlap, so every pair of adjacent same-area regions merges and the partition is the connected components of "linked and same area". The implementation then labels components directly (union over the −x and −z links, ids in order of first span) and skips the sweep and the merge; when a component is smaller than the minimum area it uses the general code instead. A test compares both on random fields. *Layer-first partition (tile builds with `PartitionType.Layers`).* A tile build decides each span's layer once, from the geometry-only heightfield (after erosion, before any obstacle or run-time volume): the layer partition above with every island kept, then layers assigned as in `build3d-polys.md` (overlapping regions on different layers). The tile's regions are then the connected components of "linked, same layer and same area" (union over the −x and −z links, ids in order of first span), followed by the island removal of §5. Because the layers stay fixed, a rebuild after an area change is one labelling pass; it never takes the sweep-and-merge path. A cached rebuild and a plain build use the same layers, so they give the same bytes. A pipeline whose `Regions.Partition` step was replaced uses that step instead. *Layer-by-layer build.* When the contour, polygon and detail steps are the default ones and the detail stage does not sample heights (`DetailSampleDistance` at most 0.9 cells), each layer of the tile is built on its own from its own spans (`build3d-runtime.md`, "Layer-first tiles"); otherwise all layers of a tile are partitioned, traced and polygonized together as described here. On the generated quality levels the polygon counts and walkable area under `Layers` equal the previous rule's (one level moves two polygon groups to another layer). ## 5. Filtering and numbering (`Regions.FilterRegions`) 1. *Islands:* a region with fewer than `MinRegionArea` spans that has no neighbouring region of any area (no link from any of its spans to a span with a different, non-zero region) is removed: its spans get region 0. 2. *Merging* (not for layers): repeat passes over regions in increasing id order; a region with fewer than `MergeRegionArea` spans merges into the neighbouring region with the **same area** that shares the most links with it (smallest id on ties), if one exists. Stop when a pass merges nothing. Afterwards no region smaller than `MergeRegionArea` has a same-area neighbouring region. 3. *Numbering:* surviving regions are numbered 1..k in order of their first span (span index order: column-major by `z`, then `x`, bottom up). Walkable spans that end with region 0 are exactly those of removed islands. Spans with area 0 never have a region. ## 6. Contours (`Contours.Build`) **Edges.** For span `s` of region `R ≥ 1` and direction `d`, the *neighbour value* is the region of the neighbour in `d`; 0 if there is no link and the adjacent cell is inside the grid; `Contours.TileBorder` (−1) if it is outside the grid. The edge is a *boundary edge* when the neighbour value ≠ `R`. **Tracing.** Cell `(x, z)` covers `[x, x+1] × [z, z+1]`. Edges are oriented with the region on the left (x right, z up): the start corner of the edge in direction 3 is `(x, z)`, direction 2 `(x+1, z)`, direction 1 `(x+1, z+1)`, direction 0 `(x, z+1)`. Loops are traced from the first unvisited boundary edge in span-index, then direction, order. State `(span, dir)`: if the edge is a boundary edge, emit a vertex at its start corner with the edge's neighbour value, mark it visited, and continue with `(span, (dir+3) mod 4)`; otherwise move to the neighbour in `dir` and continue with `(neighbour, (dir+1) mod 4)`. A loop ends on returning to its first state. Vertex `Y` = highest floor among the span and the up to three spans reached by the links around the start corner (the two links of the corner's edges and the diagonal one, reached either way). A loop with positive signed area (counter-clockwise) is an outline of the region; one with negative area is a hole. The sum of the signed areas of all loops of a region equals twice its span count, and the loop lengths sum to its number of boundary edges. **Simplification** of one loop (outline or hole), on the loop's `n` raw vertices, where a vertex's *neighbour* describes the edge to the next vertex: 1. *Kept vertices.* Every vertex whose neighbour differs from its predecessor's is kept (the *portals*). If there is none (one neighbour value all round), keep the lexicographically smallest and largest `(x, z)` vertices (lowest index on ties). 2. *Refinement.* Each stretch between consecutive kept vertices `a → b` is handled by a work stack, as in Douglas & Peucker (1973): among the raw vertices strictly inside the stretch, find the one farthest from the segment `a b` (distance to the *segment*: the perpendicular distance, or the distance to the nearer end when the foot falls outside; computed from integers, compared as `double`; ties go to the lexicographically smaller `(x, z)`, so the two sides of a shared boundary agree). If its squared distance exceeds `MaxError²` it is kept and both halves are refined. *Straight region borders* (`StraightRegionBorders`, default on, and only while `MaxError > 0`): a stretch whose neighbour value is a region `N > 0` that is not `BorderRegion` and has the same area id as the contour's region is not refined at all: it stays the single chord between its two ends. Walls, tile-grid borders (−1), the band `BorderRegion` and borders between regions of different areas are always refined. Both regions see the same chord, so shared borders still agree, and the border is only a seam between two walkable regions: it carries no obstacle, so its exact shape is free (the same freedom lets the polygon stage merge across fewer vertices). Region borders otherwise keep a vertex for every staircase step that strays more than `MaxError`. Otherwise, if `MaxEdgeLength > 0`, the stretch is a wall (neighbour value 0) and the squared chord length exceeds `MaxEdgeLength²`, the raw vertex halfway along the stretch (index `(ia + ib) / 2`) is kept and both halves are refined. Portal and border edges are never length-split. 3. Consecutive kept vertices at the same position are merged (the first stays). If fewer than 3 vertices remain, the area is 0 or its sign differs from the raw loop's, the loop is simplified again with `MaxError = 0`; if that fails too the raw loop is used. If the simplified loop has two edges that cross properly (strictly) and straight region borders were in effect, the loop is simplified again with every stretch refined; if it still crosses, the raw loop is used. Both cases count in `RawFallbacks`. Therefore every raw vertex of a wall stretch lies within `MaxError` of the simplified outline (every raw vertex, with straight region borders off; fallbacks keep the raw loop), every portal vertex survives, and a region never disappears. With `MaxError = 0` both sides of every shared boundary keep exactly the same vertices. With a positive error they do too, except around regions so small that simplification would erase them. **Holes.** Each hole is simplified like an outline, then assigned to the smallest outline of its region that contains the hole's leftmost vertex (smallest `x`, then `z`, then index; boundary counts as inside); holes without an outline are dropped (`DroppedHoles`). The holes of one outline are joined in order of their leftmost vertex. For the hole's leftmost vertex `p`, candidate outline vertices `q` are tried by increasing squared distance (lowest index on ties); the first bridge `p q` is accepted when `q` lies inside the interior cone at `p` (in the hole) and `p` inside the cone at `q` (in the outline), and the segment touches no edge of the outline, the hole or the holes still to be joined other than at edges that end at `p` or `q`'s positions. The merged loop is: outline up to and including `q`, the hole from `p` all round and back to `p`, then `q` again and the rest of the outline. The two bridge edges (`q → p` and `p → q`) have `Neighbor` = the contour's own region id. If no candidate works the hole is dropped. Cone and crossing tests use exact `long` orientation. Because every loop is simplified on its own, a simplified hole may cross the simplified outline. After joining, if the merged loop has two edges that cross properly, the outline and its holes are discarded in favour of their raw loops, joined by the same rule (counted in `RawFallbacks`). A region that overlaps itself seen from above (a spiral ramp) can have holes that cancel the outline's area; if the joined loop's signed area is not positive, all of the outline's holes are dropped (`DroppedHoles`) and the outline stays unjoined. **Output.** `ContourSet.Contours` ordered by region id, then by discovery order; each `Contour` has `Region`, `Area` (of the region), `Vertices` (counter-clockwise, at least 3), and, if requested, the raw outline and raw holes. ## 7. Defaults `RegionSettings`: watershed, `MinRegionArea` 64, `MergeRegionArea` 400 (spans; 8² and 20², the usual reference values). `ContourSettings`: `MaxError` 1.3, `MaxEdgeLength` 12 cells, `StraightRegionBorders` true, `BorderRegion` 0 (the tile pipeline sets it to its border band's id). ## 8. Determinism Everything is integer arithmetic over arrays and sorted collections; no result depends on hash order, thread count or platform. The hash-matrix scenarios `build3d/regions-*`, `build3d/contours` and `build3d/regions-contours-terrain` cover §1–§6. # 3D builder: polygons, height detail, tiles Normative text for the last stages of the voxel pipeline and for `NavMesh.Build`. Earlier stages: `build3d-spans.md` (voxelization, filters, compaction, erosion) and `build3d-regions.md` (regions, contours). The mesh format is in `navmesh-format.md`. Everything is integer arithmetic, exact predicates, or IEEE `+ − × ÷ sqrt` in float32/double; nothing depends on thread count or hash order. Tiles are built one after the other in row-major order (tile row, then column), so slot numbers are fixed. ## 1. Tile grid and settings (`Mesh3DPipeline`) - `TileCells = max(1, ceil(TileSize / CellSize − 1e-4))`; the cell size used is `cs = TileSize / TileCells` (double). Cells therefore divide a tile exactly; `cs` is at most the configured cell size plus the 1e-4 tolerance. Rows are `CellHeight` high. - Span settings (all in the nominal float32 sizes `cellSize = (float)CellSize`, `ch = (float)CellHeight`, float32 division): `WalkableHeight = max(1, ceil(AgentHeight / ch))`, `WalkableClimb = floor(WalkableClimb / ch)`, `WalkableRadius = ceil(AgentRadius / cellSize)`, slope cosine = `SlopeMath.CosineOfDegrees(AgentMaxSlopeDegrees)`: the Taylor series of cos in double with 14 terms (`t₀ = 1`, `t_k = −t_{k−1}·x² / ((2k−1)(2k))`), summed from the smallest term up, `x = degrees · (π/180)`, rounded to float32. (45° gives 0.70710677f.) - Region settings come from `NavSettings` (`Partition`, `MinRegionArea`, `MergeRegionArea`); contour settings: `MaxError = ContourMaxError`, `MaxEdgeLength = (int)(MaxEdgeLength / CellSize + 0.5)` cells. - **Border** `B = WalkableRadius + 3` cells on each side. The build grid of tile `(tx, tz)` is `(TileCells + 2B)²` cells with its minimum corner at `(TileOriginX(tx) − B·cs, y₀, TileOriginZ(tz) − B·cs)` (double products and sums in that order). - `y₀` and the row count: for a triangle soup `y₀ = max(minY − CellHeight, −2^21 + 1)` over the tile's triangles, rows = `ceil((maxY − y₀) / CellHeight) + WalkableHeight + 2` (error beyond 65535 rows); for a heightfield `y₀` is the source frame's origin Y and rows are its rows + `WalkableHeight + 2`; for a chunk `y₀ = 0` and rows are chunk rows + `WalkableHeight + 2`. - **Triangle selection (soup).** A triangle belongs to every tile whose grid box (the tile expanded by `B·cs`) its XZ bounding box touches, found as `floor((v − origin) / TileSize)` of the box expanded by `B·cs`, clamped to the soup's tile range. Triangles are passed in input order. A tile none of whose triangles `MayBeWalkable` is skipped: `MayBeWalkable` is `len > 0 && ny ≥ (cosine − 1e-3)·len` for the double cross product `n` of the triangle, so a skip never changes a result. The soup's tile range must not exceed 2^20 tiles (`ArgumentOutOfRangeException` naming the count). - **Voxel input.** Voxel `(i, j, k)` is cell `i`, row `j`, cell `k` from `(OriginX, 0, OriginZ)`; tile `tx` shows chunk columns `tx·TileCells − B …`; one voxel is one cell, so choose `TileSize` a whole multiple of `CellSize`. Heightfield input maps cell `(i, k)` the same way and copies the spans (`AddSpan` with threshold 0); its frame's cell sizes are ignored. ## 2. Per tile 1. Rasterize (soup: `Voxelizer.ClassifyTriangles` + `Rasterize` with the span merge threshold), run `SpanPipeline` (filters, compaction, erosion, volumes). 2. Partition regions (`RegionPipeline.Partition`). 3. **Cut the border.** Let `borderId = (largest region id) + 1`. Every span with `Region > 0` whose cell is outside the tile's `TileCells²` core cells gets region `borderId`; `chf.RegionCount = borderId`. If no span of a region inside the core exists the tile is empty and skipped. The edges between a tile region and the border region are region-to-region edges, which contour simplification keeps straight between their end vertices (build3d-regions.md §6), so they lie exactly on the tile boundary lines. 4. Trace contours (`RegionPipeline.Trace`); contours of `borderId` are ignored from here on. 5. Polygonize (§3), choose `minRow` = lowest vertex row, build the detail (§4), write the tile(s) (§5). The tile's vertical reach `(maxRow − minRow + 2·(WalkableClimb + 1))·CellHeight` must not exceed `MaxLocalExtent` (`InvalidOperationException` naming the tile). ## 3. Polygons (`Polygonizer`) Per contour, in contour order; all tests use `long` cross products on cell coordinates (exact below 2^20 cells) or the exact orientation of the stored float32 positions. **Straight wall vertices.** Before anything else, a contour vertex is removed when both its edges are wall edges (neighbour 0), it is exactly collinear with the previous and next kept vertices (cross 0, same direction), and its floor row is within one row of the straight line between those neighbours (`|(yᵥ−yₚ)·D − (yₙ−yₚ)·T| ≤ D`, with `D = |n−p|²`, `T = (n−p)·(v−p)`, all in `long`). Removal repeats until none is left, unless the loop would drop below three vertices (then it is kept as it was). These vertices come from the edge-length split of simplification; each costs a polygon one of its vertex slots and removing it leaves the wall on the same line. **Ear clipping** (O'Rourke, *Computational Geometry in C*, ch. 1). The loop is a doubly linked ring. A vertex `i` with neighbours `p`, `q` is a *valid ear* when `cross(p, i, q) > 0` and `(p, q)` is a diagonal: it lies in the cone at `p` and at `q` (ring neighbours decide the cone, so hole bridges, which visit a vertex twice, are handled), and no ring edge that has no endpoint at the position of `p` or `q` touches it (proper crossing, or an endpoint of either segment on the other). Among all valid ears the one with the shortest squared diagonal is clipped, ties to the lowest vertex index; the triangle `(p, i, q)` is emitted only if its float32 orientation is positive. A valid ear stays valid when another is clipped, so only the neighbours of the clipped vertex and previously blocked ears are retested. With no valid ear the first vertex from the current head that has `cross ≥ 0` and a diagonal is clipped (counted in `DegenerateEars`), else the head. The last three vertices form a triangle if positive. **Welding.** Corner vertices with the same `(x, z)` and floor rows differing by at most 1 are one vertex (the first one created there, numbered in order of first appearance): bridge ends collapse, neighbouring contours share their boundary vertices, and a contour touching itself at a corner does not give a polygon two vertices at one place. Triangles with a repeated vertex are dropped. **Merging** (Hertel & Mehlhorn 1983). Within one contour, repeat: among all pairs of polygons `A < B` (by index) sharing a directed edge `a→b` of `A` and `b→a` of `B`, with `|A| + |B| − 2 ≤ MaxVertsPerPoly`, choose the pair with the largest squared edge length (first found in `A` index, then edge index order wins ties) whose merge is valid; merge them and delete `B`. The merged ring is `A` rotated to start at `b` (ending at `a`) followed by `B`'s vertices after `a` up to before `b`. A merge is valid when the two joint vertices `a` (neighbours `x`, `w`) and `b` (`z`, `y`) do not turn right (`cross ≥ 0`) in cells, no vertex repeats, and in float32 positions `Local(x)`, `Local(z)` the exact orientation at each joint is `≥ 0`. Exception: a joint that is collinear in cells and whose both incident edges are *wall edges* (contour edges with neighbour 0, or edges created by an earlier drop) is removed from the polygon instead; the turns at its neighbours must then still be `≥ 0` in float32. (Float32 rounding can tip collinear points to the wrong side, and the format rejects that; wall-collinear vertices exist only because long wall edges are split.) Merging never crosses contours. The polygon's area and region are the contour's. ## 4. Height detail (`DetailMeshBuilder`) Coordinates are the stored tile-local float32 values, widened to double: `TileCoordinates.Local(c)` is `0` for `c = B`, `(float)TileSize` for `c = B + TileCells`, else `(float)((c − B)·cs)`; `LocalY(row) = (float)((row − minRow)·CellHeight)`. The sample distance in cells is `sd = DetailSampleDistance / cs`; detail is *off* (each polygon is just triangulated) when `sd ≤ 0.9`. - **Height lookup** `NearestRow(x, z, expected, region)`: the cells `floor(x), floor(z)` and, when `x` (or `z`) is an integer, the cell before it as well; among their spans (all, or only those of `region` when `region ≥ 0`) the floor row nearest to `expected` within `max(WalkableClimb, 1) + 1` rows, first (lowest cell, lowest span) on ties; none → no sample. - **Edge points.** For each polygon edge longer than `sd`: walk from the lexicographically smaller end `(x, z)` (so both polygons sharing the edge generate identical points); `ns = ceil(len / sd)` (edges needing more than 4096 steps get none); point `k` (1 ≤ k < ns) is at `p0 + (p1 − p0)·k/ns` with row = `NearestRow(any region, expected = linear interpolation)` or the interpolation if none. Douglas–Peucker over the rows (error in rows; limit `MaxError / CellHeight`; the point of largest deviation, first on ties, is kept if the deviation exceeds the limit, then both halves are processed) keeps a subset; kept points are emitted in the polygon's direction. If the polygon vertices plus all edge points exceed 255 vertices no edge points are used. - **Hull triangulation.** The ring (polygon vertices with their edge points) is ear-clipped with the exact orientation (strictly positive ears, empty of other ring vertices, closed test; relaxed to `≥ 0` then forced if needed; first valid ear from the head) and then every interior edge is made locally Delaunay by Lawson flips with the exact in-circle test, flipping only strictly convex quads, in LIFO order starting with all edges in triangle/edge order. - **Interior samples.** On the grid `x = minX + i·sd`, `z = minZ + j·sd` (i, j ≥ 1, strictly inside the polygon's bounding box, `z` outer loop, `x` inner), keep points whose distance to every polygon edge line exceeds `sd/2`, with row = `NearestRow(region of the polygon, expected = mean vertex row)`. - **Refinement.** Each sample is located in a triangle (first containing it by exact orientation, boundary included) and its error is `|y − plane height|` in double. While fewer than 255 vertices exist: take the live sample with the largest error (lowest index on ties); if its error is not above `MaxError` stop; add it as a vertex (a point on an edge splits the edge and its neighbour; a point on a hull edge or vertex is dropped) and legalize the surrounding edges; only samples inside changed triangles are re-located and re-measured. - **Output.** Extra vertices in creation order (edge points, then inserted samples); triangles in triangle-id order as three vertex indices (polygon vertices `0…n−1`, then extras) and an edge-flag byte whose bit `k` is set when the triangle's edge `k` has no neighbour (lies on the polygon boundary). ## 5. Tile data - A region's layer: with `Partition = Layers`, regions are visited in id order; a region may not share a column with a region of the layer it takes; it prefers the lowest-numbered layer of an adjacent region that is free, else the lowest free layer. Other partitions put every polygon on layer 0 (overlapping floors then share one tile layer, as in a single Detour tile). A tile layer holds only polygons of its regions; links between layers of one tile do not exist (they connect only across tile boundaries). Up to 1024 layers. - Per layer: vertices used by its polygons in ascending original index; polygons in original order; area = the contour's area with 63 (`SpanSettings.WalkableArea`) replaced by `NavSettings.Area`, other ids unchanged; flags `NavSettings.Flags`; detail ranges copied and rebased. - `NavTileAssembler.FromLocalPolygons` derives in-tile adjacency (edges shared by exactly two polygons with opposite direction) and marks wall edges whose two vertices lie bitwise on `0` or `(float)TileSize` as portals; `NavTile.Create` validates (convex, counter-clockwise, ranges). - Tile `OriginY = frameOriginY + minRow·CellHeight` (double). A tile with no polygons is omitted. At most 65536 vertices per layer (`InvalidOperationException`). - Portals are stitched by the mesh as described in `navmesh-format.md`; neighbouring tiles' boundary vertices need not match. ## 6. Errors and limits `ArgumentException` (non-finite coordinate, lengths not multiples of three), `ArgumentOutOfRangeException` (coordinate beyond ±2^21 naming the vertex and value, index out of range, setting out of range, more than 2^20 tiles, more than 65535 rows), `InvalidOperationException` for limits found while building (a tile's vertical reach, vertices per layer, layers, a polygon's detail triangles). Nothing else is thrown for any input; a validator failure of the result is a bug. ## 7. Format changes None to the format or its version. New public builder API: `NavTileAssembler.FromLocalPolygons` (like `FromPolygons`, but takes the tile-local float32 vertices as they are stored). New `NavSettings` fields: `CellHeight`, `Partition`, `MinRegionArea`, `MergeRegionArea`, `ContourMaxError`, `MaxEdgeLength`, `DetailSampleDistance`, `DetailMaxError`. ## 8. Quality against Recast 1.6.0 Generated levels (`tools/geom/gen_levels.py`, size 256, 2000 points), same settings (`bench/Bar/OursBuild.cs` prints them), one build each, measured with `tools/bench/quality-3d.sh`; coverage is the sum of polygon areas projected on the ground plane. | level | coverage ours vs Recast, solo | tiled (64 cells) | polys ours/Recast, solo | tiled | mean path length delta, solo | tiled | |---|---|---|---|---|---|---| | city | +0.15 % | −0.09 % | 679 / 361 | 702 / 605 | +0.51 % | +0.39 % | | overlapping | +0.19 % | −0.01 % | 39 / 256 | 612 / 776 | +0.37 % | −0.11 % | | ramps | +0.25 % | +0.03 % | 68 / 138 | 282 / 365 | −0.02 % | −0.14 % | | slivers | +0.08 % | 0.00 % | 5 / 65 | 196 / 247 | 0.00 % | −0.06 % | | stairs | +0.22 % | −0.07 % | 63 / 147 | 256 / 315 | +0.06 % | −0.06 % | | terraces | +0.20 % | 0.00 % | 5 / 65 | 196 / 247 | −0.02 % | −0.01 % | (`stairs` finds 1969 of 1973 paths, −0.2 %, the rest unreachable between snapped points.) ## 9. Hash-matrix scenarios `build3d/polys` (polygonizer and detail on seeded fields), `build3d/mesh-levels` (serialized meshes of generated levels, three settings), `build3d/mesh-layers-chunk` (layers; voxel chunk). # glTF input `GltfReader.Read(bytes)` turns a glTF 2.0 file into world-space triangles for the 3D builder (`NavMesh.BuildGltf`). The same file gives the same triangles on every platform: node transforms are composed in double with `+ − ×` only (no trigonometry, no square roots). ## Files - **`.glb`**: recognised by the magic `glTF` (little endian `0x46546C67`). Version must be 2; the header length must not exceed the file (a shorter file than its header says is an error; extra bytes after the stated length are ignored). Chunks run to the stated length, each padded to 4 bytes; the first JSON chunk and the first BIN chunk are used, other chunk types are ignored. - **`.gltf`**: UTF-8 JSON, nesting at most 64 deep. Buffers are base64 data URIs, or any other URI answered by `GltfOptions.ResolveBuffer` (the reader never opens files). An unanswerable URI is an error naming it. - `asset.version` must start with `2.`. `extensionsRequired` must be empty; `extensionsUsed` is ignored, extension data is not read. ## What is read - **Scene:** `scene` if given, else scene 0, else (no `scenes`) every node that no node lists as a child. Nodes are walked depth first, children in listed order, with an explicit stack; depth above 256 (which includes cycles) is an error. A node is visited once per path to it (instancing), counted against `MaxNodes` (default 1 000 000). - **Transform:** `matrix` (16 numbers, column-major) or any of `translation`, `rotation` (unit quaternion x, y, z, w; its squared length must be within 1e-3 of 1), `scale`, composed as T·R·S; both forms on one node is an error. World matrix = parent × local. Rotation matrix terms use `1 − 2(y²+z²)`, `2(xy ± zw)` and so on in this fixed operation order, then each column is scaled, then translation is added. A vertex is `M · (x, y, z, 1)` summed in the order x, y, z, 1. - **Meshes:** each primitive's `POSITION` (float, VEC3, finite values) and optional `indices` (unsigned byte, short or int, scalar, each below the vertex count). Modes 4 (triangles), 5 (strip) and 6 (fan) are read; strips alternate winding (triangle *t* is *(t, t+1, t+2)* for even *t*, *(t+1, t, t+2)* for odd) and fans are *(0, t+1, t+2)*. Modes 0 to 3 are skipped; other modes are an error. Without `indices` the vertices are used in order. - **Winding:** glTF's front face is counter-clockwise, which is the builder's convention (a floor seen from above). A node whose 3×3 has a negative determinant (a mirror) has the last two indices of every triangle swapped, as the specification requires. - **Not read:** sparse accessors (error), normalized or integer positions (error), skins, animation, morph targets, materials, cameras. ## Accessor checks Every accessor is resolved to a byte range before it is read: view offset + accessor offset + (count−1)·stride + element size must lie inside the view, and the view inside the buffer, computed in 64 bits. Strides below the element size or above 252 are errors. A buffer shorter than its `byteLength` is an error (a longer one, such as a padded GLB chunk, is fine). ## Limits and errors `GltfOptions.MaxTriangles` (default 4 000 000, instances counted) and `MaxNodes` bound the work a small file can ask for. Every failure is a `GltfException` whose message names the JSON path (`meshes[2].primitives[0]`, `accessors[5]`, …); a value of the wrong JSON kind anywhere surfaces as a `GltfException` too. Coordinates beyond ±2^21 or non-finite after transformation are reported by the builder, not the reader. # The 3D builder at run time: tile cache, obstacles, area volumes Status: follows the frozen mesh format (`navmesh-format.md`). Written so a port reproduces the same tiles bit for bit. A level built once can change while the game runs: a barricade appears, a door's doorway closes, a swamp becomes costly. Rebuilding the whole level is too slow, and rebuilding a tile from the triangles repeats work that did not change. A **tile cache** keeps, for each tile, the part of the build that depends only on the geometry, and redoes the rest. ## What is cached Each tile is built on a grid of its cells plus a border of `Border` cells (agent radius in cells + 3) (`build3d-polys.md`). The stages are 1. rasterize the triangles (or voxels) into a solid heightfield; 2. filters (low-hanging obstacles, ledges, low clearance) and compaction into the open-span heightfield (`build3d-spans.md`); 3. erosion by the agent radius and the pipeline's build-time area volumes; 4. **obstacles** (marked grown by the agent radius) and **run-time area volumes**; 5. regions, contours, polygons, detail, tile data (`build3d-regions.md`, `build3d-polys.md`). The cache keeps the result of step 3 for every tile that was built (a tile with no triangle that could be walkable is never built and has nothing to cache; nothing can make it walkable later, since obstacles and volumes only remove walkability or change an area id). A rebuild copies that heightfield and runs steps 4 and 5; it never erodes again. It also keeps the area ids of the span array as last built and as first built, and the tile data of the first build: - a rebuild whose areas equal the last build's changes nothing (queueing is generous, so most rebuilds of a tile next to an obstacle find this); - a rebuild whose areas equal the first build's reuses the first build's tile data (taking the last obstacle off a tile costs no partition). The links between spans do not depend on areas or regions, so the span graph (mutual links, column of each span) is built once per cached tile and shared by every rebuild of it; this saves time and changes no result. Neither shortcut changes a result: a tile is a function of its areas. After a restore the last-built areas are forgotten (the mesh may hold anything), so the next rebuild of each queued tile runs in full. Building with a cache (`Mesh3DPipeline.BuildSoupCached`, `BuildChunkCached`, `BuildHeightfieldCached`) produces exactly the mesh the plain build produces; the pipeline runs the same code on a copy. With `PartitionType.Layers` the cache also keeps each span's layer from the first build (see the layer-first partition in `build3d-regions.md`). The rebuild's cost is the partition and contour stages of `NavSettings.Partition`. `PartitionType.Layers` (monotone regions on height layers) with `DetailSampleDistance = 0` is the cheapest setting, and is what a host that adds and removes obstacles often should choose; watershed with detail sampling gives smoother regions at about twice the rebuild cost. ## Layer-first tiles With `PartitionType.Layers`, the default contour, polygon and detail steps, and no height sampling in the detail stage (`DetailSampleDistance` at most 0.9 cells; this is the cheap setting a host that adds and removes obstacles often should choose), a tile is built **one layer at a time**. The general path (every other setting) builds all layers of a tile together and keeps the compact heightfield of the whole tile. **Splitting.** At the first build the tile's layers are decided as in `build3d-regions.md` §4 (layer partition, then overlapping regions on different layers). A span that is not walkable after erosion goes with the layer of its first walkable neighbour (direction order −x, +z, +x, −z), else layer 0. Each layer keeps its own spans in compact-heightfield order (column by column), their floor rows, column coordinates, base areas and the links between spans **of that layer** (a link to a span of another layer is dropped, so a layer's contours see the other layer as a wall and its corner heights ignore it). Every rebuild starts from the layer's *first-build areas* (the areas after erosion, the pipeline's own area volumes and the pipeline's own `Obstacles`). **Per layer, for the areas in force:** 1. *Marking.* The cache's obstacles, then its volumes, in the order they were added, on a copy of the first-build areas (rules below). Nothing is marked, and nothing is rebuilt, for a layer whose walkable cells' box (x and z) the obstacle's box grown by the agent radius plus one cell (volumes: not grown) does not touch. 2. *Regions.* Connected components of spans of one non-zero area under the −x and −z links, numbered in order of first span; a component smaller than `MinRegionArea` spans with no walkable span of another area linked to it is dropped (the island removal of `build3d-regions.md` §5). Spans in the border ring that belong to a region become one border region. 3. *Contours.* Every region with a span inside the tile's own cells is traced and simplified exactly as in `build3d-regions.md` §6, with the border region as the tile border; corner heights use the layer's links. 4. *Polygons, detail, tile.* As in `build3d-polys.md` §3–§5 with this layer's polygons only: the vertex welding is per layer, the tile's vertical origin is this layer's lowest vertex, the layer number is the tile's layer. A layer with no polygon has no tile. For a tile with one layer the result equals the general path's byte for byte (tested). Where a tile has several layers the two differ in welding across layers, corner heights next to another layer's floor, and the vertical origin of each layer's tile; the walkable area is the same within 1 % (tested). **Rebuilding.** `Update` marks every layer of a queued tile and compares its areas with those the mesh's tile for that layer was last built from; only a layer that differs is built again, and a layer whose areas equal the first build's takes the first build's tile data without building. A rebuilt layer whose tile data equals what the mesh holds keeps its tile. With authored off-mesh links, links go to the layer that fits them best, so a tile in which any layer changed is rebuilt in full, all layers. Pipeline `Obstacles` added or removed after the first build are not seen by rebuilds of a cache (its first-build areas are fixed). ### Repairing a layer An obstacle only takes walkability away, so the layer a rebuild starts from is the first build's with some spans removed. For such a rebuild the cache does not label, trace and simplify the whole layer again: it **repairs** what the first build left, and the result is exactly what the from-scratch stages give for the same areas (tested byte for byte against a cache with the repair turned off, on random obstacles and histories). The first build keeps per layer: the region of every span, each region's size and first span, every traced loop (raw vertices, the edge each vertex starts, its simplified form), which loop owns which edge, and every region's contours. Given the list of spans the obstacles set to area 0 (the *removed* spans): 1. *Preconditions.* Otherwise the layer is built from scratch. (a) The change is only removed spans, no area volume. (b) No region shrinks below `MinRegionArea` or vanishes, and no region smaller than `MinRegionArea` (which is kept only while a walkable span of another area touches it) has a removed span next to it. (c) Every region that lost spans is still one piece: the walkable spans of the region next to a removed span (the *ring*) all reach each other inside the region. Every other span of the region reaches one of them, since a path from it to a removed span passes a ring span first, so this proves the region whole. The search first runs through the spans next to the ring, then through the whole region with a budget of a quarter of the layer (at least 256 spans). (d) The regions are still numbered in the same order: a region's number is the rank of its first span, and a removed first span moves it later. 2. *New loops.* Every edge of a still-walkable span of a traced region that looks at a removed span starts the trace of a loop (same walk and rule as the full trace). Each loop is rotated to start at its smallest edge key (span index × 4 + direction), where a scan of the whole layer would have started it. An old loop with an edge in a new loop is gone; an old loop that loses an edge of a removed span is found the same way, because its walk reaches the removed span through an edge that now looks at it. 3. *Regions.* A region with no new and no gone loop keeps the contours of the first build (shared, never changed). Any other region takes its surviving old loops, with the simplified forms the first build kept (simplification depends on the loop and on the areas of the regions it borders, which did not change), and the new loops, simplifies the new ones, joins holes to outlines and orders loops by their smallest edge key, as the full build does. 4. *Then* polygons, detail and the tile as always. A repaired rebuild is a function of the first build and of the removed spans, so the result does not depend on the history of edits. **Orientation tests in the polygon stage.** Polygons are kept convex in the stored float32 positions as well as in cells, which used to cost an exact predicate call per test (about 850 per rebuilt layer). Rounding a coordinate to float32 moves it by at most 2^-24 of the grid's extent W, so a determinant moves by less than 2^-21·W², and a turn of at least `((cells across the grid)² >> 19) + 1` cell areas keeps its sign: only turns closer than that call the exact predicate. The tile coordinate map exposes the bound; a test checks it against the exact predicate on random and nearly collinear triples. Outputs are unchanged. **Cost.** See "Cost of a rebuild" at the end. ## Obstacles (step 4) An obstacle is a shape with a height range. Erosion has already run, so the shape is **grown by the agent radius** (`WalkableRadius` cells times the cell size) instead: a cylinder's radius grows by it, a box's sides and an oriented box's half sizes move out by it. For every column of the tile's grid (border included) whose **cell centre** (`origin + (index + 0.5) * cellSize`, in double) lies inside the grown footprint (boundary included), every span with a non-zero area whose **floor height** (`originY + row * cellHeight`, double) lies in `[MinY, MaxY]` (both included) gets area 0. Order does not matter (the operation is idempotent per span). The grown footprint is a square-cornered or round offset, not the chamfer distance erosion uses, so a wall made by an obstacle sits within about a cell of where erosion would put it. | Shape | Parameters | Footprint test (x, z world; all double) | Height range | |---|---|---|---| | Cylinder | base centre (x, y, z), radius r > 0, height h ≥ 0 | (x−cx)² + (z−cz)² ≤ (r+g)² | [y, y+h] | | Box | min and max corners | min − g ≤ point ≤ max + g on x and z | [min y, max y] | | Oriented box | centre, half sizes (hx > 0, hy ≥ 0, hz > 0), yaw | with (s, c) = SinCos(yaw) from the deterministic math library: u = dx·c + dz·s, v = dz·c − dx·s; \|u\| ≤ hx+g and \|v\| ≤ hz+g | [y−hy, y+hy] | g is the growth above. The yaw turns from the +x axis towards the +z axis. Every number is finite and within ±2^21, as is every extent (centre ± radius, centre ± the rotated half size); anything else is rejected with `ArgumentException` or `ArgumentOutOfRangeException` naming the value. **Baking.** `Mesh3DPipeline.Obstacles` (set in an object initializer; the list is copied and fixed, because a built cache never reads it again, and `TileCache.AddObstacle` is the only run-time route) holds obstacles applied to every tile of a build, in list order, before the cache's own and after erosion. A mesh rebuilt from a cache after obstacles A, B, C were added equals, tile for tile and byte for byte, a fresh build of the same geometry with A, B, C in `Obstacles` (the tests check this), and removing every obstacle gives back the original tiles. ## Area volumes (step 3 and 4) `ConvexVolume` (`build3d-spans.md`) assigns an area id to eroded, walkable spans. In a rebuild, the pipeline's own volumes (`Regions.Spans.Volumes`) apply first, then the cache's run-time volumes in the order they were added; the later volume wins where they overlap. Building with a volume in `Regions.Spans.Volumes` equals adding it to the cache at run time. Because volumes run after erosion, area 0 makes spans non-walkable without growing by the radius. ## Which tiles rebuild An operation (add or remove an obstacle or volume) queues every cached tile whose build grid, widened by the agent radius and one cell, overlaps the shape's bounding box in x and z (height is ignored). Queueing is by tile row, then tile column. A tile already queued keeps its place. Queueing a tile that turns out unchanged is harmless: it is rebuilt to the same bytes. ## Rebuilding (`Update`) `Update(maxTiles)` takes tiles from the front of the queue. **One tile is one iteration**; no clock is involved, so a budget does the same work everywhere. A tile is rebuilt from **the obstacles and volumes in force when it is processed**, never from those at the time it was queued, so the mesh once the queue is empty depends only on the final set of obstacles and volumes, not on how the work was sliced (tested for budgets of one tile and for all at once). Swapping a rebuilt position into the mesh, layer by layer from the lowest: - a layer whose as-built content id equals the existing tile's keeps the existing tile (its polygon references stay valid and its run-time polygon edits stay); - a changed layer replaces the tile in its slot (new generation; references into it become stale, as for `ReplaceTile`); - a new layer is added; a layer that no longer exists is removed, lowest first. Queries and corridors on the mesh see each swap at once, since the mesh object is the same. ## State and snapshots Run-time state is: the obstacles (with ids), the volumes (with ids), the next ids, the queue, and the mesh's own dynamic state. The cache itself is not state: it is rebuilt from the geometry, so restoring needs a cache built from the same geometry and settings. Ids start at 1, grow by one, and are never reused. Layout: `navmesh-snapshot.md`, "Tile cache snapshot". ## Not done here Tiles rebuild one at a time in queue order; a host may run rebuilds in parallel by tile only if it applies the results in queue order. Off-mesh links, several agent sizes per world and streaming are separate parts. ## Cost of a rebuild Measured on the generated `city` level (tile 64 cells, layers, no detail sampling, 16 cylinder obstacles added and removed one at a time, each followed by `UpdateAll`; about 19 layer builds per cycle, 5000 spans each, 150 removed spans per obstacle and layer). With a warmed-up runtime one cycle takes 1.2 ms on an Apple M2 (the native tile cache it is compared with takes 1.29 ms there; the layer-by-layer build without repair took 1.95 ms, and 3.85 / 7.7 ms before that). Of about 80 µs per layer build on a loaded x64 desktop, the repair is about 25 µs (the pass over the removed spans' neighbours, the search for connectivity, joining the new hole), the polygon stage 15, making the tile 6–10, creating and stitching it 10, and comparing the areas of queued layers that turn out unchanged about 10. A layer is built from scratch when a precondition above fails (about one in twelve on `city`, mostly obstacles that cut a street in two). ## Hash-matrix scenario `build3d/tilecache`: a rolling terrain built with a cache, then obstacles of all three shapes and a volume added, a sliced update, a snapshot, more changes, a restore, and finally everything removed; the serialized meshes and snapshots are hashed at each step. # Path corridors and path following Normative behaviour of `PathCorridor`, `CorridorReplan`, `PathFollower` and `FollowerOptions` (namespace `RecursiveAI.OnePath.Crowds.Corridors`) over a `TiledNavMesh` (`navmesh-format.md`) and a `NavMeshQuery` (`navmesh-queries.md`). Everything here is deterministic: results depend only on the mesh, the inputs and the options, never on platform, thread count or time. Arithmetic is IEEE `+ − × ÷ sqrt` on double (positions are world doubles). There is no clock: the caller passes dt, and every budget counts steps or search iterations. The corridor idea (an agent keeps the polygon route still ahead of it, trimmed as it moves and repaired as the mesh changes, with corners from the funnel algorithm) follows the public talks and writing of M. Mononen; the funnel algorithm is Lee and Preparata 1984 (simple stupid funnel as in `navmesh-queries.md`); arrival steering is Reynolds, *Steering Behaviors for Autonomous Characters*, 1999. Every rule below that those sources leave open is decided here. ## Inputs and errors - Positions are `Vector3D` doubles. A non-finite coordinate or one with |v| ≥ 2^21 throws `ArgumentOutOfRangeException` naming the argument. dt must be positive and finite (same exception). Options out of range throw the same exception naming the field. - Stale, forged or rejected polygon references never throw: the call returns false or a status and changes nothing (or, for `Validate`, trims or reports). - Objects are not thread-safe. The query object and filter are arguments of every call and are never stored. A search in progress inside a query (`CorridorReplan`) must not be disturbed by other uses of that query. - Steady-state steps allocate nothing (checked by a test). ## Corridor state A corridor holds: a capacity (2 to 4096, default 256), a list of polygon references (`Path`, at most capacity, no polygon twice, each connected to the previous), the agent position (on the surface of the first polygon), the target (world point), the target polygon (or invalid), and three flags: `IsComplete` (the last polygon is the target polygon), `NeedsReplan`, and a private *replan from start* flag (set by `SetTarget`, `Relocate` and `RequestReplan`). "Connected" means: the next polygon is entered through a shared in-tile edge (`EdgeNeighbors`), a tile-portal link, or an off-mesh link whose own flags and area the filter accepts; and the filter accepts the next polygon. ## Moving the agent `MovePosition(to)` traces `MoveAlongSurface` from the first polygon at the current position to `to` (sliding along a wall, so the result is always on the mesh). Let *V* be the polygons it visited (first = the first polygon, last = the polygon holding the new position). The new corridor is `reverse(V[j..last]) ++ path[i+1..]` where *i* is the largest corridor index whose polygon is in *V* and *V[j]* is that polygon. So polygons walked are trimmed, a step into a side polygon leads back through the polygons walked, and the result has no repeated polygon (no polygon of `path[i+1..]` is in *V*). A merge that crosses more polygons than the capacity is refused (nothing changes, returns false); an end that no longer fits is cut and the corridor becomes incomplete with a replan requested. `MoveTarget(to)` does the same at the other end (on the reversed corridor) from the target polygon; it is refused if the merge would not fit. If the corridor is not complete the target is only recorded (target polygon cleared, replan requested). `MoveAlong(to, limit)` places the agent at a point known to lie inside the first `limit` corridor polygons (the leg to a corner does, by construction of the funnel): of the polygons whose outline holds the point horizontally (within 1e-7), the one whose surface there is vertically closest to `to.Y` wins, and the furthest along on a tie (within 1e-9). The corridor is trimmed to start there and the position becomes the surface point (`ClosestPointOnPoly`). It follows the corridor instead of tracing a ray, so a leg that grazes polygon vertices or runs along a wall cannot be blocked by rounding at a vertex. False if no polygon in range holds it. ## Corners `FindCorners` is `FindStraightPath(position, target, corridor)`: the first point is the position (flag `Start`), then portal corners, the last point has flag `End` (the target when `IsComplete`, else the nearest point of the last polygon to the target). An off-mesh link in the corridor gives an entry point (flag `OffMeshConnection`) and an exit point; the funnel restarts at the exit. A point equal to the previous one merges its flags into it. `InvalidCorridor` is returned (nothing written) if a polygon is stale or two neighbours no longer touch. ## Shortcuts (each bounded by a budget) **Visibility** (`OptimizeVisibility(point, range, filter, budget)`). Cast a ray along the surface from the position toward `point`, cut to `range` horizontally. The polygons crossed, at most `budget` of them (at least 2, at most the capacity), form a chain *C* starting at the first polygon. Let *i* be the largest corridor index ≥ 1 whose polygon is in *C* at index *j* ≥ 1. Replace `path[0..i]` by `C[0..j]`. Nothing changes if there is no such *i*, if the result equals the corridor, or if it would exceed the capacity (a detour through more polygons than the corridor holds is not a shortcut). The shortcut trusts a straight ray, so it can rarely lengthen the string-pulled path (a test counts: it shortens well over four times more often than it lengthens). **Topology** (`OptimizeTopology(filter, budget, window)`). Run a path search (the query's A\*, see `navmesh-queries.md`) from the first polygon at the position to polygon `path[t]`, *t* = min(count−1, window−1), ending at the nearest point of that polygon to the target, with at most `budget` node expansions. If it finishes with the route, the corridor becomes `route ++ path[t+1..]` with loops cut (a polygon met again cuts everything between its two occurrences, the first stays) and cut to the capacity (then incomplete, replan requested). An unfinished search changes nothing. It needs at least 3 polygons; `window` ≥ 3. The route is taken only if it is *shorter to walk*: the string-pulled length from the position to the same end point through the new polygons must be less than the old length times (1 − 10⁻⁴). A search that costs less is not always shorter to walk (a polygon-node search charges polygon centres, and which way round an obstacle it prefers can change with the agent's place in its polygon): without this test the corridor flipped between two ways round and the agent stood between them (4 of the 1000 seeded crowd runs). **Local query.** When the follower's query is a polygon-node query, topology searches (and the refine below) run on a crossing-node query of the same mesh instead, created once per follower (per worker in a `Crowd`) with `clamp(8 · max(RefineBudget, TopologyBudget), 1024, 2²⁰)` nodes (8192 in a `Crowd`). A crossing-node query is used as is. The routes of the two models are the same search with different node costs, so the choice is only about which route is shorter to walk. **Refine.** When a replan on a polygon-node query ends `Success`, one topology search with budget `RefineBudget` (default 128, 0 = off) and window 64 runs at once, under the same shorter-to-walk test. Over 725 random pairs on scattered obstacles the polygon-node route was up to 1.41 × the shortest walk (worst of the three pairs over 1.08); after the refine none is over 1.08. Two-floor meshes need a budget of 2000. ## Mesh changes `Validate(filter, lookAhead)` checks polygons `0 .. min(count, lookAhead)−1`: each must be a valid reference, accepted by the filter, and connected to the previous one. The first failing index *k*: *k* = 0 → `Invalid` (nothing changes; relocate); *k* > 0 → the corridor is cut to its first *k* polygons, marked incomplete, replan requested (`from start` flag cleared, so the replan splices at the end of the valid part), `Trimmed`. If everything checked is fine and the whole corridor was checked while complete, a stale target polygon makes it incomplete with a replan requested; otherwise `Valid`. `Relocate(filter, extents)` replaces the corridor by the single nearest accepted polygon to the position within `extents` (default (2, 4, 2)), moves the position onto it, keeps the target, requests a replan from start. False if none is near. ## Replanning The target polygon is found afresh at the start of each replan: the accepted polygon nearest to `Target` within the search extents (default (2, 4, 2)); the target itself is **not** moved (so a point on a boundary resolves the same way every time and a later replan asks the same question a new query would). None → `NoEndNearby`. Search 1 starts at the last polygon of the corridor (the end of the still-valid part) at the nearest point of it to the agent, unless the *from start* flag is set (then search 1 is search 2). If search 1 cannot start or ends `Partial` (no route), **search 2** runs from the first polygon at the position. A search is the query's sliced A\* (`BeginPath`/`UpdatePath`). The route of the search that ended is spliced in: `path[0..e−1] ++ route` for search 1 (*e* = its start index), `route` for search 2, loops cut, cut to capacity. Outcome: | Search result | Corridor | Flags | |---|---|---| | `Success` | leads to the target polygon | complete, no replan | | `Partial` (open list empty: no route) | leads to the expanded polygon closest to the target | incomplete, no replan | | `InProgress` (budget used up) | leads to the closest polygon so far | incomplete, replan from start requested | | cut to capacity | as above | incomplete, replan requested | **An unfinished search is not walked.** The closest polygon so far is a guess, and the next round does not continue from its end (that would splice two searches and the route would not be the shortest): the next round searches again from the agent's polygon with twice the budget (`ReplanBudget << rounds`), and the agent stands still while a search from the start is unfinished (`rounds > 0`, corridor incomplete, replan requested). With 40 iterations per replan the worst walked/route over 100 pairs fell from 1.33 to under 1.2; with the default budget of 2048 the case does not arise on small meshes. In a `Crowd` step (not in `PathFollower.Step`), a replan requested only because the corridor was cut to capacity waits until the corridor holds at most three quarters of its capacity (the agent has walked a quarter of it; corridors of fewer than 16 polygons always extend at once); it is not repeated every step. A replan from start, or one after a mesh change that left a short corridor, runs at once. `CorridorReplan` splits one replan over calls: `Begin` then `Update(budget)` until not `InProgress`, then `Apply`. Total iterations across both searches are counted; the result after any split of the same work equals one unlimited call (tested for budgets 1, 2, 3, 5, 17, 100 against `int.MaxValue` on meshes with one-way links and edits, byte-exact). `PathCorridor.Replan(budget)` is `Begin`, `Update(budget)`, `Apply`: when the budget runs out it applies the partial route (above). ## Following `PathFollower` owns a corridor and the agent state: state, position, velocity, destination, counters. `Place(position)` puts it on the nearest accepted polygon (state `Idle`). `SetDestination(target)` sets the target and replans from start in one call of `ReplanBudget` iterations; the state becomes `Moving` (or `NoPath` when no polygon is near the target or the agent was not placed). ### Step(dt) Only in state `Moving` (an off-mesh crossing has its own rule; other states set velocity to zero). In order: 1. **Mesh check.** `Validate(lookAhead = ValidateLookAhead)`. `Invalid` → `Relocate`; failing that, `NoPath`. 2. **Replan.** If the corridor needs one: one `Replan` call of budget `ReplanBudget · 2^rounds` (rounds = unfinished calls so far for this destination; capped at 2^20), then rounds += 1 if still `InProgress`. Reaching `MaxReplanRounds` → `NoPath`. The doubling guarantees that some call is large enough to leave a pocket that is closer to the target than its exits (a restarted search from the closest polygon cannot otherwise). A failed start (stale first polygon) relocates and the step ends; `NoEndNearby` → `NoPath`. Otherwise the step goes on with the corridor as the replan left it (a partial corridor is walked to its end, where the agent waits). 3. **Corners.** `FindCorners` into a buffer of `MaxCorners`; if the corridor does not hold together beyond the look-ahead it is validated in full (cut or relocated) and the step ends without moving. 4. **Shortcuts.** Every `OptimizeInterval` steps (counted since `Place`, 0 = off) a visibility shortcut aimed at the farthest corner within `OptimizeRange` that comes before an off-mesh entry (budget `OptimizeBudget`); every `TopologyInterval` steps a topology optimization (budget `TopologyBudget`). Corners are recomputed if the corridor changed. 5. **Off-mesh entry.** If the first corner (the position itself) or the next one is flagged `OffMeshConnection` and is within `OffMeshTriggerRadius` horizontally, the crossing begins (below) and the step ends. 6. **Arrival.** The steering corner is the first corner after the position that is more than 1e-9 away (or the last). If it is the last and within `ArrivalRadius`: if the corridor is incomplete and a replan is pending, the agent waits (velocity 0, stuck clock reset); otherwise the state is `Arrived`, `ReachedTarget` = `IsComplete`, velocity 0. 7. **Steering.** The wanted speed is `MaxSpeed`, or, if `SlowRadius` > 0 and the end (or an off-mesh entry) is among the corners, `max(MaxSpeed · min(1, d / SlowRadius), ArrivalMinSpeed)` with *d* the corner-polyline length to it (Reynolds' arrival). The wanted velocity points at the steering corner. The velocity moves toward it by at most `MaxAcceleration · dt` (in both speeding up and slowing down); the step length is |v| · dt. 8. **Walking.** Up to 4 hops: move toward the steering corner; if the step length reaches it, land exactly on it, subtract the distance, and (unless the corner is the end or an off-mesh entry, which are never passed) recompute the corners and continue with the rest. Each move is `MoveAlong(…, limit = index of the corner's polygon + 1)`, falling back to `MovePosition`; if both fail the agent is relocated. Landing on the end corner arrives (unless a replan is pending). 9. **Report.** `Velocity` = displacement / dt (3D). `RemainingDistance` = the length computed in step 7 minus the horizontal displacement, or +∞ when the end was not within the corners. 10. **Stuck.** Only at full speed (not while slowing). The clock accumulates dt; each `StuckWindow` seconds the horizontal displacement over the window must be at least `MaxSpeed · window · StuckProgress`. If not: recoveries += 1; more than `MaxStuckRecoveries` → state `Stuck` (velocity 0, until a new destination); otherwise the corridor is asked to replan from start. `StuckWindow` = 0 turns detection off. ### Off-mesh links At the entry the corridor is trimmed to the entry's polygon and the position set to the entry point; the state is `OffMeshLink`; start, end, exit polygon and duration (3D length ÷ `OffMeshSpeed`) are recorded. `Automatic`: each step adds dt to the elapsed time; position = entry + (exit − entry) · elapsed / duration (velocity = displacement / dt); when elapsed ≥ duration the crossing finishes (so it takes ⌈duration / dt⌉ steps). `Host`: nothing moves; the host calls `CompleteOffMeshLink` when its animation ends. Finishing puts the agent at the exit point and trims the corridor to the exit polygon (if it is no longer in the corridor the agent is relocated there), and the state returns to `Moving`. `SetDestination` during a crossing only records the target (a replan follows the crossing). ### Defaults (`FollowerOptions.Humanoid`; `Small` = speed 2.5, acceleration 6, slow radius 0.5, off-mesh speed 2.5) `MaxSpeed` 3.5, `MaxAcceleration` 8, `SlowRadius` 1, `ArrivalMinSpeed` 0.25, `ArrivalRadius` 0.01, `MaxCorners` 8, `ValidateLookAhead` 8, `OptimizeInterval` 4, `OptimizeRange` 10, `OptimizeBudget` 16, `TopologyInterval` 16, `TopologyBudget` 32, `ReplanBudget` 2048, `MaxReplanRounds` 16, `StuckWindow` 1, `StuckProgress` 0.1, `MaxStuckRecoveries` 2, `OffMeshMode` Automatic, `OffMeshSpeed` 3.5, `OffMeshTriggerRadius` 0.05, `SearchExtents` (2, 4, 2), `CorridorCapacity` 256, `RefineBudget` 128, `CornerClearance` 0. They are starting points chosen for a one-unit-per-metre world; none was tuned against a reference. ### What the tests establish (stated bounds) - A follower reached every reachable goal in 750 random pairs on scattered-obstacle fields and 480 on two-floor meshes with off-mesh links, and ended exactly on the end point (within `ArrivalRadius`). The distance it walked was at most 1.08 × the funnel length of the fresh crossing-node route, on both node models (polygon-node with `RefineBudget` 2000), and at most 1.2 × with budgets of 40 iterations per replan and shortcuts off. The excess comes from shortcuts and replans choosing other corridors; walking one corridor is exact. - The corridor after any number of moves equals a fresh path query from the same polygon and position on perfect mazes (where the polygon route is unique), polygons and corners. - Repair after tile removal, replacement and polygon edits reaches the target whenever a fresh query from the same polygon does. ### Limits - Funnel corners are computed in the horizontal plane. Where two floors overlap horizontally and are joined at a point, the route can hairpin through the joint; the three-layer test mesh with a floor a quarter unit over the ground is such a world and is used for corridor invariants only. Floors a body-height apart (bridges, balconies) work. - Budgeted replans that end unfinished restart from the closest polygon found; the budget doubles each round so they cannot stall, at the price of a longer search in a trap. ## State bytes Little-endian; doubles by IEEE bit pattern; the same state gives the same bytes on every platform. Written by `Write`/`ToBytes`, read by `Read` (validated: truncation, version, ranges, finite coordinates within ±2^21, flag bits and trailing bytes are errors, `CrowdStateException`; a follower is unchanged if its read fails). **Corridor** (`1 + 1 + 4 + 4 + 48 + 8 + 8·n` bytes): version u8 (= 1); flags u8 (1 complete, 2 needs replan, 4 replan from start); capacity i32; count *n* i32; position 3 × f64; target 3 × f64; target polygon u64; *n* × polygon u64. **Follower** (203 bytes + the corridor): version u8 (= 1); state u8; reached u8 (0/1); step count u64; replan rounds i32; stuck recoveries i32; stuck clock f64; stuck anchor 3 × f64; position 3 × f64; velocity 3 × f64; destination 3 × f64; off-mesh start 3 × f64; off-mesh end 3 × f64; off-mesh elapsed f64; off-mesh duration f64; off-mesh exit polygon u64; remaining distance f64 (≥ 0, +∞ allowed); then the corridor. Not state: the options (the host rebuilds the follower with the same options before `Read`; a corridor of another capacity is rejected), the corner buffer (recomputed at the start of every step), and a search being sliced through `CorridorReplan` (take snapshots between steps; unfinished sessions are dropped). Because a step's replan round is complete within the step and its counters are state, a snapshot taken at any step boundary resumes identically (the hash scenario `crowds/corridor-follow-edits` restores and reruns and writes both). A host that snapshots the mesh (`navmesh-snapshot.md`) and its followers together can roll both back and resimulate. ## Debug geometry `FollowerDebug.Collect(query, follower)` returns plain data: state, position, velocity, destination, the corridor polygon outlines, the corners (position, flags, polygon) and the current off-mesh crossing. # Crowds: `Crowd` and `Agent` Normative behaviour of `RecursiveAI.OnePath.Crowd`, `Agent`, `AgentOptions`, `CrowdOptions` (root namespace) and `CrowdDebug`. A crowd walks one `TiledNavMesh`; every agent owns a `PathFollower` (above) and is steered by ORCA (van den Berg, Guy, Lin and Manocha, *Reciprocal n-body collision avoidance*, 2011; `orca.md`) against the neighbouring agents and the boundary edges of the mesh around it. Arithmetic is IEEE `+ − × ÷ sqrt` on double. There is no clock, no random number and no sub-stepping: `Step(dt)` is one step of `dt`. ## Agents and slots Agents live in *slots* 0, 1, 2, …; `Add` takes the lowest free slot (so the slot order is a function of the sequence of `Add` and `Remove` calls), `Remove` frees it. `Agent.Id` counts agents ever added (0, 1, …) and is never reused. Everything below is evaluated in slot order. `Add` places the agent with `PathFollower.Place` (the nearest accepted polygon within the follower's search extents; an agent with no polygon near is refused with `ArgumentException`), standing still with no destination. The crowd's `Filter` (null = everything, cost 1) is the filter of every follower and wall query. `Agent.SetOptions(options)` replaces a live agent's settings: the options are validated as `Add` validates them, the follower's tuning is swapped in place (its position, velocity, destination and corridor are untouched; `CorridorCapacity` and `MaxCorners` of `Path` cannot change, `ArgumentException`), the slot's priority becomes `options.Priority`, and the slot's wall cache is marked invalid so the next step gathers walls with the new reach. Nothing else changes, so the next step is a function of the new settings and the old state. The snapshot already writes every slot's settings in full, so the change is saved with no format change; restoring a snapshot into a crowd that holds the same agent id in that slot with different settings keeps the `Agent` object and replaces its settings and follower. ## Step Three phases, each over the slots in fixed groups of 32 (worker *w* takes groups *w*, *w* + threads, …). In a phase an agent writes only its own slot and reads the state the phase began with, so the result is the same for any `MaxThreads` (and for a worker that happens to run late): 0. **Snapshot** (single thread): every agent's position and velocity (the velocity it ended the last step with); the neighbour grid. 1. **Plan** (per agent): an `Arrived` agent that was pushed farther than `2 · ArrivalRadius + 0.01` from its destination is sent back (`SetDestination`). Then `PathFollower.PlanCrowd`: the corridor upkeep of `Step` (steps 1 to 6 of the follower's step: validation, replans, shortcuts, off-mesh entry, arrival), and the wanted velocity in X/Z, `MaxSpeed` toward the steering corner slowed by the same arrival rule as step 7 (no acceleration limit). An intermediate corner is aimed at `CornerClearance` past the corner: from the corner, along (unit vector to the corner − unit vector from the corner to the next one) normalised, skipped when that is shorter than 10⁻⁶ (no bend) or the corner is an off-mesh entry. A corner is a wall vertex and wall avoidance keeps `WallClearance` from it, so an agent aimed exactly at it slowed toward it and stopped short. A crowd sets `CornerClearance = 2 · WallClearance + 0.01` (0.11); a lone follower leaves it 0. An agent in an off-mesh crossing runs the whole crossing step here (no avoidance while crossing). An agent that is not walking (idle, arrived, stuck, no path) wants 0. Then the wall lookup (below). 2. **Avoid** (per agent; `AvoidanceQuality.Off` and crossing agents skip it and keep the wanted velocity): neighbours, separation, side bias, constraints, solve. 3. **Move** (per agent): the new velocity is limited to a change of `MaxAcceleration · dt` from the start-of-step velocity; a walking agent then moves with `PathFollower.ApplyCrowd`, others with `Nudge`. `ApplyCrowd` moves by velocity · dt along the surface: `MoveAlong` within the corridor up to the steering corner's polygon, else `MovePosition` (a ray along the surface that slides along walls), so the agent is always on the mesh. It never walks past the end or an off-mesh entry (it lands on it), reports velocity = displacement / dt and the remaining distance, and arrives or counts stuck time as `Step` does. `Nudge` pushes a standing agent the same way (`MovePosition`) and reports the velocity that resulted. ## Neighbours Agent *i* considers the `MaxNeighbors` nearest agents *j* ≠ *i* with squared centre distance < `NeighborDistance²`, ordered by (squared distance, slot) — `Low` 4, `Medium` 8, `High` 12 neighbours; `NeighborDistance = max(SeparationDistance, 2 · Radius + MaxSpeed · TimeHorizon)`; `TimeHorizon` 1.5, 2, 2.5 s. The set does not depend on the structure that finds it (the implementation sorts agents by (grid cell, slot) with cells at least as wide as the largest `NeighborDistance` and scans the 3 × 3 cells around the agent; the grid is rebuilt every step and cell keys are 64-bit integers, never hashes). Two agents at exactly the same place get a relative position of (±1e-3, 0), plus for the one with the higher slot, so they separate. Constraint of *i* against neighbour *j*: `OrcaConstraints.ForAgent(pj − pi, vi, vj, ri + rj, TimeHorizon, dt, share)` with the start-of-step velocities and `share = priority_j / (priority_i + priority_j)` (the other's weight over both, so the two agents' shares add to 1; `Priority` is a weight above 0 and a higher priority gives way less), or `share = 1` when *j* is crossing an off-mesh link and cannot give way. ## Separation and side bias If `SeparationDistance` > 0: each neighbour *j* of *i* with centre distance *d* < `SeparationDistance` adds `SeparationWeight · MaxSpeed · t²` to the wanted velocity in the direction away from *j* (`t = 1 − d / SeparationDistance`; when *d* ≤ 1e-9 the direction is +X for *j* below *i* in slot order and −X otherwise); the sum is clamped to `MaxSpeed`. Separation acts on the agent's wanted velocity before avoidance, on standing agents too. The *side bias* (`SidePreference`, default 0.05) adds to the wanted velocity *p* the vector `(p.z, −p.x) · SidePreference`, a turn to one fixed side that breaks the symmetry of head-on meetings (exact avoidance resolves a perfectly head-on pair by stopping). If a wall constraint of the agent lies within four radii of it on that side (its normal points back against the bias direction by more than a half), the bias goes to the other side. ## Walls: the mesh boundary as obstacles Wall pieces come from `NavMeshQuery.FindWalls` (`navmesh-queries.md`): the edges with nothing walkable behind them, within a radius of the agent, from the polygons the circle search reaches. Each agent keeps a list of at most `MaxWallsPerAgent` pieces (the nearest to where it was looked up), looked up with radius `reach + WallRefreshDistance` where `reach = ObstacleHorizon · MaxSpeed + WallClearance` and `ObstacleHorizon = TimeHorizon / 2`. The lookup is repeated when the agent is farther than `WallRefreshDistance` from where it was done, when `TiledNavMesh.EditCount` changed since, or when the last lookup failed (the lookup is a function of the mesh and the position it was done at; that position is state, see below). In phase 2 the agent takes the pieces within `reach` of it that face it, nearest first (ties by polygon reference, edge, then the first end's X and Z), at most `MaxWallsAvoided`. A piece faces the agent when `OrcaConstraints.ForBoundary` gives a half-plane (the agent is on its walkable side, within 1e-6, and projects on it more than `WallClearance` + 1e-6 from both ends) and the piece is on the agent's floor (the height of the piece at its nearest point differs from the agent's by at most `1 + 1.5 · distance`, so the walls of a floor above or below, joined by a link, are left out). The half-planes are the **hard** constraints of the linear program (they come first); neighbour half-planes follow in neighbour order. The program is `OrcaLinearProgram.Solve` (`orca.md`). The agent's centre may touch the boundary: the mesh was built for an agent radius and is already shrunk by it, so a wall constraint keeps only `WallClearance` (0.05) from the line, and a path that grazes a corner (the taut string does) is not held back by the wall it passes. Because agents are moved *along the surface*, they stay on the mesh even where the constraints let a corner be clipped. **Blocked by a standing agent.** If the solved velocity is shorter than 0.3 of the wanted speed, the agent is more than eight radii from its destination and a neighbour is not walking, the program is also solved for the wanted velocity turned +90°, −90°, +45°, −45° (constants `(−z, x)`, `(z, −x)`, `√½·(x − z, x + z)`, `√½·(x + z, z − x)`, `√½ = 0.7071067811865476`) and the fastest result (greatest squared length, first on a tie) is taken. Near its goal an agent joins the queue instead. ## Off-mesh links in crowds `AgentOptions.ToFollowerOptions` sets `OffMeshTriggerRadius = max(follower default, 2 · Radius)`: a link end is attached to the mesh within a few radii, and agents queue at it instead of having to stand within a hair of the point. The crossing itself is the follower's. ## Presets and defaults `Agent.Humanoid`: radius 0.6, speed 3.5, acceleration 8, arrival radius 0.25, priority 1, quality `Medium`, wall clearance 0.05, separation off (distance 0, weight 0.3), side bias 0.05. `Agent.Small`: radius 0.3, speed 2.5, acceleration 6, arrival radius 0.3. `Agent.Large`: radius 1.5, speed 3, acceleration 5, arrival radius 0.6. The follower options of an agent are `FollowerOptions.Humanoid` (or `AgentOptions.Path`) with the agent's speed, acceleration and arrival radius, corridor capacity 64 and stuck detection off (a crowd stands in queues; `Path` can turn it on). `CrowdOptions`: 65536 agents, 1 thread, no filter, 16384 query nodes per worker, 48 walls kept, refresh 1.0, 8 avoided, constraints not recorded. None of these was tuned against a reference. ## What the tests establish (stated bounds) - 1000 seeded runs of 6 agents each on scattered-obstacle fields (passages at least 2 units wide), agents removed on arrival: every agent arrived within 3× the time to walk the longest route at full speed plus 10 s (worst measured ratio of time taken to straight-route time at full speed 3.7); no agent position was ever off the mesh (checked every step); the deepest agent–agent overlap was 0.18 (combined radius 0.6), under 0.05 in all but 8 runs. - Crossing streams (20, 60, 150 agents), crowds through a two-cell door (20, 40, 60) and 160 agents on two-floor meshes with off-mesh links (72 crossings): all arrive and stay on the mesh; overlap at most 0.034 (open scenes) and 0.30 for two-floor meshes (an agent leaving a link lands where it lands; crossing agents do not avoid). - 1, 2, 5 and 16 threads give byte-identical agent states after 150 steps of 300 agents. 60 steady-state steps of 120 agents allocate nothing beyond 4 KB. ## Limits - Standing agents are obstacles. In a passage less than about two body diameters wide an agent standing still (arrived) can block others, and two agents meeting head-on in such a passage can crawl; the tests that must arrive remove agents on arrival or keep goals at least 1.2 apart in passages at least 2 wide. - Avoidance is on the mesh plane; agents on stacked floors are separated only by the height tolerance of walls and by distance (an agent above another on a different floor is still a neighbour in X/Z). - `ApplyCrowd` follows the corridor, not the neighbours: an agent pushed far off its corridor is brought back by `MovePosition` and the follower's repair, not by a new search. ## State and snapshots (`Crowd.SaveState`, `RestoreState`, `SaveStateWithMesh`, `RestoreStateWithMesh`) A host that rolls back gets the same crowd, bit for bit, as one that never left: the step after a restore equals the step after the save, for any thread count, and saving again gives the same bytes. **What is state.** Per crowd: the slot count (slots are never given back), the agent count, the lowest-free-slot hint, the next agent id and the step count. Per active slot: the agent's id, its options, its priority (changeable at any time), the flags of the last step (walking, crossing) and wanted velocity (both only for debug views: every step sets them before use), the follower's own bytes (above; the corridor and a replan in progress are in them), and the wall cache. **The wall cache is state, not an optimisation.** A cache is refreshed when the agent has moved more than `WallRefreshDistance` from where it was filled or the mesh was edited, so the pieces an agent avoids depend on *where the cache was filled*. A restore that dropped it would refill at the current position and the next steps would differ (a test that does this fails within a few steps). The cache is written only while it is valid for the mesh as it is now (stamp equal to `Mesh.EditCount`): its centre and the pieces in cache order. A stale cache is written as "stale" and refilled by the next step, exactly as the run that was saved would have done. On restore a valid cache is stamped with the restored mesh's current edit count. **The mesh must therefore be restored to the saved state first**; `RestoreStateWithMesh` does it in that order. **Not state** (rebuilt by the step, never read before being written): the neighbour grid and its sort keys, the snapshot of positions and velocities every agent sees, the solved velocities, the workers' query objects and buffers, the recorded constraint lines (they read as empty until the next step). `CrowdOptions` are not saved; the crowd restored into is created with the same options (the filter is a delegate and cannot be saved). `MaxWallsPerAgent` is checked against the bytes; `MaxAgents` bounds the slot count. There is no clock, random number or other hidden state: a step reads only the above, the mesh and `dt`. **Byte format, version 1** (little-endian, doubles by bit pattern; a flag is 0 or 1, anything else is an error): | Field | Type | |---|---| | magic `4F 50 43 53` ("OPCS"), format version | u32, u16 = 1 | | kind: 1 crowd, 2 crowd with mesh | u16 | | `MaxWallsPerAgent` | i32 | | slot count, agent count, lowest free slot | i32 ×3 | | next id, step count | u64 ×2 (at most 2^63 − 1) | | per slot, in slot order: active flag | u8 | | — if active: id (below the next id, no two equal) | u64 | | priority (above 0), flags (bit 0 active must be set; bit 1 walking, bit 2 crossing), preferred velocity x and z | f64, u8, f64 ×2 | | agent options: radius, max speed, max acceleration, arrival radius, priority, quality (u8), wall clearance, separation distance, separation weight, side preference, path-settings flag, and if set every `FollowerOptions` field in declaration order (doubles as f64, ints as i32, off-mesh mode as u8, search extents as three f64) | see order | | wall cache valid flag; if 1: centre x, z (f64 ×2), piece count (≤ `MaxWallsPerAgent`), per piece: ax ay az bx by bz (f64 ×6), polygon ref (u64), edge (i32) | u8, … | | follower byte length, follower bytes (`PathFollower.Write`) | i32, bytes | Kind 2 is: header, i32 mesh length, the mesh snapshot (`navmesh-snapshot.md`, in full or referenced mode), i32 crowd length, a kind-1 crowd state. **Reading.** Every count is bounded by the options and the bytes left; coordinates are finite and within ±2^21; the options pass the same validation as `Crowd.Add`; each follower is read by `PathFollower.Read` (its corridor must fit the capacity the options give); slots below the lowest-free hint must be active; the active slots must number the agent count. The reader is structural: positions, corridors and cached walls are not checked against the mesh (a snapshot of your own memory; stale ones are repaired by the steps), but nothing indexes out of range. Failures throw `CrowdStateException` naming the field and byte offset. **A restore is all or nothing**: everything is parsed into new objects first and nothing changes if any part is rejected (`RestoreStateWithMesh` also checks the crowd part before touching the mesh, and the mesh restore is itself all or nothing; a bad mesh part throws `NavMeshFormatException`, a bad crowd part `CrowdStateException`). **Span forms, size and hash.** `Crowd.GetStateSize()` is the length of the bytes above; `TrySaveState(Span, out int)` writes exactly the bytes `SaveState()` returns (both run one code path, so they cannot drift) and reports `false`, writing nothing it promises, when the buffer is short; `RestoreState` takes a `ReadOnlySpan` (a slice of any buffer). `Crowd.StateHash()` is **FNV-1a, 64-bit** (offset basis `CBF29CE484222325`, prime `100000001B3`, per byte `h = (h xor byte) · prime` mod 2^64) over the kind-1 bytes, computed as they are produced without storing them. Equal hashes mean equal snapshots up to 64-bit collisions; the hash covers what the snapshot covers (not the mesh, not `CrowdOptions`), is the same on every platform and for any thread count, and a host can check it against `Fnv1a64(SaveState())`. **Allocation.** `GetStateSize`, `TrySaveState` and `StateHash` allocate nothing, except that the first `StateHash` grows an internal scratch buffer to the largest follower (it is reused afterwards). `RestoreState` validates the whole input first and then applies it, so a rejected restore changes nothing. It allocates nothing when every active slot of the bytes holds, in the crowd, the agent with the same id and byte-identical settings (the rollback case: same agents, different per-step state) and the slot arrays already suffice; its followers are read in place, corridor included. It allocates for each agent that is new to its slot (the `Agent`, its settings and its follower), when the slot arrays must grow, and for the exception when bytes are rejected. `StateHash`, `GetStateSize` and `TrySaveState` use crowd-owned scratch and must not run at the same time as each other or a step. `WorldCrowd` has no snapshot of its own: a host calls these on each crowd of `WorldCrowd.Crowds`, in class order. **Agent objects.** Agents that are in the snapshot and still in the crowd keep their `Agent` object and their follower object (read in place). Agents added since are deactivated. Agents in the snapshot but removed since come back as new `Agent` objects with the same id (`Crowd.GetAgent(id)`, `CopyAgents`, enumerating the crowd): a removed `Agent` is never revived. The next added agent gets the rewound id and the lowest free slot, as in the saved run. **Order for a host** that also holds other state: restore the mesh (or use the combined call), then the crowd, then anything built on them. Mesh edits made after the snapshot are undone by the mesh restore; agents' wall caches are valid again because the cache stamp is set from the restored mesh. ## Debug geometry `CrowdDebug.Collect(crowd)` returns, per agent in slot order, plain data: id, radius, the last wanted velocity, the follower's `FollowerDebugGeometry` (state, position, velocity, destination, corridor outlines, corners, off-mesh crossing), the wall pieces looked up, and (with `RecordConstraints`) the half-planes solved in the last step (walls first, then neighbours), each as a point and unit normal in velocity space. # ORCA solver Normative description of `RecursiveAI.OnePath.Crowds.Orca`. A port that follows this text reproduces the same bits. Source: van den Berg, Guy, Lin and Manocha, *Reciprocal n-body collision avoidance*, ISRR 2011 (sections 4, 5.1, 5.3, 5.4), plus the choices listed under "Where the paper is silent". All arithmetic is IEEE binary64 `+ - * /` and square root, evaluated as written (no fused multiply-add). Vectors are `Vector2D` from the math package: `Dot(a, b) = a.x*b.x + a.y*b.y`, `Length = sqrt(Dot(a, a))`, `v / s` divides each component, `v * s` multiplies each. Agent state is binary64: positions cross the library boundary as double with the usual limit (magnitude below 2^21); an application that keeps tile-local float32 state converts at its boundary. ## Half-planes A constraint is a half-plane `{ v : (v - P) . N >= 0 }` with a unit normal `N`. `SignedDistance(v) = (v - P) . N`; it is negative when `v` violates the constraint, and then its magnitude is the penetration depth. ## Building a constraint Everything is expressed relative to the agent being steered (it sits at the origin). ### The obstacle shape The agent's neighbour is a segment `a-b` (a single point when `a == b`: another agent at relative position `p` is the point obstacle `p` with the radius `r` = sum of both radii). The segment grown by `r` is the capsule `K`. The velocity obstacle for horizon `tau` is `S = { s*k : k in K, s >= 1/tau }`, which is convex (equation 11 of the paper for segments, equation 4 for agents). For a unit direction `n` the support function of `S` is finite exactly when `n.a <= -r` and `n.b <= -r` (the *domain*, an arc of directions), and then equals `h(n) = (max(n.a, n.b) + r) / tau`. The signed distance of a velocity `x` from `S` (positive outside, negative inside) is therefore d(x) = max over unit n in the domain of ( min(n.wa, n.wb) ) - r/tau, wa = x - a/tau, wb = x - b/tau and the maximizer `n*` is the outward normal of `S` at its boundary point closest to `x`, namely `c = x - d(x) n*`. The paper's `u` (equation 5) is `c - x = -d(x) n*`. The maximum is found among these candidates, in this order; a candidate replaces the current best only when its value is strictly greater, and candidates outside the domain (tolerance below) are skipped: 1. `wa / |wa|` (skipped when `wa = 0`); 2. `wb / |wb|` (only when `a != b`, skipped when `wb = 0`); 3. `+nn` and `-nn`, where `nn = (-e.y, e.x) / |e|`, `e = b - a` (only when `a != b` and `|e| > 0`); 4. the two *leg normals* of `a`, then (only when `a != b`) of `b`: for a point `p` with `rho2 = p.p`, `l = sqrt(rho2 - r*r)`, `c = -r*p`, `q = (-p.y*l, p.x*l)`: `n = ((c + q) / rho2)` and `n = ((c - q) / rho2)`, each component divided separately. These satisfy `n.p = -r`. Value of a candidate: `min(n.wa, n.wb)`. Domain test for a candidate `n`: `n.a + r <= tolA` and `n.b + r <= tolB`, where `tolA = 1e-9 * (|a| + r)` and `tolB = 1e-9 * (|b| + r)`. If no candidate survives, `n` is the unit vector toward the centre of the obstacle, `-(a + b)/|a + b|` (or `(1, 0)` if `a + b = 0`), with its value computed the same way. The result is `d = best - r/tau` and `n = bestN`. ### The agent construction in cases (what the code does for agents) For another agent (`a = b`, `|a| > r`) the closest boundary point is computed case by case on the truncated cone of section 4 of the paper (the cone from the origin around `a`, cut off at time `tau` by the disc of radius `r/tau` around `a/tau`), which is the same point the support-function form above finds (a test checks that on random inputs). With `x` the relative optimization velocity: 1. `w = x - a/tau` (each component divided); `dot1 = w.a`. 2. **Cutoff case** when `dot1 < 0` and `dot1*dot1 > r*r * (w.w)`: `x` projects onto the arc. `n = w / |w|` and `u = n * (r/tau - |w|)`. 3. **Leg case** otherwise. `leg = sqrt(a.a - r*r)`. When `cross(a, w) = a.x*w.y - a.y*w.x` is greater than 0 the left leg is used, else the right leg. The left leg's unit direction is `dir = ((a.x*leg - a.y*r), (a.x*r + a.y*leg)) / a.a` and the normal `n = (-dir.y, dir.x)`; the right leg's is `dir = ((a.x*leg + a.y*r), (a.y*leg - a.x*r)) / a.a` and `n = (dir.y, -dir.x)`. `along = x.dir` and `u = dir * along - x`. (`/ a.a` divides each component; the cutoff normal divides each component of `w` by `|w|`.) 4. `P = vA + responsibility * u`, `N = n`, component-wise `u * share` added to the origin. The tie `cross = 0` (relative velocity exactly along the axis) takes the right leg for both agents of a pair, so a symmetric pair turns the same way. The construction for an overlapping pair, and the one for segments, stay as described in the support-function form. The support-function text above remains the definition; the case form is its closed evaluation for agents. ### Overlap (collision) case When the origin is within `r` of the segment (`distance^2 <= r*r`, the squared distance from the origin to the segment computed with the clamped projection parameter) the velocity obstacle would be the whole plane. The paper assumes this does not happen; here the obstacle is instead the capsule `K` cut off at one time step `dt`: `S = { k/dt : k in K }`, which is bounded and has no domain restriction. The same formula applies with `tau` replaced by `dt` and every candidate admitted (step 4 is not used). The constraint then asks for a relative velocity that leaves the overlap within one step. ### Agents (equation 6) For agent A with neighbour B: `a = b = pB - pA`, `r = rA + rB`, `x = vA - vB` where `vA`, `vB` are the optimization velocities (the current velocities, as recommended in section 5.2), `tau` the agent time horizon. With `u = -d n`: P = vA + responsibility * u, N = n, responsibility = 1/2 (B takes the other half) ### Static obstacles (section 5.4) For a segment obstacle: `a`, `b` are its ends minus the agent's position, `r` is the agent's radius, `tau` is the obstacle horizon, and the optimization velocity is zero (`x = 0`). The agent takes the whole responsibility: `P = u`, `N = n` (the tangent line at the boundary point closest to the zero velocity). While the agent does not overlap the segment, the zero velocity is permitted, so the obstacle constraints alone always admit a velocity. Obstacles block from both sides. ## The linear program (section 5.1) Constraints are processed in list order (a fixed order instead of the paper's random order). Inputs: the list, the speed limit `R`, the preferred velocity `p`. Result: the velocity inside the disc `|v| <= R` and every half-plane that is closest to `p`. 1. Start: `v = p` if `|p| <= R`, else `(p / |p|) * R`. 2. For `i = 0, 1, ...`: if `SignedDistance_i(v) >= 0`, continue. Otherwise the optimum lies on line `i`: with `d = (-N.y, N.x)` and `P = P_i`, the points `P + t d` inside the disc are `t in [-pd - s, -pd + s]` with `pd = P.d`, `s = sqrt(pd*pd + R*R - P.P)`; if `pd*pd + R*R - P.P < 0` the program is infeasible at `i`. For `j < i`: `denom = d . N_j`, `num = (P - P_j) . N_j`; if `|denom| <= 1e-12` the line is parallel to `j` and the program is infeasible at `i` when `num < 0` (otherwise `j` is ignored); else `t = -num/denom` raises the lower end when `denom > 0` and lowers the upper end when `denom < 0`; the program is infeasible at `i` as soon as lower > upper. The new `v = P + d * t` with `t` the projection of `p` on the line, `t = d . (p - P)`, clamped to the interval. 3. If every constraint passes, the result is `v` and the program was feasible; otherwise the first failing index `i` is reported and the fallback runs. ## The fallback (section 5.3) Constraints before `hardCount` (obstacles, listed first) are hard. If the program failed at an index inside the hard prefix, the hard prefix alone is relaxed by the procedure below with no hard constraints and the other constraints are ignored (this cannot happen with constraints built by the functions above). Otherwise the procedure runs over all constraints with the first `hardCount` kept hard. The paper looks for the point `(v*, d*)` above every plane `d_k(v) = -(signed distance of v to line k)` with minimal `d`; the solution is the velocity maximizing the smallest signed distance `g_k(v) = (v - P_k).N_k` over the soft constraints. It is computed incrementally, in order, as a program in `v` only: 1. `v` = the point inside the disc and the hard half-planes that maximizes `g` of the first soft constraint (the 2D program below with objective `N` of that constraint); `level = g(v)` for that constraint. 2. For each later soft constraint `j`: if `g_j(v) >= level`, continue. Otherwise the new optimum has `g_j(v) = level`, and each earlier soft constraint `k` contributes the half-plane `g_k(v) >= g_j(v)`, i.e. `v . (N_k - N_j) >= P_k.N_k - P_j.N_j`. With `m = N_k - N_j` (skipped when `m.m <= 1e-24`), `len = sqrt(m.m)`, `mh = m / len`, `rhs = P_k.N_k - P_j.N_j`: the half-plane has unit normal `mh` and point `mh * (rhs / len)`. Maximize `g_j` (objective direction `N_j`) over the disc, the hard half-planes and these half-planes, in the order hard, then `k` ascending; if that program is feasible set `v` to its result and `level = g_j(v)`. 3. The result is `v`. The objective-direction program is the program of the previous section with the start `v = (N / |N|) * R` and, on each line, `t` at the upper end when `d . N > 0`, the lower end when `d . N < 0`, and the projection of the current `v` clamped to the interval when `d . N = 0`. ## Neighbours and constraint order The simulator builds the constraints of agent `i` from the state at the start of the step: 1. **Obstacles** within reach (`SegmentDistanceSquared <= (obstacleHorizon * maxSpeed + radius)^2`), in order of increasing squared distance from the agent to the segment, ties by obstacle index. These are the hard constraints. 2. **Agents** `j != i` with squared centre distance `< neighborDistance^2`, the `maxNeighbors` smallest in the order `(squared distance, index)`, listed in that order. Squared distance is `Dot(d, d)` of the position difference. The set and order do not depend on the data structure used to find them. (The implementation sorts agents by grid cell and index; the 3x3 block of cells around the agent, each cell at least `max(neighborDistance)` wide, contains every candidate.) ## The step `Step(dt)`: (1) for every agent compute `v_new` from the start-of-step state, (2) for every agent `velocity = v_new` and then `position = position + velocity * dt` (component-wise). Agents are independent in step 1, so any split by index, any number of workers, gives the same bits. There is no clock. Zero agents is a no-op. ## Where the paper is silent (choices) - **Overlap:** time-step cutoff capsule, above. (A black-box comparison with a reference implementation is pending.) - **Agents that touch from outside** (`|a|` within rounding of `r`): the cone is nearly a half-plane and the normal is fixed by the leg or cutoff case that the position of `x` selects; the two forms of this document agree to the rounding of `sqrt(a.a - r*r)`, not bit for bit. - **Order of constraints:** fixed (obstacles by distance, then agents by distance), not random. Equal distances break by index. - **Fallback with ties:** the program above returns one of possibly several equally good velocities in a fixed way. - **Exact head-on symmetry:** two agents meeting exactly head on with zero perturbation stop in front of each other (the permitted region shrinks symmetrically); real use perturbs the preferred velocity slightly, as the scenarios in the tests do. - **Obstacle shape:** two-sided segments; a one-sided or convex-polygon variant belongs to the callers that know the mesh. - **Parameters** (radius, speed, horizons, neighbour limits) are per agent and public; defaults belong to a higher tier. ## Checks - The linear programs agree with vertex-enumeration brute force on over 10^5 random constraint sets (optimal when feasible, minimum largest penetration when not). - The velocity-obstacle closest point and normal are checked against a sampled membership oracle for agents, segments and the overlap case. - Simulations of antipodal circles, crossing blocks, a gap in a wall and a two-way corridor reach their goals; the deepest overlap is bounded: sparse scenes 0.1 radius; the dense centre of an antipodal circle 0.5 radius at 100-300 agents (observed 0.31 at 100) and 0.75 radius at 1000 agents (observed 0.55). - `hash matrix`: the `orca/*` scenarios are equal on x64 JIT/AOT/scalar, WebAssembly, and arm64. ## One-sided boundary constraint (`ForBoundary`) For a walkable area whose boundary the agent's centre may touch (the area was already shrunk by the agent radius), `OrcaConstraints.ForBoundary(a, b, clearance, tau)` gives the half-plane for one boundary stretch `a`–`b` (relative to the agent; the walkable side is to the left of `a` → `b`, the winding of a counter-clockwise polygon). With `e = b − a`, `s = −(a · e) / (e · e)` (the projection of the agent on the line, as a fraction), `n = (−e.y, e.x) / |e|` and `d = −(a · n)` (the signed distance of the agent from the line, positive on the walkable side): - there is **no** constraint when `e = 0`, when `s ≤ m` or `s ≥ 1 − m` with `m = (clearance + 1e-6) / |e|` (the agent is beside the stretch: grazing a corner is not a collision), or when `d < −1e-6` (the stretch faces away: it is the far side of a thin wall); - otherwise the half-plane has normal `n` and point `−n · (d − clearance) / tau`, i.e. `v · n ≥ −(d − clearance) / tau`: an agent may approach the line at the speed that reaches `clearance` from it in `tau`, may always move along it or away from it, and is pushed away at `(clearance − d) / tau` when it is closer than `clearance`. This is the paper's section 5.4 with the obstacle taken one-sided and without the corner legs, which the surface the agent moves on handles. # Hash matrix Results must be bit-identical on every platform, runtime mode and thread count. The hash matrix checks that: a runner executes registered *scenarios* (deterministic functions of the library), hashes the bytes each one writes, and prints a manifest. Equal manifests across *columns* mean equal behaviour. The manifest format is the same as the one used by the family's math library, so one comparison script serves both. ## Manifest format Text, `\n` line ends, UTF-8: ``` one line per scenario, ordinal name order manifest SHA-256 of all the lines above, each with its '\n' ``` A scenario's hash is the SHA-256 of the bytes it wrote through `ScenarioContext`: integers little-endian, floats and doubles by bit pattern (so NaN payloads and signed zeros count). SHA-256 is implemented inside the runner, so it behaves the same on every runtime including browser-wasm. ## Columns `tools/hash-matrix.sh run [column ...]` builds and runs each column and writes `/-.manifest`; the tag is `$HASHMATRIX_TAG` or `-`. With no columns it runs every column that applies to the machine. | Column | What differs | |---|---| | `jit` | framework-dependent, default JIT, 1 thread | | `jit-threads` | same, `--threads 0` (all cores) | | `jit-scalar` | `DOTNET_EnableHWIntrinsic=0`: no SIMD instructions | | `jit-noavx2` | `DOTNET_EnableAVX2=0` (x64 only; skipped elsewhere) | | `jit-fulljit` | `DOTNET_TieredCompilation=0`: no tier-0 code | | `aot` | NativeAOT for the current OS and architecture | | `wasm` | browser-wasm, interpreted, run headless under Node | | `wasm-aot` | browser-wasm, AOT-compiled (`RunAOTCompilation`), under Node | The `osx-arm64` and other-OS columns are the same script run on that machine; copy the manifests together and compare. `tools/hash-matrix.sh compare ` exits 0 when all files are identical and otherwise prints the differing lines; `all [column ...]` runs, then compares. Paths may be relative to where you call the script. Requirements: the .NET 10 SDK (`ONEPATH_DOTNET_ROOT` selects a user-local one); the `wasm-tools` workload for the wasm columns (installed automatically when missing); Node (from `PATH`, otherwise the copy the workload ships); a C toolchain for NativeAOT. `--threads N` (0 = all cores, default 1) reaches every scenario as `ctx.Threads`. A scenario may use it to split work, but must split by index and combine in index order: the bytes must not depend on it. ## Adding a scenario Create one file under `tools/HashMatrix/Scenarios//` with a module initializer; no shared file changes. Module initializers need no reflection, so this works under NativeAOT, trimming and wasm. ```csharp using System.Runtime.CompilerServices; namespace RecursiveAI.OnePath.HashMatrix; internal static class AStarScenarios { [ModuleInitializer] internal static void Init() => Scenarios.Register("grids/astar/octile-random", ctx => { // build inputs from a fixed seed, run the part, write every result: ctx.Write(cost); // int, long, float, double, or ctx.Write(ReadOnlySpan) }); } ``` Names are lower-case, slash-separated and unique. Seed inputs from the library's own generator, never from time or `System.Random`. Write results, not timings. Write both sides of a SIMD/scalar pair so a mismatch shows. `--filter PREFIX` runs a subset and `--list` prints names. A changed manifest line is either an intended change (say so in the commit) or a bug. ## Cost With the placeholder scenarios a full local run of every column takes about a minute. Once real scenarios exist, the full matrix is a heavy job: run it on a dedicated build machine. A focused `--filter` run of a single column is light. ## Placeholder scenarios `meta/bits` (bit patterns), `meta/float-chunks` (an IEEE-exact float computation split over 64 chunks, run on `ctx.Threads` workers) and `meta/simd-vs-scalar` (Vector128 against its scalar twin). They stay as runner self-checks. # Benchmarking: method, row format and how to run The bar compares our code with a reference on the same machine, in the same session, on the same inputs and settings. This page is normative: a later port of the bar runner must produce comparable rows from it. ## Method - **Warm, in-process, best of N.** Every driver loads its input, does untimed warm-up work, then times the measured work repeatedly inside one process and reports the best (minimum) time, plus the median. The bar runner repeats the whole process `--reps` times and keeps the best per row. Times are monotonic-clock wall times of the measured section only (no process start, no file loading), except where a metric says otherwise. Our query rows warm up for about half a second per phase (path, raycast, nearest) before timing: a short row otherwise runs mostly in the .NET runtime's first-tier code, which a native reference has no counterpart of. The wall clock decides only how long the warm-up lasts, never a result. - **Same inputs and settings.** Navmesh builds read the same OBJ with the same settings (below); query sets are the same point file; crowds and grid scenarios are the same parameters. Settings for the builders default to a humanoid: cell 0.3, cell height 0.2, agent height 2.0, radius 0.6, max climb 0.9, max slope 45 degrees, min region 8, merge region 20, max edge length 12, max edge error 1.3, 6 vertices per polygon, detail sample distance 6, detail max error 1, 64-cell tiles for tiled builds. The maximum edge length is a length in world units (12 units, 40 cells), not 12 cells, on every side. Our solo 3D builds use one tile that covers the level's bounds plus one cell, like the references' grid over the input bounds. - **Memory** is the peak resident set of the whole process from `/usr/bin/time -v` (Linux) or `/usr/bin/time -l` (macOS), reported as `peak_rss` in MB. The managed runtime's own overhead is therefore part of a .NET row. - **Machines.** x64 rows run on the Linux benchmark host and arm64 rows on a Mac, one heavy job at a time, with the host's benchmark lock held inside the job (`tools/bench/locked-run.sh`). Each run records machine, CPU, core count, 1-minute load before, between rows and after, SDK, runtime and reference versions in `env.json`. A run that overlapped another heavy job is not publishable. - **The pass rule** (per row): time-like rows pass at ours ≤ 1.15 × reference; throughput rows at reference ≤ 1.15 × ours; grid rows against the fastest reference at 1.0. Navmesh quality: walkable coverage within 1% of Recast's on the same settings, with path lengths over the same query set reported. Tolerances live in the suite file, per row. ## Row format Drivers print one measurement per line on stdout: ``` ROW,,,,,, ``` `part` is `build`, `query`, `crowd`, `grid` or `tilecache`; `side` names the implementation and version (`recast-1.6.0`, `dotrecast-2026.3.1`, `rvo2-2.0.2`, `rvo2cs-2.0.1`, `warthog-0.6.2`, `warthog-jps-0.6.2`, `onepath-`); `unit` is `ms`, `count`, `MB`, `world_units`, `world_units2`, `1/s`, `agent-steps/ms`, `cost` or `text`. The bar runner replaces `scenario` by the suite row id and writes everything, ours and reference, to `rows.csv` in long format: ``` run_id,suite,row_id,part,scenario,side,metric,value,unit ``` Best-of-N across process repetitions: `ms` metrics keep the minimum; throughput (`…/s`, `…/ms`) and `MB` the maximum; the rest the last run's value. ### Metrics by part | part | metrics | |---|---| | build | `time_best`, `time_median` (ms, whole build of all tiles from triangles to navmesh data), `polys`, `verts`, `coverage_area` (sum of polygon areas projected on the ground plane, world units²), `tiles`, `input_tris`, `peak_rss`, `settings`; our tile-map rows add `exact_area` and `delta_vs_exact_pct` (the 2D gate, below) | | query | `points_snapped`, `pairs`; `path_time_best`, `path_per_sec`, `path_mean_length` (string-pulled path, world units), `path_found`, `path_partial`; `raycast_time_best`, `raycast_per_sec`, `raycast_hits`; `nearest_time_best`, `nearest_per_sec` | | tilecache | `time_best`, `time_median` (ms, one whole obstacle cycle), `cache_build_time` (ms, the level built with its tile cache), `obstacles`, `polys_initial`, `polys` (all obstacles in force), `polys_restored`, `coverage_area_initial`, `coverage_area`, `coverage_area_restored`, `tiles`, `layers`, `input_tris`, `peak_rss`, `settings` | | crowd | `agents`, `steps`, `time` (ms for the timed steps), `agents_per_ms` (agent-steps per ms), `moving_at_end` or `position_checksum`, `threads` where the library has them | | grid | per scenario file: `instances`, `reference_exit_code` (only when non-zero: Warthog's `--checkopt` exits with its count of cost mismatches after printing every instance; the rows are kept), `search_time` (sum of per-instance search time, ms), `wall_time` (whole process, ms), `expanded`, `generated`, `cost_sum`, `cost_mismatch_vs_scenario` (instances whose cost differs from the scenario file's optimal cost) | The query set pairs consecutive points of the level's `.points` file (point 0 with 1, 2 with 3, …); each point is snapped to the nearest polygon within a (2, 4, 2) box and pairs with an unsnapped end are dropped. Path queries are `findPath` followed by `findStraightPath`; an incomplete path (goal unreachable) is counted in `path_partial` and measured to the closest reachable point. Raycasts go from the first to the second point of each pair. On the 3D levels about two thirds of the pairs of `overlapping` (and a few percent of `city`, `ramps`, `stairs`) have an unreachable goal, so the mean path length mixes whole routes with routes to the closest reachable point; `tools/bench/quality-3d.sh` therefore reports `path_found` and `path_partial` of both sides beside the means, with the search in both node models (the reference's midpoint-crossing model is `Crossing`). For per-pair comparison, `ONEPATH_DUMP_PATHS=` (ours) and `HARNESS_DUMP_PATHS=` (the Recast driver) write one line per pair: index, status, corridor polygons, string-pulled length, both points. Our side of a crowd row is run untimed in the same process, repeatedly (with a pause after each so the JIT finishes promoting hot methods) until two runs in a row agree within 3%, before the timed run: a native reference is optimized from its first instruction, and a few dozen steps of a small crowd never reach the JIT's promotion thresholds. The row argument `--prewarm 0` turns it off. The crowd scenarios: **navmesh crowd** — N agents start at the query points and walk to other query points (humanoid parameters: radius 0.6, height 2.0, max speed 3.5, max acceleration 8, separation weight 2, all steering flags on), fixed step 0.05 s, 50 warm-up steps then the timed steps. **Circle** — N agents evenly on a circle (radius 200·√(N/250) units) walk to the antipodal point, step 0.25 s, no obstacles, RVO2's example parameters (neighbour distance 15, 10 neighbours, time horizons 10 and 5, radius 2, max speed 2). ## Suites and the bar runner `bench/Bar` runs a **suite** (`bench/Bar/suites/*.tsv`): one tab-separated line per row, `id part driver metric better tolerance args`. `driver` is `recast`, `rvo2`, `warthog` (A\*, Dijkstra), `jps` (JPS, JPS+ and variants), `dotrecast` or `rvo2cs`. `args` is the driver's command line, with `{home}`, `{maps}`, `{geom}` (levels, size 256) and `{geomsmall}` (size 96) expanded, and `env:NAME=VALUE` tokens setting an environment variable for that run. Our side is whatever `OurScenarios.Run` returns for the row id; until a part registers its scenarios the report shows "no ours yet" beside the reference's numbers. ``` tools/bench/run-bar.sh [--out dir] [--reps N] [--only substring] [--machine name] [--light] [--in-process] ``` writes `rows.csv`, `env.json` and `report.md` (per row: ours, reference, ratio, memory, verdict; then every reference metric; for tile-map rows also the 2D gate table). Suites: `smoke` (tiny; proves every driver on a machine; no timing claims), `references` (the full reference rows), `quality3d` and `shapes` (coverage and path-length rows, no timing). Our side of a row runs in a fresh child process under `/usr/bin/time`, exactly like a reference driver, so its `peak_rss` is that row's own peak and its timing starts from a cold process (the row's own `--reps` give the warm best). `--in-process` runs it inside the runner instead (debugging only; memory is then the runner's peak so far). **Light check.** `--light` proves that every row of a suite runs and prints every metric: levels of size 96 instead of 256, one repetition of every row, 20 crowd steps and at most 200 agents. Label such a run `--machine light-check`; its numbers are never published. ## References Built unattended from their public releases by `tools/refs/refs.sh` into `$ONEPATH_HOME/refs/-/` (default `~/onepath`), pinned by URL and SHA-256 in `tools/refs/manifest.tsv`. They are benchmark-only black boxes: nothing of theirs is stored in this repository, and their sources are never read; each is driven through its documented API or command line. | reference | version | driven through | notes | |---|---|---|---| | Recast, Detour, DetourCrowd | 1.6.0 | `bench/Harness/Native/recast_harness.cpp`, written from the public API reference | static libraries, CMake Release (`-O3`), no `-march` flags | | RVO2 | 2.0.2 | `bench/Harness/Native/rvo_harness.cpp`, from the library documentation | parallel with OpenMP where the compiler has it; rows set `OMP_NUM_THREADS=1` for the single-thread comparison and report `threads` | | DetourTileCache | 1.6.0 (same release) | the `tilecache` command of `recast_harness.cpp`, from the class reference at recastnav.com (`dtTileCache`, `dtTileCacheParams`, `dtTileCacheLayerHeader`, `dtTileCacheCompressor`, `dtTileCacheMeshProcess`, `DetourTileCacheBuilder.h` and `Recast.h` function pages) | layers are stored uncompressed (a pass-through compressor), so the rows measure the rebuild and not LZ compression | | DotRecast | 2026.3.1 (NuGet) | `bench/Harness/RefHarness` (C#), compiled against the package | same settings as Recast; per-polygon areas set after the build as in the native driver; tiled builds hand the library the bounds of the whole input and the tile's grid position (it offsets the box itself) | | DotRecast tile cache | 2026.3.1 (NuGet `DotRecast.Detour.TileCache`) | `bench/Harness/RefHarness/DotRecastTileCache.cs` | its own layer builder over the whole tile grid on one thread, the same pass-through storage and the same scenario as the native driver | | RVO2-CS | 2.0.1 | `bench/Harness/RefHarness`, library sources compiled as shipped by `bench/Harness/Rvo2CsLib` | not on NuGet; its own projects target an old .NET Framework. It spreads each step over all cores by default (about 5 cores busy at 5000 agents on an 8-core machine: 2.9 s of CPU in 0.57 s of wall time), so its rows run our side with `--threads auto` (every core); the single-thread comparison is the RVO2 C++ row | | Warthog (core, JPS) | 0.6.2 | the documented `warthog` and `warthog-jps` command lines on Moving AI scenario files; on macOS built with a private LLVM 22.1.5 toolchain (below) | `search_time` sums the per-instance times the program prints; JPS+ preprocessing is not in it, so `wall_time` is reported too; maps with swamp or water (the wc3 set) are given as a converted copy through the documented `--map` option (swamp as free ground, water as blocked, the reading of the published optimal costs), since the programs otherwise stop at the first instance crossing one | **Warthog on macOS.** Warthog's logger calls `std::chrono::current_zone`, which Apple's C++ library does not have. `refs.sh` therefore builds a private toolchain under `$ONEPATH_HOME/tools` on macOS and compiles Warthog, unmodified, with it: the pinned LLVM 22.1.5 macOS release supplies clang, and the same release's runtimes source is built into a private libc++ with its time-zone database enabled (`-DLIBCXX_ENABLE_TIME_ZONE_DATABASE=ON`), which Warthog links through an rpath. One line of libc++'s own source needs a change, applied and checked by the script: its time-zone code names the zoneinfo folder for Linux only, and macOS keeps the same database at the same path (`/usr/share/zoneinfo`). The official LLVM macOS binaries ship libc++ with the database switched off, and the 21.x clang does not parse the macOS 27 SDK's ``, which is why 22.1.5 is pinned. No Homebrew or system change; the toolchain adds about 1.5 GB of downloads and a few minutes of build to a first macOS setup. Warthog's search itself does not use time zones; only its log timestamps do. Known gaps: DotRecast's and Recast's `raycast_hits` differ on the same input (the meshes differ slightly, and ray semantics at the end of a path are not documented); raycast rows compare ours with Recast's. ## Inputs - **Levels:** `tools/geom/gen_levels.py` writes terraces, ramps, stairs, overlapping floors, slivers, a city and a tile map (OBJ plus a point file) from a seed; `tools/bench/prepare.sh` generates the size-96 and size-256 sets under `$ONEPATH_HOME/geom` with a `SHA256SUMS` file. Nothing third-party. - **Maps:** `tools/maps/maps.sh` fetches the Moving AI Lab 2D and voxel sets at pinned SHA-256 into `$ONEPATH_HOME/maps//` (about 665 MB unpacked). Never committed; see `THIRD-PARTY-NOTICES.md`. ## The 2D builder row `tilemap` is a square of size/2 by size/2 tiles of 2 world units (128 x 128 at size 256), each tile floor with probability 0.6 from a 64-bit linear congruential generator (`Lcg(seed * 1000003 + 77)`, row-major, the generator's own `next() < 0.6`), extruded to two flat up-facing triangles per floor tile at height 0. The bounds are always the whole square. The reference builds it from the triangles (`--cell 0.25 --agent-radius 0.5`, tiled rows with `--tile-size 128` cells = 32 units, a solo row without); ours is `NavMesh.Build2D` from the tiles, which `bench/Shared/TileMapWorld.cs` recovers from the OBJ (each triangle belongs to the tile its centroid is in; `OnePath.Micro --check-tilemap` checks this against its own generator) and times from tiles to mesh (best of `--reps` builds in one process). Ours builds at the nominal agent radius, after untimed builds until three in a row are within 5% of each other (at most 40): the first builds run the runtime's unoptimized code, which the native reference never pays and the warm method excludes (the first build of a process takes about 300 ms against 75 ms warm). **The gate.** The 2D builder is judged against the exact eroded area of the same tile map, not against Recast: the area of the floor points at least the agent radius away from every wall (solid tiles and everything outside the map), with round joins at convex corners. `TileMapWorld.ExactErodedArea` computes it in closed form without any builder. For a radius up to half a tile (2 units), wall tiles two or more tiles away are farther than the radius from every point of a floor tile, so each floor tile only sees its eight neighbours; the lost area inside a tile of side T is a strip of width r along each side that borders a wall plus a quarter disc of radius r at each corner whose two adjacent sides border floor and whose diagonal neighbour is a wall, and these overlap only in r x r corner squares where two strips meet: lost = 2 r S - r^2 C + (pi r^2 / 4) D for S wall sides, C wall-side pairs meeting at a corner and D discs. The only inexact step is the double rounding of pi, so the result is within 1e-9 relative of the true area; it agrees with the point-sampling in `tools/geom/gen_shapes.py` (`exact.tsv`, sampled at 1/200 unit) to 0.03% on the shape rows. Our rows print `exact_area` and `delta_vs_exact_pct`, and the report's "2D gate" table lists, per row, the exact area, ours, ours against exact (pass within 1%), then the reference's coverage, its delta against exact and ours against it, as information. Recast's coverage equals the exact area at radius + one cell (its ledge filter and minimum region area), so it falls below the exact area by construction: on the 256 tile map at radius 0.5 and cell 0.25, exact 23819, ours 23761 (-0.25%), Recast 16407 (-31%). Quality rows to read beside the times: `coverage_area` (sum of polygon areas on the ground plane), `polys`, `verts`, and the query row `query/tilemap-tiled/recast` (`path_mean_length` over the level's own points; each side snaps the points itself, so the pairs it answers differ slightly). ## The run-time rebuild rows `tilecache` rows compare temporary-obstacle rebuilds on a tiled level (`--mode tiled --tile-size 64`, cell 0.3): ours `TileCache` (`Mesh3DPipeline.BuildSoupCached`, `AddObstacle`, `UpdateAll`) against DetourTileCache and DotRecast's tile cache. Obstacle k of `--obstacles` (16) is a cylinder of radius 2 and height 3 whose base is half a unit under the k-th of every (points / obstacles) query points. One timed cycle adds every obstacle in turn, each followed by rebuilding the tiles it touches until the cache is up to date, then removes them the same way; `time_best` is the best cycle of `--reps`, on one cache built before the cycles (`cache_build_time` is the best of `--reps` builds of the level with its cache, layers stored uncompressed). Quality rows: polygon count and walkable area before, with all obstacles in force and after they are removed (`polys_restored` and `coverage_area_restored` must equal the initial values). Warm-up: our 3D build and tile-cache rows run untimed builds (obstacle cycles for `tilecache`) first, until three in a row are within 5% (at most 40, or 30 s; the clock bounds only the warm-up). A `tilecache` cycle takes a millisecond or two, so three similar cycles say nothing about optimized code: the runtime promotes a method only after a quiet 100 ms and enough calls (twice with profile-guided optimization), and five unoptimized cycles are over in 15 ms. The `tilecache` warm-up therefore also runs for at least two seconds. Measured on the same machine and code, a cycle took 2.65 ms with the short warm-up and 1.2 ms with the two-second one; the earlier method measured mostly unoptimized code. Setting `ONEPATH_PROFILE=1` adds per-stage rows to our side: `stage_*_ms` is the time spent in each replaceable stage of `Mesh3DPipeline` (the public hooks, wrapped with a clock; summed over tiles, best of several runs), `stage_other_ms` the rest (triangle binning, voxelizing, tile data, stitching) and `stage_voxelize_standalone_ms` the voxelize step timed alone. Our `tilecache` rows use the partition and detail work DetourTileCache does on a rebuild (layer partition, no detail sampling) by default; `ONEPATH_TC_VARIANT=watershed` uses watershed with detail sampling instead. The settings row says which one ran. ## Running the full reference rows On the Linux benchmark host, once per checkout: `tools/refs/refs.sh all` and `tools/bench/prepare.sh`. Then, with the checkout synced there: ``` tools/bench/locked-run.sh "bar references" 90 tools/bench/run-bar.sh references --reps 3 --out ~/onepath/bar/$(date +%Y%m%d) --machine ``` The lock is taken inside the job and removed when it ends; the command fails at once if the lock is held. On an Apple-silicon Mac the same commands apply, including the Warthog rows. # onepath documentation The documents here are normative: where a paper leaves a choice open (tie-breaks, open-list order, rounding, byte layouts, budget units, preset values) the choice is written down so that a port to another language can reproduce the same bits from the text alone. API reference is in the XML documentation that ships with the package; the [README](https://recursiveai.org/onepath/docs/overview/) has the quickstart and the speed table. The library has three tiers, each built on the one below: 1. **Happy path.** `Grid.FromText(...)` or `Grid.From(...)` then `.FindPath(a, b)`, `HexGrid`, `VoxelGrid`, `Graph`, `PointGraph`, `NavMesh.Build(...)`, `NavMesh.Build2D(...)`, `NavMesh.BuildGltf(...)`, `mesh.FindPath(a, b)`, `mesh.CreateCrowd()`, `crowd.Add(...)`, `agent.SetDestination(...)`, `crowd.Step(dt)`. Everything is an options object with presets (`GridOptions`, `NavSettings.Humanoid`, `Agent.Humanoid`) or a plain delegate. Namespace `RecursiveAI.OnePath`. You never write a struct. 2. **Middle.** Each build stage as a visible, replaceable static method; one `NavMeshQuery` with filters, area costs and sync, span and sliced forms; grid and graph searches with struct-generic cost and heuristic hooks; corridors and path followers; tile cache; tile streaming. Namespaces `.Grids`, `.Graphs`, `.Mesh.*`, `.Crowds.Corridors`. 3. **Raw.** Voxel spans, regions, contours, polygons, tile data, the open and closed sets, the search loop, the ORCA solver (`.Mesh.Build3D`, `.Mesh.Build2D`, `.Mesh.Format`, `.Grids` engines, `.Crowds.Orca`). ## Where to start | You want to | Read | |---|---| | Move from Recast, Detour, DotRecast or Unity's navigation | [migrating.md](https://recursiveai.org/onepath/docs/migrating/) | | Know what a path result contains and how statuses map across grids, graphs and meshes | [path-results.md](https://recursiveai.org/onepath/docs/path-results/) | | Search a tile map, hex map or voxel world | [grids.md](https://recursiveai.org/onepath/docs/grids/) | | Route over a weighted or point graph, with landmarks | [graphs.md](https://recursiveai.org/onepath/docs/graphs/) | | Build a navmesh from a tile map or outlines | [build2d-tiles.md](https://recursiveai.org/onepath/docs/build2d-tiles/) | | Build a navmesh from level geometry | [build3d-polys.md](https://recursiveai.org/onepath/docs/build3d-polys/) | | Query a mesh (path, raycast, nearest, random point) | [navmesh-queries.md](https://recursiveai.org/onepath/docs/navmesh-queries/) | | Run a crowd | [crowds.md](https://recursiveai.org/onepath/docs/crowds/), then [orca.md](https://recursiveai.org/onepath/docs/orca/) for the solver | | Run inside a lockstep or rollback host | [Host hooks](#host-hooks-for-deterministic-hosts) below | ## By area ### Grids and graphs (tier 1 and 2) | Document | Covers | |---|---| | [grids.md](https://recursiveai.org/onepath/docs/grids/) | Square (4- and 8-connected), hex and 3D voxel grids; A\*, JPS, JPS+, Theta\*, Lazy Theta\*, HPA\*; LPA\* and D\* Lite replanners; flow fields; area costs; agent clearance; tie-breaking and open-list order; incremental tile updates; cost and heuristic hooks | | [graphs.md](https://recursiveai.org/onepath/docs/graphs/) | Weighted and point graphs, A\*, bidirectional search, ALT landmarks and their serialization | | [grids-state.md](https://recursiveai.org/onepath/docs/grids-state/) | Byte-exact save and restore of grid, graph, replanner, flow-field and sliced-search state | ### Navigation mesh: format and queries (tiers 2 and 3) | Document | Covers | |---|---| | [navmesh-format.md](https://recursiveai.org/onepath/docs/navmesh-format/) | The tiled mesh (format version 2, frozen): tile layout, polygons, detail heights, areas and flags, off-mesh links, several agent sizes, streaming, serialization, build fingerprint | | [navmesh-queries.md](https://recursiveai.org/onepath/docs/navmesh-queries/) | `NavMeshQuery`: path (polygon and crossing node models), string pulling, raycast, nearest polygon and point, random point, distance to wall, shape queries, sliced queries | | [navmesh-snapshot.md](https://recursiveai.org/onepath/docs/navmesh-snapshot/) | Byte-exact snapshot and restore of a dynamic mesh (rebuilt tiles, obstacles) | ### Navigation mesh: builders | Document | Covers | |---|---| | [build2d-geometry.md](https://recursiveai.org/onepath/docs/build2d-geometry/) | The exact 2D builder's geometry: snap grid, regions, boolean operations, offsets and shrink, constrained Delaunay, convex merge, exact predicates | | [build2d-tiles.md](https://recursiveai.org/onepath/docs/build2d-tiles/) | Tile slicing, plane-to-mesh mapping and rounding, `NavMesh.Build2D` | | [build3d-spans.md](https://recursiveai.org/onepath/docs/build3d-spans/) | 3D builder, front half: voxelization of triangle soup and voxel chunks, span filters, compaction, erosion | | [build3d-regions.md](https://recursiveai.org/onepath/docs/build3d-regions/) | Regions (watershed, monotone, layers) and contours | | [build3d-polys.md](https://recursiveai.org/onepath/docs/build3d-polys/) | Polygons, per-polygon height detail, tiles, `NavMesh.Build` | | [build3d-gltf.md](https://recursiveai.org/onepath/docs/build3d-gltf/) | The glTF 2.0 reader and `NavMesh.BuildGltf` | | [build3d-runtime.md](https://recursiveai.org/onepath/docs/build3d-runtime/) | Run-time tile cache: tile rebuild, temporary obstacles, area volumes | ### Crowds | Document | Covers | |---|---| | [crowds.md](https://recursiveai.org/onepath/docs/crowds/) | Path corridors, path following, off-mesh links, `Crowd` and `Agent`, separation, priorities, avoidance quality, crowd snapshot bytes | | [orca.md](https://recursiveai.org/onepath/docs/orca/) | The ORCA solver: constraints, linear program, neighbour order, where the paper is silent | ## Conventions across the tiers - **Options are sealed records, immutable once constructed** (`GridOptions`, `VoxelOptions`, `PathOptions`, `NavSettings`, `CrowdOptions`, `AgentOptions`, `GraphOptions`, `PointGraphOptions`, `LandmarkOptions`, `FollowerOptions`): properties are `init`-only, so a shared preset cannot be changed by one caller under another, and `with` derives a changed copy (`GridOptions.FourConnected with { Algorithm = GridAlgorithm.Jps }`). There is no `Clone()`. Two options with the same values are equal (delegates compare by reference). A list or array an option holds (`NavSettings.OffMeshLinks`, `PointGraphOptions.Links`, `Mesh3DPipeline.Obstacles`) is copied when it is set, so editing the caller's array afterwards changes nothing. `QueryFilter` is immutable too (`WithAreaCost` copies). Settings classes of the middle and raw tiers (stage settings of the 3D builder, for example) stay mutable builders. - **One validation point.** A value is checked where it is set: `new GridOptions { AgentSize = 0 }` or `Agent.Humanoid with { MaxSpeed = -5 }` throws `ArgumentOutOfRangeException` right there, with `ParamName` the property and a message that states the allowed range. Rules that tie several values together (a tile size against the cell size, a filter against a mesh) are checked when the options are used and name the properties involved. `Validate()` on the options types repeats the field checks for values built another way. - **Cancellation:** every tier-1 `FindPath` takes an optional `CancellationToken` and throws `OperationCanceledException`; a token that is never cancelled changes no result. - **`FindPathAsync` means one thing everywhere** (`Grid`, `NavMesh`, `NavMeshQuery`, `NavWorld`): the signature is `(start, goal, options, cancellationToken)` and the search runs cooperatively on the caller's synchronization context in slices of `GridOptions.IterationsPerSlice` / `PathOptions.IterationsPerSlice` iterations, yielding between slices. The slices count iterations, never time, and any slicing returns what `FindPath` returns. Nothing moves to the thread pool, so `Blocked`, `StepCost` and filter delegates always run on the thread that awaits. To search on another thread, call `FindPath` inside `Task.Run` (and do not edit the world meanwhile). Hex and voxel grids have no async form for that reason. `Grid.FindPathAsync` slices A\*, JPS and JPS+ (the algorithms `GridSearch` can resume) and refuses the others and `RecordExpansions` with an `ArgumentException`. - **Errors.** Misuse (a bad argument, a call in the wrong order) throws `ArgumentException` (`ArgumentOutOfRangeException`, `ArgumentNullException`) or `InvalidOperationException`. Bad *data* (bytes, text, files, geometry the builder cannot use) throws a subclass of `OnePathFormatException` (itself a `FormatException`), so a host that loads untrusted saves catches one type: `NavMeshFormatException` (with `NavMeshVersionException` and `NavMeshFingerprintException`) for meshes, tiles and snapshots, `GridStateException` and `GraphStateException` for saved grid, search, replanner, flow-field and graph state and landmark tables, `CrowdStateException` for crowd, corridor and follower state, `GridFormatException` for map and scenario text, `GltfException` for glTF input and `GeometryException` for a 2D builder invariant failure. Each message names its subject, the offset or field, and what was wrong. A data error is never an `InvalidOperationException` or `ArgumentException`. Expected outcomes of a query (no path, a start off the mesh) are statuses, not exceptions. - **Where the types live.** Everything the happy path names, including `PathResult`, `PathOptions`, `QueryFilter`, `FollowerState`, `PartitionType`, `PathNodeModel`, `StraightPathPoint` and the status enums, is in `RecursiveAI.OnePath`: one `using` (plus OneMath's vectors) is enough for the quickstart. Tier 2 and 3 types are in the sub-namespaces. - **Enumerating a crowd:** `foreach (Agent a in crowd)` and `crowd.CopyAgents(Span)` allocate nothing; both go in slot order. - **Listeners:** `AddListener(IGridChangeListener)` and `AddListener(IVoxelChangeListener)` on the tier-1 grids are observer registration, not search hooks: a class implements the interface, never a struct, and nothing on the happy path needs one. They are the only library interfaces the tier-1 surface test allows in the root namespace. Replanners and flow fields implement the callbacks explicitly, so `OnCellChanged` is not a method you can call on them. ## Host hooks for deterministic hosts A lockstep or rollback host needs five things; each has its own tests and is documented where it lives. | Hook | What | Document | |---|---|---| | Coordinate rule | World positions are `double`, exact within ±2^21 (a Q31.32 fixed-point value converts exactly); beyond that `ArgumentOutOfRangeException`. Meshes store tile-local `float32` relative to a double tile origin | [navmesh-format.md](https://recursiveai.org/onepath/docs/navmesh-format/), [grids.md](https://recursiveai.org/onepath/docs/grids/) | | Snapshot and restore | Same state, same bytes, on every platform: grids, searches, replanners and flow fields; dynamic meshes; crowds | [grids-state.md](https://recursiveai.org/onepath/docs/grids-state/), [navmesh-snapshot.md](https://recursiveai.org/onepath/docs/navmesh-snapshot/), [crowds.md](https://recursiveai.org/onepath/docs/crowds/) | | Fixed-dt stepping | `Crowd.Step(dt)` with a caller-supplied step; no internal clock | [crowds.md](https://recursiveai.org/onepath/docs/crowds/) | | Iteration budgets | Sliced grid, graph, mesh and tile-cache work counts iterations, never time; the unit of one iteration per family is stated in each document | [grids.md](https://recursiveai.org/onepath/docs/grids/), [grids-state.md](https://recursiveai.org/onepath/docs/grids-state/), [navmesh-queries.md](https://recursiveai.org/onepath/docs/navmesh-queries/), [build3d-runtime.md](https://recursiveai.org/onepath/docs/build3d-runtime/) | | Debug geometry | Polygons, links, paths, corridors, agent velocities and grid state as plain data (namespace `RecursiveAI.OnePath.Debug`) | [navmesh-format.md](https://recursiveai.org/onepath/docs/navmesh-format/), [crowds.md](https://recursiveai.org/onepath/docs/crowds/), [grids-state.md](https://recursiveai.org/onepath/docs/grids-state/) | ## Saved formats and their fixtures Every 1.x release reads every format written by an earlier 1.x release: navigation meshes and tiles, mesh and tile-cache snapshots, crowd state (with and without the mesh), path corridors and followers, grid, hex and voxel state, search, replanner and flow-field state, graphs and landmark tables. Reading is backward only; a later release may write a newer version marker. The rule that keeps it true: - A change to a format bumps that format's version marker. The reader keeps reading every earlier 1.x version. - Each release has a directory `tests/OnePath.Tests/Fixtures//` of small files its own code wrote (made by `tools/FormatFixtures` from generated scenes, with a `fixtures.tsv` manifest). A fixture is never regenerated or edited. A release that adds or changes a format adds a directory of its own and appends it to the list in `FormatFixtureTests`. - The expectation recorded beside each fixture is what the loaded state does, not its bytes: queries, searches, steps and edits after the load are run and hashed (SHA-256 of a transcript written only through the public API). Re-writing and comparing bytes would break the first time a release writes a newer marker. - `FormatFixtureTests` loads every fixture of every release with the current readers and compares. It also lists every public method that returns a byte array or fills a `Span`, and fails for one that has no fixture, so a new saved format cannot ship without one. ## Determinism and measurement | Document | Covers | |---|---| | [hash-matrix.md](https://recursiveai.org/onepath/docs/hash-matrix/) | The manifest format and the columns (JIT, scalar, threads, NativeAOT, WASM, ...) that must produce equal hashes | | [benchmarking.md](https://recursiveai.org/onepath/docs/benchmarking/) | How the speed rows are measured: warm-up, best of N, references and their versions, quality metrics, memory | # Changelog All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] ## [1.1.0] Additions only: every 1.0 result and saved format is unchanged, and 1.0 data loads as before. ### Added - **`NavMesh.Save`/`TrySave`/`GetSaveSize` and `NavMesh.Load`, `NavWorld.Save`/`TrySave`/`GetSaveSize` and `NavWorld.Load`:** a baked tier-1 mesh (or a world of agent classes) saves to bytes and loads back as a tier-1 `NavMesh`/`NavWorld`, with `Settings`, `CreateCrowd`, `CreateStreamer`, `FindPathAsync` and the query members working, and the same bits from every query. The bytes are a small envelope (`ONNM`, `ONNW`) around the unchanged version 2 mesh bytes and carry the `NavSettings`; `Load(bytes, settings)` also takes bare `NavMeshSerializer.WriteMesh` bytes, checking the settings against the mesh's build fingerprint (`NavMeshFingerprintException` on a mismatch). Layout in `docs/navmesh-format.md`; new fixtures under `Fixtures/1.1.0`; a fuzz target `navmesh-load`. - **`Crowd.TryAdd`:** like `Add`, but returns `false` instead of throwing when no walkable polygon is near the position or the crowd is full. `Add` is unchanged. The one policy for positions off the mesh is written down in `docs/path-results.md`. - **`NavMesh.Build(ReadOnlySpan vertices, ReadOnlySpan indices, NavSettings?)`**: single-precision input, as engines and `System.Numerics` arrays hold it. The float to double step is exact, so the mesh is byte-equal to the double overload's on the same values. - **`DefaultQueryFilter`**, a struct `IQueryFilter` that accepts every polygon at cost 1 and gives exactly the results of `new QueryFilter()`, so the allocation-free span `FindPath` needs no user-written filter. - **`Grid.FindPathWithPoints(Cell, Cell, ...)`**: the cell form of `FindPath` with `GridPath.Points` filled with the world-space cell centres through `Grid.Space`, so `Length` is a number instead of NaN. `FindPath(Cell, Cell)` is unchanged. - `docs/migrating.md`: concept, type and unit tables for moving from Recast, Detour, DotRecast and Unity's navigation, and the note that their mesh bytes must be re-baked. ### Changed - Error messages of `AgentOptions.Validate`, `CrowdOptions.Validate` and `FollowerOptions.Validate` now state the allowed range (`SeparationWeight must be 0 to 1.`); the setters of `HpaOptions` and `OrcaSimulator.MaxThreads` report their own property name as `ParamName` instead of `value`. Exception types are unchanged. ## [1.0.0] The first stable release. The public API is the one frozen in 1.0.0-rc.1 plus the additions below; results and saved formats from here on are covered by the 1.x promises in the README (bit-identical results within a major version, every 1.x release reads every earlier 1.x format). ### Added - **`NavMesh.Build2D(Grid, NavSettings?)`** builds a navigation mesh from a square grid: non-wall cells are walkable, cell size and origin come from `Grid.Space`, and the result equals the tile-map overload on the same walkability. Area ids and costs are not carried into the mesh (the 2D builder has one area per mesh); the agent radius comes from `NavSettings`. ### Changed - `Grid.ContentHash()` caches its value: the first call after an edit, restore or area-cost change is O(cells) as before, repeated calls are O(1). The value is unchanged for every grid state. ## [1.0.0-rc.1] Release candidate for 1.0: the public API is frozen from here unless a release candidate finds a defect. Every part measures within its speed target against the native references on x64 and arm64 (see the README table); results are bit-identical across x64, arm64, WebAssembly, SIMD and scalar, and any thread count. ### Breaking Changes to names, numbers, namespaces and shapes that are cheap now and costly after 1.0. Compile errors guide most of the migration; the table lists each one. | Area | Before | After | |---|---|---| | Status enums | `PathStatus.Found = 0` (`default` read as success); `QueryStatus.Success = 0`; `AreaQueryStatus.Success = 0`; `RaycastStatus.Ok = 0` | Each has `None = 0` and every other value is one higher (`PathStatus`: `Found` 1, `StartInvalid` 2, `GoalInvalid` 3, `NoPath` 4, `BudgetExhausted` 5, `Partial` 6, `InProgress` 7; `QueryStatus.Success` 1 and so on). Code that compares by name is unchanged; code that stored or logged the numbers must map them. `default(PathStatus).HasPath()` is false | | `GridSearch.Status` | `SearchStatus` (`NotStarted`, `Running`, `Found`, `NotFound`) | `PathStatus` (`None`, `InProgress`, `Found`, `NoPath`), the type `Step` and `TryBegin` already returned. `SearchStatus` stays on the raw `AStar`, `JumpSearch`, `ThetaSearch` and `GraphAStar` | | `FindPathAsync` | `Grid.FindPathAsync(start, goal, options, ct)` ran the sync search on the thread pool; `HexGrid.` and `VoxelGrid.FindPathAsync` the same; `NavMesh.`/`NavMeshQuery.`/`NavWorld.FindPathAsync(start, end, options, int iterationsPerSlice, ct)` sliced on the caller's context; graphs had none | One meaning and one shape, `(start, goal, options, ct)`, on `Grid`, `NavMesh`, `NavMeshQuery` and `NavWorld`: cooperative slices on the caller's context, sized by `GridOptions.IterationsPerSlice` / `PathOptions.IterationsPerSlice` (default 256). `Grid.FindPathAsync` supports A\*, JPS and JPS+ without `RecordExpansions`. `HexGrid` and `VoxelGrid` lose `FindPathAsync` (they were new in the release candidate); for a thread-pool search call `Task.Run(() => grid.FindPath(...))`. Move an `iterationsPerSlice` argument into the options: `mesh.FindPathAsync(a, b, new PathOptions { IterationsPerSlice = 64 }, ct)` | | Namespaces | `Mesh.Queries.PathResult`, `PathOptions`, `QueryFilter`, `PathNodeModel`, `StraightPathPoint`; `Crowds.Corridors.FollowerState`; `Mesh.Build3D.PartitionType` | All in `RecursiveAI.OnePath`: drop the `using RecursiveAI.OnePath.Mesh.Queries;` (and the others) from happy-path code | | Data errors | `NavMeshFormatException : Exception`; `GridStateException` and `GridFormatException : FormatException`; `GraphFormatException : Exception`; `GltfException : Exception`; `GeometryException : InvalidOperationException`; crowd, corridor and follower state threw `NavMeshFormatException` | One base, `OnePathFormatException : FormatException`; `catch (OnePathFormatException)` covers every bad-data error. `GraphFormatException` is now `GraphStateException`; crowd, corridor and follower state throw the new `CrowdStateException`; `Graph.FromState` garbage throws `GraphStateException` (it threw `GridStateException`); `GeometryException` is no longer an `InvalidOperationException`. Every exception type has the three standard constructors | | Options | `GridOptions`, `VoxelOptions`, `PathOptions`, `GraphOptions`, `PointGraphOptions`, `LandmarkOptions` were classes (`GridOptions` and `VoxelOptions` with `Clone()`) | Sealed records beside `NavSettings`, `AgentOptions`, `CrowdOptions` and `FollowerOptions`: `options with { ... }` replaces `options.Clone()`; options with equal values are equal. `NavSettings.OffMeshLinks`, `PointGraphOptions.Links` and `Mesh3DPipeline.Obstacles` are copied when set | | Validation | `GridOptions` checked values at init (`ParamName` "value"); `NavSettings`, `AgentOptions`, `CrowdOptions`, `PathOptions` and `GraphOptions` accepted anything until a build or `Add` | Every value is checked where it is set, `ArgumentOutOfRangeException.ParamName` is the property, the message states the range. `Agent.Humanoid with { MaxSpeed = -5 }` throws on the spot | | `NavSettings` types | `AgentRadius`, `AgentHeight`, `WalkableClimb`, `AgentMaxSlopeDegrees`, `ContourMaxError` were `float` | `double` like every other length (rounded to float32 once when a build starts, as before, so meshes are unchanged). Drop the `f` suffixes | | `NavSettings.Height` | World Y of the 2D floor, beside `AgentHeight` | `NavSettings.FloorY` | | `Crowd.CopyAgents` | `CopyAgents(List)` appended | `int CopyAgents(Span)` fills a span of at least `Count` entries and returns the count; `Crowd` is also enumerable (`foreach (Agent a in crowd)`, no allocation). Replace `var l = new List(); crowd.CopyAgents(l);` with `var l = new List(crowd);` | | `TileStreamer.StreamOut` | returned `List` | returns `IReadOnlyList` | | `Mesh3DPipeline.Obstacles` | mutable `IList`, silently ignored once a cache existed | read-only, set in an object initializer: `new Mesh3DPipeline(s) { Obstacles = [box] }`. Add obstacles at run time with `TileCache.AddObstacle` | | Replanner callbacks | `GridReplanner` and `HexReplanner` had public `OnCellChanged` and `OnRectChanged` | implemented explicitly; they are called by the grid, not by you | | Internal helpers | `GridSpace.CheckCoordinate` and `OnePathInfo.FixedToDouble` were public | removed from the surface; use OneMath's `FixedBridge.ToDouble` | | `QueryFilter` | `IncludeFlags`, `ExcludeFlags` and `Predicate` were settable; `SetAreaCost(area, cost)` mutated | `init`-only, and `WithAreaCost(area, cost)` returns a new filter and leaves the original alone, so a filter inside `PathOptions` or `CrowdOptions` can be shared by queries, crowds and threads. Migrate `f.SetAreaCost(3, x);` to `f = f.WithAreaCost(3, x);` | Saved data: no format changed (every fixture file of release 1.0.0 is byte-identical). The enum renumbering changes the numbers some hash-matrix scenarios write and the behaviour transcripts recorded beside the 1.0.0 fixtures; those transcripts were rewritten once, before any release. ### Added - **Results are bit-identical across every release of a major version:** no 1.x minor or patch release changes an existing path, build, query, search or crowd result (README, "Determinism"). - **Every 1.x release reads every format written by an earlier 1.x release** (navigation meshes, tile caches, saved states); backward reading only. - **`NavMesh.TrySamplePosition`, `NavMesh.Raycast` and `NavMesh.TryGetRandomPoint`**, with the small `NavMeshHit` struct: snap a position to the mesh, trace a line along it, and draw a seeded random point, without a query object, polygon reference or callback. - **`Agent.SetOptions(AgentOptions)`** changes a live agent's settings in place (id, position, velocity and path are kept). A crowd snapshot already stored per-agent settings, so it carries the change, and restoring a snapshot taken before the change gives the agent its old settings again without invalidating the handle you hold. The snapshot format is unchanged. ### Changed - **`NavMesh.FindPath` and `FindPathAsync`, `NavWorld.FindPath` and the `TiledNavMesh.FindPath` extension no longer build a query object per call.** A fresh query allocated about 10 MB (a 65,536-node search table), which put a gen-2 collection into most hot loops. The mesh now keeps up to four spare query objects, borrowed per call with an atomic exchange, so concurrent calls never share one; a steady-state call allocates only the returned path (about 250 bytes for a short path, against 10.2 MB before). Results are bit-identical to a fresh query, also across mesh edits. ## [0.5.0-preview.1] ### Breaking - **Options are immutable after construction.** `GridOptions`, `VoxelOptions` and `PathOptions` properties are `init`-only like `NavSettings`, `CrowdOptions` and `GraphOptions` already were, so a shared preset (`GridOptions.Default`, the defaults used when you pass `null`) cannot be changed by one caller under another. Code that assigned a property after construction (`o.Algorithm = GridAlgorithm.Jps;`) must set it in an object initializer (`new GridOptions { Algorithm = GridAlgorithm.Jps }`). Binary-incompatible with 0.4.0-preview.3 (setters are replaced by init accessors); recompiling is enough. `Clone()` is kept. - **`FindPath` gains a trailing `CancellationToken cancellationToken = default`** on `Grid`, `HexGrid`, `VoxelGrid`, `NavMesh`, `NavWorld`, `NavMeshQuery` and the `TiledNavMesh` extension, so existing source compiles unchanged but already-built callers must be recompiled (the old signatures are gone). ### Added - **Cancellation on every tier-1 `FindPath`:** the token is checked every 256 iterations; cancelling throws `OperationCanceledException` and leaves the grid or mesh untouched. A token that is never cancelled returns exactly the result of no token (slicing never changes a search). - **`NavMesh.FindPathAsync` and `Grid.FindPathAsync`** (cells and world points): same results as the synchronous forms. The mesh form is `NavMeshQuery.FindPathAsync` (slices of expansions with a yield between them); the grid form runs the synchronous search on the thread pool. - Documentation of the cross-tier conventions (immutable options, cancellation, observer-style listeners) in `docs/README.md`; the 3D builder and mesh format public members got a documentation pass. - **`GridSearch` runs JPS and JPS+:** `Grid.CreateSearch` accepts `GridAlgorithm.Jps` and `GridAlgorithm.JpsPlus` on the engines `FindPath` uses (the specialised octile jump point search, the generic block-map and JPS+ table searches, or plain A\* where `FindPath` falls back to it), so a budgeted jump point search resumes with `Step(budget)`, returns cell for cell the path and cost bits `FindPath` returns for every slicing, and saves and restores byte-exactly. One iteration is one pop of a jump point. State kind 12 gains mode 3 (specialised jump point engine), documented in `docs/grids-state.md`; existing states are unchanged. ### Changed - `GridSearch.SaveState(Span)`, `GetStateSize` and `RestoreState` allocate nothing in steady state on the generic engine too (the handle keeps one serialisation buffer); `GridSearch.UsesSpecializedEngine` now answers from the options and the grid before the first search. ## [0.4.0-preview.3] ### Fixed - **Path queries with an unreachable goal are fast again.** In the polygon node model (the default), a path whose goal cannot be reached ran a second search toward the nearest reachable polygon; on tiled tile maps, where most such queries end partial, that made path queries 1.40x (x64) and 1.26x (arm64) Detour's time. Nodes are now relaxed on cost plus the remaining straight-line estimate from their entry point, which gives the same corridors without the second search: that row now measures 1.04 / 0.94, and every path-query row is within the bar on both machines. Partial paths still end at the reachable polygon nearest the goal; path lengths on the 3D quality levels move by at most 0.4%, all toward shorter. ## [0.4.0-preview.2] ### Added - **Grids for deterministic hosts:** `Grid.CreateSearch` returns a non-generic resumable `GridSearch` (`TryBegin`, `Step`, `CopyPath`, byte-exact `SaveState`/`RestoreState`, allocation-free on the specialised engine) that returns the path `FindPath` returns; `GridReplanner.TryReplan`/`CopyPath` (no allocation); `AStar.TryBegin`, `Topology`, `Start`, `Goal`; `Grid.ContentHash`; `Grid.TryFindNearestWalkable`, `CanFit`, `MaxAgentSizeAt`; `Grid.TryGetFixedCost` (Q31.32) and `GridPath.TryGetStepCounts`; options `SpecializedEngines` (force the generic engine so tier-1 and tier-2 A\* break ties alike) and `NearestRadius`. Documented in `docs/grids.md` and `docs/grids-state.md` (state kind 12). - **Crowd state without allocation:** `Crowd.StateHash()` (FNV-1a 64 of the snapshot bytes), `Crowd.GetStateSize()` and `Crowd.TrySaveState(Span, out int)` write the same OPCS v1 bytes as `SaveState()` into caller memory; `RestoreState` restores the same agents without allocating (followers and corridors are read in place, so a held `Agent.Path` stays valid). Documented in `docs/crowds.md`. ### Changed - **The run-time tile cache is no longer experimental:** the `[Experimental]` marker is removed from `TileCache`, `TileObstacle`, `ObstacleShape`, `Mesh3DPipeline.Build*Cached` and `Mesh3DPipeline.Obstacles`. An obstacle add-and-remove cycle now measures 0.78-0.93 (x64) and 0.68-0.91 (arm64) of DetourTileCache's time on the city and terraces levels. Rebuilds repair the contours of the changed layer instead of redoing them, and the result is byte-identical to a fresh build of the same state. ### Fixed - A crowd whose settings (speed, horizon, wall clearance) were huge but valid, for example restored from a saved state, could ask the wall query for a radius beyond its limit; the radius is now bounded. - Restoring a sliced search whose open list named a node the search never reached is now a `GridStateException` (it would have popped the node with stale parent links, and `PathLength` could loop). A state saved before `Begin` restores as `NotStarted`; check `Status` before `Step`. - Restoring a sliced search (`AStar`, jump point, Theta\*, graph A\*) from a state that claimed *found* with a goal the search never closed could be accepted and then hang in `PathLength`/`CopyPath` by following leftover parent links in a loop; it is now a `GridStateException`. ## [0.4.0-preview.1] First public preview. The API can still change between previews. The only runtime dependency is `RecursiveAI.OneMath`. ### Added - **Grids:** square (4- and 8-connected), hex and 3D voxel grids; A\*, JPS and JPS+ (with improved-pruning variants), Theta\*, Lazy Theta\*, HPA\* over chunks, LPA\* and D\* Lite replanning, flow fields, area costs, agent clearance for several sizes, incremental tile updates, iteration-budgeted (sliced) searches. Costs are exact for the metric used (octile, hex distance, 3D octile). - **Graphs:** weighted and point graphs, A\* and Dijkstra, bidirectional search, ALT landmarks. - **Navigation mesh:** one tiled format (tiles with a double origin and float32 tile-local coordinates, multi-layer tiles, per-polygon height detail, areas, flags, off-mesh link slots, 128-bit build fingerprint per tile) with versioned, byte-exact serialization, streaming of tiles in and out, and several agent sizes (`NavWorld`). Layout documented in `docs/navmesh-format.md`; the first public version of the format is version 2. - **Builders:** an exact 2D builder (outlines and tile maps; exact predicates, constrained Delaunay, convex merge) and a Recast-class 3D builder (triangle soup, glTF, height fields and voxel chunks entering at the span stage; watershed, monotone and layer regions; contours, polygons, height detail), both behind `NavMesh.Build2D` and `NavMesh.Build`. - **Queries:** path (polygon-node or edge-crossing model, filters and area costs; sync and sliced), string pulling, raycast, nearest polygon and point, random point, distance to wall, polygons in a circle or shape, off-mesh links. - **Crowds:** path corridors that follow and repair paths, path following, navmesh-aware ORCA (closed-form truncated-cone constraints), separation, priorities, fixed-step `Crowd.Step`, byte-exact crowd snapshot and restore. - **Deterministic-host hooks:** snapshot and restore of crowd, grid, search and dynamic-mesh state; iteration budgets for sliced queries and rebuilds; debug geometry as plain data. - **Determinism:** results are bit-identical across x64, arm64 and WASM, scalar and SIMD, and any thread count; world coordinates are `double`, exact within ±2^21, and anything beyond throws `ArgumentOutOfRangeException`. - NativeAOT, trimming and browser-WASM smoke apps; the package is trim- and AOT-compatible. ### Experimental These types carry `[Experimental]` and may change or move in any preview: - the run-time tile cache: `TileCache`, `TileObstacle`, `ObstacleShape`, `Mesh3DPipeline.BuildSoupCached`, `BuildChunkCached`, `BuildHeightfieldCached` and `Mesh3DPipeline.Obstacles`. A tile rebuild still costs several times what a comparable native tile cache spends (see the speed table in the README). ### Known limits - Peak memory is higher than the native references'. - Path queries on one large single-tile city mesh ran 1.6-1.8x slower than Detour in the last locked run (a later change to the mesh contours is not yet re-measured), and the tile cache is 4-6x slower than DetourTileCache; the README table lists the rows. [Unreleased]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v1.1.0...HEAD [1.1.0]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v1.0.0-rc.1...v1.0.0 [1.0.0-rc.1]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v0.5.0-preview.1...v1.0.0-rc.1 [0.5.0-preview.1]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v0.4.0-preview.3...v0.5.0-preview.1 [0.4.0-preview.3]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v0.4.0-preview.2...v0.4.0-preview.3 [0.4.0-preview.2]: https://github.com/recursive-ai-oss/onepath-csharp/compare/v0.4.0-preview.1...v0.4.0-preview.2 [0.4.0-preview.1]: https://github.com/recursive-ai-oss/onepath-csharp/releases/tag/v0.4.0-preview.1 # Grid pathfinding: A*, JPS, Theta* and HPA* Find paths on a square grid with OnePath, compare the search algorithms on one map, size the path for a larger agent, and add area costs. The map is 64 × 64 with a wall down the middle that leaves a gap two cells wide at the bottom: ```csharp using RecursiveAI.OnePath; // A 64 x 64 map with a long wall that leaves a two-cell gap at the bottom. var map = new Grid(64, 64); map.SetRect(32, 0, 1, 62, AreaCosts.Wall); Cell start = new(2, 2), goal = new(60, 4); foreach (var algorithm in new[] { GridAlgorithm.AStar, GridAlgorithm.Jps, GridAlgorithm.JpsPlus, GridAlgorithm.ThetaStar, GridAlgorithm.Hpa }) { GridPath p = map.FindPath(start, goal, new GridOptions { Algorithm = algorithm }); Console.WriteLine($"{algorithm,-10} ran {p.Algorithm,-10} cost {p.Cost,9:F4} cells {p.Cells.Length,3} expanded {p.ExpandedCount,5}"); } // A 3 x 3 agent doesn't fit through the gap. GridPath fat = map.FindPath(start, goal, GridOptions.ForAgentSize(3)); Console.WriteLine($"3x3 agent: {fat.Status}"); // Area costs: a swamp (area 2) that costs three times as much to cross. JPS needs one cost // everywhere, so this query falls back to A*, and GridPath.Algorithm says so. map.SetRect(10, 50, 22, 12, 2); map.Areas[2] = 3.0; GridPath around = map.FindPath(start, goal, new GridOptions { Algorithm = GridAlgorithm.Jps }); Console.WriteLine($"with swamp: ran {around.Algorithm}, cost {around.Cost:F4}"); ``` ```text AStar ran AStar cost 143.1960 cells 121 expanded 2072 Jps ran Jps cost 143.1960 cells 121 expanded 5 JpsPlus ran JpsPlus cost 143.1960 cells 121 expanded 6 ThetaStar ran ThetaStar cost 132.6174 cells 4 expanded 2118 Hpa ran Hpa cost 143.1960 cells 121 expanded 47 3x3 agent: NoPath with swamp: ran AStar, cost 155.4975 ``` ## Reading the results - **A\*, JPS and JPS+** find the same optimal eight-connected path (cost 143.196). Jump point search prunes the symmetric alternatives, so it expands 5 nodes instead of 2,072; JPS+ precomputes jump distances once per grid. - **Theta\*** checks line of sight and returns an any-angle path: 4 corner points and a shorter true length. Lazy Theta\* does the same with fewer line-of-sight checks. - **HPA\*** searches an abstract graph of grid chunks: very few expansions on large maps, and a path that is near optimal but not guaranteed optimal. - **`GridOptions.ForAgentSize(3)`** plans for a 3 × 3 agent from each cell’s clearance; it can’t fit through a 2-cell gap, so the status is `NoPath`. - **Area costs** multiply the cost of crossing a cell. JPS needs one cost everywhere, so with the swamp it falls back to A\*, and `GridPath.Algorithm` tells you which search ran. ## Other options `GridOptions` also sets four- or eight-connected moves and the corner rule, the heuristic and its weight, `StepCost` and `Blocked` delegates, and `IterationBudget` with `IterationsPerSlice` for spreading a search over frames. `HexGrid` and `VoxelGrid` take the same calls. All of it is in [grids](https://recursiveai.org/onepath/docs/grids/). # Replanning and flow fields Keep a path up to date as the map changes with OnePath’s incremental replanner, and route many units to one goal with a single flow field. ```csharp using RecursiveAI.OnePath; var yard = new Grid(16, 8); using GridReplanner replanner = yard.CreateReplanner(new Cell(0, 0), new Cell(15, 0)); GridPath first = replanner.Replan(); Console.WriteLine($"open yard: cost {first.Cost:F4}, {first.Cells.Length} cells"); yard.SetRect(7, 0, 1, 7, AreaCosts.Wall); // a wall appears, open only at the bottom row GridPath detour = replanner.Replan(); Console.WriteLine($"after wall: cost {detour.Cost:F4}, {detour.Cells.Length} cells"); replanner.MoveStart(new Cell(4, 5)); // the unit has moved along GridPath onward = replanner.Replan(); Console.WriteLine($"moved start: cost {onward.Cost:F4}, {onward.Cells.Length} cells"); // One flow field serves every unit heading for the same goal. using GridFlowField toGoal = yard.CreateFlowField(new Cell(15, 0)); foreach (var unit in new[] { new Cell(0, 0), new Cell(3, 6), new Cell(12, 2) }) { toGoal.TryGetNext(unit, out Cell next); Console.WriteLine($"unit at ({unit.X},{unit.Y}): cost to goal {toGoal.GetCost(unit):F4}, next step ({next.X},{next.Y})"); } ``` ```text open yard: cost 15.0000, 16 cells after wall: cost 21.3848, 17 cells moved start: cost 14.7279, 12 cells unit at (0,0): cost to goal 21.3848, next step (0,1) unit at (3,6): cost to goal 15.3137, next step (4,6) unit at (12,2): cost to goal 3.8284, next step (13,1) ``` ## The replanner `grid.CreateReplanner(start, goal)` keeps its search state between calls (D\* Lite by default; LPA\* is the other choice). After an edit to the grid, `Replan()` repairs only what the edit affected, instead of searching again from scratch. `MoveStart` follows the unit as it walks, and the next `Replan()` reuses everything still valid. ## The flow field `grid.CreateFlowField(goal)` computes the least cost to the goal from every cell once. Each unit then asks two cheap questions: `GetCost(cell)` and `TryGetNext(cell, out next)`, the step to take. Hundreds of units heading for one rally point cost one field. ## Good to know - Both are `IDisposable`; dispose them when the unit or the goal goes away. - Both hear about edits to the grid they came from and repair only the affected region. - Their state saves to bytes and restores exactly, like grids and searches; see [saved search state](https://recursiveai.org/onepath/docs/grids-state/). # Navigation mesh queries Build a navigation mesh from a tile map with OnePath, then find a path around an obstacle, cast a ray along the mesh, snap a point to the nearest walkable spot, and save the mesh to bytes to load at run time. ```csharp using RecursiveAI.OneMath; using RecursiveAI.OnePath; // A 30 x 20 tile map, one unit per cell, with a block in the middle. var map = Grid.FromText(""" .............................. .............................. .............................. .............................. .............................. .............................. ...........########........... ...........########........... ...........########........... ...........########........... ...........########........... ...........########........... .............................. .............................. .............................. .............................. .............................. .............................. .............................. .............................. """); NavMesh mesh = NavMesh.Build2D(map); PathResult route = mesh.FindPath(new Vector3D(2, 0, 9), new Vector3D(28, 0, 9)); Console.WriteLine($"path: length {route.Length:F4}, {route.Points.Count} points"); foreach (var p in route.Points) Console.WriteLine($" {p.Position}"); // Line of sight along the mesh: does a straight walk hit the block? bool hit = mesh.Raycast(new Vector3D(2, 0, 9), new Vector3D(28, 0, 9), out NavMeshHit info); Console.WriteLine($"raycast hit: {hit}, at {info.Position}"); // Snap a click to the nearest walkable point. if (mesh.TrySamplePosition(new Vector3D(15, 0, 9), 5, out Vector3D snapped)) Console.WriteLine($"nearest walkable to (15, 0, 9): {snapped}"); // Bake once, ship the bytes, load at run time (1.1.0). byte[] baked = mesh.Save(); NavMesh loaded = NavMesh.Load(baked); PathResult again = loaded.FindPath(new Vector3D(2, 0, 9), new Vector3D(28, 0, 9)); Console.WriteLine($"saved {baked.Length} bytes; loaded mesh gives the same length: {again.Length == route.Length}"); ``` ```text path: length 27.4165, 4 points (2, 0, 9) (10.79296875, 0, 12.599609375) (19.20703125, 0, 12.599609375) (28, 0, 9) raycast hit: True, at (10.400390625, 0, 9) nearest walkable to (15, 0, 9): (15, 0, 5.400390625) saved 793 bytes; loaded mesh gives the same length: True ``` ## What happened - **`NavMesh.Build2D(grid)`** treats every non-wall cell as walkable and uses `Grid.Space` for cell size and origin (one unit per cell here). The 2D builder is exact: it shrinks the walkable area by the agent radius from `NavSettings` (0.6 for the default `Humanoid`), which is why the path keeps clear of the block’s corners. - **`FindPath`** returns the string-pulled path: the start, one point past each corner it has to round, and the goal. - **`Raycast`** walks the mesh surface in a straight line and reports where it leaves the walkable area, here at the eroded edge of the block. - **`TrySamplePosition`** returns the nearest walkable point within the distance you give; a click inside the block snaps to its edge. - **`Save` and `Load`** (new in 1.1.0) bake a mesh once and ship the bytes. The loaded mesh gives the same bits from every query, and every later 1.x release reads them. The full query layer, with filters, area costs, random points, distance to walls and sliced queries, is `NavMeshQuery`: see [navmesh queries](https://recursiveai.org/onepath/docs/navmesh-queries/). For level geometry, `NavMesh.Build` (triangles) and `NavMesh.BuildGltf` run the 3D builder into the same format. # Crowds in a rollback game Run a OnePath crowd with a fixed time step, save its state, roll back and resimulate, and check with a state hash that the second run reaches the same bits. Eight agents cross a 40 × 40 floor in two rows, through each other: ```csharp using RecursiveAI.OneMath; using RecursiveAI.OnePath; var floor = new[] { new Triangle3D(new Vector3D(0, 0, 0), new Vector3D(0, 0, 40), new Vector3D(40, 0, 0)), new Triangle3D(new Vector3D(40, 0, 0), new Vector3D(0, 0, 40), new Vector3D(40, 0, 40)), }; NavMesh level = NavMesh.Build(floor, NavSettings.Humanoid); Crowd crowd = level.CreateCrowd(); // Eight agents in two rows, each walking to the other side. for (int i = 0; i < 4; i++) { crowd.Add(new Vector3D(5 + 8 * i, 0, 5), Agent.Humanoid).SetDestination(new Vector3D(35 - 8 * i, 0, 35)); crowd.Add(new Vector3D(5 + 8 * i, 0, 35), Agent.Humanoid).SetDestination(new Vector3D(35 - 8 * i, 0, 5)); } for (int i = 0; i < 100; i++) crowd.Step(1.0 / 30); byte[] saved = crowd.SaveState(); // frame 100 Console.WriteLine($"frame {crowd.StepCount}: saved {saved.Length} bytes, hash {crowd.StateHash():X16}"); for (int i = 0; i < 200; i++) crowd.Step(1.0 / 30); ulong ahead = crowd.StateHash(); Console.WriteLine($"frame {crowd.StepCount}: hash {ahead:X16}"); crowd.RestoreState(saved); // roll back to frame 100 and simulate again Console.WriteLine($"restored: frame {crowd.StepCount}, hash {crowd.StateHash():X16}"); for (int i = 0; i < 200; i++) crowd.Step(1.0 / 30); Console.WriteLine($"frame {crowd.StepCount}: hash {crowd.StateHash():X16}, same as before: {crowd.StateHash() == ahead}"); Console.WriteLine($"{crowd.Count(a => a.ReachedTarget)} of {crowd.Count} agents have arrived"); ``` ```text frame 100: saved 3320 bytes, hash E7D9E5CD5E787CD2 frame 300: hash 75F1194801CC93C2 restored: frame 100, hash E7D9E5CD5E787CD2 frame 300: hash 75F1194801CC93C2, same as before: True 4 of 8 agents have arrived ``` ## The pieces - **`crowd.Step(dt)`** takes the time step from you. The crowd has no clock, and nothing in it depends on threads or hash order, so the same steps give the same state on every machine. - **`SaveState()`** writes the crowd’s state to bytes and **`RestoreState(bytes)`** puts it back, so the next step continues exactly where the saved one left off. The same state writes the same bytes on every platform. - **`StateHash()`** is a cheap fingerprint to exchange with peers every few frames: a mismatch is a desync. - `SaveStateWithMesh` also saves a dynamic mesh (rebuilt tiles, obstacles) with the crowd. ORCA and the corridor follower are in [ORCA solver](https://recursiveai.org/onepath/docs/orca/) and [corridors and following](https://recursiveai.org/onepath/docs/crowds/). # Routes on graphs Find routes on a weighted graph with OnePath, speed up repeated queries with landmarks, and link nearby points into a point graph. ```csharp using RecursiveAI.OnePath; // A small road network: node ids, and edges with travel times in minutes. var roads = Graph.From(new[] { new GraphEdge(0, 1, 4.0), new GraphEdge(1, 2, 1.5), new GraphEdge(0, 2, 7.0), new GraphEdge(2, 3, 2.0), new GraphEdge(1, 3, 6.0), new GraphEdge(3, 4, 3.0), }); GraphPath route = roads.FindPath(0, 4); Console.WriteLine($"0 -> 4: {string.Join(" -> ", route.Nodes.ToArray())}, {route.Cost} minutes"); // Landmarks (ALT) speed up repeated queries on the same graph; the answer is the same. Landmarks landmarks = roads.BuildLandmarks(); GraphPath fast = roads.FindPath(0, 4, GraphOptions.WithLandmarks(landmarks)); Console.WriteLine($"with landmarks: {string.Join(" -> ", fast.Nodes.ToArray())}, {fast.Cost} minutes"); // A point graph links points closer than MaxDistance. var points = PointGraph.From(new[] { new Point3(0, 0), new Point3(1, 0), new Point3(2, 0), new Point3(2, 1) }, new PointGraphOptions { MaxDistance = 1.1 }); GraphPath walk = points.FindPath(new Point3(0, 0), new Point3(2, 1)); Console.WriteLine($"point graph: {walk.Nodes.Length} nodes, cost {walk.Cost}"); ``` ```text 0 -> 4: 0 -> 1 -> 2 -> 3 -> 4, 10.5 minutes with landmarks: 0 -> 1 -> 2 -> 3 -> 4, 10.5 minutes point graph: 4 nodes, cost 3 ``` ## Good to know - **`Graph.From(edges)`** builds a weighted graph from `GraphEdge(from, to, weight)` values, each added in both directions unless you pass `directed: true`. `FindPath` returns `Nodes` and `Cost`; bidirectional search is an option. - **Landmarks (ALT)** precompute distances to a few nodes once, which tightens the heuristic for every later query on the same graph. The path is the same; the search is shorter on large road networks. `Landmarks` serialize, so you can build them offline. - **`PointGraph.From(points, options)`** links every pair of points closer than `MaxDistance`, for waypoints, cover points or a coarse network over open terrain. Graphs, bidirectional search and the landmark format are in [graphs](https://recursiveai.org/onepath/docs/graphs/).