use std::collections::VecDeque;
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use oxigis_render::TileId;
use oxigis_ui::{TileError, TileSink, TileTransport};
pub const WORKER_THREADS: usize = 4;
pub const MAX_TILE_BYTES: u64 = 8 * 1024 * 1024;
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const IDLE_CONNECTION_AGE: Duration = Duration::from_secs(60);
struct Job {
tile: TileId,
url: String,
sink: TileSink,
}
struct QueueState<T> {
jobs: VecDeque<T>,
closed: bool,
}
pub(crate) struct JobQueue<T> {
state: Mutex<QueueState<T>>,
ready: Condvar,
}
impl<T> JobQueue<T> {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(QueueState {
jobs: VecDeque::new(),
closed: false,
}),
ready: Condvar::new(),
}
}
pub(crate) fn push(&self, job: T) {
let mut state = self.lock();
state.jobs.push_back(job);
drop(state);
self.ready.notify_one();
}
pub(crate) fn pop(&self) -> Option<T> {
let mut state = self.lock();
loop {
if let Some(job) = state.jobs.pop_front() {
return Some(job);
}
if state.closed {
return None;
}
state = self
.ready
.wait(state)
.unwrap_or_else(PoisonError::into_inner);
}
}
pub(crate) fn close(&self) {
let mut state = self.lock();
state.closed = true;
drop(state);
self.ready.notify_all();
}
fn lock(&self) -> MutexGuard<'_, QueueState<T>> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
pub struct HttpTileTransport {
queue: Arc<JobQueue<Job>>,
worker_count: usize,
}
impl HttpTileTransport {
pub fn new() -> Result<Self, std::io::Error> {
let agent = Arc::new(build_agent());
let cooldowns = Arc::new(HostCooldowns::default());
let queue = Arc::new(JobQueue::<Job>::new());
for index in 0..WORKER_THREADS {
let agent = Arc::clone(&agent);
let cooldowns = Arc::clone(&cooldowns);
let queue = Arc::clone(&queue);
std::thread::Builder::new()
.name(format!("oxigis-tile-{index}"))
.spawn(move || {
while let Some(job) = queue.pop() {
let result = fetch(&agent, &cooldowns, &job.url);
job.sink.deliver(job.tile, result);
}
})?;
}
Ok(Self {
queue,
worker_count: WORKER_THREADS,
})
}
#[must_use]
pub fn user_agent() -> String {
format!(
"OxiGIS/{} ({})",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_REPOSITORY")
)
}
}
impl Drop for HttpTileTransport {
fn drop(&mut self) {
self.queue.close();
}
}
impl core::fmt::Debug for HttpTileTransport {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("HttpTileTransport")
.field("workers", &self.worker_count)
.finish()
}
}
impl TileTransport for HttpTileTransport {
fn request(&self, tile: TileId, url: String, sink: TileSink) {
if self.worker_count == 0 {
sink.deliver(tile, Err(TileError::permanent("no tile worker threads")));
return;
}
self.queue.push(Job { tile, url, sink });
}
}
pub(crate) fn build_agent() -> ureq::Agent {
build_agent_with_timeout(REQUEST_TIMEOUT, WORKER_THREADS)
}
pub(crate) fn build_agent_with_timeout(timeout: Duration, workers: usize) -> ureq::Agent {
let tls = ureq::tls::TlsConfig::builder()
.provider(ureq::tls::TlsProvider::Rustls)
.unversioned_rustls_crypto_provider(Arc::new(rustls_graviola::default_provider()))
.build();
ureq::Agent::config_builder()
.user_agent(HttpTileTransport::user_agent())
.tls_config(tls)
.timeout_global(Some(timeout))
.timeout_connect(Some(CONNECT_TIMEOUT))
.max_idle_connections(workers.saturating_mul(2))
.max_idle_connections_per_host(workers)
.max_idle_age(IDLE_CONNECTION_AGE)
.build()
.new_agent()
}
const STATUS_TOO_MANY_REQUESTS: u16 = 429;
const FIRST_SERVER_ERROR_STATUS: u16 = 500;
pub(crate) fn header_value(
response: &ureq::http::Response<ureq::Body>,
name: &str,
) -> Option<String> {
response
.headers()
.get(name)?
.to_str()
.ok()
.map(str::to_owned)
}
pub(crate) fn origin_of(url: &str) -> &str {
let Some(scheme_end) = url.find("://") else {
return url;
};
let after = scheme_end + 3;
match url.get(after..).and_then(|rest| rest.find('/')) {
Some(offset) => &url[..after + offset],
None => url,
}
}
fn parse_retry_after_secs(value: &str) -> Option<Duration> {
let trimmed = value.trim();
if trimmed.is_empty() || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some(Duration::from_secs(trimmed.parse().ok()?))
}
const MAX_COOLDOWN_ORIGINS: usize = 64;
#[derive(Debug, Default)]
pub(crate) struct HostCooldowns {
entries: Mutex<Vec<(String, Instant)>>,
}
impl HostCooldowns {
pub(crate) fn is_paused(&self, origin: &str) -> bool {
let now = Instant::now();
self.lock()
.iter()
.any(|(held, until)| held == origin && *until > now)
}
pub(crate) fn note_retry_after(&self, origin: &str, retry_after: Option<&str>) {
if let Some(wait) = retry_after.and_then(parse_retry_after_secs) {
self.note(origin, wait);
}
}
fn note(&self, origin: &str, wait: Duration) {
let Some(until) = Instant::now().checked_add(wait) else {
return;
};
let mut entries = self.lock();
if let Some((_, slot)) = entries.iter_mut().find(|(held, _)| held == origin) {
if until > *slot {
*slot = until;
}
return;
}
while entries.len() >= MAX_COOLDOWN_ORIGINS {
entries.remove(0);
}
entries.push((origin.to_owned(), until));
}
fn lock(&self) -> MutexGuard<'_, Vec<(String, Instant)>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn classify_status(
cooldowns: &HostCooldowns,
origin: &str,
status: u16,
retry_after: Option<&str>,
) -> TileError {
let transient =
(FIRST_SERVER_ERROR_STATUS..600).contains(&status) || status == STATUS_TOO_MANY_REQUESTS;
if transient {
cooldowns.note_retry_after(origin, retry_after);
}
let message = format!("HTTP {status}");
if transient {
TileError::transient(message)
} else {
TileError::permanent(message)
}
}
fn fetch(agent: &ureq::Agent, cooldowns: &HostCooldowns, url: &str) -> Result<Vec<u8>, TileError> {
let origin = origin_of(url);
if cooldowns.is_paused(origin) {
return Err(TileError::transient(format!(
"{origin} asked to be left alone after a 429/503 Retry-After; not dialled yet"
)));
}
let response = match agent
.get(url)
.config()
.http_status_as_error(false)
.build()
.call()
{
Ok(response) => response,
Err(error) => return Err(TileError::transient(error.to_string())),
};
let status = response.status().as_u16();
if (200..300).contains(&status) {
let mut body = response.into_body();
return body
.with_config()
.limit(MAX_TILE_BYTES)
.read_to_vec()
.map_err(|error| TileError::transient(format!("body read failed: {error}")));
}
let retry_after = header_value(&response, "retry-after");
Err(classify_status(
cooldowns,
origin,
status,
retry_after.as_deref(),
))
}
#[cfg(test)]
mod tests {
use super::{
CONNECT_TIMEOUT, HostCooldowns, HttpTileTransport, IDLE_CONNECTION_AGE, REQUEST_TIMEOUT,
STATUS_TOO_MANY_REQUESTS, WORKER_THREADS, build_agent, build_agent_with_timeout,
classify_status, fetch, origin_of, parse_retry_after_secs,
};
use std::time::Duration;
#[test]
fn user_agent_identifies_oxigis_and_its_version() {
let agent = HttpTileTransport::user_agent();
assert!(agent.starts_with("OxiGIS/"));
assert!(agent.contains(env!("CARGO_PKG_VERSION")));
assert!(agent.contains("github.com"));
}
#[test]
fn the_agent_pool_is_sized_to_the_worker_count() {
let agent = build_agent_with_timeout(REQUEST_TIMEOUT, WORKER_THREADS);
let config = agent.config();
assert_eq!(config.max_idle_connections_per_host(), WORKER_THREADS);
assert!(
config.max_idle_connections() >= WORKER_THREADS,
"the whole-pool cap must not undercut the per-host one"
);
assert_eq!(config.max_idle_age(), IDLE_CONNECTION_AGE);
assert_eq!(config.timeouts().connect, Some(CONNECT_TIMEOUT));
assert_eq!(config.timeouts().global, Some(REQUEST_TIMEOUT));
}
#[test]
fn the_transport_starts_a_worker_pool() {
let transport = HttpTileTransport::new().expect("worker threads must start");
assert!(format!("{transport:?}").contains(&WORKER_THREADS.to_string()));
}
#[test]
fn origin_of_keeps_the_scheme_and_authority_and_drops_the_rest() {
assert_eq!(
origin_of("https://tile.openstreetmap.org/3/4/5.png?x=1"),
"https://tile.openstreetmap.org"
);
assert_eq!(origin_of("https://host"), "https://host");
assert_eq!(origin_of("https://host/"), "https://host");
assert_eq!(origin_of("not-a-url"), "not-a-url");
assert_eq!(origin_of(""), "");
}
#[test]
fn parse_retry_after_accepts_delay_seconds_and_rejects_everything_else() {
assert_eq!(
parse_retry_after_secs("120"),
Some(Duration::from_secs(120))
);
assert_eq!(
parse_retry_after_secs(" 5 "),
Some(Duration::from_secs(5))
);
assert_eq!(parse_retry_after_secs("0"), Some(Duration::from_secs(0)));
assert_eq!(
parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
None
);
assert_eq!(parse_retry_after_secs(""), None);
assert_eq!(parse_retry_after_secs("-1"), None);
assert_eq!(parse_retry_after_secs("1.5"), None);
assert_eq!(parse_retry_after_secs("99999999999999999999999999"), None);
}
#[test]
fn a_429_becomes_transient_and_pauses_its_origin() {
let cooldowns = HostCooldowns::default();
let origin = "https://limited.example";
assert!(!cooldowns.is_paused(origin));
let error = classify_status(&cooldowns, origin, STATUS_TOO_MANY_REQUESTS, Some("30"));
assert!(error.retryable(), "{error}");
assert!(cooldowns.is_paused(origin));
}
#[test]
fn a_403_stays_permanent_and_does_not_pause_anything() {
let cooldowns = HostCooldowns::default();
let origin = "https://strict.example";
let error = classify_status(&cooldowns, origin, 403, None);
assert!(!error.retryable(), "{error}");
assert!(!cooldowns.is_paused(origin));
}
#[test]
fn a_5xx_is_transient_and_a_retry_after_on_one_pauses_its_origin_too() {
let cooldowns = HostCooldowns::default();
let origin = "https://flaky.example";
let error = classify_status(&cooldowns, origin, 503, Some("2"));
assert!(error.retryable(), "{error}");
assert!(cooldowns.is_paused(origin));
}
#[test]
fn a_429_with_no_usable_retry_after_is_still_transient_and_pauses_nothing() {
let cooldowns = HostCooldowns::default();
for retry_after in [None, Some("Wed, 21 Oct 2015 07:28:00 GMT")] {
let origin = "https://quiet.example";
let error = classify_status(&cooldowns, origin, STATUS_TOO_MANY_REQUESTS, retry_after);
assert!(error.retryable(), "{error}");
assert!(!cooldowns.is_paused(origin), "{retry_after:?}");
}
}
#[test]
fn a_paused_origin_fails_fast_without_dialling() {
let cooldowns = HostCooldowns::default();
let origin = "http://rate-limited.invalid";
let _ = classify_status(&cooldowns, origin, STATUS_TOO_MANY_REQUESTS, Some("60"));
assert!(cooldowns.is_paused(origin));
let error = fetch(
&build_agent(),
&cooldowns,
"http://rate-limited.invalid/tile.png",
)
.expect_err("a paused origin must fail without a network round trip");
assert!(error.retryable(), "{error}");
}
#[test]
fn an_absurd_retry_after_does_not_panic_the_instant_arithmetic() {
let cooldowns = HostCooldowns::default();
let origin = "https://hostile.example";
let error = classify_status(
&cooldowns,
origin,
STATUS_TOO_MANY_REQUESTS,
Some("18446744073709551615"),
);
assert!(error.retryable(), "{error}");
assert!(!cooldowns.is_paused(origin));
}
fn deadline(cooldowns: &HostCooldowns, origin: &str) -> std::time::Instant {
*cooldowns
.lock()
.iter()
.find(|(held, _)| held == origin)
.map(|(_, until)| until)
.expect("the pause must be recorded")
}
#[test]
fn a_second_429_extends_a_running_pause_but_never_shortens_it() {
let cooldowns = HostCooldowns::default();
let origin = "https://limited.example";
classify_status(&cooldowns, origin, STATUS_TOO_MANY_REQUESTS, Some("60"));
let after_first = deadline(&cooldowns, origin);
classify_status(&cooldowns, origin, STATUS_TOO_MANY_REQUESTS, Some("1"));
assert_eq!(
deadline(&cooldowns, origin),
after_first,
"a shorter pause must not shorten the deadline"
);
classify_status(&cooldowns, origin, STATUS_TOO_MANY_REQUESTS, Some("120"));
assert!(
deadline(&cooldowns, origin) > after_first,
"a longer pause must extend the deadline"
);
}
#[test]
fn a_stalled_job_does_not_block_the_rest_of_the_queue() {
use super::JobQueue;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
let queue = Arc::new(JobQueue::<u32>::new());
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let release_rx = Arc::new(Mutex::new(release_rx));
let done: Arc<Mutex<Vec<u32>>> = Arc::new(Mutex::new(Vec::new()));
let workers: Vec<_> = (0..2)
.map(|_| {
let queue = Arc::clone(&queue);
let release_rx = Arc::clone(&release_rx);
let done = Arc::clone(&done);
std::thread::spawn(move || {
while let Some(job) = queue.pop() {
if job == 0 {
let _ = release_rx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.recv();
}
done.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(job);
}
})
})
.collect();
queue.push(0);
std::thread::sleep(Duration::from_millis(100));
queue.push(1);
queue.push(2);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let count = done
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len();
if count >= 2 {
break;
}
assert!(
Instant::now() < deadline,
"jobs 1 and 2 must complete without waiting on stalled job 0"
);
std::thread::sleep(Duration::from_millis(10));
}
{
let finished = done
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(finished.contains(&1));
assert!(finished.contains(&2));
assert!(!finished.contains(&0), "job 0 is still stalled");
}
let _ = release_tx.send(());
queue.close();
for worker in workers {
let _ = worker.join();
}
assert!(
done.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(&0),
"job 0 completes once released, rather than being lost"
);
}
#[test]
fn close_ends_pop_only_after_the_queue_drains() {
let queue = super::JobQueue::<u32>::new();
queue.push(1);
queue.push(2);
queue.close();
assert_eq!(queue.pop(), Some(1));
assert_eq!(queue.pop(), Some(2));
assert_eq!(queue.pop(), None);
}
#[test]
#[ignore = "network: fetches a real tile from tile.openstreetmap.org"]
fn live_osm_tile_round_trip() {
use oxigis_render::TileId;
use oxigis_ui::BoxedTileProvider;
use oxigis_ui::tile_provider::{BasemapConfig, XyzTileProvider};
let transport = HttpTileTransport::new().expect("worker threads must start");
let provider: BoxedTileProvider = Box::new(
XyzTileProvider::new(
&BasemapConfig::openstreetmap(),
&egui::Context::default(),
Box::new(transport),
)
.expect("the OSM basemap must build a provider"),
);
let tile = TileId::new(0, 0, 0).expect("0/0/0 is a valid tile");
let mut decoded = None;
for _ in 0..150 {
if let Some(pixels) = provider.tile(tile) {
decoded = Some(pixels);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let pixels = decoded.expect("z0 OSM tile must arrive within 15 s");
assert_eq!(pixels.width(), 256, "OSM serves 256 px tiles");
assert_eq!(pixels.height(), 256);
assert_eq!(pixels.rgba().len(), 256 * 256 * 4);
let first = &pixels.rgba()[0..4];
assert!(
pixels.rgba().chunks_exact(4).any(|px| px != first),
"the decoded tile must contain more than one colour"
);
}
#[test]
#[ignore = "network: fetches a real tile from tiles.maps.eox.at"]
fn live_custom_xyz_round_trip() {
use oxigis_render::TileId;
use oxigis_ui::BoxedTileProvider;
use oxigis_ui::tile_provider::{BasemapConfig, XyzTileProvider};
const URL: &str = "https://e.tiles.maps.eox.at/wmts/1.0.0/s2cloudless_3857/\
default/GoogleMapsCompatible/{z}/{y}/{x}.jpg";
let url = std::env::var("OXIGIS_LIVE_XYZ_URL").unwrap_or_else(|_| URL.to_owned());
println!("live XYZ: {url}");
let config = BasemapConfig {
url_template: url,
subdomains: Vec::new(),
attribution: String::new(),
};
let transport = HttpTileTransport::new().expect("worker threads must start");
let provider: BoxedTileProvider = Box::new(
XyzTileProvider::new(&config, &egui::Context::default(), Box::new(transport))
.expect("the template must build a provider"),
);
let tile = TileId::new(2, 2, 1).expect("2/2/1 is a valid tile");
let mut decoded = None;
for _ in 0..300 {
if let Some(pixels) = provider.tile(tile) {
decoded = Some(pixels);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let pixels = decoded.expect("the z2 tile must arrive within 30 s");
assert_eq!(pixels.width(), 256, "EOX serves 256 px tiles");
assert_eq!(pixels.height(), 256);
let first = &pixels.rgba()[0..4];
assert!(
pixels.rgba().chunks_exact(4).any(|px| px != first),
"real imagery is not a single flat colour"
);
}
#[test]
#[ignore = "network: fetches one tile from every preset basemap service"]
fn live_basemap_presets_round_trip() {
use oxigis_render::TileId;
use oxigis_ui::BoxedTileProvider;
use oxigis_ui::tile_provider::{BASEMAP_PRESETS, XyzTileProvider};
let tile = TileId::new(2, 2, 1).expect("2/2/1 is a valid tile");
for preset in BASEMAP_PRESETS {
println!("live preset '{}': {}", preset.name, preset.url_template);
let transport = HttpTileTransport::new().expect("worker threads must start");
let provider: BoxedTileProvider = Box::new(
XyzTileProvider::new(
&preset.config(),
&egui::Context::default(),
Box::new(transport),
)
.unwrap_or_else(|error| {
panic!("preset '{}' must build a provider: {error}", preset.name)
}),
);
let mut decoded = None;
for _ in 0..300 {
if let Some(pixels) = provider.tile(tile) {
decoded = Some(pixels);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let pixels = decoded.unwrap_or_else(|| {
panic!(
"preset '{}': the z2 tile must arrive within 30 s",
preset.name
)
});
assert_eq!(pixels.width(), 256, "preset '{}' tile width", preset.name);
assert_eq!(pixels.height(), 256, "preset '{}' tile height", preset.name);
let first = &pixels.rgba()[0..4];
assert!(
pixels.rgba().chunks_exact(4).any(|px| px != first),
"preset '{}': a real map tile is not a single flat colour",
preset.name
);
}
}
#[test]
#[ignore = "network: fetches a real vector tile from demotiles.maplibre.org"]
fn live_maplibre_vector_tile_round_trip() {
use oxigis_render::{LonLat, MapView, TileId};
use oxigis_ui::{VectorTileConfig, VectorTileProvider, VectorTileSource as _};
let config = VectorTileConfig::maplibre_demo();
let transport = HttpTileTransport::new().expect("worker threads must start");
let provider =
VectorTileProvider::new(&config, &egui::Context::default(), Box::new(transport))
.expect("the demo config must build a provider");
let view = MapView::new(LonLat::new(0.0, 0.0), 0.0, [256.0, 256.0]).expect("a valid view");
let _ = provider.begin_frame(view);
let tile = TileId::new(0, 0, 0).expect("0/0/0 is a valid tile");
let mut mesh = None;
for _ in 0..150 {
let _ = provider.begin_frame(view);
if let Some(built) = provider.mesh(tile) {
mesh = Some(built);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let mesh = mesh.expect("the z0 demo vector tile must arrive within 15 s");
let raw = fetch(
&build_agent(),
&HostCooldowns::default(),
"https://demotiles.maplibre.org/tiles/0/0/0.pbf",
)
.expect("the demo tile must be fetchable");
let bytes = if raw.starts_with(&oxigis_ui::vector_provider::GZIP_MAGIC) {
oxiarc_deflate::gzip_decompress(&raw).expect("a gzip body must inflate")
} else {
raw
};
let decoded = oxigis_render::decode_mvt(&bytes).expect("the demo tile must decode");
for layer in &decoded.layers {
println!(
"layer {:?}: {} features, extent {}",
layer.name,
layer.features.len(),
layer.extent
);
}
println!(
"mesh: {} vertices, {} triangles",
mesh.vertices.len(),
mesh.triangle_count()
);
assert!(
!decoded.layers.is_empty(),
"the demo tile must carry at least one named layer"
);
assert!(
decoded.layers.iter().all(|layer| !layer.name.is_empty()),
"every layer must be named"
);
assert!(
decoded
.layers
.iter()
.any(|layer| !layer.features.is_empty()),
"at least one layer must carry features"
);
assert!(
!mesh.is_empty(),
"the default paint table must tessellate the demo tile to something",
);
}
#[test]
#[ignore = "network: fetches a real vector tile from demotiles.maplibre.org"]
fn live_maplibre_label_placement() {
use oxigis_render::{LabelEngine, LabelPlacer, LonLat, MapView, TileId, TilePlacement};
use oxigis_ui::{VectorTileConfig, VectorTileProvider, VectorTileSource as _};
const VIEWPORT_PX: f32 = 1024.0;
let config = VectorTileConfig::maplibre_demo();
let labels = config.label_table();
assert_eq!(
labels.len(),
2,
"the demo style labels centroids and geolines"
);
let transport = HttpTileTransport::new().expect("worker threads must start");
let provider =
VectorTileProvider::new(&config, &egui::Context::default(), Box::new(transport))
.expect("the demo config must build a provider");
let view = MapView::new(LonLat::new(0.0, 0.0), 0.0, [256.0, 256.0]).expect("a valid view");
let tile = TileId::new(0, 0, 0).expect("0/0/0 is a valid tile");
let mut decoded = None;
for _ in 0..150 {
let _ = provider.begin_frame(view);
let _ = provider.mesh(tile);
if let Some(vector_tile) = provider.decoded(tile) {
decoded = Some(vector_tile);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let vector_tile = decoded.expect("the z0 demo vector tile must arrive within 15 s");
let names: Vec<String> = vector_tile
.layers
.iter()
.filter(|layer| layer.name == "centroids")
.flat_map(|layer| &layer.features)
.filter_map(|feature| {
feature
.properties
.iter()
.find(|(key, _)| key == oxigis_ui::DEMO_CENTROID_TEXT_FIELD)
.and_then(|(_, value)| oxigis_render::label_text(value))
})
.collect();
println!(
"first country names in the tile: {:?}",
&names[..names.len().min(8)]
);
assert!(
!names.is_empty(),
"the demo tile's centroids layer must carry {} values",
oxigis_ui::DEMO_CENTROID_TEXT_FIELD,
);
let mut engine = LabelEngine::new(oxifont_bundled::NOTO_SANS_REGULAR.to_vec())
.expect("the bundled Noto Sans must parse");
let placement = TilePlacement {
tile,
x: 0.0,
y: 0.0,
size: VIEWPORT_PX,
};
let mut placer = LabelPlacer::new([VIEWPORT_PX, VIEWPORT_PX]);
placer
.place_tile(&mut engine, &vector_tile, &placement, &labels)
.expect("shaping the demo tile's names must succeed");
assert!(
!placer.is_stale(),
"one z0 tile of country names must fit the atlas without a repack",
);
let considered = placer.considered();
let placed = placer.finish();
println!(
"considered {considered} labels, placed {} of them in a {VIEWPORT_PX}x{VIEWPORT_PX} \
viewport",
placed.len(),
);
for label in placed.iter().take(8) {
println!(
" origin ({:.0}, {:.0}) {:.0}x{:.0} px colour {:?}",
label.origin_px[0],
label.origin_px[1],
label.shaped.width_px(),
label.shaped.height_px(),
label.color,
);
}
assert!(
!placed.is_empty(),
"the demo tile's country names must place at least one label",
);
assert!(
considered >= placed.len(),
"every placed label must have been considered",
);
for (index, left) in placed.iter().enumerate() {
assert!(
left.collision_box.is_inside([VIEWPORT_PX, VIEWPORT_PX]),
"label {index} must be fully inside the viewport",
);
for right in &placed[index + 1..] {
assert!(
!left.collision_box.intersects(&right.collision_box),
"accepted labels must not overlap: {:?} and {:?}",
left.collision_box,
right.collision_box,
);
}
}
}
}