diff --git a/geomesa-trino/geomesa-trino-plugin/pom.xml b/geomesa-trino/geomesa-trino-plugin/pom.xml index 5f385afe893..fa737d7ff89 100644 --- a/geomesa-trino/geomesa-trino-plugin/pom.xml +++ b/geomesa-trino/geomesa-trino-plugin/pom.xml @@ -59,10 +59,6 @@ org.locationtech.jts jts-core - - org.scala-lang - scala-library - org.locationtech.geomesa geomesa-z3_${scala.binary.version} @@ -169,34 +165,6 @@ - - net.alchim31.maven - scala-maven-plugin - - ${scala.version} - ${scala.binary.version} - ${jdk.version} - ${jdk.version} - false - - - - scala-compile-first - process-resources - - add-source - compile - - - - scala-test-compile - process-test-resources - - testCompile - - - - org.apache.maven.plugins maven-shade-plugin diff --git a/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/connector/SpatialConnectorMetadata.java b/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/connector/SpatialConnectorMetadata.java index be02e0f5a1c..e3390483752 100644 --- a/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/connector/SpatialConnectorMetadata.java +++ b/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/connector/SpatialConnectorMetadata.java @@ -29,6 +29,7 @@ import io.trino.spi.type.RealType; import io.trino.spi.type.RowType; import io.trino.spi.type.VarcharType; +import org.locationtech.geomesa.curve.interop.SpaceFillingCurves.HexRange; import org.locationtech.geomesa.trino.spatial.iceberg.transforms.SpatialIndexRanges; import org.locationtech.geomesa.trino.spatial.iceberg.BboxHandles; import org.locationtech.geomesa.trino.spatial.iceberg.GeoMesaColumnCatalog; @@ -243,16 +244,16 @@ public Optional> applyFilter( private static List buildPartitionRanges(SpatialPartitionHandle sp, List envelopes) { List ranges = new ArrayList<>(); for (Envelope env : envelopes) { - List hexRanges = switch (sp.kind()) { + List hexRanges = switch (sp.kind()) { case Z2 -> SpatialIndexRanges.z2Ranges(env); case XZ2 -> SpatialIndexRanges.xz2Ranges(env); }; - for (String[] r : hexRanges) { - if (r[0].equals(r[1])) { - ranges.add(Range.equal(VarcharType.VARCHAR, Slices.utf8Slice(r[0]))); + for (HexRange r : hexRanges) { + if (r.lower().equals(r.upper())) { + ranges.add(Range.equal(VarcharType.VARCHAR, Slices.utf8Slice(r.lower()))); } else { ranges.add(Range.range(VarcharType.VARCHAR, - Slices.utf8Slice(r[0]), true, Slices.utf8Slice(r[1]), true)); + Slices.utf8Slice(r.lower()), true, Slices.utf8Slice(r.upper()), true)); } } } diff --git a/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRanges.java b/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRanges.java index cb8e42e09c4..86122f090bb 100644 --- a/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRanges.java +++ b/geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRanges.java @@ -8,21 +8,22 @@ package org.locationtech.geomesa.trino.spatial.iceberg.transforms; +import org.locationtech.geomesa.curve.interop.SpaceFillingCurves; +import org.locationtech.geomesa.curve.interop.SpaceFillingCurves.HexRange; import org.locationtech.jts.geom.Envelope; -import java.util.Arrays; import java.util.List; /** - * Hex-encoded Z2/XZ2 index ranges covering a query envelope, for pushdown + * Hex-encoded Z2/XZ2 index ranges covering a JTS query envelope, for pushdown * against hex-partitioned storage columns ({@code ___z2__} / * {@code ___xz2__}). * *

Endpoints are encoded with {@code Z2SFC.hexEncode}/{@code XZ2SFC.hexEncode} - * (via {@link SfcBridge}), which left-align the significant bits so + * (via {@link SpaceFillingCurves}), which left-align the significant bits so * lexicographic comparison over the fixed-width strings matches numeric * comparison of the underlying index values. That makes the inclusive - * {@code [lo, hi]} string ranges directly usable both as VARCHAR domains on + * {@code [lo, hi]} ranges directly usable both as VARCHAR domains on * the full column value and — because prefix order is preserved — under * Iceberg {@code truncate(width)} partition projection at any width. */ @@ -41,26 +42,26 @@ public final class SpatialIndexRanges { private SpatialIndexRanges() {} /** - * Z2 index ranges covering the query envelope, hex-encoded for pushdown + * Z2 index ranges covering a JTS query envelope, hex-encoded for pushdown * against a {@code ___z2__} column. * * @param env query envelope in WGS84 lon/lat - * @return inclusive {@code [lo, hi]} hex ranges + * @return inclusive hex ranges */ - public static List z2Ranges(Envelope env) { - return Arrays.asList(SfcBridge.z2HexRanges( - env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY(), MAX_RANGES)); + public static List z2Ranges(Envelope env) { + return SpaceFillingCurves.z2HexRanges( + env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY(), MAX_RANGES); } /** - * XZ2 index ranges covering the query envelope at {@link #G}, hex-encoded + * XZ2 index ranges covering a JTS query envelope at {@link #G}, hex-encoded * for pushdown against a {@code ___xz2__} column. * * @param env query envelope in WGS84 lon/lat - * @return inclusive {@code [lo, hi]} hex ranges + * @return inclusive hex ranges */ - public static List xz2Ranges(Envelope env) { - return Arrays.asList(SfcBridge.xz2HexRanges( - env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY(), G, MAX_RANGES)); + public static List xz2Ranges(Envelope env) { + return SpaceFillingCurves.xz2HexRanges( + env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY(), G, MAX_RANGES); } } diff --git a/geomesa-trino/geomesa-trino-plugin/src/main/scala/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SfcBridge.scala b/geomesa-trino/geomesa-trino-plugin/src/main/scala/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SfcBridge.scala deleted file mode 100644 index 6087d85ef0c..00000000000 --- a/geomesa-trino/geomesa-trino-plugin/src/main/scala/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SfcBridge.scala +++ /dev/null @@ -1,93 +0,0 @@ -/*********************************************************************** - * Copyright (c) 2013-2025 General Atomics Integrated Intelligence, Inc. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Apache License, Version 2.0 - * which accompanies this distribution and is available at - * https://www.apache.org/licenses/LICENSE-2.0 - ***********************************************************************/ - -package org.locationtech.geomesa.trino.spatial.iceberg.transforms - -import org.locationtech.geomesa.curve.{XZ2SFC, Z2SFC} - -/** - * Java-friendly facade over GeoMesa's Z2SFC and XZ2SFC space-filling curves. - * - * Java code cannot reference Scala's package-object types whose binary class - * names start with the `package` keyword (`package$IndexRange`, `package$ZRange` - * in `org.locationtech.geomesa.zorder.sfcurve`) — that's a JLS reserved-word - * conflict. This bridge keeps all such references on the Scala side and - * returns plain `Array[Array[Long]]` to Java callers. - * - * Bridge methods take no Scala default arguments; all parameters are explicit. - * - * Hex outputs delegate to `Z2SFC.hexEncode`/`XZ2SFC.hexEncode`, which - * left-align the significant bits so lexicographic (truncate-prefix) - * comparison over the encoded strings matches numeric comparison of the - * underlying index values — the same encoding GeoMesa writers use for - * hex-partitioned storage columns. - */ -object SfcBridge { - private def clampLon(x: Double): Double = math.min(180.0, math.max(-180.0, x)) - private def clampLat(y: Double): Double = math.min(90.0, math.max(-90.0, y)) - - /** - * Z2 cell index for a single (lon, lat) point at the default Z2SFC precision - * (31 bits/axis, 62-bit positive Long). - */ - def z2Index(lon: Double, lat: Double): Long = - Z2SFC.index(clampLon(lon), clampLat(lat), lenient = false) - - /** - * Z2 cell value for a single (lon, lat) point, hex-encoded via - * `Z2SFC.hexEncode` (left-aligned so truncate-prefix matching works). - */ - def z2Hex(lon: Double, lat: Double): String = - Z2SFC.hexEncode(z2Index(lon, lat)) - - /** - * Z2 index ranges covering the query envelope, hex-encoded via - * `Z2SFC.hexEncode`. Returns an array of inclusive `[lower, upper]` pairs; - * stored values produced by `z2Hex` compare lexicographically within them. - * - * @param maxRanges rough upper bound on the number of ranges returned; the SFC - * coarsens (merges) past it, so the cover remains a superset of - * the envelope — pruning gets less selective, never lossy - */ - def z2HexRanges(xMin: Double, yMin: Double, xMax: Double, yMax: Double, maxRanges: Int): Array[Array[String]] = - Z2SFC.ranges((clampLon(xMin), clampLon(xMax)), (clampLat(yMin), clampLat(yMax)), 64, Some(maxRanges)).iterator - .map(r => Array(Z2SFC.hexEncode(r.lower), Z2SFC.hexEncode(r.upper))) - .toArray - - /** - * XZ2 cell index for a geometry's envelope at the given `g` resolution. - * Returns the sequence-code Long produced by XZ2SFC(g).index. - */ - def xz2Index(xMin: Double, yMin: Double, xMax: Double, yMax: Double, g: Short): Long = - XZ2SFC(g).index(clampLon(xMin), clampLat(yMin), clampLon(xMax), clampLat(yMax), lenient = false) - - /** - * XZ2 cell value for a geometry's envelope at the given `g` resolution, - * hex-encoded via `XZ2SFC.hexEncode` (bit-shifted left so the significant - * bits are left-aligned and truncate-prefix matching works). - */ - def xz2Hex(xMin: Double, yMin: Double, xMax: Double, yMax: Double, g: Short): String = - XZ2SFC(g).hexEncode(xz2Index(xMin, yMin, xMax, yMax, g)) - - /** - * XZ2 index ranges covering the query envelope at the given `g` resolution, - * hex-encoded via `XZ2SFC.hexEncode`. Returns an array of inclusive - * `[lower, upper]` pairs; stored values produced by `xz2Hex` compare - * lexicographically within them. - * - * @param maxRanges rough upper bound on the number of ranges returned; the SFC - * coarsens (merges) past it, so the cover remains a superset of - * the envelope — pruning gets less selective, never lossy - */ - def xz2HexRanges(xMin: Double, yMin: Double, xMax: Double, yMax: Double, g: Short, maxRanges: Int): Array[Array[String]] = { - val sfc = XZ2SFC(g) - sfc.ranges((clampLon(xMin), clampLat(yMin), clampLon(xMax), clampLat(yMax)), Some(maxRanges)).iterator - .map(r => Array(sfc.hexEncode(r.lower), sfc.hexEncode(r.upper))) - .toArray - } -} diff --git a/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRangesTest.java b/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRangesTest.java index 6d6337faaa3..5bfd5dff8c7 100644 --- a/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRangesTest.java +++ b/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/SpatialIndexRangesTest.java @@ -16,6 +16,8 @@ import org.apache.iceberg.transforms.UnknownTransform; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; +import org.locationtech.geomesa.curve.interop.SpaceFillingCurves; +import org.locationtech.geomesa.curve.interop.SpaceFillingCurves.HexRange; import org.locationtech.jts.geom.Envelope; import java.nio.ByteBuffer; @@ -24,7 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Behavioral tests for {@link SpatialIndexRanges} and the {@link SfcBridge} + * Behavioral tests for {@link SpatialIndexRanges} and the {@link SpaceFillingCurves} * hex encodings. Stored values and range endpoints delegate to upstream * GeoMesa {@code Z2SFC.hexEncode}/{@code XZ2SFC.hexEncode}, which left-align * the significant bits so lexicographic (and truncate-prefix) comparison @@ -37,9 +39,9 @@ class SpatialIndexRangesTest { private static final long Z2_MAX_62 = (1L << 62) - 1L; - private static boolean covered(List ranges, String stored) { + private static boolean covered(List ranges, String stored) { return ranges.stream().anyMatch(r -> - stored.compareTo(r[0]) >= 0 && stored.compareTo(r[1]) <= 0); + stored.compareTo(r.lower()) >= 0 && stored.compareTo(r.upper()) <= 0); } // ── Z2 ──────────────────────────────────────────────────────────────── @@ -50,39 +52,39 @@ void z2IndexOutputsAreNonNegativeAnd62BitBounded() { double[] lats = { -89.999, 38.9, 0.0, 35.7, 89.999}; for (double lon : lons) { for (double lat : lats) { - assertThat(SfcBridge.z2Index(lon, lat)).isBetween(0L, Z2_MAX_62); + assertThat(SpaceFillingCurves.z2Index(lon, lat)).isBetween(0L, Z2_MAX_62); } } } @Test void z2IndexClampsCoordinatesSlightlyOutsideWgs84Bounds() { - assertThat(SfcBridge.z2Index(180.0000001, 39.0)).isEqualTo(SfcBridge.z2Index(180.0, 39.0)); - assertThat(SfcBridge.z2Index(-180.0000001, 39.0)).isEqualTo(SfcBridge.z2Index(-180.0, 39.0)); - assertThat(SfcBridge.z2Index(116.0, 90.0000001)).isEqualTo(SfcBridge.z2Index(116.0, 90.0)); - assertThat(SfcBridge.z2Index(116.0, -90.0000001)).isEqualTo(SfcBridge.z2Index(116.0, -90.0)); + assertThat(SpaceFillingCurves.z2Index(180.0000001, 39.0)).isEqualTo(SpaceFillingCurves.z2Index(180.0, 39.0)); + assertThat(SpaceFillingCurves.z2Index(-180.0000001, 39.0)).isEqualTo(SpaceFillingCurves.z2Index(-180.0, 39.0)); + assertThat(SpaceFillingCurves.z2Index(116.0, 90.0000001)).isEqualTo(SpaceFillingCurves.z2Index(116.0, 90.0)); + assertThat(SpaceFillingCurves.z2Index(116.0, -90.0000001)).isEqualTo(SpaceFillingCurves.z2Index(116.0, -90.0)); } @Test void z2HexIs16CharLowercaseAndOrderPreserving() { // Encoded corners: SW (index 0) and NE (62-bit max, shifted left by 2). - assertThat(SfcBridge.z2Hex(-180.0, -90.0)).isEqualTo("0000000000000000"); - assertThat(SfcBridge.z2Hex(180.0, 90.0)).isEqualTo("fffffffffffffffc"); - String h = SfcBridge.z2Hex(-77.0, 38.9); + assertThat(SpaceFillingCurves.z2Hex(-180.0, -90.0)).isEqualTo("0000000000000000"); + assertThat(SpaceFillingCurves.z2Hex(180.0, 90.0)).isEqualTo("fffffffffffffffc"); + String h = SpaceFillingCurves.z2Hex(-77.0, 38.9); assertThat(h).hasSize(16).doesNotMatch(".*[A-F].*").doesNotStartWith("-"); } @Test void z2RangesCoverEveryStoredPointInEnvelope() { Envelope env = new Envelope(-90.0, 90.0, -45.0, 45.0); - List ranges = SpatialIndexRanges.z2Ranges(env); + List ranges = SpatialIndexRanges.z2Ranges(env); double lonStep = (env.getMaxX() - env.getMinX()) / 8.0; double latStep = (env.getMaxY() - env.getMinY()) / 8.0; for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) { double lon = env.getMinX() + i * lonStep + lonStep / 2.0; double lat = env.getMinY() + j * latStep + latStep / 2.0; - String stored = SfcBridge.z2Hex(lon, lat); + String stored = SpaceFillingCurves.z2Hex(lon, lat); assertThat(covered(ranges, stored)) .as("Point (%s, %s) hex %s not covered by any of %d ranges", lon, lat, stored, ranges.size()) @@ -94,10 +96,10 @@ void z2RangesCoverEveryStoredPointInEnvelope() { @Test void z2RangesCoverEnvelopeCorners() { Envelope env = new Envelope(-80.0, -70.0, 37.0, 47.0); - List ranges = SpatialIndexRanges.z2Ranges(env); + List ranges = SpatialIndexRanges.z2Ranges(env); for (double lon : new double[]{env.getMinX(), env.getMaxX()}) { for (double lat : new double[]{env.getMinY(), env.getMaxY()}) { - assertThat(covered(ranges, SfcBridge.z2Hex(lon, lat))) + assertThat(covered(ranges, SpaceFillingCurves.z2Hex(lon, lat))) .as("Corner (%s, %s)", lon, lat).isTrue(); } } @@ -106,27 +108,27 @@ void z2RangesCoverEnvelopeCorners() { @Test void z2RangesCoverPointEnvelope() { Envelope env = new Envelope(-77.0, -77.0, 38.9, 38.9); - List ranges = SpatialIndexRanges.z2Ranges(env); + List ranges = SpatialIndexRanges.z2Ranges(env); assertThat(ranges).isNotEmpty(); - assertThat(covered(ranges, SfcBridge.z2Hex(-77.0, 38.9))).isTrue(); + assertThat(covered(ranges, SpaceFillingCurves.z2Hex(-77.0, 38.9))).isTrue(); } @Test void z2RangesClampQueryEnvelopeOutsideWgs84Bounds() { // e.g. a DWITHIN buffer near a pole exceeds the WGS84 bounds - List clamped = SpatialIndexRanges.z2Ranges(new Envelope(-180.0, -179.0, 88.0, 90.0)); - List outside = SpatialIndexRanges.z2Ranges(new Envelope(-180.5, -179.0, 88.0, 90.5)); + List clamped = SpatialIndexRanges.z2Ranges(new Envelope(-180.0, -179.0, 88.0, 90.0)); + List outside = SpatialIndexRanges.z2Ranges(new Envelope(-180.5, -179.0, 88.0, 90.5)); assertThat(outside).usingRecursiveComparison().isEqualTo(clamped); } @Test void z2NearWorldEnvelopeRangeCountIsCappedButStillCoversPoints() { Envelope env = new Envelope(-179.9, 179.9, -89.9, 89.9); - List ranges = SpatialIndexRanges.z2Ranges(env); + List ranges = SpatialIndexRanges.z2Ranges(env); assertThat(ranges).isNotEmpty(); assertThat(ranges.size()).isLessThanOrEqualTo(SpatialIndexRanges.MAX_RANGES * 2); for (double[] pt : new double[][] {{0.5, 0.5}, {-122.4, 37.8}, {151.2, -33.9}}) { - assertThat(covered(ranges, SfcBridge.z2Hex(pt[0], pt[1]))) + assertThat(covered(ranges, SpaceFillingCurves.z2Hex(pt[0], pt[1]))) .as("point (%s, %s) still covered by capped ranges", pt[0], pt[1]) .isTrue(); } @@ -135,13 +137,12 @@ void z2NearWorldEnvelopeRangeCountIsCappedButStillCoversPoints() { @Test void z2RangeEndpointsAre16CharLowercaseAndOrdered() { Envelope env = new Envelope(-80.0, -70.0, 35.0, 45.0); - List ranges = SpatialIndexRanges.z2Ranges(env); + List ranges = SpatialIndexRanges.z2Ranges(env); assertThat(ranges).isNotEmpty(); - for (String[] r : ranges) { - assertThat(r).hasSize(2); - assertThat(r[0]).hasSize(16).doesNotMatch(".*[A-F].*").doesNotStartWith("-"); - assertThat(r[1]).hasSize(16).doesNotMatch(".*[A-F].*").doesNotStartWith("-"); - assertThat(r[0].compareTo(r[1])).isLessThanOrEqualTo(0); + for (HexRange r : ranges) { + assertThat(r.lower()).hasSize(16).doesNotMatch(".*[A-F].*").doesNotStartWith("-"); + assertThat(r.upper()).hasSize(16).doesNotMatch(".*[A-F].*").doesNotStartWith("-"); + assertThat(r.lower().compareTo(r.upper())).isLessThanOrEqualTo(0); } } @@ -149,8 +150,8 @@ void z2RangeEndpointsAre16CharLowercaseAndOrdered() { @Test void xz2IndexClampsEnvelopeSlightlyOutsideWgs84Bounds() { - assertThat(SfcBridge.xz2Index(-180.0000001, 39.0, -179.0, 90.0000001, SpatialIndexRanges.G)) - .isEqualTo(SfcBridge.xz2Index(-180.0, 39.0, -179.0, 90.0, SpatialIndexRanges.G)); + assertThat(SpaceFillingCurves.xz2Index(-180.0000001, 39.0, -179.0, 90.0000001, SpatialIndexRanges.G)) + .isEqualTo(SpaceFillingCurves.xz2Index(-180.0, 39.0, -179.0, 90.0, SpatialIndexRanges.G)); } @Test @@ -158,9 +159,9 @@ void xz2HexIsLeftAlignedFixedWidth() { // XZ2SFC(g=12) sequence codes are ≤ 25 significant bits; hexEncode // left-shifts by 3 and emits 7 hex chars, so the FIRST char already // discriminates spatially — truncate(width≥1) partition pruning works. - String world = SfcBridge.xz2Hex(-179.0, -89.0, 179.0, 89.0, SpatialIndexRanges.G); - String dc = SfcBridge.xz2Hex(-77.5, 38.5, -76.5, 39.5, SpatialIndexRanges.G); - String tokyo = SfcBridge.xz2Hex(139.0, 35.0, 140.0, 36.0, SpatialIndexRanges.G); + String world = SpaceFillingCurves.xz2Hex(-179.0, -89.0, 179.0, 89.0, SpatialIndexRanges.G); + String dc = SpaceFillingCurves.xz2Hex(-77.5, 38.5, -76.5, 39.5, SpatialIndexRanges.G); + String tokyo = SpaceFillingCurves.xz2Hex(139.0, 35.0, 140.0, 36.0, SpatialIndexRanges.G); for (String h : List.of(world, dc, tokyo)) { assertThat(h).hasSize(7).doesNotMatch(".*[A-F].*"); } @@ -169,10 +170,10 @@ void xz2HexIsLeftAlignedFixedWidth() { @Test void xz2HexMatchesUpstreamEncodingOfIndex() { - long seq = SfcBridge.xz2Index(-77.5, 38.5, -76.5, 39.5, SpatialIndexRanges.G); + long seq = SpaceFillingCurves.xz2Index(-77.5, 38.5, -76.5, 39.5, SpatialIndexRanges.G); // Upstream XZ2SFC(12).hexEncode(z) == toHexDigits(z << 3, 7). String expected = java.util.HexFormat.of().toHexDigits(seq << 3, 7); - assertThat(SfcBridge.xz2Hex(-77.5, 38.5, -76.5, 39.5, SpatialIndexRanges.G)) + assertThat(SpaceFillingCurves.xz2Hex(-77.5, 38.5, -76.5, 39.5, SpatialIndexRanges.G)) .isEqualTo(expected); } @@ -180,9 +181,9 @@ void xz2HexMatchesUpstreamEncodingOfIndex() { void xz2RangesCoverStoredValueForPolygonInEnvelope() { // For a polygon whose envelope is fully inside the query envelope, its // stored hex-encoded XZ2 must fall within at least one returned range. - String stored = SfcBridge.xz2Hex(-75.0, 38.0, -74.0, 39.0, SpatialIndexRanges.G); + String stored = SpaceFillingCurves.xz2Hex(-75.0, 38.0, -74.0, 39.0, SpatialIndexRanges.G); Envelope queryEnv = new Envelope(-80.0, -70.0, 35.0, 45.0); - List ranges = SpatialIndexRanges.xz2Ranges(queryEnv); + List ranges = SpatialIndexRanges.xz2Ranges(queryEnv); assertThat(covered(ranges, stored)) .as("hex-encoded XZ2 %s not covered by any of %d ranges", stored, ranges.size()) .isTrue(); @@ -193,11 +194,11 @@ void xz2RangesCoverStoredValueForLargeStraddlingPolygon() { // The regression case: a polygon that straddles the query boundary // and whose centroid is OUTSIDE the envelope, but whose stored cell // must still match because the polygon overlaps the query. - String stored = SfcBridge.xz2Hex(9.0, 5.0, 11.0, 6.0, SpatialIndexRanges.G); + String stored = SpaceFillingCurves.xz2Hex(9.0, 5.0, 11.0, 6.0, SpatialIndexRanges.G); // Use a wider query envelope that fully contains the polygon for the // coverage assertion (boundary-straddle pruning is the Domain layer's // job at row time, not the range encoder's). - List ranges = SpatialIndexRanges.xz2Ranges(new Envelope(8.0, 13.0, 4.0, 7.0)); + List ranges = SpatialIndexRanges.xz2Ranges(new Envelope(8.0, 13.0, 4.0, 7.0)); assertThat(covered(ranges, stored)).isTrue(); // Sanity: the straddling envelope at least produces some ranges. assertThat(SpatialIndexRanges.xz2Ranges(new Envelope(10.5, 12.0, 4.0, 7.0))).isNotEmpty(); @@ -206,10 +207,10 @@ void xz2RangesCoverStoredValueForLargeStraddlingPolygon() { @Test void xz2NearWorldEnvelopeRangeCountIsCappedButStillCoversGeometries() { Envelope env = new Envelope(-179.9, 179.9, -89.9, 89.9); - List ranges = SpatialIndexRanges.xz2Ranges(env); + List ranges = SpatialIndexRanges.xz2Ranges(env); assertThat(ranges).isNotEmpty(); assertThat(ranges.size()).isLessThanOrEqualTo(SpatialIndexRanges.MAX_RANGES * 2); - String stored = SfcBridge.xz2Hex(-75.0, 38.0, -74.0, 39.0, SpatialIndexRanges.G); + String stored = SpaceFillingCurves.xz2Hex(-75.0, 38.0, -74.0, 39.0, SpatialIndexRanges.G); assertThat(covered(ranges, stored)) .as("stored XZ2 value still covered by capped ranges") .isTrue(); @@ -218,13 +219,12 @@ void xz2NearWorldEnvelopeRangeCountIsCappedButStillCoversGeometries() { @Test void xz2RangeEndpointsAre7CharLowercaseAndOrdered() { Envelope env = new Envelope(-80.0, -70.0, 35.0, 45.0); - List ranges = SpatialIndexRanges.xz2Ranges(env); + List ranges = SpatialIndexRanges.xz2Ranges(env); assertThat(ranges).isNotEmpty(); - for (String[] r : ranges) { - assertThat(r).hasSize(2); - assertThat(r[0]).hasSize(7).doesNotMatch(".*[A-F].*"); - assertThat(r[1]).hasSize(7).doesNotMatch(".*[A-F].*"); - assertThat(r[0].compareTo(r[1])).isLessThanOrEqualTo(0); + for (HexRange r : ranges) { + assertThat(r.lower()).hasSize(7).doesNotMatch(".*[A-F].*"); + assertThat(r.upper()).hasSize(7).doesNotMatch(".*[A-F].*"); + assertThat(r.lower().compareTo(r.upper())).isLessThanOrEqualTo(0); } } diff --git a/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/Z2ParityCorpusGenerator.java b/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/Z2ParityCorpusGenerator.java index b2090241955..a7a11a2cea3 100644 --- a/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/Z2ParityCorpusGenerator.java +++ b/geomesa-trino/geomesa-trino-plugin/src/test/java/org/locationtech/geomesa/trino/spatial/iceberg/transforms/Z2ParityCorpusGenerator.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.geomesa.curve.interop.SpaceFillingCurves; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; @@ -43,9 +44,9 @@ *

Both corpora are emitted from the SAME geometry sequence; the Z2 corpus * keeps only the Point entries (Z2 rejects extended geometries) while the XZ2 * corpus keeps everything. Values are sourced directly from the upstream - * GeoMesa SFCs via {@link SfcBridge} so the corpus is the ground truth. + * GeoMesa SFCs via {@link SpaceFillingCurves} so the corpus is the ground truth. * Each entry includes the raw SFC Long, the left-aligned hex encoding - * ({@code Z2SFC.hexEncode}/{@code XZ2SFC.hexEncode} via {@link SfcBridge}), + * ({@code Z2SFC.hexEncode}/{@code XZ2SFC.hexEncode} via {@link SpaceFillingCurves}), * and the WKB hex. */ class Z2ParityCorpusGenerator { @@ -136,8 +137,8 @@ void generate() throws Exception { if (!(g instanceof org.locationtech.jts.geom.Point pt)) continue; byte[] wkb = wkbW.write(g); String wkbHex = HexFormat.of().formatHex(wkb); - long z2 = SfcBridge.z2Index(pt.getX(), pt.getY()); - String z2Hex = SfcBridge.z2Hex(pt.getX(), pt.getY()); + long z2 = SpaceFillingCurves.z2Index(pt.getX(), pt.getY()); + String z2Hex = SpaceFillingCurves.z2Hex(pt.getX(), pt.getY()); if (!first) pw.println(","); pw.printf(" {\"wkt\": \"%s\", \"wkb_hex\": \"%s\", \"z2\": %d, \"z2_hex\": \"%s\"}", g.toText(), wkbHex, z2, z2Hex); @@ -157,9 +158,9 @@ void generate() throws Exception { byte[] wkb = wkbW.write(g); String wkbHex = HexFormat.of().formatHex(wkb); Envelope env = g.getEnvelopeInternal(); - long xz2 = SfcBridge.xz2Index( + long xz2 = SpaceFillingCurves.xz2Index( env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY(), SpatialIndexRanges.G); - String xz2Hex = SfcBridge.xz2Hex( + String xz2Hex = SpaceFillingCurves.xz2Hex( env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY(), SpatialIndexRanges.G); if (!first) pw.println(","); pw.printf(" {\"wkt\": \"%s\", \"wkb_hex\": \"%s\", \"xz2\": %d, \"xz2_hex\": \"%s\"}", diff --git a/geomesa-z3/src/main/scala/org/locationtech/geomesa/curve/interop/SpaceFillingCurves.scala b/geomesa-z3/src/main/scala/org/locationtech/geomesa/curve/interop/SpaceFillingCurves.scala new file mode 100644 index 00000000000..10cb2ef24a3 --- /dev/null +++ b/geomesa-z3/src/main/scala/org/locationtech/geomesa/curve/interop/SpaceFillingCurves.scala @@ -0,0 +1,135 @@ +/*********************************************************************** + * Copyright (c) 2013-2025 General Atomics Integrated Intelligence, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution and is available at + * https://www.apache.org/licenses/LICENSE-2.0 + ***********************************************************************/ + +package org.locationtech.geomesa.curve.interop + +import java.util.{List => jList} + +import org.locationtech.geomesa.curve.SpaceFillingCurve.FullPrecision +import org.locationtech.geomesa.curve.Z2SFC.{lat, lon} +import org.locationtech.geomesa.curve.{XZ2SFC, Z2SFC} + +import scala.collection.JavaConverters._ + +/** + * Java interop-friendly facade over the Z2SFC and XZ2SFC space-filling curves. + * + * Inputs are clamped to the WGS84 domain, matching the curves' lenient + * handling of out-of-bounds coordinates. + * + * Hex outputs delegate to `Z2SFC.hexEncode`/`XZ2SFC.hexEncode`, and + * left-align the significant bits so lexicographic (truncate-prefix) + * comparison over the encoded strings matches numeric comparison of the + * underlying index values. + */ +object SpaceFillingCurves { + + /** + * A contiguous, inclusive range of hex-encoded curve values — + * Java-interop stand-in for Scala's `IndexRange`. + * + * @param lower inclusive lower bound + * @param upper inclusive upper bound + */ + case class HexRange(lower: String, upper: String) + + private def clampLon(x: Double): Double = math.min(lon.max, math.max(lon.min, x)) + private def clampLat(y: Double): Double = math.min(lat.max, math.max(lat.min, y)) + + /** + * Z2 cell index for a single (lon, lat) point at the default Z2SFC precision + * (31 bits/axis, 62-bit positive Long). + * + * @param lon longitude, clamped to [-180, 180] + * @param lat latitude, clamped to [-90, 90] + * @return the Z2SFC index value + */ + def z2Index(lon: Double, lat: Double): Long = + Z2SFC.index(clampLon(lon), clampLat(lat)) + + /** + * Z2 cell value for a single (lon, lat) point, hex-encoded via + * `Z2SFC.hexEncode` (left-shifted). + * + * @param lon longitude, clamped to [-180, 180] + * @param lat latitude, clamped to [-90, 90] + * @return the hex-encoded Z2SFC index value + */ + def z2Hex(lon: Double, lat: Double): String = + Z2SFC.hexEncode(z2Index(lon, lat)) + + /** + * Z2 index ranges covering the provided lat/lon bounds, hex-encoded via + * `Z2SFC.hexEncode`. Returns inclusive `HexRange` pairs; + * stored values produced by `z2Hex` that compare lexicographically. + * + * @param minLon envelope min longitude, clamped to [-180, 180] + * @param minLat envelope min latitude, clamped to [-90, 90] + * @param maxLon envelope max longitude, clamped to [-180, 180] + * @param maxLat envelope max latitude, clamped to [-90, 90] + * @param maxRanges rough upper bound on the number of ranges returned; the SFC + * coarsens (merges) past it, so the cover remains a superset of + * the envelope — pruning gets less selective, never lossy + * @return inclusive hex range pairs + */ + def z2HexRanges(minLon: Double, minLat: Double, maxLon: Double, maxLat: Double, maxRanges: Int): jList[HexRange] = + Z2SFC.ranges((clampLon(minLon), clampLon(maxLon)), (clampLat(minLat), clampLat(maxLat)), FullPrecision, Some(maxRanges)).iterator + .map(r => HexRange(Z2SFC.hexEncode(r.lower), Z2SFC.hexEncode(r.upper))) + .toList.asJava + + /** + * XZ2 cell index for lat/lon bounds at the given `g` resolution. + * Returns the appropriate XZ2SFC sequence-code. + * + * @param minLon envelope min longitude, clamped to [-180, 180] + * @param minLat envelope min latitude, clamped to [-90, 90] + * @param maxLon envelope max longitude, clamped to [-180, 180] + * @param maxLat envelope max latitude, clamped to [-90, 90] + * @param g XZ2 quad-tree resolution + * @return the XZ2SFC sequence-code value + */ + def xz2Index(minLon: Double, minLat: Double, maxLon: Double, maxLat: Double, g: Short): Long = + XZ2SFC(g).index(clampLon(minLon), clampLat(minLat), clampLon(maxLon), clampLat(maxLat)) + + /** + * XZ2 cell value for a geometry's envelope at the given `g` resolution, + * hex-encoded via `XZ2SFC.hexEncode` (bit-shifted left so the significant + * bits are left-aligned and truncate-prefix matching works). + * + * @param minLon envelope min longitude, clamped to [-180, 180] + * @param minLat envelope min latitude, clamped to [-90, 90] + * @param maxLon envelope max longitude, clamped to [-180, 180] + * @param maxLat envelope max latitude, clamped to [-90, 90] + * @param g XZ2 quad-tree resolution + * @return the hex-encoded XZ2SFC sequence-code value + */ + def xz2Hex(minLon: Double, minLat: Double, maxLon: Double, maxLat: Double, g: Short): String = + XZ2SFC(g).hexEncode(xz2Index(minLon, minLat, maxLon, maxLat, g)) + + /** + * XZ2 index ranges covering the query envelope at the given `g` resolution, + * hex-encoded via `XZ2SFC.hexEncode`. Returns inclusive `HexRange` pairs; + * stored values produced by `xz2Hex` compare lexicographically within them. + * + * @param minLon envelope min longitude, clamped to [-180, 180] + * @param minLat envelope min latitude, clamped to [-90, 90] + * @param maxLon envelope max longitude, clamped to [-180, 180] + * @param maxLat envelope max latitude, clamped to [-90, 90] + * @param g XZ2 quad-tree resolution + * @param maxRanges rough upper bound on the number of ranges returned; the SFC + * coarsens (merges) past it, so the cover remains a superset of + * the envelope — pruning gets less selective, never lossy + * @return inclusive hex range pairs + */ + def xz2HexRanges(minLon: Double, minLat: Double, maxLon: Double, maxLat: Double, g: Short, maxRanges: Int): jList[HexRange] = { + val sfc = XZ2SFC(g) + sfc.ranges((clampLon(minLon), clampLat(minLat), clampLon(maxLon), clampLat(maxLat)), Some(maxRanges)).iterator + .map(r => HexRange(sfc.hexEncode(r.lower), sfc.hexEncode(r.upper))) + .toList.asJava + } +} diff --git a/geomesa-z3/src/test/scala/org/locationtech/geomesa/curve/interop/SpaceFillingCurvesTest.scala b/geomesa-z3/src/test/scala/org/locationtech/geomesa/curve/interop/SpaceFillingCurvesTest.scala new file mode 100644 index 00000000000..f0d59030793 --- /dev/null +++ b/geomesa-z3/src/test/scala/org/locationtech/geomesa/curve/interop/SpaceFillingCurvesTest.scala @@ -0,0 +1,65 @@ +/*********************************************************************** + * Copyright (c) 2013-2025 General Atomics Integrated Intelligence, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution and is available at + * https://www.apache.org/licenses/LICENSE-2.0 + ***********************************************************************/ + +package org.locationtech.geomesa.curve.interop + +import org.junit.runner.RunWith +import org.locationtech.geomesa.curve.{XZ2SFC, Z2SFC} +import org.specs2.mutable.Specification +import org.specs2.runner.JUnitRunner + +@RunWith(classOf[JUnitRunner]) +class SpaceFillingCurvesTest extends Specification { + + val g: Short = 12 + + "SpaceFillingCurves" should { + "match Z2SFC index and hex encoding" >> { + SpaceFillingCurves.z2Index(-77.0, 38.9) mustEqual Z2SFC.index(-77.0, 38.9) + SpaceFillingCurves.z2Hex(-77.0, 38.9) mustEqual Z2SFC.hexEncode(Z2SFC.index(-77.0, 38.9)) + } + + "match XZ2SFC index and hex encoding" >> { + val expected = XZ2SFC(g).index(-77.0, 38.0, -76.0, 39.0) + SpaceFillingCurves.xz2Index(-77.0, 38.0, -76.0, 39.0, g) mustEqual expected + SpaceFillingCurves.xz2Hex(-77.0, 38.0, -76.0, 39.0, g) mustEqual XZ2SFC(g).hexEncode(expected) + } + + "clamp out-of-bounds coordinates to the WGS84 domain" >> { + SpaceFillingCurves.z2Index(-180.0000001, 39.0) mustEqual SpaceFillingCurves.z2Index(-180.0, 39.0) + SpaceFillingCurves.z2Index(116.0, 90.0000001) mustEqual SpaceFillingCurves.z2Index(116.0, 90.0) + SpaceFillingCurves.xz2Index(-180.0000001, 39.0, -179.0, 90.0000001, g) mustEqual + SpaceFillingCurves.xz2Index(-180.0, 39.0, -179.0, 90.0, g) + } + + "produce Z2 hex ranges that cover indexed points in the envelope" >> { + val ranges = SpaceFillingCurves.z2HexRanges(-80.0, 35.0, -70.0, 45.0, 2000) + ranges.isEmpty must beFalse + foreach(Seq((-75.0, 40.0), (-79.9, 35.1), (-70.1, 44.9))) { case (lon, lat) => + val hex = SpaceFillingCurves.z2Hex(lon, lat) + ranges.stream().anyMatch(r => r.lower <= hex && hex <= r.upper) must beTrue + } + } + + "produce XZ2 hex ranges that cover indexed envelopes in the query" >> { + val ranges = SpaceFillingCurves.xz2HexRanges(-80.0, 35.0, -70.0, 45.0, g, 2000) + ranges.isEmpty must beFalse + val hex = SpaceFillingCurves.xz2Hex(-75.0, 38.0, -74.0, 39.0, g) + ranges.stream().anyMatch(r => r.lower <= hex && hex <= r.upper) must beTrue + } + + "respect the maxRanges coarsening bound" >> { + val fine = SpaceFillingCurves.z2HexRanges(-179.9, -89.9, 179.9, 89.9, 2000) + val coarse = SpaceFillingCurves.z2HexRanges(-179.9, -89.9, 179.9, 89.9, 10) + coarse.size must beLessThanOrEqualTo(fine.size) + // the coarse cover must still contain everything the fine cover does + val hex = SpaceFillingCurves.z2Hex(0.5, 0.5) + coarse.stream().anyMatch(r => r.lower <= hex && hex <= r.upper) must beTrue + } + } +}