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