This is an automated email from the ASF dual-hosted git repository.
desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git
The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
new d169152a6e Improve AWT to JTS conversion logic.
d169152a6e is described below
commit d169152a6eb617455e9cbf63f0b278fef56f4bb4
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Mon May 19 04:00:47 2025 +0200
Improve AWT to JTS conversion logic.
---
.../sis/geometry/wrapper/jts/ConverterTo2D.java | 227 +++++++++++++++++++++
.../sis/geometry/wrapper/jts/ShapeConverter.java | 73 ++++---
.../geometry/wrapper/jts/ShapeConverterTest.java | 40 ++--
3 files changed, 288 insertions(+), 52 deletions(-)
diff --git
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ConverterTo2D.java
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ConverterTo2D.java
new file mode 100644
index 0000000000..c65b0bde62
--- /dev/null
+++
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ConverterTo2D.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometry.wrapper.jts;
+
+import java.lang.reflect.Array;
+import java.util.function.BiFunction;
+import java.util.function.UnaryOperator;
+import org.apache.sis.util.resources.Errors;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryCollection;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.geom.LinearRing;
+import org.locationtech.jts.geom.MultiLineString;
+import org.locationtech.jts.geom.MultiPoint;
+import org.locationtech.jts.geom.MultiPolygon;
+import org.locationtech.jts.geom.Point;
+import org.locationtech.jts.geom.Polygon;
+
+
+/**
+ * Converts a geometry from 3D to 2D coordinate tuples.
+ * <abbr>JTS</abbr> tends to expend the dimension of {@link
CoordinateSequence} from 2D to 3D
+ * with the addition of a <var>z</var> coordinate initialized to {@link
java.lang.Double#NaN}.
+ * Since some operations do not want any <var>z</var> coordinates, this class
is the base class
+ * for any operation that needs to check for the presence of <var>z</var>
values and remove them.
+ *
+ * @author Johann Sorel (Geomatys)
+ * @author Martin Desruisseaux (Geomatys)
+ */
+class ConverterTo2D {
+ /**
+ * Number of dimensions of geometries built by this class.
+ */
+ protected static final int DIMENSION = Factory.BIDIMENSIONAL;
+
+ /**
+ * The <abbr>JTS</abbr> factory for creating geometry. May be
user-specified.
+ * Note that the {@link
org.locationtech.jts.geom.CoordinateSequenceFactory} is ignored.
+ */
+ protected final GeometryFactory factory;
+
+ /**
+ * Creates a new converter from 3D to 2D geometries.
+ *
+ * @param factory the <abbr>JTS</abbr> factory for creating geometry, or
{@code null} for automatic.
+ * @param isFloat whether to store coordinates as {@code float} instead
of {@code double}.
+ */
+ protected ConverterTo2D(final GeometryFactory factory, final boolean
isFloat) {
+ this.factory = (factory != null) ? factory :
Factory.INSTANCE.factory(isFloat);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given geometry if not already 2D.
+ * This is the general version of {@code enforce2D(…)} for geometries of
type unknown at compile time.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ * @throws IllegalArgumentException if the given geometry is an instance
of an unsupported class.
+ */
+ protected final Geometry anyTo2D(final Geometry geometry) {
+ if (geometry instanceof Point) return enforce2D((Point)
geometry);
+ if (geometry instanceof LineString) return
enforce2D((LineString) geometry);
+ if (geometry instanceof LinearRing) return
enforce2D((LinearRing) geometry);
+ if (geometry instanceof Polygon) return enforce2D((Polygon)
geometry);
+ if (geometry instanceof MultiPoint) return
enforce2D((MultiPoint) geometry);
+ if (geometry instanceof MultiLineString) return
enforce2D((MultiLineString) geometry);
+ if (geometry instanceof MultiPolygon) return
enforce2D((MultiPolygon) geometry);
+ if (geometry instanceof GeometryCollection) return
collect2D((GeometryCollection) geometry);
+ throw new
IllegalArgumentException(Errors.format(Errors.Keys.UnsupportedType_1,
geometry.getGeometryType()));
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given geometry collection if not
already 2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final GeometryCollection collect2D(final GeometryCollection
geometry) {
+ return enforce2D(geometry, Geometry.class, this::anyTo2D,
GeometryFactory::createGeometryCollection);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given multi-points if not already
2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final MultiPoint enforce2D(final MultiPoint geometry) {
+ return enforce2D(geometry, Point.class, this::enforce2D,
GeometryFactory::createMultiPoint);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given point if not already 2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final Point enforce2D(final Point geometry) {
+ return enforce2D(geometry, geometry.getCoordinateSequence(),
GeometryFactory::createPoint);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given multi-line-strings if not
already 2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final MultiLineString enforce2D(final MultiLineString geometry) {
+ return enforce2D(geometry, LineString.class, this::enforce2D,
GeometryFactory::createMultiLineString);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given line-string if not already
2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final LineString enforce2D(final LineString geometry) {
+ return enforce2D(geometry, geometry.getCoordinateSequence(),
GeometryFactory::createLineString);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given line-ring if not already 2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final LinearRing enforce2D(final LinearRing geometry) {
+ return enforce2D(geometry, geometry.getCoordinateSequence(),
GeometryFactory::createLinearRing);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given multi-polygons if not
already 2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final MultiPolygon enforce2D(final MultiPolygon geometry) {
+ return enforce2D(geometry, Polygon.class, this::enforce2D,
GeometryFactory::createMultiPolygon);
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given polygon if not already 2D.
+ *
+ * @param geometry the geometry to force to a two-dimensional geometry.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ protected final Polygon enforce2D(final Polygon geometry) {
+ LinearRing exterior = geometry.getExteriorRing();
+ boolean changed = (exterior != (exterior = enforce2D(exterior)));
+ final var rings = new LinearRing[geometry.getNumInteriorRing()];
+ for (int i = 0; i < rings.length; i++) {
+ final LinearRing interior = geometry.getInteriorRingN(i);
+ changed |= (rings[i] = enforce2D(interior)) != interior;
+ }
+ return changed ? factory.createPolygon(exterior, rings) : geometry;
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given geometry collection if not
already 2D.
+ * This is a helper method for {@code enforce2D(…)} implementations.
+ *
+ * @param <G> the type of the geometry collection.
+ * @param <E> the type of all components in the geometry
collection.
+ * @param collection the geometry collection to eventually copy.
+ * @param componentType the type of all components in the geometry
collection.
+ * @param toComponent2D the method to invoke for enforcing a component to
two dimensions.
+ * @param creator the method to invoke for recreating a collection
from the components.
+ * @return a two-dimension copy of the given collection, or directly
{@code collection} if it was already 2D.
+ */
+ private <G extends GeometryCollection, E extends Geometry> G enforce2D(
+ final G collection,
+ final Class<E> componentType,
+ final UnaryOperator<E> toComponent2D,
+ final BiFunction<GeometryFactory, E[], G> creator)
+ {
+ boolean changed = false;
+ @SuppressWarnings("unchecked")
+ final E[] components = (E[]) Array.newInstance(componentType,
collection.getNumGeometries());
+ for (int i = 0; i < components.length; i++) {
+ final E component = componentType.cast(collection.getGeometryN(i));
+ changed |= (components[i] = toComponent2D.apply(component)) !=
component;
+ }
+ return changed ? creator.apply(factory, components) : collection;
+ }
+
+ /**
+ * Creates a two-dimensional copy of the given geometry if not already 2D.
+ * This is a helper method for {@code enforce2D(…)} implementations.
+ *
+ * @param <G> the type of the geometry argument.
+ * @param geometry the geometry to eventually copy.
+ * @param cs the coordinate sequence of the geometry.
+ * @param creator the factory method to invoke if the geometry needs to
be recreated.
+ * @return a two-dimension copy of the given geometry, or directly {@code
geometry} if it was already 2D.
+ */
+ private <G extends Geometry> G enforce2D(final G geometry, final
CoordinateSequence cs,
+ final BiFunction<GeometryFactory,
CoordinateSequence, G> creator)
+ {
+ if (cs.getDimension() == DIMENSION) {
+ return geometry;
+ }
+ final int size = cs.size();
+ final CoordinateSequence copy =
factory.getCoordinateSequenceFactory().create(size, 2);
+ for (int i = 0; i < size; i++) {
+ copy.setOrdinate(i, 0, cs.getX(i));
+ copy.setOrdinate(i, 1, cs.getY(i));
+ }
+ return creator.apply(factory, copy);
+ }
+}
diff --git
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ShapeConverter.java
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ShapeConverter.java
index ddb8d707d9..f7e8e4f028 100644
---
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ShapeConverter.java
+++
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/geometry/wrapper/jts/ShapeConverter.java
@@ -24,6 +24,7 @@ import java.awt.geom.PathIterator;
import java.awt.geom.IllegalPathStateException;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.util.GeometryFixer;
import org.apache.sis.referencing.privy.AbstractShape;
@@ -35,12 +36,7 @@ import org.apache.sis.referencing.privy.AbstractShape;
* @author Johann Sorel (Puzzle-GIS, Geomatys)
* @author Martin Desruisseaux (Geomatys)
*/
-abstract class ShapeConverter {
- /**
- * Number of dimensions of geometries built by this class.
- */
- private static final int DIMENSION = Factory.BIDIMENSIONAL;
-
+abstract class ShapeConverter extends ConverterTo2D {
/**
* Initial number of coordinate values that the buffer can hold.
* The buffer capacity will be expanded as needed.
@@ -61,12 +57,6 @@ abstract class ShapeConverter {
*/
private final List<Geometry> geometries;
- /**
- * The JTS factory for creating geometry. May be user-specified.
- * Note that the {@link
org.locationtech.jts.geom.CoordinateSequenceFactory} is ignored;
- */
- private final GeometryFactory factory;
-
/**
* Iterator over the coordinates of the Java2D shape to convert to a JTS
geometry.
*/
@@ -91,9 +81,9 @@ abstract class ShapeConverter {
* @param isFloat whether to store coordinates as {@code float} instead
of {@code double}.
*/
ShapeConverter(final GeometryFactory factory, final PathIterator iterator,
final boolean isFloat) {
+ super(factory, isFloat);
this.iterator = iterator;
this.geometries = new ArrayList<>();
- this.factory = (factory != null) ? factory :
Factory.INSTANCE.factory(isFloat);
}
/**
@@ -236,7 +226,8 @@ abstract class ShapeConverter {
/**
* Iterates over all coordinates given by the {@link #iterator} and stores
them in a JTS geometry.
- * The path shall contain only straight lines; curves are not supported.
+ * The path shall contain only straight lines. Curves are not supported.
+ * The geometry will be constrained to two-dimensional coordinate tuples.
*/
private Geometry build() {
while (!iterator.isDone()) {
@@ -266,29 +257,35 @@ abstract class ShapeConverter {
flush(false);
final int count = geometries.size();
if (count == 1) {
- return geometries.get(0);
+ return anyTo2D(geometries.get(0));
}
switch (geometryType) {
- case 0: return factory.createEmpty(DIMENSION);
- default: return
factory.createGeometryCollection(GeometryFactory.toGeometryArray (geometries));
- case POINT: return factory.createMultiPoint
(GeometryFactory.toPointArray (geometries));
- case LINESTRING: return factory.createMultiLineString
(GeometryFactory.toLineStringArray(geometries));
- case POLYGON: {
- Geometry result = geometries.get(0);
- for (int i=1; i<count; i++) {
- /*
- * Java2D shapes and JTS geometries differ in their way to
fill interior.
- * Java2D fills the resulting contour based on visual
winding rules.
- * JTS has a system where outer shell and holes are
clearly separated.
- * We would need to draw contours as Java2D for computing
JTS equivalent,
- * but it would require a lot of work. In the meantime,
the SymDifference
- * operation is what behave the most like EVEN_ODD or
NON_ZERO winding rules.
- */
- result = result.symDifference(geometries.get(i));
- }
- return result;
+ case 0: return factory.createEmpty(DIMENSION); // No
need for `enforce2D(…)` since the geometry is empty.
+ default: return
collect2D(factory.createGeometryCollection(GeometryFactory.toGeometryArray
(geometries)));
+ case POINT: return enforce2D(factory.createMultiPoint
(GeometryFactory.toPointArray (geometries)));
+ case LINESTRING: return enforce2D(factory.createMultiLineString
(GeometryFactory.toLineStringArray(geometries)));
+ case POLYGON: break;
+ }
+ /*
+ * Java2D shapes and JTS geometries differ in their way to fill
interior.
+ * Java2D fills the resulting contour based on visual winding rules.
+ * JTS has a system where outer shell and holes are clearly separated.
+ * We would need to draw contours as Java2D for computing JTS
equivalent,
+ * but it would require a lot of work. In the meantime, the
SymDifference
+ * operation is what behave the most like EVEN_ODD or NON_ZERO winding
rules.
+ */
+ // Sort by area, bigger geometries are the outter rings.
+ geometries.sort((Geometry o1, Geometry o2) ->
java.lang.Double.compare(o2.getArea(), o1.getArea()));
+ Geometry result = geometries.get(0);
+ for (int i=1; i<count; i++) {
+ Geometry other = geometries.get(i);
+ if (result.intersects(other)) {
+ result = result.symDifference(other); // Ring is a hole.
+ } else {
+ result = result.union(other); // Ring is a separate
polygon.
}
}
+ return anyTo2D(result);
}
/**
@@ -299,7 +296,7 @@ abstract class ShapeConverter {
*/
private void flush(final boolean isRing) {
if (length != 0) {
- final Geometry geometry;
+ Geometry geometry;
if (length == DIMENSION) {
geometry = factory.createPoint(toSequence(false));
geometryType |= POINT;
@@ -311,6 +308,14 @@ abstract class ShapeConverter {
*/
geometry = factory.createPolygon(toSequence(true));
geometryType |= POLYGON;
+ /*
+ * The following operation is expensive, but must be done
because Java2D
+ * is more tolerant than JTS regarding incoherent paths.
We need to fix
+ * those otherwise we might have errors when aggregating
holes in polygons.
+ */
+ if (!geometry.isValid()) {
+ geometry = GeometryFixer.fix(geometry);
+ }
} else {
geometry = factory.createLineString(toSequence(false));
geometryType |= LINESTRING;
diff --git
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/geometry/wrapper/jts/ShapeConverterTest.java
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/geometry/wrapper/jts/ShapeConverterTest.java
index 9a50222b84..a71f78d87a 100644
---
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/geometry/wrapper/jts/ShapeConverterTest.java
+++
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/geometry/wrapper/jts/ShapeConverterTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.sis.geometry.wrapper.jts;
+import java.util.Arrays;
import java.awt.Shape;
import java.awt.Graphics2D;
import java.awt.Font;
@@ -90,7 +91,7 @@ public final class ShapeConverterTest extends TestCase {
*/
@Test
public void testPoint() {
- final GeneralPath shape = new GeneralPath();
+ final var shape = new GeneralPath();
shape.moveTo(10, 20);
assertCoordinatesEqual(shape, Point.class,
new Coordinate(10, 20));
@@ -101,7 +102,7 @@ public final class ShapeConverterTest extends TestCase {
*/
@Test
public void testLine() {
- final Line2D shape = new Line2D.Double(1, 2, 3, 4);
+ final var shape = new Line2D.Double(1, 2, 3, 4);
assertCoordinatesEqual(shape, LineString.class,
new Coordinate(1, 2),
new Coordinate(3, 4));
@@ -112,7 +113,7 @@ public final class ShapeConverterTest extends TestCase {
*/
@Test
public void testRectangle() {
- final Rectangle2D shape = new Rectangle2D.Double(1, 2, 10, 20);
+ final var shape = new Rectangle2D.Double(1, 2, 10, 20);
assertCoordinatesEqual(shape, Polygon.class,
new Coordinate( 1, 2),
new Coordinate(11, 2),
@@ -126,14 +127,13 @@ public final class ShapeConverterTest extends TestCase {
*/
@Test
public void testRectangleWithHole() {
- final Rectangle2D contour = new Rectangle2D.Double(1, 2, 10, 20);
- final Rectangle2D hole = new Rectangle2D.Double(5, 6, 2, 3);
- final Area shape = new Area(contour);
+ final var contour = new Rectangle2D.Double(1, 2, 10, 20);
+ final var hole = new Rectangle2D.Double(5, 6, 2, 3);
+ final var shape = new Area(contour);
shape.subtract(new Area(hole));
final Geometry geometry = ShapeConverter.create(factory, shape,
0.0001);
- assertInstanceOf(Polygon.class, geometry);
- final Polygon polygon = (Polygon) geometry;
+ final Polygon polygon = assertInstanceOf(Polygon.class, geometry);
assertEquals(1, polygon.getNumInteriorRing());
assertCoordinatesEqual(polygon.getExteriorRing(), LinearRing.class,
@@ -155,7 +155,7 @@ public final class ShapeConverterTest extends TestCase {
* Tests {@link ShapeConverter} with the shape of an arbitrary text.
* We use that as an easy way to create relatively complex shapes.
* The arbitrary text is "Labi": 4 letters, 5 polygons (because "i" is made
- * of 2 detached polygons),* with 2 polygons ("a" and "b") having a hole.
+ * of 2 detached polygons), with 2 polygons ("a" and "b") having a hole.
*/
@Test
public void testText() {
@@ -170,20 +170,24 @@ public final class ShapeConverterTest extends TestCase {
handler.dispose();
}
final Geometry geometry = ShapeConverter.create(factory, shape, 0.1);
- assertInstanceOf(MultiPolygon.class, geometry);
- final MultiPolygon mp = (MultiPolygon) geometry;
+ final MultiPolygon mp = assertInstanceOf(MultiPolygon.class, geometry);
/*
- * The "Labi" text contaons 4 characters but `i` is split in two
ploygons,
+ * The "Labi" text contains 4 characters but `i` is split in two
ploygons,
* for a total of 5 polygons. Two letters ("a" and "b") are polyogns
whith
- * hole inside them.
+ * a hole inside them.
*/
assertEquals(5, mp.getNumGeometries());
- for (int i=0; i<5; i++) {
+ final var parts = new Geometry[mp.getNumGeometries()];
+ Arrays.setAll(parts, mp::getGeometryN);
+ Arrays.sort(parts, (Geometry o1, Geometry o2) -> //
Sort on X
+ Double.compare(o1.getEnvelopeInternal().getMinX(),
+ o2.getEnvelopeInternal().getMinX()));
+
+ for (int i=0; i < parts.length; i++) {
final String message = "Glyph #" + i;
- final Geometry glyph = mp.getGeometryN(i);
- assertInstanceOf(Polygon.class, glyph, message);
- assertEquals((i == 1 || i == 2) ? 1 : 0, // `a` and `b`
should contain a hole.
- ((Polygon) glyph).getNumInteriorRing(), message);
+ final Geometry glyph = parts[i];
+ final Polygon polygon = assertInstanceOf(Polygon.class, glyph,
message);
+ assertEquals((i == 1 || i == 2) ? 1 : 0,
polygon.getNumInteriorRing(), message); // Expect a hole in `a` and `b`.
}
/*
* Compare the bounding boxes.