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}