CLI usage
npm-workspaces monorepo. Each input format is an independent converter with its own deps
npm-workspaces monorepo
npm-workspaces monorepo. Each input format is an independent converter with its own deps (install just the one you need); a shared core holds the common machinery; a meta-converter detects any input and dispatches.
packages/
core/ 3dtiles-convert-core — shared: SPZ-v2 GLB + POINTS GLB encoders (async gzip),
parallelMap + WorkerPool, format detection
convert/ 3dtiles-convert — meta-converter; exposes the `3dtiled` CLI + a JS/TS API
rad-to-3dtiles/ sog-to-3dtiles/ lcc-to-3dtiles/ copc-to-3dtiles/ potree-to-3dtiles/ i3s-to-3dtiles/
— the per-format converters (each its own package + deps)All packages live under packages/ (npm-workspaces members). Run npm install once at the repo root
to link the 3dtiles-convert-core workspace symlink and each converter's deps.
Naming: per-format converters keep the descriptive <fmt>-to-3dtiles convention (not scoped
@scope/<fmt>); the two shared packages are the unscoped 3dtiles-convert-core / 3dtiles-convert.
Meta-converter — convert anything
npm install # wire up the workspaces
# CLI (auto-detects format):
npm run convert -- <input> <outDir> [--format rad|sog|lcc|copc|potree|i3s] \
[--scale N] [--ge-scale N] [--ge-layer N] [-- <extra converter args>]
# or directly:
node packages/convert/cli.js sample-data/input/RAD/coit.rad sample-data/output/rad/coit-3dtiles-from-rad// JS/TS API — same detection + dispatch
import { convert, detectFormat, supportedFormats } from '3dtiles-convert';
await convert('path/to/input', 'out/dir', { geScale: 2, onLog: s => process.stdout.write(s) });Each
<fmt>-to-3dtilesconverter already lives underpackages/<fmt>-to-3dtiles/and exports a uniformconvert(input, outDir, opts); the meta-converter's registry dispatches to it in-process (a plainimport) by default. Child-process spawn is only a fallback for callers passing format-specific CLI flags (opts.extraArgs) the in-process options object doesn't model yet — not a sign of an incomplete migration. See ARCHITECTURE.md.
Individual converter (à la carte quick reference)
Each converter also runs standalone (only its own deps) — this is a compact index into the per-format sections below, not new information:
node packages/rad-to-3dtiles/src/index.js <coit.rad> sample-data/output/rad/<name>-3dtiles-from-rad
node packages/sog-to-3dtiles/src/index.js <scene-dir> sample-data/output/sog/<name>-3dtiles-from-sog
node packages/lcc-to-3dtiles/src/index.js <lcc-dir> sample-data/output/lcc/<name>-3dtiles-from-lcc
node packages/copc-to-3dtiles/src/index.js <input.copc.laz> sample-data/output/copc/<name>-3dtiles-from-copc
node packages/potree-to-3dtiles/src/index.js <potree-dir> sample-data/output/potree/<name>
node packages/i3s-to-3dtiles/src/index.js <extracted-slpk-dir> sample-data/output/i3s/<name> # draftOutput convention: regenerate into
sample-data/output/<fmt>/…(the viewer presets andtools-inspectorscripts point there).
Per-format converters
copc-to-3dtiles
Converts COPC .copc.laz LiDAR files to 3D Tiles 1.1 with implicit octree tiling.
cd packages/copc-to-3dtiles
npm install
node src/index.js input.copc.laz ./out-copcOutput:
out-copc/
tileset.json
tiles/<level>/<x>/<y>/<z>.glb POINTS, tile-local coords
subtrees/<level>/<x>/<y>/<z>.subtree binary subtree filesDesign:
- Implicit octree (
subdivisionScheme: "OCTREE") — COPC hierarchy maps directly, no re-tiling - Subtree binary format (3D Tiles 1.1 §7.3): 24-byte header, Morton Z-order bitstreams, bufferView indices
- Refine:
ADD— each level independently contributes points - CRS handling: see note below
On CRS and proj4:
COPC files may carry a WKT CRS in their VLRs. Currently georef.js uses proj4 to convert the
dataset origin to WGS84 lon/lat, then builds an ENU→ECEF (EPSG:4978) root transform — which is
what most 3D Tiles viewers (CesiumJS) expect.
However, 3D Tiles 1.1 does not mandate ECEF. The spec allows any CRS expressed via a
transform matrix and optional metadata. If the COPC's native CRS is known and the viewer
supports it, proj4 could be skipped entirely by passing the raw COPC transform hierarchy
root-to-leaf verbatim. Viewers like opengeos' lidar-viewer likely position COPC files correctly
by reading their internal spatial metadata directly rather than relying on the 3D Tiles CRS.
This is worth exploring to simplify the pipeline and eliminate the proj4 dependency for
native-CRS outputs.
potree-to-3dtiles
Converts a Potree octree point cloud → 3D Tiles (glTF POINTS tiles). Auto-detects
2.0 (metadata.json + hierarchy.bin + octree.bin) and 1.x (cloud.js + .hrc +
per-node .bin).
cd packages/potree-to-3dtiles && npm install
node src/index.js path/to/potree-dir ./out-potreeHow it works: the Potree octree (r, r0..r7, …) maps 1:1 to a 3D Tiles tree with
refine: ADD (children add denser detail in sub-octants, like COPC); geometricError = point
spacing per depth (rootSpacing / 2^depth). 2.0 decode is authoritative (hierarchy.bin 22 B/node:
type u8, childMask u8, numPoints u32, byteOffset u64, byteSize u64; positions int32×3·scale+offset;
DEFAULT + BROTLI via Node's zlib). Points are emitted relative to the dataset centre (float32-safe).
Validated on real datasets (2026-06-22):
| Sample | Potree | Result |
|---|---|---|
iTowns lion | 2.0 (BROTLI) | ✅ 1624 tiles / 3,954,696 pts = metadata.points exactly (Morton-decoded position+rgb) |
lion_takanawa | 1.x v1.7 (.hrc + chunked data/r/…) | ✅ 167 tiles / 272,768 pts (per-node origin) |
vol_total | 1.x v1.4 (inline hierarchy, flat data/<name>.bin) | ✅ 42 tiles |
lion_takanawa_laz | 1.x v1.4 + LAZ (pointAttributes:"LAZ", flat data/<name>.laz) | ✅ 200 tiles — tiled LAZ octree (one .laz per node, laz-perf) |
Status: 🟢 operational for Potree 2.0 (DEFAULT + BROTLI) and 1.x (v1.7
.hrc+ v1.4 inline), with.binand tiled-.laznodes. 2.0 BROTLI positions/rgb are Morton-decoded; 1.x points are quantised per-node-origin; LAZ nodes are decoded with laz-perf (viacopc). Output is not geo-referenced (local origin; add a root transform downstream). Samples insample-data/input/POTREE/. Reference: yeyan00/potree23dtiles + PotreeDecoderWorker_brotli.js.
Implicit vs explicit tiling — available on all four surfaces:
| Surface | Default | Option |
|---|---|---|
Dedicated CLI (node src/index.js … --tiling=explicit) | implicit | --tiling=explicit |
Meta CLI (node packages/convert/cli.js … --tiling=explicit) | implicit | --tiling=explicit |
/convert middleware | implicit | &tiling=explicit |
/stream middleware | implicit | &tiling=explicit |
Both COPC and Potree are octree hierarchies, so implicit is the natural default (smaller
tileset JSON, subtrees served on demand). Pass explicit only when the client doesn't support
3D Tiles 1.1 implicit tiling (e.g. legacy CesiumJS < 1.96 or simple tile fetchers).
rad-to-3dtiles
Converts SparkJS .RAD Gaussian Splat LoD files to 3D Tiles 1.1 with KHR_gaussian_splatting GLB tiles.
cd packages/rad-to-3dtiles
npm install
# Always inspect first:
node scripts/dump-rad-header.js coit.rad
# Convert (defaults: --scale=1, --ge-scale=1, --ge-layer=1):
node src/index.js coit.rad ./out-rad
# Noise filtering (OPT-IN, default OFF — SparkJS renders the RAD with no filtering, so we don't either):
# --opacity-filter=0.05 drop faint splats (final opacity < N)
# --max-scale-mult=30 drop giant "floating noise" splats (maxScale > per-chunk median×N)
node src/index.js coit.rad ./out-rad --opacity-filter=0.05 --max-scale-mult=30 # only for data with known noise
# Tune splat size and LoD (rarely needed — raw values match the reference):
node src/index.js coit.rad ./out-rad --scale=2 # σ×2 (larger splats)
node src/index.js coit.rad ./out-rad --ge-scale=2 # GE×2 (refine at greater distance)
node src/index.js coit.rad ./out-rad --ge-layer=2 # widen depth rangeOutput:
out-rad/
tileset.json explicit tiling, one tile per RAD chunk
tiles/node_<index>.glb KHR_gaussian_splatting GLB (POINTS)Note:
rad-to-3dtilesandcopc-to-3dtilesused to list a package-name-typo'd@cesium/3d-tiles-tools(no such npm package — 404; the real one is plain3d-tiles-tools) as an optional dependency that was never wired to any CLI flag and never actually installable — removed 2026-07-02 (it was silently breakingpnpm install --frozen-lockfile, since pnpm can't reconcile an unresolvable optional dep against the lockfile). For.3tz/.3dtilestoday, convert to a plain tileset dir then use the in-repo packer (scripts/pack-3dtiles.mjs) ornpx 3d-tiles-tools convert, both documented under/3dtiles-self-containedin Ambition.
Live /stream (no pre-conversion) — packages/tile-server/rad-live.js, served at
GET /stream/tileset.json?url=<.rad>&format=rad:
- tileset.json — fetch the
.radonce (cached in memory), build the node table, and decode only thecentercolumn per chunk for bounds — no SPZ encode, no gzip. Reuses the sharedbuildTileset. This is the cheap pass that makes the first paint fast. - tile/
<i>.glb — full-decode + SPZ-v2 encode that one chunk on demand (~200 ms / 65k-splat chunk), via the sharedencodeChunkGlb.
This defers the expensive per-tile decode+encode+gzip (which /convert runs for the whole dataset
up front) to on-demand, so tiles stream as the camera requests them. Only monolithic .rad files
stream (a streaming-manifest .rad has no embedded chunk data); /convert produces identical tiles.
sog-to-3dtiles
Converts a PlayCanvas Streamed SOG dataset (lod-meta.json
- per-chunk unbundled SOG dirs) to 3D Tiles 1.1 with the same
KHR_gaussian_splatting+_compression_spz_2GLB tiles as the RAD path.
Arrival.Space
.loduses this same streamed-SOG format. An Arrival entity'slodParameter.glbUrlpoints at a…_LOD/lod-meta.jsonwhose tree + per-chunkversion:2webp splats (means_l/u,quats,scales,sh0,shN_*, generated bysplat-transform) are byte-shape-identical to PlayCanvas streamed-SOG — so the SOG adapter/converter reads Arrival.loddirectly; no Arrival-specific code. Verified live againstugc.arrival.space. (Arrival's per-entityrotation/scale/positionfrom its own API are a separate placement layer, not part of the.lodpayload.)Not georeferenced (checked 2026-07-02):
lod-meta.jsonand every per-chunkmeta.jsoncarry only local tree bounds — no lon/lat/EPSG anywhere in the.lodpayload itself, sosog-live.js's root transform is a fixed local upright-correction (RX(−90°)), same as any other non-georeferenced SOG source. Real-world placement for an Arrival scene would need to fetch its entity API separately (therotation/scale/positionmentioned above) and compose that into the root transform — untried, Arrival-specific, not implemented here.
cd packages/sog-to-3dtiles
npm install # pulls @playcanvas/splat-transform
node src/index.js path/to/scene/ ./out-sog # dir containing lod-meta.json, or the json itselfHow it works: the streamed-SOG binary spatial tree maps almost 1:1 onto a 3D Tiles HLOD
tree — interior nodes become empty grouping tiles, and each leaf becomes a REPLACE chain of
tiles from coarsest LOD level down to level 0 (finest). Bounds are emitted in the Z-up tileset
frame (RX+90°), matching the RAD path.
The per-chunk pipeline (intermediate format):
@playcanvas/splat-transform'sreadSogdecodes each SOG chunk's WebP textures (CPUwebp.wasm— no GPU) into aDataTable— that's the intermediate per-chunk format. Columns:x,y,z,rot_0..3(quaternion, w-first),scale_0..2(log),f_dc_0..2(SH DC),opacity(logit).- For each leaf's
lods[level]run, we slice[offset, offset+count)of that DataTable, convert conventions (expscale,sigmoidopacity,0.5 + SH_C0·f_dc→ color, quaternion reorder w-first → xyzw), and re-encode to an SPZ-v2 GLB with our own encoder (the same onerad-to-3dtilesuses).
That this path renders cleanly is itself proof the SPZ-v2/GLB encoder and tree builder are correct — so when the RAD path looked wrong, the bug had to be in the RAD decode, not the shared encoder.
Output:
out-sog/
tileset.json HLOD tree mirroring the SOG spatial tree
tiles/node_<n>.glb one per LOD run; tiles/env.glb if the scene has an environmentStatus: validated end-to-end on PlayCanvas's
example_skatepark_02&example_roman_parish_02(see Sample Data). Decoded tiles contain real splats — varied positions, valid scales, unit quaternions, proper opacity — confirming the WebP CPU decode + decode conventions (quaternionrot_0=w, log scales, logit opacity, SH0→color). geometricError halves cleanly root→leaf (e.g. skatepark17.8 → 8.9 → … → 0.0). Bundled.sogenvironments are skipped (best-effort) since the spec's chunks are unbundled.
lcc-to-3dtiles
Converts an XGRIDS LCC dataset (meta.lcc + Index.bin +
Data.bin [+ Shcoef.bin]) to 3D Tiles with the same KHR_gaussian_splatting + _spz_2 GLB tiles.
cd packages/lcc-to-3dtiles
node src/index.js path/to/lcc-dir ./out-lcc # dir containing meta.lcc (name can vary)How it works: LCC organises splats as a grid of Units (one per partition cell, x/y packed in
the index) × totalLevel LODs (LOD0 = finest). Each Unit → a REPLACE chain of tiles
coarsest→finest under a single root. Data.bin is uncompressed, fixed 32 B/splat (the meta
"encoding": "COMPRESS" refers to field quantization, not stream compression — verified
LODSize / PointsCount == 32). The 32-byte layout is taken directly from the official
xgrids/LCC-Web-SDK vertex shader (the non-LCC_COMPRESSED
decodeCov path), not guessed: 0–11 pos f32×3 · 12–15 color rgba8 (a = opacity) · 16–21 scale uint16×3 · 22–25 rot uint32 (10-10-10-2 smallest-three) · 26–27 semantic · 28–31 pad.
Scale = linear mix(scale.min, scale.max, u/65535) (the SDK's compressedScaleMin/Max uniforms =
meta attributes.scale.min/max); rotation decode = the SDK's QUAT_TAB.
⚠️ LCC partitions in X/Y only (a 2-D grid, not an octree).
Index.binpacks each Unit's cell asx(low16)/y(high16)withcellLengthX/Y; there is no Z partition — each Unit spans the full scene height, refined by LOD detail rather than by vertical tiling. For a tall scan (e.g. an XGRIDS L2 sweep of a tower) the live/stream tile boxes therefore use the cell's X/Y extent + the full scene Z, so vertical frustum culling is coarse (everything in a column shares one Z span). Not a bug — it's how LCC organizes data; tighter per-Unit Z would require decoding the splats.
Validated: Guan_Temple → 154 tiles, 11,424,293 splats (= meta.totalSplats exactly),
0 bad quaternions, scale p50 ≈ 3 cm (sane for a 325 m-diagonal temple). SH (Shcoef.bin, Quality
files) not yet emitted (DC only, like the other converters). LCC is XGRIDS' format — attribution
required per its White Paper (see the doc).
License note: the LCC White Paper requires displaying "Data Organization Format originated from XGRIDS" and publishing derivatives under terms no less open. This converter reads the format; downstream products must carry that attribution.
i3s-to-3dtiles
Converts the supported subset of Esri I3S (Indexed 3D Scene
Layer) → 3D Tiles 1.1. Validated on Buildings_NewYork_v18.slpk → 5882 glTF TRIANGLES tiles that
place correctly in NYC: I3S geographic-layer vertices are (Δlon°, Δlat°, Δheight_m) relative to the
node OBB centre, converted per-vertex to ECEF (stored relative to the centre for float32 precision,
tile transform translates back). Still a draft: projected/vertical CRS placement is not handled,
normals stay in the source frame, and Draco/attributes are gated out. JPEG/PNG base-color
textures ARE embedded (2026-07-02) when the layer offers a raster format; DDS/KTX2/Basis-only
textures have no decoder here and are gated the same way Draco is (geometry still renders,
untextured). LEPCC-compressed PointCloud positions now decode too (2026-07-06,
i3sParse.js's decodeDefaultGeometry — shared with the live i3s-live.js adapter, which is what's
actually been run against real LEPCC data; this offline CLI hasn't separately been exercised on a
LEPCC dataset yet, so treat it as wired-but-unverified through this specific code path).
unzip your-layer.slpk -d your-layer/ # an SLPK is a plain zip
cd packages/i3s-to-3dtiles
node src/index.js ../your-layer ./out-i3sScope (deliberately gated — warns/skips everything else):
| Supported ✓ | Not supported ✗ (detected → warn/skip/exit) |
|---|---|
Explicit nodepages/*.json trees | Legacy per-node 3dNodeIndexDocument index (pre-1.7) |
IntegratedMesh / 3DObject → glTF TRIANGLES | Draco-compressed geometry (no decoder) |
PointCloud → glTF POINTS | Point (symbol/instance) & Building (composite) layers |
Uncompressed DefaultGeometrySchema (PerAttributeArray) | Per-feature attributes (dropped); non-WGS84 vertical CRS |
| LEPCC-compressed point-cloud positions (decoded — shared w/ live adapter) | RGB/Intensity LEPCC blob types (position-only decoder) |
OBB → 3D Tiles box; node tree → REPLACE tileset | |
| JPEG/PNG base-color textures (embedded, per-node) | DDS/KTX2/Basis-encoded textures (no decoder — gated, geometry-only) |
Investigation notes (answering "is it complex? meshes/points/instances/mix? implicit?"):
I3S does cover meshes (IntegratedMesh, 3DObject), point clouds (PointCloud), and
symbol/model instances (Point layers); a Building Scene Layer is the "mix" — a composite of
sublayers. Tiling is always explicit (a node tree addressed via paged indices); I3S has no
implicit/template scheme like 3D Tiles subtrees, so the user's "explicit tiling only" constraint is
automatic. The node-tree → tileset mapping is direct (OBB↔box, children↔children, REPLACE). The
genuinely hard remaining part — and why this stays a draft — is Draco geometry compression (no
decoder; LEPCC point-cloud positions and raster textures are now both handled, see above),
per-feature attributes (→ EXT_mesh_features/metadata), and the CRS/ECEF vertex placement
nuances. A mesh-or-points-only,
uncompressed, explicit-tree converter (this draft) is moderate; full-fidelity I3S is high effort
and overlaps mature tools (loaders.gl, Cesium ion).
ESRI also publishes Gaussian splats as 3D Tiles directly, not through I3S: ArcGIS's 3D Tiles Server hosts
KHR_gaussian_splatting/SPZ GLB tilesets alongside its regularIntegratedMeshoutput (see theesri-mantes/esri-portcoastpills inviewer.html, and ESRI's 3D Tiles samples experience) — so "does ESRI do splats" is really two separate answers: I3S itself has no splat layer type (mesh/points/instances only, per above); ArcGIS's 3D Tiles Server product does, as plain OGC 3D Tiles, sitting entirely outside I3S's own format.
See I3S open-data samples for a
curated list of hosted ArcGIS I3S mesh/point-cloud SLPK + SceneServer endpoints, all verified live
against /stream?format=i3s.
Reference — bidirectional I3S ⇄ 3D Tiles converter: loaders.gl's
@loaders.gl/tile-converter(docs) converts 3D Tiles → I3S and I3S → 3D Tiles (Draco, textures, attributes, gzip), and is the mature baseline this draft is measured against. Use it for full-fidelity I3S;i3s-to-3dtileshere stays a minimal, dependency-light subset (uncompressed, explicit tree, mesh/points only).
Reference — 3D Tiles ⇄ 3D Tiles tooling: CesiumGS's
3d-tiles-toolsis the canonical toolbox for tileset-level operations, several of which overlap this repo's "3D Tiles → 3D Tiles" plans:
upgrade— legacy → 3D Tiles 1.0/1.1 (asset.version,url→uri,refineupper-case, glTF 1.0→2.0, and with--targetVersion 1.1converts PNTS/B3DM/I3DM/CMPT content to glTF; drops3DTILES_content_gltf).merge/mergeJson— combine tilesets as external tilesets (mergeJson writes only the referencing JSON, using relative paths to the inputs).b3dmToGlb/i3dmToGlb/cmptToGlb/convertB3dmToGlb/convertPntsToGlb/convertI3dmToGlb/splitCmpt— extract or convert legacy tile content to glTF.The
3D Tiles → 3D Tilestranscodes in Ambition are the live/streaming-flavored counterparts (implicit→explicit, legacy upgrade); use3d-tiles-toolsfor full offline pipelines.
See Related tilers (prior art / reference implementations) for the catalogue of offline tilers that produce the tiled inputs these converters consume (COPC, Potree, streamed-SOG, RAD, I3S), including the "why is PotreeConverter 2.0 so fast" deep dive.