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.Point; 009import java.awt.event.ActionEvent; 010import java.awt.event.KeyEvent; 011import java.util.ArrayList; 012import java.util.Collection; 013import java.util.Collections; 014import java.util.HashMap; 015import java.util.HashSet; 016import java.util.List; 017import java.util.Map; 018import java.util.Set; 019import java.util.stream.Collectors; 020 021import javax.swing.JOptionPane; 022 023import org.openstreetmap.josm.command.AddCommand; 024import org.openstreetmap.josm.command.ChangeCommand; 025import org.openstreetmap.josm.command.ChangeMembersCommand; 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.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; 045 046/** 047 * Duplicate nodes that are used by multiple ways or tagged nodes used by a single way 048 * or nodes which referenced more than once by a single way. 049 * 050 * This is the opposite of the MergeNodesAction. 051 * 052 */ 053public class UnGlueAction extends JosmAction { 054 055 private transient Node selectedNode; 056 private transient Way selectedWay; 057 private transient Set<Node> selectedNodes; 058 059 /** 060 * Create a new UnGlueAction. 061 */ 062 public UnGlueAction() { 063 super(tr("UnGlue Ways"), "unglueways", tr("Duplicate nodes that are used by multiple ways."), 064 Shortcut.registerShortcut("tools:unglue", tr("Tools: {0}", tr("UnGlue Ways")), KeyEvent.VK_G, Shortcut.DIRECT), true); 065 setHelpId(ht("/Action/UnGlue")); 066 } 067 068 /** 069 * Called when the action is executed. 070 * 071 * This method does some checking on the selection and calls the matching unGlueWay method. 072 */ 073 @Override 074 public void actionPerformed(ActionEvent e) { 075 try { 076 unglue(); 077 } catch (UserCancelException ignore) { 078 Logging.trace(ignore); 079 } finally { 080 cleanup(); 081 } 082 } 083 084 protected void unglue() throws UserCancelException { 085 086 Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getSelected(); 087 088 String errMsg = null; 089 int errorTime = Notification.TIME_DEFAULT; 090 091 if (checkSelectionOneNodeAtMostOneWay(selection)) { 092 checkAndConfirmOutlyingUnglue(); 093 List<Way> parentWays = selectedNode.getParentWays().stream().filter(Way::isUsable).collect(Collectors.toList()); 094 095 if (parentWays.size() < 2) { 096 if (!parentWays.isEmpty()) { 097 // single way 098 Way way = selectedWay == null ? parentWays.get(0) : selectedWay; 099 boolean closedOrSelfCrossing = way.getNodes().stream().filter(n -> n == selectedNode).count() > 1; 100 101 final PropertiesMembershipChoiceDialog dialog = PropertiesMembershipChoiceDialog.showIfNecessary( 102 Collections.singleton(selectedNode), !selectedNode.isTagged()); 103 if (dialog != null && dialog.getTags().isPresent()) { 104 unglueOneNodeAtMostOneWay(way, dialog); 105 return; 106 } else if (closedOrSelfCrossing) { 107 unglueClosedOrSelfCrossingWay(way, dialog); 108 return; 109 } 110 } 111 errorTime = Notification.TIME_SHORT; 112 errMsg = tr("This node is not glued to anything else."); 113 } else { 114 // and then do the work. 115 unglueWays(); 116 } 117 } else if (checkSelectionOneWayAnyNodes(selection)) { 118 checkAndConfirmOutlyingUnglue(); 119 selectedNodes.removeIf(n -> n.getParentWays().stream().filter(Way::isUsable).count() < 2); 120 if (selectedNodes.isEmpty()) { 121 if (selection.size() > 1) { 122 errMsg = tr("None of these nodes are glued to anything else."); 123 } else { 124 errMsg = tr("None of this way''s nodes are glued to anything else."); 125 } 126 } else if (selectedNodes.size() == 1) { 127 selectedNode = selectedNodes.iterator().next(); 128 unglueWays(); 129 } else { 130 // and then do the work. 131 unglueOneWayAnyNodes(); 132 } 133 } else { 134 errorTime = Notification.TIME_VERY_LONG; 135 errMsg = 136 tr("The current selection cannot be used for unglueing.")+'\n'+ 137 '\n'+ 138 tr("Select either:")+'\n'+ 139 tr("* One tagged node, or")+'\n'+ 140 tr("* One node that is used by more than one way, or")+'\n'+ 141 tr("* One node that is used by more than one way and one of those ways, or")+'\n'+ 142 tr("* One way that has one or more nodes that are used by more than one way, or")+'\n'+ 143 tr("* One way and one or more of its nodes that are used by more than one way.")+'\n'+ 144 '\n'+ 145 tr("Note: If a way is selected, this way will get fresh copies of the unglued\n"+ 146 "nodes and the new nodes will be selected. Otherwise, all ways will get their\n"+ 147 "own copy and all nodes will be selected."); 148 } 149 150 if (errMsg != null) { 151 new Notification( 152 errMsg) 153 .setIcon(JOptionPane.ERROR_MESSAGE) 154 .setDuration(errorTime) 155 .show(); 156 } 157 } 158 159 private void cleanup() { 160 selectedNode = null; 161 selectedWay = null; 162 selectedNodes = null; 163 } 164 165 static void update(PropertiesMembershipChoiceDialog dialog, Node existingNode, List<Node> newNodes, List<Command> cmds) { 166 updateMemberships(dialog.getMemberships().orElse(null), existingNode, newNodes, cmds); 167 updateProperties(dialog.getTags().orElse(null), existingNode, newNodes, cmds); 168 } 169 170 private static void updateProperties(ExistingBothNew tags, Node existingNode, Iterable<Node> newNodes, List<Command> cmds) { 171 if (ExistingBothNew.NEW == tags) { 172 final Node newSelectedNode = new Node(existingNode); 173 newSelectedNode.removeAll(); 174 cmds.add(new ChangeCommand(existingNode, newSelectedNode)); 175 } else if (ExistingBothNew.OLD == tags) { 176 for (Node newNode : newNodes) { 177 newNode.removeAll(); 178 } 179 } 180 } 181 182 /** 183 * Assumes there is one tagged Node stored in selectedNode that it will try to unglue. 184 * (i.e. copy node and remove all tags from the old one.) 185 * @param way way to modify 186 * @param dialog the user dialog 187 */ 188 private void unglueOneNodeAtMostOneWay(Way way, PropertiesMembershipChoiceDialog dialog) { 189 List<Command> cmds = new ArrayList<>(); 190 List<Node> newNodes = new ArrayList<>(); 191 cmds.add(new ChangeNodesCommand(way, modifyWay(selectedNode, way, cmds, newNodes))); 192 if (dialog != null) { 193 update(dialog, selectedNode, newNodes, cmds); 194 } 195 196 // Place the selected node where the cursor is or some pixels above 197 MapView mv = MainApplication.getMap().mapView; 198 Point currMousePos = mv.getMousePosition(); 199 if (currMousePos != null) { 200 cmds.add(new MoveCommand(selectedNode, mv.getLatLon(currMousePos.getX(), currMousePos.getY()))); 201 } else { 202 cmds.add(new MoveCommand(selectedNode, 0, 5)); 203 } 204 UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Unglued Node"), cmds)); 205 getLayerManager().getEditDataSet().setSelected(selectedNode); 206 } 207 208 /** 209 * Checks if the selection consists of something we can work with. 210 * Checks only if the number and type of items selected looks good. 211 * 212 * If this method returns "true", selectedNode will be set, selectedWay might be set 213 * 214 * Returns true if either one node is selected or one node and one 215 * way are selected and the node is part of the way. 216 * 217 * The way will be put into the object variable "selectedWay", the node into "selectedNode". 218 * @param selection selected primitives 219 * @return true if either one node is selected or one node and one way are selected and the node is part of the way 220 */ 221 private boolean checkSelectionOneNodeAtMostOneWay(Collection<? extends OsmPrimitive> selection) { 222 223 int size = selection.size(); 224 if (size < 1 || size > 2) 225 return false; 226 227 selectedNode = null; 228 selectedWay = null; 229 230 for (OsmPrimitive p : selection) { 231 if (p instanceof Node) { 232 selectedNode = (Node) p; 233 if (size == 1 || (selectedWay != null && selectedWay.containsNode(selectedNode))) 234 return true; 235 } else if (p instanceof Way) { 236 selectedWay = (Way) p; 237 if (size == 2 && selectedNode != null) 238 return selectedWay.containsNode(selectedNode); 239 } 240 } 241 242 return false; 243 } 244 245 /** 246 * Checks if the selection consists of something we can work with. 247 * Checks only if the number and type of items selected looks good. 248 * 249 * Returns true if one way and any number of nodes that are part of that way are selected. 250 * Note: "any" can be none, then all nodes of the way are used. 251 * 252 * The way will be put into the object variable "selectedWay", the nodes into "selectedNodes". 253 * @param selection selected primitives 254 * @return true if one way and any number of nodes that are part of that way are selected 255 */ 256 private boolean checkSelectionOneWayAnyNodes(Collection<? extends OsmPrimitive> selection) { 257 if (selection.isEmpty()) 258 return false; 259 260 selectedWay = null; 261 for (OsmPrimitive p : selection) { 262 if (p instanceof Way) { 263 if (selectedWay != null) 264 return false; 265 selectedWay = (Way) p; 266 } 267 } 268 if (selectedWay == null) 269 return false; 270 271 selectedNodes = new HashSet<>(); 272 for (OsmPrimitive p : selection) { 273 if (p instanceof Node) { 274 Node n = (Node) p; 275 if (!selectedWay.containsNode(n)) 276 return false; 277 selectedNodes.add(n); 278 } 279 } 280 281 if (selectedNodes.isEmpty()) { 282 selectedNodes.addAll(selectedWay.getNodes()); 283 } 284 285 return true; 286 } 287 288 /** 289 * dupe the given node of the given way 290 * 291 * assume that originalNode is in the way 292 * <ul> 293 * <li>the new node will be put into the parameter newNodes.</li> 294 * <li>the add-node command will be put into the parameter cmds.</li> 295 * <li>the changed way will be returned and must be put into cmds by the caller!</li> 296 * </ul> 297 * @param originalNode original node to duplicate 298 * @param w parent way 299 * @param cmds List of commands that will contain the new "add node" command 300 * @param newNodes List of nodes that will contain the new node 301 * @return The modified list of way nodes. Change command must be handled by the caller 302 */ 303 private static List<Node> modifyWay(Node originalNode, Way w, List<Command> cmds, List<Node> newNodes) { 304 // clone the node for the way 305 Node newNode = cloneNode(originalNode, cmds); 306 newNodes.add(newNode); 307 308 List<Node> nn = new ArrayList<>(w.getNodes()); 309 nn.replaceAll(n -> n == originalNode ? newNode : n); 310 return nn; 311 } 312 313 private static Node cloneNode(Node originalNode, List<Command> cmds) { 314 Node newNode = new Node(originalNode, true /* clear OSM ID */); 315 cmds.add(new AddCommand(originalNode.getDataSet(), newNode)); 316 return newNode; 317 } 318 319 /** 320 * put all newNodes into the same relation(s) that originalNode is in 321 * @param memberships where the memberships should be places 322 * @param originalNode original node to duplicate 323 * @param cmds List of commands that will contain the new "change relation" commands 324 * @param newNodes List of nodes that contain the new node 325 */ 326 private static void updateMemberships(ExistingBothNew memberships, Node originalNode, List<Node> newNodes, List<Command> cmds) { 327 if (memberships == null || ExistingBothNew.OLD == memberships) { 328 return; 329 } 330 // modify all relations containing the node 331 for (Relation r : OsmPrimitive.getParentRelations(Collections.singleton(originalNode))) { 332 if (r.isDeleted()) { 333 continue; 334 } 335 List<RelationMember> newMembers = new ArrayList<>(r.getMembers()); 336 // loop backwards because we add or remove members, works also when nodes appear 337 // multiple times in the same relation 338 boolean changed = false; 339 for (int i = r.getMembersCount() - 1; i >= 0; i--) { 340 RelationMember rm = r.getMember(i); 341 if (rm.getMember() != originalNode) 342 continue; 343 for (Node n : newNodes) { 344 newMembers.add(i + 1, new RelationMember(rm.getRole(), n)); 345 } 346 if (ExistingBothNew.NEW == memberships) { 347 // remove old member 348 newMembers.remove(i); 349 } 350 changed = true; 351 } 352 if (changed) { 353 cmds.add(new ChangeMembersCommand(r, newMembers)); 354 } 355 } 356 } 357 358 /** 359 * dupe a single node into as many nodes as there are ways using it, OR 360 * 361 * dupe a single node once, and put the copy on the selected way 362 * @throws UserCancelException if user cancels choice 363 */ 364 private void unglueWays() throws UserCancelException { 365 final PropertiesMembershipChoiceDialog dialog = PropertiesMembershipChoiceDialog 366 .showIfNecessary(Collections.singleton(selectedNode), false); 367 List<Command> cmds = new ArrayList<>(); 368 List<Node> newNodes = new ArrayList<>(); 369 List<Way> parentWays; 370 if (selectedWay == null) { 371 parentWays = selectedNode.referrers(Way.class).filter(Way::isUsable).collect(Collectors.toList()); 372 // see #5452 and #18670 373 parentWays.sort((o1, o2) -> { 374 int d = Boolean.compare(!o1.isNew() && !o1.isModified(), !o2.isNew() && !o2.isModified()); 375 if (d == 0) { 376 d = Integer.compare(o2.getReferrers().size(), o1.getReferrers().size()); // reversed 377 } 378 if (d == 0) { 379 d = Boolean.compare(o1.isFirstLastNode(selectedNode), o2.isFirstLastNode(selectedNode)); 380 } 381 return d; 382 }); 383 // first way should not be changed, preferring older ways and those with fewer parents 384 parentWays.remove(0); 385 } else { 386 parentWays = Collections.singletonList(selectedWay); 387 } 388 Set<Way> warnParents = new HashSet<>(); 389 for (Way w : parentWays) { 390 if (w.isFirstLastNode(selectedNode)) 391 warnParents.add(w); 392 cmds.add(new ChangeNodesCommand(w, modifyWay(selectedNode, w, cmds, newNodes))); 393 } 394 395 if (dialog != null) { 396 update(dialog, selectedNode, newNodes, cmds); 397 } 398 notifyWayPartOfRelation(warnParents); 399 400 execCommands(cmds, newNodes); 401 } 402 403 /** 404 * Add commands to undo-redo system. 405 * @param cmds Commands to execute 406 * @param newNodes New created nodes by this set of command 407 */ 408 private void execCommands(List<Command> cmds, List<Node> newNodes) { 409 UndoRedoHandler.getInstance().add(new SequenceCommand(/* for correct i18n of plural forms - see #9110 */ 410 trn("Dupe into {0} node", "Dupe into {0} nodes", newNodes.size() + 1L, newNodes.size() + 1L), cmds)); 411 // select one of the new nodes 412 getLayerManager().getEditDataSet().setSelected(newNodes.get(0)); 413 } 414 415 /** 416 * Duplicates a node used several times by the same way. See #9896. 417 * First occurrence is kept. A closed way will be "opened" when the closing node is unglued. 418 * @param way way to modify 419 * @param dialog user dialog, might be null 420 * @return true if action is OK false if there is nothing to do 421 */ 422 private boolean unglueClosedOrSelfCrossingWay(Way way, PropertiesMembershipChoiceDialog dialog) { 423 // According to previous check, only one valid way through that node 424 List<Command> cmds = new ArrayList<>(); 425 List<Node> oldNodes = way.getNodes(); 426 List<Node> newNodes = new ArrayList<>(oldNodes.size()); 427 List<Node> addNodes = new ArrayList<>(); 428 int count = 0; 429 for (Node n: oldNodes) { 430 if (n == selectedNode && count++ > 0) { 431 n = cloneNode(selectedNode, cmds); 432 addNodes.add(n); 433 } 434 newNodes.add(n); 435 } 436 if (addNodes.isEmpty()) { 437 // selectedNode doesn't need unglue 438 return false; 439 } 440 if (dialog != null) { 441 update(dialog, selectedNode, addNodes, cmds); 442 } 443 addCheckedChangeNodesCmd(cmds, way, newNodes); 444 execCommands(cmds, addNodes); 445 return true; 446 } 447 448 /** 449 * dupe all nodes that are selected, and put the copies on the selected way 450 * @throws UserCancelException if user cancels choice 451 */ 452 private void unglueOneWayAnyNodes() throws UserCancelException { 453 final PropertiesMembershipChoiceDialog dialog = 454 PropertiesMembershipChoiceDialog.showIfNecessary(selectedNodes, false); 455 456 Map<Node, Node> replaced = new HashMap<>(); 457 List<Command> cmds = new ArrayList<>(); 458 459 selectedNodes.forEach(n -> replaced.put(n, cloneNode(n, cmds))); 460 List<Node> modNodes = new ArrayList<>(selectedWay.getNodes()); 461 modNodes.replaceAll(n -> replaced.getOrDefault(n, n)); 462 463 if (dialog != null) { 464 replaced.forEach((k, v) -> update(dialog, k, Collections.singletonList(v), cmds)); 465 } 466 467 // only one changeCommand for a way, else garbage will happen 468 addCheckedChangeNodesCmd(cmds, selectedWay, modNodes); 469 UndoRedoHandler.getInstance().add(new SequenceCommand( 470 trn("Dupe {0} node into {1} nodes", "Dupe {0} nodes into {1} nodes", 471 selectedNodes.size(), selectedNodes.size(), 2 * selectedNodes.size()), cmds)); 472 getLayerManager().getEditDataSet().setSelected(replaced.values()); 473 } 474 475 private boolean addCheckedChangeNodesCmd(List<Command> cmds, Way w, List<Node> nodes) { 476 boolean relationCheck = !calcAffectedRelations(Collections.singleton(w)).isEmpty(); 477 cmds.add(new ChangeNodesCommand(w, nodes)); 478 if (relationCheck) { 479 notifyWayPartOfRelation(Collections.singleton(w)); 480 } 481 return relationCheck; 482 } 483 484 @Override 485 protected void updateEnabledState() { 486 updateEnabledStateOnCurrentSelection(); 487 } 488 489 @Override 490 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 491 updateEnabledStateOnModifiableSelection(selection); 492 } 493 494 protected void checkAndConfirmOutlyingUnglue() throws UserCancelException { 495 List<OsmPrimitive> primitives = new ArrayList<>(2 + (selectedNodes == null ? 0 : selectedNodes.size())); 496 if (selectedNodes != null) 497 primitives.addAll(selectedNodes); 498 if (selectedNode != null) 499 primitives.add(selectedNode); 500 final boolean ok = checkAndConfirmOutlyingOperation("unglue", 501 tr("Unglue confirmation"), 502 tr("You are about to unglue nodes which can have other referrers not yet downloaded." 503 + "<br>" 504 + "This can cause problems because other objects (that you do not see) might use them." 505 + "<br>" 506 + "Do you really want to unglue?"), 507 tr("You are about to unglue incomplete objects." 508 + "<br>" 509 + "This will cause problems because you don''t see the real object." 510 + "<br>" + "Do you really want to unglue?"), 511 primitives, null); 512 if (!ok) { 513 throw new UserCancelException(); 514 } 515 } 516 517 protected void notifyWayPartOfRelation(final Collection<Way> ways) { 518 Set<Relation> affectedRelations = calcAffectedRelations(ways); 519 if (affectedRelations.isEmpty()) { 520 return; 521 } 522 final int size = affectedRelations.size(); 523 final String msg1 = trn("Unglueing possibly affected {0} relation: {1}", "Unglueing possibly affected {0} relations: {1}", 524 size, size, DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(affectedRelations, 20)); 525 final String msg2 = trn("Ensure that the relation has not been broken!", "Ensure that the relations have not been broken!", 526 size); 527 new Notification(msg1 + msg2).setIcon(JOptionPane.WARNING_MESSAGE).show(); 528 } 529 530 protected Set<Relation> calcAffectedRelations(final Collection<Way> ways) { 531 final Set<Node> affectedNodes = (selectedNodes != null) ? selectedNodes : Collections.singleton(selectedNode); 532 return OsmPrimitive.getParentRelations(ways) 533 .stream().filter(r -> isRelationAffected(r, affectedNodes, ways)) 534 .collect(Collectors.toSet()); 535 } 536 537 private static boolean isRelationAffected(Relation r, Set<Node> affectedNodes, Collection<Way> ways) { 538 if (!r.isUsable()) 539 return false; 540 // see #18670: suppress notification when well known restriction types are not affected 541 if (!r.hasTag("type", "restriction", "connectivity", "destination_sign") || r.hasIncompleteMembers()) 542 return true; 543 int count = 0; 544 for (RelationMember rm : r.getMembers()) { 545 if (rm.isNode() && affectedNodes.contains(rm.getNode())) 546 count++; 547 if (rm.isWay() && ways.contains(rm.getWay())) { 548 count++; 549 if ("via".equals(rm.getRole())) { 550 count++; 551 } 552 } 553 } 554 return count >= 2; 555 } 556}