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