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.Collection; 011import java.util.Collections; 012import java.util.HashSet; 013import java.util.LinkedList; 014import java.util.List; 015import java.util.Set; 016 017import javax.swing.JOptionPane; 018 019import org.openstreetmap.josm.command.Command; 020import org.openstreetmap.josm.command.MoveCommand; 021import org.openstreetmap.josm.command.SequenceCommand; 022import org.openstreetmap.josm.data.UndoRedoHandler; 023import org.openstreetmap.josm.data.coor.EastNorth; 024import org.openstreetmap.josm.data.coor.PolarCoor; 025import org.openstreetmap.josm.data.osm.DataSet; 026import org.openstreetmap.josm.data.osm.Node; 027import org.openstreetmap.josm.data.osm.OsmPrimitive; 028import org.openstreetmap.josm.data.osm.Way; 029import org.openstreetmap.josm.gui.Notification; 030import org.openstreetmap.josm.tools.Geometry; 031import org.openstreetmap.josm.tools.Shortcut; 032 033/** 034 * Aligns all selected nodes within a circle. (Useful for roundabouts) 035 * 036 * @author Matthew Newton 037 * @author Petr DlouhĂ˝ 038 * @author Teemu Koskinen 039 * @author Alain Delplanque 040 * 041 * @since 146 042 */ 043public final class AlignInCircleAction extends JosmAction { 044 045 /** 046 * Constructs a new {@code AlignInCircleAction}. 047 */ 048 public AlignInCircleAction() { 049 super(tr("Align Nodes in Circle"), "aligncircle", tr("Move the selected nodes into a circle."), 050 Shortcut.registerShortcut("tools:aligncircle", tr("Tool: {0}", tr("Align Nodes in Circle")), 051 KeyEvent.VK_O, Shortcut.DIRECT), true); 052 setHelpId(ht("/Action/AlignInCircle")); 053 } 054 055 /** 056 * Create a {@link MoveCommand} to move a node to a PolarCoor. 057 * @param n Node to move 058 * @param coor polar coordinate where to move the node 059 * @return new MoveCommand 060 * @since 13107 061 */ 062 public static MoveCommand createMoveCommand(Node n, PolarCoor coor) { 063 EastNorth en = coor.toEastNorth(); 064 return new MoveCommand(n, en.east() - n.getEastNorth().east(), en.north() - n.getEastNorth().north()); 065 } 066 067 /** 068 * Perform AlignInCircle action. 069 * 070 * A fixed node is a node for which it is forbidden to change the angle relative to center of the circle. 071 * All other nodes are uniformly distributed. 072 * 073 * Case 1: One unclosed way. 074 * --> allow action, and align selected way nodes 075 * If nodes contained by this way are selected, there are fix. 076 * If nodes outside from the way are selected there are ignored. 077 * 078 * Case 2: One or more ways are selected and can be joined into a polygon 079 * --> allow action, and align selected ways nodes 080 * If 1 node outside of way is selected, it became center 081 * If 1 node outside and 1 node inside are selected there define center and radius 082 * If no outside node and 2 inside nodes are selected those 2 nodes define diameter 083 * In all other cases outside nodes are ignored 084 * In all cases, selected nodes are fix, nodes with more than one referrers are fix 085 * (first referrer is the selected way) 086 * 087 * Case 3: Only nodes are selected 088 * --> Align these nodes, all are fix 089 */ 090 @Override 091 public void actionPerformed(ActionEvent e) { 092 if (!isEnabled()) 093 return; 094 095 Collection<OsmPrimitive> sel = getLayerManager().getEditDataSet().getSelected(); 096 List<Node> nodes = new LinkedList<>(); 097 // fixNodes: All nodes for which the angle relative to center should not be modified 098 Set<Node> fixNodes = new HashSet<>(); 099 List<Way> ways = new LinkedList<>(); 100 EastNorth center = null; 101 double radius = 0; 102 103 for (OsmPrimitive osm : sel) { 104 if (osm instanceof Node) { 105 nodes.add((Node) osm); 106 } else if (osm instanceof Way) { 107 ways.add((Way) osm); 108 } 109 } 110 111 if (ways.size() == 1 && !ways.get(0).isClosed()) { 112 // Case 1 113 Way w = ways.get(0); 114 fixNodes.add(w.firstNode()); 115 fixNodes.add(w.lastNode()); 116 fixNodes.addAll(nodes); 117 fixNodes.addAll(collectNodesWithExternReferers(ways)); 118 // Temporary closed way used to reorder nodes 119 Way closedWay = new Way(w); 120 closedWay.addNode(w.firstNode()); 121 List<Way> usedWays = new ArrayList<>(1); 122 usedWays.add(closedWay); 123 nodes = collectNodesAnticlockwise(usedWays); 124 } else if (!ways.isEmpty() && checkWaysArePolygon(ways)) { 125 // Case 2 126 List<Node> inside = new ArrayList<>(); 127 List<Node> outside = new ArrayList<>(); 128 129 for (Node n: nodes) { 130 boolean isInside = false; 131 for (Way w: ways) { 132 if (w.getNodes().contains(n)) { 133 isInside = true; 134 break; 135 } 136 } 137 if (isInside) 138 inside.add(n); 139 else 140 outside.add(n); 141 } 142 143 if (outside.size() == 1 && inside.isEmpty()) { 144 center = outside.get(0).getEastNorth(); 145 } else if (outside.size() == 1 && inside.size() == 1) { 146 center = outside.get(0).getEastNorth(); 147 radius = center.distance(inside.get(0).getEastNorth()); 148 } else if (inside.size() == 2 && outside.isEmpty()) { 149 // 2 nodes inside, define diameter 150 EastNorth en0 = inside.get(0).getEastNorth(); 151 EastNorth en1 = inside.get(1).getEastNorth(); 152 center = new EastNorth((en0.east() + en1.east()) / 2, (en0.north() + en1.north()) / 2); 153 radius = en0.distance(en1) / 2; 154 } 155 156 fixNodes.addAll(inside); 157 fixNodes.addAll(collectNodesWithExternReferers(ways)); 158 nodes = collectNodesAnticlockwise(ways); 159 if (nodes.size() < 4) { 160 new Notification( 161 tr("Not enough nodes in selected ways.")) 162 .setIcon(JOptionPane.INFORMATION_MESSAGE) 163 .setDuration(Notification.TIME_SHORT) 164 .show(); 165 return; 166 } 167 } else if (ways.isEmpty() && nodes.size() > 3) { 168 // Case 3 169 fixNodes.addAll(nodes); 170 // No need to reorder nodes since all are fix 171 } else { 172 // Invalid action 173 new Notification( 174 tr("Please select at least four nodes.")) 175 .setIcon(JOptionPane.INFORMATION_MESSAGE) 176 .setDuration(Notification.TIME_SHORT) 177 .show(); 178 return; 179 } 180 181 if (center == null) { 182 // Compute the center of nodes 183 center = Geometry.getCenter(nodes); 184 if (center == null) { 185 new Notification(tr("Cannot determine center of selected nodes.")) 186 .setIcon(JOptionPane.INFORMATION_MESSAGE) 187 .setDuration(Notification.TIME_SHORT) 188 .show(); 189 return; 190 } 191 } 192 193 // Now calculate the average distance to each node from the 194 // center. This method is ok as long as distances are short 195 // relative to the distance from the N or S poles. 196 if (radius == 0) { 197 for (Node n : nodes) { 198 radius += center.distance(n.getEastNorth()); 199 } 200 radius = radius / nodes.size(); 201 } 202 203 if (!actionAllowed(nodes)) return; 204 205 Collection<Command> cmds = new LinkedList<>(); 206 207 // Move each node to that distance from the center. 208 // Nodes that are not "fix" will be adjust making regular arcs. 209 int nodeCount = nodes.size(); 210 // Search first fixed node 211 int startPosition; 212 for (startPosition = 0; startPosition < nodeCount; startPosition++) { 213 if (fixNodes.contains(nodes.get(startPosition % nodeCount))) 214 break; 215 } 216 int i = startPosition; // Start position for current arc 217 int j; // End position for current arc 218 while (i < startPosition + nodeCount) { 219 for (j = i + 1; j < startPosition + nodeCount; j++) { 220 if (fixNodes.contains(nodes.get(j % nodeCount))) 221 break; 222 } 223 Node first = nodes.get(i % nodeCount); 224 PolarCoor pcFirst = new PolarCoor(radius, PolarCoor.computeAngle(first.getEastNorth(), center), center); 225 cmds.add(createMoveCommand(first, pcFirst)); 226 if (j > i + 1) { 227 double delta; 228 if (j == i + nodeCount) { 229 delta = 2 * Math.PI / nodeCount; 230 } else { 231 PolarCoor pcLast = new PolarCoor(nodes.get(j % nodeCount).getEastNorth(), center); 232 delta = pcLast.angle - pcFirst.angle; 233 if (delta < 0) // Assume each PolarCoor.angle is in range ]-pi; pi] 234 delta += 2*Math.PI; 235 delta /= j - i; 236 } 237 for (int k = i+1; k < j; k++) { 238 PolarCoor p = new PolarCoor(radius, pcFirst.angle + (k-i)*delta, center); 239 cmds.add(createMoveCommand(nodes.get(k % nodeCount), p)); 240 } 241 } 242 i = j; // Update start point for next iteration 243 } 244 245 UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Align Nodes in Circle"), cmds)); 246 } 247 248 /** 249 * Collect all nodes with more than one referrer. 250 * @param ways Ways from witch nodes are selected 251 * @return List of nodes with more than one referrer 252 */ 253 private static List<Node> collectNodesWithExternReferers(List<Way> ways) { 254 List<Node> withReferrers = new ArrayList<>(); 255 for (Way w: ways) { 256 for (Node n: w.getNodes()) { 257 if (n.getReferrers().size() > 1) { 258 withReferrers.add(n); 259 } 260 } 261 } 262 return withReferrers; 263 } 264 265 /** 266 * Assuming all ways can be joined into polygon, create an ordered list of node. 267 * @param ways List of ways to be joined 268 * @return Nodes anticlockwise ordered 269 */ 270 private static List<Node> collectNodesAnticlockwise(List<Way> ways) { 271 List<Node> nodes = new ArrayList<>(); 272 Node firstNode = ways.get(0).firstNode(); 273 Node lastNode = null; 274 Way lastWay = null; 275 while (firstNode != lastNode) { 276 if (lastNode == null) lastNode = firstNode; 277 for (Way way: ways) { 278 if (way == lastWay) continue; 279 if (way.firstNode() == lastNode) { 280 List<Node> wayNodes = way.getNodes(); 281 for (int i = 0; i < wayNodes.size() - 1; i++) { 282 nodes.add(wayNodes.get(i)); 283 } 284 lastNode = way.lastNode(); 285 lastWay = way; 286 break; 287 } 288 if (way.lastNode() == lastNode) { 289 List<Node> wayNodes = way.getNodes(); 290 for (int i = wayNodes.size() - 1; i > 0; i--) { 291 nodes.add(wayNodes.get(i)); 292 } 293 lastNode = way.firstNode(); 294 lastWay = way; 295 break; 296 } 297 } 298 } 299 // Check if nodes are in anticlockwise order 300 int nc = nodes.size(); 301 double area = 0; 302 for (int i = 0; i < nc; i++) { 303 EastNorth p1 = nodes.get(i).getEastNorth(); 304 EastNorth p2 = nodes.get((i+1) % nc).getEastNorth(); 305 area += p1.east()*p2.north() - p2.east()*p1.north(); 306 } 307 if (area < 0) 308 Collections.reverse(nodes); 309 return nodes; 310 } 311 312 /** 313 * Check if one or more nodes are outside of download area 314 * @param nodes Nodes to check 315 * @return true if action can be done 316 */ 317 private static boolean actionAllowed(Collection<Node> nodes) { 318 boolean outside = false; 319 for (Node n: nodes) { 320 if (n.isOutsideDownloadArea()) { 321 outside = true; 322 break; 323 } 324 } 325 if (outside) 326 new Notification( 327 tr("One or more nodes involved in this action is outside of the downloaded area.")) 328 .setIcon(JOptionPane.WARNING_MESSAGE) 329 .setDuration(Notification.TIME_SHORT) 330 .show(); 331 return true; 332 } 333 334 @Override 335 protected void updateEnabledState() { 336 DataSet ds = getLayerManager().getEditDataSet(); 337 setEnabled(ds != null && !ds.selectionEmpty()); 338 } 339 340 @Override 341 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 342 updateEnabledStateOnModifiableSelection(selection); 343 } 344 345 /** 346 * Determines if ways can be joined into a polygon. 347 * @param ways The ways collection to check 348 * @return true if all ways can be joined into a polygon 349 */ 350 private static boolean checkWaysArePolygon(Collection<Way> ways) { 351 // For each way, nodes strictly between first and last should't be reference by an other way 352 for (Way way: ways) { 353 for (Node node: way.getNodes()) { 354 if (way.isFirstLastNode(node)) continue; 355 for (Way wayOther: ways) { 356 if (way == wayOther) continue; 357 if (node.getReferrers().contains(wayOther)) return false; 358 } 359 } 360 } 361 // Test if ways can be joined 362 Way currentWay = null; 363 Node startNode = null, endNode = null; 364 int used = 0; 365 while (true) { 366 Way nextWay = null; 367 for (Way w: ways) { 368 if (w.isClosed()) return ways.size() == 1; 369 if (w == currentWay) continue; 370 if (currentWay == null) { 371 nextWay = w; 372 startNode = w.firstNode(); 373 endNode = w.lastNode(); 374 break; 375 } 376 if (w.firstNode() == endNode) { 377 nextWay = w; 378 endNode = w.lastNode(); 379 break; 380 } 381 if (w.lastNode() == endNode) { 382 nextWay = w; 383 endNode = w.firstNode(); 384 break; 385 } 386 } 387 if (nextWay == null) return false; 388 used += 1; 389 currentWay = nextWay; 390 if (endNode == startNode) return used == ways.size(); 391 } 392 } 393}