Add bounded portable Home thumbnails to design files (#518)

A save with cache: true also stores a small bounded PNG thumbnail (240 × 150, at most 256 KiB, keyed to the exact document) made from cached meshes only. Home uses it when its local picture is missing, without rebuilding the design; ordinary saves and downloads still omit it. Closes #303.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
This commit is contained in:
selune authored and GitHub committed 2026-10-13 05:20:47 -04:00
1 parent 24974e6f37
commit bcbfa50eac
10 files changed
+431 -5

No files matched your search

+69
View File
@@ -5,7 +5,9 @@ use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
mod native_cache;
mod thumbnail;
pub use native_cache::{DesignGeometry, read_design_geometry};
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
@@ -110,6 +112,18 @@ pub fn write_design_container_with_cache(
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
) -> Result<Vec<u8>> {
write_design_container_with_thumbnail(doc, compression, preview, geometry, None)
}
/// Include a bounded portable PNG picture when one is already available.
/// Unsupported headers or oversized pictures are omitted without changing the design.
pub fn write_design_container_with_thumbnail(
doc: &Document,
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
thumbnail: Option<&[u8]>,
) -> Result<Vec<u8>> {
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
let mut features = Vec::with_capacity(doc.features.len());
@@ -184,6 +198,9 @@ pub fn write_design_container_with_cache(
writer.part("cache/preview.json", &manifest, false)?;
writer.part("cache/preview.bin", &data, false)?;
}
if let Some(png) = thumbnail {
thumbnail::write(&mut writer, png, &metadata)?;
}
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid("compressed file too large"));
@@ -465,6 +482,11 @@ impl<R: Read + Seek> DesignContainerReader<R> {
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
}
/// Portable PNG picture only; no source blobs or geometry are evaluated.
pub fn thumbnail(&mut self) -> Option<Vec<u8>> {
thumbnail::read(&mut self.reader).ok()
}
/// Native geometry is provisional, never a substitute for rebuilding the document.
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
let document = self.reader.document(16 << 20).ok()?;
@@ -837,6 +859,53 @@ mod tests {
zip.finish().unwrap().into_inner()
}
#[test]
fn portable_thumbnail_is_keyed_bounded_and_does_not_read_sources() {
// IO validates an opaque PNG header/hash; UI tests exercise real PNG decoding.
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend(240u32.to_be_bytes());
png.extend(150u32.to_be_bytes());
let mut doc = Document::from_json(&document().to_json()).unwrap();
if let FeatureKind::Import { step, .. } = &mut doc.features[0].kind {
*step = "source data".repeat(250_000);
}
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&png)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes.clone()), count: count.clone() }).unwrap();
assert_eq!(reader.thumbnail(), Some(png.clone()));
assert!(count.get() < 256 * 1024);
assert_eq!(reader.read_document().unwrap(), doc);
let original = parts(&bytes);
for field in ["format", "app", "document", "payload"] {
let mut entries = original.clone();
let mut key: Value = serde_json::from_slice(&entries["cache/thumbnail.json"]).unwrap();
key[field] = json!("mismatch");
entries.insert("cache/thumbnail.json".into(), serde_json::to_vec(&key).unwrap());
let changed = package(&entries);
assert!(read_design_thumbnail(&changed).is_none());
assert_eq!(crate::read_design(&changed).unwrap(), doc);
}
let mut entries = original.clone();
let source = entries.keys().find(|k| k.ends_with(".step")).unwrap().clone();
entries.insert(source, b"corrupt source".to_vec());
let changed = package(&entries);
assert_eq!(read_design_thumbnail(&changed), Some(png));
assert!(crate::read_design(&changed).is_err());
for n in [0, 1, 8, bytes.len() / 2, bytes.len() - 1] {
assert!(read_design_thumbnail(&bytes[..n]).is_none());
}
for i in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
let mut changed = bytes.clone();
changed[i] ^= 0xff;
assert!(std::panic::catch_unwind(|| read_design_thumbnail(&changed)).is_ok());
}
for invalid in [vec![], vec![0; 300_000]] {
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&invalid)).unwrap();
assert!(read_design_thumbnail(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
}
}
#[test]
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
let doc = Document::from_json(&document().to_json()).unwrap();