001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.actions; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.event.ActionEvent; 008import java.awt.event.KeyEvent; 009import java.util.ArrayList; 010import java.util.Arrays; 011import java.util.Collection; 012import java.util.Collections; 013import java.util.HashMap; 014import java.util.HashSet; 015import java.util.LinkedHashSet; 016import java.util.List; 017import java.util.Map; 018import java.util.Map.Entry; 019import java.util.Set; 020import java.util.TreeSet; 021import java.util.stream.Collectors; 022 023import javax.swing.JOptionPane; 024import javax.swing.SwingUtilities; 025 026import org.openstreetmap.josm.actions.relation.DownloadSelectedIncompleteMembersAction; 027import org.openstreetmap.josm.command.AddCommand; 028import org.openstreetmap.josm.command.ChangeCommand; 029import org.openstreetmap.josm.command.ChangeMembersCommand; 030import org.openstreetmap.josm.command.ChangePropertyCommand; 031import org.openstreetmap.josm.command.Command; 032import org.openstreetmap.josm.command.SequenceCommand; 033import org.openstreetmap.josm.data.UndoRedoHandler; 034import org.openstreetmap.josm.data.osm.DataSet; 035import org.openstreetmap.josm.data.osm.IPrimitive; 036import org.openstreetmap.josm.data.osm.OsmPrimitive; 037import org.openstreetmap.josm.data.osm.OsmUtils; 038import org.openstreetmap.josm.data.osm.Relation; 039import org.openstreetmap.josm.data.osm.RelationMember; 040import org.openstreetmap.josm.data.osm.Way; 041import org.openstreetmap.josm.data.validation.TestError; 042import org.openstreetmap.josm.data.validation.tests.MultipolygonTest; 043import org.openstreetmap.josm.gui.MainApplication; 044import org.openstreetmap.josm.gui.Notification; 045import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationMemberTask; 046import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationTask; 047import org.openstreetmap.josm.gui.dialogs.relation.RelationEditor; 048import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter; 049import org.openstreetmap.josm.gui.layer.OsmDataLayer; 050import org.openstreetmap.josm.gui.util.GuiHelper; 051import org.openstreetmap.josm.spi.preferences.Config; 052import org.openstreetmap.josm.tools.Pair; 053import org.openstreetmap.josm.tools.Shortcut; 054import org.openstreetmap.josm.tools.SubclassFilteredCollection; 055import org.openstreetmap.josm.tools.Utils; 056 057/** 058 * Create multipolygon from selected ways automatically. 059 * 060 * New relation with type=multipolygon is created. 061 * 062 * If one or more of ways is already in relation with type=multipolygon or the 063 * way is not closed, then error is reported and no relation is created. 064 * 065 * The "inner" and "outer" roles are guessed automatically. First, bbox is 066 * calculated for each way. then the largest area is assumed to be outside and 067 * the rest inside. In cases with one "outside" area and several cut-ins, the 068 * guess should be always good ... In more complex (multiple outer areas) or 069 * buggy (inner and outer ways intersect) scenarios the result is likely to be 070 * wrong. 071 */ 072public class CreateMultipolygonAction extends JosmAction { 073 074 private final boolean update; 075 private static final int MAX_MEMBERS_TO_DOWNLOAD = 100; 076 077 /** 078 * Constructs a new {@code CreateMultipolygonAction}. 079 * @param update {@code true} if the multipolygon must be updated, {@code false} if it must be created 080 */ 081 public CreateMultipolygonAction(final boolean update) { 082 super(getName(update), 083 update ? /* ICON */ "multipoly_update" : /* ICON */ "multipoly_create", 084 getName(update), 085 /* at least three lines for each shortcut or the server extractor fails */ 086 update ? Shortcut.registerShortcut("tools:multipoly_update", 087 tr("Tools: {0}", getName(true)), 088 KeyEvent.VK_B, Shortcut.CTRL_SHIFT) 089 : Shortcut.registerShortcut("tools:multipoly_create", 090 tr("Tools: {0}", getName(false)), 091 KeyEvent.VK_B, Shortcut.CTRL), 092 true, update ? "multipoly_update" : "multipoly_create", true); 093 this.update = update; 094 } 095 096 private static String getName(boolean update) { 097 return update ? tr("Update multipolygon") : tr("Create multipolygon"); 098 } 099 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 || Utils.isEmpty(selection)) { 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 way.visitKeys((p, key, value) -> { 426 if (!values.containsKey(key)) { //relation values take precedence 427 values.put(key, value); 428 } else if (!values.get(key).equals(value)) { 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}