diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ea5cc893..7eff57f9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,6 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 + - uses: arduino/setup-protoc@v3.0.0 - name: Build run: cargo build - name: Cargo Check All Features @@ -54,6 +55,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 + - uses: arduino/setup-protoc@v3.0.0 - name: Tests run: cargo test --verbose - name: Formatting diff --git a/CHANGELOG.md b/CHANGELOG.md index 94adeb05..ddb840b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. ## Unreleased * New `LocalTiles` implementation for loading tiles from a local directory. +* New, highly experimental vector tile rendering using `.pmtiles`. It has very poor performance and + at this point it should be considered more of a tech demo than a usable feature. +* Removed `Image` plugin. * Fixed zooming on high refresh rates. ## 0.45.0 diff --git a/Cargo.lock b/Cargo.lock index 97760a32..a0fcf913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977eb15ea9efd848bb8a4a1a2500347ed7f0bf794edf0dc3ddcf439f43d36b23" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.13.2" @@ -667,6 +680,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -806,6 +825,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485abf41ac0c8047c07c87c72c8fb3eb5197f6e9d7ded615dfd1a00ae00a0f64" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1037,6 +1073,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "earcut" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d565e947a723df9f069393f8c3e723cf386ff9a052443339235ae2a4d9ffee3f" +dependencies = [ + "num-traits", +] + [[package]] name = "ecolor" version = "0.32.3" @@ -1229,6 +1274,18 @@ dependencies = [ "syn", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1352,6 +1409,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast_hilbert" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3faa84f4bea4fba03e05500ec1fad62efe4a07632100f1cbef165bb22dcb77fc" + [[package]] name = "fastrand" version = "2.3.0" @@ -1387,6 +1450,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.2" @@ -1403,6 +1472,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fmmap" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687c574434dc6e3cd24a363fe0944711174f947fe71696fdc9a0ae046fe6e715" +dependencies = [ + "byteorder", + "bytes", + "enum_dispatch", + "fs4", + "memmap2 0.9.5", + "parse-display", + "pin-project-lite", + "tokio", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1466,6 +1551,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29c30684418547d476f0b48e84f4821639119c483b1eccd566c8cd0cd05f521" +dependencies = [ + "rustix 0.38.44", + "tokio", + "windows-sys 0.52.0", +] + [[package]] name = "futures" version = "0.3.31" @@ -2207,6 +2303,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2523,6 +2628,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "mvt-reader" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7c5dc7ebed3e8d6c4fae2397303df7ee0b7c99122993c1e6ae5fbda7839536" +dependencies = [ + "geo-types", + "prost", + "prost-build", +] + [[package]] name = "naga" version = "25.0.1" @@ -3072,6 +3194,31 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-display" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287d8d3ebdce117b8539f59411e4ed9ec226e0a4153c7f55495c6070d68e6f72" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc048687be30d79502dea2f623d052f3a074012c6eac41726b7ab17213616b1" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + [[package]] name = "paste" version = "1.0.15" @@ -3084,6 +3231,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -3183,6 +3340,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "pmtiles" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b66bf92795a1ed0845a4b271c88dd5e787514df655710859970306c47d0c56" +dependencies = [ + "async-compression", + "bytes", + "fast_hilbert", + "fmmap", + "thiserror 2.0.16", + "tokio", + "varint-rs", +] + [[package]] name = "png" version = "0.17.16" @@ -3275,6 +3447,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -3299,6 +3481,58 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "pxfm" version = "0.1.23" @@ -4050,6 +4284,29 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "strum" version = "0.26.3" @@ -4591,6 +4848,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "varint-rs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4618,10 +4881,12 @@ name = "walkers" version = "0.45.0" dependencies = [ "approx", + "earcut", "eframe", "egui", "egui_extras", "env_logger", + "flate2", "futures", "geo-types", "http-cache-reqwest", @@ -4629,6 +4894,8 @@ dependencies = [ "image", "log", "lru", + "mvt-reader", + "pmtiles", "reqwest", "reqwest-middleware", "serde", diff --git a/demo/Cargo.toml b/demo/Cargo.toml index 26e23a76..28cf1c75 100644 --- a/demo/Cargo.toml +++ b/demo/Cargo.toml @@ -12,3 +12,6 @@ egui_extras.workspace = true # This is needed until https://github.com/emilk/egui/pull/7344 is released. wgpu = { version = "25", default-features = true } + +[features] +vector_tiles = ["walkers/vector_tiles"] diff --git a/demo/src/lib.rs b/demo/src/lib.rs index c4fbb577..75115277 100644 --- a/demo/src/lib.rs +++ b/demo/src/lib.rs @@ -3,18 +3,16 @@ mod plugins; mod tiles; mod windows; -use std::collections::BTreeMap; - -use crate::plugins::ImagesPluginData; use egui::{Button, CentralPanel, Context, DragPanButtons, Frame, OpenUrl, Rect, Vec2}; -use tiles::{providers, Provider, TilesKind}; +use tiles::{providers, TilesKind}; use walkers::{Map, MapMemory}; +use crate::tiles::Providers; + pub struct MyApp { - providers: BTreeMap>, - selected_provider: Provider, + providers: Providers, + selected_provider: String, map_memory: MapMemory, - images_plugin_data: ImagesPluginData, click_watcher: plugins::ClickWatcher, zoom_with_ctrl: bool, } @@ -23,14 +21,10 @@ impl MyApp { pub fn new(egui_ctx: Context) -> Self { egui_extras::install_image_loaders(&egui_ctx); - // Data for the `images` plugin showcase. - let images_plugin_data = ImagesPluginData::new(egui_ctx.to_owned()); - Self { providers: providers(egui_ctx.to_owned()), - selected_provider: Provider::OpenStreetMap, + selected_provider: "OpenStreetMap".to_string(), map_memory: MapMemory::default(), - images_plugin_data, click_watcher: Default::default(), zoom_with_ctrl: true, } @@ -43,7 +37,11 @@ impl eframe::App for MyApp { // Typically this would be a GPS acquired position which is tracked by the map. let my_position = places::wroclaw_glowny(); - let tiles = self.providers.get_mut(&self.selected_provider).unwrap(); + let tiles = self + .providers + .available + .get_mut(&self.selected_provider) + .unwrap(); let attributions: Vec<_> = tiles .iter() .map(|tile| tile.as_ref().attribution()) @@ -60,7 +58,6 @@ impl eframe::App for MyApp { // Optionally, plugins can be attached. map = map .with_plugin(plugins::places()) - .with_plugin(plugins::images(&mut self.images_plugin_data)) .with_plugin(plugins::CustomShapes {}) .with_plugin(&mut self.click_watcher); diff --git a/demo/src/places.rs b/demo/src/places.rs index 91d6955a..2132ee07 100644 --- a/demo/src/places.rs +++ b/demo/src/places.rs @@ -21,11 +21,6 @@ pub fn capitol() -> Position { lon_lat(17.03018, 51.10073) } -/// Shopping center, and the main intercity bus station. -pub fn wroclavia() -> Position { - lon_lat(17.03471, 51.09648) -} - /// Main square of the city, with many restaurants and historical buildings. pub fn rynek() -> Position { lon_lat(17.032094, 51.110090) diff --git a/demo/src/plugins.rs b/demo/src/plugins.rs index 7b28fb4f..b4cbc073 100644 --- a/demo/src/plugins.rs +++ b/demo/src/plugins.rs @@ -1,8 +1,8 @@ use egui::{Color32, Response, Ui}; use walkers::{ extras::{ - GroupedPlaces, Image, LabeledSymbol, LabeledSymbolGroup, LabeledSymbolGroupStyle, - LabeledSymbolStyle, Places, Symbol, Texture, + GroupedPlaces, LabeledSymbol, LabeledSymbolGroup, LabeledSymbolGroupStyle, + LabeledSymbolStyle, Symbol, }, MapMemory, Plugin, Position, Projector, }; @@ -46,35 +46,6 @@ pub fn places() -> impl Plugin { ) } -/// Helper structure for the `Images` plugin. -pub struct ImagesPluginData { - pub texture: Texture, - pub angle: f32, - pub x_scale: f32, - pub y_scale: f32, -} - -impl ImagesPluginData { - pub fn new(egui_ctx: egui::Context) -> Self { - Self { - texture: Texture::from_color_image(egui::ColorImage::example(), &egui_ctx), - angle: 0.0, - x_scale: 1.0, - y_scale: 1.0, - } - } -} - -/// Creates a built-in `Images` plugin with an example image. -pub fn images(images_plugin_data: &mut ImagesPluginData) -> impl Plugin { - Places::new(vec![{ - let mut image = Image::new(images_plugin_data.texture.clone(), places::wroclavia()); - image.scale(images_plugin_data.x_scale, images_plugin_data.y_scale); - image.angle(images_plugin_data.angle.to_radians()); - image - }]) -} - /// Sample map plugin which draws custom stuff on the map. pub struct CustomShapes {} diff --git a/demo/src/tiles.rs b/demo/src/tiles.rs index 4819b14d..71d5de4a 100644 --- a/demo/src/tiles.rs +++ b/demo/src/tiles.rs @@ -1,21 +1,15 @@ use std::{collections::BTreeMap, path::PathBuf}; use egui::Context; +#[cfg(feature = "vector_tiles")] +use walkers::PmTiles; use walkers::{HttpOptions, HttpTiles, LocalTiles, Tiles}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Provider { - OpenStreetMap, - Geoportal, - OpenStreetMapWithGeoportal, - MapboxStreets, - MapboxSatellite, - LocalTiles, -} - pub(crate) enum TilesKind { Http(HttpTiles), Local(LocalTiles), + #[cfg(feature = "vector_tiles")] + PmTiles(PmTiles), } impl AsMut for TilesKind { @@ -23,6 +17,8 @@ impl AsMut for TilesKind { match self { TilesKind::Http(tiles) => tiles, TilesKind::Local(tiles) => tiles, + #[cfg(feature = "vector_tiles")] + TilesKind::PmTiles(tiles) => tiles, } } } @@ -32,6 +28,8 @@ impl AsRef for TilesKind { match self { TilesKind::Http(tiles) => tiles, TilesKind::Local(tiles) => tiles, + #[cfg(feature = "vector_tiles")] + TilesKind::PmTiles(tiles) => tiles, } } } @@ -48,11 +46,18 @@ fn http_options() -> HttpOptions { } } -pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> { - let mut providers = BTreeMap::default(); +#[derive(Default)] +pub struct Providers { + pub available: BTreeMap>, + #[cfg(feature = "vector_tiles")] + pub have_some_pmtiles: bool, +} + +pub(crate) fn providers(egui_ctx: Context) -> Providers { + let mut providers = Providers::default(); - providers.insert( - Provider::OpenStreetMap, + providers.available.insert( + "OpenStreetMap".to_string(), vec![TilesKind::Http(HttpTiles::with_options( walkers::sources::OpenStreetMap, http_options(), @@ -60,8 +65,8 @@ pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> ))], ); - providers.insert( - Provider::Geoportal, + providers.available.insert( + "Geoportal".to_string(), vec![TilesKind::Http(HttpTiles::with_options( walkers::sources::Geoportal, http_options(), @@ -69,8 +74,8 @@ pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> ))], ); - providers.insert( - Provider::OpenStreetMapWithGeoportal, + providers.available.insert( + "OpenStreetMapWithGeoportal".to_string(), vec![ TilesKind::Http(HttpTiles::with_options( walkers::sources::OpenStreetMap, @@ -85,8 +90,8 @@ pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> ], ); - providers.insert( - Provider::Geoportal, + providers.available.insert( + "Geoportal".to_string(), vec![TilesKind::Http(HttpTiles::with_options( walkers::sources::Geoportal, http_options(), @@ -94,22 +99,35 @@ pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> ))], ); - providers.insert( - Provider::LocalTiles, + providers.available.insert( + "LocalTiles".to_string(), vec![TilesKind::Local(LocalTiles::new( PathBuf::from_iter(&[env!("CARGO_MANIFEST_DIR"), "assets"]), egui_ctx.to_owned(), ))], ); + #[cfg(feature = "vector_tiles")] + { + let pmtiles = find_pmtiles_files(); + providers.have_some_pmtiles = !pmtiles.is_empty(); + + for path in pmtiles { + providers.available.insert( + path.file_name().unwrap().to_string_lossy().to_string(), + vec![TilesKind::PmTiles(PmTiles::new(path))], + ); + } + } + // Pass in a mapbox access token at compile time. May or may not be what you want to do, // potentially loading it from application settings instead. let mapbox_access_token = std::option_env!("MAPBOX_ACCESS_TOKEN"); // We only show the mapbox map if we have an access token if let Some(token) = mapbox_access_token { - providers.insert( - Provider::MapboxStreets, + providers.available.insert( + "MapboxStreets".to_string(), vec![TilesKind::Http(HttpTiles::with_options( walkers::sources::Mapbox { style: walkers::sources::MapboxStyle::Streets, @@ -120,8 +138,8 @@ pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> egui_ctx.to_owned(), ))], ); - providers.insert( - Provider::MapboxSatellite, + providers.available.insert( + "MapboxSatellite".to_string(), vec![TilesKind::Http(HttpTiles::with_options( walkers::sources::Mapbox { style: walkers::sources::MapboxStyle::Satellite, @@ -136,3 +154,16 @@ pub(crate) fn providers(egui_ctx: Context) -> BTreeMap> providers } + +#[cfg(feature = "vector_tiles")] +fn find_pmtiles_files() -> Vec { + let Ok(dir) = std::fs::read_dir(".") else { + return Vec::new(); + }; + + dir.filter_map(|entry| { + let path = entry.ok()?.path(); + (path.extension()?.to_str()? == "pmtiles").then_some(path) + }) + .collect() +} diff --git a/demo/src/windows.rs b/demo/src/windows.rs index 5704f686..56de5234 100644 --- a/demo/src/windows.rs +++ b/demo/src/windows.rs @@ -1,5 +1,5 @@ use crate::MyApp; -use egui::{Align2, ComboBox, Image, RichText, Slider, Ui, Window}; +use egui::{Align2, ComboBox, Image, RichText, Ui, Window}; use walkers::{sources::Attribution, MapMemory}; pub fn acknowledge(ui: &Ui, attributions: Vec) { @@ -31,40 +31,43 @@ pub fn controls(app: &mut MyApp, ui: &Ui, http_stats: Vec) { .anchor(Align2::RIGHT_TOP, [-10., 10.]) .fixed_size([150., 150.]) .show(ui.ctx(), |ui| { - ui.collapsing("Map", |ui| { - ComboBox::from_label("Tile Provider") - .selected_text(format!("{:?}", app.selected_provider)) - .show_ui(ui, |ui| { - for p in app.providers.keys() { - ui.selectable_value(&mut app.selected_provider, *p, format!("{:?}", p)); - } - }); + ui.heading("Map"); - ui.checkbox(&mut app.zoom_with_ctrl, "Zoom with Ctrl"); + ComboBox::from_label("Tile Provider") + .selected_text(app.selected_provider.to_owned()) + .show_ui(ui, |ui| { + for p in app.providers.available.keys() { + ui.selectable_value(&mut app.selected_provider, p.clone(), p); + } + }); - ui.separator(); + #[cfg(feature = "vector_tiles")] + if !app.providers.have_some_pmtiles { + ui.label("No .pmtiles files found in the current directory. Go to"); + ui.hyperlink("https://docs.protomaps.com/guide/getting-started"); + ui.label(" to see how to fetch some."); + } - if app.map_memory.animating() { - ui.label("Map is animating"); - } else { - ui.label("Map is not animating"); - } - }); + ui.add_space(10.0); + ui.heading("Settings"); - ui.collapsing("HTTP statistics", |ui| { - for http_stats in http_stats { - ui.label(format!( - "{:?} requests in progress: {}", - app.selected_provider, http_stats.in_progress - )); - } - }); + ui.checkbox(&mut app.zoom_with_ctrl, "Zoom with Ctrl"); - ui.collapsing("Images plugin", |ui| { - ui.add(Slider::new(&mut app.images_plugin_data.angle, 0.0..=360.0).text("Rotate")); - ui.add(Slider::new(&mut app.images_plugin_data.x_scale, 0.1..=3.0).text("Scale X")); - ui.add(Slider::new(&mut app.images_plugin_data.y_scale, 0.1..=3.0).text("Scale Y")); - }); + ui.add_space(10.0); + ui.heading("Debug"); + + if app.map_memory.animating() { + ui.label("Map is animating"); + } else { + ui.label("Map is not animating"); + } + + for http_stats in http_stats { + ui.label(format!( + "{:?} requests in progress: {}", + app.selected_provider, http_stats.in_progress + )); + } }); } diff --git a/demo_native/Cargo.toml b/demo_native/Cargo.toml index 163be555..c4ce53f8 100644 --- a/demo_native/Cargo.toml +++ b/demo_native/Cargo.toml @@ -6,7 +6,7 @@ publish = false default-run = "demo_native" [dependencies] -demo = { path = "../demo" } +demo = { path = "../demo", features = ["vector_tiles"] } eframe.workspace = true egui.workspace = true env_logger = "0.11" diff --git a/walkers/Cargo.toml b/walkers/Cargo.toml index 95928f15..bd24aa39 100644 --- a/walkers/Cargo.toml +++ b/walkers/Cargo.toml @@ -27,6 +27,14 @@ futures = "0.3.28" serde = { version = "1", features = ["derive"], optional = true } reqwest-middleware = "0.4.2" +# Vector tiles +pmtiles = { version = "0.16.0", default-features = false, features = [ + "mmap-async-tokio", +], optional = true } +mvt-reader = { version = "2.1.0", optional = true } +flate2 = { version = "1.1.2", optional = true } +earcut = { version = "0.4.4", optional = true } + [target.'cfg(target_family = "wasm")'.dependencies] wasm-bindgen-futures = "0.4" @@ -42,4 +50,6 @@ hypermocker = { path = "../hypermocker" } [features] default = [] -serde = ["dep:serde", "geo-types/serde", "egui/serde"] \ No newline at end of file +serde = ["dep:serde", "geo-types/serde", "egui/serde"] +vector_tiles = ["dep:pmtiles", "dep:mvt-reader", "dep:flate2", "dep:earcut"] +debug_vector_rendering = [] diff --git a/walkers/README.md b/walkers/README.md index cd9eb69e..71f29a3c 100644 --- a/walkers/README.md +++ b/walkers/README.md @@ -65,15 +65,26 @@ necessarily compatible with Cargo alone. ### Native -To enable mapbox layers, you need to define `MAPBOX_ACCESS_TOKEN` environment -variable before building. You can obtain one, by creating a -[mapbox account](https://account.mapbox.com/). - ```sh -cd demo_native cargo run ``` +#### PMTiles + +To see PMTiles support in action, you need to obtain some `.pmtiles` files and +put them into the directory from where you run the demo. One way of doing that +is to download an extract from [Protonmaps](https://docs.protomaps.com/guide/getting-started). + +```sh +pmtiles extract https://build.protomaps.com/20250928.pmtiles --bbox 16.802768,51.036355,17.209205,51.180686 wroclaw.pmtiles +``` + +#### Mapbox + +To enable mapbox layers, you need to define `MAPBOX_ACCESS_TOKEN` environment +variable before building. You can obtain one, by creating a +[mapbox account](https://account.mapbox.com/). + ### Web / WASM ```sh diff --git a/walkers/src/extras/image.rs b/walkers/src/extras/image.rs deleted file mode 100644 index aeb037fa..00000000 --- a/walkers/src/extras/image.rs +++ /dev/null @@ -1,55 +0,0 @@ -use super::{places::Place, Texture}; -use crate::Position; -use egui::{emath::Rot2, Rect, Ui, Vec2}; - -/// An image to be drawn on the map. -pub struct Image { - /// Geographical position. - position: Position, - - scale: Vec2, - angle: Rot2, - texture: Texture, -} - -impl Image { - pub fn new(texture: Texture, position: Position) -> Self { - Self { - position, - scale: Vec2::splat(1.0), - angle: Rot2::from_angle(0.0), - texture, - } - } - - /// Scale the image. - pub fn scale(&mut self, x: f32, y: f32) { - self.scale.x = x; - self.scale.y = y; - } - - /// Set the image's angle in radians. - pub fn angle(&mut self, angle: f32) { - self.angle = Rot2::from_angle(angle); - } -} - -impl Place for Image { - fn position(&self) -> Position { - self.position - } - - fn draw(&self, ui: &Ui, projector: &crate::Projector) { - let painter = ui.painter(); - let rect = Rect::from_center_size( - projector.project(self.position).to_pos2(), - self.texture.size() * self.scale, - ); - - if painter.clip_rect().intersects(rect) { - let mut mesh = self.texture.mesh_with_rect(rect); - mesh.rotate(self.angle, rect.center()); - painter.add(mesh); - } - } -} diff --git a/walkers/src/extras/mod.rs b/walkers/src/extras/mod.rs index 9f533c3e..8dfa9c57 100644 --- a/walkers/src/extras/mod.rs +++ b/walkers/src/extras/mod.rs @@ -1,10 +1,8 @@ //! Extra functionalities that can be used with the map. -mod image; mod labeled_symbol; mod places; pub use crate::tiles::Texture; -pub use image::Image; pub use labeled_symbol::{ LabeledSymbol, LabeledSymbolGroup, LabeledSymbolGroupStyle, LabeledSymbolStyle, Symbol, }; diff --git a/walkers/src/lib.rs b/walkers/src/lib.rs index 33050a92..b28567d1 100644 --- a/walkers/src/lib.rs +++ b/walkers/src/lib.rs @@ -10,6 +10,10 @@ mod local_tiles; mod map; mod memory; mod mercator; +#[cfg(feature = "vector_tiles")] +mod mvt; +#[cfg(feature = "vector_tiles")] +mod pmtiles; mod position; mod projector; pub mod sources; @@ -21,6 +25,8 @@ pub use http_tiles::{HttpStats, HttpTiles}; pub use local_tiles::LocalTiles; pub use map::{Map, Plugin}; pub use memory::MapMemory; +#[cfg(feature = "vector_tiles")] +pub use pmtiles::PmTiles; pub use position::{lat_lon, lon_lat, Position}; pub use projector::Projector; pub use tiles::{Texture, TextureWithUv, TileId, Tiles}; diff --git a/walkers/src/mvt.rs b/walkers/src/mvt.rs new file mode 100644 index 00000000..9a7bfc04 --- /dev/null +++ b/walkers/src/mvt.rs @@ -0,0 +1,145 @@ +//! Renderer for Mapbox Vector Tiles. + +use egui::{ + epaint::{PathShape, PathStroke}, + pos2, Color32, Pos2, Stroke, +}; +use geo_types::Geometry; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Mvt(#[from] mvt_reader::error::ParserError), +} + +/// Currently this is the only supported extent. +const ONLY_SUPPORTED_EXTENT: u32 = 4096; + +pub fn render( + tile: &mvt_reader::Reader, + painter: egui::Painter, + rect: egui::Rect, +) -> Result<(), Error> { + #[cfg(feature = "debug_vector_rendering")] + // Draw a rect around the tile. + painter.rect_stroke( + rect, + 0.0, + egui::Stroke::new(1.0, Color32::RED), + egui::StrokeKind::Inside, + ); + + // Transform coordinates from MVT space to screen space. + let transformed_pos2 = |x: f32, y: f32| { + pos2( + rect.left() + (x / ONLY_SUPPORTED_EXTENT as f32) * rect.width(), + rect.top() + (y / ONLY_SUPPORTED_EXTENT as f32) * rect.height(), + ) + }; + + let line_stroke = Stroke::new(3.0, Color32::WHITE); + + let supported_layers = tile.get_layer_metadata()?.into_iter().filter_map(|layer| { + if layer.extent == ONLY_SUPPORTED_EXTENT { + Some(layer.layer_index) + } else { + log::warn!( + "Skipping layer '{}' with unsupported extent {}.", + layer.name, + layer.extent + ); + None + } + }); + + for index in supported_layers { + for feature in tile.get_features(index)? { + match feature.geometry { + Geometry::Point(_point) => todo!(), + Geometry::Line(_line) => todo!(), + Geometry::LineString(line_string) => { + for segment in line_string.0.windows(2) { + painter.line_segment( + [ + transformed_pos2(segment[0].x, segment[0].y), + transformed_pos2(segment[1].x, segment[1].y), + ], + line_stroke, + ); + } + } + Geometry::Polygon(_polygon) => todo!(), + Geometry::MultiPoint(multi_point) => { + for point in multi_point { + painter.circle_filled( + transformed_pos2(point.x(), point.y()), + 3.0, + Color32::from_rgb(200, 200, 0), + ); + } + } + Geometry::MultiLineString(multi_line_string) => { + for line_string in multi_line_string { + let points = line_string + .0 + .iter() + .map(|p| transformed_pos2(p.x, p.y)) + .collect::>(); + painter.line(points, line_stroke); + } + } + Geometry::MultiPolygon(multi_polygon) => { + for polygon in multi_polygon.iter() { + let points = polygon + .exterior() + .0 + .iter() + .map(|p| transformed_pos2(p.x, p.y)) + .collect::>(); + arbitrary_polygon(&points, &painter); + } + } + Geometry::GeometryCollection(_geometry_collection) => todo!(), + Geometry::Rect(_rect) => todo!(), + Geometry::Triangle(_triangle) => todo!(), + } + } + } + Ok(()) +} + +/// Egui can only draw convex polygons, so we need to triangulate arbitrary ones. +fn arbitrary_polygon(points: &[Pos2], painter: &egui::Painter) { + let mut triangles = Vec::::new(); + let mut earcut = earcut::Earcut::new(); + earcut.earcut(points.iter().map(|p| [p.x, p.y]), &[], &mut triangles); + + for triangle_indices in triangles.chunks(3) { + let triangle = [ + points[triangle_indices[0]], + points[triangle_indices[1]], + points[triangle_indices[2]], + ]; + + if triangle_area(triangle[0], triangle[1], triangle[2]) < 0.1 { + // Too small to render without artifacts. + continue; + } + + painter.add(PathShape::convex_polygon( + triangle.to_vec(), + Color32::WHITE.gamma_multiply(0.2), + PathStroke::NONE, + )); + + #[cfg(feature = "debug_vector_rendering")] + painter.add(PathShape::closed_line( + triangle.to_vec(), + PathStroke::new(2.0, Color32::RED), + )); + } +} + +fn triangle_area(a: Pos2, b: Pos2, c: Pos2) -> f32 { + ((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2.0).abs() +} diff --git a/walkers/src/pmtiles.rs b/walkers/src/pmtiles.rs new file mode 100644 index 00000000..1c5f8a8a --- /dev/null +++ b/walkers/src/pmtiles.rs @@ -0,0 +1,110 @@ +use crate::{ + sources::Attribution, tiles::interpolate_from_lower_zoom, Texture, TextureWithUv, TileId, Tiles, +}; +use lru::LruCache; +use pmtiles::{AsyncPmTilesReader, TileCoord}; +use std::{ + io::{self, Read as _}, + path::{Path, PathBuf}, +}; +use thiserror::Error; + +#[derive(Clone)] +enum CachedTexture { + Valid(Texture), + Invalid, +} + +/// https://docs.protomaps.com/guide/getting-started +pub struct PmTiles { + path: PathBuf, + cache: LruCache, +} + +impl PmTiles { + pub fn new(path: impl AsRef) -> Self { + // Just arbitrary value which seemed right. + #[allow(clippy::unwrap_used)] + let cache_size = std::num::NonZeroUsize::new(256).unwrap(); + + Self { + path: path.as_ref().into(), + cache: LruCache::new(cache_size), + } + } + + fn load_and_cache(&mut self, tile_id: TileId) -> CachedTexture { + self.cache + .get_or_insert(tile_id, || match load(&self.path, tile_id) { + Ok(texture) => CachedTexture::Valid(texture), + Err(err) => { + log::warn!("Failed to load tile {:?}: {}", tile_id, err); + CachedTexture::Invalid + } + }) + .clone() + } +} + +impl Tiles for PmTiles { + fn at(&mut self, tile_id: TileId) -> Option { + (0..=tile_id.zoom).rev().find_map(|zoom_candidate| { + let (donor_tile_id, uv) = interpolate_from_lower_zoom(tile_id, zoom_candidate); + match self.load_and_cache(donor_tile_id) { + CachedTexture::Valid(texture) => Some(TextureWithUv::new(texture.clone(), uv)), + CachedTexture::Invalid => None, + } + }) + } + + fn attribution(&self) -> Attribution { + Attribution { + text: "PMTiles", + url: "", + logo_light: None, + logo_dark: None, + } + } + + fn tile_size(&self) -> u32 { + // Vector tiles can be rendered at any size. Effectively this means that the lower the + // tile, the more details are visible. + 512 + } +} + +#[derive(Debug, Error)] +enum PmTilesError { + #[error("Tile not found")] + TileNotFound, + #[error(transparent)] + Other(#[from] pmtiles::PmtError), +} + +fn load(path: &Path, tile_id: TileId) -> Result> { + // TODO: Yes, that's heavy. + let bytes = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let reader = AsyncPmTilesReader::new_with_path(path).await?; + reader + .get_tile(TileCoord::new(tile_id.zoom, tile_id.x, tile_id.y)?) + .await? + .ok_or(PmTilesError::TileNotFound) + })?; + + let decompressed = decompress(&bytes)?; + Ok(Texture::from_mvt(&decompressed)?) +} + +/// Decompress the tile. +/// +/// This function assumes the input is gzip compressed data, but this might not always be the case. +/// You can use `pmtiles info ` to check the compression type. +fn decompress(data: &[u8]) -> io::Result> { + let mut decoder = flate2::read::GzDecoder::new(data); + let mut buf = Vec::new(); + decoder.read_to_end(&mut buf)?; + Ok(buf) +} diff --git a/walkers/src/tiles.rs b/walkers/src/tiles.rs index 2bf1c1e4..d71302f6 100644 --- a/walkers/src/tiles.rs +++ b/walkers/src/tiles.rs @@ -1,5 +1,6 @@ -use std::collections::hash_map::Entry; -use std::collections::HashMap; +use std::collections::HashSet; +#[cfg(feature = "vector_tiles")] +use std::sync::Arc; use egui::{pos2, Color32, Context, Mesh, Rect, Vec2}; use egui::{ColorImage, TextureHandle}; @@ -80,7 +81,11 @@ pub(crate) fn rect(screen_position: Vec2, tile_size: f64) -> Rect { } #[derive(Clone)] -pub struct Texture(TextureHandle); +pub enum Texture { + Raster(TextureHandle), + #[cfg(feature = "vector_tiles")] + Vector(Arc), +} impl Texture { pub fn new(image: &[u8], ctx: &Context) -> Result { @@ -96,37 +101,37 @@ impl Texture { /// Load the texture from egui's [`ColorImage`]. pub fn from_color_image(color_image: ColorImage, ctx: &Context) -> Self { - Self(ctx.load_texture("image", color_image, Default::default())) + Self::Raster(ctx.load_texture("image", color_image, Default::default())) } - pub(crate) fn size(&self) -> Vec2 { - self.0.size_vec2() + #[cfg(feature = "vector_tiles")] + pub fn from_mvt(data: &[u8]) -> Result { + let reader = mvt_reader::Reader::new(data.to_vec())?; + Ok(Self::Vector(Arc::new(reader))) } - pub(crate) fn mesh_with_uv( - &self, - screen_position: Vec2, - tile_size: f64, - uv: Rect, - transparency: f32, - ) -> Mesh { - self.mesh_with_rect_and_uv(rect(screen_position, tile_size), uv, transparency) - } + /// Draw the tile on the given `rect`. The `uv` parameter defines which part of the tile + /// should be drawn on the `rect`. + fn draw(&self, painter: &egui::Painter, rect: Rect, uv: Rect, transparency: f32) { + match self { + Texture::Raster(texture_handle) => { + let mut mesh = Mesh::with_texture(texture_handle.id()); + mesh.add_rect_with_uv(rect, uv, Color32::WHITE.gamma_multiply(transparency)); + painter.add(egui::Shape::mesh(mesh)); + } + #[cfg(feature = "vector_tiles")] + Texture::Vector(reader) => { + // Renderer needs to work on the full tile, before it was clipped with `uv`. + let full_rect = full_rect_of_clipped_tile(rect, uv); - pub(crate) fn mesh_with_rect(&self, rect: Rect) -> Mesh { - let mut mesh = Mesh::with_texture(self.0.id()); - mesh.add_rect_with_uv( - rect, - Rect::from_min_max(pos2(0., 0.0), pos2(1.0, 1.0)), - Color32::WHITE, - ); - mesh - } + // Then it can be clipped to the `rect`. + let painter = painter.with_clip_rect(rect); - pub(crate) fn mesh_with_rect_and_uv(&self, rect: Rect, uv: Rect, transparency: f32) -> Mesh { - let mut mesh = Mesh::with_texture(self.0.id()); - mesh.add_rect_with_uv(rect, uv, Color32::WHITE.gamma_multiply(transparency)); - mesh + if let Err(err) = crate::mvt::render(reader, painter, full_rect) { + log::warn!("Could not render MVT tile: {}", err); + } + } + } } } @@ -151,7 +156,7 @@ pub(crate) fn draw_tiles( ) { let mut meshes = Default::default(); flood_fill_tiles( - painter.clip_rect(), + painter, tile_id(map_center, zoom.round(), tiles.tile_size()), project(map_center, zoom.into()), zoom.into(), @@ -159,61 +164,56 @@ pub(crate) fn draw_tiles( transparency, &mut meshes, ); - - for shape in meshes.drain().filter_map(|(_, mesh)| mesh) { - painter.add(shape); - } } /// Use simple [flood fill algorithm](https://en.wikipedia.org/wiki/Flood_fill) to draw tiles on the map. fn flood_fill_tiles( - viewport: Rect, + painter: &egui::Painter, tile_id: TileId, map_center_projected_position: Pixels, zoom: f64, tiles: &mut dyn Tiles, transparency: f32, - meshes: &mut HashMap>, + meshes: &mut HashSet, ) { // We need to make up the difference between integer and floating point zoom levels. let corrected_tile_size = tiles.tile_size() as f64 * 2f64.powf(zoom - zoom.round()); let tile_projected = tile_id.project(corrected_tile_size); - let tile_screen_position = - viewport.center().to_vec2() + (tile_projected - map_center_projected_position).to_vec2(); - - if viewport.intersects(rect(tile_screen_position, corrected_tile_size)) { - if let Entry::Vacant(entry) = meshes.entry(tile_id) { - // It's still OK to insert an empty one, as we need to mark the spot for the filling algorithm. - let tile = tiles.at(tile_id).map(|tile| { - tile.texture.mesh_with_uv( - tile_screen_position, - corrected_tile_size, - tile.uv, - transparency, - ) - }); - - entry.insert(tile); - - for next_tile_id in [ - tile_id.north(), - tile_id.east(), - tile_id.south(), - tile_id.west(), - ] - .iter() - .flatten() - { - flood_fill_tiles( - viewport, - *next_tile_id, - map_center_projected_position, - zoom, - tiles, - transparency, - meshes, - ); - } + let tile_screen_position = painter.clip_rect().center().to_vec2() + + (tile_projected - map_center_projected_position).to_vec2(); + + if painter + .clip_rect() + .intersects(rect(tile_screen_position, corrected_tile_size)) + && meshes.insert(tile_id) + { + if let Some(tile) = tiles.at(tile_id) { + tile.texture.draw( + painter, + rect(tile_screen_position, corrected_tile_size), + tile.uv, + transparency, + ) + } + + for next_tile_id in [ + tile_id.north(), + tile_id.east(), + tile_id.south(), + tile_id.west(), + ] + .iter() + .flatten() + { + flood_fill_tiles( + painter, + *next_tile_id, + map_center_projected_position, + zoom, + tiles, + transparency, + meshes, + ); } } } @@ -243,10 +243,38 @@ pub(crate) fn interpolate_from_lower_zoom(tile_id: TileId, available_zoom: u8) - (zoomed_tile_id, uv) } +/// Get the original rect which was clipped using the `uv`. +fn full_rect_of_clipped_tile(rect: Rect, uv: Rect) -> Rect { + let uv_width = uv.max.x - uv.min.x; + let uv_height = uv.max.y - uv.min.y; + + let full_width = rect.width() / uv_width; + let full_height = rect.height() / uv_height; + + let full_min_x = rect.min.x - (full_width * uv.min.x); + let full_min_y = rect.min.y - (full_height * uv.min.y); + + Rect::from_min_max( + pos2(full_min_x, full_min_y), + pos2(full_min_x + full_width, full_min_y + full_height), + ) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_full_rect_of_clipped_tile() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(50.0, 50.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 0.5)); + + let full_rect = full_rect_of_clipped_tile(rect, uv); + + assert_eq!(full_rect.min, pos2(0.0, 0.0)); + assert_eq!(full_rect.max, pos2(100.0, 100.0)); + } + #[test] fn tile_id_cannot_go_beyond_limits() { // There is only one tile at zoom 0.