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

+4
View File
@@ -389,6 +389,10 @@ and reference-object placement remain separate work, as do loft rails and center
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
+6 -1
View File
@@ -1,5 +1,8 @@
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
#[path = "file_thumbnail.rs"]
mod thumbnail;
use serde_json::{Value, json};
use solidcraft_doc::Document;
use solidcraft_io::Format;
@@ -205,7 +208,8 @@ impl Session {
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
let geometry = cache.then(|| saved_geometry(self)).flatten();
let preview = preview.then(|| saved_preview(self)).flatten();
solidcraft_io::write_design_container_with_cache(&self.design(), compression, preview.as_ref(), geometry.as_deref())
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
solidcraft_io::write_design_container_with_thumbnail(&self.design(), compression, preview.as_ref(), geometry.as_deref(), thumbnail.as_deref())
}
}
@@ -507,6 +511,7 @@ mod tests {
session.execute(command, &params).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert!(
!bytes.windows(b"cache/preview.bin".len()).any(|entry| entry == b"cache/preview.bin"),
"display meshes require preview: true"
+150
View File
@@ -0,0 +1,150 @@
//! A portable picture from warm display meshes only; never evaluate on save.
use crate::Session;
use solidcraft_geom::Aabb3;
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 1024 {
return None;
}
let mut scene = Scene::default();
let mut bounds = Aabb3::EMPTY;
let mut triangles = 0usize;
let mut points = 0usize;
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
let mesh = body.cached_mesh()?;
triangles = triangles.saturating_add(mesh.triangles.len());
points = points.saturating_add(mesh.positions.len());
if triangles > 100_000 || points > 100_000 {
return None;
}
bounds = bounds.union(&mesh.bounds());
let channel = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8;
let color = s.doc.body_color(body).map_or(crate::view::colors::BODY, |[r, g, b]| Rgb(channel(r), channel(g), channel(b)));
let face_colors =
s.doc.face_colors(body).into_iter().map(|(face, look)| (face as u32, Rgb(look.color[0], look.color[1], look.color[2]))).collect();
scene.meshes.push(SceneMesh { mesh, color, face_colors });
}
if bounds.is_empty() {
return None;
}
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
camera.distance *= 0.7;
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
if !within_raster_budget(&scene, &camera, width, height) {
return None;
}
let image = solidcraft_render::render(&scene, &camera, width, height);
solidcraft_render::png(width, height, image.rgba)
}
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
// 2x supersampling; conservatively count each projected triangle's pixel box.
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
let mut pixels = 0usize;
let mut colors = 0usize;
for entry in &scene.meshes {
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
if colors > 1_000_000 {
return false;
}
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
for point in &entry.mesh.positions {
let q = matrix.apply(*point);
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
return false;
}
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
if pixel.iter().any(|x| !x.is_finite()) {
return false;
}
projected.push(pixel);
}
for triangle in &entry.mesh.triangles {
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
return false;
};
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
pixels = pixels.saturating_add(dx.saturating_mul(dy));
if pixels > 4_000_000 {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn portable_thumbnail_refuses_excessive_overdraw() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let mesh = session.world_state().bodies[0].mesh();
let bounds = mesh.bounds();
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
assert!(within_raster_budget(&scene, &camera, 240, 150));
let mut repeated = (*mesh).clone();
repeated.triangles = vec![repeated.triangles[0]; 100_000];
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
assert!(!within_raster_budget(&scene, &camera, 240, 150));
}
#[test]
fn portable_thumbnail_preserves_saved_visibility() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
session.execute("browser.visibility", &serde_json::json!({"items":["Hidden"],"visible":false})).unwrap();
let world = session.world_state();
for body in &world.bodies {
body.mesh();
}
let expected = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let doc = solidcraft_io::read_design(&bytes).unwrap();
assert_eq!(doc.visibility.hidden_bodies, vec!["Hidden"]);
assert_eq!(doc, *session.design());
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(expected));
let reopened = Session::new(doc);
assert_eq!(reopened.visibility, session.visibility);
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert_eq!(solidcraft_io::read_design(&disabled).unwrap().visibility, session.visibility);
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
#[test]
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
assert!(saved(&session).is_none());
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
for body in &world.bodies {
body.mesh();
}
let png = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(png));
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
}
+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;
+39 -1
View File
@@ -13,7 +13,7 @@ use crate::theme::Tokens;
/// Recent designs kept.
pub const MAX_RECENT: usize = 12;
const THUMB: [usize; 2] = [240, 150];
const THUMB: [usize; 2] = solidcraft_engine::io::THUMBNAIL_SIZE;
/// 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).
@@ -96,6 +96,13 @@ fn cached(key: &str) -> Option<egui::ColorImage> {
return None;
}
let bytes = std::fs::read(path).ok()?;
decode_thumbnail(bytes)
}
fn decode_thumbnail(bytes: Vec<u8>) -> Option<egui::ColorImage> {
if bytes.len() > 512 * 1024 {
return None;
}
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(THUMB[0] as u32);
@@ -171,6 +178,11 @@ fn make(key: &str) -> Made {
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)
{
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()) {
Some(i) => solidcraft_engine::sample::samples().get(i).is_some_and(|smp| {
@@ -575,6 +587,32 @@ mod tests {
std::fs::remove_file(png).unwrap();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn portable_thumbnail_loads_without_a_local_picture_or_model_rebuild() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
for body in &session.world_state().bodies {
body.mesh();
}
let bytes = session.design_container(solidcraft_engine::io::DesignCompression::Fast, false, true).unwrap();
let png = solidcraft_engine::io::read_design_thumbnail(&bytes).unwrap();
let image = decode_thumbnail(png).unwrap();
assert_eq!(image.size, THUMB);
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("solidcraft-portable-thumb-{}-{nonce}.solidcraft", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let key = path.to_string_lossy().to_string();
assert!(cached(&key).is_none());
let (_, picture, rebuilt) = make(&key);
assert!(rebuilt.is_none());
assert_eq!(picture.unwrap().pixels, image.pixels);
assert!(!cache_path(&key).unwrap().exists(), "fallback rebuilding would create a local picture");
std::fs::remove_file(path).unwrap();
assert!(decode_thumbnail(vec![0; 600_000]).is_none());
assert!(decode_thumbnail(vec![0; 24]).is_none());
}
#[test]
fn recent_list_is_newest_first_and_bounded() {
let mut h = HomeState::default();
+4
View File
@@ -52,6 +52,10 @@ cache with `cache: true` on warm saves; saving never rebuilds cold geometry just
create one. Ordinary saves and browser downloads default to `cache: false`. An additional display-mesh cache remains opt-in
with `preview: true`. Unsupported or mismatched caches are ignored. Browser opening
still rebuilds synchronously.
Warm saves with explicit `cache: true` can also include a small PNG thumbnail. It travels with the file, so
Home can show the recent design without reconstructing it when no local picture
is available. Missing or incompatible pictures use the usual thumbnail generation.
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 16, including physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
+31
View File
@@ -185,3 +185,34 @@ existing preview remains opt-in. GPU tessellation is independent work. Continue
using format/application constants rather than source-tree hashing build scripts,
retain JSON Save As/MCP/script compatibility and the documented legacy shims, and
run standalone wasm validation on each published code revision.
## Portable thumbnail
A warm save with `cache: true` may add `cache/thumbnail.png` and its small
`cache/thumbnail.json` key. The picture is a 240 x 150 PNG, bounded to 256 KiB,
keyed by `solidcraft-thumbnail/1`, application version, exact document SHA-256 and
PNG SHA-256. It contains an isometric view of visible solid bodies with their
appearances. It is display-only and independent of the producing geometry kernel.
Saving uses only already placed bodies and cached meshes, at most 1,024 bodies,
100,000 triangles and 100,000 vertices. A preflight also caps projected pixel-box
work at four million supersampled pixels and face-color lookups at one million,
so overlapping triangles cannot make a small picture arbitrarily expensive.
It never evaluates or tessellates a cold
model for a picture. Active edits, pending moves, model errors, absent warm
geometry or exceeded limits omit the picture. Ordinary saves and browser downloads
default to `cache: false` and omit the PNG; `cache: true` explicitly opts in. Plain JSON
never contains the PNG. The same writer is used by desktop and browser saves.
The seekable thumbnail reader checks the small key, retained document metadata,
PNG size/header and hashes without resolving source blobs. The Home/Recent Files
reader tries its local picture cache, then this portable picture, then its existing
background reconstruction. PNG decoding also has dimension and allocation limits.
Missing, mismatched, truncated or malformed pictures are misses and never prevent
authoritative document opening. Embedding a PNG does not install an operating-system
file-browser thumbnail provider.
Desktop thumbnail reads seek within the file. The browser VFS supplies a bounded
in-memory buffer instead; it avoids geometry reconstruction but does not provide
range-based OPFS reads. IO checks the PNG header and hashes, while the consuming
UI performs the full bounded PNG decode.
+47
View File
@@ -536,3 +536,50 @@ eliminated. A live frame's held solve was already cheap: the seed places the str
returns at once. Of a live strip frame, the seed reads the constraints in about 0.15 ms, so a
per-gesture cache of that reading is not worth its invalidation (its signs depend on the
reference positions, which change every step).
## Portable thumbnail save cost (#303)
Portable thumbnail generation requires explicit `cache: true`. Ordinary saves and
browser downloads default to `cache: false`, so they incur none of the thumbnail
work or added bytes measured below.
Measured 2026-10-13 on the same Windows machine and `ci` profile as above, but
with **SolidCraft 0.7.0** on both sides: `d3d215b` before the thumbnail change and
thumbnail revision `e180cdc` after it. These measurements precede the integration
of upstream `9f88537`; later geometry changes are not included in these timings.
A preserved executable of the baseline
and the new executable ran the existing `benchmark_native_cache_save` on the same
STEP and sensor documents. Three paired batches alternated before/after order;
each batch performed nine alternating atomic saves per encoding. The values below
are medians of the three batch medians, not a percentile of all individual saves.
No compilation, test suite or desktop model was running during these measurements.
| 512-cube STEP encoding | Before PNG (ms) | After PNG (ms) | Container bytes before → after |
|---|---:|---:|---:|
| Plain JSON | 10.334 | 9.676 | 1,998,826 → 1,998,826 |
| Container, `cache: false` | 8.850 | 9.013 | 1,950,084 → 1,950,084 |
| Native cache, fast | 45.323 | 53.473 | 4,694,035 → 4,720,385 |
| Native cache, compact | 42.739 | 48.643 | 837,067 → 863,417 |
The PNG itself is 25,916 bytes; its key and ZIP records bring the increase to
26,350 bytes. Fast-cache batch medians ranged from 41.585–47.779 ms before and
53.212–56.556 ms after. Compact ranged from 36.672–43.040 ms before and
47.688–51.724 ms after. The approximately 8 ms fast-save increase is a measured
cost on this example, not a general latency bound. JSON bytes and document/source
blob bytes were compared across both implementations and remained identical.
The sensor enclosure deliberately receives **no portable PNG**: its projected
triangle boxes total 4,293,457 supersampled pixels, above the four-million work
budget. The native fast container remains 379,099 bytes. Its save batch medians
are 14.714 ms before (12.187–14.956) and 13.728 ms after (13.439–14.648); these
vary within the observed run ranges and do not establish a speedup. Home retains
its existing thumbnail-generation fallback for this case. A small triangle count
alone does not guarantee eligibility, because large overlapping faces cost more.
Actual desktop check: copy the STEP container to a new public temporary path,
start SolidCraft 0.7.0 with that path in Recent and an empty local thumbnail cache,
and show Home without opening the design. The portable picture was ready in one
observed 47.6 ms check; no local PNG was created and the active document remained
empty. The screenshot shows the transferred file's picture while built-in sample
pictures are still loading. This is one functional visual check, not a first-frame
benchmark. The separate counting-reader regression proves source-blob deferral.