perf(ui): show sample thumbnails immediately and reuse Recent previews (#2944)

This commit is contained in:
cronos
2026-10-20 02:33:35 +00:00
committed by GitHub
parent 46a0b25280
commit b4520d265e
12 changed files with 318 additions and 50 deletions
+7
View File
@@ -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 |
+19
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+8
View File
@@ -0,0 +1,8 @@
[
"1911ce16bbcc8a961b013f53f184fa556705779dbebee2d89f7508463f60da15",
"394116b0b022dcf8f1bb469f486f9d3443d5e6abb0fb947b6946264f998f4b76",
"13f68dd015a1efd9dd9c720d94796048671579de49958821d003be5ae17b9d59",
"4a98200a71f7301a2e289ef4df34e3527e5acc4535671b5f955606712583fdb1",
"0d5fd0fd99d606215845a625ac56c8899a407527ef289a427424f064872ab25b",
"54d29037a7d599a07602e8884a628b52ccbb055a8c870c9b7ce7cc5c7390247a"
]
+1
View File
@@ -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(),
+1 -1
View File
@@ -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);
}
}
}
+282 -49
View File
@@ -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<String>,
#[cfg(not(target_arch = "wasm32"))]
rx: Option<std::sync::mpsc::Receiver<Made>>,
/// Building a selected sample must not wait for a cold recent file.
#[cfg(not(target_arch = "wasm32"))]
sample_rx: Option<std::sync::mpsc::Receiver<Made>>,
#[cfg(not(target_arch = "wasm32"))]
remembering: Option<std::sync::mpsc::Receiver<Made>>,
/// 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<u8>) -> Option<egui::ColorImage> {
.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<egui::ColorImage> {
/// 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<egui::ColorImage> {
#[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<std::path::PathBuf>) -> Option<egui::ColorImage> {
fn remember_at(s: &Session, key: &str, #[cfg(not(target_arch = "wasm32"))] identity: Option<std::path::PathBuf>) -> Option<egui::ColorImage> {
#[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<u8> = 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<egui::ColorImage>) {
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<egui::ColorImage> {
#[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<egui::ColorImage> {
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::<usize>().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::<usize>().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<String>, tx: &std::sync::mpsc::Sender<Made>) -> Option<Vec<String>> {
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::<usize>().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<String> = 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();