use std::f64::consts::{PI, TAU};
use glam::{DMat3, DMat4, DVec3, DVec4};
use crate::Space;
use crate::geo::mercator::{self, MAX_LATITUDE, latitude};
use crate::gpu::shaders as wgsl;
#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[serde(untagged, deny_unknown_fields)]
pub enum Camera {
Map {
#[serde(with = "center")]
#[schemars(with = "center::Center")]
center: [f64; 3],
zoom: f64,
pitch: f64,
bearing: f64,
},
Plot {
x: [f64; 2],
y: Option<[f64; 2]>,
},
}
mod center {
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
#[derive(schemars::JsonSchema)]
#[serde(untagged)]
#[schemars(inline)]
#[allow(dead_code, reason = "its schema only")]
pub(super) enum Center {
Ground([f64; 2]),
Point([f64; 3]),
}
pub(super) fn serialize<S: Serializer>(center: &[f64; 3], serializer: S) -> Result<S::Ok, S::Error> {
if center[2] == 0.0 { [center[0], center[1]].serialize(serializer) } else { center.serialize(serializer) }
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<[f64; 3], D::Error> {
match Vec::<f64>::deserialize(deserializer)?[..] {
[x, y] => Ok([x, y, 0.0]),
[x, y, z] => Ok([x, y, z]),
_ => Err(de::Error::custom("a center is [x, y] or [x, y, elevation]")),
}
}
}
const FOV: f64 = 0.643_501_108_793_284_4;
pub(crate) const MAX_PITCH: f64 = 75.0;
const MAX_ZOOM: f64 = 24.0;
fn closest_fit(space: &Space) -> Option<f64> {
match space {
Space::Diagram(_) => Some(0.0),
Space::Earth(_) => Some(16.0),
Space::Plane(_) | Space::Plot(_) => None,
}
}
fn pixels_per_unit(space: &Space, zoom: f64) -> f64 {
match space {
Space::Earth(_) => 256.0 * zoom.exp2() / 360.0,
_ => zoom.exp2(),
}
}
pub(crate) type Area = [f64; 4];
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Bounds {
pub(crate) min: [f64; 3],
pub(crate) max: [f64; 3],
pub(crate) zoom: [f64; 2],
}
impl Bounds {
pub(crate) fn with(self, other: Bounds) -> Bounds {
Bounds {
min: std::array::from_fn(|k| self.min[k].min(other.min[k])),
max: std::array::from_fn(|k| self.max[k].max(other.max[k])),
zoom: [self.zoom[0].min(other.zoom[0]), self.zoom[1].max(other.zoom[1])],
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Reach {
pub(crate) zoom: [f64; 2],
framed: Option<Bounds>,
slack: f64,
world: Option<[f64; 2]>,
}
impl Reach {
pub(crate) fn new(space: &Space, room: Option<[f64; 2]>, framed: Option<Bounds>) -> Reach {
let projection = match space {
Space::Earth(earth) => Some(earth.projection),
_ => None,
};
let least = match (projection, room) {
(Some(crate::Projection::Mercator), Some([w, h])) => (w.min(h) / 256.0).log2(),
(Some(crate::Projection::Globe), Some([w, h])) => (w.min(h) / 3.0 * PI / 256.0).log2(),
_ => -MAX_ZOOM,
};
let (mut low, mut high) = (least.max(-MAX_ZOOM), MAX_ZOOM);
if let Some(framed) = framed {
low = low.max(framed.zoom[0] - 3.0);
high = high.min(framed.zoom[1] + 8.0);
}
let slack = match (framed, room) {
(Some(framed), Some([w, h])) => 0.5 * w.max(h) / pixels_per_unit(space, framed.zoom[0]),
_ => 0.0,
};
Reach {
zoom: [low, high.max(low)],
framed,
slack,
world: room.filter(|_| projection == Some(crate::Projection::Mercator)),
}
}
}
impl Camera {
pub(crate) fn default_for(space: &Space) -> Camera {
match space {
Space::Earth(_) => Camera::Map { center: [0.0, 20.0, 0.0], zoom: 1.5, pitch: 0.0, bearing: 0.0 },
Space::Plane(_) | Space::Diagram(_) => {
Camera::Map { center: [0.0; 3], zoom: 0.0, pitch: 0.0, bearing: 0.0 }
}
Space::Plot(_) => Camera::Plot { x: [0.0, 1.0], y: None },
}
}
pub fn fits(&self, space: &Space) -> bool {
matches!(
(self, space),
(Camera::Map { .. }, Space::Earth(_) | Space::Plane(_) | Space::Diagram(_))
| (Camera::Plot { .. }, Space::Plot(_))
)
}
#[must_use]
pub fn ease(&self, to: &Camera, t: f64, space: &Space) -> Camera {
let globe = matches!(space, Space::Earth(earth) if earth.projection == crate::Projection::Globe);
let lerp = |a: f64, b: f64| a + (b - a) * t;
let turn = |a: f64, b: f64, full: f64| a + ((b - a + full * 1.5).rem_euclid(full) - full * 0.5) * t;
match (self, to) {
(
Camera::Map { center: c0, zoom: z0, pitch: p0, bearing: b0 },
Camera::Map { center: c1, zoom: z1, pitch: p1, bearing: b1 },
) => Camera::Map {
center: [
if globe { turn(c0[0], c1[0], 360.0) } else { lerp(c0[0], c1[0]) },
lerp(c0[1], c1[1]),
lerp(c0[2], c1[2]),
],
zoom: lerp(*z0, *z1),
pitch: lerp(*p0, *p1),
bearing: turn(*b0, *b1, 360.0),
},
(Camera::Plot { x: x0, y: y0 }, Camera::Plot { x: x1, y: y1 }) => Camera::Plot {
x: [lerp(x0[0], x1[0]), lerp(x0[1], x1[1])],
y: match (y0, y1) {
(Some(a), Some(b)) => Some([lerp(a[0], b[0]), lerp(a[1], b[1])]),
_ => *y1,
},
},
_ => *to,
}
}
pub(crate) fn panned(&self, view: &Projected, anchor: [f64; 3], delta: [f64; 2]) -> Camera {
match *self {
Camera::Plot { x, y } => {
let dx = delta[0] / view.px_per_unit[0];
let dy = delta[1] / view.px_per_unit[1];
Camera::Plot { x: [x[0] - dx, x[1] - dx], y: y.map(|y| [y[0] + dy, y[1] + dy]) }
}
Camera::Map { center, zoom, pitch, bearing } => {
let a = view.world(anchor);
let Some(s) = view.screen(a) else { return *self };
let to = [s[0] + delta[0], s[1] + delta[1]];
if view.globe >= 0.5 {
if view.ground(to).is_none() {
return *self;
}
return view.keeping(*self, anchor, to);
}
let reach = 50.0 * (view.eye - a).length();
let level = view.on_plane(to, a.z).filter(|p| (*p - view.eye).length() < reach);
let p = match level {
Some(p) if view.grazing(to) > 0.1 => p,
_ => view.unproject(to, s[2]),
};
Camera::Map { center: view.shifted(center, a - p, 0.0), zoom, pitch, bearing }
}
}
}
pub(crate) fn turned(&self, view: &Projected, anchor: [f64; 3], degrees: [f64; 2]) -> Camera {
let Camera::Map { center, zoom, pitch, bearing } = *self else { return *self };
if view.space == wgsl::SPACE_DIAGRAM {
return *self;
}
let to_pitch = (pitch + degrees[1]).clamp(0.0, MAX_PITCH);
let to_bearing = (bearing + degrees[0]).rem_euclid(360.0);
if view.globe >= 0.5 {
let turned = Camera::Map { center, zoom, pitch: to_pitch, bearing: to_bearing };
return match view.screen(view.world(anchor)) {
Some(s) => view.keeping(turned, anchor, [s[0], s[1]]),
None => turned,
};
}
let rig = |p: f64, b: f64| DMat3::from_rotation_z(-b.to_radians()) * DMat3::from_rotation_x(p.to_radians());
let r = rig(to_pitch, to_bearing) * rig(pitch, bearing).transpose();
let a = view.world(anchor);
let c = DVec3::new(0.0, 0.0, view.height(center[2]));
let moved = a + r * (c - a);
let d = moved - c;
Camera::Map { center: view.shifted(center, d, d.z), zoom, pitch: to_pitch, bearing: to_bearing }
}
pub(crate) fn zoomed(&self, view: &Projected, anchor: [f64; 3], levels: f64, reach: &Reach) -> Camera {
match *self {
Camera::Plot { x, y } => {
let scale = (-levels).exp2();
let about = |range: [f64; 2], pivot: f64| {
[pivot + (range[0] - pivot) * scale, pivot + (range[1] - pivot) * scale]
};
Camera::Plot { x: about(x, anchor[0]), y: y.map(|y| about(y, anchor[1])) }
}
Camera::Map { center, zoom, pitch, bearing } => {
let [least, most] = reach.zoom;
let to = (zoom + levels).clamp(least.min(zoom), most.max(zoom));
if to == zoom {
return *self;
}
if view.globe >= 0.5 {
let zoomed = Camera::Map { center, zoom: to, pitch, bearing };
return match view.screen(view.world(anchor)) {
Some(s) => view.keeping(zoomed, anchor, [s[0], s[1]]),
None => zoomed,
};
}
let a = view.world(anchor);
let c = DVec3::new(0.0, 0.0, view.height(center[2]));
let d = (a + (c - a) * (zoom - to).exp2()) - c;
Camera::Map { center: view.shifted(center, d, d.z), zoom: to, pitch, bearing }
}
}
}
pub(crate) fn within(&self, reach: &Reach) -> Camera {
let Camera::Map { mut center, zoom, pitch, bearing } = *self else { return *self };
let zoom = zoom.clamp(reach.zoom[0], reach.zoom[1]);
if let Some(framed) = reach.framed {
let span = (0..2).map(|k| framed.max[k] - framed.min[k]).fold(reach.slack, f64::max).max(1e-9);
let across = |k: usize| center[k].clamp(framed.min[k] - span, framed.max[k] + span);
center = [across(0), across(1), center[2].clamp(framed.min[2], framed.max[2])];
}
if let Some([w, h]) = reach.world {
let world = 256.0 * zoom.exp2();
let east = 180.0 * (1.0 - w / world).max(0.0);
let north = latitude(PI * (1.0 - h / world).max(0.0));
center[0] = center[0].clamp(-east, east);
center[1] = center[1].clamp(-north, north);
}
Camera::Map { center, zoom, pitch: pitch.clamp(0.0, MAX_PITCH), bearing }
}
pub(crate) fn framing(&self, space: &Space, min: [f64; 3], max: [f64; 3], size: [f64; 2], area: Area) -> Camera {
const PADDING: f64 = 24.0;
let room = [(area[2] - area[0] - 2.0 * PADDING).max(1.0), (area[3] - area[1] - 2.0 * PADDING).max(1.0)];
let (pitch, bearing) = match *self {
Camera::Map { pitch, bearing, .. } => (pitch.clamp(0.0, MAX_PITCH), bearing),
Camera::Plot { .. } => (0.0, 0.0),
};
let height = min[2].midpoint(max[2]);
let closest = closest_fit(space);
if let Camera::Map { zoom, .. } = *self
&& !matches!(space, Space::Plot(_))
&& max[0] - min[0] <= 1e-9
&& max[1] - min[1] <= 1e-9
{
return Camera::Map { center: [min[0], min[1], height], zoom: closest.unwrap_or(zoom), pitch, bearing };
}
let capped = |camera: Camera| match (camera, closest) {
(Camera::Map { center, zoom, pitch, bearing }, Some(most)) => {
Camera::Map { center, zoom: zoom.min(most), pitch, bearing }
}
(camera, _) => camera,
};
match space {
Space::Plot(_) => {
let pad = |lo: f64, hi: f64, px: f64| {
let span = (hi - lo).max(1e-9);
let extra = span * PADDING / px.max(1.0);
[lo - extra, hi + extra]
};
let x = if max[0] > min[0] { [min[0], max[0]] } else { [min[0] - 0.5, min[0] + 0.5] };
Camera::Plot { x, y: Some(pad(min[1], max[1], room[1])) }
}
Space::Earth(_) => {
let (y0, y1) = (mercator::y(min[1]), mercator::y(max[1]));
let dx = (max[0] - min[0]).to_radians().max(1e-9);
let dy = (y1 - y0).max(1e-9);
let px_per_radian = (room[0] / dx).min(room[1] / dy);
let zoom = (px_per_radian * TAU / 256.0).log2().clamp(0.0, 22.0);
let center = [min[0].midpoint(max[0]), latitude(y0.midpoint(y1)), height];
capped(Camera::Map { center, zoom, pitch, bearing }.framed(space, min, max, size, area, room))
}
Space::Plane(_) | Space::Diagram(_) => {
let dx = (max[0] - min[0]).max(1e-9);
let dy = (max[1] - min[1]).max(1e-9);
let zoom = (room[0] / dx).min(room[1] / dy).log2().clamp(-MAX_ZOOM, MAX_ZOOM);
let center = [min[0].midpoint(max[0]), min[1].midpoint(max[1]), height];
capped(Camera::Map { center, zoom, pitch, bearing }.framed(space, min, max, size, area, room))
}
}
}
fn framed(self, space: &Space, min: [f64; 3], max: [f64; 3], size: [f64; 2], area: Area, room: [f64; 2]) -> Camera {
let middle = [area[0].midpoint(area[2]), area[1].midpoint(area[3])];
let mut camera = self;
for _ in 0..12 {
let Camera::Map { center, zoom, pitch, bearing } = camera else { break };
let view = Projected::new(space, &camera, size, area, 0.0);
let mut reach = [1e-9_f64; 2];
for i in 0..8 {
let corner = [[min, max][i & 1][0], [min, max][(i >> 1) & 1][1], [min, max][i >> 2][2]];
let Some(s) = view.screen(view.world(corner)) else { continue };
reach = [reach[0].max((s[0] - middle[0]).abs()), reach[1].max((s[1] - middle[1]).abs())];
}
let scale = (0.5 * room[0] / reach[0]).min(0.5 * room[1] / reach[1]);
camera = Camera::Map { center, zoom: (zoom + scale.log2()).clamp(-MAX_ZOOM, MAX_ZOOM), pitch, bearing };
if (scale - 1.0).abs() < 1e-4 {
break;
}
}
camera
}
pub(crate) fn centered(&self, position: [f64; 3]) -> Camera {
match *self {
Camera::Map { zoom, pitch, bearing, .. } => Camera::Map { center: position, zoom, pitch, bearing },
Camera::Plot { x, y } => {
let half = (x[1] - x[0]) / 2.0;
Camera::Plot { x: [position[0] - half, position[0] + half], y }
}
}
}
}
fn wrap(lon: f64) -> f64 {
(lon + 180.0).rem_euclid(360.0) - 180.0
}
#[derive(Clone)]
pub(crate) struct Projected {
pub(crate) clip_from_world: DMat4,
pub(crate) world_from_clip: DMat4,
pub(crate) eye: DVec3,
pub(crate) light: DVec3,
pub(crate) center: [f64; 3],
pub(crate) px_per_unit: [f64; 2],
pub(crate) zoom: f64,
pub(crate) mercator_y: f64,
pub(crate) globe: f64,
pub(crate) space: u32,
size: [f64; 2],
area: Area,
}
impl Projected {
pub(crate) fn globe_of(space: &Space) -> f64 {
match space {
Space::Earth(earth) => f64::from(u8::from(earth.projection == crate::Projection::Globe)),
_ => 0.0,
}
}
pub(crate) fn new(space: &Space, camera: &Camera, size: [f64; 2], area: Area, globe: f64) -> Projected {
let camera = if camera.fits(space) { *camera } else { Camera::default_for(space) };
let (w, h) = (size[0].max(1.0), size[1].max(1.0));
let room = [(area[2] - area[0]).max(1.0), (area[3] - area[1]).max(1.0)];
let (center, px_per_unit, pitch, bearing, space_code) = match (space, camera) {
(Space::Earth(_), Camera::Map { center, zoom, pitch, bearing }) => {
let px = 256.0 * zoom.exp2() / TAU;
(center, [px, px], pitch, bearing, wgsl::SPACE_EARTH)
}
(Space::Plane(_), Camera::Map { center, zoom, pitch, bearing }) => {
let px = zoom.exp2();
(center, [px, px], pitch, bearing, wgsl::SPACE_PLANE)
}
(Space::Diagram(_), Camera::Map { center, zoom, .. }) => {
let px = zoom.exp2();
(center, [px, px], 0.0, 0.0, wgsl::SPACE_DIAGRAM)
}
(_, Camera::Plot { x, y }) => {
let y = y.unwrap_or([0.0, 1.0]);
let px = [room[0] / (x[1] - x[0]).abs().max(1e-12), room[1] / (y[1] - y[0]).abs().max(1e-12)];
([x[0].midpoint(x[1]), y[0].midpoint(y[1]), 0.0], px, 0.0, 0.0, wgsl::SPACE_PLOT)
}
_ => unreachable!("the camera matches the space"),
};
let distance = 0.5 * h / (0.5 * FOV).tan();
let projection = perspective(FOV, w / h, distance * 100.0, distance * 0.01);
let up = if space_code == wgsl::SPACE_DIAGRAM { -px_per_unit[1] } else { px_per_unit[1] };
let view = DMat4::from_translation(DVec3::new(0.0, 0.0, -distance))
* DMat4::from_rotation_x(-pitch.clamp(0.0, MAX_PITCH).to_radians())
* DMat4::from_rotation_z(bearing.to_radians())
* DMat4::from_scale(DVec3::new(px_per_unit[0], up, px_per_unit[0]))
* DMat4::from_translation(DVec3::new(0.0, 0.0, -height(space_code, center[2])));
let middle = [area[0].midpoint(area[2]) / w * 2.0 - 1.0, 1.0 - area[1].midpoint(area[3]) / h * 2.0];
let offset = DMat4::from_cols(DVec4::X, DVec4::Y, DVec4::Z, DVec4::new(middle[0], middle[1], 0.0, 1.0));
let clip_from_world = offset * projection * view;
let world_from_view = view.inverse();
let eye = world_from_view.transform_point3(DVec3::ZERO);
let light = world_from_view.transform_vector3(DVec3::new(-0.4, 0.8, 1.0)).normalize_or_zero();
let mercator = if space_code == wgsl::SPACE_EARTH { mercator::y(center[1]) } else { 0.0 };
Projected {
clip_from_world,
world_from_clip: clip_from_world.inverse(),
eye,
light,
center,
px_per_unit,
zoom: px_per_unit[0].max(1e-300).log2()
- if space_code == wgsl::SPACE_EARTH { (256.0 / TAU).log2() } else { 0.0 },
mercator_y: mercator,
globe: if space_code == wgsl::SPACE_EARTH { globe } else { 0.0 },
space: space_code,
size: [w, h],
area,
}
}
pub(crate) fn keeping(&self, camera: Camera, anchor: [f64; 3], to: [f64; 2]) -> Camera {
let Camera::Map { center, zoom, pitch, bearing } = camera else { return camera };
let earth = Space::Earth(crate::Earth::default().projection(crate::Projection::Globe));
let at = |center: [f64; 3]| {
let camera = Camera::Map { center, zoom, pitch, bearing };
let view = Projected::new(&earth, &camera, self.size, self.area, self.globe);
let w = view.world(anchor);
let radius = 1.0 / center[1].to_radians().cos();
let front = (w - DVec3::new(0.0, 0.0, -radius)).dot(view.eye - w) > 0.0;
view.screen(w).filter(|_| front).map(|s| [s[0], s[1]])
};
let step = 4.0 * 360.0 / (256.0 * zoom.exp2());
let mut c = center;
for _ in 0..8 {
let Some(s) = at(c) else { return camera };
let miss = [to[0] - s[0], to[1] - s[1]];
if miss[0].hypot(miss[1]) < 0.01 {
break;
}
let moved = |dc: [f64; 2]| {
at([c[0] + dc[0], c[1] + dc[1], c[2]]).map(|m| [(m[0] - s[0]) / step, (m[1] - s[1]) / step])
};
let (Some(dx), Some(dy)) = (moved([step, 0.0]), moved([0.0, step])) else { return camera };
let det = dx[0] * dy[1] - dy[0] * dx[1];
if det.abs() < 1e-12 {
return camera;
}
let along = [(miss[0] * dy[1] - dy[0] * miss[1]) / det, (dx[0] * miss[1] - miss[0] * dx[1]) / det];
let scale = (30.0 / along[0].hypot(along[1]).max(1e-12)).min(1.0);
c = [wrap(c[0] + along[0] * scale), (c[1] + along[1] * scale).clamp(-MAX_LATITUDE, MAX_LATITUDE), c[2]];
}
match at(c) {
Some(s) if (s[0] - to[0]).hypot(s[1] - to[1]) < 0.5 => Camera::Map { center: c, zoom, pitch, bearing },
_ => camera,
}
}
pub(crate) fn height(&self, z: f64) -> f64 {
height(self.space, z)
}
pub(crate) fn world(&self, position: [f64; 3]) -> DVec3 {
let [x, y, z] = position;
let z = self.height(z);
if self.space != wgsl::SPACE_EARTH {
return DVec3::new(x - self.center[0], y - self.center[1], z);
}
let flat = DVec3::new((x - self.center[0]).to_radians(), mercator::y(y) - self.mercator_y, z);
if self.globe <= 0.0 {
return flat;
}
let lon = (x - self.center[0]).to_radians();
let (lat, lat0) = (y.clamp(-90.0, 90.0).to_radians(), self.center[1].to_radians());
let v = DVec3::new(lat.cos() * lon.sin(), lat.sin(), lat.cos() * lon.cos());
let s = DVec3::new(v.x, v.y * lat0.cos() - v.z * lat0.sin(), v.y * lat0.sin() + v.z * lat0.cos()) * (1.0 + z);
let round = DVec3::new(s.x, s.y, s.z - 1.0) / lat0.cos();
flat.lerp(round, self.globe)
}
pub(crate) fn position(&self, w: DVec3) -> [f64; 3] {
if self.space != wgsl::SPACE_EARTH {
return [w.x + self.center[0], w.y + self.center[1], w.z];
}
if self.globe < 0.5 {
return [self.center[0] + w.x.to_degrees(), latitude(w.y + self.mercator_y), w.z.to_degrees()];
}
let lat0 = self.center[1].to_radians();
let s = DVec3::new(w.x * lat0.cos(), w.y * lat0.cos(), w.z * lat0.cos() + 1.0);
let r = s.length().max(1e-12);
let u = s / r;
let v = DVec3::new(u.x, u.y * lat0.cos() + u.z * lat0.sin(), -u.y * lat0.sin() + u.z * lat0.cos());
let elevation = (r - 1.0).to_degrees();
[wrap(self.center[0] + v.x.atan2(v.z).to_degrees()), v.y.clamp(-1.0, 1.0).asin().to_degrees(), elevation]
}
pub(crate) fn screen(&self, w: DVec3) -> Option<[f64; 3]> {
let clip = self.clip_from_world * w.extend(1.0);
(clip.w > 0.0).then(|| {
let ndc = clip.truncate() / clip.w;
[(ndc.x * 0.5 + 0.5) * self.size[0], (0.5 - ndc.y * 0.5) * self.size[1], ndc.z]
})
}
pub(crate) fn unproject(&self, p: [f64; 2], depth: f64) -> DVec3 {
let ndc = DVec4::new(p[0] / self.size[0] * 2.0 - 1.0, 1.0 - p[1] / self.size[1] * 2.0, depth, 1.0);
let w = self.world_from_clip * ndc;
w.truncate() / w.w
}
fn ray(&self, p: [f64; 2]) -> (DVec3, DVec3) {
let near = self.unproject(p, 1.0);
(near, (self.unproject(p, 0.0) - near).normalize_or_zero())
}
pub(crate) fn grazing(&self, p: [f64; 2]) -> f64 {
self.ray(p).1.z.abs()
}
pub(crate) fn on_plane(&self, p: [f64; 2], z: f64) -> Option<DVec3> {
let (o, d) = self.ray(p);
let t = (z - o.z) / d.z;
(t.is_finite() && t >= 0.0).then(|| o + d * t)
}
pub(crate) fn ground(&self, p: [f64; 2]) -> Option<DVec3> {
if self.globe < 0.5 {
return self.on_plane(p, 0.0);
}
let radius = 1.0 / self.center[1].to_radians().cos();
let (o, d) = self.ray(p);
let to = DVec3::new(0.0, 0.0, -radius) - o;
let along = to.dot(d);
let miss = (to - d * along).length_squared();
let half = (radius * radius - miss).sqrt();
(half.is_finite() && along - half >= 0.0).then(|| o + d * (along - half))
}
pub(crate) fn shifted(&self, center: [f64; 3], d: DVec3, raise: f64) -> [f64; 3] {
if self.space != wgsl::SPACE_EARTH {
return [center[0] + d.x, center[1] + d.y, center[2] + raise];
}
let lat = latitude(self.mercator_y + d.y).clamp(-MAX_LATITUDE, MAX_LATITUDE);
let lon = center[0] + d.x.to_degrees();
[if self.globe >= 0.5 { wrap(lon) } else { lon }, lat, center[2] + raise.to_degrees()]
}
}
fn perspective(fov: f64, aspect: f64, near: f64, far: f64) -> DMat4 {
let f = 1.0 / (0.5 * fov).tan();
DMat4::from_cols_array(&[
f / aspect,
0.0,
0.0,
0.0,
0.0,
f,
0.0,
0.0,
0.0,
0.0,
far / (near - far),
-1.0,
0.0,
0.0,
near * far / (near - far),
0.0,
])
}
fn height(space: u32, z: f64) -> f64 {
if space == wgsl::SPACE_EARTH { z.to_radians() } else { z }
}
pub(crate) fn split(x: f64) -> (f32, f32) {
let hi = x as f32;
(hi, (x - f64::from(hi)) as f32)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Diagram, Earth, Plane, Projection};
const SIZE: [f64; 2] = [800.0, 600.0];
const AREA: Area = [0.0, 0.0, 800.0, 600.0];
fn plane() -> Space {
Space::Plane(Plane::default())
}
fn flat() -> Space {
Space::Earth(Earth::default())
}
fn globe() -> Space {
Space::Earth(Earth::default().projection(Projection::Globe))
}
fn to_screen(view: &Projected, p: [f64; 3]) -> [f64; 2] {
let s = view.screen(view.world(p)).expect("in front");
[s[0], s[1]]
}
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).hypot(a[1] - b[1]) < 1e-6
}
#[test]
fn plane_centers_and_scales() {
let camera = Camera::Map { center: [10.0, 20.0, 0.0], zoom: 1.0, pitch: 0.0, bearing: 0.0 };
let view = Projected::new(&plane(), &camera, SIZE, AREA, 0.0);
assert!(near(to_screen(&view, [10.0, 20.0, 0.0]), [400.0, 300.0]));
assert!(near(to_screen(&view, [11.0, 21.0, 0.0]), [402.0, 298.0]), "two pixels per unit, y up");
}
#[test]
fn a_diagram_runs_y_down_and_never_turns() {
let space = Space::Diagram(crate::Diagram::default());
let camera = Camera::Map { center: [10.0, 20.0, 0.0], zoom: 1.0, pitch: 40.0, bearing: 30.0 };
let view = Projected::new(&space, &camera, SIZE, AREA, 0.0);
assert!(near(to_screen(&view, [10.0, 20.0, 0.0]), [400.0, 300.0]));
assert!(near(to_screen(&view, [11.0, 21.0, 0.0]), [402.0, 302.0]), "two pixels per unit, y down, upright");
assert_eq!(camera.turned(&view, [0.0; 3], [20.0, 10.0]), camera);
let back = view.position(view.ground([402.0, 302.0]).expect("the sheet"));
assert!((back[0] - 11.0).abs() < 1e-9 && (back[1] - 21.0).abs() < 1e-9, "{back:?}");
}
#[test]
fn the_center_is_a_point_in_space() {
let camera = Camera::Map { center: [1.0, 2.0, 5.0], zoom: 3.0, pitch: 60.0, bearing: 40.0 };
let view = Projected::new(&plane(), &camera, SIZE, AREA, 0.0);
assert!(near(to_screen(&view, [1.0, 2.0, 5.0]), [400.0, 300.0]), "the center is in the middle");
}
#[test]
fn depth_is_reversed_and_unprojects() {
let camera = Camera::Map { center: [0.0; 3], zoom: 2.0, pitch: 50.0, bearing: 30.0 };
let view = Projected::new(&plane(), &camera, SIZE, AREA, 0.0);
let low = view.screen(view.world([0.0, 0.0, 0.0])).expect("in front");
let high = view.screen(view.world([0.0, 0.0, 30.0])).expect("in front");
assert!(high[2] > low[2], "nearer points have greater depth: {low:?} {high:?}");
let back = view.position(view.unproject([high[0], high[1]], high[2]));
assert!((back[2] - 30.0).abs() < 1e-6 && back[0].abs() < 1e-6, "{back:?}");
assert!(view.ground([low[0], low[1]]).expect("the ground").length() < 1e-6);
}
#[test]
fn framing_fits_a_tilted_box_and_looks_at_its_middle() {
let camera = Camera::Map { center: [0.0; 3], zoom: 0.0, pitch: 58.0, bearing: 30.0 };
let (min, max) = ([-4.0, -4.0, 0.0], [4.0, 4.0, 2.4]);
let framed = camera.framing(&plane(), min, max, SIZE, AREA);
let Camera::Map { center, .. } = framed else { panic!("a map camera") };
assert_eq!(center, [0.0, 0.0, 1.2], "turns about the box's middle");
let view = Projected::new(&plane(), &framed, SIZE, AREA, 0.0);
for i in 0..8 {
let s = to_screen(&view, [[min, max][i & 1][0], [min, max][(i >> 1) & 1][1], [min, max][i >> 2][2]]);
assert!((23.0..=777.0).contains(&s[0]) && (23.0..=577.0).contains(&s[1]), "corner {i}: {s:?}");
}
}
#[test]
fn panning_turning_and_zooming_keep_the_anchor_in_place() {
let camera = Camera::Map { center: [0.0, 0.0, 1.0], zoom: 3.0, pitch: 40.0, bearing: 10.0 };
let view = Projected::new(&plane(), &camera, SIZE, AREA, 0.0);
let anchor = [3.0, -2.0, 1.5];
let at = to_screen(&view, anchor);
let check = |camera: Camera, want: [f64; 2]| {
let after = Projected::new(&plane(), &camera, SIZE, AREA, 0.0);
let s = to_screen(&after, anchor);
assert!((s[0] - want[0]).abs() < 1e-6 && (s[1] - want[1]).abs() < 1e-6, "{s:?} for {want:?}");
};
let reach = Reach::new(&plane(), Some(SIZE), None);
check(camera.panned(&view, anchor, [120.0, -50.0]), [at[0] + 120.0, at[1] - 50.0]);
check(camera.turned(&view, anchor, [35.0, 12.0]), at);
check(camera.zoomed(&view, anchor, 1.5, &reach), at);
check(camera.zoomed(&view, anchor, -2.0, &reach), at);
}
#[test]
fn panning_toward_the_horizon_does_not_jump_far() {
let camera = Camera::Map { center: [0.0; 3], zoom: 4.0, pitch: 75.0, bearing: 0.0 };
let view = Projected::new(&plane(), &camera, SIZE, AREA, 0.0);
let anchor = view.position(view.ground([400.0, 200.0]).expect("ground"));
let Camera::Map { center, .. } = camera.panned(&view, anchor, [0.0, -190.0]) else { panic!("a map") };
assert!(center[1].abs() < 100.0, "a drag toward the horizon moves the center a short way: {center:?}");
}
#[test]
fn earth_positions_round_trip() {
for globe in [0.0, 1.0] {
let space = Space::Earth(if globe > 0.0 {
Earth::default().projection(Projection::Globe)
} else {
Earth::default()
});
let camera = Camera::Map { center: [-97.0, 31.0, 0.0], zoom: 4.0, pitch: 0.0, bearing: 0.0 };
let view = Projected::new(&space, &camera, SIZE, AREA, globe);
let p = [-95.5, 29.75, 0.0];
let back = view.position(view.world(p));
assert!((back[0] - p[0]).abs() < 1e-9 && (back[1] - p[1]).abs() < 1e-9, "globe {globe}: {back:?}");
let s = to_screen(&view, p);
let ground = view.position(view.ground(s).expect("the ground"));
assert!((ground[0] - p[0]).abs() < 1e-6 && (ground[1] - p[1]).abs() < 1e-6, "globe {globe}: {ground:?}");
}
}
#[test]
fn globe_gestures_keep_the_anchor_under_the_pointer() {
let space = Space::Earth(Earth::default().projection(Projection::Globe));
let camera = Camera::Map { center: [-97.0, 31.0, 0.0], zoom: 3.0, pitch: 20.0, bearing: 15.0 };
let view = Projected::new(&space, &camera, SIZE, AREA, 1.0);
let anchor = view.position(view.ground([520.0, 260.0]).expect("the globe"));
let at = to_screen(&view, anchor);
let check = |moved: Camera, want: [f64; 2]| {
let after = Projected::new(&space, &moved, SIZE, AREA, 1.0);
let s = to_screen(&after, anchor);
assert!((s[0] - want[0]).abs() < 0.5 && (s[1] - want[1]).abs() < 0.5, "{s:?} for {want:?}");
};
let reach = Reach::new(&space, Some(SIZE), None);
check(camera.panned(&view, anchor, [-90.0, 40.0]), [at[0] - 90.0, at[1] + 40.0]);
check(camera.zoomed(&view, anchor, 1.25, &reach), at);
check(camera.zoomed(&view, anchor, -0.75, &reach), at);
check(camera.turned(&view, anchor, [25.0, 10.0]), at);
}
#[test]
fn longitude_eases_the_short_way_only_on_the_globe() {
let a = Camera::Map { center: [170.0, 0.0, 0.0], zoom: 1.0, pitch: 0.0, bearing: 350.0 };
let b = Camera::Map { center: [-170.0, 0.0, 0.0], zoom: 1.0, pitch: 0.0, bearing: 10.0 };
let Camera::Map { center, bearing, .. } = a.ease(&b, 0.5, &globe()) else { panic!("a map camera") };
assert!((center[0] - 180.0).abs() < 1e-9);
assert!((bearing - 360.0).abs() < 1e-9);
let Camera::Map { center, bearing, .. } = a.ease(&b, 0.5, &flat()) else { panic!("a map camera") };
assert!(center[0].abs() < 1e-9, "{center:?}");
assert!((bearing - 360.0).abs() < 1e-9);
}
#[test]
fn a_flat_map_zooms_out_until_the_whole_world_fits() {
assert_eq!(Reach::new(&flat(), None, None).zoom, [-MAX_ZOOM, MAX_ZOOM], "a view without a size");
let reach = Reach::new(&flat(), Some(SIZE), None);
assert!((reach.zoom[0] - (600.0_f64 / 256.0).log2()).abs() < 1e-12, "{:?}", reach.zoom);
let out = Camera::Map { center: [170.0, 80.0, 0.0], zoom: 0.0, pitch: 0.0, bearing: 0.0 };
let fitted = out.within(&reach);
let Camera::Map { center, zoom, .. } = fitted else { panic!("a map camera") };
assert!((zoom - reach.zoom[0]).abs() < 1e-12, "{fitted:?}");
assert!(center[0].abs() < 1e-9 && center[1].abs() < 1e-9, "the world's middle: {fitted:?}");
assert_eq!(fitted.within(&reach), fitted, "a camera within reach stays put");
let view = Projected::new(&flat(), &fitted, SIZE, AREA, 0.0);
let anchor = view.position(view.ground([600.0, 200.0]).expect("the map"));
assert_eq!(fitted.zoomed(&view, anchor, -1.0, &reach), fitted);
}
#[test]
fn a_flat_map_keeps_the_world_s_edges_outside_the_view() {
let reach = Reach::new(&flat(), Some(SIZE), None);
let near = Camera::Map { center: [170.0, 80.0, 0.0], zoom: 3.0, pitch: 0.0, bearing: 0.0 };
let kept = near.within(&reach);
assert_eq!(kept.within(&reach), kept, "a camera within reach stays put");
let view = Projected::new(&flat(), &kept, SIZE, AREA, 0.0);
let [east, _] = to_screen(&view, [180.0, 0.0, 0.0]);
let [_, north] = to_screen(&view, [0.0, MAX_LATITUDE, 0.0]);
assert!((east - 800.0).abs() < 1e-6 && north.abs() < 1e-6, "the edges meet the view's: {east}, {north}");
}
#[test]
fn a_flat_map_pans_up_to_the_antimeridian_without_wrapping() {
let camera = Camera::Map { center: [170.0, 0.0, 0.0], zoom: 6.0, pitch: 0.0, bearing: 0.0 };
let view = Projected::new(&flat(), &camera, SIZE, AREA, 0.0);
let anchor = view.position(view.ground([400.0, 300.0]).expect("the map"));
let panned = camera.panned(&view, anchor, [-1000.0, 0.0]);
let Camera::Map { center, .. } = panned else { panic!("a map camera") };
assert!(center[0] > 180.0, "{center:?}");
let reach = Reach::new(&flat(), Some(SIZE), None);
let Camera::Map { center, .. } = panned.within(&reach) else { panic!("a map camera") };
assert!((center[0] - 180.0 * (1.0 - 800.0 / (256.0 * 64.0))).abs() < 1e-9, "{center:?}");
}
#[test]
fn the_globe_zooms_out_until_it_spans_a_third_of_the_view() {
let reach = Reach::new(&globe(), Some(SIZE), None);
let across = 256.0 * reach.zoom[0].exp2() / PI;
assert!((across - 200.0).abs() < 1e-9, "{across}");
let far = Camera::Map { center: [179.0, 60.0, 0.0], zoom: 3.0, pitch: 0.0, bearing: 0.0 };
assert_eq!(far.within(&reach), far);
}
#[test]
fn fits_narrow_the_reach_and_the_space_bounds_it() {
let framed = Bounds { min: [170.0, -5.0, 0.0], max: [179.0, 5.0, 0.0], zoom: [5.0; 2] };
let reach = Reach::new(&flat(), Some(SIZE), Some(framed));
assert_eq!(reach.zoom, [2.0, 13.0], "3 levels out and 8 in from the framing");
let east = Camera::Map { center: [200.0, 0.0, 0.0], zoom: 2.0, pitch: 0.0, bearing: 0.0 };
let Camera::Map { center, .. } = east.within(&reach) else { panic!("a map camera") };
assert!((center[0] - 180.0 * (1.0 - 800.0 / 1024.0)).abs() < 1e-9, "{center:?}");
let framed = Bounds { min: [0.0; 3], max: [10.0, 10.0, 0.0], zoom: [5.0; 2] };
let far = Camera::Map { center: [100.0, -100.0, 5.0], zoom: -10.0, pitch: 90.0, bearing: 0.0 };
let held = far.within(&Reach::new(&plane(), Some(SIZE), Some(framed)));
assert_eq!(held, Camera::Map { center: [22.5, -12.5, 0.0], zoom: 2.0, pitch: MAX_PITCH, bearing: 0.0 });
let point = Bounds { min: [3.0, 4.0, 0.0], max: [3.0, 4.0, 0.0], zoom: [0.0; 2] };
let far = Camera::Map { center: [1000.0, 4.0, 0.0], zoom: 0.0, pitch: 0.0, bearing: 0.0 };
let Camera::Map { center, .. } = far.within(&Reach::new(&plane(), Some(SIZE), Some(point))) else {
panic!("a map camera")
};
assert_eq!(center, [403.0, 4.0, 0.0]);
}
#[test]
fn a_fit_frames_a_diagram_at_most_at_natural_size_and_a_point_at_its_space_scale() {
let area = [0.0, 0.0, SIZE[0], SIZE[1]];
let start = Camera::Map { center: [0.0; 3], zoom: 1.5, pitch: 0.0, bearing: 0.0 };
let Camera::Map { zoom, .. } =
start.framing(&Space::Diagram(Diagram::default()), [0.0; 3], [10.0, 10.0, 0.0], SIZE, area)
else {
panic!("a map camera")
};
assert_eq!(zoom, 0.0, "a small diagram frames at its natural size");
let Camera::Map { zoom, center, .. } = start.framing(&flat(), [10.0, 20.0, 0.0], [10.0, 20.0, 0.0], SIZE, area)
else {
panic!("a map camera")
};
assert_eq!((zoom, center), (16.0, [10.0, 20.0, 0.0]), "one point on the earth frames at street scale");
let Camera::Map { zoom, .. } = start.framing(&plane(), [1.0; 3], [1.0; 3], SIZE, area) else {
panic!("a map camera")
};
assert_eq!(zoom, 1.5, "a plane has no natural scale, so a point keeps the zoom");
}
}