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):
- 1.7: https://github.com/potree/potree/blob/c266f3391ffa7149f832426a18c94492fb17bf49/docs/potree-file-format.md
- older: https://github.com/potree/potree/blob/7edc0c536b59e6747cd068856440e7cb54bdaa33/docs/file_format.md
- (find via
repo:potree/potree file-format.mdcommit search)
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;INTENSITYu16;CLASSIFICATIONu8;NORMAL_SPHEREMAPPED2 B. Stride = Σ attribute sizes, little-endian, inpointAttributesorder.
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
.hrcfiles.
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 bugThat 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 = interleavedint32×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'sdealign24b). SeeDecoderWorker_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.