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