001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.osm;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.HashSet;
009import java.util.List;
010import java.util.Map;
011import java.util.Set;
012
013import org.openstreetmap.josm.data.coor.LatLon;
014import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor;
015import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
016import org.openstreetmap.josm.spi.preferences.Config;
017import org.openstreetmap.josm.tools.CopyList;
018import org.openstreetmap.josm.tools.Geometry;
019import org.openstreetmap.josm.tools.Pair;
020import org.openstreetmap.josm.tools.Utils;
021
022/**
023 * One full way, consisting of a list of way {@link Node nodes}.
024 *
025 * @author imi
026 * @since 64
027 */
028public final class Way extends OsmPrimitive implements IWay {
029
030    /**
031     * All way nodes in this way
032     *
033     */
034    private Node[] nodes = new Node[0];
035    private BBox bbox;
036
037    /**
038     *
039     * You can modify returned list but changes will not be propagated back
040     * to the Way. Use {@link #setNodes(List)} to update this way
041     * @return Nodes composing the way
042     * @since 1862
043     */
044    public List<Node> getNodes() {
045        return new CopyList<>(nodes);
046    }
047
048    /**
049     * Set new list of nodes to way. This method is preferred to multiple calls to addNode/removeNode
050     * and similar methods because nodes are internally saved as array which means lower memory overhead
051     * but also slower modifying operations.
052     * @param nodes New way nodes. Can be null, in that case all way nodes are removed
053     * @since 1862
054     */
055    public void setNodes(List<Node> nodes) {
056        checkDatasetNotReadOnly();
057        boolean locked = writeLock();
058        try {
059            for (Node node:this.nodes) {
060                node.removeReferrer(this);
061                node.clearCachedStyle();
062            }
063
064            if (nodes == null) {
065                this.nodes = new Node[0];
066            } else {
067                this.nodes = nodes.toArray(new Node[0]);
068            }
069            for (Node node: this.nodes) {
070                node.addReferrer(this);
071                node.clearCachedStyle();
072            }
073
074            clearCachedStyle();
075            fireNodesChanged();
076        } finally {
077            writeUnlock(locked);
078        }
079    }
080
081    /**
082     * Prevent directly following identical nodes in ways.
083     * @param nodes list of nodes
084     * @return {@code nodes} with consecutive identical nodes removed
085     */
086    private static List<Node> removeDouble(List<Node> nodes) {
087        Node last = null;
088        int count = nodes.size();
089        for (int i = 0; i < count && count > 2;) {
090            Node n = nodes.get(i);
091            if (last == n) {
092                nodes.remove(i);
093                --count;
094            } else {
095                last = n;
096                ++i;
097            }
098        }
099        return nodes;
100    }
101
102    @Override
103    public int getNodesCount() {
104        return nodes.length;
105    }
106
107    /**
108     * Replies the node at position <code>index</code>.
109     *
110     * @param index the position
111     * @return  the node at position <code>index</code>
112     * @throws ArrayIndexOutOfBoundsException if <code>index</code> &lt; 0
113     * or <code>index</code> &gt;= {@link #getNodesCount()}
114     * @since 1862
115     */
116    public Node getNode(int index) {
117        return nodes[index];
118    }
119
120    @Override
121    public long getNodeId(int idx) {
122        return nodes[idx].getUniqueId();
123    }
124
125    /**
126     * Replies true if this way contains the node <code>node</code>, false
127     * otherwise. Replies false if  <code>node</code> is null.
128     *
129     * @param node the node. May be null.
130     * @return true if this way contains the node <code>node</code>, false
131     * otherwise
132     * @since 1911
133     */
134    public boolean containsNode(Node node) {
135        if (node == null) return false;
136
137        Node[] nodes = this.nodes;
138        for (Node n : nodes) {
139            if (n.equals(node))
140                return true;
141        }
142        return false;
143    }
144
145    /**
146     * Return nodes adjacent to <code>node</code>
147     *
148     * @param node the node. May be null.
149     * @return Set of nodes adjacent to <code>node</code>
150     * @since 4671
151     */
152    public Set<Node> getNeighbours(Node node) {
153        Set<Node> neigh = new HashSet<>();
154
155        if (node == null) return neigh;
156
157        Node[] nodes = this.nodes;
158        for (int i = 0; i < nodes.length; i++) {
159            if (nodes[i].equals(node)) {
160                if (i > 0)
161                    neigh.add(nodes[i-1]);
162                if (i < nodes.length-1)
163                    neigh.add(nodes[i+1]);
164            }
165        }
166        return neigh;
167    }
168
169    /**
170     * Replies the ordered {@link List} of chunks of this way. Each chunk is replied as a {@link Pair} of {@link Node nodes}.
171     * @param sort If true, the nodes of each pair are sorted as defined by {@link Pair#sort}.
172     *             If false, Pair.a and Pair.b are in the way order
173     *             (i.e for a given Pair(n), Pair(n-1).b == Pair(n).a, Pair(n).b == Pair(n+1).a, etc.)
174     * @return The ordered list of chunks of this way.
175     * @since 3348
176     */
177    public List<Pair<Node, Node>> getNodePairs(boolean sort) {
178        List<Pair<Node, Node>> chunkSet = new ArrayList<>();
179        if (isIncomplete()) return chunkSet;
180        Node lastN = null;
181        Node[] nodes = this.nodes;
182        for (Node n : nodes) {
183            if (lastN == null) {
184                lastN = n;
185                continue;
186            }
187            Pair<Node, Node> np = new Pair<>(lastN, n);
188            if (sort) {
189                Pair.sort(np);
190            }
191            chunkSet.add(np);
192            lastN = n;
193        }
194        return chunkSet;
195    }
196
197    @Override public void accept(OsmPrimitiveVisitor visitor) {
198        visitor.visit(this);
199    }
200
201    @Override public void accept(PrimitiveVisitor visitor) {
202        visitor.visit(this);
203    }
204
205    protected Way(long id, boolean allowNegative) {
206        super(id, allowNegative);
207    }
208
209    /**
210     * Contructs a new {@code Way} with id 0.
211     * @since 86
212     */
213    public Way() {
214        super(0, false);
215    }
216
217    /**
218     * Contructs a new {@code Way} from an existing {@code Way}.
219     * @param original The original {@code Way} to be identically cloned. Must not be null
220     * @param clearMetadata If {@code true}, clears the OSM id and other metadata as defined by {@link #clearOsmMetadata}.
221     * If {@code false}, does nothing
222     * @since 2410
223     */
224    public Way(Way original, boolean clearMetadata) {
225        super(original.getUniqueId(), true);
226        cloneFrom(original);
227        if (clearMetadata) {
228            clearOsmMetadata();
229        }
230    }
231
232    /**
233     * Contructs a new {@code Way} from an existing {@code Way} (including its id).
234     * @param original The original {@code Way} to be identically cloned. Must not be null
235     * @since 86
236     */
237    public Way(Way original) {
238        this(original, false);
239    }
240
241    /**
242     * Contructs a new {@code Way} for the given id. If the id &gt; 0, the way is marked
243     * as incomplete. If id == 0 then way is marked as new
244     *
245     * @param id the id. &gt;= 0 required
246     * @throws IllegalArgumentException if id &lt; 0
247     * @since 343
248     */
249    public Way(long id) {
250        super(id, false);
251    }
252
253    /**
254     * Contructs a new {@code Way} with given id and version.
255     * @param id the id. &gt;= 0 required
256     * @param version the version
257     * @throws IllegalArgumentException if id &lt; 0
258     * @since 2620
259     */
260    public Way(long id, int version) {
261        super(id, version, false);
262    }
263
264    @Override
265    public void load(PrimitiveData data) {
266        if (!(data instanceof WayData))
267            throw new IllegalArgumentException("Not a way data: " + data);
268        boolean locked = writeLock();
269        try {
270            super.load(data);
271
272            WayData wayData = (WayData) data;
273
274            if (!wayData.getNodes().isEmpty() && getDataSet() == null) {
275                throw new AssertionError("Data consistency problem - way without dataset detected");
276            }
277
278            List<Node> newNodes = new ArrayList<>(wayData.getNodes().size());
279            for (Long nodeId : wayData.getNodes()) {
280                Node node = (Node) getDataSet().getPrimitiveById(nodeId, OsmPrimitiveType.NODE);
281                if (node != null) {
282                    newNodes.add(node);
283                } else {
284                    throw new AssertionError("Data consistency problem - way with missing node detected");
285                }
286            }
287            setNodes(newNodes);
288        } finally {
289            writeUnlock(locked);
290        }
291    }
292
293    @Override
294    public WayData save() {
295        WayData data = new WayData();
296        saveCommonAttributes(data);
297        for (Node node:nodes) {
298            data.getNodes().add(node.getUniqueId());
299        }
300        return data;
301    }
302
303    @Override
304    public void cloneFrom(OsmPrimitive osm) {
305        if (!(osm instanceof Way))
306            throw new IllegalArgumentException("Not a way: " + osm);
307        boolean locked = writeLock();
308        try {
309            super.cloneFrom(osm);
310            Way otherWay = (Way) osm;
311            setNodes(otherWay.getNodes());
312        } finally {
313            writeUnlock(locked);
314        }
315    }
316
317    @Override
318    public String toString() {
319        String nodesDesc = isIncomplete() ? "(incomplete)" : ("nodes=" + Arrays.toString(nodes));
320        return "{Way id=" + getUniqueId() + " version=" + getVersion()+ ' ' + getFlagsAsString() + ' ' + nodesDesc + '}';
321    }
322
323    @Override
324    public boolean hasEqualSemanticAttributes(OsmPrimitive other, boolean testInterestingTagsOnly) {
325        if (!(other instanceof Way))
326            return false;
327        Way w = (Way) other;
328        if (getNodesCount() != w.getNodesCount()) return false;
329        if (!super.hasEqualSemanticAttributes(other, testInterestingTagsOnly))
330            return false;
331        for (int i = 0; i < getNodesCount(); i++) {
332            if (!getNode(i).hasEqualSemanticAttributes(w.getNode(i)))
333                return false;
334        }
335        return true;
336    }
337
338    /**
339     * Removes the given {@link Node} from this way. Ignored, if n is null.
340     * @param n The node to remove. Ignored, if null
341     * @since 1463
342     */
343    public void removeNode(Node n) {
344        checkDatasetNotReadOnly();
345        if (n == null || isIncomplete()) return;
346        boolean locked = writeLock();
347        try {
348            boolean closed = lastNode() == n && firstNode() == n;
349            int i;
350            List<Node> copy = getNodes();
351            while ((i = copy.indexOf(n)) >= 0) {
352                copy.remove(i);
353            }
354            i = copy.size();
355            if (closed && i > 2) {
356                copy.add(copy.get(0));
357            } else if (i >= 2 && i <= 3 && copy.get(0) == copy.get(i-1)) {
358                copy.remove(i-1);
359            }
360            setNodes(removeDouble(copy));
361            n.clearCachedStyle();
362        } finally {
363            writeUnlock(locked);
364        }
365    }
366
367    /**
368     * Removes the given set of {@link Node nodes} from this way. Ignored, if selection is null.
369     * @param selection The selection of nodes to remove. Ignored, if null
370     * @since 5408
371     */
372    public void removeNodes(Set<? extends Node> selection) {
373        checkDatasetNotReadOnly();
374        if (selection == null || isIncomplete()) return;
375        boolean locked = writeLock();
376        try {
377            boolean closed = isClosed() && selection.contains(lastNode());
378            List<Node> copy = new ArrayList<>();
379
380            for (Node n: nodes) {
381                if (!selection.contains(n)) {
382                    copy.add(n);
383                }
384            }
385
386            int i = copy.size();
387            if (closed && i > 2) {
388                copy.add(copy.get(0));
389            } else if (i >= 2 && i <= 3 && copy.get(0) == copy.get(i-1)) {
390                copy.remove(i-1);
391            }
392            setNodes(removeDouble(copy));
393            for (Node n : selection) {
394                n.clearCachedStyle();
395            }
396        } finally {
397            writeUnlock(locked);
398        }
399    }
400
401    /**
402     * Adds a node to the end of the list of nodes. Ignored, if n is null.
403     *
404     * @param n the node. Ignored, if null
405     * @throws IllegalStateException if this way is marked as incomplete. We can't add a node
406     * to an incomplete way
407     * @since 1313
408     */
409    public void addNode(Node n) {
410        checkDatasetNotReadOnly();
411        if (n == null) return;
412
413        boolean locked = writeLock();
414        try {
415            if (isIncomplete())
416                throw new IllegalStateException(tr("Cannot add node {0} to incomplete way {1}.", n.getId(), getId()));
417            clearCachedStyle();
418            n.addReferrer(this);
419            nodes = Utils.addInArrayCopy(nodes, n);
420            n.clearCachedStyle();
421            fireNodesChanged();
422        } finally {
423            writeUnlock(locked);
424        }
425    }
426
427    /**
428     * Adds a node at position offs.
429     *
430     * @param offs the offset
431     * @param n the node. Ignored, if null.
432     * @throws IllegalStateException if this way is marked as incomplete. We can't add a node
433     * to an incomplete way
434     * @throws IndexOutOfBoundsException if offs is out of bounds
435     * @since 1313
436     */
437    public void addNode(int offs, Node n) {
438        checkDatasetNotReadOnly();
439        if (n == null) return;
440
441        boolean locked = writeLock();
442        try {
443            if (isIncomplete())
444                throw new IllegalStateException(tr("Cannot add node {0} to incomplete way {1}.", n.getId(), getId()));
445
446            clearCachedStyle();
447            n.addReferrer(this);
448            Node[] newNodes = new Node[nodes.length + 1];
449            System.arraycopy(nodes, 0, newNodes, 0, offs);
450            System.arraycopy(nodes, offs, newNodes, offs + 1, nodes.length - offs);
451            newNodes[offs] = n;
452            nodes = newNodes;
453            n.clearCachedStyle();
454            fireNodesChanged();
455        } finally {
456            writeUnlock(locked);
457        }
458    }
459
460    @Override
461    public void setDeleted(boolean deleted) {
462        boolean locked = writeLock();
463        try {
464            for (Node n:nodes) {
465                if (deleted) {
466                    n.removeReferrer(this);
467                } else {
468                    n.addReferrer(this);
469                }
470                n.clearCachedStyle();
471            }
472            fireNodesChanged();
473            super.setDeleted(deleted);
474        } finally {
475            writeUnlock(locked);
476        }
477    }
478
479    @Override
480    public boolean isClosed() {
481        if (isIncomplete()) return false;
482
483        Node[] nodes = this.nodes;
484        return nodes.length >= 3 && nodes[nodes.length-1] == nodes[0];
485    }
486
487    /**
488     * Determines if this way denotes an area (closed way with at least three distinct nodes).
489     * @return {@code true} if this way is closed and contains at least three distinct nodes
490     * @see #isClosed
491     * @since 5490
492     */
493    public boolean isArea() {
494        if (this.nodes.length >= 4 && isClosed()) {
495            Node distinctNode = null;
496            for (int i = 1; i < nodes.length-1; i++) {
497                if (distinctNode == null && nodes[i] != nodes[0]) {
498                    distinctNode = nodes[i];
499                } else if (distinctNode != null && nodes[i] != nodes[0] && nodes[i] != distinctNode) {
500                    return true;
501                }
502            }
503        }
504        return false;
505    }
506
507    /**
508     * Returns the last node of this way.
509     * The result equals <code>{@link #getNode getNode}({@link #getNodesCount getNodesCount} - 1)</code>.
510     * @return the last node of this way
511     * @since 1400
512     */
513    public Node lastNode() {
514        Node[] nodes = this.nodes;
515        if (isIncomplete() || nodes.length == 0) return null;
516        return nodes[nodes.length-1];
517    }
518
519    /**
520     * Returns the first node of this way.
521     * The result equals {@link #getNode getNode}{@code (0)}.
522     * @return the first node of this way
523     * @since 1400
524     */
525    public Node firstNode() {
526        Node[] nodes = this.nodes;
527        if (isIncomplete() || nodes.length == 0) return null;
528        return nodes[0];
529    }
530
531    /**
532     * Replies true if the given node is the first or the last one of this way, false otherwise.
533     * @param n The node to test
534     * @return true if the {@code n} is the first or the last node, false otherwise.
535     * @since 1400
536     */
537    public boolean isFirstLastNode(Node n) {
538        Node[] nodes = this.nodes;
539        if (isIncomplete() || nodes.length == 0) return false;
540        return n == nodes[0] || n == nodes[nodes.length -1];
541    }
542
543    /**
544     * Replies true if the given node is an inner node of this way, false otherwise.
545     * @param n The node to test
546     * @return true if the {@code n} is an inner node, false otherwise.
547     * @since 3515
548     */
549    public boolean isInnerNode(Node n) {
550        Node[] nodes = this.nodes;
551        if (isIncomplete() || nodes.length <= 2) return false;
552        /* circular ways have only inner nodes, so return true for them! */
553        if (n == nodes[0] && n == nodes[nodes.length-1]) return true;
554        for (int i = 1; i < nodes.length - 1; ++i) {
555            if (nodes[i] == n) return true;
556        }
557        return false;
558    }
559
560    @Override
561    public OsmPrimitiveType getType() {
562        return OsmPrimitiveType.WAY;
563    }
564
565    @Override
566    public OsmPrimitiveType getDisplayType() {
567        return isClosed() ? OsmPrimitiveType.CLOSEDWAY : OsmPrimitiveType.WAY;
568    }
569
570    private void checkNodes() {
571        DataSet dataSet = getDataSet();
572        if (dataSet != null) {
573            Node[] nodes = this.nodes;
574            for (Node n: nodes) {
575                if (n.getDataSet() != dataSet)
576                    throw new DataIntegrityProblemException("Nodes in way must be in the same dataset",
577                            tr("Nodes in way must be in the same dataset"));
578                if (n.isDeleted())
579                    throw new DataIntegrityProblemException("Deleted node referenced: " + toString(),
580                            "<html>" + tr("Deleted node referenced by {0}",
581                                    DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(this)) + "</html>");
582            }
583            if (Config.getPref().getBoolean("debug.checkNullCoor", true)) {
584                for (Node n: nodes) {
585                    if (n.isVisible() && !n.isIncomplete() && !n.isLatLonKnown())
586                        throw new DataIntegrityProblemException("Complete visible node with null coordinates: " + toString(),
587                                "<html>" + tr("Complete node {0} with null coordinates in way {1}",
588                                DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(n),
589                                DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(this)) + "</html>");
590                }
591            }
592        }
593    }
594
595    private void fireNodesChanged() {
596        checkNodes();
597        if (getDataSet() != null) {
598            getDataSet().fireWayNodesChanged(this);
599        }
600    }
601
602    @Override
603    void setDataset(DataSet dataSet) {
604        super.setDataset(dataSet);
605        checkNodes();
606    }
607
608    @Override
609    public BBox getBBox() {
610        if (getDataSet() == null)
611            return new BBox(this);
612        if (bbox == null) {
613            bbox = new BBox(this);
614        }
615        return new BBox(bbox);
616    }
617
618    @Override
619    protected void addToBBox(BBox box, Set<PrimitiveId> visited) {
620        box.add(getBBox());
621    }
622
623    @Override
624    public void updatePosition() {
625        bbox = new BBox(this);
626    }
627
628    /**
629     * Replies true if this way has incomplete nodes, false otherwise.
630     * @return true if this way has incomplete nodes, false otherwise.
631     * @since 2587
632     */
633    public boolean hasIncompleteNodes() {
634        Node[] nodes = this.nodes;
635        for (Node node : nodes) {
636            if (node.isIncomplete())
637                return true;
638        }
639        return false;
640    }
641
642    /**
643     * Replies true if all nodes of the way have known lat/lon, false otherwise.
644     * @return true if all nodes of the way have known lat/lon, false otherwise
645     * @since 13033
646     */
647    public boolean hasOnlyLocatableNodes() {
648        Node[] nodes = this.nodes;
649        for (Node node : nodes) {
650            if (!node.isLatLonKnown())
651                return false;
652        }
653        return true;
654    }
655
656    @Override
657    public boolean isUsable() {
658        return super.isUsable() && !hasIncompleteNodes();
659    }
660
661    @Override
662    public boolean isDrawable() {
663        return super.isDrawable() && hasOnlyLocatableNodes();
664    }
665
666    /**
667     * Replies the length of the way, in metres, as computed by {@link LatLon#greatCircleDistance}.
668     * @return The length of the way, in metres
669     * @since 4138
670     */
671    public double getLength() {
672        double length = 0;
673        Node lastN = null;
674        for (Node n:nodes) {
675            if (lastN != null) {
676                LatLon lastNcoor = lastN.getCoor();
677                LatLon coor = n.getCoor();
678                if (lastNcoor != null && coor != null) {
679                    length += coor.greatCircleDistance(lastNcoor);
680                }
681            }
682            lastN = n;
683        }
684        return length;
685    }
686
687    /**
688     * Replies the length of the longest segment of the way, in metres, as computed by {@link LatLon#greatCircleDistance}.
689     * @return The length of the segment, in metres
690     * @since 8320
691     */
692    public double getLongestSegmentLength() {
693        double length = 0;
694        Node lastN = null;
695        for (Node n:nodes) {
696            if (lastN != null) {
697                LatLon lastNcoor = lastN.getCoor();
698                LatLon coor = n.getCoor();
699                if (lastNcoor != null && coor != null) {
700                    double l = coor.greatCircleDistance(lastNcoor);
701                    if (l > length) {
702                        length = l;
703                    }
704                }
705            }
706            lastN = n;
707        }
708        return length;
709    }
710
711    /**
712     * Tests if this way is a oneway.
713     * @return {@code 1} if the way is a oneway,
714     *         {@code -1} if the way is a reversed oneway,
715     *         {@code 0} otherwise.
716     * @since 5199
717     */
718    public int isOneway() {
719        String oneway = get("oneway");
720        if (oneway != null) {
721            if ("-1".equals(oneway)) {
722                return -1;
723            } else {
724                Boolean isOneway = OsmUtils.getOsmBoolean(oneway);
725                if (isOneway != null && isOneway) {
726                    return 1;
727                }
728            }
729        }
730        return 0;
731    }
732
733    /**
734     * Replies the first node of this way, respecting or not its oneway state.
735     * @param respectOneway If true and if this way is a reversed oneway, replies the last node. Otherwise, replies the first node.
736     * @return the first node of this way, according to {@code respectOneway} and its oneway state.
737     * @since 5199
738     */
739    public Node firstNode(boolean respectOneway) {
740        return !respectOneway || isOneway() != -1 ? firstNode() : lastNode();
741    }
742
743    /**
744     * Replies the last node of this way, respecting or not its oneway state.
745     * @param respectOneway If true and if this way is a reversed oneway, replies the first node. Otherwise, replies the last node.
746     * @return the last node of this way, according to {@code respectOneway} and its oneway state.
747     * @since 5199
748     */
749    public Node lastNode(boolean respectOneway) {
750        return !respectOneway || isOneway() != -1 ? lastNode() : firstNode();
751    }
752
753    @Override
754    public boolean concernsArea() {
755        return hasAreaTags();
756    }
757
758    @Override
759    public boolean isOutsideDownloadArea() {
760        for (final Node n : nodes) {
761            if (n.isOutsideDownloadArea()) {
762                return true;
763            }
764        }
765        return false;
766    }
767
768    @Override
769    protected void keysChangedImpl(Map<String, String> originalKeys) {
770        super.keysChangedImpl(originalKeys);
771        clearCachedNodeStyles();
772    }
773
774    /**
775     * Clears all cached styles for all nodes of this way. This should not be called from outside.
776     * @see Node#clearCachedStyle()
777     */
778    public void clearCachedNodeStyles() {
779        for (final Node n : nodes) {
780            n.clearCachedStyle();
781        }
782    }
783
784    /**
785     * Returns angles of vertices.
786     * @return angles of the way
787     * @since 13670
788     */
789    public synchronized List<Pair<Double, Node>> getAngles() {
790        List<Pair<Double, Node>> angles = new ArrayList<>();
791
792        for (int i = 1; i < nodes.length - 1; i++) {
793            Node n0 = nodes[i - 1];
794            Node n1 = nodes[i];
795            Node n2 = nodes[i + 1];
796
797            double angle = Geometry.getNormalizedAngleInDegrees(Geometry.getCornerAngle(
798                    n0.getEastNorth(), n1.getEastNorth(), n2.getEastNorth()));
799            angles.add(new Pair<>(angle, n1));
800        }
801
802        angles.add(new Pair<>(Geometry.getNormalizedAngleInDegrees(Geometry.getCornerAngle(
803                nodes[nodes.length - 2].getEastNorth(),
804                nodes[0].getEastNorth(),
805                nodes[1].getEastNorth())), nodes[0]));
806
807        return angles;
808    }
809}