# Point clouds

`@mapmap/points` draws LiDAR and photogrammetry point clouds **inside** a
MapMap map: in MapLibre's own WebGL context, in one draw call, so a cloud
composites with the basemap, the terrain and every other layer rather than
floating in a separate canvas on top. It reads two kinds of input: a small
baked payload you fetch whole, or a [COPC](https://copc.io) file streamed
straight from storage over HTTP range requests.

> **Availability:** live on
> [npm](https://www.npmjs.com/package/@mapmap/points), **0.6.0** (12 October
> 2026). That is the version this page documents and the version this site
> runs. 0.6.0 draws clouds on the globe as well as on mercator; 0.5.0 added
> MapLibre GL JS 6 support and published the clearance module; 0.3.0 added
> the streaming COPC path. Everything since 0.1.0 is additive, with the
> exceptions the changelog shipped in the package
> (`node_modules/@mapmap/points/CHANGELOG.md`) lists under **Changed** and
> **Fixed**: chiefly the peer range (`maplibre-gl` `^5.6.0 || ^6.9.0`, so
> 4.x, 5.0 to 5.5 and 6.0 to 6.8 are out), WebGL 2 being required on
> MapLibre 6, and `three` at 0.186.

See it first: the [live point-cloud scenes](/demo/lidar) run on this
package, the [point clouds product page](/products/point-clouds) covers the
managed viewer and the clearance service, and
[measured clearance](/docs/clearance) is the routing answer built on the
same surveys.

## Install

```bash
npm install @mapmap/points three maplibre-gl
```

Add `laz-perf` as well if you will stream COPC files:

```bash
npm install @mapmap/points three maplibre-gl laz-perf
```

| Package | Peer requirement |
| --- | --- |
| `maplibre-gl` | `^5.6.0 \|\| ^6.9.0` (MapLibre 5.6 and later, or 6.9 and later) |
| `three` | `>=0.160.0 <1.0.0` |
| `laz-perf` | `^0.0.7`, optional: only the COPC path needs it, and it is loaded lazily inside a worker on the first decode |

`three` and `maplibre-gl` are peer dependencies, so provide a single shared
copy of each: two MapLibre instances on one page break the WebGL context,
and two copies of three give you two incompatible sets of classes. The
package is ESM only and needs Node 18 or later to build. On MapLibre 6,
import the module as a namespace (`import * as maplibregl from
"maplibre-gl"`), because MapLibre 6 has no default export.

## A minimal working example

A baked cloud is two files: a JSON sidecar and a binary payload. This draws
one on a MapMap map, coloured by class:

```ts
import "maplibre-gl/dist/maplibre-gl.css";
import { createMap } from "@mapmap/maps";
import { classesFromMeta, createPointCloudLayer } from "@mapmap/points";

const [meta, raw] = await Promise.all([
  fetch("/cloud.json").then((r) => r.json()),
  fetch("/cloud.bin").then((r) => r.arrayBuffer()),
]);

const mm = createMap({
  container: "map",
  apiKey: "snk_...", // from the quickstart
  center: meta.anchor, // [lng, lat], from the sidecar
  zoom: 17,
});
await mm.whenReady();

const cloud = createPointCloudLayer(
  { meta, raw },
  { classes: classesFromMeta(meta.classes), colourMode: "class" },
);
mm.map.addLayer(cloud); // mm.map is the underlying MapLibre map
```

With plain MapLibre instead of `@mapmap/maps`, add the layer once the style
has loaded: `map.on("style.load", () => map.addLayer(cloud))`.

Then drive it from your own UI: `setColourMode("rgb" | "class" | "height")`,
`setClassVisible(byte, on)`, `setAllClassesVisible(on)`,
`setPointSize(metres)`, `setHeightRange(minM, maxM)`, `resetHeightRange()`
and `getFps()`. `getClasses()` returns the resolved class table, which is
what you build a legend from, and `getHeightRange()` returns the band the
height ramp currently spans, which is where a slider starts.

The table's `visible` array is live: it is the array the shader itself
reads, so it always reflects the last `setClassVisible` or
`setAllClassesVisible` call, and a legend reads its checked state straight
off the layer rather than shadowing the filter state alongside it.
`getVisiblePointCount()` is the matching number for a stat readout: how
many points are on screen right now. That is `meta.count` for a layer with
no class table, since nothing is being culled, and otherwise the sum of
`meta.classCounts` over the visible classes. It is `null` when the sidecar
carries no `classCounts`, because the only other way to answer is to walk
the class byte of every point in the payload on the main thread, which is
far too expensive to do on every toggle for a number the producer could
have baked in.

## Supported formats

| Input | How it loads | Notes |
| --- | --- | --- |
| Baked payload: a JSON sidecar plus a binary blob | `createPointCloudLayer({ meta, raw })`, fetched whole | 10 bytes per point, described below. Comfortable to a few million points; past that, use COPC. |
| COPC (`.copc.laz`, LAS 1.4, point data record formats 6, 7 and 8) | `openCopc(url)`, streamed over HTTP range requests | One file per layer. A plain LAZ or LAS that is not COPC is refused with a message naming `pdal translate in.las out.copc.laz --writers.copc`. |
| Your own range reader | `openCopc(rangeGetter)` | Any byte source: pass a `RangeGetter` and no `fetch` of ours runs. |

Not supported in 0.6.0: EPT sources, multi-file mosaics in one layer, and
point data record formats 0 to 5 (refused by name). Rebake those to COPC
first.

## The format: 10 bytes per point

One JSON sidecar and one binary blob:

```
[ count * 3 * Uint16 ]  positions, little-endian
[ count * 4 * Uint8  ]  colour RGB + class in the alpha byte
```

The naive encoding (float64 ordinates, float colours) is about 48 bytes
per point, which puts a million-point corridor at 48 MB before compression.
More importantly, these two blocks reach the GPU untouched: decoding is two
typed-array views over the downloaded `ArrayBuffer`, with no copy and no
per-point loop, and the shader dequantises.

Positions are integers in units of `quant` metres from `origin`, in a local
east/up/south frame. At the default 2 cm a `Uint16` spans 1.3 km per axis.
`validateMeta` **fails** a sidecar whose extent will not fit, because the
alternative is a cloud silently folded back on itself.

The class rides in the alpha byte rather than a fourth attribute buffer: a
`Uint8x4` colour attribute is one interleaved upload, and alpha is dead
weight in an opaque cloud.

`encodePointCloud` exists for clouds built in the browser; production baking
usually happens offline.

The sidecar also carries two free-form provenance strings the SDK never
renders. `source` is attribution; `sensor` is what captured the cloud, such
as `"Ouster OS-1-256-RGB @ 2048x10"`, so a viewer's info panel does not have
to parse it back out of `source` or duplicate it in app config. Both are
carried through encode, decode and validation untouched.

## Classes are yours

Every point carries one class byte, and **this SDK has no opinion about what
those bytes mean.** A survey vendor, a national mapping agency and an ASPRS
LAS file all number their classes differently, and a renderer that assumes
one of them silently mislabels the other two.

So the class table is an input:

```ts
createPointCloudLayer({ meta, raw }, {
  classes: [
    { value: 2,  label: "ground",   colour: "#6b6f76" },
    { value: 6,  label: "building", colour: "#dbd2c0" },
    { value: 40, label: "overhead wire" },   // default palette colour
  ],
});
```

Your byte values survive as your byte values. `40` stays `40`, it is not
reindexed to `2`. The shader is generated per class table, which is what
makes that possible. Duplicate bytes, values outside 0–255 and tables over
`MAX_CLASSES` (16, a bounded uniform scan with a compile-time trip count)
throw, because each of those otherwise renders as points quietly taking a
neighbour's colour with nothing on screen to explain it.

Omit `classes` and you get RGB and height colouring; `setColourMode("class")`
then throws rather than inventing a taxonomy.

`classesFromMeta(meta.classes)` builds that table from a sidecar, and reads
either shape a producer might have written: a map of class byte to label,
`{ "2": "ground", "6": "building" }`, or a list of entries,
`[{ "byte": 2, "label": "ground", "colour": "#6b6f76" }]`, where `color` is
read as well as `colour`. The map is ordered by class byte, since a JSON
object has no order worth trusting; a list is kept in the producer's own
order, because they chose it and it drives the default palette. Anything
that is neither shape throws naming both, and `validateMeta` reports the
same thing at bake time rather than leaving it to surface as a broken class
table at first render. A table longer than `MAX_CLASSES` is truncated with a
warning naming the dropped count, so a viewer handed a 20-class sidecar
still draws something.

## The height ramp

`setColourMode("height")` ramps colour over local height, and by default the
ramp spans the cloud's whole vertical extent. That is right for a clean
cloud and wrong for a real one: a scene with a few below-datum returns and
one tall roof, -6.4 m to 82.5 m on a real street, squeezes everything a
viewer came to look at into a slice of the ramp.

```ts
cloud.setHeightRange(0, 25);   // local metres, live
cloud.resetHeightRange();      // back to bboxLocal.y
```

Or set the band up front with the `heightRange` option, so the first frame
is already right. Bounds are sorted if they arrive the wrong way round and a
collapsed range is widened rather than dividing by zero in the shader; a
non-finite bound throws, and a call on a layer that has been removed from
the map warns and does nothing.

## Fog scales with the scene

Fog distances are in metres from the eye, so one pair of constants cannot
serve both a 4 km aerial capture and a 340 m street. Both bounds default
from the cloud's own horizontal extent: `nearM` is 0.6 of the horizontal
diagonal, floored at 120 m, and `farM` is three times `nearM`.

```ts
createPointCloudLayer({ meta, raw }, {
  fog: { colour: "#dce7f2", nearM: 300, farM: 900 },
});
```

Anything you pass wins, and the two default independently, so `{ colour }`
on its own still gets extent-scaled distances. Set `colour` to your
basemap's fog colour, or the cloud fades towards a grey the map never
reaches. If you would rather choose the distances by hand:

| Scene | Extent | `nearM` | `farM` |
| --- | --- | --- | --- |
| One junction, a yard | under 150 m | 120 | 360 |
| A street, a short corridor | 150 to 600 m | 200 to 350 | 600 to 1050 |
| A district | 600 m to 2 km | 400 to 1200 | 1200 to 3600 |
| Aerial, a whole town | over 2 km | 1500+ | 4500+ |

Fog set too near for the scene is the failure worth knowing about: the cloud
flattens to even grey as soon as the camera pulls back, which presents as a
broken renderer rather than as fog. `autoFogRange(meta.bboxLocal)` is
exported if you want the derived pair as a starting point.

## Terrain

**Bake absolute heights into your payload.** Every point is drawn exactly
where the payload puts it (nothing in the SDK displaces a cloud by terrain)
and MapLibre displaces its own 3D ground by absolute metres. A cloud on
any other datum is therefore off by the height of the hill the moment
terrain is switched on: the street either floats above it or is buried
inside it.

**DEM-subtracted payloads are not supported.** No option makes one render
correctly. If your producer subtracted a DEM, put the heights back before
encoding: estimate the smooth residual between the survey's datum and the
DEM and subtract that, rather than resampling ground, which keeps the
survey's own camber, kerb faces and gradient instead of replacing them with
a resampled surface.

The one terrain field in the sidecar moves the **camera**, not the points.
`absoluteHeights: true` (the default when the field is absent)
reconstructs the eye at an altitude that includes the terrain elevation at
the map centre, which is where it belongs for a cloud whose heights are
absolute. Set it to `false` only for a cloud on a flat z = 0 datum with
terrain off.

That sounds like a detail and is not. MapLibre gives a custom layer a
combined matrix and no world-space camera, so the eye position has to be
derived, and with terrain on, the camera orbits the *terrain surface*, not
the z = 0 plane. Get it wrong in hilly country and every point size and
every fog distance is computed from an eye a hundred metres underground.
The cloud still renders; it just renders wrong, and nothing in the picture
says why.

`terrainRelative` is the deprecated 0.1.0 spelling of `absoluteHeights`,
read as an alias with the same polarity (`absoluteHeights` wins if both are
set) so existing sidecars render identically. Its old documentation
promised that the layer would add terrain elevation back onto
DEM-subtracted points. It never did.

## Globe

From `@mapmap/points` 0.6.0 a point cloud draws on a globe map
(`projection: { type: "globe" }`) as well as on mercator, including the
blend between the two at zoom 11 to 12. There is nothing to configure.

- Mercator rendering is unchanged, to the bit.
- On the globe the cloud sits on a tangent plane at its anchor; the sphere
  curves away by about 2 m at 5 km, below a pixel at any zoom where a 5 km
  cloud fills the screen.
- A streamed cloud chooses its level of detail against the mercator view
  below zoom 12 on a globe map, where a street-scale cloud is a few pixels
  across.
- A cloud on the far side of the planet is hidden by it.

## Streaming COPC

*Added in 0.3.0. Absent from 0.1.0.*

The payload path above is one buffer, fetched whole, and it has a ceiling at
a few million points. `openCopc` is the other path: a
[COPC](https://copc.io) file read directly over HTTP **range requests**, its
own octree traversed each frame by a screen-space-error test, and only the
nodes this camera can resolve fetched, decompressed in a Web Worker pool and
drawn. One URL, no tiling server, no bake step, and the file may be
gigabytes. Our own [Montreal scene](/demo/lidar/montreal) streams 23.7 million points
out of a 233 MB file on somebody else's bucket.

```bash
npm install @mapmap/points three maplibre-gl laz-perf
```

`laz-perf` is an **optional** peer dependency, imported lazily inside the
worker on the first decode, so a page that never opens a COPC downloads no
wasm decoder.

```ts
import {
  ASPRS_LAS14_CLASSES,
  createPointCloudLayer,
  openCopc,
} from "@mapmap/points";

// Two range requests and no points: the LAS public header with its VLR
// block, then the root hierarchy page. Under 64 KB, whatever the file size.
const source = await openCopc("https://example.org/city.copc.laz", {
  source: "Contains information licensed under the Open Government Licence, Canada.",
});

map.fitBounds(source.meta.bboxWgs!);

const cloud = createPointCloudLayer(source, { fog: { colour: "#dce7f2" } });
map.on("style.load", () => map.addLayer(cloud));

// Poll, do not push: the counters change every frame and no readout needs
// them at 60 Hz.
setInterval(() => {
  const s = cloud.getStats()!;
  ui.textContent = `${fmt(s.residentPoints)} of ${fmt(s.totalPoints)} points`;
}, 500);
```

`residentPoints / totalPoints` never reaches 1 and should not pretend to.
The honest label is "2.1M of 23.7M points", not a progress bar to 100 %: a
streamed cloud is a window onto something larger, not a download. The rest
of `StreamStats` is what a diagnostics panel wants, and `busy` is the flag
that says whether anything is still outstanding.

Everything the layer already does still applies. `setColourMode`,
`setClassVisible`, `setPointSize`, `setHeightRange` and `getFps` behave
identically on a streamed source, and `getVisiblePointCount()` is exact and
live rather than read off `classCounts`. Three methods are new and are the
quality controls: `setChannel("rgb" | "intensity")` re-decodes the resident
set with **zero** new range requests while the compressed cache is warm,
`setTargetSse(px)` moves the screen-space-error target, and
`setPointBudget(points)` moves the resident-point ceiling.

**Classes are discovered, not declared, and the numbers are estimates.**
COPC declares no class taxonomy and LAS 1.4 defines no statistics VLR, so an
ASPRS-numbered file and a vendor-numbered one are indistinguishable to a
reader. `meta.classes` and `meta.classCounts` are therefore **absent** on a
streamed source. Ask the points that have arrived instead:

```ts
const seen = cloud.observedClasses();   // [{ value: 2, count: 3469091 }, …]
cloud.setClasses(ASPRS_LAS14_CLASSES);  // a uniform write, not a recompile
```

`observedClasses()` is a **sample**: it describes the nodes decoded so far,
not the file. It is a good sample from the first request, because a COPC
root node is a spatially uniform subsample of the whole cloud by
construction, and it sharpens as levels arrive. Label it as an estimate in
your UI, the way the demo viewer does; a count that moves as the camera
moves is honest only if it says so. `ASPRS_LAS14_CLASSES` is exported and
**never applied by default**: it is a curation of the 14 classes that occur
in national LiDAR, not the standard's 23, and picking it is a claim about
the file that only you can make.

**The CRS comes from the file, never from its filename.** The
`LASF_Projection` WKT record is read and reprojected by a self-contained
kit: geographic, Web Mercator, Transverse Mercator (every UTM zone, BNG,
MTM) and Lambert Conformal Conic 2SP, agreeing with `pyproj` to well under a
millimetre across a zone. Anything else throws `UnsupportedCrsError`, naming
the CRS, with the two ways forward. One of them is to bring your own:

```ts
import proj4 from "proj4";
const from = proj4("EPSG:2056");
openCopc(url, { crs: { toWgs84: (x, y) => from.inverse([x, y]) } });
```

No datum shift is performed, by design. A datum outside a known-safe list
warns once, naming it and the likely offset, and renders anyway: a viewer
that is a metre out is more useful than one that refuses.

### Failure modes

The appeal of COPC is pointing a viewer at somebody else's URL, which is
also where it breaks. A generic "failed to load" is treated as a bug here:
each condition gets its own message, and the recoverable ones do not stop
the scene.

| Condition | What happens |
| --- | --- |
| No CORS headers, or `Range` rejected on the preflight | `CopcTransportError` with `code: "network"`, listing the three headers the server must return and how to tell CORS from an unreachable host in the Network tab |
| Server answers `200` to a ranged GET | `code: "range-unsupported"`: it cannot be streamed at all. Serve it from storage that does ranges (S3, GCS, Azure Blob, any CDN, nginx as shipped) or bake it to a payload |
| `403`, `404`, `416` | `code: "http-status"`, not retried. A signed URL or an `Authorization` header goes through `requestInit` |
| A request delivers nothing for `requestStallMs` (15 s by default) | `code: "stalled"`, and the range is retried |
| Retries spent on one byte range | `code: "retries-exhausted"` |
| `laz-perf` not installed | `code: "laz-perf-missing"`, naming the install command |
| The caller's `AbortSignal` fires | `code: "aborted"`, with `name` kept as `AbortError`. `isAbortError(err)` is true; this is never a failure |
| CRS unsupported, or no WKT record at all | `UnsupportedCrsError`, naming the CRS and the two ways forward |
| PDRF outside 6, 7, 8, or the COPC info VLR missing | Throws: it is a LAZ, not a COPC. Points at `pdal translate` |
| An unknown datum in the WKT | Warns once, naming it and the likely offset, and renders |
| One node fails after retries | Warns once for that node, cools down 30 s, carries on. The scene is coarse there rather than holed |
| WebGL context lost | Geometries are disposed and rebuilt from the decoded cache. Nothing is re-downloaded |

The first two are the ones that will actually happen. Both are the
publisher's server, not your code, which is why the messages name headers
rather than apologise.

### Bundlers

`openCopc` builds its decode workers from
`new URL("./worker.js", import.meta.url)`, and the worker's lazy `laz-perf`
import only resolves if your bundler processes that worker's module graph.
Not every bundler does: Next copies the worker as an **asset** without
processing it, and Vite 8 bundles the worker chunk without the lazy
`laz-perf` import. Treat the three-part setup below as the default whatever
you bundle with: your own worker module that calls `setLazPerfImporter`,
`createWorker` pointing at that module, and `lazPerfWasmUrl` pointing at the
wasm you serve. You will want all three together:

```ts
// your own worker module, so your bundler owns the graph
import { setLazPerfImporter } from "@mapmap/points/worker";
setLazPerfImporter(() => import("laz-perf/lib/worker/index.js"));
```

The `laz-perf` typings reference Emscripten's global types, so add
`@types/emscripten` as a dev dependency if you do not use `skipLibCheck`.

```ts
openCopc(url, {
  createWorker: () =>
    new Worker(new URL("./my-worker.ts", import.meta.url), { type: "module" }),
  // Emscripten looks for its wasm beside its glue, and after bundling its
  // glue is in a hashed chunk. Serve laz-perf/lib/worker/laz-perf.wasm and
  // say where.
  lazPerfWasmUrl: "/vendor/laz-perf/laz-perf.wasm",
});
```

Without them the failure is silent rather than loud: nothing is logged, no
request fails, and the scene stays empty while the network and the
georeferencing both work.

## Browser and device budgets

The layer needs WebGL 2 on MapLibre 6, which dropped the WebGL 1 fallback;
in practice it has only ever run on a WebGL 2 context on MapLibre 5 too.

**A baked payload is all resident.** One buffer, one draw call, fetched
whole: comfortable to a few million points on a modern GPU, and the file
size is the point count times 10 bytes plus the sidecar. Past that, stream
a COPC.

**A streamed cloud runs to a resident point budget**, and the default comes
from the device:

| `navigator.deviceMemory` | Default `pointBudget` |
| --- | --- |
| 8 GB or more | 12,000,000 points |
| 4 GB or more | 6,000,000 points |
| below 4 GB, or unknown | 3,000,000 points |

`deviceMemory` is Chromium-only, so Safari and Firefox take the
conservative tier unless you pass `pointBudget` yourself. The decoded-point
cache (`cacheBytes`) is sized from the budget and capped at 256 MB, and a
compressed-node cache (64 MB by default) is what lets a colour-channel
switch re-decode without a single new request. `pointBudgetForDevice()`,
`cacheBytesForBudget()` and `deviceMemoryGb()` return the same defaults, so
a quality slider can start where the SDK would have started.

Four layer options steer it, all for streamed sources only:

- `targetSsePx` (default `2`): the screen-space-error target in device
  pixels. Higher is coarser and cheaper. `setTargetSse(px)` moves it live.
- `pointBudget`: the resident ceiling above. `setPointBudget(points)` moves
  it live.
- `budgetRamp` (off by default): a scene whose budget is large opens at a
  smaller one so the near field sharpens in a second or two, then grows to
  the full budget. Phones and mid-tier budgets ignore it.
- `adaptiveBudget` (off by default): samples the real frame rate and moves
  the budget to hold it. `true` may only shrink the budget, to a floor of a
  quarter of where it started; `{ min, max, targetFps }` (default target
  about 50 fps) may also grow it up to `max`.

On the network side, `openCopc` keeps at most 6 range requests in flight to
the file's origin (`maxConcurrent`), merges ranges closer than 32 KB into
one request up to 4 MB (`coalesceGapBytes`, `coalesceMaxBytes`), and treats
a request that stalls for 15 seconds as failed and retries it
(`requestStallMs`). Decoding runs in a pool of
`clamp(hardwareConcurrency - 2, 2, 4)` Web Workers (`workers`).

## Clearance on route

> **Availability:** published since **0.5.0**, and in 0.6.0. The module is
> `points-sdk/src/clearance/`: the types, `formatClearance`, `formatLateral`
> and `reportToGeoJson`, with their tests. This site's own clearance viewer
> imports it from the package, which is how the code below is exercised.

The gateway can answer whether a vehicle passes under what a survey actually
measured, rather than under what the map records
([`POST /v1/clearance/along`](/docs/api-reference#measured-clearance-along-a-route)).
This module is the client half of that answer: the wire types, and two pure
functions for putting one in front of a person.

It does not fetch, it does not render, and it **does not compute a
clearance**. The arithmetic lives once, server-side, so there is a single
implementation of the safety-critical part rather than a second one drifting
in a browser.

```ts
import { formatClearance, reportToGeoJson } from "@mapmap/points";
import type { RouteClearanceReport } from "@mapmap/points";

const report: RouteClearanceReport = await fetch(`${BASE}/v1/clearance/along`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
  body: JSON.stringify({ geometry_polyline6: shape, vehicle_height_m: 4.2 }),
}).then((r) => r.json());

// The corridor, its coverage gaps and its measurement points, ready for
// map.addSource(). Pure: same report in, same features out.
map.getSource("clearance").setData(reportToGeoJson(report));

if (report.height.verdict === "fail") {
  // "4.31 m measured (safe bound 4.12 m, surveyed Jan 2026)"
  label.textContent = formatClearance(report.height.limiting.clearance);
}
```

Four rules are enforced by the type system rather than by this page, because
a caveat in prose is a caveat somebody skips.

**A `pass` cannot carry coverage gaps.** `Coverage` is two distinct shapes
and the passing verdict admits only the complete one, which has no field for
gaps. A route that passes over ground nobody surveyed is a state the module
cannot represent. It is the typed version of the elevation API's rule that a
missing height is `null` and never a guess.

**A measured clearance carries no signed or legal height.** A survey measures
where a structure is; a sign states what you are permitted to do, with
somebody's margin already inside it. `MeasuredClearance` has no field for the
second, and a compile-time assertion keeps it that way. Signed heights appear
in `TagComparison` and nowhere else, where the verdict being reported is
about the tag.

**A figure cannot be displayed without its bound.** `formatClearance` takes
the whole measurement rather than a number, so you cannot format what you do
not hold, and it returns `ClearanceText`: a branded string a hand-built
`"4.62 m"` is not assignable to. Type a label as `ClearanceText` and it can
only be fed from here. Both figures round **down** to the centimetre, because
rounding a clearance up to make a label tidy is inventing headroom that was
never measured. Non-finite figures, negative bounds and a safe bound above
the measured value all throw rather than rendering as a confident label.

**The two axes do not share a measurement type.** A headroom metre and a
clear-width metre are different quantities, so `MeasuredLateral` has no
headroom field, `MeasuredClearance` has no clear width, and `formatLateral`
will not take the other's input. A lateral distance cannot be captioned as
overhead clearance by passing the wrong flag, because there is no flag.

`reportToGeoJson(report)` returns the corridor as a `ClearanceGeoJson`: the
route ribbon split by verdict, coverage gaps and advisory runs as their own
line features, and the limiting, contested and tightest points as point
features. Line features carry `ClearanceLineProperties`, points carry
`ClearancePointProperties` or `LateralPointProperties`, and every one of them
carries a `ClearanceStatus` to style on, so a map layer branches on a
property rather than re-deriving the verdict. The formatted text is already
in the properties, bound included.

Two groups of types here are **contracts shipped ahead of the mechanism that
fills them**, and are listed as such rather than left to look live.
`TagAuditReport`, `TagComparison` and `OsmDraftEdit` describe the comparison
between what the map claims and what a survey measured; no gateway route
serves one yet. `EpochRegistration`, `ChangeReport`, `ChangeEvent` and
`ChangeKind` describe change detection between survey epochs, which is the
same. Both are here so that a client written against them does not have to
be rewritten when they arrive.

Full export list, with every field, is in the
[API reference below](#api-reference). Everything the answer means, including
why unsurveyed ground is never reported as clear, is on the
[clearance guide](/docs/clearance).

## Limits

- **The payload path is not an LOD system.** One buffer, one draw call,
  everything resident, payload fetched whole. Comfortable to a few million
  points on a modern GPU; past that, bake to COPC and use `openCopc`, or
  split the area into several clouds and add or remove layers by viewport.
- **One COPC per layer.** Multi-file mosaics, and EPT sources, are not in
  0.6.0.
- **Vertical datums are the caller's problem.** LAS `Z` is used as-is;
  `openCopc(url, { heightOffsetM })` shifts the whole cloud if you know the
  offset. DEM-subtracted payloads remain unsupported.
- **Not a reprojection tool.** The sidecar's `anchor` is trusted as
  lng/lat. If you are unsure a dataset's declared CRS matches its
  coordinates, check it first. The `validate_geodata` MCP tool and
  [`POST /geodata/validate`](/docs/api-reference#validating-a-datasets-crs)
  exist for exactly that.
- `three` and `maplibre-gl` are peer dependencies. Provide a single shared
  copy of each: two MapLibre instances on one page break the WebGL context,
  and two copies of three give you two incompatible sets of classes.

## Live demos and the sample data

Every scene at [/demo/lidar](/demo/lidar) is drawn by this package, and
each one is a dated third-party survey published under its own terms. The
credit is rendered on the canvas and no viewer control removes it.

| Scene | Source and licence |
| --- | --- |
| [Vienna, Schillgasse](/demo/lidar/vienna) | City of Vienna, Kappazunder mobile-mapping survey, CC BY 4.0 |
| [Tower Bridge](/demo/lidar/tower-bridge) and [Brighton seafront](/demo/lidar/brighton) | Environment Agency National LIDAR Programme, Open Government Licence v3.0 |
| [San Francisco, Chinatown and Nob Hill](/demo/lidar/san-francisco) | USGS 3D Elevation Program, US public domain |
| [Kawagoe, Saitama](/demo/lidar/saitama) | Saitama Prefecture open road point cloud, CC BY 4.0 |
| [Osaka, Kyobashi](/demo/lidar/osaka-kyobashi) | Osaka City Construction Bureau, CC-BY |
| [Montreal](/demo/lidar/montreal) | Natural Resources Canada and the Communauté métropolitaine de Montréal (2023 acquisition), Open Government Licence, Canada. 23.7 million points streamed from a 233 MB COPC file read straight from NRCan's own bucket: the COPC path end to end, with no copy on our side |
| [Balmy Alley](/demo/lidar/balmy-alley), [traffic cops](/demo/lidar/traffic-cops) and [Chinatown intersection](/demo/lidar/chinatown) | Ouster sample library (studio.ouster.com), CC BY-NC-SA, published here with Ouster's written permission. That permission covers these three recordings on this site; the licence does not extend it to you |

[/demo/vienna](/demo/vienna) is the Vienna scene on its own route, kept as
the SDK walkthrough. If you reuse any of these datasets in your own app,
take the licence from the publisher, not from this table, and carry the
credit with the data.

## API reference

Every public export of `@mapmap/points`, grouped by the job it does.

**The layer.** `createPointCloudLayer(source, options?)` returns a
`PointCloudLayer`: a MapLibre `CustomLayerInterface` with the extra methods
listed above (`setColourMode`, `setClassVisible`, `setAllClassesVisible`,
`setPointSize`, `setHeightRange`, `resetHeightRange`, `getHeightRange`,
`getFps`, `getClasses`, `getVisiblePointCount`, and a readonly `meta`), plus
the streaming controls `getStats`, `observedClasses`, `setClasses`,
`setChannel`, `setTargetSse` and `setPointBudget`.

- `PointCloudSource` is what `source` may be: a `DecodedPointCloud`, a
  `{ meta, raw }` pair which is decoded and validated for you, or, from
  0.3.0, a `StreamingSource` such as the `CopcSource` that `openCopc`
  returns. The three are interchangeable at the call site, which is what
  lets a viewer carry baked and streamed scenes down one code path.
- `PointCloudLayerOptions` configures a layer: `id` (default
  `"mapmap-point-cloud"`), `classes` (a `PointClass[]`), `colourMode`
  (default `"rgb"`), `pointSizeM` (world size of one point, default `0.15`),
  `maxPointPx` (screen-size cap in device pixels, default `10`, raise it for
  offline video bakes), `heightRange` (`{ minM, maxM }`, default the cloud's
  own vertical extent) and `fog` (`{ colour?, nearM?, farM? }`, where each
  distance defaults on its own from the cloud's horizontal extent). For a
  streamed source only: `pointBudget`, `cacheBytes`, `targetSsePx` (default
  `2`), `budgetRamp` and `adaptiveBudget` (`AdaptiveBudget`, `{ min, max,
  targetFps? }`, or `true`), described under
  [browser and device budgets](#browser-and-device-budgets).
- `ColourMode` is `"rgb" | "class" | "height"`: the payload's own colours,
  flat per-class colours, or a ramp over local height.
- `getClasses()` returns the layer's live `ResolvedClasses`, or `null` for a
  layer created without a class table. Its `visible` array is the one the
  shader reads, so it never goes stale behind `setClassVisible` or
  `setAllClassesVisible`.
- `getVisiblePointCount()` returns how many points are currently drawn:
  `meta.count` when there is no class table, otherwise the sum of
  `meta.classCounts` over the visible classes, or `null` when the sidecar
  carries no `classCounts`.
- `localOffset(anchor, lngLat)` returns the `{ x, z }` offset in metres (x
  east, z south) of a lng/lat from a cloud's anchor, using MapLibre's
  Mercator maths.

**Streaming a COPC** (added in 0.3.0; absent from 0.1.0).

- `openCopc(source, options?)` reads a COPC's LAS public header and root
  hierarchy page and fetches no points, returning a `CopcSource`. `source`
  is a URL, or a `RangeGetter` if you want to read the bytes yourself.
- `CopcOpenOptions` is every knob on the read: `anchor`, `crs` (a
  `{ toWgs84(x, y) }` hook, which `proj4` satisfies as-is),
  `heightOffsetM`, `channel`, `workers`, `createWorker`, `lazPerfWasmUrl`,
  `cacheBytes`, `compressedCacheBytes`, `coalesceGapBytes`,
  `coalesceMaxBytes`, `requestStallMs`, `maxConcurrent`, `requestInit` (auth
  headers, credentials, a cache mode), `getRange` and `getPageRange`, `source` and `sensor` (what
  `meta.source` and `meta.sensor` say, which is where the credit goes),
  `onWarn` and `signal`.
- `CopcSource` is the opened file: `kind: "copc"`, `url`, `meta`, the
  `channels` its PDRF allows and the `channel` in the colour bytes now,
  `setChannel(channel)`, a `stats` ledger, the parsed `header`, `info` and
  projection `descriptor`, and `dispose()`. **Call `dispose()`**: a source
  owns a worker pool and its in-flight sockets, and a scene switch that
  walks away from one leaks both.
- `CopcChannel` is `"rgb" | "intensity"`: which scalar lands in the three
  colour bytes. A *channel* is what is in the bytes and changing it
  re-decodes; a *colour mode* is how the shader paints them and changing it
  is a uniform write.
- `RangeGetter` is `(begin, end, signal?) => Promise<Uint8Array>`, with
  `end` exclusive. It is the whole I/O seam: pass one and no `fetch` of ours
  runs.
- `CopcCloudMeta` is the metadata synthesised from the header and the COPC
  info VLR. It is a `PointCloudMeta` (so `anchor`, `origin`, `quant`,
  `count`, `bboxLocal`, `bboxWgs`, `source`, `sensor` all mean what they
  mean everywhere else) plus `streaming: true`, `totalPoints`, `maxLevel`,
  `spacingM`, `pdrf`, `hasRgb`, `hasNir`, the file's own `wkt` and the
  `projection` family used to place it. `classes` and `classCounts` are
  deliberately absent. `quant` is the **root node's**, descriptive only:
  every node carries its own, so code that multiplies by it is wrong for
  all the others.
- `StreamCloudMeta` is the container-agnostic half of that (`streaming`,
  `totalPoints`, `spacingM`, `maxLevel`), and `StreamingSource` is the
  container-agnostic source `createPointCloudLayer` consumes. COPC is the
  only container in 0.6.0; the pair exists so a second one is not a second
  code path. `isStreamingSource(source)` is the type guard, for a viewer
  branching on which kind of cloud it was handed.
- `getStats()` returns `StreamStats` for a streamed layer and `null` for a
  baked one: `residentPoints`, `residentNodes`, `selectedNodes`,
  `totalPoints`, `pendingRequests`, `cacheBytes`, `cacheBudgetBytes`,
  `failedNodes`, `maxLevelResident` and `busy` (true while anything is
  outstanding, which is the repaint condition).
- `observedClasses()` returns `ObservedClass[]`, each `{ value, count }`:
  the class bytes present in the **resident** set right now, counted from
  points the workers decoded anyway. A sample, not a census. `setClasses(table)`
  names and colours them afterwards.
- `CopcDataStats`, on `source.stats`, is the request ledger: `requests`,
  `nodeRequests`, `pageRequests`, `bytes`, `wastedBytes` (bytes inside a
  coalesced span nobody asked for), `nodesDecoded`, `nodesFailed`,
  `cacheHits`, `decodedCacheBytes` and `compressedCacheBytes`. It is what a
  proof run measures and what tells you whether a channel switch really
  cost zero requests.
- `ASPRS_LAS14_CLASSES` is a ready-made `PointClass[]` for files that follow
  the standard, `ASPRS_LAS14_CLASS_LABELS` is the byte-to-label map behind
  it and `asprsClassLabel(value)` is the single lookup, `undefined` for a
  byte the curation does not name. Never applied by default, on either path.
- `pointBudgetForDevice(deviceMemoryGb?, override?)` and
  `cacheBytesForBudget(pointBudget, override?)` are the defaults behind
  `pointBudget` and `cacheBytes`, and `deviceMemoryGb()` reads
  `navigator.deviceMemory` without assuming a DOM. It is Chromium-only, so
  `undefined` takes the conservative tier rather than the optimistic one.
  Together they are where a quality slider should start.

**Streaming errors.** Every one names what to do next, and all of them are
throwable from `openCopc` or reported through `onWarn`.

- `CopcTransportError` carries a `code` (`CopcTransportErrorCode`), plus the
  `status` and `url` when there were any. The codes are
  `"range-unsupported"`, `"network"`, `"stalled"` (a request delivered
  nothing for `requestStallMs` and was abandoned), `"http-status"`,
  `"retries-exhausted"`, `"aborted"`, `"laz-perf-missing"` and
  `"invalid-request"`. Branch on the code: CORS, a server that ignores
  `Range` and a plain 404 need three different fixes.
- `isAbortError(err)` is true for our `"aborted"` code and for the DOM
  `AbortError` and `TimeoutError`, so one check covers a cancelled load
  however it was cancelled. An abort is never a failure and is never
  retried.
- `UnsupportedCrsError` carries `crsName`, `epsg` and `family`. Its message
  names the CRS rather than saying "unsupported projection", because the
  name is what sends a developer to the two fixes under it: supply a `crs`
  hook, or reproject the file.

**The wire format** (pure, no WebGL or MapLibre).

- `decodePointCloud(meta, raw)` points two zero-copy typed-array views at a
  payload after checking its byte length, returning a `DecodedPointCloud`
  (`{ positions: Uint16Array, colours: Uint8Array, meta }`). It throws on a
  truncated or mismatched payload rather than rendering a partial cloud.
- `PointCloudMeta` is the JSON sidecar: `anchor` (`[lng, lat]`), `quant`
  (metres per position unit), `origin`, `count`, `bboxLocal` (a `LocalBox`),
  optional `bboxWgs`, `classes` (a `MetaClassTable`, either shape),
  `classCounts`, `absoluteHeights` (default `true`; `terrainRelative` is its
  deprecated alias), `source` and `sensor`.
- `absoluteHeightsFromMeta(meta)` resolves that pair to the single boolean
  the layer uses, preferring `absoluteHeights` and warning once if a sidecar
  still spells it `terrainRelative`.
- `validateMeta(meta)` returns the problems with a sidecar, an empty array
  meaning usable, and covers the class table's shape and the provenance
  strings' types as well as the geometry. `classTableProblems(classes)` is
  the class-table half on its own, for a baker checking one table.
- `LocalBox` is the local bounding box in metres: `{ x, y, z }`, each a
  `[min, max]` pair, in the cloud's east/up/south frame.
- `BYTES_PER_POINT` is `10` (3 x Uint16 positions + 4 x Uint8 colour), and
  `payloadBytes(count)` returns the byte length a payload of `count` points
  must have.

**Classes.**

- `PointClass` is one row of a class table: `{ value, label, colour?,
  visible? }`, where `value` is the class byte and `colour` is `#rrggbb` or
  an `[r, g, b]` triple in 0 to 1.
- `resolveClasses(classes)` normalises a `PointClass[]` into the flat arrays
  the shader wants, returned as `ResolvedClasses` (`{ values, labels,
  colours, visible, lookup }`). It throws on duplicate bytes, a byte outside
  0 to 255, or a table over `MAX_CLASSES`.
- `classesFromMeta(meta.classes)` builds a `PointClass[]` from either
  sidecar shape, `MetaClassTable`: a `Record<string, string>` of class byte
  to label, or a `MetaClassEntry[]` of `{ byte, label, colour? }` (with
  `color` read too). It returns an empty table for an absent one, truncates
  past `MAX_CLASSES` with a warning, and throws on any third shape.
- `visiblePointCount(classes, meta.classCounts)` is the pure helper behind
  `getVisiblePointCount()`, for code holding a `ResolvedClasses` of its own.
  A visible class the producer never counted contributes nothing, so a
  partial `classCounts` under-reports rather than throwing.
- `DEFAULT_PALETTE` is the built-in, colour-blind-aware palette used when a
  class declares no explicit colour; its early entries stay separable under
  common colour-vision deficiencies. `MAX_CLASSES` is `16`: a bounded
  uniform scan with a compile-time trip count, which the shader is generated
  against. It is a product decision rather than a WebGL-version artefact, so
  MapLibre 6 and WebGL 2 do not widen it; a wider producer taxonomy needs a
  texture lookup and a different shader.

**Scene ranges** (pure, the defaults the layer applies).

- `autoFogRange(bboxLocal)` returns the `FogRange` (`{ nearM, farM }`) a
  cloud gets when `fog.nearM` or `fog.farM` is unset: `MIN_FOG_NEAR_M`
  (`120`) or `FOG_NEAR_DIAGONAL_FRACTION` (`0.6`) of the horizontal
  diagonal, whichever is larger, and `FOG_FAR_MULTIPLE` (`3`) times that.
- `defaultHeightRange(bboxLocal)` returns the `MetreRange`
  (`{ minM, maxM }`) the height ramp spans unsteered, widened to
  `MIN_HEIGHT_SPAN_M` (`8`) for a flat cloud, and
  `clampHeightRange(minM, maxM)` is what `setHeightRange` puts a caller's
  bounds through: sorted, a collapsed range widened, a non-finite one
  thrown.

**Camera reconstruction** (used internally by the layer, exported for custom
renderers).

- `CameraView` is the subset of a MapLibre map the maths reads: `centre`,
  `zoom`, `bearing`, `pitch`, `fovDeg?`, `bufferHeightPx` (device pixels,
  not CSS) and `centreElevationM?`.
- `eyePosition(view, centre)` returns the `EyePosition` (`{ x, y, z,
  distanceM }`) in the cloud's local frame, raising the eye by the terrain
  height at the centre when terrain is on.
- `LocalCentre` is where the map centre sits in the local frame (`{ x, z }`).
- `stickyElevation(sampled, remembered)` carries the last elevation the DEM
  actually answered with across the frames where `queryTerrainElevation`
  returns `null` (before a tile has loaded), so the eye never drops to sea
  level and pops back up when it arrives. `null` only until the first real
  sample.
- `metresPerPixel(lat, zoom)` returns the ground metres one screen pixel
  covers, and `pointSizeScale(worldSizeM, bufferHeightPx, fovDeg?)` returns
  the device pixels a point of that world size subtends at one metre (the
  shader divides by distance so points shrink with depth).
- `DEFAULT_FOV_DEG` (`36.87`) is MapLibre's default vertical field of view,
  the fallback when `CameraView.fovDeg` is unset.

**Clearance on route** (types and pure functions; nothing here computes a
clearance, and nothing here fetches or renders).

- `RouteClearanceReport` is the answer for one route and one vehicle:
  `basis`, a `ClearanceEnforcement` block, the route, the `VehicleQuery`,
  a `RouteVerdict` for height, a `RouteVerdict` or `not_assessed` for
  width, the datasets consulted, advisories and an `explanation`.
- `RouteVerdict` is the height answer: `pass`, `fail`, `indeterminate` or
  `no_verdict`. A `pass` accepts only complete `Coverage`, so a route that
  passes over unsurveyed ground is a state the type cannot represent. An
  `indeterminate` carries `IndeterminateReason` values beside its sentence.
- `WidthVerdict` is the width answer, and a separate type carrying
  separate measurements.
- `Coverage` is `complete` (no field for gaps) or `partial` (with
  `not_surveyed` and `insufficient_data` runs, each a `RouteSection`).
- `MeasuredClearance` is one overhead measurement and its bound:
  `headroom_m`, `sigma_m`, `sampling_gap_m`, `safe_headroom_m`, an
  `OverheadClass`, `support_m2` and `surveyed_on`. It has no field for a
  signed or legal height. `LimitingPoint` places one on the route with a
  viewer deep link.
- `MeasuredLateral` is one lateral measurement: `clear_width_m`,
  `sigma_m`, `sampling_gap_m`, `safe_clear_width_m`, an
  `ObstructionClass` in `limited_by`, `support_m2` and `surveyed_on`. It
  has no headroom field, so a clear width cannot be captioned as
  overhead clearance. `LateralPoint` places one on the route.
- `formatClearance(clearance)` and `formatLateral(lateral)` render a
  measurement as text, bound included, and return `ClearanceText`, a
  branded string a hand-built label cannot be substituted for. Each takes
  only its own measurement type.
- `reportToGeoJson(report)` returns a `ClearanceGeoJson`: the route
  ribbon, coverage gaps, advisory runs and measurement points, as
  `ClearanceFeature` values carrying `ClearanceLineProperties`,
  `ClearancePointProperties` or `LateralPointProperties`, and a
  `ClearanceStatus` for styling.
- `TagAuditReport` compares what OpenStreetMap claims against what the
  survey measured: `TagComparison` records and `OsmDraftEdit` drafts.
- `ClearanceFieldSidecar` is the baked artefact's sidecar, including its
  mandatory `DatumBlock` and `VerticalBasis`.
- `EpochRegistration`, `ChangeReport`, `ChangeEvent` and `ChangeKind` are
  the change-detection contracts, shipped ahead of the mechanism.

## Related

- [Point clouds product page](/products/point-clouds): the SDK, the managed
  viewer and the clearance service.
- [Measured clearance on a route](/docs/clearance): the gateway half of the
  clearance types above.
- [Truck restriction data, measured](/docs/truck-data-audit): what the map
  records about heights and widths, and where a survey adds to it.
- [SDKs and installation](/docs/sdks): the other packages, and per-vehicle
  or per-MAU licensing.
