Skip to content

Add RelateNG: exact DE-9IM predicate engine ported from JTS, on planar and spherical manifolds#416

Draft
asinghvi17 wants to merge 128 commits into
mainfrom
relateng
Draft

Add RelateNG: exact DE-9IM predicate engine ported from JTS, on planar and spherical manifolds#416
asinghvi17 wants to merge 128 commits into
mainfrom
relateng

Conversation

@asinghvi17

@asinghvi17 asinghvi17 commented Jun 11, 2026

Copy link
Copy Markdown
Member

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 on main. Merge #434 first.

Summary

Implements the JTS RelateNG DE-9IM predicate engine in GeometryOps, on both Planar and Spherical manifolds, per the designs in docs/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 exact Rational{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 AntipodalEdgeSplit correction. 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 (S2 InitOriented: 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, and Extents.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: prepare validates ring self-crossings by default on Spherical (off by default on Planar, where standard tooling already covers validity and prepared-geometry precedent is no validation — validate = true opts in), erroring with the crossing edge pair and pointing to the new CrossingEdgeSplit correction, 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 planar intersects now 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 under benchmarks/ with recorded representative output.

Shared substrate. The kernel consumes the shared geometry layer instead of forking it: interaction bounds compose spherical_arc_extent and the crossing-parity region extents from #434 over kernel-converted points; ring containment is the promoted UnitSpherical.spherical_ring_contains with injectable predicates (the kernel injects its exact ones; the extent path keeps the tolerance-based defaults). Index structures are shared too: FlexibleRTrees.RTree now carries the collection it indexes (tree.data, with precomputed-extents support and a zero-copy Unsorted path), the relate edge index is a natural-order RTree of segment owners, and the prepared point-in-area interval index is RTree(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 (atan2 instead of atan∘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:

PR Layer
#416 (original commits) Planar RelateNG engine + topology layer
#422 Spherical rk_ kernel, kernel-point threading, spherical acceleration, S2 crossings conformance
#438 Substrate unification: extent delegation, spherical_ring_contains promotion/graft, rk_bounds_* pruning
#439 Cleanups: twin-method merges, DoubleNaturalTree naming, lazy point-in-area auto-index removal (Java isPrepared-only rule restored), dead code
#440 Payload-carrying RTree; edge-index side-tables (PreparedEdgeIndex, owners) dissolved
#441 SortedPackedIntervalRTree re-expressed as 1-D RTree(STR(), ...)

Validation

  • All 64 methods of JTS RelateNGTest plus every component JUnit suite, ported and passing (engine file: 1,868 assertions).
  • Full vendored JTS XML conformance suites (20 files, 6,537 ops) with a 2-entry documented skiplist — both are cases where GEOS ≥ 3.13 RelateNG agrees with GeometryOps against the legacy XML expectations.
  • Seeded differential fuzzing vs GEOS native RelateNG: zero divergences (50,000-case deep runs on the planar engine; 500 cases per gate run).
  • Spherical: 51,943-assertion kernel conformance suite, S2 edge-crossing conformance ported from s2geometry, and 186 end-to-end spherical relates including prepared mode, both interior modes, winding-independence, bowtie even-odd degradation, and the antipodal ingest guard.
  • Spherical vs planar intersects agree 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).
  • GADM full-resolution census: 43 countries / 25.7M vertices, zero spherical or planar validity defects (the Sudan needle is a low-resolution simplification artifact) and zero cross-engine disagreements on real land-border pairs; benchmarks at 3.9M-vertex Canada committed (benchmarks/relateng_gadm.jl) — prepared point-in-area 1.8 µs planar / 2.3 µs spherical.
  • Allocation budgets and type-stability checks.

Test plan

  • test/methods/relateng/runtests.jl green per-file on the collapsed tip (engine 1,786 + 82; XML 6,537; fuzz 500; spherical conformance; allocations)
  • Clean uninterrupted engine + XML confirmation run on the final tip (1,786 + 82 in 16m52; 6,537 in 24m06)
  • CI green on the collapsed tip (full package suite + docs, 5 Julia matrices) — run 29355001887, all 7 jobs
  • CI green on the perf + spherical-semantics tip 833f5cd1b — runs 29382495363 and 29384536647, all 7 jobs, matrix wall times 13–27 min
  • CI green on the validity-contract tip 2e6dd8aef (prepare validation, CrossingEdgeSplit, definitional-exterior anchor, kernel-point equality fix) — run 29434731843, all 7 jobs
  • CI green on the kernel-fast-path + signed-area tip 69e416484 (certified spherical Float64 fast paths 8d938e832, signed Eriksson area fix) — run 29500550781, all 7 jobs

🤖 Generated with Claude Code

asinghvi17 and others added 30 commits June 10, 2026 21:06
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>
asinghvi17 and others added 25 commits July 14, 2026 13:44
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant