You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2025/05/19 02:26:35 UTC
(sis) branch geoapi-4.0 updated (d169152a6e -> 305ae17810)
This is an automated email from the ASF dual-hosted git repository.
desruisseaux pushed a change to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git
discard d169152a6e Improve AWT to JTS conversion logic.
new 305ae17810 Improve AWT to JTS conversion logic.
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (d169152a6e)
\
N -- N -- N refs/heads/geoapi-4.0 (305ae17810)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
(sis) 01/01: Improve AWT to JTS conversion logic.
Posted by de...@apache.org.
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
commit 305ae17810e22308c0bf6bd50ecd90e12e92cecd
Author: jsorel <jo...@geomatys.com>
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.