001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.event.ActionEvent;
008import java.awt.event.KeyEvent;
009import java.util.ArrayList;
010import java.util.Arrays;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.HashMap;
014import java.util.HashSet;
015import java.util.Iterator;
016import java.util.LinkedList;
017import java.util.List;
018import java.util.Map;
019import java.util.Set;
020import java.util.stream.Collectors;
021import java.util.stream.IntStream;
022
023import javax.swing.JOptionPane;
024
025import org.openstreetmap.josm.command.Command;
026import org.openstreetmap.josm.command.MoveCommand;
027import org.openstreetmap.josm.command.SequenceCommand;
028import org.openstreetmap.josm.data.UndoRedoHandler;
029import org.openstreetmap.josm.data.coor.EastNorth;
030import org.openstreetmap.josm.data.coor.PolarCoor;
031import org.openstreetmap.josm.data.osm.Node;
032import org.openstreetmap.josm.data.osm.OsmPrimitive;
033import org.openstreetmap.josm.data.osm.Way;
034import org.openstreetmap.josm.data.projection.ProjectionRegistry;
035import org.openstreetmap.josm.gui.ConditionalOptionPaneUtil;
036import org.openstreetmap.josm.gui.MainApplication;
037import org.openstreetmap.josm.gui.Notification;
038import org.openstreetmap.josm.tools.Geometry;
039import org.openstreetmap.josm.tools.JosmRuntimeException;
040import org.openstreetmap.josm.tools.Logging;
041import org.openstreetmap.josm.tools.Shortcut;
042import org.openstreetmap.josm.tools.Utils;
043
044/**
045 * Tools / Orthogonalize
046 *
047 * Align edges of a way so all angles are angles of 90 or 180 degrees.
048 * See USAGE String below.
049 */
050public final class OrthogonalizeAction extends JosmAction {
051    private static final String USAGE = tr(
052            "<h3>When one or more ways are selected, the shape is adjusted such, that all angles are 90 or 180 degrees.</h3>"+
053            "You can add two nodes to the selection. Then, the direction is fixed by these two reference nodes. "+
054            "(Afterwards, you can undo the movement for certain nodes:<br>"+
055            "Select them and press the shortcut for Orthogonalize / Undo. The default is Shift-Q.)");
056
057    private static final double EPSILON = 1E-6;
058
059    /**
060     * Constructs a new {@code OrthogonalizeAction}.
061     */
062    public OrthogonalizeAction() {
063        super(tr("Orthogonalize Shape"),
064                "ortho",
065                tr("Move nodes so all angles are 90 or 180 degrees"),
066                Shortcut.registerShortcut("tools:orthogonalize", tr("Tools: {0}", tr("Orthogonalize Shape")),
067                        KeyEvent.VK_Q,
068                        Shortcut.DIRECT), true);
069        setHelpId(ht("/Action/OrthogonalizeShape"));
070    }
071
072    /**
073     * excepted deviation from an angle of 0, 90, 180, 360 degrees
074     * maximum value: 45 degrees
075     *
076     * Current policy is to except just everything, no matter how strange the result would be.
077     */
078    private static final double TOLERANCE1 = Utils.toRadians(45.);   // within a way
079    private static final double TOLERANCE2 = Utils.toRadians(45.);   // ways relative to each other
080
081    /**
082     * Remember movements, so the user can later undo it for certain nodes
083     */
084    private static final Map<Node, EastNorth> rememberMovements = new HashMap<>();
085
086    /**
087     * Undo the previous orthogonalization for certain nodes.
088     *
089     * This is useful, if the way shares nodes that you don't like to change, e.g. imports or
090     * work of another user.
091     *
092     * This action can be triggered by shortcut only.
093     */
094    public static class Undo extends JosmAction {
095        /**
096         * Constructor
097         */
098        public Undo() {
099            super(tr("Orthogonalize Shape / Undo"), "ortho",
100                    tr("Undo orthogonalization for certain nodes"),
101                    Shortcut.registerShortcut("tools:orthogonalizeUndo", tr("Orthogonalize Shape / Undo"),
102                            KeyEvent.VK_Q,
103                            Shortcut.SHIFT),
104                    true, "action/orthogonalize/undo", true);
105        }
106
107        @Override
108        public void actionPerformed(ActionEvent e) {
109            if (!isEnabled())
110                return;
111            final Collection<Command> commands = new LinkedList<>();
112            final Collection<OsmPrimitive> sel = getLayerManager().getEditDataSet().getSelected();
113            try {
114                for (OsmPrimitive p : sel) {
115                    if (!(p instanceof Node)) throw new InvalidUserInputException("selected object is not a node");
116                    Node n = (Node) p;
117                    if (rememberMovements.containsKey(n)) {
118                        EastNorth tmp = rememberMovements.get(n);
119                        commands.add(new MoveCommand(n, -tmp.east(), -tmp.north()));
120                        rememberMovements.remove(n);
121                    }
122                }
123                if (!commands.isEmpty()) {
124                    UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Orthogonalize / Undo"), commands));
125                } else {
126                    throw new InvalidUserInputException("Commands are empty");
127                }
128            } catch (InvalidUserInputException ex) {
129                Logging.debug(ex);
130                new Notification(
131                        tr("Orthogonalize Shape / Undo<br>"+
132                        "Please select nodes that were moved by the previous Orthogonalize Shape action!"))
133                        .setIcon(JOptionPane.INFORMATION_MESSAGE)
134                        .show();
135            }
136        }
137
138        @Override
139        protected void updateEnabledState() {
140            updateEnabledStateOnCurrentSelection();
141        }
142
143        @Override
144        protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
145            updateEnabledStateOnModifiableSelection(selection);
146        }
147    }
148
149    @Override
150    public void actionPerformed(ActionEvent e) {
151        if (!isEnabled())
152            return;
153        if ("EPSG:4326".equals(ProjectionRegistry.getProjection().toString())) {
154            String msg = tr("<html>You are using the EPSG:4326 projection which might lead<br>" +
155                    "to undesirable results when doing rectangular alignments.<br>" +
156                    "Change your projection to get rid of this warning.<br>" +
157            "Do you want to continue?</html>");
158            if (!ConditionalOptionPaneUtil.showConfirmationDialog(
159                    "align_rectangular_4326",
160                    MainApplication.getMainFrame(),
161                    msg,
162                    tr("Warning"),
163                    JOptionPane.YES_NO_OPTION,
164                    JOptionPane.QUESTION_MESSAGE,
165                    JOptionPane.YES_OPTION))
166                return;
167        }
168
169        final Collection<OsmPrimitive> sel = getLayerManager().getEditDataSet().getSelected();
170
171        try {
172            UndoRedoHandler.getInstance().add(orthogonalize(sel));
173        } catch (InvalidUserInputException ex) {
174            Logging.debug(ex);
175            String msg;
176            if ("usage".equals(ex.getMessage())) {
177                msg = "<h2>" + tr("Usage") + "</h2>" + USAGE;
178            } else {
179                msg = ex.getMessage() + "<br><hr><h2>" + tr("Usage") + "</h2>" + USAGE;
180            }
181            new Notification(msg)
182                    .setIcon(JOptionPane.INFORMATION_MESSAGE)
183                    .setDuration(Notification.TIME_DEFAULT)
184                    .show();
185        }
186    }
187
188    /**
189     * Rectifies the selection
190     * @param selection the selection which should be rectified
191     * @return a rectifying command
192     * @throws InvalidUserInputException if the selection is invalid
193     * @since 13670
194     */
195    public static SequenceCommand orthogonalize(Iterable<OsmPrimitive> selection) throws InvalidUserInputException {
196        final List<Node> nodeList = new ArrayList<>();
197        final List<WayData> wayDataList = new ArrayList<>();
198        // collect nodes and ways from the selection
199        for (OsmPrimitive p : selection) {
200            if (p instanceof Node) {
201                nodeList.add((Node) p);
202            } else if (p instanceof Way) {
203                Way w = (Way) p;
204                if (!w.isIncomplete() && !w.isEmpty()) {
205                    wayDataList.add(new WayData(w.getNodes()));
206                }
207            } else {
208                throw new InvalidUserInputException(tr("Selection must consist only of ways and nodes."));
209            }
210        }
211        final int nodesCount = nodeList.size();
212        if (wayDataList.isEmpty() && nodesCount > 2) {
213            return new SequenceCommand(tr("Orthogonalize"),
214                    orthogonalize(Collections.singletonList(new WayData(nodeList)), Collections.<Node>emptyList()));
215        } else if (!wayDataList.isEmpty() && nodesCount <= 2) {
216            OrthogonalizeAction.rememberMovements.clear();
217            final Collection<Command> commands = new LinkedList<>();
218
219            if (nodesCount == 2) {  // fixed direction, or single node to move
220                commands.addAll(orthogonalize(wayDataList, nodeList));
221            } else if (nodesCount == 1) {
222                commands.add(orthogonalize(wayDataList, nodeList.get(0)));
223            } else if (nodesCount == 0) {
224                for (List<WayData> g : buildGroups(wayDataList)) {
225                    commands.addAll(orthogonalize(g, nodeList));
226                }
227            }
228
229            if (!commands.isEmpty()) {
230                return new SequenceCommand(tr("Orthogonalize"), commands);
231            }
232        }
233        throw new InvalidUserInputException("usage");
234    }
235
236    /**
237     * Collect groups of ways with common nodes in order to orthogonalize each group separately.
238     * @param wayDataList list of ways
239     * @return groups of ways with common nodes
240     */
241    private static List<List<WayData>> buildGroups(List<WayData> wayDataList) {
242        List<List<WayData>> groups = new ArrayList<>();
243        Set<WayData> remaining = new HashSet<>(wayDataList);
244        while (!remaining.isEmpty()) {
245            List<WayData> group = new ArrayList<>();
246            groups.add(group);
247            Iterator<WayData> it = remaining.iterator();
248            WayData next = it.next();
249            it.remove();
250            extendGroupRec(group, next, new ArrayList<>(remaining));
251            remaining.removeAll(group);
252        }
253        return groups;
254    }
255
256    private static void extendGroupRec(List<WayData> group, WayData newGroupMember, List<WayData> remaining) {
257        group.add(newGroupMember);
258        for (int i = 0; i < remaining.size(); ++i) {
259            WayData candidate = remaining.get(i);
260            if (candidate == null) continue;
261            if (!Collections.disjoint(candidate.wayNodes, newGroupMember.wayNodes)) {
262                remaining.set(i, null);
263                extendGroupRec(group, candidate, remaining);
264            }
265        }
266    }
267
268    /**
269     * Try to orthogonalize the given ways by moving only a single given node
270     * @param wayDataList list of ways
271     * @param singleNode common node to ways to orthogonalize. Only this one will be moved
272     * @return the command to move the node
273     * @throws InvalidUserInputException if the command cannot be computed
274     */
275    private static Command orthogonalize(List<WayData> wayDataList, Node singleNode) throws InvalidUserInputException {
276        List<EastNorth> rightAnglePositions = new ArrayList<>();
277        int wayCount = wayDataList.size();
278        for (WayData wd : wayDataList) {
279            int n = wd.wayNodes.size();
280            int i = wd.wayNodes.indexOf(singleNode);
281            Node n0, n2;
282            if (i == 0 && n >= 3 && singleNode.equals(wd.wayNodes.get(n-1))) {
283                n0 = wd.wayNodes.get(n-2);
284                n2 = wd.wayNodes.get(1);
285            } else if (i > 0 && i < n-1) {
286                n0 = wd.wayNodes.get(i-1);
287                n2 = wd.wayNodes.get(i+1);
288            } else {
289                continue;
290            }
291            EastNorth n0en = n0.getEastNorth();
292            EastNorth n1en = singleNode.getEastNorth();
293            EastNorth n2en = n2.getEastNorth();
294            double angle = Geometry.getNormalizedAngleInDegrees(Geometry.getCornerAngle(n0en, n1en, n2en));
295            if (wayCount == 1 || (80 <= angle && angle <= 100)) {
296                EastNorth c = n0en.getCenter(n2en);
297                double r = n0en.distance(n2en) / 2d;
298                double vX = n1en.east() - c.east();
299                double vY = n1en.north() - c.north();
300                double magV = Math.sqrt(vX*vX + vY*vY);
301                rightAnglePositions.add(new EastNorth(c.east() + vX / magV * r,
302                                                     c.north() + vY / magV * r));
303            }
304        }
305        if (rightAnglePositions.isEmpty()) {
306            throw new InvalidUserInputException("Unable to orthogonalize " + singleNode);
307        }
308        return new MoveCommand(singleNode, ProjectionRegistry.getProjection().eastNorth2latlon(Geometry.getCentroidEN(rightAnglePositions)));
309    }
310
311    /**
312     *
313     *  Outline:
314     *  1. Find direction of all segments
315     *      - direction = 0..3 (right,up,left,down)
316     *      - right is not really right, you may have to turn your screen
317     *  2. Find average heading of all segments
318     *      - heading = angle of a vector in polar coordinates
319     *      - sum up horizontal segments (those with direction 0 or 2)
320     *      - sum up vertical segments
321     *      - turn the vertical sum by 90 degrees and add it to the horizontal sum
322     *      - get the average heading from this total sum
323     *  3. Rotate all nodes by the average heading so that right is really right
324     *      and all segments are approximately NS or EW.
325     *  4. If nodes are connected by a horizontal segment: Replace their y-Coordinate by
326     *      the mean value of their y-Coordinates.
327     *      - The same for vertical segments.
328     *  5. Rotate back.
329     * @param wayDataList list of ways
330     * @param headingNodes list of heading nodes
331     * @return list of commands to perform
332     * @throws InvalidUserInputException if selected ways have an angle different from 90 or 180 degrees
333     **/
334    private static Collection<Command> orthogonalize(List<WayData> wayDataList, List<Node> headingNodes) throws InvalidUserInputException {
335        // find average heading
336        double headingAll;
337        try {
338            if (headingNodes.isEmpty()) {
339                // find directions of the segments and make them consistent between different ways
340                wayDataList.get(0).calcDirections(Direction.RIGHT);
341                double refHeading = wayDataList.get(0).heading;
342                EastNorth totSum = new EastNorth(0., 0.);
343                for (WayData w : wayDataList) {
344                    w.calcDirections(Direction.RIGHT);
345                    int directionOffset = angleToDirectionChange(w.heading - refHeading, TOLERANCE2);
346                    w.calcDirections(Direction.RIGHT.changeBy(directionOffset));
347                    if (angleToDirectionChange(refHeading - w.heading, TOLERANCE2) != 0)
348                        throw new JosmRuntimeException("orthogonalize error");
349                    totSum = EN.sum(totSum, w.segSum);
350                }
351                headingAll = EN.polar(EastNorth.ZERO, totSum);
352            } else {
353                headingAll = EN.polar(headingNodes.get(0).getEastNorth(), headingNodes.get(1).getEastNorth());
354                for (WayData w : wayDataList) {
355                    w.calcDirections(Direction.RIGHT);
356                    int directionOffset = angleToDirectionChange(w.heading - headingAll, TOLERANCE2);
357                    w.calcDirections(Direction.RIGHT.changeBy(directionOffset));
358                }
359            }
360        } catch (RejectedAngleException ex) {
361            throw new InvalidUserInputException(
362                    tr("<html>Please make sure all selected ways head in a similar direction<br>"+
363                    "or orthogonalize them one by one.</html>"), ex);
364        }
365
366        // put the nodes of all ways in a set
367        final Set<Node> allNodes = wayDataList.stream().flatMap(w -> w.wayNodes.stream()).collect(Collectors.toSet());
368
369        // the new x and y value for each node
370        final Map<Node, Double> nX = new HashMap<>();
371        final Map<Node, Double> nY = new HashMap<>();
372
373        // calculate the centroid of all nodes
374        // it is used as rotation center
375        EastNorth pivot = EastNorth.ZERO;
376        for (Node n : allNodes) {
377            pivot = EN.sum(pivot, n.getEastNorth());
378        }
379        pivot = new EastNorth(pivot.east() / allNodes.size(), pivot.north() / allNodes.size());
380
381        // rotate
382        for (Node n: allNodes) {
383            EastNorth tmp = EN.rotateCC(pivot, n.getEastNorth(), -headingAll);
384            nX.put(n, tmp.east());
385            nY.put(n, tmp.north());
386        }
387
388        // orthogonalize
389        final Direction[] horizontal = {Direction.RIGHT, Direction.LEFT};
390        final Direction[] vertical = {Direction.UP, Direction.DOWN};
391        final Direction[][] orientations = {horizontal, vertical};
392        for (Direction[] orientation : orientations) {
393            final Set<Node> s = new HashSet<>(allNodes);
394            int size = s.size();
395            for (int dummy = 0; dummy < size; ++dummy) {
396                if (s.isEmpty()) {
397                    break;
398                }
399                final Node dummyN = s.iterator().next();     // pick arbitrary element of s
400
401                final Set<Node> cs = new HashSet<>(); // will contain each node that can be reached from dummyN
402                cs.add(dummyN);                      // walking only on horizontal / vertical segments
403
404                boolean somethingHappened = true;
405                while (somethingHappened) {
406                    somethingHappened = false;
407                    for (WayData w : wayDataList) {
408                        for (int i = 0; i < w.nSeg; ++i) {
409                            Node n1 = w.wayNodes.get(i);
410                            Node n2 = w.wayNodes.get(i+1);
411                            if (Arrays.asList(orientation).contains(w.segDirections[i])) {
412                                if (cs.contains(n1) && !cs.contains(n2)) {
413                                    cs.add(n2);
414                                    somethingHappened = true;
415                                }
416                                if (cs.contains(n2) && !cs.contains(n1)) {
417                                    cs.add(n1);
418                                    somethingHappened = true;
419                                }
420                            }
421                        }
422                    }
423                }
424
425                final Map<Node, Double> nC = (orientation == horizontal) ? nY : nX;
426
427                double average = 0;
428                for (Node n : cs) {
429                    s.remove(n);
430                    average += nC.get(n).doubleValue();
431                }
432                average = average / cs.size();
433
434                // if one of the nodes is a heading node, forget about the average and use its value
435                for (Node fn : headingNodes) {
436                    if (cs.contains(fn)) {
437                        average = nC.get(fn);
438                    }
439                }
440
441                // At this point, the two heading nodes (if any) are horizontally aligned, i.e. they
442                // have the same y coordinate. So in general we shouldn't find them in a vertical string
443                // of segments. This can still happen in some pathological cases (see #7889). To avoid
444                // both heading nodes collapsing to one point, we simply skip this segment string and
445                // don't touch the node coordinates.
446                if (orientation == vertical && headingNodes.size() == 2 && cs.containsAll(headingNodes)) {
447                    continue;
448                }
449
450                for (Node n : cs) {
451                    nC.put(n, average);
452                }
453            }
454            if (!s.isEmpty()) throw new JosmRuntimeException("orthogonalize error");
455        }
456
457        // rotate back and log the change
458        final Collection<Command> commands = new LinkedList<>();
459        for (Node n: allNodes) {
460            EastNorth tmp = new EastNorth(nX.get(n), nY.get(n));
461            tmp = EN.rotateCC(pivot, tmp, headingAll);
462            final double dx = tmp.east() - n.getEastNorth().east();
463            final double dy = tmp.north() - n.getEastNorth().north();
464            if (headingNodes.contains(n)) { // The heading nodes should not have changed
465                if (Math.abs(dx) > Math.abs(EPSILON * tmp.east()) ||
466                    Math.abs(dy) > Math.abs(EPSILON * tmp.east()))
467                    throw new AssertionError("heading node has changed");
468            } else {
469                OrthogonalizeAction.rememberMovements.put(n, new EastNorth(dx, dy));
470                commands.add(new MoveCommand(n, dx, dy));
471            }
472        }
473        return commands;
474    }
475
476    /**
477     * Class contains everything we need to know about a single way.
478     */
479    private static class WayData {
480        /** The assigned way */
481        public final List<Node> wayNodes;
482        /** Number of Segments of the Way */
483        public final int nSeg;
484        /** Number of Nodes of the Way */
485        public final int nNode;
486        /** Direction of the segments */
487        public final Direction[] segDirections;
488        // segment i goes from node i to node (i+1)
489        /** (Vector-)sum of all horizontal segments plus the sum of all vertical */
490        public EastNorth segSum;
491        // segments turned by 90 degrees
492        /** heading of segSum == approximate heading of the way */
493        public double heading;
494
495        WayData(List<Node> wayNodes) {
496            this.wayNodes = wayNodes;
497            this.nNode = wayNodes.size();
498            this.nSeg = nNode - 1;
499            this.segDirections = new Direction[nSeg];
500        }
501
502        /**
503         * Estimate the direction of the segments, given the first segment points in the
504         * direction <code>pInitialDirection</code>.
505         * Then sum up all horizontal / vertical segments to have a good guess for the
506         * heading of the entire way.
507         * @param pInitialDirection initial direction
508         * @throws InvalidUserInputException if selected ways have an angle different from 90 or 180 degrees
509         */
510        public void calcDirections(Direction pInitialDirection) throws InvalidUserInputException {
511            // alias: wayNodes.get(i).getEastNorth() ---> en[i]
512            final EastNorth[] en = IntStream.range(0, nNode).mapToObj(i -> wayNodes.get(i).getEastNorth()).toArray(EastNorth[]::new);
513            Direction direction = pInitialDirection;
514            segDirections[0] = direction;
515            for (int i = 0; i < nSeg - 1; i++) {
516                double h1 = EN.polar(en[i], en[i+1]);
517                double h2 = EN.polar(en[i+1], en[i+2]);
518                try {
519                    direction = direction.changeBy(angleToDirectionChange(h2 - h1, TOLERANCE1));
520                } catch (RejectedAngleException ex) {
521                    throw new InvalidUserInputException(tr("Please select ways with angles of approximately 90 or 180 degrees."), ex);
522                }
523                segDirections[i+1] = direction;
524            }
525
526            // sum up segments
527            EastNorth h = new EastNorth(0., 0.);
528            EastNorth v = new EastNorth(0., 0.);
529            for (int i = 0; i < nSeg; ++i) {
530                EastNorth segment = EN.diff(en[i+1], en[i]);
531                if (segDirections[i] == Direction.RIGHT) {
532                    h = EN.sum(h, segment);
533                } else if (segDirections[i] == Direction.UP) {
534                    v = EN.sum(v, segment);
535                } else if (segDirections[i] == Direction.LEFT) {
536                    h = EN.diff(h, segment);
537                } else if (segDirections[i] == Direction.DOWN) {
538                    v = EN.diff(v, segment);
539                } else throw new IllegalStateException();
540            }
541            // rotate the vertical vector by 90 degrees (clockwise) and add it to the horizontal vector
542            segSum = EN.sum(h, new EastNorth(v.north(), -v.east()));
543            this.heading = EN.polar(new EastNorth(0., 0.), segSum);
544        }
545    }
546
547    enum Direction {
548        RIGHT, UP, LEFT, DOWN;
549        public Direction changeBy(int directionChange) {
550            int tmp = (this.ordinal() + directionChange) % 4;
551            if (tmp < 0) {
552                tmp += 4;          // the % operator can return negative value
553            }
554            return Direction.values()[tmp];
555        }
556    }
557
558    /**
559     * Make sure angle (up to 2*Pi) is in interval [ 0, 2*Pi ).
560     * @param a angle
561     * @return correct angle
562     */
563    private static double standardAngle0to2PI(double a) {
564        while (a >= 2 * Math.PI) {
565            a -= 2 * Math.PI;
566        }
567        while (a < 0) {
568            a += 2 * Math.PI;
569        }
570        return a;
571    }
572
573    /**
574     * Make sure angle (up to 2*Pi) is in interval ( -Pi, Pi ].
575     * @param a angle
576     * @return correct angle
577     */
578    private static double standardAngleMPItoPI(double a) {
579        while (a > Math.PI) {
580            a -= 2 * Math.PI;
581        }
582        while (a <= -Math.PI) {
583            a += 2 * Math.PI;
584        }
585        return a;
586    }
587
588    /**
589     * Class contains some auxiliary functions
590     */
591    static final class EN {
592        private EN() {
593            // Hide implicit public constructor for utility class
594        }
595
596        /**
597         * Rotate counter-clock-wise.
598         * @param pivot pivot
599         * @param en original east/north
600         * @param angle angle, in radians
601         * @return new east/north
602         */
603        public static EastNorth rotateCC(EastNorth pivot, EastNorth en, double angle) {
604            double cosPhi = Math.cos(angle);
605            double sinPhi = Math.sin(angle);
606            double x = en.east() - pivot.east();
607            double y = en.north() - pivot.north();
608            double nx = cosPhi * x - sinPhi * y + pivot.east();
609            double ny = sinPhi * x + cosPhi * y + pivot.north();
610            return new EastNorth(nx, ny);
611        }
612
613        public static EastNorth sum(EastNorth en1, EastNorth en2) {
614            return new EastNorth(en1.east() + en2.east(), en1.north() + en2.north());
615        }
616
617        public static EastNorth diff(EastNorth en1, EastNorth en2) {
618            return new EastNorth(en1.east() - en2.east(), en1.north() - en2.north());
619        }
620
621        public static double polar(EastNorth en1, EastNorth en2) {
622            return PolarCoor.computeAngle(en2, en1);
623        }
624    }
625
626    /**
627     * Recognize angle to be approximately 0, 90, 180 or 270 degrees.
628     * returns an integral value, corresponding to a counter clockwise turn.
629     * @param a angle, in radians
630     * @param deltaMax maximum tolerance, in radians
631     * @return an integral value, corresponding to a counter clockwise turn
632     * @throws RejectedAngleException in case of invalid angle
633     */
634    private static int angleToDirectionChange(double a, double deltaMax) throws RejectedAngleException {
635        a = standardAngleMPItoPI(a);
636        double d0 = Math.abs(a);
637        double d90 = Math.abs(a - Math.PI / 2);
638        double dm90 = Math.abs(a + Math.PI / 2);
639        int dirChange;
640        if (d0 < deltaMax) {
641            dirChange = 0;
642        } else if (d90 < deltaMax) {
643            dirChange = 1;
644        } else if (dm90 < deltaMax) {
645            dirChange = -1;
646        } else {
647            a = standardAngle0to2PI(a);
648            double d180 = Math.abs(a - Math.PI);
649            if (d180 < deltaMax) {
650                dirChange = 2;
651            } else
652                throw new RejectedAngleException();
653        }
654        return dirChange;
655    }
656
657    /**
658     * Exception: unsuited user input
659     * @since 13670
660     */
661    public static final class InvalidUserInputException extends Exception {
662        InvalidUserInputException(String message) {
663            super(message);
664        }
665
666        InvalidUserInputException(String message, Throwable cause) {
667            super(message, cause);
668        }
669    }
670
671    /**
672     * Exception: angle cannot be recognized as 0, 90, 180 or 270 degrees
673     */
674    protected static class RejectedAngleException extends Exception {
675        RejectedAngleException() {
676            super();
677        }
678    }
679
680    @Override
681    protected void updateEnabledState() {
682        if (MainApplication.getLayerManager().getEditLayer() == null)
683            rememberMovements.clear();
684        updateEnabledStateOnCurrentSelection();
685    }
686
687    @Override
688    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
689        updateEnabledStateOnModifiableSelection(selection);
690    }
691}