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
22use std::cell::RefCell;
23use std::collections::BTreeMap;
24use std::future::Future;
25use std::pin::Pin;
26use std::rc::Rc;
27use std::sync::{Arc, Mutex};
28use std::task::{Context, Poll, Waker};
29
30use brep_render::engine_state::EngineState;
31use brep_render::thumbnail::{self as raster, Capture};
32
33use super::backend::SavedHook;
34use super::client::PlmClient;
35
36/// SHA-256 of a document's bytes, lowercase hex: the server's `content_hash`.
37pub fn content_hash(document: &str) -> String {
38    use sha2::{Digest, Sha256};
39    Sha256::digest(document.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
40}
41
42/// What happened to the pictures this session made, for scripts and tests.
43#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
44pub struct Stats {
45    /// Pictures staged at a save.
46    pub staged: u32,
47    /// Uploaded and accepted.
48    pub uploaded: u32,
49    /// Nothing to picture (an empty scene) or superseded before it was sent.
50    pub skipped: u32,
51    /// Refused or unreachable.
52    pub failed: u32,
53    /// The last refusal, as the server said it.
54    pub last_error: String,
55    /// The last raster's cost, in milliseconds.
56    pub last_render_ms: f64,
57}
58
59thread_local! {
60    static STAGED: RefCell<BTreeMap<String, Staged>> = RefCell::new(BTreeMap::new());
61    static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
62    static STATS: RefCell<Stats> = RefCell::new(Stats::default());
63}
64
65/// This session's counts.
66pub fn stats() -> Stats {
67    STATS.with(|s| s.borrow().clone())
68}
69
70fn count(f: impl FnOnce(&mut Stats)) {
71    STATS.with(|s| f(&mut s.borrow_mut()));
72}
73
74struct Staged {
75    hash: String,
76    picture: Picture,
77}
78
79/// A save made while its document was still rebuilding: pictured once the
80/// run that was pending lands.
81struct Waiting {
82    doc: u64,
83    after: u64,
84    picture: Picture,
85}
86
87/// A picture on its way: filled by a raster thread (native), or rastered on
88/// first poll from the capture it holds (the browser).
89#[derive(Clone, Default)]
90pub struct Picture(Arc<Shared>);
91
92#[derive(Default)]
93struct Shared {
94    capture: Mutex<Option<Capture>>,
95    result: Mutex<Option<Option<Vec<u8>>>>,
96    waker: Mutex<Option<Waker>>,
97}
98
99impl Picture {
100    /// Raster `capture`: on a thread natively, later on this one in the browser.
101    fn start(&self, capture: Capture) {
102        #[cfg(not(target_arch = "wasm32"))]
103        {
104            let picture = self.clone();
105            std::thread::spawn(move || picture.fill(render(&capture)));
106        }
107        #[cfg(target_arch = "wasm32")]
108        {
109            *self.0.capture.lock().unwrap_or_else(|p| p.into_inner()) = Some(capture);
110            if let Some(waker) = self.0.waker.lock().unwrap_or_else(|p| p.into_inner()).take() {
111                waker.wake();
112            }
113        }
114    }
115
116    fn fill(&self, png: Option<Vec<u8>>) {
117        *self.0.result.lock().unwrap_or_else(|p| p.into_inner()) = Some(png);
118        if let Some(waker) = self.0.waker.lock().unwrap_or_else(|p| p.into_inner()).take() {
119            waker.wake();
120        }
121    }
122}
123
124impl Future for Picture {
125    type Output = Option<Vec<u8>>;
126
127    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
128        let capture = self.0.capture.lock().unwrap_or_else(|p| p.into_inner()).take();
129        if let Some(capture) = capture {
130            return Poll::Ready(render(&capture));
131        }
132        *self.0.waker.lock().unwrap_or_else(|p| p.into_inner()) = Some(cx.waker().clone());
133        match self.0.result.lock().unwrap_or_else(|p| p.into_inner()).take() {
134            Some(png) => Poll::Ready(png),
135            None => Poll::Pending,
136        }
137    }
138}
139
140/// The raster, timed. `None` for an empty scene.
141fn render(capture: &Capture) -> Option<Vec<u8>> {
142    let started = crate::recovery::wall_clock();
143    let png = raster::render_png(capture, raster::SIZE);
144    let ms = (crate::recovery::wall_clock() - started) * 1000.0;
145    // A raster thread has its own thread-locals; the count that matters is
146    // the UI thread's, so a thread's figure rides back with the picture.
147    #[cfg(target_arch = "wasm32")]
148    count(|s| s.last_render_ms = ms);
149    #[cfg(not(target_arch = "wasm32"))]
150    LAST_RENDER_MS.store(ms.to_bits(), std::sync::atomic::Ordering::Relaxed);
151    png
152}
153
154#[cfg(not(target_arch = "wasm32"))]
155static LAST_RENDER_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
156
157/// Stage the picture of `engine`'s model for the save of `document` under
158/// the PLM store key `key` (`part/<p>/rev/<r>`). `doc` is the document's tab
159/// id, for a save made mid-rebuild ([`tick`]).
160pub fn stage(key: &str, document: &str, engine: &EngineState, doc: u64) {
161    let picture = Picture::default();
162    if engine.run_pending() {
163        WAITING.with(|w| w.borrow_mut().push(Waiting { doc, after: engine.applied_generation(), picture: picture.clone() }));
164    } else {
165        picture.start(raster::capture(&engine.scene));
166    }
167    count(|s| s.staged += 1);
168    STAGED.with(|s| s.borrow_mut().insert(key.to_string(), Staged { hash: content_hash(document), picture }));
169}
170
171/// [`stage`] for a save through the store: only a PLM store, and only a
172/// revision's document (`name` in either spelling), get a picture. The file
173/// stores never do.
174pub fn stage_save(store: &dyn crate::store::ModelStore, name: &str, document: &str, engine: &EngineState, doc: u64) {
175    if store.plm_client().is_none() {
176        return;
177    }
178    if let Some(key) = crate::plm::uses::revision_key_in(name) {
179        stage(&key, document, engine, doc);
180    }
181}
182
183/// Picture the saves made mid-rebuild whose run has landed; called once per
184/// frame. `engine_of` finds a tab's engine by id (`None`: the tab closed,
185/// and its picture is dropped).
186pub fn tick<'a>(engine_of: impl Fn(u64) -> Option<&'a EngineState>) {
187    if WAITING.with(|w| w.borrow().is_empty()) {
188        return;
189    }
190    WAITING.with(|w| {
191        w.borrow_mut().retain(|waiting| match engine_of(waiting.doc) {
192            None => {
193                waiting.picture.fill(None);
194                false
195            }
196            Some(engine) if engine.run_pending() || engine.applied_generation() == waiting.after => true,
197            Some(engine) => {
198                waiting.picture.start(raster::capture(&engine.scene));
199                false
200            }
201        })
202    });
203}
204
205/// The picture staged for exactly these bytes under `key`, taken. One staged
206/// for other bytes (a later save) is left for that save's own hook.
207fn take(key: &str, document: &str) -> Option<Picture> {
208    let hash = content_hash(document);
209    STAGED.with(|s| {
210        let mut staged = s.borrow_mut();
211        match staged.get(key) {
212            Some(entry) if entry.hash == hash => staged.remove(key).map(|e| e.picture),
213            _ => None,
214        }
215    })
216}
217
218/// The hook: after an accepted document write, upload the picture staged for
219/// it, in a task of its own. Always `Ok`: a thumbnail never fails a save.
220pub(crate) fn upload_on_save() -> SavedHook {
221    Rc::new(|client, key, value| {
222        // One spelling for the staged picture and the upload's route.
223        let key = crate::plm::uses::revision_key_in(&key).unwrap_or(key);
224        if let Some(picture) = take(&key, &value) {
225            let hash = content_hash(&value);
226            detach(async move { upload(&client, &key, &hash, picture).await });
227        }
228        Box::pin(async { Ok(()) })
229    })
230}
231
232/// Run `first`, then `then` whatever `first` answered; the answer is
233/// `first`'s. The thumbnail upload follows S5's uses publisher this way.
234pub(crate) fn chain(first: SavedHook, then: SavedHook) -> SavedHook {
235    Rc::new(move |client: Rc<PlmClient>, key: String, value: String| {
236        let before = first(client.clone(), key.clone(), value.clone());
237        let then = then.clone();
238        Box::pin(async move {
239            let answer = before.await;
240            let _ = then(client, key, value).await;
241            answer
242        })
243    })
244}
245
246/// `PUT /api/parts/<p>/revisions/<r>/thumbnail` with the picture.
247async fn upload(client: &PlmClient, key: &str, hash: &str, picture: Picture) {
248    let Some(png) = picture.await else {
249        count(|s| s.skipped += 1);
250        return;
251    };
252    #[cfg(not(target_arch = "wasm32"))]
253    count(|s| s.last_render_ms = f64::from_bits(LAST_RENDER_MS.load(std::sync::atomic::Ordering::Relaxed)));
254    match put(client, key, hash, png).await {
255        Ok(()) => count(|s| s.uploaded += 1),
256        Err(why) => {
257            log::warn!("thumbnail of {key} not uploaded: {why}");
258            count(|s| {
259                s.failed += 1;
260                s.last_error = why;
261            });
262        }
263    }
264}
265
266/// Upload `png` as the thumbnail of the revision `key`, picturing the
267/// document whose hash is `hash`. The bake worker calls this directly.
268pub async fn put(client: &PlmClient, key: &str, hash: &str, png: Vec<u8>) -> Result<(), String> {
269    let (part, revision) = revision_of(key)?;
270    let path = format!("/api/parts/{part}/revisions/{revision}/thumbnail?content_hash={hash}&renderer={}", raster::RENDERER);
271    client.call_typed("PUT", &path, png, "image/png").await.map(|_| ()).map_err(|e| e.to_string())
272}
273
274fn revision_of(key: &str) -> Result<(&str, &str), String> {
275    let mut parts = key.split('/');
276    match (parts.next(), parts.next(), parts.next(), parts.next(), parts.next()) {
277        (Some("part"), Some(p), Some("rev"), Some(r), None) if !p.is_empty() && !r.is_empty() => Ok((p, r)),
278        _ => Err(format!("`{key}` is not a revision key")),
279    }
280}
281
282fn detach(task: impl Future<Output = ()> + 'static) {
283    #[cfg(target_arch = "wasm32")]
284    wasm_bindgen_futures::spawn_local(task);
285    #[cfg(not(target_arch = "wasm32"))]
286    super::native::spawn(task);
287}
288