Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_geom/src/proj.rs

72.3 KiB, 47 runs

created by r1870400018:19776, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Map projections: turning a position on a sphere into a position on a page, and back.
2//!
3//! The projection functions work on the plane every projection reference uses: right-handed,
4//! `y` increasing northward, scaled by a sphere radius the caller passes. Equal Earth draws
5//! the whole world at once, Web Mercator draws a flat map that street maps have made familiar,
6//! and orthographic draws the half of the world a viewer is looking at, as a globe. Each is
7//! checked against PROJ 9.7.1.
8//!
9//! A [`Viewport`] puts the last two on a screen: one camera -- a centre, metres per pixel,
10//! a heading -- read by either projection, with `y` downward as a canvas has it. It projects
11//! whole rings of unit vectors at a time, clipped and ready to paint, into a [`ScreenPaths`].
12//! Further projections belong beside these in this module rather than in a module of their
13//! own.
14
15use crate::planar::Pt;
16
17use oxedyne_fe2o3_core::prelude::*;
18
19use std::f64::consts::{
20 FRAC_PI_2,
21 PI,
22 TAU,
23};
24
25// ---------------------------------------------------------------------------------------------
26// Equal Earth
27// ---------------------------------------------------------------------------------------------
28
29/// First polynomial coefficient of the Equal Earth projection.
30///
31/// The four coefficients are those published by Šavrič, Patterson and Jenny, *The Equal
32/// Earth map projection*, International Journal of Geographical Information Science 33(3),
33/// 2019, and are the same values PROJ carries for `+proj=eqearth`.
34pub const EQUAL_EARTH_A1: f64 = 1.340264;
35
36/// Second polynomial coefficient of the Equal Earth projection.
37pub const EQUAL_EARTH_A2: f64 = -0.081106;
38
39/// Third polynomial coefficient of the Equal Earth projection.
40pub const EQUAL_EARTH_A3: f64 = 0.000893;
41
42/// Fourth polynomial coefficient of the Equal Earth projection.
43pub const EQUAL_EARTH_A4: f64 = 0.003796;
44
45/// Half the width of the projected world on a unit sphere.
46///
47/// The value of `x` at the equator on the antimeridian, which is where the map is widest.
48pub const EQUAL_EARTH_HALF_WIDTH: f64 = 2.706_629_983_696_074_3;
49
50/// Half the height of the projected world on a unit sphere.
51///
52/// The value of `y` at a pole. The world is therefore 2.0546 times as wide as it is tall.
53pub const EQUAL_EARTH_HALF_HEIGHT: f64 = 1.317_362_759_157_413;
54
55/// Projects a position onto the Equal Earth plane.
56///
57/// Equal Earth is pseudocylindrical and equal-area: every square metre of ground occupies
58/// the same area on the page wherever it is, which is what makes it honest about how much
59/// of the world a continent is. Parallels are straight and evenly spaced enough to read,
60/// meridians are curved, and the shape of the land is close to what a person expects.
61///
62/// # Arguments
63/// * `lat` - Latitude in signed decimal degrees, positive north.
64/// * `lng` - Longitude in signed decimal degrees, positive east.
65/// * `lon_0` - The central meridian, in degrees. Zero for Greenwich down the middle.
66/// * `radius` - The radius of the sphere, in whatever units the result should come out in.
67///
68/// # Returns
69/// The projected point, `x` eastward and `y` northward from the map's centre.
70///
71/// Latitude is clamped to the poles and longitude wrapped into a half turn either side of
72/// `lon_0`, so a fix arriving one part in a billion outside its range projects rather than
73/// producing a not-a-number. A non-finite argument yields a non-finite result: the caller
74/// is the one that knows whether that is a hole in its data or an error.
75pub fn equal_earth(lat: f64, lng: f64, lon_0: f64, radius: f64) -> Pt {
76 let phi = lat.clamp(-90.0, 90.0).to_radians();
77 let lam = wrap_half_turn(lng - lon_0).to_radians();
78
79 // The parametric latitude, on which the whole projection is a polynomial.
80 let theta = ((3.0f64).sqrt() / 2.0 * phi.sin()).clamp(-1.0, 1.0).asin();
81 let t2 = theta * theta;
82 let t3 = t2 * theta;
83 let t6 = t3 * t3;
84 let t7 = t6 * theta;
85 let t8 = t6 * t2;
86 let t9 = t8 * theta;
87
88 // The denominator of x is dy/dθ, which is what makes the projection equal-area.
89 let dy = 9.0 * EQUAL_EARTH_A4 * t8
90 + 7.0 * EQUAL_EARTH_A3 * t6
91 + 3.0 * EQUAL_EARTH_A2 * t2
92 + EQUAL_EARTH_A1;
93
94 let x = 2.0 * (3.0f64).sqrt() * lam * theta.cos() / (3.0 * dy);
95 let y = EQUAL_EARTH_A4 * t9
96 + EQUAL_EARTH_A3 * t7
97 + EQUAL_EARTH_A2 * t3
98 + EQUAL_EARTH_A1 * theta;
99
100 Pt::new(radius * x, radius * y)
101}
102
103/// Projects a position onto the Equal Earth plane of a unit sphere, Greenwich centred.
104///
105/// The bare form, for a caller that will scale the result itself.
106pub fn equal_earth_unit(lat: f64, lng: f64) -> Pt {
107 equal_earth(lat, lng, 0.0, 1.0)
108}
109
110/// The sphere radius that makes an Equal Earth map `2 * half_width` across.
111///
112/// A map is usually specified by the box it has to fill rather than by the size of the
113/// world it draws, and this converts one into the other. The height that follows is
114/// `half_width * EQUAL_EARTH_HALF_HEIGHT / EQUAL_EARTH_HALF_WIDTH`, or very nearly half
115/// the width.
116pub fn equal_earth_radius_for_half_width(half_width: f64) -> f64 {
117 half_width / EQUAL_EARTH_HALF_WIDTH
118}
119
120// ---------------------------------------------------------------------------------------------
121// Orthographic
122// ---------------------------------------------------------------------------------------------
123
124/// Half the width of the orthographic projection on a unit sphere.
125///
126/// The projection is the sphere seen from infinitely far away, so the map is the sphere's own
127/// disc and its half width is the radius. A caller wanting a globe `2 * half_width` across
128/// therefore passes `half_width` as the radius; there is no conversion to do.
129pub const ORTHOGRAPHIC_HALF_WIDTH: f64 = 1.0;
130
131/// Projects a position onto the orthographic plane, as seen from above `lat_0`, `lon_0`.
132///
133/// The orthographic projection is what a sphere looks like from far enough away that the rays
134/// arrive parallel: the near hemisphere fills a disc, foreshortened towards the rim, and the
135/// far hemisphere is behind it. It is neither equal-area nor conformal, and it is the only
136/// projection that does not have to choose, because it is not flattening the world at all —
137/// it is drawing the object. That makes it the honest one for a globe a viewer turns.
138///
139/// # Arguments
140/// * `lat` - Latitude in signed decimal degrees, positive north.
141/// * `lng` - Longitude in signed decimal degrees, positive east.
142/// * `lat_0` - The latitude the viewer is above, in degrees.
143/// * `lon_0` - The longitude the viewer is above, in degrees.
144/// * `radius` - The radius of the sphere, in whatever units the result should come out in.
145///
146/// # Returns
147/// The projected point, `x` rightward and `y` upward from the centre of the disc.
148///
149/// **A position on the far hemisphere still projects**, onto the point of the near hemisphere
150/// directly in front of it, because the formula cannot tell them apart. Ask
151/// [`orthographic_cos_c`] which side of the globe a position is on; it is separate so that a
152/// caller clipping a coastline can interpolate along an edge to the horizon, where that
153/// cosine is zero, rather than being handed a hole.
154pub fn orthographic(lat: f64, lng: f64, lat_0: f64, lon_0: f64, radius: f64) -> Pt {
155 let phi = lat.clamp(-90.0, 90.0).to_radians();
156 let phi_0 = lat_0.clamp(-90.0, 90.0).to_radians();
157 let lam = wrap_half_turn(lng - lon_0).to_radians();
158
159 let x = phi.cos() * lam.sin();
160 let y = phi_0.cos() * phi.sin() - phi_0.sin() * phi.cos() * lam.cos();
161
162 Pt::new(radius * x, radius * y)
163}
164
165/// The cosine of the angle between a position and the centre of an orthographic projection.
166///
167/// One where the position is under the viewer, zero on the horizon, and negative on the far
168/// side of the globe. A caller drawing a coastline keeps the vertices where this is positive
169/// and finds the horizon crossing by interpolating an edge to where it is zero.
170///
171/// # Arguments
172/// * `lat` - Latitude in signed decimal degrees, positive north.
173/// * `lng` - Longitude in signed decimal degrees, positive east.
174/// * `lat_0` - The latitude the viewer is above, in degrees.
175/// * `lon_0` - The longitude the viewer is above, in degrees.
176pub fn orthographic_cos_c(lat: f64, lng: f64, lat_0: f64, lon_0: f64) -> f64 {
177 let phi = lat.clamp(-90.0, 90.0).to_radians();
178 let phi_0 = lat_0.clamp(-90.0, 90.0).to_radians();
179 let lam = wrap_half_turn(lng - lon_0).to_radians();
180 phi_0.sin() * phi.sin() + phi_0.cos() * phi.cos() * lam.cos()
181}
182
183/// Brings an angle in degrees into the half turn either side of zero.
184///
185/// A longitude difference of 190° east is 170° west, and a map has to draw it there.
186fn wrap_half_turn(deg: f64) -> f64 {
187 if !deg.is_finite() {
188 return deg;
189 }
190 let full = 360.0;
191 let mut d = deg % full;
192 if d > 180.0 {
193 d -= full;
194 } else if d < -180.0 {
195 d += full;
196 }
197 d
198}
199
200// ---------------------------------------------------------------------------------------------
201// Web Mercator
202// ---------------------------------------------------------------------------------------------
203
204pub const EARTH_RADIUS_M: f64 = 6_371_008.8; // IUGG mean radius
205pub const WEB_MERCATOR_MAX_LAT: f64 = 85.051_128_779_806_59; // atan(sinh(pi)): the map is square
206
207/// Projects a position onto the spherical Web Mercator plane.
208///
209/// Web Mercator is conformal, so a small square of ground stays square on the page at every
210/// latitude, and it is the projection street maps have taught everyone to read. Its price is
211/// the poles, which are infinitely far away: latitude is clamped to
212/// [`WEB_MERCATOR_MAX_LAT`], where the map is exactly as tall as it is wide, `2 * pi * radius`.
213/// Longitude is wrapped into a half turn either side of `lon_0`.
214pub fn web_mercator(lat: f64, lng: f64, lon_0: f64, radius: f64) -> Pt {
215 let phi = lat.clamp(-WEB_MERCATOR_MAX_LAT, WEB_MERCATOR_MAX_LAT).to_radians();
216 let lam = wrap_half_turn(lng - lon_0).to_radians();
217 // PROJ writes the ordinate as asinh(tan(phi)); it equals ln(tan(pi/4 + phi/2)).
218 Pt::new(radius * lam, radius * phi.tan().asinh())
219}
220
221/// The position a Web Mercator point came from, as `(lat, lng)` in degrees.
222///
223/// Longitude comes back wrapped into `[-180, 180]`, so a point on a copy of the world to either
224/// side of the central one answers with the same position as its twin. A point above or below
225/// the square map still answers, with a latitude past [`WEB_MERCATOR_MAX_LAT`]; a caller that
226/// draws only the square asks [`Viewport::inverse`] instead.
227pub fn web_mercator_inverse(pt: Pt, lon_0: f64, radius: f64) -> (f64, f64) {
228 let lat = (pt.y / radius).sinh().atan().to_degrees();
229 let lng = wrap_half_turn(lon_0 + (pt.x / radius).to_degrees());
230 (lat, lng)
231}
232
233/// The position an orthographic point came from, as `(lat, lng)` in degrees.
234///
235/// The inverse of [`orthographic`] for the near hemisphere, which is the only one a point on
236/// the disc can mean. A point off the disc is off the globe and answers `None`; one within a
237/// part in ten billion of the rim is taken to be on it.
238pub fn orthographic_inverse(pt: Pt, lat_0: f64, lon_0: f64, radius: f64) -> Option<(f64, f64)> {
239 let rho = pt.x.hypot(pt.y) / radius;
240 if !rho.is_finite() || rho > 1.0 + 1.0e-10 {
241 return None;
242 }
243 let phi_0 = lat_0.clamp(-90.0, 90.0).to_radians();
244 if rho < 1.0e-15 {
245 return Some((lat_0.clamp(-90.0, 90.0), wrap_half_turn(lon_0)));
246 }
247 let sin_c = rho.min(1.0);
248 let cos_c = (1.0 - sin_c * sin_c).max(0.0).sqrt();
249 let (x, y) = (pt.x / radius, pt.y / radius);
250 // Snyder, Map Projections: A Working Manual, equations 20-14 and 20-15.
251 let phi = (cos_c * phi_0.sin() + y * sin_c * phi_0.cos() / rho).clamp(-1.0, 1.0).asin();
252 let lam = (x * sin_c).atan2(rho * cos_c * phi_0.cos() - y * sin_c * phi_0.sin());
253 Some((phi.to_degrees(), wrap_half_turn(lon_0 + lam.to_degrees())))
254}
255
256// ---------------------------------------------------------------------------------------------
257// Unit vectors
258// ---------------------------------------------------------------------------------------------
259
260/// A position as a unit vector: `x` towards Greenwich on the equator, `z` towards the north
261/// pole. The same frame [`crate::cell`] uses.
262pub fn unit_vec(lat: f64, lng: f64) -> [f64; 3] {
263 let (a, b) = (lat.to_radians(), lng.to_radians());
264 let c = a.cos();
265 [c * b.cos(), c * b.sin(), a.sin()]
266}
267
268/// The position of a vector of any non-zero length, as `(lat, lng)` in degrees.
269pub fn vec_lat_lng(v: &[f64; 3]) -> (f64, f64) {
270 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
271 let z = (v[2] / len).clamp(-1.0, 1.0);
272 (z.asin().to_degrees(), v[1].atan2(v[0]).to_degrees())
273}
274
275fn dot3(a: &[f64; 3], b: &[f64; 3]) -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
276
277// ---------------------------------------------------------------------------------------------
278// Viewport
279// ---------------------------------------------------------------------------------------------
280
281/// The projection a [`Viewport`] draws with.
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub enum Projection {
284 WebMercator, // a flat map, north up unless turned, wrapping east-west without end
285 Orthographic, // a globe seen from above the centre
286}
287
288/// How [`Viewport::project_rings`] treats each ring it is given.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub enum RingMode {
291 Fill, // closed ring, painted: clipped pieces are stitched along the clip edge
292 Outline, // closed ring, stroked: its closing edge is drawn, and it is cut where clipped
293 Line, // open line, stroked, cut where clipped
294}
295
296/// One camera over the sphere, shared by the flat map and the globe.
297///
298/// A viewport is the centre being looked at, how much ground a pixel covers there, which way
299/// is up, and the size of the screen. The two projections read the same numbers, so a caller
300/// switching between them keeps the place and the scale and changes only the picture.
301///
302/// Screen coordinates are pixels from the top left corner, `y` downward, as a canvas has them.
303/// `heading` is the compass bearing, in degrees clockwise from north, that points up the
304/// screen; zero is north up. `m_per_px` is ground metres per pixel at the centre, on a sphere
305/// of [`EARTH_RADIUS_M`], so a Mercator map at 60 degrees draws the world twice as wide as the
306/// same `m_per_px` does at the equator, and a cell the same size on the ground stays the same
307/// size on the screen either way.
308#[derive(Clone, Copy, Debug, PartialEq)]
309pub struct Viewport {
310 pub kind: Projection,
311 pub lat_0: f64, // degrees, the centre of the screen
312 pub lon_0: f64, // degrees
313 pub heading: f64, // degrees clockwise from north, pointing up the screen
314 pub m_per_px: f64, // ground metres per pixel at the centre
315 pub w: f64, // screen width in pixels
316 pub h: f64, // screen height in pixels
317}
318
319/// The clip and the margin around the screen, in pixels, inside which geometry is kept.
320///
321/// Wide enough that a stroke's own width never shows the cut, and small enough that nothing
322/// far off the screen is carried.
323const CLIP_MARGIN_PX: f64 = 8.0;
324
325/// A viewport's numbers turned into the arithmetic that draws with them, once per call.
326pub(crate) struct Frame {
327 pub(crate) cx: f64, // screen centre
328 pub(crate) cy: f64,
329 pub(crate) ch: f64, // cosine and sine of the heading
330 pub(crate) sh: f64,
331 pub(crate) r_px: f64, // the sphere's radius in pixels (Mercator: at the equator)
332 pub(crate) c: [f64; 3], // orthographic basis: centre, east, north
333 pub(crate) e: [f64; 3],
334 pub(crate) n: [f64; 3],
335 pub(crate) cos_clip: f64, // orthographic clip cap, as the cosine of its radius
336 pub(crate) rho_clip: f64, // and as a circle on the screen, in pixels
337 pub(crate) lam_0: f64, // Mercator central meridian, radians
338 pub(crate) y_0: f64, // Mercator ordinate of the centre, pixels
339 pub(crate) x_lo: f64, // Mercator: the screen and its margin on the unturned plane
340 pub(crate) x_hi: f64,
341 pub(crate) y_lo: f64,
342 pub(crate) y_hi: f64,
343}
344
345impl Frame {
346 /// A point on the unturned plane, `yd` already downward, to the screen.
347 pub(crate) fn to_screen(&self, x: f64, yd: f64) -> (f64, f64) {
348 (self.cx + x * self.ch + yd * self.sh, self.cy - x * self.sh + yd * self.ch)
349 }
350
351 /// A screen point back to the unturned plane, `yd` downward.
352 pub(crate) fn from_screen(&self, sx: f64, sy: f64) -> (f64, f64) {
353 let (dx, dy) = (sx - self.cx, sy - self.cy);
354 (dx * self.ch - dy * self.sh, dx * self.sh + dy * self.ch)
355 }
356
357 /// An orthographic vertex: where it lands on the unturned plane, and how far in front of
358 /// the clip it is. The last is linear in the vector, which is what lets a crossing be
359 /// found by one division along the straight edge drawn between two vertices.
360 fn ortho(&self, p: &[f64; 3]) -> (f64, f64, f64) {
361 (self.r_px * dot3(p, &self.e), -self.r_px * dot3(p, &self.n), dot3(p, &self.c) - self.cos_clip)
362 }
363
364 /// A Mercator vertex's longitude from the central meridian, in radians, and its ordinate,
365 /// both unscaled. `None` for a pole, whose longitude is undefined.
366 fn merc(&self, p: &[f64; 3]) -> Option<(f64, f64)> {
367 if p[0] * p[0] + p[1] * p[1] < 1.0e-24 {
368 return None;
369 }
370 let len = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
371 let y = (p[2] / len).clamp(-1.0, 1.0).atanh().clamp(-PI, PI);
372 Some((wrap_pi(p[1].atan2(p[0]) - self.lam_0), y))
373 }
374}
375
376/// Brings an angle in radians into the half turn either side of zero.
377fn wrap_pi(a: f64) -> f64 {
378 let mut d = a % TAU;
379 if d > PI {
380 d -= TAU;
381 } else if d < -PI {
382 d += TAU;
383 }
384 d
385}
386
387impl Viewport {
388 /// A viewport, refusing numbers that could not draw anything.
389 pub fn new(
390 kind: Projection,
391 lat_0: f64,
392 lon_0: f64,
393 heading: f64,
394 m_per_px: f64,
395 w: f64,
396 h: f64,
397 )
398 -> Outcome<Self>
399 {
400 let v = Self { kind, lat_0, lon_0, heading, m_per_px, w, h };
401 res!(v.check());
402 Ok(v)
403 }
404
405 pub(crate) fn check(&self) -> Outcome<()> {
406 let finite = self.lat_0.is_finite() && self.lon_0.is_finite() && self.heading.is_finite();
407 if !finite {
408 return Err(err!("A viewport centred on {}, {} with heading {} is not a place.",
409 self.lat_0, self.lon_0, self.heading; Invalid, Input));
410 }
411 if !(self.m_per_px > 0.0 && self.m_per_px.is_finite()) {
412 return Err(err!("A viewport of {} m per pixel draws nothing.", self.m_per_px;
413 Invalid, Input, Range));
414 }
415 if !(self.w > 0.0 && self.h > 0.0 && self.w.is_finite() && self.h.is_finite()) {
416 return Err(err!("A viewport {} by {} pixels has no screen.", self.w, self.h;
417 Invalid, Input, Range));
418 }
419 Ok(())
420 }
421
422 pub(crate) fn frame(&self) -> Frame {
423 let lat_0 = self.lat_0.clamp(-90.0, 90.0);
424 let (phi, lam) = (lat_0.to_radians(), self.lon_0.to_radians());
425 let hd = self.heading.to_radians();
426 let (cx, cy) = (self.w / 2.0, self.h / 2.0);
427 let reach = cx.hypot(cy) + CLIP_MARGIN_PX;
428 let mut f = Frame {
429 cx, cy, ch: hd.cos(), sh: hd.sin(), r_px: 0.0,
430 c: [0.0; 3], e: [0.0; 3], n: [0.0; 3], cos_clip: 0.0, rho_clip: 0.0,
431 lam_0: lam, y_0: 0.0, x_lo: 0.0, x_hi: 0.0, y_lo: 0.0, y_hi: 0.0,
432 };
433 match self.kind {
434 Projection::Orthographic => {
435 f.r_px = EARTH_RADIUS_M / self.m_per_px;
436 f.c = unit_vec(lat_0, self.lon_0);
437 f.e = [-lam.sin(), lam.cos(), 0.0];
438 f.n = [-phi.sin() * lam.cos(), -phi.sin() * lam.sin(), phi.cos()];
439 // Clip to the smaller of the horizon and the circle round the screen, so that
440 // close in, a ring is cut just off the screen rather than at a horizon
441 // thousands of pixels away.
442 if reach >= f.r_px {
443 f.cos_clip = 0.0;
444 f.rho_clip = f.r_px;
445 } else {
446 let s = reach / f.r_px;
447 f.cos_clip = (1.0 - s * s).sqrt();
448 f.rho_clip = reach;
449 }
450 },
451 Projection::WebMercator => {
452 let phi_c = lat_0.clamp(-WEB_MERCATOR_MAX_LAT, WEB_MERCATOR_MAX_LAT).to_radians();
453 // The Mercator scale factor at the centre is sec(phi), so the plane is drawn
454 // cos(phi) as large as the ground resolution alone would say.
455 f.r_px = EARTH_RADIUS_M * phi_c.cos() / self.m_per_px;
456 f.y_0 = f.r_px * phi_c.tan().asinh();
457 let m = CLIP_MARGIN_PX;
458 let corners = [(-m, -m), (self.w + m, -m), (-m, self.h + m), (self.w + m, self.h + m)];
459 let (mut xl, mut xh, mut yl, mut yh) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
460 for (sx, sy) in corners {
461 let (x, yd) = f.from_screen(sx, sy);
462 xl = xl.min(x);
463 xh = xh.max(x);
464 yl = yl.min(-yd);
465 yh = yh.max(-yd);
466 }
467 f.x_lo = xl;
468 f.x_hi = xh;
469 f.y_lo = yl;
470 f.y_hi = yh;
471 },
472 }
473 f
474 }
475
476 /// Where a position lands on the screen, or `None` behind the globe.
477 ///
478 /// On the flat map every position lands somewhere, on the copy of the world nearest the
479 /// centre; whether that is within the screen is the caller's question.
480 pub fn forward(&self, lat: f64, lng: f64) -> Option<Pt> {
481 if self.check().is_err() {
482 return None;
483 }
484 let f = self.frame();
485 self.forward_in(&f, lat, lng)
486 }
487
488 /// [`Viewport::forward`] with the frame already made, for a caller projecting many points.
489 pub(crate) fn forward_in(&self, f: &Frame, lat: f64, lng: f64) -> Option<Pt> {
490 match self.kind {
491 Projection::Orthographic => {
492 let p = unit_vec(lat, lng);
493 if dot3(&p, &f.c) < 0.0 {
494 return None;
495 }
496 let (x, yd, _) = f.ortho(&p);
497 let (sx, sy) = f.to_screen(x, yd);
498 Some(Pt::new(sx, sy))
499 },
500 Projection::WebMercator => {
501 let q = web_mercator(lat, lng, self.lon_0, f.r_px);
502 let (sx, sy) = f.to_screen(q.x, -(q.y - f.y_0));
503 Some(Pt::new(sx, sy))
504 },
505 }
506 }
507
508 /// The position under a screen point, as `(lat, lng)` in degrees, or `None` where the
509 /// point is off the globe or above or below the square flat map.
510 pub fn inverse(&self, pt: Pt) -> Option<(f64, f64)> {
511 if self.check().is_err() {
512 return None;
513 }
514 let f = self.frame();
515 let (x, yd) = f.from_screen(pt.x, pt.y);
516 match self.kind {
517 Projection::Orthographic =>
518 orthographic_inverse(Pt::new(x, -yd), self.lat_0, self.lon_0, f.r_px),
519 Projection::WebMercator => {
520 let y = -yd + f.y_0;
521 if y.abs() > PI * f.r_px {
522 return None;
523 }
524 Some(web_mercator_inverse(Pt::new(x, y), self.lon_0, f.r_px))
525 },
526 }
527 }
528
529 /// A spherical cap holding everything the screen shows, as its centre and its angular
530 /// radius in radians.
531 ///
532 /// It is what a caller asks for the cells in view ([`crate::cell::cover_cap`]) and what
533 /// culls a ring before it is projected. On the globe it is exact: the circle round the
534 /// screen, or the horizon if that is nearer. On the flat map it is the furthest point of
535 /// the screen's edge from the centre, with two per cent to spare, and the whole sphere
536 /// once the screen spans half the world either side.
537 pub fn bounding_cap(&self) -> ([f64; 3], f64) {
538 let lat_0 = self.lat_0.clamp(-90.0, 90.0);
539 let centre = unit_vec(lat_0, self.lon_0);
540 if self.check().is_err() {
541 return (centre, PI);
542 }
543 let f = self.frame();
544 match self.kind {
545 Projection::Orthographic => {
546 let reach = f.cx.hypot(f.cy) + CLIP_MARGIN_PX;
547 if reach >= f.r_px {
548 (centre, FRAC_PI_2)
549 } else {
550 (centre, (reach / f.r_px).asin())
551 }
552 },
553 Projection::WebMercator => {
554 let half = PI * f.r_px;
555 if f.x_lo <= -half || f.x_hi >= half {
556 return (centre, PI);
557 }
558 // Walk the screen's edge, clamped to the square map, for the furthest point.
559 const STEPS: usize = 64;
560 let m = CLIP_MARGIN_PX;
561 let mut far: f64 = 0.0;
562 for k in 0..(4 * STEPS) {
563 let t = (k % STEPS) as f64 / STEPS as f64;
564 let (sx, sy) = match k / STEPS {
565 0 => (-m + t * (self.w + 2.0 * m), -m),
566 1 => (self.w + m, -m + t * (self.h + 2.0 * m)),
567 2 => (self.w + m - t * (self.w + 2.0 * m), self.h + m),
568 _ => (-m, self.h + m - t * (self.h + 2.0 * m)),
569 };
570 let (x, yd) = f.from_screen(sx, sy);
571 let y = (-yd + f.y_0).clamp(-half, half);
572 let (lat, lng) = web_mercator_inverse(Pt::new(x, y), self.lon_0, f.r_px);
573 let p = unit_vec(lat, lng);
574 far = far.max(dot3(&p, &centre).clamp(-1.0, 1.0).acos());
575 }
576 (centre, (far * 1.02 + 1.0e-9).min(PI))
577 },
578 }
579 }
580
581 /// Projects rings of unit vectors onto the screen, clipped, into `out`.
582 ///
583 /// This is the one routine that draws a coastline, a border or a cell outline, on either
584 /// projection, so that a caller painting a frame hands over its rings and gets back
585 /// screen paths with nothing left to work out.
586 ///
587 /// On the globe a ring is clipped against a cap: the horizon, or the circle round the
588 /// screen when that is nearer. A filled ring's visible stretches are stitched together
589 /// along that circle's rim in the ring's own winding, so a continent cut by the horizon
590 /// is still a closed shape, and a ring with nothing showing still fills the screen when the
591 /// view is inside it. On the flat map a ring is unwrapped across the antimeridian, closed
592 /// round the pole it encircles if it encircles one, repeated for every copy of the world
593 /// the screen shows, and clipped to the screen.
594 ///
595 /// # Arguments
596 /// * `tol_m` - How far the rings may already stray from the truth, in metres of ground: a
597 /// simplified coastline's own tolerance, or zero for exact rings such as cell outlines.
598 /// A visible stretch of a filled ring no larger than this is noise from simplification
599 /// and is dropped, so that a ring which crosses itself cannot break the stitching.
600 /// * `eps_px` - A vertex this close to the one before it on the screen is dropped.
601 pub fn project_rings<R: AsRef<[[f64; 3]]>>(
602 &self,
603 rings: &[R],
604 mode: RingMode,
605 tol_m: f64,
606 eps_px: f64,
607 out: &mut ScreenPaths,
608 )
609 -> Outcome<()>
610 {
611 res!(self.check());
612 let f = self.frame();
613 let eps = if eps_px.is_finite() { eps_px.max(0.0) } else { 0.0 };
614 let mut pen = Pen { out, eps2: eps * eps, open: false };
615 match self.kind {
616 Projection::Orthographic => {
617 let slack = if tol_m.is_finite() { f.r_px * tol_m.max(0.0) / EARTH_RADIUS_M } else { 0.0 };
618 // The rim is walked in steps whose sagitta is under the vertex tolerance.
619 let sag = eps.max(0.25).min(f.rho_clip);
620 let step = (2.0 * (1.0 - sag / f.rho_clip).acos()).clamp(1.0e-4, 0.1);
621 for ring in rings {
622 let ring = ring.as_ref();
623 match mode {
624 RingMode::Fill => ortho_fill(&f, ring, slack, step, &mut pen),
625 _ => ortho_line(&f, ring, mode == RingMode::Outline, &mut pen),
626 }
627 }
628 },
629 Projection::WebMercator => {
630 for ring in rings {
631 merc_ring(&f, ring.as_ref(), mode, &mut pen);
632 }
633 },
634 }
635 Ok(())
636 }
637}
638
639// ---------------------------------------------------------------------------------------------
640// Screen paths
641// ---------------------------------------------------------------------------------------------
642
643/// One path in a [`ScreenPaths`]: a run of its points, and whether to close it.
644#[derive(Clone, Copy, Debug, PartialEq, Eq)]
645pub struct PathSpan {
646 pub start: u32, // index of the first point, in points rather than floats
647 pub len: u32, // number of points
648 pub closed: bool, // join the last point back to the first
649}
650
651/// Screen paths as flat `f32` pairs, the shape a canvas painter or a typed array wants.
652///
653/// Kept by the caller and cleared between frames, so a frame allocates nothing once the
654/// buffers have grown to fit.
655#[derive(Clone, Debug, Default)]
656pub struct ScreenPaths {
657 pub xy: Vec<f32>, // x0, y0, x1, y1, ... in screen pixels
658 pub spans: Vec<PathSpan>,
659}
660
661impl ScreenPaths {
662 pub fn new() -> Self { Self::default() }
663
664 pub fn clear(&mut self) {
665 self.xy.clear();
666 self.spans.clear();
667 }
668
669 pub fn len(&self) -> usize { self.spans.len() }
670
671 pub fn is_empty(&self) -> bool { self.spans.is_empty() }
672
673 /// The points of the `i`th path as `x, y` pairs, and whether it is closed.
674 pub fn path(&self, i: usize) -> Option<(&[f32], bool)> {
675 let s = match self.spans.get(i) {
676 Some(s) => s,
677 None => return None,
678 };
679 let a = 2 * s.start as usize;
680 let b = a + 2 * s.len as usize;
681 match self.xy.get(a..b) {
682 Some(pts) => Some((pts, s.closed)),
683 None => None,
684 }
685 }
686}
687
688/// Writes paths into a [`ScreenPaths`], dropping vertices within the tolerance of the last.
689struct Pen<'a> {
690 out: &'a mut ScreenPaths,
691 eps2: f64,
692 open: bool,
693}
694
695impl<'a> Pen<'a> {
696 fn begin(&mut self) {
697 if self.open {
698 self.end(false);
699 }
700 let start = (self.out.xy.len() / 2) as u32;
701 self.out.spans.push(PathSpan { start, len: 0, closed: false });
702 self.open = true;
703 }
704
705 fn push(&mut self, x: f64, y: f64) {
706 if !self.open {
707 self.begin();
708 }
709 let n = self.out.xy.len();
710 if let Some(s) = self.out.spans.last() {
711 if s.len > 0 {
712 let dx = x - self.out.xy[n - 2] as f64;
713 let dy = y - self.out.xy[n - 1] as f64;
714 if dx * dx + dy * dy <= self.eps2 {
715 return;
716 }
717 }
718 }
719 self.out.xy.push(x as f32);
720 self.out.xy.push(y as f32);
721 if let Some(s) = self.out.spans.last_mut() {
722 s.len += 1;
723 }
724 }
725
726 /// Ends the open path, discarding it if it has too few points to draw.
727 fn end(&mut self, closed: bool) {
728 if !self.open {
729 return;
730 }
731 self.open = false;
732 let least = if closed { 3 } else { 2 };
733 let keep = match self.out.spans.last_mut() {
734 Some(s) => {
735 s.closed = closed;
736 s.len >= least
737 },
738 None => return,
739 };
740 if !keep {
741 if let Some(s) = self.out.spans.pop() {
742 self.out.xy.truncate(2 * s.start as usize);
743 }
744 }
745 }
746}
747
748// ---------------------------------------------------------------------------------------------
749// The globe: clip to a cap and walk the rim
750// ---------------------------------------------------------------------------------------------
751//
752// Lifted from Ochre's globe (`web/apps/ochre/src/web/globe.rs`, August 2026), which drew it
753// against the horizon alone and verified it against PROJ and against the sea staying sea at
754// every turn. Two things are new here: the clip cap may be smaller than the horizon, so that
755// close in the rim being walked is the circle just round the screen rather than one thousands
756// of pixels across; and the rim is walked out as points, so the result is plain polylines.
757
758/// Which way round a ring is wound, seen from outside the sphere.
759///
760/// Measured by the vector area of the ring's own chords, whose component along the ring's own
761/// centre is twice the signed area it covers. A polygon's outline and the holes in it come in
762/// the same list of rings, and a hole winds the other way.
763fn wound_cw(ring: &[[f64; 3]]) -> bool {
764 let mut sum = [0.0; 3];
765 let mut area = [0.0; 3];
766 let n = ring.len();
767 for i in 0..n {
768 let a = &ring[i];
769 let b = &ring[(i + 1) % n];
770 sum = [sum[0] + a[0], sum[1] + a[1], sum[2] + a[2]];
771 area = [
772 area[0] + a[1] * b[2] - a[2] * b[1],
773 area[1] + a[2] * b[0] - a[0] * b[2],
774 area[2] + a[0] * b[1] - a[1] * b[0],
775 ];
776 }
777 let m = dot3(&sum, &sum).sqrt().max(f64::EPSILON);
778 dot3(&area, &sum) / m < 0.0
779}
780
781/// Is the view inside a ring none of whose boundary shows?
782///
783/// The bearing of the ring about the centre is swept right round, and a ring wound the
784/// shapefile way sweeps a whole turn forwards when the view is inside it. A view whose
785/// antipode is inside sweeps a whole turn backwards, so the direction is the answer and the
786/// size alone is not.
787fn inside(seen: &[(f64, f64, f64)], cw: bool) -> bool {
788 let mut sweep = 0.0;
789 let mut last = 0.0;
790 for i in 0..=seen.len() {
791 let p = seen[i % seen.len()];
792 let angle = p.1.atan2(p.0);
793 if i > 0 {
794 let mut step = angle - last;
795 if step > PI {
796 step -= TAU;
797 }
798 if step < -PI {
799 step += TAU;
800 }
801 sweep += step;
802 }
803 last = angle;
804 }
805 if cw { sweep > PI } else { sweep < -PI }
806}
807
808/// A visible stretch of a filled ring, between two crossings of the clip.
809struct Run {
810 pts: Vec<(f64, f64)>, // on the unturned plane, the ends on the clip circle
811 entry: f64, // angle on the clip circle where it starts
812 exit: f64, // and where it ends
813 done: bool, // already drawn into a loop
814}
815
816/// Where the edge from `was` to `here` crosses the clip, on the clip circle.
817fn crossing(f: &Frame, was: (f64, f64, f64), here: (f64, f64, f64)) -> (f64, f64, f64) {
818 let s = was.2 / (was.2 - here.2);
819 let (ex, ey) = (was.0 + s * (here.0 - was.0), was.1 + s * (here.1 - was.1));
820 // The crossing lies on the chord, a hair inside the sphere, and is put on the rim.
821 let m = ex.hypot(ey).max(f64::EPSILON);
822 let (ex, ey) = (ex / m * f.rho_clip, ey / m * f.rho_clip);
823 (ex, ey, ey.atan2(ex))
824}
825
826/// Walks the clip circle from angle `from` through `span` radians (signed), excluding both
827/// ends.
828fn rim(f: &Frame, from: f64, span: f64, step: f64, pen: &mut Pen) {
829 let k = (span.abs() / step).ceil() as usize;
830 for i in 1..k {
831 let a = from + span * i as f64 / k as f64;
832 let (sx, sy) = f.to_screen(f.rho_clip * a.cos(), f.rho_clip * a.sin());
833 pen.push(sx, sy);
834 }
835}
836
837fn ortho_fill(f: &Frame, ring: &[[f64; 3]], slack: f64, step: f64, pen: &mut Pen) {
838 let n = ring.len();
839 if n < 3 {
840 return;
841 }
842 let seen: Vec<(f64, f64, f64)> = ring.iter().map(|p| f.ortho(p)).collect();
843 let first = match seen.iter().position(|p| p.2 >= 0.0) {
844 Some(at) => at,
845 None => {
846 // Nothing of the boundary shows: the ring is out of sight, or the whole view is
847 // inside it and the clip circle is filled.
848 let cw = wound_cw(ring);
849 if inside(&seen, cw) {
850 let way = if cw { TAU } else { -TAU };
851 pen.begin();
852 let (sx, sy) = f.to_screen(f.rho_clip, 0.0);
853 pen.push(sx, sy);
854 rim(f, 0.0, way, step, pen);
855 pen.end(true);
856 }
857 return;
858 },
859 };
860 let cw = wound_cw(ring);
861
862 // Gather the visible stretches, each ending exactly where the ring meets the clip.
863 let mut runs: Vec<Run> = Vec::new();
864 let mut open = false;
865 let mut hidden = false;
866 for k in 0..=n {
867 let here = seen[(first + k) % n];
868 if k > 0 {
869 let was = seen[(first + k - 1) % n];
870 if (was.2 >= 0.0) != (here.2 >= 0.0) {
871 let (ex, ey, angle) = crossing(f, was, here);
872 if here.2 < 0.0 {
873 if let Some(run) = runs.last_mut() {
874 run.pts.push((ex, ey));
875 run.exit = angle;
876 }
877 open = false;
878 } else if k == n {
879 // The wrap: this is where the first run began.
880 runs[0].pts.insert(0, (ex, ey));
881 runs[0].entry = angle;
882 } else {
883 runs.push(Run { pts: vec![(ex, ey)], entry: angle, exit: 0.0, done: false });
884 open = true;
885 }
886 }
887 }
888 if here.2 < 0.0 {
889 hidden = true;
890 continue;
891 }
892 if k == n {
893 break;
894 }
895 if !open {
896 runs.push(Run { pts: Vec::new(), entry: 0.0, exit: 0.0, done: false });
897 open = true;
898 }
899 if let Some(run) = runs.last_mut() {
900 run.pts.push((here.0, here.1));
901 }
902 }
903 if !hidden {
904 // Wholly in view: one plain closed outline.
905 pen.begin();
906 for (x, y) in &runs[0].pts {
907 let (sx, sy) = f.to_screen(*x, *y);
908 pen.push(sx, sy);
909 }
910 pen.end(true);
911 return;
912 }
913 if open && runs.len() > 1 {
914 // Ended visible, so the open run wraps onto the first one.
915 let head = runs.remove(0);
916 if let Some(run) = runs.last_mut() {
917 run.pts.extend(head.pts);
918 run.exit = head.exit;
919 }
920 }
921 // A run no bigger than the rings' own tolerance is a stub from simplification, and goes
922 // with the two crossings that bound it. Entries and exits have to alternate round the rim
923 // for the nearest one to be the right one, and a ring that crosses itself breaks that.
924 runs.retain(|run| {
925 let (mut lox, mut hix) = (f64::MAX, f64::MIN);
926 let (mut loy, mut hiy) = (f64::MAX, f64::MIN);
927 for (x, y) in &run.pts {
928 lox = lox.min(*x);
929 hix = hix.max(*x);
930 loy = loy.min(*y);
931 hiy = hiy.max(*y);
932 }
933 hix - lox >= slack || hiy - loy >= slack
934 });
935 if runs.is_empty() {
936 return;
937 }
938 // Slack as an angle on the clip circle, for deciding that two crossings are one.
939 let slack_a = slack / f.rho_clip;
940
941 // Stitch: from each run's exit the rim is walked, in the ring's own winding, to the
942 // nearest entry, and loops close where they began.
943 for start in 0..runs.len() {
944 if runs[start].done {
945 continue;
946 }
947 let mut at = start;
948 pen.begin();
949 for _ in 0..=runs.len() {
950 runs[at].done = true;
951 for (x, y) in &runs[at].pts {
952 let (sx, sy) = f.to_screen(*x, *y);
953 pen.push(sx, sy);
954 }
955 let exit = runs[at].exit;
956 let (mut best, mut near) = (at, f64::MAX);
957 for i in 0..runs.len() {
958 if runs[i].done && i != start {
959 continue;
960 }
961 let raw = if cw { runs[i].entry - exit } else { exit - runs[i].entry };
962 let mut d = raw.rem_euclid(TAU);
963 if d > TAU - slack_a {
964 d -= TAU;
965 }
966 if d < near {
967 near = d;
968 best = i;
969 }
970 }
971 if near > 0.0 {
972 rim(f, exit, if cw { near } else { -near }, step, pen);
973 }
974 if best == start {
975 break;
976 }
977 at = best;
978 }
979 pen.end(true);
980 }
981}
982
983fn ortho_line(f: &Frame, ring: &[[f64; 3]], closed: bool, pen: &mut Pen) {
984 let n = ring.len();
985 if n < 2 {
986 return;
987 }
988 let seen: Vec<(f64, f64, f64)> = ring.iter().map(|p| f.ortho(p)).collect();
989 let start = match seen.iter().position(|p| p.2 < 0.0) {
990 Some(at) => at,
991 None => {
992 // Wholly in view.
993 pen.begin();
994 for p in &seen {
995 let (sx, sy) = f.to_screen(p.0, p.1);
996 pen.push(sx, sy);
997 }
998 pen.end(closed);
999 return;
1000 },
1001 };
1002 // A closed outline is walked from a hidden vertex round to itself, so that no path is
1003 // broken where the ring happens to begin.
1004 let (from, count) = if closed { (start, n + 1) } else { (0, n) };
1005 let mut open = false;
1006 for k in 0..count {
1007 let here = seen[(from + k) % n];
1008 if k > 0 {
1009 let was = seen[(from + k - 1) % n];
1010 if (was.2 >= 0.0) != (here.2 >= 0.0) {
1011 let (ex, ey, _) = crossing(f, was, here);
1012 let (sx, sy) = f.to_screen(ex, ey);
1013 if here.2 >= 0.0 {
1014 pen.begin();
1015 pen.push(sx, sy);
1016 open = true;
1017 } else {
1018 pen.push(sx, sy);
1019 pen.end(false);
1020 open = false;
1021 }
1022 }
1023 }
1024 if here.2 >= 0.0 {
1025 if !open {
1026 pen.begin();
1027 open = true;
1028 }
1029 let (sx, sy) = f.to_screen(here.0, here.1);
1030 pen.push(sx, sy);
1031 }
1032 }
1033 if open {
1034 pen.end(false);
1035 }
1036}
1037
1038// ---------------------------------------------------------------------------------------------
1039// The flat map: unwrap, repeat and clip
1040// ---------------------------------------------------------------------------------------------
1041
1042/// A ring on the unturned Mercator plane, in pixels from the centre with `y` north, its
1043/// longitudes unwrapped so that it runs continuously across the antimeridian.
1044///
1045/// A vertex at a pole has no longitude, so it becomes two points on the map's top or bottom
1046/// edge, under the vertices either side of it.
1047fn merc_unwrap(f: &Frame, ring: &[[f64; 3]], closed: bool) -> Vec<(f64, f64)> {
1048 let n = ring.len();
1049 // A closed ring is started at a vertex that has a longitude.
1050 let from = if closed {
1051 match ring.iter().position(|p| f.merc(p).is_some()) {
1052 Some(at) => at,
1053 None => return Vec::new(),
1054 }
1055 } else {
1056 0
1057 };
1058 let mut out: Vec<(f64, f64)> = Vec::with_capacity(n + 2);
1059 let mut last: Option<(f64, f64)> = None; // unwrapped longitude, raw longitude
1060 let mut pole: Option<f64> = None; // the ordinate of a pole awaiting a longitude
1061 for k in 0..n {
1062 let p = &ring[(from + k) % n];
1063 match f.merc(p) {
1064 None => {
1065 let y = if p[2] > 0.0 { PI } else { -PI };
1066 if let Some((u, _)) = last {
1067 out.push((u, y));
1068 }
1069 pole = Some(y);
1070 },
1071 Some((lam, y)) => {
1072 let u = match last {
1073 Some((u, l)) => u + wrap_pi(lam - l),
1074 None => lam,
1075 };
1076 if let Some(py) = pole.take() {
1077 out.push((u, py));
1078 }
1079 out.push((u, y));
1080 last = Some((u, lam));
1081 },
1082 }
1083 }
1084 if closed {
1085 if let (Some(py), Some((u_last, l_last))) = (pole, last) {
1086 // A closed ring ending on a pole: the pole's second point sits under the first
1087 // vertex, reached from the last.
1088 if let Some(Some((lam0, _))) = ring.get(from).map(|p| f.merc(p)) {
1089 out.push((u_last + wrap_pi(lam0 - l_last), py));
1090 }
1091 }
1092 }
1093 for p in out.iter_mut() {
1094 p.0 *= f.r_px;
1095 p.1 = p.1 * f.r_px - f.y_0;
1096 }
1097 out
1098}
1099
1100fn merc_ring(f: &Frame, ring: &[[f64; 3]], mode: RingMode, pen: &mut Pen) {
1101 let closed = mode != RingMode::Line;
1102 let mut pts = merc_unwrap(f, ring, closed);
1103 if pts.len() < 2 {
1104 return;
1105 }
1106 let world = TAU * f.r_px;
1107 match mode {
1108 RingMode::Fill => {
1109 // A ring that winds once round the world encircles a pole, and is closed round
1110 // the side of the map where that pole is. Which pole is the one its vertices lean
1111 // towards: no land ring encloses more than a hemisphere.
1112 let first = pts[0];
1113 let last = pts[pts.len() - 1];
1114 let lam_first = first.0 / f.r_px;
1115 let lam_last = last.0 / f.r_px;
1116 let wind = lam_last + wrap_pi(lam_first - lam_last) - lam_first;
1117 if wind.abs() > PI {
1118 let lean: f64 = ring.iter().map(|p| p[2]).sum();
1119 let cap = if lean > 0.0 { PI * f.r_px - f.y_0 } else { -PI * f.r_px - f.y_0 };
1120 let u_end = first.0 + wind * f.r_px;
1121 pts.push((u_end, first.1));
1122 pts.push((u_end, cap));
1123 pts.push((first.0, cap));
1124 }
1125 },
1126 RingMode::Outline => pts.push(pts[0]),
1127 RingMode::Line => (),
1128 }
1129 let (mut xl, mut xh, mut yl, mut yh) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
1130 for (x, y) in &pts {
1131 xl = xl.min(*x);
1132 xh = xh.max(*x);
1133 yl = yl.min(*y);
1134 yh = yh.max(*y);
1135 }
1136 if yh < f.y_lo || yl > f.y_hi {
1137 return;
1138 }
1139 let k_lo = ((f.x_lo - xh) / world).ceil() as i64;
1140 let k_hi = ((f.x_hi - xl) / world).floor() as i64;
1141 if k_hi < k_lo || k_hi - k_lo > 64 {
1142 return;
1143 }
1144 let (w, h) = (2.0 * f.cx, 2.0 * f.cy);
1145 let rect = (-CLIP_MARGIN_PX, -CLIP_MARGIN_PX, w + CLIP_MARGIN_PX, h + CLIP_MARGIN_PX);
1146 let mut screen: Vec<(f64, f64)> = Vec::with_capacity(pts.len());
1147 for k in k_lo..=k_hi {
1148 let dx = k as f64 * world;
1149 screen.clear();
1150 for (x, y) in &pts {
1151 screen.push(f.to_screen(x + dx, -y));
1152 }
1153 if mode == RingMode::Fill {
1154 let clipped = clip_polygon(&screen, rect);
1155 if clipped.len() >= 3 {
1156 pen.begin();
1157 for (sx, sy) in &clipped {
1158 pen.push(*sx, *sy);
1159 }
1160 pen.end(true);
1161 }
1162 } else {
1163 clip_polyline(&screen, rect, pen);
1164 }
1165 }
1166}
1167
1168/// Sutherland and Hodgman's polygon clip against an axis-aligned rectangle.
1169///
1170/// It keeps the winding, so holes stay holes under a nonzero fill, and where a polygon leaves
1171/// and re-enters it leaves degenerate edges along the rectangle, which fill nothing.
1172fn clip_polygon(poly: &[(f64, f64)], rect: (f64, f64, f64, f64)) -> Vec<(f64, f64)> {
1173 let (x0, y0, x1, y1) = rect;
1174 let mut cur: Vec<(f64, f64)> = poly.to_vec();
1175 for edge in 0..4 {
1176 if cur.is_empty() {
1177 break;
1178 }
1179 let keep = |p: &(f64, f64)| -> bool {
1180 match edge {
1181 0 => p.0 >= x0,
1182 1 => p.0 <= x1,
1183 2 => p.1 >= y0,
1184 _ => p.1 <= y1,
1185 }
1186 };
1187 let cut = |a: &(f64, f64), b: &(f64, f64)| -> (f64, f64) {
1188 let t = match edge {
1189 0 => (x0 - a.0) / (b.0 - a.0),
1190 1 => (x1 - a.0) / (b.0 - a.0),
1191 2 => (y0 - a.1) / (b.1 - a.1),
1192 _ => (y1 - a.1) / (b.1 - a.1),
1193 };
1194 (a.0 + t * (b.0 - a.0), a.1 + t * (b.1 - a.1))
1195 };
1196 let mut next = Vec::with_capacity(cur.len() + 4);
1197 let n = cur.len();
1198 for i in 0..n {
1199 let a = &cur[(i + n - 1) % n];
1200 let b = &cur[i];
1201 match (keep(a), keep(b)) {
1202 (true, true) => next.push(*b),
1203 (true, false) => next.push(cut(a, b)),
1204 (false, true) => {
1205 next.push(cut(a, b));
1206 next.push(*b);
1207 },
1208 (false, false) => (),
1209 }
1210 }
1211 cur = next;
1212 }
1213 cur
1214}
1215
1216/// Liang and Barsky's segment clip, run along a polyline, cutting it into the pieces that lie
1217/// within the rectangle.
1218fn clip_polyline(line: &[(f64, f64)], rect: (f64, f64, f64, f64), pen: &mut Pen) {
1219 let (x0, y0, x1, y1) = rect;
1220 let mut open = false;
1221 for pair in line.windows(2) {
1222 let (a, b) = (pair[0], pair[1]);
1223 let (dx, dy) = (b.0 - a.0, b.1 - a.1);
1224 let (mut t0, mut t1) = (0.0f64, 1.0f64);
1225 let mut gone = false;
1226 for (p, q) in [(-dx, a.0 - x0), (dx, x1 - a.0), (-dy, a.1 - y0), (dy, y1 - a.1)] {
1227 if p == 0.0 {
1228 if q < 0.0 {
1229 gone = true;
1230 break;
1231 }
1232 } else {
1233 let r = q / p;
1234 if p < 0.0 {
1235 t0 = t0.max(r);
1236 } else {
1237 t1 = t1.min(r);
1238 }
1239 }
1240 }
1241 if gone || t0 > t1 {
1242 if open {
1243 pen.end(false);
1244 open = false;
1245 }
1246 continue;
1247 }
1248 if !open || t0 > 0.0 {
1249 if open {
1250 pen.end(false);
1251 }
1252 pen.begin();
1253 pen.push(a.0 + t0 * dx, a.1 + t0 * dy);
1254 open = true;
1255 }
1256 pen.push(a.0 + t1 * dx, a.1 + t1 * dy);
1257 if t1 < 1.0 {
1258 pen.end(false);
1259 open = false;
1260 }
1261 }
1262 if open {
1263 pen.end(false);
1264 }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270
1271 /// How close a projected coordinate has to be to the oracle's, on a unit sphere.
1272 ///
1273 /// The oracle is printed to twelve decimal places, so this is loose enough to absorb its
1274 /// own rounding and tight enough that a wrong coefficient could not pass.
1275 const TOL: f64 = 1.0e-11;
1276
1277 /// Positions and their projections on a unit sphere, Greenwich centred.
1278 ///
1279 /// The expected values come from PROJ 9.7.1, an implementation outside this crate:
1280 ///
1281 /// ```text
1282 /// proj -f "%.12f" +proj=eqearth +R=1 +lon_0=0
1283 /// ```
1284 ///
1285 /// PROJ is the reference implementation the projection's authors' work was folded into,
1286 /// and it is not derived from anything here.
1287 const ORACLE: [(f64, f64, f64, f64); 18] = [
1288 // Perth.
1289 (-31.9535, 115.8571, 1.614621210487, -0.629372441721),
1290 // London.
1291 ( 51.5074, -0.1278, -0.001565514410, 0.965105647318),
1292 // New York.
1293 ( 40.7128, -74.0060, -0.981861449986, 0.787064220049),
1294 // Tokyo.
1295 ( 35.6895, 139.6917, 1.909462941700, 0.697844715778),
1296 // Cape Town.
1297 (-33.9189, 18.4233, 0.254226330215, -0.665601683690),
1298 // Buenos Aires.
1299 (-34.6037, -58.3816, -0.802723755324, -0.678117876992),
1300 // Sydney.
1301 (-33.8688, 151.2093, 2.087106438222, -0.664683776716),
1302 // Honolulu.
1303 ( 21.3069, -157.8580, -2.295788127853, 0.426387151159),
1304 // The origin, and the widest and tallest points of the map.
1305 ( 0.0, 0.0, 0.0, 0.0),
1306 ( 0.0, 180.0, 2.706629983696, 0.0),
1307 ( 0.0, -180.0, -2.706629983696, 0.0),
1308 ( 90.0, 0.0, 0.0, 1.317362759157),
1309 (-90.0, 0.0, 0.0, -1.317362759157),
1310 // Round numbers in all four quadrants.
1311 ( 45.0, 90.0, 1.159854499103, 0.860231085522),
1312 (-45.0, -90.0, -1.159854499103, -0.860231085522),
1313 ( 60.0, 30.0, 0.339843347929, 1.088300835505),
1314 (-60.0, 120.0, 1.359373391714, -1.088300835505),
1315 // A small angle, where the polynomial's high terms contribute nothing.
1316 ( 1.0, 2.0, 0.030071478499, 0.020257546047),
1317 ];
1318
1319 #[test]
1320 fn test_equal_earth_agrees_with_proj_00() -> Outcome<()> {
1321 for (lat, lng, x, y) in ORACLE {
1322 let p = equal_earth_unit(lat, lng);
1323 let near_x = (p.x - x).abs() < TOL;
1324 let near_y = (p.y - y).abs() < TOL;
1325 req!(near_x, true, "{}, {} came out at x = {:.12}, wanted {:.12}.", lat, lng, p.x, x);
1326 req!(near_y, true, "{}, {} came out at y = {:.12}, wanted {:.12}.", lat, lng, p.y, y);
1327 }
1328 Ok(())
1329 }
1330
1331 #[test]
1332 fn test_the_stated_extremes_are_the_extremes_01() -> Outcome<()> {
1333 let east = equal_earth_unit(0.0, 180.0);
1334 let near = (east.x - EQUAL_EARTH_HALF_WIDTH).abs() < TOL;
1335 req!(near, true, "The map is {} wide, not {}.", east.x, EQUAL_EARTH_HALF_WIDTH);
1336 let north = equal_earth_unit(90.0, 0.0);
1337 let near = (north.y - EQUAL_EARTH_HALF_HEIGHT).abs() < TOL;
1338 req!(near, true, "The map is {} tall, not {}.", north.y, EQUAL_EARTH_HALF_HEIGHT);
1339 // No point escapes the box those two describe.
1340 let mut lat = -90.0;
1341 while lat <= 90.0 {
1342 let mut lng = -180.0;
1343 while lng <= 180.0 {
1344 let p = equal_earth_unit(lat, lng);
1345 let in_x = p.x.abs() <= EQUAL_EARTH_HALF_WIDTH + TOL;
1346 let in_y = p.y.abs() <= EQUAL_EARTH_HALF_HEIGHT + TOL;
1347 req!(in_x, true, "x escaped at {}, {}.", lat, lng);
1348 req!(in_y, true, "y escaped at {}, {}.", lat, lng);
1349 lng += 3.0;
1350 }
1351 lat += 3.0;
1352 }
1353 Ok(())
1354 }
1355
1356 #[test]
1357 fn test_the_projection_is_symmetric_02() -> Outcome<()> {
1358 // A pseudocylindrical projection is symmetric about both axes, so the same
1359 // latitude north and south is the same height, and east and west the same width.
1360 for (lat, lng) in [(31.9535, 115.8571), (12.0, 5.0), (78.0, 179.0)] {
1361 let a = equal_earth_unit(lat, lng);
1362 let b = equal_earth_unit(-lat, lng);
1363 let c = equal_earth_unit(lat, -lng);
1364 let same_x = (a.x - b.x).abs() < TOL;
1365 let flip_y = (a.y + b.y).abs() < TOL;
1366 let flip_x = (a.x + c.x).abs() < TOL;
1367 let same_y = (a.y - c.y).abs() < TOL;
1368 req!(same_x, true, "x differed across the equator at {}.", lat);
1369 req!(flip_y, true, "y was not mirrored across the equator at {}.", lat);
1370 req!(flip_x, true, "x was not mirrored across Greenwich at {}.", lng);
1371 req!(same_y, true, "y differed across Greenwich at {}.", lng);
1372 }
1373 // A parallel is straight: every longitude on it has the same y.
1374 let y = equal_earth_unit(20.0, 0.0).y;
1375 for lng in [-170.0, -60.0, 45.0, 179.9] {
1376 let p = equal_earth_unit(20.0, lng);
1377 let level = (p.y - y).abs() < TOL;
1378 req!(level, true, "The 20° parallel bent at {}.", lng);
1379 }
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn test_a_central_meridian_moves_the_map_and_not_its_shape_03() -> Outcome<()> {
1385 // Recentring on 150° puts Sydney where Greenwich centring puts 1.209° east.
1386 let a = equal_earth(-33.8688, 151.2093, 150.0, 1.0);
1387 let b = equal_earth(-33.8688, 1.2093, 0.0, 1.0);
1388 let same_x = (a.x - b.x).abs() < TOL;
1389 let same_y = (a.y - b.y).abs() < TOL;
1390 req!(same_x, true, "Recentring moved x to {} rather than {}.", a.x, b.x);
1391 req!(same_y, true, "Recentring moved y at all: {} against {}.", a.y, b.y);
1392 // And a longitude that wraps past the antimeridian lands on the far side rather
1393 // than off the map.
1394 let west = equal_earth(0.0, -170.0, 20.0, 1.0);
1395 let wrapped = west.x > 0.0;
1396 req!(wrapped, true, "170° west of a map centred on 20° east came out at {}.", west.x);
1397 Ok(())
1398 }
1399
1400 #[test]
1401 fn test_a_radius_scales_the_map_uniformly_04() -> Outcome<()> {
1402 let r = equal_earth_radius_for_half_width(180.0);
1403 let east = equal_earth(0.0, 180.0, 0.0, r);
1404 let wide = (east.x - 180.0).abs() < 1.0e-9;
1405 req!(wide, true, "The map came out {} wide.", east.x);
1406 let north = equal_earth(90.0, 0.0, 0.0, r);
1407 let want = 180.0 * EQUAL_EARTH_HALF_HEIGHT / EQUAL_EARTH_HALF_WIDTH;
1408 let tall = (north.y - want).abs() < 1.0e-9;
1409 req!(tall, true, "The map came out {} tall.", north.y);
1410 // Scaling is uniform, or the projection would stop being equal-area.
1411 let one = equal_earth_unit(-31.9535, 115.8571);
1412 let big = equal_earth(-31.9535, 115.8571, 0.0, r);
1413 let scaled_x = (big.x - one.x * r).abs() < 1.0e-9;
1414 let scaled_y = (big.y - one.y * r).abs() < 1.0e-9;
1415 req!(scaled_x, true, "x did not scale by the radius.");
1416 req!(scaled_y, true, "y did not scale by the radius.");
1417 Ok(())
1418 }
1419
1420 /// Positions and their orthographic projections on a unit sphere centred on the origin.
1421 ///
1422 /// From PROJ 9.7.1, an implementation outside this crate:
1423 ///
1424 /// ```text
1425 /// proj -f "%.12f" +proj=ortho +R=1 +lat_0=0 +lon_0=0
1426 /// ```
1427 ///
1428 /// Every position here is on the near hemisphere, because PROJ answers `*` for the far
1429 /// one rather than a coordinate; that refusal is what
1430 /// [`test_the_far_side_of_the_globe_is_known_from_the_near_07`] checks against.
1431 const ORTHO_ORACLE: [(f64, f64, f64, f64); 11] = [
1432 // The centre itself.
1433 ( 0.0, 0.0, 0.0, 0.0),
1434 // London, Cape Town, Buenos Aires, New York: all within a quarter turn of Greenwich.
1435 ( 51.5074, -0.1278, -0.001388311442, 0.782688550807),
1436 (-33.9189, 18.4233, 0.262254675949, -0.558018872482),
1437 (-34.6037, -58.3816, -0.700917639761, -0.567896899696),
1438 ( 40.7128, -74.0060, -0.728647317901, 0.652267756393),
1439 // The rim, a tenth of a degree short of it.
1440 ( 0.0, 89.9, 0.999998476913, 0.0),
1441 // Both poles, which sit at the top and bottom of the disc from the equator.
1442 ( 90.0, 0.0, 0.0, 1.0),
1443 (-90.0, 0.0, 0.0, -1.0),
1444 // Round numbers, and a small angle where the foreshortening is negligible.
1445 ( 1.0, 2.0, 0.034894181340, 0.017452406437),
1446 ( 60.0, 30.0, 0.250000000000, 0.866025403784),
1447 (-45.0, -90.0, -0.707106781187, -0.707106781187),
1448 ];
1449
1450 /// The same, seen from above Perth, so the centre is oblique in both coordinates.
1451 ///
1452 /// ```text
1453 /// proj -f "%.12f" +proj=ortho +R=1 +lat_0=-31.9535 +lon_0=115.8571
1454 /// ```
1455 const ORTHO_ORACLE_PERTH: [(f64, f64, f64, f64); 8] = [
1456 // Perth, which is the centre.
1457 (-31.9535, 115.8571, 0.0, 0.0),
1458 // Sydney, Tokyo, Kuala Lumpur, Auckland, Suva.
1459 (-33.8688, 151.2093, 0.480421544470, -0.114447989448),
1460 ( 35.6895, 139.6917, 0.328204336296, 0.888173527162),
1461 ( 3.1390, 101.6869, -0.244435840733, 0.558819302390),
1462 (-36.8485, 174.7633, 0.685250212424, -0.290118911137),
1463 (-18.1416, 178.4419, 0.843565957319, -0.032624197369),
1464 // Fremantle, sixteen kilometres away and barely off the centre.
1465 (-32.0569, 115.7439, -0.001674457753, -0.001805544881),
1466 // The south pole, which from this latitude sits low on the disc and not on its rim.
1467 (-90.0, 0.0, 0.0, -0.848477887693),
1468 ];
1469
1470 #[test]
1471 fn test_orthographic_agrees_with_proj_06() -> Outcome<()> {
1472 for (lat, lng, x, y) in ORTHO_ORACLE {
1473 let p = orthographic(lat, lng, 0.0, 0.0, 1.0);
1474 let near_x = (p.x - x).abs() < TOL;
1475 let near_y = (p.y - y).abs() < TOL;
1476 req!(near_x, true, "{}, {} came out at x = {:.12}, wanted {:.12}.", lat, lng, p.x, x);
1477 req!(near_y, true, "{}, {} came out at y = {:.12}, wanted {:.12}.", lat, lng, p.y, y);
1478 }
1479 for (lat, lng, x, y) in ORTHO_ORACLE_PERTH {
1480 let p = orthographic(lat, lng, -31.9535, 115.8571, 1.0);
1481 let near_x = (p.x - x).abs() < TOL;
1482 let near_y = (p.y - y).abs() < TOL;
1483 req!(near_x, true, "{}, {} came out at x = {:.12}, wanted {:.12}.", lat, lng, p.x, x);
1484 req!(near_y, true, "{}, {} came out at y = {:.12}, wanted {:.12}.", lat, lng, p.y, y);
1485 }
1486 Ok(())
1487 }
1488
1489 #[test]
1490 fn test_the_far_side_of_the_globe_is_known_from_the_near_07() -> Outcome<()> {
1491 // Perth is a quarter turn and more from Greenwich, so PROJ answers `*` for it on a
1492 // Greenwich-centred globe. The cosine is what says so here.
1493 let behind = orthographic_cos_c(-31.9535, 115.8571, 0.0, 0.0);
1494 let far = behind < 0.0;
1495 req!(far, true, "Perth came out in front of Greenwich, at {}.", behind);
1496 // The centre is directly under the viewer, and its antipode directly behind.
1497 let under = orthographic_cos_c(-31.9535, 115.8571, -31.9535, 115.8571);
1498 let full = (under - 1.0).abs() < TOL;
1499 req!(full, true, "The centre came out at {}.", under);
1500 let anti = orthographic_cos_c(31.9535, -64.1429, -31.9535, 115.8571);
1501 let behind_us = (anti + 1.0).abs() < TOL;
1502 req!(behind_us, true, "The antipode came out at {}.", anti);
1503 // On the horizon it is zero, and the point lands exactly on the rim.
1504 let rim = orthographic_cos_c(0.0, 90.0, 0.0, 0.0);
1505 let level = rim.abs() < TOL;
1506 req!(level, true, "A quarter turn away came out at {}.", rim);
1507 let p = orthographic(0.0, 90.0, 0.0, 0.0, 1.0);
1508 let on = (p.x.hypot(p.y) - ORTHOGRAPHIC_HALF_WIDTH).abs() < TOL;
1509 req!(on, true, "The horizon came out {} from the centre.", p.x.hypot(p.y));
1510 // And nothing escapes the disc, near side or far.
1511 let mut lat = -90.0;
1512 while lat <= 90.0 {
1513 let mut lng = -180.0;
1514 while lng <= 180.0 {
1515 let p = orthographic(lat, lng, -31.9535, 115.8571, 1.0);
1516 let inside = p.x.hypot(p.y) <= ORTHOGRAPHIC_HALF_WIDTH + TOL;
1517 req!(inside, true, "{}, {} escaped the disc at {}, {}.", lat, lng, p.x, p.y);
1518 lng += 3.0;
1519 }
1520 lat += 3.0;
1521 }
1522 Ok(())
1523 }
1524
1525 #[test]
1526 fn test_the_globe_turns_without_changing_shape_08() -> Outcome<()> {
1527 // Turning the globe by a degree of longitude and asking for a position a degree
1528 // further east is the same picture, because only the difference matters.
1529 let a = orthographic(12.0, 45.0, 0.0, 30.0, 1.0);
1530 let b = orthographic(12.0, 15.0, 0.0, 0.0, 1.0);
1531 let same_x = (a.x - b.x).abs() < TOL;
1532 let same_y = (a.y - b.y).abs() < TOL;
1533 req!(same_x, true, "Turning moved x to {} rather than {}.", a.x, b.x);
1534 req!(same_y, true, "Turning moved y to {} rather than {}.", a.y, b.y);
1535 // A radius scales the disc and nothing else.
1536 let one = orthographic(-33.8688, 151.2093, -31.9535, 115.8571, 1.0);
1537 let big = orthographic(-33.8688, 151.2093, -31.9535, 115.8571, 320.0);
1538 let scaled_x = (big.x - one.x * 320.0).abs() < 1.0e-9;
1539 let scaled_y = (big.y - one.y * 320.0).abs() < 1.0e-9;
1540 req!(scaled_x, true, "x did not scale by the radius.");
1541 req!(scaled_y, true, "y did not scale by the radius.");
1542 // A longitude past the antimeridian comes round rather than off the globe.
1543 let round = orthographic(0.0, 190.0, 0.0, 170.0, 1.0);
1544 let west = orthographic(0.0, -170.0, 0.0, 170.0, 1.0);
1545 let wrapped = (round.x - west.x).abs() < TOL;
1546 req!(wrapped, true, "190° east did not wrap to 170° west.");
1547 Ok(())
1548 }
1549
1550 #[test]
1551 fn test_a_fix_just_outside_its_range_still_projects_05() -> Outcome<()> {
1552 let over = equal_earth_unit(90.000_000_1, 0.0);
1553 let finite = over.y.is_finite();
1554 req!(finite, true, "A latitude a hair over the pole gave {}.", over.y);
1555 let clamped = (over.y - EQUAL_EARTH_HALF_HEIGHT).abs() < TOL;
1556 req!(clamped, true, "It did not clamp to the pole.");
1557 let round = equal_earth_unit(0.0, 190.0);
1558 let west = equal_earth_unit(0.0, -170.0);
1559 let wrapped = (round.x - west.x).abs() < TOL;
1560 req!(wrapped, true, "190° east did not wrap to 170° west.");
1561 Ok(())
1562 }
1563 /// Positions and their spherical Web Mercator projections on a unit sphere, at four
1564 /// central meridians, from PROJ 9.7.1:
1565 ///
1566 /// ```text
1567 /// proj -f "%.12f" +proj=webmerc +R=1 +lon_0=<lon_0>
1568 /// ```
1569 const MERC_POINTS: [(f64, f64); 10] = [
1570 (-31.9535, 115.8571), // Perth
1571 ( 51.5074, -0.1278), // London
1572 ( 40.7128, -74.0060), // New York
1573 ( 35.6895, 139.6917), // Tokyo
1574 ( 21.3069, -157.8580), // Honolulu
1575 (-33.9189, 18.4233), // Cape Town
1576 ( 0.0, 0.0),
1577 ( 85.0, 179.9), // near the top corner
1578 (-85.0, -179.9),
1579 ( 60.0, 30.0),
1580 ];
1581 const MERC_ORACLE: [(f64, [(f64, f64); 10]); 4] = [
1582 (0.0, [
1583 ( 2.022087856812, -0.589076140273), (-0.002230530784, 1.052273175629),
1584 (-1.291648366231, 0.779235626193), ( 2.438080102708, 0.667590039565),
1585 (-2.755141850613, 0.380755537637), ( 0.321547244083, -0.629951578195),
1586 ( 0.000000000000, 0.000000000000), ( 3.139847324338, 3.131301331472),
1587 (-3.139847324338, -3.131301331472), ( 0.523598775598, 1.316957896925),
1588 ]),
1589 (115.8571, [
1590 ( 0.000000000000, -0.589076140273), (-2.024318387596, 1.052273175629),
1591 ( 2.969449084136, 0.779235626193), ( 0.415992245896, 0.667590039565),
1592 ( 1.505955599754, 0.380755537637), (-1.700540612730, -0.629951578195),
1593 (-2.022087856812, 0.000000000000), ( 1.117759467525, 3.131301331472),
1594 ( 1.121250126029, -3.131301331472), (-1.498489081214, 1.316957896925),
1595 ]),
1596 (-74.006, [
1597 (-2.969449084136, -0.589076140273), ( 1.289417835447, 1.052273175629),
1598 ( 0.000000000000, 0.779235626193), (-2.553456838240, 0.667590039565),
1599 (-1.463493484382, 0.380755537637), ( 1.613195610314, -0.629951578195),
1600 ( 1.291648366231, 0.000000000000), (-1.851689616611, 3.131301331472),
1601 (-1.848198958107, -3.131301331472), ( 1.815247141829, 1.316957896925),
1602 ]),
1603 (-150.0, [
1604 (-1.643103572376, -0.589076140273), ( 2.615763347207, 1.052273175629),
1605 ( 1.326345511761, 0.779235626193), (-1.227111326480, 0.667590039565),
1606 (-0.137147972622, 0.380755537637), ( 2.939541122074, -0.629951578195),
1607 ( 2.617993877991, 0.000000000000), (-0.525344104850, 3.131301331472),
1608 (-0.521853446346, -3.131301331472), ( 3.141592653590, 1.316957896925),
1609 ]),
1610 ];
1611
1612 #[test]
1613 fn test_web_mercator_agrees_with_proj_09() -> Outcome<()> {
1614 for (lon_0, want) in MERC_ORACLE {
1615 for ((lat, lng), (x, y)) in MERC_POINTS.iter().zip(want.iter()) {
1616 let p = web_mercator(*lat, *lng, lon_0, 1.0);
1617 let near = (p.x - x).abs() < TOL && (p.y - y).abs() < TOL;
1618 req!(near, true, "{}, {} about {} came out at ({:.12}, {:.12}), wanted ({}, {}).",
1619 lat, lng, lon_0, p.x, p.y, x, y);
1620 }
1621 }
1622 // The map is square at its stated limit, and a pole clamps to it.
1623 let top = web_mercator(90.0, 0.0, 0.0, 1.0);
1624 let square = (top.y - PI).abs() < 1.0e-12;
1625 req!(square, true, "The map's top came out at {}, not pi.", top.y);
1626 Ok(())
1627 }
1628
1629 #[test]
1630 fn test_web_mercator_inverse_agrees_with_proj_10() -> Outcome<()> {
1631 // proj -I -f "%.12f" +proj=webmerc +R=1 +lon_0=115.8571, which answers lng, lat.
1632 for (x, y, lng, lat) in [
1633 ( 0.3, -0.6, 133.045833853925, -32.483015050012),
1634 (-1.2, 1.1, 47.102164584301, 53.177781879084),
1635 ( 2.5, -2.9, -100.903451217294, -83.701155006501),
1636 ( 0.0, 0.0, 115.857100000000, 0.000000000000),
1637 ] {
1638 let (a, b) = web_mercator_inverse(Pt::new(x, y), 115.8571, 1.0);
1639 let near = (a - lat).abs() < 1.0e-9 && (b - lng).abs() < 1.0e-9;
1640 req!(near, true, "({}, {}) came back as {}, {}; PROJ says {}, {}.", x, y, a, b, lat, lng);
1641 }
1642 // Forward and back is the identity, including across the antimeridian.
1643 for (lat, lng) in MERC_POINTS {
1644 let p = web_mercator(lat, lng, -150.0, 6.0);
1645 let (a, b) = web_mercator_inverse(p, -150.0, 6.0);
1646 let same = (a - lat).abs() < 1.0e-9 && (wrap_half_turn(b - lng)).abs() < 1.0e-9;
1647 req!(same, true, "{}, {} came back as {}, {}.", lat, lng, a, b);
1648 }
1649 Ok(())
1650 }
1651
1652 #[test]
1653 fn test_orthographic_inverse_agrees_with_proj_11() -> Outcome<()> {
1654 // proj -I -f "%.12f" +proj=ortho +R=1 +lat_0=<lat_0> +lon_0=<lon_0>, answering lng,
1655 // lat, at a centre on the equator, two oblique centres, a third far north, the north
1656 // pole, and a southern one. The last input of each is off the disc, where PROJ
1657 // answers `*`.
1658 let xy = [(0.0, 0.0), (0.3, -0.2), (0.7, 0.5), (-0.9, 0.1), (0.0, 0.99), (-0.45, -0.8),
1659 (0.001, 0.002)];
1660 let oracle: [((f64, f64), [(f64, f64); 7]); 6] = [
1661 ((0.0, 0.0), [
1662 (0.000000000000, 0.000000000000), (17.829543848069, -11.536959032815),
1663 (53.929231349204, 30.000000000000), (-64.760598179321, 5.739170477267),
1664 (0.000000000000, 81.890385544006), (-48.590377890729, -53.130102354156),
1665 (0.057295903654, 0.114591635421),
1666 ]),
1667 ((-31.9535, 115.8571), [
1668 (115.857100000000, -31.953500000000), (139.491172018755, -41.554276226097),
1669 (160.969622305304, 8.881020396659), (50.501798095373, -8.029667432931),
1670 (115.857100000000, 49.936885544006), (14.957210228986, -62.724626645594),
1671 (115.924543725221, -31.838890517892),
1672 ]),
1673 ((51.5074, -0.1278), [
1674 (-0.127800000000, 51.507400000000), (22.018911603216, 37.269190760395),
1675 (95.904696130209, 45.259426785352), (-78.463343669197, 23.222821817975),
1676 (179.872200000000, 46.602214455994), (-27.392900860832, -10.795895994418),
1677 (-0.035513551106, 51.621955519567),
1678 ]),
1679 ((35.6895, 139.6917), [
1680 (139.691700000000, 35.689500000000), (158.631638139189, 22.439898158901),
1681 (-140.229884106383, 44.713981955783), (67.334907163601, 19.191798955953),
1682 (-40.308300000000, 62.420114455994), (109.995075616966, -24.722618420153),
1683 (139.762346389474, 35.804071028094),
1684 ]),
1685 ((90.0, 0.0), [
1686 (0.000000000000, 90.000000000000), (56.309932474020, 68.865707785214),
1687 (125.537677791974, 30.657298992941), (-96.340191745910, 25.104090250221),
1688 (180.000000000000, 8.109614455994), (-29.357753542791, 23.382196425509),
1689 (153.434948822922, 89.871882635420),
1690 ]),
1691 ((-60.0, -70.0), [
1692 (-70.000000000000, -60.000000000000), (-24.339703634894, -65.199623342309),
1693 (-24.503147524221, -11.045475113038), (-141.637585260840, -18.507178106808),
1694 (-70.000000000000, 21.890385544006), (152.308915595694, -48.046975207444),
1695 (-69.885803894134, -59.885358916100),
1696 ]),
1697 ];
1698 for ((lat_0, lon_0), want) in oracle {
1699 for ((x, y), (lng, lat)) in xy.iter().zip(want.iter()) {
1700 let got = match orthographic_inverse(Pt::new(*x, *y), lat_0, lon_0, 1.0) {
1701 Some(g) => g,
1702 None => return Err(err!("({}, {}) about {}, {} fell off the globe.",
1703 x, y, lat_0, lon_0; Test)),
1704 };
1705 // At the pole longitude is arbitrary; everywhere else it is compared too.
1706 let near_lat = (got.0 - lat).abs() < 1.0e-9;
1707 let near_lng = lat.abs() > 89.999_999 || wrap_half_turn(got.1 - lng).abs() < 1.0e-9;
1708 req!(near_lat && near_lng, true,
1709 "({}, {}) about {}, {} came back as {}, {}; PROJ says {}, {}.",
1710 x, y, lat_0, lon_0, got.0, got.1, lat, lng);
1711 }
1712 let off = orthographic_inverse(Pt::new(0.8, 0.7), lat_0, lon_0, 1.0);
1713 req!(off.is_none(), true, "A point off the disc came back as {:?}.", off);
1714 }
1715 Ok(())
1716 }
1717
1718}