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 }