diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fa381d0974..c39715f386 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -28,6 +28,13 @@ Editable drawing code, native projects and shipped exports are collected under ` | docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 | +| assets/samples/exports/0.png | SolidCraft contributors | original Sample Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 | +| assets/samples/exports/1.png | SolidCraft contributors | original Sheet Metal Bracket from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 | +| assets/samples/exports/2.png | SolidCraft contributors | original Hinge Assembly from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 | +| assets/samples/exports/3.png | SolidCraft contributors | original Plastic Enclosure from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 | +| assets/samples/exports/4.png | SolidCraft contributors | original Bolted Flange from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 | +| assets/samples/exports/5.png | SolidCraft contributors | original Vent Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 | +| assets/samples/exports/scripts.json | SolidCraft contributors | SHA-256 fingerprints of the original sample scripts, generated with the shipped thumbnails | MIT OR Apache-2.0 | | docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 | | docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 | | docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 | diff --git a/assets/samples/README.md b/assets/samples/README.md new file mode 100644 index 0000000000..01ea15e681 --- /dev/null +++ b/assets/samples/README.md @@ -0,0 +1,19 @@ +# Sample previews + +The editable authority is the built-in command scripts in +`crates/engine/src/sample.rs`. The six 240 × 150 PNGs in `exports/` are generated +by SolidCraft's thumbnail renderer from those original designs, using the same +isometric camera as Recent. They are embedded by `home.rs`, so displaying Home +does not evaluate sample geometry or read pixels back from the GPU. Selecting a +sample builds its editable design through the normal commands. + +Regenerate after changing a sample script: + +```sh +cargo test --profile ci -p solidcraft-ui-egui --lib home::tests::regenerate_sample_thumbnails -- --ignored --exact --nocapture +``` + +`exports/scripts.json` records SHA-256 hashes of the scripts. The regular tests +check the hashes and decode all six pictures with the same bounds as Recent. +The generated PNGs and hash file are necessary shipped exports; benchmark +screenshots and other review output stay outside the asset tree. diff --git a/assets/samples/exports/0.png b/assets/samples/exports/0.png new file mode 100644 index 0000000000..cbe0b69c15 Binary files /dev/null and b/assets/samples/exports/0.png differ diff --git a/assets/samples/exports/1.png b/assets/samples/exports/1.png new file mode 100644 index 0000000000..0fbed21378 Binary files /dev/null and b/assets/samples/exports/1.png differ diff --git a/assets/samples/exports/2.png b/assets/samples/exports/2.png new file mode 100644 index 0000000000..637896f5a5 Binary files /dev/null and b/assets/samples/exports/2.png differ diff --git a/assets/samples/exports/3.png b/assets/samples/exports/3.png new file mode 100644 index 0000000000..f8290901ff Binary files /dev/null and b/assets/samples/exports/3.png differ diff --git a/assets/samples/exports/4.png b/assets/samples/exports/4.png new file mode 100644 index 0000000000..0edfac48f0 Binary files /dev/null and b/assets/samples/exports/4.png differ diff --git a/assets/samples/exports/5.png b/assets/samples/exports/5.png new file mode 100644 index 0000000000..264d979ce3 Binary files /dev/null and b/assets/samples/exports/5.png differ diff --git a/assets/samples/exports/scripts.json b/assets/samples/exports/scripts.json new file mode 100644 index 0000000000..3d1f135101 --- /dev/null +++ b/assets/samples/exports/scripts.json @@ -0,0 +1,8 @@ +[ + "1911ce16bbcc8a961b013f53f184fa556705779dbebee2d89f7508463f60da15", + "394116b0b022dcf8f1bb469f486f9d3443d5e6abb0fb947b6946264f998f4b76", + "13f68dd015a1efd9dd9c720d94796048671579de49958821d003be5ae17b9d59", + "4a98200a71f7301a2e289ef4df34e3527e5acc4535671b5f955606712583fdb1", + "0d5fd0fd99d606215845a625ac56c8899a407527ef289a427424f064872ab25b", + "54d29037a7d599a07602e8884a628b52ccbb055a8c870c9b7ce7cc5c7390247a" +] \ No newline at end of file diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c22f9b87f2..a96f0c5c15 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -304,6 +304,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, "home": app.home.open, "recent_thumbnails_ready": app.home.ready_recents(), + "sample_thumbnails_ready": app.home.ready_samples(), "documents": app.docs.count(), "quit_requested": app.quit_requested, "quitting": app.docs.quitting.is_some(), diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index c130370ab5..a664722301 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -802,7 +802,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { if request.kind == Kind::Open { app.home.add_recent(&request.path); if made.thumbnail { - crate::home::remember_session(&mut app.home, &app.session); + crate::home::remember_source(&mut app.home, &app.session, &request.path); } } } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index a0e493369a..f9738b972b 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -14,6 +14,14 @@ use crate::theme::Tokens; /// Recent designs kept. pub const MAX_RECENT: usize = 12; const THUMB: [usize; 2] = solidcraft_engine::io::THUMBNAIL_SIZE; +const SAMPLE_PICTURES: [&[u8]; 6] = [ + include_bytes!("../../../assets/samples/exports/0.png"), + include_bytes!("../../../assets/samples/exports/1.png"), + include_bytes!("../../../assets/samples/exports/2.png"), + include_bytes!("../../../assets/samples/exports/3.png"), + include_bytes!("../../../assets/samples/exports/4.png"), + include_bytes!("../../../assets/samples/exports/5.png"), +]; /// What a background job makes: a key ("sample:N" or a path), the picture, and for samples the /// built design (opened at once when picked). @@ -31,6 +39,9 @@ pub struct HomeState { queued: Vec, #[cfg(not(target_arch = "wasm32"))] rx: Option>, + /// Building a selected sample must not wait for a cold recent file. + #[cfg(not(target_arch = "wasm32"))] + sample_rx: Option>, #[cfg(not(target_arch = "wasm32"))] remembering: Option>, /// A sample picked before its design was ready. @@ -42,8 +53,14 @@ impl HomeState { self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count() } + pub fn ready_samples(&self) -> usize { + (0..solidcraft_engine::sample::samples().len()).filter(|i| self.thumbs.get(&format!("sample:{i}")).is_some_and(Option::is_some)).count() + } + pub fn show(&mut self, _rev: u64) { self.open = true; + // Returning from another document cancels an earlier, unfinished sample selection. + self.wanted = None; } /// Put a design at the top of the recent list. @@ -115,21 +132,25 @@ fn decode_thumbnail(bytes: Vec) -> Option { .then(|| egui::ColorImage::from_rgba_unmultiplied(THUMB, image.as_raw())) } -/// Render from the model already built by Open/Save, rather than evaluating its file again. -pub(crate) fn remember(s: &Session) -> Option { +/// Imported STEP/mesh files open as unsaved designs, with no session path. Cache their +/// pictures under the source file identity too, so Home need not reimport them on each launch. +pub(crate) fn remember_file(s: &Session, key: &str) -> Option { #[cfg(not(target_arch = "wasm32"))] - let identity = s.path.as_deref().and_then(cache_path); + let identity = cache_path(key); remember_at( s, + key, #[cfg(not(target_arch = "wasm32"))] identity, ) } -fn remember_at(s: &Session, #[cfg(not(target_arch = "wasm32"))] identity: Option) -> Option { +fn remember_at(s: &Session, key: &str, #[cfg(not(target_arch = "wasm32"))] identity: Option) -> Option { + #[cfg(target_arch = "wasm32")] + let _ = key; let img = thumbnail(s)?; #[cfg(not(target_arch = "wasm32"))] - if let Some(path) = identity.filter(|p| s.path.as_deref().and_then(cache_path).as_ref() == Some(p)) { + if let Some(path) = identity.filter(|p| cache_path(key).as_ref() == Some(p)) { let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); if let Some(bytes) = solidcraft_engine::render::png(THUMB[0], THUMB[1], rgba) && let Some(dir) = path.parent() @@ -148,45 +169,67 @@ pub(crate) fn remembering(h: &HomeState) -> bool { } pub(crate) fn remember_session(h: &mut HomeState, s: &Session) { + if let Some(path) = s.path.as_deref() { + remember_source(h, s, path); + } +} + +/// Draw an opened file's picture after adoption, including imports with no save path. +pub(crate) fn remember_source(h: &mut HomeState, s: &Session, key: &str) { #[cfg(not(target_arch = "wasm32"))] - if let Some(path) = &s.path { - let identity = cache_path(path); - let mut snapshot = s.scratch(); - snapshot.path = Some(path.clone()); - let key = path.clone(); + { + let identity = cache_path(key); + let snapshot = s.scratch(); + let source = key.to_owned(); let (tx, rx) = std::sync::mpsc::channel(); if std::thread::Builder::new() .name("saved-thumbnail".into()) .spawn(move || { - let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, identity))).ok().flatten(); - let _ = tx.send((key, img, None)); + let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, &source, identity))).ok().flatten(); + let _ = tx.send((source, img, None)); }) .is_ok() { h.remembering = Some(rx); - h.queued.retain(|k| k != path); - h.thumbs.insert(path.clone(), None); + h.queued.retain(|k| k != key); + h.thumbs.insert(key.to_owned(), None); } } #[cfg(target_arch = "wasm32")] { - let _ = (h, s); + let _ = (h, s, key); } } +pub(crate) fn install(h: &mut HomeState, ctx: &egui::Context, key: &str, img: Option) { + let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR)); + h.thumbs.insert(key.to_string(), tex); +} + +/// Read a disposable picture without evaluating, importing or meshing a model. +fn picture(key: &str) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(img) = cached(key) { + return Some(img); + } + solidcraft_engine::io::read_design_thumbnail_file(key).and_then(decode_thumbnail) +} + +/// A sample's shipped picture. Its model is built only when the sample is selected. +fn sample_picture(i: usize) -> Option { + decode_thumbnail(SAMPLE_PICTURES.get(i)?.to_vec()) +} + /// Build what a key stands for (runs off the UI thread where threads exist). fn make(key: &str) -> Made { - #[cfg(not(target_arch = "wasm32"))] - if let Some(img) = cached(key) { - return (key.to_string(), Some(img), None); - } - if !key.starts_with("sample:") - && let Some(img) = solidcraft_engine::io::read_design_thumbnail_file(key).and_then(decode_thumbnail) + let sample = key.strip_prefix("sample:").and_then(|i| i.parse::().ok()); + if sample.is_none() + && let Some(img) = picture(key) { return (key.to_string(), Some(img), None); } let mut s = Session::default(); - let built = match key.strip_prefix("sample:").and_then(|i| i.parse::().ok()) { + let built = match sample { Some(i) => solidcraft_engine::sample::samples().get(i).is_some_and(|smp| { let ok = s.run_script(&(smp.script)()).is_ok(); s.doc_mut().name = smp.name.to_string(); @@ -199,8 +242,29 @@ fn make(key: &str) -> Made { if !built { return (key.to_string(), None, None); } - let img = remember(&s); - (key.to_string(), img, key.starts_with("sample:").then_some(s)) + if sample.is_some() { + // Keep the first viewport frame warm even though the preview needs no new render. + s.world_state().warm_meshes(|| true); + } + let img = match sample { + Some(i) => sample_picture(i), + None => remember_file(&s, key), + }; + (key.to_string(), img, sample.map(|_| s)) +} + +/// Send every readable picture before starting any cold model work. A portable picture in a +/// later file must not wait for an earlier STEP import or legacy design rebuild. +#[cfg(not(target_arch = "wasm32"))] +fn read_pictures(keys: Vec, tx: &std::sync::mpsc::Sender) -> Option> { + let mut cold = Vec::new(); + for key in keys { + match std::panic::catch_unwind(|| picture(&key)).ok().flatten() { + Some(img) => tx.send((key, Some(img), None)).ok()?, + None => cold.push(key), + } + } + Some(cold) } #[cfg(not(target_arch = "wasm32"))] @@ -209,20 +273,12 @@ fn start_jobs(h: &mut HomeState) { return; } let mut keys = std::mem::take(&mut h.queued); - // Cached recents first, then cold recents, then samples. A sample must never hold up - // pictures of the user's work; a cold file must not hold up already cached pictures. - keys.sort_by_key(|k| { - if k.starts_with("sample:") { - 2 - } else if cache_path(k).is_some_and(|p| p.exists()) { - 0 - } else { - 1 - } - }); let (tx, rx) = std::sync::mpsc::channel(); let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || { - for k in keys { + // Prefer local PNGs; even the metadata checks belong off the window thread. + keys.sort_by_key(|k| !cache_path(k).is_some_and(|p| p.exists())); + let Some(cold) = read_pictures(keys, &tx) else { return }; + for k in cold { let made = std::panic::catch_unwind(|| make(&k)).unwrap_or((k, None, None)); if tx.send(made).is_err() { return; @@ -234,6 +290,26 @@ fn start_jobs(h: &mut HomeState) { } } +#[cfg(not(target_arch = "wasm32"))] +fn start_sample(h: &mut HomeState) { + let Some(i) = h.wanted.filter(|i| !h.samples.contains_key(i)) else { return }; + if h.sample_rx.is_some() { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("selected-sample".into()) + .spawn(move || { + let key = format!("sample:{i}"); + let made = std::panic::catch_unwind(|| make(&key)).unwrap_or((key, None, None)); + let _ = tx.send(made); + }) + .is_ok() + { + h.sample_rx = Some(rx); + } +} + /// Take finished pictures and designs (one job per frame on the web, where there are no /// threads). fn poll(app: &mut SolveApp, ctx: &egui::Context) { @@ -242,6 +318,17 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) { let mut remembered = None; #[cfg(not(target_arch = "wasm32"))] { + start_sample(&mut app.home); + if let Some(rx) = &app.home.sample_rx { + match rx.try_recv() { + Ok(m) => { + done.push(m); + app.home.sample_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => app.home.sample_rx = None, + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(16)), + } + } if let Some(rx) = &app.home.remembering { match rx.try_recv() { Ok(m) => { @@ -280,9 +367,12 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) { } } #[cfg(target_arch = "wasm32")] - if !app.home.queued.is_empty() { - let k = app.home.queued.remove(0); - done.push(make(&k)); + if let Some(i) = app.home.wanted { + done.push(make(&format!("sample:{i}"))); + ctx.request_repaint(); + } else if !app.home.queued.is_empty() { + let key = app.home.queued.remove(0); + done.push(make(&key)); ctx.request_repaint(); } for (key, img, session) in done { @@ -290,8 +380,10 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) { app.home.wanted = None; app.set_status(format!("the sample could not be built ({key})"), true); } - let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR)); - app.home.thumbs.insert(key.clone(), tex); + // A failed sample build does not discard its shipped preview. + if img.is_some() || !app.home.thumbs.contains_key(&key) { + install(&mut app.home, ctx, &key, img); + } if let (Some(s), Some(i)) = (session, key.strip_prefix("sample:").and_then(|i| i.parse::().ok())) { app.home.samples.insert(i, s); } @@ -301,6 +393,9 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) { { app.home.wanted = None; open_sample(app, i); + } else if app.home.wanted.is_some() { + // A selection can change while another sample finishes; schedule the next build. + ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } @@ -321,10 +416,11 @@ pub fn open_sample(app: &mut SolveApp, i: usize) { crate::documents::adopt(app, copy); app.home.open = false; } - None => { + None if i < solidcraft_engine::sample::samples().len() => { + app.home.open = true; app.home.wanted = Some(i); - want(&mut app.home, &format!("sample:{i}")); } + None => {} } } @@ -342,11 +438,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { poll(app, ui.ctx()); let t = Tokens::get(); let samples = solidcraft_engine::sample::samples(); - // In the browser a picture blocks the page while it builds (no threads) and a kernel - // failure there can't be caught: samples are built when they are opened. - if !cfg!(target_arch = "wasm32") { - for i in 0..samples.len() { - want(&mut app.home, &format!("sample:{i}")); + // Embedded previews are available on the first frame, including on the web. No model + // scripts, filesystem reads or GPU readbacks are needed to show the sample cards. + for i in 0..samples.len() { + let key = format!("sample:{i}"); + if !app.home.thumbs.contains_key(&key) { + install(&mut app.home, ui.ctx(), &key, sample_picture(i)); } } let recent = app.home.recent.clone(); @@ -569,6 +666,46 @@ fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui:: mod tests { use super::*; + #[test] + fn shipped_sample_pictures_are_bounded_and_match_the_current_scripts() { + use sha2::{Digest, Sha256}; + let hashes: Vec = serde_json::from_str(include_str!("../../../assets/samples/exports/scripts.json")).unwrap(); + let samples = solidcraft_engine::sample::samples(); + assert_eq!(samples.len(), SAMPLE_PICTURES.len()); + assert_eq!(hashes.len(), samples.len()); + for (i, sample) in samples.iter().enumerate() { + let script = serde_json::to_vec(&(sample.script)()).unwrap(); + assert_eq!(hashes.get(i).unwrap(), &format!("{:x}", Sha256::digest(script)), "regenerate the preview for {}", sample.name); + assert_eq!(sample_picture(i).unwrap().size, THUMB); + } + assert!(sample_picture(samples.len()).is_none()); + } + + /// Rebuild the shipped pictures from the authoritative sample command scripts. + #[cfg(not(target_arch = "wasm32"))] + #[test] + #[ignore = "asset authoring: writes assets/samples/exports"] + fn regenerate_sample_thumbnails() { + use sha2::{Digest, Sha256}; + assert!(crate::gpu::test_picture_renderer()); + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/samples/exports"); + std::fs::create_dir_all(&dir).unwrap(); + let mut hashes = Vec::new(); + for (i, sample) in solidcraft_engine::sample::samples().into_iter().enumerate() { + let began = std::time::Instant::now(); + let script = (sample.script)(); + let mut session = Session::default(); + session.run_script(&script).unwrap(); + let image = thumbnail(&session).unwrap(); + let rgba = image.pixels.iter().flat_map(|p| p.to_srgba_unmultiplied()).collect(); + let png = solidcraft_engine::render::png(THUMB[0], THUMB[1], rgba).unwrap(); + std::fs::write(dir.join(format!("{i}.png")), png).unwrap(); + hashes.push(format!("{:x}", Sha256::digest(serde_json::to_vec(&script).unwrap()))); + eprintln!("{}: {:.3} s", sample.name, began.elapsed().as_secs_f64()); + } + std::fs::write(dir.join("scripts.json"), serde_json::to_vec_pretty(&hashes).unwrap()).unwrap(); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() { @@ -584,7 +721,7 @@ mod tests { let mut s = Session::default(); s.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap(); s.path = Some(key.clone()); - let image = remember(&s).unwrap(); + let image = remember_file(&s, &key).unwrap(); let png = cache_path(&key).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), bytes); let (_, cached_image, session) = make(&key); @@ -625,6 +762,102 @@ mod tests { assert!(decode_thumbnail(vec![0; 24]).is_none()); } + /// STEP opens deliberately have no save path, but their source still gets a cache entry. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn imported_step_picture_is_cached_under_its_source_without_a_save_path() { + if !crate::gpu::test_picture_renderer() { + return; + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("solidcraft-import-thumb-{}-{nonce}.step", std::process::id())); + let key = path.to_string_lossy().into_owned(); + let mut part = Session::default(); + part.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); + part.execute("file.export", &serde_json::json!({"path":key})).unwrap(); + let original = std::fs::read(&path).unwrap(); + let mut opened = Session::default(); + opened.execute("doc.open", &serde_json::json!({"path":key})).unwrap(); + assert!(opened.path.is_none(), "an imported file is an unsaved design"); + let mut home = HomeState::default(); + remember_source(&mut home, &opened, &key); + let (made_key, image, rebuilt) = home.remembering.take().unwrap().recv_timeout(std::time::Duration::from_secs(30)).unwrap(); + assert_eq!(made_key, key); + assert!(rebuilt.is_none()); + let image = image.unwrap(); + assert!(opened.path.is_none(), "caching must not turn Save into an overwrite of the STEP source"); + let png = cache_path(&key).unwrap(); + assert_eq!(cached(&key).unwrap().pixels, image.pixels); + assert_eq!(std::fs::read(&path).unwrap(), original); + let (_, picture, rebuilt) = make(&key); + assert!(rebuilt.is_none()); + assert_eq!(picture.unwrap().pixels, image.pixels); + std::fs::write(&path, b"file replaced").unwrap(); + assert!(cached(&key).is_none()); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(png).unwrap(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn portable_pictures_are_sent_before_any_cold_recent_is_built() { + if !crate::gpu::test_picture_renderer() { + return; + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("solidcraft-fast-thumb-{}-{nonce}.solidcraft", std::process::id())); + let key = path.to_string_lossy().into_owned(); + let mut s = Session::default(); + s.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); + for b in &s.world_state().bodies { + b.mesh(); + } + let bytes = s.design_container(solidcraft_engine::io::DesignCompression::Fast, false, true).unwrap(); + std::fs::write(&path, bytes).unwrap(); + let cold = format!("missing-cold-file-{nonce}.step"); + let (tx, rx) = std::sync::mpsc::channel(); + let misses = read_pictures(vec![cold.clone(), key.clone()], &tx).unwrap(); + assert_eq!(misses, vec![cold]); + let (ready, picture, rebuilt) = rx.try_recv().unwrap(); + assert_eq!(ready, key); + assert!(picture.is_some() && rebuilt.is_none()); + assert!(cached(&key).is_none(), "the portable picture did not rebuild the design"); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn first_home_frame_shows_all_samples_without_building_any_models() { + let mut h = crate::scenario::Harness::new(); + h.app.home.show(0); + h.frames(1); + assert_eq!(h.app.home.ready_samples(), solidcraft_engine::sample::samples().len()); + assert!(h.app.home.samples.is_empty()); + assert!(h.app.home.queued.is_empty()); + #[cfg(not(target_arch = "wasm32"))] + assert!(h.app.home.sample_rx.is_none() && h.app.home.rx.is_none()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn selecting_a_sample_does_not_wait_for_a_running_cold_recent() { + let mut app = SolveApp::new(Session::default(), Default::default()); + let (_blocked_worker, rx) = std::sync::mpsc::channel(); + app.home.rx = Some(rx); + app.home.open = false; + let ctx = egui::Context::default(); + open_sample(&mut app, 1); + assert!(app.home.open, "a programmatic selection from a document must start the sample job too"); + let began = std::time::Instant::now(); + while app.home.open && began.elapsed() < std::time::Duration::from_secs(30) { + poll(&mut app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!app.home.open, "the recent worker is still blocked, but the sample must open"); + assert!(app.home.rx.is_some()); + assert_eq!(app.session.doc.name, "Sheet Metal Bracket"); + assert!(!app.session.is_dirty()); + } + #[test] fn recent_list_is_newest_first_and_bounded() { let mut h = HomeState::default();