Skip to content

PackedBVH direct SFC-build (no TreeBVH) + AABBT::getDistance2#106

Draft
rmrsk wants to merge 76 commits into
mainfrom
particle_soa_pr3
Draft

PackedBVH direct SFC-build (no TreeBVH) + AABBT::getDistance2#106
rmrsk wants to merge 76 commits into
mainfrom
particle_soa_pr3

Conversation

@rmrsk

@rmrsk rmrsk commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #92

Background

Solution

Side-effects

Alternative solutions

Reviewer checklist (to be completed by a human)

  • The test suite compiles and runs to completion without warnings or errors.
  • All relevant new features are documented in the user documentation (Sphinx).
  • This contribution does not break existing sections in the user documentation.
  • All relevant APIs are documented in the doxygen documentation.
  • Appropriate labels have been assigned to this PR.
  • New or revised proper licensing and copyright information is in place.
  • A PR review has been run using @claude review.
  • The continuous integration and testing hooks at GitHub run to completion.

rmrsk and others added 20 commits July 9, 2026 12:04
…lueStorage)

Lets TreeBVH::pack()/packWith() choose how the resulting PackedBVH stores
each primitive: the default SharedPtrStorage<P> keeps today's shared_ptr
behavior unchanged, while the new ValueStorage<P> stores primitives inline
with no pointer indirection, for small value-type primitives (e.g. points
or particles) where per-access indirection and heap ownership overhead
aren't worth it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…MeshSDF pointer-only

TriMeshSDF and Parser::readIntoTriangleBVH now take an explicit StoragePolicy
template parameter (defaulting to BVH::ValueStorage<TriangleSoAT<T, W>>) so
callers can choose inline storage or opt back into BVH::SharedPtrStorage.
TriMeshSDF's SoA groups are freshly built during packing and shared with
nothing else, so storing them by value is both safe and, by dropping a
heap allocation and pointer indirection per group, the better default.

MeshSDF/Parser::readIntoPackedBVH deliberately do NOT get the same
treatment: DCEL::FaceT's copy constructor does not copy its cached 2D
polygon embedding (m_poly2), so a BVH::ValueStorage-style plain copy left
every packed face with a null embedding, crashing on the first
signedDistance() query -- caught by a test added alongside this change.
MeshSDF stays hardcoded to BVH::SharedPtrStorage<Face>, sharing each
packed face with the DCEL mesh's own face list instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…p unused virtual

TreeBVH's copy constructor/assignment are now deleted: it is a recursive
structure of shared_ptr-linked children, and the implicitly-generated copy
would only alias the same mutable child subtrees rather than cloning them,
which topDownSortAndPartition()/bottomUpSortAndPartition() could then mutate
out from under a supposedly independent "copy". Move is explicitly defaulted
to restore what the user-declared destructor otherwise silently suppresses.

PackedBVH's copy and move are explicitly defaulted instead: its members are
all owned value containers with no shared mutable substructure, so the
implicit deep copy is already correct under both BVH::SharedPtrStorage and
BVH::ValueStorage. Its destructor's `virtual` is also dropped -- nothing in
the repository subclasses PackedBVH, so it served no purpose.

Drive-by: FlatMeshSDF/MeshSDF/TriMeshSDF get the same explicit copy/move
treatment. Their only members are shared_ptr, so copying was already a
cheap, correct handle-copy; only move needed restoring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…stance2

Adds a PackedBVH constructor that builds directly from a flat, by-value
primitive list -- no TreeBVH, no per-node shared_ptr<TreeBVH> allocation,
and (with StoragePolicy = ValueStorage<P>) no per-primitive shared_ptr
either. Primitives are sorted along a space-filling curve (SFC::Morton by
default, SFC::Nested selectable via a trailing tag argument -- a
constructor template's own parameters can't be explicitly named the way a
regular function template's can), then cut into leaves by one linear scan
at a caller-chosen target leaf size, rather than deriving a leaf count
purely from primitive count and K the way TreeBVH::bottomUpSortAndPartition
does. The resulting leaf count is padded up to the next power of K (by
re-using the last real leaf's node rather than an empty placeholder) so
every interior node still has exactly K children -- no change to Node's
shape or to traverse()/pruneTraverse().

Factored the centroid-binning math shared by this and
TreeBVH::bottomUpSortAndPartition (including the degenerate-axis guard) into
a new BVH::computeSFCBins() free function, used by both.

Also adds AABBT<T>::getDistance2(), a sqrt-free squared-distance companion
to getDistance() for callers that only need distance comparisons (e.g. BVH
pruning). Not yet wired into existing traversal call sites -- that remains
a separate, flagged follow-up, matching how the original design discussion
scoped it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Builds the same random point cloud five ways -- TreeBVH top-down (default
centroid split), TreeBVH top-down with the SAH partitioner, TreeBVH
bottom-up along the Morton and Nested space-filling curves, and PackedBVH's
direct constructor (no TreeBVH at all) -- and reports build time for each
at a few point-cloud sizes, cross-checking every result against a
brute-force nearest-neighbor scan first.

For the four TreeBVH-based strategies, reports both the TreeBVH build time
alone and the additional pack() time, since their sum is the fair number to
compare against the direct constructor's single "already queryable" time --
answering the open question from EBGeometry issue #92 about whether
SFC-based construction still beats top-down once shared_ptr overhead is
removed from the comparison, with a reproducible, versioned benchmark
instead of ad hoc scratch numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…in BuildBVH

New PackedBVH constructor overload reuses the existing (shared_ptr-based)
Partitioner/LeafPredicate machinery -- BVCentroidPartitioner,
BinnedSAHPartitioner, or any custom one -- exactly as
TreeBVH::topDownSortAndPartition() does, but writes nodes directly into the
flat node array in depth-first pre-order as the recursion unwinds, instead
of building a persistent TreeBVH first. Top-down recursion visits the root
before its children, so (unlike the SFC-build constructor) no relayout pass
is needed. A lightweight, stack-local TreeBVH is constructed and
immediately discarded at every split purely to reuse LeafPredicate's
existing signature and read off its primitive list -- proportionate to
what topDownSortAndPartition() already does at every node, avoiding only
the *persistent* shared_ptr<TreeBVH> node allocation that dominates the
traditional path's build time.

Verified bit-for-bit equivalent to TreeBVH-then-pack() with the same
partitioner. Examples/BuildBVH now benchmarks all three direct-constructor
variants (Morton, TopDown, SAH) against their TreeBVH-based counterparts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…uild

strategies x both paths in BuildBVH

New partitioner splits primitives by the midpoint of their bounding-volume
centroids' extent along the longest axis, in a single std::partition pass
-- no sorting (unlike BVCentroidPartitioner) and no per-plane cost
evaluation (unlike BinnedSAHPartitioner). K groups are produced via the
same recursive 2-way-split structure BinnedSAHPartitioner already uses
(MidpointKWaySplit mirrors SAHKWaySplit exactly), so it drops straight into
both TreeBVH::topDownSortAndPartition() and PackedBVH's direct top-down
constructor with no other changes needed.

Examples/BuildBVH now benchmarks all five construction strategies
(TopDown, SAH, Midpoint, Morton, Nested) via both the traditional
TreeBVH-then-pack() path and the matching direct constructor, in one
unified table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Reviewed TreeBVH::bottomUpSortAndPartition() and PackedBVH's direct
SFC-build constructor for avoidable reallocation. Both grow several
vectors via emplace_back()/push_back() in a loop without reserving
capacity first, causing repeated reallocation/copy passes as they grow:

- bottomUpSortAndPartition(): sortedPrimitives (size known up front: total
  primitive count), the leaf-level nodes[treeDepth] (size known: numLeaves),
  each leaf's own primsAndBVs (size known: its exact primitive range), and
  each merge level's nodes[lvl] (size known: numNodesAtLevel). Also replaced
  emplace_back(std::make_tuple(...)) with in-place tuple construction,
  avoiding one temporary tuple move per primitive.
- PackedBVH's direct SFC-build constructor: leafRanges (estimated from
  primitive count / target leaf size) and, more significantly, the scratch
  node array -- only its initial paddedLeafCount leaves were reserved, so
  every interior-node emplace_back() during the upward merge could
  reallocate; now reserved to the exact total node count for a full K-ary
  tree up front ((paddedLeafCount * K - 1) / (K - 1)).

Pure reserve()/construction-site changes -- no behavior change, confirmed
by the full existing test suite (including the degenerate-axis regression
tests, which exercise this exact code path). The effect is real but modest
relative to the dominant build costs (shared_ptr allocation, sorting) at
the sizes Examples/BuildBVH benchmarks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…imitive

New first-class library primitives for the point-cloud/particle use case
from EBGeometry issue #92, analogous to Triangle<T,Meta>/TriangleSoAT<T,W>
but deliberately simpler:

- Point<T, Meta>: a bare position + metadata, no orientation. Since a
  point has no inside/outside notion, it only ever reports unsigned
  distance (getDistance()/getDistance2()), never a signed one.
- PointSoAT<T, W, Meta>: Structure-of-Arrays packing of W points (mirroring
  TriangleSoAT's pack()/computeBoundingVolume() shape and alignas layout),
  carrying each point's metadata alongside its position -- unlike
  TriangleSoAT, which currently drops per-triangle metadata during packing
  (see issue #105). getMetaData(lane) retrieves a given lane's metadata.

Both getDistance()/getDistance2() currently use a scalar fallback loop
only; a SIMD dispatch (mirroring TriangleSoAT::signedDistance()'s
SSE4.1/AVX/AVX-512F horizontal-min-reduction shape, substantially simpler
here since there's no triangle inside/outside feature classification to
do) is flagged as a natural follow-up once this shape is confirmed, not
yet implemented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Replaces the previous Point<T,Meta>/PointSoAT<T,W,Meta> pair with a
cleaner separation of concerns:

- Point<T,Meta> is removed entirely -- a bare position is just a
  Vec3T<T>, no dedicated wrapper class needed.
- PointSoAT<T, W> is now genuinely SoA: position-only (x[W]/y[W]/z[W]),
  no metadata anywhere in the type. pack() takes a plain Vec3T<T> array.
- PointAoSoA<T, Meta, W> is a new, separate wrapper: one PointSoAT<T,W>
  member plus a std::array<Meta, W> alongside it. getDistance()/
  getDistance2() delegate straight through to the embedded PointSoAT and
  never touch metadata at all -- not just unused, but a different member
  entirely -- so a nearest-neighbor traversal over PointAoSoA-packed
  leaves touches exactly the same bytes it would over bare
  PointSoAT-packed leaves. Metadata is retrieved separately, by lane, via
  getMetaData(), only after a query already knows which lane won.

This makes explicit what was previously only true by accident (metadata
happened to be a separate, if adjacent, member): the split is now a type
boundary, not just a field-ordering convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Mirrors TriangleSoAT::signedDistance()'s per-ISA if-constexpr dispatch
(AVX-512F, SSE4.1, AVX, including the AVX two-pass lo/hi split for
double W=8) instead of always falling back to the scalar loop. Add
W=8 test coverage alongside the existing W=4 cases so the AVX
(float,W=8) and two-pass (double,W=8) branches run on hardware that
actually supports them, not just compile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Mirrors TriangleSoA's own convention (PointSoA::DefaultWidth<T>()
already existed as a free function, matching the same ISA table) by
wiring it in as the class-level default for W on both PointSoAT<T,W>
and PointAoSoA<T,Meta,W>, since -- unlike triangles -- there is no
higher-level Parser entry point yet to apply the default at instead.
Covered by a new test per class confirming PointSoAT<T> ==
PointSoAT<T, PointSoA::DefaultWidth<T>()> (and the PointAoSoA
equivalent) and that the default-width group packs/queries correctly
end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Demonstrates the PointAoSoA<T, Meta, W> primitive as a PackedBVH leaf for
nearest-neighbor search over a 50,000-point cloud in the unit cube, each point
carrying its list index as metadata. Points are Morton-sorted into
spatially-coherent PointAoSoA<T, int, W> groups (W = PointSoA::DefaultWidth<T>()),
then the same groups are built into a PackedBVH four ways -- Morton (SFC),
top-down centroid, midpoint, and SAH -- and 500 random queries are resolved via
pruneTraverse(), tracking a running squared distance as the pruning bound (no
sqrt in the hot path; getMetaData() read only once, for the winning group).

Every strategy is cross-checked against a brute-force scan, and the output table
reports build time, query time, speedup, leaf visits, and groups-per-leaf side by
side -- the query-time mirror of Examples/BuildBVH's build-time comparison.

The pruning bound is squared distance specifically: pruneTraverse() compares it
against the squared box distance, so a linear bound would (in the unit cube, where
distances are < 1) be far too loose and balloon leaf visits ~6x. Leaf size is
tuned via a single maxLeafGroups = 16 knob shared by all four strategies; a
leaf-size sweep showed queries keep speeding up from ~K groups/leaf through ~16-32
(fewer, fatter leaves amortize traversal overhead) before plateauing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
BVH::ValueStorage::appendTreeLeaf reserved a_dst to size + leafSize on
every leaf as a tree was packed. Reserving to an each-time-slightly-larger
exact size defeats std::vector's geometric growth, forcing a reallocation
(copying every already-appended primitive) per leaf and making the whole
build O(N^2) in the primitive count. Both ValueStorage build paths that
append leaves one at a time -- TreeBVH::pack<ValueStorage>() and the direct
top-down PackedBVH constructor -- were affected; the SFC/Morton path uses
appendAliased (one bulk move) and was already linear.

Dropping the reserve and letting push_back grow geometrically makes pack
linear: on a 100k-primitive ValueStorage build, pack drops from ~19.8 s to
~17 ms (measured), and the ClosestPoint example's SAH/TopDown/Midpoint
build times fall from ~90-150 ms to single-digit ms, with byte-identical
query results (this was purely a build-speed bug).

Adds a TestBVH regression case that builds a 60k-primitive ValueStorage
PackedBVH through both affected paths and checks nearest-neighbor
correctness; at that size a reintroduced quadratic blows past the unit-test
timeout while the linear build stays well under a second.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The scalar branch-and-bound path keyed each node on getDistanceToBoundingVolume()
(a linear box distance -- a sqrt) and then compared d*d against the squared pruning
bound, i.e. it took a square root only to square it right back on every box test.
The SIMD paths already work entirely in squared distance; this makes the scalar
fallback consistent.

Adds Node::getDistanceToBoundingVolume2() (squared, via AABBT::getDistance2()) and
switches the fallback's node key + prune predicate to squared distance throughout.
Child ordering is unchanged (squaring is monotonic). Only the scalar path is
affected -- the AVX/AVX-512 hot paths were already sqrt-free -- so this speeds up
no-SIMD builds and any (T,K) without a compiled SIMD path (measured ~8% on a
scalar-compiled 100k-point nearest-neighbor query); the AVX example is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…ever

Adds a final measurement to the example: the same 500 queries, run through the
SAH BVH in generation order versus Morton-sorted order, with the resulting
speedup. The nearest-neighbor query is memory-latency bound (dependent node loads
down a tree that does not fit in L1/L2), so issuing spatially-near queries
consecutively reuses warm cache. Sorting the query *batch* along the same Morton
curve used to build the tree cuts misses with no change to the data structure or
the distance math -- ~1.15x at this scale (50k points, 500 queries), growing to
~1.5x at 100k/5k as the tree grows relative to cache. Batch-only: a single online
query, or queries fixed in arrival order, cannot benefit.

Adds mortonOrder() and timeQueries() example-local helpers and documents the lever
in the README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…m docs

Converged cleanup of the example:

- Derive the BVH branching factor K from BVH::DefaultBranchingRatio<T>() instead of
  hardcoding 4, so the K-wide SIMD box test fills one register per ISA/precision
  (4 for double, 8 for float on AVX), matching how W already derives from
  PointSoA::DefaultWidth<T>().

- Morton-sort the query batch once, after the trees are built, and run every
  strategy over that shared order (previously a separate side demonstration). The
  cache-locality speedup is now simply part of the benchmark; timeQueries() and the
  standalone demo section are gone.

- Trim the over-long comments in main.cpp (removing stale references) and shorten
  README.md from 137 to 87 lines, in line with the other examples.

No behavior change to the queries themselves -- results are still verified against
brute force for both precisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Pulls reusable pieces out of the ClosestPoint example into the library and
regroups the example's header so it reads better for a first-timer.

New EBGeometry_Random.hpp / EBGeometry_RandomImplem.hpp: a centralized RNG home.
Random::samplePoints(count, seed) -> vector<Vec3T<T>> in the unit cube replaces
the example's local makeRandomPoints().

SFC: computeSFCBins moves out of BVH (it is an SFC operation, not a BVH one) into
SFC::computeBins in EBGeometry_SFC.hpp/Implem, and a new SFC::order(points, S{})
returns the index permutation ordering points along a space-filling curve -- the
one-call form of the bin/encode/sort pattern the example (and users) need. Binning
is curve-independent, so computeBins takes no curve type; the curve lives in
order(), which does. The two internal BVH builders keep their move-aware
primitive+BV+code sort and just call the relocated SFC::computeBins.

ClosestPoint: use Random::samplePoints and SFC::order (dropping the two local
helpers), derive K from BVH::DefaultBranchingRatio<T>() rather than hardcoding, and
group the top-of-file declarations into three batches (SIMD widths, type aliases,
run configuration) with trimmed comments.

Adds TestRandom and SFC::order/computeBins cases to TestSFC; updates the mainpage
namespace table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Reduce the example's output to just the header (widths, K, point/query counts,
target leaf size) and the final timing table. Drop the intermediate prose lines
(all-agree, metadata-index match, average distance).

Replace the always-on cerr/exit correctness check with EBGEOMETRY_EXPECT: every
query's BVH nearest squared distance must match brute force's, so building with
-DEBGEOMETRY_ENABLE_ASSERTIONS aborts on any mismatch (the ctest examples run has
assertions on), and the check compiles out otherwise. A volatile sink keeps each
query's result -- metadata rescan included -- observably used, so the benchmark
measures the same work whether or not assertions are enabled.

Removes the now-unused StrategyResult::indexMatches and benchmarkStrategy's label
argument; updates the README's Running section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The degenerate-axis test's inner stopCrit lambda captured [K] with a NOLINT, but
NOLINT only silences clang-tidy -- not the compiler's -Werror,-Wunused-lambda-capture,
which broke the clang++-14 test build (surfaced in the SIMD=avx512 cell).

The two toolchains genuinely disagree about the local constexpr K in this
nested-lambda context: g++ errors "K is not captured" with no capture list, while
clang -Werror rejects an explicit [K] as unused. A [=] capture-default satisfies
both (GCC has a capture-default; clang does not flag defaults). Verified the full
test suite compiles under clang++-18 + -DEBGEOMETRY_SIMD=avx512 + -Werror, and g++
still passes. ([&] is avoided -- g++ 13 ICEs on a by-reference capture here.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
@rmrsk rmrsk self-assigned this Jul 10, 2026
@rmrsk rmrsk added enhancement New feature or request bvh Affects the bounding volume hierarchies functionalities labels Jul 10, 2026
rmrsk and others added 5 commits July 10, 2026 10:04
… helpers

- Collapse the two near-identical structs (Nearest result, SearchState traversal
  state) into a single Nearest{dist2, index}: both are a squared distance plus an
  index, so bvhNearest now carries a Nearest through pruneTraverse (index = winning
  group) and resolves it to the point index at the end. Adds an EBGEOMETRY_EXPECT
  that the traversal set an index.
- Rename the leaf-primitive alias Group -> PointGroup throughout.
- Collect both structs together above the functions, with trailing (///<) doxygen
  on their members.
- Give buildGroups, bruteForceNearest, bvhNearest, and benchmarkStrategy full
  doxygen blocks (@brief/@param/@return).

No behavior change. Verified clean on g++ and clang++-18 with -Wall -Wextra
-Wpedantic -Werror (both precisions, with and without assertions), plus clang-tidy
and the examples ctest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…ment

encode() is static, so SFC::order never needed an actual curve instance -- the
trailing tag argument only existed for template deduction. Replace it with a pure
template parameter, placed first (template <class Curve = Morton, class T>) so the
curve can be named while T is still deduced from the points: order<SFC::Nested>(pts),
or order(pts) for the Morton default. Verified default-before-deduced compiles on
g++ and clang++-18. Updates the example and TestSFC call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
- Switch the example's wording from "nearest neighbor" to "closest point"
  throughout (comments, doxygen, output header, README), and rename the matching
  identifiers: struct Nearest -> Closest, bruteForceNearest -> bruteForceClosest,
  bvhNearest -> bvhClosest. This example resolves a batch of independent closest-point
  queries, not a nearest-neighbor graph over the cloud; the terminology now matches.
- Extract the results-table printRow lambda out of main() into a documented function
  below benchmarkStrategy.

No behavior change. Clean on g++ and clang++-18 (-Wall -Wextra -Wpedantic -Werror,
both precisions), clang-tidy, and the examples ctest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…queryPoints

The query-batch Morton sort was ~1.13-1.18x at this example's scale (50k points,
500 queries) -- a real but modest cache-locality win that added a conceptual
detour (an extra Morton pass over the queries plus a paragraph on memory latency)
not worth it for a first-time-reader example. Query the points in generation order
instead; all four strategies still see the identical order, so the comparison stays
fair, and none of the table's conclusions change.

Also rename the query vector rawQueries -> queryPoints (it is no longer "raw" vs a
sorted copy), and fix a stale README line that described a final sqrt for an
average-distance report the example no longer prints. SFC::order remains, used to
group the cloud into spatially-coherent leaves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Make the PointGroup metadata (and the Closest::index field it resolves to) size_t
instead of int. The point-cloud indices are naturally size_t, so this removes the
casts on metadata assignment, the brute-force/traversal index writes, and the
positions[]/groups[] lookups -- only the genuine uint32_t pack-count and long long
statistic casts remain. The former -1 "none" sentinel is gone (dist2 == max already
signals nothing-found); the bvhClosest invariant now checks dist2 instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
@rmrsk rmrsk marked this pull request as draft July 10, 2026 09:02
Turn the single ClosestPoint example into two standalone examples that
demonstrate the two construction paths for a PackedBVH over PointAoSoA leaves:

- ClosestPointSFC (renamed from ClosestPoint): the space-filling-curve /
  direct path. Points are Morton-sorted and chunked into PointAoSoA groups up
  front, then the same groups are built into a PackedBVH four ways (Morton,
  TopDown, Midpoint, SAH) via the direct constructors and compared.

- ClosestPointPacked (new): the packWith path, mirroring TriMeshSDF. Build a
  TreeBVH over the individual points (SAH, leaf <= maxLeafGroups*W), then
  TreeBVH::packWith() coalesces each leaf's points into PointAoSoA groups in
  place. Partitioning all points rather than pre-formed groups yields a
  higher-quality tree (~1.9 vs ~4.6 leaf visits/query) at a higher build cost,
  with nearly-full groups.

Both are registered in Examples/CMakeLists.txt and Examples/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
rmrsk and others added 14 commits July 10, 2026 19:39
The earlier "~32-64 groups is the query sweet spot" came from a lean scratch
query (direct min + cached bound). Bumping the examples' maxLeafGroups 16->32 and
re-measuring showed the OPPOSITE: query got slower (SFCPacked SAH 0.65->0.71,
TreePacked SAH 0.44->0.53) while build got faster. The examples' real query is
heavier per lane (Knn::tryInsert + worst2() as the prune bound), so scanning the
bigger leaves' extra points costs more and pushes the optimum back to ~16. Keep
maxLeafGroups=16 for the examples; bigger leaves win only with a lean per-point
scan. Lesson: profile the actual query loop, not a lean proxy. Examples unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…la nanoflann

Wire the full optimization stack into the EBGeometry side of the comparison:
seed-from-own-leaf + skip (buildLeafMap builds the point->leaf map once per
tree), Hilbert-order queries, on the lean kNN=1 query loop. Report each tree as
plain top-down vs seed+skip, and time nanoflann both vanilla (natural order) and
on the same spatial ordering. Drop the now-unused natural-order EBGeometry runs.

Measured (double, 500k, us/pt): our best (SAH-over-points, seed+skip) ~0.33-0.35
vs vanilla nanoflann ~0.58 (natural order) or ~0.27 (queries pre-sorted). So with
our optimizations EBGeometry is ~1.7x FASTER than vanilla natural-order nanoflann
on query, and ~1.3x slower on equal spatial ordering (down from ~2x unoptimized);
build stays ~6x nanoflann's for the tight tree, ~2x for cheap-build ClusterSAH.
Results cross-checked identical. seed+skip alone is worth ~20-35%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…bing

Broke down the tight SAH-over-points build (~390 ms double 500k): make_shared
wrap 29 ms, TreeBVH ctor 15 ms, SAH topDownSortAndPartition 330 ms (79%),
packWith 42 ms. So the tight-tree build is dominated by the SAH partitioner, not
the shared_ptr plumbing (that was the Midpoint story). Binned SAH over 500k
points is inherently ~5-6x nanoflann's median-split kd-tree build.

Prototyped longest-axis SAH (bin only the longest centroid-bbox axis instead of
all 3): measured ~23% off the SAH build (390->301 ms) with zero query cost (0.31
vs 0.32 us/pt, same leaf visits) -- a free win, best as an option not the
default. Other levers noted (index-based build, radix-sort Morton codes, LBVH +
treelet as the real gap-closer). Prototype reverted; Source unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Swept ClusterSAH maxClusterSize with the lean seed+skip query (double 500k): the
build->query knee is at ~16 (build 23ms, 2.5x faster than nanoflann, query 0.42);
the current default 8 is past the knee (2x build for the same query). But the
leaf-size trap recurs: in the examples' general-Knn query, 8->16 regresses query
(0.71->0.81) while halving build, because bigger clusters -> bigger leaves ->
heavier per-lane tryInsert scan. Examples keep maxClusterGroups=8. The recurring
lean-vs-general divergence argues for specializing the kNN=1 query, which would
unlock the cheaper build (bigger clusters) as a free lunch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…builds)

Add an optional final template flag BinnedSAHPartitioner<T,P,BV,K,LongestAxisOnly>
(default false). When true, SAH bins candidate planes on only the longest
centroid-bounding-box axis instead of all three -- roughly a third of the binning
work. Threaded via a defaulted runtime bool through SAH2WaySplit and SAHKWaySplit
(existing 4-arg callers unaffected).

Measured (SAH-over-points, double, 500k): build 395 -> 312 ms (~21% faster) with
zero query cost (0.31 vs 0.32 us/pt, same leaf visits). The 3-axis default is
kept because it may matter more for mesh BVHs than for near-uniform point clouds.

Adds a TestBVH case (runs under both precisions), an ImplemBVH.rst note, and the
Doxygen @tparam/@param. All 269 tests pass; Doxygen builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…-based machinery

Read nanoflann's kd-tree build. It partitions a single uint32 index array in
place (no per-primitive alloc, no shared_ptr, no sublist copies), pool-allocates
nodes, and splits by longest-axis MIDPOINT (no SAH, no sort). So its build is
essentially our MidpointPartitioner over individual points, index-based.

Two hints, one measured: (1) SAH is unnecessary for the tight tree -- over
individual points, Midpoint/BVCentroid/SAH all give identical query (~0.34 us/pt,
~2.1 leaf/pt); the tightness is from point-granularity partitioning, not SAH. (2)
The remaining ~200ms-vs-57ms gap is build machinery, not the algorithm --
make_shared wrap + per-node sublist copies + packWith. The real improvement is an
index-based, copy-free, pool-allocated top-down build (nanoflann/LBVH style) with
a cheap Midpoint split. Recorded in NOTES; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…90ms)

Prototyped the nanoflann-style build: partition a single uint32 index array in
place by longest-axis Midpoint, pack leaves into PointGroups inline (no
make_shared, no per-node sublist copies, no packWith, no TreeBVH); the
point->own-leaf map falls out for free. Measured (double 500k): build 76ms (vs
current Midpoint-over-points ~275, SAH-over-points ~390; nanoflann ~57), query
0.32 us/pt (2.22 leaf/pt, as tight as SAH; nanoflann ~0.26), 0/400 brute-force
mismatches. Puts EBGeometry within ~1.3x of nanoflann on BOTH build and query,
from a general BVH -- confirming the gap was machinery, not algorithm. Next:
integrate as a direct PackedBVH point-cloud build path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
New public class PointCloudBVH<T, Meta, K, W> subclassing PackedBVH. It is built
directly from a particle cloud (positions + a parallel user-metadata array) via an
index-based, copy-free top-down Midpoint build -- partition an index permutation in
place, pack the PointAoSoA leaves inline, no shared_ptr/PrimAndBVList/packWith --
which is ~3-5x cheaper than the general path (~87ms/500k, near nanoflann's ~57ms)
and, for near-uniform clouds, just as tight to query. Leaves carry the cloud index;
the user's Meta is stored alongside (metadata()/position() accessors).

The class hides pruneTraverse/the SoA leaf kernel/seed-from-own-leaf behind a small
query API: closestPoint/closestPoints (arbitrary external point), nearestNeighbor/
nearestNeighbors (a particle in the cloud, seeded from its own leaf and excluding
itself), and allNearestNeighbors(k) (Hilbert-ordered batch k-NN graph).

To let a subclass fill the packed representation from its own build, PackedBVH gets
a protected "adopt arrays" constructor (moves in the node + primitive vectors and
runs buildSoA). Added: TestPointCloudBVH (both precisions, every query checked vs
brute force), an InstantiateAll entry, doxygen, and an ImplemBVH.rst section. All
270 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The single-nearest hot path (closestPoint/nearestNeighbor/allNearestNeighbors
with k==1) routed through the base PackedBVH::pruneTraverse, which is tuned for
expensive-leaf SDF/mesh queries: at every interior node it does a near-first
ordered descent -- a per-node std::sort of the K children driven by a SIMD
child-AABB kernel that reads a separate m_childAabbSoA side array. For a point
cloud the leaves are cheap SoA groups and AABB tests are abundant and nearly
free, so the ordering machinery costs more than the nodes it prunes.

Replace it on the k==1 path with a plain unordered scalar DFS over the packed
nodes. Traversal-only, 500k self-NN, double: 0.35 -> 0.28 us/pt (~20%). The
general k>1 path stays on pruneTraverse, where the k-best insert dominates and
the tighter bound from ordered descent helps. Behavior is unchanged
(TestPointCloudBVH still passes in both precisions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The four hand-rolled point-cloud examples (ClosestPoint{SFC,Tree}Packed,
NearestNeighbor{SFC,Tree}Packed) each built the whole pipeline by hand: SFC- or
TreeBVH-grouping points into PointAoSoA leaves, building a PackedBVH five ways,
and driving pruneTraverse() with a bespoke state. PointCloudBVH now hides all of
that, so the SFC-vs-Tree construction split no longer exists and the examples
collapse to a handful of lines:

  Examples/ClosestPoint     -- external closestPoint() / closestPoints()
  Examples/NearestNeighbor  -- self allNearestNeighbors() k-NN graph

The old four are moved to Dev/ at the repo root with a DELETE_BEFORE_MERGE.md and
a temporary Dev/** REUSE.toml block, kept only so the two paths can be compared
during review; both are to be removed before merge.

Also fixes a regression in the k==1 query path: the earlier lean unordered scalar
DFS is a ~20% win for seeded self-queries (tight bound up front) but ~17x SLOWER
for unseeded external queries, where the bound starts at infinity and pruneTraverse's
near-first ordered descent is essential. The path now branches on the seed: lean DFS
when seeded, pruneTraverse when not. External closestPoint goes from ~17 to ~1.3
us/query; seeded self-NN keeps the 0.28 us/pt traversal.

Examples/CMakeLists.txt, Examples/README.md, and SIMDClasses.rst updated to the two
new examples; both pass under ctest and EBGEOMETRY_ENABLE_ASSERTIONS in both precisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Add O(N) full-scan counterparts of the accelerated queries --
closestPointBruteForce, closestPointsBruteForce, nearestNeighborBruteForce,
nearestNeighborsBruteForce -- so tests, examples, and downstream debugging can
check a BVH result against ground truth without re-implementing a scan, and so a
suspected tree/traversal bug can be A/B-tested against an unaccelerated path. They
are clearly documented as reference implementations for testing/debugging, not for
production queries (each is linear in the cloud size). Two private cores back them:
bruteForceOne (no-alloc min scan) and bruteForceK (scratch + partial_sort).

Both point-cloud examples now use these for their brute-force baseline and
verification instead of hand-rolled scans, and TestPointCloudBVH gains a section
that checks the reference methods against its own independent oracle and
cross-checks the accelerated queries against them (3320 assertions, was 1440).
ImplemBVH.rst documents the new methods; doxygen passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
- Class @tparam documented a parameter "W"; the actual template parameter is
  named "Width". Renamed the tag to match (the sibling PointAoSoA/PointSoA use
  "W" as the actual name, so only the doc was wrong here, not the code).
- query()'s @param a_exclude referenced "Base::npos()" as the exclude-nothing
  sentinel, which does not exist; the sentinel is s_none (as the callers and the
  bruteForce* helpers' docs already say).

Documentation audit only; no behavior change. Doxygen builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Match the sibling SoA classes (PointAoSoA, PointSoA, TriangleSoA), which all name
their SoA-width template parameter W. PointCloudBVH was the lone outlier calling it
Width. Pure rename (the default PointSoA::DefaultWidth<T>() is untouched); no
behavior change, tests pass in both precisions and doxygen is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Prototype (Dev/, delete before merge) answering whether a uniform spatial hash
grid should replace the PointCloudBVH tree for the self-kNN use case. Points are
counting-sorted into a dense CSR bucket array and queried with an expanding-shell
search that has an exact stopping rule (best <= R*h after searching Chebyshev
radius R), so it never misses a neighbor; results are verified against brute force.

Finding on a 500k near-uniform cloud: the grid wins clearly -- ~7x faster build
(counting sort, no BVH nodes) and ~1.4x faster query (0.40 vs 0.55 us/pt at ~1
point/cell). README records the caveats that gate actually switching: the win is
specific to near-uniform density (a clustered cloud defeats a global cell size,
where the tree's density adaptivity wins), the dense grid's memory is O(bboxVol/h^3)
(true spatial hashing trades that for a slower constant), only k=1 is prototyped,
and the grid serves only self-kNN (not external closest-point or BVH composition).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…restNeighbor -> NearestNeighborBVH

The names now say which backing structure they use, in anticipation of a
hash-grid counterpart (PointCloudHashGrid, issue #107) that will get its own
NearestNeighborHashGrid example. Pure rename: example directories, their
project/target/binary names, READMEs, cross-references, Examples/CMakeLists.txt,
Examples/README.md, and the SIMDClasses.rst citation. No test changes (the tests
exercise the PointCloudBVH class, not the example names). Both examples still build
and pass under ctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
rmrsk and others added 7 commits July 11, 2026 08:48
…m.hpp

CI runs the clang-format hook across all files; this one had trailing whitespace
on two blank lines that the commit-time hook (changed-files only) never flagged.
No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Dev/ held the four superseded point-cloud examples (replaced by
ClosestPointBVH/NearestNeighborBVH on this branch) and the GridNN prototype
(promoted to the PointCloudHashGrid class, PR #108). It was always marked
DELETE_BEFORE_MERGE; removing it now, along with the temporary Dev/** annotation
block in REUSE.toml. reuse lint stays compliant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Keep the descriptions terse and evaluation intrinsic: drop the seed-from-own-leaf,
cache-locality, Hilbert-ordering, and speedup-extrapolation explanations, keeping
the functional description and the brute-force correctness check. No outward/external
comparisons were present. Both READMEs stay in the sibling-example length range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…re Hilbert in SFC docs

- Remove the branch-carried NOTES-particle-soa.md hand-off note and its REUSE.toml entry.
- Reset .gitignore to match main (drops the now-obsolete nanoflann fetched-header rule).
- Remove the ThirdParty/nanoflann comparison harness.
- Add Docs/Sphinx/source/PointCloud.rst (Implementation page) documenting the PointCloudBVH
  class, moved out of ImplemBVH.rst, and wire it into the Implementation toctree.
- Reference the three new BVH/point-cloud examples (BuildBVH, ClosestPointBVH,
  NearestNeighborBVH) from Examples.rst.
- Capture the Hilbert curve in the SFC docs: BVH.rst (concepts) now lists Morton/Hilbert/Nested
  and notes Hilbert's locality, and the direct-SFC-build note in ImplemBVH.rst mentions
  SFC::Hilbert{} as an option. (The BVH::Build enum lists -- TopDown/Morton/Nested/SAH -- are
  left as-is since that enum has no Hilbert value.)

Docs audit clean: doxygen and Sphinx build without warnings, no dangling references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
"confined to the three places listed below" -> "confined to the places listed below",
so the sentence no longer hard-codes a count that can drift as SIMD sites are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The Chombo example set now mirrors the AMReX naming (MeshSDF, PackedSpheres,
RandomCity, Shapes):

- Remove ThirdParty/Chombo/F18 (an undocumented, one-off example not listed in the docs).
- Rename ThirdParty/Chombo/DCEL -> ThirdParty/Chombo/MeshSDF, matching ThirdParty/AMReX/MeshSDF.
  Updated the README title and reworded its description from "DCEL functionality" to the
  mesh-signed-distance framing the AMReX/MeshSDF README uses (the example already loads a surface
  mesh as an implicit function). The lone EBGeometry::DCEL:: use in main.cpp is a real API type
  and is unchanged.
- Update Docs/Sphinx/source/ThirdParty.rst's Chombo list accordingly.

Documentation audit: the AMReX and Chombo lists in ThirdParty.rst now match the folders on disk
exactly, every ThirdParty example README title matches its path, and no DCEL-folder/F18 references
remain in the sources. reuse lint compliant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The margin was hoisted into `const T tol = tightMargin<T>()`, but tightMargin<T>()
returns double and withinAbsT()'s margin parameter is double. With T=float that made
`tol` a float, so every `withinAbsT(target, tol)` call promoted it float->double at the
call site -- which clang's -Wdouble-promotion flags as an error under -Werror (the
Sanitizers CI job, clang++-14, EBGEOMETRY_TEST_BOTH_PRECISIONS). Declare `tol` as
double to match the margin type. Verified: TestPointCloudBVH builds clean under
clang++-18 with both precisions and -Wdouble-promotion -Werror, and still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
Comment thread Docs/mainpage.md Outdated
| `EBGeometry::BoundingVolumes` | `AABBT` (axis-aligned box) and `SphereT` (bounding sphere) |
| `EBGeometry::Octree` | Pointer-based octree used internally for bounding-volume estimation of arbitrary implicit functions |
| `EBGeometry::SFC` | Space-filling curves (`Morton`, `Nested`) used for bottom-up BVH construction |
| `EBGeometry::Random` | Centralized random sampling utilities (`samplePoints`) |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude remove the 'samplePoints' specification - keep it general

@github-actions github-actions Bot Jul 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @rmrsk's task in 1m 46s —— View job


Tasks

  • Gather context (found samplePoints references in this PR)
  • Understand the request
  • Implement the change
  • Verify the diff
  • Push changes — blocked, see below

What was done

The trigger comment referred to Docs/mainpage.md, line 37 — the newly-added EBGeometry::Random row in the namespace table:

-| `EBGeometry::Random` | Centralized random sampling utilities (`samplePoints`) |
+| `EBGeometry::Random` | Centralized random sampling utilities |

This drops the specific samplePoints function name so the table entry stays general (consistent with how other rows describe capabilities without necessarily enumerating every free function). I checked the rest of the diff (Docs/Sphinx/source/*.rst, Source/EBGeometry_Random.hpp, examples, tests) and this was the only "specification" of samplePoints outside of its actual usage/declaration sites, so no other doc changes were needed.

Committed locally as a19cfde.

Push failed

Pushing the commit to particle_soa_pr3 failed with a permissions error unrelated to the code change:

remote: Permission to rmrsk/EBGeometry.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/rmrsk/EBGeometry.git/': The requested URL returned error: 403

The commit exists locally in this job's checkout but could not be pushed to the branch. This looks like a workflow/token permissions issue (the installation token is resolving to github-actions[bot], which lacks write access) rather than anything about the requested change — @rmrsk, you may need to check the GitHub Action's permissions/token configuration for this trigger.

rmrsk and others added 3 commits July 11, 2026 15:48
Keep the namespace-table entry general (like the other rows, which describe a
capability without enumerating every free function). This is the edit requested in
the PR #106 review thread; applying it directly here since the @claude job could not
push it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
…ting

allNearestNeighbors processed particles in Hilbert order for cache locality, but
recomputed SFC::order<Hilbert> on every call -- a full O(N log N) sort that was ~38%
of the batch cost (measured 0.173 of 0.455 us/pt at 500k). The top-down build already
produces a spatially-coherent particle ordering (the leaf/depth-first permutation), so
that sort is redundant.

Capture the build's index permutation (BuildResult::order, from buildTree's idx after
partitioning) into a new m_order member and iterate it in allNearestNeighbors -- same
cache benefit (leaf order is as spatially coherent as Hilbert; natural order measured
2x slower, so the ordering does matter), zero per-call sort. The SFC include is no
longer needed here.

Measured (500k, double, all-nearest-neighbor): allNearestNeighbors 0.455 -> 0.298 us/pt
(~1.5x); ~1095x over brute force. Results are unchanged (order affects only speed);
TestPointCloudBVH passes in both precisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
The folder holds examples that integrate EBGeometry into third-party application
codes (AMReX, Chombo); "Integrations" describes that more directly than "ThirdParty".
Pure rename: the folder and all its example subdirs, their README titles,
Docs/Sphinx/source/ThirdParty.rst -> Integrations.rst (title, Chap:Integrations label,
:file: paths, index toctree entry), the :ref:`Chap:Integrations` cross-references in
Examples.rst/Quickstart.rst, Examples/README.md, README.md, CLAUDE.md, the .gitignore
build-artifact rules, and the REUSE.toml path globs. The lowercase term "third-party"
(and the unrelated Parsers.rst Chap:ThirdPartyParser label, about parsing third-party
file formats) are left untouched. Docs build clean; reuse lint compliant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5LFQihKoMrE6nbPaQW9R
@rmrsk

rmrsk commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Claude finished @rmrsk's task in 2m 48s —— View job


Review complete ✅

  • Gather context (diff vs origin/main, changed files)
  • Understand the request (@claude review)
  • Review Source/EBGeometry_BVH*.hpp (PackedBVH direct SFC-build, top-down-build, ClusterSAH, AABBT::getDistance2, AVX/AVX-512 pruneTraverse paths)
  • Review Source/EBGeometry_PointCloudBVH*.hpp, PointSoA/PointAoSoA, SFC, Random
  • Review Tests/ coverage for the new constructors/classes
  • Review Docs changes
  • Post review feedback

This is a large PR (76 commits, ~5.9k additions), so I focused on the core new BVH-construction/query code and cross-checked it against the new tests and Sphinx docs rather than every renamed/moved file.

Bugs

1. PointCloudBVH on an empty cloud → out-of-bounds access. buildTree() only calls builder.build(...) when n > 0 (Source/EBGeometry_PointCloudBVHImplem.hpp:177), so for a 0-particle cloud res.nodes stays empty and the base PackedBVH ends up with an empty m_linearNodes. Nothing guards against this — the constructor's only EBGEOMETRY_EXPECT (PointCloudBVHImplem.hpp:32) only checks positions.size() == metadata.size(), and it runs after delegating to buildTree()/the base constructor anyway. Any subsequent query (closestPoint, nearestNeighbor, ...) unconditionally reads m_linearNodes[0] via pruneTraverse()/getBoundingVolume() — UB on an empty vector. Not documented as a precondition anywhere in Source/EBGeometry_PointCloudBVH.hpp, and untested (Tests/TestPointCloudBVH.cpp never builds a 0- or 1-particle cloud).
Fix this →

2. Seeded self-query fast path: undersized fixed stack with no bounds check, and no prune-before-push. In query()'s a_k==1 seeded branch (Source/EBGeometry_PointCloudBVHImplem.hpp:230-250), the custom DFS uses std::uint32_t stack[64] and pushes all K children of every interior node unconditionally — the distance check happens only after popping (node.getDistanceToBoundingVolume2(a_query) >= best.d2), not before pushing. That's unlike the base PackedBVH::pruneTraverse() (EBGeometry_BVHImplem.hpp), which prunes before pushing and reserves a 256-entry stack for the same (K, T) combinations. Since the stack here grows by up to K-1 net entries per DFS level, for K=16 (the AVX-512-float default via BVH::DefaultBranchingRatio), the stack overflows after only ~4-5 tree levels — well within reach for an ordinary large point cloud, not just an adversarial input. This is a real stack-buffer-overflow risk, not an edge case.
Fix this →

Test / doc gaps

3. BVH::ClusterSpec (ClusterSAH direct-build constructor) has zero test coverage and zero Sphinx documentation. PackedBVH(std::vector<std::pair<P,BV>>, BVH::ClusterSpec) (Source/EBGeometry_BVH.hpp:~1511, impl EBGeometry_BVHImplem.hpp:~736) doesn't appear anywhere under Tests/ (grep -rl ClusterSAH\|ClusterSpec Tests/ is empty) — it's only exercised as an unchecked benchmark in Examples/BuildBVH/main.cpp. It's also entirely absent from Docs/Sphinx/source/ImplemBVH.rst/BVH.rst, even though this same PR added a whole "Direct construction (no TreeBVH)" section documenting the two sibling direct constructors right next to it. Per this repo's own CLAUDE.md doc-audit rule, a new public constructor this substantial (two-phase density-adaptive clustering + binned SAH) needs at least a correctness test (vs. brute force / TreeBVH+SAH, both precisions) and a doc mention.

4. Minor gaps:

  • Tests/InstantiateAll.cpp adds PointCloudBVH<PREC, Meta> but not PointSoAT<PREC> / PointAoSoA<PREC, Meta> — both have usable default template args (unlike TriangleSoAT, which requires an explicit W and is consistently absent from that file for that reason) and are presented as public API in Docs/Sphinx/source/SIMDClasses.rst.
  • The direct top-down/SAH PackedBVH constructor has no single-primitive test section, unlike its SFC-build sibling (Tests/TestBVH.cpp:~275).
  • nearestNeighbor()/closestPoint()'s single-result API returns Hit{index=0, distanceSquared=max} when nothing is found (e.g. a 1-particle cloud querying itself) — index 0 is indistinguishable from a real match at particle 0 and this isn't documented; the batch closestPoints/nearestNeighbors at least return a found-count.
  • SFC::computeBins() (EBGeometry_SFCImplem.hpp:239-243) can round a boundary point's bin index one past ValidSpan via a double-division, violating encode()'s precondition. This logic predates this PR (was inline in bottomUpSortAndPartition), but the PR promotes it to a public, directly-callable API, which widens its exposure. Not blocking, but worth a defensive clamp now that it's public.

What looked solid

  • AABBT<T>::getDistance2() / SphereT<T>::getDistance2() — correct, symmetric with getDistance(), well tested in Tests/TestBoundingVolumes.cpp for both precisions.
  • The direct SFC-build and direct top-down PackedBVH constructors — well tested (non-power-of-K leaf counts, degenerate axes, both precisions, ValueStorage/SharedPtrStorage agreement, cross-checked against TreeBVH+pack()).
  • New SFC::Hilbert curve — encode/decode round-trips and locality property well exercised in Tests/TestSFC.cpp.
  • PointSoA/PointAoSoA/Random::samplePoints — no correctness issues found; SIMD kernels have alignment static_asserts, padding/dedup handled consistently, good precision coverage.
  • CI is green on the latest push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bvh Affects the bounding volume hierarchies functionalities enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PointCloudSoA

1 participant