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

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

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

  • unlink members from new relation when multipolygon is not valid
  • Property svn:eol-style set to native
File size: 22.1 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 calculated.setMembers(null); // see #19885
307 return null; //could not make multipolygon.
308 }
309
310 private static void showErrors(List<TestError> errors) {
311 if (!errors.isEmpty()) {
312 String errorMessages = errors.stream()
313 .map(TestError::getMessage)
314 .distinct()
315 .collect(Collectors.joining("\n"));
316 GuiHelper.runInEDT(() -> new Notification(errorMessages).setIcon(JOptionPane.INFORMATION_MESSAGE).show());
317 }
318 }
319
320 /**
321 * Returns a {@link Pair} of a multipolygon creating/modifying {@link Command} as well as the multipolygon {@link Relation}.
322 * @param selectedWays selected ways
323 * @param selectedMultipolygonRelation selected multipolygon relation
324 * @return pair of command and multipolygon relation
325 */
326 public static Pair<SequenceCommand, Relation> createMultipolygonCommand(Collection<Way> selectedWays,
327 Relation selectedMultipolygonRelation) {
328
329 final Pair<Relation, Relation> rr = selectedMultipolygonRelation == null
330 ? createMultipolygonRelation(selectedWays, true)
331 : updateMultipolygonRelation(selectedWays, selectedMultipolygonRelation);
332 if (rr == null) {
333 return null;
334 }
335 boolean unchanged = rr.a == rr.b;
336 final Relation existingRelation = rr.a;
337 final Relation relation = rr.b;
338
339 final List<Command> list = removeTagsFromWaysIfNeeded(relation);
340 final String commandName;
341 if (existingRelation == null) {
342 list.add(new AddCommand(selectedWays.iterator().next().getDataSet(), relation));
343 commandName = getName(false);
344 } else {
345 if (!unchanged) {
346 list.add(new ChangeCommand(existingRelation, relation));
347 }
348 if (list.isEmpty()) {
349 if (unchanged) {
350 MultipolygonTest mpTest = new MultipolygonTest();
351 mpTest.visit(existingRelation);
352 if (!mpTest.getErrors().isEmpty()) {
353 showErrors(mpTest.getErrors());
354 return null;
355 }
356 }
357
358 GuiHelper.runInEDT(() -> new Notification(tr("Nothing changed")).setDuration(Notification.TIME_SHORT)
359 .setIcon(JOptionPane.INFORMATION_MESSAGE).show());
360 return null;
361 }
362 commandName = getName(true);
363 }
364 return Pair.create(new SequenceCommand(commandName, list), relation);
365 }
366
367 /** Enable this action only if something is selected */
368 @Override
369 protected void updateEnabledState() {
370 updateEnabledStateOnCurrentSelection();
371 }
372
373 /**
374 * Enable this action only if something is selected
375 *
376 * @param selection the current selection, gets tested for emptiness
377 */
378 @Override
379 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
380 DataSet ds = getLayerManager().getEditDataSet();
381 if (ds == null || selection.isEmpty()) {
382 setEnabled(false);
383 } else if (update) {
384 setEnabled(getSelectedMultipolygonRelation(ds.getSelectedWays(), ds.getSelectedRelations()) != null);
385 } else {
386 setEnabled(!ds.getSelectedWays().isEmpty());
387 }
388 }
389
390 private static final List<String> DEFAULT_LINEAR_TAGS = Arrays.asList("barrier", "fence_type", "source");
391
392 /**
393 * This method removes tags/value pairs from inner and outer ways and put them on relation if necessary
394 * Function was extended in reltoolbox plugin by Zverikk and copied back to the core
395 * @param relation the multipolygon style relation to process
396 * @return a list of commands to execute
397 */
398 public static List<Command> removeTagsFromWaysIfNeeded(Relation relation) {
399 Map<String, String> values = new HashMap<>(relation.getKeys());
400
401 List<Way> innerWays = new ArrayList<>();
402 List<Way> outerWays = new ArrayList<>();
403
404 Set<String> conflictingKeys = new TreeSet<>();
405
406 for (RelationMember m : relation.getMembers()) {
407
408 if (m.hasRole() && "inner".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) {
409 innerWays.add(m.getWay());
410 }
411
412 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) {
413 Way way = m.getWay();
414 outerWays.add(way);
415
416 for (String key : way.keySet()) {
417 if (!values.containsKey(key)) { //relation values take precedence
418 values.put(key, way.get(key));
419 } else if (!relation.hasKey(key) && !values.get(key).equals(way.get(key))) {
420 conflictingKeys.add(key);
421 }
422 }
423 }
424 }
425
426 // filter out empty key conflicts - we need second iteration
427 if (!Config.getPref().getBoolean("multipoly.alltags", false)) {
428 for (RelationMember m : relation.getMembers()) {
429 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay()) {
430 for (String key : values.keySet()) {
431 if (!m.getWay().hasKey(key) && !relation.hasKey(key)) {
432 conflictingKeys.add(key);
433 }
434 }
435 }
436 }
437 }
438
439 for (String key : conflictingKeys) {
440 values.remove(key);
441 }
442
443 for (String linearTag : Config.getPref().getList("multipoly.lineartagstokeep", DEFAULT_LINEAR_TAGS)) {
444 values.remove(linearTag);
445 }
446
447 if ("coastline".equals(values.get("natural")))
448 values.remove("natural");
449
450 values.put("area", OsmUtils.TRUE_VALUE);
451
452 List<Command> commands = new ArrayList<>();
453 boolean moveTags = Config.getPref().getBoolean("multipoly.movetags", true);
454
455 for (Entry<String, String> entry : values.entrySet()) {
456 String key = entry.getKey();
457 String value = entry.getValue();
458 List<OsmPrimitive> affectedWays = innerWays.stream().filter(way -> value.equals(way.get(key))).collect(Collectors.toList());
459
460 if (moveTags) {
461 // remove duplicated tags from outer ways
462 for (Way way : outerWays) {
463 if (way.hasKey(key)) {
464 affectedWays.add(way);
465 }
466 }
467 }
468
469 if (!affectedWays.isEmpty()) {
470 // reset key tag on affected ways
471 commands.add(new ChangePropertyCommand(affectedWays, key, null));
472 }
473 }
474
475 if (moveTags) {
476 // add those tag values to the relation
477 boolean fixed = false;
478 Relation r2 = new Relation(relation);
479 for (Entry<String, String> entry : values.entrySet()) {
480 String key = entry.getKey();
481 if (!r2.hasKey(key) && !"area".equals(key)) {
482 if (relation.isNew())
483 relation.put(key, entry.getValue());
484 else
485 r2.put(key, entry.getValue());
486 fixed = true;
487 }
488 }
489 if (fixed && !relation.isNew()) {
490 DataSet ds = relation.getDataSet();
491 if (ds == null) {
492 ds = MainApplication.getLayerManager().getEditDataSet();
493 }
494 commands.add(new ChangeCommand(ds, relation, r2));
495 } else {
496 r2.setMembers(null); // see #19885
497 }
498 }
499
500 return commands;
501 }
502}
Note: See TracBrowser for help on using the repository browser.