Prune shadowed neighbours in alpha-shape search and add FastInSphereTesterSoS - #6566
Merged
Conversation
After a triangle (#v, p, q) is appended, every neighbour strictly outside the ball just proven empty and strictly inside the wedge of the planes (#v, p, center) and (#v, q, center) is dropped: each ball of the given radius via #v containing such a point has p or q strictly inside, so it can neither make a triangle with #v nor be the last blocker of a ball of others. The output is bit-identical.
The two touching balls of the triangle are mirror images of each other in its plane, so the shadow is the wedge of four planes via #v, one of p and q, and one of the two centers, minus both balls. Each pair of mirrored planes collapses into a single squared comparison without any sign analysis, the filter no longer depends on which side's ball was found empty, and one pass per pair replaces two. The region is twice as large: on a piecewise planar cloud the previous one-sided form dropped nothing at all, since every candidate is in the triangle's plane.
neis is sorted by distance from #v, so with the farther point outside every pair of the closest neighbours is processed before any farther point is touched. Their triangles have the smallest touching balls and thus the largest shadows, and the points those shadows drop are exactly the ones the outer loop has not reached yet. consideredTris -11% / -5% / -3% and inBallTests -7% / -3% / -2% on the spheres and the crossing grids, with the found triangles unchanged.
…oint The two dot products deciding the dihedral angle are computed in double first, and the exact Int256 values only when the approximation is within the tolerance of zero, which cannot change any verdict: the error of the dot products is below 2^78 here. The scan of the neighbours is what the filter spends its time on, so this is worth 6..8% of the whole search on the spheres and the crossing grids.
…iangles The claim behind the filter needs only the existence of the two balls touching #v, p and q, never their emptiness, so the pair does not have to give a triangle. Doing it before the emptiness tests shortens their scans as well. consideredTris -29% and inBallTests -22% on the crossing grids, -0.4% and -6% on the spheres, where almost every touchable pair yields a triangle anyway. The found triangles are unchanged.
shadowTests are the neighbours tested for being shadowed, exactShadowTests those of them the floating-point rejection could not decide, and shadowedNeis those found shadowed and excluded from the search. The increments cost nothing measurable, and they show the rejection stopping 84..90% of the tests: sphere 20k 381631 tested / 40211 exact / 33425 shadowed sphere 50k 840270 / 84267 / 62742 grids 61x61 604657 / 98659 / 22007
pp and qq are neis[i].distSq and neis[j].distSq, which #6551 introduced exactly to avoid recomputing them, and the tail is checked for being empty before anything is prepared. Timing-neutral, the found triangles and all the counters are unchanged.
The chain now ends at the constant itself: coordinate differences below 2^31 make pp and pq below 2^64, the components of up and uq below 2^96 and the dot products below 2^129, whose roundings keep the error below 2^78, and the tolerance takes 64 times that. Measured over 2M configurations at the full coordinate range the worst error is 2^74, so the constant has three decimal orders of margin in practice.
The new class answers exactly as InSphereTesterSoS, only sooner: reset() also computes the sphere's center in double, and a query farther from the surface than the tolerance is decided by one squared distance, leaving the exact predicates and the tie resolution to the points near it. The center splits in the in-plane part M/(2W) and the height S*w/(2W^2), which are orthogonal, so their sum loses nothing to cancellation and flip() only negates the second. The tolerance is rSq*2^-44, sixteen times the error of that distance, which also guarantees that a point exactly on the sphere always reaches the simulation-of-simplicity - on the integer grid nothing else can come that close. The existing classes and their tests are untouched; findAlphaShapeNeiTriangles takes the new one, which is 34% / 36% / 16% of the whole search on the two Fibonacci spheres and the crossing grids, with the found triangles unchanged.
…erSoS The shadow filter had its own copy of the sphere arithmetic; as a method of the tester it is filtered in floating point like the queries, and the same tolerance argument covers it. Both centers are cc_ +- hn_, so the two squared distances come from one subtraction each, and the exact fallback is the A * W > S * |t| of operator() without any tie to resolve: a point on either sphere is not outside it. Worth 5% on the Fibonacci spheres and 1.5% on the crossing grids, and the shadow filter no longer duplicates the predicate.
master replaced boost multiprecision with FastInt<N>/VarBigInt, so the shadow filter and outsideBothSpheres no longer compiled on this branch. Exact path only; the floating-point pre-filters need a bignum->double that master dropped and are re-added separately once measured.
Branch was 14 commits behind; master's FastInt rework (toDouble, MRFastInt.cpp, doubleFromWords) is needed by the pending double pre-filters. File sets are disjoint, so the merge is textual only.
The port to FastInt dropped the floating-point pre-filter because the family has no conversion to double, so every candidate was evaluated exactly. The filter's operands are dot products of point differences below 2^31, which Vector3d represents exactly, so they can be recomputed in double directly instead of converting the exact bignums - 0.7 bits worse, and no dependency on a bignum-to-double conversion. Co-authored-by: meshinspector-agent[bot] <meshinspector-agent[bot]@users.noreply.github.com>
* Add toDouble for the FastInt family The pre-filters that reject a case in double before evaluating it exactly need a bignum-to-double conversion, which the family lacked: MSVC's std::_Signed128 has no such operator at all. It is correctly rounded, and deliberately platform-independent rather than deferring to the built-in __int128_t conversion, since the stated error bound is what makes those rejections safe. * Do not generate a C binding for toDouble( FastInt128 ) mrbind emitted MR_toDouble(__int128 v), which no build accepts: gcc rejects __int128 in a header compiled with -pedantic-errors, and MSVC has no such keyword at all. FastInt128 has no C spelling, and the rest of the family is already ignored for the same reason. * Restore the double query filter in FastInSphereTesterSoS The center was computed exactly only, because the port to FastInt lost the bignum-to-double conversion the floating-point pre-filter needs; toDouble brings it back. W, M and E are converted rather than recomputed in double: E is a difference of two values below 2^322 that cancels completely on exactly the near-degenerate triangles the filter has to decide. --------- Co-authored-by: meshinspector-agent[bot] <meshinspector-agent[bot]@users.noreply.github.com>
Both comments asserted a bound; now they show where it comes from, step by step, including which term dominates - for the wedge filter it is the error of the double dot products propagated through up and uq, not the roundings of the final dot product, and for the tester it is the displacement of the center, of which the conversion of w is a part since its components reach 2^63. The tester comment also records why the tolerance is proportional to rSq instead of absolute: that is what bounds the error below it on both sides of the surface, and an absolute one fitted to the largest coordinates would decide nothing at small ones. Both derivations were re-checked against the exact values: worst error 2^74.6 over 400k configurations for the wedge filter (bound 2^79) and rSq * 2^-50 over 60k near-surface queries for the tester (bound rSq * 2^-48), no wrong decision.
…Inspector/MeshLib into alpha-shape-triangle-shadow
The shadow filter reached w, W and E through a local class deriving from the tester only to widen their access; as accessors of InSphereTesterSoS they serve any caller needing its own exact predicate about the prepared sphere, and findAlphaShapeNeiTriangles declares a plain FastInSphereTesterSoS. Their doc comments give the exact center identity they belong to, and heightSq() states that it is sqr( 2 * h * normalSq() ) rather than h^2, which the member name E did not. MR_BIND_IGNORE on all three, as on everything else mentioning FastInt.
collectedNeis, redundancyTests and redundantNeis were the only stage of the search with no counters at all, although the filter of #6551 runs on every point and is quadratic in its neighbours. On a scanned cloud of 18820 points at radius 0.5 it turns out to be 383M tests dropping 8.42M of the 10.73M neighbours found - the same scale as the shadow filter's 387M tests, and invisible until now. collectedNeis also gives the size the quadratic loops start from, which is what explains the time per point differing by two orders between clouds.
dropShadowed had grown to eighty lines of body and half as much comment inside findAlphaShapeNeiTriangles, reached through a [&] capture of four locals. As a function of an anonymous namespace it states what it needs - the neighbours it prunes, the pair shadowing them, the central point, the tester already reset on their two balls, and the counters - and the derivations read as its documentation rather than as an aside in the middle of another function. insideWedge follows it, having captured nothing in the first place. Pure move: the body is identical after dedenting and renaming the two captures, and on a scanned cloud every counter and the triangle hash are unchanged, with the timings within noise of each other.
The product is below 2^353 and so fits in six words, while its type is seven wide as the sum of its factors'. mulWords skips the zero words of its left operand but runs over every word of its right one, so narrowing first makes the square cost six words by six. The left side stays 1024 bits, which is what its factors' widths add up to, and the right one is widened to it for the comparison as before. Six limb products out of the hundred a full evaluation takes, in the branch reached only by a candidate passing both sign tests: not measurable on the benchmarks, the counters and the triangle hashes are unchanged. It is the narrowest width the bound allows, which is how the rest of the FastInt code is written.
Fedr
added a commit
that referenced
this pull request
Aug 18, 2026
Besides the trivial include conflict, FastInSphereTesterSoS from #6566 meets the degenerate-triangle mode of this branch: there is no exact sphere center to compute in floating point when W == 0, so reset() skips it, the query delegates to the symbolic evaluation of the base class, and outsideBothSpheres conservatively never prunes (an exact answer for the perturbed spheres would need the id of the query point). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A follow-up to #6551. There the filter was pairwise: a neighbour
pshadows a farther neighbourxwhen
pis strictly inside every ball of the given radius via#vthat containsx. Here the sameidea is applied to a pair: as soon as the two balls of the given radius touching
#v,pandqareknown to exist, they shadow a whole wedge, and every neighbour inside it is dropped from the not yet
visited tail of
neis.The output is bit-identical to master, the win is fewer pairs in the quadratic loops and shorter
ballEmptyscans.The conditions
All relative to the origin at
#v. Letp,qbe the two other points of the pair,candcbarthe centers of the two radius-rballs touching0, p, q(mirror images of each other in thetriangle's plane), and
nthe doubled normal of the triangle. Withpp = p*p,qq = q*q,pq = p*qand, for a candidate
x,pd = p*x,qd = q*x,nd = n*x,xx = x*x:W,M,Eare exactly the quantitiesInSphereTester<int>::resetalready computed (Edoes notdepend on which point of the triangle is taken as the origin, since that point is on the circumcircle,
so
|M| = 2*W*circumradiuseither way), and the two centers satisfy exactlyxis dropped iff all three hold, strictly:q's side of both planes(0, p, c)and(0, p, cbar), which isS*bp > |cp*nd|withbp = pp*qd - pq*pdandcp = W*pp*(qq-pq), i.e.bp > 0 && bp^2*E*W > (cp*nd)^2;p's side of both planes(0, q, c)and(0, q, cbar):bq > 0 && bq^2*E*W > (cq*nd)^2withbq = qq*pd - pq*qd,cq = W*qq*(pp-pq);W*(W*xx - M*x) > S*|nd|, which in the notation ofInSphereTester<int>::operator()isA*W > S*|t|, so it reuses the tester's own arithmetic.Substituting the center identity into
cross(p, c)*xgives 1 for thecplane, andS -> -Sgives thecbarplane; the two differ only in the sign of the right-hand side, so requiring both is the singlecomparison with
|cp*nd|above — no sign analysis and no case split anywhere. Writingx = alpha*p + beta*q + gamma*c, conditions 1 and 2 sayalpha, beta > 0, so the shadow is the union ofthe cones spanned by
(p, q, c)and(p, q, cbar)together with the in-plane sector betweenpandq,minus the two balls. Since it is symmetric in
candcbar, it does not depend on which side's ball wasfound empty, and one pass per pair of neighbours is enough.
All arithmetic is exact integer:
bp, bqfitInt256,cp, cqfitInt512, and the squaredcomparisons stay below
2^705, soInt1024is enough — the same tier the SoS path uses. No division andno square root anywhere.
Why dropping is safe
For
xsatisfying 1-3, every radius-rball via#vcontainingxinside or on its boundary containsporqstrictly inside. Hencexcan neither make a triangle with#v(both balls of anytriangle
(#v, x, y)are blocked) nor be the last point blocking a ball of others, and the strictnessmakes it immune to the simulation-of-simplicity tie resolution — exactly as in #6551. Note that
pandqare on both balls, so condition 3 never removes them, and the strictly-inside point the claimprovides is never one of the two points
ballEmptyskips by id.The centers of the radius-
rballs via#vthat contain neitherpnorqstrictly form a region onthe sphere
|c'| = rwhose two corners are exactlycandcbar; conditions 1 and 2 placexin thenormal cone at a corner, and condition 3 says the corner value is negative. The
cbarhalf of the regionalso follows from the
chalf by the reflection in the triangle's plane, which fixes0, p, q, swaps thetwo balls and maps balls via
#vto balls via#v.Condition 3 must be strict for the same reason condition 2 of #6551 must be: for
xexactly on a ballthe touching ball itself leaves
pandqon the boundary, and0, p, q, xare cospherical, sosimulation-of-simplicity decides the tie by ids and
xmay well be needed.Dropping composes: a point that shadowed
xmay itself be dropped later, but only by points present atthat later moment, and the removal times strictly increase, so the chain always ends at a point that is
still in
neiswhen the ball is tested.Verified numerically (scripts are re-derivable from the formulas above):
(sides of the four planes and distances to the two explicitly constructed centers) in every
non-degenerate case;
rvia#vcontainingx:no violation, worst relative margin
1.4e-4; half of them are not caught by the one-sided form;worst relative margin
1.4e-4;every one of the three is load-bearing.
Benchmarks
VS2026 x64 Release, Ryzen 3900X, master and this branch interleaved, 18 samples each (6 for the
cloud), median. The hash of the whole triangle list is identical to master everywhere.
r = 1.5*sqrt(4*pi/n)rr = 0.1r = 0.5The scanned cloud is the case the search is actually used on, and the one with resolution to spare: the
three synthetic tests now run in tens of milliseconds, where the run-to-run spread is wider than most of
the effects below, so every isolated measurement here is taken on the cloud.
Both floating-point pre-filters still earn their place after the exact arithmetic moved to
FastInt,which is worth re-checking since that made the integers several times faster. Removing either, on the
cloud, median of 6:
Casting the shadow from every touchable pair rather than only from the pairs that yield a triangle is
also still right: 5006 ms against 7801 ms for the latter, which does 5.6x more ball tests (235M against
41.8M) and 6.6x more pairs. That balance depends on the ball tests being cheap, so it moves with the
arithmetic -- it was the other way round before the pre-filters were restored.
The commits are worth comparing on the crossing grids: with the one-sided wedge of the first commit that
case dropped nothing at all (
consideredTris-0.6%) and cost 2.4%, because the cloud is piecewise planarand every candidate lies exactly in the triangle's plane, on the boundary of the one-sided wedge. The
second commit is both simpler and strictly stronger.
The remaining three commits are independent of the geometry:
neisis sorted bydistance, so every pair of the closest neighbours is then processed before any farther point is
touched; those pairs have the smallest touching balls and hence the largest shadows, and what those
shadows drop is exactly what the outer loop has not reached yet. Free, and worth another -11% / -5% /
-3% of
consideredTris.bpandbqcomputed indouble, falling back to the exactvalues only within the tolerance of zero. The operands are dot products of point differences below
2^31, whichVector3drepresents exactly, so they are recomputed in double rather than convertedfrom the bignums -- 0.7 bits worse and no conversion needed. Their dot products with
dstay below2^129and the roundings keep the error below2^79, so a double below-2^79is negative exactly aswell; the tolerance takes 32 times that margin.
shadowedNeisis identical with and without therejection on every test above, and the rejection skips 84-90% of the exact evaluations.
claim needs only the existence of the touching balls, never their emptiness, and dropping before the
emptiness tests shortens their scans too:
consideredTris-29% andinBallTests-22% on the crossinggrids. On the spheres almost every touchable pair yields a triangle anyway, so there it only saves the
scans (-6%).
FastInSphereTesterSoS, a new class next toInSphereTesterSoSgiving exactly its answers butfiltering the queries in floating point first.
reset()also computes the sphere's center asM/(2W)in the plane of the triangle plus the heightS*w/(2W^2)orthogonal to it -- the two cannotcancel each other, and
flip()only negates the second -- and a query farther from the surface thanrSq*2^-44is then decided by one squared distance. That is sixteen times the error of the distance,which also means a point exactly on the sphere always reaches the tie resolution: on the integer grid
one unit of movement changes the squared distance by about
2r, far more than the tolerance, sonothing but an exactly cospherical point can land inside it. The existing classes and their tests are
untouched, and a new test checks the two agree over the cospherical square and thousands of
pseudo-random spheres and queries at three magnitudes. Alone it is worth 34% / 36% / 16% of the whole
search -- more than everything above together, since the emptiness tests are what dominates once the
pairs have been cut down.
The shadow is close to the largest one possible for a pair of blockers, but not exactly it. The maximum
of
2c'x - xxover the centersc'of the radius-rballs via#vhaving neitherpnorqstrictlyinside can sit only in three places: the free maximizer
r*x/|x|(there it is non-negative for anyxwithin the search radius, so
xis not redundant), one of the two cornersc,cbar(what theconditions above test), or the relative interior of one of the two bounding arcs (there redundancy is
decided by a single blocker, which is #6551's case). Enumerating those exactly on 20000 random
configurations that are redundant:
por withqso 99.91% of the redundancy available from a pair of blockers is already taken. The remainder is 13 arc
cases, where #6551's predicate is sufficient but not necessary, and 4 corner cases, where the wedge is
slightly conservative because the normal cone at a corner of a spherical region is not exactly
alpha, beta > 0. Closing either would need more predicates per candidate than the 0.09% is worth.Finally,
AlphaShapeStatsgained six counters, none of them measurable in the timings.shadowTests,exactShadowTestsandshadowedNeis, together withinBallTests, show what the shadow filter reallydoes -- it trades the expensive point-in-ball tests for cheap shadow tests, 84-90% of which end at two
doubledot products:On the crossing grids the total number of point tests is almost unchanged, so the whole gain there comes
from their cost, not from their number.
collectedNeis,redundancyTestsandredundantNeiscover the one stage that had no counters at all,the distance filter of #6551. On the scanned cloud it turns out to be 383M tests dropping 8.42M of the
10.73M neighbours found -- the same scale as the shadow filter's 387M tests, and invisible until now.
collectedNeisis also the size the quadratic loops start from, which is what makes the time per pointdiffer by two orders of magnitude between one cloud and another.
The API this adds
MRInSphere.hgainsFastInSphereTesterSoS, withreset,flip,operator()andoutsideBothSpheres;InSphereTesterSoSgains the accessorsnormal(),normalSq()andheightSq(),which describe the prepared sphere exactly and were protected before. All of them carry
MR_BIND_IGNORE, as everything mentioningFastIntdoes.InSphereTester<int>,InSphereTesterSoSand their tests are otherwise untouched, and
AlphaShapeDataand the signature offindAlphaShapeNeiTrianglesdo not change -- onlyAlphaShapeStatsgrows the counters above.