git.lucas.co / cce-map
map viewer
git clone https://git.lucas.co/cce-map.git

src/tiles.rs (8.3K)

  1 //! Slippy-map tile store: disk cache + HTTP fetch workers + GPU-image LRU.
  2 //!
  3 //! Workers decode PNGs and call `cce_ui::vk::upload_rgba` directly (the
  4 //! upload queue is thread-safe; the actual GPU work happens on the next
  5 //! frame), then notify the app through the calloop channel so the engine
  6 //! wakes and repaints.
  7 
  8 use std::collections::HashMap;
  9 use std::path::PathBuf;
 10 use std::sync::mpsc;
 11 use std::sync::{Arc, Mutex};
 12 
 13 use crate::Message;
 14 
 15 pub const TILE_SIZE: f64 = 256.0;
 16 pub const MAX_ZOOM: u8 = 19;
 17 
 18 /// GPU tiles kept resident. The cce-ui image registry hard-caps at 256
 19 /// images total, so leave headroom for other consumers and churn.
 20 const MAX_GPU_TILES: usize = 180;
 21 const FETCH_THREADS: usize = 4;
 22 
 23 /// OSM tile-usage policy requires an identifying User-Agent.
 24 const USER_AGENT: &str = concat!("cce-map/", env!("CARGO_PKG_VERSION"), " (cce desktop environment)");
 25 const DEFAULT_TILE_URL: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
 26 
 27 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 28 pub struct TileKey {
 29     pub z: u8,
 30     pub x: u32,
 31     pub y: u32,
 32 }
 33 
 34 enum TileState {
 35     Pending,
 36     Ready { image: u32, last_used: u64 },
 37     Failed,
 38 }
 39 
 40 pub struct TileManager {
 41     states: HashMap<TileKey, TileState>,
 42     queue: mpsc::Sender<(u64, TileKey)>,
 43     /// Frame counter used as the LRU clock; bumped by the app each rebuild.
 44     frame: u64,
 45     /// Bumped by [`TileManager::reset`]. A fetch carries the generation it
 46     /// was queued under, so a tile uploaded to a renderer that has since been
 47     /// replaced is freed on arrival instead of drawn as a dead id.
 48     generation: u64,
 49 }
 50 
 51 impl TileManager {
 52     pub fn new(notify: calloop::channel::Sender<Message>) -> Self {
 53         let (queue, rx) = mpsc::channel::<(u64, TileKey)>();
 54         let rx = Arc::new(Mutex::new(rx));
 55         let url_template = std::env::var("CCE_MAP_TILE_URL").unwrap_or_else(|_| DEFAULT_TILE_URL.to_string());
 56         let cache_root = cache_root();
 57         for _ in 0..FETCH_THREADS {
 58             let rx = Arc::clone(&rx);
 59             let notify = notify.clone();
 60             let url_template = url_template.clone();
 61             let cache_root = cache_root.clone();
 62             std::thread::spawn(move || worker(rx, notify, url_template, cache_root));
 63         }
 64         Self { states: HashMap::new(), queue, frame: 0, generation: 0 }
 65     }
 66 
 67     pub fn begin_frame(&mut self) {
 68         self.frame += 1;
 69     }
 70 
 71     /// The tile's GPU image if resident (marks it used); otherwise queues a
 72     /// fetch (once) and returns None.
 73     pub fn ensure(&mut self, key: TileKey) -> Option<u32> {
 74         match self.states.get_mut(&key) {
 75             Some(TileState::Ready { image, last_used }) => {
 76                 *last_used = self.frame;
 77                 Some(*image)
 78             }
 79             Some(_) => None,
 80             None => {
 81                 self.states.insert(key, TileState::Pending);
 82                 let _ = self.queue.send((self.generation, key));
 83                 None
 84             }
 85         }
 86     }
 87 
 88     /// Like `ensure` but never queues a fetch — used for ancestor fallback.
 89     pub fn ready(&mut self, key: TileKey) -> Option<u32> {
 90         match self.states.get_mut(&key) {
 91             Some(TileState::Ready { image, last_used }) => {
 92                 *last_used = self.frame;
 93                 Some(*image)
 94             }
 95             _ => None,
 96         }
 97     }
 98 
 99     pub fn complete(&mut self, generation: u64, key: TileKey, image: Option<u32>) {
100         if generation != self.generation {
101             // Uploaded to a renderer that is gone (see `reset`): the id names
102             // nothing, so free it rather than cache it as a tile that would
103             // draw blank for as long as it stayed resident.
104             if let Some(image) = image {
105                 cce_ui::vk::free_image(image);
106             }
107             return;
108         }
109         let state = match image {
110             Some(image) => TileState::Ready { image, last_used: self.frame },
111             None => TileState::Failed,
112         };
113         self.states.insert(key, state);
114         self.evict();
115     }
116 
117     /// Throw every resident tile away and re-fetch on demand.
118     ///
119     /// For one caller: the renderer has been replaced. GPU tile ids belong to
120     /// a **renderer**, and a renderer does not outlive its session —
121     /// `cce-ui`'s `window_runner` repairs a lost Wayland transport by opening
122     /// a new session around the same `Application`, which rebuilds the
123     /// renderer and with it the image table. A draw for an unknown id is
124     /// skipped rather than reported, so a reconnected map came back as bare
125     /// background with its markers and scale bar floating on it, and stayed
126     /// that way: a resident tile is never re-fetched.
127     ///
128     /// Re-fetching is cheap — every tile that was resident is already on disk
129     /// under `~/.cache/cce/map/tiles`, so this is a decode and an upload, not
130     /// a network round trip. `Failed` entries go too, which is the one
131     /// behavior change: a tile that failed before the reconnect gets one more
132     /// try.
133     pub fn reset(&mut self) {
134         for (_, state) in self.states.drain() {
135             if let TileState::Ready { image, .. } = state {
136                 cce_ui::vk::free_image(image);
137             }
138         }
139         self.generation += 1;
140     }
141 
142     /// Free the least-recently-used GPU tiles once over budget. Tiles
143     /// touched this frame are never evicted.
144     fn evict(&mut self) {
145         let resident = self.states.values().filter(|s| matches!(s, TileState::Ready { .. })).count();
146         if resident <= MAX_GPU_TILES {
147             return;
148         }
149         let mut ready: Vec<(TileKey, u64)> = self
150             .states
151             .iter()
152             .filter_map(|(k, s)| match s {
153                 TileState::Ready { last_used, .. } if *last_used < self.frame => Some((*k, *last_used)),
154                 _ => None,
155             })
156             .collect();
157         ready.sort_by_key(|&(_, used)| used);
158         let excess = resident - MAX_GPU_TILES;
159         for (key, _) in ready.into_iter().take(excess) {
160             if let Some(TileState::Ready { image, .. }) = self.states.remove(&key) {
161                 cce_ui::vk::free_image(image);
162             }
163         }
164     }
165 }
166 
167 fn cache_root() -> PathBuf {
168     let base = match std::env::var("XDG_CACHE_HOME") {
169         Ok(x) if !x.is_empty() => PathBuf::from(x),
170         _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache"),
171     };
172     base.join("cce").join("map").join("tiles")
173 }
174 
175 fn worker(
176     rx: Arc<Mutex<mpsc::Receiver<(u64, TileKey)>>>,
177     notify: calloop::channel::Sender<Message>,
178     url_template: String,
179     cache_root: PathBuf,
180 ) {
181     let client = reqwest::blocking::Client::builder()
182         .user_agent(USER_AGENT)
183         .timeout(std::time::Duration::from_secs(15))
184         .build()
185         .expect("http client");
186     loop {
187         let (generation, key) = match rx.lock().unwrap().recv() {
188             Ok(k) => k,
189             Err(_) => return,
190         };
191         let image = fetch_tile(&client, &url_template, &cache_root, key)
192             .map_err(|e| log::warn!("tile {}/{}/{}: {e}", key.z, key.x, key.y))
193             .ok();
194         if notify.send(Message::Tile { generation, key, image }).is_err() {
195             return;
196         }
197     }
198 }
199 
200 fn fetch_tile(
201     client: &reqwest::blocking::Client,
202     url_template: &str,
203     cache_root: &PathBuf,
204     key: TileKey,
205 ) -> Result<u32, String> {
206     let path = cache_root.join(key.z.to_string()).join(key.x.to_string()).join(format!("{}.png", key.y));
207     let bytes = match std::fs::read(&path) {
208         Ok(b) => b,
209         Err(_) => {
210             let url = url_template
211                 .replace("{z}", &key.z.to_string())
212                 .replace("{x}", &key.x.to_string())
213                 .replace("{y}", &key.y.to_string());
214             let resp = client.get(&url).send().map_err(|e| e.to_string())?;
215             if !resp.status().is_success() {
216                 return Err(format!("HTTP {}", resp.status()));
217             }
218             let bytes = resp.bytes().map_err(|e| e.to_string())?.to_vec();
219             if let Some(dir) = path.parent() {
220                 let _ = std::fs::create_dir_all(dir);
221             }
222             let _ = std::fs::write(&path, &bytes);
223             bytes
224         }
225     };
226     let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
227     let rgba = img.to_rgba8();
228     let (w, h) = rgba.dimensions();
229     Ok(cce_ui::vk::upload_rgba(rgba.into_raw(), w, h))
230 }