Add RelateNG: exact DE-9IM predicate engine ported from JTS, on planar and spherical manifolds#416
Draft
asinghvi17 wants to merge 128 commits into
Draft
Add RelateNG: exact DE-9IM predicate engine ported from JTS, on planar and spherical manifolds#416asinghvi17 wants to merge 128 commits into
asinghvi17 wants to merge 128 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS `Location`, `Dimension`, `IntersectionMatrix.matches`, and `operation/relateng/DimensionLocation.java`. Note: the plan's `dimloc_location` used `dimloc % 10`, which mis-maps `DL_POINT_INTERIOR == 103` to 3 instead of `LOC_INTERIOR`; ported the Java switch explicitly instead (Java semantics win). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS TopologyPredicate, BasicPredicate, and IMPredicate (operation/relateng), plus the intersects and disjoint BasicPredicate kinds from RelatePredicate.java. JTS's class triangle becomes two kind-parameterized mutable structs so requirement flags and determination checks specialize per predicate type. Discrepancies between the plan skeleton and the Java, resolved in favor of the Java: - The plan's port note speculated JTS initializes IM entries to DIM_UNKNOWN; in fact JTS IntersectionMatrix() initializes all entries to Dimension.FALSE, so DE9IM() (all-F) is the correct init and finish!/accessors need no unknown-to-F mapping. - JTS IMPredicate's constructor additionally presets the E/E entry to Dimension.A (E/E is always dim 2); the plan skeleton missed this. - is_known_entry compares against DIM_UNKNOWN (= Dimension.DONTCARE), not DIM_FALSE as the plan guessed. - IMPredicate update_dim!/finish! set the value via set_value! so an already-known value is never overwritten, matching BasicPredicate setValue semantics (the plan skeleton assigned p.value directly). - BasicPredicate's unused setValue(int) overload is not ported. Also ports the requireCovers(Envelope, Envelope) helper and isDimsCompatibleWithCovers for use by the named IM predicate kinds; extent checks use Extents.intersects/Extents.covers, which match JTS Envelope.intersects/covers semantics (boundary-inclusive). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of the RelatePredicate.java `IMPredicate` inner classes
(contains, within, covers, coveredBy, crosses, equalsTopo, overlaps,
touches) as `IMPredicate{K}` kind singletons, IMPatternMatcher.java
(standalone struct with instance-level `require_interaction` computed
from the pattern matrix), IntersectionMatrixPattern.java constants, and
RelateMatrixPredicate.java. The kinds' `value_im` matrix queries are
ported from the IntersectionMatrix.java named-relationship methods as
`is_contains`/`is_within`/... functions of `DE9IM` in `de9im.jl`.
Java-vs-plan discrepancy: the plan's flag table lists
`require_interaction` = true for all eight kinds, but the Java
`equalsTopo()` overrides `requireInteraction()` to false (to allow
EMPTY = EMPTY); the Java wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unds The kernel API contract (`kernel.jl`) documents every function a manifold implementation must provide; `kernel_planar.jl` implements it for `Planar` via `Predicates.orient` and `_point_filled_curve_orientation`. The plan's adversarial near-collinear test used `0.5 + 1e-17`, which rounds to exactly 0.5 in Float64 (the point would be exactly on the line); the test uses `nextfloat(0.5)` instead so the perturbation survives rounding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements `rk_classify_intersection` per design D2: classification only, replacing JTS's RobustLineIntersector — no intersection coordinates are ever constructed. Proper interior crossings are reported symbolically as `SS_PROPER`; all vertex incidences are reported via `SegSegClass` flags whose coordinates are exact input vertices. Beyond the planned tests, adds edge cases for non-collinear T-touch, both almost-crossing orientations, double-degenerate (zero-length × zero-length) segments, opposite-direction collinear endpoint touch, and identical segments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`NodeKey{P}` keys vertex nodes exactly by coordinate and proper-crossing
nodes by their canonicalized segment pair (design D2); no intersection
coordinate is ever constructed for a key. Constructors normalize signed
zeros (-0.0 -> 0.0) so the default isbits bit-pattern `==`/`hash` agrees
with coordinate equality. Cross-kind coincidence (`rk_nodes_coincide`) is
decided exactly via Rational{BigInt} arithmetic (design D3), invoked only
on self-noding paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS PolygonNodeTopology with the apex generalized to a NodeKey: rk_quadrant, rk_compare_edge_dir, rk_crossing_dirs_ccw, rk_is_crossing, rk_is_interior_segment. Vertex-node apexes are a direct port; for crossing-node apexes, rk_compare_edge_dir reproduces compareAngle's positive-X-axis-anchored order exactly via the original segment endpoints (quadrant and orientation transfer from the apex to the opposite endpoint of each incident segment, since the apex lies strictly inside both segments), never constructing the apex. rk_is_crossing/rk_is_interior_segment accept vertex apexes only: their relateng caller (TopologyComputer.updateAreaAreaCross) short-circuits proper crossings with `isProper ||` before asking. Also fold in Task 7 review minors: correct the crossing_node canonicalization docstring (segments ordered lexicographically by their endpoint tuples) and document the SS_PROPER precondition on crossing_node / _exact_crossing_point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Property-style suite over a manifold (`kernel_conformance_suite(m; exact)`,
instantiated for `Planar()` with both `exact = True()` and `False()`):
`rk_orient` antisymmetry/cyclic invariance/degeneracy,
`rk_classify_intersection` symmetry and incidence-flag consistency with
`rk_point_on_segment`, `rk_compare_edge_dir` strict-weak-order laws on a
16-direction fan, `rk_nodes_coincide` reflexivity/symmetry/`==` agreement,
and `rk_point_in_ring` boundary agreement with `rk_point_on_segment`.
Includes the Task 8 review item: a seeded differential test comparing
crossing-apex `rk_compare_edge_dir` against a `Rational{BigInt}` reference
`compareAngle` evaluated at the exact rational apex, over random
integer-grid proper crossings. Also documents why coordinate-equality
matching in `_crossing_opposite` is unambiguous.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS LinearBoundary.java with all tests from LinearBoundaryTest.java (WKT literals translated to GI geometries). Lives in point_locator.jl, which will also hold AdjacentEdgeLocator and RelatePointLocator in later tasks. Discrepancy vs the plan, resolved in Java's favor: the plan said closed lines contribute nothing to the boundary map, but JTS only skips empty lines — a closed line adds degree 2 to its closure vertex (no boundary under Mod-2/monovalent rules, but boundary under e.g. the endpoint rule). Ported faithfully. Dict keys use `_node_point` from kernel.jl ((Float64, Float64) tuples, -0.0 normalized to +0.0) so coordinate lookups match the `NodeKey` vertex-node identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS AdjacentEdgeLocator.java (all AdjacentEdgeLocatorTest.java
tests ported, WKT hand-translated to GI geometries). Determines whether a
point on at least one polygon edge of a (possibly collection) geometry is
on the boundary or in the interior under union semantics.
Also adds node_sections.jl with the plain `NodeSection` struct (Task-15
final field shape: symbolic `NodeKey` node, `Union{P, Nothing}` edge
vertices) plus the `get_vertex` accessor; the full NodeSection/NodeSections
API lands in Task 15.
Until Tasks 15-17 land, the Java pipeline `NodeSections.createNode()` ->
`RelateNode.hasExteriorEdge(true)` is provided by a faithful slice of
NodeSections/PolygonNodeConverter/RelateNode/RelateEdge specialized to the
sections AdjacentEdgeLocator produces (area corners of geometry A, all
`ring_id = 0`, so `PolygonNodeConverter.convert` reduces to a stable
angle sort plus duplicate removal). The sequential edge-wheel construction
is reproduced exactly, including its order dependence (`updateEdgesInArea`
only marks edges already present), which an earlier declarative
sector-coverage formulation got wrong for unfilled holes.
Ring orientation is the JTS `Orientation.isCCW` algorithm with the
orientation index routed through `rk_orient` (exact, unlike a floating
signed-area sum); `RelateGeometry.orient` is ported as `_orient_ring` for
reuse in Task 13. The manifold and `exact` flag are stored in the locator
struct (matching how `RelateGeometry` will hold them) rather than threaded
through every call.
Also adds the missing `BoundaryNodeRule` docstring so the cross-reference
from `LinearBoundary` resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS RelatePointLocator.java with RelatePointLocatorTest.java ported in full (gcPLA fixture hand-translated from WKT to GI constructors). Element extraction traverses possibly-nested GeometryCollections via trait dispatch; line/polygon location goes through the kernel (`rk_point_on_segment`, `rk_point_in_ring`). Deviations from Java, noted in source comments: - `is_prepared` is stored but both modes use the direct SimplePointInAreaLocator-style ring loop; prepared-mode spatial indexing (IndexedPointInAreaLocator) is deferred to Task 22, as are the envelope short-circuits (GI geometries do not cache extents). - `LinearBoundary` is built unconditionally (empty one is equivalent to Java's null when no lines exist), and `isEmpty` is derived from the extracted elements rather than cached up front. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS RelateGeometry.java and RelateSegmentString.java (Task 13), with RelateGeometryTest.java ported in full plus hand-verified unit tests for RelateSegmentString (which has no JTS JUnit counterpart). Notes on deviations from the Java: - `create_node_section` takes a symbolic `NodeKey` instead of a constructed intersection Coordinate (design D2); for proper-crossing nodes the section vertices are the segment endpoints and the node is never at a vertex. - `extent` is the union of `rk_interaction_bounds` over non-empty elements (or `nothing` for an empty geometry), standing in for Java's `getEnvelopeInternal`, which skips empty elements the same way. - The Java `instanceof Point/LineString/Polygon` checks are widened to the GI abstract traits per the Task 12 review. - Segment indices are 1-based (Julia convention), documented on the struct. - `_orient_ring`/`_ring_is_ccw` moved from point_locator.jl to relate_geometry.jl, since `_orient_ring` is the port of RelateGeometry.orient; AdjacentEdgeLocator keeps using them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…trings` Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…files
Vendor 20 relate-related XML test files from JTS (locationtech/jts @
123a182e, EPL 2.0 / EDL 1.0) into test/data/jts/{general,validate}/ with
a LICENSE-NOTICE.md recording provenance.
Rework test/external/jts/jts_testset_reader.jl into a pure parser:
- `TestItem` gains a `pattern` field (relate `arg3`) and tolerates a
missing `arg2` (unary ops like `getboundary`); boolean ops
(`BOOLEAN_OPS`) parse their expected result as `Bool` via
`parse_expected`, geometry ops still parse WKT.
- `parse_case` walks case children by tag instead of fixed indices, so
missing `<desc>`/`<b>` and interspersed comments are tolerated; ops
whose args don't resolve to A/B are counted, never silently dropped.
- New `Run` struct carries run-level metadata (`<desc>`,
`<precisionModel>` as FLOATING/FIXED — used later to skip
FIXED-precision cases) via `load_test_run`; `load_test_cases` stays
backward compatible.
- `jts_wkt_to_geom` routes EMPTY, GEOMETRYCOLLECTION, and LINEARRING WKT
through LibGEOS, since WellKnownGeometry/GI wrappers can't represent
them.
- The executable overlay loop moves verbatim to
test/external/jts/overlay_runner.jl.
Add test/external/jts/relate_runner.jl with `run_relate_cases`
(parameterized over the relate implementation; activated fully in the
RelateNG conformance task) and relate_skiplist.jl (empty, with the
mandatory per-entry justification discipline documented). Smoke-tested
via the new test/methods/relateng/xml_harness.jl.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`(file, description, op)` keys collide: TestRelateLL.xml has two cases with the same description and TestRelateGC.xml runs each predicate with arguments both ways round. Skiplist keys are now `(file, case_index, op, arg_order)` where `case_index` is the 1-based case position in the file and `arg_order` is "AB"/"BA" derived from which geometry is arg1; the human-readable description moves to the mandatory justification comment. Also add an execute-path test for `run_relate_cases` with stub predicates, document the intentional raw LibGEOS fallback and the reserved `relate_fn` argument, and TODO the hardcoded overlay runner path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of JTS NodeSection.java and NodeSections.java (Task 15). Completes the Task-11 `NodeSection` data shape with the full Java API in method order (`node_pt`, `dimension`, `id`, `ring_id`, `get_polygonal`, `is_shell`, `is_area`, `is_a`, `is_same_geometry`, `is_same_polygon`, `is_node_at_vertex`, `is_proper`, `toString` as `Base.show`, and the full `compare_to` chain incl. `compareWithNull`), the `EdgeAngleComparator` as `edge_angle_compare` over `rk_compare_edge_dir` (works for symbolic crossing-node apexes), and the `NodeSections` collector (`add_node_section!`, `has_interaction_ab`, `get_polygonal`, `prepare_sections!`, per-polygon grouping). `create_node` is a partial port: `RelateNode` lands in Task 17, so it ports the section-assembly half (sort + grouping + converter delegation) and returns the ordered section list the node's `addEdges` will consume; the `PolygonNodeConverter` call is stubbed until Task 16. `_compare_pt` moves from the Task-11 point_locator slice into node_sections.jl as the permanent `Coordinate.compareTo` port. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port JTS PolygonNodeConverter.java in Java method order (`polygon_node_convert`, `_convert_shell_and_holes`, `_convert_holes`, `_create_section`, `_extract_unique`, `_next`, `_find_shell`), with the angle sort going through `edge_angle_compare`/`rk_compare_edge_dir` and the manifold/`exact` flag threaded in. Wire the real converter into `NodeSections.create_node`, replacing the Task-15 stub, and port every PolygonNodeConverterTest.java test method plus its checkConversion/checkSectionsEqual/section harness. The Task-11 AdjacentEdgeLocator slice's `_extract_unique` is superseded by the converter's (its `compare_to` ordering reduces to the slice's vertex comparison for AEL sections); the Point locator suite is the regression gate. Also fold in two carried-over review minors: document the crossing-node precondition on `edge_angle_compare`, and add a B-geometry line section to the `prepare_sections!` invariant fixture so it discriminates isA-before-dimension ordering on its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port JTS RelateEdge.java and RelateNode.java into relate_node.jl: the per-edge dimension/location labels with the coincident-edge merge semantics (area-over-line dim override, INTERIOR side precedence), the CCW-sorted node wheel with insertion-or-merge, the area-label propagation (updateEdgesInArea / updateIfAreaPrev / updateIfAreaNext), and finish! with side-location propagation. Nodes are keyed by the symbolic NodeKey (design D2); the manifold and exact flag for the edge-angle comparisons are stored on the RelateNode. Complete NodeSections.createNode to assemble and return the RelateNode (replacing the Task-15 section-list stub). Tests assert full hand-traced edge-wheel state for crossing lines, area corners, mixed line/area nodes, coincident corner merges, and a converted shell-hole group mixed with another geometry's singleton corner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the Task-11 private node-wheel slice in point_locator.jl (_AelEdge and the _create_node_edges family) with the real machinery: locate now builds a NodeSections collector, adds sections, assembles the RelateNode via create_node, and tests has_exterior_edge(node, true), matching AdjacentEdgeLocator.locate exactly. The ported AdjacentEdgeLocatorTest cases are the regression gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 14, 2026
XML.jl 0.4 surfaces declaration/comment/whitespace nodes as document children, so `only(children(doc))` in the JTS testset reader throws on every vendored file (CI resolves 0.4; no test manifest is committed). Pick the single `Element` child instead — works on 0.3 and 0.4 — and record the tested range in the test compat. Full 6,537-op conformance suite verified on XML 0.4.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…string `fix` is unexported, so its docstring is not included in the manual and the reference cannot resolve; the rendered `./@ref` dead links (in api.md and geometry_correction.md via autodocs) fail the VitePress build. No other correction docstring links it either. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'GO.intersects(RelateNG(; manifold = Spherical()), canada, australia)' returned true for disjoint Natural Earth countries. Three defects in the spherical kernel, all surfaced by real-world rings: - 'rk_point_in_ring(::Spherical)' read the ring under the S2 left-of-ring convention, so a shapefile-convention CW shell (all NE 110m countries) meant "the sphere minus the country" and everything intersected it. The kernel contract (kernel.jl) is the area *enclosed* by the ring, winding- independent like the planar ray-crossing parity. The left-region parity from 'spherical_ring_contains' is now interpreted through a canonical orientation: interior iff on the left of a CCW ring, where CCW comes from the new spherical '_ring_is_ccw' method — the sign of the Girard fan sum, positive iff the enclosed (sub-hemisphere) region is on the left. The planar extreme-vertex 'isCCW' this replaces assumes a coordinate plane and fails on xyz points (an equator-symmetric ring has two exactly-equal extreme-y vertices and read CW in both windings). '_orient_ring' (edge- side topology), 'rk_point_in_ring', and 'rk_interaction_bounds' now all derive the region from the same bit, so they agree by construction; the polygon interaction bounds orient the shell CCW before the region extent, since a CW shell would bound the complement and under-cover an enclosed pole. The public 'Extents.extent(Spherical(), ...)' keeps its documented S2 winding convention. - 'rk_point_on_segment(::Spherical)' accepted every point against a zero-length arc: orient against 'q0 == q1' is identically 0 and the span test degenerates to '0 >= 0'. NE 110m North Korea carries an '[A, A, B, A]' sliver ring, so every point on the sphere located as LOC_BOUNDARY of it. Parallel endpoints now admit only direction coincidence with an endpoint. - The same sliver broke the parity count in 'rk_point_in_ring': its retraced edge lies exactly under the anchor midpoint. Repeated consecutive vertices are dropped (JTS removes them at ingest; this path receives the raw ring), and a ring with fewer than 3 distinct vertices bounds no area. Spot-checked against NE 110m: canada/australia and canada/brazil disjoint, canada/usa intersect and touch, and spherical vs planar 'intersects' agree on all 3655 non-pole, non-antimeridian country pairs sampled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First-call compilation of predicate x geometry-type instances took seconds
per instance — 2.25x worse on Julia 1.12 than 1.11 (192.7 s vs 85.8 s over
the 147-instance grid), up to 36x on polygon-target instances — because
every engine type downstream of extraction was parameterized on the full
input geometry types: 'TopologyComputer{TP, RA, RB, P}' carried both
'RelateGeometry{...G...}'s, 'RelateSegmentString{P, G, RG}' carried the
parent polygonal and the whole facade type, and 'NodeSection{P, G}' the
polygonal again. The topology computer, the edge intersector (and its
'dual_depth_first_search' traversal closures — the single largest inference
sink at 3.6 s for one 'crosses(poly, poly)'), and the node machinery were
re-inferred from scratch for every geometry-type pair, though none of that
code reads the geometries in its hot loops.
Store those references opaquely instead, as Java does: 'TopologyComputer'
keeps 'geom_a'/'geom_b' as abstract 'RelateGeometry' fields and copies the
kernel 'm'/'exact' out of A (the fields the per-segment paths read), and
'RelateSegmentString{P}'/'NodeSection{P}' hold 'input_geom'/'polygonal'
untyped — they are only compared by identity or handed to per-node point
location, where a dynamic dispatch is noise against the ring-scan it
fronts. The whole edge/node engine now compiles once per (manifold, exact,
predicate) and is shared by every input geometry type; only the thin outer
layer (facade construction, extraction, the 'evaluate!' phases) still
specializes per input type, and it keeps concretely-typed geometry
arguments, so extraction loops stay static. 'init_exterior_dims!' takes the
real dimensions from the constructor arguments for the same reason.
'NodeSections' also gains a concretely-typed section vector
('Vector{NodeSection{P}}' instead of 'Vector{NodeSection}'), and the
'_segment_string_eltype' fleet is deleted — 'RelateSegmentString{P}' is
concrete by itself now.
The 147-instance first-call grid drops 192.7 s -> 33.4 s on 1.12.6 and
85.8 s -> 18.2 s on 1.11.9 (before any precompile workload); steady-state
runtime improves across the board (tiny-poly intersects 1.02 -> 0.74 us,
full relate 3.44 -> 2.53 us, shared-edge touches 3.57 -> 2.55 us, prepared
point-in-area 0.355 -> 0.321 us/query, zigzag crosses unchanged; 1.11
improves similarly).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the engine core typed on kernel-level types only (previous commit), one workload run caches it for every input geometry type, so precompiling pays off across the whole predicate x type grid instead of one instance. The workload runs all ten predicates on a polygon pair, the full 'relate' matrix over the native geometry-type combinations (poly, mpoly, line, mline, point — 'GO.tuples'-shaped wrappers, which the tests feed too), and one prepared evaluation. First calls in a fresh process drop from seconds to cached or near-cached: 'crosses(poly, poly)' 9.5 s -> 0.01 s on Julia 1.12.6 (1.2 s -> 0.01 s on 1.11.9), 'touches(mline, poly)' 7.5 s -> 0.54 s, 'intersects(line, poly)' 4.8 s -> 0.21 s. Package precompilation grows from ~4 s to 24.5 s on 1.12.6 (15.4 s on 1.11.9) — paid once per package version instead of per session/test process; the RelateNG fuzz file's wall time drops 19.8 s -> 2.5 s and the allocations file 14.3 s -> 1.4 s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`relateng_realdata.jl` runs the Natural Earth workload matrix from the 2026-07-14 profiling campaign — point-in-area on the largest 10m country (unprepared, extent-stamped, prepared, and LibGEOS both ways, with `prepare` break-even), pairwise 110m `intersects`, `touches`/full `relate` on real border-sharing neighbors, rivers x countries, and spherical-vs-planar comparisons. `relateng_ttfx.jl` measures fresh-process first-call compile time per predicate x type-pair instance with a configurable julia binary, serving as the regression gate for the engine compile-surface work; representative output for both julia channels is recorded in each file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`rk_interaction_bounds(::Planar, geom)` delegated its computed fallback to `GI.extent(geom, fallback = true)`, whose `calc_extent` makes two separate closure-`extrema` passes over the points (three for 3D). The RelateGeometry extent-cache pass runs that fallback over every ring of both inputs on every unprepared call, which made it 92% of an unprepared point-in-area query on real coastline data. A single min/max sweep over `GI.getpoint` produces identical extents (same trait dispatch, same 2D/3D bounds, same GC union-of-members semantics, stored extents still returned as-is) at ~13x the speed: the Canada (68k vertices) extent pass drops from 815 us to 43 us and the unprepared point query from 862 us to ~98 us. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unprepared point-in-area arm (`_locate_point_in_polygonal`) scanned every shell of a polygonal element unconditionally, while its JTS counterpart short-circuits on the geometry envelope (SimplePointInAreaLocator.locate) and on each ring's envelope (locatePointInRing). The omission was a port-fidelity gap: the comment claimed the skip matched `locate_on_line`, but that port kept its line envelope check. Restore both checks — O(1) reads against the stored extents of the RelateGeometry wrapper tree (or an extent-stamped input) — on `Planar` only: spherical arc-extent boxes bound linework, not enclosed regions, so an envelope miss is not conclusive there. Unprepared point-in-Canada drops from ~98 us to ~63 us per query, and extent-stamped input (`GO.tuples(x; calc_extent = true)`) from ~56 us to ~22 us; answers agree with the prepared locator on the seed-7 point mix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inputs carrying stored extents at every level skip the per-call extent pass, which dominates unprepared evaluations against large geometries; point users at the `GO.tuples(x; calc_extent = true)` one-liner and at `prepare` for repeated queries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_ring_is_ccw(::Spherical)` summed Girard fan triangles from the ring's first vertex. A fan triangle with an antipodal apex-vertex pair has no defined connecting geodesic — its Girard excess degenerates to `atan(~0, ~0)`, which the formula guards to zero — so the sum collapses and the shared orientation bit (edge topology, point location, interaction bounds) flips the polygon interior. Ingest only rejects antipodal *edges*; fan chords to non-adjacent vertices can still be antipodal, and the `AntipodalEdgeSplit` output carries exactly such vertex pairs, so this was a mainstream case (CI failed on the corrected ring `[(0,0), (90,0), (180,0), (90,80), (0,0)]` against the point (10,10)). Pick the first vertex — then the first edge midpoint — with no ring vertex within ~1e-9 of its antipode (nearly antipodal chords are just as unstable); the signed fan sum is apex-invariant, so any clean apex gives the same bit. A ring defeating every candidate falls back to the first vertex: it pairs its whole vertex set with antipodes, splitting the sphere near-evenly, where the sign is intrinsically ambiguous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layer 1 of docs/plans/2026-07-14-spherical-indexed-locator.md: the
spherical `rk_point_in_ring` re-derived everything from lon/lat on every
query — vertex conversion, repeated-vertex dedup, and the Girard
orientation sum. Split it into a cached form (`SphericalKernelRing`:
converted vertex vector, deduped parity walk, orientation bit) plus a thin
GI-ring wrapper, and give `IndexedPointInAreaLocator` a spherical arm that
holds the element's rings in kernel space and locates by the exact ring
scan over them. `RelatePointLocator` now routes every spherical polygonal
query through that cached per-element locator (the JTS shape: Java's
unprepared arm caches per-element SimplePointInAreaLocators in the same
`polyLocator` slot), so prepared-mode queries never reconvert a vertex and
unprepared mode converts once per call.
On its own this trims prepared spherical point-in-Canada from 17.6 ms to
~16.5 ms per query — the remaining cost is the full-ring crossing-parity
scan and its exact `Rational{BigInt}` crossing confirms, which the
longitude-interval indexed locator (Layer 2) replaces with a stab plus a
handful of candidate edges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layer 3 of docs/plans/2026-07-14-spherical-indexed-locator.md: replace the
per-call anonymous closures handed to `spherical_ring_contains` (`orient`,
`proper_crossing`) with callable structs parametrized on the kernel's
manifold and exactness flag. The injectable-predicate design is unchanged;
the predicates just stop being rebuilt on every `rk_point_in_ring` call.
On the real-data Canada workload the query time is unchanged within noise
(the scan is dominated by the exact `Rational{BigInt}` crossing confirms,
addressed by the Layer 2 indexed locator); the functors remove the closure
construction and keep the call sites monomorphic by construction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prepared-mode spherical point location previously scanned every ring
edge with exact predicates per query: 17.6 ms per point against 10m
Canada (68k vertices). This is Layer 2 of the spherical indexed
locator design, the arc analogue of the planar y-interval index:
- Reference arc: the meridian arc from the query point to a pole
anchor whose location is computed once at build by the exact scan
(south pole; falls back to north if the south pole lies on the
boundary, and to unindexed scan mode if both poles do).
- Index: an STR `RTree` over per-edge 1-D longitude intervals, like
the planar `_PIAIndex` with Y swapped for X in radians. Along a
great-circle arc the east component of the tangent is the constant
`n_z`, so longitude is monotone between endpoints and the interval
is exactly the shorter wrapped span — edges straddling ±180° get
two entries, edges touching the polar axis a full interval.
- Counting: `ArcCrossingCounter` decides each candidate edge with the
exact `rk_orient` kernel; after four strict straddles the arcs
cross iff `sign(rk_orient(q, s, a)) != sign(rk_orient(a, b, q))`,
which replaces the `Rational{BigInt}` proper-crossing confirm that
dominated query time (436 us -> 2-3 us per locate). Vertex grazing
uses the S2 symbolic rule: count iff the off-arc neighbor lies
strictly on the positive side.
Prepared spherical point-in-Canada drops 17.6 ms -> ~2.4 us per query
(mean 3 us over the mixed seed-7 cloud), with build time unchanged at
~100 ms; prepared answers agree with the exact scan on every tested
cloud and with prepared planar 1000/1000 on the Canada workload. The
unprepared arm keeps the exact scan over the cached kernel rings
(`indexed = false`) — a one-shot query cannot amortize the build —
and both arms now share the closing-edge boundary test that the
cached-ring path previously missed for implicitly closed rings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The representative block predates the single-pass extent kernel, the envelope short-circuits, and the spherical indexed locator; every number it recorded is stale (unprepared planar point-in-area 861.9 us -> 62.6 us, prepared spherical 17.6 ms -> 2.2 us). Also retire the workload-5 comment about the always-true spherical containment bug, fixed before these measurements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spherical `_ring_is_ccw` summed Girard fan triangles from a selected apex, with fallback scans to dodge (near-)antipodal apex-vertex chords — a workaround: any fan connects non-adjacent vertices, so some ring always defeats the apex search. The S2 formulation is structurally immune: the loop's geodesic curvature is the sum of per-vertex turn angles (`GetCurvature`, s2loop_measures.cc), each term built from ADJACENT vertex pairs only, with its magnitude from `robust_cross_product` normals and its sign from the exact orient (`TurnAngle`, s2measures.cc). Positive curvature ⇔ the region on the ring's left is the enclosed one (Gauss–Bonnet: area = 2π − curvature). Ported alongside, so the bit inherits S2's robustness properties: `PruneDegeneracies` (duplicate runs and ABA whiskers, wraparound included), `GetCanonicalLoopOrder` + Kahan summation (the curvature is exactly invariant under rotation and exactly negated under reversal — a fixed storage-order float sum is neither), and the `GetCurvatureMaxError` bound (11.25ε per vertex) as the normalization tolerance, so an exact hemisphere — intrinsically winding-ambiguous — reads CCW in both windings instead of falling to rounding noise. The apex-selection machinery is deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iors `Spherical(; oriented = false)` (the default, unchanged) reads a ring's interior as the region it encloses — the smaller of the two it bounds — independent of winding, matching how R s2/sf, spherely, and BigQuery treat unoriented data; no region larger than a hemisphere is representable. `Spherical(; oriented = true)` declares ring directions correct (exterior rings CCW, interior rings CW): the polygon interior is the region on the LEFT of every ring's stored order, the semantics of S2 `S2Polygon::InitOriented` adapted to GeoInterface's explicit shell/hole roles (no nesting inference) — so "the sphere minus a cap" is a CW ring. Everything reduces to the one per-ring bit the consumers already share — is the ring's denoted region on the left of its stored order? — now derived by `_ring_interior_on_left`: unoriented, the computed winding (`_ring_is_ccw`); oriented, the declared role (a shell's region is its left, a hole's cavity — being wound opposite the shell with the polygon interior on ITS left too — is its right). `_orient_ring` (edge-side topology), `rk_point_in_ring` / `SphericalKernelRing` (point location, scan and lon-interval indexed alike), and the polygon interaction bounds all take the role and inherit the mode; the planar kernel accepts and ignores it. A complement region's interaction bounds cover essentially the whole sphere — unprunable but correct, noted on the manifold docstring. On release GeometryOpsCore needs a minor version bump for the new field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Extents.extent(Spherical(), ring)` kept the S2 left-of-ring convention from its introduction — a CW-wound ring got the complement's box (the whole sphere for a CW Canada) — while the relate engine had since moved to winding-independent enclosed regions. With the interior interpretation now a manifold mode, the extent follows it instead of a convention of its own: the default bounds the ENCLOSED region whichever way the ring is wound, and `Spherical(; oriented = true)` bounds the region on the left of the stored order (the previous behavior, now opt-in), where a CW ring genuinely denotes its complement. The ring path routes through the same `_orient_ring` / `_ring_interior_on_left` bit as every other consumer, so the public extent and the kernel's interaction bounds can never disagree about which region a ring means. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_remove_repeated_points`, `is_closed`, the node-section vertex walks, `is_containing_segment`, `add_edge!`, and the adjacent-edge ring walk compared kernel points with `_equals2` — Java's planar `equals2D`. Spherical kernel points are 3D: two vertices at mirror latitudes on one meridian share x and y and differ only in z, so 2D equality read them as repeated points and deleted ring vertices at extraction, corrupting edge topology for any ring with an equator-mirrored meridian edge. Kernel-point `==` compares all coordinates and is identical to `_equals2` on the planar 2-tuples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`prepare` gains a `validate` kwarg: a self-join over the prepared segment set (through the prepared edge tree when one is built, a pruned nested loop otherwise) that throws an S2-style `ArgumentError` — "edge i crosses edge j", in input order — on the first PROPER crossing between non-adjacent edges of the same polygonal element. Vertex touches are not flagged: the scope is the crossing class that breaks containment parity, not full OGC validity; the named remedy is the `CrossingEdgeSplit` correction (landing in this series). The default is manifold-dependent by design. On `Spherical` (true) a planar-valid ring whose edges cross when reinterpreted as great-circle arcs is invisible to planar tooling, and undetected it can invert containment globally (Natural Earth 110m Sudan: edge 1 crosses edge 79, matching S2's loop validation); the check adds ~28 ms to the ~111 ms Canada 10m build (+25%). On `Planar` (false) the same class is visible to standard planar tools, degrades even-odd instead of inverting, and JTS/GEOS prepared geometries do not validate either; a validation join would dwarf the ~600 µs build and its amortization economics. The confirm predicate `_edges_cross_properly` is kernel-generic: a strict mutual-straddle prefilter of adaptive-exact `rk_orient` signs, then the manifold's `rk_classify_intersection` for the rare survivors (on the sphere mutual straddle alone cannot distinguish the antipodal candidate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…point In the default `Spherical(oriented = false)` mode, `rk_point_in_ring` now decides interiors by even-odd crossing parity of the arc from the query to the antipode of the ring's normalized vertex mass — a point exterior BY DEFINITION of the enclosed-region semantics for any ring meaningfully smaller than a hemisphere (`spherical_ring_encloses` and `spherical_exterior_anchor` in UnitSpherical; the anchor is cached on `SphericalKernelRing`). The previous bootstrap composed one edge's local interior-side wedge (`spherical_ring_contains`) with the turning-angle winding bit, and a ring that self-intersects on the sphere defeats both at once: a figure-eight's lobes cancel the curvature while the wedge propagates whichever lobe hosts the anchor edge — on NE 110m Sudan every containment answer on the globe was inverted (S2's forced-through layer behaves identically). Parity from a definitionally exterior anchor degrades to even-odd instead: both lobes IN, everything else OUT, matching planar even-odd parity and S2Builder's undirected repair. The spherical-vs-planar NE 110m pair sweep drops from 161 mismatches (all involving Sudan) to 0, and the Sudan panel (Khartoum/El Fasher IN, Chad/Juba/Libya/Egypt/mid-Pacific OUT) is correct in both windings. Degenerate configurations fall back to the wedge bootstrap: a tiny vertex mass (near-hemisphere or vertex-symmetric rings, where the enclosed/complement distinction is itself near-degenerate and the turning-angle tolerance already treats hemispheres permissively), a query at the mass center, or an anchor exactly on the ring. Exact vertex grazings on the test arc are resolved symbolically (S2 VertexCrossing style) with the same rules as the indexed locator's `count_arc_segment!`, whose build-time pole-anchor classification goes through this same scan — prepared parity composition stays consistent, tested on the invalid bowties. `Spherical(oriented = true)` keeps the winding-authoritative wedge bootstrap untouched: garbage-in-garbage-out is that mode's documented contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…here The repair half of the spherical-validity contract, beside `AntipodalEdgeSplit`: find the proper great-circle crossings within each polygon ring (the `prepare`-validation predicate, arc-extent pruned) and split the ring at each crossing point, reassembling the resulting loops as separate rings — even-odd semantics, both lobes kept, matching S2Builder's undirected `split_crossing_edges` repair, which the S2 experiment showed recovers planar even-odd truth exactly on this class. A polygon whose shell splits becomes a `MultiPolygon`; holes (themselves repaired the same way) are assigned to the shell loop that encloses them. Crossing points are constructed in Float64 lon/lat from the exact crossing direction — corrections construct geometry, they do not decide predicates (the `AntipodalEdgeSplit` midpoint standard); on NE 110m Sudan the constructed vertex agrees with S2's `GetIntersection` to 1e-6°, the repaired geometry passes `prepare` validation, and containment matches the planar truth panel. Scope is deliberately the isolated-pairwise class (needles and bowties): an edge in more than one crossing, interleaved crossings, or a hole left in no shell loop throw an `ArgumentError` rather than emitting wrong geometry. The `Spherical` docstring now carries the manifold-dependent-validity caveat pointing here, completing the detect (`prepare`) → repair (`CrossingEdgeSplit`) wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tring `fix` is unexported, so its docstring is not in the rendered manual and the `@ref` cannot resolve — VitePress fails the docs build on the resulting dead link, exactly as it did for `AntipodalEdgeSplit`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`benchmarks/relateng_gadm.jl` exercises `prepare`, its indexed point-in-area locators, and the spherical ring self-crossing validation self-join at GADM's full resolution — Canada is ~3.9M vertices / 24k rings here, one to two orders of magnitude denser than Natural Earth. Kept a separate, independently runnable file (with a pre-download note) because GADM.jl fetches a per-country GeoPackage on first use. Records the `prepare` build split (index build vs the validating build) so the ring-crossing validation overhead at ~4M edges is a documented number: 938 ms planar (96% of the validating build) and 1.58 s spherical (22%). Prepared point-in-area holds spherical/planar parity (2.3 vs 1.8 μs) through the longitude-interval locator, and `touches` on real land borders runs faster than LibGEOS at full resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`exact = True()` classification previously lifted to `Rational{BigInt}`
unconditionally. Three behavior-preserving changes:
- reduce the proper-crossing branch of `rk_classify_intersection` to four
`rk_orient` signs (BAC-CAB; `d = 0` iff all four signs are zero),
delegating to the certified adaptive ladder; exact-collinear cases still
escalate to the unchanged `Rational{BigInt}` authority, and
`exact = False()` keeps the original float formulation bit-identical
- certified Float64 triage for `_on_arc_span` (Higham-style running-error
bound `16u * sum(W_i * N_i)`, scale-invariant) with an exact endpoint
short-circuit
- `_rk_orient(::True)` returns 0 directly for repeated vertices
Audited against the pure-exact path on 1.65M arc pairs (real NE110/GADM
candidate populations, 1e6 random, exact-coplanar and near-degenerate
adversarial sets): zero disagreements; escalation fires only where
required. Spherical classification drops from ~30-71 us to ~0.08 us per
clearly-separated pair (GADM CAN x USA spherical collect 3.12 s -> 674 ms;
crossing-dense synthetic 1.15 s -> 16.5 ms).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_spherical_triangle_area(::Eriksson, ...)` applied the Eriksson / Van Oosterom–Strackee tangent-half-angle formula UNSIGNED (`2*atan(abs(numerator/denominator))`) inside the fan triangulation, so reflex fan triangles in a concave ring added instead of subtracting and every non-convex ring was overcounted — about +50% on an L-shape, +82% on a 5-pointed star, and +150% to +430% on real concave countries (Norway, Canada, Chile) at NE 110m. Restore the formula's native sign with `2*atan2(numerator, denominator)`. This keeps Eriksson's magnitude (bit-identical to the previous form where `denominator > 0`), so the small-polygon accuracy Eriksson was chosen for is preserved: a 0.01° equatorial square matches a BigFloat signed-fan reference to ~1e-12, and all tiny squares match to <=6e-14 relative error (and beat `Girard` on the tiniest triangles). Ring area remains `abs(sum of signed fan triangles)`, fanned from the ring's own first vertex, so the public positive-area semantics are unchanged and the result is winding- and fan-apex-independent. The old `atan(abs(...))` form also misbranched for large fan triangles whose `denominator <= 0`: it caps the per-triangle area below π (e.g. 2.66 sr where the true value is 3.62 sr), which `atan2` now follows onto the correct branch. The `Girard` variant was already the signed `atan2` form and is unchanged. 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.
Formerly the head of a six-PR stack — #422, #438, #439, #440, and #441 are collapsed into this PR so the port can be reviewed as a whole. Each closed PR remains readable as a focused diff of its layer (review map below).
Merge order note: the branch merges
spherical-arc-extent(#434) to consume the shared spherical substrate, so the diff shows #434's content until that lands onmain. Merge #434 first.Summary
Implements the JTS RelateNG DE-9IM predicate engine in GeometryOps, on both
PlanarandSphericalmanifolds, per the designs indocs/plans/(included on the branch).Engine (planar port). Exact, construction-free evaluation: every geometric decision goes through an
rk_*kernel backed by exact orientation predicates; proper crossings are symbolic nodes keyed by the canonicalized segment pair, with coincidence decided in exactRational{BigInt}arithmetic — no floating-point intersection coordinate is ever constructed for node identity. The topology layer is a faithful, file-by-file diffable port (TopologyComputer,RelateGeometry,RelatePointLocator, node/edge topology, predicate framework), with predicate-type specialization, tree-accelerated edge enumeration with early exit, and prepared mode (prepare). Public surface is opt-in (design D4):relate,DE9IM,RelateNG,prepare, boundary-node rules — existing defaults untouched.Spherical kernel. The same engine runs on the sphere by swapping the kernel: points convert once to unit-sphere kernel points, orientation and arc crossings are decided exactly (3D orientation + exact-rational crossing classification, following S2's predicate structure), boundary/interior ring classification stays exact, and exactly-antipodal edges are rejected at ingest with a pointer to the
AntipodalEdgeSplitcorrection. Acceleration carries over — segment extents are bulge-aware great-circle arc extents, and the same trees prune the dual traversals in 3D.Spherical interior semantics. A lon/lat ring on the sphere bounds two finite regions, so the port defines which one a polygon means — and follows the ecosystem consensus (R
s2/sf,spherely, BigQuery, S2's unoriented ingest): by default the interior is the enclosed (smaller-than-hemisphere) region, independent of winding, so shapefile-wound real data (e.g. Natural Earth's CW shells) means what its producers intend.Spherical(; oriented = true)opts into winding-authoritative interiors (S2InitOriented: interior on the left, shells CCW/holes CW), which makes regions larger than a hemisphere representable. One orientation bit — an S2-style turning-angle curvature with exact predicate signs — is the single authority shared by edge topology, point location, andExtents.extent(Spherical(), ...).Spherical validity contract. A ring that is simple in planar lon/lat can self-intersect when its edges are reinterpreted as geodesics (Natural Earth's 110m Sudan does — a 0.5 km² needle lobe that no planar validity tool can see), and undetected it corrupts containment. Following the S2-ecosystem contract:
preparevalidates ring self-crossings by default onSpherical(off by default onPlanar, where standard tooling already covers validity and prepared-geometry precedent is no validation —validate = trueopts in), erroring with the crossing edge pair and pointing to the newCrossingEdgeSplitcorrection, which splits rings at geodesic crossings and keeps both lobes as shells (even-odd, matching S2Builder's undirected repair). Unprepared calls don't pay a validation toll; instead, enclosed-region containment parity is anchored at a definitionally-exterior point (antipode of the ring's vertex mass), so unvalidated invalid input degrades even-odd-gracefully like the planar path instead of inverting globally — spherical vs planarintersectsnow agree on all 15,051 Natural Earth 110m country pairs, Sudan included.Performance. Engine internals are type-erased over input geometry types and a PrecompileTools workload covers the common predicates, fixing a Julia 1.12 first-call compile blowup (CI matrix jobs: ~113 min → 13–27 min;
crosses(poly, poly)first call 9.5 s → 0.01 s). Unprepared point-in-area drops 862 → 63 µs on Natural Earth Canada (single-pass extent kernel + JTS-parity envelope short-circuits; 22 µs with extent-stamped inputs, faster than plain GEOS), and prepared spherical point queries drop 17.6 ms → 2.2 µs via a longitude-interval indexed locator (the 1-D-stab analog of JTS's y-interval trick, with a meridian reference arc and a build-time pole anchor). The spherical exact kernel gains certified Float64 fast paths (the proper-crossing branch reduced algebraically to four orientation signs, plus a certified arc-span triage) — audited sign-identical against the pure-exact path on 1.65M arc pairs, bringing spherical classification below planar cost per clearly-separated pair (GADM Canada × USA spherical edge-pair collection: 3.12 s → 674 ms). Real-data and TTFX benchmark suites are committed underbenchmarks/with recorded representative output.Shared substrate. The kernel consumes the shared geometry layer instead of forking it: interaction bounds compose
spherical_arc_extentand the crossing-parity region extents from #434 over kernel-converted points; ring containment is the promotedUnitSpherical.spherical_ring_containswith injectable predicates (the kernel injects its exact ones; the extent path keeps the tolerance-based defaults). Index structures are shared too:FlexibleRTrees.RTreenow carries the collection it indexes (tree.data, with precomputed-extents support and a zero-copyUnsortedpath), the relate edge index is a natural-orderRTreeof segment owners, and the prepared point-in-area interval index isRTree(STR(), segs; extents = y_intervals)— STR in one dimension is JTS's midpoint sort. No private index structures remain in the port.Also on this branch. Spherical
area's Eriksson fan triangulation is restored to its native signed Van Oosterom–Strackee form (atan2instead ofatan∘abs): unsigned fan triangles overcounted concave polygons badly on real data (Norway +174%, Chile +432% vs a BigFloat reference), and the unsigned form also mis-branched for fan triangles with negative denominator. Magnitude is bit-identical where the denominator is positive, so the small-polygon accuracy that motivated Eriksson over Girard is retained (verified against BigFloat down to 1e-5° features). Found by area-conservation checks during OverlayNG design spikes on this foundation.Review map
The collapsed PRs each cover one layer, in stack order:
rk_kernel, kernel-point threading, spherical acceleration, S2 crossings conformancespherical_ring_containspromotion/graft,rk_bounds_*pruningDoubleNaturalTreenaming, lazy point-in-area auto-index removal (JavaisPrepared-only rule restored), dead codeRTree; edge-index side-tables (PreparedEdgeIndex,owners) dissolvedSortedPackedIntervalRTreere-expressed as 1-DRTree(STR(), ...)Validation
RelateNGTestplus every component JUnit suite, ported and passing (engine file: 1,868 assertions).intersectsagree on all 15,051 Natural Earth 110m country pairs (previously 161 disagreements, all from Sudan's geodesically self-intersecting 110m ring — resolved by the definitional-exterior parity anchor; S2 forced past validation inverts the same ring, verified against s2geometry directly).benchmarks/relateng_gadm.jl) — prepared point-in-area 1.8 µs planar / 2.3 µs spherical.Test plan
test/methods/relateng/runtests.jlgreen per-file on the collapsed tip (engine 1,786 + 82; XML 6,537; fuzz 500; spherical conformance; allocations)833f5cd1b— runs 29382495363 and 29384536647, all 7 jobs, matrix wall times 13–27 min2e6dd8aef(prepare validation,CrossingEdgeSplit, definitional-exterior anchor, kernel-point equality fix) — run 29434731843, all 7 jobs69e416484(certified spherical Float64 fast paths8d938e832, signed Eriksson area fix) — run 29500550781, all 7 jobs🤖 Generated with Claude Code