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