copc-tileset-provider
v0.10.0
Published
Stream static COPC point clouds into CesiumJS with no pre-tiling step.
Maintainers
Readme
copc-tileset-provider
Stream static COPC point clouds into CesiumJS — no pre-tiling, no backend, no conversion step.
A COPC file is a LAZ file whose points are already sorted into an octree. That means the parts you need can be read with HTTP Range requests from any static host — S3, nginx, GitHub Pages. This library maps that octree onto Cesium's own 3D Tiles engine as it loads, so traversal, level of detail, request priority, caching, styling and picking all stay Cesium's.
Point it at a URL and it renders:
COPCTilesetProvider.registerCrs(2992, '+proj=lcc +lat_0=41.75 +lon_0=-120.5 …');
const provider = await COPCTilesetProvider.fromUrl(
'https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz',
);
viewer.scene.primitives.add(provider);
viewer.camera.flyTo({ destination: provider.extent });Install
npm install copc-tileset-provider cesiumCesium is a peer dependency, >=1.142.0 <1.145.0. Both ends of that range are
rendered in a real browser before it is widened.
Quick start
Register the file's coordinate system before opening it — see Coordinate systems.
import { Viewer } from 'cesium';
import { COPCTilesetProvider } from 'copc-tileset-provider';
// EPSG:2992 — Oregon Statewide Lambert, the system Autzen is stored in.
COPCTilesetProvider.registerCrs(
2992,
'+proj=lcc +lat_0=41.75 +lon_0=-120.5 +lat_1=43 +lat_2=45.5 ' +
'+x_0=399999.9999984 +y_0=0 +datum=NAD83 +units=ft +no_defs',
);
const viewer = new Viewer('cesiumContainer');
const provider = await COPCTilesetProvider.fromUrl(
'https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz',
);
viewer.scene.primitives.add(provider);
viewer.camera.flyTo({ destination: provider.extent });That URL is not a placeholder: it is the public Autzen scan, 81 MB, of which opening the file reads about 10 KB.
provider is a Cesium primitive — scene.primitives.add takes it directly.
A complete, runnable example is in examples/; it is the
screenshot at the top.
Coordinate systems
Only EPSG:4326 is known by default. Every other system has to be registered once, before the file is opened:
COPCTilesetProvider.registerCrs(2992, '<proj4 definition>');The library reads the EPSG code out of the file's WKT and looks it up. It does not feed the WKT to proj4 directly: proj4 either throws on some dialects (measured, on Autzen's compound WKT) or silently produces wrong coordinates when datum information is missing, and there is no way to tell which in advance. A registered definition is the only input somebody has vouched for.
An unregistered system fails with an error that names it and hands you the call to paste, including where to find the definition:
This file uses EPSG:2992, which is not registered. […]
registerCrs(2992, '<proj4 definition>');
The definition for EPSG:2992 is at https://epsg.io/2992 […]A registered definition's accuracy is the registrant's; this library applies what it is given.
Your server has to support Range requests
Every read is an HTTP Range request, and every response is verified.
- The host must serve
206. Most static hosts do; some CDNs and proxies strip Range support on compressed responses. A200is refused rather than accepted as a fallback — it is the whole file, which is what streaming exists to avoid. - Cross-origin, the check is weaker. Browsers hand JavaScript only the
CORS-safelisted response headers, and
Content-Rangeis not one of them unless the server sendsAccess-Control-Expose-Headers: Content-Range— which no public COPC dataset does. With the header, the range is checked against it exactly; without it, on the status and the exact length of the body, which cannot confirm which bytes came back. Send the header if you control the host and want the stronger check.
Styling and picking
Tiles are standard Cesium content, so the engine's own tools work unchanged:
import { Cesium3DTileStyle } from 'cesium';
provider.tileset.style = new Cesium3DTileStyle({
color: "${Classification} === 2 ? color('brown') : color('green')",
show: '${Intensity} > 30',
});Each point carries these batch-table properties:
| Property | Type | From |
|---|---|---|
| Classification | uint8 | LAS classification |
| Intensity | uint16 | LAS intensity |
| GpsTime | float64 | LAS GPS time |
| ReturnNumber | uint8 | LAS return number |
| NumberOfReturns | uint8 | LAS number of returns |
Unstyled, points take the file's own colour. LAS point format 6 carries none, so such a file renders in Cesium's constant dark grey until a style gives it a colour — every property above is still there to style on.
Picking goes through Cesium's own scene.pick. Every point carries a
BATCH_ID, which is what lets a picked point resolve to the properties above.
Limits
Heights are ellipsoidal. Every Z is height above the WGS84 ellipsoid. Orthometric data — most surveyed LiDAR — sits at a visible vertical offset until you pass the geoid separation at your dataset's location, in metres:
await COPCTilesetProvider.fromUrl(url, { geoidHeight: -23.333 });One constant for the whole file, so it holds where the separation does not vary
— a survey site, not a continent. Grid-based correction is out of scope for v1.
A file that declares a vertical CRS and gets no geoidHeight loads anyway, with
a console warning naming the code. That check cannot tell an already-ellipsoidal
vertical CRS from a geoid-referenced one, so pass geoidHeight: 0 to silence
it rather than omitting the option.
Content is PNTS, which is 3D Tiles 1.0 legacy, superseded by glTF-based content in 3D Tiles 1.1. Chosen deliberately: a Worker can hand-encode PNTS — a header, a feature table, a binary body — where glTF has to be assembled, and its batch table is what gives Cesium's style language and picking. glTF is on the roadmap after v1.
A strict worker-src CSP blocks the default Worker. It is built from a
bundle inlined into the library and loaded from a blob: URL, so nothing has
to be served or configured — but a policy that forbids blob: refuses it, and
the library cannot work around that. Supply your own Worker instead:
// 1. Your own Worker module — the subpath installs itself when evaluated.
// your-worker.js:
import 'copc-tileset-provider/worker';
// and where you build the provider:
import { browserPort } from 'copc-tileset-provider';
await COPCTilesetProvider.fromUrl(url, {
spawnWorker: () =>
browserPort(new Worker(new URL('./your-worker.js', import.meta.url), { type: 'module' })),
});A bundler that ignores browser fields will fail to build. Your bundler
resolves laz-perf itself, and what keeps it off laz-perf's Node build — which
reaches for require("fs") — is that package's own
"browser": "lib/web/index.js". Vite and webpack honour it by default, esbuild
when its platform is browser, plain Rollup only with
@rollup/plugin-node-resolve set to { browser: true }. Otherwise alias
laz-perf to laz-perf/lib/web/index.js. Only the Vite path is measured — the
publish smoke builds with it.
Cesium 1.141 and earlier is not a choice: the _runtimeContentCodec slot this
library installs onto arrived in 1.142, so on anything older the mechanism it
depends on does not exist.
API
COPCTilesetProvider.fromUrl(url, options?)
Opens the file and returns a provider. Reads metadata and the root hierarchy page — three Range requests — before resolving.
| Option | Default | What it does |
|---|---|---|
| maximumScreenSpaceError | 16 | Cesium's own quality knob, passed through. Lower means more tiles and more detail. |
| workerPoolSize | 4 | How many Workers decode in parallel. |
| spawnWorker | bundled Worker | Supply your own Worker, as a WorkerPort. See Limits. |
| fetch | globalThis.fetch | Every Range request goes through this. Use it to add auth headers, sign URLs, or route through a proxy. |
| signal | — | Aborts the three reads fromUrl makes. Tile requests are cancelled by Cesium itself. |
| geoidHeight | — (HAE) | The geoid's separation from the WGS84 ellipsoid at this file's location, in metres, added to every height. Omit it for a file whose Z is already ellipsoidal. See Limits. |
Provider
| Member | Type | What it is |
|---|---|---|
| tileset | Cesium3DTileset | The live tileset. Styling, events and traversal settings go here. |
| extent | Rectangle | The file's measured extent, for camera framing. Not the inflated tile bounds. |
| stats() | ProviderStats | Range counters, budget admissions, and registry size. |
| destroy() | void | Releases the tileset, the Workers and every reservation. Idempotent. |
COPCTilesetProvider.registerCrs(code, proj4Definition)
Teaches this process one coordinate system. Static, because it has to be callable before any file is opened.
browserPort(worker)
Wraps a browser Worker as the WorkerPort that spawnWorker must return.
copc-tileset-provider/worker
The Worker realm's entry point. Importing it inside a Worker installs the message handler; it does not reach Cesium.
Errors
Every failure is a typed class exported from the package root, each carrying a
code and a message that names the fix. Catch CopcTilesetError for all of
them, or a specific class for one.
Contributing
How to run the suite, what review looks for, and how a release is cut: CONTRIBUTING.md. How the pieces fit together: docs/architecture.md. The decisions behind them, with their reasoning and measurements: OVERVIEW.md (Korean).
License
MIT. See LICENSE. The published bundles inline their dependencies, whose licenses are reproduced in THIRD-PARTY-NOTICES.md.
