Skip to content

Commit f725a3b

Browse files
authored
Merge pull request #6980 from seadowg/epsilon
Only detect exact collinear intersections in traces
2 parents 298e4dc + 74a6464 commit f725a3b

File tree

4 files changed

+133
-74
lines changed

4 files changed

+133
-74
lines changed

shared/src/main/java/org/odk/collect/shared/geometry/Geometry.kt

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,21 @@ fun Trace.segments(): List<LineSegment> {
2525
/**
2626
* Returns `true` if any segment of the trace intersects with any other and `false` otherwise.
2727
*/
28-
fun Trace.intersects(): Boolean {
28+
fun Trace.intersects(epsilon: Double = 0.0): Boolean {
2929
val points = this.points
3030
return if (points.size >= 3) {
3131
val segments = segments()
3232
if (segments.size == 2) {
33-
segments[0].intersects(segments[1], allowConnection = true)
33+
segments[0].intersects(segments[1], allowConnection = true, epsilon = epsilon)
3434
} else {
3535
segments.filterIndexed { line1Index, line1 ->
3636
segments.filterIndexed { line2Index, line2 ->
3737
if (isClosed() && line1Index == 0 && line2Index == segments.size - 1) {
3838
false
3939
} else if (line2Index == line1Index + 1) {
40-
line1.intersects(line2, allowConnection = true)
40+
line1.intersects(line2, allowConnection = true, epsilon = epsilon)
4141
} else if (line2Index >= line1Index + 2) {
42-
line1.intersects(line2)
42+
line1.intersects(line2, epsilon = epsilon)
4343
} else {
4444
false
4545
}
@@ -73,12 +73,12 @@ fun Point.within(segment: LineSegment): Boolean {
7373
* @param allowConnection will allow the end of `this` and the start of `other` to intersect
7474
* provided they are equivalent (the two segments are "connected")
7575
*/
76-
fun LineSegment.intersects(other: LineSegment, allowConnection: Boolean = false): Boolean {
76+
fun LineSegment.intersects(other: LineSegment, allowConnection: Boolean = false, epsilon: Double = 0.0): Boolean {
7777
val (a, b) = this
7878
val (c, d) = other
7979

80-
val orientationA = orientation(a, c, d)
81-
val orientationD = orientation(a, b, d)
80+
val orientationA = orientation(a, c, d, epsilon)
81+
val orientationD = orientation(a, b, d, epsilon)
8282

8383
return if (orientationA == Orientation.Collinear && a.within(other)) {
8484
true
@@ -87,8 +87,8 @@ fun LineSegment.intersects(other: LineSegment, allowConnection: Boolean = false)
8787
} else if (b == c && allowConnection) {
8888
false
8989
} else {
90-
val orientationB = orientation(b, c, d)
91-
val orientationC = orientation(a, b, c)
90+
val orientationB = orientation(b, c, d, epsilon)
91+
val orientationC = orientation(a, b, c, epsilon)
9292

9393
if (orientationA.isOpposing(orientationB) && orientationC.isOpposing(orientationD)) {
9494
true
@@ -102,17 +102,6 @@ fun LineSegment.intersects(other: LineSegment, allowConnection: Boolean = false)
102102
}
103103
}
104104

105-
/**
106-
* Calculate a [Point] on this [LineSegment] based on the `position` using
107-
* [Linear interpolation](https://en.wikipedia.org/wiki/Linear_interpolation). `0` will return
108-
* [LineSegment.start] and `1` will return [LineSegment.end].
109-
*/
110-
fun LineSegment.interpolate(position: Double): Point {
111-
val x = start.x + position * (end.x - start.x)
112-
val y = start.y + position * (end.y - start.y)
113-
return Point(x, y)
114-
}
115-
116105
/**
117106
* Calculate the "orientation" (or "direction") of three points using the cross product of the
118107
* vectors of the pairs of points (see
@@ -122,9 +111,9 @@ fun LineSegment.interpolate(position: Double): Point {
122111
* @param epsilon the epsilon used to check for collinearity
123112
*
124113
*/
125-
fun orientation(a: Point, b: Point, c: Point, epsilon: Double = 0.00000000001): Orientation {
114+
fun orientation(a: Point, b: Point, c: Point, epsilon: Double = 0.0): Orientation {
126115
val crossProduct = crossProduct(Pair(b.x - a.x, b.y - a.y), Pair(c.x - a.x, c.y - a.y))
127-
return if (abs(crossProduct) < epsilon) {
116+
return if (abs(crossProduct) <= epsilon) {
128117
Orientation.Collinear
129118
} else if (crossProduct > 0) {
130119
Orientation.AntiClockwise

shared/src/test/java/org/odk/collect/shared/geometry/GeometryTest.kt

Lines changed: 7 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ package org.odk.collect.shared.geometry
33
import org.hamcrest.MatcherAssert.assertThat
44
import org.hamcrest.Matchers.equalTo
55
import org.junit.Test
6+
import org.odk.collect.shared.geometry.support.GeometryTestUtils.addRandomIntersectingSegment
7+
import org.odk.collect.shared.geometry.support.GeometryTestUtils.getTraceGenerator
8+
import org.odk.collect.shared.geometry.support.GeometryTestUtils.reverse
9+
import org.odk.collect.shared.geometry.support.GeometryTestUtils.scale
610
import org.odk.collect.shared.quickCheck
711
import kotlin.random.Random
812

@@ -214,18 +218,15 @@ class GeometryTest {
214218
generator = getTraceGenerator()
215219
) { trace, intersects ->
216220
// Check intersects is consistent when trace is reversed
217-
val reversedTrace = Trace(trace.points.reversed())
221+
val reversedTrace = trace.reverse()
218222
assertThat(
219223
"Expected intersects=$intersects:\n$reversedTrace",
220224
reversedTrace.intersects(),
221225
equalTo(intersects)
222226
)
223227

224228
// Check intersects is consistent when trace is scaled
225-
val scaleFactor = Random.nextDouble(0.1, 10.0)
226-
val scaledTrace = Trace(trace.points.map {
227-
Point(it.x * scaleFactor, it.y * scaleFactor)
228-
})
229+
val scaledTrace = trace.scale(Random.nextDouble(0.1, 10.0))
229230
assertThat(
230231
"Expected intersects=$intersects:\n$scaledTrace",
231232
scaledTrace.intersects(),
@@ -234,11 +235,7 @@ class GeometryTest {
234235

235236
// Check adding an intersection makes intersects true
236237
if (!intersects) {
237-
val intersectionSegment = trace.segments().random()
238-
val intersectPosition = Random.nextDouble(0.1, 1.0)
239-
val intersectionPoint = intersectionSegment.interpolate(intersectPosition)
240-
val intersectingTrace =
241-
Trace(trace.points + listOf(trace.points.last(), intersectionPoint))
238+
val intersectingTrace = trace.addRandomIntersectingSegment()
242239
assertThat(
243240
"Expected intersects=true:\n$intersectingTrace",
244241
intersectingTrace.intersects(),
@@ -280,46 +277,4 @@ class GeometryTest {
280277

281278
assertThat(segment1.intersects(segment2, allowConnection = true), equalTo(true))
282279
}
283-
284-
@Test
285-
fun `LineSegment#interpolate returns a point on the segment at a proportional distance`() {
286-
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 0.0))
287-
288-
assertThat(segment.interpolate(0.0), equalTo(Point(0.0, 0.0)))
289-
assertThat(segment.interpolate(0.5), equalTo(Point(0.5, 0.0)))
290-
assertThat(segment.interpolate(1.0), equalTo(Point(1.0, 0.0)))
291-
}
292-
293-
@Test
294-
fun `LineSegment#interpolate returns a collinear point within the line's bounding box`() {
295-
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 1.0))
296-
val interpolatedPoint = segment.interpolate(0.5)
297-
298-
val orientation = orientation(interpolatedPoint, segment.start, segment.end)
299-
assertThat(orientation, equalTo(Orientation.Collinear))
300-
assertThat(interpolatedPoint.within(segment), equalTo(true))
301-
}
302-
303-
private fun getTraceGenerator(maxLength: Int = 10, maxCoordinate: Double = 100.0): Sequence<Trace> {
304-
return generateSequence {
305-
val length = Random.nextInt(2, maxLength)
306-
val trace = Trace(0.until(length).map {
307-
Point(
308-
Random.nextDouble(maxCoordinate * -1, maxCoordinate),
309-
Random.nextDouble(maxCoordinate * -1, maxCoordinate)
310-
)
311-
})
312-
313-
if (trace.isClosed()) {
314-
trace
315-
} else {
316-
val shouldClose = Random.nextBoolean()
317-
if (shouldClose) {
318-
trace.copy(points = trace.points + trace.points.first())
319-
} else {
320-
trace
321-
}
322-
}
323-
}
324-
}
325280
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.odk.collect.shared.geometry.support
2+
3+
import org.odk.collect.shared.geometry.LineSegment
4+
import org.odk.collect.shared.geometry.Point
5+
import org.odk.collect.shared.geometry.Trace
6+
import org.odk.collect.shared.geometry.segments
7+
import org.odk.collect.shared.geometry.support.GeometryTestUtils.interpolate
8+
import kotlin.random.Random
9+
10+
object GeometryTestUtils {
11+
12+
fun getTraceGenerator(maxLength: Int = 10, maxCoordinate: Double = 100.0): Sequence<Trace> {
13+
return generateSequence {
14+
val length = Random.nextInt(3, maxLength)
15+
val trace = Trace(0.until(length).map {
16+
Point(
17+
Random.nextDouble(maxCoordinate * -1, maxCoordinate),
18+
Random.nextDouble(maxCoordinate * -1, maxCoordinate)
19+
)
20+
})
21+
22+
if (trace.isClosed()) {
23+
trace
24+
} else {
25+
val shouldClose = Random.nextBoolean()
26+
if (shouldClose) {
27+
trace.copy(points = trace.points + trace.points.first())
28+
} else {
29+
trace
30+
}
31+
}
32+
}
33+
}
34+
35+
fun Trace.reverse(): Trace {
36+
return Trace(points.reversed())
37+
}
38+
39+
fun Trace.scale(factor: Double): Trace {
40+
return Trace(points.map {
41+
Point(it.x * factor, it.y * factor)
42+
})
43+
}
44+
45+
/**
46+
* Choose random segment, a random (interpolated) point on that segment and then create a new
47+
* trace with an additional point just beyond that to create an intersecting trace.
48+
*
49+
* Never chooses the last segment as a target for intersecting as that can only create a
50+
* collinear intersection which is unlikely to be accurate due to inaccuracies in [interpolate].
51+
*/
52+
fun Trace.addRandomIntersectingSegment(): Trace {
53+
val intersectionSegment = segments().dropLast(1).random()
54+
val intersectPosition = Random.nextDouble(0.1, 1.0)
55+
val intersectionPoint = intersectionSegment.interpolate(intersectPosition)
56+
val lineSegment = LineSegment(points.last(), intersectionPoint)
57+
val intersectingSegment =
58+
LineSegment(lineSegment.start, lineSegment.interpolate(1.1))
59+
return Trace(points + intersectingSegment.end)
60+
}
61+
62+
/**
63+
* Calculate a [Point] on this [LineSegment] based on the `position` using
64+
* [Linear interpolation](https://en.wikipedia.org/wiki/Linear_interpolation). `0` will return
65+
* [LineSegment.start] and `1` will return [LineSegment.end].
66+
*/
67+
fun LineSegment.interpolate(position: Double): Point {
68+
val x = start.x + position * (end.x - start.x)
69+
val y = start.y + position * (end.y - start.y)
70+
return Point(x, y)
71+
}
72+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package org.odk.collect.shared.geometry.support
2+
3+
import org.hamcrest.MatcherAssert.assertThat
4+
import org.hamcrest.Matchers.equalTo
5+
import org.junit.Test
6+
import org.odk.collect.shared.geometry.LineSegment
7+
import org.odk.collect.shared.geometry.Orientation
8+
import org.odk.collect.shared.geometry.Point
9+
import org.odk.collect.shared.geometry.orientation
10+
import org.odk.collect.shared.geometry.support.GeometryTestUtils.interpolate
11+
import org.odk.collect.shared.geometry.within
12+
13+
class GeometryTestUtilsTest {
14+
15+
@Test
16+
fun `LineSegment#interpolate returns a point on the segment at a proportional distance`() {
17+
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 0.0))
18+
19+
assertThat(segment.interpolate(0.0), equalTo(Point(0.0, 0.0)))
20+
assertThat(segment.interpolate(0.5), equalTo(Point(0.5, 0.0)))
21+
assertThat(segment.interpolate(1.0), equalTo(Point(1.0, 0.0)))
22+
}
23+
24+
@Test
25+
fun `LineSegment#interpolate returns a collinear point within the line's bounding box`() {
26+
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 1.0))
27+
val interpolatedPoint = segment.interpolate(0.5)
28+
29+
val orientation = orientation(interpolatedPoint, segment.start, segment.end)
30+
assertThat(orientation, equalTo(Orientation.Collinear))
31+
assertThat(interpolatedPoint.within(segment), equalTo(true))
32+
}
33+
34+
@Test
35+
fun `LineSegment#interpolate returns a collinear point within the line's bounding box for higher precision points with a suitable epsilon`() {
36+
val segment = LineSegment(Point(56.6029153, 20.2311124), Point(56.6029192, 20.2310467))
37+
val interpolatedPoint = segment.interpolate(0.5)
38+
39+
val orientation = orientation(interpolatedPoint, segment.start, segment.end, epsilon = 0.000001)
40+
assertThat(orientation, equalTo(Orientation.Collinear))
41+
assertThat(interpolatedPoint.within(segment), equalTo(true))
42+
}
43+
}

0 commit comments

Comments
 (0)