Skip to content

Commit a891315

Browse files
rsneddsymonds
authored andcommitted
s2: Update EdgeTesselator to be more robust and conservative.
Previously the algorithm could not handle points of inflection (that is, edges that cross the equator in the Plate Carree and Mercator projections), and was not always conservative even when edges did not have a point of inflection. The new algorithm solves these problems by modeling the error as a cubic polynomial. This is done by evaluating the distance between the edges at two equally space points rather than at the midpoint. This results is a small amount of extra tessellation (about 1.5% on average) but is much more robust. Signed-off-by: David Symonds <dsymonds@golang.org>
1 parent 4223c58 commit a891315

File tree

4 files changed

+251
-65
lines changed

4 files changed

+251
-65
lines changed

s2/edge_tessellator.go

Lines changed: 183 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,162 @@
1515
package s2
1616

1717
import (
18-
"math"
19-
2018
"github.com/golang/geo/r2"
2119
"github.com/golang/geo/s1"
2220
)
2321

22+
// Tessellation is implemented by subdividing the edge until the estimated
23+
// maximum error is below the given tolerance. Estimating error is a hard
24+
// problem, especially when the only methods available are point evaluation of
25+
// the projection and its inverse. (These are the only methods that
26+
// Projection provides, which makes it easier and less error-prone to
27+
// implement new projections.)
28+
//
29+
// One technique that significantly increases robustness is to treat the
30+
// geodesic and projected edges as parametric curves rather than geometric ones.
31+
// Given a spherical edge AB and a projection p:S2->R2, let f(t) be the
32+
// normalized arc length parametrization of AB and let g(t) be the normalized
33+
// arc length parameterization of the projected edge p(A)p(B). (In other words,
34+
// f(0)=A, f(1)=B, g(0)=p(A), g(1)=p(B).) We now define the geometric error as
35+
// the maximum distance from the point p^-1(g(t)) to the geodesic edge AB for
36+
// any t in [0,1], where p^-1 denotes the inverse projection. In other words,
37+
// the geometric error is the maximum distance from any point on the projected
38+
// edge (mapped back onto the sphere) to the geodesic edge AB. On the other
39+
// hand we define the parametric error as the maximum distance between the
40+
// points f(t) and p^-1(g(t)) for any t in [0,1], i.e. the maximum distance
41+
// (measured on the sphere) between the geodesic and projected points at the
42+
// same interpolation fraction t.
43+
//
44+
// The easiest way to estimate the parametric error is to simply evaluate both
45+
// edges at their midpoints and measure the distance between them (the "midpoint
46+
// method"). This is very fast and works quite well for most edges, however it
47+
// has one major drawback: it doesn't handle points of inflection (i.e., points
48+
// where the curvature changes sign). For example, edges in the Mercator and
49+
// Plate Carree projections always curve towards the equator relative to the
50+
// corresponding geodesic edge, so in these projections there is a point of
51+
// inflection whenever the projected edge crosses the equator. The worst case
52+
// occurs when the edge endpoints have different longitudes but the same
53+
// absolute latitude, since in that case the error is non-zero but the edges
54+
// have exactly the same midpoint (on the equator).
55+
//
56+
// One solution to this problem is to split the input edges at all inflection
57+
// points (i.e., along the equator in the case of the Mercator and Plate Carree
58+
// projections). However for general projections these inflection points can
59+
// occur anywhere on the sphere (e.g., consider the Transverse Mercator
60+
// projection). This could be addressed by adding methods to the S2Projection
61+
// interface to split edges at inflection points but this would make it harder
62+
// and more error-prone to implement new projections.
63+
//
64+
// Another problem with this approach is that the midpoint method sometimes
65+
// underestimates the true error even when edges do not cross the equator.
66+
// For the Plate Carree and Mercator projections, the midpoint method can
67+
// underestimate the error by up to 3%.
68+
//
69+
// Both of these problems can be solved as follows. We assume that the error
70+
// can be modeled as a convex combination of two worst-case functions, one
71+
// where the error is maximized at the edge midpoint and another where the
72+
// error is *minimized* (i.e., zero) at the edge midpoint. For example, we
73+
// could choose these functions as:
74+
//
75+
// E1(x) = 1 - x^2
76+
// E2(x) = x * (1 - x^2)
77+
//
78+
// where for convenience we use an interpolation parameter "x" in the range
79+
// [-1, 1] rather than the original "t" in the range [0, 1]. Note that both
80+
// error functions must have roots at x = {-1, 1} since the error must be zero
81+
// at the edge endpoints. E1 is simply a parabola whose maximum value is 1
82+
// attained at x = 0, while E2 is a cubic with an additional root at x = 0,
83+
// and whose maximum value is 2 * sqrt(3) / 9 attained at x = 1 / sqrt(3).
84+
//
85+
// Next, it is convenient to scale these functions so that the both have a
86+
// maximum value of 1. E1 already satisfies this requirement, and we simply
87+
// redefine E2 as
88+
//
89+
// E2(x) = x * (1 - x^2) / (2 * sqrt(3) / 9)
90+
//
91+
// Now define x0 to be the point where these two functions intersect, i.e. the
92+
// point in the range (-1, 1) where E1(x0) = E2(x0). This value has the very
93+
// convenient property that if we evaluate the actual error E(x0), then the
94+
// maximum error on the entire interval [-1, 1] is bounded by
95+
//
96+
// E(x) <= E(x0) / E1(x0)
97+
//
98+
// since whether the error is modeled using E1 or E2, the resulting function
99+
// has the same maximum value (namely E(x0) / E1(x0)). If it is modeled as
100+
// some other convex combination of E1 and E2, the maximum value can only
101+
// decrease.
102+
//
103+
// Finally, since E2 is not symmetric about the y-axis, we must also allow for
104+
// the possibility that the error is a convex combination of E1 and -E2. This
105+
// can be handled by evaluating the error at E(-x0) as well, and then
106+
// computing the final error bound as
107+
//
108+
// E(x) <= max(E(x0), E(-x0)) / E1(x0) .
109+
//
110+
// Effectively, this method is simply evaluating the error at two points about
111+
// 1/3 and 2/3 of the way along the edges, and then scaling the maximum of
112+
// these two errors by a constant factor. Intuitively, the reason this works
113+
// is that if the two edges cross somewhere in the interior, then at least one
114+
// of these points will be far from the crossing.
115+
//
116+
// The actual algorithm implemented below has some additional refinements.
117+
// First, edges longer than 90 degrees are always subdivided; this avoids
118+
// various unusual situations that can happen with very long edges, and there
119+
// is really no reason to avoid adding vertices to edges that are so long.
120+
//
121+
// Second, the error function E1 above needs to be modified to take into
122+
// account spherical distortions. (It turns out that spherical distortions are
123+
// beneficial in the case of E2, i.e. they only make its error estimates
124+
// slightly more conservative.) To do this, we model E1 as the maximum error
125+
// in a Plate Carree edge of length 90 degrees or less. This turns out to be
126+
// an edge from 45:-90 to 45:90 (in lat:lng format). The corresponding error
127+
// as a function of "x" in the range [-1, 1] can be computed as the distance
128+
// between the Plate Caree edge point (45, 90 * x) and the geodesic
129+
// edge point (90 - 45 * abs(x), 90 * sgn(x)). Using the Haversine formula,
130+
// the corresponding function E1 (normalized to have a maximum value of 1) is:
131+
//
132+
// E1(x) =
133+
// asin(sqrt(sin(Pi / 8 * (1 - x)) ^ 2 +
134+
// sin(Pi / 4 * (1 - x)) ^ 2 * cos(Pi / 4) * sin(Pi / 4 * x))) /
135+
// asin(sqrt((1 - 1 / sqrt(2)) / 2))
136+
//
137+
// Note that this function does not need to be evaluated at runtime, it
138+
// simply affects the calculation of the value x0 where E1(x0) = E2(x0)
139+
// and the corresponding scaling factor C = 1 / E1(x0).
140+
//
141+
// ------------------------------------------------------------------
142+
//
143+
// In the case of the Mercator and Plate Carree projections this strategy
144+
// produces a conservative upper bound (verified using 10 million random
145+
// edges). Furthermore the bound is nearly tight; the scaling constant is
146+
// C = 1.19289, whereas the maximum observed value was 1.19254.
147+
//
148+
// Compared to the simpler midpoint evaluation method, this strategy requires
149+
// more function evaluations (currently twice as many, but with a smarter
150+
// tessellation algorithm it will only be 50% more). It also results in a
151+
// small amount of additional tessellation (about 1.5%) compared to the
152+
// midpoint method, but this is due almost entirely to the fact that the
153+
// midpoint method does not yield conservative error estimates.
154+
//
155+
// For random edges with a tolerance of 1 meter, the expected amount of
156+
// overtessellation is as follows:
157+
//
158+
// Midpoint Method Cubic Method
159+
// Plate Carree 1.8% 3.0%
160+
// Mercator 15.8% 17.4%
161+
24162
const (
25-
// MinTessellationTolerance is the minimum supported tolerance (which
163+
// tessellationInterpolationFraction is the fraction at which the two edges
164+
// are evaluated in order to measure the error between them. (Edges are
165+
// evaluated at two points measured this fraction from either end.)
166+
tessellationInterpolationFraction = 0.31215691082248312
167+
tessellationScaleFactor = 0.83829992569888509
168+
169+
// minTessellationTolerance is the minimum supported tolerance (which
26170
// corresponds to a distance less than 1 micrometer on the Earth's
27171
// surface, but is still much larger than the expected projection and
28172
// interpolation errors).
29-
MinTessellationTolerance s1.Angle = 1e-13
173+
minTessellationTolerance s1.Angle = 1e-13
30174
)
31175

32176
// EdgeTessellator converts an edge in a given projection (e.g., Mercator) into
@@ -41,17 +185,18 @@ const (
41185
// Projected | S2 geodesics | Planar projected edges
42186
// Unprojected | Planar projected edges | S2 geodesics
43187
type EdgeTessellator struct {
44-
projection Projection
45-
tolerance s1.ChordAngle
46-
wrapDistance r2.Point
188+
projection Projection
189+
190+
// The given tolerance scaled by a constant fraction so that it can be
191+
// compared against the result returned by estimateMaxError.
192+
scaledTolerance s1.ChordAngle
47193
}
48194

49195
// NewEdgeTessellator creates a new edge tessellator for the given projection and tolerance.
50196
func NewEdgeTessellator(p Projection, tolerance s1.Angle) *EdgeTessellator {
51197
return &EdgeTessellator{
52-
projection: p,
53-
tolerance: s1.ChordAngleFromAngle(maxAngle(tolerance, MinTessellationTolerance)),
54-
wrapDistance: p.WrapDistance(),
198+
projection: p,
199+
scaledTolerance: s1.ChordAngleFromAngle(maxAngle(tolerance, minTessellationTolerance)),
55200
}
56201
}
57202

@@ -68,46 +213,25 @@ func (e *EdgeTessellator) AppendProjected(a, b Point, vertices []r2.Point) []r2.
68213
if len(vertices) == 0 {
69214
vertices = []r2.Point{pa}
70215
} else {
71-
pa = e.wrapDestination(vertices[len(vertices)-1], pa)
216+
pa = e.projection.WrapDestination(vertices[len(vertices)-1], pa)
72217
}
73218

74-
pb := e.wrapDestination(pa, e.projection.Project(b))
219+
pb := e.projection.Project(b)
75220
return e.appendProjected(pa, a, pb, b, vertices)
76221
}
77222

78223
// appendProjected splits a geodesic edge AB as necessary and returns the
79224
// projected vertices appended to the given vertices.
80225
//
81-
// The maximum recursion depth is (math.Pi / MinTessellationTolerance) < 45
82-
func (e *EdgeTessellator) appendProjected(pa r2.Point, a Point, pb r2.Point, b Point, vertices []r2.Point) []r2.Point {
83-
// It's impossible to robustly test whether a projected edge is close enough
84-
// to a geodesic edge without knowing the details of the projection
85-
// function, but the following heuristic works well for a wide range of map
86-
// projections. The idea is simply to test whether the midpoint of the
87-
// projected edge is close enough to the midpoint of the geodesic edge.
88-
//
89-
// This measures the distance between the two edges by treating them as
90-
// parametric curves rather than geometric ones. The problem with
91-
// measuring, say, the minimum distance from the projected midpoint to the
92-
// geodesic edge is that this is a lower bound on the value we want, because
93-
// the maximum separation between the two curves is generally not attained
94-
// at the midpoint of the projected edge. The distance between the curve
95-
// midpoints is at least an upper bound on the distance from either midpoint
96-
// to opposite curve. It's not necessarily an upper bound on the maximum
97-
// distance between the two curves, but it is a powerful requirement because
98-
// it demands that the two curves stay parametrically close together. This
99-
// turns out to be much more robust with respect for projections with
100-
// singularities (e.g., the North and South poles in the rectangular and
101-
// Mercator projections) because the curve parameterization speed changes
102-
// rapidly near such singularities.
103-
mid := Point{a.Add(b.Vector).Normalize()}
104-
testMid := e.projection.Unproject(e.projection.Interpolate(0.5, pa, pb))
105-
106-
if ChordAngleBetweenPoints(mid, testMid) < e.tolerance {
226+
// The maximum recursion depth is (math.Pi / minTessellationTolerance) < 45
227+
func (e *EdgeTessellator) appendProjected(pa r2.Point, a Point, pbIn r2.Point, b Point, vertices []r2.Point) []r2.Point {
228+
pb := e.projection.WrapDestination(pa, pbIn)
229+
if e.estimateMaxError(pa, a, pb, b) <= e.scaledTolerance {
107230
return append(vertices, pb)
108231
}
109232

110-
pmid := e.wrapDestination(pa, e.projection.Project(mid))
233+
mid := Point{a.Add(b.Vector).Normalize()}
234+
pmid := e.projection.WrapDestination(pa, e.projection.Project(mid))
111235
vertices = e.appendProjected(pa, a, pmid, mid, vertices)
112236
return e.appendProjected(pmid, mid, pb, b, vertices)
113237
}
@@ -120,7 +244,6 @@ func (e *EdgeTessellator) appendProjected(pa r2.Point, a Point, pb r2.Point, b P
120244
// (e.g. across the 180 degree meridian) then the first and last vertices may not
121245
// be exactly the same.
122246
func (e *EdgeTessellator) AppendUnprojected(pa, pb r2.Point, vertices []Point) []Point {
123-
pb2 := e.wrapDestination(pa, pb)
124247
a := e.projection.Unproject(pa)
125248
b := e.projection.Unproject(pb)
126249

@@ -133,35 +256,36 @@ func (e *EdgeTessellator) AppendUnprojected(pa, pb r2.Point, vertices []Point) [
133256
// transformed into "0:-175, 0:-181" while the second is transformed into
134257
// "0:179, 0:183". The two coordinate pairs for the middle vertex
135258
// ("0:-181" and "0:179") may not yield exactly the same S2Point.
136-
return e.appendUnprojected(pa, a, pb2, b, vertices)
259+
return e.appendUnprojected(pa, a, pb, b, vertices)
137260
}
138261

139262
// appendUnprojected interpolates a projected edge and appends the corresponding
140263
// points on the sphere.
141-
func (e *EdgeTessellator) appendUnprojected(pa r2.Point, a Point, pb r2.Point, b Point, vertices []Point) []Point {
142-
pmid := e.projection.Interpolate(0.5, pa, pb)
143-
mid := e.projection.Unproject(pmid)
144-
testMid := Point{a.Add(b.Vector).Normalize()}
145-
146-
if ChordAngleBetweenPoints(mid, testMid) < e.tolerance {
264+
func (e *EdgeTessellator) appendUnprojected(pa r2.Point, a Point, pbIn r2.Point, b Point, vertices []Point) []Point {
265+
pb := e.projection.WrapDestination(pa, pbIn)
266+
if e.estimateMaxError(pa, a, pb, b) <= e.scaledTolerance {
147267
return append(vertices, b)
148268
}
149269

270+
pmid := e.projection.Interpolate(0.5, pa, pb)
271+
mid := e.projection.Unproject(pmid)
272+
150273
vertices = e.appendUnprojected(pa, a, pmid, mid, vertices)
151274
return e.appendUnprojected(pmid, mid, pb, b, vertices)
152275
}
153276

154-
// wrapDestination returns the coordinates of the edge destination wrapped if
155-
// necessary to obtain the shortest edge.
156-
func (e *EdgeTessellator) wrapDestination(pa, pb r2.Point) r2.Point {
157-
x := pb.X
158-
y := pb.Y
159-
// The code below ensures that pb is unmodified unless wrapping is required.
160-
if e.wrapDistance.X > 0 && math.Abs(x-pa.X) > 0.5*e.wrapDistance.X {
161-
x = pa.X + math.Remainder(x-pa.X, e.wrapDistance.X)
162-
}
163-
if e.wrapDistance.Y > 0 && math.Abs(y-pa.Y) > 0.5*e.wrapDistance.Y {
164-
y = pa.Y + math.Remainder(y-pa.Y, e.wrapDistance.Y)
277+
func (e *EdgeTessellator) estimateMaxError(pa r2.Point, a Point, pb r2.Point, b Point) s1.ChordAngle {
278+
// See the algorithm description at the top of this file.
279+
// We always tessellate edges longer than 90 degrees on the sphere, since the
280+
// approximation below is not robust enough to handle such edges.
281+
if a.Dot(b.Vector) < -1e-14 {
282+
return s1.InfChordAngle()
165283
}
166-
return r2.Point{x, y}
284+
t1 := tessellationInterpolationFraction
285+
t2 := 1 - tessellationInterpolationFraction
286+
mid1 := Interpolate(t1, a, b)
287+
mid2 := Interpolate(t2, a, b)
288+
pmid1 := e.projection.Unproject(e.projection.Interpolate(t1, pa, pb))
289+
pmid2 := e.projection.Unproject(e.projection.Interpolate(t2, pa, pb))
290+
return maxChordAngle(ChordAngleBetweenPoints(mid1, pmid1), ChordAngleBetweenPoints(mid2, pmid2))
167291
}

s2/projections.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ type Projection interface {
5959
//
6060
// If a given axis does not wrap, its WrapDistance should be set to zero.
6161
WrapDistance() r2.Point
62+
63+
// WrapDestination that wraps the coordinates of B if necessary in order to
64+
// obtain the shortest edge AB. For example, suppose that A = [170, 20],
65+
// B = [-170, 20], and the projection wraps so that [x, y] == [x + 360, y].
66+
// Then this function would return [190, 20] for point B (reducing the edge
67+
// length in the "x" direction from 340 to 20).
68+
WrapDestination(a, b r2.Point) r2.Point
69+
70+
// We do not support implementations of this interface outside this package.
71+
privateInterface()
6272
}
6373

6474
// PlateCarreeProjection defines the "plate carree" (square plate) projection,
@@ -127,6 +137,13 @@ func (p *PlateCarreeProjection) WrapDistance() r2.Point {
127137
return r2.Point{p.xWrap, 0}
128138
}
129139

140+
// WrapDestination wraps the points if needed to get the shortest edge.
141+
func (p *PlateCarreeProjection) WrapDestination(a, b r2.Point) r2.Point {
142+
return wrapDestination(a, b, p.WrapDistance)
143+
}
144+
145+
func (p *PlateCarreeProjection) privateInterface() {}
146+
130147
// MercatorProjection defines the spherical Mercator projection. Google Maps
131148
// uses this projection together with WGS84 coordinates, in which case it is
132149
// known as the "Web Mercator" projection (see Wikipedia). This class makes
@@ -201,3 +218,24 @@ func (p *MercatorProjection) Interpolate(f float64, a, b r2.Point) r2.Point {
201218
func (p *MercatorProjection) WrapDistance() r2.Point {
202219
return r2.Point{p.xWrap, 0}
203220
}
221+
222+
// WrapDestination wraps the points if needed to get the shortest edge.
223+
func (p *MercatorProjection) WrapDestination(a, b r2.Point) r2.Point {
224+
return wrapDestination(a, b, p.WrapDistance)
225+
}
226+
227+
func (p *MercatorProjection) privateInterface() {}
228+
229+
func wrapDestination(a, b r2.Point, wrapDistance func() r2.Point) r2.Point {
230+
wrap := wrapDistance()
231+
x := b.X
232+
y := b.Y
233+
// The code below ensures that "b" is unmodified unless wrapping is required.
234+
if wrap.X > 0 && math.Abs(x-a.X) > 0.5*wrap.X {
235+
x = a.X + math.Remainder(x-a.X, wrap.X)
236+
}
237+
if wrap.Y > 0 && math.Abs(y-a.Y) > 0.5*wrap.Y {
238+
y = a.Y + math.Remainder(y-a.Y, wrap.Y)
239+
}
240+
return r2.Point{x, y}
241+
}

s2/projections_test.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@ func TestPlateCarreeProjectionInterpolate(t *testing.T) {
5757
}
5858
}
5959

60-
func r2pointsApproxEqual(a, b r2.Point) bool {
61-
return float64Eq(a.X, b.X) && float64Eq(a.Y, b.Y)
62-
}
63-
6460
func TestPlateCarreeProjectionProjectUnproject(t *testing.T) {
6561
tests := []struct {
6662
have Point
@@ -77,7 +73,7 @@ func TestPlateCarreeProjectionProjectUnproject(t *testing.T) {
7773
proj := NewPlateCarreeProjection(180)
7874

7975
for _, test := range tests {
80-
if got := proj.Project(test.have); !r2pointsApproxEqual(test.want, got) {
76+
if got := proj.Project(test.have); !r2PointsApproxEqual(test.want, got, epsilon) {
8177
t.Errorf("proj.Project(%v) = %v, want %v", test.have, got, test.want)
8278
}
8379
if got := proj.Unproject(test.want); !got.ApproxEqual(test.have) {
@@ -102,7 +98,7 @@ func TestMercatorProjectionProjectUnproject(t *testing.T) {
10298
proj := NewMercatorProjection(180)
10399

104100
for _, test := range tests {
105-
if got := proj.Project(test.have); !r2pointsApproxEqual(test.want, got) {
101+
if got := proj.Project(test.have); !r2PointsApproxEqual(test.want, got, epsilon) {
106102
t.Errorf("proj.Project(%v) = %v, want %v", test.have, got, test.want)
107103
}
108104
if got := proj.Unproject(test.want); !got.ApproxEqual(test.have) {

0 commit comments

Comments
 (0)