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.io.Serializable;
010import java.util.Collection;
011import java.util.Collections;
012import java.util.Comparator;
013import java.util.HashMap;
014import java.util.HashSet;
015import java.util.LinkedHashMap;
016import java.util.LinkedList;
017import java.util.List;
018import java.util.Map;
019import java.util.Map.Entry;
020import java.util.Set;
021import java.util.TreeMap;
022
023import javax.swing.JOptionPane;
024
025import org.openstreetmap.josm.command.ChangeCommand;
026import org.openstreetmap.josm.command.Command;
027import org.openstreetmap.josm.command.MoveCommand;
028import org.openstreetmap.josm.command.SequenceCommand;
029import org.openstreetmap.josm.data.UndoRedoHandler;
030import org.openstreetmap.josm.data.coor.EastNorth;
031import org.openstreetmap.josm.data.osm.DataSet;
032import org.openstreetmap.josm.data.osm.Node;
033import org.openstreetmap.josm.data.osm.OsmPrimitive;
034import org.openstreetmap.josm.data.osm.Way;
035import org.openstreetmap.josm.data.osm.WaySegment;
036import org.openstreetmap.josm.data.projection.ProjectionRegistry;
037import org.openstreetmap.josm.gui.MainApplication;
038import org.openstreetmap.josm.gui.MapView;
039import org.openstreetmap.josm.gui.Notification;
040import org.openstreetmap.josm.tools.Geometry;
041import org.openstreetmap.josm.tools.MultiMap;
042import org.openstreetmap.josm.tools.Shortcut;
043
044/**
045 * Action allowing to join a node to a nearby way, operating on two modes:<ul>
046 * <li><b>Join Node to Way</b>: Include a node into the nearest way segments. The node does not move</li>
047 * <li><b>Move Node onto Way</b>: Move the node onto the nearest way segments and include it</li>
048 * </ul>
049 * @since 466
050 */
051public class JoinNodeWayAction extends JosmAction {
052
053    protected final boolean joinWayToNode;
054
055    protected JoinNodeWayAction(boolean joinWayToNode, String name, String iconName, String tooltip,
056            Shortcut shortcut, boolean registerInToolbar) {
057        super(name, iconName, tooltip, shortcut, registerInToolbar);
058        this.joinWayToNode = joinWayToNode;
059    }
060
061    /**
062     * Constructs a Join Node to Way action.
063     * @return the Join Node to Way action
064     */
065    public static JoinNodeWayAction createJoinNodeToWayAction() {
066        JoinNodeWayAction action = new JoinNodeWayAction(false,
067                tr("Join Node to Way"), /* ICON */ "joinnodeway",
068                tr("Include a node into the nearest way segments"),
069                Shortcut.registerShortcut("tools:joinnodeway", tr("Tool: {0}", tr("Join Node to Way")),
070                        KeyEvent.VK_J, Shortcut.DIRECT), true);
071        action.setHelpId(ht("/Action/JoinNodeWay"));
072        return action;
073    }
074
075    /**
076     * Constructs a Move Node onto Way action.
077     * @return the Move Node onto Way action
078     */
079    public static JoinNodeWayAction createMoveNodeOntoWayAction() {
080        JoinNodeWayAction action = new JoinNodeWayAction(true,
081                tr("Move Node onto Way"), /* ICON*/ "movenodeontoway",
082                tr("Move the node onto the nearest way segments and include it"),
083                Shortcut.registerShortcut("tools:movenodeontoway", tr("Tool: {0}", tr("Move Node onto Way")),
084                        KeyEvent.VK_N, Shortcut.DIRECT), true);
085        action.setHelpId(ht("/Action/MoveNodeWay"));
086        return action;
087    }
088
089    @Override
090    public void actionPerformed(ActionEvent e) {
091        if (!isEnabled())
092            return;
093        DataSet ds = getLayerManager().getEditDataSet();
094        Collection<Node> selectedNodes = ds.getSelectedNodes();
095        Collection<Command> cmds = new LinkedList<>();
096        Map<Way, MultiMap<Integer, Node>> data = new LinkedHashMap<>();
097
098        // If the user has selected some ways, only join the node to these.
099        boolean restrictToSelectedWays = !ds.getSelectedWays().isEmpty();
100
101        // Planning phase: decide where we'll insert the nodes and put it all in "data"
102        MapView mapView = MainApplication.getMap().mapView;
103        for (Node node : selectedNodes) {
104            List<WaySegment> wss = mapView.getNearestWaySegments(mapView.getPoint(node), OsmPrimitive::isSelectable);
105            // we cannot trust the order of elements in wss because it was calculated based on rounded position value of node
106            TreeMap<Double, List<WaySegment>> nearestMap = new TreeMap<>();
107            EastNorth en = node.getEastNorth();
108            for (WaySegment ws : wss) {
109                // Maybe cleaner to pass a "isSelected" predicate to getNearestWaySegments, but this is less invasive.
110                if (restrictToSelectedWays && !ws.way.isSelected()) {
111                    continue;
112                }
113                /* perpendicular distance squared
114                 * loose some precision to account for possible deviations in the calculation above
115                 * e.g. if identical (A and B) come about reversed in another way, values may differ
116                 * -- zero out least significant 32 dual digits of mantissa..
117                 */
118                double distSq = en.distanceSq(Geometry.closestPointToSegment(ws.getFirstNode().getEastNorth(),
119                        ws.getSecondNode().getEastNorth(), en));
120                // resolution in numbers with large exponent not needed here..
121                distSq = Double.longBitsToDouble(Double.doubleToLongBits(distSq) >> 32 << 32);
122                List<WaySegment> wslist = nearestMap.computeIfAbsent(distSq, k -> new LinkedList<>());
123                wslist.add(ws);
124            }
125            Set<Way> seenWays = new HashSet<>();
126            Double usedDist = null;
127            while (!nearestMap.isEmpty()) {
128                Entry<Double, List<WaySegment>> entry = nearestMap.pollFirstEntry();
129                if (usedDist != null) {
130                    double delta = entry.getKey() - usedDist;
131                    if (delta > 1e-4)
132                        break;
133                }
134                for (WaySegment ws : entry.getValue()) {
135                    // only use the closest WaySegment of each way and ignore those that already contain the node
136                    if (!ws.getFirstNode().equals(node) && !ws.getSecondNode().equals(node)
137                            && !seenWays.contains(ws.way)) {
138                        if (usedDist == null)
139                            usedDist = entry.getKey();
140                        MultiMap<Integer, Node> innerMap = data.get(ws.way);
141                        if (innerMap == null) {
142                            innerMap = new MultiMap<>();
143                            data.put(ws.way, innerMap);
144                        }
145                        innerMap.put(ws.lowerIndex, node);
146                    }
147                    seenWays.add(ws.way);
148                }
149            }
150        }
151
152        // Execute phase: traverse the structure "data" and finally put the nodes into place
153        Map<Node, EastNorth> movedNodes = new HashMap<>();
154        for (Map.Entry<Way, MultiMap<Integer, Node>> entry : data.entrySet()) {
155            final Way w = entry.getKey();
156            final MultiMap<Integer, Node> innerEntry = entry.getValue();
157
158            List<Integer> segmentIndexes = new LinkedList<>();
159            segmentIndexes.addAll(innerEntry.keySet());
160            segmentIndexes.sort(Collections.reverseOrder());
161
162            List<Node> wayNodes = w.getNodes();
163            for (Integer segmentIndex : segmentIndexes) {
164                final Set<Node> nodesInSegment = innerEntry.get(segmentIndex);
165                if (joinWayToNode) {
166                    for (Node node : nodesInSegment) {
167                        EastNorth newPosition = Geometry.closestPointToSegment(
168                                w.getNode(segmentIndex).getEastNorth(),
169                                w.getNode(segmentIndex+1).getEastNorth(),
170                                node.getEastNorth());
171                        EastNorth prevMove = movedNodes.get(node);
172                        if (prevMove != null) {
173                            if (!prevMove.equalsEpsilon(newPosition, 1e-4)) {
174                                // very unlikely: node has same distance to multiple ways which are not nearly overlapping
175                                new Notification(tr("Multiple target ways, no common point found. Nothing was changed."))
176                                        .setIcon(JOptionPane.INFORMATION_MESSAGE)
177                                        .show();
178                                return;
179                            }
180                            continue;
181                        }
182                        MoveCommand c = new MoveCommand(node,
183                                ProjectionRegistry.getProjection().eastNorth2latlon(newPosition));
184                        cmds.add(c);
185                        movedNodes.put(node, newPosition);
186                    }
187                }
188                List<Node> nodesToAdd = new LinkedList<>();
189                nodesToAdd.addAll(nodesInSegment);
190                nodesToAdd.sort(new NodeDistanceToRefNodeComparator(
191                        w.getNode(segmentIndex), w.getNode(segmentIndex+1), !joinWayToNode));
192                wayNodes.addAll(segmentIndex + 1, nodesToAdd);
193            }
194            Way wnew = new Way(w);
195            wnew.setNodes(wayNodes);
196            cmds.add(new ChangeCommand(ds, w, wnew));
197        }
198
199        if (cmds.isEmpty()) return;
200        UndoRedoHandler.getInstance().add(new SequenceCommand(getValue(NAME).toString(), cmds));
201    }
202
203    /**
204     * Sorts collinear nodes by their distance to a common reference node.
205     */
206    private static class NodeDistanceToRefNodeComparator implements Comparator<Node>, Serializable {
207
208        private static final long serialVersionUID = 1L;
209
210        private final EastNorth refPoint;
211        private final EastNorth refPoint2;
212        private final boolean projectToSegment;
213
214        NodeDistanceToRefNodeComparator(Node referenceNode, Node referenceNode2, boolean projectFirst) {
215            refPoint = referenceNode.getEastNorth();
216            refPoint2 = referenceNode2.getEastNorth();
217            projectToSegment = projectFirst;
218        }
219
220        @Override
221        public int compare(Node first, Node second) {
222            EastNorth firstPosition = first.getEastNorth();
223            EastNorth secondPosition = second.getEastNorth();
224
225            if (projectToSegment) {
226                firstPosition = Geometry.closestPointToSegment(refPoint, refPoint2, firstPosition);
227                secondPosition = Geometry.closestPointToSegment(refPoint, refPoint2, secondPosition);
228            }
229
230            double distanceFirst = firstPosition.distance(refPoint);
231            double distanceSecond = secondPosition.distance(refPoint);
232            return Double.compare(distanceFirst, distanceSecond);
233        }
234    }
235
236    @Override
237    protected void updateEnabledState() {
238        updateEnabledStateOnCurrentSelection();
239    }
240
241    @Override
242    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
243        updateEnabledStateOnModifiableSelection(selection);
244    }
245}