001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Shape;
007import java.awt.geom.Area;
008import java.awt.geom.Ellipse2D;
009import java.awt.geom.Path2D;
010import java.util.ArrayList;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.List;
014
015/**
016 * A class to generate geometry for a vector tile
017 * @author Taylor Smock
018 * @since 17862
019 */
020public class Geometry {
021    final Collection<Shape> shapes = new ArrayList<>();
022
023    /**
024     * Create a {@link Geometry} for a {@link Feature}
025     * @param geometryType The type of geometry
026     * @param commands The commands used to create the geometry
027     * @throws IllegalArgumentException if arguments are not understood or if the shoelace formula returns 0 for a polygon ring.
028     */
029    public Geometry(GeometryTypes geometryType, List<CommandInteger> commands) {
030        if (geometryType == GeometryTypes.POINT) {
031            for (CommandInteger command : commands) {
032                final short[] operations = command.getOperations();
033                // Each MoveTo command is a new point
034                if (command.getType() == Command.MoveTo && operations.length % 2 == 0 && operations.length > 0) {
035                    for (int i = 0; i < operations.length / 2; i++) {
036                        // Just using Ellipse2D since it extends Shape
037                        shapes.add(new Ellipse2D.Float(operations[2 * i], operations[2 * i + 1], 0, 0));
038                    }
039                } else {
040                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
041                }
042            }
043        } else if (geometryType == GeometryTypes.LINESTRING || geometryType == GeometryTypes.POLYGON) {
044            Path2D.Float line = null;
045            Area area = null;
046            // MVT uses delta encoding. Each feature starts at (0, 0).
047            int x = 0;
048            int y = 0;
049            // Area is used to determine the inner/outer of a polygon
050            final int maxArraySize = commands.stream().filter(command -> command.getType() != Command.ClosePath)
051                    .mapToInt(command -> command.getOperations().length).sum();
052            final List<Integer> xArray = new ArrayList<>(maxArraySize);
053            final List<Integer> yArray = new ArrayList<>(maxArraySize);
054            for (CommandInteger command : commands) {
055                final short[] operations = command.getOperations();
056                // Technically, there is no reason why there can be multiple MoveTo operations in one command, but that is undefined behavior
057                if (command.getType() == Command.MoveTo && operations.length == 2) {
058                    x += operations[0];
059                    y += operations[1];
060                    line = new Path2D.Float();
061                    line.moveTo(x, y);
062                    xArray.add(x);
063                    yArray.add(y);
064                    shapes.add(line);
065                } else if (command.getType() == Command.LineTo && operations.length % 2 == 0 && line != null) {
066                    for (int i = 0; i < operations.length / 2; i++) {
067                        x += operations[2 * i];
068                        y += operations[2 * i + 1];
069                        xArray.add(x);
070                        yArray.add(y);
071                        line.lineTo(x, y);
072                    }
073                // ClosePath should only be used with Polygon geometry
074                } else if (geometryType == GeometryTypes.POLYGON && command.getType() == Command.ClosePath && line != null) {
075                    shapes.remove(line);
076                    // new Area() closes the line if it isn't already closed
077                    if (area == null) {
078                        area = new Area();
079                        shapes.add(area);
080                    }
081
082                    final double areaAreaSq = calculateSurveyorsArea(xArray.stream().mapToInt(i -> i).toArray(),
083                            yArray.stream().mapToInt(i -> i).toArray());
084                    Area nArea = new Area(line);
085                    // SonarLint thinks that this is never > 0. It can be.
086                    if (areaAreaSq > 0) {
087                        area.add(nArea);
088                    } else if (areaAreaSq < 0) {
089                        area.exclusiveOr(nArea);
090                    } else {
091                        throw new IllegalArgumentException(tr("{0} cannot have zero area", geometryType));
092                    }
093                    xArray.clear();
094                    yArray.clear();
095                } else {
096                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
097                }
098            }
099        }
100    }
101
102    /**
103     * This is also known as the "shoelace formula".
104     * @param xArray The array of x coordinates
105     * @param yArray The array of y coordinates
106     * @return The area of the object
107     * @throws IllegalArgumentException if the array lengths are not equal
108     */
109    static double calculateSurveyorsArea(int[] xArray, int[] yArray) {
110        if (xArray.length != yArray.length) {
111            throw new IllegalArgumentException("Cannot calculate areas when arrays are uneven");
112        }
113        // Lines have no area
114        if (xArray.length < 3) {
115            return 0;
116        }
117        int area = 0;
118        // Do the non-special stuff first (x0 * y1 - x1 * y0)
119        for (int i = 0; i < xArray.length - 1; i++) {
120            area += xArray[i] * yArray[i + 1] - xArray[i + 1] * yArray[i];
121        }
122        // Now calculate the edges (xn * y0 - x0 * yn)
123        area += xArray[xArray.length - 1] * yArray[0] - xArray[0] * yArray[yArray.length - 1];
124        return area / 2d;
125    }
126
127    /**
128     * Get the shapes to draw this geometry with
129     * @return A collection of shapes
130     */
131    public Collection<Shape> getShapes() {
132        return Collections.unmodifiableCollection(this.shapes);
133    }
134}