Coordinate Systems - Jangmyun/copcesium GitHub Wiki
Coordinate Systems
CesiumJS renders on a WGS84 globe in ECEF (Earth-centred, Earth-fixed) meters. COPC point coordinates almost never arrive that way — they're in a projected CRS (a UTM zone, a state plane, a national grid), often in feet, and sometimes with a separate vertical datum. copcesium's job is to turn those into lon/lat/height and then ECEF, correctly and automatically.
The detection flow
src/crs/detectCrs.ts runs on every load(), reading the COPC file's WKT VLR (exposed by copc.js as copc.wkt):
- Extract the horizontal CRS. If the WKT is a compound
COMPD_CS["…", PROJCS[…], VERT_CS[…]], the innerPROJCS/GEOGCS(or WKT2PROJCRS/GEOGCRS) block is pulled out by bracket counting. proj4 can't parseCOMPD_CS, so this step is essential. - Geographic short-circuit. If the horizontal CRS is already geographic (
GEOGCS/GEOGCRS/GEODCRS), the data is lon/lat degrees — returnEPSG:4326directly. - Feed the WKT straight to proj4.
proj4.defs(name, crsWkt)— proj4 parses most WKT1 dialects natively. This is the primary path and needs no lookup table. - Fallback: EPSG code from the WKT. If proj4 can't parse the dialect (common with WKT2), the
ID["EPSG", n]/AUTHORITY["EPSG","n"]code is extracted and resolved against a built-in offline table (lookupEpsg()insrc/crs/projections.ts, backed by the data insrc/crs/epsgTable.ts). - Give up gracefully. If nothing resolves, return
nulland let the caller fall back to the default or an explicit override.
The EPSG code in step 4 is deliberately searched only within the extracted horizontal block, not the whole string — otherwise a compound CRS's vertical datum code (e.g. NAVD88) could be mistaken for the horizontal CRS.
Units — the part that silently breaks clouds
proj4 applies the horizontal linear unit to X/Y, but Z is passed through untouched. A file in US survey feet whose heights aren't scaled will float thousands of meters too high. copcesium reads the WKT's linear/vertical unit and exposes it as:
zFactor— multiplies every height into meters (feet →0.3048, US survey foot →0.30480061…).xyFactor— the XY unit in meters, used for bounding-sphere sizing (so LoD math stays correct in feet-based CRSes).
Both are auto-detected from the WKT vertical/linear unit, and — importantly — they still apply even when you override proj/projDef, because a compound CRS's vertical unit can differ from the horizontal CRS you're supplying. To set them yourself, pass zFactor/xyFactor explicitly; an explicit value always wins over detection.
geoidOffset — orthometric vs. ellipsoidal height
WGS84 heights are ellipsoidal; many datasets store orthometric heights (above a geoid like NAVD88 or EGM2008). The two differ by the local geoid separation — often tens of meters. copcesium doesn't ship a geoid model, so if your cloud sits consistently above or below the terrain, pass a constant:
{ geoidOffset: -20 } // meters added to every height
This is a single global offset, not a per-point geoid grid — good enough when the dataset covers a limited area (the separation varies slowly).
heightOffset — the live alternative
geoidOffset is baked in at decode time, so changing it means reloading. To find the right value interactively, use the live dataSource.heightOffset property (meters, default 0) instead:
dataSource.heightOffset = -20; // applies on the next frame, no reload
It shifts each node's model matrix along its local "up" (the direction from Earth's center through the node), so already-loaded nodes move instantly and nothing is re-fetched or re-decoded. The two add together. A common workflow is to tune heightOffset with a slider, then bake the value into geoidOffset for production.
Overriding detection
Pass proj + projDef when the WKT is missing, truncated, or an unrecognized dialect:
const dataSource = await CopcDataSource.load(url, viewer, {
proj: 'EPSG:2992', // Oregon State Plane North (ft)
projDef:
'+proj=lcc +lat_1=43 +lat_2=45.5 +lat_0=41.75 +lon_0=-120.5' +
' +x_0=399999.9999999999 +y_0=0 +datum=NAD83 +units=ft +no_defs',
geoidOffset: -20,
});
If proj4 already knows the code, projDef can be omitted. Out of the box proj4 knows EPSG:4326, EPSG:4269, EPSG:3857 (and its aliases), the WGS84 UTM zones EPSG:32601–32660 / 32701–32760, and the UPS zones EPSG:5041/5042. For anything else, supply the proj4 string — grab it from epsg.io (the "proj4" export on any CRS page).
The fallback table — what it is and isn't
src/crs/epsgTable.ts is a hand-curated offline EPSG → proj4 map, looked up through lookupEpsg() in src/crs/projections.ts. It exists so detection works without network calls to epsg.io, and only for step 4 above (when proj4's own WKT parsing doesn't recognize a dialect). It covers:
- Geographic WGS84 / NAD83 and Web Mercator (
3857) - WGS84 UTM north and south, ETRS89 UTM zones 28–38, and NAD83 UTM zones 1–23 (the UTM ranges are generated in a loop, not hand-listed)
- US State Plane (NAD83, both meter and foot variants)
- Korean CRSes (Korea 2000 belts
5185–5188, unified5179, legacy Bessel5174–5177) - Japanese (JGD2000/2011 plane-rectangular + UTM), Australian (GDA94/2020 MGA), and common European national grids
If your CRS isn't in the table and proj4 can't parse its WKT, detection returns null — pass proj/projDef explicitly. Because the primary path is "feed WKT to proj4," a CRS absent from the table still works fine as long as its WKT is a dialect proj4 understands.
Debugging coordinates
- Cloud is on the globe but in the wrong country → wrong horizontal CRS. Check the file's WKT; override
proj/projDef. - Cloud is in the right place but floating / underground → wrong
zFactor(feet not scaled) or a geoid/ellipsoid mismatch (geoidOffset, orheightOffsetto tune it live). - Cloud is a tiny dot or absurdly huge → wrong
xyFactor, usually a feet CRS not detected. - Nothing renders and the console shows non-finite coordinate errors → proj4 produced
Infinityfor out-of-domain input; the source CRS is likely wrong for this data.
See Troubleshooting for the full checklist.