source: josm/trunk/src/org/openstreetmap/josm/actions/CreateMultipolygonAction.java@ 17214

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

see #19885: memory leak with "temporary" objects in validator and actions

  • unlink cloned relation if not used

With current code method removeTagsFromWaysIfNeeded()is never called for old relations.

  • Property svn:eol-style set to native
File size: 22.0 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.actions;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.event.ActionEvent;
8import java.awt.event.KeyEvent;
9import java.util.ArrayList;
10import java.util.Arrays;
11import java.util.Collection;
12import java.util.Collections;
13import java.util.HashMap;
14import java.util.HashSet;
15import java.util.LinkedHashSet;
16import java.util.List;
17import java.util.Map;
18import java.util.Map.Entry;
19import java.util.Set;
20import java.util.TreeSet;
21import java.util.stream.Collectors;
22
23import javax.swing.JOptionPane;
24import javax.swing.SwingUtilities;
25
26import org.openstreetmap.josm.actions.relation.DownloadSelectedIncompleteMembersAction;
27import org.openstreetmap.josm.command.AddCommand;
28import org.openstreetmap.josm.command.ChangeCommand;
29import org.openstreetmap.josm.command.ChangePropertyCommand;
30import org.openstreetmap.josm.command.Command;
31import org.openstreetmap.josm.command.SequenceCommand;
32import org.openstreetmap.josm.data.UndoRedoHandler;
33import org.openstreetmap.josm.data.osm.DataSet;
34import org.openstreetmap.josm.data.osm.IPrimitive;
35import org.openstreetmap.josm.data.osm.OsmPrimitive;
36import org.openstreetmap.josm.data.osm.OsmUtils;
37import org.openstreetmap.josm.data.osm.Relation;
38import org.openstreetmap.josm.data.osm.RelationMember;
39import org.openstreetmap.josm.data.osm.Way;
40import org.openstreetmap.josm.data.validation.TestError;
41import org.openstreetmap.josm.data.validation.tests.MultipolygonTest;
42import org.openstreetmap.josm.gui.MainApplication;
43import org.openstreetmap.josm.gui.Notification;
44import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationMemberTask;
45import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationTask;
46import org.openstreetmap.josm.gui.dialogs.relation.RelationEditor;
47import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter;
48import org.openstreetmap.josm.gui.layer.OsmDataLayer;
49import org.openstreetmap.josm.gui.util.GuiHelper;
50import org.openstreetmap.josm.spi.preferences.Config;
51import org.openstreetmap.josm.tools.Pair;
52import org.openstreetmap.josm.tools.Shortcut;
53import org.openstreetmap.josm.tools.SubclassFilteredCollection;
54import org.openstreetmap.josm.tools.Utils;
55
56/**
57 * Create multipolygon from selected ways automatically.
58 *
59 * New relation with type=multipolygon is created.
60 *
61 * If one or more of ways is already in relation with type=multipolygon or the
62 * way is not closed, then error is reported and no relation is created.
63 *
64 * The "inner" and "outer" roles are guessed automatically. First, bbox is
65 * calculated for each way. then the largest area is assumed to be outside and
66 * the rest inside. In cases with one "outside" area and several cut-ins, the
67 * guess should be always good ... In more complex (multiple outer areas) or
68 * buggy (inner and outer ways intersect) scenarios the result is likely to be
69 * wrong.
70 */
71public class CreateMultipolygonAction extends JosmAction {
72
73 private final boolean update;
74 private static final int MAX_MEMBERS_TO_DOWNLOAD = 100;
75
76 /**
77 * Constructs a new {@code CreateMultipolygonAction}.
78 * @param update {@code true} if the multipolygon must be updated, {@code false} if it must be created
79 */
80 public CreateMultipolygonAction(final boolean update) {
81 super(getName(update),
82 update ? /* ICON */ "multipoly_update" : /* ICON */ "multipoly_create",
83 getName(update),
84 /* at least three lines for each shortcut or the server extractor fails */
85 update ? Shortcut.registerShortcut("tools:multipoly_update",
86 tr("Tools: {0}", getName(true)),
87 KeyEvent.VK_B, Shortcut.CTRL_SHIFT)
88 : Shortcut.registerShortcut("tools:multipoly_create",
89 tr("Tools: {0}", getName(false)),
90 KeyEvent.VK_B, Shortcut.CTRL),
91 true, update ? "multipoly_update" : "multipoly_create", true);
92 this.update = update;
93 }
94
95 private static String getName(boolean update) {
96 return update ? tr("Update multipolygon") : tr("Create multipolygon");
97 }
98
99 private static final class CreateUpdateMultipolygonTask implements Runnable {
100 private final Collection<Way> selectedWays;
101 private final Relation multipolygonRelation;
102
103 private CreateUpdateMultipolygonTask(Collection<Way> selectedWays, Relation multipolygonRelation) {
104 this.selectedWays = selectedWays;
105 this.multipolygonRelation = multipolygonRelation;
106 }
107
108 @Override
109 public void run() {
110 final Pair<SequenceCommand, Relation> commandAndRelation = createMultipolygonCommand(selectedWays, multipolygonRelation);
111 if (commandAndRelation == null) {
112 return;
113 }
114 final Command command = commandAndRelation.a;
115
116 // to avoid EDT violations
117 SwingUtilities.invokeLater(() -> {
118 UndoRedoHandler.getInstance().add(command);
119 final Relation relation = (Relation) MainApplication.getLayerManager().getEditDataSet()
120 .getPrimitiveById(commandAndRelation.b);
121 if (relation == null || relation.getDataSet() == null)
122 return; // should not happen
123
124 // Use 'SwingUtilities.invokeLater' to make sure the relationListDialog
125 // knows about the new relation before we try to select it.
126 // (Yes, we are already in event dispatch thread. But DatasetEventManager
127 // uses 'SwingUtilities.invokeLater' to fire events so we have to do the same.)
128 SwingUtilities.invokeLater(() -> {
129 MainApplication.getMap().relationListDialog.selectRelation(relation);
130 if (Config.getPref().getBoolean("multipoly.show-relation-editor", false)) {
131 //Open relation edit window, if set up in preferences
132 // see #19346 un-select updated multipolygon
133 MainApplication.getLayerManager().getEditDataSet().clearSelection(relation);
134 RelationEditor editor = RelationEditor
135 .getEditor(MainApplication.getLayerManager().getEditLayer(), relation, null);
136 editor.setModal(true);
137 editor.setVisible(true);
138 } else {
139 MainApplication.getLayerManager().getEditLayer().setRecentRelation(relation);
140 if (multipolygonRelation == null) {
141 // see #19346 select new multipolygon
142 MainApplication.getLayerManager().getEditDataSet().setSelected(relation);
143 }
144 }
145 });
146 });
147 }
148 }
149
150 @Override
151 public void actionPerformed(ActionEvent e) {
152 DataSet dataSet = getLayerManager().getEditDataSet();
153 if (dataSet == null) {
154 new Notification(
155 tr("No data loaded."))
156 .setIcon(JOptionPane.WARNING_MESSAGE)
157 .setDuration(Notification.TIME_SHORT)
158 .show();
159 return;
160 }
161
162 final Collection<Way> selectedWays = dataSet.getSelectedWays();
163
164 if (selectedWays.isEmpty()) {
165 // Sometimes it make sense creating multipoly of only one way (so it will form outer way)
166 // and then splitting the way later (so there are multiple ways forming outer way)
167 new Notification(
168 tr("You must select at least one way."))
169 .setIcon(JOptionPane.INFORMATION_MESSAGE)
170 .setDuration(Notification.TIME_SHORT)
171 .show();
172 return;
173 }
174
175 final Collection<Relation> selectedRelations = dataSet.getSelectedRelations();
176 final Relation multipolygonRelation = update
177 ? getSelectedMultipolygonRelation(selectedWays, selectedRelations)
178 : null;
179
180 if (update && multipolygonRelation == null)
181 return;
182 // download incomplete relation or incomplete members if necessary
183 OsmDataLayer editLayer = getLayerManager().getEditLayer();
184 if (multipolygonRelation != null && editLayer != null && editLayer.isDownloadable()) {
185 if (!multipolygonRelation.isNew() && multipolygonRelation.isIncomplete()) {
186 MainApplication.worker
187 .submit(new DownloadRelationTask(Collections.singleton(multipolygonRelation), editLayer));
188 } else if (multipolygonRelation.hasIncompleteMembers()) {
189 // with complex relations the download of the full relation is much faster than download of almost all members, see #18341
190 SubclassFilteredCollection<IPrimitive, OsmPrimitive> incompleteMembers = Utils
191 .filteredCollection(DownloadSelectedIncompleteMembersAction.buildSetOfIncompleteMembers(
192 Collections.singleton(multipolygonRelation)), OsmPrimitive.class);
193
194 if (incompleteMembers.size() <= MAX_MEMBERS_TO_DOWNLOAD) {
195 MainApplication.worker
196 .submit(new DownloadRelationMemberTask(multipolygonRelation, incompleteMembers, editLayer));
197 } else {
198 MainApplication.worker
199 .submit(new DownloadRelationTask(Collections.singleton(multipolygonRelation), editLayer));
200
201 }
202 }
203 }
204 // create/update multipolygon relation
205 MainApplication.worker.submit(new CreateUpdateMultipolygonTask(selectedWays, multipolygonRelation));
206 }
207
208 private static Relation getSelectedMultipolygonRelation(Collection<Way> selectedWays, Collection<Relation> selectedRelations) {
209 Relation candidate = null;
210 if (selectedRelations.size() == 1) {
211 candidate = selectedRelations.iterator().next();
212 if (!candidate.hasTag("type", "multipolygon"))
213 candidate = null;
214 } else if (!selectedWays.isEmpty()) {
215 for (final Way w : selectedWays) {
216 for (OsmPrimitive r : w.getReferrers()) {
217 if (r != candidate && !r.isDisabled() && r instanceof Relation && r.hasTag("type", "multipolygon")) {
218 if (candidate != null)
219 return null; // found another multipolygon relation
220 candidate = (Relation) r;
221 }
222 }
223 }
224 }
225 return candidate;
226 }
227
228 /**
229 * Returns a {@link Pair} of the old multipolygon {@link Relation} (or null) and the newly created/modified multipolygon {@link Relation}.
230 * @param selectedWays selected ways
231 * @param selectedMultipolygonRelation selected multipolygon relation
232 * @return pair of old and new multipolygon relation if a difference was found, else the pair contains the old relation twice
233 */
234 public static Pair<Relation, Relation> updateMultipolygonRelation(Collection<Way> selectedWays, Relation selectedMultipolygonRelation) {
235
236 // add ways of existing relation to include them in polygon analysis
237 Set<Way> ways = new HashSet<>(selectedWays);
238 ways.addAll(selectedMultipolygonRelation.getMemberPrimitives(Way.class));
239
240 // even if no way was added the inner/outer roles might be different
241 MultipolygonTest mpTest = new MultipolygonTest();
242 Relation calculated = mpTest.makeFromWays(ways);
243 if (mpTest.getErrors().isEmpty()) {
244 return mergeRelationsMembers(selectedMultipolygonRelation, calculated);
245 }
246 showErrors(mpTest.getErrors());
247 return null; //could not make multipolygon.
248 }
249
250 /**
251 * Merge members of multipolygon relation. Maintains the order of the old relation. May change roles,
252 * removes duplicate and non-way members and adds new members found in {@code calculated}.
253 * @param old old multipolygon relation
254 * @param calculated calculated multipolygon relation
255 * @return pair of old and new multipolygon relation if a difference was found, else the pair contains the old relation twice
256 */
257 private static Pair<Relation, Relation> mergeRelationsMembers(Relation old, Relation calculated) {
258 Set<RelationMember> merged = new LinkedHashSet<>();
259 boolean foundDiff = false;
260 int nonWayMember = 0;
261 // maintain order of members in updated relation
262 for (RelationMember oldMem :old.getMembers()) {
263 if (oldMem.isNode() || oldMem.isRelation()) {
264 nonWayMember++;
265 continue;
266 }
267 for (RelationMember newMem : calculated.getMembers()) {
268 if (newMem.getMember().equals(oldMem.getMember())) {
269 if (!newMem.getRole().equals(oldMem.getRole())) {
270 foundDiff = true;
271 }
272 foundDiff |= !merged.add(newMem); // detect duplicate members in old relation
273 break;
274 }
275 }
276 }
277 if (nonWayMember > 0) {
278 foundDiff = true;
279 String msg = trn("Non-Way member removed from multipolygon", "Non-Way members removed from multipolygon", nonWayMember);
280 GuiHelper.runInEDT(() -> new Notification(msg).setIcon(JOptionPane.WARNING_MESSAGE).show());
281 }
282 foundDiff |= merged.addAll(calculated.getMembers());
283 if (!foundDiff) {
284 return Pair.create(old, old); // unchanged
285 }
286 Relation toModify = new Relation(old);
287 toModify.setMembers(new ArrayList<>(merged));
288 return Pair.create(old, toModify);
289 }
290
291 /**
292 * Returns a {@link Pair} null and the newly created/modified multipolygon {@link Relation}.
293 * @param selectedWays selected ways
294 * @param showNotif if {@code true}, shows a notification if an error occurs
295 * @return pair of null and new multipolygon relation
296 */
297 public static Pair<Relation, Relation> createMultipolygonRelation(Collection<Way> selectedWays, boolean showNotif) {
298 MultipolygonTest mpTest = new MultipolygonTest();
299 Relation calculated = mpTest.makeFromWays(selectedWays);
300 calculated.setMembers(RelationSorter.sortMembersByConnectivity(calculated.getMembers()));
301 if (mpTest.getErrors().isEmpty())
302 return Pair.create(null, calculated);
303 if (showNotif) {
304 showErrors(mpTest.getErrors());
305 }
306 return null; //could not make multipolygon.
307 }
308
309 private static void showErrors(List<TestError> errors) {
310 if (!errors.isEmpty()) {
311 String errorMessages = errors.stream()
312 .map(TestError::getMessage)
313 .distinct()
314 .collect(Collectors.joining("\n"));
315 GuiHelper.runInEDT(() -> new Notification(errorMessages).setIcon(JOptionPane.INFORMATION_MESSAGE).show());
316 }
317 }
318
319 /**
320 * Returns a {@link Pair} of a multipolygon creating/modifying {@link Command} as well as the multipolygon {@link Relation}.
321 * @param selectedWays selected ways
322 * @param selectedMultipolygonRelation selected multipolygon relation
323 * @return pair of command and multipolygon relation
324 */
325 public static Pair<SequenceCommand, Relation> createMultipolygonCommand(Collection<Way> selectedWays,
326 Relation selectedMultipolygonRelation) {
327
328 final Pair<Relation, Relation> rr = selectedMultipolygonRelation == null
329 ? createMultipolygonRelation(selectedWays, true)
330 : updateMultipolygonRelation(selectedWays, selectedMultipolygonRelation);
331 if (rr == null) {
332 return null;
333 }
334 boolean unchanged = rr.a == rr.b;
335 final Relation existingRelation = rr.a;
336 final Relation relation = rr.b;
337
338 final List<Command> list = removeTagsFromWaysIfNeeded(relation);
339 final String commandName;
340 if (existingRelation == null) {
341 list.add(new AddCommand(selectedWays.iterator().next().getDataSet(), relation));
342 commandName = getName(false);
343 } else {
344 if (!unchanged) {
345 list.add(new ChangeCommand(existingRelation, relation));
346 }
347 if (list.isEmpty()) {
348 if (unchanged) {
349 MultipolygonTest mpTest = new MultipolygonTest();
350 mpTest.visit(existingRelation);
351 if (!mpTest.getErrors().isEmpty()) {
352 showErrors(mpTest.getErrors());
353 return null;
354 }
355 }
356
357 GuiHelper.runInEDT(() -> new Notification(tr("Nothing changed")).setDuration(Notification.TIME_SHORT)
358 .setIcon(JOptionPane.INFORMATION_MESSAGE).show());
359 return null;
360 }
361 commandName = getName(true);
362 }
363 return Pair.create(new SequenceCommand(commandName, list), relation);
364 }
365
366 /** Enable this action only if something is selected */
367 @Override
368 protected void updateEnabledState() {
369 updateEnabledStateOnCurrentSelection();
370 }
371
372 /**
373 * Enable this action only if something is selected
374 *
375 * @param selection the current selection, gets tested for emptiness
376 */
377 @Override
378 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
379 DataSet ds = getLayerManager().getEditDataSet();
380 if (ds == null || selection.isEmpty()) {
381 setEnabled(false);
382 } else if (update) {
383 setEnabled(getSelectedMultipolygonRelation(ds.getSelectedWays(), ds.getSelectedRelations()) != null);
384 } else {
385 setEnabled(!ds.getSelectedWays().isEmpty());
386 }
387 }
388
389 private static final List<String> DEFAULT_LINEAR_TAGS = Arrays.asList("barrier", "fence_type", "source");
390
391 /**
392 * This method removes tags/value pairs from inner and outer ways and put them on relation if necessary
393 * Function was extended in reltoolbox plugin by Zverikk and copied back to the core
394 * @param relation the multipolygon style relation to process
395 * @return a list of commands to execute
396 */
397 public static List<Command> removeTagsFromWaysIfNeeded(Relation relation) {
398 Map<String, String> values = new HashMap<>(relation.getKeys());
399
400 List<Way> innerWays = new ArrayList<>();
401 List<Way> outerWays = new ArrayList<>();
402
403 Set<String> conflictingKeys = new TreeSet<>();
404
405 for (RelationMember m : relation.getMembers()) {
406
407 if (m.hasRole() && "inner".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) {
408 innerWays.add(m.getWay());
409 }
410
411 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) {
412 Way way = m.getWay();
413 outerWays.add(way);
414
415 for (String key : way.keySet()) {
416 if (!values.containsKey(key)) { //relation values take precedence
417 values.put(key, way.get(key));
418 } else if (!relation.hasKey(key) && !values.get(key).equals(way.get(key))) {
419 conflictingKeys.add(key);
420 }
421 }
422 }
423 }
424
425 // filter out empty key conflicts - we need second iteration
426 if (!Config.getPref().getBoolean("multipoly.alltags", false)) {
427 for (RelationMember m : relation.getMembers()) {
428 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay()) {
429 for (String key : values.keySet()) {
430 if (!m.getWay().hasKey(key) && !relation.hasKey(key)) {
431 conflictingKeys.add(key);
432 }
433 }
434 }
435 }
436 }
437
438 for (String key : conflictingKeys) {
439 values.remove(key);
440 }
441
442 for (String linearTag : Config.getPref().getList("multipoly.lineartagstokeep", DEFAULT_LINEAR_TAGS)) {
443 values.remove(linearTag);
444 }
445
446 if ("coastline".equals(values.get("natural")))
447 values.remove("natural");
448
449 values.put("area", OsmUtils.TRUE_VALUE);
450
451 List<Command> commands = new ArrayList<>();
452 boolean moveTags = Config.getPref().getBoolean("multipoly.movetags", true);
453
454 for (Entry<String, String> entry : values.entrySet()) {
455 String key = entry.getKey();
456 String value = entry.getValue();
457 List<OsmPrimitive> affectedWays = innerWays.stream().filter(way -> value.equals(way.get(key))).collect(Collectors.toList());
458
459 if (moveTags) {
460 // remove duplicated tags from outer ways
461 for (Way way : outerWays) {
462 if (way.hasKey(key)) {
463 affectedWays.add(way);
464 }
465 }
466 }
467
468 if (!affectedWays.isEmpty()) {
469 // reset key tag on affected ways
470 commands.add(new ChangePropertyCommand(affectedWays, key, null));
471 }
472 }
473
474 if (moveTags) {
475 // add those tag values to the relation
476 boolean fixed = false;
477 Relation r2 = new Relation(relation);
478 for (Entry<String, String> entry : values.entrySet()) {
479 String key = entry.getKey();
480 if (!r2.hasKey(key) && !"area".equals(key)) {
481 if (relation.isNew())
482 relation.put(key, entry.getValue());
483 else
484 r2.put(key, entry.getValue());
485 fixed = true;
486 }
487 }
488 if (fixed && !relation.isNew()) {
489 DataSet ds = relation.getDataSet();
490 if (ds == null) {
491 ds = MainApplication.getLayerManager().getEditDataSet();
492 }
493 commands.add(new ChangeCommand(ds, relation, r2));
494 } else {
495 r2.setMembers(null); // see #19885
496 }
497 }
498
499 return commands;
500 }
501}
Note: See TracBrowser for help on using the repository browser.