Skip to main content

brep_app/plm/
thumbnail_view.rs

1//! Authenticated, versioned previews shared by the CAD catalog and workspace.
2//! A browser owns its cache, so changing sessions drops pictures and requests.
3use super::{client::PlmClient, PlmFuture};
4use eframe::egui;
5use std::{collections::HashMap, io::Cursor, rc::Rc, task::{Context, Poll, Waker}};
6
7const MAX_ENTRIES: usize = 128;
8const MAX_PENDING: usize = 4;
9const MAX_BYTES: usize = 1024 * 1024;
10pub const SIDE: f32 = 32.0;
11
12/// Only the server's versioned thumbnail routes are accepted, never an arbitrary
13/// URL from catalog data. Credentials remain on this client's server.
14fn version(url: &str) -> Option<&str> {
15    let (path, hash) = url.split_once("?v=")?;
16    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
17        return None;
18    }
19    let segments: Vec<_> = path.split('/').collect();
20    let id = |s: &str| !s.is_empty() && super::identity::unsegment(s).is_some_and(|raw| super::identity::segment(&raw) == s);
21    match segments.as_slice() {
22        ["", "api", "parts", part, "thumbnail"] if id(part) => Some(hash),
23        ["", "api", "parts", part, "revisions", revision, "thumbnail"] if id(part) && id(revision) => Some(hash),
24        _ => None,
25    }
26}
27
28
29pub fn fetch(client: Rc<PlmClient>, url: String) -> PlmFuture<Option<Vec<u8>>> {
30    Box::pin(async move {
31        let expected = version(&url).ok_or("invalid thumbnail URL")?;
32        let response = client.call("GET", &url, None).await.map_err(|e| e.to_string())?;
33        if response.status == 204 {
34            return Ok(None);
35        }
36        // A document can change between listing and GET. The route then serves
37        // its NEW picture: never keep those bytes under the old version's key.
38        let etag = response.headers.iter().find(|(name, _)| name.eq_ignore_ascii_case("etag"));
39        if etag.map(|(_, value)| value.trim_matches('"')) != Some(expected) {
40            return Ok(None);
41        }
42        if response.body.len() > MAX_BYTES {
43            return Err("thumbnail exceeds the size limit".into());
44        }
45        Ok(Some(response.body))
46    })
47}
48
49pub(crate) fn decode(bytes: &[u8]) -> Option<egui::ColorImage> {
50    if bytes.len() > MAX_BYTES {
51        return None;
52    }
53    let mut reader = image::ImageReader::with_format(Cursor::new(bytes), image::ImageFormat::Png);
54    let mut limits = image::Limits::default();
55    limits.max_image_width = Some(1024);
56    limits.max_image_height = Some(1024);
57    limits.max_alloc = Some(16 * 1024 * 1024);
58    reader.limits(limits);
59    let image = reader.decode().ok()?.thumbnail(64, 64).to_rgba8();
60    Some(egui::ColorImage::from_rgba_unmultiplied(
61        [image.width() as usize, image.height() as usize], image.as_raw(),
62    ))
63}
64
65struct Entry {
66    pending: Option<PlmFuture<Option<Vec<u8>>>>,
67    image: Option<egui::ColorImage>,
68    texture: Option<egui::TextureHandle>,
69    used: u64,
70}
71
72#[derive(Default)]
73pub struct ThumbnailCache {
74    entries: HashMap<String, Entry>,
75    clock: u64,
76}
77
78impl ThumbnailCache {
79    pub fn clear(&mut self) {
80        self.entries.clear();
81    }
82
83    pub fn busy(&self) -> bool {
84        self.entries.values().any(|e| e.pending.is_some())
85    }
86
87    pub fn ready(&self, url: &str) -> bool {
88        self.entries.get(url).is_some_and(|e| e.image.is_some() || e.texture.is_some())
89    }
90
91    /// Poll even when the panel is hidden; textures are allocated only on draw.
92    pub fn poll(&mut self, waker: &Waker) {
93        for entry in self.entries.values_mut() {
94            if let Some(future) = &mut entry.pending {
95                if let Poll::Ready(answer) = future.as_mut().poll(&mut Context::from_waker(waker)) {
96                    entry.pending = None;
97                    entry.image = answer.ok().flatten().and_then(|bytes| decode(&bytes));
98                }
99            }
100        }
101    }
102
103    fn request(&mut self, url: &str, fetch: impl FnOnce(&str) -> PlmFuture<Option<Vec<u8>>>) {
104        self.clock = self.clock.wrapping_add(1);
105        if let Some(entry) = self.entries.get_mut(url) {
106            entry.used = self.clock;
107            return;
108        }
109        if version(url).is_none() || self.entries.values().filter(|e| e.pending.is_some()).count() >= MAX_PENDING {
110            return;
111        }
112        if self.entries.len() >= MAX_ENTRIES {
113            let oldest = self.entries.iter().filter(|(_, e)| e.pending.is_none())
114                .min_by_key(|(_, e)| e.used).map(|(url, _)| url.clone());
115            let Some(oldest) = oldest else { return };
116            self.entries.remove(&oldest);
117        }
118        self.entries.insert(url.to_string(), Entry {
119            pending: Some(fetch(url)), image: None, texture: None, used: self.clock,
120        });
121    }
122
123    /// Reserve the same space for every row. Offscreen rows make no requests.
124    pub fn show(&mut self, ui: &mut egui::Ui, url: &str, fetch: impl FnOnce(&str) -> PlmFuture<Option<Vec<u8>>>) -> egui::Response {
125        let (rect, response) = ui.allocate_exact_size(egui::Vec2::splat(SIDE), egui::Sense::click());
126        if !ui.is_rect_visible(rect) {
127            return response;
128        }
129        if !url.is_empty() {
130            self.request(url, fetch);
131        }
132        if let Some(entry) = self.entries.get_mut(url) {
133            if let Some(image) = entry.image.take() {
134                entry.texture = Some(ui.ctx().load_texture(format!("plm-thumbnail:{url}"), image, egui::TextureOptions::LINEAR));
135            }
136            if let Some(texture) = &entry.texture {
137                let size = texture.size_vec2();
138                let size = size * (SIDE / size.x.max(size.y));
139                ui.painter().image(texture.id(), egui::Rect::from_center_size(rect.center(), size),
140                    egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
141                return response;
142            }
143        }
144        ui.painter().rect_filled(rect.shrink(2.0), 3.0, ui.visuals().faint_bg_color);
145        ui.painter().rect_stroke(rect.shrink(2.0), 3.0, ui.visuals().widgets.noninteractive.bg_stroke, egui::StrokeKind::Inside);
146        if self.busy() {
147            ui.ctx().request_repaint();
148        }
149        response
150    }
151}