Stream Cloud Optimized Point Clouds (COPC
.laz) directly in CesiumJS, and convert plain.las/.lazinto COPC server-side with PDAL.
Demo Page : https://web-ten-xi-ca7nuj2n4c.vercel.app/
COPC exmaple 1(COPC.IO) : https://s3.amazonaws.com/hobu-lidar/sofi.copc.laz
COPC exmaple 2(COPC.IO) : https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz
YouTube : https://youtu.be/-Vy1g-Bm-O4
CesiumJS ships native support only for pnts (3D Tiles) point clouds. COPC is
a different, increasingly common format: a single LAZ file whose chunks are
arranged as an octree and indexed by a hierarchy VLR, so a viewer can fetch just
the nodes it needs through HTTP range requests — no tiling pipeline, no
server-side index, attributes fully preserved. It's the same format QGIS writes
when it indexes a .las for fast rendering.
This project brings COPC to the browser and gives you a one-command path from
raw .las to a streamable .copc.laz.
- Features
- System architecture
- How it works
- Repository structure
- File-by-file reference
- Getting started
- Library API
- Server API
- Configuration
- Deployment
- Limitations & notes
- Tech stack
- Contributing
- License
- 🌍 Native COPC streaming in Cesium — octree traversal with screen-space-error LOD; only visible, sufficiently-detailed nodes are fetched and rendered.
- 📡 HTTP range requests — reads a COPC file in place from any range-capable host (S3, nginx, the bundled server…). No pre-tiling.
- 🧩 laz-perf (WASM) decoding — LAZ chunks are decompressed in the browser.
- 🗺️ Automatic reprojection — reads the CRS from the file's WKT VLR and reprojects to the WGS84 globe with proj4 (UTM and other projected CRSs work out of the box).
- 🎨 Attribute-aware rendering — RGB when present, intensity as grayscale fallback.
- 📐 Terrain anchoring & height offset — depth-tests points against the globe so the cloud stays locked to the ground (no "swimming"), with a terrain on/off toggle and a height-offset control for vertical-datum mismatches.
- 🔄
.las→ COPC conversion server — Express endpoint wrappingpdal translate … --writers.copc.forward=all, preserving all attributes & SRS. - 📦 Reusable library — drop
CopcPointCloudinto any Cesium app; the demo is just a thin UI around it.
┌──────────────────────────── Browser ─────────────────────────────┐
│ │
│ Control panel (index.html / main.js) │
│ │ URL │ .las/.laz file │
│ ▼ ▼ │
│ CopcPointCloud ──────────┐ (upload, multipart) │
│ │ 1. range reads │ │ │
│ │ 2. octree LOD │ │ │
│ │ 3. decode (WASM) │ │ │
│ │ 4. reproject │ │ │
│ ▼ │ │ │
│ Cesium Viewer (points) │ │ │
└─────────────────────────────┼────────────┼────────────────────────┘
│ │
HTTP Range (bytes)│ │ POST /api/convert
│ ▼
┌─────────┴──────── Conversion server ──────────┐
│ Express (server/index.js) │
│ │ │
│ ▼ pdal translate --writers.copc.forward=all
│ PDAL ────────────────▶ <name>.copc.laz │
│ │ │ │
│ └── /files (range) ◀───────┘ │
│ /download │
└───────────────────────────────────────────────┘
Two independent data paths:
- Visualize —
CopcPointCloudopens a.copc.lazURL and streams it. This path is 100% client-side and works against any range-capable host. - Convert — the browser uploads a
.las/.laz; the server runs PDAL, writes a.copc.laz, and serves it back (with range support) so path 1 can stream it.
A COPC file embeds an octree. Each node (D-X-Y-Z: depth + xyz index) holds a
spatially-decimated subset of points, and the file's hierarchy VLR maps node
keys to byte ranges. CopcPointCloud:
- Reads the header + root hierarchy page via
copc. - On every camera change, walks the octree from the root and computes each node's screen-space error — roughly how many pixels its point spacing projects to. Nodes above the error threshold are refined into their 8 children; deeper hierarchy pages are fetched lazily as needed.
- For each selected node it issues a single HTTP range request for that node's
compressed bytes, decodes them with laz-perf, reprojects, and adds them to the
scene as a
PointPrimitiveCollection. - An LRU budget (
maxLoadedNodes) evicts off-screen nodes to bound memory.
LAZ chunks are decompressed by laz-perf, compiled to WebAssembly. The WASM
binary is served as a static asset (/laz-perf.wasm) and located via
locateFile, sidestepping bundler path issues.
Cesium positions everything on the WGS84 ellipsoid, so native point coordinates
must be reprojected. CopcPointCloud reads the WKT VLR (exposed by copc.js as
copc.wkt) and builds a proj4 transform to EPSG:4326, then to ECEF via
Cartesian3.fromDegrees. Files without a CRS VLR are treated as already lon/lat.
The server shells out to PDAL:
pdal translate <input>.las <output>.copc.laz --writers.copc.forward=all
The .copc.laz extension selects PDAL's COPC writer; forward=all carries
through scale/offset, SRS, VLRs, and every point dimension from the source.
distribute/
├── README.md # You are here
├── LICENSE # MIT
├── package.json # Local dev scripts + frontend & server dependencies
├── Dockerfile # Conversion-server image (Node + PDAL) for Cloud Run
├── .dockerignore
├── DEPLOY-cloudrun.md # Step-by-step Cloud Run deploy guide (Korean)
├── .env.example # Configuration template
├── .gitignore
├── index.html # Demo app shell + control panel (Korean UI)
├── vite.config.js # Vite config (vite-plugin-cesium, dep optimization)
│
├── public/
│ └── laz-perf.wasm # LAZ decoder, served at /laz-perf.wasm
│
├── src/
│ ├── main.js # Demo UI wiring (load URL, upload→convert→view)
│ ├── style.css # Control-panel styling
│ └── lib/ # ── Reusable, framework-agnostic library ──
│ ├── copc-cesium.js # CopcPointCloud: octree LOD streaming + render
│ ├── crs.js # WKT → WGS84 reprojection (proj4)
│ └── http-getter.js # HTTP range "getter" for copc.js
│
├── server/
│ ├── index.js # Express conversion server (PDAL wrapper, TTL cleanup)
│ └── package.json # Server-only deps (lean Docker image)
│
└── scripts/
└── pdal-docker.sh # Run PDAL via Docker when not installed locally
| File | Responsibility |
|---|---|
copc-cesium.js |
The CopcPointCloud class. Owns the whole client pipeline: opens the COPC file, ingests hierarchy pages, selects nodes by screen-space error, fetches/decodes/reprojects points, renders each node as a Cesium PointPrimitiveCollection, and evicts nodes under a memory budget. Also lazy-loads the shared laz-perf WASM instance. |
crs.js |
resolveCrs(getter, copc) — turns the file's CRS (from copc.wkt) into a forward(x, y) → [lon, lat] function via proj4. Falls back to pass-through when no WKT is present. |
http-getter.js |
createHttpGetter(url) — returns the (begin, end) → Uint8Array function copc.js uses to read bytes, implemented as a single HTTP Range request per call. |
| File | Responsibility |
|---|---|
index.html |
Page shell: the #cesiumContainer and a floating control panel (COPC URL input + Load button, file picker, status/download areas). |
src/main.js |
Creates the Cesium Viewer, wires the panel: loadUrl() instantiates CopcPointCloud; convertAndLoad() uploads a file to the server, then streams the result. Also handles the terrain toggle and height-offset controls, and enables globe.depthTestAgainstTerrain so points are occluded by terrain (anchored to the ground) instead of drawing on top. Exposes window.viewer for debugging. |
src/style.css |
Styling for the control panel. |
| File | Responsibility |
|---|---|
server/index.js |
Express app. POST /api/convert accepts a multipart upload (extension preserved so PDAL can pick a reader), runs pdal translate … --writers.copc.forward=all, and returns stream/download URLs. Serves outputs from /files with range support and /download with a friendly filename. Enforces a MAX_UPLOAD_MB cap (→ HTTP 413) and a TTL_MINUTES sweeper that deletes outputs not downloaded in time. GET /health reports status. |
server/package.json |
Server-only dependency manifest (express, cors, multer, dotenv) so the Docker image stays lean (no frontend deps). |
| File | Responsibility |
|---|---|
vite.config.js |
Registers vite-plugin-cesium (handles Cesium's static assets) and pre-bundles laz-perf/copc/proj4 so their CommonJS builds expose proper ESM named exports. |
Dockerfile |
Builds the conversion-server image on node:20-bookworm-slim + Debian's pdal package (COPC writer, with GDAL/PROJ data bundled). Cloud Build compiles this when you run gcloud run deploy --source .. |
public/laz-perf.wasm |
The laz-perf WebAssembly binary, served at the site root and referenced via locateFile. |
scripts/pdal-docker.sh |
A drop-in pdal replacement using the official pdal/pdal Docker image, for environments without a local PDAL. Point the server at it with PDAL_BIN=./scripts/pdal-docker.sh. |
- Node.js 18+
- PDAL on
PATH(only for the conversion feature). Check withpdal --version. No local PDAL? Use Docker viascripts/pdal-docker.sh(see below).
npm install
npm run dev # viewer on :5173, conversion server on :3000Open http://localhost:5173:
- Visualize: paste a
.copc.lazURL → Load. - Convert: pick a
.las/.laz→ it uploads, converts, displays, and offers a download link.
Run pieces individually with npm run web / npm run server.
PDAL_BIN=./scripts/pdal-docker.sh npm run server
Cannot find proj.db? Some PDAL builds (e.g. the one bundled with QGIS) need their PROJ data dir pointed out. SetPROJ_DATAto that install'sshare/proj(andGDAL_DATAtoshare/gdal). See.env.example.
import { Viewer } from 'cesium';
import { CopcPointCloud } from './src/lib/copc-cesium.js';
const viewer = new Viewer('cesiumContainer');
const cloud = new CopcPointCloud(viewer, 'https://host/data.copc.laz', {
maxScreenSpaceError: 16,
pointSize: 2,
maxLoadedNodes: 180,
});
await cloud.load(); // open file, read octree, start streaming
cloud.zoomTo(); // frame the dataset
// …
cloud.destroy(); // remove primitives + listenersConstructor new CopcPointCloud(viewer, url, options?)
| Option | Default | Meaning |
|---|---|---|
maxScreenSpaceError |
16 |
Refinement threshold in px. Higher = fewer points / faster. |
pointSize |
2 |
Rendered point size in px. |
maxLoadedNodes |
180 |
LRU memory budget (resident octree nodes). |
heightOffset |
0 |
Metres added to every point's height. Use to seat a cloud whose Z is orthometric (MSL) onto Cesium's ellipsoidal terrain — in Korea the geoid offset is ≈ +24..25 m. |
onPoint |
— | Optional (x, y, z, rgba) => void callback per decoded point. |
Methods: load(), zoomTo(), destroy().
Properties: boundingSphere, crs ({ wkt, forward }), copc (parsed metadata).
Static: CopcPointCloud.lazPerfWasmUrl — override the WASM URL (default
/laz-perf.wasm) before the first load().
Base URL defaults to http://localhost:3000.
| Method & path | Description |
|---|---|
POST /api/convert |
Multipart upload, field file (.las/.laz). Returns { url, downloadUrl, bytes, expiresInMinutes }. url streams the COPC (range support); downloadUrl forces a download. Returns 413 if the file exceeds MAX_UPLOAD_MB. |
GET /files/:name |
The converted .copc.laz, served with Accept-Ranges: bytes. Removed after TTL_MINUTES. |
GET /download/:name |
Same file with a Content-Disposition attachment name. |
GET /health |
{ ok, pdal, maxUploadMb, ttlMinutes } for liveness checks. |
Example:
curl -F "file=@cloud.las" http://localhost:3000/api/convert
# {"url":"/files/<id>__cloud.copc.laz","downloadUrl":"/download/<id>__cloud.copc.laz","bytes":777221}Copy .env.example → .env:
| Variable | Default | Scope | Purpose |
|---|---|---|---|
VITE_SERVER_URL |
(empty) | web | Conversion server URL the browser calls. Blank = upload disabled (viewer-only). |
VITE_CESIUM_ION_TOKEN |
— | web | Optional Ion token for world terrain/imagery. |
PORT |
3000 (Cloud Run injects 8080) |
server | Listen port. |
PDAL_BIN |
pdal |
server | PDAL binary or wrapper script. |
MAX_UPLOAD_MB |
30 |
server | Upload size limit. On Cloud Run the effective ceiling is ~30 MB regardless (see Limitations); other hosts can raise it. |
TTL_MINUTES |
30 |
server | Auto-delete converted files this long after creation. |
ALLOWED_ORIGIN |
* |
server | Restrict CORS to your frontend origin. |
DATA_DIR |
OS temp dir | server | Where uploads/outputs are written (ephemeral on Cloud Run). |
PROJ_DATA / GDAL_DATA |
— | server | PROJ/GDAL data dirs if PDAL can't find proj.db (not needed with the Docker image). |
The reference deployment is frontend on Vercel + conversion server on Google
Cloud Run, linked by the VITE_SERVER_URL env var.
-
Viewer → Vercel (or any static host). The frontend builds to static files (
npm run build→dist/). A ready-to-deploy copy with a beginner guide lives in the siblingweb/folder. -
Conversion server → Google Cloud Run. PDAL is a native binary, so it cannot run on Vercel's serverless functions. The included
Dockerfilepackages Node + PDAL;gcloud run deploy --source .lets Cloud Build compile and host it (scales to zero → no idle cost). Full step-by-step guide: DEPLOY-cloudrun.md.Recommended Cloud Run flags bound memory/cost and keep results ephemeral:
gcloud run deploy copc-convert --source . \ --region asia-northeast3 --memory 4Gi --cpu 2 \ --concurrency 1 --max-instances 2 --timeout 300 \ --allow-unauthenticated \ --set-env-vars MAX_UPLOAD_MB=30,TTL_MINUTES=30
Any other container host (Render, Fly.io, Cloud Run, a VM) works too — it just
needs to run the Dockerfile.
- Conversion needs PDAL — there is no pure-browser COPC writer; conversion is intentionally server-side to preserve all attributes reliably.
- Rendering uses Cesium's
PointPrimitiveCollection, which is great for the bounded, LOD-selected working set but is not a GPU-instanced renderer. Very dense views are kept in check bymaxScreenSpaceError/maxLoadedNodes. A custom WebGL primitive would scale further — PRs welcome. - CRS support covers files with a WKT VLR (the common case), including compound
CRSs (
COMPD_CS→ horizontalPROJCSis extracted) and feet/other linear units (heights are scaled to metres). Exotic CRSs still depend on proj4's coverage. - Upload size on Cloud Run is ~30 MB. Cloud Run rejects request bodies over
~32 MiB at the platform layer, so the convert-upload path is capped at 30 MB
(
MAX_UPLOAD_MB). This is a conversion limit only — viewing an existing COPC via URL streams with no size limit. Larger conversions need HTTP/2 or a bucket-upload flow, or running the server on a host without that cap. - Range + CORS — the host serving a
.copc.lazmust allow HTTP range requests and cross-origin reads. The bundled server does both.
- CesiumJS — globe & rendering
- copc.js — COPC parsing
- laz-perf — LAZ decoding (WASM)
- proj4js — reprojection
- Vite + vite-plugin-cesium
- Express + Multer
- PDAL —
.las→ COPC conversion
Issues and PRs are welcome — especially a GPU-instanced point renderer, EPT
support, classification/elevation color ramps, and broader CRS handling. Please
keep the library (src/lib/) framework-agnostic and dependency-light.
