> For the complete documentation index, see [llms.txt](https://multiset.gitbook.io/multiset/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://multiset.gitbook.io/multiset/webxr-sdk/navigation.md).

# Navigation

Pick a destination, follow an arrowed path along the floor, arrive. Navigation runs on top of VPS localization and works with both [ThreeAdapter](/multiset/webxr-sdk/api-reference/threeadapter.md) and [NeedleAdapter](/multiset/webxr-sdk/api-reference/needleadapter.md).

```bash
npm install @multisetai/vps three-pathfinding
```

`three-pathfinding` is an **optional** peer dependency, loaded on demand. Install it only if you use `NavMeshPathfinder`, which you almost certainly will.

{% hint style="info" %}
A complete working app is available as the [Needle navigation sample](https://github.com/MultiSet-AI/multiset-vps-navigation-needle-sample): a Unity project plus web folder with the components, visuals, and UI already wired. Clone it, add your credentials and map code, and press play. Everything on this page is also written to stand on its own, so you can build from scratch instead.
{% endhint %}

### What the SDK provides

The `@multisetai/vps/navigation` entry point has everything needed to build a navigation app. It is not a navigation app itself.

| Export                                                        | What it is                                                                                                          |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `Navigation`                                                  | The state machine: recalculation cadence, off-navmesh grace period, arrival detection, events, and distance queries |
| `NavMeshPathfinder`                                           | Pathfinding over a navmesh, backed by `three-pathfinding`                                                           |
| `StraightLinePathfinder`                                      | Development stub that walks through walls. Useful before a navmesh exists.                                          |
| `buildPathRibbon(corners, options)`                           | Converts path corners into ribbon triangles you can render                                                          |
| `IMapPOI`, `IPathfinder`, `MapPOIType`, event and state types | Contracts                                                                                                           |

Deliberately **not** included: UI, CSS, shaders, materials, label design, POI icons, and debug overlays. Those are design decisions, so you own them. Nothing needs a hook or a configuration flag:

```typescript
const mesh = new THREE.Mesh(buildPathRibbon(corners), yourMaterial);
navigation.on('pathUpdated', ({ corners }) => {
  mesh.geometry.dispose();
  mesh.geometry = buildPathRibbon(corners);
  mesh.visible = corners.length > 1;
});
```

### Requirements

1. A [MapSpace](/multiset/webxr-sdk/api-reference/mapspace.md) frame. Navigation computes every route in map space, which is what makes relocalization free.
2. A navmesh: any triangulated walkable surface, expressed in map space.
3. One or more destinations, as `IMapPOI` objects.

{% hint style="warning" %}
`MapSpace` is required, not optional. It defines the coordinate frame that navigation works in. A scene using only [MapAnchor](/multiset/webxr-sdk/api-reference/mapanchor.md) cannot run navigation.
{% endhint %}

### Creating the navmesh

Navigation reads triangles, so any triangulated surface works. Unity's runtime NavMesh is not available on the web, so generate the mesh ahead of time.

The quickest route is the Recast browser tool at [navmesh.isaacmason.com](https://navmesh.isaacmason.com/):

1. Download your map's **3D Mesh (.glb)** from the [developer portal](https://developer.multiset.ai). Take the raw mesh, not the textured one, because pathfinding only reads geometry and textures add tens of megabytes.
2. Drag the file into the tool and set the generation config. Note that Walkable Radius, Climb, and Height are expressed in **voxels**, which are multiples of Cell Size and Cell Height, so the numbers look small. For a typical indoor scan: Cell Size `0.10`, Cell Height `0.10`, Walkable Slope Angle `60`, Walkable Height `8`, Walkable Climb `3`, Walkable Radius `1`, Min Region Area `2`.
3. Click **Generate NavMesh**. Teal polygons should appear over the floor.
4. **Test connectivity before exporting.** Enable **Test Agent** under Display Options, right-click to place the agent in one room, then left-click a target in another. If the agent walks there, the navmesh is sound. If it refuses, the surface is torn and nothing downstream will fix it, so raise Cell Size and regenerate.
5. Click **Export as GLB**. Do not use **Export as Recast NavMesh**, which is Recast's own binary format that neither Unity nor this SDK reads.

{% hint style="warning" %}
Verify the exported file before using it. The tool's GLB export is unreliable and can produce a file that looks plausible but that no viewer will load.

Open it in a glTF viewer such as [gltf-viewer.donmccurdy.com](https://gltf-viewer.donmccurdy.com/) and confirm the geometry appears. If it fails to load, use **Export as GLTF** instead, open the [Three.js editor](https://threejs.org/editor/), drag the `.gltf` file in, then choose **File, Export GLB**. That round trip only re-serialises the file, so geometry and coordinates are unchanged.

A file of only a few bytes means nothing was generated. Press **Generate NavMesh** first, because export writes out the last generated result.
{% endhint %}

### Plain Three.js

```typescript
import * as THREE from 'three';
import { MapSpace } from '@multisetai/vps/three';
import { Navigation, NavMeshPathfinder, buildPathRibbon } from '@multisetai/vps/navigation';

// 1. The map coordinate frame. All navigation content lives under it, so a
//    relocalization moves everything at once and nothing needs recomputing.
const mapSpace = new MapSpace(new THREE.Object3D());
scene.add(mapSpace.object);
mapSpace.connect(adapter);

// 2. Pathfinding. Passing `space` transforms the navmesh geometry into map space.
const pathfinder = await NavMeshPathfinder.fromObject3D(navMeshObject, {
  space: mapSpace.object,
});

// 3. The state machine. POI positions are map coordinates. Convert values copied
//    from the portal with MapSpace.toLocal().
const navigation = await Navigation.create({
  adapter,
  mapSpace,
  pathfinder,
  pois: [
    { id: 'kitchen', name: 'Kitchen', position: MapSpace.toLocal(new THREE.Vector3(1.5, 0, -2)) },
    { id: 'desk', name: 'Desk', position: MapSpace.toLocal(new THREE.Vector3(4.0, 0, 1.2)) },
  ],
});

// 4. Draw it. This part is entirely yours.
const path = new THREE.Mesh(new THREE.BufferGeometry(), myMaterial);
path.frustumCulled = false;   // geometry is rebuilt on every recalculation
mapSpace.object.add(path);

navigation.on('pathUpdated', ({ corners, remainingDistance }) => {
  path.geometry.dispose();
  path.geometry = buildPathRibbon(corners, { width: 0.35, heightAboveFloor: 0.1 });
  path.visible = corners.length > 1;
  console.log(`${remainingDistance.toFixed(1)} m remaining`);
});

navigation.on('arrived', poi => console.log('arrived at', poi.name));

navigation.setDestination('kitchen');
```

### Unity and Needle Engine

Navigation needs three things in the scene.

#### 1. Scene structure

```
MultisetVPS            credentials and map code, owns the WebXR session
MapSpace               the map coordinate frame, at identity
├── navmesh            your navmesh GLB, at identity
└── POIs
    ├── Kitchen        a GameObject marking a destination
    └── Desk
MyNavigation           your component, OUTSIDE the MapSpace subtree
```

{% hint style="warning" %}
Your navigation component must sit **outside** the `MapSpace` subtree. `MapSpace.hideUntilLocalized` hides its subtree until the first localization, Needle treats an invisible GameObject as inactive, and an inactive component never runs `update()`. Nested there, navigation would never start.
{% endhint %}

Keep both `MapSpace` and the navmesh at identity: position `0,0,0`, no rotation, scale `1`. The navmesh is already in map coordinates because it was generated from the map mesh, so any transform on it applies the offset a second time. A displaced navmesh is the most common setup mistake.

#### 2. Import the navmesh

Drag the GLB into your Unity project, then drag that asset from the Project window into the Hierarchy as a child of `MapSpace`. Those are two separate actions, and only the second puts the mesh in the scene. Rename it and zero its Transform.

#### 3. A navigation component

Create this in your web project's `src/scripts/` folder. Needle generates a Unity Inspector component from it.

```typescript
import * as THREE from 'three';
import { Behaviour, serializable, GameObject } from '@needle-tools/engine';
import { Navigation, NavMeshPathfinder, buildPathRibbon, type IMapPOI } from '@multisetai/vps/navigation';
import { MapSpace } from './MapSpace.js';
import { MultisetVPS } from './MultisetVPS.js';

export class MyNavigation extends Behaviour {

    /** The walkable surface. Drag the navmesh object from the Hierarchy. */
    @serializable(THREE.Object3D)
    navMesh?: THREE.Object3D;

    /** Path width in metres. */
    @serializable()
    pathWidth: number = 0.35;

    private _navigation: Navigation | null = null;
    private _pathMesh: THREE.Mesh | null = null;
    private _setupStarted = false;

    update(): void {
        // MultisetVPS.adapter only exists after its async authorize() resolves, so setup cannot
        // happen in start(). Poll here until the adapter is available, then run setup once.
        if (this._setupStarted) return;

        const vps = GameObject.findObjectsOfType(MultisetVPS, this.context)[0];
        const adapter = vps?.adapter;
        if (!adapter) return;

        this._setupStarted = true;
        void this._setup(adapter);
    }

    private async _setup(adapter: NonNullable<MultisetVPS['adapter']>): Promise<void> {
        const mapSpace = GameObject.findObjectsOfType(MapSpace, this.context)[0];
        if (!mapSpace || !this.navMesh) {
            console.error('[MyNavigation] MapSpace or Nav Mesh is missing');
            return;
        }

        // The package MapSpace behind the Needle component. Its .object is the map frame.
        const space = mapSpace.space;

        const pathfinder = await NavMeshPathfinder.fromObject3D(this.navMesh, {
            space: space.object,
        });

        // The navmesh is geometry for pathfinding, not something to look at. Hiding it at
        // runtime lets you keep it visible in the Unity editor, where you need to see it.
        this.navMesh.visible = false;

        this._navigation = await Navigation.create({
            adapter,
            mapSpace,
            pathfinder,
            pois: this._collectPOIs(space),
        });

        this._pathMesh = new THREE.Mesh(
            new THREE.BufferGeometry(),
            new THREE.MeshBasicMaterial({ color: 0x33ccff, transparent: true, opacity: 0.8 })
        );
        this._pathMesh.frustumCulled = false;   // geometry is rebuilt on every recalculation
        space.object.add(this._pathMesh);

        this._navigation.on('pathUpdated', ({ corners }) => {
            if (!this._pathMesh) return;
            this._pathMesh.geometry.dispose();
            this._pathMesh.geometry = buildPathRibbon(corners, { width: this.pathWidth });
            this._pathMesh.visible = corners.length > 1;
        });
    }

    /**
     * Read destinations out of the scene. Any GameObject under MapSpace can mark one; here
     * every child of a GameObject named POIs is used.
     *
     * World position is converted with worldToMap rather than read from .position, so it does
     * not matter how deeply the object is nested under MapSpace.
     */
    private _collectPOIs(space: MapSpace['space']): IMapPOI[] {
        const root = space.object.getObjectByName('POIs');
        if (!root) return [];

        const world = new THREE.Vector3();
        return root.children.map(obj => {
            obj.updateWorldMatrix(true, false);
            obj.getWorldPosition(world);
            return {
                id: obj.name,
                name: obj.name,
                position: space.worldToMap(world, new THREE.Vector3()),
            };
        });
    }

    /** Call this from a Unity Button's On Click list. */
    navigateToNearest(): void {
        const nearest = this._navigation?.nearestPOI();
        if (nearest) this._navigation?.setDestination(nearest);
    }

    stopNavigation(): void {
        this._navigation?.stop();
    }

    onDestroy(): void {
        super.onDestroy();
        this._navigation?.dispose();
        this._navigation = null;
    }
}
```

{% hint style="warning" %}
Setup must not run in `start()`. `MultisetVPS` authorizes asynchronously, so its `adapter` is still `null` when `start()` runs on other components. Polling in `update()` as shown above is the simplest correct approach.
{% endhint %}

Public methods such as `navigateToNearest()` and `stopNavigation()` appear in a Unity Button's **On Click** dropdown, so you can drive navigation without writing UI code.

{% hint style="info" %}
UI must be HTML, not a Unity Canvas. The SDK owns the WebXR session, so Needle's UI raycaster never activates, and in `immersive-ar` the WebGL canvas receives no pointer events. The session requests the `dom-overlay` feature, which does receive taps. Mount your UI in the session's overlay root, which is `adapter.getSession().getOverlayRoot()` with `NeedleAdapter`, or the `overlayRoot` element you passed to `XRSessionManager` with `ThreeAdapter`, which does not expose `getSession()`. See [Building your own UI](#building-your-own-ui). A world-space Canvas still renders correctly for signage, but it cannot be tapped during a session.
{% endhint %}

### Navigation API

| Member                                                        | Returns                    | Description                                                                                                                     |
| ------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `Navigation.create(options)`                                  | `Promise<Navigation>`      | Static. Builds and attaches to the adapter.                                                                                     |
| `setDestination(target)`                                      | `void`                     | Accepts an `IMapPOI`, a registered POI id, or a bare map coordinate. Emits `unreachable` and does not start if no route exists. |
| `stop()`                                                      | `void`                     | Stop navigating and clear the path.                                                                                             |
| `recalculate()`                                               | `void`                     | Force an immediate recalculation, ignoring the interval and movement threshold.                                                 |
| `state`                                                       | `NavigationState`          | `'unlocalized'`, `'idle'`, `'navigating'`, `'off-navmesh'`, or `'arrived'`.                                                     |
| `destination`                                                 | `IMapPOI` or `null`        | The active destination.                                                                                                         |
| `currentPath`                                                 | `readonly THREE.Vector3[]` | Path corners in map space. Empty when not navigating.                                                                           |
| `remainingDistance`                                           | `number`                   | Metres left along the current path.                                                                                             |
| `pois`                                                        | `readonly IMapPOI[]`       | All registered destinations.                                                                                                    |
| `setPOIs(list)`, `addPOI(poi)`, `removePOI(id)`, `getPOI(id)` |                            | Manage destinations at runtime. Removing the active destination stops navigation.                                               |
| `distanceTo(poi)`                                             | `number`                   | Walking distance in metres, or `-1` when unknown. Cached and throttled, so it is safe to call every frame.                      |
| `isReachable(poi)`                                            | `boolean`                  | Whether a complete route exists.                                                                                                |
| `nearestPOI()`                                                | `IMapPOI` or `null`        | Closest destination by walking distance, skipping unreachable ones.                                                             |
| `getViewerMapPosition(target?)`                               | `THREE.Vector3` or `null`  | The viewer's position in map space, or `null` before the first localization.                                                    |
| `diagnose()`                                                  | `NavigationDiagnosis`      | Why navigation is not working, in one word. See [Troubleshooting](#troubleshooting).                                            |
| `on(event, fn)`                                               | `() => void`               | Subscribe. Returns an unsubscribe function.                                                                                     |
| `attach()`, `detach()`                                        | `void`                     | Subscribe to or unsubscribe from the adapter. `create()` attaches for you.                                                      |
| `update(deltaSeconds)`                                        | `void`                     | Advance manually. Only needed if you drive your own loop.                                                                       |
| `dispose()`                                                   | `void`                     | Detach and release everything.                                                                                                  |
| `Navigation.pathLength(corners)`                              | `number`                   | Static. Summed distance between consecutive corners.                                                                            |

#### Events

| Event                | Payload                                  |
| -------------------- | ---------------------------------------- |
| `stateChanged`       | `{ state, previous }`                    |
| `destinationChanged` | `IMapPOI` or `null`                      |
| `pathUpdated`        | `{ corners, remainingDistance }`         |
| `arrived`            | `IMapPOI`                                |
| `unreachable`        | `IMapPOI`, when no complete route exists |
| `tick`               | `{ deltaSeconds }`, every frame          |

The `tick` event fires from the XR frame loop during a session and from `requestAnimationFrame` outside one, so animation and UI can be developed and tested on a desktop before putting a phone in AR.

### Building your own UI

`Navigation` ships no UI, so a destination picker, HUD, or toast is yours to write. It needs nothing from the SDK beyond the events above and the query methods, which is the point: you are not styling around a widget, you are rendering your own view of plain state.

#### Where to mount it

In `immersive-ar` the WebGL canvas receives no pointer events, so UI has to live in the WebXR DOM overlay:

| Adapter         | Overlay root                                               |
| --------------- | ---------------------------------------------------------- |
| `NeedleAdapter` | `adapter.getSession().getOverlayRoot()`                    |
| `ThreeAdapter`  | the `overlayRoot` element you passed to `XRSessionManager` |

Outside a session, ordinary DOM works normally, so build and test your UI on a desktop first. The `tick` event fires from `requestAnimationFrame` when no session is running, so live distances and progress animate in a browser tab.

{% hint style="warning" %}
The session requests `dom-overlay` as an **optional** feature. If the device does not grant it, your UI still renders but never receives taps. Check `adapter.getSession().getXRSession()?.domOverlayState` to detect that, rather than looking for the fault in your own event handlers.
{% endhint %}

#### A destination list and a progress HUD

```typescript
const root = adapter.getSession().getOverlayRoot() ?? document.body;

// A button per destination, nearest first, with live walking distances.
const list = document.createElement('div');
root.appendChild(list);

function renderList() {
  list.replaceChildren();
  const sorted = [...navigation.pois]
    .map(poi => ({ poi, distance: navigation.distanceTo(poi) }))
    .filter(entry => entry.distance >= 0)        // -1 means unreachable, or not known yet
    .sort((a, b) => a.distance - b.distance);

  for (const { poi, distance } of sorted) {
    const button = document.createElement('button');
    button.textContent = `${poi.name} (${distance.toFixed(0)} m)`;
    button.onclick = () => navigation.setDestination(poi);
    list.appendChild(button);
  }
}

// A HUD that follows the active route.
const hud = document.createElement('div');
root.appendChild(hud);

let routeStartDistance = 0;
navigation.on('destinationChanged', poi => {
  hud.hidden = !poi;
  // Baseline only for a real destination: this event also fires with null after arrival.
  if (poi) routeStartDistance = navigation.remainingDistance;
});

navigation.on('pathUpdated', ({ remainingDistance }) => {
  const done = routeStartDistance > 0 ? 1 - remainingDistance / routeStartDistance : 0;
  hud.textContent =
    `${navigation.destination?.name}: ${remainingDistance.toFixed(0)} m, `
    + `${Math.round(done * 100)} percent`;
});

navigation.on('arrived', poi => { hud.textContent = `Arrived at ${poi.name}`; });
navigation.on('unreachable', poi => { hud.textContent = `No route to ${poi.name}`; });

// Distances change as the user walks. distanceTo() is cached and throttled internally,
// so polling every destination on a timer is inexpensive.
setInterval(renderList, 1000);
```

Two details are easy to get wrong. Baseline the progress bar on `destinationChanged` rather than the first `pathUpdated`, because a route recalculated around a corner can briefly grow longer and a bar seeded from a mid-route value then jumps backwards. And `destinationChanged` fires with `null` right after `arrived`, so guard the baseline on a non-null destination or the bar resets the moment the user gets there.

#### Triggering content near a destination

There is no proximity event yet, so poll from `tick`:

```typescript
navigation.on('tick', () => {
  const distance = navigation.distanceTo(kitchen);   // cached, safe to call per frame
  if (distance >= 0 && distance < 3) video.play();
});
```

`distanceTo` returns walking distance along the navmesh rather than straight-line distance, so this fires when the user is genuinely three metres from the destination on foot, not three metres away through a wall.

#### When nothing appears

Call `navigation.diagnose()` from your UI and surface the result during development. It names the blocker in one word, which is faster than reasoning about why a list came out empty.

#### A working implementation

The [Needle navigation sample](https://github.com/MultiSet-AI/multiset-vps-navigation-needle-sample) has all of this built, with the destination picker, HUD, and toasts in one file and the 3D visuals in another, so each can be replaced independently. Its README documents which file owns what.

### NavMeshPathfinder

| Member                                               | Returns                      | Description                                                                                                            |
| ---------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `NavMeshPathfinder.fromObject3D(object, options?)`   | `Promise<NavMeshPathfinder>` | Static. Merges every descendant mesh and transforms it into `options.space`.                                           |
| `NavMeshPathfinder.fromGeometry(geometry, options?)` | `Promise<NavMeshPathfinder>` | Static. From geometry already in map space.                                                                            |
| `findPath(from, to)`                                 | `THREE.Vector3[]` or `null`  | Corners including both endpoints. `null` means no complete route, because a partial path is never returned as success. |
| `clampToNavMesh(p, maxDistance?)`                    | `THREE.Vector3` or `null`    | The viewer's projection onto the walkable surface.                                                                     |
| `snapDestination(p, label?)`                         | `THREE.Vector3` or `null`    | A destination's projection onto the walkable surface.                                                                  |
| `geometry`                                           | `THREE.BufferGeometry`       | The merged navmesh in map space. Use it to build a debug overlay.                                                      |
| `groupCount`                                         | `number`                     | The number of disconnected walkable regions.                                                                           |
| `dispose()`                                          | `void`                       | Release the zone data.                                                                                                 |

#### Options

| Option                      | Default           | Description                                                                                                             |
| --------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `space`                     | the source object | The space to express navmesh geometry in. Pass `mapSpace.object`.                                                       |
| `weldTolerance`             | `1e-4`            | Vertex welding tolerance during zone construction.                                                                      |
| `destinationSnapRadius`     | `1`               | How far a destination may sit off the walkable surface **horizontally**, in metres.                                     |
| `destinationSnapWarnHeight` | `3`               | Log a warning once when snapping moves a destination further than this vertically.                                      |
| `startRegionTolerance`      | `1`               | How much further than the closest walkable point a disconnected region may be and still count as the viewer's location. |

#### Destination height does not matter

A destination's Y value is almost always an authoring accident: dropped at zero, left at eye level, or placed above a counter. Its horizontal position is the real information. Destinations are therefore projected onto the walkable surface with **no height limit at all**, and only the horizontal offset is checked against `destinationSnapRadius`.

When several surfaces qualify, the closest one at or below the destination wins, and a surface above is used only when nothing is below. That rule is what makes stacked floors behave, because a destination authored above a floor belongs to that floor rather than the one overhead.

`destinationSnapRadius` is deliberately small. A thin wall plus the navmesh's own erosion from walls is roughly 0.8 m, so a larger radius could snap a destination through a wall into the next room. A destination silently bound to the wrong room is worse than a clear "no route" that tells you to move it.

The viewer's projection uses a different rule, because for the viewer height is real information: it is how one storey is told from another. `IPathfinder` exposes `snapDestination` as optional for this reason, and a custom pathfinder that omits it falls back to `clampToNavMesh`.

### Drawing the path

`buildPathRibbon` converts corners into triangles. It is the only rendering code in the SDK, because it is the only part with no design content and a single correct answer.

| Option             | Default | Description                                                                         |
| ------------------ | ------- | ----------------------------------------------------------------------------------- |
| `width`            | `0.35`  | Ribbon width in metres                                                              |
| `heightAboveFloor` | `0.1`   | Lift above the walkable surface. Raise it if the ribbon flickers against the floor. |
| `cornerRadius`     | `0.4`   | Radius used to round off interior corners. `0` gives sharp corners.                 |
| `cornerSegments`   | `4`     | Points per rounded corner. Higher is smoother and costs triangles.                  |
| `miterLimit`       | `3`     | Cap on how far a sharp joint may extend, as a multiple of half the width.           |

#### The vertex contract

This is stable API, so you can write a shader against it:

* `uv.x` is the distance along the path in **metres**: cumulative, horizontal, and not normalised
* `uv.y` is `0` to `1` across the ribbon width
* two vertices per corner, one quad per segment, indexed, with no normals

Metres rather than a normalised range keeps a pattern's real-world size constant whatever the path length, and lets it flow unbroken across corners instead of restarting per segment. Horizontal distance rather than 3D means a path climbing a ramp does not stretch its pattern.

#### Corners are rounded by default

At a sharp corner the two vertices are offset along the miter, which is the angle bisector, rather than perpendicular to either segment. That makes the quad a trapezoid, so any pattern mapped onto it is sheared by up to half the turn angle, roughly 45 degrees on a right-angle turn. Arrows visibly bend. Rounding spreads the turn over a short arc, which reduces the worst shear from 45 degrees to about 76 degrees, where 90 degrees means no shear at all.

The radius is clamped per corner to 40 percent of the shorter adjacent segment, so tight zig-zags degrade sensibly instead of folding the ribbon back on itself. Endpoints are never moved. Note that a rounded path is slightly shorter than a sharp one, because corners are cut, so `remainingDistance` drops by a few centimetres per corner.

#### Tiling an arrow texture

Arrow size and arrow spacing must be separate controls. Mapping one texture repeat across the whole spacing interval is the obvious shortcut, and it stretches each arrow by spacing divided by width, which for a 0.35 m ribbon at 2 m spacing is 5.7 times. Map the texture over an explicit arrow length and leave the rest of the interval empty:

```glsl
uniform float uArrowSpacing;   // metres from one arrow to the next
uniform float uArrowLength;    // metres one arrow occupies
uniform float uScrollOffset;   // metres travelled, advanced each frame

float along = vUv.x - uScrollOffset;
float cell  = fract(along / max(uArrowSpacing, 0.0001));
float u     = cell * uArrowSpacing / max(uArrowLength, 0.0001);
if (u > 1.0) discard;          // the gap between two arrows

vec4 texel = texture2D(uMap, vec2(u, vUv.y));
```

Default the arrow length to the ribbon width and a square texture comes out undistorted. The [sample](https://github.com/MultiSet-AI/multiset-vps-navigation-needle-sample) has a working implementation of this shader in `NavigationVisuals.ts`.

{% hint style="info" %}
Arrow art is conventionally a silhouette in the **alpha channel** with flat RGB. Treat the texture as a mask and take the colour from a uniform, because multiplying by the texture's RGB gives black arrows whatever colour you set.
{% endhint %}

Advance `uScrollOffset` by `speed * deltaSeconds` each frame and wrap it on `uArrowSpacing`. Accumulating distance rather than elapsed time is what lets the value wrap exactly on one arrow repeat at any speed.

### Tuning

| Option                | Default | Description                                                                          |
| --------------------- | ------- | ------------------------------------------------------------------------------------ |
| `recalcIntervalMs`    | `500`   | How often the route is recomputed. Fires regardless of whether the viewer has moved. |
| `repathMoveThreshold` | `0.5`   | Metres of movement that force an extra immediate recalculation.                      |
| `invalidPathGraceMs`  | `10000` | How long the viewer may be off the walkable surface before navigation gives up.      |
| `arrivalRadius`       | `1.5`   | Metres that count as arrival, measured horizontally.                                 |
| `navMeshSnapDistance` | `4`     | How far to search for walkable ground under the viewer. Also separates floors.       |

Two of these are less obvious than they look. The interval fires **regardless of movement**, because recalculating only while moving cannot notice a route breaking while the user stands still. And arrival is measured **horizontally**, because a 3D distance includes eye height: a destination at floor level sits about 1.6 m below the camera, so a 1.5 m radius would be unreachable from any standing position.

### Inspecting the navmesh

`NavMeshPathfinder` warns at construction when it finds more than one walkable region:

```
[NavMeshPathfinder] The navmesh has 3 disconnected regions. Routes between them will report
unreachable. Expected for separate floors; if not, look for T-junctions or gaps at the seams.
```

That is expected for a genuine multi-floor building, where routes between floors correctly report no route. Otherwise the navmesh is torn.

To see the walkable surface, render the pathfinder's own geometry:

```typescript
const debug = new THREE.Mesh(pathfinder.geometry, new THREE.MeshBasicMaterial({
  color: 0x00ff00, opacity: 0.3, transparent: true,
  depthWrite: false, side: THREE.DoubleSide,
}));
debug.position.y = 0.01;                       // avoid z-fighting the floor
mapSpace.object.add(debug);

// Wireframe on top makes torn seams and T-junctions visible.
mapSpace.object.add(new THREE.LineSegments(
  new THREE.WireframeGeometry(pathfinder.geometry),
  new THREE.LineBasicMaterial({ color: 0x00ff00 }),
));
```

`pathfinder.geometry` is already in map space, so parenting it to the `MapSpace` object puts it exactly where routing thinks the floor is. If the green surface does not line up with the real floor in AR, that mismatch is the bug, not the pathfinding.

### Troubleshooting

Call `diagnose()` first. It names the blocker in one word.

| Result             | Meaning                                                                                                                                                              |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ok`               | Navigation is ready                                                                                                                                                  |
| `no-navmesh`       | No pathfinder, or the navmesh produced no walkable surface                                                                                                           |
| `not-localized`    | The first localization has not succeeded yet                                                                                                                         |
| `off-navmesh`      | The viewer is not within `navMeshSnapDistance` of the walkable surface                                                                                               |
| `no-pois`          | No destinations registered                                                                                                                                           |
| `pois-off-navmesh` | Every destination failed to project onto the surface. Usually the navmesh covers a different area than the destinations, or they were authored in a different frame. |

| Symptom                                       | Cause and fix                                                                                                                                                                                                                            |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Content is misaligned after localizing        | `MapSpace` or the navmesh has a stray Transform. Both must be at identity. Enable `showMesh` on the adapter to compare the scanned map against your content.                                                                             |
| Every destination reports no route            | Enable a navmesh debug overlay built from `pathfinder.geometry` and check the surface is where you expect. Also check `groupCount`.                                                                                                      |
| One destination reports no route, others work | It is more than `destinationSnapRadius` outside the walkable area horizontally. Height is forgiven, sideways placement is not.                                                                                                           |
| The route disappears while walking            | The navmesh is fragmented, so the viewer's projection flips between disconnected regions. Regenerate with a larger Cell Size and Min Region Area around `2`.                                                                             |
| `groupCount` is greater than 1 on one floor   | The navmesh is torn. The usual cause is a T-junction, where a vertex touches another triangle's edge without being shared with it. Two triangles need two shared vertices to count as neighbours, so the surface looks solid but is not. |
| The path never appears, even after localizing | The path mesh is a child of `MapSpace`, which stays hidden until the first localization succeeds. Confirm localization actually succeeded.                                                                                               |
| Arrows look stretched                         | Arrow size is being derived from spacing. Give the shader an explicit arrow length.                                                                                                                                                      |
| Arrows look bent at turns                     | Raise `cornerRadius`, or raise `cornerSegments` for a smoother arc.                                                                                                                                                                      |
| The path flickers against the floor           | Raise `heightAboveFloor`.                                                                                                                                                                                                                |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://multiset.gitbook.io/multiset/webxr-sdk/navigation.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
