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 <[email protected]>
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();
+77
View File
@@ -0,0 +1,77 @@
//! Portable, disposable PNG pictures. Reading never resolves source blobs.
use super::*;
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
const MAX_BYTES: usize = 256 << 10;
const FORMAT: &str = "solidcraft-thumbnail/1";
fn valid_header(bytes: &[u8]) -> bool {
bytes.len() <= MAX_BYTES
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
}
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
if !valid_header(png) {
return Ok(());
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|| writer
.expanded
.saturating_add(key.len())
.saturating_add(png.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
> MAX_DESIGN_BYTES
{
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("thumbnail container version"));
}
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(16 << 20)?;
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
// Consumers must also use a bounded PNG decoder; the header is not a decode.
Ok(png)
}
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
/// decode with dimension/allocation limits; invalid pictures are cache misses.
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
}
/// Read a portable picture without importing or reading the source blobs.
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
let source = {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
let source = std::fs::File::open(path).ok()?;
let metadata = source.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
source
};
#[cfg(target_arch = "wasm32")]
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
DesignContainerReader::new(source).ok()?.thumbnail()
}
+4 -3
View File
@@ -10,9 +10,10 @@ mod design;
mod design_metadata;
mod dxf;
pub use design::{
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, is_design_container, read_design_file, read_design_geometry,
read_design_preview, write_design_container, write_design_container_with_cache, write_design_container_with_compression,
write_design_container_with_preview,
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
write_design_container_with_thumbnail,
};
mod legacy_dir;
pub mod safe_write;