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}