Node ID Semantics
Node ids in a TwoNTree are not arbitrary handles: their numeric order carries the tree's physical layout. This page documents exactly what that layout guarantees, and (just as importantly) what it does not.
The layout
Bulk construction assigns final ids level-major, Hilbert-ordered within each level:
Node IDs →
level Lmin: [ nodes in Hilbert order ]
level Lmin + 1: [ nodes in Hilbert order ]
level Lmin + 2: [ nodes in Hilbert order ]
...
leaf level: [ nodes in Hilbert order ]
^ ^
first leaf last leafConcretely:
- the root keeps exactly the id it was asked for (
TwoNTreeBuilder(; root=...), default1), and all ids form the contiguous rangeroot : root + numberofnodes(tree) - 1; - every level is one contiguous block of ids, so
nodesatlevel(tree, level)is always a contiguous range; - adjacent levels touch:
last(nodesatlevel(tree, l)) + 1 == first(nodesatlevel(tree, l + 1)); - within a level, increasing node id follows the Hilbert order of that level's occupied cells;
firstchild/nextsibling(and thereforeH2Trees.children) also follow Hilbert order.
Ids are determined by geometry alone. Building the same point set in a different input order produces the same node ids with the same geometry attached.
Why level-major, and why Hilbert
Hierarchical methods do most of their work between geometrically nearby boxes on the same level. Level-major storage makes level-wise kernels natural, and Hilbert ordering within the level gives consecutive ids good spatial locality. Together they mean a fixed-size chunk of ids tends to cover a compact region of space, so near-interaction data gets reused rather than re-fetched.
The curve itself
Before looking at trees, here is the plain Hilbert curve H2Trees orders by, in each supported dimension. Marker colour runs along the traversal (dark → light), so the direction of travel is visible and not just the shape of the path.
In 1D the curve degenerates to the natural interval order: left to right at every refinement, one orientation state, nothing to choose
using H2Trees
using PlotlyJS
const HO = H2Trees.HilbertOrdering
levels = 1:4
# In one dimension the Hilbert curve degenerates to the natural interval order, so the index and
# the coordinate coincide: the traversal is simply left to right at every refinement.
curves = map(levels) do level
return [HO.hilbertcoordinates(Val(1), i, level)[1] for i in 0:(2 ^ level - 1)]
endIn 2D, refined four times. Each level's path visits every cell exactly once, and consecutive cells share a face: the property that makes consecutive ids spatially local:
using H2Trees
using PlotlyJS
# `HilbertOrdering` is internal, but its index <-> coordinate helpers are exactly what is needed
# to draw the curve itself: walk the level-L index range in order and ask for each cell's
# integer grid coordinates.
const HO = H2Trees.HilbertOrdering
levels = 1:4
curves = map(levels) do level
cells = [HO.hilbertcoordinates(Val(2), i, level) for i in 0:(4 ^ level - 1)]
return (first.(cells), last.(cells))
endAnd in 3D (drag to rotate). This is H2Trees' long-standing 3D convention, preserved exactly: 3D Hilbert curves are not unique, so the specific one is frozen rather than regenerated:
using H2Trees
using PlotlyJS
const HO = H2Trees.HilbertOrdering
levels = 1:3
curves = map(levels) do level
cells = [HO.hilbertcoordinates(Val(3), i, level) for i in 0:(8 ^ level - 1)]
return (getindex.(cells, 1), getindex.(cells, 2), getindex.(cells, 3))
endThe curve through a real tree
The figures above are the abstract curve. What actually matters is that a built TwoNTree's node ids follow it: walking nodesatlevel(tree, level) (which is just increasing node id) traces the curve through the node centers, with no sorting and no stored Hilbert keys.
Each figure below builds a complete tree (one point per finest cell, so every box on every level is occupied) and draws the polyline through the node centers in id order. Use the slider to step through the tree levels; hover a marker to see its node id.
1D — ids simply run left to right:
using H2Trees
using PlotlyJS
using StaticArrays
# One point per finest cell, so the tree is complete and the traversal is the full curve.
side = 16
points = [SVector(i + 0.5) for i in 0:(side - 1)]
tree = buildtree(points; builder=TwoNTreeBuilder(; minhalfsize=0.5, minvalues=0))
# `nodesatlevel` is already Hilbert-ordered within each level. In 1D that is left to right.
levelcurves = map(H2Trees.levels(tree)) do level
nodes = H2Trees.nodesatlevel(tree, level)
return ([H2Trees.center(tree, node)[1] for node in nodes], nodes)
end2D — the box grid is drawn faintly underneath, so the curve is visibly threading actual tree cells rather than abstract grid points:
using H2Trees
using PlotlyJS
using StaticArrays
# A complete 8x8 grid, one point per finest cell, so every box on every level is occupied and
# the traversal below is the full Hilbert curve rather than a subsequence of it.
side = 8
points = [SVector(i + 0.5, j + 0.5) for i in 0:(side - 1) for j in 0:(side - 1)]
tree = buildtree(points; builder=TwoNTreeBuilder(; minhalfsize=0.5, minvalues=0))
# `nodesatlevel` is Hilbert-ordered within each level, so node centers trace the curve directly.
levelcurves = map(H2Trees.levels(tree)) do level
nodes = H2Trees.nodesatlevel(tree, level)
centers = [H2Trees.center(tree, node) for node in nodes]
return (getindex.(centers, 1), getindex.(centers, 2), nodes)
end3D — the deepest level here is the leaf level, so its path is exactly the order a Iterators.partition over the leaf id range would chunk:
using H2Trees
using PlotlyJS
using StaticArrays
# A complete 4x4x4 grid, one point per finest cell, so every box on every level is occupied and
# the traversal below is the full Hilbert curve rather than a subsequence of it.
side = 4
points = [
SVector(i + 0.5, j + 0.5, k + 0.5) for i in 0:(side - 1) for j in 0:(side - 1) for
k in 0:(side - 1)
]
tree = buildtree(points; builder=TwoNTreeBuilder(; minhalfsize=0.5, minvalues=0))
# `nodesatlevel` is Hilbert-ordered within each level, so node centers trace the curve directly.
levelcurves = map(H2Trees.levels(tree)) do level
nodes = H2Trees.nodesatlevel(tree, level)
centers = [H2Trees.center(tree, node) for node in nodes]
return (getindex.(centers, 1), getindex.(centers, 2), getindex.(centers, 3), nodes)
endThese examples are deliberately complete grids. On a sparse tree only occupied boxes are stored, so the drawn path would skip cells: still Hilbert-ordered, but no longer face-adjacent step to step. See Sparse levels below.
Deep, non-uniform trees
Real geometry is rarely a complete grid. The figures below use a dense core plus a sparse halo, with minvalues stopping subdivision where the points thin out: so the tree runs much deeper (9–15 levels here), only a fraction of each level's cells exist, and leaves appear on many different levels rather than all at the bottom.
Stepping the slider shows what that costs and what survives. The path visibly jumps between distant cells, because only occupied boxes are stored and the stored order is a subsequence of the full curve: the caveat spelled out below. What still holds at every level is the ordering itself: increasing node id follows the Hilbert order of the boxes that do exist. Each level's title also reports how many of its nodes are leaves, which is the cross-level leaf distribution that makes H2Trees.leaves non-contiguous for these trees.
1D (15 levels):
using H2Trees
using PlotlyJS
using StaticArrays
using Random
# Dense cluster plus a sparse tail: `minvalues` keeps splitting the cluster long after the tail
# has stopped, so the tree runs far deeper than the uniform figure above and its leaves sit on
# many different levels.
Random.seed!(21)
points = vcat([SVector(0.02 * randn()) for _ in 1:300], [SVector(randn()) for _ in 1:60])
tree = buildtree(points; builder=TwoNTreeBuilder(; minvalues=8))
levelcurves = map(H2Trees.levels(tree)) do level
nodes = H2Trees.nodesatlevel(tree, level)
return ([H2Trees.center(tree, node)[1] for node in nodes], nodes)
end2D (11 levels) — the deeper levels cover only the dense core, which is why the drawn boxes shrink into one corner of the root:
using H2Trees
using PlotlyJS
using StaticArrays
using Random
# Strongly non-uniform: a dense core plus a sparse halo. With `minvalues` stopping subdivision
# once a box holds few enough points, the core keeps splitting long after the halo has stopped,
# so the tree is deep, adaptive, and has leaves on many different levels.
Random.seed!(22)
points = vcat(
[SVector((0.03 .* randn(2))...) for _ in 1:600], [SVector(randn(2)...) for _ in 1:150]
)
tree = buildtree(points; builder=TwoNTreeBuilder(; minvalues=12))
# Same walk as the uniform figure: `nodesatlevel` is increasing node id, which within a level is
# Hilbert order. Only the geometry changed.
levelcurves = map(H2Trees.levels(tree)) do level
nodes = H2Trees.nodesatlevel(tree, level)
centers = [H2Trees.center(tree, node) for node in nodes]
return (getindex.(centers, 1), getindex.(centers, 2), nodes)
end3D (9 levels):
using H2Trees
using PlotlyJS
using StaticArrays
using Random
# Dense core plus sparse halo, as in the 2D adaptive figure: `minvalues` stops subdivision early
# in the halo and late in the core, so leaves end up spread over many levels.
Random.seed!(23)
points = vcat(
[SVector((0.05 .* randn(3))...) for _ in 1:800], [SVector(randn(3)...) for _ in 1:250]
)
tree = buildtree(points; builder=TwoNTreeBuilder(; minvalues=20))
levelcurves = map(H2Trees.levels(tree)) do level
nodes = H2Trees.nodesatlevel(tree, level)
centers = [H2Trees.center(tree, node) for node in nodes]
return (getindex.(centers, 1), getindex.(centers, 2), getindex.(centers, 3), nodes)
endThese are exactly the trees where the level-major layout earns its keep: see Adaptive trees for the measured effect on near-field chunking.
Balanced trees: leaves are one contiguous chunkable range
When H2Trees.checkbalancedtree is true, every leaf sits on the deepest level. Since that whole level is contiguous, the leaves are one contiguous Hilbert-ordered id range and can be chunked directly:
leafids = leaves(tree)
@assert leafids == collect(first(leafids):last(leafids))
for chunk in Iterators.partition(first(leafids):last(leafids), chunksize)
# geometrically local, Hilbert-ordered work
endNo permutation array, no stored Hilbert keys, no sorting at runtime: the ids themselves are the ordering.
What is not guaranteed
Sparse levels: locality, not strict adjacency
Hilbert continuity gives face adjacency for consecutive cells of a complete grid. H2Trees only stores occupied boxes, so a sparse level's stored order is a subsequence of the full Hilbert traversal:
full Hilbert grid: A B C D E F G H I ...
stored boxes: A B C H I ...
resulting order: A B C H IC and H are consecutive ids but need not touch. The guarantee is:
Increasing node ids follow the Hilbert order of the occupied boxes, and therefore have good spatial locality.
It is not:
Every pair of consecutive node ids is geometrically adjacent.
Adaptive trees: no globally contiguous leaf block
If leaves exist on several levels, it is impossible to have both "every level is one contiguous id block" and "all leaves form one contiguous id block". H2Trees chooses level-major. So for an adaptive tree each level is still contiguous and Hilbert-ordered, but leaves(tree) is spread across several level blocks and is not a contiguous range. Only check for a contiguous leaf range after confirming H2Trees.checkbalancedtree.
Chunking an adaptive tree's leaves therefore needs one extra step — sort them by id first:
leafids = sort(leaves(tree)) # NOT a contiguous range, but level-grouped and Hilbert-ordered
for chunk in Iterators.partition(leafids, chunksize)
# leaves of one level (mostly), in Hilbert order
endThis is worth doing rather than partitioning leaves(tree) directly, which comes back in depth-first order. Near-field coupling is predominantly same-level, so grouping by level puts same-scale boxes with heavily overlapping near fields into one chunk, while depth-first order interleaves scales. Measured with benchmark/locality.jl (distinct near boxes per leaf, lower is better), sorting by id needs 1.14x–1.63x fewer distinct near boxes per chunk than depth-first order across several adaptive geometries.
For balanced trees the two orders coincide, so the sort is harmless there and the plain first(leafids):last(leafids) range above stays the cheapest option.
Ids are only stable within one tree
The layout is a property of a constructed tree. Rebuilding with different builder settings (different minhalfsize, minvalues, protrusion policy, …) changes the partition and therefore the ids. Do not persist node ids across builds, and do not compare ids between two separately built trees: compare geometry instead.
Implementation notes
Hilbert mathematics lives in the internal H2Trees.HilbertOrdering submodule, which supports dimensions 1, 2, and 3 and throws an ArgumentError outside that range. The 3D convention is the long-standing H2Trees one (root sector order 0, 1, 3, 2, 6, 7, 5, 4); 2D uses 0, 1, 3, 2; 1D is the natural interval order.
Renumbering happens exactly once, during bulk construction, before the tree and its TreeIndex exist: so no caller can ever observe the temporary construction ids. It costs O(numberofnodes(tree)) and leaves no Hilbert state on the finished tree: normal tree use carries no Hilbert overhead at all.