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.Iterator; 016import java.util.LinkedHashSet; 017import java.util.List; 018import java.util.Map; 019import java.util.Map.Entry; 020import java.util.Set; 021import java.util.TreeSet; 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.ChangePropertyCommand; 030import org.openstreetmap.josm.command.Command; 031import org.openstreetmap.josm.command.SequenceCommand; 032import org.openstreetmap.josm.data.UndoRedoHandler; 033import org.openstreetmap.josm.data.osm.DataSet; 034import org.openstreetmap.josm.data.osm.IPrimitive; 035import org.openstreetmap.josm.data.osm.OsmPrimitive; 036import org.openstreetmap.josm.data.osm.OsmUtils; 037import org.openstreetmap.josm.data.osm.Relation; 038import org.openstreetmap.josm.data.osm.RelationMember; 039import org.openstreetmap.josm.data.osm.Way; 040import org.openstreetmap.josm.data.validation.TestError; 041import org.openstreetmap.josm.data.validation.tests.MultipolygonTest; 042import org.openstreetmap.josm.gui.MainApplication; 043import org.openstreetmap.josm.gui.Notification; 044import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationMemberTask; 045import org.openstreetmap.josm.gui.dialogs.relation.DownloadRelationTask; 046import org.openstreetmap.josm.gui.dialogs.relation.RelationEditor; 047import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter; 048import org.openstreetmap.josm.gui.layer.OsmDataLayer; 049import org.openstreetmap.josm.gui.util.GuiHelper; 050import org.openstreetmap.josm.spi.preferences.Config; 051import org.openstreetmap.josm.tools.Pair; 052import org.openstreetmap.josm.tools.Shortcut; 053import org.openstreetmap.josm.tools.SubclassFilteredCollection; 054import org.openstreetmap.josm.tools.Utils; 055 056/** 057 * Create multipolygon from selected ways automatically. 058 * 059 * New relation with type=multipolygon is created. 060 * 061 * If one or more of ways is already in relation with type=multipolygon or the 062 * way is not closed, then error is reported and no relation is created. 063 * 064 * The "inner" and "outer" roles are guessed automatically. First, bbox is 065 * calculated for each way. then the largest area is assumed to be outside and 066 * the rest inside. In cases with one "outside" area and several cut-ins, the 067 * guess should be always good ... In more complex (multiple outer areas) or 068 * buggy (inner and outer ways intersect) scenarios the result is likely to be 069 * wrong. 070 */ 071public class CreateMultipolygonAction extends JosmAction { 072 073 private final boolean update; 074 private static final int MAX_MEMBERS_TO_DOWNLOAD = 100; 075 076 /** 077 * Constructs a new {@code CreateMultipolygonAction}. 078 * @param update {@code true} if the multipolygon must be updated, {@code false} if it must be created 079 */ 080 public CreateMultipolygonAction(final boolean update) { 081 super(getName(update), /* ICON */ "multipoly_create", getName(update), 082 /* at least three lines for each shortcut or the server extractor fails */ 083 update ? Shortcut.registerShortcut("tools:multipoly_update", 084 tr("Tool: {0}", getName(true)), 085 KeyEvent.VK_B, Shortcut.CTRL_SHIFT) 086 : Shortcut.registerShortcut("tools:multipoly_create", 087 tr("Tool: {0}", getName(false)), 088 KeyEvent.VK_B, Shortcut.CTRL), 089 true, update ? "multipoly_update" : "multipoly_create", true); 090 this.update = update; 091 } 092 093 private static String getName(boolean update) { 094 return update ? tr("Update multipolygon") : tr("Create multipolygon"); 095 } 096 097 private static final class CreateUpdateMultipolygonTask implements Runnable { 098 private final Collection<Way> selectedWays; 099 private final Relation multipolygonRelation; 100 101 private CreateUpdateMultipolygonTask(Collection<Way> selectedWays, Relation multipolygonRelation) { 102 this.selectedWays = selectedWays; 103 this.multipolygonRelation = multipolygonRelation; 104 } 105 106 @Override 107 public void run() { 108 final Pair<SequenceCommand, Relation> commandAndRelation = createMultipolygonCommand(selectedWays, multipolygonRelation); 109 if (commandAndRelation == null) { 110 return; 111 } 112 final Command command = commandAndRelation.a; 113 final Relation relation = commandAndRelation.b; 114 115 // to avoid EDT violations 116 SwingUtilities.invokeLater(() -> { 117 UndoRedoHandler.getInstance().add(command); 118 119 // Use 'SwingUtilities.invokeLater' to make sure the relationListDialog 120 // knows about the new relation before we try to select it. 121 // (Yes, we are already in event dispatch thread. But DatasetEventManager 122 // uses 'SwingUtilities.invokeLater' to fire events so we have to do the same.) 123 SwingUtilities.invokeLater(() -> { 124 MainApplication.getMap().relationListDialog.selectRelation(relation); 125 if (Config.getPref().getBoolean("multipoly.show-relation-editor", false)) { 126 //Open relation edit window, if set up in preferences 127 RelationEditor editor = RelationEditor.getEditor( 128 MainApplication.getLayerManager().getEditLayer(), relation, null); 129 editor.setModal(true); 130 editor.setVisible(true); 131 } else { 132 MainApplication.getLayerManager().getEditLayer().setRecentRelation(relation); 133 } 134 }); 135 }); 136 } 137 } 138 139 @Override 140 public void actionPerformed(ActionEvent e) { 141 DataSet dataSet = getLayerManager().getEditDataSet(); 142 if (dataSet == null) { 143 new Notification( 144 tr("No data loaded.")) 145 .setIcon(JOptionPane.WARNING_MESSAGE) 146 .setDuration(Notification.TIME_SHORT) 147 .show(); 148 return; 149 } 150 151 final Collection<Way> selectedWays = dataSet.getSelectedWays(); 152 153 if (selectedWays.isEmpty()) { 154 // Sometimes it make sense creating multipoly of only one way (so it will form outer way) 155 // and then splitting the way later (so there are multiple ways forming outer way) 156 new Notification( 157 tr("You must select at least one way.")) 158 .setIcon(JOptionPane.INFORMATION_MESSAGE) 159 .setDuration(Notification.TIME_SHORT) 160 .show(); 161 return; 162 } 163 164 final Collection<Relation> selectedRelations = dataSet.getSelectedRelations(); 165 final Relation multipolygonRelation = update 166 ? getSelectedMultipolygonRelation(selectedWays, selectedRelations) 167 : null; 168 169 if (update && multipolygonRelation == null) 170 return; 171 // download incomplete relation or incomplete members if necessary 172 OsmDataLayer editLayer = getLayerManager().getEditLayer(); 173 if (multipolygonRelation != null && editLayer != null && editLayer.isDownloadable()) { 174 if (!multipolygonRelation.isNew() && multipolygonRelation.isIncomplete()) { 175 MainApplication.worker 176 .submit(new DownloadRelationTask(Collections.singleton(multipolygonRelation), editLayer)); 177 } else if (multipolygonRelation.hasIncompleteMembers()) { 178 // with complex relations the download of the full relation is much faster than download of almost all members, see #18341 179 SubclassFilteredCollection<IPrimitive, OsmPrimitive> incompleteMembers = Utils 180 .filteredCollection(DownloadSelectedIncompleteMembersAction.buildSetOfIncompleteMembers( 181 Collections.singleton(multipolygonRelation)), OsmPrimitive.class); 182 183 if (incompleteMembers.size() <= MAX_MEMBERS_TO_DOWNLOAD) { 184 MainApplication.worker 185 .submit(new DownloadRelationMemberTask(multipolygonRelation, incompleteMembers, editLayer)); 186 } else { 187 MainApplication.worker 188 .submit(new DownloadRelationTask(Collections.singleton(multipolygonRelation), editLayer)); 189 190 } 191 } 192 } 193 // create/update multipolygon relation 194 MainApplication.worker.submit(new CreateUpdateMultipolygonTask(selectedWays, multipolygonRelation)); 195 } 196 197 private static Relation getSelectedMultipolygonRelation(Collection<Way> selectedWays, Collection<Relation> selectedRelations) { 198 Relation candidate = null; 199 if (selectedRelations.size() == 1) { 200 candidate = selectedRelations.iterator().next(); 201 if (!candidate.hasTag("type", "multipolygon")) 202 candidate = null; 203 } else if (!selectedWays.isEmpty()) { 204 for (final Way w : selectedWays) { 205 for (OsmPrimitive r : w.getReferrers()) { 206 if (r != candidate && !r.isDisabled() && r instanceof Relation && r.hasTag("type", "multipolygon")) { 207 if (candidate != null) 208 return null; // found another multipolygon relation 209 candidate = (Relation) r; 210 } 211 } 212 } 213 } 214 return candidate; 215 } 216 217 /** 218 * Returns a {@link Pair} of the old multipolygon {@link Relation} (or null) and the newly created/modified multipolygon {@link Relation}. 219 * @param selectedWays selected ways 220 * @param selectedMultipolygonRelation selected multipolygon relation 221 * @return pair of old and new multipolygon relation if a difference was found, else the pair contains the old relation twice 222 */ 223 public static Pair<Relation, Relation> updateMultipolygonRelation(Collection<Way> selectedWays, Relation selectedMultipolygonRelation) { 224 225 // add ways of existing relation to include them in polygon analysis 226 Set<Way> ways = new HashSet<>(selectedWays); 227 ways.addAll(selectedMultipolygonRelation.getMemberPrimitives(Way.class)); 228 229 // even if no way was added the inner/outer roles might be different 230 MultipolygonTest mpTest = new MultipolygonTest(); 231 Relation calculated = mpTest.makeFromWays(ways); 232 if (mpTest.getErrors().isEmpty()) { 233 return mergeRelationsMembers(selectedMultipolygonRelation, calculated); 234 } 235 showErrors(mpTest.getErrors()); 236 return null; //could not make multipolygon. 237 } 238 239 /** 240 * Merge members of multipolygon relation. Maintains the order of the old relation. May change roles, 241 * removes duplicate and non-way members and adds new members found in {@code calculated}. 242 * @param old old multipolygon relation 243 * @param calculated calculated multipolygon relation 244 * @return pair of old and new multipolygon relation if a difference was found, else the pair contains the old relation twice 245 */ 246 private static Pair<Relation, Relation> mergeRelationsMembers(Relation old, Relation calculated) { 247 Set<RelationMember> merged = new LinkedHashSet<>(); 248 boolean foundDiff = false; 249 int nonWayMember = 0; 250 // maintain order of members in updated relation 251 for (RelationMember oldMem :old.getMembers()) { 252 if (oldMem.isNode() || oldMem.isRelation()) { 253 nonWayMember++; 254 continue; 255 } 256 for (RelationMember newMem : calculated.getMembers()) { 257 if (newMem.getMember().equals(oldMem.getMember())) { 258 if (!newMem.getRole().equals(oldMem.getRole())) { 259 foundDiff = true; 260 } 261 foundDiff |= !merged.add(newMem); // detect duplicate members in old relation 262 break; 263 } 264 } 265 } 266 if (nonWayMember > 0) { 267 foundDiff = true; 268 String msg = trn("Non-Way member removed from multipolygon", "Non-Way members removed from multipolygon", nonWayMember); 269 GuiHelper.runInEDT(() -> new Notification(msg).setIcon(JOptionPane.WARNING_MESSAGE).show()); 270 } 271 foundDiff |= merged.addAll(calculated.getMembers()); 272 if (!foundDiff) { 273 return Pair.create(old, old); // unchanged 274 } 275 Relation toModify = new Relation(old); 276 toModify.setMembers(new ArrayList<>(merged)); 277 return Pair.create(old, toModify); 278 } 279 280 /** 281 * Returns a {@link Pair} null and the newly created/modified multipolygon {@link Relation}. 282 * @param selectedWays selected ways 283 * @param showNotif if {@code true}, shows a notification if an error occurs 284 * @return pair of null and new multipolygon relation 285 */ 286 public static Pair<Relation, Relation> createMultipolygonRelation(Collection<Way> selectedWays, boolean showNotif) { 287 MultipolygonTest mpTest = new MultipolygonTest(); 288 Relation calculated = mpTest.makeFromWays(selectedWays); 289 calculated.setMembers(RelationSorter.sortMembersByConnectivity(calculated.getMembers())); 290 if (mpTest.getErrors().isEmpty()) 291 return Pair.create(null, calculated); 292 if (showNotif) { 293 showErrors(mpTest.getErrors()); 294 } 295 return null; //could not make multipolygon. 296 } 297 298 private static void showErrors(List<TestError> errors) { 299 if (!errors.isEmpty()) { 300 StringBuilder sb = new StringBuilder(); 301 Set<String> errorMessages = new LinkedHashSet<>(); 302 errors.forEach(e-> errorMessages.add(e.getMessage())); 303 Iterator<String> iter = errorMessages.iterator(); 304 while (iter.hasNext()) { 305 sb.append(iter.next()); 306 if (iter.hasNext()) 307 sb.append('\n'); 308 } 309 GuiHelper.runInEDT(() -> new Notification(sb.toString()).setIcon(JOptionPane.INFORMATION_MESSAGE).show()); 310 } 311 } 312 313 /** 314 * Returns a {@link Pair} of a multipolygon creating/modifying {@link Command} as well as the multipolygon {@link Relation}. 315 * @param selectedWays selected ways 316 * @param selectedMultipolygonRelation selected multipolygon relation 317 * @return pair of command and multipolygon relation 318 */ 319 public static Pair<SequenceCommand, Relation> createMultipolygonCommand(Collection<Way> selectedWays, 320 Relation selectedMultipolygonRelation) { 321 322 final Pair<Relation, Relation> rr = selectedMultipolygonRelation == null 323 ? createMultipolygonRelation(selectedWays, true) 324 : updateMultipolygonRelation(selectedWays, selectedMultipolygonRelation); 325 if (rr == null) { 326 return null; 327 } 328 boolean unchanged = rr.a == rr.b; 329 final Relation existingRelation = rr.a; 330 final Relation relation = rr.b; 331 332 final List<Command> list = removeTagsFromWaysIfNeeded(relation); 333 final String commandName; 334 if (existingRelation == null) { 335 list.add(new AddCommand(selectedWays.iterator().next().getDataSet(), relation)); 336 commandName = getName(false); 337 } else { 338 if (!unchanged) { 339 list.add(new ChangeCommand(existingRelation, relation)); 340 } 341 if (list.isEmpty()) { 342 if (unchanged) { 343 MultipolygonTest mpTest = new MultipolygonTest(); 344 mpTest.visit(existingRelation); 345 if (!mpTest.getErrors().isEmpty()) { 346 showErrors(mpTest.getErrors()); 347 return null; 348 } 349 } 350 351 GuiHelper.runInEDT(() -> new Notification(tr("Nothing changed")).setDuration(Notification.TIME_SHORT) 352 .setIcon(JOptionPane.INFORMATION_MESSAGE).show()); 353 return null; 354 } 355 commandName = getName(true); 356 } 357 return Pair.create(new SequenceCommand(commandName, list), relation); 358 } 359 360 /** Enable this action only if something is selected */ 361 @Override 362 protected void updateEnabledState() { 363 updateEnabledStateOnCurrentSelection(); 364 } 365 366 /** 367 * Enable this action only if something is selected 368 * 369 * @param selection the current selection, gets tested for emptiness 370 */ 371 @Override 372 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 373 DataSet ds = getLayerManager().getEditDataSet(); 374 if (ds == null || selection.isEmpty()) { 375 setEnabled(false); 376 } else if (update) { 377 setEnabled(getSelectedMultipolygonRelation(ds.getSelectedWays(), ds.getSelectedRelations()) != null); 378 } else { 379 setEnabled(!ds.getSelectedWays().isEmpty()); 380 } 381 } 382 383 private static final List<String> DEFAULT_LINEAR_TAGS = Arrays.asList("barrier", "fence_type", "source"); 384 385 /** 386 * This method removes tags/value pairs from inner and outer ways and put them on relation if necessary 387 * Function was extended in reltoolbox plugin by Zverikk and copied back to the core 388 * @param relation the multipolygon style relation to process 389 * @return a list of commands to execute 390 */ 391 public static List<Command> removeTagsFromWaysIfNeeded(Relation relation) { 392 Map<String, String> values = new HashMap<>(relation.getKeys()); 393 394 List<Way> innerWays = new ArrayList<>(); 395 List<Way> outerWays = new ArrayList<>(); 396 397 Set<String> conflictingKeys = new TreeSet<>(); 398 399 for (RelationMember m : relation.getMembers()) { 400 401 if (m.hasRole() && "inner".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) { 402 innerWays.add(m.getWay()); 403 } 404 405 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay() && m.getWay().hasKeys()) { 406 Way way = m.getWay(); 407 outerWays.add(way); 408 409 for (String key : way.keySet()) { 410 if (!values.containsKey(key)) { //relation values take precedence 411 values.put(key, way.get(key)); 412 } else if (!relation.hasKey(key) && !values.get(key).equals(way.get(key))) { 413 conflictingKeys.add(key); 414 } 415 } 416 } 417 } 418 419 // filter out empty key conflicts - we need second iteration 420 if (!Config.getPref().getBoolean("multipoly.alltags", false)) { 421 for (RelationMember m : relation.getMembers()) { 422 if (m.hasRole() && "outer".equals(m.getRole()) && m.isWay()) { 423 for (String key : values.keySet()) { 424 if (!m.getWay().hasKey(key) && !relation.hasKey(key)) { 425 conflictingKeys.add(key); 426 } 427 } 428 } 429 } 430 } 431 432 for (String key : conflictingKeys) { 433 values.remove(key); 434 } 435 436 for (String linearTag : Config.getPref().getList("multipoly.lineartagstokeep", DEFAULT_LINEAR_TAGS)) { 437 values.remove(linearTag); 438 } 439 440 if ("coastline".equals(values.get("natural"))) 441 values.remove("natural"); 442 443 values.put("area", OsmUtils.TRUE_VALUE); 444 445 List<Command> commands = new ArrayList<>(); 446 boolean moveTags = Config.getPref().getBoolean("multipoly.movetags", true); 447 448 for (Entry<String, String> entry : values.entrySet()) { 449 List<OsmPrimitive> affectedWays = new ArrayList<>(); 450 String key = entry.getKey(); 451 String value = entry.getValue(); 452 453 for (Way way : innerWays) { 454 if (value.equals(way.get(key))) { 455 affectedWays.add(way); 456 } 457 } 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 } 495 } 496 497 return commands; 498 } 499}