Skip to content
Documentation menu
docs / point-clouds · raw .md

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 file streamed straight from storage over HTTP range requests.

Availability: live on npm, 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 run on this package, the point clouds product page covers the managed viewer and the clearance service, and measured 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
PackagePeer 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

InputHow it loadsNotes
Baked payload: a JSON sidecar plus a binary blobcreatePointCloudLayer({ meta, raw }), fetched whole10 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 requestsOne 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 readeropenCopc(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:

arduino
[ 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:

SceneExtentnearMfarM
One junction, a yardunder 150 m120360
A street, a short corridor150 to 600 m200 to 350600 to 1050
A district600 m to 2 km400 to 12001200 to 3600
Aerial, a whole townover 2 km1500+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 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 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.

ConditionWhat happens
No CORS headers, or Range rejected on the preflightCopcTransportError 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 GETcode: "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, 416code: "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 rangecode: "retries-exhausted"
laz-perf not installedcode: "laz-perf-missing", naming the install command
The caller's AbortSignal firescode: "aborted", with name kept as AbortError. isAbortError(err) is true; this is never a failure
CRS unsupported, or no WKT record at allUnsupportedCrsError, naming the CRS and the two ways forward
PDRF outside 6, 7, 8, or the COPC info VLR missingThrows: it is a LAZ, not a COPC. Points at pdal translate
An unknown datum in the WKTWarns once, naming it and the likely offset, and renders
One node fails after retriesWarns once for that node, cools down 30 s, carries on. The scene is coarse there rather than holed
WebGL context lostGeometries 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.deviceMemoryDefault pointBudget
8 GB or more12,000,000 points
4 GB or more6,000,000 points
below 4 GB, or unknown3,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). 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. Everything the answer means, including why unsurveyed ground is never reported as clear, is on the clearance guide.

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

SceneSource and licence
Vienna, SchillgasseCity of Vienna, Kappazunder mobile-mapping survey, CC BY 4.0
Tower Bridge and Brighton seafrontEnvironment Agency National LIDAR Programme, Open Government Licence v3.0
San Francisco, Chinatown and Nob HillUSGS 3D Elevation Program, US public domain
Kawagoe, SaitamaSaitama Prefecture open road point cloud, CC BY 4.0
Osaka, KyobashiOsaka City Construction Bureau, CC-BY
MontrealNatural 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, traffic cops and Chinatown intersectionOuster 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 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.
  • 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.