For the complete documentation index, see llms.txt. This page is also available as Markdown.

Navigation

AR indoor wayfinding on top of VPS localization, with pathfinding and an arrowed floor path

Pick a destination, follow an arrowed path along the floor, arrive. Navigation runs on top of VPS localization and works with both ThreeAdapter and NeedleAdapter.

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.

A complete working app is available as the Needle navigation 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.

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:

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 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.

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:

  1. Download your map's 3D Mesh (.glb) from the developer portal. 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.

Plain Three.js

Unity and Needle Engine

Navigation needs three things in the scene.

1. Scene structure

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.

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.

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 adapter.getSession().getOverlayRoot(). A world-space Canvas still renders correctly for signage, but it cannot be tapped during a session.

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.

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.

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:

Default the arrow length to the ribbon width and a square texture comes out undistorted. The sample has a working implementation of this shader in NavigationVisuals.ts.

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.

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.

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.

Last updated

Was this helpful?