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 <johann.so...@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.

Reply via email to