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:
1 parent
24974e6f37
commit
bcbfa50eac
10 files changed
+431
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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, ¶ms).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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user