Ticket #24891: josm-parallel-way-buffer.patch

File josm-parallel-way-buffer.patch, 71.0 KB (added by wangi, 23 hours ago)
  • src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java

    diff --git src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java
    index 790293029c..c770e17834 100644
    public class ParallelWayAction extends MapMode implements ModifierExListener {  
    101101    private static final CachingProperty<Double> SNAP_DISTANCE_IMPERIAL = new DoubleProperty(prefKey("snap-distance-imperial"), 1).cached();
    102102    private static final CachingProperty<Double> SNAP_DISTANCE_CHINESE  = new DoubleProperty(prefKey("snap-distance-chinese"), 1).cached();
    103103    private static final CachingProperty<Double> SNAP_DISTANCE_NAUTICAL = new DoubleProperty(prefKey("snap-distance-nautical"), 0.1).cached();
     104    private static final CachingProperty<Double> ARC_STEP_DEGREES
     105            = new DoubleProperty(prefKey("arc-step-degrees"), ParallelWays.DEFAULT_ARC_STEP_DEGREES).cached();
     106    private static final CachingProperty<BasicStroke> PREVIEW_STROKE = new StrokeProperty(prefKey("stroke.preview"), "2").cached();
    104107    private static final CachingProperty<Color> MAIN_COLOR = new NamedColorProperty(marktr("make parallel helper line"), Color.RED).cached();
    105108
    106109    private static final CachingProperty<Map<Modifier, Boolean>> SNAP_MODIFIER_COMBO
    public class ParallelWayAction extends MapMode implements ModifierExListener {  
    331334            } // else -> invalid modifier combination
    332335        } else if (mode == Mode.DRAGGING) {
    333336            clearSourceWays();
    334             MainApplication.getMap().statusLine.setDist(pWays.getWays());
     337            if (pWays != null) {
     338                // The nodes and ways are only created now, since their number depends on the offset
     339                pWays.commit();
     340                List<Way> newWays = pWays.getWays();
     341                if (newWays.isEmpty()) {
     342                    new Notification(tr("ParallelWayAction\n" +
     343                            "The offset is too large, nothing remains of the parallel way(s)"))
     344                            .setIcon(JOptionPane.INFORMATION_MESSAGE)
     345                            .show();
     346                    pWays = null;
     347                } else {
     348                    getLayerManager().getEditDataSet().setSelected(newWays);
     349                    MainApplication.getMap().statusLine.setDist(newWays);
     350                }
     351            }
    335352        }
    336353
    337354        setMode(Mode.NORMAL);
    public class ParallelWayAction extends MapMode implements ModifierExListener {  
    522539                }
    523540                i++;
    524541            }
    525             pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex);
    526             pWays.commit();
    527             getLayerManager().getEditDataSet().setSelected(pWays.getWays());
     542            pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex, ARC_STEP_DEGREES.get());
    528543            return true;
    529544        } catch (IllegalArgumentException e) {
    530545            Logging.debug(e);
    public class ParallelWayAction extends MapMode implements ModifierExListener {  
    638653                line.moveTo(helperLineStart);
    639654                line.lineTo(helperLineEnd);
    640655                g.draw(line.computeClippedLine(g.getStroke()));
     656
     657                // Preview of the parallel way(s)
     658                if (pWays != null) {
     659                    List<EastNorth> pts = pWays.getOffsetPoints();
     660                    if (pts.size() > 1) {
     661                        g.setStroke(PREVIEW_STROKE.get());
     662                        line = new MapViewPath(mv);
     663                        line.moveTo(pts.get(0));
     664                        for (int i = 1; i < pts.size(); i++) {
     665                            line.lineTo(pts.get(i));
     666                        }
     667                        if (pWays.isResultClosed()) {
     668                            line.lineTo(pts.get(0));
     669                        }
     670                        g.draw(line.computeClippedLine(g.getStroke()));
     671                    }
     672                }
    641673            }
    642674        }
    643675    }
  • src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java

    diff --git src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java
    index 8f2b297f2c..0a47bd93ca 100644
     
    22package org.openstreetmap.josm.actions.mapmode;
    33
    44import java.util.ArrayList;
     5import java.util.Arrays;
    56import java.util.Collection;
    67import java.util.Collections;
    7 import java.util.HashMap;
    88import java.util.HashSet;
    99import java.util.List;
    10 import java.util.Map;
    1110import java.util.Set;
     11import java.util.function.IntConsumer;
    1212import java.util.stream.IntStream;
    1313
    1414import org.openstreetmap.josm.command.AddCommand;
    import org.openstreetmap.josm.data.osm.Node;  
    2121import org.openstreetmap.josm.data.osm.NodeGraph;
    2222import org.openstreetmap.josm.data.osm.OsmDataManager;
    2323import org.openstreetmap.josm.data.osm.Way;
    24 import org.openstreetmap.josm.tools.Geometry;
    25 import org.openstreetmap.josm.tools.Utils;
    2624
    2725/**
    2826 * Helper for {@link ParallelWayAction}.
    29  *
    30  * @author Ole Jørgen Brønner (olejorgenb)
     27 * <p>
     28 * Computes a one-sided offset ("parallel") of a branchless path made of one or more ways.
     29 * <p>
     30 * The algorithm is a proper one-sided buffer, which also works when the offset is much larger than
     31 * the length of the segments of the source path (e.g. a maritime boundary 22 km off a coastline):
     32 * <ol>
     33 * <li>Each segment is offset by the requested distance.</li>
     34 * <li>At every vertex the two neighbouring offset segments are joined: on the inner side of a turn they are
     35 * clipped at their intersection, on the outer side a mitre is used for gentle turns and a circular arc
     36 * (approximated by chords) for sharp turns or when the mitre would overshoot too far.</li>
     37 * <li>The resulting raw polyline is split at its self-intersections. Every piece is kept only if it lies at
     38 * (at least) the offset distance from the source path. Pieces which are closer belong to inverted loops
     39 * ("swallowtails") and are dropped. The remaining pieces are chained; the longest chain is the result.</li>
     40 * </ol>
     41 * All calculations are done in projected coordinates.
     42 * <p>
     43 * Contrary to earlier versions the nodes and ways are only created (and added to the data set) by
     44 * {@link #commit()}, since the number of nodes of the result depends on the offset. Use
     45 * {@link #getOffsetPoints()} to draw a preview while the offset is changed.
    3146 */
    3247public class ParallelWays {
    33     private final List<Way> ways;
    34     private final List<Node> sortedNodes;
    3548
     49    /** Default angular step used to approximate circular arcs, in degrees */
     50    public static final double DEFAULT_ARC_STEP_DEGREES = 10;
     51
     52    private static final int NO_NODE = -1;
     53    /** marker for raw segments of the end caps, which are only used to trim the result and are never part of it */
     54    private static final int CAP = -2;
     55
     56    private final List<Way> sourceWays;
     57    private final boolean copyTags;
     58    private final double arcStep;
     59
     60    /** the source nodes, in path order (duplicates by coordinate removed, oriented like the reference way) */
     61    private final List<Node> sortedNodes;
     62    private final boolean closed;
    3663    private final int nodeCount;
    3764
    38     private final EastNorth[] pts;
    39     private final EastNorth[] normals;
     65    private final double[] px;
     66    private final double[] py;
     67    /** unit direction of segment i */
     68    private final double[] dirX;
     69    private final double[] dirY;
     70    private final double[] segLen;
     71    /** bounding boxes of the source segments, used to speed up distance computations */
     72    private final double[] segMinX;
     73    private final double[] segMinY;
     74    private final double[] segMaxX;
     75    private final double[] segMaxY;
     76
     77    /** whether the source way runs in the same direction as sortedNodes (aligned with sourceWays) */
     78    private final boolean[] wayForward;
     79    /** source way index for each source segment */
     80    private final int[] segWay;
     81
     82    // Spatial index of the source segments (rebuilt for every offset, since the cell size depends on it)
     83    private double gridCell;
     84    private double gridMinX;
     85    private double gridMinY;
     86    private int gridCols;
     87    private int gridRows;
     88    private int[][] gridCells;
     89    private int[] gridStamp;
     90    private int gridQuery;
     91
     92    // Result of the last changeOffset call
     93    private List<EastNorth> resultPts = Collections.emptyList();
     94    private int[] resultPieceSeg = new int[0];
     95    private int[] resultPointNode = new int[0];
     96    private boolean resultClosed;
     97
     98    private List<Way> ways = Collections.emptyList();
    4099
    41100    /**
    42101     * Constructs a new {@code ParallelWays}.
    43102     * @param sourceWays source ways
    44103     * @param copyTags whether tags should be copied
    45104     * @param refWayIndex Need a reference way to determine the direction of the offset when we manage multiple ways
     105     * @throws IllegalArgumentException if the ways do not form a branchless path
    46106     */
    47107    public ParallelWays(Collection<Way> sourceWays, boolean copyTags, int refWayIndex) {
    48         // Possible/sensible to use PrimitiveDeepCopy here?
    49 
    50         // Make a deep copy of the ways, keeping the copied ways connected
    51         // TODO: This assumes the first/last nodes of the ways are the only possible shared nodes.
    52         Map<Node, Node> splitNodeMap = new HashMap<>(Utils.hashMapInitialCapacity(sourceWays.size()));
    53         for (Way w : sourceWays) {
    54             copyNodeInMap(splitNodeMap, w.firstNode(), copyTags);
    55             copyNodeInMap(splitNodeMap, w.lastNode(), copyTags);
    56         }
    57         ways = new ArrayList<>(sourceWays.size());
    58         for (Way w : sourceWays) {
    59             Way wCopy = new Way();
    60             wCopy.addNode(splitNodeMap.get(w.firstNode()));
    61             for (int i = 1; i < w.getNodesCount() - 1; i++) {
    62                 wCopy.addNode(copyNode(w.getNode(i), copyTags));
    63             }
    64             wCopy.addNode(splitNodeMap.get(w.lastNode()));
    65             if (copyTags) {
    66                 wCopy.setKeys(w.getKeys());
    67             }
    68             ways.add(wCopy);
    69         }
     108        this(sourceWays, copyTags, refWayIndex, DEFAULT_ARC_STEP_DEGREES);
     109    }
     110
     111    /**
     112     * Constructs a new {@code ParallelWays}.
     113     * @param sourceWays source ways
     114     * @param copyTags whether tags should be copied
     115     * @param refWayIndex Need a reference way to determine the direction of the offset when we manage multiple ways
     116     * @param arcStepDegrees angular step (in degrees) of the chords approximating arcs at convex corners
     117     * @throws IllegalArgumentException if the ways do not form a branchless path
     118     * @since xxx
     119     */
     120    public ParallelWays(Collection<Way> sourceWays, boolean copyTags, int refWayIndex, double arcStepDegrees) {
     121        this.sourceWays = new ArrayList<>(sourceWays);
     122        this.copyTags = copyTags;
     123        this.arcStep = Math.toRadians(Math.max(1, Math.min(90, arcStepDegrees)));
    70124
    71125        // Find a linear ordering of the nodes. Fail if there isn't one.
    72         NodeGraph nodeGraph = NodeGraph.createUndirectedGraphFromNodeWays(ways);
     126        NodeGraph nodeGraph = NodeGraph.createUndirectedGraphFromNodeWays(this.sourceWays);
    73127        List<Node> sortedNodesPath = nodeGraph.buildSpanningPath();
    74         if (sortedNodesPath == null)
     128        if (sortedNodesPath == null || sortedNodesPath.size() < 2)
    75129            throw new IllegalArgumentException("Ways must have spanning path"); // Create a dedicated exception?
    76130
    77         // Fix #8631 - Remove duplicated nodes from graph to be robust with self-intersecting ways
    78         Set<Node> removedNodes = new HashSet<>();
    79         sortedNodes = new ArrayList<>();
    80         for (int i = 0; i < sortedNodesPath.size(); i++) {
    81             Node n = sortedNodesPath.get(i);
    82             if (i < sortedNodesPath.size()-1 && sortedNodesPath.get(i+1).getCoor().equals(n.getCoor())) {
    83                 removedNodes.add(n);
    84                 for (Way w : ways) {
    85                     w.removeNode(n);
    86                 }
    87                 continue;
    88             }
    89             if (!removedNodes.contains(n)) {
    90                 sortedNodes.add(n);
     131        List<Node> nodes = new ArrayList<>(sortedNodesPath.size());
     132        for (Node n : sortedNodesPath) {
     133            if (nodes.isEmpty() || !nodes.get(nodes.size() - 1).getEastNorth().equalsEpsilon(n.getEastNorth(), 1e-9)) {
     134                nodes.add(n);
    91135            }
    92136        }
     137        closed = nodes.size() > 2 && nodes.get(0) == nodes.get(nodes.size() - 1);
     138        if (closed) {
     139            nodes.remove(nodes.size() - 1);
     140        }
     141        if (nodes.size() < 2)
     142            throw new IllegalArgumentException("Ways must have spanning path");
    93143
    94         // Ugly method of ensuring that the offset isn't inverted. I'm sure there is a better and more elegant way
    95         Way refWay = ways.get(refWayIndex);
    96         boolean refWayReversed = IntStream.range(0, sortedNodes.size() - 1)
    97                 .noneMatch(i -> sortedNodes.get(i) == refWay.firstNode() && sortedNodes.get(i + 1) == refWay.getNode(1));
    98         if (refWayReversed) {
    99             Collections.reverse(sortedNodes); // need to keep the orientation of the reference way.
     144        Way refWay = this.sourceWays.get(refWayIndex);
     145        if (!isForward(nodes, refWay, closed)) {
     146            Collections.reverse(nodes); // need to keep the orientation of the reference way.
     147        }
     148        if (closed) {
     149            // rotate so that the path starts at a way boundary: every way is then a contiguous run of nodes
     150            Node start = this.sourceWays.stream().map(Way::firstNode).filter(nodes::contains).findFirst().orElse(nodes.get(0));
     151            Collections.rotate(nodes, -nodes.indexOf(start));
     152            nodes.add(nodes.get(0));
    100153        }
     154        sortedNodes = nodes;
     155        nodeCount = nodes.size();
    101156
    102         // Initialize the required parameters. (segment normals, etc.)
    103         nodeCount = sortedNodes.size();
    104         pts = new EastNorth[nodeCount];
    105         normals = new EastNorth[nodeCount - 1];
    106         int i = 0;
    107         for (Node n : sortedNodes) {
    108             EastNorth t = n.getEastNorth();
    109             pts[i] = t;
    110             i++;
     157        // Initialize the required parameters. (segment directions, etc.)
     158        px = new double[nodeCount];
     159        py = new double[nodeCount];
     160        for (int i = 0; i < nodeCount; i++) {
     161            EastNorth en = nodes.get(i).getEastNorth();
     162            px[i] = en.getX();
     163            py[i] = en.getY();
     164        }
     165        int segCount = nodeCount - 1;
     166        dirX = new double[segCount];
     167        dirY = new double[segCount];
     168        segLen = new double[segCount];
     169        segMinX = new double[segCount];
     170        segMinY = new double[segCount];
     171        segMaxX = new double[segCount];
     172        segMaxY = new double[segCount];
     173        for (int i = 0; i < segCount; i++) {
     174            double dx = px[i + 1] - px[i];
     175            double dy = py[i + 1] - py[i];
     176            double len = Math.hypot(dx, dy);
     177            segLen[i] = len;
     178            dirX[i] = dx / len;
     179            dirY[i] = dy / len;
     180            segMinX[i] = Math.min(px[i], px[i + 1]);
     181            segMaxX[i] = Math.max(px[i], px[i + 1]);
     182            segMinY[i] = Math.min(py[i], py[i + 1]);
     183            segMaxY[i] = Math.max(py[i], py[i + 1]);
    111184        }
    112         for (i = 0; i < nodeCount - 1; i++) {
    113             double dx = pts[i + 1].getX() - pts[i].getX();
    114             double dy = pts[i + 1].getY() - pts[i].getY();
    115             double len = Math.sqrt(dx * dx + dy * dy);
    116             normals[i] = new EastNorth(-dy / len, dx / len);
     185
     186        // Map the source ways onto the path
     187        int wayCount = this.sourceWays.size();
     188        wayForward = new boolean[wayCount];
     189        segWay = new int[segCount];
     190        Arrays.fill(segWay, -1);
     191        for (int w = 0; w < wayCount; w++) {
     192            Way way = this.sourceWays.get(w);
     193            Set<Node> wayNodes = new HashSet<>(way.getNodes());
     194            // indices (without the closing duplicate) of the path nodes belonging to this way
     195            boolean[] present = new boolean[nodeCount];
     196            int first = -1;
     197            int last = -1;
     198            for (int i = 0; i < nodeCount - (closed ? 1 : 0); i++) {
     199                if (wayNodes.contains(sortedNodes.get(i))) {
     200                    present[i] = true;
     201                    if (first < 0) {
     202                        first = i;
     203                    }
     204                    last = i;
     205                }
     206            }
     207            if (first < 0) {
     208                first = 0;
     209                last = 0;
     210            } else if (closed && present[0] && present[nodeCount - 2]) {
     211                // the run of this way wraps around the closing node: find where it starts
     212                int i = nodeCount - 2;
     213                while (i > 0 && present[i - 1]) {
     214                    i--;
     215                }
     216                first = i;
     217                last = nodeCount - 1;
     218            }
     219            wayForward[w] = isForward(sortedNodes.subList(first, last + 1), way, false);
     220            for (int i = first; i < last; i++) {
     221                segWay[i] = w;
     222            }
    117223        }
    118224    }
    119225
    120     private static void copyNodeInMap(Map<Node, Node> splitNodeMap, Node node, boolean copyTags) {
    121         if (!splitNodeMap.containsKey(node)) {
    122             splitNodeMap.put(node, copyNode(node, copyTags));
     226    /**
     227     * Checks whether a way runs in the same direction as a node list.
     228     * @param path the node list
     229     * @param way the way
     230     * @param cyclic whether the node list is a ring (without repeated closing node)
     231     * @return {@code true} if the first segment of the way, found in the path, has the same orientation
     232     */
     233    private static boolean isForward(List<Node> path, Way way, boolean cyclic) {
     234        int n = path.size();
     235        for (int k = 0; k < way.getNodesCount() - 1; k++) {
     236            int a = path.indexOf(way.getNode(k));
     237            int b = path.indexOf(way.getNode(k + 1));
     238            if (a >= 0 && b >= 0 && a != b) {
     239                if (cyclic) {
     240                    return ((b - a) % n + n) % n < n / 2.0;
     241                }
     242                return b > a;
     243            }
    123244        }
     245        return true;
    124246    }
    125247
    126248    /**
    public class ParallelWays {  
    128250     * @return {@code true} if the nodes graph form a closed path
    129251     */
    130252    public boolean isClosedPath() {
    131         return sortedNodes.get(0) == sortedNodes.get(sortedNodes.size() - 1);
     253        return closed;
    132254    }
    133255
    134256    /**
    public class ParallelWays {  
    136258     * @param d offset
    137259     */
    138260    public void changeOffset(double d) {
    139         // This is the core algorithm:
    140         /* 1. Calculate a parallel line, offset by 'd', to each segment in the path
    141          * 2. Find the intersection of lines belonging to neighboring segments. These become the new node positions
    142          * 3. Do some special casing for closed paths
    143          *
    144          * Simple and probably not even close to optimal performance wise
    145          */
    146 
    147         EastNorth[] ppts = new EastNorth[nodeCount];
    148 
    149         EastNorth prevA = pts[0].add(normals[0].scale(d));
    150         EastNorth prevB = pts[1].add(normals[0].scale(d));
    151         for (int i = 1; i < nodeCount - 1; i++) {
    152             EastNorth a = pts[i].add(normals[i].scale(d));
    153             EastNorth b = pts[i + 1].add(normals[i].scale(d));
    154             if (Geometry.segmentsParallel(a, b, prevA, prevB)) {
    155                 ppts[i] = a;
    156             } else {
    157                 ppts[i] = Geometry.getLineLineIntersection(a, b, prevA, prevB);
     261        if (d == 0 || Double.isNaN(d)) {
     262            // no offset: the result is a copy (for a ring without the repeated closing node)
     263            int pointCount = closed ? nodeCount - 1 : nodeCount;
     264            resultPts = new ArrayList<>(pointCount);
     265            for (int i = 0; i < pointCount; i++) {
     266                resultPts.add(new EastNorth(px[i], py[i]));
     267            }
     268            resultPieceSeg = IntStream.range(0, nodeCount - 1).toArray();
     269            resultPointNode = IntStream.range(0, pointCount).toArray();
     270            resultClosed = closed;
     271            return;
     272        }
     273        buildGrid(Math.abs(d));
     274        RawPolyline raw = buildRawOffset(d);
     275        trim(raw, Math.abs(d));
     276    }
     277
     278    /**
     279     * Builds a uniform grid over the source segments, with a cell size of (at least) r, so that all segments
     280     * within distance r of a point are found in the 3x3 cells around it.
     281     * @param r the (absolute) offset
     282     */
     283    private void buildGrid(double r) {
     284        double minX = Double.POSITIVE_INFINITY;
     285        double minY = Double.POSITIVE_INFINITY;
     286        double maxX = Double.NEGATIVE_INFINITY;
     287        double maxY = Double.NEGATIVE_INFINITY;
     288        for (int i = 0; i < nodeCount; i++) {
     289            minX = Math.min(minX, px[i]);
     290            maxX = Math.max(maxX, px[i]);
     291            minY = Math.min(minY, py[i]);
     292            maxY = Math.max(maxY, py[i]);
     293        }
     294        double extent = Math.max(maxX - minX, maxY - minY);
     295        gridCell = Math.max(r, extent / 128);
     296        gridMinX = minX;
     297        gridMinY = minY;
     298        gridCols = (int) ((maxX - minX) / gridCell) + 1;
     299        gridRows = (int) ((maxY - minY) / gridCell) + 1;
     300        int[] counts = new int[gridCols * gridRows];
     301        int segCount = nodeCount - 1;
     302        for (int i = 0; i < segCount; i++) {
     303            forEachCell(segMinX[i], segMinY[i], segMaxX[i], segMaxY[i], c -> counts[c]++);
     304        }
     305        gridCells = new int[counts.length][];
     306        for (int c = 0; c < counts.length; c++) {
     307            gridCells[c] = new int[counts[c]];
     308            counts[c] = 0;
     309        }
     310        for (int i = 0; i < segCount; i++) {
     311            final int seg = i;
     312            forEachCell(segMinX[i], segMinY[i], segMaxX[i], segMaxY[i], c -> gridCells[c][counts[c]++] = seg);
     313        }
     314        gridStamp = new int[segCount];
     315        gridQuery = 0;
     316    }
     317
     318    private void forEachCell(double minX, double minY, double maxX, double maxY, IntConsumer consumer) {
     319        int c0 = Math.max(0, (int) ((minX - gridMinX) / gridCell));
     320        int c1 = Math.min(gridCols - 1, (int) ((maxX - gridMinX) / gridCell));
     321        int r0 = Math.max(0, (int) ((minY - gridMinY) / gridCell));
     322        int r1 = Math.min(gridRows - 1, (int) ((maxY - gridMinY) / gridCell));
     323        for (int row = r0; row <= r1; row++) {
     324            for (int col = c0; col <= c1; col++) {
     325                consumer.accept(row * gridCols + col);
     326            }
     327        }
     328    }
     329
     330    // ---------------------------------------------------------------------------------------------------------
     331    // Step 1: raw offset polyline
     332
     333    /**
     334     * The raw (untrimmed) offset polyline. Segment k runs from point k to point k+1 (or to point 0 for the last
     335     * segment of a closed polyline). The attributes of a segment are stored at its end point.
     336     */
     337    private static final class RawPolyline {
     338        double[] x = new double[64];
     339        double[] y = new double[64];
     340        /** index of the source node a point was derived from, or {@link #NO_NODE} */
     341        int[] node = new int[64];
     342        /** source segment index of the segment ending at this point, or {@link #CAP} */
     343        int[] seg = new int[64];
     344        /** if the segment ending at this point is an arc chord: the source node the arc is centered on, else NO_NODE */
     345        int[] arcCenter = new int[64];
     346        int size;
     347
     348        /** for each source node with an arc: raw index of the first arc point, else -1 */
     349        final int[] arcStart;
     350        /** number of chords of the arc at each source node */
     351        final int[] arcChords;
     352        /** whether the arc can be replaced by a mitre when it is not affected by the trimming */
     353        final boolean[] arcMitre;
     354        final double[] mitreX;
     355        final double[] mitreY;
     356
     357        RawPolyline(int nodeCount) {
     358            arcStart = new int[nodeCount];
     359            Arrays.fill(arcStart, -1);
     360            arcChords = new int[nodeCount];
     361            arcMitre = new boolean[nodeCount];
     362            mitreX = new double[nodeCount];
     363            mitreY = new double[nodeCount];
     364        }
     365
     366        void add(double px, double py, int srcNode, int srcSeg, int arc) {
     367            if (size == x.length) {
     368                int n = size * 2;
     369                x = Arrays.copyOf(x, n);
     370                y = Arrays.copyOf(y, n);
     371                node = Arrays.copyOf(node, n);
     372                seg = Arrays.copyOf(seg, n);
     373                arcCenter = Arrays.copyOf(arcCenter, n);
    158374            }
    159             prevA = a;
    160             prevB = b;
     375            x[size] = px;
     376            y[size] = py;
     377            node[size] = srcNode;
     378            seg[size] = srcSeg;
     379            arcCenter[size] = arc;
     380            size++;
     381        }
     382    }
     383
     384    private RawPolyline buildRawOffset(double d) {
     385        RawPolyline raw = new RawPolyline(nodeCount);
     386        int segCount = nodeCount - 1;
     387        if (closed) {
     388            addJoin(raw, 0, segCount - 1, 0, d);
     389        } else {
     390            // Start cap: half circle on the back side of the first node. It is never part of the result, but
     391            // trims pieces of the offset which come closer than d to the first node.
     392            addCap(raw, 0, Math.atan2(-dirX[0] * d, dirY[0] * d), d > 0 ? -1 : 1, d, true);
     393            raw.add(px[0] - dirY[0] * d, py[0] + dirX[0] * d, 0, CAP, NO_NODE);
     394        }
     395        for (int k = 1; k < segCount; k++) {
     396            addJoin(raw, k, k - 1, k, d);
     397        }
     398        if (!closed) {
     399            int s = segCount - 1;
     400            raw.add(px[s + 1] - dirY[s] * d, py[s + 1] + dirX[s] * d, s + 1, s, NO_NODE);
     401            // End cap: half circle on the front side of the last node
     402            addCap(raw, s + 1, Math.atan2(dirX[s] * d, -dirY[s] * d), d > 0 ? -1 : 1, d, false);
     403        } else {
     404            // the closing segment ends at raw point 0
     405            raw.seg[0] = segCount - 1;
     406            raw.arcCenter[0] = NO_NODE;
     407        }
     408        return raw;
     409    }
     410
     411    /**
     412     * Adds the chords of a half circle around node {@code k}, starting at the given angle. The chords are marked
     413     * as {@link #CAP}: they are only used to trim other pieces.
     414     * @param raw the raw polyline
     415     * @param k source node index
     416     * @param startAngle angle of the first point of the half circle
     417     * @param direction rotation direction (+1 counter clockwise)
     418     * @param d the offset
     419     * @param leading {@code true} if the cap precedes the offset path (the end point of the half circle is then
     420     * added by the caller), {@code false} if it follows it (the start point has been added by the caller)
     421     */
     422    private void addCap(RawPolyline raw, int k, double startAngle, int direction, double d, boolean leading) {
     423        double r = Math.abs(d);
     424        int steps = (int) Math.ceil(Math.PI / arcStep - 1e-9);
     425        for (int j = leading ? 0 : 1; j < (leading ? steps : steps + 1); j++) {
     426            double angle = startAngle + direction * Math.PI * j / steps;
     427            raw.add(px[k] + r * Math.cos(angle), py[k] + r * Math.sin(angle), NO_NODE, CAP, NO_NODE);
     428        }
     429    }
     430
     431    /**
     432     * Adds the offset points at vertex {@code k}, where segment {@code prev} ends and segment {@code next} starts.
     433     * @param raw the raw polyline
     434     * @param k source node index
     435     * @param prev index of the segment ending at k
     436     * @param next index of the segment starting at k
     437     * @param d the offset
     438     */
     439    private void addJoin(RawPolyline raw, int k, int prev, int next, double d) {
     440        double r = Math.abs(d);
     441        // offset end point of prev, offset start point of next
     442        double bx = px[k] - dirY[prev] * d;
     443        double by = py[k] + dirX[prev] * d;
     444        double ax = px[k] - dirY[next] * d;
     445        double ay = py[k] + dirX[next] * d;
     446
     447        double cross = dirX[prev] * dirY[next] - dirY[prev] * dirX[next];
     448        double dot = dirX[prev] * dirX[next] + dirY[prev] * dirY[next];
     449        double theta = Math.atan2(Math.abs(cross), dot); // turn angle in [0, pi]
     450
     451        if (theta < 1e-9) {
     452            // collinear: the offset points coincide
     453            raw.add(bx, by, k, prev, NO_NODE);
     454            return;
    161455        }
    162         if (isClosedPath()) {
    163             EastNorth a = pts[0].add(normals[0].scale(d));
    164             EastNorth b = pts[1].add(normals[0].scale(d));
    165             if (Geometry.segmentsParallel(a, b, prevA, prevB)) {
    166                 ppts[0] = a;
     456        boolean concave = cross * d > 0; // the turn goes towards the offset side
     457        if (concave && theta < Math.PI - 1e-9) {
     458            // Clip: intersect the two offset segments.
     459            // X = b + t*dir[prev] with -len[prev] <= t <= 0 and X = a + s*dir[next] with 0 <= s <= len[next]
     460            double ex = ax - bx;
     461            double ey = ay - by;
     462            double t = (ex * dirY[next] - ey * dirX[next]) / cross;
     463            double s = (ex * dirY[prev] - ey * dirX[prev]) / cross;
     464            if (-t <= segLen[prev] && s <= segLen[next] && t <= 1e-9 && s >= -1e-9) {
     465                raw.add(bx + t * dirX[prev], by + t * dirY[prev], k, prev, NO_NODE);
    167466            } else {
    168                 ppts[0] = Geometry.getLineLineIntersection(a, b, prevA, prevB);
     467                // The offset is larger than the neighbouring segments allow: local inversion.
     468                // Emit both points; the inverted part is removed when trimming.
     469                raw.add(bx, by, k, prev, NO_NODE);
     470                raw.add(ax, ay, NO_NODE, next, k);
    169471            }
    170             ppts[nodeCount - 1] = ppts[0];
     472            return;
     473        }
     474        // Convex corner (or a hairpin): arc from b to a around the vertex. If the arc survives the trimming
     475        // untouched, it may later be replaced by a mitre (for gentle corners, see arcMitre).
     476        raw.add(bx, by, k, prev, NO_NODE);
     477        int steps = (int) Math.ceil(theta / arcStep - 1e-9);
     478        double angle0 = Math.atan2(by - py[k], bx - px[k]);
     479        double delta;
     480        if (Math.abs(cross) < 1e-12) {
     481            // hairpin: rotate away from the segments, i.e. towards dir[prev]
     482            delta = theta * (d > 0 ? -1 : 1);
    171483        } else {
    172             ppts[0] = pts[0].add(normals[0].scale(d));
    173             ppts[nodeCount - 1] = pts[nodeCount - 1].add(normals[nodeCount - 2].scale(d));
     484            delta = theta * (cross >= 0 ? 1 : -1);
     485        }
     486        raw.arcStart[k] = raw.size - 1;
     487        raw.arcChords[k] = steps;
     488        for (int j = 1; j < steps; j++) {
     489            double angle = angle0 + delta * j / steps;
     490            raw.add(px[k] + r * Math.cos(angle), py[k] + r * Math.sin(angle), NO_NODE, next, k);
    174491        }
     492        raw.add(ax, ay, NO_NODE, next, k);
    175493
    176         for (int i = 0; i < nodeCount; i++) {
    177             sortedNodes.get(i).setEastNorth(ppts[i]);
     494        if (theta < Math.PI - 1e-6) {
     495            double mitreDist = r / Math.cos(theta / 2);
     496            double overshoot = mitreDist - r;
     497            raw.arcMitre[k] = theta <= arcStep || overshoot <= 0.5 * Math.min(segLen[prev], segLen[next]);
     498            // the mitre point lies on the bisector of the two normals
     499            double mx = (bx - px[k]) + (ax - px[k]);
     500            double my = (by - py[k]) + (ay - py[k]);
     501            double ml = Math.hypot(mx, my);
     502            raw.mitreX[k] = px[k] + mx / ml * mitreDist;
     503            raw.mitreY[k] = py[k] + my / ml * mitreDist;
     504        }
     505    }
     506
     507    // ---------------------------------------------------------------------------------------------------------
     508    // Step 2: trimming
     509
     510    /** Growable list of pieces (sub segments of the raw polyline) */
     511    private static final class Pieces {
     512        int[] start = new int[256];
     513        int[] end = new int[256];
     514        int[] rawSeg = new int[256];
     515        boolean[] valid = new boolean[256];
     516        int size;
     517
     518        void add(int s, int e, int seg, boolean v) {
     519            if (size == start.length) {
     520                int n = size * 2;
     521                start = Arrays.copyOf(start, n);
     522                end = Arrays.copyOf(end, n);
     523                rawSeg = Arrays.copyOf(rawSeg, n);
     524                valid = Arrays.copyOf(valid, n);
     525            }
     526            start[size] = s;
     527            end[size] = e;
     528            rawSeg[size] = seg;
     529            valid[size] = v;
     530            size++;
    178531        }
    179532    }
    180533
     534    /** Point table used while trimming: raw points first, then intersection and transition points */
     535    private static final class Points {
     536        final List<double[]> xy = new ArrayList<>();
     537        final List<Integer> node = new ArrayList<>();
     538
     539        int add(double x, double y, int srcNode) {
     540            xy.add(new double[] {x, y});
     541            node.add(srcNode);
     542            return xy.size() - 1;
     543        }
     544
     545        double[] get(int id) {
     546            return xy.get(id);
     547        }
     548
     549        double distance(int a, int b) {
     550            double[] p = xy.get(a);
     551            double[] q = xy.get(b);
     552            return Math.hypot(q[0] - p[0], q[1] - p[1]);
     553        }
     554    }
     555
     556    private void trim(RawPolyline raw, double r) {
     557        int m = raw.size;
     558        int rawSegCount = closed ? m : m - 1;
     559        if (rawSegCount < 1) {
     560            setEmptyResult();
     561            return;
     562        }
     563
     564        Points points = new Points();
     565        for (int i = 0; i < m; i++) {
     566            points.add(raw.x[i], raw.y[i], raw.node[i]);
     567        }
     568        // Raw segments which lie completely inside the offset distance ("deep") can neither be part of the result
     569        // nor trim it: skip them when looking for intersections. This is a huge saving for large offsets.
     570        boolean[] deep = new boolean[rawSegCount];
     571        double tolerance = r * 1e-7 + 1e-7;
     572        double sagitta = r * (1 - Math.cos(arcStep / 2));
     573        for (int i = 0; i < rawSegCount; i++) {
     574            int endPoint = (i + 1) % m;
     575            double mx = (raw.x[i] + raw.x[endPoint]) / 2;
     576            double my = (raw.y[i] + raw.y[endPoint]) / 2;
     577            double halfLen = Math.hypot(raw.x[endPoint] - raw.x[i], raw.y[endPoint] - raw.y[i]) / 2;
     578            // every point of the segment is within halfLen (+ sagitta for a chord) of the middle
     579            double cutoff = r - halfLen - tolerance - (raw.arcCenter[endPoint] != NO_NODE ? sagitta : 0);
     580            deep[i] = cutoff > 0 && distanceToSource(mx, my, cutoff) < cutoff;
     581        }
     582
     583        // breakpoints per raw segment: parallel lists of (param, pointId)
     584        List<List<double[]>> breaks = new ArrayList<>(Collections.nCopies(rawSegCount, null));
     585        findSelfIntersections(raw, rawSegCount, deep, points, breaks);
     586
     587        // Build the pieces and determine their validity: a piece must lie at (at least) distance r from the source
     588        Pieces pieces = new Pieces();
     589        boolean anyInvalid = false;
     590        for (int i = 0; i < rawSegCount; i++) {
     591            int endPoint = (i + 1) % m;
     592            if (deep[i]) {
     593                pieces.add(i, endPoint, i, false);
     594                continue;
     595            }
     596            List<double[]> b = breaks.get(i);
     597            int prevId = i;
     598            if (b != null) {
     599                b.sort((o1, o2) -> Double.compare(o1[0], o2[0]));
     600                for (double[] bp : b) {
     601                    classify(raw, r, points, pieces, i, prevId, (int) bp[1]);
     602                    prevId = (int) bp[1];
     603                }
     604            }
     605            classify(raw, r, points, pieces, i, prevId, endPoint);
     606        }
     607        for (int p = 0; p < pieces.size; p++) {
     608            anyInvalid |= !pieces.valid[p];
     609        }
     610
     611        // Chain the valid pieces. Consecutive valid pieces normally meet at a common point (self-intersection or
     612        // transition). Chord approximations of arcs can leave small gaps - such gaps are bridged; larger gaps
     613        // separate different components (e.g. inner loops).
     614        double bridgeTolerance = 2 * r * Math.sin(arcStep / 2);
     615        double spikeTolerance = 2 * r * (1 - Math.cos(arcStep / 2)) + tolerance;
     616        List<Chain> chains = new ArrayList<>();
     617        int pieceCount = pieces.size;
     618        int startPiece = 0;
     619        if (closed && anyInvalid) {
     620            while (pieces.valid[startPiece]) {
     621                startPiece++;
     622            }
     623            startPiece = (startPiece + 1) % pieceCount;
     624        }
     625        Chain current = null;
     626        for (int c = 0; c < pieceCount; c++) {
     627            int p = (startPiece + c) % pieceCount;
     628            if (!pieces.valid[p]) {
     629                continue;
     630            }
     631            int srcSeg = raw.seg[(pieces.rawSeg[p] + 1) % m];
     632            if (current != null && current.lastId != pieces.start[p]) {
     633                if (points.distance(current.lastId, pieces.start[p]) <= bridgeTolerance) {
     634                    current.append(pieces.start[p], srcSeg, points);
     635                } else {
     636                    chains.add(current);
     637                    current = null;
     638                }
     639            }
     640            if (current == null) {
     641                current = new Chain(pieces.start[p], spikeTolerance);
     642            }
     643            current.append(pieces.end[p], srcSeg, points);
     644        }
     645        if (current != null) {
     646            chains.add(current);
     647        }
     648        if (closed && chains.size() > 1) {
     649            Chain first = chains.get(0);
     650            Chain last = chains.get(chains.size() - 1);
     651            int firstId = first.ids.get(0);
     652            if (last.lastId == firstId || points.distance(last.lastId, firstId) <= bridgeTolerance) {
     653                if (last.lastId != firstId) {
     654                    last.append(firstId, first.segs.get(0), points);
     655                }
     656                last.appendChain(first);
     657                chains.remove(0);
     658            }
     659        }
     660        if (chains.isEmpty()) {
     661            setEmptyResult();
     662            return;
     663        }
     664        Chain best = chains.get(0);
     665        for (Chain ch : chains) {
     666            if (ch.length > best.length) {
     667                best = ch;
     668            }
     669        }
     670        if (closed && best.lastId != best.ids.get(0) && points.distance(best.lastId, best.ids.get(0)) <= bridgeTolerance) {
     671            // close a ring which is open by a small gap only
     672            best.append(best.ids.get(0), best.segs.get(best.segs.size() - 1), points);
     673        }
     674
     675        // Convert to the result
     676        boolean isRing = closed && best.ids.size() > 2 && best.ids.get(0) == best.lastId;
     677        List<Integer> ids = new ArrayList<>(best.ids);
     678        List<Integer> segs = new ArrayList<>(best.segs);
     679        replaceIntactArcsByMitres(raw, points, ids, segs);
     680        if (isRing) {
     681            ids.remove(ids.size() - 1);
     682            // rotate so that the ring starts at a way boundary
     683            int n = segs.size();
     684            int rot = 0;
     685            for (int i = 0; i < n; i++) {
     686                if (segWay[segs.get((i + n - 1) % n)] != segWay[segs.get(i)]) {
     687                    rot = i;
     688                    break;
     689                }
     690            }
     691            Collections.rotate(ids, -rot);
     692            Collections.rotate(segs, -rot);
     693        }
     694        resultPts = new ArrayList<>(ids.size());
     695        resultPointNode = new int[ids.size()];
     696        for (int i = 0; i < ids.size(); i++) {
     697            int id = ids.get(i);
     698            double[] pt = points.get(id);
     699            resultPts.add(new EastNorth(pt[0], pt[1]));
     700            resultPointNode[i] = points.node.get(id);
     701        }
     702        resultPieceSeg = segs.stream().mapToInt(Integer::intValue).toArray();
     703        resultClosed = isRing;
     704    }
     705
     706    private void setEmptyResult() {
     707        resultPts = Collections.emptyList();
     708        resultPieceSeg = new int[0];
     709        resultPointNode = new int[0];
     710        resultClosed = false;
     711    }
     712
     713    /**
     714     * Determines the validity of the piece of raw segment {@code i} between two points, and adds the resulting
     715     * piece(s). The validity is sampled at both ends and in the middle; if it changes, the transition point is
     716     * located and the piece is split there.
     717     */
     718    private void classify(RawPolyline raw, double r, Points points, Pieces pieces, int i, int sId, int eId) {
     719        int endPoint = (i + 1) % raw.size;
     720        if (raw.seg[endPoint] == CAP) {
     721            pieces.add(sId, eId, i, false); // end caps are never part of the result
     722            return;
     723        }
     724        int arc = raw.arcCenter[endPoint];
     725        double[] s = points.get(sId);
     726        double[] e = points.get(eId);
     727        double tolerance = r * 1e-7 + 1e-7;
     728        boolean vs = isValid(s[0], s[1], r, tolerance, arc);
     729        boolean ve = isValid(e[0], e[1], r, tolerance, arc);
     730        boolean vm = isValid(s[0] + (e[0] - s[0]) / 2, s[1] + (e[1] - s[1]) / 2, r, tolerance, arc);
     731        if (vs == vm && vm == ve) {
     732            pieces.add(sId, eId, i, vm);
     733            return;
     734        }
     735        // Transitions very close to an end point are artefacts of the chord approximation (a point where two
     736        // chords cross lies inside both circles): the validity of the middle is then used for the whole piece.
     737        double snapTolerance = 2 * r * (1 - Math.cos(arcStep / 2)) + tolerance;
     738        double len = Math.hypot(e[0] - s[0], e[1] - s[1]);
     739        int prev = sId;
     740        boolean state = vs;
     741        if (vs != vm) {
     742            double t1 = locateTransition(s, e, 0, 0.5, r, tolerance, arc);
     743            if (t1 * len <= snapTolerance) {
     744                state = vm;
     745            } else {
     746                int id = points.add(s[0] + (e[0] - s[0]) * t1, s[1] + (e[1] - s[1]) * t1, NO_NODE);
     747                pieces.add(prev, id, i, state);
     748                prev = id;
     749                state = vm;
     750            }
     751        }
     752        if (vm != ve) {
     753            double t2 = locateTransition(s, e, 0.5, 1, r, tolerance, arc);
     754            if ((1 - t2) * len > snapTolerance) {
     755                int id = points.add(s[0] + (e[0] - s[0]) * t2, s[1] + (e[1] - s[1]) * t2, NO_NODE);
     756                pieces.add(prev, id, i, state);
     757                prev = id;
     758                state = ve;
     759            }
     760        }
     761        pieces.add(prev, eId, i, state);
     762    }
     763
     764    /**
     765     * Locates (by bisection) the parameter between {@code a} and {@code b} where the validity changes.
     766     * @return the parameter of the transition
     767     */
     768    private double locateTransition(double[] s, double[] e, double a, double b, double r, double tolerance, int arc) {
     769        boolean va = isValid(s[0] + (e[0] - s[0]) * a, s[1] + (e[1] - s[1]) * a, r, tolerance, arc);
     770        for (int it = 0; it < 40 && b - a > 1e-12; it++) {
     771            double mid = (a + b) / 2;
     772            boolean vmid = isValid(s[0] + (e[0] - s[0]) * mid, s[1] + (e[1] - s[1]) * mid, r, tolerance, arc);
     773            if (vmid == va) {
     774                a = mid;
     775            } else {
     776                b = mid;
     777            }
     778        }
     779        return (a + b) / 2;
     780    }
     781
    181782    /**
    182      * Performs the action by adding a new sequence command to the undo/redo queue.
     783     * Checks whether a point of the raw polyline lies at (at least) distance r from the source path.
     784     * @param x point
     785     * @param y point
     786     * @param r offset
     787     * @param tolerance allowed deficit
     788     * @param arc if the point lies on an arc chord: the center node of the arc; the point is then projected onto
     789     * the arc before testing, else {@link #NO_NODE}
     790     * @return {@code true} if the point is valid
     791     */
     792    private boolean isValid(double x, double y, double r, double tolerance, int arc) {
     793        if (arc != NO_NODE) {
     794            double vx = x - px[arc];
     795            double vy = y - py[arc];
     796            double vl = Math.hypot(vx, vy);
     797            if (vl > 0) {
     798                x = px[arc] + vx / vl * r;
     799                y = py[arc] + vy / vl * r;
     800            }
     801        }
     802        return distanceToSource(x, y, r - tolerance) >= r - tolerance;
     803    }
     804
     805    /**
     806     * Replaces arcs which are completely part of the result by a mitre (where the corner is gentle enough).
     807     */
     808    private static void replaceIntactArcsByMitres(RawPolyline raw, Points points, List<Integer> ids, List<Integer> segs) {
     809        int[] arcOfRawPoint = new int[raw.size];
     810        Arrays.fill(arcOfRawPoint, NO_NODE);
     811        for (int k = 0; k < raw.arcStart.length; k++) {
     812            if (raw.arcStart[k] >= 0 && raw.arcMitre[k]) {
     813                arcOfRawPoint[raw.arcStart[k]] = k;
     814            }
     815        }
     816        for (int i = 0; i < ids.size(); i++) {
     817            int id = ids.get(i);
     818            if (id >= raw.size || arcOfRawPoint[id] == NO_NODE) {
     819                continue;
     820            }
     821            int k = arcOfRawPoint[id];
     822            int chords = raw.arcChords[k];
     823            if (i + chords >= ids.size()) {
     824                continue;
     825            }
     826            boolean intact = true;
     827            for (int j = 1; j <= chords && intact; j++) {
     828                intact = ids.get(i + j) == id + j;
     829            }
     830            if (!intact) {
     831                continue;
     832            }
     833            int mitreId = points.add(raw.mitreX[k], raw.mitreY[k], k);
     834            // the chord pieces i..i+chords-1 collapse into two pieces: (i-1 -> mitre), (mitre -> i+chords)
     835            ids.set(i, mitreId);
     836            ids.subList(i + 1, i + chords + 1).clear();
     837            // segs.get(j) belongs to the piece ending at ids.get(j+1); keep the segment of the last chord for the
     838            // piece leaving the mitre, and drop the others
     839            segs.subList(i, i + chords).clear();
     840        }
     841    }
     842
     843    /** A chain of connected valid pieces */
     844    private static final class Chain {
     845        final List<Integer> ids = new ArrayList<>();
     846        final List<Integer> segs = new ArrayList<>();
     847        final double spikeTolerance;
     848        int lastId;
     849        double length;
     850
     851        Chain(int startId, double spikeTolerance) {
     852            ids.add(startId);
     853            lastId = startId;
     854            this.spikeTolerance = spikeTolerance;
     855        }
     856
     857        void append(int endId, int srcSeg, Points points) {
     858            lastId = endId;
     859            if (ids.size() >= 2 && points.distance(ids.get(ids.size() - 2), endId) <= spikeTolerance) {
     860                // tiny out-and-back spike (an artefact of the chord approximation at a crossing): drop its tip
     861                int tip = ids.remove(ids.size() - 1);
     862                segs.remove(segs.size() - 1);
     863                length -= points.distance(ids.get(ids.size() - 1), tip);
     864            }
     865            double len = points.distance(ids.get(ids.size() - 1), endId);
     866            if (len < 1e-9) {
     867                // zero length piece: keep the connectivity, but don't add a point
     868                return;
     869            }
     870            length += len;
     871            ids.add(endId);
     872            segs.add(srcSeg);
     873        }
     874
     875        void appendChain(Chain other) {
     876            ids.addAll(other.ids.subList(1, other.ids.size()));
     877            segs.addAll(other.segs);
     878            lastId = other.lastId;
     879            length += other.length;
     880        }
     881    }
     882
     883    /**
     884     * Finds all intersections between non adjacent segments of the raw polyline (sweep on x).
     885     * @param raw the raw polyline
     886     * @param rawSegCount number of raw segments
     887     * @param skip raw segments to ignore
     888     * @param points point table, intersection points are appended
     889     * @param breaks per raw segment list of (param, pointId), filled
     890     */
     891    private void findSelfIntersections(RawPolyline raw, int rawSegCount, boolean[] skip, Points points,
     892            List<List<double[]>> breaks) {
     893        int m = raw.size;
     894        double[] minX = new double[rawSegCount];
     895        double[] maxX = new double[rawSegCount];
     896        double[] minY = new double[rawSegCount];
     897        double[] maxY = new double[rawSegCount];
     898        int count = 0;
     899        Integer[] order = new Integer[rawSegCount];
     900        for (int i = 0; i < rawSegCount; i++) {
     901            if (skip[i]) {
     902                continue;
     903            }
     904            int j = (i + 1) % m;
     905            minX[i] = Math.min(raw.x[i], raw.x[j]);
     906            maxX[i] = Math.max(raw.x[i], raw.x[j]);
     907            minY[i] = Math.min(raw.y[i], raw.y[j]);
     908            maxY[i] = Math.max(raw.y[i], raw.y[j]);
     909            order[count++] = i;
     910        }
     911        Arrays.sort(order, 0, count, (a, b) -> Double.compare(minX[a], minX[b]));
     912        double[] uv = new double[2];
     913        for (int oi = 0; oi < count; oi++) {
     914            int i = order[oi];
     915            for (int oj = oi + 1; oj < count; oj++) {
     916                int j = order[oj];
     917                if (minX[j] > maxX[i]) {
     918                    break;
     919                }
     920                if (minY[j] > maxY[i] || maxY[j] < minY[i]) {
     921                    continue;
     922                }
     923                int lo = Math.min(i, j);
     924                int hi = Math.max(i, j);
     925                if (hi - lo == 1 || (closed && lo == 0 && hi == rawSegCount - 1)) {
     926                    continue; // adjacent
     927                }
     928                int i2 = (i + 1) % m;
     929                int j2 = (j + 1) % m;
     930                if (segmentIntersection(raw.x[i], raw.y[i], raw.x[i2], raw.y[i2], raw.x[j], raw.y[j], raw.x[j2], raw.y[j2], uv)) {
     931                    int id = points.add(raw.x[i] + (raw.x[i2] - raw.x[i]) * uv[0], raw.y[i] + (raw.y[i2] - raw.y[i]) * uv[0], NO_NODE);
     932                    if (breaks.get(i) == null) {
     933                        breaks.set(i, new ArrayList<>(2));
     934                    }
     935                    if (breaks.get(j) == null) {
     936                        breaks.set(j, new ArrayList<>(2));
     937                    }
     938                    breaks.get(i).add(new double[] {uv[0], id});
     939                    breaks.get(j).add(new double[] {uv[1], id});
     940                }
     941            }
     942        }
     943    }
     944
     945    /**
     946     * Segment/segment intersection.
     947     * @param uv output: parameters along the first and the second segment
     948     * @return true if the segments intersect (touching end points count as intersection)
     949     */
     950    private static boolean segmentIntersection(double x1, double y1, double x2, double y2,
     951            double x3, double y3, double x4, double y4, double[] uv) {
     952        double a1 = x2 - x1;
     953        double a2 = y2 - y1;
     954        double b1 = x3 - x4;
     955        double b2 = y3 - y4;
     956        double c1 = x3 - x1;
     957        double c2 = y3 - y1;
     958        double det = a1 * b2 - a2 * b1;
     959        double uu = b2 * c1 - b1 * c2;
     960        double vv = a1 * c2 - a2 * c1;
     961        double mag = Math.abs(uu) + Math.abs(vv);
     962        if (det == 0 || Math.abs(det) <= 1e-12 * mag) {
     963            return false; // parallel or collinear
     964        }
     965        double u = uu / det;
     966        double v = vv / det;
     967        if (u < -1e-9 || u > 1 + 1e-9 || v < -1e-9 || v > 1 + 1e-9) {
     968            return false;
     969        }
     970        uv[0] = Math.max(0, Math.min(1, u));
     971        uv[1] = Math.max(0, Math.min(1, v));
     972        return true;
     973    }
     974
     975    /**
     976     * Distance from a point to the source path.
     977     * @param x point
     978     * @param y point
     979     * @param cutoff the search can stop as soon as a distance below this value is found
     980     * @return the distance (or any value below cutoff if such a distance exists)
     981     */
     982    private double distanceToSource(double x, double y, double cutoff) {
     983        // Only segments within the cutoff matter (the result is only compared against it); with a cell size of
     984        // at least the offset they all lie in the 3x3 cells around the point.
     985        int col = (int) Math.floor((x - gridMinX) / gridCell);
     986        int row = (int) Math.floor((y - gridMinY) / gridCell);
     987        if (col < -1 || col > gridCols || row < -1 || row > gridRows) {
     988            return Double.POSITIVE_INFINITY;
     989        }
     990        gridQuery++;
     991        double best = Double.POSITIVE_INFINITY;
     992        double bestSq = Double.POSITIVE_INFINITY;
     993        double cutoffSq = cutoff * cutoff;
     994        for (int rr = Math.max(0, row - 1); rr <= Math.min(gridRows - 1, row + 1); rr++) {
     995            for (int cc = Math.max(0, col - 1); cc <= Math.min(gridCols - 1, col + 1); cc++) {
     996                for (int i : gridCells[rr * gridCols + cc]) {
     997                    if (gridStamp[i] == gridQuery) {
     998                        continue;
     999                    }
     1000                    gridStamp[i] = gridQuery;
     1001                    if (x < segMinX[i] - best || x > segMaxX[i] + best || y < segMinY[i] - best || y > segMaxY[i] + best) {
     1002                        continue;
     1003                    }
     1004                    double rx = x - px[i];
     1005                    double ry = y - py[i];
     1006                    double t = rx * dirX[i] + ry * dirY[i];
     1007                    double dSq;
     1008                    if (t <= 0) {
     1009                        dSq = rx * rx + ry * ry;
     1010                    } else if (t >= segLen[i]) {
     1011                        double ex = x - px[i + 1];
     1012                        double ey = y - py[i + 1];
     1013                        dSq = ex * ex + ey * ey;
     1014                    } else {
     1015                        double c = rx * dirY[i] - ry * dirX[i];
     1016                        dSq = c * c;
     1017                    }
     1018                    if (dSq < bestSq) {
     1019                        bestSq = dSq;
     1020                        best = Math.sqrt(dSq);
     1021                        if (bestSq < cutoffSq) {
     1022                            return best;
     1023                        }
     1024                    }
     1025                }
     1026            }
     1027        }
     1028        return best;
     1029    }
     1030
     1031    // ---------------------------------------------------------------------------------------------------------
     1032    // Result access and commit
     1033
     1034    /**
     1035     * Returns the points of the offset path computed by the last call to {@link #changeOffset(double)}.
     1036     * For a closed result the first point is not repeated at the end, see {@link #isResultClosed()}.
     1037     * @return the offset points (projected coordinates), empty if nothing has been computed or nothing remains
     1038     * @since xxx
     1039     */
     1040    public List<EastNorth> getOffsetPoints() {
     1041        return Collections.unmodifiableList(resultPts);
     1042    }
     1043
     1044    /**
     1045     * Determines if the result of the last {@link #changeOffset(double)} call is a closed ring.
     1046     * @return {@code true} if the offset path is a closed ring
     1047     * @since xxx
     1048     */
     1049    public boolean isResultClosed() {
     1050        return resultClosed;
     1051    }
     1052
     1053    /**
     1054     * Creates the nodes and ways of the offset path (as computed by the last call to {@link #changeOffset(double)}),
     1055     * and adds them to the edit data set by adding a new sequence command to the undo/redo queue.
     1056     * <p>
     1057     * Does nothing if there is no offset path.
    1831058     */
    1841059    public void commit() {
    185         UndoRedoHandler.getInstance().add(new SequenceCommand("Make parallel way(s)", makeAddWayAndNodesCommandList()));
     1060        List<Command> commands = makeAddWayAndNodesCommandList();
     1061        if (!commands.isEmpty()) {
     1062            UndoRedoHandler.getInstance().add(new SequenceCommand("Make parallel way(s)", commands));
     1063        }
    1861064    }
    1871065
    1881066    private List<Command> makeAddWayAndNodesCommandList() {
    1891067        DataSet ds = OsmDataManager.getInstance().getEditDataSet();
    190 
    191         List<Command> commands = new ArrayList<>(sortedNodes.size() + ways.size());
    192         Set<Node> dupCheck = new HashSet<>();
    193         for (int i = 0; i < sortedNodes.size() - (isClosedPath() ? 1 : 0); i++) {
    194             Node n = sortedNodes.get(i);
    195             // don't add the same node twice, see #18386
    196             if (dupCheck.add(n)) {
    197                 commands.add(new AddCommand(ds, n));
     1068        List<Way> newWays = buildWays();
     1069        ways = newWays;
     1070        List<Command> commands = new ArrayList<>();
     1071        if (newWays.isEmpty()) {
     1072            return commands;
     1073        }
     1074        List<Node> added = new ArrayList<>();
     1075        for (Way w : newWays) {
     1076            for (Node n : w.getNodes()) {
     1077                // don't add the same node twice, see #18386
     1078                if (!added.contains(n)) {
     1079                    added.add(n);
     1080                    commands.add(new AddCommand(ds, n));
     1081                }
    1981082            }
    1991083        }
    200         for (Way w : ways) {
     1084        for (Way w : newWays) {
    2011085            commands.add(new AddCommand(ds, w));
    2021086        }
    2031087        return commands;
    2041088    }
    2051089
    206     private static Node copyNode(Node source, boolean copyTags) {
    207         if (copyTags)
    208             return new Node(source, true);
    209         else {
    210             Node n = new Node();
    211             n.setCoor(source.getCoor());
    212             return n;
     1090    /**
     1091     * Builds the (not yet added) ways from the last computed offset path.
     1092     * @return the ways, in the order of the source ways; ways swallowed by the offset are omitted
     1093     */
     1094    private List<Way> buildWays() {
     1095        int pointCount = resultPts.size();
     1096        List<Way> result = new ArrayList<>(sourceWays.size());
     1097        if (pointCount < 2) {
     1098            return result;
     1099        }
     1100        int pieceCount = resultPieceSeg.length;
     1101        Node[] nodes = new Node[pointCount];
     1102        for (int w = 0; w < sourceWays.size(); w++) {
     1103            int first = -1;
     1104            int last = -1;
     1105            for (int p = 0; p < pieceCount; p++) {
     1106                if (segWay[resultPieceSeg[p]] == w) {
     1107                    if (first < 0) {
     1108                        first = p;
     1109                    }
     1110                    last = p;
     1111                }
     1112            }
     1113            if (first < 0) {
     1114                continue; // way is swallowed by the offset
     1115            }
     1116            List<Node> wayNodes = new ArrayList<>(last - first + 2);
     1117            for (int p = first; p <= last + 1; p++) {
     1118                int idx = p % pointCount;
     1119                if (nodes[idx] == null) {
     1120                    nodes[idx] = makeNode(idx);
     1121                }
     1122                wayNodes.add(nodes[idx]);
     1123            }
     1124            if (!wayForward[w]) {
     1125                Collections.reverse(wayNodes);
     1126            }
     1127            Way source = sourceWays.get(w);
     1128            Way copy = new Way();
     1129            copy.setNodes(wayNodes);
     1130            if (copyTags) {
     1131                copy.setKeys(source.getKeys());
     1132            }
     1133            result.add(copy);
     1134        }
     1135        return result;
     1136    }
     1137
     1138    private Node makeNode(int idx) {
     1139        Node n;
     1140        int src = resultPointNode[idx];
     1141        if (copyTags && src != NO_NODE) {
     1142            n = new Node(sortedNodes.get(src), true);
     1143        } else {
     1144            n = new Node();
    2131145        }
     1146        n.setEastNorth(resultPts.get(idx));
     1147        return n;
    2141148    }
    2151149
    2161150    /**
    217      * Returns the resulting parallel ways.
    218      * @return the resulting parallel ways
     1151     * Returns the resulting parallel ways, available after {@link #commit()}.
     1152     * @return the resulting parallel ways (empty before commit)
    2191153     */
    2201154    public final List<Way> getWays() {
    2211155        return ways;
  • new file test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java

    diff --git test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java
    new file mode 100644
    index 0000000000..d3197bfd9c
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.actions.mapmode;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertFalse;
     6import static org.junit.jupiter.api.Assertions.assertNotSame;
     7import static org.junit.jupiter.api.Assertions.assertSame;
     8import static org.junit.jupiter.api.Assertions.assertTrue;
     9
     10import java.util.Arrays;
     11import java.util.List;
     12import java.util.Random;
     13
     14import org.junit.jupiter.api.Test;
     15import org.openstreetmap.josm.data.UndoRedoHandler;
     16import org.openstreetmap.josm.data.coor.EastNorth;
     17import org.openstreetmap.josm.data.osm.DataSet;
     18import org.openstreetmap.josm.data.osm.Node;
     19import org.openstreetmap.josm.data.osm.Way;
     20import org.openstreetmap.josm.gui.MainApplication;
     21import org.openstreetmap.josm.gui.layer.OsmDataLayer;
     22import org.openstreetmap.josm.testutils.annotations.Main;
     23import org.openstreetmap.josm.testutils.annotations.Projection;
     24import org.openstreetmap.josm.tools.Geometry;
     25
     26/**
     27 * Unit tests for class {@link ParallelWays}.
     28 */
     29@Main
     30@Projection
     31class ParallelWaysTest {
     32
     33    private static Node node(double x, double y) {
     34        Node n = new Node();
     35        n.setEastNorth(new EastNorth(x, y));
     36        return n;
     37    }
     38
     39    private static Way way(Node... nodes) {
     40        Way w = new Way();
     41        w.setNodes(Arrays.asList(nodes));
     42        return w;
     43    }
     44
     45    private static Way way(double... xy) {
     46        Node[] nodes = new Node[xy.length / 2];
     47        for (int i = 0; i < nodes.length; i++) {
     48            nodes[i] = node(xy[2 * i], xy[2 * i + 1]);
     49        }
     50        return way(nodes);
     51    }
     52
     53    private static Way closedWay(double... xy) {
     54        Way w = way(xy);
     55        w.addNode(w.firstNode());
     56        return w;
     57    }
     58
     59    private static void assertContains(List<EastNorth> pts, double x, double y) {
     60        EastNorth expected = new EastNorth(x, y);
     61        assertTrue(pts.stream().anyMatch(p -> p.equalsEpsilon(expected, 1e-6)), pts.toString());
     62    }
     63
     64    private static double distanceToWay(Way w, EastNorth p) {
     65        double best = Double.POSITIVE_INFINITY;
     66        for (int i = 0; i < w.getNodesCount() - 1; i++) {
     67            EastNorth c = Geometry.closestPointToSegment(w.getNode(i).getEastNorth(), w.getNode(i + 1).getEastNorth(), p);
     68            best = Math.min(best, c.distance(p));
     69        }
     70        return best;
     71    }
     72
     73    private static double maxSegmentLength(Way w) {
     74        double max = 0;
     75        for (int i = 0; i < w.getNodesCount() - 1; i++) {
     76            max = Math.max(max, w.getNode(i).getEastNorth().distance(w.getNode(i + 1).getEastNorth()));
     77        }
     78        return max;
     79    }
     80
     81    /**
     82     * Checks the basic invariants of a parallel: every vertex is at least the offset away from the source,
     83     * and never much further than the offset (the mitre may overshoot by at most half a segment length).
     84     * @param source the source way
     85     * @param pts the parallel
     86     * @param r the (absolute) offset
     87     */
     88    private static void assertParallelInvariants(Way source, List<EastNorth> pts, double r) {
     89        assertVertexDistances(source, pts, r);
     90        // no self intersections
     91        for (int i = 0; i < pts.size() - 1; i++) {
     92            for (int j = i + 2; j < pts.size() - 1; j++) {
     93                EastNorth x = Geometry.getSegmentSegmentIntersection(pts.get(i), pts.get(i + 1), pts.get(j), pts.get(j + 1));
     94                assertTrue(x == null, "self intersection between segments " + i + " and " + j + " at " + x);
     95            }
     96        }
     97    }
     98
     99    /**
     100     * Checks that every vertex is at least the offset away from the source (a point on an arc chord may be
     101     * closer by the sagitta of the chord), and never much further than the offset (the mitre may overshoot by at
     102     * most half a segment length).
     103     * @param source the source way
     104     * @param pts the parallel
     105     * @param r the (absolute) offset
     106     */
     107    private static void assertVertexDistances(Way source, List<EastNorth> pts, double r) {
     108        double maxOvershoot = 0.5 * maxSegmentLength(source);
     109        // a point where two chords cross may lie inside both circles, i.e. up to two sagittas inside
     110        double minDist = r * (1 - 2 * (1 - Math.cos(Math.toRadians(ParallelWays.DEFAULT_ARC_STEP_DEGREES / 2)))) * (1 - 1e-6);
     111        for (EastNorth p : pts) {
     112            double dist = distanceToWay(source, p);
     113            assertTrue(dist >= minDist, "vertex " + p + " too close to source: " + dist + " < " + r);
     114            assertTrue(dist <= r + maxOvershoot + 1e-6, "vertex " + p + " too far from source: " + dist + " > " + r);
     115        }
     116    }
     117
     118    /**
     119     * A small offset of a simple way must keep the node count
     120     */
     121    @Test
     122    void testSimpleOffset() {
     123        Way w = way(0, 0, 100, 0, 200, 0);
     124        ParallelWays pw = new ParallelWays(Arrays.asList(w), false, 0);
     125        assertFalse(pw.isClosedPath());
     126        pw.changeOffset(10);
     127        List<EastNorth> pts = pw.getOffsetPoints();
     128        assertEquals(3, pts.size());
     129        assertEquals(new EastNorth(0, 10), pts.get(0));
     130        assertEquals(new EastNorth(100, 10), pts.get(1));
     131        assertEquals(new EastNorth(200, 10), pts.get(2));
     132        pw.changeOffset(-10);
     133        pts = pw.getOffsetPoints();
     134        assertEquals(new EastNorth(0, -10), pts.get(0));
     135        assertEquals(new EastNorth(200, -10), pts.get(2));
     136    }
     137
     138    /**
     139     * Small offsets of a closed way: mitre outside, clipped inside
     140     */
     141    @Test
     142    void testSquare() {
     143        Way square = closedWay(0, 0, 100, 0, 100, 100, 0, 100); // counter clockwise: left is inside
     144        ParallelWays pw = new ParallelWays(Arrays.asList(square), false, 0);
     145        assertTrue(pw.isClosedPath());
     146
     147        pw.changeOffset(-10); // outside
     148        List<EastNorth> pts = pw.getOffsetPoints();
     149        assertTrue(pw.isResultClosed());
     150        assertEquals(4, pts.size());
     151        assertParallelInvariants(square, pts, 10);
     152        assertContains(pts, -10, -10);
     153        assertContains(pts, 110, 110);
     154
     155        pw.changeOffset(10); // inside
     156        pts = pw.getOffsetPoints();
     157        assertTrue(pw.isResultClosed());
     158        assertEquals(4, pts.size());
     159        assertParallelInvariants(square, pts, 10);
     160        assertContains(pts, 10, 10);
     161        assertContains(pts, 90, 90);
     162
     163        pw.changeOffset(60); // inside, larger than the square
     164        assertTrue(pw.getOffsetPoints().isEmpty());
     165    }
     166
     167    /**
     168     * A large offset of a closed way is a ring with arcs at the corners
     169     */
     170    @Test
     171    void testSquareLargeOffset() {
     172        Way square = closedWay(0, 0, 100, 0, 100, 100, 0, 100);
     173        ParallelWays pw = new ParallelWays(Arrays.asList(square), false, 0, 10);
     174        pw.changeOffset(-1000);
     175        List<EastNorth> pts = pw.getOffsetPoints();
     176        assertTrue(pw.isResultClosed());
     177        // 4 corners, each an arc of 90° in 9 chords -> 10 points per corner
     178        assertEquals(40, pts.size(), pts.toString());
     179        assertParallelInvariants(square, pts, 1000);
     180        for (EastNorth p : pts) {
     181            assertEquals(1000, distanceToWay(square, p), 1e-6);
     182        }
     183    }
     184
     185    /**
     186     * Offsets larger than the local radius of curvature must not produce spikes or loops (the use case
     187     * of a maritime boundary 22 km off a coastline).
     188     */
     189    @Test
     190    void testLargeOffsetSawtooth() {
     191        double[] xy = new double[2 * 41];
     192        for (int i = 0; i <= 40; i++) {
     193            xy[2 * i] = i * 200;
     194            xy[2 * i + 1] = (i % 2 == 0 ? 0 : 150) + 30 * Math.sin(i);
     195        }
     196        Way coast = way(xy);
     197        ParallelWays pw = new ParallelWays(Arrays.asList(coast), false, 0);
     198        for (double d : new double[] {10, -10, 300, -300, 5000, -5000, 22000, -22000}) {
     199            pw.changeOffset(d);
     200            List<EastNorth> pts = pw.getOffsetPoints();
     201            assertTrue(pts.size() >= 2, "d=" + d);
     202            assertFalse(pw.isResultClosed());
     203            assertParallelInvariants(coast, pts, Math.abs(d));
     204            // the result must span the whole source
     205            assertTrue(pts.get(0).getX() < Math.abs(d) + 100, "d=" + d + ": " + pts.get(0));
     206            assertTrue(pts.get(pts.size() - 1).getX() > 8000 - Math.abs(d) - 100, "d=" + d + ": " + pts.get(pts.size() - 1));
     207        }
     208    }
     209
     210    /**
     211     * A random "coastline" with many nodes: the offset must be correct and fast enough for interactive use.
     212     */
     213    @Test
     214    void testLargeOffsetRandomCoastline() {
     215        Random rnd = new Random(42);
     216        int count = 1600;
     217        double[] xy = new double[2 * count];
     218        double x = 0;
     219        double y = 0;
     220        double heading = 0;
     221        for (int i = 0; i < count; i++) {
     222            xy[2 * i] = x;
     223            xy[2 * i + 1] = y;
     224            heading += (rnd.nextDouble() - 0.5) * 2.5;
     225            double len = 50 + rnd.nextDouble() * 300;
     226            x += Math.cos(heading) * len;
     227            y += Math.sin(heading) * len;
     228        }
     229        Way coast = way(xy);
     230        ParallelWays pw = new ParallelWays(Arrays.asList(coast), false, 0);
     231        for (double d : new double[] {50, -50, 2000, -2000, 22224, -22224}) {
     232            long start = System.nanoTime();
     233            pw.changeOffset(d);
     234            long millis = (System.nanoTime() - start) / 1_000_000;
     235            List<EastNorth> pts = pw.getOffsetPoints();
     236            assertTrue(pts.size() >= 2, "d=" + d);
     237            assertVertexDistances(coast, pts, Math.abs(d));
     238            assertTrue(millis < 5000, "offset took " + millis + " ms");
     239            System.out.println("ParallelWays: " + count + " nodes, d=" + d + " -> " + pts.size() + " points in " + millis + " ms");
     240        }
     241    }
     242
     243    /**
     244     * Commit of multiple ways: the ways stay connected, keep their direction and their tags.
     245     */
     246    @Test
     247    void testCommitMultipleWays() {
     248        DataSet ds = new DataSet();
     249        OsmDataLayer layer = new OsmDataLayer(ds, "ParallelWaysTest", null);
     250        MainApplication.getLayerManager().addLayer(layer);
     251        try {
     252            Node shared = node(100, 0);
     253            Way w1 = way(node(0, 0), shared);
     254            w1.put("highway", "primary");
     255            Way w2 = way(node(200, 0), node(150, 0), shared); // reversed direction
     256            w2.put("highway", "secondary");
     257            ds.addPrimitive(w1.getNode(0));
     258            ds.addPrimitive(shared);
     259            ds.addPrimitive(w2.getNode(0));
     260            ds.addPrimitive(w2.getNode(1));
     261            ds.addPrimitive(w1);
     262            ds.addPrimitive(w2);
     263
     264            ParallelWays pw = new ParallelWays(Arrays.asList(w1, w2), true, 0);
     265            pw.changeOffset(10);
     266            assertTrue(pw.getWays().isEmpty());
     267            pw.commit();
     268            List<Way> ways = pw.getWays();
     269            assertEquals(2, ways.size());
     270            Way p1 = ways.get(0);
     271            Way p2 = ways.get(1);
     272            assertEquals("primary", p1.get("highway"));
     273            assertEquals("secondary", p2.get("highway"));
     274            assertEquals(2, p1.getNodesCount());
     275            assertEquals(3, p2.getNodesCount());
     276            assertSame(p1.lastNode(), p2.lastNode());
     277            assertNotSame(shared, p1.lastNode());
     278            assertEquals(new EastNorth(0, 10), p1.firstNode().getEastNorth());
     279            assertEquals(new EastNorth(100, 10), p1.lastNode().getEastNorth());
     280            assertEquals(new EastNorth(200, 10), p2.firstNode().getEastNorth());
     281            assertEquals(new EastNorth(150, 10), p2.getNode(1).getEastNorth());
     282            assertEquals(6 + 4 + 2, ds.allPrimitives().size());
     283            assertSame(ds, p1.getDataSet());
     284            UndoRedoHandler.getInstance().undo();
     285            assertEquals(6, ds.allPrimitives().size());
     286        } finally {
     287            MainApplication.getLayerManager().removeLayer(layer);
     288        }
     289    }
     290
     291    /**
     292     * A closed ring made of several ways
     293     */
     294    @Test
     295    void testClosedRingOfWays() {
     296        Node a = node(0, 0);
     297        Node b = node(100, 0);
     298        Node c = node(100, 100);
     299        Node d = node(0, 100);
     300        Way w1 = way(a, b, c);
     301        Way w2 = way(c, d, a);
     302        ParallelWays pw = new ParallelWays(Arrays.asList(w1, w2), false, 0);
     303        assertTrue(pw.isClosedPath());
     304        pw.changeOffset(-10);
     305        List<EastNorth> pts = pw.getOffsetPoints();
     306        assertTrue(pw.isResultClosed());
     307        assertEquals(4, pts.size());
     308        assertContains(pts, -10, -10);
     309        assertContains(pts, 110, 110);
     310    }
     311}