source: josm/trunk/src/org/openstreetmap/josm/actions/UnGlueAction.java@ 17534

Last change on this file since 17534 was 17205, checked in by GerdP, 4 years ago

see #19885: memory leak with "temporary" objects in validator and actions
Start to use ChangeMembersCommand instead of ChangeCommand in those places where the cloned relation was only created for the ChangeCommand and not referenced elsewhere.

  • Property svn:eol-style set to native
File size: 23.0 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.actions;
3
4import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
5import static org.openstreetmap.josm.tools.I18n.tr;
6import static org.openstreetmap.josm.tools.I18n.trn;
7
8import java.awt.Point;
9import java.awt.event.ActionEvent;
10import java.awt.event.KeyEvent;
11import java.util.ArrayList;
12import java.util.Collection;
13import java.util.Collections;
14import java.util.HashMap;
15import java.util.HashSet;
16import java.util.List;
17import java.util.Map;
18import java.util.Set;
19import java.util.stream.Collectors;
20
21import javax.swing.JOptionPane;
22
23import org.openstreetmap.josm.command.AddCommand;
24import org.openstreetmap.josm.command.ChangeCommand;
25import org.openstreetmap.josm.command.ChangeMembersCommand;
26import org.openstreetmap.josm.command.ChangeNodesCommand;
27import org.openstreetmap.josm.command.Command;
28import org.openstreetmap.josm.command.MoveCommand;
29import org.openstreetmap.josm.command.SequenceCommand;
30import org.openstreetmap.josm.data.UndoRedoHandler;
31import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
32import org.openstreetmap.josm.data.osm.Node;
33import org.openstreetmap.josm.data.osm.OsmPrimitive;
34import org.openstreetmap.josm.data.osm.Relation;
35import org.openstreetmap.josm.data.osm.RelationMember;
36import org.openstreetmap.josm.data.osm.Way;
37import org.openstreetmap.josm.gui.MainApplication;
38import org.openstreetmap.josm.gui.MapView;
39import org.openstreetmap.josm.gui.Notification;
40import org.openstreetmap.josm.gui.dialogs.PropertiesMembershipChoiceDialog;
41import org.openstreetmap.josm.gui.dialogs.PropertiesMembershipChoiceDialog.ExistingBothNew;
42import org.openstreetmap.josm.tools.Logging;
43import org.openstreetmap.josm.tools.Shortcut;
44import org.openstreetmap.josm.tools.UserCancelException;
45
46/**
47 * Duplicate nodes that are used by multiple ways or tagged nodes used by a single way
48 * or nodes which referenced more than once by a single way.
49 *
50 * This is the opposite of the MergeNodesAction.
51 *
52 */
53public class UnGlueAction extends JosmAction {
54
55 private transient Node selectedNode;
56 private transient Way selectedWay;
57 private transient Set<Node> selectedNodes;
58
59 /**
60 * Create a new UnGlueAction.
61 */
62 public UnGlueAction() {
63 super(tr("UnGlue Ways"), "unglueways", tr("Duplicate nodes that are used by multiple ways."),
64 Shortcut.registerShortcut("tools:unglue", tr("Tools: {0}", tr("UnGlue Ways")), KeyEvent.VK_G, Shortcut.DIRECT), true);
65 setHelpId(ht("/Action/UnGlue"));
66 }
67
68 /**
69 * Called when the action is executed.
70 *
71 * This method does some checking on the selection and calls the matching unGlueWay method.
72 */
73 @Override
74 public void actionPerformed(ActionEvent e) {
75 try {
76 unglue();
77 } catch (UserCancelException ignore) {
78 Logging.trace(ignore);
79 } finally {
80 cleanup();
81 }
82 }
83
84 protected void unglue() throws UserCancelException {
85
86 Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getSelected();
87
88 String errMsg = null;
89 int errorTime = Notification.TIME_DEFAULT;
90
91 if (checkSelectionOneNodeAtMostOneWay(selection)) {
92 checkAndConfirmOutlyingUnglue();
93 List<Way> parentWays = selectedNode.getParentWays().stream().filter(Way::isUsable).collect(Collectors.toList());
94
95 if (parentWays.size() < 2) {
96 if (!parentWays.isEmpty()) {
97 // single way
98 Way way = selectedWay == null ? parentWays.get(0) : selectedWay;
99 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}
Note: See TracBrowser for help on using the repository browser.