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