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

Last change on this file since 17534 was 17429, checked in by GerdP, 3 years ago

fix #20325: Update Multipolygon removes tags instead of moving them to relation

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