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}