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.