1 /* 2 * Copyright 2006 Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.google.common.geometry; 18 19 import com.google.common.collect.Lists; 20 21 import java.util.List; 22 import java.util.logging.Logger; 23 24 /** 25 * Tests for {@link S2Loop}. 26 * 27 */ 28 public strictfp class S2PolygonBuilderTest extends GeometryTestCase { 29 private static final Logger log = Logger.getLogger(S2PolygonBuilderTest.class.getCanonicalName()); 30 31 // A chain represents either a polyline or a loop, depending 32 // on whether "closed" is true. 33 private class Chain { 34 String str; 35 boolean closed; 36 Chain(String str, boolean closed)37 public Chain(String str, boolean closed) { 38 this.str = str; 39 this.closed = closed; 40 } 41 } 42 43 private class TestCase { 44 // +1 = undirected, -1 = directed, 0 = either one 45 int undirectedEdges; 46 47 // +1 = XOR, -1 = don't XOR, 0 = either one 48 int xorEdges; 49 50 // Minimum and maximum merge distances for this test case in degrees. 51 double minMerge; 52 double maxMerge; 53 54 // Each test case consists of a set of input loops and polylines. 55 Chain[] chainsIn; 56 57 // The expected set of output loops, directed appropriately. 58 String[] loopsOut; 59 60 // The expected number of unused edges. 61 int numUnusedEdges; 62 TestCase(int undirectedEdges, int xorEdges, double minMerge, double maxMerge, Chain[] chainsIn, String[] loopsOut, int numUnusedEdges)63 public TestCase(int undirectedEdges, 64 int xorEdges, 65 double minMerge, 66 double maxMerge, 67 Chain[] chainsIn, 68 String[] loopsOut, 69 int numUnusedEdges) { 70 this.undirectedEdges = undirectedEdges; 71 this.xorEdges = xorEdges; 72 this.minMerge = minMerge; 73 this.maxMerge = maxMerge; 74 this.chainsIn = chainsIn; 75 this.loopsOut = loopsOut; 76 this.numUnusedEdges = numUnusedEdges; 77 } 78 } 79 80 TestCase[] testCases = new TestCase[] { 81 // 0: No loops. 82 new TestCase(0, 0, 0.0, 10.0, new Chain[] {new Chain(null, false)}, new String[] {}, 0), 83 84 // 1: One loop with some extra edges. 85 new TestCase(0, 86 0, 87 0.0, 88 4.0, 89 new Chain[] {new Chain("0:0, 0:10, 10:5", true), new Chain("0:0, 5:5", false), 90 new Chain("10:5, 20:7, 30:10, 40:15, 50:3, 60:-20", false)}, 91 new String[] {"0:0, 0:10, 10:5"}, 92 6), 93 94 // 2: One loop that has an edge removed by XORing, plus lots of 95 // extra edges. 96 new TestCase(0, 1, 0.0, 1.0, // XOR 97 new Chain[] {new Chain("0:0, 0:10, 5:15, 10:10, 10:0", true), 98 new Chain("10:10, 12:12, 14:14, 16:16, 18:18", false), 99 new Chain("14:14, 14:16, 14:18, 14:20", false), 100 new Chain("14:18, 16:20, 18:22", false), 101 new Chain("18:12, 16:12, 14:12, 12:12", false), 102 new Chain("20:18, 18:16, 16:14, 14:12", false), 103 new Chain("20:14, 18:14, 16:14", false), 104 new Chain("5:15, 0:10", false)}, 105 new String[] {}, 106 21), 107 108 // 3: Three loops (two shells and one hole) that combine into one. 109 new TestCase(0, 1, 0.0, 4.0, // XOR 110 new Chain[] {new Chain("0:0, 0:10, 5:10, 10:10, 10:5, 10:0", true), 111 new Chain("0:10, 0:15, 5:15, 5:10", true), 112 new Chain("10:10, 5:10, 5:5, 10:5", true), }, 113 new String[] {"0:0, 0:10, 0:15, 5:15, 5:10, 5:5, 10:5, 10:0"}, 114 0), 115 116 // 4: A big CCW triangle contained 3 CW triangular holes. The whole thing 117 // looks like a pyramid of nine small triangles (with two extra edges). 118 new TestCase(-1, 0, 0.0, 0.9, // Directed edges required for unique result. 119 new Chain[] {new Chain("0:0, 0:2, 0:4, 0:6, 1:5, 2:4, 3:3, 2:2, 1:1", true), 120 new Chain("0:2, 1:1, 1:3", true), 121 new Chain("0:4, 1:3, 1:5", true), 122 new Chain("1:3, 2:2, 2:4", true), 123 new Chain("0:0, 0:1", false), 124 new Chain("1:3, 5:7", false)}, 125 new String[] {"0:0, 0:2, 1:1", 126 "0:2, 0:4, 1:3", 127 "0:4, 0:6, 1:5", 128 "1:1, 1:3, 2:2", 129 "1:3, 1:5, 2:4", 130 "2:2, 2:4, 3:3"}, 131 2), 132 133 // 5: A square divided into four subsquares. In this case we want 134 // to extract the four loops rather than taking their union. 135 // There are four extra edges as well. 136 new TestCase(0, -1, 0.0, 4.0, // Don't XOR 137 new Chain[] {new Chain("0:0, 0:5, 5:5, 5:0", true), 138 new Chain("0:5, 0:10, 5:10, 5:5", true), 139 new Chain("5:0, 5:5, 10:5, 10:0", true), 140 new Chain("5:5, 5:10, 10:10, 10:5", true), 141 new Chain("0:10, 0:15, 0:20", false), 142 new Chain("20:0, 15:0, 10:0", false)}, 143 new String[] {"0:0, 0:5, 5:5, 5:0", "0:5, 0:10, 5:10, 5:5", "5:0, 5:5, 10:5, 10:0", 144 "5:5, 5:10, 10:10, 10:5"}, 145 4), 146 147 // 6: Five nested loops that touch at a point. 148 new TestCase(0, 149 0, 150 0.0, 151 0.8, 152 new Chain[] {new Chain("0:0, 0:10, 10:10, 10:0", true), 153 new Chain("0:0, 1:9, 9:9, 9:1", true), new Chain("0:0, 2:8, 8:8, 8:2", true), 154 new Chain("0:0, 3:7, 7:7, 7:3", true), new Chain("0:0, 4:6, 6:6, 6:4", true)}, 155 new String[] {"0:0, 0:10, 10:10, 10:0", "0:0, 1:9, 9:9, 9:1", "0:0, 2:8, 8:8, 8:2", 156 "0:0, 3:7, 7:7, 7:3", "0:0, 4:6, 6:6, 6:4"}, 157 0), 158 159 160 // 7: Four diamonds nested within each other touching at two points. 161 new TestCase(-1, 0, 0.0, 4.0, // Directed edges required for unique result. 162 new Chain[] {new Chain("0:-20, -10:0, 0:20, 10:0", true), 163 new Chain("0:10, -10:0, 0:-10, 10:0", true), 164 new Chain("0:-10, -5:0, 0:10, 5:0", true), new Chain("0:5, -5:0, 0:-5, 5:0", true)}, 165 new String[] {"0:-20, -10:0, 0:-10, 10:0", "0:-10, -5:0, 0:-5, 5:0", 166 "0:5, -5:0, 0:10, 5:0", "0:10, -10:0, 0:20, 10:0"}, 167 0), 168 169 // 8: Seven diamonds nested within each other touching at one 170 // point between each nested pair. 171 new TestCase(0, 172 0, 173 0.0, 174 9.0, 175 new Chain[] {new Chain("0:-70, -70:0, 0:70, 70:0", true), 176 new Chain("0:-70, -60:0, 0:60, 60:0", true), 177 new Chain("0:-50, -60:0, 0:50, 50:0", true), 178 new Chain("0:-40, -40:0, 0:50, 40:0", true), 179 new Chain("0:-30, -30:0, 0:30, 40:0", true), 180 new Chain("0:-20, -20:0, 0:30, 20:0", true), 181 new Chain("0:-10, -20:0, 0:10, 10:0", true)}, 182 new String[] {"0:-70, -70:0, 0:70, 70:0", 183 "0:-70, -60:0, 0:60, 60:0", 184 "0:-50, -60:0, 0:50, 50:0", 185 "0:-40, -40:0, 0:50, 40:0", 186 "0:-30, -30:0, 0:30, 40:0", 187 "0:-20, -20:0, 0:30, 20:0", 188 "0:-10, -20:0, 0:10, 10:0"}, 189 0), 190 191 // 9: A triangle and a self-intersecting bowtie. 192 new TestCase(0, 193 0, 194 0.0, 195 4.0, 196 new Chain[] {new Chain("0:0, 0:10, 5:5", true), new Chain("0:20, 0:30, 10:20", false), 197 new Chain("10:20, 10:30, 0:20", false)}, 198 new String[] {"0:0, 0:10, 5:5"}, 199 4), 200 201 // 10: Two triangles that intersect each other. 202 new TestCase(0, 203 0, 204 0.0, 205 2.0, 206 new Chain[] {new Chain("0:0, 0:10, 5:5", true), new Chain("2:2, 2:12, 7:7", true)}, 207 new String[] {}, 208 6), 209 210 // 11: Four squares that combine to make a big square. The nominal 211 // edges of the square are at +/-8.5 degrees in latitude and longitude. 212 // All vertices except the center vertex are perturbed by up to 0.5 213 // degrees in latitude and/or longitude. The various copies of the 214 // center vertex are misaligned by more than this (i.e. they are 215 // structured as a tree where adjacent vertices are separated by at 216 // most 1 degree in latitude and/or longitude) so that the clustering 217 // algorithm needs more than one iteration to find them all. Note that 218 // the merged position of this vertex doesn't matter because it is XORed 219 // away in the output. 220 new TestCase(0, 1, 1.5, 5.8, // XOR, min_merge > sqrt(2), max_merge < 6. 221 new Chain[] {new Chain("-8:-8, -8:0", false), 222 new Chain("-8:1, -8:8", false), 223 new Chain("0:-9, -2:0", false), 224 new Chain("-1:1, 1:9", false), 225 new Chain("0:8, 2:2", false), 226 new Chain("0:-2, 1:-8", false), 227 new Chain("8:9, 9:1", false), 228 new Chain("9:0, 8:-9", false), 229 new Chain("9:-9, 0:-8", false), 230 new Chain("1:-9, -9:-9", false), 231 new Chain("8:0, 1:0", false), 232 new Chain("1:2, -8:0", false), 233 new Chain("-8:1, 1:-1", false), 234 new Chain("0:1, 8:1", false), 235 new Chain("-9:8, 1:8", false), 236 new Chain("0:9, 8:8", false)}, 237 new String[] {"8.5:8.5, 8.5:0.5, 8.5:-8.5, 0.5:-8.5, " 238 + "-8.5:-8.5, -8.5:0.5, -8.5:8.5, 0.5:8.5"}, 239 0)}; 240 getVertices(String str, S2Point x, S2Point y, S2Point z, double maxPerturbation, List<S2Point> vertices)241 private void getVertices(String str, 242 S2Point x, 243 S2Point y, 244 S2Point z, 245 double maxPerturbation, 246 List<S2Point> vertices) { 247 248 // Parse the vertices, perturb them if desired, and transform them into the 249 // given frame. 250 S2Polyline line = makePolyline(str); 251 252 for (int i = 0; i < line.numVertices(); ++i) { 253 S2Point p = line.vertex(i); 254 // (p[0]*x + p[1]*y + p[2]*z).Normalize() 255 S2Point axis = S2Point.normalize( 256 S2Point.add(S2Point.add(S2Point.mul(x, p.x), S2Point.mul(y, p.y)), S2Point.mul(z, p.z))); 257 S2Cap cap = S2Cap.fromAxisAngle(axis, S1Angle.radians(maxPerturbation)); 258 vertices.add(samplePoint(cap)); 259 } 260 } 261 loopsEqual(S2Loop a, S2Loop b, double maxError)262 private boolean loopsEqual(S2Loop a, S2Loop b, double maxError) { 263 // Return true if two loops have the same cyclic vertex sequence. 264 265 if (a.numVertices() != b.numVertices()) { 266 return false; 267 } 268 for (int offset = 0; offset < a.numVertices(); ++offset) { 269 if (S2.approxEquals(a.vertex(offset), b.vertex(0), maxError)) { 270 boolean success = true; 271 for (int i = 0; i < a.numVertices(); ++i) { 272 if (!S2.approxEquals(a.vertex(i + offset), b.vertex(i), maxError)) { 273 success = false; 274 break; 275 } 276 } 277 if (success) { 278 return true; 279 } 280 // Otherwise continue looping. There may be more than one candidate 281 // starting offset since vertices are only matched approximately. 282 } 283 } 284 return false; 285 } 286 findLoop(S2Loop loop, List<S2Loop> candidates, double maxError)287 private boolean findLoop(S2Loop loop, List<S2Loop> candidates, double maxError) { 288 for (int i = 0; i < candidates.size(); ++i) { 289 if (loopsEqual(loop, candidates.get(i), maxError)) { 290 return true; 291 } 292 } 293 return false; 294 } 295 findMissingLoops( List<S2Loop> actual, List<S2Loop> expected, double maxError, String label)296 boolean findMissingLoops( 297 List<S2Loop> actual, List<S2Loop> expected, double maxError, String label) { 298 // Dump any loops from "actual" that are not present in "expected". 299 boolean found = false; 300 for (int i = 0; i < actual.size(); ++i) { 301 if (findLoop(actual.get(i), expected, maxError)) { 302 continue; 303 } 304 System.err.print(label + " loop " + i + ":\n"); 305 S2Loop loop = actual.get(i); 306 for (int j = 0; j < loop.numVertices(); ++j) { 307 S2Point p = loop.vertex(j); 308 System.err.print(" [" + p.x + ", " + p.y + ", " + p.z + "]\n"); 309 } 310 found = true; 311 } 312 return found; 313 } 314 addChain(Chain chain, S2Point x, S2Point y, S2Point z, double maxPerturbation, S2PolygonBuilder builder)315 void addChain(Chain chain, 316 S2Point x, 317 S2Point y, 318 S2Point z, 319 double maxPerturbation, 320 S2PolygonBuilder builder) { 321 322 // Transform the given edge chain to the frame (x,y,z), perturb each vertex 323 // up to the given distance, and add it to the builder. 324 325 List<S2Point> vertices = Lists.newArrayList(); 326 getVertices(chain.str, x, y, z, maxPerturbation, vertices); 327 if (chain.closed) { 328 vertices.add(vertices.get(0)); 329 } 330 for (int i = 1; i < vertices.size(); ++i) { 331 builder.addEdge(vertices.get(i - 1), vertices.get(i)); 332 } 333 } 334 evalTristate(int state)335 boolean evalTristate(int state) { 336 return (state > 0) ? true : (state < 0) ? false : (rand.nextDouble() > 0.5); 337 } 338 testBuilder(TestCase test)339 boolean testBuilder(TestCase test) { 340 for (int iter = 0; iter < 200; ++iter) { 341 // Initialize to the default options, which are changed below 342 S2PolygonBuilder.Options options = S2PolygonBuilder.Options.DIRECTED_XOR; 343 344 options.setUndirectedEdges(evalTristate(test.undirectedEdges)); 345 options.setXorEdges(evalTristate(test.xorEdges)); 346 347 // Each test has a minimum and a maximum merge distance. The merge 348 // distance must be at least the given minimum to ensure that all expected 349 // merging will take place, and it must be at most the given maximum to 350 // ensure that no unexpected merging takes place. 351 // 352 // If the minimum and maximum values are different, we have some latitude 353 // to perturb the vertices as long as the merge distance is adjusted 354 // appropriately. If "p" is the maximum perturbation distance, "min" and 355 // "max" are the min/max merge distances, and "m" is the actual merge 356 // distance for this test, we require that 357 // 358 // x >= min + 2*p and x <= max - 2*p . 359 // 360 // This implies that p <= 0.25 * (max - min). We choose "p" so that it is 361 // zero half of the time, and otherwise chosen randomly up to this limit. 362 363 double minMerge = S1Angle.degrees(test.minMerge).radians(); 364 double maxMerge = S1Angle.degrees(test.maxMerge).radians(); 365 double r = Math.max(0.0, 2 * rand.nextDouble() - 1); 366 double maxPerturbation = r * 0.25 * (maxMerge - minMerge); 367 368 // Now we set the merge distance chosen randomly within the limits above 369 // (min + 2*p and max - 2*p). Half of the time we set the merge distance 370 // to the minimum value. 371 372 r = Math.max(0.0, 2 * rand.nextDouble() - 1); 373 options.setMergeDistance(S1Angle.radians( 374 minMerge + 2 * maxPerturbation + r * (maxMerge - minMerge - 4 * maxPerturbation))); 375 376 options.setValidate(true); 377 S2PolygonBuilder builder = new S2PolygonBuilder(options); 378 379 // On each iteration we randomly rotate the test case around the sphere. 380 // This causes the S2PolygonBuilder to choose different first edges when 381 // trying to build loops. 382 S2Point x = randomPoint(); 383 S2Point y = S2Point.normalize(S2Point.crossProd(x, randomPoint())); 384 S2Point z = S2Point.normalize(S2Point.crossProd(x, y)); 385 386 for (Chain chain : test.chainsIn) { 387 addChain(chain, x, y, z, maxPerturbation, builder); 388 } 389 List<S2Loop> loops = Lists.newArrayList(); 390 List<S2Edge> unusedEdges = Lists.newArrayList(); 391 if (test.xorEdges < 0) { 392 builder.assembleLoops(loops, unusedEdges); 393 } else { 394 S2Polygon polygon = new S2Polygon(); 395 builder.assemblePolygon(polygon, unusedEdges); 396 polygon.release(loops); 397 } 398 List<S2Loop> expected = Lists.newArrayList(); 399 for (String loop : test.loopsOut) { 400 List<S2Point> vertices = Lists.newArrayList(); 401 getVertices(loop, x, y, z, 0, vertices); 402 expected.add(new S2Loop(vertices)); 403 } 404 // We assume that the vertex locations in the expected output polygon 405 // are separated from the corresponding vertex locations in the input 406 // edges by at most half of the minimum merge distance. Essentially 407 // this means that the expected output vertices should be near the 408 // centroid of the various input vertices. 409 double maxError = 0.5 * minMerge + maxPerturbation; 410 411 // Note single "|" below so that we print both sets of loops. 412 if (findMissingLoops(loops, expected, maxError, "Actual") 413 | findMissingLoops(expected, loops, maxError, "Expected")) { 414 System.err.print( 415 "During iteration " + iter + ", undirected: " + options.getUndirectedEdges() + ", xor: " 416 + options.getXorEdges() + "\n\n"); 417 return false; 418 } 419 if (unusedEdges.size() != test.numUnusedEdges) { 420 System.err.print("Wrong number of unused edges: " + unusedEdges.size() + "%d (should be " 421 + test.numUnusedEdges + ")\n"); 422 return false; 423 } 424 } 425 return true; 426 } 427 testAssembleLoops()428 public void testAssembleLoops() { 429 boolean success = true; 430 for (int i = 0; i < testCases.length; ++i) { 431 log.info("Starting test case " + i); 432 433 boolean caseSuccess = testBuilder(testCases[i]); 434 435 log.info("Test case " + i + " finished: " + ((caseSuccess) ? "SUCCESS" : "FAILED")); 436 437 success &= caseSuccess; 438 } 439 assertTrue(success); 440 } 441 } 442