_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
+ }
+ }
+}