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;
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`]), and an instructions revision's save its player data
218/// ([`crate::plm::player_data`]). The file stores upload nothing.
219pub fn stage_save(store: &dyn crate::store::ModelStore, name: &str, document: &str, engine: &EngineState, doc: u64) {
220    stage_recent(name, engine, doc);
221    if store.plm_client().is_none() {
222        return;
223    }
224    if let Some(key) = crate::plm::uses::revision_key_in(name) {
225        stage(&key, document, engine, doc);
226        crate::plm::player_data::stage(&key, document, engine, doc);
227    }
228}
229
230/// Picture the saves made mid-rebuild whose run has landed; called once per
231/// frame. `engine_of` finds a tab's engine by id (`None`: the tab closed,
232/// and its picture is dropped).
233pub fn tick<'a>(engine_of: impl Fn(u64) -> Option<&'a EngineState>) {
234    if WAITING.with(|w| w.borrow().is_empty()) {
235        return;
236    }
237    WAITING.with(|w| {
238        w.borrow_mut().retain(|waiting| match engine_of(waiting.doc) {
239            None => {
240                waiting.picture.fill(None);
241                false
242            }
243            Some(engine) if engine.run_pending() || engine.applied_generation() == waiting.after => true,
244            Some(engine) => {
245                waiting.picture.start(raster::capture(&engine.scene));
246                false
247            }
248        })
249    });
250}
251
252/// The picture staged for exactly these bytes under `key`, taken. One staged
253/// for other bytes (a later save) is left for that save's own hook.
254fn take(key: &str, document: &str) -> Option<Picture> {
255    let hash = content_hash(document);
256    STAGED.with(|s| {
257        let mut staged = s.borrow_mut();
258        match staged.get(key) {
259            Some(entry) if entry.hash == hash => staged.remove(key).map(|e| e.picture),
260            _ => None,
261        }
262    })
263}
264
265/// The hook: after an accepted document write, upload the picture staged for
266/// it, in a task of its own. Always `Ok`: a thumbnail never fails a save.
267pub(crate) fn upload_on_save() -> SavedHook {
268    Rc::new(|client, key, value| {
269        // One spelling for the staged picture and the upload's route.
270        let key = crate::plm::uses::revision_key_in(&key).unwrap_or(key);
271        if let Some(picture) = take(&key, &value) {
272            let hash = content_hash(&value);
273            detach(async move { upload(&client, &key, &hash, picture).await });
274        }
275        Box::pin(async { Ok(()) })
276    })
277}
278
279/// Run `first`, then `then` whatever `first` answered; the answer is
280/// `first`'s. The thumbnail upload follows S5's uses publisher this way.
281pub(crate) fn chain(first: SavedHook, then: SavedHook) -> SavedHook {
282    Rc::new(move |client: Rc<PlmClient>, key: String, value: String| {
283        let before = first(client.clone(), key.clone(), value.clone());
284        let then = then.clone();
285        Box::pin(async move {
286            let answer = before.await;
287            let _ = then(client, key, value).await;
288            answer
289        })
290    })
291}
292
293/// `PUT /api/parts/<p>/revisions/<r>/thumbnail` with the picture.
294async fn upload(client: &PlmClient, key: &str, hash: &str, picture: Picture) {
295    let Some(png) = picture.await else {
296        count(|s| s.skipped += 1);
297        return;
298    };
299    #[cfg(not(target_arch = "wasm32"))]
300    count(|s| s.last_render_ms = f64::from_bits(LAST_RENDER_MS.load(std::sync::atomic::Ordering::Relaxed)));
301    match put(client, key, hash, png).await {
302        Ok(()) => count(|s| s.uploaded += 1),
303        Err(why) => {
304            log::warn!("thumbnail of {key} not uploaded: {why}");
305            count(|s| {
306                s.failed += 1;
307                s.last_error = why;
308            });
309        }
310    }
311}
312
313/// Upload `png` as the thumbnail of the revision `key`, picturing the
314/// document whose hash is `hash`. The bake worker calls this directly.
315pub async fn put(client: &PlmClient, key: &str, hash: &str, png: Vec<u8>) -> Result<(), String> {
316    let (part, revision) = revision_of(key)?;
317    let path = format!("{}/thumbnail?content_hash={hash}&renderer={}", super::identity::revision_path(&part, &revision), raster::RENDERER);
318    client.call_typed("PUT", &path, png, "image/png").await.map(|_| ()).map_err(|e| e.to_string())
319}
320
321fn revision_of(key: &str) -> Result<(String, String), String> {
322    super::family::key_ids(key).ok_or_else(|| format!("'{key}' is not a revision key"))
323}
324
325fn detach(task: impl Future<Output = ()> + 'static) {
326    crate::store::spawn_waking(task);
327}
328
329// ---------------------------------------------------------------------------
330// The File menu's recent-file pictures.
331// ---------------------------------------------------------------------------
332
333use crate::store::{ModelStore, RECENT_THUMBNAILS_KEY};
334use eframe::egui;
335
336/// A recent-file picture's edge, in pixels: small, because the browser store
337/// is bounded, and drawn at [`RECENT_SIDE`] points (two pixels per point on a
338/// HiDPI screen). A shaded 80-pixel PNG of a part is a few kilobytes; twenty
339/// of them, the recent list's cap, stay well under a hundred.
340pub const RECENT_SIZE: u32 = 80;
341/// The side of the square a recent row draws its picture in, in points.
342pub const RECENT_SIDE: f32 = 40.0;
343
344/// What happened to the recent-file pictures this session made, for tests.
345#[derive(Debug, Clone, Default, PartialEq)]
346pub struct RecentStats {
347    /// Pictures staged at a save.
348    pub staged: u32,
349    /// Written to the store.
350    pub stored: u32,
351    /// Nothing to picture, or the name was no longer a recent document.
352    pub skipped: u32,
353}
354
355/// A picture on its way to the recent-thumbnail map.
356struct Recent {
357    name: String,
358    picture: Picture,
359}
360
361thread_local! {
362    static RECENT: RefCell<Vec<Recent>> = const { RefCell::new(Vec::new()) };
363    static RECENT_STATS: RefCell<RecentStats> = RefCell::new(RecentStats::default());
364    static RECENT_TEXTURES: RefCell<Option<RecentTextures>> = const { RefCell::new(None) };
365}
366
367/// This session's recent-picture counts.
368pub fn recent_stats() -> RecentStats {
369    RECENT_STATS.with(|s| s.borrow().clone())
370}
371
372fn count_recent(f: impl FnOnce(&mut RecentStats)) {
373    RECENT_STATS.with(|s| f(&mut s.borrow_mut()));
374}
375
376/// Stage the recent-list picture of `engine`'s model for the save of `name`.
377/// The raster runs off the UI thread natively and in the frame after the
378/// save in the browser; [`persist_recent`] writes what it made.
379pub fn stage_recent(name: &str, engine: &EngineState, doc: u64) {
380    let picture = picture_of(engine, doc, RECENT_SIZE);
381    count_recent(|s| s.staged += 1);
382    RECENT.with(|r| r.borrow_mut().push(Recent { name: name.to_string(), picture }));
383}
384
385/// The recent-thumbnail map as stored: name → base64 PNG.
386fn recent_map(store: &dyn ModelStore) -> BTreeMap<String, String> {
387    store
388        .read(RECENT_THUMBNAILS_KEY)
389        .and_then(|text| serde_json::from_str(&text).ok())
390        .unwrap_or_default()
391}
392
393/// Write the recent-list pictures that finished since the last frame into
394/// the store, pruning the map to the names the recent list still holds.
395/// Called once per frame. Never an error: a picture that could not be
396/// rendered or written is dropped and the menu shows its placeholder.
397pub fn persist_recent(store: &dyn ModelStore) {
398    let done: Vec<(String, Option<Vec<u8>>)> = RECENT.with(|r| {
399        let mut pending = r.borrow_mut();
400        let mut done = Vec::new();
401        pending.retain(|entry| match entry.picture.peek() {
402            Some(png) => {
403                done.push((entry.name.clone(), png));
404                false
405            }
406            None => true,
407        });
408        done
409    });
410    if done.is_empty() {
411        return;
412    }
413    let recent = crate::panels::file::recent_documents(store);
414    let mut map = recent_map(store);
415    map.retain(|name, _| recent.contains(name));
416    for (name, png) in done {
417        match png {
418            Some(png) if recent.contains(&name) => {
419                map.insert(name, base64_encode(&png));
420                count_recent(|s| s.stored += 1);
421            }
422            Some(_) => count_recent(|s| s.skipped += 1),
423            None => {
424                // An empty scene: no picture, so the row shows its placeholder.
425                map.remove(&name);
426                count_recent(|s| s.skipped += 1);
427            }
428        }
429    }
430    let text = serde_json::to_string(&map).unwrap_or_else(|_| "{}".into());
431    if let Err(why) = store.write(RECENT_THUMBNAILS_KEY, &text) {
432        log::warn!("recent thumbnails not stored: {why}");
433    }
434}
435
436/// The recent-file pictures as GPU textures, decoded once per store
437/// mutation and reused while their bytes are unchanged.
438struct RecentTextures {
439    generation: u64,
440    textures: Rc<BTreeMap<String, (String, egui::TextureHandle)>>,
441}
442
443/// The recent-file pictures the store holds right now, name → texture, for
444/// the File menu to draw. A name with no entry has no picture.
445pub fn recent_textures(ctx: &egui::Context, store: &dyn ModelStore) -> Rc<BTreeMap<String, (String, egui::TextureHandle)>> {
446    let generation = store.mutation_generation();
447    RECENT_TEXTURES.with(|cache| {
448        let mut cache = cache.borrow_mut();
449        if let Some(cached) = cache.as_ref().filter(|c| c.generation == generation) {
450            return cached.textures.clone();
451        }
452        let previous = cache.take().map(|c| c.textures).unwrap_or_default();
453        let mut textures = BTreeMap::new();
454        for (name, encoded) in recent_map(store) {
455            if let Some((kept, texture)) = previous.get(&name).filter(|(kept, _)| *kept == encoded) {
456                textures.insert(name, (kept.clone(), texture.clone()));
457                continue;
458            }
459            let Some(image) = base64_decode(&encoded).and_then(|png| decode_png(&png)) else { continue };
460            let texture = ctx.load_texture(format!("recent-thumbnail:{name}"), image, egui::TextureOptions::LINEAR);
461            textures.insert(name, (encoded, texture));
462        }
463        let textures = Rc::new(textures);
464        *cache = Some(RecentTextures { generation, textures: textures.clone() });
465        textures
466    })
467}
468
469/// The preview a document carries (`thumbnail: {data, status}`, as a save
470/// or a bake embeds it) as an egui image; `None` when it has none, or only
471/// the empty placeholder.
472pub(crate) fn embedded_image(document: &serde_json::Value) -> Option<egui::ColorImage> {
473    let thumbnail = &document["thumbnail"];
474    if thumbnail["status"].as_str().is_some_and(|status| status != "ready") {
475        return None;
476    }
477    let encoded = thumbnail["data"].as_str().or_else(|| thumbnail["png"].as_str())?;
478    decode_png(&base64_decode(encoded)?)
479}
480
481/// A stored PNG as an egui image; `None` for bytes that are not one.
482fn decode_png(bytes: &[u8]) -> Option<egui::ColorImage> {
483    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png);
484    let mut limits = image::Limits::default();
485    limits.max_image_width = Some(512);
486    limits.max_image_height = Some(512);
487    limits.max_alloc = Some(4 * 1024 * 1024);
488    reader.limits(limits);
489    let image = reader.decode().ok()?.to_rgba8();
490    Some(egui::ColorImage::from_rgba_unmultiplied([image.width() as usize, image.height() as usize], image.as_raw()))
491}
492
493const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
494
495/// Standard base64 with padding: how a PNG rides in the string-valued store.
496fn base64_encode(bytes: &[u8]) -> String {
497    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
498    for chunk in bytes.chunks(3) {
499        let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i)));
500        for i in 0..4 {
501            if i <= chunk.len() {
502                out.push(BASE64[((n >> (18 - 6 * i)) & 63) as usize] as char);
503            } else {
504                out.push('=');
505            }
506        }
507    }
508    out
509}
510
511fn base64_decode(text: &str) -> Option<Vec<u8>> {
512    let text = text.trim_end_matches('=');
513    let mut out = Vec::with_capacity(text.len() * 3 / 4);
514    let mut acc = 0u32;
515    let mut bits = 0u32;
516    for c in text.bytes() {
517        let value = BASE64.iter().position(|b| *b == c)? as u32;
518        acc = (acc << 6) | value;
519        bits += 6;
520        if bits >= 8 {
521            bits -= 8;
522            out.push(((acc >> bits) & 0xff) as u8);
523        }
524    }
525    Some(out)
526}
527