1use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28pub struct UserTexture {
30 pub name: String,
34 pub texture: wgpu::Texture,
35 pub view: wgpu::TextureView,
36 pub width: u32,
37 pub height: u32,
38}
39
40impl UserTexture {
41 pub fn texsize(&self) -> [f32; 4] {
45 let w = self.width as f32;
46 let h = self.height as f32;
47 [w, h, 1.0 / w, 1.0 / h]
48 }
49}
50
51pub struct TexturePool {
54 textures: Vec<Arc<UserTexture>>,
58 by_name: HashMap<String, usize>,
60 fallback: Arc<UserTexture>,
64}
65
66impl TexturePool {
67 pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
70 let fallback = Arc::new(make_fallback_texture(device, queue));
71 Self {
72 textures: Vec::new(),
73 by_name: HashMap::new(),
74 fallback,
75 }
76 }
77
78 pub fn from_dirs(device: &wgpu::Device, queue: &wgpu::Queue, dirs: &[PathBuf]) -> Self {
85 let mut pool = Self::new(device, queue);
86 for dir in dirs {
87 pool.scan_dir(device, queue, dir);
88 }
89 pool
90 }
91
92 pub fn scan_dir(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, dir: &Path) {
95 let entries = match std::fs::read_dir(dir) {
96 Ok(e) => e,
97 Err(err) => {
98 log::debug!("texture dir {} not readable: {}", dir.display(), err);
99 return;
100 }
101 };
102 for entry in entries.flatten() {
103 let path = entry.path();
104 if !path.is_file() {
105 continue;
106 }
107 let Some(name) = canonical_stem(&path) else {
108 continue;
109 };
110 if self.by_name.contains_key(&name) {
111 continue;
112 }
113 match load_texture_file(device, queue, &path, &name) {
114 Ok(tex) => {
115 let idx = self.textures.len();
116 self.textures.push(Arc::new(tex));
117 self.by_name.insert(name, idx);
118 }
119 Err(e) => {
120 log::warn!("failed to load texture {}: {}", path.display(), e);
121 }
122 }
123 }
124 }
125
126 pub fn get(&self, name: &str) -> Option<&Arc<UserTexture>> {
130 let canon = name.to_ascii_lowercase();
131 self.by_name.get(&canon).map(|&i| &self.textures[i])
132 }
133
134 pub fn fallback(&self) -> &Arc<UserTexture> {
138 &self.fallback
139 }
140
141 pub fn is_empty(&self) -> bool {
145 self.textures.is_empty()
146 }
147
148 pub fn len(&self) -> usize {
150 self.textures.len()
151 }
152
153 pub fn pick_rand(&self, seed: u64, prefix: Option<&str>) -> Option<&Arc<UserTexture>> {
161 let pool_subset: Vec<usize> = match prefix {
162 Some(p) => {
163 let p = p.to_ascii_lowercase();
164 self.textures
165 .iter()
166 .enumerate()
167 .filter(|(_, t)| t.name.starts_with(&p))
168 .map(|(i, _)| i)
169 .collect()
170 }
171 None => (0..self.textures.len()).collect(),
172 };
173 if pool_subset.is_empty() {
174 return None;
175 }
176 let idx = pool_subset[(seed as usize) % pool_subset.len()];
177 Some(&self.textures[idx])
178 }
179}
180
181pub fn default_texture_dirs() -> Vec<PathBuf> {
187 let mut out = Vec::new();
188 if let Some(home) = std::env::var_os("HOME") {
189 let home = PathBuf::from(home);
190 out.push(home.join(".config/onedrop/textures"));
191 out.push(home.join("Music/milkdrop/textures"));
194 }
195 if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
196 out.push(PathBuf::from(xdg).join("onedrop/textures"));
197 }
198 out
199}
200
201fn canonical_stem(path: &Path) -> Option<String> {
202 let stem = path.file_stem()?.to_str()?.to_ascii_lowercase();
203 if stem.is_empty() {
204 return None;
205 }
206 Some(stem)
207}
208
209fn load_texture_file(
210 device: &wgpu::Device,
211 queue: &wgpu::Queue,
212 path: &Path,
213 name: &str,
214) -> Result<UserTexture, image::ImageError> {
215 let img = image::open(path)?;
216 let rgba = img.to_rgba8();
217 let (width, height) = rgba.dimensions();
218 Ok(upload_rgba8(device, queue, name, width, height, &rgba))
219}
220
221fn upload_rgba8(
222 device: &wgpu::Device,
223 queue: &wgpu::Queue,
224 name: &str,
225 width: u32,
226 height: u32,
227 bytes: &[u8],
228) -> UserTexture {
229 let texture = device.create_texture(&wgpu::TextureDescriptor {
230 label: Some(&format!("UserTexture {name}")),
231 size: wgpu::Extent3d {
232 width,
233 height,
234 depth_or_array_layers: 1,
235 },
236 mip_level_count: 1,
237 sample_count: 1,
238 dimension: wgpu::TextureDimension::D2,
239 format: wgpu::TextureFormat::Rgba8Unorm,
240 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
241 view_formats: &[],
242 });
243 queue.write_texture(
244 wgpu::TexelCopyTextureInfo {
245 texture: &texture,
246 mip_level: 0,
247 origin: wgpu::Origin3d::ZERO,
248 aspect: wgpu::TextureAspect::All,
249 },
250 bytes,
251 wgpu::TexelCopyBufferLayout {
252 offset: 0,
253 bytes_per_row: Some(width * 4),
254 rows_per_image: Some(height),
255 },
256 wgpu::Extent3d {
257 width,
258 height,
259 depth_or_array_layers: 1,
260 },
261 );
262 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
263 UserTexture {
264 name: name.to_string(),
265 texture,
266 view,
267 width,
268 height,
269 }
270}
271
272fn make_fallback_texture(device: &wgpu::Device, queue: &wgpu::Queue) -> UserTexture {
273 upload_rgba8(
277 device,
278 queue,
279 "__fallback_white",
280 1,
281 1,
282 &[255, 255, 255, 255],
283 )
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::config::RenderConfig;
290 use crate::gpu_context::GpuContext;
291
292 fn write_solid_png(path: &Path, color: [u8; 4]) {
295 let pixels: Vec<u8> = (0..16).flat_map(|_| color).collect();
296 let img =
297 image::RgbaImage::from_raw(4, 4, pixels).expect("RgbaImage::from_raw must succeed");
298 img.save_with_format(path, image::ImageFormat::Png).unwrap();
299 }
300
301 #[test]
302 fn empty_pool_has_fallback() {
303 let cfg = RenderConfig::default();
304 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
305 let pool = TexturePool::new(&gpu.device, &gpu.queue);
306 assert!(pool.is_empty());
307 assert_eq!(pool.len(), 0);
308 assert!(pool.get("anything").is_none());
309 assert_eq!(pool.fallback().width, 1);
311 assert_eq!(pool.fallback().height, 1);
312 }
313
314 #[test]
315 fn from_dirs_loads_pngs() {
316 let cfg = RenderConfig::default();
317 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
318 let dir = tempfile::tempdir().unwrap();
319 write_solid_png(&dir.path().join("Clouds.png"), [10, 20, 30, 255]);
320 write_solid_png(&dir.path().join("worms.png"), [40, 50, 60, 255]);
321
322 let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
323 assert_eq!(pool.len(), 2);
324 assert!(pool.get("clouds").is_some());
326 assert!(pool.get("Clouds").is_some());
327 assert!(pool.get("worms").is_some());
328 assert!(pool.get("missing").is_none());
329 }
330
331 #[test]
332 fn missing_dir_is_skipped() {
333 let cfg = RenderConfig::default();
334 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
335 let pool = TexturePool::from_dirs(
336 &gpu.device,
337 &gpu.queue,
338 &[PathBuf::from("/definitely/does/not/exist/onedrop-test")],
339 );
340 assert!(pool.is_empty());
342 }
343
344 #[test]
345 fn name_collision_first_wins() {
346 let cfg = RenderConfig::default();
347 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
348 let dir_a = tempfile::tempdir().unwrap();
349 let dir_b = tempfile::tempdir().unwrap();
350 write_solid_png(&dir_a.path().join("clouds.png"), [10, 20, 30, 255]);
351 write_solid_png(&dir_b.path().join("clouds.png"), [200, 210, 220, 255]);
352
353 let pool = TexturePool::from_dirs(
354 &gpu.device,
355 &gpu.queue,
356 &[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()],
357 );
358 assert_eq!(pool.len(), 1);
360 assert!(pool.get("clouds").is_some());
361 }
362
363 #[test]
364 fn pick_rand_is_deterministic() {
365 let cfg = RenderConfig::default();
366 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
367 let dir = tempfile::tempdir().unwrap();
368 for n in ["alpha", "beta", "gamma"] {
369 write_solid_png(&dir.path().join(format!("{n}.png")), [0, 0, 0, 255]);
370 }
371 let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
372 let a = pool.pick_rand(42, None).map(|t| t.name.clone());
374 let b = pool.pick_rand(42, None).map(|t| t.name.clone());
375 assert_eq!(a, b);
376 assert!(a.is_some());
377 }
378
379 #[test]
380 fn pick_rand_with_prefix_filters() {
381 let cfg = RenderConfig::default();
382 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
383 let dir = tempfile::tempdir().unwrap();
384 write_solid_png(&dir.path().join("smalltiled_a.png"), [0; 4]);
385 write_solid_png(&dir.path().join("smalltiled_b.png"), [0; 4]);
386 write_solid_png(&dir.path().join("unrelated.png"), [0; 4]);
387 let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
388
389 for seed in 0..16 {
392 let pick = pool.pick_rand(seed, Some("smalltiled_")).unwrap();
393 assert!(
394 pick.name.starts_with("smalltiled_"),
395 "seed {seed} picked {}",
396 pick.name
397 );
398 }
399 assert!(pool.pick_rand(0, Some("nothing_matches_")).is_none());
401 }
402
403 #[test]
404 fn pick_rand_on_empty_pool_is_none() {
405 let cfg = RenderConfig::default();
406 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
407 let pool = TexturePool::new(&gpu.device, &gpu.queue);
408 assert!(pool.pick_rand(0, None).is_none());
409 }
410
411 #[test]
412 fn texsize_matches_dimensions() {
413 let cfg = RenderConfig::default();
414 let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
415 let dir = tempfile::tempdir().unwrap();
416 write_solid_png(&dir.path().join("a.png"), [0; 4]);
417 let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
418 let t = pool.get("a").unwrap();
419 let v = t.texsize();
420 assert_eq!(v[0], 4.0);
421 assert_eq!(v[1], 4.0);
422 assert!((v[2] - 0.25).abs() < 1e-6);
423 assert!((v[3] - 0.25).abs() < 1e-6);
424 }
425}