Ticket #24891: josm-parallel-way-buffer.patch
| File josm-parallel-way-buffer.patch, 71.0 KB (added by , 23 hours ago) |
|---|
-
src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java
diff --git src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java index 790293029c..c770e17834 100644
public class ParallelWayAction extends MapMode implements ModifierExListener { 101 101 private static final CachingProperty<Double> SNAP_DISTANCE_IMPERIAL = new DoubleProperty(prefKey("snap-distance-imperial"), 1).cached(); 102 102 private static final CachingProperty<Double> SNAP_DISTANCE_CHINESE = new DoubleProperty(prefKey("snap-distance-chinese"), 1).cached(); 103 103 private static final CachingProperty<Double> SNAP_DISTANCE_NAUTICAL = new DoubleProperty(prefKey("snap-distance-nautical"), 0.1).cached(); 104 private static final CachingProperty<Double> ARC_STEP_DEGREES 105 = new DoubleProperty(prefKey("arc-step-degrees"), ParallelWays.DEFAULT_ARC_STEP_DEGREES).cached(); 106 private static final CachingProperty<BasicStroke> PREVIEW_STROKE = new StrokeProperty(prefKey("stroke.preview"), "2").cached(); 104 107 private static final CachingProperty<Color> MAIN_COLOR = new NamedColorProperty(marktr("make parallel helper line"), Color.RED).cached(); 105 108 106 109 private static final CachingProperty<Map<Modifier, Boolean>> SNAP_MODIFIER_COMBO … … public class ParallelWayAction extends MapMode implements ModifierExListener { 331 334 } // else -> invalid modifier combination 332 335 } else if (mode == Mode.DRAGGING) { 333 336 clearSourceWays(); 334 MainApplication.getMap().statusLine.setDist(pWays.getWays()); 337 if (pWays != null) { 338 // The nodes and ways are only created now, since their number depends on the offset 339 pWays.commit(); 340 List<Way> newWays = pWays.getWays(); 341 if (newWays.isEmpty()) { 342 new Notification(tr("ParallelWayAction\n" + 343 "The offset is too large, nothing remains of the parallel way(s)")) 344 .setIcon(JOptionPane.INFORMATION_MESSAGE) 345 .show(); 346 pWays = null; 347 } else { 348 getLayerManager().getEditDataSet().setSelected(newWays); 349 MainApplication.getMap().statusLine.setDist(newWays); 350 } 351 } 335 352 } 336 353 337 354 setMode(Mode.NORMAL); … … public class ParallelWayAction extends MapMode implements ModifierExListener { 522 539 } 523 540 i++; 524 541 } 525 pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex); 526 pWays.commit(); 527 getLayerManager().getEditDataSet().setSelected(pWays.getWays()); 542 pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex, ARC_STEP_DEGREES.get()); 528 543 return true; 529 544 } catch (IllegalArgumentException e) { 530 545 Logging.debug(e); … … public class ParallelWayAction extends MapMode implements ModifierExListener { 638 653 line.moveTo(helperLineStart); 639 654 line.lineTo(helperLineEnd); 640 655 g.draw(line.computeClippedLine(g.getStroke())); 656 657 // Preview of the parallel way(s) 658 if (pWays != null) { 659 List<EastNorth> pts = pWays.getOffsetPoints(); 660 if (pts.size() > 1) { 661 g.setStroke(PREVIEW_STROKE.get()); 662 line = new MapViewPath(mv); 663 line.moveTo(pts.get(0)); 664 for (int i = 1; i < pts.size(); i++) { 665 line.lineTo(pts.get(i)); 666 } 667 if (pWays.isResultClosed()) { 668 line.lineTo(pts.get(0)); 669 } 670 g.draw(line.computeClippedLine(g.getStroke())); 671 } 672 } 641 673 } 642 674 } 643 675 } -
src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java
diff --git src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java index 8f2b297f2c..0a47bd93ca 100644
2 2 package org.openstreetmap.josm.actions.mapmode; 3 3 4 4 import java.util.ArrayList; 5 import java.util.Arrays; 5 6 import java.util.Collection; 6 7 import java.util.Collections; 7 import java.util.HashMap;8 8 import java.util.HashSet; 9 9 import java.util.List; 10 import java.util.Map;11 10 import java.util.Set; 11 import java.util.function.IntConsumer; 12 12 import java.util.stream.IntStream; 13 13 14 14 import org.openstreetmap.josm.command.AddCommand; … … import org.openstreetmap.josm.data.osm.Node; 21 21 import org.openstreetmap.josm.data.osm.NodeGraph; 22 22 import org.openstreetmap.josm.data.osm.OsmDataManager; 23 23 import org.openstreetmap.josm.data.osm.Way; 24 import org.openstreetmap.josm.tools.Geometry;25 import org.openstreetmap.josm.tools.Utils;26 24 27 25 /** 28 26 * Helper for {@link ParallelWayAction}. 29 * 30 * @author Ole Jørgen Brønner (olejorgenb) 27 * <p> 28 * Computes a one-sided offset ("parallel") of a branchless path made of one or more ways. 29 * <p> 30 * The algorithm is a proper one-sided buffer, which also works when the offset is much larger than 31 * the length of the segments of the source path (e.g. a maritime boundary 22 km off a coastline): 32 * <ol> 33 * <li>Each segment is offset by the requested distance.</li> 34 * <li>At every vertex the two neighbouring offset segments are joined: on the inner side of a turn they are 35 * clipped at their intersection, on the outer side a mitre is used for gentle turns and a circular arc 36 * (approximated by chords) for sharp turns or when the mitre would overshoot too far.</li> 37 * <li>The resulting raw polyline is split at its self-intersections. Every piece is kept only if it lies at 38 * (at least) the offset distance from the source path. Pieces which are closer belong to inverted loops 39 * ("swallowtails") and are dropped. The remaining pieces are chained; the longest chain is the result.</li> 40 * </ol> 41 * All calculations are done in projected coordinates. 42 * <p> 43 * Contrary to earlier versions the nodes and ways are only created (and added to the data set) by 44 * {@link #commit()}, since the number of nodes of the result depends on the offset. Use 45 * {@link #getOffsetPoints()} to draw a preview while the offset is changed. 31 46 */ 32 47 public class ParallelWays { 33 private final List<Way> ways;34 private final List<Node> sortedNodes;35 48 49 /** Default angular step used to approximate circular arcs, in degrees */ 50 public static final double DEFAULT_ARC_STEP_DEGREES = 10; 51 52 private static final int NO_NODE = -1; 53 /** marker for raw segments of the end caps, which are only used to trim the result and are never part of it */ 54 private static final int CAP = -2; 55 56 private final List<Way> sourceWays; 57 private final boolean copyTags; 58 private final double arcStep; 59 60 /** the source nodes, in path order (duplicates by coordinate removed, oriented like the reference way) */ 61 private final List<Node> sortedNodes; 62 private final boolean closed; 36 63 private final int nodeCount; 37 64 38 private final EastNorth[] pts; 39 private final EastNorth[] normals; 65 private final double[] px; 66 private final double[] py; 67 /** unit direction of segment i */ 68 private final double[] dirX; 69 private final double[] dirY; 70 private final double[] segLen; 71 /** bounding boxes of the source segments, used to speed up distance computations */ 72 private final double[] segMinX; 73 private final double[] segMinY; 74 private final double[] segMaxX; 75 private final double[] segMaxY; 76 77 /** whether the source way runs in the same direction as sortedNodes (aligned with sourceWays) */ 78 private final boolean[] wayForward; 79 /** source way index for each source segment */ 80 private final int[] segWay; 81 82 // Spatial index of the source segments (rebuilt for every offset, since the cell size depends on it) 83 private double gridCell; 84 private double gridMinX; 85 private double gridMinY; 86 private int gridCols; 87 private int gridRows; 88 private int[][] gridCells; 89 private int[] gridStamp; 90 private int gridQuery; 91 92 // Result of the last changeOffset call 93 private List<EastNorth> resultPts = Collections.emptyList(); 94 private int[] resultPieceSeg = new int[0]; 95 private int[] resultPointNode = new int[0]; 96 private boolean resultClosed; 97 98 private List<Way> ways = Collections.emptyList(); 40 99 41 100 /** 42 101 * Constructs a new {@code ParallelWays}. 43 102 * @param sourceWays source ways 44 103 * @param copyTags whether tags should be copied 45 104 * @param refWayIndex Need a reference way to determine the direction of the offset when we manage multiple ways 105 * @throws IllegalArgumentException if the ways do not form a branchless path 46 106 */ 47 107 public ParallelWays(Collection<Way> sourceWays, boolean copyTags, int refWayIndex) { 48 // Possible/sensible to use PrimitiveDeepCopy here? 49 50 // Make a deep copy of the ways, keeping the copied ways connected 51 // TODO: This assumes the first/last nodes of the ways are the only possible shared nodes. 52 Map<Node, Node> splitNodeMap = new HashMap<>(Utils.hashMapInitialCapacity(sourceWays.size())); 53 for (Way w : sourceWays) { 54 copyNodeInMap(splitNodeMap, w.firstNode(), copyTags); 55 copyNodeInMap(splitNodeMap, w.lastNode(), copyTags); 56 } 57 ways = new ArrayList<>(sourceWays.size()); 58 for (Way w : sourceWays) { 59 Way wCopy = new Way(); 60 wCopy.addNode(splitNodeMap.get(w.firstNode())); 61 for (int i = 1; i < w.getNodesCount() - 1; i++) { 62 wCopy.addNode(copyNode(w.getNode(i), copyTags)); 63 } 64 wCopy.addNode(splitNodeMap.get(w.lastNode())); 65 if (copyTags) { 66 wCopy.setKeys(w.getKeys()); 67 } 68 ways.add(wCopy); 69 } 108 this(sourceWays, copyTags, refWayIndex, DEFAULT_ARC_STEP_DEGREES); 109 } 110 111 /** 112 * Constructs a new {@code ParallelWays}. 113 * @param sourceWays source ways 114 * @param copyTags whether tags should be copied 115 * @param refWayIndex Need a reference way to determine the direction of the offset when we manage multiple ways 116 * @param arcStepDegrees angular step (in degrees) of the chords approximating arcs at convex corners 117 * @throws IllegalArgumentException if the ways do not form a branchless path 118 * @since xxx 119 */ 120 public ParallelWays(Collection<Way> sourceWays, boolean copyTags, int refWayIndex, double arcStepDegrees) { 121 this.sourceWays = new ArrayList<>(sourceWays); 122 this.copyTags = copyTags; 123 this.arcStep = Math.toRadians(Math.max(1, Math.min(90, arcStepDegrees))); 70 124 71 125 // Find a linear ordering of the nodes. Fail if there isn't one. 72 NodeGraph nodeGraph = NodeGraph.createUndirectedGraphFromNodeWays( ways);126 NodeGraph nodeGraph = NodeGraph.createUndirectedGraphFromNodeWays(this.sourceWays); 73 127 List<Node> sortedNodesPath = nodeGraph.buildSpanningPath(); 74 if (sortedNodesPath == null )128 if (sortedNodesPath == null || sortedNodesPath.size() < 2) 75 129 throw new IllegalArgumentException("Ways must have spanning path"); // Create a dedicated exception? 76 130 77 // Fix #8631 - Remove duplicated nodes from graph to be robust with self-intersecting ways 78 Set<Node> removedNodes = new HashSet<>(); 79 sortedNodes = new ArrayList<>(); 80 for (int i = 0; i < sortedNodesPath.size(); i++) { 81 Node n = sortedNodesPath.get(i); 82 if (i < sortedNodesPath.size()-1 && sortedNodesPath.get(i+1).getCoor().equals(n.getCoor())) { 83 removedNodes.add(n); 84 for (Way w : ways) { 85 w.removeNode(n); 86 } 87 continue; 88 } 89 if (!removedNodes.contains(n)) { 90 sortedNodes.add(n); 131 List<Node> nodes = new ArrayList<>(sortedNodesPath.size()); 132 for (Node n : sortedNodesPath) { 133 if (nodes.isEmpty() || !nodes.get(nodes.size() - 1).getEastNorth().equalsEpsilon(n.getEastNorth(), 1e-9)) { 134 nodes.add(n); 91 135 } 92 136 } 137 closed = nodes.size() > 2 && nodes.get(0) == nodes.get(nodes.size() - 1); 138 if (closed) { 139 nodes.remove(nodes.size() - 1); 140 } 141 if (nodes.size() < 2) 142 throw new IllegalArgumentException("Ways must have spanning path"); 93 143 94 // Ugly method of ensuring that the offset isn't inverted. I'm sure there is a better and more elegant way 95 Way refWay = ways.get(refWayIndex); 96 boolean refWayReversed = IntStream.range(0, sortedNodes.size() - 1) 97 .noneMatch(i -> sortedNodes.get(i) == refWay.firstNode() && sortedNodes.get(i + 1) == refWay.getNode(1)); 98 if (refWayReversed) { 99 Collections.reverse(sortedNodes); // need to keep the orientation of the reference way. 144 Way refWay = this.sourceWays.get(refWayIndex); 145 if (!isForward(nodes, refWay, closed)) { 146 Collections.reverse(nodes); // need to keep the orientation of the reference way. 147 } 148 if (closed) { 149 // rotate so that the path starts at a way boundary: every way is then a contiguous run of nodes 150 Node start = this.sourceWays.stream().map(Way::firstNode).filter(nodes::contains).findFirst().orElse(nodes.get(0)); 151 Collections.rotate(nodes, -nodes.indexOf(start)); 152 nodes.add(nodes.get(0)); 100 153 } 154 sortedNodes = nodes; 155 nodeCount = nodes.size(); 101 156 102 // Initialize the required parameters. (segment normals, etc.) 103 nodeCount = sortedNodes.size(); 104 pts = new EastNorth[nodeCount]; 105 normals = new EastNorth[nodeCount - 1]; 106 int i = 0; 107 for (Node n : sortedNodes) { 108 EastNorth t = n.getEastNorth(); 109 pts[i] = t; 110 i++; 157 // Initialize the required parameters. (segment directions, etc.) 158 px = new double[nodeCount]; 159 py = new double[nodeCount]; 160 for (int i = 0; i < nodeCount; i++) { 161 EastNorth en = nodes.get(i).getEastNorth(); 162 px[i] = en.getX(); 163 py[i] = en.getY(); 164 } 165 int segCount = nodeCount - 1; 166 dirX = new double[segCount]; 167 dirY = new double[segCount]; 168 segLen = new double[segCount]; 169 segMinX = new double[segCount]; 170 segMinY = new double[segCount]; 171 segMaxX = new double[segCount]; 172 segMaxY = new double[segCount]; 173 for (int i = 0; i < segCount; i++) { 174 double dx = px[i + 1] - px[i]; 175 double dy = py[i + 1] - py[i]; 176 double len = Math.hypot(dx, dy); 177 segLen[i] = len; 178 dirX[i] = dx / len; 179 dirY[i] = dy / len; 180 segMinX[i] = Math.min(px[i], px[i + 1]); 181 segMaxX[i] = Math.max(px[i], px[i + 1]); 182 segMinY[i] = Math.min(py[i], py[i + 1]); 183 segMaxY[i] = Math.max(py[i], py[i + 1]); 111 184 } 112 for (i = 0; i < nodeCount - 1; i++) { 113 double dx = pts[i + 1].getX() - pts[i].getX(); 114 double dy = pts[i + 1].getY() - pts[i].getY(); 115 double len = Math.sqrt(dx * dx + dy * dy); 116 normals[i] = new EastNorth(-dy / len, dx / len); 185 186 // Map the source ways onto the path 187 int wayCount = this.sourceWays.size(); 188 wayForward = new boolean[wayCount]; 189 segWay = new int[segCount]; 190 Arrays.fill(segWay, -1); 191 for (int w = 0; w < wayCount; w++) { 192 Way way = this.sourceWays.get(w); 193 Set<Node> wayNodes = new HashSet<>(way.getNodes()); 194 // indices (without the closing duplicate) of the path nodes belonging to this way 195 boolean[] present = new boolean[nodeCount]; 196 int first = -1; 197 int last = -1; 198 for (int i = 0; i < nodeCount - (closed ? 1 : 0); i++) { 199 if (wayNodes.contains(sortedNodes.get(i))) { 200 present[i] = true; 201 if (first < 0) { 202 first = i; 203 } 204 last = i; 205 } 206 } 207 if (first < 0) { 208 first = 0; 209 last = 0; 210 } else if (closed && present[0] && present[nodeCount - 2]) { 211 // the run of this way wraps around the closing node: find where it starts 212 int i = nodeCount - 2; 213 while (i > 0 && present[i - 1]) { 214 i--; 215 } 216 first = i; 217 last = nodeCount - 1; 218 } 219 wayForward[w] = isForward(sortedNodes.subList(first, last + 1), way, false); 220 for (int i = first; i < last; i++) { 221 segWay[i] = w; 222 } 117 223 } 118 224 } 119 225 120 private static void copyNodeInMap(Map<Node, Node> splitNodeMap, Node node, boolean copyTags) { 121 if (!splitNodeMap.containsKey(node)) { 122 splitNodeMap.put(node, copyNode(node, copyTags)); 226 /** 227 * Checks whether a way runs in the same direction as a node list. 228 * @param path the node list 229 * @param way the way 230 * @param cyclic whether the node list is a ring (without repeated closing node) 231 * @return {@code true} if the first segment of the way, found in the path, has the same orientation 232 */ 233 private static boolean isForward(List<Node> path, Way way, boolean cyclic) { 234 int n = path.size(); 235 for (int k = 0; k < way.getNodesCount() - 1; k++) { 236 int a = path.indexOf(way.getNode(k)); 237 int b = path.indexOf(way.getNode(k + 1)); 238 if (a >= 0 && b >= 0 && a != b) { 239 if (cyclic) { 240 return ((b - a) % n + n) % n < n / 2.0; 241 } 242 return b > a; 243 } 123 244 } 245 return true; 124 246 } 125 247 126 248 /** … … public class ParallelWays { 128 250 * @return {@code true} if the nodes graph form a closed path 129 251 */ 130 252 public boolean isClosedPath() { 131 return sortedNodes.get(0) == sortedNodes.get(sortedNodes.size() - 1);253 return closed; 132 254 } 133 255 134 256 /** … … public class ParallelWays { 136 258 * @param d offset 137 259 */ 138 260 public void changeOffset(double d) { 139 // This is the core algorithm: 140 /* 1. Calculate a parallel line, offset by 'd', to each segment in the path 141 * 2. Find the intersection of lines belonging to neighboring segments. These become the new node positions 142 * 3. Do some special casing for closed paths 143 * 144 * Simple and probably not even close to optimal performance wise 145 */ 146 147 EastNorth[] ppts = new EastNorth[nodeCount]; 148 149 EastNorth prevA = pts[0].add(normals[0].scale(d)); 150 EastNorth prevB = pts[1].add(normals[0].scale(d)); 151 for (int i = 1; i < nodeCount - 1; i++) { 152 EastNorth a = pts[i].add(normals[i].scale(d)); 153 EastNorth b = pts[i + 1].add(normals[i].scale(d)); 154 if (Geometry.segmentsParallel(a, b, prevA, prevB)) { 155 ppts[i] = a; 156 } else { 157 ppts[i] = Geometry.getLineLineIntersection(a, b, prevA, prevB); 261 if (d == 0 || Double.isNaN(d)) { 262 // no offset: the result is a copy (for a ring without the repeated closing node) 263 int pointCount = closed ? nodeCount - 1 : nodeCount; 264 resultPts = new ArrayList<>(pointCount); 265 for (int i = 0; i < pointCount; i++) { 266 resultPts.add(new EastNorth(px[i], py[i])); 267 } 268 resultPieceSeg = IntStream.range(0, nodeCount - 1).toArray(); 269 resultPointNode = IntStream.range(0, pointCount).toArray(); 270 resultClosed = closed; 271 return; 272 } 273 buildGrid(Math.abs(d)); 274 RawPolyline raw = buildRawOffset(d); 275 trim(raw, Math.abs(d)); 276 } 277 278 /** 279 * Builds a uniform grid over the source segments, with a cell size of (at least) r, so that all segments 280 * within distance r of a point are found in the 3x3 cells around it. 281 * @param r the (absolute) offset 282 */ 283 private void buildGrid(double r) { 284 double minX = Double.POSITIVE_INFINITY; 285 double minY = Double.POSITIVE_INFINITY; 286 double maxX = Double.NEGATIVE_INFINITY; 287 double maxY = Double.NEGATIVE_INFINITY; 288 for (int i = 0; i < nodeCount; i++) { 289 minX = Math.min(minX, px[i]); 290 maxX = Math.max(maxX, px[i]); 291 minY = Math.min(minY, py[i]); 292 maxY = Math.max(maxY, py[i]); 293 } 294 double extent = Math.max(maxX - minX, maxY - minY); 295 gridCell = Math.max(r, extent / 128); 296 gridMinX = minX; 297 gridMinY = minY; 298 gridCols = (int) ((maxX - minX) / gridCell) + 1; 299 gridRows = (int) ((maxY - minY) / gridCell) + 1; 300 int[] counts = new int[gridCols * gridRows]; 301 int segCount = nodeCount - 1; 302 for (int i = 0; i < segCount; i++) { 303 forEachCell(segMinX[i], segMinY[i], segMaxX[i], segMaxY[i], c -> counts[c]++); 304 } 305 gridCells = new int[counts.length][]; 306 for (int c = 0; c < counts.length; c++) { 307 gridCells[c] = new int[counts[c]]; 308 counts[c] = 0; 309 } 310 for (int i = 0; i < segCount; i++) { 311 final int seg = i; 312 forEachCell(segMinX[i], segMinY[i], segMaxX[i], segMaxY[i], c -> gridCells[c][counts[c]++] = seg); 313 } 314 gridStamp = new int[segCount]; 315 gridQuery = 0; 316 } 317 318 private void forEachCell(double minX, double minY, double maxX, double maxY, IntConsumer consumer) { 319 int c0 = Math.max(0, (int) ((minX - gridMinX) / gridCell)); 320 int c1 = Math.min(gridCols - 1, (int) ((maxX - gridMinX) / gridCell)); 321 int r0 = Math.max(0, (int) ((minY - gridMinY) / gridCell)); 322 int r1 = Math.min(gridRows - 1, (int) ((maxY - gridMinY) / gridCell)); 323 for (int row = r0; row <= r1; row++) { 324 for (int col = c0; col <= c1; col++) { 325 consumer.accept(row * gridCols + col); 326 } 327 } 328 } 329 330 // --------------------------------------------------------------------------------------------------------- 331 // Step 1: raw offset polyline 332 333 /** 334 * The raw (untrimmed) offset polyline. Segment k runs from point k to point k+1 (or to point 0 for the last 335 * segment of a closed polyline). The attributes of a segment are stored at its end point. 336 */ 337 private static final class RawPolyline { 338 double[] x = new double[64]; 339 double[] y = new double[64]; 340 /** index of the source node a point was derived from, or {@link #NO_NODE} */ 341 int[] node = new int[64]; 342 /** source segment index of the segment ending at this point, or {@link #CAP} */ 343 int[] seg = new int[64]; 344 /** if the segment ending at this point is an arc chord: the source node the arc is centered on, else NO_NODE */ 345 int[] arcCenter = new int[64]; 346 int size; 347 348 /** for each source node with an arc: raw index of the first arc point, else -1 */ 349 final int[] arcStart; 350 /** number of chords of the arc at each source node */ 351 final int[] arcChords; 352 /** whether the arc can be replaced by a mitre when it is not affected by the trimming */ 353 final boolean[] arcMitre; 354 final double[] mitreX; 355 final double[] mitreY; 356 357 RawPolyline(int nodeCount) { 358 arcStart = new int[nodeCount]; 359 Arrays.fill(arcStart, -1); 360 arcChords = new int[nodeCount]; 361 arcMitre = new boolean[nodeCount]; 362 mitreX = new double[nodeCount]; 363 mitreY = new double[nodeCount]; 364 } 365 366 void add(double px, double py, int srcNode, int srcSeg, int arc) { 367 if (size == x.length) { 368 int n = size * 2; 369 x = Arrays.copyOf(x, n); 370 y = Arrays.copyOf(y, n); 371 node = Arrays.copyOf(node, n); 372 seg = Arrays.copyOf(seg, n); 373 arcCenter = Arrays.copyOf(arcCenter, n); 158 374 } 159 prevA = a; 160 prevB = b; 375 x[size] = px; 376 y[size] = py; 377 node[size] = srcNode; 378 seg[size] = srcSeg; 379 arcCenter[size] = arc; 380 size++; 381 } 382 } 383 384 private RawPolyline buildRawOffset(double d) { 385 RawPolyline raw = new RawPolyline(nodeCount); 386 int segCount = nodeCount - 1; 387 if (closed) { 388 addJoin(raw, 0, segCount - 1, 0, d); 389 } else { 390 // Start cap: half circle on the back side of the first node. It is never part of the result, but 391 // trims pieces of the offset which come closer than d to the first node. 392 addCap(raw, 0, Math.atan2(-dirX[0] * d, dirY[0] * d), d > 0 ? -1 : 1, d, true); 393 raw.add(px[0] - dirY[0] * d, py[0] + dirX[0] * d, 0, CAP, NO_NODE); 394 } 395 for (int k = 1; k < segCount; k++) { 396 addJoin(raw, k, k - 1, k, d); 397 } 398 if (!closed) { 399 int s = segCount - 1; 400 raw.add(px[s + 1] - dirY[s] * d, py[s + 1] + dirX[s] * d, s + 1, s, NO_NODE); 401 // End cap: half circle on the front side of the last node 402 addCap(raw, s + 1, Math.atan2(dirX[s] * d, -dirY[s] * d), d > 0 ? -1 : 1, d, false); 403 } else { 404 // the closing segment ends at raw point 0 405 raw.seg[0] = segCount - 1; 406 raw.arcCenter[0] = NO_NODE; 407 } 408 return raw; 409 } 410 411 /** 412 * Adds the chords of a half circle around node {@code k}, starting at the given angle. The chords are marked 413 * as {@link #CAP}: they are only used to trim other pieces. 414 * @param raw the raw polyline 415 * @param k source node index 416 * @param startAngle angle of the first point of the half circle 417 * @param direction rotation direction (+1 counter clockwise) 418 * @param d the offset 419 * @param leading {@code true} if the cap precedes the offset path (the end point of the half circle is then 420 * added by the caller), {@code false} if it follows it (the start point has been added by the caller) 421 */ 422 private void addCap(RawPolyline raw, int k, double startAngle, int direction, double d, boolean leading) { 423 double r = Math.abs(d); 424 int steps = (int) Math.ceil(Math.PI / arcStep - 1e-9); 425 for (int j = leading ? 0 : 1; j < (leading ? steps : steps + 1); j++) { 426 double angle = startAngle + direction * Math.PI * j / steps; 427 raw.add(px[k] + r * Math.cos(angle), py[k] + r * Math.sin(angle), NO_NODE, CAP, NO_NODE); 428 } 429 } 430 431 /** 432 * Adds the offset points at vertex {@code k}, where segment {@code prev} ends and segment {@code next} starts. 433 * @param raw the raw polyline 434 * @param k source node index 435 * @param prev index of the segment ending at k 436 * @param next index of the segment starting at k 437 * @param d the offset 438 */ 439 private void addJoin(RawPolyline raw, int k, int prev, int next, double d) { 440 double r = Math.abs(d); 441 // offset end point of prev, offset start point of next 442 double bx = px[k] - dirY[prev] * d; 443 double by = py[k] + dirX[prev] * d; 444 double ax = px[k] - dirY[next] * d; 445 double ay = py[k] + dirX[next] * d; 446 447 double cross = dirX[prev] * dirY[next] - dirY[prev] * dirX[next]; 448 double dot = dirX[prev] * dirX[next] + dirY[prev] * dirY[next]; 449 double theta = Math.atan2(Math.abs(cross), dot); // turn angle in [0, pi] 450 451 if (theta < 1e-9) { 452 // collinear: the offset points coincide 453 raw.add(bx, by, k, prev, NO_NODE); 454 return; 161 455 } 162 if (isClosedPath()) { 163 EastNorth a = pts[0].add(normals[0].scale(d)); 164 EastNorth b = pts[1].add(normals[0].scale(d)); 165 if (Geometry.segmentsParallel(a, b, prevA, prevB)) { 166 ppts[0] = a; 456 boolean concave = cross * d > 0; // the turn goes towards the offset side 457 if (concave && theta < Math.PI - 1e-9) { 458 // Clip: intersect the two offset segments. 459 // X = b + t*dir[prev] with -len[prev] <= t <= 0 and X = a + s*dir[next] with 0 <= s <= len[next] 460 double ex = ax - bx; 461 double ey = ay - by; 462 double t = (ex * dirY[next] - ey * dirX[next]) / cross; 463 double s = (ex * dirY[prev] - ey * dirX[prev]) / cross; 464 if (-t <= segLen[prev] && s <= segLen[next] && t <= 1e-9 && s >= -1e-9) { 465 raw.add(bx + t * dirX[prev], by + t * dirY[prev], k, prev, NO_NODE); 167 466 } else { 168 ppts[0] = Geometry.getLineLineIntersection(a, b, prevA, prevB); 467 // The offset is larger than the neighbouring segments allow: local inversion. 468 // Emit both points; the inverted part is removed when trimming. 469 raw.add(bx, by, k, prev, NO_NODE); 470 raw.add(ax, ay, NO_NODE, next, k); 169 471 } 170 ppts[nodeCount - 1] = ppts[0]; 472 return; 473 } 474 // Convex corner (or a hairpin): arc from b to a around the vertex. If the arc survives the trimming 475 // untouched, it may later be replaced by a mitre (for gentle corners, see arcMitre). 476 raw.add(bx, by, k, prev, NO_NODE); 477 int steps = (int) Math.ceil(theta / arcStep - 1e-9); 478 double angle0 = Math.atan2(by - py[k], bx - px[k]); 479 double delta; 480 if (Math.abs(cross) < 1e-12) { 481 // hairpin: rotate away from the segments, i.e. towards dir[prev] 482 delta = theta * (d > 0 ? -1 : 1); 171 483 } else { 172 ppts[0] = pts[0].add(normals[0].scale(d)); 173 ppts[nodeCount - 1] = pts[nodeCount - 1].add(normals[nodeCount - 2].scale(d)); 484 delta = theta * (cross >= 0 ? 1 : -1); 485 } 486 raw.arcStart[k] = raw.size - 1; 487 raw.arcChords[k] = steps; 488 for (int j = 1; j < steps; j++) { 489 double angle = angle0 + delta * j / steps; 490 raw.add(px[k] + r * Math.cos(angle), py[k] + r * Math.sin(angle), NO_NODE, next, k); 174 491 } 492 raw.add(ax, ay, NO_NODE, next, k); 175 493 176 for (int i = 0; i < nodeCount; i++) { 177 sortedNodes.get(i).setEastNorth(ppts[i]); 494 if (theta < Math.PI - 1e-6) { 495 double mitreDist = r / Math.cos(theta / 2); 496 double overshoot = mitreDist - r; 497 raw.arcMitre[k] = theta <= arcStep || overshoot <= 0.5 * Math.min(segLen[prev], segLen[next]); 498 // the mitre point lies on the bisector of the two normals 499 double mx = (bx - px[k]) + (ax - px[k]); 500 double my = (by - py[k]) + (ay - py[k]); 501 double ml = Math.hypot(mx, my); 502 raw.mitreX[k] = px[k] + mx / ml * mitreDist; 503 raw.mitreY[k] = py[k] + my / ml * mitreDist; 504 } 505 } 506 507 // --------------------------------------------------------------------------------------------------------- 508 // Step 2: trimming 509 510 /** Growable list of pieces (sub segments of the raw polyline) */ 511 private static final class Pieces { 512 int[] start = new int[256]; 513 int[] end = new int[256]; 514 int[] rawSeg = new int[256]; 515 boolean[] valid = new boolean[256]; 516 int size; 517 518 void add(int s, int e, int seg, boolean v) { 519 if (size == start.length) { 520 int n = size * 2; 521 start = Arrays.copyOf(start, n); 522 end = Arrays.copyOf(end, n); 523 rawSeg = Arrays.copyOf(rawSeg, n); 524 valid = Arrays.copyOf(valid, n); 525 } 526 start[size] = s; 527 end[size] = e; 528 rawSeg[size] = seg; 529 valid[size] = v; 530 size++; 178 531 } 179 532 } 180 533 534 /** Point table used while trimming: raw points first, then intersection and transition points */ 535 private static final class Points { 536 final List<double[]> xy = new ArrayList<>(); 537 final List<Integer> node = new ArrayList<>(); 538 539 int add(double x, double y, int srcNode) { 540 xy.add(new double[] {x, y}); 541 node.add(srcNode); 542 return xy.size() - 1; 543 } 544 545 double[] get(int id) { 546 return xy.get(id); 547 } 548 549 double distance(int a, int b) { 550 double[] p = xy.get(a); 551 double[] q = xy.get(b); 552 return Math.hypot(q[0] - p[0], q[1] - p[1]); 553 } 554 } 555 556 private void trim(RawPolyline raw, double r) { 557 int m = raw.size; 558 int rawSegCount = closed ? m : m - 1; 559 if (rawSegCount < 1) { 560 setEmptyResult(); 561 return; 562 } 563 564 Points points = new Points(); 565 for (int i = 0; i < m; i++) { 566 points.add(raw.x[i], raw.y[i], raw.node[i]); 567 } 568 // Raw segments which lie completely inside the offset distance ("deep") can neither be part of the result 569 // nor trim it: skip them when looking for intersections. This is a huge saving for large offsets. 570 boolean[] deep = new boolean[rawSegCount]; 571 double tolerance = r * 1e-7 + 1e-7; 572 double sagitta = r * (1 - Math.cos(arcStep / 2)); 573 for (int i = 0; i < rawSegCount; i++) { 574 int endPoint = (i + 1) % m; 575 double mx = (raw.x[i] + raw.x[endPoint]) / 2; 576 double my = (raw.y[i] + raw.y[endPoint]) / 2; 577 double halfLen = Math.hypot(raw.x[endPoint] - raw.x[i], raw.y[endPoint] - raw.y[i]) / 2; 578 // every point of the segment is within halfLen (+ sagitta for a chord) of the middle 579 double cutoff = r - halfLen - tolerance - (raw.arcCenter[endPoint] != NO_NODE ? sagitta : 0); 580 deep[i] = cutoff > 0 && distanceToSource(mx, my, cutoff) < cutoff; 581 } 582 583 // breakpoints per raw segment: parallel lists of (param, pointId) 584 List<List<double[]>> breaks = new ArrayList<>(Collections.nCopies(rawSegCount, null)); 585 findSelfIntersections(raw, rawSegCount, deep, points, breaks); 586 587 // Build the pieces and determine their validity: a piece must lie at (at least) distance r from the source 588 Pieces pieces = new Pieces(); 589 boolean anyInvalid = false; 590 for (int i = 0; i < rawSegCount; i++) { 591 int endPoint = (i + 1) % m; 592 if (deep[i]) { 593 pieces.add(i, endPoint, i, false); 594 continue; 595 } 596 List<double[]> b = breaks.get(i); 597 int prevId = i; 598 if (b != null) { 599 b.sort((o1, o2) -> Double.compare(o1[0], o2[0])); 600 for (double[] bp : b) { 601 classify(raw, r, points, pieces, i, prevId, (int) bp[1]); 602 prevId = (int) bp[1]; 603 } 604 } 605 classify(raw, r, points, pieces, i, prevId, endPoint); 606 } 607 for (int p = 0; p < pieces.size; p++) { 608 anyInvalid |= !pieces.valid[p]; 609 } 610 611 // Chain the valid pieces. Consecutive valid pieces normally meet at a common point (self-intersection or 612 // transition). Chord approximations of arcs can leave small gaps - such gaps are bridged; larger gaps 613 // separate different components (e.g. inner loops). 614 double bridgeTolerance = 2 * r * Math.sin(arcStep / 2); 615 double spikeTolerance = 2 * r * (1 - Math.cos(arcStep / 2)) + tolerance; 616 List<Chain> chains = new ArrayList<>(); 617 int pieceCount = pieces.size; 618 int startPiece = 0; 619 if (closed && anyInvalid) { 620 while (pieces.valid[startPiece]) { 621 startPiece++; 622 } 623 startPiece = (startPiece + 1) % pieceCount; 624 } 625 Chain current = null; 626 for (int c = 0; c < pieceCount; c++) { 627 int p = (startPiece + c) % pieceCount; 628 if (!pieces.valid[p]) { 629 continue; 630 } 631 int srcSeg = raw.seg[(pieces.rawSeg[p] + 1) % m]; 632 if (current != null && current.lastId != pieces.start[p]) { 633 if (points.distance(current.lastId, pieces.start[p]) <= bridgeTolerance) { 634 current.append(pieces.start[p], srcSeg, points); 635 } else { 636 chains.add(current); 637 current = null; 638 } 639 } 640 if (current == null) { 641 current = new Chain(pieces.start[p], spikeTolerance); 642 } 643 current.append(pieces.end[p], srcSeg, points); 644 } 645 if (current != null) { 646 chains.add(current); 647 } 648 if (closed && chains.size() > 1) { 649 Chain first = chains.get(0); 650 Chain last = chains.get(chains.size() - 1); 651 int firstId = first.ids.get(0); 652 if (last.lastId == firstId || points.distance(last.lastId, firstId) <= bridgeTolerance) { 653 if (last.lastId != firstId) { 654 last.append(firstId, first.segs.get(0), points); 655 } 656 last.appendChain(first); 657 chains.remove(0); 658 } 659 } 660 if (chains.isEmpty()) { 661 setEmptyResult(); 662 return; 663 } 664 Chain best = chains.get(0); 665 for (Chain ch : chains) { 666 if (ch.length > best.length) { 667 best = ch; 668 } 669 } 670 if (closed && best.lastId != best.ids.get(0) && points.distance(best.lastId, best.ids.get(0)) <= bridgeTolerance) { 671 // close a ring which is open by a small gap only 672 best.append(best.ids.get(0), best.segs.get(best.segs.size() - 1), points); 673 } 674 675 // Convert to the result 676 boolean isRing = closed && best.ids.size() > 2 && best.ids.get(0) == best.lastId; 677 List<Integer> ids = new ArrayList<>(best.ids); 678 List<Integer> segs = new ArrayList<>(best.segs); 679 replaceIntactArcsByMitres(raw, points, ids, segs); 680 if (isRing) { 681 ids.remove(ids.size() - 1); 682 // rotate so that the ring starts at a way boundary 683 int n = segs.size(); 684 int rot = 0; 685 for (int i = 0; i < n; i++) { 686 if (segWay[segs.get((i + n - 1) % n)] != segWay[segs.get(i)]) { 687 rot = i; 688 break; 689 } 690 } 691 Collections.rotate(ids, -rot); 692 Collections.rotate(segs, -rot); 693 } 694 resultPts = new ArrayList<>(ids.size()); 695 resultPointNode = new int[ids.size()]; 696 for (int i = 0; i < ids.size(); i++) { 697 int id = ids.get(i); 698 double[] pt = points.get(id); 699 resultPts.add(new EastNorth(pt[0], pt[1])); 700 resultPointNode[i] = points.node.get(id); 701 } 702 resultPieceSeg = segs.stream().mapToInt(Integer::intValue).toArray(); 703 resultClosed = isRing; 704 } 705 706 private void setEmptyResult() { 707 resultPts = Collections.emptyList(); 708 resultPieceSeg = new int[0]; 709 resultPointNode = new int[0]; 710 resultClosed = false; 711 } 712 713 /** 714 * Determines the validity of the piece of raw segment {@code i} between two points, and adds the resulting 715 * piece(s). The validity is sampled at both ends and in the middle; if it changes, the transition point is 716 * located and the piece is split there. 717 */ 718 private void classify(RawPolyline raw, double r, Points points, Pieces pieces, int i, int sId, int eId) { 719 int endPoint = (i + 1) % raw.size; 720 if (raw.seg[endPoint] == CAP) { 721 pieces.add(sId, eId, i, false); // end caps are never part of the result 722 return; 723 } 724 int arc = raw.arcCenter[endPoint]; 725 double[] s = points.get(sId); 726 double[] e = points.get(eId); 727 double tolerance = r * 1e-7 + 1e-7; 728 boolean vs = isValid(s[0], s[1], r, tolerance, arc); 729 boolean ve = isValid(e[0], e[1], r, tolerance, arc); 730 boolean vm = isValid(s[0] + (e[0] - s[0]) / 2, s[1] + (e[1] - s[1]) / 2, r, tolerance, arc); 731 if (vs == vm && vm == ve) { 732 pieces.add(sId, eId, i, vm); 733 return; 734 } 735 // Transitions very close to an end point are artefacts of the chord approximation (a point where two 736 // chords cross lies inside both circles): the validity of the middle is then used for the whole piece. 737 double snapTolerance = 2 * r * (1 - Math.cos(arcStep / 2)) + tolerance; 738 double len = Math.hypot(e[0] - s[0], e[1] - s[1]); 739 int prev = sId; 740 boolean state = vs; 741 if (vs != vm) { 742 double t1 = locateTransition(s, e, 0, 0.5, r, tolerance, arc); 743 if (t1 * len <= snapTolerance) { 744 state = vm; 745 } else { 746 int id = points.add(s[0] + (e[0] - s[0]) * t1, s[1] + (e[1] - s[1]) * t1, NO_NODE); 747 pieces.add(prev, id, i, state); 748 prev = id; 749 state = vm; 750 } 751 } 752 if (vm != ve) { 753 double t2 = locateTransition(s, e, 0.5, 1, r, tolerance, arc); 754 if ((1 - t2) * len > snapTolerance) { 755 int id = points.add(s[0] + (e[0] - s[0]) * t2, s[1] + (e[1] - s[1]) * t2, NO_NODE); 756 pieces.add(prev, id, i, state); 757 prev = id; 758 state = ve; 759 } 760 } 761 pieces.add(prev, eId, i, state); 762 } 763 764 /** 765 * Locates (by bisection) the parameter between {@code a} and {@code b} where the validity changes. 766 * @return the parameter of the transition 767 */ 768 private double locateTransition(double[] s, double[] e, double a, double b, double r, double tolerance, int arc) { 769 boolean va = isValid(s[0] + (e[0] - s[0]) * a, s[1] + (e[1] - s[1]) * a, r, tolerance, arc); 770 for (int it = 0; it < 40 && b - a > 1e-12; it++) { 771 double mid = (a + b) / 2; 772 boolean vmid = isValid(s[0] + (e[0] - s[0]) * mid, s[1] + (e[1] - s[1]) * mid, r, tolerance, arc); 773 if (vmid == va) { 774 a = mid; 775 } else { 776 b = mid; 777 } 778 } 779 return (a + b) / 2; 780 } 781 181 782 /** 182 * Performs the action by adding a new sequence command to the undo/redo queue. 783 * Checks whether a point of the raw polyline lies at (at least) distance r from the source path. 784 * @param x point 785 * @param y point 786 * @param r offset 787 * @param tolerance allowed deficit 788 * @param arc if the point lies on an arc chord: the center node of the arc; the point is then projected onto 789 * the arc before testing, else {@link #NO_NODE} 790 * @return {@code true} if the point is valid 791 */ 792 private boolean isValid(double x, double y, double r, double tolerance, int arc) { 793 if (arc != NO_NODE) { 794 double vx = x - px[arc]; 795 double vy = y - py[arc]; 796 double vl = Math.hypot(vx, vy); 797 if (vl > 0) { 798 x = px[arc] + vx / vl * r; 799 y = py[arc] + vy / vl * r; 800 } 801 } 802 return distanceToSource(x, y, r - tolerance) >= r - tolerance; 803 } 804 805 /** 806 * Replaces arcs which are completely part of the result by a mitre (where the corner is gentle enough). 807 */ 808 private static void replaceIntactArcsByMitres(RawPolyline raw, Points points, List<Integer> ids, List<Integer> segs) { 809 int[] arcOfRawPoint = new int[raw.size]; 810 Arrays.fill(arcOfRawPoint, NO_NODE); 811 for (int k = 0; k < raw.arcStart.length; k++) { 812 if (raw.arcStart[k] >= 0 && raw.arcMitre[k]) { 813 arcOfRawPoint[raw.arcStart[k]] = k; 814 } 815 } 816 for (int i = 0; i < ids.size(); i++) { 817 int id = ids.get(i); 818 if (id >= raw.size || arcOfRawPoint[id] == NO_NODE) { 819 continue; 820 } 821 int k = arcOfRawPoint[id]; 822 int chords = raw.arcChords[k]; 823 if (i + chords >= ids.size()) { 824 continue; 825 } 826 boolean intact = true; 827 for (int j = 1; j <= chords && intact; j++) { 828 intact = ids.get(i + j) == id + j; 829 } 830 if (!intact) { 831 continue; 832 } 833 int mitreId = points.add(raw.mitreX[k], raw.mitreY[k], k); 834 // the chord pieces i..i+chords-1 collapse into two pieces: (i-1 -> mitre), (mitre -> i+chords) 835 ids.set(i, mitreId); 836 ids.subList(i + 1, i + chords + 1).clear(); 837 // segs.get(j) belongs to the piece ending at ids.get(j+1); keep the segment of the last chord for the 838 // piece leaving the mitre, and drop the others 839 segs.subList(i, i + chords).clear(); 840 } 841 } 842 843 /** A chain of connected valid pieces */ 844 private static final class Chain { 845 final List<Integer> ids = new ArrayList<>(); 846 final List<Integer> segs = new ArrayList<>(); 847 final double spikeTolerance; 848 int lastId; 849 double length; 850 851 Chain(int startId, double spikeTolerance) { 852 ids.add(startId); 853 lastId = startId; 854 this.spikeTolerance = spikeTolerance; 855 } 856 857 void append(int endId, int srcSeg, Points points) { 858 lastId = endId; 859 if (ids.size() >= 2 && points.distance(ids.get(ids.size() - 2), endId) <= spikeTolerance) { 860 // tiny out-and-back spike (an artefact of the chord approximation at a crossing): drop its tip 861 int tip = ids.remove(ids.size() - 1); 862 segs.remove(segs.size() - 1); 863 length -= points.distance(ids.get(ids.size() - 1), tip); 864 } 865 double len = points.distance(ids.get(ids.size() - 1), endId); 866 if (len < 1e-9) { 867 // zero length piece: keep the connectivity, but don't add a point 868 return; 869 } 870 length += len; 871 ids.add(endId); 872 segs.add(srcSeg); 873 } 874 875 void appendChain(Chain other) { 876 ids.addAll(other.ids.subList(1, other.ids.size())); 877 segs.addAll(other.segs); 878 lastId = other.lastId; 879 length += other.length; 880 } 881 } 882 883 /** 884 * Finds all intersections between non adjacent segments of the raw polyline (sweep on x). 885 * @param raw the raw polyline 886 * @param rawSegCount number of raw segments 887 * @param skip raw segments to ignore 888 * @param points point table, intersection points are appended 889 * @param breaks per raw segment list of (param, pointId), filled 890 */ 891 private void findSelfIntersections(RawPolyline raw, int rawSegCount, boolean[] skip, Points points, 892 List<List<double[]>> breaks) { 893 int m = raw.size; 894 double[] minX = new double[rawSegCount]; 895 double[] maxX = new double[rawSegCount]; 896 double[] minY = new double[rawSegCount]; 897 double[] maxY = new double[rawSegCount]; 898 int count = 0; 899 Integer[] order = new Integer[rawSegCount]; 900 for (int i = 0; i < rawSegCount; i++) { 901 if (skip[i]) { 902 continue; 903 } 904 int j = (i + 1) % m; 905 minX[i] = Math.min(raw.x[i], raw.x[j]); 906 maxX[i] = Math.max(raw.x[i], raw.x[j]); 907 minY[i] = Math.min(raw.y[i], raw.y[j]); 908 maxY[i] = Math.max(raw.y[i], raw.y[j]); 909 order[count++] = i; 910 } 911 Arrays.sort(order, 0, count, (a, b) -> Double.compare(minX[a], minX[b])); 912 double[] uv = new double[2]; 913 for (int oi = 0; oi < count; oi++) { 914 int i = order[oi]; 915 for (int oj = oi + 1; oj < count; oj++) { 916 int j = order[oj]; 917 if (minX[j] > maxX[i]) { 918 break; 919 } 920 if (minY[j] > maxY[i] || maxY[j] < minY[i]) { 921 continue; 922 } 923 int lo = Math.min(i, j); 924 int hi = Math.max(i, j); 925 if (hi - lo == 1 || (closed && lo == 0 && hi == rawSegCount - 1)) { 926 continue; // adjacent 927 } 928 int i2 = (i + 1) % m; 929 int j2 = (j + 1) % m; 930 if (segmentIntersection(raw.x[i], raw.y[i], raw.x[i2], raw.y[i2], raw.x[j], raw.y[j], raw.x[j2], raw.y[j2], uv)) { 931 int id = points.add(raw.x[i] + (raw.x[i2] - raw.x[i]) * uv[0], raw.y[i] + (raw.y[i2] - raw.y[i]) * uv[0], NO_NODE); 932 if (breaks.get(i) == null) { 933 breaks.set(i, new ArrayList<>(2)); 934 } 935 if (breaks.get(j) == null) { 936 breaks.set(j, new ArrayList<>(2)); 937 } 938 breaks.get(i).add(new double[] {uv[0], id}); 939 breaks.get(j).add(new double[] {uv[1], id}); 940 } 941 } 942 } 943 } 944 945 /** 946 * Segment/segment intersection. 947 * @param uv output: parameters along the first and the second segment 948 * @return true if the segments intersect (touching end points count as intersection) 949 */ 950 private static boolean segmentIntersection(double x1, double y1, double x2, double y2, 951 double x3, double y3, double x4, double y4, double[] uv) { 952 double a1 = x2 - x1; 953 double a2 = y2 - y1; 954 double b1 = x3 - x4; 955 double b2 = y3 - y4; 956 double c1 = x3 - x1; 957 double c2 = y3 - y1; 958 double det = a1 * b2 - a2 * b1; 959 double uu = b2 * c1 - b1 * c2; 960 double vv = a1 * c2 - a2 * c1; 961 double mag = Math.abs(uu) + Math.abs(vv); 962 if (det == 0 || Math.abs(det) <= 1e-12 * mag) { 963 return false; // parallel or collinear 964 } 965 double u = uu / det; 966 double v = vv / det; 967 if (u < -1e-9 || u > 1 + 1e-9 || v < -1e-9 || v > 1 + 1e-9) { 968 return false; 969 } 970 uv[0] = Math.max(0, Math.min(1, u)); 971 uv[1] = Math.max(0, Math.min(1, v)); 972 return true; 973 } 974 975 /** 976 * Distance from a point to the source path. 977 * @param x point 978 * @param y point 979 * @param cutoff the search can stop as soon as a distance below this value is found 980 * @return the distance (or any value below cutoff if such a distance exists) 981 */ 982 private double distanceToSource(double x, double y, double cutoff) { 983 // Only segments within the cutoff matter (the result is only compared against it); with a cell size of 984 // at least the offset they all lie in the 3x3 cells around the point. 985 int col = (int) Math.floor((x - gridMinX) / gridCell); 986 int row = (int) Math.floor((y - gridMinY) / gridCell); 987 if (col < -1 || col > gridCols || row < -1 || row > gridRows) { 988 return Double.POSITIVE_INFINITY; 989 } 990 gridQuery++; 991 double best = Double.POSITIVE_INFINITY; 992 double bestSq = Double.POSITIVE_INFINITY; 993 double cutoffSq = cutoff * cutoff; 994 for (int rr = Math.max(0, row - 1); rr <= Math.min(gridRows - 1, row + 1); rr++) { 995 for (int cc = Math.max(0, col - 1); cc <= Math.min(gridCols - 1, col + 1); cc++) { 996 for (int i : gridCells[rr * gridCols + cc]) { 997 if (gridStamp[i] == gridQuery) { 998 continue; 999 } 1000 gridStamp[i] = gridQuery; 1001 if (x < segMinX[i] - best || x > segMaxX[i] + best || y < segMinY[i] - best || y > segMaxY[i] + best) { 1002 continue; 1003 } 1004 double rx = x - px[i]; 1005 double ry = y - py[i]; 1006 double t = rx * dirX[i] + ry * dirY[i]; 1007 double dSq; 1008 if (t <= 0) { 1009 dSq = rx * rx + ry * ry; 1010 } else if (t >= segLen[i]) { 1011 double ex = x - px[i + 1]; 1012 double ey = y - py[i + 1]; 1013 dSq = ex * ex + ey * ey; 1014 } else { 1015 double c = rx * dirY[i] - ry * dirX[i]; 1016 dSq = c * c; 1017 } 1018 if (dSq < bestSq) { 1019 bestSq = dSq; 1020 best = Math.sqrt(dSq); 1021 if (bestSq < cutoffSq) { 1022 return best; 1023 } 1024 } 1025 } 1026 } 1027 } 1028 return best; 1029 } 1030 1031 // --------------------------------------------------------------------------------------------------------- 1032 // Result access and commit 1033 1034 /** 1035 * Returns the points of the offset path computed by the last call to {@link #changeOffset(double)}. 1036 * For a closed result the first point is not repeated at the end, see {@link #isResultClosed()}. 1037 * @return the offset points (projected coordinates), empty if nothing has been computed or nothing remains 1038 * @since xxx 1039 */ 1040 public List<EastNorth> getOffsetPoints() { 1041 return Collections.unmodifiableList(resultPts); 1042 } 1043 1044 /** 1045 * Determines if the result of the last {@link #changeOffset(double)} call is a closed ring. 1046 * @return {@code true} if the offset path is a closed ring 1047 * @since xxx 1048 */ 1049 public boolean isResultClosed() { 1050 return resultClosed; 1051 } 1052 1053 /** 1054 * Creates the nodes and ways of the offset path (as computed by the last call to {@link #changeOffset(double)}), 1055 * and adds them to the edit data set by adding a new sequence command to the undo/redo queue. 1056 * <p> 1057 * Does nothing if there is no offset path. 183 1058 */ 184 1059 public void commit() { 185 UndoRedoHandler.getInstance().add(new SequenceCommand("Make parallel way(s)", makeAddWayAndNodesCommandList())); 1060 List<Command> commands = makeAddWayAndNodesCommandList(); 1061 if (!commands.isEmpty()) { 1062 UndoRedoHandler.getInstance().add(new SequenceCommand("Make parallel way(s)", commands)); 1063 } 186 1064 } 187 1065 188 1066 private List<Command> makeAddWayAndNodesCommandList() { 189 1067 DataSet ds = OsmDataManager.getInstance().getEditDataSet(); 190 191 List<Command> commands = new ArrayList<>(sortedNodes.size() + ways.size()); 192 Set<Node> dupCheck = new HashSet<>(); 193 for (int i = 0; i < sortedNodes.size() - (isClosedPath() ? 1 : 0); i++) { 194 Node n = sortedNodes.get(i); 195 // don't add the same node twice, see #18386 196 if (dupCheck.add(n)) { 197 commands.add(new AddCommand(ds, n)); 1068 List<Way> newWays = buildWays(); 1069 ways = newWays; 1070 List<Command> commands = new ArrayList<>(); 1071 if (newWays.isEmpty()) { 1072 return commands; 1073 } 1074 List<Node> added = new ArrayList<>(); 1075 for (Way w : newWays) { 1076 for (Node n : w.getNodes()) { 1077 // don't add the same node twice, see #18386 1078 if (!added.contains(n)) { 1079 added.add(n); 1080 commands.add(new AddCommand(ds, n)); 1081 } 198 1082 } 199 1083 } 200 for (Way w : ways) {1084 for (Way w : newWays) { 201 1085 commands.add(new AddCommand(ds, w)); 202 1086 } 203 1087 return commands; 204 1088 } 205 1089 206 private static Node copyNode(Node source, boolean copyTags) { 207 if (copyTags) 208 return new Node(source, true); 209 else { 210 Node n = new Node(); 211 n.setCoor(source.getCoor()); 212 return n; 1090 /** 1091 * Builds the (not yet added) ways from the last computed offset path. 1092 * @return the ways, in the order of the source ways; ways swallowed by the offset are omitted 1093 */ 1094 private List<Way> buildWays() { 1095 int pointCount = resultPts.size(); 1096 List<Way> result = new ArrayList<>(sourceWays.size()); 1097 if (pointCount < 2) { 1098 return result; 1099 } 1100 int pieceCount = resultPieceSeg.length; 1101 Node[] nodes = new Node[pointCount]; 1102 for (int w = 0; w < sourceWays.size(); w++) { 1103 int first = -1; 1104 int last = -1; 1105 for (int p = 0; p < pieceCount; p++) { 1106 if (segWay[resultPieceSeg[p]] == w) { 1107 if (first < 0) { 1108 first = p; 1109 } 1110 last = p; 1111 } 1112 } 1113 if (first < 0) { 1114 continue; // way is swallowed by the offset 1115 } 1116 List<Node> wayNodes = new ArrayList<>(last - first + 2); 1117 for (int p = first; p <= last + 1; p++) { 1118 int idx = p % pointCount; 1119 if (nodes[idx] == null) { 1120 nodes[idx] = makeNode(idx); 1121 } 1122 wayNodes.add(nodes[idx]); 1123 } 1124 if (!wayForward[w]) { 1125 Collections.reverse(wayNodes); 1126 } 1127 Way source = sourceWays.get(w); 1128 Way copy = new Way(); 1129 copy.setNodes(wayNodes); 1130 if (copyTags) { 1131 copy.setKeys(source.getKeys()); 1132 } 1133 result.add(copy); 1134 } 1135 return result; 1136 } 1137 1138 private Node makeNode(int idx) { 1139 Node n; 1140 int src = resultPointNode[idx]; 1141 if (copyTags && src != NO_NODE) { 1142 n = new Node(sortedNodes.get(src), true); 1143 } else { 1144 n = new Node(); 213 1145 } 1146 n.setEastNorth(resultPts.get(idx)); 1147 return n; 214 1148 } 215 1149 216 1150 /** 217 * Returns the resulting parallel ways .218 * @return the resulting parallel ways 1151 * Returns the resulting parallel ways, available after {@link #commit()}. 1152 * @return the resulting parallel ways (empty before commit) 219 1153 */ 220 1154 public final List<Way> getWays() { 221 1155 return ways; -
new file test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java
diff --git test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java new file mode 100644 index 0000000000..d3197bfd9c
- + 1 // License: GPL. For details, see LICENSE file. 2 package org.openstreetmap.josm.actions.mapmode; 3 4 import static org.junit.jupiter.api.Assertions.assertEquals; 5 import static org.junit.jupiter.api.Assertions.assertFalse; 6 import static org.junit.jupiter.api.Assertions.assertNotSame; 7 import static org.junit.jupiter.api.Assertions.assertSame; 8 import static org.junit.jupiter.api.Assertions.assertTrue; 9 10 import java.util.Arrays; 11 import java.util.List; 12 import java.util.Random; 13 14 import org.junit.jupiter.api.Test; 15 import org.openstreetmap.josm.data.UndoRedoHandler; 16 import org.openstreetmap.josm.data.coor.EastNorth; 17 import org.openstreetmap.josm.data.osm.DataSet; 18 import org.openstreetmap.josm.data.osm.Node; 19 import org.openstreetmap.josm.data.osm.Way; 20 import org.openstreetmap.josm.gui.MainApplication; 21 import org.openstreetmap.josm.gui.layer.OsmDataLayer; 22 import org.openstreetmap.josm.testutils.annotations.Main; 23 import org.openstreetmap.josm.testutils.annotations.Projection; 24 import org.openstreetmap.josm.tools.Geometry; 25 26 /** 27 * Unit tests for class {@link ParallelWays}. 28 */ 29 @Main 30 @Projection 31 class ParallelWaysTest { 32 33 private static Node node(double x, double y) { 34 Node n = new Node(); 35 n.setEastNorth(new EastNorth(x, y)); 36 return n; 37 } 38 39 private static Way way(Node... nodes) { 40 Way w = new Way(); 41 w.setNodes(Arrays.asList(nodes)); 42 return w; 43 } 44 45 private static Way way(double... xy) { 46 Node[] nodes = new Node[xy.length / 2]; 47 for (int i = 0; i < nodes.length; i++) { 48 nodes[i] = node(xy[2 * i], xy[2 * i + 1]); 49 } 50 return way(nodes); 51 } 52 53 private static Way closedWay(double... xy) { 54 Way w = way(xy); 55 w.addNode(w.firstNode()); 56 return w; 57 } 58 59 private static void assertContains(List<EastNorth> pts, double x, double y) { 60 EastNorth expected = new EastNorth(x, y); 61 assertTrue(pts.stream().anyMatch(p -> p.equalsEpsilon(expected, 1e-6)), pts.toString()); 62 } 63 64 private static double distanceToWay(Way w, EastNorth p) { 65 double best = Double.POSITIVE_INFINITY; 66 for (int i = 0; i < w.getNodesCount() - 1; i++) { 67 EastNorth c = Geometry.closestPointToSegment(w.getNode(i).getEastNorth(), w.getNode(i + 1).getEastNorth(), p); 68 best = Math.min(best, c.distance(p)); 69 } 70 return best; 71 } 72 73 private static double maxSegmentLength(Way w) { 74 double max = 0; 75 for (int i = 0; i < w.getNodesCount() - 1; i++) { 76 max = Math.max(max, w.getNode(i).getEastNorth().distance(w.getNode(i + 1).getEastNorth())); 77 } 78 return max; 79 } 80 81 /** 82 * Checks the basic invariants of a parallel: every vertex is at least the offset away from the source, 83 * and never much further than the offset (the mitre may overshoot by at most half a segment length). 84 * @param source the source way 85 * @param pts the parallel 86 * @param r the (absolute) offset 87 */ 88 private static void assertParallelInvariants(Way source, List<EastNorth> pts, double r) { 89 assertVertexDistances(source, pts, r); 90 // no self intersections 91 for (int i = 0; i < pts.size() - 1; i++) { 92 for (int j = i + 2; j < pts.size() - 1; j++) { 93 EastNorth x = Geometry.getSegmentSegmentIntersection(pts.get(i), pts.get(i + 1), pts.get(j), pts.get(j + 1)); 94 assertTrue(x == null, "self intersection between segments " + i + " and " + j + " at " + x); 95 } 96 } 97 } 98 99 /** 100 * Checks that every vertex is at least the offset away from the source (a point on an arc chord may be 101 * closer by the sagitta of the chord), and never much further than the offset (the mitre may overshoot by at 102 * most half a segment length). 103 * @param source the source way 104 * @param pts the parallel 105 * @param r the (absolute) offset 106 */ 107 private static void assertVertexDistances(Way source, List<EastNorth> pts, double r) { 108 double maxOvershoot = 0.5 * maxSegmentLength(source); 109 // a point where two chords cross may lie inside both circles, i.e. up to two sagittas inside 110 double minDist = r * (1 - 2 * (1 - Math.cos(Math.toRadians(ParallelWays.DEFAULT_ARC_STEP_DEGREES / 2)))) * (1 - 1e-6); 111 for (EastNorth p : pts) { 112 double dist = distanceToWay(source, p); 113 assertTrue(dist >= minDist, "vertex " + p + " too close to source: " + dist + " < " + r); 114 assertTrue(dist <= r + maxOvershoot + 1e-6, "vertex " + p + " too far from source: " + dist + " > " + r); 115 } 116 } 117 118 /** 119 * A small offset of a simple way must keep the node count 120 */ 121 @Test 122 void testSimpleOffset() { 123 Way w = way(0, 0, 100, 0, 200, 0); 124 ParallelWays pw = new ParallelWays(Arrays.asList(w), false, 0); 125 assertFalse(pw.isClosedPath()); 126 pw.changeOffset(10); 127 List<EastNorth> pts = pw.getOffsetPoints(); 128 assertEquals(3, pts.size()); 129 assertEquals(new EastNorth(0, 10), pts.get(0)); 130 assertEquals(new EastNorth(100, 10), pts.get(1)); 131 assertEquals(new EastNorth(200, 10), pts.get(2)); 132 pw.changeOffset(-10); 133 pts = pw.getOffsetPoints(); 134 assertEquals(new EastNorth(0, -10), pts.get(0)); 135 assertEquals(new EastNorth(200, -10), pts.get(2)); 136 } 137 138 /** 139 * Small offsets of a closed way: mitre outside, clipped inside 140 */ 141 @Test 142 void testSquare() { 143 Way square = closedWay(0, 0, 100, 0, 100, 100, 0, 100); // counter clockwise: left is inside 144 ParallelWays pw = new ParallelWays(Arrays.asList(square), false, 0); 145 assertTrue(pw.isClosedPath()); 146 147 pw.changeOffset(-10); // outside 148 List<EastNorth> pts = pw.getOffsetPoints(); 149 assertTrue(pw.isResultClosed()); 150 assertEquals(4, pts.size()); 151 assertParallelInvariants(square, pts, 10); 152 assertContains(pts, -10, -10); 153 assertContains(pts, 110, 110); 154 155 pw.changeOffset(10); // inside 156 pts = pw.getOffsetPoints(); 157 assertTrue(pw.isResultClosed()); 158 assertEquals(4, pts.size()); 159 assertParallelInvariants(square, pts, 10); 160 assertContains(pts, 10, 10); 161 assertContains(pts, 90, 90); 162 163 pw.changeOffset(60); // inside, larger than the square 164 assertTrue(pw.getOffsetPoints().isEmpty()); 165 } 166 167 /** 168 * A large offset of a closed way is a ring with arcs at the corners 169 */ 170 @Test 171 void testSquareLargeOffset() { 172 Way square = closedWay(0, 0, 100, 0, 100, 100, 0, 100); 173 ParallelWays pw = new ParallelWays(Arrays.asList(square), false, 0, 10); 174 pw.changeOffset(-1000); 175 List<EastNorth> pts = pw.getOffsetPoints(); 176 assertTrue(pw.isResultClosed()); 177 // 4 corners, each an arc of 90° in 9 chords -> 10 points per corner 178 assertEquals(40, pts.size(), pts.toString()); 179 assertParallelInvariants(square, pts, 1000); 180 for (EastNorth p : pts) { 181 assertEquals(1000, distanceToWay(square, p), 1e-6); 182 } 183 } 184 185 /** 186 * Offsets larger than the local radius of curvature must not produce spikes or loops (the use case 187 * of a maritime boundary 22 km off a coastline). 188 */ 189 @Test 190 void testLargeOffsetSawtooth() { 191 double[] xy = new double[2 * 41]; 192 for (int i = 0; i <= 40; i++) { 193 xy[2 * i] = i * 200; 194 xy[2 * i + 1] = (i % 2 == 0 ? 0 : 150) + 30 * Math.sin(i); 195 } 196 Way coast = way(xy); 197 ParallelWays pw = new ParallelWays(Arrays.asList(coast), false, 0); 198 for (double d : new double[] {10, -10, 300, -300, 5000, -5000, 22000, -22000}) { 199 pw.changeOffset(d); 200 List<EastNorth> pts = pw.getOffsetPoints(); 201 assertTrue(pts.size() >= 2, "d=" + d); 202 assertFalse(pw.isResultClosed()); 203 assertParallelInvariants(coast, pts, Math.abs(d)); 204 // the result must span the whole source 205 assertTrue(pts.get(0).getX() < Math.abs(d) + 100, "d=" + d + ": " + pts.get(0)); 206 assertTrue(pts.get(pts.size() - 1).getX() > 8000 - Math.abs(d) - 100, "d=" + d + ": " + pts.get(pts.size() - 1)); 207 } 208 } 209 210 /** 211 * A random "coastline" with many nodes: the offset must be correct and fast enough for interactive use. 212 */ 213 @Test 214 void testLargeOffsetRandomCoastline() { 215 Random rnd = new Random(42); 216 int count = 1600; 217 double[] xy = new double[2 * count]; 218 double x = 0; 219 double y = 0; 220 double heading = 0; 221 for (int i = 0; i < count; i++) { 222 xy[2 * i] = x; 223 xy[2 * i + 1] = y; 224 heading += (rnd.nextDouble() - 0.5) * 2.5; 225 double len = 50 + rnd.nextDouble() * 300; 226 x += Math.cos(heading) * len; 227 y += Math.sin(heading) * len; 228 } 229 Way coast = way(xy); 230 ParallelWays pw = new ParallelWays(Arrays.asList(coast), false, 0); 231 for (double d : new double[] {50, -50, 2000, -2000, 22224, -22224}) { 232 long start = System.nanoTime(); 233 pw.changeOffset(d); 234 long millis = (System.nanoTime() - start) / 1_000_000; 235 List<EastNorth> pts = pw.getOffsetPoints(); 236 assertTrue(pts.size() >= 2, "d=" + d); 237 assertVertexDistances(coast, pts, Math.abs(d)); 238 assertTrue(millis < 5000, "offset took " + millis + " ms"); 239 System.out.println("ParallelWays: " + count + " nodes, d=" + d + " -> " + pts.size() + " points in " + millis + " ms"); 240 } 241 } 242 243 /** 244 * Commit of multiple ways: the ways stay connected, keep their direction and their tags. 245 */ 246 @Test 247 void testCommitMultipleWays() { 248 DataSet ds = new DataSet(); 249 OsmDataLayer layer = new OsmDataLayer(ds, "ParallelWaysTest", null); 250 MainApplication.getLayerManager().addLayer(layer); 251 try { 252 Node shared = node(100, 0); 253 Way w1 = way(node(0, 0), shared); 254 w1.put("highway", "primary"); 255 Way w2 = way(node(200, 0), node(150, 0), shared); // reversed direction 256 w2.put("highway", "secondary"); 257 ds.addPrimitive(w1.getNode(0)); 258 ds.addPrimitive(shared); 259 ds.addPrimitive(w2.getNode(0)); 260 ds.addPrimitive(w2.getNode(1)); 261 ds.addPrimitive(w1); 262 ds.addPrimitive(w2); 263 264 ParallelWays pw = new ParallelWays(Arrays.asList(w1, w2), true, 0); 265 pw.changeOffset(10); 266 assertTrue(pw.getWays().isEmpty()); 267 pw.commit(); 268 List<Way> ways = pw.getWays(); 269 assertEquals(2, ways.size()); 270 Way p1 = ways.get(0); 271 Way p2 = ways.get(1); 272 assertEquals("primary", p1.get("highway")); 273 assertEquals("secondary", p2.get("highway")); 274 assertEquals(2, p1.getNodesCount()); 275 assertEquals(3, p2.getNodesCount()); 276 assertSame(p1.lastNode(), p2.lastNode()); 277 assertNotSame(shared, p1.lastNode()); 278 assertEquals(new EastNorth(0, 10), p1.firstNode().getEastNorth()); 279 assertEquals(new EastNorth(100, 10), p1.lastNode().getEastNorth()); 280 assertEquals(new EastNorth(200, 10), p2.firstNode().getEastNorth()); 281 assertEquals(new EastNorth(150, 10), p2.getNode(1).getEastNorth()); 282 assertEquals(6 + 4 + 2, ds.allPrimitives().size()); 283 assertSame(ds, p1.getDataSet()); 284 UndoRedoHandler.getInstance().undo(); 285 assertEquals(6, ds.allPrimitives().size()); 286 } finally { 287 MainApplication.getLayerManager().removeLayer(layer); 288 } 289 } 290 291 /** 292 * A closed ring made of several ways 293 */ 294 @Test 295 void testClosedRingOfWays() { 296 Node a = node(0, 0); 297 Node b = node(100, 0); 298 Node c = node(100, 100); 299 Node d = node(0, 100); 300 Way w1 = way(a, b, c); 301 Way w2 = way(c, d, a); 302 ParallelWays pw = new ParallelWays(Arrays.asList(w1, w2), false, 0); 303 assertTrue(pw.isClosedPath()); 304 pw.changeOffset(-10); 305 List<EastNorth> pts = pw.getOffsetPoints(); 306 assertTrue(pw.isResultClosed()); 307 assertEquals(4, pts.size()); 308 assertContains(pts, -10, -10); 309 assertContains(pts, 110, 110); 310 } 311 }
