Skip to main content

brep_app/plm/
thumbnail.rs

1//! Thumbnails of PLM revisions (P9): the picture a save makes and uploads.
2//!
3//! The server never renders ([`brep_plm::thumbnail`]); the CAD app does, with
4//! the CPU rasterizer every host shares ([`brep_render::thumbnail`]). The
5//! parts of it here:
6//!
7//! - [`stage`], at the save site: the scene is copied out of the engine on the
8//!   UI thread (well under a millisecond), and the raster runs off it. Natively
9//!   on a thread of its own; in the browser, which has no threads, when the
10//!   upload first asks for the picture, which is after the server has accepted
11//!   the document, not in the frame that saved. A save made while a rebuild is
12//!   still running waits for that run to land ([`tick`]), so the picture is of
13//!   the model that was saved, not the one before.
14//! - [`upload_on_save`], a [`SavedHook`] the PLM backend runs after each
15//!   accepted document write: when a picture was staged for exactly those
16//!   bytes (their SHA-256 is the server's `content_hash`), it is uploaded in a
17//!   task of its own, so the key's next write never waits for it. It never
18//!   fails the save: an upload the server refuses is counted and said in
19//!   [`stats`], and the next save tries again.
20//! - [`chain`] runs it after S5's uses publisher, which keeps its own error.
21//! - The RECENT-FILE pictures (`stage_recent`, [`persist_recent`],
22//!   [`recent_textures`]): the File menu's recent list shows a small square of
23//!   every document saved through ANY store (browser, native file, PLM), made
24//!   by the same rasterizer and the same staging (a save mid-rebuild waits the
25//!   same way) and kept in the local store under
26//!   [`RECENT_THUMBNAILS_KEY`](crate::store::RECENT_THUMBNAILS_KEY) — one
27//!   JSON map, name → base64 PNG, pruned to the recent list. The PLM upload
28//!   path and its [`stats`] are untouched by it: a PLM save stages two
29//!   pictures, the server's and the menu's, and counts the server's.
30
31use std::cell::RefCell;
32use std::collections::BTreeMap;
33use std::future::Future;
34use std::pin::Pin;
35use std::rc::Rc;
36use std::sync::{Arc, Mutex};
37use std::task::{Context, Poll, Waker};
38
39use brep_render::engine_state::EngineState;
40use brep_render::thumbnail::{self as raster, Capture};
41
42use super::backend::SavedHook;
43use super::client::PlmClient;
44
45/// SHA-256 of a document's bytes, lowercase hex: the server's `content_hash`.
46pub fn content_hash(document: &str) -> String {
47    use sha2::{Digest, Sha256};
48    Sha256::digest(document.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
49}
50
51/// What happened to the pictures this session made, for scripts and tests.
52#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
53pub struct Stats {
54    /// Pictures staged at a save.
55    pub staged: u32,
56    /// Uploaded and accepted.
57    pub uploaded: u32,
58    /// Nothing to picture (an empty scene) or superseded before it was sent.
59    pub skipped: u32,
60    /// Refused or unreachable.
61    pub failed: u32,
62    /// The last refusal, as the server said it.
63    pub last_error: String,
64    /// The last raster's cost, in milliseconds.
65    pub last_render_ms: f64,
66}
67
68thread_local! {
69    static STAGED: RefCell<BTreeMap<String, Staged>> = RefCell::new(BTreeMap::new());
70    static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
71    static STATS: RefCell<Stats> = RefCell::new(Stats::default());
72}
73
74/// This session's counts.
75pub fn stats() -> Stats {
76    STATS.with(|s| s.borrow().clone())
77}
78
79fn count(f: impl FnOnce(&mut Stats)) {
80    STATS.with(|s| f(&mut s.borrow_mut()));
81}
82
83struct Staged {
84    hash: String,
85    picture: Picture,
86}
87
88/// A save made while its document was still rebuilding: pictured once the
89/// run that was pending lands.
90struct Waiting {
91    doc: u64,
92    after: u64,
93    picture: Picture,
94}
95
96/// A picture on its way: filled by a raster thread (native), or rastered on
97/// first poll from the capture it holds (the browser).
98#[derive(Clone, Default)]
99pub struct Picture(Arc<Shared>);
100
101struct Shared {
102    /// The picture's edge, in pixels.
103    size: u32,
104    capture: Mutex<Option<Capture>>,
105    result: Mutex<Option<Option<Vec<u8>>>>,
106    waker: Mutex<Option<Waker>>,
107}
108
109impl Default for Shared {
110    fn default() -> Self {
111        Self { size: raster::SIZE, capture: Mutex::new(None), result: Mutex::new(None), waker: Mutex::new(None) }
112    }
113}
114
115impl Picture {
116    /// A picture `size` pixels on edge (the default is the server's [`raster::SIZE`]).
117    fn sized(size: u32) -> Self {
118        Self(Arc::new(Shared { size, ..Shared::default() }))
119    }
120
121    /// Done without waiting: `Some(None)` is a picture of nothing (an empty
122    /// scene). In the browser the first look IS the raster, on this thread.
123    fn peek(&self) -> Option<Option<Vec<u8>>> {
124        let capture = self.0.capture.lock().unwrap_or_else(|p| p.into_inner()).take();
125        if let Some(capture) = capture {
126            return Some(render(&capture, self.0.size));
127        }
128        self.0.result.lock().unwrap_or_else(|p| p.into_inner()).take()
129    }
130
131    /// Raster `capture`: on a thread natively, later on this one in the browser.
132    fn start(&self, capture: Capture) {
133        #[cfg(not(target_arch = "wasm32"))]
134        {
135            let picture = self.clone();
136            let size = self.0.size;
137            std::thread::spawn(move || picture.fill(render(&capture, size)));
138        }
139        #[cfg(target_arch = "wasm32")]
140        {
141            *self.0.capture.lock().unwrap_or_else(|p| p.into_inner()) = Some(capture);
142            if let Some(waker) = self.0.waker.lock().unwrap_or_else(|p| p.into_inner()).take() {
143                waker.wake();
144            }
145        }
146    }
147
148    fn fill(&self, png: Option<Vec<u8>>) {
149        *self.0.result.lock().unwrap_or_else(|p| p.into_inner()) = Some(png);
150        if let Some(waker) = self.0.waker.lock().unwrap_or_else(|p| p.into_inner()).take() {
151            waker.wake();
152        }
153    }
154}
155
156impl Future for Picture {
157    type Output = Option<Vec<u8>>;
158
159    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
160        let capture = self.0.capture.lock().unwrap_or_else(|p| p.into_inner()).take();
161        if let Some(capture) = capture {
162            return Poll::Ready(render(&capture, self.0.size));
163        }
164        *self.0.waker.lock().unwrap_or_else(|p| p.into_inner()) = Some(cx.waker().clone());
165        match self.0.result.lock().unwrap_or_else(|p| p.into_inner()).take() {
166            Some(png) => Poll::Ready(png),
167            None => Poll::Pending,
168        }
169    }
170}
171
172/// The raster, timed. `None` for an empty scene.
173fn render(capture: &Capture, size: u32) -> Option<Vec<u8>> {
174    let started = crate::recovery::wall_clock();
175    let png = raster::render_png(capture, size);
176    let ms = (crate::recovery::wall_clock() - started) * 1000.0;
177    // The timing is the SERVER picture's ([`stats`]); a recent-list raster
178    // (`RECENT_SIZE`) never overwrites it.
179    if size == raster::SIZE {
180        // A raster thread has its own thread-locals; the count that matters is
181        // the UI thread's, so a thread's figure rides back with the picture.
182        #[cfg(target_arch = "wasm32")]
183        count(|s| s.last_render_ms = ms);
184        #[cfg(not(target_arch = "wasm32"))]
185        LAST_RENDER_MS.store(ms.to_bits(), std::sync::atomic::Ordering::Relaxed);
186    }
187    png
188}
189
190#[cfg(not(target_arch = "wasm32"))]
191static LAST_RENDER_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
192
193/// Stage the picture of `engine`'s model for the save of `document` under
194/// the PLM store key `key` (`part/<p>/rev/<r>`). `doc` is the document's tab
195/// id, for a save made mid-rebuild ([`tick`]).
196pub fn stage(key: &str, document: &str, engine: &EngineState, doc: u64) {
197    let picture = picture_of(engine, doc, raster::SIZE);
198    count(|s| s.staged += 1);
199    STAGED.with(|s| s.borrow_mut().insert(key.to_string(), Staged { hash: content_hash(document), picture }));
200}
201
202/// A picture of `engine`'s model, `size` on edge: started now, or once the
203/// run that is pending lands ([`tick`]).
204fn picture_of(engine: &EngineState, doc: u64, size: u32) -> Picture {
205    let picture = Picture::sized(size);
206    if engine.run_pending() {
207        WAITING.with(|w| w.borrow_mut().push(Waiting { doc, after: engine.applied_generation(), picture: picture.clone() }));
208    } else {
209        picture.start(raster::capture(&engine.scene));
210    }
211    picture
212}
213
214/// The pictures of a save through the store. Every store's save gets the
215/// File menu's recent-list picture (`stage_recent`); a PLM store's save of a
216/// revision's document (`name` in either spelling) also gets the server's
217/// ([`stage`]). The file stores upload nothing.
218pub fn stage_save(store: &dyn crate::store::ModelStore, name: &str, document: &str, engine: &EngineState, doc: u64) {
219    stage_recent(name, engine, doc);
220    if store.plm_client().is_none() {
221        return;
222    }
223    if let Some(key) = crate::plm::uses::revision_key_in(name) {
224        stage(&key, document, engine, doc);
225    }
226}
227
228/// Picture the saves made mid-rebuild whose run has landed; called once per
229/// frame. `engine_of` finds a tab's engine by id (`None`: the tab closed,
230/// and its picture is dropped).
231pub fn tick<'a>(engine_of: impl Fn(u64) -> Option<&'a EngineState>) {
232    if WAITING.with(|w| w.borrow().is_empty()) {
233        return;
234    }
235    WAITING.with(|w| {
236        w.borrow_mut().retain(|waiting| match engine_of(waiting.doc) {
237            None => {
238                waiting.picture.fill(None);
239                false
240            }
241            Some(engine) if engine.run_pending() || engine.applied_generation() == waiting.after => true,
242            Some(engine) => {
243                waiting.picture.start(raster::capture(&engine.scene));
244                false
245            }
246        })
247    });
248}
249
250/// The picture staged for exactly these bytes under `key`, taken. One staged
251/// for other bytes (a later save) is left for that save's own hook.
252fn take(key: &str, document: &str) -> Option<Picture> {
253    let hash = content_hash(document);
254    STAGED.with(|s| {
255        let mut staged = s.borrow_mut();
256        match staged.get(key) {
257            Some(entry) if entry.hash == hash => staged.remove(key).map(|e| e.picture),
258            _ => None,
259        }
260    })
261}
262
263/// The hook: after an accepted document write, upload the picture staged for
264/// it, in a task of its own. Always `Ok`: a thumbnail never fails a save.
265pub(crate) fn upload_on_save() -> SavedHook {
266    Rc::new(|client, key, value| {
267        // One spelling for the staged picture and the upload's route.
268        let key = crate::plm::uses::revision_key_in(&key).unwrap_or(key);
269        if let Some(picture) = take(&key, &value) {
270            let hash = content_hash(&value);
271            detach(async move { upload(&client, &key, &hash, picture).await });
272        }
273        Box::pin(async { Ok(()) })
274    })
275}
276
277/// Run `first`, then `then` whatever `first` answered; the answer is
278/// `first`'s. The thumbnail upload follows S5's uses publisher this way.
279pub(crate) fn chain(first: SavedHook, then: SavedHook) -> SavedHook {
280    Rc::new(move |client: Rc<PlmClient>, key: String, value: String| {
281        let before = first(client.clone(), key.clone(), value.clone());
282        let then = then.clone();
283        Box::pin(async move {
284            let answer = before.await;
285            let _ = then(client, key, value).await;
286            answer
287        })
288    })
289}
290
291/// `PUT /api/parts/<p>/revisions/<r>/thumbnail` with the picture.
292async fn upload(client: &PlmClient, key: &str, hash: &str, picture: Picture) {
293    let Some(png) = picture.await else {
294        count(|s| s.skipped += 1);
295        return;
296    };
297    #[cfg(not(target_arch = "wasm32"))]
298    count(|s| s.last_render_ms = f64::from_bits(LAST_RENDER_MS.load(std::sync::atomic::Ordering::Relaxed)));
299    match put(client, key, hash, png).await {
300        Ok(()) => count(|s| s.uploaded += 1),
301        Err(why) => {
302            log::warn!("thumbnail of {key} not uploaded: {why}");
303            count(|s| {
304                s.failed += 1;
305                s.last_error = why;
306            });
307        }
308    }
309}
310
311/// Upload `png` as the thumbnail of the revision `key`, picturing the
312/// document whose hash is `hash`. The bake worker calls this directly.
313pub async fn put(client: &PlmClient, key: &str, hash: &str, png: Vec<u8>) -> Result<(), String> {
314    let (part, revision) = revision_of(key)?;
315    let path = format!("{}/thumbnail?content_hash={hash}&renderer={}", super::identity::revision_path(&part, &revision), raster::RENDERER);
316    client.call_typed("PUT", &path, png, "image/png").await.map(|_| ()).map_err(|e| e.to_string())
317}
318
319fn revision_of(key: &str) -> Result<(String, String), String> {
320    super::family::key_ids(key).ok_or_else(|| format!("'{key}' is not a revision key"))
321}
322
323fn detach(task: impl Future<Output = ()> + 'static) {
324    #[cfg(target_arch = "wasm32")]
325    wasm_bindgen_futures::spawn_local(task);
326    #[cfg(not(target_arch = "wasm32"))]
327    super::native::spawn(task);
328}
329
330// ---------------------------------------------------------------------------
331// The File menu's recent-file pictures.
332// ---------------------------------------------------------------------------
333
334use crate::store::{ModelStore, RECENT_THUMBNAILS_KEY};
335use eframe::egui;
336
337/// A recent-file picture's edge, in pixels: small, because the browser store
338/// is bounded, and drawn at [`RECENT_SIDE`] points (two pixels per point on a
339/// HiDPI screen). A shaded 80-pixel PNG of a part is a few kilobytes; twenty
340/// of them, the recent list's cap, stay well under a hundred.
341pub const RECENT_SIZE: u32 = 80;
342/// The side of the square a recent row draws its picture in, in points.
343pub const RECENT_SIDE: f32 = 40.0;
344
345/// What happened to the recent-file pictures this session made, for tests.
346#[derive(Debug, Clone, Default, PartialEq)]
347pub struct RecentStats {
348    /// Pictures staged at a save.
349    pub staged: u32,
350    /// Written to the store.
351    pub stored: u32,
352    /// Nothing to picture, or the name was no longer a recent document.
353    pub skipped: u32,
354}
355
356/// A picture on its way to the recent-thumbnail map.
357struct Recent {
358    name: String,
359    picture: Picture,
360}
361
362thread_local! {
363    static RECENT: RefCell<Vec<Recent>> = const { RefCell::new(Vec::new()) };
364    static RECENT_STATS: RefCell<RecentStats> = RefCell::new(RecentStats::default());
365    static RECENT_TEXTURES: RefCell<Option<RecentTextures>> = const { RefCell::new(None) };
366}
367
368/// This session's recent-picture counts.
369pub fn recent_stats() -> RecentStats {
370    RECENT_STATS.with(|s| s.borrow().clone())
371}
372
373fn count_recent(f: impl FnOnce(&mut RecentStats)) {
374    RECENT_STATS.with(|s| f(&mut s.borrow_mut()));
375}
376
377/// Stage the recent-list picture of `engine`'s model for the save of `name`.
378/// The raster runs off the UI thread natively and in the frame after the
379/// save in the browser; [`persist_recent`] writes what it made.
380pub fn stage_recent(name: &str, engine: &EngineState, doc: u64) {
381    let picture = picture_of(engine, doc, RECENT_SIZE);
382    count_recent(|s| s.staged += 1);
383    RECENT.with(|r| r.borrow_mut().push(Recent { name: name.to_string(), picture }));
384}
385
386/// The recent-thumbnail map as stored: name → base64 PNG.
387fn recent_map(store: &dyn ModelStore) -> BTreeMap<String, String> {
388    store
389        .read(RECENT_THUMBNAILS_KEY)
390        .and_then(|text| serde_json::from_str(&text).ok())
391        .unwrap_or_default()
392}
393
394/// Write the recent-list pictures that finished since the last frame into
395/// the store, pruning the map to the names the recent list still holds.
396/// Called once per frame. Never an error: a picture that could not be
397/// rendered or written is dropped and the menu shows its placeholder.
398pub fn persist_recent(store: &dyn ModelStore) {
399    let done: Vec<(String, Option<Vec<u8>>)> = RECENT.with(|r| {
400        let mut pending = r.borrow_mut();
401        let mut done = Vec::new();
402        pending.retain(|entry| match entry.picture.peek() {
403            Some(png) => {
404                done.push((entry.name.clone(), png));
405                false
406            }
407            None => true,
408        });
409        done
410    });
411    if done.is_empty() {
412        return;
413    }
414    let recent = crate::panels::file::recent_documents(store);
415    let mut map = recent_map(store);
416    map.retain(|name, _| recent.contains(name));
417    for (name, png) in done {
418        match png {
419            Some(png) if recent.contains(&name) => {
420                map.insert(name, base64_encode(&png));
421                count_recent(|s| s.stored += 1);
422            }
423            Some(_) => count_recent(|s| s.skipped += 1),
424            None => {
425                // An empty scene: no picture, so the row shows its placeholder.
426                map.remove(&name);
427                count_recent(|s| s.skipped += 1);
428            }
429        }
430    }
431    let text = serde_json::to_string(&map).unwrap_or_else(|_| "{}".into());
432    if let Err(why) = store.write(RECENT_THUMBNAILS_KEY, &text) {
433        log::warn!("recent thumbnails not stored: {why}");
434    }
435}
436
437/// The recent-file pictures as GPU textures, decoded once per store
438/// mutation and reused while their bytes are unchanged.
439struct RecentTextures {
440    generation: u64,
441    textures: Rc<BTreeMap<String, (String, egui::TextureHandle)>>,
442}
443
444/// The recent-file pictures the store holds right now, name → texture, for
445/// the File menu to draw. A name with no entry has no picture.
446pub fn recent_textures(ctx: &egui::Context, store: &dyn ModelStore) -> Rc<BTreeMap<String, (String, egui::TextureHandle)>> {
447    let generation = store.mutation_generation();
448    RECENT_TEXTURES.with(|cache| {
449        let mut cache = cache.borrow_mut();
450        if let Some(cached) = cache.as_ref().filter(|c| c.generation == generation) {
451            return cached.textures.clone();
452        }
453        let previous = cache.take().map(|c| c.textures).unwrap_or_default();
454        let mut textures = BTreeMap::new();
455        for (name, encoded) in recent_map(store) {
456            if let Some((kept, texture)) = previous.get(&name).filter(|(kept, _)| *kept == encoded) {
457                textures.insert(name, (kept.clone(), texture.clone()));
458                continue;
459            }
460            let Some(image) = base64_decode(&encoded).and_then(|png| decode_png(&png)) else { continue };
461            let texture = ctx.load_texture(format!("recent-thumbnail:{name}"), image, egui::TextureOptions::LINEAR);
462            textures.insert(name, (encoded, texture));
463        }
464        let textures = Rc::new(textures);
465        *cache = Some(RecentTextures { generation, textures: textures.clone() });
466        textures
467    })
468}
469
470/// A stored PNG as an egui image; `None` for bytes that are not one.
471fn decode_png(bytes: &[u8]) -> Option<egui::ColorImage> {
472    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png);
473    let mut limits = image::Limits::default();
474    limits.max_image_width = Some(512);
475    limits.max_image_height = Some(512);
476    limits.max_alloc = Some(4 * 1024 * 1024);
477    reader.limits(limits);
478    let image = reader.decode().ok()?.to_rgba8();
479    Some(egui::ColorImage::from_rgba_unmultiplied([image.width() as usize, image.height() as usize], image.as_raw()))
480}
481
482const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
483
484/// Standard base64 with padding: how a PNG rides in the string-valued store.
485fn base64_encode(bytes: &[u8]) -> String {
486    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
487    for chunk in bytes.chunks(3) {
488        let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i)));
489        for i in 0..4 {
490            if i <= chunk.len() {
491                out.push(BASE64[((n >> (18 - 6 * i)) & 63) as usize] as char);
492            } else {
493                out.push('=');
494            }
495        }
496    }
497    out
498}
499
500fn base64_decode(text: &str) -> Option<Vec<u8>> {
501    let text = text.trim_end_matches('=');
502    let mut out = Vec::with_capacity(text.len() * 3 / 4);
503    let mut acc = 0u32;
504    let mut bits = 0u32;
505    for c in text.bytes() {
506        let value = BASE64.iter().position(|b| *b == c)? as u32;
507        acc = (acc << 6) | value;
508        bits += 6;
509        if bits >= 8 {
510            bits -= 8;
511            out.push(((acc >> bits) & 0xff) as u8);
512        }
513    }
514    Some(out)
515}
516