1use 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
48pub struct Stats {
49 pub staged: u32,
51 pub uploaded: u32,
53 pub skipped: u32,
55 pub failed: u32,
57 pub last_error: String,
59 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
69pub 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
83struct Waiting {
86 doc: u64,
87 after: u64,
88 picture: Picture,
89}
90
91#[derive(Clone, Default)]
94pub struct Picture(Arc<Shared>);
95
96struct Shared {
97 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 fn sized(size: u32) -> Self {
113 Self(Arc::new(Shared { size, ..Shared::default() }))
114 }
115
116 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 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
167fn 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 if size == raster::SIZE {
175 #[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
188pub 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
197fn 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
209pub 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
225pub 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
247fn 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
260pub(crate) fn upload_on_save() -> SavedHook {
263 Rc::new(|client, key, value| {
264 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
274pub(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
288async 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
308pub 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
324use crate::store::{ModelStore, RECENT_THUMBNAILS_KEY};
329use eframe::egui;
330
331pub const RECENT_SIZE: u32 = 80;
336pub const RECENT_SIDE: f32 = 40.0;
338
339#[derive(Debug, Clone, Default, PartialEq)]
341pub struct RecentStats {
342 pub staged: u32,
344 pub stored: u32,
346 pub skipped: u32,
348}
349
350struct 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
362pub 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
371pub 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
380fn 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
388pub 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 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
431struct RecentTextures {
434 generation: u64,
435 textures: Rc<BTreeMap<String, (String, egui::TextureHandle)>>,
436}
437
438pub 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
464pub(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
476fn 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
488fn base64_encode(bytes: &[u8]) -> String {
490 use base64::Engine as _;
491 base64::engine::general_purpose::STANDARD.encode(bytes)
492}
493
494fn 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