001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
007
008import java.awt.event.ActionEvent;
009import java.awt.event.KeyEvent;
010import java.util.ArrayList;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.LinkedHashSet;
014import java.util.LinkedList;
015import java.util.List;
016import java.util.Objects;
017import java.util.stream.Collectors;
018
019import javax.swing.JOptionPane;
020
021import org.openstreetmap.josm.actions.corrector.ReverseWayTagCorrector;
022import org.openstreetmap.josm.command.ChangeCommand;
023import org.openstreetmap.josm.command.Command;
024import org.openstreetmap.josm.command.DeleteCommand;
025import org.openstreetmap.josm.command.SequenceCommand;
026import org.openstreetmap.josm.data.UndoRedoHandler;
027import org.openstreetmap.josm.data.osm.DataSet;
028import org.openstreetmap.josm.data.osm.Node;
029import org.openstreetmap.josm.data.osm.NodeGraph;
030import org.openstreetmap.josm.data.osm.OsmPrimitive;
031import org.openstreetmap.josm.data.osm.OsmUtils;
032import org.openstreetmap.josm.data.osm.TagCollection;
033import org.openstreetmap.josm.data.osm.Way;
034import org.openstreetmap.josm.data.osm.visitor.paint.relations.Multipolygon;
035import org.openstreetmap.josm.data.osm.visitor.paint.relations.Multipolygon.JoinedWay;
036import org.openstreetmap.josm.data.preferences.BooleanProperty;
037import org.openstreetmap.josm.data.validation.Test;
038import org.openstreetmap.josm.data.validation.tests.OverlappingWays;
039import org.openstreetmap.josm.data.validation.tests.SelfIntersectingWay;
040import org.openstreetmap.josm.gui.ExtendedDialog;
041import org.openstreetmap.josm.gui.MainApplication;
042import org.openstreetmap.josm.gui.Notification;
043import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog;
044import org.openstreetmap.josm.gui.util.GuiHelper;
045import org.openstreetmap.josm.tools.Logging;
046import org.openstreetmap.josm.tools.Pair;
047import org.openstreetmap.josm.tools.Shortcut;
048import org.openstreetmap.josm.tools.UserCancelException;
049
050/**
051 * Combines multiple ways into one.
052 * @since 213
053 */
054public class CombineWayAction extends JosmAction {
055
056    private static final BooleanProperty PROP_REVERSE_WAY = new BooleanProperty("tag-correction.reverse-way", true);
057
058    /**
059     * Constructs a new {@code CombineWayAction}.
060     */
061    public CombineWayAction() {
062        super(tr("Combine Way"), "combineway", tr("Combine several ways into one."),
063                Shortcut.registerShortcut("tools:combineway", tr("Tool: {0}", tr("Combine Way")), KeyEvent.VK_C, Shortcut.DIRECT), true);
064        setHelpId(ht("/Action/CombineWay"));
065    }
066
067    protected static boolean confirmChangeDirectionOfWays() {
068        return new ExtendedDialog(MainApplication.getMainFrame(),
069                tr("Change directions?"),
070                tr("Reverse and Combine"), tr("Cancel"))
071            .setButtonIcons("wayflip", "cancel")
072            .setContent(tr("The ways can not be combined in their current directions.  "
073                + "Do you want to reverse some of them?"))
074            .toggleEnable("combineway-reverse")
075            .showDialog()
076            .getValue() == 1;
077    }
078
079    protected static void warnCombiningImpossible() {
080        String msg = tr("Could not combine ways<br>"
081                + "(They could not be merged into a single string of nodes)");
082        new Notification(msg)
083                .setIcon(JOptionPane.INFORMATION_MESSAGE)
084                .show();
085    }
086
087    protected static Way getTargetWay(Collection<Way> combinedWays) {
088        // init with an arbitrary way
089        Way targetWay = combinedWays.iterator().next();
090
091        // look for the first way already existing on
092        // the server
093        for (Way w : combinedWays) {
094            targetWay = w;
095            if (!w.isNew()) {
096                break;
097            }
098        }
099        return targetWay;
100    }
101
102    /**
103     * Combine multiple ways into one.
104     * @param ways the way to combine to one way
105     * @return null if ways cannot be combined. Otherwise returns the combined ways and the commands to combine
106     * @throws UserCancelException if the user cancelled a dialog.
107     */
108    public static Pair<Way, Command> combineWaysWorker(Collection<Way> ways) throws UserCancelException {
109
110        // prepare and clean the list of ways to combine
111        //
112        if (ways == null || ways.isEmpty())
113            return null;
114        ways.remove(null); // just in case -  remove all null ways from the collection
115
116        // remove duplicates, preserving order
117        ways = new LinkedHashSet<>(ways);
118        // remove incomplete ways
119        ways.removeIf(OsmPrimitive::isIncomplete);
120        // we need at least two ways
121        if (ways.size() < 2)
122            return null;
123
124        List<DataSet> dataSets = ways.stream().map(Way::getDataSet).filter(Objects::nonNull).distinct().collect(Collectors.toList());
125        if (dataSets.size() != 1) {
126            throw new IllegalArgumentException("Cannot combine ways of multiple data sets.");
127        }
128
129        // try to build a new way which includes all the combined ways
130        List<Node> path = tryJoin(ways);
131        if (path.isEmpty()) {
132            warnCombiningImpossible();
133            return null;
134        }
135        // check whether any ways have been reversed in the process
136        // and build the collection of tags used by the ways to combine
137        //
138        TagCollection wayTags = TagCollection.unionOfAllPrimitives(ways);
139
140        final List<Command> reverseWayTagCommands = new LinkedList<>();
141        List<Way> reversedWays = new LinkedList<>();
142        List<Way> unreversedWays = new LinkedList<>();
143        detectReversedWays(ways, path, reversedWays, unreversedWays);
144        // reverse path if all ways have been reversed
145        if (unreversedWays.isEmpty()) {
146            Collections.reverse(path);
147            unreversedWays = reversedWays;
148            reversedWays = null;
149        }
150        if ((reversedWays != null) && !reversedWays.isEmpty()) {
151            if (!confirmChangeDirectionOfWays()) return null;
152            // filter out ways that have no direction-dependent tags
153            unreversedWays = ReverseWayTagCorrector.irreversibleWays(unreversedWays);
154            reversedWays = ReverseWayTagCorrector.irreversibleWays(reversedWays);
155            // reverse path if there are more reversed than unreversed ways with direction-dependent tags
156            if (reversedWays.size() > unreversedWays.size()) {
157                Collections.reverse(path);
158                List<Way> tempWays = unreversedWays;
159                unreversedWays = null;
160                reversedWays = tempWays;
161            }
162            // if there are still reversed ways with direction-dependent tags, reverse their tags
163            if (!reversedWays.isEmpty() && Boolean.TRUE.equals(PROP_REVERSE_WAY.get())) {
164                List<Way> unreversedTagWays = new ArrayList<>(ways);
165                unreversedTagWays.removeAll(reversedWays);
166                ReverseWayTagCorrector reverseWayTagCorrector = new ReverseWayTagCorrector();
167                List<Way> reversedTagWays = new ArrayList<>(reversedWays.size());
168                for (Way w : reversedWays) {
169                    Way wnew = new Way(w);
170                    reversedTagWays.add(wnew);
171                    reverseWayTagCommands.addAll(reverseWayTagCorrector.execute(w, wnew));
172                }
173                if (!reverseWayTagCommands.isEmpty()) {
174                    // commands need to be executed for CombinePrimitiveResolverDialog
175                    UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Reverse Ways"), reverseWayTagCommands));
176                }
177                wayTags = TagCollection.unionOfAllPrimitives(reversedTagWays);
178                wayTags.add(TagCollection.unionOfAllPrimitives(unreversedTagWays));
179            }
180        }
181
182        // create the new way and apply the new node list
183        //
184        Way targetWay = getTargetWay(ways);
185        Way modifiedTargetWay = new Way(targetWay);
186        modifiedTargetWay.setNodes(path);
187
188        final List<Command> resolution;
189        try {
190            resolution = CombinePrimitiveResolverDialog.launchIfNecessary(wayTags, ways, Collections.singleton(targetWay));
191        } finally {
192            if (!reverseWayTagCommands.isEmpty()) {
193                // undo reverseWayTagCorrector and merge into SequenceCommand below
194                UndoRedoHandler.getInstance().undo();
195            }
196        }
197
198        List<Command> cmds = new LinkedList<>();
199        List<Way> deletedWays = new LinkedList<>(ways);
200        deletedWays.remove(targetWay);
201
202        cmds.add(new ChangeCommand(dataSets.get(0), targetWay, modifiedTargetWay));
203        cmds.addAll(reverseWayTagCommands);
204        cmds.addAll(resolution);
205        cmds.add(new DeleteCommand(dataSets.get(0), deletedWays));
206        final Command sequenceCommand = new SequenceCommand(/* for correct i18n of plural forms - see #9110 */
207                trn("Combine {0} way", "Combine {0} ways", ways.size(), ways.size()), cmds);
208
209        return new Pair<>(targetWay, sequenceCommand);
210    }
211
212    protected static void detectReversedWays(Collection<Way> ways, List<Node> path, List<Way> reversedWays,
213            List<Way> unreversedWays) {
214        for (Way w: ways) {
215            // Treat zero or one-node ways as unreversed as Combine action action is a good way to fix them (see #8971)
216            if (w.getNodesCount() < 2) {
217                unreversedWays.add(w);
218            } else {
219                boolean foundStartSegment = false;
220                int last = path.lastIndexOf(w.getNode(0));
221
222                for (int i = path.indexOf(w.getNode(0)); i <= last; i++) {
223                    if (path.get(i) == w.getNode(0) && i + 1 < path.size() && w.getNode(1) == path.get(i + 1)) {
224                        foundStartSegment = true;
225                        break;
226                    }
227                }
228                if (foundStartSegment) {
229                    unreversedWays.add(w);
230                } else {
231                    reversedWays.add(w);
232                }
233            }
234        }
235    }
236
237    protected static List<Node> tryJoin(Collection<Way> ways) {
238        List<Node> path = joinWithMultipolygonCode(ways);
239        if (path.isEmpty()) {
240            NodeGraph graph = NodeGraph.createNearlyUndirectedGraphFromNodeWays(ways);
241            path = graph.buildSpanningPathNoRemove();
242        }
243        return path;
244    }
245
246    /**
247     * Use {@link Multipolygon#joinWays(Collection)} to join ways.
248     * @param ways the ways
249     * @return List of nodes of the combined ways or null if ways could not be combined to a single way.
250     * Result may contain overlapping segments.
251     */
252    private static List<Node> joinWithMultipolygonCode(Collection<Way> ways) {
253        // sort so that old unclosed ways appear first
254        LinkedList<Way> toJoin = new LinkedList<>(ways);
255        toJoin.sort((o1, o2) -> {
256            int d = Boolean.compare(o1.isNew(), o2.isNew());
257            if (d == 0)
258                d = Boolean.compare(o1.isClosed(), o2.isClosed());
259            return d;
260        });
261        Collection<JoinedWay> list = Multipolygon.joinWays(toJoin);
262        if (list.size() == 1) {
263            // ways form a single line string
264            return new ArrayList<>(list.iterator().next().getNodes());
265        }
266        return Collections.emptyList();
267    }
268
269    @Override
270    public void actionPerformed(ActionEvent event) {
271        final DataSet ds = getLayerManager().getEditDataSet();
272        if (ds == null)
273            return;
274        Collection<Way> selectedWays = ds.getSelectedWays();
275        if (selectedWays.size() < 2) {
276            new Notification(
277                    tr("Please select at least two ways to combine."))
278                    .setIcon(JOptionPane.INFORMATION_MESSAGE)
279                    .setDuration(Notification.TIME_SHORT)
280                    .show();
281            return;
282        }
283        // combine and update gui
284        Pair<Way, Command> combineResult;
285        try {
286            combineResult = combineWaysWorker(selectedWays);
287        } catch (UserCancelException ex) {
288            Logging.trace(ex);
289            return;
290        }
291
292        if (combineResult == null)
293            return;
294
295        final Way selectedWay = combineResult.a;
296        UndoRedoHandler.getInstance().add(combineResult.b);
297        Test test = new OverlappingWays();
298        test.startTest(null);
299        test.visit(combineResult.a);
300        test.endTest();
301        if (test.getErrors().isEmpty()) {
302            test = new SelfIntersectingWay();
303            test.startTest(null);
304            test.visit(combineResult.a);
305            test.endTest();
306        }
307        if (!test.getErrors().isEmpty()) {
308            new Notification(test.getErrors().get(0).getMessage())
309            .setIcon(JOptionPane.WARNING_MESSAGE)
310            .setDuration(Notification.TIME_SHORT)
311            .show();
312        }
313        if (selectedWay != null) {
314            GuiHelper.runInEDT(() -> ds.setSelected(selectedWay));
315        }
316    }
317
318    @Override
319    protected void updateEnabledState() {
320        updateEnabledStateOnCurrentSelection();
321    }
322
323    @Override
324    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
325        int numWays = 0;
326        if (OsmUtils.isOsmCollectionEditable(selection)) {
327            for (OsmPrimitive osm : selection) {
328                if (osm instanceof Way && !osm.isIncomplete() && ++numWays >= 2) {
329                    break;
330                }
331            }
332        }
333        setEnabled(numWays >= 2);
334    }
335
336}