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;
44
45pub 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
53pub struct Stats {
54 pub staged: u32,
56 pub uploaded: u32,
58 pub skipped: u32,
60 pub failed: u32,
62 pub last_error: String,
64 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
74pub 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
88struct Waiting {
91 doc: u64,
92 after: u64,
93 picture: Picture,
94}
95
96#[derive(Clone, Default)]
99pub struct Picture(Arc<Shared>);
100
101struct Shared {
102 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 fn sized(size: u32) -> Self {
118 Self(Arc::new(Shared { size, ..Shared::default() }))
119 }
120
121 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 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
172fn 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 if size == raster::SIZE {
180 #[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
193pub 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
202fn 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
214pub 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
230pub 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
252fn 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
265pub(crate) fn upload_on_save() -> SavedHook {
268 Rc::new(|client, key, value| {
269 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
279pub(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
293async 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
313pub 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
329use crate::store::{ModelStore, RECENT_THUMBNAILS_KEY};
334use eframe::egui;
335
336pub const RECENT_SIZE: u32 = 80;
341pub const RECENT_SIDE: f32 = 40.0;
343
344#[derive(Debug, Clone, Default, PartialEq)]
346pub struct RecentStats {
347 pub staged: u32,
349 pub stored: u32,
351 pub skipped: u32,
353}
354
355struct 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
367pub 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
376pub 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
385fn 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
393pub 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 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
436struct RecentTextures {
439 generation: u64,
440 textures: Rc<BTreeMap<String, (String, egui::TextureHandle)>>,
441}
442
443pub 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
469pub(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
481fn 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
495fn 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