3dtiled-to-3dtiles
Pointclouds

Potree file formats (1.x .hrc/.bin and 2.0 binary octree)

Reference for potree-to-3dtiles (and the planned potree-to-copc). Two generations:

Reference for potree-to-3dtiles (and the planned potree-to-copc). Two generations:

Potree 1.x (cloud.js + per-node files) — authoritative spec

Sources (Potree repo docs/, pinned commits):

cloud.js (meta): version, octreeDir, boundingBox{lx,ly,lz,ux,uy,uz} (world bbox), tightBoundingBox, pointAttributes[], spacing (root point spacing, halved per level), scale, hierarchyStepSize. cloud.js may also carry a projection/proj4 CRS string — recover it.

Point decode — THE load-bearing rule. The spec writes position = (POSITION_CARTESIAN * scale) + boundingBox.min, but boundingBox.min is the NODE's box min, not the global cloud min — the loader subdivides the cloud bbox per node (createChildAABB) and adds that node's min. POSITION_CARTESIAN is node-relative: raw uint32 span 0..(nodeWidth/scale).

  • ⚠️ Using the global cloud min for every node piles all geometry at the min corner (raw values are small node-relative offsets). Using a per-node min computed with the wrong octant convention scatters nodes into wrong octants. BOTH must be right: node-relative decode with the correct octant boxes.
  • Octant → sub-box convention (Potree PointCloudOctreeGeometryNode.createChildAABB): child index bit 0 (0b001) → Z, bit 1 (0b010) → Y, bit 2 (0b100) → X. (Not bit0→X — that X/Z swap scatters; verified via child→root nearest-neighbour: 0.03 m correct vs 0.81 m swapped.)
  • POSITION_CARTESIAN = 3× uint32 (12 B); COLOR_PACKED/RGBA = 4× uint8; INTENSITY u16; CLASSIFICATION u8; NORMAL_SPHEREMAPPED 2 B. Stride = Σ attribute sizes, little-endian, in pointAttributes order.

Hierarchy (.hrc): a node r + octants 0–7. Each .hrc record = uint8 childMask + uint32 numPoints (BFS within a chunk of hierarchyStepSize levels; the deepest level recurses into a sub-.hrc). childMask bit c set ⇒ child c exists. File path: octreeDir/r/<name[1..] chunked by stepSize>/<name>.<bin|laz>. Node files are .bin (raw), .las, or .laz (pointAttributes:"LAZ").

1.8 variant: same layout as 1.7, but pointAttributes is an array of objects ({ name, size, elements, elementSize, type, description }) instead of names. Read the byte size from the object's size. This repo's decoder once read byteSize — a field 1.8 does not have — so every attribute size was undefined, the point stride NaN, and every 1.8 node decoded to zero points: the root tile answered 404 and roughly half of Iconem's Potree datasets streamed as an empty bounding volume. A 60-dataset sweep went from 27/60 to 60/60 with the fix.

1.4 variant: inline hierarchy:[[name,numPoints],…] in cloud.js (no .hrc, no hierarchyStepSize), flat data/<name>.<bin|laz>.

.hrc chunks are a tree of files, and the boundary nodes are the only way down

This is the single subtlety that a streaming implementation gets wrong, so it is worth stating plainly: Potree 1.x does not ship one hierarchy file. It ships a tree of .hrc chunks, each covering hierarchyStepSize levels (5 for most datasets). A chunk is a BFS blob rooted at one node, and reading it yields two different kinds of thing:

  • the interior nodes of that chunk — everything from the chunk root down to stepSize − 1; and
  • the boundary nodes — the nodes sitting at the chunk's bottom edge, which are precisely the roots of the next generation of .hrc files.

The boundary nodes are load-bearing. They are the only edge connecting one chunk to the next, so the hierarchy is reachable only by: read chunk → collect its boundary nodes → fetch the chunk rooted at each boundary node → repeat. Drop the boundary list and everything below that chunk becomes invisible — with no error, because a missing .hrc child and a genuinely absent subtree look identical.

The truncation bug this repo hit (fixed). The live adapter (potree-live.js) loads chunks lazily and guarded re-entry with a loadedChunks set — but the guard returned an empty list:

if (loadedChunks.has(cr)) return Promise.resolve([]);   // ← the bug

That conflates "already loaded" with "has nothing to give you". It is harmless on the first walk and fatal on every later one, because a lazy adapter walks the tree more than once — once per subtree request the client makes.

The failure runs like this. Serving the root subtree loads chunk r with a shallow depth target. Later the client asks for the level-3 subtree; that walk also starts at r, gets [] back, and so never learns r's boundary nodes — which means it never reaches the level-5 chunk generation, or anything beyond it. Every availability bit past that point stays 0, and a 3D Tiles client that reads "not available" simply stops refining.

Net effect on Geghard (hierarchyStepSize: 5): the tileset silently capped at level 4 (stepSize − 1) against a real depth of 11. No exception, no 404, no warning — just a point cloud that stops getting denser as you zoom. The fix is to cache each chunk's boundary list on first load so a re-entrant walk traverses through an already-loaded chunk instead of dying at it. Verified live against the Iconem host: max depth 4 → 11.

The general lesson for any lazily-walked chunked hierarchy: a memo guarding re-entry must return what the chunk contained, never a sentinel empty. Repro: scripts/debug-potree-depth.mjs; the shape of the truncation is visible at a glance in /hierarchy as a radial tree whose rings stop dead at one level.

Potree 2.0 (metadata.json + hierarchy.bin + octree.bin)

  • metadata.json: attributes[{name,type,size,numElements}], scale[3], offset[3], boundingBox, spacing, encoding (DEFAULT|BROTLI), hierarchy.firstChunkSize.
  • hierarchy.bin: 22 B/node — type u8, childMask u8, numPoints u32, byteOffset u64, byteSize u64; type==2 = PROXY (descend into a child hierarchy chunk).
  • octree.bin: per-node point records. DEFAULT = interleaved int32×3·scale+offset + attrs. BROTLI = per-node brotli; the decompressed buffer is attribute-PLANAR and position/rgb are Morton-encoded (16 B / 8 B per point; de-interleave with Potree's dealign24b). See DecoderWorker_brotli.js.

Both store positions as integers + a global scale(+offset/boundingBox.min) → the octree maps directly onto an EPT/COPC-style octree (r, r0..r7), which is why potree-to-copc is natural.

On this page