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; 006import static org.openstreetmap.josm.tools.I18n.trn; 007 008import java.awt.event.ActionEvent; 009import java.awt.event.KeyEvent; 010import java.util.ArrayList; 011import java.util.Collection; 012import java.util.Collections; 013import java.util.HashMap; 014import java.util.HashSet; 015import java.util.LinkedList; 016import java.util.List; 017import java.util.Map; 018import java.util.Set; 019 020import javax.swing.JOptionPane; 021import javax.swing.JPanel; 022 023import org.openstreetmap.josm.command.AddCommand; 024import org.openstreetmap.josm.command.ChangeCommand; 025import org.openstreetmap.josm.command.ChangeNodesCommand; 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.LatLon; 031import org.openstreetmap.josm.data.osm.DefaultNameFormatter; 032import org.openstreetmap.josm.data.osm.Node; 033import org.openstreetmap.josm.data.osm.OsmPrimitive; 034import org.openstreetmap.josm.data.osm.Relation; 035import org.openstreetmap.josm.data.osm.RelationMember; 036import org.openstreetmap.josm.data.osm.Way; 037import org.openstreetmap.josm.gui.MainApplication; 038import org.openstreetmap.josm.gui.MapView; 039import org.openstreetmap.josm.gui.Notification; 040import org.openstreetmap.josm.gui.dialogs.PropertiesMembershipChoiceDialog; 041import org.openstreetmap.josm.gui.dialogs.PropertiesMembershipChoiceDialog.ExistingBothNew; 042import org.openstreetmap.josm.tools.Logging; 043import org.openstreetmap.josm.tools.Shortcut; 044import org.openstreetmap.josm.tools.UserCancelException; 045import org.openstreetmap.josm.tools.Utils; 046 047/** 048 * Duplicate nodes that are used by multiple ways. 049 * 050 * Resulting nodes are identical, up to their position. 051 * 052 * This is the opposite of the MergeNodesAction. 053 * 054 * If a single node is selected, it will copy that node and remove all tags from the old one 055 */ 056public class UnGlueAction extends JosmAction { 057 058 private transient Node selectedNode; 059 private transient Way selectedWay; 060 private transient Set<Node> selectedNodes; 061 062 /** 063 * Create a new UnGlueAction. 064 */ 065 public UnGlueAction() { 066 super(tr("UnGlue Ways"), "unglueways", tr("Duplicate nodes that are used by multiple ways."), 067 Shortcut.registerShortcut("tools:unglue", tr("Tool: {0}", tr("UnGlue Ways")), KeyEvent.VK_G, Shortcut.DIRECT), true); 068 setHelpId(ht("/Action/UnGlue")); 069 } 070 071 /** 072 * Called when the action is executed. 073 * 074 * This method does some checking on the selection and calls the matching unGlueWay method. 075 */ 076 @Override 077 public void actionPerformed(ActionEvent e) { 078 try { 079 unglue(e); 080 } catch (UserCancelException ignore) { 081 Logging.trace(ignore); 082 } finally { 083 cleanup(); 084 } 085 } 086 087 protected void unglue(ActionEvent e) throws UserCancelException { 088 089 Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getSelected(); 090 091 String errMsg = null; 092 int errorTime = Notification.TIME_DEFAULT; 093 if (checkSelectionOneNodeAtMostOneWay(selection)) { 094 checkAndConfirmOutlyingUnglue(); 095 int count = 0; 096 for (Way w : selectedNode.getParentWays()) { 097 if (!w.isUsable() || w.getNodesCount() < 1) { 098 continue; 099 } 100 count++; 101 } 102 if (count < 2) { 103 boolean selfCrossing = false; 104 if (count == 1) { 105 // First try unglue self-crossing way 106 selfCrossing = unglueSelfCrossingWay(); 107 } 108 // If there aren't enough ways, maybe the user wanted to unglue the nodes 109 // (= copy tags to a new node) 110 if (!selfCrossing) 111 if (checkForUnglueNode(selection)) { 112 unglueOneNodeAtMostOneWay(e); 113 } else { 114 errorTime = Notification.TIME_SHORT; 115 errMsg = tr("This node is not glued to anything else."); 116 } 117 } else { 118 // and then do the work. 119 unglueWays(); 120 } 121 } else if (checkSelectionOneWayAnyNodes(selection)) { 122 checkAndConfirmOutlyingUnglue(); 123 Set<Node> tmpNodes = new HashSet<>(); 124 for (Node n : selectedNodes) { 125 int count = 0; 126 for (Way w : n.getParentWays()) { 127 if (!w.isUsable()) { 128 continue; 129 } 130 count++; 131 } 132 if (count >= 2) { 133 tmpNodes.add(n); 134 } 135 } 136 if (tmpNodes.isEmpty()) { 137 if (selection.size() > 1) { 138 errMsg = tr("None of these nodes are glued to anything else."); 139 } else { 140 errMsg = tr("None of this way''s nodes are glued to anything else."); 141 } 142 } else { 143 // and then do the work. 144 selectedNodes = tmpNodes; 145 unglueOneWayAnyNodes(); 146 } 147 } else { 148 errorTime = Notification.TIME_VERY_LONG; 149 errMsg = 150 tr("The current selection cannot be used for unglueing.")+'\n'+ 151 '\n'+ 152 tr("Select either:")+'\n'+ 153 tr("* One tagged node, or")+'\n'+ 154 tr("* One node that is used by more than one way, or")+'\n'+ 155 tr("* One node that is used by more than one way and one of those ways, or")+'\n'+ 156 tr("* One way that has one or more nodes that are used by more than one way, or")+'\n'+ 157 tr("* One way and one or more of its nodes that are used by more than one way.")+'\n'+ 158 '\n'+ 159 tr("Note: If a way is selected, this way will get fresh copies of the unglued\n"+ 160 "nodes and the new nodes will be selected. Otherwise, all ways will get their\n"+ 161 "own copy and all nodes will be selected."); 162 } 163 164 if (errMsg != null) { 165 new Notification( 166 errMsg) 167 .setIcon(JOptionPane.ERROR_MESSAGE) 168 .setDuration(errorTime) 169 .show(); 170 } 171 } 172 173 private void cleanup() { 174 selectedNode = null; 175 selectedWay = null; 176 selectedNodes = null; 177 } 178 179 static void update(PropertiesMembershipChoiceDialog dialog, Node existingNode, List<Node> newNodes, Collection<Command> cmds) { 180 updateMemberships(dialog.getMemberships().orElse(null), existingNode, newNodes, cmds); 181 updateProperties(dialog.getTags().orElse(null), existingNode, newNodes, cmds); 182 } 183 184 private static void updateProperties(ExistingBothNew tags, Node existingNode, Iterable<Node> newNodes, Collection<Command> cmds) { 185 if (ExistingBothNew.NEW == tags) { 186 final Node newSelectedNode = new Node(existingNode); 187 newSelectedNode.removeAll(); 188 cmds.add(new ChangeCommand(existingNode, newSelectedNode)); 189 } else if (ExistingBothNew.OLD == tags) { 190 for (Node newNode : newNodes) { 191 newNode.removeAll(); 192 } 193 } 194 } 195 196 /** 197 * Assumes there is one tagged Node stored in selectedNode that it will try to unglue. 198 * (i.e. copy node and remove all tags from the old one. Relations will not be removed) 199 * @param e event that triggered the action 200 */ 201 private void unglueOneNodeAtMostOneWay(ActionEvent e) { 202 final PropertiesMembershipChoiceDialog dialog; 203 try { 204 dialog = PropertiesMembershipChoiceDialog.showIfNecessary(Collections.singleton(selectedNode), true); 205 } catch (UserCancelException ex) { 206 Logging.trace(ex); 207 return; 208 } 209 210 final Node unglued = new Node(selectedNode, true); 211 boolean moveSelectedNode = false; 212 213 List<Command> cmds = new LinkedList<>(); 214 cmds.add(new AddCommand(selectedNode.getDataSet(), unglued)); 215 if (dialog != null && ExistingBothNew.NEW == dialog.getTags().orElse(null)) { 216 // unglued node gets the ID and history, thus replace way node with a fresh one 217 final Way way = selectedNode.getParentWays().get(0); 218 final List<Node> newWayNodes = way.getNodes(); 219 newWayNodes.replaceAll(n -> selectedNode.equals(n) ? unglued : n); 220 cmds.add(new ChangeNodesCommand(way, newWayNodes)); 221 updateMemberships(dialog.getMemberships().map(ExistingBothNew::opposite).orElse(null), 222 selectedNode, Collections.singletonList(unglued), cmds); 223 updateProperties(dialog.getTags().map(ExistingBothNew::opposite).orElse(null), 224 selectedNode, Collections.singletonList(unglued), cmds); 225 moveSelectedNode = true; 226 } else if (dialog != null) { 227 update(dialog, selectedNode, Collections.singletonList(unglued), cmds); 228 } 229 230 // If this wasn't called from menu, place it where the cursor is/was 231 MapView mv = MainApplication.getMap().mapView; 232 if (e.getSource() instanceof JPanel) { 233 final LatLon latLon = mv.getLatLon(mv.lastMEvent.getX(), mv.lastMEvent.getY()); 234 if (moveSelectedNode) { 235 cmds.add(new MoveCommand(selectedNode, latLon)); 236 } else { 237 unglued.setCoor(latLon); 238 } 239 } 240 241 UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Unglued Node"), cmds)); 242 getLayerManager().getEditDataSet().setSelected(moveSelectedNode ? selectedNode : unglued); 243 mv.repaint(); 244 } 245 246 /** 247 * Checks if selection is suitable for ungluing. This is the case when there's a single, 248 * tagged node selected that's part of at least one way (ungluing an unconnected node does 249 * not make sense. Due to the call order in actionPerformed, this is only called when the 250 * node is only part of one or less ways. 251 * 252 * @param selection The selection to check against 253 * @return {@code true} if selection is suitable 254 */ 255 private boolean checkForUnglueNode(Collection<? extends OsmPrimitive> selection) { 256 if (selection.size() != 1) 257 return false; 258 OsmPrimitive n = (OsmPrimitive) selection.toArray()[0]; 259 if (!(n instanceof Node)) 260 return false; 261 if (((Node) n).getParentWays().isEmpty()) 262 return false; 263 264 selectedNode = (Node) n; 265 return selectedNode.isTagged(); 266 } 267 268 /** 269 * Checks if the selection consists of something we can work with. 270 * Checks only if the number and type of items selected looks good. 271 * 272 * If this method returns "true", selectedNode and selectedWay will be set. 273 * 274 * Returns true if either one node is selected or one node and one 275 * way are selected and the node is part of the way. 276 * 277 * The way will be put into the object variable "selectedWay", the node into "selectedNode". 278 * @param selection selected primitives 279 * @return true if either one node is selected or one node and one way are selected and the node is part of the way 280 */ 281 private boolean checkSelectionOneNodeAtMostOneWay(Collection<? extends OsmPrimitive> selection) { 282 283 int size = selection.size(); 284 if (size < 1 || size > 2) 285 return false; 286 287 selectedNode = null; 288 selectedWay = null; 289 290 for (OsmPrimitive p : selection) { 291 if (p instanceof Node) { 292 selectedNode = (Node) p; 293 if (size == 1 || selectedWay != null) 294 return size == 1 || selectedWay.containsNode(selectedNode); 295 } else if (p instanceof Way) { 296 selectedWay = (Way) p; 297 if (size == 2 && selectedNode != null) 298 return selectedWay.containsNode(selectedNode); 299 } 300 } 301 302 return false; 303 } 304 305 /** 306 * Checks if the selection consists of something we can work with. 307 * Checks only if the number and type of items selected looks good. 308 * 309 * Returns true if one way and any number of nodes that are part of that way are selected. 310 * Note: "any" can be none, then all nodes of the way are used. 311 * 312 * The way will be put into the object variable "selectedWay", the nodes into "selectedNodes". 313 * @param selection selected primitives 314 * @return true if one way and any number of nodes that are part of that way are selected 315 */ 316 private boolean checkSelectionOneWayAnyNodes(Collection<? extends OsmPrimitive> selection) { 317 if (selection.isEmpty()) 318 return false; 319 320 selectedWay = null; 321 for (OsmPrimitive p : selection) { 322 if (p instanceof Way) { 323 if (selectedWay != null) 324 return false; 325 selectedWay = (Way) p; 326 } 327 } 328 if (selectedWay == null) 329 return false; 330 331 selectedNodes = new HashSet<>(); 332 for (OsmPrimitive p : selection) { 333 if (p instanceof Node) { 334 Node n = (Node) p; 335 if (!selectedWay.containsNode(n)) 336 return false; 337 selectedNodes.add(n); 338 } 339 } 340 341 if (selectedNodes.isEmpty()) { 342 selectedNodes.addAll(selectedWay.getNodes()); 343 } 344 345 return true; 346 } 347 348 /** 349 * dupe the given node of the given way 350 * 351 * assume that originalNode is in the way 352 * <ul> 353 * <li>the new node will be put into the parameter newNodes.</li> 354 * <li>the add-node command will be put into the parameter cmds.</li> 355 * <li>the changed way will be returned and must be put into cmds by the caller!</li> 356 * </ul> 357 * @param originalNode original node to duplicate 358 * @param w parent way 359 * @param cmds List of commands that will contain the new "add node" command 360 * @param newNodes List of nodes that will contain the new node 361 * @return new way The modified way. Change command mus be handled by the caller 362 */ 363 private static Way modifyWay(Node originalNode, Way w, List<Command> cmds, List<Node> newNodes) { 364 // clone the node for the way 365 Node newNode = new Node(originalNode, true /* clear OSM ID */); 366 newNodes.add(newNode); 367 cmds.add(new AddCommand(originalNode.getDataSet(), newNode)); 368 369 List<Node> nn = new ArrayList<>(); 370 for (Node pushNode : w.getNodes()) { 371 if (originalNode == pushNode) { 372 pushNode = newNode; 373 } 374 nn.add(pushNode); 375 } 376 Way newWay = new Way(w); 377 newWay.setNodes(nn); 378 379 return newWay; 380 } 381 382 /** 383 * put all newNodes into the same relation(s) that originalNode is in 384 * @param memberships where the memberships should be places 385 * @param originalNode original node to duplicate 386 * @param cmds List of commands that will contain the new "change relation" commands 387 * @param newNodes List of nodes that contain the new node 388 */ 389 private static void updateMemberships(ExistingBothNew memberships, Node originalNode, List<Node> newNodes, Collection<Command> cmds) { 390 if (memberships == null || ExistingBothNew.OLD == memberships) { 391 return; 392 } 393 // modify all relations containing the node 394 for (Relation r : OsmPrimitive.getParentRelations(Collections.singleton(originalNode))) { 395 if (r.isDeleted()) { 396 continue; 397 } 398 Relation newRel = null; 399 Map<String, Integer> rolesToReAdd = null; // <role name, index> 400 int i = 0; 401 for (RelationMember rm : r.getMembers()) { 402 if (rm.isNode() && rm.getMember() == originalNode) { 403 if (newRel == null) { 404 newRel = new Relation(r); 405 rolesToReAdd = new HashMap<>(); 406 } 407 if (rolesToReAdd != null) { 408 rolesToReAdd.put(rm.getRole(), i); 409 } 410 } 411 i++; 412 } 413 if (newRel != null) { 414 if (rolesToReAdd != null) { 415 for (Map.Entry<String, Integer> role : rolesToReAdd.entrySet()) { 416 for (Node n : newNodes) { 417 newRel.addMember(role.getValue() + 1, new RelationMember(role.getKey(), n)); 418 } 419 if (ExistingBothNew.NEW == memberships) { 420 // remove old member 421 newRel.removeMember(role.getValue()); 422 } 423 } 424 } 425 cmds.add(new ChangeCommand(r, newRel)); 426 } 427 } 428 } 429 430 /** 431 * dupe a single node into as many nodes as there are ways using it, OR 432 * 433 * dupe a single node once, and put the copy on the selected way 434 */ 435 private void unglueWays() { 436 final PropertiesMembershipChoiceDialog dialog; 437 try { 438 dialog = PropertiesMembershipChoiceDialog.showIfNecessary(Collections.singleton(selectedNode), false); 439 } catch (UserCancelException e) { 440 Logging.trace(e); 441 return; 442 } 443 444 List<Command> cmds = new LinkedList<>(); 445 List<Node> newNodes = new LinkedList<>(); 446 if (selectedWay == null) { 447 Way wayWithSelectedNode = null; 448 LinkedList<Way> parentWays = new LinkedList<>(); 449 for (OsmPrimitive osm : selectedNode.getReferrers()) { 450 if (osm.isUsable() && osm instanceof Way) { 451 Way w = (Way) osm; 452 if (wayWithSelectedNode == null && !w.isFirstLastNode(selectedNode)) { 453 wayWithSelectedNode = w; 454 } else { 455 parentWays.add(w); 456 } 457 } 458 } 459 if (wayWithSelectedNode == null) { 460 parentWays.removeFirst(); 461 } 462 for (Way w : parentWays) { 463 cmds.add(new ChangeCommand(w, modifyWay(selectedNode, w, cmds, newNodes))); 464 } 465 notifyWayPartOfRelation(parentWays); 466 } else { 467 cmds.add(new ChangeCommand(selectedWay, modifyWay(selectedNode, selectedWay, cmds, newNodes))); 468 notifyWayPartOfRelation(Collections.singleton(selectedWay)); 469 } 470 471 if (dialog != null) { 472 update(dialog, selectedNode, newNodes, cmds); 473 } 474 475 execCommands(cmds, newNodes); 476 } 477 478 /** 479 * Add commands to undo-redo system. 480 * @param cmds Commands to execute 481 * @param newNodes New created nodes by this set of command 482 */ 483 private void execCommands(List<Command> cmds, List<Node> newNodes) { 484 UndoRedoHandler.getInstance().add(new SequenceCommand(/* for correct i18n of plural forms - see #9110 */ 485 trn("Dupe into {0} node", "Dupe into {0} nodes", newNodes.size() + 1L, newNodes.size() + 1L), cmds)); 486 // select one of the new nodes 487 getLayerManager().getEditDataSet().setSelected(newNodes.get(0)); 488 } 489 490 /** 491 * Duplicates a node used several times by the same way. See #9896. 492 * @return true if action is OK false if there is nothing to do 493 */ 494 private boolean unglueSelfCrossingWay() { 495 // According to previous check, only one valid way through that node 496 Way way = null; 497 for (Way w: selectedNode.getParentWays()) { 498 if (w.isUsable() && w.getNodesCount() >= 1) { 499 way = w; 500 } 501 } 502 if (way == null) { 503 return false; 504 } 505 List<Command> cmds = new LinkedList<>(); 506 List<Node> oldNodes = way.getNodes(); 507 List<Node> newNodes = new ArrayList<>(oldNodes.size()); 508 List<Node> addNodes = new ArrayList<>(); 509 boolean seen = false; 510 for (Node n: oldNodes) { 511 if (n == selectedNode) { 512 if (seen) { 513 Node newNode = new Node(n, true /* clear OSM ID */); 514 cmds.add(new AddCommand(selectedNode.getDataSet(), newNode)); 515 newNodes.add(newNode); 516 addNodes.add(newNode); 517 } else { 518 newNodes.add(n); 519 seen = true; 520 } 521 } else { 522 newNodes.add(n); 523 } 524 } 525 if (addNodes.isEmpty()) { 526 // selectedNode doesn't need unglue 527 return false; 528 } 529 cmds.add(new ChangeNodesCommand(way, newNodes)); 530 notifyWayPartOfRelation(Collections.singleton(way)); 531 try { 532 final PropertiesMembershipChoiceDialog dialog = PropertiesMembershipChoiceDialog.showIfNecessary( 533 Collections.singleton(selectedNode), false); 534 if (dialog != null) { 535 update(dialog, selectedNode, addNodes, cmds); 536 } 537 execCommands(cmds, addNodes); 538 return true; 539 } catch (UserCancelException ignore) { 540 Logging.trace(ignore); 541 } 542 return false; 543 } 544 545 /** 546 * dupe all nodes that are selected, and put the copies on the selected way 547 * 548 */ 549 private void unglueOneWayAnyNodes() { 550 Way tmpWay = selectedWay; 551 552 final PropertiesMembershipChoiceDialog dialog; 553 try { 554 dialog = PropertiesMembershipChoiceDialog.showIfNecessary(selectedNodes, false); 555 } catch (UserCancelException e) { 556 Logging.trace(e); 557 return; 558 } 559 560 List<Command> cmds = new LinkedList<>(); 561 List<Node> allNewNodes = new LinkedList<>(); 562 for (Node n : selectedNodes) { 563 List<Node> newNodes = new LinkedList<>(); 564 tmpWay = modifyWay(n, tmpWay, cmds, newNodes); 565 if (dialog != null) { 566 update(dialog, n, newNodes, cmds); 567 } 568 allNewNodes.addAll(newNodes); 569 } 570 cmds.add(new ChangeCommand(selectedWay, tmpWay)); // only one changeCommand for a way, else garbage will happen 571 notifyWayPartOfRelation(Collections.singleton(selectedWay)); 572 573 UndoRedoHandler.getInstance().add(new SequenceCommand( 574 trn("Dupe {0} node into {1} nodes", "Dupe {0} nodes into {1} nodes", 575 selectedNodes.size(), selectedNodes.size(), selectedNodes.size()+allNewNodes.size()), cmds)); 576 getLayerManager().getEditDataSet().setSelected(allNewNodes); 577 } 578 579 @Override 580 protected void updateEnabledState() { 581 updateEnabledStateOnCurrentSelection(); 582 } 583 584 @Override 585 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 586 updateEnabledStateOnModifiableSelection(selection); 587 } 588 589 protected void checkAndConfirmOutlyingUnglue() throws UserCancelException { 590 List<OsmPrimitive> primitives = new ArrayList<>(2 + (selectedNodes == null ? 0 : selectedNodes.size())); 591 if (selectedNodes != null) 592 primitives.addAll(selectedNodes); 593 if (selectedNode != null) 594 primitives.add(selectedNode); 595 final boolean ok = checkAndConfirmOutlyingOperation("unglue", 596 tr("Unglue confirmation"), 597 tr("You are about to unglue nodes outside of the area you have downloaded." 598 + "<br>" 599 + "This can cause problems because other objects (that you do not see) might use them." 600 + "<br>" 601 + "Do you really want to unglue?"), 602 tr("You are about to unglue incomplete objects." 603 + "<br>" 604 + "This will cause problems because you don''t see the real object." 605 + "<br>" + "Do you really want to unglue?"), 606 primitives, null); 607 if (!ok) { 608 throw new UserCancelException(); 609 } 610 } 611 612 protected void notifyWayPartOfRelation(final Iterable<Way> ways) { 613 final Set<String> affectedRelations = new HashSet<>(); 614 for (Way way : ways) { 615 for (OsmPrimitive ref : way.getReferrers()) { 616 if (ref instanceof Relation && ref.isUsable()) { 617 affectedRelations.add(ref.getDisplayName(DefaultNameFormatter.getInstance())); 618 } 619 } 620 } 621 if (affectedRelations.isEmpty()) { 622 return; 623 } 624 625 final String msg1 = trn("Unglueing affected {0} relation: {1}", "Unglueing affected {0} relations: {1}", 626 affectedRelations.size(), affectedRelations.size(), Utils.joinAsHtmlUnorderedList(affectedRelations)); 627 final String msg2 = trn("Ensure that the relation has not been broken!", "Ensure that the relations have not been broken!", 628 affectedRelations.size()); 629 new Notification("<html>" + msg1 + msg2).setIcon(JOptionPane.WARNING_MESSAGE).show(); 630 } 631}