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