atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-59: auto-generate preview images from the renderer

Orual (Jun 29, 2026, 7:06 PM EDT) 916a0344 9b195aeb

+572 -47
+1 -2
Cargo.toml
··· 49 49 "dep:axum-extra", 50 50 51 51 "dep:keyring", 52 - "dep:base64", 53 52 "dep:sha2", 54 53 "dep:zip", 55 54 # PM-43: server-side decode of uploaded image dimensions (for #image ··· 116 115 axum-extra = { version = "0.10", optional = true, features = ["cookie"] } 117 116 118 117 keyring = { version = "3", optional = true } 119 - base64 = { version = "0.22", optional = true } 118 + base64 = { version = "0.22" } 120 119 sha2 = { version = "0.10", optional = true } 121 120 zip = { version = "2", default-features = false, optional = true } 122 121 # PM-43: header-only image dimension probe (no full decode) for #image
+2 -1
src/main.rs
··· 12 12 mod fonts; 13 13 mod foundation; 14 14 mod mesh; 15 + mod preview_capture; 15 16 mod profile; 16 17 mod publish; 17 18 mod search; ··· 66 67 let _ = tracing_subscriber::fmt() 67 68 .with_env_filter( 68 69 tracing_subscriber::EnvFilter::try_from_default_env() 69 - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,hydrant=warn")), 70 + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug,hydrant=warn")), 70 71 ) 71 72 .try_init(); 72 73 // The closure runs inside the server's Tokio runtime at startup, so the
+213
src/preview_capture.rs
··· 1 + //! Offscreen WebGL2 capture for publish-wizard preview images. 2 + //! 3 + //! `PreviewCapture` owns a single offscreen `<canvas>` + `three_d::Context` + 4 + //! standard light rig, lazily created once per wizard session. `render_png` 5 + //! renders one frame and reads it back as PNG bytes via `toDataURL` + `atob`. 6 + //! 7 + //! The wasm-only implementation is gated behind 8 + //! `all(target_family = "wasm", target_os = "unknown")`; a no-op stub on other 9 + //! targets keeps the type resolvable for `Wizard`'s signal field. 10 + 11 + use crate::mesh::contract::ModelMesh; 12 + 13 + /// Errors that can occur constructing or rendering with a [`PreviewCapture`]. 14 + #[derive(Debug, thiserror::Error)] 15 + #[allow(dead_code)] 16 + pub enum PreviewCaptureError { 17 + #[error("preview capture is not supported on this target")] 18 + Unsupported, 19 + #[error("browser window is unavailable")] 20 + WindowUnavailable, 21 + #[error("document is unavailable")] 22 + DocumentUnavailable, 23 + #[error("could not create an offscreen canvas element")] 24 + CanvasCreation, 25 + #[error("WebGL2 context is unavailable")] 26 + WebGL2Unavailable, 27 + #[error("could not acquire a WebGL2 context: {0}")] 28 + Context(String), 29 + #[error("PNG readback failed: {0}")] 30 + Readback(String), 31 + } 32 + 33 + // --------------------------------------------------------------------------- 34 + // wasm target — real implementation 35 + // --------------------------------------------------------------------------- 36 + 37 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 38 + mod wasm { 39 + use std::sync::Arc; 40 + 41 + use three_d::context::Context as GlowContext; 42 + use wasm_bindgen::JsCast; 43 + 44 + use super::PreviewCaptureError; 45 + use crate::mesh::contract::ModelMesh; 46 + use crate::viewer::{build_scene_with_viewport, standard_lights}; 47 + 48 + pub(super) struct PreviewCaptureInner { 49 + canvas: web_sys::HtmlCanvasElement, 50 + context: three_d::Context, 51 + ambient_light: three_d::AmbientLight, 52 + key_light: three_d::DirectionalLight, 53 + fill_light: three_d::DirectionalLight, 54 + } 55 + 56 + impl PreviewCaptureInner { 57 + pub(super) fn new() -> Result<Self, PreviewCaptureError> { 58 + let window = web_sys::window().ok_or(PreviewCaptureError::WindowUnavailable)?; 59 + let document = window 60 + .document() 61 + .ok_or(PreviewCaptureError::DocumentUnavailable)?; 62 + let canvas = document 63 + .create_element("canvas") 64 + .map_err(|_| PreviewCaptureError::CanvasCreation)? 65 + .dyn_into::<web_sys::HtmlCanvasElement>() 66 + .map_err(|_| PreviewCaptureError::CanvasCreation)?; 67 + 68 + // `preserveDrawingBuffer: true` is REQUIRED for `toDataURL` to read 69 + // back a non-blank frame. The viewer's plain `get_context("webgl2")` 70 + // has no preserve flag and would yield a blank readback. 71 + let opts = js_sys::Object::new(); 72 + js_sys::Reflect::set(&opts, &"preserveDrawingBuffer".into(), &true.into()) 73 + .map_err(|e| PreviewCaptureError::Context(format!("{e:?}")))?; 74 + let gl = canvas 75 + .get_context_with_context_options("webgl2", &opts) 76 + .map_err(|_| PreviewCaptureError::WebGL2Unavailable)? 77 + .ok_or(PreviewCaptureError::WebGL2Unavailable)? 78 + .dyn_into::<web_sys::WebGl2RenderingContext>() 79 + .map_err(|_| PreviewCaptureError::WebGL2Unavailable)?; 80 + 81 + let glow_context = GlowContext::from_webgl2_context(gl); 82 + #[allow( 83 + clippy::arc_with_non_send_sync, 84 + reason = "three-d requires Arc<glow::Context> for browser WebGL context construction" 85 + )] 86 + let context = three_d::Context::from_gl_context(Arc::new(glow_context)) 87 + .map_err(|e| PreviewCaptureError::Context(format!("{e:?}")))?; 88 + 89 + let (ambient_light, key_light, fill_light) = standard_lights(&context); 90 + 91 + Ok(Self { 92 + canvas, 93 + context, 94 + ambient_light, 95 + key_light, 96 + fill_light, 97 + }) 98 + } 99 + 100 + pub(super) fn render_png( 101 + &self, 102 + model: &ModelMesh, 103 + width: u32, 104 + height: u32, 105 + ) -> Result<Vec<u8>, PreviewCaptureError> { 106 + self.canvas.set_width(width); 107 + self.canvas.set_height(height); 108 + 109 + let (mesh, camera, _control) = build_scene_with_viewport( 110 + &self.context, 111 + model, 112 + three_d::Viewport::new_at_origo(width, height), 113 + ); 114 + 115 + let screen = three_d::RenderTarget::screen(&self.context, width, height); 116 + let _ = screen 117 + .clear(three_d::ClearState::color_and_depth( 118 + 0.035, 0.043, 0.067, 1.0, 1.0, 119 + )) 120 + .render( 121 + &camera, 122 + [&mesh], 123 + &[&self.ambient_light, &self.key_light, &self.fill_light], 124 + ); 125 + 126 + // Primary readback: toDataURL → base64 → atob → bytes. 127 + // `atob` returns a Latin-1 binary string (code units 0x00–0xFF); each 128 + // Rust `char` is exactly one byte. Iterating `.bytes()` would 129 + // UTF-8-expand bytes ≥0x80 and silently corrupt the PNG. 130 + let data_url = self 131 + .canvas 132 + .to_data_url_with_type("image/png") 133 + .map_err(|e| PreviewCaptureError::Readback(format!("toDataURL failed: {e:?}")))?; 134 + let b64 = data_url.split_once(',').map(|(_, b)| b).ok_or_else(|| { 135 + PreviewCaptureError::Readback("missing data URL delimiter".into()) 136 + })?; 137 + let window = web_sys::window().ok_or(PreviewCaptureError::WindowUnavailable)?; 138 + let binary_string = window 139 + .atob(b64) 140 + .map_err(|e| PreviewCaptureError::Readback(format!("atob failed: {e:?}")))?; 141 + Ok(binary_string.chars().map(|c| c as u8).collect()) 142 + } 143 + } 144 + } 145 + 146 + // --------------------------------------------------------------------------- 147 + // non-wasm target — stub 148 + // --------------------------------------------------------------------------- 149 + 150 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 151 + #[allow(dead_code)] 152 + pub(super) struct PreviewCaptureInner; 153 + 154 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 155 + #[allow(dead_code)] 156 + impl PreviewCaptureInner { 157 + pub(super) fn new() -> Result<Self, PreviewCaptureError> { 158 + Err(PreviewCaptureError::Unsupported) 159 + } 160 + 161 + pub(super) fn render_png( 162 + &self, 163 + _model: &ModelMesh, 164 + _width: u32, 165 + _height: u32, 166 + ) -> Result<Vec<u8>, PreviewCaptureError> { 167 + Err(PreviewCaptureError::Unsupported) 168 + } 169 + } 170 + 171 + // --------------------------------------------------------------------------- 172 + // Public cfg-agnostic facade 173 + // --------------------------------------------------------------------------- 174 + 175 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 176 + use wasm::PreviewCaptureInner; 177 + 178 + /// An offscreen WebGL2 capture surface for rendering preview images. 179 + /// 180 + /// On wasm this owns a real `<canvas>` + `three_d::Context`; on every other 181 + /// target it is a no-op stub whose `new()` always returns `Err(Unsupported)`. 182 + /// The wasm inner is wrapped in `Arc` so cloning is cheap without requiring 183 + /// the GL resources themselves to be `Clone`. 184 + #[derive(Clone)] 185 + pub struct PreviewCapture { 186 + #[allow(dead_code)] 187 + inner: std::sync::Arc<PreviewCaptureInner>, 188 + } 189 + 190 + #[allow(dead_code)] // stub methods are unused on non-wasm targets 191 + impl PreviewCapture { 192 + /// Create a capture surface. On non-wasm targets this always fails with 193 + /// [`PreviewCaptureError::Unsupported`]. 194 + #[allow( 195 + clippy::arc_with_non_send_sync, 196 + reason = "three-d requires Arc<glow::Context> for browser WebGL context construction" 197 + )] 198 + pub fn new() -> Result<Self, PreviewCaptureError> { 199 + Ok(Self { 200 + inner: std::sync::Arc::new(PreviewCaptureInner::new()?), 201 + }) 202 + } 203 + 204 + /// Render `model` to a PNG byte vector at the given dimensions. 205 + pub fn render_png( 206 + &self, 207 + model: &ModelMesh, 208 + width: u32, 209 + height: u32, 210 + ) -> Result<Vec<u8>, PreviewCaptureError> { 211 + self.inner.render_png(model, width, height) 212 + } 213 + }
+287 -5
src/publish.rs
··· 1 1 use dioxus::html::FileData; 2 + use std::collections::HashMap; 3 + 2 4 use dioxus::prelude::*; 3 5 4 6 use crate::Route; 5 7 use crate::client::PolymodelClient; 8 + use crate::preview_capture::PreviewCapture; 6 9 use crate::session::SessionIdentity; 7 10 use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput}; 11 + use jacquard_common::deps::smol_str::SmolStr; 8 12 use polymodel_api::space_polymodel::library::Image; 9 13 use state::{PublishPhase, WizardStep}; 10 14 ··· 30 34 fn image_cdn_url(did: &str, image: &Image) -> String { 31 35 let cid = image.image.blob().r#ref.as_str(); 32 36 format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") 37 + } 38 + 39 + /// Build a `data:` URI from raw bytes for immediate display before CDN 40 + /// replication completes. Uses base64 encoding. 41 + fn data_uri_from_bytes(bytes: &[u8], mime: &str) -> SmolStr { 42 + use base64::Engine; 43 + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); 44 + format!("data:{mime};base64,{encoded}").into() 33 45 } 34 46 35 47 /// Transient state of a single in-flight raw upload (file or image). ··· 118 130 drafts: Signal<Vec<DraftSummary>>, 119 131 /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. 120 132 did: Option<String>, 133 + /// Lazily-created offscreen WebGL2 capture surface for auto-rendered 134 + /// preview images. `None` on non-wasm targets (stub no-ops). 135 + preview_capture: Signal<Option<PreviewCapture>>, 136 + /// Transient data-URI overrides for freshly-uploaded images, keyed by 137 + /// blobref CID. CDN URLs for just-uploaded blobs aren't available until 138 + /// replication completes; this shows the image immediately. 139 + image_data_uris: Signal<HashMap<SmolStr, SmolStr>>, 121 140 } 122 141 123 142 #[component] ··· 141 160 let upload = use_signal(|| UploadStatus::Idle); 142 161 let image_upload = use_signal(|| UploadStatus::Idle); 143 162 let drafts = use_signal(Vec::<DraftSummary>::new); 163 + let preview_capture = use_signal(|| None); 164 + let image_data_uris = use_signal(HashMap::<SmolStr, SmolStr>::new); 144 165 145 166 let wizard = Wizard { 146 167 client, ··· 153 174 image_upload, 154 175 drafts, 155 176 did, 177 + preview_capture, 178 + image_data_uris, 156 179 }; 157 180 158 181 // Resume a draft once on mount when `?draft=<id>` is present; always load ··· 664 687 .content_type() 665 688 .filter(|s| !s.is_empty()) 666 689 .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 667 - match draft_client::stage_file(&self.client, &filename, &mime, bytes).await { 690 + // Clone before staging: `stage_file` takes `bytes` by value, but we 691 + // need the original bytes for preview rendering. For large STL this 692 + // briefly holds two copies — acceptable for v1. 693 + match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { 668 694 Ok(staged) => { 669 695 let format = format_from_filename(&filename); 670 - let part = DraftPartInput { 696 + let mut part = DraftPartInput { 671 697 name: Some(filename.clone()), 672 698 upload_id: Some(staged.upload_id.to_string()), 673 699 file: Some(staged.file), 674 - format, 700 + format: format.clone(), 675 701 ..Default::default() 676 702 }; 703 + // Best-effort preview generation (wasm-only; no-op off-wasm). 704 + // v1 supports STL only — skip preview for other formats. 705 + let preview = if format.as_deref() == Some("stl") { 706 + let client = self.client.clone(); 707 + let upload_sig = self.upload; 708 + let preview_capture_sig = self.preview_capture; 709 + let data_uris_sig = self.image_data_uris; 710 + generate_preview( 711 + client, 712 + filename.clone(), 713 + bytes, 714 + upload_sig, 715 + preview_capture_sig, 716 + data_uris_sig, 717 + ) 718 + .await 719 + } else { 720 + None 721 + }; 677 722 { 678 723 let mut composition = self.composition; 679 724 let mut guard = composition.write(); 725 + if let Some(image) = preview { 726 + let parts_before_push = 727 + guard.models.get(model_index).map_or(0, |m| m.parts.len()); 728 + maybe_seed_cover(&mut guard, model_index, parts_before_push, &image); 729 + attach_preview(&mut part, image); 730 + } 680 731 if let Some(model) = guard.models.get_mut(model_index) { 681 732 model.parts.push(part); 682 733 } ··· 761 812 { 762 813 let alt = image.alt.to_string(); 763 814 let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); 764 - let preview_url = self.did.as_ref().map(|did| image_cdn_url(did, image)); 815 + let cid = image.image.blob().r#ref.as_str().to_string(); 816 + let preview_url = self.image_data_uris.read().get(cid.as_str()) 817 + .map(|uri| uri.to_string()) 818 + .or_else(|| self.did.as_ref().map(|did| image_cdn_url(did, image))); 765 819 let alt_w = self.clone(); 766 820 let remove_w = self.clone(); 767 821 rsx! { ··· 835 889 .content_type() 836 890 .filter(|s| !s.is_empty()) 837 891 .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 838 - match draft_client::upload_image(&self.client, &filename, &mime, bytes).await { 892 + match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await { 839 893 Ok(image) => { 840 894 { 895 + let cid = image.image.blob().r#ref.as_str().to_string(); 896 + let data_uri = data_uri_from_bytes(&bytes, &mime); 897 + let mut data_uris = self.image_data_uris; 898 + data_uris.write().insert(cid.into(), data_uri); 841 899 let mut composition = self.composition; 842 900 let mut guard = composition.write(); 843 901 let list = if is_cover { ··· 1160 1218 } 1161 1219 } 1162 1220 1221 + /// Attach a preview image to a draft part. v1 sets (overwrites) rather than 1222 + /// appends: each file stage creates a fresh `DraftPartInput`, so this is the 1223 + /// single auto-preview for that part. 1224 + fn attach_preview(part: &mut DraftPartInput, image: Image) { 1225 + part.previews = Some(vec![image]); 1226 + } 1227 + 1228 + /// Seed the thing-level cover from the first file of the first model when no 1229 + /// cover exists. Only fires for `model_index == 0` when the model has zero 1230 + /// parts *before* pushing the new one (the first file overall) and cover is 1231 + /// empty. Non-empty cover is never overwritten. 1232 + fn maybe_seed_cover( 1233 + composition: &mut DraftThingInput, 1234 + model_index: usize, 1235 + parts_before_push: usize, 1236 + image: &Image, 1237 + ) { 1238 + if model_index == 0 1239 + && parts_before_push == 0 1240 + && composition.cover.as_ref().is_none_or(Vec::is_empty) 1241 + { 1242 + composition.cover = Some(vec![image.clone()]); 1243 + } 1244 + } 1245 + 1246 + /// Render a PNG preview from staged file bytes and upload it. Wasm-only: on 1247 + /// non-wasm targets this is a no-op stub returning `None`. 1248 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 1249 + async fn generate_preview( 1250 + client: PolymodelClient, 1251 + filename: String, 1252 + bytes: Vec<u8>, 1253 + mut upload: Signal<UploadStatus>, 1254 + preview_capture: Signal<Option<PreviewCapture>>, 1255 + mut image_data_uris: Signal<HashMap<SmolStr, SmolStr>>, 1256 + ) -> Option<Image> { 1257 + // v1 supports STL only; other formats are skipped cleanly. 1258 + let model = crate::mesh::stl::load_stl(&bytes).ok()?; 1259 + 1260 + // Lazily get-or-create the capture surface. 1261 + let capture = { 1262 + let mut pc = preview_capture; 1263 + if pc.read().is_none() { 1264 + match PreviewCapture::new() { 1265 + Ok(c) => pc.set(Some(c)), 1266 + Err(err) => { 1267 + tracing::warn!(%err, "preview capture unavailable"); 1268 + return None; 1269 + } 1270 + } 1271 + } 1272 + pc.read().as_ref().unwrap().clone() 1273 + }; 1274 + 1275 + let png_bytes = match capture.render_png(&model, 1024, 1024) { 1276 + Ok(bytes) => bytes, 1277 + Err(err) => { 1278 + tracing::warn!(%err, "preview render failed for {filename}"); 1279 + return None; 1280 + } 1281 + }; 1282 + 1283 + let alt = format!("Rendered preview of {filename}"); 1284 + upload.set(UploadStatus::Uploading(format!("preview of {filename}"))); 1285 + match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await { 1286 + Ok(image) => { 1287 + let cid = image.image.blob().r#ref.as_str().to_string(); 1288 + let data_uri = data_uri_from_bytes(&png_bytes, "image/png"); 1289 + image_data_uris.write().insert(cid.into(), data_uri); 1290 + Some(image) 1291 + } 1292 + Err(err) => { 1293 + tracing::warn!(%err, "preview upload failed for {filename}"); 1294 + upload.set(UploadStatus::Idle); 1295 + None 1296 + } 1297 + } 1298 + } 1299 + 1300 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 1301 + async fn generate_preview( 1302 + _client: PolymodelClient, 1303 + _filename: String, 1304 + _bytes: Vec<u8>, 1305 + _upload: Signal<UploadStatus>, 1306 + _preview_capture: Signal<Option<PreviewCapture>>, 1307 + _image_data_uris: Signal<HashMap<SmolStr, SmolStr>>, 1308 + ) -> Option<Image> { 1309 + None 1310 + } 1311 + 1163 1312 #[cfg(test)] 1164 1313 mod tests { 1165 1314 use super::*; 1315 + use std::str::FromStr; 1316 + 1317 + fn alt_str(image: &Image) -> &str { 1318 + <jacquard_common::SmolStr as AsRef<str>>::as_ref(&image.alt) 1319 + } 1166 1320 1167 1321 #[test] 1168 1322 fn mime_and_format_from_extension() { ··· 1178 1332 assert_eq!(human_size(512), "512 B"); 1179 1333 assert_eq!(human_size(2048), "2.0 KB"); 1180 1334 assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); 1335 + } 1336 + 1337 + fn test_image(alt: &str) -> Image { 1338 + use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; 1339 + use jacquard_common::types::cid::CidLink; 1340 + use polymodel_api::space_polymodel::library::AspectRatio; 1341 + Image { 1342 + alt: alt.into(), 1343 + aspect_ratio: AspectRatio { 1344 + height: 1024, 1345 + width: 1024, 1346 + extra_data: None, 1347 + }, 1348 + image: BlobRef::Blob(Blob { 1349 + r#ref: CidLink::from_str( 1350 + "bafyreigqdh7sjw7w6d7bk6f4x4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4", 1351 + ) 1352 + .unwrap(), 1353 + mime_type: MimeType::new("image/png".into()), 1354 + size: 42, 1355 + }), 1356 + extra_data: None, 1357 + } 1358 + } 1359 + 1360 + #[test] 1361 + fn attach_preview_sets_previews_when_empty() { 1362 + let mut part = DraftPartInput { 1363 + name: Some("Bracket".into()), 1364 + upload_id: Some("upload-1".into()), 1365 + ..Default::default() 1366 + }; 1367 + assert!(part.previews.is_none()); 1368 + attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); 1369 + assert_eq!(part.previews.as_ref().unwrap().len(), 1); 1370 + assert_eq!( 1371 + alt_str(&part.previews.as_ref().unwrap()[0]), 1372 + "Rendered preview of Bracket.stl" 1373 + ); 1374 + } 1375 + 1376 + #[test] 1377 + fn attach_preview_overwrites_existing_previews() { 1378 + let mut part = DraftPartInput { 1379 + name: Some("Bracket".into()), 1380 + previews: Some(vec![test_image("old user-uploaded preview")]), 1381 + ..Default::default() 1382 + }; 1383 + assert_eq!(part.previews.as_ref().unwrap().len(), 1); 1384 + attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); 1385 + assert_eq!(part.previews.as_ref().unwrap().len(), 1); 1386 + assert_eq!( 1387 + alt_str(&part.previews.as_ref().unwrap()[0]), 1388 + "Rendered preview of Bracket.stl" 1389 + ); 1390 + } 1391 + 1392 + #[test] 1393 + fn maybe_seed_cover_seeds_first_file_first_model() { 1394 + let mut composition = DraftThingInput::default(); 1395 + let image = test_image("Rendered preview of Tray.stl"); 1396 + maybe_seed_cover(&mut composition, 0, 0, &image); 1397 + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); 1398 + assert_eq!( 1399 + alt_str(&composition.cover.as_ref().unwrap()[0]), 1400 + "Rendered preview of Tray.stl" 1401 + ); 1402 + } 1403 + 1404 + #[test] 1405 + fn maybe_seed_cover_does_not_seed_for_nonzero_model() { 1406 + let mut composition = DraftThingInput::default(); 1407 + let image = test_image("Rendered preview of Lid.stl"); 1408 + maybe_seed_cover(&mut composition, 1, 0, &image); 1409 + assert!(composition.cover.is_none()); 1410 + } 1411 + 1412 + #[test] 1413 + fn maybe_seed_cover_does_not_seed_when_parts_already_exist() { 1414 + let mut composition = DraftThingInput::default(); 1415 + let image = test_image("Rendered preview of second.stl"); 1416 + maybe_seed_cover(&mut composition, 0, 1, &image); 1417 + assert!(composition.cover.is_none()); 1418 + } 1419 + 1420 + #[test] 1421 + fn maybe_seed_cover_does_not_overwrite_nonempty_cover() { 1422 + let mut composition = DraftThingInput { 1423 + cover: Some(vec![test_image("user cover")]), 1424 + ..Default::default() 1425 + }; 1426 + let image = test_image("Rendered preview of Tray.stl"); 1427 + maybe_seed_cover(&mut composition, 0, 0, &image); 1428 + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); 1429 + assert_eq!( 1430 + alt_str(&composition.cover.as_ref().unwrap()[0]), 1431 + "user cover" 1432 + ); 1433 + } 1434 + 1435 + #[test] 1436 + fn maybe_seed_cover_re_stage_after_delete_does_not_seed_over_user_cover() { 1437 + // Edge: model-0 parts deleted back to zero, but a user manually set a 1438 + // cover. The cover must NOT be overwritten by a new auto-preview. 1439 + let mut composition = DraftThingInput { 1440 + cover: Some(vec![test_image("user cover")]), 1441 + ..Default::default() 1442 + }; 1443 + let image = test_image("Rendered preview of re-staged.stl"); 1444 + maybe_seed_cover(&mut composition, 0, 0, &image); 1445 + assert_eq!( 1446 + alt_str(&composition.cover.as_ref().unwrap()[0]), 1447 + "user cover" 1448 + ); 1449 + } 1450 + 1451 + #[test] 1452 + fn maybe_seed_cover_re_stage_after_delete_seeds_when_both_empty() { 1453 + // Edge: model-0 parts deleted back to zero AND cover is empty. A new 1454 + // first file should re-seed the cover. 1455 + let mut composition = DraftThingInput::default(); 1456 + let image = test_image("Rendered preview of fresh.stl"); 1457 + maybe_seed_cover(&mut composition, 0, 0, &image); 1458 + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); 1459 + assert_eq!( 1460 + alt_str(&composition.cover.as_ref().unwrap()[0]), 1461 + "Rendered preview of fresh.stl" 1462 + ); 1181 1463 } 1182 1464 }
+69 -39
src/viewer.rs
··· 1022 1022 first_render_logged: bool, 1023 1023 } 1024 1024 1025 + /// Viewport-parameterized scene construction shared by the viewer and offscreen 1026 + /// preview capture. The `viewport` dims MUST equal the backing-store dims of the 1027 + /// canvas the caller intends to render into (it does NOT call `canvas_viewport` 1028 + /// or apply `devicePixelRatio` — the caller owns canvas sizing). 1029 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 1030 + pub(crate) fn build_scene_with_viewport( 1031 + context: &three_d::Context, 1032 + model: &ModelMesh, 1033 + viewport: three_d::Viewport, 1034 + ) -> ( 1035 + three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>, 1036 + three_d::Camera, 1037 + three_d::OrbitControl, 1038 + ) { 1039 + let fit = model.camera_fit(); 1040 + let mesh = three_d::Gm::new( 1041 + three_d::Mesh::new(context, &model.trimesh), 1042 + three_d::PhysicalMaterial { 1043 + albedo: three_d::Srgba::new_opaque(116, 180, 255), 1044 + roughness: 0.72, 1045 + metallic: 0.0, 1046 + ..Default::default() 1047 + }, 1048 + ); 1049 + let radius = fit.radius.max(1.0); 1050 + let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); 1051 + let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); 1052 + let camera = three_d::Camera::new_perspective( 1053 + viewport, 1054 + position, 1055 + target, 1056 + three_d::vec3(0.0, 0.0, 1.0), 1057 + three_d::degrees(45.0), 1058 + (radius / 100.0).max(0.1), 1059 + radius * 20.0, 1060 + ); 1061 + let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); 1062 + (mesh, camera, control) 1063 + } 1064 + 1065 + /// Standard three-light rig shared by the viewer and preview capture so both 1066 + /// produce identical lighting. 1067 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 1068 + pub(crate) fn standard_lights( 1069 + context: &three_d::Context, 1070 + ) -> ( 1071 + three_d::AmbientLight, 1072 + three_d::DirectionalLight, 1073 + three_d::DirectionalLight, 1074 + ) { 1075 + let ambient_light = 1076 + three_d::AmbientLight::new(context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); 1077 + let key_light = three_d::DirectionalLight::new( 1078 + context, 1079 + 2.6, 1080 + three_d::Srgba::new_opaque(255, 255, 255), 1081 + three_d::vec3(-0.45, -0.55, -0.70), 1082 + ); 1083 + let fill_light = three_d::DirectionalLight::new( 1084 + context, 1085 + 0.9, 1086 + three_d::Srgba::new_opaque(170, 205, 255), 1087 + three_d::vec3(0.55, 0.35, -0.35), 1088 + ); 1089 + (ambient_light, key_light, fill_light) 1090 + } 1091 + 1025 1092 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 1026 1093 impl ThreeDRenderer { 1027 1094 /// Build the GPU mesh + camera framing for `model` against an existing ··· 1035 1102 three_d::Camera, 1036 1103 three_d::OrbitControl, 1037 1104 ) { 1038 - let fit = model.camera_fit(); 1039 - let mesh = three_d::Gm::new( 1040 - three_d::Mesh::new(context, &model.trimesh), 1041 - three_d::PhysicalMaterial { 1042 - albedo: three_d::Srgba::new_opaque(116, 180, 255), 1043 - roughness: 0.72, 1044 - metallic: 0.0, 1045 - ..Default::default() 1046 - }, 1047 - ); 1048 - let radius = fit.radius.max(1.0); 1049 - let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); 1050 - let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); 1051 - let viewport = canvas_viewport(canvas); 1052 - let camera = three_d::Camera::new_perspective( 1053 - viewport, 1054 - position, 1055 - target, 1056 - three_d::vec3(0.0, 0.0, 1.0), 1057 - three_d::degrees(45.0), 1058 - (radius / 100.0).max(0.1), 1059 - radius * 20.0, 1060 - ); 1061 - let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); 1062 - (mesh, camera, control) 1105 + build_scene_with_viewport(context, model, canvas_viewport(canvas)) 1063 1106 } 1064 1107 1065 1108 fn resize_and_render(&mut self) { ··· 1169 1212 tracing::info!(backend = "WebGL2", "three-d WebGL2 context acquired"); 1170 1213 1171 1214 let (mesh, camera, control) = Self::build_scene(&context, &canvas, model); 1172 - let ambient_light = 1173 - three_d::AmbientLight::new(&context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); 1174 - let key_light = three_d::DirectionalLight::new( 1175 - &context, 1176 - 2.6, 1177 - three_d::Srgba::new_opaque(255, 255, 255), 1178 - three_d::vec3(-0.45, -0.55, -0.70), 1179 - ); 1180 - let fill_light = three_d::DirectionalLight::new( 1181 - &context, 1182 - 0.9, 1183 - three_d::Srgba::new_opaque(170, 205, 255), 1184 - three_d::vec3(0.55, 0.35, -0.35), 1185 - ); 1215 + let (ambient_light, key_light, fill_light) = standard_lights(&context); 1186 1216 let frame_started_ms = window 1187 1217 .performance() 1188 1218 .map(|performance| performance.now())