001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.actions; 003 004import static org.openstreetmap.josm.tools.I18n.marktr; 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.awt.geom.Area; 011import java.util.ArrayList; 012import java.util.Collection; 013import java.util.Collections; 014import java.util.HashMap; 015import java.util.HashSet; 016import java.util.LinkedHashSet; 017import java.util.LinkedList; 018import java.util.List; 019import java.util.Map; 020import java.util.Set; 021import java.util.TreeMap; 022 023import javax.swing.JOptionPane; 024 025import org.openstreetmap.josm.Main; 026import org.openstreetmap.josm.actions.ReverseWayAction.ReverseWayResult; 027import org.openstreetmap.josm.actions.SplitWayAction.SplitWayResult; 028import org.openstreetmap.josm.command.AddCommand; 029import org.openstreetmap.josm.command.ChangeCommand; 030import org.openstreetmap.josm.command.Command; 031import org.openstreetmap.josm.command.DeleteCommand; 032import org.openstreetmap.josm.command.SequenceCommand; 033import org.openstreetmap.josm.corrector.UserCancelException; 034import org.openstreetmap.josm.data.UndoRedoHandler; 035import org.openstreetmap.josm.data.coor.EastNorth; 036import org.openstreetmap.josm.data.osm.DataSet; 037import org.openstreetmap.josm.data.osm.Node; 038import org.openstreetmap.josm.data.osm.NodePositionComparator; 039import org.openstreetmap.josm.data.osm.OsmPrimitive; 040import org.openstreetmap.josm.data.osm.Relation; 041import org.openstreetmap.josm.data.osm.RelationMember; 042import org.openstreetmap.josm.data.osm.TagCollection; 043import org.openstreetmap.josm.data.osm.Way; 044import org.openstreetmap.josm.gui.Notification; 045import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog; 046import org.openstreetmap.josm.tools.Geometry; 047import org.openstreetmap.josm.tools.Pair; 048import org.openstreetmap.josm.tools.Shortcut; 049 050/** 051 * Join Areas (i.e. closed ways and multipolygons) 052 */ 053public class JoinAreasAction extends JosmAction { 054 // This will be used to commit commands and unite them into one large command sequence at the end 055 private LinkedList<Command> cmds = new LinkedList<Command>(); 056 private int cmdsCount = 0; 057 058 /** 059 * This helper class describes join ares action result. 060 * @author viesturs 061 * 062 */ 063 public static class JoinAreasResult { 064 065 public boolean hasChanges; 066 067 public List<Multipolygon> polygons; 068 } 069 070 public static class Multipolygon { 071 public Way outerWay; 072 public List<Way> innerWays; 073 074 public Multipolygon(Way way) { 075 outerWay = way; 076 innerWays = new ArrayList<Way>(); 077 } 078 } 079 080 // HelperClass 081 // Saves a relation and a role an OsmPrimitve was part of until it was stripped from all relations 082 private static class RelationRole { 083 public final Relation rel; 084 public final String role; 085 public RelationRole(Relation rel, String role) { 086 this.rel = rel; 087 this.role = role; 088 } 089 090 @Override 091 public int hashCode() { 092 return rel.hashCode(); 093 } 094 095 @Override 096 public boolean equals(Object other) { 097 if (!(other instanceof RelationRole)) return false; 098 RelationRole otherMember = (RelationRole) other; 099 return otherMember.role.equals(role) && otherMember.rel.equals(rel); 100 } 101 } 102 103 104 /** 105 * HelperClass - saves a way and the "inside" side. 106 * 107 * insideToTheLeft: if true left side is "in", false -right side is "in". 108 * Left and right are determined along the orientation of way. 109 */ 110 public static class WayInPolygon { 111 public final Way way; 112 public boolean insideToTheRight; 113 114 public WayInPolygon(Way _way, boolean _insideRight) { 115 this.way = _way; 116 this.insideToTheRight = _insideRight; 117 } 118 119 @Override 120 public int hashCode() { 121 return way.hashCode(); 122 } 123 124 @Override 125 public boolean equals(Object other) { 126 if (!(other instanceof WayInPolygon)) return false; 127 WayInPolygon otherMember = (WayInPolygon) other; 128 return otherMember.way.equals(this.way) && otherMember.insideToTheRight == this.insideToTheRight; 129 } 130 } 131 132 /** 133 * This helper class describes a polygon, assembled from several ways. 134 * @author viesturs 135 * 136 */ 137 public static class AssembledPolygon { 138 public List<WayInPolygon> ways; 139 140 public AssembledPolygon(List<WayInPolygon> boundary) { 141 this.ways = boundary; 142 } 143 144 public List<Node> getNodes() { 145 List<Node> nodes = new ArrayList<Node>(); 146 for (WayInPolygon way : this.ways) { 147 //do not add the last node as it will be repeated in the next way 148 if (way.insideToTheRight) { 149 for (int pos = 0; pos < way.way.getNodesCount() - 1; pos++) { 150 nodes.add(way.way.getNode(pos)); 151 } 152 } 153 else { 154 for (int pos = way.way.getNodesCount() - 1; pos > 0; pos--) { 155 nodes.add(way.way.getNode(pos)); 156 } 157 } 158 } 159 160 return nodes; 161 } 162 } 163 164 public static class AssembledMultipolygon { 165 public AssembledPolygon outerWay; 166 public List<AssembledPolygon> innerWays; 167 168 public AssembledMultipolygon(AssembledPolygon way) { 169 outerWay = way; 170 innerWays = new ArrayList<AssembledPolygon>(); 171 } 172 } 173 174 /** 175 * This hepler class implements algorithm traversing trough connected ways. 176 * Assumes you are going in clockwise orientation. 177 * @author viesturs 178 * 179 */ 180 private static class WayTraverser { 181 182 private Set<WayInPolygon> availableWays; 183 private WayInPolygon lastWay; 184 private boolean lastWayReverse; 185 186 public WayTraverser(Collection<WayInPolygon> ways) { 187 188 availableWays = new HashSet<WayInPolygon>(ways); 189 lastWay = null; 190 } 191 192 public void removeWays(Collection<WayInPolygon> ways) { 193 availableWays.removeAll(ways); 194 } 195 196 public boolean hasWays() { 197 return !availableWays.isEmpty(); 198 } 199 200 public WayInPolygon startNewWay(WayInPolygon way) { 201 lastWay = way; 202 lastWayReverse = !lastWay.insideToTheRight; 203 204 return lastWay; 205 } 206 207 public WayInPolygon startNewWay() { 208 if (availableWays.isEmpty()) { 209 lastWay = null; 210 } else { 211 lastWay = availableWays.iterator().next(); 212 lastWayReverse = !lastWay.insideToTheRight; 213 } 214 215 return lastWay; 216 } 217 218 219 public WayInPolygon advanceNextLeftmostWay() { 220 return advanceNextWay(false); 221 } 222 223 public WayInPolygon advanceNextRightmostWay() { 224 return advanceNextWay(true); 225 } 226 227 private WayInPolygon advanceNextWay(boolean rightmost) { 228 229 Node headNode = !lastWayReverse ? lastWay.way.lastNode() : lastWay.way.firstNode(); 230 Node prevNode = !lastWayReverse ? lastWay.way.getNode(lastWay.way.getNodesCount() - 2) : lastWay.way.getNode(1); 231 232 //find best next way 233 WayInPolygon bestWay = null; 234 Node bestWayNextNode = null; 235 boolean bestWayReverse = false; 236 237 for (WayInPolygon way : availableWays) { 238 if (way.way.firstNode().equals(headNode)) { 239 //start adjacent to headNode 240 Node nextNode = way.way.getNode(1); 241 242 if (nextNode.equals(prevNode)) 243 { 244 //this is the path we came from - ignore it. 245 } 246 else if (bestWay == null || (Geometry.isToTheRightSideOfLine(prevNode, headNode, bestWayNextNode, nextNode) == rightmost)) { 247 //the new way is better 248 bestWay = way; 249 bestWayReverse = false; 250 bestWayNextNode = nextNode; 251 } 252 } 253 254 if (way.way.lastNode().equals(headNode)) { 255 //end adjacent to headNode 256 Node nextNode = way.way.getNode(way.way.getNodesCount() - 2); 257 258 if (nextNode.equals(prevNode)) { 259 //this is the path we came from - ignore it. 260 } 261 else if (bestWay == null || (Geometry.isToTheRightSideOfLine(prevNode, headNode, bestWayNextNode, nextNode) == rightmost)) { 262 //the new way is better 263 bestWay = way; 264 bestWayReverse = true; 265 bestWayNextNode = nextNode; 266 } 267 } 268 } 269 270 lastWay = bestWay; 271 lastWayReverse = bestWayReverse; 272 273 return lastWay; 274 } 275 276 public boolean isLastWayInsideToTheRight() { 277 return lastWayReverse != lastWay.insideToTheRight; 278 } 279 280 public Node getLastWayStartNode() { 281 return lastWayReverse ? lastWay.way.lastNode() : lastWay.way.firstNode(); 282 } 283 284 public Node getLastWayEndNode() { 285 return lastWayReverse ? lastWay.way.firstNode() : lastWay.way.lastNode(); 286 } 287 } 288 289 /** 290 * Helper storage class for finding findOuterWays 291 * @author viesturs 292 */ 293 static class PolygonLevel { 294 public final int level; 295 public final AssembledMultipolygon pol; 296 297 public PolygonLevel(AssembledMultipolygon _pol, int _level) { 298 pol = _pol; 299 level = _level; 300 } 301 } 302 303 // Adds the menu entry, Shortcuts, etc. 304 public JoinAreasAction() { 305 super(tr("Join overlapping Areas"), "joinareas", tr("Joins areas that overlap each other"), 306 Shortcut.registerShortcut("tools:joinareas", tr("Tool: {0}", tr("Join overlapping Areas")), 307 KeyEvent.VK_J, Shortcut.SHIFT), true); 308 } 309 310 /** 311 * Gets called whenever the shortcut is pressed or the menu entry is selected 312 * Checks whether the selected objects are suitable to join and joins them if so 313 */ 314 @Override 315 public void actionPerformed(ActionEvent e) { 316 LinkedList<Way> ways = new LinkedList<Way>(Main.main.getCurrentDataSet().getSelectedWays()); 317 318 if (ways.isEmpty()) { 319 new Notification( 320 tr("Please select at least one closed way that should be joined.")) 321 .setIcon(JOptionPane.INFORMATION_MESSAGE) 322 .show(); 323 return; 324 } 325 326 List<Node> allNodes = new ArrayList<Node>(); 327 for (Way way : ways) { 328 if (!way.isClosed()) { 329 new Notification( 330 tr("One of the selected ways is not closed and therefore cannot be joined.")) 331 .setIcon(JOptionPane.INFORMATION_MESSAGE) 332 .show(); 333 return; 334 } 335 336 allNodes.addAll(way.getNodes()); 337 } 338 339 // TODO: Only display this warning when nodes outside dataSourceArea are deleted 340 Area dataSourceArea = Main.main.getCurrentDataSet().getDataSourceArea(); 341 boolean ok = Command.checkAndConfirmOutlyingOperation("joinarea", tr("Join area confirmation"), 342 trn("The selected way has nodes outside of the downloaded data region.", 343 "The selected ways have nodes outside of the downloaded data region.", 344 ways.size()) + "<br/>" 345 + tr("This can lead to nodes being deleted accidentally.") + "<br/>" 346 + tr("Are you really sure to continue?") 347 + tr("Please abort if you are not sure"), 348 tr("The selected area is incomplete. Continue?"), 349 dataSourceArea, allNodes, null); 350 if(!ok) return; 351 352 //analyze multipolygon relations and collect all areas 353 List<Multipolygon> areas = collectMultipolygons(ways); 354 355 if (areas == null) 356 //too complex multipolygon relations found 357 return; 358 359 if (!testJoin(areas)) { 360 new Notification( 361 tr("No intersection found. Nothing was changed.")) 362 .setIcon(JOptionPane.INFORMATION_MESSAGE) 363 .show(); 364 return; 365 } 366 367 if (!resolveTagConflicts(areas)) 368 return; 369 //user canceled, do nothing. 370 371 try { 372 JoinAreasResult result = joinAreas(areas); 373 374 if (result.hasChanges) { 375 376 List<Way> allWays = new ArrayList<Way>(); 377 for (Multipolygon pol : result.polygons) { 378 allWays.add(pol.outerWay); 379 allWays.addAll(pol.innerWays); 380 } 381 DataSet ds = Main.main.getCurrentDataSet(); 382 ds.setSelected(allWays); 383 Main.map.mapView.repaint(); 384 } else { 385 new Notification( 386 tr("No intersection found. Nothing was changed.")) 387 .setIcon(JOptionPane.INFORMATION_MESSAGE) 388 .show(); 389 } 390 } 391 catch (UserCancelException exception) { 392 //revert changes 393 //FIXME: this is dirty hack 394 makeCommitsOneAction(tr("Reverting changes")); 395 Main.main.undoRedo.undo(); 396 Main.main.undoRedo.redoCommands.clear(); 397 } 398 } 399 400 /** 401 * Tests if the areas have some intersections to join. 402 * @param areas Areas to test 403 * @return @{code true} if areas are joinable 404 */ 405 private boolean testJoin(List<Multipolygon> areas) { 406 List<Way> allStartingWays = new ArrayList<Way>(); 407 408 for (Multipolygon area : areas) { 409 allStartingWays.add(area.outerWay); 410 allStartingWays.addAll(area.innerWays); 411 } 412 413 //find intersection points 414 Set<Node> nodes = Geometry.addIntersections(allStartingWays, true, cmds); 415 return !nodes.isEmpty(); 416 } 417 418 /** 419 * Will join two or more overlapping areas 420 * @param areas list of areas to join 421 * @return new area formed. 422 */ 423 private JoinAreasResult joinAreas(List<Multipolygon> areas) throws UserCancelException { 424 425 JoinAreasResult result = new JoinAreasResult(); 426 result.hasChanges = false; 427 428 List<Way> allStartingWays = new ArrayList<Way>(); 429 List<Way> innerStartingWays = new ArrayList<Way>(); 430 List<Way> outerStartingWays = new ArrayList<Way>(); 431 432 for (Multipolygon area : areas) { 433 outerStartingWays.add(area.outerWay); 434 innerStartingWays.addAll(area.innerWays); 435 } 436 437 allStartingWays.addAll(innerStartingWays); 438 allStartingWays.addAll(outerStartingWays); 439 440 //first remove nodes in the same coordinate 441 boolean removedDuplicates = false; 442 removedDuplicates |= removeDuplicateNodes(allStartingWays); 443 444 if (removedDuplicates) { 445 result.hasChanges = true; 446 commitCommands(marktr("Removed duplicate nodes")); 447 } 448 449 //find intersection points 450 Set<Node> nodes = Geometry.addIntersections(allStartingWays, false, cmds); 451 452 //no intersections, return. 453 if (nodes.isEmpty()) 454 return result; 455 commitCommands(marktr("Added node on all intersections")); 456 457 List<RelationRole> relations = new ArrayList<RelationRole>(); 458 459 // Remove ways from all relations so ways can be combined/split quietly 460 for (Way way : allStartingWays) { 461 relations.addAll(removeFromAllRelations(way)); 462 } 463 464 // Don't warn now, because it will really look corrupted 465 boolean warnAboutRelations = !relations.isEmpty() && allStartingWays.size() > 1; 466 467 List<WayInPolygon> preparedWays = new ArrayList<WayInPolygon>(); 468 469 for (Way way : outerStartingWays) { 470 List<Way> splitWays = splitWayOnNodes(way, nodes); 471 preparedWays.addAll(markWayInsideSide(splitWays, false)); 472 } 473 474 for (Way way : innerStartingWays) { 475 List<Way> splitWays = splitWayOnNodes(way, nodes); 476 preparedWays.addAll(markWayInsideSide(splitWays, true)); 477 } 478 479 // Find boundary ways 480 List<Way> discardedWays = new ArrayList<Way>(); 481 List<AssembledPolygon> bounadries = findBoundaryPolygons(preparedWays, discardedWays); 482 483 //find polygons 484 List<AssembledMultipolygon> preparedPolygons = findPolygons(bounadries); 485 486 487 //assemble final polygons 488 List<Multipolygon> polygons = new ArrayList<Multipolygon>(); 489 Set<Relation> relationsToDelete = new LinkedHashSet<Relation>(); 490 491 for (AssembledMultipolygon pol : preparedPolygons) { 492 493 //create the new ways 494 Multipolygon resultPol = joinPolygon(pol); 495 496 //create multipolygon relation, if necessary. 497 RelationRole ownMultipolygonRelation = addOwnMultigonRelation(resultPol.innerWays, resultPol.outerWay); 498 499 //add back the original relations, merged with our new multipolygon relation 500 fixRelations(relations, resultPol.outerWay, ownMultipolygonRelation, relationsToDelete); 501 502 //strip tags from inner ways 503 //TODO: preserve tags on existing inner ways 504 stripTags(resultPol.innerWays); 505 506 polygons.add(resultPol); 507 } 508 509 commitCommands(marktr("Assemble new polygons")); 510 511 for(Relation rel: relationsToDelete) { 512 cmds.add(new DeleteCommand(rel)); 513 } 514 515 commitCommands(marktr("Delete relations")); 516 517 // Delete the discarded inner ways 518 if (!discardedWays.isEmpty()) { 519 Command deleteCmd = DeleteCommand.delete(Main.main.getEditLayer(), discardedWays, true); 520 if (deleteCmd != null) { 521 cmds.add(deleteCmd); 522 commitCommands(marktr("Delete Ways that are not part of an inner multipolygon")); 523 } 524 } 525 526 makeCommitsOneAction(marktr("Joined overlapping areas")); 527 528 if (warnAboutRelations) { 529 new Notification( 530 tr("Some of the ways were part of relations that have been modified.<br>Please verify no errors have been introduced.")) 531 .setIcon(JOptionPane.INFORMATION_MESSAGE) 532 .setDuration(Notification.TIME_LONG) 533 .show(); 534 } 535 536 result.hasChanges = true; 537 result.polygons = polygons; 538 return result; 539 } 540 541 /** 542 * Checks if tags of two given ways differ, and presents the user a dialog to solve conflicts 543 * @param polygons ways to check 544 * @return {@code true} if all conflicts are resolved, {@code false} if conflicts remain. 545 */ 546 private boolean resolveTagConflicts(List<Multipolygon> polygons) { 547 548 List<Way> ways = new ArrayList<Way>(); 549 550 for (Multipolygon pol : polygons) { 551 ways.add(pol.outerWay); 552 ways.addAll(pol.innerWays); 553 } 554 555 if (ways.size() < 2) { 556 return true; 557 } 558 559 TagCollection wayTags = TagCollection.unionOfAllPrimitives(ways); 560 try { 561 cmds.addAll(CombinePrimitiveResolverDialog.launchIfNecessary(wayTags, ways, ways)); 562 commitCommands(marktr("Fix tag conflicts")); 563 return true; 564 } catch (UserCancelException ex) { 565 return false; 566 } 567 } 568 569 /** 570 * This method removes duplicate points (if any) from the input way. 571 * @param ways the ways to process 572 * @return {@code true} if any changes where made 573 */ 574 private boolean removeDuplicateNodes(List<Way> ways) { 575 //TODO: maybe join nodes with JoinNodesAction, rather than reconnect the ways. 576 577 Map<Node, Node> nodeMap = new TreeMap<Node, Node>(new NodePositionComparator()); 578 int totalNodesRemoved = 0; 579 580 for (Way way : ways) { 581 if (way.getNodes().size() < 2) { 582 continue; 583 } 584 585 int nodesRemoved = 0; 586 List<Node> newNodes = new ArrayList<Node>(); 587 Node prevNode = null; 588 589 for (Node node : way.getNodes()) { 590 if (!nodeMap.containsKey(node)) { 591 //new node 592 nodeMap.put(node, node); 593 594 //avoid duplicate nodes 595 if (prevNode != node) { 596 newNodes.add(node); 597 } else { 598 nodesRemoved ++; 599 } 600 } else { 601 //node with same coordinates already exists, substitute with existing node 602 Node representator = nodeMap.get(node); 603 604 if (representator != node) { 605 nodesRemoved ++; 606 } 607 608 //avoid duplicate node 609 if (prevNode != representator) { 610 newNodes.add(representator); 611 } 612 } 613 prevNode = node; 614 } 615 616 if (nodesRemoved > 0) { 617 618 if (newNodes.size() == 1) { //all nodes in the same coordinate - add one more node, to have closed way. 619 newNodes.add(newNodes.get(0)); 620 } 621 622 Way newWay=new Way(way); 623 newWay.setNodes(newNodes); 624 cmds.add(new ChangeCommand(way, newWay)); 625 totalNodesRemoved += nodesRemoved; 626 } 627 } 628 629 return totalNodesRemoved > 0; 630 } 631 632 /** 633 * Commits the command list with a description 634 * @param description The description of what the commands do 635 */ 636 private void commitCommands(String description) { 637 switch(cmds.size()) { 638 case 0: 639 return; 640 case 1: 641 Main.main.undoRedo.add(cmds.getFirst()); 642 break; 643 default: 644 Command c = new SequenceCommand(tr(description), cmds); 645 Main.main.undoRedo.add(c); 646 break; 647 } 648 649 cmds.clear(); 650 cmdsCount++; 651 } 652 653 /** 654 * This method analyzes the way and assigns each part what direction polygon "inside" is. 655 * @param parts the split parts of the way 656 * @param isInner - if true, reverts the direction (for multipolygon islands) 657 * @return list of parts, marked with the inside orientation. 658 */ 659 private List<WayInPolygon> markWayInsideSide(List<Way> parts, boolean isInner) { 660 661 List<WayInPolygon> result = new ArrayList<WayInPolygon>(); 662 663 //prepare prev and next maps 664 Map<Way, Way> nextWayMap = new HashMap<Way, Way>(); 665 Map<Way, Way> prevWayMap = new HashMap<Way, Way>(); 666 667 for (int pos = 0; pos < parts.size(); pos ++) { 668 669 if (!parts.get(pos).lastNode().equals(parts.get((pos + 1) % parts.size()).firstNode())) 670 throw new RuntimeException("Way not circular"); 671 672 nextWayMap.put(parts.get(pos), parts.get((pos + 1) % parts.size())); 673 prevWayMap.put(parts.get(pos), parts.get((pos + parts.size() - 1) % parts.size())); 674 } 675 676 //find the node with minimum y - it's guaranteed to be outer. (What about the south pole?) 677 Way topWay = null; 678 Node topNode = null; 679 int topIndex = 0; 680 double minY = Double.POSITIVE_INFINITY; 681 682 for (Way way : parts) { 683 for (int pos = 0; pos < way.getNodesCount(); pos ++) { 684 Node node = way.getNode(pos); 685 686 if (node.getEastNorth().getY() < minY) { 687 minY = node.getEastNorth().getY(); 688 topWay = way; 689 topNode = node; 690 topIndex = pos; 691 } 692 } 693 } 694 695 //get the upper way and it's orientation. 696 697 boolean wayClockwise; // orientation of the top way. 698 699 if (topNode.equals(topWay.firstNode()) || topNode.equals(topWay.lastNode())) { 700 Node headNode = null; // the node at junction 701 Node prevNode = null; // last node from previous path 702 wayClockwise = false; 703 704 //node is in split point - find the outermost way from this point 705 706 headNode = topNode; 707 //make a fake node that is downwards from head node (smaller Y). It will be a division point between paths. 708 prevNode = new Node(new EastNorth(headNode.getEastNorth().getX(), headNode.getEastNorth().getY() - 1e5)); 709 710 topWay = null; 711 wayClockwise = false; 712 Node bestWayNextNode = null; 713 714 for (Way way : parts) { 715 if (way.firstNode().equals(headNode)) { 716 Node nextNode = way.getNode(1); 717 718 if (topWay == null || !Geometry.isToTheRightSideOfLine(prevNode, headNode, bestWayNextNode, nextNode)) { 719 //the new way is better 720 topWay = way; 721 wayClockwise = true; 722 bestWayNextNode = nextNode; 723 } 724 } 725 726 if (way.lastNode().equals(headNode)) { 727 //end adjacent to headNode 728 Node nextNode = way.getNode(way.getNodesCount() - 2); 729 730 if (topWay == null || !Geometry.isToTheRightSideOfLine(prevNode, headNode, bestWayNextNode, nextNode)) { 731 //the new way is better 732 topWay = way; 733 wayClockwise = false; 734 bestWayNextNode = nextNode; 735 } 736 } 737 } 738 } else { 739 //node is inside way - pick the clockwise going end. 740 Node prev = topWay.getNode(topIndex - 1); 741 Node next = topWay.getNode(topIndex + 1); 742 743 //there will be no parallel segments in the middle of way, so all fine. 744 wayClockwise = Geometry.angleIsClockwise(prev, topNode, next); 745 } 746 747 Way curWay = topWay; 748 boolean curWayInsideToTheRight = wayClockwise ^ isInner; 749 750 //iterate till full circle is reached 751 while (true) { 752 753 //add cur way 754 WayInPolygon resultWay = new WayInPolygon(curWay, curWayInsideToTheRight); 755 result.add(resultWay); 756 757 //process next way 758 Way nextWay = nextWayMap.get(curWay); 759 Node prevNode = curWay.getNode(curWay.getNodesCount() - 2); 760 Node headNode = curWay.lastNode(); 761 Node nextNode = nextWay.getNode(1); 762 763 if (nextWay == topWay) { 764 //full loop traversed - all done. 765 break; 766 } 767 768 //find intersecting segments 769 // the intersections will look like this: 770 // 771 // ^ 772 // | 773 // X wayBNode 774 // | 775 // wayB | 776 // | 777 // curWay | nextWay 778 //----X----------------->X----------------------X----> 779 // prevNode ^headNode nextNode 780 // | 781 // | 782 // wayA | 783 // | 784 // X wayANode 785 // | 786 787 int intersectionCount = 0; 788 789 for (Way wayA : parts) { 790 791 if (wayA == curWay) { 792 continue; 793 } 794 795 if (wayA.lastNode().equals(headNode)) { 796 797 Way wayB = nextWayMap.get(wayA); 798 799 //test if wayA is opposite wayB relative to curWay and nextWay 800 801 Node wayANode = wayA.getNode(wayA.getNodesCount() - 2); 802 Node wayBNode = wayB.getNode(1); 803 804 boolean wayAToTheRight = Geometry.isToTheRightSideOfLine(prevNode, headNode, nextNode, wayANode); 805 boolean wayBToTheRight = Geometry.isToTheRightSideOfLine(prevNode, headNode, nextNode, wayBNode); 806 807 if (wayAToTheRight != wayBToTheRight) { 808 intersectionCount ++; 809 } 810 } 811 } 812 813 //if odd number of crossings, invert orientation 814 if (intersectionCount % 2 != 0) { 815 curWayInsideToTheRight = !curWayInsideToTheRight; 816 } 817 818 curWay = nextWay; 819 } 820 821 return result; 822 } 823 824 /** 825 * This is a method splits way into smaller parts, using the prepared nodes list as split points. 826 * Uses SplitWayAction.splitWay for the heavy lifting. 827 * @return list of split ways (or original ways if no splitting is done). 828 */ 829 private List<Way> splitWayOnNodes(Way way, Set<Node> nodes) { 830 831 List<Way> result = new ArrayList<Way>(); 832 List<List<Node>> chunks = buildNodeChunks(way, nodes); 833 834 if (chunks.size() > 1) { 835 SplitWayResult split = SplitWayAction.splitWay(Main.main.getEditLayer(), way, chunks, Collections.<OsmPrimitive>emptyList()); 836 837 //execute the command, we need the results 838 cmds.add(split.getCommand()); 839 commitCommands(marktr("Split ways into fragments")); 840 841 result.add(split.getOriginalWay()); 842 result.addAll(split.getNewWays()); 843 } else { 844 //nothing to split 845 result.add(way); 846 } 847 848 return result; 849 } 850 851 /** 852 * Simple chunking version. Does not care about circular ways and result being 853 * proper, we will glue it all back together later on. 854 * @param way the way to chunk 855 * @param splitNodes the places where to cut. 856 * @return list of node paths to produce. 857 */ 858 private List<List<Node>> buildNodeChunks(Way way, Collection<Node> splitNodes) { 859 List<List<Node>> result = new ArrayList<List<Node>>(); 860 List<Node> curList = new ArrayList<Node>(); 861 862 for (Node node : way.getNodes()) { 863 curList.add(node); 864 if (curList.size() > 1 && splitNodes.contains(node)) { 865 result.add(curList); 866 curList = new ArrayList<Node>(); 867 curList.add(node); 868 } 869 } 870 871 if (curList.size() > 1) { 872 result.add(curList); 873 } 874 875 return result; 876 } 877 878 879 /** 880 * This method finds which ways are outer and which are inner. 881 * @param boundaries list of joined boundaries to search in 882 * @return outer ways 883 */ 884 private List<AssembledMultipolygon> findPolygons(Collection<AssembledPolygon> boundaries) { 885 886 List<PolygonLevel> list = findOuterWaysImpl(0, boundaries); 887 List<AssembledMultipolygon> result = new ArrayList<AssembledMultipolygon>(); 888 889 //take every other level 890 for (PolygonLevel pol : list) { 891 if (pol.level % 2 == 0) { 892 result.add(pol.pol); 893 } 894 } 895 896 return result; 897 } 898 899 /** 900 * Collects outer way and corresponding inner ways from all boundaries. 901 * @param level depth level 902 * @param boundaryWays 903 * @return the outermostWay. 904 */ 905 private List<PolygonLevel> findOuterWaysImpl(int level, Collection<AssembledPolygon> boundaryWays) { 906 907 //TODO: bad performance for deep nestings... 908 List<PolygonLevel> result = new ArrayList<PolygonLevel>(); 909 910 for (AssembledPolygon outerWay : boundaryWays) { 911 912 boolean outerGood = true; 913 List<AssembledPolygon> innerCandidates = new ArrayList<AssembledPolygon>(); 914 915 for (AssembledPolygon innerWay : boundaryWays) { 916 if (innerWay == outerWay) { 917 continue; 918 } 919 920 if (wayInsideWay(outerWay, innerWay)) { 921 outerGood = false; 922 break; 923 } else if (wayInsideWay(innerWay, outerWay)) { 924 innerCandidates.add(innerWay); 925 } 926 } 927 928 if (!outerGood) { 929 continue; 930 } 931 932 //add new outer polygon 933 AssembledMultipolygon pol = new AssembledMultipolygon(outerWay); 934 PolygonLevel polLev = new PolygonLevel(pol, level); 935 936 //process inner ways 937 if (!innerCandidates.isEmpty()) { 938 List<PolygonLevel> innerList = findOuterWaysImpl(level + 1, innerCandidates); 939 result.addAll(innerList); 940 941 for (PolygonLevel pl : innerList) { 942 if (pl.level == level + 1) { 943 pol.innerWays.add(pl.pol.outerWay); 944 } 945 } 946 } 947 948 result.add(polLev); 949 } 950 951 return result; 952 } 953 954 /** 955 * Finds all ways that form inner or outer boundaries. 956 * @param multigonWays A list of (splitted) ways that form a multigon and share common end nodes on intersections. 957 * @param discardedResult this list is filled with ways that are to be discarded 958 * @return A list of ways that form the outer and inner boundaries of the multigon. 959 */ 960 public static List<AssembledPolygon> findBoundaryPolygons(Collection<WayInPolygon> multigonWays, List<Way> discardedResult) { 961 //first find all discardable ways, by getting outer shells. 962 //this will produce incorrect boundaries in some cases, but second pass will fix it. 963 964 List<WayInPolygon> discardedWays = new ArrayList<WayInPolygon>(); 965 Set<WayInPolygon> processedWays = new HashSet<WayInPolygon>(); 966 WayTraverser traverser = new WayTraverser(multigonWays); 967 968 for (WayInPolygon startWay : multigonWays) { 969 if (processedWays.contains(startWay)) { 970 continue; 971 } 972 973 traverser.startNewWay(startWay); 974 975 List<WayInPolygon> boundary = new ArrayList<WayInPolygon>(); 976 WayInPolygon lastWay = startWay; 977 978 while (true) { 979 boundary.add(lastWay); 980 981 WayInPolygon bestWay = traverser.advanceNextLeftmostWay(); 982 boolean wayInsideToTheRight = bestWay == null ? false : traverser.isLastWayInsideToTheRight(); 983 984 if (bestWay == null || processedWays.contains(bestWay) || !wayInsideToTheRight) { 985 //bad segment chain - proceed to discard it 986 lastWay = null; 987 break; 988 } else if (boundary.contains(bestWay)) { 989 //traversed way found - close the way 990 lastWay = bestWay; 991 break; 992 } else { 993 //proceed to next segment 994 lastWay = bestWay; 995 } 996 } 997 998 if (lastWay != null) { 999 //way good 1000 processedWays.addAll(boundary); 1001 1002 //remove junk segments at the start 1003 while (boundary.get(0) != lastWay) { 1004 discardedWays.add(boundary.get(0)); 1005 boundary.remove(0); 1006 } 1007 } else { 1008 //way bad 1009 discardedWays.addAll(boundary); 1010 processedWays.addAll(boundary); 1011 } 1012 } 1013 1014 //now we have removed junk segments, collect the real result ways 1015 1016 traverser.removeWays(discardedWays); 1017 1018 List<AssembledPolygon> result = new ArrayList<AssembledPolygon>(); 1019 1020 while (traverser.hasWays()) { 1021 1022 WayInPolygon startWay = traverser.startNewWay(); 1023 List<WayInPolygon> boundary = new ArrayList<WayInPolygon>(); 1024 WayInPolygon curWay = startWay; 1025 1026 do { 1027 boundary.add(curWay); 1028 curWay = traverser.advanceNextRightmostWay(); 1029 1030 //should not happen 1031 if (curWay == null || !traverser.isLastWayInsideToTheRight()) 1032 throw new RuntimeException("Join areas internal error."); 1033 1034 } while (curWay != startWay); 1035 1036 //build result 1037 traverser.removeWays(boundary); 1038 result.add(new AssembledPolygon(boundary)); 1039 } 1040 1041 for (WayInPolygon way : discardedWays) { 1042 discardedResult.add(way.way); 1043 } 1044 1045 //split inner polygons that have several touching parts. 1046 result = fixTouchingPolygons(result); 1047 1048 return result; 1049 } 1050 1051 /** 1052 * This method checks if polygons have several touching parts and splits them in several polygons. 1053 * @param polygons the polygons to process. 1054 */ 1055 public static List<AssembledPolygon> fixTouchingPolygons(List<AssembledPolygon> polygons) 1056 { 1057 List<AssembledPolygon> newPolygons = new ArrayList<AssembledPolygon>(); 1058 1059 for (AssembledPolygon innerPart : polygons) { 1060 WayTraverser traverser = new WayTraverser(innerPart.ways); 1061 1062 while (traverser.hasWays()) { 1063 1064 WayInPolygon startWay = traverser.startNewWay(); 1065 List<WayInPolygon> boundary = new ArrayList<WayInPolygon>(); 1066 WayInPolygon curWay = startWay; 1067 1068 Node startNode = traverser.getLastWayStartNode(); 1069 boundary.add(curWay); 1070 1071 while (startNode != traverser.getLastWayEndNode()) { 1072 curWay = traverser.advanceNextLeftmostWay(); 1073 boundary.add(curWay); 1074 1075 //should not happen 1076 if (curWay == null || !traverser.isLastWayInsideToTheRight()) 1077 throw new RuntimeException("Join areas internal error."); 1078 } 1079 1080 //build result 1081 traverser.removeWays(boundary); 1082 newPolygons.add(new AssembledPolygon(boundary)); 1083 } 1084 } 1085 1086 return newPolygons; 1087 } 1088 1089 /** 1090 * Tests if way is inside other way 1091 * @param outside outer polygon description 1092 * @param inside inner polygon description 1093 * @return {@code true} if inner is inside outer 1094 */ 1095 public static boolean wayInsideWay(AssembledPolygon inside, AssembledPolygon outside) { 1096 Set<Node> outsideNodes = new HashSet<Node>(outside.getNodes()); 1097 List<Node> insideNodes = inside.getNodes(); 1098 1099 for (Node insideNode : insideNodes) { 1100 1101 if (!outsideNodes.contains(insideNode)) 1102 //simply test the one node 1103 return Geometry.nodeInsidePolygon(insideNode, outside.getNodes()); 1104 } 1105 1106 //all nodes shared. 1107 return false; 1108 } 1109 1110 /** 1111 * Joins the lists of ways. 1112 * @param polygon The list of outer ways that belong to that multigon. 1113 * @return The newly created outer way 1114 */ 1115 private Multipolygon joinPolygon(AssembledMultipolygon polygon) throws UserCancelException { 1116 Multipolygon result = new Multipolygon(joinWays(polygon.outerWay.ways)); 1117 1118 for (AssembledPolygon pol : polygon.innerWays) { 1119 result.innerWays.add(joinWays(pol.ways)); 1120 } 1121 1122 return result; 1123 } 1124 1125 /** 1126 * Joins the outer ways and deletes all short ways that can't be part of a multipolygon anyway. 1127 * @param ways The list of outer ways that belong to that multigon. 1128 * @return The newly created outer way 1129 */ 1130 private Way joinWays(List<WayInPolygon> ways) throws UserCancelException { 1131 1132 //leave original orientation, if all paths are reverse. 1133 boolean allReverse = true; 1134 for (WayInPolygon way : ways) { 1135 allReverse &= !way.insideToTheRight; 1136 } 1137 1138 if (allReverse) { 1139 for (WayInPolygon way : ways) { 1140 way.insideToTheRight = !way.insideToTheRight; 1141 } 1142 } 1143 1144 Way joinedWay = joinOrientedWays(ways); 1145 1146 //should not happen 1147 if (joinedWay == null || !joinedWay.isClosed()) 1148 throw new RuntimeException("Join areas internal error."); 1149 1150 return joinedWay; 1151 } 1152 1153 /** 1154 * Joins a list of ways (using CombineWayAction and ReverseWayAction as specified in WayInPath) 1155 * @param ways The list of ways to join and reverse 1156 * @return The newly created way 1157 */ 1158 private Way joinOrientedWays(List<WayInPolygon> ways) throws UserCancelException{ 1159 if (ways.size() < 2) 1160 return ways.get(0).way; 1161 1162 // This will turn ways so all of them point in the same direction and CombineAction won't bug 1163 // the user about this. 1164 1165 //TODO: ReverseWay and Combine way are really slow and we use them a lot here. This slows down large joins. 1166 List<Way> actionWays = new ArrayList<Way>(ways.size()); 1167 1168 for (WayInPolygon way : ways) { 1169 actionWays.add(way.way); 1170 1171 if (!way.insideToTheRight) { 1172 ReverseWayResult res = ReverseWayAction.reverseWay(way.way); 1173 Main.main.undoRedo.add(res.getReverseCommand()); 1174 cmdsCount++; 1175 } 1176 } 1177 1178 Pair<Way, Command> result = CombineWayAction.combineWaysWorker(actionWays); 1179 1180 Main.main.undoRedo.add(result.b); 1181 cmdsCount ++; 1182 1183 return result.a; 1184 } 1185 1186 /** 1187 * This method analyzes multipolygon relationships of given ways and collects addition inner ways to consider. 1188 * @param selectedWays the selected ways 1189 * @return list of polygons, or null if too complex relation encountered. 1190 */ 1191 private List<Multipolygon> collectMultipolygons(List<Way> selectedWays) { 1192 1193 List<Multipolygon> result = new ArrayList<Multipolygon>(); 1194 1195 //prepare the lists, to minimize memory allocation. 1196 List<Way> outerWays = new ArrayList<Way>(); 1197 List<Way> innerWays = new ArrayList<Way>(); 1198 1199 Set<Way> processedOuterWays = new LinkedHashSet<Way>(); 1200 Set<Way> processedInnerWays = new LinkedHashSet<Way>(); 1201 1202 for (Relation r : OsmPrimitive.getParentRelations(selectedWays)) { 1203 if (r.isDeleted() || !r.isMultipolygon()) { 1204 continue; 1205 } 1206 1207 boolean hasKnownOuter = false; 1208 outerWays.clear(); 1209 innerWays.clear(); 1210 1211 for (RelationMember rm : r.getMembers()) { 1212 if (rm.getRole().equalsIgnoreCase("outer")) { 1213 outerWays.add(rm.getWay()); 1214 hasKnownOuter |= selectedWays.contains(rm.getWay()); 1215 } 1216 else if (rm.getRole().equalsIgnoreCase("inner")) { 1217 innerWays.add(rm.getWay()); 1218 } 1219 } 1220 1221 if (!hasKnownOuter) { 1222 continue; 1223 } 1224 1225 if (outerWays.size() > 1) { 1226 new Notification( 1227 tr("Sorry. Cannot handle multipolygon relations with multiple outer ways.")) 1228 .setIcon(JOptionPane.INFORMATION_MESSAGE) 1229 .show(); 1230 return null; 1231 } 1232 1233 Way outerWay = outerWays.get(0); 1234 1235 //retain only selected inner ways 1236 innerWays.retainAll(selectedWays); 1237 1238 if (processedOuterWays.contains(outerWay)) { 1239 new Notification( 1240 tr("Sorry. Cannot handle way that is outer in multiple multipolygon relations.")) 1241 .setIcon(JOptionPane.INFORMATION_MESSAGE) 1242 .show(); 1243 return null; 1244 } 1245 1246 if (processedInnerWays.contains(outerWay)) { 1247 new Notification( 1248 tr("Sorry. Cannot handle way that is both inner and outer in multipolygon relations.")) 1249 .setIcon(JOptionPane.INFORMATION_MESSAGE) 1250 .show(); 1251 return null; 1252 } 1253 1254 for (Way way :innerWays) 1255 { 1256 if (processedOuterWays.contains(way)) { 1257 new Notification( 1258 tr("Sorry. Cannot handle way that is both inner and outer in multipolygon relations.")) 1259 .setIcon(JOptionPane.INFORMATION_MESSAGE) 1260 .show(); 1261 return null; 1262 } 1263 1264 if (processedInnerWays.contains(way)) { 1265 new Notification( 1266 tr("Sorry. Cannot handle way that is inner in multiple multipolygon relations.")) 1267 .setIcon(JOptionPane.INFORMATION_MESSAGE) 1268 .show(); 1269 return null; 1270 } 1271 } 1272 1273 processedOuterWays.add(outerWay); 1274 processedInnerWays.addAll(innerWays); 1275 1276 Multipolygon pol = new Multipolygon(outerWay); 1277 pol.innerWays.addAll(innerWays); 1278 1279 result.add(pol); 1280 } 1281 1282 //add remaining ways, not in relations 1283 for (Way way : selectedWays) { 1284 if (processedOuterWays.contains(way) || processedInnerWays.contains(way)) { 1285 continue; 1286 } 1287 1288 result.add(new Multipolygon(way)); 1289 } 1290 1291 return result; 1292 } 1293 1294 /** 1295 * Will add own multipolygon relation to the "previously existing" relations. Fixup is done by fixRelations 1296 * @param inner List of already closed inner ways 1297 * @param outer The outer way 1298 * @return The list of relation with roles to add own relation to 1299 */ 1300 private RelationRole addOwnMultigonRelation(Collection<Way> inner, Way outer) { 1301 if (inner.isEmpty()) return null; 1302 // Create new multipolygon relation and add all inner ways to it 1303 Relation newRel = new Relation(); 1304 newRel.put("type", "multipolygon"); 1305 for (Way w : inner) { 1306 newRel.addMember(new RelationMember("inner", w)); 1307 } 1308 cmds.add(new AddCommand(newRel)); 1309 1310 // We don't add outer to the relation because it will be handed to fixRelations() 1311 // which will then do the remaining work. 1312 return new RelationRole(newRel, "outer"); 1313 } 1314 1315 /** 1316 * Removes a given OsmPrimitive from all relations 1317 * @param osm Element to remove from all relations 1318 * @return List of relations with roles the primitives was part of 1319 */ 1320 private List<RelationRole> removeFromAllRelations(OsmPrimitive osm) { 1321 List<RelationRole> result = new ArrayList<RelationRole>(); 1322 1323 for (Relation r : Main.main.getCurrentDataSet().getRelations()) { 1324 if (r.isDeleted()) { 1325 continue; 1326 } 1327 for (RelationMember rm : r.getMembers()) { 1328 if (rm.getMember() != osm) { 1329 continue; 1330 } 1331 1332 Relation newRel = new Relation(r); 1333 List<RelationMember> members = newRel.getMembers(); 1334 members.remove(rm); 1335 newRel.setMembers(members); 1336 1337 cmds.add(new ChangeCommand(r, newRel)); 1338 RelationRole saverel = new RelationRole(r, rm.getRole()); 1339 if (!result.contains(saverel)) { 1340 result.add(saverel); 1341 } 1342 break; 1343 } 1344 } 1345 1346 commitCommands(marktr("Removed Element from Relations")); 1347 return result; 1348 } 1349 1350 /** 1351 * Adds the previously removed relations again to the outer way. If there are multiple multipolygon 1352 * relations where the joined areas were in "outer" role a new relation is created instead with all 1353 * members of both. This function depends on multigon relations to be valid already, it won't fix them. 1354 * @param rels List of relations with roles the (original) ways were part of 1355 * @param outer The newly created outer area/way 1356 * @param ownMultipol elements to directly add as outer 1357 * @param relationsToDelete set of relations to delete. 1358 */ 1359 private void fixRelations(List<RelationRole> rels, Way outer, RelationRole ownMultipol, Set<Relation> relationsToDelete) { 1360 List<RelationRole> multiouters = new ArrayList<RelationRole>(); 1361 1362 if (ownMultipol != null) { 1363 multiouters.add(ownMultipol); 1364 } 1365 1366 for (RelationRole r : rels) { 1367 if (r.rel.isMultipolygon() && r.role.equalsIgnoreCase("outer")) { 1368 multiouters.add(r); 1369 continue; 1370 } 1371 // Add it back! 1372 Relation newRel = new Relation(r.rel); 1373 newRel.addMember(new RelationMember(r.role, outer)); 1374 cmds.add(new ChangeCommand(r.rel, newRel)); 1375 } 1376 1377 Relation newRel; 1378 switch (multiouters.size()) { 1379 case 0: 1380 return; 1381 case 1: 1382 // Found only one to be part of a multipolygon relation, so just add it back as well 1383 newRel = new Relation(multiouters.get(0).rel); 1384 newRel.addMember(new RelationMember(multiouters.get(0).role, outer)); 1385 cmds.add(new ChangeCommand(multiouters.get(0).rel, newRel)); 1386 return; 1387 default: 1388 // Create a new relation with all previous members and (Way)outer as outer. 1389 newRel = new Relation(); 1390 for (RelationRole r : multiouters) { 1391 // Add members 1392 for (RelationMember rm : r.rel.getMembers()) 1393 if (!newRel.getMembers().contains(rm)) { 1394 newRel.addMember(rm); 1395 } 1396 // Add tags 1397 for (String key : r.rel.keySet()) { 1398 newRel.put(key, r.rel.get(key)); 1399 } 1400 // Delete old relation 1401 relationsToDelete.add(r.rel); 1402 } 1403 newRel.addMember(new RelationMember("outer", outer)); 1404 cmds.add(new AddCommand(newRel)); 1405 } 1406 } 1407 1408 /** 1409 * Remove all tags from the all the way 1410 * @param ways The List of Ways to remove all tags from 1411 */ 1412 private void stripTags(Collection<Way> ways) { 1413 for (Way w : ways) { 1414 stripTags(w); 1415 } 1416 /* I18N: current action printed in status display */ 1417 commitCommands(marktr("Remove tags from inner ways")); 1418 } 1419 1420 /** 1421 * Remove all tags from the way 1422 * @param x The Way to remove all tags from 1423 */ 1424 private void stripTags(Way x) { 1425 Way y = new Way(x); 1426 for (String key : x.keySet()) { 1427 y.remove(key); 1428 } 1429 cmds.add(new ChangeCommand(x, y)); 1430 } 1431 1432 /** 1433 * Takes the last cmdsCount actions back and combines them into a single action 1434 * (for when the user wants to undo the join action) 1435 * @param message The commit message to display 1436 */ 1437 private void makeCommitsOneAction(String message) { 1438 UndoRedoHandler ur = Main.main.undoRedo; 1439 cmds.clear(); 1440 int i = Math.max(ur.commands.size() - cmdsCount, 0); 1441 for (; i < ur.commands.size(); i++) { 1442 cmds.add(ur.commands.get(i)); 1443 } 1444 1445 for (i = 0; i < cmds.size(); i++) { 1446 ur.undo(); 1447 } 1448 1449 commitCommands(message == null ? marktr("Join Areas Function") : message); 1450 cmdsCount = 0; 1451 } 1452 1453 @Override 1454 protected void updateEnabledState() { 1455 if (getCurrentDataSet() == null) { 1456 setEnabled(false); 1457 } else { 1458 updateEnabledState(getCurrentDataSet().getSelected()); 1459 } 1460 } 1461 1462 @Override 1463 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 1464 setEnabled(selection != null && !selection.isEmpty()); 1465 } 1466}