Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions common/src/main/java/org/apache/sedona/common/Constructors.java
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,39 @@ public static Geometry makeEnvelope(double minX, double minY, double maxX, doubl
return makeEnvelope(minX, minY, maxX, maxY, 0);
}

/**
* Convert a {@link Box2D} to a Geometry. Mirrors PostGIS {@code box2d::geometry}: dispatches on
* dimensionality so the result matches what {@code ST_Envelope(geom)} would have produced for the
* source geometry. Degenerate boxes return:
*
* <ul>
* <li>{@code POINT} when {@code xmin == xmax && ymin == ymax}
* <li>{@code LINESTRING} when exactly one of the X / Y intervals collapses
* <li>{@code POLYGON} otherwise
* </ul>
*
* Returns NULL on null input.
*/
public static Geometry geomFromBox2D(Box2D box) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added direct ConstructorsTest.geomFromBox2D in 8007abd covering happy path, degenerate box (collapsed to a point), and null input.

if (box == null) {
return null;
}
double xmin = box.getXMin();
double ymin = box.getYMin();
double xmax = box.getXMax();
double ymax = box.getYMax();
boolean xCollapsed = xmin == xmax;
boolean yCollapsed = ymin == ymax;
if (xCollapsed && yCollapsed) {
return GEOMETRY_FACTORY.createPoint(new Coordinate(xmin, ymin));
}
if (xCollapsed || yCollapsed) {
return GEOMETRY_FACTORY.createLineString(
new Coordinate[] {new Coordinate(xmin, ymin), new Coordinate(xmax, ymax)});
}
return polygonFromEnvelope(xmin, ymin, xmax, ymax);
}

/**
* Build a {@link Box2D} from two corner points. The corners are taken verbatim — no swapping or
* validation of ordering — so {@code xmin > xmax} or {@code ymin > ymax} are preserved as
Expand Down
27 changes: 27 additions & 0 deletions common/src/main/java/org/apache/sedona/common/Functions.java
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,33 @@ public static String asWKT(Geometry geometry) {
return GeomUtils.getWKT(geometry);
}

/**
* PostGIS-format text for a Box2D: {@code BOX(x1 y1, x2 y2)}. NULL on null input.
*
* <p>Values are emitted exactly as stored on the Box2D — this method does not normalize the
* corners. Sedona's Box2D allows {@code xmin > xmax} (or {@code ymin > ymax}); that ordering is
* reserved for a future antimeridian-wraparound semantics on geography bboxes (cf. sedona-db's
* {@code WraparoundInterval}). The text faithfully reflects what {@code ST_XMin} / {@code
* ST_XMax} / etc. would return.
*
* <p>Not WKT (WKT has no {@code BOX} type), so this lives outside the {@code asWKT} family to
* keep that API a true geometry serializer.
*/
public static String box2dAsText(Box2D box) {
if (box == null) {
return null;
}
return "BOX("
+ box.getXMin()
+ " "
+ box.getYMin()
+ ", "
+ box.getXMax()
+ " "
+ box.getYMax()
+ ")";
Comment on lines +887 to +895
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the as-stored emit semantics — but tightened the Javadoc in c8eecaf to make the contract explicit. Reasoning: the rest of the Box2D API (ST_XMin/XMax/YMin/YMax) returns stored values, not normalized values; if box2dAsText normalized, text output would diverge from accessor output and round-trip via text would be lossy. The swapped-corner ordering is reserved for future antimeridian semantics on geography bboxes (cf. sedona-db WraparoundInterval) and was decided in #2883. Inputs that arrive swapped today are user error rather than a contract violation, and faithful text output is the right debug aid.

}

public static byte[] asEWKB(Geometry geometry) {
return GeomUtils.getEWKB(geometry);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import static org.junit.Assert.*;

import org.apache.sedona.common.geometryObjects.Box2D;
import org.apache.sedona.common.utils.GeomUtils;
import org.junit.Test;
import org.locationtech.jts.geom.*;
Expand Down Expand Up @@ -241,6 +242,30 @@ public void makeEnvelope() {
assertEquals(expected, actual);
}

@Test
public void geomFromBox2D() {
// 2-D box → POLYGON
Geometry polygon = Constructors.geomFromBox2D(new Box2D(1.0, 2.0, 4.0, 5.0));
assertTrue(polygon instanceof Polygon);
assertEquals("POLYGON ((1 2, 1 5, 4 5, 4 2, 1 2))", Functions.asWKT(polygon));

// Collapsed in both axes → POINT (matches PostGIS box2d::geometry and ST_Envelope(point)).
Geometry point = Constructors.geomFromBox2D(new Box2D(3.0, 3.0, 3.0, 3.0));
assertTrue(point instanceof Point);
assertEquals("POINT (3 3)", Functions.asWKT(point));

// Collapsed in one axis → LINESTRING (matches ST_Envelope of an axis-aligned line).
Geometry horizontalLine = Constructors.geomFromBox2D(new Box2D(1.0, 5.0, 4.0, 5.0));
assertTrue(horizontalLine instanceof LineString);
assertEquals("LINESTRING (1 5, 4 5)", Functions.asWKT(horizontalLine));

Geometry verticalLine = Constructors.geomFromBox2D(new Box2D(2.0, 1.0, 2.0, 4.0));
assertTrue(verticalLine instanceof LineString);
assertEquals("LINESTRING (2 1, 2 4)", Functions.asWKT(verticalLine));

assertNull(Constructors.geomFromBox2D(null));
}

@Test
public void makePointM() {
Geometry point = Constructors.makePointM(1, 2, 3);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.google.common.math.DoubleMath;
import java.util.*;
import java.util.stream.Collectors;
import org.apache.sedona.common.geometryObjects.Box2D;
import org.apache.sedona.common.sphere.Haversine;
import org.apache.sedona.common.sphere.Spheroid;
import org.apache.sedona.common.utils.*;
Expand Down Expand Up @@ -166,6 +167,15 @@ public void asWKT() throws Exception {
assertEquals(expectedResult, actualResult);
}

@Test
public void box2dAsText() {
assertEquals("BOX(1.0 2.0, 4.0 5.0)", Functions.box2dAsText(new Box2D(1.0, 2.0, 4.0, 5.0)));
assertEquals(
"BOX(-180.0 -90.0, 180.0 90.0)",
Functions.box2dAsText(new Box2D(-180.0, -90.0, 180.0, 90.0)));
assertNull(Functions.box2dAsText(null));
}

@Test
public void asWKB() throws Exception {
Geometry geometry = GEOMETRY_FACTORY.createPoint(new Coordinate(1.0, 2.0));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ object Catalog extends AbstractCatalog with Logging {
function[ST_GeomFromEWKT](),
function[ST_GeomFromWKB](),
function[ST_GeomFromEWKB](),
function[ST_GeomFromBox2D](),
function[ST_GeomFromGeoJSON](),
function[ST_GeomFromGML](),
function[ST_GeomFromKML](),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,22 @@ private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression])
}
}

/**
* Convert a Box2D to a closed rectangular polygon Geometry. Equivalent to PostGIS {@code
* box2d::geometry}. Exposed as a function rather than a Catalyst implicit cast because UDT-to-UDT
* implicit casts require Catalyst-level work; ST_GeomFromBox2D lives alongside the other
* ST_GeomFrom* constructors.
*
* @param inputExpressions
*/
private[apache] case class ST_GeomFromBox2D(inputExpressions: Seq[Expression])
extends InferredExpression(Constructors.geomFromBox2D _) {

protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
copy(inputExpressions = newChildren)
}
}

private[apache] trait UserDataGenerator {
def generateUserData(
minInputLength: Integer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,10 @@ private[apache] case class ST_SimplifyPolygonHull(inputExpressions: Seq[Expressi

private[apache] case class ST_AsText(inputExpressions: Seq[Expression])
extends InferredExpression(
inferrableFunction1(Functions.asWKT),
inferrableFunction1(org.apache.sedona.common.geography.Functions.asText)) {
inferrableFunction1((g: Geometry) => Functions.asWKT(g)),
inferrableFunction1((g: Geography) =>
org.apache.sedona.common.geography.Functions.asText(g)),
inferrableFunction1((b: Box2D) => Functions.box2dAsText(b))) {

protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
copy(inputExpressions = newChildren)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,21 @@ class constructorTestScala extends TestBaseScala with Matchers {
assert(bbox.getYMax == 5.0)
}

it("Passed ST_GeomFromBox2D") {
val df = sparkSession.sql("""
SELECT
ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(1.0, 2.0), ST_Point(4.0, 5.0)))) AS poly,
ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(3.0, 3.0), ST_Point(3.0, 3.0)))) AS point,
ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(1.0, 5.0), ST_Point(4.0, 5.0)))) AS line,
ST_GeomFromBox2D(ST_MakeBox2D(ST_GeomFromText(NULL), ST_Point(1.0, 1.0))) AS null_geom
""")
val row = df.collect()(0)
assert(row.getString(0) == "POLYGON ((1 2, 1 5, 4 5, 4 2, 1 2))")
assert(row.getString(1) == "POINT (3 3)")
assert(row.getString(2) == "LINESTRING (1 5, 4 5)")
assert(row.isNullAt(3))
}

it("ST_MakeBox2D rejects non-point input") {
val ex = intercept[Exception] {
sparkSession
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ class functionTestScala
assert(test.take(1)(0).get(0).asInstanceOf[Double] == -3.0)
}

it("Passed ST_AsText for Box2D") {
val df = sparkSession.sql("""
SELECT
ST_AsText(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))'))) AS wkt,
ST_AsText(ST_Box2D(ST_GeomFromText(NULL))) AS null_wkt
""")
val row = df.collect()(0)
assert(row.getString(0) == "BOX(1.0 2.0, 4.0 5.0)")
assert(row.isNullAt(1))
}

it("Passed ST_XMin / XMax / YMin / YMax for Box2D") {
val df = sparkSession.sql("""
WITH t AS (
Expand Down
Loading