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}