diff --git a/GeometryOpsCore/src/types/manifold.jl b/GeometryOpsCore/src/types/manifold.jl index 837bdc2d9d..6f00e3aeeb 100644 --- a/GeometryOpsCore/src/types/manifold.jl +++ b/GeometryOpsCore/src/types/manifold.jl @@ -54,22 +54,56 @@ struct Planar <: Manifold end """ - Spherical(; radius) - -A spherical manifold means that the geometry is on the 3-sphere (but is represented by 2-D longitude and latitude). + Spherical(; radius, oriented = false) + +A spherical manifold means that the geometry is on the 3-sphere (but is represented by 2-D longitude and latitude). + +`oriented` selects how the interior of a polygon ring is interpreted: + +- `oriented = false` (the default): a ring's interior is the region it + *encloses* — the smaller of the two regions it bounds — independent of + winding direction. This matches how most of the ecosystem treats + unoriented data by default (R's `s2`/`sf`, spherely, BigQuery), and means + shapefile-convention data (clockwise shells) is read the same way as + counterclockwise data. No region larger than a hemisphere can be + represented. +- `oriented = true`: polygon ring directions are known to be correct — + exterior rings counterclockwise, interior rings clockwise — so the + interior of the polygon is the region on the *left* of each ring's stored + vertex order (the convention of S2's `S2Polygon::InitOriented`). This + makes regions larger than a hemisphere representable, e.g. "the sphere + minus a small cap" as a clockwise ring. ## Extended help !!! note - The traditional definition of spherical coordinates in physics and mathematics, - ``r, \\theta, \\phi``, uses the _colatitude_, that measures angular displacement from the `z`-axis. - + The traditional definition of spherical coordinates in physics and mathematics, + ``r, \\theta, \\phi``, uses the _colatitude_, that measures angular displacement from the `z`-axis. + Here, we use the geographic definition of longitude and latitude, meaning - that `lon` is longitude between -180 and 180, and `lat` is latitude between + that `lon` is longitude between -180 and 180, and `lat` is latitude between `-90` (south pole) and `90` (north pole). + +!!! note + With `oriented = true`, a ring may denote a region covering most of the + sphere. Operations remain correct on such regions, but extent-based + pruning degenerates (the region's bounding box is essentially the whole + sphere), so spatial predicates against them fall back to slower paths. + +!!! warning "Validity is manifold-dependent" + A ring that is valid in lon/lat can be *invalid on the sphere*: two + non-adjacent edges may cross when reinterpreted as great-circle arcs + (a planar needle a few meters wide is enough — Natural Earth 110m + Sudan is a real instance), and no planar validity tool can detect it. + Prepared spherical predicates (`GeometryOps.prepare`) therefore + validate against this class by default and throw an "edge i crosses + edge j" error; the remedy is the `GeometryOps.CrossingEdgeSplit` + correction, which splits each ring at its crossing points into + separate loops (even-odd semantics). """ Base.@kwdef struct Spherical{T} <: Manifold radius::T = WGS84_EARTH_MEAN_RADIUS # this should be theWGS84 defined mean radius + oriented::Bool = false end """ diff --git a/Project.toml b/Project.toml index 90ae4f8c41..6d15feb9a9 100644 --- a/Project.toml +++ b/Project.toml @@ -15,6 +15,7 @@ GeoFormatTypes = "68eda718-8dee-11e9-39e7-89f7f65f511f" GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f" GeometryOpsCore = "05efe853-fabf-41c8-927e-7063c8b9f013" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" SortTileRecursiveTree = "746ee33f-1797-42c2-866d-db2fce69d14d" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" @@ -58,6 +59,7 @@ GeoInterface = "1.6" GeometryOpsCore = "=0.1.10" LibGEOS = "0.9.2" LinearAlgebra = "1" +PrecompileTools = "1.2" Proj = "1" Random = "1" SortTileRecursiveTree = "0.1.2" diff --git a/benchmarks/relateng.jl b/benchmarks/relateng.jl new file mode 100644 index 0000000000..31222ac5fe --- /dev/null +++ b/benchmarks/relateng.jl @@ -0,0 +1,152 @@ +# # RelateNG benchmarks +# +#= +Benchmarks for the RelateNG DE-9IM engine (Task 28 of the relateng plan): +RelateNG vs the old GO per-pair predicate processors vs LibGEOS (plain and +prepared — GEOS >= 3.13 runs RelateNG natively), across polygon sizes, for +two workload shapes: + +- `intersects`: early-exit-friendly — the engine can stop at the first + interior/interior interaction it proves. +- full `relate`: no early exit — the complete DE-9IM matrix is computed. + +Run with `julia --project=docs benchmarks/relateng.jl`. Prints a comparison +table; no CI gating (representative output is recorded in the comment block +at the bottom of this file). +=# + +import GeometryOps as GO, + GeoInterface as GI, + LibGEOS as LG +using Chairmarks +using Printf +using Random + +include(joinpath(@__DIR__, "..", "test", "data", "polygon_generation.jl")) + +# As in `benchmarks.jl`: give each package its native geometry. +lg_and_go(geometry) = (GI.convert(LG, geometry), GO.tuples(geometry)) + +# A valid (LibGEOS-checked) random polygon with `nverts` vertices centered at +# `(x, y)`. The generator does not guarantee non-self-intersecting rings, so +# draw until valid (low spikiness makes rejection rare). +function valid_random_poly(x, y, nverts, rng) + while true + poly = GI.Polygon(generate_random_poly(x, y, nverts, 2.0, 0.3, 0.1, rng)) + LG.isValid(GI.convert(LG, poly)) && return poly + end +end + +const LG_CTX = LG.get_global_context() +# LibGEOS has no high-level `relate`; the generated wrapper returns the +# DE-9IM string itself (cf. test/methods/relateng/fuzz.jl). +lg_relate(la, lb) = LG.GEOSRelate_r(LG_CTX, la, lb) + +prettytime(s) = + s < 1e-6 ? @sprintf("%8.1f ns", s * 1e9) : + s < 1e-3 ? @sprintf("%8.1f μs", s * 1e6) : + s < 1.0 ? @sprintf("%8.1f ms", s * 1e3) : + @sprintf("%8.2f s ", s) + +median_time(trial) = Chairmarks.median(trial).time + +function print_table(title, colnames, rows) + printstyled(title; color = :green, bold = true) + println() + @printf("%8s", "nverts") + foreach(c -> @printf(" │ %18s", c), colnames) + println() + println("─"^(8 + 21 * length(colnames))) + for (n, times) in rows + @printf("%8d", n) + foreach(t -> @printf(" │ %18s", prettytime(t)), times) + println() + end + println() +end + +const ALG = GO.RelateNG() +const NVERTS = [2^4, 2^6, 2^8, 2^10, 2^12] + +intersects_rows = Vector{Pair{Int, Vector{Float64}}}() +relate_rows = Vector{Pair{Int, Vector{Float64}}}() + +for nverts in NVERTS + rng = Xoshiro(42) + # Two overlapping random polygons (centers 2.0 apart, radius 2.0), so + # `intersects` is true and the full matrix is the overlaps pattern. + a = valid_random_poly(0.0, 0.0, nverts, rng) + b = valid_random_poly(2.0, 0.0, nverts, rng) + lg_a, go_a = lg_and_go(a) + lg_b, go_b = lg_and_go(b) + prep_go = GO.prepare(ALG, go_a) + prep_lg = LG.prepareGeom(lg_a) + + #-- Workload 1: `intersects` (early-exit-friendly) + t_old = @be GO.intersects($go_a, $go_b) seconds=1 + t_ng = @be GO.intersects($ALG, $go_a, $go_b) seconds=1 + t_ng_prep = @be GO.relate_predicate($prep_go, GO.pred_intersects(), $go_b) seconds=1 + t_lg = @be LG.intersects($lg_a, $lg_b) seconds=1 + t_lg_prep = @be LG.intersects($prep_lg, $lg_b) seconds=1 + push!(intersects_rows, nverts => median_time.([t_old, t_ng, t_ng_prep, t_lg, t_lg_prep])) + + #-- Workload 2: full `relate` (no early exit; old GO has no `relate`) + t_ng = @be GO.relate($ALG, $go_a, $go_b) seconds=1 + t_ng_prep = @be GO.relate($prep_go, $go_b) seconds=1 + t_lg = @be lg_relate($lg_a, $lg_b) seconds=1 + push!(relate_rows, nverts => median_time.([t_ng, t_ng_prep, t_lg])) +end + +print_table("intersects (early exit, overlapping random polygons)", + ["GO old", "RelateNG", "RelateNG prepared", "LibGEOS", "LibGEOS prepared"], + intersects_rows) + +print_table("relate (full DE-9IM matrix, overlapping random polygons)", + ["RelateNG", "RelateNG prepared", "LibGEOS"], + relate_rows) + +#= +Representative output (2026-06-11, Apple M4 Pro, macOS — Darwin 25.5.0; +Julia 1.12.6, GEOS 3.14.1; `julia --project=docs benchmarks/relateng.jl`): + +intersects (early exit, overlapping random polygons) + nverts │ GO old │ RelateNG │ RelateNG prepared │ LibGEOS │ LibGEOS prepared +───────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 16 │ 1.7 μs │ 11.6 μs │ 11.0 μs │ 2.1 μs │ 827.4 ns + 64 │ 23.0 μs │ 137.8 μs │ 70.4 μs │ 4.7 μs │ 2.1 μs + 256 │ 328.4 μs │ 562.7 μs │ 282.6 μs │ 13.8 μs │ 7.5 μs + 1024 │ 5.2 ms │ 38.7 μs │ 26.2 μs │ 2.0 μs │ 106.4 ns + 4096 │ 79.8 ms │ 155.5 μs │ 105.1 μs │ 6.7 μs │ 469.1 ns + +relate (full DE-9IM matrix, overlapping random polygons) + nverts │ RelateNG │ RelateNG prepared │ LibGEOS +─────────────────────────────────────────────────────────────────────── + 16 │ 38.4 μs │ 37.5 μs │ 3.6 μs + 64 │ 158.7 μs │ 90.2 μs │ 5.7 μs + 256 │ 681.1 μs │ 399.0 μs │ 23.7 μs + 1024 │ 3.7 ms │ 2.6 ms │ 201.1 μs + 4096 │ 36.3 ms │ 37.1 ms │ 3.5 ms + +Reading notes: + +- The `intersects` columns are non-monotonic by design of the workload, not + noise: above the accelerator threshold (1024+ vertices here) the STRtree + edge index kicks in AND the early exit fires on the first proven + interior/interior interaction, so the 1024/4096 rows are *cheaper* than + 256 (which takes the below-threshold nested loop over ~65k segment pairs + to the first hit). GEOS short-circuits the same way throughout, and its + prepared `intersects` resolves these cases from the point-in-area index + alone (sub-μs). +- Old GO `intersects` scales quadratically here (5.2 ms / 80 ms at + 1024/4096) — RelateNG overtakes it between 256 and 1024 vertices and is + ~500x faster at 4096. +- Full `relate` has no early exit, so every engine pays for the complete + node analysis; RelateNG is within ~10x of native GEOS RelateNG at the + large end. A flat profile of the 4096-vertex A/A `relate` case shows the + time split between exact segment-pair classification (~25%, mostly + AdaptivePredicates orientation — productive work, negligible call-boundary + overhead), per-call segment-extent-table construction for the STRtree + (~28%), and node analysis (Dict-backed `evaluate_nodes!` is ~8%). No + rational-fallback (`rk_nodes_coincide`) frames appear at all — no + F1-level hotspot. +=# diff --git a/benchmarks/relateng_gadm.jl b/benchmarks/relateng_gadm.jl new file mode 100644 index 0000000000..1b3ee8bcc4 --- /dev/null +++ b/benchmarks/relateng_gadm.jl @@ -0,0 +1,263 @@ +# # RelateNG GADM real-data benchmarks +# +#= +Benchmarks for the RelateNG DE-9IM engine on **GADM** full-resolution country +boundaries — the high-resolution complement to `benchmarks/relateng_realdata.jl` +(Natural Earth). GADM level-0 polygons are one to two orders of magnitude denser +than Natural Earth (Canada is ~3.9M vertices / 24k rings here, vs ~68k / 412 at +NE 10m), so this file exercises `prepare`, its indexed point-in-area locators, +and the spherical validation self-join at real production scale. + +This is a **separate, independently runnable file** because GADM downloads are +heavy: GADM.jl fetches a per-country GeoPackage on first use (tens of MB each), +so the first ever run needs network access and writes to the DataDeps cache; +cached runs are offline. **Pre-download** the countries used below before a timed +run — the file loads: + + CAN (point-in-area target, largest sampled country) + EGY SDN FRA ITA GRC TUR (neighbour-pair `touches`) + +e.g. `julia --project=docs -e 'import GADM; foreach(GADM.get, ["CAN","EGY","SDN","FRA","ITA","GRC","TUR"])'`. + +Three workload groups, each a table of per-operation medians: + +1. Point-in-area at GADM scale on the largest sampled country (Canada): the + planar `intersects` mix — unprepared, extent-stamped (`GO.tuples(x; + calc_extent = true)`), prepared (`GO.prepare`), LibGEOS plain and prepared — + per query, plus the `GO.prepare` build split into *index build* + (`validate = false`) vs the default validating build, so the validation + overhead at ~4M vertices is a recorded number. +2. Spherical at GADM scale on the same country: prepared point-in-area through + the longitude-interval indexed locator, and the prepared build with and + without the ring self-crossing validation — the locator and validator's + first serious-scale exercise. +3. `touches` on real land-border neighbour pairs, planar RelateNG vs LibGEOS. + +Run with `julia --project=docs benchmarks/relateng_gadm.jl`. Prints comparison +tables; no CI gating (representative output is in the comment block at the +bottom). Everything is seeded (`MersenneTwister(7)`), so reruns measure +identical workloads. Total runtime is ~5–10 min on a warm data cache (excluding +package precompilation), dominated by the ~6 s spherical `prepare` builds. +=# + +import GeometryOps as GO, + GeoInterface as GI, + LibGEOS as LG +import Extents +import GADM +using Chairmarks +using Printf +using Random + +# As in `benchmarks/relateng_realdata.jl`: give each package its native geometry. +lg_and_go(geometry) = (GI.convert(LG, geometry), GO.tuples(geometry)) + +prettytime(s) = + s < 1e-6 ? @sprintf("%8.1f ns", s * 1e9) : + s < 1e-3 ? @sprintf("%8.1f μs", s * 1e6) : + s < 1.0 ? @sprintf("%8.1f ms", s * 1e3) : + @sprintf("%8.2f s ", s) + +median_time(trial) = Chairmarks.median(trial).time +# Each evaluation sweeps a workload of `n` operations; report per-op time. +per_op(trial, n) = median_time(trial) / n + +function print_table(title, firstcol, colnames, rows) + printstyled(title; color = :green, bold = true) + println() + @printf("%-30s", firstcol) + foreach(c -> @printf(" │ %18s", c), colnames) + println() + println("─"^(30 + 21 * length(colnames))) + for (label, times) in rows + @printf("%-30s", label) + foreach(t -> @printf(" │ %18s", prettytime(t)), times) + println() + end + println() +end + +# Load a GADM level-0 country by ISO-3 code and return its (Multi)Polygon. +# GADM.jl returns a Tables.jl feature collection; level-0 is a single feature. +function gadm_geom(code) + tbl = try + GADM.get(code) + catch err + error(""" + Could not load GADM country `$code`. + GADM.jl downloads a per-country GeoPackage on first use — this + machine either needs network access or a pre-warmed DataDeps cache. + Underlying error: $(sprint(showerror, err))""") + end + return GI.geometry(GI.getfeature(tbl, 1)) +end + +const ALG = GO.RelateNG() +const SALG = GO.RelateNG(; manifold = GO.Spherical()) + +# ## Workload 1: point-in-area on the largest sampled country (Canada) + +big_raw = gadm_geom("CAN") +lg_big, go_big = lg_and_go(big_raw) +go_big_ext = GO.tuples(big_raw; calc_extent = true) # extent-stamped variant +nrings = GO.applyreduce(x -> 1, +, GI.LinearRingTrait, go_big; init = 0) + +# Seed-7 point mix: uniform points in Canada's extent (about half hit). +ext = GI.extent(go_big) +rng = MersenneTwister(7) +pts = [GI.Point((ext.X[1] + rand(rng) * (ext.X[2] - ext.X[1]), + ext.Y[1] + rand(rng) * (ext.Y[2] - ext.Y[1]))) for _ in 1:10_000] +lg_pts = [LG.readgeom("POINT($(GI.x(p)) $(GI.y(p)))") for p in pts] + +prep = GO.prepare(ALG, go_big) +lg_prep = LG.prepareGeom(lg_big) + +nhit = count(p -> GO.relate_predicate(prep, GO.pred_intersects(), p), pts) +@printf("Point-in-area target: Canada — %d vertices, %d rings; point mix: %d/%d hits\n\n", + GI.npoint(go_big), nrings, nhit, length(pts)) + +# Unprepared point queries reconvert and re-extent the whole geometry per call, +# so at ~4M vertices they are milliseconds — a small point count suffices. +t_unprep = @be count(p -> GO.intersects($ALG, $go_big, p), $(pts[1:20])) seconds=1 +t_stamped = @be count(p -> GO.intersects($ALG, $go_big_ext, p), $(pts[1:50])) seconds=1 +t_prep = @be count(p -> GO.relate_predicate($prep, GO.pred_intersects(), p), $pts) seconds=1 +t_lg = @be count(p -> LG.intersects($lg_big, p), $(lg_pts[1:1000])) seconds=1 +t_lg_prep = @be count(p -> LG.intersects($lg_prep, p), $lg_pts) seconds=1 +t_build = @be GO.prepare($ALG, $go_big) seconds=2 + +pq_unprep, pq_prep = per_op(t_unprep, 20), per_op(t_prep, length(pts)) +print_table("point-in-area intersects (Canada, GADM full res, seed-7 point mix, per query)", + "workload", + ["unprepared", "extent-stamped", "prepared", "LibGEOS", "LibGEOS prepared"], + ["Canada, per point" => + [pq_unprep, per_op(t_stamped, 50), pq_prep, + per_op(t_lg, 1000), per_op(t_lg_prep, length(pts))]]) + +build = median_time(t_build) +@printf("GO.prepare build: %s → amortized against unprepared after ~%.1f queries\n\n", + strip(prettytime(build)), build / (pq_unprep - pq_prep)) + +# Build cost split: index build (`validate = false`) vs the default validating +# build (the ring self-crossing self-join). Planar `prepare` defaults to +# `validate = false`; timing both isolates the validation overhead at scale. +t_build_noval = @be GO.prepare($ALG, $go_big; validate = false) seconds=2 +t_build_val = @be GO.prepare($ALG, $go_big; validate = true) seconds=2 +print_table("planar prepare build cost split (Canada, per build)", + "build", + ["index only (validate=false)", "validating (validate=true)"], + ["GO.prepare" => [median_time(t_build_noval), median_time(t_build_val)]]) +@printf("planar validation overhead: %s (%.0f%% of the validating build)\n\n", + strip(prettytime(median_time(t_build_val) - median_time(t_build_noval))), + 100 * (median_time(t_build_val) - median_time(t_build_noval)) / median_time(t_build_val)) + +# ## Workload 2: spherical point-in-area + build on the same country +# +# Prepared spherical point-in-area runs the indexed locator (longitude-interval +# edge index + meridian-arc crossing parity). Spherical `prepare` defaults to +# `validate = true` (the ring self-crossing check that motivates default +# validation); the build split records what that validation costs at ~4M edges. + +sprep = GO.prepare(SALG, go_big; validate = false) +nhit_s = count(p -> GO.relate_predicate(sprep, GO.pred_intersects(), p), pts[1:200]) +t_s_prep = @be count(p -> GO.relate_predicate($sprep, GO.pred_intersects(), p), $(pts[1:200])) seconds=1 +t_s_build_noval = @be GO.prepare($SALG, $go_big; validate = false) seconds=1 +t_s_build_val = @be GO.prepare($SALG, $go_big; validate = true) seconds=1 + +print_table("spherical RelateNG at GADM scale (Canada, per op)", + "workload", + ["Spherical", "Planar"], + ["point-in-area prepared, per point" => [per_op(t_s_prep, 200), pq_prep], + "prepare build (index only)" => [median_time(t_s_build_noval), median_time(t_build_noval)], + "prepare build (validating)" => [median_time(t_s_build_val), median_time(t_build_val)]]) +@printf("spherical prepared point mix: %d/200 hits; spherical validation overhead: %s (%.0f%% of the validating build)\n\n", + nhit_s, + strip(prettytime(median_time(t_s_build_val) - median_time(t_s_build_noval))), + 100 * (median_time(t_s_build_val) - median_time(t_s_build_noval)) / median_time(t_s_build_val)) + +# ## Workload 3: `touches` on real land-border neighbour pairs + +const PAIRS = [("EGY", "SDN"), ("FRA", "ITA"), ("GRC", "TUR")] +pair_geoms = Dict(c => lg_and_go(gadm_geom(c)) for c in unique(Iterators.flatten(PAIRS))) + +rows = Pair{String, Vector{Float64}}[] +for (ca, cb) in PAIRS + lga, goa = pair_geoms[ca] + lgb, gob = pair_geoms[cb] + pt_ng = @be GO.touches($ALG, $goa, $gob) seconds=1 + pt_lg = @be LG.touches($lga, $lgb) seconds=1 + push!(rows, "$(ca)–$(cb) ($(GI.npoint(goa))/$(GI.npoint(gob)) verts)" => + [median_time(pt_ng), median_time(pt_lg)]) +end +print_table("touches on real land-border pairs (GADM full res, per predicate)", + "pair", ["RelateNG", "LibGEOS"], rows) + +#= +Representative output (2026-07-15, Apple M4 Pro, macOS — Darwin 25.5.0; +Julia 1.12.6, GEOS 3.14.1, GADM.jl 1.2.0 (GADM 4.1), GeometryOps @ 2e6dd8aef; +`julia --project=docs benchmarks/relateng_gadm.jl`, ~4 min wall on a warm data +cache excluding package precompilation): + +Point-in-area target: Canada — 3892522 vertices, 24480 rings; point mix: 4676/10000 hits + +point-in-area intersects (Canada, GADM full res, seed-7 point mix, per query) +workload │ unprepared │ extent-stamped │ prepared │ LibGEOS │ LibGEOS prepared +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Canada, per point │ 2.9 ms │ 550.1 μs │ 1.8 μs │ 710.2 μs │ 1.2 μs + +GO.prepare build: 40.1 ms → amortized against unprepared after ~13.7 queries + +planar prepare build cost split (Canada, per build) +build │ index only (validate=false) │ validating (validate=true) +──────────────────────────────────────────────────────────────────────── +GO.prepare │ 35.7 ms │ 973.3 ms + +planar validation overhead: 937.6 ms (96% of the validating build) + +spherical RelateNG at GADM scale (Canada, per op) +workload │ Spherical │ Planar +──────────────────────────────────────────────────────────────────────── +point-in-area prepared, per point │ 2.3 μs │ 1.8 μs +prepare build (index only) │ 5.47 s │ 35.7 ms +prepare build (validating) │ 7.06 s │ 973.3 ms + +spherical prepared point mix: 104/200 hits; spherical validation overhead: 1.58 s (22% of the validating build) + +touches on real land-border pairs (GADM full res, per predicate) +pair │ RelateNG │ LibGEOS +──────────────────────────────────────────────────────────────────────── +EGY–SDN (117090/49006 verts) │ 1.4 ms │ 1.9 ms +FRA–ITA (216353/303163 verts) │ 6.1 ms │ 10.8 ms +GRC–TUR (436532/230727 verts) │ 31.1 ms │ 55.9 ms + +Reading notes: + +- Point-in-area at ~4M vertices: the prepared indexed locator is the whole + story. Prepared RelateNG (1.8 μs) tracks prepared LibGEOS (1.2 μs) to within + ~1.5x and is ~1600x faster than unprepared RelateNG (2.9 ms) and ~390x + faster than plain LibGEOS (710 μs) — at this scale the per-call envelope / + reconversion cost that unprepared and plain-GEOS pay every query dwarfs the + point test. `GO.prepare` (35.7 ms index build) pays for itself after ~14 + unprepared-equivalent queries. Extent-stamping the input + (`GO.tuples(x; calc_extent = true)`) removes the per-call extent pass and + cuts unprepared from 2.9 ms to 550 μs, but the indexed prepared path is three + orders of magnitude below either. +- Planar validation at scale: the ring self-crossing self-join over ~3.9M edges + is 938 ms — 96% of the validating build. Planar `prepare` therefore leaves it + off by default (index build alone is 36 ms); opting in (`validate = true`) is + a ~1 s one-time cost that a planar-invalid input would otherwise surface only + as a wrong answer. +- Spherical: the longitude-interval indexed locator makes prepared spherical + point-in-area 2.3 μs/query — essentially at parity with planar (1.8 μs) even + at 3.9M vertices, confirming the locator's per-query cost scales with + edges-crossing-the-meridian, not total edges. The spherical build is the real + cost: 5.47 s index build (dominated by per-vertex kernel-point conversion of + 3.9M vertices) plus 1.58 s validation (22% of the 7.06 s validating build). + Spherical `prepare` validates by default because the silent-class defect it + catches inverts containment; at GADM scale that safety costs ~1.6 s on top of + a build already dominated by index construction. +- `touches` on real land borders: RelateNG is faster than LibGEOS at GADM full + resolution (1.4 vs 1.9 ms, 6.1 vs 10.8 ms, 31.1 vs 55.9 ms) — the reverse of + the Natural Earth 10m result (~1.2x behind), because full-res borders resolve + the predicate from the boundary node topology before escalation, where + RelateNG's edge indexing amortizes better than GEOS's per-call relate. +=# diff --git a/benchmarks/relateng_realdata.jl b/benchmarks/relateng_realdata.jl new file mode 100644 index 0000000000..8003f44c42 --- /dev/null +++ b/benchmarks/relateng_realdata.jl @@ -0,0 +1,323 @@ +# # RelateNG real-data benchmarks +# +#= +Benchmarks for the RelateNG DE-9IM engine on real Natural Earth data +(complementing `benchmarks/relateng.jl`, which uses synthetic random +polygons). Data comes from NaturalEarth.jl — the artifacts are downloaded +and cached on first use, so the first ever run needs network access; cached +runs are offline. + +Five workload groups, each printed as a table of per-operation medians: + +1. Point-in-area `intersects` on the largest 10m country (Canada) over a + seeded uniform point mix in its extent: unprepared, unprepared with + extent-stamped input (`GO.tuples(x; calc_extent = true)` — the per-call + extent pass dominates unprepared point queries, and pre-stamped extents + short-circuit it; this column tracks a planned optimization), prepared + (`GO.prepare`), and LibGEOS plain/prepared. Plus `GO.prepare` build cost + and the break-even query count. +2. Pairwise country `intersects` at 110m on a seeded sample of ordered + pairs: RelateNG vs the old GO per-pair processors vs LibGEOS. +3. `touches` and full `relate` on real border-sharing neighbor pairs at 10m + (the extent-densest 24 countries; pairs that actually touch per LibGEOS). +4. Rivers x countries `intersects`/`crosses` at 10m on a seeded sample of + extent-intersecting pairs (`crosses` exercises the self-noding path). +5. Spherical variants (`GO.RelateNG(; manifold = GO.Spherical())`) of the + point-in-area and pairwise workloads, against their planar equivalents. + +Run with `julia --project=docs benchmarks/relateng_realdata.jl`. Prints +comparison tables; no CI gating (representative output is recorded in the +comment block at the bottom of this file). Everything is seeded — reruns +measure identical workloads. +=# + +import GeometryOps as GO, + GeoInterface as GI, + LibGEOS as LG +import Extents +using NaturalEarth, GeoJSON +using Chairmarks +using Printf +using Random + +# As in `benchmarks.jl`: give each package its native geometry. +lg_and_go(geometry) = (GI.convert(LG, geometry), GO.tuples(geometry)) + +const LG_CTX = LG.get_global_context() +# LibGEOS has no high-level `relate`; the generated wrapper returns the +# DE-9IM string itself (cf. benchmarks/relateng.jl, test/methods/relateng/fuzz.jl). +lg_relate(la, lb) = LG.GEOSRelate_r(LG_CTX, la, lb) + +prettytime(s) = + s < 1e-6 ? @sprintf("%8.1f ns", s * 1e9) : + s < 1e-3 ? @sprintf("%8.1f μs", s * 1e6) : + s < 1.0 ? @sprintf("%8.1f ms", s * 1e3) : + @sprintf("%8.2f s ", s) + +median_time(trial) = Chairmarks.median(trial).time +# Each evaluation sweeps a workload of `n` operations; report per-op time. +per_op(trial, n) = median_time(trial) / n + +function print_table(title, firstcol, colnames, rows) + printstyled(title; color = :green, bold = true) + println() + @printf("%-30s", firstcol) + foreach(c -> @printf(" │ %18s", c), colnames) + println() + println("─"^(30 + 21 * length(colnames))) + for (label, times) in rows + @printf("%-30s", label) + foreach(t -> @printf(" │ %18s", prettytime(t)), times) + println() + end + println() +end + +# Load a NaturalEarth layer and return (names, geometries), skipping features +# with no geometry and empty geometries (an empty MultiLineString in the 10m +# rivers layer breaks `GO.tuples`). +function ne_geoms(name, scale) + fc = try + naturalearth(name, scale) + catch err + error(""" + Could not load NaturalEarth layer `$name` at $(scale)m. + NaturalEarth.jl downloads each layer on first use — this machine + either needs network access or a pre-warmed artifact cache. + Underlying error: $(sprint(showerror, err))""") + end + names = String[] + geoms = [] + for f in fc + g = GeoJSON.geometry(f) + g === nothing && continue + GI.npoint(g) == 0 && continue + props = GeoJSON.properties(f) # Dict{Symbol, Any} + push!(names, string(get(props, :NAME, get(props, :name, "?")))) + push!(geoms, g) + end + return names, geoms +end + +const ALG = GO.RelateNG() + +# ## Workload 1: point-in-area on the largest 10m country + +names10, geoms10_raw = ne_geoms("admin_0_countries", 10) +go10 = [GO.tuples(g) for g in geoms10_raw] +exts10 = [GI.extent(g) for g in go10] + +i_big = argmax(GI.npoint.(go10)) +big_raw = geoms10_raw[i_big] +lg_big, go_big = lg_and_go(big_raw) +go_big_ext = GO.tuples(big_raw; calc_extent = true) # extent-stamped variant +nrings = GO.applyreduce(x -> 1, +, GI.LinearRingTrait, go_big; init = 0) + +# The profiling campaign's seed-7 point mix: uniform points in the target's +# extent (about half hit). Sub-ranges below are prefixes of the same mix. +ext = exts10[i_big] +rng = MersenneTwister(7) +pts = [GI.Point((ext.X[1] + rand(rng) * (ext.X[2] - ext.X[1]), + ext.Y[1] + rand(rng) * (ext.Y[2] - ext.Y[1]))) for _ in 1:10_000] +lg_pts = [LG.readgeom("POINT($(GI.x(p)) $(GI.y(p)))") for p in pts] + +prep = GO.prepare(ALG, go_big) +lg_prep = LG.prepareGeom(lg_big) + +nhit = count(p -> GO.relate_predicate(prep, GO.pred_intersects(), p), pts) +@printf("Point-in-area target: %s — %d vertices, %d rings; point mix: %d/%d hits\n\n", + names10[i_big], GI.npoint(go_big), nrings, nhit, length(pts)) + +t_unprep = @be count(p -> GO.intersects($ALG, $go_big, p), $(pts[1:100])) seconds=1 +t_stamped = @be count(p -> GO.intersects($ALG, $go_big_ext, p), $(pts[1:200])) seconds=1 +t_prep = @be count(p -> GO.relate_predicate($prep, GO.pred_intersects(), p), $pts) seconds=1 +t_lg = @be count(p -> LG.intersects($lg_big, p), $(lg_pts[1:1000])) seconds=1 +t_lg_prep = @be count(p -> LG.intersects($lg_prep, p), $lg_pts) seconds=1 +t_build = @be GO.prepare($ALG, $go_big) seconds=1 + +pq_unprep, pq_prep = per_op(t_unprep, 100), per_op(t_prep, length(pts)) +print_table("point-in-area intersects (largest 10m country, seed-7 point mix, per query)", + "workload", + ["unprepared", "extent-stamped", "prepared", "LibGEOS", "LibGEOS prepared"], + ["$(names10[i_big]), per point" => + [pq_unprep, per_op(t_stamped, 200), pq_prep, + per_op(t_lg, 1000), per_op(t_lg_prep, length(lg_pts))]]) + +build = median_time(t_build) +@printf("GO.prepare build: %s → amortized against unprepared after ~%.1f queries\n\n", + strip(prettytime(build)), build / (pq_unprep - pq_prep)) + +# ## Workload 2: pairwise country `intersects` at 110m + +names110, geoms110_raw = ne_geoms("admin_0_countries", 110) +go110 = [GO.tuples(g) for g in geoms110_raw] +lg110 = [GI.convert(LG, g) for g in geoms110_raw] +n110 = length(go110) + +pairs_all = [(i, j) for i in 1:n110 for j in 1:n110 if i != j] +pair_sample = shuffle(Xoshiro(42), pairs_all)[1:3000] +npairhit = count(((i, j),) -> LG.intersects(lg110[i], lg110[j]), pair_sample) +@printf("110m: %d countries (%d vertices); %d sampled ordered pairs, %d intersecting\n\n", + n110, sum(GI.npoint, go110), length(pair_sample), npairhit) + +t_ng = @be count(((i, j),) -> GO.intersects($ALG, $go110[i], $go110[j]), $pair_sample) seconds=1 +t_old = @be count(((i, j),) -> GO.intersects($go110[i], $go110[j]), $pair_sample) seconds=1 +t_lgp = @be count(((i, j),) -> LG.intersects($lg110[i], $lg110[j]), $pair_sample) seconds=1 + +print_table("pairwise intersects (110m countries, seeded pair sample, per pair)", + "workload", + ["RelateNG", "GO old", "LibGEOS"], + ["$(length(pair_sample)) ordered pairs" => + per_op.([t_ng, t_old, t_lgp], length(pair_sample))]) + +# ## Workload 3: `touches` + full `relate` on real 10m border pairs +# +# The campaign's 10m neighbor set: among the 24 countries with the most +# extent-overlaps, the ordered pairs that actually share a border (per +# LibGEOS `touches`). Real borders are near-collinear point soups — this is +# the exact-predicate stress test. + +deg = zeros(Int, length(go10)) +for i in eachindex(go10), j in eachindex(go10) + if i != j && Extents.intersects(exts10[i], exts10[j]) + deg[i] += 1 + end +end +top = sortperm(deg; rev = true)[1:24] +cand10 = [(i, j) for i in top for j in top if i != j && Extents.intersects(exts10[i], exts10[j])] +lg10 = Dict(i => GI.convert(LG, geoms10_raw[i]) for i in unique(Iterators.flatten(cand10))) +nbrs = [(i, j) for (i, j) in cand10 if LG.touches(lg10[i], lg10[j])] +@printf("10m neighbor set: %d extent-hit ordered pairs among top-24 countries, %d touching\n\n", + length(cand10), length(nbrs)) + +t_t_ng = @be count(((i, j),) -> GO.touches($ALG, $go10[i], $go10[j]), $nbrs) seconds=1 +t_t_lg = @be count(((i, j),) -> LG.touches($lg10[i], $lg10[j]), $nbrs) seconds=1 +t_r_ng = @be sum(((i, j),) -> length(string(GO.relate($ALG, $go10[i], $go10[j]))), $nbrs) seconds=1 +t_r_lg = @be sum(((i, j),) -> length(lg_relate($lg10[i], $lg10[j])), $nbrs) seconds=1 + +print_table("real border-sharing neighbor pairs (10m, $(length(nbrs)) pairs, per pair)", + "predicate", + ["RelateNG", "LibGEOS"], + ["touches" => per_op.([t_t_ng, t_t_lg], length(nbrs)), + "full relate" => per_op.([t_r_ng, t_r_lg], length(nbrs))]) + +# ## Workload 4: rivers x countries at 10m + +rnames, rgeoms_raw = ne_geoms("rivers_lake_centerlines", 10) +gor = [GO.tuples(g) for g in rgeoms_raw] +rexts = [GI.extent(g) for g in gor] +rc = [(ri, ci) for ri in eachindex(gor) for ci in eachindex(go10) + if Extents.intersects(rexts[ri], exts10[ci])] +rc_sample = shuffle(Xoshiro(42), rc)[1:150] +lgr = Dict(ri => GI.convert(LG, rgeoms_raw[ri]) for ri in unique(first.(rc_sample))) +lgc = Dict(ci => GI.convert(LG, geoms10_raw[ci]) for ci in unique(last.(rc_sample))) +@printf("rivers x countries: %d rivers (%d vertices), %d extent-hit pairs, %d sampled\n\n", + length(gor), sum(GI.npoint, gor), length(rc), length(rc_sample)) + +t_i_ng = @be count(((ri, ci),) -> GO.intersects($ALG, $gor[ri], $go10[ci]), $rc_sample) seconds=1 +t_i_lg = @be count(((ri, ci),) -> LG.intersects($lgr[ri], $lgc[ci]), $rc_sample) seconds=1 +t_c_ng = @be count(((ri, ci),) -> GO.crosses($ALG, $gor[ri], $go10[ci]), $rc_sample) seconds=1 +t_c_lg = @be count(((ri, ci),) -> LG.crosses($lgr[ri], $lgc[ci]), $rc_sample) seconds=1 + +print_table("rivers x countries (10m, seeded sample of extent-hit pairs, per pair)", + "predicate", + ["RelateNG", "LibGEOS"], + ["intersects" => per_op.([t_i_ng, t_i_lg], length(rc_sample)), + "crosses" => per_op.([t_c_ng, t_c_lg], length(rc_sample))]) + +# ## Workload 5: spherical variants +# +# Prepared spherical point-in-area goes through the indexed locator +# (longitude-interval edge index + meridian-arc crossing parity, the +# 2026-07-14 spherical-indexed-locator design); the unprepared arm scans +# the rings exactly and reconverts vertices per call. + +const SALG = GO.RelateNG(; manifold = GO.Spherical()) +sprep = GO.prepare(SALG, go_big) + +t_s_unprep = @be count(p -> GO.intersects($SALG, $go_big, p), $(pts[1:5])) seconds=1 +t_s_prep = @be count(p -> GO.relate_predicate($sprep, GO.pred_intersects(), p), $(pts[1:20])) seconds=1 +t_s_build = @be GO.prepare($SALG, $go_big) seconds=1 +t_s_pairs = @be count(((i, j),) -> GO.intersects($SALG, $go110[i], $go110[j]), $(pair_sample[1:50])) seconds=1 +t_p_pairs = @be count(((i, j),) -> GO.intersects($ALG, $go110[i], $go110[j]), $(pair_sample[1:50])) seconds=1 + +print_table("spherical RelateNG vs planar (same real-data workloads, per op)", + "workload", + ["Spherical", "Planar"], + ["point-in-area unprepared" => [per_op(t_s_unprep, 5), pq_unprep], + "point-in-area prepared" => [per_op(t_s_prep, 20), pq_prep], + "prepare build" => [median_time(t_s_build), build], + "pairwise intersects 110m" => [per_op(t_s_pairs, 50), per_op(t_p_pairs, 50)]]) + +#= +Representative output (2026-07-14, Apple M4 Pro, macOS — Darwin 25.5.0; +Julia 1.12.6, GEOS 3.14.1, GeometryOps @ ed2f9474e — after the single-pass +extent kernel, the point-in-area envelope short-circuits, and the spherical +indexed point-in-area locator; +`julia --project=docs benchmarks/relateng_realdata.jl`, ~90 s wall +excluding package precompilation): + +Point-in-area target: Canada — 68193 vertices, 412 rings; point mix: 4672/10000 hits + +point-in-area intersects (largest 10m country, seed-7 point mix, per query) +workload │ unprepared │ extent-stamped │ prepared │ LibGEOS │ LibGEOS prepared +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Canada, per point │ 62.6 μs │ 21.9 μs │ 1.2 μs │ 26.6 μs │ 653.3 ns + +GO.prepare build: 587.8 μs → amortized against unprepared after ~9.6 queries + +110m: 177 countries (10654 vertices); 3000 sampled ordered pairs, 70 intersecting + +pairwise intersects (110m countries, seeded pair sample, per pair) +workload │ RelateNG │ GO old │ LibGEOS +───────────────────────────────────────────────────────────────────────────────────────────── +3000 ordered pairs │ 1.6 μs │ 3.3 μs │ 115.7 ns + +10m neighbor set: 180 extent-hit ordered pairs among top-24 countries, 18 touching + +real border-sharing neighbor pairs (10m, 18 pairs, per pair) +predicate │ RelateNG │ LibGEOS +──────────────────────────────────────────────────────────────────────── +touches │ 790.9 μs │ 614.6 μs +full relate │ 720.0 μs │ 619.1 μs + +rivers x countries: 1454 rivers (256386 vertices), 5503 extent-hit pairs, 150 sampled + +rivers x countries (10m, seeded sample of extent-hit pairs, per pair) +predicate │ RelateNG │ LibGEOS +──────────────────────────────────────────────────────────────────────── +intersects │ 65.8 μs │ 35.6 μs +crosses │ 784.4 μs │ 79.1 μs + +spherical RelateNG vs planar (same real-data workloads, per op) +workload │ Spherical │ Planar +──────────────────────────────────────────────────────────────────────── +point-in-area unprepared │ 112.6 ms │ 62.6 μs +point-in-area prepared │ 2.2 μs │ 1.2 μs +prepare build │ 102.8 ms │ 587.8 μs +pairwise intersects 110m │ 171.4 μs │ 1.9 μs + +Reading notes: + +- Point-in-area: the single-pass extent kernel plus the JTS-parity envelope + short-circuits put unprepared RelateNG within ~2.4x of plain LibGEOS; + pre-stamped extents (`GO.tuples(x; calc_extent = true)`) edge past it, and + `GO.prepare` (indexed point-in-area locator) closes to ~2x of prepared + GEOS. Prepare pays for itself after ~10 unprepared-equivalent queries. +- Pairwise 110m: RelateNG is ~2x faster than the old GO processors and sits + ~14x behind LibGEOS on these mostly-disjoint pairs (LibGEOS resolves most + of them from the envelope alone). +- Real 10m borders (near-collinear point soups) force constant escalation + to exact predicates: ~1.2x behind GEOS on `touches`/full `relate`. +- Rivers x countries: `crosses` is ~12x `intersects` — it requires + self-noding, which rebuilds and re-traverses edge indexes per evaluation. +- Spherical: prepared point-in-area now runs the indexed locator + (longitude-interval edge index, meridian-arc crossing parity; the + 2026-07-14 spherical-indexed-locator design) — ~2x planar prepared, five + orders of magnitude down from the exact ring re-scan it replaced. The + build and the unprepared arm remain ~100 ms on Canada: both are dominated + by per-call kernel-point conversion and the exact scan, which a one-shot + query cannot amortize (that is what `prepare` is for). Pairwise spherical + `intersects` is edge-topology-bound (exact spherical predicates), not + point-location-bound, and is unchanged by the locator. +=# diff --git a/benchmarks/relateng_ttfx.jl b/benchmarks/relateng_ttfx.jl new file mode 100644 index 0000000000..66c969da55 --- /dev/null +++ b/benchmarks/relateng_ttfx.jl @@ -0,0 +1,170 @@ +# # RelateNG TTFX (fresh-process first-call) probe +# +#= +First-call latency ("time to first X") for RelateNG predicates. Each probe +instance spawns a *fresh* Julia process, loads GeometryOps, builds tiny +synthetic geometries, and times the first and second call of one predicate +on one geometry-type pair — so the first call is almost pure compile time +and the second call is steady state. + +This is the tool that exposed the Julia 1.12 compile-time blowup: steady- +state runtime does *not* regress on 1.12, but first-call compilation of +polygon-target instances regresses 10-36x (e.g. `crosses(poly, poly)` +0.20 s on 1.11.9 vs 7.40 s on 1.12.6). A test suite touches many such +predicate x type-pair instances, which is what turned the package test +suite from ~8 min into ~48 min on 1.12 CI. The instance set here is the +worst tail of the campaign's full 3 x 7 x 7 sweep: the three boolean +predicates against polygonal/line targets. + +Run with `julia --project=docs benchmarks/relateng_ttfx.jl`. The child +processes use the same project as the parent and, by default, the same +julia binary. To compare julia versions, point the children at another +binary via the `JULIA_EXE` environment variable or trailing ARGS (both +forms work with juliaup channel selectors): + + JULIA_EXE="julia +1.11" julia --project=docs benchmarks/relateng_ttfx.jl + julia --project=docs benchmarks/relateng_ttfx.jl julia +1.12 + +The project must be instantiated for the child's julia version (for a +non-default version: copy `Manifest.toml` to `Manifest-v1.X.toml`, then +`julia +1.X --project=docs -e 'using Pkg; Pkg.resolve(); Pkg.instantiate()'`). +Children reuse the depot's precompile caches — on a cold cache the first +instance additionally pays package precompilation, so rerun for clean +numbers. Representative output for both julia channels is recorded in the +comment block at the bottom of this file; no CI gating. +=# + +using Printf + +const JULIA_CMD = + !isempty(ARGS) ? Cmd(String.(ARGS)) : + haskey(ENV, "JULIA_EXE") ? Cmd(String.(split(ENV["JULIA_EXE"]))) : + Cmd([joinpath(Sys.BINDIR, "julia")]) +const PROJECT = Base.active_project() + +# The child program: time package load, then the first and second call of +# `ARGS = (predicate, type-of-a, type-of-b)`. Geometries are the tiny +# overlapping shapes from the profiling campaign's compile probe — compile +# time depends on types, not sizes. The call is wrapped in try/catch exactly +# like the campaign probe (some predicate/type combos may throw; the compile +# cost is what is being measured). +const CHILD_CODE = raw""" +const t0 = time_ns() +import GeometryOps as GO +import GeoInterface as GI +const t_load = (time_ns() - t0) / 1e9 +p1 = GI.Polygon([[(0.0, 0.0), (3.0, 0.0), (3.0, 3.0), (0.0, 3.0), (0.0, 0.0)]]) +p2 = GI.Polygon([[(2.0, 2.0), (5.0, 2.0), (5.0, 5.0), (2.0, 5.0), (2.0, 2.0)]]) +geoms = Dict( + "poly" => p1, + "mpoly" => GI.MultiPolygon([p1, p2]), + "line" => GI.LineString([(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)]), +) +preds = Dict("intersects" => GO.intersects, "touches" => GO.touches, "crosses" => GO.crosses) +f, a, b = preds[ARGS[1]], geoms[ARGS[2]], geoms[ARGS[3]] +alg = GO.RelateNG() +t1 = @elapsed try f(alg, a, b) catch end +t2 = @elapsed try f(alg, a, b) catch end +println("TTFX_RESULT ", VERSION, " ", t_load, " ", t1, " ", t2) +""" + +function probe(pred, an, bn) + cmd = `$JULIA_CMD --startup-file=no --project=$PROJECT -e $CHILD_CODE $pred $an $bn` + buf = IOBuffer() + ok = success(pipeline(cmd; stdout = buf, stderr = buf)) + out = String(take!(buf)) + m = match(r"TTFX_RESULT (\S+) (\S+) (\S+) (\S+)", out) + (ok && m !== nothing) || error("child process failed for $pred($an, $bn):\n$out") + return (; version = m[1], + t_load = parse(Float64, m[2]), + t_first = parse(Float64, m[3]), + t_second = parse(Float64, m[4])) +end + +prettytime(s) = + s < 1e-6 ? @sprintf("%8.1f ns", s * 1e9) : + s < 1e-3 ? @sprintf("%8.1f μs", s * 1e6) : + s < 1.0 ? @sprintf("%8.1f ms", s * 1e3) : + @sprintf("%8.2f s ", s) + +const PREDS = ("intersects", "touches", "crosses") +const PAIRS = (("poly", "poly"), ("mpoly", "poly"), ("line", "poly")) + +results = Pair{String, NamedTuple}[] +for pred in PREDS, (an, bn) in PAIRS + push!(results, "$pred($an, $bn)" => probe(pred, an, bn)) +end + +println("child: julia $(last(results).second.version) (`$(join(JULIA_CMD.exec, ' '))`)") +println("project: $PROJECT") +println() +printstyled("fresh-process first call (compile) vs second call (steady state)"; + color = :green, bold = true) +println() +@printf("%-24s", "instance") +foreach(c -> @printf(" │ %18s", c), ["package load", "first call", "second call"]) +println() +println("─"^(24 + 21 * 3)) +for (label, r) in results + @printf("%-24s", label) + foreach(t -> @printf(" │ %18s", prettytime(t)), [r.t_load, r.t_first, r.t_second]) + println() +end +println() + +#= +Representative output (2026-07-14, Apple M4 Pro, macOS — Darwin 25.5.0; +GeometryOps @ ba903d1ac — after the engine type-erasure + PrecompileTools +workload; both julia channels installed via juliaup; warm precompile caches). + +`julia --project=docs benchmarks/relateng_ttfx.jl`: + +child: julia 1.12.6 (`.../julia-1.12.6+0.aarch64.apple.darwin14/.../bin/julia`) + +fresh-process first call (compile) vs second call (steady state) +instance │ package load │ first call │ second call +─────────────────────────────────────────────────────────────────────────────────────── +intersects(poly, poly) │ 631.6 ms │ 322.5 μs │ 17.1 μs +intersects(mpoly, poly) │ 374.4 ms │ 243.3 ms │ 25.7 μs +intersects(line, poly) │ 363.1 ms │ 195.0 ms │ 10.9 μs +touches(poly, poly) │ 374.9 ms │ 436.6 μs │ 22.0 μs +touches(mpoly, poly) │ 369.1 ms │ 256.9 ms │ 28.0 μs +touches(line, poly) │ 368.5 ms │ 201.8 ms │ 19.0 μs +crosses(poly, poly) │ 375.7 ms │ 29.0 μs │ 3.8 μs +crosses(mpoly, poly) │ 369.7 ms │ 249.5 ms │ 26.2 μs +crosses(line, poly) │ 382.0 ms │ 204.9 ms │ 26.2 μs + +`JULIA_EXE="julia +1.11" julia --project=docs benchmarks/relateng_ttfx.jl`: + +child: julia 1.11.9 (`julia +1.11`) + +fresh-process first call (compile) vs second call (steady state) +instance │ package load │ first call │ second call +─────────────────────────────────────────────────────────────────────────────────────── +intersects(poly, poly) │ 396.4 ms │ 311.4 μs │ 8.6 μs +intersects(mpoly, poly) │ 404.1 ms │ 95.6 ms │ 32.5 μs +intersects(line, poly) │ 408.3 ms │ 106.2 ms │ 21.1 μs +touches(poly, poly) │ 413.4 ms │ 9.1 ms │ 27.8 μs +touches(mpoly, poly) │ 407.7 ms │ 140.8 ms │ 53.6 μs +touches(line, poly) │ 413.8 ms │ 127.6 ms │ 40.3 μs +crosses(poly, poly) │ 420.4 ms │ 105.7 μs │ 6.4 μs +crosses(mpoly, poly) │ 413.6 ms │ 72.8 ms │ 28.5 μs +crosses(line, poly) │ 448.0 ms │ 125.3 ms │ 43.0 μs + +Reading notes: + +- Summed first-call time: ~1.35 s on 1.12.6 vs ~0.68 s on 1.11.9. Instances + covered by the PrecompileTools workload (the `(poly, poly)` rows and + `crosses(poly, poly)` in particular) resolve in the μs range; the rest pay + ~100–260 ms of residual per-type-pair inference. +- History: at 099acacd1, before the engine core was type-erased (it was + re-inferred per input geometry-type pair) and before the package had a + PrecompileTools workload, this probe read 66.5 s summed first-call on + 1.12.6 vs 21.5 s on 1.11.9 — up to 8.6x per instance + (`crosses(poly, poly)`: 9.61 s vs 1.12 s), with package load and + steady-state equivalent across versions. That first-call pathology is what + made CI's RelateNG testsets take 48 min on Julia 1.12 vs 7.65 min on 1.11. + This probe is the regression gate for it: if the 1.12 column grows back + into the seconds, the engine has re-grown geometry-type-parameterized + internals. +=# diff --git a/docs/plans/2026-06-10-relateng-design.md b/docs/plans/2026-06-10-relateng-design.md new file mode 100644 index 0000000000..c50bd7268d --- /dev/null +++ b/docs/plans/2026-06-10-relateng-design.md @@ -0,0 +1,324 @@ +# RelateNG for GeometryOps.jl — Design + +**Date:** 2026-06-10 +**Status:** Validated design, pre-implementation +**Reference implementation:** JTS `org.locationtech.jts.operation.relateng` (Martin Davis, JTS 1.20) + +## Context and motivation + +GeometryOps' DE-9IM predicates (`intersects`, `within`, `touches`, …) are implemented as +~2,500 lines of hand-written per-trait-pair processors in `src/methods/geom_relations/`. +They are planar-only, have no `relate()` returning a full DE-9IM matrix, no prepared +(build-once, query-many) mode, and known gaps in degenerate cases. + +JTS's RelateNG engine is a single unified evaluator for all DE-9IM predicates with +aggressive short-circuiting, prepared-geometry support, pluggable boundary node rules, +and well-tested handling of dark corners (GeometryCollections, empty/zero-length inputs, +invalid geometry tolerance). This document specifies a Julia implementation of RelateNG +in GeometryOps, designed for: + +1. **Exactness** — answers computed via exact geometric predicates, with *no constructed + (rounded) intersection coordinates* in the topology logic, unlike JTS. +2. **Manifold generality** — the engine is generic over `Manifold`; `Planar` ships first, + `Spherical` is a planned follow-up implementing the same kernel contract. +3. **Performance** — predicate-specialized compilation, allocation-free hot paths, + reuse of GeometryOps' spatial-index acceleration. + +## Key decisions + +### D1. Split at the layer boundary: faithful topology layer, redesigned geometry layer + +RelateNG is two things glued together, and we treat them differently: + +- **Topology layer** (predicate contract, IM bookkeeping, evaluation phases, + node-labelling semantics, GC/empty/boundary-rule handling): purely combinatorial and + manifold-independent. **Ported faithfully** — same phase structure, same predicate + requirement flags, same labelling rules, file-by-file traceable to the Java source, + validated by the JTS XML test suite. This is where JTS's hard-won correctness lives; + we keep it diffable against the reference and able to absorb upstream fixes. +- **Geometry layer** (every question asked about coordinates): **redesigned** as a small + manifold-parameterized exact kernel with symbolic node identity. This is exactly where + JTS's floating-point construction and planar assumptions live. + +The interface between the layers is the `RelateKernel` API (below) — it is also the +contract a future `Spherical` kernel implements. + +### D2. Exact predicates; no constructed intersection points + +JTS constructs intersection coordinates in floating point +(`EdgeSegmentIntersector.java:71`, via `RobustLineIntersector`) and makes them +load-bearing in two places: node identity (`TopologyComputer` groups `NodeSection`s in a +`Map`) and angle ordering (the constructed point is the apex of +`PolygonNodeTopology.compareAngle`). JTS's own comments acknowledge the containing-segment +check "is not reliable" near proper intersections due to roundoff. + +We eliminate constructed points. Every segment-pair intersection is first classified +*combinatorially* by exact orientation/on-segment tests: + +- **At a vertex** of A and/or B → the node is that input vertex; its coordinate is exact. +- **Proper interior crossing** → the node is represented *symbolically* as the segment + pair. Its DE-9IM contribution (interior∩interior) and the cyclic order of the four + incident half-edges are decidable by orientation tests on the original endpoints; the + coordinate is never needed. + +This is possible because relate produces no output geometry: every answer is a finite +set of sign computations on input coordinates. + +> **Amendment (2026-06-11, Task 18).** One bounded deviation: locating a *crossing +> node* against a **lineal or GeometryCollection target** uses a representative +> Float64 point, correctly rounded from the exact rational crossing point +> (`_crossing_locate_point` in `topology_computer.jl`). Polygonal targets still need +> no coordinate (a node of a polygonal geometry is on its boundary, exactly). +> The failure window of the rounded point is only when the exact crossing lies +> within half an ULP of another element's endpoint (lineal targets — and only a +> false BOUNDARY answer is possible there; a false INTERIOR is impossible, since a +> crossing that is exactly representable rounds to itself) or within half an ULP of +> a non-parent polygon's boundary (GC targets). Correct rounding means this matches +> or beats JTS's `RobustLineIntersector`-constructed coordinate in every such +> window. A fully exact alternative (rational on-segment / rational point-in-polygon +> tests over the target's edges) is recorded as **Follow-up F6**. + +### D3. Exact crossing-node coincidence via a slow path (follow-up: make it fast) + +Whether two *proper crossings* coincide (only relevant for self-intersecting/invalid +input or predicates with `require_self_noding`) is decided by exact comparison of +rational intersection parameters (extended-precision/rational arithmetic). This is +accepted as a slow path for now. **Follow-up F1** investigates a fast filter +(interval arithmetic before the rational fallback) or a proof that the case is +unreachable for valid inputs with non-self-noding predicates. + +> **Amendment (2026-06-11, Task 21).** The "only relevant for +> self-intersecting/invalid input or `require_self_noding`" scoping above is +> wrong. JTS `RelateNGTest.testPolygonLineCrossingContained` disproves it: a +> *valid* MultiPolygon A against a line B, through the ordinary mutual-edges +> path, requires a B-line's *proper crossing* of one A polygon to merge with +> its *vertex touch* of the other A polygon. JTS performs this merge +> implicitly and mode-independently: its nodeMap is keyed by the constructed +> floating-point intersection Coordinate, so any keys that round to the same +> FP point land in one node — in every mode, not only under self-noding. +> Consequently the coincidence-merge pass is **unconditional** — it runs +> whenever any crossing key exists — and it covers **crossing–vertex** +> coincidence, not just crossing–crossing. Implemented +> (`_merge_coincident_nodes!` in `topology_computer.jl`) by hash-bucketing +> all node keys on the deterministically-rounded representative point of +> each key, with coincidence confirmed *exactly* (rational arithmetic) +> inside multi-member buckets. This is strictly more correct than Java in +> both directions: it merges true coincidences Java can miss by an ulp (Java +> compares two independently-rounded constructed coordinates), and it never +> conflates distinct exact points that merely round to the same Float64. + +### D4. Integration: `Algorithm` type, opt-in first + +The engine is exposed as `RelateNG{M<:Manifold} <: Algorithm{M}` (mirroring `GEOS()`, +`TG()`, `FosterHormannClipping`). The new `relate(...)` always uses it. Existing named +predicates gain `intersects(RelateNG(), a, b)` etc. methods while their current +implementations remain the default. The default flips in a later release once the JTS +XML suite passes (**Follow-up F3**) — mirroring JTS's own `-Djts.relate=ng` migration. + +## Public API + +```julia +relate(a, b)::DE9IM # full DE-9IM matrix +relate(a, b, pattern::String)::Bool # pattern match ("T*F**FFF*", with 0/1/2/T/F/*) +relate(alg::RelateNG, a, b, ...) # explicit algorithm form + +RelateNG(; boundary_rule = Mod2Boundary(), accelerator = AutoAccelerator(), exact = True()) +RelateNG(m::Manifold; ...) # Planar() default + +prepared = prepare(RelateNG(), a) # PreparedRelate: cached locators + edge index +relate(prepared, b); intersects(prepared, b); ... + +# Named predicates, opt-in: +intersects(RelateNG(), a, b), within(RelateNG(), a, b), contains, covers, +coveredby, touches, crosses, overlaps, disjoint, equals +``` + +`DE9IM` is an immutable struct wrapping the 9 entries (dimension codes `F/0/1/2` packed +compactly, e.g. 2 bits × 9 in a `UInt32` or `NTuple{9,Int8}`), with `Base.show` printing +the standard string form (`"212101212"`), `matches(im, pattern)`, and indexed access +`im[Interior, Boundary]`. + +Boundary node rules are zero-size structs: `Mod2Boundary` (OGC default), +`EndpointBoundary`, `MultivalentEndpointBoundary`, `MonovalentEndpointBoundary`. + +## Code layout + +``` +src/methods/geom_relations/relateng/ +├── kernel.jl # RelateKernel API definition (the layer contract) +├── kernel_planar.jl # Planar implementation +├── de9im.jl # DE9IM type, Location/Dimension codes, DimensionLocation packing +├── topology_predicate.jl # predicate framework: TopologyPredicate, BasicPredicate, IMPredicate +├── relate_predicates.jl # named predicates + IMPatternMatcher + RelateMatrixPredicate +├── relate_geometry.jl # RelateGeometry: input facade (dims, bounds, points, edges) +├── point_locator.jl # RelatePointLocator, LinearBoundary, AdjacentEdgeLocator +├── node_sections.jl # NodeSection, NodeSections, symbolic NodeId +├── polygon_node_converter.jl # minimal→maximal ring rewriting at nodes +├── relate_node.jl # RelateNode, RelateEdge: edge wheel, label propagation +├── topology_computer.jl # TopologyComputer: IM accumulation, early exit +├── edge_intersector.jl # edge enumeration via SpatialTreeInterface + classification +└── relate_ng.jl # RelateNG algorithm type, evaluation phases, prepare +``` + +One file per ported JTS concept on the topology layer, so each diffs against its Java +counterpart. + +## The `RelateKernel` API (geometry layer) + +Every coordinate-level question the engine may ask. Each function takes the manifold and +GeometryOps' `exact::True/False` flag, and returns **only discrete classifications — +never constructed coordinates**: + +| Function | Returns | Planar implementation | +|---|---|---| +| `orient(m, exact, a, b, c)` | `-1/0/+1` | `Predicates.orient` (AdaptivePredicates); spherical later: sign of scalar triple product, `RobustCrossProduct` robust path | +| `classify_intersection(m, exact, a0, a1, b0, b1)` | symbolic: `disjoint`, `proper_cross`, `touch(endpoint incidences)`, `collinear_overlap(endpoint ordering)` | orientation signs + on-segment tests; replaces `RobustLineIntersector` | +| `point_on_segment(m, exact, p, q0, q1)` | `Bool` (and endpoint/interior distinction) | orient == 0 + range check | +| `point_in_ring(m, exact, p, ring)` | in/on/out | existing Hao–Sun machinery; spherical later: arc-crossing count from a reference exterior point | +| `edge_cmp_around_node(m, exact, node, d1, d2)` | cyclic ordering | JTS `compareAngle` logic, quadrant + orient, but apex is a *symbolic* node | +| `node_id(...)` / `nodes_coincide(...)` | node key / `Bool` | `VertexNode(coord)` keys exactly by coordinate; `CrossingNode(segA, segB)` compares by exact rational intersection parameters (D3 slow path) | +| `interaction_bounds(m, geom)`, `bounds_disjoint`, `bounds_covers` | bounds / `Bool` | `Extents`; spherical later: lon-wrapped extents or caps | + +The topology layer may **only** call these. A kernel-conformance testset is written +against this API and instantiated for `Planar`; the future `Spherical` kernel must pass +the same suite on great-circle analogues. + +## Topology layer (faithful port) + +JTS-class → Julia mapping, with idiom changes: + +- **Predicates as concrete structs.** Each named predicate is a small mutable struct + holding its tri-state value / partial IM. JTS interface methods become functions: + `init_dims!`, `init_bounds!`, `update_dim!`, `finish!`, `is_known`. The declarative + flags (`require_interaction`, `require_covers`, `require_exterior_check`, + `require_self_noding`) are pure functions of the predicate *type*, so the whole + evaluation specializes per predicate and unused checks are dead-code-eliminated — + a performance win unavailable to JVM interface dispatch. +- **`RelateGeometry`**: real dimension (zero-length-line demotion to P), emptiness, + bounds, lazily-built unique point set, line ends, lazily-built `RelatePointLocator`. + GeometryCollection *union semantics* ported exactly (highest dimension wins; + `AdjacentEdgeLocator` resolves boundary-vs-interior for points on multiple polygon + boundaries). +- **`RelatePointLocator`**: location + dimension packed as `DimensionLocation` codes + (`Int8`); `LinearBoundary` valence counting parameterized by boundary rule. +- **`TopologyComputer`**: same entry points (`add_point_on_geometry!`, + `add_line_end!`, `add_area_vertex!`, `add_intersection!`), node-sections dictionary + keyed by symbolic `NodeId`, same exterior-dimension initialization, early-exit checks + after every update. +- **Node analysis ported verbatim in logic**: `NodeSections` grouping → + `PolygonNodeConverter` (minimal→maximal ring rewriting when shell/holes meet at a + node) → `RelateNode`/`RelateEdge` building the CCW edge wheel, merging collinear + edges (area-over-line override), propagating left/right locations around the wheel — + with every geometric comparison routed through the kernel and the angle-ordering apex + being the symbolic node. +- **Evaluation phases** match `RelateNG.evaluate` (RelateNG.java:222–268) exactly: + 1. required-bounds interaction screen (`require_covers` / `require_interaction`) + 2. `init_dims!` — exit if known (incl. empty-geometry handling) + 3. `init_bounds!` — exit if known + 4. point–point fast path when both inputs are puntal + 5. B points located against A; A line-ends/area-vertices located against B; exit + checks throughout + 6. edge phase: mutual edge intersection enumeration + node topology analysis + 7. `finish!` — fill remaining IM entries from dimension defaults; return value + +## Edge enumeration, indexing, prepared mode + +- **No monotone-chain port.** Edge sets from A and B each get an extent tree (STRtree or + `NaturalIndex` behind `SpatialTreeInterface`); the dual depth-first traversal yields + candidate segment pairs, each handed to `classify_intersection`. This mirrors the + clipping `IntersectionAccelerator` pattern (`AutoAccelerator` picks nested-loop below + a size threshold), is user-selectable via a `RelateNG` field, and works unchanged on + the sphere with cap/wrapped extents — avoiding monotone chains' planar x-sortedness. +- **Prepared mode.** `prepare(RelateNG(), a)` builds once: A's edge tree, indexed + point-in-area locators per polygon, unique-point set, `LinearBoundary`. Each + evaluation against a B only indexes B's side (or nested-loops if B is small). + Same caveat as JTS: predicates requiring self-noding bypass parts of the cache. + This should seed GeometryOps' broader prepared-geometry story (**Follow-up F4**). + +## Performance disciplines + +- Points are coordinate tuples; `NodeSection` immutable; node dictionary maps + `NodeId → Vector{NodeSection}`. +- Hot path (per-discovery predicate update) non-allocating: IM packed in `UInt32`, + locations/dimensions as `Int8` codes. +- Whole-evaluation specialization on `{Manifold, predicate type, exact}`; verified with + `@allocated`/JET-adjacent tests. +- Benchmarks under `benchmarks/`: RelateNG vs existing GO predicates vs LibGEOS + (incl. prepared GEOS), across polygon-scaling providers, for both early-exit-friendly + (`intersects`) and full-matrix (`relate`) workloads. + +## Testing and validation + +**Extend the existing harness** at `test/external/jts/jts_testset_reader.jl` (XML.jl + +WellKnownGeometry), which is currently overlay-specific: + +- Parse `expected_result` by operation kind: geometry (overlay, as now), **boolean** + (named predicates), **DE-9IM string** (`relate`, pattern in `arg3`). Read + `` headers. +- Vendor relate-relevant XML files (`TestRelate*.xml`, `TestFunctionPL/LL/LA/AA*.xml`, + boundary-node-rule files; EPL/EDL-licensed) into `test/data/jts/` so CI has no JTS + checkout dependency. Replace the hardcoded path. +- Wire a `relate` dispatcher into the case runner; register in `runtests.jl`. + +Cross-validation layers: + +1. **Vendored JTS XML suite** = planar ground truth. Intentional divergences go on an + explicit, documented skip-list — never silent. +2. Existing 63-pair predicate suite (`test/methods/geom_relations.jl`) run through + `RelateNG()` via `@testset_implementations`; must agree with current implementations + and LibGEOS (GEOS ≥ 3.13 itself runs RelateNG — an independent check). +3. **Differential fuzzing** vs LibGEOS: random pairs from `benchmarks/geometry_providers.jl` + plus adversarial near-degenerate cases (shared vertices, collinear edges, + ulp-perturbed crossings), with `exact=True()`. Exact-vs-floating divergences are + triaged and become documented exactness wins, not failures. +4. **Per-file unit tests** ported from JTS JUnit suites (`RelateNGTest`, + `RelatePointLocatorTest`, `PolygonNodeConverterTest`, `LinearBoundaryTest`, …), + written test-first alongside each stage. +5. **Kernel conformance testset** against the abstract kernel API (the spec for the + future spherical kernel). + +## Phasing + +Each stage lands reviewable and green: + +1. **Foundations** — `DE9IM`, location/dimension codes, boundary-rule structs; + predicate framework + all named predicates + pattern matcher, with ported unit + tests. Purely combinatorial. +2. **Kernel** — `RelateKernel` API + `Planar` implementation + conformance testset + (incl. the rational crossing-coincidence slow path). +3. **Point location** — `RelateGeometry`, `RelatePointLocator`, `LinearBoundary`, + `AdjacentEdgeLocator`; XML-harness generalization lands here so point-only predicate + paths validate immediately. +4. **Node topology** — `NodeSection(s)`, `PolygonNodeConverter`, `RelateNode`, + `RelateEdge`, `TopologyComputer`. +5. **Engine** — `RelateNG` phases, edge enumeration via dual tree traversal, prepared + mode; full XML suite + differential fuzzing pass. (GeometryCollection support may + split into its own stage if this grows large.) +6. **Surface & perf** — `relate()` export, predicate methods on `RelateNG()`, literate + docs, benchmarks, allocation checks. + +## Follow-up register (non-blocking) + +- **F1.** Faster exact crossing-node coincidence: interval-arithmetic filter before the + rational fallback, or prove unreachability for valid inputs + non-self-noding + predicates. +- **F2.** `Spherical` kernel: `RobustCrossProduct` orientation, great-circle-arc + classification, cap/wrapped bounds; must pass the kernel conformance suite. +- **F3.** Flip named-predicate defaults to RelateNG once parity is proven; deprecate the + per-pair processors in `geom_relations/`. +- **F4.** Generalize `prepare` into a package-wide prepared-geometry mechanism. +- **F5.** GeometryCollection edge cases beyond the JTS XML suite's coverage (mixed-dim + GCs with overlapping components) — extra fuzzing. +- **F6.** Exact crossing-node location against lineal/GC targets: replace the + correctly-rounded representative point of `_crossing_locate_point` with rational + on-segment / rational point-in-polygon tests over the target's edges (see the + D2 amendment of 2026-06-11 for the half-ULP failure window this would close). + +## References + +- JTS sources: `jts/modules/core/src/main/java/org/locationtech/jts/operation/relateng/` +- JTS XML tests: `jts/modules/tests/src/test/resources/testxml/` +- Martin Davis, "JTS Topological Relationships — the Next Generation" (lin-ear-th-inking + blog, 2024) and the RelateNG design notes in the JTS repository (`doc/`). +- GEOS 3.13+ `RelateNG` port (used here as an independent differential-testing oracle + via LibGEOS.jl). diff --git a/docs/plans/2026-06-10-relateng-implementation.md b/docs/plans/2026-06-10-relateng-implementation.md new file mode 100644 index 0000000000..4fc2ed08bd --- /dev/null +++ b/docs/plans/2026-06-10-relateng-implementation.md @@ -0,0 +1,1462 @@ +# RelateNG Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement the RelateNG DE-9IM predicate engine in GeometryOps.jl per the validated design in `docs/plans/2026-06-10-relateng-design.md` — exact predicates, no constructed intersection coordinates, manifold-parameterized kernel, Planar first. + +**Architecture:** Two layers. The *topology layer* (predicate framework, IM bookkeeping, point location, node-topology analysis, evaluation phases) is a faithful, file-by-file port of JTS `org.locationtech.jts.operation.relateng` so it stays diffable against the Java. The *geometry layer* is a small `rk_*` kernel API (orientation, symbolic segment-intersection classification, point-in-ring, edge ordering around symbolic nodes, bounds) implemented for `Planar` via AdaptivePredicates; it is the only place coordinates are touched, and it never constructs intersection points. + +**Tech Stack:** Julia; GeometryOpsCore (`Algorithm{M}`, `Manifold`, `True`/`False`/`booltype`); `GO.Predicates` (AdaptivePredicates exact orient); SpatialTreeInterface (`dual_depth_first_search`, STRtree/FlatNoTree); Extents.jl; XML.jl + WellKnownGeometry (test harness); LibGEOS (differential oracle, GEOS ≥ 3.13 runs RelateNG natively). + +--- + +## Conventions for every task (read once, apply always) + +- **JTS source root:** `/Users/anshul/temp/GO_jts/jts/modules/core/src/main/java/org/locationtech/jts/operation/relateng/` (abbreviated `JTS:` below). JTS tests: `/Users/anshul/temp/GO_jts/jts/modules/core/src/test/java/org/locationtech/jts/operation/relateng/`. JTS XML: `/Users/anshul/temp/GO_jts/jts/modules/tests/src/test/resources/testxml/`. +- **Port protocol for "port from Java" steps:** Read the named Java file *in full* first. Port the JUnit tests for it first (RED), then the implementation (GREEN). Keep function-level structure parallel to the Java (same method names in snake_case, same order in file) so the Julia file diffs against its Java counterpart. Where this plan's code conflicts with the Java semantics, the Java wins — note the discrepancy in the commit message. +- **Run command:** `julia --project=docs ` or `julia --project=docs -e '...'` (per AGENTS.md the docs environment has GeometryOps devved with all dependencies). Verify once in Task 0. +- **Commit style (AGENTS.md, overrides generic templates):** imperative, capitalized, no `feat:`-style prefixes, no trailing period, backticks for code identifiers. Trailer: `Co-Authored-By: Claude Fable 5 `. +- **TDD:** every task is test-first. Write the failing test, run it, watch it fail for the right reason, implement, run again, commit. One commit per task minimum; commit more often if a task has natural checkpoints. +- **Naming:** all kernel functions are prefixed `rk_` (RelateKernel). Topology-layer types keep their JTS names (`NodeSection`, `TopologyComputer`, …). Internal constants are prefixed (`LOC_`, `DIM_`, `DL_`, `TRI_`). Nothing internal is exported. +- **Points are coordinate tuples** (`Tuple{Float64,Float64}` typically) obtained via `GO._tuple_point`. The `exact` flag is a keyword taking `True()`/`False()` (GeometryOpsCore BoolsAsTypes), threaded exactly like `Predicates.orient(a, b, c; exact)` in `src/methods/clipping/predicates.jl:13`. +- **File include discipline:** each new `src/.../relateng/*.jl` file gets its `include(...)` line added to `src/GeometryOps.jl` (after line 89, `include("methods/geom_relations/common.jl")`) in the same task that creates the file. Same for test files in `test/methods/relateng/runtests.jl`. + +--- + +## Task 0: Workspace setup and test-command verification + +**Files:** +- Create: `test/methods/relateng/runtests.jl` (empty shell) +- Modify: `test/runtests.jl` + +**Step 1: Create the working branch/worktree** + +The design is committed on branch `relateng-design`. Create the implementation branch from it: + +```bash +cd /Users/anshul/temp/GO_jts/GeometryOps.jl +git checkout relateng-design +git checkout -b relateng +``` + +(If executing via the worktrees skill, create the worktree off `relateng-design` instead.) + +**Step 2: Verify the test run command works** + +```bash +julia --project=docs -e 'import GeometryOps, Test, Extents; println("ok")' +``` + +Expected: `ok`. If GeometryOps is not devved in the docs env, fix with `julia --project=docs -e 'using Pkg; Pkg.develop(path=".")'` and record the working command — it is used by every task below. + +**Step 3: Create the relateng test shell and register it** + +Create `test/methods/relateng/runtests.jl`: + +```julia +using SafeTestsets + +@safetestset "DE9IM" begin include("de9im.jl") end +# Further files appended here as tasks land: +# @safetestset "Predicates" begin include("predicates.jl") end +# @safetestset "Kernel" begin include("kernel.jl") end +# ... +``` + +(Comment lines are uncommented as each test file is created.) + +In `test/runtests.jl`, after the line `@safetestset "DE-9IM Geom Relations" begin include("methods/geom_relations.jl") end`, add: + +```julia +@safetestset "RelateNG" begin include("methods/relateng/runtests.jl") end +``` + +**Step 4: Commit** + +```bash +git add test/methods/relateng/runtests.jl test/runtests.jl +git commit -m "Add RelateNG test scaffolding" +``` + +--- + +# Stage 1 — Foundations (combinatorial, no geometry) + +## Task 1: Location/dimension codes, `DimensionLocation`, `DE9IM` type + +**Files:** +- Create: `src/methods/geom_relations/relateng/de9im.jl` +- Modify: `src/GeometryOps.jl` (add include) +- Test: `test/methods/relateng/de9im.jl` + +**Java references:** `org/locationtech/jts/geom/Location.java`, `Dimension.java`, `IntersectionMatrix.java` (matches semantics), `JTS:DimensionLocation.java` (89 lines, constants verbatim). + +**Step 1: Write the failing tests** — `test/methods/relateng/de9im.jl`: + +```julia +using Test +import GeometryOps as GO +import GeometryOps: DE9IM + +@testset "codes" begin + @test GO.LOC_INTERIOR == 0 && GO.LOC_BOUNDARY == 1 && GO.LOC_EXTERIOR == 2 + @test GO.DIM_FALSE == -1 && GO.DIM_P == 0 && GO.DIM_L == 1 && GO.DIM_A == 2 + @test GO.dim_char(GO.DIM_FALSE) == 'F' + @test GO.dim_code('T') == GO.DIM_TRUE && GO.dim_code('*') == GO.DIM_DONTCARE + @test_throws ArgumentError GO.dim_code('X') +end + +@testset "DimensionLocation" begin + # Constants verbatim from JTS DimensionLocation.java + @test GO.DL_POINT_INTERIOR == 103 + @test GO.DL_LINE_INTERIOR == 110 && GO.DL_LINE_BOUNDARY == 111 + @test GO.DL_AREA_INTERIOR == 120 && GO.DL_AREA_BOUNDARY == 121 + @test GO.dimloc_location(GO.DL_AREA_BOUNDARY) == GO.LOC_BOUNDARY + @test GO.dimloc_dimension(GO.DL_LINE_INTERIOR) == GO.DIM_L + @test GO.dimloc_location(GO.DL_EXTERIOR) == GO.LOC_EXTERIOR + @test GO.dimloc_area(GO.LOC_INTERIOR) == GO.DL_AREA_INTERIOR + @test GO.dimloc_line(GO.LOC_BOUNDARY) == GO.DL_LINE_BOUNDARY + @test GO.dimloc_point(GO.LOC_EXTERIOR) == GO.DL_EXTERIOR +end + +@testset "DE9IM" begin + im = DE9IM("212101212") + @test string(im) == "212101212" + @test im[GO.LOC_INTERIOR, GO.LOC_INTERIOR] == GO.DIM_A + @test im[GO.LOC_BOUNDARY, GO.LOC_BOUNDARY] == GO.DIM_P + @test DE9IM() == DE9IM("FFFFFFFFF") + im2 = GO.with_entry(DE9IM(), GO.LOC_INTERIOR, GO.LOC_BOUNDARY, GO.DIM_L) + @test string(im2) == "F1FFFFFFF" + # pattern matching (JTS IntersectionMatrix.matches semantics) + @test GO.matches(DE9IM("212101212"), "T*F**FFF*") == false + @test GO.matches(DE9IM("2FF1FF212"), "T*F**FFF*") == false + @test GO.matches(DE9IM("2FF1FFFF2"), "T*F**FFF*") == true + @test GO.matches(DE9IM("0FFFFFFF2"), "0FFFFFFF2") == true + @test_throws ArgumentError DE9IM("212") # wrong length + @test_throws ArgumentError GO.matches(DE9IM(), "T*F**FF") # wrong length +end +``` + +**Step 2: Run to verify failure** + +```bash +julia --project=docs test/methods/relateng/de9im.jl +``` + +Expected: FAIL / error — `LOC_INTERIOR not defined`. + +**Step 3: Implement** — `src/methods/geom_relations/relateng/de9im.jl`: + +```julia +# # DE-9IM matrix, location and dimension codes +#= +Port of code-level concepts from JTS: `Location`, `Dimension`, +`IntersectionMatrix` (pattern matching only), and +`operation/relateng/DimensionLocation.java`. +The `DE9IM` struct is immutable and isbits (`NTuple{9, Int8}`). +=# + +# Locations (JTS Location.java) +const LOC_INTERIOR = Int8(0) +const LOC_BOUNDARY = Int8(1) +const LOC_EXTERIOR = Int8(2) +const LOC_NONE = Int8(-1) + +# Dimensions (JTS Dimension.java) +const DIM_FALSE = Int8(-1) # 'F' +const DIM_TRUE = Int8(-2) # 'T' (patterns only) +const DIM_DONTCARE = Int8(-3) # '*' (patterns only) +const DIM_P = Int8(0) +const DIM_L = Int8(1) +const DIM_A = Int8(2) + +function dim_char(d::Integer) + d == DIM_FALSE && return 'F' + d == DIM_TRUE && return 'T' + d == DIM_DONTCARE && return '*' + (0 <= d <= 2) && return Char('0' + d) + throw(ArgumentError("invalid dimension code $d")) +end + +function dim_code(c::AbstractChar) + c == 'F' && return DIM_FALSE + c == 'T' && return DIM_TRUE + c == '*' && return DIM_DONTCARE + ('0' <= c <= '2') && return Int8(c - '0') + throw(ArgumentError("invalid DE-9IM character '$c'")) +end + +# DimensionLocation codes (JTS DimensionLocation.java, verbatim values) +const DL_EXTERIOR = LOC_EXTERIOR # 2 +const DL_POINT_INTERIOR = Int8(103) +const DL_LINE_INTERIOR = Int8(110) +const DL_LINE_BOUNDARY = Int8(111) +const DL_AREA_INTERIOR = Int8(120) +const DL_AREA_BOUNDARY = Int8(121) + +dimloc_point(loc::Integer) = loc == LOC_INTERIOR ? DL_POINT_INTERIOR : DL_EXTERIOR +function dimloc_line(loc::Integer) + loc == LOC_INTERIOR && return DL_LINE_INTERIOR + loc == LOC_BOUNDARY && return DL_LINE_BOUNDARY + return DL_EXTERIOR +end +function dimloc_area(loc::Integer) + loc == LOC_INTERIOR && return DL_AREA_INTERIOR + loc == LOC_BOUNDARY && return DL_AREA_BOUNDARY + return DL_EXTERIOR +end +dimloc_location(dimloc::Integer) = dimloc < 100 ? Int8(dimloc) : Int8(dimloc % 10) +function dimloc_dimension(dimloc::Integer, exterior_dim::Integer = DIM_FALSE) + dimloc < 100 && return Int8(exterior_dim) + return Int8(dimloc ÷ 10 - 10) +end + +""" + DE9IM + +An immutable DE-9IM intersection matrix. Entries are dimension codes +(`DIM_FALSE`, `DIM_P`, `DIM_L`, `DIM_A`) stored row-major over +(Interior, Boundary, Exterior) of A × B, matching the standard string +form `"212101212"`. Construct from a 9-character string or empty +(all-`F`) via `DE9IM()`. Index with `im[locA, locB]`. +""" +struct DE9IM + entries::NTuple{9, Int8} +end +DE9IM() = DE9IM(ntuple(_ -> DIM_FALSE, 9)) +function DE9IM(s::AbstractString) + length(s) == 9 || throw(ArgumentError("DE-9IM string must have 9 characters, got $(repr(s))")) + return DE9IM(ntuple(i -> dim_code(s[i]), 9)) +end + +@inline im_index(locA::Integer, locB::Integer) = 3 * Int(locA) + Int(locB) + 1 +Base.getindex(im::DE9IM, locA::Integer, locB::Integer) = im.entries[im_index(locA, locB)] +with_entry(im::DE9IM, locA::Integer, locB::Integer, dim::Integer) = + DE9IM(Base.setindex(im.entries, Int8(dim), im_index(locA, locB))) + +Base.string(im::DE9IM) = join(dim_char(d) for d in im.entries) +Base.show(io::IO, im::DE9IM) = print(io, "DE9IM(\"", string(im), "\")") + +"Match a single matrix entry against a pattern code (JTS `IntersectionMatrix.matches`)." +function matches_entry(dim::Int8, pat::Int8) + pat == DIM_DONTCARE && return true + pat == DIM_TRUE && return dim >= 0 + return dim == pat +end + +function matches(im::DE9IM, pattern::AbstractString) + length(pattern) == 9 || throw(ArgumentError("DE-9IM pattern must have 9 characters, got $(repr(pattern))")) + return all(matches_entry(im.entries[i], dim_code(pattern[i])) for i in 1:9) +end +``` + +Add to `src/GeometryOps.jl` after the `common.jl` include: + +```julia +include("methods/geom_relations/relateng/de9im.jl") +``` + +**Step 4: Run tests** — same command. Expected: all PASS. + +**Step 5: Commit** + +```bash +git add src/methods/geom_relations/relateng/de9im.jl src/GeometryOps.jl test/methods/relateng/de9im.jl +git commit -m "Add \`DE9IM\` matrix type and location/dimension codes for RelateNG" +``` + +## Task 2: Boundary node rules + +**Files:** +- Modify: `src/methods/geom_relations/relateng/de9im.jl` (append; small enough not to need its own file) +- Test: `test/methods/relateng/de9im.jl` (append) + +**Java reference:** `org/locationtech/jts/algorithm/BoundaryNodeRule.java`. + +**Step 1: Failing tests** (append to `test/methods/relateng/de9im.jl`): + +```julia +@testset "BoundaryNodeRule" begin + @test GO.is_in_boundary(GO.Mod2Boundary(), 1) == true + @test GO.is_in_boundary(GO.Mod2Boundary(), 2) == false + @test GO.is_in_boundary(GO.Mod2Boundary(), 3) == true + @test GO.is_in_boundary(GO.EndpointBoundary(), 1) == true + @test GO.is_in_boundary(GO.EndpointBoundary(), 2) == true + @test GO.is_in_boundary(GO.MultivalentEndpointBoundary(), 1) == false + @test GO.is_in_boundary(GO.MultivalentEndpointBoundary(), 2) == true + @test GO.is_in_boundary(GO.MonovalentEndpointBoundary(), 1) == true + @test GO.is_in_boundary(GO.MonovalentEndpointBoundary(), 2) == false +end +``` + +**Step 2: Run** — expected FAIL (`Mod2Boundary not defined`). + +**Step 3: Implement** (append to `de9im.jl`): + +```julia +# Boundary node rules (JTS BoundaryNodeRule.java). Zero-size structs. +abstract type BoundaryNodeRule end +"OGC SFS standard rule: a vertex is on the boundary iff an odd number of line ends meet it (Mod-2 rule)." +struct Mod2Boundary <: BoundaryNodeRule end +struct EndpointBoundary <: BoundaryNodeRule end +struct MultivalentEndpointBoundary <: BoundaryNodeRule end +struct MonovalentEndpointBoundary <: BoundaryNodeRule end + +is_in_boundary(::Mod2Boundary, boundary_count::Integer) = isodd(boundary_count) +is_in_boundary(::EndpointBoundary, boundary_count::Integer) = boundary_count > 0 +is_in_boundary(::MultivalentEndpointBoundary, boundary_count::Integer) = boundary_count > 1 +is_in_boundary(::MonovalentEndpointBoundary, boundary_count::Integer) = boundary_count == 1 +``` + +**Step 4: Run** — PASS. + +**Step 5: Commit** — `git commit -m "Add boundary node rule types for RelateNG"` + +## Task 3: Predicate framework (`TopologyPredicate` API, tri-state, `BasicPredicate`, `IMPredicate` core) + +**Files:** +- Create: `src/methods/geom_relations/relateng/topology_predicate.jl` +- Modify: `src/GeometryOps.jl` (include after `de9im.jl`) +- Test: `test/methods/relateng/predicates.jl` (+ register in `test/methods/relateng/runtests.jl`) + +**Java references:** `JTS:TopologyPredicate.java` (166 lines — the full contract, read it first), `JTS:BasicPredicate.java` (108), `JTS:IMPredicate.java` (134). + +**Design mapping (decided, do not re-derive):** Julia has no field inheritance, so JTS's class triangle becomes two kind-parameterized mutable structs. Per-kind behavior (`is_determined`, `value_im`, requirement flags, init overrides) dispatches on the kind singleton; requirement flags are pure functions of the *type*, so evaluation specializes per predicate (design D1 performance note). + +**Step 1: Failing tests** — `test/methods/relateng/predicates.jl`. Exercise the framework through the two `BasicPredicate` kinds (implemented in this task) before any IM kinds exist: + +```julia +using Test +import GeometryOps as GO +import Extents + +@testset "tri-state and intersects predicate" begin + p = GO.pred_intersects() + @test GO.predicate_name(p) == "intersects" + @test !GO.is_known(p) + @test GO.require_self_noding(typeof(p)) == false + @test GO.require_interaction(typeof(p)) == true + @test GO.require_exterior_check(typeof(p), true) == false + # interior/interior intersection determines intersects=true immediately + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == true + # exterior-only updates never determine it; finish! defaults to false + q = GO.pred_intersects() + GO.update_dim!(q, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_L) + @test !GO.is_known(q) + GO.finish!(q) + @test GO.is_known(q) && GO.predicate_value(q) == false + # disjoint envelopes determine intersects=false at init_bounds! + r = GO.pred_intersects() + GO.init_bounds!(r, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(5.0, 6.0), Y=(5.0, 6.0))) + @test GO.is_known(r) && GO.predicate_value(r) == false +end + +@testset "disjoint predicate" begin + p = GO.pred_disjoint() + @test GO.require_interaction(typeof(p)) == false + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == false + q = GO.pred_disjoint() + GO.finish!(q) + @test GO.is_known(q) && GO.predicate_value(q) == true +end +``` + +Register in `test/methods/relateng/runtests.jl` (uncomment/add the `Predicates` line). + +**Step 2: Run** — FAIL (`pred_intersects not defined`). + +**Step 3: Implement** — `src/methods/geom_relations/relateng/topology_predicate.jl`. Port `TopologyPredicate.java` + `BasicPredicate.java` + `IMPredicate.java` faithfully. Framework skeleton (complete — the `intersects`/`disjoint` kinds live here because they are `BasicPredicate`s; all IM kinds come in Task 4): + +```julia +# # Topology predicate framework +#= +Port of JTS `TopologyPredicate`, `BasicPredicate`, `IMPredicate` +(operation/relateng). Each named predicate is a kind singleton +parameterizing one of two mutable state structs, so requirement flags +and determination checks are compile-time-specialized per predicate. +=# + +abstract type TopologyPredicate end + +# Tri-state (BasicPredicate.java) +const TRI_UNKNOWN = Int8(-1) +const TRI_FALSE = Int8(0) +const TRI_TRUE = Int8(1) + +# --- API with JTS defaults (TopologyPredicate.java) --- +require_self_noding(::Type{<:TopologyPredicate}) = true +require_interaction(::Type{<:TopologyPredicate}) = true +require_covers(::Type{<:TopologyPredicate}, is_source_a::Bool) = false +require_exterior_check(::Type{<:TopologyPredicate}, is_source_a::Bool) = true +init_dims!(p::TopologyPredicate, dimA::Integer, dimB::Integer) = nothing +init_bounds!(p::TopologyPredicate, extA, extB) = nothing +# update_dim!(p, locA, locB, dim), finish!(p), is_known(p), predicate_value(p), +# predicate_name(p) are implemented per struct below. + +is_intersection(locA::Integer, locB::Integer) = + locA != LOC_EXTERIOR && locB != LOC_EXTERIOR + +ext_intersects(extA, extB) = Extents.intersects(extA, extB) + +# --- BasicPredicate kinds --- +struct IntersectsPred end +struct DisjointPred end + +mutable struct BasicPredicate{K} <: TopologyPredicate + kind::K + value::Int8 +end +BasicPredicate(kind) = BasicPredicate(kind, TRI_UNKNOWN) + +is_known(p::BasicPredicate) = p.value != TRI_UNKNOWN +predicate_value(p::BasicPredicate) = p.value == TRI_TRUE +set_value!(p, v::Bool) = is_known(p) ? nothing : (p.value = v ? TRI_TRUE : TRI_FALSE; nothing) +set_value_if!(p, v::Bool, cond::Bool) = cond ? set_value!(p, v) : nothing +require!(p, cond::Bool) = cond ? nothing : set_value!(p, false) + +# intersects (RelatePredicate.java intersects()) +pred_intersects() = BasicPredicate(IntersectsPred()) +predicate_name(::BasicPredicate{IntersectsPred}) = "intersects" +require_self_noding(::Type{BasicPredicate{IntersectsPred}}) = false +require_exterior_check(::Type{BasicPredicate{IntersectsPred}}, is_source_a::Bool) = false +init_bounds!(p::BasicPredicate{IntersectsPred}, extA, extB) = + require!(p, ext_intersects(extA, extB)) +update_dim!(p::BasicPredicate{IntersectsPred}, locA, locB, dim) = + set_value_if!(p, true, is_intersection(locA, locB)) +finish!(p::BasicPredicate{IntersectsPred}) = set_value!(p, false) + +# disjoint (RelatePredicate.java disjoint()) +pred_disjoint() = BasicPredicate(DisjointPred()) +predicate_name(::BasicPredicate{DisjointPred}) = "disjoint" +require_self_noding(::Type{BasicPredicate{DisjointPred}}) = false +require_interaction(::Type{BasicPredicate{DisjointPred}}) = false +require_exterior_check(::Type{BasicPredicate{DisjointPred}}, is_source_a::Bool) = false +init_bounds!(p::BasicPredicate{DisjointPred}, extA, extB) = + set_value_if!(p, true, !ext_intersects(extA, extB)) +update_dim!(p::BasicPredicate{DisjointPred}, locA, locB, dim) = + set_value_if!(p, false, is_intersection(locA, locB)) +finish!(p::BasicPredicate{DisjointPred}) = set_value!(p, true) + +# --- IMPredicate core (IMPredicate.java) --- +const DIM_UNKNOWN = DIM_DONTCARE # JTS IMPredicate.DIM_UNKNOWN + +mutable struct IMPredicate{K} <: TopologyPredicate + kind::K + dimA::Int8 + dimB::Int8 + im::DE9IM + value::Int8 +end +IMPredicate(kind) = IMPredicate(kind, DIM_UNKNOWN, DIM_UNKNOWN, DE9IM(), TRI_UNKNOWN) + +is_known(p::IMPredicate) = p.value != TRI_UNKNOWN +predicate_value(p::IMPredicate) = p.value == TRI_TRUE + +function init_dims!(p::IMPredicate, dimA::Integer, dimB::Integer) + p.dimA = dimA; p.dimB = dimB + init_dims_kind!(p) # per-kind hook, default no-op +end +init_dims_kind!(p::IMPredicate) = nothing + +is_dim_changed(p::IMPredicate, locA, locB, dim) = dim > p.im[locA, locB] + +function update_dim!(p::IMPredicate, locA, locB, dim) + if is_dim_changed(p, locA, locB, dim) + p.im = with_entry(p.im, locA, locB, dim) + if is_determined(p) # per-kind + p.value = value_im(p) ? TRI_TRUE : TRI_FALSE + end + end + nothing +end + +function finish!(p::IMPredicate) + is_known(p) && return nothing + p.value = value_im(p) ? TRI_TRUE : TRI_FALSE + nothing +end + +# Shared IM queries (IMPredicate.java helpers) +intersects_exterior_of(p::IMPredicate, is_a::Bool) = is_a ? + (is_intersects_entry(p, LOC_EXTERIOR, LOC_INTERIOR) || is_intersects_entry(p, LOC_EXTERIOR, LOC_BOUNDARY)) : + (is_intersects_entry(p, LOC_INTERIOR, LOC_EXTERIOR) || is_intersects_entry(p, LOC_BOUNDARY, LOC_EXTERIOR)) +is_intersects_entry(p::IMPredicate, locA, locB) = p.im[locA, locB] >= DIM_P +is_known_entry(p::IMPredicate, locA, locB) = p.im[locA, locB] != DIM_FALSE # verify vs Java +is_dimension_entry(p::IMPredicate, locA, locB, dim) = p.im[locA, locB] == dim +get_dimension(p::IMPredicate, locA, locB) = p.im[locA, locB] +``` + +**Port note:** `is_known_entry` semantics must be verified against `IMPredicate.java` (JTS tracks "known" differently from `FALSE` — it initializes entries to `DIM_UNKNOWN`, not `FALSE`; if so, initialize `p.im` entries to `DIM_UNKNOWN` and adjust `finish!` to map unknown→`F`. Follow the Java exactly.) + +**Step 4: Run** — PASS (fix until green). + +**Step 5: Commit** — `Add topology predicate framework for RelateNG` + +## Task 4: Named IM predicates, pattern matcher, matrix predicate + +**Files:** +- Create: `src/methods/geom_relations/relateng/relate_predicates.jl` +- Modify: `src/GeometryOps.jl` +- Test: `test/methods/relateng/predicates.jl` (append) + +**Java references:** `JTS:RelatePredicate.java` (631 lines — contains all 8 IM kinds with their flag overrides, `init` overrides, `isDetermined`, `valueIM`), `JTS:IMPatternMatcher.java` (111), `JTS:IntersectionMatrixPattern.java` (63), `JTS:RelateMatrixPredicate.java` (55). Test source: `RelatePredicateTest.java` (92 lines). + +**Step 1: Port `RelatePredicateTest.java` to Julia** — append to `test/methods/relateng/predicates.jl`. Port every test method; they drive predicates via `update_dim!`/`finish!` sequences and check values. Also add flag-table assertions for each kind: + +| factory | self_noding | interaction | covers(A)/covers(B) | ext_check(A)/ext_check(B) | +|---|---|---|---|---| +| `pred_contains()` | per Java | T | per Java | per Java | +| `pred_within()` | per Java | T | per Java | per Java | +| `pred_covers()` | per Java | T | per Java | per Java | +| `pred_coveredby()` | per Java | T | per Java | per Java | +| `pred_crosses()` | per Java | T | F/F | per Java | +| `pred_equalstopo()` | per Java | T | per Java | per Java | +| `pred_overlaps()` | per Java | T | F/F | per Java | +| `pred_touches()` | per Java | T | F/F | per Java | + +Fill the "per Java" cells while reading `RelatePredicate.java` — write the assertion for the value the Java declares (each inner class's `requireX` overrides). Do not guess. + +Add pattern-matcher tests: + +```julia +@testset "IMPatternMatcher" begin + p = GO.IMPatternMatcher("T*F**FFF*") + @test GO.predicate_name(p) == "IMPattern" + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + @test !GO.is_known(p) + # an entry violating the pattern mask determines false immediately + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_L) # pattern 'F' at I/E + @test GO.is_known(p) && GO.predicate_value(p) == false +end + +@testset "RelateMatrixPredicate" begin + p = GO.RelateMatrixPredicate() + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + GO.finish!(p) + @test !isnothing(GO.result_im(p)) + @test GO.result_im(p)[GO.LOC_INTERIOR, GO.LOC_INTERIOR] == GO.DIM_A +end +``` + +**Step 2: Run** — FAIL. + +**Step 3: Implement** — `relate_predicates.jl`. One kind singleton + `IMPredicate{K}` method set per JTS inner class, in the same order as `RelatePredicate.java`: `ContainsPred`, `WithinPred`, `CoversPred`, `CoveredByPred`, `CrossesPred`, `EqualsTopoPred`, `OverlapsPred`, `TouchesPred`. For each, port: factory function, `predicate_name`, requirement-flag overrides, `init_dims_kind!` (dimension-compatibility early exits, e.g. contains is false if `dimA < dimB`), `init_bounds!` (`require_covers` envelope checks via `ext_covers(extA, extB)` — implement `ext_covers` with explicit interval comparisons if `Extents` lacks a `contains`), `is_determined`, `value_im`. Then port `IMPatternMatcher` (standalone mutable struct holding `pattern::String`, `pattern_matrix::DE9IM`, plus IM state — including its `require_interaction` computed from the pattern) and `RelateMatrixPredicate` (never determined early; `result_im(p)` accessor returns the accumulated `DE9IM`). + +**Step 4: Run** — PASS. + +**Step 5: Commit** — `Add named DE-9IM predicates and pattern matcher for RelateNG` + +--- + +# Stage 2 — The exact geometry kernel + +## Task 5: Kernel API + planar orientation/on-segment/point-in-ring/bounds + +**Files:** +- Create: `src/methods/geom_relations/relateng/kernel.jl` (API contract, docstrings, generic fallbacks) +- Create: `src/methods/geom_relations/relateng/kernel_planar.jl` +- Modify: `src/GeometryOps.jl` (include both, *before* the topology-layer files that will use them) +- Test: `test/methods/relateng/kernel.jl` + +**Step 1: Failing tests** — `test/methods/relateng/kernel.jl`: + +```julia +using Test +import GeometryOps as GO +import GeometryOps: Planar, True, False +import GeoInterface as GI + +const PT = Tuple{Float64, Float64} +m = Planar() + +@testset "rk_orient" begin + @test GO.rk_orient(m, (0.0,0.0), (1.0,0.0), (0.0,1.0); exact = True()) > 0 + @test GO.rk_orient(m, (0.0,0.0), (1.0,0.0), (0.0,-1.0); exact = True()) < 0 + @test GO.rk_orient(m, (0.0,0.0), (1.0,0.0), (2.0,0.0); exact = True()) == 0 + # adversarial near-collinear: exact must get the sign right + a, b = (0.0, 0.0), (1.0, 1.0) + c = (0.5, 0.5 + 1e-17) # above the line by less than eps + @test GO.rk_orient(m, a, b, c; exact = True()) == GO.rk_orient(m, a, b, (0.5, 0.6); exact = True()) +end + +@testset "rk_point_on_segment" begin + @test GO.rk_point_on_segment(m, (0.5,0.5), (0.0,0.0), (1.0,1.0); exact = True()) == true + @test GO.rk_point_on_segment(m, (2.0,2.0), (0.0,0.0), (1.0,1.0); exact = True()) == false # collinear, outside + @test GO.rk_point_on_segment(m, (0.5,0.6), (0.0,0.0), (1.0,1.0); exact = True()) == false + @test GO.rk_point_on_segment(m, (0.0,0.0), (0.0,0.0), (1.0,1.0); exact = True()) == true # endpoint inclusive +end + +@testset "rk_point_in_ring" begin + ring = GI.LinearRing([(0.0,0.0), (10.0,0.0), (10.0,10.0), (0.0,10.0), (0.0,0.0)]) + @test GO.rk_point_in_ring(m, (5.0,5.0), ring; exact = True()) == GO.LOC_INTERIOR + @test GO.rk_point_in_ring(m, (5.0,0.0), ring; exact = True()) == GO.LOC_BOUNDARY + @test GO.rk_point_in_ring(m, (15.0,5.0), ring; exact = True()) == GO.LOC_EXTERIOR +end + +@testset "bounds" begin + pa = GI.Polygon([[(0.0,0.0), (1.0,0.0), (1.0,1.0), (0.0,0.0)]]) + ea = GO.rk_interaction_bounds(m, pa) + @test !GO.rk_bounds_disjoint(ea, ea) + @test GO.rk_bounds_covers(ea, ea) +end +``` + +Register `kernel.jl` testset in `test/methods/relateng/runtests.jl`. + +**Step 2: Run** — FAIL. + +**Step 3: Implement.** `kernel.jl` declares the contract: a docstring block listing every kernel function (this is the spherical-implementation spec) and any manifold-generic helpers. `kernel_planar.jl`: + +```julia +# # Planar RelateKernel +#= +The geometry layer of RelateNG (design doc D1/D2): every coordinate-level +question the topology layer may ask, answered with exact predicates and +no constructed coordinates. Planar implementation; a future Spherical +kernel implements the same functions and must pass the same +conformance testset. +=# + +rk_orient(::Planar, a, b, c; exact) = Predicates.orient(a, b, c; exact) + +# valid only when p is known collinear with (q0, q1) +@inline function _collinear_between(p, q0, q1) + (min(GI.x(q0), GI.x(q1)) <= GI.x(p) <= max(GI.x(q0), GI.x(q1))) && + (min(GI.y(q0), GI.y(q1)) <= GI.y(p) <= max(GI.y(q0), GI.y(q1))) +end + +function rk_point_on_segment(m::Planar, p, q0, q1; exact) + rk_orient(m, q0, q1, p; exact) == 0 || return false + return _collinear_between(p, q0, q1) +end + +function rk_point_in_ring(m::Planar, p, ring; exact) + o = _point_filled_curve_orientation(m, p, ring; exact) + o == point_in && return LOC_INTERIOR + o == point_on && return LOC_BOUNDARY + return LOC_EXTERIOR +end + +rk_interaction_bounds(::Planar, geom) = GI.extent(geom, fallback = true) +rk_bounds_disjoint(extA, extB) = !Extents.intersects(extA, extB) +function rk_bounds_covers(extA, extB) + (extA.X[1] <= extB.X[1] && extB.X[2] <= extA.X[2]) && + (extA.Y[1] <= extB.Y[1] && extB.Y[2] <= extA.Y[2]) +end +``` + +Notes: `_point_filled_curve_orientation` is the existing Hao–Sun machinery at `src/methods/geom_relations/geom_geom_processors.jl:495` (signature `(::Planar, point, curve; in, on, out, exact)`); `GI.extent(geom, fallback=true)` — check actual extent-fetch idiom used elsewhere in GO (e.g. clipping) and match it. + +**Step 4: Run** — PASS. + +**Step 5: Commit** — `Add planar RelateKernel orientation, on-segment, point-in-ring and bounds` + +## Task 6: Symbolic segment-intersection classification + +**Files:** +- Modify: `src/methods/geom_relations/relateng/kernel.jl` (the `SegSegClass` type is manifold-generic), `kernel_planar.jl` +- Test: `test/methods/relateng/kernel.jl` (append) + +This replaces JTS's `RobustLineIntersector`: classification only, no intersection coordinates (design D2). + +**Step 1: Failing tests** (append): + +```julia +@testset "rk_classify_intersection" begin + cl(a0,a1,b0,b1) = GO.rk_classify_intersection(m, a0, a1, b0, b1; exact = True()) + # disjoint + @test cl((0.,0.),(1.,0.),(0.,1.),(1.,1.)).kind == GO.SS_DISJOINT + # proper crossing + r = cl((0.,0.),(2.,2.),(0.,2.),(2.,0.)) + @test r.kind == GO.SS_PROPER + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # touch: b0 on interior of a + r = cl((0.,0.),(2.,0.),(1.,0.),(1.,1.)) + @test r.kind == GO.SS_TOUCH && r.b0_on_a && !r.b1_on_a && !r.a0_on_b && !r.a1_on_b + # touch: shared endpoint + r = cl((0.,0.),(1.,0.),(1.,0.),(1.,1.)) + @test r.kind == GO.SS_TOUCH && r.a1_on_b && r.b0_on_a + # collinear overlap + r = cl((0.,0.),(2.,0.),(1.,0.),(3.,0.)) + @test r.kind == GO.SS_COLLINEAR && r.b0_on_a && r.a1_on_b + # collinear disjoint + @test cl((0.,0.),(1.,0.),(2.,0.),(3.,0.)).kind == GO.SS_DISJOINT + # collinear, touching only at one shared endpoint -> SS_TOUCH + r = cl((0.,0.),(1.,0.),(1.,0.),(2.,0.)) + @test r.kind == GO.SS_TOUCH && r.a1_on_b && r.b0_on_a + # containment: b inside a (collinear) + r = cl((0.,0.),(3.,0.),(1.,0.),(2.,0.)) + @test r.kind == GO.SS_COLLINEAR && r.b0_on_a && r.b1_on_a + # degenerate: zero-length b on a + r = cl((0.,0.),(2.,0.),(1.,0.),(1.,0.)) + @test r.kind == GO.SS_TOUCH && r.b0_on_a && r.b1_on_a +end +``` + +**Step 2: Run** — FAIL. + +**Step 3: Implement.** In `kernel.jl`: + +```julia +# Symbolic segment-pair intersection classification (replaces RobustLineIntersector). +@enum SegSegKind::Int8 SS_DISJOINT SS_PROPER SS_TOUCH SS_COLLINEAR + +""" + SegSegClass + +Combinatorial classification of the intersection of closed segments +(a0,a1) × (b0,b1). `kind` is `SS_PROPER` only for a crossing in both +segments' interiors (the node is *symbolic*: no coordinate exists for +it anywhere in the engine). All vertex incidences are reported via the +`*_on_*` flags, whose coordinates are exact input vertices. +""" +struct SegSegClass + kind::SegSegKind + a0_on_b::Bool + a1_on_b::Bool + b0_on_a::Bool + b1_on_a::Bool +end +``` + +In `kernel_planar.jl`: + +```julia +function rk_classify_intersection(m::Planar, a0, a1, b0, b1; exact) + oa0 = rk_orient(m, b0, b1, a0; exact) + oa1 = rk_orient(m, b0, b1, a1; exact) + ob0 = rk_orient(m, a0, a1, b0; exact) + ob1 = rk_orient(m, a0, a1, b1; exact) + # fully collinear configuration (handles zero-length segments too) + if oa0 == 0 && oa1 == 0 && ob0 == 0 && ob1 == 0 + a0_on_b = _collinear_between(a0, b0, b1) + a1_on_b = _collinear_between(a1, b0, b1) + b0_on_a = _collinear_between(b0, a0, a1) + b1_on_a = _collinear_between(b1, a0, a1) + n_inc = a0_on_b + a1_on_b + b0_on_a + b1_on_a + n_inc == 0 && return SegSegClass(SS_DISJOINT, false, false, false, false) + # single shared endpoint counts twice (one endpoint of each on the other) + shared_endpoint_only = n_inc == 2 && + ((a0_on_b || a1_on_b) && (b0_on_a || b1_on_a)) && + (_equals2(a0, b0) || _equals2(a0, b1) || _equals2(a1, b0) || _equals2(a1, b1)) + kind = shared_endpoint_only ? SS_TOUCH : SS_COLLINEAR + # zero-length degenerate: a point on a segment is a touch, not an overlap + if _equals2(a0, a1) || _equals2(b0, b1) + kind = SS_TOUCH + end + return SegSegClass(kind, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + a0_on_b = oa0 == 0 && _collinear_between(a0, b0, b1) + a1_on_b = oa1 == 0 && _collinear_between(a1, b0, b1) + b0_on_a = ob0 == 0 && _collinear_between(b0, a0, a1) + b1_on_a = ob1 == 0 && _collinear_between(b1, a0, a1) + if a0_on_b || a1_on_b || b0_on_a || b1_on_a + return SegSegClass(SS_TOUCH, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + if (oa0 > 0) != (oa1 > 0) && oa0 != 0 && oa1 != 0 && + (ob0 > 0) != (ob1 > 0) && ob0 != 0 && ob1 != 0 + return SegSegClass(SS_PROPER, false, false, false, false) + end + return SegSegClass(SS_DISJOINT, false, false, false, false) +end + +_equals2(p, q) = GI.x(p) == GI.x(q) && GI.y(p) == GI.y(q) +``` + +**Step 4: Run** — PASS. Add any edge case that failed during implementation as a permanent test. + +**Step 5: Commit** — `Add exact symbolic segment intersection classification to RelateKernel` + +## Task 7: Symbolic node identity and the rational coincidence slow path + +**Files:** +- Modify: `kernel.jl` (NodeKey type), `kernel_planar.jl` (rational path) +- Test: `test/methods/relateng/kernel.jl` (append) + +Design D2/D3: vertex nodes key exactly by coordinate; proper crossings key by canonicalized segment pair; cross-kind coincidence uses exact rational arithmetic and is only invoked on self-noding paths. + +**Step 1: Failing tests** (append): + +```julia +@testset "NodeKey" begin + v = GO.vertex_node((1.0, 2.0)) + v2 = GO.vertex_node((1.0, 2.0)) + @test v == v2 && hash(v) == hash(v2) + c1 = GO.crossing_node((0.,0.), (2.,2.), (0.,2.), (2.,0.)) + c2 = GO.crossing_node((0.,2.), (2.,0.), (2.,2.), (0.,0.)) # same pair, swapped & reversed + @test c1 == c2 && hash(c1) == hash(c2) + @test v != c1 +end + +@testset "exact crossing coincidence (rational slow path)" begin + # X crossing at exactly (1,1); a vertex node placed there must coincide + c = GO.crossing_node((0.,0.), (2.,2.), (0.,2.), (2.,0.)) + @test GO.rk_nodes_coincide(m, c, GO.vertex_node((1.0, 1.0)); exact = True()) == true + @test GO.rk_nodes_coincide(m, c, GO.vertex_node((1.0, 1.0 + eps(1.0))); exact = True()) == false + # two crossings meeting at the same point + c2 = GO.crossing_node((1.,0.), (1.,2.), (0.,1.), (2.,1.)) + @test GO.rk_nodes_coincide(m, c, c2; exact = True()) == true + # crossing point with non-representable rational coordinates + c3 = GO.crossing_node((0.,0.), (3.,1.), (0.,1.), (3.,0.)) # crosses at (1.5, 0.5) + @test GO.rk_nodes_coincide(m, c3, GO.vertex_node((1.5, 0.5)); exact = True()) == true +end +``` + +**Step 2: Run** — FAIL. + +**Step 3: Implement.** In `kernel.jl`: + +```julia +# Symbolic node identity (design D2). One concrete isbits key type for both +# node kinds so Dict{NodeKey{P}, ...} is type-stable. +struct NodeKey{P} + is_crossing::Bool + pt::P # vertex nodes: the coordinate. crossing nodes: canonical a0. + a1::P + b0::P + b1::P +end + +vertex_node(pt) = NodeKey(false, pt, pt, pt, pt) + +"Canonicalize: each segment ordered lexicographically by (x, y); segments ordered by their first point." +function crossing_node(a0, a1, b0, b1) + a0, a1 = _seg_canon(a0, a1) + b0, b1 = _seg_canon(b0, b1) + if (GI.x(b0), GI.y(b0), GI.x(b1), GI.y(b1)) < (GI.x(a0), GI.y(a0), GI.x(a1), GI.y(a1)) + a0, a1, b0, b1 = b0, b1, a0, a1 + end + return NodeKey(true, a0, a1, b0, b1) +end +_seg_canon(p, q) = (GI.x(p), GI.y(p)) <= (GI.x(q), GI.y(q)) ? (p, q) : (q, p) +``` + +(`==`/`hash` come free from the default struct definitions since fields are tuples — add explicit ones only if `P` is not bitstype-comparable.) + +In `kernel_planar.jl`, the rational slow path (Float64 values are exact rationals, so `Rational{BigInt}` arithmetic is exact): + +```julia +"Exact intersection point of two properly crossing segments, as rationals." +function _exact_crossing_point(a0, a1, b0, b1) + R = Rational{BigInt} + ax0, ay0 = R(GI.x(a0)), R(GI.y(a0)); ax1, ay1 = R(GI.x(a1)), R(GI.y(a1)) + bx0, by0 = R(GI.x(b0)), R(GI.y(b0)); bx1, by1 = R(GI.x(b1)), R(GI.y(b1)) + dax, day = ax1 - ax0, ay1 - ay0 + dbx, dby = bx1 - bx0, by1 - by0 + denom = dax * dby - day * dbx # nonzero for a proper crossing + t = ((bx0 - ax0) * dby - (by0 - ay0) * dbx) // denom + return (ax0 + t * dax, ay0 + t * day) +end + +_exact_node_point(k::NodeKey) = k.is_crossing ? + _exact_crossing_point(k.pt, k.a1, k.b0, k.b1) : + (Rational{BigInt}(GI.x(k.pt)), Rational{BigInt}(GI.y(k.pt))) + +function rk_nodes_coincide(::Planar, k1::NodeKey, k2::NodeKey; exact) + k1 == k2 && return true + # Slow path (design D3, follow-up F1): exact rational comparison. + return _exact_node_point(k1) == _exact_node_point(k2) +end +``` + +**Step 4: Run** — PASS. + +**Step 5: Commit** — `Add symbolic node identity with exact rational coincidence slow path` + +## Task 8: Edge ordering around nodes (`PolygonNodeTopology` port with symbolic apex) + +**Files:** +- Modify: `kernel.jl`, `kernel_planar.jl` +- Test: `test/methods/relateng/kernel.jl` (append) + +**Java reference:** `/Users/anshul/temp/GO_jts/jts/modules/core/src/main/java/org/locationtech/jts/algorithm/PolygonNodeTopology.java` — methods `compareAngle`, `isAngleGreater`, `isBetween`, `compareBetween`, `quadrant`, `isCrossing`, `isInteriorSegment`. Read in full. + +**What changes vs Java:** the apex argument becomes a `NodeKey`. For vertex nodes the apex coordinate is exact and the port is direct. For crossing nodes the four incident directions are the segment endpoints themselves, and their CCW cyclic order is derived from orientation signs of the *original endpoints* — never a constructed apex. + +**Step 1: Failing tests** (append): + +```julia +@testset "edge ordering around a vertex node" begin + origin = GO.vertex_node((0.0, 0.0)) + east, north, west, south = (1.,0.), (0.,1.), (-1.,0.), (0.,-1.) + cmp(p, q) = GO.rk_compare_edge_dir(m, origin, p, q; exact = True()) + @test cmp(east, north) < 0 # JTS compareAngle: CCW from positive x-axis + @test cmp(north, west) < 0 + @test cmp(west, south) < 0 + @test cmp(east, east) == 0 + @test cmp(north, east) > 0 + # same quadrant resolved by orientation + @test cmp((2.0, 1.0), (1.0, 2.0)) < 0 +end + +@testset "crossing-node incident edge order" begin + # a: (0,0)->(2,2), b: (0,2)->(2,0); crossing at symbolic (1,1) + dirs = GO.rk_crossing_dirs_ccw(m, (0.,0.), (2.,2.), (0.,2.), (2.,0.); exact = True()) + # CCW order starting from direction toward a1=(2,2): + @test dirs == ((2.,2.), (0.,2.), (0.,0.), (2.,0.)) +end + +@testset "isCrossing / isInteriorSegment" begin + n = (1.0, 1.0) + @test GO.rk_is_crossing(m, GO.vertex_node(n), (0.,0.), (2.,2.), (0.,2.), (2.,0.); exact = True()) + @test !GO.rk_is_crossing(m, GO.vertex_node(n), (0.,0.), (2.,2.), (2.,0.), (2.,2.); exact = True()) +end +``` + +(Adjust the `compareAngle` sign expectations to whatever the Java actually returns — write the test by reading `PolygonNodeTopology.compareAngle`'s contract first, then assert that.) + +**Step 2: Run** — FAIL. + +**Step 3: Implement.** + +- `rk_quadrant(m, origin_pt, p)` — port `quadrant` (uses coordinate comparisons against the origin point). +- `rk_compare_edge_dir(m, node::NodeKey, p, q; exact)` — for `!node.is_crossing`, port `compareAngle(origin, p, q)` verbatim with `origin = node.pt`, routing the orientation test through `rk_orient`. +- For crossing nodes, `rk_compare_edge_dir` is only ever needed among the 4 incident endpoints; implement via `rk_crossing_dirs_ccw`: + +```julia +""" +CCW cyclic order of the four half-edge directions incident to the +proper crossing of (a0,a1) × (b0,b1), starting from a1. Since the +crossing is proper, b0/b1 are strictly on opposite sides of line(a0,a1): +if b1 is to the left, CCW order is (a1, b1, a0, b0), else (a1, b0, a0, b1). +""" +function rk_crossing_dirs_ccw(m::Planar, a0, a1, b0, b1; exact) + if rk_orient(m, a0, a1, b1; exact) > 0 + return (a1, b1, a0, b0) + else + return (a1, b0, a0, b1) + end +end +``` + +- `rk_is_crossing(m, node, a0, a1, b0, b1; exact)` — port `PolygonNodeTopology.isCrossing` (apex = `node` vertex; used by `TopologyComputer.updateAreaAreaCross`). +- `rk_is_interior_segment(m, node, a0, a1, b; exact)` — port `isInteriorSegment`. + +**Step 4: Run** — PASS. + +**Step 5: Commit** — `Add edge ordering around symbolic nodes to RelateKernel` + +## Task 9: Kernel conformance testset + +**Files:** +- Create: `test/methods/relateng/kernel_conformance.jl` +- Test registration: `test/methods/relateng/runtests.jl` + +This is the spec the future `Spherical` kernel must pass (design layer contract). Structure it as a function over a manifold: + +**Step 1: Write the testset** — a `function kernel_conformance_suite(m; exact)` containing property-style checks, instantiated for `Planar()`: + +- `rk_orient` antisymmetry (`orient(a,b,c) == -orient(b,a,c)`), cyclic invariance, degeneracy (`orient(a,a,b) == 0`). +- `rk_classify_intersection` symmetry: swapping A and B swaps the flag pairs; reversing a segment's endpoints swaps its two flags; classification of shared-endpoint configurations is `SS_TOUCH`. +- Consistency: `SS_PROPER` implies all four incidence flags false and all four orientations nonzero; every flagged endpoint passes `rk_point_on_segment`. +- `rk_compare_edge_dir` is a strict weak order on a fan of 16 directions around a vertex node. +- `rk_nodes_coincide` is reflexive/symmetric and agrees with `==` on equal keys. +- `rk_point_in_ring` agrees with `rk_point_on_segment` for points on ring edges. + +Use randomized inputs from a fixed-seed RNG (`StableRNGs` if already a test dep, else `Random.seed!`) plus the explicit corner cases above. + +**Step 2: Run, fix any kernel bugs it finds, re-run** — PASS. + +**Step 3: Commit** — `Add RelateKernel conformance testset` + +--- + +# Stage 3 — Point location + +## Task 10: `LinearBoundary` + +**Files:** +- Create: `src/methods/geom_relations/relateng/point_locator.jl` (holds LinearBoundary + AdjacentEdgeLocator + RelatePointLocator, in that order — they are small and tightly coupled; JTS file boundaries preserved as clearly-marked sections) +- Modify: `src/GeometryOps.jl` +- Test: `test/methods/relateng/point_locator.jl` + +**Java references:** `JTS:LinearBoundary.java` (83 lines); tests from `LinearBoundaryTest.java` (97 lines — port every method; cases cover Mod2 vs Endpoint rules on lines/multilines). + +**Step 1: Port `LinearBoundaryTest.java`** to `test/methods/relateng/point_locator.jl`, using `GO.tuples` on WKT strings via the existing test idiom (parse with `jts_wkt_to_geom`-style helper or construct GI geometries directly to avoid the dependency — prefer direct GI construction for unit tests). + +**Step 2: Run** — FAIL. + +**Step 3: Implement:** + +```julia +struct LinearBoundary{BR <: BoundaryNodeRule, P} + vertex_degree::Dict{P, Int} + has_boundary::Bool + rule::BR +end +``` + +Constructor takes an iterable of linestrings (each a coordinate vector) + rule; port `computeBoundaryPoints` (count endpoint degree per coordinate; closed lines contribute nothing per JTS — verify in Java), `hasBoundary`, `isBoundary(lb, pt)`. + +**Step 4: Run** — PASS. **Step 5: Commit** — `Add LinearBoundary for RelateNG point location` + +## Task 11: `AdjacentEdgeLocator` + +**Files:** same source file; test file appended. + +**Java references:** `JTS:AdjacentEdgeLocator.java` (117); `AdjacentEdgeLocatorTest.java` (85 — port all). + +Depends on `NodeSection`/`NodeSections` only lightly in Java (it builds sections to test adjacency); port the dependency-minimal version: the Java's `addSections`/`createSection` usage can be expressed with a local struct or by moving `NodeSection` forward — **decision: create `node_sections.jl` with the plain `NodeSection` struct in this task** (fields only, no `NodeSections` logic yet), include it before `point_locator.jl`. Port `locate(ael, p)` returning `LOC_BOUNDARY`/`LOC_INTERIOR` with the section-based exterior-edge check, routing ring orientation through existing GO ring-orientation utilities and all geometry through the kernel. + +TDD steps as usual. **Commit** — `Add AdjacentEdgeLocator for polygon union semantics` + +## Task 12: `RelatePointLocator` + +**Files:** same source file; test appended. + +**Java references:** `JTS:RelatePointLocator.java` (347); `RelatePointLocatorTest.java` (101 — port all; it exercises mixed GeometryCollections). + +Port: constructor extracts points/lines/polygons from the (possibly GC) input via `apply`/`GI.getgeom` traversal mirroring `extractElements`; methods `has_boundary`, `locate(p)`, `locate_with_dim(p)`, `locate_line_end_with_dim(p)`, `locate_node(p, parent_polygonal)`, `locate_node_with_dim`, and the private precedence chain `compute_dim_location` → `locate_on_points/lines/line/polygons/polygonal` returning `DL_*` codes. Point-on-line checks use `rk_point_on_segment` looped over segments; polygon location uses `rk_point_in_ring` over shell/holes (exterior ring then holes, standard evenodd composition — port `locateOnPolygonal`'s use of JTS `PolygonUtil`/locators as a loop over rings). `isNode` handling and the `AdjacentEdgeLocator` delegation ported exactly. + +TDD steps as usual. **Commit** — `Add RelatePointLocator` + +## Task 13: `RelateGeometry` + +**Files:** +- Create: `src/methods/geom_relations/relateng/relate_geometry.jl` +- Modify: `src/GeometryOps.jl` +- Test: `test/methods/relateng/relate_geometry.jl` + +**Java references:** `JTS:RelateGeometry.java` (420); `RelateGeometryTest.java` (72 — port all). Also `JTS:RelateSegmentString.java` (158) — port here or in Stage 5 Task 18; **decision: port `RelateSegmentString` in this task** since `extract_segment_strings` is `RelateGeometry` API. + +Struct sketch: + +```julia +mutable struct RelateGeometry{G, M, E, BR} + geom::G + manifold::M + exact::E + boundary_rule::BR + is_prepared::Bool + extent::Any # concrete Extents.Extent in practice — type it + dim::Int8 + has_points::Bool; has_lines::Bool; has_areas::Bool + is_line_zero_len::Bool + is_geom_empty::Bool + # lazy caches + unique_points::Union{Nothing, Set{...}} + locator::Union{Nothing, RelatePointLocator{...}} +end +``` + +Port: `analyzeDimensions` (GI-trait traversal), `isZeroLength` (per-line all-segments-zero check using coordinate equality — exact), `getDimensionReal`, `hasDimension`, `hasAreaAndLine`, `hasEdges`, `hasBoundary`, `isPolygonal`, `isSelfNodingRequired` (trait-based: anything that can self-cross — port the Java condition), `getUniquePoints`, `getEffectivePoints`, `extract_segment_strings(rg, is_a, ext_filter)` building `RelateSegmentString`s (one per line / per ring, with `is_a`, `dim`, element id, ring id, parent polygonal ref, coordinate vector), `locate_*` delegations to the lazy locator. + +`RelateSegmentString` port includes `prev_vertex`/`next_vertex`/ring wraparound and `create_node_section` — but with the symbolic twist: `create_node_section(ss, seg_index, node::NodeKey, ...)` takes a `NodeKey` instead of a constructed `Coordinate`. For `SS_TOUCH`/vertex incidences the key is `vertex_node(pt)`; for `SS_PROPER` it is the `crossing_node(...)` of the two segments. + +TDD steps as usual. **Commit** — `Add RelateGeometry input facade and segment strings` + +## Task 14: Generalize the JTS XML harness and vendor relate test files + +**Files:** +- Modify: `test/external/jts/jts_testset_reader.jl` +- Create: `test/external/jts/relate_runner.jl`, `test/data/jts/general/` + `test/data/jts/validate/` (vendored XML), `test/data/jts/LICENSE-NOTICE.md` +- Test: `test/methods/relateng/xml_harness.jl` (parser smoke test; the full runner activates in Stage 5) + +**Step 1: Vendor the XML files** + +```bash +mkdir -p test/data/jts/general test/data/jts/validate +cp /Users/anshul/temp/GO_jts/jts/modules/tests/src/test/resources/testxml/general/TestRelate{PP,PL,PA,LL,LA,AA}.xml test/data/jts/general/ +cp /Users/anshul/temp/GO_jts/jts/modules/tests/src/test/resources/testxml/general/TestBoundary.xml test/data/jts/general/ +cp /Users/anshul/temp/GO_jts/jts/modules/tests/src/test/resources/testxml/validate/TestRelate*.xml test/data/jts/validate/ +cp /Users/anshul/temp/GO_jts/jts/modules/tests/src/test/resources/testxml/misc/TestRelate{Empty,GC}.xml test/data/jts/general/ 2>/dev/null || true +cp /Users/anshul/temp/GO_jts/jts/modules/tests/src/test/resources/testxml/robust/TestRobustRelate*.xml test/data/jts/validate/ 2>/dev/null || true +``` + +(Adjust to actual filenames; the misc/robust names were reported without extensions verified — `ls` first.) Add `LICENSE-NOTICE.md` stating the files are from JTS (EPL/EDL dual license) with the upstream URL and commit. + +**Step 2: Failing parser test** — `test/methods/relateng/xml_harness.jl`: + +```julia +using Test +include(joinpath(@__DIR__, "..", "..", "external", "jts", "jts_testset_reader.jl")) + +@testset "relate XML parsing" begin + cases = load_test_cases(joinpath(@__DIR__, "..", "..", "data", "jts", "general", "TestRelatePP.xml")) + @test !isempty(cases) + item = first(first(cases).items) + @test item.operation == "relate" + @test item.expected_result isa Bool # boolean ops parse as Bool now + @test item.pattern isa String && length(item.pattern) == 9 +end +``` + +**Step 3: Run** — FAIL (current reader parses every expected result as geometry, `test/external/jts/jts_testset_reader.jl:65`, and `TestItem` has no `pattern` field). + +**Step 4: Generalize the reader** (`jts_testset_reader.jl`): + +- Add `pattern::Union{Nothing, String}` to `TestItem`; parse by op kind: + +```julia +const BOOLEAN_OPS = Set(["relate", "intersects", "disjoint", "contains", "within", + "covers", "coveredby", "crosses", "touches", "overlaps", "equalstopo", "equals"]) + +function parse_expected(operation, raw::String) + lowercase(operation) in BOOLEAN_OPS && return parse(Bool, lowercase(strip(raw))) + return jts_wkt_to_geom(raw) +end +``` + +- In `parse_case`, read `arg3` from `op_attrs` when present (`get(op_attrs, "arg3", nothing)`) as the relate pattern; handle ops whose `arg1` isn't `"A"`/`"B"` by skipping with a counter. +- Delete the hardcoded `testfile` path and the executable overlay loop at lines 73–123 — move that overlay-running code to `test/external/jts/overlay_runner.jl` unchanged (it is not currently wired into CI, so this is a pure file move; do not silently delete it). +- Read `` from the run header into a `Run` metadata field (used later to skip FIXED-precision cases). + +**Step 5: Create `test/external/jts/relate_runner.jl`** — the case runner used in Stage 5, parameterized so it can be smoke-tested now: + +```julia +""" + run_relate_cases(relate_fn, pattern_fn, predicate_fns, files; skiplist) + +For each XML case: `relate_fn(a, b)::DE9IM`, `pattern_fn(a, b, pattern)::Bool`, +`predicate_fns[opname](a, b)::Bool`. Cases whose (file, description, op) is in +`skiplist` are recorded as skipped, never silently dropped. +""" +``` + +Iterates cases, `@test`s expected values, collects a summary. Skiplist lives at `test/external/jts/relate_skiplist.jl` as a `Set{Tuple{String,String,String}}` with a mandatory comment per entry explaining the divergence. + +**Step 6: Run parser test** — PASS. Register `xml_harness.jl` in the relateng test runner. + +**Step 7: Commit** — `Generalize JTS XML test reader for relate operations and vendor test files` + +--- + +# Stage 4 — Node topology + +## Task 15: `NodeSection` + `NodeSections` + +**Files:** +- Modify/Create: `src/methods/geom_relations/relateng/node_sections.jl` (struct exists since Task 11; add full API + `NodeSections`) +- Test: `test/methods/relateng/node_topology.jl` + +**Java references:** `JTS:NodeSection.java` (201), `JTS:NodeSections.java` (122). No dedicated JUnit file — write unit tests for: `EdgeAngleComparator` ordering (via `rk_compare_edge_dir`), `isProper`/`isNodeAtVertex`, `NodeSections.prepareSections` ordering invariant, and `createNode` on a simple two-area touch (assert resulting edge count; full label assertions come with Task 17). + +`NodeSection` final form (replacing the Task-11 minimal version): + +```julia +struct NodeSection{P, G} + is_a::Bool + dim::Int8 + id::Int32 + ring_id::Int32 + polygonal::G # parent polygonal geometry or `nothing` + is_node_at_vertex::Bool + v0::Union{P, Nothing} # vertex before node (nothing at line start) + node::NodeKey{P} # symbolic — JTS stores a Coordinate here + v1::Union{P, Nothing} +end +``` + +`NodeSections` is `mutable struct` holding `node::NodeKey{P}` and `sections::Vector{NodeSection}`; port `addNodeSection`, `hasInteractionAB`, `getPolygonal(is_a)`, `createNode` (sort + `PolygonNodeConverter` delegation — stub the converter call until Task 16; in the meantime the test covers only single-polygon nodes), `prepareSections` (the comparator chain: lines before areas, grouped by polygon). + +TDD steps as usual. **Commit** — `Add NodeSection and NodeSections with symbolic node keys` + +## Task 16: `PolygonNodeConverter` + +**Files:** +- Create: `src/methods/geom_relations/relateng/polygon_node_converter.jl` +- Test: `test/methods/relateng/node_topology.jl` (append) + +**Java references:** `JTS:PolygonNodeConverter.java` (148); **`PolygonNodeConverterTest.java` (154 — port every test method first**; they construct section lists and assert converted output, which ports directly). + +Port `convert`, `extractUnique`, `findShell`, `convertHoles`, `convertShellAndHoles`, `createSection` exactly; geometric comparisons go through the kernel comparator. Wire the real call into `NodeSections.createNode` (remove Task-15 stub) and re-run Task 15's tests. + +TDD steps as usual. **Commit** — `Add PolygonNodeConverter for shell-hole node rewriting` + +## Task 17: `RelateEdge` + `RelateNode` + +**Files:** +- Create: `src/methods/geom_relations/relateng/relate_node.jl` +- Test: `test/methods/relateng/node_topology.jl` (append) + +**Java references:** `JTS:RelateEdge.java` (362), `JTS:RelateNode.java` (230). No dedicated JUnit tests — write unit tests asserting full edge-wheel state for hand-built configurations (each verified by hand against the Java semantics before writing): + +1. Two crossing lines at a vertex node → 4 edges, all dims L, no area labels. +2. Area corner (two edges of one polygon) → 2 edges with interior on the correct side (left of forward edge for CCW shell). +3. Area corner of A + line end of B at the same node → line edge labeled interior/boundary w.r.t. A correctly. +4. Two area corners (A and B) overlapping → collinear-edge merge case: coincident edges merged, area-over-line dim override. + +`RelateEdge`: + +```julia +mutable struct RelateEdge{P} + node::NodeKey{P} + dir_pt::P + # per-geometry: dimension and left/right/on locations + a_dim::Int8; a_loc_left::Int8; a_loc_right::Int8; a_loc_line::Int8 + b_dim::Int8; b_loc_left::Int8; b_loc_right::Int8; b_loc_line::Int8 +end +``` + +Port the factory `relate_edge(node, dir_pt, is_a, dim, is_forward)`, `compare_to_edge` (via `rk_compare_edge_dir` with the symbolic node as apex), `merge!`, `set_area_interior!`, `is_known`, `is_interior`, `set_location!`, `get_location`, plus statics `find_known_edge_index` and `set_all_area_interior!`. `RelateNode` ports `add_edges!` (both arities), `add_line_edge`/`add_area_edge`/`add_edge` insertion-or-merge into the sorted wheel, and the `update_edges_in_area`/`update_if_area_prev/next` label propagation with circular `next_index`/`prev_index`. + +**Additional step (added after Task 11 review):** Task 11's `AdjacentEdgeLocator` shipped with a private sequential slice of the node-wheel pipeline (`_AelEdge`, `_create_node_edges`, etc. in `point_locator.jl`, marked `TODO(Task 17)`). Once the real `NodeSections`/`RelateNode` exist, rewire `locate(::AdjacentEdgeLocator, p)` onto them (build `NodeSections`, push sections, `create_node`, `has_exterior_edge(node, true)`) and delete the slice. The ported AdjacentEdgeLocatorTest cases are the regression gate. + +TDD steps as usual. **Commit** — `Add RelateNode and RelateEdge wheel with label propagation` + +## Task 18: `TopologyComputer` + +**Files:** +- Create: `src/methods/geom_relations/relateng/topology_computer.jl` +- Test: `test/methods/relateng/topology_computer.jl` + +**Java reference:** `JTS:TopologyComputer.java` (520 — the heart of the topology layer; read fully, port in Java method order). No dedicated JUnit file; unit-test through the public entry points with a `RelateMatrixPredicate` attached and assert resulting IM strings: + +```julia +# Example shape (values verified by hand/LibGEOS before writing): +@testset "addPointOnGeometry P vs A" begin + # point in area interior: II entry must become 0... etc. +end +``` + +Cover: `initExteriorDims` for all dim pairs (P/P, P/L, P/A, L/L, L/A, A/A — assert the a-priori exterior entries match `TopologyComputer.java:44-102`), empty-geometry init, `add_point_on_point_interior!`/`_exterior!`, `add_point_on_geometry!`, `add_line_end_on_geometry!`, `add_area_vertex!`, `add_intersection!` + `evaluate_nodes!` (node-section grouping in `Dict{NodeKey{P}, NodeSections}`), `updateAreaAreaCross` via `rk_is_crossing`, short-circuit (`is_result_known` true → entry points become no-ops). + +The struct: + +```julia +mutable struct TopologyComputer{TP <: TopologyPredicate, RA, RB, P} + predicate::TP + geom_a::RA + geom_b::RB + node_sections::Dict{NodeKey{P}, NodeSectionsCollector} # name per Task 15 +end +``` + +Where the predicate's `require_self_noding(typeof(predicate))` is true **and** the geometry's `isSelfNodingRequired`, run the D3 coincidence-merge pass before `evaluate_nodes!`: group keys via `rk_nodes_coincide` (O(k²) over crossing keys — acceptable slow path; reference follow-up F1 in a comment). + +TDD steps as usual. **Commit** — `Add TopologyComputer with symbolic node grouping` + +--- + +# Stage 5 — The engine + +## Task 19: Edge intersection (`EdgeSegmentIntersector` semantics over `SegSegClass`) + +**Files:** +- Create: `src/methods/geom_relations/relateng/edge_intersector.jl` +- Test: `test/methods/relateng/edge_intersector.jl` + +**Java references:** `JTS:EdgeSegmentIntersector.java` (89), the canonicality logic in `JTS:RelateSegmentString.java#isContainingSegment`. + +Port `add_intersections!(computer, ssA, iA, ssB, iB; m, exact)`: + +1. `rk_classify_intersection` on the two segments. +2. `SS_DISJOINT` → return. +3. `SS_PROPER` → one `crossing_node` key; `create_node_section` on both strings; `add_intersection!(computer, nsA, nsB)`. +4. `SS_TOUCH`/`SS_COLLINEAR` → for each incident vertex, create vertex-node sections. **Once-only rule:** JTS ensures a vertex shared by two adjacent segments of the same string is processed once via `isContainingSegment` (start-inclusive, end-exclusive containment). Our equivalent: attribute a vertex incidence to a segment only if the vertex is *not* that segment's end vertex (i.e. `a1_on_b` where the touch point equals `a1` is attributed to the *next* segment's `a0` — except for the final segment of an open line). Port the Java rule exactly; encode it as a predicate `_is_canonical_incidence(ss, seg_index, which_endpoint)` with its own unit tests for: mid-string vertex (two segments), string start, string end, ring wraparound. + +Unit tests: hand-built two-string configurations asserting the exact set of `(NodeKey, section count)` recorded by a mock/`RelateMatrixPredicate`-backed computer — crossing, T-touch, shared endpoint, collinear overlap, adjacent-segment shared vertex (must produce sections once, not twice). + +**Commit** — `Add edge segment intersector over symbolic classification` + +## Task 20: Edge set enumeration via `SpatialTreeInterface` + +**Files:** +- Modify: `edge_intersector.jl` +- Test: `test/methods/relateng/edge_intersector.jl` (append) + +Replaces `JTS:EdgeSetIntersector.java` (HPRtree + monotone chains). + +Implement `process_edge_intersections!(computer, ssa_list, ssb_list, accelerator; m, exact)`: + +- Build per-segment extent lists for each side (`Extents.Extent` per segment). +- Accelerator selection mirrors the clipping pattern (`src/methods/clipping/clipping_processor.jl:8-62`): `NestedLoop` below a size threshold or on non-planar manifolds; STRtree-backed otherwise; `AutoAccelerator` picks by total segment count (use the same threshold constant as clipping; read it from the clipping source rather than inventing a new one). +- Tree path: `STRtree` over segment extents per side, then `dual_depth_first_search((i, j) -> ..., Extents.intersects, treeA, treeB)` (`src/utils/SpatialTreeInterface/dual_depth_first_search.jl:25`) mapping flat segment indices back to `(segment_string, seg_index)` pairs via an offset table. +- **Early exit:** after each pair, check `is_result_known(computer)`; since `dual_depth_first_search` has no built-in termination, throw a private `struct _RelateDone <: Exception end` from the callback and catch it at the call site. (Check first whether the traversal respects a `Break` return from `LoopStateMachine` — if so use that instead; note which in the commit.) + +Tests: same fixtures as Task 19 run through both `NestedLoop` and the tree path must produce identical computer state; an early-exit test (intersects predicate over two heavily-overlapping rings) asserting the traversal stops (count pairs processed via a counting wrapper). + +**Commit** — `Add accelerated edge set enumeration for RelateNG` + +## Task 21: `RelateNG` algorithm type and evaluation phases + +**Files:** +- Create: `src/methods/geom_relations/relateng/relate_ng.jl` +- Modify: `src/GeometryOps.jl` +- Test: `test/methods/relateng/relate_ng.jl` + +**Java reference:** `JTS:RelateNG.java` (549) — port `evaluate` (lines 224–268) and its helpers `computePP` (305–331), `computeAtPoints` (333–361), `computeLineEnds` (392–457), `computeAreaVertex`-related (459–506), `computeAtEdges` (508–546) in order. + +The algorithm type (mirrors `FosterHormannClipping` at `src/methods/clipping/clipping_processor.jl:51`): + +```julia +struct RelateNG{M <: Manifold, A <: IntersectionAccelerator, E, BR <: BoundaryNodeRule} <: GeometryOpsCore.Algorithm{M} + manifold::M + accelerator::A + exact::E + boundary_rule::BR +end +RelateNG(; manifold::Manifold = Planar(), accelerator = AutoAccelerator(), + exact = True(), boundary_rule = Mod2Boundary()) = + RelateNG(manifold, accelerator, exact, boundary_rule) +RelateNG(m::Manifold; kw...) = RelateNG(; manifold = m, kw...) +GeometryOpsCore.manifold(alg::RelateNG) = alg.manifold +``` + +Entry points: + +```julia +relate(alg::RelateNG, a, b) = ... # RelateMatrixPredicate → DE9IM +relate(alg::RelateNG, a, b, pattern::String) = ... # IMPatternMatcher → Bool +relate(a, b) = relate(RelateNG(), a, b) +relate(a, b, pattern::String) = relate(RelateNG(), a, b, pattern) +relate_predicate(alg::RelateNG, pred::TopologyPredicate, a, b)::Bool # core +``` + +`relate_predicate` is the ported `evaluate`: build `RelateGeometry` for both sides, then phases 1–7 from the design doc (envelope screen using `require_interaction`/`require_covers` flags → `init_dims!` → exit-if-known → `init_bounds!` → exit-if-known → P/P fast path → points-vs-geometry (`computeAtPoints` both directions, line ends, area vertices, honoring `require_exterior_check`) → edge phase (extract segment strings filtered by interaction envelope, Task 20 enumeration, `evaluate_nodes!`) → `finish!` → `predicate_value`). + +**Step 1 (RED): port `RelateNGTest.java` (686 lines).** This is the big one — port every test method into `test/methods/relateng/relate_ng.jl`, using the `RelateNGTestCase.java` helper shape: + +```julia +function check_relate(awkt, bwkt, expected_im::String) + a, b = from_wkt(awkt), from_wkt(bwkt) + @test string(GO.relate(GO.RelateNG(), a, b)) == expected_im +end +function check_predicate(pred_factory, awkt, bwkt, expected::Bool) ... end +``` + +(`from_wkt` = the sanitizing WKT parser from the harness; move `jts_wkt_to_geom` into a shared test util include.) Port the whole file even though it takes a while — it is the primary correctness net for the engine and every case is a one-liner through these helpers. Skip prepared-mode checks until Task 22 (mark `@test_broken` or guard with a flag, then flip in Task 22). + +**Step 2: Run** — FAIL (engine missing). + +**Step 3: Implement** the engine; iterate per phase, running the test file after each helper lands. Debugging discipline: when a case disagrees, diff phase-by-phase against the Java (same WKT through JTS `RelateNG` semantics via LibGEOS `relate` if needed). + +**Step 4: Run** — all ported tests PASS (GC-dependent cases may remain `@test_broken` if GC traversal gaps surface; record them in the task notes, fix before Task 23 declares the XML suite green). + +**Step 5: Commit** — `Add RelateNG engine with phased evaluation` (commit earlier per-phase if the diff grows beyond ~500 lines). + +## Task 22: Prepared mode + +**Files:** +- Modify: `relate_ng.jl` +- Test: `test/methods/relateng/relate_ng.jl` (enable prepared checks) + +```julia +struct PreparedRelate{RG, T, ALG <: RelateNG} + alg::ALG + geom_a::RG # RelateGeometry with locator/unique-points caches forced + edge_tree::T # prebuilt segment tree for A (or nothing below threshold) + segs_a::... # extracted segment strings for A +end +prepare(alg::RelateNG, a) = ... +relate(p::PreparedRelate, b), relate(p::PreparedRelate, b, pattern), relate_predicate(p, pred, b) +``` + +Port the JTS caveat: predicates requiring self-noding bypass the cached interaction-envelope filtering (JTS `RelateNG.java` prepared branch). Tests: every `checkPrepared` case from `RelateNGTest.java` now enabled — prepared results must equal unprepared results across the whole ported suite (add a loop asserting that wholesale); plus a cache-reuse smoke test (two evaluations against one `PreparedRelate`). + +**Commit** — `Add prepared mode for RelateNG` + +## Task 23: Full JTS XML suite green + +**Files:** +- Create: `test/external/jts/relate_skiplist.jl` +- Modify: `test/methods/relateng/xml_harness.jl` (activate the runner over all vendored files) +- Test: the XML suite itself + +**Step 1:** Wire `run_relate_cases` (Task 14) to the real engine: `relate_fn = (a,b) -> GO.relate(GO.RelateNG(), a, b)`, `pattern_fn`, and predicate closures for every `BOOLEAN_OPS` name. Run over `test/data/jts/general/*.xml` first. + +**Step 2:** Triage failures one file at a time (PP → PL → PA → LL → LA → AA → Boundary → Empty/GC), fixing engine bugs. Only after a documented analysis may a case go on the skiplist (legitimate reasons: FIXED precision-model cases; semantics divergence documented in the design doc). Every skiplist entry carries a comment with the case description and reason. + +**Step 3:** Extend to `test/data/jts/validate/*.xml` (the big suites, ~11k lines of cases). Same triage discipline. + +**Step 4:** Register the XML suite in `test/methods/relateng/runtests.jl` (guard the `validate/` set behind `if get(ENV, "GO_TEST_FULL_JTS", "true") == "true"` only if runtime proves prohibitive — measure first; target keeping it always-on). + +**Step 5: Commit** — `Pass JTS relate XML test suites` (intermediate commits per fixed file are encouraged: `Fix found by TestRelateLL.xml`). + +## Task 24: Cross-validation against existing GO predicates and LibGEOS + +**Files:** +- Modify: `test/methods/geom_relations.jl` +- Test: same + +**Step 1:** The existing suite compares `GO_f` vs `LG_f` per pair (`test/methods/geom_relations.jl:188-203`). Extend the comparison tuple: for every existing `@test_implementations` predicate check, add the RelateNG form, e.g.: + +```julia +@test_implementations GO_f($g1, $g2) == GO.relateng_predicate(GO_f, $g1, $g2) +``` + +where a small mapping from the GO function to its `pred_*` factory lives in the test file. (Mechanically: add one line per existing comparison; keep the diff reviewable.) + +**Step 2:** Run; divergences are triaged — a divergence where RelateNG agrees with LibGEOS and old-GO disagrees is a *known-gap win*: record it in a `KNOWN_OLD_GO_GAPS` list asserting the new behavior, don't regress the new engine to match the old. + +**Step 3: Commit** — `Cross-validate RelateNG against existing predicates and LibGEOS` + +## Task 25: Differential fuzzing vs LibGEOS + +**Files:** +- Create: `test/methods/relateng/fuzz.jl` +- Test registration: relateng runner (seeded, bounded case count for CI) + +**Step 1:** Generator: random polygon/line/point pairs reusing `test/data/polygon_generation.jl` and adversarial constructors — shared vertices, collinear edges, ulp-perturbed near-crossings (`nextfloat`/`prevfloat` on crossing configurations), zero-length lines, rings touching at points. + +**Step 2:** Property: `string(GO.relate(GO.RelateNG(), a, b)) == LibGEOS.relate(lg(a), lg(b))` for N seeded cases (N≈500 in CI; overridable via ENV for deep runs). On divergence, print the WKT pair; triage: GEOS-version check (`LibGEOS.GEOS_VERSION >= v"3.13"` asserted up front so the oracle is RelateNG-native), exactness wins (we are right where FP GEOS is wrong — verify by hand with rational arithmetic, then add to a documented `EXACTNESS_WINS` fixture list asserting *our* answer), real bugs (fix). + +**Step 3: Commit** — `Add differential fuzzing of RelateNG against LibGEOS` + +--- + +# Stage 6 — Surface, docs, performance + +## Task 26: Public API surface and named-predicate methods + +**Files:** +- Modify: `relate_ng.jl`, `src/GeometryOps.jl` (exports) +- Test: `test/methods/relateng/relate_ng.jl` (append) + +**Step 1 (RED):** API tests: `GO.relate(a, b) isa GO.DE9IM`; `GO.relate(a, b, "T*F**FFF*") isa Bool`; `GO.intersects(GO.RelateNG(), a, b) == GO.intersects(a, b)` for each of the 10 named predicates (opt-in form, design D4 — existing defaults untouched); `prepare`d equivalents. + +**Step 2:** Implement the named-predicate methods: + +```julia +intersects(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_intersects(), g1, g2) +# ... one line each for disjoint, contains, within, covers, coveredby, +# crosses, overlaps, touches, equals (equals → pred_equalstopo) +``` + +Export `relate`, `DE9IM`, `RelateNG`, `prepare`, `Mod2Boundary` (+ other rules). Check for export collisions (`prepare` vs `prepare_naturally` is fine; verify `relate` is genuinely new — Task 0's survey said yes). + +**Step 3:** Run full relateng test battery + the whole GO test suite (`julia --project=docs -e 'using Pkg; Pkg.test("GeometryOps")'` or the established command) to catch collisions. + +**Step 4: Commit** — `Export \`relate\` and RelateNG predicate methods` + +## Task 27: Literate docs + +**Files:** +- Modify: every `src/methods/geom_relations/relateng/*.jl` (top-of-file literate headers where still missing), `relate_ng.jl` (main docstring + `@example` blocks) + +Follow the house literate convention (`src/methods/geom_relations/within.jl:1-53` as the template): `# # Relate` header, `export` line, `#= ... =#` block with a "What is relate?/DE-9IM" explainer and a small Makie `@example` (two overlapping polygons, show the matrix string), then `## Implementation`. Docstrings for `relate`, `RelateNG`, `DE9IM`, `prepare`, boundary rules. Check whether docs build includes new files automatically (look at `docs/make.jl` page generation) and add pages if manual. + +Run docs build: `julia --project=docs docs/make.jl` — expect no errors (or at minimum no new errors; note preexisting failures). + +**Commit** — `Add literate documentation for RelateNG` + +## Task 28: Benchmarks and allocation discipline + +**Files:** +- Create: `benchmarks/relateng.jl` +- Create: `test/methods/relateng/allocations.jl` + +**Step 1: Benchmarks** (Chairmarks `@b`, matching `benchmarks/` house style): RelateNG vs old GO predicates vs LibGEOS (plain + prepared GEOS) across polygon sizes from the existing providers/`polygon_generation.jl`; two workload shapes: `intersects` (early-exit-friendly) and full `relate` (no exit). Print a comparison table; no CI gating. + +**Step 2: Allocation tests:** after warmup, `@allocated GO.relate_predicate(alg, GO.pred_intersects(), a, b)` for mid-size polygon pairs — assert below a budget that excludes the unavoidable per-call setup (RelateGeometry, dicts) but catches per-segment-pair allocation regressions: establish the measured baseline, assert `<= 2x baseline` with the measured number recorded in a comment. Type-stability spot checks with `@inferred` on `rk_classify_intersection`, `update_dim!`, `relate_predicate`. + +**Step 3:** Profile one large A/A `relate` case; if >20% of time is in the kernel boundary or dict ops, file observations in the follow-up register (F1 territory) — do not optimize speculatively in this task. + +**Step 4: Commit** — `Add RelateNG benchmarks and allocation tests` + +--- + +## Done criteria (whole plan) + +1. All vendored JTS XML relate suites pass (skiplist documented and short). +2. Ported `RelateNGTest` + component JUnit suites pass. +3. Existing 63-pair predicate suite agrees (modulo documented old-GO gaps). +4. Seeded fuzz vs LibGEOS ≥ 3.13 clean (modulo documented exactness wins). +5. Kernel conformance suite green (the Spherical contract). +6. No FP-constructed intersection coordinate anywhere in `src/.../relateng/` (grep for `getIntersection`-style construction; `_exact_crossing_point` rationals are the only intersection-point computation and only inside `rk_nodes_coincide`). +7. Benchmarks recorded; hot path allocation-bounded. + +Out of scope (follow-up register in the design doc): F1 fast coincidence filter, F2 Spherical kernel, F3 default flip + old-processor deprecation, F4 generalized prepared geometry, F5 extra GC fuzzing. diff --git a/docs/plans/2026-06-11-relateng-audit-fixes.md b/docs/plans/2026-06-11-relateng-audit-fixes.md new file mode 100644 index 0000000000..0e9449ae52 --- /dev/null +++ b/docs/plans/2026-06-11-relateng-audit-fixes.md @@ -0,0 +1,808 @@ +# RelateNG Audit Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the findings from the 2026-06-11 audit of `src/methods/geom_relations/relateng/`: one real API-misuse bug (`GI.extent` positional argument), one Base-generic hijack (`Base.merge!`), one kernel-contract fragility (`comp == 1`), a namespace-collision rename sweep, and minor style/documentation items. + +**Architecture:** All changes are local to `src/methods/geom_relations/relateng/` and its test directory `test/methods/relateng/`. No public API changes except: `string(im::DE9IM)` keeps its value but `print(io, im)` changes from the `show` form to the bare matrix string. The renames only touch internal (unexported, underscore-convention) functions; the tests that call them via `GO.` are updated in the same commits. + +**Tech Stack:** Julia, GeometryOps.jl monorepo (flat module), GeoInterface.jl (`GI`), Extents.jl. Tests run with `julia --project=test` from the `GeometryOps.jl/` repo root (per AGENTS.md). Each relateng test file is self-contained and can be `include`d directly. + +**Important context for the executor:** + +- Work from `/Users/anshul/temp/GO_jts/GeometryOps.jl` (the git repo root). The parent `GO_jts/` directory is NOT a git repo. +- If `julia --project=test` complains about missing packages, run `julia --project=test -e 'using Pkg; Pkg.instantiate()'` once first. +- Single test files run in ~seconds to ~2 minutes. The FULL relateng suite (`test/methods/relateng/runtests.jl`) takes ~25 minutes (JTS XML conformance + LibGEOS differential fuzz) — only run it once, at the end, in a background shell. +- Commit style (per AGENTS.md): imperative mood, capitalized, no trailing period, **no** `feat:`/`fix:` prefixes, backticks around code identifiers. +- The relateng port convention: files diff against their JTS Java counterparts; method order parallels the Java files. Do not reorder functions while editing. +- **Plan reconciled to commit `2a8ad4eab`** (2026-06-11): five performance commits landed after this plan was written (extent-cache wrapper tree `_rce`/`_relate_cache_extents`, NaturalIndex edge indexes, box-overlap node clustering, `IndexedPointInAreaLocator` in the new `indexed_point_in_area.jl` + its test file). All line references below were re-verified against that commit. The new `indexed_point_in_area.jl` is already trait-dispatch style and adds no rename targets (its `locate` method joins the existing keep-list `locate` generic). + +--- + +### Task 1: Fix `GI.extent(c, true)` silently returning `nothing` + +**The bug:** `GeoInterface.extent`'s `fallback` parameter is a *keyword* (`extent(obj; fallback=true)`). Passing it positionally as `GI.extent(c, true)` dispatches to the trait-form fallback `GI.extent(::Any, x) = Extents.extent(x)` — i.e. `c` is treated as a trait and `true` as the geometry — and `Extents.extent(true)` returns `nothing`. Net effect: in `_union_stored_extents`, any non-empty child without a stored extent (e.g. a Point member of a GeometryCollection) is silently dropped from the cached union extent. + +**Files:** +- Modify: `src/methods/geom_relations/relateng/relate_geometry.jl:175` +- Test: `test/methods/relateng/relate_geometry.jl` (append a testset) + +**Step 1: Write the failing test** + +Open `test/methods/relateng/relate_geometry.jl`, look at the top of the file to confirm it imports `GeometryOps as GO` and `GeoInterface as GI` (it is self-contained per house convention). Append this testset at the end of the file: + +```julia +@testset "GC extent cache includes point members" begin + # A GC whose point member lies far outside its polygon member. + # _union_stored_extents must include the point's coordinates in the + # cached wrapper extent (audit 2026-06-11: GI.extent(c, true) passed + # `fallback` positionally and silently returned `nothing`). + gc = GI.GeometryCollection([ + GI.Point(10.0, 10.0), + GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]]), + ]) + cached = GO._relate_cache_extents(GO.Planar(), gc) + ext = GI.extent(cached) + @test ext.X[2] == 10.0 + @test ext.Y[2] == 10.0 +end +``` + +**Step 2: Run the test to verify it fails** + +Run from the repo root: +```bash +julia --project=test -e 'include("test/methods/relateng/relate_geometry.jl")' +``` +Expected: the new testset FAILS with `ext.X[2] == 1.0` (the polygon-only extent). All pre-existing testsets in the file must PASS. If anything else fails, stop and investigate before proceeding. + +**Step 3: Fix the call** + +In `src/methods/geom_relations/relateng/relate_geometry.jl`, inside `_union_stored_extents` (defined at line 167; the call is at line 175), change: + +```julia + else + GI.extent(c, true) + end +``` +to: +```julia + else + GI.extent(c; fallback = true) + end +``` + +**Step 4: Check there are no other positional-Bool `GI.extent` calls** + +```bash +grep -rn "GI.extent(" src/ | grep -v "fallback" +``` +Expected: every hit either takes a single argument or is unrelated. If any other call passes a second positional argument, fix it the same way. + +**Step 5: Run the test to verify it passes** + +```bash +julia --project=test -e 'include("test/methods/relateng/relate_geometry.jl")' +``` +Expected: ALL testsets PASS, including the new one. + +**Step 6: Commit** + +```bash +git add src/methods/geom_relations/relateng/relate_geometry.jl test/methods/relateng/relate_geometry.jl +git commit -m 'Fix GC extent caching dropping point members (positional `GI.extent` fallback arg)' +``` + +--- + +### Task 2: Rename the `Base.merge!` method on `RelateEdge` to `merge_edge!` + +**The problem:** `Base.merge!(e::RelateEdge, is_a, dir_pt, dim, is_forward)` extends a Base generic whose contract is collection merging with unrelated semantics (merging edge labels). Not type piracy (we own `RelateEdge`), but it pollutes a Base generic. The Java method is named `merge`; the port-parallel convention only needs a recognizably parallel name, so `merge_edge!` is fine. + +**Files:** +- Modify: `src/methods/geom_relations/relateng/relate_node.jl:209` (definition) and `:555` (callsite inside `add_edge!`) +- Test: `test/methods/relateng/node_topology.jl` (append a testset) + +**Step 1: Write the failing test** + +Append to `test/methods/relateng/node_topology.jl`: + +```julia +@testset "no GO methods on Base.merge!" begin + # RelateEdge label merging must not extend Base.merge! (collection + # semantics). It lives on the internal function `merge_edge!` instead. + @test !any(m -> m.module == GO, methods(Base.merge!)) +end +``` + +(Confirm the file imports `GeometryOps as GO` at the top; it does, per house convention.) + +**Step 2: Run the test to verify it fails** + +```bash +julia --project=test -e 'include("test/methods/relateng/node_topology.jl")' +``` +Expected: the new testset FAILS (one `Base.merge!` method is defined in `GeometryOps`). Everything else PASSES. + +**Step 3: Rename definition and callsite** + +In `src/methods/geom_relations/relateng/relate_node.jl`: + +1. Around line 209, change the definition line + `function Base.merge!(e::RelateEdge, is_a::Bool, dir_pt, dim::Integer, is_forward::Bool)` + to + `function merge_edge!(e::RelateEdge, is_a::Bool, dir_pt, dim::Integer, is_forward::Bool)` +2. Around line 555 (inside `add_edge!`), change + `merge!(e, is_a, dir_pt, dim, is_forward)` + to + `merge_edge!(e, is_a, dir_pt, dim, is_forward)` +3. In the `#= Port of RelateEdge.merge(...) =#` comment block above the definition, no change needed — it already cites the Java name. + +**Step 4: Verify no remaining callers** + +```bash +grep -rn "merge!(" src/methods/geom_relations/relateng/ test/methods/relateng/ +``` +Expected: only `merge_edge!` hits (plus the new test's `methods(Base.merge!)` introspection line). No bare `merge!(e, ...)` calls remain. + +**Step 5: Run the test to verify it passes** + +```bash +julia --project=test -e 'include("test/methods/relateng/node_topology.jl")' +``` +Expected: ALL testsets PASS. + +**Step 6: Commit** + +```bash +git add src/methods/geom_relations/relateng/relate_node.jl test/methods/relateng/node_topology.jl +git commit -m 'Rename `Base.merge!` method on `RelateEdge` to internal `merge_edge!`' +``` + +--- + +### Task 3: Harden `add_edge!` against the kernel sign contract + +**The problem:** `rk_compare_edge_dir`'s documented contract (kernel.jl) is "negative / zero / positive", but `add_edge!` tests `comp == 1`. The planar kernel happens to return exactly ±1/0; a future kernel returning any other positive value would silently corrupt the edge-wheel ordering. + +**Note on TDD:** No failing test is possible here — every current kernel returns exactly ±1/0, so the change has no observable behavior under the existing implementations. This is a contract-hardening change verified by the existing suite. Do NOT build a mock manifold to force it (YAGNI). + +**Files:** +- Modify: `src/methods/geom_relations/relateng/relate_node.jl:558` + +**Step 1: Make the change** + +In `add_edge!` (around line 558), change: +```julia + if comp == 1 +``` +to: +```julia + if comp > 0 +``` + +**Step 2: Check for other exact-sign comparisons against kernel results** + +```bash +grep -rn "== 1\|== -1" src/methods/geom_relations/relateng/ +``` +Expected: any hits are NOT comparisons of `rk_compare_edge_dir`/`compare_to`-style results (e.g. counts are fine). If another kernel-comparison result is tested with `== 1`/`== -1`, change it to `> 0`/`< 0` the same way. + +**Step 3: Run the node-topology tests** + +```bash +julia --project=test -e 'include("test/methods/relateng/node_topology.jl")' +``` +Expected: ALL PASS (no behavior change). + +**Step 4: Commit** + +```bash +git add src/methods/geom_relations/relateng/relate_node.jl +git commit -m 'Compare kernel edge-angle results by sign in `add_edge!`' +``` + +--- + +### Task 4: Replace `Base.string(::DE9IM)` with `Base.print` + +**The problem:** Defining `Base.string` directly breaks the invariant `string(x) == sprint(print, x)`: `string(im)` gives `"212101212"` while `print(io, im)` falls through to `show` and gives `DE9IM("212101212")`. The Julian fix is to define `Base.print` and let `string` fall out of it. + +**Files:** +- Modify: `src/methods/geom_relations/relateng/de9im.jl:102` +- Test: `test/methods/relateng/de9im.jl` (around line 41, where `string(im)` is already tested) + +**Step 1: Write the failing test** + +In `test/methods/relateng/de9im.jl`, directly after the existing line +`@test string(im) == "212101212"` (line ~41), add: + +```julia + @test sprint(print, im) == "212101212" # print and string must agree + @test sprint(show, im) == "DE9IM(\"212101212\")" +``` + +**Step 2: Run the test to verify it fails** + +```bash +julia --project=test -e 'include("test/methods/relateng/de9im.jl")' +``` +Expected: the `sprint(print, im)` assertion FAILS (currently yields `DE9IM("212101212")` via `show`). The `sprint(show, ...)` and `string(...)` assertions PASS. + +**Step 3: Swap the definition** + +In `src/methods/geom_relations/relateng/de9im.jl`, change line 102 from: +```julia +Base.string(im::DE9IM) = join(dim_char(d) for d in im.entries) +``` +to: +```julia +# `string(im)` and `"$im"` yield the standard 9-character matrix form via +# this `print`; `show` keeps the constructor form for the REPL. +Base.print(io::IO, im::DE9IM) = join(io, (dim_char(d) for d in im.entries)) +``` +Leave the `Base.show` method on the next line unchanged. Note `string(im)` continues to work — Julia's generic `string(x)` is `sprint(print, x)`. + +**Step 4: Check existing `string(im)` callers still resolve** + +```bash +grep -rn "string(p.im)\|string(im)" src/methods/geom_relations/relateng/ +``` +Expected: hits in `topology_predicate.jl` (`Base.show(::IMPredicate)`) and `de9im.jl` — all fine, they go through generic `string`. + +**Step 5: Run the test to verify it passes** + +```bash +julia --project=test -e 'include("test/methods/relateng/de9im.jl")' +``` +Expected: ALL PASS. + +**Step 6: Commit** + +```bash +git add src/methods/geom_relations/relateng/de9im.jl test/methods/relateng/de9im.jl +git commit -m 'Define `Base.print` for `DE9IM` instead of `Base.string`' +``` + +--- + +### Task 5: Throw `ArgumentError` instead of bare `error(...)` in `TopologyComputer` + +**The problem:** Three unreachable-state guards use `error("Unknown target dimension: ...")` (→ `ErrorException`); the rest of the folder throws typed `ArgumentError`s. + +**Files:** +- Modify: `src/methods/geom_relations/relateng/topology_computer.jl:293,326,400` +- Test: `test/methods/relateng/topology_computer.jl` (append a testset) + +**Step 1: Write the failing test** + +Open `test/methods/relateng/topology_computer.jl` and find how existing testsets construct a `TopologyComputer` (they build two `GO.RelateGeometry`s and pass a predicate — mirror that construction exactly, including the `exact = ...` keyword they use). Append: + +```julia +@testset "unknown target dimension throws ArgumentError" begin + a = GI.Point(0.0, 0.0) + b = GI.Point(1.0, 1.0) + rga = GO.RelateGeometry(GO.Planar(), a; exact = GO.True()) + rgb = GO.RelateGeometry(GO.Planar(), b; exact = GO.True()) + tc = GO.TopologyComputer(GO.pred_intersects(), rga, rgb) + @test_throws ArgumentError GO.add_point_on_geometry!( + tc, true, GO.LOC_INTERIOR, Int8(9), (0.0, 0.0)) +end +``` + +(If the file's existing tests spell the exactness flag differently — e.g. `GO.GeometryOpsCore.True()` — match them.) + +**Step 2: Run the test to verify it fails** + +```bash +julia --project=test -e 'include("test/methods/relateng/topology_computer.jl")' +``` +Expected: the new testset FAILS — an `ErrorException` is thrown where `ArgumentError` is expected. + +**Step 3: Replace the three `error` calls** + +In `src/methods/geom_relations/relateng/topology_computer.jl`, at the ends of `add_point_on_geometry!` (~line 293), `add_line_end_on_geometry!` (~line 326), and `add_area_vertex!` (~line 400), change each: +```julia + error("Unknown target dimension: $dim_target") +``` +to: +```julia + throw(ArgumentError("unknown target dimension: $dim_target")) +``` + +**Step 4: Run the test to verify it passes** + +```bash +julia --project=test -e 'include("test/methods/relateng/topology_computer.jl")' +``` +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +git add src/methods/geom_relations/relateng/topology_computer.jl test/methods/relateng/topology_computer.jl +git commit -m 'Throw `ArgumentError` for unknown target dimensions in `TopologyComputer`' +``` + +--- + +### Task 6: Rename collision-prone generic internal names + +**The problem:** The flat GO module gains unexported internal functions with extremely generic names (`id`, `dimension`, `location`, `matches`, `is_empty`). No collision exists today (verified by grep), but any future GO file defining the same name silently merges methods. We rename the five riskiest; the rest (`locate`, `finish!`, `compare_to`, `get_*`, `is_known`, …) are deliberately KEPT — `locate` is a legitimate multi-locator generic, and the others are subsystem-scoped port-parity names whose rename would cost more diffability than it buys. + +**Rename map (definition → all callsites, src AND test):** + +| Old | New | Definition | Known callsites | +|---|---|---|---| +| `id(ns::NodeSection)` | `section_id` | `node_sections.jl:94` | `node_sections.jl:126` (`is_same_polygon`, two calls), `polygon_node_converter.jl:107`, `test/.../node_topology.jl:32` | +| `dimension(ns::NodeSection)` | `section_dim` | `node_sections.jl:91` | `node_sections.jl:80` (`is_area_area`, two calls), `relate_node.jl:464`, `test/.../node_topology.jl:31` | +| `dimension(e::RelateEdge, is_a)` | `edge_dim` | `relate_node.jl:351` | `relate_node.jl:243` | +| `location(e::RelateEdge, is_a, pos)` | `edge_location` | `relate_node.jl:337` | `relate_node.jl:254,360,364,407–410,609,615,640–641`; `topology_computer.jl:658–664`; `test/.../node_topology.jl:188–189,340–341,381,383,446–447` | +| `matches(im::DE9IM, pattern)` | `im_matches` | `de9im.jl:112` | `relate_predicates.jl:283` (`value_im(::IMPatternMatcher)`); `test/.../de9im.jl:48–55` | +| `is_empty(rg::RelateGeometry)` | `is_geom_empty` | `relate_geometry.jl:397` | `topology_computer.jl:271,314` | + +When renaming, keep the adjacent "Port of ``" comments exactly as they are — they record the Java name, which is the point. + +**Step 1: For each row, find every occurrence** + +For each old name, run a word-boundary grep BEFORE editing, e.g.: +```bash +grep -rnw "dimension" src/methods/geom_relations/relateng/ test/methods/relateng/ +``` +Cross-check the hits against the table. If you find a callsite NOT in the table, update it too — the table is the audit's snapshot, the grep is the truth. + +**Step 2: Apply the renames** + +Edit each definition and callsite. Careful with `dimension`: it has TWO methods becoming TWO differently-named functions (`section_dim` for `NodeSection`, `edge_dim` for `RelateEdge`) — match by argument type at each callsite. Careful with `is_empty` vs. the *field* `rg.is_geom_empty` (the accessor becomes `is_geom_empty(rg) = rg.is_geom_empty`, which is fine — function and field namespaces don't clash). Do NOT touch `GI.isempty` or Julia's `isempty`. + +**Step 3: Verify zero stale references** + +```bash +grep -rnw -e "id" -e "dimension" -e "matches" src/methods/geom_relations/relateng/ | grep -v "_id\|section_id\|section_dim\|edge_dim\|im_matches\|matches_entry\|pred_matches\|ring_id\|element_id\|node_id\|#" +grep -rnw "is_empty" src/methods/geom_relations/relateng/ +grep -rnw -e "GO.id" -e "GO.dimension" -e "GO.location" -e "GO.matches" -e "GO.is_empty" test/methods/relateng/ +``` +Expected: no hits that are calls/definitions of the old names (comments citing Java method names like `NodeSection.id` are fine and should remain). + +**Step 4: Run the affected unit test files** + +```bash +julia --project=test -e 'include("test/methods/relateng/de9im.jl")' +julia --project=test -e 'include("test/methods/relateng/node_topology.jl")' +julia --project=test -e 'include("test/methods/relateng/topology_computer.jl")' +julia --project=test -e 'include("test/methods/relateng/predicates.jl")' +``` +Expected: ALL PASS. (`MethodError`/`UndefVarError` here means a missed callsite — go back to Step 3.) + +**Step 5: Commit** + +```bash +git add src/methods/geom_relations/relateng/ test/methods/relateng/ +git commit -m 'Rename collision-prone internal relateng helpers (`id`, `dimension`, `location`, `matches`, `is_empty`)' +``` + +--- + +### Task 7: Convert trait `isa` ladders to trait-dispatched methods + +**The problem:** Some relateng walks classify geometry by `trait = GI.trait(geom)` followed by an `if trait isa X ... elseif trait isa Y ...` ladder, while neighboring functions in the same folder (`_rce`, `_iig_add_geom!`, `_add_rings!`, `_locate_point_in_polygonal`) use the house style: trait-dispatched methods. Unify on dispatch. + +**Scope — convert ONLY pure classification ladders** (functions whose body is an if/elseif over the trait covering several geometry classes). Explicitly KEEP as-is: + +- Single-trait guards inside larger logic (`is_polygonal`, `locate_with_dim`'s `isa Union{...}` check, `locate_node`/`is_node_in_area` in topology_computer.jl, the inline checks in `_extract_segment_strings!`). +- The sequential walks `_compute_line_ends_walk!` / `_compute_area_vertex_walk!` (relate_ng.jl) and `_extract_segment_strings_from_atomic!` — they mix trait checks with threaded state/side effects and are guard-style, not ladders. + +**Functions to convert:** + +| Function | File | +|---|---| +| `_extract_elements!` | `point_locator.jl:305` (trait-form method; the 4-arg entry at `:302` already exists) | +| `_relate_is_empty` | `relate_geometry.jl:85` | +| `_relate_extent` | `relate_geometry.jl:99` | +| `_geom_dimension` | `relate_geometry.jl:187` | +| `_analyze_dimensions` + element classification in `_analyze_collection_dimensions` | `relate_geometry.jl:211,227` | +| `_is_zero_length` | `relate_geometry.jl:259` | +| `_add_component_coordinates!` | `relate_geometry.jl:419` | +| `_extract_point_elements!` | `relate_geometry.jl:457` | +| `_segment_string_eltype` | `relate_geometry.jl:497` | + +**⚠️ Correctness note for the executor:** in the ladders, *branch order* encodes precedence. In GeoInterface, the `Multi*` traits are subtypes of `GI.AbstractGeometryCollectionTrait`, and the ladders check the point/curve/polygon unions BEFORE the GC branch. The converted methods rely on dispatch specificity instead: a `Union` containing `GI.AbstractMultiPointTrait` (etc.) is strictly more specific than `GI.AbstractGeometryCollectionTrait`, so dispatch picks the same branch. The characterization testset in Step 1 pins exactly these cases — do not skip it. + +**Files:** +- Modify: `src/methods/geom_relations/relateng/point_locator.jl`, `src/methods/geom_relations/relateng/relate_geometry.jl` +- Test: `test/methods/relateng/relate_geometry.jl` (append characterization testset) + +**Step 1: Write the characterization test (pins behavior BEFORE the refactor)** + +Append to `test/methods/relateng/relate_geometry.jl`. This is a refactor, so the test must PASS both before and after — it exists to catch dispatch-specificity mistakes (especially `Multi*` traits vs. the GC trait): + +```julia +@testset "trait classification characterization" begin + mp = GI.MultiPoint([(0.0, 0.0), (1.0, 1.0)]) + ml = GI.MultiLineString([[(0.0, 0.0), (1.0, 0.0)], [(0.0, 1.0), (1.0, 1.0)]]) + mpoly = GI.MultiPolygon([[[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]]]) + zline = GI.LineString([(0.0, 0.0), (0.0, 0.0)]) # zero-length + gc = GI.GeometryCollection([GI.Point(5.0, 5.0), mpoly]) + empty_gc = GI.GeometryCollection([GI.LineString(Tuple{Float64, Float64}[])]) + + #-- _geom_dimension: Multi* must classify by content, not as GC + @test GO._geom_dimension(mp) == GO.DIM_P + @test GO._geom_dimension(ml) == GO.DIM_L + @test GO._geom_dimension(mpoly) == GO.DIM_A + @test GO._geom_dimension(gc) == GO.DIM_A + + #-- _relate_is_empty: recursive emptiness through collections + @test GO._relate_is_empty(empty_gc) + @test !GO._relate_is_empty(gc) + + #-- _is_zero_length: MultiLineString takes the collection branch + @test GO._is_zero_length(zline) + @test !GO._is_zero_length(ml) + + #-- _relate_extent: union over GC elements including the bare point + @test GO._relate_extent(GO.Planar(), gc).X == (0.0, 5.0) + + #-- _analyze_dimensions via the constructor: mixed GC + rg = GO.RelateGeometry(GO.Planar(), gc; exact = GO.True()) + @test GO.get_dimension(rg) == GO.DIM_A + @test rg.has_points && rg.has_areas && !rg.has_lines + + #-- _add_component_coordinates!: point coords + first line coords + pts = Set{Tuple{Float64, Float64}}() + GO._add_component_coordinates!(pts, GI.GeometryCollection([mp, ml])) + @test (0.0, 0.0) in pts + + #-- _extract_point_elements!: only the Point element of the GC + lst = Any[] + GO._extract_point_elements!(lst, gc) + @test length(lst) == 1 + + #-- _extract_elements!: classification into points/lines/polygons + points = Set{Tuple{Float64, Float64}}(); lines = Any[]; polygons = Any[] + GO._extract_elements!(points, lines, polygons, + GI.GeometryCollection([GI.Point(0.0, 0.0), ml, mpoly])) + @test length(points) == 1 && length(lines) == 2 && length(polygons) == 1 +end +``` + +(If the existing testsets construct `RelateGeometry` with a different exactness spelling than `GO.True()`, match them.) + +**Step 2: Run the test — it must PASS against the CURRENT code** + +```bash +julia --project=test -e 'include("test/methods/relateng/relate_geometry.jl")' +``` +Expected: ALL PASS. If the new testset fails here, the test is wrong (it mischaracterizes current behavior) — fix the test, not the source. + +**Step 3: Convert `_extract_elements!` (point_locator.jl)** + +Replace the trait-form method `_extract_elements!(points, lines, polygons, trait::GI.AbstractTrait, geom)` (lines ~305–323) with dispatch methods. Keep the 4-argument entry point above it (`point_locator.jl:302–303`) and its "Port of RelatePointLocator.extractElements" comment unchanged. The original ladder checks the polygonal union before the GC branch — dispatch specificity reproduces this (see the correctness note): + +```julia +function _extract_elements!(points, lines, polygons, ::GI.PointTrait, geom) + GI.isempty(geom) && return nothing + #-- addPoint: normalized coordinate tuples, as in LinearBoundary + push!(points, _node_point(geom)) + return nothing +end +function _extract_elements!(points, lines, polygons, ::GI.AbstractCurveTrait, geom) + GI.isempty(geom) && return nothing + #-- addLine (Java LinearRing extends LineString, hence AbstractCurve) + push!(lines, geom) + return nothing +end +function _extract_elements!(points, lines, polygons, + ::Union{GI.PolygonTrait, GI.MultiPolygonTrait}, geom) + GI.isempty(geom) && return nothing + #-- addPolygonal: whole polygonal geometry kept as one element + push!(polygons, geom) + return nothing +end +function _extract_elements!(points, lines, polygons, + ::GI.AbstractGeometryCollectionTrait, geom) + GI.isempty(geom) && return nothing + #-- covers GeometryCollection, MultiPoint, MultiLineString + for g in GI.getgeom(geom) + _extract_elements!(points, lines, polygons, g) + end + return nothing +end +_extract_elements!(points, lines, polygons, ::GI.AbstractTrait, geom) = nothing +``` + +**Step 4: Convert the relate_geometry.jl ladders** + +Replace each function body with dispatch methods, preserving the existing comments by moving them onto the matching method. Trait argument goes before the geometry, matching `_rce`/`_iig_add_geom!`. + +`_relate_is_empty`: +```julia +_relate_is_empty(geom) = _relate_is_empty(GI.trait(geom), geom) +function _relate_is_empty(::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _relate_is_empty(g) || return false + end + return true +end +_relate_is_empty(::GI.AbstractTrait, geom) = GI.isempty(geom) +``` + +`_relate_extent`: +```julia +_relate_extent(m::Manifold, geom) = _relate_extent(m, GI.trait(geom), geom) +function _relate_extent(m::Manifold, ::GI.AbstractGeometryCollectionTrait, geom) + ext = nothing + for g in GI.getgeom(geom) + e = _relate_extent(m, g) + e === nothing && continue + ext = ext === nothing ? e : Extents.union(ext, e) + end + return ext +end +function _relate_extent(m::Manifold, ::GI.AbstractTrait, geom) + GI.isempty(geom) && return nothing + return rk_interaction_bounds(m, geom) +end +``` + +`_geom_dimension`: +```julia +_geom_dimension(geom) = _geom_dimension(GI.trait(geom), geom) +_geom_dimension(::Union{GI.AbstractPointTrait, GI.AbstractMultiPointTrait}, geom) = DIM_P +_geom_dimension(::Union{GI.AbstractCurveTrait, GI.AbstractMultiCurveTrait}, geom) = DIM_L +_geom_dimension(::Union{GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait}, geom) = DIM_A +function _geom_dimension(::GI.AbstractGeometryCollectionTrait, geom) + dim = DIM_FALSE + for g in GI.getgeom(geom) + d = _geom_dimension(g) + d > dim && (dim = d) + end + return dim +end +_geom_dimension(::GI.AbstractTrait, geom) = DIM_FALSE +``` + +`_analyze_dimensions` (keep the 3-arg entry; the GC/other fallback goes to the collection walk as before): +```julia +function _analyze_dimensions(geom, dim0::Int8, is_geom_empty::Bool) + is_geom_empty && return (dim0, false, false, false) + return _analyze_dimensions(GI.trait(geom), geom, dim0) +end +_analyze_dimensions(::Union{GI.AbstractPointTrait, GI.AbstractMultiPointTrait}, geom, dim0) = + (DIM_P, true, false, false) +_analyze_dimensions(::Union{GI.AbstractCurveTrait, GI.AbstractMultiCurveTrait}, geom, dim0) = + (DIM_L, false, true, false) +_analyze_dimensions(::Union{GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait}, geom, dim0) = + (DIM_A, false, false, true) +#-- analyze a (possibly mixed type) collection +_analyze_dimensions(::GI.AbstractTrait, geom, dim0) = + _analyze_collection_dimensions(geom, dim0, false, false, false) +``` + +`_analyze_collection_dimensions` — extract the per-element ladder into a dispatch helper: +```julia +# The recursive element walk of analyzeDimensions (Java uses a +# GeometryCollectionIterator; only atomic elements match the checks). +function _analyze_collection_dimensions(geom, dim, has_points, has_lines, has_areas) + for g in GI.getgeom(geom) + dim, has_points, has_lines, has_areas = _analyze_element_dimensions( + GI.trait(g), g, dim, has_points, has_lines, has_areas) + end + return (dim, has_points, has_lines, has_areas) +end +_analyze_element_dimensions(::GI.AbstractGeometryCollectionTrait, g, dim, hp, hl, ha) = + _analyze_collection_dimensions(g, dim, hp, hl, ha) +function _analyze_element_dimensions(::GI.AbstractPointTrait, g, dim, hp, hl, ha) + GI.isempty(g) && return (dim, hp, hl, ha) + return (max(dim, DIM_P), true, hl, ha) +end +function _analyze_element_dimensions(::GI.AbstractCurveTrait, g, dim, hp, hl, ha) + GI.isempty(g) && return (dim, hp, hl, ha) + return (max(dim, DIM_L), hp, true, ha) +end +function _analyze_element_dimensions(::GI.AbstractPolygonTrait, g, dim, hp, hl, ha) + GI.isempty(g) && return (dim, hp, hl, ha) + return (max(dim, DIM_A), hp, hl, true) +end +_analyze_element_dimensions(::GI.AbstractTrait, g, dim, hp, hl, ha) = (dim, hp, hl, ha) +``` + +`_is_zero_length`: +```julia +_is_zero_length(geom) = _is_zero_length(GI.trait(geom), geom) +function _is_zero_length(::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _is_zero_length(g) || return false + end + return true +end +_is_zero_length(::GI.AbstractCurveTrait, geom) = _is_zero_length_linestring(geom) +_is_zero_length(::GI.AbstractTrait, geom) = true +``` + +`_add_component_coordinates!` (the point/curve ternary splits into two methods): +```julia +_add_component_coordinates!(set, geom) = + _add_component_coordinates!(set, GI.trait(geom), geom) +function _add_component_coordinates!(set, ::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _add_component_coordinates!(set, g) + end + return nothing +end +function _add_component_coordinates!(set, ::GI.AbstractPointTrait, geom) + GI.isempty(geom) && return nothing + push!(set, _node_point(geom)) + return nothing +end +function _add_component_coordinates!(set, ::GI.AbstractCurveTrait, geom) + GI.isempty(geom) && return nothing + push!(set, _node_point(GI.getpoint(geom, 1))) + return nothing +end +_add_component_coordinates!(set, ::GI.AbstractTrait, geom) = nothing +``` + +`_extract_point_elements!`: +```julia +_extract_point_elements!(list, geom) = + _extract_point_elements!(list, GI.trait(geom), geom) +function _extract_point_elements!(list, ::GI.AbstractPointTrait, geom) + push!(list, geom) + return nothing +end +function _extract_point_elements!(list, ::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _extract_point_elements!(list, g) + end + return nothing +end +_extract_point_elements!(list, ::GI.AbstractTrait, geom) = nothing +``` + +`_segment_string_eltype` (the separate Polygon/MultiPolygon branches had identical bodies — merge into one `Union` method; keep the explanatory comment block above the entry point): +```julia +_segment_string_eltype(rg::RelateGeometry, geom) = + _segment_string_eltype(rg, GI.trait(geom), geom) +_segment_string_eltype(rg::RG, ::GI.AbstractCurveTrait, geom) where {RG <: RelateGeometry} = + RelateSegmentString{Tuple{Float64, Float64}, Nothing, RG} +#-- rings of MultiPolygon elements carry the MultiPolygon as parent +_segment_string_eltype(rg::RG, ::Union{GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait}, + geom) where {RG <: RelateGeometry} = + RelateSegmentString{Tuple{Float64, Float64}, typeof(geom), RG} +function _segment_string_eltype(rg::RelateGeometry, ::GI.AbstractGeometryCollectionTrait, geom) + T = Union{} + for g in GI.getgeom(geom) + T = Union{T, _segment_string_eltype(rg, g)} + end + return T +end +#-- point elements produce no segment strings +_segment_string_eltype(::RelateGeometry, ::GI.AbstractTrait, geom) = Union{} +``` + +**Step 5: Check for ladders you missed and stale callers** + +```bash +grep -rn "trait isa" src/methods/geom_relations/relateng/ +``` +Expected: remaining hits are ONLY the keep-list items (single guards in `is_polygonal`, `locate_with_dim`, `locate_node`/`is_node_in_area`, `_extract_segment_strings!`/`_extract_segment_strings_from_atomic!`, and the relate_ng.jl walks). All converted functions must have no `trait isa` left. + +**Step 6: Run the characterization test and neighbors — must still pass** + +```bash +julia --project=test -e 'include("test/methods/relateng/relate_geometry.jl")' +julia --project=test -e 'include("test/methods/relateng/point_locator.jl")' +julia --project=test -e 'include("test/methods/relateng/relate_ng.jl")' +``` +Expected: ALL PASS. A `MethodError: ... is ambiguous` here means a `Union` method conflicts with the GC-trait method for some `Multi*` trait — resolve by adding an explicit method for that concrete trait that forwards to the intended branch. + +**Step 7: Commit** + +```bash +git add src/methods/geom_relations/relateng/ test/methods/relateng/relate_geometry.jl +git commit -m 'Convert trait `isa` ladders in relateng to trait-dispatched methods' +``` + +--- + +### Task 8: Documentation and comment fixes + +No code behavior changes; no tests. Four edits: + +**Files:** +- Modify: `src/methods/geom_relations/relateng/de9im.jl` (DE9IM docstring, ~line 79) +- Modify: `src/methods/geom_relations/relateng/relate_ng.jl` (RelateNG docstring ~line 103, `prepare` docstring ~line 650, definition at `:657`) +- Modify: `src/methods/geom_relations/relateng/topology_predicate.jl` (`is_known_entry`, ~line 214) + +**Step 1: DE9IM docstring — explain the index convention** + +In the `DE9IM` docstring, replace the sentence `Index with `im[locA, locB]`.` with: + +``` +Index with `im[locA, locB]`, where the indices are the JTS location *codes* +(`0` = Interior, `1` = Boundary, `2` = Exterior — the internal `LOC_*` +constants), **not** 1-based array positions: `im[0, 0]` is the +Interior/Interior entry. +``` + +**Step 2: RelateNG docstring — document Float64 evaluation** + +In the `RelateNG` docstring (after the numbered capability list, before the "Keyword arguments" paragraph), add: + +``` +All coordinates are evaluated as `Float64`: input coordinates are converted +on extraction, and the exact-predicate machinery (adaptive orientation +predicates, rational-arithmetic node coincidence) assumes `Float64` inputs. +Non-`Float64` geometries are accepted but evaluated at `Float64` precision. +``` + +**Step 3: `prepare` docstring — state the genericity intent** + +At the top of the `prepare(alg::RelateNG, a)` docstring, after the signature line, add: + +``` +`prepare` is the generic entry point for prepared-geometry optimizations in +GeometryOps; `RelateNG` is currently the only algorithm implementing it. +``` + +**Step 4: `is_known_entry` — mark the parity dead code** + +Above `is_known_entry` in `topology_predicate.jl` (~line 214), add: + +```julia +# NOTE: unused; kept for JTS IMPredicate API parity. As ported it can never +# return `false`: matrix entries are initialized to DIM_FALSE and only ever +# increase, so they never hold DIM_UNKNOWN (-3). Do not use as a real check. +``` + +**Step 5: Sanity-check the doc edits compile** + +```bash +julia --project=test -e 'include("test/methods/relateng/de9im.jl")' +``` +Expected: PASS (docstrings/comments only; this just catches syntax slips). + +**Step 6: Commit** + +```bash +git add src/methods/geom_relations/relateng/ +git commit -m 'Document DE9IM index codes, Float64 evaluation, `prepare` genericity, and `is_known_entry` parity status' +``` + +--- + +### Task 9: Full-suite verification + +**Step 1: Run the full relateng suite in the background** + +This takes ~25 minutes (XML conformance + LibGEOS differential fuzz). Run it in a background shell and poll: + +```bash +cd /Users/anshul/temp/GO_jts/GeometryOps.jl && julia --project=test -e 'include("test/methods/relateng/runtests.jl")' > /tmp/relateng_suite.log 2>&1 +``` + +**Step 2: Check the result** + +```bash +tail -20 /tmp/relateng_suite.log +``` +Expected: the standard `Test Summary` block with zero `Fail`/`Error`. If anything fails, identify which task introduced it (`git log --oneline` + the failing testset name), fix in place, and re-run only the affected unit file before re-running the full suite. + +**Step 3: Review the final diff** + +```bash +git log --oneline main..HEAD 2>/dev/null || git log --oneline -9 +git diff HEAD~8 --stat +``` +Expected: 8 commits, all changes confined to `src/methods/geom_relations/relateng/`, `test/methods/relateng/`, and this plan file's directory. + +--- + +## Explicitly out of scope (decided during the audit) + +- **Submodule restructuring** (`module RelateNG ... end`): rejected in favor of targeted renames — the package convention is a flat module (clipping is flat too), and wrapping 14 files would hurt the file-by-file Java diffability that the port is built around. +- **Renaming `get_`/`set_` accessors, `compare_to`, `finish!`, `locate`, `is_known`, `get_geometry`, …**: deliberate port-parity names; `locate` is a proper multi-type generic. Leave them. +- **Converting the single-trait guards and sequential walks to dispatch** (`is_polygonal`, `locate_with_dim`, `locate_node`/`is_node_in_area`, `_compute_line_ends_walk!`, `_compute_area_vertex_walk!`, `_extract_segment_strings_from_atomic!`): these mix trait checks with threaded state or are single predicates inside larger logic — Task 7 deliberately converts only the pure classification ladders. +- **`Vector{Any}` element collections in `RelatePointLocator` / abstract `Vector{NodeSection}`**: documented, deliberate, not on hot paths. No change. +- **Exporting `LOC_*` constants or symbol-based `DE9IM` indexing**: YAGNI for now; the docstring fix (Task 8) covers usability. +- **`IM_PATTERN_*` constants**: NOT dead code — they are used in `test/methods/relateng/relate_ng.jl`. Keep as-is. diff --git a/docs/plans/2026-06-15-relateng-spherical-kernel-design.md b/docs/plans/2026-06-15-relateng-spherical-kernel-design.md new file mode 100644 index 0000000000..403f6b9fd0 --- /dev/null +++ b/docs/plans/2026-06-15-relateng-spherical-kernel-design.md @@ -0,0 +1,146 @@ +# RelateNG on the Spherical manifold — implementation design + +2026-06-15. Builds on the spike `2026-06-12-relateng-spherical-spike.md` / +`.jl`. This document records the validated design decisions; the bite-sized +task list lives in `2026-06-15-relateng-spherical-kernel.md`. + +## Goal + +Make `relate(RelateNG(; manifold = Spherical()), a, b)` work end-to-end — +Phases 1+2+3 of the spike: the spherical kernel, the acceleration, and the +exact paths — verified by a standalone spherical kernel-conformance suite and +end-to-end tests. + +## Decisions (from the 2026-06-15 brainstorm) + +1. **Scope: full implementation**, not a stub scaffold. All 13 `rk_*` + functions for `::Spherical`, including the un-prototyped angle-ordering + cluster and the `Rational{BigInt}` exact paths. +2. **Conformance: a separate standalone suite** + (`test/methods/relateng/kernel_conformance_spherical.jl`). The planar + `kernel_conformance.jl` is left untouched. The spherical suite uses + exactly-representable integer-xyz inputs in general position (so sign + predicates stay exact) and carries its own `Rational{BigInt}` differential + reference for the crossing-apex property. +3. **Boundary: full end-to-end** (Phases 1+2+3). Out of scope: Phase 4's + broad differential-testing program (JTS-XML port, densified-geodesic + oracle); we keep validation to the conformance suite plus targeted + end-to-end smoke tests. +4. **Spherical kernel point type is `UnitSphericalPoint{Float64}`** (not a + plain `NTuple{3,Float64}`): isbits, `GI.x/y/z`-accessible, and the kernel's + cross/dot math runs on it directly. + +## Architecture + +The engine is **already manifold-generic in its control flow**: `RelateNG`, +`RelateGeometry`, `TopologyComputer`, both point locators, +`RayCrossingCounter` and `edge_intersector` all take `m::Manifold`, and the +accelerator selection already dispatches `Planar` vs generic `Manifold`. Two +things are missing: + +1. **Point element type `P` is pinned to `Tuple{Float64,Float64}`** at + construction (`topology_computer.jl:53`, the `Set`/`Dict`/struct fields in + `relate_geometry.jl`, `point_locator.jl`, `indexed_point_in_area.jl`). The + structs are *already* parameterized on `P`; only the construction fixes it. + The enabling refactor derives `P` from the manifold and converts lon/lat → + xyz once at ingest. + +2. **The spherical kernel itself** (`kernel_spherical.jl`) — the 13 `rk_*` + methods on `::Spherical`. + +Correctness vs acceleration are cleanly separable: at `point_locator.jl:525` +non-`Planar` already falls through to `rk_point_in_ring`, and +`edge_intersector` already falls back to `NestedLoop()` for non-`Planar`. So +once the kernel and `P`-threading exist, `relate(Spherical(), …)` is +*correct* (if unaccelerated). Acceleration (3D edge index, lon-interval +point locator, STR, prepared mode) is layered on after. + +### Point-type plumbing + +- `_kernel_point_type(::Planar) = NTuple{2,Float64}`, + `_kernel_point_type(::Spherical) = UnitSphericalPoint{Float64}`. +- `_to_kernel_point(m, geopoint)`: identity `(x,y)` on `Planar`; lon/lat → xyz + via `UnitSphereFromGeographic`, **renormalized** (`normalize`) and + signed-zero-normalized per component, on `Spherical`. +- `_node_point` / `_node_points` / `_canonical_segment` / `crossing_node` in + `kernel.jl` generalize from `(x,y)` to N components. +- Thread the derived `P` through `RelateGeometry` → + `TopologyComputer`/`RelateSegmentString`/`RelatePointLocator`. + +### The spherical kernel + +Every predicate reduces to a sign of `det(u,v,w) = (u×v)·w`: + +- `rk_orient` → `sign((a×b)·c)`; exact via + `ExactPredicates.orient(tup(a),tup(b),tup(c),(0,0,0))`. +- `rk_point_on_segment` → coplanarity + arc-span dot tests. +- `rk_classify_intersection` → full `SegSegClass`: `SS_PROPER` (candidate + direction `d = n_a×n_b` strictly interior to both arcs), `SS_COLLINEAR` + (same great circle, overlapping spans), `SS_TOUCH`/endpoint flags. +- `rk_point_in_ring` → meridian-arc crossing parity to a pole reference; + pole-insideness derived from the ring's signed area (S2 convention, + interior on the left of the directed ring). +- angle cluster (`rk_quadrant`, `rk_compare_edge_dir`, `rk_crossing_dirs_ccw`, + `rk_is_crossing`, `rk_is_interior_segment`) → tangent-plane hemisphere + split with a reference direction `r` (a pole; fall back when the apex is + that pole). +- `rk_nodes_coincide` → `Rational{BigInt}` on xyz components. +- `rk_interaction_bounds` → `arc_extent` (spike-proven); area elements + extended by the six ±eᵢ axis-point test. +- antipodal degeneracy → informative `ArgumentError` naming `AntipodalEdgeSplit`. + +### Acceleration + +- 3D `Extent{(:X,:Y,:Z)}` flow through `_relate_edge_index` unchanged + (`NaturalIndex` is dimension-generic). **Done**, but the per-segment extent + table had to be made manifold-aware too: it built a 2D endpoint box, which + misses a long arc's bulge and prunes real crossings — `_segment_extent` + now uses `arc_extent` on the sphere. +- `edge_intersector.jl`: `_select_edge_set_accelerator(::Spherical, …)` → + tree accelerator; `_segment_envs_disjoint(::Spherical, …)` → 3D arc-extent + disjoint test. **Done.** +- Prepared geometries: `_build_prepared_edge_index(::Spherical, …)` indexes A + in 3D; the dimension-generic `NaturalIndex` needs no separate STR ordering, + so prepared spherical relate matches unprepared. **Done.** +- **SKIPPED (time-boxed):** the lon-interval indexed spherical point locator + (`SortedPackedIntervalRTree` over longitude intervals; antimeridian crossers + split). This is a pure *optimization* over the already-correct fall-through + at `point_locator.jl`'s `locate_on_polygonal` — Spherical takes the direct + `rk_point_in_ring` ring loop (the `loc.m isa Planar` guard skips the indexed + arm), which is correct but O(n) per query. Revisit if spherical point-in-area + becomes a hot path. + +### Antipodal edges + +Kernel throws on exactly-antipodal consecutive vertices. The opt-in +`AntipodalEdgeSplit` correction (in `transformations/correction/`) inserts the +lon/lat midpoint; this is the final, independently-deferrable task. + +### Ring/direction containment (`_ring_contains_dir`) + +Whether a direction `N` is interior to a ring (S2 interior-on-left) is decided +by the ring's **winding** about `N`: project each edge onto the plane ⊥ `N` and +sum the signed turn. Each edge contributes a turn of magnitude < π, so the sum +has no atan2 branch ambiguity — a per-triangle signed-solid-angle sum +(Van Oosterom–Strackee) does *not* have this property and reports spurious +interiors for reference directions far from the loop's winding axis (e.g. an +equatorial axis against a polar-cap ring). A single interior-on-left enclosure +sweeps +2π, so `N` is interior iff the total exceeds π. + +This is exact for rings smaller than a hemisphere — the common geographic case, +and all rings the conformance/end-to-end suites build. **Limitation:** a ring +whose interior is *larger* than a hemisphere (interior = the bigger region) +would under-report a non-encircled interior axis (winding 0 but interior). +`_ring_contains_pole` (used for `rk_point_in_ring`'s pole reference) and the +area-bounds axis extension (`_widen_area_axes`) both rest on this assumption. +The axis extension only over-prunes when wrong, so it stays conservative-safe; +super-hemisphere rings in `rk_point_in_ring` are explicitly out of scope here. + +## Out of scope + +Phase 4 differential-testing program (JTS-XML spherical port, +densified-geodesic oracle). The conformance suite + end-to-end smoke tests are +the proof for this work. + +Super-hemisphere rings (interior larger than a hemisphere) in +`_ring_contains_dir` — see the limitation above. diff --git a/src/GeometryOps.jl b/src/GeometryOps.jl index 58d380eb34..b9ae685c3e 100644 --- a/src/GeometryOps.jl +++ b/src/GeometryOps.jl @@ -69,6 +69,7 @@ include("methods/centroid.jl") include("methods/convex_hull.jl") include("methods/distance.jl") include("methods/equals.jl") +include("methods/extent.jl") include("methods/perimeter.jl") include("methods/clipping/predicates.jl") include("methods/clipping/clipping_processor.jl") @@ -89,6 +90,39 @@ include("methods/geom_relations/overlaps.jl") include("methods/geom_relations/touches.jl") include("methods/geom_relations/within.jl") include("methods/geom_relations/common.jl") +include("methods/geom_relations/relateng/de9im.jl") +include("methods/geom_relations/relateng/topology_predicate.jl") +include("methods/geom_relations/relateng/relate_predicates.jl") +# Kernel files: after de9im.jl (they use the `LOC_` constants) and before the +# topology-layer files (Task 6+) that will call the kernel functions. +include("methods/geom_relations/relateng/kernel.jl") +include("methods/geom_relations/relateng/kernel_planar.jl") +include("methods/geom_relations/relateng/kernel_spherical.jl") +# Node sections: after the kernel (uses `NodeKey`), before the point locator +# (AdjacentEdgeLocator builds NodeSections). +include("methods/geom_relations/relateng/node_sections.jl") +# Polygon node converter: after node sections (rewrites a polygon's +# NodeSection group into maximal-ring structure for `create_node`). +include("methods/geom_relations/relateng/polygon_node_converter.jl") +# Node-edge topology: after node sections and the converter (`create_node` +# assembles a RelateNode from converted sections). +include("methods/geom_relations/relateng/relate_node.jl") +# Indexed point-in-area location: after the kernel (uses `rk_orient`, +# `_node_points` and the `LOC_` codes), before the point locator (a prepared +# RelatePointLocator caches these locators per polygonal element). +include("methods/geom_relations/relateng/indexed_point_in_area.jl") +# Point location: after the kernel (uses `_node_point` and de9im constants). +include("methods/geom_relations/relateng/point_locator.jl") +# Input facade: after the point locator (RelateGeometry wraps a lazy +# RelatePointLocator) and node sections (RelateSegmentString creates them). +include("methods/geom_relations/relateng/relate_geometry.jl") +# Topology computer: after the input facade and node topology (it drives +# RelateGeometry locates, NodeSections grouping and RelateNode evaluation). +include("methods/geom_relations/relateng/topology_computer.jl") +# Edge intersector: feeds segment-pair intersections into the computer. +include("methods/geom_relations/relateng/edge_intersector.jl") +# The RelateNG engine: drives all of the above through the phased evaluation. +include("methods/geom_relations/relateng/relate_ng.jl") include("methods/orientation.jl") include("methods/polygonize.jl") include("methods/minimum_bounding_circle.jl") @@ -106,6 +140,8 @@ include("transformations/forcedims.jl") include("transformations/correction/geometry_correction.jl") include("transformations/correction/closed_ring.jl") include("transformations/correction/intersecting_polygons.jl") +include("transformations/correction/antipodal_edge_split.jl") +include("transformations/correction/crossing_edge_split.jl") # Import all names from GeoInterface and Extents, so users can do `GO.extent` or `GO.trait`. for name in names(GeoInterface) @@ -122,4 +158,6 @@ function __init__() Base.Experimental.register_error_hint(_buffer_error_hinter, MethodError) end +include("precompile.jl") + end diff --git a/src/methods/area.jl b/src/methods/area.jl index 32d369eabd..6327e8e52e 100644 --- a/src/methods/area.jl +++ b/src/methods/area.jl @@ -219,11 +219,19 @@ function _spherical_triangle_area(::Girard, p1::UnitSphericalPoint, p2::UnitSphe end # Using Eriksson's formula for the area of spherical triangles: https://www.jstor.org/stable/2691141 +# This is the Van Oosterom–Strackee tangent-half-angle form, kept natively SIGNED so that +# reflex triangles in a fan triangulation subtract (concave rings) instead of adding. +# `numerator` is the raw signed triple product a ⋅ (b × c); `denominator` is 1 + a⋅b + b⋅c + c⋅a. +# The `(b - a, c - a)` / `(b + a, c + a)` rewrites are algebraically identical to the commented +# lines below but better conditioned for tiny triangles (Eriksson 1990), so small-polygon +# accuracy is preserved. `atan2` also branches correctly when `denominator ≤ 0` (large fan +# triangles whose true |area| exceeds π), which `atan(abs(t))` cannot represent. function _spherical_triangle_area(::Eriksson, a::UnitSphericalPoint, b::UnitSphericalPoint, c::UnitSphericalPoint) - #t = abs(dot(a, cross(b, c))) - #t /= 1 + dot(b,c) + dot(c, a) + dot(a, b) - t = abs(dot(a, (cross(b - a, c - a))) / dot(b + a, c + a)) - return 2*atan(t) + #numerator = dot(a, cross(b, c)) + #denominator = 1 + dot(b,c) + dot(c, a) + dot(a, b) + numerator = dot(a, (cross(b - a, c - a))) + denominator = dot(b + a, c + a) + return 2 * atan(numerator, denominator) end diff --git a/src/methods/extent.jl b/src/methods/extent.jl new file mode 100644 index 0000000000..f36f9abe7b --- /dev/null +++ b/src/methods/extent.jl @@ -0,0 +1,105 @@ +# # Extent + +#= +```@docs; canonical=false +Extents.extent(::Manifold, ::Any) +``` + +`Extents.extent(m::Manifold, geom)` computes the extent of a geometry *on a +manifold*. On `Planar()` it delegates to `GI.extent`. On `Spherical()` it +returns 3D Cartesian `Extent{(:X, :Y, :Z)}`s on the unit sphere — the boxes +that spatial indices over spherical geometry prune with. + +An extremum of a coordinate over a *region* on the sphere lies either on +the boundary or at an on-sphere critical point of that coordinate — across +the three coordinates, the six axis points `(±1,0,0)`, `(0,±1,0)`, +`(0,0,±1)`. A region's box is therefore the union of its edges' +[`UnitSpherical.spherical_arc_extent`](@ref)s plus an enclosure check per +axis point, decided by crossing parity as in `S2Loop::InitBound` +(`s2loop.cc`). + +Which of the two ring-bounded regions a ring denotes follows the +manifold's interior mode (see [`Spherical`](@ref)): by default the +ENCLOSED region, independent of winding; under `oriented = true` the +region on the left of the stored vertex order (S2's loop convention, +`s2loop.h`), so there a clockwise ring bounds the complement. +Configurations too close to degenerate for +[`UnitSpherical.spherical_orient`](@ref) to call are retried with the next +edge as anchor; if every anchor fails, the axis is extended to `±1`, so +the box can come out loose but never under-covers. +=# + +""" + extent(m::Manifold, geom, [::Type{T} = Float64])::Extents.Extent + +The extent of `geom` on the manifold `m`, as an `Extents.Extent`. The +method extends `Extents.extent` (GeometryOps does not export `extent`), so +call it as `GO.extent(m, geom)`. + +On `Planar()`, `GI.extent(geom)`. On `Spherical()`, the 3D Cartesian +extent of the geometry on the unit sphere, with geographic (longitude, +latitude) input converted like `UnitSphericalPoint`: curves are covered by +the union of their edges' great-circle arc extents; rings and polygons are +regions, whose extent also covers any enclosed axis point (a pole, say). +Which region a ring bounds follows the manifold's interior mode (see +[`Spherical`](@ref)): by default the region it encloses, independent of +winding; with `Spherical(; oriented = true)` the region on the left of +the stored vertex order, so a clockwise ring denotes the complement (and +gets a box covering essentially the whole sphere). + +## Example + +```jldoctest +julia> import GeometryOps as GO, GeoInterface as GI + +julia> cap = GI.Polygon([[(lon, 60.0) for lon in 0.0:30.0:360.0]]); # around the north pole + +julia> GO.extent(GO.Spherical(), cap).Z[2] +1.0 +``` +""" +function Extents.extent(m::Manifold, geom, ::Type{T} = Float64) where T + return _extent(m, GI.trait(geom), geom, T) +end + +_extent(::Planar, trait, geom, ::Type{T}) where T = GI.extent(geom) + +_extent(::Spherical, ::GI.PointTrait, geom, ::Type{T}) where T = + GI.extent(UnitSpherical.UnitSphericalPoint(geom)) +_extent(m::Spherical, ::Union{GI.LineTrait, GI.LineStringTrait}, geom, ::Type{T}) where T = + mapreduce(GI.extent, Extents.union, lazy_edgelist(m, geom, T)) +# rings are regions: put the denoted region on the ring's left — a flip of +# a copy for a CW ring in the default (enclosed-region) mode, a no-op under +# `oriented = true` — since `_spherical_region_extent` bounds the left region +function _extent(m::Spherical, ::GI.LinearRingTrait, geom, ::Type{T}) where T + pts = _orient_ring(m, UnitSpherical.to_unit_spherical_points(geom), false, false; + exact = True()) + return _spherical_region_extent(pts) +end +_extent(m::Spherical, ::GI.PolygonTrait, geom, ::Type{T}) where T = + _extent(m, GI.LinearRingTrait(), GI.getexterior(geom), T) +# multi-geometries and collections; holes never extend a polygon's extent, +# so only the exterior ring above matters +_extent(m::Spherical, ::GI.AbstractGeometryTrait, geom, ::Type{T}) where T = + mapreduce(g -> Extents.extent(m, g, T), Extents.union, GI.getgeom(geom)) + +function _spherical_region_extent(pts::Vector{<:UnitSpherical.UnitSphericalPoint}) + n = length(pts) + n > 1 && pts[end] == pts[1] && (n -= 1) + ext = mapreduce(Extents.union, 1:n) do i + UnitSpherical.spherical_arc_extent(pts[i], pts[mod1(i + 1, n)]) + end + n < 3 && return ext + + lo = MVector(ext.X[1], ext.Y[1], ext.Z[1]) + hi = MVector(ext.X[2], ext.Y[2], ext.Z[2]) + for i in 1:3, s in (1.0, -1.0) + q = UnitSpherical.UnitSphericalPoint(ntuple(j -> j == i ? s : 0.0, 3)) + inside = UnitSpherical.spherical_ring_contains(pts, n, q) + # nothing (undecidable) extends too; the box must never under-cover + if inside === nothing || inside + s > 0 ? (hi[i] = one(hi[i])) : (lo[i] = -one(lo[i])) + end + end + return Extents.Extent(X = (lo[1], hi[1]), Y = (lo[2], hi[2]), Z = (lo[3], hi[3])) +end diff --git a/src/methods/geom_relations/relateng/de9im.jl b/src/methods/geom_relations/relateng/de9im.jl new file mode 100644 index 0000000000..3ef592150f --- /dev/null +++ b/src/methods/geom_relations/relateng/de9im.jl @@ -0,0 +1,292 @@ +# # DE-9IM matrix, location and dimension codes +export DE9IM +export Mod2Boundary, EndpointBoundary, MultivalentEndpointBoundary, MonovalentEndpointBoundary +#= +Port of code-level concepts from JTS: `Location`, `Dimension`, +`IntersectionMatrix` (pattern matching only), and +`operation/relateng/DimensionLocation.java`. +The `DE9IM` struct is immutable and isbits (`NTuple{9, Int8}`). +=# + +# Locations (JTS Location.java) +const LOC_INTERIOR = Int8(0) +const LOC_BOUNDARY = Int8(1) +const LOC_EXTERIOR = Int8(2) +const LOC_NONE = Int8(-1) + +# Dimensions (JTS Dimension.java) +const DIM_FALSE = Int8(-1) # 'F' +const DIM_TRUE = Int8(-2) # 'T' (patterns only) +const DIM_DONTCARE = Int8(-3) # '*' (patterns only) +const DIM_P = Int8(0) +const DIM_L = Int8(1) +const DIM_A = Int8(2) + +function dim_char(d::Integer) + d == DIM_FALSE && return 'F' + d == DIM_TRUE && return 'T' + d == DIM_DONTCARE && return '*' + (0 <= d <= 2) && return Char('0' + d) + throw(ArgumentError("invalid dimension code $d")) +end + +function dim_code(c::AbstractChar) + c = uppercase(c) + c == 'F' && return DIM_FALSE + c == 'T' && return DIM_TRUE + c == '*' && return DIM_DONTCARE + ('0' <= c <= '2') && return Int8(c - '0') + throw(ArgumentError("invalid DE-9IM character '$c'")) +end + +# DimensionLocation codes (JTS DimensionLocation.java, verbatim values) +const DL_EXTERIOR = LOC_EXTERIOR # 2 +const DL_POINT_INTERIOR = Int8(103) +const DL_LINE_INTERIOR = Int8(110) +const DL_LINE_BOUNDARY = Int8(111) +const DL_AREA_INTERIOR = Int8(120) +const DL_AREA_BOUNDARY = Int8(121) + +dimloc_point(loc::Integer) = loc == LOC_INTERIOR ? DL_POINT_INTERIOR : DL_EXTERIOR +function dimloc_line(loc::Integer) + loc == LOC_INTERIOR && return DL_LINE_INTERIOR + loc == LOC_BOUNDARY && return DL_LINE_BOUNDARY + return DL_EXTERIOR +end +function dimloc_area(loc::Integer) + loc == LOC_INTERIOR && return DL_AREA_INTERIOR + loc == LOC_BOUNDARY && return DL_AREA_BOUNDARY + return DL_EXTERIOR +end +# Explicit switch ports of JTS `DimensionLocation.location` / `.dimension` +# (a `dimloc % 10` shortcut would mis-map `DL_POINT_INTERIOR == 103`). +function dimloc_location(dimloc::Integer) + (dimloc == DL_POINT_INTERIOR || dimloc == DL_LINE_INTERIOR || dimloc == DL_AREA_INTERIOR) && return LOC_INTERIOR + (dimloc == DL_LINE_BOUNDARY || dimloc == DL_AREA_BOUNDARY) && return LOC_BOUNDARY + return LOC_EXTERIOR +end +function dimloc_dimension(dimloc::Integer) + dimloc == DL_POINT_INTERIOR && return DIM_P + (dimloc == DL_LINE_INTERIOR || dimloc == DL_LINE_BOUNDARY) && return DIM_L + (dimloc == DL_AREA_INTERIOR || dimloc == DL_AREA_BOUNDARY) && return DIM_A + return DIM_FALSE +end +function dimloc_dimension(dimloc::Integer, exterior_dim::Integer) + dimloc == DL_EXTERIOR && return Int8(exterior_dim) + return dimloc_dimension(dimloc) +end + +""" + DE9IM + +An immutable DE-9IM intersection matrix. Entries are dimension codes +(`DIM_FALSE`, `DIM_P`, `DIM_L`, `DIM_A`) stored row-major over +(Interior, Boundary, Exterior) of A × B, matching the standard string +form `"212101212"`. Construct from a 9-character string or empty +(all-`F`) via `DE9IM()`. Index with `im[locA, locB]`, where the indices are +the JTS location *codes* (`0` = Interior, `1` = Boundary, `2` = Exterior — the +internal `LOC_*` constants), **not** 1-based array positions: `im[0, 0]` is the +Interior/Interior entry. +""" +struct DE9IM + entries::NTuple{9, Int8} +end +DE9IM() = DE9IM(ntuple(_ -> DIM_FALSE, 9)) +function DE9IM(s::AbstractString) + length(s) == 9 || throw(ArgumentError("DE-9IM string must have 9 characters, got $(repr(s))")) + return DE9IM(ntuple(i -> dim_code(s[i]), 9)) +end + +@inline im_index(locA::Integer, locB::Integer) = 3 * Int(locA) + Int(locB) + 1 +Base.getindex(im::DE9IM, locA::Integer, locB::Integer) = im.entries[im_index(locA, locB)] +with_entry(im::DE9IM, locA::Integer, locB::Integer, dim::Integer) = + DE9IM(Base.setindex(im.entries, Int8(dim), im_index(locA, locB))) + +# `string(im)` and `"$im"` yield the standard 9-character matrix form via +# this `print`; `show` keeps the constructor form for the REPL. +Base.print(io::IO, im::DE9IM) = join(io, (dim_char(d) for d in im.entries)) +Base.show(io::IO, im::DE9IM) = print(io, "DE9IM(\"", string(im), "\")") + +"Match a single matrix entry against a pattern code (JTS `IntersectionMatrix.matches`)." +function matches_entry(dim::Int8, pat::Int8) + pat == DIM_DONTCARE && return true + pat == DIM_TRUE && return dim >= 0 || dim == DIM_TRUE + return dim == pat +end + +function im_matches(im::DE9IM, pattern::AbstractString) + length(pattern) == 9 || throw(ArgumentError("DE-9IM pattern must have 9 characters, got $(repr(pattern))")) + return all(matches_entry(im.entries[i], dim_code(pattern[i])) for i in 1:9) +end + +#= +## Relate queries on a DE-9IM matrix + +Ports of the JTS `IntersectionMatrix` named-relationship test methods +(`isContains`, `isWithin`, `isCovers`, `isCoveredBy`, `isCrosses`, +`isEquals`, `isOverlaps`, `isTouches`); `im_matches` above is the port of +`IntersectionMatrix.matches`. These are used as the `value_im` +implementations of the named `IMPredicate` kinds in +`relate_predicates.jl`. +=# + +# JTS `IntersectionMatrix.isTrue`: an actual dimension value >= 0 is "true" +# ('T' only occurs in patterns, but is accepted for parity with JTS). +im_is_true(dim::Integer) = dim >= 0 || dim == DIM_TRUE + +# Matches `[T*****FF*]`. +is_contains(im::DE9IM) = + im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) && + im[LOC_EXTERIOR, LOC_INTERIOR] == DIM_FALSE && + im[LOC_EXTERIOR, LOC_BOUNDARY] == DIM_FALSE + +# Matches `[T*F**F***]`. +is_within(im::DE9IM) = + im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) && + im[LOC_INTERIOR, LOC_EXTERIOR] == DIM_FALSE && + im[LOC_BOUNDARY, LOC_EXTERIOR] == DIM_FALSE + +# Matches `[T*****FF*]`, `[*T****FF*]`, `[***T**FF*]` or `[****T*FF*]`. +function is_covers(im::DE9IM) + has_point_in_common = + im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) || + im_is_true(im[LOC_INTERIOR, LOC_BOUNDARY]) || + im_is_true(im[LOC_BOUNDARY, LOC_INTERIOR]) || + im_is_true(im[LOC_BOUNDARY, LOC_BOUNDARY]) + return has_point_in_common && + im[LOC_EXTERIOR, LOC_INTERIOR] == DIM_FALSE && + im[LOC_EXTERIOR, LOC_BOUNDARY] == DIM_FALSE +end + +# Matches `[T*F**F***]`, `[*TF**F***]`, `[**FT*F***]` or `[**F*TF***]`. +function is_coveredby(im::DE9IM) + has_point_in_common = + im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) || + im_is_true(im[LOC_INTERIOR, LOC_BOUNDARY]) || + im_is_true(im[LOC_BOUNDARY, LOC_INTERIOR]) || + im_is_true(im[LOC_BOUNDARY, LOC_BOUNDARY]) + return has_point_in_common && + im[LOC_INTERIOR, LOC_EXTERIOR] == DIM_FALSE && + im[LOC_BOUNDARY, LOC_EXTERIOR] == DIM_FALSE +end + +# Matches `[T*T******]` (P/L, P/A, L/A), `[T*****T**]` (L/P, A/P, A/L) +# or `[0********]` (L/L); false for any other dimension combination. +function is_crosses(im::DE9IM, dimA::Integer, dimB::Integer) + if (dimA == DIM_P && dimB == DIM_L) || + (dimA == DIM_P && dimB == DIM_A) || + (dimA == DIM_L && dimB == DIM_A) + return im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) && + im_is_true(im[LOC_INTERIOR, LOC_EXTERIOR]) + end + if (dimA == DIM_L && dimB == DIM_P) || + (dimA == DIM_A && dimB == DIM_P) || + (dimA == DIM_A && dimB == DIM_L) + return im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) && + im_is_true(im[LOC_EXTERIOR, LOC_INTERIOR]) + end + if dimA == DIM_L && dimB == DIM_L + return im[LOC_INTERIOR, LOC_INTERIOR] == DIM_P + end + return false +end + +# Dimensions equal and matrix matches `[T*F**FFF*]`. (JTS deliberately +# deviates from the SFS pattern `[TFFFTFFFT]` so identical points are equal.) +function is_equals(im::DE9IM, dimA::Integer, dimB::Integer) + dimA != dimB && return false + return im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) && + im[LOC_INTERIOR, LOC_EXTERIOR] == DIM_FALSE && + im[LOC_BOUNDARY, LOC_EXTERIOR] == DIM_FALSE && + im[LOC_EXTERIOR, LOC_INTERIOR] == DIM_FALSE && + im[LOC_EXTERIOR, LOC_BOUNDARY] == DIM_FALSE +end + +# Matches `[T*T***T**]` (P/P, A/A) or `[1*T***T**]` (L/L). +function is_overlaps(im::DE9IM, dimA::Integer, dimB::Integer) + if (dimA == DIM_P && dimB == DIM_P) || (dimA == DIM_A && dimB == DIM_A) + return im_is_true(im[LOC_INTERIOR, LOC_INTERIOR]) && + im_is_true(im[LOC_INTERIOR, LOC_EXTERIOR]) && + im_is_true(im[LOC_EXTERIOR, LOC_INTERIOR]) + end + if dimA == DIM_L && dimB == DIM_L + return im[LOC_INTERIOR, LOC_INTERIOR] == DIM_L && + im_is_true(im[LOC_INTERIOR, LOC_EXTERIOR]) && + im_is_true(im[LOC_EXTERIOR, LOC_INTERIOR]) + end + return false +end + +# Matches `[FT*******]`, `[F**T*****]` or `[F***T****]`; +# false if both geometries are points. +function is_touches(im::DE9IM, dimA::Integer, dimB::Integer) + if dimA > dimB + # no need to get transpose because the pattern matrix is symmetrical + return is_touches(im, dimB, dimA) + end + if (dimA == DIM_A && dimB == DIM_A) || + (dimA == DIM_L && dimB == DIM_L) || + (dimA == DIM_L && dimB == DIM_A) || + (dimA == DIM_P && dimB == DIM_A) || + (dimA == DIM_P && dimB == DIM_L) + return im[LOC_INTERIOR, LOC_INTERIOR] == DIM_FALSE && + (im_is_true(im[LOC_INTERIOR, LOC_BOUNDARY]) || + im_is_true(im[LOC_BOUNDARY, LOC_INTERIOR]) || + im_is_true(im[LOC_BOUNDARY, LOC_BOUNDARY])) + end + return false +end + +# Boundary node rules (JTS BoundaryNodeRule.java). Zero-size structs. +""" + BoundaryNodeRule + +Abstract supertype for rules deciding which endpoints of a linear geometry +are on its boundary, given the number of line ends meeting at the point +(port of JTS `BoundaryNodeRule`). Concrete rules are [`Mod2Boundary`](@ref) +(the OGC SFS default), [`EndpointBoundary`](@ref), +[`MultivalentEndpointBoundary`](@ref), and +[`MonovalentEndpointBoundary`](@ref); each implements +`is_in_boundary(rule, boundary_count)`. +""" +abstract type BoundaryNodeRule end +""" + Mod2Boundary() + +The OGC SFS standard [`BoundaryNodeRule`](@ref) (and the RelateNG default): +an endpoint is on the boundary iff an odd number of line ends meet it +(the "Mod-2 rule"; JTS `Mod2BoundaryNodeRule`). +""" +struct Mod2Boundary <: BoundaryNodeRule end + +""" + EndpointBoundary() + +[`BoundaryNodeRule`](@ref) under which any endpoint of a line is on the +boundary, regardless of how many line ends meet there (JTS +`EndPointBoundaryNodeRule`). +""" +struct EndpointBoundary <: BoundaryNodeRule end + +""" + MultivalentEndpointBoundary() + +[`BoundaryNodeRule`](@ref) under which an endpoint is on the boundary iff +**more than one** line end meets it (JTS +`MultiValentEndPointBoundaryNodeRule`). +""" +struct MultivalentEndpointBoundary <: BoundaryNodeRule end + +""" + MonovalentEndpointBoundary() + +[`BoundaryNodeRule`](@ref) under which an endpoint is on the boundary iff +**exactly one** line end meets it (JTS +`MonoValentEndPointBoundaryNodeRule`). +""" +struct MonovalentEndpointBoundary <: BoundaryNodeRule end + +is_in_boundary(::Mod2Boundary, boundary_count::Integer) = isodd(boundary_count) +is_in_boundary(::EndpointBoundary, boundary_count::Integer) = boundary_count > 0 +is_in_boundary(::MultivalentEndpointBoundary, boundary_count::Integer) = boundary_count > 1 +is_in_boundary(::MonovalentEndpointBoundary, boundary_count::Integer) = boundary_count == 1 diff --git a/src/methods/geom_relations/relateng/edge_intersector.jl b/src/methods/geom_relations/relateng/edge_intersector.jl new file mode 100644 index 0000000000..06d3d36e39 --- /dev/null +++ b/src/methods/geom_relations/relateng/edge_intersector.jl @@ -0,0 +1,446 @@ +# # RelateNG edge segment intersector +# +# Port of JTS `EdgeSegmentIntersector.java`: tests segments of +# [`RelateSegmentString`](@ref)s and, if they intersect, adds the +# intersection(s) to the [`TopologyComputer`](@ref). +# +# The Java class wraps a `RobustLineIntersector`, which *constructs* the +# intersection coordinate(s) and then filters endpoint intersections through +# `RelateSegmentString.isContainingSegment` so each one is processed once +# only, on its canonical segments. Here the construction is replaced by the +# symbolic classification [`rk_classify_intersection`](@ref) (design D2): +# +# - `SS_PROPER` ↔ `li.isProper()`: the node is identified by its +# [`crossing_node`](@ref) key (the canonicalized defining segment pair) — +# no coordinate is ever computed. Proper intersections lie on a unique +# segment pair, so (as in Java) no canonicality check is needed. +# - `SS_TOUCH`/`SS_COLLINEAR` ↔ the non-proper point and collinear outcomes: +# every JTS intersection point is an *input vertex* lying on the other +# segment, which is exactly what the `SegSegClass` incidence flags +# (`a0_on_b`, `a1_on_b`, `b0_on_a`, `b1_on_a`) report. Each true flag is a +# candidate intersection point; flags naming the same coordinate (e.g. a +# shared endpoint `a0 == b0` sets both `a0_on_b` and `b0_on_a`) denote ONE +# geometric point. A collinear overlap has up to two distinct points (the +# ends of the overlap interval), matching `li.getIntersectionNum() == 2`. +# +# Per distinct intersection point the Java loop adds one `NodeSection` on +# ssA AND one on ssB (`topologyComputer.addIntersection(nsa, nsb)`); the +# point is processed only if BOTH strings contain it canonically. +# +# The Java `isDone()` (the noder's early-exit hook) is `is_result_known` on +# the computer; the Task 20 enumerator consults it directly. +# +# Method order parallels the Java file, so this file diffs against its +# counterpart. + +""" + process_intersections!(tc::TopologyComputer, ss0, seg_index0, ss1, seg_index1; m, exact) + +Classify the intersection of segment `seg_index0` of `ss0` with segment +`seg_index1` of `ss1` and record any intersections in `tc`. The strings are +ordered so the A geometry's string is processed first (the computer's A/B +matrix updates rely on it). + +Port of EdgeSegmentIntersector.processIntersections. +""" +function process_intersections!(tc::TopologyComputer, + ss0::RelateSegmentString, seg_index0::Integer, + ss1::RelateSegmentString, seg_index1::Integer; + m::Manifold = _manifold(tc), exact = _exact(tc)) + #-- don't intersect a segment with itself + ss0 === ss1 && seg_index0 == seg_index1 && return nothing + + if ss0.is_a + add_intersections!(tc, ss0, seg_index0, ss1, seg_index1; m, exact) + else + add_intersections!(tc, ss1, seg_index1, ss0, seg_index0; m, exact) + end + return nothing +end + +""" + add_intersections!(tc::TopologyComputer, ssA, seg_index_a, ssB, seg_index_b; m, exact) + +Classify the intersection of one segment pair via +`rk_classify_intersection` and add a [`NodeSection`](@ref) pair (one +on `ssA`, one on `ssB`) to `tc` for each distinct intersection point: + +- `SS_DISJOINT`: nothing to add. +- `SS_PROPER`: one section pair at the symbolic [`crossing_node`](@ref). +- `SS_TOUCH`/`SS_COLLINEAR`: a section pair at the [`vertex_node`](@ref) of + each distinct flagged vertex — but only when both segments contain the + vertex canonically ([`_is_canonical_incidence`](@ref)), which ensures + endpoint intersections are added once only across adjacent segments. + +Port of EdgeSegmentIntersector.addIntersections (private). +""" +function add_intersections!(tc::TopologyComputer, + ssA::RelateSegmentString, seg_index_a::Integer, + ssB::RelateSegmentString, seg_index_b::Integer; + m::Manifold = _manifold(tc), exact = _exact(tc)) + a0 = ssA.pts[seg_index_a] + a1 = ssA.pts[seg_index_a + 1] + b0 = ssB.pts[seg_index_b] + b1 = ssB.pts[seg_index_b + 1] + + cls = rk_classify_intersection(m, a0, a1, b0, b1; exact) + + cls.kind == SS_DISJOINT && return nothing + + if cls.kind == SS_PROPER + #-- a proper intersection lies on a unique segment pair, so it needs + #-- no canonicality check (and its node is purely symbolic) + node = crossing_node(a0, a1, b0, b1) + nsa = create_node_section(ssA, seg_index_a, node) + nsb = create_node_section(ssB, seg_index_b, node) + add_intersection!(tc, nsa, nsb) + return nothing + end + + #-- SS_TOUCH / SS_COLLINEAR: each flagged vertex is a candidate + #-- intersection point. Flags naming the same coordinate (a shared + #-- endpoint flags both strings) are deduped via the normalized vertex + #-- key; at most two distinct points exist (a collinear overlap's ends). + seen1 = seen2 = nothing + for (flag, pt) in ((cls.a0_on_b, a0), (cls.a1_on_b, a1), + (cls.b0_on_a, b0), (cls.b1_on_a, b1)) + flag || continue + node = vertex_node(pt) + (node === seen1 || node === seen2) && continue + if seen1 === nothing + seen1 = node + else + seen2 = node + end + #-- ensure endpoint intersections are added once only, for their + #-- canonical segments + if _is_canonical_incidence(ssA, seg_index_a, pt) && + _is_canonical_incidence(ssB, seg_index_b, pt) + nsa = create_node_section(ssA, seg_index_a, node) + nsb = create_node_section(ssB, seg_index_b, node) + add_intersection!(tc, nsa, nsb) + end + end + return nothing +end + +""" + _is_canonical_incidence(ss::RelateSegmentString, seg_index::Integer, pt) + +The once-only rule for vertex incidences: whether segment `seg_index` of +`ss` is the canonical owner of the intersection point `pt`. Segments are +half-closed — a segment owns its start vertex but not its end vertex, except +for the final segment of a non-closed string, which also owns its endpoint; +in a closed ring the closing vertex is owned by the first segment +(wraparound). This attributes every vertex of a segment string to exactly +one of its segments, so an endpoint intersection enumerated against both +incident segments produces node sections once, not twice. + +Encodes the canonicality semantics of the Java +`addIntersections`/`RelateSegmentString.isContainingSegment` pairing; +delegates to [`is_containing_segment`](@ref) (its direct port). +""" +_is_canonical_incidence(ss::RelateSegmentString, seg_index::Integer, pt) = + is_containing_segment(ss, seg_index, pt) + +#========================================================================== +## Edge set enumeration + +Replaces JTS `EdgeSetIntersector.java` (HPRtree + monotone chains) and the +mutual A x B pruning of `MCIndexSegmentSetMutualIntersector`: enumerate +every segment pair (one segment from an A string, one from a B string) +whose extents interact and feed it through `process_intersections!`. + +The accelerator strategy mirrors the clipping pattern +(`foreach_pair_of_maybe_intersecting_edges_in_order` in +clipping_processor.jl), reusing its `IntersectionAccelerator` types and the +`GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS` size threshold. + +The Java noder's `isDone()` early-exit hook lands here: after each +processed pair the enumerator consults `is_result_known(computer)` and +stops as soon as the predicate value is determined. On the tree path the +traversal is terminated by returning `Action(:full_return, nothing)` from +the callback — `dual_depth_first_search` processes `LoopStateMachine` +actions via `@controlflow`, and `:full_return` propagates out of the whole +recursion (a plain `:break` would only exit the innermost leaf loop), so +no exception is needed. +==========================================================================# + +""" + process_edge_intersections!(computer, ssa_list, ssb_list, + accelerator = AutoAccelerator(); m, exact) + +Enumerate all extent-interacting segment pairs between the A-side segment +strings `ssa_list` and the B-side segment strings `ssb_list`, feeding each +pair through [`process_intersections!`](@ref) so the intersections are +recorded on `computer`. + +`accelerator` selects the enumeration strategy: + +- `NestedLoop`: a plain double loop over + string pairs and segment pairs, with a per-pair segment-extent + disjointness skip (on `Planar`). +- Any tree-backed accelerator (canonically `DoubleNaturalTree`): a spatial + index (`_relate_edge_index`, a natural-order `RTree` carrying segment + owners as data) is built over the per-segment extents of each side and + traversed with `SpatialTreeInterface.dual_depth_first_search` + under the `Extents.intersects` predicate. +- [`AutoAccelerator`](@ref): picks `NestedLoop` below the clipping size + threshold (`GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS`) and on + manifolds without a segment-extent kernel (neither `Planar` nor + `Spherical`), and the tree path otherwise. + +After each processed pair `is_result_known(computer)` is consulted and the +enumeration stops early once the predicate value is determined (the port of +the Java noder's `isDone()` hook used by `EdgeSetIntersector.process`). + +!!! warning + This enumerates A×B pairs only. JTS's `EdgeSetIntersector` also feeds + A×A and B×B pairs (self-noding) with an id-ordering guard so each + unordered pair is processed once. Calling this with the same list on + both sides would process every pair twice — the engine's + `computeAtEdges` port uses [`process_self_intersections!`](@ref) for + the self-pair path instead. +""" +process_edge_intersections!(tc::TopologyComputer, + ssa_list::AbstractVector{<:RelateSegmentString}, + ssb_list::AbstractVector{<:RelateSegmentString}; + m::Manifold = _manifold(tc), exact = _exact(tc)) = + process_edge_intersections!(tc, ssa_list, ssb_list, AutoAccelerator(); m, exact) + +# AutoAccelerator: pick the strategy from the manifold and the segment +# counts, mirroring the clipping selection +# (`foreach_pair_of_maybe_intersecting_edges_in_order(::AutoAccelerator)`). +function process_edge_intersections!(tc::TopologyComputer, + ssa_list::AbstractVector{<:RelateSegmentString}, + ssb_list::AbstractVector{<:RelateSegmentString}, + ::AutoAccelerator; + m::Manifold = _manifold(tc), exact = _exact(tc)) + return process_edge_intersections!(tc, ssa_list, ssb_list, + _select_edge_set_accelerator(m, ssa_list, ssb_list); m, exact) +end + +# Below the clipping threshold the nested loop wins; above it, the tree +# path. Valid on both manifolds with a segment-extent kernel: planar boxes +# on `Planar`, 3D great-circle arc extents (`_segment_extent`) on +# `Spherical` — the `RTree`, the dual DFS, and `Extents.intersects` are +# dimension-generic. Other manifolds have no extent kernel and always take +# the nested loop. +function _select_edge_set_accelerator(::Union{Planar, Spherical}, ssa_list, ssb_list) + na = _total_segment_count(ssa_list) + nb = _total_segment_count(ssb_list) + if na < GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS && + nb < GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS + return NestedLoop() + else + return DoubleNaturalTree() + end +end +_select_edge_set_accelerator(::Manifold, ssa_list, ssb_list) = NestedLoop() + +_total_segment_count(ss_list) = + sum(ss -> length(ss.pts) - 1, ss_list; init = 0) + +# NestedLoop path: double loop over string pairs x segment pairs, skipping +# pairs whose segment extents are disjoint (the pruning that the monotone +# chains of Java's MCIndexSegmentSetMutualIntersector provide). +function process_edge_intersections!(tc::TopologyComputer, + ssa_list::AbstractVector{<:RelateSegmentString}, + ssb_list::AbstractVector{<:RelateSegmentString}, + ::NestedLoop; + m::Manifold = _manifold(tc), exact = _exact(tc)) + for ssa in ssa_list, ssb in ssb_list + for ia in 1:(length(ssa.pts) - 1) + a0 = ssa.pts[ia] + a1 = ssa.pts[ia + 1] + for ib in 1:(length(ssb.pts) - 1) + b0 = ssb.pts[ib] + b1 = ssb.pts[ib + 1] + _segment_envs_disjoint(m, a0, a1, b0, b1) && continue + process_intersections!(tc, ssa, ia, ssb, ib; m, exact) + #-- the Java noder's isDone() early-exit hook + is_result_known(tc) && return nothing + end + end + end + return nothing +end + +# Per-pair extent pruning is a planar coordinate comparison; on other +# manifolds it could wrongly discard interacting pairs (e.g. across the +# antimeridian), so prune nothing there. +_segment_envs_disjoint(::Planar, a0, a1, b0, b1) = + min(a0[1], a1[1]) > max(b0[1], b1[1]) || + max(a0[1], a1[1]) < min(b0[1], b1[1]) || + min(a0[2], a1[2]) > max(b0[2], b1[2]) || + max(a0[2], a1[2]) < min(b0[2], b1[2]) +# On the sphere the per-segment extent is the bulge-aware 3D great-circle arc +# extent, which is exact in xyz and has no antimeridian pathology — so this +# prune is valid (and tighter than no pruning). +_segment_envs_disjoint(::Spherical, a0, a1, b0, b1) = + !Extents.intersects(spherical_arc_extent(a0, a1), spherical_arc_extent(b0, b1)) +_segment_envs_disjoint(::Manifold, a0, a1, b0, b1) = false + +# The spatial index built over per-segment extents for the tree-accelerated +# paths (here and in the prepared mode of relate_ng.jl), or `nothing` when +# the list has no segments. An `Unsorted` (natural-order) `RTree`: segments +# arrive in ring/line order, which is already spatially coherent, so the +# no-sort layout builds fastest (zero copies) while pruning the dual +# traversal almost as well. The tree carries each leaf's owner — +# (string index, segment index) — as its data, so a hit `i` maps back to a +# segment via `tree.data[i]`. +function _relate_edge_index(m::Manifold, ss_list) + extents, owners = _segment_extent_table(m, ss_list) + isempty(extents) && return nothing + return RTree(Unsorted(), owners; extents, nodecapacity = 16) +end + +# Tree path (any other accelerator, canonically DoubleNaturalTree): a spatial +# index over the per-segment extents of each side, traversed simultaneously. +function process_edge_intersections!(tc::TopologyComputer, + ssa_list::AbstractVector{<:RelateSegmentString}, + ssb_list::AbstractVector{<:RelateSegmentString}, + ::IntersectionAccelerator; + m::Manifold = _manifold(tc), exact = _exact(tc)) + tree_a = _relate_edge_index(m, ssa_list) + tree_b = _relate_edge_index(m, ssb_list) + (tree_a === nothing || tree_b === nothing) && return nothing + SpatialTreeInterface.dual_depth_first_search(Extents.intersects, tree_a, tree_b) do ia, ib + (sa, ka) = tree_a.data[ia] + (sb, kb) = tree_b.data[ib] + process_intersections!(tc, ssa_list[sa], ka, ssb_list[sb], kb; m, exact) + #-- the Java noder's isDone() early-exit hook; :full_return + #-- propagates out of the whole dual traversal via @controlflow + is_result_known(tc) && return Action(:full_return, nothing) + return nothing + end + return nothing +end + +#========================================================================== +## Self-pair enumeration (the A×A / B×B side of JTS EdgeSetIntersector) + +When `is_self_noding_required(tc)` holds, JTS's `computeEdgesAll` puts the +edges of BOTH inputs into one `EdgeSetIntersector`, whose `process` visits +every unordered pair of distinct monotone chains exactly once (the +`testChain.getId() <= queryChain.getId()` guard) — i.e. all A×B pairs plus +the A×A and B×B self pairs. The mutual A×B pairs are handled by +`process_edge_intersections!` above; `process_self_intersections!` is the +guarded self-pair path for one side's list. + +The id-ordering guard becomes: unordered string pairs `si <= sj`, and +within a single string (`si == sj`) unordered segment pairs `ka < kb` +(never a segment with itself). Note JTS never compares a chain with +itself — safe there because a monotone chain cannot self-intersect; a +whole segment string can, so same-string segment pairs ARE enumerated +here. Trivial adjacent-segment endpoint touches are filtered by the same +canonical-incidence rule as in Java (`is_containing_segment`), so this +produces exactly the node sections the Java chain enumeration does. +==========================================================================# + +""" + process_self_intersections!(computer, ss_list, + accelerator = AutoAccelerator(); m, exact) + +Enumerate all extent-interacting segment pairs *within* the segment-string +list `ss_list` (each unordered pair once, never a segment against itself), +feeding each through [`process_intersections!`](@ref) — the self-noding +(A×A or B×B) counterpart of [`process_edge_intersections!`](@ref). + +After each processed pair `is_result_known(computer)` is consulted and the +enumeration stops early once the predicate value is determined. +""" +process_self_intersections!(tc::TopologyComputer, + ss_list::AbstractVector{<:RelateSegmentString}; + m::Manifold = _manifold(tc), exact = _exact(tc)) = + process_self_intersections!(tc, ss_list, AutoAccelerator(); m, exact) + +function process_self_intersections!(tc::TopologyComputer, + ss_list::AbstractVector{<:RelateSegmentString}, + ::AutoAccelerator; + m::Manifold = _manifold(tc), exact = _exact(tc)) + return process_self_intersections!(tc, ss_list, + _select_edge_set_accelerator(m, ss_list, ss_list); m, exact) +end + +# NestedLoop path: unordered string pairs si <= sj; within one string the +# segment pairs are also unordered (ka < kb). +function process_self_intersections!(tc::TopologyComputer, + ss_list::AbstractVector{<:RelateSegmentString}, + ::NestedLoop; + m::Manifold = _manifold(tc), exact = _exact(tc)) + for si in eachindex(ss_list) + ssa = ss_list[si] + for sj in si:lastindex(ss_list) + ssb = ss_list[sj] + for ia in 1:(length(ssa.pts) - 1) + a0 = ssa.pts[ia] + a1 = ssa.pts[ia + 1] + ib0 = si == sj ? ia + 1 : 1 + for ib in ib0:(length(ssb.pts) - 1) + b0 = ssb.pts[ib] + b1 = ssb.pts[ib + 1] + _segment_envs_disjoint(m, a0, a1, b0, b1) && continue + process_intersections!(tc, ssa, ia, ssb, ib; m, exact) + #-- the Java noder's isDone() early-exit hook + is_result_known(tc) && return nothing + end + end + end + end + return nothing +end + +# Tree path: one index over the per-segment extents, dual-traversed with +# itself; the flat-index ordering `ia < ib` is the unordered-pair guard +# (excluding a segment against itself). +function process_self_intersections!(tc::TopologyComputer, + ss_list::AbstractVector{<:RelateSegmentString}, + ::IntersectionAccelerator; + m::Manifold = _manifold(tc), exact = _exact(tc)) + tree = _relate_edge_index(m, ss_list) + tree === nothing && return nothing + SpatialTreeInterface.dual_depth_first_search(Extents.intersects, tree, tree) do ia, ib + ia < ib || return nothing + (sa, ka) = tree.data[ia] + (sb, kb) = tree.data[ib] + process_intersections!(tc, ss_list[sa], ka, ss_list[sb], kb; m, exact) + #-- the Java noder's isDone() early-exit hook; :full_return + #-- propagates out of the whole dual traversal via @controlflow + is_result_known(tc) && return Action(:full_return, nothing) + return nothing + end + return nothing +end + +# Flat per-segment extent list for a segment-string list, with the offset +# table mapping each flat index back to (string index, segment index). +# Per-segment extent in the manifold's coordinate space: a planar 2D box from +# the endpoints, or the bulge-aware 3D great-circle arc extent on the sphere +# (the endpoint box would miss a long arc's bulge and wrongly prune crossings). +_segment_extent(::Planar, p, q) = Extents.Extent(X = minmax(p[1], q[1]), Y = minmax(p[2], q[2])) +_segment_extent(::Spherical, p, q) = spherical_arc_extent(p, q) +_segment_extent_type(::Planar) = Extents.Extent{(:X, :Y), NTuple{2, NTuple{2, Float64}}} +_segment_extent_type(::Spherical) = Extents.Extent{(:X, :Y, :Z), NTuple{3, NTuple{2, Float64}}} + +function _segment_extent_table(m::Manifold, ss_list) + extents = _segment_extent_type(m)[] + owners = NTuple{2, Int}[] + nseg = _total_segment_count(ss_list) + sizehint!(extents, nseg) + sizehint!(owners, nseg) + for (si, ss) in enumerate(ss_list) + _push_segment_extents!(m, extents, owners, si, ss.pts) + end + return extents, owners +end + +# Function barrier: dispatch once per segment string, so the per-segment +# loop stays statically typed even if `ss_list` has a non-concrete eltype. +function _push_segment_extents!(m::Manifold, extents::Vector, owners::Vector, si::Int, pts::Vector) + for k in 1:(length(pts) - 1) + push!(extents, _segment_extent(m, pts[k], pts[k + 1])) + push!(owners, (si, k)) + end + return nothing +end diff --git a/src/methods/geom_relations/relateng/indexed_point_in_area.jl b/src/methods/geom_relations/relateng/indexed_point_in_area.jl new file mode 100644 index 0000000000..569900dd51 --- /dev/null +++ b/src/methods/geom_relations/relateng/indexed_point_in_area.jl @@ -0,0 +1,560 @@ +# # RelateNG indexed point-in-area location +# +# Prepared-mode point-in-area locator for RelateNG (Task 22). This file holds +# the ports of the two JTS classes behind prepared-mode point location +# (JTS file boundaries preserved as clearly marked sections): +# +# 1. `RayCrossingCounter` (JTS algorithm/RayCrossingCounter.java) +# 2. `IndexedPointInAreaLocator` (JTS algorithm/locate/IndexedPointInAreaLocator.java) +# +# `RelatePointLocator` (point_locator.jl) swaps this locator in for the +# SimplePointInAreaLocator ring loop when `is_prepared` is set, mirroring +# JTS `RelatePointLocator.getLocator`. +# +# JTS backs the locator with its 1D `SortedPackedIntervalRTree` over +# segment y-intervals; here that role is played by the shared +# `RTree(STR(), ...)` over 1-D `(Y,)` extents — sort-tile-recursive in one +# dimension IS the midpoint sort of JTS's `NodeComparator`, so the packed +# layout is the same idea with a wider fanout. The query is inherently +# 1-dimensional: the horizontal ray from the test point must visit *every* +# segment whose y-interval contains `p.y`, regardless of x (segments wholly +# left of the point are rejected inside `count_segment!`, exactly as in +# JTS), so a 2D index could not prune more candidates without changing the +# ray-crossing counting contract. The midpoint sort earns its cost in this +# index's only (prepared, build-once-query-forever) use: it groups same-`y` +# segments so a point query descends few subtrees, where insertion (ring) +# order recrosses the query `y` in many separated runs — measured on +# Natural Earth 10m Canada, ring-order queries are ~3× slower. + +#========================================================================== +## RayCrossingCounter (port of JTS RayCrossingCounter.java) +==========================================================================# + +""" + RayCrossingCounter(m::Manifold, p; exact) + +Counts the number of segments crossed by a horizontal ray extending to the +right from a given point, in an incremental fashion. This can be used to +determine whether a point lies in a polygonal geometry. The class determines +the situation where the point lies exactly on a segment. This handles +polygonal geometries with any number of shells and holes; ring orientation +is unimportant. In order to compute a correct location for a given polygonal +geometry, it is essential that **all** segments are counted which touch the +ray or lie in any ring which may contain the point — which is what allows +optimization by y-interval indexing, since segments whose y-extent misses +the ray a priori cannot touch it. + +The manifold `m` and the `exact` flag are stored in the struct (consistent +with [`AdjacentEdgeLocator`](@ref)); the orientation test goes through +`rk_orient` (JTS uses the extended-precision `Orientation.index`, matching +`exact = True()`). The horizontal-ray sweep itself is coordinate-plane +logic, exactly as in JTS. +""" +mutable struct RayCrossingCounter{M <: Manifold, E} + const m::M + const exact::E + const p::Tuple{Float64, Float64} + crossing_count::Int + # true if the test point lies on an input segment + is_point_on_segment::Bool +end + +RayCrossingCounter(m::Manifold, p; exact) = + RayCrossingCounter(m, exact, _node_point(p), 0, false) + +""" + count_segment!(rcc::RayCrossingCounter, p1, p2) + +Counts a segment with endpoints `p1`, `p2`. Port of +`RayCrossingCounter.countSegment`. +""" +function count_segment!(rcc::RayCrossingCounter, p1, p2) + px, py = rcc.p + p1x, p1y = GI.x(p1), GI.y(p1) + p2x, p2y = GI.x(p2), GI.y(p2) + #= + For each segment, check if it crosses a horizontal ray running from the + test point in the positive x direction. + =# + #-- check if the segment is strictly to the left of the test point + (p1x < px && p2x < px) && return nothing + #-- check if the point is equal to the current ring vertex + if px == p2x && py == p2y + rcc.is_point_on_segment = true + return nothing + end + #= + For horizontal segments, check if the point is on the segment. + Otherwise, horizontal segments are not counted. + =# + if p1y == py && p2y == py + minx, maxx = minmax(p1x, p2x) + if minx <= px <= maxx + rcc.is_point_on_segment = true + end + return nothing + end + #= + Evaluate all non-horizontal segments which cross a horizontal ray to the + right of the test pt. To avoid double-counting shared vertices, we use + the convention that + - an upward edge includes its starting endpoint, and excludes its final + endpoint + - a downward edge excludes its starting endpoint, and includes its final + endpoint + =# + if (p1y > py && p2y <= py) || (p2y > py && p1y <= py) + orient = rk_orient(rcc.m, (p1x, p1y), (p2x, p2y), rcc.p; exact = rcc.exact) + if orient == 0 + rcc.is_point_on_segment = true + return nothing + end + #-- re-orient the result if needed to ensure effective segment direction is upwards + if p2y < p1y + orient = -orient + end + #-- the upward segment crosses the ray if the test point lies to the left (CCW) of the segment + if orient > 0 + rcc.crossing_count += 1 + end + end + return nothing +end + +# Port of RayCrossingCounter.isOnSegment: whether the point lies exactly on +# one of the supplied segments. May be checked at any time as segments are +# processed; once true, the result never changes again. +is_on_segment(rcc::RayCrossingCounter) = rcc.is_point_on_segment + +# Port of RayCrossingCounter.getLocation: the `LOC_*` location of the point +# relative to the ring, polygon or multipolygon from which the processed +# segments were provided. Only correct once all relevant segments have been +# processed. +function rcc_location(rcc::RayCrossingCounter) + rcc.is_point_on_segment && return LOC_BOUNDARY + #-- the point is in the interior of the ring if the number of X-crossings is odd + return isodd(rcc.crossing_count) ? LOC_INTERIOR : LOC_EXTERIOR +end + +#========================================================================== +## IndexedPointInAreaLocator (port of JTS IndexedPointInAreaLocator.java) +==========================================================================# + +# Leaf item of the segment index: a ring segment as a pair of node points. +const _PIASegment = Tuple{Tuple{Float64, Float64}, Tuple{Float64, Float64}} +# The segment index: a midpoint-sorted packed tree over the segments' +# y-intervals (see the header note on how this maps to JTS's +# SortedPackedIntervalRTree). +const _PIAExtent = Extents.Extent{(:Y,), Tuple{NTuple{2, Float64}}} +const _PIAIndex = RTree{STR, _PIAExtent, Vector{_PIASegment}, Vector{Int}} + +# One polygon's cached kernel rings (spherical): shell plus holes, in the +# even-odd composition order of `_locate_point_in_polygonal`. +struct _SphPolyRings + shell::SphericalKernelRing + holes::Vector{SphericalKernelRing} +end + +# The spherical segment index (Layer 2 of the 2026-07-14 +# spherical-indexed-locator design): ring edges as kernel-point pairs, on +# 1-D *longitude* intervals in radians — the spherical isomorph of the +# planar y-interval trick with `Y → X (lon)` and `ray to -∞ → meridian arc +# to a pole`. An edge can only meet the reference meridian arc if its +# longitude span contains the query longitude. +const _SphPIASegment = Tuple{UnitSphericalPoint{Float64}, UnitSphericalPoint{Float64}} +const _SphPIAExtent = Extents.Extent{(:X,), Tuple{NTuple{2, Float64}}} +const _SphPIAIndex = RTree{STR, _SphPIAExtent, Vector{_SphPIASegment}, Vector{Int}} + +const _SPH_SOUTH_POLE = UnitSphericalPoint(0.0, 0.0, -1.0) +const _SPH_NORTH_POLE = UnitSphericalPoint(0.0, 0.0, 1.0) + +""" + IndexedPointInAreaLocator(m::Manifold, geom; exact) + +Determines the location (`LOC_*` code) of points relative to an areal +geometry, using indexing for efficiency. This algorithm is suitable for use +in cases where many points will be tested against a given area. The location +is computed precisely: points located on the geometry boundary or segments +return `LOC_BOUNDARY`. + +Port of JTS `IndexedPointInAreaLocator` together with its private +`IntervalIndexedGeometry` (the y-interval segment index; its `isEmpty` +flag is `index === nothing` here, since a recursively empty polygonal +geometry contributes no rings, hence no segments). JTS lazy-loads the +index on the first `locate`; here the index is built in the constructor, +since `RelatePointLocator` already creates the locator itself lazily on +the first use per polygonal element (`_get_poly_locator`, the port of +`RelatePointLocator.getLocator`). + +On `Spherical`, the locator caches the element's rings in kernel space +(`polys` — [`SphericalKernelRing`](@ref)s; Layer 1 of the 2026-07-14 +spherical-indexed-locator design), so queries never reconvert vertices, and +— with `indexed = true`, the default — builds the longitude-interval edge +index plus the parity anchor (Layer 2): the location of a reference pole, +computed once by the exact scan, from which each query is a 1-D stab at its +longitude and a crossing-parity count along its meridian arc to the pole. +With `indexed = false` (the unprepared arm, where a one-shot query cannot +amortize the build) it locates by the exact ring scan over the cached +rings. The `polys` vector is empty on `Planar`, where the corresponding +roles are played by `index` and the implicit EXTERIOR at the ray's far end. +""" +struct IndexedPointInAreaLocator{M <: Manifold, E} + m::M + exact::E + index::Union{Nothing, _PIAIndex, _SphPIAIndex} + polys::Vector{_SphPolyRings} + #-- spherical parity anchor: the reference-arc far end (a pole whose + #-- location the exact scan computed at build) and its location. + #-- `anchor_loc == LOC_BOUNDARY` means both poles lie on the element + #-- boundary: no index is built and every query takes the exact scan. + anchor::UnitSphericalPoint{Float64} + anchor_loc::Int8 +end + +function IndexedPointInAreaLocator(m::Planar, geom; exact) + exts = _PIAExtent[] + segs = _PIASegment[] + n = GI.npoint(geom) + sizehint!(exts, n); sizehint!(segs, n) + _interval_index_add_geom!(exts, segs, GI.trait(geom), geom) + index = isempty(segs) ? nothing : RTree(STR(), segs; extents = exts) + return IndexedPointInAreaLocator(m, exact, index, _SphPolyRings[], _SPH_SOUTH_POLE, LOC_EXTERIOR) +end + +function IndexedPointInAreaLocator(m::Spherical, geom; exact, indexed::Bool = true) + polys = _SphPolyRings[] + _sph_rings_add_geom!(polys, m, GI.trait(geom), geom; exact) + anchor, anchor_loc = _SPH_SOUTH_POLE, LOC_EXTERIOR + index = nothing + if indexed && !isempty(polys) + anchor, anchor_loc = _sph_pia_anchor(m, polys; exact) + if anchor_loc != LOC_BOUNDARY + index = _sph_lon_index(polys) + end + end + return IndexedPointInAreaLocator(m, exact, index, polys, anchor, anchor_loc) +end + +""" + locate(loc::IndexedPointInAreaLocator, p) + +The location (`LOC_*` code) of point `p` in the locator's areal geometry. +Port of `IndexedPointInAreaLocator.locate`. +""" +function locate(loc::IndexedPointInAreaLocator{<:Planar}, p) + index = loc.index + index === nothing && return LOC_EXTERIOR #-- IntervalIndexedGeometry.isEmpty + rcc = RayCrossingCounter(loc.m, p; exact = loc.exact) + y = rcc.p[2] + ray = Extents.Extent(Y = (y, y)) + #-- SegmentVisitor: count every segment whose y-interval touches the ray + SpatialTreeInterface.depth_first_search(Base.Fix1(Extents.intersects, ray), index) do i + seg = index.data[i] + count_segment!(rcc, seg[1], seg[2]) + end + return rcc_location(rcc) +end + +function locate(loc::IndexedPointInAreaLocator{<:Spherical}, p) + index = loc.index + #-- signed-zero-normalize an already-kernel point: the index longitudes + #-- come from normalized vertices, and atan(-0.0, x<0) is -π, not π + q = p isa UnitSphericalPoint{Float64} ? _node_point(p) : _to_kernel_point(loc.m, p) + #-- scan mode: unindexed (unprepared), empty, or both poles on boundary + index isa _SphPIAIndex || + return _sph_scan_locate(loc.m, loc.polys, q; exact = loc.exact) + if q[1] == 0.0 && q[2] == 0.0 + #-- on the polar axis: the anchor itself answers from the stored + #-- bit; its antipode has no reference arc (antipodal pair), so it + #-- takes the exact scan + q == loc.anchor && return loc.anchor_loc + return _sph_scan_locate(loc.m, loc.polys, q; exact = loc.exact) + end + acc = ArcCrossingCounter(loc.m, loc.exact, q, loc.anchor, 0, false) + λ = atan(q[2], q[1]) + stab = Extents.Extent(X = (λ, λ)) + #-- count every edge whose longitude interval contains the query's + SpatialTreeInterface.depth_first_search(Base.Fix1(Extents.intersects, stab), index) do i + seg = index.data[i] + count_arc_segment!(acc, seg[1], seg[2]) + end + acc.is_point_on_segment && return LOC_BOUNDARY + if isodd(acc.crossing_count) + return loc.anchor_loc == LOC_INTERIOR ? LOC_EXTERIOR : LOC_INTERIOR + end + return loc.anchor_loc +end + +#========================================================================== +## Spherical kernel-ring cache (Layer 1 of the spherical indexed locator) +==========================================================================# + +# The exact ring scan of `_locate_point_in_polygonal` (point_locator.jl) +# over cached kernel rings: same shell-then-holes even-odd composition, +# with the conversion, dedup, and orientation bit precomputed. +function _sph_scan_locate(m::Spherical, polys::Vector{_SphPolyRings}, p; exact) + for pr in polys + l = _sph_locate_in_poly(m, pr, p; exact) + l != LOC_EXTERIOR && return l + end + return LOC_EXTERIOR +end + +function _sph_locate_in_poly(m::Spherical, pr::_SphPolyRings, p; exact) + shell_loc = rk_point_in_ring(m, p, pr.shell; exact) + shell_loc != LOC_INTERIOR && return shell_loc + for hole in pr.holes + hole_loc = rk_point_in_ring(m, p, hole; exact) + hole_loc == LOC_BOUNDARY && return LOC_BOUNDARY + hole_loc == LOC_INTERIOR && return LOC_EXTERIOR + #-- if in EXTERIOR of this hole keep checking the other ones + end + return LOC_INTERIOR +end + +function _sph_rings_add_geom!(polys, m::Spherical, ::GI.PolygonTrait, poly; exact) + GI.isempty(poly) && return nothing + shell = SphericalKernelRing(m, GI.getexterior(poly); exact) + holes = [SphericalKernelRing(m, h; exact, is_hole = true) + for h in GI.gethole(poly) if !GI.isempty(h)] + push!(polys, _SphPolyRings(shell, holes)) + return nothing +end + +function _sph_rings_add_geom!(polys, m::Spherical, ::GI.MultiPolygonTrait, mp; exact) + for poly in GI.getgeom(mp) + _sph_rings_add_geom!(polys, m, GI.trait(poly), poly; exact) + end + return nothing +end + +#========================================================================== +## Spherical longitude-interval index (Layer 2) +==========================================================================# + +#= +Parity anchor: in the plane the far end of the crossing ray is at infinity +and EXTERIOR by construction; on the sphere the far end is a pole, whose +location relative to the element is computed once by the exact scan. The +south pole is preferred; if it lies ON the boundary the reference arc +could graze the linework everywhere along it, so the north pole takes +over. Both poles on the boundary leaves `LOC_BOUNDARY`, which the +constructor maps to unindexed scan mode — pathological, and correctness +is cheap. +=# +function _sph_pia_anchor(m::Spherical, polys; exact) + loc_s = _sph_scan_locate(m, polys, _SPH_SOUTH_POLE; exact) + loc_s != LOC_BOUNDARY && return (_SPH_SOUTH_POLE, loc_s) + loc_n = _sph_scan_locate(m, polys, _SPH_NORTH_POLE; exact) + return (_SPH_NORTH_POLE, loc_n) +end + +""" + ArcCrossingCounter(m::Spherical, exact, p, anchor, 0, false) + +Counts ring edges crossing the reference meridian arc from the query point +`p` to the `anchor` pole, in an incremental fashion — the spherical +counterpart of [`RayCrossingCounter`](@ref). As there, the location is only +correct once **all** edges whose longitude interval contains `p`'s +longitude have been counted, and a query point found to lie on an edge is +recorded in `is_point_on_segment` (final location `LOC_BOUNDARY`). + +The final location is `anchor`'s location, flipped once per crossing. +Vertex grazing — an edge endpoint exactly on the reference arc — is +resolved symbolically, S2-`VertexCrossing` style: the edge counts iff its +off-arc endpoint lies strictly on the positive side of the meridian great +circle, so a crossing pair of incident edges counts once, a same-side pair +counts zero or twice (parity-equal), and edges collinear with the meridian +count never (their chain's terminal edges decide). With `exact = True()` +every branch below is decided by exact predicates. +""" +mutable struct ArcCrossingCounter{M <: Spherical, E} + const m::M + const exact::E + const p::UnitSphericalPoint{Float64} + const anchor::UnitSphericalPoint{Float64} + crossing_count::Int + is_point_on_segment::Bool +end + +function count_arc_segment!(acc::ArcCrossingCounter, a, b) + m, exact = acc.m, acc.exact + q, s = acc.p, acc.anchor + #-- the query point on the closed edge (vertex or interior, including + #-- collinear overlap): boundary + if rk_point_on_segment(m, q, a, b; exact) + acc.is_point_on_segment = true + return nothing + end + a == b && return nothing # zero-length edge: only boundary-relevant + sa = rk_orient(m, q, s, a; exact) + sb = rk_orient(m, q, s, b; exact) + if sa == 0 || sb == 0 + #-- edge collinear with the meridian circle: were q interior to it + #-- the boundary test above caught it; the anchor is never on an + #-- edge (anchor selection); otherwise its neighbors decide + (sa == 0 && sb == 0) && return nothing + #-- vertex grazing: an endpoint on the meridian great circle. Two + #-- distinct great circles meet only at one antipodal point pair, so + #-- the edge can touch the reference arc only at that endpoint — + #-- count iff the endpoint is ON the arc and the other endpoint is + #-- strictly on the positive side (see the docstring) + von, s_off = sa == 0 ? (a, sb) : (b, sa) + if s_off > 0 && rk_point_on_segment(m, von, q, s; exact) + acc.crossing_count += 1 + end + return nothing + end + #-- endpoints strictly on the same side: no crossing + (sa > 0) == (sb > 0) && return nothing + #-- q (or the anchor) on the edge's great circle but not on the edge: + #-- the circles meet only at ±q (resp. ±anchor), out of reach of both + #-- arcs — cf. `_arc_crossing_parity` + sq = rk_orient(m, a, b, q; exact) + sq == 0 && return nothing + sm = rk_orient(m, a, b, s; exact) + sm == 0 && return nothing + (sq > 0) == (sm > 0) && return nothing + #= + Mutual strict straddle: the two great circles meet at ±x, x = n₁×n₂, + and each arc contains exactly one of the pair — they cross iff it is + the same one. Which one each arc contains is already encoded in the + computed signs: traveling the reference arc q → s (tangent n₁×p), the + plane-2 crossing is +x iff sq > 0; traveling the edge a → b (tangent + n₂×p), the plane-1 crossing is −x iff sa > 0. So the arcs cross iff + sa and sq differ — no constructed point, and exactly as exact as + `rk_orient` (this replaces a `Rational{BigInt}` in-arc confirmation + that dominated indexed query time). + =# + if (sa > 0) != (sq > 0) + acc.crossing_count += 1 + end + return nothing +end + +# The longitude-interval edge index over every ring of the element, built +# from the same cached kernel points the exact scan walks. +function _sph_lon_index(polys) + exts = _SphPIAExtent[] + segs = _SphPIASegment[] + for pr in polys + _sph_lon_index_add_ring!(exts, segs, pr.shell) + for hole in pr.holes + _sph_lon_index_add_ring!(exts, segs, hole) + end + end + return isempty(segs) ? nothing : RTree(STR(), segs; extents = exts) +end + +# Index the boundary edge walk (consecutive vertex pairs plus the implicit +# closing edge) — the same edge set `rk_point_in_ring` tests for boundary +# and parity. +function _sph_lon_index_add_ring!(exts, segs, kr::SphericalKernelRing) + pts = kr.pts + n = length(pts) + n < 2 && return nothing + for i in 1:(n - 1) + _sph_lon_entries!(exts, segs, pts[i], pts[i + 1]) + end + if pts[n] != pts[1] + _sph_lon_entries!(exts, segs, pts[n], pts[1]) + end + return nothing +end + +#= +The longitude interval(s) of one great-circle edge. Longitude is strictly +monotonic along a great-circle arc — the east component of the tangent at +`p` is `(n × p) ⋅ (ẑ × p) = n_z`, constant along the whole circle (`n` the +circle normal) — so an edge spans exactly the wrapped interval between its +endpoint longitudes; and since `n_z = cos(lat_a) cos(lat_b) sin(λ_b − λ_a)` +the sweep is always the SHORTER of the two candidate intervals. (The design +note's worry that longitude "bulges" like latitude does not arise.) +Intervals are padded a few ulps against `atan` rounding, as the arc extents +pad. Conservative full-interval fallbacks, where the float longitudes do +not determine the sweep: an endpoint exactly on the polar axis (undefined +longitude), or endpoint longitudes within ~1e-9 of half a turn apart +(pole-hugging edges — the short/long choice would hang on the sign of a +vanishing cross product). An interval crossing the antimeridian contributes +two entries; the two never overlap, so no edge is double-counted. +=# +function _sph_lon_entries!(exts, segs, a, b) + seg = (a, b) + halfturn = Float64(π) + if (a[1] == 0.0 && a[2] == 0.0) || (b[1] == 0.0 && b[2] == 0.0) + return _push_lon_entry!(exts, segs, seg, -halfturn, halfturn) + end + λa = atan(a[2], a[1]) + λb = atan(b[2], b[1]) + Δ = λb - λa + Δ > halfturn && (Δ -= 2halfturn) + Δ < -halfturn && (Δ += 2halfturn) + if abs(Δ) > halfturn - 1e-9 + return _push_lon_entry!(exts, segs, seg, -halfturn, halfturn) + end + lo, hi = Δ >= 0 ? (λa, λa + Δ) : (λa + Δ, λa) + lo = prevfloat(lo, 32) + hi = nextfloat(hi, 32) + #-- antimeridian wraparound: split the overflow back into [-π, π] + if lo < -halfturn + _push_lon_entry!(exts, segs, seg, lo + 2halfturn, halfturn) + lo = -halfturn + elseif hi > halfturn + _push_lon_entry!(exts, segs, seg, -halfturn, hi - 2halfturn) + hi = halfturn + end + return _push_lon_entry!(exts, segs, seg, lo, hi) +end + +function _push_lon_entry!(exts, segs, seg, lo, hi) + push!(exts, Extents.Extent(X = (lo, hi))) + push!(segs, seg) + return nothing +end + +# Port of IntervalIndexedGeometry.init. JTS extracts all linear components +# (LinearComponentExtracter) and keeps the closed ones; here only polygonal +# elements ever reach this locator (RelatePointLocator extracts Polygon / +# MultiPolygon elements), so the rings are iterated directly. +function _interval_index_add_geom!(exts, segs, ::GI.PolygonTrait, poly) + GI.isempty(poly) && return nothing + _interval_index_add_line!(exts, segs, GI.getexterior(poly)) + for hole in GI.gethole(poly) + _interval_index_add_line!(exts, segs, hole) + end + return nothing +end + +function _interval_index_add_geom!(exts, segs, ::GI.MultiPolygonTrait, mp) + for poly in GI.getgeom(mp) + _interval_index_add_geom!(exts, segs, GI.trait(poly), poly) + end + return nothing +end + +# Port of IntervalIndexedGeometry.addLine: index each ring segment on its +# y-interval, streaming the points directly (no `_node_points` copy). +# GI rings may be implicitly closed (no repeated end point); the +# SimplePointInAreaLocator ring loop (`rk_point_in_ring`) treats rings as +# closed regardless, so the closing segment is added here too. +function _interval_index_add_line!(exts, segs, ring) + n = GI.npoint(ring) + n < 2 && return nothing + first_pt = _node_point(GI.getpoint(ring, 1)) + prev = first_pt + for i in 2:n + pt = _node_point(GI.getpoint(ring, i)) + _interval_index_add_segment!(exts, segs, prev, pt) + prev = pt + end + if prev != first_pt + _interval_index_add_segment!(exts, segs, prev, first_pt) + end + return nothing +end + +function _interval_index_add_segment!(exts, segs, p0, p1) + push!(exts, Extents.Extent(Y = minmax(p0[2], p1[2]))) + push!(segs, (p0, p1)) + return nothing +end diff --git a/src/methods/geom_relations/relateng/kernel.jl b/src/methods/geom_relations/relateng/kernel.jl new file mode 100644 index 0000000000..53e651d178 --- /dev/null +++ b/src/methods/geom_relations/relateng/kernel.jl @@ -0,0 +1,331 @@ +# # RelateKernel API contract + +#= +The geometry layer of RelateNG (design doc D1/D2): every coordinate-level +question the topology layer may ask, answered with exact predicates and no +constructed coordinates. Each function takes the manifold as its first +argument; the planar implementations live in `kernel_planar.jl`. A future +`Spherical` kernel implements the same functions and must pass the same +conformance testset (Task 9). + +All kernel functions are prefixed `rk_` (RelateKernel) and are internal — +nothing here is exported. Points are coordinate tuples (typically +`Tuple{Float64, Float64}`) obtained via `_tuple_point`; the `exact` flag is a +keyword taking `True()`/`False()` (GeometryOpsCore BoolsAsTypes), threaded +exactly like `Predicates.orient`. + +The contract — what every manifold implementation must provide: + + rk_orient(m, a, b, c; exact) + +Orientation of point `c` relative to the oriented segment `(a, b)`, +returned as a sign-valued number: `> 0` (counterclockwise, `c` to the +left), `< 0` (clockwise, `c` to the right), `== 0` (collinear, or +`a == b`). With `exact = True()` the sign must be correct even for +adversarial near-collinear inputs. + + rk_point_on_segment(m, p, q0, q1; exact)::Bool + +Whether point `p` lies on the closed segment `[q0, q1]`, endpoints included. + + rk_point_in_ring(m, p, ring; exact, is_hole = false)::Int8 + +Location of point `p` relative to the region denoted by the closed `ring` +(a GeoInterface linestring/linearring, assumed closed regardless of a +repeated last point): one of `LOC_INTERIOR`, `LOC_BOUNDARY`, `LOC_EXTERIOR`. +The denoted region is the area *enclosed* by the ring, independent of its +winding — except on `Spherical(; oriented = true)`, where the stored +winding is authoritative and `is_hole` declares the ring's role (see +`_ring_interior_on_left`); other manifolds ignore `is_hole`. + + rk_interaction_bounds(m, geom)::Extents.Extent + +The bounding region within which `geom` can interact with another geometry: +the shared manifold extent (`Extents.extent(m, geom)`) in kernel +coordinates, plus any relate-specific conservatism (ulp padding, dim-1 +curve semantics for rings — see kernel_spherical.jl). Extent tests on +these boxes use `Extents.intersects`/`Extents.covers` directly (via the +`nothing`-tolerant `ext_intersects`/`ext_covers` where empty geometries +can occur). + + _validate_relate_edges(m, curve) + +Ingest validation hook, run once per curve at `RelateGeometry` +construction: throw on edges the manifold cannot represent. A no-op on +`Planar`; `Spherical` rejects exactly-antipodal vertex pairs. + + rk_classify_intersection(m, a0, a1, b0, b1; exact)::SegSegClass + +Combinatorial classification of the intersection of the closed segments +`(a0, a1)` and `(b0, b1)` (replaces JTS's `RobustLineIntersector`, design +D2). No intersection coordinate is ever constructed: a proper interior +crossing is reported purely symbolically as `SS_PROPER`, and all vertex +incidences are reported via the boolean `*_on_*` flags of the returned +`SegSegClass`, whose coordinates are exact input vertices. With +`exact = True()` the classification must be correct even for adversarial +near-collinear inputs. + + vertex_node(pt)::NodeKey + crossing_node(a0, a1, b0, b1)::NodeKey + +Manifold-generic constructors for symbolic node identities (design D2). +`vertex_node` keys a node by its exact coordinate; `crossing_node` keys a +proper-crossing node by the canonicalized defining segment pair, never by a +computed intersection coordinate. Keys constructed from the same vertex, or +from the same segment pair in any order/orientation, are `==` and hash +equal, so they can be used directly as `Dict` keys. + + rk_nodes_coincide(m, k1, k2; exact)::Bool + +Whether two node keys denote the same point of the manifold. Same-kind keys +that are `==` trivially coincide; cross-kind coincidence (a vertex lying +exactly on a proper crossing, or two distinct crossings meeting at one +point) is decided exactly — on the plane via `Rational{BigInt}` arithmetic +(design D3). Only invoked on self-noding paths, so the slow path is +acceptable. + + rk_quadrant(m, origin, p)::Int + +Quadrant of the direction from `origin` to `p`, in the JTS `Quadrant` +convention: `0` = NE, `1` = NW, `2` = SW, `3` = SE, numbered CCW from the +positive X-axis, with axis directions belonging to the `dx >= 0` / +`dy >= 0` side. Throws `ArgumentError` for a zero-length direction. + + rk_compare_edge_dir(m, node::NodeKey, p, q; exact)::Int + +Compare the angles of the edge directions `node → p` and `node → q` around +the (possibly symbolic) apex `node`: negative / zero / positive as the +direction toward `p` has angle less than / equal to / greater than the +direction toward `q`, angles increasing CCW from the positive X-axis at the +apex (port of JTS `PolygonNodeTopology.compareAngle` with a `NodeKey` apex). +For vertex nodes the apex coordinate is exact and the port is direct. For +crossing nodes, directions which are endpoints of the node's defining +segments are compared exactly from the original endpoints, never from a +constructed apex coordinate; foreign directions (incident edges of a D3 +coincidence-merged node, from other segment pairs crossing at the same +point) are compared exactly around the rational apex (slow path). + + rk_crossing_dirs_ccw(m, a0, a1, b0, b1; exact) + +CCW cyclic order of the four half-edge directions incident to the proper +crossing of `(a0, a1)` × `(b0, b1)`, as a 4-tuple of the original segment +endpoints starting from `a1`. Derived from a single orientation sign; no +crossing coordinate is constructed. + + rk_is_crossing(m, node, a0, a1, b0, b1; exact)::Bool + +Whether the rings entering vertex node `node` along segments `a0–node–a1` +and `b0–node–b1` cross at the node (port of JTS +`PolygonNodeTopology.isCrossing`). If any segment is collinear with another, +returns `false`. Vertex-node apexes only: proper crossings are crossings by +construction and are short-circuited by the caller +(JTS `TopologyComputer.updateAreaAreaCross`). + + rk_is_interior_segment(m, node, a0, a1, b; exact)::Bool + +Whether the segment `node → b` lies in the interior of the ring corner +`a0–node–a1`, the ring interior being on the right of the corner (i.e. a CW +shell or CCW hole); port of JTS `PolygonNodeTopology.isInteriorSegment`. +The test segment must not be collinear with the corner segments. +Vertex-node apexes only. +=# + +# Symbolic segment-pair intersection classification (replaces RobustLineIntersector). +# JTS LineIntersector outcome mapping (for porting reference): +# SS_DISJOINT ↔ NO_INTERSECTION +# SS_PROPER ↔ POINT_INTERSECTION with isProper() +# SS_TOUCH ↔ POINT_INTERSECTION, not proper (incl. collinear abutment) +# SS_COLLINEAR ↔ COLLINEAR_INTERSECTION +@enum SegSegKind::Int8 SS_DISJOINT SS_PROPER SS_TOUCH SS_COLLINEAR + +""" + SegSegClass + +Combinatorial classification of the intersection of closed segments +(a0,a1) × (b0,b1). `kind` is `SS_PROPER` only for a crossing in both +segments' interiors (the node is *symbolic*: no coordinate exists for +it anywhere in the engine). All vertex incidences are reported via the +`*_on_*` flags, whose coordinates are exact input vertices. +""" +struct SegSegClass + kind::SegSegKind + a0_on_b::Bool + a1_on_b::Bool + b0_on_a::Bool + b1_on_a::Bool +end + +# Manifold-generic helpers + +# Symbolic node identity (design D2). One concrete isbits key type for both +# node kinds so Dict{NodeKey{P}, ...} is type-stable. Equality and hashing +# are the default bit-pattern (egal) semantics for isbits structs; this is +# safe because the constructors normalize the only Float64 values whose +# numeric equality disagrees with bit equality: signed zeros (-0.0 → 0.0, +# via `x + zero(x)`, exact in IEEE arithmetic). +""" + NodeKey{P} + +Symbolic identity of a node (design D2). Vertex nodes key exactly by their +coordinate (`is_crossing == false`, all point fields equal to the vertex); +proper-crossing nodes key by their canonicalized defining segment pair +(`is_crossing == true`, fields `(pt, a1)` and `(b0, b1)` are the two +segments). No intersection coordinate is ever computed for the key. +Construct via [`vertex_node`](@ref) and [`crossing_node`](@ref). +""" +struct NodeKey{P} + is_crossing::Bool + pt::P # vertex nodes: the coordinate. crossing nodes: canonical a0. + a1::P + b0::P + b1::P +end + +# Normalize signed zeros: -0.0 + 0.0 == +0.0 exactly, every other finite +# value is unchanged. Keeps bit-pattern key equality == coordinate equality. +@inline _pos_zero(x) = x + zero(x) + +# A node point is the engine's canonical, signed-zero-normalized representation +# of a coordinate. Planar points stay 2-tuples (byte-identical to the original +# implementation, so `NodeKey` bytes are unchanged); 3D points (e.g. a spherical +# `UnitSphericalPoint`) keep all three components and their concrete type. +@inline _node_point(p) = _node_point(booltype(GI.is3d(p)), p) +@inline _node_point(::False, p) = (_pos_zero(GI.x(p)), _pos_zero(GI.y(p))) +@inline function _node_point(::True, p) + return _rebuild_point(p, _pos_zero(GI.x(p)), _pos_zero(GI.y(p)), _pos_zero(GI.z(p))) +end +# Default rebuild: a plain 3-tuple. Concrete point types that must survive node +# construction add their own method. The spherical kernel point type is the one +# such type today: a `UnitSphericalPoint` must stay a `UnitSphericalPoint` so +# `NodeKey{P}`'s `P` matches the kernel point type (the spherical kernel math +# runs on it directly). +@inline _rebuild_point(::Any, x, y, z) = (x, y, z) +@inline _rebuild_point(::UnitSphericalPoint, x, y, z) = UnitSphericalPoint(x, y, z) + +# Collect a curve's coordinates as node points into a plain `Vector`. (A +# typed comprehension over `GI.getpoint` is not enough: for geometries backed +# by StaticArrays — e.g. the `extent_to_polygon` output — the iterator has +# static axes, so `collect` returns a `SizedVector`, which downstream code +# expecting `Vector` point lists rejects.) +function _node_points(geom) + p1 = GI.getpoint(geom, 1) + pts = Vector{typeof(_node_point(p1))}() + sizehint!(pts, GI.npoint(geom)) + for p in GI.getpoint(geom) + push!(pts, _node_point(p)) + end + return pts +end + +#= +## Manifold-derived kernel point type (Phase 3 ingest) + +The engine stores every coordinate in the manifold's *kernel point type*. The +planar path keeps the signed-zero-normalized 2-tuple — byte-identical to the +pre-Phase-3 engine, so `NodeKey` bytes and the locator hash sets are unchanged. +A non-planar manifold (e.g. `Spherical`) converts each lon/lat (or already-xyz) +vertex to its kernel representation at ingest, so everything downstream — node +keys, segment-string coordinate vectors, the point-locator collections, the +kernel predicate calls — runs in the kernel coordinate space. + +`_kernel_point_type(m)` types those collections; `_to_kernel_point(m, p)` / +`_to_kernel_points(m, geom)` perform the conversion. The `Spherical` methods +live in kernel_spherical.jl next to `_spherical_kernel_point`. +=# +_kernel_point_type(::Planar) = Tuple{Float64, Float64} +@inline _to_kernel_point(::Planar, p) = _node_point(p) + +# Manifold-aware counterpart of `_node_points` (identical to it on the planar +# path): a curve's coordinates as a `Vector` of kernel points. +function _to_kernel_points(m::Manifold, geom) + P = _kernel_point_type(m) + pts = Vector{P}() + sizehint!(pts, GI.npoint(geom)) + for p in GI.getpoint(geom) + push!(pts, _to_kernel_point(m, p)) + end + return pts +end + +# Manifold hook for edge validation at ingest (the `RelateGeometry` +# extent-cache pass): a manifold may reject edges it cannot represent. Planar +# edges are always fine; `Spherical` throws on exactly-antipodal vertex pairs +# (see kernel_spherical.jl). +_validate_relate_edges(::Manifold, curve) = nothing + +# Degenerate interaction box of a single *kernel* point, matching the +# dimensionality of `rk_interaction_bounds` (2D for planar tuples, 3D for 3D +# kernel points such as `UnitSphericalPoint`). Used by the point-locator line +# envelope short-circuit, where the query point is already a kernel point. +@inline _kernel_point_box(p) = _kernel_point_box(booltype(GI.is3d(p)), p) +@inline _kernel_point_box(::False, p) = + Extents.Extent(X = (GI.x(p), GI.x(p)), Y = (GI.y(p), GI.y(p))) +@inline _kernel_point_box(::True, p) = + Extents.Extent(X = (GI.x(p), GI.x(p)), Y = (GI.y(p), GI.y(p)), Z = (GI.z(p), GI.z(p))) + +"Node key of a vertex node: keyed exactly by its coordinate." +function vertex_node(pt) + p = _node_point(pt) + return NodeKey(false, p, p, p, p) +end + +""" + crossing_node(a0, a1, b0, b1)::NodeKey + +Node key of the proper crossing of segments `(a0, a1)` and `(b0, b1)`. +Canonicalize: each segment ordered lexicographically by (x, y); segments +ordered lexicographically by their endpoint tuples — so any +order/orientation of the same pair produces an identical key. + +Only construct crossing keys for properly crossing segments (`SS_PROPER` +from `rk_classify_intersection`): the exact rational slow path in +`rk_nodes_coincide` divides by the segments' direction cross product, which +is nonzero precisely when the crossing is proper. +""" +function crossing_node(a0, a1, b0, b1) + a0, a1 = _canonical_segment(_node_point(a0), _node_point(a1)) + b0, b1 = _canonical_segment(_node_point(b0), _node_point(b1)) + if (_lex(b0), _lex(b1)) < (_lex(a0), _lex(a1)) + a0, a1, b0, b1 = b0, b1, a0, a1 + end + return NodeKey(true, a0, a1, b0, b1) +end + +# Lexicographic key of a point: (x, y) on the plane, (x, y, z) in 3D. +@inline _lex(p) = GI.is3d(p) ? (GI.x(p), GI.y(p), GI.z(p)) : (GI.x(p), GI.y(p)) + +# Order a segment's endpoints lexicographically (by (x, y), or (x, y, z) in 3D). +_canonical_segment(p, q) = _lex(p) <= _lex(q) ? (p, q) : (q, p) + +# Whether `p` lies within the coordinate bounding box of segment `(q0, q1)`. +# Valid as an on-segment test only when `p` is already known collinear with +# `(q0, q1)`; shared by manifolds whose segments are coordinate-monotone. The +# spherical kernel does NOT use this for arc membership (it uses +# `rk_point_on_segment` with dot tests); the 3D clause keeps any incidental +# planar-style call correct. +@inline function _collinear_between(p, q0, q1) + (min(GI.x(q0), GI.x(q1)) <= GI.x(p) <= max(GI.x(q0), GI.x(q1))) && + (min(GI.y(q0), GI.y(q1)) <= GI.y(p) <= max(GI.y(q0), GI.y(q1))) && + (!GI.is3d(p) || (min(GI.z(q0), GI.z(q1)) <= GI.z(p) <= max(GI.z(q0), GI.z(q1)))) +end + +# Whether the segments `(a0, a1)` and `(b0, b1)` cross PROPERLY — transversally, +# interior to both — as a bare Bool (the yes/no core of `SS_PROPER`, without the +# incidence bookkeeping of `rk_classify_intersection`). Manifold-generic: a +# strict mutual-straddle prefilter of four `rk_orient` signs (adaptive-exact, so +# clearly-separated pairs — the overwhelming majority in a validation or repair +# sweep — resolve in the float stage; any shared endpoint zeroes a sign and +# returns `false`, which is what excludes adjacent ring edges) and, for the +# rare survivors, the manifold's authoritative classification (on `Spherical` +# mutual straddle alone is NOT sufficient: two arcs can straddle each other's +# great circles yet meet only at the antipodal candidate). Used by the +# `prepare` ring-validation join and the `CrossingEdgeSplit` correction. +function _edges_cross_properly(m::Manifold, a0, a1, b0, b1; exact) + ob0 = rk_orient(m, a0, a1, b0; exact) + ob1 = rk_orient(m, a0, a1, b1; exact) + (ob0 > 0 && ob1 < 0) || (ob0 < 0 && ob1 > 0) || return false + oa0 = rk_orient(m, b0, b1, a0; exact) + oa1 = rk_orient(m, b0, b1, a1; exact) + (oa0 > 0 && oa1 < 0) || (oa0 < 0 && oa1 > 0) || return false + return rk_classify_intersection(m, a0, a1, b0, b1; exact).kind == SS_PROPER +end diff --git a/src/methods/geom_relations/relateng/kernel_planar.jl b/src/methods/geom_relations/relateng/kernel_planar.jl new file mode 100644 index 0000000000..72d17086ba --- /dev/null +++ b/src/methods/geom_relations/relateng/kernel_planar.jl @@ -0,0 +1,365 @@ +# # Planar RelateKernel + +#= +Planar implementation of the RelateKernel contract declared in `kernel.jl`. +Orientation goes through `Predicates.orient` (AdaptivePredicates when +`exact = True()`), point-in-ring reuses the existing Hao–Sun ray-crossing +machinery (`_point_filled_curve_orientation`), and bounds are plain extents. +No coordinates are ever constructed. +=# + +rk_orient(::Planar, a, b, c; exact) = Predicates.orient(a, b, c; exact) + +function rk_point_on_segment(m::Planar, p, q0, q1; exact) + rk_orient(m, q0, q1, p; exact) == 0 || return false + return _collinear_between(p, q0, q1) +end + +#-- `is_hole` is consulted only by oriented spherical manifolds (see the +#-- kernel contract); planar ray-crossing parity is winding- and +#-- role-independent +function rk_point_in_ring(m::Planar, p, ring; exact, is_hole::Bool = false) + o = _point_filled_curve_orientation(m, p, ring; in = point_in, on = point_on, out = point_out, exact) + o == point_in && return LOC_INTERIOR + o == point_on && return LOC_BOUNDARY + return LOC_EXTERIOR +end + +#= +Planar interaction bounds are the plain GI extent: stored extents are +returned as-is (the wrapper tree built by `_relate_cache_extents`, or a +user's `GO.tuples(x; calc_extent = true)` input). The computed fallback, +however, does not go through `GI.calc_extent`, which makes two separate +closure-`extrema` passes over the points — on the ring-heavy extent-cache +pass that dominates unprepared point-in-area queries a single min/max sweep +is ~13× faster with identical results (same trait dispatch, same 2D/3D +bounds, same union-of-stored-extents semantics for GeometryCollections). +=# +function rk_interaction_bounds(m::Planar, geom) + ex = GI.extent(geom; fallback = false) + ex === nothing || return ex + return _planar_sweep_extent(m, GI.trait(geom), geom) +end + +function _planar_sweep_extent(::Planar, ::GI.AbstractPointTrait, p) + x, y = GI.x(p), GI.y(p) + GI.is3d(p) || return Extents.Extent(X = (x, x), Y = (y, y)) + z = GI.z(p) + return Extents.Extent(X = (x, x), Y = (y, y), Z = (z, z)) +end + +#-- concrete GeometryCollections union their members' extents (reading +#-- stored ones), exactly as GI's `calc_extent` does +_planar_sweep_extent(m::Planar, ::GI.GeometryCollectionTrait, geom) = + reduce(Extents.union, (rk_interaction_bounds(m, g) for g in GI.getgeom(geom))) + +function _planar_sweep_extent(::Planar, ::GI.AbstractGeometryTrait, geom) + itr = GI.getpoint(geom) + st = iterate(itr) + #-- empty geometry: defer to GI's own (throwing) computation path + st === nothing && return GI.extent(geom, fallback = true) + p, s = st + xlo = xhi = GI.x(p) + ylo = yhi = GI.y(p) + if GI.is3d(geom) + zlo = zhi = GI.z(p) + while (st = iterate(itr, s)) !== nothing + p, s = st + x, y, z = GI.x(p), GI.y(p), GI.z(p) + xlo = min(xlo, x); xhi = max(xhi, x) + ylo = min(ylo, y); yhi = max(yhi, y) + zlo = min(zlo, z); zhi = max(zhi, z) + end + return Extents.Extent(X = (xlo, xhi), Y = (ylo, yhi), Z = (zlo, zhi)) + end + while (st = iterate(itr, s)) !== nothing + p, s = st + x, y = GI.x(p), GI.y(p) + xlo = min(xlo, x); xhi = max(xhi, x) + ylo = min(ylo, y); yhi = max(yhi, y) + end + return Extents.Extent(X = (xlo, xhi), Y = (ylo, yhi)) +end + +# Exact coordinate equality of two points. +_equals2(p, q) = GI.x(p) == GI.x(q) && GI.y(p) == GI.y(q) + +function rk_classify_intersection(m::Planar, a0, a1, b0, b1; exact) + oa0 = rk_orient(m, b0, b1, a0; exact) + oa1 = rk_orient(m, b0, b1, a1; exact) + ob0 = rk_orient(m, a0, a1, b0; exact) + ob1 = rk_orient(m, a0, a1, b1; exact) + # fully collinear configuration (handles zero-length segments too) + if oa0 == 0 && oa1 == 0 && ob0 == 0 && ob1 == 0 + a0_on_b = _collinear_between(a0, b0, b1) + a1_on_b = _collinear_between(a1, b0, b1) + b0_on_a = _collinear_between(b0, a0, a1) + b1_on_a = _collinear_between(b1, a0, a1) + n_inc = a0_on_b + a1_on_b + b0_on_a + b1_on_a + n_inc == 0 && return SegSegClass(SS_DISJOINT, false, false, false, false) + # single shared endpoint counts twice (one endpoint of each on the other) + shared_endpoint_only = n_inc == 2 && + ((a0_on_b || a1_on_b) && (b0_on_a || b1_on_a)) && + (_equals2(a0, b0) || _equals2(a0, b1) || _equals2(a1, b0) || _equals2(a1, b1)) + kind = shared_endpoint_only ? SS_TOUCH : SS_COLLINEAR + # zero-length degenerate: a point on a segment is a touch, not an overlap + if _equals2(a0, a1) || _equals2(b0, b1) + kind = SS_TOUCH + end + return SegSegClass(kind, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + a0_on_b = oa0 == 0 && _collinear_between(a0, b0, b1) + a1_on_b = oa1 == 0 && _collinear_between(a1, b0, b1) + b0_on_a = ob0 == 0 && _collinear_between(b0, a0, a1) + b1_on_a = ob1 == 0 && _collinear_between(b1, a0, a1) + if a0_on_b || a1_on_b || b0_on_a || b1_on_a + return SegSegClass(SS_TOUCH, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + if (oa0 > 0) != (oa1 > 0) && oa0 != 0 && oa1 != 0 && + (ob0 > 0) != (ob1 > 0) && ob0 != 0 && ob1 != 0 + return SegSegClass(SS_PROPER, false, false, false, false) + end + return SegSegClass(SS_DISJOINT, false, false, false, false) +end + +# Edge ordering around nodes: port of JTS PolygonNodeTopology +# (algorithm/PolygonNodeTopology.java), with the apex generalized to a +# symbolic NodeKey. Vertex-node apexes are a direct port; crossing-node +# apexes are handled exactly via the original segment endpoints (see +# rk_compare_edge_dir) — no apex coordinate is ever constructed. + +# Port of PolygonNodeTopology.quadrant / Quadrant.quadrant: NE=0, NW=1, +# SW=2, SE=3, numbered CCW from the positive X-axis; axis directions belong +# to the `dx >= 0` / `dy >= 0` side. Pure coordinate comparisons, exact. +function rk_quadrant(::Planar, origin, p) + ox, oy = GI.x(origin), GI.y(origin) + px, py = GI.x(p), GI.y(p) + (px == ox && py == oy) && + throw(ArgumentError("cannot compute the quadrant of a zero-length direction")) + if px >= ox + return py >= oy ? 0 : 3 # NE : SE + else + return py >= oy ? 1 : 2 # NW : SW + end +end + +# Port of PolygonNodeTopology.compareAngle with a vertex apex: angles +# increase CCW from the positive X-axis; different quadrants decide the +# comparison, same-quadrant ties are resolved by orientation (P > Q if P is +# CCW of Q). +# +# This and the helpers below (`_is_angle_greater`, `_is_between`, +# `_compare_between`, `rk_crossing_dirs_ccw`, `rk_is_crossing`, +# `rk_is_interior_segment`) depend only on `rk_quadrant` and `rk_orient`, both +# manifold-dispatched, so they are manifold-generic (`m::Manifold`). The +# spherical kernel supplies a tangent-plane `rk_quadrant`; the same-quadrant +# orient tiebreak `rk_orient(m, origin, q, p)` is already the tangent-plane CCW +# sign there. Only `rk_quadrant` and the crossing-apex `rk_compare_edge_dir` +# are manifold-specific. +function _compare_angle(m::Manifold, origin, p, q; exact) + quadrant_p = rk_quadrant(m, origin, p) + quadrant_q = rk_quadrant(m, origin, q) + quadrant_p > quadrant_q && return 1 + quadrant_p < quadrant_q && return -1 + # vectors are in the same quadrant: check relative orientation + o = rk_orient(m, origin, q, p; exact) + o > 0 && return 1 + o < 0 && return -1 + return 0 +end + +# Port of PolygonNodeTopology.isAngleGreater. +function _is_angle_greater(m::Manifold, origin, p, q; exact) + quadrant_p = rk_quadrant(m, origin, p) + quadrant_q = rk_quadrant(m, origin, q) + quadrant_p > quadrant_q && return true + quadrant_p < quadrant_q && return false + # vectors are in the same quadrant: P > Q if it is CCW of Q + return rk_orient(m, origin, q, p; exact) > 0 +end + +# Port of PolygonNodeTopology.isBetween: whether edge p is inside the arc +# from e0 to e1 (the arc not including the origin direction). Edges assumed +# distinct (non-collinear). +function _is_between(m::Manifold, origin, p, e0, e1; exact) + _is_angle_greater(m, origin, p, e0; exact) || return false + return !_is_angle_greater(m, origin, p, e1; exact) +end + +# Port of PolygonNodeTopology.compareBetween: 1 if p is inside the arc from +# e0 to e1 (the arc not crossing the positive X-axis), -1 if outside, 0 if +# collinear with either edge. +function _compare_between(m::Manifold, origin, p, e0, e1; exact) + comp0 = _compare_angle(m, origin, p, e0; exact) + comp0 == 0 && return 0 + comp1 = _compare_angle(m, origin, p, e1; exact) + comp1 == 0 && return 0 + (comp0 > 0 && comp1 < 0) && return 1 + return -1 +end + +# The opposite endpoint of incident endpoint `p` on its defining segment of +# crossing node `k`, or `nothing` if `p` is not one of the four endpoints +# (a direction from a foreign segment pair, on a D3 coincidence-merged node). +# Coordinate-equality matching is unambiguous because the four endpoints of +# a proper crossing are pairwise distinct. +function _crossing_opposite(k::NodeKey, p) + _equals2(p, k.pt) && return k.a1 + _equals2(p, k.a1) && return k.pt + _equals2(p, k.b0) && return k.b1 + _equals2(p, k.b1) && return k.b0 + return nothing +end + +function rk_compare_edge_dir(m::Planar, node::NodeKey, p, q; exact) + node.is_crossing || return _compare_angle(m, node.pt, p, q; exact) + #= + Crossing apex (needed by RelateEdge/NodeSection edge ordering, where the + node may be a proper crossing): the directions to compare are normally + among the four endpoints of the defining segments. Because the symbolic + apex lies *strictly* inside both segments (SS_PROPER), for any incident + endpoint `x` with opposite endpoint `opp(x)` on the same segment: + - the vector apex → x is a positive multiple of opp(x) → x, so + quadrant(apex, x) == quadrant(opp(x), x), and + - the directed line apex → x equals the directed line opp(x) → x, so + sign(orient(apex, x, y)) == sign(orient(opp(x), x, y)). + Substituting these into compareAngle reproduces the Java comparison + (anchored at the positive X-axis of the apex) exactly, without ever + constructing the apex coordinate. + =# + popp = _crossing_opposite(node, p) + qopp = _crossing_opposite(node, q) + if popp !== nothing && qopp !== nothing + quadrant_p = rk_quadrant(m, popp, p) + quadrant_q = rk_quadrant(m, qopp, q) + quadrant_p > quadrant_q && return 1 + quadrant_p < quadrant_q && return -1 + # same quadrant: orient(apex, q, p) has the sign of orient(opp(q), q, p). + # Zero only when p == q (distinct incident endpoints in the same quadrant + # are never collinear through the apex of a proper crossing). + o = rk_orient(m, qopp, q, p; exact) + o > 0 && return 1 + o < 0 && return -1 + return 0 + end + #= + A direction point from a foreign segment pair: the node is a D3 + coincidence-merged node (TopologyComputer's self-noding merge pass), + whose incident edges come from several segment pairs crossing at the + same point. The endpoint substitution above does not apply, so compare + around the exact rational apex instead (slow path; only reachable on + the rare self-noding merge path). + =# + apex = _exact_crossing_point(node.pt, node.a1, node.b0, node.b1) + return _compare_angle_exact(apex, p, q) +end + +""" +CCW cyclic order of the four half-edge directions incident to the +proper crossing of (a0,a1) × (b0,b1), starting from a1. Since the +crossing is proper, b0/b1 are strictly on opposite sides of line(a0,a1): +if b1 is to the left, CCW order is (a1, b1, a0, b0), else (a1, b0, a0, b1). +""" +function rk_crossing_dirs_ccw(m::Manifold, a0, a1, b0, b1; exact) + if rk_orient(m, a0, a1, b1; exact) > 0 + return (a1, b1, a0, b0) + else + return (a1, b0, a0, b1) + end +end + +# Port of PolygonNodeTopology.isCrossing, apex = vertex node coordinate. +# Crossing-node apexes are rejected: a proper crossing is a crossing by +# construction, and the only caller (TopologyComputer.updateAreaAreaCross) +# short-circuits proper intersections before asking. +function rk_is_crossing(m::Manifold, node::NodeKey, a0, a1, b0, b1; exact) + node.is_crossing && + throw(ArgumentError("rk_is_crossing requires a vertex-node apex; proper crossings cross by construction")) + nodept = node.pt + a_lo, a_hi = a0, a1 + if _is_angle_greater(m, nodept, a_lo, a_hi; exact) + a_lo, a_hi = a1, a0 + end + # Find positions of b0 and b1. The edges cross if the positions are + # different. If any edge is collinear they are reported as not crossing. + comp_between0 = _compare_between(m, nodept, b0, a_lo, a_hi; exact) + comp_between0 == 0 && return false + comp_between1 = _compare_between(m, nodept, b1, a_lo, a_hi; exact) + comp_between1 == 0 && return false + return comp_between0 != comp_between1 +end + +# Port of PolygonNodeTopology.isInteriorSegment, apex = vertex node +# coordinate: whether segment node→b lies in the interior of the ring corner +# a0–node–a1 (ring interior on the right, i.e. a CW shell or CCW hole). +function rk_is_interior_segment(m::Manifold, node::NodeKey, a0, a1, b; exact) + node.is_crossing && + throw(ArgumentError("rk_is_interior_segment requires a vertex-node apex")) + nodept = node.pt + a_lo, a_hi = a0, a1 + is_interior_between = true + if _is_angle_greater(m, nodept, a_lo, a_hi; exact) + a_lo, a_hi = a1, a0 + is_interior_between = false + end + is_between = _is_between(m, nodept, b, a_lo, a_hi; exact) + return (is_between && is_interior_between) || (!is_between && !is_interior_between) +end + +# Node coincidence, rational slow path (design D3). Float64 values are +# dyadic rationals, so Rational{BigInt} conversion and arithmetic are exact. + +# Precondition: the segments cross *properly* (SS_PROPER), so their +# direction vectors are non-parallel and the denominator below is nonzero. +# crossing_node keys are only ever constructed for proper crossings. +"Exact intersection point of two properly crossing segments, as rationals." +function _exact_crossing_point(a0, a1, b0, b1) + R = Rational{BigInt} + ax0, ay0 = R(GI.x(a0)), R(GI.y(a0)); ax1, ay1 = R(GI.x(a1)), R(GI.y(a1)) + bx0, by0 = R(GI.x(b0)), R(GI.y(b0)); bx1, by1 = R(GI.x(b1)), R(GI.y(b1)) + dax, day = ax1 - ax0, ay1 - ay0 + dbx, dby = bx1 - bx0, by1 - by0 + denom = dax * dby - day * dbx # nonzero for a proper crossing + t = ((bx0 - ax0) * dby - (by0 - ay0) * dbx) // denom + return (ax0 + t * dax, ay0 + t * day) +end + +_exact_node_point(k::NodeKey) = k.is_crossing ? + _exact_crossing_point(k.pt, k.a1, k.b0, k.b1) : + (Rational{BigInt}(GI.x(k.pt)), Rational{BigInt}(GI.y(k.pt))) + +function rk_nodes_coincide(::Planar, k1::NodeKey, k2::NodeKey; exact) + k1 == k2 && return true + # Slow path (design D3, follow-up F1): exact rational comparison. + return _exact_node_point(k1) == _exact_node_point(k2) +end + +#= +`_compare_angle` with an exact rational apex: the slow path of +`rk_compare_edge_dir` for direction points incident to a D3 +coincidence-merged crossing node which are not endpoints of the node's +defining segments. All arithmetic is over `Rational{BigInt}` (Float64 +inputs convert exactly), so the comparison is exact. Mirrors +`_compare_angle` (quadrant first, then `orient(origin, q, p)`). + +Precondition: the direction points `p` and `q` must differ from the apex +`origin` — the quadrant of a zero vector is undefined. This is unreachable +in practice because section direction points are adjacent ring/line +vertices, which are distinct from the node. +=# +function _compare_angle_exact(origin, p, q) + R = Rational{BigInt} + ox, oy = origin + px, py = R(GI.x(p)), R(GI.y(p)) + qx, qy = R(GI.x(q)), R(GI.y(q)) + _quad(x, y) = x >= ox ? (y >= oy ? 0 : 3) : (y >= oy ? 1 : 2) + quadrant_p = _quad(px, py) + quadrant_q = _quad(qx, qy) + quadrant_p > quadrant_q && return 1 + quadrant_p < quadrant_q && return -1 + #-- same quadrant: orient(origin, q, p) as an exact rational cross product + o = (qx - ox) * (py - oy) - (qy - oy) * (px - ox) + o > 0 && return 1 + o < 0 && return -1 + return 0 +end diff --git a/src/methods/geom_relations/relateng/kernel_spherical.jl b/src/methods/geom_relations/relateng/kernel_spherical.jl new file mode 100644 index 0000000000..5a40c6258d --- /dev/null +++ b/src/methods/geom_relations/relateng/kernel_spherical.jl @@ -0,0 +1,836 @@ +# # Spherical RelateKernel +# +#= +Spherical implementation of the RelateKernel contract declared in `kernel.jl`, +over `UnitSphericalPoint{Float64}`. Every predicate is a sign of +det(u, v, w) = (u×v)·w, so the exact path mirrors planar: a float filter then an +exact fallback (`ExactPredicates.orient` for the plain orient, `Rational{BigInt}` +on the xyz components for composites). No intersection coordinate is ever +constructed. See the design doc 2026-06-15. + +All of `cross`, `⋅`, `normalize` (LinearAlgebra), `ExactPredicates`, and the +`UnitSpherical` names are already in scope here — this file is `include`d into +`GeometryOps`, which `using`s them at the top of the module. The +`_rebuild_point(::UnitSphericalPoint, …)` hook that keeps node points typed +lives next to the generic `_rebuild_point` in `kernel.jl`. +=# + +# xyz tuple of a 3D point, for the ExactPredicates / Rational{BigInt} paths. +@inline _tup3(u) = (GI.x(u), GI.y(u), GI.z(u)) + +# ## rk_orient + +# Orientation of `c` relative to the great-circle arc `(a, b)`: the sign of the +# scalar triple product (a×b)·c. Exact path: `ExactPredicates.orient` over the +# xyz tuples about the origin (the spike measured 3.5 ns); NOT +# `UnitSpherical.spherical_orient`, whose eps*16 tolerance is unfit for the +# exact contract. Float path: the plain triple product. +rk_orient(::Spherical, a, b, c; exact) = _rk_orient(booltype(exact), a, b, c) +@inline function _rk_orient(::True, a, b, c) + # Repeated-vertex short-circuit: a triple product with two equal vectors is + # exactly 0 (per the `rk_orient` contract, `== 0` for `a == b`). On real data + # this is the dominant coplanar case — adjacent rings meet at bit-identical + # shared border vertices — and it lets the classify/on-segment gate skip + # ExactPredicates' µs-scale exact fallback (a genuine zero it cannot filter). + (_usp_eq(a, b) || _usp_eq(a, c) || _usp_eq(b, c)) && return 0 + return ExactPredicates.orient(_tup3(a), _tup3(b), _tup3(c), (0.0, 0.0, 0.0)) +end +@inline _rk_orient(::False, a, b, c) = cross(a, b) ⋅ c + +# ## Exact-aware 3-vector arithmetic +# +# Composite predicates (arc membership, proper crossing, node coincidence) +# reduce to signs of polynomials in the xyz components. With `exact = True()` we +# evaluate over `Rational{BigInt}` (Float64 are dyadic rationals → exact); with +# `False()`, Float64. `_vec3(bt, p)` lifts a point to the chosen number type; the +# rest are plain tuple cross/dot, so one code path serves both — exactly how the +# planar kernel threads `exact`. +@inline _vec3(::True, u) = (Rational{BigInt}(GI.x(u)), Rational{BigInt}(GI.y(u)), Rational{BigInt}(GI.z(u))) +@inline _vec3(::False, u) = (Float64(GI.x(u)), Float64(GI.y(u)), Float64(GI.z(u))) +@inline _cross3(a, b) = (a[2]*b[3] - a[3]*b[2], a[3]*b[1] - a[1]*b[3], a[1]*b[2] - a[2]*b[1]) +@inline _dot3(a, b) = a[1]*b[1] + a[2]*b[2] + a[3]*b[3] +@inline _iszero3(a) = iszero(a[1]) && iszero(a[2]) && iszero(a[3]) +@inline _neg3(a) = (-a[1], -a[2], -a[3]) +# `w` strictly interior to the minor arc (a, b) with normal n = a×b. +@inline _strictly_in_arc3(w, a, b, n) = _dot3(_cross3(a, w), n) > 0 && _dot3(_cross3(w, b), n) > 0 +_usp_eq(p, q) = GI.x(p) == GI.x(q) && GI.y(p) == GI.y(q) && GI.z(p) == GI.z(q) + +# ## rk_point_on_segment + +# Whether `p` lies on the closed minor arc `[q0, q1]`. Two conditions: `p` is on +# the arc's great circle (coplanar with `q0`, `q1`, origin — an exact orient == +# 0), and within the minor-arc span. The span test is scale-invariant: writing +# the coplanar `p` as `α q0 + β q1`, `p` is on the closed minor arc iff α, β ≥ 0, +# and `sign(β) = sign((q0×p)·n)`, `sign(α) = sign((p×q1)·n)` with `n = q0×q1` — a +# pure determinant sign, correct for unit and non-unit inputs alike. +function rk_point_on_segment(m::Spherical, p, q0, q1; exact) + rk_orient(m, q0, q1, p; exact) == 0 || return false + return _on_arc_span(booltype(exact), p, q0, q1) +end +# Exact path: a certified Float64 triage (`_on_arc_span_filter`) that escalates +# to the `Rational{BigInt}` authority only when a span sign is not proven. On the +# candidate populations this fires on ~every shared-vertex / T-junction pair +# (the exact orient gate reduces `p` to the arc's great circle), and was the +# second spherical hot spot after `_sph_classify`. +@inline function _on_arc_span(bt::True, p, q0, q1) + r = _on_arc_span_filter(p, q0, q1) + r === nothing || return r + return _on_arc_span_authority(bt, p, q0, q1) +end +# Approximate path: the authority evaluated in Float64 (no exact contract to +# honour, so no filter — an errant sign here is the caller's accepted risk). +@inline _on_arc_span(bt::False, p, q0, q1) = _on_arc_span_authority(bt, p, q0, q1) + +@inline function _on_arc_span_authority(bt, p, q0, q1) + P = _vec3(bt, p); Q0 = _vec3(bt, q0); Q1 = _vec3(bt, q1) + n = _cross3(Q0, Q1) + if _iszero3(n) + # parallel endpoints — a zero-length arc (real rings carry repeated + # vertices; NE 110m North Korea has an `[A, A, B, A]` sliver ring) or + # an ill-defined antipodal pair: the closed arc holds only its + # endpoints, but with `n == 0` the span tests below are `0 >= 0` and + # would accept every `p` on the great circle (which the orient gate + # already reduced to every `p`, since orient against a zero normal is + # identically 0). Membership is direction coincidence with an endpoint. + return (_iszero3(_cross3(P, Q0)) && _dot3(P, Q0) > 0) || + (_iszero3(_cross3(P, Q1)) && _dot3(P, Q1) > 0) + end + return _dot3(_cross3(Q0, P), n) >= 0 && _dot3(_cross3(P, Q1), n) >= 0 +end + +# Certified forward-error constant for the two `_on_arc_span` span determinants +# `s = (u × v) · n`. Both are degree-4 polynomials in the point components; a +# Higham running-error analysis (products then a difference per cross component, +# products then a length-3 accumulation for the dot) bounds the rounding error +# by ~9u·Σᵢ|wᵢ_terms|·|nᵢ_terms| with u = ½eps. `16u` (this constant) carries a +# ~1.7× margin over the derived 9u for the dropped O(u²) terms and the rounding +# in the abs-magnitude sum itself. Scale-invariant (homogeneous degree 4), so it +# is valid for non-unit inputs (the exact-integer conformance rings) too. +const _SPAN_ERR_C = 16 * (eps(Float64) / 2) + +# Float64 triage of `_on_arc_span`'s decision `(q0×p)·n ≥ 0 && (p×q1)·n ≥ 0`, +# `n = q0×q1`. Returns the Bool iff BOTH span signs are certified by the bound, +# else `nothing` (escalate). A sign is reported only when |value| > its bound, +# so the filter can never disagree with the rational authority: a certified `<0` +# proves the result `false`; two certified `>0` prove it `true`; anything near a +# span boundary — including the exact-boundary `p == endpoint` and the +# degenerate `n == 0` (zero-length arc) cases, where the value sits inside its +# own bound — escalates. +@inline function _on_arc_span_filter(p, q0, q1) + # Shared-vertex short-circuit: an endpoint is on its own closed arc. This is + # exact and resolves the dominant real-data span call (adjacent rings share + # border vertices bit-for-bit), which otherwise always escalates — `s1` or + # `s2` is exactly 0 at an endpoint and sits inside its own error band. + (_usp_eq(p, q0) || _usp_eq(p, q1)) && return true + x0 = GI.x(q0); y0 = GI.y(q0); z0 = GI.z(q0) + x1 = GI.x(q1); y1 = GI.y(q1); z1 = GI.z(q1) + xp = GI.x(p); yp = GI.y(p); zp = GI.z(p) + # n = q0 × q1, with per-component abs-magnitude sums Nᵢ + n1 = y0*z1 - z0*y1; N1 = abs(y0*z1) + abs(z0*y1) + n2 = z0*x1 - x0*z1; N2 = abs(z0*x1) + abs(x0*z1) + n3 = x0*y1 - y0*x1; N3 = abs(x0*y1) + abs(y0*x1) + # s1 = (q0 × p) · n + w1 = y0*zp - z0*yp; W1 = abs(y0*zp) + abs(z0*yp) + w2 = z0*xp - x0*zp; W2 = abs(z0*xp) + abs(x0*zp) + w3 = x0*yp - y0*xp; W3 = abs(x0*yp) + abs(y0*xp) + s1 = w1*n1 + w2*n2 + w3*n3 + e1 = _SPAN_ERR_C * (W1*N1 + W2*N2 + W3*N3) + # s2 = (p × q1) · n + v1 = yp*z1 - zp*y1; V1 = abs(yp*z1) + abs(zp*y1) + v2 = zp*x1 - xp*z1; V2 = abs(zp*x1) + abs(xp*z1) + v3 = xp*y1 - yp*x1; V3 = abs(xp*y1) + abs(yp*x1) + s2 = v1*n1 + v2*n2 + v3*n3 + e2 = _SPAN_ERR_C * (V1*N1 + V2*N2 + V3*N3) + (s1 < -e1 || s2 < -e2) && return false # one span factor certainly < 0 + (s1 > e1 && s2 > e2) && return true # both span factors certainly > 0 + return nothing # near a boundary — escalate +end + +# ## Ingest and interaction bounds + +# Renormalize to unit length (Float32-sourced data — e.g. Natural Earth GeoJSON +# converted to Float64 — is ~1e-8 off unit and trips `robust_cross_product`). +@inline rk_normalize_usp(u) = UnitSphericalPoint(normalize(u)) + +# Canonical kernel point of a GeoInterface point: lon/lat (2D) → unit xyz, or an +# already-3D point treated as xyz; renormalized and signed-zero normalized so +# the same vertex always produces identical bits (NodeKey equality). The vertex +# ingest (Phase 3 `_to_kernel_point`) and the extent computation below share +# this, so a vertex and its extent agree exactly. +@inline function _spherical_kernel_point(p) + u = GI.is3d(p) ? + UnitSphericalPoint(Float64(GI.x(p)), Float64(GI.y(p)), Float64(GI.z(p))) : + UnitSphereFromGeographic()((Float64(GI.x(p)), Float64(GI.y(p)))) + return _node_point(rk_normalize_usp(u)) +end + +# Phase 3 ingest hooks (the planar methods live in kernel.jl). The spherical +# kernel point type is the unit-sphere xyz point; conversion is the canonical +# `_spherical_kernel_point` (lon/lat → unit xyz, or an already-xyz point +# renormalized), so an ingested vertex agrees bit-for-bit with its extent. +_kernel_point_type(::Spherical) = UnitSphericalPoint{Float64} +@inline _to_kernel_point(::Spherical, p) = _spherical_kernel_point(p) + +@inline _widen(lo, hi) = (prevfloat(lo, 4), nextfloat(hi, 4)) + +@noinline _throw_antipodal_edge(a, b) = throw(ArgumentError( + "spherical edge between antipodal vertices $(_tup3(a)) and $(_tup3(b)) has no " * + "unique great-circle arc; densify it first with the `AntipodalEdgeSplit` " * + "correction (it inserts the lon/lat midpoint)")) + +# Exactly antipodal pair: vanishing cross product, opposed directions. A +# vanishing cross with `u ⋅ v > 0` is a zero-length/repeated vertex, fine. +_exactly_antipodal(u, v) = iszero(cross(u, v)) && (u ⋅ v) < 0.0 + +# Ingest validation, once per curve at `RelateGeometry` construction: an +# exactly-antipodal edge has no unique great-circle arc, so throw rather +# than pick one (`spherical_arc_extent` picks a stable plane, never throws). +function _validate_relate_edges(::Spherical, curve) + n = GI.npoint(curve) + n < 2 && return nothing + prev = _spherical_kernel_point(GI.getpoint(curve, 1)) + for i in 2:n + cur = _spherical_kernel_point(GI.getpoint(curve, i)) + _exactly_antipodal(prev, cur) && _throw_antipodal_edge(prev, cur) + prev = cur + end + return nothing +end + +# ## rk_classify_intersection +# +# The two great circles meet at ±d, d = (a0×a1)×(b0×b1). `SS_PROPER` iff one of +# ±d is strictly interior to both minor arcs (the candidate-direct formulation — +# planar straddle tests are NOT sufficient on the sphere, where arcs can straddle +# each other's great circle while meeting only at the antipodal point). Endpoint +# incidences are exact arc-membership; collinear = the arcs share a great circle +# (d == 0). No intersection coordinate is constructed. +# +# ### Float-fast path (spike S4): the four-orient reduction +# +# The exact classification above was the sole spherical hot spot: it lifted the +# candidate direction `d`, the normals `na, nb`, and the four `_strictly_in_arc3` +# tests to `Rational{BigInt}` UNCONDITIONALLY for every candidate pair (measured +# ~30 µs/pair on clean crossings vs ~0.2 µs planar). The float stage the planar +# kernel gets from `AdaptivePredicates.orient` was simply absent here. +# +# It turns out no per-expression triage is needed, because the whole +# `_strictly_in_arc3(±d, …)` proper-crossing branch is *algebraically* a function +# of the four orientation signs, which `rk_orient` already resolves through +# ExactPredicates' float-filter→exact ladder (~3 ns when separated). With +# `na = a0×a1`, `nb = b0×b1`, `d = na×nb`, and using `a0·na = a1·na = 0` and +# BAC–CAB (`u×(v×w) = v(u·w) − w(u·v)`): +# +# (a0×d)·na = (a0·nb)|na|² (d×a1)·na = −(a1·nb)|na|² +# (b0×d)·nb = −(b0·na)|nb|² (d×b1)·nb = (b1·na)|nb|² +# +# so, writing `[u,v,w] = u·(v×w)` (exactly `rk_orient`), when `d ≠ 0` (⟹ `na≠0`, +# `nb≠0`): +# +# _strictly_in_arc3(d , a,·) ⟺ [b0,b1,a0]>0 ∧ [b0,b1,a1]<0 +# _strictly_in_arc3(d , b,·) ⟺ [a0,a1,b0]<0 ∧ [a0,a1,b1]>0 +# +# and `−d` flips every sign — the classic S2 four-orient near-crossing pattern. +# +# `d == 0` (same great circle / degenerate) ⟺ the four points are coplanar ⟺ +# ALL four orients are 0: any single nonzero orient proves `d ≠ 0`. A zero-length +# arc can't fake this — it forces its two orients *equal* (not a lone spurious +# zero), so the strict straddle pattern is false and the pair falls to DISJOINT +# or, when all four vanish, to the exact authority. That same-circle branch is +# rare on real data (≈0% of separated candidates), so it escalates to the +# unchanged `Rational{BigInt}` `_sph_classify`. Every non-escalated answer is +# bit-identical to `_sph_classify`'s (proven above; audited over 10⁶ random + +# adversarial pairs, zero disagreement). +rk_classify_intersection(m::Spherical, a0, a1, b0, b1; exact) = + _rk_classify_intersection(booltype(exact), m, a0, a1, b0, b1) + +# Exact path: the four-orient fast path. Provably bit-identical to the exact +# `_sph_classify` (derivation above; audited over 10⁶ random + 4·10⁵ adversarial +# pairs, zero disagreement) but built from float-filtered `rk_orient` signs +# instead of the unconditional `Rational{BigInt}` lift. +function _rk_classify_intersection(bt::True, m, a0, a1, b0, b1) + # The four exact orientation signs. `sABi = sign[a0,a1,bi]`, + # `sBAi = sign[b0,b1,ai]` — the same signs the four `rk_point_on_segment` + # arc-membership gates need, computed once and reused. + sAB0 = rk_orient(m, a0, a1, b0; exact = bt) + sAB1 = rk_orient(m, a0, a1, b1; exact = bt) + sBA0 = rk_orient(m, b0, b1, a0; exact = bt) + sBA1 = rk_orient(m, b0, b1, a1; exact = bt) + # Arc membership: `p` on the arc iff coplanar (orient 0) and within the + # minor-arc span. `_on_arc_span` (float-filtered) only fires on the coplanar + # `== 0` cases, i.e. shared vertices / T-junctions. + a0_on_b = sBA0 == 0 && _on_arc_span(bt, a0, b0, b1) + a1_on_b = sBA1 == 0 && _on_arc_span(bt, a1, b0, b1) + b0_on_a = sAB0 == 0 && _on_arc_span(bt, b0, a0, a1) + b1_on_a = sAB1 == 0 && _on_arc_span(bt, b1, a0, a1) + if sAB0 == 0 && sAB1 == 0 && sBA0 == 0 && sBA1 == 0 + # d == 0: same great circle or a degenerate arc — the exact authority + return _sph_classify(bt, a0, a1, b0, b1, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + # d ≠ 0 (proven exactly by a nonzero orient). Endpoint incidence ⇒ touch. + if a0_on_b || a1_on_b || b0_on_a || b1_on_a + return SegSegClass(SS_TOUCH, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + # Proper crossing ⟺ +d or −d strictly interior to both arcs, i.e. the + # four-orient near-crossing pattern (equal to `_strictly_in_arc3(±d,…)`). + proper = (sBA0 > 0 && sBA1 < 0 && sAB0 < 0 && sAB1 > 0) || + (sBA0 < 0 && sBA1 > 0 && sAB0 > 0 && sAB1 < 0) + return proper ? SegSegClass(SS_PROPER, false, false, false, false) : + SegSegClass(SS_DISJOINT, false, false, false, false) +end + +# Approximate path (`exact = False()`): the original float formulation, kept +# bit-for-bit. There is no Rational to shed here (`_sph_classify(False())` is +# already all-Float64), and the four-orient float reduction rounds differently +# than the float `_strictly_in_arc3` on near-collinear arcs — a sign the caller +# has already opted out of, but no reason to perturb the established output. +function _rk_classify_intersection(bt::False, m, a0, a1, b0, b1) + a0_on_b = rk_point_on_segment(m, a0, b0, b1; exact = bt) + a1_on_b = rk_point_on_segment(m, a1, b0, b1; exact = bt) + b0_on_a = rk_point_on_segment(m, b0, a0, a1; exact = bt) + b1_on_a = rk_point_on_segment(m, b1, a0, a1; exact = bt) + return _sph_classify(bt, a0, a1, b0, b1, a0_on_b, a1_on_b, b0_on_a, b1_on_a) +end + +function _sph_classify(bt, a0, a1, b0, b1, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + A0 = _vec3(bt, a0); A1 = _vec3(bt, a1); B0 = _vec3(bt, b0); B1 = _vec3(bt, b1) + na = _cross3(A0, A1); nb = _cross3(B0, B1) + d = _cross3(na, nb) + n_inc = a0_on_b + a1_on_b + b0_on_a + b1_on_a + if _iszero3(d) # same great circle (or a degenerate, zero-length arc) + n_inc == 0 && return SegSegClass(SS_DISJOINT, false, false, false, false) + # a degenerate (zero-length) arc on the other is a touch, not an overlap + zero_len = _iszero3(na) || _iszero3(nb) + shared_only = n_inc == 2 && (a0_on_b || a1_on_b) && (b0_on_a || b1_on_a) && + (_usp_eq(a0, b0) || _usp_eq(a0, b1) || _usp_eq(a1, b0) || _usp_eq(a1, b1)) + kind = (shared_only || zero_len) ? SS_TOUCH : SS_COLLINEAR + return SegSegClass(kind, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + if a0_on_b || a1_on_b || b0_on_a || b1_on_a + return SegSegClass(SS_TOUCH, a0_on_b, a1_on_b, b0_on_a, b1_on_a) + end + nd = _neg3(d) + if (_strictly_in_arc3(d, A0, A1, na) && _strictly_in_arc3(d, B0, B1, nb)) || + (_strictly_in_arc3(nd, A0, A1, na) && _strictly_in_arc3(nd, B0, B1, nb)) + return SegSegClass(SS_PROPER, false, false, false, false) + end + return SegSegClass(SS_DISJOINT, false, false, false, false) +end + +# ## Angle ordering at nodes (tangent-plane port of PolygonNodeTopology) +# +# Directions around an apex `n` live in the tangent plane at `n`. Pick a +# reference axis `r` (the coordinate axis least aligned with `n`, so `r ≁ ±n`); +# the tangent frame is `u = r - (r·n̂)n̂`, `v = n × r`, right-handed with `u×v = +# n̂`. A direction toward `p` has tangent coordinates `(p·u, p·v)`, and we only +# need their *signs* — both are determinant signs of `n, r, p`, exact for +# integer inputs and scale-corrected so the apex need not be unit: +# sign(p·u) = sign((p·r)(n·n) - (r·n)(p·n)), sign(p·v) = sign((n×r)·p). +# Feeding these to the planar quadrant scheme, with the same-quadrant tiebreak +# `rk_orient(m, n, q, p) = sign((n×q)·p)` (already the tangent-plane CCW sign), +# reproduces PolygonNodeTopology exactly. + +# Coordinate axis least aligned with `n3` (smallest |component|, first-index +# tiebreak — matches `argmin`), as a unit vector of `n3`'s element type. +@inline function _ref_axis(n3) + ax, ay, az = abs(n3[1]), abs(n3[2]), abs(n3[3]) + o = one(ax); z = zero(ax) + if ax <= ay && ax <= az + return (o, z, z) + elseif ay <= az + return (z, o, z) + else + return (z, z, o) + end +end + +# JTS quadrant of the direction toward `P3` around apex `n3` with reference +# `r3`: NE=0, NW=1, SW=2, SE=3, axis directions on the `>= 0` side. +@inline function _sph_quadrant3(n3, r3, P3) + nn = _dot3(n3, n3); nr = _dot3(n3, r3); pn = _dot3(P3, n3); pr = _dot3(P3, r3) + su = pr * nn - nr * pn # sign of P·u + sv = _dot3(_cross3(n3, r3), P3) # sign of P·v + (su == 0 && sv == 0) && + throw(ArgumentError("cannot compute the quadrant of a zero-length direction")) + if su >= 0 + return sv >= 0 ? 0 : 3 + else + return sv >= 0 ? 1 : 2 + end +end + +function rk_quadrant(::Spherical, origin, p) + n3 = _tup3(origin) + return _sph_quadrant3(n3, _ref_axis(n3), _tup3(p)) +end + +# compareAngle around an explicit apex direction `n3` (a vec3 tuple): the +# crossing-apex slow path, where `n3` is the *constructed* crossing direction +# and so must be compared with explicit determinant signs (not ExactPredicates, +# which needs Float64 vertices). Mirrors `_compare_angle`: quadrant first, then +# the orient tiebreak `sign((n×q)·p)`. +function _sph_compare_around(bt, n3, p, q) + P = _vec3(bt, p); Q = _vec3(bt, q) + r3 = _ref_axis(n3) + qp = _sph_quadrant3(n3, r3, P) + qq = _sph_quadrant3(n3, r3, Q) + qp > qq && return 1 + qp < qq && return -1 + o = _dot3(_cross3(n3, Q), P) + return o > 0 ? 1 : (o < 0 ? -1 : 0) +end + +# The crossing direction (the sphere point where the two arcs of a crossing +# node meet): ±(na×nb), the candidate strictly interior to both minor arcs. +function _sph_crossing_dir(bt, node::NodeKey) + A0 = _vec3(bt, node.pt); A1 = _vec3(bt, node.a1) + B0 = _vec3(bt, node.b0); B1 = _vec3(bt, node.b1) + na = _cross3(A0, A1); nb = _cross3(B0, B1) + d = _cross3(na, nb) + (_strictly_in_arc3(d, A0, A1, na) && _strictly_in_arc3(d, B0, B1, nb)) && return d + return _neg3(d) +end + +function rk_compare_edge_dir(m::Spherical, node::NodeKey, p, q; exact) + node.is_crossing || return _compare_angle(m, node.pt, p, q; exact) + # Crossing apex: unlike the plane, the tangent direction apex→x is not + # parallel to opp(x)→x, so the planar endpoint substitution does not carry + # over. Compare around the (exact, on-arc) crossing direction instead — + # the slow path, only on crossing-node edge ordering. + bt = booltype(exact) + return _sph_compare_around(bt, _sph_crossing_dir(bt, node), p, q) +end + +# ## rk_nodes_coincide (exact slow path) +# +# Whether two node keys denote the same sphere point. The point of a vertex node +# is its coordinate direction; of a crossing node, the on-arc crossing direction +# `±(na×nb)`. Two directions denote the same sphere point iff they are parallel +# (cross product zero) and point into the same hemisphere (positive dot) — `-d` +# is the antipodal point, a different node. Exact via `Rational{BigInt}` (the +# `True()` branch of `_vec3`), mirroring the planar D3 rational slow path. +@inline _exact_node_dir(bt, k::NodeKey) = + k.is_crossing ? _sph_crossing_dir(bt, k) : _vec3(bt, k.pt) + +function rk_nodes_coincide(::Spherical, k1::NodeKey, k2::NodeKey; exact) + k1 == k2 && return true + bt = booltype(exact) + d1 = _exact_node_dir(bt, k1); d2 = _exact_node_dir(bt, k2) + return _iszero3(_cross3(d1, d2)) && _dot3(d1, d2) > 0 +end + +# ## Ring orientation + +#= +Spherical method of `_ring_is_ccw` (relate_geometry.jl — the port of JTS +`Orientation.isCCW` used by `_orient_ring`). The planar extreme-vertex cap +algorithm assumes a coordinate plane: its y-extreme vertex pick and flat-cap +`del_x` tiebreak are meaningless on xyz points (a ring symmetric about the +equator has two exactly-equal extreme-y vertices and reads CW in *both* +windings). On the sphere the ring is CCW iff the region on its left is the +enclosed one — the one no larger than a hemisphere — decided by the sign of +the loop's geodesic curvature (Gauss–Bonnet: enclosed area = 2π − curvature), +the port of S2 `GetCurvature` (s2loop_measures.cc). Each turn angle involves +only ADJACENT vertex pairs, so an antipodal pair of non-adjacent vertices — +legal at ingest, and produced by `AntipodalEdgeSplit` — never meets in one +term (the previous Girard fan ran chords from a single apex through the +whole ring and degenerated on exactly those pairs). + +This is the sole place the engine resolves which of the two ring-bounded +regions an unoriented ring means; `_orient_ring` (edge-side topology), +`rk_point_in_ring`, and `rk_interaction_bounds` all inherit it, so they +agree by construction. + +As in S2 `IsNormalized`, the sign test allows the curvature error bound +(`GetCurvatureMaxError`: 11.25ε per vertex), so an exact hemisphere — +curvature 0, intrinsically winding-ambiguous — reads CCW in *both* windings +rather than falling to the sign of rounding noise. `exact` is accepted for +signature parity but unused: the turn-angle signs are always exact +(`_rk_orient(True(), …)`, our port of S2's `Sign`). Vertices are +renormalized on entry (`robust_cross_product` expects unit vectors; the +conformance suite feeds exact-integer non-unit rings). +=# +function _ring_is_ccw(::Spherical, ring::Vector; exact) + loop = _prune_loop_degeneracies([rk_normalize_usp(p) for p in ring]) + n = length(loop) + n < 3 && return false # bounds no area (JTS convention for flat rings) + return _spherical_loop_curvature(loop) >= -(11.25 * eps(Float64) * n) +end + +#= +Port of S2 `PruneDegeneracies` (s2loop_measures.cc): the loop with all +degenerate segments removed — repeated vertices (`AA → A`, wraparound +included) and retraced whiskers (`ABA → A`, including whiskers straddling +the closure) — so every remaining vertex has two distinct, non-retracing +neighbors and its turn angle is well defined. Returns fewer than 3 vertices +for a completely degenerate loop. +=# +function _prune_loop_degeneracies(pts::Vector) + vertices = empty(pts) + sizehint!(vertices, length(pts)) + for v in pts + if !isempty(vertices) + v == vertices[end] && continue # AA → A + if length(vertices) >= 2 && v == vertices[end - 1] # ABA → A + pop!(vertices) + continue + end + end + push!(vertices, v) + end + length(vertices) > 1 && vertices[1] == vertices[end] && pop!(vertices) + m = length(vertices) + m < 3 && return vertices + # whiskers straddling the closure (the loop begins with `BA…` and ends + # with `…A`, or begins with `A…` and ends with `…AB`): strip first/last + # pairs while the terminal edges retrace each other — guaranteed to stop + # before consuming the loop (S2: some portion is non-degenerate) + k = 0 + while vertices[k + 2] == vertices[m - k] || vertices[k + 1] == vertices[m - k - 1] + k += 1 + end + return k == 0 ? vertices : vertices[(k + 1):(m - k)] +end + +# Port of S2 `TurnAngle` (s2measures.cc): the signed turning angle at `b` on +# the walk a → b → c, positive for a left (CCW) turn. The magnitude is the +# angle between the edge normals (`robust_cross_product` keeps it accurate +# when adjacent vertices are nearly coincident); the sign comes from the +# exact orient, correct even for turns close to ±180°. +function _sph_turn_angle(a, b, c) + angle = _usp_angle(robust_cross_product(a, b), robust_cross_product(b, c)) + return _rk_orient(True(), a, b, c) > 0 ? angle : -angle +end + +# S2 `Vector3.Angle`: atan2(|u×v|, u·v), stable near both parallel and +# antiparallel (acos of the dot is not). +_usp_angle(u, v) = atan(norm(cross(u, v)), u ⋅ v) + +#= +Port of S2 `GetCurvature` (s2loop_measures.cc) over a pruned loop: the sum +of the turn angles, taken in canonical order and Kahan-compensated (a naive +sum's error is quadratic in the vertex count on spiral-like inputs), then +restored to the stored direction's sign. Positive curvature ⇔ the region on +the loop's left is smaller than a hemisphere (its area is 2π − curvature). +=# +function _spherical_loop_curvature(loop) + n = length(loop) + i, dir = _canonical_loop_order(loop) + at(k) = loop[mod1(k, n)] + total = _sph_turn_angle(at(i - dir), at(i), at(i + dir)) + compensation = 0.0 + for _ in 1:(n - 1) + i += dir + angle = _sph_turn_angle(at(i - dir), at(i), at(i + dir)) + compensation + old_total = total + total += angle + compensation = (old_total - total) + angle + end + return dir * (total + compensation) +end + +#= +Port of S2 `GetCanonicalLoopOrder` (s2loop_measures.cc): the traversal +`(start, dir)` minimizing the traversed vertex sequence lexicographically +over all rotations of both directions. A loop and its reversal share the +same canonical sequence, so summing turn angles along it — and restoring +the stored direction's sign afterwards, as `_spherical_loop_curvature` +does — makes the curvature exactly invariant under rotation and exactly +negated under reversal, which no fixed storage-order float sum is. +=# +function _canonical_loop_order(loop) + n = length(loop) + min_indices = [1] + for i in 2:n + if _tup3(loop[i]) <= _tup3(loop[min_indices[1]]) + _tup3(loop[i]) < _tup3(loop[min_indices[1]]) && empty!(min_indices) + push!(min_indices, i) + end + end + best = (min_indices[1], 1) + for i in min_indices + _loop_order_less((i, 1), best, loop) && (best = (i, 1)) + _loop_order_less((i, -1), best, loop) && (best = (i, -1)) + end + return best +end + +# Port of S2 `IsOrderLess`: whether traversal `o1` yields a lexicographically +# smaller vertex sequence than `o2` (both start at the same minimal vertex). +function _loop_order_less(o1, o2, loop) + o1 == o2 && return false + n = length(loop) + (i1, d1) = o1 + (i2, d2) = o2 + for _ in 1:(n - 1) + i1 += d1; i2 += d2 + p1 = _tup3(loop[mod1(i1, n)]); p2 = _tup3(loop[mod1(i2, n)]) + p1 < p2 && return true + p1 > p2 && return false + end + return false +end + +#= +`_ring_interior_on_left` (generic method in relate_geometry.jl) with the +manifold mode applied: on `Spherical(; oriented = true)` the stored winding +is authoritative, per S2 `InitOriented` (s2polygon.h) — "the input loops +[are] oriented such that the polygon interior is on the left-hand side of +every loop", exterior rings counterclockwise and interior rings clockwise. +A shell's denoted region is therefore the region on its left, and a hole — +wound oppositely, with the polygon interior on ITS left too — has its +cavity (the region the hole denotes to the engine) on its right. Nothing is +computed from the coordinates, so a ring wound against the convention +simply denotes the complement region — which is how regions larger than a +hemisphere are expressed. +=# +_ring_interior_on_left(m::Spherical, pts::Vector, is_hole::Bool; exact) = + m.oriented ? !is_hole : _ring_is_ccw(m, pts; exact) + +# ## rk_point_in_ring (definitional-exterior crossing parity, winding-independent) + +# Whether the two minor arcs (p0,p1) and (q0,q1) cross properly (interior to +# both). The great circles meet at ±d, d = (p0×p1)×(q0×q1); a proper crossing is +# one of ±d strictly interior to both arcs (the spike's `arcs_cross_properly`). +function _arcs_cross_properly(bt, p0, p1, q0, q1) + P0 = _vec3(bt, p0); P1 = _vec3(bt, p1); Q0 = _vec3(bt, q0); Q1 = _vec3(bt, q1) + na = _cross3(P0, P1); nb = _cross3(Q0, Q1) + d = _cross3(na, nb) + _iszero3(d) && return false + (_strictly_in_arc3(d, P0, P1, na) && _strictly_in_arc3(d, Q0, Q1, nb)) && return true + nd = _neg3(d) + return _strictly_in_arc3(nd, P0, P1, na) && _strictly_in_arc3(nd, Q0, Q1, nb) +end + +# Ring vertices as spherical kernel points. A 3D ring is already in kernel +# coordinates (e.g. the conformance suite's exact integer USP rings) and is read +# verbatim — renormalizing would perturb the exact orient the boundary test +# relies on. A 2D (lon/lat) ring — the engine's ingested polygon — is converted +# to unit xyz. +_ring_kernel_pts(ring) = _ring_kernel_pts(booltype(GI.is3d(GI.getpoint(ring, 1))), ring) +_ring_kernel_pts(::True, ring) = _node_points(ring) +_ring_kernel_pts(::False, ring) = _ring_usp(ring) + +# Location of `p` relative to the region denoted by `ring` — per the kernel +# contract (kernel.jl): winding-independent by default, like the planar +# ray-crossing parity (real-world rings arrive in either winding — Natural +# Earth ships shapefile-convention CW shells — and +# `_locate_point_in_polygonal` passes them unoriented); winding-authoritative +# with role `is_hole` on an oriented manifold. Boundary first (exact arc +# membership), then the parity test, with this kernel's predicates injected — +# `rk_orient` for sides, `_arcs_cross_properly` for transversality — so the +# decision is as exact as the predicates. +# +# In the default enclosed-region mode the parity is the shared +# `spherical_ring_encloses`: even-odd crossing parity anchored at the +# antipode of the ring's vertex mass, a point exterior BY DEFINITION of the +# semantics. No winding bit is consulted, so a ring that self-intersects on +# the sphere (a planar-valid figure-eight — see the `prepare` validation) +# degrades to even-odd answers instead of inverting globally: the previous +# bootstrap composed the local interior-side wedge of one edge +# (`spherical_ring_contains`) with the turning-angle winding +# (`_ring_interior_on_left`), and a figure-eight defeats both at once — the +# lobes cancel the turning angle while the wedge propagates whichever lobe +# hosts the anchor edge. When the definitional anchor is itself degenerate +# (near-hemisphere vertex mass, or `p` at the mass center) the query falls +# back to that wedge-plus-winding bootstrap — for such rings the +# enclosed/complement distinction is near-degenerate anyway, and the +# turning-angle tolerance already treats hemispheres permissively. +# +# On `Spherical(; oriented = true)` the stored winding is authoritative +# (garbage-in-garbage-out is that mode's documented contract), so the wedge +# bootstrap IS the semantics: `spherical_ring_contains` reports the region +# on the ring's *left*, the interior iff `_ring_interior_on_left` (the same +# bit `_orient_ring` feeds the edge-side topology). All anchors degenerate +# (unreachable for a non-degenerate ring and an off-boundary point) is +# refused, not answered wrong. +rk_point_in_ring(m::Spherical, p, ring; exact, is_hole::Bool = false) = + rk_point_in_ring(m, p, SphericalKernelRing(m, ring; exact, is_hole); exact) + +""" + SphericalKernelRing(m::Spherical, ring; exact, is_hole = false) + +The cached kernel-space form of one ring: the converted +`UnitSphericalPoint` vertex vector (`pts` — the boundary edge walk), its +deduped open form (`ded`/`n` — the parity walk; aliases `pts` when the +ring has no repeated vertices), the ring's denoted-region bit +(`_ring_interior_on_left`, from the ring's winding or — on an oriented +manifold — its declared role; the same bit edge topology and interaction +bounds use), and — in enclosed-region mode — the definitional-exterior +parity anchor (`spherical_exterior_anchor`; `nothing` on an oriented +manifold, which never consults it, or for a degenerate vertex mass, where +queries fall back to the wedge bootstrap). + +`rk_point_in_ring` re-derived all of this from lon/lat on every query — +vertex conversion alone was ~60% of a prepared spherical point query. The +point-in-area locators (indexed_point_in_area.jl) convert each ring once +and query on this form (Layer 1 of the 2026-07-14 spherical-indexed-locator +design). + +Repeated consecutive vertices are dropped from the parity walk (real rings +carry them — NE 110m North Korea's sliver is `[A, A, B, A]`; JTS removes +them at ingest, but this path receives the raw ring): a retraced edge lies +exactly under the anchor midpoint and breaks the parity count. After dedup +a ring with fewer than 3 distinct vertices bounds no area. +""" +struct SphericalKernelRing + pts::Vector{UnitSphericalPoint{Float64}} + ded::Vector{UnitSphericalPoint{Float64}} + n::Int + interior_on_left::Bool + anchor::Union{Nothing, UnitSphericalPoint{Float64}} +end + +function SphericalKernelRing(m::Spherical, ring; exact, is_hole::Bool = false) + pts = _ring_kernel_pts(ring) + n = length(pts) + n > 1 && pts[end] == pts[1] && (n -= 1) + ded, n = _drop_repeated_ring_pts(pts, n) + interior_on_left = n >= 3 && _ring_interior_on_left(m, ded, is_hole; exact) + anchor = (!m.oriented && n >= 3) ? spherical_exterior_anchor(ded, n) : nothing + return SphericalKernelRing(pts, ded, n, interior_on_left, anchor) +end + +# Type-stable functors for the predicates injected into +# `spherical_ring_contains` (Layer 3 of the spherical-indexed-locator +# design): the anonymous closures they replace were rebuilt per call and +# heap-boxed, costing an allocation and dynamic dispatch per predicate call +# on the point-in-area hot path. The injectable-predicate design of +# `spherical_ring_contains` is unchanged. +struct _RKOrient{M <: Spherical, E} <: Function + m::M + exact::E +end +(f::_RKOrient)(a, b, c) = rk_orient(f.m, a, b, c; exact = f.exact) + +struct _RKProperCrossing{BT} <: Function + bt::BT +end +(f::_RKProperCrossing)(q, mid, a, b) = _arcs_cross_properly(f.bt, q, mid, a, b) ? 1 : 0 + +# Exact span test for the anchor walk's vertex-grazing resolution +# (`_anchor_crossing_parity`): whether `p`, already known to lie on the +# great circle of `(a, b)`, lies on the closed minor arc. +struct _RKOnTestArc{BT} <: Function + bt::BT +end +(f::_RKOnTestArc)(p, a, b) = _on_arc_span(f.bt, p, a, b) + +function rk_point_in_ring(m::Spherical, p, kr::SphericalKernelRing; exact) + pts = kr.pts + @inbounds for i in 1:length(pts)-1 + rk_point_on_segment(m, p, pts[i], pts[i+1]; exact) && return LOC_BOUNDARY + end + #-- rings are closed regardless of a repeated last point (the kernel + #-- contract), so an implicitly closed ring's closing edge is boundary + #-- too — the same edge set the longitude-interval index walks + if length(pts) > 1 && pts[end] != pts[1] && + rk_point_on_segment(m, p, pts[end], pts[1]; exact) + return LOC_BOUNDARY + end + kr.n < 3 && return LOC_EXTERIOR + orient = _RKOrient(m, exact) + on_arc = Returns(false) # boundary classified exactly above + proper_crossing = _RKProperCrossing(booltype(exact)) + if !m.oriented + #-- enclosed-region mode: even-odd parity from the definitional + #-- exterior anchor (see the section comment); `nothing` — degenerate + #-- anchor or `p` at the mass center — falls through to the wedge + enc = spherical_ring_encloses(kr.ded, kr.n, p; anchor = kr.anchor, + orient, on_arc, proper_crossing, + on_test_arc = _RKOnTestArc(booltype(exact))) + enc === nothing || return enc ? LOC_INTERIOR : LOC_EXTERIOR + end + inside = spherical_ring_contains(kr.ded, kr.n, p; orient, on_arc, proper_crossing) + inside === nothing && _throw_degenerate_point_in_ring(p) + return inside == kr.interior_on_left ? LOC_INTERIOR : LOC_EXTERIOR +end + +@noinline _throw_degenerate_point_in_ring(p) = throw(ArgumentError( + "rk_point_in_ring: every anchor edge of the ring is degenerate with " * + "respect to the query point $(_tup3(p)) — the ring is degenerate at " * + "this point")) + +# Interaction bounds on the sphere: the shared substrate +# (`spherical_arc_extent` per edge, `_spherical_region_extent` for area +# interiors) over kernel-converted points, so box and ingested vertices +# agree bit-for-bit. Rings are dim-1 linework here (JTS semantics), not S2 +# regions — a CW hole must not become a complement region. Boxes get a few +# ulps of padding so a vertex from another conversion path still prunes as +# interacting. +rk_interaction_bounds(m::Spherical, geom) = + _pad_bounds(_sph_interaction_extent(m, GI.trait(geom), geom)) + +_sph_interaction_extent(m::Spherical, ::GI.AbstractPointTrait, geom) = + GI.extent(_spherical_kernel_point(geom)) +function _sph_interaction_extent(m::Spherical, ::GI.AbstractCurveTrait, geom) + n = GI.npoint(geom) + prev = _spherical_kernel_point(GI.getpoint(geom, 1)) + # seeding with pts[1]'s box covers the degenerate n == 1 curve; it is + # absorbed by the first edge box otherwise + ext = spherical_arc_extent(prev, prev) + for i in 2:n + cur = _spherical_kernel_point(GI.getpoint(geom, i)) + ext = Extents.union(ext, spherical_arc_extent(prev, cur)) + prev = cur + end + return ext +end +function _sph_interaction_extent(m::Spherical, ::GI.AbstractPolygonTrait, geom) + # region box of the exterior ring: edge arc extents plus enclosed-axis + # widening, on the same converted points the engine ingests. + # `_spherical_region_extent` bounds the region on the ring's left, so + # orient the shell's denoted region onto its left first — unoriented, a + # CW-wound input (shapefile convention) would otherwise bound the + # complement, under-covering an enclosed pole; oriented, the shell is + # interior-on-left by declaration and is used verbatim, so a complement + # shell's box covers (nearly) the whole sphere — unprunable but correct + # (`exact` is unused by the spherical `_ring_is_ccw`) + pts = _orient_ring(m, _ring_usp(GI.getexterior(geom)), false, false; exact = True()) + ext = _spherical_region_extent(pts) + # a valid polygon's holes lie inside that region — but JTS's element + # envelope also covers a stray hole outside the shell, and extraction + # relies on that to keep the element alive + # (see `_extract_segment_strings_from_atomic!`) + for hole in GI.gethole(geom) + GI.isempty(hole) && continue + ext = Extents.union(ext, _sph_interaction_extent(m, GI.trait(hole), hole)) + end + return ext +end +function _sph_interaction_extent(m::Spherical, ::GI.AbstractGeometryTrait, geom) + ext = nothing + for g in GI.getgeom(geom) + GI.isempty(g) && continue + e = _sph_interaction_extent(m, GI.trait(g), g) + ext = ext === nothing ? e : Extents.union(ext, e) + end + return ext +end + +# Converted (kernel-ingest: unit, signed-zero) vertices of a ring/curve. +_ring_usp(ring) = [_spherical_kernel_point(p) for p in GI.getpoint(ring)] + +# `pts[1:n]` (implied closure) with repeated consecutive vertices removed, +# copying only when one exists; wraparound repeats included. +function _drop_repeated_ring_pts(pts, n) + has_dup = false + for i in 1:n + if pts[i] == pts[mod1(i + 1, n)] + has_dup = true + break + end + end + has_dup || return pts, n + ded = empty(pts) + sizehint!(ded, n) + for i in 1:n + (isempty(ded) || ded[end] != pts[i]) && push!(ded, pts[i]) + end + length(ded) > 1 && ded[end] == ded[1] && pop!(ded) + return ded, length(ded) +end + +_pad_bounds(::Nothing) = nothing +_pad_bounds(ext) = Extents.Extent( + X = _widen(ext.X...), Y = _widen(ext.Y...), Z = _widen(ext.Z...)) diff --git a/src/methods/geom_relations/relateng/node_sections.jl b/src/methods/geom_relations/relateng/node_sections.jl new file mode 100644 index 0000000000..6d585122d3 --- /dev/null +++ b/src/methods/geom_relations/relateng/node_sections.jl @@ -0,0 +1,352 @@ +# # RelateNG node sections +# +# Ports of JTS `NodeSection.java` and `NodeSections.java`, in this order +# (JTS file boundaries preserved as clearly marked sections): +# +# 1. `NodeSection` — a geometry component's contribution to a node +# (struct created in Task 11 for `AdjacentEdgeLocator`; full API Task 15) +# 2. `NodeSections` — the collector of all sections at one node +# +# Method order within each section parallels the Java file, so this file +# diffs against its Java counterparts. + +#========================================================================== +## NodeSection (port of JTS NodeSection.java) +==========================================================================# + +""" + NodeSection{P} + +Represents a computed node along with the incident edges on either side of +it (if they exist). This captures the information about a node in a geometry +component required to determine the component's contribution to the node +topology. A node in an area geometry always has edges on both sides of the +node. A node in a linear geometry may have one or other incident edge +missing, if the node occurs at an endpoint of the line. + +The edges of an area node are assumed to be provided with CW-shell +orientation (as per JTS norm). This must be enforced by the caller. + +Port of JTS `NodeSection`, with one symbolic twist (design D2): the Java +class stores the node as a `Coordinate`; here `node` is a [`NodeKey`](@ref), +so proper-crossing nodes never need a constructed coordinate. `v0`/`v1` are +coordinate tuples of type `P` (or `nothing` for a missing incident edge at a +line endpoint); `polygonal` is the parent polygonal geometry of an area +section, or `nothing` if the section is not on a polygon boundary — an +opaque payload (abstract field type, as in Java): it is only compared by +identity, so parameterizing on it would re-specialize the node machinery +per input geometry type for no runtime gain. + +The field order matches the Java constructor argument order +`(isA, dimension, id, ringId, poly, isNodeAtVertex, v0, nodePt, v1)`. +(The struct is declared before the comparator helpers below because their +signatures reference it; the Java file declares `EdgeAngleComparator` and +`isAreaArea` first.) +""" +struct NodeSection{P} + is_a::Bool + dim::Int8 + id::Int32 + ring_id::Int32 + polygonal::Any + is_node_at_vertex::Bool + v0::Union{P, Nothing} + node::NodeKey{P} + v1::Union{P, Nothing} +end + +""" + edge_angle_compare(m::Manifold, ns1::NodeSection, ns2::NodeSection; exact) + +Compares sections by the angle the entering edge (`get_vertex(ns, 0)`) makes +with the positive X axis at the node, angles increasing CCW. + +Port of `NodeSection.EdgeAngleComparator` (a static nested `Comparator` +class in Java, `compareAngle(ns1.nodePt, ns1.getVertex(0), ns2.getVertex(0))`); +here a comparator function over `rk_compare_edge_dir` with the +symbolic node of `ns1` as apex, taking the manifold and `exact` flag the +kernel comparison needs. Use as a sort predicate via +`lt = (a, b) -> edge_angle_compare(m, a, b; exact) < 0`. + +At a crossing node (a `NodeKey` with `is_crossing`) the sections' `v0` are +normally among the four endpoints of the node's defining segments (the +sections are built from those segments themselves), where the comparison +is derived from the original endpoints. Sections merged onto the node by +the D3 coincidence pass (`TopologyComputer`) may carry foreign directions; +`rk_compare_edge_dir` then compares around the exact rational apex. +""" +edge_angle_compare(m::Manifold, ns1::NodeSection, ns2::NodeSection; exact) = + rk_compare_edge_dir(m, ns1.node, get_vertex(ns1, 0), get_vertex(ns2, 0); exact) + +# Port of NodeSection.isAreaArea: whether both sections are area sections. +is_area_area(a::NodeSection, b::NodeSection) = + section_dim(a) == DIM_A && section_dim(b) == DIM_A + +# Port of NodeSection.getVertex(i): the incident edge vertex before (0) or +# after (1) the node, or `nothing` if that edge does not exist. +get_vertex(ns::NodeSection, i::Integer) = i == 0 ? ns.v0 : ns.v1 + +# Port of NodeSection.nodePt. The Java method returns the node Coordinate; +# here the node is its symbolic NodeKey (design D2). +node_pt(ns::NodeSection) = ns.node + +# Port of NodeSection.dimension. +section_dim(ns::NodeSection) = ns.dim + +# Port of NodeSection.id. +section_id(ns::NodeSection) = ns.id + +# Port of NodeSection.ringId. +ring_id(ns::NodeSection) = ns.ring_id + +""" + get_polygonal(ns::NodeSection) + +Gets the polygon this section is part of. +Will be `nothing` if section is not on a polygon boundary. + +Port of NodeSection.getPolygonal. +""" +get_polygonal(ns::NodeSection) = ns.polygonal + +# Port of NodeSection.isShell. +is_shell(ns::NodeSection) = ns.ring_id == 0 + +# Port of NodeSection.isArea. +is_area(ns::NodeSection) = ns.dim == DIM_A + +# Port of NodeSection.isA. +is_a(ns::NodeSection) = ns.is_a + +# Port of NodeSection.isSameGeometry: both sections from the same input +# geometry (A or B). +is_same_geometry(ns::NodeSection, other::NodeSection) = is_a(ns) == is_a(other) + +# Port of NodeSection.isSamePolygon: both sections from the same polygon +# element of the same input geometry. (Element ids are only unique within +# one input geometry.) +is_same_polygon(ns::NodeSection, other::NodeSection) = + is_a(ns) == is_a(other) && section_id(ns) == section_id(other) + +# Port of NodeSection.isNodeAtVertex. +is_node_at_vertex(ns::NodeSection) = ns.is_node_at_vertex + +# Port of NodeSection.isProper: a node is "proper" for a section if it lies +# in the interior of an edge (i.e. NOT at a vertex of the component). +is_proper(ns::NodeSection) = !ns.is_node_at_vertex + +# Port of the static NodeSection.isProper(a, b): both sections proper. +is_proper(a::NodeSection, b::NodeSection) = is_proper(a) && is_proper(b) + +# Port of NodeSection.toString (+ edgeRep), as a debugging aid. The node is +# symbolic, so crossing nodes print their NodeKey segment pair instead of a +# coordinate. +function Base.show(io::IO, ns::NodeSection) + at_vertex_ind = ns.is_node_at_vertex ? "-V-" : "---" + poly_id = ns.id >= 0 ? "[$(ns.id):$(ns.ring_id)]" : "" + print(io, geom_name(ns.is_a), ns.dim, poly_id, ": ", + _edge_string(ns.v0, ns.node), " ", at_vertex_ind, " ", _edge_string(ns.node, ns.v1)) +end + +_edge_string(p0, p1) = + (p0 === nothing || p1 === nothing) ? "null" : string(_point_string(p0), " - ", _point_string(p1)) +_point_string(p) = "($(GI.x(p)) $(GI.y(p)))" +_point_string(k::NodeKey) = k.is_crossing ? + string("X[", _point_string(k.pt), " - ", _point_string(k.a1), " × ", _point_string(k.b0), " - ", _point_string(k.b1), "]") : + _point_string(k.pt) + +""" + compare_to(ns::NodeSection, other::NodeSection) + +Compare node sections by parent geometry, dimension, element id and ring id, +and edge vertices. Sections are assumed to be at the same node point. +Returns a negative/zero/positive `Int` (Java `compareTo` contract). + +Port of NodeSection.compareTo. +""" +function compare_to(ns::NodeSection, o::NodeSection) + # Assert: ns.node == o.node + + #-- sort A before B + if ns.is_a != o.is_a + ns.is_a && return -1 + return 1 + end + #-- sort on dimensions + comp_dim = _compare_int(ns.dim, o.dim) + comp_dim != 0 && return comp_dim + + #-- sort on id and ring id + comp_id = _compare_int(ns.id, o.id) + comp_id != 0 && return comp_id + + comp_ring_id = _compare_int(ns.ring_id, o.ring_id) + comp_ring_id != 0 && return comp_ring_id + + #-- sort on edge coordinates + comp_v0 = _compare_with_null(ns.v0, o.v0) + comp_v0 != 0 && return comp_v0 + + return _compare_with_null(ns.v1, o.v1) +end + +# Java Integer.compare. +_compare_int(a, b) = a < b ? -1 : (a > b ? 1 : 0) + +# Port of NodeSection.compareWithNull: `nothing` (Java null) sorts below +# any coordinate; coordinates compare via Coordinate.compareTo. +_compare_with_null(::Nothing, ::Nothing) = 0 +_compare_with_null(::Nothing, v1) = -1 # null is lower than non-null +_compare_with_null(v0, ::Nothing) = 1 +_compare_with_null(v0, v1) = _compare_pt(v0, v1) + +# JTS Coordinate.compareTo: lexicographic on (x, y). (Lived in the Task-11 +# AdjacentEdgeLocator slice in point_locator.jl until Task 15.) +function _compare_pt(p, q) + GI.x(p) < GI.x(q) && return -1 + GI.x(p) > GI.x(q) && return 1 + GI.y(p) < GI.y(q) && return -1 + GI.y(p) > GI.y(q) && return 1 + return 0 +end + +#========================================================================== +## NodeSections (port of JTS NodeSections.java) +==========================================================================# + +""" + NodeSections(node::NodeKey) + +Collects the [`NodeSection`](@ref)s of all geometry components incident on +one node, and assembles them into the node's edge topology +([`create_node`](@ref)). + +Port of JTS `NodeSections`; the Java class is keyed by the node +`Coordinate`, here by the symbolic [`NodeKey`](@ref) (design D2). +""" +mutable struct NodeSections{P} + const node::NodeKey{P} + const sections::Vector{NodeSection{P}} +end + +NodeSections(node::NodeKey{P}) where {P} = NodeSections(node, NodeSection{P}[]) + +# Port of NodeSections.getCoordinate. The Java method returns the node +# Coordinate; here the node is its symbolic NodeKey (design D2). +get_coordinate(nss::NodeSections) = nss.node + +# Port of NodeSections.addNodeSection. +function add_node_section!(nss::NodeSections, e::NodeSection) + push!(nss.sections, e) + return nothing +end + +# Port of NodeSections.hasInteractionAB: whether both input geometries +# contribute a section at this node. +function has_interaction_ab(nss::NodeSections) + found_a = false + found_b = false + for ns in nss.sections + if is_a(ns) + found_a = true + else + found_b = true + end + found_a && found_b && return true + end + return false +end + +""" + get_polygonal(nss::NodeSections, is_a::Bool) + +The parent polygonal geometry of the first section of input geometry +`is_a` that has one, or `nothing`. + +Port of NodeSections.getPolygonal(boolean isA). +""" +function get_polygonal(nss::NodeSections, is_a_target::Bool) + for ns in nss.sections + if is_a(ns) == is_a_target + poly = get_polygonal(ns) + poly !== nothing && return poly + end + end + return nothing +end + +""" + create_node(m::Manifold, nss::NodeSections; exact) + +Creates the node topology: prepares the sections, builds a +[`RelateNode`](@ref) at the node and feeds it the sections via +`add_edges!`. Per-polygon section groups are first rewritten into +maximal-ring structure by [`polygon_node_convert`](@ref) (the +`PolygonNodeConverter.convert` port). Returns the assembled node. + +Port of NodeSections.createNode. The manifold/`exact` parameters (absent in +Java, where `createNode()` is nullary) are threaded through for the angle +comparisons in the converter and the node's edge wheel. (`RelateNode` is +defined in relate_node.jl, included after this file; the reference resolves +at call time.) +""" +function create_node(m::Manifold, nss::NodeSections; exact) + prepare_sections!(nss) + + node = RelateNode(m, nss.node; exact) + i = 1 + while i <= length(nss.sections) + ns = nss.sections[i] + #-- if there multiple polygon sections incident at node convert them to maximal-ring structure + if is_area(ns) && _has_multiple_polygon_sections(nss.sections, i) + poly_sections = _collect_polygon_sections(nss.sections, i) + ns_convert = polygon_node_convert(m, poly_sections; exact) + add_edges!(node, ns_convert) + i += length(poly_sections) + else + #-- the most common case is a line or a single polygon ring section + add_edges!(node, ns) + i += 1 + end + end + return node +end + +""" + prepare_sections!(nss::NodeSections) + +Sorts the sections (by [`compare_to`](@ref), the Java natural ordering) +so that: + +- lines are before areas +- edges from the same polygon are contiguous + +Port of NodeSections.prepareSections. +""" +function prepare_sections!(nss::NodeSections) + sort!(nss.sections; lt = (a, b) -> compare_to(a, b) < 0) + #TODO: remove duplicate sections + return nothing +end + +# Port of NodeSections.hasMultiplePolygonSections (1-based index). +function _has_multiple_polygon_sections(sections::Vector{<:NodeSection}, i::Integer) + #-- if last section can only be one + i >= length(sections) && return false + #-- check if there are at least two sections for same polygon + ns = sections[i] + ns_next = sections[i + 1] + return is_same_polygon(ns, ns_next) +end + +# Port of NodeSections.collectPolygonSections (1-based index). +function _collect_polygon_sections(sections::Vector{<:NodeSection}, i::Integer) + poly_sections = NodeSection[] + #-- note ids are only unique to a geometry + poly_section = sections[i] + while i <= length(sections) && is_same_polygon(poly_section, sections[i]) + push!(poly_sections, sections[i]) + i += 1 + end + return poly_sections +end diff --git a/src/methods/geom_relations/relateng/point_locator.jl b/src/methods/geom_relations/relateng/point_locator.jl new file mode 100644 index 0000000000..48092d68d9 --- /dev/null +++ b/src/methods/geom_relations/relateng/point_locator.jl @@ -0,0 +1,590 @@ +# # RelateNG point location +# +# Point-location machinery for RelateNG. This file holds the ports of three +# small, tightly coupled JTS classes, in this order (JTS file boundaries +# preserved as clearly marked sections): +# +# 1. `LinearBoundary` (JTS LinearBoundary.java) +# 2. `AdjacentEdgeLocator` (JTS AdjacentEdgeLocator.java) — Task 11 +# 3. `RelatePointLocator` (JTS RelatePointLocator.java) — Task 12 + +#========================================================================== +## LinearBoundary (port of JTS LinearBoundary.java) +==========================================================================# + +""" + LinearBoundary(lines, rule::BoundaryNodeRule) + +Determines the boundary points of a linear geometry, using a +[`BoundaryNodeRule`](@ref). `lines` is an iterable of linestrings +(any GeoInterface linestring-like geometries); the endpoint degree of +every line endpoint is counted and the rule decides which degrees are +boundary points. + +Coordinate keys are normalized via `_node_point` (kernel.jl): exact +`(Float64, Float64)` tuples with signed zeros normalized (`-0.0 → +0.0`), +so lookups here agree with the `NodeKey` vertex-node identity from the +kernel (Task 7) under Dict bit-pattern hashing. + +Faithful to Java: only *empty* lines are skipped. Closed lines are NOT +special-cased — a closed line contributes degree 2 to its closure vertex +(both endpoints coincide), which is never a boundary under the Mod-2 or +monovalent rules but would be under e.g. the endpoint rule. +""" +struct LinearBoundary{BR <: BoundaryNodeRule, P} + vertex_degree::Dict{P, Int} + has_boundary::Bool + rule::BR +end + +function LinearBoundary(m::Manifold, lines, rule::BoundaryNodeRule) + # assert: dim(geom) == 1 + vertex_degree = _compute_boundary_points(m, lines) + has_boundary = _check_boundary(vertex_degree, rule) + return LinearBoundary(vertex_degree, has_boundary, rule) +end + +function _check_boundary(vertex_degree::Dict, rule::BoundaryNodeRule) + for degree in values(vertex_degree) + if is_in_boundary(rule, degree) + return true + end + end + return false +end + +has_boundary(lb::LinearBoundary) = lb.has_boundary + +function is_boundary(lb::LinearBoundary, pt) + key = _node_point(pt) + haskey(lb.vertex_degree, key) || return false + degree = lb.vertex_degree[key] + return is_in_boundary(lb.rule, degree) +end + +function _compute_boundary_points(m::Manifold, lines) + vertex_degree = Dict{_kernel_point_type(m), Int}() + for line in lines + n = GI.npoint(line) + n == 0 && continue + _add_endpoint!(_to_kernel_point(m, GI.getpoint(line, 1)), vertex_degree) + _add_endpoint!(_to_kernel_point(m, GI.getpoint(line, n)), vertex_degree) + end + return vertex_degree +end + +function _add_endpoint!(p, degree::Dict) + dim = get(degree, p, 0) + dim += 1 + degree[p] = dim + return nothing +end + +#========================================================================== +## AdjacentEdgeLocator (port of JTS AdjacentEdgeLocator.java) +==========================================================================# + +""" + AdjacentEdgeLocator(m::Manifold, geom; exact) + +Determines the location for a point which is known to lie on at least one +edge of a set of polygons. This provides the union-semantics for determining +point location in a GeometryCollection, which may have polygons with +adjacent edges which are effectively in the interior of the geometry. +Note that it is also possible to have adjacent edges which lie on the +boundary of the geometry (e.g. a polygon contained within another polygon +with adjacent edges). + +The manifold `m` and the `exact` flag are stored in the struct (rather than +threaded through every call) for consistency with how `RelateGeometry` +holds them (Task 13); [`locate`](@ref locate(::AdjacentEdgeLocator, ::Any)) +uses the stored values for all kernel queries. + +The Java constructor signature is `AdjacentEdgeLocator(Geometry geom)`; the +manifold/exact parameters are the only additions. +""" +struct AdjacentEdgeLocator{M <: Manifold, E, P} + m::M + exact::E + ring_list::Vector{Vector{P}} +end + +function AdjacentEdgeLocator(m::Manifold, geom; exact) + ring_list = Vector{_kernel_point_type(m)}[] + _ael_init!(m, ring_list, geom; exact) + return AdjacentEdgeLocator(m, exact, ring_list) +end + +""" + locate(ael::AdjacentEdgeLocator, p) + +Location (`LOC_INTERIOR` or `LOC_BOUNDARY`) of point `p`, which must lie on +at least one polygon edge of the locator's geometry, under union semantics. +""" +function locate(ael::AdjacentEdgeLocator, p) + pt = _node_point(p) + sections = NodeSections(vertex_node(pt)) + for ring in ael.ring_list + _add_sections!(ael, pt, ring, sections) + end + node = create_node(ael.m, sections; exact = ael.exact) + #node.finish(false, false); + return has_exterior_edge(node, true) ? LOC_BOUNDARY : LOC_INTERIOR +end + +# Port of AdjacentEdgeLocator.addSections. +function _add_sections!(ael::AdjacentEdgeLocator, p, ring, sections::NodeSections) + for i in 1:(length(ring) - 1) + p0 = ring[i] + pnext = ring[i + 1] + + #-- kernel-point equality (all coordinates; the Java equals2D is + #-- planar-only — spherical kernel points can differ in z alone) + if p == pnext + #-- segment final point is assigned to next segment + continue + elseif p == p0 + iprev = i > 1 ? i - 1 : length(ring) - 1 + pprev = ring[iprev] + add_node_section!(sections, _create_section(ael, p, pprev, pnext)) + elseif rk_point_on_segment(ael.m, p, p0, pnext; exact = ael.exact) + add_node_section!(sections, _create_section(ael, p, p0, pnext)) + end + end + return nothing +end + +# Port of AdjacentEdgeLocator.createSection. (The Java prints a debug warning +# for zero-length section segments; here they are simply constructed — they +# only arise from invalid rings with repeated points.) +function _create_section(::AdjacentEdgeLocator, p, prev, next) + return NodeSection(true, DIM_A, Int32(1), Int32(0), nothing, false, prev, vertex_node(p), next) +end + +# Port of AdjacentEdgeLocator.init + addRings: collect the polygon rings of +# the (possibly collection) geometry as canonically oriented coordinate +# vectors. (Java leaves `ringList` null for an empty geometry; here it just +# stays empty.) +function _ael_init!(m, ring_list, geom; exact) + _add_rings!(m, GI.trait(geom), geom, ring_list; exact) + return nothing +end + +_add_rings!(m, geom, ring_list; exact) = + _add_rings!(m, GI.trait(geom), geom, ring_list; exact) + +function _add_rings!(m, ::GI.PolygonTrait, poly, ring_list; exact) + shell = GI.getexterior(poly) + _add_ring!(m, shell, true, ring_list; exact) + for hole in GI.gethole(poly) + _add_ring!(m, hole, false, ring_list; exact) + end + return nothing +end + +#-- recurse through collections (Java `instanceof GeometryCollection` covers +#-- MultiPolygon etc.; multi-point/line elements fall through to the no-op) +function _add_rings!(m, ::GI.AbstractGeometryCollectionTrait, geom, ring_list; exact) + for g in GI.getgeom(geom) + _add_rings!(m, g, ring_list; exact) + end + return nothing +end + +_add_rings!(m, ::GI.AbstractTrait, geom, ring_list; exact) = nothing + +# Port of AdjacentEdgeLocator.addRing. (`_orient_ring` — the port of +# RelateGeometry.orient — lived here until Task 13; it now resides with the +# rest of the RelateGeometry port in relate_geometry.jl, together with its +# helper `_ring_is_ccw`.) +function _add_ring!(m, ring, require_cw::Bool, ring_list; exact) + #TODO: remove repeated points? + pts = _to_kernel_points(m, ring) + #-- callers request CW for shells and CCW for holes, so `!require_cw` + #-- is the ring's role + pts = _orient_ring(m, pts, require_cw, !require_cw; exact) + push!(ring_list, pts) + return nothing +end + +#========================================================================== +## RelatePointLocator (port of JTS RelatePointLocator.java) +==========================================================================# + +""" + RelatePointLocator(m::Manifold, geom; exact, is_prepared = false, + boundary_rule = Mod2Boundary()) + +Locates a point on a geometry, including mixed-type collections. +The dimension of the containing geometry element is also determined. +GeometryCollections are handled with union semantics; +i.e. the location of a point is that location of that point +on the union of the elements of the collection. + +Union semantics for GeometryCollections has the following behaviours: + +1. For a mixed-dimension (heterogeneous) collection a point may lie on two + geometry elements with different dimensions. In this case the location on + the largest-dimension element is reported. +2. For a collection with overlapping or adjacent polygons, points on polygon + element boundaries may lie in the effective interior of the collection + geometry. + +Supports specifying the [`BoundaryNodeRule`](@ref) to use for line endpoints +(`RelateGeometry` passes its rule down here; the default matches Java's +`BoundaryNodeRule.OGC_SFS_BOUNDARY_RULE`, i.e. Mod-2). + +The Java constructor signature is `RelatePointLocator(geom, isPrepared, +bnRule)`; the manifold/`exact` parameters are the only additions (consistent +with [`AdjacentEdgeLocator`](@ref)). As in JTS, prepared mode swaps the +per-polygon `SimplePointInAreaLocator` ring loop for a cached +[`IndexedPointInAreaLocator`](@ref) (indexed_point_in_area.jl), created +lazily on the first use per polygonal element (Task 22); unprepared planar +mode scans the rings directly on every query, while the spherical path +caches a per-element locator in both modes (its kernel-space ring cache is +what makes queries conversion-free). Repeated point location against one +geometry is what [`prepare`](@ref) is for. +""" +mutable struct RelatePointLocator{M <: Manifold, E, G, BR <: BoundaryNodeRule, P} + const m::M + const exact::E + const geom::G + const is_prepared::Bool + const boundary_rule::BR + # element collections extracted from the (possibly nested-GC) input. + # Java leaves these null when no element of that kind exists; here they + # are simply empty. Heterogeneous GI element types force `Any` element + # eltypes. `P` is the manifold's kernel point type (Phase 3). + const points::Set{P} + const lines::Vector{Any} + const polygons::Vector{Any} + const line_boundary::LinearBoundary{BR, P} + const is_empty::Bool + # per-polygonal-element locators (prepared mode, and every spherical + # mode), created lazily by `_get_poly_locator` on the element's first + # query (Java: polyLocator, filled by getLocator) + const poly_locator::Vector{Union{Nothing, IndexedPointInAreaLocator{M, E}}} + # lazily built on the first multi-boundary point (Java: adjEdgeLocator) + adj_edge_locator::Union{Nothing, AdjacentEdgeLocator{M, E, P}} +end + +function RelatePointLocator(m::Manifold, geom; exact, + is_prepared::Bool = false, boundary_rule::BoundaryNodeRule = Mod2Boundary()) + #-- init(geom) + P = _kernel_point_type(m) + points = Set{P}() + lines = Any[] + polygons = Any[] + _extract_elements!(m, points, lines, polygons, geom) + # Java caches `isEmpty = geom.isEmpty()` (recursive emptiness); since + # `extractElements` skips empty elements, the input is recursively empty + # iff nothing was extracted. + is_empty = isempty(points) && isempty(lines) && isempty(polygons) + # Java builds `lineBoundary` only when lines exist; an empty + # LinearBoundary behaves identically (no boundary, no boundary points), + # so it is built unconditionally here. + line_boundary = LinearBoundary(m, lines, boundary_rule) + # Java allocates `polyLocator` for both modes (its unprepared arm caches + # SimplePointInAreaLocator objects); the planar direct ring scan here is + # stateless, so on Planar only prepared mode fills it — the spherical + # path fills it in both modes (kernel-space ring cache). + poly_locator = Vector{Union{Nothing, IndexedPointInAreaLocator{typeof(m), typeof(exact)}}}( + nothing, length(polygons)) + #-- P cannot be inferred from the `nothing` adj_edge_locator, so spell out + #-- every type parameter + return RelatePointLocator{typeof(m), typeof(exact), typeof(geom), + typeof(boundary_rule), P}( + m, exact, geom, is_prepared, boundary_rule, + points, lines, polygons, line_boundary, is_empty, poly_locator, + nothing) +end + +has_boundary(loc::RelatePointLocator) = has_boundary(loc.line_boundary) + +# Port of RelatePointLocator.extractElements + addPoint/addLine/addPolygonal: +# trait-dispatched traversal of the (possibly nested) collection structure. +_extract_elements!(m, points, lines, polygons, geom) = + _extract_elements!(m, points, lines, polygons, GI.trait(geom), geom) + +function _extract_elements!(m, points, lines, polygons, ::GI.PointTrait, geom) + GI.isempty(geom) && return nothing + #-- addPoint: normalized kernel points, as in LinearBoundary + push!(points, _to_kernel_point(m, geom)) + return nothing +end +function _extract_elements!(m, points, lines, polygons, ::GI.AbstractCurveTrait, geom) + GI.isempty(geom) && return nothing + #-- addLine (Java LinearRing extends LineString, hence AbstractCurve) + push!(lines, geom) + return nothing +end +function _extract_elements!(m, points, lines, polygons, + ::Union{GI.PolygonTrait, GI.MultiPolygonTrait}, geom) + GI.isempty(geom) && return nothing + #-- addPolygonal: whole polygonal geometry kept as one element + push!(polygons, geom) + return nothing +end +function _extract_elements!(m, points, lines, polygons, + ::GI.AbstractGeometryCollectionTrait, geom) + GI.isempty(geom) && return nothing + #-- covers GeometryCollection, MultiPoint, MultiLineString + for g in GI.getgeom(geom) + _extract_elements!(m, points, lines, polygons, g) + end + return nothing +end +_extract_elements!(m, points, lines, polygons, ::GI.AbstractTrait, geom) = nothing + +""" + locate(loc::RelatePointLocator, p) + +The location (`LOC_*` code) of point `p` relative to the locator's geometry, +under GC union semantics. +""" +locate(loc::RelatePointLocator, p) = dimloc_location(locate_with_dim(loc, p)) + +""" + locate_line_end_with_dim(loc::RelatePointLocator, p) + +Locates a line endpoint, as a `DL_*` dimension-location code. +In a mixed-dim GC, the line end point may also lie in an area. +In this case the area location is reported. +Otherwise, the dimloc is either `DL_LINE_BOUNDARY` or `DL_LINE_INTERIOR`, +depending on the endpoint valence and the [`BoundaryNodeRule`](@ref) in place. +""" +function locate_line_end_with_dim(loc::RelatePointLocator, p) + #-- if a GC with areas, check for point on area + if !isempty(loc.polygons) + loc_poly = locate_on_polygons(loc, p, false, nothing) + loc_poly != LOC_EXTERIOR && return dimloc_area(loc_poly) + end + #-- not in area, so return line end location + return is_boundary(loc.line_boundary, p) ? DL_LINE_BOUNDARY : DL_LINE_INTERIOR +end + +""" + locate_node(loc::RelatePointLocator, p, parent_polygonal) + +The location (`LOC_*` code) of a point `p` which is known to be a node of +the geometry (i.e. a vertex or on an edge). `parent_polygonal` is the +polygonal element the point is a node of (or `nothing`). +""" +locate_node(loc::RelatePointLocator, p, parent_polygonal) = + dimloc_location(locate_node_with_dim(loc, p, parent_polygonal)) + +""" + locate_node_with_dim(loc::RelatePointLocator, p, parent_polygonal) + +The dimension-location (`DL_*` code) of a point `p` which is known to be a +node of the geometry. +""" +locate_node_with_dim(loc::RelatePointLocator, p, parent_polygonal) = + locate_with_dim(loc, p, true, parent_polygonal) + +""" + locate_with_dim(loc::RelatePointLocator, p) + +Computes the topological location (`DL_*` dimension-location code) of a +single point in a geometry, including the dimension of the geometry element +the point is located in (if not in the exterior). It handles both +single-element and multi-element geometries. The algorithm for multi-part +geometries takes into account the SFS Boundary Determination Rule. +""" +locate_with_dim(loc::RelatePointLocator, p) = locate_with_dim(loc, p, false, nothing) + +# Private 4-argument form (Java `locateWithDim(p, isNode, parentPolygonal)`): +# `is_node` indicates the coordinate is a node (on an edge) of the geometry. +function locate_with_dim(loc::RelatePointLocator, p, is_node::Bool, parent_polygonal) + loc.is_empty && return DL_EXTERIOR + + #= + In a polygonal geometry a node must be on the boundary. + (This is not the case for a mixed collection, since + the node may be in the interior of a polygon.) + =# + if is_node && GI.trait(loc.geom) isa Union{GI.PolygonTrait, GI.MultiPolygonTrait} + return DL_AREA_BOUNDARY + end + + dim_loc = compute_dim_location(loc, p, is_node, parent_polygonal) + return dim_loc +end + +# Port of RelatePointLocator.computeDimLocation. +function compute_dim_location(loc::RelatePointLocator, p, is_node::Bool, parent_polygonal) + #-- check dimensions in order of precedence + if !isempty(loc.polygons) + loc_poly = locate_on_polygons(loc, p, is_node, parent_polygonal) + loc_poly != LOC_EXTERIOR && return dimloc_area(loc_poly) + end + if !isempty(loc.lines) + loc_line = locate_on_lines(loc, p, is_node) + loc_line != LOC_EXTERIOR && return dimloc_line(loc_line) + end + if !isempty(loc.points) + loc_pt = locate_on_points(loc, p) + loc_pt != LOC_EXTERIOR && return dimloc_point(loc_pt) + end + return DL_EXTERIOR +end + +# Port of RelatePointLocator.locateOnPoints. +function locate_on_points(loc::RelatePointLocator, p) + return _node_point(p) in loc.points ? LOC_INTERIOR : LOC_EXTERIOR +end + +# Port of RelatePointLocator.locateOnLines. +function locate_on_lines(loc::RelatePointLocator, p, is_node::Bool) + if is_boundary(loc.line_boundary, p) + return LOC_BOUNDARY + end + #-- must be on line, in interior + is_node && return LOC_INTERIOR + + #TODO: index the lines + for line in loc.lines + #-- have to check every line, since any/all may contain point + l = locate_on_line(loc, p, is_node, line) + l != LOC_EXTERIOR && return l + #TODO: minor optimization - some BoundaryNodeRules can short-circuit + end + return LOC_EXTERIOR +end + +# Port of RelatePointLocator.locateOnLine, including Java's short-circuit on +# the cached line envelope (the lines come from the RelateGeometry wrapper +# tree, which carries a stored extent on every linework element). +# `is_node` is unused, as in Java (kept for signature parity). +function locate_on_line(loc::RelatePointLocator, p, is_node::Bool, line) + #-- Java: lineEnv.intersects(p) short-circuit (p is already a kernel point) + pt_ext = _kernel_point_box(p) + if !Extents.intersects(rk_interaction_bounds(loc.m, line), pt_ext) + return LOC_EXTERIOR + end + #-- Java: PointLocation.isOnLine over the coordinate sequence + n = GI.npoint(line) + q0 = _to_kernel_point(loc.m, GI.getpoint(line, 1)) + for i in 2:n + q1 = _to_kernel_point(loc.m, GI.getpoint(line, i)) + if rk_point_on_segment(loc.m, p, q0, q1; exact = loc.exact) + return LOC_INTERIOR + end + q0 = q1 + end + return LOC_EXTERIOR +end + +# Port of RelatePointLocator.locateOnPolygons. +function locate_on_polygons(loc::RelatePointLocator, p, is_node::Bool, parent_polygonal) + num_bdy = 0 + #TODO: use a spatial index on the polygons + for i in eachindex(loc.polygons) + l = locate_on_polygonal(loc, p, is_node, parent_polygonal, i) + if l == LOC_INTERIOR + return LOC_INTERIOR + end + if l == LOC_BOUNDARY + num_bdy += 1 + end + end + if num_bdy == 1 + return LOC_BOUNDARY + #-- check for point lying on adjacent boundaries + elseif num_bdy > 1 + if loc.adj_edge_locator === nothing + loc.adj_edge_locator = AdjacentEdgeLocator(loc.m, loc.geom; exact = loc.exact) + end + return locate(loc.adj_edge_locator, p) + end + return LOC_EXTERIOR +end + +# Port of RelatePointLocator.locateOnPolygonal: Java dispatches to a +# per-polygonal PointOnGeometryLocator — a cached IndexedPointInAreaLocator +# when prepared, a SimplePointInAreaLocator otherwise. Same here (Task 22). +function locate_on_polygonal(loc::RelatePointLocator, p, is_node::Bool, parent_polygonal, index::Int) + polygonal = loc.polygons[index] + if is_node && parent_polygonal === polygonal + return LOC_BOUNDARY + end + #-- prepared mode queries the interval-indexed locator (y-intervals on + #-- the plane, lon-intervals on the sphere); the spherical unprepared + #-- arm also goes through the cached per-element locator, in scan mode + #-- over its kernel-space rings — the JTS shape too: Java's unprepared + #-- arm caches per-element SimplePointInAreaLocators in the same slot + if loc.is_prepared || loc.m isa Spherical + return locate(_get_poly_locator(loc, index), p) + end + return _locate_point_in_polygonal(loc.m, p, GI.trait(polygonal), polygonal; exact = loc.exact) +end + +# Port of RelatePointLocator.getLocator (indexed arm): lazily create and +# cache the indexed locator for polygonal element `index`. +function _get_poly_locator(loc::RelatePointLocator, index::Int) + locator = loc.poly_locator[index] + if locator === nothing + locator = _new_poly_locator(loc.m, loc.polygons[index], loc.is_prepared; exact = loc.exact) + loc.poly_locator[index] = locator + end + return locator +end + +_new_poly_locator(m::Manifold, geom, is_prepared::Bool; exact) = + IndexedPointInAreaLocator(m, geom; exact) +#-- the spherical unprepared arm stays in scan mode: a one-shot query +#-- cannot amortize the lon-interval index + pole-anchor build +_new_poly_locator(m::Spherical, geom, is_prepared::Bool; exact) = + IndexedPointInAreaLocator(m, geom; exact, indexed = is_prepared) + +#= +Port of the SimplePointInAreaLocator logic used by `locateOnPolygonal` +(SimplePointInAreaLocator.locate → locateInGeometry → locatePointInPolygon), +with point-in-ring routed through the kernel: shell first, then standard +even-odd composition over the holes. The Java envelope short-circuits — +`locate` checks the geometry envelope, `locatePointInRing` each ring's — +are kept, like the line envelope check in `locate_on_line`; the elements +normally come from the RelateGeometry wrapper tree (or an extent-stamped +input), whose stored extents make them O(1) reads. +=# +function _locate_point_in_polygonal(m, p, ::GI.PolygonTrait, poly; exact) + GI.isempty(poly) && return LOC_EXTERIOR + _area_env_disjoint(m, p, poly) && return LOC_EXTERIOR + shell_loc = _locate_point_in_ring(m, p, GI.getexterior(poly); exact) + shell_loc != LOC_INTERIOR && return shell_loc + #-- now test if the point lies in or on the holes + for hole in GI.gethole(poly) + hole_loc = _locate_point_in_ring(m, p, hole; exact, is_hole = true) + hole_loc == LOC_BOUNDARY && return LOC_BOUNDARY + hole_loc == LOC_INTERIOR && return LOC_EXTERIOR + #-- if in EXTERIOR of this hole keep checking the other ones + end + return LOC_INTERIOR +end + +function _locate_point_in_polygonal(m, p, ::GI.MultiPolygonTrait, mp; exact) + _area_env_disjoint(m, p, mp) && return LOC_EXTERIOR + for poly in GI.getgeom(mp) + l = _locate_point_in_polygonal(m, p, GI.trait(poly), poly; exact) + l != LOC_EXTERIOR && return l + end + return LOC_EXTERIOR +end + +# Port of SimplePointInAreaLocator.locatePointInRing, including its ring +# envelope short-circuit. +function _locate_point_in_ring(m, p, ring; exact, is_hole::Bool = false) + _area_env_disjoint(m, p, ring) && return LOC_EXTERIOR + return rk_point_in_ring(m, p, ring; exact, is_hole) +end + +#= +An envelope miss is conclusive for point-in-area EXTERIOR only where the +linework's interaction bounds cover the enclosed region: true on the plane +(a ring's coordinate box is its region's box), but not on the sphere, +where a ring's arc-extent box may exclude an enclosed region (e.g. a polar +cap's pole) — so `Spherical` scans unconditionally. +=# +_area_env_disjoint(m::Planar, p, geom) = + !Extents.intersects(rk_interaction_bounds(m, geom), _kernel_point_box(p)) +_area_env_disjoint(::Manifold, p, geom) = false diff --git a/src/methods/geom_relations/relateng/polygon_node_converter.jl b/src/methods/geom_relations/relateng/polygon_node_converter.jl new file mode 100644 index 0000000000..85df843168 --- /dev/null +++ b/src/methods/geom_relations/relateng/polygon_node_converter.jl @@ -0,0 +1,141 @@ +# # RelateNG polygon node converter +# +# Port of JTS `PolygonNodeConverter.java`. Method order parallels the Java +# file (`convert`, `convertShellAndHoles`, `convertHoles`, `createSection`, +# `extractUnique`, `next`, `findShell`), so this file diffs against its Java +# counterpart. Indices are 1-based throughout (Java's `findShell` returns -1 +# for "no shell"; here `_find_shell` returns 0). + +""" + polygon_node_convert(m::Manifold, poly_sections::Vector{<:NodeSection}; exact) + +Converts the node sections at a polygon node where a shell and one or more +holes touch, or two or more holes touch. This converts the node topological +structure from the OGC "touching-rings" (AKA "minimal-ring") model to the +equivalent "self-touch" (AKA "inverted/exverted ring" or "maximal ring") +model. In the "self-touch" model the converted [`NodeSection`](@ref) corners +enclose areas which all lie inside the polygon (i.e. they do not enclose +hole edges). This allows `RelateNode` (Task 17) to use simple area-additive +semantics for adding edges and propagating edge locations. + +The input node sections are assumed to have canonical orientation (CW shells +and CCW holes). The arrangement of shells and holes must be topologically +valid. Specifically, the node sections must not cross or be collinear. + +This supports multiple shell-shell touches (including ones containing +holes), and hole-hole touches. This generalizes the relate algorithm to +support both the OGC model and the self-touch model. + +Converts a list of sections of valid polygon rings to have "self-touching" +structure. There are the same number of output sections as input ones. +Sorts (and thereby mutates) `poly_sections`; returns the converted sections. + +Port of `PolygonNodeConverter.convert`. The angle sort goes through +[`edge_angle_compare`](@ref) (the `NodeSection.EdgeAngleComparator` port), +which is why the manifold and the `exact` flag are threaded in (the Java +method is geometry-context-free). +""" +function polygon_node_convert(m::Manifold, poly_sections::Vector{<:NodeSection}; exact) + # Stable, like Java List.sort: equal-angle sections (duplicates) keep + # their input (prepareSections) order, so extractUnique sees them adjacent. + sort!(poly_sections; alg = MergeSort, + lt = (a, b) -> edge_angle_compare(m, a, b; exact) < 0) + + #TODO: move uniquing up to caller + sections = _extract_unique(poly_sections) + length(sections) == 1 && return sections + + #-- find shell section index + shell_index = _find_shell(sections) + if shell_index == 0 + return _convert_holes(sections) + end + #-- at least one shell is present. Handle multiple ones if present + converted_sections = NodeSection[] + next_shell_index = shell_index + while true # Java do-while + next_shell_index = _convert_shell_and_holes(sections, next_shell_index, converted_sections) + next_shell_index == shell_index && break + end + return converted_sections +end + +# Port of PolygonNodeConverter.convertShellAndHoles: walk CCW from the shell +# section at `shell_index`, closing each shell-hole / hole-hole / hole-shell +# corner into a self-touch shell corner. Returns the index of the next shell +# section (the do-while cursor in `polygon_node_convert`). +function _convert_shell_and_holes(sections::Vector{<:NodeSection}, shell_index::Integer, + converted_sections::Vector{NodeSection}) + shell_section = sections[shell_index] + in_vertex = get_vertex(shell_section, 0) + i = _next(sections, shell_index) + while !is_shell(sections[i]) + hole_section = sections[i] + # Assert: is_shell(hole_section) == false + out_vertex = get_vertex(hole_section, 1) + ns = _create_section(shell_section, in_vertex, out_vertex) + push!(converted_sections, ns) + + in_vertex = get_vertex(hole_section, 0) + i = _next(sections, i) + end + #-- create final section for corner from last hole to shell + out_vertex = get_vertex(shell_section, 1) + ns = _create_section(shell_section, in_vertex, out_vertex) + push!(converted_sections, ns) + return i +end + +# Port of PolygonNodeConverter.convertHoles: no shell at the node, so each +# pair of angularly-adjacent hole sections contributes one self-touch corner. +function _convert_holes(sections::Vector{<:NodeSection}) + converted_sections = NodeSection[] + copy_section = sections[1] + for i in eachindex(sections) + inext = _next(sections, i) + in_vertex = get_vertex(sections[i], 0) + out_vertex = get_vertex(sections[inext], 1) + ns = _create_section(copy_section, in_vertex, out_vertex) + push!(converted_sections, ns) + end + return converted_sections +end + +# Port of PolygonNodeConverter.createSection: a shell (ring id 0) area +# section of the same polygon as `ns`, with the given edge vertices. +_create_section(ns::NodeSection, v0, v1) = NodeSection( + is_a(ns), DIM_A, section_id(ns), Int32(0), get_polygonal(ns), + is_node_at_vertex(ns), v0, node_pt(ns), v1) + +# Port of PolygonNodeConverter.extractUnique: drop consecutive duplicate +# sections (the list is sorted, so duplicates are adjacent). +function _extract_unique(sections::Vector{S}) where {S <: NodeSection} + unique_sections = S[] + last_unique = sections[1] + push!(unique_sections, last_unique) + for ns in sections + if compare_to(last_unique, ns) != 0 + push!(unique_sections, ns) + last_unique = ns + end + end + return unique_sections +end + +# Port of PolygonNodeConverter.next: circular increment (1-based). +function _next(ns::Vector{<:NodeSection}, i::Integer) + next = i + 1 + if next > length(ns) + next = 1 + end + return next +end + +# Port of PolygonNodeConverter.findShell: index of the first shell section, +# or 0 if there is none (Java returns -1). +function _find_shell(poly_sections::Vector{<:NodeSection}) + for i in eachindex(poly_sections) + is_shell(poly_sections[i]) && return i + end + return 0 +end diff --git a/src/methods/geom_relations/relateng/relate_geometry.jl b/src/methods/geom_relations/relateng/relate_geometry.jl new file mode 100644 index 0000000000..c3d9c67db5 --- /dev/null +++ b/src/methods/geom_relations/relateng/relate_geometry.jl @@ -0,0 +1,829 @@ +# # RelateNG input geometry facade +# +# Ports of two tightly coupled JTS classes (JTS file boundaries preserved as +# marked sections): +# +# 1. `RelateGeometry` (JTS RelateGeometry.java) — Task 13 +# 2. `RelateSegmentString` (JTS RelateSegmentString.java) — Task 13 +# +# `RelateGeometry` wraps one input geometry of a relate operation and caches +# the metadata the topology computer needs: dimension analysis, emptiness, +# extent, plus lazy access to unique points and a `RelatePointLocator`. +# `RelateSegmentString` models one linear edge (line or ring) extracted from +# it. + +#========================================================================== +## RelateGeometry (port of JTS RelateGeometry.java) +==========================================================================# + +# `GEOM_A`/`GEOM_B` (JTS `RelateGeometry.GEOM_A/GEOM_B`) are defined in +# relate_predicates.jl (Task 4) and are not redefined here. + +# Port of RelateGeometry.name(isA). +geom_name(is_a::Bool) = is_a ? "A" : "B" + +""" + RelateGeometry(m::Manifold, geom; exact, is_prepared = false, + boundary_rule = Mod2Boundary()) + +The input-geometry facade of RelateNG: wraps one of the two operand +geometries and caches its metadata — recursive emptiness, extent, +dimension analysis (`has_points`/`has_lines`/`has_areas`), zero-length-line +degeneracy — plus lazily created unique points and a +[`RelatePointLocator`](@ref). + +The Java constructor signature is `RelateGeometry(Geometry input, boolean +isPrepared, BoundaryNodeRule bnRule)`; the manifold/`exact` parameters are +the only additions (consistent with [`RelatePointLocator`](@ref)). Where the +Java caches `geomEnv = input.getEnvelopeInternal()`, here `extent` is the +union of the interaction bounds (`rk_interaction_bounds`) of the non-empty +elements, or `nothing` if the geometry is empty. +""" +mutable struct RelateGeometry{M <: Manifold, E, G, BR <: BoundaryNodeRule, X, P} + const m::M + const exact::E + const geom::G + const is_prepared::Bool + const boundary_rule::BR + const extent::X + const dim::Int8 + const has_points::Bool + const has_lines::Bool + const has_areas::Bool + const is_line_zero_len::Bool + const is_geom_empty::Bool + # id counter for extracted elements (Java `elementId`); never reset, so + # repeated extraction (prepared mode) keeps producing distinct ids. + element_id::Int32 + # lazy caches. `P` is the manifold's kernel point type (Phase 3): the + # coordinate type of every node point and segment-string vertex. + unique_points::Union{Nothing, Set{P}} + locator::Union{Nothing, RelatePointLocator{M, E, G, BR, P}} +end + +function RelateGeometry(m::Manifold, geom; exact, + is_prepared::Bool = false, boundary_rule::BoundaryNodeRule = Mod2Boundary()) + #-- cache geometry metadata + is_geom_empty = _relate_is_empty(geom) + #-- Hold an extent-cached wrapper tree instead of the raw input: one + #-- coordinate pass here makes every downstream extent consult (the + #-- engine's envelope checks, extraction filters, line-end walks, point + #-- locator short-circuits) O(1) — the GI equivalent of the envelope + #-- cache JTS carries on every Geometry. Coordinates are never copied: + #-- the wrappers share the original linework objects. + geom = is_geom_empty ? geom : _relate_cache_extents(m, geom) + extent = _relate_extent(m, geom) + dim = _geom_dimension(geom) + dim, has_points, has_lines, has_areas = _analyze_dimensions(geom, dim, is_geom_empty) + is_line_zero_len = _is_zero_length_line(geom, dim) + #-- P (the kernel point type) cannot be inferred from the `nothing` lazy + #-- caches, so spell out every type parameter + P = _kernel_point_type(m) + return RelateGeometry{typeof(m), typeof(exact), typeof(geom), typeof(boundary_rule), + typeof(extent), P}( + m, exact, geom, is_prepared, boundary_rule, extent, + dim, has_points, has_lines, has_areas, is_line_zero_len, is_geom_empty, + Int32(0), nothing, nothing) +end + +# Recursive emptiness (Java `Geometry.isEmpty()`): a collection is empty iff +# every element is empty. `GI.isempty` is not recursive for collections. +_relate_is_empty(geom) = _relate_is_empty(GI.trait(geom), geom) +function _relate_is_empty(::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _relate_is_empty(g) || return false + end + return true +end +_relate_is_empty(::GI.AbstractTrait, geom) = GI.isempty(geom) + +# Equivalent of Java `Geometry.getEnvelopeInternal()` as interaction bounds: +# the union of `rk_interaction_bounds` over the non-empty atomic elements +# (empty elements contribute nothing in Java too), or `nothing` if the +# geometry is (recursively) empty. +_relate_extent(m::Manifold, geom) = _relate_extent(m, GI.trait(geom), geom) +function _relate_extent(m::Manifold, ::GI.AbstractGeometryCollectionTrait, geom) + ext = nothing + for g in GI.getgeom(geom) + e = _relate_extent(m, g) + e === nothing && continue + ext = ext === nothing ? e : Extents.union(ext, e) + end + return ext +end +function _relate_extent(m::Manifold, ::GI.AbstractTrait, geom) + GI.isempty(geom) && return nothing + return rk_interaction_bounds(m, geom) +end + +#========================================================================== +## Extent caching (the stand-in for Java's per-Geometry envelope cache) + +Rebuild the input as a GeoInterface wrapper tree with the interaction +bounds embedded at every level, in one coordinate pass. Wrappers share the +original linework objects (a `GI.LinearRing(ring; extent)` around a +same-trait geometry stores `ring`'s coordinate backing, copying nothing), +so this costs O(#elements) small allocations plus the one extent scan the +constructor performed anyway. Levels whose stored extent is usable as +interaction bounds (`_reusable_stored_extent`) are reused as-is, so +re-wrapping an already-cached tree does no coordinate work. +==========================================================================# + +_has_stored_extent(geom) = + geom isa GI.Wrappers.WrapperGeometry && hasproperty(geom, :extent) && + geom.extent isa Extents.Extent + +# A stored extent is reusable as interaction bounds only if it lives in the +# space the kernel prunes in: any stored extent on `Planar`, but only a 3D +# `(X, Y, Z)` extent on `Spherical` — user inputs typically carry lon/lat +# boxes, which must not be compared against unit-sphere boxes. Our own cache +# pass always stores `(X, Y, Z)`; a user storing one is trusted to mean it. +_reusable_stored_extent(::Manifold, geom) = _has_stored_extent(geom) +_reusable_stored_extent(::Spherical, geom) = + _has_stored_extent(geom) && geom.extent isa Extents.Extent{(:X, :Y, :Z)} + +_relate_cache_extents(m::Manifold, geom) = _relate_cache_extents(m, GI.trait(geom), geom) + +#-- point elements: their extent is themselves, nothing to cache +_relate_cache_extents(::Manifold, ::Union{GI.AbstractPointTrait, GI.AbstractMultiPointTrait}, geom) = geom + +#-- linework leaves: lines and rings (the only level where coordinates are +#-- read, and hence where the manifold's edge validation runs) +function _relate_cache_extents(m::Manifold, trait::GI.AbstractCurveTrait, line) + (GI.isempty(line) || _reusable_stored_extent(m, line)) && return line + _validate_relate_edges(m, line) + return GI.geointerface_geomtype(trait)(line; + extent = rk_interaction_bounds(m, line), crs = GI.crs(line)) +end + +function _relate_cache_extents(m::Manifold, trait::GI.AbstractPolygonTrait, poly) + GI.isempty(poly) && return poly + if _reusable_stored_extent(m, poly) && all(r -> GI.isempty(r) || _reusable_stored_extent(m, r), GI.getring(poly)) + return poly + end + rings = [_relate_cache_extents(m, GI.trait(r), r) for r in GI.getring(poly)] + ext = _union_stored_extents(m, rings) + ext === nothing && return poly + return GI.geointerface_geomtype(trait)(rings; extent = ext, crs = GI.crs(poly)) +end + +#-- collections (covers Multi* types too): recurse, union the child extents +function _relate_cache_extents(m::Manifold, trait::GI.AbstractGeometryCollectionTrait, geom) + children = [_relate_cache_extents(m, GI.trait(g), g) for g in GI.getgeom(geom)] + ext = _union_stored_extents(m, children) + ext === nothing && return geom + return GI.geointerface_geomtype(trait)(children; extent = ext, crs = GI.crs(geom)) +end + +#-- any other trait: leave untouched +_relate_cache_extents(::Manifold, ::GI.AbstractTrait, geom) = geom + +# Union of the children's extents, reading stored ones and computing only +# for non-empty children that have none (e.g. point members of a GC); +# `nothing` when no child contributes one. Computed extents go through +# `rk_interaction_bounds` so they stay in the manifold's coordinate space. +function _union_stored_extents(m::Manifold, children) + ext = nothing + for c in children + ce = if _reusable_stored_extent(m, c) + c.extent + elseif GI.isempty(c) + nothing + else + rk_interaction_bounds(m, c) + end + ce === nothing && continue + ext = ext === nothing ? ce : Extents.union(ext, ce) + end + return ext +end + +# Equivalent of Java `Geometry.getDimension()`: the inherent dimension of the +# geometry type, including empty elements (an empty polygon still has +# dimension 2); collections report the maximum over their elements +# (`DIM_FALSE` when there are none). +_geom_dimension(geom) = _geom_dimension(GI.trait(geom), geom) +_geom_dimension(::Union{GI.AbstractPointTrait, GI.AbstractMultiPointTrait}, geom) = DIM_P +_geom_dimension(::Union{GI.AbstractCurveTrait, GI.AbstractMultiCurveTrait}, geom) = DIM_L +_geom_dimension(::Union{GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait}, geom) = DIM_A +function _geom_dimension(::GI.AbstractGeometryCollectionTrait, geom) + dim = DIM_FALSE + for g in GI.getgeom(geom) + d = _geom_dimension(g) + d > dim && (dim = d) + end + return dim +end +_geom_dimension(::GI.AbstractTrait, geom) = DIM_FALSE + +# Port of RelateGeometry.analyzeDimensions, returning +# `(dim, has_points, has_lines, has_areas)`. The Java `instanceof +# Point/LineString/Polygon` checks are widened to the corresponding GI +# abstract traits (e.g. `GI.AbstractPolygonTrait` also covers triangles +# etc.), per the Task 12 review. +function _analyze_dimensions(geom, dim0::Int8, is_geom_empty::Bool) + is_geom_empty && return (dim0, false, false, false) + return _analyze_dimensions(GI.trait(geom), geom, dim0) +end +_analyze_dimensions(::Union{GI.AbstractPointTrait, GI.AbstractMultiPointTrait}, geom, dim0) = + (DIM_P, true, false, false) +_analyze_dimensions(::Union{GI.AbstractCurveTrait, GI.AbstractMultiCurveTrait}, geom, dim0) = + (DIM_L, false, true, false) +_analyze_dimensions(::Union{GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait}, geom, dim0) = + (DIM_A, false, false, true) +#-- analyze a (possibly mixed type) collection +_analyze_dimensions(::GI.AbstractTrait, geom, dim0) = + _analyze_collection_dimensions(geom, dim0, false, false, false) + +# The recursive element walk of analyzeDimensions (Java uses a +# GeometryCollectionIterator; only atomic elements match the checks). +function _analyze_collection_dimensions(geom, dim, has_points, has_lines, has_areas) + for g in GI.getgeom(geom) + dim, has_points, has_lines, has_areas = _analyze_element_dimensions( + GI.trait(g), g, dim, has_points, has_lines, has_areas) + end + return (dim, has_points, has_lines, has_areas) +end +_analyze_element_dimensions(::GI.AbstractGeometryCollectionTrait, g, dim, hp, hl, ha) = + _analyze_collection_dimensions(g, dim, hp, hl, ha) +function _analyze_element_dimensions(::GI.AbstractPointTrait, g, dim, hp, hl, ha) + GI.isempty(g) && return (dim, hp, hl, ha) + return (max(dim, DIM_P), true, hl, ha) +end +function _analyze_element_dimensions(::GI.AbstractCurveTrait, g, dim, hp, hl, ha) + GI.isempty(g) && return (dim, hp, hl, ha) + return (max(dim, DIM_L), hp, true, ha) +end +function _analyze_element_dimensions(::GI.AbstractPolygonTrait, g, dim, hp, hl, ha) + GI.isempty(g) && return (dim, hp, hl, ha) + return (max(dim, DIM_A), hp, hl, true) +end +_analyze_element_dimensions(::GI.AbstractTrait, g, dim, hp, hl, ha) = (dim, hp, hl, ha) + +# Port of RelateGeometry.isZeroLengthLine. +function _is_zero_length_line(geom, dim::Int8) + #-- avoid expensive zero-length calculation if not linear + dim == DIM_L || return false + return _is_zero_length(geom) +end + +# Port of RelateGeometry.isZeroLength(Geometry): tests if all linear elements +# are zero-length. For efficiency the test avoids computing actual length. +_is_zero_length(geom) = _is_zero_length(GI.trait(geom), geom) +function _is_zero_length(::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _is_zero_length(g) || return false + end + return true +end +_is_zero_length(::GI.AbstractCurveTrait, geom) = _is_zero_length_linestring(geom) +_is_zero_length(::GI.AbstractTrait, geom) = true + +# Port of RelateGeometry.isZeroLength(LineString): exact coordinate equality +# of every point with the first one. +function _is_zero_length_linestring(line) + n = GI.npoint(line) + if n >= 2 + p0 = _tuple_point(GI.getpoint(line, 1)) + for i in 1:n + p = _tuple_point(GI.getpoint(line, i)) + #-- most non-zero-len lines will trigger this right away + _equals2(p0, p) || return false + end + end + return true +end + +# Java getGeometry / isPrepared / getEnvelope / getDimension. +get_geometry(rg::RelateGeometry) = rg.geom +is_prepared(rg::RelateGeometry) = rg.is_prepared +get_extent(rg::RelateGeometry) = rg.extent +get_dimension(rg::RelateGeometry) = rg.dim + +# Port of RelateGeometry.hasDimension(dim). +function has_dimension(rg::RelateGeometry, dim::Integer) + dim == DIM_P && return rg.has_points + dim == DIM_L && return rg.has_lines + dim == DIM_A && return rg.has_areas + return false +end + +has_area_and_line(rg::RelateGeometry) = rg.has_areas && rg.has_lines + +""" + get_dimension_real(rg::RelateGeometry) + +Gets the actual non-empty dimension of the geometry. +Zero-length LineStrings are treated as Points. +""" +function get_dimension_real(rg::RelateGeometry) + rg.is_geom_empty && return DIM_FALSE + get_dimension(rg) == DIM_L && rg.is_line_zero_len && return DIM_P + rg.has_areas && return DIM_A + rg.has_lines && return DIM_L + return DIM_P +end + +has_edges(rg::RelateGeometry) = rg.has_lines || rg.has_areas + +# Port of RelateGeometry.getLocator (lazy). +function _get_locator(rg::RelateGeometry) + loc = rg.locator + loc === nothing || return loc + loc = RelatePointLocator(rg.m, rg.geom; exact = rg.exact, + is_prepared = rg.is_prepared, boundary_rule = rg.boundary_rule) + rg.locator = loc + return loc +end + +# Port of RelateGeometry.isNodeInArea. +function is_node_in_area(rg::RelateGeometry, node_pt, parent_polygonal) + loc = locate_node_with_dim(_get_locator(rg), node_pt, parent_polygonal) + return loc == DL_AREA_INTERIOR +end + +locate_line_end_with_dim(rg::RelateGeometry, p) = + locate_line_end_with_dim(_get_locator(rg), p) + +""" + locate_area_vertex(rg::RelateGeometry, pt) + +Locates a vertex of a polygon. A vertex of a Polygon or MultiPolygon is on +the boundary; but a vertex of an overlapped polygon in a GeometryCollection +may be in the interior. +""" +function locate_area_vertex(rg::RelateGeometry, pt) + #= + Can pass a `nothing` polygon, because the point is an exact vertex, + which will be detected as being on the boundary of its polygon + =# + return locate_node(rg, pt, nothing) +end + +locate_node(rg::RelateGeometry, pt, parent_polygonal) = + locate_node(_get_locator(rg), pt, parent_polygonal) + +locate_with_dim(rg::RelateGeometry, pt) = locate_with_dim(_get_locator(rg), pt) + +""" + is_self_noding_required(rg::RelateGeometry) + +Indicates whether the geometry requires self-noding for correct evaluation +of specific spatial predicates. Self-noding is required for geometries which +may self-cross — i.e. lines, and overlapping elements in +GeometryCollections. Self-noding is not required for polygonal geometries, +since they can only touch at vertices. +""" +function is_self_noding_required(rg::RelateGeometry) + trait = GI.trait(rg.geom) + if trait isa Union{GI.AbstractPointTrait, GI.AbstractMultiPointTrait, + GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait} + return false + end + #-- a GC with a single polygon does not need noding + rg.has_areas && _num_geometries(rg.geom) == 1 && return false + #-- GCs with only points do not need noding + !rg.has_areas && !rg.has_lines && return false + return true +end + +# Java Geometry.getNumGeometries: element count for collections, 1 otherwise. +_num_geometries(geom) = + GI.trait(geom) isa GI.AbstractGeometryCollectionTrait ? GI.ngeom(geom) : 1 + +""" + is_polygonal(rg::RelateGeometry) + +Tests whether the geometry has polygonal topology. This is not the case if +it is a GeometryCollection containing more than one polygon (since they may +overlap or be adjacent). The significance is that polygonal topology allows +more assumptions about the location of boundary vertices. +""" +function is_polygonal(rg::RelateGeometry) + #TODO: also true for a GC containing one polygonal element (and possibly some lower-dimension elements) + return GI.trait(rg.geom) isa Union{GI.AbstractPolygonTrait, GI.AbstractMultiPolygonTrait} +end + +is_geom_empty(rg::RelateGeometry) = rg.is_geom_empty + +has_boundary(rg::RelateGeometry) = has_boundary(_get_locator(rg)) + +function get_unique_points(rg::RelateGeometry) + #-- will be re-used in prepared mode + up = rg.unique_points + up === nothing || return up + up = _create_unique_points(rg.m, rg.geom) + rg.unique_points = up + return up +end + +# Port of RelateGeometry.createUniquePoints. Only called on P geometries. +# (Java uses ComponentCoordinateExtracter, which records the first coordinate +# of each point/line component; for point geometries that is every point.) +function _create_unique_points(m::Manifold, geom) + set = Set{_kernel_point_type(m)}() + _add_component_coordinates!(set, m, geom) + return set +end + +_add_component_coordinates!(set, m, geom) = + _add_component_coordinates!(set, m, GI.trait(geom), geom) +function _add_component_coordinates!(set, m, ::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _add_component_coordinates!(set, m, g) + end + return nothing +end +function _add_component_coordinates!(set, m, ::GI.AbstractPointTrait, geom) + GI.isempty(geom) && return nothing + push!(set, _to_kernel_point(m, geom)) + return nothing +end +function _add_component_coordinates!(set, m, ::GI.AbstractCurveTrait, geom) + GI.isempty(geom) && return nothing + push!(set, _to_kernel_point(m, GI.getpoint(geom, 1))) + return nothing +end +_add_component_coordinates!(set, m, ::GI.AbstractTrait, geom) = nothing + +# Port of RelateGeometry.getEffectivePoints: the point elements which are not +# covered by an element of higher dimension. (This JTS version has no +# MAX_EFFECTIVE_POINTS cap; all points are checked.) Returns the point +# geometries themselves, as in Java. +function get_effective_points(rg::RelateGeometry) + pt_list_all = Any[] + _extract_point_elements!(pt_list_all, rg.geom) + + get_dimension_real(rg) <= DIM_P && return pt_list_all + + #-- only return Points not covered by another element + pt_list = Any[] + for p in pt_list_all + GI.isempty(p) && continue + loc_dim = locate_with_dim(rg, _to_kernel_point(rg.m, p)) + if dimloc_dimension(loc_dim) == DIM_P + push!(pt_list, p) + end + end + return pt_list +end + +# Equivalent of Java PointExtracter.getPoints: every Point element, including +# those nested in collections. +_extract_point_elements!(list, geom) = + _extract_point_elements!(list, GI.trait(geom), geom) +function _extract_point_elements!(list, ::GI.AbstractPointTrait, geom) + push!(list, geom) + return nothing +end +function _extract_point_elements!(list, ::GI.AbstractGeometryCollectionTrait, geom) + for g in GI.getgeom(geom) + _extract_point_elements!(list, g) + end + return nothing +end +_extract_point_elements!(list, ::GI.AbstractTrait, geom) = nothing + +""" + extract_segment_strings(rg::RelateGeometry, is_a::Bool, ext_filter) + +Extract [`RelateSegmentString`](@ref)s from the geometry which intersect a +given extent (one per line, one per polygon ring). If `ext_filter` is +`nothing` all edges are extracted. + +!!! warning + `nothing` here means *no filter* (Java's prepared-mode `null`), while + `get_extent(rg)` returns `nothing` for an *empty* geometry, where JTS's + null Envelope intersects nothing. Never forward an empty geometry's + extent as the filter — callers (the engine's `computeAtEdges` port) + must early-return on empty inputs before extraction. +""" +function extract_segment_strings(rg::RelateGeometry, is_a::Bool, ext_filter) + #-- `RelateSegmentString{P}` is concrete (its geometry references are + #-- opaque), so the vector is concretely typed and the per-segment loops + #-- downstream (`_segment_extent_table`, the NestedLoop enumerator) stay + #-- statically dispatched, for any input geometry type + seg_strings = Vector{RelateSegmentString{_kernel_point_type(rg.m)}}() + _extract_segment_strings!(rg, is_a, ext_filter, rg.geom, seg_strings) + return seg_strings +end + +function _extract_segment_strings!(rg::RelateGeometry, is_a::Bool, ext_filter, geom, seg_strings) + trait = GI.trait(geom) + #-- record if parent is MultiPolygon + parent_polygonal = trait isa GI.AbstractMultiPolygonTrait ? geom : nothing + + # Java iterates getGeometryN over getNumGeometries: for an atomic + # geometry that yields the geometry itself. + elements = trait isa GI.AbstractGeometryCollectionTrait ? GI.getgeom(geom) : (geom,) + for g in elements + # Java `instanceof GeometryCollection` covers the Multi* types too. + if GI.trait(g) isa GI.AbstractGeometryCollectionTrait + _extract_segment_strings!(rg, is_a, ext_filter, g, seg_strings) + else + #-- an atomic input geometry's extent is already cached on `rg` + #-- (Java's getEnvelopeInternal cache); don't rescan it below + elem_ext = g === rg.geom ? get_extent(rg) : missing + _extract_segment_strings_from_atomic!(rg, is_a, g, parent_polygonal, + ext_filter, seg_strings, elem_ext) + end + end + return nothing +end + +function _extract_segment_strings_from_atomic!(rg::RelateGeometry, is_a::Bool, geom, + parent_polygonal, ext_filter, seg_strings, elem_ext = missing) + GI.isempty(geom) && return nothing + if ext_filter !== nothing + if elem_ext === missing + elem_ext = rk_interaction_bounds(rg.m, geom) + end + !Extents.intersects(ext_filter, elem_ext) && return nothing + end + + rg.element_id += Int32(1) + trait = GI.trait(geom) + if trait isa GI.AbstractCurveTrait + pts = _to_kernel_points(rg.m, geom) + ss = _rss_create_line(pts, is_a, rg.element_id, rg) + push!(seg_strings, ss) + elseif trait isa GI.AbstractPolygonTrait + parent_poly = parent_polygonal !== nothing ? parent_polygonal : geom + #-- the exterior ring's extent is the element extent (for an invalid + #-- polygon with a hole outside its shell it is a superset, which can + #-- only under-prune — extracted non-interacting edges are harmless) + _extract_ring_to_segment_string!(rg, is_a, GI.getexterior(geom), 0, ext_filter, + parent_poly, seg_strings, elem_ext) + for (i, hole) in enumerate(GI.gethole(geom)) + _extract_ring_to_segment_string!(rg, is_a, hole, i, ext_filter, parent_poly, seg_strings) + end + end + return nothing +end + +function _extract_ring_to_segment_string!(rg::RelateGeometry, is_a::Bool, ring, ring_id::Integer, + ext_filter, parent_poly, seg_strings, ring_ext = missing) + GI.isempty(ring) && return nothing + if ext_filter !== nothing + if ring_ext === missing + ring_ext = rk_interaction_bounds(rg.m, ring) + end + !Extents.intersects(ext_filter, ring_ext) && return nothing + end + + #-- orient the points if required + require_cw = ring_id == 0 + pts = _to_kernel_points(rg.m, ring) + pts = _orient_ring(rg.m, pts, require_cw, ring_id != 0; exact = rg.exact) + ss = _rss_create_ring(pts, is_a, rg.element_id, ring_id, parent_poly, rg) + push!(seg_strings, ss) + return nothing +end + +# Port of RelateGeometry.orient (static; moved here from point_locator.jl in +# Task 13 — `AdjacentEdgeLocator._add_ring!` also uses it): coordinate vector +# of `pts` with the ring's denoted region on the requested side (`orient_cw = +# true` ⇒ on the right), reversing a copy only if needed. Which side the +# region lies on in the stored order comes from `_ring_interior_on_left`. +function _orient_ring(m, pts::Vector, orient_cw::Bool, is_hole::Bool; exact) + is_flipped = orient_cw == _ring_interior_on_left(m, pts, is_hole; exact) + return is_flipped ? reverse(pts) : pts +end + +#= +The one per-ring bit every consumer shares: whether the region the ring +DENOTES — the shell region for a shell or bare ring, the cavity for a hole +— lies on the left of the stored vertex order. On the plane, and on an +unoriented spherical manifold, the denoted region is the enclosed one and +the bit is the ring's winding (`_ring_is_ccw`); `Spherical(; oriented = +true)` overrides this (kernel_spherical.jl) with the declared role — there +the stored winding is authoritative. +=# +_ring_interior_on_left(m, pts::Vector, is_hole::Bool; exact) = + _ring_is_ccw(m, pts; exact) + +#= +Port of JTS `Orientation.isCCW(CoordinateSequence)` with the orientation +index routed through the kernel (`rk_orient`): whether the closed `ring` +(repeated end point required) is counterclockwise. Returns `false` for flat +or degenerate rings. The algorithm finds the highest point reached by an +upward segment, then the subsequent downward segment, and decides from the +"cap" they form — using only one exact orientation test, so it is robust +(unlike a floating signed-area sum). +=# +function _ring_is_ccw(m, ring::Vector; exact) + # number of points without closing endpoint + npts = length(ring) - 1 + # return default value if ring is flat + npts < 3 && return false + pt(i) = ring[i + 1] # 0-based access, mirroring the Java indexing + + # Find first highest point after a lower point, if one exists + # (e.g. a rising segment). If one does not exist, i_up_hi remains 0 + # and the ring must be flat. + up_hi_pt = pt(0) + prev_y = GI.y(up_hi_pt) + up_low_pt = up_hi_pt # only read when i_up_hi != 0, i.e. after assignment + i_up_hi = 0 + for i in 1:npts + py = GI.y(pt(i)) + # if segment is upwards and endpoint is higher, record it + if py > prev_y && py >= GI.y(up_hi_pt) + up_hi_pt = pt(i) + i_up_hi = i + up_low_pt = pt(i - 1) + end + prev_y = py + end + # check if ring is flat and return default value if so + i_up_hi == 0 && return false + + # Find the next lower point after the high point (e.g. a falling + # segment). This must exist since the ring is not flat. + i_down_low = i_up_hi + while true + i_down_low = (i_down_low + 1) % npts + (i_down_low != i_up_hi && GI.y(pt(i_down_low)) == GI.y(up_hi_pt)) || break + end + + down_low_pt = pt(i_down_low) + i_down_hi = i_down_low > 0 ? i_down_low - 1 : npts - 1 + down_hi_pt = pt(i_down_hi) + + if _equals2(up_hi_pt, down_hi_pt) + # the high point is on a "pointed cap": its orientation decides. + # Degenerate A-B-A caps (coincident segments / < 3 distinct points) + # have orientation 0 and return false. + (_equals2(up_low_pt, up_hi_pt) || _equals2(down_low_pt, up_hi_pt) || + _equals2(up_low_pt, down_low_pt)) && return false + return rk_orient(m, up_low_pt, up_hi_pt, down_low_pt; exact) > 0 + else + # flat cap - direction of flat top determines orientation + del_x = GI.x(down_hi_pt) - GI.x(up_hi_pt) + return del_x < 0 + end +end + +#========================================================================== +## RelateSegmentString (port of JTS RelateSegmentString.java) +==========================================================================# + +""" + RelateSegmentString{P} + +Models a linear edge of a [`RelateGeometry`](@ref): the coordinate vector of +one line or one polygon ring, tagged with which input geometry it came from +(`is_a`), its dimension, the element/ring ids assigned during extraction, +and (for rings) the parent polygonal geometry. + +In JTS this extends `BasicSegmentString`; here the coordinates are stored +directly in `pts`. Segment indices are 1-based: segment `i` runs from +`pts[i]` to `pts[i + 1]` (the Java equivalents are 0-based). + +The geometry references are deliberately opaque (abstract field types, as +they are in Java): `parent_polygonal` is only ever compared by identity and +carried into [`NodeSection`](@ref)s, so parameterizing on the input geometry +type would only re-specialize the whole edge machinery per geometry-type +pair (a pure compile-time cost). Only `pts` — the per-segment hot path — +stays concretely typed, on the manifold's kernel point type `P`. +""" +struct RelateSegmentString{P} + is_a::Bool + dim::Int8 + id::Int32 + ring_id::Int32 + input_geom::RelateGeometry + parent_polygonal::Any + pts::Vector{P} +end + +# Port of RelateSegmentString.createLine. +_rss_create_line(pts::Vector, is_a::Bool, element_id::Integer, parent::RelateGeometry) = + _rss_create(pts, is_a, DIM_L, element_id, -1, nothing, parent) + +# Port of RelateSegmentString.createRing. +_rss_create_ring(pts::Vector, is_a::Bool, element_id::Integer, ring_id::Integer, + poly, parent::RelateGeometry) = + _rss_create(pts, is_a, DIM_A, element_id, ring_id, poly, parent) + +# Port of RelateSegmentString.createSegmentString. +function _rss_create(pts::Vector, is_a::Bool, dim::Int8, element_id::Integer, + ring_id::Integer, poly, parent::RelateGeometry) + pts = _remove_repeated_points(pts) + return RelateSegmentString(is_a, dim, Int32(element_id), Int32(ring_id), parent, poly, pts) +end + +# Port of CoordinateArrays.removeRepeatedPoints (via hasRepeatedPoints): +# drops consecutive coordinates that are exactly equal, returning the input +# vector unchanged (not copied) when there is nothing to remove. Equality is +# kernel-point `==` — all coordinates — NOT the planar `_equals2`: spherical +# kernel points at mirror latitudes on one meridian share x and y and differ +# only in z, and 2D equality deleted such vertices from the ring. +function _remove_repeated_points(pts::Vector) + any(i -> pts[i] == pts[i + 1], 1:(length(pts) - 1)) || return pts + out = [pts[1]] + for i in 2:length(pts) + pts[i] == last(out) || push!(out, pts[i]) + end + return out +end + +get_geometry(ss::RelateSegmentString) = ss.input_geom +get_polygonal(ss::RelateSegmentString) = ss.parent_polygonal + +# Port of BasicSegmentString.isClosed. Kernel-point `==` (all coordinates; +# Java's equals2D is planar-only — see `_remove_repeated_points`). +is_closed(ss::RelateSegmentString) = ss.pts[1] == ss.pts[end] + +""" + create_node_section(ss::RelateSegmentString, seg_index::Integer, node::NodeKey) + +The [`NodeSection`](@ref) of this segment string at the node `node`, known +to lie on segment `seg_index`. + +Port of RelateSegmentString.createNodeSection, with the symbolic twist +(design D2): the Java method takes the intersection `Coordinate`; here the +node is identified by its [`NodeKey`](@ref). For a vertex node (`SS_TOUCH` +or other vertex incidences) the key carries the exact coordinate and the +incident vertices are found as in Java. A proper-crossing node (`SS_PROPER`) +lies strictly inside the segment, so the incident vertices are the segment +endpoints and the node is never at a vertex. +""" +function create_node_section(ss::RelateSegmentString, seg_index::Integer, node::NodeKey) + if node.is_crossing + #-- a proper crossing is interior to its segment + is_node_at_vertex = false + prev = ss.pts[seg_index] + next = ss.pts[seg_index + 1] + else + pt = node.pt + #-- kernel-point equality (all coordinates), here and in the vertex + #-- walks below: node points and segment vertices are kernel points + is_node_at_vertex = + pt == ss.pts[seg_index] || pt == ss.pts[seg_index + 1] + prev = prev_vertex(ss, seg_index, pt) + next = next_vertex(ss, seg_index, pt) + end + return NodeSection(ss.is_a, ss.dim, ss.id, ss.ring_id, ss.parent_polygonal, + is_node_at_vertex, prev, node, next) +end + +# Port of RelateSegmentString.prevVertex: the vertex before the node lying +# on segment `seg_index`, or `nothing` if none exists. +function prev_vertex(ss::RelateSegmentString, seg_index::Integer, pt) + seg_start = ss.pts[seg_index] + seg_start == pt || return seg_start + #-- pt is at segment start, so get previous vertex + seg_index > 1 && return ss.pts[seg_index - 1] + is_closed(ss) && return _prev_in_ring(ss, seg_index) + return nothing +end + +# Port of RelateSegmentString.nextVertex: the vertex after the node lying +# on segment `seg_index`, or `nothing` if none exists. +function next_vertex(ss::RelateSegmentString, seg_index::Integer, pt) + seg_end = ss.pts[seg_index + 1] + seg_end == pt || return seg_end + #-- pt is at seg end, so get next vertex + seg_index < length(ss.pts) - 1 && return ss.pts[seg_index + 2] + is_closed(ss) && return _next_in_ring(ss, seg_index + 1) + #-- segstring is not closed, so there is no next segment + return nothing +end + +# Ports of BasicSegmentString.prevInRing / nextInRing (1-based; the closing +# point duplicates the first, so the wraparound skips it). +function _prev_in_ring(ss::RelateSegmentString, index::Integer) + prev_index = index - 1 + prev_index < 1 && (prev_index = length(ss.pts) - 1) + return ss.pts[prev_index] +end + +function _next_in_ring(ss::RelateSegmentString, index::Integer) + next_index = index + 1 + next_index > length(ss.pts) && (next_index = 2) + return ss.pts[next_index] +end + +""" + is_containing_segment(ss::RelateSegmentString, seg_index::Integer, pt) + +Tests if a segment intersection point has that segment as its canonical +containing segment. Segments are half-closed, and contain their start point +but not the endpoint, except for the final segment in a non-closed segment +string, which contains its endpoint as well. This test ensures that vertices +are assigned to a unique segment in a segment string. In particular, this +avoids double-counting intersections which lie exactly at segment endpoints. +""" +function is_containing_segment(ss::RelateSegmentString, seg_index::Integer, pt) + #-- intersection is at segment start vertex - process it + pt == ss.pts[seg_index] && return true + if pt == ss.pts[seg_index + 1] + is_final_segment = seg_index == length(ss.pts) - 1 + (is_closed(ss) || !is_final_segment) && return false + #-- for final segment, process intersections with final endpoint + return true + end + #-- intersection is interior - process it + return true +end diff --git a/src/methods/geom_relations/relateng/relate_ng.jl b/src/methods/geom_relations/relateng/relate_ng.jl new file mode 100644 index 0000000000..99edd106d0 --- /dev/null +++ b/src/methods/geom_relations/relateng/relate_ng.jl @@ -0,0 +1,949 @@ +# # Relate + +export relate, RelateNG, prepare + +#= +## What is relate? + +The `relate` function computes the full topological relationship between two +geometries — not just a single yes/no question like [`intersects`](@ref) or +[`within`](@ref), but the complete description from which *every* such +question can be answered. That description is the +**dimensionally extended nine-intersection model (DE-9IM)** matrix. + +To provide an example, consider these two overlapping polygons: +```@example relateng +import GeometryOps as GO +import GeoInterface as GI +using Makie +using CairoMakie + +p1 = GI.Polygon([[(0.0, 0.0), (3.0, 0.0), (3.0, 3.0), (0.0, 3.0), (0.0, 0.0)]]) +p2 = GI.Polygon([[(2.0, 2.0), (5.0, 2.0), (5.0, 5.0), (2.0, 5.0), (2.0, 2.0)]]) +f, a, p = poly(collect(GI.getpoint(p1)); color = (:blue, 0.5), axis = (; aspect = DataAspect())) +poly!(collect(GI.getpoint(p2)); color = (:orange, 0.5)) +f +``` +Their relationship is captured by a single matrix: +```@example relateng +GO.relate(p1, p2) +``` + +## What is the DE-9IM? + +Every geometry partitions the plane into three point sets: its *interior*, +its *boundary*, and its *exterior*. The DE-9IM matrix records, for each of +the nine pairings of those sets between geometry A (rows) and geometry B +(columns), the dimension of the pair's intersection: `F` for empty, `0` for +points, `1` for lines, `2` for areas. The matrix above, `"212101212"`, reads +row-major: + +| | B interior | B boundary | B exterior | +|----------------|------------|------------|------------| +| **A interior** | `2` | `1` | `2` | +| **A boundary** | `1` | `0` | `1` | +| **A exterior** | `2` | `1` | `2` | + +The interiors meet in an area (`2`), the boundaries cross at points (`0`), +and each geometry has interior outside the other (`2` in the exterior +column/row) — exactly the *overlaps* relationship. Named predicates are +just patterns over this matrix, where `T` means "non-empty (any dimension)" +and `*` means "don't care". You can match a pattern directly, which +short-circuits as soon as the answer is known instead of computing the full +matrix: +```@example relateng +GO.relate(p1, p2, "T*T***T**") # the `overlaps` pattern for two areas +``` +or evaluate a named predicate through the [`RelateNG`](@ref) algorithm: +```@example relateng +GO.overlaps(GO.RelateNG(), p1, p2) +``` +When one geometry is tested against many others, [`prepare`](@ref) it once +and reuse the cached indexes: +```@example relateng +prep = GO.prepare(GO.RelateNG(), p1) +GO.relate(prep, p2) +``` + +## Implementation + +This file is the port of JTS `RelateNG.java` — the evaluation engine that +drives all the RelateNG machinery (the kernel, point locators, topology +computer, and node analysis in the surrounding files) to compute the value +of a topological predicate between two geometries, based on the DE-9IM +model. + +Method order parallels the Java file (`evaluate`, `hasRequiredEnvelopeInteraction`, +`finishValue`, `computePP`, `computeAtPoints`, `computePoints`, `computePoint`, +`computeLineEnds`, `computeLineEnd`, `computeAreaVertex` ×2, `computeAtEdges`, +`computeEdgesAll`, `computeEdgesMutual`), so this file diffs against its +Java counterpart. Idiom changes: + +- The Java class holds `geomA` (for prepared mode); here the unprepared + entry points build both `RelateGeometry`s per call, while prepared mode + carries the A side — with its lazy caches forced and the segment + strings/segment tree prebuilt — in a [`PreparedRelate`](@ref), threaded + through the evaluation as the optional `prep` argument. +- The algorithm configuration (manifold, accelerator, exactness flag, + boundary node rule) travels in the `RelateNG` algorithm struct, the + house `Algorithm{M}` idiom (cf. `FosterHormannClipping`). +- Java's `computeEdgesAll` feeds the combined A∪B edge set through one + `EdgeSetIntersector` (every unordered chain pair once). Here that is + phased as A×B (`process_edge_intersections!`), then A×A and B×B + (`process_self_intersections!`) — the same pair set, possibly visited + in a different order, which only affects *when* a short-circuiting + predicate exits, never the final value (dimension updates are + monotone). +- Java's null `Envelope` is `nothing` here: empty geometries have a + `nothing` extent, `ext_intersects`/`ext_covers` return `false` for it + (as Java's null envelope does), and `computeAtEdges` early-returns + before ever forwarding an extent filter that could be `nothing`. +=# + +""" + RelateNG{M <: Manifold, A <: IntersectionAccelerator, E, BR <: BoundaryNodeRule} + +The next-generation DE-9IM topological-relationship algorithm, a port of the +JTS RelateNG algorithm by Martin Davis. Capabilities: + +1. Efficient short-circuited evaluation of topological predicates (including + matching custom DE-9IM matrix patterns). +2. Robust evaluation: all answers are computed from exact predicates on the + input coordinates (no constructed intersection points), so invalid + topology does not cause failures. +3. `GeometryCollection` inputs containing mixed types and overlapping + polygons are supported, using *union semantics*. +4. Zero-length LineStrings are treated as being topologically identical to + Points. +5. Support for [`BoundaryNodeRule`](@ref)s. + +All coordinates are evaluated as `Float64`: input coordinates are converted +on extraction, and the exact-predicate machinery (adaptive orientation +predicates, rational-arithmetic node coincidence) assumes `Float64` inputs. +Non-`Float64` geometries are accepted but evaluated at `Float64` precision. + +Keyword arguments (all optional): `manifold` (default `Planar()`), +`accelerator` (default [`AutoAccelerator`](@ref)), `exact` (default +`True()`), `boundary_rule` (default [`Mod2Boundary`](@ref), the OGC SFS +rule). + +## Unprepared performance + +Every unprepared evaluation rebuilds an extent-annotated view of both +inputs (the stand-in for the envelope cache JTS keeps on each Geometry), +one coordinate pass per call. Inputs that already carry extents at every +level skip that pass — stamp them once with + +```julia +geom = GO.tuples(geom; calc_extent = true) +``` + +and repeated unprepared calls read the stored extents instead. When one +geometry is queried many times, [`prepare`](@ref) it instead: the prepared +form also caches the point locators and edge index. + +See [`relate`](@ref) and [`relate_predicate`](@ref) for the entry points. +""" +struct RelateNG{M <: Manifold, A <: IntersectionAccelerator, E, BR <: BoundaryNodeRule} <: GeometryOpsCore.Algorithm{M} + manifold::M + accelerator::A + exact::E + boundary_rule::BR +end +RelateNG(; manifold::Manifold = Planar(), accelerator = AutoAccelerator(), + exact = True(), boundary_rule = Mod2Boundary()) = + RelateNG(manifold, accelerator, exact, boundary_rule) +RelateNG(m::Manifold; kw...) = RelateNG(; manifold = m, kw...) +GeometryOpsCore.manifold(alg::RelateNG) = alg.manifold + +#========================================================================== +## Entry points (the static RelateNG.relate(...) overloads) +==========================================================================# + +""" + relate([alg::RelateNG], a, b)::DE9IM + +Computes the [`DE9IM`](@ref) matrix for the topological relationship between +geometries `a` and `b`. + +Port of `RelateNG.relate(Geometry, Geometry)`. +""" +function relate(alg::RelateNG, a, b) + pred = RelateMatrixPredicate() + relate_predicate(alg, pred, a, b) + return result_im(pred) +end +relate(a, b) = relate(RelateNG(), a, b) + +""" + relate([alg::RelateNG], a, b, im_pattern::AbstractString)::Bool + +Tests whether the topological relationship between geometries `a` and `b` +matches the DE-9IM matrix pattern `im_pattern` (9 characters over +`012TF*`). + +Port of `RelateNG.relate(Geometry, Geometry, String)`. +""" +relate(alg::RelateNG, a, b, im_pattern::AbstractString) = + relate_predicate(alg, pred_matches(im_pattern), a, b) +relate(a, b, im_pattern::AbstractString) = relate(RelateNG(), a, b, im_pattern) + +# Method-ambiguity guard between `relate(alg, a, b)` and +# `relate(a, b, pattern)`: a String third argument with a RelateNG first +# argument is missing its B geometry. +relate(alg::RelateNG, a, im_pattern::AbstractString) = + throw(ArgumentError("`relate(alg, a, b, pattern)` requires both geometries; got a pattern in place of the B geometry")) + +""" + relate_predicate(alg::RelateNG, predicate::TopologyPredicate, a, b)::Bool + +Tests whether the topological relationship between geometries `a` and `b` +satisfies the given [`TopologyPredicate`](@ref). This is the core evaluation +entry point (the port of `RelateNG.evaluate(Geometry, TopologyPredicate)`, +via the static `RelateNG.relate(a, b, pred)`). + +!!! note + Predicates are mutable accumulators — pass a freshly constructed one + (e.g. `pred_intersects()`) per evaluation. +""" +function relate_predicate(alg::RelateNG, predicate::TopologyPredicate, a, b) + m = GeometryOpsCore.manifold(alg) + geom_a = RelateGeometry(m, a; exact = alg.exact, boundary_rule = alg.boundary_rule) + return evaluate!(alg, geom_a, b, predicate) +end + +#========================================================================== +## Named-predicate methods + +The ports of the JTS RelateNG static predicate overloads. These add +`RelateNG` algorithm methods to the existing GO predicate functions, +following the house `GO.f(alg::Algorithm, a, b)` idiom (cf. +`GO.intersects(GO.GEOS(), a, b)` in the LibGEOS extension). They are +opt-in: the two-argument forms `GO.intersects(a, b)` etc. keep +dispatching to the old engines (design D4). + +`equals` maps to `pred_equalstopo`, i.e. *topological* equality (the +DE-9IM `T*F**FFF*` sense), which can differ from the structural equality +the two-argument `GO.equals` implements only in exotic cases (both +treat rotated/reversed rings and repeated points as equal). +==========================================================================# +intersects(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_intersects(), g1, g2) +disjoint(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_disjoint(), g1, g2) +contains(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_contains(), g1, g2) +within(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_within(), g1, g2) +covers(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_covers(), g1, g2) +coveredby(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_coveredby(), g1, g2) +crosses(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_crosses(), g1, g2) +overlaps(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_overlaps(), g1, g2) +touches(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_touches(), g1, g2) +equals(alg::RelateNG, g1, g2) = relate_predicate(alg, pred_equalstopo(), g1, g2) + +#========================================================================== +## Evaluation (port of RelateNG.evaluate and helpers) +==========================================================================# + +# Port of RelateNG.evaluate(Geometry b, TopologyPredicate predicate): +# the phased evaluation against a prebuilt A-side RelateGeometry. In +# prepared mode `prep` is the `PreparedRelate` carrying the cached A-side +# edges/index; otherwise `nothing`. +function evaluate!(alg::RelateNG, geom_a::RelateGeometry, b, predicate::TopologyPredicate, + prep = nothing) + #-- Java performs the envelope fast-exit before building the B + #-- RelateGeometry, reading the envelope cached on the Geometry. Here the + #-- RelateGeometry constructor is what caches the extents (one coordinate + #-- pass either way), so B is built first and the check reads its extent. + geom_b = RelateGeometry(geom_a.m, b; exact = geom_a.exact, + boundary_rule = geom_a.boundary_rule) + if !has_required_envelope_interaction(geom_a, get_extent(geom_b), predicate) + return false + end + + dim_a = get_dimension_real(geom_a) + dim_b = get_dimension_real(geom_b) + + #-- check if predicate is determined by dimension or envelope + init_dims!(predicate, dim_a, dim_b) + is_known(predicate) && return finish_value!(predicate) + + init_bounds!(predicate, get_extent(geom_a), get_extent(geom_b)) + is_known(predicate) && return finish_value!(predicate) + + tc = TopologyComputer(predicate, geom_a, geom_b) + + #-- optimized P/P evaluation + if dim_a == DIM_P && dim_b == DIM_P + compute_pp!(tc, geom_a, geom_b) + finish!(tc) + return get_result(tc) + end + + #-- test points against (potentially) indexed geometry first + compute_at_points!(tc, geom_b, GEOM_B, geom_a) + is_result_known(tc) && return get_result(tc) + compute_at_points!(tc, geom_a, GEOM_A, geom_b) + is_result_known(tc) && return get_result(tc) + + if has_edges(geom_a) && has_edges(geom_b) + compute_at_edges!(alg, tc, geom_a, geom_b, prep) + end + + #-- after all processing, set remaining unknown values in IM + finish!(tc) + return get_result(tc) +end + +# Port of RelateNG.hasRequiredEnvelopeInteraction (private). `env_b` is the +# B extent computed directly from the raw geometry by the caller, as Java +# reads `b.getEnvelopeInternal()` before constructing the B RelateGeometry; +# an empty geometry yields a `nothing` (null) extent, for which `ext_covers`/ +# `ext_intersects` return false, exactly like Java's null Envelope. +function has_required_envelope_interaction(geom_a::RelateGeometry, env_b, predicate::TopologyPredicate) + is_interacts = false + if require_covers(predicate, GEOM_A) + if !ext_covers(get_extent(geom_a), env_b) + return false + end + is_interacts = true + elseif require_covers(predicate, GEOM_B) + if !ext_covers(env_b, get_extent(geom_a)) + return false + end + is_interacts = true + end + if !is_interacts && + require_interaction(predicate) && + !ext_intersects(get_extent(geom_a), env_b) + return false + end + return true +end + +# Port of RelateNG.finishValue (private). +function finish_value!(predicate::TopologyPredicate) + finish!(predicate) + return predicate_value(predicate) +end + +#= +Port of RelateNG.computePP (private): an optimized algorithm for evaluating +P/P cases. It tests one point set against the other. +=# +function compute_pp!(tc::TopologyComputer, geom_a::RelateGeometry, geom_b::RelateGeometry) + pts_a = get_unique_points(geom_a) + #TODO: only query points in interaction extent? + pts_b = get_unique_points(geom_b) + + num_b_in_a = 0 + for pt_b in pts_b + if pt_b in pts_a + num_b_in_a += 1 + add_point_on_point_interior!(tc, pt_b) + else + add_point_on_point_exterior!(tc, GEOM_B, pt_b) + end + is_result_known(tc) && return nothing + end + #= + If number of matched B points is less than size of A, + there must be at least one A point in the exterior of B + =# + if num_b_in_a < length(pts_a) + #TODO: determine actual exterior point? + add_point_on_point_exterior!(tc, GEOM_A, nothing) + end + return nothing +end + +# Port of RelateNG.computeAtPoints (private). +function compute_at_points!(tc::TopologyComputer, geom::RelateGeometry, is_a::Bool, + geom_target::RelateGeometry) + is_result_known_ = compute_points!(tc, geom, is_a, geom_target) + is_result_known_ && return nothing + + #= + Performance optimization: only check points against target + if it has areas OR if the predicate requires checking for + exterior interaction. + In particular, this avoids testing line ends against lines + for the intersects predicate (since these are checked + during segment/segment intersection checking anyway). + Checking points against areas is necessary, since the input + linework is disjoint if one input lies wholly inside an area, + so segment intersection checking is not sufficient. + =# + check_disjoint_points = has_dimension(geom_target, DIM_A) || + is_exterior_check_required(tc, is_a) + check_disjoint_points || return nothing + + is_result_known_ = compute_line_ends!(tc, geom, is_a, geom_target) + is_result_known_ && return nothing + + compute_area_vertex!(tc, geom, is_a, geom_target) + return nothing +end + +# Port of RelateNG.computePoints (private). +function compute_points!(tc::TopologyComputer, geom::RelateGeometry, is_a::Bool, + geom_target::RelateGeometry) + has_dimension(geom, DIM_P) || return false + + points = get_effective_points(geom) + for point in points + #TODO: exit when all possible target locations (E,I,B) have been found? + GI.isempty(point) && continue + + pt = _to_kernel_point(geom.m, point) + compute_point!(tc, is_a, pt, geom_target) + is_result_known(tc) && return true + end + return false +end + +# Port of RelateNG.computePoint (private). +function compute_point!(tc::TopologyComputer, is_a::Bool, pt, geom_target::RelateGeometry) + loc_dim_target = locate_with_dim(geom_target, pt) + loc_target = dimloc_location(loc_dim_target) + dim_target = dimloc_dimension(loc_dim_target, get_dimension(tc, !is_a)) + add_point_on_geometry!(tc, is_a, loc_target, dim_target, pt) + return nothing +end + +# Port of RelateNG.computeLineEnds (private). The Java +# GeometryCollectionIterator + `instanceof LineString` filter becomes a +# recursive walk over atomic curve elements; the walk threads the +# `hasExteriorIntersection` flag and reports early exit through its first +# return value. +function compute_line_ends!(tc::TopologyComputer, geom::RelateGeometry, is_a::Bool, + geom_target::RelateGeometry) + has_dimension(geom, DIM_L) || return false + done, _ = _compute_line_ends_walk!(tc, geom.geom, geom, is_a, geom_target, false) + return done +end + +function _compute_line_ends_walk!(tc::TopologyComputer, elem, geom::RelateGeometry, + is_a::Bool, geom_target::RelateGeometry, has_exterior_intersection::Bool) + trait = GI.trait(elem) + if trait isa GI.AbstractGeometryCollectionTrait + for g in GI.getgeom(elem) + done, has_exterior_intersection = _compute_line_ends_walk!( + tc, g, geom, is_a, geom_target, has_exterior_intersection) + done && return (true, has_exterior_intersection) + end + return (false, has_exterior_intersection) + end + trait isa GI.AbstractCurveTrait || return (false, has_exterior_intersection) + GI.isempty(elem) && return (false, has_exterior_intersection) + + #-- once an intersection with target exterior is recorded, skip further known-exterior points + if has_exterior_intersection && _elem_env_disjoint(geom.m, elem, get_extent(geom_target)) + return (false, has_exterior_intersection) + end + + e0 = _to_kernel_point(geom.m, GI.getpoint(elem, 1)) + has_exterior_intersection |= compute_line_end!(tc, geom, is_a, e0, geom_target) + is_result_known(tc) && return (true, has_exterior_intersection) + + if !_line_is_closed(elem) + e1 = _to_kernel_point(geom.m, GI.getpoint(elem, GI.npoint(elem))) + has_exterior_intersection |= compute_line_end!(tc, geom, is_a, e1, geom_target) + is_result_known(tc) && return (true, has_exterior_intersection) + end + #TODO: break when all possible locations have been found? + return (false, has_exterior_intersection) +end + +#= +Port of RelateNG.computeLineEnd (private): compute the topology of a line +endpoint. Also reports if the line end is in the exterior of the target +geometry, to optimize testing multiple exterior endpoints. +=# +function compute_line_end!(tc::TopologyComputer, geom::RelateGeometry, is_a::Bool, pt, + geom_target::RelateGeometry) + loc_dim_line_end = locate_line_end_with_dim(geom, pt) + dim_line_end = dimloc_dimension(loc_dim_line_end, get_dimension(tc, is_a)) + #-- skip line ends which are in a GC area + dim_line_end != DIM_L && return false + loc_line_end = dimloc_location(loc_dim_line_end) + + loc_dim_target = locate_with_dim(geom_target, pt) + loc_target = dimloc_location(loc_dim_target) + dim_target = dimloc_dimension(loc_dim_target, get_dimension(tc, !is_a)) + add_line_end_on_geometry!(tc, is_a, loc_line_end, loc_target, dim_target, pt) + return loc_target == LOC_EXTERIOR +end + +# Port of RelateNG.computeAreaVertex(geom, isA, geomTarget, topoComputer) +# (private): the recursive walk over atomic polygon elements. +function compute_area_vertex!(tc::TopologyComputer, geom::RelateGeometry, is_a::Bool, + geom_target::RelateGeometry) + has_dimension(geom, DIM_A) || return false + #-- evaluate for line and area targets only, since points are handled in the reverse direction + get_dimension(geom_target) < DIM_L && return false + done, _ = _compute_area_vertex_walk!(tc, geom.geom, geom, is_a, geom_target, false) + return done +end + +function _compute_area_vertex_walk!(tc::TopologyComputer, elem, geom::RelateGeometry, + is_a::Bool, geom_target::RelateGeometry, has_exterior_intersection::Bool) + trait = GI.trait(elem) + if trait isa GI.AbstractGeometryCollectionTrait + for g in GI.getgeom(elem) + done, has_exterior_intersection = _compute_area_vertex_walk!( + tc, g, geom, is_a, geom_target, has_exterior_intersection) + done && return (true, has_exterior_intersection) + end + return (false, has_exterior_intersection) + end + trait isa GI.AbstractPolygonTrait || return (false, has_exterior_intersection) + GI.isempty(elem) && return (false, has_exterior_intersection) + + #-- once an intersection with target exterior is recorded, skip further known-exterior points + if has_exterior_intersection && _elem_env_disjoint(geom.m, elem, get_extent(geom_target)) + return (false, has_exterior_intersection) + end + + has_exterior_intersection |= + compute_area_vertex_on_ring!(tc, geom, is_a, GI.getexterior(elem), geom_target) + is_result_known(tc) && return (true, has_exterior_intersection) + + for hole in GI.gethole(elem) + has_exterior_intersection |= + compute_area_vertex_on_ring!(tc, geom, is_a, hole, geom_target) + is_result_known(tc) && return (true, has_exterior_intersection) + end + return (false, has_exterior_intersection) +end + +# Port of RelateNG.computeAreaVertex(geom, isA, ring, geomTarget, topoComputer) +# (private). +function compute_area_vertex_on_ring!(tc::TopologyComputer, geom::RelateGeometry, + is_a::Bool, ring, geom_target::RelateGeometry) + #TODO: use extremal (highest) point to ensure one is on boundary of polygon cluster + pt = _to_kernel_point(geom.m, GI.getpoint(ring, 1)) + + loc_area = locate_area_vertex(geom, pt) + loc_dim_target = locate_with_dim(geom_target, pt) + loc_target = dimloc_location(loc_dim_target) + dim_target = dimloc_dimension(loc_dim_target, get_dimension(tc, !is_a)) + add_area_vertex!(tc, is_a, loc_area, loc_target, dim_target, pt) + return loc_target == LOC_EXTERIOR +end + +# Port of RelateNG.computeAtEdges (private). The interaction envelope +# replaces Java's `Envelope.intersection` + `isNull` with +# `Extents.intersection`, which returns `nothing` for disjoint extents; an +# empty input's `nothing` extent also short-circuits here, so the extent +# filter passed to `extract_segment_strings` is never `nothing` +# (see the warning on that function). +function compute_at_edges!(alg::RelateNG, tc::TopologyComputer, + geom_a::RelateGeometry, geom_b::RelateGeometry, prep = nothing) + ext_a = get_extent(geom_a) + ext_b = get_extent(geom_b) + (ext_a === nothing || ext_b === nothing) && return nothing + env_int = Extents.intersection(ext_a, ext_b) + env_int === nothing && return nothing + + edges_b = extract_segment_strings(geom_b, GEOM_B, env_int) + + if is_self_noding_required(tc) + #-- predicates requiring self-noding bypass the prepared cache: as in + #-- Java, computeEdgesAll re-extracts the A edges per evaluation, + #-- filtered by the interaction envelope (`prep` is not forwarded) + compute_edges_all!(alg, tc, geom_a, edges_b, env_int) + else + compute_edges_mutual!(alg, tc, geom_a, edges_b, env_int, prep) + end + is_result_known(tc) && return nothing + + evaluate_nodes!(tc) + return nothing +end + +# Port of RelateNG.computeEdgesAll (private): the self-noding path. Java +# feeds A∪B through one EdgeSetIntersector (each unordered chain pair once, +# including self pairs); here the same pair set is phased as A×B, A×A, B×B. +function compute_edges_all!(alg::RelateNG, tc::TopologyComputer, + geom_a::RelateGeometry, edges_b, env_int) + #TODO: find a way to reuse prepared index? + edges_a = extract_segment_strings(geom_a, GEOM_A, env_int) + + #-- mutual A×B pairs + process_edge_intersections!(tc, edges_a, edges_b, alg.accelerator) + is_result_known(tc) && return nothing + #-- guarded A×A self pairs + process_self_intersections!(tc, edges_a, alg.accelerator) + is_result_known(tc) && return nothing + #-- guarded B×B self pairs + process_self_intersections!(tc, edges_b, alg.accelerator) + return nothing +end + +# Port of RelateNG.computeEdgesMutual (private). In prepared mode (`prep` +# a `PreparedRelate`) the cached A-side segment strings and prebuilt segment +# tree are reused — the port of Java's cached +# `MCIndexSegmentSetMutualIntersector`, which is built over A edges extracted +# with a *null* filter (`envExtract = geomA.isPrepared() ? null : envInt`): +# the cached strings are unfiltered so they serve any future B. +function compute_edges_mutual!(alg::RelateNG, tc::TopologyComputer, + geom_a::RelateGeometry, edges_b, env_int, prep = nothing) + if prep === nothing + edges_a = extract_segment_strings(geom_a, GEOM_A, env_int) + process_edge_intersections!(tc, edges_a, edges_b, alg.accelerator) + else + _process_prepared_edges!(tc, prep.segs_a, prep.edge_tree, edges_b) + end + return nothing +end + +#========================================================================== +## Prepared mode + +The port of the RelateNG.prepare entry points and the prepared-mode +branches. +==========================================================================# + +""" + PreparedRelate{ALG, RG, SS, T} + +A prepared RelateNG instance for optimized repeated evaluation of +topological relationships against a single geometry `a` (the "prepared +mode" of JTS `RelateNG.prepare`). Holds: + +- `alg`: the [`RelateNG`](@ref) algorithm configuration, +- `geom_a`: the A-side [`RelateGeometry`](@ref), constructed with + `is_prepared = true` and with its lazy locator/unique-points caches + forced, +- `segs_a`: the A segment strings, extracted once *without* an + interaction-envelope filter so they serve any B geometry, +- `edge_tree`: the prebuilt segment index over `segs_a` (`_relate_edge_index`, + edge_intersector.jl — the stand-in for Java's cached + `MCIndexSegmentSetMutualIntersector`), or `nothing` below the accelerator + size threshold (where the nested loop wins). + +Construct with [`prepare`](@ref); evaluate with [`relate`](@ref) / +[`relate_predicate`](@ref). + +!!! warning + Not safe for concurrent use: self-noding evaluations mutate the held + `RelateGeometry` (edge re-extraction, element-id counter). Use one + `PreparedRelate` per thread. +""" +struct PreparedRelate{ALG <: RelateNG, RG <: RelateGeometry, + SS <: AbstractVector{<:RelateSegmentString}, T <: Union{Nothing, RTree}} + alg::ALG + geom_a::RG + segs_a::SS + edge_tree::T +end + +""" + prepare(alg::RelateNG, a; validate = )::PreparedRelate + +`prepare` is the generic entry point for prepared-geometry optimizations in +GeometryOps; `RelateNG` is currently the only algorithm implementing it. + +Creates a prepared relate instance to optimize the repeated evaluation of +relationships against the single geometry `a`. + +Port of `RelateNG.prepare(Geometry)` (the algorithm's `boundary_rule` plays +the role of the `prepare(Geometry, BoundaryNodeRule)` overload). The A-side +`RelateGeometry` is constructed with `is_prepared = true`, and the lazy +caches that the Java instance accumulates across evaluations are forced +eagerly: + +- the [`RelatePointLocator`](@ref) — the prepared flag selects the + per-polygonal-element [`IndexedPointInAreaLocator`](@ref) caches, as in + Java (see `point_locator.jl` / `indexed_point_in_area.jl`); +- the unique-point set, when `a` has effective dimension P (the only case + the P/P fast path consults it); +- the A segment strings, extracted ONCE **without** an interaction-envelope + filter (Java's prepared-mode `envExtract = null` in `computeEdgesMutual`) + so the cache serves any future B, plus the prebuilt segment tree over + them. + +!!! note + Predicates whose evaluation requires self-noding + (`is_self_noding_required`) bypass the cached edges entirely: as in the + Java prepared branch, `computeEdgesAll` re-extracts the A edges per + evaluation, filtered by the A/B interaction envelope. + +## Validation + +`validate` controls a ring self-crossing check over the prepared geometry: +a self-join of the segment set (reusing the prepared edge index) that +detects PROPER crossings — transversal, interior to both edges — between +non-adjacent edges of the same polygonal element. Shared-endpoint adjacency +is excluded and vertex touches are NOT flagged: the scope is exactly the +crossing class that breaks the engine's containment parity, not full OGC +validity. On the first crossing found an `ArgumentError` is thrown naming +the ring and the edge pair (\"edge i crosses edge j\"); the documented +remedy is the [`CrossingEdgeSplit`](@ref) correction. + +The default is manifold-dependent, and deliberately so: + +- `Spherical` → `validate = true`. A planar-valid ring whose edges cross + when reinterpreted as great-circle arcs is *undetectable by standard + planar tooling* (planar validity checks pass it), and undetected it can + invert containment globally — the figure-eight's lobes cancel the + curvature the interior bootstrap reads, so every query lands on the + wrong side (Natural Earth 110m Sudan is a real instance). The check is + a small fraction of the ~100 ms spherical prepare build. +- `Planar` → `validate = false`. Planar invalidity of the same class is + visible to ordinary planar tools, and the planar engine degrades + gracefully under even-odd ray-crossing parity instead of inverting; + JTS/GEOS prepared geometries do not validate either. A planar prepare + costs ~600 µs, which a validation join would dominate, destroying the + build-cost amortization. Pass `validate = true` to opt in. +""" +function prepare(alg::RelateNG, a; + validate::Bool = _prepare_validate_default(GeometryOpsCore.manifold(alg))) + m = GeometryOpsCore.manifold(alg) + geom_a = RelateGeometry(m, a; exact = alg.exact, is_prepared = true, + boundary_rule = alg.boundary_rule) + #-- cached A edges: extracted once, unfiltered (Java's null envExtract). + #-- Extracted (and validated) before the locator build below, so an + #-- invalid ring fails fast instead of after the expensive locator pass. + segs_a = extract_segment_strings(geom_a, GEOM_A, nothing) + edge_tree = _build_prepared_edge_index(m, alg.accelerator, segs_a) + validate && _validate_ring_crossings(m, alg.exact, segs_a, edge_tree) + #-- force the lazy caches that repeated evaluations reuse + _get_locator(geom_a) + get_dimension_real(geom_a) == DIM_P && get_unique_points(geom_a) + return PreparedRelate(alg, geom_a, segs_a, edge_tree) +end + +# The manifold-dependent `validate` default of `prepare` (see its docstring +# for the rationale): only `Spherical`, where this invalidity class is both +# invisible to planar tooling and containment-inverting, validates by +# default. +_prepare_validate_default(::Spherical) = true +_prepare_validate_default(::Manifold) = false + +#= +The validation join: enumerate extent-interacting segment pairs within the +A segment strings — through the prepared edge tree when one was built +(`dual_depth_first_search` of the tree against itself, exactly like the +self-noding tree path), otherwise a nested loop with the same per-pair +extent prune — and throw on the first PROPER crossing between non-adjacent +edges of the same polygonal element. Only ring edges are checked +(`dim == DIM_A` on both strings, same element id): lines may legitimately +self-cross, and rings of different elements (e.g. two polygons of a +MultiPolygon) may overlap without breaking per-element containment parity. +Shared-endpoint pairs are skipped by coordinate equality before any +predicate runs: adjacency and vertex touches are out of scope (see the +`prepare` docstring), and an exactly-zero orient would otherwise force the +adaptive exact stage on every adjacent pair. +=# +function _validate_ring_crossings(m::Manifold, exact, segs_a, edge_tree) + if edge_tree === nothing + _validate_ring_crossings_nested(m, exact, segs_a) + else + SpatialTreeInterface.dual_depth_first_search(Extents.intersects, edge_tree, edge_tree) do ia, ib + ia < ib || return nothing + (sa, ka) = edge_tree.data[ia] + (sb, kb) = edge_tree.data[ib] + _check_ring_crossing(m, exact, segs_a[sa], ka, segs_a[sb], kb) + return nothing + end + end + return nothing +end + +function _validate_ring_crossings_nested(m::Manifold, exact, segs_a) + for si in eachindex(segs_a) + ssa = segs_a[si] + ssa.dim == DIM_A || continue + for sj in si:lastindex(segs_a) + ssb = segs_a[sj] + (ssb.dim == DIM_A && ssb.id == ssa.id) || continue + for ka in 1:(length(ssa.pts) - 1) + a0 = ssa.pts[ka] + a1 = ssa.pts[ka + 1] + kb0 = si == sj ? ka + 1 : 1 + for kb in kb0:(length(ssb.pts) - 1) + _segment_envs_disjoint(m, a0, a1, ssb.pts[kb], ssb.pts[kb + 1]) && continue + _check_ring_crossing(m, exact, ssa, ka, ssb, kb) + end + end + end + end + return nothing +end + +# One candidate pair of the validation join: filter (same-element rings, +# no shared endpoint), confirm (`_edges_cross_properly`), throw. +function _check_ring_crossing(m::Manifold, exact, + ssa::RelateSegmentString, ka::Integer, ssb::RelateSegmentString, kb::Integer) + (ssa.dim == DIM_A && ssb.dim == DIM_A && ssa.id == ssb.id) || return nothing + a0 = ssa.pts[ka] + a1 = ssa.pts[ka + 1] + b0 = ssb.pts[kb] + b1 = ssb.pts[kb + 1] + #-- kernel-point `==` (all coordinates): adjacency and vertex touches + #-- are out of scope, and skipping them here keeps the exactly-zero + #-- orients of shared endpoints out of the adaptive exact stage + (a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1) && return nothing + _edges_cross_properly(m, a0, a1, b0, b1; exact) && + _throw_ring_crossing(m, ssa, ka, ssb, kb) + return nothing +end + +@noinline function _throw_ring_crossing(m::Manifold, ssa, ka, ssb, kb) + ia = _input_edge_index(m, ssa, ka) + ib = _input_edge_index(m, ssb, kb) + ssa === ssb && ib < ia && ((ia, ib) = (ib, ia)) + ring_desc(ss) = ss.ring_id == 0 ? "the shell" : "hole $(ss.ring_id)" + place = ssa === ssb ? + "edge $ia crosses edge $ib in $(ring_desc(ssa)) of polygonal element $(ssa.id)" : + "edge $ia of $(ring_desc(ssa)) crosses edge $ib of $(ring_desc(ssb)) in polygonal element $(ssa.id)" + throw(ArgumentError( + "prepare: geometry is invalid on the `$(nameof(typeof(m)))` manifold: $place " * + "(a proper crossing between non-adjacent ring edges). Left undetected, such a " * + "crossing can invert containment globally; repair the geometry first with the " * + "`CrossingEdgeSplit` correction (it splits each ring at its crossing points " * + "into separate loops), or pass `validate = false` to skip this check")) +end + +#= +Input-order edge index for the error message: segment strings store ring +vertices in engine orientation (shells and holes reversed as needed, repeated +points removed), so a stored segment index does not generally match an edge +of the ring as the user wrote it. Re-derive the input index by locating the +crossing edge's kernel endpoints among the input ring's consecutive vertex +pairs, in either direction. Falls back to the stored index if the ring or +pair cannot be found (it always should be). Error-path only. +=# +function _input_edge_index(m::Manifold, ss::RelateSegmentString, k::Integer) + ring = _extracted_ring(ss) + ring === nothing && return Int(k) + e0 = ss.pts[k] + e1 = ss.pts[k + 1] + n = GI.npoint(ring) + prev = _to_kernel_point(m, GI.getpoint(ring, 1)) + for i in 2:n + cur = _to_kernel_point(m, GI.getpoint(ring, i)) + ((prev == e0 && cur == e1) || (prev == e1 && cur == e0)) && return i - 1 + prev = cur + end + return Int(k) +end + +# The input ring a ring segment string was extracted from: the `ring_id`-th +# ring of the `id`-th non-empty atomic element of the input geometry. +function _extracted_ring(ss::RelateSegmentString) + elem, _ = _nth_atomic_element(ss.input_geom.geom, Int(ss.id)) + (elem === nothing || !(GI.trait(elem) isa GI.AbstractPolygonTrait)) && return nothing + ss.ring_id == 0 && return GI.getexterior(elem) + for (i, hole) in enumerate(GI.gethole(elem)) + i == ss.ring_id && return hole + end + return nothing +end + +# The `remaining`-th non-empty atomic element of `geom` in extraction order — +# the walk of `_extract_segment_strings!`, whose `element_id` counter ticks +# once per non-empty atomic element (collections, including Multi* types, +# recurse). Returns `(element_or_nothing, remaining_after)`. +function _nth_atomic_element(geom, remaining::Int) + if GI.trait(geom) isa GI.AbstractGeometryCollectionTrait + for g in GI.getgeom(geom) + elem, remaining = _nth_atomic_element(g, remaining) + elem === nothing || return (elem, remaining) + end + return (nothing, remaining) + end + GI.isempty(geom) && return (nothing, remaining) + remaining -= 1 + return (remaining == 0 ? geom : nothing, remaining) +end + +""" + relate(p::PreparedRelate, b)::DE9IM + +Computes the DE-9IM matrix for the topological relationship of the prepared +geometry to `b`. Port of the instance method `RelateNG.evaluate(Geometry)`. +""" +function relate(p::PreparedRelate, b) + pred = RelateMatrixPredicate() + relate_predicate(p, pred, b) + return result_im(pred) +end + +""" + relate(p::PreparedRelate, b, im_pattern::AbstractString)::Bool + +Tests whether the topological relationship of the prepared geometry to `b` +matches the DE-9IM pattern. Port of `RelateNG.evaluate(Geometry, String)`. +""" +relate(p::PreparedRelate, b, im_pattern::AbstractString) = + relate_predicate(p, pred_matches(im_pattern), b) + +""" + relate_predicate(p::PreparedRelate, predicate::TopologyPredicate, b)::Bool + +Tests whether the topological relationship of the prepared geometry to `b` +satisfies the predicate. Port of the instance method +`RelateNG.evaluate(Geometry, TopologyPredicate)` in prepared mode. +""" +relate_predicate(p::PreparedRelate, predicate::TopologyPredicate, b) = + evaluate!(p.alg, p.geom_a, b, predicate, p) + +# Whether to prebuild the A-side segment tree, mirroring the dispatch of +# `process_edge_intersections!` + `_select_edge_set_accelerator`: an explicit +# `NestedLoop` accelerator never uses a tree; `AutoAccelerator` uses one on +# the manifolds with a segment-extent kernel (`Planar`/`Spherical`) above +# the clipping size threshold only (B is unknown at prepare time, so the +# decision is made on A's segment count alone); any other explicit +# accelerator always takes the tree path. +_build_prepared_edge_index(m::Manifold, ::IntersectionAccelerator, segs_a) = + _relate_edge_index(m, segs_a) +_build_prepared_edge_index(::Manifold, ::NestedLoop, segs_a) = nothing +_build_prepared_edge_index(::Manifold, ::AutoAccelerator, segs_a) = nothing +function _build_prepared_edge_index(m::Union{Planar, Spherical}, ::AutoAccelerator, segs_a) + _total_segment_count(segs_a) >= GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS || + return nothing + return _relate_edge_index(m, segs_a) +end + +# The prepared counterpart of the mutual-pair enumeration: no prebuilt tree +# means the cached strings go through the plain nested loop. +_process_prepared_edges!(tc::TopologyComputer, segs_a, ::Nothing, edges_b) = + process_edge_intersections!(tc, segs_a, edges_b, NestedLoop()) + +# Tree path: the prebuilt A tree is dual-traversed against a per-call tree +# over B's (envelope-filtered) segment extents — cf. the unprepared tree path +# in `process_edge_intersections!`, which builds both trees per call. +function _process_prepared_edges!(tc::TopologyComputer, segs_a, + tree_a::RTree, edges_b; + m::Manifold = _manifold(tc), exact = _exact(tc)) + tree_b = _relate_edge_index(m, edges_b) + tree_b === nothing && return nothing + SpatialTreeInterface.dual_depth_first_search(Extents.intersects, tree_a, tree_b) do ia, ib + (sa, ka) = tree_a.data[ia] + (sb, kb) = tree_b.data[ib] + process_intersections!(tc, segs_a[sa], ka, edges_b[sb], kb; m, exact) + #-- the Java noder's isDone() early-exit hook + is_result_known(tc) && return Action(:full_return, nothing) + return nothing + end + return nothing +end + +#========================================================================== +## Small geometry helpers +==========================================================================# + +# Java `elem.getEnvelopeInternal().disjoint(geomTarget.getEnvelope())`. A +# null (empty-geometry) target extent intersects nothing, hence is disjoint +# (only reachable when the target is empty but the predicate still requires +# exterior checks). +_elem_env_disjoint(m::Manifold, elem, target_ext) = + target_ext === nothing || !Extents.intersects(rk_interaction_bounds(m, elem), target_ext) + +# Java LineString.isClosed: false for an empty line, otherwise exact 2D +# coordinate equality of the endpoints. +function _line_is_closed(line) + n = GI.npoint(line) + n == 0 && return false + return _equals2(_node_point(GI.getpoint(line, 1)), _node_point(GI.getpoint(line, n))) +end diff --git a/src/methods/geom_relations/relateng/relate_node.jl b/src/methods/geom_relations/relateng/relate_node.jl new file mode 100644 index 0000000000..5dfa0b01f0 --- /dev/null +++ b/src/methods/geom_relations/relateng/relate_node.jl @@ -0,0 +1,648 @@ +# # RelateNG node-edge topology +# +# Ports of JTS `RelateEdge.java` and `RelateNode.java`, in this order (JTS +# file boundaries preserved as clearly marked sections): +# +# 1. `RelateEdge` — one edge of the node "wheel": a direction around the +# node carrying, per input geometry, the dimension and the left/on/right +# locations of that geometry relative to the edge. +# 2. `RelateNode` — the wheel itself: the edges around a node in CCW order, +# with insertion-or-merge and area-label propagation. +# +# Method order within each section parallels the Java file, so this file +# diffs against its Java counterparts. +# +# The Java classes are mutually recursive (`RelateEdge` holds its parent +# `RelateNode` to reach the node coordinate in `compareToEdge`); here the +# edge stores the symbolic `NodeKey` directly (design D2), and the manifold +# and `exact` flag the angle comparison needs are stored on the `RelateNode` +# (consistent with `RelateGeometry`/`AdjacentEdgeLocator`) and passed into +# `compare_to_edge`. + +# Port of JTS Position constants (org.locationtech.jts.geom.Position): +# the indices for the location of a point relative to a directed edge. +const POS_ON = Int8(0) +const POS_LEFT = Int8(1) +const POS_RIGHT = Int8(2) + +#========================================================================== +## RelateEdge (port of JTS RelateEdge.java) +==========================================================================# + +# Port of RelateEdge.IS_FORWARD / IS_REVERSE. +const IS_FORWARD = true +const IS_REVERSE = false + +#= +The dimension of an input geometry which is not known (Java +RelateEdge.DIM_UNKNOWN = -1). Numerically equal to `DIM_FALSE`, but kept as +its own name because the module-level `DIM_UNKNOWN` (from the IMPredicate +port in topology_predicate.jl) is JTS's *other* DIM_UNKNOWN constant, which +equals Dimension.DONTCARE = -3. +=# +const DIM_UNKNOWN_EDGE = Int8(-1) + +# Indicates that the location is currently unknown (Java LOC_UNKNOWN = +# Location.NONE); `LOC_NONE` (de9im.jl) is used directly below. + +""" + RelateEdge{P} + +An edge of a [`RelateNode`](@ref)'s wheel: the direction `node → dir_pt`, +labeled per input geometry with the dimension of the geometry element the +edge came from and the geometry's location on the left of, right of, and on +the edge. Unknown dimensions are `DIM_UNKNOWN_EDGE`; unknown locations are +`LOC_NONE`. + +Port of JTS `RelateEdge`; the Java class stores its parent `RelateNode` to +reach the node coordinate, here the symbolic [`NodeKey`](@ref) is stored +directly (design D2). +""" +mutable struct RelateEdge{P} + const node::NodeKey{P} + const dir_pt::P + + a_dim::Int8 + a_loc_left::Int8 + a_loc_right::Int8 + a_loc_line::Int8 + + b_dim::Int8 + b_loc_left::Int8 + b_loc_right::Int8 + b_loc_line::Int8 +end + +#= +Port of the static RelateEdge.create(node, dirPt, isA, dim, isForward) +factory: an area edge for `DIM_A`, a line edge otherwise. +=# +function relate_edge(node::NodeKey, dir_pt, is_a::Bool, dim::Integer, is_forward::Bool) + if dim == DIM_A + #-- create an area edge + return RelateEdge(node, dir_pt, is_a, is_forward) + end + #-- create line edge + return RelateEdge(node, dir_pt, is_a) +end + +# Port of the static RelateEdge.findKnownEdgeIndex(edges, isA): index of the +# first edge with a known dimension for the geometry (1-based; 0 if none — +# Java returns -1). +function find_known_edge_index(edges::AbstractVector{<:RelateEdge}, is_a::Bool) + for (i, e) in pairs(edges) + is_known(e, is_a) && return i + end + return 0 +end + +# Port of the static RelateEdge.setAreaInterior(edges, isA) (renamed to +# distinguish it from the single-edge `set_area_interior!`). +function set_all_area_interior!(edges::AbstractVector{<:RelateEdge}, is_a::Bool) + for e in edges + set_area_interior!(e, is_a) + end + return nothing +end + +# All-unknown base edge (the Java field initializers). +_relate_edge_unknown(node::NodeKey{P}, pt) where {P} = RelateEdge{P}(node, pt, + DIM_UNKNOWN_EDGE, LOC_NONE, LOC_NONE, LOC_NONE, + DIM_UNKNOWN_EDGE, LOC_NONE, LOC_NONE, LOC_NONE) + +# Port of RelateEdge(node, pt, isA, isForward): an area edge. +function RelateEdge(node::NodeKey, pt, is_a::Bool, is_forward::Bool) + e = _relate_edge_unknown(node, pt) + set_locations_area!(e, is_a, is_forward) + return e +end + +# Port of RelateEdge(node, pt, isA): a line edge. +function RelateEdge(node::NodeKey, pt, is_a::Bool) + e = _relate_edge_unknown(node, pt) + set_locations_line!(e, is_a) + return e +end + +# Port of RelateEdge(node, pt, isA, locLeft, locRight, locLine): an area +# edge with explicit locations. +function RelateEdge(node::NodeKey, pt, is_a::Bool, + loc_left::Integer, loc_right::Integer, loc_line::Integer) + e = _relate_edge_unknown(node, pt) + set_locations!(e, is_a, loc_left, loc_right, loc_line) + return e +end + +# Port of RelateEdge.setLocations (private; forces dim 2, as in Java). +function set_locations!(e::RelateEdge, is_a::Bool, loc_left::Integer, + loc_right::Integer, loc_line::Integer) + if is_a + e.a_dim = 2 + e.a_loc_left = loc_left + e.a_loc_right = loc_right + e.a_loc_line = loc_line + else + e.b_dim = 2 + e.b_loc_left = loc_left + e.b_loc_right = loc_right + e.b_loc_line = loc_line + end + return nothing +end + +# Port of RelateEdge.setLocationsLine (private). +function set_locations_line!(e::RelateEdge, is_a::Bool) + if is_a + e.a_dim = 1 + e.a_loc_left = LOC_EXTERIOR + e.a_loc_right = LOC_EXTERIOR + e.a_loc_line = LOC_INTERIOR + else + e.b_dim = 1 + e.b_loc_left = LOC_EXTERIOR + e.b_loc_right = LOC_EXTERIOR + e.b_loc_line = LOC_INTERIOR + end + return nothing +end + +# Port of RelateEdge.setLocationsArea (private): a forward edge (the exiting +# edge of a CW-oriented corner) has the area interior on the right; a +# reverse (entering) edge has it on the left. +function set_locations_area!(e::RelateEdge, is_a::Bool, is_forward::Bool) + loc_left = is_forward ? LOC_EXTERIOR : LOC_INTERIOR + loc_right = is_forward ? LOC_INTERIOR : LOC_EXTERIOR + if is_a + e.a_dim = 2 + e.a_loc_left = loc_left + e.a_loc_right = loc_right + e.a_loc_line = LOC_BOUNDARY + else + e.b_dim = 2 + e.b_loc_left = loc_left + e.b_loc_right = loc_right + e.b_loc_line = LOC_BOUNDARY + end + return nothing +end + +#= +Port of RelateEdge.compareToEdge(edgeDirPt): CCW angle comparison of this +edge's direction against `edge_dir_pt` around the node. The Java calls +`PolygonNodeTopology.compareAngle(node.getCoordinate(), dirPt, edgeDirPt)`; +here the apex is the symbolic node key and the comparison goes through the +kernel (`rk_compare_edge_dir`), which is why the manifold and `exact` flag +are threaded in (stored on the `RelateNode` by the callers). +=# +compare_to_edge(m::Manifold, e::RelateEdge, edge_dir_pt; exact) = + rk_compare_edge_dir(m, e.node, e.dir_pt, edge_dir_pt; exact) + +#= +Port of RelateEdge.merge(isA, dirPt, dim, isForward): merge the labeling of +a coincident (collinear, same-direction) edge of geometry `is_a` into this +edge. If the geometry is so far unknown on this edge, its dimension and +locations are simply installed; otherwise the dimension/on-location merge +(`merge_dim_edge_loc!`: area overrides line) and the side merges +(`merge_side_location!`: INTERIOR takes precedence) apply. +`dir_pt` is unused, as in Java (kept for signature parity). +=# +function merge_edge!(e::RelateEdge, is_a::Bool, dir_pt, dim::Integer, is_forward::Bool) + loc_edge = LOC_INTERIOR + loc_left = LOC_EXTERIOR + loc_right = LOC_EXTERIOR + if dim == DIM_A + loc_edge = LOC_BOUNDARY + loc_left = is_forward ? LOC_EXTERIOR : LOC_INTERIOR + loc_right = is_forward ? LOC_INTERIOR : LOC_EXTERIOR + end + + if !is_known(e, is_a) + set_dimension!(e, is_a, dim) + set_on!(e, is_a, loc_edge) + set_left!(e, is_a, loc_left) + set_right!(e, is_a, loc_right) + return nothing + end + + # Assert: node-dirpt is collinear with node-pt + merge_dim_edge_loc!(e, is_a, loc_edge) + merge_side_location!(e, is_a, POS_LEFT, loc_left) + merge_side_location!(e, is_a, POS_RIGHT, loc_right) + return nothing +end + +#= +Port of RelateEdge.mergeDimEdgeLoc (private). Area edges override Line +edges. Merging edges of same dimension is a no-op for the dimension and on +location. But merging an area edge into a line edge sets the dimension to A +and the location to BOUNDARY. +=# +function merge_dim_edge_loc!(e::RelateEdge, is_a::Bool, loc_edge::Integer) + #TODO: this logic needs work - ie handling A edges marked as Interior + dim = loc_edge == LOC_BOUNDARY ? DIM_A : DIM_L + if dim == DIM_A && edge_dim(e, is_a) == DIM_L + set_dimension!(e, is_a, dim) + set_on!(e, is_a, LOC_BOUNDARY) + end + return nothing +end + +# Port of RelateEdge.mergeSideLocation (private): INTERIOR takes precedence +# over EXTERIOR. +function merge_side_location!(e::RelateEdge, is_a::Bool, pos::Integer, loc::Integer) + curr_loc = edge_location(e, is_a, pos) + if curr_loc != LOC_INTERIOR + set_location!(e, is_a, pos, loc) + end + return nothing +end + +# Port of RelateEdge.setDimension (private). +function set_dimension!(e::RelateEdge, is_a::Bool, dim::Integer) + if is_a + e.a_dim = dim + else + e.b_dim = dim + end + return nothing +end + +# Port of RelateEdge.setLocation. +function set_location!(e::RelateEdge, is_a::Bool, pos::Integer, loc::Integer) + if pos == POS_LEFT + set_left!(e, is_a, loc) + elseif pos == POS_RIGHT + set_right!(e, is_a, loc) + elseif pos == POS_ON + set_on!(e, is_a, loc) + end + return nothing +end + +# Port of RelateEdge.setAllLocations. +function set_all_locations!(e::RelateEdge, is_a::Bool, loc::Integer) + set_left!(e, is_a, loc) + set_right!(e, is_a, loc) + set_on!(e, is_a, loc) + return nothing +end + +# Port of RelateEdge.setUnknownLocations: fill only the still-unknown +# positions with `loc`. +function set_unknown_locations!(e::RelateEdge, is_a::Bool, loc::Integer) + if !is_known(e, is_a, POS_LEFT) + set_location!(e, is_a, POS_LEFT, loc) + end + if !is_known(e, is_a, POS_RIGHT) + set_location!(e, is_a, POS_RIGHT, loc) + end + if !is_known(e, is_a, POS_ON) + set_location!(e, is_a, POS_ON, loc) + end + return nothing +end + +# Port of RelateEdge.setLeft (private). +function set_left!(e::RelateEdge, is_a::Bool, loc::Integer) + if is_a + e.a_loc_left = loc + else + e.b_loc_left = loc + end + return nothing +end + +# Port of RelateEdge.setRight (private). +function set_right!(e::RelateEdge, is_a::Bool, loc::Integer) + if is_a + e.a_loc_right = loc + else + e.b_loc_right = loc + end + return nothing +end + +# Port of RelateEdge.setOn (private). +function set_on!(e::RelateEdge, is_a::Bool, loc::Integer) + if is_a + e.a_loc_line = loc + else + e.b_loc_line = loc + end + return nothing +end + +# Port of RelateEdge.location(isA, position). (Java asserts unreachable for +# a bad position; here an ArgumentError.) +function edge_location(e::RelateEdge, is_a::Bool, position::Integer) + if is_a + position == POS_LEFT && return e.a_loc_left + position == POS_RIGHT && return e.a_loc_right + position == POS_ON && return e.a_loc_line + else + position == POS_LEFT && return e.b_loc_left + position == POS_RIGHT && return e.b_loc_right + position == POS_ON && return e.b_loc_line + end + throw(ArgumentError("invalid position: $position")) +end + +# Port of RelateEdge.dimension (private). +edge_dim(e::RelateEdge, is_a::Bool) = is_a ? e.a_dim : e.b_dim + +# Port of RelateEdge.isKnown(isA) (private): whether the geometry's +# dimension on this edge is known. +is_known(e::RelateEdge, is_a::Bool) = + is_a ? e.a_dim != DIM_UNKNOWN_EDGE : e.b_dim != DIM_UNKNOWN_EDGE + +# Port of RelateEdge.isKnown(isA, pos) (private): whether the location at +# `pos` is known. +is_known(e::RelateEdge, is_a::Bool, pos::Integer) = edge_location(e, is_a, pos) != LOC_NONE + +# Port of RelateEdge.isInterior. +is_interior(e::RelateEdge, is_a::Bool, position::Integer) = + edge_location(e, is_a, position) == LOC_INTERIOR + +# Port of RelateEdge.setDimLocations. +function set_dim_locations!(e::RelateEdge, is_a::Bool, dim::Integer, loc::Integer) + if is_a + e.a_dim = dim + e.a_loc_left = loc + e.a_loc_right = loc + e.a_loc_line = loc + else + e.b_dim = dim + e.b_loc_left = loc + e.b_loc_right = loc + e.b_loc_line = loc + end + return nothing +end + +# Port of RelateEdge.setAreaInterior(isA): all locations become INTERIOR +# (the dimension is untouched — a line edge inside an area keeps dim L). +function set_area_interior!(e::RelateEdge, is_a::Bool) + if is_a + e.a_loc_left = LOC_INTERIOR + e.a_loc_right = LOC_INTERIOR + e.a_loc_line = LOC_INTERIOR + else + e.b_loc_left = LOC_INTERIOR + e.b_loc_right = LOC_INTERIOR + e.b_loc_line = LOC_INTERIOR + end + return nothing +end + +# Port of RelateEdge.toString (+ labelString/locationString), as a debugging +# aid. The node is symbolic, so crossing nodes print their NodeKey segment +# pair instead of a coordinate (`_point_string`, node_sections.jl). +function Base.show(io::IO, e::RelateEdge) + print(io, _point_string(e.node), " - ", _point_string(e.dir_pt), " - ", _label_string(e)) +end + +_label_string(e::RelateEdge) = + string("A:", _location_string(e, true), "/B:", _location_string(e, false)) + +_location_string(e::RelateEdge, is_a::Bool) = string( + _loc_symbol(edge_location(e, is_a, POS_LEFT)), + _loc_symbol(edge_location(e, is_a, POS_ON)), + _loc_symbol(edge_location(e, is_a, POS_RIGHT))) + +# Port of Location.toLocationSymbol. +function _loc_symbol(loc::Integer) + loc == LOC_EXTERIOR && return 'e' + loc == LOC_BOUNDARY && return 'b' + loc == LOC_INTERIOR && return 'i' + loc == LOC_NONE && return '-' + throw(ArgumentError("Unknown location value: $loc")) +end + +#========================================================================== +## RelateNode (port of JTS RelateNode.java) +==========================================================================# + +""" + RelateNode(m::Manifold, node::NodeKey; exact) + +The topology at a node between the edges of two input geometries: a list of +the [`RelateEdge`](@ref)s around the node in CCW order, ordered by their CCW +angle with the positive X-axis. + +Port of JTS `RelateNode`; the Java class is keyed by the node `Coordinate`, +here by the symbolic [`NodeKey`](@ref) (design D2). The manifold and the +`exact` flag (absent in Java) are stored for the edge-angle comparisons in +[`add_edge!`](@ref add_edge!(::RelateNode, ::Bool, ::Any, ::Integer, ::Bool)). +""" +struct RelateNode{M <: Manifold, E, P} + m::M + exact::E + node::NodeKey{P} + edges::Vector{RelateEdge{P}} +end + +RelateNode(m::Manifold, node::NodeKey{P}; exact) where {P} = + RelateNode(m, exact, node, RelateEdge{P}[]) + +# Port of RelateNode.getCoordinate. The Java method returns the node +# Coordinate; here the node is its symbolic NodeKey (design D2). +get_coordinate(n::RelateNode) = n.node + +# Port of RelateNode.getEdges. +get_edges(n::RelateNode) = n.edges + +# Port of RelateNode.addEdges(List). +function add_edges!(n::RelateNode, nss::AbstractVector{<:NodeSection}) + for ns in nss + add_edges!(n, ns) + end + return nothing +end + +# Port of RelateNode.addEdges(NodeSection). +function add_edges!(n::RelateNode, ns::NodeSection) + dim = section_dim(ns) + isa_g = is_a(ns) + if dim == DIM_L + add_line_edge!(n, isa_g, get_vertex(ns, 0)) + add_line_edge!(n, isa_g, get_vertex(ns, 1)) + elseif dim == DIM_A + #-- assumes node edges have CW orientation (as per JTS norm) + #-- entering edge - interior on L + e0 = add_area_edge!(n, isa_g, get_vertex(ns, 0), false) + #-- exiting edge - interior on R + e1 = add_area_edge!(n, isa_g, get_vertex(ns, 1), true) + # Zero-length edges are skipped (Java addEdge returns null; they + # only arise from invalid rings with repeated points). + (e0 === nothing || e1 === nothing) && return nothing + + index0 = findfirst(e -> e === e0, n.edges) + index1 = findfirst(e -> e === e1, n.edges) + update_edges_in_area!(n, isa_g, index0, index1) + update_if_area_prev!(n, isa_g, index0) + update_if_area_next!(n, isa_g, index1) + end + return nothing +end + +# Port of RelateNode.updateEdgesInArea (private): mark every edge strictly +# between the entering and exiting edge (in CCW order) as area interior. +function update_edges_in_area!(n::RelateNode, is_a::Bool, index_from::Integer, index_to::Integer) + index = next_index(n.edges, index_from) + while index != index_to + edge = n.edges[index] + set_area_interior!(edge, is_a) + index = next_index(n.edges, index) + end + return nothing +end + +# Port of RelateNode.updateIfAreaPrev (private): if the CW-previous edge has +# the area interior on its left, this edge lies inside the area. +function update_if_area_prev!(n::RelateNode, is_a::Bool, index::Integer) + index_prev = prev_index(n.edges, index) + edge_prev = n.edges[index_prev] + if is_interior(edge_prev, is_a, POS_LEFT) + edge = n.edges[index] + set_area_interior!(edge, is_a) + end + return nothing +end + +# Port of RelateNode.updateIfAreaNext (private): if the CCW-next edge has +# the area interior on its right, this edge lies inside the area. +function update_if_area_next!(n::RelateNode, is_a::Bool, index::Integer) + index_next = next_index(n.edges, index) + edge_next = n.edges[index_next] + if is_interior(edge_next, is_a, POS_RIGHT) + edge = n.edges[index] + set_area_interior!(edge, is_a) + end + return nothing +end + +# Port of RelateNode.addLineEdge (private). +add_line_edge!(n::RelateNode, is_a::Bool, dir_pt) = + add_edge!(n, is_a, dir_pt, DIM_L, false) + +# Port of RelateNode.addAreaEdge (private). +add_area_edge!(n::RelateNode, is_a::Bool, dir_pt, is_forward::Bool) = + add_edge!(n, is_a, dir_pt, DIM_A, is_forward) + +""" + add_edge!(n::RelateNode, is_a, dir_pt, dim, is_forward) + +Adds or merges an edge to the node, keeping the wheel sorted by CCW angle +with the positive X-axis. `dim` is the dimension of the geometry element +containing the edge, `is_forward` the direction of the edge. Returns the +created or merged edge for this point, or `nothing` for a malformed +(`nothing` or zero-length) input edge. + +Port of RelateNode.addEdge. +""" +function add_edge!(n::RelateNode, is_a::Bool, dir_pt, dim::Integer, is_forward::Bool) + #-- check for well-formed edge - skip null or zero-len input + dir_pt === nothing && return nothing + # Java: nodePt.equals2D(dirPt) — but node and direction points are + # kernel points, so equality compares all coordinates (spherical kernel + # points can differ in z alone). A proper-crossing node lies strictly in + # the interior of its defining segments, so a (vertex) direction point + # can never coincide with it; only vertex nodes need the check. + (!n.node.is_crossing && n.node.pt == dir_pt) && return nothing + + insert_index = 0 + for (i, e) in pairs(n.edges) + comp = compare_to_edge(n.m, e, dir_pt; exact = n.exact) + if comp == 0 + merge_edge!(e, is_a, dir_pt, dim, is_forward) + return e + end + if comp > 0 + #-- found further edge, so insert a new edge at this position + insert_index = i + break + end + end + #-- add a new edge + e = relate_edge(n.node, dir_pt, is_a, dim, is_forward) + if insert_index == 0 + #-- add edge at end of list + push!(n.edges, e) + else + #-- add edge before higher edge found + insert!(n.edges, insert_index, e) + end + return e +end + +""" + finish!(n::RelateNode, is_area_interior_a::Bool, is_area_interior_b::Bool) + +Computes the final topology for the edges around this node. Although nodes +lie on the boundary of areas or the interior of lines, in a mixed GC they +may also lie in the interior of an area. This changes the locations of the +sides and line to Interior. + +Port of RelateNode.finish. +""" +function finish!(n::RelateNode, is_area_interior_a::Bool, is_area_interior_b::Bool) + finish_node!(n, true, is_area_interior_a) + finish_node!(n, false, is_area_interior_b) + return nothing +end + +# Port of RelateNode.finishNode (private). +function finish_node!(n::RelateNode, is_a::Bool, is_area_interior::Bool) + if is_area_interior + set_all_area_interior!(n.edges, is_a) + else + start_index = find_known_edge_index(n.edges, is_a) + #-- only interacting nodes are finished, so this should never happen + #Assert: start_index > 0, "Node does not have AB interaction" + propagate_side_locations!(n, is_a, start_index) + end + return nothing +end + +# Port of RelateNode.propagateSideLocations (private): walk the wheel CCW +# from the first known edge, filling unknown locations with the latest known +# LEFT location (the location of the angular sector CCW of each edge). +function propagate_side_locations!(n::RelateNode, is_a::Bool, start_index::Integer) + curr_loc = edge_location(n.edges[start_index], is_a, POS_LEFT) + #-- edges are stored in CCW order + index = next_index(n.edges, start_index) + while index != start_index + e = n.edges[index] + set_unknown_locations!(e, is_a, curr_loc) + curr_loc = edge_location(e, is_a, POS_LEFT) + index = next_index(n.edges, index) + end + return nothing +end + +# Ports of the static RelateNode.prevIndex / nextIndex (1-based, circular). +prev_index(edges::AbstractVector, index::Integer) = + index > 1 ? index - 1 : length(edges) + +next_index(edges::AbstractVector, i::Integer) = + i >= length(edges) ? 1 : i + 1 + +# Port of RelateNode.toString, as a debugging aid. +function Base.show(io::IO, n::RelateNode) + print(io, "Node[", _point_string(n.node), "]:") + for e in n.edges + print(io, "\n", e) + end +end + +# Port of RelateNode.hasExteriorEdge(isA): whether any edge has the geometry +# in its exterior on either side. +function has_exterior_edge(n::RelateNode, is_a::Bool) + for e in n.edges + if LOC_EXTERIOR == edge_location(e, is_a, POS_LEFT) || + LOC_EXTERIOR == edge_location(e, is_a, POS_RIGHT) + return true + end + end + return false +end diff --git a/src/methods/geom_relations/relateng/relate_predicates.jl b/src/methods/geom_relations/relateng/relate_predicates.jl new file mode 100644 index 0000000000..6f77268e23 --- /dev/null +++ b/src/methods/geom_relations/relateng/relate_predicates.jl @@ -0,0 +1,319 @@ +# # Named DE-9IM relate predicates +#= +Port of JTS `RelatePredicate.java` (the `IMPredicate` kinds — the +`intersects`/`disjoint` `BasicPredicate` kinds live in +`topology_predicate.jl`), `IMPatternMatcher.java`, +`IntersectionMatrixPattern.java` and `RelateMatrixPredicate.java`. + +Each JTS anonymous inner class becomes a kind singleton parameterizing +`IMPredicate{K}`, with its `requireX` overrides, `init` overrides, +`isDetermined` and `valueIM` ported method-for-method, in the same +order as `RelatePredicate.java`. The `value_im` matrix queries +(`is_contains` etc.) are the ports of `geom/IntersectionMatrix.java` +named-relationship methods in `de9im.jl`. +=# + +# Which input geometry a source flag refers to (JTS `RelateGeometry.GEOM_A/GEOM_B`). +const GEOM_A = true +const GEOM_B = false + +# Envelope helpers for `init_bounds!` (JTS `Envelope.isNull` / `Envelope.equals`). +# A null (empty-geometry) extent is represented as `nothing` or as an +# extent with an inverted X interval, matching the JTS null-envelope convention. +ext_isnull(::Nothing) = true +ext_isnull(ext) = ext.X[2] < ext.X[1] +function ext_equals(extA, extB) + (ext_isnull(extA) || ext_isnull(extB)) && return ext_isnull(extA) && ext_isnull(extB) + return extA.X == extB.X && extA.Y == extB.Y +end + +#= +## `contains` (RelatePredicate.java `contains()`) +=# +struct ContainsPred end +pred_contains() = IMPredicate(ContainsPred()) +predicate_name(::IMPredicate{ContainsPred}) = "contains" +require_covers(::Type{IMPredicate{ContainsPred}}, is_source_a::Bool) = is_source_a == GEOM_A +# only need to check B against Exterior of A +require_exterior_check(::Type{IMPredicate{ContainsPred}}, is_source_a::Bool) = is_source_a == GEOM_B +init_dims_kind!(p::IMPredicate{ContainsPred}) = + require!(p, is_dims_compatible_with_covers(p.dimA, p.dimB)) +init_bounds!(p::IMPredicate{ContainsPred}, extA, extB) = require_covers!(p, extA, extB) +is_determined(p::IMPredicate{ContainsPred}) = intersects_exterior_of(p, GEOM_A) +value_im(p::IMPredicate{ContainsPred}) = is_contains(p.im) + +#= +## `within` (RelatePredicate.java `within()`) +=# +struct WithinPred end +pred_within() = IMPredicate(WithinPred()) +predicate_name(::IMPredicate{WithinPred}) = "within" +require_covers(::Type{IMPredicate{WithinPred}}, is_source_a::Bool) = is_source_a == GEOM_B +# only need to check A against Exterior of B +require_exterior_check(::Type{IMPredicate{WithinPred}}, is_source_a::Bool) = is_source_a == GEOM_A +init_dims_kind!(p::IMPredicate{WithinPred}) = + require!(p, is_dims_compatible_with_covers(p.dimB, p.dimA)) +init_bounds!(p::IMPredicate{WithinPred}, extA, extB) = require_covers!(p, extB, extA) +is_determined(p::IMPredicate{WithinPred}) = intersects_exterior_of(p, GEOM_B) +value_im(p::IMPredicate{WithinPred}) = is_within(p.im) + +#= +## `covers` (RelatePredicate.java `covers()`) +=# +struct CoversPred end +pred_covers() = IMPredicate(CoversPred()) +predicate_name(::IMPredicate{CoversPred}) = "covers" +require_covers(::Type{IMPredicate{CoversPred}}, is_source_a::Bool) = is_source_a == GEOM_A +# only need to check B against Exterior of A +require_exterior_check(::Type{IMPredicate{CoversPred}}, is_source_a::Bool) = is_source_a == GEOM_B +init_dims_kind!(p::IMPredicate{CoversPred}) = + require!(p, is_dims_compatible_with_covers(p.dimA, p.dimB)) +init_bounds!(p::IMPredicate{CoversPred}, extA, extB) = require_covers!(p, extA, extB) +is_determined(p::IMPredicate{CoversPred}) = intersects_exterior_of(p, GEOM_A) +value_im(p::IMPredicate{CoversPred}) = is_covers(p.im) + +#= +## `coveredBy` (RelatePredicate.java `coveredBy()`) +=# +struct CoveredByPred end +pred_coveredby() = IMPredicate(CoveredByPred()) +predicate_name(::IMPredicate{CoveredByPred}) = "coveredBy" +require_covers(::Type{IMPredicate{CoveredByPred}}, is_source_a::Bool) = is_source_a == GEOM_B +# only need to check A against Exterior of B +require_exterior_check(::Type{IMPredicate{CoveredByPred}}, is_source_a::Bool) = is_source_a == GEOM_A +init_dims_kind!(p::IMPredicate{CoveredByPred}) = + require!(p, is_dims_compatible_with_covers(p.dimB, p.dimA)) +init_bounds!(p::IMPredicate{CoveredByPred}, extA, extB) = require_covers!(p, extB, extA) +is_determined(p::IMPredicate{CoveredByPred}) = intersects_exterior_of(p, GEOM_B) +value_im(p::IMPredicate{CoveredByPred}) = is_coveredby(p.im) + +#= +## `crosses` (RelatePredicate.java `crosses()`) +=# +struct CrossesPred end +pred_crosses() = IMPredicate(CrossesPred()) +predicate_name(::IMPredicate{CrossesPred}) = "crosses" +function init_dims_kind!(p::IMPredicate{CrossesPred}) + is_both_points_or_areas = (p.dimA == DIM_P && p.dimB == DIM_P) || + (p.dimA == DIM_A && p.dimB == DIM_A) + require!(p, !is_both_points_or_areas) +end +function is_determined(p::IMPredicate{CrossesPred}) + if p.dimA == DIM_L && p.dimB == DIM_L + # L/L interaction can only be dim = P + get_dimension(p, LOC_INTERIOR, LOC_INTERIOR) > DIM_P && return true + elseif p.dimA < p.dimB + if is_intersects_entry(p, LOC_INTERIOR, LOC_INTERIOR) && + is_intersects_entry(p, LOC_INTERIOR, LOC_EXTERIOR) + return true + end + elseif p.dimA > p.dimB + if is_intersects_entry(p, LOC_INTERIOR, LOC_INTERIOR) && + is_intersects_entry(p, LOC_EXTERIOR, LOC_INTERIOR) + return true + end + end + return false +end +value_im(p::IMPredicate{CrossesPred}) = is_crosses(p.im, p.dimA, p.dimB) + +#= +## `equalsTopo` (RelatePredicate.java `equalsTopo()`) +=# +struct EqualsTopoPred end +pred_equalstopo() = IMPredicate(EqualsTopoPred()) +predicate_name(::IMPredicate{EqualsTopoPred}) = "equals" +# don't require equal dims, because EMPTY = EMPTY for all dims +init_dims_kind!(p::IMPredicate{EqualsTopoPred}) = nothing +# allow EMPTY = EMPTY +require_interaction(::Type{IMPredicate{EqualsTopoPred}}) = false +function init_bounds!(p::IMPredicate{EqualsTopoPred}, extA, extB) + # handle EMPTY = EMPTY cases + set_value_if!(p, true, ext_isnull(extA) && ext_isnull(extB)) + require!(p, ext_equals(extA, extB)) + return nothing +end +function is_determined(p::IMPredicate{EqualsTopoPred}) + is_either_exterior_intersects = + is_intersects_entry(p, LOC_INTERIOR, LOC_EXTERIOR) || + is_intersects_entry(p, LOC_BOUNDARY, LOC_EXTERIOR) || + is_intersects_entry(p, LOC_EXTERIOR, LOC_INTERIOR) || + is_intersects_entry(p, LOC_EXTERIOR, LOC_BOUNDARY) + return is_either_exterior_intersects +end +value_im(p::IMPredicate{EqualsTopoPred}) = is_equals(p.im, p.dimA, p.dimB) + +#= +## `overlaps` (RelatePredicate.java `overlaps()`) +=# +struct OverlapsPred end +pred_overlaps() = IMPredicate(OverlapsPred()) +predicate_name(::IMPredicate{OverlapsPred}) = "overlaps" +init_dims_kind!(p::IMPredicate{OverlapsPred}) = require!(p, p.dimA == p.dimB) +function is_determined(p::IMPredicate{OverlapsPred}) + if p.dimA == DIM_A || p.dimA == DIM_P + if is_intersects_entry(p, LOC_INTERIOR, LOC_INTERIOR) && + is_intersects_entry(p, LOC_INTERIOR, LOC_EXTERIOR) && + is_intersects_entry(p, LOC_EXTERIOR, LOC_INTERIOR) + return true + end + end + if p.dimA == DIM_L + if is_dimension_entry(p, LOC_INTERIOR, LOC_INTERIOR, DIM_L) && + is_intersects_entry(p, LOC_INTERIOR, LOC_EXTERIOR) && + is_intersects_entry(p, LOC_EXTERIOR, LOC_INTERIOR) + return true + end + end + return false +end +value_im(p::IMPredicate{OverlapsPred}) = is_overlaps(p.im, p.dimA, p.dimB) + +#= +## `touches` (RelatePredicate.java `touches()`) +=# +struct TouchesPred end +pred_touches() = IMPredicate(TouchesPred()) +predicate_name(::IMPredicate{TouchesPred}) = "touches" +function init_dims_kind!(p::IMPredicate{TouchesPred}) + # Points have only interiors, so cannot touch + is_both_points = p.dimA == DIM_P && p.dimB == DIM_P + require!(p, !is_both_points) +end +function is_determined(p::IMPredicate{TouchesPred}) + # for touches interiors cannot intersect + is_interiors_intersects = is_intersects_entry(p, LOC_INTERIOR, LOC_INTERIOR) + return is_interiors_intersects +end +value_im(p::IMPredicate{TouchesPred}) = is_touches(p.im, p.dimA, p.dimB) + +#= +## `IMPatternMatcher` (IMPatternMatcher.java) + +A predicate that matches a DE-9IM pattern. Unlike the named kinds above +this is a standalone mutable struct (not an `IMPredicate` kind), because +its `require_interaction` flag depends on runtime data (the pattern +matrix), via the instance-level requirement-flag methods; the small +`IMPredicate` state-machine methods are mirrored below (Java gets them +by inheritance). +=# +mutable struct IMPatternMatcher <: TopologyPredicate + const im_pattern::String + const pattern_matrix::DE9IM + dimA::Int8 + dimB::Int8 + im::DE9IM + value::Int8 +end +IMPatternMatcher(im_pattern::AbstractString) = + IMPatternMatcher(String(im_pattern), DE9IM(im_pattern), DIM_UNKNOWN, DIM_UNKNOWN, + # E/E is always dim = 2 (IMPredicate constructor) + with_entry(DE9IM(), LOC_EXTERIOR, LOC_EXTERIOR, DIM_A), TRI_UNKNOWN) + +predicate_name(::IMPatternMatcher) = "IMPattern" + +# RelatePredicate.java `matches(String)` factory. +pred_matches(im_pattern::AbstractString) = IMPatternMatcher(im_pattern) + +function init_dims!(p::IMPatternMatcher, dimA::Integer, dimB::Integer) + p.dimA = dimA + p.dimB = dimB + return nothing +end + +# if pattern specifies any non-E/non-E interaction, envelopes must not be disjoint +# (the Java method also starts with `super.init(dimA, dimB)`, which only +# re-assigns the already-set dims — a no-op not reproduced here) +function init_bounds!(p::IMPatternMatcher, extA, extB) + requires_interaction = im_requires_interaction(p.pattern_matrix) + is_disjoint = !ext_intersects(extA, extB) + set_value_if!(p, false, requires_interaction && is_disjoint) + return nothing +end + +require_interaction(p::IMPatternMatcher) = im_requires_interaction(p.pattern_matrix) + +# IMPatternMatcher.java static `requireInteraction(IntersectionMatrix)` +function im_requires_interaction(im::DE9IM) + requires_interaction = + _is_interaction(im[LOC_INTERIOR, LOC_INTERIOR]) || + _is_interaction(im[LOC_INTERIOR, LOC_BOUNDARY]) || + _is_interaction(im[LOC_BOUNDARY, LOC_INTERIOR]) || + _is_interaction(im[LOC_BOUNDARY, LOC_BOUNDARY]) + return requires_interaction +end +_is_interaction(im_dim::Integer) = im_dim == DIM_TRUE || im_dim >= DIM_P + +# Mirrors of the inherited IMPredicate state-machine methods. +function update_dim!(p::IMPatternMatcher, locA, locB, dim) + # only record an increased dimension value + if dim > p.im[locA, locB] + p.im = with_entry(p.im, locA, locB, dim) + # set value if predicate value can be known + if is_determined(p) + set_value!(p, value_im(p)) + end + end + return nothing +end +get_dimension(p::IMPatternMatcher, locA, locB) = p.im[locA, locB] +finish!(p::IMPatternMatcher) = set_value!(p, value_im(p)) + +function is_determined(p::IMPatternMatcher) + #= + Matrix entries only increase in dimension as topology is computed. + The predicate can be short-circuited (as false) if + any computed entry is greater than the mask value. + =# + for i in 0:2, j in 0:2 + pattern_entry = p.pattern_matrix[i, j] + pattern_entry == DIM_DONTCARE && continue + matrix_val = get_dimension(p, i, j) + if pattern_entry == DIM_TRUE + # mask entry TRUE requires a known matrix entry + matrix_val < 0 && return false + elseif matrix_val > pattern_entry + # result is known (false) if matrix entry has exceeded mask + return true + end + end + return false +end + +value_im(p::IMPatternMatcher) = im_matches(p.im, p.im_pattern) + +Base.show(io::IO, p::IMPatternMatcher) = + print(io, predicate_name(p), "(", p.im_pattern, ")") + +#= +## DE-9IM matrix pattern constants (IntersectionMatrixPattern.java) +=# + +# Detects whether two polygonal geometries are adjacent along an edge, +# but do not overlap. +const IM_PATTERN_ADJACENT = "F***1****" +# Detects a geometry which properly contains another geometry (i.e. which +# lies entirely in the interior of the first geometry). +const IM_PATTERN_CONTAINS_PROPERLY = "T**FF*FF*" +# Detects if two geometries intersect in their interiors. +const IM_PATTERN_INTERIOR_INTERSECTS = "T********" + +#= +## `RelateMatrixPredicate` (RelateMatrixPredicate.java) + +Evaluates the full relate intersection matrix: it is never determined +early, so the entire matrix is computed. `result_im` returns the +accumulated matrix (which may be only partially complete before +`finish!` has been called). +=# +struct RelateMatrixPred end +RelateMatrixPredicate() = IMPredicate(RelateMatrixPred()) +predicate_name(::IMPredicate{RelateMatrixPred}) = "relateMatrix" +# ensure entire matrix is computed +require_interaction(::Type{IMPredicate{RelateMatrixPred}}) = false +# ensure entire matrix is computed +is_determined(::IMPredicate{RelateMatrixPred}) = false +# indicates full matrix is being evaluated +value_im(::IMPredicate{RelateMatrixPred}) = false +# Gets the current state of the IM matrix (JTS `getIM`). +result_im(p::IMPredicate{RelateMatrixPred}) = p.im diff --git a/src/methods/geom_relations/relateng/topology_computer.jl b/src/methods/geom_relations/relateng/topology_computer.jl new file mode 100644 index 0000000000..6a1b38d11a --- /dev/null +++ b/src/methods/geom_relations/relateng/topology_computer.jl @@ -0,0 +1,730 @@ +# # RelateNG topology computer +# +# Port of JTS `TopologyComputer.java` — the heart of the topology layer. +# Receives topological events from the evaluation phases (point locations, +# line ends, area vertices, edge intersections), translates each into +# DE-9IM dimension updates on the attached `TopologyPredicate`, and groups +# the `NodeSection`s of edge intersections per node for the final node +# topology analysis (`evaluate_nodes!`). +# +# Method order parallels the Java file, so this file diffs against its Java +# counterpart. Idiom changes: +# +# - The Java `Map nodeMap` becomes a +# `Dict{NodeKey, NodeSections}` keyed by the *symbolic* node identity +# (design D2): vertex nodes key by their exact coordinate, proper-crossing +# nodes by their canonicalized defining segment pair. No intersection +# coordinate is ever constructed for node identity. +# - Because identical segment pairs always produce identical keys, the +# "canonical example" of JTS's self-noding note (a self-crossing line +# tested against a copy of one of the crossed segments) merges +# automatically. Crossings of *different* segment pairs at the same +# geometric point — which JTS only merges when floating-point intersection +# coordinates happen to round identically — are merged *exactly* by the +# D3 coincidence pass in `evaluate_nodes!` (self-noding predicates only). +# - The manifold and `exact` flag the kernel calls need are taken from +# `geom_a` (the constructor asserts both inputs agree). + +""" + TopologyComputer(predicate, geom_a::RelateGeometry, geom_b::RelateGeometry) + +The DE-9IM accumulation engine of RelateNG: translates topological events +into dimension updates on `predicate` and collects edge-intersection +[`NodeSection`](@ref)s per node (keyed by symbolic [`NodeKey`](@ref), +design D2) for `evaluate_nodes!`. + +Port of JTS `TopologyComputer`. +""" +struct TopologyComputer{TP <: TopologyPredicate, M <: Manifold, E, P} + predicate::TP + #-- the kernel settings the hot per-segment paths read (copied out of + #-- geom_a so those reads stay statically typed) + m::M + exact::E + #-- deliberately opaque (abstract) references: the geometries are only + #-- consulted per relate call or per node (dimension queries, point + #-- location), never in the per-segment hot path — and keeping the input + #-- geometry types out of the `TopologyComputer` type keeps the whole + #-- edge/node machinery downstream (the edge intersector, its dual-tree + #-- traversal closures, every `update_*`/`evaluate_*` method here) + #-- compiled once per (manifold, exact, predicate) instead of once per + #-- input geometry-type pair + geom_a::RelateGeometry + geom_b::RelateGeometry + node_sections::Dict{NodeKey{P}, NodeSections{P}} +end + +function TopologyComputer(predicate::TopologyPredicate, + geom_a::RelateGeometry, geom_b::RelateGeometry) + #-- the kernel manifold/exact settings are read from geom_a; + #-- both inputs must have been built with the same settings + (geom_a.m == geom_b.m && geom_a.exact == geom_b.exact) || + throw(ArgumentError("RelateGeometry manifold/exact settings of the A and B inputs must agree")) + #-- P is the manifold's kernel point type, matching the coordinate type of + #-- every segment string / node point produced at ingest (Tuple{Float64, + #-- Float64} for Planar, UnitSphericalPoint{Float64} for Spherical) + P = _kernel_point_type(geom_a.m) + tc = TopologyComputer(predicate, geom_a.m, geom_a.exact, geom_a, geom_b, + Dict{NodeKey{P}, NodeSections{P}}()) + #-- the dimensions are read off the *arguments*, which are concretely + #-- typed here (the `tc` fields are opaque; construction runs once per + #-- evaluation, so its geometry queries should stay statically typed) + init_exterior_dims!(tc, get_dimension_real(geom_a), get_dimension_real(geom_b)) + return tc +end + +# The manifold / exactness flag for kernel calls (asserted equal across both +# inputs in the constructor). +_manifold(tc::TopologyComputer) = tc.m +_exact(tc::TopologyComputer) = tc.exact + +# Port of TopologyComputer.initExteriorDims (private): determine a priori +# partial EXTERIOR topology based on the real dimensions (computed by the +# constructor, where the geometries are still concretely typed). +function init_exterior_dims!(tc::TopologyComputer, dim_real_a::Integer, dim_real_b::Integer) + if dim_real_a == DIM_P && dim_real_b == DIM_L + #-- For P/L case, P exterior intersects L interior + update_dim!(tc, LOC_EXTERIOR, LOC_INTERIOR, DIM_L) + elseif dim_real_a == DIM_L && dim_real_b == DIM_P + update_dim!(tc, LOC_INTERIOR, LOC_EXTERIOR, DIM_L) + elseif dim_real_a == DIM_P && dim_real_b == DIM_A + #-- For P/A case, the Area Int and Bdy intersect the Point exterior. + update_dim!(tc, LOC_EXTERIOR, LOC_INTERIOR, DIM_A) + update_dim!(tc, LOC_EXTERIOR, LOC_BOUNDARY, DIM_L) + elseif dim_real_a == DIM_A && dim_real_b == DIM_P + update_dim!(tc, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + update_dim!(tc, LOC_BOUNDARY, LOC_EXTERIOR, DIM_L) + elseif dim_real_a == DIM_L && dim_real_b == DIM_A + update_dim!(tc, LOC_EXTERIOR, LOC_INTERIOR, DIM_A) + elseif dim_real_a == DIM_A && dim_real_b == DIM_L + update_dim!(tc, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + elseif dim_real_a == DIM_FALSE || dim_real_b == DIM_FALSE + #-- cases where one geom is EMPTY + if dim_real_a != DIM_FALSE + init_exterior_empty!(tc, GEOM_A) + end + if dim_real_b != DIM_FALSE + init_exterior_empty!(tc, GEOM_B) + end + end + return nothing +end + +# Port of TopologyComputer.initExteriorEmpty (private). +function init_exterior_empty!(tc::TopologyComputer, geom_non_empty::Bool) + dim_non_empty = get_dimension(tc, geom_non_empty) + if dim_non_empty == DIM_P + update_dim!(tc, geom_non_empty, LOC_INTERIOR, LOC_EXTERIOR, DIM_P) + elseif dim_non_empty == DIM_L + if has_boundary(get_geometry(tc, geom_non_empty)) + update_dim!(tc, geom_non_empty, LOC_BOUNDARY, LOC_EXTERIOR, DIM_P) + end + update_dim!(tc, geom_non_empty, LOC_INTERIOR, LOC_EXTERIOR, DIM_L) + elseif dim_non_empty == DIM_A + update_dim!(tc, geom_non_empty, LOC_BOUNDARY, LOC_EXTERIOR, DIM_L) + update_dim!(tc, geom_non_empty, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + end + return nothing +end + +# Port of TopologyComputer.getGeometry (private). +get_geometry(tc::TopologyComputer, is_a::Bool) = is_a ? tc.geom_a : tc.geom_b + +# Port of TopologyComputer.getDimension. +get_dimension(tc::TopologyComputer, is_a::Bool) = get_dimension(get_geometry(tc, is_a)) + +# Port of TopologyComputer.isAreaArea. +is_area_area(tc::TopologyComputer) = + get_dimension(tc, GEOM_A) == DIM_A && get_dimension(tc, GEOM_B) == DIM_A + +""" + is_self_noding_required(tc::TopologyComputer) + +Indicates whether the input geometries require self-noding for correct +evaluation of specific spatial predicates. Self-noding is required for +geometries which may have self-crossing linework, or may have lines lying +in the boundary of an area. This ensures that node locations match in +situations where a self-crossing and mutual crossing occur at the same +logical location (here via the D3 coincidence-merge pass, since node +identities are symbolic). + +Currently self-noding is required for: +- A geoms which require self-noding (lines or GCs, except for single-polygon GCs) +- B geoms which are mixed A/L GCs + +Port of TopologyComputer.isSelfNodingRequired. +""" +function is_self_noding_required(tc::TopologyComputer) + require_self_noding(tc.predicate) || return false + + is_self_noding_required(tc.geom_a) && return true + + #-- if B is a mixed GC with A and L require full noding + has_area_and_line(tc.geom_b) && return true + + return false +end + +# Port of TopologyComputer.isExteriorCheckRequired. +is_exterior_check_required(tc::TopologyComputer, is_a::Bool) = + require_exterior_check(tc.predicate, is_a) + +# Port of TopologyComputer.updateDim(locA, locB, dimension) (private). +function update_dim!(tc::TopologyComputer, loc_a::Integer, loc_b::Integer, dim::Integer) + update_dim!(tc.predicate, loc_a, loc_b, dim) + return nothing +end + +# Port of TopologyComputer.updateDim(isAB, loc1, loc2, dimension) (private): +# `loc1`/`loc2` are ordered source/target; swapped when the source is B. +function update_dim!(tc::TopologyComputer, is_ab::Bool, loc1::Integer, loc2::Integer, dim::Integer) + if is_ab + update_dim!(tc, loc1, loc2, dim) + else + #-- is ordered BA + update_dim!(tc, loc2, loc1, dim) + end + return nothing +end + +# Port of TopologyComputer.isResultKnown. +is_result_known(tc::TopologyComputer) = is_known(tc.predicate) + +# Port of TopologyComputer.getResult. +get_result(tc::TopologyComputer) = predicate_value(tc.predicate) + +# Port of TopologyComputer.finish: finalize the evaluation. +function finish!(tc::TopologyComputer) + finish!(tc.predicate) + return nothing +end + +# Port of TopologyComputer.getNodeSections (private); the map is keyed by +# the symbolic NodeKey instead of a Coordinate (design D2). +_get_node_sections(tc::TopologyComputer, node::NodeKey) = + get!(() -> NodeSections(node), tc.node_sections, node) + +# Port of TopologyComputer.addIntersection. +function add_intersection!(tc::TopologyComputer, a::NodeSection, b::NodeSection) + if !is_same_geometry(a, b) + update_intersection_ab!(tc, a, b) + end + #-- add edges to node to allow full topology evaluation later + add_node_sections!(tc, a, b) + return nothing +end + +# Port of TopologyComputer.updateIntersectionAB (private): update topology +# for an intersection between A and B. +function update_intersection_ab!(tc::TopologyComputer, a::NodeSection, b::NodeSection) + if is_area_area(a, b) + update_area_area_cross!(tc, a, b) + end + update_node_location!(tc, a, b) + return nothing +end + +#= +Port of TopologyComputer.updateAreaAreaCross (private): updates topology for +an AB Area-Area crossing node. Sections cross at a node if (a) the +intersection is proper (i.e. in the interior of two segments) or (b) if +non-proper then whether the linework crosses is determined by the geometry +of the segments on either side of the node. In these situations the area +geometry interiors intersect (in dimension 2). + +A proper intersection short-circuits `rk_is_crossing` (which requires a +vertex-node apex; proper crossings cross by construction). +=# +function update_area_area_cross!(tc::TopologyComputer, a::NodeSection, b::NodeSection) + if is_proper(a, b) || rk_is_crossing(_manifold(tc), node_pt(a), + get_vertex(a, 0), get_vertex(a, 1), + get_vertex(b, 0), get_vertex(b, 1); exact = _exact(tc)) + update_dim!(tc, LOC_INTERIOR, LOC_INTERIOR, DIM_A) + end + return nothing +end + +# Port of TopologyComputer.updateNodeLocation (private): updates topology +# for a node at an AB edge intersection. The Java passes the node +# Coordinate; here the symbolic NodeKey is located (see `locate_node` on +# NodeKey below). +function update_node_location!(tc::TopologyComputer, a::NodeSection, b::NodeSection) + pt = node_pt(a) + loc_a = locate_node(tc.geom_a, pt, get_polygonal(a)) + loc_b = locate_node(tc.geom_b, pt, get_polygonal(b)) + update_dim!(tc, loc_a, loc_b, DIM_P) + return nothing +end + +# Port of TopologyComputer.addNodeSections (private). +function add_node_sections!(tc::TopologyComputer, ns0::NodeSection, ns1::NodeSection) + sections = _get_node_sections(tc, node_pt(ns0)) + add_node_section!(sections, ns0) + add_node_section!(sections, ns1) + return nothing +end + +# Port of TopologyComputer.addPointOnPointInterior. +function add_point_on_point_interior!(tc::TopologyComputer, pt) + update_dim!(tc, LOC_INTERIOR, LOC_INTERIOR, DIM_P) + return nothing +end + +# Port of TopologyComputer.addPointOnPointExterior. +function add_point_on_point_exterior!(tc::TopologyComputer, is_geom_a::Bool, pt) + update_dim!(tc, is_geom_a, LOC_INTERIOR, LOC_EXTERIOR, DIM_P) + return nothing +end + +# Port of TopologyComputer.addPointOnGeometry. +function add_point_on_geometry!(tc::TopologyComputer, is_point_a::Bool, + loc_target::Integer, dim_target::Integer, pt) + #-- update entry for Point interior + update_dim!(tc, is_point_a, LOC_INTERIOR, loc_target, DIM_P) + + #-- an empty geometry has no points to infer entries from + is_geom_empty(get_geometry(tc, !is_point_a)) && return nothing + + if dim_target == DIM_P + return nothing + elseif dim_target == DIM_L + #= + Because zero-length lines are handled, a point lying in the exterior + of the line target may imply either P or L for the Exterior + interaction + =# + #TODO: determine if effective dimension of linear target is L? + return nothing + elseif dim_target == DIM_A + #= + If a point intersects an area target, then the area interior and + boundary must extend beyond the point and thus interact with its + exterior. + =# + update_dim!(tc, is_point_a, LOC_EXTERIOR, LOC_INTERIOR, DIM_A) + update_dim!(tc, is_point_a, LOC_EXTERIOR, LOC_BOUNDARY, DIM_L) + return nothing + end + throw(ArgumentError("unknown target dimension: $dim_target")) +end + +""" + add_line_end_on_geometry!(tc, is_line_a, loc_line_end, loc_target, dim_target, pt) + +Add topology for a line end. The line end point must be "significant"; i.e. +not contained in an area if the source is a mixed-dimension GC. +`loc_line_end` is the location of the line end (Interior or Boundary); +`loc_target` the location on the target geometry; `dim_target` the dimension +of the interacting target geometry element (if any), or the dimension of +the target. + +Port of TopologyComputer.addLineEndOnGeometry. +""" +function add_line_end_on_geometry!(tc::TopologyComputer, is_line_a::Bool, + loc_line_end::Integer, loc_target::Integer, dim_target::Integer, pt) + #-- record topology at line end point + update_dim!(tc, is_line_a, loc_line_end, loc_target, DIM_P) + + #-- an empty geometry has no points to infer entries from + is_geom_empty(get_geometry(tc, !is_line_a)) && return nothing + + #-- Line and Area targets may have additional topology + if dim_target == DIM_P + return nothing + elseif dim_target == DIM_L + add_line_end_on_line!(tc, is_line_a, loc_line_end, loc_target, pt) + return nothing + elseif dim_target == DIM_A + add_line_end_on_area!(tc, is_line_a, loc_line_end, loc_target, pt) + return nothing + end + throw(ArgumentError("unknown target dimension: $dim_target")) +end + +# Port of TopologyComputer.addLineEndOnLine (private). +function add_line_end_on_line!(tc::TopologyComputer, is_line_a::Bool, + loc_line_end::Integer, loc_line::Integer, pt) + #= + When a line end is in the EXTERIOR of a Line, some length of the source + Line INTERIOR is also in the target Line EXTERIOR. This works for + zero-length lines as well. + =# + if loc_line == LOC_EXTERIOR + update_dim!(tc, is_line_a, LOC_INTERIOR, LOC_EXTERIOR, DIM_L) + end + return nothing +end + +# Port of TopologyComputer.addLineEndOnArea (private). +function add_line_end_on_area!(tc::TopologyComputer, is_line_a::Bool, + loc_line_end::Integer, loc_area::Integer, pt) + if loc_area != LOC_BOUNDARY + #= + When a line end is in an Area INTERIOR or EXTERIOR some length of + the source Line Interior AND the Exterior of the line is also in + that location of the target. + NOTE: this assumes the line end is NOT also in an Area of a mixed-dim GC + =# + #TODO: handle zero-length lines? + update_dim!(tc, is_line_a, LOC_INTERIOR, loc_area, DIM_L) + update_dim!(tc, is_line_a, LOC_EXTERIOR, loc_area, DIM_A) + end + return nothing +end + +""" + add_area_vertex!(tc, is_area_a, loc_area, loc_target, dim_target, pt) + +Adds topology for an area vertex interaction with a target geometry element. +Assumes the target geometry element has highest dimension (i.e. if the point +lies on two elements of different dimension, the location on the higher +dimension element is provided. This is the semantic provided by +[`RelatePointLocator`](@ref).) + +Note that in a GeometryCollection containing overlapping or adjacent +polygons, the area vertex location may be INTERIOR instead of BOUNDARY. + +Port of TopologyComputer.addAreaVertex. +""" +function add_area_vertex!(tc::TopologyComputer, is_area_a::Bool, + loc_area::Integer, loc_target::Integer, dim_target::Integer, pt) + if loc_target == LOC_EXTERIOR + update_dim!(tc, is_area_a, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + #= + If area vertex is on Boundary further topology can be deduced from + the neighbourhood around the boundary vertex. This is always the + case for polygonal geometries. For GCs, the vertex may be either on + boundary or in interior (i.e. of overlapping or adjacent polygons) + =# + if loc_area == LOC_BOUNDARY + update_dim!(tc, is_area_a, LOC_BOUNDARY, LOC_EXTERIOR, DIM_L) + update_dim!(tc, is_area_a, LOC_EXTERIOR, LOC_EXTERIOR, DIM_A) + end + return nothing + end + if dim_target == DIM_P + add_area_vertex_on_point!(tc, is_area_a, loc_area, pt) + return nothing + elseif dim_target == DIM_L + add_area_vertex_on_line!(tc, is_area_a, loc_area, loc_target, pt) + return nothing + elseif dim_target == DIM_A + add_area_vertex_on_area!(tc, is_area_a, loc_area, loc_target, pt) + return nothing + end + throw(ArgumentError("unknown target dimension: $dim_target")) +end + +#= +Port of TopologyComputer.addAreaVertexOnPoint (private): updates topology +for an area vertex (in Interior or on Boundary) intersecting a point. Note +that because the largest dimension of intersecting target is determined, +the intersecting point is not part of any other target geometry, and hence +its neighbourhood is in the Exterior of the target. +=# +function add_area_vertex_on_point!(tc::TopologyComputer, is_area_a::Bool, + loc_area::Integer, pt) + #-- Assert: loc_area != EXTERIOR + #-- Assert: loc_target == INTERIOR + #-- The vertex location intersects the Point. + update_dim!(tc, is_area_a, loc_area, LOC_INTERIOR, DIM_P) + #-- The area interior intersects the point's exterior neighbourhood. + update_dim!(tc, is_area_a, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + #= + If the area vertex is on the boundary, the area boundary and exterior + intersect the point's exterior neighbourhood + =# + if loc_area == LOC_BOUNDARY + update_dim!(tc, is_area_a, LOC_BOUNDARY, LOC_EXTERIOR, DIM_L) + update_dim!(tc, is_area_a, LOC_EXTERIOR, LOC_EXTERIOR, DIM_A) + end + return nothing +end + +# Port of TopologyComputer.addAreaVertexOnLine (private). +function add_area_vertex_on_line!(tc::TopologyComputer, is_area_a::Bool, + loc_area::Integer, loc_target::Integer, pt) + #-- Assert: loc_area != EXTERIOR + #= + If an area vertex intersects a line, all we know is the intersection at + that point. e.g. the line may or may not be collinear with the area + boundary, and the line may or may not intersect the area interior. + Full topology is determined later by node analysis + =# + update_dim!(tc, is_area_a, loc_area, loc_target, DIM_P) + if loc_area == LOC_INTERIOR + #-- The area interior intersects the line's exterior neighbourhood. + update_dim!(tc, is_area_a, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + end + return nothing +end + +# Port of TopologyComputer.addAreaVertexOnArea (public in Java). +function add_area_vertex_on_area!(tc::TopologyComputer, is_area_a::Bool, + loc_area::Integer, loc_target::Integer, pt) + if loc_target == LOC_BOUNDARY + if loc_area == LOC_BOUNDARY + #-- B/B topology is fully computed later by node analysis + update_dim!(tc, is_area_a, LOC_BOUNDARY, LOC_BOUNDARY, DIM_P) + else + #-- loc_area == INTERIOR + update_dim!(tc, is_area_a, LOC_INTERIOR, LOC_INTERIOR, DIM_A) + update_dim!(tc, is_area_a, LOC_INTERIOR, LOC_BOUNDARY, DIM_L) + update_dim!(tc, is_area_a, LOC_INTERIOR, LOC_EXTERIOR, DIM_A) + end + else + #-- loc_target is INTERIOR or EXTERIOR + update_dim!(tc, is_area_a, LOC_INTERIOR, loc_target, DIM_A) + #= + If area vertex is on Boundary further topology can be deduced from + the neighbourhood around the boundary vertex. This is always the + case for polygonal geometries. For GCs, the vertex may be either on + boundary or in interior (i.e. of overlapping or adjacent polygons) + =# + if loc_area == LOC_BOUNDARY + update_dim!(tc, is_area_a, LOC_BOUNDARY, loc_target, DIM_L) + update_dim!(tc, is_area_a, LOC_EXTERIOR, loc_target, DIM_A) + end + end + return nothing +end + +# Port of TopologyComputer.evaluateNodes, preceded by the D3 +# coincidence-merge pass (not present in Java, where concrete node +# coordinates merge in the HashMap when they round identically). +function evaluate_nodes!(tc::TopologyComputer) + _merge_coincident_nodes!(tc) + for node_sections in values(tc.node_sections) + if has_interaction_ab(node_sections) + evaluate_node!(tc, node_sections) + is_result_known(tc) && return nothing + end + end + return nothing +end + +#= +D3 coincidence-merge pass: distinct symbolic crossing keys (different +segment pairs) — and vertex keys — may denote the same geometric point. +Group them exactly and merge their NodeSections into one node. + +In JTS this merging is implicit and unconditional: the nodeMap is keyed by +the *constructed* intersection Coordinate, so a proper crossing whose +(floating-point) intersection point coincides with a vertex node — or with +another crossing — lands in the same map entry, in every mode (not only +under self-noding; e.g. RelateNGTest.testPolygonLineCrossingContained needs +a B-line proper crossing of one A polygon merged with its vertex touch of +another). Here node identity is symbolic, so the merge is an explicit pass, +run whenever any crossing key exists. + +Candidate grouping is by *exact bounding boxes* (the F1 follow-up to +design D3): a vertex key's box is its exact coordinate; a crossing key's +box is the intersection of its two defining segments' bounding boxes, +which provably contains the exact crossing point (the point lies on both +segments). Boxes are computed with exact Float64 comparisons — no +rounding is involved — so coincident keys (equal exact points) always +have overlapping boxes and always land in the same overlap cluster. +Clusters are the sweep closure of box overlap along x, then y: a +conservative superset of the true coincidence classes. Within a +multi-member cluster containing at least one crossing key, coincidence is +confirmed *exactly* via the rational `_exact_node_point` (distinct exact +points whose boxes happen to overlap are never merged); vertex-only +clusters need no check at all, since distinct vertex keys key by their +exact coordinates and can never coincide. This keeps the merge decisions +exact (design D3) at sorting cost O(N log N), with the rational +arithmetic reserved for genuinely near-coincident nodes instead of every +crossing key on every evaluation. + +A vertex key is preferred as the canonical merged node: its coordinate is +exact, so the edge wheel and node location never need the rational apex. +Otherwise the merged crossing node's wheel compares foreign directions +around the exact rational apex (`rk_compare_edge_dir` slow path). +=# +function _merge_coincident_nodes!(tc::TopologyComputer) + nodemap = tc.node_sections + length(nodemap) > 1 || return nothing + any(k -> k.is_crossing, keys(nodemap)) || return nothing + K = keytype(nodemap) + #-- collect (x interval, y interval, key) and cluster by box overlap + items = Vector{Tuple{NTuple{2, Float64}, NTuple{2, Float64}, K}}() + sizehint!(items, length(nodemap)) + for k in keys(nodemap) + xint, yint = _node_key_box(k) + push!(items, (xint, yint, k)) + end + sort!(items; by = it -> it[1][1]) + i = 1 + n = length(items) + while i <= n + #-- extend the x-cluster while the next box starts inside it + j = i + xhi = items[i][1][2] + while j < n && items[j + 1][1][1] <= xhi + j += 1 + xhi = max(xhi, items[j][1][2]) + end + j > i && _merge_coincident_y_clusters!(nodemap, items[i:j]) + i = j + 1 + end + return nothing +end + +# The exact coordinate box guaranteed to contain a node key's point: the +# vertex coordinate itself, or the intersection of the defining segments' +# bounding boxes for a proper crossing. +function _node_key_box(k::NodeKey) + if !k.is_crossing + return (k.pt[1], k.pt[1]), (k.pt[2], k.pt[2]) + end + axlo, axhi = minmax(k.pt[1], k.a1[1]) + aylo, ayhi = minmax(k.pt[2], k.a1[2]) + bxlo, bxhi = minmax(k.b0[1], k.b1[1]) + bylo, byhi = minmax(k.b0[2], k.b1[2]) + return (max(axlo, bxlo), min(axhi, bxhi)), (max(aylo, bylo), min(ayhi, byhi)) +end + +# Second sweep dimension: within one x-overlap cluster, cluster by y +# overlap; multi-member y-clusters with a crossing key are the candidate +# coincidence groups handed to exact confirmation. +function _merge_coincident_y_clusters!(nodemap::Dict, items::Vector) + sort!(items; by = it -> it[2][1]) + i = 1 + n = length(items) + while i <= n + j = i + yhi = items[i][2][2] + while j < n && items[j + 1][2][1] <= yhi + j += 1 + yhi = max(yhi, items[j][2][2]) + end + if j > i + group = [items[t][3] for t in i:j] + any(k -> k.is_crossing, group) && + _merge_coincident_group!(nodemap, group) + end + i = j + 1 + end + return nothing +end + +# Merge each exact-coincidence class within one rounded-coordinate bucket. +function _merge_coincident_group!(nodemap::Dict, group::Vector) + #-- confirm coincidence exactly (rational arithmetic): bucket members + #-- are only candidates, since distinct exact points may round together + exacts = [_exact_node_point(k) for k in group] + merged = falses(length(group)) + for i in eachindex(group) + merged[i] && continue + cls = [i] + for j in (i + 1):length(group) + merged[j] && continue + if exacts[j] == exacts[i] + push!(cls, j) + merged[j] = true + end + end + length(cls) > 1 || continue + #-- prefer a vertex key as the canonical node (at most one exists: + #-- distinct vertex keys are distinct points) + ci = findfirst(j -> !group[j].is_crossing, cls) + canonical = group[cls[ci === nothing ? 1 : ci]] + target = nodemap[canonical] + for j in cls + k = group[j] + k == canonical && continue + _merge_node_sections!(target, pop!(nodemap, k), canonical) + end + end + return nothing +end + +# Move the sections of `src` into `target`, rewriting their node to the +# canonical key so the section angle comparators and `create_node` see a +# consistent apex. +function _merge_node_sections!(target::NodeSections, src::NodeSections, canonical::NodeKey) + for ns in src.sections + add_node_section!(target, _section_with_node(ns, canonical)) + end + return nothing +end + +_section_with_node(ns::NodeSection, node::NodeKey) = + NodeSection(ns.is_a, ns.dim, ns.id, ns.ring_id, ns.polygonal, + ns.is_node_at_vertex, ns.v0, node, ns.v1) + +# Port of TopologyComputer.evaluateNode (private). +function evaluate_node!(tc::TopologyComputer, node_sections::NodeSections) + p = get_coordinate(node_sections) + node = create_node(_manifold(tc), node_sections; exact = _exact(tc)) + #-- Node must have edges for geom, but may also be in interior of a overlapping GC + is_area_interior_a = is_node_in_area(tc.geom_a, p, get_polygonal(node_sections, GEOM_A)) + is_area_interior_b = is_node_in_area(tc.geom_b, p, get_polygonal(node_sections, GEOM_B)) + finish!(node, is_area_interior_a, is_area_interior_b) + evaluate_node_edges!(tc, node) + return nothing +end + +# Port of TopologyComputer.evaluateNodeEdges (private). +function evaluate_node_edges!(tc::TopologyComputer, node::RelateNode) + #TODO: collect distinct dim settings by using temporary matrix? + for e in get_edges(node) + #-- An optimization to avoid updates for cases with a linear geometry + if is_area_area(tc) + update_dim!(tc, edge_location(e, GEOM_A, POS_LEFT), + edge_location(e, GEOM_B, POS_LEFT), DIM_A) + update_dim!(tc, edge_location(e, GEOM_A, POS_RIGHT), + edge_location(e, GEOM_B, POS_RIGHT), DIM_A) + end + update_dim!(tc, edge_location(e, GEOM_A, POS_ON), + edge_location(e, GEOM_B, POS_ON), DIM_L) + end + return nothing +end + +#========================================================================== +## Locating symbolic nodes in an input geometry + +Java's updateNodeLocation/evaluateNode pass the node Coordinate into +RelateGeometry.locateNode/isNodeInArea. Here the node may be a symbolic +proper-crossing key (design D2) with no stored coordinate: + +- Vertex keys locate by their exact coordinate, as in Java. +- Crossing keys on a *polygonal* geometry need no coordinate at all: a + node of a Polygon/MultiPolygon lies on its boundary (the same exact + shortcut as `locate_with_dim`'s isNode branch). +- Otherwise (lineal geometries and GCs, where another element may cover + the node) a representative coordinate is required. The exact rational + crossing point is computed and rounded to Float64 — at least as precise + as JTS, whose node coordinate is the floating-point intersection + computed by RobustLineIntersector. +==========================================================================# + +function locate_node(rg::RelateGeometry, key::NodeKey, parent_polygonal) + key.is_crossing || return locate_node(rg, key.pt, parent_polygonal) + #-- exact shortcut: a node of a polygonal geometry is on its boundary + if GI.trait(rg.geom) isa Union{GI.PolygonTrait, GI.MultiPolygonTrait} + return LOC_BOUNDARY + end + return locate_node(rg, _crossing_locate_point(key), parent_polygonal) +end + +function is_node_in_area(rg::RelateGeometry, key::NodeKey, parent_polygonal) + key.is_crossing || return is_node_in_area(rg, key.pt, parent_polygonal) + #-- exact shortcut: a node of a polygonal geometry is on its boundary, + #-- never in its interior + if GI.trait(rg.geom) isa Union{GI.PolygonTrait, GI.MultiPolygonTrait} + return false + end + return is_node_in_area(rg, _crossing_locate_point(key), parent_polygonal) +end + +# Representative Float64 coordinate of a proper-crossing node, +# deterministically rounded via 256-bit BigFloat from the exact rational +# crossing point (BigFloat avoids overflow in the Rational → Float64 +# conversion of huge numerators/denominators; the double rounding through +# BigFloat's default precision is deterministic, though not strictly +# correctly rounded). +function _crossing_locate_point(key::NodeKey) + xr, yr = _exact_crossing_point(key.pt, key.a1, key.b0, key.b1) + return (Float64(BigFloat(xr)), Float64(BigFloat(yr))) +end diff --git a/src/methods/geom_relations/relateng/topology_predicate.jl b/src/methods/geom_relations/relateng/topology_predicate.jl new file mode 100644 index 0000000000..ca32467220 --- /dev/null +++ b/src/methods/geom_relations/relateng/topology_predicate.jl @@ -0,0 +1,224 @@ +# # Topology predicate framework +#= +Port of JTS `TopologyPredicate`, `BasicPredicate`, `IMPredicate` +(operation/relateng), plus the `intersects` and `disjoint` +`BasicPredicate` kinds from `RelatePredicate.java` (the other named +predicates are `IMPredicate` kinds and live in `relate_predicates.jl`). + +Julia has no field inheritance, so JTS's class triangle becomes two +kind-parameterized mutable structs (`BasicPredicate{K}`, `IMPredicate{K}`). +Per-kind behavior (`is_determined`, `value_im`, requirement flags, init +overrides) dispatches on the kind singleton; requirement flags are pure +functions of the *type*, so evaluation specializes per predicate. +=# + +#= +## `TopologyPredicate` API (TopologyPredicate.java) +=# + +""" + TopologyPredicate + +The abstract supertype for strategy types implementing spatial predicates +based on the DE-9IM topology model (port of JTS `TopologyPredicate`). +Concrete predicates implement `predicate_name(p)`, +`update_dim!(p, locA, locB, dim)`, `finish!(p)`, `is_known(p)`, and +`predicate_value(p)`, and may override the requirement flags and the +`init_dims!`/`init_bounds!` hooks. Evaluate one against a pair of +geometries with [`relate_predicate`](@ref). +""" +abstract type TopologyPredicate end + +# Whether the predicate requires self-noding for geometries with +# crossing edges. JTS default: true. +require_self_noding(::Type{<:TopologyPredicate}) = true +# Whether the predicate requires interaction between the input geometries +# (i.e. some entry of IM[I/B, I/B] >= 0). JTS default: true. +require_interaction(::Type{<:TopologyPredicate}) = true +# Whether the predicate requires the source to cover the target. +# JTS default: false. +require_covers(::Type{<:TopologyPredicate}, is_source_a::Bool) = false +# Whether the predicate requires checking if the source input intersects +# the exterior of the target input. JTS default: true. +require_exterior_check(::Type{<:TopologyPredicate}, is_source_a::Bool) = true + +# Instance-level forwarding of the requirement flags. Most predicates' +# flags are pure functions of the type (so evaluation can specialize on +# them), but runtime-data-dependent predicates (e.g. `IMPatternMatcher`, +# whose `require_interaction` is computed from its pattern matrix) can +# override these instance methods. +require_self_noding(p::TopologyPredicate) = require_self_noding(typeof(p)) +require_interaction(p::TopologyPredicate) = require_interaction(typeof(p)) +require_covers(p::TopologyPredicate, is_source_a::Bool) = require_covers(typeof(p), is_source_a) +require_exterior_check(p::TopologyPredicate, is_source_a::Bool) = require_exterior_check(typeof(p), is_source_a) +# Initializes the predicate for a specific geometric case from the input +# dimensions. Default: dimensions provide no information. +init_dims!(p::TopologyPredicate, dimA::Integer, dimB::Integer) = nothing +# Initializes the predicate from the input bounds (JTS `init(Envelope, Envelope)`). +# Default: bounds provide no information. +init_bounds!(p::TopologyPredicate, extA, extB) = nothing + +#= +## Tri-state value and `BasicPredicate` (BasicPredicate.java) + +The base for relate predicates with a boolean value, with tri-state logic +to detect when the final value has been determined. +=# + +const TRI_UNKNOWN = Int8(-1) +const TRI_FALSE = Int8(0) +const TRI_TRUE = Int8(1) + +# Tests if two geometries intersect based on an interaction at given locations. +is_intersection(locA::Integer, locB::Integer) = + locA != LOC_EXTERIOR && locB != LOC_EXTERIOR + +# JTS `Envelope.intersects`/`covers` return false when either envelope is +# null. A null (empty-geometry) extent is represented as `nothing` here +# (`get_extent(rg)` of an empty `RelateGeometry`), so the `nothing` methods +# mirror the Java null-envelope behavior exactly. +ext_intersects(extA, extB) = Extents.intersects(extA, extB) +ext_intersects(::Nothing, extB) = false +ext_intersects(extA, ::Nothing) = false +ext_intersects(::Nothing, ::Nothing) = false +ext_covers(extA, extB) = Extents.covers(extA, extB) +ext_covers(::Nothing, extB) = false +ext_covers(extA, ::Nothing) = false +ext_covers(::Nothing, ::Nothing) = false + +mutable struct BasicPredicate{K} <: TopologyPredicate + const kind::K + value::Int8 +end +BasicPredicate(kind) = BasicPredicate(kind, TRI_UNKNOWN) + +is_known(p::TopologyPredicate) = p.value != TRI_UNKNOWN +predicate_value(p::TopologyPredicate) = p.value == TRI_TRUE + +# Updates the predicate value to the given state if it is currently unknown. +# (JTS `setValue`: doesn't change an already-known value.) +function set_value!(p::TopologyPredicate, val::Bool) + is_known(p) && return nothing + p.value = val ? TRI_TRUE : TRI_FALSE + return nothing +end +set_value_if!(p::TopologyPredicate, val::Bool, cond::Bool) = + cond ? set_value!(p, val) : nothing +require!(p::TopologyPredicate, cond::Bool) = + cond ? nothing : set_value!(p, false) +require_covers!(p::TopologyPredicate, extA, extB) = + require!(p, ext_covers(extA, extB)) + +#= +## `intersects` and `disjoint` kinds (RelatePredicate.java) + +These are the only named predicates which are plain `BasicPredicate`s +in JTS (everything else tracks an intersection matrix). +=# + +struct IntersectsPred end +struct DisjointPred end + +# intersects (RelatePredicate.java `intersects()`) +pred_intersects() = BasicPredicate(IntersectsPred()) +predicate_name(::BasicPredicate{IntersectsPred}) = "intersects" +# self-noding is not required to check for a simple interaction +require_self_noding(::Type{BasicPredicate{IntersectsPred}}) = false +# intersects only requires testing interaction +require_exterior_check(::Type{BasicPredicate{IntersectsPred}}, is_source_a::Bool) = false +init_bounds!(p::BasicPredicate{IntersectsPred}, extA, extB) = + require!(p, ext_intersects(extA, extB)) +update_dim!(p::BasicPredicate{IntersectsPred}, locA, locB, dim) = + set_value_if!(p, true, is_intersection(locA, locB)) +# if no intersecting locations were found +finish!(p::BasicPredicate{IntersectsPred}) = set_value!(p, false) + +# disjoint (RelatePredicate.java `disjoint()`) +pred_disjoint() = BasicPredicate(DisjointPred()) +predicate_name(::BasicPredicate{DisjointPred}) = "disjoint" +# self-noding is not required to check for a simple interaction +require_self_noding(::Type{BasicPredicate{DisjointPred}}) = false +require_interaction(::Type{BasicPredicate{DisjointPred}}) = false +# disjoint only requires testing interaction +require_exterior_check(::Type{BasicPredicate{DisjointPred}}, is_source_a::Bool) = false +init_bounds!(p::BasicPredicate{DisjointPred}, extA, extB) = + set_value_if!(p, true, !ext_intersects(extA, extB)) +update_dim!(p::BasicPredicate{DisjointPred}, locA, locB, dim) = + set_value_if!(p, false, is_intersection(locA, locB)) +# if no intersecting locations were found +finish!(p::BasicPredicate{DisjointPred}) = set_value!(p, true) + +#= +## `IMPredicate` core (IMPredicate.java) + +The base for predicates which are determined using entries in an +intersection matrix. Each kind must implement `is_determined(p)` and +`value_im(p)`; the kinds themselves are ported in `relate_predicates.jl`. +=# + +# allow Points coveredBy zero-length Lines +is_dims_compatible_with_covers(dim0::Integer, dim1::Integer) = + (dim0 == DIM_P && dim1 == DIM_L) ? true : dim0 >= dim1 + +const DIM_UNKNOWN = DIM_DONTCARE # JTS IMPredicate.DIM_UNKNOWN = Dimension.DONTCARE + +mutable struct IMPredicate{K} <: TopologyPredicate + const kind::K + dimA::Int8 + dimB::Int8 + im::DE9IM + value::Int8 +end +# JTS `IntersectionMatrix()` initializes all entries to `Dimension.FALSE`, +# then the IMPredicate constructor presets E/E, which is always dim = 2. +IMPredicate(kind) = IMPredicate(kind, DIM_UNKNOWN, DIM_UNKNOWN, + with_entry(DE9IM(), LOC_EXTERIOR, LOC_EXTERIOR, DIM_A), TRI_UNKNOWN) + +function init_dims!(p::IMPredicate, dimA::Integer, dimB::Integer) + p.dimA = dimA + p.dimB = dimB + init_dims_kind!(p) # per-kind hook (JTS subclasses override `init` and call super) + return nothing +end +init_dims_kind!(p::IMPredicate) = nothing + +function update_dim!(p::IMPredicate, locA, locB, dim) + # only record an increased dimension value + if is_dim_changed(p, locA, locB, dim) + p.im = with_entry(p.im, locA, locB, dim) + # set value if predicate value can be known + if is_determined(p) + set_value!(p, value_im(p)) + end + end + return nothing +end + +is_dim_changed(p::IMPredicate, locA, locB, dim) = dim > p.im[locA, locB] + +# Tests whether predicate evaluation can be short-circuited due to the +# current state of the matrix providing enough information to determine +# the predicate value. Implemented per kind. +function is_determined end +# Gets the value of the predicate according to the current intersection +# matrix state. Implemented per kind. +function value_im end + +# Tests whether the exterior of the specified input geometry +# is intersected by any part of the other input. +intersects_exterior_of(p::IMPredicate, is_a::Bool) = is_a ? + (is_intersects_entry(p, LOC_EXTERIOR, LOC_INTERIOR) || is_intersects_entry(p, LOC_EXTERIOR, LOC_BOUNDARY)) : + (is_intersects_entry(p, LOC_INTERIOR, LOC_EXTERIOR) || is_intersects_entry(p, LOC_BOUNDARY, LOC_EXTERIOR)) + +is_intersects_entry(p::IMPredicate, locA, locB) = p.im[locA, locB] >= DIM_P +# JTS's isKnownEntry is not ported: entries here are initialized to +# DIM_FALSE and only ever increase, so they never hold DIM_UNKNOWN and the +# check could never return false. +is_dimension_entry(p::IMPredicate, locA, locB, dim) = p.im[locA, locB] == dim +get_dimension(p::IMPredicate, locA, locB) = p.im[locA, locB] + +# Sets the final value based on the state of the IM. +finish!(p::IMPredicate) = set_value!(p, value_im(p)) + +Base.show(io::IO, p::IMPredicate) = + print(io, predicate_name(p), ": ", string(p.im)) diff --git a/src/precompile.jl b/src/precompile.jl new file mode 100644 index 0000000000..e262a0c224 --- /dev/null +++ b/src/precompile.jl @@ -0,0 +1,46 @@ +# # Precompile workload +# +#= +First-call latency of the RelateNG predicates is dominated by inferring the +engine (topology computer, edge intersector, tree traversals) plus one thin +per-geometry-type outer layer (`RelateGeometry` construction, extraction). +The engine core is typed on kernel-level types only — see the opaque +geometry references in `TopologyComputer` / `RelateSegmentString` — so one +workload run caches it for *every* input geometry type; the outer layer is +exercised here for the native geometry types (`GO.tuples` output wrapped in +`GI.Wrappers`, which is also what the tests feed). This matters most on +Julia 1.12, where inference of these instances is several times slower than +on 1.11. +=# + +using PrecompileTools: @setup_workload, @compile_workload + +@setup_workload begin + _pc_ring(pts) = GI.LinearRing(pts) + _pc_poly1 = GI.Polygon([_pc_ring([(0.0, 0.0), (3.0, 0.0), (3.0, 3.0), (0.0, 3.0), (0.0, 0.0)])]) + _pc_poly2 = GI.Polygon([_pc_ring([(2.0, 2.0), (5.0, 2.0), (5.0, 5.0), (2.0, 5.0), (2.0, 2.0)])]) + _pc_mpoly = GI.MultiPolygon([_pc_poly1, _pc_poly2]) + _pc_line = GI.LineString([(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)]) + _pc_mline = GI.MultiLineString([_pc_line, GI.LineString([(0.0, 1.0), (2.0, 1.0)])]) + _pc_pt = GI.Point((1.0, 1.0)) + _pc_geoms = (_pc_poly1, _pc_mpoly, _pc_line, _pc_mline, _pc_pt) + + @compile_workload begin + alg = RelateNG() + #-- every predicate re-specializes the topology computer on its + #-- predicate type; one polygon-pair call each caches the engine + for f in (intersects, disjoint, contains, within, covers, + coveredby, crosses, overlaps, touches, equals) + f(alg, _pc_poly1, _pc_poly2) + end + #-- the per-geometry-type outer layer (RelateGeometry construction, + #-- extraction, point location), over the native type combinations + for a in _pc_geoms, b in _pc_geoms + relate(alg, a, b) + end + #-- prepared mode + prep = prepare(alg, _pc_poly1) + relate(prep, _pc_poly2) + relate(prep, _pc_pt) + end +end diff --git a/src/transformations/correction/antipodal_edge_split.jl b/src/transformations/correction/antipodal_edge_split.jl new file mode 100644 index 0000000000..7ab4186018 --- /dev/null +++ b/src/transformations/correction/antipodal_edge_split.jl @@ -0,0 +1,76 @@ +# # Antipodal Edge Split + +export AntipodalEdgeSplit + +#= +On the sphere an edge between two *antipodal* vertices (a point and its +antipode) has no unique great-circle arc — infinitely many great circles pass +through both — so `relate` with a `Spherical` manifold refuses such an edge +at ingest (the kernel's edge validation). This correction +is the documented remedy: it splits every antipodal edge by inserting the +lon/lat midpoint of its endpoints, replacing one ambiguous edge with two +well-defined (roughly quarter-circle) arcs. + +## Example +=# +# ```@example antipodal +# import GeometryOps as GO, GeoInterface as GI +# # the edge (0,0)→(180,0) maps to the antipodal unit vectors (1,0,0) and (-1,0,0) +# polygon = GI.Polygon([GI.LinearRing([(0., 0.), (180., 0.), (90., 80.), (0., 0.)])]) +# GO.fix(polygon; corrections = [GO.AntipodalEdgeSplit()]) +# ``` +#= +The corrected ring carries the inserted midpoint `(90, 0)`, after which +`relate(GO.RelateNG(; manifold = GO.Spherical()), …)` runs without error. + +## Implementation +=# + +""" + AntipodalEdgeSplit() <: GeometryCorrection + +Split every edge whose endpoints map to exactly-antipodal unit vectors by +inserting the lon/lat midpoint of the edge, so each edge has a well-defined +great-circle arc. This is the remedy for the antipodal-edge `ArgumentError` +thrown by `relate` on the `Spherical` manifold. + +It can be called on any geometry as usual (`AntipodalEdgeSplit()(geom)`), or +passed to `GeometryOps.fix`. + +See also [`GeometryCorrection`](@ref). +""" +struct AntipodalEdgeSplit <: GeometryCorrection end + +application_level(::AntipodalEdgeSplit) = GI.PolygonTrait + +function (::AntipodalEdgeSplit)(::GI.PolygonTrait, polygon) + exterior = _split_antipodal_curve(GI.getexterior(polygon)) + holes = map(_split_antipodal_curve, GI.gethole(polygon)) + return GI.Wrappers.Polygon([exterior, holes...]) +end + +(::AntipodalEdgeSplit)(::GI.AbstractCurveTrait, curve) = _split_antipodal_curve(curve) + +# Whether the lon/lat points `p`, `q` map to exactly-antipodal unit vectors — +# the condition the kernel's edge validation throws on. +_is_antipodal_lonlat(p, q) = + _exactly_antipodal(_spherical_kernel_point(p), _spherical_kernel_point(q)) + +# Insert the lon/lat midpoint into every antipodal edge of a ring/line, +# returning the input unchanged (no copy) when there is nothing to split. +function _split_antipodal_curve(curve) + pts = [tuples(p) for p in GI.getpoint(curve)] + split = false + out = similar(pts, 0) + push!(out, pts[1]) + for i in 1:(length(pts) - 1) + p, q = pts[i], pts[i + 1] + if _is_antipodal_lonlat(p, q) + push!(out, ((p[1] + q[1]) / 2, (p[2] + q[2]) / 2)) + split = true + end + push!(out, q) + end + split || return curve + return GI.geointerface_geomtype(GI.trait(curve))(out) +end diff --git a/src/transformations/correction/crossing_edge_split.jl b/src/transformations/correction/crossing_edge_split.jl new file mode 100644 index 0000000000..bd44d2139a --- /dev/null +++ b/src/transformations/correction/crossing_edge_split.jl @@ -0,0 +1,272 @@ +# # Crossing Edge Split + +export CrossingEdgeSplit + +#= +On the sphere a ring that is simple in lon/lat can still self-intersect: two +non-adjacent edges, reinterpreted as great-circle arcs, can cross properly +(a planar needle a few meters wide is enough — the arcs bulge by more than +the needle's width; Natural Earth 110m Sudan is a real instance). Standard +planar validity tooling cannot see this class, and left in place one +crossing turns the ring into a figure-eight whose containment topology is +not the one the data meant. `prepare` on the `Spherical` manifold therefore +rejects such rings by default (see its `validate` docstring), naming this +correction as the remedy. + +The repair splits each ring at its proper crossing points and reassembles +the resulting loops as separate rings — even-odd semantics, both lobes +kept, the same resolution as S2Builder's undirected +`split_crossing_edges(true)` repair (which recovers planar even-odd truth +exactly on this class). A polygon whose shell splits therefore becomes a +`MultiPolygon`. + +## Example +=# +# ```@example crossingsplit +# import GeometryOps as GO, GeoInterface as GI +# # an explicit figure-eight: edges 1 and 3 cross near (0, 0) +# bowtie = GI.Polygon([GI.LinearRing([(-10., -10.), (10., 10.), (10., -10.), (-10., 10.), (-10., -10.)])]) +# GO.fix(bowtie; corrections = [GO.CrossingEdgeSplit()]) +# ``` +#= +The result is a `MultiPolygon` of the two lobes, sharing the constructed +crossing vertex, after which `GO.prepare(GO.RelateNG(; manifold = +GO.Spherical()), …)` validates clean. + +## Implementation +=# + +""" + CrossingEdgeSplit() <: GeometryCorrection + +Split every polygon ring at the points where two of its non-adjacent edges +cross properly as great-circle arcs, reassembling the resulting loops as +separate rings (even-odd semantics: both lobes of a figure-eight are kept, +matching S2Builder's undirected `split_crossing_edges` repair). A polygon +whose shell splits becomes a `MultiPolygon`; when it carries holes, each +(likewise repaired) hole loop is assigned to the shell loop that contains +it. This is the remedy for the ring-crossing `ArgumentError` thrown by +`prepare` on the `Spherical` manifold. + +Crossing points are constructed in `Float64` (lon/lat of the exact crossing +direction) — corrections construct geometry; they don't decide predicates — +the same standard as [`AntipodalEdgeSplit`](@ref)'s midpoint insertion. + +!!! warning "Scope" + The correction handles *isolated pairwise* crossings — the needle and + bowtie class, where no edge participates in more than one crossing and + no two crossings interleave around the ring. Rings with tangled + multi-crossing topology beyond that throw an `ArgumentError` rather + than emitting wrong geometry. Vertex touches (rings meeting at a point) + are valid and are not split. + +It can be called on any polygonal geometry as usual +(`CrossingEdgeSplit()(geom)`), or passed to `GeometryOps.fix`. Because a +split changes the geometry type (`Polygon` → `MultiPolygon`), apply it +directly to `MultiPolygon` inputs rather than through `fix`'s per-polygon +traversal. + +See also [`GeometryCorrection`](@ref), [`AntipodalEdgeSplit`](@ref). +""" +struct CrossingEdgeSplit <: GeometryCorrection end + +application_level(::CrossingEdgeSplit) = GI.PolygonTrait + +function (::CrossingEdgeSplit)(::GI.PolygonTrait, polygon) + shell_loops = _split_ring_at_crossings(GI.getexterior(polygon)) + hole_splits = [_split_ring_at_crossings(h) for h in GI.gethole(polygon)] + #-- identity, no copy, when nothing crossed + shell_loops === nothing && all(isnothing, hole_splits) && return polygon + + shells = shell_loops === nothing ? + [_ring_lonlat_open(GI.getexterior(polygon))] : shell_loops + holes = Vector{Vector{Tuple{Float64, Float64}}}() + for (hole, split) in zip(GI.gethole(polygon), hole_splits) + split === nothing ? push!(holes, _ring_lonlat_open(hole)) : append!(holes, split) + end + + _close(loop) = GI.LinearRing([loop; [loop[1]]]) + length(shells) == 1 && + return GI.Polygon([_close(shells[1]), map(_close, holes)...]) + + #-- the shell split: assign each hole loop to the shell loop enclosing it + m = Spherical() + shell_krs = [SphericalKernelRing(m, _close(s); exact = True()) for s in shells] + shell_holes = [Vector{Vector{Tuple{Float64, Float64}}}() for _ in shells] + for h in holes + s = _enclosing_shell(m, shell_krs, h) + s === nothing && throw(ArgumentError( + "CrossingEdgeSplit: a hole loop lies in no shell loop after the " * + "split — the ring topology is tangled beyond the isolated-crossing " * + "class this correction repairs")) + push!(shell_holes[s], h) + end + return GI.MultiPolygon([GI.Polygon([_close(s), map(_close, hs)...]) + for (s, hs) in zip(shells, shell_holes)]) +end + +function (c::CrossingEdgeSplit)(::GI.MultiPolygonTrait, mp) + polys = collect(GI.getgeom(mp)) + fixed = [c(GI.PolygonTrait(), p) for p in polys] + all(f === p for (f, p) in zip(fixed, polys)) && return mp + out = Any[] + for f in fixed + GI.trait(f) isa GI.MultiPolygonTrait ? append!(out, GI.getgeom(f)) : push!(out, f) + end + return GI.MultiPolygon(out) +end + +# The open lon/lat vertex list of a ring (closing duplicate dropped). +function _ring_lonlat_open(ring) + ll = [(Float64(GI.x(p)), Float64(GI.y(p))) for p in GI.getpoint(ring)] + length(ll) > 1 && ll[end] == ll[1] && pop!(ll) + return ll +end + +#= +Split one ring at its proper great-circle crossings: `nothing` when there +are none (the caller keeps the input, no copy), otherwise the open lon/lat +loops of the split. Detection is the `prepare`-validation predicate +(`_edges_cross_properly` over non-adjacent edge pairs, arc-extent pruned); +the isolation checks reject the tangled class upfront. Splitting recurses: +cut at one crossing, re-detect in each resulting loop — with a laminar +(non-interleaved), edge-disjoint crossing family every remaining crossing +falls wholly inside one loop, and each cut consumes one crossing, so the +recursion performs exactly `k` cuts for `k` crossings. The budget guards +the pathological case where a constructed (rounded) crossing vertex creates +a crossing that was not in the input family. +=# +function _split_ring_at_crossings(ring) + ll = _ring_lonlat_open(ring) + kp = [_spherical_kernel_point(p) for p in ll] + _dedup_ring_vertices!(ll, kp) + crossings = _ring_proper_crossings(kp) + isempty(crossings) && return nothing + _check_isolated_crossings(crossings) + out = Vector{Vector{Tuple{Float64, Float64}}}() + budget = Ref(length(crossings)) + _split_loops!(out, ll, kp, budget) + return out +end + +# Consecutive duplicate kernel vertices (repeated input points) collapse in +# tandem in both coordinate lists, so edge indices stay aligned. +function _dedup_ring_vertices!(ll, kp) + i = 1 + while i <= length(kp) && length(kp) > 1 + j = mod1(i + 1, length(kp)) + if kp[i] == kp[j] + deleteat!(ll, j) + deleteat!(kp, j) + else + i += 1 + end + end + return nothing +end + +# All proper great-circle crossings between non-adjacent edges of the open +# vertex list `kp` (edge k = kp[k] → kp[mod1(k + 1, n)]), as sorted index +# pairs. Arc-extent pruned: a nested pair loop below the accelerator +# threshold, an `RTree` self-join (the `prepare`-validation pattern) above. +function _ring_proper_crossings(kp::Vector) + n = length(kp) + out = Tuple{Int, Int}[] + n < 4 && return out + exts = [spherical_arc_extent(kp[k], kp[mod1(k + 1, n)]) for k in 1:n] + if n < GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS + for i in 1:n, j in (i + 1):n + Extents.intersects(exts[i], exts[j]) || continue + _push_ring_crossing!(out, kp, n, i, j) + end + else + tree = RTree(Unsorted(), collect(1:n); extents = exts, nodecapacity = 16) + SpatialTreeInterface.dual_depth_first_search(Extents.intersects, tree, tree) do ia, ib + ia < ib || return nothing + _push_ring_crossing!(out, kp, n, tree.data[ia], tree.data[ib]) + return nothing + end + sort!(out) + end + return out +end + +function _push_ring_crossing!(out, kp, n, i, j) + a0 = kp[i]; a1 = kp[mod1(i + 1, n)] + b0 = kp[j]; b1 = kp[mod1(j + 1, n)] + #-- shared endpoints (adjacency, vertex touches) are not crossings + (a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1) && return nothing + _edges_cross_properly(Spherical(), a0, a1, b0, b1; exact = True()) && + push!(out, (i, j)) + return nothing +end + +@noinline _throw_tangled_crossings(why) = throw(ArgumentError( + "CrossingEdgeSplit: the ring's proper crossings are tangled ($why); " * + "this correction repairs isolated pairwise crossings (the needle/bowtie " * + "class) only, and refuses rather than emit wrong geometry")) + +# Repairability: each edge in at most one crossing, and no two crossings +# interleaved around the ring (as chords of the vertex circle they must +# nest or be disjoint — a laminar family — for the cut loops to be simple). +function _check_isolated_crossings(crossings) + for a in eachindex(crossings) + (i1, j1) = crossings[a] + for b in (a + 1):lastindex(crossings) + (i2, j2) = crossings[b] + (i1 == i2 || i1 == j2 || j1 == i2 || j1 == j2) && + _throw_tangled_crossings("an edge crosses two others") + (i1 < i2 < j1) == (i1 < j2 < j1) || + _throw_tangled_crossings("two crossings interleave") + end + end + return nothing +end + +function _split_loops!(out, ll::Vector, kp::Vector, budget::Base.RefValue{Int}) + crossings = _ring_proper_crossings(kp) + if isempty(crossings) + push!(out, ll) + return nothing + end + _check_isolated_crossings(crossings) + budget[] -= 1 + budget[] < 0 && _throw_tangled_crossings( + "splitting produced more crossings than the input family") + n = length(kp) + (i, j) = crossings[1] + x_ll = _ring_crossing_point(kp[i], kp[mod1(i + 1, n)], kp[j], kp[mod1(j + 1, n)]) + #-- the loop vertices stay lon/lat; their kernel points are re-derived + #-- from the STORED coordinates, so the recursion (and any later + #-- `prepare` of the output) sees exactly the emitted geometry + x_kp = _spherical_kernel_point(x_ll) + ja = (i + 1):j # loop A: X, v[i+1] … v[j] + jb = j == n ? (1:i) : [(j + 1):n; 1:i] # loop B: X, v[j+1] … v[i] + _split_loops!(out, [[x_ll]; ll[ja]], [[x_kp]; kp[ja]], budget) + _split_loops!(out, [[x_ll]; ll[jb]], [[x_kp]; kp[jb]], budget) + return nothing +end + +# The crossing point of two properly crossing arcs, as Float64 lon/lat: the +# exact crossing direction (`_sph_crossing_dir` over the canonical crossing +# node — the candidate strictly interior to both arcs), rounded once. +function _ring_crossing_point(a0, a1, b0, b1) + d = _sph_crossing_dir(True(), crossing_node(a0, a1, b0, b1)) + x = normalize(SVector{3, Float64}(Float64(d[1]), Float64(d[2]), Float64(d[3]))) + return GeographicFromUnitSphere()(x) +end + +# The index of the shell loop whose enclosed region contains hole loop `h`: +# decided at the first hole vertex that is not on the candidate shell's +# boundary. `nothing` if every shell rejects it. +function _enclosing_shell(m::Spherical, shell_krs, h) + for (s, kr) in enumerate(shell_krs) + for v in h + loc = rk_point_in_ring(m, _spherical_kernel_point(v), kr; exact = True()) + loc == LOC_BOUNDARY && continue + loc == LOC_INTERIOR && return s + break # exterior of this shell: try the next one + end + end + return nothing +end diff --git a/src/utils/FlexibleRTrees/FlexibleRTrees.jl b/src/utils/FlexibleRTrees/FlexibleRTrees.jl index 4ba94f558b..945ea69f99 100644 --- a/src/utils/FlexibleRTrees/FlexibleRTrees.jl +++ b/src/utils/FlexibleRTrees/FlexibleRTrees.jl @@ -6,10 +6,11 @@ dimensionality, with a pluggable bulk-load algorithm — sort-tile-recursive ([`STR`](@ref)), Hilbert-packed ([`HPR`](@ref)), or none ([`Unsorted`](@ref)) — behind one tree type. -Storage is flat: `RTree{A, E}` holds a vector of per-level extent vectors -plus a leaf permutation, and is a concrete type at any size or depth. A -bulk-load algorithm chooses only the *leaf order*, via [`loadorder`](@ref); -packing always unions consecutive runs of `nodecapacity` extents, bottom-up. +Storage is flat: an `RTree` holds a vector of per-level extent vectors, a +leaf permutation, and the indexed collection itself (`tree.data`), and is a +concrete type at any size or depth. A bulk-load algorithm chooses only the +*leaf order*, via [`loadorder`](@ref); packing always unions consecutive +runs of `nodecapacity` extents, bottom-up. Upper levels therefore group runs of the leaf order rather than re-tiling each level: Hilbert order is spatially local at every scale so `HPR` packs tightly, while `STR`'s upper levels are slightly looser than a re-tiled diff --git a/src/utils/FlexibleRTrees/bulk_loading.jl b/src/utils/FlexibleRTrees/bulk_loading.jl index 262762be07..326bf9097c 100644 --- a/src/utils/FlexibleRTrees/bulk_loading.jl +++ b/src/utils/FlexibleRTrees/bulk_loading.jl @@ -3,12 +3,15 @@ # ## Leaf ordering """ - loadorder(algorithm, extents::Vector{<:Extents.Extent}, nodecapacity)::Vector{Int} + loadorder(algorithm, extents::Vector{<:Extents.Extent}, nodecapacity) -The permutation in which `algorithm` packs `extents` into leaves. Implement -this for a new `BulkLoadAlgorithm` subtype to plug in another ordering. +The permutation (an `AbstractVector{Int}`) in which `algorithm` packs +`extents` into leaves. Implement this for a new `BulkLoadAlgorithm` subtype +to plug in another ordering. Return `Base.OneTo` for the identity — the +constructor then skips the reorder copy and aliases `extents` as the leaf +level. """ -loadorder(::Unsorted, extents, nodecapacity) = collect(1:length(extents)) +loadorder(::Unsorted, extents, nodecapacity) = Base.OneTo(length(extents)) loadorder(::HPR, extents, nodecapacity) = sortperm(_hilbert_keys(extents)) function loadorder(::STR, extents::Vector{E}, nodecapacity) where E centers = [_center(e) for e in extents] diff --git a/src/utils/FlexibleRTrees/interface.jl b/src/utils/FlexibleRTrees/interface.jl index 3e4c140f88..cb31abf50c 100644 --- a/src/utils/FlexibleRTrees/interface.jl +++ b/src/utils/FlexibleRTrees/interface.jl @@ -5,7 +5,7 @@ import ..SpatialTreeInterface: isspatialtree, isleaf, nchild, getchild, child_indices_extents, depth_first_search """ - RTreeNode{A, E} + RTreeNode{T, E} A cursor into one node of an [`RTree`](@ref): the tree, the node's level (0-based; the children of a level-`l` node live in `levels[l + 1]`), its @@ -17,8 +17,8 @@ level, `child_indices_extents` maps leaf slots through `tree.indices`, so queries return indices into the original collection despite the packed reordering. """ -struct RTreeNode{A <: BulkLoadAlgorithm, E <: Extents.Extent} - tree::RTree{A, E} +struct RTreeNode{T <: RTree, E <: Extents.Extent} + tree::T level::Int # 0-based; children of a level-l node live in levels[l + 1] index::Int # position within its level extent::E diff --git a/src/utils/FlexibleRTrees/types.jl b/src/utils/FlexibleRTrees/types.jl index dfb2151059..7a15a159b0 100644 --- a/src/utils/FlexibleRTrees/types.jl +++ b/src/utils/FlexibleRTrees/types.jl @@ -41,35 +41,52 @@ struct Unsorted <: BulkLoadAlgorithm end # ## The tree """ - RTree(algorithm::BulkLoadAlgorithm, data; nodecapacity = 16) + RTree(algorithm::BulkLoadAlgorithm, data; nodecapacity = 16, extents = nothing) A packed R-tree over the extents of `data` (anything `GI.extent` accepts — geometries, or `Extents.Extent`s themselves), of any dimensionality, bulk loaded in the order chosen by `algorithm`. +Pass a vector as `extents` (one per element of `data`, in order) to index +`data` by precomputed extents instead of `GI.extent` — for payload elements +that carry no extent of their own, or extents computed in another coordinate +space. The tree takes ownership of the vector (`Unsorted` aliases it as the +leaf level rather than copying). + The tree is flat and fully concrete: `levels[1]` is the coarsest level and `levels[end]` holds the leaf extents in packed order, with `indices` mapping each leaf slot back to its position in `data`. Queries through -SpatialTreeInterface therefore return indices into the original collection. +SpatialTreeInterface therefore return indices into `data`, which the tree +keeps as `tree.data` so hits map straight back to elements wherever the +tree travels. """ -struct RTree{A <: BulkLoadAlgorithm, E <: Extents.Extent} +struct RTree{A <: BulkLoadAlgorithm, E <: Extents.Extent, D <: AbstractVector, I <: AbstractVector{Int}} algorithm::A nodecapacity::Int extent::E levels::Vector{Vector{E}} # levels[1] = coarsest, levels[end] = leaf extents (packed order) - indices::Vector{Int} # leaf slot -> index into the original collection + indices::I # leaf slot -> index into `data` (`Base.OneTo` when unpermuted) + data::D # the indexed collection end -function RTree(algorithm::A, data; nodecapacity::Int = 16) where A <: BulkLoadAlgorithm +function RTree(algorithm::A, data; nodecapacity::Int = 16, + extents::Union{Nothing, Vector{<:Extents.Extent}} = nothing) where A <: BulkLoadAlgorithm nodecapacity >= 2 || throw(ArgumentError("`nodecapacity` must be at least 2, got $nodecapacity")) - isnothing(iterate(data)) && throw(ArgumentError("cannot build an `RTree` from an empty collection")) - E = typeof(GI.extent(first(data))) - extents = E[GI.extent(x) for x in data] - perm = loadorder(algorithm, extents, nodecapacity) - leaves = extents[perm] + items = data isa AbstractVector ? data : collect(data) + isempty(items) && throw(ArgumentError("cannot build an `RTree` from an empty collection")) + exts = if extents === nothing + E = typeof(GI.extent(first(items))) + E[GI.extent(x) for x in items] + else + length(extents) == length(items) || throw(ArgumentError( + "`extents` must have one entry per element of `data`, got $(length(extents)) for $(length(items))")) + extents + end + perm = loadorder(algorithm, exts, nodecapacity) + leaves = perm isa Base.OneTo ? exts : exts[perm] levels = _pack_levels(leaves, nodecapacity) total = reduce(Extents.union, levels[1]) - return RTree{A, E}(algorithm, nodecapacity, total, levels, perm) + return RTree(algorithm, nodecapacity, total, levels, perm, items) end Extents.extent(tree::RTree) = tree.extent diff --git a/src/utils/UnitSpherical/UnitSpherical.jl b/src/utils/UnitSpherical/UnitSpherical.jl index 4b4a535164..fc78df8d3c 100644 --- a/src/utils/UnitSpherical/UnitSpherical.jl +++ b/src/utils/UnitSpherical/UnitSpherical.jl @@ -21,22 +21,25 @@ include("slerp.jl") include("cap.jl") include("predicates.jl") include("arc_intersection.jl") +include("arc_extent.jl") export UnitSphericalPoint, UnitSphereFromGeographic, GeographicFromUnitSphere, slerp, SphericalCap, spherical_distance, spherical_orient, point_on_spherical_arc, + spherical_ring_contains, spherical_ring_encloses, spherical_exterior_anchor, spherical_arc_intersection, ArcIntersectionResult, arc_cross, arc_hinge, arc_overlap, arc_disjoint, + spherical_arc_extent, to_unit_spherical_points """ - to_unit_spherical_points(ring) -> Vector{UnitSphericalPoint{Float64}} + to_unit_spherical_points(ring) -> Vector{<:UnitSphericalPoint} -Convert a ring (linear ring or any GeoInterface point iterator) to a vector of UnitSphericalPoints. -Uses UnitSphereFromGeographic which is a no-op for already-converted points. +Convert a ring (linear ring or any GeoInterface point iterator) to a vector of +UnitSphericalPoints, treating geographic input as (longitude, latitude). +`UnitSphericalPoint`s pass through unchanged. """ function to_unit_spherical_points(ring) - transform = UnitSphereFromGeographic() - return [transform((GI.x(p), GI.y(p))) for p in GI.getpoint(ring)] + return [UnitSphericalPoint(p) for p in GI.getpoint(ring)] end end \ No newline at end of file diff --git a/src/utils/UnitSpherical/arc_extent.jl b/src/utils/UnitSpherical/arc_extent.jl new file mode 100644 index 0000000000..63302c5afe --- /dev/null +++ b/src/utils/UnitSpherical/arc_extent.jl @@ -0,0 +1,63 @@ +# # Spherical arc extents + +#= +```@docs; canonical=false +spherical_arc_extent +``` + +A great-circle arc bulges away from the chord between its endpoints (two +points at `z = 0.9` joined over the pole reach `z = 1`), so the endpoints' +bounding box does not contain the arc. [`spherical_arc_extent`](@ref) +computes a box that does. + +Each Cartesian coordinate along the arc is the sinusoid +`pᵢ(φ) = aᵢ cos φ + t̂ₐᵢ sin φ` (`t̂ₐ` the unit tangent at `a`), of +amplitude `hypot(aᵢ, t̂ₐᵢ)`; the arc spans at most a half turn, so an +interior extremum on axis `i` exists iff `pᵢ` rises at `a` and falls at +`b`. Tangents come from [`robust_cross_product`](@ref), keeping +nearly-degenerate and nearly-antipodal arcs stable; bounds are padded by a +few ulps, as S2's `S2LatLngRectBounder` pads by its maximum error. +=# + +""" + spherical_arc_extent(a, b)::Extents.Extent{(:X, :Y, :Z)} + +The 3D Cartesian extent of the shorter great-circle arc between `a` and `b` +on the unit sphere. Accepts `UnitSphericalPoint`s, or any GeoInterface +point (interpreted geographically, as longitude/latitude, like the +`UnitSphericalPoint` constructor itself). + +The extent is exact up to floating point error, padded by a few ulps so it +always contains the arc. For antipodal endpoints the arc's plane is +ambiguous; the one chosen by [`robust_cross_product`](@ref) is used. + +## Example + +```jldoctest +julia> using GeometryOps.UnitSpherical + +julia> ext = spherical_arc_extent(UnitSphericalPoint(1, 0, 0), UnitSphericalPoint(0, 1, 0)); + +julia> ext.X[2] ≈ 1 && ext.Y[2] ≈ 1 +true +``` +""" +spherical_arc_extent(a, b) = spherical_arc_extent(UnitSphericalPoint(a), UnitSphericalPoint(b)) +function spherical_arc_extent(a::UnitSphericalPoint{T1}, b::UnitSphericalPoint{T2}) where {T1, T2} + F = float(promote_type(T1, T2)) + pad = 4 * eps(F) + if a == b + bounds = ntuple(i -> (F(a[i]) - pad, F(a[i]) + pad), 3) + else + n = robust_cross_product(a, b) + ta = normalize(cross(n, a)) # unit tangent at `a`, pointing along the arc + tb = normalize(cross(n, b)) # unit tangent at `b`, pointing along the arc + bounds = ntuple(3) do i + lo, hi = minmax(F(a[i]), F(b[i])) + ta[i] > 0 > tb[i] && (hi = hypot(F(a[i]), F(ta[i]))) + ta[i] < 0 < tb[i] && (lo = -hypot(F(a[i]), F(ta[i]))) + (lo - pad, hi + pad) + end + end + return Extents.Extent(X = bounds[1], Y = bounds[2], Z = bounds[3]) +end diff --git a/src/utils/UnitSpherical/predicates.jl b/src/utils/UnitSpherical/predicates.jl index e0e9d36d74..457f2fa51a 100644 --- a/src/utils/UnitSpherical/predicates.jl +++ b/src/utils/UnitSpherical/predicates.jl @@ -92,3 +92,245 @@ function point_on_spherical_arc(p::UnitSphericalPoint, a::UnitSphericalPoint, b: # (in terms of angle, so larger dot product) return (ap ≥ ab - tol) && (bp ≥ ab - tol) end + +""" + spherical_ring_contains(pts, n, q; orient, on_arc, proper_crossing) -> Union{Bool, Nothing} + +Whether `q` lies in the closed region on the left of the ring `pts[1:n]` +(S2 loop convention: counterclockwise winding, interior on the left, so a +clockwise ring contains the complement). The closing edge `pts[n] → pts[1]` +is implied; boundary points count as contained. Returns `nothing` when +every anchor edge is degenerate with respect to `q` — callers must treat +that conservatively. + +Containment is decided by crossing parity, the way `S2Loop::Contains` / +`InitBound` decide pole containment: which side of an anchor edge `q` falls +on, flipped once per transversal crossing of the arc from the anchor's +midpoint to `q` with the other edges; degenerate anchors are skipped and +the next edge tried. + +The geometric predicates are injectable, for callers with stricter +requirements. They receive the input points untouched (which may be +non-unit for scale-invariant predicates — the defaults assume unit input); +only the constructed reference midpoint is normalized. + +- `orient(a, b, c)`: sign-valued orientation of `c` against the oriented + great circle through `a, b`; default [`spherical_orient`](@ref). +- `on_arc(q, a, b)::Bool`: boundary membership; default + [`point_on_spherical_arc`](@ref). Pass `Returns(false)` when boundary + points are already classified. +- `proper_crossing(q, m, a, b)::Int`: `1` if the minor arcs `(q, m)` and + `(a, b)` cross transversally in both interiors, `0` if not, `-1` for too + close to call; consulted once `orient` places both endpoint pairs + strictly transversally. The default uses `robust_cross_product` with a + small tolerance band. +""" +function spherical_ring_contains(pts, n, q; + orient = spherical_orient, + on_arc = point_on_spherical_arc, + proper_crossing = _hemisphere_proper_crossing) + for j in 1:n + on_arc(q, pts[j], pts[mod1(j + 1, n)]) && return true + end + nq = norm(q) + for j in 1:n + a, b = pts[j], pts[mod1(j + 1, n)] + a == b && continue + side = orient(a, b, q) + side == 0 && continue + mid = a + b + # near-antipodal edge: the midpoint direction is unstable + norm(mid) < 1e-9 * (norm(a) + norm(b)) && continue + m = UnitSphericalPoint(normalize(mid)) + # test arc q → m would span a half turn + dot(q, m) < (-1 + 1e-9) * nq && continue + crossings = 0 + ok = true + for k in 1:n + k == j && continue + c = _arc_crossing_parity(q, m, pts[k], pts[mod1(k + 1, n)]; orient, proper_crossing) + if c == -1 + ok = false + break + end + crossings += c + end + ok || continue + # walking from `m` toward `q` departs onto `q`'s side of the anchor + # edge (the arc meets that great circle again only at `-m`); positive + # side is the interior, and each crossing flips it + return isodd(crossings) ? side < 0 : side > 0 + end + return nothing +end + +""" + spherical_exterior_anchor(pts, n) -> Union{UnitSphericalPoint{Float64}, Nothing} + +A reference point exterior BY DEFINITION of the enclosed-region semantics +of the ring `pts[1:n]`: the antipode of the ring's normalized vertex mass +(the sum of the unit vertex directions). For any ring whose enclosed region +is meaningfully smaller than a hemisphere, the vertex mass points into the +cap the vertices bound, so its antipode lies in the larger — exterior — +region. + +Returns `nothing` when the mass norm is tiny (below `1e-6` per vertex): +near-hemisphere or vertex-symmetric rings, whose vertices spread over a +near-great circle. There the enclosed/complement distinction is itself +near-degenerate (the turning-angle winding tolerance already treats exact +hemispheres permissively — see `_ring_is_ccw`), so callers fall back to the +winding-consistent wedge bootstrap of [`spherical_ring_contains`](@ref). +""" +function spherical_exterior_anchor(pts, n) + n == 0 && return nothing + mass = normalize(SVector{3, Float64}(pts[1])) + for i in 2:n + mass += normalize(SVector{3, Float64}(pts[i])) + end + norm(mass) <= 1e-6 * n && return nothing + return UnitSphericalPoint(-normalize(mass)) +end + +""" + spherical_ring_encloses(pts, n, q; + anchor, orient, on_arc, proper_crossing) -> Union{Bool, Nothing} + +Whether `q` lies in the region ENCLOSED by the ring `pts[1:n]` (the closing +edge `pts[n] → pts[1]` is implied; boundary points count as enclosed): +even-odd crossing parity of the arc from `q` to a reference point that is +exterior *by definition* of the enclosed-region semantics — `anchor`, by +default the antipode of the normalized vertex mass +([`spherical_exterior_anchor`](@ref)). + +Winding-independent, like [`spherical_ring_contains`](@ref) composed with a +winding test — but where that composition bootstraps the interior from a +local wedge at one edge and a global turning-angle sum, both of which a +ring that self-intersects *on the sphere* defeats (a figure-eight's lobes +cancel the turning angle, and the wedge answer is anchored to whichever +lobe hosts the edge — S2's forced-through behavior, globally inverted on +real data), parity from a definitionally exterior point degrades to +even-odd semantics: both lobes enclosed, the far side out. + +Returns `nothing` — callers fall back conservatively — when: + +- `anchor === nothing` (degenerate vertex mass, see + [`spherical_exterior_anchor`](@ref)); +- `q` is (nearly) antipodal to the anchor (the test arc is ill-defined: + `q` sits at the center of the vertex mass); +- the anchor lies exactly ON a ring edge (the test arc ends on the ring); + or +- `proper_crossing` reports a crossing as too close to call (`-1`; never + with exact injected predicates). + +The `orient`/`on_arc`/`proper_crossing` predicates are injectable exactly +as in [`spherical_ring_contains`](@ref); `on_test_arc(v, a, b)` decides +whether a point already known to lie on the great circle of `(a, b)` lies +on the closed minor arc (the vertex-grazing resolution below — exact +callers inject their span test). +""" +function spherical_ring_encloses(pts, n, q; + anchor = spherical_exterior_anchor(pts, n), + orient = spherical_orient, + on_arc = point_on_spherical_arc, + on_test_arc = point_on_spherical_arc, + proper_crossing = _hemisphere_proper_crossing) + for j in 1:n + on_arc(q, pts[j], pts[mod1(j + 1, n)]) && return true + end + anchor === nothing && return nothing + # test arc q → anchor would span (nearly) a half turn + dot(q, anchor) < (-1 + 1e-9) * norm(q) && return nothing + crossings = 0 + for k in 1:n + c = _anchor_crossing_parity(q, anchor, pts[k], pts[mod1(k + 1, n)]; + orient, on_test_arc, proper_crossing) + c == -1 && return nothing + crossings += c + end + return isodd(crossings) +end + +#= +Crossing parity of the closed test arc q → z (z the definitional exterior +anchor) against ring edge a → b: `_arc_crossing_parity` with the two +exactly-degenerate configurations that helper refuses (-1) resolved the way +the indexed locator's `count_arc_segment!` resolves them — symbolically, S2 +`VertexCrossing` style — so a symmetric ring (whose vertex mass can point +exactly at a crossing point, putting the anchor on an edge's great circle) +cannot force every query back onto the wedge bootstrap: + +- an edge endpoint exactly on the test arc's great circle (`sa == 0` / + `sb == 0`): two distinct great circles meet only at one antipodal pair, + so the edge can touch the test arc only at that endpoint — count iff the + endpoint lies ON the closed test arc and the other endpoint is strictly + on the positive side, so a crossing pair of incident edges counts once + and a same-side pair counts zero or twice (parity-equal); +- the anchor exactly on the edge's great circle (`sm == 0`): the circles + meet only at ±z, and the minor test arc reaches z but never −z — no + crossing, unless the edge itself contains z (the anchor ON the ring: + refuse with -1, the caller falls back). + +`sq == 0` (q on the edge's circle but not on the edge — boundary is +excluded upfront) stays 0, and edges with a vertex at −q stay 0, exactly +as in `_arc_crossing_parity`. +=# +function _anchor_crossing_parity(q, z, a, b; orient, on_test_arc, proper_crossing) + (a == -q || b == -q) && return 0 + a == b && return 0 + sa = orient(q, z, a) + sb = orient(q, z, b) + if sa == 0 || sb == 0 + if sa == 0 && sb == 0 + # edge collinear with the test circle: its neighbors decide the + # parity — unless it holds the anchor itself + return on_test_arc(z, a, b) ? -1 : 0 + end + von, s_off = sa == 0 ? (a, sb) : (b, sa) + return (s_off > 0 && on_test_arc(von, q, z)) ? 1 : 0 + end + (sa > 0) == (sb > 0) && return 0 + sq = orient(a, b, q) + sq == 0 && return 0 + sm = orient(a, b, z) + if sm == 0 + return on_test_arc(z, a, b) ? -1 : 0 + end + (sq > 0) == (sm > 0) && return 0 + return proper_crossing(q, z, a, b) +end + +# Crossing parity of the test arc q → m against ring edge a → b: 1 for a +# transversal crossing, 0 for none, -1 for too close to degenerate to call +# (with an exact `orient`, only exact incidences return -1). +function _arc_crossing_parity(q, m, a, b; orient, proper_crossing) + # a vertex at `-q` lies on every great circle through `q`; its edges can + # reach the test arc only at `q` itself, excluded by the on-boundary check + (a == -q || b == -q) && return 0 + sa = orient(q, m, a) + sb = orient(q, m, b) + (sa == 0 || sb == 0) && return -1 + (sa > 0) == (sb > 0) && return 0 + # `q` on this edge's great circle but off the edge (checked upfront): the + # circles meet only at `±q`, out of the test arc's reach — no crossing. + # Anchor-independent (lonlat meridian edges hold `±eₓ`/`±e_y` exactly), + # so resolve instead of returning -1. + sq = orient(a, b, q) + sq == 0 && return 0 + sm = orient(a, b, m) + sm == 0 && return -1 + (sq > 0) == (sm > 0) && return 0 + return proper_crossing(q, m, a, b) +end + +# Default transversality decision: the circles' intersection direction `x` +# must point into both arcs' hemispheres (each arc holds exactly one of `±x` +# once the endpoint sides are strict). Tolerance-banded; assumes unit input. +function _hemisphere_proper_crossing(q, m, a, b) + x = cross(normalize(robust_cross_product(q, m)), + normalize(robust_cross_product(a, b))) + d1 = dot(x, q + m) + d2 = dot(x, a + b) + tol = 16 * eps(Float64) * norm(x) + (abs(d1) <= tol || abs(d2) <= tol) && return -1 + return (d1 > 0) == (d2 > 0) ? 1 : 0 +end diff --git a/src/utils/utils.jl b/src/utils/utils.jl index ddefac1a48..d270d1d9ee 100644 --- a/src/utils/utils.jl +++ b/src/utils/utils.jl @@ -159,6 +159,7 @@ Currently they only work on linear rings. """ eachedge(geom, [::Type{T}]) + eachedge(m::Manifold, geom, [::Type{T}]) Decompose a geometry into a list of edges. Currently only works for LineString and LinearRing. @@ -166,11 +167,19 @@ Currently only works for LineString and LinearRing. Returns some iterator, which yields tuples of points. Each tuple is an edge. It goes `(p1, p2), (p2, p3), (p3, p4), ...` etc. + +On `Planar()` (the default) points are 2D coordinate tuples. On +`Spherical()` they are `UnitSphericalPoint`s: geographic (longitude, +latitude) input is converted, `UnitSphericalPoint`s pass through. """ eachedge(geom) = eachedge(GI.trait(geom), geom, Float64) function eachedge(geom, ::Type{T}) where T eachedge(GI.trait(geom), geom, T) end +eachedge(::Planar, geom, ::Type{T} = Float64) where T = eachedge(geom, T) +function eachedge(::Spherical, geom, ::Type{T} = Float64) where T + return (map(UnitSpherical.UnitSphericalPoint, ps) for ps in eachedge(geom, T)) +end # implementation for LineString and LinearRing function eachedge(trait::GI.AbstractCurveTrait, geom, ::Type{T}) where T return (_tuple_point.((GI.getpoint(geom, i), GI.getpoint(geom, i+1)), T) for i in 1:GI.npoint(geom)-1) @@ -188,11 +197,18 @@ end """ to_edgelist(geom, [::Type{T}]) + to_edgelist(m::Manifold, geom, [::Type{T}]) Convert a geometry into a vector of `GI.Line` objects with attached extents. + +On `Spherical()` — or whenever the geometry's points are already +`UnitSphericalPoint`s — each edge carries the 3D +[`UnitSpherical.spherical_arc_extent`](@ref) of its great-circle arc. """ -to_edgelist(geom, ::Type{T} = Float64) where T = +to_edgelist(geom, ::Type{T} = Float64) where T = [_lineedge(ps, T) for ps in eachedge(geom, T)] +to_edgelist(m::Manifold, geom, ::Type{T} = Float64) where T = + [_lineedge(ps, T) for ps in eachedge(m, geom, T)] """ to_edgelist(ext::E, geom, [::Type{T}])::(::Vector{GI.Line}, ::Vector{Int}) @@ -222,15 +238,23 @@ function _lineedge(ps::Tuple, ::Type{T}) where T e = GI.extent(l) return GI.Line(l.geom; extent=e) end +function _lineedge(ps::Tuple{<:UnitSpherical.UnitSphericalPoint, <:UnitSpherical.UnitSphericalPoint}, ::Type{T}) where T + a, b = UnitSpherical.UnitSphericalPoint{T}.(ps) + return GI.Line(StaticArrays.SVector((a, b)); extent = UnitSpherical.spherical_arc_extent(a, b)) +end """ lazy_edgelist(geom, [::Type{T}]) + lazy_edgelist(m::Manifold, geom, [::Type{T}]) Return an iterator over `GI.Line` objects with attached extents. """ function lazy_edgelist(geom, ::Type{T} = Float64) where T (_lineedge(ps, T) for ps in eachedge(geom, T)) end +function lazy_edgelist(m::Manifold, geom, ::Type{T} = Float64) where T + (_lineedge(ps, T) for ps in eachedge(m, geom, T)) +end """ edge_extents(geom, [::Type{T}]) diff --git a/test/Project.toml b/test/Project.toml index 1f235391d5..ee35ba30bd 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -40,6 +40,7 @@ XML = "72c71f33-b9b6-44de-8c94-c961784809e2" ArchGDAL = "0.10.10" GeometryBasics = "0.4.7, 0.5" Test = "1" +XML = "0.3.8, 0.4" [sources] GeometryOps = {path = ".."} diff --git a/test/data/jts/.gitattributes b/test/data/jts/.gitattributes new file mode 100644 index 0000000000..e5b50c9f58 --- /dev/null +++ b/test/data/jts/.gitattributes @@ -0,0 +1,2 @@ +general/** linguist-generated=true +validate/** linguist-generated=true \ No newline at end of file diff --git a/test/data/jts/LICENSE-NOTICE.md b/test/data/jts/LICENSE-NOTICE.md new file mode 100644 index 0000000000..3fe3d001b6 --- /dev/null +++ b/test/data/jts/LICENSE-NOTICE.md @@ -0,0 +1,24 @@ +# JTS test data license notice + +The XML files in this directory tree (`test/data/jts/general/`, +`test/data/jts/validate/`) are vendored, unmodified, from the +[JTS Topology Suite](https://github.com/locationtech/jts) test resources +(`modules/tests/src/test/resources/testxml/`). + +- Upstream repository: https://github.com/locationtech/jts +- Upstream commit: `123a182e6e5a9cc8caed8ff037e4f824a5ce74ee` (2026-03-05) +- Vendored on: 2026-06-11 + +JTS is dual-licensed under the Eclipse Public License 2.0 (EPL 2.0) and the +Eclipse Distribution License 1.0 (EDL 1.0, a BSD-style license). These files +are redistributed here under those terms. See: + +- https://github.com/locationtech/jts/blob/master/LICENSE_EPLv2.txt +- https://github.com/locationtech/jts/blob/master/LICENSE_EDLv1.txt + +File provenance within the upstream `testxml/` directory: + +- `general/TestRelate{PP,PL,PA,LL,LA,AA}.xml`, `general/TestBoundary.xml` → `test/data/jts/general/` +- `misc/TestRelateEmpty.xml`, `misc/TestRelateGC.xml` → `test/data/jts/general/` +- `validate/TestRelate*.xml` → `test/data/jts/validate/` +- `robust/TestRobustRelate.xml`, `robust/TestRobustRelateFloat.xml` → `test/data/jts/validate/` diff --git a/test/data/jts/general/TestBoundary.xml b/test/data/jts/general/TestBoundary.xml new file mode 100644 index 0000000000..f3a6dff809 --- /dev/null +++ b/test/data/jts/general/TestBoundary.xml @@ -0,0 +1,164 @@ + + + + P - point + + POINT(10 10) + + + + GEOMETRYCOLLECTION EMPTY + + + + + + mP - MultiPoint + + MULTIPOINT((10 10), (20 20)) + + + + GEOMETRYCOLLECTION EMPTY + + + + + + L - Line + + LINESTRING(10 10, 20 20) + + + + MULTIPOINT((10 10), (20 20)) + + + + + + L - closed + + LINESTRING(10 10, 20 20, 20 10, 10 10) + + + + MULTIPOINT EMPTY + + + + + + L - self-intersecting with boundary + + LINESTRING(40 40, 100 100, 180 100, 180 180, 100 180, 100 100) + + + + MULTIPOINT((40 40), (100 100)) + + + + + + mL - 2 lines with common endpoint + + MULTILINESTRING( + (10 10, 20 20), + (20 20, 30 30)) + + + + MULTIPOINT((10 10), (30 30)) + + + + + + mL - 3 lines with common endpoint + + MULTILINESTRING( + (10 10, 20 20), + (20 20, 30 20), + (20 20, 30 30)) + + + + MULTIPOINT((10 10), (20 20), (30 20), (30 30)) + + + + + + mL - 4 lines with common endpoint + + MULTILINESTRING( + (10 10, 20 20), + (20 20, 30 20), + (20 20, 30 30), + (20 20, 30 40)) + + + + MULTIPOINT((10 10), (30 20), (30 30), (30 40)) + + + + + + mL - 2 lines, one closed, with common endpoint + + MULTILINESTRING( + (10 10, 20 20), + (20 20, 20 30, 30 30, 30 20, 20 20)) + + + + MULTIPOINT((10 10), (20 20)) + + + + + + L - 1 line, self-intersecting, topologically equal to prev case + + MULTILINESTRING( + (10 10, 20 20, 20 30, 30 30, 30 20, 20 20)) + + + + MULTIPOINT((10 10), (20 20)) + + + + + + A - polygon with no holes + + POLYGON( + (40 60, 420 60, 420 320, 40 320, 40 60)) + + + + LINESTRING(40 60, 420 60, 420 320, 40 320, 40 60) + + + + + + A - polygon with 1 hole + + POLYGON( + (40 60, 420 60, 420 320, 40 320, 40 60), + (200 140, 160 220, 260 200, 200 140)) + + + + MULTILINESTRING( + (40 60, 420 60, 420 320, 40 320, 40 60), + (200 140, 160 220, 260 200, 200 140)) + + + + + diff --git a/test/data/jts/general/TestRelateAA.xml b/test/data/jts/general/TestRelateAA.xml new file mode 100644 index 0000000000..b73c12d077 --- /dev/null +++ b/test/data/jts/general/TestRelateAA.xml @@ -0,0 +1,248 @@ + + + + AA disjoint + + POLYGON( + (0 0, 80 0, 80 80, 0 80, 0 0)) + + + POLYGON( + (100 200, 100 140, 180 140, 180 200, 100 200)) + + true + + false + false + + + + AA equal but opposite orientation + + POLYGON( + (0 0, 140 0, 140 140, 0 140, 0 0)) + + + POLYGON( + (140 0, 0 0, 0 140, 140 140, 140 0)) + + + true + + true + true + + + + AA A-shell contains B-shell + + POLYGON( + (40 60, 360 60, 360 300, 40 300, 40 60)) + + + POLYGON( + (120 100, 280 100, 280 240, 120 240, 120 100)) + + + true + + true + true + + + + AA A-shell contains B-shell contains A-hole + + POLYGON( + (40 60, 420 60, 420 320, 40 320, 40 60), + (200 140, 160 220, 260 200, 200 140)) + + + POLYGON( + (80 100, 360 100, 360 280, 80 280, 80 100)) + + + true + + true + false + + + + AA A-shell contains B-shell contains A-hole contains B-hole + + POLYGON( + (0 280, 0 0, 260 0, 260 280, 0 280), + (220 240, 40 240, 40 40, 220 40, 220 240)) + + + POLYGON( + (20 260, 240 260, 240 20, 20 20, 20 260), + (160 180, 80 180, 120 120, 160 180)) + + + true + + true + false + + + + AA A-shell overlapping B-shell + + POLYGON( + (60 80, 200 80, 200 220, 60 220, 60 80)) + + + POLYGON( + (120 140, 260 140, 260 260, 120 260, 120 140)) + + + true + + true + false + + + + AA A-shell overlapping B-shell at B-vertex + + POLYGON( + (60 220, 220 220, 140 140, 60 220)) + + + POLYGON( + (100 180, 180 180, 180 100, 100 100, 100 180)) + + + true + + true + false + + + + AA A-shell overlapping B-shell at A & B-vertex + + POLYGON( + (40 40, 180 40, 180 180, 40 180, 40 40)) + + + POLYGON( + (180 40, 40 180, 160 280, 300 140, 180 40)) + + + true + + true + false + + + + AmA A-shells overlapping B-shell at A-vertex + + POLYGON( + (100 60, 140 100, 100 140, 60 100, 100 60)) + + + MULTIPOLYGON( + ( + (80 40, 120 40, 120 80, 80 80, 80 40)), + ( + (120 80, 160 80, 160 120, 120 120, 120 80)), + ( + (80 120, 120 120, 120 160, 80 160, 80 120)), + ( + (40 80, 80 80, 80 120, 40 120, 40 80))) + + + true + + true + false + + + + AA A-shell touches B-shell, which contains A-hole + + POLYGON( + (40 280, 200 280, 200 100, 40 100, 40 280), + (100 220, 120 220, 120 200, 100 180, 100 220)) + + + POLYGON( + (40 280, 180 260, 180 120, 60 120, 40 280)) + + + true + + true + false + + + + AA - A-hole contains B, boundaries touch in line + + POLYGON( + (0 200, 0 0, 200 0, 200 200, 0 200), + (20 180, 130 180, 130 30, 20 30, 20 180)) + + + POLYGON( + (60 90, 130 90, 130 30, 60 30, 60 90)) + + + true + + true + false + + + + AA - A-hole contains B, boundaries touch in points + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + POLYGON( + (270 90, 200 50, 150 80, 210 120, 270 90)) + + + true + + true + false + + + + AA - A contained completely in B + + POLYGON ((0 0, 20 80, 120 80, -20 120, 0 0)) + + + POLYGON ((60 180, -100 120, -140 60, -40 20, -100 -80, 40 -20, 140 -100, 140 40, 260 160, 80 120, 60 180)) + + + true + + true + false + + + + A/mA A-shells overlapping B-shell at A-vertex + + POLYGON ((100 60, 140 100, 100 140, 60 100, 100 60)) + + + MULTIPOLYGON (((80 40, 120 40, 120 80, 80 80, 80 40)), ((120 80, 160 80, 160 120, 120 120, 120 80)), ((80 120, 120 120, 120 160, 80 160, 80 120)), ((40 80, 80 80, 80 120, 40 120, 40 80))) + + + true + + true + + + diff --git a/test/data/jts/general/TestRelateEmpty.xml b/test/data/jts/general/TestRelateEmpty.xml new file mode 100644 index 0000000000..6ed6128208 --- /dev/null +++ b/test/data/jts/general/TestRelateEmpty.xml @@ -0,0 +1,1114 @@ + + + Test relate predicates against cases containing EMPTY geometries. + + + + + P/P - empty point VS empty point + + POINT EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + P/L - empty point VS empty line + + POINT EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + P/A - empty point VS empty polygon + + POINT EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + P/mP - empty Point VS empty MultiPoint + + POINT EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + P/mL - empty Point VS empty MultiLineString + + POINT EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + P/mA - empty Point VS empty MultiPolygon + + POINT EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + P/GC - empty Point VS empty GC + + POINT EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + L/P - empty line VS empty point + + LINESTRING EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + L/L - empty line VS empty line + + LINESTRING EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + L/A - empty line VS empty polygon + + LINESTRING EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + L/mP - empty line VS empty MultiPoint + + LINESTRING EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + L/mL - empty line VS empty MultiLineString + + LINESTRING EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + L/mA - empty line VS empty MultiPolygon + + LINESTRING EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + L/GC - empty line VS empty GC + + LINESTRING EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + + A/P - empty polygon VS empty line + + POLYGON EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + A/L - empty polygon VS empty line + + POLYGON EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + A/A - empty polygon VS empty polygon + + POLYGON EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + A/mP - empty polygon VS empty MultiPoint + + POLYGON EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + A/mP - empty polygon VS empty MultiLineString + + POLYGON EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + A/mP - empty polygon VS empty MultiPolygon + + POLYGON EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + A/mP - empty polygon VS empty GC + + POLYGON EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + mP/P - empty MultiPoint VS empty point + + MULTIPOINT EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mP/L - empty MultiPoint VS empty line + + MULTIPOINT EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mP/A - empty MultiPoint VS empty polygon + + MULTIPOINT EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mP/mP - empty MultiPoint VS empty MultiPoint + + MULTIPOINT EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mP/mL - empty MultiPoint VS empty MultiLineString + + MULTIPOINT EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mP/mA - empty MultiPoint VS empty MultiPolygon + + MULTIPOINT EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mP/GC - empty MultiPoint VS empty GC + + MULTIPOINT EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + mL/P - empty Multiline VS empty point + + MULTILINESTRING EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mL/L - empty Multiline VS empty line + + MULTILINESTRING EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mL/A - empty Multiline VS empty polygon + + MULTILINESTRING EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mL/mP - empty Multiline VS empty MultiPoint + + MULTILINESTRING EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mL/mL - empty Multiline VS empty MultiLineString + + MULTILINESTRING EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mL/mA - empty Multiline VS empty MultiPolygon + + MULTILINESTRING EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mL/GC - empty Multiline VS empty GC + + MULTILINESTRING EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + + mA/P - empty Multipolygon VS empty line + + MULTIPOLYGON EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mA/L - empty Multipolygon VS empty line + + MULTIPOLYGON EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mA/A - empty Multipolygon VS empty polygon + + MULTIPOLYGON EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mA/mP - empty Multipolygon VS empty MultiPoint + + MULTIPOLYGON EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mA/mL - empty Multipolygon VS empty MultiLineString + + MULTIPOLYGON EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mA/mA - empty Multipolygon VS empty MultiPolygon + + MULTIPOLYGON EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + mA/GC - empty Multipolygon VS empty GC + + MULTIPOLYGON EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + + GC/P - empty GC VS empty line + + GEOMETRYCOLLECTION EMPTY + + + POINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + GC/L - empty GC VS empty line + + GEOMETRYCOLLECTION EMPTY + + + LINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + GC/A - empty GC VS empty polygon + + GEOMETRYCOLLECTION EMPTY + + + POLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + GC/mP - empty GC VS empty MultiPoint + + GEOMETRYCOLLECTION EMPTY + + + MULTIPOINT EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + GC/mL - empty GC VS empty MultiLineString + + GEOMETRYCOLLECTION EMPTY + + + MULTILINESTRING EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + GC/mA - empty GC VS empty MultiPolygon + + GEOMETRYCOLLECTION EMPTY + + + MULTIPOLYGON EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + GC/GC - empty GC VS empty GC + + GEOMETRYCOLLECTION EMPTY + + + GEOMETRYCOLLECTION EMPTY + + true + false + false + false + false + true + true + false + false + false + false + + + + + + P/P - empty Point VS Point + + POINT EMPTY + + + POINT (1 1) + + true + false + false + false + false + true + false + false + false + false + false + + + + + P/L - empty Point VS LineString + + POINT EMPTY + + + LINESTRING (1 1, 2 2) + + true + false + false + false + false + true + false + false + false + false + false + + + + P/L - empty Point VS Polygon + + POINT EMPTY + + + POLYGON ((1 1, 1 2, 2 1, 1 1)) + + true + false + false + false + false + true + false + false + false + false + false + + + + + + diff --git a/test/data/jts/general/TestRelateGC.xml b/test/data/jts/general/TestRelateGC.xml new file mode 100644 index 0000000000..9312e44140 --- /dev/null +++ b/test/data/jts/general/TestRelateGC.xml @@ -0,0 +1,623 @@ + + + + GC:L/GC:PL - a line with the same line in a collection with an empty polygon + + LINESTRING(0 0, 1 1) + + + GEOMETRYCOLLECTION(POLYGON EMPTY, LINESTRING(0 0, 1 1)) + + true + true + true + true + false + false + true + true + false + false + true + + + + A/GC:mP + + POLYGON((-60 -50,-70 -50,-60 -40,-60 -50)) + + + GEOMETRYCOLLECTION(MULTIPOINT((-60 -50),(-63 -49))) + + true + true + false + true + false + false + false + true + false + false + false + + + + A/GC:mP with empty MultiPoint elements + + POLYGON ((3 7, 7 7, 7 3, 3 3, 3 7)) + + + GEOMETRYCOLLECTION (MULTIPOINT (EMPTY, (5 5)), LINESTRING (1 9, 4 9)) + + true + false + false + false + true + false + false + true + false + false + false + + + + mA/GC:PL + + MULTIPOLYGON (((0 0, 3 0, 3 3, 0 3, 0 0))) + + + GEOMETRYCOLLECTION ( LINESTRING (1 2, 1 1), POINT (0 0)) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:PL/A + + GEOMETRYCOLLECTION (POINT (7 1), LINESTRING (6 5, 6 4)) + + + POLYGON ((7 1, 1 3, 3 9, 7 1)) + + true + false + false + false + false + false + false + true + false + true + false + + + + P/GC:PL - point on boundary of GC with line and point + + POINT(0 0) + + + GEOMETRYCOLLECTION(POINT(0 0), LINESTRING(0 0, 1 0)) + + true + false + true + false + false + false + false + true + false + true + false + + + + + L/GC:A - line in interior of GC of overlapping polygons + + LINESTRING (3 7, 7 3) + + + GEOMETRYCOLLECTION (POLYGON ((1 9, 7 9, 7 3, 1 3, 1 9)), POLYGON ((9 1, 3 1, 3 7, 9 7, 9 1))) + + true + false + true + false + false + false + false + true + false + false + true + + + + P/GC:A - point on common boundaries of 2 adjacent polygons + + POINT (4 3) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 4 6, 4 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + false + true + false + false + false + false + true + false + false + true + + + + P/GC:A - point on common node of 3 adjacent polygons + + POINT (5 4) + + + GEOMETRYCOLLECTION (POLYGON ((1 6, 5 4, 4 1, 1 6)), POLYGON ((4 1, 5 4, 9 6, 4 1)), POLYGON ((1 6, 9 6, 5 4, 1 6))) + + true + false + true + false + false + false + false + true + false + false + true + + + + P/GC:A - point on common node of 6 adjacent polygons, with holes at node + + POINT (6 6) + + +GEOMETRYCOLLECTION (POLYGON ((1 9, 5 9, 6 6, 1 5, 1 9), (2 6, 4 8, 6 6, 2 6)), POLYGON ((2 6, 4 8, 6 6, 2 6)), POLYGON ((9 9, 9 5, 6 6, 5 9, 9 9)), POLYGON ((9 1, 5 1, 6 6, 9 5, 9 1), (7 2, 6 6, 8 3, 7 2)), POLYGON ((7 2, 6 6, 8 3, 7 2)), POLYGON ((1 1, 1 5, 6 6, 5 1, 1 1))) + + true + false + true + false + false + false + false + true + false + false + true + + + + P/GC:A - point on common node of 5 adjacent polygons, with holes at node and one not filled + + POINT (6 6) + + +GEOMETRYCOLLECTION (POLYGON ((1 9, 5 9, 6 6, 1 5, 1 9), (2 6, 4 8, 6 6, 2 6)), POLYGON ((2 6, 4 8, 6 6, 2 6)), POLYGON ((9 9, 9 5, 6 6, 5 9, 9 9)), POLYGON ((9 1, 5 1, 6 6, 9 5, 9 1), (7 2, 6 6, 8 3, 7 2)), POLYGON ((1 1, 1 5, 6 6, 5 1, 1 1))) + + true + false + true + false + false + false + false + true + false + true + false + + + + L/GC:A - line on common boundaries of adjacent polygons + + LINESTRING (4 5, 4 2) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 4 6, 4 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + false + true + false + false + false + false + true + false + false + true + + + + L/GC:A - line on exterior boundaries of GC of overlapping polygons + + LINESTRING (2 6, 8 6) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 6 6, 6 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + false + true + false + false + false + false + true + false + true + false + + + + GC:L/GC:A - lines covers boundaries of overlapping polygons + + GEOMETRYCOLLECTION (LINESTRING (2 6, 9 6, 9 1, 7 1), LINESTRING (8 1, 1 1, 1 6, 7 6)) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 6 6, 6 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + false + true + false + false + false + false + true + false + true + false + + + + GC:A/GC:A - adjacent polygons contained by adjacent polygons + + GEOMETRYCOLLECTION (POLYGON ((2 2, 2 5, 4 5, 4 2, 2 2)), POLYGON ((8 2, 4 3, 4 4, 8 5, 8 2))) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 4 6, 4 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + false + true + false + false + false + false + true + false + false + true + + + + GC:A/P - adjacent polygons contain point at interior node + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + POINT (5 5) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:A/P - adjacent polygons contain point on interior edge + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + POINT (7 5) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:A/P - adjacent polygons cover point on exterior node + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + POINT (9 5) + + true + false + false + true + false + false + false + true + false + true + false + + + + GC:A/L - adjacent polygons contain line touching interior node + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + LINESTRING (5 5, 7 7) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:A/L - adjacent polygons contain line along interior edge to boundary + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + LINESTRING (5 5, 9 5) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:A/GC:PL - adjacent polygons contain line and point + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (5 7, 7 7)) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:A/A - adjacent polygons containing polygon with endpoint inside + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + POLYGON ((3 7, 7 7, 7 3, 3 3, 3 7)) + + true + true + false + true + false + false + false + true + false + false + false + + + + GC:A/A - adjacent polygons overlapping polygon with shell outside and hole inside + + GEOMETRYCOLLECTION (POLYGON ((5 5, 2 9, 9 9, 9 5, 5 5)), POLYGON ((3 1, 5 5, 9 5, 9 1, 3 1)), POLYGON ((1 9, 2 9, 5 5, 3 1, 1 1, 1 9))) + + + POLYGON ((0 10, 10 10, 10 0, 0 0, 0 10), (2 8, 8 8, 8 2, 2 2, 2 8)) + + true + false + false + false + false + false + false + true + true + false + false + + + + GC:A/GC:A - overlapping polygons equal to overlapping polygons + + GEOMETRYCOLLECTION (POLYGON ((1 6, 9 6, 9 2, 1 2, 1 6)), POLYGON ((9 1, 1 1, 1 5, 9 5, 9 1))) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 6 6, 6 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + true + true + true + false + false + true + true + false + false + true + + + + GC:A/GC:A - overlapping polygons contained by overlapping polygons + + GEOMETRYCOLLECTION (POLYGON ((4 4, 6 4, 6 3, 4 3, 4 4)), POLYGON ((2 5, 8 5, 8 2, 2 2, 2 5))) + + + GEOMETRYCOLLECTION (POLYGON ((1 1, 1 6, 6 6, 6 1, 1 1)), POLYGON ((9 1, 4 1, 4 6, 9 6, 9 1))) + + true + false + true + false + false + false + false + true + false + false + true + + + + + A/GC:A - polygon equal to nested overlapping polygons + + POLYGON ((1 9, 9 9, 9 1, 1 1, 1 9)) + + + GEOMETRYCOLLECTION ( + POLYGON ((1 1, 1 5, 5 5, 5 1, 1 1)), + GEOMETRYCOLLECTION( + POLYGON ((1 5, 5 9, 9 9, 9 5, 5 1, 1 5)), + MULTIPOLYGON (((1 9, 5 9, 5 5, 1 5, 1 9)), ((9 1, 5 1, 5 5, 9 5, 9 1))) + ) + ) + + true + true + true + true + false + false + true + true + false + false + true + + + + GC:AmP/A - polygon with overlapping points equal to polygon + + GEOMETRYCOLLECTION (POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)), + MULTIPOINT(0 2, 0 5)) + + + POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0)) + + true + true + true + true + false + false + true + true + false + false + true + + true + true + true + false + false + true + true + false + false + true + + + + GC:AL/A - polygon with line in boundary and interior equal to polygon + + GEOMETRYCOLLECTION (POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0)), + LINESTRING (0 2, 0 5, 5 5)) + + + POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0)) + + true + true + true + true + false + false + true + true + false + false + true + + true + true + true + false + false + true + true + false + false + true + + + diff --git a/test/data/jts/general/TestRelateLA.xml b/test/data/jts/general/TestRelateLA.xml new file mode 100644 index 0000000000..482dc7b2a4 --- /dev/null +++ b/test/data/jts/general/TestRelateLA.xml @@ -0,0 +1,215 @@ + + + + LA - intersection at NV: {A-Bdy, A-Int} = {B-Bdy, B-Int} + + LINESTRING(100 120, 100 240) + + + POLYGON( + (40 60, 160 60, 160 180, 40 180, 40 60)) + + + + true + + + + + + LA - intersection at V: {A-Bdy, A-Int} = {B-Bdy, B-Int} + + LINESTRING(80 80, 140 140, 200 200) + + + POLYGON( + (40 40, 140 40, 140 140, 40 140, 40 40)) + + + + true + + + + + + LmA - intersection at NV, L contained in A + + LINESTRING(70 50, 70 150) + + + MULTIPOLYGON( + ( + (0 0, 0 100, 140 100, 140 0, 0 0)), + ( + (20 170, 70 100, 130 170, 20 170))) + + + + true + + + + + + LA - A crosses B at {shell-NV, hole-V} + + LINESTRING(60 160, 150 70) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (110 110, 250 100, 140 30, 110 110)) + + + + true + + + + + + LA - A intersects B at {shell-NV}, B-Int, {hole-V} + + LINESTRING(60 160, 150 70) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (111 110, 250 100, 140 30, 111 110)) + + + + true + + + + + + LA - A crosses B hole at {hole1-V, hole2-NV} + + LINESTRING(80 110, 170 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (130 110, 60 40, 60 180, 130 110), + (130 180, 130 40, 200 110, 130 180)) + + + + true + + + + + + LA - A crosses B hole at {hole1-V}, B-Int, {hole2-NV} + + LINESTRING(80 110, 170 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (130 110, 60 40, 60 180, 130 110), + (130 180, 131 40, 200 110, 130 180)) + + + + true + + + + + +LA - Line with endpoints in interior but crossing exterior of multipolygon + + LINESTRING(160 70, 320 230) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + + + +LA - Line with a very small piece in the exterior between parts of a multipolygon + + LINESTRING(100 140, 100 40) + + + MULTIPOLYGON( + ( + (20 80, 180 79, 100 0, 20 80)), + ( + (20 160, 180 160, 100 80, 20 160))) + + + true + + + + +LA - Line contained completely and spanning parts of multipolygon + + LINESTRING(100 140, 100 40) + + + MULTIPOLYGON( + ( + (20 80, 180 80, 100 0, 20 80)), + ( + (20 160, 180 160, 100 80, 20 160))) + + + true + + + + +LA - overlapping ring and triangle + + LINESTRING(110 60, 20 150, 200 150, 110 60) + + + POLYGON( + (20 20, 200 20, 110 110, 20 20)) + + + true + + + + +LA - closed line / empty polygon + + LINESTRING(110 60, 20 150, 200 150, 110 60) + + + POLYGON EMPTY + + + true + + + + +LA - closed multiline / empty polygon + + MULTILINESTRING ((0 0, 0 1), (0 1, 1 1, 1 0, 0 0)) + + + POLYGON EMPTY + + + true + + + + diff --git a/test/data/jts/general/TestRelateLL.xml b/test/data/jts/general/TestRelateLL.xml new file mode 100644 index 0000000000..592443d1f9 --- /dev/null +++ b/test/data/jts/general/TestRelateLL.xml @@ -0,0 +1,394 @@ + + + + LL - disjoint, non-overlapping envelopes + + LINESTRING(60 0, 20 80, 100 80, 80 120, 40 140) + + + LINESTRING(140 300, 220 160, 260 200, 240 260) + + + + true + + + + + + LL - disjoint, overlapping envelopes + + LINESTRING(60 0, 20 80, 100 80, 80 120, 40 140) + + + LINESTRING(60 40, 140 40, 140 160, 0 160) + + + + true + + + + + + LL - disjoint, non-overlapping envelopes, B closed + + LINESTRING(60 0, 20 80, 100 80, 80 120, 40 140) + + + LINESTRING(140 280, 240 280, 240 180, 140 180, 140 280) + + + + true + + + + + + LL - disjoint, overlapping envelopes, B closed + + LINESTRING(140 0, 0 0, 40 60, 0 120, 60 200, 220 160, 220 40) + + + LINESTRING(80 140, 180 100, 160 40, 100 40, 60 100, 80 140) + + + + true + + + + + + Line vs line - pointwise equal + + LINESTRING(20 20, 80 80) + + + LINESTRING(20 20, 80 80) + + + + true + + + + + + Line vs line - pointwise equal + + LINESTRING(40 40, 160 160, 200 60, 60 140) + + + LINESTRING(40 40, 160 160, 200 60, 60 140) + + + + true + + + + + + Line vs line - topologically equal + + LINESTRING(40 40, 200 40) + + + LINESTRING(200 40, 140 40, 40 40) + + + + true + + + + + + LL - topographically equal with self-intersection + + LINESTRING(0 0, 110 0, 60 0) + + + LINESTRING(0 0, 110 0) + + + + true + + + + + + LmL - topographically equal with no boundary + + LINESTRING(0 0, 0 50, 50 50, 50 0, 0 0) + + + MULTILINESTRING( + (0 0, 0 50), + (0 50, 50 50), + (50 50, 50 0), + (50 0, 0 0)) + + + + true + + + + + + LmL - topographically equal with self intersections + + LINESTRING(0 0, 80 0, 80 60, 80 0, 170 0) + + + MULTILINESTRING( + (0 0, 170 0), + (80 0, 80 60)) + + + + true + + + + + + LL - A-IntNV = B-IntNV + + LINESTRING(80 100, 180 200) + + + LINESTRING(80 180, 180 120) + + + + true + + + + + + intersect in Int NV + + LINESTRING(40 40, 100 100, 160 160) + + + LINESTRING(160 60, 100 100, 60 140) + + + + true + + + + + + LL - intersection: {A-Bdy, A-IntV} = B-IntNV + + LINESTRING(40 40, 100 100, 180 100, 180 180, 100 180, 100 100) + + + LINESTRING(140 60, 60 140) + + + + true + + + + + + LL - intersection: {A-Bdy, A-IntNV} = B-IntNV + + LINESTRING(40 40, 180 180, 100 180, 100 100) + + + LINESTRING(140 60, 60 140) + + + + true + + + + + + LL - intersection: A-IntNV = {B-Bdy, B-IntNV} + + LINESTRING(20 110, 200 110) + + + LINESTRING(200 200, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + + true + + + + + + LL - one segment overlapping, one distinct + + LINESTRING(80 90, 50 50, 0 0) + + + LINESTRING(0 0, 100 100) + + + + true + + + + + + LL - A contained in B + + LINESTRING(40 140, 240 140) + + + LINESTRING(40 140, 100 140, 80 80, 120 60, 100 140, 160 140, 160 100, 200 100, 160 140, + 240 140) + + + + true + + + + + + LL - simple overlapping lines + + LINESTRING(20 20, 100 20, 20 20) + + + LINESTRING(60 20, 200 20) + + + + true + + + + + + LL - A-spiral, B-contained + + LINESTRING(40 60, 180 60, 180 140, 100 140, 100 60, 220 60, 220 180, 80 180, 80 60, + 280 60) + + + LINESTRING(140 60, 180 60, 220 60, 260 60) + + + + true + + + + + +test for LinearRing point location bug + + LINEARRING(0 0, 0 5, 5 5, 5 0, 0 0) + + + LINESTRING( 2 2, 4 4) + + + true + + + + +LL - closed multiline / empty line + + MULTILINESTRING ((0 0, 0 1), (0 1, 1 1, 1 0, 0 0)) + + + LINESTRING EMPTY + + + true + + + + +LL - test intersection node computation (see https://github.com/locationtech/jts/issues/396) + + LINESTRING (1 0, 0 2, 0 0, 2 2) + + + LINESTRING (0 0, 2 2) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +LL - test intersection node computation (see https://github.com/libgeos/geos/issues/933) + + MULTILINESTRING ((0 0, 1 1), (0.5 0.5, 1 0.1, -1 0.1)) + + + LINESTRING (0 0, 1 1) + + + true + + true + false + true + false + false + false + true + false + false + false + + + + LmL - topographically equal with no boundary + + LINESTRING(0 0, 0 50, 50 50, 50 0, 0 0) + + + MULTILINESTRING((0 0, 0 50), (0 50, 50 50), (50 50, 50 0), (50 0, 0 0)) + + + true + + + + + LmL - equal with boundary intersection + + LINESTRING(0 0, 60 0, 60 60, 60 0, 120 0) + + + MULTILINESTRING((0 0, 60 0), (60 0, 120 0), (60 0, 60 60)) + + + true + + + + diff --git a/test/data/jts/general/TestRelatePA.xml b/test/data/jts/general/TestRelatePA.xml new file mode 100644 index 0000000000..4c2345a7d2 --- /dev/null +++ b/test/data/jts/general/TestRelatePA.xml @@ -0,0 +1,284 @@ + + + + PA - disjoint + + POINT(20 20) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + + true + + + false + false + false + false + true + false + false + false + false + false + + + + mPA - points in B: E, I + + MULTIPOINT((0 20), (40 20)) + + + POLYGON( + (20 40, 20 0, 60 0, 60 40, 20 40)) + + + + true + + + false + false + false + true + false + false + true + false + false + false + + + + mPA - points in B: E, B + + MULTIPOINT((0 20), (20 20)) + + + POLYGON((20 40, 20 0, 60 0, 60 40, 20 40)) + + + + true + + + false + false + false + false + false + false + true + false + true + false + + + + mPA - points in B: B, I + + MULTIPOINT((20 20), (40 20)) + + + POLYGON((20 40, 20 0, 60 0, 60 40, 20 40)) + + + + true + + + false + true + false + false + false + false + true + false + false + true + + + + mPA - points in B: I, B, E + + MULTIPOINT((80 260), (140 260), (180 260)) + + + POLYGON((40 320, 140 320, 140 200, 40 200, 40 320)) + + + + true + + + false + false + false + true + false + false + true + false + false + false + + + + PmA - point in B: mod-2 I + + POINT(40 40) + + + MULTIPOLYGON( + ( + (0 40, 0 0, 40 0, 40 40, 0 40)), + ( + (40 80, 40 40, 80 40, 80 80, 40 80))) + + + + true + + + false + true + false + false + false + false + true + false + true + false + + + + mPA - empty MultiPoint element for A + + MULTIPOINT(EMPTY,(0 0)) + + + POLYGON ((1 0,0 1,-1 0,0 -1, 1 0)) + + + + true + + + false + true + false + false + false + false + true + false + false + true + + + + mPA - empty MultiPoint element for A, on boundary of B + + MULTIPOINT(EMPTY,(1 0)) + + + POLYGON ((1 0,0 1,-1 0,0 -1, 1 0)) + + + + true + + + false + true + false + false + false + false + true + false + true + false + + + + mPA - empty MultiPoint element for B + + POLYGON ((1 0,0 1,-1 0,0 -1, 1 0)) + + + MULTIPOINT(EMPTY,(0 0)) + + + + true + + + true + false + true + false + false + false + true + false + false + false + + + + mPA - empty MultiPoint element for B, on boundary of A + + POLYGON ((1 0,0 1,-1 0,0 -1, 1 0)) + + + MULTIPOINT(EMPTY,(1 0)) + + + + true + + + false + false + true + false + false + false + true + false + true + false + + + + PmA - empty MultiPolygon element + + POINT(0 0) + + + MULTIPOLYGON (EMPTY, ((1 0,0 1,-1 0,0 -1, 1 0))) + + + + true + + + false + true + false + false + false + false + true + false + false + true + + + diff --git a/test/data/jts/general/TestRelatePL.xml b/test/data/jts/general/TestRelatePL.xml new file mode 100644 index 0000000000..07ddff57ac --- /dev/null +++ b/test/data/jts/general/TestRelatePL.xml @@ -0,0 +1,123 @@ + + + + PL - disjoint + + POINT(60 120) + + + LINESTRING(40 40, 120 120, 200 120) + + + + true + + + + + + PL - touches Bdy + + POINT(40 40) + + + LINESTRING(40 40, 100 100, 160 100) + + + + true + + + + + + PL - touches non-vertex + + POINT(60 60) + + + LINESTRING(40 40, 100 100) + + + + true + + + + + + mPL - touches Bdy and Ext + + MULTIPOINT((40 40), (100 40)) + + + LINESTRING(40 40, 80 80) + + + + true + + + + + + mPL - touches Int and Bdy + + MULTIPOINT((40 40), (60 60)) + + + LINESTRING(40 40, 80 80) + + + + true + + + + + + mPL - touches Int and Ext + + MULTIPOINT((60 60), (100 100)) + + + LINESTRING(40 40, 80 80) + + + + true + + + + + + mPL - touches IntNV and Ext + + MULTIPOINT((60 60), (100 100)) + + + LINESTRING(40 40, 80 80) + + + + true + + + + + + mPL - touches IntV and Ext + + MULTIPOINT((60 60), (100 100)) + + + LINESTRING(40 40, 60 60, 80 80) + + + + true + + + + + diff --git a/test/data/jts/general/TestRelatePP.xml b/test/data/jts/general/TestRelatePP.xml new file mode 100644 index 0000000000..10bdf362be --- /dev/null +++ b/test/data/jts/general/TestRelatePP.xml @@ -0,0 +1,63 @@ + + + + same point + + POINT(20 20) + + + POINT(20 20) + + + + true + + + + + + different point + + POINT(20 20) + + + POINT(20 30) + + + + true + + + + + + some same, some different points + + MULTIPOINT((40 40), (80 60), (40 100)) + + + MULTIPOINT((40 40), (80 60), (120 100)) + + + + true + + + + + + same points + + MULTIPOINT((40 40), (80 60), (120 100)) + + + MULTIPOINT((40 40), (80 60), (120 100)) + + + + true + + + + + diff --git a/test/data/jts/validate/TestRelateAA-big.xml b/test/data/jts/validate/TestRelateAA-big.xml new file mode 100644 index 0000000000..2d8e9c6e2e --- /dev/null +++ b/test/data/jts/validate/TestRelateAA-big.xml @@ -0,0 +1,34 @@ + + + + +A/A-6-18: a polygon overlapping a very skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-EP}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 100, 100 200, 200 200, 200 100, 100 100)) + + + POLYGON( + (100 100, 1000000000000000 110, 1000000000000000 100, 100 100)) + + + true + + + + +A/A-6-24: a polygon overlapping a very skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-NV}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 100, 120 200, 200 200, 200 100, 120 100)) + + + POLYGON( + (100 100, 1000000000000000 110, 1000000000000000 100, 100 100)) + + + true + + + + diff --git a/test/data/jts/validate/TestRelateAA.xml b/test/data/jts/validate/TestRelateAA.xml new file mode 100644 index 0000000000..a2e530a433 --- /dev/null +++ b/test/data/jts/validate/TestRelateAA.xml @@ -0,0 +1,2833 @@ + + + + +A/A-1-1: same polygons [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-EP = B.A.Bdy.SP-EP}] + + POLYGON( + (20 20, 20 100, 120 100, 140 20, 20 20)) + + + POLYGON( + (20 20, 20 100, 120 100, 140 20, 20 20)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +A/A-1-2: same polygons with reverse sequence of points [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-EP = B.A.Bdy.EP-SP}] + + POLYGON( + (20 20, 20 100, 120 100, 140 20, 20 20)) + + + POLYGON( + (20 20, 140 20, 120 100, 20 100, 20 20)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +A/A-1-3: same polygons with different sequence of points [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-EP = B.A.Bdy.SP-EP}] + + POLYGON( + (20 20, 20 100, 120 100, 140 20, 20 20)) + + + POLYGON( + (120 100, 140 20, 20 20, 20 100, 120 100)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +A/A-1-4: same polygons with different number of points [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-EP = B.A.Bdy.SP-EP}] + + POLYGON( + (20 20, 20 100, 120 100, 140 20, 20 20)) + + + POLYGON( + (20 100, 60 100, 120 100, 140 20, 80 20, 20 20, 20 100)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +A/A-2: different polygons [dim(2){A.A.Int = B.A.Ext}] + + POLYGON( + (0 0, 80 0, 80 80, 0 80, 0 0)) + + + POLYGON( + (100 200, 100 140, 180 140, 180 200, 100 200)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +A/A-3-1-1: the closing point of a polygon touching the closing point of another polygon [dim(0){A.A.Bdy.CP = B.A.Bdy.CP}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (140 120, 140 200, 240 200, 240 120, 140 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-1-2: the closing point of a polygon touching the boundary (at a non-vertex) of another polygon [dim(0){A.A.Bdy.CP = B.A.Bdy.NV}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (80 180, 140 260, 260 200, 200 60, 80 180)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-1-3: the closing point of a polygon touching the boundary (at a vertex) of another polygon [dim(0){A.A.Bdy.CP = B.A.Bdy.V}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (240 80, 140 120, 180 240, 280 200, 240 80)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-1-4: the boundary (at a non-vertex) of a polygon touching the closing point of another polygon [dim(0){A.A.Bdy.NV = B.A.Bdy.CP}] + + POLYGON( + (140 160, 20 20, 270 20, 150 160, 230 40, 60 40, 140 160)) + + + POLYGON( + (140 40, 180 80, 120 100, 140 40)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-1-5: the boundary (at a non-vertex) of a polygon touching the boundary (at a vertex) of another polygon [dim(0){A.A.Bdy.NV = B.A.Bdy.V}] + + POLYGON( + (140 160, 20 20, 270 20, 150 160, 230 40, 60 40, 140 160)) + + + POLYGON( + (120 100, 180 80, 130 40, 120 100)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-1-6: the boundary (at a vertex) of a polygon touching the boundary (at a non-vertex) of another polygon [dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (20 20, 180 20, 140 140, 20 140, 20 20)) + + + POLYGON( + (180 100, 80 200, 180 280, 260 200, 180 100)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-1-7: the boundary (at a vertex) of a polygon touching the boundary (at a vertex) of another polygon [dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (140 140, 20 120, 0 220, 120 240, 140 140)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-1: two polygons touching at multiple points [dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (20 120, 20 20, 260 20, 260 120, 200 40, 140 120, 80 40, 20 120)) + + + POLYGON( + (20 120, 20 240, 260 240, 260 120, 200 200, 140 120, 80 200, 20 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-2: two polygons touching at multiple points [dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (20 120, 20 20, 260 20, 260 120, 180 40, 140 120, 100 40, 20 120)) + + + POLYGON( + (20 120, 300 120, 140 240, 20 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-3: two polygons touching at multiple points [dim(0){A.A.Bdy.CP = B.A.Bdy.NV}, dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (20 20, 20 300, 280 300, 280 260, 220 260, 60 100, 60 60, 280 60, 280 20, + 20 20)) + + + POLYGON( + (100 140, 160 80, 280 180, 200 240, 220 160, 160 200, 180 120, 100 140)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-4: two polygons touching at multiple points [dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (20 20, 20 300, 280 300, 280 260, 220 260, 60 100, 60 60, 280 60, 280 20, + 20 20)) + + + POLYGON( + (260 200, 180 80, 120 160, 200 160, 180 220, 260 200)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-5: two polygons touching at multiple points [dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (20 20, 280 20, 280 140, 220 60, 140 140, 80 60, 20 140, 20 20)) + + + POLYGON( + (0 140, 300 140, 140 240, 0 140)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-6: two polygons touching at multiple points [dim(0){A.A.Bdy.V = B.A.Bdy.V}, dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (20 20, 280 20, 280 140, 220 60, 140 140, 80 60, 20 140, 20 20)) + + + POLYGON( + (20 240, 20 140, 320 140, 180 240, 20 240)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-2-7: two polygons touching at multiple points [dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (20 20, 280 20, 280 140, 220 60, 140 140, 80 60, 20 140, 20 20)) + + + POLYGON( + (20 240, 20 140, 80 180, 140 140, 220 180, 280 140, 280 240, 20 240)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-1: two polygons touching along a boundary [dim(1){A.A.Bdy.SP-V = B.A.Bdy.SP-NV}] + + POLYGON( + (120 120, 180 60, 20 20, 20 120, 120 120)) + + + POLYGON( + (120 120, 220 20, 280 20, 240 160, 120 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-2: two polygons touching along a boundary [dim(1){A.A.Bdy.SP-V = B.A.Bdy.SP-V}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (140 120, 160 20, 260 120, 220 200, 140 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-3: two polygons touching along a boundary [dim(1){A.A.Bdy.SP-V = B.A.Bdy.NV-V}] + + POLYGON( + (20 140, 120 40, 20 40, 20 140)) + + + POLYGON( + (190 140, 190 20, 140 20, 20 140, 190 140)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-4: two polygons touching along a boundary [dim(1){A.A.Bdy.SP-V = B.A.Bdy.NV-V}] + + POLYGON( + (120 120, 180 60, 20 20, 20 120, 120 120)) + + + POLYGON( + (300 20, 220 20, 120 120, 260 160, 300 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-5: two polygons touching along a boundary [dim(1){A.A.Bdy.SP-V = B.A.Bdy.V-EP}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (140 120, 240 160, 280 60, 160 20, 140 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-6: two polygons touching along a boundary [dim(1){A.A.Bdy.SP-V = B.A.Bdy.V-V}] + + POLYGON( + (120 120, 180 60, 20 20, 20 120, 120 120)) + + + POLYGON( + (280 60, 180 60, 120 120, 260 180, 280 60)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-7: two polygons touching along a boundary [dim(1){A.A.Bdy.NV-NV = B.A.Bdy.V-V}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (120 200, 120 120, 40 120, 40 200, 120 200)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-8: two polygons touching along a boundary [dim(1){A.A.Bdy.NV-EP = B.A.Bdy.V-V}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (160 220, 140 120, 60 120, 40 220, 160 220)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-9: two polygons touching along a boundary [dim(1){A.A.Bdy.V-EP = B.A.Bdy.V-SP}] + + POLYGON( + (140 120, 160 20, 20 20, 20 120, 140 120)) + + + POLYGON( + (140 120, 20 120, 20 220, 140 220, 140 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-3-3-10: two polygons touching along a boundary [dim(1){A.A.Bdy.V-V = B.A.Bdy.NV-NV}] + + POLYGON( + (120 120, 180 60, 20 20, 20 120, 120 120)) + + + POLYGON( + (320 20, 220 20, 80 160, 240 140, 320 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/A-5-1: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-EP = B.A.Int}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (60 40, 60 140, 180 140, 180 40, 60 40)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-2-1: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 80 140, 160 60, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-2-2: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (160 60, 20 20, 100 140, 160 60)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-2-3: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.CP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 100, 140 160, 160 40, 20 100)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-2-4: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (160 40, 20 100, 160 160, 160 40)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-2-5: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.V = B.A.Bdy.CP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 180, 180 120, 80 40, 20 180)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-2-6: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (180 120, 100 40, 20 180, 180 120)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-3-1: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 140 40, 140 120, 20 160, 80 80, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-3-2: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 140 40, 140 140, 20 180, 80 100, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-3-3: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (40 180, 60 100, 180 100, 200 180, 120 120, 40 180)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-3-4: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.V = B.A.Bdy.CP}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 180, 60 80, 180 80, 220 180, 120 120, 20 180)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-3-5: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.V = B.A.Bdy.V}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (40 60, 20 180, 100 100, 140 180, 160 120, 220 100, 140 40, 40 60)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-3-6: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.V = B.A.Bdy.V}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (60 100, 180 100, 220 180, 120 140, 20 180, 60 100)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-1: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-NV = B.A.Bdy.SP-V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 20 140, 120 120, 120 40, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-2: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-V = B.A.Bdy.SP-V)}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 20 180, 140 140, 140 60, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-3: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-NV = B.A.Bdy.V-EP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 120 40, 120 120, 20 140, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-4: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-NV = B.A.Bdy.V-V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (120 40, 20 20, 20 140, 120 120, 120 40)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-5: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-V = B.A.Bdy.V-EP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 140 60, 140 140, 20 180, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-6: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-V = B.A.Bdy.V-V}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (140 60, 20 20, 20 180, 140 140, 140 60)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-7: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.NV-EP = B.A.Bdy.V-EP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 60 120, 140 120, 180 20, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-4-8: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.NV-NV = B.A.Bdy.V-EP}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 40, 120 40, 120 120, 20 140, 20 40)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-5-5-1: a polygon containing another polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.SP-V = B.A.Bdy.SP-V}, dim(1){A.A.Bdy.(NV, V) = B.A.Bdy.(V, V)}] + + POLYGON( + (20 20, 20 180, 220 180, 220 20, 20 20)) + + + POLYGON( + (20 20, 20 180, 60 120, 100 180, 140 120, 220 180, 200 120, 140 60, 20 20)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +A/A-6-1: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}] + + POLYGON( + (150 150, 330 150, 250 70, 70 70, 150 150)) + + + POLYGON( + (150 150, 270 150, 140 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-2: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}] + + POLYGON( + (150 150, 270 150, 330 150, 250 70, 190 70, 70 70, 150 150)) + + + POLYGON( + (150 150, 270 150, 190 70, 140 20, 20 20, 70 70, 150 150)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-3: spiky polygons overlapping; boundary <-> boundary intersecting at 0 dimension [dim(2){A.A.Int = B.A.Int}] + + POLYGON( + (20 20, 60 50, 20 40, 60 70, 20 60, 60 90, 20 90, 70 110, 20 130, + 80 130, 20 150, 80 160, 20 170, 80 180, 20 200, 80 200, 30 240, 80 220, 50 260, + 100 220, 100 260, 120 220, 130 260, 140 220, 150 280, 150 190, 160 280, 170 190, 180 280, + 190 190, 200 280, 210 190, 220 280, 230 190, 240 260, 250 230, 260 260, 260 220, 290 270, + 290 220, 330 260, 300 210, 340 240, 290 180, 340 210, 290 170, 350 170, 240 150, 350 150, + 240 140, 350 130, 240 120, 350 120, 240 110, 350 110, 240 100, 350 100, 240 90, 350 90, + 240 80, 350 80, 300 70, 340 60, 290 60, 340 40, 300 50, 340 20, 270 60, 310 20, + 250 60, 270 20, 230 60, 240 20, 210 60, 210 20, 190 70, 190 20, 180 90, 170 20, + 160 90, 150 20, 140 90, 130 20, 120 90, 110 20, 100 90, 100 20, 90 60, 80 20, + 70 40, 20 20)) + + + POLYGON( + (190 140, 140 130, 200 160, 130 150, 210 170, 130 170, 210 180, 120 190, 220 200, + 120 200, 250 210, 120 210, 250 220, 120 220, 250 230, 120 240, 230 240, 120 250, 240 260, + 120 260, 240 270, 120 270, 270 290, 120 290, 230 300, 150 310, 250 310, 180 320, 250 320, + 200 360, 260 330, 240 360, 280 320, 290 370, 290 320, 320 360, 310 320, 360 360, 310 310, + 380 340, 310 290, 390 330, 310 280, 410 310, 310 270, 420 280, 310 260, 430 250, 300 250, + 440 240, 300 240, 450 230, 280 220, 440 220, 280 210, 440 210, 300 200, 430 190, 300 190, + 440 180, 330 180, 430 150, 320 180, 420 130, 300 180, 410 120, 280 180, 400 110, 280 170, + 390 90, 280 160, 400 70, 270 160, 450 30, 260 160, 420 30, 250 160, 390 30, 240 160, + 370 30, 230 160, 360 30, 230 150, 330 50, 240 130, 330 30, 230 130, 310 30, 220 130, + 280 30, 230 100, 270 40, 220 110, 250 30, 210 130, 240 30, 210 100, 220 40, 200 90, + 200 20, 190 100, 180 30, 20 20, 180 40, 20 30, 180 50, 20 50, 180 60, 30 60, + 180 70, 20 70, 170 80, 80 80, 170 90, 20 80, 180 100, 40 100, 200 110, 60 110, + 200 120, 120 120, 190 140)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-4: spiky polygons overlapping; boundary <-> boundary intersecting at 1 dimension at a few locations [dim(2){A.A.Int = B.A.Int}] + + POLYGON( + (70 150, 20 160, 110 160, 20 180, 100 200, 20 200, 190 210, 20 210, 160 220, + 20 220, 150 230, 60 240, 180 250, 20 260, 170 260, 60 270, 160 270, 100 310, 170 280, + 200 260, 180 230, 210 260, 130 330, 230 250, 210 290, 240 250, 230 210, 260 300, 250 230, + 270 300, 270 240, 300 340, 280 250, 320 330, 290 250, 340 350, 290 240, 350 360, 270 190, + 350 340, 290 200, 350 330, 300 190, 360 320, 310 190, 360 300, 320 200, 360 280, 330 200, + 360 260, 340 200, 370 260, 340 180, 390 290, 340 170, 400 260, 350 170, 400 250, 350 160, + 410 240, 350 150, 400 170, 350 140, 310 170, 340 140, 270 180, 330 140, 260 170, 310 140, + 240 170, 290 140, 200 190, 270 140, 180 190, 260 140, 170 190, 260 130, 170 180, 250 130, + 170 170, 240 120, 170 160, 210 120, 170 150, 210 110, 340 130, 230 110, 420 140, 220 100, + 410 130, 220 90, 400 120, 220 80, 390 110, 220 70, 420 110, 240 70, 420 100, 260 70, + 420 90, 280 70, 430 80, 230 60, 430 60, 270 50, 450 40, 210 50, 370 40, 260 40, + 460 30, 160 40, 210 60, 200 110, 190 60, 190 120, 170 50, 180 130, 150 30, 170 130, + 140 20, 160 120, 130 20, 160 150, 120 20, 160 170, 110 20, 160 190, 100 20, 150 190, + 90 20, 140 180, 80 20, 120 140, 70 20, 120 150, 60 20, 110 150, 50 20, 100 140, + 50 30, 90 130, 40 30, 80 120, 30 30, 80 130, 30 40, 80 140, 20 40, 70 140, + 40 90, 60 130, 20 90, 60 140, 20 130, 70 150)) + + + POLYGON( + (190 140, 140 130, 200 160, 130 150, 210 170, 130 170, 210 180, 120 190, 220 200, + 120 200, 250 210, 120 210, 250 220, 120 220, 250 230, 120 240, 230 240, 120 250, 240 260, + 120 260, 240 270, 120 270, 270 290, 120 290, 230 300, 150 310, 250 310, 180 320, 250 320, + 200 360, 260 330, 240 360, 280 320, 290 370, 290 320, 320 360, 310 320, 360 360, 310 310, + 380 340, 310 290, 390 330, 310 280, 410 310, 310 270, 420 280, 310 260, 430 250, 300 250, + 440 240, 300 240, 450 230, 280 220, 440 220, 280 210, 440 210, 300 200, 430 190, 300 190, + 440 180, 330 180, 430 150, 320 180, 420 130, 300 180, 410 120, 280 180, 400 110, 280 170, + 390 90, 280 160, 400 70, 270 160, 450 30, 260 160, 420 30, 250 160, 390 30, 240 160, + 370 30, 230 160, 360 30, 230 150, 330 50, 240 130, 330 30, 230 130, 310 30, 220 130, + 280 30, 230 100, 270 40, 220 110, 250 30, 210 130, 240 30, 210 100, 220 40, 200 90, + 200 20, 190 100, 180 30, 20 20, 180 40, 20 30, 180 50, 20 50, 180 60, 30 60, + 180 70, 20 70, 170 80, 80 80, 170 90, 20 80, 180 100, 40 100, 200 110, 60 110, + 200 120, 120 120, 190 140)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-5: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (60 160, 220 160, 220 20, 60 20, 60 160)) + + + POLYGON( + (60 160, 20 200, 260 200, 220 160, 140 80, 60 160)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-6: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (60 160, 220 160, 220 20, 60 20, 60 160)) + + + POLYGON( + (60 160, 20 200, 260 200, 140 80, 60 160)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-7: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.NV}, dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (60 160, 220 160, 220 20, 60 20, 60 160)) + + + POLYGON( + (20 200, 140 80, 260 200, 20 200)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-8: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.V}, dim(0){A.A.Bdy.V = B.A.Bdy.V}] + + POLYGON( + (60 160, 220 160, 220 20, 60 20, 60 160)) + + + POLYGON( + (20 200, 60 160, 140 80, 220 160, 260 200, 20 200)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-9: a polygon overlapping another polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.CP = B.A.Bdy.V}, dim(0){A.A.Bdy.V = B.A.Bdy.NV}] + + POLYGON( + (60 160, 220 160, 220 20, 60 20, 60 160)) + + + POLYGON( + (20 200, 60 160, 140 80, 260 200, 20 200)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-10: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (0 0, 0 200, 200 200, 200 0, 0 0)) + + + POLYGON( + (100 100, 1000000 110, 10000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-11: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 0, 100 200, 200 200, 200 0, 100 0)) + + + POLYGON( + (100 100, 1000000 110, 10000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-12: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 0, 120 200, 200 200, 200 0, 120 0)) + + + POLYGON( + (100 100, 1000000 110, 10000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-13: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (0 0, 0 200, 110 200, 110 0, 0 0)) + + + POLYGON( + (100 100, 1000000 110, 10000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-14: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-EP}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 100, 100 200, 200 200, 200 100, 100 100)) + + + POLYGON( + (100 100, 2100 110, 2100 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-15: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-EP}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 100, 100 200, 200 200, 200 100, 100 100)) + + + POLYGON( + (100 100, 2101 110, 2101 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-16: two skinny polygons overlapping [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-EP}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 100, 200 200, 200 100, 100 100)) + + + POLYGON( + (100 100, 2101 110, 2101 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-17: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-EP}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 100, 100 200, 200 200, 200 100, 100 100)) + + + POLYGON( + (100 100, 1000000 110, 1000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-19: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-NV}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 100, 120 200, 200 200, 200 100, 120 100)) + + + POLYGON( + (100 100, 500 110, 500 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-20: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-NV}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 100, 120 200, 200 200, 200 100, 120 100)) + + + POLYGON( + (100 100, 501 110, 501 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-21: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-NV}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 100, 130 200, 200 200, 200 100, 120 100)) + + + POLYGON( + (100 100, 501 110, 501 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-22: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-NV}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 100, 17 200, 200 200, 200 100, 120 100)) + + + POLYGON( + (100 100, 501 110, 501 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-23: a polygon overlapping a skinny polygon [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-NV}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (120 100, 120 200, 200 200, 200 100, 120 100)) + + + POLYGON( + (100 100, 1000000 110, 1000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-25: two skinny polygons overlapping [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (101 99, 101 1000000, 102 1000000, 101 99)) + + + POLYGON( + (100 100, 1000000 110, 1000000 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-26: two skinny polygons overlapping [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.Bdy.V-EP = B.A.Bdy.NV-EP}, dim(0){A.A.Bdy.CP = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (100 100, 200 101, 200 100, 100 100)) + + + POLYGON( + (100 100, 2101 110, 2101 100, 100 100)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/A-6-26: two polygons overlapping [dim(2){A.A.Int = B.A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.NV}] + + POLYGON( + (16 319, 150 39, 25 302, 160 20, 265 20, 127 317, 16 319)) + + + POLYGON( + (10 307, 22 307, 153 34, 22 34, 10 307)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +A/Ah-3-1: the closing point of a polygon touching the closing points of another polygon and its hole [dim(0){A.A.Bdy.CP = B.A.oBdy.CP}, dim(0){A.A.Bdy.CP = B.A.iBdy.CP}] + + POLYGON( + (160 200, 210 70, 120 70, 160 200)) + + + POLYGON( + (160 200, 310 20, 20 20, 160 200), + (160 200, 260 40, 70 40, 160 200)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-2: the boundary of a polygon touching the inner boundary of another polygon at two spots [dim(2){A.A.Int = B.A.Ext.h}, dim(0){A.A.oBdy.SP = B.A.iBdy.SP}, dim(0){A.A.oBdy.V = B.A.iBdy.V}] + + POLYGON( + (170 120, 240 100, 260 50, 190 70, 170 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-3: the boundary of a polygon touching the inner boundary of another polygon at two spots [dim(2){A.A.Int = B.A.Ext.h}, dim(0){A.A.oBdy.SP = B.A.iBdy.SP}, dim(0){A.A.oBdy.V = B.A.iBdy.V}] + + POLYGON( + (270 90, 200 50, 150 80, 210 120, 270 90)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-4: the boundary of a polygon touching the inner boundary of another polygon at one spot [dim(2){A.A.Int = B.A.Ext.h}, dim(0){A.A.oBdy.SP = B.A.iBdy.SP}] + + POLYGON( + (170 120, 260 100, 240 60, 150 80, 170 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-5: the boundary of a polygon touching the inner boundary of another polygon at one spot [dim(2){A.A.Int = B.A.Ext.h}, dim(0){A.A.oBdy.SP = B.A.iBdy.NV}] + + POLYGON( + (220 120, 270 80, 200 60, 160 100, 220 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-6: the boundary of a polygon touching the inner boundary of another polygon at one spot [dim(2){A.A.Int = B.A.Ext.h}, dim(0){A.A.oBdy.SP = B.A.iBdy.V}] + + POLYGON( + (260 50, 180 70, 180 110, 260 90, 260 50)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-7: the boundary of a polygon touching the inner boundary of another polygon at two spots [dim(2){A.A.Int = B.A.Ext.h}, dim(0){A.A.oBdy.V = B.A.iBdy.NV}, dim(0){A.A.oBdy.V = B.A.iBdy.NV}] + + POLYGON( + (230 110, 290 80, 190 60, 140 90, 230 110)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-8: the boundary of a polygon touching the inner boundary of another polygon [dim(2){A.A.Int = B.A.Ext.h}, dim(1){A.A.oBdy.SP-EP = B.A.iBdy.SP-EP}] + + POLYGON( + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-9: part of the boundary of a polygon touching part of the inner boundary of another polygon [dim(2){A.A.Int = B.A.Ext.h}, dim(1){A.A.oBdy.SP-V = B.A.iBdy.SP-NV}, dim(1){A.A.oBdy.V-EP = B.A.iBdy.NV-EP}] + + POLYGON( + (170 120, 330 120, 280 70, 120 70, 170 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-10: part of the boundary of a polygon touching part of the inner boundary of another polygon [dim(2){A.A.Int = B.A.Ext.h}, dim(1){A.A.oBdy.SP-V = B.A.iBdy.SP-NV}, dim(1){A.A.oBdy.V-EP = B.A.iBdy.NV-EP}] + + POLYGON( + (170 120, 300 120, 250 70, 120 70, 170 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-3-11: part of the boundary of a polygon touching part of the inner boundary of another polygon [dim(2){A.A.Int = B.A.Ext.h}, dim(1){A.A.oBdy.V-V-V = B.A.iBdy.NV-V-NV}] + + POLYGON( + (190 100, 310 100, 260 50, 140 50, 190 100)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/Ah-5-1: an entire polygon within another polygon which has a hole [dim(2){A.A.Ext = B.A.Int}, dim(2){A.A.Int = B.A.Int}] + + POLYGON( + (280 130, 360 130, 270 40, 190 40, 280 130)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 250 120, 180 50, 100 50, 170 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +A/Ah-5-2: an entire polygon within another polygon which has a hole [dim(2){A.A.Int = B.A.Int}, dim(2){A.A.Ext = B.A.Int}] + + POLYGON( + (220 80, 180 40, 80 40, 170 130, 270 130, 230 90, 300 90, 250 30, 280 30, + 390 140, 150 140, 40 30, 230 30, 280 80, 220 80)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 250 120, 180 50, 100 50, 170 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +A/Ah-5-3: polygon A within polygon B, the boundary of A touching the inner boundary of B [dim(2){A.A.Int = B.A.Int}, dim(2){A.A.Ext = B.A.Int}, dim(1){A.A.Bdy.NV-NV = B.A.iBdy.V-V}] + + POLYGON( + (260 130, 360 130, 280 40, 170 40, 260 130)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 250 120, 180 50, 100 50, 170 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +A/Ah-5-4: polygon A within polygon B, the boundary of A touching the inner boundary of B [dim(2){A.A.Int = B.A.Int}, dim(2){A.A.Ext = B.A.Int}, dim(1){A.A.Bdy.V-V = B.A.iBdy.NV-NV}] + + POLYGON( + (240 110, 340 110, 290 60, 190 60, 240 110)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 250 120, 180 50, 100 50, 170 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +A/Ah-5-5: polygon A within polygon B, the boundary of A touching the inner boundary of B [dim(2){A.A.Int = B.A.Int}, dim(2){A.A.Ext = B.A.Int}, dim(1){A.A.Bdy.V-V = B.A.iBdy.V-V}] + + POLYGON( + (250 120, 350 120, 280 50, 180 50, 250 120)) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 250 120, 180 50, 100 50, 170 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +Ah/Ah-1-1: same polygons (with a hole) [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.oBdy.SP-EP = B.A.oBdy.SP-EP}, dim(1){A.A.iBdy.SP-EP = B.A.iBdy.SP-EP}] + + POLYGON( + (230 210, 230 20, 20 20, 20 210, 230 210), + (120 180, 50 50, 200 50, 120 180)) + + + POLYGON( + (230 210, 230 20, 20 20, 20 210, 230 210), + (120 180, 50 50, 200 50, 120 180)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +A2h/A2h-1-1: same polygons (with two holes) [dim(2){A.A.Int = B.A.Int}, dim(1){A.A.oBdy.SP-EP = B.A.oBdy.SP-EP}, dim(1){A.A.iBdy.SP-EP = B.A.iBdy.SP-EP}] + + POLYGON( + (230 210, 230 20, 20 20, 20 210, 230 210), + (140 40, 40 40, 40 170, 140 40), + (110 190, 210 190, 210 50, 110 190)) + + + POLYGON( + (230 210, 230 20, 20 20, 20 210, 230 210), + (140 40, 40 40, 40 170, 140 40), + (110 190, 210 190, 210 50, 110 190)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +A/mA-3-1: a polygon touching multipolygon at two points [dim(2){A.A.Int = B.2A.Ext}, dim(0){A.A.oBdy.CP = B.2A2.oBdy.NV}, dim(0){A.A.oBdy.V = B.2A1.oBdy.NV}] + + POLYGON( + (280 190, 330 150, 200 110, 150 150, 280 190)) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/mA-3-2: a polygon touching multipolygon at two points [dim(2){A.A.Int = B.2A.Ext}, dim(0){A.A.oBdy.V = B.2A1.oBdy.CP}, dim(0){A.A.oBdy.V = B.2A2.oBdy.V}] + + POLYGON( + (80 190, 220 190, 140 110, 0 110, 80 190)) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/mA-3-3: a polygon touching multipolygon at two points [dim(2){A.A.Int = B.2A.Ext}, dim(0){A.A.oBdy.V = B.2A2.oBdy.NV}, dim(0){A.A.oBdy.V = B.2A1.oBdy.NV}] + + POLYGON( + (330 150, 200 110, 150 150, 280 190, 330 150)) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/mA-3-4: a polygon touching multipolygon at one spoint [dim(2){A.A.Int = B.2A.Ext}, dim(0){A.A.oBdy.V = B.2A2.oBdy.NV}] + + POLYGON( + (290 190, 340 150, 220 120, 170 170, 290 190)) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/mA-3-5: a polygon touching multipolygon along boundaries [dim(2){A.A.Int = B.2A.Ext}, dim(1){A.A.oBdy.SP-V = B.2A2.oBdy.V-V}, dim(1){A.A.oBdy.V-V = B.2A1.oBdy.V-SP}] + + POLYGON( + (220 190, 340 190, 260 110, 140 110, 220 190)) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/mA-3-6: a polygon touching multipolygon along boundaries and at a point [dim(2){A.A.Int = B.2A.Ext}, dim(1){A.A.oBdy.V-NV = B.2A1.oBdy.NV-SP}, dim(0){A.A.oBdy.V = B.2A2.oBdy.V}] + + POLYGON( + (140 190, 220 190, 100 70, 20 70, 140 190)) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +A/mA-6-1: a polygon overlapping multipolygon [dim(2){A.A.Int = B.4A.Int}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}, dim(0){A.A.Bdy.NV = B.A.Bdy.CP}, dim(0){A.A.Bdy.NV = B.A.Bdy.V}, dim(0){A.A.Bdy.NV = B.A.Bdy.CP}] + + POLYGON( + (140 220, 60 140, 140 60, 220 140, 140 220)) + + + MULTIPOLYGON( + ( + (100 20, 180 20, 180 100, 100 100, 100 20)), + ( + (20 100, 100 100, 100 180, 20 180, 20 100)), + ( + (100 180, 180 180, 180 260, 100 260, 100 180)), + ( + (180 100, 260 100, 260 180, 180 180, 180 100))) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +mA/mA-3-1: MultiPolygon touching MultiPolygon [dim(0){A.mA.Bdy.TP = B.mA.Bdy.TP}] + + MULTIPOLYGON( + ( + (110 110, 70 200, 150 200, 110 110)), + ( + (110 110, 150 20, 70 20, 110 110))) + + + MULTIPOLYGON( + ( + (110 110, 160 160, 210 110, 160 60, 110 110)), + ( + (110 110, 60 60, 10 110, 60 160, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mAh/mAh-3-1: MultiPolygon touching MultiPolygon [dim(0){A.mA.Bdy.TP = B.mA.Bdy.TP}] + + MULTIPOLYGON( + ( + (110 110, 70 200, 150 200, 110 110), + (110 110, 100 180, 120 180, 110 110)), + ( + (110 110, 150 20, 70 20, 110 110), + (110 110, 120 40, 100 40, 110 110))) + + + MULTIPOLYGON( + ( + (110 110, 160 160, 210 110, 160 60, 110 110), + (110 110, 160 130, 160 90, 110 110)), + ( + (110 110, 60 60, 10 110, 60 160, 110 110), + (110 110, 60 90, 60 130, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mAh/mAh-3-2: MultiPolygon touching MultiPolygon [dim(1){A.mA.Bdy.NV-EP = B.mA.Bdy.V-SP}, dim(1){A.mA.Bdy.SP-NV = B.mA.Bdy.EP-V}] + + MULTIPOLYGON( + ( + (110 110, 70 200, 200 200, 110 110), + (110 110, 100 180, 120 180, 110 110)), + ( + (110 110, 200 20, 70 20, 110 110), + (110 110, 120 40, 100 40, 110 110))) + + + MULTIPOLYGON( + ( + (110 110, 160 160, 210 110, 160 60, 110 110), + (110 110, 160 130, 160 90, 110 110)), + ( + (110 110, 60 60, 10 110, 60 160, 110 110), + (110 110, 60 90, 60 130, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mAh/mAh-3-3: MultiPolygon touching MultiPolygon [dim(1){A.mA.Bdy.SP-NV = B.mA.Bdy.EP-V}, dim(1){A.mA.Bdy.NV-EP = B.mA.Bdy.V-SP}, dim(1){A.mA.Bdy.NV-EP = B.mA.Bdy.V-SP}, dim(1){A.mA.Bdy.SP-NV = B.mA.Bdy.EP-V}] + + MULTIPOLYGON( + ( + (110 110, 20 200, 200 200, 110 110), + (110 110, 100 180, 120 180, 110 110)), + ( + (110 110, 200 20, 20 20, 110 110), + (110 110, 120 40, 100 40, 110 110))) + + + MULTIPOLYGON( + ( + (110 110, 160 160, 210 110, 160 60, 110 110), + (110 110, 160 130, 160 90, 110 110)), + ( + (110 110, 60 60, 10 110, 60 160, 110 110), + (110 110, 60 90, 60 130, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mAh/mAh-6-1: MultiPolygon touching MultiPolygon [dim(2){A.mA.Int = B.mA.Int}] + + MULTIPOLYGON( + ( + (110 110, 70 200, 200 200, 110 110), + (110 110, 100 180, 120 180, 110 110)), + ( + (110 110, 200 20, 70 20, 110 110), + (110 110, 120 40, 100 40, 110 110))) + + + MULTIPOLYGON( + ( + (110 110, 160 160, 210 110, 160 60, 110 110), + (110 110, 160 130, 160 90, 110 110)), + ( + (110 110, 60 60, 10 110, 60 160, 110 110), + (110 110, 60 90, 60 130, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mAh/mAh-6-2: MultiPolygon touching MultiPolygon [dim(2){A.mA.Int = B.mA.Int}] + + MULTIPOLYGON( + ( + (110 110, 70 200, 200 200, 110 110), + (110 110, 100 180, 120 180, 110 110)), + ( + (110 110, 200 20, 70 20, 110 110), + (110 110, 120 40, 100 40, 110 110))) + + + MULTIPOLYGON( + ( + (110 110, 70 200, 210 110, 70 20, 110 110), + (110 110, 110 140, 150 110, 110 80, 110 110)), + ( + (110 110, 60 60, 10 110, 60 160, 110 110), + (110 110, 60 90, 60 130, 110 110))) + + + true + + false + false + false + false + false + false + true + true + false + false + + + diff --git a/test/data/jts/validate/TestRelateAC.xml b/test/data/jts/validate/TestRelateAC.xml new file mode 100644 index 0000000000..1e4feca114 --- /dev/null +++ b/test/data/jts/validate/TestRelateAC.xml @@ -0,0 +1,36 @@ + + + + +AC A-shells overlapping B-shell at A-vertex + + POLYGON( + (100 60, 140 100, 100 140, 60 100, 100 60)) + + + MULTIPOLYGON( + ( + (80 40, 120 40, 120 80, 80 80, 80 40)), + ( + (120 80, 160 80, 160 120, 120 120, 120 80)), + ( + (80 120, 120 120, 120 160, 80 160, 80 120)), + ( + (40 80, 80 80, 80 120, 40 120, 40 80))) + + + true + + false + false + false + false + false + false + true + true + false + false + + + diff --git a/test/data/jts/validate/TestRelateLA.xml b/test/data/jts/validate/TestRelateLA.xml new file mode 100644 index 0000000000..4f19f28800 --- /dev/null +++ b/test/data/jts/validate/TestRelateLA.xml @@ -0,0 +1,1932 @@ + + + + +L/A-3-1: a line touching the closing point of a polygon [dim(0){A.L.Bdy.SP = B.oBdy.CP}] + + LINESTRING(150 150, 40 230) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A-3-2: the start and end points of a LineString touching the boundary (at non-vertices) of a polygon [dim(0){A.L.Bdy.SP = B.oBdy.NV}, dim(0){A.L.Bdy.EP = B.oBdy.NV}] + + LINESTRING(40 40, 50 130, 130 130) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A-3-3: the end point of a line touching the closing point of a polygon [dim(0){A.L.Bdy.EP = B.oBdy.CP}] + + LINESTRING(40 230, 150 150) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A-3-4: an entire LineString touching the boundary (at non-vertices) of a polygon [dim(1){A.L.Int.SP-EP = B.oBdy.NV-NV}] + + LINESTRING(210 150, 330 150) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +L/A-3-5: the start portion of a LineString touching the boundary (at non-vertices) of a polygon [dim(1){A.L.Int.SP-V = B.oBdy.NV-NV}] + + LINESTRING(200 150, 310 150, 360 220) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A-3-6: the start portion and the end point of a LineString touching the boundary of a polygon [dim(1){A.L.Int.SP-V = B.oBdy.NV-NV}, dim(0){A.L.Bdy.EP = B.A.oBdy.V}] + + LINESTRING(180 150, 250 150, 230 250, 370 250, 410 150) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A-3-7: the middle portion of a LineString touching the boundary (at non-vertices) of a polygon [dim(1){A.L.Int.V-V = B.oBdy.NV-NV}] + + LINESTRING(210 210, 220 150, 320 150, 370 210) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A-4-1: a line at non-vertex crossing non-vertex boundary of polygon [dim(0){A.L.Int.NV = B.A.oBdy.NV}, dim(1){A.L.Int.NV-EP = B.A.Int}] + + LINESTRING(20 60, 150 60) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-4-2: a line at non-vertex crossing non-vertex boundaries of polygon twice [dim(0){A.L.Int.NV = B.A.oBdy.NV}, dim(1){A.L.Int.NV-NV = B.A.Int}] + + LINESTRING(60 90, 310 180) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-4-3: a line at non-vertex crossing vertex boundary of polygon [dim(0){A.L.Int.NV = B.A.oBdy.V}, dim(1){A.L.Int.NV-EP = B.A.Int}] + + LINESTRING(90 210, 210 90) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-4-4: a line at non-vertex crossing vertex boundaries of polygon twice [dim(0){A.L.Int.NV = B.A.oBdy.V}, dim(1){A.L.Int.NV-NV = B.A.Int}, dim(0){A.L.Int.NV = B.A.oBdy.CP}] + + LINESTRING(290 10, 130 170) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-4-5: a line at vertex crossing non-vertex boundary of polygon [dim(0){A.L.Int.V = B.A.oBdy.NV}, dim(1){A.L.Int.V-EP = B.A.Int}] + + LINESTRING(30 100, 100 100, 180 100) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-4-6: a line at vertex crossing non-vertex boundaries of polygon twice [dim(0){A.L.Int.V = B.A.oBdy.NV}, dim(1){A.L.Int.V-V = B.A.Int}] + + LINESTRING(20 100, 100 100, 360 100, 410 100) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-4-7: a line at vertex crossing vertex boundary of polygon [dim(0){A.L.Int.V = B.A.oBdy.V}, dim(1){A.L.Int.V-EP = B.A.Int}] + + LINESTRING(90 210, 150 150, 210 90) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/A-5-1: an entire line within a polygon [dim(1){A.L.Int.SP-EP = B.A.Int}] + + LINESTRING(180 90, 280 120) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/A-5-2: a line within a polygon but the line's both ends touching the boundary of the polygon [dim(1){A.L.Int.SP-EP = B.A.Int}, dim(0){A.L.Bdy.SP = B.oBdy.NV}, dim(0){A.L.Bdy.EP = B.oBdy.NV}] + + LINESTRING(70 70, 80 20) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/A-5-3: a line within a polygon but the line's start point touching the boundary of the polygon [dim(1){A.L.Int.SP-EP = B.A.Int}, dim(0){A.L.Bdy.SP = B.oBdy.NV}] + + LINESTRING(130 20, 150 60) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/A-5-4: a line within a polygon but the line's start point and middle portion touching the boundary of the polygon [dim(1){A.L.Int.SP-V = B.A.Int}, dim(1){A.L.Int.V-V = B.oBdy.NV-NV}, dim(1){A.L.Int.V-EP = B.A.Int}, dim(0){A.L.Bdy.SP = B.A.oBdy.NV}] + + LINESTRING(70 70, 80 20, 140 20, 150 60) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/A-5-5: a line within a polygon but the line's middle portion touching the boundary of the polygon [dim(1){A.L.Int.SP-V = B.A.Int}, dim(1){A.L.Int.V-V = B.A.oBdy.NV-NV}, dim(1){A.L.Int.V-EP = B.A.Int}] + + LINESTRING(170 50, 170 20, 240 20, 260 60) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/Ah-2-1: a line outside a polygon [dim(1){A.L.Int.SP-EP = B.A.Ext}] + + LINESTRING(50 100, 140 190, 280 190) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +L/Ah-2-2: a line inside a polygon's hole [dim(1){A.L.Int.SP-EP = B.A.Ext.h}] + + LINESTRING(140 60, 180 100, 290 100) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +L/Ah-3-1: the start point of a line touching the inner boundary of a polygon [dim(0){A.L.Bdy.SP = B.A.iBdy.CP}, dim(1){A.L.Int.SP-EP = B.A.Ext.h}] + + LINESTRING(170 120, 210 80, 270 80) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-2: both ends of a line touching the inner boundary of a polygon [dim(0){A.L.Bdy.SP = B.A.iBdy.CP}, dim(1){A.L.Int.SP-EP = B.A.Ext.h}, dim(0){A.L.Bdy.SP = B.A.iBdy.CP}] + + LINESTRING(170 120, 260 50) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-1: both ends of a line touching the inner boundary of a polygon [dim(0){A.L.Int.NV = B.A.Bdy.TP}] + + LINESTRING(190 90, 190 270) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (190 190, 280 50, 100 50, 190 190)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-2: a line at a non-vertex crossing the boundary of a polygon where the closing point of the hole touches the shell at a non-vertex [dim(0){A.L.Int.NV = B.A.Bdy.TP}] + + LINESTRING(60 160, 150 70) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (110 110, 250 100, 140 30, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-3: a line at a non-vertex crossing the boundary of a polygon where the hole at a vertex touches the shell at a non-vertex [dim(0){A.L.Int.NV = B.A.Bdy.TP}] + + LINESTRING(60 160, 150 70) + + + POLYGON( + (190 190, 20 20, 360 20, 190 190), + (250 100, 110 110, 140 30, 250 100)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-4: a line at a non-vertex crossing the boundary of a polygon where the hole at a vertex touches the shell at a vertex [dim(0){A.L.Int.NV = B.A.Bdy.TP}] + + LINESTRING(60 160, 150 70) + + + POLYGON( + (190 190, 20 20, 360 20, 190 190), + (250 100, 110 110, 140 30, 250 100)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-5: a line crossing polygon boundary where the closing point of the hole touches the shell at a vertex [dim(0){A.L.Int.V = B.A.Bdy.TP}] + + LINESTRING(190 90, 190 190, 190 270) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (190 190, 280 50, 100 50, 190 190)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-6: a line at a vertex crossing the boundary of a polygon where closing point of the hole touches the shell at a non-vertex [dim(0){A.L.Int.V = B.A.Bdy.TP}] + + LINESTRING(60 160, 110 110, 150 70) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (110 110, 250 100, 140 30, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-7: a line at a vertex crossing the boundary of a polygon where the hole at a vertex touches the shell at a non-vertex [dim(0){A.L.Int.V = B.A.Bdy.TP}] + + LINESTRING(60 160, 110 110, 150 70) + + + POLYGON( + (190 190, 20 20, 360 20, 190 190), + (250 100, 110 110, 140 30, 250 100)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/Ah-3-8: a line at a non-vertex crossing the boundary of a polygon where the hole at a vertex touches the shell at a vertex [dim(0){A.L.Int.V = B.A.Bdy.TP}] + + LINESTRING(60 160, 110 110, 150 70) + + + POLYGON( + (190 190, 110 110, 20 20, 360 20, 190 190), + (250 100, 110 110, 140 30, 250 100)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-1: the start point a line touching the closing points of two connected holes in a polygon [dim(0){A.L.Int.SP = B.A.iBdy.TP}] + + LINESTRING(130 110, 180 110, 190 60) + + + POLYGON( + (20 200, 240 200, 240 20, 20 20, 20 200), + (130 110, 60 180, 60 40, 130 110), + (130 110, 200 40, 200 180, 130 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-2: the interior (at a non-vertex) of a line touching the closing points of two connected holes in a polygon [dim(0){A.L.Int.NV = B.A.iBdy.TP}] + + LINESTRING(80 110, 180 110) + + + POLYGON( + (20 200, 240 200, 240 20, 20 20, 20 200), + (130 110, 60 180, 60 40, 130 110), + (130 110, 200 40, 200 180, 130 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-3: the interior (at a non-vertex) of a line touching the closing point and at a vertex of two connected holes in a polygon [dim(0){A.L.Int.NV = B.A.iBdy1.TP}] + + LINESTRING(80 110, 180 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (60 180, 130 110, 60 40, 60 180), + (130 110, 200 40, 200 180, 130 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-4: the interior (at a non-vertex) of a line touching the closing point and at a non-vertex of two connected holes in a polygon [dim(0){A.L.Int.NV = B.A.iBdy.TP}] + + LINESTRING(80 110, 170 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (130 110, 60 40, 60 180, 130 110), + (130 180, 130 40, 200 110, 130 180)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-5: the start point a line touching the closing point and a non-vertex of two connected holes in a polygon [dim(0){A.L.Int.V = B.A.iBdy.TP}] + + LINESTRING(80 110, 130 110, 170 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (130 110, 60 40, 60 180, 130 110), + (130 180, 130 40, 200 110, 130 180)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-6: the interior (at a vertex) of a line touching the closing points of two connected holes in a polygon [dim(0){A.L.Int.V = B.A.iBdy.TP}] + + LINESTRING(80 110, 130 110, 180 110) + + + POLYGON( + (20 200, 240 200, 240 20, 20 20, 20 200), + (130 110, 60 180, 60 40, 130 110), + (130 110, 200 40, 200 180, 130 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-7: the interior (at a vertex) of a line touching the closing point and at a vertex of two connected holes in a polygon [dim(0){A.L.Int.V = B.A.iBdy1.TP}] + + LINESTRING(80 110, 130 110, 180 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (60 180, 130 110, 60 40, 60 180), + (130 110, 200 40, 200 180, 130 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/A2h-3-8: the interior (at a vertex) of a line touching the closing point and at a non-vertex of two connected holes in a polygon [dim(0){A.L.Int.V = B.A.iBdy.TP}] + + LINESTRING(80 110, 130 110, 170 110) + + + POLYGON( + (20 200, 20 20, 240 20, 240 200, 20 200), + (130 110, 60 40, 60 180, 130 110), + (130 180, 130 40, 200 110, 130 180)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/mA-4-1: a line intersecting the interior and exterior of MultiPolygon [dim(1){A.L.Int.SP-NV = B.2A1.Int}, dim (1){A.L.Int.NV-EP = B.2A2.Int}] + + LINESTRING(160 70, 320 230) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/mA-4-2: a line intersecting the interior and exterior of MultiPolygon [dim(1){A.L.Int.SP-V = B.2A1.Int}, dim (1){A.L.Int.V-EP = B.2A2.Int}] + + LINESTRING(160 70, 200 110, 280 190, 320 230) + + + MULTIPOLYGON( + ( + (140 110, 260 110, 170 20, 50 20, 140 110)), + ( + (300 270, 420 270, 340 190, 220 190, 300 270))) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/mA-5-1: a line within two connected polygons [dim(1){A.L.Int = B.2A.Int}, dim(0){A.L.Int.NV = B.2A.Bdy.TP] + + LINESTRING(70 50, 70 150) + + + MULTIPOLYGON( + ( + (0 0, 0 100, 140 100, 140 0, 0 0)), + ( + (20 170, 70 100, 130 170, 20 170))) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +RL/A-3-1: a LinearRing touching a polygon's closing point [dim(0){A.RL.Int.CP = B.A.Bdy.CP}] + + LINESTRING(110 110, 20 200, 200 200, 110 110) + + + POLYGON( + (20 20, 200 20, 110 110, 20 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +RL/A-3-2: a LinearRing touching a polygon's boundary at a non-vertex [dim(0){A.RL.Int.CP = B.A.Bdy.NV}] + + LINESTRING(150 70, 160 110, 200 60, 150 70) + + + POLYGON( + (20 20, 200 20, 110 110, 20 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +RL/A-3-3: a LinearRing touching a polygon's boundary at a non-vertex [dim(0){A.RL.Int.CP = B.A.iBdy.NV}] + + LINESTRING(80 60, 120 40, 120 70, 80 60) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 50 30, 170 30, 110 90)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +RL/A-3-4: a LinearRing on the boundary of a polygon [dim(1){A.RL.Int.SP-EP = B.A.Bdy.SP-EP}] + + LINESTRING(20 20, 200 20, 110 110, 20 20) + + + POLYGON( + (20 20, 200 20, 110 110, 20 20)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +RL/A-3-5: a LinearRing on the inner boundary of a polygon [dim(1){A.RL.Int.SP-EP = B.A.iBdy.SP-EP}] + + LINESTRING(110 90, 170 30, 50 30, 110 90) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 50 30, 170 30, 110 90)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +RL/A-3-6: a LinearRing on the inner boundary of a polygon [dim(1){A.RL.Int.SP-V = B.A.oBdy.SP-NV}] + + LINESTRING(110 110, 170 50, 170 110, 110 110) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 50 30, 170 30, 110 90)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +RL/A-3-7: a LinearRing on the inner boundary of a polygon [dim(1){A.RL.Int.SP-V = B.A.iBdy.SP-NV}] + + LINESTRING(110 90, 70 50, 130 50, 110 90) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 50 30, 170 30, 110 90)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +RL/A-4-1: a LinearRing crossing a polygon [dim(1){A.RL.Int.CP-NV = B.A.Int}, dim(0){A.L.Int.NV = B.A.Bdy.NV}] + + LINESTRING(110 60, 20 150, 200 150, 110 60) + + + POLYGON( + (20 20, 200 20, 110 110, 20 20)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +RL/A-4-2: a LinearRing crossing a polygon with a hole [dim(1){A.RL.Int.NV-NV = B.A.Int}, dim(0){A.RL.Int.NV = B.A.oBdy.CP}, dim(0){A.RL.Int.NV = B.A.iBdy.CP}, dim(0){A.RL.Int.NV = B.A.oBdy.NV}, dim(0){A.RL.Int.NV = B.A.iBdy.NV}] + + LINESTRING(110 130, 110 70, 200 100, 110 130) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 50 30, 170 30, 110 90)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +RL/A-5-1: a LinearRing within a polygon [dim(1){A.RL.Int.SP-EP = B.A.Int}] + + LINESTRING(110 90, 160 40, 60 40, 110 90) + + + POLYGON( + (20 20, 200 20, 110 110, 20 20)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +RL/A-5-2: a LinearRing within a polygon with a hole [dim(1){A.RL.Int.SP-EP = B.A.Int}] + + LINESTRING(110 100, 40 30, 180 30, 110 100) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 60 40, 160 40, 110 90)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +RL/A-5-3: a LinearRing within a polygon with a hole [dim(1){A.RL.Int.SP-EP = B.A.Int}, dim(0){A.L.Int.CP = B.A.oBdy.CP}] + + LINESTRING(110 110, 180 30, 40 30, 110 110) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 60 40, 160 40, 110 90)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +RL/A-5-4: a LinearRing within a polygon with a hole [dim(1){A.RL.Int.SP-EP = B.A.Int}, dim(0){A.RL.Int.CP = B.A.iBdy.CP}] + + LINESTRING(110 90, 180 30, 40 30, 110 90) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 60 40, 160 40, 110 90)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +RL/A-5-5: a LinearRing within a polygon with a hole [dim(1){A.RL.Int.SP-EP = B.A.Int}, dim(1){A.RL.Int.SP-NV = B.A.Bdy.iBdy.SP-V}] + + LINESTRING(110 90, 50 30, 180 30, 110 90) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110), + (110 90, 60 40, 160 40, 110 90)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +nsL/A-3-1: a non-simple LineString touching a polygon [dim(0){A.nsL.Bdy.SP = B.A.Bdy.CP}] + + LINESTRING(110 110, 200 200, 200 110, 110 200) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +nsL/A-3-2: a non-simple LineString touching a polygon [dim(0){A.nsL.Bdy.SPb = B.A.Bdy.CP}] + + LINESTRING(110 110, 200 200, 110 110, 20 200, 20 110, 200 110) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +nsL/A-3-3: a non-simple LineString touching a polygon [dim(0){A.nsL.Bdy.SPo = B.A.Bdy.CP}] + + LINESTRING(110 110, 20 110, 200 110, 50 110, 110 170) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +nsL/A-3-4: a non-simple LineString touching a polygon [dim(0){A.nsL.Bdy.SPx = B.A.Bdy.CP}] + + LINESTRING(110 110, 20 200, 110 200, 110 110, 200 200) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +nsL/A-3-5: a non-simple LineString touching a polygon [dim(1){A.nsL.Int.SPb-Vo = B.A.Bdy.SP-NV}] + + LINESTRING(110 110, 170 50, 20 200, 20 110, 200 110) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +nsL/A-4-1: a non-simple LineString crossing a polygon [dim(1){A.nsL.Int.V-V-NV = B.A.Int}, dim(1){A.nsL.SPx-V = B.A.Bdy.SP-NV}] + + LINESTRING(110 110, 180 40, 110 40, 110 180) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL/A-5-1: a non-simple LineString within a polygon [dim(1){A.nsL.Int.SPx-EP = B.A.Int}] + + LINESTRING(110 60, 50 30, 170 30, 90 70) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +nsL/A-5-2: a non-simple LineString within a polygon [dim(1){A.nsL.Int.SPx-EP = B.A.Int}, dim(1){A.nsL.Int.SPx-V = B.A.Bdy.SP-NV}] + + LINESTRING(110 110, 180 40, 110 40, 110 110, 70 40) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +nsL/Ah: the self-crossing point of a non-simple LineString touching the closing point of the inner boundary of a polygon [dim(0){A.nsL.Int.V = B.A.iBdy.CP}] + + LINESTRING(230 70, 170 120, 190 60, 140 60, 170 120, 270 90) + + + POLYGON( + (150 150, 410 150, 280 20, 20 20, 150 150), + (170 120, 330 120, 260 50, 100 50, 170 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/A-3-1: MultiLineString touching a polygon's closing point [dim(0){A.mL.Bdy.SPb = B.A.Bdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (200 200, 110 110, 20 210, 110 110)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/A-3-2: MultiLineString touching a polygon's closing point [dim(0){A.mL.Bdy.SPo = B.A.Bdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (60 180, 60 110, 160 110, 110 110)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/A-3-3: MultiLineString touching a polygon's closing point [dim(0){A.mL.Bdy.SPx = B.A.Bdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (200 200, 110 110, 20 200, 110 200, 110 110)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/A-4-1: MultiLineString crossing a polygon [dim(1){A.mL.Int.SP-NVb = B.A.Int}, dim(0){A.mL.Int.NVb = B.A.Bdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (110 50, 110 170, 110 70, 110 150, 200 150)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/A-4-2: MultiLineString crossing a polygon [dim(1){A.mL.Int.SP-NVo = B.A.Int}, dim(0){A.mL.Int.NVo = B.A.Bdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (50 110, 170 110, 110 170, 110 50, 110 170, 110 50)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/A-4-3: MultiLineString crossing a polygon [dim(1){A.mL.Int.SP-NVx = B.A.Int}, dim(0){A.mL.Int.NVx = B.A.Bdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (110 60, 110 160, 200 160)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/A-4-4: MultiLineString crossing a polygon [dim(1){A.mL.Int.Vb-Vb = B.A.Int}, dim(0){A.mL.Int.Vb = B.A.oBdy.CP}, dim(0){A.mL.Int.Vb = B.A.iBdy.CP}] + + MULTILINESTRING( + (20 110, 200 110), + (110 60, 110 160, 200 160)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/A-5-1: MultiLineString within a polygon [dim(1){A.mL.Int.SP-EP = B.A.Int}] + + MULTILINESTRING( + (110 100, 40 30, 180 30), + (170 30, 110 90, 50 30)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mL/A-5-2: MultiLineString within a polygon [dim(1){A.mL.Int.SP-EP = B.A.Int}] + + MULTILINESTRING( + (110 110, 60 40, 70 20, 150 20, 170 40), + (180 30, 40 30, 110 80)) + + + POLYGON( + (110 110, 200 20, 20 20, 110 110)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mL/mA-3-1: MultiLineString within a MultiPolygon [dim(0){A.mL.Bdy.SPb = B.mA.Bdy.TP}] + + MULTILINESTRING( + (20 110, 200 110, 200 160), + (110 110, 200 110, 200 70, 20 150)) + + + MULTIPOLYGON( + ( + (110 110, 20 20, 200 20, 110 110)), + ( + (110 110, 20 200, 200 200, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mA-3-2: MultiLineString within a MultiPolygon [dim(0){A.mL.Bdy.SPo = B.mA.Bdy.TP}] + + MULTILINESTRING( + (20 160, 70 110, 150 110, 200 160), + (110 110, 20 110, 50 80, 70 110, 200 110)) + + + MULTIPOLYGON( + ( + (110 110, 20 20, 200 20, 110 110)), + ( + (110 110, 20 200, 200 200, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mA-3-3: MultiLineString within a MultiPolygon [dim(0){A.mL.Bdy.SPx = B.mA.Bdy.TP}] + + MULTILINESTRING( + (20 110, 200 110), + (110 110, 20 170, 20 130, 200 90)) + + + MULTIPOLYGON( + ( + (110 110, 20 20, 200 20, 110 110)), + ( + (110 110, 20 200, 200 200, 110 110))) + + + true + + false + false + false + false + false + false + true + false + true + false + + + diff --git a/test/data/jts/validate/TestRelateLC.xml b/test/data/jts/validate/TestRelateLC.xml new file mode 100644 index 0000000000..0bb1205fb6 --- /dev/null +++ b/test/data/jts/validate/TestRelateLC.xml @@ -0,0 +1,57 @@ + + + + +LC - topographically equal with no boundary + + LINESTRING(0 0, 0 50, 50 50, 50 0, 0 0) + + + MULTILINESTRING( + (0 0, 0 50), + (0 50, 50 50), + (50 50, 50 0), + (50 0, 0 0)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +LC - equal with boundary intersection + + LINESTRING(0 0, 60 0, 60 60, 60 0, 120 0) + + + MULTILINESTRING( + (0 0, 60 0), + (60 0, 120 0), + (60 0, 60 60)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + diff --git a/test/data/jts/validate/TestRelateLL.xml b/test/data/jts/validate/TestRelateLL.xml new file mode 100644 index 0000000000..8124b25ab6 --- /dev/null +++ b/test/data/jts/validate/TestRelateLL.xml @@ -0,0 +1,3388 @@ + + + + +L/L.1-3-1: touching at the start points of two lines [dim(0){A.L.Bdy.SP = B.L.Bdy.SP}] + + LINESTRING(40 40, 120 120) + + + LINESTRING(40 40, 60 120) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.1-3-2: start point of one line touching end point of another line [dim(0){A.L.Bdy.SP = B.L.Bdy.EP}] + + LINESTRING(40 40, 120 120) + + + LINESTRING(60 240, 40 40) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.1-3-3: start point of a line touching the interior of another line at a non-vertex [dim(0){A.L.Bdy.SP = B.L.Int.NV}] + + LINESTRING(40 40, 180 180) + + + LINESTRING(120 120, 20 200) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.1-3-4: touching at the end points of two lines [dim(0){A.L.Bdy.EP = B.L.Bdy.EP}] + + LINESTRING(40 40, 120 120) + + + LINESTRING(60 240, 120 120) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.1-3-5: end point of a line touching the interior of another line at a non-vertex [dim(0){A.L.Bdy.EP = B.L.Int.NV}] + + LINESTRING(40 40, 180 180) + + + LINESTRING(20 180, 140 140) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.1-4-1: two lines crossing at non-vertex [dim(0){A.L.Int.NV = B.L.Int.NV}] + + LINESTRING(40 40, 120 120) + + + LINESTRING(40 120, 120 40) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.1-1-1: equal pointwise [dim(1){A.L.Int.SP-EP = B.L.Int.SP-EP}] + + LINESTRING(40 40, 100 100) + + + LINESTRING(40 40, 100 100) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.1-1-2: equal lines but points in reverse sequence [dim(1){A.L.Int.SP-EP = B.L.Int.EP-SP}] + + LINESTRING(40 40, 100 100) + + + LINESTRING(100 100, 40 40) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.1-2-1: dim(1){A.L.Int.SP-EP = B.L.Ext} + + LINESTRING(40 40, 120 120) + + + LINESTRING(40 120, 120 160) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +L/L.1-5-1: line A containing line B [dim(1){A.L.Int.SP-EP = B.L.Int.SP-EP}] + + LINESTRING(20 20, 180 180) + + + LINESTRING(20 20, 180 180) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.1-5-2: line B is part of line A [dim(1){A.L.Int.SP-NV) = B.L.Int.SP-EP}] + + LINESTRING(20 20, 180 180) + + + LINESTRING(20 20, 110 110) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +L/L.1-5-3: Line B is part of line A (in the middle portion) [dim(1){A.L.Int.NV-NV = B.L.Int.SP-EP}] + + LINESTRING(20 20, 180 180) + + + LINESTRING(50 50, 140 140) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +L/L.1-6-1: start portions of two lines overlapping [dim(1){A.L.Int.SP-NV = B.L.Int.SP-NV] + + LINESTRING(180 180, 40 40) + + + LINESTRING(120 120, 260 260) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.1-6-2: end portions of two lines overlapping [dim(1){A.L.Int.NV-EP = B.L.Int.NV-EP] + + LINESTRING(40 40, 180 180) + + + LINESTRING(260 260, 120 120) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.1-6-3: end portion of line A overlapping the start portion of line B [dim(1){A.L.Int.NV-EP = B.L.Int.SP-NV] + + LINESTRING(40 40, 180 180) + + + LINESTRING(120 120, 260 260) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-3-1: two LineStrings touching at start points [dim(0){A.L.Bdy.SP = B.L.Bdy.SP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(40 40, 20 100, 40 160, 20 200) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-2: start point of LineStrings A touching the end point of LineString B [dim(0){A.L.Bdy.SP = B.L.Bdy.EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(20 200, 40 160, 20 100, 40 40) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-3: two LineStrings touching at end points [dim(0){A.L.Bdy.EP = B.L.Bdy.EP}] + + LINESTRING(80 240, 200 120, 100 100, 40 40) + + + LINESTRING(20 200, 40 160, 20 100, 40 40) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-4: both the start and end points of LineString A touching the interior of LineString B at two vertices [dim(0){A.L.Bdy.SP = B.L.Int.V}, dim(0){A.L.Bdy.EP = B.L.Int.V}] + + LINESTRING(60 60, 60 230, 140 230, 250 160) + + + LINESTRING(20 20, 60 60, 250 160, 310 230) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-5: both the start and end points of LineString A touching the interior of LineString B at two non-vertices [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Bdy.EP = B.L.Int.NV}] + + LINESTRING(60 60, 60 230, 140 230, 250 160) + + + LINESTRING(20 20, 110 110, 200 110, 320 230) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-6: the start and end points of two LineStrings touching each other [dim(0){A.L.Bdy.SP = B.L.Bdy.SP}, dim(0){A.L.Bdy.EP = B.L.Bdy.EP}] + + LINESTRING(60 110, 60 250, 360 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-7: the start and end points of two LineStrings touching each other [dim(0){A.L.Bdy.SP = B.L.Bdy.EP}, dim(0){A.L.Bdy.EP = B.L.Bdy.SP}] + + LINESTRING(60 110, 60 250, 360 210) + + + LINESTRING(360 210, 310 160, 110 160, 60 110) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-8: start point of LineString B touching LineString A at a non-vertex [dim(0){A.L.Int.NV = B.L.Bdy.SP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(160 160, 240 240) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-9: end point of LineString B touching LineString A at a non-vertex [dim(0){A.L.Int.NV = B.L.Bdy.EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(240 240, 160 160) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-10: both the start and end points of LineString B touching the interior of LineString A at two non-vertices [dim(0){A.L.Int.NV = B.L.Bdy.SP}, dim(0){A.L.Int.NV = B.L.Bdy.EP}] + + LINESTRING(60 60, 60 230, 140 230, 250 160) + + + LINESTRING(60 150, 110 100, 170 100, 110 230) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-11: the start point of LineString B touching the interior of LineString A at a non-vertex and the end point of LineString A touching the interior of LineString B at a vertex [dim(0){A.L.Int.NV = B.L.Bdy.SP}, dim(0){A.L.Bdy.EP = B.L.Int.V}] + + LINESTRING(60 60, 60 230, 140 230, 250 160) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-12: start point of LineString B touching LineString A at a vertex [dim(0){A.L.Int.V = B.L.Bdy.SP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(200 120, 200 190, 150 240, 200 240) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-13: end point of LineString B touching LineString A at a vertex [dim(0){A.L.Int.V = B.L.Bdy.EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(200 240, 150 240, 200 200, 200 120) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-3-14: both the start and end points of LineString B touching the interior of LineString A at two vertices [dim(0){A.L.Int.V = B.L.Bdy.SP}, dim(0){A.L.Int.V = B.L.Bdy.EP}] + + LINESTRING(60 60, 60 230, 140 230, 250 160) + + + LINESTRING(60 230, 80 140, 120 140, 140 230) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/L.2-4-1: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Bdy.SP}, dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(60 110, 200 110, 250 160, 300 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-2: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Int.SP}, dim(0){A.L.Int.V = B.L.Int.V}, dim(0){A.L.Bdy.EP = B.L.Int.EP}] + + LINESTRING(60 110, 200 110, 250 160, 300 210, 360 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-3: two LineStrings crossing on one side [dim(0){A.L.Bdy.SP = B.L.Bdy.SP}, dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(60 110, 220 110, 250 160, 280 110) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-4: two LineStrings crossing on one side [dim(0){A.L.Bdy.SP = B.L.Int.SP}, dim(0){A.L.Int.V = B.L.Int.NV}, dim(0){A.L.Bdy.EP = B.L.Int.EP}] + + LINESTRING(60 110, 150 110, 200 160, 250 110, 360 110, 360 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-5: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(130 160, 160 110, 220 110, 250 160, 250 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-6: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Int.NV = B.L.Int.NV}] + + LINESTRING(130 160, 160 110, 190 110, 230 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-7: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Int.NV}, dim(0){A.L.Bdy.SP = B.L.Bdy.EP}] + + LINESTRING(130 160, 160 110, 200 110, 230 160, 260 210, 360 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-8: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Bdy.EP}] + + LINESTRING(130 160, 160 110, 200 110, 230 160, 260 210, 360 210, 380 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-9: two LineStrings crossing at three points [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Int.NV}, dim(0){A.L.Int.NV = B.L.Bdy.EP}] + + LINESTRING(130 160, 160 110, 200 110, 230 160, 260 210, 380 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-10: two LineStrings crossing at two points [dim(0){A.L.Bdy.SP = B.L.Int.V}, dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(110 160, 160 110, 200 110, 250 160, 250 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-11: two LineStrings crossing on one side [dim(0){A.L.Bdy.SP = B.L.Int.V}, dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(110 160, 180 110, 250 160, 320 110) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-12: two LineStrings crossing on one side [dim(0){A.L.Bdy.SP = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Int.NV}] + + LINESTRING(140 160, 180 80, 220 160, 250 80) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-13: two LineStrings crossing at a vertex for one of the LineStrings [dim(0){A.L.Int.V = B.L.Int.NV}] + + LINESTRING(40 40, 100 100, 200 120, 130 190) + + + LINESTRING(20 130, 70 130, 160 40) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-14: two LineStrings crossing at non-vertices for both of the LineStrings [dim(0){A.L.Int.NV = B.L.Int.NV}] + + LINESTRING(40 40, 100 100, 200 120, 130 190) + + + LINESTRING(40 160, 40 100, 110 40, 170 40) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-15: two LineStrings crossing on one side [dim(0){A.L.Int.V = B.L.Int.NV}, dim(0){A.L.Int.V = B.L.Int.NV}] + + LINESTRING(130 110, 180 160, 230 110, 280 160, 330 110) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-16: two LineStrings crossing at vertices for both LineString [dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(40 40, 100 100, 200 120, 130 190) + + + LINESTRING(30 140, 80 140, 100 100, 200 30) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-17: two LineStrings crossing on one side [dim(0){A.L.Int.V = B.L.Int.V}, dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(110 110, 110 160, 180 110, 250 160, 250 110) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-18: multiple crossings [dim(0){A.L.Int.V = B.L.Int.V}, dim(0){A.L.Int.NV = B.L.Int.NV}] + + LINESTRING(20 20, 80 80, 160 80, 240 80, 300 140) + + + LINESTRING(20 60, 60 60, 60 140, 80 80, 100 20, 140 140, 180 20, 200 80, 220 20, + 240 80, 300 80, 270 110, 200 110) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-4-19: spiky LineStrings with multiple crossing [dim(0){A.L.Int.V = B.L.Int.V}] + + LINESTRING(20 20, 230 20, 20 30, 170 30, 20 40, 230 40, 20 50, 230 60, 60 60, + 230 70, 20 70, 180 80, 60 80, 230 90, 20 90, 230 100, 30 100, 210 110, 20 110, + 80 120, 20 130, 170 130, 90 120, 230 130, 170 140, 230 140, 80 150, 160 140, 20 140, + 70 150, 20 150, 230 160, 80 160, 230 170, 20 160, 180 170, 20 170, 230 180, 20 180, + 40 190, 230 190, 20 200, 230 200) + + + LINESTRING(30 210, 30 60, 40 210, 40 30, 50 190, 50 20, 60 160, 60 50, 70 220, + 70 50, 80 20, 80 210, 90 50, 90 150, 100 30, 100 210, 110 20, 110 190, 120 50, + 120 180, 130 210, 120 20, 140 210, 130 50, 150 210, 130 20, 160 210, 140 30, 170 210, + 150 20, 180 210, 160 20, 190 210, 180 80, 170 50, 170 20, 180 70, 180 20, 190 190, + 190 30, 200 210, 200 30, 210 210, 210 20, 220 150, 220 20) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/L.2-1-1: two equal LineStrings with equal pointwise [dim(1){A.L.Int.SP-EP = B.L.Int.SP-EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.2-1-2: two equal LineStrings with points in reverse sequence [dim(1){A.L.Int.SP-EP = B.L.Int.EP-SP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(80 240, 200 120, 100 100, 40 40) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.2-1-3: two equal LineStrings with different number of points [dim(1){A.L.Int.SP-EP = B.L.Int.EP-SP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(80 240, 120 200, 200 120, 100 100, 80 80, 40 40) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.2-2-1: disjoint [dim(1){A.L.Int.SP-EP = B.L.Ext}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(260 210, 240 130, 280 120, 260 40) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +L/L.2-2-2: wrapping around but still disjoint [dim(1){A.L.Int.SP-EP = B.L.Ext}] + + LINESTRING(100 20, 20 20, 20 160, 210 160, 210 20, 110 20, 50 120, 120 150, 200 150) + + + LINESTRING(140 130, 100 110, 120 60, 170 60) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +L/L.2-5-1: LineString A containing LineString B, same pointwise [dim(1){A.L.Int.SP-EP = B.L.Int.SP-EP}] + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.2-5-2: LineString A containing LineString B, LineString A with less points [dim(1){A.L.Int.SP-V = B.L.Int.SP-EP}] + + LINESTRING(60 110, 110 160, 310 160, 360 210) + + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +L/L.2-5-3: LineString A containing LineString B [dim(1){A.L.Int.SP-V = B.L.Int.SP-EP}] + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + LINESTRING(60 110, 110 160, 250 160) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +L/L.2-5-4: LineString A containing LineString B [dim(1){A.L.Int.NV-NV = B.L.Int.SP-EP}] + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + LINESTRING(110 160, 310 160, 340 190) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +L/L.2-5-5: LineString A containing LineString B [dim(1){A.L.Int.V-NV = B.L.Int.SP-EP}] + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + LINESTRING(140 160, 250 160, 310 160, 340 190) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +L/L.2-5-6: LineString A containing LineString B [dim(1){A.L.Int.V-V = B.L.Int.SP-EP}] + + LINESTRING(60 110, 110 160, 250 160, 310 160, 360 210) + + + LINESTRING(110 160, 250 160, 310 160) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +L/L.2-6-1: start portions of two LineStrings overlapping [dim(1){A.L.Int.SP-V = B.L.Int.SP-V}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(200 120, 100 100, 40 40, 140 80, 200 40) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-2: start portion of LineString A overlapping end portion of LineString B, intersecting at the middle of LineString A [dim(1){A.L.Int.SP-V = B.L.Int.V-EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(280 240, 240 140, 200 120, 100 100, 40 40) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-3: start portion of LineString A overlapping end portion of LineString B, intersecting at the middle of LineString A [dim(1){A.L.Int.SP-V = B.L.Int.NV-EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(80 190, 140 140, 40 40) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-4: end portions of two LineStrings overlapping [dim(1){A.L.Int.NV-EP = B.L.Int.V-EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(240 200, 200 260, 80 240, 140 180) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-5: end portion of LineString A overlapping start portion of LineString B [dim(1){A.L.Int.NV-EP = B.L.Int.SP-V}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(140 180, 80 240, 200 260, 240 200) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-6: end portion of LineString A overlapping end portion of LineString B, intersecting at the middle of LineString A [dim(1){A.L.Int.V-EP = B.L.Int.V-EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(280 240, 240 140, 200 120, 80 240) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-7: middle portions of two LineStrings overlapping [dim(1){A.L.Int.V-NV = B.L.Int.NV-V}] + + LINESTRING(20 20, 80 80, 160 80, 240 80, 300 140) + + + LINESTRING(20 80, 120 80, 200 80, 260 20) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-8: middle portion of LineString A overlapping start portion of LineString B [dim(1){A.L.Int.V-V = B.L.Int.SP-V}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(100 100, 200 120, 240 140, 280 240) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-9: middle portion of LineString A overlapping end portion of LineString B [dim(1){A.L.Int.V-V = B.L.Int.V-EP}] + + LINESTRING(40 40, 100 100, 200 120, 80 240) + + + LINESTRING(280 240, 240 140, 200 120, 100 100) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-10: middle portions of two LineStrings overlapping [dim(1){A.L.Int.V-V = B.L.Int.V-V}] + + LINESTRING(20 20, 80 80, 160 80, 240 80, 300 140) + + + LINESTRING(80 20, 80 80, 240 80, 300 20) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/L.2-6-11: middle portions of two LineStrings overlapping, multiple intersects [dim(1){A.L.Int.V-V = B.L.Int.V-NV}, dim(1){A.L.Int.V-V = B.L.Int.V-NV}, dim(1){A.L.Int.V-V = B.L.Int.V-NV}] + + LINESTRING(20 20, 80 80, 160 80, 240 80, 300 140) + + + LINESTRING(20 80, 80 80, 120 80, 140 140, 160 80, 200 80, 220 20, 240 80, 270 110, + 300 80) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/LR-3-1: a LineString touching a LinearRing [dim(0){A.L.Bdy.SP = B.LR.Int.CP}] + + LINESTRING(100 100, 20 180, 180 180) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/LR-4-1: a LineString crossing a LinearRing [dim(0){A.L.Int.NV = B.LR.Int.CP}] + + LINESTRING(20 100, 180 100, 100 180) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/LR-4-2: a LineString crossing a LinearRing [dim(0){A.L.Int.NV = B.LR.Int.CP}] + + LINESTRING(100 40, 100 160, 180 160) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/LR-4-3: a LineString crossing a LinearRing [dim(0){A.L.Int.V = B.LR.Int.CP}] + + LINESTRING(20 100, 100 100, 180 100, 100 180) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/LR-5-1: a LineString within a LinearRing [dim(1){A.L.Int.SP-EP = B.LR.Int.SP-NV}] + + LINESTRING(100 100, 160 40) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/LR-5-2: a LineString within a LinearRing [dim(1){A.L.Int.SP-EP = B.LR.Int.SP-NV}] + + LINESTRING(100 100, 180 20) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/LR-5-3: a LineString within a LinearRing [dim(1){A.L.Int.SP-V-EP = B.LR.Int.NV-CP-NV}] + + LINESTRING(60 60, 100 100, 140 60) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +L/LR-6-1: a LineString crossing a LinearRing [dim(1){A.L.Int.SP-NV = B.LR.Int.SP-V}] + + LINESTRING(100 100, 190 10, 190 100) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/LR-6-2: a LineString crossing a LinearRing [dim(1){A.L.Int.SP-V = B.LR.Int.SP-NV}] + + LINESTRING(100 100, 160 40, 160 100) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/LR-6-3: a LineString crossing a LinearRing [dim(1){A.L.Int.NV-V = B.LR.Int.SP-NV}] + + LINESTRING(60 140, 160 40, 160 140) + + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's end point with both crossing and overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Bdy.EPb}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(80 80, 20 80, 140 80, 80 20, 80 140) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's end point with overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Bdy.EPo}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(80 80, 20 80, 140 80) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's end point with crossing line segments [dim(0){A.L.Int.NV = B.nsL.Bdy.EPx}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(80 80, 140 80, 80 20, 80 140) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's closing point with both crossing and overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Int.CPb}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(80 80, 20 80, 140 80, 80 20, 80 80) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's closing point with overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Int.CPo}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(80 80, 20 80, 140 80, 80 80) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's closing point with crossing line segments [dim(0){A.L.Int.NV = B.nsL.Int.CPx}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(80 80, 20 80, 20 140, 140 20, 80 20, 80 80) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a non-vertex [dim(0){A.L.Int.NV = B.nsL.Int.NV}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(20 140, 140 20, 100 20, 100 80) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a non-vertex with both crossing and overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Int.NVb}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 20 80, 120 80, 80 20, 80 140) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a non-vertex with overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Int.NVo}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 20 80, 140 80) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a non-vertex with crossing line segments [dim(0){A.L.Int.NV = B.nsL.Int.NVx}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 20 80, 80 140, 80 20) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a vertex [dim(0){A.L.Int.NV = B.nsL.Int.V}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 80 80, 20 80, 50 140, 50 60) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a vertex with both crossing and overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Int.Vb}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 20 80, 120 80, 80 20, 80 80, 80 140) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a vertex with overlapping line segments [dim(0){A.L.Int.NV = B.nsL.Int.Vo}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 20 80, 80 80, 140 80) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL: A line's interior at a non-vertex intersecting a non-simple linestring's interior at a vertex with crossing line segments [dim(0){A.L.Int.NV = B.nsL.Int.Vx}] + + LINESTRING(20 20, 140 140) + + + LINESTRING(140 80, 20 80, 80 140, 80 80, 80 20) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +L/nsL.1-3-1: start point of a LineString touching the self-intersecting point of a non-simple LineString [dim(0){A.L.Bdy.SP = B.nsL.Bdy.EPx}] + + LINESTRING(130 150, 220 150, 220 240) + + + LINESTRING(130 240, 130 150, 220 20, 50 20, 130 150) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.1-3-2: the interior of a LineString touching the self-intersecting point of a non-simple LineString [dim(0){A.L.Int.V = B.nsL.Bdy.EPx}] + + LINESTRING(30 150, 130 150, 250 150) + + + LINESTRING(130 240, 130 150, 220 20, 50 20, 130 150) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.1-3-3: the interior of a LineString touching the self-intersecting point of a non-simple LineString [dim(0){A.L.Int.NV = B.nsL.Bdy.EPx}] + + LINESTRING(30 150, 250 150) + + + LINESTRING(130 240, 130 150, 220 20, 50 20, 130 150) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.1-3-4: the interior of a LineString touching the self-intersecting point of a non-simple LineString [dim(0){A.L.Int.V = B.nsL.Bdy.EPx}] + + LINESTRING(30 150, 130 150, 250 150) + + + LINESTRING(130 240, 130 20, 30 20, 130 150) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.1-4: a Line crossing a non-simple LineString at non-vertices [dim(0){A.L.Int.NV = B.nsL.Int.NV}] + + LINESTRING(30 150, 250 150) + + + LINESTRING(120 240, 120 20, 20 20, 120 170) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL.5/L-3-1: switching the geometries for case L/nsL.5-3-1 [dim(0){A.nsL.Bdy.EPx = B.L.Bdy.SP}] + + LINESTRING(200 200, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + LINESTRING(110 110, 200 110) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.5-3-2: the start point of a line touching the self-intersecting and self-crossing point of a non-simple LineString [dim(0){A.L.Bdy.SP = B.nsL.Bdy.EPx}] + + LINESTRING(110 110, 200 110) + + + LINESTRING(200 200, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.5-3-3: the interior of a line touching the self-intersecting and self-crossing point of a non-simple LineString [dim(0){A.L.Int.NV = B.nsL.Bdy.EPx}] + + LINESTRING(20 110, 200 110) + + + LINESTRING(200 200, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +nsL.5/L-3-4 touches dim(0){A.nsL.Bdy.EPx = B.L.Int.NV} + + LINESTRING(200 200, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + LINESTRING(20 110, 200 110) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +L/nsL.10-6-1: the middle portion of a line overlapping from the self-intersecting to the self-crossing a non-simple LineString [dim(1){A.L.Int.V-V = B.nsL.Int.EPx-NVx}] + + LINESTRING(90 200, 90 130, 110 110, 150 200) + + + LINESTRING(200 200, 20 20, 200 20, 20 200, 20 130, 90 130) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/nsL.10-6-2: the middle portion of a line overlapping from the self-intersecting to the self-crossing a non-simple LineString [dim(1){A.L.Int.V-V = B.nsL.Int.NVx-EPx}] + + LINESTRING(200 110, 110 110, 90 130, 90 200) + + + LINESTRING(200 200, 20 20, 200 20, 20 200, 20 130, 90 130) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +L/mL-3-1: a line's end point touching a non-vertex with crossing line segments of a MultiLineString [dim(0){A.L.Bdy.SP = B.mL.Int.NVx] + + LINESTRING(80 80, 150 80, 210 80) + + + MULTILINESTRING( + (20 20, 140 140), + (20 140, 140 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +LR/LR-1-1: two equal LinearRings, pointwise [dim(1){A.LR.Int.SP-EP = B.LR.Int.SP-EP}, dim(0){A.LR.Int.CP = B.LR.Int.CP}] + + LINESTRING(40 80, 160 200, 260 20, 40 80) + + + LINESTRING(40 80, 160 200, 260 20, 40 80) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +LR/LR-1-2: two equal LinearRings with points in reverse sequence [dim(1){A.LR.Int.SP-EP = B.LR.Int.EP-SP}, dim(0){A.LR.Int.CP = B.LR.Int.CP}] + + LINESTRING(40 80, 160 200, 260 20, 40 80) + + + LINESTRING(40 80, 260 20, 160 200, 40 80) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +LR/LR-1-3: two equal LinearRings with points in different sequence [dim(1){A.LR.Int.SP-EP = B.LR.Int.SP-EP}, dim(0){A.LR.Int.CP = B.LR.Int.V}, dim(0){A.LR.Int.V = B.LR.Int.CP}] + + LINESTRING(40 80, 160 200, 260 20, 40 80) + + + LINESTRING(260 20, 40 80, 160 200, 260 20) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +LR/LR-1-4: two equal LinearRings with different number of points [dim(1){A.LR.Int.SP-EP = B.LR.Int.SP-EP}, dim(0){A.LR.Int.CP = B.LR.Int.V}, dim(0){A.LR.Int.NV = B.LR.Int.CP}] + + LINESTRING(40 80, 160 200, 260 20, 40 80) + + + LINESTRING(100 140, 160 200, 260 20, 40 80, 100 140) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +LR/LR-4-1: two LinearRings crossing at closing points [dim(0){A.LR.Int.CP = B.LR.Int.CP}] + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + LINESTRING(100 100, 180 180, 20 180, 100 100) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +LR/LR-4-2: two LinearRings crossing at two points [dim(0){A.LR.Int.CP = B.LR.Int.CP}, dim(0){A.LR.Int.V = B.LR.Int.V},] + + LINESTRING(40 150, 40 40, 150 40, 150 150, 40 150) + + + LINESTRING(40 150, 150 40, 170 20, 170 190, 40 150) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +LR/LR-4-3: two LinearRings crossing at the closing and a non-vertex [dim(0){A.LR.Int.CP = B.LR.Int.NV}] + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + LINESTRING(180 100, 20 100, 100 180, 180 100) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +LR/LR-4-4: two LinearRings crossing at the closing and a vertex [dim(0){A.LR.Int.CP = B.LR.Int.V}] + + LINESTRING(100 100, 180 20, 20 20, 100 100) + + + LINESTRING(180 180, 100 100, 20 180, 180 180) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +LR/LR-4-5: two LinearRings crossing at a vertex and a non-vertex [dim(0){A.LR.Int.V = B.LR.Int.NV}] + + LINESTRING(20 180, 100 100, 20 20, 20 180) + + + LINESTRING(100 20, 100 180, 180 100, 100 20) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +LR/LR-4-6: two LinearRings crossing at two points [dim(0){A.LR.Int.V = B.LR.Int.NV}, dim(0){A.LR.Int.V = B.LR.Int.NV},] + + LINESTRING(40 150, 40 40, 150 40, 150 150, 40 150) + + + LINESTRING(170 20, 20 170, 170 170, 170 20) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +LR/LR-6-1: two LinearRings overlapping [dim(1){A.LR.Int.CP-V = B.LR.Int.CP-V}] + + LINESTRING(40 150, 40 40, 150 40, 150 150, 40 150) + + + LINESTRING(40 150, 150 150, 90 210, 40 150) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +LR/LR-6-2: two LinearRings overlapping [dim(1){A.LR.Int.CP-V = B.LR.Int.NV-NV}] + + LINESTRING(40 150, 40 40, 150 40, 150 150, 40 150) + + + LINESTRING(20 150, 170 150, 90 230, 20 150) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +LR/LR-6-3: two LinearRings overlapping [dim(1){A.LR.Int.(V-V-V-EP) = B.LR.Int.(NV-V-V-SP)}] + + LINESTRING(40 150, 40 40, 150 40, 150 150, 40 150) + + + LINESTRING(40 150, 150 150, 150 40, 20 40, 20 150, 40 150) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +LR/nsL-3-1: a LinearRing touching a non-simple LineString [dim(0){A.nsL.Int.CP = B.nsL.Bdy.SPb}] + + LINESTRING(110 110, 200 20, 20 20, 110 110) + + + LINESTRING(110 110, 200 200, 110 110, 20 200, 20 110, 200 110) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +LR/nsL-3-1: a LinearRing touching a non-simple LineString [dim(0){A.nsL.Int.CP = B.nsL.Bdy.SPo}] + + LINESTRING(110 110, 200 20, 20 20, 110 110) + + + LINESTRING(110 110, 20 110, 200 110, 50 110, 110 170) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +LR/nsL-3-1: a LinearRing touching a non-simple LineString [dim(0){A.nsL.Int.CP = B.nsL.Bdy.SPx}] + + LINESTRING(110 110, 200 20, 20 20, 110 110) + + + LINESTRING(110 110, 20 200, 110 200, 110 110, 200 200) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +LR/nsL-6-1: a LinearRing and a non-simple LineString overlapping [dim(1){A.nsL.Int.SP-V = B.nsL.Int.NVx-SP}] + + LINESTRING(110 110, 200 20, 20 20, 110 110) + + + LINESTRING(200 20, 20 200, 200 200, 110 110, 110 40) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +LR/nsL-6-2: a LinearRing and a non-simple LineString overlapping [dim(1){A.nsL.Int.SP-V = B.nsL.Int.NVx-SP}, dim(1){A.nsL.Int.V-EP = B.nsL.Int.EP-NVx}] + + LINESTRING(110 110, 200 20, 20 20, 110 110) + + + LINESTRING(200 20, 20 200, 200 200, 20 20) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +nsL/nsL-4-1: non-simple LineStrings crossing at closing points [dim(0){A.nsL.Int.CP = B.nsL.Int.CP}] + + LINESTRING(110 110, 20 110, 110 20, 20 20, 110 110) + + + LINESTRING(110 110, 200 200, 110 200, 200 110, 110 110) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL/nsL-4-2: non-simple LineStrings crossing at two points without vertices [dim(0){A.nsL.Int.NV = B.nsL.Int.NV}] + + LINESTRING(20 120, 120 120, 20 20, 120 20, 20 120) + + + LINESTRING(170 100, 70 100, 170 170, 70 170, 170 100) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL/nsL-4-3: non-simple LineStrings crossing at a point [dim(0){A.nsL.Int.NV = B.nsL.Int.V}] + + LINESTRING(20 110, 110 110, 20 20, 110 20, 20 110) + + + LINESTRING(110 160, 70 110, 60 160, 20 130, 110 160) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL/nsL-4-4: non-simple LineStrings crossing at self-crossing points [dim(0){A.nsL.Int.NVx = B.nsL.Int.NVx}] + + LINESTRING(20 200, 200 200, 20 20, 200 20, 20 200) + + + LINESTRING(20 110, 200 110, 200 160, 20 60, 20 110) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL/nsL-4-5: non-simple LineStrings crossing at vertices [dim(0){A.nsL.Int.V = B.nsL.Int.V}] + + LINESTRING(20 110, 110 110, 20 20, 110 20, 20 110) + + + LINESTRING(200 200, 110 110, 200 110, 110 200, 200 200) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +nsL/nsL-4-6: non-simple LineStrings crossing at two points with vertices [dim(0){A.nsL.Int.V = B.nsL.Int.V}] + + LINESTRING(20 120, 120 120, 20 20, 120 20, 20 120) + + + LINESTRING(220 120, 120 20, 220 20, 120 120, 220 120) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/mL-1: MultiLineString [dim(1){A.mL.Int.SP-EP = B.mL.Int.SP-EP}] + + MULTILINESTRING( + (70 20, 20 90, 70 170), + (70 170, 120 90, 70 20)) + + + MULTILINESTRING( + (70 20, 20 90, 70 170), + (70 170, 120 90, 70 20)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mL/mL-1-1: non-simple MultiLineString [dim(1){A.mL.Int.SP-EP = B.mL.Int.SP-EP}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mL/mL-1-2: equal non-simple MultiLineString with different sequence of lines and points [dim(1){A.mL.Int.SP-EP = B.mL.Int.EP-SP}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 140, 90 60, 90 20), + (170 20, 130 20, 20 20)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mL/mL-3-1: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Bdy.SPb}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (170 60, 90 20, 20 60), + (130 100, 130 60, 90 20, 50 90)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-2: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Bdy.SPo}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (130 140, 130 60, 90 20, 20 90, 90 20, 130 60, 170 60)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-3: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Bdy.SPx}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (170 60, 90 20, 20 60)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-4: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Bdy.SPx}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (170 60, 90 20, 20 60), + (130 100, 90 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-5: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Bdy.SPx}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (170 60, 90 20, 20 60), + (120 100, 170 100, 90 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-6: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Int.SPb}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (170 60, 90 20, 20 60), + (120 100, 170 100, 90 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-7: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Int.SPo}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (130 140, 130 60, 90 20, 20 90, 90 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-3-8: non-simple MultiLineStrings touching at boundaries [dim(0){A.mL.Bdy.SPx = B.mL.Int.SPx}] + + MULTILINESTRING( + (20 20, 90 20, 170 20), + (90 20, 90 80, 90 140)) + + + MULTILINESTRING( + (90 20, 170 100, 170 140), + (170 60, 90 20, 20 60, 20 140, 90 20)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mL/mL-4-1: non-simple MultiLineStrings crossing [dim(0){A.mL.Int.Vx = B.mL.Int.Vb}] + + MULTILINESTRING( + (20 20, 90 90, 20 160), + (90 160, 90 20)) + + + MULTILINESTRING( + (160 160, 90 90, 160 20), + (160 120, 120 120, 90 90, 160 60)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/mL-4-2: non-simple MultiLineStrings crossing [dim(0){A.mL.Int.Vx = B.mL.Int.Vo}] + + MULTILINESTRING( + (20 20, 90 90, 20 160), + (90 160, 90 20)) + + + MULTILINESTRING( + (160 160, 90 90, 160 20), + (160 120, 120 120, 90 90, 120 60, 160 60)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mL/mL-4-3: non-simple MultiLineStrings crossing [dim(0){A.mL.Int.Vx = B.mL.Int.Vx}] + + MULTILINESTRING( + (20 20, 90 90, 20 160), + (90 160, 90 20)) + + + MULTILINESTRING( + (160 160, 90 90, 160 20), + (160 120, 90 90, 160 60)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + diff --git a/test/data/jts/validate/TestRelatePA.xml b/test/data/jts/validate/TestRelatePA.xml new file mode 100644 index 0000000000..7d864c36bd --- /dev/null +++ b/test/data/jts/validate/TestRelatePA.xml @@ -0,0 +1,1018 @@ + + + + +P/A-2-1: a point outside a polygon [dim(0){A.P.Int = B.A.Ext}] + + POINT(20 20) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/A-2-2: a point outside a convex polygon [dim(0){A.P.Int = B.A.Ext}] + + POINT(70 170) + + + POLYGON( + (110 230, 80 160, 20 160, 20 20, 200 20, 200 160, 140 160, 110 230)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/A-2-3: a point outside a concave polygon [dim(0){A.P.Int = B.A.Ext}] + + POINT(110 130) + + + POLYGON( + (20 160, 80 160, 110 100, 140 160, 200 160, 200 20, 20 20, 20 160)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/A-2-4: dim(0){A.P.Int = B.A.Ext} + + POINT(100 70) + + + POLYGON( + (20 150, 100 150, 40 50, 170 50, 110 150, 190 150, 190 20, 20 20, 20 150)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/A-2-5: a point outside a concave polygon [dim(0){A.P.Int = B.A.Ext}] + + POINT(100 70) + + + POLYGON( + (20 150, 90 150, 40 50, 160 50, 110 150, 180 150, 180 20, 20 20, 20 150)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/A-3-1: a point on the closing point of a polygon [dim(0){A.P.Int = B.A.Bdy.CP}] + + POINT(60 120) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/A-3-2: a point on the boudary of a polygon at a non-vertex [dim(0){A.P.Int = B.A.Bdy.NV}] + + POINT(110 120) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/A-3-3: a point on the boundary of a polygon at a vertex [dim(0){A.P.Int = B.A.Bdy.V] + + POINT(160 120) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/A-5: a point on the interior of a polygon [dim(0){A.P.Int = B.A.Int}] + + POINT(100 80) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/Ah-2-1: a point outside of polygon with a hole [dim(0){A.P.Int = B.A.Ext}] + + POINT(60 160) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/Ah-2-2: a point inside the hole of the polygon [dim(0){A.P.Int = B.A.Ext.h}] + + POINT(190 90) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/Ah-3-1: a point on the closing point of the outer boundary of a polygon with a hole [dim(0){A.P.Int = B.A.oBdy.CP}] + + POINT(190 190) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/Ah-3-2: a point on the outer boundary of a polygon at a vertex [dim(0){A.P.Int = B.A.oBdy.V}] + + POINT(360 20) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/Ah-3-3: a point on the outer boundary of a polygon at a non-vertex [dim(0){A.P.Int = B.A.oBdy.NV}] + + POINT(130 130) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/Ah-3-4: a point on the closing point of the inner boundary of a polygon [dim(0){A.P.Int = B.A.iBdy.CP}] + + POINT(280 50) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/Ah-3-5: a point on the inner boundary of a polygon at a non-vertex [dim(0){A.P.Int = B.A.iBdy.NV}] + + POINT(150 100) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/Ah-3-6: a point on the inner boundary of a polygon at a vertex [dim(0){A.P.Int = B.A.iBdy.V}] + + POINT(100 50) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/Ah-5: a point inside the interior of a polygon with a hole [dim(0){A.P.Int = B.A.Int}] + + POINT(140 120) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/A2h-3-1: a point on the touching point of two holes in a polygon [dim(0){A.P.Int = B.A.iBdy.TP}] + + POINT(190 50) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (90 50, 150 110, 190 50, 90 50), + (190 50, 230 110, 290 50, 190 50)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/A2h-3-2: a point on the touching point of two holes in a polygon [dim(0){A.P.Int = B.A.iBdy.TP}] + + POINT(180 90) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (180 140, 180 40, 80 40, 180 140), + (180 90, 210 140, 310 40, 230 40, 180 90)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +mP/A-2: 3 points outside a polygon [dim(0){A.2P.Int = B.A.Ext}] + + MULTIPOINT((20 80), (110 160), (20 160)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +mP/A-3-1: one of 3 points on the closing point of the boundary of a polygon [dim(0){A.3P1.Int = B.A.Bdy.CP}] + + MULTIPOINT((20 80), (60 120), (20 160)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mP/A-3-2: one of 3 points on the boundary of a polygon at a non-vertex [dim(0){A.3P3 = B.A.Bdy.NV}] + + MULTIPOINT((10 80), (110 170), (110 120)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mP/A-3-3: one of 3 points on the boundary of a polygon at a vertex [dim(0){A.3P1.Int = B.A.Bdy.V}] + + MULTIPOINT((10 80), (110 170), (160 120)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mP/A-3-4: 3 of the 5 points on the boundary of a polygon [dim(0){A.5P2.Int = B.A.Bdy.CP}, dim(0){A.5P3.Int = B.A.Bdy.NV}, dim(0){A.5P4.Int = B.A.Bdy.V}] + + MULTIPOINT((20 120), (60 120), (110 120), (160 120), (200 120)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mP/A-3-5: all 3 points on the boundary of a polygon [dim(0){A.3P1.Int = B.A.Bdy.CP}, dim(0){A.3P2.Int = B.A.Bdy.NV}, dim(0){A.3P3.Int = B.A.Bdy.V}] + + MULTIPOINT((60 120), (110 120), (160 120)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +mP/A-3-6: all 4 points on the boundary of a polygon [dim(0){A.4P = B.A.Bdy}] + + MULTIPOINT((60 120), (160 120), (160 40), (60 40)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +mP/A-4-1: 1 point outside a polygon, 1 point on the boundary and 1 point inside [dim(0){A.3P1.Int = B.A.Ext}, dim(0){A.3P2.Int = B.A.Bdy.CP}, dim(0){A.3P3.Int = B.A.Int}] + + MULTIPOINT((20 150), (60 120), (110 80)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/A-4-2: 1 point outside a polygon, 1 point on the boundary and 1 point inside [dim(0){A.3P1.Int = B.A.Ext}, dim(0){A.3P2.Int = B.A.Bdy.V}, dim(0){A.3P3.Int = B.A.Int}] + + MULTIPOINT((110 80), (160 120), (200 160)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/A-4-3: 1 point outside a polygon, 1 point on the boundary and 1 point inside [dim(0){A.3P1.Int = B.A.Ext}, dim(0){A.3P2.Int = B.A.Bdy.NV}, dim(0){A.3P3.Int = B.A.Int}] + + MULTIPOINT((110 80), (110 120), (110 160)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/A-4-4: 1 point outside a polygon, 1 point inside [dim(0){A.2P1.Int = B.A.Ext}, dim(0){A.2P2.Int = B.A.Int}] + + MULTIPOINT((110 170), (110 80)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/A-4-5: 1 point outside a polygon, 2 points on the boundary and 1 point inside [dim(0){A.4P1.Int = B.A.Ext}, dim(0){A.4P2.Int = B.A.Bdy.CP}, dim(0){A.4P3.Int = B.A.Bdy.V}, dim(0){A.4P4.Int = B.A.Int}] + + MULTIPOINT((60 120), (160 120), (110 80), (110 170)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/A-5-1: 2 points within a polygon [dim(0){A.2P.Int = B.A.Int] + + MULTIPOINT((90 80), (130 80)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/A-5-2: 1 point on the boundary and 1 point inside a polygon [dim(0){A.2P1.Int = B.A.Bdy.CP}, dim(0){A.2P2.Int = B.A.Int}] + + MULTIPOINT((60 120), (160 120), (110 80)) + + + POLYGON( + (60 120, 60 40, 160 40, 160 120, 60 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/Ah-2-1: 3 points outside a polygon [dim(0){A.3P.Int = B.Ah.Ext}] + + MULTIPOINT((40 170), (40 90), (130 170)) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +mP/Ah-2-2: 2 points outside a polygon and 1 point inside the hole of the polygon [dim(0){A.3P1.Int = B.Ah.Ext}, dim(0){A.3P2.Int = B.Ah.Ext}, dim(0){A.3P3.Int = B.Ah.Ext.h}] + + MULTIPOINT((90 170), (280 170), (190 90)) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +mP/Ah-2-3: all 3 points in polygon's hole [dim(0){A.3P.Int = B.Ah.Ext.h}] + + MULTIPOINT((190 110), (150 70), (230 70)) + + + POLYGON( + (190 190, 360 20, 20 20, 190 190), + (280 50, 100 50, 190 140, 280 50)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/mA-3-1: a point on the touching point of two polygons [dim(0){A.P.Int = B.2A.Bdy}] + + POINT(100 100) + + + MULTIPOLYGON( + ( + (20 100, 20 20, 100 20, 100 100, 20 100)), + ( + (100 180, 100 100, 180 100, 180 180, 100 180))) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/mA-3-2: a point on the boundary of one of the 2 polygons [dim(0){A.P.Int = B.2A1.Bdy.CP}] + + POINT(20 100) + + + MULTIPOLYGON( + ( + (20 100, 20 20, 100 20, 100 100, 20 100)), + ( + (100 180, 100 100, 180 100, 180 180, 100 180))) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/mA-3-3: a point on the boundary of one of the 2 polygons [dim(0){A.P.Int = B.2A1.Bdy.V}] + + POINT(60 100) + + + MULTIPOLYGON( + ( + (20 100, 20 20, 100 20, 100 100, 20 100)), + ( + (100 180, 100 100, 180 100, 180 180, 100 180))) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/mA-3-4: a point touching a polygon's boundary where the boundaries touch at a point [dim(0){A.P.Int = B.2A.Bdy.TP}] + + POINT(110 110) + + + MULTIPOLYGON( + ( + (110 110, 20 200, 200 200, 110 110), + (110 110, 80 180, 140 180, 110 110)), + ( + (110 110, 20 20, 200 20, 110 110), + (110 110, 80 40, 140 40, 110 110))) + + + true + + false + true + false + false + false + false + true + false + true + false + + + diff --git a/test/data/jts/validate/TestRelatePL.xml b/test/data/jts/validate/TestRelatePL.xml new file mode 100644 index 0000000000..30972a07e2 --- /dev/null +++ b/test/data/jts/validate/TestRelatePL.xml @@ -0,0 +1,2286 @@ + + + + +P/L-2: a point and a line disjoint [dim(0){A.P.Int = B.L.Ext}] + + POINT(110 200) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/L-2: a point and a zero-length line + + POINT(110 200) + + + LINESTRING(110 200, 110 200) + + + true + + true + true + true + false + false + false + true + false + false + true + + + +P/L-3-1: a point touching the start point of a line [dim(0){A.P.Int = B.L.Bdy.SP}] + + POINT(90 80) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/L-3-2: a point touching the end point of a line [dim(0){A.P.Int = B.L.Bdy.EP}] + + POINT(340 240) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/L-5-1: a point on the line at a non-vertex [dim(0){A.P.Int = B.L.Int.NV}] + + POINT(230 150) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/L-5-2: a point on the line at a vertex [dim(0){A.P.Int = B.L.Int.V}] + + POINT(160 150) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/LR-2-1: a point outside a LinearRing [dim(0){A.P.Int = B.LR.Ext}] + + POINT(90 150) + + + LINESTRING(150 150, 20 20, 280 20, 150 150) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/LR-2-2: a point inside a LinearRing [dim(0){A.P.Int = B.LR.Ext}] + + POINT(150 80) + + + LINESTRING(150 150, 20 20, 280 20, 150 150) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/LR-5-1: a point on the closing point of a LinearRing [dim(0){A.P.Int = B.LR.Int.CP}] + + POINT(150 150) + + + LINESTRING(150 150, 20 20, 280 20, 150 150) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/LR-5-2: a point on a LinearRing at a non-vertex [dim(0){A.P.Int = B.L.Int.NV}] + + POINT(100 20) + + + LINESTRING(150 150, 20 20, 280 20, 150 150) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/LR-5-3: a point on a LinearRing at a vertex [dim(0){A.P.Int = B.L.Int.V}] + + POINT(20 20) + + + LINESTRING(150 150, 20 20, 280 20, 150 150) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.1-3-1: a point on a non-simple LineString's end point [dim(0){A.P.Int = B.nsL.Bdy.EP}] + + POINT(220 220) + + + LINESTRING(110 110, 220 20, 20 20, 110 110, 220 220) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.1-5-1: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 220 20, 20 20, 110 110, 220 220) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.1-5-2: a point a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 220 20, 20 20, 220 220) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.1-5-3: a point on a non-simple LineString's interior at a non-vertex [dim(0){A.P.Int = B.nsL.Int.NV}] + + POINT(110 20) + + + LINESTRING(110 110, 220 20, 20 20, 220 220) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.1-5-4: a point on a non-simple LineString's interior at a vertex [dim(0){A.P.Int = B.nsL.Int.V}] + + POINT(220 20) + + + LINESTRING(110 110, 220 20, 20 20, 220 220) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.2-5-2: a point on a non-simple LineString's interior at a vertex [dim(0){A.P.Int = B.nsL.Int.NV}] + + POINT(110 20) + + + LINESTRING(220 220, 20 20, 220 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.2-5-3: a point on a non-simple LineString's interior at a vertex [dim(0){A.P.Int = B.nsL.Int.V}] + + POINT(20 20) + + + LINESTRING(220 220, 20 20, 220 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.2-5-4: a point on a non-simple LineString's interior at a vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.Vx}] + + POINT(20 110) + + + LINESTRING(20 200, 20 20, 110 20, 20 110, 110 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-3-1: a point on a non-simple LineString's start point [dim(0){A.P.Int = B.nsL.Bdy.SP}] + + POINT(20 200) + + + LINESTRING(20 200, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.3-5-1: a point on a non-simple LineString's interior at a non-vertex with overlapping line segments [dim(0){A.P.Int = B.nsL.Int.NVo}] + + POINT(110 110) + + + LINESTRING(20 200, 200 20, 140 20, 140 80, 80 140, 20 140) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-2: a point on a non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(20 200, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-3: a point on a non-simple LineString's interior at a vertex with both crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vb}] + + POINT(80 140) + + + LINESTRING(20 200, 110 110, 200 20, 140 20, 140 80, 110 110, 80 140, 20 140) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-4: a point on a non-simple LineString's interior at a two-vertex point with overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vo}] + + POINT(110 110) + + + LINESTRING(20 200, 110 110, 200 20, 140 20, 140 80, 110 110, 80 140, 20 140) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-5: a point on a non-simple LineString's interior at a vertex with overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vo}] + + POINT(110 110) + + + LINESTRING(20 200, 200 20, 140 20, 140 80, 110 110, 80 140, 20 140) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-6: a point on a non-simple LineString's interior at a two-vertex point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.Vx}] + + POINT(110 110) + + + LINESTRING(20 200, 110 110, 200 20, 20 20, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-7: a point on a non-simple LineString's interior at a vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.Vx}] + + POINT(110 110) + + + LINESTRING(20 200, 200 20, 20 20, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.3-5-8: a point on a non-simple LineString's interior at a two-vertex point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.Vx}] + + POINT(110 110) + + + LINESTRING(20 200, 110 110, 20 20, 200 20, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.4-3-1: a point on a non-simple LineString's start point with crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Bdy.SPb}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 110 110, 200 20, 140 20, 140 80, 110 110, 80 140, + 20 140) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-2: a point on a non-simple LineString's start point with crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Bdy.SPb}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 200 20, 140 20, 140 80, 110 110, 80 140, 20 140) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-3:a point on a non-simple LineString's start point with crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Bdy.SPb}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 200 20, 140 20, 140 80, 80 140, 20 140) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-4: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 110 110, 200 20, 20 20, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-5: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 200 20, 20 20, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-6: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-7: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 110 110, 20 20, 200 20, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.4-3-8: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 110) + + + LINESTRING(110 110, 110 200, 20 200, 200 20, 200 110, 110 110, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.5-3-1: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(200 200, 110 110, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.5-3-2: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(200 200, 20 20, 200 20, 110 110, 20 200, 110 200, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.5-3-3: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(200 200, 20 20, 200 20, 20 200, 110 200, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.5-3-4: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(200 200, 110 110, 200 20, 20 20, 110 110, 20 200, 110 200, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.5-3-5: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(200 200, 20 20, 20 110, 110 110, 20 200, 110 200, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.6-3-1: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 160) + + + LINESTRING(110 160, 200 250, 110 250, 110 160, 110 110, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.6-3-2: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(110 160) + + + LINESTRING(110 160, 200 250, 110 250, 110 110, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.6-3-3: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(110 160, 200 250, 110 250, 110 160, 110 110, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.6-3-4: a point on a non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(110 110) + + + LINESTRING(110 160, 200 250, 110 250, 110 160, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.7-5-1: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 200 200, 110 200, 110 110, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.7-5-2: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 200 200, 110 200, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.7-5-3: a point on a closed non-simple LineString's interior at a non-vertex [dim(0){A.P.Int = B.nsL.Int.NV}] + + POINT(140 200) + + + LINESTRING(110 110, 200 200, 110 200, 110 110, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.7-5-4: a point on a closed non-simple LineString's interior at a vertex [dim(0){A.P.Int = B.nsL.Int.V}] + + POINT(110 200) + + + LINESTRING(110 110, 200 200, 110 200, 110 110, 110 20, 20 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.8-5-1: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 200 200, 110 200, 110 110, 110 20, 200 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.8-5-2: a point on the interior (at a non-vertex) of a closed non-simple LineString [dim(0){A.P.Int = B.nsL.Int.NV}] + + POINT(140 200) + + + LINESTRING(110 110, 200 200, 110 200, 110 110, 110 20, 200 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.8-5-3: a point on a closed non-simple LineString's interior at a vertex [dim(0){A.P.Int = B.nsL.Int.V}] + + POINT(110 200) + + + LINESTRING(110 110, 200 200, 110 200, 110 110, 110 20, 200 20, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.9-3-1: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(90 130) + + + LINESTRING(90 130, 20 130, 20 200, 90 130, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.9-5-1: a point on a non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(90 130, 20 130, 20 200, 90 130, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.10-3-1: a point on a non-simple LineString's start point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(90 130) + + + LINESTRING(90 130, 20 130, 20 200, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.10-5-1: a point on a non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(90 130, 20 130, 20 200, 200 20, 20 20, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.11-3-1: a point on a closed non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.EPx}] + + POINT(90 130) + + + LINESTRING(200 200, 20 20, 200 20, 90 130, 20 200, 20 130, 90 130) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.11-5-1: a point on a closed non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(200 200, 20 20, 200 20, 90 130, 20 200, 20 130, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.12-3-1: a point on a closed non-simple LineString's end point with crossing line segments [dim(0){A.P.Int = B.nsL.Bdy.SPx}] + + POINT(90 130) + + + LINESTRING(200 200, 20 20, 200 20, 20 200, 20 130, 90 130) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.12-5-1: a point on a closed non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(200 200, 20 20, 200 20, 20 200, 20 130, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.13-5-1: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 20 130, 20 200, 110 110, 200 20, 20 20, 110 110, 200 200, 200 130, + 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.13-5-2: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 20 130, 20 200, 200 20, 20 20, 200 200, 200 130, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.14-5-1: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 80 200, 20 200, 110 110, 200 20, 20 20, 110 110, 200 200, 140 200, + 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.14-5-2: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(110 110) + + + LINESTRING(110 110, 80 200, 20 200, 200 20, 20 20, 200 200, 140 200, 110 110) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.15-5-1: a point on a closed non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(200 200, 20 20, 200 20, 20 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.15-5-2: a point on a closed non-simple LineString's interior at a vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.Vx}] + + POINT(110 110) + + + LINESTRING(200 200, 110 110, 20 20, 200 20, 110 110, 20 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.15-5-3: a point on a closed non-simple LineString's interior at a vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.Vx}] + + POINT(110 110) + + + LINESTRING(200 200, 110 110, 200 20, 20 20, 110 110, 20 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.16-5-1: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(90 130) + + + LINESTRING(90 130, 20 130, 20 200, 90 130, 110 110, 200 20, 20 20, 110 110, 200 200, + 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.16-5-2: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(90 130) + + + LINESTRING(90 130, 20 130, 20 200, 110 110, 200 20, 20 20, 110 110, 200 200, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.17-5-1: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(90 130) + + + LINESTRING(90 130, 90 200, 20 200, 90 130, 110 110, 200 20, 20 20, 110 110, 200 200, + 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.17-5-2: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(90 130) + + + LINESTRING(90 130, 90 200, 20 200, 200 20, 20 20, 200 200, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.17-5-3: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(90 130) + + + LINESTRING(90 130, 90 200, 20 200, 110 110, 200 20, 20 20, 110 110, 200 200, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.17-5-4: a point on a closed non-simple LineString's closing point with crossing line segments [dim(0){A.P.Int = B.nsL.Int.CPx}] + + POINT(90 130) + + + LINESTRING(90 130, 90 200, 20 200, 200 20, 20 20, 200 200, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.17-5-5: a point on a closed non-simple LineString's interior at a non-vertex with crossing line segments [dim(0){A.P.Int = B.nsL.Int.NVx}] + + POINT(110 110) + + + LINESTRING(90 130, 90 200, 20 200, 200 20, 20 20, 200 200, 90 130) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.18-5-1: a point on a non-simple LineString's start point with both crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Bdy.SPb)}] + + POINT(110 200) + + + LINESTRING(110 200, 110 110, 20 20, 200 20, 110 110, 110 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + true + false + + + +P/nsL.18-5-2: a point on a non-simple LineString's interior at a non-vertex with overlapping line segments [dim(0){A.P.Int = B.nsL.Int.NVo}] + + POINT(110 150) + + + LINESTRING(110 200, 110 110, 20 20, 200 20, 110 110, 110 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.18-5-3: a point on a non-simple LineString's interior at a vertex with both crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vb}] + + POINT(110 110) + + + LINESTRING(110 200, 110 110, 20 20, 200 20, 110 110, 110 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.19-5-1: a point on a non-simple LineString's closing point with overlapping line segments [dim(0){A.P.Int = B.nsL.Int.CPo}] + + POINT(110 200) + + + LINESTRING(110 200, 110 110, 20 20, 200 20, 110 110, 110 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.19-5-2: a point on a non-simple LineString's interior at a non-vertex overlapping line segments [dim(0){A.P.Int = B.nsL.Int.NVo}] + + POINT(110 150) + + + LINESTRING(110 200, 110 110, 20 20, 200 20, 110 110, 110 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.19-5-3: a point on a non-simple LineString interior at a vertex with both crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vb}] + + POINT(110 110) + + + LINESTRING(110 200, 110 110, 20 20, 200 20, 110 110, 110 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.20-5-1: a point on a non-simple LineString's interior at a non-vertex with overlapping line segments [dim(0){A.P.Int = B.nsL.Int.NVo}] + + POINT(110 150) + + + LINESTRING(20 200, 110 200, 110 110, 20 20, 200 20, 110 110, 110 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsL.20-5-2: a point on a non-simple LineString's interior at a vertex with both crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vb}] + + POINT(110 110) + + + LINESTRING(20 200, 110 200, 110 110, 20 20, 200 20, 110 110, 110 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +P/nsl.20-5-3: a point on a non-simple LineString's interior at a vertex with both crossing and overlapping line segments [dim(0){A.P.Int = B.nsL.Int.Vb}] + + POINT(110 200) + + + LINESTRING(20 200, 110 200, 110 110, 20 20, 200 20, 110 110, 110 200, 200 200) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-2-1: MultiPoint and a line disjoint (points on one side of the line) [dim(0){A.3P.Int = B.L.Ext}] + + MULTIPOINT((50 250), (90 220), (130 190)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +mP/L-2-2: MultiPoint and a line disjoint (points over the line but no intersection) [dim(0){A.3P.Int = B.L.Ext}] + + MULTIPOINT((180 180), (230 130), (280 80)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +mP/L-3-1: one of the points intersecting the start point of a line [dim(0){A.3P2.Int = B.L.Bdy.SP}] + + MULTIPOINT((50 120), (90 80), (130 40)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mP/L-3-2: one of the points intersecting the end point of a line [dim(0){A.3P2 = B.L.Bdy.EP}] + + MULTIPOINT((300 280), (340 240), (380 200)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + false + false + false + true + false + true + false + + + +mP/L-4-1: one of the points intersecting the interior of a line at a non-vertex (points on one side of the line) [dim(0){A.3P1.Int = B.L.Int.NV] + + MULTIPOINT((230 150), (260 120), (290 90)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/L-4-2: one of the points intersecting the interior of a line at a non-vertex (points over the line) [dim(0){A.3P2.Int = B.L.Int.NV] + + MULTIPOINT((200 190), (240 150), (270 110)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/L-4-3: one of the points intersecting the interior of a line at a vertex (points on one side of the line) [dim(0){A.3P1.Int = B.L.Int.V] + + MULTIPOINT((160 150), (190 120), (220 90)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/L-4-4: one of the points intersecting the interior of a line at a vertex (points over the line) [dim(0){A.3P2.Int = B.L.Int.V] + + MULTIPOINT((120 190), (160 150), (200 110)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + false + false + true + false + false + true + false + false + false + + + +mP/L-5-1: all the points on a line [dim(0){A.3P1.Int = B.L.Bdy.SP}, dim(0){A.3P2.Int = B.L.Int.V}, dim(0){A.3P3.Int = B.Bdy.EP}] + + MULTIPOINT((90 80), (160 150), (340 240)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-2: all the points on a line [dim(0){A.3P1.Int = B.L.Bdy.SP}, dim(0){A.3P2.Int = B.L.Int.V}, dim(0){A.3P3.Int = B.Int.V}] + + MULTIPOINT((90 80), (160 150), (300 150)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-3: all the points on a line [dim(0){A.3P1.Int = B.L.Bdy.SP}, dim(0){A.3P2.Int = B.L.Int.V}, dim(0){A.3P3.Int = B.Int.NV}] + + MULTIPOINT((90 80), (160 150), (240 150)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-4: all the points on a line [dim(0){A.3P1.Int = B.L.Bdy.SP}, dim(0){A.3P2.Int = B.L.Int.NV}, dim(0){A.3P3.Int = B.Int.NV}] + + MULTIPOINT((90 80), (130 120), (210 150)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-5: all the points on a line [dim(0){A.3P1.Int = B.L.Int.NV}, dim(0){A.3P2.Int = B.L.Int.NV}, dim(0){A.3P3.Int = B.Int.NV}] + + MULTIPOINT((130 120), (210 150), (340 200)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-6: all the points on a line [dim(0){A.3P1.Int = B.L.Int.V}, dim(0){A.3P2.Int = B.L.Int.V}, dim(0){A.3P3.Int = B.Int.NV}] + + MULTIPOINT((160 150), (240 150), (340 210)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-7: all the points on a line [dim(0){A.3P1.Int = B.L.Int.V}, dim(0){A.3P2.Int = B.L.Int.V}, dim(0){A.3P3.Int = B.Int.V}] + + MULTIPOINT((160 150), (300 150), (340 150)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/L-5-8: all the points on a line [dim(0){A.3P1.Int = B.L.Int.V}, dim(0){A.3P2.Int = B.L.Int.NV}, dim(0){A.3P3.Int = B.Bdy.EP}] + + MULTIPOINT((160 150), (240 150), (340 240)) + + + LINESTRING(90 80, 160 150, 300 150, 340 150, 340 240) + + + true + + false + true + false + false + false + false + true + false + false + true + + + diff --git a/test/data/jts/validate/TestRelatePP.xml b/test/data/jts/validate/TestRelatePP.xml new file mode 100644 index 0000000000..e4d062902e --- /dev/null +++ b/test/data/jts/validate/TestRelatePP.xml @@ -0,0 +1,303 @@ + + + + +P/P: same point [dim(0){A.P.Int = B.P.Int}] + + POINT(20 20) + + + POINT(20 20) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +P/P: different point [dim(0){A.P.Int = B.P.Ext}] + + POINT(20 20) + + + POINT(40 60) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/mP: different points [dim(0){A.P.Int = B.3P.Ext}] + + POINT(40 40) + + + MULTIPOINT((20 20), (80 80), (20 120)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +P/mP: point A within one of B points [dim(0){A.P.Int = B.3P1.Int}] + + POINT(20 20) + + + MULTIPOINT((20 20), (80 80), (20 120)) + + + true + + false + true + false + false + false + false + true + false + false + true + + + +mP/mP-1-1: same points [dim(0){A.3P1.Int = B.3P1.Int}, dim(0){A.3P2.Int = B.3P2.Int}, dim(0){A.3P3.Int = B.3P3.Int}] + + MULTIPOINT((40 40), (80 60), (120 100)) + + + MULTIPOINT((40 40), (80 60), (120 100)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mP/mP-1-2: same but different sequence of points [dim(0){A.3P1.Int = B.3P1.Int}, dim(0){A.3P1.Int = B.3P3.Int}, dim(0){A.3P3.Int = B.3P2.Int}] + + MULTIPOINT((40 40), (80 60), (120 100)) + + + MULTIPOINT((40 40), (120 100), (80 60)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mP/mP-2: different points [dim(0){A.4P.Int = B.4P.Ext}] + + MULTIPOINT((40 40), (60 100), (100 60), (120 120)) + + + MULTIPOINT((20 120), (60 60), (100 100), (140 40)) + + + true + + false + false + false + false + true + false + false + false + false + false + + + +mP/mP-5-1: same points [dim(0){A.4P.Int = B.4P.Int}] + + MULTIPOINT((20 20), (80 70), (140 120), (200 170)) + + + MULTIPOINT((20 20), (80 70), (140 120), (200 170)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mP/mP-5-2: same points but different sequence [dim(0){A.4P.Int = B.4P.Int}] + + MULTIPOINT((20 20), (140 120), (80 70), (200 170)) + + + MULTIPOINT((80 70), (20 20), (200 170), (140 120)) + + + true + + true + true + true + false + false + true + true + false + false + true + + + +mP/mP-5-3: some points same [dim(0){A.4P2.Int = B.2P1.Int}, dim(0){A.4P3.Int = B.2P2.Int}] + + MULTIPOINT((20 20), (80 70), (140 120), (200 170)) + + + MULTIPOINT((80 70), (140 120)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +mP/mP-5-4: some points same, in a different sequence [dim(0){A.4P1.Int = B.2P2.Int}, dim(0){A.4P4.Int = B.2P1.Int}] + + MULTIPOINT((80 70), (20 200), (200 170), (140 120)) + + + MULTIPOINT((140 120), (80 70)) + + + true + + true + false + true + false + false + false + true + false + false + false + + + +mP/mP-6-1: some points same, some different [dim(0){A.4P4.Int = B.3P2.Int}] + + MULTIPOINT((80 70), (20 20), (200 170), (140 120)) + + + MULTIPOINT((80 170), (140 120), (200 80)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + +mP/mP-6-2: dim(0){A.4P1.Int = B.4P4.Int}, dim(0){A.4P4.Int = B.4P2.Int} + + MULTIPOINT((80 70), (20 20), (200 170), (140 120)) + + + MULTIPOINT((80 170), (140 120), (200 80), (80 70)) + + + true + + false + false + false + false + false + false + true + true + false + false + + + diff --git a/test/data/jts/validate/TestRobustRelate.xml b/test/data/jts/validate/TestRobustRelate.xml new file mode 100644 index 0000000000..c16c930919 --- /dev/null +++ b/test/data/jts/validate/TestRobustRelate.xml @@ -0,0 +1,19 @@ + + + + + PP - Point is not on line. Non-robust algorithms fail by erroneously reporting intersects=true. + + LINESTRING(-123456789 -40, 381039468754763 123456789) + + + POINT(0 0) + + + + false + + + + + diff --git a/test/data/jts/validate/TestRobustRelateFloat.xml b/test/data/jts/validate/TestRobustRelateFloat.xml new file mode 100644 index 0000000000..da1836de34 --- /dev/null +++ b/test/data/jts/validate/TestRobustRelateFloat.xml @@ -0,0 +1,40 @@ + + + + + A/P - Point is on boundary of polygon. + Orientation algorithms (including CGAlgorithmsDD) fail by incorrectly reporting the point to not be on the boundary. + See https://trac.osgeo.org/geos/ticket/841 + + + POLYGON ((0 0, 1 0, 0 1, 0 0)) + + + POINT (0.95 0.05) + + + + false + + + + + + L/L - Line A contains Line B. + Fails due to imprecision of computed self-node in A. + See https://trac.osgeo.org/geos/ticket/572 + + + LINESTRING (1 0, 0 2, 0 0, 2 2) + + + LINESTRING (0 0, 2 2) + + + + true + + + + + diff --git a/test/external/jts/jts_testset_reader.jl b/test/external/jts/jts_testset_reader.jl index 57e71c2185..bd3c250ece 100644 --- a/test/external/jts/jts_testset_reader.jl +++ b/test/external/jts/jts_testset_reader.jl @@ -4,6 +4,7 @@ import WellKnownGeometry import GeoFormatTypes as GFT import GeometryOps as GO import GeoInterface as GI +import LibGEOS as LG """ jts_wkt_to_geom(wkt::String) @@ -12,112 +13,170 @@ Convert a JTS WKT string to a GeometryOps geometry, via WellKnownGeometry.jl and The reason this exists is because WellKnownGeometry doesn't work well with newlines in subsidiary geometries, so this sanitizes the input before parsing and converting. + +Some WKT is parsed via LibGEOS instead, because WellKnownGeometry can't handle it: + +- WKT containing `EMPTY` (including nested, e.g. `GEOMETRYCOLLECTION(POLYGON EMPTY, ...)`), + because GI wrapper geometries cannot be empty. +- `GEOMETRYCOLLECTION`, because WellKnownGeometry mis-splits subgeometries + preceded by whitespace (e.g. `GEOMETRYCOLLECTION( LINESTRING (1 2, 1 1))`). +- `LINEARRING`, which WellKnownGeometry does not know at all. """ function jts_wkt_to_geom(wkt::String) sanitized_wkt = join(strip.(split(wkt, "\n")), "") + upper_wkt = uppercase(lstrip(sanitized_wkt)) + if occursin("EMPTY", upper_wkt) || + startswith(upper_wkt, "GEOMETRYCOLLECTION") || + startswith(upper_wkt, "LINEARRING") + # Intentionally return the raw LibGEOS geometry: GI wrappers cannot represent + # EMPTY, and LG geoms are GeoInterface-compatible, so consumers (the engine) + # must access them via GI accessors anyway. + return LG.readgeom(sanitized_wkt) + end geom = GFT.WellKnownText(GFT.Geom(), sanitized_wkt) return GO.tuples(geom) end +# Operations whose expected result is a boolean, not a geometry. +const BOOLEAN_OPS = Set(["relate", "intersects", "disjoint", "contains", "within", + "covers", "coveredby", "crosses", "touches", "overlaps", "equalstopo", "equals"]) + +function parse_expected(operation, raw::String) + lowercase(operation) in BOOLEAN_OPS && return parse(Bool, lowercase(strip(raw))) + return jts_wkt_to_geom(raw) +end + +# Note: geometry fields are untyped because `GO.tuples` returns a bare +# coordinate tuple for POINT WKT, not a wrapper geometry. struct TestItem{T} operation::String - arg1::GO.GI.Wrappers.WrapperGeometry - arg2::GO.GI.Wrappers.WrapperGeometry + arg1::Any + arg2::Any # `nothing` for unary ops like getboundary + pattern::Union{Nothing, String} # DE-9IM pattern from `arg3` (relate ops) expected_result::T end -Base.show(io::IO, ::MIME"text/plain", item::TestItem) = print(io, "TestItem(operation = $(item.operation), expects $(GI.trait(item.expected_result)))") +Base.show(io::IO, ::MIME"text/plain", item::TestItem) = print(io, "TestItem(operation = $(item.operation), expects $(item.expected_result isa Bool ? item.expected_result : GI.trait(item.expected_result)))") Base.show(io::IO, item::TestItem) = show(io, MIME"text/plain"(), item) struct Case description::String - geom_a::GO.GI.Wrappers.WrapperGeometry - geom_b::GO.GI.Wrappers.WrapperGeometry + geom_a::Any + geom_b::Any # `nothing` for cases (e.g. TestBoundary) with no items::Vector{TestItem} end -function load_test_cases(filepath::String) +""" + Run + +The parsed contents of one JTS XML test file: run-level metadata plus all cases. + +- `precision_model` is `"FLOATING"` (also the default when no `` + element is present) or `"FIXED"` (a `` with a `scale` attribute). + Used to skip FIXED-precision cases, which assume snapped coordinates. +- `n_skipped_ops` counts `` elements that could not be represented as a + `TestItem` (e.g. `arg1`/`arg2` referring to something other than the case's + A/B geometries); they are dropped from `items` but never silently — the count + is recorded here. +""" +struct Run + filepath::String + description::Union{Nothing, String} + precision_model::String + cases::Vector{Case} + n_skipped_ops::Int +end + +function load_test_run(filepath::String) doc = read(filepath, XML.Node) # lazy parsing - run = only(children(doc)) + #-- XML.jl ≥ 0.4 surfaces declaration/comment/whitespace nodes as + #-- document children; the root is the single Element among them + run = only(c for c in children(doc) if nodetype(c) === XML.Element) + description = nothing + precision_model = "FLOATING" test_cases = Case[] - for case in children(run) - if tag(case) != "case" - continue + n_skipped_ops = Ref(0) + for child in children(run) + t = tag(child) + if t == "case" + push!(test_cases, parse_case(child, n_skipped_ops)) + elseif t == "desc" + description = strip(value(only(children(child)))) + elseif t == "precisionModel" + pm_attrs = something(XML.attributes(child), Dict{String, String}()) + if haskey(pm_attrs, "type") + precision_model = uppercase(pm_attrs["type"]) + elseif haskey(pm_attrs, "scale") + precision_model = "FIXED" + end end - push!(test_cases, parse_case(case)) + # other tags (comments, , ...) are run-level config we don't use end - return test_cases + return Run(filepath, description, precision_model, test_cases, n_skipped_ops[]) +end + +""" + load_test_cases(filepath) + +Parse a JTS XML test file and return its `Vector{Case}`. +Convenience wrapper around [`load_test_run`](@ref) for consumers that +don't need the run-level metadata (e.g. `overlay_runner.jl`). +""" +load_test_cases(filepath::String) = load_test_run(filepath).cases + +# Extract the text content of an XML element, tolerating missing/empty content. +function _element_text(node::XML.Node) + isnothing(children(node)) && return "" + kids = children(node) + isempty(kids) && return "" + return join((something(value(kid), "") for kid in kids), "\n") end -function parse_case(case::XML.Node) - description = value(only(children(case.children[1]))) - a = jts_wkt_to_geom(value(only(children(case.children[2])))) - b = jts_wkt_to_geom(value(only(children(case.children[3])))) +function parse_case(case::XML.Node, n_skipped_ops::Ref{Int} = Ref(0)) + description = "" + a = nothing + b = nothing + test_elements = XML.Node[] + for child in children(case) + t = tag(child) + if t == "desc" + description = strip(_element_text(child)) + elseif t == "a" + a = jts_wkt_to_geom(_element_text(child)) + elseif t == "b" + b = jts_wkt_to_geom(_element_text(child)) + elseif t == "test" + push!(test_elements, child) + end + end + isnothing(a) && error("case \"$description\" has no geometry") + + # Resolve an argN attribute value to the case's A/B geometry, or `nothing` + # if it refers to anything else (e.g. a previous op's result). + resolve_arg(arg) = lowercase(arg) == "a" ? a : (lowercase(arg) == "b" ? b : nothing) items = TestItem[] - for item in children(case)[4:end] - ops = children(item) - for op in ops + for item in test_elements + for op in children(item) + tag(op) == "op" || continue op_attrs = XML.attributes(op) operation = op_attrs["name"] - arg1 = op_attrs["arg1"] - arg2 = op_attrs["arg2"] - expected_result = jts_wkt_to_geom(value(only(op.children))) - push!(items, TestItem(operation, lowercase(arg1) == "a" ? a : b, lowercase(arg2) == "a" ? a : b, expected_result)) + arg1 = resolve_arg(op_attrs["arg1"]) + if isnothing(arg1) + # arg1 refers to something other than A/B; we can't run this op. + n_skipped_ops[] += 1 + continue + end + arg2_raw = get(op_attrs, "arg2", nothing) + arg2 = isnothing(arg2_raw) ? nothing : resolve_arg(arg2_raw) + if !isnothing(arg2_raw) && isnothing(arg2) + n_skipped_ops[] += 1 + continue + end + pattern = get(op_attrs, "arg3", nothing) + expected_result = parse_expected(operation, _element_text(op)) + push!(items, TestItem(operation, arg1, arg2, pattern, expected_result)) end end return Case(description, a, b, items) end - - -testfile = "/Users/anshul/.julia/dev/geo/jts/modules/tests/src/test/resources/testxml/general/TestOverlayAA.xml" - -cases = load_test_cases(testfile) - -using Test - -for case in cases - @testset "$(case.description)" begin - for item in case.items - @testset "$(item.operation)" begin - result = if item.operation == "union" - GO.union(item.arg1, item.arg2; target = GO.PolygonTrait()) - elseif item.operation == "difference" - GO.difference(item.arg1, item.arg2; target = GO.PolygonTrait()) - elseif item.operation == "intersection" - GO.intersection(item.arg1, item.arg2; target = GO.PolygonTrait()) - elseif item.operation == "symdifference" - continue - else - continue - end - - finalresult = if length(result) == 0 - nothing - elseif length(result) == 1 - only(result) - else - GI.MultiPolygon(result) - end - - if isnothing(finalresult) - @warn("No result") - continue - end - - if GI.geomtrait(item.expected_result) isa Union{GI.MultiPolygonTrait, GI.PolygonTrait} - difference_in_areas = GO.area(GO.difference(finalresult, item.expected_result; target = GO.PolygonTrait())) - if difference_in_areas > 1 - @warn("Difference in areas: $(difference_in_areas)") - f, a, p = poly(finalresult; label = "Final result (GO)", axis = (; title = "$(case.description) - $(item.operation)")) - poly!(a, item.expected_result; label = "Expected result (JTS)") - display(f) - end - # @test difference_in_areas < 1 - else - @test_broken GO.equals(finalresult, item.expected_result) - end - end - end - end -end \ No newline at end of file diff --git a/test/external/jts/overlay_runner.jl b/test/external/jts/overlay_runner.jl new file mode 100644 index 0000000000..d8fe937898 --- /dev/null +++ b/test/external/jts/overlay_runner.jl @@ -0,0 +1,56 @@ +# Executable overlay-test loop, moved verbatim from `jts_testset_reader.jl` +# (which is now a pure parser). Not wired into CI; run manually. +include(joinpath(@__DIR__, "jts_testset_reader.jl")) + +# TODO: parameterize; path predates vendored test/data/jts files +testfile = "/Users/anshul/.julia/dev/geo/jts/modules/tests/src/test/resources/testxml/general/TestOverlayAA.xml" + +cases = load_test_cases(testfile) + +using Test + +for case in cases + @testset "$(case.description)" begin + for item in case.items + @testset "$(item.operation)" begin + result = if item.operation == "union" + GO.union(item.arg1, item.arg2; target = GO.PolygonTrait()) + elseif item.operation == "difference" + GO.difference(item.arg1, item.arg2; target = GO.PolygonTrait()) + elseif item.operation == "intersection" + GO.intersection(item.arg1, item.arg2; target = GO.PolygonTrait()) + elseif item.operation == "symdifference" + continue + else + continue + end + + finalresult = if length(result) == 0 + nothing + elseif length(result) == 1 + only(result) + else + GI.MultiPolygon(result) + end + + if isnothing(finalresult) + @warn("No result") + continue + end + + if GI.geomtrait(item.expected_result) isa Union{GI.MultiPolygonTrait, GI.PolygonTrait} + difference_in_areas = GO.area(GO.difference(finalresult, item.expected_result; target = GO.PolygonTrait())) + if difference_in_areas > 1 + @warn("Difference in areas: $(difference_in_areas)") + f, a, p = poly(finalresult; label = "Final result (GO)", axis = (; title = "$(case.description) - $(item.operation)")) + poly!(a, item.expected_result; label = "Expected result (JTS)") + display(f) + end + # @test difference_in_areas < 1 + else + @test_broken GO.equals(finalresult, item.expected_result) + end + end + end + end +end diff --git a/test/external/jts/relate_runner.jl b/test/external/jts/relate_runner.jl new file mode 100644 index 0000000000..b4f4ad0387 --- /dev/null +++ b/test/external/jts/relate_runner.jl @@ -0,0 +1,120 @@ +# Runner for the vendored JTS relate XML conformance suite. +# Parameterized over the relate implementation so it can be smoke-tested +# before the engine exists (Task 14); fully activated in Task 23. + +isdefined(@__MODULE__, :load_test_run) || include(joinpath(@__DIR__, "jts_testset_reader.jl")) +isdefined(@__MODULE__, :RELATE_SKIPLIST) || include(joinpath(@__DIR__, "relate_skiplist.jl")) + +using Test + +# Standard DE-9IM pattern matching over the 9-character string forms, kept +# local so the runner stays engine-agnostic (it must not depend on the +# implementation under test for its own pass/fail logic). +_de9im_entry_matches(c::Char, p::Char) = + p == '*' ? true : + p == 'T' ? c in ('0', '1', '2') : + c == p +_de9im_matches(im::AbstractString, pattern::AbstractString) = + all(_de9im_entry_matches(im[i], pattern[i]) for i in 1:9) + +""" + run_relate_cases(relate_fn, pattern_fn, predicate_fns, files; skiplist = RELATE_SKIPLIST, check_matrix = true) + +Run the JTS relate XML test cases in `files` (paths to vendored XML files) +against a relate implementation: + +- `relate_fn(a, b)::DE9IM` — computes the full DE-9IM matrix. For every + `relate` op (unless `check_matrix = false`), the full matrix's `string` + form is matched against the op's pattern as a consistency check: the + matrix route and the pattern-predicate route must agree on the expected + result for the op to pass. +- `pattern_fn(a, b, pattern)::Bool` — evaluates a DE-9IM pattern match + (used for `relate` ops, whose `arg3` is the pattern). +- `predicate_fns::AbstractDict` — maps lowercase op names (`"intersects"`, + `"contains"`, ...) to `(a, b) -> Bool` closures. Ops with no entry are + recorded as skipped. + +Ops whose `(file, case_index, op, arg_order)` key is in `skiplist` are recorded +as skipped, never silently dropped — as are ops outside `BOOLEAN_OPS` (e.g. +`getboundary`) and runs with a FIXED precision model. `case_index` is the +1-based index of the `` within the file; `arg_order` is `"AB"` when the +op's `arg1` is the case's A geometry and `"BA"` when it is B (some files, e.g. +TestRelateGC.xml, run each predicate both ways, and case descriptions are not +unique within a file, so neither alone can serve as a key). + +Each executed op contributes one `@test`. Returns a NamedTuple +`(per_file, skipped)` where `per_file` is a `Vector` of +`(file, n_pass, n_fail, n_skip)` NamedTuples and `skipped` is a `Vector` of +`(file, case_index, description, op, arg_order, reason)` NamedTuples for every +skipped op. +""" +function run_relate_cases(relate_fn, pattern_fn, predicate_fns, files; + skiplist::Set{Tuple{String, Int, String, String}} = RELATE_SKIPLIST, + check_matrix::Bool = true) + per_file = NamedTuple{(:file, :n_pass, :n_fail, :n_skip), Tuple{String, Int, Int, Int}}[] + skipped = NamedTuple{(:file, :case_index, :description, :op, :arg_order, :reason), + Tuple{String, Int, String, String, String, String}}[] + for filepath in files + file = basename(filepath) + run = load_test_run(filepath) + n_pass = 0 + n_fail = 0 + n_skip = 0 + skip!(case_index, case, item, arg_order, reason) = begin + n_skip += 1 + push!(skipped, (; file, case_index, description = case.description, + op = item.operation, arg_order, reason)) + end + @testset "$file" begin + for (case_index, case) in enumerate(run.cases) + @testset "$(case.description)" begin + for item in case.items + op = lowercase(item.operation) + # "AB" when the op's arg1 is the case's A geometry, "BA" + # when it is B (e.g. TestRelateGC runs each predicate + # with the arguments both ways round). + arg_order = item.arg1 === case.geom_a ? "AB" : "BA" + if (file, case_index, item.operation, arg_order) in skiplist + skip!(case_index, case, item, arg_order, "in skiplist") + continue + elseif run.precision_model != "FLOATING" + skip!(case_index, case, item, arg_order, "non-FLOATING precision model ($(run.precision_model))") + continue + elseif !(op in BOOLEAN_OPS) + skip!(case_index, case, item, arg_order, "non-boolean op") + continue + end + a, b = item.arg1, item.arg2 + passed = false + try + if op == "relate" + passed = pattern_fn(a, b, item.pattern) == item.expected_result + if passed && check_matrix + # Consistency check: the full matrix, matched + # against the pattern, must reproduce the + # pattern-predicate result. + im_str = string(relate_fn(a, b)) + passed = _de9im_matches(im_str, item.pattern) == item.expected_result + end + elseif haskey(predicate_fns, op) + passed = predicate_fns[op](a, b) == item.expected_result + else + skip!(case_index, case, item, arg_order, "no predicate function provided") + continue + end + catch err + # An errored op must never count as a pass (e.g. + # `relate_fn` throwing after `pattern_fn` succeeded). + passed = false + @error "relate case errored" file case.description item.operation exception = (err, catch_backtrace()) + end + passed ? (n_pass += 1) : (n_fail += 1) + @test passed + end + end + end + end + push!(per_file, (; file, n_pass, n_fail, n_skip)) + end + return (; per_file, skipped) +end diff --git a/test/external/jts/relate_skiplist.jl b/test/external/jts/relate_skiplist.jl new file mode 100644 index 0000000000..035f734b7b --- /dev/null +++ b/test/external/jts/relate_skiplist.jl @@ -0,0 +1,48 @@ +# Skiplist for the JTS relate XML conformance suite (`relate_runner.jl`). +# +# Each entry is a `(file, case_index, op name, arg order)` tuple identifying one +# `` in one `` of one vendored XML file: +# +# - `file` is the basename, e.g. "TestRelateAA.xml". +# - `case_index` is the 1-based index of the `` within the file. +# - `op name` is as written in the XML, e.g. "equalsTopo". +# - `arg order` is "AB" when the op's `arg1` is the case's A geometry and "BA" +# when it is B (e.g. TestRelateGC.xml runs each predicate with the arguments +# both ways round). +# +# Case descriptions are NOT part of the key — they are not unique within a file +# (e.g. TestRelateLL.xml has two distinct cases both described as "Line vs line +# - pointwise equal") — so the human-readable description belongs in the +# justification comment instead. +# +# DISCIPLINE: every entry MUST be accompanied by a comment giving the case +# description and explaining exactly why GeometryOps diverges from JTS on that +# case (or why the case cannot run), ideally with an issue/task reference. +# Entries without a justification comment must not be merged. Skipped cases are +# reported by `run_relate_cases` — they are never silently dropped. +# +# Example entry: +# # "P/L - empty point VS empty line" (case 3): GO returns FF* for +# # empty-geometry boundary, JTS expects F0*; tracked in #XXXX +# ("TestRelateEmpty.xml", 3, "relate", "AB"), + +const RELATE_SKIPLIST = Set{Tuple{String, Int, String, String}}([ + # "P/L-2: a point and a zero-length line" (validate/TestRelatePL.xml, + # case 2): JTS RelateNG (and GEOS >= 3.13) deliberately treats zero-length + # LineStrings as topologically identical to Points (RelateNG.java class + # javadoc, difference 3), so `equalsTopo(POINT(110 200), + # LINESTRING(110 200, 110 200))` is `true`. The legacy XML expectation + # (`false`) encodes old-RelateOp behavior, where the line's declared + # dimension (1) differs from the point's (0). GEOS 3.14.1 RelateNG agrees + # with GeometryOps. (general/TestRelatePL.xml shares this basename, but its + # case 2 has only `relate` ops, so the key is unambiguous.) + ("TestRelatePL.xml", 2, "equalsTopo", "AB"), + # "A/P - Point is on boundary of polygon" (validate/TestRobustRelateFloat.xml, + # case 1): the case intends POINT(0.95 0.05) to lie on the hypotenuse of + # POLYGON((0 0, 1 0, 0 1, 0 0)), but in exact double semantics + # 0.95 + 0.05 == 1 - 3*2^-56 < 1, so the parsed point lies strictly inside + # the polygon and `contains` is exactly `true`. The legacy expectation + # (`false`) assumes the idealized, non-representable coordinates. GEOS + # 3.14.1 RelateNG also returns `true`. + ("TestRobustRelateFloat.xml", 1, "contains", "AB"), +]) diff --git a/test/methods/area.jl b/test/methods/area.jl index eaadadfd36..04113923f5 100644 --- a/test/methods/area.jl +++ b/test/methods/area.jl @@ -171,6 +171,58 @@ end @test area_with_hole ≈ area_exterior - area_hole atol=1e-10 end +@testset "Concave spherical area (signed fan triangulation)" begin + # The spherical area fans each ring from its first vertex and sums the SIGNED + # triangle areas. Reflex fan triangles must subtract; the pre-fix code summed + # unsigned magnitudes and overcounted every non-convex ring. + usph = GO.Spherical(radius = 1.0) + + # (a) A concave L-shape equals the sum of its two convex constituent rectangles. + # The L is split along the meridian lon=1 and the equator lat=0 (both great + # circles), so the two rectangles tile the L exactly on the sphere. + rectA = GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 3.0), (0.0, 3.0), (0.0, 0.0)]]) + rectB = GI.Polygon([[(1.0, 0.0), (2.0, 0.0), (2.0, 1.0), (1.0, 1.0), (1.0, 0.0)]]) + Lverts = [(0.0, 0.0), (2.0, 0.0), (2.0, 1.0), (1.0, 1.0), (1.0, 3.0), (0.0, 3.0)] + Lshape = GI.Polygon([[Lverts..., Lverts[1]]]) + decomp = GO.area(usph, rectA) + GO.area(usph, rectB) + @test GO.area(usph, Lshape) ≈ decomp rtol = 1e-12 + # The pre-fix unsigned fan returned ≈1.83e-3 here (+50%); the true value is ≈1.22e-3. + @test GO.area(usph, Lshape) < 1.3e-3 + + # (b) Winding independence: reversing the ring gives the same (positive) area. + Lrev = GI.Polygon([reverse([Lverts..., Lverts[1]])]) + @test GO.area(usph, Lrev) ≈ GO.area(usph, Lshape) rtol = 1e-14 + + # Fan-apex independence: area is invariant to which vertex the ring starts at. + for sh in 1:5 + Lrot = GI.Polygon([[[Lverts[mod1(i + sh, 6)] for i in 0:5]..., Lverts[mod1(sh, 6)]]]) + @test GO.area(usph, Lrot) ≈ GO.area(usph, Lshape) rtol = 1e-12 + end + + # (c) Tiny-polygon accuracy is preserved (Eriksson was chosen for this): a 0.01° + # square at the equator matches the BigFloat signed-fan reference to full precision. + tiny = GI.Polygon([[(0.0, 0.0), (0.01, 0.0), (0.01, 0.01), (0.0, 0.01), (0.0, 0.0)]]) + @test GO.area(usph, tiny) ≈ 3.0461741901e-8 rtol = 1e-9 + + # A strongly non-convex 5-pointed star. Reference from the BigFloat signed fan; + # the pre-fix unsigned fan returned ≈1.63e-2 (+82%). + starpts = Tuple{Float64,Float64}[] + for k in 0:9 + r = iseven(k) ? 5.0 : 2.0 + ang = π / 2 + k * π / 5 + push!(starpts, (r * cos(ang), r * sin(ang))) + end + push!(starpts, starpts[1]) + star = GI.Polygon([starpts]) + @test GO.area(usph, star) ≈ 8.9519043343e-3 rtol = 1e-10 + + # (d) Hole subtraction on a concave shell: holed shell == shell − hole. + holering = [(0.2, 0.2), (0.2, 0.7), (0.7, 0.7), (0.7, 0.2), (0.2, 0.2)] + holed = GI.Polygon([[Lverts..., Lverts[1]], holering]) + holepoly = GI.Polygon([holering]) + @test GO.area(usph, holed) ≈ GO.area(usph, Lshape) - GO.area(usph, holepoly) rtol = 1e-12 +end + @testset "area(Spherical(), geom) dispatch" begin # Test that Spherical manifold dispatches correctly octant = GI.Polygon([[(0.0, 0.0), (90.0, 0.0), (0.0, 90.0), (0.0, 0.0)]]) diff --git a/test/methods/extent.jl b/test/methods/extent.jl new file mode 100644 index 0000000000..caa9687d5b --- /dev/null +++ b/test/methods/extent.jl @@ -0,0 +1,204 @@ +using Test +using LinearAlgebra + +import GeometryOps as GO, GeoInterface as GI +import Extents +using GeometryOps.UnitSpherical +using Random: Xoshiro + +# k vertices of the z = z₀ circle, CCW seen from +z (the S2 interior-on-left +# convention: the enclosed region is the cap containing the north pole) +polar_ring(z, k) = [UnitSphericalPoint(sqrt(1 - z^2) * cos(t), sqrt(1 - z^2) * sin(t), z) + for t in range(0, 2π; length = k + 1)[1:(end - 1)]] + +@testset "extent(Planar(), ...)" begin + poly = GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]]) + @test GO.extent(GO.Planar(), poly) == GI.extent(poly) + @test GO.extent(GO.Planar(), GI.Point(1.0, 2.0)) == GI.extent(GI.Point(1.0, 2.0)) +end + +@testset "extent(Spherical(), ...)" begin + m = GO.Spherical() + z = 0.9; s = sqrt(1 - z^2) + + @testset "CCW polar cap ring encloses the pole" begin + ext = GO.extent(m, GI.LinearRing(polar_ring(z, 8))) + @test ext isa Extents.Extent{(:X, :Y, :Z)} + @test ext.Z[2] == 1 + @test ext.Z[1] ≈ z atol = 1e-12 + @test -1 < ext.X[1] && ext.X[2] < 1 + @test -1 < ext.Y[1] && ext.Y[2] < 1 + end + + @testset "CW ring encloses the same cap (default is winding-independent)" begin + rext = GO.extent(m, GI.LinearRing(reverse(polar_ring(z, 8)))) + @test rext == GO.extent(m, GI.LinearRing(polar_ring(z, 8))) + @test rext.Z[2] == 1 # still the polar cap, not the complement + @test rext.X[2] < 1 && rext.Y[2] < 1 + end + + @testset "Geographic polygon around the pole" begin + cap = GI.Polygon([[(lon, 60.0) for lon in 0.0:30.0:360.0]]) + ext = GO.extent(m, cap) + @test ext.Z[2] == 1 + @test ext.Z[1] ≈ sind(60) atol = 1e-12 + end + + @testset "No enclosure: region extent equals curve extent" begin + pts = [(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0), (5.0, 5.0)] + @test GO.extent(m, GI.Polygon([pts])) == GO.extent(m, GI.LineString(pts)) + end + + @testset "Axis point on the boundary clamps, and extends nothing else" begin + pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)] # corner at +eₓ + ext = GO.extent(m, GI.Polygon([pts])) + @test ext.X[2] == 1.0 + @test ext.X[1] > 0.9 && ext.Y[2] < 0.2 && ext.Z[2] < 0.2 + @test ext.Y[1] > -0.1 && ext.Z[1] > -0.1 + end + + @testset "Vertex exactly at the pole" begin + ring = GI.LinearRing([UnitSphericalPoint(0.0, 0.0, 1.0), + UnitSphericalPoint(s, 0.0, z), + UnitSphericalPoint(0.0, s, z), + UnitSphericalPoint(0.0, 0.0, 1.0)]) + ext = GO.extent(m, ring) + @test ext.Z[2] >= 1 + @test all(b -> all(isfinite, b), values(ext)) + end + + @testset "Ring below the equator encloses the southern cap" begin + # the CCW ring at z = cosd(100) has the >hemisphere northern region + # on its left; the default mode bounds the ENCLOSED southern cap + # (the left region is `oriented = true` behavior, tested below) + ext = GO.extent(m, GI.LinearRing(polar_ring(cosd(100), 16))) + @test ext.Z[1] == -1 # south pole enclosed + @test ext.Z[2] ≈ cosd(100) atol = 1e-12 # rim on top; arcs bulge south + @test -1 < ext.X[1] && ext.X[2] < 1 + @test -1 < ext.Y[1] && ext.Y[2] < 1 + end + + @testset "Dumbbell: both poles through a thin corridor" begin + # two polar caps (above ±85°) joined by a corridor over lon ∈ (355°, 5°): + # small area, zero net winding about the polar axis, and both poles and + # (1, 0, 0) interior + north = [(lon, 85.0) for lon in range(5.0, 355.0; length = 15)] # eastward, pole on the left + south = [(lon, -85.0) for lon in range(355.0, 5.0; length = 15)] # westward, pole on the left + ext = GO.extent(m, GI.Polygon([vcat(north, south, [north[1]])])) + @test ext.Z == (-1.0, 1.0) + @test ext.X[2] == 1.0 # (1, 0, 0) is inside the corridor + @test ext.X[1] > -1 # (-1, 0, 0) is outside + @test -0.2 < ext.Y[1] && ext.Y[2] < 0.2 + end + + @testset "Lonlat polar cells: exact pole vertex, no far-pole leak" begin + for lon0 in 0.0:30.0:330.0 + cell = GI.Polygon([[(lon0, 80.0), (lon0 + 30.0, 80.0), + (lon0 + 30.0, 90.0), (lon0, 90.0), (lon0, 80.0)]]) + ext = GO.extent(m, cell) + @test ext.Z[2] == 1.0 # the pole is a vertex + @test ext.Z[1] > 0.9 # the south pole must not leak in + @test ext.X[1] > -1 && ext.X[2] < 1 && ext.Y[1] > -1 && ext.Y[2] < 1 + end + end + + @testset "Points, multis, and collections" begin + p = GI.Point(0.0, 0.0) # lon/lat → (1, 0, 0) + ep = GO.extent(m, p) + @test ep.X[1] == ep.X[2] == 1.0 + emp = GO.extent(m, GI.MultiPoint([(0.0, 0.0), (90.0, 0.0)])) + @test emp.X == (0.0, 1.0) && emp.Y == (0.0, 1.0) + + north = GI.Polygon([[(lon, 60.0) for lon in 0.0:30.0:360.0]]) + south = GI.Polygon([[(lon, -60.0) for lon in 360.0:-30.0:0.0]]) # CCW around the south pole + ext = GO.extent(m, GI.MultiPolygon([north, south])) + @test ext.Z == (-1.0, 1.0) + end + + @testset "LineStrings are curves, not regions" begin + ext = GO.extent(m, GI.LineString(vcat(polar_ring(z, 8), [polar_ring(z, 8)[1]]))) + @test ext.Z[2] < 1 # no interior, no pole + end + + @testset "Random cells contain their samples and enclosed axis points" begin + rng = Xoshiro(2026) + inext(q, e) = e.X[1] <= q[1] <= e.X[2] && e.Y[1] <= q[2] <= e.Y[2] && e.Z[1] <= q[3] <= e.Z[2] + axispoints = [UnitSphericalPoint(v...) for v in + ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))] + for _ in 1:50 + c = rand(rng, UnitSphericalPoint{Float64}) + r = 0.05 + 0.95 * rand(rng) + u = normalize(cross(c, abs(c.z) < 0.9 ? UnitSphericalPoint(0.0, 0.0, 1.0) : UnitSphericalPoint(1.0, 0.0, 0.0))) + v = cross(c, u) + k = rand(rng, 3:8) + ring = [UnitSphericalPoint(cos(r) * c + sin(r) * (cos(t) * u + sin(t) * v)) + for t in range(0, 2π; length = k + 1)[1:(end - 1)]] + ext = GO.extent(m, GI.LinearRing(ring)) + # boundary and interior samples lie inside + samples = [slerp(ring[i], ring[mod1(i + 1, k)], t) + for i in 1:k for t in range(0.0, 1.0; length = 33)] + @test all(q -> inext(q, ext), samples) + @test inext(c, ext) + # the polygon is star-shaped around c, so it contains the cap + # whose radius is the boundary's least distance to c — any axis + # point in that cap must be covered + r_in = 0.98 * minimum(q -> spherical_distance(c, q), samples) + for a in axispoints + spherical_distance(c, a) < r_in && @test inext(a, ext) + end + # the reversed ring encloses the same cap by default… + @test GO.extent(m, GI.LinearRing(reverse(ring))) == ext + # …and bounds the complement under `oriented = true`: it shares + # the boundary, covers axis points outside the cap, and between + # them the two boxes cover every axis point + om = GO.Spherical(oriented = true) + @test GO.extent(om, GI.LinearRing(ring)) == ext # CCW: same region + rext = GO.extent(om, GI.LinearRing(reverse(ring))) + @test all(q -> inext(q, rext), samples) + for a in axispoints + spherical_distance(c, a) > r && @test inext(a, rext) + @test inext(a, ext) || inext(a, rext) + end + end + end +end + +@testset "extent(Spherical(oriented = true), ...)" begin + m = GO.Spherical() + om = GO.Spherical(oriented = true) + z = 0.9 + + @testset "CCW polar cap matches the default mode" begin + ring = GI.LinearRing(polar_ring(z, 8)) + @test GO.extent(om, ring) == GO.extent(m, ring) + @test GO.extent(om, ring).Z[2] == 1 + end + + @testset "CW ring is the complement (left-of-ring region)" begin + ext = GO.extent(om, GI.LinearRing(reverse(polar_ring(z, 8)))) + @test ext.Z[1] == -1 + @test ext.Z[2] < 1 # sup z of the complement is on the boundary + @test ext.X == (-1.0, 1.0) # complement contains ±eₓ and ±e_y + @test ext.Y == (-1.0, 1.0) + end + + @testset "Cap larger than a hemisphere" begin + # the region on the CCW ring's left at z = cosd(100) reaches over + # the north pole and past the equator + ext = GO.extent(om, GI.LinearRing(polar_ring(cosd(100), 16))) + @test ext.Z[2] == 1 + @test ext.X == (-1.0, 1.0) # ±eₓ and ±e_y are interior + @test ext.Y == (-1.0, 1.0) + @test ext.Z[1] < cosd(100) # arcs bulge below the vertex circle + end + + @testset "Geographic CW polygon around the pole is the complement" begin + cap_cw = GI.Polygon([[(lon, 60.0) for lon in 360.0:-30.0:0.0]]) + ext = GO.extent(om, cap_cw) + @test ext.Z[1] == -1 && ext.Z[2] < 1 + # while the default mode reads it as the cap + dext = GO.extent(m, cap_cw) + @test dext.Z[2] == 1 + @test dext.Z[1] ≈ sind(60) atol = 1e-12 + end +end diff --git a/test/methods/geom_relations.jl b/test/methods/geom_relations.jl index 7bba91b049..11b869e1c4 100644 --- a/test/methods/geom_relations.jl +++ b/test/methods/geom_relations.jl @@ -181,26 +181,46 @@ test_pairs = [ (ext1, p2, "ext1", "p2", "Polygon overlapping extent"), ] -function test_geom_relation(GO_f, LG_f, f_name; swap_points=false) - for (g1, g2, sg1, sg2, sdesc) in test_pairs - @testset "$sg1 $sg2 $sdesc" begin - if swap_points - @test_implementations (GO_f($g2, $g1) == LG_f($g2, $g1)) - else - @test_implementations GO_f($g1, $g2) == LG_f($g1, $g2) - end - end - end -end - +# RelateNG cross-validation: map each GO predicate function to its RelateNG +# `pred_*` factory so every existing GO-vs-LibGEOS comparison can also be +# checked against the RelateNG engine. +relateng_pred(::typeof(GO.intersects)) = GO.pred_intersects() +relateng_pred(::typeof(GO.disjoint)) = GO.pred_disjoint() +relateng_pred(::typeof(GO.contains)) = GO.pred_contains() +relateng_pred(::typeof(GO.within)) = GO.pred_within() +relateng_pred(::typeof(GO.covers)) = GO.pred_covers() +relateng_pred(::typeof(GO.coveredby)) = GO.pred_coveredby() +relateng_pred(::typeof(GO.crosses)) = GO.pred_crosses() +relateng_pred(::typeof(GO.touches)) = GO.pred_touches() +relateng_pred(::typeof(GO.overlaps)) = GO.pred_overlaps() +relateng_pred(::typeof(GO.equals)) = GO.pred_equalstopo() + +# RelateNG operates on geometries; convert extents to polygons (the old GO +# predicates do the same internally, see `geom_relations/common.jl`). +relateng_geom(g) = g isa Extents.Extent ? GO.extent_to_polygon(g) : g +relateng_predicate(GO_f, g1, g2) = + GO.relate_predicate(GO.RelateNG(), relateng_pred(GO_f), relateng_geom(g1), relateng_geom(g2)) + +# Divergences where old GO disagrees with LibGEOS/RelateNG (which agree with +# each other): the new behavior is asserted in the loop below; the old engine +# is not consulted for these pairs. One gap IS known: old `GO.overlaps` +# wrongly returns `true` for polygons touching along an edge (DE-9IM +# interior/interior is F, so `overlaps` must be false; RelateNG and LibGEOS +# agree). It is asserted in test/methods/relateng/relate_ng.jl (PublicAPI +# testset) rather than in this list because the pair is not part of +# `test_pairs`. +KNOWN_OLD_GO_GAPS = Tuple{Function, String, String}[] function test_geom_relation(GO_f, alg::GO.Algorithm, f_name; swap_points=false) for (g1, g2, sg1, sg2, sdesc) in test_pairs @testset "$sg1 $sg2 $sdesc" begin + old_go_gap = (GO_f, sg1, sg2) in KNOWN_OLD_GO_GAPS if swap_points - @test_implementations (GO_f($g2, $g1) == GO_f(alg, $g2, $g1)) + old_go_gap || @test_implementations (GO_f($g2, $g1) == GO_f(alg, $g2, $g1)) + @test_implementations relateng_predicate(GO_f, $g2, $g1) == GO_f(alg, $g2, $g1) else - @test_implementations GO_f($g1, $g2) == GO_f(alg, $g1, $g2) + old_go_gap || @test_implementations GO_f($g1, $g2) == GO_f(alg, $g1, $g2) + @test_implementations relateng_predicate(GO_f, $g1, $g2) == GO_f(alg, $g1, $g2) end end end @@ -214,6 +234,20 @@ end @testset "Touches" begin test_geom_relation(GO.touches, GO.GEOS(), "touches") end @testset "Within" begin test_geom_relation(GO.within, GO.GEOS(), "within") end +# `equals` is not part of the relation loop above (old `GO.equals` does not +# accept Extent inputs and is structural rather than topological equality), +# so the RelateNG `pred_equalstopo` mapping gets its own cross-check here. +@testset "Equals (RelateNG)" begin + @testset_implementations begin + @test relateng_predicate(GO.equals, $p2, $p2) == GO.equals($p2, $p2) == LG.equals($p2, $p2) == true + @test relateng_predicate(GO.equals, $p1, $p2) == GO.equals($p1, $p2) == LG.equals($p1, $p2) == false + @test relateng_predicate(GO.equals, $l1, $l1) == GO.equals($l1, $l1) == LG.equals($l1, $l1) == true + @test relateng_predicate(GO.equals, $l1, $l2) == GO.equals($l1, $l2) == LG.equals($l1, $l2) == false + @test relateng_predicate(GO.equals, $l10, $r1) == GO.equals($l10, $r1) == LG.equals($l10, $r1) == true + @test relateng_predicate(GO.equals, $pt1, $pt1) == GO.equals($pt1, $pt1) == LG.equals($pt1, $pt1) == true + @test relateng_predicate(GO.equals, $pt1, $pt2) == GO.equals($pt1, $pt2) == LG.equals($pt1, $pt2) == false + end +end @testset "Overlaps" begin p1 = LG.Point([0.0, 0.0]) @@ -241,21 +275,29 @@ end @testset_implementations "Points/MultiPoints" begin # Two points can't overlap @test GO.overlaps($p1, $p1) == LG.overlaps($p1, $p2) + @test relateng_predicate(GO.overlaps, $p1, $p1) == LG.overlaps($p1, $p2) # No shared points, doesn't overlap @test GO.overlaps($p1, $mp1) == LG.overlaps($p1, $mp1) + @test relateng_predicate(GO.overlaps, $p1, $mp1) == LG.overlaps($p1, $mp1) # One shared point, does overlap @test GO.overlaps($p2, $mp1) == LG.overlaps($p2, $mp1) + @test relateng_predicate(GO.overlaps, $p2, $mp1) == LG.overlaps($p2, $mp1) # All shared points, doesn't overlap @test GO.overlaps($mp1, $mp1) == LG.overlaps($mp1, $mp1) + @test relateng_predicate(GO.overlaps, $mp1, $mp1) == LG.overlaps($mp1, $mp1) # Not all shared points, overlaps @test GO.overlaps($mp1, $mp2) == LG.overlaps($mp1, $mp2) + @test relateng_predicate(GO.overlaps, $mp1, $mp2) == LG.overlaps($mp1, $mp2) # One set of points entirely inside other set, doesn't overlap @test GO.overlaps($mp2, $mp3) == LG.overlaps($mp2, $mp3) + @test relateng_predicate(GO.overlaps, $mp2, $mp3) == LG.overlaps($mp2, $mp3) # Not all points shared, overlaps @test GO.overlaps($mp1, $mp3) == LG.overlaps($mp1, $mp3) + @test relateng_predicate(GO.overlaps, $mp1, $mp3) == LG.overlaps($mp1, $mp3) # Some shared points, overlaps @test GO.overlaps($mp1, $mp2) == LG.overlaps($mp1, $mp2) @test GO.overlaps($mp1, $mp2) == GO.overlaps($mp2, $mp1) + @test relateng_predicate(GO.overlaps, $mp1, $mp2) == relateng_predicate(GO.overlaps, $mp2, $mp1) end l1 = LG.LineString([[0.0, 0.0], [0.0, 10.0]]) @@ -269,13 +311,18 @@ end @testset_implementations "Lines/Rings" begin # Line can't overlap with itself @test GO.overlaps($l1, $l1) == LG.overlaps($l1, $l1) + @test relateng_predicate(GO.overlaps, $l1, $l1) == LG.overlaps($l1, $l1) # Line completely within other line doesn't overlap @test GO.overlaps($l1, $l2) == GO.overlaps($l2, $l1) == LG.overlaps($l1, $l2) + @test relateng_predicate(GO.overlaps, $l1, $l2) == relateng_predicate(GO.overlaps, $l2, $l1) == LG.overlaps($l1, $l2) # Overlapping lines @test GO.overlaps($l1, $l3) == GO.overlaps($l3, $l1) == LG.overlaps($l1, $l3) + @test relateng_predicate(GO.overlaps, $l1, $l3) == relateng_predicate(GO.overlaps, $l3, $l1) == LG.overlaps($l1, $l3) # Lines that don't touch @test GO.overlaps($l1, $l4) == LG.overlaps($l1, $l4) + @test relateng_predicate(GO.overlaps, $l1, $l4) == LG.overlaps($l1, $l4) @test LG.overlaps($r1, $r2) == LG.overlaps($r1, $r2) + @test relateng_predicate(GO.overlaps, $r1, $r2) == LG.overlaps($r1, $r2) end p1 = LG.Polygon([[[0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0]]]) @@ -304,22 +351,30 @@ end @testset_implementations "Polygons/MultiPolygons" begin # Test basic polygons that don't overlap @test GO.overlaps($p1, $p2) == LG.overlaps($p1, $p2) + @test relateng_predicate(GO.overlaps, $p1, $p2) == LG.overlaps($p1, $p2) @test !GO.overlaps($p1, (1, 1)) + @test !relateng_predicate(GO.overlaps, $p1, (1, 1)) @test !GO.overlaps((1, 1), $p2) + @test !relateng_predicate(GO.overlaps, (1, 1), $p2) # Test basic polygons that overlap @test GO.overlaps($p1, $p3) == LG.overlaps($p1, $p3) + @test relateng_predicate(GO.overlaps, $p1, $p3) == LG.overlaps($p1, $p3) # Test one polygon within the other @test GO.overlaps($p2, $p4) == GO.overlaps($p4, $p2) == LG.overlaps($p2, $p4) + @test relateng_predicate(GO.overlaps, $p2, $p4) == relateng_predicate(GO.overlaps, $p4, $p2) == LG.overlaps($p2, $p4) # Test equal polygons @test GO.overlaps($p5, $p5) == LG.overlaps($p5, $p5) + @test relateng_predicate(GO.overlaps, $p5, $p5) == LG.overlaps($p5, $p5) # Test polygon that overlaps with multipolygon @test GO.overlaps($m1, $p3) == LG.overlaps($m1, $p3) + @test relateng_predicate(GO.overlaps, $m1, $p3) == LG.overlaps($m1, $p3) # Test polygon in hole of multipolygon, doesn't overlap @test GO.overlaps($m1, $p4) == LG.overlaps($m1, $p4) + @test relateng_predicate(GO.overlaps, $m1, $p4) == LG.overlaps($m1, $p4) end # Test Line × Line overlaps (GI.Line, not LineString) @@ -328,21 +383,25 @@ end line1 = GI.Line([(0.0, 0.0), (2.0, 0.0)]) line2 = GI.Line([(1.0, 0.0), (3.0, 0.0)]) @test GO.overlaps(line1, line2) == true + @test relateng_predicate(GO.overlaps, line1, line2) == true # Non-overlapping collinear lines line3 = GI.Line([(0.0, 0.0), (1.0, 0.0)]) line4 = GI.Line([(2.0, 0.0), (3.0, 0.0)]) @test GO.overlaps(line3, line4) == false + @test relateng_predicate(GO.overlaps, line3, line4) == false # One line fully contains the other line5 = GI.Line([(0.0, 0.0), (4.0, 0.0)]) line6 = GI.Line([(1.0, 0.0), (2.0, 0.0)]) @test GO.overlaps(line5, line6) == false + @test relateng_predicate(GO.overlaps, line5, line6) == false # Non-collinear lines line7 = GI.Line([(0.0, 0.0), (1.0, 0.0)]) line8 = GI.Line([(0.0, 0.0), (0.0, 1.0)]) @test GO.overlaps(line7, line8) == false + @test relateng_predicate(GO.overlaps, line7, line8) == false end # Test MultiPolygon × MultiPolygon overlaps @@ -357,6 +416,7 @@ end [[[6.0, 6.0], [8.0, 6.0], [8.0, 8.0], [6.0, 8.0], [6.0, 6.0]]] ]) @test GO.overlaps(mp1, mp2) == true + @test relateng_predicate(GO.overlaps, mp1, mp2) == true # Create two multipolygons that don't overlap mp3 = GI.MultiPolygon([ @@ -366,6 +426,7 @@ end [[[5.0, 5.0], [6.0, 5.0], [6.0, 6.0], [5.0, 6.0], [5.0, 5.0]]] ]) @test GO.overlaps(mp3, mp4) == false + @test relateng_predicate(GO.overlaps, mp3, mp4) == false end end @@ -377,10 +438,15 @@ end @testset_implementations begin @test GO.crosses(GI.LineString([(-2.0, 2.0), (4.0, 2.0)]), $line6) == true + @test relateng_predicate(GO.crosses, GI.LineString([(-2.0, 2.0), (4.0, 2.0)]), $line6) == true @test GO.crosses(GI.LineString([(0.5, 2.5), (1.0, 1.0)]), $poly7) == true + @test relateng_predicate(GO.crosses, GI.LineString([(0.5, 2.5), (1.0, 1.0)]), $poly7) == true @test GO.crosses(GI.MultiPoint([(1.0, 2.0), (12.0, 12.0)]), $line7) == true + @test relateng_predicate(GO.crosses, GI.MultiPoint([(1.0, 2.0), (12.0, 12.0)]), $line7) == true @test GO.crosses(GI.MultiPoint([(1.0, 0.0), (12.0, 12.0)]), $line8) == false + @test relateng_predicate(GO.crosses, GI.MultiPoint([(1.0, 0.0), (12.0, 12.0)]), $line8) == false @test GO.crosses(GI.LineString([(-2.0, 2.0), (-4.0, 2.0)]), $poly7) == false + @test relateng_predicate(GO.crosses, GI.LineString([(-2.0, 2.0), (-4.0, 2.0)]), $poly7) == false end end diff --git a/test/methods/relateng/allocations.jl b/test/methods/relateng/allocations.jl new file mode 100644 index 0000000000..9a473efead --- /dev/null +++ b/test/methods/relateng/allocations.jl @@ -0,0 +1,67 @@ +# Allocation discipline and type-stability checks for RelateNG (Task 28). +# +# The hot path (segment-pair classification inside the edge intersector) must +# not allocate per pair; per-call setup (`RelateGeometry`, the topology +# computer's dicts, segment-string extraction) is unavoidable and scales with +# input size, not with the number of segment *pairs*. So: measure `@allocated` +# for a mid-size polygon pair after warmup and assert it stays within 2x of +# the baseline measured when this test was written. A per-segment-pair +# allocation regression on a 256-vertex pair (~65k candidate pairs) blows +# straight through that budget; honest setup-cost drift does not. + +using Test +import GeometryOps as GO +import GeoInterface as GI +using Random + +include(joinpath(@__DIR__, "..", "..", "data", "polygon_generation.jl")) + +const ALG = GO.RelateNG() + +# Deterministic mid-size (256-vertex) polygon pairs. Same generator settings +# as the benchmarks (benchmarks/relateng.jl); validity does not matter for an +# allocation measurement, so no oracle check is needed here. +rng = Xoshiro(42) +const poly_a = GO.tuples(GI.Polygon(generate_random_poly(0.0, 0.0, 256, 2.0, 0.3, 0.1, rng))) +const poly_b = GO.tuples(GI.Polygon(generate_random_poly(2.0, 0.0, 256, 2.0, 0.3, 0.1, rng))) # overlaps a +const poly_c = GO.tuples(GI.Polygon(generate_random_poly(10.0, 0.0, 256, 2.0, 0.3, 0.1, rng))) # disjoint from a + +@testset "allocation budget (256-vertex pairs)" begin + # Baselines measured 2026-06-11 (Julia 1.12.6, Apple M4 Pro macOS): + # intersects, overlapping pair: 355_232 bytes + # intersects, disjoint pair: 112 bytes (extent-filter early exit) + # full relate, overlapping pair: 587_600 bytes + # Budget = 2x measured baseline, except the disjoint case: its baseline is + # a handful of small allocations whose byte size varies across Julia minor + # versions (1.10 Array vs 1.11+ Memory headers), so it gets a generous + # 1 KiB budget — a missed early exit would land at ~355 KB regardless. + for (name, x, y, budget) in [ + ("intersects overlapping", poly_a, poly_b, 2 * 355_232), + ("intersects disjoint", poly_a, poly_c, 1024), + ] + @testset "$name" begin + GO.relate_predicate(ALG, GO.pred_intersects(), x, y) # warmup + allocated = @allocated GO.relate_predicate(ALG, GO.pred_intersects(), x, y) + @test allocated <= budget + end + end + @testset "full relate overlapping" begin + GO.relate(ALG, poly_a, poly_b) # warmup + allocated = @allocated GO.relate(ALG, poly_a, poly_b) + @test allocated <= 2 * 587_600 + end +end + +@testset "type stability" begin + m = GO.Planar() + # Kernel classification: concrete SegSegClass out of tuple points. + @test (@inferred GO.rk_classify_intersection(m, + (0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0); exact = GO.True())) isa GO.SegSegClass + # update_dim! on concrete predicate types. + @inferred GO.update_dim!(GO.pred_intersects(), GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + @inferred GO.update_dim!(GO.RelateMatrixPredicate(), GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + # Full evaluation entry point returns an inferred Bool. + @test (@inferred GO.relate_predicate(ALG, GO.pred_intersects(), poly_a, poly_b)) isa Bool + @test GO.relate_predicate(ALG, GO.pred_intersects(), poly_a, poly_b) + @test !GO.relate_predicate(ALG, GO.pred_intersects(), poly_a, poly_c) +end diff --git a/test/methods/relateng/de9im.jl b/test/methods/relateng/de9im.jl new file mode 100644 index 0000000000..520db1a279 --- /dev/null +++ b/test/methods/relateng/de9im.jl @@ -0,0 +1,72 @@ +using Test +import GeometryOps as GO +import GeometryOps: DE9IM + +@testset "codes" begin + @test GO.LOC_INTERIOR == 0 && GO.LOC_BOUNDARY == 1 && GO.LOC_EXTERIOR == 2 + @test GO.DIM_FALSE == -1 && GO.DIM_P == 0 && GO.DIM_L == 1 && GO.DIM_A == 2 + @test GO.dim_char(GO.DIM_FALSE) == 'F' + @test GO.dim_char(GO.DIM_A) == '2' + @test GO.dim_code('T') == GO.DIM_TRUE && GO.dim_code('*') == GO.DIM_DONTCARE + # JTS Dimension.toDimensionValue upper-cases its input (Dimension.java) + @test GO.dim_code('t') == GO.DIM_TRUE + @test GO.dim_code('f') == GO.DIM_FALSE + @test_throws ArgumentError GO.dim_code('X') +end + +@testset "DimensionLocation" begin + # Constants verbatim from JTS DimensionLocation.java + @test GO.DL_POINT_INTERIOR == 103 + @test GO.DL_LINE_INTERIOR == 110 && GO.DL_LINE_BOUNDARY == 111 + @test GO.DL_AREA_INTERIOR == 120 && GO.DL_AREA_BOUNDARY == 121 + @test GO.dimloc_location(GO.DL_AREA_BOUNDARY) == GO.LOC_BOUNDARY + @test GO.dimloc_location(GO.DL_POINT_INTERIOR) == GO.LOC_INTERIOR + @test GO.dimloc_dimension(GO.DL_LINE_INTERIOR) == GO.DIM_L + @test GO.dimloc_dimension(GO.DL_EXTERIOR) == GO.DIM_FALSE + # Two-arg overload (JTS DimensionLocation.dimension(dimLoc, exteriorDim)): + # exterior returns the passed exterior dimension, others ignore it. + @test GO.dimloc_dimension(GO.DL_EXTERIOR, GO.DIM_A) == GO.DIM_A + @test GO.dimloc_dimension(GO.DL_EXTERIOR, GO.DIM_FALSE) == GO.DIM_FALSE + @test GO.dimloc_dimension(GO.DL_AREA_INTERIOR, GO.DIM_P) == GO.DIM_A + @test GO.dimloc_dimension(GO.DL_LINE_BOUNDARY, GO.DIM_A) == GO.DIM_L + @test GO.dimloc_dimension(GO.DL_POINT_INTERIOR, GO.DIM_A) == GO.DIM_P + @test GO.dimloc_location(GO.DL_EXTERIOR) == GO.LOC_EXTERIOR + @test GO.dimloc_area(GO.LOC_INTERIOR) == GO.DL_AREA_INTERIOR + @test GO.dimloc_line(GO.LOC_BOUNDARY) == GO.DL_LINE_BOUNDARY + @test GO.dimloc_point(GO.LOC_EXTERIOR) == GO.DL_EXTERIOR +end + +@testset "DE9IM" begin + im = DE9IM("212101212") + @test string(im) == "212101212" + @test sprint(print, im) == "212101212" # print and string must agree + @test sprint(show, im) == "DE9IM(\"212101212\")" + @test im[GO.LOC_INTERIOR, GO.LOC_INTERIOR] == GO.DIM_A + @test im[GO.LOC_BOUNDARY, GO.LOC_BOUNDARY] == GO.DIM_P + @test DE9IM() == DE9IM("FFFFFFFFF") + im2 = GO.with_entry(DE9IM(), GO.LOC_INTERIOR, GO.LOC_BOUNDARY, GO.DIM_L) + @test string(im2) == "F1FFFFFFF" + # pattern matching (JTS IntersectionMatrix.matches semantics) + @test GO.im_matches(DE9IM("212101212"), "T*F**FFF*") == false + @test GO.im_matches(DE9IM("2FF1FF212"), "T*F**FFF*") == false + @test GO.im_matches(DE9IM("2FF1FFFF2"), "T*F**FFF*") == true + @test GO.im_matches(DE9IM("0FFFFFFF2"), "0FFFFFFF2") == true + # 'T' pattern also matches a 'T' matrix entry (JTS IntersectionMatrix.matches) + @test GO.im_matches(GO.DE9IM("TFFFFFFFF"), "TFFFFFFFF") == true + # lowercase pattern codes are accepted (JTS Dimension.toDimensionValue upper-cases) + @test GO.im_matches(GO.DE9IM("2FF1FFFF2"), "t*f**fff*") == true + @test_throws ArgumentError DE9IM("212") # wrong length + @test_throws ArgumentError GO.im_matches(DE9IM(), "T*F**FF") # wrong length +end + +@testset "BoundaryNodeRule" begin + @test GO.is_in_boundary(GO.Mod2Boundary(), 1) == true + @test GO.is_in_boundary(GO.Mod2Boundary(), 2) == false + @test GO.is_in_boundary(GO.Mod2Boundary(), 3) == true + @test GO.is_in_boundary(GO.EndpointBoundary(), 1) == true + @test GO.is_in_boundary(GO.EndpointBoundary(), 2) == true + @test GO.is_in_boundary(GO.MultivalentEndpointBoundary(), 1) == false + @test GO.is_in_boundary(GO.MultivalentEndpointBoundary(), 2) == true + @test GO.is_in_boundary(GO.MonovalentEndpointBoundary(), 1) == true + @test GO.is_in_boundary(GO.MonovalentEndpointBoundary(), 2) == false +end diff --git a/test/methods/relateng/edge_intersector.jl b/test/methods/relateng/edge_intersector.jl new file mode 100644 index 0000000000..36adb3a331 --- /dev/null +++ b/test/methods/relateng/edge_intersector.jl @@ -0,0 +1,476 @@ +# Tests for the RelateNG edge segment intersector (edge_intersector.jl), the +# port of JTS EdgeSegmentIntersector.java over the symbolic segment +# classification `rk_classify_intersection` (no JTS JUnit counterpart; per the +# implementation plan, Task 19). +# +# Strategy: hand-built two-string configurations are fed through +# `process_intersections!` for every segment pair (a nested-loop stand-in for +# the Task 20 enumerator), and the exact set of (NodeKey, section count) +# recorded in a `RelateMatrixPredicate`-backed TopologyComputer is asserted: +# +# - proper crossing (one symbolic crossing node, one section pair), +# - T-touch of a line end on a segment interior, +# - shared endpoint (two incidence flags, ONE geometric point), +# - collinear overlap (up to two distinct points, one section pair each), +# - adjacent-segment shared vertex (sections produced ONCE, not twice), +# - ring-wraparound vertex (closing vertex attributed to the first segment), +# - same-string and same-geometry handling. +# +# The once-only predicate `_is_canonical_incidence` is also unit-tested +# directly (mid-string vertex, string start, string end, ring wraparound). + +using Test +import GeometryOps as GO +import GeometryOps: Planar, True, False +import GeoInterface as GI + +const M = Planar() +const EX = True() + +rgeom(g) = GO.RelateGeometry(M, g; exact = EX) + +# A TopologyComputer with a full-matrix predicate attached; returns both. +function im_computer(rga::GO.RelateGeometry, rgb::GO.RelateGeometry) + pred = GO.RelateMatrixPredicate() + return GO.TopologyComputer(pred, rga, rgb), pred +end +im_computer(ga, gb) = im_computer(rgeom(ga), rgeom(gb)) + +imstr(pred) = string(GO.result_im(pred)) + +# Extract the single segment string of a one-element geometry. +single_ss(rg, is_a) = only(GO.extract_segment_strings(rg, is_a, nothing)) + +# Nested-loop enumeration of all segment pairs of two strings (the Task 20 +# accelerated enumerator replaces this; semantics must match). +function run_pairs!(tc, ss0, ss1) + for i0 in 1:(length(ss0.pts) - 1), i1 in 1:(length(ss1.pts) - 1) + GO.process_intersections!(tc, ss0, i0, ss1, i1) + end + return tc +end + +# The recorded node topology as a Dict of NodeKey => number of sections. +node_counts(tc) = Dict(k => length(v.sections) for (k, v) in tc.node_sections) + +# Build a computer over two geometries and run all A x B segment pairs. +function run_case(ga, gb) + rga, rgb = rgeom(ga), rgeom(gb) + tc, pred = im_computer(rga, rgb) + run_pairs!(tc, single_ss(rga, true), single_ss(rgb, false)) + return tc, pred +end + +@testset "_is_canonical_incidence" begin + # open three-point line: segments 1 = (0,0)-(1,0), 2 = (1,0)-(2,0) + line = GI.LineString([(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]) + ss = single_ss(rgeom(line), true) + + # mid-string vertex: owned by the segment it starts (seg 2), not the + # segment it ends (seg 1) + @test !GO._is_canonical_incidence(ss, 1, (1.0, 0.0)) + @test GO._is_canonical_incidence(ss, 2, (1.0, 0.0)) + # string start: owned by the first segment + @test GO._is_canonical_incidence(ss, 1, (0.0, 0.0)) + # string end: final segment of an open string owns its endpoint + @test GO._is_canonical_incidence(ss, 2, (2.0, 0.0)) + # segment-interior points are always owned by their segment + @test GO._is_canonical_incidence(ss, 1, (0.5, 0.0)) + @test GO._is_canonical_incidence(ss, 2, (1.5, 0.0)) + + # ring wraparound: the closing vertex is owned by the first segment, + # NOT by the final segment that ends there + sq = GI.Polygon([[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]) + ring = single_ss(rgeom(sq), true) + @test ring.pts[1] == ring.pts[end] # closed + n_seg = length(ring.pts) - 1 + start_pt = ring.pts[1] + @test GO._is_canonical_incidence(ring, 1, start_pt) + @test !GO._is_canonical_incidence(ring, n_seg, start_pt) + # an ordinary ring vertex behaves like a mid-string vertex + mid_pt = ring.pts[2] + @test !GO._is_canonical_incidence(ring, 1, mid_pt) + @test GO._is_canonical_incidence(ring, 2, mid_pt) +end + +@testset "proper crossing" begin + line_a = GI.LineString([(-1.0, 0.0), (1.0, 0.0)]) + line_b = GI.LineString([(0.0, -1.0), (0.0, 1.0)]) + tc, pred = run_case(line_a, line_b) + key = GO.crossing_node((-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)) + @test node_counts(tc) == Dict(key => 2) + # the crossing lies in both line interiors + @test imstr(pred) == "0FFFFFFF2" + + # argument order is normalized by is_a: feeding (B, A) gives an + # identical computer state + rga, rgb = rgeom(line_a), rgeom(line_b) + tc2, pred2 = im_computer(rga, rgb) + run_pairs!(tc2, single_ss(rgb, false), single_ss(rga, true)) + @test node_counts(tc2) == Dict(key => 2) + @test imstr(pred2) == "0FFFFFFF2" +end + +@testset "T-touch: line end on segment interior" begin + line_a = GI.LineString([(-1.0, 0.0), (1.0, 0.0)]) + # touch point is the END of B's final (open) segment + line_b = GI.LineString([(0.0, 1.0), (0.0, 0.0)]) + tc, _ = run_case(line_a, line_b) + @test node_counts(tc) == Dict(GO.vertex_node((0.0, 0.0)) => 2) + # the node lies in the interior of A's segment, so the A-side section is + # NOT at a vertex; it IS at a vertex of B (B's line end), per the JTS + # createNodeSection semantics + sections = tc.node_sections[GO.vertex_node((0.0, 0.0))].sections + @test !GO.is_node_at_vertex(only(filter(GO.is_a, sections))) + @test GO.is_node_at_vertex(only(filter(!GO.is_a, sections))) + + # touch point is the START of B + line_b2 = GI.LineString([(0.0, 0.0), (0.0, 1.0)]) + tc, _ = run_case(line_a, line_b2) + @test node_counts(tc) == Dict(GO.vertex_node((0.0, 0.0)) => 2) + sections2 = tc.node_sections[GO.vertex_node((0.0, 0.0))].sections + @test !GO.is_node_at_vertex(only(filter(GO.is_a, sections2))) + @test GO.is_node_at_vertex(only(filter(!GO.is_a, sections2))) +end + +@testset "shared endpoint: one point, one section pair" begin + # a0 == b0 sets both a0_on_b and b0_on_a — ONE geometric point + line_a = GI.LineString([(0.0, 0.0), (1.0, 0.0)]) + line_b = GI.LineString([(0.0, 0.0), (0.0, 1.0)]) + tc, _ = run_case(line_a, line_b) + @test node_counts(tc) == Dict(GO.vertex_node((0.0, 0.0)) => 2) +end + +@testset "collinear overlap" begin + # partial overlap: the interval ends are a1 = (3,0) and b0 = (1,0) + line_a = GI.LineString([(0.0, 0.0), (3.0, 0.0)]) + line_b = GI.LineString([(1.0, 0.0), (4.0, 0.0)]) + tc, _ = run_case(line_a, line_b) + @test node_counts(tc) == Dict( + GO.vertex_node((1.0, 0.0)) => 2, + GO.vertex_node((3.0, 0.0)) => 2, + ) + + # containment: B inside one segment of A — both interval ends are B's + line_a2 = GI.LineString([(0.0, 0.0), (4.0, 0.0)]) + line_b2 = GI.LineString([(1.0, 0.0), (2.0, 0.0)]) + tc, _ = run_case(line_a2, line_b2) + @test node_counts(tc) == Dict( + GO.vertex_node((1.0, 0.0)) => 2, + GO.vertex_node((2.0, 0.0)) => 2, + ) + + # identical segments (reversed orientation): all four flags set, but + # only two distinct points + line_b3 = GI.LineString([(3.0, 0.0), (0.0, 0.0)]) + tc, _ = run_case(line_a, line_b3) + @test node_counts(tc) == Dict( + GO.vertex_node((0.0, 0.0)) => 2, + GO.vertex_node((3.0, 0.0)) => 2, + ) +end + +@testset "adjacent-segment shared vertex: sections once, not twice" begin + # A has a mid-string vertex at the origin; B passes through it. Both of + # A's incident segments touch B there, but only the segment STARTING at + # the vertex owns the incidence. + line_a = GI.LineString([(-1.0, 1.0), (0.0, 0.0), (1.0, 1.0)]) + line_b = GI.LineString([(-2.0, 0.0), (2.0, 0.0)]) + tc, _ = run_case(line_a, line_b) + key = GO.vertex_node((0.0, 0.0)) + @test node_counts(tc) == Dict(key => 2) + # the A section is built on segment 2, so it sees both incident edges + sections = tc.node_sections[key].sections + nsa = only(filter(GO.is_a, sections)) + @test GO.get_vertex(nsa, 0) == (-1.0, 1.0) + @test GO.get_vertex(nsa, 1) == (1.0, 1.0) + @test GO.is_node_at_vertex(nsa) + # the node lies in the interior of B's segment, so the B-side section is + # not at a vertex + @test !GO.is_node_at_vertex(only(filter(!GO.is_a, sections))) + + # both strings have a mid-string vertex at the node: 4 touching pairs, + # still exactly one section pair + line_b2 = GI.LineString([(-1.0, -1.0), (0.0, 0.0), (1.0, -1.0)]) + tc, _ = run_case(line_a, line_b2) + @test node_counts(tc) == Dict(key => 2) +end + +@testset "ring wraparound vertex" begin + sq = GI.Polygon([[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]) + line_b = GI.LineString([(-1.0, -1.0), (0.0, 0.0)]) + rga, rgb = rgeom(sq), rgeom(line_b) + tc, _ = im_computer(rga, rgb) + ring = single_ss(rga, true) + run_pairs!(tc, ring, single_ss(rgb, false)) + key = GO.vertex_node((0.0, 0.0)) + @test node_counts(tc) == Dict(key => 2) + # the ring section wraps around: its incident edges are the ring + # vertices either side of the closing point + nsa = only(filter(GO.is_a, tc.node_sections[key].sections)) + @test GO.get_vertex(nsa, 0) == ring.pts[end - 1] + @test GO.get_vertex(nsa, 1) == ring.pts[2] +end + +@testset "same string and same geometry" begin + line_pts = [(-1.0, 1.0), (0.0, 0.0), (1.0, 1.0)] + rga, rgb = rgeom(GI.LineString(line_pts)), rgeom(GI.LineString([(5.0, 5.0), (6.0, 5.0)])) + tc, pred = im_computer(rga, rgb) + ssa = single_ss(rga, true) + # a segment is never intersected with itself + GO.process_intersections!(tc, ssa, 1, ssa, 1) + @test isempty(tc.node_sections) + # adjacent segments of the SAME string share a vertex, but a plain + # string vertex is not a node: no sections in either pair order + GO.process_intersections!(tc, ssa, 1, ssa, 2) + GO.process_intersections!(tc, ssa, 2, ssa, 1) + @test isempty(tc.node_sections) + @test imstr(pred) == "FFFFFFFF2" + + # a self-intersection within geometry A records sections (for node + # analysis) but performs no A/B matrix update + ml = GI.MultiLineString([ + GI.LineString([(-1.0, -1.0), (1.0, 1.0)]), + GI.LineString([(-1.0, 1.0), (1.0, -1.0)]), + ]) + rga2 = rgeom(ml) + tc, pred = im_computer(rga2, rgb) + ssa1, ssa2 = GO.extract_segment_strings(rga2, true, nothing) + GO.process_intersections!(tc, ssa1, 1, ssa2, 1) + key = GO.crossing_node((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)) + @test node_counts(tc) == Dict(key => 2) + @test imstr(pred) == "FFFFFFFF2" +end + +@testset "disjoint segments record nothing" begin + tc, pred = run_case( + GI.LineString([(0.0, 0.0), (1.0, 0.0)]), + GI.LineString([(0.0, 1.0), (1.0, 1.0)])) + @test isempty(tc.node_sections) + @test imstr(pred) == "FFFFFFFF2" +end + +@testset "collinear overlap across multi-segment strings" begin + # B overlaps both segments of A; the overlap interval ends (1,0) and + # (3,0) lie in segment interiors of A, and A's mid-string vertex (2,0) + # lies in B's interior. Three nodes, one section pair each, regardless + # of B's direction. + line_a = GI.LineString([(0.0, 0.0), (2.0, 0.0), (4.0, 0.0)]) + expected = Dict( + GO.vertex_node((1.0, 0.0)) => 2, + GO.vertex_node((2.0, 0.0)) => 2, + GO.vertex_node((3.0, 0.0)) => 2, + ) + for bpts in ([(1.0, 0.0), (3.0, 0.0)], [(3.0, 0.0), (1.0, 0.0)]) + tc, _ = run_case(line_a, GI.LineString(bpts)) + @test node_counts(tc) == expected + end +end + +@testset "ring x ring sharing the closing corner" begin + # two squares touching only at the point that closes BOTH rings: the + # wraparound canonicality rule must fire on both sides at once, so the + # corner is recorded exactly once with one section pair + sq_a = GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]]) + sq_b = GI.Polygon([[(0.0, 0.0), (-1.0, 0.0), (-1.0, -1.0), (0.0, -1.0), (0.0, 0.0)]]) + tc, _ = run_case(sq_a, sq_b) + @test node_counts(tc) == Dict(GO.vertex_node((0.0, 0.0)) => 2) +end + +# --------------------------------------------------------------------------- +# Task 20: accelerated edge set enumeration (`process_edge_intersections!`) +# --------------------------------------------------------------------------- + +# Run the full A x B enumeration through `process_edge_intersections!` with a +# given accelerator. +function run_enum(ga, gb, accelerator) + rga, rgb = rgeom(ga), rgeom(gb) + tc, pred = im_computer(rga, rgb) + ssa_list = GO.extract_segment_strings(rga, true, nothing) + ssb_list = GO.extract_segment_strings(rgb, false, nothing) + GO.process_edge_intersections!(tc, ssa_list, ssb_list, accelerator) + return tc, pred +end + +# Ground truth: the plain all-pairs enumeration used by the Task 19 tests, +# extended over string lists. +function run_all_pairs(ga, gb) + rga, rgb = rgeom(ga), rgeom(gb) + tc, pred = im_computer(rga, rgb) + for ssa in GO.extract_segment_strings(rga, true, nothing), + ssb in GO.extract_segment_strings(rgb, false, nothing) + run_pairs!(tc, ssa, ssb) + end + return tc, pred +end + +# A closed n-gon ring approximating a circle of radius `r` centered at +# `(cx, cy)` (many segments; used to exceed the AutoAccelerator threshold). +ngon(cx, cy, r, n) = GI.Polygon([[ + (cx + r * cos(θ), cy + r * sin(θ)) for θ in range(0, 2π; length = n + 1) +]]) + +@testset "accelerators produce identical computer state" begin + fixtures = [ + # proper crossing + (GI.LineString([(-1.0, 0.0), (1.0, 0.0)]), GI.LineString([(0.0, -1.0), (0.0, 1.0)])), + # T-touch, both directions of B + (GI.LineString([(-1.0, 0.0), (1.0, 0.0)]), GI.LineString([(0.0, 1.0), (0.0, 0.0)])), + (GI.LineString([(-1.0, 0.0), (1.0, 0.0)]), GI.LineString([(0.0, 0.0), (0.0, 1.0)])), + # shared endpoint + (GI.LineString([(0.0, 0.0), (1.0, 0.0)]), GI.LineString([(0.0, 0.0), (0.0, 1.0)])), + # collinear overlaps + (GI.LineString([(0.0, 0.0), (3.0, 0.0)]), GI.LineString([(1.0, 0.0), (4.0, 0.0)])), + (GI.LineString([(0.0, 0.0), (4.0, 0.0)]), GI.LineString([(1.0, 0.0), (2.0, 0.0)])), + (GI.LineString([(0.0, 0.0), (3.0, 0.0)]), GI.LineString([(3.0, 0.0), (0.0, 0.0)])), + # collinear overlap across multi-segment strings, both directions + (GI.LineString([(0.0, 0.0), (2.0, 0.0), (4.0, 0.0)]), GI.LineString([(1.0, 0.0), (3.0, 0.0)])), + (GI.LineString([(0.0, 0.0), (2.0, 0.0), (4.0, 0.0)]), GI.LineString([(3.0, 0.0), (1.0, 0.0)])), + # adjacent-segment shared vertex + (GI.LineString([(-1.0, 1.0), (0.0, 0.0), (1.0, 1.0)]), GI.LineString([(-2.0, 0.0), (2.0, 0.0)])), + # multi-string side: a MultiLineString crossing a line twice + (GI.MultiLineString([ + GI.LineString([(-1.0, -1.0), (-1.0, 1.0)]), + GI.LineString([(1.0, -1.0), (1.0, 1.0)]), + ]), GI.LineString([(-2.0, 0.0), (2.0, 0.0)])), + # ring x ring at the closing corner; ring x line at the wraparound + (GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]]), + GI.Polygon([[(0.0, 0.0), (-1.0, 0.0), (-1.0, -1.0), (0.0, -1.0), (0.0, 0.0)]])), + (GI.Polygon([[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]), + GI.LineString([(-1.0, -1.0), (0.0, 0.0)])), + # disjoint + (GI.LineString([(0.0, 0.0), (1.0, 0.0)]), GI.LineString([(0.0, 1.0), (1.0, 1.0)])), + # heavily-overlapping many-segment rings: above the AutoAccelerator + # threshold, so Auto takes the tree path here + (ngon(0.0, 0.0, 1.0, 64), ngon(0.1, 0.0, 1.0, 64)), + ] + for (ga, gb) in fixtures + tc_truth, pred_truth = run_all_pairs(ga, gb) + for acc in (GO.NestedLoop(), GO.DoubleNaturalTree(), GO.AutoAccelerator()) + tc, pred = run_enum(ga, gb, acc) + @test node_counts(tc) == node_counts(tc_truth) + @test imstr(pred) == imstr(pred_truth) + end + end +end + +# A TopologyPredicate wrapper that counts `is_known` queries. The enumerator +# checks `is_result_known(computer)` (which calls `is_known(predicate)`) +# exactly once after each segment pair it processes, so the count equals the +# number of pairs processed. +mutable struct CountingPredicate{P <: GO.TopologyPredicate} <: GO.TopologyPredicate + const inner::P + known_checks::Int +end +CountingPredicate(inner) = CountingPredicate(inner, 0) +GO.predicate_name(p::CountingPredicate) = GO.predicate_name(p.inner) +GO.require_self_noding(p::CountingPredicate) = GO.require_self_noding(p.inner) +GO.require_interaction(p::CountingPredicate) = GO.require_interaction(p.inner) +GO.require_covers(p::CountingPredicate, is_source_a::Bool) = GO.require_covers(p.inner, is_source_a) +GO.require_exterior_check(p::CountingPredicate, is_source_a::Bool) = GO.require_exterior_check(p.inner, is_source_a) +GO.init_dims!(p::CountingPredicate, dimA::Integer, dimB::Integer) = GO.init_dims!(p.inner, dimA, dimB) +GO.init_bounds!(p::CountingPredicate, extA, extB) = GO.init_bounds!(p.inner, extA, extB) +GO.update_dim!(p::CountingPredicate, locA, locB, dim) = GO.update_dim!(p.inner, locA, locB, dim) +GO.finish!(p::CountingPredicate) = GO.finish!(p.inner) +GO.predicate_value(p::CountingPredicate) = GO.predicate_value(p.inner) +function GO.is_known(p::CountingPredicate) + p.known_checks += 1 + return GO.is_known(p.inner) +end + +# Enumerate with a counting predicate; returns the wrapper for inspection. +function run_counted(ga, gb, accelerator, inner_pred) + rga, rgb = rgeom(ga), rgeom(gb) + pred = CountingPredicate(inner_pred) + tc = GO.TopologyComputer(pred, rga, rgb) + ssa_list = GO.extract_segment_strings(rga, true, nothing) + ssb_list = GO.extract_segment_strings(rgb, false, nothing) + GO.process_edge_intersections!(tc, ssa_list, ssb_list, accelerator) + return pred +end + +@testset "early exit when the result is known" begin + # two heavily-overlapping many-segment rings: an `intersects` predicate + # becomes known at the first proper edge crossing, so the traversal must + # stop long before enumerating every extent-overlapping pair + ga = ngon(0.0, 0.0, 1.0, 64) + gb = ngon(0.1, 0.0, 1.0, 64) + for acc in (GO.NestedLoop(), GO.DoubleNaturalTree()) + # baseline: the full-matrix predicate is never known early, so its + # count is the total number of pairs the enumeration would process + full = run_counted(ga, gb, acc, GO.RelateMatrixPredicate()) + @test !GO.is_known(full.inner) + @test full.known_checks > 0 + + early = run_counted(ga, gb, acc, GO.pred_intersects()) + @test GO.is_known(early.inner) + @test GO.predicate_value(early.inner) + @test 0 < early.known_checks < full.known_checks + end +end + +# --------------------------------------------------------------------------- +# Self-pair enumeration (`process_self_intersections!`) +# --------------------------------------------------------------------------- + +@testset "self-intersections: accelerators produce identical computer state" begin + # A self-intersecting zigzag polyline, well above the AutoAccelerator + # threshold: 41 zigzag vertices oscillating across y = 0, then a return + # segment along y = 0 that properly crosses every zigzag segment. + zig_pts = [(Float64(i), iseven(i) ? 1.0 : -1.0) for i in 0:40] + push!(zig_pts, (40.5, 0.0)) + push!(zig_pts, (-0.5, 0.0)) + zig = GI.LineString(zig_pts) + @test length(zig_pts) - 1 >= GO.GEOMETRYOPS_NO_OPTIMIZE_EDGEINTERSECT_NUMVERTS + + # disjoint far-away B side, so the computer state reflects only A's + # self-noding + rgb_far = rgeom(GI.LineString([(100.0, 100.0), (101.0, 100.0)])) + + function run_self(acc) + rga = rgeom(zig) + tc, pred = im_computer(rga, rgb_far) + GO.process_self_intersections!(tc, + GO.extract_segment_strings(rga, true, nothing), acc) + return node_counts(tc), imstr(pred) + end + + counts_loop, im_loop = run_self(GO.NestedLoop()) + counts_tree, im_tree = run_self(GO.DoubleNaturalTree()) + @test counts_loop == counts_tree + @test im_loop == im_tree + # sanity: the return segment properly crosses all 40 zigzag segments + @test count(k -> k.is_crossing, keys(counts_loop)) == 40 + # self pairs record sections but never update the A/B matrix + @test im_loop == "FFFFFFFF2" +end + +@testset "self-intersections: early exit when the result is known" begin + # Mirror JTS computeEdgesAll: feed the strings of BOTH inputs through + # one self-pair enumeration, so the cross (A x B) pairs update the + # matrix and an `intersects` predicate becomes known at the first proper + # crossing — the traversal must then stop early. + ga = ngon(0.0, 0.0, 1.0, 64) + gb = ngon(0.1, 0.0, 1.0, 64) + function run_self_counted(inner_pred, acc) + rga, rgb = rgeom(ga), rgeom(gb) + pred = CountingPredicate(inner_pred) + tc = GO.TopologyComputer(pred, rga, rgb) + ss_list = vcat(GO.extract_segment_strings(rga, true, nothing), + GO.extract_segment_strings(rgb, false, nothing)) + GO.process_self_intersections!(tc, ss_list, acc) + return pred + end + for acc in (GO.NestedLoop(), GO.DoubleNaturalTree()) + # baseline: the full-matrix predicate is never known early, so its + # count is the total number of pairs the enumeration would process + full = run_self_counted(GO.RelateMatrixPredicate(), acc) + @test !GO.is_known(full.inner) + @test full.known_checks > 0 + + early = run_self_counted(GO.pred_intersects(), acc) + @test GO.is_known(early.inner) + @test GO.predicate_value(early.inner) + @test 0 < early.known_checks < full.known_checks + end +end diff --git a/test/methods/relateng/fuzz.jl b/test/methods/relateng/fuzz.jl new file mode 100644 index 0000000000..ee089c2d72 --- /dev/null +++ b/test/methods/relateng/fuzz.jl @@ -0,0 +1,309 @@ +# Differential fuzzing of RelateNG against LibGEOS (Task 25). +# +# Property: `string(GO.relate(GO.RelateNG(), a, b)) == GEOSRelate(a, b)` for +# seeded random and adversarial geometry pairs. GEOS >= 3.13 runs RelateNG +# natively, so the oracle is the same algorithm family and any divergence is +# either a real bug on our side or an exactness win (GEOS computes in floating +# point; our kernel is exact via AdaptivePredicates). +# +# Valid-input fuzzing only for polygon inputs: JTS/GEOS RelateNG assumes valid +# polygon topology, so random polygons are filtered through `LG.isValid`. +# Self-intersecting (bowtie) rings are still fuzzed — deliberately, as +# LINESTRING inputs (see `bowtie_pair`), where they are valid geometry. +# +# Case count: ~`FUZZ_N` total, split evenly across the generators below. +# Override with `ENV["GO_RELATENG_FUZZ_N"]` for deep local runs (e.g. 5000). +# Each generator has its own fixed-seed RNG stream, so the first cases of a +# deep run are exactly the CI cases and fixtures stay stable across N. + +using Test +using Random +import GeometryOps as GO +import GeoInterface as GI +import LibGEOS as LG + +include(joinpath(@__DIR__, "..", "..", "data", "polygon_generation.jl")) + +# The oracle must be RelateNG-native GEOS (3.13+); bail out loudly otherwise. +VersionNumber(LG.GEOS_VERSION) >= v"3.13" || + error("Differential fuzz requires GEOS >= 3.13 (RelateNG-native oracle); found $(LG.GEOS_VERSION)") + +const FUZZ_N = something(tryparse(Int, get(ENV, "GO_RELATENG_FUZZ_N", "")), 500) +const ALG = GO.RelateNG() + +# -- LibGEOS oracle plumbing -------------------------------------------------- + +const LG_CTX = LG.get_global_context() +# Full-precision WKT so any printed divergence reproduces the exact doubles +# (the default writer trims to ~16 significant digits, which would erase +# ulp-level perturbations). +const WKT_WRITER = LG.WKTWriter(LG_CTX; trim = true, roundingprecision = 17) + +to_lg(g) = GI.convert(LG, g) +full_wkt(lg_geom) = LG.writegeom(lg_geom, WKT_WRITER, LG_CTX) +# LibGEOS has no high-level `relate`; the generated wrapper returns the DE-9IM +# string (and frees the GEOS-owned buffer) itself. +lg_relate(la, lb) = LG.GEOSRelate_r(LG_CTX, la, lb) + +# -- Exactness-wins fixtures -------------------------------------------------- + +# Divergences where our exact kernel is right and FP GEOS is wrong, verified +# by hand with rational arithmetic. Keyed by `(full_wkt(a), full_wkt(b))`; +# the value is OUR (verified-correct) DE-9IM string, asserted instead of the +# oracle. Document the hand verification in a comment on each entry. +const EXACTNESS_WINS = Dict{Tuple{String, String}, String}( +) + +# -- The property ------------------------------------------------------------- + +function check_pair(a, b) + la, lb = to_lg(a), to_lg(b) + wa, wb = full_wkt(la), full_wkt(lb) + ours = try + string(GO.relate(ALG, a, b)) + catch + println("RelateNG error on:\n A = $wa\n B = $wb") + rethrow() + end + fixture = get(EXACTNESS_WINS, (wa, wb), nothing) + if fixture !== nothing + @test ours == fixture + return nothing + end + theirs = lg_relate(la, lb) + if ours != theirs + println("RelateNG/LibGEOS divergence:\n A = $wa\n B = $wb\n GO = $ours GEOS = $theirs") + end + @test ours == theirs + return nothing +end + +# -- Random generators (floating-point coordinates) --------------------------- + +function random_poly(rng) + x, y = 4rand(rng) - 2, 4rand(rng) - 2 + nverts = rand(rng, 4:12) + coords = generate_random_poly(x, y, nverts, 1.0 + rand(rng), 0.3 * rand(rng), 0.2 * rand(rng), rng) + return GI.Polygon([[(Float64(p[1]), Float64(p[2])) for p in coords[1]]]) +end + +# `generate_random_poly` does not guarantee non-self-intersection; reject +# invalid candidates (RelateNG assumes valid polygons, see header note). +function random_valid_polygon(rng) + for _ in 1:50 + poly = random_poly(rng) + LG.isValid(to_lg(poly)) && return poly + end + # Vanishingly unlikely fallback: a unit right triangle at a random offset. + x, y = 4rand(rng) - 2, 4rand(rng) - 2 + return GI.Polygon([[(x, y), (x + 1, y), (x, y + 1), (x, y)]]) +end + +function random_linestring(rng; npts = rand(rng, 2:8)) + x, y = 4rand(rng) - 2, 4rand(rng) - 2 + pts = [(x, y)] + for _ in 2:npts + x += randn(rng) + y += randn(rng) + push!(pts, (x, y)) + end + return GI.LineString(pts) +end + +random_point(rng) = GI.Point(4rand(rng) - 2, 4rand(rng) - 2) + +# -- Integer-grid helpers (exact degeneracies: shared vertices, collinear +# edges, touches actually hit the zero-orientation predicate paths) -------- + +int_pt(rng) = (Float64(rand(rng, -5:5)), Float64(rand(rng, -5:5))) + +# CCW integer triangle ring, optionally through fixed (shared) vertices. +function int_triangle_ring(rng, fixed::Vector{NTuple{2, Float64}} = NTuple{2, Float64}[]) + for _ in 1:100 + pts = copy(fixed) + while length(pts) < 3 + p = int_pt(rng) + p in pts || push!(pts, p) + end + a, b, c = pts + cr = (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]) + cr == 0 && continue + return cr > 0 ? [a, b, c, a] : [a, c, b, a] + end + error("could not build a non-degenerate integer triangle") +end + +int_triangle(rng, fixed = NTuple{2, Float64}[]) = GI.Polygon([int_triangle_ring(rng, fixed)]) + +function int_rect(x0, y0, w, h) + x0, y0, x1, y1 = Float64(x0), Float64(y0), Float64(x0 + w), Float64(y0 + h) + return GI.Polygon([[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]]) +end + +function diamond(cx, cy, r) + cx, cy, r = Float64(cx), Float64(cy), Float64(r) + return GI.Polygon([[(cx - r, cy), (cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]]) +end + +function int_linestring(rng; npts = rand(rng, 2:6)) + pts = NTuple{2, Float64}[] + while length(pts) < npts + p = int_pt(rng) + (isempty(pts) || pts[end] != p) && push!(pts, p) + end + return GI.LineString(pts) +end + +# -- Mixed-type random pairs -------------------------------------------------- + +function line_line_pair(rng) + return rand(rng, Bool) ? (random_linestring(rng), random_linestring(rng)) : + (int_linestring(rng), int_linestring(rng)) +end + +function point_polygon_pair(rng) + # Integer half: the point frequently lands exactly on a vertex or edge. + return rand(rng, Bool) ? (random_point(rng), random_valid_polygon(rng)) : + (GI.Point(int_pt(rng)), int_triangle(rng)) +end + +function line_polygon_pair(rng) + return rand(rng, Bool) ? (random_linestring(rng), random_valid_polygon(rng)) : + (int_linestring(rng), int_triangle(rng)) +end + +# -- Adversarial generators --------------------------------------------------- + +# B reuses 1-2 of A's vertices: exact shared nodes between the inputs. +function shared_vertex_pair(rng) + ra = int_triangle_ring(rng) + nshared = rand(rng, 1:2) + rb = int_triangle_ring(rng, ra[1:nshared]) + return GI.Polygon([ra]), GI.Polygon([rb]) +end + +# Rectangles sharing the line y = y0: collinear overlapping/abutting edges +# (B below A touches edge-to-edge; B on the same side overlaps interiors). +function collinear_edge_pair(rng) + x0, y0 = rand(rng, -5:2), rand(rng, -5:2) + w, h = rand(rng, 1:4), rand(rng, 1:4) + a = int_rect(x0, y0, w, h) + dx = rand(rng, -2:w) + w2, h2 = rand(rng, 1:4), rand(rng, 1:4) + b = rand(rng, Bool) ? int_rect(x0 + dx, y0 - h2, w2, h2) : int_rect(x0 + dx, y0, w2, h2) + return a, b +end + +# nextfloat/prevfloat by 1-3 ulps, random direction. +nudge(rng, v) = (rand(rng, Bool) ? nextfloat : prevfloat)(v, rand(rng, 1:3)) + +# Segment configurations sitting within a few ulps of a crossing, touch, or +# collinear overlap — the FP-noise regime where exact and inexact kernels can +# disagree about orientation signs. +function ulp_pair(rng) + a1 = (2rand(rng) - 1, 2rand(rng) - 1) + a2 = (a1[1] + (rand(rng) + 0.1) * rand(rng, (-1, 1)), a1[2] + (rand(rng) + 0.1) * rand(rng, (-1, 1))) + # FP point approximately on A at parameter t (generally a few ulps off). + seg(t) = (a1[1] + t * (a2[1] - a1[1]), a1[2] + t * (a2[2] - a1[2])) + maybe_nudge(p) = rand(rng, Bool) ? (nudge(rng, p[1]), rand(rng, Bool) ? nudge(rng, p[2]) : p[2]) : p + mode = rand(rng, 1:3) + if mode == 1 + # B ends within ulps of A's interior (near endpoint-on-segment touch). + m = maybe_nudge(seg(0.2 + 0.6 * rand(rng))) + b2 = (m[1] + 2rand(rng) - 1, m[2] + 2rand(rng) - 1) + return GI.LineString([a1, a2]), GI.LineString([m, b2]) + elseif mode == 2 + # B nearly collinear with A, overlapping in parameter range. + t1, t2 = minmax(2rand(rng) - 0.5, 2rand(rng) - 0.5) + t1 == t2 && (t2 += 0.5) + return GI.LineString([a1, a2]), GI.LineString([maybe_nudge(seg(t1)), maybe_nudge(seg(t2))]) + else + # X-crossing through an ulp-nudged interior point of A. + m = seg(0.2 + 0.6 * rand(rng)) + d = (-(a2[2] - a1[2]), a2[1] - a1[1]) + s = rand(rng) + 0.1 + b1 = (m[1] - s * d[1], m[2] - s * d[2]) + b2m = maybe_nudge(m) + b2 = (b2m[1] + s * d[1], b2m[2] + s * d[2]) + return GI.LineString([a1, a2]), GI.LineString([b1, b2]) + end +end + +# Zero-length (degenerate) linestrings against everything. +function zero_length_pair(rng) + mode = rand(rng, 1:4) + if mode == 1 + p = (2rand(rng) - 1, 2rand(rng) - 1) + return GI.LineString([p, p]), random_valid_polygon(rng) + elseif mode == 2 + p = (2rand(rng) - 1, 2rand(rng) - 1) + return GI.LineString([p, p]), random_linestring(rng) + elseif mode == 3 + # Zero-length line exactly at a polygon vertex. + p = int_pt(rng) + return GI.LineString([p, p]), int_triangle(rng, [p]) + else + # Two coincident zero-length lines. + p = int_pt(rng) + return GI.LineString([p, p]), GI.LineString([p, p]) + end +end + +# Polygon rings touching at exactly one point: vertex-vertex (two diamonds) +# or vertex on the interior of an edge (diamond tip on a rectangle side). +function touching_rings_pair(rng) + if rand(rng, Bool) + cx, cy = rand(rng, -3:3), rand(rng, -3:3) + r1, r2 = rand(rng, 1:3), rand(rng, 1:3) + return diamond(cx, cy, r1), diamond(cx + r1 + r2, cy, r2) + else + x0, y0 = rand(rng, -4:1), rand(rng, -4:0) + w, h = rand(rng, 1:3), rand(rng, 2:4) + ty = rand(rng, (y0 + 1):(y0 + h - 1)) + r = rand(rng, 1:3) + return int_rect(x0, y0, w, h), diamond(x0 + w + r, ty, r) + end +end + +# Self-intersecting (bowtie) rings are invalid polygons, so they are fuzzed +# as closed LINESTRINGs, which exercises the line self-noding paths. +function bowtie_ring(rng) + x0, y0 = Float64(rand(rng, -4:2)), Float64(rand(rng, -4:2)) + s = Float64(rand(rng, 1:3)) + return [(x0, y0), (x0 + 2s, y0 + 2s), (x0 + 2s, y0), (x0, y0 + 2s), (x0, y0)] +end + +function bowtie_pair(rng) + a = GI.LineString(bowtie_ring(rng)) + b = rand(rng, Bool) ? GI.LineString(bowtie_ring(rng)) : random_valid_polygon(rng) + return a, b +end + +# -- Driver ------------------------------------------------------------------- + +const GENERATORS = [ + ("polygon/polygon", rng -> (random_valid_polygon(rng), random_valid_polygon(rng))), + ("line/line", line_line_pair), + ("point/polygon", point_polygon_pair), + ("line/polygon", line_polygon_pair), + ("shared vertices", shared_vertex_pair), + ("collinear edges", collinear_edge_pair), + ("ulp near-crossings", ulp_pair), + ("zero-length lines", zero_length_pair), + ("rings touching at points", touching_rings_pair), + ("bowtie linestrings", bowtie_pair), +] + +const CASES_PER_GENERATOR = max(1, cld(FUZZ_N, length(GENERATORS))) + +@testset "RelateNG vs LibGEOS fuzz (N=$FUZZ_N, $CASES_PER_GENERATOR per generator)" begin + for (i, (name, gen)) in enumerate(GENERATORS) + rng = Xoshiro(0x9E1A7E20260000 + i) + @testset "$name" begin + for _ in 1:CASES_PER_GENERATOR + a, b = gen(rng) + check_pair(a, b) + end + end + end +end diff --git a/test/methods/relateng/indexed_point_in_area.jl b/test/methods/relateng/indexed_point_in_area.jl new file mode 100644 index 0000000000..d46985f1d7 --- /dev/null +++ b/test/methods/relateng/indexed_point_in_area.jl @@ -0,0 +1,293 @@ +# Tests for the prepared-mode indexed point-in-area locator +# (indexed_point_in_area.jl): the 1-D y-interval segment index, the +# RayCrossingCounter / IndexedPointInAreaLocator ports, and prepared- vs +# unprepared-mode agreement of RelatePointLocator point location. The +# unprepared SimplePointInAreaLocator ring loop is the oracle: prepared mode +# must locate every point identically. On-edge points are constructed on +# horizontal/vertical edges (exactly representable) and query points +# deliberately share y-coordinates with ring vertices — the classic +# RayCrossingCounter edge cases (vertex on ray, horizontal edge on ray). +# The spherical analogue (longitude-interval index + meridian-arc crossing +# parity against a pole anchor) is tested the same way at the bottom, with +# the unindexed exact ring scan as the oracle. + +using Test +using Random +import GeometryOps as GO +import GeometryOps: Planar, Spherical, True +import GeoInterface as GI +import Extents + +@testset "1-D y-interval stabbing" begin + # the interval-index shape the locator builds: RTree(STR(), items; + # extents = y-intervals), queried with a closed [qmin, qmax] extent + interval_tree(mins, maxs, items) = + GO.FlexibleRTrees.RTree(GO.FlexibleRTrees.STR(), items; + extents = [Extents.Extent(Y = (mins[i], maxs[i])) for i in eachindex(mins)]) + collect_query(tree, qmin, qmax) = begin + out = Int[] + q = Extents.Extent(Y = (qmin, qmax)) + GO.SpatialTreeInterface.depth_first_search(Base.Fix1(Extents.intersects, q), tree) do i + push!(out, tree.data[i]) + end + sort!(out) + end + + # single item + one = interval_tree([1.0], [2.0], [1]) + @test collect_query(one, 1.5, 1.5) == [1] + @test collect_query(one, 2.5, 3.0) == Int[] + @test collect_query(one, 2.0, 3.0) == [1] # closed-interval touch + + # several overlapping intervals, including duplicates and a point interval + mins = [0.0, 1.0, 2.0, 2.0, 5.0, 5.0, -3.0] + maxs = [1.0, 3.0, 4.0, 2.0, 9.0, 6.0, -1.0] + items = collect(1:7) + tree = interval_tree(mins, maxs, items) + brute(qmin, qmax) = sort!([i for i in 1:7 if !(mins[i] > qmax || maxs[i] < qmin)]) + for (qmin, qmax) in [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (2.5, 2.5), + (-4.0, -3.5), (-2.0, 0.5), (3.5, 5.0), (10.0, 11.0), + (-10.0, 10.0), (4.5, 4.9)] + @test collect_query(tree, qmin, qmax) == brute(qmin, qmax) + end +end + +@testset "RayCrossingCounter" begin + m = Planar() + square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)] + locate_in_ring(p, ring) = begin + # port of RayCrossingCounter.locatePointInRing as the usage exemplar + rcc = GO.RayCrossingCounter(m, p; exact = True()) + for i in 2:length(ring) + GO.count_segment!(rcc, ring[i], ring[i - 1]) + GO.is_on_segment(rcc) && return GO.rcc_location(rcc) + end + return GO.rcc_location(rcc) + end + @test locate_in_ring((5.0, 5.0), square) == GO.LOC_INTERIOR + @test locate_in_ring((15.0, 5.0), square) == GO.LOC_EXTERIOR + @test locate_in_ring((-5.0, 5.0), square) == GO.LOC_EXTERIOR + @test locate_in_ring((0.0, 0.0), square) == GO.LOC_BOUNDARY # vertex + @test locate_in_ring((5.0, 0.0), square) == GO.LOC_BOUNDARY # horizontal edge + @test locate_in_ring((10.0, 5.0), square) == GO.LOC_BOUNDARY # vertical edge + # ray passes exactly through vertices: a diamond, query at vertex height + diamond = [(0.0, 0.0), (5.0, -5.0), (10.0, 0.0), (5.0, 5.0), (0.0, 0.0)] + @test locate_in_ring((5.0, 0.0), diamond) == GO.LOC_INTERIOR # ray exits through vertex (10,0) + @test locate_in_ring((-1.0, 0.0), diamond) == GO.LOC_EXTERIOR # ray enters AND exits through vertices + @test locate_in_ring((11.0, 0.0), diamond) == GO.LOC_EXTERIOR + @test locate_in_ring((0.0, 0.0), diamond) == GO.LOC_BOUNDARY +end + +# -- prepared vs unprepared location agreement -------------------------------- + +# 10k-vertex circle +const N_CIRC = 10_000 +circ = [(cos(t), sin(t)) for t in range(0.0, 2pi; length = N_CIRC)] +circ[end] = circ[1] +poly_circle = GI.Polygon([circ]) + +# polygon with two holes; shell/hole1 axis-aligned so on-edge points are exact +shell = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)] +hole1 = [(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0), (2.0, 2.0)] +hole2 = [(6.0, 6.0), (8.0, 6.0), (7.0, 8.0), (6.0, 6.0)] +poly_holes = GI.Polygon([shell, hole1, hole2]) + +# multipolygon: two squares, second with a hole +mp = GI.MultiPolygon([ + [[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0), (0.0, 0.0)]], + [[(10.0, 0.0), (15.0, 0.0), (15.0, 5.0), (10.0, 5.0), (10.0, 0.0)], + [(11.0, 1.0), (14.0, 1.0), (14.0, 4.0), (11.0, 4.0), (11.0, 1.0)]], +]) + +function check_prepared_agreement(geom, pts) + m = Planar() + loc_prep = GO.RelatePointLocator(m, geom; exact = True(), is_prepared = true) + loc_unprep = GO.RelatePointLocator(m, geom; exact = True(), is_prepared = false) + n_mismatch = 0 + for pt in pts + # unprepared = direct ring loop, prepared = indexed locator — a true + # indexed-vs-simple differential on every query + GO.locate(loc_prep, pt) == GO.locate(loc_unprep, pt) || (n_mismatch += 1) + GO.locate_with_dim(loc_prep, pt) == GO.locate_with_dim(loc_unprep, pt) || (n_mismatch += 1) + end + @test n_mismatch == 0 + # cache sanity: prepared mode built (at most) one locator per polygonal + # element; unprepared mode never builds one + @test length(loc_prep.poly_locator) == length(loc_prep.polygons) + @test all(isnothing, loc_unprep.poly_locator) +end + +function check_prepared_relate(geom, pts) + alg = GO.RelateNG() + prep = GO.prepare(alg, geom) + n_mismatch = 0 + for pt in pts + p = GI.Point(pt) + GO.relate(prep, p) == GO.relate(alg, geom, p) || (n_mismatch += 1) + end + @test n_mismatch == 0 +end + +@testset "prepared vs unprepared: 10k circle" begin + pts = Vector{Tuple{Float64, Float64}}() + append!(pts, circ[1:97:end]) # exact vertices + append!(pts, [(0.999c[1], 0.999c[2]) for c in circ[1:301:end]]) # just inside + append!(pts, [(1.001c[1], 1.001c[2]) for c in circ[1:301:end]]) # just outside + append!(pts, [(x, c[2]) for c in circ[1:211:end] for x in (-2.0, 0.0, 0.5, 2.0)]) # share y with vertices + push!(pts, (0.0, 0.0)) + push!(pts, (1.0, 0.0)) # the t = 0 vertex + push!(pts, (0.0, -1.5)) + check_prepared_agreement(poly_circle, pts) + check_prepared_relate(poly_circle, pts) +end + +@testset "prepared vs unprepared: polygon with holes" begin + pts = Vector{Tuple{Float64, Float64}}() + append!(pts, shell); append!(pts, hole1); append!(pts, hole2) # exact vertices + append!(pts, [(5.0, 0.0), (10.0, 5.0), (5.0, 10.0), (0.0, 5.0)]) # on shell edges (horiz + vert) + append!(pts, [(3.0, 2.0), (4.0, 3.0), (3.0, 4.0), (2.0, 3.0)]) # on hole1 edges + append!(pts, [(7.0, 6.0), (6.5, 7.0), (7.5, 7.0)]) # on hole2 edges + # rays through vertices and along horizontal edges, from inside, + # in-hole, and outside positions + append!(pts, [(1.0, 2.0), (3.0, 2.0), (5.0, 2.0), (1.0, 4.0), (5.0, 4.0), + (0.5, 0.0), (-1.0, 0.0), (-1.0, 2.0), (-1.0, 10.0), (5.0, 6.0), + (-1.0, 6.0), (9.0, 8.0)]) + append!(pts, [(3.0, 3.0), (7.0, 6.5), (1.0, 1.0), (5.0, 5.0)]) # hole interiors + interior + append!(pts, [(-1.0, 5.0), (11.0, 5.0), (5.0, -1.0), (5.0, 11.0)]) # exterior + # dense grid: hits vertices, edges, and every shared-y configuration + append!(pts, vec([(x, y) for x in -1.0:0.5:11.0, y in -1.0:0.5:11.0])) + check_prepared_agreement(poly_holes, pts) + check_prepared_relate(poly_holes, pts) +end + +@testset "prepared vs unprepared: multipolygon" begin + pts = Vector{Tuple{Float64, Float64}}() + append!(pts, vec([(x, y) for x in -1.0:0.5:16.0, y in -1.0:0.5:6.0])) + append!(pts, [(2.5, 0.0), (12.5, 0.0), (12.5, 1.0), (12.5, 4.0), # on edges + (11.0, 2.0), (14.0, 2.0), (7.5, 2.5), (12.5, 2.5)]) # in gap / in hole + check_prepared_agreement(mp, pts) + check_prepared_relate(mp, pts) +end + +@testset "empty polygonal element" begin + # the GI.Polygon wrapper cannot represent POLYGON EMPTY (zero rings), so + # exercise the no-segments short-circuit on a directly constructed locator + loc = GO.IndexedPointInAreaLocator(Planar(), True(), nothing, GO._SphPolyRings[], + GO._SPH_SOUTH_POLE, GO.LOC_EXTERIOR) + @test loc.index === nothing + @test GO.locate(loc, (0.0, 0.0)) == GO.LOC_EXTERIOR +end + +@testset "implicitly closed ring" begin + # no repeated closing point: the indexed locator must close the ring, + # matching rk_point_in_ring's assumed-closed semantics + open_tri = GI.Polygon([[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]]) + loc = GO.IndexedPointInAreaLocator(Planar(), open_tri; exact = True()) + @test GO.locate(loc, (1.0, 1.0)) == GO.LOC_INTERIOR + @test GO.locate(loc, (5.0, 5.0)) == GO.LOC_BOUNDARY # on the implicit closing edge + @test GO.locate(loc, (6.0, 6.0)) == GO.LOC_EXTERIOR + @test GO.locate(loc, (5.0, 0.0)) == GO.LOC_BOUNDARY +end + +# -- spherical indexed locator ------------------------------------------------- + +# The unindexed exact ring scan over the cached kernel rings (`indexed = +# false`, the unprepared arm) is the oracle: the longitude-interval stab plus +# meridian-arc crossing parity must locate every point identically. Clouds +# are seeded, and every ring runs in both windings — the enclosed region is +# winding-independent and the orientation bit lives in the ring cache. +@testset "spherical indexed locator" begin + m = Spherical() + kp(ll) = GO._to_kernel_point(m, GI.Point(ll)) + function check_sph_agreement(geom, lls; indexed_built = true) + scan = GO.IndexedPointInAreaLocator(m, geom; exact = True(), indexed = false) + idx = GO.IndexedPointInAreaLocator(m, geom; exact = True()) + @test scan.index === nothing + @test (idx.index isa GO._SphPIAIndex) == indexed_built + n_mismatch = count(ll -> GO.locate(idx, kp(ll)) != GO.locate(scan, kp(ll)), lls) + @test n_mismatch == 0 + end + rng = Xoshiro(11) + cloud(n, lonr, latr) = [(lonr[1] + rand(rng) * (lonr[2] - lonr[1]), + latr[1] + rand(rng) * (latr[2] - latr[1])) for _ in 1:n] + + @testset "mid-latitude star, both windings" begin + star = [(20.0 + (5 + 2rand(rng)) * cosd(t), 40.0 + (5 + 2rand(rng)) * sind(t)) + for t in 0:15:345] + push!(star, star[1]) + for pts in (star, reverse(star)) + check_sph_agreement(GI.Polygon([GI.LinearRing(pts)]), + vcat(cloud(400, (10, 30), (30, 50)), pts)) + end + end + + @testset "antimeridian-crossing box, both windings" begin + # edges straddle lon ±180°, so their intervals split into two index + # entries; queries sit on both sides plus the seam itself + am = [(170.0, -10.0), (-170.0, -10.0), (-170.0, 10.0), (170.0, 10.0), (170.0, -10.0)] + for pts in (am, reverse(am)) + qs = vcat(cloud(200, (160, 180), (-20, 20)), cloud(200, (-180, -160), (-20, 20)), pts, + [(180.0, 0.0), (-180.0, 5.0), (175.0, 0.0), (-175.0, 0.0), (165.0, 0.0), (-165.0, 0.0)]) + check_sph_agreement(GI.Polygon([GI.LinearRing(pts)]), qs) + end + end + + @testset "polar caps, both poles, both windings" begin + # a cap encloses its pole; the pole query exercises the polar-axis + # fallback (no meridian reference arc from a pole), and for the south + # cap the anchor itself is INTERIOR + ncap = [(t, 80.0) for t in 0.0:30.0:330.0]; push!(ncap, ncap[1]) + scap = [(t, -80.0) for t in 0.0:30.0:330.0]; push!(scap, scap[1]) + for pts in (ncap, scap), w in (pts, reverse(pts)) + qs = vcat(cloud(200, (-180, 180), (60, 90)), cloud(100, (-180, 180), (-90, -60)), w, + [(0.0, 90.0), (0.0, -90.0), (45.0, 85.0), (45.0, -85.0)]) + check_sph_agreement(GI.Polygon([GI.LinearRing(w)]), qs) + end + end + + @testset "south pole on the boundary: anchor falls back to the north pole" begin + spb = [(0.0, -90.0), (20.0, -60.0), (-20.0, -60.0), (0.0, -90.0)] + p = GI.Polygon([GI.LinearRing(spb)]) + idx = GO.IndexedPointInAreaLocator(m, p; exact = True()) + @test idx.anchor == GO._SPH_NORTH_POLE + @test idx.anchor_loc == GO.LOC_EXTERIOR + @test GO.locate(idx, kp((0.0, -90.0))) == GO.LOC_BOUNDARY # the pole itself + check_sph_agreement(p, vcat(cloud(300, (-30, 30), (-90, -50)), spb, [(0.0, -90.0)])) + end + + @testset "both poles on the boundary: unindexed scan mode" begin + bpb = [(0.0, -90.0), (20.0, 0.0), (0.0, 90.0), (40.0, 0.0), (0.0, -90.0)] + p = GI.Polygon([GI.LinearRing(bpb)]) + idx = GO.IndexedPointInAreaLocator(m, p; exact = True()) + @test idx.index === nothing # no anchor: every query scans + @test idx.anchor_loc == GO.LOC_BOUNDARY + check_sph_agreement(p, vcat(cloud(300, (-10, 50), (-80, 80)), bpb); + indexed_built = false) + end + + @testset "boundary points locate as LOC_BOUNDARY" begin + eqp = [(0.0, 0.0), (30.0, 0.0), (15.0, 20.0), (0.0, 0.0)] + idx = GO.IndexedPointInAreaLocator(m, GI.Polygon([GI.LinearRing(eqp)]); exact = True()) + for lon in (5.0, 10.0, 15.0, 29.0) # on the equator edge + @test GO.locate(idx, kp((lon, 0.0))) == GO.LOC_BOUNDARY + end + @test GO.locate(idx, kp((0.0, 0.0))) == GO.LOC_BOUNDARY # vertex + @test GO.locate(idx, kp((15.0, 5.0))) == GO.LOC_INTERIOR + @test GO.locate(idx, kp((15.0, -5.0))) == GO.LOC_EXTERIOR + end + + @testset "polygon with a hole" begin + hp = GI.Polygon([GI.LinearRing([(0., 0.), (20., 0.), (20., 20.), (0., 20.), (0., 0.)]), + GI.LinearRing([(5., 5.), (15., 5.), (15., 15.), (5., 15.), (5., 5.)])]) + check_sph_agreement(hp, vcat(cloud(400, (-2, 22), (-2, 22)), + [(10.0, 10.0), (10.0, 5.0), (2.0, 2.0)])) + end + + @testset "empty spherical element" begin + # as in the planar empty case: no rings, no index, everything exterior + loc = GO.IndexedPointInAreaLocator(m, True(), nothing, GO._SphPolyRings[], + GO._SPH_SOUTH_POLE, GO.LOC_EXTERIOR) + @test GO.locate(loc, kp((0.0, 0.0))) == GO.LOC_EXTERIOR + @test GO.locate(loc, kp((0.0, 90.0))) == GO.LOC_EXTERIOR + end +end diff --git a/test/methods/relateng/kernel.jl b/test/methods/relateng/kernel.jl new file mode 100644 index 0000000000..7665c8f10f --- /dev/null +++ b/test/methods/relateng/kernel.jl @@ -0,0 +1,286 @@ +# Tests for the planar RelateKernel: orientation, point-on-segment, +# point-in-ring and interaction bounds (design doc D1/D2). + +using Test +import GeometryOps as GO +import GeometryOps: Planar, True, False +import GeometryOps.UnitSpherical: UnitSphericalPoint +import GeoInterface as GI +import Extents + +const PT = Tuple{Float64, Float64} +m = Planar() + +@testset "rk_orient" begin + @test GO.rk_orient(m, (0.0,0.0), (1.0,0.0), (0.0,1.0); exact = True()) > 0 + @test GO.rk_orient(m, (0.0,0.0), (1.0,0.0), (0.0,-1.0); exact = True()) < 0 + @test GO.rk_orient(m, (0.0,0.0), (1.0,0.0), (2.0,0.0); exact = True()) == 0 + # adversarial near-collinear: exact must get the sign right. + # NOTE: the plan used `0.5 + 1e-17`, but that rounds to exactly 0.5 in + # Float64 (eps(0.5)/2 ≈ 5.6e-17), making the point exactly collinear. + # Use nextfloat(0.5) — one ulp above the line — so the test is meaningful. + a, b = (0.0, 0.0), (1.0, 1.0) + c = (0.5, nextfloat(0.5)) # above the line by one ulp + @test c[2] != 0.5 # guard: perturbation survives rounding + @test GO.rk_orient(m, a, b, c; exact = True()) == GO.rk_orient(m, a, b, (0.5, 0.6); exact = True()) + # plain FP determinant evaluates to exactly 0.0 here; true sign is +1 + @test GO.rk_orient(m, (12.0,12.0), (24.0,24.0), (0.5, nextfloat(0.5)); exact = True()) > 0 +end + +@testset "rk_point_on_segment" begin + @test GO.rk_point_on_segment(m, (0.5,0.5), (0.0,0.0), (1.0,1.0); exact = True()) == true + @test GO.rk_point_on_segment(m, (2.0,2.0), (0.0,0.0), (1.0,1.0); exact = True()) == false # collinear, outside + @test GO.rk_point_on_segment(m, (0.5,0.6), (0.0,0.0), (1.0,1.0); exact = True()) == false + @test GO.rk_point_on_segment(m, (0.0,0.0), (0.0,0.0), (1.0,1.0); exact = True()) == true # endpoint inclusive +end + +@testset "rk_point_in_ring" begin + ring = GI.LinearRing([(0.0,0.0), (10.0,0.0), (10.0,10.0), (0.0,10.0), (0.0,0.0)]) + @test GO.rk_point_in_ring(m, (5.0,5.0), ring; exact = True()) == GO.LOC_INTERIOR + @test GO.rk_point_in_ring(m, (5.0,0.0), ring; exact = True()) == GO.LOC_BOUNDARY + @test GO.rk_point_in_ring(m, (15.0,5.0), ring; exact = True()) == GO.LOC_EXTERIOR +end + +@testset "bounds" begin + pa = GI.Polygon([[(0.0,0.0), (1.0,0.0), (1.0,1.0), (0.0,0.0)]]) + ea = GO.rk_interaction_bounds(m, pa) + @test Extents.intersects(ea, ea) + @test Extents.covers(ea, ea) +end + +@testset "rk_classify_intersection" begin + cl(a0,a1,b0,b1) = GO.rk_classify_intersection(m, a0, a1, b0, b1; exact = True()) + # disjoint + @test cl((0.,0.),(1.,0.),(0.,1.),(1.,1.)).kind == GO.SS_DISJOINT + # proper crossing + r = cl((0.,0.),(2.,2.),(0.,2.),(2.,0.)) + @test r.kind == GO.SS_PROPER + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # touch: b0 on interior of a + r = cl((0.,0.),(2.,0.),(1.,0.),(1.,1.)) + @test r.kind == GO.SS_TOUCH && r.b0_on_a && !r.b1_on_a && !r.a0_on_b && !r.a1_on_b + # touch: shared endpoint + r = cl((0.,0.),(1.,0.),(1.,0.),(1.,1.)) + @test r.kind == GO.SS_TOUCH && r.a1_on_b && r.b0_on_a + # collinear overlap + r = cl((0.,0.),(2.,0.),(1.,0.),(3.,0.)) + @test r.kind == GO.SS_COLLINEAR && r.b0_on_a && r.a1_on_b + # collinear disjoint + @test cl((0.,0.),(1.,0.),(2.,0.),(3.,0.)).kind == GO.SS_DISJOINT + # collinear, touching only at one shared endpoint -> SS_TOUCH + r = cl((0.,0.),(1.,0.),(1.,0.),(2.,0.)) + @test r.kind == GO.SS_TOUCH && r.a1_on_b && r.b0_on_a + # containment: b inside a (collinear) + r = cl((0.,0.),(3.,0.),(1.,0.),(2.,0.)) + @test r.kind == GO.SS_COLLINEAR && r.b0_on_a && r.b1_on_a + # degenerate: zero-length b on a + r = cl((0.,0.),(2.,0.),(1.,0.),(1.,0.)) + @test r.kind == GO.SS_TOUCH && r.b0_on_a && r.b1_on_a + + # --- additional edge cases (beyond the plan) --- + # T-touch: a0 on b's interior, non-collinear segments + r = cl((1.,0.),(2.,1.),(0.,0.),(2.,0.)) + @test r.kind == GO.SS_TOUCH && r.a0_on_b && !r.a1_on_b && !r.b0_on_a && !r.b1_on_a + # proper crossing with swapped sides: orientations (+,-)/(-,+) vs (-,+)/(+,-) + r = cl((0.,2.),(2.,0.),(0.,0.),(2.,2.)) + @test r.kind == GO.SS_PROPER + # almost-crossing: b straddles line(a) but a does not reach line(b) + r = cl((0.,0.),(1.,1.),(3.,0.),(3.,4.)) + @test r.kind == GO.SS_DISJOINT + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # almost-crossing the other way: a straddles line(b) but b does not reach line(a) + @test cl((3.,0.),(3.,4.),(0.,0.),(1.,1.)).kind == GO.SS_DISJOINT + # both zero-length at the same point: degenerate touch, all flags true + r = cl((1.,1.),(1.,1.),(1.,1.),(1.,1.)) + @test r.kind == GO.SS_TOUCH && r.a0_on_b && r.a1_on_b && r.b0_on_a && r.b1_on_a + # both zero-length at different points (collinear trivially): disjoint + r = cl((0.,0.),(0.,0.),(1.,0.),(1.,0.)) + @test r.kind == GO.SS_DISJOINT + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # collinear, shared endpoint, opposite directions (no interior overlap) + r = cl((1.,0.),(0.,0.),(1.,0.),(2.,0.)) + @test r.kind == GO.SS_TOUCH && r.a0_on_b && r.b0_on_a && !r.a1_on_b && !r.b1_on_a + # identical segments: collinear, all four flags + r = cl((0.,0.),(1.,0.),(0.,0.),(1.,0.)) + @test r.kind == GO.SS_COLLINEAR && r.a0_on_b && r.a1_on_b && r.b0_on_a && r.b1_on_a + + # --- systematic symmetry checks over representative configurations --- + seg_pairs = [ + ((0.,0.),(1.,0.),(0.,1.),(1.,1.)), # disjoint + ((0.,0.),(2.,2.),(0.,2.),(2.,0.)), # proper crossing + ((0.,0.),(2.,0.),(1.,0.),(1.,1.)), # touch: b0 on interior of a + ((0.,0.),(1.,0.),(1.,0.),(1.,1.)), # touch: shared endpoint + ((0.,0.),(2.,0.),(1.,0.),(3.,0.)), # collinear overlap + ((0.,0.),(1.,0.),(2.,0.),(3.,0.)), # collinear disjoint + ((0.,0.),(1.,0.),(1.,0.),(2.,0.)), # collinear abutment + ((0.,0.),(3.,0.),(1.,0.),(2.,0.)), # collinear containment + ((0.,0.),(2.,0.),(1.,0.),(1.,0.)), # zero-length b on a + ((1.,0.),(2.,1.),(0.,0.),(2.,0.)), # T-touch: a0 on b's interior + ((0.,0.),(1.,1.),(3.,0.),(3.,4.)), # almost-crossing + ((0.,0.),(1.,0.),(0.,0.),(1.,0.)), # identical segments + ] + for (a0, a1, b0, b1) in seg_pairs + r = cl(a0, a1, b0, b1) + # swapping A and B: same kind, flags permuted + s = cl(b0, b1, a0, a1) + @test s.kind == r.kind + @test (s.a0_on_b, s.a1_on_b, s.b0_on_a, s.b1_on_a) == (r.b0_on_a, r.b1_on_a, r.a0_on_b, r.a1_on_b) + # reversing a's endpoints: kind invariant, a's flags swapped, b's unchanged + v = cl(a1, a0, b0, b1) + @test v.kind == r.kind + @test (v.a0_on_b, v.a1_on_b, v.b0_on_a, v.b1_on_a) == (r.a1_on_b, r.a0_on_b, r.b0_on_a, r.b1_on_a) + end +end + +@testset "NodeKey" begin + v = GO.vertex_node((1.0, 2.0)) + v2 = GO.vertex_node((1.0, 2.0)) + @test v == v2 && hash(v) == hash(v2) + c1 = GO.crossing_node((0.,0.), (2.,2.), (0.,2.), (2.,0.)) + c2 = GO.crossing_node((0.,2.), (2.,0.), (2.,2.), (0.,0.)) # same pair, swapped & reversed + @test c1 == c2 && hash(c1) == hash(c2) + @test v != c1 + # signed zeros: -0.0 == 0.0 numerically but has different bits; constructors + # must normalize so the default bit-pattern ==/hash agrees with coordinate equality + z1 = GO.vertex_node((-0.0, 0.0)) + z2 = GO.vertex_node((0.0, -0.0)) + @test z1 == z2 && hash(z1) == hash(z2) + cz1 = GO.crossing_node((-0.0,-2.), (0.,2.), (-2.,0.), (2.,-0.0)) + cz2 = GO.crossing_node((0.0,-2.), (-0.0,2.), (-2.,-0.0), (2.,0.)) + @test cz1 == cz2 && hash(cz1) == hash(cz2) +end + +@testset "rk_quadrant" begin + # JTS Quadrant convention: NE=0, NW=1, SW=2, SE=3, numbered CCW from the + # positive X-axis; axis directions belong to the `>= 0` side (so +x and + # +y are NE, -x is NW, -y is SE). + o = (0.0, 0.0) + @test GO.rk_quadrant(m, o, (1.,0.)) == 0 # +x axis is NE + @test GO.rk_quadrant(m, o, (1.,1.)) == 0 + @test GO.rk_quadrant(m, o, (0.,1.)) == 0 # +y axis is NE + @test GO.rk_quadrant(m, o, (-1.,1.)) == 1 + @test GO.rk_quadrant(m, o, (-1.,0.)) == 1 # -x axis is NW + @test GO.rk_quadrant(m, o, (-1.,-1.)) == 2 + @test GO.rk_quadrant(m, o, (0.,-1.)) == 3 # -y axis is SE + @test GO.rk_quadrant(m, o, (1.,-1.)) == 3 + @test GO.rk_quadrant(m, (2.0, 3.0), (3.0, 3.0)) == 0 # non-origin apex + @test_throws ArgumentError GO.rk_quadrant(m, o, (0.0, 0.0)) # zero-length direction +end + +@testset "edge ordering around a vertex node" begin + # Sign expectations verified against the Java contract + # (PolygonNodeTopology.compareAngle): returns negative / zero / positive + # as angle(P) is less than / equal to / greater than angle(Q), with + # angles increasing CCW from the positive X-axis. Different quadrants + # decide the comparison; same-quadrant ties are resolved by + # Orientation.index(origin, q, p) (CCW -> P greater). + origin = GO.vertex_node((0.0, 0.0)) + east, north, west, south = (1.,0.), (0.,1.), (-1.,0.), (0.,-1.) + vcmp(p, q) = GO.rk_compare_edge_dir(m, origin, p, q; exact = True()) + @test vcmp(east, north) < 0 # same quadrant (NE holds both + axes), orientation resolves + @test vcmp(north, west) < 0 # NE=0 < NW=1 + @test vcmp(west, south) < 0 # NW=1 < SE=3 (south lies on the dx>=0 side) + @test vcmp(east, east) == 0 + @test vcmp(north, east) > 0 + # same quadrant resolved by orientation + @test vcmp((2.0, 1.0), (1.0, 2.0)) < 0 + # full fan in strictly increasing angular order: comparator must be a + # total order matching position in the fan (antisymmetry included) + fan = [(1.,0.), (2.,1.), (1.,1.), (1.,2.), (0.,1.), # NE: 0..90 + (-1.,2.), (-1.,1.), (-2.,1.), (-1.,0.), # NW: (90)..180 + (-2.,-1.), (-1.,-1.), (-1.,-2.), # SW: (180)..(270) + (0.,-1.), (1.,-2.), (1.,-1.), (2.,-1.)] # SE: 270..(360) + for i in eachindex(fan), j in eachindex(fan) + @test vcmp(fan[i], fan[j]) == (i == j ? 0 : (i < j ? -1 : 1)) + end +end + +@testset "crossing-node incident edge order" begin + # a: (0,0)->(2,2), b: (0,2)->(2,0); crossing at symbolic (1,1) + dirs = GO.rk_crossing_dirs_ccw(m, (0.,0.), (2.,2.), (0.,2.), (2.,0.); exact = True()) + # CCW order starting from direction toward a1=(2,2): + @test dirs == ((2.,2.), (0.,2.), (0.,0.), (2.,0.)) + # reversing b gives the same cyclic order (other orientation branch) + dirs2 = GO.rk_crossing_dirs_ccw(m, (0.,0.), (2.,2.), (2.,0.), (0.,2.); exact = True()) + @test dirs2 == ((2.,2.), (0.,2.), (0.,0.), (2.,0.)) + + # rk_compare_edge_dir with a crossing apex reproduces compareAngle + # anchored at the positive X-axis of the (symbolic) crossing point (1,1): + # angles from the apex: (2,2)=45deg, (0,2)=135deg, (0,0)=225deg, (2,0)=315deg + cn = GO.crossing_node((0.,0.), (2.,2.), (0.,2.), (2.,0.)) + ccmp(p, q) = GO.rk_compare_edge_dir(m, cn, p, q; exact = True()) + order = [(2.,2.), (0.,2.), (0.,0.), (2.,0.)] + for i in eachindex(order), j in eachindex(order) + @test ccmp(order[i], order[j]) == (i == j ? 0 : (i < j ? -1 : 1)) + end + # directions which are not incident endpoints (D3 coincidence-merged + # nodes) fall back to the exact rational apex (1,1): (5,5) lies on the + # same ray from the apex as (2,2) + @test ccmp((5.,5.), (2.,2.)) == 0 + @test ccmp((5.,5.), (0.,2.)) < 0 + @test ccmp((0.,2.), (5.,5.)) > 0 + # same-quadrant pair around a crossing apex, resolved by orientation: + # a: (0,0)->(4,1) x b: (1,-1)->(2,4) cross properly at (24/19, 6/19); + # both a1=(4,1) and b1=(2,4) are NE of the apex, with angle(4,1) smaller + cn2 = GO.crossing_node((0.,0.), (4.,1.), (1.,-1.), (2.,4.)) + @test GO.rk_compare_edge_dir(m, cn2, (4.,1.), (2.,4.); exact = True()) < 0 + @test GO.rk_compare_edge_dir(m, cn2, (2.,4.), (4.,1.); exact = True()) > 0 + @test GO.rk_compare_edge_dir(m, cn2, (2.,4.), (2.,4.); exact = True()) == 0 +end + +@testset "isCrossing / isInteriorSegment" begin + n = (1.0, 1.0) + @test GO.rk_is_crossing(m, GO.vertex_node(n), (0.,0.), (2.,2.), (0.,2.), (2.,0.); exact = True()) + @test !GO.rk_is_crossing(m, GO.vertex_node(n), (0.,0.), (2.,2.), (2.,0.), (2.,2.); exact = True()) + # both b-arms on the same side of the a-corner: a touch, not a crossing + @test !GO.rk_is_crossing(m, GO.vertex_node(n), (0.,0.), (2.,2.), (0.,2.), (1.,2.); exact = True()) + # collinear arm -> reported as not crossing (Java contract) + @test !GO.rk_is_crossing(m, GO.vertex_node(n), (0.,0.), (2.,2.), (0.,0.), (2.,0.); exact = True()) + # crossing-node apexes are rejected: proper crossings are crossings by + # construction (JTS TopologyComputer.updateAreaAreaCross short-circuits + # them with `isProper ||` before ever calling isCrossing) + cn = GO.crossing_node((0.,0.), (2.,2.), (0.,2.), (2.,0.)) + @test_throws ArgumentError GO.rk_is_crossing(m, cn, (0.,0.), (2.,2.), (0.,2.), (2.,0.); exact = True()) + + # isInteriorSegment: corner a0 -> node -> a1, ring interior on the right + nd = GO.vertex_node((0.0, 0.0)) + @test !GO.rk_is_interior_segment(m, nd, (0.,1.), (1.,0.), (1.,1.); exact = True()) + @test GO.rk_is_interior_segment(m, nd, (0.,1.), (1.,0.), (-1.,-1.); exact = True()) + # reversed corner flips the interior side + @test GO.rk_is_interior_segment(m, nd, (1.,0.), (0.,1.), (1.,1.); exact = True()) + @test !GO.rk_is_interior_segment(m, nd, (1.,0.), (0.,1.), (-1.,-1.); exact = True()) + @test_throws ArgumentError GO.rk_is_interior_segment(m, cn, (0.,0.), (2.,2.), (1.,1.); exact = True()) +end + +@testset "exact crossing coincidence (rational slow path)" begin + # X crossing at exactly (1,1); a vertex node placed there must coincide + c = GO.crossing_node((0.,0.), (2.,2.), (0.,2.), (2.,0.)) + @test GO.rk_nodes_coincide(m, c, GO.vertex_node((1.0, 1.0)); exact = True()) == true + @test GO.rk_nodes_coincide(m, c, GO.vertex_node((1.0, 1.0 + eps(1.0))); exact = True()) == false + # two crossings meeting at the same point + c2 = GO.crossing_node((1.,0.), (1.,2.), (0.,1.), (2.,1.)) + @test GO.rk_nodes_coincide(m, c, c2; exact = True()) == true + # crossing point with non-representable rational coordinates + c3 = GO.crossing_node((0.,0.), (3.,1.), (0.,1.), (3.,0.)) # crosses at (1.5, 0.5) + @test GO.rk_nodes_coincide(m, c3, GO.vertex_node((1.5, 0.5)); exact = True()) == true +end + +@testset "node helpers are N-dimensional (3D round-trips)" begin + u = UnitSphericalPoint(0.0, -0.0, 1.0) + k = GO.vertex_node(u) + # signed zero normalized away, all three components preserved + @test GI.x(k.pt) == 0.0 && GI.y(k.pt) == 0.0 && GI.z(k.pt) == 1.0 + @test !signbit(GI.y(k.pt)) # -0.0 -> +0.0 + # planar 2-tuple path unchanged + k2 = GO.vertex_node((3.0, 4.0)) + @test k2.pt == (3.0, 4.0) +end + +@testset "crossing_node canonicalizes in 3D and is order-invariant" begin + a0 = UnitSphericalPoint(1.0, 0.0, 0.0); a1 = UnitSphericalPoint(0.0, 1.0, 0.0) + b0 = UnitSphericalPoint(0.0, 0.0, 1.0); b1 = UnitSphericalPoint(1.0, 1.0, 1.0) + k1 = GO.crossing_node(a0, a1, b0, b1) + k2 = GO.crossing_node(b1, b0, a1, a0) # same pair, every order/orientation flipped + @test k1 == k2 + @test k1.pt isa UnitSphericalPoint # USP preserved through canonicalization +end diff --git a/test/methods/relateng/kernel_conformance.jl b/test/methods/relateng/kernel_conformance.jl new file mode 100644 index 0000000000..42ad583747 --- /dev/null +++ b/test/methods/relateng/kernel_conformance.jl @@ -0,0 +1,276 @@ +# RelateKernel conformance testset (design layer contract, Task 9). +# +# This suite is the *specification* a kernel implementation must satisfy: +# it is written as a function over a manifold so that a future `Spherical` +# kernel can be instantiated against the very same property checks. Inputs +# are drawn from a small integer grid with a fixed-seed RNG, so every +# orientation/classification has an exactly representable answer and the +# suite is deterministic across runs. + +using Test +import GeometryOps as GO +import GeometryOps: Planar, True, False +import GeoInterface as GI +using Random + +# `rk_orient` may return Float64 (plain determinant) or Int (exact +# predicate); all properties are stated on signs. +_sgn(x) = Int(sign(x)) + +# --- Ground-truth reference for the crossing-apex differential test --- +# +# A direct reimplementation of JTS PolygonNodeTopology.compareAngle, +# evaluated at the *exact rational apex* of a proper crossing using +# Rational{BigInt} arithmetic throughout (Float64 values are dyadic +# rationals, so the conversion is exact). This is intentionally independent +# of the kernel's symbolic-apex derivation in `rk_compare_edge_dir`. + +# Quadrant convention identical to rk_quadrant: NE=0, NW=1, SW=2, SE=3, +# axis directions on the `>= 0` side. +function _ref_quadrant(ox::Rational{BigInt}, oy::Rational{BigInt}, p) + px, py = Rational{BigInt}(p[1]), Rational{BigInt}(p[2]) + @assert !(px == ox && py == oy) + if px >= ox + return py >= oy ? 0 : 3 + else + return py >= oy ? 1 : 2 + end +end + +# Sign of orient(origin, q, p) over rationals (positive when p is CCW of q). +function _ref_orient_sign(ox::Rational{BigInt}, oy::Rational{BigInt}, q, p) + R = Rational{BigInt} + qx, qy = R(q[1]), R(q[2]) + px, py = R(p[1]), R(p[2]) + return Int(sign((qx - ox) * (py - oy) - (qy - oy) * (px - ox))) +end + +# compareAngle anchored at the positive X-axis of the rational apex. +function _ref_compare_angle(apex::Tuple{Rational{BigInt}, Rational{BigInt}}, p, q) + ox, oy = apex + quadrant_p = _ref_quadrant(ox, oy, p) + quadrant_q = _ref_quadrant(ox, oy, q) + quadrant_p > quadrant_q && return 1 + quadrant_p < quadrant_q && return -1 + return _ref_orient_sign(ox, oy, q, p) +end + +function kernel_conformance_suite(m; exact) + # Fixed seed: same property sample within a Julia version (Julia only + # guarantees within-version RNG stream reproducibility). The property + # checks below are sample-independent, and the count thresholds + # (`n_proper > 20`/`> 50`, etc.) are robust to any reasonable uniform + # sample from this grid. + rng = Random.MersenneTwister(0x5e1a7e) + # Random point on a small integer grid: all predicates exactly decidable. + rpt() = (Float64(rand(rng, -8:8)), Float64(rand(rng, -8:8))) + + @testset "rk_orient: antisymmetry / cyclic invariance / degeneracy" begin + for _ in 1:500 + a, b, c = rpt(), rpt(), rpt() + o = _sgn(GO.rk_orient(m, a, b, c; exact)) + # antisymmetry: swapping any two arguments flips the sign + @test o == -_sgn(GO.rk_orient(m, b, a, c; exact)) + @test o == -_sgn(GO.rk_orient(m, a, c, b; exact)) + # cyclic invariance + @test o == _sgn(GO.rk_orient(m, b, c, a; exact)) + @test o == _sgn(GO.rk_orient(m, c, a, b; exact)) + # degeneracy: any repeated point is collinear + @test GO.rk_orient(m, a, a, b; exact) == 0 + @test GO.rk_orient(m, a, b, a; exact) == 0 + @test GO.rk_orient(m, a, b, b; exact) == 0 + end + end + + @testset "rk_classify_intersection: symmetry and incidence consistency" begin + n_proper = 0 + n_touch = 0 + n_collinear = 0 + for _ in 1:1000 + a0, a1, b0, b1 = rpt(), rpt(), rpt(), rpt() + r = GO.rk_classify_intersection(m, a0, a1, b0, b1; exact) + # swapping A and B: kind invariant, flag pairs permuted + s = GO.rk_classify_intersection(m, b0, b1, a0, a1; exact) + @test s.kind == r.kind + @test (s.a0_on_b, s.a1_on_b, s.b0_on_a, s.b1_on_a) == + (r.b0_on_a, r.b1_on_a, r.a0_on_b, r.a1_on_b) + # reversing a segment: kind invariant, its two flags swapped + v = GO.rk_classify_intersection(m, a1, a0, b0, b1; exact) + @test v.kind == r.kind + @test (v.a0_on_b, v.a1_on_b, v.b0_on_a, v.b1_on_a) == + (r.a1_on_b, r.a0_on_b, r.b0_on_a, r.b1_on_a) + w = GO.rk_classify_intersection(m, a0, a1, b1, b0; exact) + @test w.kind == r.kind + @test (w.a0_on_b, w.a1_on_b, w.b0_on_a, w.b1_on_a) == + (r.a0_on_b, r.a1_on_b, r.b1_on_a, r.b0_on_a) + # SS_PROPER: crossing strictly interior to both segments, + # so no endpoint incidence whatsoever + if r.kind == GO.SS_PROPER + n_proper += 1 + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # a proper crossing means each endpoint is strictly off the + # other segment's line: all four orientations are nonzero + @test GO.rk_orient(m, b0, b1, a0; exact) != 0 + @test GO.rk_orient(m, b0, b1, a1; exact) != 0 + @test GO.rk_orient(m, a0, a1, b0; exact) != 0 + @test GO.rk_orient(m, a0, a1, b1; exact) != 0 + end + r.kind == GO.SS_TOUCH && (n_touch += 1) + r.kind == GO.SS_COLLINEAR && (n_collinear += 1) + # each incidence flag agrees exactly with rk_point_on_segment + @test r.a0_on_b == GO.rk_point_on_segment(m, a0, b0, b1; exact) + @test r.a1_on_b == GO.rk_point_on_segment(m, a1, b0, b1; exact) + @test r.b0_on_a == GO.rk_point_on_segment(m, b0, a0, a1; exact) + @test r.b1_on_a == GO.rk_point_on_segment(m, b1, a0, a1; exact) + end + # the random sample must actually exercise the interesting kinds + @test n_proper > 20 + @test n_touch > 0 + @test n_collinear > 0 + + # shared-endpoint configurations (non-collinear) classify as touch + r = GO.rk_classify_intersection(m, (0., 0.), (1., 0.), (0., 0.), (0., 1.); exact) + @test r.kind == GO.SS_TOUCH && r.a0_on_b && r.b0_on_a + end + + @testset "rk_compare_edge_dir: strict weak order on a 16-direction fan" begin + # fan of 16 directions in strictly increasing angular order around + # a non-origin apex, covering all four quadrants and both axes + apex = (3.0, -2.0) + node = GO.vertex_node(apex) + dirs = [(1., 0.), (2., 1.), (1., 1.), (1., 2.), (0., 1.), # NE + (-1., 2.), (-1., 1.), (-2., 1.), (-1., 0.), # NW + (-2., -1.), (-1., -1.), (-1., -2.), # SW + (0., -1.), (1., -2.), (1., -1.), (2., -1.)] # SE + fan = [(apex[1] + dx, apex[2] + dy) for (dx, dy) in dirs] + cmp(p, q) = GO.rk_compare_edge_dir(m, node, p, q; exact) + for p in fan + @test cmp(p, p) == 0 # irreflexivity of < + end + for p in fan, q in fan + @test cmp(p, q) == -cmp(q, p) # antisymmetry + end + for p in fan, q in fan, r in fan # transitivity + if cmp(p, q) < 0 && cmp(q, r) < 0 + @test cmp(p, r) < 0 + elseif cmp(p, q) == 0 && cmp(q, r) == 0 + @test cmp(p, r) == 0 + end + end + # the comparator reproduces the fan's angular order exactly + for i in eachindex(fan), j in eachindex(fan) + @test cmp(fan[i], fan[j]) == (i == j ? 0 : (i < j ? -1 : 1)) + end + end + + # The crossing-apex differential test is planar-specific: it uses the + # planar internal `GO._exact_crossing_point` and a planar-Cartesian + # rational reference (`_ref_compare_angle`). A Spherical kernel must + # supply its own differential reference for this property. + if m isa GO.Planar + @testset "rk_compare_edge_dir: crossing apex vs exact rational reference" begin + # Differential test (Task 8 review item): for proper crossings on an + # integer grid, the symbolic crossing-apex comparison must agree in + # sign with compareAngle evaluated at the exact rational apex. + n_proper = 0 + for _ in 1:2000 + a0, a1, b0, b1 = rpt(), rpt(), rpt(), rpt() + r = GO.rk_classify_intersection(m, a0, a1, b0, b1; exact) + r.kind == GO.SS_PROPER || continue + n_proper += 1 + node = GO.crossing_node(a0, a1, b0, b1) + apex = GO._exact_crossing_point(a0, a1, b0, b1) + endpoints = (a0, a1, b0, b1) + # a few random non-endpoint direction points (integer-grid, + # distinct from the apex and the four endpoints), so the + # ordered pairs below mix the symbolic fast path (endpoint + # directions) with the rational-apex fallback (foreign + # directions) in one ordering + extras = Tuple{Float64, Float64}[] + while length(extras) < 3 + p = rpt() + (p in endpoints || p in extras) && continue + (Rational{BigInt}(p[1]), Rational{BigInt}(p[2])) == apex && continue + push!(extras, p) + end + pts = [endpoints..., extras...] + for p in pts, q in pts + @test _sgn(GO.rk_compare_edge_dir(m, node, p, q; exact)) == + _ref_compare_angle(apex, p, q) + end + # sortperm consistency: sorting the mixed fan by the kernel + # comparator must equal sorting by the rational reference + # (both sorts are stable, so pairwise sign agreement implies + # identical permutations) + perm_kernel = sortperm(pts; + lt = (p, q) -> GO.rk_compare_edge_dir(m, node, p, q; exact) < 0) + perm_ref = sortperm(pts; + lt = (p, q) -> _ref_compare_angle(apex, p, q) < 0) + @test perm_kernel == perm_ref + end + @test n_proper > 50 # the filter kept a meaningful sample + end + end + + @testset "rk_nodes_coincide: reflexive, symmetric, consistent with ==" begin + v1 = GO.vertex_node((2.0, 3.0)) + v2 = GO.vertex_node((2.0, 3.0)) + v3 = GO.vertex_node((2.0, 4.0)) + c1 = GO.crossing_node((0., 0.), (2., 2.), (0., 2.), (2., 0.)) + c2 = GO.crossing_node((2., 0.), (0., 2.), (2., 2.), (0., 0.)) # same pair, permuted + c3 = GO.crossing_node((0., 0.), (3., 1.), (0., 1.), (3., 0.)) # crosses elsewhere + keys = (v1, v2, v3, c1, c2, c3) + for k in keys + @test GO.rk_nodes_coincide(m, k, k; exact) # reflexive + end + for k1 in keys, k2 in keys + @test GO.rk_nodes_coincide(m, k1, k2; exact) == + GO.rk_nodes_coincide(m, k2, k1; exact) # symmetric + if k1 == k2 + @test GO.rk_nodes_coincide(m, k1, k2; exact) # == implies coincide + end + end + @test v1 == v2 && GO.rk_nodes_coincide(m, v1, v2; exact) + @test c1 == c2 && GO.rk_nodes_coincide(m, c1, c2; exact) + @test !GO.rk_nodes_coincide(m, v1, v3; exact) + @test !GO.rk_nodes_coincide(m, c1, c3; exact) + # cross-kind coincidence: c1 crosses at exactly (1, 1) + @test GO.rk_nodes_coincide(m, c1, GO.vertex_node((1.0, 1.0)); exact) + @test !GO.rk_nodes_coincide(m, c3, GO.vertex_node((1.0, 1.0)); exact) + # slow path: two *distinct* crossing pairs sharing the same apex — + # keys differ, yet the nodes coincide at (1, 1) + c_a = GO.crossing_node((0., 0.), (2., 2.), (0., 2.), (2., 0.)) + c_b = GO.crossing_node((1., 0.), (1., 2.), (0., 1.), (2., 1.)) + @test c_a != c_b + @test GO.rk_nodes_coincide(m, c_a, c_b; exact) + end + + @testset "rk_point_in_ring agrees with rk_point_on_segment on edges" begin + # non-convex ring with a reflex vertex; integer coordinates so edge + # midpoints are exactly representable + ring_pts = [(0., 0.), (8., 0.), (8., 8.), (4., 4.), (0., 8.), (0., 0.)] + ring = GI.LinearRing(ring_pts) + edges = [(ring_pts[i], ring_pts[i + 1]) for i in 1:length(ring_pts)-1] + on_edge(p) = any(GO.rk_point_on_segment(m, p, q0, q1; exact) for (q0, q1) in edges) + # deterministic probes: vertices, edge midpoints, interior, exterior + midpoints = [((q0[1] + q1[1]) / 2, (q0[2] + q1[2]) / 2) for (q0, q1) in edges] + probes = vcat(ring_pts, midpoints, [(2.0, 2.0), (4.0, 5.0), (9.0, 1.0), (4.0, 7.0)]) + # randomized probes on the grid covering inside/outside/boundary + for _ in 1:300 + push!(probes, (Float64(rand(rng, -1:9)), Float64(rand(rng, -1:9)))) + end + for p in probes + loc = GO.rk_point_in_ring(m, p, ring; exact) + @test (loc == GO.LOC_BOUNDARY) == on_edge(p) + end + end +end + +@testset "Kernel conformance: Planar" begin + @testset "exact = $E" for E in (True(), False()) + # exact = False() is included because all suite inputs are small + # integer-grid coordinates, for which plain Float64 determinants are + # already exact — both code paths must satisfy the contract here. + kernel_conformance_suite(Planar(); exact = E) + end +end diff --git a/test/methods/relateng/kernel_conformance_spherical.jl b/test/methods/relateng/kernel_conformance_spherical.jl new file mode 100644 index 0000000000..4c9b31cb26 --- /dev/null +++ b/test/methods/relateng/kernel_conformance_spherical.jl @@ -0,0 +1,336 @@ +# Spherical RelateKernel conformance suite (design layer contract, Task 9). +# Standalone counterpart to kernel_conformance.jl. Inputs are exactly- +# representable integer xyz vectors in *general position*: every predicate is +# a sign of an integer determinant, so the exact answer is unambiguous and the +# suite is deterministic. Points are NOT unit length — sign predicates do not +# require it; where unit length matters (arc membership) we use exact-on-grid +# configurations. + +using Test +import GeometryOps as GO +import GeometryOps: Spherical, True, False +import GeometryOps.UnitSpherical: UnitSphericalPoint, slerp +import GeometryOps.Extents as Extents +import GeoInterface as GI +using Random +using LinearAlgebra: ⋅, cross + +const USPt = UnitSphericalPoint{Float64} +_sgn(x) = Int(sign(x)) +_usp(x, y, z) = UnitSphericalPoint(Float64(x), Float64(y), Float64(z)) + +function kernel_conformance_suite_spherical(m; exact) + rng = Random.MersenneTwister(0x5e1a7e) + # random integer-component direction (a point on the sphere only up to + # scaling; sign predicates are scale-invariant in each argument) + rpt() = _usp(rand(rng, -8:8), rand(rng, -8:8), rand(rng, -8:8)) + function nonzero() + p = rpt() + while iszero(GI.x(p)) && iszero(GI.y(p)) && iszero(GI.z(p)) + p = rpt() + end + return p + end + + @testset "rk_orient: antisymmetry / cyclic invariance / degeneracy" begin + for _ in 1:500 + a, b, c = nonzero(), nonzero(), nonzero() + o = _sgn(GO.rk_orient(m, a, b, c; exact)) + @test o == -_sgn(GO.rk_orient(m, b, a, c; exact)) + @test o == -_sgn(GO.rk_orient(m, a, c, b; exact)) + @test o == _sgn(GO.rk_orient(m, b, c, a; exact)) + @test o == _sgn(GO.rk_orient(m, c, a, b; exact)) + @test GO.rk_orient(m, a, a, b; exact) == 0 + @test GO.rk_orient(m, a, b, a; exact) == 0 + @test GO.rk_orient(m, a, b, b; exact) == 0 + end + end + @testset "rk_point_on_segment: endpoints, midpoint, off-arc" begin + a = _usp(1, 0, 0); b = _usp(0, 1, 0) + @test GO.rk_point_on_segment(m, a, a, b; exact) # endpoint + @test GO.rk_point_on_segment(m, b, a, b; exact) # endpoint + @test GO.rk_point_on_segment(m, _usp(1, 1, 0), a, b; exact) # interior (same great circle, within span) + @test !GO.rk_point_on_segment(m, _usp(0, 0, 1), a, b; exact) # pole, off the circle + @test !GO.rk_point_on_segment(m, _usp(-1, 1, 0), a, b; exact) # on circle, outside the minor-arc span + # zero-length arc (repeated ring vertex): only its endpoint direction + # is on it — the degenerate span test must not accept every point + @test GO.rk_point_on_segment(m, a, a, a; exact) + @test GO.rk_point_on_segment(m, _usp(2, 0, 0), a, a; exact) # same direction, different scale + @test !GO.rk_point_on_segment(m, b, a, a; exact) + @test !GO.rk_point_on_segment(m, _usp(-1, 0, 0), a, a; exact) # antipode of the endpoint + end + # arc containment (bulge capture) is the shared `spherical_arc_extent`'s + # contract, tested exhaustively in test/utils/unitspherical.jl + + @testset "rk_interaction_bounds is 3D and contains the converted vertices" begin + ring = GI.LinearRing([(0.,0.), (10.,0.), (10.,10.), (0.,10.), (0.,0.)]) + e = GO.rk_interaction_bounds(m, ring) + @test hasproperty(e, :Z) + for p in GI.getpoint(ring) + u = GO.rk_normalize_usp(UnitSphericalPoint((Float64(GI.x(p)), Float64(GI.y(p))))) + @test e.X[1] <= GI.x(u) <= e.X[2] + @test e.Y[1] <= GI.y(u) <= e.Y[2] + @test e.Z[1] <= GI.z(u) <= e.Z[2] + end + end + @testset "rk_classify_intersection: symmetry and incidence consistency" begin + n_proper = 0; n_touch = 0; n_collinear = 0 + for _ in 1:2000 + a0, a1, b0, b1 = nonzero(), nonzero(), nonzero(), nonzero() + r = GO.rk_classify_intersection(m, a0, a1, b0, b1; exact) + # swapping A and B: kind invariant, flag pairs permuted + s = GO.rk_classify_intersection(m, b0, b1, a0, a1; exact) + @test s.kind == r.kind + @test (s.a0_on_b, s.a1_on_b, s.b0_on_a, s.b1_on_a) == + (r.b0_on_a, r.b1_on_a, r.a0_on_b, r.a1_on_b) + # reversing a segment: kind invariant, its two flags swapped + v = GO.rk_classify_intersection(m, a1, a0, b0, b1; exact) + @test v.kind == r.kind + @test (v.a0_on_b, v.a1_on_b, v.b0_on_a, v.b1_on_a) == + (r.a1_on_b, r.a0_on_b, r.b0_on_a, r.b1_on_a) + w = GO.rk_classify_intersection(m, a0, a1, b1, b0; exact) + @test w.kind == r.kind + @test (w.a0_on_b, w.a1_on_b, w.b0_on_a, w.b1_on_a) == + (r.a0_on_b, r.a1_on_b, r.b1_on_a, r.b0_on_a) + if r.kind == GO.SS_PROPER + n_proper += 1 + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # proper: each endpoint strictly off the other arc's great circle + @test GO.rk_orient(m, b0, b1, a0; exact) != 0 + @test GO.rk_orient(m, b0, b1, a1; exact) != 0 + @test GO.rk_orient(m, a0, a1, b0; exact) != 0 + @test GO.rk_orient(m, a0, a1, b1; exact) != 0 + end + r.kind == GO.SS_TOUCH && (n_touch += 1) + r.kind == GO.SS_COLLINEAR && (n_collinear += 1) + # each incidence flag agrees exactly with rk_point_on_segment + @test r.a0_on_b == GO.rk_point_on_segment(m, a0, b0, b1; exact) + @test r.a1_on_b == GO.rk_point_on_segment(m, a1, b0, b1; exact) + @test r.b0_on_a == GO.rk_point_on_segment(m, b0, a0, a1; exact) + @test r.b1_on_a == GO.rk_point_on_segment(m, b1, a0, a1; exact) + end + # proper crossings are common for two random great circles; touch and + # collinear (two coplanar great circles) are rare/measure-zero on the + # sphere, so they are exercised by the explicit cases below. + @test n_proper > 20 + + # --- hand-built decidable configurations --- + # proper crossing: two axis-aligned great circles meeting at +x=(1,0,0), + # strictly interior to both minor arcs + r = GO.rk_classify_intersection(m, _usp(1,0,1), _usp(1,0,-1), _usp(1,1,0), _usp(1,-1,0); exact) + @test r.kind == GO.SS_PROPER + @test !(r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a) + # shared-endpoint touch (non-collinear arcs sharing (1,0,0)) + r = GO.rk_classify_intersection(m, _usp(1,0,0), _usp(0,1,0), _usp(1,0,0), _usp(0,0,1); exact) + @test r.kind == GO.SS_TOUCH && r.a0_on_b && r.b0_on_a + # collinear overlap on the equator: arcs [+x,(1,1,0)] and [(1,1,0)... ] overlapping + r = GO.rk_classify_intersection(m, _usp(1,0,0), _usp(0,1,0), _usp(1,1,0), _usp(-1,1,0); exact) + @test r.kind == GO.SS_COLLINEAR + # collinear disjoint on the equator: [+x, (2,1,0)] vs [(-1,2,0), -y] + r = GO.rk_classify_intersection(m, _usp(1,0,0), _usp(2,1,0), _usp(-1,2,0), _usp(0,-1,0); exact) + @test r.kind == GO.SS_DISJOINT + # T-touch: b0 on the interior of arc a (a on equator, b dips to the pole) + r = GO.rk_classify_intersection(m, _usp(1,0,0), _usp(0,1,0), _usp(1,1,0), _usp(0,0,1); exact) + @test r.kind == GO.SS_TOUCH && r.b0_on_a && !r.a0_on_b + end + @testset "angle ordering: vertex-apex fan reproduces planar order" begin + # apex +x; reference axis is +y (argmin |component|), so the tangent + # frame is (u,v) = (+y,+z) and a direction-point (0,dx,dy) has tangent + # coords exactly (dx,dy) — the spherical comparator reproduces the planar + # fan order verbatim. + apex = _usp(1, 0, 0) + node = GO.vertex_node(apex) + dirs = [(1,0),(2,1),(1,1),(1,2),(0,1), + (-1,2),(-1,1),(-2,1),(-1,0), + (-2,-1),(-1,-1),(-1,-2), + (0,-1),(1,-2),(1,-1),(2,-1)] + fan = [_usp(0, dx, dy) for (dx, dy) in dirs] + vcmp(p, q) = GO.rk_compare_edge_dir(m, node, p, q; exact) + for p in fan + @test vcmp(p, p) == 0 + end + for i in eachindex(fan), j in eachindex(fan) + @test vcmp(fan[i], fan[j]) == (i == j ? 0 : (i < j ? -1 : 1)) + end + for i in eachindex(fan), j in eachindex(fan), k in eachindex(fan) + if vcmp(fan[i], fan[j]) < 0 && vcmp(fan[j], fan[k]) < 0 + @test vcmp(fan[i], fan[k]) < 0 + end + end + @test_throws ArgumentError GO.rk_quadrant(m, apex, apex) # zero-length direction + end + + @testset "isCrossing / isInteriorSegment on a spherical corner" begin + # apex +x, direction-point (0,dx,dy) -> planar direction (dx,dy); the + # planar truth table maps over verbatim. + nd = GO.vertex_node(_usp(1, 0, 0)) + d(dx, dy) = _usp(0, dx, dy) + # X cross: a arms (1,1)/(-1,-1), b arms (-1,1)/(1,-1) + @test GO.rk_is_crossing(m, nd, d(-1,-1), d(1,1), d(-1,1), d(1,-1); exact) + # collinear b arm -> not crossing + @test !GO.rk_is_crossing(m, nd, d(-1,-1), d(1,1), d(-1,-1), d(1,-1); exact) + # both b arms same side of the a-line -> not crossing + @test !GO.rk_is_crossing(m, nd, d(-1,-1), d(1,1), d(-1,1), d(0,1); exact) + # crossing-node apex is rejected + cn = GO.crossing_node(_usp(1,0,1), _usp(1,0,-1), _usp(1,1,0), _usp(1,-1,0)) + @test_throws ArgumentError GO.rk_is_crossing(m, cn, d(-1,-1), d(1,1), d(-1,1), d(1,-1); exact) + + # isInteriorSegment: corner a0->node->a1, ring interior on the right + @test !GO.rk_is_interior_segment(m, nd, d(0,1), d(1,0), d(1,1); exact) + @test GO.rk_is_interior_segment(m, nd, d(0,1), d(1,0), d(-1,-1); exact) + @test GO.rk_is_interior_segment(m, nd, d(1,0), d(0,1), d(1,1); exact) + @test !GO.rk_is_interior_segment(m, nd, d(1,0), d(0,1), d(-1,-1); exact) + end + + @testset "rk_crossing_dirs_ccw and crossing-apex comparison" begin + # proper crossing at +x=(1,0,0): a-arc (1,0,1)->(1,0,-1), b-arc (1,1,0)->(1,-1,0) + a0, a1, b0, b1 = _usp(1,0,1), _usp(1,0,-1), _usp(1,1,0), _usp(1,-1,0) + @test GO.rk_classify_intersection(m, a0, a1, b0, b1; exact).kind == GO.SS_PROPER + ccw = GO.rk_crossing_dirs_ccw(m, a0, a1, b0, b1; exact) + @test Set(ccw) == Set((a0, a1, b0, b1)) # a permutation of the four endpoints + @test ccw[1] == a1 # starts from a1 (contract) + # the crossing-apex comparator is a strict weak order on the four arms + cn = GO.crossing_node(a0, a1, b0, b1) + ccmp(p, q) = GO.rk_compare_edge_dir(m, cn, p, q; exact) + arms = (a0, a1, b0, b1) + for x in arms + @test ccmp(x, x) == 0 + end + for x in arms, y in arms + @test ccmp(x, y) == -ccmp(y, x) # antisymmetry + x === y || @test ccmp(x, y) != 0 # distinct arms have distinct angles + end + end + @testset "rk_nodes_coincide: reflexive, symmetric, cross-kind" begin + # crossing at +x via two different segment pairs + cx_a = GO.crossing_node(_usp(1,0,1), _usp(1,0,-1), _usp(1,1,0), _usp(1,-1,0)) + cx_b = GO.crossing_node(_usp(2,1,0), _usp(2,-1,0), _usp(2,0,1), _usp(2,0,-1)) + # crossing at +y + cy = GO.crossing_node(_usp(0,1,1), _usp(0,1,-1), _usp(1,1,0), _usp(-1,1,0)) + vx = GO.vertex_node(_usp(1,0,0)) + vx2 = GO.vertex_node(_usp(2,0,0)) # parallel to +x -> same sphere point + vy = GO.vertex_node(_usp(0,1,0)) + keys = (cx_a, cx_b, cy, vx, vx2, vy) + for k in keys + @test GO.rk_nodes_coincide(m, k, k; exact) # reflexive + end + for k1 in keys, k2 in keys + @test GO.rk_nodes_coincide(m, k1, k2; exact) == + GO.rk_nodes_coincide(m, k2, k1; exact) # symmetric + end + # vertex coincidence by parallelism (not exact bit equality) + @test vx != vx2 && GO.rk_nodes_coincide(m, vx, vx2; exact) + @test !GO.rk_nodes_coincide(m, vx, vy; exact) + # cross-kind: vertex lies on a proper crossing + @test GO.rk_nodes_coincide(m, cx_a, vx; exact) + @test GO.rk_nodes_coincide(m, cx_a, vx2; exact) + @test !GO.rk_nodes_coincide(m, cy, vx; exact) + # two distinct crossing pairs meeting at the same apex (+x) + @test cx_a != cx_b && GO.rk_nodes_coincide(m, cx_a, cx_b; exact) + @test !GO.rk_nodes_coincide(m, cx_a, cy; exact) + # antipodal directions are NOT the same point + @test !GO.rk_nodes_coincide(m, GO.vertex_node(_usp(1,1,0)), GO.vertex_node(_usp(-1,-1,0)); exact) + end + @testset "rk_point_in_ring: parity, boundary, winding independence" begin + # CCW-from-above diamond at z=1 encircling the north pole; integer + # vertices (membership decidable: boundary via exact coplanarity, the + # meridian-parity crossings are scale-invariant signs). + verts = [_usp(2,0,1), _usp(0,2,1), _usp(-2,0,1), _usp(0,-2,1), _usp(2,0,1)] + ring = GI.LinearRing(verts) + @test GO.rk_point_in_ring(m, _usp(2,0,1), ring; exact) == GO.LOC_BOUNDARY # vertex + @test GO.rk_point_in_ring(m, _usp(1,1,1), ring; exact) == GO.LOC_BOUNDARY # edge midpoint + @test GO.rk_point_in_ring(m, _usp(-1,1,1), ring; exact) == GO.LOC_BOUNDARY # edge midpoint + @test GO.rk_point_in_ring(m, _usp(1,1,10), ring; exact) == GO.LOC_INTERIOR # polar cap + @test GO.rk_point_in_ring(m, _usp(1,0,8), ring; exact) == GO.LOC_INTERIOR + @test GO.rk_point_in_ring(m, _usp(3,1,0), ring; exact) == GO.LOC_EXTERIOR # equatorial + @test GO.rk_point_in_ring(m, _usp(3,-1,0), ring; exact) == GO.LOC_EXTERIOR + + # Winding independence (kernel contract): a ring encloses the same + # region — the one smaller than a hemisphere — in either winding, like + # the planar ray-crossing parity. Boundary is unchanged too. + rev = GI.LinearRing(reverse(verts)) + @test GO.rk_point_in_ring(m, _usp(1,1,10), rev; exact) == GO.LOC_INTERIOR + @test GO.rk_point_in_ring(m, _usp(3,1,0), rev; exact) == GO.LOC_EXTERIOR + @test GO.rk_point_in_ring(m, _usp(1,1,1), rev; exact) == GO.LOC_BOUNDARY + end + @testset "area interaction bounds reach the enclosed pole" begin + verts = [_usp(2,0,1), _usp(0,2,1), _usp(-2,0,1), _usp(0,-2,1), _usp(2,0,1)] + poly = GI.Polygon([GI.LinearRing(verts)]) + e = GO.rk_interaction_bounds(m, poly) + @test e.Z[2] >= 1.0 # interior reaches the enclosed north pole + # the boundary ring (curve) bound tops out well below the pole + eb = GO.rk_interaction_bounds(m, GI.LinearRing(verts)) + @test eb.Z[2] < 0.99 + # only the enclosed +z axis is extended; the equatorial axes are not + @test e.X == eb.X && e.Y == eb.Y && e.Z[1] == eb.Z[1] + end + # testsets added task-by-task below +end + +@testset "Kernel conformance: Spherical" begin + @testset "exact = $E" for E in (True(), False()) + kernel_conformance_suite_spherical(Spherical(); exact = E) + end +end + +# `_ring_is_ccw(::Spherical)`: the S2 turning-angle (`GetCurvature`) +# formulation. Exact-independent (turn signs are always exact), so tested +# outside the exact-parameterized suite. +@testset "spherical ring orientation: turning-angle curvature" begin + m = Spherical() + curvature(pts) = GO._spherical_loop_curvature( + GO._prune_loop_degeneracies([GO.rk_normalize_usp(p) for p in pts])) + + @testset "antipodal vertex pair (AntipodalEdgeSplit output)" begin + # the 80° lune [(0,0), (90,0), (180,0), (90,80)]: its first and third + # vertices are antipodal — the fan formulation this replaces NaN'd on + # the chord between them; every turn angle here is between adjacent + # vertices, so the pair never meets in one term + lune = [GO._to_kernel_point(m, p) for p in [(0., 0.), (90., 0.), (180., 0.), (90., 80.)]] + @test curvature(lune) ≈ deg2rad(200) atol = 1e-12 # area 2π − curvature ≈ 2.7925268 sr + @test GO._ring_is_ccw(m, lune; exact = True()) + @test !GO._ring_is_ccw(m, reverse(lune); exact = True()) + end + + @testset "exactly negated under reversal, invariant under rotation" begin + rng = Random.Xoshiro(0xc0ffee) + for _ in 1:50 + k = rand(rng, 3:9) + ring = [GO.rk_normalize_usp(_usp(randn(rng), randn(rng), randn(rng))) for _ in 1:k] + c = curvature(ring) + @test curvature(reverse(ring)) == -c # exact, not ≈ + for r in 1:(k - 1) + @test curvature(vcat(ring[(r + 1):end], ring[1:r])) == c + end + end + end + + @testset "exact hemisphere is normalized in both windings" begin + # equator ring: curvature 0, intrinsically winding-ambiguous — the + # `>= -maxError` rule (S2 IsNormalized) reads it CCW in both windings + eq = [_usp(1, 0, 0), _usp(0, 1, 0), _usp(-1, 0, 0), _usp(0, -1, 0)] + @test GO._ring_is_ccw(m, eq; exact = True()) + @test GO._ring_is_ccw(m, reverse(eq); exact = True()) + end + + @testset "degeneracy pruning" begin + cap = [_usp(2, 0, 1), _usp(0, 2, 1), _usp(-2, 0, 1), _usp(0, -2, 1)] + # duplicate run (AA) plus a retraced whisker (ABA) leave the bit alone + spiked = [cap[1], cap[1], cap[2], _usp(5, 5, 9), cap[2], cap[3], cap[4]] + @test GO._ring_is_ccw(m, spiked; exact = True()) == GO._ring_is_ccw(m, cap; exact = True()) == true + @test curvature(spiked) == curvature(cap) + # whisker straddling the closure: [B, A, X, Y, A] prunes to [A, X, Y] + wrap = [cap[2], cap[1], _usp(3, 1, 2), _usp(1, 3, 2), cap[1]] + @test length(GO._prune_loop_degeneracies(wrap)) == 3 + # a ring degenerate after pruning bounds no area + @test !GO._ring_is_ccw(m, [cap[1], cap[2], cap[1], cap[2]]; exact = True()) + @test !GO._ring_is_ccw(m, [cap[1], cap[2]]; exact = True()) + end + + @testset "closed and open forms agree" begin + cap = [_usp(2, 0, 1), _usp(0, 2, 1), _usp(-2, 0, 1), _usp(0, -2, 1)] + closed = vcat(cap, [cap[1]]) + @test GO._ring_is_ccw(m, closed; exact = True()) == GO._ring_is_ccw(m, cap; exact = True()) + @test !GO._ring_is_ccw(m, reverse(closed); exact = True()) + end +end diff --git a/test/methods/relateng/node_topology.jl b/test/methods/relateng/node_topology.jl new file mode 100644 index 0000000000..bdb4487bfd --- /dev/null +++ b/test/methods/relateng/node_topology.jl @@ -0,0 +1,687 @@ +# Tests for the RelateNG node-topology layer (node_sections.jl and +# polygon_node_converter.jl): `NodeSection` accessors and comparators, the +# `NodeSections` collector, and the `PolygonNodeConverter` port. +# Ports of JTS NodeSection.java / NodeSections.java / PolygonNodeConverter.java. +# No dedicated JUnit file exists for the first two classes; those tests follow +# the implementation plan (Task 15): EdgeAngleComparator ordering, +# isProper/isNodeAtVertex semantics, the prepareSections ordering invariant, +# and the (partial, see node_sections.jl) createNode port on a simple +# two-area touch. The converter tests port PolygonNodeConverterTest.java +# in full (Task 16). + +using Test +import GeometryOps as GO +import GeometryOps: Planar, True +import GeoInterface as GI + +const NODE = GO.vertex_node((0.0, 0.0)) + +# Convenience constructor mirroring the Java NodeSection constructor +# argument order, with defaults for an A-geometry shell corner at NODE. +make_section(; is_a = true, dim = GO.DIM_A, id = 1, ring_id = 0, poly = nothing, + at_vertex = true, v0 = (1.0, 0.0), node = NODE, v1 = (0.0, 1.0)) = + GO.NodeSection(is_a, Int8(dim), Int32(id), Int32(ring_id), poly, at_vertex, v0, node, v1) + +@testset "NodeSection accessors" begin + poly = GI.Polygon([[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 0.0)]]) + ns = make_section(; poly) + @test GO.get_vertex(ns, 0) == (1.0, 0.0) + @test GO.get_vertex(ns, 1) == (0.0, 1.0) + @test GO.node_pt(ns) === NODE + @test GO.section_dim(ns) == GO.DIM_A + @test GO.section_id(ns) == 1 + @test GO.ring_id(ns) == 0 + @test GO.get_polygonal(ns) === poly + @test GO.is_shell(ns) + @test GO.is_area(ns) + @test GO.is_a(ns) + + hole = make_section(; ring_id = 1) + @test !GO.is_shell(hole) + + line = make_section(; is_a = false, dim = GO.DIM_L, id = 2, ring_id = -1, + at_vertex = false, v1 = nothing) + @test !GO.is_area(line) + @test !GO.is_a(line) + @test GO.get_polygonal(line) === nothing + @test GO.get_vertex(line, 1) === nothing + + @test GO.is_area_area(ns, hole) + @test !GO.is_area_area(ns, line) + + @test GO.is_same_geometry(ns, hole) + @test !GO.is_same_geometry(ns, line) + @test GO.is_same_polygon(ns, hole) # same geometry, same id + @test !GO.is_same_polygon(ns, make_section(; id = 2)) + @test !GO.is_same_polygon(ns, make_section(; is_a = false)) + + # toString port (smoke test only) + @test sprint(show, ns) isa String + @test sprint(show, line) isa String +end + +@testset "is_proper / is_node_at_vertex" begin + at_v = make_section(; at_vertex = true) + proper = make_section(; at_vertex = false) + @test GO.is_node_at_vertex(at_v) + @test !GO.is_node_at_vertex(proper) + # Java isProper() == !isNodeAtVertex(): "proper" means the node falls in + # a segment interior, not at a vertex of the section's component. + @test !GO.is_proper(at_v) + @test GO.is_proper(proper) + # static isProper(a, b): both sections proper + @test GO.is_proper(proper, proper) + @test !GO.is_proper(proper, at_v) + @test !GO.is_proper(at_v, at_v) +end + +@testset "EdgeAngleComparator" begin + m = Planar() + # Directions in CCW angular order from the positive X-axis around (0,0). + dirs_ccw = [(1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (-1.0, 1.0), + (-1.0, 0.0), (-1.0, -1.0), (0.0, -1.0), (1.0, -1.0)] + shuffled = dirs_ccw[[5, 1, 8, 3, 7, 2, 6, 4]] + sections = [make_section(; v0 = d) for d in shuffled] + sort!(sections; lt = (a, b) -> GO.edge_angle_compare(m, a, b; exact = True()) < 0) + @test [GO.get_vertex(ns, 0) for ns in sections] == dirs_ccw + + # Equal-angle (collinear, same quadrant) sections compare equal. + a = make_section(; v0 = (2.0, 2.0)) + b = make_section(; v0 = (1.0, 1.0)) + @test GO.edge_angle_compare(m, a, b; exact = True()) == 0 + + # Symbolic crossing-node apex: comparator works without a constructed + # intersection coordinate (design D2). + xnode = GO.crossing_node((-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)) + xdirs_ccw = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] + xsections = [make_section(; node = xnode, v0 = d) for d in xdirs_ccw[[3, 1, 4, 2]]] + sort!(xsections; lt = (a, b) -> GO.edge_angle_compare(m, a, b; exact = True()) < 0) + @test [GO.get_vertex(ns, 0) for ns in xsections] == xdirs_ccw +end + +@testset "compare_to" begin + # A sorts before B + @test GO.compare_to(make_section(; is_a = true), make_section(; is_a = false)) < 0 + @test GO.compare_to(make_section(; is_a = false), make_section(; is_a = true)) > 0 + # lines sort before areas + @test GO.compare_to(make_section(; dim = GO.DIM_L), make_section(; dim = GO.DIM_A)) < 0 + # then id, then ring id + @test GO.compare_to(make_section(; id = 1), make_section(; id = 2)) < 0 + @test GO.compare_to(make_section(; ring_id = 0), make_section(; ring_id = 1)) < 0 + # then edge vertices, `nothing` (Java null) sorting below non-null, + # coordinates lexicographic on (x, y) + @test GO.compare_to(make_section(; v0 = nothing), make_section(; v0 = (0.0, 0.0))) < 0 + @test GO.compare_to(make_section(; v0 = (0.0, 0.0)), make_section(; v0 = nothing)) > 0 + @test GO.compare_to(make_section(; v0 = (1.0, 0.0)), make_section(; v0 = (1.0, 2.0))) < 0 + @test GO.compare_to(make_section(; v0 = (1.0, 0.0)), make_section(; v0 = (2.0, 0.0))) < 0 + @test GO.compare_to(make_section(; v1 = nothing), make_section(; v1 = (0.0, 1.0))) < 0 + @test GO.compare_to(make_section(; v1 = (0.0, 1.0)), make_section(; v1 = (0.0, 2.0))) < 0 + @test GO.compare_to(make_section(; v1 = nothing), make_section(; v1 = nothing)) == 0 + @test GO.compare_to(make_section(), make_section()) == 0 + # geometry comparison dominates dimension, dimension dominates id, ... + @test GO.compare_to(make_section(; is_a = true, dim = GO.DIM_A), + make_section(; is_a = false, dim = GO.DIM_L)) < 0 + @test GO.compare_to(make_section(; dim = GO.DIM_L, id = 9), + make_section(; dim = GO.DIM_A, id = 1)) < 0 + @test GO.compare_to(make_section(; id = 1, ring_id = 9), + make_section(; id = 2, ring_id = 0)) < 0 +end + +@testset "NodeSections prepare_sections! ordering invariant" begin + nss = GO.NodeSections(NODE) + @test GO.get_coordinate(nss) === NODE + s_line_a = make_section(; is_a = true, dim = GO.DIM_L, id = 5, ring_id = -1, v1 = nothing) + s_shell_a1 = make_section(; is_a = true, dim = GO.DIM_A, id = 1, ring_id = 0) + s_hole_a1 = make_section(; is_a = true, dim = GO.DIM_A, id = 1, ring_id = 1) + s_shell_a2 = make_section(; is_a = true, dim = GO.DIM_A, id = 2, ring_id = 0) + s_line_b = make_section(; is_a = false, dim = GO.DIM_L, id = 3, ring_id = -1, v1 = nothing) + s_shell_b = make_section(; is_a = false, dim = GO.DIM_A, id = 1, ring_id = 0) + for s in (s_shell_b, s_shell_a2, s_hole_a1, s_line_b, s_line_a, s_shell_a1) + GO.add_node_section!(nss, s) + end + GO.prepare_sections!(nss) + # A before B (dominating dimension: the B line sorts after every A area); + # within a geometry lines before areas; same-polygon sections contiguous. + @test nss.sections == [s_line_a, s_shell_a1, s_hole_a1, s_shell_a2, s_line_b, s_shell_b] +end + +@testset "NodeSections" begin + m = Planar() + poly_a = GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]]) + poly_b = GI.Polygon([[(0.0, 0.0), (0.0, -1.0), (-1.0, 0.0), (0.0, 0.0)]]) + + nss = GO.NodeSections(NODE) + @test !GO.has_interaction_ab(nss) + # A-shell corner at the node (CW orientation: interior to the right) + sa = make_section(; is_a = true, id = 1, poly = poly_a, v0 = (0.0, 1.0), v1 = (1.0, 0.0)) + GO.add_node_section!(nss, sa) + @test !GO.has_interaction_ab(nss) + # B-shell corner touching at the node from the opposite quadrant + sb = make_section(; is_a = false, id = 1, poly = poly_b, v0 = (-1.0, 0.0), v1 = (0.0, -1.0)) + GO.add_node_section!(nss, sb) + @test GO.has_interaction_ab(nss) + + @test GO.get_polygonal(nss, true) === poly_a + @test GO.get_polygonal(nss, false) === poly_b + + # getPolygonal skips sections without a parent polygonal + nss_line = GO.NodeSections(NODE) + GO.add_node_section!(nss_line, make_section(; dim = GO.DIM_L, ring_id = -1, poly = nothing)) + @test GO.get_polygonal(nss_line, true) === nothing + @test GO.get_polygonal(nss_line, false) === nothing + @test !GO.has_interaction_ab(nss_line) + + # create_node on a simple two-area touch (full port as of Task 17: + # returns the assembled RelateNode). + # + # Hand-trace: prepared order [sa, sb] (A before B). sa: enter (0,1)@90° + # reverse {A: L=I,On=B,R=E}, exit (1,0)@0° forward {A: L=E,On=B,R=I}; + # the exit inserts BEFORE (0,1) in the CCW wheel (0° < 90°). No edges lie + # strictly between, and the prev/next interior checks see EXTERIOR sides, + # so no propagation. sb appends (-1,0)@180° {B: I,B,E} and (0,-1)@270° + # {B: E,B,I}; the A-edge B-labels stay unknown until finish! (addEdges + # never retro-labels other-geometry edges). + node = GO.create_node(m, nss; exact = True()) + @test node isa GO.RelateNode + edges = GO.get_edges(node) + @test [e.dir_pt for e in edges] == [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] + locs(e, isa_g) = (GO.edge_location(e, isa_g, GO.POS_LEFT), + GO.edge_location(e, isa_g, GO.POS_ON), GO.edge_location(e, isa_g, GO.POS_RIGHT)) + LI, LB, LE, LN = GO.LOC_INTERIOR, GO.LOC_BOUNDARY, GO.LOC_EXTERIOR, GO.LOC_NONE + @test locs(edges[1], true) == (LE, LB, LI) && locs(edges[2], true) == (LI, LB, LE) + @test locs(edges[3], false) == (LI, LB, LE) && locs(edges[4], false) == (LE, LB, LI) + @test !GO.is_known(edges[1], false) && !GO.is_known(edges[3], true) + @test GO.has_exterior_edge(node, true) && GO.has_exterior_edge(node, false) + + # Multiple sections of the same polygon route through PolygonNodeConverter + # (Task 16): two shell corners of one polygon are rewritten to themselves, + # in edge-angle order ((0,1) at 90° before (-1,0) at 180°). + # + # Hand-trace (Task 17): multi_1 (corner (0,1)→(1,0), interior sector CCW + # 90°→0°, i.e. through 180° and 270°) builds wheel [(1,0){A:E,B,I}, + # (0,1){A:I,B,E}]. multi_2 (corner (-1,0)→(0,-1), sector 180°→270°) + # appends (-1,0)@180° {I,B,E} and (0,-1)@270° {E,B,I}; then + # updateIfAreaPrev(enter=3): prev edge (0,1) has LEFT=I → edge (-1,0) + # becomes all-INTERIOR; updateIfAreaNext(exit=4): next edge (wraps to + # (1,0)) has RIGHT=I → edge (0,-1) becomes all-INTERIOR. That matches the + # geometry: multi_2's corner lies inside multi_1's interior sector. + nss_multi = GO.NodeSections(NODE) + multi_1 = make_section(; id = 1, v0 = (0.0, 1.0), v1 = (1.0, 0.0)) + multi_2 = make_section(; id = 1, v0 = (-1.0, 0.0), v1 = (0.0, -1.0)) + GO.add_node_section!(nss_multi, multi_1) + GO.add_node_section!(nss_multi, multi_2) + node_multi = GO.create_node(m, nss_multi; exact = True()) + edges_multi = GO.get_edges(node_multi) + @test [e.dir_pt for e in edges_multi] == + [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] + @test locs(edges_multi[1], true) == (LE, LB, LI) + @test locs(edges_multi[2], true) == (LI, LB, LE) + @test locs(edges_multi[3], true) == (LI, LI, LI) + @test locs(edges_multi[4], true) == (LI, LI, LI) +end + +# Port of JTS PolygonNodeConverterTest.java — every test method, plus the +# checkConversion / checkSectionsEqual / sort / section helpers. The Java +# section helpers build A-geometry area sections of polygon 1 at node (5,5) +# (no parent polygonal, node not at a vertex); equality of section lists is +# up to edge-angle order, compared with NodeSection.compareTo. +@testset "PolygonNodeConverter" begin + m = Planar() + + # Port of section(ringId, v0x, v0y, nx, ny, v1x, v1y). + section(ring_id, v0x, v0y, nx, ny, v1x, v1y) = GO.NodeSection( + true, GO.DIM_A, Int32(1), Int32(ring_id), nothing, false, + (Float64(v0x), Float64(v0y)), + GO.vertex_node((Float64(nx), Float64(ny))), + (Float64(v1x), Float64(v1y))) + # Ports of sectionShell / sectionHole. + section_shell(coords...) = section(0, coords...) + section_hole(coords...) = section(1, coords...) + + # Port of sort(List): EdgeAngleComparator ordering. + sort_sections!(ns) = + sort!(ns; lt = (a, b) -> GO.edge_angle_compare(m, a, b; exact = True()) < 0) + + # Port of checkSectionsEqual. + function is_sections_equal(ns1, ns2) + length(ns1) == length(ns2) || return false + sort_sections!(ns1) + sort_sections!(ns2) + for i in eachindex(ns1) + GO.compare_to(ns1[i], ns2[i]) == 0 || return false + end + return true + end + + # Port of checkConversion. + function check_conversion(input, expected) + actual = GO.polygon_node_convert(m, input; exact = True()) + @test is_sections_equal(actual, expected) + end + + @testset "testShells" begin + check_conversion( + GO.NodeSection[ + section_shell(1, 1, 5, 5, 9, 9), + section_shell(8, 9, 5, 5, 6, 9), + section_shell(4, 9, 5, 5, 2, 9)], + GO.NodeSection[ + section_shell(1, 1, 5, 5, 9, 9), + section_shell(8, 9, 5, 5, 6, 9), + section_shell(4, 9, 5, 5, 2, 9)]) + end + + @testset "testShellAndHole" begin + check_conversion( + GO.NodeSection[ + section_shell(1, 1, 5, 5, 9, 9), + section_hole(6, 0, 5, 5, 4, 0)], + GO.NodeSection[ + section_shell(1, 1, 5, 5, 4, 0), + section_shell(6, 0, 5, 5, 9, 9)]) + end + + @testset "testShellsAndHoles" begin + check_conversion( + GO.NodeSection[ + section_shell(1, 1, 5, 5, 9, 9), + section_hole(6, 0, 5, 5, 4, 0), + section_shell(8, 8, 5, 5, 1, 8), + section_hole(4, 8, 5, 5, 6, 8)], + GO.NodeSection[ + section_shell(1, 1, 5, 5, 4, 0), + section_shell(6, 0, 5, 5, 9, 9), + section_shell(4, 8, 5, 5, 1, 8), + section_shell(8, 8, 5, 5, 6, 8)]) + end + + @testset "testShellAnd2Holes" begin + check_conversion( + GO.NodeSection[ + section_shell(1, 1, 5, 5, 9, 9), + section_hole(7, 0, 5, 5, 6, 0), + section_hole(4, 0, 5, 5, 3, 0)], + GO.NodeSection[ + section_shell(1, 1, 5, 5, 3, 0), + section_shell(4, 0, 5, 5, 6, 0), + section_shell(7, 0, 5, 5, 9, 9)]) + end + + @testset "testHoles" begin + check_conversion( + GO.NodeSection[ + section_hole(7, 0, 5, 5, 6, 0), + section_hole(4, 0, 5, 5, 3, 0)], + GO.NodeSection[ + section_shell(4, 0, 5, 5, 6, 0), + section_shell(7, 0, 5, 5, 3, 0)]) + end +end + +# Tests for relate_node.jl (Task 17): ports of JTS RelateEdge.java and +# RelateNode.java. No dedicated JUnit file exists; each configuration below +# was hand-traced against the Java semantics (trace reasoning in comments). +# +# Conventions used in the traces (derived from the Java sources): +# - Area sections arrive in canonical orientation: CW shells / CCW holes, +# i.e. polygon interior on the RIGHT of travel v0 → node → v1. The interior +# sector at the node therefore spans CCW from ray node→v0 to ray node→v1. +# - RelateNode.addEdges(A-section): the entering edge (dirPt v0) is added +# with isForward=false → LEFT=INTERIOR, RIGHT=EXTERIOR; the exiting edge +# (dirPt v1) with isForward=true → LEFT=EXTERIOR, RIGHT=INTERIOR; both get +# ON=BOUNDARY. (RelateEdge.setLocationsArea.) +# - Line edges get LEFT=RIGHT=EXTERIOR, ON=INTERIOR, dim L. +# (RelateEdge.setLocationsLine.) +# - The wheel is kept sorted CCW by angle from the positive X-axis +# (RelateNode.addEdge insertion via compareToEdge). +@testset "RelateEdge + RelateNode" begin + m = Planar() + LI, LB, LE, LN = GO.LOC_INTERIOR, GO.LOC_BOUNDARY, GO.LOC_EXTERIOR, GO.LOC_NONE + locs(e, isa_g) = (GO.edge_location(e, isa_g, GO.POS_LEFT), + GO.edge_location(e, isa_g, GO.POS_ON), GO.edge_location(e, isa_g, GO.POS_RIGHT)) + dirs(node) = [e.dir_pt for e in GO.get_edges(node)] + + # Build a node through the full NodeSections.createNode pipeline. + function build_node(sections...; node_key = NODE) + nss = GO.NodeSections(node_key) + for s in sections + GO.add_node_section!(nss, s) + end + return GO.create_node(m, nss; exact = True()) + end + + line_section(; kw...) = make_section(; dim = GO.DIM_L, ring_id = -1, kw...) + + @testset "RelateEdge basics" begin + # Factory: area dim → area edge (sides labeled by direction); + # any other dim → line edge. + ea = GO.relate_edge(NODE, (1.0, 0.0), true, GO.DIM_A, true) + @test ea.a_dim == GO.DIM_A + @test locs(ea, true) == (LE, LB, LI) # forward: interior on R + @test locs(ea, false) == (LN, LN, LN) # B untouched + @test GO.is_known(ea, true) && !GO.is_known(ea, false) + + er = GO.relate_edge(NODE, (1.0, 0.0), true, GO.DIM_A, false) + @test locs(er, true) == (LI, LB, LE) # reverse: interior on L + + el = GO.relate_edge(NODE, (1.0, 0.0), false, GO.DIM_L, true) + @test el.b_dim == GO.DIM_L && el.a_dim == GO.DIM_UNKNOWN_EDGE + @test locs(el, false) == (LE, LI, LE) + + # Explicit-locations constructor (Java RelateEdge(node, pt, isA, + # locLeft, locRight, locLine)) forces dim 2. + ex = GO.RelateEdge(NODE, (1.0, 0.0), true, GO.LOC_INTERIOR, + GO.LOC_INTERIOR, GO.LOC_INTERIOR) + @test ex.a_dim == GO.DIM_A && locs(ex, true) == (LI, LI, LI) + + # location / is_interior / set_location! / set_all_locations! / + # set_unknown_locations! + @test GO.is_interior(ea, true, GO.POS_RIGHT) + @test !GO.is_interior(ea, true, GO.POS_LEFT) + @test_throws ArgumentError GO.edge_location(ea, true, Int8(99)) + GO.set_location!(ea, false, GO.POS_ON, GO.LOC_EXTERIOR) + @test GO.edge_location(ea, false, GO.POS_ON) == LE + GO.set_unknown_locations!(ea, false, GO.LOC_INTERIOR) + @test locs(ea, false) == (LI, LE, LI) # ON was known, kept + GO.set_all_locations!(ea, false, GO.LOC_EXTERIOR) + @test locs(ea, false) == (LE, LE, LE) + GO.set_dim_locations!(ea, false, GO.DIM_L, GO.LOC_INTERIOR) + @test ea.b_dim == GO.DIM_L && locs(ea, false) == (LI, LI, LI) + GO.set_area_interior!(er, true) + @test locs(er, true) == (LI, LI, LI) + @test er.a_dim == GO.DIM_A # dim untouched + + # statics: find_known_edge_index (1-based, 0 if none) and + # set_all_area_interior! + edges = [GO.relate_edge(NODE, (1.0, 0.0), false, GO.DIM_L, true), + GO.relate_edge(NODE, (0.0, 1.0), true, GO.DIM_A, true)] + @test GO.find_known_edge_index(edges, true) == 2 + @test GO.find_known_edge_index(edges, false) == 1 + @test GO.find_known_edge_index([edges[2]], false) == 0 + GO.set_all_area_interior!(edges, true) + @test locs(edges[1], true) == (LI, LI, LI) + @test locs(edges[2], true) == (LI, LI, LI) + + # compare_to_edge: CCW angle comparison around the node + # (negative: this edge below the query direction; positive: above). + e0 = GO.relate_edge(NODE, (1.0, 1.0), true, GO.DIM_A, true) # 45° + @test GO.compare_to_edge(m, e0, (0.0, 1.0); exact = True()) < 0 # 45 < 90 + @test GO.compare_to_edge(m, e0, (1.0, 0.0); exact = True()) > 0 # 45 > 0 + @test GO.compare_to_edge(m, e0, (2.0, 2.0); exact = True()) == 0 # collinear + # toString port (smoke) + @test sprint(show, e0) isa String + end + + # Configuration 1: two lines crossing at a shared vertex (0,0). + # + # Hand-trace: sections (sorted A first) are A-line v0=(-1,0), v1=(1,0) + # and B-line v0=(0,-1), v1=(0,1); dim L → addLineEdge per vertex. + # A: (-1,0)@180° starts the wheel; (1,0)@0° compares "further" (+1 + # against 180°) and inserts before it → [(1,0), (-1,0)]. + # B: (0,-1)@270° appends; (0,1)@90° inserts before (-1,0) → + # [(1,0)@0°, (0,1)@90°, (-1,0)@180°, (0,-1)@270°]. + # Every edge: own geometry dim L with (E, I, E), other geometry fully + # unknown — no area labels anywhere. finish!(node, false, false) then + # propagates: each edge's unknown side/on locations become the CCW-prior + # known LEFT location, which is EXTERIOR everywhere here. + @testset "config 1: crossing lines at a vertex node" begin + sA = line_section(; v0 = (-1.0, 0.0), v1 = (1.0, 0.0)) + sB = line_section(; is_a = false, v0 = (0.0, -1.0), v1 = (0.0, 1.0)) + node = build_node(sA, sB) + edges = GO.get_edges(node) + @test length(edges) == 4 + @test dirs(node) == [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] + # all dims L for the owning geometry, unknown for the other + @test edges[1].a_dim == GO.DIM_L && edges[3].a_dim == GO.DIM_L + @test edges[2].b_dim == GO.DIM_L && edges[4].b_dim == GO.DIM_L + @test edges[1].b_dim == GO.DIM_UNKNOWN_EDGE + @test edges[2].a_dim == GO.DIM_UNKNOWN_EDGE + # line labels: (E, I, E) for own geometry; nothing known for other + for (i, e) in pairs(edges) + own = isodd(i) # edges 1,3 are A's; 2,4 are B's + @test locs(e, own) == (LE, LI, LE) + @test locs(e, !own) == (LN, LN, LN) + end + # no area labels: no BOUNDARY/INTERIOR side locations at all + @test all(e -> GO.edge_location(e, true, GO.POS_LEFT) != LB && + GO.edge_location(e, false, GO.POS_LEFT) != LB, edges) + + GO.finish!(node, false, false) + for (i, e) in pairs(edges) + own = isodd(i) + @test locs(e, own) == (LE, LI, LE) # known labels survive + @test locs(e, !own) == (LE, LE, LE) # unknowns → EXTERIOR + end + @test GO.has_exterior_edge(node, true) && GO.has_exterior_edge(node, false) + + # Same configuration at a *symbolic* crossing node (design D2): the + # wheel is ordered around the crossing of (-1,0)-(1,0) × (0,-1)-(0,1) + # without any constructed apex coordinate. (The zero-length-edge + # guard must not engage for crossing keys.) + xnode = GO.crossing_node((-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)) + sAx = line_section(; at_vertex = false, v0 = (-1.0, 0.0), v1 = (1.0, 0.0), node = xnode) + sBx = line_section(; is_a = false, at_vertex = false, + v0 = (0.0, -1.0), v1 = (0.0, 1.0), node = xnode) + xn = build_node(sAx, sBx; node_key = xnode) + @test dirs(xn) == [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] + @test locs(GO.get_edges(xn)[1], true) == (LE, LI, LE) + @test locs(GO.get_edges(xn)[2], false) == (LE, LI, LE) + end + + # Configuration 2: an area corner — two edges of one CW-shell polygon. + # + # Hand-trace: corner v0=(1,0), v1=(0,1) (CW shell through the node, e.g. + # ring … → (1,0) → (0,0) → (0,1) → …; interior is the first quadrant, + # the sector CCW from ray node→v0 @0° to ray node→v1 @90°). + # addEdges: entering edge (1,0) reverse → {A: L=I, On=B, R=E}; exiting + # edge (0,1) forward appends after 0° → {A: L=E, On=B, R=I}. + # updateEdgesInArea(1→2) walks no edges (adjacent); prev/next interior + # checks see EXTERIOR → no propagation. Interior ends up on the correct + # side: LEFT of the entering ray (CCW side toward the sector) and RIGHT + # of the exiting ray. + @testset "config 2: area corner of one polygon" begin + corner = make_section(; v0 = (1.0, 0.0), v1 = (0.0, 1.0)) + node = build_node(corner) + edges = GO.get_edges(node) + @test length(edges) == 2 + @test dirs(node) == [(1.0, 0.0), (0.0, 1.0)] + @test edges[1].a_dim == GO.DIM_A && edges[2].a_dim == GO.DIM_A + @test locs(edges[1], true) == (LI, LB, LE) + @test locs(edges[2], true) == (LE, LB, LI) + @test locs(edges[1], false) == (LN, LN, LN) + @test GO.has_exterior_edge(node, true) + @test !GO.has_exterior_edge(node, false) # nothing known for B + end + + # Configuration 3: area corner of A + line end of B at the same node. + # + # Hand-trace: A corner as config 2 (interior sector 0°→90°). B line ends + # at the node arriving from (1,1): section v0=(1,1)@45°, v1=nothing. + # Sorted A before B, so the wheel is [(1,0), (0,1)] before the B line + # edge inserts between them (0° < 45° < 90°) → [(1,0), (1,1), (0,1)]; + # the nothing vertex is skipped (Java addEdge null guard). Pre-finish + # the line edge knows nothing about A. finish!(node, false, false): + # propagateSideLocations(A) starts at edge 1 (first A-known), carries + # currLoc = LEFT(A) of (1,0) = INTERIOR onto the line edge → its A + # locations all become INTERIOR (the line end lies inside A); then + # currLoc resets to EXTERIOR past (0,1)'s LEFT... (0,1) is fully known, + # so nothing else changes. propagateSideLocations(B) starts at the line + # edge, carries its LEFT(B) = EXTERIOR onto both area edges. + @testset "config 3: area corner of A + line end of B" begin + corner = make_section(; v0 = (1.0, 0.0), v1 = (0.0, 1.0)) + line_end = line_section(; is_a = false, v0 = (1.0, 1.0), v1 = nothing) + node = build_node(corner, line_end) + edges = GO.get_edges(node) + @test length(edges) == 3 + @test dirs(node) == [(1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + line_edge = edges[2] + @test line_edge.b_dim == GO.DIM_L + @test locs(line_edge, false) == (LE, LI, LE) + @test locs(line_edge, true) == (LN, LN, LN) # A unknown pre-finish + + GO.finish!(node, false, false) + # the line edge is interior to A on every position (line end is + # inside A's interior sector); its dimension for A stays unknown + @test locs(line_edge, true) == (LI, LI, LI) + @test line_edge.a_dim == GO.DIM_UNKNOWN_EDGE + # the area edges are exterior to B everywhere + @test locs(edges[1], false) == (LE, LE, LE) + @test locs(edges[3], false) == (LE, LE, LE) + # A labels on its own edges survive finish! + @test locs(edges[1], true) == (LI, LB, LE) + @test locs(edges[3], true) == (LE, LB, LI) + + # Same-geometry variant: when the line and the area belong to ONE + # geometry, the labeling happens during addEdges (updateEdgesInArea) + # rather than at finish!. prepareSections puts the line first, so + # the wheel is [(1,1) line] when the corner is added; the corner's + # entering edge (1,0) inserts at 1, exiting edge (0,1) appends → + # [(1,0), (1,1), (0,1)] with index0=1, index1=3, and + # updateEdgesInArea marks edge 2 (the line edge, strictly between + # the corner edges in CCW order) all-INTERIOR for A. Its dim stays L + # (setAreaInterior only touches locations). + line_a = line_section(; v0 = (1.0, 1.0), v1 = nothing, id = 2) + node2 = build_node(corner, line_a) + edges2 = GO.get_edges(node2) + @test dirs(node2) == [(1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + @test locs(edges2[2], true) == (LI, LI, LI) + @test edges2[2].a_dim == GO.DIM_L + end + + # Configuration 4: two area corners (A and B) with coincident edges — + # the collinear-edge merge case, plus the area-over-line dim override. + # + # Hand-trace (coincident A/B corners, both v0=(1,0), v1=(0,1)): + # A corner builds [(1,0){A: I,B,E}, (0,1){A: E,B,I}]. B corner's + # entering edge compares equal (comp == 0) to (1,0) → RelateEdge.merge + # with isA=false: B is unknown on that edge, so the !isKnown branch sets + # B dim/locations directly: {B: I,B,E}. Likewise the exiting edge merges + # into (0,1) → {B: E,B,I}. The wheel stays at 2 edges, both fully + # labeled for A and B identically (the boundaries coincide). + @testset "config 4: coincident area corners of A and B" begin + corner_a = make_section(; v0 = (1.0, 0.0), v1 = (0.0, 1.0)) + corner_b = make_section(; is_a = false, v0 = (1.0, 0.0), v1 = (0.0, 1.0)) + node = build_node(corner_a, corner_b) + edges = GO.get_edges(node) + @test length(edges) == 2 + @test dirs(node) == [(1.0, 0.0), (0.0, 1.0)] + @test edges[1].a_dim == GO.DIM_A && edges[1].b_dim == GO.DIM_A + @test locs(edges[1], true) == (LI, LB, LE) + @test locs(edges[1], false) == (LI, LB, LE) + @test locs(edges[2], true) == (LE, LB, LI) + @test locs(edges[2], false) == (LE, LB, LI) + + # Area-over-line dim override (RelateEdge.mergeDimEdgeLoc): geometry + # B contributes a line edge and a coincident area edge. The line + # sorts first (prepareSections: lines before areas), so the wheel + # holds (1,0){B: dim L, E,I,E} when the corner's entering edge + # merges into it: isKnown(B) → mergeDimEdgeLoc upgrades dim L → A + # and ON → BOUNDARY; mergeSideLocation sets LEFT to INTERIOR (curr + # EXTERIOR yields) and leaves RIGHT EXTERIOR. + line_b = line_section(; is_a = false, id = 2, v0 = (1.0, 0.0), v1 = nothing) + corner_b2 = make_section(; is_a = false, v0 = (1.0, 0.0), v1 = (0.0, 1.0)) + node2 = build_node(line_b, corner_b2) + edges2 = GO.get_edges(node2) + @test length(edges2) == 2 + merged = edges2[1] + @test merged.dir_pt == (1.0, 0.0) + @test merged.b_dim == GO.DIM_A # dim override L → A + @test locs(merged, false) == (LI, LB, LE) # ON overridden to BOUNDARY + @test locs(edges2[2], false) == (LE, LB, LI) + + # Merge side-location precedence: INTERIOR wins over EXTERIOR. Two + # self-touch corners of one polygon sharing the ray (0,1): corner 1 + # (1,0)→(0,1) (sector 0°→90°) and corner 2 (0,1)→(-1,0) (sector + # 90°→180°). After the converter (pass-through for two shells) the + # corners are added in v0-angle order: corner 1 builds + # [(1,0){I,B,E}, (0,1){E,B,I}]; corner 2's entering edge merges into + # (0,1) reverse → LEFT: EXTERIOR→INTERIOR, RIGHT: INTERIOR kept → + # {I,B,I}; the exiting edge (-1,0) appends {E,B,I}. Then + # updateIfAreaPrev(enter=2): prev edge (1,0) has LEFT=I → the shared + # edge (0,1) becomes all-INTERIOR — the coincident boundary ray is + # interior to the union of the two corners (0°→180°), exactly the + # self-touch (maximal ring) semantics. + corner_1 = make_section(; v0 = (1.0, 0.0), v1 = (0.0, 1.0)) + corner_2 = make_section(; v0 = (0.0, 1.0), v1 = (-1.0, 0.0)) + node3 = build_node(corner_1, corner_2) + edges3 = GO.get_edges(node3) + @test length(edges3) == 3 + @test dirs(node3) == [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0)] + @test locs(edges3[1], true) == (LI, LB, LE) + @test locs(edges3[2], true) == (LI, LI, LI) # shared ray: interior + @test locs(edges3[3], true) == (LE, LB, LI) + end + + # Carried-over from the Task 16 review: a node mixing a converted + # multi-section polygon group (shell + hole of A's polygon 1) with + # another geometry's singleton corner (B). + # + # Hand-trace: A shell runs straight through the node, v0=(0,-1)@270°, + # v1=(0,1)@90° (interior = east half-plane). A hole touches the node + # occupying the thin east wedge 315°→45°: CCW hole corner v0=(1,1)@45°, + # v1=(1,-1)@315° (polygon interior CCW from 45° to 315° — the rule is + # the same right-of-travel rule as for shells). PolygonNodeConverter + # (sorted by v0 angle: hole@45°, shell@270°; findShell → shell) rewrites + # them into the self-touch corners (shell.v0 → hole.v1) = (0,-1)→(1,-1) + # [sector 270°→315°] and (hole.v0 → shell.v1) = (1,1)→(0,1) [sector + # 45°→90°] — partitioning the polygon interior near the node. B's + # CW corner v0=(-1,1)@135°, v1=(-1,-1)@225° (interior = the west wedge, + # inside A's exterior) is added as a singleton, untouched by the + # converter. Wheel insertion order gives CCW dirs + # [(1,1)@45°, (0,1)@90°, (-1,1)@135°, (-1,-1)@225°, (0,-1)@270°, + # (1,-1)@315°]; no updateEdgesInArea/prev/next propagation fires (each + # corner's edges are adjacent in the wheel, and neighboring edges of the + # other corners show EXTERIOR or unknown sides at the decisive checks). + # finish!(node, false, false) then labels A's edges exterior-for-B and + # B's edges exterior-for-A (each lies in the other's exterior). + @testset "converted group + other polygon's singleton" begin + shell_a = make_section(; id = 1, ring_id = 0, v0 = (0.0, -1.0), v1 = (0.0, 1.0)) + hole_a = make_section(; id = 1, ring_id = 1, v0 = (1.0, 1.0), v1 = (1.0, -1.0)) + corner_b = make_section(; is_a = false, id = 1, v0 = (-1.0, 1.0), v1 = (-1.0, -1.0)) + node = build_node(shell_a, hole_a, corner_b) + edges = GO.get_edges(node) + @test length(edges) == 6 + @test dirs(node) == [(1.0, 1.0), (0.0, 1.0), (-1.0, 1.0), + (-1.0, -1.0), (0.0, -1.0), (1.0, -1.0)] + # A's converted self-touch corners: (0,-1)→(1,-1) and (1,1)→(0,1) + @test locs(edges[1], true) == (LI, LB, LE) # (1,1): enter of corner 2 + @test locs(edges[2], true) == (LE, LB, LI) # (0,1): exit of corner 2 + @test locs(edges[5], true) == (LI, LB, LE) # (0,-1): enter of corner 1 + @test locs(edges[6], true) == (LE, LB, LI) # (1,-1): exit of corner 1 + # B's singleton corner + @test locs(edges[3], false) == (LI, LB, LE) # (-1,1): enter + @test locs(edges[4], false) == (LE, LB, LI) # (-1,-1): exit + # cross-geometry labels unknown before finish! + @test locs(edges[1], false) == (LN, LN, LN) + @test locs(edges[3], true) == (LN, LN, LN) + + GO.finish!(node, false, false) + # B's corner lies in A's exterior and vice versa + @test locs(edges[3], true) == (LE, LE, LE) + @test locs(edges[4], true) == (LE, LE, LE) + @test locs(edges[1], false) == (LE, LE, LE) + @test locs(edges[5], false) == (LE, LE, LE) + @test GO.has_exterior_edge(node, true) && GO.has_exterior_edge(node, false) + end + + # finish! with isAreaInterior: in a mixed GC the node may lie in the + # interior of an area of the other geometry — every edge then becomes + # all-INTERIOR for that geometry (RelateNode.finishNode true branch). + @testset "finish! isAreaInterior" begin + sA = line_section(; v0 = (-1.0, 0.0), v1 = (1.0, 0.0)) + sB = line_section(; is_a = false, v0 = (0.0, -1.0), v1 = (0.0, 1.0)) + node = build_node(sA, sB) + GO.finish!(node, true, false) + for e in GO.get_edges(node) + @test locs(e, true) == (LI, LI, LI) + end + @test !GO.has_exterior_edge(node, true) + @test GO.has_exterior_edge(node, false) + end +end + +@testset "no GO methods on Base.merge!" begin + # RelateEdge label merging must not extend Base.merge! (collection + # semantics). It lives on the internal function `merge_edge!` instead. + @test !any(m -> m.module == GO, methods(Base.merge!)) +end diff --git a/test/methods/relateng/point_locator.jl b/test/methods/relateng/point_locator.jl new file mode 100644 index 0000000000..edb65fa489 --- /dev/null +++ b/test/methods/relateng/point_locator.jl @@ -0,0 +1,284 @@ +# Tests for the RelateNG point-location machinery (point_locator.jl). +# LinearBoundary section ports JTS LinearBoundaryTest.java; the +# AdjacentEdgeLocator section ports JTS AdjacentEdgeLocatorTest.java; the +# RelatePointLocator section ports JTS RelatePointLocatorTest.java. + +using Test +import GeometryOps as GO +import GeometryOps: Planar, True +import GeoInterface as GI + +# Port of LinearBoundaryTest.checkLinearBoundary. The Java tests parse WKT; +# here each WKT literal is translated by hand into GI.LineStrings and a set +# of expected boundary coordinate tuples (nothing ⇔ Java's null = no boundary). +function check_linear_boundary(lines, rule, expected_boundary) + lb = GO.LinearBoundary(Planar(), lines, rule) + has_boundary_expected = expected_boundary === nothing ? false : true + @test GO.has_boundary(lb) == has_boundary_expected + check_boundary_points(lb, lines, expected_boundary) +end + +# Port of LinearBoundaryTest.checkBoundaryPoints (+ extractPoints). +function check_boundary_points(lb, lines, expected_boundary) + bdy_set = expected_boundary === nothing ? Set{Tuple{Float64, Float64}}() : + Set{Tuple{Float64, Float64}}(expected_boundary) + for p in bdy_set + @test GO.is_boundary(lb, p) + end + for line in lines, p in GI.getpoint(line) + pt = (GI.x(p), GI.y(p)) + if !(pt in bdy_set) + @test !GO.is_boundary(lb, pt) + end + end +end + +@testset "LinearBoundary" begin + # testLineMod2: LINESTRING (0 0, 9 9), boundary MULTIPOINT((0 0), (9 9)) + @testset "line Mod2" begin + lines = [GI.LineString([(0.0, 0.0), (9.0, 9.0)])] + check_linear_boundary(lines, GO.Mod2Boundary(), [(0.0, 0.0), (9.0, 9.0)]) + end + + # testLines2Mod2: MULTILINESTRING ((0 0, 9 9), (9 9, 5 1)), + # boundary MULTIPOINT((0 0), (5 1)) + @testset "lines2 Mod2" begin + lines = [ + GI.LineString([(0.0, 0.0), (9.0, 9.0)]), + GI.LineString([(9.0, 9.0), (5.0, 1.0)]), + ] + check_linear_boundary(lines, GO.Mod2Boundary(), [(0.0, 0.0), (5.0, 1.0)]) + end + + # testLines3Mod2: MULTILINESTRING ((0 0, 9 9), (9 9, 5 1), (9 9, 1 5)), + # boundary MULTIPOINT((0 0), (5 1), (1 5), (9 9)) + @testset "lines3 Mod2" begin + lines = [ + GI.LineString([(0.0, 0.0), (9.0, 9.0)]), + GI.LineString([(9.0, 9.0), (5.0, 1.0)]), + GI.LineString([(9.0, 9.0), (1.0, 5.0)]), + ] + check_linear_boundary(lines, GO.Mod2Boundary(), + [(0.0, 0.0), (5.0, 1.0), (1.0, 5.0), (9.0, 9.0)]) + end + + # testLines3Monvalent: same lines, MONOVALENT_ENDPOINT_BOUNDARY_RULE, + # boundary MULTIPOINT((0 0), (5 1), (1 5)) — (9 9) has degree 3, not 1 + @testset "lines3 Monovalent" begin + lines = [ + GI.LineString([(0.0, 0.0), (9.0, 9.0)]), + GI.LineString([(9.0, 9.0), (5.0, 1.0)]), + GI.LineString([(9.0, 9.0), (1.0, 5.0)]), + ] + check_linear_boundary(lines, GO.MonovalentEndpointBoundary(), + [(0.0, 0.0), (5.0, 1.0), (1.0, 5.0)]) + end +end + +# Port of AdjacentEdgeLocatorTest.checkLocation. The Java tests parse WKT; +# here each WKT literal is hand-translated into GI geometries. +function check_location(geom, x, y, expected_loc) + ael = GO.AdjacentEdgeLocator(Planar(), geom; exact = True()) + loc = GO.locate(ael, (Float64(x), Float64(y))) + @test loc == expected_loc +end + +@testset "AdjacentEdgeLocator" begin + # testAdjacent2: + # GEOMETRYCOLLECTION (POLYGON ((1 9, 5 9, 5 1, 1 1, 1 9)), + # POLYGON ((9 9, 9 1, 5 1, 5 9, 9 9))) + @testset "adjacent 2" begin + gc = GI.GeometryCollection([ + GI.Polygon([[(1.0, 9.0), (5.0, 9.0), (5.0, 1.0), (1.0, 1.0), (1.0, 9.0)]]), + GI.Polygon([[(9.0, 9.0), (9.0, 1.0), (5.0, 1.0), (5.0, 9.0), (9.0, 9.0)]]), + ]) + check_location(gc, 5, 5, GO.LOC_INTERIOR) + end + + # testNonAdjacent: + # GEOMETRYCOLLECTION (POLYGON ((1 9, 4 9, 5 1, 1 1, 1 9)), + # POLYGON ((9 9, 9 1, 5 1, 5 9, 9 9))) + @testset "non-adjacent" begin + gc = GI.GeometryCollection([ + GI.Polygon([[(1.0, 9.0), (4.0, 9.0), (5.0, 1.0), (1.0, 1.0), (1.0, 9.0)]]), + GI.Polygon([[(9.0, 9.0), (9.0, 1.0), (5.0, 1.0), (5.0, 9.0), (9.0, 9.0)]]), + ]) + check_location(gc, 5, 5, GO.LOC_BOUNDARY) + end + + # testAdjacent6WithFilledHoles: + # GEOMETRYCOLLECTION ( + # POLYGON ((1 9, 5 9, 6 6, 1 5, 1 9), (2 6, 4 8, 6 6, 2 6)), + # POLYGON ((2 6, 4 8, 6 6, 2 6)), + # POLYGON ((9 9, 9 5, 6 6, 5 9, 9 9)), + # POLYGON ((9 1, 5 1, 6 6, 9 5, 9 1), (7 2, 6 6, 8 3, 7 2)), + # POLYGON ((7 2, 6 6, 8 3, 7 2)), + # POLYGON ((1 1, 1 5, 6 6, 5 1, 1 1))) + @testset "adjacent 6 with filled holes" begin + gc = GI.GeometryCollection([ + GI.Polygon([ + [(1.0, 9.0), (5.0, 9.0), (6.0, 6.0), (1.0, 5.0), (1.0, 9.0)], + [(2.0, 6.0), (4.0, 8.0), (6.0, 6.0), (2.0, 6.0)], + ]), + GI.Polygon([[(2.0, 6.0), (4.0, 8.0), (6.0, 6.0), (2.0, 6.0)]]), + GI.Polygon([[(9.0, 9.0), (9.0, 5.0), (6.0, 6.0), (5.0, 9.0), (9.0, 9.0)]]), + GI.Polygon([ + [(9.0, 1.0), (5.0, 1.0), (6.0, 6.0), (9.0, 5.0), (9.0, 1.0)], + [(7.0, 2.0), (6.0, 6.0), (8.0, 3.0), (7.0, 2.0)], + ]), + GI.Polygon([[(7.0, 2.0), (6.0, 6.0), (8.0, 3.0), (7.0, 2.0)]]), + GI.Polygon([[(1.0, 1.0), (1.0, 5.0), (6.0, 6.0), (5.0, 1.0), (1.0, 1.0)]]), + ]) + check_location(gc, 6, 6, GO.LOC_INTERIOR) + end + + # testAdjacent5WithEmptyHole: as above, but the (7 2, 6 6, 8 3) hole is + # not filled by a matching polygon, so the node is on the boundary. + @testset "adjacent 5 with empty hole" begin + gc = GI.GeometryCollection([ + GI.Polygon([ + [(1.0, 9.0), (5.0, 9.0), (6.0, 6.0), (1.0, 5.0), (1.0, 9.0)], + [(2.0, 6.0), (4.0, 8.0), (6.0, 6.0), (2.0, 6.0)], + ]), + GI.Polygon([[(2.0, 6.0), (4.0, 8.0), (6.0, 6.0), (2.0, 6.0)]]), + GI.Polygon([[(9.0, 9.0), (9.0, 5.0), (6.0, 6.0), (5.0, 9.0), (9.0, 9.0)]]), + GI.Polygon([ + [(9.0, 1.0), (5.0, 1.0), (6.0, 6.0), (9.0, 5.0), (9.0, 1.0)], + [(7.0, 2.0), (6.0, 6.0), (8.0, 3.0), (7.0, 2.0)], + ]), + GI.Polygon([[(1.0, 1.0), (1.0, 5.0), (6.0, 6.0), (5.0, 1.0), (1.0, 1.0)]]), + ]) + check_location(gc, 6, 6, GO.LOC_BOUNDARY) + end + + # testContainedAndAdjacent: + # GEOMETRYCOLLECTION (POLYGON ((1 9, 9 9, 9 1, 1 1, 1 9)), + # POLYGON ((9 2, 2 2, 2 8, 9 8, 9 2))) + @testset "contained and adjacent" begin + gc = GI.GeometryCollection([ + GI.Polygon([[(1.0, 9.0), (9.0, 9.0), (9.0, 1.0), (1.0, 1.0), (1.0, 9.0)]]), + GI.Polygon([[(9.0, 2.0), (2.0, 2.0), (2.0, 8.0), (9.0, 8.0), (9.0, 2.0)]]), + ]) + check_location(gc, 9, 5, GO.LOC_BOUNDARY) + check_location(gc, 9, 8, GO.LOC_BOUNDARY) + end + + # testDisjointCollinear (bug caused by incorrect point-on-segment logic): + # GEOMETRYCOLLECTION (MULTIPOLYGON (((1 4, 4 4, 4 1, 1 1, 1 4)), + # ((5 4, 8 4, 8 1, 5 1, 5 4)))) + @testset "disjoint collinear" begin + gc = GI.GeometryCollection([ + GI.MultiPolygon([ + GI.Polygon([[(1.0, 4.0), (4.0, 4.0), (4.0, 1.0), (1.0, 1.0), (1.0, 4.0)]]), + GI.Polygon([[(5.0, 4.0), (8.0, 4.0), (8.0, 1.0), (5.0, 1.0), (5.0, 4.0)]]), + ]), + ]) + check_location(gc, 2, 4, GO.LOC_BOUNDARY) + end +end + +# Port of RelatePointLocatorTest.java. The Java fixture WKT +# `gcPLA` is hand-translated into GI constructors: +# GEOMETRYCOLLECTION (POINT (1 1), POINT (2 1), LINESTRING (3 1, 3 9), +# LINESTRING (4 1, 5 4, 7 1, 4 1), LINESTRING (12 12, 14 14), +# POLYGON ((6 5, 6 9, 9 9, 9 5, 6 5)), +# POLYGON ((10 10, 10 16, 16 16, 16 10, 10 10)), +# POLYGON ((11 11, 11 17, 17 17, 17 11, 11 11)), +# POLYGON ((12 12, 12 16, 16 16, 16 12, 12 12))) +const gc_PLA = GI.GeometryCollection([ + GI.Point(1.0, 1.0), + GI.Point(2.0, 1.0), + GI.LineString([(3.0, 1.0), (3.0, 9.0)]), + GI.LineString([(4.0, 1.0), (5.0, 4.0), (7.0, 1.0), (4.0, 1.0)]), + GI.LineString([(12.0, 12.0), (14.0, 14.0)]), + GI.Polygon([[(6.0, 5.0), (6.0, 9.0), (9.0, 9.0), (9.0, 5.0), (6.0, 5.0)]]), + GI.Polygon([[(10.0, 10.0), (10.0, 16.0), (16.0, 16.0), (16.0, 10.0), (10.0, 10.0)]]), + GI.Polygon([[(11.0, 11.0), (11.0, 17.0), (17.0, 17.0), (17.0, 11.0), (11.0, 11.0)]]), + GI.Polygon([[(12.0, 12.0), (12.0, 16.0), (16.0, 16.0), (16.0, 12.0), (12.0, 12.0)]]), +]) + +# Port of RelatePointLocatorTest.checkDimLocation. +function check_dim_location(geom, x, y, expected_dim_loc) + locator = GO.RelatePointLocator(Planar(), geom; exact = True()) + actual = GO.locate_with_dim(locator, (Float64(x), Float64(y))) + @test actual == expected_dim_loc +end + +# Port of RelatePointLocatorTest.checkLineEndDimLocation. +function check_line_end_dim_location(geom, x, y, expected_dim_loc) + locator = GO.RelatePointLocator(Planar(), geom; exact = True()) + actual = GO.locate_line_end_with_dim(locator, (Float64(x), Float64(y))) + @test actual == expected_dim_loc +end + +# Port of RelatePointLocatorTest.checkNodeLocation. +function check_node_location(geom, x, y, expected_loc) + locator = GO.RelatePointLocator(Planar(), geom; exact = True()) + actual = GO.locate_node(locator, (Float64(x), Float64(y)), nothing) + @test actual == expected_loc +end + +@testset "RelatePointLocator" begin + # testPoint + @testset "point" begin + check_dim_location(gc_PLA, 1, 1, GO.DL_POINT_INTERIOR) + check_dim_location(gc_PLA, 0, 1, GO.DL_EXTERIOR) + end + + # testPointInLine + @testset "point in line" begin + check_dim_location(gc_PLA, 3, 8, GO.DL_LINE_INTERIOR) + end + + # testPointInArea + @testset "point in area" begin + check_dim_location(gc_PLA, 8, 8, GO.DL_AREA_INTERIOR) + end + + # testLine + @testset "line" begin + check_dim_location(gc_PLA, 3, 3, GO.DL_LINE_INTERIOR) + check_dim_location(gc_PLA, 3, 1, GO.DL_LINE_BOUNDARY) + end + + # testLineInArea + @testset "line in area" begin + check_dim_location(gc_PLA, 11, 11, GO.DL_AREA_INTERIOR) + check_dim_location(gc_PLA, 14, 14, GO.DL_AREA_INTERIOR) + end + + # testArea + @testset "area" begin + check_dim_location(gc_PLA, 8, 8, GO.DL_AREA_INTERIOR) + check_dim_location(gc_PLA, 9, 9, GO.DL_AREA_BOUNDARY) + end + + # testAreaInArea + @testset "area in area" begin + check_dim_location(gc_PLA, 11, 11, GO.DL_AREA_INTERIOR) + check_dim_location(gc_PLA, 12, 12, GO.DL_AREA_INTERIOR) + check_dim_location(gc_PLA, 10, 10, GO.DL_AREA_BOUNDARY) + check_dim_location(gc_PLA, 16, 16, GO.DL_AREA_INTERIOR) + end + + # testLineNode + @testset "line node" begin + # checkNodeLocation(gcPLA, 12.1, 12.2, Location.INTERIOR) — commented + # out in the Java test too. + check_node_location(gc_PLA, 3, 1, GO.LOC_BOUNDARY) + end + + # testLineEndInGCLA: + # GEOMETRYCOLLECTION (POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0)), + # LINESTRING (12 2, 0 2, 0 5, 5 5), LINESTRING (12 10, 12 2)) + @testset "line end in GC LA" begin + gc = GI.GeometryCollection([ + GI.Polygon([[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]]), + GI.LineString([(12.0, 2.0), (0.0, 2.0), (0.0, 5.0), (5.0, 5.0)]), + GI.LineString([(12.0, 10.0), (12.0, 2.0)]), + ]) + check_line_end_dim_location(gc, 5, 5, GO.DL_AREA_INTERIOR) + check_line_end_dim_location(gc, 12, 2, GO.DL_LINE_INTERIOR) + check_line_end_dim_location(gc, 12, 10, GO.DL_LINE_BOUNDARY) + end +end diff --git a/test/methods/relateng/predicates.jl b/test/methods/relateng/predicates.jl new file mode 100644 index 0000000000..136be2ea40 --- /dev/null +++ b/test/methods/relateng/predicates.jl @@ -0,0 +1,453 @@ +# Tests for the RelateNG topology predicate framework +# (port of JTS TopologyPredicate / BasicPredicate / IMPredicate, +# plus the `intersects` and `disjoint` BasicPredicate kinds from +# RelatePredicate.java). + +using Test +import GeometryOps as GO +import Extents + +@testset "tri-state and intersects predicate" begin + p = GO.pred_intersects() + @test GO.predicate_name(p) == "intersects" + @test !GO.is_known(p) + @test GO.require_self_noding(typeof(p)) == false + @test GO.require_interaction(typeof(p)) == true + @test GO.require_covers(typeof(p), true) == false + @test GO.require_exterior_check(typeof(p), true) == false + @test GO.require_exterior_check(typeof(p), false) == false + # interior/interior intersection determines intersects=true immediately + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == true + # exterior-only updates never determine it; finish! defaults to false + q = GO.pred_intersects() + GO.update_dim!(q, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_L) + @test !GO.is_known(q) + GO.finish!(q) + @test GO.is_known(q) && GO.predicate_value(q) == false + # disjoint envelopes determine intersects=false at init_bounds! + r = GO.pred_intersects() + GO.init_bounds!(r, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(5.0, 6.0), Y=(5.0, 6.0))) + @test GO.is_known(r) && GO.predicate_value(r) == false + # overlapping envelopes leave it unknown + s = GO.pred_intersects() + GO.init_bounds!(s, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(0.5, 6.0), Y=(0.5, 6.0))) + @test !GO.is_known(s) +end + +@testset "disjoint predicate" begin + p = GO.pred_disjoint() + @test GO.predicate_name(p) == "disjoint" + @test GO.require_self_noding(typeof(p)) == false + @test GO.require_interaction(typeof(p)) == false + @test GO.require_exterior_check(typeof(p), true) == false + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == false + q = GO.pred_disjoint() + GO.finish!(q) + @test GO.is_known(q) && GO.predicate_value(q) == true + # disjoint envelopes determine disjoint=true at init_bounds! + r = GO.pred_disjoint() + GO.init_bounds!(r, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(5.0, 6.0), Y=(5.0, 6.0))) + @test GO.is_known(r) && GO.predicate_value(r) == true +end + +@testset "tri-state value is sticky" begin + # JTS BasicPredicate.setValue does not change an already-known value + p = GO.pred_intersects() + GO.update_dim!(p, GO.LOC_BOUNDARY, GO.LOC_BOUNDARY, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == true + GO.finish!(p) # would set false if value were not sticky + @test GO.predicate_value(p) == true +end + +@testset "is_intersection" begin + @test GO.is_intersection(GO.LOC_INTERIOR, GO.LOC_INTERIOR) + @test GO.is_intersection(GO.LOC_BOUNDARY, GO.LOC_INTERIOR) + @test !GO.is_intersection(GO.LOC_EXTERIOR, GO.LOC_INTERIOR) + @test !GO.is_intersection(GO.LOC_INTERIOR, GO.LOC_EXTERIOR) + @test !GO.is_intersection(GO.LOC_EXTERIOR, GO.LOC_EXTERIOR) +end + +@testset "IMPredicate core state" begin + # No named IM kinds exist until Task 4; exercise the shared core + # through a minimal local kind. + struct _TestIMKind end + # never determined early; value is whether I/I intersects + GO.is_determined(p::GO.IMPredicate{_TestIMKind}) = false + GO.value_im(p::GO.IMPredicate{_TestIMKind}) = + GO.is_intersects_entry(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR) + + p = GO.IMPredicate(_TestIMKind()) + # JTS IMPredicate constructor presets E/E to dim 2 (Dimension.A) + @test GO.get_dimension(p, GO.LOC_EXTERIOR, GO.LOC_EXTERIOR) == GO.DIM_A + # all other entries start FALSE (JTS IntersectionMatrix init) + @test GO.get_dimension(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR) == GO.DIM_FALSE + @test !GO.is_known(p) + + GO.init_dims!(p, 2, 1) + @test p.dimA == GO.DIM_A && p.dimB == GO.DIM_L + + # only an increased dimension is recorded + @test GO.is_dim_changed(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_P) + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_L) + @test GO.get_dimension(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR) == GO.DIM_L + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_P) + @test GO.get_dimension(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR) == GO.DIM_L + @test GO.is_dimension_entry(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_L) + @test GO.is_intersects_entry(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR) + @test !GO.is_intersects_entry(p, GO.LOC_BOUNDARY, GO.LOC_BOUNDARY) + + # intersects_exterior_of + @test !GO.intersects_exterior_of(p, true) + @test !GO.intersects_exterior_of(p, false) + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_L) + @test GO.intersects_exterior_of(p, false) + @test !GO.intersects_exterior_of(p, true) + + # finish! resolves the value from the matrix state + @test !GO.is_known(p) + GO.finish!(p) + @test GO.is_known(p) && GO.predicate_value(p) == true + + # is_dims_compatible_with_covers (JTS IMPredicate static helper) + @test GO.is_dims_compatible_with_covers(GO.DIM_P, GO.DIM_L) # points coveredBy zero-length lines + @test GO.is_dims_compatible_with_covers(GO.DIM_A, GO.DIM_L) + @test GO.is_dims_compatible_with_covers(GO.DIM_L, GO.DIM_L) + @test !GO.is_dims_compatible_with_covers(GO.DIM_L, GO.DIM_A) +end + +# --- Named IM predicates, pattern matcher, matrix predicate (Task 4) --- +# Port of JTS RelatePredicateTest.java, plus requirement-flag-table +# assertions read directly from each RelatePredicate.java inner class, +# and IMPatternMatcher / RelateMatrixPredicate tests. + +# JTS RelatePredicateTest fixture matrices ('.' separators are cosmetic) +const A_EXT_B_INT = "***.***.1**" +const A_INT_B_INT = "1**.***.***" + +# JTS RelatePredicateTest.applyIM +function apply_im!(pred, im_in::String) + im = replace(im_in, "." => "") + for i in 0:8 + locA = i ÷ 3 + locB = i - 3 * locA + entry = im[i + 1] + if entry in ('0', '1', '2') + GO.update_dim!(pred, locA, locB, GO.dim_code(entry)) + end + end + return pred +end + +# JTS RelatePredicateTest.checkPredicate / checkPred +function check_predicate(pred, im::String, expected::Bool) + apply_im!(pred, im) + GO.finish!(pred) + @test GO.is_known(pred) + @test GO.predicate_value(pred) == expected +end + +# JTS RelatePredicateTest.checkPredicatePartial (value known before finish!) +function check_predicate_partial(pred, im::String, expected::Bool) + apply_im!(pred, im) + @test GO.is_known(pred) # "predicate value is not known" + GO.finish!(pred) + @test GO.predicate_value(pred) == expected +end + +@testset "RelatePredicateTest (JTS port)" begin + @testset "testIntersects" begin + check_predicate(GO.pred_intersects(), A_INT_B_INT, true) + end + @testset "testDisjoint" begin + check_predicate(GO.pred_intersects(), A_EXT_B_INT, false) + check_predicate(GO.pred_disjoint(), A_EXT_B_INT, true) + end + @testset "testCovers" begin + check_predicate(GO.pred_covers(), A_INT_B_INT, true) + check_predicate(GO.pred_covers(), A_EXT_B_INT, false) + end + @testset "testCoversFast" begin + check_predicate_partial(GO.pred_covers(), A_EXT_B_INT, false) + end + @testset "testMatch" begin + check_predicate(GO.pred_matches("1***T*0**"), "1**.*2*.0**", true) + end +end + +@testset "predicate names" begin + @test GO.predicate_name(GO.pred_contains()) == "contains" + @test GO.predicate_name(GO.pred_within()) == "within" + @test GO.predicate_name(GO.pred_covers()) == "covers" + @test GO.predicate_name(GO.pred_coveredby()) == "coveredBy" + @test GO.predicate_name(GO.pred_crosses()) == "crosses" + @test GO.predicate_name(GO.pred_equalstopo()) == "equals" + @test GO.predicate_name(GO.pred_overlaps()) == "overlaps" + @test GO.predicate_name(GO.pred_touches()) == "touches" +end + +@testset "requirement flag table (per RelatePredicate.java)" begin + # columns: self_noding, interaction, covers(A), covers(B), ext_check(A), ext_check(B) + flag_table = [ + (GO.pred_contains(), true, true, true, false, false, true), + (GO.pred_within(), true, true, false, true, true, false), + (GO.pred_covers(), true, true, true, false, false, true), + (GO.pred_coveredby(), true, true, false, true, true, false), + (GO.pred_crosses(), true, true, false, false, true, true), + (GO.pred_equalstopo(), true, false, false, false, true, true), + (GO.pred_overlaps(), true, true, false, false, true, true), + (GO.pred_touches(), true, true, false, false, true, true), + ] + for (p, sn, ia, cov_a, cov_b, ext_a, ext_b) in flag_table + T = typeof(p) + @testset "$(GO.predicate_name(p))" begin + @test GO.require_self_noding(T) == sn + @test GO.require_interaction(T) == ia + @test GO.require_covers(T, true) == cov_a + @test GO.require_covers(T, false) == cov_b + @test GO.require_exterior_check(T, true) == ext_a + @test GO.require_exterior_check(T, false) == ext_b + # instance-level forwarding agrees with the type-level flags + @test GO.require_self_noding(p) == sn + @test GO.require_interaction(p) == ia + @test GO.require_covers(p, true) == cov_a && GO.require_covers(p, false) == cov_b + @test GO.require_exterior_check(p, true) == ext_a && GO.require_exterior_check(p, false) == ext_b + end + end +end + +@testset "contains" begin + # dims incompatible with covers determine false at init_dims! + p = GO.pred_contains() + GO.init_dims!(p, GO.DIM_L, GO.DIM_A) + @test GO.is_known(p) && GO.predicate_value(p) == false + # B in interior of A + check_predicate(GO.pred_contains(), A_INT_B_INT, true) + # B intersecting exterior of A determines false early + check_predicate_partial(GO.pred_contains(), A_EXT_B_INT, false) + # envelope of A must cover envelope of B + q = GO.pred_contains() + GO.init_bounds!(q, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(0.0, 2.0), Y=(0.0, 1.0))) + @test GO.is_known(q) && GO.predicate_value(q) == false + r = GO.pred_contains() + GO.init_bounds!(r, Extents.Extent(X=(0.0, 3.0), Y=(0.0, 3.0)), + Extents.Extent(X=(1.0, 2.0), Y=(1.0, 2.0))) + @test !GO.is_known(r) +end + +@testset "within" begin + p = GO.pred_within() + GO.init_dims!(p, GO.DIM_A, GO.DIM_L) # dimB must be able to cover dimA + @test GO.is_known(p) && GO.predicate_value(p) == false + check_predicate(GO.pred_within(), A_INT_B_INT, true) + # A intersecting exterior of B determines false early + check_predicate_partial(GO.pred_within(), "1*1.***.***", false) + # envelope of B must cover envelope of A + q = GO.pred_within() + GO.init_bounds!(q, Extents.Extent(X=(0.0, 2.0), Y=(0.0, 1.0)), + Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0))) + @test GO.is_known(q) && GO.predicate_value(q) == false +end + +@testset "covers and coveredBy boundary contact" begin + # covers is true for boundary-only contact, where contains is false + check_predicate(GO.pred_covers(), "***.1**.***", true) + check_predicate(GO.pred_contains(), "***.1**.***", false) + check_predicate(GO.pred_coveredby(), "**F.1*F.***", true) + check_predicate_partial(GO.pred_coveredby(), "1*1.***.***", false) + check_predicate(GO.pred_coveredby(), A_INT_B_INT, true) +end + +@testset "crosses" begin + # P/P and A/A are determined false at init_dims! + p = GO.pred_crosses(); GO.init_dims!(p, GO.DIM_P, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == false + p = GO.pred_crosses(); GO.init_dims!(p, GO.DIM_A, GO.DIM_A) + @test GO.is_known(p) && GO.predicate_value(p) == false + # dimA < dimB: determined once I/I and I/E intersect + p = GO.pred_crosses(); GO.init_dims!(p, GO.DIM_L, GO.DIM_A) + apply_im!(p, "1*1.***.***") + @test GO.is_known(p) && GO.predicate_value(p) == true + # dimA > dimB: determined once I/I and E/I intersect + p = GO.pred_crosses(); GO.init_dims!(p, GO.DIM_A, GO.DIM_L) + apply_im!(p, "1**.***.1**") + @test GO.is_known(p) && GO.predicate_value(p) == true + # L/L: dim-0 interior intersection is a crossing, found at finish! + p = GO.pred_crosses(); GO.init_dims!(p, GO.DIM_L, GO.DIM_L) + apply_im!(p, "0**.***.***") + @test !GO.is_known(p) + GO.finish!(p) + @test GO.predicate_value(p) == true + # L/L: dim-1 (collinear) interior intersection is not a crossing + p = GO.pred_crosses(); GO.init_dims!(p, GO.DIM_L, GO.DIM_L) + apply_im!(p, "1**.***.***") + @test GO.is_known(p) && GO.predicate_value(p) == false +end + +@testset "equalsTopo" begin + p = GO.pred_equalstopo(); GO.init_dims!(p, GO.DIM_L, GO.DIM_L) + apply_im!(p, A_INT_B_INT) + GO.finish!(p) + @test GO.predicate_value(p) == true + # different dims are never topo-equal + p = GO.pred_equalstopo(); GO.init_dims!(p, GO.DIM_L, GO.DIM_A) + apply_im!(p, A_INT_B_INT) + GO.finish!(p) + @test GO.predicate_value(p) == false + # any exterior intersection determines false early + p = GO.pred_equalstopo(); GO.init_dims!(p, GO.DIM_A, GO.DIM_A) + apply_im!(p, "2*1.***.***") + @test GO.is_known(p) && GO.predicate_value(p) == false + # EMPTY = EMPTY (null bounds) + p = GO.pred_equalstopo() + GO.init_bounds!(p, nothing, nothing) + @test GO.is_known(p) && GO.predicate_value(p) == true + # unequal bounds determine false + p = GO.pred_equalstopo() + GO.init_bounds!(p, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(0.0, 2.0), Y=(0.0, 1.0))) + @test GO.is_known(p) && GO.predicate_value(p) == false + # equal bounds leave the value unknown + p = GO.pred_equalstopo() + GO.init_bounds!(p, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0))) + @test !GO.is_known(p) +end + +@testset "overlaps" begin + # different dims are determined false at init_dims! + p = GO.pred_overlaps(); GO.init_dims!(p, GO.DIM_L, GO.DIM_A) + @test GO.is_known(p) && GO.predicate_value(p) == false + # A/A: determined once I/I, I/E and E/I all intersect + p = GO.pred_overlaps(); GO.init_dims!(p, GO.DIM_A, GO.DIM_A) + apply_im!(p, "2*2.***.2**") + @test GO.is_known(p) && GO.predicate_value(p) == true + # L/L: requires a dim-1 interior/interior intersection + p = GO.pred_overlaps(); GO.init_dims!(p, GO.DIM_L, GO.DIM_L) + apply_im!(p, "0*1.***.1**") + GO.finish!(p) + @test GO.predicate_value(p) == false + p = GO.pred_overlaps(); GO.init_dims!(p, GO.DIM_L, GO.DIM_L) + apply_im!(p, "1*1.***.1**") + @test GO.is_known(p) && GO.predicate_value(p) == true +end + +@testset "touches" begin + # Points have only interiors, so cannot touch + p = GO.pred_touches(); GO.init_dims!(p, GO.DIM_P, GO.DIM_P) + @test GO.is_known(p) && GO.predicate_value(p) == false + # boundary-only contact touches + p = GO.pred_touches(); GO.init_dims!(p, GO.DIM_A, GO.DIM_A) + apply_im!(p, "***.*1*.***") + GO.finish!(p) + @test GO.predicate_value(p) == true + # interior intersection determines false early + p = GO.pred_touches(); GO.init_dims!(p, GO.DIM_A, GO.DIM_A) + apply_im!(p, "2**.***.***") + @test GO.is_known(p) && GO.predicate_value(p) == false + # dim order is symmetric (JTS isTouches transposes dims, not the matrix) + p = GO.pred_touches(); GO.init_dims!(p, GO.DIM_A, GO.DIM_L) + apply_im!(p, "***.*1*.***") + GO.finish!(p) + @test GO.predicate_value(p) == true +end + +@testset "DE9IM relate queries (IntersectionMatrix port)" begin + contains_im = GO.DE9IM("212FF1FF2") + within_im = GO.DE9IM("2FF1FF212") + equals_im = GO.DE9IM("2FF1FFFF2") + @test GO.is_contains(contains_im) && !GO.is_contains(within_im) + @test GO.is_within(within_im) && !GO.is_within(contains_im) + @test GO.is_covers(contains_im) && !GO.is_covers(within_im) + @test GO.is_coveredby(within_im) && !GO.is_coveredby(contains_im) + # covers via boundary contact only, where contains fails + boundary_im = GO.DE9IM("FF2F01FF2") + @test GO.is_covers(boundary_im) && !GO.is_contains(boundary_im) + # equals requires equal dims and no exterior intersections + @test GO.is_equals(equals_im, GO.DIM_A, GO.DIM_A) + @test !GO.is_equals(equals_im, GO.DIM_A, GO.DIM_L) + @test !GO.is_equals(contains_im, GO.DIM_A, GO.DIM_A) + # crosses + la_cross = GO.DE9IM("101FF0212") + @test GO.is_crosses(la_cross, GO.DIM_L, GO.DIM_A) + @test GO.is_crosses(la_cross, GO.DIM_A, GO.DIM_L) + @test !GO.is_crosses(la_cross, GO.DIM_L, GO.DIM_L) # L/L needs I/I == 0 + @test !GO.is_crosses(la_cross, GO.DIM_A, GO.DIM_A) # A/A never crosses + @test GO.is_crosses(GO.DE9IM("0F1FF0102"), GO.DIM_L, GO.DIM_L) + # overlaps + @test GO.is_overlaps(GO.DE9IM("212101212"), GO.DIM_A, GO.DIM_A) + @test !GO.is_overlaps(GO.DE9IM("212101212"), GO.DIM_L, GO.DIM_L) + @test GO.is_overlaps(GO.DE9IM("1F1FF0102"), GO.DIM_L, GO.DIM_L) + @test !GO.is_overlaps(GO.DE9IM("212101212"), GO.DIM_L, GO.DIM_A) + # touches + touch_im = GO.DE9IM("FF2F11212") + @test GO.is_touches(touch_im, GO.DIM_A, GO.DIM_A) + @test GO.is_touches(touch_im, GO.DIM_A, GO.DIM_L) # transposed dims + @test !GO.is_touches(touch_im, GO.DIM_P, GO.DIM_P) # points cannot touch + @test !GO.is_touches(GO.DE9IM("212101212"), GO.DIM_A, GO.DIM_A) +end + +@testset "IMPatternMatcher" begin + p = GO.IMPatternMatcher("T*F**FFF*") + @test GO.predicate_name(p) == "IMPattern" + @test !GO.is_known(p) + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + @test !GO.is_known(p) + # an entry violating the pattern mask determines false immediately + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_L) # pattern 'F' at I/E + @test GO.is_known(p) && GO.predicate_value(p) == false + + # a fully matching evaluation + q = GO.IMPatternMatcher("T*F**FFF*") + GO.update_dim!(q, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + GO.update_dim!(q, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_L) + GO.finish!(q) + @test GO.is_known(q) && GO.predicate_value(q) == true + + # pred_matches is the JTS RelatePredicate.matches factory + @test GO.pred_matches("T*F**FFF*") isa GO.IMPatternMatcher + + # require_interaction is an instance method computed from the pattern + @test GO.require_interaction(GO.IMPatternMatcher("T*F**FFF*")) == true + @test GO.require_interaction(GO.IMPatternMatcher("FF*FF****")) == false # disjoint pattern + @test GO.require_interaction(GO.IMPatternMatcher("****1****")) == true + @test GO.require_interaction(GO.IMPatternMatcher("FF*FF*1**")) == false # only E-row entries + + # init_bounds!: interaction-requiring pattern + disjoint envelopes => false + r = GO.IMPatternMatcher("T*F**FFF*") + GO.init_bounds!(r, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(5.0, 6.0), Y=(5.0, 6.0))) + @test GO.is_known(r) && GO.predicate_value(r) == false + # non-interaction pattern is not determined by disjoint envelopes + s = GO.IMPatternMatcher("FF*FF****") + GO.init_bounds!(s, Extents.Extent(X=(0.0, 1.0), Y=(0.0, 1.0)), + Extents.Extent(X=(5.0, 6.0), Y=(5.0, 6.0))) + @test !GO.is_known(s) +end + +@testset "IntersectionMatrixPattern constants" begin + @test GO.IM_PATTERN_ADJACENT == "F***1****" + @test GO.IM_PATTERN_CONTAINS_PROPERLY == "T**FF*FF*" + @test GO.IM_PATTERN_INTERIOR_INTERSECTS == "T********" +end + +@testset "RelateMatrixPredicate" begin + p = GO.RelateMatrixPredicate() + @test GO.predicate_name(p) == "relateMatrix" + @test GO.require_interaction(typeof(p)) == false + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A) + @test !GO.is_known(p) # never determined early + GO.update_dim!(p, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_A) + @test !GO.is_known(p) + GO.finish!(p) + @test !isnothing(GO.result_im(p)) + @test GO.result_im(p)[GO.LOC_INTERIOR, GO.LOC_INTERIOR] == GO.DIM_A + @test GO.result_im(p)[GO.LOC_INTERIOR, GO.LOC_EXTERIOR] == GO.DIM_A + # E/E is preset to dim 2 by the IMPredicate constructor + @test GO.result_im(p)[GO.LOC_EXTERIOR, GO.LOC_EXTERIOR] == GO.DIM_A +end diff --git a/test/methods/relateng/relate_geometry.jl b/test/methods/relateng/relate_geometry.jl new file mode 100644 index 0000000000..2c01149303 --- /dev/null +++ b/test/methods/relateng/relate_geometry.jl @@ -0,0 +1,373 @@ +# Tests for the RelateNG input facade (relate_geometry.jl). The +# "RelateGeometry" testset ports JTS RelateGeometryTest.java in full. JTS has +# no RelateSegmentStringTest, so the "RelateSegmentString" testset is +# hand-written against the Java RelateSegmentString.java semantics. + +using Test +import GeometryOps as GO +import GeometryOps: Planar, Spherical, True +import GeoInterface as GI +import LibGEOS as LG # only for POLYGON EMPTY — GI wrappers cannot be empty +import Extents + +relate_geom(geom) = GO.RelateGeometry(Planar(), geom; exact = True()) + +# Port of RelateGeometryTest.checkDimension. +function check_dimension(geom, expected_dim, expected_dim_real) + rgeom = relate_geom(geom) + @test GO.get_dimension(rgeom) == expected_dim + @test GO.get_dimension_real(rgeom) == expected_dim_real +end + +@testset "RelateGeometry" begin + # testUniquePoints: MULTIPOINT ((0 0), (5 5), (5 0), (0 0)) + @testset "unique points" begin + geom = GI.MultiPoint([(0.0, 0.0), (5.0, 5.0), (5.0, 0.0), (0.0, 0.0)]) + rgeom = relate_geom(geom) + pts = GO.get_unique_points(rgeom) + @test length(pts) == 3 # "Unique pts size" + end + + # testBoundary: MULTILINESTRING ((0 0, 9 9), (9 9, 5 1)) + @testset "boundary" begin + geom = GI.MultiLineString([ + GI.LineString([(0.0, 0.0), (9.0, 9.0)]), + GI.LineString([(9.0, 9.0), (5.0, 1.0)]), + ]) + rgeom = relate_geom(geom) + @test GO.has_boundary(rgeom) # "hasBoundary" + end + + # testHasDimension: + # GEOMETRYCOLLECTION (POLYGON ((1 9, 5 9, 5 5, 1 5, 1 9)), + # LINESTRING (1 1, 5 4), POINT (6 5)) + @testset "has dimension" begin + geom = GI.GeometryCollection([ + GI.Polygon([[(1.0, 9.0), (5.0, 9.0), (5.0, 5.0), (1.0, 5.0), (1.0, 9.0)]]), + GI.LineString([(1.0, 1.0), (5.0, 4.0)]), + GI.Point(6.0, 5.0), + ]) + rgeom = relate_geom(geom) + @test GO.has_dimension(rgeom, 0) # "hasDimension 0" + @test GO.has_dimension(rgeom, 1) # "hasDimension 1" + @test GO.has_dimension(rgeom, 2) # "hasDimension 2" + end + + # testDimension + @testset "dimension" begin + # POINT (0 0) + check_dimension(GI.Point(0.0, 0.0), 0, 0) + # LINESTRING (0 0, 0 0) — zero-length line is effectively a point + check_dimension(GI.LineString([(0.0, 0.0), (0.0, 0.0)]), 1, 0) + # LINESTRING (0 0, 9 9) + check_dimension(GI.LineString([(0.0, 0.0), (9.0, 9.0)]), 1, 1) + # LINESTRING (0 0, 0 0, 9 9) + check_dimension(GI.LineString([(0.0, 0.0), (0.0, 0.0), (9.0, 9.0)]), 1, 1) + # POLYGON ((1 9, 5 9, 5 5, 1 5, 1 9)) + check_dimension( + GI.Polygon([[(1.0, 9.0), (5.0, 9.0), (5.0, 5.0), (1.0, 5.0), (1.0, 9.0)]]), + 2, 2) + # GEOMETRYCOLLECTION (POLYGON ((1 9, 5 9, 5 5, 1 5, 1 9)), + # LINESTRING (1 1, 5 4), POINT (6 5)) + check_dimension(GI.GeometryCollection([ + GI.Polygon([[(1.0, 9.0), (5.0, 9.0), (5.0, 5.0), (1.0, 5.0), (1.0, 9.0)]]), + GI.LineString([(1.0, 1.0), (5.0, 4.0)]), + GI.Point(6.0, 5.0), + ]), 2, 2) + # GEOMETRYCOLLECTION (POLYGON EMPTY, LINESTRING (1 1, 5 4), POINT (6 5)) + # — the empty polygon still counts for getDimension (Java semantics), + # but not for the real (non-empty) dimension. + check_dimension(GI.GeometryCollection([ + LG.readgeom("POLYGON EMPTY"), + GI.LineString([(1.0, 1.0), (5.0, 4.0)]), + GI.Point(6.0, 5.0), + ]), 2, 1) + end +end + +# The remaining testsets have no JTS JUnit counterpart; each case is +# hand-verified against RelateGeometry.java / RelateSegmentString.java. + +@testset "RelateGeometry predicates" begin + poly = GI.Polygon([[(1.0, 9.0), (5.0, 9.0), (5.0, 5.0), (1.0, 5.0), (1.0, 9.0)]]) + line = GI.LineString([(1.0, 1.0), (5.0, 4.0)]) + pt = GI.Point(6.0, 5.0) + + @testset "is_polygonal" begin + @test GO.is_polygonal(relate_geom(poly)) + @test GO.is_polygonal(relate_geom(GI.MultiPolygon([poly]))) + @test !GO.is_polygonal(relate_geom(GI.GeometryCollection([poly]))) + @test !GO.is_polygonal(relate_geom(line)) + end + + @testset "has_edges / has_area_and_line" begin + @test GO.has_edges(relate_geom(poly)) + @test GO.has_edges(relate_geom(line)) + @test !GO.has_edges(relate_geom(pt)) + @test GO.has_area_and_line(relate_geom(GI.GeometryCollection([poly, line]))) + @test !GO.has_area_and_line(relate_geom(poly)) + end + + @testset "is_self_noding_required" begin + # points and polygonal geometries never require self-noding + @test !GO.is_self_noding_required(relate_geom(pt)) + @test !GO.is_self_noding_required(relate_geom(GI.MultiPoint([(0.0, 0.0), (1.0, 1.0)]))) + @test !GO.is_self_noding_required(relate_geom(poly)) + @test !GO.is_self_noding_required(relate_geom(GI.MultiPolygon([poly]))) + # lines may self-cross + @test GO.is_self_noding_required(relate_geom(line)) + # a GC with a single polygon does not need noding + @test !GO.is_self_noding_required(relate_geom(GI.GeometryCollection([poly]))) + # GCs with only points do not need noding + @test !GO.is_self_noding_required(relate_geom( + GI.GeometryCollection([pt, GI.Point(7.0, 5.0)]))) + # mixed-dimension GCs may have overlapping elements + @test GO.is_self_noding_required(relate_geom(GI.GeometryCollection([poly, line]))) + end + + @testset "get_effective_points" begin + # Points covered by a higher-dimension element are not effective. + gc = GI.GeometryCollection([ + GI.Point(5.0, 5.0), # inside the polygon — covered + GI.Point(20.0, 20.0), # outside — effective + GI.Polygon([[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]]), + ]) + rgeom = relate_geom(gc) + eff = GO.get_effective_points(rgeom) + @test length(eff) == 1 + @test GO._tuple_point(only(eff)) == (20.0, 20.0) + # For a P-dimension geometry all points are returned unfiltered. + mp = GI.MultiPoint([(0.0, 0.0), (5.0, 5.0)]) + @test length(GO.get_effective_points(relate_geom(mp))) == 2 + end +end + +@testset "RelateSegmentString" begin + # POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 2 4, 4 4, 4 2, 2 2)) + # shell is CCW (must be flipped to CW), hole is CW (must be flipped to CCW) + poly = GI.Polygon([ + [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)], + [(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0)], + ]) + + @testset "polygon ring extraction" begin + rgeom = relate_geom(poly) + sss = GO.extract_segment_strings(rgeom, GO.GEOM_A, nothing) + @test length(sss) == 2 + shell, hole = sss + @test shell.is_a && hole.is_a + @test shell.dim == GO.DIM_A && hole.dim == GO.DIM_A + @test shell.id == 1 && hole.id == 1 + @test shell.ring_id == 0 && hole.ring_id == 1 + # the parent is the RelateGeometry's held geometry — the extent-cached + # wrapper of the input, not the input object itself + @test shell.parent_polygonal === rgeom.geom && hole.parent_polygonal === rgeom.geom + @test GI.trait(shell.parent_polygonal) isa GI.PolygonTrait + @test shell.input_geom === rgeom + # shell reoriented CW, hole reoriented CCW + @test shell.pts[1] == (0.0, 0.0) && shell.pts[2] == (0.0, 10.0) + @test hole.pts[1] == (2.0, 2.0) && hole.pts[2] == (4.0, 2.0) + @test GO.is_closed(shell) && GO.is_closed(hole) + end + + @testset "multipolygon parent and element ids" begin + polys = [ + GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]]), + GI.Polygon([[(5.0, 5.0), (6.0, 5.0), (6.0, 6.0), (5.0, 5.0)]]), + ] + mp = GI.MultiPolygon(polys) + rgeom = relate_geom(mp) + sss = GO.extract_segment_strings(rgeom, GO.GEOM_B, nothing) + @test length(sss) == 2 + @test !sss[1].is_a && !sss[2].is_a + @test sss[1].id == 1 && sss[2].id == 2 + # the parent is the MultiPolygon of the RelateGeometry's held + # (extent-cached wrapper) tree, shared by both elements' rings + @test sss[1].parent_polygonal === rgeom.geom && sss[2].parent_polygonal === rgeom.geom + @test GI.trait(sss[1].parent_polygonal) isa GI.MultiPolygonTrait + end + + @testset "extent filter" begin + polys = [ + GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]]), + GI.Polygon([[(50.0, 50.0), (60.0, 50.0), (60.0, 60.0), (50.0, 50.0)]]), + ] + mp = GI.MultiPolygon(polys) + env = Extents.Extent(X = (0.0, 10.0), Y = (0.0, 10.0)) + sss = GO.extract_segment_strings(relate_geom(mp), GO.GEOM_A, env) + @test length(sss) == 1 + @test sss[1].pts[1] == (0.0, 0.0) + # ring-level filtering: shell intersects the extent but the hole does not + poly_far_hole = GI.Polygon([ + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)], + [(90.0, 90.0), (90.0, 95.0), (95.0, 95.0), (95.0, 90.0), (90.0, 90.0)], + ]) + sss = GO.extract_segment_strings(relate_geom(poly_far_hole), GO.GEOM_A, env) + @test length(sss) == 1 + @test sss[1].ring_id == 0 + end + + @testset "line extraction removes repeated points" begin + line = GI.LineString([(0.0, 0.0), (1.0, 1.0), (1.0, 1.0), (2.0, 2.0)]) + sss = GO.extract_segment_strings(relate_geom(line), GO.GEOM_A, nothing) + @test length(sss) == 1 + ss = only(sss) + @test ss.dim == GO.DIM_L + @test ss.ring_id == -1 + @test ss.parent_polygonal === nothing + @test ss.pts == [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)] + @test !GO.is_closed(ss) + end + + # Regression (found by cross-validation, Task 24): geometries backed by + # StaticArrays — e.g. `GO.extent_to_polygon` output, whose ring wraps an + # SVector — must extract to plain `Vector` point lists; a typed + # comprehension over `GI.getpoint` collected them to a `SizedVector`, + # which `_orient_ring` rejected. + @testset "static-array-backed ring extraction" begin + poly = GO.extent_to_polygon(Extents.Extent(X = (0.0, 1.0), Y = (0.0, 1.0))) + sss = GO.extract_segment_strings(relate_geom(poly), GO.GEOM_A, nothing) + @test length(sss) == 1 + @test only(sss).pts isa Vector{Tuple{Float64, Float64}} + @test GO.relate_predicate(GO.RelateNG(), GO.pred_equalstopo(), poly, poly) + end + + # CW shell ring: (0 0, 0 10, 10 10, 10 0, 0 0) after reorientation + shell = only(GO.extract_segment_strings( + relate_geom(GI.Polygon([ + [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)], + ])), GO.GEOM_A, nothing)) + open_line = only(GO.extract_segment_strings( + relate_geom(GI.LineString([(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)])), + GO.GEOM_A, nothing)) + + @testset "prev/next vertex with ring wraparound" begin + @test shell.pts == [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0), (0.0, 0.0)] + # node at ring closure vertex: prev wraps to last distinct vertex + @test GO.prev_vertex(shell, 1, (0.0, 0.0)) == (10.0, 0.0) + @test GO.next_vertex(shell, 4, (0.0, 0.0)) == (0.0, 10.0) + # node interior to a segment: prev/next are the segment endpoints + @test GO.prev_vertex(shell, 1, (0.0, 5.0)) == (0.0, 0.0) + @test GO.next_vertex(shell, 1, (0.0, 5.0)) == (0.0, 10.0) + # node at a non-closure vertex + @test GO.prev_vertex(shell, 2, (0.0, 10.0)) == (0.0, 0.0) + @test GO.next_vertex(shell, 1, (0.0, 10.0)) == (10.0, 10.0) + # open line endpoints have no prev/next beyond the ends + @test GO.prev_vertex(open_line, 1, (0.0, 0.0)) === nothing + @test GO.next_vertex(open_line, 2, (2.0, 2.0)) === nothing + end + + @testset "create_node_section" begin + # vertex node at the ring closure point + ns = GO.create_node_section(shell, 1, GO.vertex_node((0.0, 0.0))) + @test ns.is_a + @test ns.dim == GO.DIM_A + @test ns.id == 1 && ns.ring_id == 0 + @test ns.is_node_at_vertex + @test ns.node == GO.vertex_node((0.0, 0.0)) + @test GO.get_vertex(ns, 0) == (10.0, 0.0) + @test GO.get_vertex(ns, 1) == (0.0, 10.0) + # proper crossing node: section vertices are the segment endpoints + node = GO.crossing_node((0.0, 0.0), (0.0, 10.0), (-1.0, 5.0), (1.0, 5.0)) + ns = GO.create_node_section(shell, 1, node) + @test !ns.is_node_at_vertex + @test ns.node == node + @test GO.get_vertex(ns, 0) == (0.0, 0.0) + @test GO.get_vertex(ns, 1) == (0.0, 10.0) + # vertex node at the end of an open line: no next vertex + ns = GO.create_node_section(open_line, 2, GO.vertex_node((2.0, 2.0))) + @test ns.is_node_at_vertex + @test ns.dim == GO.DIM_L && ns.ring_id == -1 + @test GO.get_vertex(ns, 0) == (1.0, 1.0) + @test GO.get_vertex(ns, 1) === nothing + end + + @testset "is_containing_segment" begin + # at segment start vertex - always contained + @test GO.is_containing_segment(shell, 1, (0.0, 0.0)) + # in segment interior - always contained + @test GO.is_containing_segment(shell, 1, (0.0, 5.0)) + # at segment end vertex - assigned to the next segment + @test !GO.is_containing_segment(shell, 1, (0.0, 10.0)) + # closed ring: final segment endpoint belongs to the first segment + @test !GO.is_containing_segment(shell, 4, (0.0, 0.0)) + # open line: final segment contains its endpoint + @test GO.is_containing_segment(open_line, 2, (2.0, 2.0)) + @test !GO.is_containing_segment(open_line, 1, (1.0, 1.0)) + end +end + +@testset "GC extent cache includes point members" begin + # A GC whose point member lies far outside its polygon member. + # _union_stored_extents must include the point's coordinates in the + # cached wrapper extent (audit 2026-06-11: GI.extent(c, true) passed + # `fallback` positionally and silently returned `nothing`). + gc = GI.GeometryCollection([ + GI.Point(10.0, 10.0), + GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]]), + ]) + cached = GO._relate_cache_extents(GO.Planar(), gc) + ext = GI.extent(cached) + @test ext.X[2] == 10.0 + @test ext.Y[2] == 10.0 +end + +@testset "trait classification characterization" begin + mp = GI.MultiPoint([(0.0, 0.0), (1.0, 1.0)]) + ml = GI.MultiLineString([[(0.0, 0.0), (1.0, 0.0)], [(0.0, 1.0), (1.0, 1.0)]]) + mpoly = GI.MultiPolygon([[[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)]]]) + zline = GI.LineString([(0.0, 0.0), (0.0, 0.0)]) # zero-length + gc = GI.GeometryCollection([GI.Point(5.0, 5.0), mpoly]) + empty_gc = GI.GeometryCollection([LG.readgeom("LINESTRING EMPTY")]) # GI wrappers cannot be empty + + #-- _geom_dimension: Multi* must classify by content, not as GC + @test GO._geom_dimension(mp) == GO.DIM_P + @test GO._geom_dimension(ml) == GO.DIM_L + @test GO._geom_dimension(mpoly) == GO.DIM_A + @test GO._geom_dimension(gc) == GO.DIM_A + + #-- _relate_is_empty: recursive emptiness through collections + @test GO._relate_is_empty(empty_gc) + @test !GO._relate_is_empty(gc) + + #-- _is_zero_length: MultiLineString takes the collection branch + @test GO._is_zero_length(zline) + @test !GO._is_zero_length(ml) + + #-- _relate_extent: union over GC elements including the bare point + @test GO._relate_extent(GO.Planar(), gc).X == (0.0, 5.0) + + #-- _analyze_dimensions via the constructor: mixed GC + rg = GO.RelateGeometry(GO.Planar(), gc; exact = GO.True()) + @test GO.get_dimension(rg) == GO.DIM_A + @test rg.has_points && rg.has_areas && !rg.has_lines + + #-- _add_component_coordinates!: point coords + first line coords + pts = Set{Tuple{Float64, Float64}}() + GO._add_component_coordinates!(pts, GO.Planar(), GI.GeometryCollection([mp, ml])) + @test (0.0, 0.0) in pts + + #-- _extract_point_elements!: only the Point element of the GC + lst = Any[] + GO._extract_point_elements!(lst, gc) + @test length(lst) == 1 + + #-- _extract_elements!: classification into points/lines/polygons + points = Set{Tuple{Float64, Float64}}(); lines = Any[]; polygons = Any[] + GO._extract_elements!(GO.Planar(), points, lines, polygons, + GI.GeometryCollection([GI.Point(0.0, 0.0), ml, mpoly])) + @test length(points) == 1 && length(lines) == 2 && length(polygons) == 1 +end + +# Task 13: the manifold-derived point type flows through ingest, so a +# spherical `RelateGeometry` stores 3D (xyz) interaction bounds and converts +# lon/lat vertices to the spherical kernel point type. +@testset "Spherical RelateGeometry ingests xyz" begin + ring = GI.LinearRing([(0., 0.), (10., 0.), (10., 10.), (0., 10.), (0., 0.)]) + rg = GO.RelateGeometry(Spherical(), GI.Polygon([ring]); exact = True()) + @test rg.extent isa Extents.Extent + @test hasproperty(rg.extent, :Z) # 3D interaction bounds + #-- the unique-point / segment-string point type is the spherical kernel point + @test GO._kernel_point_type(Spherical()) == GO.UnitSpherical.UnitSphericalPoint{Float64} + @test GO._to_kernel_point(Spherical(), (0., 0.)) isa GO.UnitSpherical.UnitSphericalPoint{Float64} +end diff --git a/test/methods/relateng/relate_ng.jl b/test/methods/relateng/relate_ng.jl new file mode 100644 index 0000000000..c7cf140bef --- /dev/null +++ b/test/methods/relateng/relate_ng.jl @@ -0,0 +1,1004 @@ +# Port of JTS RelateNGTest.java (with the helper shape of +# RelateNGTestCase.java). Every test method is ported, in the same order as +# the Java file; the Java's commented-out checks are kept commented for +# parity. +# +# Prepared-mode checks (`check_prepared`/`check_prepared_matches`) follow +# RelateNGTestCase.checkPrepared/checkPreparedMatches: every result through +# the `PreparedRelate` path must equal the unprepared result. In addition +# (GO-side, Task 22) the full-matrix fixture pairs are recorded as they run +# and a wholesale prepared-vs-unprepared loop over a representative sample +# of them runs at the end, plus a cache-reuse smoke test. + +using Test +import GeometryOps as GO +import GeoInterface as GI + +include(joinpath(@__DIR__, "wkt_util.jl")) + +const RUN_PREPARED = true + +# ========================================================================= +# RelateNGTestCase.java helpers +# ========================================================================= + +# Every full-matrix fixture pair is recorded as it runs; the wholesale +# prepared-mode loop at the end of the file samples from this list. +const PREPARED_FIXTURES = Tuple{String, String}[] + +function check_relate(awkt, bwkt, expected_im::String) + push!(PREPARED_FIXTURES, (awkt, bwkt)) + a, b = from_wkt(awkt), from_wkt(bwkt) + @test string(GO.relate(GO.RelateNG(), a, b)) == expected_im +end + +function check_relate_matches(awkt, bwkt, pattern::String, expected::Bool) + check_predicate(() -> GO.pred_matches(pattern), awkt, bwkt, expected) +end + +function check_predicate(pred_factory, awkt, bwkt, expected::Bool) + a, b = from_wkt(awkt), from_wkt(bwkt) + @test GO.relate_predicate(GO.RelateNG(), pred_factory(), a, b) == expected +end + +function check_intersects_disjoint(wkta, wktb, expected::Bool) + check_predicate(GO.pred_intersects, wkta, wktb, expected) + check_predicate(GO.pred_intersects, wktb, wkta, expected) + check_predicate(GO.pred_disjoint, wkta, wktb, !expected) + check_predicate(GO.pred_disjoint, wktb, wkta, !expected) +end + +function check_contains_within(wkta, wktb, expected::Bool) + check_predicate(GO.pred_contains, wkta, wktb, expected) + check_predicate(GO.pred_within, wktb, wkta, expected) +end + +function check_covers_coveredby(wkta, wktb, expected::Bool) + check_predicate(GO.pred_covers, wkta, wktb, expected) + check_predicate(GO.pred_coveredby, wktb, wkta, expected) +end + +function check_crosses(wkta, wktb, expected::Bool) + check_predicate(GO.pred_crosses, wkta, wktb, expected) + check_predicate(GO.pred_crosses, wktb, wkta, expected) +end + +function check_overlaps(wkta, wktb, expected::Bool) + check_predicate(GO.pred_overlaps, wkta, wktb, expected) + check_predicate(GO.pred_overlaps, wktb, wkta, expected) +end + +function check_touches(wkta, wktb, expected::Bool) + check_predicate(GO.pred_touches, wkta, wktb, expected) + check_predicate(GO.pred_touches, wktb, wkta, expected) +end + +function check_equals(wkta, wktb, expected::Bool) + check_predicate(GO.pred_equalstopo, wkta, wktb, expected) + check_predicate(GO.pred_equalstopo, wktb, wkta, expected) +end + +# The named predicates checked by RelateNGTestCase.checkPrepared, in the +# same order as the Java method. +const PREPARED_PREDICATES = [ + ("equalsTopo", GO.pred_equalstopo), + ("intersects", GO.pred_intersects), + ("disjoint", GO.pred_disjoint), + ("covers", GO.pred_covers), + ("coveredBy", GO.pred_coveredby), + ("within", GO.pred_within), + ("contains", GO.pred_contains), + ("crosses", GO.pred_crosses), + ("touches", GO.pred_touches), +] + +function check_prepared(wkta, wktb) + if !RUN_PREPARED + @test_skip "prepared mode (Task 22)" + return + end + a, b = from_wkt(wkta), from_wkt(wktb) + prep_a = GO.prepare(GO.RelateNG(), a) + for (name, pred_factory) in PREPARED_PREDICATES + @test GO.relate_predicate(prep_a, pred_factory(), b) == + GO.relate_predicate(GO.RelateNG(), pred_factory(), a, b) + end + @test string(GO.relate(prep_a, b)) == string(GO.relate(GO.RelateNG(), a, b)) +end + +function check_prepared_matches(wkta, wktb, pattern::String) + if !RUN_PREPARED + @test_skip "prepared mode (Task 22)" + return + end + a, b = from_wkt(wkta), from_wkt(wktb) + prep_a = GO.prepare(GO.RelateNG(), a) + @test GO.relate(prep_a, b, pattern) == GO.relate(GO.RelateNG(), a, b, pattern) +end + +# The empty-geometry WKT list (RelateNGTest.java `empties`), hoisted above +# the testset wrapper (`const` is not allowed inside a testset block). +const EMPTIES = [ + "POINT EMPTY", + "LINESTRING EMPTY", + "POLYGON EMPTY", + "MULTIPOINT EMPTY", + "MULTILINESTRING EMPTY", + "MULTIPOLYGON EMPTY", + "GEOMETRYCOLLECTION EMPTY", +] + +# ========================================================================= +# RelateNGTest.java test methods +# ========================================================================= + +@testset "RelateNGTest" begin + +@testset "testPointsDisjoint" begin + a = "POINT (0 0)" + b = "POINT (1 1)" + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) + check_equals(a, b, false) + check_relate(a, b, "FF0FFF0F2") +end + +# ======= P/P ============ + +@testset "testPointsContained" begin + a = "MULTIPOINT (0 0, 1 1, 2 2)" + b = "MULTIPOINT (1 1, 2 2)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_equals(a, b, false) + check_relate(a, b, "0F0FFFFF2") +end + +@testset "testPointsEqual" begin + a = "MULTIPOINT (0 0, 1 1, 2 2)" + b = "MULTIPOINT (0 0, 1 1, 2 2)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_equals(a, b, true) +end + +@testset "testValidateRelatePP_13" begin + a = "MULTIPOINT ((80 70), (140 120), (20 20), (200 170))" + b = "MULTIPOINT ((80 70), (140 120), (80 170), (200 80))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_contains_within(b, a, false) + check_covers_coveredby(a, b, false) + check_overlaps(a, b, true) + check_touches(a, b, false) +end + +# ======= L/P ============ + +@testset "testLinePointContains" begin + a = "LINESTRING (0 0, 1 1, 2 2)" + b = "MULTIPOINT (0 0, 1 1, 2 2)" + check_relate(a, b, "0F10FFFF2") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_contains_within(b, a, false) + check_covers_coveredby(a, b, true) + check_covers_coveredby(b, a, false) +end + +@testset "testLinePointOverlaps" begin + a = "LINESTRING (0 0, 1 1)" + b = "MULTIPOINT (0 0, 1 1, 2 2)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_contains_within(b, a, false) + check_covers_coveredby(a, b, false) + check_covers_coveredby(b, a, false) +end + +@testset "testZeroLengthLinePoint" begin + a = "LINESTRING (0 0, 0 0)" + b = "POINT (0 0)" + check_relate(a, b, "0FFFFFFF2") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_contains_within(b, a, true) + check_covers_coveredby(a, b, true) + check_covers_coveredby(b, a, true) + check_equals(a, b, true) +end + +@testset "testZeroLengthLineLine" begin + a = "LINESTRING (10 10, 10 10, 10 10)" + b = "LINESTRING (10 10, 10 10)" + check_relate(a, b, "0FFFFFFF2") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_contains_within(b, a, true) + check_covers_coveredby(a, b, true) + check_covers_coveredby(b, a, true) + check_equals(a, b, true) +end + +# tests bug involving checking for non-zero-length lines +@testset "testNonZeroLengthLinePoint" begin + a = "LINESTRING (0 0, 0 0, 9 9)" + b = "POINT (1 1)" + check_relate(a, b, "0F1FF0FF2") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_contains_within(b, a, false) + check_covers_coveredby(a, b, true) + check_covers_coveredby(b, a, false) + check_equals(a, b, false) +end + +@testset "testLinePointIntAndExt" begin + a = "MULTIPOINT((60 60), (100 100))" + b = "LINESTRING(40 40, 80 80)" + check_relate(a, b, "0F0FFF102") +end + +# ======= L/L ============ + +@testset "testLinesCrossProper" begin + a = "LINESTRING (0 0, 9 9)" + b = "LINESTRING(0 9, 9 0)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) +end + +@testset "testLinesOverlap" begin + a = "LINESTRING (0 0, 5 5)" + b = "LINESTRING(3 3, 9 9)" + check_intersects_disjoint(a, b, true) + check_touches(a, b, false) + check_overlaps(a, b, true) +end + +@testset "testLinesCrossVertex" begin + a = "LINESTRING (0 0, 8 8)" + b = "LINESTRING(0 8, 4 4, 8 0)" + check_intersects_disjoint(a, b, true) +end + +@testset "testLinesTouchVertex" begin + a = "LINESTRING (0 0, 8 0)" + b = "LINESTRING(0 8, 4 0, 8 8)" + check_intersects_disjoint(a, b, true) +end + +@testset "testLinesDisjointByEnvelope" begin + a = "LINESTRING (0 0, 9 9)" + b = "LINESTRING(10 19, 19 10)" + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) +end + +@testset "testLinesDisjoint" begin + a = "LINESTRING (0 0, 9 9)" + b = "LINESTRING (4 2, 8 6)" + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) +end + +@testset "testLinesClosedEmpty" begin + a = "MULTILINESTRING ((0 0, 0 1), (0 1, 1 1, 1 0, 0 0))" + b = "LINESTRING EMPTY" + check_relate(a, b, "FF1FFFFF2") + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) +end + +@testset "testLinesRingTouchAtNode" begin + a = "LINESTRING (5 5, 1 8, 1 1, 5 5)" + b = "LINESTRING (5 5, 9 5)" + check_relate(a, b, "F01FFF102") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_touches(a, b, true) +end + +@testset "testLinesTouchAtBdy" begin + a = "LINESTRING (5 5, 1 8)" + b = "LINESTRING (5 5, 9 5)" + check_relate(a, b, "FF1F00102") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_touches(a, b, true) +end + +@testset "testLinesOverlapWithDisjointLine" begin + a = "LINESTRING (1 1, 9 9)" + b = "MULTILINESTRING ((2 2, 8 8), (6 2, 8 4))" + check_relate(a, b, "101FF0102") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_overlaps(a, b, true) +end + +@testset "testLinesDisjointOverlappingEnvelopes" begin + a = "LINESTRING (60 0, 20 80, 100 80, 80 120, 40 140)" + b = "LINESTRING (60 40, 140 40, 140 160, 0 160)" + check_relate(a, b, "FF1FF0102") + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) + check_touches(a, b, false) +end + +#= +Case from https://github.com/locationtech/jts/issues/270 +Strictly, the lines cross, since their interiors intersect +according to the Orientation predicate. +However, the computation of the intersection point is +non-robust, and reports it as being equal to the endpoint +POINT (-10 0.0000000000000012) +For consistency the relate algorithm uses the intersection node topology. +=# +@testset "testLinesCross_JTS270" begin + a = "LINESTRING (0 0, -10 0.0000000000000012)" + b = "LINESTRING (-9.999143275740073 -0.1308959557133398, -10 0.0000000000001054)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_crosses(a, b, false) + check_overlaps(a, b, false) + check_touches(a, b, true) +end + +@testset "testLinesContained_JTS396" begin + a = "LINESTRING (1 0, 0 2, 0 0, 2 2)" + b = "LINESTRING (0 0, 2 2)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_covers_coveredby(a, b, true) + check_crosses(a, b, false) + check_overlaps(a, b, false) + check_touches(a, b, false) +end + +#= +This case shows that lines must be self-noded, +so that node topology is constructed correctly +(at least for some predicates). +=# +@testset "testLinesContainedWithSelfIntersection" begin + a = "LINESTRING (2 0, 0 2, 0 0, 2 2)" + b = "LINESTRING (0 0, 2 2)" + #check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_covers_coveredby(a, b, true) + check_crosses(a, b, false) + check_overlaps(a, b, false) + check_touches(a, b, false) +end + +@testset "testLineContainedInRing" begin + a = "LINESTRING(60 60, 100 100, 140 60)" + b = "LINESTRING(100 100, 180 20, 20 20, 100 100)" + check_intersects_disjoint(a, b, true) + check_contains_within(b, a, true) + check_covers_coveredby(b, a, true) + check_crosses(a, b, false) + check_overlaps(a, b, false) + check_touches(a, b, false) +end + +# see https://github.com/libgeos/geos/issues/933 +@testset "testLineLineProperIntersection" begin + a = "MULTILINESTRING ((0 0, 1 1), (0.5 0.5, 1 0.1, -1 0.1))" + b = "LINESTRING (0 0, 1 1)" + #check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_covers_coveredby(a, b, true) + check_crosses(a, b, false) + check_overlaps(a, b, false) + check_touches(a, b, false) +end + +@testset "testLineSelfIntersectionCollinear" begin + a = "LINESTRING (9 6, 1 6, 1 0, 5 6, 9 6)" + b = "LINESTRING (9 9, 3 1)" + check_relate(a, b, "0F1FFF102") +end + +# ======= A/P ============ + +@testset "testPolygonPointInside" begin + a = "POLYGON ((0 10, 10 10, 10 0, 0 0, 0 10))" + b = "POINT (1 1)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) +end + +@testset "testPolygonPointOutside" begin + a = "POLYGON ((10 0, 0 0, 0 10, 10 0))" + b = "POINT (8 8)" + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) +end + +@testset "testPolygonPointInBoundary" begin + a = "POLYGON ((10 0, 0 0, 0 10, 10 0))" + b = "POINT (1 0)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, true) +end + +@testset "testAreaPointInExterior" begin + a = "POLYGON ((1 5, 5 5, 5 1, 1 1, 1 5))" + b = "POINT (7 7)" + check_relate(a, b, "FF2FF10F2") + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_touches(a, b, false) + check_overlaps(a, b, false) +end + +# ======= A/L ============ + +@testset "testAreaLineContainedAtLineVertex" begin + a = "POLYGON ((1 5, 5 5, 5 1, 1 1, 1 5))" + b = "LINESTRING (2 3, 3 5, 4 3)" + check_intersects_disjoint(a, b, true) + #check_contains_within(a, b, true) + #check_covers_coveredby(a, b, true) + check_touches(a, b, false) + check_overlaps(a, b, false) +end + +@testset "testAreaLineTouchAtLineVertex" begin + a = "POLYGON ((1 5, 5 5, 5 1, 1 1, 1 5))" + b = "LINESTRING (1 8, 3 5, 5 8)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_touches(a, b, true) + check_overlaps(a, b, false) +end + +@testset "testPolygonLineInside" begin + a = "POLYGON ((0 10, 10 10, 10 0, 0 0, 0 10))" + b = "LINESTRING (1 8, 3 5, 5 8)" + check_relate(a, b, "102FF1FF2") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) +end + +@testset "testPolygonLineOutside" begin + a = "POLYGON ((10 0, 0 0, 0 10, 10 0))" + b = "LINESTRING (4 8, 9 3)" + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) +end + +@testset "testPolygonLineInBoundary" begin + a = "POLYGON ((10 0, 0 0, 0 10, 10 0))" + b = "LINESTRING (1 0, 9 0)" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, true) + check_touches(a, b, true) + check_overlaps(a, b, false) +end + +@testset "testPolygonLineCrossingContained" begin + a = "MULTIPOLYGON (((20 80, 180 80, 100 0, 20 80)), ((20 160, 180 160, 100 80, 20 160)))" + b = "LINESTRING (100 140, 100 40)" + check_relate(a, b, "1020F1FF2") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_covers_coveredby(a, b, true) + check_touches(a, b, false) + check_overlaps(a, b, false) +end + +@testset "testValidateRelateLA_220" begin + a = "LINESTRING (90 210, 210 90)" + b = "POLYGON ((150 150, 410 150, 280 20, 20 20, 150 150))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_touches(a, b, false) + check_overlaps(a, b, false) +end + +# See RelateLA.xml (line 585) +@testset "testLineCrossingPolygonAtShellHolePoint" begin + a = "LINESTRING (60 160, 150 70)" + b = "POLYGON ((190 190, 360 20, 20 20, 190 190), (110 110, 250 100, 140 30, 110 110))" + check_relate(a, b, "F01FF0212") + check_touches(a, b, true) + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_touches(a, b, true) + check_overlaps(a, b, false) +end + +@testset "testLineCrossingPolygonAtNonVertex" begin + a = "LINESTRING (20 60, 150 60)" + b = "POLYGON ((150 150, 410 150, 280 20, 20 20, 150 150))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_touches(a, b, false) + check_overlaps(a, b, false) +end + +@testset "testPolygonLinesContainedCollinearEdge" begin + a = "POLYGON ((110 110, 200 20, 20 20, 110 110))" + b = "MULTILINESTRING ((110 110, 60 40, 70 20, 150 20, 170 40), (180 30, 40 30, 110 80))" + check_relate(a, b, "102101FF2") +end + +# ======= A/A ============ + +@testset "testPolygonsEdgeAdjacent" begin + a = "POLYGON ((1 3, 3 3, 3 1, 1 1, 1 3))" + b = "POLYGON ((5 3, 5 1, 3 1, 3 3, 5 3))" + #check_intersects_disjoint(a, b, true) + check_overlaps(a, b, false) + check_touches(a, b, true) + check_overlaps(a, b, false) +end + +@testset "testPolygonsEdgeAdjacent2" begin + a = "POLYGON ((1 3, 4 3, 3 0, 1 1, 1 3))" + b = "POLYGON ((5 3, 5 1, 3 0, 4 3, 5 3))" + #check_intersects_disjoint(a, b, true) + check_overlaps(a, b, false) + check_touches(a, b, true) + check_overlaps(a, b, false) +end + +@testset "testPolygonsNested" begin + a = "POLYGON ((1 9, 9 9, 9 1, 1 1, 1 9))" + b = "POLYGON ((2 8, 8 8, 8 2, 2 2, 2 8))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_covers_coveredby(a, b, true) + check_overlaps(a, b, false) + check_touches(a, b, false) +end + +@testset "testPolygonsOverlapProper" begin + a = "POLYGON ((1 1, 1 7, 7 7, 7 1, 1 1))" + b = "POLYGON ((2 8, 8 8, 8 2, 2 2, 2 8))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_overlaps(a, b, true) + check_touches(a, b, false) +end + +@testset "testPolygonsOverlapAtNodes" begin + a = "POLYGON ((1 5, 5 5, 5 1, 1 1, 1 5))" + b = "POLYGON ((7 3, 5 1, 3 3, 5 5, 7 3))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_overlaps(a, b, true) + check_touches(a, b, false) +end + +@testset "testPolygonsContainedAtNodes" begin + a = "POLYGON ((1 5, 5 5, 6 2, 1 1, 1 5))" + b = "POLYGON ((1 1, 5 5, 6 2, 1 1))" + #check_intersects_disjoint(a, b, true) + check_contains_within(a, b, true) + check_covers_coveredby(a, b, true) + check_overlaps(a, b, false) + check_touches(a, b, false) +end + +@testset "testPolygonsNestedWithHole" begin + a = "POLYGON ((40 60, 420 60, 420 320, 40 320, 40 60), (200 140, 160 220, 260 200, 200 140))" + b = "POLYGON ((80 100, 360 100, 360 280, 80 280, 80 100))" + #check_intersects_disjoint(true, a, b) + check_contains_within(a, b, false) + check_contains_within(b, a, false) + #check_covers_coveredby(false, a, b) + #check_overlaps(true, a, b) + check_predicate(GO.pred_contains, a, b, false) + #check_touches(false, a, b) +end + +@testset "testPolygonsOverlappingWithBoundaryInside" begin + a = "POLYGON ((100 60, 140 100, 100 140, 60 100, 100 60))" + b = "MULTIPOLYGON (((80 40, 120 40, 120 80, 80 80, 80 40)), ((120 80, 160 80, 160 120, 120 120, 120 80)), ((80 120, 120 120, 120 160, 80 160, 80 120)), ((40 80, 80 80, 80 120, 40 120, 40 80)))" + check_relate(a, b, "21210F212") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_contains_within(b, a, false) + check_covers_coveredby(a, b, false) + check_overlaps(a, b, true) + check_touches(a, b, false) +end + +@testset "testPolygonsOverlapVeryNarrow" begin + a = "POLYGON ((120 100, 120 200, 200 200, 200 100, 120 100))" + b = "POLYGON ((100 100, 100000 110, 100000 100, 100 100))" + check_relate(a, b, "212111212") + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_contains_within(b, a, false) + #check_covers_coveredby(false, a, b) + #check_overlaps(true, a, b) + #check_touches(false, a, b) +end + +@testset "testValidateRelateAA_86" begin + a = "POLYGON ((170 120, 300 120, 250 70, 120 70, 170 120))" + b = "POLYGON ((150 150, 410 150, 280 20, 20 20, 150 150), (170 120, 330 120, 260 50, 100 50, 170 120))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_overlaps(a, b, false) + check_predicate(GO.pred_within, a, b, false) + check_touches(a, b, true) +end + +@testset "testValidateRelateAA_97" begin + a = "POLYGON ((330 150, 200 110, 150 150, 280 190, 330 150))" + b = "MULTIPOLYGON (((140 110, 260 110, 170 20, 50 20, 140 110)), ((300 270, 420 270, 340 190, 220 190, 300 270)))" + check_intersects_disjoint(a, b, true) + check_contains_within(a, b, false) + check_covers_coveredby(a, b, false) + check_overlaps(a, b, false) + check_predicate(GO.pred_within, a, b, false) + check_touches(a, b, true) +end + +@testset "testAdjacentPolygons" begin + a = "POLYGON ((1 9, 6 9, 6 1, 1 1, 1 9))" + b = "POLYGON ((9 9, 9 4, 6 4, 6 9, 9 9))" + check_relate_matches(a, b, GO.IM_PATTERN_ADJACENT, true) +end + +@testset "testAdjacentPolygonsTouchingAtPoint" begin + a = "POLYGON ((1 9, 6 9, 6 1, 1 1, 1 9))" + b = "POLYGON ((9 9, 9 4, 6 4, 7 9, 9 9))" + check_relate_matches(a, b, GO.IM_PATTERN_ADJACENT, false) +end + +@testset "testAdjacentPolygonsOverlappping" begin + a = "POLYGON ((1 9, 6 9, 6 1, 1 1, 1 9))" + b = "POLYGON ((9 9, 9 4, 6 4, 5 9, 9 9))" + check_relate_matches(a, b, GO.IM_PATTERN_ADJACENT, false) +end + +@testset "testContainsProperlyPolygonContained" begin + a = "POLYGON ((1 9, 9 9, 9 1, 1 1, 1 9))" + b = "POLYGON ((2 8, 5 8, 5 5, 2 5, 2 8))" + check_relate_matches(a, b, GO.IM_PATTERN_CONTAINS_PROPERLY, true) +end + +@testset "testContainsProperlyPolygonTouching" begin + a = "POLYGON ((1 9, 9 9, 9 1, 1 1, 1 9))" + b = "POLYGON ((9 1, 5 1, 5 5, 9 5, 9 1))" + check_relate_matches(a, b, GO.IM_PATTERN_CONTAINS_PROPERLY, false) +end + +@testset "testContainsProperlyPolygonsOverlapping" begin + a = "GEOMETRYCOLLECTION (POLYGON ((1 9, 6 9, 6 4, 1 4, 1 9)), POLYGON ((2 4, 6 7, 9 1, 2 4)))" + b = "POLYGON ((5 5, 6 5, 6 4, 5 4, 5 5))" + check_relate_matches(a, b, GO.IM_PATTERN_CONTAINS_PROPERLY, true) +end + +# ================ Repeated Points ============= + +@testset "testRepeatedPointLL" begin + a = "LINESTRING(0 0, 5 5, 5 5, 5 5, 9 9)" + b = "LINESTRING(0 9, 5 5, 5 5, 5 5, 9 0)" + check_relate(a, b, "0F1FF0102") + check_intersects_disjoint(a, b, true) +end + +@testset "testRepeatedPointAA" begin + a = "POLYGON ((1 9, 9 7, 9 1, 1 3, 1 9))" + b = "POLYGON ((1 3, 1 3, 1 3, 3 7, 9 7, 9 7, 1 3))" + check_relate(a, b, "212F01FF2") +end + +# ================ EMPTY geometries ============= + +@testset "testEmptyEmpty" begin + for a in EMPTIES + for b in EMPTIES + check_relate(a, b, "FFFFFFFF2") + #-- empty geometries are all topologically equal + check_equals(a, b, true) + + check_intersects_disjoint(a, b, false) + check_contains_within(a, b, false) + end + end +end + +@testset "testEmptyNonEmpty" begin + non_empty_point = "POINT (1 1)" + non_empty_line = "LINESTRING (1 1, 2 2)" + non_empty_polygon = "POLYGON ((1 1, 1 2, 2 1, 1 1))" + + for empty in EMPTIES + check_relate(empty, non_empty_point, "FFFFFF0F2") + check_relate(non_empty_point, empty, "FF0FFFFF2") + + check_relate(empty, non_empty_line, "FFFFFF102") + check_relate(non_empty_line, empty, "FF1FF0FF2") + + check_relate(empty, non_empty_polygon, "FFFFFF212") + check_relate(non_empty_polygon, empty, "FF2FF1FF2") + + check_equals(empty, non_empty_point, false) + check_equals(empty, non_empty_line, false) + check_equals(empty, non_empty_polygon, false) + + check_intersects_disjoint(empty, non_empty_point, false) + check_intersects_disjoint(empty, non_empty_line, false) + check_intersects_disjoint(empty, non_empty_polygon, false) + + check_contains_within(empty, non_empty_point, false) + check_contains_within(empty, non_empty_line, false) + check_contains_within(empty, non_empty_polygon, false) + + check_contains_within(non_empty_point, empty, false) + check_contains_within(non_empty_line, empty, false) + check_contains_within(non_empty_polygon, empty, false) + end +end + +# ================ Prepared Relate ============= + +@testset "testPreparedAA" begin + a = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))" + b = "POLYGON((0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))" + check_prepared(a, b) +end + +@testset "testPreparedPA" begin + a = "POINT (5 5)" + b = "POLYGON ((1 9, 9 9, 9 1, 1 1, 1 9))" + check_prepared(a, b) + check_prepared(b, a) + + #-- see https://github.com/libgeos/geos/issues/1275 (not a bug, but a good test to have) + pattern = "T*****FF*" + pattern_trans = "T*F**F***" # IntersectionMatrix.transpose(pattern) + check_prepared_matches(a, b, pattern) + check_prepared_matches(b, a, pattern_trans) +end + +# === Prepared mode, GO-side additions (not in RelateNGTest.java) === + +# Wholesale prepared-vs-unprepared equality over the full-matrix fixture +# pairs recorded by `check_relate` above (they span P/L/A x P/L/A and the +# empty/GC cases). An evenly spaced sample capped at 40 pairs — plus every +# GeometryCollection pair, and one explicit mixed GC since RelateNGTest's +# only GC matrix fixtures are empty — keeps the added runtime modest +# (`check_prepared` is ~20 engine evaluations per pair). +@testset "testPreparedWholesale" begin + fixtures = unique(PREPARED_FIXTURES) + @test length(fixtures) >= 30 + idxs = unique(round.(Int, range(1, length(fixtures); length = min(40, length(fixtures))))) + sample = fixtures[idxs] + for fix in fixtures + is_gc = occursin("GEOMETRYCOLLECTION", fix[1]) || occursin("GEOMETRYCOLLECTION", fix[2]) + is_gc && !(fix in sample) && push!(sample, fix) + end + push!(sample, ( + "GEOMETRYCOLLECTION (POINT (1 1), LINESTRING (0 5, 5 5), POLYGON ((0 0, 0 3, 3 3, 3 0, 0 0)))", + "POLYGON ((2 2, 2 6, 6 6, 6 2, 2 2))", + )) + for (awkt, bwkt) in sample + check_prepared(awkt, bwkt) + end +end + +# Cache-reuse smoke test: one `PreparedRelate` evaluated against several +# different B geometries, each twice, exercising the prebuilt-tree path +# (large A), the below-threshold nested-loop path (small A), and the +# self-noding path that bypasses the cached edges (line A). +@testset "testPreparedCacheReuse" begin + #-- large A (64 segments >= threshold): the segment tree is prebuilt + n = 64 + coords = [(5 + 4 * cospi(2k / n), 5 + 4 * sinpi(2k / n)) for k in 0:(n - 1)] + push!(coords, coords[1]) + a = GI.Polygon([coords]) + prep = GO.prepare(GO.RelateNG(), a) + @test prep.edge_tree !== nothing + bs = [ + GI.Polygon([[(4.0, 4.0), (11.0, 4.0), (11.0, 11.0), (4.0, 11.0), (4.0, 4.0)]]), + GI.LineString([(-1.0, 5.0), (11.0, 5.0)]), + GI.Point((20.0, 20.0)), + ] + for b in bs + expected = string(GO.relate(GO.RelateNG(), a, b)) + @test string(GO.relate(prep, b)) == expected + #-- second evaluation against the same prepared instance + @test string(GO.relate(prep, b)) == expected + @test GO.relate_predicate(prep, GO.pred_intersects(), b) == + GO.relate_predicate(GO.RelateNG(), GO.pred_intersects(), a, b) + end + + #-- small A: below the threshold no tree is prebuilt (nested-loop reuse) + a2 = GI.Polygon([[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]]) + prep2 = GO.prepare(GO.RelateNG(), a2) + @test prep2.edge_tree === nothing + b21 = GI.Polygon([[(0.5, 0.5), (1.5, 0.5), (1.5, 1.5), (0.5, 1.5), (0.5, 0.5)]]) + b22 = GI.LineString([(0.5, -1.0), (0.5, 2.0)]) + @test string(GO.relate(prep2, b21)) == string(GO.relate(GO.RelateNG(), a2, b21)) + @test string(GO.relate(prep2, b22)) == string(GO.relate(GO.RelateNG(), a2, b22)) + + #-- self-crossing line A: matrix evaluation requires self-noding, which + #-- bypasses the cached edges (re-extracted per call, envelope-filtered) + a3 = GI.LineString([(0.0, 0.0), (5.0, 5.0), (5.0, 0.0), (0.0, 5.0)]) + prep3 = GO.prepare(GO.RelateNG(), a3) + b3 = GI.LineString([(0.0, 2.0), (6.0, 2.0)]) + expected3 = string(GO.relate(GO.RelateNG(), a3, b3)) + @test string(GO.relate(prep3, b3)) == expected3 + @test string(GO.relate(prep3, b3)) == expected3 +end + +end # @testset "RelateNGTest" + +# ========================================================================= +# Public API surface (Task 26): default-algorithm `relate`, exported names, +# and the named-predicate methods `GO.intersects(GO.RelateNG(), a, b)` etc. +# The named predicates are opt-in (design D4): the two-argument defaults +# dispatch to the old engines and are untouched here. +# ========================================================================= + +@testset "PublicAPI" begin + pa = GI.Polygon([[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)]]) + pb = GI.Polygon([[(2.0, 2.0), (6.0, 2.0), (6.0, 6.0), (2.0, 6.0), (2.0, 2.0)]]) + + @testset "exports" begin + for name in (:relate, :DE9IM, :RelateNG, :prepare, + :Mod2Boundary, :EndpointBoundary, + :MultivalentEndpointBoundary, :MonovalentEndpointBoundary) + @test name in names(GO) + end + end + + @testset "relate entry points" begin + @test GO.relate(pa, pb) isa GO.DE9IM + @test string(GO.relate(pa, pb)) == "212101212" + @test GO.relate(pa, pb, "T*F**FFF*") isa Bool + @test GO.relate(pa, pb) == GO.relate(GO.RelateNG(), pa, pb) + #-- prepared equivalents + prep = GO.prepare(GO.RelateNG(), pa) + @test GO.relate(prep, pb) == GO.relate(pa, pb) + @test GO.relate(prep, pb, "T********") == GO.relate(pa, pb, "T********") == true + end + + @testset "named predicates (opt-in RelateNG form)" begin + alg = GO.RelateNG() + far = GI.Polygon([[(10.0, 10.0), (12.0, 10.0), (12.0, 12.0), (10.0, 12.0), (10.0, 10.0)]]) + inner = GI.Polygon([[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]]) + touching = GI.Polygon([[(4.0, 0.0), (8.0, 0.0), (8.0, 4.0), (4.0, 4.0), (4.0, 0.0)]]) + + #-- each named predicate must agree with the (old-engine) default + for f in (GO.intersects, GO.disjoint, GO.contains, GO.within, + GO.covers, GO.coveredby, GO.touches, GO.equals) + for (x, y) in [(pa, pb), (pa, far), (pa, inner), (inner, pa), + (pa, touching), (pa, pa)] + @test f(alg, x, y) == f(x, y) + end + end + #-- `overlaps`: skip the edge-touching pair, where the old engine + #-- reports edge intersection (DE-9IM `overlaps` of touching + #-- polygons is false; the old GO method returns true there) + for (x, y) in [(pa, pb), (pa, far), (pa, inner), (pa, pa)] + @test GO.overlaps(alg, x, y) == GO.overlaps(x, y) + end + # LibGEOS agrees; old GO.overlaps wrongly returns true (known old-GO gap) + @test GO.overlaps(GO.RelateNG(), pa, touching) == false + #-- `crosses`: the old GO method only supports mixed-dimension + #-- (point/line/polygon) pairs + crossing_line = GI.LineString([(-1.0, 2.0), (5.0, 2.0)]) + miss_line = GI.LineString([(-2.0, -2.0), (-1.0, -2.0)]) + @test GO.crosses(alg, crossing_line, pa) == GO.crosses(crossing_line, pa) == true + @test GO.crosses(alg, miss_line, pa) == GO.crosses(miss_line, pa) == false + #-- swapped argument order (old GO supports poly/line too) + @test GO.crosses(alg, pa, crossing_line) == GO.crosses(pa, crossing_line) == true + @test GO.crosses(alg, pa, miss_line) == GO.crosses(pa, miss_line) == false + #-- poly/poly crosses is always false per DE-9IM dimension rules; + #-- RelateNG-only capability (old GO has no poly/poly `crosses` method) + @test GO.crosses(GO.RelateNG(), pa, pb) == false + + #-- spot values + @test GO.intersects(alg, pa, pb) + @test GO.disjoint(alg, pa, far) + @test GO.contains(alg, pa, inner) + @test GO.within(alg, inner, pa) + @test GO.covers(alg, pa, inner) + @test GO.coveredby(alg, inner, pa) + @test GO.touches(alg, pa, touching) + @test GO.overlaps(alg, pa, pb) + #-- `equals(::RelateNG, ...)` is *topological* equality + #-- (pred_equalstopo): a rotated ring is still equal + pa_rot = GI.Polygon([[(4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0), (4.0, 0.0)]]) + @test GO.equals(alg, pa, pa_rot) == GO.equals(pa, pa_rot) == true + @test !GO.equals(alg, pa, pb) + end +end + +# ========================================================================= +# `prepare` ring-crossing validation (GO-side; see the `prepare` docstring: +# Spherical validates by default, Planar is opt-in) +# ========================================================================= + +@testset "prepare ring-crossing validation" begin + # a self-crossing quadrilateral bowtie: edges 1 and 3 cross properly + # near (0, 0) in both the planar and the great-circle reading + bowtie = GI.Polygon([GI.LinearRing( + [(-10.0, -10.0), (10.0, 10.0), (10.0, -10.0), (-10.0, 10.0), (-10.0, -10.0)])]) + valid = GI.Polygon([GI.LinearRing( + [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)])]) + salg = GO.RelateNG(; manifold = GO.Spherical()) + palg = GO.RelateNG() + + @testset "spherical validates by default, naming the edge pair" begin + err = try + GO.prepare(salg, bowtie) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("edge 1 crosses edge 3", err.msg) + @test occursin("CrossingEdgeSplit", err.msg) + #-- opting out accepts the geometry as-is + @test GO.prepare(salg, bowtie; validate = false) isa GO.PreparedRelate + #-- a valid ring passes validation + @test GO.prepare(salg, valid) isa GO.PreparedRelate + end + + @testset "planar does not validate by default, and validates on opt-in" begin + @test GO.prepare(palg, bowtie) isa GO.PreparedRelate + @test_throws ArgumentError GO.prepare(palg, bowtie; validate = true) + @test GO.prepare(palg, valid; validate = true) isa GO.PreparedRelate + end + + @testset "cross-ring crossings of one element are caught" begin + #-- a hole poking through its shell: two proper shell x hole crossings + poked = GI.Polygon([ + GI.LinearRing([(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]), + GI.LinearRing([(5.0, 5.0), (15.0, 5.0), (15.0, 7.0), (5.0, 7.0), (5.0, 5.0)]), + ]) + err = try + GO.prepare(salg, poked) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("hole 1", err.msg) + #-- two separate polygons of a MultiPolygon may overlap: union + #-- semantics, not a per-element parity break — not flagged + overlapping = GI.MultiPolygon([valid, + GI.Polygon([GI.LinearRing( + [(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0), (5.0, 5.0)])])]) + @test GO.prepare(salg, overlapping) isa GO.PreparedRelate + end + + @testset "tree-indexed join above the accelerator threshold" begin + #-- 48 segments > threshold: the prepared edge tree exists and the + #-- validation join runs through it. Swapping two consecutive + #-- vertices of a convex ring creates exactly one proper crossing. + n = 48 + circ = [(10.0 + 8cosd(t), 45.0 + 5sind(t)) for t in range(0, 360; length = n + 1)] + ring_ok = GI.Polygon([GI.LinearRing(circ)]) + swapped = copy(circ) + swapped[20], swapped[21] = swapped[21], swapped[20] + ring_crossed = GI.Polygon([GI.LinearRing(swapped)]) + @test GO.prepare(salg, ring_ok) isa GO.PreparedRelate + @test_throws ArgumentError GO.prepare(salg, ring_crossed) + @test GO.prepare(salg, ring_crossed; validate = false) isa GO.PreparedRelate + @test_throws ArgumentError GO.prepare(palg, ring_crossed; validate = true) + end +end diff --git a/test/methods/relateng/runtests.jl b/test/methods/relateng/runtests.jl new file mode 100644 index 0000000000..ab5269128c --- /dev/null +++ b/test/methods/relateng/runtests.jl @@ -0,0 +1,22 @@ +using SafeTestsets + +@safetestset "DE9IM" begin include("de9im.jl") end +@safetestset "Predicates" begin include("predicates.jl") end +@safetestset "Kernel" begin include("kernel.jl") end +@safetestset "Kernel conformance" begin include("kernel_conformance.jl") end +@safetestset "Spherical kernel conformance" begin include("kernel_conformance_spherical.jl") end +@safetestset "S2 edge-crossing conformance" begin include("s2_crossings_conformance.jl") end +@safetestset "Point locator" begin include("point_locator.jl") end +@safetestset "Indexed point-in-area locator" begin include("indexed_point_in_area.jl") end +@safetestset "RelateGeometry" begin include("relate_geometry.jl") end +@safetestset "Node topology" begin include("node_topology.jl") end +@safetestset "TopologyComputer" begin include("topology_computer.jl") end +@safetestset "Edge intersector" begin include("edge_intersector.jl") end +@safetestset "XML harness" begin include("xml_harness.jl") end +@safetestset "RelateNG engine" begin include("relate_ng.jl") end +@safetestset "JTS XML suite" begin include("xml_suite.jl") end +@safetestset "Spherical relate end-to-end" begin include("spherical_end_to_end.jl") end +@safetestset "LibGEOS differential fuzz" begin include("fuzz.jl") end +@safetestset "Allocations and type stability" begin include("allocations.jl") end +# Further files appended here as tasks land: +# ... diff --git a/test/methods/relateng/s2_crossings_conformance.jl b/test/methods/relateng/s2_crossings_conformance.jl new file mode 100644 index 0000000000..fc8d51fe84 --- /dev/null +++ b/test/methods/relateng/s2_crossings_conformance.jl @@ -0,0 +1,189 @@ +# Spherical edge-crossing conformance, ported from Google S2. +# +# Source: s2geometry `src/s2/s2edge_crosser_test.cc` (`TEST(S2, Crossings)`, +# `CollinearEdgesThatDontTouch`, `CoincidentZeroLengthEdgesThatDontTouch`) and +# `src/s2/s2edge_crossings.h` (the `CrossingSign` contract). These are S2's +# hand-built numerically-extreme edge-crossing cases — several require exact +# arithmetic to resolve (floating-point underflow; determinants needing >2000 +# bits of precision) — and are the canonical battery for a robust spherical +# crossing predicate. +# +# Mapping S2 `CrossingSign(a,b,c,d)` onto GeometryOps `rk_classify_intersection` +# (validated empirically against the spherical kernel before this file landed): +# +# CrossingSign == +1 (interior crossing of both edges) <-> SS_PROPER +# CrossingSign == -1 (no crossing) <-> SS_DISJOINT +# CrossingSign == 0 (a vertex is shared) <-> SS_TOUCH / SS_COLLINEAR +# with the incidence +# flag(s) set +# +# Two of S2's edge-crossing tests are deliberately NOT ported, because they have +# no faithful analog in this kernel: +# +# * `GetIntersection` / `GrazingIntersections` compute an intersection *point*. +# This kernel is symbolic: a proper crossing's node has no coordinate +# anywhere in the engine (design D2), so there is nothing to compare. +# +# * `CoincidentZeroLengthEdgesThatDontTouch` asserts that four points which are +# *exactly proportional* on the sphere never cross. That holds only under +# S2's symbolic-perturbation model of `Sign`. This kernel instead computes +# the exact sign of the *actual* Float64 coordinates (via ExactPredicates / +# Rational), and the rounding of `(1+k*eps)*p` makes the four points genuinely +# non-collinear, so a tiny real interior crossing is the correct answer for +# the given inputs. The S2 test's own comment notes it "depends on the +# particular symbolic perturbations used by s2pred::Sign()". See the +# `CoincidentProportionalEdges` testset below, which asserts the property +# this kernel *does* guarantee (permutation-consistency) on such inputs. +# +# `RobustCrossProd` (also in s2edge_crossings_test.cc) is already ported, in +# test/utils/robustcrossproduct.jl. + +using Test +import GeometryOps as GO +import GeometryOps: Spherical, True, False +import GeometryOps.UnitSpherical: UnitSphericalPoint, slerp +import GeoInterface as GI +using LinearAlgebra: normalize +using Random + +# Build a unit-length spherical kernel point from raw components, normalizing +# exactly as S2's `S2Point::Normalize()` does (the S2 test harness normalizes +# every vertex before testing). +_nv(x, y, z) = (v = normalize([Float64(x), Float64(y), Float64(z)]); + UnitSphericalPoint(v[1], v[2], v[3])) + +# S2::Origin() — the conventional reference point, copied verbatim from +# s2pointutil.h. Used by S2 crossing cases 4 and 5 to exercise an edge whose +# endpoint is the origin reference. +const _S2_ORIGIN = (-0.0099994664350250197, 0.0025924542609324121, 0.99994664350250195) + +# Port of S2's `TestCrossings(a, b, c, d, crossing_sign, ...)`. We assert the +# `CrossingSign`->`SegSegClass.kind` mapping plus the symmetry that S2's harness +# checks: `kind` is invariant under reversing either edge and under swapping the +# two edges. +function check_s2_crossing(m, a, b, c, d, crossing_sign; exact) + r = GO.rk_classify_intersection(m, a, b, c, d; exact) + + if exact === True() + # The exact path is authoritative — S2's `CrossingSign` is robust, so the + # faithful analog is this kernel's exact path. Assert the full mapping. + if crossing_sign == 1 + @test r.kind == GO.SS_PROPER + elseif crossing_sign == -1 + @test r.kind == GO.SS_DISJOINT + else # crossing_sign == 0: at least one vertex is shared between the edges + @test r.kind != GO.SS_PROPER + @test r.kind != GO.SS_DISJOINT + @test r.a0_on_b || r.a1_on_b || r.b0_on_a || r.b1_on_a + end + else + # The Float64 fast path is a conservative accelerator, not an oracle: it + # falls back to SS_TOUCH on cases that need exact arithmetic (9, 11) and + # cannot register an incidence whose coplanarity determinant does not + # round to *exactly* zero (so a shared vertex of two skew normalized + # edges, as in case 6, reads as SS_DISJOINT). The contract it must honor + # is one-directional: never deny a real crossing, never invent one. + if crossing_sign == 1 + @test r.kind != GO.SS_DISJOINT + else + @test r.kind != GO.SS_PROPER + end + end + + # S2 TestCrossings symmetry: classification is invariant under reversal of + # either edge and under swapping the two edges. (Holds on both paths.) + for (p, q, s, t) in ((b, a, c, d), (a, b, d, c), (b, a, d, c), (c, d, a, b)) + @test GO.rk_classify_intersection(m, p, q, s, t; exact).kind == r.kind + end + return r +end + +function s2_crossings_suite(m; exact) + @testset "S2 Crossings: 12 hand-built cases" begin + # 1. Two regular edges that cross. + check_s2_crossing(m, _nv(1, 2, 1), _nv(1, -3, 0.5), + _nv(1, -0.5, -3), _nv(0.1, 0.5, 3), 1; exact) + # 2. Two regular edges that intersect at antipodal points (so the arcs + # themselves do not meet). + check_s2_crossing(m, _nv(1, 2, 1), _nv(1, -3, 0.5), + _nv(-1, 0.5, 3), _nv(-0.1, -0.5, -3), -1; exact) + # 3. Two edges on the same great circle that start at antipodal points. + check_s2_crossing(m, _nv(0, 0, -1), _nv(0, 1, 0), + _nv(0, 0, 1), _nv(0, 1, 1), -1; exact) + # 4. Two edges that cross where one vertex is S2::Origin(). + check_s2_crossing(m, _nv(1, 0, 0), _nv(_S2_ORIGIN...), + _nv(1, -0.1, 1), _nv(1, 1, -0.1), 1; exact) + # 5. Antipodal intersection where one vertex is S2::Origin(). + check_s2_crossing(m, _nv(1, 0, 0), _nv(_S2_ORIGIN...), + _nv(-1, 0.1, -1), _nv(-1, -1, 0.1), -1; exact) + # 6. Two edges that share an endpoint (2,3,4). + check_s2_crossing(m, _nv(7, -2, 3), _nv(2, 3, 4), + _nv(2, 3, 4), _nv(-1, 2, 5), 0; exact) + # 7. Edges that barely cross near the middle of one edge. AB is ~ in the + # x=y plane; CD is ~ perpendicular and ends exactly at the x=y plane. + check_s2_crossing(m, _nv(1, 1, 1), _nv(1, prevfloat(1.0), -1), + _nv(11, -12, -1), _nv(10, 10, 1), 1; exact) + # 8. As (7) but the edges are separated by a distance of about 1e-15. + check_s2_crossing(m, _nv(1, 1, 1), _nv(1, nextfloat(1.0), -1), + _nv(1, -1, 0), _nv(1, 1, 0), -1; exact) + # 9. Barely cross near the end of both edges — cannot be handled in plain + # double precision due to floating-point underflow. + check_s2_crossing(m, _nv(0, 0, 1), _nv(2, -1e-323, 1), + _nv(1, -1, 1), _nv(1e-323, 0, 1), 1; exact) + # 10. As (9) but separated by a distance of about 1e-640. + check_s2_crossing(m, _nv(0, 0, 1), _nv(2, 1e-323, 1), + _nv(1, -1, 1), _nv(1e-323, 0, 1), -1; exact) + # 11. Barely cross near the middle of one edge — the exact determinant of + # some triangles here needs more than 2000 bits of precision. + check_s2_crossing(m, _nv(1, -1e-323, -1e-323), _nv(1e-323, 1, 1e-323), + _nv(1, -1, 1e-323), _nv(1, 1, 0), 1; exact) + # 12. As (11) but separated by a distance of about 1e-640. + check_s2_crossing(m, _nv(1, 1e-323, -1e-323), _nv(-1e-323, 1, 1e-323), + _nv(1, -1, 1e-323), _nv(1, 1, 0), -1; exact) + end + + @testset "S2 CollinearEdgesThatDontTouch" begin + # Two disjoint sub-arcs of one minor arc: a..b == arc[0.00,0.05] and + # c..d == arc[0.95,1.00]. They share a great circle but never overlap, so + # they must never be reported as a proper crossing. + rng = MersenneTwister(0x5e2c011) + rp() = (v = normalize(randn(rng, 3)); UnitSphericalPoint(v[1], v[2], v[3])) + for _ in 1:500 + a = rp(); d = rp() + b = slerp(a, d, 0.05) + c = slerp(a, d, 0.95) + r = GO.rk_classify_intersection(m, a, b, c, d; exact) + @test r.kind != GO.SS_PROPER + # On the exact path the kernel resolves them as fully disjoint. + exact === True() && @test r.kind == GO.SS_DISJOINT + end + end + + @testset "CoincidentProportionalEdges (kernel convention, not S2's)" begin + # S2's `CoincidentZeroLengthEdgesThatDontTouch` asserts non-crossing for + # exactly-proportional points under symbolic perturbation. This kernel + # uses exact signs of the actual Float64 coordinates instead, so it does + # not promise that. What it *does* promise — and what we assert here — is + # that the classification is self-consistent: invariant under reversing + # either edge and under swapping the two edges (the same symmetry the S2 + # harness relies on). + rng = MersenneTwister(0x5e2c022) + for _ in 1:300 + p = normalize(randn(rng, 3)) + a = UnitSphericalPoint(((1 - 3e-16) .* p)...) + b = UnitSphericalPoint(((1 - 1e-16) .* p)...) + c = UnitSphericalPoint(p...) + d = UnitSphericalPoint(((1 + 2e-16) .* p)...) + r = GO.rk_classify_intersection(m, a, b, c, d; exact) + for (w, x, y, z) in ((b, a, c, d), (a, b, d, c), (b, a, d, c), (c, d, a, b)) + @test GO.rk_classify_intersection(m, w, x, y, z; exact).kind == r.kind + end + end + end +end + +@testset "S2 edge-crossing conformance: Spherical" begin + @testset "exact = $E" for E in (True(), False()) + s2_crossings_suite(Spherical(); exact = E) + end +end diff --git a/test/methods/relateng/spherical_end_to_end.jl b/test/methods/relateng/spherical_end_to_end.jl new file mode 100644 index 0000000000..defedd712c --- /dev/null +++ b/test/methods/relateng/spherical_end_to_end.jl @@ -0,0 +1,417 @@ +# End-to-end `relate(RelateNG(; manifold = Spherical()), …)` smoke tests +# (Task 14). With the spherical kernel and the manifold-derived point type +# in place, the engine runs on the unindexed NestedLoop path. Away from the +# poles and the antimeridian, spherical and planar topology agree (the spike +# measured 2000/2000), so the patch test compares the two DE-9IM matrices +# directly; the pole-containing ring is a case only the spherical kernel +# gets right. + +using Test +import GeometryOps as GO +import GeometryOps: Spherical, RelateNG +import GeoInterface as GI + +alg = RelateNG(; manifold = Spherical()) + +@testset "spherical relate agrees with planar on a small mid-latitude patch" begin + # two overlapping boxes near (10°, 45°): away from poles/antimeridian the + # spherical and planar topology agree + A = GI.Polygon([GI.LinearRing([(0., 40.), (10., 40.), (10., 50.), (0., 50.), (0., 40.)])]) + B = GI.Polygon([GI.LinearRing([(5., 45.), (15., 45.), (15., 55.), (5., 55.), (5., 45.)])]) + @test GO.relate(alg, A, B) == GO.relate(A, B) # same DE-9IM + @test GO.relate(alg, A, B, "T*T***T**") # overlaps +end + +@testset "spherical handles a pole-containing ring" begin + cap = GI.Polygon([GI.LinearRing([(0., 80.), (120., 80.), (240., 80.), (0., 80.)])]) + pt = GI.Point(0., 89.) # near north pole + @test GO.relate(alg, cap, pt, "T*****FF*") # contains +end + +# Task 15: the tree accelerator must build 3D great-circle arc extents, not a +# 2D coordinate box. A long near-equatorial arc (lon 0 → 170) bulges to y ≈ 1 +# at lon 90 while its endpoint box has y ∈ [0, 0.17]; a polygon straddling the +# equator at lon 90 crosses it there. A 2D endpoint box prunes that pair away +# (wrong DE-9IM); the bulge-aware `spherical_arc_extent` keeps it. +@testset "spherical tree accelerator agrees with NestedLoop (arc bulge)" begin + A = GI.Polygon([GI.LinearRing([(0., 0.), (170., 0.), (85., 40.), (0., 0.)])]) + B = GI.Polygon([GI.LinearRing([(88., -2.), (92., -2.), (92., 2.), (88., 2.), (88., -2.)])]) + tree = RelateNG(; manifold = Spherical(), accelerator = GO.DoubleNaturalTree()) + loop = RelateNG(; manifold = Spherical(), accelerator = GO.NestedLoop()) + @test GO.relate(tree, A, B) == GO.relate(loop, A, B) +end + +# Task 15: above the 32-segment threshold AutoAccelerator picks the tree path; +# it must give the same DE-9IM as the unindexed nested loop on a real ring. +@testset "spherical AutoAccelerator picks the tree above threshold" begin + n = 48 # 48 segments > threshold + ringpts = [(10.0 + 8cosd(t), 45.0 + 5sind(t)) for t in range(0, 360; length = n + 1)] + A = GI.Polygon([GI.LinearRing(ringpts)]) + B = GI.Polygon([GI.LinearRing([(8., 43.), (20., 43.), (20., 52.), (8., 52.), (8., 43.)])]) + loop = RelateNG(; manifold = Spherical(), accelerator = GO.NestedLoop()) + @test GO.relate(alg, A, B) == GO.relate(loop, A, B) +end + +# Task 17: prepared spherical relate (A indexed once, in 3D) must agree with +# the unprepared nested-loop relate over several B geometries. The prepared +# edge index is the dimension-generic natural-order `RTree` over 3D arc extents. +@testset "spherical prepared relate agrees with unprepared" begin + n = 48 + ringpts = [(10.0 + 8cosd(t), 45.0 + 5sind(t)) for t in range(0, 360; length = n + 1)] + A = GI.Polygon([GI.LinearRing(ringpts)]) + prep = GO.prepare(alg, A) + loop = RelateNG(; manifold = Spherical(), accelerator = GO.NestedLoop()) + Bs = ( + GI.Polygon([GI.LinearRing([(8., 43.), (20., 43.), (20., 52.), (8., 52.), (8., 43.)])]), + GI.Polygon([GI.LinearRing([(11., 45.), (13., 45.), (13., 47.), (11., 47.), (11., 45.)])]), + GI.Point(10., 45.), + GI.Point(40., 80.), + ) + for B in Bs + @test GO.relate(prep, B) == GO.relate(loop, A, B) + end +end + +# Ring winding must not change which region a polygon bounds: real-world data +# arrives in either winding (Natural Earth ships shapefile-convention CW +# shells), and the kernel contract for `rk_point_in_ring` is the area enclosed +# by the ring — the region smaller than a hemisphere — like the planar +# ray-crossing parity. Under the previous S2 left-of-ring reading, a CW +# continent meant "the whole sphere minus the continent" and intersected +# everything. +@testset "polygon interior is winding-independent" begin + sq(x0, y0, s) = [(x0, y0), (x0 + s, y0), (x0 + s, y0 + s), (x0, y0 + s), (x0, y0)] + ccw(pts) = GI.Polygon([GI.LinearRing(pts)]) + cw(pts) = GI.Polygon([GI.LinearRing(reverse(pts))]) + canada_ish = sq(-100.0, 50.0, 10.0) + australia_ish = sq(140.0, -35.0, 15.0) + continent = sq(-140.0, 20.0, 60.0) # continent-sized + far_pt = GI.Point(150.0, -25.0) + @testset "disjoint stays disjoint in all four winding combinations" begin + for A in (ccw(canada_ish), cw(canada_ish)), B in (ccw(australia_ish), cw(australia_ish)) + @test !GO.intersects(alg, A, B) + @test GO.disjoint(alg, A, B) + end + for A in (ccw(continent), cw(continent)) + @test !GO.intersects(alg, A, far_pt) + @test !GO.intersects(alg, A, ccw(australia_ish)) + end + end + @testset "containment in all four winding combinations" begin + inner = sq(-97.0, 53.0, 2.0) + for A in (ccw(canada_ish), cw(canada_ish)), B in (ccw(inner), cw(inner)) + @test GO.contains(alg, A, B) + @test GO.within(alg, B, A) + @test !GO.touches(alg, A, B) + end + end + @testset "shared-edge neighbors touch in all four winding combinations" begin + left = sq(0.0, 40.0, 10.0) + right = sq(10.0, 40.0, 10.0) + for A in (ccw(left), cw(left)), B in (ccw(right), cw(right)) + @test GO.touches(alg, A, B) + @test GO.intersects(alg, A, B) + end + end + @testset "equator-symmetric ring (planar isCCW tie case)" begin + # two vertices tie for the extreme xyz-y coordinate, which broke the + # planar extreme-vertex orientation test on the sphere: both windings + # read CW + eq = sq(-5.0, -5.0, 10.0) + inside = GI.Point(0.0, 0.0) + for A in (ccw(eq), cw(eq)) + @test GO.contains(alg, A, inside) + @test !GO.intersects(alg, A, far_pt) + end + end +end + +# A degenerate zero-area sliver ring with a repeated vertex (NE 110m North +# Korea ships an `[A, A, B, A]` first polygon) must not swallow the sphere: +# the zero-length arc `A → A` used to read every query point as on-boundary. +@testset "degenerate sliver polygon in a multipolygon stays local" begin + a = (130.78, 42.22) + sliver = GI.LinearRing([a, a, (130.78002, 42.22), a]) + body = GI.LinearRing([(124., 38.), (130., 38.), (130., 43.), (124., 43.), (124., 38.)]) + mp = GI.MultiPolygon([GI.Polygon([sliver]), GI.Polygon([body])]) + far = GI.Polygon([GI.LinearRing([(-60., -25.), (-55., -25.), (-55., -20.), (-60., -20.), (-60., -25.)])]) + @test !GO.intersects(alg, mp, far) + @test !GO.intersects(alg, mp, GI.Point(-60.0, -20.0)) + @test GO.intersects(alg, mp, GI.Point(127.0, 40.0)) +end + +# The winding-independent region also drives the kernel interaction bounds: a +# CW polar cap must still bound the cap (its enclosed pole included), not the +# complement — which would under-cover the pole and prune away a contained +# point. +@testset "CW polar cap still means the cap" begin + cap_cw = GI.Polygon([GI.LinearRing([(0., 80.), (240., 80.), (120., 80.), (0., 80.)])]) + pt = GI.Point(0., 89.) # near north pole + @test GO.relate(alg, cap_cw, pt, "T*****FF*") # contains + e = GO.rk_interaction_bounds(Spherical(), cap_cw) + @test e.Z[2] >= 1.0 # bounds reach the enclosed pole +end + +# Prepared point location goes through the indexed spherical locator (the +# longitude-interval edge index with meridian-arc crossing parity against a +# pole anchor); unprepared location is the exact ring scan. End to end the +# two must produce the same DE-9IM for every point, including the awkward +# geometries: an antimeridian-straddling box (split index intervals), polar +# caps over either pole (enclosed pole, polar-axis queries), and a shell +# with the south pole on its boundary (the parity anchor falls back to the +# north pole). The grid deliberately includes both poles, the ±180° seam, +# and points on ring boundaries. +@testset "prepared point location agrees with unprepared" begin + rings = ( + [(170., -10.), (-170., -10.), (-170., 10.), (170., 10.), (170., -10.)], # antimeridian box + [(0., 80.), (120., 80.), (240., 80.), (0., 80.)], # north polar cap + [(0., -80.), (120., -80.), (240., -80.), (0., -80.)], # south polar cap + [(0., -90.), (20., -60.), (-20., -60.), (0., -90.)], # south pole on boundary + ) + qs = [GI.Point(lon, lat) for lon in -180.0:30.0:180.0 for lat in -90.0:15.0:90.0] + push!(qs, GI.Point(180.0, 5.0), GI.Point(175.0, 0.0), GI.Point(0.0, -70.0)) + for pts in rings, w in (pts, reverse(pts)) + A = GI.Polygon([GI.LinearRing(w)]) + prep = GO.prepare(alg, A) + n_mismatch = count(q -> GO.relate(prep, q) != GO.relate(alg, A, q), qs) + @test n_mismatch == 0 + end +end + +# A ring may carry antipodal VERTEX pairs even though antipodal edges are +# rejected at ingest — the AntipodalEdgeSplit output is exactly such a ring. +# The orientation sum must not connect non-adjacent vertices (a chord between +# an antipodal pair has no defined geodesic; the one-apex Girard fan +# degenerated on exactly that, flipping the shared orientation bit and with +# it the polygon interior — CI regression on the fixed ring below. The S2 +# turning-angle curvature uses adjacent vertices only.) +@testset "antipodal vertex pair does not flip the interior" begin + # AntipodalEdgeSplit's output for the (0,0) → (180,0) edge, verbatim: + # the first vertex (0,0) is antipodal to the mid-ring vertex (180,0) + split_pts = [(0., 0.), (90., 0.), (180., 0.), (90., 80.), (0., 0.)] + inside = GI.Point(10., 10.) # under the (0,0)→(90,80) arc (lat 44.6° at lon 10) + outside = GI.Point(10., -10.) # southern hemisphere + for pts in (split_pts, reverse(split_pts)) + poly = GI.Polygon([GI.LinearRing(pts)]) + @test GO.relate(alg, poly, inside, "T*****FF*") # contains + @test GO.contains(alg, poly, inside) + @test !GO.intersects(alg, poly, outside) + end + # a different configuration: the first vertex's antipode mid-ring, with + # the ring elsewhere entirely off the first vertex's great circles + pts2 = [(20., 30.), (100., 10.), (-160., -30.), (-100., 10.), (20., 30.)] + inside2 = GI.Point(0., 40.) + for pts in (pts2, reverse(pts2)) + poly = GI.Polygon([GI.LinearRing(pts)]) + @test GO.contains(alg, poly, inside2) + @test !GO.intersects(alg, poly, GI.Point(0., -80.)) + end +end + +# `Spherical(; oriented = true)`: ring directions are declared correct +# (exterior rings CCW, interior rings CW), so the polygon interior is the +# region on the LEFT of each ring's stored order (S2 InitOriented semantics) +# — no winding is computed, and regions larger than a hemisphere become +# representable. The default manifold keeps the enclosed-region reading +# tested above. +@testset "oriented manifold: winding is authoritative" begin + oalg = RelateNG(; manifold = Spherical(oriented = true)) + sq(x0, y0, s) = [(x0, y0), (x0 + s, y0), (x0 + s, y0 + s), (x0, y0 + s), (x0, y0)] + ccw(pts...) = GI.Polygon([GI.LinearRing(p) for p in pts]) + + @testset "correctly wound data matches the unoriented mode" begin + A = ccw(sq(0., 40., 10.)) + B = ccw(sq(5., 45., 10.)) + @test GO.relate(oalg, A, B) == GO.relate(alg, A, B) + @test GO.contains(oalg, A, ccw(sq(2., 42., 2.))) + @test GO.touches(oalg, A, ccw(sq(10., 40., 10.))) + @test !GO.intersects(oalg, A, GI.Point(-90., -45.)) + end + + @testset "a CW shell denotes its complement" begin + Q = sq(0., 40., 10.) + C = ccw(reverse(Q)) # the sphere minus the square + far = GI.Point(-90., -45.) + inside_sq = GI.Point(5., 45.) + @test GO.contains(oalg, C, far) + @test !GO.contains(oalg, C, inside_sq) + @test !GO.intersects(oalg, C, inside_sq) + @test GO.intersects(oalg, C, GI.Point(0., 45.)) # boundary point + # the square and its complement share a boundary and have disjoint + # interiors: they touch, and neither contains the other + @test GO.touches(oalg, ccw(Q), C) + @test !GO.contains(oalg, C, ccw(Q)) + # the same CW ring under the default manifold is still the square + @test GO.contains(alg, C, inside_sq) + @test !GO.intersects(alg, C, far) + end + + @testset "region larger than a hemisphere: sphere minus a polar cap" begin + cap_cw = [(0., 80.), (240., 80.), (120., 80.), (0., 80.)] + S = ccw(cap_cw) + @test GO.contains(oalg, S, GI.Point(0., -89.)) # opposite pole is interior + @test !GO.intersects(oalg, S, GI.Point(0., 89.)) # the cap is excluded + # containment of a whole mid-latitude polygon in the complement + eq_box = ccw(sq(0., -5., 10.)) + @test GO.contains(oalg, S, eq_box) + @test GO.within(oalg, eq_box, S) + # a polygon straddling the cap boundary overlaps it + straddle = ccw(sq(-5., 75., 10.)) + @test GO.relate(oalg, S, straddle, "T*T***T**") # overlaps + # default manifold: the same ring is the cap + @test GO.contains(alg, S, GI.Point(0., 89.)) + @test !GO.intersects(alg, S, GI.Point(0., -89.)) + end + + @testset "hole windings in both modes" begin + shell = sq(0., 30., 20.) + hole = sq(5., 35., 5.) + in_ring = GI.Point(2., 32.) # inside the shell, outside the hole + in_hole = GI.Point(7., 37.) # inside the hole cavity + donut = ccw(shell, reverse(hole)) # CCW shell + CW hole: the convention + @test GO.contains(oalg, donut, in_ring) + @test !GO.contains(oalg, donut, in_hole) + @test !GO.intersects(oalg, donut, in_hole) + # a CCW-wound "hole" removes the cavity's complement instead, + # leaving only the disk: (shell ∩ disk) = disk + weird = ccw(shell, hole) + @test GO.contains(oalg, weird, in_hole) + @test !GO.intersects(oalg, weird, in_ring) + # CW shell with a CW hole elsewhere: (sphere − Q) − R + Q, R = shell, sq(100., -20., 10.) + comp = ccw(reverse(Q), reverse(R)) + @test GO.contains(oalg, comp, GI.Point(-90., -45.)) + @test !GO.intersects(oalg, comp, in_ring) # inside Q + @test !GO.intersects(oalg, comp, GI.Point(105., -15.)) # inside R + # the default manifold reads every winding combination as the + # enclosed regions (shapefile-convention CW shell + CCW hole too) + for s in (shell, reverse(shell)), h in (hole, reverse(hole)) + shp = ccw(s, h) + @test GO.contains(alg, shp, in_ring) + @test !GO.contains(alg, shp, in_hole) + @test GO.relate(alg, shp, donut, "T*F**FFF*") # topologically equal + end + end + + @testset "oriented prepared point location agrees with unprepared" begin + polys = ( + ccw([(0., 80.), (120., 80.), (240., 80.), (0., 80.)]), # polar cap + ccw([(0., 80.), (240., 80.), (120., 80.), (0., 80.)]), # sphere minus the cap + ccw([(170., -10.), (-170., -10.), (-170., 10.), (170., 10.), (170., -10.)]), # antimeridian box + ccw(sq(0., 30., 20.), reverse(sq(5., 35., 5.))), # donut + ccw(reverse(sq(0., 30., 20.))), # complement of a box + ) + qs = [GI.Point(lon, lat) for lon in -180.0:30.0:180.0 for lat in -90.0:15.0:90.0] + push!(qs, GI.Point(180.0, 5.0), GI.Point(7.0, 37.0), GI.Point(2.0, 32.0)) + for A in polys + prep = GO.prepare(oalg, A) + n_mismatch = count(q -> GO.relate(prep, q) != GO.relate(oalg, A, q), qs) + @test n_mismatch == 0 + end + end +end + +# Task 18: an exactly-antipodal edge has no unique great-circle arc; the kernel +# refuses it at ingest with a message pointing at the AntipodalEdgeSplit remedy. +@testset "spherical antipodal edge throws informatively" begin + p0 = GO._to_kernel_point(Spherical(), (0., 0.)) # (1, 0, 0) + p180 = GO._to_kernel_point(Spherical(), (180., 0.)) # (-1, 0, 0): antipodal + p90 = GO._to_kernel_point(Spherical(), (90., 0.)) # (0, 1, 0) + err = try; GO._validate_relate_edges(Spherical(), GI.LineString([p0, p180])); nothing; catch e; e; end + @test err isa ArgumentError + @test occursin("AntipodalEdgeSplit", err.msg) + #-- a normal edge and a repeated (zero-length) vertex do NOT throw + @test (GO._validate_relate_edges(Spherical(), GI.LineString([p0, p90])); true) + @test (GO._validate_relate_edges(Spherical(), GI.LineString([p0, p0])); true) + #-- the whole relate rejects a polygon carrying an antipodal edge at ingest + bad = GI.Polygon([GI.LinearRing([(0., 0.), (180., 0.), (90., 80.), (0., 0.)])]) + @test_throws ArgumentError GO.relate(alg, bad, GI.Point(10., 10.)) +end + +# Kernel points are 3D, so every coordinate comparison in the segment-string +# machinery must compare all three components. Two vertices at mirror +# latitudes on one meridian — e.g. (0, -5) and (0, 5) — share x and y on the +# unit sphere and differ only in z; the planar 2D coordinate equality +# (`_equals2`, Java's equals2D) read such a pair as a repeated point and +# DELETED the second vertex at extraction, corrupting edge topology for any +# ring with an equator-mirrored meridian edge. +@testset "equator-mirrored meridian edges survive extraction" begin + left = GI.Polygon([GI.LinearRing( + [(-10., -5.), (0., -5.), (0., 5.), (-10., 5.), (-10., -5.)])]) + right = GI.Polygon([GI.LinearRing( + [(0., -5.), (10., -5.), (10., 5.), (0., 5.), (0., -5.)])]) + #-- the extracted shell must keep all its vertices + rg = GO.RelateGeometry(Spherical(), left; exact = GO.True()) + ss = GO.extract_segment_strings(rg, GO.GEOM_A, nothing) + @test length(only(ss).pts) == 5 + #-- shared-meridian neighbors: same DE-9IM as the planar engine + @test GO.touches(alg, left, right) + @test string(GO.relate(alg, left, right)) == string(GO.relate(left, right)) + inner = GI.Polygon([GI.LinearRing( + [(-8., -3.), (-2., -3.), (-2., 3.), (-8., 3.), (-8., -3.)])]) + @test GO.contains(alg, left, inner) +end + +# Containment parity is anchored at a DEFINITIONALLY exterior point (the +# antipode of the ring's vertex mass), so a ring that self-intersects on the +# sphere — the class `prepare` validation rejects — degrades to even-odd +# semantics instead of inverting globally: both lobes of a figure-eight read +# IN, everything else OUT, matching planar even-odd ray crossing (and what +# S2Builder's undirected repair produces). The previous wedge-plus-winding +# bootstrap answered with whichever lobe hosted the anchor edge, which on +# real data (NE 110m Sudan) inverted every containment answer on the globe. +@testset "self-crossing rings degrade even-odd, not inverted" begin + palg = RelateNG() + #-- an explicit self-crossing quadrilateral bowtie (crossing near (0,0)) + #-- and an asymmetric variant (crossing near (-5.8, 0.3), unequal lobes) + bowtie = [(-10., -10.), (10., 10.), (10., -10.), (-10., 10.), (-10., -10.)] + skew = [(-20., -5.), (20., 10.), (20., -10.), (-20., 6.), (-20., -5.)] + cases = ( + (bowtie, (8., 1.), (-8., -1.), (0., 5.), (0., -5.)), + (skew, (15., 0.), (-17., 0.), (0., 8.), (0., -8.)), + ) + far = GI.Point(100., 40.) + for (pts, in_a, in_b, out_a, out_b) in cases, w in (pts, reverse(pts)) + poly = GI.Polygon([GI.LinearRing(w)]) + #-- even-odd: both lobes IN, between/far OUT, in both windings + @test GO.contains(alg, poly, GI.Point(in_a)) + @test GO.contains(alg, poly, GI.Point(in_b)) + @test !GO.intersects(alg, poly, GI.Point(out_a)) + @test !GO.intersects(alg, poly, GI.Point(out_b)) + @test !GO.intersects(alg, poly, far) + #-- and exact agreement with planar even-odd on the same probes + probes = (GI.Point(in_a), GI.Point(in_b), GI.Point(out_a), GI.Point(out_b), far) + for q in probes + @test GO.intersects(alg, poly, q) == GO.intersects(palg, poly, q) + end + #-- the indexed locator (prepared with validation bypassed — the + #-- build-time pole-anchor classification composes with the same + #-- parity) must agree with the unprepared exact scan + prep = GO.prepare(alg, poly; validate = false) + for q in probes + @test GO.relate(prep, q) == GO.relate(alg, poly, q) + end + end +end + +# The definitional anchor degenerates for near-hemisphere/vertex-symmetric +# rings (vertex mass ~ 0): those queries fall back to the wedge-plus-winding +# bootstrap — for such rings the enclosed/complement distinction is itself +# near-degenerate, and the fallback keeps the pre-existing behavior: an +# exact-equator ring (every edge on ONE great circle) resolves boundary +# queries exactly and refuses interior location with the documented +# degenerate-ring error, unchanged. +@testset "degenerate vertex mass falls back to the wedge bootstrap" begin + equator = [(0., 0.), (90., 0.), (180., 0.), (-90., 0.)] + usps = [GO.UnitSpherical.UnitSphereFromGeographic()(p) for p in equator] + @test GO.UnitSpherical.spherical_exterior_anchor(usps, 4) === nothing + hemi = GI.Polygon([GI.LinearRing([equator; [equator[1]]])]) + @test GO.intersects(alg, hemi, GI.Point(45., 0.)) # boundary, exact + @test_throws ArgumentError GO.relate(alg, hemi, GI.Point(10., 45.)) + #-- a ring 0.5° off the equator has a tiny but usable vertex mass: the + #-- parity path still answers, 89.5° from the anchor + near_eq = [(Float64(lon), 0.5) for lon in 0:30:360] + cap = GI.Polygon([GI.LinearRing(near_eq)]) + @test GO.contains(alg, cap, GI.Point(0., 45.)) + @test !GO.intersects(alg, cap, GI.Point(0., -45.)) +end diff --git a/test/methods/relateng/topology_computer.jl b/test/methods/relateng/topology_computer.jl new file mode 100644 index 0000000000..20a4b7197c --- /dev/null +++ b/test/methods/relateng/topology_computer.jl @@ -0,0 +1,462 @@ +# Tests for the RelateNG TopologyComputer (topology_computer.jl), the port +# of JTS TopologyComputer.java. JTS has no dedicated JUnit file for it; per +# the implementation plan (Task 18) the computer is tested through its public +# entry points with a `RelateMatrixPredicate` attached, asserting the +# resulting (partial) IM strings: +# +# - `init_exterior_dims!` a-priori exterior entries for all dim pairs +# (hand-derived from TopologyComputer.java:44-102), +# - empty-geometry initialization, +# - the addX entry points for every target dimension, +# - `add_intersection!` + `evaluate_nodes!` (symbolic node-section grouping), +# - `updateAreaAreaCross` via `rk_is_crossing`, +# - the D3 coincidence-merge pass (including the multi-segment-pair +# crossing wheel, which exercises the exact rational apex fallback in +# `rk_compare_edge_dir`), +# - short-circuiting once the predicate value is known. + +using Test +import GeometryOps as GO +import GeometryOps: Planar, True, False +import GeoInterface as GI +import LibGEOS as LG # only for EMPTY geometries — GI wrappers cannot be empty + +const M = Planar() +const EX = True() + +rgeom(g) = GO.RelateGeometry(M, g; exact = EX) + +# A TopologyComputer with a full-matrix predicate attached; returns both. +function im_computer(rga::GO.RelateGeometry, rgb::GO.RelateGeometry) + pred = GO.RelateMatrixPredicate() + return GO.TopologyComputer(pred, rga, rgb), pred +end +im_computer(ga, gb) = im_computer(rgeom(ga), rgeom(gb)) + +imstr(pred) = string(GO.result_im(pred)) + +# The partial IM accumulated by `init_exterior_dims!` alone. +init_im(ga, gb) = imstr(im_computer(ga, gb)[2]) + +# 1-based index of the segment (p, q) (either orientation) in a segment string. +function find_seg(ss, p, q) + pts = ss.pts + for i in 1:(length(pts) - 1) + (pts[i] == p && pts[i + 1] == q) && return i + (pts[i] == q && pts[i + 1] == p) && return i + end + error("segment not found") +end + +# Fixtures +const PT_A = GI.Point(1.0, 1.0) +const PT_B = GI.Point(5.0, 5.0) +const LINE_A = GI.LineString([(0.0, 0.0), (4.0, 0.0)]) +const LINE_B = GI.LineString([(0.0, 5.0), (4.0, 5.0)]) +const RING_LINE = GI.LineString([(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 0.0)]) +const POLY_A = GI.Polygon([[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]) +const POLY_B = GI.Polygon([[(5.0, 5.0), (7.0, 5.0), (7.0, 7.0), (5.0, 7.0), (5.0, 5.0)]]) +const ZERO_LEN_LINE = GI.LineString([(3.0, 3.0), (3.0, 3.0)]) + +@testset "init_exterior_dims!: a-priori exterior entries" begin + # Derived by hand from TopologyComputer.java:44-83. The matrix starts + # all-F except E/E = 2 (IMPredicate constructor). + # P/P: no a-priori entries + @test init_im(PT_A, PT_B) == "FFFFFFFF2" + # P/L: P exterior intersects L interior + @test init_im(PT_A, LINE_B) == "FFFFFF1F2" + @test init_im(LINE_A, PT_B) == "FF1FFFFF2" + # P/A: the area Int and Bdy intersect the point exterior + @test init_im(PT_A, POLY_B) == "FFFFFF212" + @test init_im(POLY_A, PT_B) == "FF2FF1FF2" + # L/A: the area interior intersects the line exterior + @test init_im(LINE_A, POLY_B) == "FFFFFF2F2" + @test init_im(POLY_A, LINE_B) == "FF2FFFFF2" + # L/L and A/A: no a-priori entries + @test init_im(LINE_A, LINE_B) == "FFFFFFFF2" + @test init_im(POLY_A, POLY_B) == "FFFFFFFF2" + # zero-length lines have real dimension P (getDimensionReal) + @test init_im(ZERO_LEN_LINE, PT_B) == "FFFFFFFF2" +end + +@testset "init_exterior_dims!: empty geometries" begin + # Derived by hand from TopologyComputer.java:74-102 (initExteriorEmpty). + empty_a = LG.readgeom("POLYGON EMPTY") + # empty vs P: non-empty interior intersects the exterior (dim P) + @test init_im(empty_a, PT_B) == "FFFFFF0F2" + @test init_im(PT_A, empty_a) == "FF0FFFFF2" + # empty vs L (open line has a boundary) + @test init_im(empty_a, LINE_B) == "FFFFFF102" + @test init_im(LINE_A, empty_a) == "FF1FF0FF2" + # empty vs closed line (ring): no boundary entry + @test init_im(empty_a, RING_LINE) == "FFFFFF1F2" + # empty vs A + @test init_im(empty_a, POLY_B) == "FFFFFF212" + @test init_im(POLY_A, empty_a) == "FF2FF1FF2" + # empty vs empty: nothing + @test init_im(empty_a, LG.readgeom("LINESTRING EMPTY")) == "FFFFFFFF2" +end + +@testset "accessors and flags" begin + tc, _ = im_computer(POLY_A, POLY_B) + @test GO.get_geometry(tc, GO.GEOM_A) === tc.geom_a + @test GO.get_geometry(tc, GO.GEOM_B) === tc.geom_b + @test GO.get_dimension(tc, true) == GO.DIM_A + @test GO.get_dimension(tc, false) == GO.DIM_A + @test GO.is_area_area(tc) + @test !GO.is_area_area(im_computer(LINE_A, POLY_B)[1]) + # exterior-check requirement forwards to the predicate + @test GO.is_exterior_check_required(tc, true) + @test GO.is_exterior_check_required(tc, false) + + # isSelfNodingRequired (TopologyComputer.java:142-154) + # - predicate does not require self-noding + pred = GO.pred_intersects() + @test !GO.is_self_noding_required(GO.TopologyComputer(pred, rgeom(LINE_A), rgeom(LINE_B))) + # - A requires self-noding (a line may self-cross) + @test GO.is_self_noding_required(im_computer(LINE_A, POLY_B)[1]) + # - polygonal A and B never require self-noding + @test !GO.is_self_noding_required(im_computer(POLY_A, POLY_B)[1]) + # - B a mixed A/L GC requires full noding + mixed_b = GI.GeometryCollection([POLY_B, LINE_B]) + @test GO.is_self_noding_required(im_computer(POLY_A, mixed_b)[1]) + + # the manifold/exact settings of both inputs must agree + @test_throws ArgumentError GO.TopologyComputer(GO.RelateMatrixPredicate(), + GO.RelateGeometry(M, PT_A; exact = True()), + GO.RelateGeometry(M, PT_B; exact = False())) +end + +@testset "add_point_on_point_interior!/_exterior!" begin + tc, pred = im_computer(PT_A, PT_B) + GO.add_point_on_point_interior!(tc, (1.0, 1.0)) + @test imstr(pred) == "0FFFFFFF2" + + tc, pred = im_computer(PT_A, PT_B) + GO.add_point_on_point_exterior!(tc, GO.GEOM_A, (1.0, 1.0)) + @test imstr(pred) == "FF0FFFFF2" + + tc, pred = im_computer(PT_A, PT_B) + GO.add_point_on_point_exterior!(tc, GO.GEOM_B, (5.0, 5.0)) + @test imstr(pred) == "FFFFFF0F2" +end + +@testset "add_point_on_geometry!" begin + # target dim P: only the point-interior entry + tc, pred = im_computer(PT_A, PT_B) + GO.add_point_on_geometry!(tc, true, GO.LOC_INTERIOR, GO.DIM_P, (5.0, 5.0)) + @test imstr(pred) == "0FFFFFFF2" + + # target dim L: no extra inference (zero-length-line caveat in Java) + tc, pred = im_computer(PT_A, LINE_B) + GO.add_point_on_geometry!(tc, true, GO.LOC_INTERIOR, GO.DIM_L, (1.0, 5.0)) + @test imstr(pred) == "0FFFFF1F2" + + # target dim A: area interior and boundary extend beyond the point + tc, pred = im_computer(PT_A, POLY_B) + GO.add_point_on_geometry!(tc, true, GO.LOC_INTERIOR, GO.DIM_A, (6.0, 6.0)) + @test imstr(pred) == "0FFFFF212" + + # B point in A area: entries transposed + tc, pred = im_computer(POLY_A, PT_B) + GO.add_point_on_geometry!(tc, false, GO.LOC_INTERIOR, GO.DIM_A, (1.0, 1.0)) + @test imstr(pred) == "0F2FF1FF2" + + # empty target: no entries to infer, the dim switch is never reached + tc, pred = im_computer(PT_A, LG.readgeom("POLYGON EMPTY")) + GO.add_point_on_geometry!(tc, true, GO.LOC_EXTERIOR, GO.DIM_FALSE, (1.0, 1.0)) + @test imstr(pred) == "FF0FFFFF2" + + # unknown target dimension throws (Java IllegalStateException) + tc, _ = im_computer(PT_A, POLY_B) + @test_throws ArgumentError GO.add_point_on_geometry!(tc, true, GO.LOC_INTERIOR, 5, (6.0, 6.0)) +end + +@testset "add_line_end_on_geometry!" begin + # target dim P: only the line-end entry + tc, pred = im_computer(LINE_A, PT_B) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_P, (0.0, 0.0)) + @test imstr(pred) == "FF10FFFF2" + + # target dim L, line end in line EXTERIOR: source line interior extends + # into the target exterior + tc, pred = im_computer(LINE_A, LINE_B) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_EXTERIOR, GO.DIM_L, (0.0, 0.0)) + @test imstr(pred) == "FF1FF0FF2" + + # target dim L, line end on line INTERIOR: only the end-point entry + tc, pred = im_computer(LINE_A, LINE_B) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_L, (0.0, 0.0)) + @test imstr(pred) == "FFF0FFFF2" + + # target dim A, line end in area INTERIOR + tc, pred = im_computer(LINE_A, POLY_B) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_A, (6.0, 6.0)) + @test imstr(pred) == "1FF0FF2F2" + + # target dim A, line end on area BOUNDARY: no further inference + tc, pred = im_computer(LINE_A, POLY_B) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_BOUNDARY, GO.DIM_A, (5.0, 5.0)) + @test imstr(pred) == "FFFF0F2F2" + + # target dim A, line end in area EXTERIOR + tc, pred = im_computer(LINE_A, POLY_B) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_A, (1.0, 0.0)) + @test imstr(pred) == "FF1FFF2F2" + + # empty target: only the end-point entry, dim switch never reached + tc, pred = im_computer(LINE_A, LG.readgeom("POLYGON EMPTY")) + GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_EXTERIOR, GO.DIM_FALSE, (0.0, 0.0)) + @test imstr(pred) == "FF1FF0FF2" + + # unknown target dimension throws + tc, _ = im_computer(LINE_A, POLY_B) + @test_throws ArgumentError GO.add_line_end_on_geometry!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, 5, (6.0, 6.0)) +end + +@testset "add_area_vertex!" begin + # locTarget EXTERIOR, boundary vertex: full neighbourhood inference + tc, pred = im_computer(POLY_A, LINE_B) + GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_EXTERIOR, GO.DIM_L, (0.0, 0.0)) + @test imstr(pred) == "FF2FF1FF2" + + # locTarget EXTERIOR, interior vertex (overlapping-GC case): only Int/Ext + tc, pred = im_computer(POLY_A, LINE_B) + GO.add_area_vertex!(tc, true, GO.LOC_INTERIOR, GO.LOC_EXTERIOR, GO.DIM_L, (1.0, 1.0)) + @test imstr(pred) == "FF2FFFFF2" + + # on point (addAreaVertexOnPoint): boundary vertex + tc, pred = im_computer(POLY_A, PT_B) + GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_P, (0.0, 0.0)) + @test imstr(pred) == "FF20F1FF2" + + # on line (addAreaVertexOnLine): boundary vertex — only the point entry + tc, pred = im_computer(POLY_A, LINE_B) + GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_L, (0.0, 0.0)) + @test imstr(pred) == "FF20FFFF2" + + # on line, interior vertex: area interior beyond the line + tc, pred = im_computer(POLY_A, LINE_B) + GO.add_area_vertex!(tc, true, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_L, (1.0, 1.0)) + @test imstr(pred) == "0F2FFFFF2" + + # on area (addAreaVertexOnArea): + # B/B: deferred to node analysis (point entry only) + tc, pred = im_computer(POLY_A, POLY_B) + GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_BOUNDARY, GO.DIM_A, (0.0, 0.0)) + @test imstr(pred) == "FFFF0FFF2" + # I/B + tc, pred = im_computer(POLY_A, POLY_B) + GO.add_area_vertex!(tc, true, GO.LOC_INTERIOR, GO.LOC_BOUNDARY, GO.DIM_A, (1.0, 1.0)) + @test imstr(pred) == "212FFFFF2" + # B/I + tc, pred = im_computer(POLY_A, POLY_B) + GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, GO.DIM_A, (0.0, 0.0)) + @test imstr(pred) == "2FF1FF2F2" + # I/I + tc, pred = im_computer(POLY_A, POLY_B) + GO.add_area_vertex!(tc, true, GO.LOC_INTERIOR, GO.LOC_INTERIOR, GO.DIM_A, (1.0, 1.0)) + @test imstr(pred) == "2FFFFFFF2" + + # unknown target dimension throws + tc, _ = im_computer(POLY_A, POLY_B) + @test_throws ArgumentError GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_INTERIOR, 5, (0.0, 0.0)) +end + +@testset "add_intersection! + evaluate_nodes!: L/L proper crossing" begin + line_a = GI.LineString([(-1.0, 0.0), (1.0, 0.0)]) + line_b = GI.LineString([(0.0, -1.0), (0.0, 1.0)]) + rga, rgb = rgeom(line_a), rgeom(line_b) + tc, pred = im_computer(rga, rgb) + ssa = GO.extract_segment_strings(rga, true, nothing)[1] + ssb = GO.extract_segment_strings(rgb, false, nothing)[1] + key = GO.crossing_node((-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)) + GO.add_intersection!(tc, GO.create_node_section(ssa, 1, key), + GO.create_node_section(ssb, 1, key)) + # updateNodeLocation: the crossing lies in both line interiors + @test imstr(pred) == "0FFFFFFF2" + @test length(tc.node_sections) == 1 + GO.evaluate_nodes!(tc) + # node wheel: each line's edges lie in the other line's exterior + @test imstr(pred) == "0F1FFF1F2" +end + +@testset "add_intersection! + evaluate_nodes!: A/A proper crossing" begin + # Overlapping unit-offset squares; one boundary crossing node carries the + # entire standard overlap matrix. + sq_a = GI.Polygon([[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]) + sq_b = GI.Polygon([[(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0), (1.0, 1.0)]]) + rga, rgb = rgeom(sq_a), rgeom(sq_b) + tc, pred = im_computer(rga, rgb) + ssa = GO.extract_segment_strings(rga, true, nothing)[1] + ssb = GO.extract_segment_strings(rgb, false, nothing)[1] + # A's top edge crosses B's left edge properly at (1, 2) + ia = find_seg(ssa, (0.0, 2.0), (2.0, 2.0)) + ib = find_seg(ssb, (1.0, 1.0), (1.0, 3.0)) + key = GO.crossing_node((0.0, 2.0), (2.0, 2.0), (1.0, 1.0), (1.0, 3.0)) + GO.add_intersection!(tc, GO.create_node_section(ssa, ia, key), + GO.create_node_section(ssb, ib, key)) + # proper crossing of two area boundaries: interiors intersect (dim 2), + # and the node lies on both boundaries (dim 0) + @test imstr(pred) == "2FFF0FFF2" + GO.evaluate_nodes!(tc) + @test imstr(pred) == "212101212" +end + +@testset "updateAreaAreaCross via rk_is_crossing (vertex node)" begin + # Two polygons whose boundaries cross at a shared vertex (origin). + # A's corner directions: (-2,-1) and (1,2); B's: (1,-2) and (-1,2) — + # interleaved around the node, so the boundaries cross. + poly_a = GI.Polygon([[(-2.0, -1.0), (0.0, 0.0), (1.0, 2.0), (3.0, 2.0), (3.0, -1.0), (-2.0, -1.0)]]) + poly_b = GI.Polygon([[(1.0, -2.0), (0.0, 0.0), (-1.0, 2.0), (-3.0, 2.0), (-3.0, -2.0), (1.0, -2.0)]]) + vkey = GO.vertex_node((0.0, 0.0)) + nsa = GO.NodeSection(true, Int8(2), Int32(1), Int32(0), nothing, true, (-2.0, -1.0), vkey, (1.0, 2.0)) + nsb = GO.NodeSection(false, Int8(2), Int32(1), Int32(0), nothing, true, (1.0, -2.0), vkey, (-1.0, 2.0)) + tc, pred = im_computer(poly_a, poly_b) + GO.add_intersection!(tc, nsa, nsb) + # crossing: interiors intersect; node is on both boundaries + @test imstr(pred) == "2FFF0FFF2" + + # touching variant: B's corner directions both on the same side of A's + # corner — boundaries do not cross, interiors entry is not inferred + nsb_touch = GO.NodeSection(false, Int8(2), Int32(1), Int32(0), nothing, true, (-1.0, 2.0), vkey, (-2.0, 1.0)) + tc, pred = im_computer(poly_a, poly_b) + GO.add_intersection!(tc, nsa, nsb_touch) + @test imstr(pred) == "FFFF0FFF2" +end + +@testset "rk_compare_edge_dir: exact rational apex fallback" begin + # Direction points which are NOT endpoints of the crossing node's + # defining segments (they arise from D3 coincidence-merged nodes). + key = GO.crossing_node((-1.0, -1.0), (1.0, 1.0), (-1.0, 0.0), (1.0, 0.0)) # apex (0,0) + @test GO.rk_compare_edge_dir(M, key, (-1.0, 1.0), (1.0, -1.0); exact = EX) < 0 # 135° vs 315° + @test GO.rk_compare_edge_dir(M, key, (1.0, -1.0), (-1.0, 1.0); exact = EX) > 0 + # mixed defining/foreign directions + @test GO.rk_compare_edge_dir(M, key, (1.0, 1.0), (-1.0, 1.0); exact = EX) < 0 # 45° vs 135° + @test GO.rk_compare_edge_dir(M, key, (-1.0, 1.0), (1.0, 1.0); exact = EX) > 0 + # equal angles: a foreign direction on the same ray as a defining endpoint + @test GO.rk_compare_edge_dir(M, key, (2.0, 2.0), (1.0, 1.0); exact = EX) == 0 + + # apex not representable in Float64: (2/3, 2/3) + key2 = GO.crossing_node((0.0, 0.0), (3.0, 3.0), (0.0, 1.0), (2.0, 0.0)) + @test GO.rk_compare_edge_dir(M, key2, (1.0, 0.5), (0.0, 0.5); exact = EX) > 0 # SE vs SW quadrant + @test GO.rk_compare_edge_dir(M, key2, (0.0, 0.5), (1.0, 0.5); exact = EX) < 0 + # exact collinearity through the irrational apex + @test GO.rk_compare_edge_dir(M, key2, (1.0, 1.0), (3.0, 3.0); exact = EX) == 0 +end + +@testset "D3 coincidence merge: crossings from different segment pairs" begin + # A self-crosses at the origin; B passes through the same point with a + # third direction. Three distinct crossing keys denote one node. + line1 = GI.LineString([(-1.0, -1.0), (1.0, 1.0)]) + line2 = GI.LineString([(-1.0, 1.0), (1.0, -1.0)]) + ml_a = GI.MultiLineString([line1, line2]) + line_b = GI.LineString([(-1.0, 0.0), (1.0, 0.0)]) + rga, rgb = rgeom(ml_a), rgeom(line_b) + tc, pred = im_computer(rga, rgb) + @test GO.is_self_noding_required(tc) + + ssa1, ssa2 = GO.extract_segment_strings(rga, true, nothing) + ssb = GO.extract_segment_strings(rgb, false, nothing)[1] + k1 = GO.crossing_node((-1.0, -1.0), (1.0, 1.0), (-1.0, 0.0), (1.0, 0.0)) + k2 = GO.crossing_node((-1.0, 1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0)) + k3 = GO.crossing_node((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)) + @test length(Set([k1, k2, k3])) == 3 + + GO.add_intersection!(tc, GO.create_node_section(ssa1, 1, k1), GO.create_node_section(ssb, 1, k1)) + GO.add_intersection!(tc, GO.create_node_section(ssa2, 1, k2), GO.create_node_section(ssb, 1, k2)) + # A self-intersection: contributes sections but no direct IM update + GO.add_intersection!(tc, GO.create_node_section(ssa1, 1, k3), GO.create_node_section(ssa2, 1, k3)) + @test imstr(pred) == "0FFFFFFF2" + @test length(tc.node_sections) == 3 + + # evaluate_nodes! merges the coinciding keys into one node, then builds + # a single 6-direction wheel. Directions from the non-canonical segment + # pairs exercise the rational-apex comparison in rk_compare_edge_dir. + GO.evaluate_nodes!(tc) + @test length(tc.node_sections) == 1 + @test imstr(pred) == "0F1FFF1F2" +end + +@testset "D3 coincidence merge: vertex node absorbs crossing node" begin + # B has a vertex exactly at A's self-crossing point. The vertex key is + # preferred as the canonical merged node. + line1 = GI.LineString([(-1.0, -1.0), (1.0, 1.0)]) + line2 = GI.LineString([(-1.0, 1.0), (1.0, -1.0)]) + ml_a = GI.MultiLineString([line1, line2]) + line_b = GI.LineString([(-1.0, 0.0), (0.0, 0.0), (1.0, 0.0)]) + rga, rgb = rgeom(ml_a), rgeom(line_b) + tc, pred = im_computer(rga, rgb) + + ssa1, ssa2 = GO.extract_segment_strings(rga, true, nothing) + ssb = GO.extract_segment_strings(rgb, false, nothing)[1] + vkey = GO.vertex_node((0.0, 0.0)) + k3 = GO.crossing_node((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)) + + GO.add_intersection!(tc, GO.create_node_section(ssa1, 1, vkey), GO.create_node_section(ssb, 1, vkey)) + GO.add_intersection!(tc, GO.create_node_section(ssa1, 1, k3), GO.create_node_section(ssa2, 1, k3)) + @test length(tc.node_sections) == 2 + + GO.evaluate_nodes!(tc) + @test length(tc.node_sections) == 1 + @test haskey(tc.node_sections, vkey) + @test imstr(pred) == "0F1FFF1F2" +end + +@testset "no merge of distinct crossing points" begin + # The coincidence merge runs whenever crossing keys exist (Task 21: + # JTS merges via its coordinate-keyed node map in every mode), but it + # only merges keys denoting the SAME exact point — the two genuine + # square-boundary crossings here are distinct points and stay distinct. + sq_a = GI.Polygon([[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]) + sq_b = GI.Polygon([[(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0), (1.0, 1.0)]]) + rga, rgb = rgeom(sq_a), rgeom(sq_b) + tc, pred = im_computer(rga, rgb) + @test !GO.is_self_noding_required(tc) + ssa = GO.extract_segment_strings(rga, true, nothing)[1] + ssb = GO.extract_segment_strings(rgb, false, nothing)[1] + # the two genuine boundary crossings of the squares: (1,2) and (2,1) + ia1 = find_seg(ssa, (0.0, 2.0), (2.0, 2.0)) + ib1 = find_seg(ssb, (1.0, 1.0), (1.0, 3.0)) + key1 = GO.crossing_node((0.0, 2.0), (2.0, 2.0), (1.0, 1.0), (1.0, 3.0)) + ia2 = find_seg(ssa, (2.0, 0.0), (2.0, 2.0)) + ib2 = find_seg(ssb, (1.0, 1.0), (3.0, 1.0)) + key2 = GO.crossing_node((2.0, 0.0), (2.0, 2.0), (1.0, 1.0), (3.0, 1.0)) + GO.add_intersection!(tc, GO.create_node_section(ssa, ia1, key1), GO.create_node_section(ssb, ib1, key1)) + GO.add_intersection!(tc, GO.create_node_section(ssa, ia2, key2), GO.create_node_section(ssb, ib2, key2)) + @test length(tc.node_sections) == 2 + GO.evaluate_nodes!(tc) + @test length(tc.node_sections) == 2 + @test imstr(pred) == "212101212" +end + +@testset "short-circuit: known result freezes the predicate" begin + pred = GO.pred_intersects() + tc = GO.TopologyComputer(pred, rgeom(LINE_A), rgeom(LINE_B)) + @test !GO.is_result_known(tc) + GO.add_point_on_point_interior!(tc, (0.0, 0.0)) + @test GO.is_result_known(tc) + @test GO.get_result(tc) + # further updates are no-ops on the determined value + GO.add_point_on_point_exterior!(tc, GO.GEOM_A, (4.0, 0.0)) + GO.add_area_vertex!(tc, true, GO.LOC_BOUNDARY, GO.LOC_EXTERIOR, GO.DIM_A, (0.0, 0.0)) + GO.evaluate_nodes!(tc) + @test GO.is_result_known(tc) && GO.get_result(tc) + + # finish! finalizes an undetermined predicate + pred = GO.pred_intersects() + tc = GO.TopologyComputer(pred, rgeom(LINE_A), rgeom(LINE_B)) + GO.finish!(tc) + @test GO.is_result_known(tc) + @test !GO.get_result(tc) +end + +@testset "unknown target dimension throws ArgumentError" begin + a = GI.Point(0.0, 0.0) + b = GI.Point(1.0, 1.0) + rga = GO.RelateGeometry(GO.Planar(), a; exact = GO.True()) + rgb = GO.RelateGeometry(GO.Planar(), b; exact = GO.True()) + tc = GO.TopologyComputer(GO.pred_intersects(), rga, rgb) + @test_throws ArgumentError GO.add_point_on_geometry!( + tc, true, GO.LOC_INTERIOR, Int8(9), (0.0, 0.0)) +end diff --git a/test/methods/relateng/wkt_util.jl b/test/methods/relateng/wkt_util.jl new file mode 100644 index 0000000000..0719607ee1 --- /dev/null +++ b/test/methods/relateng/wkt_util.jl @@ -0,0 +1,33 @@ +# Shared WKT-loading helper for the relateng test files. +# +# Mirrors `jts_wkt_to_geom` in test/external/jts/jts_testset_reader.jl (which +# keeps its own copy for the XML harness): WellKnownGeometry → GO.tuples for +# plain WKT, with a LibGEOS fallback for WKT that WellKnownGeometry or GI +# wrapper geometries cannot represent: +# +# - WKT containing `EMPTY` (including nested, e.g. +# `GEOMETRYCOLLECTION(POLYGON EMPTY, ...)`), because GI wrapper geometries +# cannot be empty. +# - `GEOMETRYCOLLECTION`, because WellKnownGeometry mis-splits subgeometries +# preceded by whitespace. +# - `LINEARRING`, which WellKnownGeometry does not know at all. +# +# The LibGEOS geometries are GeoInterface-compatible, so consumers (the +# RelateNG engine) access them via GI accessors like any other geometry. + +import WellKnownGeometry +import GeoFormatTypes as GFT +import GeometryOps as GO +import LibGEOS as LG + +function from_wkt(wkt::String) + sanitized_wkt = join(strip.(split(wkt, "\n")), "") + upper_wkt = uppercase(lstrip(sanitized_wkt)) + if occursin("EMPTY", upper_wkt) || + startswith(upper_wkt, "GEOMETRYCOLLECTION") || + startswith(upper_wkt, "LINEARRING") + return LG.readgeom(sanitized_wkt) + end + geom = GFT.WellKnownText(GFT.Geom(), sanitized_wkt) + return GO.tuples(geom) +end diff --git a/test/methods/relateng/xml_harness.jl b/test/methods/relateng/xml_harness.jl new file mode 100644 index 0000000000..eb72a3c897 --- /dev/null +++ b/test/methods/relateng/xml_harness.jl @@ -0,0 +1,101 @@ +using Test + +include(joinpath(@__DIR__, "..", "..", "external", "jts", "jts_testset_reader.jl")) +include(joinpath(@__DIR__, "..", "..", "external", "jts", "relate_runner.jl")) + +const JTS_DATA_DIR = joinpath(@__DIR__, "..", "..", "data", "jts") + +@testset "relate XML parsing" begin + cases = load_test_cases(joinpath(JTS_DATA_DIR, "general", "TestRelatePP.xml")) + @test !isempty(cases) + item = first(first(cases).items) + @test item.operation == "relate" + @test item.expected_result isa Bool # boolean ops parse as Bool now + @test item.pattern isa String && length(item.pattern) == 9 +end + +@testset "run-level metadata" begin + # validate files declare + run = load_test_run(joinpath(JTS_DATA_DIR, "validate", "TestRelateAA.xml")) + @test run.precision_model == "FLOATING" + @test run.n_skipped_ops == 0 + @test !isempty(run.cases) + + # robust files declare a scale => FIXED precision model + run_fixed = load_test_run(joinpath(JTS_DATA_DIR, "validate", "TestRobustRelate.xml")) + @test run_fixed.precision_model == "FIXED" + + # misc files have a run-level and no precisionModel (=> FLOATING default) + run_desc = load_test_run(joinpath(JTS_DATA_DIR, "general", "TestRelateEmpty.xml")) + @test run_desc.precision_model == "FLOATING" + @test run_desc.description isa String && !isempty(run_desc.description) +end + +@testset "cases without and non-boolean ops" begin + # TestBoundary cases have only an geometry and a unary getboundary op + # whose expected result is a geometry. + cases = load_test_cases(joinpath(JTS_DATA_DIR, "general", "TestBoundary.xml")) + @test !isempty(cases) + case = first(cases) + @test case.geom_b === nothing + item = first(case.items) + @test item.operation == "getboundary" + @test item.arg2 === nothing + @test item.pattern === nothing + @test !(item.expected_result isa Bool) # parses as a geometry +end + +@testset "every vendored relate file parses" begin + for dir in ("general", "validate"), file in readdir(joinpath(JTS_DATA_DIR, dir)) + endswith(file, ".xml") || continue + run = load_test_run(joinpath(JTS_DATA_DIR, dir, file)) + @test !isempty(run.cases) + @test run.n_skipped_ops == 0 + @test all(c -> !isempty(c.items), run.cases) + end +end + +@testset "relate runner skiplist machinery" begin + # Smoke-test the runner shape with every op skipped and always-throwing + # closures: nothing should execute, everything should be recorded. + file = joinpath(JTS_DATA_DIR, "general", "TestRelatePP.xml") + cases = load_test_cases(file) + skiplist = Set( + (basename(file), case_index, i.operation, i.arg1 === c.geom_a ? "AB" : "BA") + for (case_index, c) in enumerate(cases) for i in c.items) + boom(args...) = error("must not be called while everything is skipped") + summary = run_relate_cases(boom, boom, Dict{String, Function}(), [file]; skiplist) + @test length(summary.per_file) == 1 + stats = only(summary.per_file) + @test stats.file == basename(file) + @test stats.n_pass == 0 && stats.n_fail == 0 + @test stats.n_skip == sum(c -> length(c.items), cases) + @test length(summary.skipped) == stats.n_skip + @test all(s -> s.reason == "in skiplist", summary.skipped) +end + +@testset "relate runner execute path" begin + # Exercise the runner's execute path with stub implementations (not a real + # engine, so we only assert count consistency, not specific pass counts — + # except that TestRelatePP's ops all expect `true`, which the stubs return, + # so no @test inside the runner fails and pollutes this testset). + file = joinpath(JTS_DATA_DIR, "general", "TestRelatePP.xml") + cases = load_test_cases(file) + n_ops = sum(c -> length(c.items), cases) + # `check_matrix = false` so the stub `relate_fn` is never called (the full + # matrix consistency check only makes sense against a real engine; the + # real-engine run lives in xml_suite.jl). + relate_fn = (a, b) -> error("relate_fn must not be called when check_matrix = false") + pattern_fn = (a, b, p) -> true + predicate_fns = Dict{String, Function}(op => ((a, b) -> true) for op in BOOLEAN_OPS if op != "relate") + summary = run_relate_cases(relate_fn, pattern_fn, predicate_fns, [file]; check_matrix = false) + stats = only(summary.per_file) + @test stats.file == basename(file) + # nothing skipped: FLOATING precision, all ops boolean, all predicates provided + @test stats.n_skip == 0 + @test isempty(summary.skipped) + # every boolean op executed exactly once... + @test stats.n_pass + stats.n_fail == n_ops + # ...so every op of every case was visited (executed or skipped) + @test stats.n_pass + stats.n_fail + stats.n_skip == n_ops +end diff --git a/test/methods/relateng/xml_suite.jl b/test/methods/relateng/xml_suite.jl new file mode 100644 index 0000000000..318db760b9 --- /dev/null +++ b/test/methods/relateng/xml_suite.jl @@ -0,0 +1,103 @@ +# Full JTS relate XML conformance suite (Task 23): every vendored case in +# test/data/jts/{general,validate} run against the real RelateNG engine. +# The parser/runner machinery itself is unit-tested in xml_harness.jl; this +# file is the conformance run. +# +# Every executed op contributes one `@test` inside `run_relate_cases`. On top +# of that, the expected pass/skip counts per file are pinned below so that an +# accidental mass-skip (e.g. a parser regression silently dropping ops) fails +# loudly instead of shrinking the suite. +# +# Runtime: the suite itself is fast (~0.5 s warm for all 6458 ops); the +# dominant cost is one-time compilation of the engine for the LibGEOS-backed +# geometry types used by EMPTY/GC cases (~10 min cold on an M-series laptop, +# paid once per test process and shared with the other relateng test files). + +using Test +import GeometryOps as GO + +include(joinpath(@__DIR__, "..", "..", "external", "jts", "jts_testset_reader.jl")) +include(joinpath(@__DIR__, "..", "..", "external", "jts", "relate_runner.jl")) + +const JTS_DATA_DIR = joinpath(@__DIR__, "..", "..", "data", "jts") + +const RELATENG = GO.RelateNG() + +relateng_relate(a, b) = GO.relate(RELATENG, a, b) +relateng_pattern(a, b, pattern) = GO.relate(RELATENG, a, b, pattern) + +# Predicate factories return fresh mutable predicate state, so each call +# constructs its own predicate. `equals` (used by some JTS files) is JTS +# `equalsTopo`. +const RELATENG_PREDICATE_FACTORIES = Dict{String, Function}( + "intersects" => GO.pred_intersects, + "disjoint" => GO.pred_disjoint, + "contains" => GO.pred_contains, + "within" => GO.pred_within, + "covers" => GO.pred_covers, + "coveredby" => GO.pred_coveredby, + "crosses" => GO.pred_crosses, + "touches" => GO.pred_touches, + "overlaps" => GO.pred_overlaps, + "equalstopo" => GO.pred_equalstopo, + "equals" => GO.pred_equalstopo, +) + +relateng_predicate_fns() = Dict{String, Function}( + name => ((a, b) -> GO.relate_predicate(RELATENG, factory(), a, b)) + for (name, factory) in RELATENG_PREDICATE_FACTORIES) + +_xml_files(dir) = sort!(filter!(f -> endswith(f, ".xml"), + readdir(joinpath(JTS_DATA_DIR, dir); join = true))) + +# Expected (pass, skip) counts per file, pinned as of Task 23. +# Skips are: TestBoundary's 12 unary geometry-valued `getboundary` ops +# (not relate ops at all — the file has no with a boundary +# rule, just boundary construction cases); TestRobustRelate's single op (the +# run declares a FIXED precision model, which assumes snapped coordinates); +# and the 2 documented skiplist entries (see test/external/jts/relate_skiplist.jl). +const EXPECTED_COUNTS = [ + ("general", "TestBoundary.xml", 0, 12), + ("general", "TestRelateAA.xml", 41, 0), + ("general", "TestRelateEmpty.xml", 572, 0), + ("general", "TestRelateGC.xml", 328, 0), + ("general", "TestRelateLA.xml", 13, 0), + ("general", "TestRelateLL.xml", 45, 0), + ("general", "TestRelatePA.xml", 121, 0), + ("general", "TestRelatePL.xml", 8, 0), + ("general", "TestRelatePP.xml", 4, 0), + ("validate", "TestRelateAA-big.xml", 2, 0), + ("validate", "TestRelateAA.xml", 1177, 0), + ("validate", "TestRelateAC.xml", 11, 0), + ("validate", "TestRelateLA.xml", 847, 0), + ("validate", "TestRelateLC.xml", 22, 0), + ("validate", "TestRelateLL.xml", 1584, 0), + ("validate", "TestRelatePA.xml", 451, 0), + ("validate", "TestRelatePL.xml", 1088, 1), + ("validate", "TestRelatePP.xml", 143, 0), + ("validate", "TestRobustRelate.xml", 0, 1), + ("validate", "TestRobustRelateFloat.xml", 1, 1), +] + +@testset "JTS relate XML conformance suite" begin + for dir in ("general", "validate") + files = _xml_files(dir) + expected = [(f, np, ns) for (d, f, np, ns) in EXPECTED_COUNTS if d == dir] + @test basename.(files) == first.(expected) + @testset "$dir" begin + summary = run_relate_cases(relateng_relate, relateng_pattern, + relateng_predicate_fns(), files) + @test sum(s -> s.n_fail, summary.per_file) == 0 + for (s, (file, n_pass, n_skip)) in zip(summary.per_file, expected) + @test s.file == file + @test (s.file, s.n_pass) == (file, n_pass) + @test (s.file, s.n_skip) == (file, n_skip) + end + # Every skip is one of the three accounted-for kinds. + for sk in summary.skipped + @test sk.reason in ("in skiplist", "non-boolean op", + "non-FLOATING precision model (FIXED)") + end + end + end +end diff --git a/test/runtests.jl b/test/runtests.jl index f67aceee5f..55f9cc8a9e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -32,8 +32,10 @@ end @safetestset "Convex Hull" begin include("methods/convex_hull.jl") end @safetestset "Voronoi" begin include("methods/voronoi.jl") end @safetestset "DE-9IM Geom Relations" begin include("methods/geom_relations.jl") end +@safetestset "RelateNG" begin include("methods/relateng/runtests.jl") end @safetestset "Distance" begin include("methods/distance.jl") end @safetestset "Equals" begin include("methods/equals.jl") end +@safetestset "Extent" begin include("methods/extent.jl") end @safetestset "Minimum Bounding Circle" begin include("methods/minimum_bounding_circle.jl") end @safetestset "Polygonize" begin include("methods/polygonize.jl") end # Clipping @@ -54,6 +56,8 @@ end @safetestset "Geometry Correction" begin include("transformations/correction/geometry_correction.jl") end @safetestset "Closed Rings" begin include("transformations/correction/closed_ring.jl") end @safetestset "Intersecting Polygons" begin include("transformations/correction/intersecting_polygons.jl") end +@safetestset "Antipodal Edge Split" begin include("transformations/correction/antipodal_edge_split.jl") end +@safetestset "Crossing Edge Split" begin include("transformations/correction/crossing_edge_split.jl") end # Extensions @safetestset "FlexiJoins" begin include("extensions/flexijoins.jl") end @safetestset "LibGEOS" begin include("extensions/libgeos.jl") end diff --git a/test/transformations/correction/antipodal_edge_split.jl b/test/transformations/correction/antipodal_edge_split.jl new file mode 100644 index 0000000000..1e94d5a0c1 --- /dev/null +++ b/test/transformations/correction/antipodal_edge_split.jl @@ -0,0 +1,33 @@ +using Test +import GeoInterface as GI +import GeometryOps as GO +import GeometryOps: Spherical, RelateNG + +# The edge (0,0)→(180,0) maps to the antipodal unit vectors (1,0,0) and +# (-1,0,0), which the spherical kernel rejects. +antipodal_poly = GI.Polygon([GI.LinearRing([(0., 0.), (180., 0.), (90., 80.), (0., 0.)])]) +clean_poly = GI.Polygon([GI.LinearRing([(0., 0.), (10., 0.), (10., 10.), (0., 10.), (0., 0.)])]) + +@testset "AntipodalEdgeSplit" begin + alg = RelateNG(; manifold = Spherical()) + + #-- without correction the spherical kernel refuses the antipodal edge + @test_throws ArgumentError GO.relate(alg, antipodal_poly, GI.Point(10., 10.)) + + #-- the correction inserts the lon/lat midpoint (90, 0): one extra vertex + fixed = GO.AntipodalEdgeSplit()(antipodal_poly) + @test GI.npoint(fixed) == GI.npoint(antipodal_poly) + 1 + pts = [(GI.x(p), GI.y(p)) for p in GI.getpoint(GI.getexterior(fixed))] + @test (90.0, 0.0) in pts + + #-- after correction the relate runs and is correct (the big triangle + #-- north of the equator contains the near-equator point) + @test GO.relate(alg, fixed, GI.Point(10., 10.), "T*****FF*") + + #-- also reachable through `fix` + @test GI.npoint(GO.fix(antipodal_poly; corrections = [GO.AntipodalEdgeSplit()])) == + GI.npoint(antipodal_poly) + 1 + + #-- a geometry with no antipodal edge keeps its vertices + @test GI.npoint(GO.AntipodalEdgeSplit()(clean_poly)) == GI.npoint(clean_poly) +end diff --git a/test/transformations/correction/crossing_edge_split.jl b/test/transformations/correction/crossing_edge_split.jl new file mode 100644 index 0000000000..8587b0149c --- /dev/null +++ b/test/transformations/correction/crossing_edge_split.jl @@ -0,0 +1,88 @@ +using Test +import GeoInterface as GI +import GeometryOps as GO +import GeometryOps: Spherical, RelateNG + +# A ring that self-intersects on the sphere: an explicit figure-eight whose +# diagonals cross at (0, 0). `prepare` on the Spherical manifold rejects it; +# `CrossingEdgeSplit` is the documented remedy. +bowtie_pts = [(-10., -10.), (10., 10.), (10., -10.), (-10., 10.), (-10., -10.)] +bowtie = GI.Polygon([GI.LinearRing(bowtie_pts)]) +clean_poly = GI.Polygon([GI.LinearRing([(0., 0.), (10., 0.), (10., 10.), (0., 10.), (0., 0.)])]) + +salg = RelateNG(; manifold = Spherical()) + +@testset "CrossingEdgeSplit" begin + @testset "bowtie splits into two shells with even-odd containment" begin + for pts in (bowtie_pts, reverse(bowtie_pts)) + poly = GI.Polygon([GI.LinearRing(pts)]) + @test_throws ArgumentError GO.prepare(salg, poly) + fixed = GO.CrossingEdgeSplit()(poly) + @test GI.trait(fixed) isa GI.MultiPolygonTrait + @test GI.ngeom(fixed) == 2 + #-- each lobe is a triangle: the crossing vertex plus two originals + @test sort([GI.npoint(g) for g in GI.getgeom(fixed)]) == [4, 4] + #-- the constructed crossing vertex sits at the diagonals' crossing + allpts = collect(GI.getpoint(GI.getexterior(first(GI.getgeom(fixed))))) + @test any(p -> abs(GI.x(p)) < 1e-9 && abs(GI.y(p)) < 1e-9, allpts) + #-- the repair validates clean + @test GO.prepare(salg, fixed) isa GO.PreparedRelate + #-- containment matches even-odd truth in both lobes and outside + @test GO.intersects(salg, fixed, GI.Point(8., 1.)) + @test GO.intersects(salg, fixed, GI.Point(-8., -1.)) + @test !GO.intersects(salg, fixed, GI.Point(0., 5.)) + @test !GO.intersects(salg, fixed, GI.Point(0., -5.)) + @test !GO.intersects(salg, fixed, GI.Point(100., 40.)) + end + end + + @testset "valid geometry is returned unchanged (identity, no copy)" begin + @test GO.CrossingEdgeSplit()(clean_poly) === clean_poly + mp = GI.MultiPolygon([clean_poly]) + @test GO.CrossingEdgeSplit()(mp) === mp + end + + @testset "fix() integration" begin + fixed = GO.fix(bowtie; corrections = [GO.CrossingEdgeSplit()]) + @test GI.trait(fixed) isa GI.MultiPolygonTrait + @test GI.ngeom(fixed) == 2 + end + + @testset "holes are assigned to their enclosing shell loop" begin + holed = GI.Polygon([ + GI.LinearRing(bowtie_pts), + GI.LinearRing([(7., -1.), (9., -1.), (9., 1.), (7., 1.), (7., -1.)]), + ]) + fixed = GO.CrossingEdgeSplit()(holed) + @test GI.trait(fixed) isa GI.MultiPolygonTrait + @test GI.ngeom(fixed) == 2 + @test sort([GI.nring(g) for g in GI.getgeom(fixed)]) == [1, 2] + @test GO.prepare(salg, fixed) isa GO.PreparedRelate + @test !GO.intersects(salg, fixed, GI.Point(8., 0.)) # inside the hole + @test GO.intersects(salg, fixed, GI.Point(9.5, 0.)) # lobe, past the hole + @test GO.intersects(salg, fixed, GI.Point(-8., -1.)) # the unholed lobe + end + + @testset "a self-crossing hole splits into two hole loops" begin + poly = GI.Polygon([ + GI.LinearRing([(-20., -20.), (20., -20.), (20., 20.), (-20., 20.), (-20., -20.)]), + GI.LinearRing(bowtie_pts), + ]) + fixed = GO.CrossingEdgeSplit()(poly) + @test GI.trait(fixed) isa GI.PolygonTrait # the shell did not split + @test GI.nring(fixed) == 3 + @test GO.prepare(salg, fixed) isa GO.PreparedRelate + @test !GO.intersects(salg, fixed, GI.Point(8., 1.)) # in a hole lobe + @test !GO.intersects(salg, fixed, GI.Point(-8., -1.)) # in the other lobe + @test GO.contains(salg, fixed, GI.Point(0., 5.)) # between the lobes + @test GO.contains(salg, fixed, GI.Point(-15., 0.)) # ordinary interior + end + + @testset "tangled multi-crossing topology throws instead of guessing" begin + #-- a pentagram: every edge properly crosses two others + star_angles = (90., 234., 18., 162., 306.) + star = [(10cosd(a), 10sind(a)) for a in star_angles] + pentagram = GI.Polygon([GI.LinearRing([star; [star[1]]])]) + @test_throws ArgumentError GO.CrossingEdgeSplit()(pentagram) + end +end diff --git a/test/types.jl b/test/types.jl index 5622d3beb5..efc3abaff9 100644 --- a/test/types.jl +++ b/test/types.jl @@ -5,6 +5,17 @@ using GeometryOpsCore using GeometryOps: Planar, Spherical, AutoManifold, rebuild, manifold, best_manifold, enforce, get using GeometryOps: CLibraryPlanarAlgorithm, GEOS, TG, PROJ +@testset "Spherical manifold" begin + # keyword construction, with the polygon-interior mode defaulting to + # unoriented (enclosed-region) semantics + @test Spherical() isa Spherical{Float64} + @test Spherical().oriented == false + @test Spherical(; radius = 1.0).radius == 1.0 + @test Spherical(; radius = 1.0).oriented == false + @test Spherical(; oriented = true).oriented == true + @test Spherical(; oriented = true).radius == Spherical().radius +end + @testset "CLibraryPlanarAlgorithm" begin # Test that it's a subtype of SingleManifoldAlgorithm{Planar} @test CLibraryPlanarAlgorithm <: GeometryOpsCore.SingleManifoldAlgorithm{Planar} diff --git a/test/utils/FlexibleRTrees.jl b/test/utils/FlexibleRTrees.jl index 58a57d17c9..31d66ca619 100644 --- a/test/utils/FlexibleRTrees.jl +++ b/test/utils/FlexibleRTrees.jl @@ -69,6 +69,35 @@ end @test occursin("RTree{HPR}", sprint(show, gtree)) end +@testset "payload data and precomputed extents" begin + rng = Xoshiro(11) + extents = random_extents(rng, 100, 2) + # payload elements with no extent of their own, indexed by the `extents` kwarg + payloads = [(i, 100 - i) for i in 1:100] + tree = @inferred RTree(STR(), payloads; extents) + # query hits are indices into the payload vector, whatever the leaf order + for q in random_extents(rng, 10, 2) + hits = query(tree, q) + @test hits == brute_force(q, extents) + @test all(tree.data[i] == (i, 100 - i) for i in hits) + end + @test tree.data === payloads # vectors are kept, not copied + + # Unsorted is zero-copy: the extents vector IS the leaf level + utree = RTree(Unsorted(), payloads; extents) + @test utree.levels[end] === extents + @test utree.indices isa Base.OneTo + @test query(utree, extents[7]) == brute_force(extents[7], extents) + + # without the kwarg, `data` is still kept (here the extents themselves) + @test RTree(HPR(), extents).data === extents + # non-vector input is collected so `tree.data[i]` works + gtree = RTree(Unsorted(), (e for e in extents)) + @test gtree.data isa Vector && gtree.data[3] == extents[3] + + @test_throws ArgumentError RTree(STR(), payloads; extents = extents[1:99]) +end + @testset "Hilbert curve properties" begin # Order-1 2D curve: the classic U through the four quadrants. keys1 = [hilbert_key((UInt32(x), UInt32(y)), 1) for (x, y) in ((0, 0), (0, 1), (1, 1), (1, 0))] diff --git a/test/utils/unitspherical.jl b/test/utils/unitspherical.jl index 9a0fe41f15..890b8ebf4b 100644 --- a/test/utils/unitspherical.jl +++ b/test/utils/unitspherical.jl @@ -587,3 +587,49 @@ end end end end + +@testset "spherical_arc_extent" begin + # Quarter arc along the equator: x and y peak at the endpoints, z is flat + ext = spherical_arc_extent(UnitSphericalPoint(1.0, 0.0, 0.0), UnitSphericalPoint(0.0, 1.0, 0.0)) + @test ext isa Extents.Extent{(:X, :Y, :Z)} + @test ext.X[1] ≈ 0 atol = 1e-14 + @test ext.X[2] ≈ 1 atol = 1e-14 + @test ext.Y[1] ≈ 0 atol = 1e-14 + @test ext.Y[2] ≈ 1 atol = 1e-14 + @test ext.Z[1] ≈ 0 atol = 1e-14 + @test ext.Z[2] ≈ 0 atol = 1e-14 + + # Two points at z = 0.9 joined over the pole: the arc reaches z = 1 + z = 0.9; s = sqrt(1 - z^2) + bulging = spherical_arc_extent(UnitSphericalPoint(0.0, -s, z), UnitSphericalPoint(0.0, s, z)) + @test bulging.Z[2] ≈ 1 atol = 1e-14 + @test bulging.Z[2] > z + @test bulging.Z[1] ≈ z atol = 1e-14 + @test bulging.Y[1] ≈ -s atol = 1e-14 + @test bulging.Y[2] ≈ s atol = 1e-14 + + # Geographic (lon, lat) input + @test spherical_arc_extent((0.0, 0.0), (90.0, 0.0)) == + spherical_arc_extent(UnitSphericalPoint(1.0, 0.0, 0.0), UnitSphericalPoint(0.0, 1.0, 0.0)) + + # Degenerate arc: a point + p = UnitSphericalPoint(1.0, 0.0, 0.0) + degenerate = spherical_arc_extent(p, p) + @test degenerate.X[1] <= 1 <= degenerate.X[2] + @test degenerate.X[2] - degenerate.X[1] < 1e-14 + + @testset "Containment of dense arc samples" begin + using Random: Xoshiro + rng = Xoshiro(999) + inext(q, e) = e.X[1] <= q[1] <= e.X[2] && e.Y[1] <= q[2] <= e.Y[2] && e.Z[1] <= q[3] <= e.Z[2] + for _ in 1:100 + a, b = rand(rng, UnitSphericalPoint{Float64}), rand(rng, UnitSphericalPoint{Float64}) + e = spherical_arc_extent(a, b) + @test all(t -> inext(slerp(a, b, t), e), range(0.0, 1.0, length = 101)) + # nearly-degenerate arc + b2 = UnitSphericalPoint(normalize(a + 1e-12 * rand(rng, UnitSphericalPoint{Float64}))) + e2 = spherical_arc_extent(a, b2) + @test all(t -> inext(slerp(a, b2, t), e2), range(0.0, 1.0, length = 11)) + end + end +end diff --git a/test/utils/utils.jl b/test/utils/utils.jl index 79ddf4e301..cf3d9f02d2 100644 --- a/test/utils/utils.jl +++ b/test/utils/utils.jl @@ -2,6 +2,7 @@ using Test import GeometryOps as GO, GeoInterface as GI import Extents +using GeometryOps.UnitSpherical point = GI.Point(1.0, 1.0) linestring = GI.LineString([(1.0, 1.0), (2.0, 2.0)]) @@ -190,3 +191,38 @@ end @test_throws ArgumentError collect(GO.lazy_edge_extents(GI.MultiPoint([(1.0, 1.0), (2.0, 2.0)]))) end + +@testset "eachedge and to_edgelist on the sphere" begin + lonlat_ring = GI.LinearRing([(0.0, 0.0), (90.0, 0.0), (0.0, 90.0), (0.0, 0.0)]) + + edges = collect(GO.eachedge(GO.Spherical(), lonlat_ring, Float64)) + @test length(edges) == 3 + @test all(ps -> ps isa Tuple{UnitSphericalPoint{Float64}, UnitSphericalPoint{Float64}}, edges) + # Planar() matches the manifold-less form + @test collect(GO.eachedge(GO.Planar(), lonlat_ring, Float64)) == collect(GO.eachedge(lonlat_ring, Float64)) + + el = GO.to_edgelist(GO.Spherical(), lonlat_ring) + @test length(el) == 3 + @test all(l -> GI.extent(l) isa Extents.Extent{(:X, :Y, :Z)}, el) + # rings of UnitSphericalPoints take the spherical path with no manifold argument + usp_ring = GI.LinearRing(to_unit_spherical_points(lonlat_ring)) + @test GI.extent.(GO.to_edgelist(usp_ring)) == GI.extent.(el) + # the lazy variant agrees + @test GI.extent.(collect(GO.lazy_edgelist(GO.Spherical(), lonlat_ring))) == GI.extent.(el) + + # spatial trees over spherical edges index in 3D + ni = GO.NaturalIndexing.NaturalIndex(el) + rt = GO.FlexibleRTrees.RTree(GO.FlexibleRTrees.STR(), el) + @test Extents.extent(ni) isa Extents.Extent{(:X, :Y, :Z)} + @test Extents.extent(rt) isa Extents.Extent{(:X, :Y, :Z)} + + # An edge between two points at z = 0.9 arcs over the pole; a query box + # touching only the polar region must still find it + z = 0.9; s = sqrt(1 - z^2) + seg = GI.LineString([UnitSphericalPoint(0.0, -s, z), UnitSphericalPoint(0.0, s, z)]) + tree = GO.FlexibleRTrees.RTree(GO.FlexibleRTrees.STR(), GO.to_edgelist(seg)) + bulge_only = Extents.Extent(X = (-0.01, 0.01), Y = (-0.01, 0.01), Z = (0.95, 1.05)) + @test GO.FlexibleRTrees.query(tree, bulge_only) == [1] + endpoint_box = Extents.union(GI.extent(UnitSphericalPoint(0.0, -s, z)), GI.extent(UnitSphericalPoint(0.0, s, z))) + @test !Extents.intersects(endpoint_box, bulge_only) +end